<?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\PublicKeyCredentialDescriptor;
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';

    /**
     * Hidden POST field that records a user's intent to set up a passkey after password login.
     *
     * @var string
     */
    const SETUP_INTENT_FIELD = 'guard_dog_passkey_setup_intent';

    /**
     * Login state type for the post-password passkey setup prompt.
     *
     * @var string
     */
    const SETUP_PROMPT_STATE = 'passkey_setup_prompt';

    /**
     * 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;

    /**
     * TTL for passkey setup prompt security tokens (30 minutes).
     *
     * @var int
     */
    const SETUP_TOKEN_TTL = 1800;

    /**
     * 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' ) );
        add_filter( 'login_redirect', array( $this, 'maybe_redirect_password_login_to_setup_prompt' ), 20, 3 );
        add_action( 'login_init', array( $this, 'maybe_render_password_login_setup_prompt' ), 6 );
        add_filter( 'login_body_class', array( $this, 'add_login_body_class' ) );
        add_action( 'init', array( $this, 'handle_frontend_passkey_ajax' ), 4 );

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

        // Passkey AJAX needs to run before generic admin_init guards can block frontend/member requests.
        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 passkey AJAX before later admin_init hooks can block the request.
     *
     * This covers public authentication and logged-in management actions because
     * some sites restrict wp-admin access for subscribers before admin-ajax.php
     * reaches the normal wp_ajax_* dispatcher.
     *
     * @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 ( $this->dispatch_passkey_ajax_action( $action ) ) {
            exit;
        }
    }

    /**
     * Handle passkey AJAX on a frontend endpoint that avoids wp-admin blockers.
     *
     * @since 1.9.46
     */
    public function handle_frontend_passkey_ajax() {
        if ( empty( $_REQUEST['guard_dog_passkey'] ) ) {
            return;
        }

        add_filter( 'wp_doing_ajax', '__return_true' );

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

        if ( $this->dispatch_passkey_ajax_action( $action ) ) {
            exit;
        }

        wp_send_json_error(
            array(
                'message' => __( 'Invalid passkey request.', 'guard-dog' ),
            ),
            400
        );
    }

    /**
     * Dispatch an allowed passkey AJAX action.
     *
     * @since 1.9.46
     * @param string $action AJAX action name.
     * @return bool True when the action was dispatched.
     */
    private function dispatch_passkey_ajax_action( $action ) {
        $actions = array(
            'guard_dog_passkey_auth_options'      => 'ajax_auth_options',
            'guard_dog_passkey_auth_verify'       => 'ajax_auth_verify',
            'guard_dog_passkey_register_options'  => 'ajax_registration_options',
            'guard_dog_passkey_register_verify'   => 'ajax_registration_verify',
            'guard_dog_passkey_delete'            => 'ajax_delete_passkey',
            'guard_dog_passkey_rename'            => 'ajax_rename_passkey',
        );

        if ( ! isset( $actions[ $action ] ) ) {
            return false;
        }

        $this->{$actions[ $action ]}();

        return true;
    }

    /**
     * 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 WebAuthn user-verification requirement for this site.
     *
     * "Require User Verification" means the authenticator must prove the person is
     * present and is the account holder - a biometric or a device PIN - rather than
     * merely being in possession of the device. Mapped to the WebAuthn values:
     *
     * - enabled  -> "required"  : the ceremony fails unless the user verified.
     * - disabled -> "preferred" : verify where the authenticator supports it, but
     *                             do not fail when it cannot.
     *
     * "preferred" is used for the disabled case rather than "discouraged" so that
     * turning the setting off relaxes enforcement without actively asking
     * authenticators to skip verification they would otherwise perform.
     *
     * @since 1.9.51
     * @return string A WebAuthn user-verification requirement value.
     */
    public function get_user_verification_requirement() {
        $require = (bool) get_option( 'guard_dog_passkeys_require_user_verification', true );

        return $require
            ? AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED
            : AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED;
    }

    /**
     * Get the frontend passkey AJAX endpoint as a site-relative URL.
     *
     * Using a frontend endpoint keeps passkey requests away from wp-admin
     * access restrictions while staying on the current site's origin.
     *
     * @since 1.9.46
     * @return string
     */
    private function get_relative_passkey_ajax_url() {
        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '/';
        $path        = wp_parse_url( $request_uri, PHP_URL_PATH );
        $query       = wp_parse_url( $request_uri, PHP_URL_QUERY );
        $query_args  = array();

        if ( ! is_string( $path ) || '' === $path ) {
            $path = '/';
        }

        if ( is_string( $query ) && '' !== $query ) {
            parse_str( $query, $query_args );
        }

        unset( $query_args['gd_state'], $query_args['guard_dog_passkey'] );

        $url = add_query_arg( $query_args, home_url( $path ) );
        $url = add_query_arg( 'guard_dog_passkey', '1', $url );

        return wp_make_link_relative( $url );
    }

    /**
     * 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.
            //
            // Two representations are needed, and they must not be shared:
            //
            // - $exclude_credentials holds PublicKeyCredentialDescriptor objects built
            //   from the RAW binary credential ID. These go into the options object that
            //   gets serialized into the state transient and deserialized again during
            //   verification. The library encodes credential IDs as unpadded base64url,
            //   so handing it pre-encoded standard base64 (with + / and = padding) makes
            //   the later deserialize() throw "decodeNoPadding() doesn't tolerate
            //   padding" - which only happens once a user already has a passkey, i.e.
            //   from their SECOND registration onward, on any additional device.
            //
            // - $client_exclude_credentials holds plain arrays for the browser. The
            //   JS helper normalizes base64 and base64url alike, so either encoding
            //   works there.
            $existing_passkeys          = $this->get_user_passkeys( $user_id );
            $exclude_credentials        = array();
            $client_exclude_credentials = array();
            foreach ( $existing_passkeys as $passkey ) {
                $exclude_credentials[] = new PublicKeyCredentialDescriptor(
                    PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
                    $passkey->credential_id
                );
                $client_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.
            //
            // Named arguments are used deliberately. The constructor signature is
            // ( $authenticatorAttachment, $userVerification, $residentKey ), but this
            // was previously called positionally with the resident-key value second
            // and the user-verification value third - i.e. the two were swapped. It
            // went unnoticed because RESIDENT_KEY_REQUIREMENT_PREFERRED and
            // USER_VERIFICATION_REQUIREMENT_PREFERRED are both the string 'preferred',
            // so each landed on a valid value for the other's slot and nothing threw.
            // Passing anything other than 'preferred' immediately exposed it.
            $user_verification       = $this->get_user_verification_requirement();
            $authenticator_selection = new AuthenticatorSelectionCriteria(
                authenticatorAttachment: AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
                userVerification: $user_verification,
                residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED
            );

            // Create the options.
            //
            // Everything is passed through the constructor rather than assigned to
            // public properties afterwards, because the constructor type-checks
            // $pubKeyCredParams and $excludeCredentials. Property assignment bypasses
            // those guards, so a wrong type there produces no error here and instead
            // corrupts the serialized state, surfacing much later as a generic
            // "Failed to verify registration" once the authenticator has already
            // created the credential.
            $options = new PublicKeyCredentialCreationOptions(
                $rp_entity,
                $user_entity,
                $challenge,
                $this->get_pub_key_cred_params(),
                $authenticator_selection,
                PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
                $exclude_credentials,
                60000 // 60 seconds.
            );

            // 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'     => $client_exclude_credentials,
                'authenticatorSelection' => array(
                    'residentKey'      => 'preferred',
                    'userVerification' => $user_verification,
                ),
                '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' => $this->get_user_verification_requirement(),
            );

            // 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;
            // Must match the requirement advertised in generate_authentication_options(),
            // because the ceremony validates the assertion against these options.
            $request_options->userVerification = $this->get_user_verification_requirement();

            // 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 );
    }

    /**
     * Check if a user has any enabled passkeys.
     *
     * @since 1.9.46
     * @param int $user_id User ID.
     * @return bool
     */
    public function user_has_enabled_passkeys( $user_id ) {
        $passkeys = $this->get_user_passkeys( $user_id );

        foreach ( $passkeys as $passkey ) {
            if ( ! empty( $passkey->is_enabled ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build the post-password redirect for users who tried passkey login first.
     *
     * @since 1.9.46
     * @param WP_User|int $user             Authenticated user.
     * @param string      $redirect_to      Intended destination after login.
     * @param bool        $has_setup_intent Whether the login flow should offer passkey setup.
     * @return string Safe redirect URL.
     */
    public function get_password_login_setup_prompt_redirect( $user, $redirect_to = '', $has_setup_intent = false ) {
        $safe_redirect = $this->get_safe_post_login_redirect( $redirect_to );

        if ( ! $has_setup_intent || ! $this->is_enabled() ) {
            return $safe_redirect;
        }

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

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

        if ( $this->user_has_enabled_passkeys( $user->ID ) ) {
            return $safe_redirect;
        }

        return Guard_Dog_Utils::create_login_state_url(
            $this->get_login_state_base_url(),
            array(
                'type'        => self::SETUP_PROMPT_STATE,
                'user_id'     => (int) $user->ID,
                'redirect_to' => $safe_redirect,
            ),
            self::CHALLENGE_TTL
        );
    }

    /**
     * Route core password logins into the setup prompt when passkey auth was attempted first.
     *
     * @since 1.9.46
     * @param string           $redirect_to           Calculated redirect.
     * @param string           $requested_redirect_to Requested redirect.
     * @param WP_User|WP_Error $user                  Authenticated user or error.
     * @return string
     */
    public function maybe_redirect_password_login_to_setup_prompt( $redirect_to, $requested_redirect_to, $user ) {
        $candidate_redirect = ( is_string( $requested_redirect_to ) && '' !== $requested_redirect_to ) ? $requested_redirect_to : $redirect_to;

        return $this->get_password_login_setup_prompt_redirect(
            $user,
            $candidate_redirect,
            $this->request_has_password_login_setup_intent()
        );
    }

    /**
     * Render the logged-in passkey setup prompt after a successful password login.
     *
     * @since 1.9.46
     */
    public function maybe_render_password_login_setup_prompt() {
        $login_state = Guard_Dog_Utils::consume_login_state();
        if ( ! is_array( $login_state ) || ( $login_state['type'] ?? '' ) !== self::SETUP_PROMPT_STATE ) {
            return;
        }

        $redirect_to = $this->get_safe_post_login_redirect( $login_state['redirect_to'] ?? '' );
        $user_id     = absint( $login_state['user_id'] ?? 0 );

        if ( ! is_user_logged_in() || 0 === $user_id || get_current_user_id() !== $user_id ) {
            wp_safe_redirect( $redirect_to );
            exit;
        }

        $user = get_userdata( $user_id );
        if ( ! $user instanceof \WP_User || $this->user_has_enabled_passkeys( $user_id ) ) {
            wp_safe_redirect( $redirect_to );
            exit;
        }

        if ( ! function_exists( 'login_header' ) ) {
            require_once ABSPATH . 'wp-login.php';
        }

        global $error, $interim_login, $action;
        $error         = null;
        $interim_login = false;
        $action        = 'guard_dog_passkey_setup_prompt';

        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_style(
            'guard-dog-shortcode-2fa',
            GUARD_DOG_URL . 'assets/css/guard-dog-shortcode-2fa.css',
            array(),
            GUARD_DOG_VERSION
        );
        $this->enqueue_management_assets();

        $GLOBALS['guard_dog_login_screen_state'] = self::SETUP_PROMPT_STATE;

        login_header( __( 'Set Up Passkey', 'guard-dog' ), '', null );

        Guard_Dog_View_Loader::render_partial(
            'login-passkey-setup-prompt',
            array(
                'user'        => $user,
                'redirect_to' => $redirect_to,
                'nonce'       => $this->create_setup_registration_token( $user_id ),
            )
        );

        login_footer();
        exit;
    }

    /**
     * Add setup-prompt body classes for scoped login screen styling.
     *
     * @since 1.9.46
     * @param string[] $classes Existing login body classes.
     * @return string[]
     */
    public function add_login_body_class( $classes ) {
        $classes = is_array( $classes ) ? $classes : array();

        if ( ! empty( $GLOBALS['guard_dog_login_screen_state'] ) && self::SETUP_PROMPT_STATE === sanitize_key( $GLOBALS['guard_dog_login_screen_state'] ) ) {
            $classes[] = 'guard-dog-passkey-setup-screen';
        }

        return array_values( array_unique( $classes ) );
    }

    /**
     * Check the password-login request for the passkey setup intent field.
     *
     * @since 1.9.46
     * @return bool
     */
    private function request_has_password_login_setup_intent() {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        // The login form nonce/cookie state is handled by WordPress authentication before login_redirect runs.
        $intent = isset( $_POST[ self::SETUP_INTENT_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::SETUP_INTENT_FIELD ] ) ) : '';
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        return '1' === $intent;
    }

    /**
     * Validate a post-login redirect target.
     *
     * @since 1.9.46
     * @param string $redirect_to Raw redirect target.
     * @return string Safe redirect target.
     */
    private function get_safe_post_login_redirect( $redirect_to ) {
        $redirect_to = is_string( $redirect_to ) ? $redirect_to : '';
        $validated   = wp_validate_redirect( $redirect_to, admin_url() );

        return ( is_string( $validated ) && '' !== $validated ) ? $validated : admin_url();
    }

    /**
     * Get the login URL used for Guard Dog state-token flows.
     *
     * @since 1.9.46
     * @return string
     */
    private function get_login_state_base_url() {
        return Guard_Dog_Utils::get_login_base_url();
    }

    //--------------------------------------------------------------------------
    // 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() {
        $this->send_nocache_on_passkey_ajax();

        if ( ! $this->verify_registration_security_token( false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'guard-dog' ) ), 403 );
        }

        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() {
        $this->send_nocache_on_passkey_ajax();

        if ( ! $this->verify_registration_security_token( true ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'guard-dog' ) ), 403 );
        }

        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 );
    }

    /**
     * Create a setup prompt registration token.
     *
     * WordPress nonces are tied to the logged-in session token. Immediately
     * after password login, some hardened login stacks can serve this prompt
     * before the browser's new session nonce validates on the follow-up request.
     * This opaque transient token gives the post-login prompt its own CSRF guard.
     *
     * @since 1.9.46
     * @param int $user_id User ID.
     * @return string Setup token.
     */
    private function create_setup_registration_token( $user_id ) {
        $token = $this->generate_state_token();
        set_transient(
            $this->get_setup_registration_token_key( $token ),
            array(
                'user_id'    => absint( $user_id ),
                'created_at' => time(),
            ),
            self::SETUP_TOKEN_TTL
        );

        return $token;
    }

    /**
     * Get the transient key for a setup registration token.
     *
     * @since 1.9.46
     * @param string $token Setup token.
     * @return string
     */
    private function get_setup_registration_token_key( $token ) {
        return self::TRANSIENT_PREFIX . 'setup_' . hash( 'sha256', (string) $token );
    }

    /**
     * Verify the registration request security token.
     *
     * @since 1.9.46
     * @param bool $consume_setup_token Whether to consume setup prompt tokens after validation.
     * @return bool
     */
    private function verify_registration_security_token( $consume_setup_token = false ) {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        $token = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        if ( '' === $token ) {
            return false;
        }

        if ( wp_verify_nonce( $token, 'guard_dog_passkey_nonce' ) ) {
            return true;
        }

        return $this->verify_setup_registration_token( $token, $consume_setup_token );
    }

    /**
     * Verify a setup prompt registration token.
     *
     * @since 1.9.46
     * @param string $token Setup token.
     * @param bool   $consume Whether to consume the token after validation.
     * @return bool
     */
    private function verify_setup_registration_token( $token, $consume = false ) {
        if ( ! is_user_logged_in() ) {
            return false;
        }

        $key  = $this->get_setup_registration_token_key( $token );
        $data = get_transient( $key );

        if ( ! is_array( $data ) || (int) ( $data['user_id'] ?? 0 ) !== get_current_user_id() ) {
            return false;
        }

        if ( $consume ) {
            delete_transient( $key );
        }

        return true;
    }

    /**
     * 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;
        $user_id    = $this->get_management_request_user_id();

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

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

        $result = $this->delete_passkey( $passkey_id, $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'] ) ) : '';
        $user_id       = $this->get_management_request_user_id();

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

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

        $result = $this->rename_passkey( $passkey_id, $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' ) ) );
    }

    /**
     * Get the user ID targeted by a passkey management AJAX request.
     *
     * @since 1.9.48
     * @return int|WP_Error User ID on success, error when unauthorized.
     */
    private function get_management_request_user_id() {
        $current_user_id   = get_current_user_id();
        $requested_user_id = isset( $_POST['user_id'] ) ? absint( wp_unslash( $_POST['user_id'] ) ) : 0;

        if ( ! $requested_user_id || $requested_user_id === $current_user_id ) {
            return $current_user_id;
        }

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

        if ( ! current_user_can( 'edit_user', $requested_user_id ) ) {
            return new \WP_Error( 'forbidden', __( 'You are not allowed to manage passkeys for this user.', 'guard-dog' ) );
        }

        return $requested_user_id;
    }

    //--------------------------------------------------------------------------
    // 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',
                'success_redirect' => '',
            )
        );

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

        if ( '' !== $success_redirect ) {
            $success_redirect = $this->get_safe_post_login_redirect( $success_redirect );
        }

        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 ),
                'success_redirect' => $success_redirect,
            )
        );
    }

    /**
     * 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_passkey_ajax_url(),
                'isLoginPage'      => true,
                'usernameSelector' => sanitize_text_field( $args['username_selector'] ),
                'redirectTo'       => esc_url_raw( $args['redirect_to'] ),
                'setupIntentField' => self::SETUP_INTENT_FIELD,
                '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 the passkey endpoint. Another membership, security, or cache layer may be blocking the Guard Dog passkey endpoint.', 'guard-dog' ),
                    'notSupported'       => __( 'Passkeys are not supported in this browser.', 'guard-dog' ),
                    'userCancelled'      => __( 'Authentication was cancelled.', 'guard-dog' ),
                    'setupAfterPassword' => __( 'Use your password to continue. If this account does not have a passkey yet, you can create one after sign-in.', '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_passkey_ajax_url(),
            'isLoginPage'  => false,
            'setupIntentField' => self::SETUP_INTENT_FIELD,
            '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 the passkey endpoint. Another membership, security, or cache layer may be blocking the Guard Dog passkey endpoint.', 'guard-dog' ),
                'notSupported'    => __( 'Passkeys are not supported in this browser.', 'guard-dog' ),
                'userCancelled'   => __( 'Registration was cancelled.', 'guard-dog' ),
                'deviceCleanup'   => __( 'The passkey may have been saved on your device, but this site could not finish registering it. Remove that passkey from your device or password manager before trying again.', '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();
    }
}
