<?php
/**
 * Login intercept + campo telefono in registrazione (versione semplificata,
 * verifica via codice email invece di WebAuthn biometrico).
 *
 * @package SPIDy_WP
 */

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

class SPIDy_WP_Login {

	/**
	 * Prefisso del transient che collega un token di sessione "in attesa
	 * di 2FA" al relativo user_id, generato subito dopo che password è
	 * risultata corretta.
	 */
	const PENDING_TRANSIENT_PREFIX = 'spidy_wp_pending_';
	const PENDING_TTL              = 10 * MINUTE_IN_SECONDS;

	public static function init() {
		// Campo telefono nel modulo di registrazione standard di WordPress.
		add_action( 'register_form', array( __CLASS__, 'render_phone_field_registration' ) );
		add_filter( 'registration_errors', array( __CLASS__, 'validate_phone_field_registration' ), 10, 3 );
		add_action( 'user_register', array( __CLASS__, 'save_phone_field_registration' ) );

		// Intercettazione login (Fase 3 dello spec originale, versione email).
		//
		// v1.6.12 SECURITY FIX: agganciata prima ad 'wp_authenticate_user',
		// filtro nativo di WordPress che scatta PRIMA che la password sia
		// verificata (wp_check_password() viene chiamato SOLO DOPO questo
		// filtro, dentro wp_authenticate_username_password()). Con
		// l'exit() presente in redirect_to_action() qui sotto, questo
		// significava che bastava uno username valido — password
		// qualunque, anche sbagliata — per far scattare il secondo
		// fattore (invio di un vero codice OTP all'utente reale via
		// email/SMS, con relativo costo per SMS, o redirect alla pagina
		// biometrica), senza che WordPress avesse mai controllato la
		// password in quella richiesta. Non un bypass totale — serve
		// comunque il codice OTP reale o il dispositivo biometrico
		// registrato per completare il login — ma comunque enumerazione
		// di username validi + possibile abuso per far spedire OTP/SMS a
		// piacere (costo diretto se è configurato un gateway SMS a
		// pagamento). Corretto agganciandosi ad 'authenticate' con
		// priorità 30 (i filtri nativi di WordPress girano a priorità
		// 20): a questa priorità $user è un WP_User SOLO se username E
		// password sono già stati entrambi verificati con successo da
		// WordPress. MAI tornare ad agganciarsi a 'wp_authenticate_user'
		// o a una priorità <= 20 su 'authenticate'.
		add_filter( 'authenticate', array( __CLASS__, 'maybe_intercept_login' ), 30, 1 );

		// Pagina intermedia su wp-login.php?action=spidy_wp_verify
		add_action( 'login_form_spidy_wp_verify', array( __CLASS__, 'handle_verify_page' ) );

		// Permette a wp-login.php di riconoscere la nostra action custom.
		add_filter( 'login_body_class', array( __CLASS__, 'maybe_add_body_class' ) );
	}

	/* ---------------------------------------------------------------
	 * Registrazione nuovo utente: campo telefono
	 * ------------------------------------------------------------- */

