<?php
declare(strict_types=1);

class AuthController
{
    public static function install(): void
    {
        if (file_exists(DB_PATH)) {
            Helper::redirect('/login');
        }

        $errors = [];
        if (Helper::isPost()) {
            // Concurrency lock: prevents two parallel installer POSTs from each
            // creating an admin / running schema twice (security-audit #21).
            // Stored under sys_get_temp_dir() because DATA_PATH may not exist
            // yet on a totally fresh install.
            $lockFile = sys_get_temp_dir() . '/plenvo_install.lock';
            $lockFh   = @fopen($lockFile, 'c');
            if ($lockFh === false || !flock($lockFh, LOCK_EX | LOCK_NB)) {
                $errors[] = 'Eine andere Installation läuft gerade. Bitte später erneut versuchen.';
                if ($lockFh) @fclose($lockFh);
                view('auth/install', ['errors' => $errors, 'checks' => self::requirements()], 'auth');
                return;
            }
            // Re-check after acquiring the lock — the other concurrent installer
            // may have just finished.
            if (file_exists(DB_PATH)) {
                @flock($lockFh, LOCK_UN);
                @fclose($lockFh);
                Helper::redirect('/login');
            }
            register_shutdown_function(function () use ($lockFh, $lockFile) {
                @flock($lockFh, LOCK_UN);
                @fclose($lockFh);
                @unlink($lockFile);
            });
            $username = trim(Helper::post('username'));
            $email    = strtolower(trim(Helper::post('email')));
            $password = Helper::post('password');
            $confirm  = Helper::post('password_confirm');
            $appUrl   = rtrim(trim(Helper::post('app_url', '')), '/');

            if (empty($username) || strlen($username) < 3) $errors[] = Lang::get('username_min');
            if (!filter_var($email, FILTER_VALIDATE_EMAIL))  $errors[] = Lang::get('invalid_email');
            // Install runs before settings exist; just enforce length here.
            if (strlen($password) < 8)                       $errors[] = t('password_min');
            if ($password !== $confirm)                       $errors[] = t('password_mismatch');

            if (empty($errors)) {
                // Create data directories
                foreach ([DATA_PATH, UPLOAD_PATH, DATA_PATH . '/avatars'] as $dir) {
                    if (!is_dir($dir) && !mkdir($dir, 0775, true)) {
                        $errors[] = 'Could not create directory: ' . $dir . ' – check write permissions.';
                    }
                }
                // Write .htaccess files to block direct HTTP access to data/
                $htaccess = DATA_PATH . '/.htaccess';
                if (empty($errors) && !file_exists($htaccess)) {
                    file_put_contents($htaccess, 'Require all denied' . PHP_EOL);
                }
                $uploadsHtaccess = UPLOAD_PATH . '/.htaccess';
                if (empty($errors) && !file_exists($uploadsHtaccess)) {
                    file_put_contents($uploadsHtaccess,
                        'Require all denied' . PHP_EOL .
                        PHP_EOL .
                        '# Only allow image and document downloads via PHP' . PHP_EOL .
                        '<FilesMatch "\.(php|php3|php4|php5|phtml|pl|py|jsp|asp|sh|cgi)$">' . PHP_EOL .
                        '    Require all denied' . PHP_EOL .
                        '</FilesMatch>' . PHP_EOL
                    );
                }
            }

            if (empty($errors)) {
                // Create database
                Database::schema();
                $db = Database::getInstance();

                // Create admin user
                $db->insert(
                    'INSERT INTO users (username, email, password, role) VALUES (?, ?, ?, ?)',
                    [$username, $email, password_hash($password, PASSWORD_BCRYPT, ['cost' => Auth::BCRYPT_COST]), 'admin']
                );

                // Default settings
                $defaults = [
                    'app_name'              => 'Plenvo',
                    'app_url'               => $appUrl,
                    'notifications_enabled' => '1',
                    'allow_registration'    => '0',
                    'registration_domains'  => '',
                    'smtp_host'             => '',
                    'smtp_port'             => '587',
                    'smtp_encryption'       => 'tls',
                    'smtp_username'         => '',
                    'smtp_password'         => '',
                    'smtp_from_email'       => $email,
                    'smtp_from_name'        => 'Plenvo',
                ];
                foreach ($defaults as $key => $value) {
                    $db->execute(
                        'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING',
                        [$key, $value]
                    );
                }

                Helper::redirect('/login', ['success' => 'install_success']);
            }
        }

        view('auth/install', ['errors' => $errors, 'checks' => self::requirements()], 'auth');
    }

