<?php
/**
 * Verifica biometrica WebAuthn al login (opzione "Biometrico").
 * Stessa struttura della registrazione (Fase 2), lato "get()" invece di "create()".
 *
 * @package SPIDy_WP
 */

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

class SPIDy_WP_Webauthn_Login {

	const CHALLENGE_TRANSIENT_PREFIX = 'spidy_wp_login_challenge_';
	const CHALLENGE_TTL              = 5 * MINUTE_IN_SECONDS;

	public static function init() {
		add_action( 'login_form_spidy_wp_biometric', array( __CLASS__, 'handle_biometric_page' ) );
		add_action( 'wp_ajax_nopriv_spidy_wp_verify_assertion', array( __CLASS__, 'ajax_verify_assertion' ) );

		// Verifica "da un altro dispositivo": la pagina di login resta sul
		// PC, ma l'impronta/Face ID viene data sullo smartphone (che ha
		// davvero il sensore biometrico), inquadrando un QR code.
		add_action( 'login_form_spidy_wp_biometric_remote', array( __CLASS__, 'handle_biometric_remote_page' ) );
		add_action( 'wp_ajax_nopriv_spidy_wp_verify_assertion_remote', array( __CLASS__, 'ajax_verify_assertion_remote' ) );
		add_action( 'wp_ajax_nopriv_spidy_wp_poll_remote_status', array( __CLASS__, 'ajax_poll_remote_status' ) );
	}

