<?php
/**
 * WebAuthn / Passkey handling with cryptographic verification.
 *
 * Registration and authentication assertions are verified server-side using
 * the self-contained ZACLS_WebAuthn_Verifier. Challenges are single-use,
 * stored in the database, and never reused.
 *
 * @package ZA_Creative_Login_Shield
 */

defined( 'ABSPATH' ) || exit;

/**
 * Handles WebAuthn registration and authentication ceremonies.
 */
class ZACLS_WebAuthn {

	/**
	 * Challenge lifetime in seconds.
	 */
	const CHALLENGE_TTL = 300;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->register_hooks();
	}

	/**
	 * Register WordPress hooks.
	 */
	private function register_hooks() {
		add_action( 'wp_ajax_zacls_webauthn_create', array( $this, 'ajax_create_credential' ) );
		add_action( 'wp_ajax_zacls_webauthn_register', array( $this, 'ajax_register' ) );
		add_action( 'wp_ajax_zacls_webauthn_get', array( $this, 'ajax_get' ) );
		add_action( 'wp_ajax_nopriv_zacls_webauthn_get', array( $this, 'ajax_get' ) );
		add_action( 'wp_ajax_zacls_webauthn_verify', array( $this, 'ajax_verify' ) );
		add_action( 'wp_ajax_nopriv_zacls_webauthn_verify', array( $this, 'ajax_verify' ) );
		add_action( 'login_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		add_action( 'zacls_login_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		add_action( 'login_form', array( $this, 'render_login_button' ) );
		add_action( 'zacls_login_form_extra', array( $this, 'render_login_button' ) );
		add_action( 'show_user_profile', array( $this, 'user_profile_fields' ) );
		add_action( 'edit_user_profile', array( $this, 'user_profile_fields' ) );
	}

	/**
	 * Whether WebAuthn is enabled.
	 *
	 * @return bool
	 */
	private function is_enabled() {
		return 'yes' === get_option( 'zacls_webauthn_enabled', 'no' );
	}

	/**
	 * Relying Party ID (host of the site).
	 *
	 * @return string
	 */
	private function rp_id() {
		$host = wp_parse_url( home_url(), PHP_URL_HOST );
		return is_string( $host ) ? $host : '';
	}

	/**
	 * Expected WebAuthn origin (scheme://host[:port]).
	 *
	 * @return string
	 */
	private function origin() {
		$scheme = is_ssl() ? 'https' : 'http';
		$host   = $this->rp_id();
		$port   = wp_parse_url( home_url(), PHP_URL_PORT );

		$origin = $scheme . '://' . $host;
		if ( $port ) {
			$origin .= ':' . $port;
		}
		return $origin;
	}

	/**
	 * Create and persist a single-use challenge.
	 *
	 * @param int    $user_id User ID (0 for anonymous authentication).
	 * @param string $type    Challenge type: register|authenticate.
	 * @return string Base64url challenge.
	 */
	private function create_challenge( $user_id, $type ) {
		global $wpdb;

		$challenge = ZACLS_WebAuthn_Verifier::base64url_encode( random_bytes( 32 ) );

		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'zacls_webauthn_challenges',
			array(
				'user_id'    => (int) $user_id,
				'challenge'  => $challenge,
				'rp_id'      => $this->rp_id(),
				'type'       => $type,
				'expires_at' => gmdate( 'Y-m-d H:i:s', time() + self::CHALLENGE_TTL ),
				'used'       => 0,
			),
			array( '%d', '%s', '%s', '%s', '%s', '%d' )
		);

		return $challenge;
	}

	/**
	 * Fetch an unused, unexpired challenge and consume it atomically enough
	 * for our purposes (single-use enforcement).
	 *
	 * @param string $challenge  Base64url challenge from client data.
	 * @param string $type       Challenge type.
	 * @param int    $user_id    Expected user ID (or 0 to ignore).
	 * @return object|null Challenge row or null.
	 */
	private function get_valid_challenge( $challenge, $type, $user_id = 0 ) {
		global $wpdb;

		if ( ! is_string( $challenge ) || '' === $challenge ) {
			return null;
		}

		if ( $user_id > 0 ) {
			$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}zacls_webauthn_challenges
					WHERE challenge = %s AND type = %s AND used = 0 AND expires_at > %s AND user_id = %d
					ORDER BY id DESC LIMIT 1",
					$challenge,
					$type,
					current_time( 'mysql' ),
					$user_id
				)
			);
		} else {
			$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}zacls_webauthn_challenges
					WHERE challenge = %s AND type = %s AND used = 0 AND expires_at > %s
					ORDER BY id DESC LIMIT 1",
					$challenge,
					$type,
					current_time( 'mysql' )
				)
			);
		}

		return $row;
	}

	/**
	 * Mark a challenge as used and purge other used/expired challenges.
	 *
	 * @param string $challenge Base64url challenge.
	 * @param string $type      Challenge type.
	 */
	private function consume_challenge( $challenge, $type ) {
		global $wpdb;

		$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'zacls_webauthn_challenges',
			array( 'used' => 1 ),
			array( 'challenge' => $challenge ),
			array( '%d' ),
			array( '%s' )
		);

		$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"DELETE FROM {$wpdb->prefix}zacls_webauthn_challenges
				WHERE type = %s AND ( used = 1 OR expires_at < %s )",
				$type,
				current_time( 'mysql' )
			)
		);
	}

	/**
	 * Enqueue assets for the login page and profile screen.
	 */
	public function enqueue_assets() {
		wp_enqueue_script(
			'zacls-webauthn',
			ZACLS_PLUGIN_URL . 'assets/js/webauthn.js',
			array(),
			ZACLS_VERSION,
			true
		);

		$redirect_to = admin_url();
		if ( isset( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
			$candidate   = esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
			$redirect_to = wp_validate_redirect( $candidate, $redirect_to );
		}

		wp_localize_script(
			'zacls-webauthn',
			'zaclsWebAuthn',
			array(
				'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
				'nonce'      => wp_create_nonce( 'zacls_webauthn_nonce' ),
				'enabled'    => $this->is_enabled(),
				'redirectTo' => $redirect_to,
				'isLoggedIn' => is_user_logged_in(),
				'i18n'       => array(
					'notSupported' => esc_html__( 'WebAuthn is not supported by your browser.', 'za-creative-login-shield' ),
					'error'        => esc_html__( 'An error occurred. Please try again.', 'za-creative-login-shield' ),
					'registered'   => esc_html__( 'Passkey registered successfully!', 'za-creative-login-shield' ),
					'failed'       => esc_html__( 'Passkey sign-in failed. Please try again.', 'za-creative-login-shield' ),
					'canceled'     => esc_html__( 'Passkey sign-in canceled.', 'za-creative-login-shield' ),
				),
			)
		);
	}

	/**
	 * Render the passkey login button on the login form.
	 */
	public function render_login_button() {
		if ( ! $this->is_enabled() ) {
			return;
		}
		?>
		<div id="zacls-passkey-login" style="margin-top:14px;text-align:center;">
			<button type="button" id="zacls_passkey_login_button" class="button button-secondary button-large">
				<?php esc_html_e( 'Sign in with a Passkey', 'za-creative-login-shield' ); ?>
			</button>
			<div id="zacls_passkey_message" style="display:none;margin-top:8px;"></div>
		</div>
		<?php
	}

	/**
	 * Render passkey management fields on the user profile.
	 *
	 * @param WP_User $user User object.
	 */
	public function user_profile_fields( $user ) {
		if ( ! $this->is_enabled() ) {
			return;
		}
		if ( ! current_user_can( 'edit_user', $user->ID ) ) {
			return;
		}
		?>
		<h3><?php esc_html_e( 'Passkeys / WebAuthn', 'za-creative-login-shield' ); ?></h3>
		<table class="form-table">
			<tr>
				<th><?php esc_html_e( 'Registered Passkeys', 'za-creative-login-shield' ); ?></th>
				<td>
					<div id="zacls-passkeys-list">
						<?php $this->render_user_credentials( $user->ID ); ?>
					</div>
					<button type="button" class="button" id="zacls-register-passkey">
						<?php esc_html_e( 'Register New Passkey', 'za-creative-login-shield' ); ?>
					</button>
					<p class="description">
						<?php esc_html_e( 'Use Windows Hello, Face ID, Touch ID, or a security key for passwordless login.', 'za-creative-login-shield' ); ?>
					</p>
				</td>
			</tr>
		</table>
		<?php
	}

	/**
	 * Render the list of a user's registered credentials.
	 *
	 * @param int $user_id User ID.
	 */
	private function render_user_credentials( $user_id ) {
		global $wpdb;

		$credentials = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT name, credential_type, created_at, last_used FROM {$wpdb->prefix}zacls_webauthn_credentials WHERE user_id = %d ORDER BY created_at DESC",
				$user_id
			),
			ARRAY_A
		);

		if ( empty( $credentials ) ) {
			echo '<p class="description">' . esc_html__( 'No passkeys registered yet.', 'za-creative-login-shield' ) . '</p>';
			return;
		}

		echo '<table class="widefat" style="margin-bottom:12px">';
		echo '<thead><tr><th>' . esc_html__( 'Name', 'za-creative-login-shield' ) . '</th><th>' . esc_html__( 'Type', 'za-creative-login-shield' ) . '</th><th>' . esc_html__( 'Registered', 'za-creative-login-shield' ) . '</th><th>' . esc_html__( 'Last Used', 'za-creative-login-shield' ) . '</th></tr></thead><tbody>';
		foreach ( $credentials as $cred ) {
			echo '<tr>';
			echo '<td>' . esc_html( $cred['name'] ) . '</td>';
			echo '<td>' . esc_html( $cred['credential_type'] ) . '</td>';
			echo '<td>' . esc_html( $cred['created_at'] ) . '</td>';
			echo '<td>' . esc_html( $cred['last_used'] ?: '—' ) . '</td>';
			echo '</tr>';
		}
		echo '</tbody></table>';
	}

	/**
	 * AJAX: Begin registration (logged-in users only).
	 */
	public function ajax_create_credential() {
		check_ajax_referer( 'zacls_webauthn_nonce', 'nonce' );

		if ( ! $this->is_enabled() ) {
			wp_send_json_error( array( 'message' => esc_html__( 'WebAuthn is disabled.', 'za-creative-login-shield' ) ) );
		}

		if ( ! is_user_logged_in() ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Not logged in.', 'za-creative-login-shield' ) ) );
		}

		$user_id = get_current_user_id();
		$user    = get_userdata( $user_id );
		if ( ! $user ) {
			wp_send_json_error( array( 'message' => esc_html__( 'User not found.', 'za-creative-login-shield' ) ) );
		}

		$challenge = $this->create_challenge( $user_id, 'register' );

		global $wpdb;
		$existing = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT credential_id FROM {$wpdb->prefix}zacls_webauthn_credentials WHERE user_id = %d",
				$user_id
			)
		);

		$exclude = array();
		foreach ( (array) $existing as $credential_id ) {
			if ( is_string( $credential_id ) && '' !== $credential_id ) {
				$exclude[] = array(
					'type' => 'public-key',
					'id'   => $credential_id,
				);
			}
		}

		$options = array(
			'challenge'   => $challenge,
			'rp'          => array(
				'name' => get_bloginfo( 'name' ),
				'id'   => $this->rp_id(),
			),
			'user'        => array(
				'id'          => ZACLS_WebAuthn_Verifier::base64url_encode( (string) $user_id ),
				'name'        => $user->user_email,
				'displayName' => $user->display_name,
			),
			'pubKeyCredParams' => array(
				array( 'type' => 'public-key', 'alg' => -7 ),
				array( 'type' => 'public-key', 'alg' => -257 ),
			),
			'timeout'    => 60000,
			'attestation' => 'none',
			'authenticatorSelection' => array(
				'userVerification' => 'preferred',
			),
		);

		if ( ! empty( $exclude ) ) {
			$options['excludeCredentials'] = $exclude;
		}

		wp_send_json_success( $options );
	}

	/**
	 * AJAX: Complete registration (logged-in users only).
	 */
	public function ajax_register() {
		check_ajax_referer( 'zacls_webauthn_nonce', 'nonce' );

		if ( ! $this->is_enabled() ) {
			wp_send_json_error( array( 'message' => esc_html__( 'WebAuthn is disabled.', 'za-creative-login-shield' ) ) );
		}

		if ( ! is_user_logged_in() ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Not logged in.', 'za-creative-login-shield' ) ) );
		}

		$user_id = get_current_user_id();

		$credential_json = isset( $_POST['credential'] ) ? wp_unslash( $_POST['credential'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$credential      = json_decode( $credential_json, true );

		if ( ! is_array( $credential ) || empty( $credential['response'] ) ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid credential data.', 'za-creative-login-shield' ) ) );
		}

		$client_data_json   = isset( $credential['response']['clientDataJSON'] ) ? $credential['response']['clientDataJSON'] : '';
		$attestation_object = isset( $credential['response']['attestationObject'] ) ? $credential['response']['attestationObject'] : '';

		$client_data_raw   = ZACLS_WebAuthn_Verifier::base64url_decode( $client_data_json );
		$attestation_raw   = ZACLS_WebAuthn_Verifier::base64url_decode( $attestation_object );

		if ( false === $client_data_raw || false === $attestation_raw ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid credential encoding.', 'za-creative-login-shield' ) ) );
		}

		$client_data = ZACLS_WebAuthn_Verifier::parse_client_data( $client_data_raw );
		if ( null === $client_data || empty( $client_data['challenge'] ) ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid client data.', 'za-creative-login-shield' ) ) );
		}

		$challenge_row = $this->get_valid_challenge( $client_data['challenge'], 'register', $user_id );
		if ( ! $challenge_row ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Expired or invalid challenge. Please try again.', 'za-creative-login-shield' ) ) );
		}

		$auth_data = ZACLS_WebAuthn_Verifier::verify_registration( $client_data_raw, $attestation_raw, $challenge_row->challenge, $this->rp_id(), $this->origin() );
		if ( is_wp_error( $auth_data ) ) {
			$this->consume_challenge( $challenge_row->challenge, 'register' );
			ZACLS_Audit_Trail::log( 'webauthn_register_failed', sprintf( esc_html__( 'Passkey registration rejected for user ID %d: %s', 'za-creative-login-shield' ), $user_id, $auth_data->get_error_code() ) );
			wp_send_json_error( array( 'message' => $auth_data->get_error_message() ) );
		}

		$raw_credential_id = $auth_data['credential_id'];
		$credential_id     = ZACLS_WebAuthn_Verifier::base64url_encode( $raw_credential_id );

		if ( ! empty( $credential['id'] ) ) {
			$provided_raw = ZACLS_WebAuthn_Verifier::base64url_decode( $credential['id'] );
			if ( false === $provided_raw || ! hash_equals( $raw_credential_id, $provided_raw ) ) {
				$this->consume_challenge( $challenge_row->challenge, 'register' );
				wp_send_json_error( array( 'message' => esc_html__( 'Credential ID mismatch.', 'za-creative-login-shield' ) ) );
			}
		}

		$public_key_pem = ZACLS_WebAuthn_Verifier::cose_key_to_pem( $auth_data['cose_key'] );
		if ( null === $public_key_pem ) {
			$this->consume_challenge( $challenge_row->challenge, 'register' );
			wp_send_json_error( array( 'message' => esc_html__( 'Unsupported passkey type. Only P-256 and RSA keys are supported.', 'za-creative-login-shield' ) ) );
		}

		global $wpdb;
		$existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}zacls_webauthn_credentials WHERE credential_id = %s",
				$credential_id
			)
		);

		if ( $existing ) {
			$this->consume_challenge( $challenge_row->challenge, 'register' );
			wp_send_json_error( array( 'message' => esc_html__( 'This passkey is already registered.', 'za-creative-login-shield' ) ) );
		}

		$transports = array();
		if ( isset( $credential['transports'] ) && is_array( $credential['transports'] ) ) {
			foreach ( $credential['transports'] as $transport ) {
				if ( is_string( $transport ) && '' !== $transport ) {
					$transports[] = substr( sanitize_text_field( $transport ), 0, 30 );
				}
			}
		}

		$name = '';
		if ( isset( $_POST['name'] ) ) {
			$name = sanitize_text_field( wp_unslash( $_POST['name'] ) );
		}
		if ( '' === $name ) {
			$name = sprintf(
				/* translators: %s: date */
				esc_html__( 'Passkey %s', 'za-creative-login-shield' ),
				gmdate( 'Y-m-d H:i' )
			);
		}

		$inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'zacls_webauthn_credentials',
			array(
				'user_id'         => $user_id,
				'credential_id'   => $credential_id,
				'public_key'      => $public_key_pem,
				'credential_type' => 'public-key',
				'name'            => $name,
				'sign_count'      => (int) $auth_data['sign_count'],
				'aaguid'          => bin2hex( $auth_data['aaguid'] ),
				'transports'      => implode( ',', $transports ),
				'created_at'      => current_time( 'mysql' ),
			),
			array( '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s' )
		);

		if ( false === $inserted ) {
			$this->consume_challenge( $challenge_row->challenge, 'register' );
			wp_send_json_error( array( 'message' => esc_html__( 'Could not save the passkey. Please try again.', 'za-creative-login-shield' ) ) );
		}

		$this->consume_challenge( $challenge_row->challenge, 'register' );

		ZACLS_Audit_Trail::log( 'webauthn_registered', sprintf( esc_html__( 'Passkey registered for user ID %d', 'za-creative-login-shield' ), $user_id ) );

		wp_send_json_success( array( 'message' => esc_html__( 'Passkey registered successfully!', 'za-creative-login-shield' ) ) );
	}

	/**
	 * AJAX: Fetch authentication options (used for login).
	 */
	public function ajax_get() {
		check_ajax_referer( 'zacls_webauthn_nonce', 'nonce' );

		if ( ! $this->is_enabled() ) {
			wp_send_json_error( array( 'message' => esc_html__( 'WebAuthn is disabled.', 'za-creative-login-shield' ) ) );
		}

		$challenge = $this->create_challenge( 0, 'authenticate' );

		$options = array(
			'challenge'        => $challenge,
			'rpId'             => $this->rp_id(),
			'timeout'          => 60000,
			'userVerification' => 'preferred',
			'allowCredentials' => array(),
		);

		wp_send_json_success( $options );
	}

	/**
	 * AJAX: Verify an authentication assertion and log the user in.
	 */
	public function ajax_verify() {
		check_ajax_referer( 'zacls_webauthn_nonce', 'nonce' );

		if ( ! $this->is_enabled() ) {
			wp_send_json_error( array( 'message' => esc_html__( 'WebAuthn is disabled.', 'za-creative-login-shield' ) ) );
		}

		$credential_json = isset( $_POST['credential'] ) ? wp_unslash( $_POST['credential'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$credential      = json_decode( $credential_json, true );

		if ( ! is_array( $credential ) || empty( $credential['response'] ) ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid credential data.', 'za-creative-login-shield' ) ) );
		}

		$client_data_json   = isset( $credential['response']['clientDataJSON'] ) ? $credential['response']['clientDataJSON'] : '';
		$authenticator_data = isset( $credential['response']['authenticatorData'] ) ? $credential['response']['authenticatorData'] : '';
		$signature          = isset( $credential['response']['signature'] ) ? $credential['response']['signature'] : '';
		$user_handle        = isset( $credential['response']['userHandle'] ) ? $credential['response']['userHandle'] : null;

		$client_data_raw    = ZACLS_WebAuthn_Verifier::base64url_decode( $client_data_json );
		$authenticator_raw  = ZACLS_WebAuthn_Verifier::base64url_decode( $authenticator_data );
		$signature_raw      = ZACLS_WebAuthn_Verifier::base64url_decode( $signature );

		if ( false === $client_data_raw || false === $authenticator_raw || false === $signature_raw ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid credential encoding.', 'za-creative-login-shield' ) ) );
		}

		$client_data = ZACLS_WebAuthn_Verifier::parse_client_data( $client_data_raw );
		if ( null === $client_data || empty( $client_data['challenge'] ) ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid client data.', 'za-creative-login-shield' ) ) );
		}

		$challenge_row = $this->get_valid_challenge( $client_data['challenge'], 'authenticate' );
		if ( ! $challenge_row ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Expired or invalid challenge. Please try again.', 'za-creative-login-shield' ) ) );
		}

		$credential_id = isset( $credential['id'] ) ? sanitize_text_field( $credential['id'] ) : '';
		if ( '' === $credential_id ) {
			$this->consume_challenge( $challenge_row->challenge, 'authenticate' );
			wp_send_json_error( array( 'message' => esc_html__( 'Credential not found.', 'za-creative-login-shield' ) ) );
		}

		global $wpdb;
		$credential_row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}zacls_webauthn_credentials WHERE credential_id = %s",
				$credential_id
			)
		);

		if ( ! $credential_row ) {
			$this->consume_challenge( $challenge_row->challenge, 'authenticate' );
			wp_send_json_error( array( 'message' => esc_html__( 'Credential not found.', 'za-creative-login-shield' ) ) );
		}

		$user = get_userdata( (int) $credential_row->user_id );
		if ( ! $user ) {
			$this->consume_challenge( $challenge_row->challenge, 'authenticate' );
			wp_send_json_error( array( 'message' => esc_html__( 'User not found.', 'za-creative-login-shield' ) ) );
		}

		if ( null !== $user_handle && '' !== $user_handle ) {
			$expected_handle = ZACLS_WebAuthn_Verifier::base64url_encode( (string) $user->ID );
			if ( ! hash_equals( $expected_handle, $user_handle ) ) {
				$this->consume_challenge( $challenge_row->challenge, 'authenticate' );
				wp_send_json_error( array( 'message' => esc_html__( 'Passkey does not belong to this account.', 'za-creative-login-shield' ) ) );
			}
		}

		$public_key_pem = $credential_row->public_key;
		if ( false === strpos( (string) $public_key_pem, 'BEGIN PUBLIC KEY' ) ) {
			$this->consume_challenge( $challenge_row->challenge, 'authenticate' );
			wp_send_json_error( array( 'message' => esc_html__( 'This passkey is outdated. Please register it again.', 'za-creative-login-shield' ) ) );
		}

		$stored_sign_count = (int) $credential_row->sign_count;

		$result = ZACLS_WebAuthn_Verifier::verify_assertion(
			$client_data_raw,
			$authenticator_raw,
			$signature_raw,
			$challenge_row->challenge,
			$this->rp_id(),
			$this->origin(),
			$public_key_pem,
			$stored_sign_count
		);

		if ( is_wp_error( $result ) ) {
			$this->consume_challenge( $challenge_row->challenge, 'authenticate' );
			ZACLS_Audit_Trail::log( 'webauthn_login_failed', sprintf( esc_html__( 'Passkey login rejected for user ID %d: %s', 'za-creative-login-shield' ), $user->ID, $result->get_error_code() ) );
			wp_send_json_error( array( 'message' => $result->get_error_message() ) );
		}

		$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'zacls_webauthn_credentials',
			array(
				'sign_count' => (int) $result['sign_count'],
				'last_used'  => current_time( 'mysql' ),
			),
			array( 'id' => $credential_row->id ),
			array( '%d', '%s' ),
			array( '%d' )
		);

		$this->consume_challenge( $challenge_row->challenge, 'authenticate' );

		$redirect_to = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification
		$redirect_to = wp_validate_redirect( $redirect_to, admin_url() );

		$bypass_twofa = 'yes' === get_option( 'zacls_webauthn_bypasses_2fa', 'no' );

		if ( ! $bypass_twofa && ZACLS_TwoFA::is_2fa_enabled_for_user( $user ) ) {
			$token = ZACLS_TwoFA::start_2fa_flow( $user, $redirect_to );
			if ( $token ) {
				wp_send_json_success(
					array(
						'redirect' => add_query_arg(
							array(
								'zacls_2fa' => 'required',
								'token'     => rawurlencode( $token ),
							),
							wp_login_url()
						),
						'message'  => esc_html__( 'Passkey verified. Check your email for a verification code.', 'za-creative-login-shield' ),
					)
				);
			}
		}

		$this->complete_login( $user );

		ZACLS_Audit_Trail::log( 'webauthn_login', sprintf( esc_html__( 'User ID %d signed in with a passkey', 'za-creative-login-shield' ), $user->ID ) );

		wp_send_json_success(
			array(
				'redirect' => $redirect_to,
				'message'  => esc_html__( 'Passkey verified!', 'za-creative-login-shield' ),
			)
		);
	}

	/**
	 * Complete an authenticated WebAuthn login: set cookies, create a session,
	 * and fire the successful-login hook.
	 *
	 * @param WP_User $user User object.
	 */
	private function complete_login( $user ) {
		wp_set_auth_cookie( $user->ID, false );
		ZACLS_Session::create_for_user( $user );
		do_action( 'zacls_successful_login', $user );
	}
}