<?php
/**
 * PasskeysAjax - AJAX handlers for the Passkeys module.
 *
 * Extends AbstractAjaxHandler which provides verify(), verify_nonce_only(),
 * t(), send_success/error() and save_export() — exactly the pattern used by
 * LoginLimiterAjax, now available to any module via the base class.
 *
 * Registration uses register_actions() (AjaxManager config-array API) for
 * admin-only actions and add_public_action() for guest-accessible endpoints.
 *
 * Nonce action: 'ultraguard_admin_nonce'
 * (matches PasskeysDashboard::$ajax_nonce_action and the login-page nonce).
 *
 * @package UltraguardPro\modules\authenticator\ajax
 */

namespace UltraguardPro\modules\authenticator\ajax;

use UltraguardPro\Plugin;
use UltraguardPro\modules\authenticator\PasskeyManager;
use UltraguardPro\modules\core\AbstractAjaxHandler;
use UltraguardPro\support\AjaxResponseBuilder;

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

// phpcs:disable WordPress.Security.NonceVerification.Missing -- Passkeys AJAX handlers are registered through the shared protected AJAX framework or explicit public action helpers.

class PasskeysAjax extends AbstractAjaxHandler {

    /** Nonce action used by every handler in this class. */
    protected const NONCE = 'ultraguard_admin_nonce';

    /**
     * PasskeyManager instance, lazily constructed in manager().
     * Stored as a class property (not a function-level static) so it is
     * scoped to this instance and created only after all module schemas —
     * including PasskeysSchema — have been registered during boot().
     *
     * @var PasskeyManager|null
     */
    private ?PasskeyManager $manager_instance = null;

    // ── Registration ──────────────────────────────────────────────────────────

    public function register(): void {
        $this->register_actions( [
            'ultraguard_passkeys_save_settings'           => [
                'handler' => [ $this, 'handle_save_settings' ],
                'cap'     => 'manage_options',
            ],
            'ultraguard_passkeys_registration_options'    => [
                'handler' => [ $this, 'handle_registration_options' ],
                'cap'     => 'read',
            ],
            'ultraguard_passkeys_complete_registration'   => [
                'handler' => [ $this, 'handle_complete_registration' ],
                'cap'     => 'read',
            ],
            'ultraguard_passkeys_delete_credential'       => [
                'handler' => [ $this, 'handle_delete_credential' ],
                'cap'     => 'read',
            ],
            'ultraguard_passkeys_generate_recovery_codes' => [
                'handler' => [ $this, 'handle_generate_recovery_codes' ],
                'cap'     => 'read',
            ],
            'ultraguard_passkeys_list_credentials'        => [
                'handler' => [ $this, 'handle_list_credentials' ],
                'cap'     => 'read',
            ],
        ] );

        $this->add_public_action( 'ultraguard_passkeys_authentication_options', [ $this, 'handle_authentication_options' ] );
        $this->add_public_action( 'ultraguard_passkeys_complete_authentication', [ $this, 'handle_complete_authentication' ] );
        $this->add_public_action( 'ultraguard_passkeys_verify_recovery_code',   [ $this, 'handle_verify_recovery_code' ] );
    }

    // ── Registration flow ─────────────────────────────────────────────────────

    /**
     * Step 1: return PublicKeyCredentialCreationOptions to the browser.
     *
     * Security: nonce + capability already verified by AjaxManager pipeline.
     * Do NOT call $this->verify() here — it would cause a double nonce check.
     */
    public function handle_registration_options(): void {
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            AjaxResponseBuilder::make()
                ->success( __( 'Preview mode: registration options are not issued on the free plan.', 'ultraguard-security-suite' ) )
                ->addData( 'preview', true )
                ->addData( 'options', [] )
                ->send();
            return;
        }