	/**
	 * Pagina intermedia: wp-login.php?action=spidy_wp_biometric&spidy_token=...
	 * Mostra un pulsante che invoca navigator.credentials.get().
	 */
	public static function handle_biometric_page() {
		SPIDy_WP_Login::debug_log( 'handle_biometric_page() RAGGIUNTA — inizio esecuzione' );

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- pagina raggiunta da un link generato dal plugin stesso subito dopo il login con password corretta; "spidy_token" è un valore casuale a 32 caratteri, monouso e a scadenza breve (10 minuti), che qui svolge la stessa funzione protettiva di un nonce, ma prima che l'utente abbia una sessione WP autenticata su cui basare un nonce classico.
		$token = isset( $_GET['spidy_token'] ) ? sanitize_text_field( wp_unslash( $_GET['spidy_token'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- valore ri-propagato nell'URL di redirect finale, non usato per alcuna azione.
		$redirect_to = isset( $_GET['redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) : admin_url();

		$user_id = SPIDy_WP_Login::retrieve_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token );

		if ( false === $user_id ) {
			SPIDy_WP_Login::debug_log( '  handle_biometric_page: token NON trovato/scaduto (token=' . $token . ') — mostro "Sessione scaduta"' );
			login_header( __( 'Sessione scaduta', 'spidy-2fa-login' ), '', new WP_Error( 'spidy_wp', __( 'Il tempo per la verifica è scaduto. Torna al login e riprova.', 'spidy-2fa-login' ) ) );
			echo '<p id="backtoblog"><a href="' . esc_url( wp_login_url() ) . '">' . esc_html__( '&larr; Torna al login', 'spidy-2fa-login' ) . '</a></p>';
			login_footer();
			exit;
		}

		$credentials = SPIDy_WP_Credential_Store::get_credentials( (int) $user_id );

		if ( empty( $credentials ) ) {
			SPIDy_WP_Login::debug_log( "  handle_biometric_page: utente #{$user_id}, ZERO credenziali trovate da get_credentials() — mostro \"Nessun dispositivo\"" );
			login_header( __( 'Nessun dispositivo registrato', 'spidy-2fa-login' ), '', new WP_Error( 'spidy_wp', __( 'Nessun dispositivo biometrico risulta registrato per questo account. Contatta l\'amministratore.', 'spidy-2fa-login' ) ) );
			echo '<p id="backtoblog"><a href="' . esc_url( wp_login_url() ) . '">' . esc_html__( '&larr; Torna al login', 'spidy-2fa-login' ) . '</a></p>';
			login_footer();
			exit;
		}

		SPIDy_WP_Login::debug_log( "  handle_biometric_page: utente #{$user_id}, " . count( $credentials ) . ' credenziali trovate — proseguo con il rendering della pagina QR' );

		$challenge = random_bytes( 32 );
		SPIDy_WP_Login::store_value_direct( self::CHALLENGE_TRANSIENT_PREFIX . $token, base64_encode( $challenge ), self::CHALLENGE_TTL );

		$allow_credentials = array_map(
			function ( $credential ) {
				return array(
					'id'         => $credential['credential_id'],
					'type'       => 'public-key',
					'transports' => $credential['transports'],
				);
			},
			$credentials
		);

		wp_enqueue_script(
			'spidy-wp-webauthn-login',
			SPIDY_WP_PLUGIN_URL . 'assets/js/webauthn-login.js',
			array(),
			SPIDY_WP_VERSION,
			true
		);

		wp_localize_script(
			'spidy-wp-webauthn-login',
			'spidyBiometricLogin',
			array(
				'ajaxUrl'     => admin_url( 'admin-ajax.php' ),
				'token'       => $token,
				'redirectTo'  => $redirect_to,
				'options'     => array(
					'challenge'        => SPIDy_WP_Registration::base64url_encode( $challenge ),
					'rpId'             => SPIDy_WP_Registration::get_rp_id(),
					'allowCredentials' => $allow_credentials,
					'userVerification' => 'required',
					'timeout'          => 60000,
				),
				'i18n'        => array(
					'notSupported' => __( 'Questo browser non supporta l\'autenticazione biometrica.', 'spidy-2fa-login' ),
					'genericError' => __( 'Verifica non riuscita. Riprova.', 'spidy-2fa-login' ),
					'cancelled'    => __( 'Operazione annullata.', 'spidy-2fa-login' ),
					'verifying'    => __( 'Verifica in corso…', 'spidy-2fa-login' ),
				),
			)
		);

		// URL che lo smartphone apre inquadrando il QR: stessa sessione
		// "in attesa" (stesso token), ma pagina dedicata al dispositivo
		// mobile che possiede davvero il sensore biometrico.
		$remote_url = add_query_arg(
			array(
				'action'    => 'spidy_wp_biometric_remote',
				'spidy_token' => $token,
			),
			wp_login_url()
		);

		wp_enqueue_script(
			'spidy-wp-qrcode-lib',
			SPIDY_WP_PLUGIN_URL . 'assets/js/qrcode.min.js',
			array(),
			SPIDY_WP_VERSION,
			true
		);

		wp_enqueue_script(
			'spidy-wp-biometric-poll',
			SPIDY_WP_PLUGIN_URL . 'assets/js/biometric-poll.js',
			array( 'spidy-wp-qrcode-lib' ),
			SPIDY_WP_VERSION,
			true
		);

		wp_localize_script(
			'spidy-wp-biometric-poll',
			'spidyBiometricRemote',
			array(
				'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
				'token'      => $token,
				'redirectTo' => $redirect_to,
				'remoteUrl'  => $remote_url,
				'i18n'       => array(
					'waiting'   => __( 'In attesa della conferma dallo smartphone…', 'spidy-2fa-login' ),
					'confirmed' => __( 'Verificato! Accesso in corso…', 'spidy-2fa-login' ),
					'expired'   => __( 'Sessione scaduta. Torna al login e riprova.', 'spidy-2fa-login' ),
				),
			)
		);

		login_header( __( 'Verifica biometrica', 'spidy-2fa-login' ), '', null );
		?>
		<p><?php esc_html_e( 'Conferma il tuo accesso con impronta digitale o Face ID.', 'spidy-2fa-login' ); ?></p>
		<p>
			<button type="button" id="spidy-wp-login-button" class="button button-primary button-large">
				<?php esc_html_e( 'Verifica ora', 'spidy-2fa-login' ); ?>
			</button>
		</p>
		<p id="spidy-wp-login-status"></p>

		<div id="spidy-wp-qr-section">
		<hr />
		<p><strong><?php esc_html_e( 'Stai usando un PC?', 'spidy-2fa-login' ); ?></strong><br />
			<?php esc_html_e( 'Inquadra questo codice con la fotocamera del tuo smartphone: potrai confermare con impronta o Face ID direttamente da lì, e questa pagina proseguirà da sola.', 'spidy-2fa-login' ); ?>
		</p>
		<div id="spidy-wp-qr-container" style="margin:16px 0;">
			<a href="<?php echo esc_url( $remote_url ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $remote_url ); ?></a>
		</div>
		<p id="spidy-wp-remote-status" style="color:#646970;"></p>
		</div>
		<!--
			Nota per gli sviluppatori: la sezione QR sopra viene nascosta via JS
			(vedi assets/js/webauthn-login.js) SOLO su dispositivi mobili che
			dispongono già di un proprio sensore biometrico — inquadrare un QR
			sullo stesso schermo che lo mostra non avrebbe senso. Su PC/desktop
			il comportamento resta invariato: la sezione QR è sempre visibile.
		-->

		<hr />
		<p>
			<?php esc_html_e( 'Non riesci a verificare con impronta o Face ID? Ti abbiamo inviato anche un codice via email, per sicurezza.', 'spidy-2fa-login' ); ?><br />
			<a href="<?php echo esc_url( add_query_arg( array( 'action' => 'spidy_wp_verify', 'spidy_token' => $token, 'redirect_to' => rawurlencode( $redirect_to ) ), wp_login_url() ) ); ?>">
				<?php esc_html_e( 'Usa il codice ricevuto via email', 'spidy-2fa-login' ); ?>
			</a>
		</p>
		<p id="backtoblog"><a href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( '&larr; Torna al login', 'spidy-2fa-login' ); ?></a></p>
		<?php
		SPIDy_WP_Login::debug_log( '  handle_biometric_page: rendering HTML completato senza errori' );
		login_footer( 'spidy-wp-login-button' );
		exit;
	}

	/**
	 * Pagina mobile: wp-login.php?action=spidy_wp_biometric_remote&spidy_token=...
	 * Aperta scansionando il QR mostrato sul PC. Stesso token "in attesa",
	 * ma qui viene generata una sfida (challenge) dedicata, così non entra
	 * in conflitto con quella eventualmente in corso sulla pagina PC.
	 */
	public static function handle_biometric_remote_page() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- stesso motivo di handle_biometric_page(): "spidy_token" è già di per sé un token casuale monouso a scadenza breve, letto da un link generato dal plugin (il QR code), non un form da proteggere con nonce.
		$token = isset( $_GET['spidy_token'] ) ? sanitize_text_field( wp_unslash( $_GET['spidy_token'] ) ) : '';

		$user_id = SPIDy_WP_Login::retrieve_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token );

		if ( false === $user_id ) {
			login_header( __( 'Sessione scaduta', 'spidy-2fa-login' ), '', new WP_Error( 'spidy_wp', __( 'Il tempo per la verifica è scaduto. Torna al computer e ricarica la pagina di login.', 'spidy-2fa-login' ) ) );
			login_footer();
			exit;
		}

		$credentials = SPIDy_WP_Credential_Store::get_credentials( (int) $user_id );

		if ( empty( $credentials ) ) {
			login_header( __( 'Nessun dispositivo registrato', 'spidy-2fa-login' ), '', new WP_Error( 'spidy_wp', __( 'Questo smartphone non risulta registrato per questo account.', 'spidy-2fa-login' ) ) );
			login_footer();
			exit;
		}

		$challenge = random_bytes( 32 );
		SPIDy_WP_Login::store_value_direct( self::CHALLENGE_TRANSIENT_PREFIX . 'remote_' . $token, base64_encode( $challenge ), self::CHALLENGE_TTL );

		$allow_credentials = array_map(
			function ( $credential ) {
				return array(
					'id'         => $credential['credential_id'],
					'type'       => 'public-key',
					'transports' => $credential['transports'],
				);
			},
			$credentials
		);

		wp_enqueue_script(
			'spidy-wp-webauthn-login-remote',
			SPIDY_WP_PLUGIN_URL . 'assets/js/webauthn-login-remote.js',
			array(),
			SPIDY_WP_VERSION,
			true
		);

		wp_localize_script(
			'spidy-wp-webauthn-login-remote',
			'spidyBiometricLoginRemote',
			array(
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'token'   => $token,
				'options' => array(
					'challenge'        => SPIDy_WP_Registration::base64url_encode( $challenge ),
					'rpId'             => SPIDy_WP_Registration::get_rp_id(),
					'allowCredentials' => $allow_credentials,
					'userVerification' => 'required',
					'timeout'          => 60000,
				),
				'i18n'    => array(
					'notSupported' => __( 'Questo browser non supporta l\'autenticazione biometrica.', 'spidy-2fa-login' ),
					'genericError' => __( 'Verifica non riuscita. Riprova.', 'spidy-2fa-login' ),
					'cancelled'    => __( 'Operazione annullata.', 'spidy-2fa-login' ),
					'verifying'    => __( 'Verifica in corso…', 'spidy-2fa-login' ),
					'done'         => __( 'Verificato! Ora puoi tornare al computer: la pagina di login proseguirà da sola.', 'spidy-2fa-login' ),
				),
			)
		);

		login_header( __( 'Verifica biometrica', 'spidy-2fa-login' ), '', null );
		?>
		<p><?php esc_html_e( 'Tocca il pulsante e conferma con impronta digitale o Face ID per completare l\'accesso sul computer.', 'spidy-2fa-login' ); ?></p>
		<p>
			<button type="button" id="spidy-wp-login-remote-button" class="button button-primary button-large">
				<?php esc_html_e( 'Verifica ora', 'spidy-2fa-login' ); ?>
			</button>
		</p>
		<p id="spidy-wp-login-remote-status"></p>
		<?php
		login_footer( 'spidy-wp-login-remote-button' );
		exit;
	}

	/**
	 * AJAX (nopriv) chiamato dalla pagina REMOTA (smartphone) dopo un
	 * navigator.credentials.get() riuscito. Verifica crittograficamente
	 * l'assertion come per il flusso locale, ma NON autentica il browser
	 * che ha chiamato questa richiesta (è il telefono, non il PC): si
	 * limita a marcare la sessione "in attesa" come confermata, cosa che
	 * la pagina PC rileva tramite polling e usa per completare il login
	 * lì dov'è realmente necessario il cookie di sessione.
	 */
	public static function ajax_verify_assertion_remote() {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- endpoint AJAX nopriv raggiunto PRIMA che l'utente abbia una sessione WP autenticata (è la verifica del login stesso): non può esistere un nonce classico legato alla sessione. La sicurezza è garantita dal token casuale monouso "spidy_token" più dalla verifica crittografica WebAuthn della firma del dispositivo, non aggirabile senza la chiave privata del dispositivo registrato.
		$token         = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
		$credential_id = isset( $_POST['credential_id'] ) ? sanitize_text_field( wp_unslash( $_POST['credential_id'] ) ) : '';

		if ( empty( $token ) || empty( $credential_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Dati mancanti.', 'spidy-2fa-login' ) ), 400 );
		}

		$user_id = SPIDy_WP_Login::retrieve_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token );

		if ( false === $user_id ) {
			wp_send_json_error( array( 'message' => __( 'Sessione scaduta.', 'spidy-2fa-login' ) ), 400 );
		}

		$challenge_stored = SPIDy_WP_Login::retrieve_value_direct( self::CHALLENGE_TRANSIENT_PREFIX . 'remote_' . $token );

		if ( false === $challenge_stored ) {
			wp_send_json_error( array( 'message' => __( 'Richiesta scaduta.', 'spidy-2fa-login' ) ), 400 );
		}

		$challenge = base64_decode( $challenge_stored );

		$credential = SPIDy_WP_Credential_Store::get_credential_by_id( (int) $user_id, $credential_id );

		if ( null === $credential || empty( $credential['source_json'] ) ) {
			wp_send_json_error( array( 'message' => __( 'Dispositivo non riconosciuto o registrato con una versione precedente del plugin. Ri-registralo dal profilo.', 'spidy-2fa-login' ) ), 403 );
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo della funzione qui sopra: endpoint pre-autenticazione, sicurezza garantita dal token monouso + verifica crittografica WebAuthn.
		$authenticator_data = isset( $_POST['authenticator_data'] ) ? sanitize_text_field( wp_unslash( $_POST['authenticator_data'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
		$signature          = isset( $_POST['signature'] ) ? sanitize_text_field( wp_unslash( $_POST['signature'] ) ) : '';

		if ( empty( $authenticator_data ) || empty( $signature ) ) {
			wp_send_json_error( array( 'message' => __( 'Dati mancanti.', 'spidy-2fa-login' ) ), 400 );
		}

		$allowed_ids = array_map(
			function ( $c ) {
				return $c['credential_id'];
			},
			SPIDy_WP_Credential_Store::get_credentials( (int) $user_id )
		);

		$verification = SPIDy_WP_Webauthn_Service::verify_assertion(
			array(
				'credential_id'      => $credential_id,
				'authenticator_data' => $authenticator_data,
				// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
				'client_data_json'   => isset( $_POST['client_data_json'] ) ? sanitize_text_field( wp_unslash( $_POST['client_data_json'] ) ) : '',
				'signature'          => $signature,
				// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
				'user_handle'        => isset( $_POST['user_handle'] ) ? sanitize_text_field( wp_unslash( $_POST['user_handle'] ) ) : '',
			),
			$challenge,
			$credential['source_json'],
			SPIDy_WP_Registration::get_rp_id(),
			$allowed_ids,
			(int) $user_id
		);

		if ( is_wp_error( $verification ) ) {
			wp_send_json_error( array( 'message' => sprintf(
				/* translators: %s: messaggio di errore tecnico */
				__( 'Verifica non riuscita: %s', 'spidy-2fa-login' ),
				$verification->get_error_message()
			) ), 403 );
		}

		SPIDy_WP_Credential_Store::update_source_json( (int) $user_id, $credential_id, $verification['source_json'] );
		SPIDy_WP_Login::delete_value_direct( self::CHALLENGE_TRANSIENT_PREFIX . 'remote_' . $token );

		// Segna la sessione come confermata: il PC (via polling) completerà
		// il login sul proprio browser. TTL breve: il PC controlla ogni
		// pochi secondi, non serve tenerlo più a lungo.
		SPIDy_WP_Login::store_value_direct( 'spidy_wp_remote_confirmed_' . $token, (int) $user_id, 2 * MINUTE_IN_SECONDS );

		wp_send_json_success( array( 'message' => __( 'Verificato! Ora puoi tornare al computer.', 'spidy-2fa-login' ) ) );
	}

	/**
	 * AJAX (nopriv) interrogato dalla pagina PC ogni pochi secondi per
	 * sapere se la verifica da smartphone è stata completata. È QUESTA
	 * richiesta — proveniente dal browser del PC — a impostare
	 * effettivamente il cookie di sessione, perché i cookie sono legati
	 * al browser che li riceve: farlo lato smartphone non servirebbe ad
	 * autenticare il PC.
	 */
	public static function ajax_poll_remote_status() {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- endpoint AJAX nopriv di solo polling (nessuna azione distruttiva se non l'esito già determinato altrove): protetto dal token casuale monouso "spidy_token", stesso motivo delle altre funzioni pre-autenticazione in questo file.
		$token       = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- valore ri-propagato al termine del polling, non usato per un'azione.
		$redirect_to = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url();

		if ( empty( $token ) ) {
			wp_send_json_error( array( 'status' => 'expired' ), 400 );
		}

		// Se la sessione "in attesa" originale è scaduta, non ha più senso
		// continuare a interrogare: l'utente deve rifare il login da capo.
		if ( false === SPIDy_WP_Login::retrieve_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token ) ) {
			wp_send_json_success( array( 'status' => 'expired' ) );
		}

		$confirmed_user_id = SPIDy_WP_Login::retrieve_value_direct( 'spidy_wp_remote_confirmed_' . $token );

		if ( false === $confirmed_user_id ) {
			wp_send_json_success( array( 'status' => 'pending' ) );
		}

		SPIDy_WP_Login::delete_value_direct( 'spidy_wp_remote_confirmed_' . $token );
		SPIDy_WP_Login::delete_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token );

		wp_set_auth_cookie( (int) $confirmed_user_id, true );
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- hook core "wp_login" di WordPress, rilanciato di proposito dopo il login manuale via QR/remoto.
		do_action( 'wp_login', get_userdata( $confirmed_user_id )->user_login, get_userdata( $confirmed_user_id ) );

		wp_send_json_success(
			array(
				'status'   => 'confirmed',
				'redirect' => $redirect_to,
			)
		);
	}

	/**
	 * Endpoint AJAX (accessibile anche da utente non loggato: è proprio
	 * il login in corso) che riceve l'assertion firmata dal dispositivo
	 * e la verifica crittograficamente tramite SPIDy_WP_Webauthn_Service
	 * (web-auth/webauthn-framework).
	 */
	public static function ajax_verify_assertion() {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- endpoint AJAX nopriv: è la verifica del login stesso, non può esistere un nonce legato a una sessione non ancora autenticata. Sicurezza garantita dal token monouso "spidy_token" + verifica crittografica WebAuthn della firma del dispositivo.
		$token         = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
		$credential_id = isset( $_POST['credential_id'] ) ? sanitize_text_field( wp_unslash( $_POST['credential_id'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- valore ri-propagato nel redirect finale, non usato per un'azione.
		$redirect_to   = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url();

		if ( empty( $token ) || empty( $credential_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Dati mancanti.', 'spidy-2fa-login' ) ), 400 );
		}

		$user_id = SPIDy_WP_Login::retrieve_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token );

		if ( false === $user_id ) {
			wp_send_json_error( array( 'message' => __( 'Sessione scaduta.', 'spidy-2fa-login' ) ), 400 );
		}

		$challenge_stored = SPIDy_WP_Login::retrieve_value_direct( self::CHALLENGE_TRANSIENT_PREFIX . $token );

		if ( false === $challenge_stored ) {
			wp_send_json_error( array( 'message' => __( 'Richiesta scaduta.', 'spidy-2fa-login' ) ), 400 );
		}

		$challenge = base64_decode( $challenge_stored );

		$credential = SPIDy_WP_Credential_Store::get_credential_by_id( (int) $user_id, $credential_id );

		if ( null === $credential || empty( $credential['source_json'] ) ) {
			wp_send_json_error( array( 'message' => __( 'Dispositivo non riconosciuto o registrato con una versione precedente del plugin. Ri-registralo dal profilo.', 'spidy-2fa-login' ) ), 403 );
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo della funzione qui sopra: endpoint pre-autenticazione, sicurezza garantita dal token monouso + verifica crittografica WebAuthn.
		$authenticator_data = isset( $_POST['authenticator_data'] ) ? sanitize_text_field( wp_unslash( $_POST['authenticator_data'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
		$signature          = isset( $_POST['signature'] ) ? sanitize_text_field( wp_unslash( $_POST['signature'] ) ) : '';

		if ( empty( $authenticator_data ) || empty( $signature ) ) {
			wp_send_json_error( array( 'message' => __( 'Dati mancanti.', 'spidy-2fa-login' ) ), 400 );
		}

		$allowed_ids = array_map(
			function ( $c ) {
				return $c['credential_id'];
			},
			SPIDy_WP_Credential_Store::get_credentials( (int) $user_id )
		);

		$verification = SPIDy_WP_Webauthn_Service::verify_assertion(
			array(
				'credential_id'       => $credential_id,
				'authenticator_data'  => $authenticator_data,
				// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
				'client_data_json'    => isset( $_POST['client_data_json'] ) ? sanitize_text_field( wp_unslash( $_POST['client_data_json'] ) ) : '',
				'signature'           => $signature,
				// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
				'user_handle'         => isset( $_POST['user_handle'] ) ? sanitize_text_field( wp_unslash( $_POST['user_handle'] ) ) : '',
			),
			$challenge,
			$credential['source_json'],
			SPIDy_WP_Registration::get_rp_id(),
			$allowed_ids,
			(int) $user_id
		);

		if ( is_wp_error( $verification ) ) {
			wp_send_json_error( array( 'message' => sprintf(
				/* translators: %s: messaggio di errore tecnico */
				__( 'Verifica non riuscita: %s', 'spidy-2fa-login' ),
				$verification->get_error_message()
			) ), 403 );
		}

		SPIDy_WP_Credential_Store::update_source_json( (int) $user_id, $credential_id, $verification['source_json'] );

		SPIDy_WP_Login::delete_value_direct( self::CHALLENGE_TRANSIENT_PREFIX . $token );
		SPIDy_WP_Login::delete_value_direct( SPIDy_WP_Login::PENDING_TRANSIENT_PREFIX . $token );

		wp_set_auth_cookie( (int) $user_id, true );
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- hook core "wp_login" di WordPress, rilanciato di proposito dopo la verifica biometrica.
		do_action( 'wp_login', get_userdata( $user_id )->user_login, get_userdata( $user_id ) );

		wp_send_json_success( array( 'redirect' => $redirect_to ) );
	}
}
