package service

import (
	"bytes"
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"html"
	"io"
	"log"
	"net"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/pquerna/otp"
	"github.com/pquerna/otp/totp"
	"golang.org/x/crypto/argon2"

	"wucher/internal/config"
	"wucher/internal/domain/auth"
	"wucher/internal/queue"
	"wucher/internal/shared/pkg/timezone"
	"wucher/internal/shared/pkg/uuidv7"
)

var (
	ErrInvalidCredentials        = errors.New("invalid credentials")
	ErrEmailNotVerified          = errors.New("email not verified")
	ErrInvalidToken              = errors.New("invalid or expired token")
	ErrInvalidSSOState           = errors.New("invalid or expired sso state")
	ErrInvalidSSOReauth          = errors.New("invalid or expired sso reauth")
	ErrSSONotLinked              = errors.New("sso not linked")
	ErrSSOEmailNotFound          = errors.New("sso email not found")
	ErrSSOAlreadyLinked          = errors.New("sso already linked")
	ErrSSOLinkedToAnotherUser    = errors.New("sso linked to another user")
	ErrUserNotFound              = errors.New("user not found")
	ErrUnsupportedSSOProvider    = errors.New("unsupported sso provider")
	ErrTOTPRequired              = errors.New("totp required")
	ErrTOTPChallengeAttemptLimit = errors.New("totp challenge attempt limit reached")
	ErrEmailOTPRequired          = errors.New("email otp required")
	ErrInvalidEmailOTP           = errors.New("invalid email otp")
	ErrEmailOTPRateLimited       = errors.New("email otp resend is rate limited")
	ErrEmailOTPResendExceeded    = errors.New("email otp resend limit exceeded")
	ErrInvalidResetLink          = errors.New("invalid or expired reset link")
	ErrInvalidSecurityPIN        = errors.New("invalid security pin")
	ErrSecurityPINAlreadySet     = errors.New("security pin is already set")
	ErrSecurityPINNotSet         = errors.New("security pin is not set")
	ErrSecurityPINLocked         = errors.New("security pin is blocked")
	ErrSecurityPINNotBlocked     = errors.New("security pin is not blocked")
	ErrSecurityPINResetRatelimit = errors.New("security pin reset request rate limited")
	ErrSecurityPINResetRequired  = errors.New("security pin reset required")
	ErrSecurityPINAttemptLimit   = errors.New("security pin attempt limit reached")
	ErrInvalidPINResetPassword   = errors.New("invalid password for pin reset")
	ErrInvalidPINAction          = errors.New("invalid or expired pin verification token")
	ErrInvalidReauthMethod       = errors.New("invalid reauth method")
	ErrSessionNotFound           = errors.New("session not found")
	ErrMicrosoftReauthRequired   = errors.New("microsoft reauth required")
)

type TokenStore interface {
	Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
	SetIfNotExists(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)
	Get(ctx context.Context, key string) ([]byte, error)
	Delete(ctx context.Context, key string) error
}

type SecretProtector interface {
	Encrypt(plain []byte) ([]byte, error)
	Decrypt(cipher []byte) ([]byte, error)
}

type MicrosoftSSO interface {
	AuthCodeURL(state string) string
	LogoutURL(postLogoutRedirectURL string) string
	ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error)
}

type MicrosoftClaims struct {
	Subject  string
	Email    string
	Name     string
	TenantID string
	ObjectID string
	SessionID string
}

const totpChallengeMaxAttempts = 5

type microsoftTokenExchanger interface {
	ExchangeCodeAndTokens(ctx context.Context, code, codeVerifier string) (MicrosoftTokenSet, MicrosoftClaims, error)
	RefreshAccessToken(ctx context.Context, refreshToken, codeVerifier string) (MicrosoftTokenSet, error)
}

type microsoftOAuthTokenStore interface {
	UpsertMicrosoftOAuthToken(ctx context.Context, token *auth.UserMicrosoftOAuthToken) error
	GetMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (*auth.UserMicrosoftOAuthToken, error)
	DeleteMicrosoftOAuthTokenByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error)
}

type microsoftStatePayload struct {
	Flow         string `json:"flow"`
	UserID       string `json:"user_id,omitempty"`
	Purpose      string `json:"purpose,omitempty"`
	CodeVerifier string `json:"code_verifier,omitempty"`
}

type AuthService struct {
	repo            auth.Repository
	roleRepo        auth.RoleRepository
	tokens          TokenStore
	emailQueue      EmailQueue
	emailOutbox     auth.EmailOutboxWriter
	emailSerializer queue.MessageSerializer
	audit           *AuditLogService
	sso             MicrosoftSSO
	protector       SecretProtector
	ipProtector     SecretProtector
	emailBranding   AuthEmailBrandingResolver
	emailOTPEnabled func(context.Context) (bool, error)
	cfg             config.AuthConfig
}

type AuthServiceDependencies struct {
	SSO             MicrosoftSSO
	Protector       SecretProtector
	Config          config.AuthConfig
	Audit           *AuditLogService
	EmailOutbox     auth.EmailOutboxWriter
	EmailSerializer queue.MessageSerializer
	EmailBranding   AuthEmailBrandingResolver
	EmailOTPEnabled func(context.Context) (bool, error)
	// Disable legacy behavior that swaps nil email queue with noop queue.
	DisableLegacyNoopEmailQueue bool
}

func NewAuthService(
	repo auth.Repository,
	roleRepo auth.RoleRepository,
	tokens TokenStore,
	emailQueue EmailQueue,
	deps AuthServiceDependencies,
) *AuthService {
	if emailQueue == nil && !deps.DisableLegacyNoopEmailQueue {
		emailQueue = noopEmailQueue{}
	}
	cfg := normalizeAuthConfig(deps.Config)
	return &AuthService{
		repo:            repo,
		roleRepo:        roleRepo,
		tokens:          tokens,
		emailQueue:      emailQueue,
		emailOutbox:     deps.EmailOutbox,
		emailSerializer: deps.EmailSerializer,
		audit:           deps.Audit,
		sso:             deps.SSO,
		protector:       deps.Protector,
		ipProtector:     newIPSecretProtector(cfg.IPEncryptionKey),
		emailBranding:   deps.EmailBranding,
		emailOTPEnabled: deps.EmailOTPEnabled,
		cfg:             cfg,
	}
}

type noopEmailQueue struct{}

func (noopEmailQueue) Enqueue(context.Context, EmailJob) error {
	return nil
}

func (s *AuthService) SetAuditLogger(audit *AuditLogService) {
	s.audit = audit
}

func (s *AuthService) SetEmailOutbox(outbox auth.EmailOutboxWriter, serializer queue.MessageSerializer) {
	s.emailOutbox = outbox
	s.emailSerializer = serializer
}

func (s *AuthService) BeginMicrosoftLogin(ctx context.Context) (string, error) {
	return s.beginMicrosoftLoginWithFlowAndPrompt(ctx, microsoftStateFlowLogin, "select_account")
}

func (s *AuthService) BeginMicrosoftSessionCheck(ctx context.Context) (string, error) {
	return s.beginMicrosoftLoginWithFlowAndPrompt(ctx, microsoftStateFlowLoginCheck, "none")
}

func (s *AuthService) BeginMicrosoftSessionCheckStrict(ctx context.Context) (string, error) {
	return s.beginMicrosoftLoginWithFlowAndPrompt(ctx, microsoftStateFlowLoginCheckStrict, "none")
}

func (s *AuthService) beginMicrosoftLoginWithFlowAndPrompt(ctx context.Context, flow, prompt string) (string, error) {
	if s.sso == nil || s.tokens == nil {
		return "", errors.New("sso not configured")
	}
	state, err := randomToken(32)
	if err != nil {
		return "", err
	}
	codeVerifier, err := randomToken(64)
	if err != nil {
		return "", err
	}
	payload, err := encodeMicrosoftStatePayload(microsoftStatePayload{Flow: flow, CodeVerifier: codeVerifier})
	if err != nil {
		return "", err
	}
	authURL := strings.TrimSpace(s.sso.AuthCodeURL(state))
	if _, ok := s.sso.(microsoftTokenExchanger); ok {
		authURL = withMicrosoftPKCE(authURL, codeVerifier)
	}
	authURL = withMicrosoftAuthPrompt(authURL, prompt)
	if authURL == "" {
		return "", errors.New("sso not configured")
	}
	if err := s.tokens.Set(ctx, ssoStateKey(state), payload, s.cfg.SSOStateTTL); err != nil {
		return "", err
	}
	return authURL, nil
}

func (s *AuthService) BeginMicrosoftLink(ctx context.Context, userID []byte) (string, error) {
	if s.sso == nil || s.tokens == nil {
		return "", errors.New("sso not configured")
	}
	if len(userID) != 16 {
		return "", ErrUserNotFound
	}
	userIDStr, err := uuidv7.BytesToString(userID)
	if err != nil {
		return "", ErrUserNotFound
	}
	state, err := randomToken(32)
	if err != nil {
		return "", err
	}
	codeVerifier, err := randomToken(64)
	if err != nil {
		return "", err
	}
	payload, err := encodeMicrosoftStatePayload(microsoftStatePayload{
		Flow:         microsoftStateFlowLink,
		UserID:       userIDStr,
		CodeVerifier: codeVerifier,
	})
	if err != nil {
		return "", err
	}
	authURL := strings.TrimSpace(s.sso.AuthCodeURL(state))
	if _, ok := s.sso.(microsoftTokenExchanger); ok {
		authURL = withMicrosoftPKCE(authURL, codeVerifier)
	}
	if authURL == "" {
		return "", errors.New("sso not configured")
	}
	if err := s.tokens.Set(ctx, ssoStateKey(state), payload, s.cfg.SSOStateTTL); err != nil {
		return "", err
	}
	return authURL, nil
}

func (s *AuthService) BeginMicrosoftReauth(ctx context.Context, userID []byte, purpose string) (string, error) {
	if s.sso == nil || s.tokens == nil {
		return "", errors.New("sso not configured")
	}
	if len(userID) != 16 || !isSupportedMicrosoftReauthPurpose(purpose) {
		return "", ErrInvalidToken
	}
	userIDStr, err := uuidv7.BytesToString(userID)
	if err != nil {
		return "", ErrInvalidToken
	}
	state, err := randomToken(32)
	if err != nil {
		return "", err
	}
	codeVerifier, err := randomToken(64)
	if err != nil {
		return "", err
	}
	payload, err := encodeMicrosoftStatePayload(microsoftStatePayload{
		Flow:         microsoftStateFlowReauth,
		UserID:       userIDStr,
		Purpose:      purpose,
		CodeVerifier: codeVerifier,
	})
	if err != nil {
		return "", err
	}
	authURL := strings.TrimSpace(s.sso.AuthCodeURL(state))
	if _, ok := s.sso.(microsoftTokenExchanger); ok {
		authURL = withMicrosoftPKCE(authURL, codeVerifier)
	}
	if authURL == "" {
		return "", errors.New("sso not configured")
	}
	if err := s.tokens.Set(ctx, ssoStateKey(state), payload, s.cfg.SSOStateTTL); err != nil {
		return "", err
	}
	return authURL, nil
}

func (s *AuthService) BeginMicrosoftPINResetReauth(ctx context.Context, rawResetToken string) (string, error) {
	rawResetToken = strings.TrimSpace(rawResetToken)
	if rawResetToken == "" {
		return "", ErrInvalidResetLink
	}
	now := time.Now().UTC()
	user, err := s.repo.GetActiveSecurityPINResetTokenUser(ctx, hashToken(rawResetToken), now)
	if err != nil {
		return "", err
	}
	if user == nil || len(user.ID) != 16 {
		return "", ErrInvalidResetLink
	}
	return s.BeginMicrosoftReauth(ctx, user.ID, microsoftReauthPurposePINResetConfirm)
}

func (s *AuthService) MicrosoftStateMode(ctx context.Context, state string) (string, error) {
	payload, err := s.loadMicrosoftStatePayload(ctx, state)
	if err != nil {
		return "", err
	}
	return payload.Flow, nil
}

func (s *AuthService) CompleteMicrosoftLogin(ctx context.Context, code, state string) (*auth.UserIdentity, string, error) {
	if s.sso == nil || s.tokens == nil {
		return nil, "", errors.New("sso not configured")
	}
	payload, err := s.loadMicrosoftStatePayload(ctx, state)
	if err != nil {
		return nil, "", err
	}
	if payload.Flow != microsoftStateFlowLogin && payload.Flow != microsoftStateFlowLoginCheck && payload.Flow != microsoftStateFlowLoginCheckStrict {
		return nil, "", ErrInvalidSSOState
	}
	// Consume SSO state exactly once to avoid replay with the same OAuth code.
	defer func() {
		_ = s.tokens.Delete(ctx, ssoStateKey(state))
	}()
	identity, microsoftSessionID, err := s.LoginWithMicrosoft(ctx, code, payload.CodeVerifier)
	if err != nil {
		return nil, "", err
	}
	return identity, microsoftSessionID, nil
}

func (s *AuthService) ClearMicrosoftState(ctx context.Context, state string) {
	if s == nil || s.tokens == nil {
		return
	}
	state = strings.TrimSpace(state)
	if state == "" {
		return
	}
	_ = s.tokens.Delete(ctx, ssoStateKey(state))
}

func (s *AuthService) CompleteMicrosoftReauth(ctx context.Context, code, state string) (string, string, error) {
	if s.sso == nil || s.tokens == nil {
		return "", "", errors.New("sso not configured")
	}
	payload, err := s.loadMicrosoftStatePayload(ctx, state)
	if err != nil {
		return "", "", err
	}
	if payload.Flow != microsoftStateFlowReauth || payload.UserID == "" || !isSupportedMicrosoftReauthPurpose(payload.Purpose) {
		return "", "", ErrInvalidSSOState
	}
	// Consume SSO state exactly once to avoid replay with the same OAuth code.
	defer func() {
		_ = s.tokens.Delete(ctx, ssoStateKey(state))
	}()

	claims, _, err := s.exchangeMicrosoftCode(ctx, code, payload.CodeVerifier)
	if err != nil {
		return "", "", err
	}
	identity, err := s.repo.GetIdentityByProviderSubject(ctx, "microsoft", claims.Subject)
	if err != nil {
		return "", "", err
	}
	if identity == nil || len(identity.UserID) != 16 || bytesToString(identity.UserID) != payload.UserID {
		return "", "", ErrInvalidSSOReauth
	}

	token, err := randomToken(32)
	if err != nil {
		return "", "", err
	}
	if err := s.tokens.Set(ctx, microsoftReauthKey(token), []byte(payload.UserID+"|"+payload.Purpose), s.microsoftReauthTTL()); err != nil {
		return "", "", err
	}
	return token, payload.Purpose, nil
}

func (s *AuthService) CompleteMicrosoftLink(ctx context.Context, code, state string) (*auth.UserIdentity, error) {
	if s.sso == nil || s.tokens == nil {
		return nil, errors.New("sso not configured")
	}
	payload, err := s.loadMicrosoftStatePayload(ctx, state)
	if err != nil {
		return nil, err
	}
	if payload.Flow != microsoftStateFlowLink || payload.UserID == "" {
		return nil, ErrInvalidSSOState
	}
	parsedUserID, err := uuidv7.ParseString(payload.UserID)
	if err != nil || len(parsedUserID) != 16 {
		return nil, ErrInvalidSSOState
	}
	// Consume SSO state exactly once to avoid replay with the same OAuth code.
	defer func() {
		_ = s.tokens.Delete(ctx, ssoStateKey(state))
	}()
	return s.LinkSSOForUser(ctx, parsedUserID, ssoProviderMicrosoft, code, payload.CodeVerifier)
}

