<?php
/**
 * Handles WebAuthn Passkey Authentication for Guard Dog.
 *
 * Provides passwordless authentication via biometrics/hardware keys.
 * WebAuthn is inherently MFA (possession + biometric), so successful
 * passkey authentication bypasses traditional 2FA.
 *
 * @package GuardDog
 * @since 1.9.1
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

use GuardDog\Vendor\Webauthn\PublicKeyCredentialCreationOptions;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialRequestOptions;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialRpEntity;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialUserEntity;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialParameters;
use GuardDog\Vendor\Webauthn\AuthenticatorSelectionCriteria;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialSource;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialSourceRepository;
use GuardDog\Vendor\Webauthn\AuthenticatorAttestationResponse;
use GuardDog\Vendor\Webauthn\AuthenticatorAssertionResponse;
use GuardDog\Vendor\Webauthn\PublicKeyCredential;
use GuardDog\Vendor\Webauthn\AttestationStatement\AttestationStatementSupportManager;
use GuardDog\Vendor\Webauthn\AttestationStatement\AttestationObjectLoader;
use GuardDog\Vendor\Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use GuardDog\Vendor\Webauthn\AuthenticatorAttestationResponseValidator;
use GuardDog\Vendor\Webauthn\AuthenticatorAssertionResponseValidator;
use GuardDog\Vendor\Webauthn\PublicKeyCredentialLoader;
use GuardDog\Vendor\Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use GuardDog\Vendor\Webauthn\Denormalizer\WebauthnSerializerFactory;
use GuardDog\Vendor\Cose\Algorithm\Manager;
use GuardDog\Vendor\Cose\Algorithm\Signature\ECDSA\ES256;
use GuardDog\Vendor\Cose\Algorithm\Signature\RSA\RS256;

/**
 * Guard Dog Passkeys Feature Class
 *
 * Implements WebAuthn for passwordless authentication.
 *
 * @since 1.9.1
 */
class Guard_Dog_Passkeys {

    /**
     * Shortcode tag for frontend passkey management.
     *
     * @var string
     */
    const SHORTCODE = 'guard_dog_passkeys';

    /**
     * Block name for frontend passkey management.
     *
     * @var string
     */
    const BLOCK_NAME = 'guard-dog/passkeys';

    /**
     * Editor script handle for the passkeys block.
     *
     * @var string
     */
    const BLOCK_EDITOR_SCRIPT = 'guard-dog-passkeys-block';

    /**
     * Shortcode tag for frontend passkey login.
     *
     * @var string
     */
    const LOGIN_SHORTCODE = 'guard_dog_passkey_login';

    /**
     * Block name for frontend passkey login.
     *
     * @var string
     */
    const LOGIN_BLOCK_NAME = 'guard-dog/passkey-login';

    /**
     * Editor script handle for the passkey login block.
     *
     * @var string
     */
    const LOGIN_BLOCK_EDITOR_SCRIPT = 'guard-dog-passkey-login-block';

    /**
     * Transient prefix for passkey state data.
     *
     * @var string
     */
    const TRANSIENT_PREFIX = 'guard_dog_pk_';

    /**
     * TTL for passkey challenge transients (5 minutes).
     *
     * @var int
     */
    const CHALLENGE_TTL = 300;

    /**
     * TTL for passkey auth flag transient (60 seconds).
     *
     * @var int
     */
    const AUTH_FLAG_TTL = 60;

    /**
     * Max passkey auth (options + verify) requests per IP per window.
     *
     * @var int
     */
    const PASSKEY_AUTH_RATE_LIMIT = 30;

    /**
     * Passkey auth rate limit window in seconds (15 minutes).
     *
     * @var int
     */
    const PASSKEY_AUTH_RATE_WINDOW = 900;

    /**
     * Activity log instance.
     *
     * @var Guard_Dog_Activity_Log|null
     */
    private $activity_log = null;

    /**
     * WebAuthn serializer.
     *
     * @var mixed
     */
    private $serializer = null;

    /**
     * Constructor.
     *
     * @since 1.9.1
     * @param Guard_Dog_Activity_Log|null $activity_log_instance Activity log instance.
     */
    public function __construct( $activity_log_instance = null ) {
        $this->activity_log = $activity_log_instance;

        // Only proceed if the library class exists (via Composer).
        if ( ! class_exists( '\GuardDog\Vendor\Webauthn\PublicKeyCredentialCreationOptions' ) ) {
            Guard_Dog()->debug->error( 'Passkeys: WebAuthn library not available', 'passkeys' );
            return;
        }

        // Initialize serializer for WebAuthn data handling.
        $this->init_serializer();

        $this->add_hooks();
    }

    /**
     * Initialize the WebAuthn serializer.
     *
     * @since 1.9.1
     */
    private function init_serializer() {
        try {
            $factory = new WebauthnSerializerFactory( $this->get_attestation_support_manager() );
            $this->serializer = $factory->create();
        } catch ( \Exception $e ) {
            Guard_Dog()->debug->error( 'Passkeys: Failed to initialize serializer: ' . $e->getMessage(), 'passkeys' );
        }
    }

    /**
     * Generate a cryptographically random state token.
     *
     * @since 1.9.20
     * @return string 32-character alphanumeric token.
     */
    private function generate_state_token() {
        return wp_generate_password( 32, false );
    }

    /**
     * Store state data in a transient keyed by token.
     *
     * @since 1.9.20
     * @param string $token State token.
     * @param mixed  $data  Data to store.
     * @param int    $ttl   Time to live in seconds.
     */
    private function set_state_data( $token, $data, $ttl ) {
        set_transient( self::TRANSIENT_PREFIX . $token, $data, $ttl );
    }

    /**
     * Retrieve and delete state data (one-time use).
     *
     * @since 1.9.20
     * @param string $token State token.
     * @return mixed|false Data if found, false if missing/expired.
     */
    private function get_and_delete_state_data( $token ) {
        $key  = self::TRANSIENT_PREFIX . $token;
        $data = get_transient( $key );
        if ( false !== $data ) {
            delete_transient( $key );
        }
        return $data;
    }

    /**
     * Get attestation statement support manager.
     *
     * @since 1.9.1
     * @return AttestationStatementSupportManager
     */
    private function get_attestation_support_manager() {
        $manager = new AttestationStatementSupportManager();
        $manager->add( new NoneAttestationStatementSupport() );
        return $manager;
    }

    /**
     * Get COSE algorithm manager.
     *
     * @since 1.9.1
     * @return Manager
     */
    private function get_algorithm_manager() {
        $manager = new Manager();
        $manager->add( new ES256() );
        $manager->add( new RS256() );
        return $manager;
    }

    /**
     * Adds WordPress hooks.
     *
     * @since 1.9.1
     */
    private function add_hooks() {
        add_shortcode( self::SHORTCODE, array( $this, 'shortcode_handler' ) );
        add_shortcode( self::LOGIN_SHORTCODE, array( $this, 'login_shortcode_handler' ) );
        add_action( 'init', array( $this, 'register_block' ) );

        // Only add hooks if passkeys are enabled.
        if ( ! $this->is_enabled() ) {
            return;
        }

        // User profile hooks.
        add_action( 'show_user_profile', array( $this, 'render_passkey_management' ) );
        add_action( 'edit_user_profile', array( $this, 'render_passkey_management' ) );

        // Login form modification.
        add_action( 'login_form', array( $this, 'render_passkey_login_button' ) );
        add_action( 'login_enqueue_scripts', array( $this, 'enqueue_passkey_scripts' ) );

        // Admin profile scripts.
        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );

        // Public passkey auth needs to run before generic admin_init guards.
        add_action( 'admin_init', array( $this, 'handle_public_passkey_auth_ajax_early' ), -1000 );

