<?php

declare(strict_types=1);

namespace SiteFort\Modules\TwoFactor;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use SiteFort\Includes\MasterKey;
use SiteFort\Includes\SecretManager;

/**
 * Login interstitial for Two-Factor Authentication.
 *
 * Intercepts the WordPress authentication flow to present a method-aware
 * 2FA challenge, verify the submitted factor, and manage "remember this
 * device" cookies.
 *
 */
class LoginInterstitial {

	/**
	 * Maximum failed verification attempts allowed for a challenge.
	 */
	private const MAX_FAIL_COUNT = 5;

	/**
	 * Cookie name for the "remember device" feature.
	 */
	private const REMEMBER_COOKIE = 'sitefort_2fa_remember';

	/**
	 * HMAC algorithm used for cookie signatures.
	 */
	private const HMAC_ALGO = 'sha256';

	/**
	 * User meta key for tracking when enforcement was first seen.
	 */
	private const ENFORCE_META = 'sitefort_2fa_enforce_since';

	/**
	 * Module settings.
	 *
	 * @var array
	 */
	private array $settings;

	/**
	 * Remember-device cookies cleared during the current request.
	 *
	 * @var array<string,bool>
	 */
	private array $cleared_remember_cookies = [];

	/**
	 * Constructor.
	 *
	 * @param array $settings Module settings from TwoFactor controller.
	 */
	public function __construct( array $settings ) {
		$this->settings = $settings;
	}

	/**
	 * Filter callback for 'authenticate' at priority 30.
	 *
	 * If the authenticated user has 2FA enabled, this prevents the login
	 * from completing and redirects to the 2FA challenge form.
	 *
	 * @param \WP_User|\WP_Error|null $user     The authenticated user or error.
	 * @param string                  $username The username.
	 * @param string                  $password The password.
	 * @return \WP_User|\WP_Error|null The user (if no 2FA), or WP_Error to block login.
	 */
	public function intercept_authentication( $user, $username, $password ) {
		if ( ! ( $user instanceof \WP_User ) ) {
			return $user;
		}

		if ( $this->is_application_password_request( $user, (string) $password ) ) {
			return $user;
		}

		if ( ! self::is_2fa_enabled_for_user( $user->ID ) ) {
			return $user;
		}

		$configured_method = self::normalize_method( self::get_user_method( $user->ID ) );

		if ( $this->verify_remember_cookie( $user->ID ) ) {
			$this->emit_audit_event(
				'sitefort_2fa_remembered_device',
				$user->ID,
				[
					'configured_method' => $configured_method,
				]
			);
			return $user;
		}

		$requested_redirect = esc_url_raw( self::get_request_value( 'redirect_to' ) );
		$redirect_to        = $requested_redirect !== ''
			? wp_validate_redirect( $requested_redirect, admin_url() )
			: admin_url();
		$remember           = self::has_post_value( 'rememberme' );

		$challenge = $this->prepare_challenge_session( $user->ID, $redirect_to, $remember, $configured_method );
		if ( is_wp_error( $challenge ) ) {
			return $challenge;
		}

		$login_url = add_query_arg(
			[
				'action' => 'sitefort_2fa',
				'token'  => $challenge['token'],
			],
			wp_login_url()
		);

		wp_safe_redirect( $login_url );
		exit;
	}

	/**
	 * Render the 2FA challenge form.
	 *
	 * Hooked to `login_form_sitefort_2fa`. Displays a complete HTML
	 * page styled like the WordPress login form.
	 */
	public function render_2fa_form(): void {
		if ( 'POST' === strtoupper( self::read_input_scalar( INPUT_SERVER, 'REQUEST_METHOD' ) ) ) {
			$this->handle_2fa_submit();
			return;
		}

		$token = sanitize_text_field( self::get_query_value( 'token' ) );
		if ( empty( $token ) ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}

		$session = LoginSessionStore::get( $token );
		if ( ! is_array( $session ) ) {
			wp_safe_redirect( add_query_arg( 'sitefort_2fa_error', 'expired', wp_login_url() ) );
			exit;
		}

		$user_id            = (int) ( $session['user_id'] ?? 0 );
		$redirect_to        = (string) ( $session['redirect_to'] ?? admin_url() );
		$error              = sanitize_text_field( self::get_query_value( 'error' ) );
		$method             = self::normalize_method( (string) ( $session['method'] ?? 'totp' ) );
		$notice_code        = (string) ( $session['notice_code'] ?? '' );
		$resent             = self::has_query_value( 'resent' );
		$recovery_available = $user_id > 0 && RecoveryCodes::get_remaining_count( $user_id ) > 0;

		$this->render_form_page( $token, $redirect_to, $error, $method, $resent, $recovery_available, $notice_code );
		exit;
	}