	public static function render_phone_field_registration() {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- rilettura del valore per ripopolare il campo dopo un errore di validazione; agganciato al form nativo "register_form" di WordPress, che non usa un nonce proprio.
		$phone = isset( $_POST['spidy_wp_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['spidy_wp_phone'] ) ) : '';
		?>
		<p>
			<label for="spidy_wp_phone"><?php esc_html_e( 'Numero di telefono (per il 2FA)', 'spidy-2fa-login' ); ?><br />
				<input type="tel" name="spidy_wp_phone" id="spidy_wp_phone" class="input" value="<?php echo esc_attr( $phone ); ?>" size="25" autocomplete="tel" />
			</label>
		</p>
		<?php
	}

	/**
	 * Validazione minima: formato plausibile, campo facoltativo.
	 * Non blocca la registrazione se lasciato vuoto — l'attivazione
	 * effettiva del 2FA resta una scelta esplicita dall'Admin del plugin.
	 */
	public static function validate_phone_field_registration( WP_Error $errors, string $login, string $email ): WP_Error {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- agganciato al filtro nativo "registration_errors" di WordPress: la sicurezza dell'invio è già gestita dal form di registrazione core, che non usa un nonce proprio.
		if ( empty( $_POST['spidy_wp_phone'] ) ) {
			return $errors;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
		$phone = sanitize_text_field( wp_unslash( $_POST['spidy_wp_phone'] ) );

		if ( ! preg_match( '/^[0-9+ ]{6,20}$/', $phone ) ) {
			$errors->add( 'spidy_wp_phone_invalid', __( '<strong>Errore</strong>: il numero di telefono inserito non è valido.', 'spidy-2fa-login' ) );
		}

		return $errors;
	}

	public static function save_phone_field_registration( int $user_id ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- agganciato all'azione nativa "user_register" di WordPress, eseguita solo dopo che il core ha già completato la registrazione con le sue verifiche.
		if ( ! empty( $_POST['spidy_wp_phone'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Missing -- stesso motivo qui sopra.
			SPIDy_WP_User_Store::set_phone( $user_id, sanitize_text_field( wp_unslash( $_POST['spidy_wp_phone'] ) ) );
		}
	}

	/* ---------------------------------------------------------------
	 * Intercettazione login
	 * ------------------------------------------------------------- */

	/**
	 * Agganciato ad 'authenticate' con priorità 30 (v1.6.12 — vedi la nota
	 * di sicurezza su init()): se l'utente ha il 2FA attivo, genera e
	 * invia il codice, poi interrompe il flusso standard con un redirect
	 * alla pagina di verifica PRIMA che venga creato il cookie di
	 * sessione.
	 *
	 * @param WP_User|WP_Error|null $user Utente già autenticato con successo (o errore).
	 * @return WP_User|WP_Error|null
	 */
	/**
	 * Log interno del plugin, sempre nello stesso posto indipendentemente
	 * da come è configurato il logging PHP del server (utile quando non
	 * si ha accesso SSH o il sito è uno delle tante installazioni sotto lo
	 * stesso account hosting). File: spidy-2fa-login/debug.log
	 */
	public static function debug_log( string $message ) {
		$file = SPIDY_WP_PLUGIN_DIR . 'debug.log';
		$line = '[' . current_time( 'mysql' ) . '] ' . $message . "\n";
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- non è codice di debug dimenticato: è il canale di log dedicato del plugin (message type 3, file proprio), usato deliberatamente per diagnosi su hosting condivisi senza accesso SSH.
		error_log( $line, 3, $file );
	}

	/**
	 * Storage chiave/valore con scadenza, scritto e letto DIRETTAMENTE
	 * dalla tabella wp_options, bypassando le Transients API e quindi
	 * qualunque cache oggetti esterna (Redis/Memcached/APCu). Su questo
	 * hosting le transient standard si sono dimostrate inaffidabili
	 * proprio nei casi in cui il valore deve sopravvivere per una decina
	 * di secondi/minuti (es. mentre l'utente completa l'impronta sul
	 * telefono) — con l'immediato redirect di email/SMS invece funzionano,
	 * probabile eviction della cache sotto carico/tempo. Usato per tutto
	 * ciò che deve reggere più di qualche istante.
	 */
	public static function store_value_direct( string $key, $value, int $ttl_seconds ) {
		global $wpdb;
		// Le chiavi vengono ridotte a un hash di lunghezza fissa (32 caratteri
		// esadecimali) prima di scrivere su option_name: la colonna è
		// VARCHAR(64) sugli schemi WordPress più datati, e con prefissi
		// più lunghi (necessari dopo il rename del plugin) una chiave
		// concatenata poteva superare quel limite, venendo troncata in
		// silenzio in scrittura — la successiva lettura, cercando la
		// stringa intera, non trovava più nulla.
		$option_name = '_spidy_wp_kv_' . md5( $key );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- scrittura diretta intenzionale, non tramite Transients API: vedi commento sopra la funzione per il motivo (chiavi lunghe, cache oggetti inaffidabile su questo hosting).
		$result = $wpdb->query(
			$wpdb->prepare(
				"REPLACE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
				$option_name,
				maybe_serialize(
					array(
						'value'   => $value,
						'expires' => time() + $ttl_seconds,
					)
				)
			)
		);
		$write_error = $wpdb->last_error; // catturato SUBITO, prima di qualunque altra query

		// Rilettura immediata per confermare che il dato sia davvero
		// visibile subito dopo la scrittura (su alcuni hosting con
		// replica/lettura separata potrebbe non esserlo).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- stessa scrittura diretta, rilettura di verifica immediata.
		$verify = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $option_name ) );

		self::debug_log(
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- non è debug residuo: il valore va nel canale di log dedicato del plugin (debug_log), non in output diretto.
			"store_value_direct key='{$key}' — query result: " . var_export( $result, true )
			. ' | wpdb error (al momento della scrittura): ' . ( $write_error ? $write_error : '(nessuno)' )
			. ' | rilettura immediata: ' . ( $verify ? 'RIGA TROVATA (' . strlen( $verify ) . ' byte)' : 'RIGA ASSENTE — PROBLEMA CONFERMATO' )
		);
	}

	/**
	 * @return mixed|false Il valore salvato, o false se assente/scaduto.
	 */
	public static function retrieve_value_direct( string $key ) {
		global $wpdb;
		$option_name = '_spidy_wp_kv_' . md5( $key );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lettura diretta intenzionale, stesso motivo di store_value_direct qui sopra.
		$row = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $option_name ) );

		if ( empty( $row ) ) {
			self::debug_log( "retrieve_value_direct key='{$key}' — NESSUNA RIGA TROVATA nel DB | wpdb error: " . ( $wpdb->last_error ? $wpdb->last_error : '(nessuno)' ) );
			return false;
		}

		$data = maybe_unserialize( $row );

		if ( ! is_array( $data ) || empty( $data['expires'] ) || $data['expires'] < time() ) {
			self::debug_log( "retrieve_value_direct key='{$key}' — riga trovata ma SCADUTA o malformata. expires=" . ( is_array( $data ) ? ( $data['expires'] ?? 'n/d' ) : 'dato non array' ) . ', ora=' . time() );
			return false;
		}

		self::debug_log( "retrieve_value_direct key='{$key}' — OK, valore trovato e valido" );

		return $data['value'];
	}

	public static function delete_value_direct( string $key ) {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cancellazione diretta intenzionale, stesso motivo di store_value_direct qui sopra.
		$wpdb->delete( $wpdb->options, array( 'option_name' => '_spidy_wp_kv_' . md5( $key ) ) );
	}

	public static function maybe_intercept_login( $user ) {
		if ( is_wp_error( $user ) ) {
			return $user;
		}

		// v1.6.12: a questo punto, con l'hook corretto ('authenticate',
		// priorità 30), $user è un WP_User SOLO se WordPress ha già
		// verificato con successo sia lo username che la password. Se
		// fosse ancora possibile arrivare qui con credenziali sbagliate
		// (ad es. tornando per errore ad agganciarsi a
		// 'wp_authenticate_user' o a una priorità <= 20), sarebbe di
		// nuovo il bug di sicurezza corretto in questa versione: il
		// secondo fattore scatterebbe senza che la password sia mai
		// stata controllata.
		if ( ! ( $user instanceof WP_User ) ) {
			return $user;
		}

		// Sblocco d'emergenza (Fase 5): salta il controllo per tutti gli utenti.
		if ( defined( 'SPIDY_WP_BYPASS_BIOMETRIC' ) && SPIDY_WP_BYPASS_BIOMETRIC ) {
			return $user;
		}

		if ( ! SPIDy_WP_User_Store::is_2fa_enabled( $user->ID ) ) {
			return $user;
		}

		$method = SPIDy_WP_User_Store::get_method( $user->ID );
		self::debug_log( "LOGIN utente #{$user->ID} ({$user->user_login}) — metodo letto dal DB: '{$method}'" );

		// --- Metodo biometrico: nessun OTP come canale primario, ma si
		// invia comunque un codice via email in parallelo come rete di
		// sicurezza (es. QR non scansionabile, smartphone non a portata di
		// mano, sensore che non risponde): l'utente non resta mai fuori.
		if ( SPIDy_WP_User_Store::METHOD_BIOMETRIC === $method ) {
			$has_devices = SPIDy_WP_Credential_Store::user_has_credentials( $user->ID );
			self::debug_log( "  ramo BIOMETRICO — dispositivi trovati: " . ( $has_devices ? 'SI' : 'NO' ) );

			if ( $has_devices ) {
				SPIDy_WP_OTP::generate_and_send( $user->ID );

				$token = wp_generate_password( 32, false );
				self::store_value_direct( self::PENDING_TRANSIENT_PREFIX . $token, $user->ID, self::PENDING_TTL );

				self::debug_log( '  -> redirect verso spidy_wp_biometric, token=' . $token );
				self::redirect_to_action( 'spidy_wp_biometric', $token );
			}
			// Nessun dispositivo registrato: ricade sul canale email qui sotto,
			// invece di bloccare fuori l'utente senza alternative.
		}

		// --- Metodo SMS (con fallback automatico su email) ---
		if ( SPIDy_WP_User_Store::METHOD_SMS === $method ) {
			self::debug_log( '  ramo SMS' );
			$phone = SPIDy_WP_User_Store::get_phone( $user->ID );
			$code  = SPIDy_WP_OTP::generate( $user->ID );

			$sms_result = ! empty( $phone ) ? SPIDy_WP_OTP::send_sms( $phone, $code ) : new WP_Error( 'spidy_wp_no_phone', 'no phone' );

			if ( is_wp_error( $sms_result ) ) {
				// Il telefono non risponde (spento/fuori campo) o gateway non
				// configurato: fallback automatico via email, stesso codice.
				SPIDy_WP_SMS::log_error( $phone, $sms_result->get_error_message() );
				SPIDy_WP_OTP::send_email( $user->ID, $code );
			}

			$token = wp_generate_password( 32, false );
			self::store_value_direct( self::PENDING_TRANSIENT_PREFIX . $token, $user->ID, self::PENDING_TTL );

			self::redirect_to_action( 'spidy_wp_verify', $token );
		}

		// --- Metodo email (default) ---
		self::debug_log( '  ramo DEFAULT/EMAIL (nessuno dei rami sopra ha intercettato ed effettuato un redirect)' );
		$sent = SPIDy_WP_OTP::generate_and_send( $user->ID );

		if ( ! $sent ) {
			// Se anche l'email fallisce, non blocchiamo l'utente fuori dal
			// sito senza via d'uscita: meglio far passare il login e
			// segnalarlo, piuttosto che un lockout silenzioso.
			return $user;
		}

		$token = wp_generate_password( 32, false );
		self::store_value_direct( self::PENDING_TRANSIENT_PREFIX . $token, $user->ID, self::PENDING_TTL );

		self::redirect_to_action( 'spidy_wp_verify', $token );
	}

	/**
	 * Redirect comune verso una action di wp-login.php, preservando la
	 * destinazione finale (redirect_to) e terminando l'esecuzione PRIMA
	 * che WordPress crei il cookie di sessione standard.
	 */
	private static function redirect_to_action( string $action, string $token ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- valore letto solo per essere ri-propagato nell'URL di redirect (già sanitizzato con esc_url_raw), non usato per alcuna azione o scrittura.
		$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url();

		wp_safe_redirect(
			add_query_arg(
				array(
					'action'      => $action,
					'spidy_token'   => $token,
					'redirect_to' => rawurlencode( $redirect_to ),
				),
				wp_login_url()
			)
		);
		exit;
	}

	/**
	 * Renderizza e gestisce la pagina intermedia di verifica del codice.
	 * Agganciata su wp-login.php?action=spidy_wp_verify
	 */
	public static function handle_verify_page() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lettura del token dall'URL (GET), usato solo per recuperare la sessione pendente; l'azione vera e propria (invio del codice) è protetta più sotto da check_admin_referer().
		$token = isset( $_REQUEST['spidy_token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['spidy_token'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- stesso motivo qui sopra: valore ri-propagato nell'URL, non usato per un'azione.
		$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url();

		if ( empty( $token ) ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}

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

		if ( false === $user_id ) {
			self::render_message_page(
				__( 'Sessione scaduta', 'spidy-2fa-login' ),
				__( 'Il tempo per inserire il codice è scaduto. Torna al login e riprova.', 'spidy-2fa-login' ),
				true
			);
			exit;
		}

		$error = null;

		if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] && isset( $_POST['spidy_wp_code'] ) ) {
			check_admin_referer( 'spidy_wp_verify_action', 'spidy_wp_verify_nonce' );

			$submitted = sanitize_text_field( wp_unslash( $_POST['spidy_wp_code'] ) );
			$result    = SPIDy_WP_OTP::verify( (int) $user_id, $submitted );

			if ( is_wp_error( $result ) ) {
				$error = $result->get_error_message();
			} else {
				self::delete_value_direct( self::PENDING_TRANSIENT_PREFIX . $token );
				wp_set_auth_cookie( (int) $user_id, true );
				// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- non è un hook custom: è il hook core "wp_login" di WordPress, rilanciato di proposito perché il login qui è completato manualmente (via wp_set_auth_cookie) dopo la verifica 2FA, saltando il flusso standard che lo scatenerebbe da solo.
				do_action( 'wp_login', get_userdata( $user_id )->user_login, get_userdata( $user_id ) );
				wp_safe_redirect( $redirect_to );
				exit;
			}
		}

		self::render_verify_form( $token, $redirect_to, $error );
		exit;
	}