    private static function requirements(): array
    {
        return [
            'required' => [
                [
                    'label'  => 'PHP &ge; 8.1',
                    'ok'     => version_compare(PHP_VERSION, '8.1.0', '>='),
                    'detail' => 'PHP ' . PHP_VERSION . ' detected',
                ],
                [
                    'label'  => 'PDO SQLite',
                    'ok'     => extension_loaded('pdo_sqlite'),
                    'detail' => 'pdo_sqlite',
                ],
                [
                    'label'  => 'mbstring',
                    'ok'     => extension_loaded('mbstring'),
                    'detail' => 'mbstring',
                ],
                [
                    'label'  => 'fileinfo',
                    'ok'     => extension_loaded('fileinfo'),
                    'detail' => 'fileinfo',
                ],
                [
                    'label'  => 'OpenSSL',
                    'ok'     => extension_loaded('openssl'),
                    'detail' => 'openssl',
                ],
                [
                    'label'  => 'DOM',
                    'ok'     => extension_loaded('dom'),
                    'detail' => 'dom (HTML sanitizer)',
                ],
                [
                    'label'  => 'Zip',
                    'ok'     => extension_loaded('zip'),
                    'detail' => 'zip (auto-updater)',
                ],
                [
                    'label'  => t('sys_req_write_permission'),
                    'ok'     => is_writable(BASE_PATH),
                    'detail' => BASE_PATH,
                ],
            ],
            'optional' => [
                [
                    'label'  => 'cURL',
                    'ok'     => extension_loaded('curl'),
                    'detail' => t('sys_req_curl_detail'),
                ],
                [
                    'label'  => 'LDAP',
                    'ok'     => extension_loaded('ldap'),
                    'detail' => t('sys_req_ldap_detail'),
                ],
            ],
        ];
    }

    public static function login(): void
    {
        if (Auth::check()) {
            Helper::redirect('/dashboard');
        }

        $error   = Helper::flash('error');
        $success = Helper::flash('success');

        // Validate redirect target: must be a relative path starting with / but not //
        $redirectTo = Helper::get('redirect', '');
        if (!preg_match('/^\\/[a-zA-Z0-9\\/_\\-\\.?=&%]*$/', $redirectTo) || str_starts_with(rawurldecode($redirectTo), '//')) {
            $redirectTo = '';
        }

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $email      = Helper::post('email');
            $password   = Helper::post('password');
            $redirectTo = Helper::post('redirect_to', '');
            if (!preg_match('/^\\/[a-zA-Z0-9\\/_\\-\\.?=&%]*$/', $redirectTo) || str_starts_with(rawurldecode($redirectTo), '//')) {
                $redirectTo = '';
            }

            $result = Auth::login($email, $password);
            if ($result === true) {
                $user = Auth::user();
                Lang::load($user['language'] ?? 'de');
                Helper::redirect($redirectTo ?: '/dashboard');
            } elseif ($result === 'totp') {
                Helper::redirect('/totp-verify');
            } elseif ($result === 'mfa_choice') {
                Helper::redirect('/mfa-choice');
            } elseif ($result === 'email_mfa') {
                Helper::redirect('/email-mfa-verify');
            } elseif ($result === 'mfa_enforce') {
                Helper::redirect('/mfa-setup');
            } elseif ($result === 'totp_enforce') {
                Helper::redirect('/totp-setup-forced');
            } elseif ($result === 'rate_limited') {
                $error = Lang::get('login_rate_limited');
            } elseif ($result === 'account_locked') {
                $error = Lang::get('login_account_locked');
            } elseif ($result === 'email_unverified') {
                $error = Lang::get('verify_email_required');
            } else {
                $error = Lang::get('login_error');
            }
        }