// RegisterWithEmail creates a user and records verification email intent in outbox.
func (s *AuthService) RegisterWithEmail(ctx context.Context, email, username, firstName, lastName, mobilePhone, password string, roleID []byte) (*auth.User, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	username, err := normalizeUsername(username)
	if err != nil {
		return nil, err
	}
	if email == "" || password == "" {
		return nil, errors.New("email, username, and password are required")
	}
	existingByUsername, err := s.repo.GetUserByUsername(ctx, username)
	if err != nil {
		return nil, err
	}
	if existingByUsername != nil {
		return nil, errors.New("username already registered")
	}

	hash, salt, err := s.hashPassword(password)
	if err != nil {
		return nil, err
	}

	user := &auth.User{
		ID:           uuidv7.MustBytes(),
		Email:        email,
		Username:     ptrString(username),
		FirstName:    firstName,
		LastName:     lastName,
		MobilePhone:  mobilePhone,
		Timezone:     timezone.Default,
		RoleID:       roleID,
		RoleIDs:      normalizeAuthServiceRoleIDs(roleID, nil),
		IsActive:     true,
		PasswordHash: hash,
		SaltValue:    salt,
	}

	job, err := s.buildEmailVerificationJob(ctx, user)
	if err != nil {
		return nil, err
	}
	if job.Metadata == nil {
		job.Metadata = map[string]string{}
	}
	// Keep outbox record for auditing/workflow, but suppress actual delivery.
	job.Metadata["skip_send"] = "true"
	if job.Tags == nil {
		job.Tags = map[string]string{}
	}
	job.Tags["source"] = "register"

	if err := s.createUserAndDispatchEmail(ctx, user, job); err != nil {
		return nil, err
	}

	return user, nil
}

// InviteUser creates a user without password and sends set-password link.
func (s *AuthService) InviteUser(ctx context.Context, email, username, firstName, lastName, mobilePhone string, roleID []byte) (*auth.User, error) {
	return s.InviteUserWithRoles(ctx, email, username, firstName, lastName, mobilePhone, roleID, nil)
}

func (s *AuthService) InviteUserWithRoles(
	ctx context.Context,
	email, username, firstName, lastName, mobilePhone string,
	roleID []byte,
	roleIDs [][]byte,
) (*auth.User, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	username, err := normalizeUsername(username)
	if err != nil {
		return nil, err
	}
	if email == "" {
		return nil, errors.New("email and username are required")
	}
	existing, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	if existing != nil {
		return nil, errors.New("email already registered")
	}
	existingByUsername, err := s.repo.GetUserByUsername(ctx, username)
	if err != nil {
		return nil, err
	}
	if existingByUsername != nil {
		return nil, errors.New("username already registered")
	}

	user := &auth.User{
		ID:          uuidv7.MustBytes(),
		Email:       email,
		Username:    ptrString(username),
		FirstName:   firstName,
		LastName:    lastName,
		MobilePhone: mobilePhone,
		Timezone:    timezone.Default,
		RoleID:      roleID,
		RoleIDs:     normalizeAuthServiceRoleIDs(roleID, roleIDs),
		IsActive:    true,
	}
	job, err := s.buildInviteSetPasswordJob(ctx, user)
	if err != nil {
		return nil, err
	}
	if err := s.createUserAndDispatchEmail(ctx, user, job); err != nil {
		return nil, err
	}
	return user, nil
}

// SendInviteSetPasswordForUser sends invite set-password email for an existing user.
func (s *AuthService) SendInviteSetPasswordForUser(ctx context.Context, userID []byte) error {
	if s.tokens == nil || strings.TrimSpace(s.cfg.SetPasswordURL) == "" {
		return errors.New("invite set-password is not configured")
	}
	if len(userID) != 16 {
		return errors.New("invalid user id")
	}
	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil {
		return errors.New("user not found")
	}
	if strings.TrimSpace(user.Email) == "" {
		return errors.New("user email is empty")
	}
	return s.sendInviteSetPassword(ctx, user)
}

// ResendInviteSetPassword triggers invite set-password email for invited users.
// This method intentionally keeps behavior generic and returns no error.
func (s *AuthService) ResendInviteSetPassword(ctx context.Context, userID []byte) {
	start := time.Now()
	defer sleepRemaining(start, s.cfg.ForgotPasswordMinDuration)

	if s.tokens == nil || strings.TrimSpace(s.cfg.SetPasswordURL) == "" || len(userID) != 16 {
		return
	}

	cooldownKey := inviteSetPasswordResendCooldownKey(userID)
	if s.isInviteSetPasswordResendCooldownActive(ctx, cooldownKey) {
		return
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil || user == nil {
		return
	}
	if !user.IsActive || strings.TrimSpace(user.Email) == "" {
		return
	}
	// Only resend for users that still have no password set.
	if len(user.PasswordHash) > 0 {
		return
	}

	if err := s.sendInviteSetPassword(ctx, user); err != nil {
		return
	}
	_ = s.markInviteSetPasswordResendCooldown(ctx, cooldownKey)
}

// ForgotPassword always returns without exposing whether an account exists.
func (s *AuthService) ForgotPassword(ctx context.Context, email, requestedIP, userAgent string) {
	start := time.Now()
	defer sleepRemaining(start, s.cfg.ForgotPasswordMinDuration)

	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" {
		s.logServiceAudit(ctx, "auth.forgot_password.request", nil, false, "empty_email", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
		return
	}

	cooldownKey := forgotPasswordCooldownKey(email)
	if s.isForgotPasswordCooldownActive(ctx, cooldownKey, email, requestedIP) {
		s.logServiceAudit(ctx, "auth.forgot_password.cooldown", nil, true, "cooldown_active", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
		return
	}

	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		s.auditForgotPasswordAnomaly(email, requestedIP, "lookup_failed")
		_ = s.markForgotPasswordCooldown(ctx, cooldownKey)
		s.logServiceAudit(ctx, "auth.forgot_password.lookup", nil, false, "lookup_failed", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
		return
	}

	if user != nil {
		if err := s.createAndSendPasswordReset(ctx, user, requestedIP, userAgent); err != nil {
			s.auditForgotPasswordAnomaly(email, requestedIP, "issue_failed")
			s.logServiceAudit(ctx, "auth.forgot_password.issue", user.ID, false, "issue_failed", map[string]any{
				"email_sha256": shortEmailHash(email),
			})
		} else {
			s.logServiceAudit(ctx, "auth.forgot_password.issue", user.ID, true, "enqueued", map[string]any{
				"email_sha256": shortEmailHash(email),
			})
		}
	} else {
		s.logServiceAudit(ctx, "auth.forgot_password.request", nil, true, "account_not_found_hidden", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
	}

	_ = s.markForgotPasswordCooldown(ctx, cooldownKey)
}

func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
	rawToken = strings.TrimSpace(rawToken)
	if rawToken == "" || newPassword == "" {
		s.logServiceAudit(ctx, "auth.reset_password.consume", nil, false, "missing_token_or_password", nil)
		return ErrInvalidResetLink
	}

	passwordHash, salt, err := s.hashPassword(newPassword)
	if err != nil {
		return err
	}

	userID, consumed, err := s.repo.ConsumePasswordResetTokenAndSetPassword(ctx, hashToken(rawToken), passwordHash, salt, time.Now().UTC())
	if err != nil {
		s.logServiceAudit(ctx, "auth.reset_password.consume", nil, false, "consume_failed", nil)
		return err
	}
	if !consumed || len(userID) != 16 {
		s.logServiceAudit(ctx, "auth.reset_password.consume", nil, false, "invalid_or_expired", nil)
		return ErrInvalidResetLink
	}

	if err := s.bumpSessionVersion(ctx, userID); err != nil {
		log.Printf("auth reset-password session_revocation_failed user=%s", shortIdentifier(userID))
		s.logServiceAudit(ctx, "auth.reset_password.revoke_sessions", userID, false, "session_revocation_failed", nil)
	} else {
		s.logServiceAudit(ctx, "auth.reset_password.revoke_sessions", userID, true, "session_revoked", nil)
	}
	s.logServiceAudit(ctx, "auth.reset_password.consume", userID, true, "password_reset_success", nil)
	return nil
}

func (s *AuthService) GetRoleIDByName(ctx context.Context, name string) ([]byte, error) {
	if s.roleRepo == nil {
		return nil, errors.New("role repo not configured")
	}
	role, err := s.roleRepo.GetRoleByName(ctx, name)
	if err != nil || role == nil {
		return nil, errors.New("role not found")
	}
	return role.ID, nil
}

func (s *AuthService) DefaultRoleName() string { return s.cfg.DefaultRoleName }
func (s *AuthService) RegisterDisabled() bool  { return s.cfg.DisableRegister }

// LoginWithEmailPassword verifies email/password and returns the user.
func (s *AuthService) LoginWithEmailPassword(ctx context.Context, email, password string) (*auth.User, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil || user == nil {
		return nil, ErrInvalidCredentials
	}

	if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) {
		return nil, ErrInvalidCredentials
	}
	if !user.IsActive {
		return nil, ErrInvalidCredentials
	}

	if user.EmailVerifiedAt == nil {
		return nil, ErrEmailNotVerified
	}

	return user, nil
}

func (s *AuthService) IsTOTPEnabled(ctx context.Context, userID []byte) (bool, error) {
	record, err := s.repo.GetUserTOTP(ctx, userID)
	if err != nil || record == nil {
		return false, err
	}
	return record.Enabled, nil
}

func (s *AuthService) IsTOTPPreviouslyConfigured(ctx context.Context, userID []byte) (bool, error) {
	record, err := s.repo.GetUserTOTP(ctx, userID)
	if err != nil || record == nil {
		return false, err
	}
	return !record.Enabled, nil
}

type TokenPair struct {
	AccessToken  string
	RefreshToken string
	RefreshTTL   time.Duration
	SessionID    string
}

func (s *AuthService) IssueTokens(ctx context.Context, user *auth.User) (TokenPair, error) {
	return s.IssueTokensWithClient(ctx, user, "", "")
}

func (s *AuthService) IssueTokensAfterTOTP(ctx context.Context, user *auth.User) (TokenPair, error) {
	return s.IssueTokensAfterTOTPWithClient(ctx, user, "", "")
}

func (s *AuthService) issueLoginTokensWithRefreshTTL(ctx context.Context, user *auth.User, refreshTTL time.Duration, longLived bool) (TokenPair, error) {
	if user == nil || len(user.ID) != 16 {
		return TokenPair{}, ErrInvalidToken
	}
	return s.issueTokensWithRefreshTTL(ctx, user, refreshTTL, longLived)
}

func (s *AuthService) issueTokensWithRefreshTTL(ctx context.Context, user *auth.User, refreshTTL time.Duration, longLived bool) (TokenPair, error) {
	return s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, "", "", "", "", "", "")
}

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

func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
	return s.revokeRefreshTokenWithSession(ctx, refreshToken)
}

func (s *AuthService) ParseAccessToken(ctx context.Context, accessToken string) ([]byte, error) {
	userID, _, err := s.parseAccessTokenWithSession(ctx, accessToken)
	if err != nil {
		return nil, err
	}
	return userID, nil
}

func (s *AuthService) parseAccessTokenWithSession(ctx context.Context, accessToken string) ([]byte, string, error) {
	if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" {
		return nil, "", errors.New("jwt secrets not configured")
	}
	if accessToken == "" {
		return nil, "", ErrInvalidToken
	}

	parsed, err := jwt.Parse(accessToken, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, ErrInvalidToken
		}
		return []byte(s.cfg.JWTAccessSecret), nil
	})
	if err != nil || !parsed.Valid {
		return nil, "", ErrInvalidToken
	}

	claims, ok := parsed.Claims.(jwt.MapClaims)
	if !ok {
		return nil, "", ErrInvalidToken
	}
	if claims["type"] != "access" {
		return nil, "", ErrInvalidToken
	}
	sub, _ := claims["sub"].(string)
	sid, _ := claims["sid"].(string)
	if sub == "" || sid == "" {
		return nil, "", ErrInvalidToken
	}
	userID, err := uuidv7.ParseString(sub)
	if err != nil {
		return nil, "", ErrInvalidToken
	}
	if !s.validSessionVersion(ctx, userID, claims["sv"]) {
		return nil, "", ErrInvalidToken
	}
	if !s.isSessionActive(ctx, userID, sid) {
		return nil, "", ErrInvalidToken
	}
	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil || user == nil || !user.IsActive {
		return nil, "", ErrInvalidToken
	}
	record, err := s.loadSessionRecord(ctx, sid)
	if err != nil || record == nil {
		return nil, "", ErrInvalidToken
	}
	if s.isSessionIdleExpired(record) {
		// Local/email sessions expire at idle boundary.
		if !s.canRecoverSSOSessionOnIdle(ctx, userID, record) {
			_ = s.RevokeUserSession(ctx, userID, sid)
			return nil, "", ErrInvalidToken
		}
	}
	s.touchSessionActivity(ctx, userID, sid)
	return userID, sid, nil
}

func (s *AuthService) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) {
	return s.repo.GetUserByID(ctx, id)
}

func (s *AuthService) IsSSOLinked(ctx context.Context, userID []byte) (bool, error) {
	if len(userID) != 16 {
		return false, nil
	}
	identity, err := s.repo.GetIdentityByUserID(ctx, userID)
	if err != nil {
		return false, err
	}
	return identity != nil, nil
}

func (s *AuthService) GetMicrosoftScopeByUserID(ctx context.Context, userID []byte) (string, error) {
	if len(userID) != 16 {
		return "", nil
	}
	repo, ok := s.repo.(microsoftOAuthTokenStore)
	if !ok {
		return "", nil
	}
	record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
	if err != nil {
		return "", err
	}
	if record == nil {
		return "", nil
	}
	return normalizeMicrosoftScopeCSV(record.Scope), nil
}

func normalizeMicrosoftScopeCSV(scope string) string {
	parts := strings.Fields(strings.TrimSpace(scope))
	if len(parts) == 0 {
		return ""
	}
	return strings.Join(parts, ", ")
}

func (s *AuthService) UpdateUserTimezone(ctx context.Context, userID []byte, timezoneName string) (string, error) {
	if len(userID) == 0 {
		return "", errors.New("user_id is required")
	}
	normalized, err := timezone.Normalize(timezoneName)
	if err != nil {
		return "", err
	}
	if err := s.repo.UpdateUserTimezone(ctx, userID, normalized); err != nil {
		return "", err
	}
	return normalized, nil
}

