<?php
/**
 * 2-Factor Authentication Module
 *
 * Requires selected user roles to complete 2FA after password login.
 * Methods: Email code (default), Authenticator app (TOTP).
 *
 * @package TwoFactorAuthentication
 * @since 1.0.0
 */

namespace TwoFactorAuthentication;

use WP_PowerSuite\Captcha\Shared\ClientIp;
use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\TransientPurger;
use WP_PowerSuite\Email\EmailComponents;
use WP_PowerSuite\Email\EmailDesign;
use WP_PowerSuite\Email\EmailRenderer;

class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID        = 'two-factor-authentication';
	private const MODULE_VERSION   = '1.7.1';
	private const COOKIE_NAME      = 'wpps_2fa_token';
	private const TRUST_COOKIE     = 'wpps_2fa_trust';
	private const TRUST_USER_META  = 'wpps_2fa_trusted';
	private const TRANSIENT_PREFIX = 'wpps_2fa_';
	private const PENDING_USER_PREFIX = 'wpps_2fa_uid_';
	private const OTP_FAIL_USER_PREFIX = 'wpps_2fa_otp_u_';
	private const OTP_FAIL_IP_PREFIX   = 'wpps_2fa_otp_ip_';

	private const TRANSIENT_KEYS_OPTION = 'wpps_2fa_transient_keys';
	private const TOKEN_LENGTH     = 64;   // bin2hex(32 bytes)
	private const RESEND_MAX       = 3;
	private const RESEND_WINDOW    = 900;
	private const OTP_LOCKOUT_SECONDS = 900;