        view('auth/login', ['error' => $error, 'success' => $success, 'redirectTo' => $redirectTo], 'auth');
    }

    public static function totpVerify(): void
    {
        // If already logged in, go to dashboard
        if (Auth::check()) {
            Helper::redirect('/dashboard');
        }
        // If no pending TOTP session, back to login
        if (empty($_SESSION['totp_pending_id'])) {
            Helper::redirect('/login');
        }

        $error = null;

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $code = trim(Helper::post('code', ''));
            $db   = Database::getInstance();
            $user = $db->fetch('SELECT * FROM users WHERE id = ?', [(int)$_SESSION['totp_pending_id']]);

            if ($user && Auth::verifyTotpCode((int)$user['id'], $user['totp_secret'], $code)) {
                Auth::completeTotpLogin();
                $user = Auth::user();
                Lang::load($user['language'] ?? 'de');
                Helper::redirect('/dashboard');
            } else {
                Auth::recordMfaFailure();
                if (Auth::bumpMfaAttempts('totp_pending')) {
                    Helper::redirect('/login', ['error' => Lang::get('mfa_attempts_exceeded')]);
                }
                $error = Lang::get('totp_invalid');
            }
        }

        view('auth/totp-verify', ['error' => $error], 'auth');
    }

    public static function totpSetupForced(): void
    {
        // Must not be fully logged in yet
        if (Auth::check()) {
            Helper::redirect('/dashboard');
        }
        // Must have a pending enforce session
        if (empty($_SESSION['totp_enforce_id'])) {
            Helper::redirect('/login');
        }

        $db     = Database::getInstance();
        $userId = (int)$_SESSION['totp_enforce_id'];
        $error  = null;

        // Ensure the user has a secret (generate one if not yet)
        $user = $db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
        if (!$user) {
            Helper::redirect('/login');
        }

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action');

            if ($action === 'totp_generate_forced') {
                $secret = TOTP::generateSecret();
                $db->execute('UPDATE users SET totp_secret=? WHERE id=?', [Crypto::encrypt($secret), $userId]);
                // Re-fetch to get the new secret into $user
                Helper::redirect('/totp-setup-forced');
            }

            if ($action === 'totp_enable_forced') {
                // Always use the server-stored secret; never trust POST input
                $secret = $user['totp_secret'] ?? '';
                $code   = trim(Helper::post('totp_code', ''));

                if (!$secret || !Auth::verifyTotpCode($userId, $secret, $code)) {
                    if (Auth::bumpMfaAttempts('totp_enforce')) {
                        Helper::redirect('/login', ['error' => Lang::get('mfa_attempts_exceeded')]);
                    }
                    $error = Lang::get('totp_invalid');
                } else {
                    $db->execute('UPDATE users SET totp_enabled=1 WHERE id=?', [$userId]);
                    Auth::completeTotpEnforceLogin();
                    $loggedUser = Auth::user();
                    Lang::load($loggedUser['language'] ?? 'de');
                    Helper::redirect('/dashboard', ['success' => Lang::get('totp_enabled_ok')]);
                }
                // Re-fetch after failed attempt
                $user = $db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
            }
        }

        // Auto-generate a secret on first visit if none exists
        if (empty($user['totp_secret'])) {
            $secret = TOTP::generateSecret();
            $db->execute('UPDATE users SET totp_secret=? WHERE id=?', [Crypto::encrypt($secret), $userId]);
            $user['totp_secret'] = $secret;
        }

        $appName = Helper::getSetting('app_name', 'Plenvo');
        $otpUrl  = TOTP::getOtpAuthUrl($user['totp_secret'], $user['email'], $appName);

        view('auth/totp-setup-forced', compact('user', 'error', 'otpUrl'), 'auth');
    }

    public static function emailMfaVerify(): void
    {
        if (Auth::check()) { Helper::redirect('/dashboard'); }
        if (empty($_SESSION['email_mfa_pending_id'])) { Helper::redirect('/login'); }

        $error = null;

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action', '');

            if ($action === 'resend') {
                Auth::resendEmailMfaCode();
                Helper::redirect('/email-mfa-verify', ['success' => Lang::get('email_mfa_resent')]);
            }

            $code = trim(Helper::post('code', ''));
            if (Auth::completeEmailMfaLogin($code)) {
                $user = Auth::user();
                Lang::load($user['language'] ?? 'de');
                Helper::redirect('/dashboard');
            } else {
                Auth::recordMfaFailure();
                if (Auth::bumpMfaAttempts('email_mfa_pending')) {
                    Helper::redirect('/login', ['error' => Lang::get('mfa_attempts_exceeded')]);
                }
                $error = Lang::get('email_mfa_invalid');
            }
        }

        view('auth/email-mfa-verify', ['error' => $error], 'auth');
    }

    public static function mfaSetup(): void
    {
        if (Auth::check()) { Helper::redirect('/dashboard'); }
        if (empty($_SESSION['mfa_enforce_id'])) { Helper::redirect('/login'); }

        $db     = Database::getInstance();
        $userId = (int)$_SESSION['mfa_enforce_id'];
        $error  = null;
        $method = Helper::get('method', '');

        $user = $db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
        if (!$user) { Helper::redirect('/login'); }

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action');

            if ($action === 'choose_email') {
                if (Auth::activateEmailMfaFromEnforce()) {
                    Helper::redirect('/email-mfa-verify');
                }
                Helper::redirect('/mfa-setup');
            }

            if ($action === 'choose_totp') {
                if (empty($user['totp_secret'])) {
                    $secret = TOTP::generateSecret();
                    $db->execute('UPDATE users SET totp_secret=? WHERE id=?', [Crypto::encrypt($secret), $userId]);
                }
                Helper::redirect('/mfa-setup?method=totp');
            }

            if ($action === 'totp_enable_mfa') {
                // Always use the server-stored secret; never trust POST input
                $secret = $user['totp_secret'] ?? '';
                $code   = trim(Helper::post('totp_code', ''));
                $method = 'totp';
                if (!$secret || !Auth::verifyTotpCode($userId, $secret, $code)) {
                    if (Auth::bumpMfaAttempts('mfa_enforce')) {
                        Helper::redirect('/login', ['error' => Lang::get('mfa_attempts_exceeded')]);
                    }
                    $error = Lang::get('totp_invalid');
                    $user  = $db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
                } else {
                    $db->execute('UPDATE users SET totp_enabled=1 WHERE id=?', [$userId]);
                    Auth::completeMfaEnforceLogin();
                    $loggedUser = Auth::user();
                    Lang::load($loggedUser['language'] ?? 'de');
                    Helper::redirect('/dashboard', ['success' => Lang::get('totp_enabled_ok')]);
                }
            }
        }

        if ($method === 'totp' && empty($user['totp_secret'])) {
            $secret = TOTP::generateSecret();
            $db->execute('UPDATE users SET totp_secret=? WHERE id=?', [Crypto::encrypt($secret), $userId]);
            $user['totp_secret'] = $secret;
        }

        $appName = Helper::getSetting('app_name', 'Plenvo');
        $otpUrl  = (!empty($user['totp_secret']) && $method === 'totp')
                    ? TOTP::getOtpAuthUrl($user['totp_secret'], $user['email'], $appName)
                    : null;

        view('auth/mfa-setup', compact('user', 'error', 'method', 'otpUrl'), 'auth');
    }

    public static function mfaChoice(): void
    {
        if (Auth::check()) { Helper::redirect('/dashboard'); }
        if (empty($_SESSION['mfa_choice_id'])) { Helper::redirect('/login'); }

        $error = null;

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $method = Helper::post('method', '');
            if (!in_array($method, ['totp', 'email'], true)) {
                $error = Lang::get('login_error');
            } else {
                $result = Auth::chooseMfaMethod($method);
                if ($result === 'totp') {
                    Helper::redirect('/totp-verify');
                } elseif ($result === 'email_mfa') {
                    Helper::redirect('/email-mfa-verify');
                } else {
                    Helper::redirect('/login');
                }
            }
        }

        view('auth/mfa-choice', ['error' => $error], 'auth');
    }

    public static function forgotPassword(): void
    {
        if (Helper::getSetting('password_reset_enabled', '0') !== '1') {
            Helper::redirect('/login');
        }
        if (Auth::check()) {
            Helper::redirect('/dashboard');
        }

        $errors  = [];
        $success = false;

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $email = strtolower(trim(Helper::post('email', '')));

            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                $errors[] = Lang::get('invalid_email');
            } else {
                $db   = Database::getInstance();
                $user = $db->fetch('SELECT * FROM users WHERE email = ? AND is_active = 1', [$email]);

                // Never run a password-reset for accounts owned by an external auth
                // provider (LDAP/AD). The local password is a non-verifiable marker
                // for them; sending a reset link would mislead the user. Treating
                // them like an unknown email keeps the response indistinguishable
                // and avoids leaking which accounts are LDAP-managed.
                if ($user && ($user['auth_provider'] ?? 'local') !== 'local') {
                    $user = null;
                }

                if ($user) {
                    $tokenRaw  = bin2hex(random_bytes(32));
                    $tokenHash = hash('sha256', $tokenRaw);
                    $expires   = time() + 3600;
                    $db->execute(
                        'UPDATE users SET reset_token=?, reset_token_expires=? WHERE id=?',
                        [$tokenHash, $expires, $user['id']]
                    );
                    $resetUrl = Helper::baseUrl() . APP_BASE . '/reset-password?token=' . $tokenRaw;
                    $appName  = Helper::getSetting('app_name', 'Plenvo');
                    // sendSync (not the queue): the user is on the
                    // "Check your inbox" page expecting the reset link
                    // immediately; a queued delay would feel broken.
                    Mailer::sendSync(
                        $user['email'],
                        Lang::get('reset_password_title') . ' – ' . $appName,
                        '<p>' . Lang::get('reset_password_subtitle') . '</p>'
                            . '<p><a href="' . Helper::e($resetUrl) . '">' . Helper::e($resetUrl) . '</a></p>'
                            . '<p style="color:#666;font-size:13px">Der Link ist 1 Stunde gültig.</p>',
                        Lang::get('reset_password_subtitle') . "\n\n" . $resetUrl . "\n\nDer Link ist 1 Stunde gültig."
                    );
                }
                // Always show success to prevent email enumeration
                $success = true;
            }
        }

        view('auth/forgot-password', compact('errors', 'success'), 'auth');
    }

    public static function resetPassword(): void
    {
        if (Helper::getSetting('password_reset_enabled', '0') !== '1') {
            Helper::redirect('/login');
        }
        if (Auth::check()) {
            Helper::redirect('/dashboard');
        }

        $token = Helper::get('token', '');
        if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
            Helper::redirect('/login', ['error' => Lang::get('reset_token_invalid')]);
        }

        $db        = Database::getInstance();
        $tokenHash = hash('sha256', $token);
        $user      = $db->fetch(
            'SELECT * FROM users WHERE reset_token = ? AND reset_token_expires > ? AND is_active = 1',
            [$tokenHash, time()]
        );

        if (!$user) {
            Helper::redirect('/login', ['error' => Lang::get('reset_token_invalid')]);
        }

        $errors = [];

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $password = Helper::post('password', '');
            $confirm  = Helper::post('password_confirm', '');

            if ($pwErr = PasswordPolicy::validate($password)) {
                $errors[] = t($pwErr);
            } elseif ($password !== $confirm) {
                $errors[] = t('password_mismatch');
            } else {
                // Atomic consume: condition the UPDATE on the token still being
                // present, so a parallel reset request cannot reuse it
                // (security-audit #17). Also stamps password_changed_at so any
                // still-live sessions for this user are killed by index.php.
                $consumed = $db->execute(
                    'UPDATE users SET password=?, password_changed_at=?, reset_token=NULL, reset_token_expires=NULL, failed_login_count=0, locked_until=NULL WHERE id=? AND reset_token=?',
                    [password_hash($password, PASSWORD_BCRYPT, ['cost' => Auth::BCRYPT_COST]), time(), $user['id'], $tokenHash]
                );
                if ($consumed === 0) {
                    Helper::redirect('/login', ['error' => Lang::get('reset_token_invalid')]);
                }
                Helper::redirect('/login', ['success' => Lang::get('reset_password_success')]);
            }
        }

        view('auth/reset-password', compact('errors', 'token'), 'auth');
    }

    public static function register(): void
    {
        if (Helper::getSetting('allow_registration', '0') !== '1') {
            Helper::redirect('/login');
        }
        if (Auth::check()) {
            Helper::redirect('/dashboard');
        }

        $errors = [];

        if (Helper::isPost()) {
            Helper::verifyCsrf();

            // Honeypot: an off-screen field real users never fill. Bots that
            // blindly populate every input get the same "verification mail
            // sent" response as a legitimate signup, so the trap is invisible
            // to them, while we skip all DB and SMTP work.
            if (Helper::post('website', '') !== '') {
                SecurityLog::log('register_honeypot_triggered', SecurityLog::SEV_INFO, [
                    'email_hint' => Helper::maskEmail((string)Helper::post('email', '')),
                ]);
                Helper::redirect('/login', ['success' => Lang::get('verify_email_sent')]);
            }

            $username = trim(Helper::post('username', ''));
            $email    = strtolower(trim(Helper::post('email', '')));
            $password = Helper::post('password', '');
            $confirm  = Helper::post('password_confirm', '');

            if (strlen($username) < 3)                         $errors[] = Lang::get('register_username_short');
            if (!filter_var($email, FILTER_VALIDATE_EMAIL))    $errors[] = Lang::get('register_email_invalid');
            if ($pwErr = PasswordPolicy::validate($password))  $errors[] = t($pwErr);
            if ($password !== $confirm)                        $errors[] = Lang::get('password_mismatch');

            // Domain whitelist check
            if (empty($errors)) {
                $domainsRaw = Helper::getSetting('registration_domains', '');
                if (!empty(trim($domainsRaw))) {
                    $allowed = array_filter(array_map('trim', preg_split('/[\r\n,]+/', $domainsRaw)));
                    $emailDomain = substr($email, strrpos($email, '@') + 1);
                    if (!in_array($emailDomain, $allowed, true)) {
                        $errors[] = Lang::get('register_domain_not_allowed');
                    }
                }
            }

            if (empty($errors)) {
                $db = Database::getInstance();
                // Always answer with the same success message regardless of
                // whether the email already exists (mitigates user-enumeration —
                // see security-audit #6). For brand-new addresses we go through
                // the normal verify flow; for taken addresses we skip the INSERT
                // silently and the legitimate owner sees no spurious mail.
                if (!$db->fetch('SELECT id FROM users WHERE email = ?', [$email])) {
                    $verifyTokenRaw  = bin2hex(random_bytes(32));
                    $verifyTokenHash = hash('sha256', $verifyTokenRaw);
                    $verifyExpires   = time() + 86400; // 24 hours
                    $db->execute(
                        'INSERT INTO users (username, email, password, role, is_active, email_verify_token, email_verify_expires) VALUES (?,?,?,?,?,?,?)',
                        [$username, $email, password_hash($password, PASSWORD_BCRYPT, ['cost' => Auth::BCRYPT_COST]), 'user', 0, $verifyTokenHash, $verifyExpires]
                    );
                    $verifyUrl = Helper::baseUrl() . APP_BASE . '/verify-email?token=' . $verifyTokenRaw;
                    $appName   = Helper::getSetting('app_name', 'Plenvo');
                    // sendSync: the user is on the "verify your email" page
                    // and needs the link in their inbox right away.
                    Mailer::sendSync(
                        $email,
                        Lang::get('verify_email_subject') . ' – ' . $appName,
                        '<p>' . Lang::get('verify_email_body') . '</p>'
                            . '<p><a href="' . Helper::e($verifyUrl) . '">' . Helper::e($verifyUrl) . '</a></p>'
                            . '<p style="color:#666;font-size:13px">' . Lang::get('verify_email_expires') . '</p>',
                        Lang::get('verify_email_body') . "\n\n" . $verifyUrl . "\n\n" . Lang::get('verify_email_expires')
                    );
                }
                Helper::redirect('/login', ['success' => Lang::get('verify_email_sent')]);
            }
        }

        view('auth/register', ['errors' => $errors], 'auth');
    }

    public static function verifyEmail(): void
    {
        $token = Helper::get('token', '');
        if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
            Helper::redirect('/login', ['error' => Lang::get('verify_token_invalid')]);
        }

        $db        = Database::getInstance();
        $tokenHash = hash('sha256', $token);
        $user      = $db->fetch(
            'SELECT * FROM users WHERE email_verify_token = ? AND email_verify_expires > ?',
            [$tokenHash, time()]
        );

        if (!$user) {
            Helper::redirect('/login', ['error' => Lang::get('verify_token_invalid')]);
        }

        // Atomic consume: include the token hash in WHERE so a parallel verify
        // request cannot reuse the same token (security-audit #17).
        $consumed = $db->execute(
            'UPDATE users SET is_active=1, email_verify_token=NULL, email_verify_expires=NULL WHERE id=? AND email_verify_token=?',
            [$user['id'], $tokenHash]
        );
        if ($consumed === 0) {
            Helper::redirect('/login', ['error' => Lang::get('verify_token_invalid')]);
        }

        // Admins are notified only after the address is proven, so unverified
        // signup attempts can't be used to spam them.
        Mailer::notifyAdminsNewRegistration($user['username'], $user['email']);

        Helper::redirect('/login', ['success' => Lang::get('verify_email_success')]);
    }

    /**
     * Verify a pending email-address change. The user clicks the link from the
     * mail sent to the NEW address; we then commit `pending_email` -> `email`
     * and clear the staging columns. (security-audit #2)
     */
    public static function verifyEmailChange(): void
    {
        $token = Helper::get('token', '');
        if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
            Helper::redirect('/login', ['error' => Lang::get('email_change_token_invalid')]);
        }

        $db        = Database::getInstance();
        $tokenHash = hash('sha256', $token);
        $user      = $db->fetch(
            'SELECT * FROM users WHERE pending_email_token = ? AND pending_email_expires > ? AND pending_email IS NOT NULL',
            [$tokenHash, time()]
        );

        if (!$user) {
            Helper::redirect('/login', ['error' => Lang::get('email_change_token_invalid')]);
        }

        $newEmail = $user['pending_email'];

        // Final UNIQUE check — between issuing the token and clicking the link
        // someone else might have grabbed this address.
        $taken = $db->fetch('SELECT id FROM users WHERE email = ? AND id != ?', [$newEmail, $user['id']]);
        if ($taken) {
            $db->execute(
                'UPDATE users SET pending_email=NULL, pending_email_token=NULL, pending_email_expires=NULL WHERE id=?',
                [$user['id']]
            );
            Helper::redirect('/login', ['error' => Lang::get('email_taken')]);
        }

        try {
            // Atomic consume: include token hash in WHERE so a parallel click on
            // the same link cannot replay (security-audit #17).
            $consumed = $db->execute(
                'UPDATE users SET email=?, pending_email=NULL, pending_email_token=NULL, pending_email_expires=NULL WHERE id=? AND pending_email_token=?',
                [$newEmail, $user['id'], $tokenHash]
            );
            if ($consumed === 0) {
                Helper::redirect('/login', ['error' => Lang::get('email_change_token_invalid')]);
            }
        } catch (\PDOException $e) {
            if (str_contains($e->getMessage(), 'UNIQUE')) {
                Helper::redirect('/login', ['error' => Lang::get('email_taken')]);
            }
            throw $e;
        }

        SecurityLog::log('email_change_completed', SecurityLog::SEV_INFO, [
            'new_hint' => Helper::maskEmail($newEmail),
        ], (int)$user['id']);

        $target = Auth::check() && Auth::id() === (int)$user['id'] ? '/settings' : '/login';
        Helper::redirect($target, ['success' => Lang::get('email_change_success')]);
    }

    public static function logout(): void
    {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            Helper::redirect('/dashboard');
        }
        Helper::verifyCsrf();
        Auth::logout();
        Helper::redirect('/');
    }
}