func (s *AuthService) SetSecurityPIN(ctx context.Context, userID []byte, pin string) error {
	if len(userID) != 16 {
		return ErrInvalidSecurityPIN
	}
	pin = strings.TrimSpace(pin)
	if err := validateSecurityPIN(pin); err != nil {
		return err
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil {
		return ErrInvalidToken
	}
	if len(user.SecurityPINHash) > 0 {
		return ErrSecurityPINAlreadySet
	}

	pinRecord, err := s.hashSecurityPIN(pin)
	if err != nil {
		return err
	}
	now := time.Now().UTC()
	if err := s.repo.SetUserSecurityPIN(ctx, userID, pinRecord, now); err != nil {
		return err
	}
	_ = s.clearSecurityPINLockState(ctx, userID)
	s.logServiceAudit(ctx, "auth.pin.set", userID, true, "pin_set", nil)
	return nil
}

func (s *AuthService) ChangeSecurityPIN(ctx context.Context, userID []byte, currentPIN, newPIN string) error {
	if len(userID) != 16 {
		return ErrInvalidSecurityPIN
	}
	currentPIN = strings.TrimSpace(currentPIN)
	newPIN = strings.TrimSpace(newPIN)

	if err := validateSecurityPIN(currentPIN); err != nil {
		return err
	}
	if err := validateSecurityPIN(newPIN); err != nil {
		return err
	}

	if err := s.verifySecurityPIN(ctx, userID, currentPIN); err != nil {
		return err
	}

	pinRecord, err := s.hashSecurityPIN(newPIN)
	if err != nil {
		return err
	}
	now := time.Now().UTC()
	if err := s.repo.SetUserSecurityPIN(ctx, userID, pinRecord, now); err != nil {
		return err
	}
	_ = s.clearSecurityPINLockState(ctx, userID)
	s.logServiceAudit(ctx, "auth.pin.change", userID, true, "pin_changed", nil)
	return nil
}

func (s *AuthService) VerifySecurityPINForAction(ctx context.Context, userID []byte, pin, action string) (string, error) {
	return s.verifySecurityPINForAction(ctx, userID, pin, action, "")
}

func (s *AuthService) VerifySecurityPINForActionInSession(ctx context.Context, userID []byte, pin, action, sessionID string) (string, error) {
	return s.verifySecurityPINForAction(ctx, userID, pin, action, sessionID)
}

func (s *AuthService) verifySecurityPINForAction(ctx context.Context, userID []byte, pin, action, sessionID string) (string, error) {
	if len(userID) != 16 {
		return "", ErrInvalidSecurityPIN
	}
	if err := s.verifySecurityPIN(ctx, userID, strings.TrimSpace(pin)); err != nil {
		return "", err
	}

	action = normalizeSensitiveAction(action)
	if action == "" {
		return "", errors.New("action is required")
	}
	if s.tokens == nil {
		return "", errors.New("token store not configured")
	}

	token, err := randomToken(24)
	if err != nil {
		return "", err
	}

	payload := bytesToString(userID) + "|" + action
	if strings.TrimSpace(sessionID) != "" {
		payload += "|" + strings.TrimSpace(sessionID)
	}
	if err := s.tokens.Set(ctx, pinActionKey(token), []byte(payload), s.cfg.SecurityPINActionTokenTTL); err != nil {
		return "", err
	}
	s.logServiceAudit(ctx, "auth.pin.verify", userID, true, "pin_verified", map[string]any{
		"action": action,
	})
	return token, nil
}

func (s *AuthService) ConsumeSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error {
	return s.validateSecurityPINActionToken(ctx, userID, action, token, "", true)
}

func (s *AuthService) ValidateSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error {
	return s.validateSecurityPINActionToken(ctx, userID, action, token, "", false)
}

func (s *AuthService) ConsumeSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error {
	return s.validateSecurityPINActionToken(ctx, userID, action, token, sessionID, true)
}

func (s *AuthService) ValidateSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error {
	return s.validateSecurityPINActionToken(ctx, userID, action, token, sessionID, false)
}

func (s *AuthService) validateSecurityPINActionToken(ctx context.Context, userID []byte, action, token, sessionID string, consume bool) error {
	if len(userID) != 16 {
		return ErrInvalidPINAction
	}
	action = normalizeSensitiveAction(action)
	token = strings.TrimSpace(token)
	if action == "" || token == "" || s.tokens == nil {
		return ErrInvalidPINAction
	}

	raw, err := s.tokens.Get(ctx, pinActionKey(token))
	if err != nil || raw == nil {
		return ErrInvalidPINAction
	}

	parts := strings.Split(string(raw), "|")
	if len(parts) != 2 && len(parts) != 3 {
		return ErrInvalidPINAction
	}
	if !subtleConstantTimeCompare([]byte(parts[0]), []byte(bytesToString(userID))) {
		return ErrInvalidPINAction
	}
	if !subtleConstantTimeCompare([]byte(parts[1]), []byte(action)) {
		return ErrInvalidPINAction
	}
	if len(parts) == 3 {
		if !subtleConstantTimeCompare([]byte(parts[2]), []byte(strings.TrimSpace(sessionID))) {
			return ErrInvalidPINAction
		}
	}
	if consume {
		_ = s.tokens.Delete(ctx, pinActionKey(token))
	}
	return nil
}

// ForgotSecurityPIN always returns without exposing whether an account exists.
func (s *AuthService) ForgotSecurityPIN(ctx context.Context, email, requestedIP, userAgent string) {
	start := time.Now()
	defer sleepRemaining(start, s.cfg.ForgotSecurityPINMinDuration)

	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" {
		s.logServiceAudit(ctx, "auth.pin.forgot.request", nil, false, "empty_email", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
		return
	}

	cooldownKey := forgotSecurityPINCooldownKey(email)
	if s.isForgotSecurityPINCooldownActive(ctx, cooldownKey) {
		s.logServiceAudit(ctx, "auth.pin.forgot.cooldown", nil, true, "cooldown_active", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
		return
	}

	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		_ = s.markForgotSecurityPINCooldown(ctx, cooldownKey)
		s.logServiceAudit(ctx, "auth.pin.forgot.lookup", nil, false, "lookup_failed", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
		return
	}

	if user != nil {
		if err := s.createAndSendSecurityPINReset(ctx, user, requestedIP, userAgent); err != nil {
			s.logServiceAudit(ctx, "auth.pin.forgot.issue", user.ID, false, "issue_failed", map[string]any{
				"email_sha256": shortEmailHash(email),
			})
		} else {
			s.logServiceAudit(ctx, "auth.pin.forgot.issue", user.ID, true, "enqueued", map[string]any{
				"email_sha256": shortEmailHash(email),
			})
		}
	} else {
		s.logServiceAudit(ctx, "auth.pin.forgot.request", nil, true, "account_not_found_hidden", map[string]any{
			"email_sha256": shortEmailHash(email),
		})
	}

	_ = s.markForgotSecurityPINCooldown(ctx, cooldownKey)
}

func (s *AuthService) RequestSecurityPINReset(ctx context.Context, userID []byte, method, password, reauthToken, requestedIP, userAgent string) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	method = normalizePINResetMethod(method)
	if !s.isSecurityPINLocked(ctx, userID) {
		return ErrSecurityPINNotBlocked
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil {
		return ErrInvalidToken
	}
	switch method {
	case pinResetMethodPassword:
		password = strings.TrimSpace(password)
		if password == "" {
			return ErrInvalidPINResetPassword
		}
		if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) {
			s.logServiceAudit(ctx, "auth.pin.reset.issue", userID, false, "invalid_password", map[string]any{
				"trigger": "blocked_reset_button",
			})
			return ErrInvalidPINResetPassword
		}
	case pinResetMethodMicrosoft:
		if err := s.consumeMicrosoftReauthToken(ctx, userID, microsoftReauthPurposePINResetRequest, reauthToken); err != nil {
			s.logServiceAudit(ctx, "auth.pin.reset.issue", userID, false, "invalid_sso_reauth", map[string]any{
				"trigger": "blocked_reset_button",
			})
			return err
		}
	default:
		return ErrInvalidReauthMethod
	}

	cooldownKey := forgotSecurityPINCooldownKey(user.Email)
	if s.isForgotSecurityPINCooldownActive(ctx, cooldownKey) {
		return ErrSecurityPINResetRatelimit
	}
	if err := s.createAndSendSecurityPINReset(ctx, user, requestedIP, userAgent); err != nil {
		return err
	}
	_ = s.markForgotSecurityPINCooldown(ctx, cooldownKey)
	s.logServiceAudit(ctx, "auth.pin.reset.issue", userID, true, "requested_by_user", map[string]any{
		"trigger": "blocked_reset_button",
	})
	return nil
}

func (s *AuthService) ResetSecurityPIN(ctx context.Context, rawToken, method, password, reauthToken, newPIN string) error {
	method = normalizePINResetMethod(method)
	rawToken = strings.TrimSpace(rawToken)
	password = strings.TrimSpace(password)
	newPIN = strings.TrimSpace(newPIN)
	if err := validateSecurityPIN(newPIN); err != nil {
		return err
	}

	now := time.Now().UTC()
	switch method {
	case pinResetMethodPassword:
		if rawToken == "" {
			return ErrInvalidResetLink
		}
		tokenHash := hashToken(rawToken)
		user, err := s.repo.GetActiveSecurityPINResetTokenUser(ctx, tokenHash, now)
		if err != nil {
			s.logServiceAudit(ctx, "auth.pin.reset.lookup", nil, false, "lookup_failed", nil)
			return err
		}
		if user == nil || len(user.ID) != 16 {
			s.logServiceAudit(ctx, "auth.pin.reset.lookup", nil, false, "invalid_or_expired", nil)
			return ErrInvalidResetLink
		}
		if password == "" {
			return ErrInvalidPINResetPassword
		}
		if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) {
			s.logServiceAudit(ctx, "auth.pin.reset.password", user.ID, false, "invalid_password", nil)
			return ErrInvalidPINResetPassword
		}
		pinRecord, err := s.hashSecurityPIN(newPIN)
		if err != nil {
			return err
		}

		userID, consumed, err := s.repo.ConsumeSecurityPINResetTokenAndSetPIN(ctx, tokenHash, pinRecord, now)
		if err != nil {
			s.logServiceAudit(ctx, "auth.pin.reset.consume", nil, false, "consume_failed", nil)
			return err
		}
		if !consumed || len(userID) != 16 {
			s.logServiceAudit(ctx, "auth.pin.reset.consume", nil, false, "invalid_or_expired", nil)
			return ErrInvalidResetLink
		}
		_ = s.clearSecurityPINLockState(ctx, userID)
		s.logServiceAudit(ctx, "auth.pin.reset.consume", userID, true, "pin_reset_success", nil)
		return nil
	case pinResetMethodMicrosoft:
		userID, err := s.consumeMicrosoftReauthTokenForPurposes(ctx, []string{
			microsoftReauthPurposePINResetRequest,
			microsoftReauthPurposePINResetConfirm,
		}, reauthToken)
		if err != nil {
			s.logServiceAudit(ctx, "auth.pin.reset.microsoft", nil, false, "invalid_sso_reauth", nil)
			return err
		}
		user, err := s.repo.GetUserByID(ctx, userID)
		if err != nil {
			return err
		}
		if user == nil || len(user.ID) != 16 {
			return ErrInvalidToken
		}
		pinRecord, err := s.hashSecurityPIN(newPIN)
		if err != nil {
			return err
		}
		if err := s.repo.SetUserSecurityPIN(ctx, user.ID, pinRecord, now); err != nil {
			return err
		}
		if err := s.repo.InvalidateActiveSecurityPINResetTokensByUserID(ctx, user.ID, now); err != nil {
			return err
		}
		_ = s.clearSecurityPINLockState(ctx, user.ID)
		s.logServiceAudit(ctx, "auth.pin.reset.microsoft", user.ID, true, "pin_reset_success", nil)
		return nil
	default:
		return ErrInvalidReauthMethod
	}
}

func (s *AuthService) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) {
	if s.roleRepo == nil {
		return nil, errors.New("role repo not configured")
	}
	return s.roleRepo.GetRoleByID(ctx, id)
}

func (s *AuthService) PermissionRequiresPIN(ctx context.Context, permissionKey string) (bool, error) {
	if s.roleRepo == nil {
		return false, errors.New("role repo not configured")
	}

	perm, err := s.roleRepo.GetPermissionByKey(ctx, permissionKey)
	if err != nil {
		return false, err
	}
	if perm == nil {
		return false, nil
	}
	return perm.RequiresPIN, nil
}

func (s *AuthService) HasPermission(ctx context.Context, userID []byte, permissionKey string) (bool, error) {
	if s.repo == nil {
		return false, errors.New("auth repo not configured")
	}
	if s.roleRepo == nil {
		return false, errors.New("role repo not configured")
	}
	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return false, err
	}
	if user == nil {
		return false, nil
	}

	for _, roleID := range collectAuthUserRoleIDs(user) {
		allowed, err := s.roleRepo.HasRolePermission(ctx, roleID, permissionKey)
		if err != nil {
			return false, err
		}
		if allowed {
			return true, nil
		}
	}

	return false, nil
}

func (s *AuthService) GetPermissionsByRoleID(ctx context.Context, roleID []byte) ([]auth.Permission, error) {
	if s.roleRepo == nil {
		return nil, errors.New("role repo not configured")
	}
	return s.roleRepo.ListPermissionsByRoleID(ctx, roleID)
}

func (s *AuthService) GetPermissionsByUserID(ctx context.Context, userID []byte) ([]auth.Permission, error) {
	if s.repo == nil {
		return nil, errors.New("auth repo not configured")
	}
	if s.roleRepo == nil {
		return nil, errors.New("role repo not configured")
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return []auth.Permission{}, nil
	}

	permsByKey := make(map[string]auth.Permission)
	for _, roleID := range collectAuthUserRoleIDs(user) {
		perms, err := s.roleRepo.ListPermissionsByRoleID(ctx, roleID)
		if err != nil {
			return nil, err
		}
		for i := range perms {
			key := strings.TrimSpace(perms[i].Key)
			if key == "" {
				key = string(perms[i].ID)
			}
			if _, exists := permsByKey[key]; exists {
				continue
			}
			permsByKey[key] = perms[i]
		}
	}

	out := make([]auth.Permission, 0, len(permsByKey))
	for _, perm := range permsByKey {
		out = append(out, perm)
	}
	sort.Slice(out, func(i, j int) bool {
		leftName := strings.ToLower(strings.TrimSpace(out[i].Name))
		rightName := strings.ToLower(strings.TrimSpace(out[j].Name))
		if leftName == rightName {
			return strings.ToLower(strings.TrimSpace(out[i].Key)) < strings.ToLower(strings.TrimSpace(out[j].Key))
		}
		return leftName < rightName
	})

	return out, nil
}