        $user_id = get_current_user_id();
        $options = $this->manager()->generate_registration_options( $user_id );
        AjaxResponseBuilder::make()->success()->addData( 'options', $options )->send();
    }

    /**
     * Step 2: verify the authenticator response and store the credential.
     *
     * Security: nonce + capability already verified by AjaxManager pipeline.
     */
    public function handle_complete_registration(): void {
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            \UltraguardPro\support\UpgradeManager::soft_gate_response( 'all_users_2fa', __( 'register passkeys', 'ultraguard-security-suite' ) );
        }

        $user_id     = get_current_user_id();
        $response    = $this->decode_json_post( 'response' );
        $device_name = sanitize_text_field( wp_unslash( $_POST['device_name'] ?? '' ) );

        if ( ! $response ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid credential data.', 'ultraguard-security-suite' ) )->addNotification( __( 'Invalid credential data.', 'ultraguard-security-suite' ), 'error' )->send( 400 );
            return;
        }

        if ( $this->manager()->complete_registration( $user_id, $response, $device_name ) ) {
            AjaxResponseBuilder::make()
                ->success( __( 'Passkey registered successfully!', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Passkey registered successfully!', 'ultraguard-security-suite' ), 'success' )
                ->reloadDataTable( 'passkeys-credentials-table' )
                ->closeModal( 'passkeys-register-modal' )
                ->send();
        } else {
            AjaxResponseBuilder::make()
                ->error( __( 'Registration failed. Please try again.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Registration failed. Please try again.', 'ultraguard-security-suite' ), 'error' )
                ->send( 400 );
        }
    }

    // ── Authentication flow ───────────────────────────────────────────────────

    /**
     * Step 1: return PublicKeyCredentialRequestOptions.
     * Available to guests (login-page usernameless flow).
     *
     * Registered via add_public_action() — bypasses AjaxManager, so we
     * must verify the nonce ourselves here.
     */
    public function handle_authentication_options(): void {
        $this->verify_nonce_only();

        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            AjaxResponseBuilder::make()->error( __( 'Passkey authentication is available in preview mode only after upgrading.', 'ultraguard-security-suite' ) )->send( 403 );
            return;
        }

        $user_id = absint( $_POST['user_id'] ?? 0 );
        if ( $user_id && ! get_userdata( $user_id ) ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid user.', 'ultraguard-security-suite' ) )->addNotification( __( 'Invalid user.', 'ultraguard-security-suite' ), 'error' )->send( 400 );
            return;
        }

        $options = $this->manager()->generate_authentication_options( $user_id );
        AjaxResponseBuilder::make()->success()->addData( 'options', $options )->send();
    }

    /**
     * Step 2: verify assertion and create a WordPress session.
     * Available to guests.
     *
     * Registered via add_public_action() — bypasses AjaxManager, so we
     * must verify the nonce ourselves here.
     */
    public function handle_complete_authentication(): void {
        $this->verify_nonce_only();

        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            AjaxResponseBuilder::make()->error( __( 'Passkey authentication is available in preview mode only after upgrading.', 'ultraguard-security-suite' ) )->send( 403 );
            return;
        }

        $response = $this->decode_json_post( 'response' );
        if ( ! $response ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid credential data.', 'ultraguard-security-suite' ) )->addNotification( __( 'Invalid credential data.', 'ultraguard-security-suite' ), 'error' )->send( 400 );
            return;
        }

        $user_id = $this->manager()->complete_authentication( $response );
        if ( ! $user_id ) {
            AjaxResponseBuilder::make()->error( __( 'Authentication failed. Please try again.', 'ultraguard-security-suite' ) )->addNotification( __( 'Authentication failed. Please try again.', 'ultraguard-security-suite' ), 'error' )->send( 403 );
            return;
        }

        wp_set_current_user( $user_id );
        wp_set_auth_cookie( $user_id, true );
        $user_data = get_userdata( $user_id );
        do_action( 'wp_login', $user_data->user_login, $user_data );

        $redirect = apply_filters( 'login_redirect', admin_url(), '', $user_data );

        AjaxResponseBuilder::make()
            ->success( __( 'Authentication successful.', 'ultraguard-security-suite' ) )
            ->addData( 'redirect_url', esc_url_raw( $redirect ) )
            ->addRedirect( esc_url_raw( $redirect ), 200 )
            ->send();
    }

    // ── Credential management ─────────────────────────────────────────────────

    /**
     * Delete one of the current user's passkeys.
     *
     * Security: nonce + capability already verified by AjaxManager pipeline.
     */
    public function handle_delete_credential(): void {
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            \UltraguardPro\support\UpgradeManager::soft_gate_response( 'all_users_2fa', __( 'remove registered passkeys', 'ultraguard-security-suite' ) );
        }

        $user_id = get_current_user_id();
        $db_id   = absint( $_POST['credential_id'] ?? 0 );

        if ( ! $db_id ) {
            AjaxResponseBuilder::make()->error( __( 'Missing credential ID.', 'ultraguard-security-suite' ) )->addNotification( __( 'Missing credential ID.', 'ultraguard-security-suite' ), 'error' )->send( 400 );
            return;
        }

        if ( $this->manager()->delete_credential( $user_id, $db_id ) ) {
            AjaxResponseBuilder::make()
                ->success( __( 'Passkey removed.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Passkey removed.', 'ultraguard-security-suite' ), 'success' )
                ->addRemove( '#ug-row-' . $db_id, true, 300 )
                ->reloadDataTable( 'passkeys-credentials-table' )
                ->send();
        } else {
            AjaxResponseBuilder::make()
                ->error( __( 'Failed to delete passkey.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Failed to delete passkey.', 'ultraguard-security-suite' ), 'error' )
                ->send( 400 );
        }
    }

    /**
     * List credentials for the current user (used by the credentials data-table).
     *
     * Security: nonce + capability already verified by AjaxManager pipeline.
     */
    public function handle_list_credentials(): void {
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            AjaxResponseBuilder::make()
                ->success()
                ->addData( 'credentials', $this->preview_credentials() )
                ->addData( 'total', count( $this->preview_credentials() ) )
                ->addData( 'preview', true )
                ->send();
            return;
        }

        $user_id     = get_current_user_id();
        $credentials = $this->manager()->list_credentials( $user_id );

        $safe = array_map( function ( $cred ) {
            return [
                'id'          => (int) $cred['id'],
                'device_name' => esc_html( $cred['device_name'] ),
                'aaguid'      => esc_html( $cred['aaguid'] ?? '' ),
                'last_used'   => esc_html( $cred['last_used'] ? $this->format_date( $cred['last_used'] ) : __( 'Never', 'ultraguard-security-suite' ) ),
                'created_at'  => esc_html( $this->format_date( $cred['created_at'] ) ),
            ];
        }, $credentials );

        AjaxResponseBuilder::make()
            ->success()
            ->addData( 'credentials', $safe )
            ->addData( 'total', count( $safe ) )
            ->send();
    }

    // ── Recovery codes ────────────────────────────────────────────────────────

    /**
     * Generate 8 fresh single-use recovery codes and return them as a JSON download.
     *
     * Uses AjaxResponseBuilder::download() so the core action pipeline
     * (handleDownload in ultraguard-managers.js) triggers a browser file-save.
     * The codes are also returned in data.codes for the inline reminder JS.
     *
     * Security: nonce + capability already verified by AjaxManager pipeline.
     */
    public function handle_generate_recovery_codes(): void {
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            \UltraguardPro\support\UpgradeManager::soft_gate_response( 'all_users_2fa', __( 'generate recovery codes', 'ultraguard-security-suite' ) );
        }

        $user_id = get_current_user_id();

        if ( ! (bool) $this->plugin->settings->get_option( 'passkey_allow_recovery_codes', true ) ) {
            AjaxResponseBuilder::make()->error( __( 'Recovery codes are disabled by your administrator.', 'ultraguard-security-suite' ) )->addNotification( __( 'Recovery codes are disabled.', 'ultraguard-security-suite' ), 'error' )->send( 403 );
            return;
        }

        $codes = $this->manager()->generate_recovery_codes( $user_id );

        if ( empty( $codes ) ) {
            AjaxResponseBuilder::make()->error( __( 'Failed to generate recovery codes. Please try again.', 'ultraguard-security-suite' ) )->addNotification( __( 'Failed to generate recovery codes.', 'ultraguard-security-suite' ), 'error' )->send( 500 );
            return;
        }

        $json_content = wp_json_encode(
            [
                'generated_at'   => gmdate( 'Y-m-d H:i:s' ) . ' UTC',
                'site'           => get_bloginfo( 'name' ),
                'site_url'       => get_site_url(),
                'user_id'        => $user_id,
                'recovery_codes' => $codes,
                'warning'        => 'Store these codes somewhere safe. Each code can only be used ONCE. They will not be shown again.',
            ],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
        );

        $data_url = 'data:application/json;charset=utf-8,' . rawurlencode( $json_content );
        $filename  = sanitize_file_name( 'recovery-codes-' . gmdate( 'Y-m-d' ) . '.json' );

        AjaxResponseBuilder::make()
            ->success( __( 'Recovery codes generated! Your download will start automatically. Store the file somewhere safe — these codes will not be shown again.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'Recovery codes generated! Keep the downloaded file safe.', 'ultraguard-security-suite' ), 'success' )
            ->addData( 'codes', $codes )
            ->download( $data_url, $filename )
            ->send();
    }

    /**
     * Verify a recovery code on the login page.
     * Available to guests.
     *
     * Registered via add_public_action() — bypasses AjaxManager, so we
     * must verify the nonce ourselves here.
     */
    public function handle_verify_recovery_code(): void {
        $this->verify_nonce_only();

        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            AjaxResponseBuilder::make()->error( __( 'Recovery code login is unavailable until Passkeys is activated on a paid plan.', 'ultraguard-security-suite' ) )->send( 403 );
            return;
        }

        $user_id = absint( $_POST['user_id'] ?? 0 );
        $code    = sanitize_text_field( wp_unslash( $_POST['code'] ?? '' ) );

        if ( ! $user_id || ! $code ) {
            AjaxResponseBuilder::make()->error( __( 'Missing parameters.', 'ultraguard-security-suite' ) )->addNotification( __( 'Missing parameters.', 'ultraguard-security-suite' ), 'error' )->send( 400 );
            return;
        }

        if ( $this->manager()->verify_recovery_code( $user_id, $code ) ) {
            wp_set_current_user( $user_id );
            wp_set_auth_cookie( $user_id, true );
            $user_data = get_userdata( $user_id );
            $redirect  = apply_filters( 'login_redirect', admin_url(), '', $user_data );

            AjaxResponseBuilder::make()
                ->success( __( 'Recovery code accepted.', 'ultraguard-security-suite' ) )
                ->addData( 'redirect_url', esc_url_raw( $redirect ) )
                ->addRedirect( esc_url_raw( $redirect ), 300 )
                ->send();
        } else {
            AjaxResponseBuilder::make()->error( __( 'Invalid or already-used recovery code.', 'ultraguard-security-suite' ) )->addNotification( __( 'Invalid or already-used recovery code.', 'ultraguard-security-suite' ), 'error' )->send( 403 );
        }
    }

    // ── Settings ──────────────────────────────────────────────────────────────

    /**
     * Security: nonce + capability already verified by AjaxManager pipeline.
     */
    public function handle_save_settings(): void {
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            \UltraguardPro\support\UpgradeManager::soft_gate_response( 'all_users_2fa', __( 'save passkey settings', 'ultraguard-security-suite' ) );
        }

        $data = $this->sanitize_fields( (array) wp_unslash( $_POST ), $this->field_schema() );

        $ok = $this->plugin->settings->save_module_settings( 'passkeys', $data );

        if ( ! $ok ) {
            AjaxResponseBuilder::make()->error( __( 'Failed to save Passkeys settings.', 'ultraguard-security-suite' ) )->addNotification( __( 'Failed to save settings.', 'ultraguard-security-suite' ), 'error' )->send( 500 );
            return;
        }

        AjaxResponseBuilder::make()
            ->success( __( 'Passkeys settings saved.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'Settings saved!', 'ultraguard-security-suite' ), 'success' )
            ->send();
    }

    private function field_schema(): array {
        $allowed_roles = array_keys( wp_roles()->get_names() );
        return [
            'passkey_allow_recovery_codes' => [ 'type' => 'string', 'default' => "0" ],
            'passkey_required_roles'        => [
                'type'    => 'array',
                'options' => $allowed_roles,
                'default' => [],
            ],
        ];
    }

    /**
     * Lazily construct PasskeyManager on first use.
     *
     * Stored as a class property instead of a function-level static to avoid
     * the static variable persisting a stale instance across the full request
     * lifecycle. A function-level static is set the first time this method is
     * called — which can happen at register() time during boot(), before
     * PasskeysSchema has been registered with DatabaseTablesModule. Any
     * subsequent call would reuse that stale instance whose t('passkeys')
     * lookup returns null, causing a fatal on get_for_user().
     *
     * Using a class property means PasskeyManager is constructed fresh per
     * PasskeysAjax instance, always after boot() has completed and all
     * schemas are registered.
     */
    private function manager(): PasskeyManager {
        if ( null === $this->manager_instance ) {
            $this->manager_instance = new PasskeyManager( $this->plugin );
        }
        return $this->manager_instance;
    }

    /**
     * Decode a JSON string stored in a POST field.
     * Clients may send raw JSON or a base64-encoded JSON string.
     */
    private function decode_json_post( string $key ): ?array {
        $raw = wp_unslash( $_POST[ $key ] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Raw JSON is decoded and validated below before use.
        if ( empty( $raw ) ) { return null; }

        if ( $raw[0] !== '{' ) {
            $raw = base64_decode( $raw );
        }

        $decoded = json_decode( $raw, true );
        return ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) ? $decoded : null;
    }

    private function format_date( string $utc_date ): string {
        return get_date_from_gmt( $utc_date, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );
    }

    private function preview_credentials(): array {
        return [
            [
                'id'          => 21,
                'device_name' => 'MacBook Pro Touch ID',
                'aaguid'      => 'adce0002-35bc-c60a-648b-0b25f1f05503',
                'last_used'   => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ),
                'created_at'  => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * 8 ),
            ],
            [
                'id'          => 22,
                'device_name' => 'YubiKey 5 NFC',
                'aaguid'      => 'fa2b99dc-9e39-4257-8f92-4a30d23c4118',
                'last_used'   => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * 4 ),
                'created_at'  => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * 19 ),
            ],
        ];
    }
}

// phpcs:enable
