package auth

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"time"

	jwt "github.com/golang-jwt/jwt/v5"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/xerr"
	authdomain "hyapp/services/user-service/internal/domain/auth"
	userdomain "hyapp/services/user-service/internal/domain/user"
)

const (
	defaultRefreshRotationGrace = 30 * time.Second
	refreshReplayCipherVersion  = "v1"
	refreshMetricSuccess        = "refresh_success"
	refreshMetricGraceHit       = "grace_hit"
	refreshMetricTokenReuse     = "token_reuse"
	refreshMetricFamilyRevoked  = "family_revoked"
	revokeReasonRefreshReuse    = "REFRESH_TOKEN_REUSE"
	sessionDenylistSafetyMargin = 60 * time.Second
	// 仓库历史曾签发 7 天 access JWT；即使配置降到 1 小时，首次撤销也必须覆盖这批尚未到期的历史 token。
	sessionDenylistHistoricalAccessTTLFloor = 7 * 24 * time.Hour
)

// RefreshToken 轮换 refresh token；直接父代在短暂宽限期内只复用首次提交的 token pair，不能产生分叉。
func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceID string, meta Meta) (authdomain.Token, error) {
	if strings.TrimSpace(refreshToken) == "" || strings.TrimSpace(deviceID) == "" {
		// refresh token 和 device_id 共同定位并校验会话归属。
		return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "refresh_token and device_id are required")
	}
	if s.authRepository == nil {
		return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
	}

	nowMs := s.now().UnixMilli()
	refreshTokenHash := hashRefreshToken(refreshToken)
	refreshRequestID, err := normalizeRefreshRequestID(meta.RefreshRequestID, refreshTokenHash)
	if err != nil {
		return authdomain.Token{}, err
	}
	// 必须读取已轮换行才能区分直接父代 grace 和更早代 replay；数据库仍只用 token hash 查询。
	session, err := s.authRepository.FindSessionByRefreshHash(ctx, refreshTokenHash)
	if err != nil {
		s.audit(ctx, meta, 0, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
		return authdomain.Token{}, err
	}
	grace := s.refreshRotationGrace()
	command := authdomain.RefreshRotationCommand{
		AppCode:          appcode.Normalize(meta.AppCode),
		RefreshTokenHash: refreshTokenHash,
		DeviceID:         strings.TrimSpace(deviceID),
		RefreshRequestID: refreshRequestID,
		NowMs:            nowMs,
		GracePeriodMs:    grace.Milliseconds(),
		DenyUntilMs:      s.sessionDenyUntilMs(nowMs),
	}

	// 只有当前仍 active、未过期且设备匹配的 session 才准备新一代；已轮换 token 直接交给 repository 原子判断 grace/replay。
	if session.RevokedAtMs == 0 && session.ExpiresAtMs > nowMs && subtle.ConstantTimeCompare([]byte(session.DeviceID), []byte(strings.TrimSpace(deviceID))) == 1 {
		// refresh 时重新读取用户，确保禁用状态和靓号过期恢复生效。
		user, freshErr := s.freshUser(ctx, session.UserID, nowMs, meta.RequestID)
		if freshErr != nil {
			s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(freshErr)))
			return authdomain.Token{}, freshErr
		}
		if !user.CanLogin() {
			s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.UserDisabled))
			return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
		}

		newSession, newRefreshToken, createErr := s.newRotatedSession(session, deviceID)
		if createErr != nil {
			return authdomain.Token{}, createErr
		}
		candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newSession.DeviceID, newRefreshToken, newSession.ExpiresAtMs)
		if issueErr != nil {
			return authdomain.Token{}, issueErr
		}
		ciphertext, encryptErr := s.encryptRefreshOutcome(candidateToken, session.SessionID, newSession.SessionID, refreshRequestID)
		if encryptErr != nil {
			return authdomain.Token{}, encryptErr
		}
		command.NewSession = newSession
		command.TokenCiphertext = ciphertext
		command.OutcomeExpiresAtMs = nowMs + grace.Milliseconds()

	}

	rotation, err := s.authRepository.RotateRefreshSession(ctx, command)
	if err != nil {
		s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
		return authdomain.Token{}, err
	}

	switch rotation.Status {
	case authdomain.RefreshRotationCreated, authdomain.RefreshRotationGraceHit:
		// 无论本实例是否赢得轮换锁，都从事务内保存的密文恢复结果，保证并发和崩溃重试拿到完全相同的 token pair。
		token, decryptErr := s.decryptRefreshOutcome(rotation)
		if decryptErr != nil {
			s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable))
			return authdomain.Token{}, xerr.New(xerr.Unavailable, "refresh outcome is unavailable")
		}
		// 先由 MySQL 原子提交 parent revoke + child + outcome + durable job，再同步写 Redis。
		// 这样 DB deadlock/失败不会把仍 active 的 sid 单独 deny；Redis 失败则返回 UNAVAILABLE，旧 RT 重试从 outcome 恢复同 pair 并再次补写。
		if err := s.writeRevokedAccessSessionUntil(ctx, rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, revokeReasonRefreshRotated, command.DenyUntilMs); err != nil {
			s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable))
			return authdomain.Token{}, xerr.New(xerr.Unavailable, "rotated session denylist is unavailable")
		}
		_ = s.authRepository.MarkSessionDenylistJobDelivered(ctx, 0, rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, revokeReasonRefreshRotated, command.DenyUntilMs, nowMs, nowMs+sessionDenylistRehydrateInterval.Milliseconds())
		if rotation.Status == authdomain.RefreshRotationCreated {
			s.refreshMetrics.Increment(ctx, refreshMetricSuccess, rotation.SourceSession.AppCode)
			// refresh 是登录态存续的主路径；老设备升级新客户端后，设备档案主要在这里补齐。
			s.recordUserDevice(ctx, meta, rotation.SourceSession.UserID, deviceID)
		} else {
			s.refreshMetrics.Increment(ctx, refreshMetricGraceHit, rotation.SourceSession.AppCode)
		}
		s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultSuccess, "")
		return token, nil
	case authdomain.RefreshRotationReuseDetected:
		s.refreshMetrics.Increment(ctx, refreshMetricTokenReuse, rotation.SourceSession.AppCode)
		if rotation.FamilyNewlyRevoked {
			s.refreshMetrics.Increment(ctx, refreshMetricFamilyRevoked, rotation.SourceSession.AppCode)
		}
		if err := s.writeRevokedAccessFamilyUntil(ctx, rotation.SourceSession.AppCode, rotation.FamilySessionIDs, revokeReasonRefreshReuse, command.DenyUntilMs); err != nil {
			// MySQL 已是 revoked 事实；返回 unavailable 促使调用方重试，后续重试会再次补齐全部 sid denylist。
			s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable))
			return authdomain.Token{}, err
		}
		s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked))
		return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
	default:
		return authdomain.Token{}, xerr.New(xerr.Unavailable, "refresh rotation result is invalid")
	}
}