func (s *AuthService) AccessTTL() time.Duration  { return s.cfg.JWTAccessTTL }
func (s *AuthService) RefreshTTL() time.Duration { return s.cfg.JWTRefreshTTL }
func (s *AuthService) RefreshTOTPTTL() time.Duration {
	return s.cfg.JWTRefreshTOTPTTL
}
func (s *AuthService) EmailOTPResendCooldown() time.Duration { return s.cfg.EmailOTPResendCooldown }
func (s *AuthService) EmailOTPMaxResendPerHour() int         { return s.cfg.EmailOTPMaxResendPerHour }
func (s *AuthService) IsLoginEmailOTPEnabled(ctx context.Context) bool {
	if s == nil || s.emailOTPEnabled == nil {
		return true
	}
	enabled, err := s.emailOTPEnabled(ctx)
	if err != nil {
		return true
	}
	return enabled
}
func (s *AuthService) SecurityPINTokenTTL() time.Duration {
	return s.cfg.SecurityPINActionTokenTTL
}
func (s *AuthService) IsSecurityPINBlocked(ctx context.Context, userID []byte) bool {
	return s.isSecurityPINLocked(ctx, userID)
}
func (s *AuthService) CookieDomain() string          { return s.cfg.JWTCookieDomain }
func (s *AuthService) CookieSecure() bool            { return s.cfg.JWTCookieSecure }
func (s *AuthService) CookieSameSite() string        { return s.cfg.JWTCookieSameSite }
func (s *AuthService) AccessCookieName() string      { return s.cfg.JWTAccessCookieName }
func (s *AuthService) RefreshCookieName() string     { return s.cfg.JWTRefreshCookieName }
func (s *AuthService) SSOSuccessRedirectURL() string { return s.cfg.SSOSuccessRedirectURL }
func (s *AuthService) MicrosoftLogoutURL() string {
	if s == nil || s.sso == nil {
		return ""
	}
	return strings.TrimSpace(s.sso.LogoutURL(s.cfg.SSOSuccessRedirectURL))
}
func (s *AuthService) ForgotPasswordIPRateMax() int { return s.cfg.ForgotPasswordIPRateMax }
func (s *AuthService) LoginIPRateMax() int          { return s.cfg.LoginIPRateMax }

func collectAuthUserRoleIDs(user *auth.User) [][]byte {
	if user == nil {
		return nil
	}

	roleIDs := make([][]byte, 0, len(user.RoleIDs)+1)
	seen := make(map[string]struct{}, len(user.RoleIDs)+1)

	appendRole := func(roleID []byte) {
		if len(roleID) != 16 {
			return
		}
		key := string(roleID)
		if _, exists := seen[key]; exists {
			return
		}
		seen[key] = struct{}{}
		roleIDs = append(roleIDs, append([]byte(nil), roleID...))
	}

	appendRole(user.RoleID)
	for i := range user.RoleIDs {
		appendRole(user.RoleIDs[i])
	}

	return roleIDs
}

func normalizeAuthServiceRoleIDs(primaryRoleID []byte, roleIDs [][]byte) [][]byte {
	roleSet := &auth.User{
		RoleID:  primaryRoleID,
		RoleIDs: roleIDs,
	}
	return collectAuthUserRoleIDs(roleSet)
}
func (s *AuthService) ForgotPasswordIPRateWindow() time.Duration {
	return s.cfg.ForgotPasswordIPRateWindow
}

func (s *AuthService) LoginIPRateWindow() time.Duration {
	return s.cfg.LoginIPRateWindow
}

// VerifyEmail verifies a token and marks the user as verified.
func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
	if token == "" {
		return ErrInvalidToken
	}
	if s.tokens == nil {
		return ErrInvalidToken
	}
	key := emailVerifyKey(token)
	data, err := s.tokens.Get(ctx, key)
	if err != nil || len(data) != 16 {
		return ErrInvalidToken
	}
	_ = s.tokens.Delete(ctx, key)
	return s.repo.MarkEmailVerified(ctx, data)
}

func (s *AuthService) SetPasswordWithInviteToken(ctx context.Context, token, password string) error {
	if token == "" || password == "" {
		return errors.New("token and password are required")
	}
	if s.tokens == nil {
		return ErrInvalidToken
	}
	var (
		data      []byte
		key       string
		err       error
		parsedUID []byte
	)
	if uid, jti, _, parseErr := s.parseInviteJWT(token); parseErr == nil {
		key = inviteKey(jti)
		data, err = s.tokens.Get(ctx, key)
		if err != nil || len(data) != 16 || !bytes.Equal(data, uid) {
			return ErrInvalidToken
		}
		parsedUID = uid
	} else {
		// Backward compatibility for legacy invite token format.
		key = inviteKey(token)
		data, err = s.tokens.Get(ctx, key)
		if err != nil || len(data) != 16 {
			return ErrInvalidToken
		}
		parsedUID = data
	}

	hash, salt, err := s.hashPassword(password)
	if err != nil {
		return err
	}
	if err := s.repo.SetUserPassword(ctx, parsedUID, hash, salt); err != nil {
		return err
	}
	if err := s.repo.MarkEmailVerified(ctx, parsedUID); err != nil {
		return err
	}
	_ = s.tokens.Delete(ctx, key)
	return nil
}

// SSO Microsoft (Entra) login flow: exchange code -> resolve existing identity -> get user.
func (s *AuthService) LoginWithMicrosoft(ctx context.Context, code string, codeVerifier ...string) (*auth.UserIdentity, string, error) {
	claims, tokenSet, err := s.exchangeMicrosoftCode(ctx, code, msFirstNonEmpty(codeVerifier...))
	if err != nil {
		return nil, "", err
	}
	identity, err := s.resolveOrSyncMicrosoftIdentityByClaims(ctx, claims)
	if err != nil {
		return nil, "", err
	}
	if tokenSet != nil {
		if err := s.storeMicrosoftOAuthToken(ctx, identity.UserID, claims, *tokenSet); err != nil {
			return nil, "", err
		}
	}
	return identity, claims.SessionID, nil
}

// ExchangeMicrosoftToken resolves a Microsoft identity from frontend-provided tokens.
// This flow is intended for SPA silent SSO (ssoSilent) where backend receives id_token/access_token directly.
func (s *AuthService) ExchangeMicrosoftToken(ctx context.Context, accessToken, idToken string, idTokenClaims map[string]any) (*auth.UserIdentity, error) {
	accessToken = strings.TrimSpace(accessToken)
	idToken = strings.TrimSpace(idToken)
	if accessToken == "" {
		return nil, errors.New("access token is required")
	}
	if idToken == "" {
		return nil, errors.New("id token is required")
	}

	claimsMap := idTokenClaims
	if len(claimsMap) == 0 {
		var err error
		claimsMap, err = parseJWTClaims(idToken)
		if err != nil {
			return nil, errors.New("invalid id token claims")
		}
	}

	exp := parseJWTUnixClaim(claimsMap["exp"])
	if exp <= 0 || time.Unix(exp, 0).Before(time.Now().UTC()) {
		return nil, ErrInvalidToken
	}

	claims := MicrosoftClaims{
		Subject:   strings.TrimSpace(parseJWTStringClaim(claimsMap, "sub")),
		Email:     strings.TrimSpace(parseJWTStringClaim(claimsMap, "email")),
		Name:      strings.TrimSpace(parseJWTStringClaim(claimsMap, "name")),
		TenantID:  strings.TrimSpace(parseJWTStringClaim(claimsMap, "tid")),
		ObjectID:  strings.TrimSpace(parseJWTStringClaim(claimsMap, "oid")),
		SessionID: strings.TrimSpace(parseJWTStringClaim(claimsMap, "sid")),
	}
	if claims.Email == "" {
		claims.Email = strings.TrimSpace(parseJWTStringClaim(claimsMap, "preferred_username"))
	}

	return s.resolveOrSyncMicrosoftIdentityByClaims(ctx, claims)
}

func (s *AuthService) LinkSSOForUser(ctx context.Context, userID []byte, provider, code string, codeVerifier ...string) (*auth.UserIdentity, error) {
	if s.sso == nil {
		return nil, errors.New("sso not configured")
	}
	if len(userID) != 16 {
		return nil, ErrUserNotFound
	}
	provider = strings.ToLower(strings.TrimSpace(provider))
	if provider != ssoProviderMicrosoft {
		return nil, ErrUnsupportedSSOProvider
	}
	code = strings.TrimSpace(code)
	if code == "" {
		return nil, errors.New("authorization code is required")
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil || len(user.ID) != 16 {
		return nil, ErrUserNotFound
	}

	claims, tokenSet, err := s.exchangeMicrosoftCode(ctx, code, msFirstNonEmpty(codeVerifier...))
	if err != nil {
		return nil, err
	}
	if !strings.EqualFold(strings.TrimSpace(user.Email), strings.TrimSpace(claims.Email)) {
		return nil, ErrSSONotLinked
	}
	subject := strings.TrimSpace(claims.Subject)
	if subject == "" {
		return nil, errors.New("microsoft subject is empty")
	}

	existingBySubject, err := s.repo.GetIdentityByProviderSubject(ctx, provider, subject)
	if err != nil {
		return nil, err
	}
	if existingBySubject != nil {
		if bytes.Equal(existingBySubject.UserID, userID) {
			return nil, ErrSSOAlreadyLinked
		}
		return nil, ErrSSOLinkedToAnotherUser
	}

	existingByUser, err := s.repo.GetIdentityByUserID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if existingByUser != nil {
		return nil, ErrSSOAlreadyLinked
	}

	identity := &auth.UserIdentity{
		ID:              uuidv7.MustBytes(),
		UserID:          userID,
		Provider:        provider,
		ProviderSubject: subject,
		Email:           strings.ToLower(strings.TrimSpace(claims.Email)),
	}
	if err := s.repo.CreateIdentity(ctx, identity); err != nil {
		return nil, err
	}
	if tokenSet != nil {
		if err := s.storeMicrosoftOAuthToken(ctx, userID, claims, *tokenSet); err != nil {
			return nil, err
		}
	}
	return identity, nil
}

func (s *AuthService) resolveOrSyncMicrosoftIdentityByClaims(ctx context.Context, claims MicrosoftClaims) (*auth.UserIdentity, error) {
	subject := strings.TrimSpace(claims.Subject)
	if subject == "" {
		return nil, errors.New("microsoft subject is empty")
	}

	email := strings.ToLower(strings.TrimSpace(claims.Email))
	if email == "" {
		return nil, ErrSSOEmailNotFound
	}

	user, err := s.repo.GetUserBySSOEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	if user == nil || len(user.ID) != 16 {
		return nil, ErrSSONotLinked
	}

	existingBySubject, err := s.repo.GetIdentityByProviderSubject(ctx, ssoProviderMicrosoft, subject)
	if err != nil {
		return nil, err
	}
	if existingBySubject != nil && len(existingBySubject.UserID) == 16 && !bytes.Equal(existingBySubject.UserID, user.ID) {
		return nil, ErrSSOLinkedToAnotherUser
	}

	existingByUser, err := s.repo.GetIdentityByUserID(ctx, user.ID)
	if err != nil {
		return nil, err
	}
	if existingByUser != nil && existingByUser.Provider == ssoProviderMicrosoft && strings.TrimSpace(existingByUser.ProviderSubject) != "" && !strings.EqualFold(existingByUser.ProviderSubject, subject) {
		if _, err := s.repo.DeleteIdentityByUserIDProvider(ctx, user.ID, ssoProviderMicrosoft); err != nil {
			return nil, err
		}
	}

	identity := &auth.UserIdentity{
		UserID:          append([]byte(nil), user.ID...),
		Provider:        ssoProviderMicrosoft,
		ProviderSubject: subject,
		Email:           email,
	}
	if name := strings.TrimSpace(claims.Name); name != "" {
		metadata, _ := json.Marshal(map[string]string{"name": name})
		identity.Metadata = metadata
	}
	if existingBySubject != nil && len(existingBySubject.ID) == 16 {
		identity.ID = append([]byte(nil), existingBySubject.ID...)
	}
	if err := s.repo.UpsertIdentity(ctx, identity); err != nil {
		return nil, err
	}

	createdIdentity, err := s.repo.GetIdentityByProviderSubject(ctx, ssoProviderMicrosoft, subject)
	if err != nil {
		return nil, err
	}
	if createdIdentity != nil {
		return createdIdentity, nil
	}
	return identity, nil
}

func (s *AuthService) UnlinkSSOForUser(ctx context.Context, userID []byte, provider string) error {
	if len(userID) != 16 {
		return ErrUserNotFound
	}
	provider = strings.ToLower(strings.TrimSpace(provider))
	if provider != ssoProviderMicrosoft {
		return ErrUnsupportedSSOProvider
	}
	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil || len(user.ID) != 16 {
		return ErrUserNotFound
	}
	identity, err := s.repo.GetIdentityByUserID(ctx, userID)
	if err != nil {
		return err
	}
	if identity == nil || !strings.EqualFold(identity.Provider, provider) {
		return ErrSSONotLinked
	}
	deleted, err := s.repo.DeleteIdentityByUserIDProvider(ctx, userID, provider)
	if err != nil {
		return err
	}
	if !deleted {
		return ErrSSONotLinked
	}
	return nil
}

// TOTP verify: decrypt secret and verify code (6 digits).
func (s *AuthService) VerifyTOTP(ctx context.Context, userID []byte, code string) (bool, error) {
	record, err := s.repo.GetUserTOTP(ctx, userID)
	if err != nil || record == nil || !record.Enabled {
		return false, nil
	}

	secret, err := s.protector.Decrypt(record.SecretEncrypted)
	if err != nil {
		return false, err
	}

	ok := verifyTOTP(string(secret), code)
	if ok {
		_ = s.repo.UpdateUserTOTPLastUsed(ctx, userID)
	}
	return ok, nil
}

func (s *AuthService) CreateTOTPChallenge(ctx context.Context, userID []byte) (string, error) {
	if s.tokens == nil {
		return "", errors.New("token store not configured")
	}
	token, err := randomToken(32)
	if err != nil {
		return "", err
	}
	if err := s.tokens.Set(ctx, totpChallengeKey(token), userID, s.cfg.TOTPChallengeTTL); err != nil {
		return "", err
	}
	return token, nil
}

func (s *AuthService) ConsumeTOTPChallenge(ctx context.Context, token string) ([]byte, error) {
	if s.tokens == nil || token == "" {
		return nil, ErrInvalidToken
	}
	data, err := s.tokens.Get(ctx, totpChallengeKey(token))
	if err != nil || data == nil {
		return nil, ErrInvalidToken
	}
	_ = s.tokens.Delete(ctx, totpChallengeKey(token))
	_ = s.tokens.Delete(ctx, totpChallengeAttemptKey(token))
	return data, nil
}

func (s *AuthService) ResolveTOTPChallenge(ctx context.Context, token string) ([]byte, error) {
	token = strings.TrimSpace(token)
	if s.tokens == nil || token == "" {
		return nil, ErrInvalidToken
	}
	data, err := s.tokens.Get(ctx, totpChallengeKey(token))
	if err != nil || data == nil {
		return nil, ErrInvalidToken
	}
	return data, nil
}

func (s *AuthService) InvalidateTOTPChallenge(ctx context.Context, token string) {
	token = strings.TrimSpace(token)
	if s.tokens == nil || token == "" {
		return
	}
	_ = s.tokens.Delete(ctx, totpChallengeKey(token))
	_ = s.tokens.Delete(ctx, totpChallengeAttemptKey(token))
}

func (s *AuthService) RegisterFailedTOTPChallengeAttempt(ctx context.Context, token string) (int, error) {
	token = strings.TrimSpace(token)
	if s.tokens == nil || token == "" {
		return 0, ErrInvalidToken
	}
	key := totpChallengeAttemptKey(token)
	raw, err := s.tokens.Get(ctx, key)
	if err != nil {
		return 0, err
	}
	attempts := 0
	if len(raw) > 0 {
		attempts, _ = strconv.Atoi(strings.TrimSpace(string(raw)))
	}
	attempts++
	if attempts >= totpChallengeMaxAttempts {
		s.InvalidateTOTPChallenge(ctx, token)
		return 0, ErrTOTPChallengeAttemptLimit
	}
	remaining := totpChallengeMaxAttempts - attempts
	if setErr := s.tokens.Set(ctx, key, []byte(strconv.Itoa(attempts)), s.cfg.TOTPChallengeTTL); setErr != nil {
		return 0, setErr
	}
	return remaining, nil
}

func (s *AuthService) TOTPChallengeMaxAttempts() int {
	return totpChallengeMaxAttempts
}

func (s *AuthService) CreateEmailOTPChallenge(ctx context.Context, userID []byte) (string, error) {
	if s.tokens == nil {
		return "", errors.New("token store not configured")
	}
	if len(userID) != 16 {
		return "", ErrUserNotFound
	}
	token, err := randomToken(32)
	if err != nil {
		return "", err
	}
	if err := s.tokens.Set(ctx, emailOTPChallengeKey(token), userID, s.cfg.EmailOTPChallengeTTL); err != nil {
		return "", err
	}
	return token, nil
}

func (s *AuthService) ResolveEmailOTPChallenge(ctx context.Context, token string) ([]byte, error) {
	token = strings.TrimSpace(token)
	if token == "" || s.tokens == nil {
		return nil, ErrInvalidToken
	}
	data, err := s.tokens.Get(ctx, emailOTPChallengeKey(token))
	if err != nil || len(data) != 16 {
		return nil, ErrInvalidToken
	}
	return data, nil
}

func (s *AuthService) SendLoginEmailOTP(ctx context.Context, challengeToken string) error {
	userID, err := s.ResolveEmailOTPChallenge(ctx, challengeToken)
	if err != nil {
		return ErrInvalidToken
	}
	now := time.Now().UTC()
	active, err := s.repo.GetActiveUserEmailLoginOTP(ctx, userID, now)
	if err != nil {
		return err
	}
	if active != nil && now.Sub(active.LastSentAt) < s.cfg.EmailOTPResendCooldown {
		s.logServiceAudit(ctx, "auth.email_otp.send", userID, false, "resend_cooldown", nil)
		return ErrEmailOTPRateLimited
	}
	count, err := s.repo.CountUserEmailLoginOTPSince(ctx, userID, now.Add(-5*time.Minute))
	if err != nil {
		return err
	}
	if count >= int64(s.cfg.EmailOTPMaxResendPerHour) {
		s.logServiceAudit(ctx, "auth.email_otp.send", userID, false, "resend_limit_exceeded", nil)
		return ErrEmailOTPResendExceeded
	}
	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil || !user.IsActive || strings.TrimSpace(user.Email) == "" {
		return ErrUserNotFound
	}
	otpCode, err := randomNumericCode(s.cfg.EmailOTPLength)
	if err != nil {
		return err
	}
	if err := s.repo.InvalidateActiveUserEmailLoginOTPs(ctx, userID, now); err != nil {
		return err
	}
	row := &auth.UserEmailLoginOTP{
		UserID:       append([]byte(nil), userID...),
		OTPHash:      hashEmailOTPCode(userID, otpCode, s.cfg.PasswordPepper),
		ExpiresAt:    now.Add(s.cfg.EmailOTPExpiry),
		MaxAttempts:  s.cfg.EmailOTPMaxAttempts,
		ResendCount:  int(count) + 1,
		LastSentAt:   now,
		AttemptCount: 0,
	}
	if err := s.repo.CreateUserEmailLoginOTP(ctx, row); err != nil {
		return err
	}
	job := s.buildLoginEmailOTPJob(ctx, user, otpCode)
	if err := s.dispatchPreparedEmailJob(ctx, job); err != nil {
		return err
	}
	s.logServiceAudit(ctx, "auth.email_otp.send", userID, true, "otp_sent", map[string]any{
		"resend_count": row.ResendCount,
	})
	return nil
}

func (s *AuthService) VerifyLoginEmailOTP(ctx context.Context, challengeToken, code string) (*auth.User, error) {
	userID, err := s.ResolveEmailOTPChallenge(ctx, challengeToken)
	if err != nil {
		return nil, ErrInvalidToken
	}
	code = strings.TrimSpace(code)
	if len(code) != s.cfg.EmailOTPLength {
		s.logServiceAudit(ctx, "auth.email_otp.verify", userID, false, "invalid_format", nil)
		return nil, ErrInvalidEmailOTP
	}
	for i := 0; i < len(code); i++ {
		if code[i] < '0' || code[i] > '9' {
			s.logServiceAudit(ctx, "auth.email_otp.verify", userID, false, "invalid_format", nil)
			return nil, ErrInvalidEmailOTP
		}
	}
	ok, attemptsLeft, err := s.repo.ConsumeUserEmailLoginOTP(ctx, userID, hashEmailOTPCode(userID, code, s.cfg.PasswordPepper), time.Now().UTC())
	if err != nil {
		return nil, err
	}
	if !ok {
		s.logServiceAudit(ctx, "auth.email_otp.verify", userID, false, "invalid_otp", map[string]any{
			"attempts_left": attemptsLeft,
		})
		return nil, ErrInvalidEmailOTP
	}
	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil || user == nil {
		return nil, ErrUserNotFound
	}
	_ = s.tokens.Delete(ctx, emailOTPChallengeKey(strings.TrimSpace(challengeToken)))
	s.logServiceAudit(ctx, "auth.email_otp.verify", userID, true, "otp_verified", nil)
	return user, nil
}

// SetupTOTP creates or replaces the user's TOTP secret and returns the provisioning URI.
func (s *AuthService) SetupTOTP(ctx context.Context, userID []byte, accountName string) (secret, otpAuthURL, issuer string, err error) {
	if s.protector == nil {
		return "", "", "", errors.New("secret protector not configured")
	}

	issuer = s.cfg.TOTPIssuer
	existing, err := s.repo.GetUserTOTP(ctx, userID)
	if err != nil {
		return "", "", "", err
	}
	if existing != nil && len(existing.SecretEncrypted) > 0 {
		secretBytes, err := s.protector.Decrypt(existing.SecretEncrypted)
		if err != nil {
			return "", "", "", err
		}
		secret = string(secretBytes)
		return secret, buildTOTPAuthURL(issuer, accountName, secret), issuer, nil
	}

	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      issuer,
		AccountName: accountName,
	})
	if err != nil {
		return "", "", "", err
	}

	secret = key.Secret()
	otpAuthURL = key.URL()

	enc, err := s.protector.Encrypt([]byte(secret))
	if err != nil {
		return "", "", "", err
	}

	record := &auth.UserTOTP{
		UserID:          userID,
		SecretEncrypted: enc,
		Enabled:         false,
	}
	if err := s.repo.UpsertUserTOTP(ctx, record); err != nil {
		return "", "", "", err
	}

	return secret, otpAuthURL, issuer, nil
}

