<?php

namespace ChurchCRM\model\ChurchCRM;

use ChurchCRM\Authentication\AuthenticationManager;
use ChurchCRM\Authentication\Exceptions\PasswordChangeException;
use ChurchCRM\dto\SystemConfig;
use ChurchCRM\Utils\KeyManagerUtils;
use ChurchCRM\model\ChurchCRM\Base\User as BaseUser;
use ChurchCRM\Utils\MiscUtils;
use Defuse\Crypto\Crypto;
use PragmaRX\Google2FA\Google2FA;
use Propel\Runtime\Connection\ConnectionInterface;

/**
 * Skeleton subclass for representing a row from the 'user_usr' table.
 *
 *
 *
 * You should add additional methods to this class to meet the
 * application requirements.  This class will only be generated as
 * long as it does not already exist in the output directory.
 */
class User extends BaseUser
{
    private $provisional2FAKey;

    public function getId()
    {
        return $this->getPersonId();
    }

    public function getName(): string
    {
        return $this->getPerson()->getFullName();
    }

    public function getEmail(): ?string
    {
        return $this->getPerson()->getEmail();
    }

    public function getFullName(): string
    {
        return $this->getPerson()->getFullName();
    }

    // ── Consolidated Permission Checks ─────────────────────────────
    //
    // Every permission method follows the same contract:
    //   1. Admin users ALWAYS return true (admin bypasses everything).
    //   2. Non-admins need the specific per-user flag (from user_usr
    //      column or userconfig_ucfg row) AND, for module-gated
    //      permissions, the system-wide feature flag to be enabled.
    //
    // The naming convention is `isXxxEnabled()` for all permissions,
    // regardless of which storage layer backs the raw flag.
    //
    // See #8667, #8458 for the consolidation rationale.
    // ─────────────────────────────────────────────────────────────────

    // -- Per-user permissions (backed by user_usr columns) --
    //
    // EditSelf is an exclusive mode: when a non-admin user has EditSelf=1,
    // all module permissions (AddRecords, EditRecords, …, Notes, Finance) are
    // treated as false regardless of what is stored in the database, so every
    // consumer sees a consistent view.

    /**
     * True when the user is confined to the self-service flow.
     *
     * A non-admin user with EditSelf=1 has no module permissions and cannot use
     * the CRM interface — PageInit and AuthMiddleware redirect them to
     * /external/limited-access.
     *
     * Deliberately NOT true for a zero-permission user (all flags 0). Those users
     * retain read-only access to people and family records under the read-default
     * policy (#9003); writes are denied by the per-page and per-route permission
     * checks.
     */
    public function isEditSelfExclusive(): bool
    {
        return !$this->isAdmin() && $this->isEditSelf();
    }