/** Pending redirect token (same-request redirect when 2FA required). */
	private static ?string $pending_2fa_token = null;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( '2-Factor Authentication', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Two-factor login for selected roles—extra proof beyond the password.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return 'both';
	}

	public function getDependencies(): array {
		return [];
	}

	public function getDefaultSettings(): array {
		return [
			'roles'                 => [],
			'enable_email'          => true,
			'enable_authenticator'  => true,
			'email_code_ttl'        => 1200,
			'max_verify_attempts'   => 5,
			'grace_period_days'     => 0,
			'trusted_device_days'   => 0,
			'restrict_api_login_to_app_passwords' => true,
		];
	}

	/**
	 * Grace period in seconds (0 = off). Only applies when authenticator is enabled.
	 */
	private function getGracePeriodSeconds(): int {
		$settings = $this->getSettings();
		if ( empty( $settings['enable_authenticator'] ) ) {
			return 0;
		}
		$days    = isset( $settings['grace_period_days'] ) ? absint( $settings['grace_period_days'] ) : 0;
		$allowed = [ 0, 1, 3, 7, 14, 30, 60, 90 ];
		if ( ! in_array( $days, $allowed, true ) ) {
			$days = 0;
		}
		return $days * DAY_IN_SECONDS;
	}

	/**
	 * Trusted-device duration in days (0 = feature off).
	 */
	private function getTrustedDeviceDays(): int {
		$settings = $this->getSettings();
		$days     = isset( $settings['trusted_device_days'] ) ? absint( $settings['trusted_device_days'] ) : 0;
		$allowed  = [ 0, 7, 14, 30, 60, 90 ];
		if ( ! in_array( $days, $allowed, true ) ) {
			$days = 0;
		}
		return $days;
	}

	public function userInEnforcedRoles( \WP_User $user ): bool {
		$roles = $this->getSettings()['roles'] ?? [];
		if ( ! is_array( $roles ) || empty( $roles ) ) {
			return false;
		}
		return ! empty( array_intersect( (array) $user->roles, $roles ) );
	}

	/**
	 * User must visit profile to confirm 2FA options (grace) before deadline.
	 */
	public function userNeedsGraceEnrollment( \WP_User $user ): bool {
		if ( get_user_meta( $user->ID, 'wpps_2fa_disabled', true ) === '1' ) {
			return false;
		}
		if ( $this->getGracePeriodSeconds() <= 0 ) {
			return false;
		}
		if ( ! $this->userInEnforcedRoles( $user ) ) {
			return false;
		}
		if ( get_user_meta( $user->ID, 'wpps_2fa_profile_saved', true ) === '1' ) {
			return false;
		}
		if ( UserProfile::getTotpSecret( $user->ID ) !== '' ) {
			return false;
		}
		return true;
	}

	private function trustCookieValidForUser( \WP_User $user ): bool {
		$days = $this->getTrustedDeviceDays();
		if ( $days <= 0 || ! apply_filters( 'wpps_2fa_trust_device_allowed', true, $user ) ) {
			return false;
		}
		if ( empty( $_COOKIE[ self::TRUST_COOKIE ] ) ) {
			return false;
		}
		$token = sanitize_text_field( wp_unslash( $_COOKIE[ self::TRUST_COOKIE ] ) );
		if ( strlen( $token ) !== self::TOKEN_LENGTH || ! ctype_xdigit( $token ) ) {
			return false;
		}
		$hash = hash_hmac( 'sha256', $token, wp_salt( 'wpps_2fa_trust' ) );
		$list = get_user_meta( $user->ID, self::TRUST_USER_META, true );
		if ( ! is_array( $list ) ) {
			return false;
		}
		$now   = time();
		$clean = [];
		$ok    = false;
		foreach ( $list as $row ) {
			if ( ! is_array( $row ) || empty( $row['h'] ) || empty( $row['e'] ) ) {
				continue;
			}
			$row['e'] = (int) $row['e'];
			if ( $row['e'] < $now ) {
				continue;
			}
			$clean[] = $row;
			if ( ! $ok && hash_equals( (string) $row['h'], $hash ) ) {
				$ok = true;
			}
		}
		if ( count( $clean ) !== count( $list ) ) {
			update_user_meta( $user->ID, self::TRUST_USER_META, $clean );
		}
		return $ok;
	}

	/**
	 * Set short-lived cookie so the browser can reach the 2FA step (password or passwordless login).
	 */
	private function setPending2FACookie( string $token ): void {
		$cookie_path   = COOKIEPATH;
		$cookie_domain = COOKIE_DOMAIN;
		$secure        = is_ssl();
		$httponly      = true;
		$samesite      = 'Lax';
		$expires       = time() + $this->getSessionTtl();
		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie(
				self::COOKIE_NAME,
				$token,
				[
					'expires'  => $expires,
					'path'     => $cookie_path,
					'domain'   => $cookie_domain,
					'secure'   => $secure,
					'httponly' => $httponly,
					'samesite' => $samesite,
				]
			);
		} else {
			setcookie( self::COOKIE_NAME, $token, $expires, $cookie_path . '; samesite=' . $samesite, $cookie_domain, $secure, $httponly );
		}
	}

	/**
	 * Clear the HttpOnly pending-session cookie after verify or invalidation.
	 */
	private function clearPending2FACookie(): void {
		$secure = is_ssl();
		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie(
				self::COOKIE_NAME,
				'',
				[
					'expires'  => time() - 3600,
					'path'     => COOKIEPATH,
					'domain'   => COOKIE_DOMAIN,
					'secure'   => $secure,
					'httponly' => true,
					'samesite' => 'Lax',
				]
			);
		} else {
			setcookie( self::COOKIE_NAME, '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, $secure, true );
		}
	}

	/**
	 * Pending token from HttpOnly cookie (preferred) or legacy query arg.
	 */
	private function resolvePending2FATokenFromRequest(): string {
		if ( ! empty( $_COOKIE[ self::COOKIE_NAME ] ) ) {
			$token = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) );
			if ( $this->isValid2FAToken( $token ) ) {
				return $token;
			}
		}
		if ( isset( $_GET['token'] ) ) {
			$token = sanitize_text_field( wp_unslash( $_GET['token'] ) );
			if ( $this->isValid2FAToken( $token ) ) {
				return $token;
			}
		}

		return '';
	}

	/**
	 * After passwordless verification (e.g. Magic Login), start 2FA when policy requires it.
	 *
	 * Does not set auth cookies. Caller should redirect to the returned URL.
	 *
	 * @return string|null Safe redirect URL to the 2FA step, or null to complete login normally.
	 */
	public function beginPendingLoginIfRequired( \WP_User $user, string $redirect_to = '' ): ?string {
		if ( apply_filters( 'wpps_2fa_allow_bypass', false, $user ) ) {
			return null;
		}
		if ( get_user_meta( $user->ID, 'wpps_2fa_disabled', true ) === '1' ) {
			return null;
		}
		$settings = $this->getSettings();
		$roles    = $settings['roles'] ?? [];
		if ( ! is_array( $roles ) || empty( $roles ) ) {
			return null;
		}
		if ( ! $this->userInEnforcedRoles( $user ) ) {
			return null;
		}
		if ( $this->trustCookieValidForUser( $user ) ) {
			return null;
		}

		$method       = UserProfile::getUserMethod( $user->ID );
		$enable_email = ! empty( $settings['enable_email'] );
		$enable_auth  = ! empty( $settings['enable_authenticator'] );

		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			return add_query_arg( 'error', 'wpps_2fa_locked', wp_login_url() );
		}

		if ( $method === 'authenticator' && $enable_auth && UserProfile::getTotpSecret( $user->ID ) ) {
			$result = $this->create2FATotpSession( $user );
		} elseif ( $enable_email ) {
			$result = $this->create2FACode( $user );
		} else {
			return null;
		}

		if ( ! $result || empty( $result['token'] ) ) {
			if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
				return add_query_arg( 'error', 'wpps_2fa_locked', wp_login_url() );
			}
			return null;
		}

		$this->setPending2FACookie( $result['token'] );
		$validated = $redirect_to !== '' ? wp_validate_redirect( $redirect_to, '' ) : '';

		return $this->buildLoginUrlFor2FAStep( $validated !== '' ? $validated : null );
	}

	/**
	 * Remember this browser after a successful second step.
	 */
	private function maybePersistTrustedDevice( \WP_User $user ): void {
		$want = ! empty( $_POST['wpps_2fa_trust_device'] ) || ! empty( $_POST['trust_device'] );
		if ( ! $want ) {
			return;
		}
		$days = $this->getTrustedDeviceDays();
		if ( $days <= 0 ) {
			return;
		}
		$token = bin2hex( random_bytes( 32 ) );
		$hash  = hash_hmac( 'sha256', $token, wp_salt( 'wpps_2fa_trust' ) );
		$exp   = time() + $days * DAY_IN_SECONDS;
		$list  = get_user_meta( $user->ID, self::TRUST_USER_META, true );
		if ( ! is_array( $list ) ) {
			$list = [];
		}
		$list[] = [ 'h' => $hash, 'e' => $exp ];
		if ( count( $list ) > 10 ) {
			$list = array_slice( $list, -10 );
		}
		update_user_meta( $user->ID, self::TRUST_USER_META, $list );
		$this->sendTrustCookie( $token, $exp );
	}

	private function sendTrustCookie( string $token, int $expires_ts ): void {
		$secure   = is_ssl();
		$httponly = true;
		$samesite = 'Lax';
		$path     = COOKIEPATH;
		$domain   = COOKIE_DOMAIN;
		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie( self::TRUST_COOKIE, $token, [
				'expires'  => $expires_ts,
				'path'     => $path,
				'domain'   => $domain,
				'secure'   => $secure,
				'httponly' => $httponly,
				'samesite' => $samesite,
			] );
		} else {
			setcookie( self::TRUST_COOKIE, $token, $expires_ts, $path . '; samesite=' . $samesite, $domain, $secure, $httponly );
		}
	}

	public function enforceGracePeriodInAdmin(): void {
		if ( ! is_admin() || wp_doing_ajax() ) {
			return;
		}
		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			return;
		}
		$user = get_user_by( 'id', $user_id );
		if ( ! $user instanceof \WP_User ) {
			return;
		}
		if ( ! $this->userNeedsGraceEnrollment( $user ) ) {
			delete_user_meta( $user_id, 'wpps_2fa_grace_started_at' );
			return;
		}
		$grace   = $this->getGracePeriodSeconds();
		$started = (int) get_user_meta( $user_id, 'wpps_2fa_grace_started_at', true );
		if ( ! $started ) {
			$started = time();
			update_user_meta( $user_id, 'wpps_2fa_grace_started_at', $started );
		}
		$expired = time() > ( $started + $grace );

		global $pagenow;
		$allowed_pages = [ 'profile.php', 'wp-login.php' ];
		if ( in_array( $pagenow, $allowed_pages, true ) ) {
			return;
		}
		if ( $pagenow === 'user-edit.php' ) {
			$editing = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
			if ( $editing === $user_id ) {
				return;
			}
		}
		if ( ! $expired ) {
			return;
		}
		wp_safe_redirect( self_admin_url( 'profile.php#wpps-2fa-section' ) );
		exit;
	}

	public function renderGracePeriodNotice(): void {
		if ( ! is_admin() || wp_doing_ajax() ) {
			return;
		}
		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			return;
		}
		$user = get_user_by( 'id', $user_id );
		if ( ! $user instanceof \WP_User || ! $this->userNeedsGraceEnrollment( $user ) ) {
			return;
		}
		$grace = $this->getGracePeriodSeconds();
		if ( $grace <= 0 ) {
			return;
		}
		$started = (int) get_user_meta( $user_id, 'wpps_2fa_grace_started_at', true );
		if ( ! $started ) {
			return;
		}
		if ( time() > $started + $grace ) {
			return;
		}
		$days_left = max( 1, (int) ceil( ( $started + $grace - time() ) / DAY_IN_SECONDS ) );
		/* translators: %d: approximate days left in grace period */
		$msg = sprintf( __( 'Two-factor settings must be reviewed for your account within about %d day(s) or you may lose access to the admin area.', 'wp-powersuite' ), $days_left );
		$link = self_admin_url( 'profile.php#wpps-2fa-section' );
		echo '<div class="notice notice-warning wpps-2fa-grace-notice"><p>' . esc_html( $msg ) . ' <a class="button" href="' . esc_url( $link ) . '">' . esc_html__( 'Open profile', 'wp-powersuite' ) . '</a></p></div>';
	}

	public function filterUsersListColumnHeader( array $columns ): array {
		$columns['wpps_2fa'] = __( '2FA', 'wp-powersuite' );
		return $columns;
	}

	public function filterUsersListColumnValue( string $output, string $column_name, int $user_id ): string {
		if ( $column_name !== 'wpps_2fa' ) {
			return $output;
		}
		$user = get_userdata( $user_id );
		if ( ! $user || ! $this->userInEnforcedRoles( $user ) ) {
			return '&#8212;';
		}
		if ( get_user_meta( $user_id, 'wpps_2fa_disabled', true ) === '1' ) {
			return esc_html__( 'Exempt', 'wp-powersuite' );
		}
		$has_totp = UserProfile::getTotpSecret( $user_id ) !== '';
		$use_mail = UserProfile::userUsesEmail( $user_id );
		$use_auth = UserProfile::userUsesAuthenticator( $user_id );
		if ( $has_totp && $use_mail ) {
			$label = __( 'Email + app', 'wp-powersuite' );
		} elseif ( $has_totp ) {
			$label = __( 'App', 'wp-powersuite' );
		} elseif ( $use_mail ) {
			$label = __( 'Email', 'wp-powersuite' );
		} else {
			$label = __( 'Not set', 'wp-powersuite' );
		}
		return esc_html( $label );
	}

	/**
	 * Second-factor session length (email code validity and pending login), in seconds.
	 */
	private function getSessionTtl(): int {
		$settings = $this->getSettings();
		$ttl      = isset( $settings['email_code_ttl'] ) ? absint( $settings['email_code_ttl'] ) : 1200;
		$allowed  = [ 300, 600, 900, 1200, 1800, 3600 ];
		if ( ! in_array( $ttl, $allowed, true ) ) {
			$ttl = 1200;
		}
		return (int) apply_filters( 'wpps_2fa_session_ttl', $ttl, $settings );
	}

	/**
	 * Max wrong codes per pending session before the session is destroyed.
	 */
	private function getMaxVerifyAttempts(): int {
		$settings = $this->getSettings();
		$max      = isset( $settings['max_verify_attempts'] ) ? absint( $settings['max_verify_attempts'] ) : 5;
		$allowed  = [ 3, 5, 8, 10 ];
		if ( ! in_array( $max, $allowed, true ) ) {
			$max = 5;
		}
		return (int) apply_filters( 'wpps_2fa_max_verify_attempts', $max, $settings );
	}

	/**
	 * How long a user/IP OTP fail budget is remembered (does not reset on re-login).
	 */
	private function getOtpLockoutSeconds(): int {
		$seconds = (int) apply_filters( 'wpps_2fa_otp_lockout', self::OTP_LOCKOUT_SECONDS );
		return max( 60, min( HOUR_IN_SECONDS, $seconds ) );
	}

	private function otpClientIp(): string {
		return ClientIp::resolveOrDefault();
	}

	private function rememberTransientKey( string $key ): void {
		TransientPurger::remember( self::TRANSIENT_KEYS_OPTION, $key );
	}

	/**
	 * One live pending ticket per user. A new login invalidates the previous token.
	 */
	private function replacePendingTokenForUser( int $user_id, string $token ): void {
		if ( $user_id <= 0 || ! $this->isValid2FAToken( $token ) ) {
			return;
		}
		$map_key = self::PENDING_USER_PREFIX . $user_id;
		$prev    = get_transient( $map_key );
		if ( is_string( $prev ) && $prev !== '' && $prev !== $token && $this->isValid2FAToken( $prev ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $prev );
		}
		if ( set_transient( $map_key, $token, $this->getSessionTtl() ) ) {
			$this->rememberTransientKey( $map_key );
		}
	}

	private function otpFailCount( string $key ): int {
		return max( 0, (int) get_transient( $key ) );
	}

	private function bumpOtpFailCount( string $key ): int {
		$count = $this->otpFailCount( $key ) + 1;
		if ( set_transient( $key, $count, $this->getOtpLockoutSeconds() ) ) {
			$this->rememberTransientKey( $key );
		}
		return $count;
	}

	/**
	 * Account-scoped lock. Password re-auth must not mint a fresh OTP budget.
	 */
	private function isOtpBudgetLockedForUser( int $user_id ): bool {
		if ( $user_id <= 0 ) {
			return false;
		}
		return $this->otpFailCount( self::OTP_FAIL_USER_PREFIX . $user_id ) >= $this->getMaxVerifyAttempts();
	}

	/**
	 * Extra IP budget so one host cannot spray many accounts. Higher than the per-user cap
	 * so a shared NAT is not locked out by one person's five mistakes.
	 */
	private function isOtpBudgetLockedForIp(): bool {
		$ip = $this->otpClientIp();
		if ( $ip === '' || $ip === '0.0.0.0' ) {
			return false;
		}
		$ip_max = (int) apply_filters( 'wpps_2fa_max_verify_attempts_ip', $this->getMaxVerifyAttempts() * 4 );
		$ip_max = max( $this->getMaxVerifyAttempts(), min( 100, $ip_max ) );
		return $this->otpFailCount( self::OTP_FAIL_IP_PREFIX . md5( $ip ) ) >= $ip_max;
	}

	private function isOtpBudgetLocked( int $user_id ): bool {
		return $this->isOtpBudgetLockedForUser( $user_id ) || $this->isOtpBudgetLockedForIp();
	}

	private function clearOtpFailBudgetForUser( int $user_id ): void {
		if ( $user_id <= 0 ) {
			return;
		}
		delete_transient( self::OTP_FAIL_USER_PREFIX . $user_id );
		delete_transient( self::PENDING_USER_PREFIX . $user_id );
	}

	private function otpLockedError(): \WP_Error {
		return new \WP_Error(
			'wpps_2fa_locked',
			__( 'Too many incorrect verification codes. Please wait a few minutes before trying again.', 'wp-powersuite' )
		);
	}

	/**
	 * Persist pending 2FA payload; TTL is remaining time until session anchor expires.
	 *
	 * @param array $data Must include `created` (unix) for remaining TTL; adds failed_attempts if missing.
	 * @return bool False if the transient could not be saved.
	 */
	private function put2FATransient( string $token, array $data ): bool {
		if ( ! isset( $data['created'] ) ) {
			$data['created'] = time();
		}
		if ( ! isset( $data['failed_attempts'] ) ) {
			$data['failed_attempts'] = 0;
		}
		$ttl = max( 60, (int) $data['created'] + $this->getSessionTtl() - time() );
		$key = self::TRANSIENT_PREFIX . $token;
		$ok  = (bool) set_transient( $key, $data, $ttl );
		if ( $ok ) {
			TransientPurger::remember( self::TRANSIENT_KEYS_OPTION, $key );
		}
		return $ok;
	}

	/**
	 * After a failed code: increment attempts; delete session if max reached.
	 *
	 * @return string 'locked' or 'invalid'
	 */
	private function registerFailedVerification( string $token, array $data ): string {
		$user_id = (int) ( $data['user_id'] ?? 0 );
		$failed  = (int) ( $data['failed_attempts'] ?? 0 ) + 1;
		$data['failed_attempts'] = $failed;

		$user_fails = $user_id > 0 ? $this->bumpOtpFailCount( self::OTP_FAIL_USER_PREFIX . $user_id ) : $failed;
		$ip         = $this->otpClientIp();
		if ( $ip !== '' && $ip !== '0.0.0.0' ) {
			$this->bumpOtpFailCount( self::OTP_FAIL_IP_PREFIX . md5( $ip ) );
		}

		$locked = $failed >= $this->getMaxVerifyAttempts()
			|| $user_fails >= $this->getMaxVerifyAttempts()
			|| $this->isOtpBudgetLockedForIp();

		if ( $locked ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			return 'locked';
		}
		$this->put2FATransient( $token, $data );
		return 'invalid';
	}

	private function isStandaloneTwoFactorPluginActive(): bool {
		if ( class_exists( 'Two_Factor_Core', false ) || defined( 'TWO_FACTOR_VERSION' ) ) {
			return true;
		}
		$basename = 'two-factor/two-factor.php';
		if ( in_array( $basename, (array) get_option( 'active_plugins', [] ), true ) ) {
			return true;
		}
		$network = (array) get_site_option( 'active_sitewide_plugins', [] );
		return isset( $network[ $basename ] );
	}

	public function renderStandaloneTwoFactorConflictNotice(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		echo '<div class="notice notice-error"><p><strong>' . esc_html__( 'WP PowerSuite 2FA is not running.', 'wp-powersuite' ) . '</strong> ' . esc_html__( 'The standalone “Two-Factor” plugin is active, so this module’s login enforcement is disabled. Deactivate the standalone plugin to use PowerSuite 2FA, or disable this module to avoid confusion.', 'wp-powersuite' ) . '</p></div>';
	}

	/**
	 * Browser tab title when showing the full-page 2FA step (login_header title is empty).
	 *
	 * @param string $login_title The filtered document title.
	 * @param string $title       Title string passed to login_header().
	 */
	public function filterLoginDocumentTitleFor2FAScreen( string $login_title, string $title ): string {
		$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '';
		if ( $action !== 'wpps_2fa' ) {
			return $login_title;
		}
		$site = get_bloginfo( 'name', 'display' );
		/* translators: 1: screen name, 2: site name */
		return sprintf( __( '%1$s ‹ %2$s — WordPress', 'wp-powersuite' ), __( 'Two-Factor Authentication', 'wp-powersuite' ), $site );
	}

	/**
	 * Body class for full-page 2FA only — CSS widens #login here without affecting the default login form.
	 *
	 * @param string[] $classes Body classes.
	 * @param string   $action  Current login action.
	 * @return string[]
	 */
	public function filterLoginBodyClassFor2FAScreen( array $classes, $action ): array {
		$action = is_string( $action ) ? $action : '';
		if ( $action === 'wpps_2fa' ) {
			$classes[] = 'wpps-2fa-fullpage';
		}
		return $classes;
	}

	public function addLoginErrorForLockedOut( \WP_Error $errors, $redirect = '' ): \WP_Error {
		if ( isset( $_GET['error'] ) && sanitize_text_field( wp_unslash( $_GET['error'] ) ) === 'wpps_2fa_locked' ) {
			$errors->add(
				'wpps_2fa_locked',
				__( 'Too many incorrect verification codes. Please wait a few minutes before trying again.', 'wp-powersuite' )
			);
		}
		return $errors;
	}

	/** Profile AJAX actions; UserProfile is loaded only when one of these or profile screen runs. */
	private const PROFILE_AJAX_ACTIONS = [
		'wpps_2fa_setup_authenticator',
		'wpps_2fa_verify_authenticator',
		'wpps_2fa_switch_method',
		'wpps_2fa_regenerate_backup_codes',
		'wpps_2fa_remove_authenticator',
		'wpps_2fa_disable_for_user',
		'wpps_2fa_enable_for_user',
	];

		protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		if ( $this->isStandaloneTwoFactorPluginActive() ) {
		add_action( 'admin_notices', [ $this, 'renderStandaloneTwoFactorConflictNotice' ] );
		add_action( 'network_admin_notices', [ $this, 'renderStandaloneTwoFactorConflictNotice' ] );
		return;
		}

		$settings = $this->getSettings();
		$roles    = $settings['roles'] ?? [];
		if ( ! is_array( $roles ) || empty( $roles ) ) {
		return;
		}

		require_once __DIR__ . '/includes/class-crypto.php';
		require_once __DIR__ . '/includes/class-totp.php';
		require_once __DIR__ . '/user-profile.php';

		add_filter( 'wp_login_errors', [ $this, 'addLoginErrorForLockedOut' ], 10, 2 );
		add_filter( 'login_title', [ $this, 'filterLoginDocumentTitleFor2FAScreen' ], 10, 2 );
		add_filter( 'login_body_class', [ $this, 'filterLoginBodyClassFor2FAScreen' ], 10, 2 );

		add_filter( 'authenticate', [ $this, 'requireTwoFactor' ], 50, 3 );
		add_filter( 'authenticate', [ $this, 'restrictXmlRpcRestAuthenticateToAppPasswordForEnforcedRoles' ], 52, 3 );
		add_filter( 'two_factor_user_api_login_enable', [ $this, 'filterTwoFactorUserApiLoginEnable' ], 10, 2 );
		add_action( 'login_init', [ $this, 'maybeAbandon2FAOnCancelRequest' ], 1 );
		add_action( 'login_init', [ $this, 'redirectTo2FAForm' ], 5 );
		add_action( 'login_form_login', [ $this, 'redirectTo2FAFormSameRequest' ], 5 );
		add_filter( 'login_errors', [ $this, 'loginMessageWhen2FARequired' ], 10, 1 );
		add_action( 'login_form_wpps_2fa', [ $this, 'render2FAForm' ] );
		add_action( 'login_form_wpps_2fa', [ $this, 'handle2FASubmit' ], 5 );
		add_action( 'login_enqueue_scripts', [ $this, 'enqueueLoginAssets' ] );
		add_action( 'login_footer', [ $this, 'output2FAStepTemplate' ] );

		// ASE / wordpress.org Two-Factor pattern: pending cookie redirects any front-end URL to wp-login.php for step 2.
		// Always register; callback validates cookie (same as registering only when isset is fragile across edge cases).
		add_action( 'template_redirect', [ $this, 'redirectTo2FAFormFromAnyPage' ], 5 );

		// Same-request redirect when wp_signon ran during wp_loaded (WooCommerce My Account, forums, etc.; not wp-login.php).
		add_action( 'wp_loaded', [ $this, 'redirectPending2FAFromFrontend' ], 999 );

		add_action( 'wp_ajax_nopriv_wpps_2fa_login', [ $this, 'ajaxLogin' ] );
		add_action( 'wp_ajax_nopriv_wpps_2fa_verify', [ $this, 'ajaxVerify' ] );
		add_action( 'wp_ajax_nopriv_wpps_2fa_resend', [ $this, 'ajaxResend' ] );
		add_action( 'wp_ajax_nopriv_wpps_2fa_switch_method', [ $this, 'ajaxSwitchLoginMethod' ] );

		if ( $this->getGracePeriodSeconds() > 0 ) {
		add_action( 'admin_init', [ $this, 'enforceGracePeriodInAdmin' ], 0 );
		add_action( 'admin_notices', [ $this, 'renderGracePeriodNotice' ] );
		}

		add_action( 'admin_init', [ $this, 'registerAdminOnlyHooks' ], 1 );

		add_filter( 'wp_privacy_personal_data_exporters', [ $this, 'registerPrivacyExporter' ] );
		add_filter( 'wp_privacy_personal_data_erasers', [ $this, 'registerPrivacyEraser' ] );
	}


	/**
	 * Hooks that only apply in wp-admin: users list column + profile UI (avoid registering on the front end).
	 */
	public function registerAdminOnlyHooks(): void {
		if ( ! is_admin() ) {
			return;
		}
		add_filter( 'manage_users_columns', [ $this, 'filterUsersListColumnHeader' ] );
		add_filter( 'manage_users_custom_column', [ $this, 'filterUsersListColumnValue' ], 10, 3 );

		$load_profile = false;
		$pagenow      = $GLOBALS['pagenow'] ?? '';
		if ( in_array( $pagenow, [ 'profile.php', 'user-edit.php' ], true ) ) {
			$load_profile = true;
		}
		if ( ! $load_profile && wp_doing_ajax() && isset( $_REQUEST['action'] ) ) {
			$ajax_action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
			if ( in_array( $ajax_action, self::PROFILE_AJAX_ACTIONS, true ) ) {
				$load_profile = true;
			}
		}
		if ( $load_profile ) {
			UserProfile::init( $this );
		}
	}

	/**
	 * Enqueue CSS/JS on login page for AJAX 2FA flow.
	 */
	public function enqueueLoginAssets(): void {
		$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : 'login';
		if ( $action !== 'login' && $action !== '' && $action !== 'wpps_2fa' ) {
			return;
		}
		$this->enqueueLoginAssetsCommon();
	}

	/**
	 * Shared enqueue logic for wp-login.php (AJAX second step stays on login screen).
	 */
	private function enqueueLoginAssetsCommon(): void {
		$base = plugin_dir_url( __FILE__ );
		wp_enqueue_style( 'wpps-2fa-login', $base . 'assets/two-factor-auth.css', [], self::MODULE_VERSION );
		wp_enqueue_script( 'wpps-2fa-login', $base . 'assets/two-factor-auth.js', [], self::MODULE_VERSION, true );
		$trust_days = $this->getTrustedDeviceDays();
		wp_localize_script( 'wpps-2fa-login', 'wpps2faConfig', [
			'ajaxUrl'        => admin_url( 'admin-ajax.php' ),
			'loginUrl'       => wp_login_url(),
			'cancelUrl'      => $this->buildCancel2FAUrl(),
			'trustDeviceDays' => $trust_days,
			'loginNonce'     => wp_create_nonce( 'wpps_2fa_login' ),
			'verifyNonce'    => wp_create_nonce( 'wpps_2fa_verify' ),
			'siteName'       => get_bloginfo( 'name' ),
			'isWoo'          => function_exists( 'is_account_page' ) && is_account_page(),
			'redirectTo'     => ( function_exists( 'wc_get_page_permalink' ) && function_exists( 'is_account_page' ) && is_account_page() ) ? wc_get_page_permalink( 'myaccount' ) : '',
			'strings'        => [
				'emptyFields'                 => __( 'Please enter username and password.', 'wp-powersuite' ),
				'loginError'                  => __( 'Invalid username or password.', 'wp-powersuite' ),
				'requestError'                => __( 'Request failed. Please try again.', 'wp-powersuite' ),
				'invalidCode'                 => __( 'Invalid or expired code. Please try again or request a new code.', 'wp-powersuite' ),
				'codeSent'                    => __( 'Code sent!', 'wp-powersuite' ),
				'resendError'                 => __( 'Could not resend code. Please try logging in again.', 'wp-powersuite' ),
				'authenticatorHint'           => __( 'Enter the 6-digit code from your authenticator app.', 'wp-powersuite' ),
				'loggedOut'                   => __( 'You are now logged out.', 'wp-powersuite' ),
				'emailCodeHintAbove'          => __( 'Enter the 8-digit code we sent to your email below.', 'wp-powersuite' ),
				'verificationCodeLabel'       => __( 'Verification Code', 'wp-powersuite' ),
				'backupCodeLabel'             => __( 'Backup Code', 'wp-powersuite' ),
				'useBackupCodeInstead'        => __( 'Use backup code instead', 'wp-powersuite' ),
				'useAuthenticatorCodeInstead' => __( 'Use authenticator code instead', 'wp-powersuite' ),
				'useEmailCodeInstead'         => __( 'Use email code instead', 'wp-powersuite' ),
				'backupCodeHint'              => __( 'Enter one of your 8-character backup codes.', 'wp-powersuite' ),
				'tooManyAttempts'             => __( 'Too many incorrect codes. Returning to sign in…', 'wp-powersuite' ),
				'verifyButton'                => __( 'Verify', 'wp-powersuite' ),
				'recoveryCode'                => __( 'Recovery code', 'wp-powersuite' ),
				'credentialErrorPrefix'       => __( 'Error:', 'wp-powersuite' ),
			],
		] );
	}

	/**
	 * Output the 2FA verification step HTML (hidden until shown via AJAX).
	 */
	public function output2FAStepTemplate(): void {
		$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : 'login';
		if ( $action !== 'login' && $action !== '' ) {
			return;
		}
		$this->output2FAStepTemplateHtml();
	}

	/**
	 * Output the 2FA step HTML markup.
	 */
	private function output2FAStepTemplateHtml(): void {
		$trust_days  = $this->getTrustedDeviceDays();
		$cancel_url  = $this->buildCancel2FAUrl();
		?>
		<div id="wpps-2fa-step" class="wpps-2fa-step">
			<div class="wpps-2fa-card" role="region" aria-labelledby="wpps-2fa-card-heading">
				<div id="wpps-2fa-error" class="wpps-2fa-error" style="display:none;"></div>
				<h1 class="wpps-2fa-card__title" id="wpps-2fa-card-heading"><?php esc_html_e( 'Two-Factor Authentication', 'wp-powersuite' ); ?></h1>
				<form id="wpps-2fa-verify-form" class="wpps-2fa-form" autocomplete="off">
					<input type="hidden" name="wpps_2fa_token" id="wpps_2fa_token" value="" />
					<input type="hidden" name="redirect_to" id="wpps_2fa_redirect_to" value="" />
					<?php wp_nonce_field( 'wpps_2fa_verify', 'wpps_2fa_nonce', false ); ?>
					<p class="wpps-2fa-code-field-wrap">
						<label for="wpps_2fa_code" id="wpps_2fa_code_label" class="wpps-2fa-code-label"><?php esc_html_e( 'Verification Code', 'wp-powersuite' ); ?></label>
						<span class="wpps-2fa-code-hint" id="wpps-2fa-code-hint" role="status"></span>
						<input type="text" name="wpps_2fa_code" id="wpps_2fa_code" class="input" size="20" maxlength="8" inputmode="numeric" autocomplete="one-time-code" placeholder="XXXXXX" />
					</p>
					<?php if ( $trust_days > 0 ) : ?>
					<p class="wpps-2fa-trust-wrap" id="wpps-2fa-trust-wrap">
						<label>
							<input type="checkbox" name="wpps_2fa_trust_device" id="wpps_2fa_trust_device" value="1" />
							<?php
							echo esc_html(
								sprintf(
									/* translators: %d: number of days */
									__( 'Remember this browser for %d days.', 'wp-powersuite' ),
									$trust_days
								)
							);
							?>
						</label>
					</p>
					<?php endif; ?>
					<div class="wpps-2fa-primary-actions" id="wpps-2fa-primary-actions">
						<p class="submit wpps-2fa-submit-row">
							<button type="submit" id="wpps-2fa-verify-submit" class="button button-primary button-large wpps-2fa-verify-btn"><?php esc_html_e( 'Verify', 'wp-powersuite' ); ?></button>
						</p>
						<p class="wpps-2fa-resend-wrap">
							<button type="button" id="wpps-2fa-resend" class="button button-large"><?php esc_html_e( 'Resend Code', 'wp-powersuite' ); ?></button>
						</p>
					</div>
				</form>
				<p class="wpps-2fa-back-wrap">
					<a href="<?php echo esc_url( $cancel_url ); ?>" id="wpps-2fa-back" class="wpps-2fa-back-link"><?php esc_html_e( '&larr; Back to login', 'wp-powersuite' ); ?></a>
				</p>
				<div class="wpps-2fa-secondary-actions" id="wpps-2fa-secondary-actions">
					<span class="wpps-2fa-secondary-actions__item" id="wpps-2fa-backup-btn-wrap" style="display:none;">
						<button type="button" id="wpps-2fa-use-backup" class="button button-large button-secondary wpps-2fa-backup-toggle"><?php esc_html_e( 'Recovery code', 'wp-powersuite' ); ?></button>
					</span>
					<span class="wpps-2fa-secondary-actions__item" id="wpps-2fa-switch-btn-wrap" style="display:none;">
						<button type="button" id="wpps-2fa-switch-method-btn" class="button button-large button-secondary"><?php esc_html_e( 'Use email instead', 'wp-powersuite' ); ?></button>
					</span>
				</div>
			</div>
		</div>
		<?php
	}

	/**
	 * Ensure the user tied to a pending 2FA session still requires and is allowed to complete 2FA.
	 *
	 * @param array $data Transient payload (expects user_id).
	 */
	private function getTransient2FAUserOrError( array $data ): \WP_User|\WP_Error {
		$uid = (int) ( $data['user_id'] ?? 0 );
		if ( $uid <= 0 ) {
			return new \WP_Error( 'wpps_2fa_expired', __( 'Session expired. Please log in again.', 'wp-powersuite' ) );
		}
		$user = get_user_by( 'ID', $uid );
		if ( ! $user instanceof \WP_User ) {
			return new \WP_Error( 'wpps_2fa_expired', __( 'Session expired. Please log in again.', 'wp-powersuite' ) );
		}
		if ( get_user_meta( $user->ID, 'wpps_2fa_disabled', true ) === '1' || ! $this->userInEnforcedRoles( $user ) ) {
			return new \WP_Error( 'wpps_2fa_invalid', __( 'Two-factor session is no longer valid. Please sign in again.', 'wp-powersuite' ) );
		}
		return $user;
	}

	/**
	 * Whether the token string is valid format (64 hex chars). Reject invalid early to avoid DB lookups.
	 */
	private function isValid2FAToken( string $token ): bool {
		return strlen( $token ) === self::TOKEN_LENGTH && ctype_xdigit( $token );
	}

	/**
	 * wp-login.php URL for the second factor (token carried in HttpOnly cookie, not the query string).
	 *
	 * @param string|null $redirect_to Optional redirect_to after validation.
	 */
	private function buildLoginUrlFor2FAStep( ?string $redirect_to = null ): string {
		$url = add_query_arg( 'action', 'wpps_2fa', wp_login_url( '', true ) );
		if ( $redirect_to !== null && $redirect_to !== '' ) {
			$url = add_query_arg( 'redirect_to', $redirect_to, $url );
		}
		return $url;
	}

	/**
	 * URL that abandons the pending 2FA session and returns to the login form.
	 */
	public function buildCancel2FAUrl(): string {
		return wp_nonce_url(
			add_query_arg( 'action', 'wpps_2fa_cancel', wp_login_url() ),
			'wpps_2fa_cancel',
			'wpps_2fa_cancel_nonce'
		);
	}

	/**
	 * Clear pending 2FA cookie and transient so the user can sign in again.
	 */
	private function abandonPending2FASession( ?string $token = null ): void {
		if ( $token === null || $token === '' ) {
			$token = $this->resolvePending2FATokenFromRequest();
		}
		if ( $token !== '' && $this->isValid2FAToken( $token ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
		}
		$this->clearPending2FACookie();
	}

	/**
	 * Handle ?action=wpps_2fa_cancel — must run before redirectTo2FAForm on login_init.
	 */
	public function maybeAbandon2FAOnCancelRequest(): void {
		$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '';
		if ( $action !== 'wpps_2fa_cancel' ) {
			return;
		}
		$nonce = isset( $_GET['wpps_2fa_cancel_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['wpps_2fa_cancel_nonce'] ) ) : '';
		if ( $nonce === '' || ! wp_verify_nonce( $nonce, 'wpps_2fa_cancel' ) ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}
		$this->abandonPending2FASession( null );
		wp_safe_redirect( wp_login_url() );
		exit;
	}

	/**
	 * Final login destination after auth (and 2FA) — runs login_redirect so Redirect After Login applies.
	 *
	 * @param string $requested Raw redirect_to from the login / 2FA request (may be empty).
	 */
	private function resolvePostLoginRedirect( \WP_User $user, string $requested ): string {
		$requested = trim( $requested );
		$candidate = $requested !== '' ? wp_validate_redirect( $requested, admin_url() ) : admin_url();
		$redirect  = apply_filters( 'login_redirect', $candidate, $requested, $user );
		if ( class_exists( 'WooCommerce' ) ) {
			$redirect = apply_filters( 'woocommerce_login_redirect', (string) $redirect, $user );
		}

		return (string) wp_validate_redirect( $redirect, admin_url() );
	}

	/**
	 * Credential failures use a generic message for AJAX (JS bypasses wp-login.php filters).
	 *
	 * @param \WP_Error $error Authentication error from wp_authenticate().
	 */
	private function getAjaxAuthenticationErrorMessage( \WP_Error $error ): string {
		$credential_codes = [ 'invalid_username', 'incorrect_password', 'invalid_email' ];
		foreach ( $credential_codes as $code ) {
			if ( in_array( $code, $error->get_error_codes(), true ) ) {
				$msg = apply_filters(
					'wp_powersuite_ajax_login_generic_message',
					__( 'Invalid username or password.', 'wp-powersuite' ),
					$error,
					self::MODULE_ID
				);
				return wp_strip_all_tags( (string) $msg );
			}
		}

		return wp_strip_all_tags( $error->get_error_message() );
	}

	/**
	 * AJAX: Handle login. If 2FA required, return token (no redirect).
	 */
	public function ajaxLogin(): void {
		check_ajax_referer( 'wpps_2fa_login', '_wpnonce' );

		$log = isset( $_POST['log'] ) ? sanitize_user( wp_unslash( $_POST['log'] ) ) : ( isset( $_POST['username'] ) ? sanitize_user( wp_unslash( $_POST['username'] ) ) : '' );
		$pwd = isset( $_POST['pwd'] ) ? wp_unslash( $_POST['pwd'] ) : ( isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : '' );
		$redirect_to = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url();

		if ( $log === '' || $pwd === '' ) {
			wp_send_json_error( [ 'message' => __( 'Please enter username and password.', 'wp-powersuite' ) ] );
		}

		$user = wp_authenticate( $log, $pwd );

		if ( $user instanceof \WP_Error ) {
			$code = $user->get_error_code();
			if ( $code === 'wpps_2fa_required' ) {
				$data = $user->get_error_data();
				if ( ! empty( $data['token'] ) ) {
					$method     = $data['method'] ?? 'email';
					$can_switch = ! empty( $data['can_switch'] );
					$other      = $can_switch ? ( $data['other_method'] ?? ( $method === 'email' ? 'authenticator' : 'email' ) ) : '';
					wp_send_json_success( [
						'requires_2fa' => true,
						'token'        => $data['token'],
						'redirect_to'  => $redirect_to,
						'method'       => $method,
						'can_switch'   => $can_switch,
						'other_method' => $other,
					] );
				}
			}
			wp_send_json_error( [ 'message' => $this->getAjaxAuthenticationErrorMessage( $user ) ] );
		}

		if ( ! $user instanceof \WP_User ) {
			wp_send_json_error( [ 'message' => __( 'Invalid username or password.', 'wp-powersuite' ) ] );
		}

		$settings = $this->getSettings();
		$roles    = $settings['roles'] ?? [];
		if ( ! is_array( $roles ) || empty( $roles ) ) {
			wp_set_auth_cookie( $user->ID, true );
			do_action( 'wp_login', $user->user_login, $user );
			wp_send_json_success( [ 'redirect' => $this->resolvePostLoginRedirect( $user, $redirect_to ) ] );
		}

		$user_roles = (array) $user->roles;
		if ( empty( array_intersect( $user_roles, $roles ) ) ) {
			wp_set_auth_cookie( $user->ID, true );
			do_action( 'wp_login', $user->user_login, $user );
			wp_send_json_success( [ 'redirect' => $this->resolvePostLoginRedirect( $user, $redirect_to ) ] );
		}

		if ( $this->trustCookieValidForUser( $user ) ) {
			wp_set_auth_cookie( $user->ID, true );
			do_action( 'wp_login', $user->user_login, $user );
			wp_send_json_success( [ 'redirect' => $this->resolvePostLoginRedirect( $user, $redirect_to ) ] );
		}

		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			wp_send_json_error( [ 'message' => $this->otpLockedError()->get_error_message(), 'code' => 'locked' ] );
		}

		$method       = UserProfile::getUserMethod( $user->ID );
		$enable_email = ! empty( $settings['enable_email'] );
		$enable_auth  = ! empty( $settings['enable_authenticator'] );

		if ( $method === 'authenticator' && $enable_auth && UserProfile::getTotpSecret( $user->ID ) ) {
			$result = $this->create2FATotpSession( $user );
		} elseif ( $enable_email ) {
			$result = $this->create2FACode( $user );
		} else {
			wp_send_json_error( [ 'message' => __( 'No 2FA method available. Contact support.', 'wp-powersuite' ) ] );
		}

		if ( $result ) {
			$current_method = $result['method'] ?? 'email';
			$can_switch     = (bool) $this->userHasBothMethods( $user );
			$other_method   = $can_switch ? ( $current_method === 'email' ? 'authenticator' : 'email' ) : '';
			wp_send_json_success( [
				'requires_2fa' => true,
				'token'        => $result['token'],
				'redirect_to'  => $redirect_to,
				'method'       => $current_method,
				'can_switch'   => $can_switch,
				'other_method' => $other_method,
			] );
		}

		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			wp_send_json_error( [ 'message' => $this->otpLockedError()->get_error_message(), 'code' => 'locked' ] );
		}

		wp_send_json_error( [ 'message' => __( 'Could not send verification code. Please try again.', 'wp-powersuite' ) ] );
	}

	/**
	 * Whether the user has both email and authenticator enabled for their account (so they can switch at login).
	 * Only true when the user has both checkboxes checked in their profile.
	 */
	public function userHasBothMethods( \WP_User $user ): bool {
		$settings          = $this->getSettings();
		$enable_email      = ! empty( $settings['enable_email'] );
		$enable_auth       = ! empty( $settings['enable_authenticator'] );
		$use_email         = UserProfile::userUsesEmail( $user->ID );
		$use_authenticator = UserProfile::userUsesAuthenticator( $user->ID );
		return $enable_email && $enable_auth && $use_email && $use_authenticator;
	}

	/**
	 * AJAX: Switch 2FA method on login (e.g. from authenticator to email) and return new token.
	 */
	public function ajaxSwitchLoginMethod(): void {
		check_ajax_referer( 'wpps_2fa_verify', '_wpnonce' );

		$token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
		if ( $token === '' || ! $this->isValid2FAToken( $token ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid session.', 'wp-powersuite' ) ] );
		}

		$data = get_transient( self::TRANSIENT_PREFIX . $token );
		if ( $data === false || ! is_array( $data ) || empty( $data['user_id'] ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid or expired session. Please log in again.', 'wp-powersuite' ) ] );
		}

		$user_check = $this->getTransient2FAUserOrError( $data );
		if ( is_wp_error( $user_check ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			wp_send_json_error( [ 'message' => $user_check->get_error_message() ] );
		}
		$user = $user_check;

		if ( ! $this->userHasBothMethods( $user ) ) {
			wp_send_json_error( [ 'message' => __( 'Cannot switch method.', 'wp-powersuite' ) ] );
		}

		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			wp_send_json_error( [ 'message' => $this->otpLockedError()->get_error_message(), 'code' => 'locked' ] );
		}

		$current_method = $data['method'] ?? 'email';
		$other_method   = $current_method === 'email' ? 'authenticator' : 'email';

		delete_transient( self::TRANSIENT_PREFIX . $token );

		if ( $other_method === 'authenticator' ) {
			$result = $this->create2FATotpSession( $user );
		} else {
			$result = $this->create2FACode( $user );
		}

		if ( ! $result ) {
			wp_send_json_error( [ 'message' => __( 'Could not switch method. Please try again.', 'wp-powersuite' ) ] );
		}

		$this->setPending2FACookie( $result['token'] );

		wp_send_json_success( [
			'token'  => $result['token'],
			'method' => $result['method'] ?? $other_method,
		] );
	}

	/**
	 * AJAX: Verify 2FA code and log in.
	 */
	public function ajaxVerify(): void {
		check_ajax_referer( 'wpps_2fa_verify', '_wpnonce' );

		$token       = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
		$code_raw    = isset( $_POST['code'] ) ? trim( (string) wp_unslash( $_POST['code'] ) ) : '';
		$code_digits = preg_replace( '/\D/', '', $code_raw );
		$code_alpha  = strtoupper( preg_replace( '/\s/', '', $code_raw ) );
		$code        = ( strlen( $code_digits ) === 6 || strlen( $code_digits ) === 8 ) ? $code_digits : ( strlen( $code_alpha ) === 8 ? $code_alpha : '' );
		$redirect_to = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url();

		if ( $token === '' || ! $this->isValid2FAToken( $token ) || ( $code === '' && strlen( $code_raw ) < 6 ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid or expired code. Please try again.', 'wp-powersuite' ) ] );
		}

		$data = get_transient( self::TRANSIENT_PREFIX . $token );

		if ( $data === false || ! is_array( $data ) || empty( $data['user_id'] ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid or expired session. Please log in again.', 'wp-powersuite' ) ] );
		}

		$user_check = $this->getTransient2FAUserOrError( $data );
		if ( is_wp_error( $user_check ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			wp_send_json_error( [ 'message' => $user_check->get_error_message() ] );
		}

		if ( $this->isOtpBudgetLocked( (int) $data['user_id'] ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			$this->clearPending2FACookie();
			wp_send_json_error(
				[
					'message' => $this->otpLockedError()->get_error_message(),
					'code'    => 'locked',
				]
			);
		}

		$valid = false;
		if ( ( $data['method'] ?? 'email' ) === 'authenticator' ) {
			$user_id = (int) $data['user_id'];
			$secret  = UserProfile::getTotpSecret( $user_id );
			if ( strlen( $code ) === 6 && ctype_digit( $code ) ) {
				$valid = UserProfile::verifyTotpCode( $user_id, $secret, $code );
			}
			if ( ! $valid && strlen( $code_alpha ) === 8 ) {
				$valid = UserProfile::verifyBackupCode( $user_id, $code_alpha );
			}
		} else {
			$valid = Crypto::verifyEmailCode( $code, $data );
		}

		if ( ! $valid ) {
			$fail = $this->registerFailedVerification( $token, $data );
			if ( $fail === 'locked' ) {
				$this->clearPending2FACookie();
				wp_send_json_error(
					[
						'message' => __( 'Too many incorrect codes. Please sign in again.', 'wp-powersuite' ),
						'code'    => 'locked',
					]
				);
			}
			wp_send_json_error( [ 'message' => __( 'Invalid or expired code. Please try again or request a new code.', 'wp-powersuite' ) ] );
		}

		delete_transient( self::TRANSIENT_PREFIX . $token );
		$this->clearPending2FACookie();
		$this->clearOtpFailBudgetForUser( (int) $user_check->ID );

		$user = $user_check;

		wp_clear_auth_cookie();
		wp_set_current_user( $user->ID );
		wp_set_auth_cookie( $user->ID, true );
		do_action( 'wp_login', $user->user_login, $user );
		$this->maybePersistTrustedDevice( $user );

		wp_send_json_success( [ 'redirect' => $this->resolvePostLoginRedirect( $user, $redirect_to ) ] );
	}

	/**
	 * Whether another verification email may be sent for this pending session.
	 */
	private function canResendEmailCode( int $user_id, string $token ): bool {
		$max     = (int) apply_filters( 'wpps_2fa_max_resends', self::RESEND_MAX, $user_id, $token );
		$window  = (int) apply_filters( 'wpps_2fa_resend_window', self::RESEND_WINDOW, $user_id, $token );
		$max     = max( 1, min( 10, $max ) );
		$window  = max( 60, min( HOUR_IN_SECONDS, $window ) );
		$user_key = 'wpps_2fa_resend_u_' . $user_id;
		$count    = (int) get_transient( $user_key );
		if ( $count >= $max ) {
			return false;
		}
		set_transient( $user_key, $count + 1, $window );

		return true;
	}

	/**
	 * AJAX: Resend 2FA code (email method only).
	 */
	public function ajaxResend(): void {
		check_ajax_referer( 'wpps_2fa_verify', '_wpnonce' );

		$token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
		if ( $token === '' || ! $this->isValid2FAToken( $token ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid session. Please log in again.', 'wp-powersuite' ) ] );
		}

		$data = get_transient( self::TRANSIENT_PREFIX . $token );
		if ( $data === false || ! is_array( $data ) || empty( $data['user_id'] ) ) {
			wp_send_json_error( [ 'message' => __( 'Session expired. Please log in again.', 'wp-powersuite' ) ] );
		}

		if ( ( $data['method'] ?? 'email' ) !== 'email' ) {
			wp_send_json_error( [ 'message' => __( 'Resend is only available for email codes.', 'wp-powersuite' ) ] );
		}

		$user_check = $this->getTransient2FAUserOrError( $data );
		if ( is_wp_error( $user_check ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			wp_send_json_error( [ 'message' => $user_check->get_error_message() ] );
		}
		$user = $user_check;

		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			wp_send_json_error( [ 'message' => $this->otpLockedError()->get_error_message(), 'code' => 'locked' ] );
		}

		if ( ! $this->canResendEmailCode( (int) $user->ID, $token ) ) {
			wp_send_json_error( [ 'message' => __( 'Too many resend requests. Please wait a few minutes or sign in again.', 'wp-powersuite' ) ] );
		}

		$code  = str_pad( (string) random_int( 0, 99999999 ), 8, '0', STR_PAD_LEFT );
		$transient_data = $data;
		$transient_data['code_hash']       = Crypto::hashEmailCode( $code );
		unset( $transient_data['code'] );
		$transient_data['created']         = time();
		$this->put2FATransient( $token, $transient_data );

		$this->queueCodeEmail( $user, $code );
		wp_send_json_success();
	}

	/**
	 * Create 2FA TOTP session (no email). Returns token or false.
	 */
	private function create2FATotpSession( \WP_User $user ): array|false {
		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			return false;
		}
		$token = bin2hex( random_bytes( 32 ) );
		$saved = $this->put2FATransient(
			$token,
			[
				'user_id'         => $user->ID,
				'method'          => 'authenticator',
				'created'         => time(),
				'failed_attempts' => 0,
			]
		);
		if ( ! $saved ) {
			return false;
		}
		$this->replacePendingTokenForUser( (int) $user->ID, $token );
		return [ 'token' => $token, 'method' => 'authenticator' ];
	}

	/**
	 * Create 2FA code and transient. Returns token or false.
	 */
	private function create2FACode( \WP_User $user ): array|false {
		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			return false;
		}
		$token = bin2hex( random_bytes( 32 ) );
		$code  = str_pad( (string) random_int( 0, 99999999 ), 8, '0', STR_PAD_LEFT );

		$saved = $this->put2FATransient(
			$token,
			[
				'user_id'         => $user->ID,
				'code_hash'       => Crypto::hashEmailCode( $code ),
				'method'          => 'email',
				'created'         => time(),
				'failed_attempts' => 0,
			]
		);

		if ( ! $saved ) {
			return false;
		}

		$this->replacePendingTokenForUser( (int) $user->ID, $token );
		$this->queueCodeEmail( $user, $code );
		return [ 'token' => $token, 'method' => 'email' ];
	}

	/**
	 * Match WordPress.org Two-Factor: block XML-RPC / REST authentication with regular credentials for enforced roles unless an application password was used.
	 *
	 * @param \WP_User|\WP_Error|null $user     Authenticated user or error.
	 * @param string                  $username Username (unused).
	 * @param string                  $password Password (unused).
	 * @return \WP_User|\WP_Error|null
	 */
	public function restrictXmlRpcRestAuthenticateToAppPasswordForEnforcedRoles( $user, string $username, string $password ) {
		unset( $username, $password );
		$settings = $this->getSettings();
		if ( empty( $settings['restrict_api_login_to_app_passwords'] ) ) {
			return $user;
		}
		if ( ! $this->isXmlRpcOrRestApiRequest() ) {
			return $user;
		}
		if ( ! $user instanceof \WP_User ) {
			return $user;
		}
		if ( apply_filters( 'wpps_2fa_allow_bypass', false, $user ) ) {
			return $user;
		}
		if ( ! $this->userInEnforcedRoles( $user ) ) {
			return $user;
		}
		if ( get_user_meta( $user->ID, 'wpps_2fa_disabled', true ) === '1' ) {
			return $user;
		}
		if ( did_action( 'application_password_did_authenticate' ) ) {
			return $user;
		}

		return new \WP_Error(
			'invalid_application_credentials',
			__( 'Error: API login for user disabled.', 'wp-powersuite' )
		);
	}

	/**
	 * Same semantics as Two_Factor_Core::is_user_api_login_enabled() filter when the standalone Two-Factor plugin is not loaded.
	 *
	 * @param bool $enabled Whether API login is allowed (often from application_password_did_authenticate).
	 * @param int  $user_id User ID.
	 * @return bool
	 */
	public function filterTwoFactorUserApiLoginEnable( $enabled, $user_id ): bool {
		$user_id = (int) $user_id;
		if ( empty( $this->getSettings()['restrict_api_login_to_app_passwords'] ) ) {
			return true;
		}
		if ( $user_id <= 0 ) {
			return (bool) $enabled;
		}
		$user = get_user_by( 'id', $user_id );
		if ( ! $user instanceof \WP_User ) {
			return (bool) $enabled;
		}
		if ( ! $this->userInEnforcedRoles( $user ) ) {
			return (bool) $enabled;
		}
		if ( get_user_meta( $user_id, 'wpps_2fa_disabled', true ) === '1' ) {
			return true;
		}

		return (bool) did_action( 'application_password_did_authenticate' );
	}

	/**
	 * Whether the current request is XML-RPC or REST (same idea as Two_Factor_Core::is_api_request).
	 */
	private function isXmlRpcOrRestApiRequest(): bool {
		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
			return true;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return true;
		}

		return false;
	}

	/**
	 * After password is valid, require 2FA for configured roles.
	 */
	public function requireTwoFactor( $user, string $username, string $password ) {
		if ( ! $user instanceof \WP_User ) {
			return $user;
		}

		// REST/XML-RPC application passwords are already a second credential; requiring the
		// interactive 2FA step here breaks legitimate API clients (see wp_authenticate_application_password).
		if ( did_action( 'application_password_did_authenticate' ) ) {
			return $user;
		}

		// Allow other modules (e.g. Temporary Login) to bypass 2FA when appropriate.
		if ( apply_filters( 'wpps_2fa_allow_bypass', false, $user ) ) {
			return $user;
		}

		if ( get_user_meta( $user->ID, 'wpps_2fa_disabled', true ) === '1' ) {
			return $user;
		}

		$settings = $this->getSettings();
		$roles    = $settings['roles'] ?? [];
		if ( ! is_array( $roles ) || empty( $roles ) ) {
			return $user;
		}

		$user_roles = (array) $user->roles;
		$applicable = array_intersect( $user_roles, $roles );
		if ( empty( $applicable ) ) {
			return $user;
		}

		if ( $this->trustCookieValidForUser( $user ) ) {
			return $user;
		}

		if ( $this->isOtpBudgetLocked( (int) $user->ID ) ) {
			return $this->otpLockedError();
		}

		$method = UserProfile::getUserMethod( $user->ID );
		$enable_email = ! empty( $settings['enable_email'] );
		$enable_auth  = ! empty( $settings['enable_authenticator'] );

		if ( $method === 'authenticator' && $enable_auth && UserProfile::getTotpSecret( $user->ID ) ) {
			$result = $this->create2FATotpSession( $user );
		} elseif ( $enable_email ) {
			$result = $this->create2FACode( $user );
		} elseif ( $enable_auth ) {
			return new \WP_Error( 'wpps_2fa_error', __( 'Set up an authenticator app in your profile or ask an administrator to enable email codes.', 'wp-powersuite' ) );
		} else {
			return new \WP_Error( 'wpps_2fa_error', __( 'No 2FA method available. Contact support.', 'wp-powersuite' ) );
		}

		if ( ! $result ) {
			return new \WP_Error( 'wpps_2fa_error', __( 'Could not complete 2FA setup. Please try again.', 'wp-powersuite' ) );
		}

		// For non-AJAX (fallback): store token for same-request redirect and set cookie
		if ( ! wp_doing_ajax() ) {
			self::$pending_2fa_token = $result['token'];
			$this->setPending2FACookie( $result['token'] );
		}

		$current_method = $result['method'] ?? 'email';
		$can_switch     = $this->userHasBothMethods( $user );
		$other_method   = $can_switch ? ( $current_method === 'email' ? 'authenticator' : 'email' ) : '';
		$msg = $current_method === 'authenticator'
			? __( 'Enter the code from your authenticator app.', 'wp-powersuite' )
			: __( 'A verification code has been sent to your email. You will be redirected to enter it.', 'wp-powersuite' );
		return new \WP_Error(
			'wpps_2fa_required',
			$msg,
			[
				'token'        => $result['token'],
				'method'       => $current_method,
				'can_switch'   => $can_switch,
				'other_method' => $other_method,
			]
		);
	}

	/**
	 * When the login form shows our 2FA-required message but the user has no 2FA session
	 * (e.g. opened from WP Toolkit / cPanel), show a clear message to re-enter credentials
	 * so they can reach the verification step.
	 *
	 * @param string $message Current login form message.
	 * @return string
	 */
	public function loginMessageWhen2FARequired( string $message ): string {
		$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : 'login';
		if ( $action !== 'login' && $action !== '' ) {
			return $message;
		}
		$is_2fa_error = isset( $_GET['error'] ) && sanitize_text_field( wp_unslash( $_GET['error'] ) ) === 'wpps_2fa_required';
		if ( ! $is_2fa_error && $message !== '' ) {
			$our_messages = [
				__( 'Enter the code from your authenticator app.', 'wp-powersuite' ),
				__( 'A verification code has been sent to your email. You will be redirected to enter it.', 'wp-powersuite' ),
			];
			foreach ( $our_messages as $our_msg ) {
				if ( $our_msg !== '' && strpos( $message, $our_msg ) !== false ) {
					$is_2fa_error = true;
					break;
				}
			}
		}
		if ( ! $is_2fa_error ) {
			return $message;
		}
		return __( 'Two-factor authentication is required. Please enter your username and password below; you will then be asked for your verification code.', 'wp-powersuite' );
	}

	/**
	 * Redirect to 2FA form in same request when 2FA was just required (wp-login.php).
	 */
	public function redirectTo2FAFormSameRequest(): void {
		if ( self::$pending_2fa_token === null ) {
			return;
		}
		$token = self::$pending_2fa_token;
		self::$pending_2fa_token = null;
		$validated_to = '';
		if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) {
			$validated_to = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), '' );
		}
		$redirect = $this->buildLoginUrlFor2FAStep( $validated_to !== '' ? $validated_to : null );
		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Same-request redirect after storefront/custom login forms call wp_signon during wp_loaded (e.g. WooCommerce priority 20).
	 * wp-login.php runs later in the bootstrap, so pending token is not set there for same-request redirect there (login_form_login handles it).
	 * Matches ASE / Two-Factor: step two on wp-login.php; no storefront AJAX required.
	 */
	public function redirectPending2FAFromFrontend(): void {
		if ( self::$pending_2fa_token === null ) {
			return;
		}
		if ( wp_doing_ajax() ) {
			return;
		}
		if ( is_admin() ) {
			return;
		}
		$pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
		if ( $pagenow === 'wp-login.php' ) {
			return;
		}
		$token = self::$pending_2fa_token;
		self::$pending_2fa_token = null;

		$redirect_to = '';
		if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) {
			$redirect_to = wp_unslash( $_REQUEST['redirect_to'] );
		} elseif ( isset( $_REQUEST['redirect'] ) && is_string( $_REQUEST['redirect'] ) ) {
			$redirect_to = wp_unslash( $_REQUEST['redirect'] );
		}
		$validated_to = '';
		if ( $redirect_to !== '' ) {
			$validated_to = wp_validate_redirect( $redirect_to, '' );
		}

		$redirect = $this->buildLoginUrlFor2FAStep( $validated_to !== '' ? $validated_to : null );

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Redirect to 2FA form when cookie is present (e.g. after WooCommerce login attempt).
	 */
	public function redirectTo2FAForm(): void {
		$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '';
		if ( wp_doing_ajax() || $action === 'wpps_2fa' || $action === 'wpps_2fa_cancel' ) {
			return;
		}
		$token = isset( $_COOKIE[ self::COOKIE_NAME ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) : '';
		if ( $token === '' || ! $this->isValid2FAToken( $token ) ) {
			if ( $token !== '' ) {
				setcookie( self::COOKIE_NAME, '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
			}
			return;
		}
		$data = get_transient( self::TRANSIENT_PREFIX . $token );
		if ( $data === false || empty( $data['user_id'] ) ) {
			setcookie( self::COOKIE_NAME, '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
			return;
		}
		$validated_to = '';
		if ( isset( $_GET['redirect_to'] ) && is_string( $_GET['redirect_to'] ) ) {
			$validated_to = wp_validate_redirect( wp_unslash( $_GET['redirect_to'] ), '' );
		}
		$redirect = $this->buildLoginUrlFor2FAStep( $validated_to !== '' ? $validated_to : null );
		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Redirect to 2FA form from any page when 2FA cookie is present (WooCommerce, etc.).
	 */
	public function redirectTo2FAFormFromAnyPage(): void {
		if ( ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
			return;
		}
		$token = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) );
		if ( $token === '' || ! $this->isValid2FAToken( $token ) ) {
			setcookie( self::COOKIE_NAME, '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
			return;
		}
		if ( is_user_logged_in() ) {
			return;
		}
		if ( isset( $GLOBALS['pagenow'] ) && $GLOBALS['pagenow'] === 'wp-login.php' ) {
			return;
		}
		$data = get_transient( self::TRANSIENT_PREFIX . $token );
		if ( $data === false || empty( $data['user_id'] ) ) {
			setcookie( self::COOKIE_NAME, '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
			return;
		}
		$path = '/';
		if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
			$parsed = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ) );
			if ( is_array( $parsed ) && ! empty( $parsed['path'] ) ) {
				$path = $parsed['path'];
				if ( ! empty( $parsed['query'] ) ) {
					$path .= '?' . $parsed['query'];
				}
			}
		}
		$current  = wp_validate_redirect( home_url( $path ), home_url( '/' ) );
		$redirect = $this->buildLoginUrlFor2FAStep( $current );
		wp_safe_redirect( $redirect );
		exit;
	}

	public function handle2FASubmit(): void {
		if ( ( $_SERVER['REQUEST_METHOD'] ?? '' ) !== 'POST' || ! isset( $_POST['wpps_2fa_nonce'] ) ) {
			return;
		}
		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['wpps_2fa_nonce'] ) ), 'wpps_2fa_verify' ) ) {
			wp_safe_redirect( add_query_arg( [ 'action' => 'wpps_2fa', 'error' => 'invalid_nonce' ], wp_login_url() ) );
			exit;
		}
		$token    = isset( $_POST['wpps_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_2fa_token'] ) ) : '';
		if ( $token === '' || ! $this->isValid2FAToken( $token ) ) {
			$token = $this->resolvePending2FATokenFromRequest();
		}
		$code_raw = isset( $_POST['wpps_2fa_code'] ) ? trim( (string) wp_unslash( $_POST['wpps_2fa_code'] ) ) : '';
		$code_d   = preg_replace( '/\D/', '', $code_raw );
		$code_a   = strtoupper( preg_replace( '/\s/', '', $code_raw ) );

		if ( $token === '' || ! $this->isValid2FAToken( $token ) ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}

		$data = get_transient( self::TRANSIENT_PREFIX . $token );

		if ( $data === false || ! is_array( $data ) || empty( $data['user_id'] ) ) {
			$this->clearPending2FACookie();
			wp_safe_redirect( add_query_arg( [ 'action' => 'wpps_2fa', 'error' => 'invalid_code' ], wp_login_url() ) );
			exit;
		}

		$user_check = $this->getTransient2FAUserOrError( $data );
		if ( is_wp_error( $user_check ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			$this->clearPending2FACookie();
			wp_safe_redirect( wp_login_url() );
			exit;
		}

		if ( $this->isOtpBudgetLocked( (int) $data['user_id'] ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			$this->clearPending2FACookie();
			wp_safe_redirect( add_query_arg( 'error', 'wpps_2fa_locked', wp_login_url() ) );
			exit;
		}

		$valid = false;
		if ( ( $data['method'] ?? 'email' ) === 'authenticator' ) {
			$uid = (int) $data['user_id'];
			$secret = UserProfile::getTotpSecret( $uid );
			if ( strlen( $code_d ) === 6 && ctype_digit( $code_d ) ) {
				$valid = UserProfile::verifyTotpCode( $uid, $secret, $code_d );
			}
			if ( ! $valid && strlen( $code_a ) === 8 ) {
				$valid = UserProfile::verifyBackupCode( $uid, $code_a );
			}
		} else {
			$valid = Crypto::verifyEmailCode( $code_d, $data );
		}

		if ( ! $valid ) {
			$fail = $this->registerFailedVerification( $token, $data );
			if ( $fail === 'locked' ) {
				$this->clearPending2FACookie();
				wp_safe_redirect( add_query_arg( 'error', 'wpps_2fa_locked', wp_login_url() ) );
				exit;
			}
			wp_safe_redirect( add_query_arg( [ 'action' => 'wpps_2fa', 'error' => 'invalid_code' ], wp_login_url() ) );
			exit;
		}

		delete_transient( self::TRANSIENT_PREFIX . $token );
		$this->clearPending2FACookie();
		$this->clearOtpFailBudgetForUser( (int) $user_check->ID );

		$user = $user_check;

		wp_clear_auth_cookie();
		wp_set_current_user( $user->ID );
		wp_set_auth_cookie( $user->ID, true );
		do_action( 'wp_login', $user->user_login, $user );
		$this->maybePersistTrustedDevice( $user );

		$requested = isset( $_POST['redirect_to'] ) && is_string( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : '';
		wp_safe_redirect( $this->resolvePostLoginRedirect( $user, $requested ) );
		exit;
	}

	public function render2FAForm(): void {
		$token = $this->resolvePending2FATokenFromRequest();
		$error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : '';

		if ( $token === '' ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}
		$data = get_transient( self::TRANSIENT_PREFIX . $token );
		if ( $data === false || ! is_array( $data ) || empty( $data['user_id'] ) ) {
			$this->clearPending2FACookie();
			wp_safe_redirect( wp_login_url() );
			exit;
		}
		if ( is_wp_error( $this->getTransient2FAUserOrError( $data ) ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $token );
			$this->clearPending2FACookie();
			wp_safe_redirect( wp_login_url() );
			exit;
		}

		$this->setPending2FACookie( $token );

		$method    = $data['method'] ?? 'email';
		$is_auth   = $method === 'authenticator';
		$trust_days = $this->getTrustedDeviceDays();
		$user      = get_user_by( 'ID', (int) $data['user_id'] );
		$can_switch   = $user && $this->userHasBothMethods( $user );
		$other_method = $can_switch ? ( $method === 'email' ? 'authenticator' : 'email' ) : null;

		login_header( '', '' );

		$redirect_to  = isset( $_GET['redirect_to'] ) ? esc_url( wp_unslash( $_GET['redirect_to'] ) ) : admin_url();
		$base_2fa_url = add_query_arg( 'action', 'wpps_2fa', wp_login_url( '', true ) );
		?>
		<div class="wpps-2fa-card wpps-2fa-card--fullpage" role="region" aria-labelledby="wpps-2fa-card-heading-full">
			<div class="wpps-2fa-error" id="wpps-2fa-fullpage-error" role="alert"<?php echo in_array( $error, [ 'invalid_code', 'invalid_nonce' ], true ) ? '' : ' style="display:none;"'; ?>>
				<?php if ( $error === 'invalid_code' ) : ?>
					<?php esc_html_e( 'Invalid or expired code. Please try again or log in again to receive a new code.', 'wp-powersuite' ); ?>
				<?php elseif ( $error === 'invalid_nonce' ) : ?>
					<?php esc_html_e( 'Security check failed. Please try again.', 'wp-powersuite' ); ?>
				<?php endif; ?>
			</div>
			<h1 class="wpps-2fa-card__title" id="wpps-2fa-card-heading-full"><?php esc_html_e( 'Two-Factor Authentication', 'wp-powersuite' ); ?></h1>
			<form name="wpps_2fa_form" id="wpps_2fa_form" class="wpps-2fa-form" action="<?php echo esc_url( $base_2fa_url ); ?>" method="post" autocomplete="off" data-can-switch="<?php echo $can_switch ? '1' : '0'; ?>" data-other-method="<?php echo esc_attr( $other_method ?? '' ); ?>" data-base-2fa-url="<?php echo esc_attr( $base_2fa_url ); ?>">
				<input type="hidden" name="wpps_2fa_token" value="<?php echo esc_attr( $token ); ?>" />
				<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect_to ); ?>" />
				<?php wp_nonce_field( 'wpps_2fa_verify', 'wpps_2fa_nonce' ); ?>
				<p class="wpps-2fa-code-field-wrap">
					<label for="wpps_2fa_code" id="wpps_2fa_code_label" class="wpps-2fa-code-label"><?php esc_html_e( 'Verification Code', 'wp-powersuite' ); ?></label>
					<span class="wpps-2fa-code-hint" id="wpps-2fa-code-hint-fullpage"><?php echo esc_html( $is_auth ? __( 'Enter the 6-digit code from your authenticator app.', 'wp-powersuite' ) : __( 'Enter the 8-digit code we sent to your email below.', 'wp-powersuite' ) ); ?></span>
					<input type="text" name="wpps_2fa_code" id="wpps_2fa_code" class="input wpps-2fa-code-input" size="10" maxlength="8" inputmode="numeric" autocomplete="one-time-code" placeholder="<?php echo esc_attr( $is_auth ? 'XXXXXX' : 'XXXXXXXX' ); ?>" autofocus data-mode="<?php echo $is_auth ? 'authenticator' : 'email'; ?>" />
				</p>
				<?php if ( $trust_days > 0 ) : ?>
				<p class="wpps-2fa-trust-wrap">
					<label>
						<input type="checkbox" name="wpps_2fa_trust_device" id="wpps_2fa_trust_device" value="1" />
						<?php
						echo esc_html(
							sprintf(
								/* translators: %d: number of days */
								__( 'Remember this browser for %d days.', 'wp-powersuite' ),
								$trust_days
							)
						);
						?>
					</label>
				</p>
				<?php endif; ?>
				<div class="wpps-2fa-primary-actions<?php echo $is_auth ? '' : ' wpps-2fa-primary-actions--email'; ?>" id="wpps-2fa-primary-actions-full">
					<p class="submit wpps-2fa-submit-row">
						<input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large wpps-2fa-verify-btn" value="<?php esc_attr_e( 'Verify', 'wp-powersuite' ); ?>" />
					</p>
					<?php if ( ! $is_auth ) : ?>
					<p class="wpps-2fa-resend-wrap">
						<button type="button" id="wpps-2fa-resend-fullpage" class="button button-large"><?php esc_html_e( 'Resend Code', 'wp-powersuite' ); ?></button>
					</p>
					<?php endif; ?>
				</div>
			</form>
			<p id="backtoblog" class="wpps-2fa-back-wrap">
				<a href="<?php echo esc_url( $this->buildCancel2FAUrl() ); ?>" class="wpps-2fa-back-link"><?php echo esc_html( '&larr; ' . __( 'Back to login', 'wp-powersuite' ) ); ?></a>
			</p>
			<?php if ( $is_auth || ( $can_switch && $other_method ) ) : ?>
			<div class="wpps-2fa-secondary-actions" id="wpps-2fa-secondary-actions-full">
				<?php if ( $is_auth ) : ?>
				<span class="wpps-2fa-secondary-actions__item">
					<button type="button" id="wpps-2fa-use-backup-fullpage" class="button button-large button-secondary wpps-2fa-backup-toggle"><?php esc_html_e( 'Recovery code', 'wp-powersuite' ); ?></button>
				</span>
				<?php endif; ?>
				<?php if ( $can_switch && $other_method ) : ?>
				<span class="wpps-2fa-secondary-actions__item">
					<button type="button" id="wpps-2fa-switch-method-fullpage" class="button button-large button-secondary"><?php echo $other_method === 'email' ? esc_html__( 'Use email instead', 'wp-powersuite' ) : esc_html__( 'Use authenticator instead', 'wp-powersuite' ); ?></button>
				</span>
				<?php endif; ?>
			</div>
			<?php endif; ?>
		</div>
		<?php
		login_footer();
		exit;
	}

	/**
	 * Schedule verification email on shutdown so AJAX responses and redirects are not blocked by wp_mail().
	 */
	private function queueCodeEmail( \WP_User $user, string $code ): void {
		add_action(
			'shutdown',
			function () use ( $user, $code ): void {
				$this->sendCodeEmail( $user, $code );
			},
			20
		);
	}

	private function sendCodeEmail( \WP_User $user, string $code ): void {
		/* translators: %s: string value. */
		$subject = sprintf( __( 'Your login verification code for %s', 'wp-powersuite' ), get_bloginfo( 'name' ) );

		$ip_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
		$ip     = apply_filters(
			'wpps_2fa_email_client_ip',
			is_string( $ip_raw ) && $ip_raw !== '' ? sanitize_text_field( $ip_raw ) : '',
			$user
		);

		$ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';

		if ( $ip === '' ) {
			$ip = '—';
		}

		$geo_rows = [];
		if ( apply_filters( 'wpps_2fa_email_geo_lookup_enabled', false, $user, $ip ) ) {
			$geo_rows = $this->lookupGeoLinesForIp( $ip );
		}

		$device_summary = self::summarizeClientUserAgent( $ua_raw );
		if ( $device_summary === '' ) {
			$device_summary = $ua_raw !== '' ? self::truncateForEmailSummary( $ua_raw ) : '—';
		}

		$datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
		$contact  = apply_filters( 'wpps_2fa_email_contact_url', home_url( '/' ), $user );

		$html    = $this->buildHtmlEmail( $code, $ip, $geo_rows, $device_summary, $ua_raw, $datetime, $contact, $this->getSessionTtl() );
		$headers = EmailRenderer::headers();
		wp_mail( $user->user_email, $subject, $html, $headers );
	}

	/**
	 * Lightweight IP geo lines for verification email only (cached; no lookups unless email is sending).
	 *
	 * @return array<int, array{label: string, value: string}>
	 */
	private function lookupGeoLinesForIp( string $ip ): array {
		if ( $ip === '' || $ip === '—' ) {
			return [];
		}
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return [];
		}

		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return [
				[
					'label' => __( 'Estimated location:', 'wp-powersuite' ),
					'value' => __( 'Unavailable on private or reserved IP addresses', 'wp-powersuite' ),
				],
			];
		}

		$cache_key = 'wpps_2fa_ge_' . md5( $ip );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$url = apply_filters(
			'wpps_2fa_geo_lookup_url',
			'https://ipwho.is/' . rawurlencode( $ip ) . '?fields=success,message,country,region,city,type,connection',
			$ip
		);

		$response = wp_remote_get(
			$url,
			[
				'timeout'     => apply_filters( 'wpps_2fa_geo_http_timeout', 3, $ip ),
				'redirection' => 1,
				'user-agent'  => 'WP-PowerSuite/2FA',
			]
		);

		$lines = [];
		if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
			set_transient( $cache_key, $lines, (int) apply_filters( 'wpps_2fa_geo_cache_ttl_empty', MINUTE_IN_SECONDS * 15, $ip ) );
			TransientPurger::remember( self::TRANSIENT_KEYS_OPTION, $cache_key );
			return $lines;
		}

		$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $data ) || empty( $data['success'] ) ) {
			set_transient( $cache_key, $lines, (int) apply_filters( 'wpps_2fa_geo_cache_ttl_empty', MINUTE_IN_SECONDS * 15, $ip ) );
			TransientPurger::remember( self::TRANSIENT_KEYS_OPTION, $cache_key );
			return $lines;
		}

		if ( ! empty( $data['city'] ) || ! empty( $data['region'] ) || ! empty( $data['country'] ) ) {
			$loc_parts = array_filter(
				[
					(string) ( $data['city'] ?? '' ),
					(string) ( $data['region'] ?? '' ),
					(string) ( $data['country'] ?? '' ),
				]
			);
			if ( ! empty( $loc_parts ) ) {
				$lines[] = [
					'label' => __( 'Approximate location:', 'wp-powersuite' ),
					'value' => implode( ', ', $loc_parts ),
				];
			}
		}

		$conn = isset( $data['connection'] ) && is_array( $data['connection'] ) ? $data['connection'] : [];

		if ( ! empty( $conn['isp'] ) || ! empty( $conn['org'] ) ) {
			$net = implode(
				' · ',
				array_unique(
					array_filter(
						[
							(string) ( $conn['isp'] ?? '' ),
							(string) ( $conn['org'] ?? '' ),
						]
					)
				)
			);
			if ( $net !== '' ) {
				$lines[] = [
					'label' => __( 'Network:', 'wp-powersuite' ),
					'value' => $net,
				];
			}
		}

		if ( ! empty( $data['type'] ) ) {
			$lines[] = [
				'label' => __( 'Connection type:', 'wp-powersuite' ),
				'value' => (string) $data['type'],
			];
		}

		$ttl_ok = (int) apply_filters( 'wpps_2fa_geo_cache_ttl', HOUR_IN_SECONDS, $ip );
		set_transient( $cache_key, $lines, $ttl_ok > 60 ? $ttl_ok : HOUR_IN_SECONDS );
		TransientPurger::remember( self::TRANSIENT_KEYS_OPTION, $cache_key );

		return $lines;
	}

	/**
	 * Human-readable browser + OS hint for verification email (avoid wp_trim_words on raw UA strings).
	 */
	private static function summarizeClientUserAgent( string $ua ): string {
		$ua = trim( preg_replace( '/[\r\n\x00]/', '', $ua ) );
		if ( $ua === '' ) {
			return '';
		}

		$browser = self::guessBrowserLabel( $ua );
		$os      = self::guessOperatingSystemLabel( $ua );

		if ( $browser !== '' && $os !== '' ) {
			/* translators: 1: browser name/version, 2: operating system */
			return sprintf( __( '%1$s on %2$s', 'wp-powersuite' ), $browser, $os );
		}

		return $browser !== '' ? $browser : $os;
	}

	private static function truncateForEmailSummary( string $ua, int $length = 200 ): string {
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			if ( mb_strlen( $ua ) > $length ) {
				return mb_substr( $ua, 0, $length ) . '…';
			}
		} elseif ( strlen( $ua ) > $length ) {
			return substr( $ua, 0, $length ) . '…';
		}
		return $ua;
	}

	private static function guessBrowserLabel( string $ua ): string {
		if ( preg_match( '/SamsungBrowser\/([\d.]+)/i', $ua, $m ) ) {
			return 'Samsung Internet ' . $m[1];
		}
		if ( preg_match( '/Edg(?:e|)\/([\d.]+)/i', $ua, $m ) ) {
			return 'Microsoft Edge ' . $m[1];
		}
		if ( preg_match( '/OPR\/([\d.]+)/i', $ua, $m ) || preg_match( '/Opera\/([\d.]+)/i', $ua, $m ) ) {
			return 'Opera ' . $m[1];
		}
		if ( preg_match( '/CriOS\/([\d.]+)/i', $ua, $m ) ) {
			return 'Chrome (iOS) ' . $m[1];
		}
		if ( preg_match( '/FxiOS\/([\d.]+)/i', $ua, $m ) ) {
			return 'Firefox (iOS) ' . $m[1];
		}
		if ( preg_match( '/Firefox\/([\d.]+)/i', $ua, $m ) ) {
			return 'Firefox ' . $m[1];
		}
		if ( preg_match( '/Chrome\/([\d.]+)/i', $ua, $m ) && ! preg_match( '/Edg/i', $ua ) ) {
			return 'Chrome ' . $m[1];
		}
		if ( preg_match( '/Version\/([\d.]+).*Safari/i', $ua, $m ) && ! preg_match( '/Chrome|Chromium|CriOS/i', $ua ) ) {
			return 'Safari ' . $m[1];
		}

		return '';
	}

	private static function guessOperatingSystemLabel( string $ua ): string {
		if ( preg_match( '/Windows NT ([\d.]+)/i', $ua, $m ) ) {
			return 'Windows ' . $m[1];
		}
		if ( preg_match( '/Windows Phone ([\d.]+)/i', $ua, $m ) ) {
			return 'Windows Phone ' . $m[1];
		}
		if ( stripos( $ua, 'Windows' ) !== false ) {
			return 'Windows';
		}
		if ( preg_match( '/Mac OS X [\d_.]+/i', $ua ) ) {
			return 'macOS';
		}
		if ( stripos( $ua, 'iPhone' ) !== false ) {
			return __( 'iPhone (iOS)', 'wp-powersuite' );
		}
		if ( stripos( $ua, 'iPad' ) !== false ) {
			return __( 'iPad', 'wp-powersuite' );
		}
		if ( preg_match( '/Android ([\d.]+)/i', $ua, $m ) ) {
			return 'Android ' . $m[1];
		}
		if ( preg_match( '/CrOS .*Chrome\/([\d.]+)/i', $ua, $m ) ) {
			return 'Chrome OS';
		}
		if ( stripos( $ua, 'Linux' ) !== false ) {
			return 'Linux';
		}

		return '';
	}

	/**
	 * @param array<int, array{label: string, value: string}> $geo_rows
	 */
	private function buildHtmlEmail( string $code, string $ip, array $geo_rows, string $device_summary, string $ua_raw, string $datetime, string $contact_url, int $valid_seconds ): string {
		$mins      = max( 1, (int) ceil( $valid_seconds / MINUTE_IN_SECONDS ) );
		$valid_for = sprintf(
			/* translators: %d: number of minutes the code remains valid */
			_n( 'Valid for %d minute', 'Valid for %d minutes', $mins, 'wp-powersuite' ),
			$mins
		);

		$detail_rows = [
			[ 'label' => __( 'Time', 'wp-powersuite' ), 'value' => $datetime ],
			[ 'label' => __( 'IP address', 'wp-powersuite' ), 'value' => $ip ],
			[ 'label' => __( 'Device', 'wp-powersuite' ), 'value' => $device_summary ],
		];
		foreach ( $geo_rows as $row ) {
			if ( empty( $row['label'] ) || empty( $row['value'] ) ) {
				continue;
			}
			$detail_rows[] = [
				'label' => (string) $row['label'],
				'value' => (string) $row['value'],
			];
		}

		$content  = EmailComponents::paragraph( esc_html__( 'Someone just tried to sign in to your account. If this was you, enter the code below to continue:', 'wp-powersuite' ) );
		$content .= EmailComponents::codeBlock( $code, __( 'Verification Code', 'wp-powersuite' ), $valid_for );
		$content .= EmailComponents::paragraph( esc_html__( 'Enter this code on the login screen to complete your sign in.', 'wp-powersuite' ) );
		$content .= EmailComponents::detailsBox( __( 'Login details', 'wp-powersuite' ), $detail_rows );

		if ( $ua_raw !== '' ) {
			$snippet  = self::truncateForEmailSummary( $ua_raw, 2048 );
			$content .= EmailComponents::detailRow( __( 'User-Agent', 'wp-powersuite' ), $snippet, true );
		}

		$content .= EmailComponents::notice(
			'<strong>' . esc_html__( "Wasn't you?", 'wp-powersuite' ) . '</strong> ' . esc_html__( 'Change your password immediately and contact our support team. This code will expire shortly.', 'wp-powersuite' )
		);
		$content .= EmailComponents::divider();
		$content .= EmailComponents::paragraph(
			esc_html__( "Never share this code with anyone. We'll never ask for it over email or phone. If you didn't request this, you can safely ignore this message.", 'wp-powersuite' ),
			[ 'size' => EmailDesign::FONT_SMALL, 'muted' => true, 'margin' => '0' ]
		);

		$footer_link = sprintf(
			'%s <a href="%s" style="color:%s;">%s</a>',
			esc_html__( 'Questions?', 'wp-powersuite' ),
			esc_url( $contact_url ),
			esc_attr( EmailRenderer::accentColor() ),
			esc_html__( 'Contact support', 'wp-powersuite' )
		);

		return EmailRenderer::wrap(
			[
				'title'        => __( 'Verification code for your account', 'wp-powersuite' ),
				'preheader'    => sprintf(
					/* translators: %s: verification code */
					__( 'Your sign-in code is %s', 'wp-powersuite' ),
					$code
				),
				'content'      => $content,
				'footer'       => $footer_link,
				'footer_brand' => esc_html__( 'This is an automated message from your account security system.', 'wp-powersuite' ),
			]
		);
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	public function getSettings(): array {
		$defaults = $this->getDefaultSettings();
		$saved    = $this->moduleSettings()->all( [] );
		$settings = self::normalizeSettings( array_merge( $defaults, is_array( $saved ) ? $saved : [] ) );

		if ( \WP_PowerSuite\Core\ApplicationPasswordConflictPolicy::isDisableApplicationPasswordsEnabled() ) {
			$settings['restrict_api_login_to_app_passwords'] = false;
		}

		return $settings;
	}

	/**
	 * Sanitize module settings before save or read.
	 *
	 * @param array<string, mixed> $settings Raw settings.
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $settings ): array {
		$ttl_allowed  = [ 300, 600, 900, 1200, 1800, 3600 ];
		$att_allowed  = [ 3, 5, 8, 10 ];
		$grace_allowed = [ 0, 1, 3, 7, 14, 30, 60, 90 ];
		$trust_allowed = [ 0, 7, 14, 30, 60, 90 ];

		$roles = $settings['roles'] ?? [];
		if ( ! is_array( $roles ) ) {
			$roles = [];
		}
		if ( ! function_exists( 'wp_roles' ) ) {
			require_once ABSPATH . 'wp-admin/includes/user.php';
		}
		$roles_obj   = wp_roles();
		$valid_roles = is_object( $roles_obj ) && isset( $roles_obj->roles ) ? array_keys( $roles_obj->roles ) : [];
		$settings['roles'] = array_values(
			array_unique(
				array_filter(
					array_map( 'sanitize_key', $roles ),
					static fn( $slug ) => $slug !== '' && in_array( $slug, $valid_roles, true )
				)
			)
		);

		$settings['enable_email']         = ! empty( $settings['enable_email'] );
		$settings['enable_authenticator']   = ! empty( $settings['enable_authenticator'] );
		$settings['restrict_api_login_to_app_passwords'] = ! empty( $settings['restrict_api_login_to_app_passwords'] );

		if ( ! empty( $settings['roles'] ) && ! $settings['enable_email'] && ! $settings['enable_authenticator'] ) {
			$settings['enable_email'] = true;
		}

		$ttl = isset( $settings['email_code_ttl'] ) ? absint( $settings['email_code_ttl'] ) : 1200;
		$settings['email_code_ttl'] = in_array( $ttl, $ttl_allowed, true ) ? $ttl : 1200;

		$max = isset( $settings['max_verify_attempts'] ) ? absint( $settings['max_verify_attempts'] ) : 5;
		$settings['max_verify_attempts'] = in_array( $max, $att_allowed, true ) ? $max : 5;

		$grace = isset( $settings['grace_period_days'] ) ? absint( $settings['grace_period_days'] ) : 0;
		$settings['grace_period_days'] = in_array( $grace, $grace_allowed, true ) ? $grace : 0;

		$trust = isset( $settings['trusted_device_days'] ) ? absint( $settings['trusted_device_days'] ) : 0;
		$settings['trusted_device_days'] = in_array( $trust, $trust_allowed, true ) ? $trust : 0;

		return $settings;
	}

	/**
	 * {@inheritdoc}
	 */
	public function getDataLocations(): array {
		return [
			'option_store_keys' => [ self::MODULE_ID ],
			'user_meta'         => [
				'wpps_2fa_method',
				'wpps_2fa_use_email',
				'wpps_2fa_use_authenticator',
				'wpps_2fa_totp_secret',
				'wpps_2fa_backup_codes',
				'wpps_2fa_disabled',
				'wpps_2fa_profile_saved',
				'wpps_2fa_grace_started_at',
				'wpps_2fa_trusted',
				'wpps_2fa_totp_last_step',
			],
		];
	}

	/**
	 * Delete pending session and geo-cache transients (module disable / cleanup).
	 */
	public static function clearPendingSessionTransients(): void {
		TransientPurger::deleteRemembered( self::TRANSIENT_KEYS_OPTION );
		TransientPurger::deleteByPrefix( 'wpps_2fa_' );
	}

	/**
	 * @param array<string, mixed> $exporters Registered exporters.
	 * @return array<string, mixed>
	 */
	public function registerPrivacyExporter( array $exporters ): array {
		$exporters['wp-powersuite-two-factor-authentication'] = [
			'exporter_friendly_name' => __( 'Two-Factor Authentication (WP PowerSuite)', 'wp-powersuite' ),
			'callback'               => [ $this, 'exportPrivacyData' ],
		];
		return $exporters;
	}

	/**
	 * @param array<string, mixed> $erasers Registered erasers.
	 * @return array<string, mixed>
	 */
	public function registerPrivacyEraser( array $erasers ): array {
		$erasers['wp-powersuite-two-factor-authentication'] = [
			'eraser_friendly_name' => __( 'Two-Factor Authentication (WP PowerSuite)', 'wp-powersuite' ),
			'callback'             => [ $this, 'erasePrivacyData' ],
		];
		return $erasers;
	}

	/**
	 * @param string $email_address User email.
	 * @param int    $page          Export page.
	 * @return array{data: array<int, array{group_id: string, group_label: string, item_id: string, data: array<int, array{name: string, value: string}>}>, done: bool}
	 */
	public function exportPrivacyData( string $email_address, int $page = 1 ): array {
		$this->ensurePrivacyDependenciesLoaded();
		$user = get_user_by( 'email', $email_address );
		if ( ! $user instanceof \WP_User || $page > 1 ) {
			return [ 'data' => [], 'done' => true ];
		}
		$uid = (int) $user->ID;
		$data = [
			[
				'name'  => __( '2FA status', 'wp-powersuite' ),
				'value' => get_user_meta( $uid, 'wpps_2fa_disabled', true ) === '1'
					? __( 'Disabled for this user', 'wp-powersuite' )
					: __( 'Enabled', 'wp-powersuite' ),
			],
			[
				'name'  => __( 'Primary method', 'wp-powersuite' ),
				'value' => UserProfile::getUserMethod( $uid ),
			],
			[
				'name'  => __( 'Authenticator configured', 'wp-powersuite' ),
				'value' => UserProfile::getTotpSecret( $uid ) !== '' ? __( 'Yes', 'wp-powersuite' ) : __( 'No', 'wp-powersuite' ),
			],
		];
		return [
			'data' => [
				[
					'group_id'    => 'wpps-2fa',
					'group_label' => __( 'Two-Factor Authentication', 'wp-powersuite' ),
					'item_id'     => 'wpps-2fa-' . $uid,
					'data'        => $data,
				],
			],
			'done' => true,
		];
	}

	/**
	 * @param string $email_address User email.
	 * @param int    $page          Eraser page.
	 * @return array{items_removed: bool, items_retained: bool, messages: array<int, string>, done: bool}
	 */
	public function erasePrivacyData( string $email_address, int $page = 1 ): array {
		$this->ensurePrivacyDependenciesLoaded();
		$user = get_user_by( 'email', $email_address );
		if ( ! $user instanceof \WP_User || $page > 1 ) {
			return [
				'items_removed'  => false,
				'items_retained' => false,
				'messages'       => [],
				'done'           => true,
			];
		}
		$uid = (int) $user->ID;
		foreach (
			[
				'wpps_2fa_method',
				'wpps_2fa_use_email',
				'wpps_2fa_use_authenticator',
				'wpps_2fa_totp_secret',
				'wpps_2fa_backup_codes',
				'wpps_2fa_disabled',
				'wpps_2fa_profile_saved',
				'wpps_2fa_grace_started_at',
				'wpps_2fa_trusted',
				'wpps_2fa_totp_last_step',
				'wpps_2fa_totp_secret_pending',
			] as $meta_key
		) {
			delete_user_meta( $uid, $meta_key );
		}
		delete_transient( 'wpps_2fa_pending_' . $uid );

		return [
			'items_removed'  => true,
			'items_retained' => false,
			'messages'       => [ __( 'Two-factor authentication data erased.', 'wp-powersuite' ) ],
			'done'           => true,
		];
	}

	private function ensurePrivacyDependenciesLoaded(): void {
		if ( ! class_exists( Crypto::class, false ) ) {
			require_once __DIR__ . '/includes/class-crypto.php';
		}
		if ( ! class_exists( UserProfile::class, false ) ) {
			require_once __DIR__ . '/user-profile.php';
		}
	}

}