func (s *AuthService) DisableTOTP(ctx context.Context, userID []byte) error {
	return s.repo.DisableUserTOTP(ctx, userID)
}

func (s *AuthService) ResetTOTPSetup(ctx context.Context, userID []byte, method, pin, password string) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	if err := s.authorizeWithPINOrPassword(ctx, userID, method, pin, password); err != nil {
		return err
	}
	return s.DeleteTOTPSetup(ctx, userID)
}

func (s *AuthService) ResetWebAuthnSetup(ctx context.Context, userID []byte, method, pin, password string) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	if err := s.authorizeWithPINOrPassword(ctx, userID, method, pin, password); err != nil {
		return err
	}
	return s.DeleteWebAuthnSetup(ctx, userID)
}

func (s *AuthService) DeleteTOTPSetup(ctx context.Context, userID []byte) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	if err := s.repo.DeleteUserTOTPSetup(ctx, userID); err != nil {
		return err
	}
	s.logServiceAudit(ctx, "auth.totp.reset_setup", userID, true, "totp_setup_deleted", nil)
	return nil
}

func (s *AuthService) DeleteWebAuthnSetup(ctx context.Context, userID []byte) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	if err := s.repo.DeleteAllUserWebAuthnCredentials(ctx, userID); err != nil {
		return err
	}
	s.logServiceAudit(ctx, "auth.webauthn.reset_setup", userID, true, "webauthn_credentials_deleted", nil)
	return nil
}

func (s *AuthService) authorizeWithPINOrPassword(ctx context.Context, userID []byte, method, pin, password string) error {
	switch strings.ToLower(strings.TrimSpace(method)) {
	case "pin":
		return s.verifySecurityPIN(ctx, userID, strings.TrimSpace(pin))
	case "password":
		user, err := s.repo.GetUserByID(ctx, userID)
		if err != nil {
			return err
		}
		if user == nil {
			return ErrInvalidToken
		}
		if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) {
			return ErrInvalidCredentials
		}
		return nil
	default:
		return ErrInvalidReauthMethod
	}
}

func (s *AuthService) consumeMicrosoftReauthToken(ctx context.Context, userID []byte, purpose, token string) error {
	userIDFromToken, err := s.consumeMicrosoftReauthTokenForPurposes(ctx, []string{purpose}, token)
	if err != nil {
		return ErrInvalidSSOReauth
	}
	if !subtleConstantTimeCompare(userIDFromToken, userID) {
		return ErrInvalidSSOReauth
	}
	return nil
}

func (s *AuthService) consumeMicrosoftReauthTokenForPurposes(ctx context.Context, purposes []string, token string) ([]byte, error) {
	if s.tokens == nil || len(purposes) == 0 {
		return nil, ErrInvalidSSOReauth
	}
	validPurposes := make([]string, 0, len(purposes))
	for _, purpose := range purposes {
		purpose = strings.TrimSpace(purpose)
		if !isSupportedMicrosoftReauthPurpose(purpose) {
			return nil, ErrInvalidSSOReauth
		}
		validPurposes = append(validPurposes, purpose)
	}
	token = strings.TrimSpace(token)
	if token == "" {
		return nil, ErrInvalidSSOReauth
	}
	raw, err := s.tokens.Get(ctx, microsoftReauthKey(token))
	if err != nil || raw == nil {
		return nil, ErrInvalidSSOReauth
	}
	_ = s.tokens.Delete(ctx, microsoftReauthKey(token))
	parts := strings.SplitN(string(raw), "|", 2)
	if len(parts) != 2 {
		return nil, ErrInvalidSSOReauth
	}
	userID, err := uuidv7.ParseString(parts[0])
	if err != nil || len(userID) != 16 {
		return nil, ErrInvalidSSOReauth
	}
	for _, purpose := range validPurposes {
		expected := parts[0] + "|" + purpose
		if subtleConstantTimeCompare(raw, []byte(expected)) {
			return userID, nil
		}
	}
	return nil, ErrInvalidSSOReauth
}

func (s *AuthService) ConfirmTOTP(ctx context.Context, userID []byte, code string) (bool, error) {
	record, err := s.repo.GetUserTOTP(ctx, userID)
	if err != nil || record == nil {
		return false, ErrInvalidToken
	}
	secret, err := s.protector.Decrypt(record.SecretEncrypted)
	if err != nil {
		return false, err
	}
	ok := verifyTOTP(string(secret), code)
	if !ok {
		return false, nil
	}
	record.Enabled = true
	if err := s.repo.UpsertUserTOTP(ctx, record); err != nil {
		return false, err
	}
	return true, nil
}

func (s *AuthService) sendEmailVerification(ctx context.Context, user *auth.User) error {
	job, err := s.buildEmailVerificationJob(ctx, user)
	if err != nil {
		return err
	}
	return s.dispatchPreparedEmailJob(ctx, job)
}

func (s *AuthService) buildEmailVerificationJob(ctx context.Context, user *auth.User) (EmailJob, error) {
	if s.tokens == nil || s.cfg.EmailVerifyURL == "" {
		return EmailJob{}, nil
	}

	token, err := randomToken(32)
	if err != nil {
		return EmailJob{}, err
	}

	if err := s.tokens.Set(ctx, emailVerifyKey(token), user.ID, s.cfg.EmailVerifyTTL); err != nil {
		return EmailJob{}, err
	}

	link := strings.TrimRight(s.cfg.EmailVerifyURL, "/") + "?token=" + token
	return EmailJob{
		MessageType:  "email_verify",
		TemplateName: "auth.email_verify",
		To:           user.Email,
		Subject:      "Verify your email",
		TextBody:     "Click this link to verify your email: " + link,
		Tags: map[string]string{
			"message_type": "email_verify",
			"template":     "auth_email_verify",
		},
	}, nil
}

func (s *AuthService) sendInviteSetPassword(ctx context.Context, user *auth.User) error {
	job, err := s.buildInviteSetPasswordJob(ctx, user)
	if err != nil {
		return err
	}
	return s.dispatchPreparedEmailJob(ctx, job)
}

func (s *AuthService) buildInviteSetPasswordJob(ctx context.Context, user *auth.User) (EmailJob, error) {
	if s.tokens == nil || s.cfg.SetPasswordURL == "" {
		return EmailJob{}, nil
	}
	token := ""
	storeKey := ""
	if s.cfg.JWTAccessSecret != "" {
		var jti string
		var err error
		token, jti, err = s.issueInviteJWT(user)
		if err != nil {
			return EmailJob{}, err
		}
		storeKey = inviteKey(jti)
		if err := s.tokens.Set(ctx, storeKey, user.ID, s.cfg.InviteTTL); err != nil {
			return EmailJob{}, err
		}
	} else {
		// Backward compatibility when JWT secret isn't configured.
		var err error
		token, err = randomToken(32)
		if err != nil {
			return EmailJob{}, err
		}
		storeKey = inviteKey(token)
		if err := s.tokens.Set(ctx, storeKey, user.ID, s.cfg.InviteTTL); err != nil {
			return EmailJob{}, err
		}
	}

	link := strings.TrimRight(s.cfg.SetPasswordURL, "/") + "?token=" + token
	subject, textBody, htmlBody := buildInviteSetPasswordEmail(user, link, s.cfg.InviteTTL)
	return EmailJob{
		MessageType:  "invite_set_password",
		TemplateName: "auth.invite_set_password",
		To:           user.Email,
		Subject:      subject,
		TextBody:     textBody,
		HTMLBody:     htmlBody,
		Tags: map[string]string{
			"message_type": "invite_set_password",
			"template":     "auth_invite_set_password",
		},
	}, nil
}

func (s *AuthService) buildLoginEmailOTPJob(ctx context.Context, user *auth.User, code string) EmailJob {
	email := buildLoginOTPEmail(ctx, s.emailBranding, user.FirstName, code, s.cfg.EmailOTPExpiry)
	return EmailJob{
		To:           user.Email,
		MessageType:  "login_email_otp",
		TemplateName: "auth.login_email_otp",
		TemplateData: map[string]any{"otp_code": code},
		Subject:      email.Subject,
		TextBody:     email.TextBody,
		HTMLBody:     email.HTMLBody,
		Attachments:  email.Attachments,
		Tags: map[string]string{
			"message_type": "login_email_otp",
			"template":     "auth_login_email_otp",
		},
	}
}

func (s *AuthService) issueInviteJWT(user *auth.User) (token, jti string, err error) {
	if user == nil || len(user.ID) != 16 {
		return "", "", ErrInvalidToken
	}
	userID, convErr := uuidv7.BytesToString(user.ID)
	if convErr != nil {
		return "", "", convErr
	}
	jti, err = randomToken(24)
	if err != nil {
		return "", "", err
	}
	now := time.Now().UTC()
	claims := jwt.MapClaims{
		"type":  "invite_set_password",
		"sub":   userID,
		"email": strings.ToLower(strings.TrimSpace(user.Email)),
		"jti":   jti,
		"iat":   now.Unix(),
		"exp":   now.Add(s.cfg.InviteTTL).Unix(),
	}
	token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.cfg.JWTAccessSecret))
	if err != nil {
		return "", "", err
	}
	return token, jti, nil
}

