package com.ttstd.signaling.service;

import com.ttstd.signaling.model.AccountStatus;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceType;
import com.ttstd.signaling.model.LoginSession;
import com.ttstd.signaling.model.PrincipalType;
import com.ttstd.signaling.model.UserAccount;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.JwtService;
import com.ttstd.signaling.security.SecurityProperties;
import com.ttstd.signaling.security.TokenUtils;
import com.ttstd.signaling.security.TotpService;
import com.ttstd.signaling.store.SessionStore;
import com.ttstd.signaling.store.UserStore;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * 主控端账号服务：注册、登录、令牌刷新、登出、封禁与强制下线。
 *
 * <p>底层存储通过 {@link UserStore}/{@link SessionStore} 抽象，生产环境使用 MySQL（JPA），
 * 测试/演示使用内存实现，调用方无感知。
 */
@Service
public class AccountService {

    private static final Logger logger = LoggerFactory.getLogger(AccountService.class);
    private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]{3,32}$");

    /** 常见弱口令黑名单（小写比对）。生产可扩展为从文件加载。 */
    private static final Set<String> WEAK_PASSWORDS = Set.of(
            "password", "passw0rd", "password1", "password123", "passw0rd!",
            "12345678", "123456789", "1234567890", "qwertyui", "qwerty123",
            "abc12345", "admin123", "administrator", "letmein1", "welcome1",
            "iloveyou", "sunshine", "princess", "football", "baseball",
            "monkey123", "dragon123", "master123", "shadow123", "superman");

    private final UserStore userStore;
    private final SessionStore sessionStore;
    private final SecurityProperties properties;
    private final JwtService jwtService;
    private final TotpService totpService;
    private final AuditService auditService;
    private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12);

    /** 会话失效回调：由信令层注册，用于立即断开对应 WebSocket 连接 */
    private volatile SessionRevocationListener revocationListener;

    public AccountService(UserStore userStore,
                           SessionStore sessionStore,
                           SecurityProperties properties,
                           JwtService jwtService,
                           TotpService totpService,
                           AuditService auditService) {
        this.userStore = userStore;
        this.sessionStore = sessionStore;
        this.properties = properties;
        this.jwtService = jwtService;
        this.totpService = totpService;
        this.auditService = auditService;
    }

    /** 会话/账号失效通知接口。 */
    public interface SessionRevocationListener {
        void onRevoked(String principalId, String sessionId, String reason);
    }

    public void setRevocationListener(SessionRevocationListener listener) {
        this.revocationListener = listener;
    }

    // ==================== 注册 ====================

    public UserAccount register(String username, String password) {
        if (!properties.getAccount().isRegistrationEnabled()) {
            throw AuthException.forbidden("当前不开放注册", "registration disabled");
        }
        validateUsername(username);
        validatePassword(password, username);
        return createAccountInternal(username, password);
    }

    private UserAccount createAccountInternal(String username, String password) {
        String key = username.toLowerCase(Locale.ROOT);
        if (userStore.existsByUsername(username)) {
            throw AuthException.badRequest("用户名已被占用");
        }
        String userId = "usr_" + TokenUtils.randomBase62(20);
        UserAccount account = new UserAccount(userId, username, passwordEncoder.encode(password));
        account.setPasswordChangedAt(Instant.now());
        userStore.save(account);
        logger.info("账号已创建: {} ({})", username, userId);
        auditService.recordUser(userId, AuditService.ACTION_REGISTER,
                AuditService.RESULT_SUCCESS, null, "账号注册成功: " + username);
        return account;
    }

    private void validateUsername(String username) {
        if (username == null || !USERNAME_PATTERN.matcher(username).matches()) {
            throw AuthException.badRequest("用户名需为 3-32 位字母、数字、下划线、点或连字符");
        }
    }

    /**
     * 密码强度校验：长度、字符类别、弱口令黑名单、与用户名的相关性。
     */
    private void validatePassword(String password, String username) {
        if (password == null || password.length() < 8 || password.length() > 128) {
            throw AuthException.badRequest("密码长度需为 8-128 位");
        }
        int classes = 0;
        if (password.matches(".*[a-z].*")) classes++;
        if (password.matches(".*[A-Z].*")) classes++;
        if (password.matches(".*\\d.*")) classes++;
        if (password.matches(".*[^a-zA-Z0-9].*")) classes++;
        if (classes < 2) {
            throw AuthException.badRequest("密码需至少包含大小写字母、数字、符号中的两类");
        }

        String lower = password.toLowerCase(Locale.ROOT);
        if (WEAK_PASSWORDS.contains(lower)) {
            throw AuthException.badRequest("密码过于简单，请更换");
        }
        // 密码不得包含用户名（防止 alice/alice123 这类弱口令）
        if (username != null && username.length() >= 3
                && lower.contains(username.toLowerCase(Locale.ROOT))) {
            throw AuthException.badRequest("密码不能包含用户名");
        }
        // 连续或重复字符检测，如 111111 / abcdef
        if (hasTrivialSequence(lower)) {
            throw AuthException.badRequest("密码不能为连续或重复字符");
        }
    }

    /** 检测全部为同一字符，或为连续递增/递减序列。 */
    private static boolean hasTrivialSequence(String password) {
        boolean allSame = true;
        boolean ascending = true;
        boolean descending = true;
        for (int i = 1; i < password.length(); i++) {
            char prev = password.charAt(i - 1);
            char curr = password.charAt(i);
            if (curr != prev) allSame = false;
            if (curr != prev + 1) ascending = false;
            if (curr != prev - 1) descending = false;
        }
        return allSame || ascending || descending;
    }

    // ==================== 登录 ====================

    /** 兼容旧签名：不带 TOTP 动态码的登录。 */
    public TokenPair login(String username, String password, String ip, String userAgent) {
        return login(username, password, null, ip, userAgent);
    }

    /**
     * 账号密码登录，支持 TOTP 双因子。
     *
     * <p>无论用户名是否存在，失败均返回同一模糊提示，避免账号枚举。
     *
     * @param totpCode 动态码；账号未启用 TOTP 时忽略
     */
    public TokenPair login(String username, String password, String totpCode,
                           String ip, String userAgent) {
        UserAccount account = userStore.findByUsername(username).orElse(null);

        if (account == null) {
            // 执行一次伪哈希，抹平“用户不存在”与“密码错误”的响应时间差
            passwordEncoder.encode("dummy-password-for-timing");
            auditService.record("USER", username, AuditService.ACTION_LOGIN_FAILED,
                    null, AuditService.RESULT_FAILURE, ip, "账号不存在");
            throw AuthException.unauthorized("username not found: " + username);
        }
        if (account.isLocked()) {
            auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN_FAILED,
                    AuditService.RESULT_FAILURE, ip, "账号处于锁定期");
            throw AuthException.tooManyRequests("尝试过于频繁，请稍后再试");
        }
        if (!passwordEncoder.matches(password, account.getPasswordHash())) {
            recordLoginFailure(account, ip);
            throw AuthException.unauthorized("bad password for " + account.getUserId());
        }

        // 第二因子校验
        if (account.isTotpEnabled()) {
            if (totpCode == null || totpCode.isBlank()) {
                // 用专门的错误码告知客户端需要补充动态码，而非笼统的认证失败
                throw new AuthException(401, "TOTP_REQUIRED", "请输入动态验证码",
                        "totp required for " + account.getUserId());
            }
            if (!totpService.verify(account.getTotpSecret(), totpCode)) {
                recordLoginFailure(account, ip);
                auditService.recordUser(account.getUserId(), AuditService.ACTION_TOTP_FAILED,
                        AuditService.RESULT_FAILURE, ip, "动态码校验失败");
                throw AuthException.unauthorized("bad totp for " + account.getUserId());
            }
        }

        account = assertAccountUsable(account);

        account.setFailedAttempts(0);
        account.setLockedUntil(null);
        account.setLastLoginAt(Instant.now());
        account.setLastLoginIp(ip);
        userStore.save(account);

        enforceSessionLimit(account.getUserId());
        TokenPair pair = issueTokens(account, ip, userAgent);
        auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN,
                AuditService.RESULT_SUCCESS, ip,
                account.isTotpEnabled() ? "密码+动态码登录" : "密码登录");
        return pair;
    }

    private void recordLoginFailure(UserAccount account, String ip) {
        int attempts = account.getFailedAttempts() + 1;
        account.setFailedAttempts(attempts);
        if (attempts >= properties.getAccount().getMaxFailedAttempts()) {
            account.setLockedUntil(Instant.now()
                    .plusSeconds(properties.getAccount().getLockDurationSeconds()));
            account.setFailedAttempts(0);
            logger.warn("账号 {} 连续登录失败已触发锁定", account.getUserId());
            auditService.recordUser(account.getUserId(), AuditService.ACTION_ACCOUNT_LOCKED,
                    AuditService.RESULT_FAILURE, ip, "连续登录失败触发锁定");
        }
        userStore.save(account);
        auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN_FAILED,
                AuditService.RESULT_FAILURE, ip, "凭据校验失败");
    }

    /** 校验账号可用性；对已过期的临时封禁自动恢复。返回最新账号对象。 */
    private UserAccount assertAccountUsable(UserAccount account) {
        if (account.getStatus() == AccountStatus.SUSPENDED
                && account.getStatusUntil() != null
                && Instant.now().isAfter(account.getStatusUntil())) {
            account.setStatus(AccountStatus.ACTIVE);
            account.setStatusUntil(null);
            account.setStatusReason(null);
            userStore.save(account);
            logger.info("账号 {} 临时封禁已到期，自动恢复", account.getUserId());
        }
        if (!account.isUsable()) {
            String msg = account.getStatus() == AccountStatus.BANNED
                    ? "账号已被封禁" : "账号已被临时封禁";
            if (account.getStatusReason() != null && !account.getStatusReason().isBlank()) {
                msg = msg + "：" + account.getStatusReason();
            }
            throw AuthException.forbidden(msg, "account not usable: " + account.getUserId());
        }
        return account;
    }

    /** 超出最大并发会话数时，踢掉最旧的会话。 */
    private void enforceSessionLimit(String userId) {
        int max = properties.getAccount().getMaxConcurrentSessions();
        if (max <= 0) {
            return;
        }
        List<LoginSession> active = sessionStore.listByPrincipalAndType(userId, PrincipalType.USER).stream()
                .filter(LoginSession::isActive)
                .sorted(Comparator.comparing(LoginSession::getCreatedAt))
                .toList();
        int excess = active.size() - (max - 1);
        for (int i = 0; i < excess && i < active.size(); i++) {
            LoginSession old = active.get(i);
            old.setRevoked(true);
            sessionStore.save(old);
            notifyRevoked(userId, old.getSessionId(), "会话数超限，最旧会话已下线");
            logger.info("账号 {} 会话数超限，已踢出最旧会话 {}", userId, old.getSessionId());
        }
    }

    private TokenPair issueTokens(UserAccount account, String ip, String userAgent) {
        String sessionId = "ses_" + TokenUtils.randomBase62(20);
        String refreshToken = TokenUtils.randomSecret(32);
        Instant refreshExpiry = Instant.now()
                .plusSeconds(properties.getJwt().getRefreshTokenTtlSeconds());

        LoginSession session = new LoginSession(
                sessionId, account.getUserId(), PrincipalType.USER,
                TokenUtils.sha256(refreshToken), refreshExpiry, ip, userAgent);
        sessionStore.save(session);

        String accessToken = buildAccessToken(account, sessionId);
        return new TokenPair(accessToken, sessionId + "." + refreshToken,
                properties.getJwt().getAccessTokenTtlSeconds(),
                sessionId, account.getUserId(), account.getUsername());
    }

    private String buildAccessToken(UserAccount account, String sessionId) {
        Map<String, Object> claims = jwtService.newClaims();
        claims.put("typ", PrincipalType.USER.name());
        claims.put("sid", sessionId);
        claims.put("name", account.getUsername());
        return jwtService.issue(account.getUserId(), JwtService.PURPOSE_ACCESS,
                properties.getJwt().getAccessTokenTtlSeconds(),
                account.getTokenVersion(), claims);
    }

    // ==================== 刷新 ====================

    public TokenPair refresh(String compositeRefreshToken, String ip, String userAgent) {
        if (compositeRefreshToken == null || !compositeRefreshToken.contains(".")) {
            throw AuthException.unauthorized("刷新令牌格式非法");
        }
        int idx = compositeRefreshToken.indexOf('.');
        String sessionId = compositeRefreshToken.substring(0, idx);
        String rawToken = compositeRefreshToken.substring(idx + 1);

        LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null);
        if (session == null || !session.isActive()) {
            throw AuthException.unauthorized("刷新令牌无效或已过期");
        }
        if (!TokenUtils.constantTimeEquals(session.getRefreshTokenHash(), TokenUtils.sha256(rawToken))) {
            session.setRevoked(true);
            sessionStore.save(session);
            notifyRevoked(session.getPrincipalId(), sessionId, "检测到刷新令牌异常复用，会话已终止");
            logger.warn("检测到刷新令牌复用，已吊销会话 {}（主体 {}）",
                    sessionId, session.getPrincipalId());
            auditService.recordUser(session.getPrincipalId(), AuditService.ACTION_REFRESH_REUSE,
                    AuditService.RESULT_FAILURE, ip,
                    "刷新令牌复用，疑似泄露，已吊销会话 " + sessionId);
            throw AuthException.unauthorized("刷新令牌无效");
        }

        UserAccount account = userStore.findByUserId(session.getPrincipalId()).orElse(null);
        if (account == null) {
            throw AuthException.unauthorized("账号不存在");
        }
        account = assertAccountUsable(account);

        String newRefresh = TokenUtils.randomSecret(32);
        session.setRefreshTokenHash(TokenUtils.sha256(newRefresh));
        session.setRefreshExpiresAt(Instant.now()
                .plusSeconds(properties.getJwt().getRefreshTokenTtlSeconds()));
        session.setLastSeenAt(Instant.now());
        sessionStore.save(session);

        String accessToken = buildAccessToken(account, sessionId);
        return new TokenPair(accessToken, sessionId + "." + newRefresh,
                properties.getJwt().getAccessTokenTtlSeconds(),
                sessionId, account.getUserId(), account.getUsername());
    }

    // ==================== 令牌校验 ====================

    public AuthPrincipal authenticate(String accessToken) {
        Map<String, Object> claims = jwtService.verify(accessToken, JwtService.PURPOSE_ACCESS);
        if (!PrincipalType.USER.name().equals(JwtService.claimAsString(claims, "typ"))) {
            throw AuthException.unauthorized("令牌主体类型不匹配");
        }
        String userId = JwtService.claimAsString(claims, "sub");
        UserAccount account = userStore.findByUserId(userId).orElse(null);
        if (account == null) {
            throw AuthException.unauthorized("账号不存在: " + userId);
        }
        long ver = JwtService.claimAsLong(claims, "ver", -1);
        if (ver != account.getTokenVersion()) {
            throw AuthException.unauthorized("令牌已失效（凭据版本变更）");
        }
        account = assertAccountUsable(account);

        String sessionId = JwtService.claimAsString(claims, "sid");
        LoginSession session = sessionId == null ? null : sessionStore.findBySessionId(sessionId).orElse(null);
        if (session == null || !session.isActive()) {
            throw AuthException.unauthorized("会话已结束");
        }
        session.setLastSeenAt(Instant.now());
        sessionStore.save(session);

        String signalDeviceId = "ctl_" + sessionId;
        return new AuthPrincipal(PrincipalType.USER, userId, signalDeviceId,
                DeviceType.CONTROLLER, sessionId, account.getUsername());
    }

    // ==================== 登出 / 封禁 / 踢线 ====================

    public void logout(String sessionId) {
        LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null);
        if (session != null) {
            session.setRevoked(true);
            sessionStore.save(session);
            notifyRevoked(session.getPrincipalId(), sessionId, "已登出");
            logger.info("会话 {} 已登出", sessionId);
        }
    }

    /** 吊销指定账号的全部会话并使所有令牌失效。 */
    public void revokeAllSessions(String userId, String reason) {
        UserAccount account = userStore.findByUserId(userId).orElse(null);
        if (account != null) {
            account.setTokenVersion(account.getTokenVersion() + 1);
            userStore.save(account);
        }
        sessionStore.revokeByPrincipal(userId);
        notifyRevoked(userId, null, reason);
        logger.info("账号 {} 的全部会话已吊销：{}", userId, reason);
    }

    /** 踢出单个会话。 */
    public void revokeSession(String sessionId, String reason) {
        LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null);
        if (session == null) {
            throw AuthException.badRequest("会话不存在");
        }
        session.setRevoked(true);
        sessionStore.save(session);
        notifyRevoked(session.getPrincipalId(), sessionId, reason);
        logger.info("会话 {} 已被踢出：{}", sessionId, reason);
    }

    public void ban(String userId, Instant until, String reason) {
        UserAccount account = requireUser(userId);
        account.setStatus(until == null ? AccountStatus.BANNED : AccountStatus.SUSPENDED);
        account.setStatusUntil(until);
        account.setStatusReason(reason);
        userStore.save(account);
        revokeAllSessions(userId, "账号已被封禁" + (reason == null ? "" : "：" + reason));
        logger.info("账号 {} 已被封禁（until={}, reason={}）", userId, until, reason);
        auditService.record("ADMIN", null, AuditService.ACTION_BAN, userId,
                AuditService.RESULT_SUCCESS, null,
                (until == null ? "永久封禁" : "临时封禁至 " + until) + "；原因：" + reason);
    }

    public void unban(String userId) {
        UserAccount account = requireUser(userId);
        account.setStatus(AccountStatus.ACTIVE);
        account.setStatusUntil(null);
        account.setStatusReason(null);
        account.setFailedAttempts(0);
        account.setLockedUntil(null);
        userStore.save(account);
        logger.info("账号 {} 已解封", userId);
        auditService.record("ADMIN", null, AuditService.ACTION_UNBAN, userId,
                AuditService.RESULT_SUCCESS, null, "账号已解封");
    }

    /**
     * 修改密码。
     *
     * <p>校验原密码、密码强度，并禁止复用最近
     * {@value com.ttstd.signaling.model.UserAccount#PASSWORD_HISTORY_SIZE} 个密码。
     * 成功后吊销全部会话，强制重新登录。
     */
    public void changePassword(String userId, String oldPassword, String newPassword) {
        UserAccount account = requireUser(userId);
        if (!passwordEncoder.matches(oldPassword, account.getPasswordHash())) {
            auditService.recordUser(userId, AuditService.ACTION_PASSWORD_CHANGED,
                    AuditService.RESULT_FAILURE, null, "原密码校验失败");
            throw AuthException.unauthorized("原密码不正确");
        }
        validatePassword(newPassword, account.getUsername());

        if (passwordEncoder.matches(newPassword, account.getPasswordHash())) {
            throw AuthException.badRequest("新密码不能与当前密码相同");
        }
        for (String historical : account.passwordHistoryList()) {
            if (passwordEncoder.matches(newPassword, historical)) {
                throw AuthException.badRequest("新密码不能与最近使用过的密码相同");
            }
        }

        // 先把旧密码存入历史，再更新为新密码
        account.pushPasswordHistory(account.getPasswordHash());
        account.setPasswordHash(passwordEncoder.encode(newPassword));
        account.setPasswordChangedAt(Instant.now());
        userStore.save(account);

        revokeAllSessions(userId, "密码已修改，请重新登录");
        auditService.recordUser(userId, AuditService.ACTION_PASSWORD_CHANGED,
                AuditService.RESULT_SUCCESS, null, "密码修改成功，已吊销全部会话");
    }

    // ==================== 管理端账号维护 ====================

    /**
     * 管理员直接创建账号，绕过“是否开放注册”开关。
     *
     * <p>注意：此处的账号是普通用户（写入 app_user），与管理员（写入 admin_account）完全隔离。
     * 若需创建后台管理员，请使用 AdminService，而非此接口。
     */
    public UserAccount adminCreateUser(String username, String password) {
        validateUsername(username);
        validatePassword(password, username);
        UserAccount account = createAccountInternal(username, password);
        auditService.record("ADMIN", null, AuditService.ACTION_USER_CREATED, account.getUserId(),
                AuditService.RESULT_SUCCESS, null,
                "管理员创建账号: " + username);
        return account;
    }

    /**
     * 删除账号，同时吊销其全部会话。
     */
    public void deleteUser(String userId) {
        UserAccount account = requireUser(userId);
        revokeAllSessions(userId, "账号已被删除");
        sessionStore.deleteByPrincipal(userId);
        userStore.delete(userId);
        logger.info("账号 {} ({}) 已被删除", account.getUsername(), userId);
        auditService.record("ADMIN", null, AuditService.ACTION_USER_DELETED, userId,
                AuditService.RESULT_SUCCESS, null, "删除账号: " + account.getUsername());
    }

    /**
     * 管理员重置密码：无需原密码，成功后强制该账号全部会话下线。
     */
    public void adminResetPassword(String userId, String newPassword) {
        UserAccount account = requireUser(userId);
        validatePassword(newPassword, account.getUsername());
        account.pushPasswordHistory(account.getPasswordHash());
        account.setPasswordHash(passwordEncoder.encode(newPassword));
        account.setPasswordChangedAt(Instant.now());
        account.setFailedAttempts(0);
        account.setLockedUntil(null);
        userStore.save(account);
        revokeAllSessions(userId, "密码已被管理员重置，请重新登录");
        auditService.record("ADMIN", null, AuditService.ACTION_PASSWORD_CHANGED, userId,
                AuditService.RESULT_SUCCESS, null, "管理员重置密码: " + account.getUsername());
    }

    // ==================== TOTP 双因子 ====================

    /**
     * 生成 TOTP 密钥并返回 otpauth URI。此时尚未启用，需调用
     * {@link #enableTotp(String, String)} 输入一次动态码完成绑定。
     */
    public Map<String, String> setupTotp(String userId) {
        UserAccount account = requireUser(userId);
        if (account.isTotpEnabled()) {
            throw AuthException.badRequest("双因子认证已启用，请先关闭再重新绑定");
        }
        String secret = totpService.generateSecret();
        account.setTotpSecret(secret);
        account.setTotpEnabled(false);
        userStore.save(account);

        String uri = totpService.buildOtpAuthUri(
                properties.getJwt().getIssuer(), account.getUsername(), secret);
        return Map.of("secret", secret, "otpauthUri", uri);
    }

    /** 校验一次动态码并正式启用 TOTP。 */
    public void enableTotp(String userId, String code) {
        UserAccount account = requireUser(userId);
        if (account.getTotpSecret() == null || account.getTotpSecret().isBlank()) {
            throw AuthException.badRequest("请先调用 setup 生成密钥");
        }
        if (account.isTotpEnabled()) {
            throw AuthException.badRequest("双因子认证已启用");
        }
        if (!totpService.verify(account.getTotpSecret(), code)) {
            auditService.recordUser(userId, AuditService.ACTION_TOTP_FAILED,
                    AuditService.RESULT_FAILURE, null, "启用时动态码校验失败");
            throw AuthException.badRequest("动态码不正确");
        }
        account.setTotpEnabled(true);
        userStore.save(account);
        auditService.recordUser(userId, AuditService.ACTION_TOTP_ENABLED,
                AuditService.RESULT_SUCCESS, null, "双因子认证已启用");
    }

    /** 关闭 TOTP，需同时校验密码与动态码，防止令牌被盗后被单方面关闭。 */
    public void disableTotp(String userId, String password, String code) {
        UserAccount account = requireUser(userId);
        if (!account.isTotpEnabled()) {
            throw AuthException.badRequest("双因子认证未启用");
        }
        if (!passwordEncoder.matches(password, account.getPasswordHash())) {
            throw AuthException.unauthorized("密码不正确");
        }
        if (!totpService.verify(account.getTotpSecret(), code)) {
            throw AuthException.badRequest("动态码不正确");
        }
        account.setTotpEnabled(false);
        account.setTotpSecret(null);
        userStore.save(account);
        auditService.recordUser(userId, AuditService.ACTION_TOTP_DISABLED,
                AuditService.RESULT_SUCCESS, null, "双因子认证已关闭");
    }

    private void notifyRevoked(String principalId, String sessionId, String reason) {
        SessionRevocationListener listener = this.revocationListener;
        if (listener != null) {
            try {
                listener.onRevoked(principalId, sessionId, reason);
            } catch (Exception e) {
                logger.warn("下发会话失效通知失败: {}", e.getMessage());
            }
        }
    }

    // ==================== 查询 ====================

    public UserAccount requireUser(String userId) {
        return userStore.findByUserId(userId).orElseThrow(
                () -> AuthException.badRequest("账号不存在"));
    }

    public List<UserAccount> listUsers() {
        return userStore.listAll();
    }

    public List<LoginSession> listSessions(String userId) {
        return sessionStore.listByPrincipal(userId).stream()
                .filter(LoginSession::isActive)
                .sorted(Comparator.comparing(LoginSession::getCreatedAt).reversed())
                .toList();
    }

    /** 清理刷新令牌已过期或已吊销的会话记录（周期性维护）。 */
    public void evictExpiredSessions() {
        Instant now = Instant.now();
        for (LoginSession s : sessionStore.listAll()) {
            if (s.isRevoked() || s.getRefreshExpiresAt().isBefore(now)) {
                sessionStore.delete(s.getSessionId());
            }
        }
    }
}
