<?php
/**
 * Generazione dei parametri di registrazione WebAuthn (Fase 2).
 *
 * @package SPIDy_WP
 */

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

class SPIDy_WP_Registration {

	/**
	 * Transient dove viene salvata la challenge in attesa di verifica,
	 * per utente. Scade dopo 5 minuti: tempo ragionevole per completare
	 * lo scan biometrico senza lasciare challenge "vive" troppo a lungo.
	 */
	const CHALLENGE_TRANSIENT_PREFIX = 'spidy_wp_reg_challenge_';
	const CHALLENGE_TTL              = 20 * MINUTE_IN_SECONDS;

	/**
	 * Salva la challenge scrivendo DIRETTAMENTE nella tabella wp_options
	 * (bypassando le Transients API e quindi qualunque cache oggetti
	 * esterna — Redis/Memcached/APCu — mal configurata o non condivisa
	 * fra i processi PHP). Necessario perché con le transient normali la
	 * challenge scritta da una richiesta a volte non risultava visibile
	 * alla richiesta successiva (stesso identico dato, processo diverso).
	 */
	private static function store_challenge_direct( int $user_id, string $challenge ) {
		global $wpdb;
		$key = '_spidy_wp_reg_challenge_' . $user_id;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- scrittura diretta intenzionale, non tramite Transients API: vedi commento sopra la funzione (challenge non sempre visibile fra richieste con i transient normali).
		$wpdb->query(
			$wpdb->prepare(
				"REPLACE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
				$key,
				maybe_serialize(
					array(
						'value'   => base64_encode( $challenge ),
						'expires' => time() + self::CHALLENGE_TTL,
					)
				)
			)
		);
	}

