<?php
declare(strict_types=1);

class Auth
{
    private const MFA_MAX_ATTEMPTS = 5;

    /**
     * Bcrypt work factor for every password and OTP hash. Bumping this re-hashes
     * existing user passwords on their next successful login via
     * password_needs_rehash(), so the cluster of users at the old cost migrates
     * over time without forcing a password reset. The timing-oracle dummy in
     * dummyHash() reads the same constant so unknown-email and known-email
     * logins keep matching response times.
     */
    public const BCRYPT_COST = 12;

    private static ?array $currentUser = null;

    public static function check(): bool
    {
        return isset($_SESSION['user_id']);
    }

    public static function user(): ?array
    {
        if (self::$currentUser !== null) {
            return self::$currentUser;
        }
        if (!isset($_SESSION['user_id'])) {
            return null;
        }
        $db = Database::getInstance();
        self::$currentUser = $db->fetch(
            'SELECT * FROM users WHERE id = ? AND is_active = 1',
            [$_SESSION['user_id']]
        ) ?: null;
        return self::$currentUser;
    }

    public static function id(): ?int
    {
        return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
    }

    public static function isAdmin(): bool
    {
        $user = self::user();
        return $user !== null && $user['role'] === 'admin';
    }

    public static function getGroupIds(): array
    {
        $uid = self::id();
        if (!$uid) return [];
        $db = Database::getInstance();
        $rows = $db->fetchAll('SELECT group_id FROM group_members WHERE user_id = ?', [$uid]);
        return array_column($rows, 'group_id');
    }

    public static function getManagedGroupIds(): array
    {
        $uid = self::id();
        if (!$uid) return [];
        $db = Database::getInstance();
        $rows = $db->fetchAll("SELECT group_id FROM group_members WHERE user_id = ? AND role = 'admin'", [$uid]);
        return array_column($rows, 'group_id');
    }

    public static function isGroupAdmin(): bool
    {
        if (self::id() === null) return false;
        return count(self::getManagedGroupIds()) > 0;
    }

    /**
     * Returns true if AI is blocked for the current user.
     * A user is blocked when at least one of their groups has ai_disabled = 1.
     * Global admins are never blocked.
     */
    public static function isAiBlockedForUser(): bool
    {
        if (self::isAdmin()) return false;
        $uid = self::id();
        if (!$uid) return false;
        $db = Database::getInstance();
        $row = $db->fetch(
            'SELECT 1 FROM group_members gm
             JOIN groups g ON g.id = gm.group_id
             WHERE gm.user_id = ? AND g.ai_disabled = 1
             LIMIT 1',
            [$uid]
        );
        return (bool)$row;
    }

    public static function requireGroupAdmin(): void
    {
        self::requireLogin();
        if (!self::isAdmin() && !self::isGroupAdmin()) {
            Helper::redirect('/dashboard', ['error' => 'access_denied']);
        }
    }

    /**
     * Attempt login with email + password.
     * Returns:
     *   true    – fully logged in (no TOTP)
     *   'totp'  – credentials OK, TOTP verification required (pending session set)
     *   false   – invalid credentials
     */
    private static function getClientIp(): string
    {
        return Helper::clientIp() ?? '0.0.0.0';
    }

    public static function isRateLimited(): bool
    {
        $db      = Database::getInstance();
        $ip      = self::getClientIp();
        $window  = time() - 900; // 15-minute window
        $count   = $db->fetch(
            'SELECT COUNT(*) as c FROM login_attempts WHERE ip = ? AND attempted_at > ?',
            [$ip, $window]
        )['c'] ?? 0;
        return $count >= 10;
    }

    public static function recordMfaFailure(): void
    {
        self::recordFailedAttempt();
        SecurityLog::log('mfa_failure', SecurityLog::SEV_WARN);
    }

    private static function recordFailedAttempt(): void
    {
        $db = Database::getInstance();
        $ip = self::getClientIp();
        $db->execute('INSERT INTO login_attempts (ip, attempted_at) VALUES (?, ?)', [$ip, time()]);
        // Clean up old entries (keep DB small)
        $db->execute('DELETE FROM login_attempts WHERE attempted_at < ?', [time() - 3600]);
    }