// IssueAccessTokenForSession 基于当前 active session 重签 access token，不轮换 refresh token。
// 该链路用于 CompleteOnboarding 后刷新 profile_completed 快照，避免客户端必须额外 refresh。
func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64, sessionID string, meta Meta) (authdomain.Token, error) {
	sessionID = strings.TrimSpace(sessionID)
	if userID <= 0 || sessionID == "" {
		// session_id 来自已校验 JWT 的 sid claim，缺失说明调用方不能安全重签。
		return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "user_id and session_id are required")
	}
	if s.authRepository == nil {
		return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
	}

	nowMs := s.now().UnixMilli()
	session, err := s.authRepository.FindActiveSessionByID(ctx, sessionID, nowMs)
	if err != nil {
		s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.CodeOf(err)))
		return authdomain.Token{}, err
	}
	if session.UserID != userID {
		// sid 和 user_id 必须同源于同一个 access token，任何不一致都按会话失效处理。
		s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.SessionRevoked))
		return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
	}
	if session.AppCode != appcode.Normalize(meta.AppCode) {
		s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.SessionRevoked))
		return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
	}

	user, err := s.freshUser(ctx, userID, nowMs, meta.RequestID)
	if err != nil {
		s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.CodeOf(err)))
		return authdomain.Token{}, err
	}
	if !user.CanLogin() {
		s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.UserDisabled))
		return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
	}

	s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
	return s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
}

// AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。
// 与 IssueAccessTokenForSession 的关键差异：这里不写 login_audit，也不更新 session 行。
// 后台取 token 不是用户行为，写审计会污染“最近活跃 = GREATEST(session.updated, audit.created)”口径；
// 取用责任由 admin 侧操作日志承担。签发本身复用 issueToken 的 claim 组装，不复制 JWT 逻辑。
func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, meta Meta) (authdomain.Token, authdomain.Session, error) {
	if userID <= 0 {
		return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	if s.authRepository == nil {
		return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
	}
	if !s.TokenConfigValid() {
		// 签发配置残缺时 fail closed，不能签出 gateway 无法验证的 token。
		return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.Unavailable, "token config is invalid")
	}

	nowMs := s.now().UnixMilli()
	// 没有可信 sid 输入，取最新 active session 近似客户端当前会话；无会话时透传 NotFound 给后台展示。
	session, err := s.authRepository.FindLatestActiveSessionByUser(ctx, userID, nowMs)
	if err != nil {
		return authdomain.Token{}, authdomain.Session{}, err
	}
	user, err := s.freshUser(ctx, userID, nowMs, meta.RequestID)
	if err != nil {
		return authdomain.Token{}, authdomain.Session{}, err
	}
	if !user.CanLogin() {
		// 禁用用户不能经由后台间接拿到有效 token，语义与所有签发路径一致。
		return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.UserDisabled, "user is disabled")
	}

	// refreshToken 传空：后台没有轮换会话的理由，响应中也绝不携带 refresh 原文。
	token, err := s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
	if err != nil {
		return authdomain.Token{}, authdomain.Session{}, err
	}
	return token, session, nil
}

// Logout 失效指定 session 所属 token family；与 refresh 并发时也不能留下可继续登录的 orphan child。
func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken string, meta Meta) (bool, error) {
	if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(refreshToken) == "" {
		// 至少需要一种 session 定位方式。
		return false, xerr.New(xerr.InvalidArgument, "session_id or refresh_token is required")
	}
	if s.authRepository == nil {
		return false, xerr.New(xerr.Unavailable, "auth repository is not configured")
	}
	if s.ipDecisionCache == nil {
		// 没有 Redis 写能力时不进入 DB 撤销；运行中 Redis 瞬断则由事务内 denylist job 持久补偿。
		return false, xerr.New(xerr.Unavailable, "session denylist is not configured")
	}

	nowMs := s.now().UnixMilli()
	denyUntilMs := s.sessionDenyUntilMs(nowMs)
	// repository 读取包含已轮换父代在内的 lineage，并在同事务写每个 sid 的 denylist 补偿 job。
	resolvedAppCode, sessionIDs, revoked, err := s.authRepository.RevokeSessionFamily(ctx, strings.TrimSpace(sessionID), hashRefreshToken(refreshToken), nowMs, denyUntilMs, meta.RequestID)
	if err != nil {
		s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
		return false, err
	}
	if len(sessionIDs) > 0 {
		if err := s.writeRevokedAccessFamilyUntil(ctx, resolvedAppCode, sessionIDs, revokeReasonUserLogout, denyUntilMs); err != nil {
			s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.Unavailable))
			return false, err
		}
	}

	s.audit(ctx, meta, 0, "logout", "", resultSuccess, "")
	return revoked, nil
}