	/**
	 * Process the 2FA form submission.
	 *
	 * Verifies nonce, loads the pending DB-backed challenge session, validates
	 * the submitted factor, optionally sets a remember-device cookie, then
	 * completes the login.
	 */
	public function handle_2fa_submit(): void {
		if ( ! wp_verify_nonce( sanitize_text_field( self::read_input_scalar( INPUT_POST, '_wpnonce' ) ), 'sitefort_2fa_verify' ) ) {
			wp_safe_redirect( add_query_arg( 'sitefort_2fa_error', 'nonce', wp_login_url() ) );
			exit;
		}

		$token = sanitize_text_field( self::read_input_scalar( INPUT_POST, 'sitefort_2fa_token' ) );
		if ( empty( $token ) ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}

		$session = LoginSessionStore::get( $token );
		if ( ! is_array( $session ) || ! isset( $session['user_id'] ) ) {
			wp_safe_redirect( add_query_arg( 'sitefort_2fa_error', 'expired', wp_login_url() ) );
			exit;
		}

		$user_id     = (int) $session['user_id'];
		$redirect_to = (string) ( $session['redirect_to'] ?? admin_url() );
		$remember    = ! empty( $session['remember'] );
		$method      = self::normalize_method( (string) ( $session['method'] ?? 'totp' ) );

		if ( $method === 'email' && self::has_post_value( 'resend_otp' ) ) {
			$resent     = LoginSessionStore::send_email_code( $token, $user_id );
			$resend_url = add_query_arg(
				[
					'action' => 'sitefort_2fa',
					'token'  => $token,
					'resent' => $resent ? '1' : '0',
					'error'  => $resent ? null : 'email_delivery_failed',
				],
				wp_login_url()
			);

			wp_safe_redirect( $resend_url );
			exit;
		}

		$totp_code      = sanitize_text_field( self::read_input_scalar( INPUT_POST, 'totp_code' ) );
		$email_otp_code = sanitize_text_field( self::read_input_scalar( INPUT_POST, 'email_otp_code' ) );
		$recovery_code  = sanitize_text_field( self::read_input_scalar( INPUT_POST, 'recovery_code' ) );

		$verified = false;
		$verified_method = '';
		$configured_method = self::normalize_method( self::get_user_method( $user_id ) );

		if ( $method === 'email' && ! empty( $email_otp_code ) ) {
			$verified = LoginSessionStore::verify_email_code( $token, $email_otp_code );
			if ( $verified ) {
				$verified_method = 'email';
			}
		} elseif ( $method === 'totp' && ! empty( $totp_code ) ) {
			$secret = self::get_user_secret( $user_id );
			if ( $secret && TOTPProvider::verify_code_for_user( $user_id, $secret, $totp_code ) ) {
				$verified = true;
				$verified_method = 'totp';
			}
		}

		if ( ! $verified && ! empty( $recovery_code ) ) {
			if ( RecoveryCodes::verify_and_consume( $user_id, $recovery_code ) ) {
				$verified = true;
				$verified_method = 'recovery';
			}
		}

		if ( ! $verified ) {
			$submitted_method = self::detect_submitted_method( $method, $totp_code, $email_otp_code, $recovery_code );
			$fail_count = LoginSessionStore::increment_fail_count( $token );

			// Feed the site's existing IP/user login-lockout counters (the same ones
			// a wrong password hits) so an attacker cannot bypass them by requesting
			// a fresh challenge session for a new 5-guess budget every time. This
			// keeps the per-session limit generous for legitimate typos/resends
			// while still bounding total guesses across sessions to whatever the
			// site's own lockout policy allows.
			$subject_user = get_userdata( $user_id );
			if ( $subject_user instanceof \WP_User ) {
				do_action( 'wp_login_failed', $subject_user->user_login, new \WP_Error( 'sitefort_2fa_failed', 'Two-factor verification failed.' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress hook, reused so 2FA failures count toward the same lockout thresholds as password failures.
			}

			$this->emit_audit_event(
				'sitefort_2fa_verification_failed',
				$user_id,
				[
					'configured_method' => $configured_method,
					'submitted_method'  => $submitted_method,
					'fail_count'        => $fail_count,
					'max_failures'      => self::MAX_FAIL_COUNT,
				]
			);
			if ( $fail_count >= self::MAX_FAIL_COUNT ) {
				$this->emit_audit_event(
					'sitefort_2fa_challenge_locked',
					$user_id,
					[
						'configured_method' => $configured_method,
						'submitted_method'  => $submitted_method,
						'fail_count'        => $fail_count,
					]
				);
				LoginSessionStore::delete( $token );
				wp_safe_redirect( add_query_arg( 'sitefort_2fa_error', 'too_many_attempts', wp_login_url() ) );
				exit;
			}

			$error_url = add_query_arg(
				[
					'action' => 'sitefort_2fa',
					'token'  => $token,
					'error'  => 'invalid_code',
				],
				wp_login_url()
			);

			wp_safe_redirect( $error_url );
			exit;
		}

		self::update_last_used( $user_id );

		$remember_device = self::has_post_value( 'remember_device' );
		if ( $remember_device ) {
			$days = (int) ( $this->settings['remember_device_days'] ?? 30 );
			$this->set_remember_cookie( $user_id, $days );
		}

		LoginSessionStore::delete( $token );
		wp_set_auth_cookie( $user_id, $remember );
		$user = get_userdata( $user_id );
		if ( $user instanceof \WP_User ) {
			wp_set_current_user( $user_id );
		}
		$this->emit_audit_event(
			'sitefort_2fa_verified',
			$user_id,
			[
				'configured_method'        => $configured_method,
				'method_used'              => '' !== $verified_method ? $verified_method : $method,
				'remember_device'          => $remember_device,
				'recovery_codes_remaining' => 'recovery' === $verified_method ? RecoveryCodes::get_remaining_count( $user_id ) : null,
			]
		);
			if ( $user instanceof \WP_User ) {
				do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress login hook fired after the 2FA challenge completes.
			}
		wp_safe_redirect( $redirect_to );
		exit;
	}

	/**
	 * Verify the "remember this device" cookie.
	 *
	 * Checks if a valid HMAC-signed cookie exists for the given user
	 * and has not expired.
	 *
	 * @param int $user_id The WordPress user ID.
	 * @return bool True if a valid, non-expired cookie exists.
	 */
	public function verify_remember_cookie( int $user_id ): bool {
		$cookie_name = self::REMEMBER_COOKIE . '_' . $user_id;

		if ( isset( $this->cleared_remember_cookies[ $cookie_name ] ) ) {
			return false;
		}

		if ( ! filter_has_var( INPUT_COOKIE, $cookie_name ) ) {
			return false;
		}

		$cookie_value = filter_input(
			INPUT_COOKIE,
			$cookie_name,
			FILTER_CALLBACK,
			[
				'options' => 'sanitize_text_field',
				'flags'   => FILTER_REQUIRE_SCALAR,
			]
		);
		$cookie_value = is_string( $cookie_value ) ? $cookie_value : '';
		$parts        = explode( '|', $cookie_value, 3 );

		if ( count( $parts ) !== 3 ) {
			return false;
		}

		[ $stored_user_id, $expires, $signature ] = $parts;

		// Verify the user ID matches.
		if ( (int) $stored_user_id !== $user_id ) {
			return false;
		}

		// Check expiration.
		if ( (int) $expires < time() ) {
			$this->clear_remember_cookie( $user_id );
			return false;
		}

		// Verify HMAC signature.
		$expected_signature = self::generate_cookie_signature( $user_id, (int) $expires );
		if ( ! hash_equals( $expected_signature, $signature ) ) {
			$this->clear_remember_cookie( $user_id );
			return false;
		}

		global $wpdb;
		$table = $wpdb->prefix . 'sitefort_two_factor';

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT is_enabled, remember_token, remember_expires FROM {$table} WHERE user_id = %d",
					$user_id
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		if ( ! $row || ! (int) $row->is_enabled ) {
			$this->clear_remember_cookie( $user_id );
			return false;
		}

		$db_expires = (int) ( $row->remember_expires ?? 0 );
		if ( $db_expires < time() || $db_expires < (int) $expires ) {
			$this->clear_remember_cookie( $user_id );
			return false;
		}

		$stored_hash = (string) ( $row->remember_token ?? '' );
		$cookie_hash = hash( 'sha256', $signature );
		if ( $stored_hash === '' || ! hash_equals( $stored_hash, $cookie_hash ) ) {
			$this->clear_remember_cookie( $user_id );
			return false;
		}

		return true;
	}

	/**
	 * Set the "remember this device" cookie.
	 *
	 * Creates an HMAC-signed cookie that allows the user to skip
	 * 2FA verification for a specified number of days.
	 *
	 * @param int $user_id The WordPress user ID.
	 * @param int $days    Number of days to remember the device.
	 */
	public function set_remember_cookie( int $user_id, int $days ): void {
		$days      = max( 1, min( 365, $days ) );
		$expires   = time() + ( $days * DAY_IN_SECONDS );
		$signature = self::generate_cookie_signature( $user_id, $expires );

		$cookie_value = $user_id . '|' . $expires . '|' . $signature;
		$cookie_name  = self::REMEMBER_COOKIE . '_' . $user_id;

		// Also store the token hash in the DB for server-side revocation.
		global $wpdb;
		$table = $wpdb->prefix . 'sitefort_two_factor';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->update(
			$table,
			[
				'remember_token'   => hash( 'sha256', $signature ),
				'remember_expires' => $expires,
				'updated_at'       => time(),
			],
			[ 'user_id' => $user_id ],
			[ '%s', '%d', '%d' ],
			[ '%d' ]
		);

		$secure   = is_ssl();
		$path     = COOKIEPATH ?: '/';
		$domain   = COOKIE_DOMAIN ?: '';

		setcookie( $cookie_name, $cookie_value, [
			'expires'  => $expires,
			'path'     => $path,
			'domain'   => $domain,
			'secure'   => $secure,
			'httponly' => true,
			'samesite' => 'Lax',
		] );
	}

	/**
	 * Determine if the current request is an Application Password authentication.
	 *
	 * WordPress Application Passwords are used for REST API and XML-RPC
	 * and should bypass 2FA since they are already a form of token-based
	 * authentication that cannot support interactive challenges.
	 *
	 * @return bool True if the request appears to be using an Application Password.
	 */
	private function is_application_password_request( \WP_User $user, string $password ): bool {
		$is_api_context = ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST );
		if ( ! $is_api_context ) {
			return false;
		}

		if ( ! class_exists( 'WP_Application_Passwords' ) ) {
			return false;
		}

		if ( ! function_exists( 'wp_is_application_passwords_available_for_user' ) || ! wp_is_application_passwords_available_for_user( $user ) ) {
			return false;
		}

		$normalized = preg_replace( '/[^a-z\d]/i', '', $password );
		if ( ! is_string( $normalized ) || ! preg_match( '/^[a-zA-Z0-9]{24}$/', $normalized ) ) {
			return false;
		}

		$hashed_passwords = \WP_Application_Passwords::get_user_application_passwords( $user->ID );
		foreach ( $hashed_passwords as $item ) {
			$hash = isset( $item['password'] ) ? (string) $item['password'] : '';
			if ( $hash !== '' && \WP_Application_Passwords::check_password( $normalized, $hash ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get or initialize the timestamp when enforcement was first applied to a user.
	 *
	 * On the first call for a user, stores the current time in user meta and returns it.
	 * Subsequent calls return the stored timestamp.
	 *
	 * @param int $user_id The WordPress user ID.
	 * @return int Unix timestamp when enforcement was first recorded.
	 */
	public static function get_enforcement_start( int $user_id ): int {
		$since = get_user_meta( $user_id, self::ENFORCE_META, true );
		if ( ! $since ) {
			$since = time();
			update_user_meta( $user_id, self::ENFORCE_META, $since );
		}

		return (int) $since;
	}

	/**
	 * Clear the enforcement start timestamp for a user.
	 *
	 * Called when the user enables 2FA so that a fresh grace period applies
	 * if they later disable it and enforcement is still active.
	 *
	 * @param int $user_id The WordPress user ID.
	 */
	public static function clear_enforcement_start( int $user_id ): void {
		delete_user_meta( $user_id, self::ENFORCE_META );
	}

	/**
	 * Clear enforcement timestamps for all users when policy is newly enabled.
	 */
	public static function clear_all_enforcement_starts(): void {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One-time reset of grace-period tracking after 2FA policy changes.
		$wpdb->delete( $wpdb->usermeta, [ 'meta_key' => self::ENFORCE_META ], [ '%s' ] );
	}

	/**
	 * Check whether 2FA is enabled for a specific user.
	 *
	 * @param int $user_id The WordPress user ID.
	 * @return bool True if 2FA is enabled and confirmed.
	 */
	public static function is_2fa_enabled_for_user( int $user_id ): bool {
		global $wpdb;

		$table = $wpdb->prefix . 'sitefort_two_factor';

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT is_enabled, confirmed_at FROM {$table} WHERE user_id = %d",
					$user_id
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( ! $row ) {
			return false;
		}

		return (bool) $row->is_enabled && ! empty( $row->confirmed_at );
	}

	/**
	 * Get the decrypted TOTP secret for a user.
	 *
	 * @param int $user_id The WordPress user ID.
	 * @return string|null The base32-encoded secret, or null if not found.
	 */
	private static function get_user_secret( int $user_id ): ?string {
		global $wpdb;

		$table = $wpdb->prefix . 'sitefort_two_factor';

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$encrypted = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT totp_secret FROM {$table} WHERE user_id = %d",
					$user_id
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $encrypted ) ) {
			return null;
		}

		$decrypted = SecretManager::decrypt( $encrypted, TOTPProvider::SECRET_CONTEXT );

		return $decrypted !== false ? $decrypted : null;
	}

	/**
	 * Get the 2FA method for a user (totp or email).
	 *
	 * @param int $user_id The WordPress user ID.
	 * @return string The method string, defaults to 'totp'.
	 */
	public static function get_user_method( int $user_id ): string {
		global $wpdb;

		$table = $wpdb->prefix . 'sitefort_two_factor';

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$method = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT method FROM {$table} WHERE user_id = %d",
					$user_id
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return $method ?: 'totp';
	}

	/**
	 * Update the last_used_at timestamp for a user's 2FA record.
	 *
	 * @param int $user_id The WordPress user ID.
	 */
	private static function update_last_used( int $user_id ): void {
		global $wpdb;

		$table = $wpdb->prefix . 'sitefort_two_factor';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->update(
			$table,
			[ 'last_used_at' => time() ],
			[ 'user_id' => $user_id ],
			[ '%d' ],
			[ '%d' ]
		);
	}

	/**
	 * HKDF info string for the remember-device cookie signing key.
	 * Domain-separated from all other MasterKey derivations.
	 */
	private const COOKIE_SIGN_HKDF_INFO = 'sitefort-2fa-remember-v1';

	/**
	 * Generate an HMAC signature for the remember-device cookie.
	 *
	 * Uses a dedicated key derived from the SiteFort MasterKey via HKDF,
	 * domain-separated from all other key usages. Falls back to wp_salt('auth')
	 * if MasterKey is unavailable (e.g., key file not yet created).
	 *
	 * @param int $user_id The WordPress user ID.
	 * @param int $expires Unix timestamp when the cookie expires.
	 * @return string HMAC-SHA256 hex signature.
	 */
	private static function generate_cookie_signature( int $user_id, int $expires ): string {
		try {
			if ( ! class_exists( 'SiteFort\\Includes\\MasterKey' ) ) {
				require_once dirname( __DIR__, 2 ) . '/includes/MasterKey.php';
			}
			$master = MasterKey::get();
			$key    = hash_hkdf( 'sha256', $master, 32, self::COOKIE_SIGN_HKDF_INFO );
		} catch ( \Throwable $e ) {
			// MasterKey not yet available; fall back to WordPress auth salt.
			$key = (string) wp_salt( 'auth' );
		}

		$data = $user_id . '|' . $expires . '|sitefort_2fa_remember';

		return hash_hmac( self::HMAC_ALGO, $data, $key );
	}

	/**
	 * Clear remember-device cookie for a user.
	 */
	private function clear_remember_cookie( int $user_id ): void {
		$cookie_name = self::REMEMBER_COOKIE . '_' . $user_id;
		$secure      = is_ssl();
		$path        = COOKIEPATH ?: '/';
		$domain      = COOKIE_DOMAIN ?: '';
		$this->cleared_remember_cookies[ $cookie_name ] = true;

		setcookie( $cookie_name, '', [
			'expires'  => time() - HOUR_IN_SECONDS,
			'path'     => $path,
			'domain'   => $domain,
			'secure'   => $secure,
			'httponly' => true,
			'samesite' => 'Lax',
		] );
	}

	/**
	 * Build a pending 2FA session and choose a working verification method.
	 *
	 * @return array<string,string>|\WP_Error
	 */
	private function prepare_challenge_session( int $user_id, string $redirect_to, bool $remember, string $configured_method ) {
		$configured_method = self::normalize_method( $configured_method );
		$recovery_ready    = RecoveryCodes::get_remaining_count( $user_id ) > 0;
		$totp_allowed      = in_array( 'totp', $this->get_allowed_methods(), true );
		$totp_ready        = $totp_allowed && self::is_totp_available_for_user( $user_id );
		$email_ready       = $this->is_email_method_available( $user_id );
		$session_method    = $configured_method;
		$notice_code       = '';
		$audit_reason      = '';

		if ( $configured_method === 'totp' ) {
			if ( $totp_ready ) {
				$session_method = 'totp';
			} elseif ( $email_ready ) {
				$session_method = 'email';
				$notice_code    = $totp_allowed ? 'totp_unavailable_email' : 'totp_disabled_email';
				$audit_reason   = $notice_code;
			} elseif ( $recovery_ready ) {
				$session_method = 'recovery';
				$notice_code    = $totp_allowed ? 'totp_unavailable_recovery' : 'totp_disabled_recovery';
				$audit_reason   = $notice_code;
			} else {
				$reason = $totp_allowed ? 'totp_unavailable_no_recovery' : 'totp_disabled_no_recovery';
				$this->emit_audit_event(
					'sitefort_2fa_unavailable',
					$user_id,
					[
						'configured_method' => $configured_method,
						'reason'            => $reason,
					]
				);
				return new \WP_Error(
					'sitefort_2fa_unavailable',
					$totp_allowed
						? __( 'Your configured two-factor method is unavailable and no recovery method is currently available. Please contact the site administrator.', 'sitefort' )
						: __( 'Your configured authenticator app method is no longer allowed by site policy and no recovery method is currently available. Please contact the site administrator.', 'sitefort' )
				);
			}
		} elseif ( $configured_method === 'email' ) {
			if ( $email_ready ) {
				$session_method = 'email';
			} elseif ( $recovery_ready ) {
				$session_method = 'recovery';
				$notice_code    = 'email_disabled_recovery';
				$audit_reason   = 'email_disabled_recovery';
			} else {
				$this->emit_audit_event(
					'sitefort_2fa_unavailable',
					$user_id,
					[
						'configured_method' => $configured_method,
						'reason'            => 'email_unavailable_no_recovery',
					]
				);
				return new \WP_Error(
					'sitefort_2fa_unavailable',
					__( 'Email verification is unavailable and no recovery code is currently available. Please contact the site administrator.', 'sitefort' )
				);
			}
		}

		$token = LoginSessionStore::create( $user_id, $redirect_to, $remember, $session_method, $notice_code );
		if ( '' === $token ) {
			$this->emit_audit_event(
				'sitefort_2fa_unavailable',
				$user_id,
				[
					'configured_method' => $configured_method,
					'reason'            => 'session_storage_unavailable',
				]
			);
			return new \WP_Error(
				'sitefort_2fa_session_unavailable',
				__( 'Two-factor verification could not be prepared right now. Please try again in a moment.', 'sitefort' )
			);
		}

		if ( $session_method === 'email' && ! LoginSessionStore::send_email_code( $token, $user_id ) ) {
			LoginSessionStore::delete( $token );

			if ( ! $recovery_ready ) {
				$this->emit_audit_event(
					'sitefort_2fa_unavailable',
					$user_id,
					[
						'configured_method' => $configured_method,
						'reason'            => 'email_delivery_failed_no_recovery',
					]
				);
				return new \WP_Error(
					'sitefort_2fa_email_failed',
					__( 'Email verification is temporarily unavailable and no recovery code is currently available. Please contact the site administrator.', 'sitefort' )
				);
			}

			$notice_code    = $configured_method === 'totp' ? 'totp_unavailable_recovery' : 'email_unavailable_recovery';
			$session_method = 'recovery';
			$audit_reason   = 'email_unavailable_recovery';
			$token          = LoginSessionStore::create( $user_id, $redirect_to, $remember, $session_method, $notice_code );
			if ( '' === $token ) {
				$this->emit_audit_event(
					'sitefort_2fa_unavailable',
					$user_id,
					[
						'configured_method' => $configured_method,
						'reason'            => 'session_storage_unavailable',
					]
				);
				return new \WP_Error(
					'sitefort_2fa_session_unavailable',
					__( 'Two-factor verification could not be prepared right now. Please try again in a moment.', 'sitefort' )
				);
			}
		}

		if ( $session_method !== $configured_method ) {
			$this->emit_audit_event(
				'sitefort_2fa_fallback_activated',
				$user_id,
				[
					'configured_method' => $configured_method,
					'effective_method'  => $session_method,
					'reason'            => '' !== $audit_reason ? $audit_reason : $notice_code,
				]
			);
		}

		return [
			'token'  => $token,
			'method' => $session_method,
		];
	}

	/**
	 * Check whether the configured TOTP method is available.
	 */
	private static function is_totp_available_for_user( int $user_id ): bool {
		return self::get_user_secret( $user_id ) !== null;
	}

	/**
	 * Check whether email-based login verification is allowed and possible.
	 */
	private function is_email_method_available( int $user_id ): bool {
		$allowed_methods = $this->get_allowed_methods();
		if ( ! in_array( 'email', $allowed_methods, true ) ) {
			return false;
		}

		$user = get_userdata( $user_id );
		return $user instanceof \WP_User && ! empty( $user->user_email );
	}

	/**
	 * Return the normalized set of admin-allowed 2FA methods.
	 *
	 * @return array<int,string>
	 */
	private function get_allowed_methods(): array {
		$defaults = TwoFactor::get_default_settings();
		$methods  = TwoFactor::sanitize_allowed_methods( $this->settings['allowed_methods'] ?? ( $defaults['allowed_methods'] ?? [ 'totp', 'email' ] ) );

		return ! empty( $methods ) ? $methods : TwoFactor::sanitize_allowed_methods( $defaults['allowed_methods'] ?? [ 'totp', 'email' ] );
	}

	/**
	 * Normalize method values from DB/session state to supported values.
	 */
	private static function normalize_method( string $method ): string {
		return in_array( $method, [ 'totp', 'email', 'recovery' ], true ) ? $method : 'totp';
	}

	/**
	 * Read a raw request value for the pre-auth login flow.
	 */
	private static function get_request_value( string $key ): string {
		if ( filter_has_var( INPUT_POST, $key ) ) {
			return self::read_input_scalar( INPUT_POST, $key );
		}

		return self::read_input_scalar( INPUT_GET, $key );
	}

	/**
	 * Read a raw query-string value for the pre-auth login flow.
	 */
	private static function get_query_value( string $key ): string {
		return self::read_input_scalar( INPUT_GET, $key );
	}

	/**
	 * Check whether a query-string key is present.
	 */
	private static function has_query_value( string $key ): bool {
		return filter_has_var( INPUT_GET, $key );
	}

	/**
	 * Check whether a POST key is present for the pre-auth login flow.
	 */
	private static function has_post_value( string $key ): bool {
		$value = self::read_input_scalar( INPUT_POST, $key );
		return $value !== '' && $value !== '0';
	}

	private static function read_input_scalar( int $type, string $key ): string {
		$value = filter_input(
			$type,
			$key,
			FILTER_CALLBACK,
			[
				'options' => [ self::class, 'sanitize_login_request_scalar' ],
				'flags'   => FILTER_REQUIRE_SCALAR,
			]
		);

		return is_string( $value ) ? $value : '';
	}

	/**
	 * Preserve URL/token characters while removing invalid scalar request bytes.
	 *
	 * @param mixed $value
	 */
	public static function sanitize_login_request_scalar( $value ): string {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$value = str_replace( "\0", '', (string) $value );
		$value = preg_replace( '/[\x00-\x1F\x7F]/', '', $value );

		return is_string( $value ) ? trim( $value ) : '';
	}

	/**
	 * Emit a structured 2FA audit event for the subject user.
	 *
	 * The login interstitial runs before a normal WordPress session exists, so
	 * subject attribution must be supplied explicitly for the audit subsystem.
	 *
	 * @param string $hook    Audit hook name.
	 * @param int    $user_id Subject user ID.
	 * @param array  $details Event metadata.
	 */
	private function emit_audit_event( string $hook, int $user_id, array $details = [] ): void {
		$user = get_userdata( $user_id );
		if ( ! $user instanceof \WP_User ) {
			return;
		}

		if ( ! isset( $details['username'] ) || ! is_string( $details['username'] ) || '' === trim( $details['username'] ) ) {
			$details['username'] = $user->user_login;
		}

		$details['_actor_user_id'] = $user->ID;

		switch ( $hook ) {
			case 'sitefort_2fa_remembered_device':
				do_action( 'sitefort_2fa_remembered_device', $user->ID, $details );
				break;
			case 'sitefort_2fa_verification_failed':
				do_action( 'sitefort_2fa_verification_failed', $user->ID, $details );
				break;
			case 'sitefort_2fa_challenge_locked':
				do_action( 'sitefort_2fa_challenge_locked', $user->ID, $details );
				break;
			case 'sitefort_2fa_verified':
				do_action( 'sitefort_2fa_verified', $user->ID, $details );
				break;
			case 'sitefort_2fa_unavailable':
				do_action( 'sitefort_2fa_unavailable', $user->ID, $details );
				break;
			case 'sitefort_2fa_fallback_activated':
				do_action( 'sitefort_2fa_fallback_activated', $user->ID, $details );
				break;
		}
	}

	/**
	 * Detect which verification method the user submitted on the challenge form.
	 *
	 * @param string $active_method   Current primary challenge method.
	 * @param string $totp_code       Submitted TOTP code.
	 * @param string $email_otp_code  Submitted email OTP code.
	 * @param string $recovery_code   Submitted recovery code.
	 * @return string
	 */
	private static function detect_submitted_method( string $active_method, string $totp_code, string $email_otp_code, string $recovery_code ): string {
		if ( '' !== trim( $recovery_code ) ) {
			return 'recovery';
		}

		if ( '' !== trim( $email_otp_code ) ) {
			return 'email';
		}

		if ( '' !== trim( $totp_code ) ) {
			return 'totp';
		}

		return self::normalize_method( $active_method );
	}

	/**
	 * Enqueue assets for the standalone 2FA challenge document.
	 */
	private function enqueue_form_assets( bool $allow_recovery ): void {
		$style_path = SITEFORT_PLUGIN_DIR . 'assets/2fa-interstitial.css';
		wp_enqueue_style(
			'sitefort-2fa-interstitial',
			SITEFORT_PLUGIN_URL . 'assets/2fa-interstitial.css',
			[],
			file_exists( $style_path ) ? (string) filemtime( $style_path ) : SITEFORT_VERSION
		);

		if ( ! $allow_recovery ) {
			return;
		}

		$script_path = SITEFORT_PLUGIN_DIR . 'assets/2fa-interstitial.js';
		wp_enqueue_script(
			'sitefort-2fa-interstitial',
			SITEFORT_PLUGIN_URL . 'assets/2fa-interstitial.js',
			[],
			file_exists( $script_path ) ? (string) filemtime( $script_path ) : SITEFORT_VERSION,
			true
		);
	}

	/**
	 * Render the 2FA challenge form page.
	 *
	 * @param string $token              Session token.
	 * @param string $redirect_to        Redirect target after successful verification.
	 * @param string $error              Optional error code.
	 * @param string $method             Verification method.
	 * @param bool   $resent             Whether the email code was just re-sent.
	 * @param bool   $recovery_available Whether recovery-code fallback is available.
	 * @param string $notice_code        Optional contextual notice code.
	 */
	private function render_form_page( string $token, string $redirect_to, string $error, string $method = 'totp', bool $resent = false, bool $recovery_available = true, string $notice_code = '' ): void {
		nocache_headers();

		$method             = self::normalize_method( $method );
		$allow_recovery     = $method !== 'recovery' && $recovery_available;
		$site_name          = get_bloginfo( 'name', 'display' );
		$error_message      = '';
		$notice_message     = self::get_notice_message( $notice_code );
		$success_message    = '';
		$remember_days      = max( 1, (int) ( $this->settings['remember_device_days'] ?? 30 ) );
		$remember_day_label = sprintf(
			/* translators: %d: number of days to remember the device. */
			_n( 'Remember this device for %d day', 'Remember this device for %d days', $remember_days, 'sitefort' ),
			$remember_days
		);
		$primary_input   = $method === 'email' ? 'email_otp_code' : ( $method === 'recovery' ? 'recovery_code' : 'totp_code' );
		$primary_section = $method === 'email' ? 'sitefort-2fa-email-section' : 'sitefort-2fa-totp-section';
		$toggle_back_to  = $method === 'email'
			? __( 'Use email code', 'sitefort' )
			: __( 'Use authenticator app', 'sitefort' );
		$icon_url       = plugins_url( 'assets/images/icon.svg', dirname( __DIR__ ) );
		$action_url     = wp_login_url();
		$branding = class_exists( '\\SiteFort\\Modules\\WhiteLabel\\WhiteLabel' )
			? \SiteFort\Modules\WhiteLabel\WhiteLabel::get_active_branding()
			: [];
		$brand_name       = sanitize_text_field( (string) ( $branding['plugin_name'] ?? 'SiteFort' ) );
		$brand_name       = '' !== $brand_name ? $brand_name : 'SiteFort';
		$brand_logo_url   = esc_url( (string) ( $branding['logo_url'] ?? '' ), [ 'https' ] );
		$challenge_logo   = '' !== $brand_logo_url ? $brand_logo_url : $icon_url;
		$hide_attribution = ! empty( $branding['hide_attribution'] );

		switch ( $error ) {
			case 'invalid_code':
				$error_message = __( 'Verification failed. Enter a valid code and try again.', 'sitefort' );
				break;
			case 'expired':
				$error_message = __( 'This verification session expired. Sign in again to continue.', 'sitefort' );
				break;
			case 'too_many_attempts':
				$error_message = __( 'Too many failed verification attempts. Sign in again to continue.', 'sitefort' );
				break;
			case 'email_delivery_failed':
				$error_message = __( 'A verification email could not be delivered right now. Try again or use a recovery code.', 'sitefort' );
				break;
			case 'nonce':
				$error_message = __( 'This verification request could not be validated. Sign in again to continue.', 'sitefort' );
				break;
		}

		if ( $resent ) {
			$success_message = __( 'A new verification code has been sent to your email.', 'sitefort' );
		}

		$this->enqueue_form_assets( $allow_recovery );
		?>
		<!DOCTYPE html>
		<html <?php language_attributes(); ?>>
		<head>
			<meta charset="<?php bloginfo( 'charset' ); ?>" />
			<meta name="viewport" content="width=device-width, initial-scale=1.0" />
			<title><?php
			/* translators: %s: site name shown in the browser title on the two-factor challenge screen. */
			echo esc_html( sprintf( __( 'Verify your identity - %s', 'sitefort' ), $site_name ) );
			?></title>
			<?php wp_print_styles(); ?>
			<?php wp_print_head_scripts(); ?>
		</head>
		<body class="sitefort-2fa-body">
			<div class="sitefort-2fa-wrap">
				<div class="sitefort-2fa-card">
					<div class="sitefort-2fa-head">
						<img src="<?php echo esc_url( $challenge_logo ); ?>" alt="<?php echo esc_attr( $brand_name ); ?>" class="sitefort-2fa-icon" />
						<h1 class="sitefort-2fa-title"><?php esc_html_e( 'Verify your identity', 'sitefort' ); ?></h1>
						<p class="sitefort-2fa-site"><?php echo esc_html( $site_name ); ?></p>
					</div>

					<?php if ( $error_message !== '' ) : ?>
						<div class="sitefort-2fa-alert sitefort-2fa-alert-error"><?php echo esc_html( $error_message ); ?></div>
					<?php endif; ?>
					<?php if ( $notice_message !== '' ) : ?>
						<div class="sitefort-2fa-alert sitefort-2fa-alert-info"><?php echo esc_html( $notice_message ); ?></div>
					<?php endif; ?>
					<?php if ( $success_message !== '' ) : ?>
						<div class="sitefort-2fa-alert sitefort-2fa-alert-success"><?php echo esc_html( $success_message ); ?></div>
					<?php endif; ?>

					<form name="sitefort_2fa_form" id="sitefort_2fa_form" method="post" action="<?php echo esc_url( add_query_arg( 'action', 'sitefort_2fa', $action_url ) ); ?>">
						<?php if ( $method === 'email' ) : ?>
							<div id="sitefort-2fa-email-section" class="sitefort-2fa-group">
								<label for="email_otp_code" class="sitefort-2fa-label"><?php esc_html_e( 'Email Verification Code', 'sitefort' ); ?></label>
								<input type="text" name="email_otp_code" id="email_otp_code" class="sitefort-2fa-input" value="" maxlength="6" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000" autofocus="autofocus" />
								<p class="sitefort-2fa-help">
									<?php esc_html_e( 'Enter the 6-digit code sent to your email.', 'sitefort' ); ?>
									<br />
									<button type="submit" name="resend_otp" value="1" class="sitefort-2fa-link"><?php esc_html_e( 'Resend code', 'sitefort' ); ?></button>
								</p>
							</div>
						<?php elseif ( $method === 'totp' ) : ?>
							<div id="sitefort-2fa-totp-section" class="sitefort-2fa-group">
								<label for="totp_code" class="sitefort-2fa-label"><?php esc_html_e( 'Authentication Code', 'sitefort' ); ?></label>
								<input type="text" name="totp_code" id="totp_code" class="sitefort-2fa-input" value="" maxlength="6" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000" autofocus="autofocus" />
								<p class="sitefort-2fa-help"><?php esc_html_e( 'Enter the 6-digit code from your authenticator app.', 'sitefort' ); ?></p>
							</div>
						<?php endif; ?>

						<div id="sitefort-2fa-recovery-section" class="sitefort-2fa-group" <?php echo $method === 'recovery' ? '' : 'style="display:none;"'; ?>>
							<label for="recovery_code" class="sitefort-2fa-label"><?php esc_html_e( 'Recovery Code', 'sitefort' ); ?></label>
							<input type="text" name="recovery_code" id="recovery_code" class="sitefort-2fa-input sitefort-2fa-input-recovery" value="" autocomplete="off" <?php echo $method === 'recovery' ? 'autofocus="autofocus"' : ''; ?> />
							<p class="sitefort-2fa-help"><?php esc_html_e( 'Enter one of your single-use recovery codes.', 'sitefort' ); ?></p>
						</div>

						<?php if ( $allow_recovery ) : ?>
							<a href="#sitefort-2fa-recovery-section"
								id="sitefort-2fa-toggle-recovery"
								class="sitefort-2fa-toggle"
								data-primary-section="<?php echo esc_attr( $primary_section ); ?>"
								data-primary-input="<?php echo esc_attr( $primary_input ); ?>"
								data-toggle-back-label="<?php echo esc_attr( $toggle_back_to ); ?>"
								data-toggle-recovery-label="<?php esc_attr_e( 'Use a recovery code', 'sitefort' ); ?>">
								<?php esc_html_e( 'Use a recovery code', 'sitefort' ); ?>
							</a>
						<?php endif; ?>

						<div class="sitefort-2fa-remember">
							<input name="remember_device" type="checkbox" id="remember_device" value="1" />
							<label for="remember_device"><?php echo esc_html( $remember_day_label ); ?></label>
						</div>

						<?php wp_nonce_field( 'sitefort_2fa_verify' ); ?>
						<input type="hidden" name="sitefort_2fa_token" value="<?php echo esc_attr( $token ); ?>" />
						<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect_to ); ?>" />

						<button type="submit" name="wp-submit" id="wp-submit" class="sitefort-2fa-btn">
							<?php esc_html_e( 'Verify', 'sitefort' ); ?>
						</button>
					</form>

					<div class="sitefort-2fa-divider"></div>
					<div class="sitefort-2fa-footer">
						<a href="<?php echo esc_url( wp_login_url() ); ?>">&larr; <?php esc_html_e( 'Back to login', 'sitefort' ); ?></a>
					</div>
				</div>

				<?php if ( ! $hide_attribution ) : ?>
					<div class="sitefort-2fa-branding">
						<span>
							<?php
							/* translators: %s: white-label plugin name. */
							echo esc_html( sprintf( __( 'Protected by %s', 'sitefort' ), $brand_name ) );
							?>
						</span>
					</div>
				<?php endif; ?>
			</div>

			<?php wp_print_footer_scripts(); ?>
		</body>
		</html>
		<?php
		exit;
	}

	/**
	 * Convert session notice codes into user-facing context.
	 */
	private static function get_notice_message( string $notice_code ): string {
		switch ( $notice_code ) {
			case 'totp_unavailable_email':
				return __( 'Authenticator app verification is temporarily unavailable. A one-time code was sent to your email instead.', 'sitefort' );
			case 'totp_disabled_email':
				return __( 'Authenticator app verification is not currently allowed by site policy. A one-time code was sent to your email instead.', 'sitefort' );
			case 'totp_unavailable_recovery':
				return __( 'Authenticator app verification is temporarily unavailable. Use a recovery code to continue.', 'sitefort' );
			case 'totp_disabled_recovery':
				return __( 'Authenticator app verification is not currently allowed by site policy. Use a recovery code to continue.', 'sitefort' );
			case 'email_unavailable_recovery':
				return __( 'Email verification is temporarily unavailable. Use a recovery code to continue.', 'sitefort' );
			case 'email_disabled_recovery':
				return __( 'Email verification is not currently allowed for this site. Use a recovery code to continue.', 'sitefort' );
			default:
				return '';
		}
	}
}