	private static function retrieve_challenge_direct( int $user_id ) {
		global $wpdb;
		$key = '_spidy_wp_reg_challenge_' . $user_id;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lettura diretta intenzionale, stesso motivo di store_challenge_direct qui sopra.
		$row = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) );

		if ( empty( $row ) ) {
			return false;
		}

		$data = maybe_unserialize( $row );

		if ( ! is_array( $data ) || empty( $data['expires'] ) || $data['expires'] < time() ) {
			return false;
		}

		return base64_decode( $data['value'] );
	}

	private static function delete_challenge_direct( int $user_id ) {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cancellazione diretta intenzionale, stesso motivo di store_challenge_direct qui sopra.
		$wpdb->delete( $wpdb->options, array( 'option_name' => '_spidy_wp_reg_challenge_' . $user_id ) );
	}

	public static function init() {
		add_action( 'show_user_profile', array( __CLASS__, 'render_profile_section' ) );
		add_action( 'edit_user_profile', array( __CLASS__, 'render_profile_section' ) );

		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_assets' ) );

		add_action( 'wp_ajax_spidy_wp_save_credential', array( __CLASS__, 'ajax_save_credential' ) );
		add_action( 'wp_ajax_spidy_wp_remove_credential', array( __CLASS__, 'ajax_remove_credential' ) );
		add_action( 'wp_ajax_spidy_wp_get_fresh_challenge', array( __CLASS__, 'ajax_get_fresh_challenge' ) );
		add_action( 'wp_ajax_spidy_wp_admin_emergency_reset', array( __CLASS__, 'ajax_admin_emergency_reset' ) );
	}

	/**
	 * AJAX: sblocco d'emergenza per un admin — riporta il metodo 2FA di un
	 * altro utente su Email (il più affidabile, nessun dispositivo
	 * richiesto), senza dover passare dalla tabella "Gestione utenti".
	 * Utile quando un utente resta fuori (es. smartphone con la passkey
	 * perso/rotto) e serve sbloccarlo subito.
	 */
	public static function ajax_admin_emergency_reset() {
		if ( ! current_user_can( 'edit_users' ) ) {
			wp_send_json_error( array( 'message' => __( 'Non autorizzato.', 'spidy-2fa-login' ) ), 403 );
		}

		check_ajax_referer( 'spidy_wp_register_nonce', 'nonce' );

		$target_user_id = isset( $_POST['target_user_id'] ) ? absint( $_POST['target_user_id'] ) : 0;

		if ( ! $target_user_id || ! get_userdata( $target_user_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Utente non valido.', 'spidy-2fa-login' ) ), 400 );
		}

		SPIDy_WP_User_Store::set_method( $target_user_id, SPIDy_WP_User_Store::METHOD_EMAIL );

		SPIDy_WP_Login::debug_log( "ajax_admin_emergency_reset: admin #" . get_current_user_id() . " ha riportato l'utente #{$target_user_id} al metodo Email" );

		wp_send_json_success( array( 'message' => __( 'Metodo riportato su Email. L\'utente potrà accedere subito col codice via email.', 'spidy-2fa-login' ) ) );
	}

	/**
	 * AJAX: rigenera le opzioni di creazione (challenge fresca) proprio
	 * nell'istante in cui l'utente preme "Associa Smartphone", invece di
	 * usare quella generata al caricamento della pagina — che poteva
	 * scadere se l'utente impiegava qualche minuto fra i vari tentativi
	 * (es. Google Password Manager irraggiungibile, passaggio a Windows
	 * Hello, ecc.), causando "La richiesta è scaduta" anche quando
	 * l'utente stava semplicemente completando la procedura.
	 */
	public static function ajax_get_fresh_challenge() {
		if ( ! is_user_logged_in() ) {
			SPIDy_WP_Login::debug_log( 'ajax_get_fresh_challenge: utente non autenticato (sessione persa?)' );
			wp_send_json_error( array( 'message' => __( 'Non autenticato.', 'spidy-2fa-login' ) ), 401 );
		}

		check_ajax_referer( 'spidy_wp_register_nonce', 'nonce', false ) || self::fail_fresh_challenge_nonce();

		$user_id = get_current_user_id();
		$options = self::build_creation_options( $user_id );

		SPIDy_WP_Login::debug_log( "ajax_get_fresh_challenge: OK per utente #{$user_id}, nuova challenge generata e salvata" );

		wp_send_json_success( array( 'options' => $options ) );
	}

	/**
	 * Helper per ajax_get_fresh_challenge: nonce non valido, logga il
	 * motivo (invece di un wp_die silenzioso) e interrompe con errore JSON.
	 */
	private static function fail_fresh_challenge_nonce() {
		SPIDy_WP_Login::debug_log( 'ajax_get_fresh_challenge: NONCE NON VALIDO — la pagina è aperta da troppo tempo o la sessione è cambiata. Serve ricaricare la pagina.' );
		wp_send_json_error( array( 'message' => __( 'Sessione di sicurezza non valida: ricarica la pagina e riprova.', 'spidy-2fa-login' ) ), 403 );
	}

	/**
	 * Carica JS/CSS solo nella pagina profilo (profile.php / user-edit.php),
	 * per non appesantire il resto della bacheca.
	 */
	public static function maybe_enqueue_assets( string $hook ) {
		if ( ! in_array( $hook, array( 'profile.php', 'user-edit.php' ), true ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lettura di sola consultazione per decidere quali asset caricare (pagina profilo di quale utente), nessuna azione o scrittura basata su questo valore.
		$user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : get_current_user_id();

		wp_enqueue_style(
			'spidy-wp-profile',
			SPIDY_WP_PLUGIN_URL . 'assets/css/profile.css',
			array(),
			SPIDY_WP_VERSION
		);

		// Un amministratore che guarda il profilo di un altro utente non
		// può registrare dispositivi al suo posto (serve l'impronta/Face ID
		// di quella persona), ma deve poter intervenire in emergenza:
		// rimuovere un dispositivo bloccato o riportare il metodo su Email.
		if ( $user_id !== get_current_user_id() ) {
			if ( ! current_user_can( 'edit_users' ) ) {
				return;
			}

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

			wp_localize_script(
				'spidy-wp-admin-emergency',
				'spidyBiometricAdminEmergency',
				array(
					'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
					'nonce'        => wp_create_nonce( 'spidy_wp_register_nonce' ),
					'targetUserId' => $user_id,
					'i18n'         => array(
						'confirmRemove' => __( 'Rimuovere questo dispositivo? L\'utente non potrà più usarlo per accedere.', 'spidy-2fa-login' ),
						'confirmReset'  => __( 'Riportare il metodo 2FA di questo utente su Email? Potrà accedere subito col codice via email.', 'spidy-2fa-login' ),
						'genericError'  => __( 'Operazione non riuscita. Riprova.', 'spidy-2fa-login' ),
					),
				)
			);

			return;
		}

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

		$default_label = SPIDy_WP_User_Store::get_phone( $user_id );

		wp_localize_script(
			'spidy-wp-registration',
			'spidyBiometricReg',
			array(
				'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
				'nonce'        => wp_create_nonce( 'spidy_wp_register_nonce' ),
				'options'      => self::build_creation_options( $user_id ),
				'defaultLabel' => $default_label ? $default_label : __( 'Il mio dispositivo', 'spidy-2fa-login' ),
				'i18n'         => array(
					'notSupported'   => __( 'Questo browser non supporta l\'autenticazione biometrica (WebAuthn).', 'spidy-2fa-login' ),
					'genericError'   => __( 'Registrazione non riuscita. Riprova.', 'spidy-2fa-login' ),
					'cancelled'      => __( 'Operazione annullata.', 'spidy-2fa-login' ),
					'confirmRemove'  => __( 'Rimuovere questo dispositivo? Non potrà più essere usato per accedere.', 'spidy-2fa-login' ),
					'labelPrompt'    => __( 'Dai un nome a questo dispositivo (es. il tuo numero di telefono):', 'spidy-2fa-login' ),
					'success'        => __( 'Dispositivo associato correttamente.', 'spidy-2fa-login' ),
					'cancelButton'   => __( 'Annulla', 'spidy-2fa-login' ),
					'saveButton'     => __( 'Salva', 'spidy-2fa-login' ),
				),
			)
		);
	}

	/**
	 * Costruisce le "PublicKeyCredentialCreationOptions" da passare a
	 * navigator.credentials.create() lato JS. Genera una challenge
	 * random, la salva in un transient legato all'utente, e restituisce
	 * la struttura in un formato semplice che il JS convertirà nei
	 * corretti ArrayBuffer.
	 *
	 * @param int $user_id ID utente per cui generare le opzioni.
	 * @return array
	 */
	public static function build_creation_options( int $user_id ): array {
		$challenge = self::generate_challenge();

		self::store_challenge_direct( $user_id, $challenge );

		$user     = get_userdata( $user_id );
		$rp_id    = self::get_rp_id();
		$rp_name  = self::get_rp_name();

		// user.id per WebAuthn deve essere un identificativo stabile e
		// non riutilizzabile fra account: usiamo l'ID WP con un prefisso,
		// codificato base64url (il JS lo riconverte in Uint8Array).
		$user_handle = self::base64url_encode( 'wp-user-' . $user_id );

		// Credenziali già registrate: da escludere (excludeCredentials)
		// per impedire che lo stesso dispositivo venga associato due volte.
		$existing_ids = array_map(
			function ( $credential ) {
				return array(
					'id'   => $credential['credential_id'],
					'type' => 'public-key',
				);
			},
			SPIDy_WP_Credential_Store::get_credentials( $user_id )
		);

		return array(
			'challenge' => self::base64url_encode( $challenge ),
			'rp'        => array(
				'id'   => $rp_id,
				'name' => $rp_name,
			),
			'user'      => array(
				'id'          => $user_handle,
				'name'        => $user->user_login,
				'displayName' => $user->display_name ? $user->display_name : $user->user_login,
			),
			'pubKeyCredParams'      => array(
				array( 'type' => 'public-key', 'alg' => -7 ),   // ES256
				array( 'type' => 'public-key', 'alg' => -257 ), // RS256
			),
			'authenticatorSelection' => array(
				// Nessun vincolo su authenticatorAttachment: forzare 'platform'
				// escludeva a priori chi non ha Windows Hello/Touch ID/Face ID
				// attivo sul dispositivo, obbligandolo ad attivarlo nelle
				// impostazioni di sistema solo per usare il sito. Lasciando
				// libero il campo, il browser propone tutte le opzioni
				// disponibili: sensore integrato, chiave di sicurezza USB,
				// passkey sincronizzata nel gestore password del browser, o
				// telefono via QR/Bluetooth — l'utente sceglie quella che ha.
				'userVerification' => 'required',
				'residentKey'      => 'preferred',
			),
			'timeout'               => 60000,
			'attestation'           => 'none', // non serve la catena di attestazione del produttore per questo caso d'uso
			'excludeCredentials'    => $existing_ids,
		);
	}

	/**
	 * Endpoint AJAX: riceve l'oggetto restituito da
	 * navigator.credentials.create(), verifica il nonce, e salva la
	 * credenziale nel database.
	 *
	 * Verifica crittografica reale tramite SPIDy_WP_Webauthn_Service
	 * (web-auth/webauthn-framework): l'attestation viene validata prima
	 * di salvare qualunque credenziale.
	 */
	public static function ajax_save_credential() {
		if ( ! is_user_logged_in() ) {
			wp_send_json_error( array( 'message' => __( 'Non autenticato.', 'spidy-2fa-login' ) ), 401 );
		}

		check_ajax_referer( 'spidy_wp_register_nonce', 'nonce' );

		$user_id = get_current_user_id();

		$credential_id = isset( $_POST['credential_id'] ) ? sanitize_text_field( wp_unslash( $_POST['credential_id'] ) ) : '';
		$public_key    = isset( $_POST['public_key'] ) ? sanitize_text_field( wp_unslash( $_POST['public_key'] ) ) : '';
		$attestation   = isset( $_POST['attestation_object'] ) ? sanitize_textarea_field( wp_unslash( $_POST['attestation_object'] ) ) : '';
		$client_data   = isset( $_POST['client_data_json'] ) ? sanitize_textarea_field( wp_unslash( $_POST['client_data_json'] ) ) : '';
		$label         = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
		// La stringa JSON grezza viene sanitizzata come testo PRIMA del
		// decode (Plugin Check richiede una sanitize_* esplicita sul dato
		// $_POST prima di qualunque elaborazione); i singoli valori
		// decodificati vengono poi ri-sanitizzati e filtrati sui soli
		// transport WebAuthn validi, scartando qualunque altra cosa.
		$transports_raw  = isset( $_POST['transports'] ) ? sanitize_text_field( wp_unslash( $_POST['transports'] ) ) : '';
		$transports_json = ! empty( $transports_raw ) ? json_decode( $transports_raw, true ) : array();
		$transports_json = is_array( $transports_json ) ? $transports_json : array();
		$valid_transports = array( 'usb', 'nfc', 'ble', 'internal', 'hybrid', 'cable', 'smart-card' );
		$transports       = array_values(
			array_intersect(
				array_map( 'sanitize_text_field', $transports_json ),
				$valid_transports
			)
		);

		if ( empty( $credential_id ) || empty( $public_key ) || empty( $attestation ) || empty( $client_data ) ) {
			wp_send_json_error( array( 'message' => __( 'Dati di registrazione incompleti.', 'spidy-2fa-login' ) ), 400 );
		}

		$challenge = self::retrieve_challenge_direct( $user_id );

		if ( false === $challenge ) {
			SPIDy_WP_Login::debug_log( "ajax_save_credential: challenge scaduta/non trovata per utente #{$user_id} (dopo il fix, non dovrebbe più succedere in condizioni normali)" );
			wp_send_json_error( array( 'message' => __( 'La richiesta è scaduta. Ricarica la pagina e riprova.', 'spidy-2fa-login' ) ), 400 );
		}

		self::delete_challenge_direct( $user_id );

		$verification = SPIDy_WP_Webauthn_Service::verify_registration(
			array(
				'credential_id'      => $credential_id,
				'attestation_object' => $attestation,
				'client_data_json'   => $client_data,
			),
			$challenge,
			$user_id,
			self::get_rp_id(),
			self::get_rp_name()
		);

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

		$saved = SPIDy_WP_Credential_Store::add_credential(
			$user_id,
			$verification['credential_id'],
			$public_key,
			0,
			$label,
			$verification['transports'],
			$verification['source_json']
		);

		if ( ! $saved ) {
			wp_send_json_error( array( 'message' => __( 'Questo dispositivo risulta già registrato.', 'spidy-2fa-login' ) ), 409 );
		}

		wp_send_json_success( array( 'message' => __( 'Dispositivo registrato correttamente.', 'spidy-2fa-login' ) ) );
	}

	/**
	 * Endpoint AJAX per rimuovere un dispositivo dal proprio profilo.
	 */
	public static function ajax_remove_credential() {
		if ( ! is_user_logged_in() ) {
			wp_send_json_error( array( 'message' => __( 'Non autenticato.', 'spidy-2fa-login' ) ), 401 );
		}

		check_ajax_referer( 'spidy_wp_register_nonce', 'nonce' );

		$acting_admin_id   = get_current_user_id();
		$requested_user_id = isset( $_POST['target_user_id'] ) ? absint( $_POST['target_user_id'] ) : 0;
		$user_id           = $acting_admin_id;

		// Un admin può rimuovere il dispositivo di un altro utente (es.
		// smartphone perso/rotto: senza questo, l'utente resterebbe bloccato
		// fuori senza modo di rientrare finché non tocca lui stesso quel
		// dispositivo, che magari non ha più). Solo con capacità
		// 'edit_users', e solo specificando esplicitamente l'utente target.
		if ( $requested_user_id && $requested_user_id !== $acting_admin_id ) {
			if ( ! current_user_can( 'edit_users' ) ) {
				wp_send_json_error( array( 'message' => __( 'Non autorizzato a modificare i dispositivi di altri utenti.', 'spidy-2fa-login' ) ), 403 );
			}
			$user_id = $requested_user_id;
			SPIDy_WP_Login::debug_log( "ajax_remove_credential: admin #{$acting_admin_id} rimuove dispositivo per conto dell'utente #{$user_id}" );
		}

		$credential_id = isset( $_POST['credential_id'] ) ? sanitize_text_field( wp_unslash( $_POST['credential_id'] ) ) : '';

		if ( empty( $credential_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Credenziale non specificata.', 'spidy-2fa-login' ) ), 400 );
		}

		$removed = SPIDy_WP_Credential_Store::remove_credential( $user_id, $credential_id );

		if ( ! $removed ) {
			wp_send_json_error( array( 'message' => __( 'Dispositivo non trovato.', 'spidy-2fa-login' ) ), 404 );
		}

		wp_send_json_success( array( 'message' => __( 'Dispositivo rimosso.', 'spidy-2fa-login' ) ) );
	}

	/**
	 * Renderizza la sezione "Sicurezza Biometrica Web, Data & Service"
	 * nella pagina profilo (show_user_profile / edit_user_profile).
	 * Visibile solo quando l'utente sta modificando il PROPRIO profilo.
	 */
	public static function render_profile_section( WP_User $user ) {
		$is_own_profile = ( $user->ID === get_current_user_id() );

		$vendor_ok = file_exists( SPIDY_WP_PLUGIN_DIR . 'vendor/autoload.php' );
		$https_ok  = SPIDy_WP_Requirements::is_site_secure();

		SPIDy_WP_Login::debug_log(
			"render_profile_section utente #{$user->ID} (profilo proprio: " . ( $is_own_profile ? 'SI' : 'NO, visto da admin' ) . ') — vendor/autoload.php presente: ' . ( $vendor_ok ? 'SI' : 'NO — QUESTO E\' IL PROBLEMA' )
			. ' | sito rilevato come HTTPS: ' . ( $https_ok ? 'SI' : 'NO — QUESTO E\' IL PROBLEMA' )
		);

		if ( ! self::requirements_met() ) {
			echo '<h2>' . esc_html__( 'Sicurezza Biometrica Web, Data & Service', 'spidy-2fa-login' ) . '</h2>';
			echo '<p style="color:#b32d2e;font-weight:bold;background:#fff3f3;border-left:4px solid #b32d2e;padding:10px;">' . esc_html__( 'La libreria WebAuthn non è ancora installata (cartella vendor/ mancante) oppure il sito non è in HTTPS. Contatta l\'amministratore.', 'spidy-2fa-login' ) . '</p>';
			return;
		}

		$credentials = SPIDy_WP_Credential_Store::get_credentials( $user->ID );

		if ( ! $is_own_profile ) {
			// Un admin non può registrare/rimuovere un dispositivo per
			// conto di un altro utente (serve l'impronta/Face ID di quella
			// persona), ma deve poter vedere lo stato: altrimenti la
			// sezione sparisce del tutto e sembra che il plugin non sia
			// nemmeno installato per quell'utente.
			include SPIDY_WP_PLUGIN_DIR . 'templates/profile-section-readonly.php';
			return;
		}

		include SPIDY_WP_PLUGIN_DIR . 'templates/profile-section.php';
	}

	/**
	 * Verifica rapida: libreria disponibile e sito in HTTPS.
	 * (Il blocco/attivazione a livello plugin è già gestito da
	 * SPIDy_WP_Requirements, questo è un controllo di comodo per non
	 * mostrare la sezione se qualcosa non torna.)
	 */
	private static function requirements_met(): bool {
		return file_exists( SPIDY_WP_PLUGIN_DIR . 'vendor/autoload.php' ) && SPIDy_WP_Requirements::is_site_secure();
	}

	/**
	 * RP ID per WebAuthn: il dominio del sito, senza schema né porta.
	 * Deve corrispondere esattamente al dominio da cui viene servito il
	 * login, altrimenti il browser rifiuta la richiesta.
	 */
	public static function get_rp_id(): string {
		return (string) wp_parse_url( home_url(), PHP_URL_HOST );
	}

	/**
	 * Nome leggibile mostrato dal browser nel pop-up biometrico.
	 */
	public static function get_rp_name(): string {
		return get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'Web, Data & Service';
	}

	/**
	 * Genera una challenge crittograficamente sicura (32 byte random).
	 */
	private static function generate_challenge(): string {
		return random_bytes( 32 );
	}

	/**
	 * Codifica base64url (RFC 4648 §5), lo standard richiesto da
	 * WebAuthn per challenge/id (senza padding, con - e _ al posto di + e /).
	 */
	public static function base64url_encode( string $data ): string {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}

	/**
	 * Decodifica base64url verso stringa binaria.
	 */
	public static function base64url_decode( string $data ): string {
		$padded = str_pad( strtr( $data, '-_', '+/' ), strlen( $data ) % 4 === 0 ? strlen( $data ) : strlen( $data ) + ( 4 - strlen( $data ) % 4 ), '=', STR_PAD_RIGHT );
		return base64_decode( $padded );
	}
}