// AppHeartbeat 刷新当前 App 登录会话的服务端在线时间。
func (s *Service) AppHeartbeat(ctx context.Context, userID int64, sessionID string, meta Meta) (authdomain.Session, error) {
	sessionID = strings.TrimSpace(sessionID)
	if userID <= 0 || sessionID == "" {
		// APP 心跳只能续当前 access token 中的会话，不能让客户端传任意 user_id 或空 sid。
		return authdomain.Session{}, xerr.New(xerr.InvalidArgument, "user_id and session_id are required")
	}
	if s.authRepository == nil {
		return authdomain.Session{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
	}

	nowMs := s.now().UnixMilli()
	session, err := s.authRepository.UpdateSessionHeartbeat(ctx, sessionID, userID, nowMs, meta.RequestID)
	if err != nil {
		return authdomain.Session{}, err
	}
	if session.AppCode != appcode.Normalize(meta.AppCode) {
		// 理论上 repository 已按 app_code 限定；这里保留防御，避免错误 context 造成跨 App 心跳成功。
		return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
	}

	return session, nil
}

// TokenConfigValid 判断签发配置是否具备启动条件。
func (s *Service) TokenConfigValid() bool {
	// 当前只支持 HS256，避免签发和下游解析在算法上分叉。
	return s.cfg.Issuer != "" && s.cfg.AccessTokenTTLSec > 0 && s.cfg.RefreshTokenTTLSec > 0 && s.cfg.SigningAlg == jwt.SigningMethodHS256.Alg() && s.cfg.SigningSecret != ""
}

func (s *Service) newSession(appCode string, userID int64, deviceID string) (authdomain.Session, string, error) {
	// refresh token 原文只返回给调用方，session 中只保存 hash。
	refreshToken, err := randomToken("refresh")
	if err != nil {
		return authdomain.Session{}, "", err
	}

	nowMs := s.now().UnixMilli()
	// SessionID 使用独立前缀，便于日志和排障识别。
	sessionID := idgen.New("sess")
	session := authdomain.Session{
		AppCode:          appcode.Normalize(appCode),
		SessionID:        sessionID,
		UserID:           userID,
		RefreshTokenHash: hashRefreshToken(refreshToken),
		DeviceID:         strings.TrimSpace(deviceID),
		ExpiresAtMs:      nowMs + s.cfg.RefreshTokenTTLSec*1000,
		CreatedAtMs:      nowMs,
		UpdatedAtMs:      nowMs,
	}
	// 首个登录 session 自己就是 family root；后续轮换只继承该 ID 并递增 generation。
	session.TokenFamilyID = session.SessionID
	return session, refreshToken, nil
}

func (s *Service) newRotatedSession(parent authdomain.Session, deviceID string) (authdomain.Session, string, error) {
	child, refreshToken, err := s.newSession(parent.AppCode, parent.UserID, deviceID)
	if err != nil {
		return authdomain.Session{}, "", err
	}
	familyID := strings.TrimSpace(parent.TokenFamilyID)
	if familyID == "" {
		// 兼容迁移前创建、尚未轮换的 active session：第一次新版轮换时把自身升级为 family root。
		familyID = parent.SessionID
	}
	child.TokenFamilyID = familyID
	child.Generation = parent.Generation + 1
	child.ParentSessionID = parent.SessionID
	return child, refreshToken, nil
}

func (s *Service) refreshRotationGrace() time.Duration {
	if s.cfg.RefreshRotationGraceSec <= 0 {
		return defaultRefreshRotationGrace
	}
	return time.Duration(s.cfg.RefreshRotationGraceSec) * time.Second
}

func normalizeRefreshRequestID(refreshRequestID string, refreshTokenHash string) (string, error) {
	refreshRequestID = strings.TrimSpace(refreshRequestID)
	if refreshRequestID == "" {
		// 旧客户端每次 HTTP request_id 都会变化，不能拿它当幂等键；只从已 hash 的 predecessor token 派生稳定兼容键。
		if len(refreshTokenHash) < 48 {
			return "", xerr.New(xerr.InvalidArgument, "refresh token hash is invalid")
		}
		refreshRequestID = "legacy_" + refreshTokenHash[:48]
	}
	if len(refreshRequestID) > 96 {
		return "", xerr.New(xerr.InvalidArgument, "refresh_request_id is too long")
	}
	return refreshRequestID, nil
}

func (s *Service) encryptRefreshOutcome(token authdomain.Token, parentSessionID string, childSessionID string, refreshRequestID string) ([]byte, error) {
	plaintext, err := json.Marshal(token)
	if err != nil {
		return nil, err
	}
	gcm, err := s.refreshReplayCipher()
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	// nonce 前置于密文；AAD 绑定父子 lineage 和幂等键，数据库行被搬移后无法解密成可用 token。
	return gcm.Seal(nonce, nonce, plaintext, refreshOutcomeAAD(token.AppCode, parentSessionID, childSessionID, refreshRequestID)), nil
}

func (s *Service) decryptRefreshOutcome(rotation authdomain.RefreshRotationResult) (authdomain.Token, error) {
	gcm, err := s.refreshReplayCipher()
	if err != nil {
		return authdomain.Token{}, err
	}
	if len(rotation.TokenCiphertext) <= gcm.NonceSize() {
		return authdomain.Token{}, fmt.Errorf("refresh outcome ciphertext is empty")
	}
	nonce := rotation.TokenCiphertext[:gcm.NonceSize()]
	ciphertext := rotation.TokenCiphertext[gcm.NonceSize():]
	plaintext, err := gcm.Open(nil, nonce, ciphertext, refreshOutcomeAAD(rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, rotation.ActiveSession.SessionID, rotation.RefreshRequestID))
	if err != nil {
		return authdomain.Token{}, err
	}
	var token authdomain.Token
	if err := json.Unmarshal(plaintext, &token); err != nil {
		return authdomain.Token{}, err
	}
	if token.SessionID != rotation.ActiveSession.SessionID || token.AppCode != rotation.SourceSession.AppCode || token.RefreshToken == "" || token.AccessToken == "" {
		// 密文即使能通过认证，也必须和事务返回的 child session 一致，避免错误行关联泄露另一个会话结果。
		return authdomain.Token{}, fmt.Errorf("refresh outcome lineage mismatch")
	}
	return token, nil
}

func (s *Service) refreshReplayCipher() (cipher.AEAD, error) {
	if strings.TrimSpace(s.cfg.SigningSecret) == "" {
		return nil, fmt.Errorf("signing secret is empty")
	}
	// 使用带用途前缀的 SHA-256 派生独立 AES-256 key，避免把 JWT HMAC key 直接作为分组密码 key。
	key := sha256.Sum256([]byte("hyapp-auth-refresh-replay:" + refreshReplayCipherVersion + "\x00" + s.cfg.SigningSecret))
	block, err := aes.NewCipher(key[:])
	if err != nil {
		return nil, err
	}
	return cipher.NewGCM(block)
}

func refreshOutcomeAAD(appCode string, parentSessionID string, childSessionID string, refreshRequestID string) []byte {
	return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00"))
}

func (s *Service) issueToken(user userdomain.User, sessionID string, deviceID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) {
	now := s.now()
	expiresInSec := s.cfg.AccessTokenTTLSec
	expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
	if sessionExpiresAtMs > 0 {
		sessionExpiresAt := time.UnixMilli(sessionExpiresAtMs)
		if !sessionExpiresAt.After(now) {
			// access token 不能越过服务端 refresh session 的生命周期；已到期 session 必须重新登录。
			return authdomain.Token{}, xerr.New(xerr.SessionExpired, "session expired")
		}
		if sessionExpiresAt.Before(expiresAt) {
			expiresAt = sessionExpiresAt
			expiresInSec = int64(expiresAt.Sub(now) / time.Second)
			if expiresInSec <= 0 {
				return authdomain.Token{}, xerr.New(xerr.SessionExpired, "session expired")
			}
		}
	}
	onboardingStatus := tokenOnboardingStatus(user)
	// JWT 只携带内部 user_id；客户端展示和人工输入统一使用 display_user_id。
	// user_id 必须写成字符串：雪花 ID 超过 JS/JSON number 的安全整数范围，写成 number 会在 gateway 解析时丢精度。
	// device_id 只能来自已落库 auth_session 的绑定值；心跳/资料完成重签也必须从同一 session 回读，
	// 不允许 gateway 用 sid 或业务 command_id 伪造设备风控作用域。
	claims := jwt.MapClaims{
		"iss":                           s.cfg.Issuer,
		"app_code":                      appcode.Normalize(user.AppCode),
		"sub":                           strconv.FormatInt(user.UserID, 10),
		"user_id":                       strconv.FormatInt(user.UserID, 10),
		"display_user_id":               user.CurrentDisplayUserID,
		"default_display_user_id":       user.DefaultDisplayUserID,
		"display_user_id_kind":          string(user.CurrentDisplayUserIDKind),
		"display_user_id_expires_at_ms": user.CurrentDisplayUserIDExpiresAtMs,
		"profile_completed":             user.ProfileCompleted,
		"onboarding_status":             onboardingStatus,
		"sid":                           sessionID,
		"device_id":                     strings.TrimSpace(deviceID),
		"typ":                           "access",
		"iat":                           now.Unix(),
		"exp":                           expiresAt.Unix(),
	}

	// 当前固定 HS256，配置有效性由 TokenConfigValid 校验。
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	accessToken, err := token.SignedString([]byte(s.cfg.SigningSecret))
	if err != nil {
		return authdomain.Token{}, err
	}

	return authdomain.Token{
		AppCode:                  appcode.Normalize(user.AppCode),
		UserID:                   user.UserID,
		DisplayUserID:            user.CurrentDisplayUserID,
		DefaultDisplayUserID:     user.DefaultDisplayUserID,
		DisplayUserIDKind:        string(user.CurrentDisplayUserIDKind),
		DisplayUserIDExpiresAtMs: user.CurrentDisplayUserIDExpiresAtMs,
		ProfileCompleted:         user.ProfileCompleted,
		OnboardingStatus:         onboardingStatus,
		SessionID:                sessionID,
		AccessToken:              accessToken,
		RefreshToken:             refreshToken,
		ExpiresInSec:             expiresInSec,
		TokenType:                tokenType,
	}, nil
}

func (s *Service) writeRevokedAccessSession(ctx context.Context, appCode string, sessionID string, reason string) error {
	return s.writeRevokedAccessSessionUntil(ctx, appCode, sessionID, reason, s.sessionDenyUntilMs(s.now().UnixMilli()))
}

func (s *Service) writeRevokedAccessSessionUntil(ctx context.Context, appCode string, sessionID string, reason string, denyUntilMs int64) error {
	if strings.TrimSpace(sessionID) == "" {
		return nil
	}
	if s.ipDecisionCache == nil {
		// refresh/logout 一旦成功，gateway 必须能立刻拒绝旧 access token；缺少 denylist 不能假装吊销成功。
		return xerr.New(xerr.Unavailable, "session denylist is not configured")
	}
	ttl := time.Until(time.UnixMilli(denyUntilMs))
	if s.now != nil {
		ttl = time.UnixMilli(denyUntilMs).Sub(s.now())
	}
	if ttl <= 0 {
		// deny_until 已过说明该 sid 的 access JWT 已全部自然失效，无需再写 Redis。
		return nil
	}
	return s.ipDecisionCache.SetRevokedSession(ctx, appcode.Normalize(appCode), sessionID, reason, ttl)
}

func (s *Service) writeRevokedAccessFamilyUntil(ctx context.Context, appCode string, sessionIDs []string, reason string, denyUntilMs int64) error {
	// replay 是罕见安全事件，可以逐 sid 补齐 denylist；不能只写当前 active child，否则 family 内尚未自然过期的旧 JWT 仍能通过 gateway。
	for _, sessionID := range sessionIDs {
		if err := s.writeRevokedAccessSessionUntil(ctx, appCode, sessionID, reason, denyUntilMs); err != nil {
			return xerr.New(xerr.Unavailable, "session family denylist is unavailable")
		}
		// family revoke 事务已经写入持久 job；即时 Redis 成功后尽量标 delivered，标记失败会由 worker 幂等重放。
		nowMs := s.now().UnixMilli()
		_ = s.authRepository.MarkSessionDenylistJobDelivered(ctx, 0, appCode, sessionID, reason, denyUntilMs, nowMs, nowMs+sessionDenylistRehydrateInterval.Milliseconds())
	}
	return nil
}

func (s *Service) sessionDenyUntilMs(nowMs int64) int64 {
	accessTTL := time.Duration(s.cfg.AccessTokenTTLSec) * time.Second
	if accessTTL < sessionDenylistHistoricalAccessTTLFloor {
		// 这个 floor 只能随历史最大签发 TTL 上调，不能因当前配置下降而下调。
		accessTTL = sessionDenylistHistoricalAccessTTLFloor
	}
	return nowMs + (accessTTL + sessionDenylistSafetyMargin).Milliseconds()
}

func tokenOnboardingStatus(user userdomain.User) string {
	// 旧测试数据或手工种子可能没有显式状态；token 按 profile_completed 推导安全默认值。
	if user.OnboardingStatus != "" {
		return string(user.OnboardingStatus)
	}
	if user.ProfileCompleted {
		return string(userdomain.OnboardingStatusCompleted)
	}

	return string(userdomain.OnboardingStatusProfileRequired)
}

func (s *Service) freshUser(ctx context.Context, userID int64, nowMs int64, requestID string) (userdomain.User, error) {
	if s.userRepository == nil {
		// 没有用户读取能力时不能签发 token。
		return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
	}

	user, err := s.userRepository.GetUser(ctx, userID)
	if err != nil {
		return userdomain.User{}, err
	}
	if !user.DisplayUserIDExpired(nowMs) {
		// 当前展示号仍有效，直接返回规范化用户。
		return user, nil
	}

	if s.identityRepository == nil {
		// 发现过期靓号但无法恢复时不能签发带过期短号的 token。
		return userdomain.User{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
	}

	// 登录和 refresh 路径都复用同一过期恢复事务。
	if _, err := s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{
		UserID:    userID,
		RequestID: requestID,
		NowMs:     nowMs,
	}); err != nil {
		return userdomain.User{}, err
	}

	// 恢复后重新读取 users，确保 token 使用最新默认短号。
	return s.userRepository.GetUser(ctx, userID)
}

func randomToken(prefix string) (string, error) {
	// 32 字节随机熵足够作为 refresh token 原文，使用 URL-safe base64 返回。
	var entropy [32]byte
	if _, err := rand.Read(entropy[:]); err != nil {
		return "", err
	}

	return fmt.Sprintf("%s_%s", prefix, base64.RawURLEncoding.EncodeToString(entropy[:])), nil
}

func hashRefreshToken(refreshToken string) string {
	if refreshToken == "" {
		// 空 refresh token hash 保持空字符串，支持只按 session_id 吊销。
		return ""
	}

	// refresh token 只持久化 SHA-256 hash，避免数据库泄漏后可直接使用原文。
	sum := sha256.Sum256([]byte(refreshToken))
	return hex.EncodeToString(sum[:])
}