    /** Public wrapper so non-Auth callers (WebAuthn) can feed the IP rate limiter. */
    public static function recordIpFailure(): void
    {
        self::recordFailedAttempt();
    }

    private static function clearFailedAttempts(): void
    {
        $db = Database::getInstance();
        $db->execute('DELETE FROM login_attempts WHERE ip = ?', [self::getClientIp()]);
    }

    /**
     * True if the user has any second factor configured (TOTP, Email-MFA,
     * or at least one WebAuthn credential). Per-account lockout is skipped
     * for these users because 2FA already protects against brute-force.
     */
    public static function userHasMfa(array $user): bool
    {
        if (!empty($user['totp_enabled']))      return true;
        if (!empty($user['email_mfa_enabled'])) return true;
        $row = Database::getInstance()->fetch(
            'SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1',
            [(int)$user['id']]
        );
        return (bool)$row;
    }

    /** Returns true if account-level lockout is enabled in settings. */
    private static function lockoutEnabled(): bool
    {
        return Helper::getSetting('account_lockout_enabled', '1') === '1';
    }

    /**
     * Per-account brute-force protection. Increments the failed counter and
     * sets locked_until once the configured threshold is reached. Skipped for
     * users who already have a second factor configured.
     */
    public static function recordAccountFailure(array $user): void
    {
        if (!self::lockoutEnabled() || self::userHasMfa($user)) return;

        $db        = Database::getInstance();
        $threshold = max(1, (int)Helper::getSetting('account_lockout_threshold', '5'));
        $duration  = max(1, (int)Helper::getSetting('account_lockout_duration_minutes', '15'));
        $count     = (int)($user['failed_login_count'] ?? 0) + 1;

        if ($count >= $threshold) {
            $db->execute(
                'UPDATE users SET failed_login_count = ?, locked_until = ? WHERE id = ?',
                [$count, time() + $duration * 60, (int)$user['id']]
            );
            SecurityLog::log('account_lockout_triggered', SecurityLog::SEV_CRITICAL, [
                'attempts'         => $count,
                'duration_minutes' => $duration,
            ], (int)$user['id']);
        } else {
            $db->execute(
                'UPDATE users SET failed_login_count = ? WHERE id = ?',
                [$count, (int)$user['id']]
            );
        }
    }

    /** Reset the failed-login counter and lock state. Called on successful auth. */
    public static function clearAccountFailures(int $userId): void
    {
        Database::getInstance()->execute(
            'UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = ?',
            [$userId]
        );
    }

    /**
     * True if the account is currently locked. MFA-protected accounts are
     * never locked (the lockout is meant to mitigate password-only brute-force).
     */
    public static function isAccountLocked(array $user): bool
    {
        if (!self::lockoutEnabled() || self::userHasMfa($user)) return false;
        $until = (int)($user['locked_until'] ?? 0);
        return $until > time();
    }

    /** Stamp the freshly authenticated session with login + last-activity timestamps. */
    public static function stampSessionLogin(): void
    {
        $now = time();
        $_SESSION['_login_time']    = $now;
        $_SESSION['_last_activity'] = $now;
    }

    /**
     * Lazy-generated dummy bcrypt hash. Used to verify against a non-existent
     * user so the login response time does not leak account existence.
     * Generated once per process — the cost dominates over the SELECT, so the
     * subsequent password_verify against this dummy gives the same timing
     * profile as a verify against a real user's hash.
     */
    private static ?string $dummyHash = null;
    private static function dummyHash(): string
    {
        return self::$dummyHash ??= password_hash('plenvo-no-such-user', PASSWORD_BCRYPT, ['cost' => self::BCRYPT_COST]);
    }