	private static function render_verify_form( string $token, string $redirect_to, ?string $error ) {
		login_header( __( 'Verifica accesso', 'spidy-2fa-login' ), '', $error ? new WP_Error( 'spidy_wp', $error ) : null );
		?>
		<form name="wds2faverifyform" id="wds2faverifyform" action="<?php echo esc_url( add_query_arg( array( 'action' => 'spidy_wp_verify' ), wp_login_url() ) ); ?>" method="post">
			<p>
				<?php esc_html_e( 'Ti abbiamo inviato un codice via email. Inseriscilo qui sotto per completare l\'accesso.', 'spidy-2fa-login' ); ?>
			</p>
			<p>
				<label for="spidy_wp_code"><?php esc_html_e( 'Codice di accesso', 'spidy-2fa-login' ); ?></label>
				<input type="text" name="spidy_wp_code" id="spidy_wp_code" class="input" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autofocus autocomplete="one-time-code" />
			</p>
			<input type="hidden" name="spidy_token" value="<?php echo esc_attr( $token ); ?>" />
			<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect_to ); ?>" />
			<?php wp_nonce_field( 'spidy_wp_verify_action', 'spidy_wp_verify_nonce' ); ?>
			<p class="submit">
				<input type="submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Conferma', 'spidy-2fa-login' ); ?>" />
			</p>
		</form>
		<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
		login_footer( 'spidy_wp_code' );
	}

	private static function render_message_page( string $title, string $message, bool $is_error ) {
		login_header( $title, '', $is_error ? new WP_Error( 'spidy_wp', $message ) : null );
		if ( ! $is_error ) {
			echo '<p>' . esc_html( $message ) . '</p>';
		}
		echo '<p id="backtoblog"><a href="' . esc_url( wp_login_url() ) . '">' . esc_html__( '&larr; Torna al login', 'spidy-2fa-login' ) . '</a></p>';
		login_footer();
	}

	public static function maybe_add_body_class( array $classes ): array {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lettura di sola consultazione per aggiungere una classe CSS al body in base alla pagina corrente; nessuna azione o scrittura basata su questo valore.
		if ( isset( $_GET['action'] ) && 'spidy_wp_verify' === $_GET['action'] ) {
			$classes[] = 'spidy-wp-verify';
		}
		return $classes;
	}
}