        // AJAX handlers for logged-in users (registration).
        add_action( 'wp_ajax_guard_dog_passkey_register_options', array( $this, 'ajax_registration_options' ) );
        add_action( 'wp_ajax_guard_dog_passkey_register_verify', array( $this, 'ajax_registration_verify' ) );
        add_action( 'wp_ajax_guard_dog_passkey_delete', array( $this, 'ajax_delete_passkey' ) );
        add_action( 'wp_ajax_guard_dog_passkey_rename', array( $this, 'ajax_rename_passkey' ) );

        // AJAX handlers for non-logged-in users (authentication).
        add_action( 'wp_ajax_nopriv_guard_dog_passkey_auth_options', array( $this, 'ajax_auth_options' ) );
        add_action( 'wp_ajax_nopriv_guard_dog_passkey_auth_verify', array( $this, 'ajax_auth_verify' ) );
        add_action( 'wp_ajax_guard_dog_passkey_auth_options', array( $this, 'ajax_auth_options' ) );
        add_action( 'wp_ajax_guard_dog_passkey_auth_verify', array( $this, 'ajax_auth_verify' ) );
    }

    /**
     * Handle public passkey auth before later admin_init hooks can block AJAX.
     *
     * @since 1.9.40
     */
    public function handle_public_passkey_auth_ajax_early() {
        if ( ! wp_doing_ajax() ) {
            return;
        }

        $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';

        if ( 'guard_dog_passkey_auth_options' === $action ) {
            $this->ajax_auth_options();
            exit;
        }

        if ( 'guard_dog_passkey_auth_verify' === $action ) {
            $this->ajax_auth_verify();
            exit;
        }
    }

    /**
     * Check if passkeys feature is enabled.
     *
     * @since 1.9.1
     * @return bool
     */
    public function is_enabled() {
        return (bool) get_option( 'guard_dog_passkeys_enabled', false );
    }

    /**
     * Check if passkey authentication should bypass 2FA.
     *
     * @since 1.9.1
     * @return bool
     */
    public function should_bypass_2fa() {
        return (bool) get_option( 'guard_dog_passkeys_bypass_2fa', true );
    }

    /**
     * Get the admin-ajax endpoint as a site-relative URL.
     *
     * Using a relative path keeps passkey requests on the same origin as the
     * current login page, even when home and admin URLs use different hosts.
     *
     * @since 1.9.37
     * @return string
     */
    private function get_relative_admin_ajax_url() {
        return wp_make_link_relative( admin_url( 'admin-ajax.php' ) );
    }

    /**
     * Get the post-authentication redirect as a site-relative URL.
     *
     * @since 1.9.37
     * @return string
     */
    private function get_relative_login_redirect_url() {
        $fallback    = wp_make_link_relative( admin_url() );
        $redirect_to = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : '';

        if ( '' === $redirect_to ) {
            return $fallback;
        }

        $validated_redirect = wp_validate_redirect( $redirect_to, $fallback );
        if ( ! is_string( $validated_redirect ) || '' === $validated_redirect ) {
            return $fallback;
        }

        return wp_make_link_relative( $validated_redirect );
    }

    /**
     * Get the Relying Party configuration.
     *
     * @since 1.9.1
     * @return PublicKeyCredentialRpEntity
     */
    private function get_rp_entity() {
        $site_name = get_bloginfo( 'name' );
        $site_url  = wp_parse_url( home_url(), PHP_URL_HOST );

        return new PublicKeyCredentialRpEntity(
            $site_name,
            $site_url
        );
    }

    /**
     * Get RP IDs that are exempt from the HTTPS origin check.
     *
     * The WebAuthn spec allows HTTP for localhost origins during development.
     * The webauthn-lib library requires HTTPS by default for all origins,
     * so we explicitly mark localhost as "secured" (exempt) when the site
     * uses HTTP, matching the spec's localhost exception.
     *
     * @since 1.9.20
     * @see https://www.w3.org/TR/webauthn-3/#sctn-validating-origin
     * @return string[] Array of RP IDs exempt from HTTPS requirement.
     */
    private function get_secured_rp_ids() {
        $host   = wp_parse_url( home_url(), PHP_URL_HOST );
        $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME );

        $localhost_hosts = array( 'localhost', '127.0.0.1', '::1' );
        if ( 'http' === $scheme && in_array( $host, $localhost_hosts, true ) ) {
            return array( $host );
        }

        return array();
    }

    /**
     * Get the user entity for WebAuthn operations.
     *
     * @since 1.9.1
     * @param WP_User $user The WordPress user.
     * @return PublicKeyCredentialUserEntity
     */
    private function get_user_entity( $user ) {
        // Create a unique user handle (binary string).
        $user_handle = hash( 'sha256', $user->ID . '-' . $user->user_login, true );

        return new PublicKeyCredentialUserEntity(
            $user->user_login,
            $user_handle,
            $user->display_name
        );
    }

    /**
     * Get supported public key credential parameters.
     *
     * @since 1.9.1
     * @return array
     */
    private function get_pub_key_cred_params() {
        return array(
            new PublicKeyCredentialParameters( 'public-key', -7 ),  // ES256.
            new PublicKeyCredentialParameters( 'public-key', -257 ), // RS256.
        );
    }

    //--------------------------------------------------------------------------
    // Registration Flow
    //--------------------------------------------------------------------------

    /**
     * Generate registration options for a new passkey.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return array|WP_Error Registration options or error.
     */
    public function generate_registration_options( $user_id ) {
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return new \WP_Error( 'invalid_user', __( 'Invalid user.', 'guard-dog' ) );
        }

        try {
            // Get existing passkeys to exclude.
            $existing_passkeys = $this->get_user_passkeys( $user_id );
            $exclude_credentials = array();
            foreach ( $existing_passkeys as $passkey ) {
                $exclude_credentials[] = array(
                    'type' => 'public-key',
                    'id'   => base64_encode( $passkey->credential_id ),
                );
            }

            $rp_entity   = $this->get_rp_entity();
            $user_entity = $this->get_user_entity( $user );

            // Generate challenge.
            $challenge = random_bytes( 32 );

            // Create authenticator selection criteria.
            $authenticator_selection = new AuthenticatorSelectionCriteria(
                AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
                AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
                AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED
            );

            // Create the options.
            $options = new PublicKeyCredentialCreationOptions(
                $rp_entity,
                $user_entity,
                $challenge,
                $this->get_pub_key_cred_params()
            );
            $options->excludeCredentials = $exclude_credentials;
            $options->authenticatorSelection = $authenticator_selection;
            $options->timeout = 60000; // 60 seconds.
            $options->attestation = PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;

            // Store in transient for verification (keyed by state token).
            $state_token   = $this->generate_state_token();
            $serialized    = $this->serializer->serialize( $options, 'json' );
            $this->set_state_data( $state_token, $serialized, self::CHALLENGE_TTL );

            // Convert to JSON-encodable format for client.
            $client_options = array(
                'rp'                     => array(
                    'name' => $rp_entity->name,
                    'id'   => $rp_entity->id,
                ),
                'user'                   => array(
                    'id'          => base64_encode( $user_entity->id ),
                    'name'        => $user_entity->name,
                    'displayName' => $user_entity->displayName,
                ),
                'challenge'              => base64_encode( $challenge ),
                'pubKeyCredParams'       => array_map( function ( $param ) {
                    return array(
                        'type' => $param->type,
                        'alg'  => $param->alg,
                    );
                }, $this->get_pub_key_cred_params() ),
                'timeout'                => 60000,
                'excludeCredentials'     => $exclude_credentials,
                'authenticatorSelection' => array(
                    'residentKey'      => 'preferred',
                    'userVerification' => 'preferred',
                ),
                'attestation'            => 'none',
            );

            return array(
                'success'     => true,
                'options'     => $client_options,
                'state_token' => $state_token,
            );

        } catch ( \Exception $e ) {
            Guard_Dog()->debug->error( 'Passkeys: Registration options error: ' . $e->getMessage(), 'passkeys' );
            return new \WP_Error( 'registration_error', __( 'Failed to generate registration options.', 'guard-dog' ) );
        }
    }

    /**
     * Verify registration response and store passkey.
     *
     * @since 1.9.1
     * @param int    $user_id       The user ID.
     * @param string $response_json The credential response JSON from client.
     * @param string $friendly_name Optional friendly name for the passkey.
     * @return array|WP_Error Success response or error.
     */
    public function verify_registration_response( $user_id, $response_json, $friendly_name = '', $state_token = '' ) {
        $options_json = $this->get_and_delete_state_data( $state_token );

        if ( false === $options_json || empty( $options_json ) ) {
            return new \WP_Error( 'no_challenge', __( 'Registration session expired. Please try again.', 'guard-dog' ) );
        }

        try {
            $user = get_userdata( $user_id );
            if ( ! $user ) {
                return new \WP_Error( 'invalid_user', __( 'Invalid user.', 'guard-dog' ) );
            }

            // Parse the response.
            $response_data = json_decode( $response_json, true );
            if ( json_last_error() !== JSON_ERROR_NONE ) {
                return new \WP_Error( 'invalid_json', __( 'Invalid response format.', 'guard-dog' ) );
            }

            // Deserialize the stored options.
            $options = $this->serializer->deserialize( $options_json, PublicKeyCredentialCreationOptions::class, 'json' );

            // Load the credential.
            // AttestationObjectLoader wraps AttestationStatementSupportManager for webauthn-lib v4.x
            $attestation_object_loader = new AttestationObjectLoader( $this->get_attestation_support_manager() );
            $loader = new PublicKeyCredentialLoader( $attestation_object_loader );
            $credential = $loader->load( $response_json );

            if ( ! $credential->response instanceof AuthenticatorAttestationResponse ) {
                return new \WP_Error( 'invalid_response', __( 'Invalid attestation response.', 'guard-dog' ) );
            }

            // Get ceremony step manager.
            $ceremony_factory = new CeremonyStepManagerFactory();
            $ceremony_manager = $ceremony_factory->creationCeremony( $this->get_secured_rp_ids() );

            // Create validator.
            // Constructor: ($attestationStatementSupportManager, $publicKeyCredentialSourceRepository,
            //               $tokenBindingHandler, $extensionOutputCheckerHandler, $eventDispatcher, $ceremonyStepManager)
            $validator = new AuthenticatorAttestationResponseValidator(
                $this->get_attestation_support_manager(),
                null, // publicKeyCredentialSourceRepository - deprecated
                null, // tokenBindingHandler - deprecated
                null, // extensionOutputCheckerHandler - deprecated
                null, // eventDispatcher
                $ceremony_manager
            );

            // Validate the response.
            $public_key_credential_source = $validator->check(
                $credential->response,
                $options,
                wp_parse_url( home_url(), PHP_URL_HOST )
            );

            // Store the passkey.
            $passkey_id = $this->store_passkey( $user_id, $public_key_credential_source, $friendly_name );

            if ( is_wp_error( $passkey_id ) ) {
                return $passkey_id;
            }

            // Log the event.
            if ( $this->activity_log ) {
                $this->activity_log->add_log_entry( 'passkey_registered', array(
                    'user_id'       => $user_id,
                    'passkey_id'    => $passkey_id,
                    'friendly_name' => $friendly_name,
                ) );
            }

            Guard_Dog()->debug->info( 'Passkeys: Passkey registered', 'passkeys', array(
                'user_id'    => $user_id,
                'passkey_id' => $passkey_id,
            ) );

            return array(
                'success'    => true,
                'passkey_id' => $passkey_id,
                'message'    => __( 'Passkey registered successfully.', 'guard-dog' ),
            );

        } catch ( \Exception $e ) {
            Guard_Dog()->debug->error( 'Passkeys: Registration verification error: ' . $e->getMessage(), 'passkeys' );
            return new \WP_Error( 'verification_error', __( 'Failed to verify registration. Please try again.', 'guard-dog' ) );
        }
    }

    /**
     * Store a passkey in the database.
     *
     * @since 1.9.1
     * @param int                         $user_id                    User ID.
     * @param PublicKeyCredentialSource   $public_key_credential_source The credential source.
     * @param string                      $friendly_name              Friendly name.
     * @return int|WP_Error Passkey ID or error.
     */
    private function store_passkey( $user_id, $public_key_credential_source, $friendly_name = '' ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_passkeys_table_name();

        // Serialize the public key.
        $public_key_pem = $this->serializer->serialize( $public_key_credential_source, 'json' );

        // Get transports if available.
        $transports = $public_key_credential_source->transports ?? array();

        // Generate friendly name if not provided.
        if ( empty( $friendly_name ) ) {
            $friendly_name = sprintf( __( 'Passkey %d', 'guard-dog' ), time() );
        }

        $credential_id_hex = bin2hex( $public_key_credential_source->publicKeyCredentialId );
        $aaguid_raw        = $public_key_credential_source->aaguid?->toBinary() ?? null;
        $transports_json   = wp_json_encode( $transports );
        $attestation_type  = $public_key_credential_source->attestationType ?? 'none';
        $sign_count        = $public_key_credential_source->counter ?? 0;
        $friendly_name_san = sanitize_text_field( $friendly_name );
        $created_at        = current_time( 'mysql' );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        // Direct database insert is necessary for passkey storage.
        // Use UNHEX for credential_id (and aaguid when present) to avoid binary corruption from string escaping.
        $escaped_table = esc_sql( $table_name );
        if ( $aaguid_raw === null || $aaguid_raw === '' ) {
            $sql    = "INSERT INTO $escaped_table (user_id, credential_id, public_key_pem, attestation_type, transports, aaguid, sign_count, friendly_name, created_at, is_enabled) VALUES (%d, UNHEX(%s), %s, %s, %s, NULL, %d, %s, %s, %d)";
            $result = $wpdb->query(
                $wpdb->prepare(
                    $sql,
                    $user_id,
                    $credential_id_hex,
                    $public_key_pem,
                    $attestation_type,
                    $transports_json,
                    $sign_count,
                    $friendly_name_san,
                    $created_at,
                    1
                )
            );
        } else {
            $aaguid_hex = bin2hex( $aaguid_raw );
            $sql        = "INSERT INTO $escaped_table (user_id, credential_id, public_key_pem, attestation_type, transports, aaguid, sign_count, friendly_name, created_at, is_enabled) VALUES (%d, UNHEX(%s), %s, %s, %s, UNHEX(%s), %d, %s, %s, %d)";
            $result     = $wpdb->query(
                $wpdb->prepare(
                    $sql,
                    $user_id,
                    $credential_id_hex,
                    $public_key_pem,
                    $attestation_type,
                    $transports_json,
                    $aaguid_hex,
                    $sign_count,
                    $friendly_name_san,
                    $created_at,
                    1
                )
            );
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        if ( false === $result ) {
            Guard_Dog()->debug->error( 'Passkeys: Failed to store passkey: ' . $wpdb->last_error, 'passkeys' );
            return new \WP_Error( 'db_error', __( 'Failed to save passkey.', 'guard-dog' ) );
        }

        return $wpdb->insert_id;
    }

    //--------------------------------------------------------------------------
    // Authentication Flow
    //--------------------------------------------------------------------------

    /**
     * Generate authentication options.
     *
     * @since 1.9.1
     * @param string $username Optional username to limit credentials.
     * @return array|WP_Error Authentication options or error.
     */
    public function generate_authentication_options( $username = '' ) {
        try {
            $allowed_credentials = array();
            $user_id = null;

            // If username provided, get user's passkeys.
            if ( ! empty( $username ) ) {
                $user = get_user_by( 'login', $username );
                if ( ! $user ) {
                    $user = get_user_by( 'email', $username );
                }
                if ( $user ) {
                    $user_id = $user->ID;
                    $passkeys = $this->get_user_passkeys( $user->ID );
                    foreach ( $passkeys as $passkey ) {
                        if ( $passkey->is_enabled ) {
                            $transports = json_decode( $passkey->transports, true ) ?: array();
                            $allowed_credentials[] = array(
                                'type'       => 'public-key',
                                'id'         => base64_encode( $passkey->credential_id ),
                                'transports' => $transports,
                            );
                        }
                    }
                }
            }

            // Generate challenge.
            $challenge = random_bytes( 32 );

            // Store challenge data in transient keyed by state token.
            $state_token = $this->generate_state_token();
            $this->set_state_data( $state_token, array(
                'challenge' => base64_encode( $challenge ),
                'user_id'   => $user_id,
            ), self::CHALLENGE_TTL );

            $options = array(
                'challenge'        => base64_encode( $challenge ),
                'timeout'          => 60000,
                'rpId'             => wp_parse_url( home_url(), PHP_URL_HOST ),
                'userVerification' => 'preferred',
            );

            // Only include allowCredentials if we have specific passkeys.
            if ( ! empty( $allowed_credentials ) ) {
                $options['allowCredentials'] = $allowed_credentials;
            }

            return array(
                'success'     => true,
                'options'     => $options,
                'state_token' => $state_token,
            );

        } catch ( \Exception $e ) {
            Guard_Dog()->debug->error( 'Passkeys: Auth options error: ' . $e->getMessage(), 'passkeys' );
            return new \WP_Error( 'auth_options_error', __( 'Failed to generate authentication options.', 'guard-dog' ) );
        }
    }

    /**
     * Verify authentication response.
     *
     * @since 1.9.1
     * @param string $response_json The credential response JSON.
     * @return array|WP_Error User data or error.
     */
    public function verify_authentication_response( $response_json, $state_token = '' ) {
        $state_data = $this->get_and_delete_state_data( $state_token );

        if ( false === $state_data || ! is_array( $state_data ) || empty( $state_data['challenge'] ) ) {
            return new \WP_Error( 'no_challenge', __( 'Authentication session expired. Please try again.', 'guard-dog' ) );
        }

        try {
            // Parse the response.
            $response_data = json_decode( $response_json, true );
            if ( json_last_error() !== JSON_ERROR_NONE ) {
                return new \WP_Error( 'invalid_json', __( 'Invalid response format.', 'guard-dog' ) );
            }

            // Get the credential ID. Client sends base64url (RFC 4648); convert to standard base64 for decode.
            $id_b64 = isset( $response_data['id'] ) ? strtr( (string) $response_data['id'], '-_', '+/' ) : '';
            $credential_id = $id_b64 !== '' ? base64_decode( $id_b64, true ) : false;
            if ( false === $credential_id || '' === $credential_id ) {
                return new \WP_Error( 'no_credential', __( 'No credential ID provided.', 'guard-dog' ) );
            }

            // Find the passkey.
            $passkey = $this->get_passkey_by_credential_id( $credential_id );
            if ( ! $passkey ) {
                $this->log_failed_auth( 0, 'unknown_credential' );
                // Use generic message to prevent credential enumeration.
                return new \WP_Error( 'auth_failed', __( 'Authentication failed.', 'guard-dog' ) );
            }

            if ( ! $passkey->is_enabled ) {
                $this->log_failed_auth( $passkey->user_id, 'passkey_disabled' );
                // Use generic message to prevent credential enumeration.
                return new \WP_Error( 'auth_failed', __( 'Authentication failed.', 'guard-dog' ) );
            }

            // Get the stored challenge.
            $challenge = base64_decode( $state_data['challenge'] );

            // Load the stored credential source.
            $credential_source = $this->serializer->deserialize(
                $passkey->public_key_pem,
                PublicKeyCredentialSource::class,
                'json'
            );

            // Load the credential from response.
            // AttestationObjectLoader wraps AttestationStatementSupportManager for webauthn-lib v4.x
            $attestation_object_loader = new AttestationObjectLoader( $this->get_attestation_support_manager() );
            $loader = new PublicKeyCredentialLoader( $attestation_object_loader );
            $credential = $loader->load( $response_json );

            if ( ! $credential->response instanceof AuthenticatorAssertionResponse ) {
                return new \WP_Error( 'invalid_response', __( 'Invalid assertion response.', 'guard-dog' ) );
            }

            // Create request options for validation.
            $request_options = new PublicKeyCredentialRequestOptions(
                $challenge,
                wp_parse_url( home_url(), PHP_URL_HOST )
            );
            $request_options->timeout = 60000;
            $request_options->userVerification = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED;

            // Get ceremony step manager.
            $ceremony_factory = new CeremonyStepManagerFactory();
            $ceremony_manager = $ceremony_factory->requestCeremony( $this->get_secured_rp_ids() );

            // Create validator.
            // Constructor: ($publicKeyCredentialSourceRepository, $tokenBindingHandler,
            //               $extensionOutputCheckerHandler, $algorithmManager, $eventDispatcher, $ceremonyStepManager)
            $validator = new AuthenticatorAssertionResponseValidator(
                null, // publicKeyCredentialSourceRepository - deprecated
                null, // tokenBindingHandler - deprecated
                null, // extensionOutputCheckerHandler - deprecated
                null, // algorithmManager - deprecated
                null, // eventDispatcher
                $ceremony_manager
            );

            // Validate.
            $updated_source = $validator->check(
                $credential_source,
                $credential->response,
                $request_options,
                wp_parse_url( home_url(), PHP_URL_HOST ),
                null // User handle verification optional.
            );

            // Update sign count.
            $this->update_passkey_sign_count( $passkey->passkey_id, $updated_source->counter );

            // Set the passkey authentication flag via transient.
            set_transient( self::TRANSIENT_PREFIX . 'authed_' . $passkey->user_id, true, self::AUTH_FLAG_TTL );

            // Get the user.
            $user = get_userdata( $passkey->user_id );
            if ( ! $user ) {
                return new \WP_Error( 'user_not_found', __( 'User not found.', 'guard-dog' ) );
            }

            // Log success.
            if ( $this->activity_log ) {
                $this->activity_log->add_log_entry( 'passkey_login_success', array(
                    'user_id'       => $user->ID,
                    'passkey_id'    => $passkey->passkey_id,
                    'friendly_name' => $passkey->friendly_name,
                    'ip_address'    => Guard_Dog_Utils::get_client_ip(),
                ) );
            }

            Guard_Dog()->debug->info( 'Passkeys: Authentication successful', 'passkeys', array(
                'user_id'    => $user->ID,
                'passkey_id' => $passkey->passkey_id,
            ) );

            return array(
                'success'  => true,
                'user_id'  => $user->ID,
                'username' => $user->user_login,
                'message'  => __( 'Authentication successful.', 'guard-dog' ),
            );

        } catch ( \Exception $e ) {
            Guard_Dog()->debug->error( 'Passkeys: Authentication verification error: ' . $e->getMessage(), 'passkeys' );
            return new \WP_Error( 'verification_error', __( 'Authentication failed. Please try again.', 'guard-dog' ) );
        }
    }

    /**
     * Log failed authentication attempt.
     *
     * @since 1.9.1
     * @param int    $user_id User ID (0 if unknown).
     * @param string $reason  Failure reason.
     */
    private function log_failed_auth( $user_id, $reason ) {
        if ( $this->activity_log ) {
            $this->activity_log->add_log_entry( 'passkey_login_failed', array(
                'user_id'    => $user_id,
                'reason'     => $reason,
                'ip_address' => Guard_Dog_Utils::get_client_ip(),
            ) );
        }
    }

    //--------------------------------------------------------------------------
    // Database Operations
    //--------------------------------------------------------------------------

    /**
     * Get all passkeys for a user.
     *
     * @since 1.9.1
     * @param int $user_id User ID.
     * @return array Array of passkey objects.
     */
    public function get_user_passkeys( $user_id ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_passkeys_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        // Direct query necessary for passkey retrieval.
        // Caching not appropriate for security-sensitive credential data.
        $escaped_table = esc_sql( $table_name );
        $results = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM $escaped_table WHERE user_id = %d ORDER BY created_at DESC",
                $user_id
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $results ?: array();
    }

    /**
     * Get a passkey by credential ID.
     *
     * @since 1.9.1
     * @param string $credential_id The credential ID (binary).
     * @return object|null Passkey object or null.
     */
    public function get_passkey_by_credential_id( $credential_id ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_passkeys_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        // Direct query necessary for passkey lookup. Use UNHEX to compare VARBINARY without
        // string escaping corrupting binary bytes (e.g. backslash, null).
        // Caching not appropriate for security-sensitive credential data.
        $escaped_table   = esc_sql( $table_name );
        $credential_hex  = bin2hex( $credential_id );
        $result = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM $escaped_table WHERE credential_id = UNHEX(%s)",
                $credential_hex
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return $result;
    }

    /**
     * Update passkey sign count.
     *
     * @since 1.9.1
     * @param int $passkey_id Passkey ID.
     * @param int $sign_count New sign count.
     */
    private function update_passkey_sign_count( $passkey_id, $sign_count ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_passkeys_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        // Direct query necessary for passkey update.
        $wpdb->update(
            $table_name,
            array(
                'sign_count'   => $sign_count,
                'last_used_at' => current_time( 'mysql' ),
            ),
            array( 'passkey_id' => $passkey_id ),
            array( '%d', '%s' ),
            array( '%d' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }

    /**
     * Delete a passkey.
     *
     * @since 1.9.1
     * @param int $passkey_id Passkey ID.
     * @param int $user_id    User ID (for verification).
     * @return bool|WP_Error True on success, error otherwise.
     */
    public function delete_passkey( $passkey_id, $user_id ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_passkeys_table_name();

        // Get the passkey first for logging.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $escaped_table = esc_sql( $table_name );
        $passkey = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM $escaped_table WHERE passkey_id = %d AND user_id = %d",
                $passkey_id,
                $user_id
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        if ( ! $passkey ) {
            return new \WP_Error( 'not_found', __( 'Passkey not found.', 'guard-dog' ) );
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $result = $wpdb->delete(
            $table_name,
            array(
                'passkey_id' => $passkey_id,
                'user_id'    => $user_id,
            ),
            array( '%d', '%d' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( false === $result ) {
            return new \WP_Error( 'db_error', __( 'Failed to delete passkey.', 'guard-dog' ) );
        }

        // Log the event.
        if ( $this->activity_log ) {
            $this->activity_log->add_log_entry( 'passkey_deleted', array(
                'user_id'       => $user_id,
                'passkey_id'    => $passkey_id,
                'friendly_name' => $passkey->friendly_name,
            ) );
        }

        Guard_Dog()->debug->info( 'Passkeys: Passkey deleted', 'passkeys', array(
            'user_id'    => $user_id,
            'passkey_id' => $passkey_id,
        ) );

        return true;
    }

    /**
     * Rename a passkey.
     *
     * @since 1.9.1
     * @param int    $passkey_id    Passkey ID.
     * @param int    $user_id       User ID.
     * @param string $friendly_name New friendly name.
     * @return bool|WP_Error True on success, error otherwise.
     */
    public function rename_passkey( $passkey_id, $user_id, $friendly_name ) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_passkeys_table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $result = $wpdb->update(
            $table_name,
            array( 'friendly_name' => sanitize_text_field( $friendly_name ) ),
            array(
                'passkey_id' => $passkey_id,
                'user_id'    => $user_id,
            ),
            array( '%s' ),
            array( '%d', '%d' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( false === $result || 0 === $result ) {
            return new \WP_Error( 'db_error', __( 'Failed to rename passkey.', 'guard-dog' ) );
        }

        return true;
    }

    /**
     * Check if a user has any passkeys.
     *
     * @since 1.9.1
     * @param int $user_id User ID.
     * @return bool
     */
    public function user_has_passkeys( $user_id ) {
        $passkeys = $this->get_user_passkeys( $user_id );
        return ! empty( $passkeys );
    }

    //--------------------------------------------------------------------------
    // WordPress Integration
    //--------------------------------------------------------------------------

    /**
     * Check if the current session was authenticated via passkey.
     *
     * Used by 2FA to decide whether to skip verification.
     *
     * @since 1.9.1
     * @return bool
     */
    public static function was_passkey_authenticated( $user_id = null ) {
        if ( null === $user_id ) {
            $user_id = get_current_user_id();
        }
        return (bool) get_transient( self::TRANSIENT_PREFIX . 'authed_' . $user_id );
    }

    /**
     * Clear the passkey authentication flag.
     *
     * Should be called after successful login.
     *
     * @since 1.9.1
     * @param int|null $user_id User ID. Defaults to current user.
     */
    public static function clear_passkey_flag( $user_id = null ) {
        if ( null === $user_id ) {
            $user_id = get_current_user_id();
        }
        delete_transient( self::TRANSIENT_PREFIX . 'authed_' . $user_id );
    }

    /**
     * Check passkey auth rate limit (options + verify). Used for unauthenticated
     * passkey auth endpoints that cannot use a nonce.
     *
     * @since 1.9.1
     * @param string $ip Client IP.
     * @return bool True if under limit, false if over.
     */
    private function check_passkey_auth_rate_limit( $ip ) {
        $key   = 'guard_dog_passkey_auth_' . md5( $ip );
        $count = (int) get_transient( $key );
        if ( $count >= self::PASSKEY_AUTH_RATE_LIMIT ) {
            return false;
        }
        set_transient( $key, $count + 1, self::PASSKEY_AUTH_RATE_WINDOW );
        return true;
    }

    //--------------------------------------------------------------------------
    // AJAX Handlers
    //--------------------------------------------------------------------------

    /**
     * AJAX: Get registration options.
     *
     * @since 1.9.1
     */
    public function ajax_registration_options() {
        check_ajax_referer( 'guard_dog_passkey_nonce', 'nonce' );

        if ( ! is_user_logged_in() ) {
            wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'guard-dog' ) ) );
        }

        $result = $this->generate_registration_options( get_current_user_id() );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

        wp_send_json_success( $result );
    }

    /**
     * AJAX: Verify registration.
     *
     * @since 1.9.1
     */
    public function ajax_registration_verify() {
        check_ajax_referer( 'guard_dog_passkey_nonce', 'nonce' );

        if ( ! is_user_logged_in() ) {
            wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'guard-dog' ) ) );
        }

        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        // WebAuthn credential data must be passed through unchanged for cryptographic verification.
        $credential = isset( $_POST['credential'] ) ? wp_unslash( $_POST['credential'] ) : '';
        // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $friendly_name = isset( $_POST['friendly_name'] ) ? sanitize_text_field( wp_unslash( $_POST['friendly_name'] ) ) : '';
        $state_token   = isset( $_POST['state_token'] ) ? sanitize_text_field( wp_unslash( $_POST['state_token'] ) ) : '';

        if ( empty( $credential ) ) {
            wp_send_json_error( array( 'message' => __( 'No credential provided.', 'guard-dog' ) ) );
        }

        $result = $this->verify_registration_response( get_current_user_id(), $credential, $friendly_name, $state_token );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

        wp_send_json_success( $result );
    }

    /**
     * Send no-cache headers for passkey AJAX responses.
     *
     * Prevents CDNs (Cloudflare) and reverse proxies from caching responses
     * that contain WebAuthn challenges or Set-Cookie authentication headers.
     * A cached challenge transient is consumed by the first verification,
     * causing all subsequent passkey logins to fail with "session expired."
     *
     * @since 1.9.36
     */
    public function send_nocache_on_passkey_ajax() {
        nocache_headers();
    }

    /**
     * AJAX: Get authentication options.
     *
     * @since 1.9.1
     */
    public function ajax_auth_options() {
        $this->send_nocache_on_passkey_ajax();

        // Rate limiting check - integrate with login attempt limiting.
        $ip = Guard_Dog_Utils::get_client_ip();
        $limit_login = isset( Guard_Dog()->features['limit_login_attempts'] ) ? Guard_Dog()->features['limit_login_attempts'] : null;

        if ( $limit_login && method_exists( $limit_login, 'is_ip_locked_out' ) && $limit_login->is_ip_locked_out( $ip ) ) {
            wp_send_json_error( array( 'message' => __( 'Too many failed attempts. Please try again later.', 'guard-dog' ) ) );
        }

        if ( ! $this->check_passkey_auth_rate_limit( $ip ) ) {
            wp_send_json_error( array( 'message' => __( 'Too many requests. Please try again later.', 'guard-dog' ) ) );
        }

        // No nonce for unauthenticated users (login page). Rate limiting via Limit Login
        // Attempts and passkey-specific per-IP cap is used instead.
        $username = isset( $_POST['username'] ) ? sanitize_user( wp_unslash( $_POST['username'] ) ) : '';

        $result = $this->generate_authentication_options( $username );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

        wp_send_json_success( $result );
    }

    /**
     * AJAX: Verify authentication.
     *
     * @since 1.9.1
     */
    public function ajax_auth_verify() {
        $this->send_nocache_on_passkey_ajax();

        // Rate limiting check - integrate with login attempt limiting.
        $ip = Guard_Dog_Utils::get_client_ip();
        $limit_login = isset( Guard_Dog()->features['limit_login_attempts'] ) ? Guard_Dog()->features['limit_login_attempts'] : null;

        if ( $limit_login && method_exists( $limit_login, 'is_ip_locked_out' ) && $limit_login->is_ip_locked_out( $ip ) ) {
            wp_send_json_error( array( 'message' => __( 'Too many failed attempts. Please try again later.', 'guard-dog' ) ) );
        }

        if ( ! $this->check_passkey_auth_rate_limit( $ip ) ) {
            wp_send_json_error( array( 'message' => __( 'Too many requests. Please try again later.', 'guard-dog' ) ) );
        }

        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        // WebAuthn credential data must be passed through unchanged for cryptographic verification.
        $credential = isset( $_POST['credential'] ) ? wp_unslash( $_POST['credential'] ) : '';
        // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $state_token = isset( $_POST['state_token'] ) ? sanitize_text_field( wp_unslash( $_POST['state_token'] ) ) : '';

        if ( empty( $credential ) ) {
            wp_send_json_error( array( 'message' => __( 'No credential provided.', 'guard-dog' ) ) );
        }

        $result = $this->verify_authentication_response( $credential, $state_token );

        if ( is_wp_error( $result ) ) {
            // Record failed attempt for rate limiting.
            if ( $limit_login && method_exists( $limit_login, 'record_failed_attempt' ) ) {
                $limit_login->record_failed_attempt( $ip );
            }
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

            // Log the user in.
            $user = get_userdata( $result['user_id'] );
            if ( $user ) {
                // Verify user account is active.
                if ( ! user_can( $user, 'read' ) ) {
                    wp_send_json_error( array( 'message' => __( 'Account is disabled.', 'guard-dog' ) ) );
                    return;
                }

                // Allow "remember me" to be configurable via POST parameter.
                $remember = isset( $_POST['remember'] ) && 'true' === $_POST['remember'];

                wp_set_current_user( $user->ID );
                wp_set_auth_cookie( $user->ID, $remember );

                // Trigger wp_login action for session manager.
                do_action( 'wp_login', $user->user_login, $user );

                // Clear passkey flag so a subsequent password login cannot incorrectly bypass 2FA.
                // The transient has a short TTL but we clear it explicitly for safety.
                if ( class_exists( 'Guard_Dog_Passkeys' ) ) {
                    Guard_Dog_Passkeys::clear_passkey_flag( $user->ID );
                }
            }

        wp_send_json_success( array_merge( $result, array(
            'redirect' => $this->get_relative_login_redirect_url(),
        ) ) );
    }

    /**
     * AJAX: Delete passkey.
     *
     * @since 1.9.1
     */
    public function ajax_delete_passkey() {
        check_ajax_referer( 'guard_dog_passkey_nonce', 'nonce' );

        if ( ! is_user_logged_in() ) {
            wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'guard-dog' ) ) );
        }

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

        if ( ! $passkey_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid passkey ID.', 'guard-dog' ) ) );
        }

        $result = $this->delete_passkey( $passkey_id, get_current_user_id() );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

        wp_send_json_success( array( 'message' => __( 'Passkey deleted.', 'guard-dog' ) ) );
    }

    /**
     * AJAX: Rename passkey.
     *
     * @since 1.9.1
     */
    public function ajax_rename_passkey() {
        check_ajax_referer( 'guard_dog_passkey_nonce', 'nonce' );

        if ( ! is_user_logged_in() ) {
            wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'guard-dog' ) ) );
        }

        $passkey_id    = isset( $_POST['passkey_id'] ) ? absint( $_POST['passkey_id'] ) : 0;
        $friendly_name = isset( $_POST['friendly_name'] ) ? sanitize_text_field( wp_unslash( $_POST['friendly_name'] ) ) : '';

        if ( ! $passkey_id || empty( $friendly_name ) ) {
            wp_send_json_error( array( 'message' => __( 'Invalid passkey ID or name.', 'guard-dog' ) ) );
        }

        $result = $this->rename_passkey( $passkey_id, get_current_user_id(), $friendly_name );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

        wp_send_json_success( array( 'message' => __( 'Passkey renamed.', 'guard-dog' ) ) );
    }

    //--------------------------------------------------------------------------
    // UI Rendering
    //--------------------------------------------------------------------------

    /**
     * Render passkey login button on the login form.
     *
     * @since 1.9.1
     */
    public function render_passkey_login_button() {
        $markup = $this->get_login_button_markup();
        if ( '' === $markup ) {
            return;
        }

        // Passkey button markup is generated from a trusted plugin partial.
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        echo $markup;
    }

    /**
     * Get reusable passkey login button markup for public login surfaces.
     *
     * @since 1.9.39
     * @return string
     */
    public function get_login_button_markup( $args = array() ) {
        if ( ! $this->is_enabled() ) {
            return '';
        }

        $args = wp_parse_args(
            $args,
            array(
                'wrapper_class'     => 'guard-dog-passkey-login',
                'button_class'      => 'button button-secondary guard-dog-passkey-login__button',
                'status_class'      => 'guard-dog-passkey-login__status',
                'show_icon'         => true,
                'username_selector' => '#user_login',
                'redirect_to'       => '',
            )
        );

        return Guard_Dog_View_Loader::load_partial( 'frontend-passkey-login-button', $args );
    }

    /**
     * Render the frontend passkey login shortcode.
     *
     * @since 1.9.40
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function login_shortcode_handler( $atts ) {
        if ( ! $this->is_enabled() || is_user_logged_in() ) {
            return '';
        }

        $atts   = $this->normalize_login_shortcode_atts( $atts );
        $markup = $this->get_login_button_markup(
            array(
                'wrapper_class'     => $atts['wrapper_class'],
                'button_class'      => $atts['button_class'],
                'status_class'      => $atts['status_class'],
                'show_icon'         => $atts['show_icon'],
                'username_selector' => $atts['username_selector'],
                'redirect_to'       => $atts['redirect_to'],
            )
        );

        if ( '' !== $markup ) {
            $this->enqueue_login_assets(
                array(
                    'username_selector' => $atts['username_selector'],
                    'redirect_to'       => $atts['redirect_to'],
                )
            );
        }

        return $markup;
    }

    /**
     * Render the frontend passkey management shortcode.
     *
     * @since 1.9.40
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function shortcode_handler( $atts ) {
        if ( ! $this->is_enabled() ) {
            return '';
        }

        $atts = $this->normalize_shortcode_atts( $atts );

        if ( ! is_user_logged_in() ) {
            return sprintf(
                '<div class="guard-dog-passkeys-shortcode guard-dog-passkeys-shortcode--logged-out" data-style="%1$s"><p class="guard-dog-2fa-description">%2$s</p></div>',
                esc_attr( $atts['style'] ),
                esc_html__( 'You must be logged in to manage passkeys.', 'guard-dog' )
            );
        }

        $markup = $this->get_management_markup(
            get_current_user_id(),
            array(
                'context' => 'shortcode',
                'style'   => $atts['style'],
            )
        );

        if ( '' !== $markup ) {
            $this->enqueue_management_assets();
        }

        return $markup;
    }

    /**
     * Register the Gutenberg block wrapper for frontend passkey management.
     *
     * @since 1.9.40
     */
    public function register_block() {
        if ( ! function_exists( 'register_block_type' ) || ! class_exists( '\WP_Block_Type_Registry' ) ) {
            return;
        }

        $registry = \WP_Block_Type_Registry::get_instance();

        if ( ! $registry->is_registered( self::BLOCK_NAME ) ) {
            $this->register_block_editor_assets();

            register_block_type(
                self::BLOCK_NAME,
                array(
                    'api_version'     => 2,
                    'editor_script'   => self::BLOCK_EDITOR_SCRIPT,
                    'render_callback' => array( $this, 'render_block' ),
                    'attributes'      => $this->get_block_attributes_schema(),
                )
            );
        }

        if ( ! $registry->is_registered( self::LOGIN_BLOCK_NAME ) ) {
            $this->register_login_block_editor_assets();

            register_block_type(
                self::LOGIN_BLOCK_NAME,
                array(
                    'api_version'     => 2,
                    'editor_script'   => self::LOGIN_BLOCK_EDITOR_SCRIPT,
                    'render_callback' => array( $this, 'render_login_block' ),
                    'attributes'      => $this->get_login_block_attributes_schema(),
                )
            );
        }
    }

    /**
     * Register the editor-side block assets.
     *
     * @since 1.9.40
     */
    public function register_block_editor_assets() {
        if ( wp_script_is( self::BLOCK_EDITOR_SCRIPT, 'registered' ) ) {
            return;
        }

        wp_register_script(
            self::BLOCK_EDITOR_SCRIPT,
            GUARD_DOG_URL . 'assets/js/guard-dog-passkeys-block.js',
            array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-i18n', 'wp-block-editor' ),
            GUARD_DOG_VERSION,
            true
        );
    }

    /**
     * Register the editor-side passkey login block assets.
     *
     * @since 1.9.40
     */
    public function register_login_block_editor_assets() {
        if ( wp_script_is( self::LOGIN_BLOCK_EDITOR_SCRIPT, 'registered' ) ) {
            return;
        }

        wp_register_script(
            self::LOGIN_BLOCK_EDITOR_SCRIPT,
            GUARD_DOG_URL . 'assets/js/guard-dog-passkey-login-block.js',
            array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-i18n', 'wp-block-editor' ),
            GUARD_DOG_VERSION,
            true
        );
    }

    /**
     * Render the passkeys block using the canonical shortcode path.
     *
     * @since 1.9.40
     * @param array  $attributes Block attributes.
     * @param string $content    Block content.
     * @return string
     */
    public function render_block( $attributes = array(), $content = '' ) {
        unset( $content );

        return $this->shortcode_handler( (array) $attributes );
    }

    /**
     * Render the passkey login block using the canonical shortcode path.
     *
     * @since 1.9.40
     * @param array  $attributes Block attributes.
     * @param string $content    Block content.
     * @return string
     */
    public function render_login_block( $attributes = array(), $content = '' ) {
        unset( $content );

        return $this->login_shortcode_handler( (array) $attributes );
    }

    /**
     * Return the block attribute schema for frontend passkey management.
     *
     * @since 1.9.40
     * @return array
     */
    private function get_block_attributes_schema() {
        return array(
            'style' => array(
                'type'    => 'string',
                'default' => 'default',
            ),
        );
    }

    /**
     * Return the block attribute schema for frontend passkey login.
     *
     * @since 1.9.40
     * @return array
     */
    private function get_login_block_attributes_schema() {
        return array(
            'username_selector' => array(
                'type'    => 'string',
                'default' => '#user_login',
            ),
            'redirect_to'       => array(
                'type'    => 'string',
                'default' => '',
            ),
            'show_icon'         => array(
                'type'    => 'boolean',
                'default' => true,
            ),
        );
    }

    /**
     * Normalize shortcode and block attributes for frontend passkey management.
     *
     * @since 1.9.40
     * @param array|string $atts Raw attributes.
     * @return array
     */
    private function normalize_shortcode_atts( $atts ) {
        $atts = shortcode_atts(
            array(
                'style' => 'default',
            ),
            (array) $atts,
            self::SHORTCODE
        );

        $style = is_string( $atts['style'] ) ? sanitize_key( $atts['style'] ) : 'default';
        if ( ! in_array( $style, array( 'default', 'minimal', 'compact' ), true ) ) {
            $style = 'default';
        }

        return array(
            'style' => $style,
        );
    }

    /**
     * Normalize shortcode and block attributes for frontend passkey login.
     *
     * @since 1.9.40
     * @param array|string $atts Raw attributes.
     * @return array
     */
    private function normalize_login_shortcode_atts( $atts ) {
        $defaults = array(
            'wrapper_class'     => 'guard-dog-passkey-login',
            'button_class'      => 'button button-secondary guard-dog-passkey-login__button',
            'status_class'      => 'guard-dog-passkey-login__status',
            'show_icon'         => 'true',
            'username_selector' => '#user_login',
            'redirect_to'       => '',
        );

        $atts = shortcode_atts( $defaults, (array) $atts, self::LOGIN_SHORTCODE );

        return array(
            'wrapper_class'     => $this->sanitize_class_list( $atts['wrapper_class'], $defaults['wrapper_class'] ),
            'button_class'      => $this->sanitize_class_list( $atts['button_class'], $defaults['button_class'] ),
            'status_class'      => $this->sanitize_class_list( $atts['status_class'], $defaults['status_class'] ),
            'show_icon'         => $this->to_bool( $atts['show_icon'] ),
            'username_selector' => is_string( $atts['username_selector'] ) ? sanitize_text_field( $atts['username_selector'] ) : $defaults['username_selector'],
            'redirect_to'       => is_string( $atts['redirect_to'] ) ? esc_url_raw( $atts['redirect_to'] ) : '',
        );
    }

    /**
     * Sanitize a whitespace-separated class list.
     *
     * @since 1.9.40
     * @param mixed  $value   Raw class list.
     * @param string $default Fallback class list.
     * @return string
     */
    private function sanitize_class_list( $value, $default ) {
        $value = is_string( $value ) ? trim( $value ) : '';
        if ( '' === $value ) {
            return $default;
        }

        $classes = array_filter(
            array_map(
                'sanitize_html_class',
                preg_split( '/\s+/', $value )
            )
        );

        return empty( $classes ) ? $default : implode( ' ', $classes );
    }

    /**
     * Convert shortcode-ish booleans to real booleans.
     *
     * @since 1.9.40
     * @param mixed $value Raw value.
     * @return bool
     */
    private function to_bool( $value ) {
        if ( is_bool( $value ) ) {
            return $value;
        }

        $normalized = strtolower( trim( (string) $value ) );

        return ! in_array( $normalized, array( '', '0', 'false', 'no', 'off' ), true );
    }

    /**
     * Get reusable passkey management markup for profile and frontend account pages.
     *
     * @since 1.9.40
     * @param WP_User|int $user The user whose passkeys should be managed.
     * @param array       $args Optional rendering arguments.
     * @return string
     */
    public function get_management_markup( $user, $args = array() ) {
        if ( ! $this->is_enabled() ) {
            return '';
        }

        if ( ! $user instanceof \WP_User ) {
            $user = get_userdata( $user );
        }

        if ( ! $user instanceof \WP_User ) {
            return '';
        }

        // Only show to users editing their own profile or admins.
        if ( get_current_user_id() !== $user->ID && ! current_user_can( 'edit_users' ) ) {
            return '';
        }

        $args = wp_parse_args(
            $args,
            array(
                'context' => 'profile',
                'style'   => 'default',
            )
        );

        $context = ( 'shortcode' === $args['context'] ) ? 'shortcode' : 'profile';
        $style   = is_string( $args['style'] ) ? sanitize_key( $args['style'] ) : 'default';
        if ( ! in_array( $style, array( 'default', 'minimal', 'compact' ), true ) ) {
            $style = 'default';
        }

        return Guard_Dog_View_Loader::load_partial(
            'frontend-passkeys',
            array(
                'user'         => $user,
                'passkeys'     => $this->get_user_passkeys( $user->ID ),
                'nonce'        => wp_create_nonce( 'guard_dog_passkey_nonce' ),
                'context'      => $context,
                'style'        => $style,
                'can_register' => ( get_current_user_id() === $user->ID ),
            )
        );
    }

    /**
     * Render passkey management section on user profile.
     *
     * @since 1.9.1
     * @param WP_User $user The user being edited.
     */
    public function render_passkey_management( $user ) {
        $markup = $this->get_management_markup(
            $user,
            array(
                'context' => 'profile',
            )
        );

        if ( '' === $markup ) {
            return;
        }

        // Passkey management markup is generated from a trusted plugin partial.
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        echo $markup;
    }

    /**
     * Enqueue passkey scripts for login page.
     *
     * @since 1.9.1
     */
    public function enqueue_passkey_scripts() {
        $this->enqueue_login_assets();
    }

    /**
     * Enqueue passkey assets for public login surfaces.
     *
     * @since 1.9.39
     */
    public function enqueue_login_assets( $args = array() ) {
        if ( ! $this->is_enabled() ) {
            return;
        }

        $args = wp_parse_args(
            $args,
            array(
                'username_selector' => '#user_login',
                'redirect_to'       => '',
            )
        );

        wp_enqueue_style( 'dashicons' );

        wp_enqueue_style(
            'guard-dog-passkey-login',
            GUARD_DOG_URL . 'assets/css/guard-dog-passkey-login.css',
            array(),
            GUARD_DOG_VERSION
        );

        wp_enqueue_script(
            'guard-dog-passkeys',
            GUARD_DOG_URL . 'assets/js/guard-dog-passkeys.js',
            array( 'jquery' ),
            GUARD_DOG_VERSION,
            true
        );

        if ( ! is_string( wp_scripts()->get_data( 'guard-dog-passkeys', 'data' ) ) ) {
            wp_localize_script( 'guard-dog-passkeys', 'guardDogPasskeys', array(
                'ajaxUrl'          => $this->get_relative_admin_ajax_url(),
                'isLoginPage'      => true,
                'usernameSelector' => sanitize_text_field( $args['username_selector'] ),
                'redirectTo'       => esc_url_raw( $args['redirect_to'] ),
                'debug'            => ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug && method_exists( Guard_Dog()->debug, 'is_enabled' ) && Guard_Dog()->debug->is_enabled() ),
                'i18n'             => array(
                    'authenticating'     => __( 'Authenticating...', 'guard-dog' ),
                    'success'            => __( 'Success! Redirecting...', 'guard-dog' ),
                    'error'              => __( 'Authentication failed.', 'guard-dog' ),
                    'unexpectedResponse' => __( 'The site returned an unexpected response while processing the passkey request.', 'guard-dog' ),
                    'endpointBlocked'    => __( 'Guard Dog could not reach WordPress AJAX for this passkey request. Another membership, security, or cache layer may be blocking /wp-admin/admin-ajax.php for logged-in members.', 'guard-dog' ),
                    'notSupported'       => __( 'Passkeys are not supported in this browser.', 'guard-dog' ),
                    'userCancelled'      => __( 'Authentication was cancelled.', 'guard-dog' ),
                ),
            ) );
        }
    }

    /**
     * Enqueue passkey assets for frontend and admin management screens.
     *
     * @since 1.9.40
     */
    public function enqueue_management_assets() {
        if ( ! $this->is_enabled() ) {
            return;
        }

        wp_enqueue_script(
            'guard-dog-passkeys',
            GUARD_DOG_URL . 'assets/js/guard-dog-passkeys.js',
            array( 'jquery' ),
            GUARD_DOG_VERSION,
            true
        );

        wp_localize_script( 'guard-dog-passkeys', 'guardDogPasskeys', array(
            'ajaxUrl'      => $this->get_relative_admin_ajax_url(),
            'isLoginPage'  => false,
            'debug'        => ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug && method_exists( Guard_Dog()->debug, 'is_enabled' ) && Guard_Dog()->debug->is_enabled() ),
            'i18n'         => array(
                'registering'     => __( 'Registering passkey...', 'guard-dog' ),
                'success'         => __( 'Passkey registered successfully!', 'guard-dog' ),
                'error'           => __( 'Registration failed.', 'guard-dog' ),
                'unexpectedResponse' => __( 'The site returned an unexpected response while processing the passkey request.', 'guard-dog' ),
                'endpointBlocked' => __( 'Guard Dog could not reach WordPress AJAX for this passkey request. Another membership, security, or cache layer may be blocking /wp-admin/admin-ajax.php for logged-in members.', 'guard-dog' ),
                'notSupported'    => __( 'Passkeys are not supported in this browser.', 'guard-dog' ),
                'userCancelled'   => __( 'Registration was cancelled.', 'guard-dog' ),
                'confirmDelete'   => __( 'Are you sure you want to delete this passkey?', 'guard-dog' ),
                'deleted'         => __( 'Passkey deleted.', 'guard-dog' ),
                'renamed'         => __( 'Passkey renamed.', 'guard-dog' ),
                'enterName'       => __( 'Enter new name:', 'guard-dog' ),
                'emptyState'      => __( 'No passkeys registered.', 'guard-dog' ),
            ),
        ) );
    }

    /**
     * Enqueue passkey scripts for admin pages.
     *
     * @since 1.9.1
     * @param string $hook The current admin page hook.
     */
    public function enqueue_admin_scripts( $hook ) {
        if ( ! $this->is_enabled() ) {
            return;
        }

        // Only on profile pages.
        if ( 'profile.php' !== $hook && 'user-edit.php' !== $hook ) {
            return;
        }

        $this->enqueue_management_assets();
    }
}