func (s *AuthService) parseInviteJWT(token string) (userID []byte, jti, email string, err error) {
	if strings.TrimSpace(token) == "" || s.cfg.JWTAccessSecret == "" {
		return nil, "", "", ErrInvalidToken
	}
	parsed, parseErr := jwt.Parse(token, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, ErrInvalidToken
		}
		return []byte(s.cfg.JWTAccessSecret), nil
	})
	if parseErr != nil || !parsed.Valid {
		return nil, "", "", ErrInvalidToken
	}
	claims, ok := parsed.Claims.(jwt.MapClaims)
	if !ok {
		return nil, "", "", ErrInvalidToken
	}
	if claims["type"] != "invite_set_password" {
		return nil, "", "", ErrInvalidToken
	}
	sub, _ := claims["sub"].(string)
	email, _ = claims["email"].(string)
	jti, _ = claims["jti"].(string)
	if sub == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(jti) == "" {
		return nil, "", "", ErrInvalidToken
	}
	userID, err = uuidv7.ParseString(sub)
	if err != nil {
		return nil, "", "", ErrInvalidToken
	}
	return userID, jti, strings.ToLower(strings.TrimSpace(email)), nil
}

func (s *AuthService) createAndSendPasswordReset(ctx context.Context, user *auth.User, requestedIP, userAgent string) error {
	if strings.TrimSpace(s.cfg.PasswordResetURL) == "" {
		log.Printf("auth forgot-password email skipped: AUTH_PASSWORD_RESET_URL is empty")
		return nil
	}

	rawToken, err := randomToken(32)
	if err != nil {
		return err
	}

	now := time.Now().UTC()
	expiresAt := now.Add(s.cfg.PasswordResetTTL)
	record := &auth.PasswordResetToken{
		UserID:      user.ID,
		TokenHash:   hashToken(rawToken),
		ExpiresAt:   expiresAt,
		RequestedIP: sanitizeIP(requestedIP, s.ipProtector),
		UserAgent:   truncate(strings.TrimSpace(userAgent), 255),
	}

	link := buildResetLink(s.cfg.PasswordResetURL, rawToken)
	if link == "" {
		return errors.New("invalid password reset url")
	}
	email := buildPasswordResetEmail(ctx, s.emailBranding, user.FirstName, link, s.cfg.PasswordResetTTL)
	job := EmailJob{
		MessageType:  "password_reset",
		TemplateName: "auth.password_reset",
		To:           user.Email,
		Subject:      email.Subject,
		TextBody:     email.TextBody,
		HTMLBody:     email.HTMLBody,
		Attachments:  email.Attachments,
		Tags: map[string]string{
			"message_type": "password_reset",
			"template":     "auth_password_reset",
		},
	}

	if txRepo, ok := s.repo.(auth.TransactionalRepository); ok && s.emailOutboxReady() {
		return txRepo.WithTransaction(ctx, func(repo auth.Repository, outbox auth.EmailOutboxWriter) error {
			if err := repo.InvalidateActivePasswordResetTokensByUserID(ctx, user.ID, now); err != nil {
				return err
			}
			if err := repo.CreatePasswordResetToken(ctx, record); err != nil {
				return err
			}
			return s.persistEmailOutbox(ctx, outbox, job)
		})
	}

	if err := s.repo.InvalidateActivePasswordResetTokensByUserID(ctx, user.ID, now); err != nil {
		return err
	}
	if err := s.repo.CreatePasswordResetToken(ctx, record); err != nil {
		return err
	}
	return s.dispatchPreparedEmailJob(ctx, job)
}

func (s *AuthService) createAndSendSecurityPINReset(ctx context.Context, user *auth.User, requestedIP, userAgent string) error {
	if strings.TrimSpace(s.cfg.SecurityPINResetURL) == "" {
		log.Printf("auth pin-reset email skipped: AUTH_SECURITY_PIN_RESET_URL is empty")
		return nil
	}

	rawToken, err := randomToken(32)
	if err != nil {
		return err
	}

	now := time.Now().UTC()
	expiresAt := now.Add(s.cfg.SecurityPINResetTTL)
	record := &auth.SecurityPINResetToken{
		UserID:      user.ID,
		TokenHash:   hashToken(rawToken),
		ExpiresAt:   expiresAt,
		RequestedIP: sanitizeIP(requestedIP, s.ipProtector),
		UserAgent:   truncate(strings.TrimSpace(userAgent), 255),
	}

	link := buildResetLink(s.cfg.SecurityPINResetURL, rawToken)
	if link == "" {
		return errors.New("invalid security pin reset url")
	}
	email := buildSecurityPINResetEmail(ctx, s.emailBranding, user.FirstName, link, s.cfg.SecurityPINResetTTL)
	job := EmailJob{
		MessageType:  "security_pin_reset",
		TemplateName: "auth.security_pin_reset",
		To:           user.Email,
		Subject:      email.Subject,
		TextBody:     email.TextBody,
		HTMLBody:     email.HTMLBody,
		Attachments:  email.Attachments,
		Tags: map[string]string{
			"message_type": "security_pin_reset",
			"template":     "auth_security_pin_reset",
		},
	}

	if txRepo, ok := s.repo.(auth.TransactionalRepository); ok && s.emailOutboxReady() {
		return txRepo.WithTransaction(ctx, func(repo auth.Repository, outbox auth.EmailOutboxWriter) error {
			if err := repo.InvalidateActiveSecurityPINResetTokensByUserID(ctx, user.ID, now); err != nil {
				return err
			}
			if err := repo.CreateSecurityPINResetToken(ctx, record); err != nil {
				return err
			}
			return s.persistEmailOutbox(ctx, outbox, job)
		})
	}

	if err := s.repo.InvalidateActiveSecurityPINResetTokensByUserID(ctx, user.ID, now); err != nil {
		return err
	}
	if err := s.repo.CreateSecurityPINResetToken(ctx, record); err != nil {
		return err
	}
	return s.dispatchPreparedEmailJob(ctx, job)
}

func (s *AuthService) isForgotPasswordCooldownActive(ctx context.Context, key, email, requestedIP string) bool {
	if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 {
		return false
	}
	v, err := s.tokens.Get(ctx, key)
	if err != nil {
		return false
	}
	if v != nil {
		s.auditForgotPasswordAnomaly(email, requestedIP, "cooldown_hit")
		return true
	}
	return false
}

func (s *AuthService) markForgotPasswordCooldown(ctx context.Context, key string) error {
	if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 {
		return nil
	}
	return s.tokens.Set(ctx, key, []byte("1"), s.cfg.ForgotPasswordEmailCooldown)
}

func (s *AuthService) isForgotSecurityPINCooldownActive(ctx context.Context, key string) bool {
	if s.tokens == nil || s.cfg.ForgotSecurityPINCooldown <= 0 {
		return false
	}
	v, err := s.tokens.Get(ctx, key)
	if err != nil {
		return false
	}
	return v != nil
}

func (s *AuthService) markForgotSecurityPINCooldown(ctx context.Context, key string) error {
	if s.tokens == nil || s.cfg.ForgotSecurityPINCooldown <= 0 {
		return nil
	}
	return s.tokens.Set(ctx, key, []byte("1"), s.cfg.ForgotSecurityPINCooldown)
}

func (s *AuthService) isInviteSetPasswordResendCooldownActive(ctx context.Context, key string) bool {
	if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 {
		return false
	}
	v, err := s.tokens.Get(ctx, key)
	if err != nil {
		return false
	}
	return v != nil
}

func (s *AuthService) markInviteSetPasswordResendCooldown(ctx context.Context, key string) error {
	if s.tokens == nil || s.cfg.ForgotPasswordEmailCooldown <= 0 {
		return nil
	}
	return s.tokens.Set(ctx, key, []byte("1"), s.cfg.ForgotPasswordEmailCooldown)
}

func (s *AuthService) verifySecurityPIN(ctx context.Context, userID []byte, pin string) error {
	if len(userID) != 16 {
		return ErrInvalidSecurityPIN
	}
	if err := validateSecurityPIN(pin); err != nil {
		return err
	}
	if s.isSecurityPINLocked(ctx, userID) {
		s.logServiceAudit(ctx, "auth.pin.verify", userID, false, "blocked", nil)
		return ErrSecurityPINLocked
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil {
		return err
	}
	if user == nil {
		return ErrInvalidToken
	}
	if len(user.SecurityPINHash) == 0 {
		return ErrSecurityPINNotSet
	}

	pinVerified := false
	if hash, salt, ok := decodeSecurityPINRecord(user.SecurityPINHash); ok {
		pinVerified = s.verifyPassword(pin, hash, salt)
	} else {
		// Backward-compatibility path for legacy encrypted PIN values.
		if s.protector == nil {
			return errors.New("secret protector not configured")
		}
		storedPIN, err := s.protector.Decrypt(user.SecurityPINHash)
		if err != nil {
			return err
		}
		pinVerified = subtleConstantTimeCompare(storedPIN, []byte(pin))
	}
	if pinVerified {
		_ = s.clearSecurityPINLockState(ctx, userID)
		return nil
	}

	reachedLimit, _ := s.increaseSecurityPINFailedAttempt(ctx, userID)
	if reachedLimit {
		s.logServiceAudit(ctx, "auth.pin.verify", userID, false, "max_attempts_reached_blocked", nil)
		return ErrSecurityPINAttemptLimit
	}
	s.logServiceAudit(ctx, "auth.pin.verify", userID, false, "invalid_pin", nil)
	return ErrInvalidSecurityPIN
}

func (s *AuthService) isSecurityPINLocked(ctx context.Context, userID []byte) bool {
	if s.tokens == nil {
		return false
	}
	v, err := s.tokens.Get(ctx, securityPINLockKey(userID))
	if err != nil {
		return false
	}
	return v != nil
}

func (s *AuthService) increaseSecurityPINFailedAttempt(ctx context.Context, userID []byte) (bool, error) {
	if s.tokens == nil || s.cfg.SecurityPINMaxAttempts <= 0 {
		return false, nil
	}

	key := securityPINAttemptKey(userID)
	current := 0
	raw, err := s.tokens.Get(ctx, key)
	if err == nil && raw != nil {
		if parsed, parseErr := strconv.Atoi(string(raw)); parseErr == nil && parsed >= 0 {
			current = parsed
		}
	}
	current++
	if current >= s.cfg.SecurityPINMaxAttempts {
		lockTTL := s.cfg.SecurityPINLockDuration
		if lockTTL < 0 {
			lockTTL = 0
		}
		_ = s.tokens.Set(ctx, securityPINLockKey(userID), []byte("1"), lockTTL)
		current = 0
		if err := s.tokens.Set(ctx, key, []byte(strconv.Itoa(current)), lockTTL); err != nil {
			return true, err
		}
		return true, nil
	}
	lockTTL := s.cfg.SecurityPINLockDuration
	if lockTTL < 0 {
		lockTTL = 0
	}
	return false, s.tokens.Set(ctx, key, []byte(strconv.Itoa(current)), lockTTL)
}

func (s *AuthService) clearSecurityPINLockState(ctx context.Context, userID []byte) error {
	if s.tokens == nil {
		return nil
	}
	_ = s.tokens.Delete(ctx, securityPINAttemptKey(userID))
	_ = s.tokens.Delete(ctx, securityPINLockKey(userID))
	return nil
}

func (s *AuthService) sessionVersion(ctx context.Context, userID []byte) (int64, error) {
	if s.tokens == nil || len(userID) == 0 {
		return 0, nil
	}
	raw, err := s.tokens.Get(ctx, sessionVersionKey(userID))
	if err != nil || len(raw) == 0 {
		return 0, err
	}
	n, err := strconv.ParseInt(string(raw), 10, 64)
	if err != nil || n < 0 {
		return 0, ErrInvalidToken
	}
	return n, nil
}

func (s *AuthService) bumpSessionVersion(ctx context.Context, userID []byte) error {
	if s.tokens == nil || len(userID) == 0 {
		return nil
	}
	current, err := s.sessionVersion(ctx, userID)
	if err != nil {
		return err
	}
	next := strconv.FormatInt(current+1, 10)
	return s.tokens.Set(ctx, sessionVersionKey(userID), []byte(next), 0)
}

func (s *AuthService) validSessionVersion(ctx context.Context, userID []byte, claim any) bool {
	if s.tokens == nil || len(userID) == 0 {
		return false
	}
	current, err := s.sessionVersion(ctx, userID)
	if err != nil {
		return false
	}
	return current == int64Claim(claim)
}

func (s *AuthService) auditForgotPasswordAnomaly(email, requestedIP, reason string) {
	log.Printf("auth forgot-password %s email_sha256=%s ip=%s", reason, shortEmailHash(email), sanitizeIP(requestedIP, s.ipProtector))
}

func (s *AuthService) logServiceAudit(ctx context.Context, action string, userID []byte, success bool, message string, metadata map[string]any) {
	if s.audit == nil {
		return
	}
	s.audit.Log(ctx, AuditLogInput{
		Layer:       "service",
		Module:      "auth",
		Action:      action,
		ActorUserID: userID,
		Success:     success,
		Message:     message,
		Metadata:    metadata,
	})
}

func (s *AuthService) hashPassword(password string) (hash []byte, salt []byte, err error) {
	salt = make([]byte, 16)
	if _, err = rand.Read(salt); err != nil {
		return nil, nil, err
	}
	peppered := []byte(password + s.cfg.PasswordPepper)
	hash = argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen)
	return hash, salt, nil
}

func (s *AuthService) verifyPassword(password string, hash, salt []byte) bool {
	if len(hash) == 0 || len(salt) == 0 {
		return false
	}
	peppered := []byte(password + s.cfg.PasswordPepper)
	candidate := argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen)
	return subtleConstantTimeCompare(hash, candidate)
}

func (s *AuthService) hashSecurityPIN(pin string) ([]byte, error) {
	hash, salt, err := s.hashPassword(pin)
	if err != nil {
		return nil, err
	}
	return encodeSecurityPINRecord(hash, salt), nil
}

func encodeSecurityPINRecord(hash, salt []byte) []byte {
	if len(hash) == 0 || len(salt) == 0 {
		return nil
	}
	return []byte(
		base64.RawURLEncoding.EncodeToString(salt) +
			"$" +
			base64.RawURLEncoding.EncodeToString(hash),
	)
}

func decodeSecurityPINRecord(v []byte) (hash, salt []byte, ok bool) {
	raw := strings.TrimSpace(string(v))
	if raw == "" {
		return nil, nil, false
	}
	parts := strings.Split(raw, "$")
	var saltPart, hashPart string
	switch {
	case len(parts) == 2:
		saltPart = parts[0]
		hashPart = parts[1]
	case len(parts) == 3 && parts[0] == "v1":
		// Backward compatibility for older records.
		saltPart = parts[1]
		hashPart = parts[2]
	default:
		return nil, nil, false
	}
	salt, err := base64.RawURLEncoding.DecodeString(saltPart)
	if err != nil || len(salt) == 0 {
		return nil, nil, false
	}
	hash, err = base64.RawURLEncoding.DecodeString(hashPart)
	if err != nil || len(hash) == 0 {
		return nil, nil, false
	}
	return hash, salt, true
}