    public function isAddRecordsEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isAddRecords();
    }

    public function isEditRecordsEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isEditRecords();
    }

    public function isDeleteRecordsEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isDeleteRecords();
    }

    public function isMenuOptionsEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isMenuOptions();
    }

    public function isManageGroupsEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isManageGroups();
    }

    public function isNotesEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isNotes();
    }

    public function isEditSelfEnabled(): bool
    {
        return $this->isAdmin() || $this->isEditSelf();
    }

    // -- Module-gated permissions (backed by userconfig_ucfg) --
    //    These require a system-wide feature flag to be ON for all users,
    //    regardless of role or admin status. The flag gates access; permissions gate specific features.

    public function isFinanceEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return SystemConfig::getBooleanValue('bEnabledFinance') && ($this->isAdmin() || $this->isFinance());
    }

    public function isManageFundraisersEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return SystemConfig::getBooleanValue('bEnabledFundraiser') && ($this->isAdmin() || $this->isManageFundraisers());
    }

    public function isAddEventEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isEnabledSecurity('bAddEvent');
    }

    public function isEmailEnabled(): bool
    {
        if ($this->isEditSelfExclusive()) {
            return false;
        }
        return $this->isAdmin() || $this->isEnabledSecurity('bEmailMailto');
    }

    // -- Module view/manage permissions (combine feature flag + per-user) --

    public function canViewEvents(): bool
    {
        return $this->isAdmin() || self::isEventsEnabled();
    }

    public function canManageEvents(): bool
    {
        return $this->isAdmin() || (self::isEventsEnabled() && $this->isEnabledSecurity('bAddEvent'));
    }

    /**
     * Whether the Events module is enabled system-wide via SystemConfig.
     * Pure system check — no per-user permission gate.
     */
    public static function isEventsEnabled(): bool
    {
        return SystemConfig::getBooleanValue('bEnabledEvents');
    }

    // -- Consolidated permission map for API/UI consumption --

    /**
     * Return a structured map of all permissions for this user.
     * Useful for the user editor UI (/admin/system/users/{personId}/edit) and the user settings API.
     * Every value reflects the effective permission (with admin bypass applied).
     *
     * @return array<string, bool>
     */
    public function getAllPermissions(): array
    {
        return [
            // Core record permissions (user_usr columns)
            'isAdmin'             => $this->isAdmin(),
            'addRecords'          => $this->isAddRecordsEnabled(),
            'editRecords'         => $this->isEditRecordsEnabled(),
            'deleteRecords'       => $this->isDeleteRecordsEnabled(),
            'menuOptions'         => $this->isMenuOptionsEnabled(),
            'manageGroups'        => $this->isManageGroupsEnabled(),
            'finance'             => $this->isFinanceEnabled(),
            'manageFundraisers'   => $this->isManageFundraisersEnabled(),
            'notes'               => $this->isNotesEnabled(),
            'editSelf'            => $this->isEditSelfEnabled(),
            // Module permissions (userconfig_ucfg rows)
            'addEvent'            => $this->isAddEventEnabled(),
            'emailMailto'         => $this->isEmailEnabled(),
            // Computed module-level gates
            'canViewEvents'       => $this->canViewEvents(),
            'canManageEvents'     => $this->canManageEvents(),
        ];
    }

    /**
     * Check if the user lacks all functional admin permissions.
     * Users with no permissions (or only EditSelf) cannot use the admin interface
     * and should be redirected to a self-service flow or blocked.
     *
     * @see https://github.com/ChurchCRM/CRM/issues/8617
     */
    public function hasNoAdminPermissions(): bool
    {
        if ($this->isAdmin()) {
            return false;
        }
        if ($this->isEditSelf()) {
            // EditSelf is exclusive — no module permissions apply
            return true;
        }

        return !$this->isAddRecords()
            && !$this->isEditRecords()
            && !$this->isDeleteRecords()
            && !$this->isMenuOptions()
            && !$this->isManageGroups()
            && !$this->isFinance()
            && !$this->isManageFundraisers()
            && !$this->isNotes();
    }

    /**
     * Returns true if the current user may read basic metadata for any family.
     * All authenticated users have this capability by default (read-default policy).
     *
     * $familyId is reserved for future row-level security (e.g. pastoral-confidentiality
     * holds or per-family privacy flags). Pass the family ID at every call site so that
     * adding ABAC checks later requires no call-site changes.
     *
     * @param int $familyId The ID of the family to potentially read
     * @return bool True if user can read this family's record
     */
    public function canReadFamily(int $familyId = 0): bool
    {
        return true; // read is a default capability for all authenticated users
    }

    /**
     * Returns true if the current user may read basic metadata for any person.
     * All authenticated users have this capability by default (read-default policy).
     *
     * $personId is reserved for future row-level security (e.g. pastoral-confidentiality
     * holds or per-person privacy flags). Pass the person ID at every call site so that
     * adding ABAC checks later requires no call-site changes.
     *
     * @param int $personId The ID of the person to potentially read
     * @return bool True if user can read this person's record
     */
    public function canReadPerson(int $personId): bool
    {
        return true; // read is a default capability for all authenticated users
    }

    /**
     * Check if the user can edit a specific person's record.
     * Combines role-based (EditRecords) and object-level (EditSelf + family/own) authorization.
     *
     * @param int $personId The ID of the person to potentially edit
     * @param int $personFamilyId The family ID of the person (0 if no family)
     * @return bool True if user can edit this person's record
     */
    public function canEditPerson(int $personId, int $personFamilyId = 0): bool
    {
        // Users with EditRecords permission can edit anyone
        if ($this->isEditRecordsEnabled()) {
            return true;
        }

        // Users with EditSelf permission can edit their own record or family members
        if ($this->isEditSelfEnabled()) {
            // Can edit own record
            if ($personId === $this->getId()) {
                return true;
            }

            // Can edit family members (if person has a family)
            $person = $this->getPerson();
            if ($person === null) {
                return false; // orphaned user — deny access
            }
            if ($personFamilyId > 0 && $personFamilyId === (int) $person->getFamId()) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if the user can view/access a specific family's record.
     * All authenticated users can read any family by default (canReadFamily()).
     * EditSelf-only users are further restricted to their own family.
     *
     * @param int $familyId The ID of the family to potentially view
     * @return bool True if user can view this family's record
     */
    public function canViewFamily(int $familyId): bool
    {
        if ($this->isEditSelfEnabled() && !$this->isAdmin() && !$this->isEditRecordsEnabled()) {
            $person = $this->getPerson();
            if ($person === null) {
                return false;
            }
            return $familyId > 0 && $familyId === (int) $person->getFamId();
        }
        return true;
    }

    /**
     * Returns true if the current user may read non-private notes on the given object.
     * Requires the Notes role (or Admin via isNotesEnabled()).
     *
     * $personId / $familyId are reserved for future row-level security (e.g.
     * pastoral-confidentiality flags). Pass them at every call site so that
     * adding ABAC checks later requires no call-site changes.
     *
     * @param int|null $personId Reserved for future ABAC use
     * @param int|null $familyId Reserved for future ABAC use
     */
    public function canReadNotes(?int $personId = null, ?int $familyId = null): bool
    {
        return $this->isNotesEnabled();
    }

    /**
     * Returns true if the current user may read private notes authored by other users.
     *
     * Policy: admins may read any private note (full content visible in timeline,
     * API, and NoteEditor). Non-admin users may only read their own private notes;
     * that author-equality check is handled in Note::isVisibleTo(), so this method
     * governs only the "read someone else's private note" case.
     *
     * $personId / $familyId are reserved for future ABAC extensions (e.g. a future
     * per-record delegate granted read access to specific private notes).
     *
     * @param int|null $personId Reserved for future ABAC use
     * @param int|null $familyId Reserved for future ABAC use
     */
    public function canReadPrivateNotes(?int $personId = null, ?int $familyId = null): bool
    {
        return $this->isAdmin();
    }

    /**
     * Returns true if the current user may create a note on the given family.
     * Notes=1 or Admin currently grants cross-family write (intentional, see #9036/#9003).
     * Parameter is reserved as the ABAC hook for future per-family privacy holds.
     *
     * @param int|null $familyId Reserved for future ABAC use
     */
    public function canWriteNoteOnFamily(?int $familyId = null): bool
    {
        return $this->isNotesEnabled();
    }

    /**
     * Update password using secure bcrypt hashing.
     * Also rotates the API key so that any previously-stolen key is revoked
     * (GHSA-f2fq-4rmp-9x8c: password reset must invalidate compromised API keys).
     * Callers are responsible for calling save() to persist both changes.
     */
    public function updatePassword(string $password): void
    {
        $this->setPassword($this->hashPassword($password));
        $this->setApiKey(self::randomApiKey());
    }

    /**
     * Validate password against stored hash.
     * Supports bcrypt (current), legacy SHA-256 (6.x migration), and legacy MD5
     * (pre-6.x / ChurchInfo 1.x migration) formats.
     * On any legacy match, the stored hash is transparently upgraded to bcrypt.
     */
    public function isPasswordValid(string $password): bool
    {
        $storedHash = $this->getPassword();

        // Check if this is a bcrypt hash (starts with $2y$)
        if ($this->isBcryptHash($storedHash)) {
            return password_verify($password, $storedHash);
        }

        // Legacy MD5 check — pre-6.x / ChurchInfo 1.x stored passwords as unsalted
        // md5(password). MD5 is cryptographically weak and unsalted MD5 plaintexts are
        // in public rainbow tables, so we accept it here only to let migrated accounts
        // log in once, immediately re-hash to bcrypt, and force a new password — the
        // weak plaintext must not remain in use just because the hash got stronger.
        if ($this->isMd5Hash($storedHash) && hash_equals($storedHash, md5($password))) {
            $this->setPassword($this->hashPassword($password));
            $this->setNeedPasswordChange(true);
            $this->save();
            return true;
        }

        // Legacy SHA-256 check for migration period
        $legacyHash = $this->legacyHashPassword($password);
        if (hash_equals($storedHash, $legacyHash)) {
            // Upgrade to bcrypt on successful login
            $this->setPassword($this->hashPassword($password));
            $this->save();
            return true;
        }

        return false;
    }

    /**
     * Hash password using bcrypt (PHP's password_hash with PASSWORD_DEFAULT).
     * This is the secure method for new passwords.
     */
    public function hashPassword(string $password): string
    {
        return password_hash($password, PASSWORD_DEFAULT);
    }

    /**
     * Legacy SHA-256 hashing for backward compatibility during migration.
     * @deprecated Will be removed in a future version
     */
    private function legacyHashPassword(string $password): string
    {
        return hash('sha256', $password . $this->getPersonId());
    }

    /**
     * Check if a hash is in bcrypt format.
     */
    private function isBcryptHash(string $hash): bool
    {
        return str_starts_with($hash, '$2y$') || str_starts_with($hash, '$2b$') || str_starts_with($hash, '$2a$');
    }

    /**
     * Check if a hash looks like an unsalted MD5 digest (32 lowercase hex chars).
     * Used during the ChurchInfo → ChurchCRM upgrade migration path.
     */
    private function isMd5Hash(string $hash): bool
    {
        return (bool) preg_match('/^[a-f0-9]{32}$/', $hash);
    }

    // isAddEvent() is kept as an alias for isAddEventEnabled() since it's
    // called by isEnabledSecurity('bAddEvent') checks elsewhere in the codebase
    public function isAddEvent(): bool
    {
        return $this->isAddEventEnabled();
    }

    public function isLocked(): bool
    {
        return SystemConfig::getIntValue('iMaxFailedLogins') > 0 && $this->getFailedLogins() >= SystemConfig::getIntValue('iMaxFailedLogins');
    }

    public function resetPasswordToRandom(): string
    {
        $password = User::randomPassword();
        $this->updatePassword($password);
        $this->setNeedPasswordChange(true);
        $this->setFailedLogins(0);

        return $password;
    }

    public static function randomPassword(): string
    {
        $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
        $pass = []; //remember to declare $pass as an array
        $alphaLength = strlen($alphabet) - 1; //put the length -1 in cache
        for ($i = 0; $i < SystemConfig::getIntValue('iMinPasswordLength'); $i++) {
            $n = random_int(0, $alphaLength);
            $pass[] = $alphabet[$n];
        }

        return implode('', $pass); //turn the array into a string
    }

    public static function randomApiKey(): string
    {
        return MiscUtils::randomToken();
    }

    public function postInsert(ConnectionInterface $con = null): void
    {
        $this->createTimeLineNote('created');
    }

    public function postDelete(ConnectionInterface $con = null): void
    {
        $this->createTimeLineNote('deleted');
    }

    public function createTimeLineNote($type): void
    {
        $note = new Note();
        $note->setPerId($this->getPersonId());
        $note->setEntered(AuthenticationManager::getCurrentUser()->getId());
        $note->setType('user');

        switch ($type) {
            case 'created':
                $note->setText(gettext('system user created'));
                break;
            case 'updated':
                $note->setText(gettext('system user updated'));
                break;
            case 'deleted':
                $note->setText(gettext('system user deleted'));
                break;
            case 'password-reset':
                $note->setText(gettext('system user password reset'));
                break;
            case 'password-changed':
                $note->setText(gettext('system user changed password'));
                break;
            case 'password-changed-admin':
                $note->setText(gettext('system user password changed by admin'));
                break;
            case 'login-reset':
                $note->setText(gettext('system user login reset'));
                break;
        }

        $note->save();
    }

    public function isEnabledSecurity($securityConfigName): bool
    {
        if ($this->isAdmin()) {
            return true;
        } elseif ($securityConfigName == 'bAdmin') {
            return false;
        }

        if ($securityConfigName == 'bAll') {
            return true;
        }

        if ($securityConfigName == 'bAddRecords' && $this->isAddRecordsEnabled()) {
            return true;
        }

        if ($securityConfigName == 'bEditRecords' && $this->isEditRecordsEnabled()) {
            return true;
        }

        if ($securityConfigName == 'bDeleteRecords' && $this->isDeleteRecordsEnabled()) {
            return true;
        }

        if ($securityConfigName == 'bManageGroups' && $this->isManageGroupsEnabled()) {
            return true;
        }

        if ($securityConfigName == 'bFinance' && $this->isFinanceEnabled()) {
            return true;
        }

        if ($securityConfigName == 'bNotes' && $this->isNotesEnabled()) {
            return true;
        }

        foreach ($this->getUserConfigs() as $userConfig) {
            if ($userConfig->getName() == $securityConfigName) {
                return $userConfig->getPermission() == 'TRUE';
            }
        }

        return false;
    }

    public function getUserConfigString($userConfigName)
    {
        foreach ($this->getUserConfigs() as $userConfig) {
            if ($userConfig->getName() == $userConfigName) {
                return $userConfig->getValue();
            }
        }
    }

    public function setUserConfigString($userConfigName, $value)
    {
        foreach ($this->getUserConfigs() as $userConfig) {
            if ($userConfig->getName() == $userConfigName) {
                return $userConfig->setValue($value);
            }
        }
    }

    public function setSetting($name, $value): void
    {
        $setting = $this->getSetting($name);
        if (!$setting) {
            $setting = new UserSetting();
            $setting->set($this, $name, $value);
        } else {
            $setting->setValue($value);
        }
        $setting->save();
    }

    public function getSettingValue($name)
    {
        $userSetting = $this->getSetting($name);

        return $userSetting === null ? '' : $userSetting->getValue();
    }

    public function getSetting($name)
    {
        foreach ($this->getUserSettings() as $userSetting) {
            if ($userSetting->getName() == $name) {
                return $userSetting;
            }
        }

        return null;
    }

    /**
     * Returns the effective theme mode for this user.
     *
     * Maps the raw ui.style setting to a canonical value:
     *   '' (unset) => 'auto'  — users who never chose a theme default to system preference
     *   'auto'     => 'auto'
     *   'default'  => 'default' (explicit light)
     *   'dark'     => 'dark'
     *
     * @return string 'auto' | 'default' | 'dark'
     */
    public function getThemeMode(): string
    {
        $raw = $this->getSettingValue(UserSetting::UI_STYLE);
        if ($raw === null || $raw === '' || $raw === 'auto') {
            return 'auto';
        }
        if ($raw === 'dark') {
            return 'dark';
        }

        return 'default';
    }

    public function getStyle(): string
    {
        $skin = $this->getSetting(UserSetting::UI_STYLE) ?? 'skin-red';
        $cssClasses = [];
        $cssClasses[] = $skin;
        $cssClasses[] = $this->getSetting(UserSetting::UI_BOXED);
        $cssClasses[] = $this->getSetting(UserSetting::UI_SIDEBAR);

        return implode(' ', $cssClasses);
    }

    public function isShowPledges(): bool
    {
        return $this->getSettingValue(UserSetting::FINANCE_SHOW_PLEDGES) === 'true';
    }

    public function isShowPayments(): bool
    {
        return $this->getSettingValue(UserSetting::FINANCE_SHOW_PAYMENTS) === 'true';
    }

    public function getShowSince()
    {
        return $this->getSettingValue(UserSetting::FINANCE_SHOW_SINCE);
    }

    /**
     * Generates a new 2FA secret key for enrollment.
     * Uses pragmarx/google2fa v9.0+ default: 32-character secrets (160-bit entropy).
     * usr_TwoFactorAuthSecret (VARCHAR 255) supports both legacy 16-char and new 32-char secrets.
     *
     * @return string Base32-encoded TOTP secret
     */
    public function provisionNew2FAKey(): string
    {
        $google2fa = new Google2FA();
        $key = $google2fa->generateSecretKey();
        // store the temporary 2FA key in a private variable on this User object
        // we don't want to update the database with the new key until we've confirmed
        // that the user is capable of generating valid 2FA codes
        // encrypt the 2FA key since this object and its properties are serialized into the $_SESSION store
        // which is generally written to disk.
        $this->provisional2FAKey = Crypto::encryptWithPassword($key, KeyManagerUtils::getTwoFASecretKey());

        return $key;
    }

    public function confirmProvisional2FACode(string $twoFACode): bool
    {
        $google2fa = new Google2FA();
        $window = 2;
        $pw = Crypto::decryptWithPassword($this->provisional2FAKey, KeyManagerUtils::getTwoFASecretKey());
        $isKeyValid = $google2fa->verifyKey($pw, $twoFACode, $window);
        if ($isKeyValid) {
            $this->setTwoFactorAuthSecret($this->provisional2FAKey);
            $this->save();

            return true;
        }

        return $isKeyValid;
    }

    public function remove2FAKey(): void
    {
        $this->setTwoFactorAuthSecret(null);
        $this->save();
    }

    public function getDecryptedTwoFactorAuthSecret(): string
    {
        return Crypto::decryptWithPassword($this->getTwoFactorAuthSecret(), KeyManagerUtils::getTwoFASecretKey());
    }

    private function getDecryptedTwoFactorAuthRecoveryCodes(): array
    {
        $encrypted = $this->getTwoFactorAuthRecoveryCodes();
        if (empty($encrypted)) {
            return [];
        }
        try {
            return explode(',', Crypto::decryptWithPassword($encrypted, KeyManagerUtils::getTwoFASecretKey()));
        } catch (\Exception $e) {
            return [];
        }
    }

    public function disableTwoFactorAuthentication(): void
    {
        $this->setTwoFactorAuthRecoveryCodes(null);
        $this->setTwoFactorAuthSecret(null);
        $this->save();
    }

    public function is2FactorAuthEnabled(): bool
    {
        return !empty($this->getTwoFactorAuthSecret());
    }

    public function getNewTwoFARecoveryCodes(): array
    {
        // generate 12 human-readable recovery codes formatted as xxxxxxxx-xxxxxxxx (lowercase hex, 64 bits of entropy each)
        // and store as an encrypted, comma-separated list
        $recoveryCodes = [];
        for ($i = 0; $i < 12; $i++) {
            // random_bytes(8) yields 16 hex characters; split into two 8-char segments for xxxxxxxx-xxxxxxxx format
            $hex = bin2hex(random_bytes(8));
            $recoveryCodes[$i] = substr($hex, 0, 8) . '-' . substr($hex, 8, 8);
        }
        $recoveryCodesString = implode(',', $recoveryCodes);
        $this->setTwoFactorAuthRecoveryCodes(Crypto::encryptWithPassword($recoveryCodesString, KeyManagerUtils::getTwoFASecretKey()));
        $this->save();

        return $recoveryCodes;
    }

    public function isTwoFACodeValid(string $twoFACode): bool
    {
        try {
            $google2fa = new Google2FA();
            $window = 2;
            $timestamp = $google2fa->verifyKeyNewer(
                $this->getDecryptedTwoFactorAuthSecret(),
                $twoFACode,
                $this->getTwoFactorAuthLastKeyTimestamp(),
                $window
            );
            if ($timestamp !== false) {
                $this->setTwoFactorAuthLastKeyTimestamp($timestamp);
                $this->save();

                return true;
            }

            return false;
        } catch (\Exception $e) {
            return false;
        }
    }

    public function isTwoFaRecoveryCodeValid(string $twoFaRecoveryCode): bool
    {
        // checks for validity of a 2FA recovery code
        // if the specified code was valid, the code is also removed.
        // New-format codes (xxxxxxxx-xxxxxxxx lowercase hex) are compared with normalization:
        // hyphens/spaces stripped and lowercased, so users can type them either way.
        // Legacy base64 codes are compared byte-exact to preserve their original entropy
        // and avoid case-collision edge cases.
        $newFormatRegex = '/^[a-f0-9]+-?[a-f0-9]+$/i';
        $inputIsNewFormat = (bool) preg_match($newFormatRegex, $twoFaRecoveryCode);
        $normalizedInput = str_replace(['-', ' '], '', strtolower($twoFaRecoveryCode));

        $codes = $this->getDecryptedTwoFactorAuthRecoveryCodes();
        foreach ($codes as $key => $code) {
            $storedIsNewFormat = (bool) preg_match($newFormatRegex, $code);
            $matches = $inputIsNewFormat && $storedIsNewFormat
                ? str_replace(['-', ' '], '', strtolower($code)) === $normalizedInput
                : hash_equals($code, $twoFaRecoveryCode);

            if ($matches) {
                unset($codes[$key]);
                $recoveryCodesString = implode(',', $codes);
                $this->setTwoFactorAuthRecoveryCodes(Crypto::encryptWithPassword($recoveryCodesString, KeyManagerUtils::getTwoFASecretKey()));
                $this->save();

                return true;
            }
        }

        return false;
    }

    public function adminSetUserPassword(string $newPassword): void
    {
        $this->updatePassword($newPassword);
        $this->setNeedPasswordChange(false);
        $this->save();
        $this->createTimeLineNote('password-changed-admin');
    }

    public function userChangePassword($oldPassword, $newPassword): void
    {
        if (!$this->isPasswordValid($oldPassword)) {
            throw new PasswordChangeException('Old', gettext('Incorrect password supplied for current user'));
        }

        if (!$this->getIsPasswordPermissible($newPassword)) {
            throw new PasswordChangeException('New', gettext('Your password choice is too obvious. Please choose something else.'));
        }

        if (strlen($newPassword) < SystemConfig::getIntValue('iMinPasswordLength')) {
            throw new PasswordChangeException('New', gettext('Your new password must be at least') . ' ' . SystemConfig::getIntValue('iMinPasswordLength') . ' ' . gettext('characters'));
        }

        if ($newPassword == $oldPassword) {
            throw new PasswordChangeException('New', gettext('Your new password must not match your old one.'));
        }

        if (levenshtein(strtolower($newPassword), strtolower($oldPassword)) < SystemConfig::getIntValue('iMinPasswordChange')) {
            throw new PasswordChangeException('New', gettext('Your new password is too similar to your old one.'));
        }

        $this->updatePassword($newPassword);
        $this->setNeedPasswordChange(false);
        $this->save();
        $this->createTimeLineNote('password-changed');
    }

    private function getIsPasswordPermissible($newPassword): bool
    {
        $aBadPasswords = explode(',', strtolower(SystemConfig::getValue('aDisallowedPasswords')));
        $aBadPasswords[] = strtolower($this->getPerson()->getFirstName());
        $aBadPasswords[] = strtolower($this->getPerson()->getMiddleName());
        $aBadPasswords[] = strtolower($this->getPerson()->getLastName());

        return !in_array(strtolower($newPassword), $aBadPasswords);
    }
}