    public static function login(string $email, string $password): bool|string
    {
        if (self::isRateLimited()) {
            return 'rate_limited';
        }

        $db = Database::getInstance();
        $user = $db->fetch(
            'SELECT * FROM users WHERE email = ?',
            [strtolower(trim($email))]
        );

        // Per-account lockout (only if user exists and has no MFA)
        if ($user && self::isAccountLocked($user)) {
            self::recordFailedAttempt();
            SecurityLog::log('login_locked', SecurityLog::SEV_CRITICAL, ['email_hint' => Helper::maskEmail($email)], (int)$user['id']);
            return 'account_locked';
        }

        // External auth providers (LDAP plugin, future OIDC) get a chance to
        // authenticate before the local password path runs. The filter receives
        // the email/password and the existing user row (or null for unknown
        // emails). It must return either an array (successful external auth,
        // possibly JIT-provisioned) or null to fall through to local auth.
        // The filter is only meaningful when the local user (if any) has
        // auth_provider != 'local' or does not exist yet — for local-provider
        // users the filter is expected to return null so the bcrypt path runs.
        $externalUser = PluginManager::filter('auth_external_login', null, [
            'email'    => strtolower(trim($email)),
            'password' => $password,
            'user'     => $user,
        ]);

        $isExternalAuth = false;
        if (is_array($externalUser)) {
            $user           = $externalUser;
            $passwordOk     = true;
            $isExternalAuth = true;
        } else {
            // Always run password_verify so the response time is identical regardless
            // of whether the email exists in the DB (mitigates user-enumeration via
            // timing oracle — see security-audit #5). LDAP users carry an
            // unverifiable hash marker, so this branch will return false for them
            // when the LDAP plugin is disabled or refused to authenticate.
            $hashToCheck = $user['password'] ?? self::dummyHash();
            $passwordOk  = password_verify($password, $hashToCheck);
        }

        if (!$user || !$passwordOk) {
            self::recordFailedAttempt();
            if ($user) self::recordAccountFailure($user);
            SecurityLog::log('login_failure', SecurityLog::SEV_WARN, ['email_hint' => Helper::maskEmail($email), 'reason' => $user ? 'wrong_password' : 'unknown_email'], $user['id'] ?? null);
            return false;
        }

        // Upgrade legacy bcrypt hashes to the current BCRYPT_COST. Skipped for
        // external-auth users (LDAP etc.) whose stored "password" is a non-
        // verifiable marker rather than a real bcrypt of the user's secret.
        if (!$isExternalAuth && !empty($user['password'])
            && password_needs_rehash($user['password'], PASSWORD_BCRYPT, ['cost' => self::BCRYPT_COST])) {
            try {
                $newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => self::BCRYPT_COST]);
                Database::getInstance()->execute(
                    'UPDATE users SET password = ? WHERE id = ?',
                    [$newHash, (int)$user['id']]
                );
                $user['password'] = $newHash;
            } catch (\Throwable $e) {
                // Rehashing is opportunistic: a failed UPDATE must not block login.
                error_log('Auth: password rehash failed for user ' . (int)$user['id'] . ': ' . $e->getMessage());
            }
        }

        // Account registered but email not yet verified
        if (!$user['is_active'] && !empty($user['email_verify_token'])) {
            self::recordFailedAttempt();
            return 'email_unverified';
        }

        // Account deactivated by admin
        if (!$user['is_active']) {
            self::recordFailedAttempt();
            return false;
        }

        self::clearFailedAttempts();
        self::clearAccountFailures((int)$user['id']);

        // Both TOTP and Email MFA enabled: let user choose
        if (!empty($user['totp_enabled']) && !empty($user['email_mfa_enabled'])) {
            session_regenerate_id(true);
            $_SESSION['mfa_choice_id']      = $user['id'];
            $_SESSION['mfa_choice_lang']    = $user['language'];
            $_SESSION['mfa_choice_theme']   = $user['theme'];
            $_SESSION['mfa_choice_expires'] = time() + 300;
            unset($_SESSION['user_id']);
            self::$currentUser = null;
            return 'mfa_choice';
        }

        // TOTP enabled: existing flow
        if (!empty($user['totp_enabled'])) {
            session_regenerate_id(true);
            $_SESSION['totp_pending_id']      = $user['id'];
            $_SESSION['totp_pending_lang']    = $user['language'];
            $_SESSION['totp_pending_theme']   = $user['theme'];
            $_SESSION['totp_pending_expires'] = time() + 300;
            unset($_SESSION['user_id']);
            self::$currentUser = null;
            return 'totp';
        }

        // Email MFA enabled: send code
        if (!empty($user['email_mfa_enabled'])) {
            self::generateAndSendEmailMfaCode($user['id'], $user['email']);
            session_regenerate_id(true);
            $_SESSION['email_mfa_pending_id']      = $user['id'];
            $_SESSION['email_mfa_pending_lang']    = $user['language'];
            $_SESSION['email_mfa_pending_theme']   = $user['theme'];
            $_SESSION['email_mfa_pending_expires'] = time() + 600;
            unset($_SESSION['user_id']);
            self::$currentUser = null;
            return 'email_mfa';
        }

        // MFA globally enforced and user has neither method configured
        if (Helper::getSetting('mfa_enforce', '0') === '1') {
            session_regenerate_id(true);
            $_SESSION['mfa_enforce_id']      = $user['id'];
            $_SESSION['mfa_enforce_lang']    = $user['language'];
            $_SESSION['mfa_enforce_theme']   = $user['theme'];
            $_SESSION['mfa_enforce_expires'] = time() + 600;
            unset($_SESSION['user_id']);
            self::$currentUser = null;
            return 'mfa_enforce';
        }

        session_regenerate_id(true);
        $_SESSION['user_id'] = $user['id'];
        $_SESSION['lang']    = $user['language'];
        $_SESSION['theme']   = $user['theme'];
        self::stampSessionLogin();

        // Reset the inactive-account warning timestamp on every successful login.
        // The cleanup sweep treats NULL as "never warned" and re-evaluates on
        // the next pass, so a user who logs in is automatically out of the
        // grace-period countdown.
        $db->execute('UPDATE users SET last_login = CURRENT_TIMESTAMP, inactive_warning_sent_at = NULL WHERE id = ?', [$user['id']]);
        self::$currentUser = null;

        SecurityLog::log('login_success', SecurityLog::SEV_INFO, ['method' => 'password'], (int)$user['id']);
        return true;
    }

    /** Complete the enforced TOTP setup login (sets user_id session). */
    public static function completeTotpEnforceLogin(): bool
    {
        if (empty($_SESSION['totp_enforce_id'])) {
            return false;
        }
        if (time() > ($_SESSION['totp_enforce_expires'] ?? 0)) {
            self::clearMfaPending('totp_enforce');
            return false;
        }
        $userId = (int)$_SESSION['totp_enforce_id'];
        $lang   = $_SESSION['totp_enforce_lang']  ?? 'de';
        $theme  = $_SESSION['totp_enforce_theme'] ?? 'light';

        self::clearMfaPending('totp_enforce');

        session_regenerate_id(true);
        $_SESSION['user_id'] = $userId;
        $_SESSION['lang']    = $lang;
        $_SESSION['theme']   = $theme;
        self::stampSessionLogin();

        $db = Database::getInstance();
        $db->execute('UPDATE users SET last_login = CURRENT_TIMESTAMP, inactive_warning_sent_at = NULL WHERE id = ?', [$userId]);
        self::clearAccountFailures($userId);
        self::$currentUser = null;

        SecurityLog::log('login_success', SecurityLog::SEV_INFO, ['method' => 'totp_enforce_setup'], $userId);
        return true;
    }

    /** Complete login after successful TOTP verification. */
    public static function completeTotpLogin(): bool
    {
        if (empty($_SESSION['totp_pending_id'])) {
            return false;
        }
        if (time() > ($_SESSION['totp_pending_expires'] ?? 0)) {
            self::clearMfaPending('totp_pending');
            return false;
        }
        $userId = (int)$_SESSION['totp_pending_id'];
        $lang   = $_SESSION['totp_pending_lang']  ?? 'de';
        $theme  = $_SESSION['totp_pending_theme'] ?? 'light';

        self::clearMfaPending('totp_pending');

        session_regenerate_id(true);
        $_SESSION['user_id'] = $userId;
        $_SESSION['lang']    = $lang;
        $_SESSION['theme']   = $theme;
        self::stampSessionLogin();

        $db = Database::getInstance();
        $db->execute('UPDATE users SET last_login = CURRENT_TIMESTAMP, inactive_warning_sent_at = NULL WHERE id = ?', [$userId]);
        self::clearAccountFailures($userId);
        self::$currentUser = null;

        SecurityLog::log('login_success', SecurityLog::SEV_INFO, ['method' => 'totp'], $userId);
        return true;
    }

    // ── Email MFA ─────────────────────────────────────────────────────────────

    private static function generateAndSendEmailMfaCode(int $userId, string $email): void
    {
        $code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
        $db   = Database::getInstance();
        $db->execute('DELETE FROM email_mfa_codes WHERE user_id = ?', [$userId]);
        $db->execute(
            'INSERT INTO email_mfa_codes (user_id, code_hash, expires_at) VALUES (?, ?, ?)',
            [$userId, password_hash($code, PASSWORD_BCRYPT, ['cost' => self::BCRYPT_COST]), time() + 600]
        );
        Mailer::sendEmailMfaCode($email, $code);
    }

    /** Transition from mfa_choice session to TOTP or Email MFA pending session. */
    public static function chooseMfaMethod(string $method): string
    {
        if (empty($_SESSION['mfa_choice_id'])) return 'invalid';
        if (time() > ($_SESSION['mfa_choice_expires'] ?? 0)) {
            self::clearMfaPending('mfa_choice');
            return 'expired';
        }

        $userId = (int)$_SESSION['mfa_choice_id'];
        $lang   = $_SESSION['mfa_choice_lang']  ?? 'de';
        $theme  = $_SESSION['mfa_choice_theme'] ?? 'light';
        self::clearMfaPending('mfa_choice');

        if ($method === 'totp') {
            session_regenerate_id(true);
            $_SESSION['totp_pending_id']      = $userId;
            $_SESSION['totp_pending_lang']    = $lang;
            $_SESSION['totp_pending_theme']   = $theme;
            $_SESSION['totp_pending_expires'] = time() + 300;
            return 'totp';
        }

        // email
        $db   = Database::getInstance();
        $user = $db->fetch('SELECT email FROM users WHERE id = ?', [$userId]);
        if (!$user) return 'invalid';
        self::generateAndSendEmailMfaCode($userId, $user['email']);
        session_regenerate_id(true);
        $_SESSION['email_mfa_pending_id']      = $userId;
        $_SESSION['email_mfa_pending_lang']    = $lang;
        $_SESSION['email_mfa_pending_theme']   = $theme;
        $_SESSION['email_mfa_pending_expires'] = time() + 600;
        return 'email_mfa';
    }

    public static function resendEmailMfaCode(): bool
    {
        $userId = (int)($_SESSION['email_mfa_pending_id'] ?? 0);
        if (!$userId) return false;
        $db   = Database::getInstance();
        $user = $db->fetch('SELECT email FROM users WHERE id = ?', [$userId]);
        if (!$user) return false;
        self::generateAndSendEmailMfaCode($userId, $user['email']);
        $_SESSION['email_mfa_pending_expires'] = time() + 600;
        return true;
    }

    public static function completeEmailMfaLogin(string $code): bool
    {
        if (empty($_SESSION['email_mfa_pending_id'])) return false;
        if (time() > ($_SESSION['email_mfa_pending_expires'] ?? 0)) {
            self::clearMfaPending('email_mfa_pending');
            return false;
        }
        $userId = (int)$_SESSION['email_mfa_pending_id'];
        $lang   = $_SESSION['email_mfa_pending_lang']  ?? 'de';
        $theme  = $_SESSION['email_mfa_pending_theme'] ?? 'light';

        $db  = Database::getInstance();
        $row = $db->fetch(
            'SELECT * FROM email_mfa_codes WHERE user_id = ? AND expires_at > ? AND used_at IS NULL ORDER BY id DESC LIMIT 1',
            [$userId, time()]
        );
        if (!$row) return false;

        // DB-side brute-force guard: independent from the per-session counter so
        // an attacker cannot reset progress by clearing cookies.
        if ((int)($row['attempts'] ?? 0) >= self::MFA_MAX_ATTEMPTS) {
            $db->execute('UPDATE email_mfa_codes SET used_at = ? WHERE id = ?', [time(), $row['id']]);
            return false;
        }

        if (!password_verify(trim($code), $row['code_hash'])) {
            $newAttempts = (int)($row['attempts'] ?? 0) + 1;
            if ($newAttempts >= self::MFA_MAX_ATTEMPTS) {
                $db->execute('UPDATE email_mfa_codes SET attempts = ?, used_at = ? WHERE id = ?', [$newAttempts, time(), $row['id']]);
            } else {
                $db->execute('UPDATE email_mfa_codes SET attempts = ? WHERE id = ?', [$newAttempts, $row['id']]);
            }
            return false;
        }

        // Conditional UPDATE: only consume the code if it has not already been
        // used by a parallel request (security-audit #17). If rowCount is 0,
        // someone else won the race and we must reject this attempt.
        $consumed = $db->execute(
            'UPDATE email_mfa_codes SET used_at = ? WHERE id = ? AND used_at IS NULL',
            [time(), $row['id']]
        );
        if ($consumed === 0) return false;
        self::clearMfaPending('email_mfa_pending');

        session_regenerate_id(true);
        $_SESSION['user_id'] = $userId;
        $_SESSION['lang']    = $lang;
        $_SESSION['theme']   = $theme;
        self::stampSessionLogin();

        $db->execute('UPDATE users SET last_login = CURRENT_TIMESTAMP, inactive_warning_sent_at = NULL WHERE id = ?', [$userId]);
        self::clearAccountFailures($userId);
        self::$currentUser = null;
        SecurityLog::log('login_success', SecurityLog::SEV_INFO, ['method' => 'email_mfa'], $userId);
        return true;
    }

    /** Complete login after MFA enforce setup (TOTP path). */
    public static function completeMfaEnforceLogin(): bool
    {
        if (empty($_SESSION['mfa_enforce_id'])) return false;
        if (time() > ($_SESSION['mfa_enforce_expires'] ?? 0)) {
            self::clearMfaPending('mfa_enforce');
            return false;
        }
        $userId = (int)$_SESSION['mfa_enforce_id'];
        $lang   = $_SESSION['mfa_enforce_lang']  ?? 'de';
        $theme  = $_SESSION['mfa_enforce_theme'] ?? 'light';

        self::clearMfaPending('mfa_enforce');

        session_regenerate_id(true);
        $_SESSION['user_id'] = $userId;
        $_SESSION['lang']    = $lang;
        $_SESSION['theme']   = $theme;
        self::stampSessionLogin();

        $db = Database::getInstance();
        $db->execute('UPDATE users SET last_login = CURRENT_TIMESTAMP, inactive_warning_sent_at = NULL WHERE id = ?', [$userId]);
        self::clearAccountFailures($userId);
        self::$currentUser = null;
        SecurityLog::log('login_success', SecurityLog::SEV_INFO, ['method' => 'mfa_enforce_setup'], $userId);
        return true;
    }

    /** Activate email MFA during enforce setup and transition to email_mfa_pending session. */
    public static function activateEmailMfaFromEnforce(): bool
    {
        if (empty($_SESSION['mfa_enforce_id'])) return false;
        if (time() > ($_SESSION['mfa_enforce_expires'] ?? 0)) {
            self::clearMfaPending('mfa_enforce');
            return false;
        }
        $userId = (int)$_SESSION['mfa_enforce_id'];
        $lang   = $_SESSION['mfa_enforce_lang']  ?? 'de';
        $theme  = $_SESSION['mfa_enforce_theme'] ?? 'light';

        $db   = Database::getInstance();
        $user = $db->fetch('SELECT email FROM users WHERE id = ?', [$userId]);
        if (!$user) return false;

        $db->execute('UPDATE users SET email_mfa_enabled = 1 WHERE id = ?', [$userId]);
        self::generateAndSendEmailMfaCode($userId, $user['email']);

        self::clearMfaPending('mfa_enforce');

        // Regenerate session ID on every MFA-state transition for consistency
        // with the other transition helpers (security-audit #18).
        session_regenerate_id(true);
        $_SESSION['email_mfa_pending_id']      = $userId;
        $_SESSION['email_mfa_pending_lang']    = $lang;
        $_SESSION['email_mfa_pending_theme']   = $theme;
        $_SESSION['email_mfa_pending_expires'] = time() + 600;
        return true;
    }

    /**
     * Increment the per-pending-session MFA attempt counter. Returns true if
     * the configured limit has been exceeded; in that case the pending-session
     * keys are wiped so the user must restart the login flow.
     *
     * $kind is the session-key prefix, e.g. 'totp_pending', 'email_mfa_pending',
     * 'totp_enforce', 'mfa_enforce'.
     */
    public static function bumpMfaAttempts(string $kind): bool
    {
        $key = $kind . '_attempts';
        $n   = (int)($_SESSION[$key] ?? 0) + 1;
        $_SESSION[$key] = $n;
        if ($n >= self::MFA_MAX_ATTEMPTS) {
            self::clearMfaPending($kind);
            return true;
        }
        return false;
    }

    public static function clearMfaPending(string $kind): void
    {
        foreach (['_id', '_lang', '_theme', '_expires', '_attempts'] as $suffix) {
            unset($_SESSION[$kind . $suffix]);
        }
    }

    /**
     * Verify a TOTP code with replay protection: rejects codes whose counter
     * is <= the user's stored totp_last_counter, and stores the new counter
     * on success. Use this for both login and setup flows.
     */
    public static function verifyTotpCode(int $userId, string $secret, string $code): bool
    {
        $db   = Database::getInstance();
        $row  = $db->fetch('SELECT totp_last_counter FROM users WHERE id = ?', [$userId]);
        $last = $row ? (int)($row['totp_last_counter'] ?? 0) : 0;

        $matched = TOTP::verifyAndGetCounter($secret, $code, 1, $last);
        if ($matched === null) return false;

        $db->execute('UPDATE users SET totp_last_counter = ? WHERE id = ?', [$matched, $userId]);
        return true;
    }

    public static function logout(): void
    {
        $_SESSION = [];
        if (ini_get('session.use_cookies')) {
            $p = session_get_cookie_params();
            setcookie(session_name(), '', time() - 42000,
                $p['path'], $p['domain'], $p['secure'], $p['httponly']);
        }
        session_destroy();
        self::$currentUser = null;
    }

    // Project-level permission checks
    // Roles: owner > manager > member > guest
    public static function getProjectRole(int $projectId): ?string
    {
        if (!self::check()) return null;
        if (self::isAdmin()) return 'owner'; // Global admins have full access

        $db = Database::getInstance();
        $member = $db->fetch(
            'SELECT role FROM project_members WHERE project_id = ? AND user_id = ?',
            [$projectId, self::id()]
        );
        return $member ? $member['role'] : null;
    }

    public static function canViewProject(int $projectId): bool
    {
        return self::getProjectRole($projectId) !== null;
    }

    public static function canEditProject(int $projectId): bool
    {
        return in_array(self::getProjectRole($projectId), ['owner', 'manager']);
    }

    public static function canManageProject(int $projectId): bool
    {
        return self::getProjectRole($projectId) === 'owner';
    }

    public static function canCreateTask(int $projectId): bool
    {
        return in_array(self::getProjectRole($projectId), ['owner', 'manager', 'member']);
    }

    public static function canEditTask(int $projectId): bool
    {
        return in_array(self::getProjectRole($projectId), ['owner', 'manager', 'member']);
    }

    public static function requireLogin(): void
    {
        if (!self::check()) {
            Helper::redirect('/login');
        }
    }

    public static function requireAdmin(): void
    {
        self::requireLogin();
        if (!self::isAdmin()) {
            Helper::redirect('/dashboard', ['error' => 'access_denied']);
        }
        header('Cache-Control: no-store');
    }

    public static function requireProjectAccess(int $projectId): void
    {
        self::requireLogin();
        if (!self::canViewProject($projectId)) {
            Helper::redirect('/dashboard', ['error' => 'access_denied']);
        }
    }

    public static function generateCsrfToken(): string
    {
        if (empty($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf_token'];
    }

    public static function verifyCsrfToken(string $token): bool
    {
        return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
    }

    public static function canCreateGroups(): bool
    {
        if (self::isAdmin()) return true;
        $user = self::user();
        return !empty($user['can_create_groups']);
    }
}