func randomToken(n int) (string, error) {
	b := make([]byte, n)
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func hashToken(raw string) []byte {
	sum := sha256.Sum256([]byte(raw))
	return sum[:]
}

func hashEmailOTPCode(userID []byte, code, pepper string) []byte {
	payload := bytes.NewBuffer(nil)
	payload.Write(userID)
	payload.WriteString(":")
	payload.WriteString(strings.TrimSpace(code))
	payload.WriteString(":")
	payload.WriteString(strings.TrimSpace(pepper))
	sum := sha256.Sum256(payload.Bytes())
	return sum[:]
}

func randomNumericCode(length int) (string, error) {
	if length <= 0 {
		length = 6
	}
	buf := make([]byte, length)
	if _, err := io.ReadFull(rand.Reader, buf); err != nil {
		return "", err
	}
	for i := range buf {
		buf[i] = '0' + (buf[i] % 10)
	}
	return string(buf), nil
}

func hashIdentifier(raw string) string {
	sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(raw))))
	return hex.EncodeToString(sum[:])
}

func emailVerifyKey(token string) string {
	return "auth:email_verify:" + token
}

const (
	ssoProviderMicrosoft                  = "microsoft"
	microsoftStateFlowLogin               = "login"
	microsoftStateFlowLoginCheck          = "login_check"
	microsoftStateFlowLoginCheckStrict    = "login_check_strict"
	microsoftStateFlowLink                = "link"
	microsoftStateFlowReauth              = "reauth"
	pinResetMethodPassword                = "password"
	pinResetMethodMicrosoft               = "microsoft"
	microsoftReauthPurposePINResetRequest = "pin_reset_request"
	microsoftReauthPurposePINResetConfirm = "pin_reset_confirm"
)

func withMicrosoftAuthPrompt(rawURL, prompt string) string {
	rawURL = strings.TrimSpace(rawURL)
	prompt = strings.TrimSpace(prompt)
	if rawURL == "" || prompt == "" {
		return rawURL
	}
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return rawURL
	}
	q := parsed.Query()
	q.Set("prompt", prompt)
	parsed.RawQuery = q.Encode()
	return parsed.String()
}

func withMicrosoftPKCE(rawURL, codeVerifier string) string {
	rawURL = strings.TrimSpace(rawURL)
	codeVerifier = strings.TrimSpace(codeVerifier)
	if rawURL == "" || codeVerifier == "" {
		return rawURL
	}
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return rawURL
	}
	q := parsed.Query()
	q.Set("code_challenge_method", "S256")
	q.Set("code_challenge", microsoftPKCEChallengeS256(codeVerifier))
	parsed.RawQuery = q.Encode()
	return parsed.String()
}

func ssoStateKey(state string) string {
	return "auth:sso_state:" + state
}

func microsoftReauthKey(token string) string {
	return "auth:microsoft_reauth:" + token
}

func refreshKey(jti string) string {
	return "auth:refresh:" + jti
}

func refreshReplayKey(jti string) string {
	return "auth:refresh:replay:" + jti
}

func refreshLockKey(sessionID string) string {
	return "auth:refresh:lock:" + sessionID
}

func totpChallengeKey(token string) string {
	return "auth:totp_challenge:" + token
}

func totpChallengeAttemptKey(token string) string {
	return "auth:totp_challenge_attempt:" + token
}

func emailOTPChallengeKey(token string) string {
	return "auth:email_otp_challenge:" + token
}

func inviteKey(token string) string {
	return "auth:invite:" + token
}

func forgotPasswordCooldownKey(email string) string {
	return "auth:forgot_password:cooldown:" + hashIdentifier(email)
}

func forgotSecurityPINCooldownKey(email string) string {
	return "auth:security_pin:cooldown:" + hashIdentifier(email)
}

func inviteSetPasswordResendCooldownKey(userID []byte) string {
	return "auth:invite:resend:cooldown:" + bytesToString(userID)
}

func sessionVersionKey(userID []byte) string {
	return "auth:session_version:" + bytesToString(userID)
}

func pinActionKey(token string) string {
	return "auth:pin_action:" + token
}

func securityPINAttemptKey(userID []byte) string {
	return "auth:security_pin:attempt:" + bytesToString(userID)
}

func securityPINLockKey(userID []byte) string {
	return "auth:security_pin:lock:" + bytesToString(userID)
}

func encodeMicrosoftStatePayload(payload microsoftStatePayload) ([]byte, error) {
	return json.Marshal(payload)
}

func decodeMicrosoftStatePayload(raw []byte) (microsoftStatePayload, error) {
	if len(raw) == 1 && raw[0] == '1' {
		return microsoftStatePayload{Flow: microsoftStateFlowLogin}, nil
	}
	var payload microsoftStatePayload
	if err := json.Unmarshal(raw, &payload); err != nil {
		return microsoftStatePayload{}, err
	}
	if payload.Flow == "" {
		return microsoftStatePayload{}, ErrInvalidSSOState
	}
	return payload, nil
}

func (s *AuthService) loadMicrosoftStatePayload(ctx context.Context, state string) (microsoftStatePayload, error) {
	state = strings.TrimSpace(state)
	if state == "" || s.tokens == nil {
		return microsoftStatePayload{}, ErrInvalidSSOState
	}
	raw, err := s.tokens.Get(ctx, ssoStateKey(state))
	if err != nil || raw == nil {
		return microsoftStatePayload{}, ErrInvalidSSOState
	}
	payload, err := decodeMicrosoftStatePayload(raw)
	if err != nil {
		return microsoftStatePayload{}, ErrInvalidSSOState
	}
	return payload, nil
}

func isSupportedMicrosoftReauthPurpose(purpose string) bool {
	switch strings.TrimSpace(purpose) {
	case microsoftReauthPurposePINResetRequest, microsoftReauthPurposePINResetConfirm:
		return true
	default:
		return false
	}
}

func normalizePINResetMethod(method string) string {
	method = strings.ToLower(strings.TrimSpace(method))
	if method == "" {
		return pinResetMethodPassword
	}
	return method
}

func (s *AuthService) microsoftReauthTTL() time.Duration {
	if s.cfg.SSOStateTTL > 0 {
		return s.cfg.SSOStateTTL
	}
	return 5 * time.Minute
}

func (s *AuthService) exchangeMicrosoftCode(ctx context.Context, code, codeVerifier string) (MicrosoftClaims, *MicrosoftTokenSet, error) {
	if advanced, ok := s.sso.(microsoftTokenExchanger); ok {
		set, claims, err := advanced.ExchangeCodeAndTokens(ctx, code, codeVerifier)
		if err != nil {
			return MicrosoftClaims{}, nil, err
		}
		return claims, &set, nil
	}
	claims, err := s.sso.ExchangeCode(ctx, code)
	if err != nil {
		return MicrosoftClaims{}, nil, err
	}
	return claims, nil, nil
}

func (s *AuthService) storeMicrosoftOAuthToken(ctx context.Context, userID []byte, claims MicrosoftClaims, tokenSet MicrosoftTokenSet) error {
	repo, ok := s.repo.(microsoftOAuthTokenStore)
	if !ok {
		return errors.New("microsoft oauth token store is not configured")
	}
	if s.protector == nil {
		return errors.New("secret protector not configured")
	}
	tenantID := strings.TrimSpace(claims.TenantID)
	if tenantID == "" {
		return errors.New("missing microsoft tenant id")
	}
	objectID := strings.TrimSpace(claims.ObjectID)
	if objectID == "" {
		return errors.New("missing microsoft object id")
	}
	refreshToken := strings.TrimSpace(tokenSet.RefreshToken)
	if refreshToken == "" {
		return errors.New("missing refresh token")
	}
	encRefresh, err := s.protector.Encrypt([]byte(refreshToken))
	if err != nil {
		return err
	}
	var encAccess []byte
	if strings.TrimSpace(tokenSet.AccessToken) != "" {
		encAccess, err = s.protector.Encrypt([]byte(strings.TrimSpace(tokenSet.AccessToken)))
		if err != nil {
			return err
		}
	}
	var encID []byte
	if strings.TrimSpace(tokenSet.IDToken) != "" {
		encID, err = s.protector.Encrypt([]byte(strings.TrimSpace(tokenSet.IDToken)))
		if err != nil {
			return err
		}
	}
	now := time.Now().UTC()
	var expAt *time.Time
	if tokenSet.ExpiresIn > 0 {
		t := now.Add(time.Duration(tokenSet.ExpiresIn) * time.Second)
		expAt = &t
	}
	var refreshExpAt *time.Time
	if tokenSet.RefreshTokenExpiresIn > 0 {
		t := now.Add(time.Duration(tokenSet.RefreshTokenExpiresIn) * time.Second)
		refreshExpAt = &t
	}
	return repo.UpsertMicrosoftOAuthToken(ctx, &auth.UserMicrosoftOAuthToken{
		UserID:                append([]byte(nil), userID...),
		Provider:              ssoProviderMicrosoft,
		TenantID:              tenantID,
		MicrosoftObjectID:     objectID,
		MicrosoftSubject:      strings.TrimSpace(claims.Subject),
		MicrosoftSessionID:    strings.TrimSpace(claims.SessionID),
		RefreshTokenEncrypted: encRefresh,
		AccessTokenEncrypted:  encAccess,
		IDTokenEncrypted:      encID,
		Scope:                 strings.TrimSpace(tokenSet.Scope),
		TokenType:             strings.TrimSpace(tokenSet.TokenType),
		AccessTokenExpiresAt:  expAt,
		RefreshTokenExpiresAt: refreshExpAt,
		RefreshTokenRotatedAt: &now,
	})
}

func (s *AuthService) RefreshMicrosoftAccessToken(ctx context.Context, userID []byte) (MicrosoftTokenSet, error) {
	if len(userID) != 16 {
		return MicrosoftTokenSet{}, ErrInvalidToken
	}
	advanced, ok := s.sso.(microsoftTokenExchanger)
	if !ok {
		return MicrosoftTokenSet{}, errors.New("microsoft refresh is not supported")
	}
	if s.protector == nil {
		return MicrosoftTokenSet{}, errors.New("secret protector not configured")
	}
	repo, ok := s.repo.(microsoftOAuthTokenStore)
	if !ok {
		return MicrosoftTokenSet{}, errors.New("microsoft oauth token store is not configured")
	}
	record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
	if err != nil {
		return MicrosoftTokenSet{}, err
	}
	if record == nil || len(record.RefreshTokenEncrypted) == 0 {
		return MicrosoftTokenSet{}, ErrMicrosoftReauthRequired
	}
	rawRefresh, err := s.protector.Decrypt(record.RefreshTokenEncrypted)
	if err != nil || len(rawRefresh) == 0 {
		return MicrosoftTokenSet{}, ErrMicrosoftReauthRequired
	}
	set, err := advanced.RefreshAccessToken(ctx, string(rawRefresh), "")
	if err != nil {
		msg := strings.ToLower(strings.TrimSpace(err.Error()))
		if strings.Contains(msg, "invalid_grant") || strings.Contains(msg, "interaction_required") || strings.Contains(msg, "consent_required") {
			_, _ = repo.DeleteMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
			return MicrosoftTokenSet{}, ErrMicrosoftReauthRequired
		}
		return MicrosoftTokenSet{}, err
	}
	claims, parseErr := parseMicrosoftClaimsFromIDToken(set.IDToken)
	if parseErr != nil {
		return MicrosoftTokenSet{}, parseErr
	}
	if claims.Subject == "" {
		claims.Subject = record.MicrosoftSubject
	}
	if claims.TenantID == "" {
		claims.TenantID = record.TenantID
	}
	if claims.ObjectID == "" {
		claims.ObjectID = record.MicrosoftObjectID
	}
	if set.RefreshToken == "" {
		// Microsoft may not always rotate refresh token in response.
		set.RefreshToken = string(rawRefresh)
	}
	if err := s.storeMicrosoftOAuthToken(ctx, userID, claims, set); err != nil {
		return MicrosoftTokenSet{}, err
	}
	return set, nil
}

func (s *AuthService) LogoutMicrosoft(ctx context.Context, userID []byte) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	repo, ok := s.repo.(microsoftOAuthTokenStore)
	if !ok {
		return errors.New("microsoft oauth token store is not configured")
	}
	if _, err := repo.DeleteMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft); err != nil {
		return err
	}
	return s.RevokeAllUserSessions(ctx, userID)
}

func (s *AuthService) MicrosoftSessionIDForUser(ctx context.Context, userID []byte) (string, error) {
	if len(userID) != 16 {
		return "", ErrInvalidToken
	}
	repo, ok := s.repo.(microsoftOAuthTokenStore)
	if !ok {
		return "", errors.New("microsoft oauth token store is not configured")
	}
	record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
	if err != nil || record == nil {
		return "", err
	}
	return strings.TrimSpace(record.MicrosoftSessionID), nil
}

func msFirstNonEmpty(values ...string) string {
	for i := range values {
		if strings.TrimSpace(values[i]) != "" {
			return strings.TrimSpace(values[i])
		}
	}
	return ""
}

func parseJWTStringClaim(claims map[string]any, key string) string {
	if claims == nil {
		return ""
	}
	v, _ := claims[key].(string)
	return strings.TrimSpace(v)
}

func parseJWTUnixClaim(value any) int64 {
	switch v := value.(type) {
	case int64:
		return v
	case int:
		return int64(v)
	case float64:
		return int64(v)
	case json.Number:
		i, _ := v.Int64()
		return i
	default:
		return 0
	}
}

func bytesToString(b []byte) string {
	s, _ := uuidv7.BytesToString(b)
	return s
}

func splitName(full string) (string, string) {
	full = strings.TrimSpace(full)
	if full == "" {
		return "User", ""
	}
	parts := strings.Fields(full)
	if len(parts) == 1 {
		return parts[0], ""
	}
	return parts[0], strings.Join(parts[1:], " ")
}

func normalizeUsername(username string) (string, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return "", errors.New("username is required")
	}
	return username, nil
}

func (s *AuthService) nextAvailableUsernameFromEmail(ctx context.Context, email string) (string, error) {
	base := usernameBaseFromEmail(email)
	if base == "" {
		return "", errors.New("username is required")
	}
	for i := 1; i <= 999; i++ {
		candidate := base
		if i > 1 {
			suffix := strconv.Itoa(i)
			candidate = truncate(base, 100-len(suffix)) + suffix
		}
		existing, err := s.repo.GetUserByUsername(ctx, candidate)
		if err != nil {
			return "", err
		}
		if existing == nil {
			return candidate, nil
		}
	}
	return "", errors.New("unable to allocate username")
}

func usernameBaseFromEmail(email string) string {
	email = strings.ToLower(strings.TrimSpace(email))
	localPart, _, found := strings.Cut(email, "@")
	if !found {
		localPart = email
	}
	localPart = strings.Trim(localPart, ".")
	if localPart == "" {
		localPart = "user"
	}
	return truncate(localPart, 100)
}

func ptrString(v string) *string {
	if strings.TrimSpace(v) == "" {
		return nil
	}
	out := strings.TrimSpace(v)
	return &out
}

func ptrTime(t time.Time) *time.Time { return &t }

func subtleConstantTimeCompare(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	var v byte
	for i := 0; i < len(a); i++ {
		v |= a[i] ^ b[i]
	}
	return v == 0
}

func verifyTOTP(secret, code string) bool {
	code = strings.ReplaceAll(code, " ", "")
	ok, _ := totp.ValidateCustom(code, secret, time.Now().UTC(), totp.ValidateOpts{
		Period:    30,
		Skew:      1,
		Digits:    otp.DigitsSix,
		Algorithm: otp.AlgorithmSHA1,
	})
	return ok
}

func buildTOTPAuthURL(issuer, accountName, secret string) string {
	label := url.PathEscape(strings.TrimSpace(issuer) + ":" + strings.TrimSpace(accountName))
	q := url.Values{}
	q.Set("secret", strings.TrimSpace(secret))
	q.Set("issuer", strings.TrimSpace(issuer))
	return "otpauth://totp/" + label + "?" + q.Encode()
}

func int64Claim(v any) int64 {
	switch n := v.(type) {
	case nil:
		return 0
	case float64:
		return int64(n)
	case float32:
		return int64(n)
	case int:
		return int64(n)
	case int64:
		return n
	case int32:
		return int64(n)
	case uint:
		return int64(n)
	case uint64:
		return int64(n)
	case uint32:
		return int64(n)
	case string:
		parsed, err := strconv.ParseInt(n, 10, 64)
		if err != nil {
			return 0
		}
		return parsed
	default:
		return 0
	}
}

func boolClaim(v any) bool {
	switch b := v.(type) {
	case bool:
		return b
	case string:
		return strings.EqualFold(strings.TrimSpace(b), "true")
	default:
		return false
	}
}

func buildResetLink(baseURL, token string) string {
	u, err := url.Parse(strings.TrimSpace(baseURL))
	if err != nil || u.Scheme == "" || u.Host == "" {
		return ""
	}
	q := u.Query()
	q.Set("token", token)
	u.RawQuery = q.Encode()
	return u.String()
}

func buildInviteSetPasswordEmail(user *auth.User, link string, ttl time.Duration) (string, string, string) {
	minutes := int(ttl.Minutes())
	if minutes < 1 {
		minutes = 1440
	}
	displayName := strings.TrimSpace(strings.TrimSpace(user.FirstName + " " + user.LastName))
	if displayName == "" {
		displayName = "there"
	}
	subject := "Welcome to Wucher - set your password"
	textBody := strings.Join([]string{
		"Hi " + displayName + ",",
		"",
		"You've been invited to Wucher.",
		"Set your password using the link below:",
		link,
		"",
		"This invitation link expires in " + strconv.Itoa(minutes) + " minutes and can only be used once.",
		"If you did not expect this invitation, you can ignore this email.",
	}, "\n")
	escapedLink := html.EscapeString(link)
	escapedName := html.EscapeString(displayName)
	htmlBody := strings.Join([]string{
		"<!DOCTYPE html>",
		`<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">`,
		"<title>Set your password</title></head>",
		`<body style="margin:0;padding:0;background:#eef2f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#111827;">`,
		`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2f7;padding:28px 12px;">`,
		`<tr><td align="center">`,
		`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:680px;background:#ffffff;border:1px solid #e5e7eb;border-radius:16px;overflow:hidden;">`,
		`<tr><td style="padding:22px 28px;background:linear-gradient(135deg,#0f172a,#1f2937);">`,
		`<div style="font-size:20px;line-height:1.2;font-weight:700;color:#ffffff;">Wucher</div>`,
		`<div style="margin-top:6px;font-size:13px;line-height:1.5;color:#cbd5e1;">Account Invitation</div>`,
		`</td></tr>`,
		`<tr><td style="padding:30px 28px 8px;font-size:24px;line-height:1.3;font-weight:700;color:#0f172a;">Set your password to activate your account</td></tr>`,
		`<tr><td style="padding:0 28px 8px;font-size:15px;line-height:1.7;color:#334155;">Hi ` + escapedName + `, you've been invited to access Wucher. Click the button below to create your password and complete account activation.</td></tr>`,
		`<tr><td style="padding:20px 28px 10px;">`,
		`<a href="` + escapedLink + `" style="display:inline-block;background:#0f172a;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;letter-spacing:0.2px;padding:13px 22px;border-radius:10px;">Set Password</a>`,
		`</td></tr>`,
		`<tr><td style="padding:4px 28px 0;font-size:13px;line-height:1.7;color:#475569;">This invitation link expires in ` + strconv.Itoa(minutes) + ` minutes and can be used only once.</td></tr>`,
		`<tr><td style="padding:6px 28px 0;font-size:13px;line-height:1.7;color:#475569;">If the button does not work, copy this URL into your browser:</td></tr>`,
		`<tr><td style="padding:6px 28px 18px;word-break:break-all;font-size:12px;line-height:1.6;color:#0f172a;">` + escapedLink + `</td></tr>`,
		`<tr><td style="padding:14px 28px 24px;border-top:1px solid #e5e7eb;font-size:12px;line-height:1.7;color:#64748b;">For security reasons, never forward this email or share this link.</td></tr>`,
		`</table>`,
		`</td></tr></table>`,
		`</body></html>`,
	}, "")
	return subject, textBody, htmlBody
}

func shortEmailHash(email string) string {
	return hashIdentifier(email)[:12]
}

func shortIdentifier(id []byte) string {
	sum := sha256.Sum256(id)
	encoded := hex.EncodeToString(sum[:])
	if len(encoded) < 12 {
		return encoded
	}
	return encoded[:12]
}

func sanitizeIP(v string, protector SecretProtector) string {
	v = strings.TrimSpace(v)
	if v == "" {
		return ""
	}

	normalized := v
	if ip := net.ParseIP(v); ip != nil {
		normalized = ip.String()
	} else {
		normalized = truncate(v, 45)
	}

	if protector == nil {
		return ""
	}
	enc, err := protector.Encrypt([]byte(normalized))
	if err != nil {
		return ""
	}
	return base64.RawURLEncoding.EncodeToString(enc)
}

func decryptStoredIP(v string, protector SecretProtector) string {
	v = strings.TrimSpace(v)
	if v == "" {
		return ""
	}
	raw, err := base64.RawURLEncoding.DecodeString(v)
	if err != nil {
		// Backward compatibility: existing rows may still contain non-encrypted values.
		return truncate(v, 45)
	}
	if protector == nil {
		return truncate(v, 45)
	}
	plain, err := protector.Decrypt(raw)
	if err != nil {
		// Backward compatibility: existing rows may still contain non-encrypted values.
		return truncate(v, 45)
	}
	return truncate(strings.TrimSpace(string(plain)), 45)
}

func deriveIPEncryptionKey(secret string) []byte {
	sum := sha256.Sum256([]byte(secret))
	key := make([]byte, len(sum))
	copy(key, sum[:])
	return key
}

func (s *AuthService) emailOutboxReady() bool {
	return s.emailOutbox != nil && s.emailSerializer != nil
}

func (s *AuthService) createUserAndDispatchEmail(ctx context.Context, user *auth.User, job EmailJob) error {
	if emailJobEmpty(job) {
		return s.repo.CreateUser(ctx, user)
	}
	if txRepo, ok := s.repo.(auth.TransactionalRepository); ok && s.emailOutboxReady() {
		return txRepo.WithTransaction(ctx, func(repo auth.Repository, outbox auth.EmailOutboxWriter) error {
			if err := repo.CreateUser(ctx, user); err != nil {
				return err
			}
			return s.persistEmailOutbox(ctx, outbox, job)
		})
	}
	if err := s.repo.CreateUser(ctx, user); err != nil {
		return err
	}
	return s.dispatchPreparedEmailJob(ctx, job)
}

func (s *AuthService) persistEmailOutbox(ctx context.Context, outbox auth.EmailOutboxWriter, job EmailJob) error {
	if outbox == nil || !s.emailOutboxReady() {
		return errors.New("email outbox is not configured")
	}
	if err := job.Validate(); err != nil {
		return err
	}
	message, err := s.emailSerializer.Serialize(ctx, queue.EmailJob(job))
	if err != nil {
		return err
	}
	record, err := auth.NewEmailOutboxMessage(time.Now().UTC(), message)
	if err != nil {
		return err
	}
	if err := outbox.CreateEmailOutboxMessage(ctx, record); err != nil {
		log.Printf("auth email outbox_persist_failed type=%s email_sha256=%s err=%v", job.NormalizedType(), job.RecipientHash(), err)
		return err
	}
	log.Printf("auth email outbox_persisted type=%s message_id=%s email_sha256=%s correlation_id=%s", job.NormalizedType(), record.MessageID, job.RecipientHash(), record.CorrelationID)
	return nil
}

func (s *AuthService) dispatchPreparedEmailJob(ctx context.Context, job EmailJob) error {
	if emailJobEmpty(job) {
		return nil
	}
	if s.emailOutboxReady() {
		return s.persistEmailOutbox(ctx, s.emailOutbox, job)
	}
	if s.emailQueue != nil {
		if err := s.emailQueue.Enqueue(ctx, job); err != nil {
			log.Printf("auth email enqueue_failed type=%s email_sha256=%s err=%v", job.NormalizedType(), job.RecipientHash(), err)
			return err
		}
		log.Printf("auth email enqueued type=%s email_sha256=%s", job.NormalizedType(), job.RecipientHash())
		return nil
	}
	return errors.New("email queue is not configured")
}

func emailJobEmpty(job EmailJob) bool {
	job = job.Canonical()
	return strings.TrimSpace(job.To) == "" &&
		strings.TrimSpace(job.Subject) == "" &&
		strings.TrimSpace(job.TemplateName) == "" &&
		strings.TrimSpace(job.TextBody) == "" &&
		strings.TrimSpace(job.HTMLBody) == ""
}

func newIPSecretProtector(secret string) SecretProtector {
	secret = strings.TrimSpace(secret)
	if secret == "" {
		return nil
	}
	if p, err := NewAESGCMProtectorFromBase64(secret); err == nil {
		return p
	}
	// Backward compatibility for non-base64 secrets already used in environments/tests.
	return &AESGCMProtector{key: deriveIPEncryptionKey(secret)}
}

func truncate(v string, max int) string {
	if max <= 0 || len(v) <= max {
		return v
	}
	return v[:max]
}

func sleepRemaining(start time.Time, min time.Duration) {
	if min <= 0 {
		return
	}
	elapsed := time.Since(start)
	if elapsed < min {
		time.Sleep(min - elapsed)
	}
}

func validateSecurityPIN(pin string) error {
	if len(pin) != 6 {
		return ErrInvalidSecurityPIN
	}
	for i := 0; i < len(pin); i++ {
		if pin[i] < '0' || pin[i] > '9' {
			return ErrInvalidSecurityPIN
		}
	}
	return nil
}

func normalizeSensitiveAction(action string) string {
	action = strings.TrimSpace(strings.ToLower(action))
	if !isCanonicalUUID(action) {
		return ""
	}
	return action
}

func isCanonicalUUID(v string) bool {
	if len(v) != 36 {
		return false
	}
	if v[8] != '-' || v[13] != '-' || v[18] != '-' || v[23] != '-' {
		return false
	}
	_, err := uuidv7.ParseString(v)
	return err == nil
}

func normalizeAuthConfig(cfg config.AuthConfig) config.AuthConfig {
	if strings.TrimSpace(cfg.IPEncryptionKey) == "" {
		cfg.IPEncryptionKey = cfg.PasswordPepper
	}
	if cfg.HashTime == 0 {
		cfg.HashTime = 1
	}
	if cfg.HashMemoryKB == 0 {
		cfg.HashMemoryKB = 64 * 1024
	}
	if cfg.HashThreads == 0 {
		cfg.HashThreads = 4
	}
	if cfg.HashKeyLen == 0 {
		cfg.HashKeyLen = 32
	}
	if cfg.PasswordResetTTL < 15*time.Minute || cfg.PasswordResetTTL > 30*time.Minute {
		cfg.PasswordResetTTL = 20 * time.Minute
	}
	if cfg.SecurityPINResetTTL < 15*time.Minute || cfg.SecurityPINResetTTL > 30*time.Minute {
		cfg.SecurityPINResetTTL = 20 * time.Minute
	}
	if cfg.ForgotSecurityPINCooldown < 0 {
		cfg.ForgotSecurityPINCooldown = 2 * time.Minute
	}
	if cfg.ForgotSecurityPINMinDuration < 0 {
		cfg.ForgotSecurityPINMinDuration = 0
	}
	if cfg.SecurityPINMaxAttempts <= 0 {
		cfg.SecurityPINMaxAttempts = 5
	}
	if cfg.SecurityPINLockDuration < 0 {
		cfg.SecurityPINLockDuration = 0
	}
	if cfg.SecurityPINActionTokenTTL <= 0 {
		cfg.SecurityPINActionTokenTTL = 5 * time.Minute
	}
	if cfg.JWTAccessTTL <= 0 {
		cfg.JWTAccessTTL = 10 * time.Minute
	}
	if cfg.JWTRefreshTTL <= 0 {
		cfg.JWTRefreshTTL = time.Hour
	}
	if cfg.JWTRefreshTOTPTTL <= 0 {
		cfg.JWTRefreshTOTPTTL = cfg.JWTRefreshTTL
	}
	if cfg.JWTLocalRefreshTTL <= 0 {
		cfg.JWTLocalRefreshTTL = 24 * time.Hour
	}
	if cfg.SessionIdleTTL <= 0 {
		cfg.SessionIdleTTL = 15 * time.Minute
	}
	if cfg.ForgotPasswordEmailCooldown < 0 {
		cfg.ForgotPasswordEmailCooldown = 2 * time.Minute
	}
	if cfg.ForgotPasswordMinDuration < 0 {
		cfg.ForgotPasswordMinDuration = 0
	}
	if cfg.ForgotPasswordIPRateMax <= 0 {
		cfg.ForgotPasswordIPRateMax = 10
	}
	if cfg.ForgotPasswordIPRateWindow <= 0 {
		cfg.ForgotPasswordIPRateWindow = 15 * time.Minute
	}
	if cfg.LoginIPRateMax <= 0 {
		cfg.LoginIPRateMax = 5
	}
	if cfg.LoginIPRateWindow <= 0 {
		cfg.LoginIPRateWindow = time.Minute
	}
	if cfg.EmailOTPChallengeTTL <= 0 {
		cfg.EmailOTPChallengeTTL = 10 * time.Minute
	}
	if cfg.EmailOTPLength < 6 || cfg.EmailOTPLength > 8 {
		cfg.EmailOTPLength = 6
	}
	if cfg.EmailOTPExpiry < 5*time.Minute || cfg.EmailOTPExpiry > 10*time.Minute {
		cfg.EmailOTPExpiry = 10 * time.Minute
	}
	if cfg.EmailOTPMaxAttempts < 3 || cfg.EmailOTPMaxAttempts > 5 {
		cfg.EmailOTPMaxAttempts = 5
	}
	if cfg.EmailOTPResendCooldown <= 0 {
		cfg.EmailOTPResendCooldown = 60 * time.Second
	}
	if cfg.EmailOTPMaxResendPerHour <= 0 {
		cfg.EmailOTPMaxResendPerHour = 5
	}
	return cfg
}
