<?php

declare(strict_types=1);

namespace SecurWP\Modules\Hardening\Tweaks;

use SecurWP\Includes\IpDetector;
use SecurWP\Includes\Module_Loader;
use SecurWP\Modules\Firewall\FirewallLogger;
use SecurWP\Modules\Hardening\Hardening;
use SecurWP\Modules\Hardening\LockoutRegistry;

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


class WordPressTweaks {
    private const XMLRPC_MULTIAUTH_ERROR = 'securwp_xmlrpc_multiauth_blocked';

    private const CORE_REST_USERS_ROUTE = '/wp/v2/users';
    private array $settings;

    /** @var array{string,string}|null First XML-RPC credentials seen in this request. */
    private ?array $first_xmlrpc_credentials = null;

    private const CORE_REGISTRATION_ENUMERATION_CODES = [ 'username_exists', 'email_exists' ];
    private const WOOCOMMERCE_REGISTRATION_ENUMERATION_CODES = [ 'registration-error-username-exists', 'registration-error-email-exists' ];

    public function __construct( array $settings, Module_Loader $loader ) {
        $this->settings = $settings;
        $this->apply_tweaks( $loader );
    }

    /**
     * Read a scalar request value, unslash it, and sanitize it for downstream checks.
     *
     * @param array<string,mixed> $source
     */
    private static function read_request_scalar( array $source, string $key ): string {
        if ( ! array_key_exists( $key, $source ) || ! is_scalar( $source[ $key ] ) ) {
            return '';
        }

        return sanitize_text_field( wp_unslash( (string) $source[ $key ] ) );
    }

    private function apply_tweaks( Module_Loader $loader ) {
        //    head and all feed formats, and replaces the WP core version in
        //    script/style ?ver= query strings with a per-site hash so cache
        //    busting still works while the exact version is hidden.
        //    Plugin/theme versions are left untouched.
        if ( ! empty( $this->settings['hide_wp_version'] ) ) {
            remove_action('wp_head', 'wp_generator');
            $loader->add_filter( 'the_generator', null, '__return_empty_string', 10, 0 );
            $loader->add_filter( 'style_loader_src', $this, 'hash_version_in_src', 10, 1 );
            $loader->add_filter( 'script_loader_src', $this, 'hash_version_in_src', 10, 1 );
        }

        $xmlrpc_mode = $this->settings['disable_xmlrpc'] ?? Hardening::get_default_settings()['disable_xmlrpc'];

        if ( true === $xmlrpc_mode || '1' === $xmlrpc_mode ) {
            $xmlrpc_mode = 'disable';
        }

        if ( 'disable' === $xmlrpc_mode ) {
            // Full disable: server-level block + PHP filter + remove all discovery.
            $loader->add_filter( 'xmlrpc_enabled', null, '__return_false', 10, 0 );
            $loader->add_filter( 'wp_headers', $this, 'remove_x_pingback_header', 11, 1 );
            $loader->add_filter( 'bloginfo_url', $this, 'remove_pingback_url', 10, 2 );

            // Remove dangerous methods even if another plugin re-enables XML-RPC.
            $loader->add_filter( 'xmlrpc_methods', $this, 'disable_xmlrpc_methods', 10, 1 );
        } elseif ( 'disable_pingbacks' === $xmlrpc_mode ) {
            // Keep XML-RPC working (Jetpack, mobile apps) but remove pingback
            // methods that enable DDoS amplification and SSRF.
            $loader->add_filter( 'xmlrpc_methods', $this, 'disable_pingback_methods', 10, 1 );
            $loader->add_filter( 'wp_headers', $this, 'remove_x_pingback_header', 11, 1 );
            $loader->add_filter( 'bloginfo_url', $this, 'remove_pingback_url', 10, 2 );
        }

        // Block multiple credential pairs in a single XML-RPC request.
        // Prevents system.multicall brute-force amplification (500+ login
        // attempts packed into one HTTP request). Always active during
        // XML-RPC requests regardless of mode - zero false-positive risk
        // since legitimate clients only send one credential set per request.
        if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
            $loader->add_filter( 'authenticate', $this, 'block_xmlrpc_multiauth', 0, 3 );
            $loader->add_filter( 'xmlrpc_login_error', $this, 'filter_xmlrpc_login_error', 10, 2 );
        }

        // Uses rest_dispatch_request (fires after auth) instead of
        // rest_authentication_errors (fires before auth). Only restricts
        // /wp/ namespace - all other namespaces (wc/, oembed/, jetpack/,
        // contact-form-7/, etc.) pass through automatically.
        if ( ! empty( $this->settings['restrict_rest_api'] ) ) {
            $loader->add_filter( 'rest_dispatch_request', $this, 'filter_rest_dispatch_request', 10, 4 );
        }

        //    login form, lost-password form, and registration form.
        if ( ! empty( $this->settings['hide_login_errors'] ) ) {
            // a) Login form: replace detailed error with generic message.
            $loader->add_filter( 'login_errors', $this, 'hide_login_errors', 10, 1 );
            // b) Preserve SecurWP lockout messaging even if a theme or another
            //    plugin adds a later generic login_errors filter.
            $loader->add_filter( 'login_errors', $this, 'preserve_lockout_login_errors', 9999, 1 );

            // c) Lost-password form: redirect non-existent users to the same
            //    success page that existing users see, so the attacker cannot
            //    distinguish "user found" from "user not found".
            //    Priority 99 so other plugins (CAPTCHA, rate-limiters) add
            //    their errors first - we only suppress enumeration errors.
            $loader->add_action( 'lostpassword_post', $this, 'suppress_lostpassword_enumeration', 99, 2 );

            // d) Registration form: replace "username exists" / "email exists"
            //    with a generic error that does not reveal which field conflicts.
            //    Priority 99 so other plugins add their errors first.
            $loader->add_filter( 'registration_errors', $this, 'hide_registration_enumeration', 99, 3 );

            if ( $this->has_woocommerce() ) {
                $loader->add_filter( 'woocommerce_registration_errors', $this, 'hide_woocommerce_registration_enumeration', 99, 3 );
            }
        }

        if ( ! empty( $this->settings['block_user_enumeration'] ) ) {
            // Priority 0: fire before redirect_canonical (priority 10) so
            // ?author=N never redirects to /author/slug/ (which leaks the username
            // in the Location header).
            $loader->add_action( 'template_redirect', $this, 'block_user_enumeration', 0, 0 );
            $loader->add_filter( 'rest_endpoints', $this, 'remove_user_rest_endpoints', 10, 1 );
            $loader->add_filter( 'rest_request_before_callbacks', $this, 'guard_user_rest_request', 10, 3 );
            $loader->add_filter( 'oembed_response_data', $this, 'filter_oembed_author', 10, 1 );
            // Disable the users sitemap provider (WP 5.5+ exposes /wp-sitemap-users-1.xml).
            $loader->add_filter( 'wp_sitemaps_add_provider', $this, 'disable_user_sitemap', 10, 2 );
            // Strip author identity from RSS/Atom feeds (dc:creator, <author>).
            $loader->add_filter( 'the_author', $this, 'filter_feed_author', 10, 1 );
            $loader->add_filter( 'author_link', $this, 'filter_feed_author_link', 10, 3 );
        }

        if ( ! empty( $this->settings['disable_application_passwords'] ) ) {
            $loader->add_filter( 'wp_is_application_passwords_available', null, '__return_false', 10, 0 );
        }

        if ( ! empty( $this->settings['force_unique_nicename'] ) ) {
            $loader->add_action( 'user_profile_update_errors', $this, 'validate_unique_nicename', 10, 3 );
            $loader->add_filter( 'wp_pre_insert_user_data', $this, 'enforce_unique_user_nicename_data', 10, 4 );
        }

        if ( ! empty( $this->settings['login_type_restriction'] ) && $this->settings['login_type_restriction'] !== 'default' ) {
            $loader->add_action( 'login_init', $this, 'setup_login_type_filters' );
            $loader->add_filter( 'authenticate', $this, 'restrict_login_authenticate', 0, 3 );
        }

        // Prefer the persisted wp-config.php rule when SecurWP can write it.
        // This runtime define remains as the fallback path so core checks
        // (editor menu items, file-edit guards, etc.) still lock correctly on
        // read-only hosts or when Write to Files is disabled.
        if ( ! empty( $this->settings['disable_file_editor'] ) ) {
            if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) {
                define( 'DISALLOW_FILE_EDIT', true );
            }
        }

        if ( ! empty( $this->settings['disable_extra_headers'] ) ) {
             remove_action( 'wp_head', 'rsd_link' );                          // EditURI / RSD
             remove_action( 'wp_head', 'wlwmanifest_link' );                  // Windows Live Writer
             remove_action( 'wp_head', 'wp_shortlink_wp_head' );              // Shortlink header
             remove_action( 'template_redirect', 'wp_shortlink_header', 11 ); // Shortlink HTTP header
             remove_action( 'wp_head', 'feed_links', 2 );                     // General RSS feed links
             remove_action( 'wp_head', 'feed_links_extra', 3 );               // Extra RSS feed links (categories, etc.)
        }

        if ( ! empty( $this->settings['enable_strong_passwords'] ) ) {
             $loader->add_action( 'user_profile_update_errors', $this, 'validate_strong_password', 10, 3 );
             $loader->add_action( 'validate_password_reset', $this, 'validate_password_reset', 10, 2 );
             $loader->add_filter( 'registration_errors', $this, 'validate_registration_password', 10, 3 );
             $loader->add_filter( 'random_password', $this, 'filter_random_password', 10, 2 );
        }

        if ( $this->has_woocommerce() && ( ! empty( $this->settings['enable_strong_passwords'] ) || ! empty( $this->settings['check_breached_passwords'] ) ) ) {
             $loader->add_filter( 'woocommerce_process_registration_errors', $this, 'validate_woocommerce_registration_password', 10, 4 );
             $loader->add_action( 'woocommerce_after_checkout_validation', $this, 'validate_woocommerce_checkout_account_password', 10, 2 );
        }

        if ( ! empty( $this->settings['prevent_password_reuse'] ) ) {
             $loader->add_action( 'user_profile_update_errors', $this, 'check_password_reuse', 9, 3 );
             $loader->add_action( 'validate_password_reset', $this, 'check_password_reuse_on_reset', 9, 2 );
        }
    }


    public function remove_x_pingback_header( $headers ) {
        if ( isset( $headers['X-Pingback'] ) ) {
            unset( $headers['X-Pingback'] );
        }
        return $headers;
    }

    /**
     * Remove dangerous XML-RPC methods.
     *
     * system.multicall allows attackers to test hundreds of passwords
     * in a single HTTP request, bypassing rate limiting. Pingback methods
     * enable DDoS amplification and SSRF attacks.
     *
     * @param array $methods Available XML-RPC methods.
     * @return array Filtered methods.
     */
    public function disable_xmlrpc_methods( array $methods ): array {
        // Remove multicall (brute-force amplification).
        unset( $methods['system.multicall'] );
        unset( $methods['system.listMethods'] );
        unset( $methods['system.getCapabilities'] );

        // Remove pingback methods (DDoS amplification + SSRF).
        unset( $methods['pingback.ping'] );
        unset( $methods['pingback.extensions.getPingbacks'] );

        return $methods;
    }

    /**
     * Remove only pingback methods - keeps the rest of XML-RPC working
     * for Jetpack, WordPress mobile app, and blog clients.
     */
    public function disable_pingback_methods( array $methods ): array {
        unset( $methods['pingback.ping'] );
        unset( $methods['pingback.extensions.getPingbacks'] );
        return $methods;
    }

    /**
     * Remove the pingback URL from the HTML <link> tag output by bloginfo().
     */
    public function remove_pingback_url( $output, $show ) {
        if ( 'pingback_url' === $show ) {
            return '';
        }
        return $output;
    }

    /**
     * Block multiple authentication attempts in a single XML-RPC request.
     *
     * Stores the first username+password pair seen during this request.
     * If any subsequent authenticate call uses DIFFERENT credentials,
     * the request is terminated immediately. This prevents attackers
     * from packing hundreds of credential pairs into a single
     * system.multicall request to bypass per-request rate limiting.
     *
     * Zero false-positive risk: legitimate XML-RPC clients (Jetpack,
     * mobile app, blog editors) only send one credential set per request.
     *
     * @param mixed  $user     User object, WP_Error, or null.
     * @param string $username Attempted username.
     * @param string $password Attempted password.
     * @return mixed Passthrough on first attempt; WP_Error on credential switch.
     */
    public function block_xmlrpc_multiauth( $user, $username, $password ) {
        if ( null === $this->first_xmlrpc_credentials ) {
            // First authentication attempt in this request - allow it.
            $this->first_xmlrpc_credentials = [ $username, $password ];
            return $user;
        }

        // Same credentials as the first attempt - allow (normal retry / multicall
        // calling multiple methods with the same login).
        if ( $username === $this->first_xmlrpc_credentials[0]
            && $password === $this->first_xmlrpc_credentials[1] ) {
            return $user;
        }

        // Different credentials = brute-force amplification attempt.
        do_action( 'securwp_firewall_block', [
            'site_url'    => home_url(),
            'ip'          => IpDetector::get_ip(),
            'http_status' => 403,
            'attack_type' => 'XML-RPC',
            'request_uri' => IpDetector::get_server_value( 'REQUEST_URI' ),
        ] );

        return new \WP_Error(
            self::XMLRPC_MULTIAUTH_ERROR,
            __( 'XML-RPC authentication request rejected.', 'securwp' )
        );
    }

    /**
     * Convert SecurWP XML-RPC hardening failures into an XML-RPC fault response.
     *
     * @param \IXR_Error $error Core XML-RPC login error.
     * @param mixed      $user  Authentication result from wp_authenticate().
     * @return \IXR_Error
     */
    public function filter_xmlrpc_login_error( $error, $user ) {
        if ( ! is_wp_error( $user ) || $user->get_error_code() !== self::XMLRPC_MULTIAUTH_ERROR ) {
            return $error;
        }

        return new \IXR_Error( 403, __( 'XML-RPC authentication request rejected.', 'securwp' ) );
    }


    /**
     * Replace the WordPress core version in script/style ?ver= query
     * strings with a per-site hash.
     *
     * Only replaces values that exactly match the current $wp_version.
     * Plugin and theme versions are left untouched, so their cache
     * busting continues to work correctly.
     *
     * @param string $src Script or stylesheet URL.
     * @return string URL with WP version hashed.
     */
    public function hash_version_in_src( $src ) {
        if ( ! is_string( $src ) ) {
            return $src;
        }

        global $wp_version;

        // Cache the hash for the duration of the request. wp_hash() calls
        // hash_hmac() which is cheap but runs once per enqueued asset.
        static $hashed_version = null;
        if ( null === $hashed_version ) {
            $hashed_version = wp_hash( $wp_version );
        }

        $result = preg_replace_callback(
            '/([&;?]ver)=([^&]+)/',
            static function ( $matches ) use ( $wp_version, $hashed_version ) {
                return $matches[2] === $wp_version
                    ? $matches[1] . '=' . $hashed_version
                    : $matches[0];
            },
            $src
        );

        // preg_replace_callback returns null on PCRE error (e.g. invalid
        // UTF-8 in the URL). Fall back to the original URL rather than
        // breaking the asset.
        return $result ?? $src;
    }


    /**
     * Replace detailed login errors with a generic message.
     *
     * WordPress core returns different messages for "invalid username",
     * "incorrect password", and "invalid email", allowing attackers to
     * enumerate valid accounts. This replaces them all with one message.
     */
    public function hide_login_errors( $error ) {
        $lockout_error = $this->get_active_login_lockout_error_message();
        if ( $lockout_error !== '' ) {
            return $lockout_error;
        }

        return __( 'Authentication failed. Please check your credentials and try again.', 'securwp' );
    }

    /**
     * Preserve SecurWP lockout messaging even when a later filter replaces
     * the login error with a theme-specific generic string.
     *
     * @param string $error Current login error HTML.
     */
    public function preserve_lockout_login_errors( $error ) {
        $lockout_error = $this->get_active_login_lockout_error_message();
        if ( $lockout_error !== '' ) {
            return $lockout_error;
        }

        return $error;
    }

    /**
     * Build the lockout-specific login error message for fallback form renders.
     */
    private function get_active_login_lockout_error_message(): string {
        $context = $this->get_active_login_lockout_context();
        if ( null === $context ) {
            return '';
        }

        $time_remaining = (int) $context['remaining'];
        if ( $context['type'] === 'ip' ) {
            return sprintf(
                /* translators: %s: human-readable time remaining until the IP lockout expires. */
                __( '<strong>ERROR</strong>: Too many failed login attempts from this IP. Please try again in %s.', 'securwp' ),
                human_time_diff( time(), time() + $time_remaining )
            );
        }

        return sprintf(
            /* translators: %s: human-readable time remaining until the account lockout expires. */
            __( '<strong>ERROR</strong>: This account has failed too many logins. Please try again in %s.', 'securwp' ),
            human_time_diff( time(), time() + $time_remaining )
        );
    }

    /**
     * Resolve the active login lockout context for the current visitor.
     *
     * @return array{type:string,remaining:int}|null
     */
    private function get_active_login_lockout_context(): ?array {
        $lockout_duration = absint( $this->settings['limit_login_attempts_lockout'] ?? 0 );
        if ( $lockout_duration <= 0 ) {
            return null;
        }

        $ip = IpDetector::get_ip();
        if ( $ip !== '' ) {
            $lockout_start_time = get_transient( 'securwp_lockout_' . md5( $ip ) );
            if ( false !== $lockout_start_time ) {
                $time_remaining = ( (int) $lockout_start_time + $lockout_duration ) - time();
                if ( $time_remaining > 0 ) {
                    return [
                        'type'      => 'ip',
                        'remaining' => $time_remaining,
                    ];
                }

                delete_transient( 'securwp_lockout_' . md5( $ip ) );
                LockoutRegistry::clear_ip_lockout( $ip );
            }
        }

        foreach ( Hardening::get_user_lockout_key_candidates( $this->get_submitted_login_identifier() ) as $username ) {
            $lockout_start_time = get_transient( 'securwp_lockout_user_' . md5( $username ) );
            if ( false !== $lockout_start_time ) {
                $time_remaining = ( (int) $lockout_start_time + $lockout_duration ) - time();
                if ( $time_remaining > 0 ) {
                    return [
                        'type'      => 'user',
                        'remaining' => $time_remaining,
                    ];
                }

                delete_transient( 'securwp_lockout_user_' . md5( $username ) );
                LockoutRegistry::clear_user_lockouts( [ $username ] );
            }
        }

        return null;
    }

    /**
     * Resolve the submitted login identifier from the current request payload.
     */
    private function get_submitted_login_identifier(): string {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        $candidates = [
            self::read_request_scalar( $_POST, 'log' ),
            self::read_request_scalar( $_POST, 'username' ),
            self::read_request_scalar( $_POST, 'user_login' ),
            self::read_request_scalar( $_REQUEST, 'log' ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- anonymous login-form inspection only; no state change occurs here.
            self::read_request_scalar( $_REQUEST, 'username' ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- anonymous login-form inspection only; no state change occurs here.
            self::read_request_scalar( $_REQUEST, 'user_login' ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- anonymous login-form inspection only; no state change occurs here.
        ];
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        foreach ( $candidates as $candidate ) {
            if ( ! is_scalar( $candidate ) ) {
                continue;
            }

            $normalized = Hardening::sanitize_login_identifier_input( $candidate );
            if ( $normalized !== '' ) {
                return $normalized;
            }
        }

        return '';
    }

    /**
     * Prevent user-existence enumeration via the lost-password form.
     *
     * When a non-existent username or email is entered, WordPress shows
     * an error ("There is no account with that username or email address")
     * while a valid account gets a redirect to "check your email".
     * This binary difference lets attackers confirm valid accounts.
     *
     * Fix: if the user was not found and the only errors are WordPress
     * core enumeration codes, redirect to the same success page.
     * The attacker sees identical behaviour regardless.
     *
     * Non-enumeration errors (CAPTCHA failures, rate-limit blocks from
     * other plugins) are left intact so those plugins keep working.
     *
     * Hooked to lostpassword_post at priority 99 - runs after other
     * plugins have added their own errors.
     *
     * @param \WP_Error      $errors    Errors collected so far.
     * @param \WP_User|false $user_data User if found, false otherwise.
     */
    public function suppress_lostpassword_enumeration( \WP_Error $errors, $user_data ): void {
        // User exists - let the normal flow continue.
        if ( $user_data instanceof \WP_User ) {
            return;
        }

        // Only suppress WordPress core enumeration errors.
        // If another plugin added non-enumeration errors (CAPTCHA,
        // rate-limiting, etc.), respect them and let them be displayed.
        $enumeration_codes = [ 'invalid_email', 'invalidcombo' ];
        $other_errors      = array_diff( $errors->get_error_codes(), $enumeration_codes );

        if ( ! empty( $other_errors ) ) {
            return;
        }

        $redirect_to = $this->get_masked_lostpassword_redirect();

        wp_safe_redirect( $redirect_to );
        exit;
    }

    /**
     * Prevent user-existence enumeration via the registration form.
     *
     * WordPress core returns "This username is already registered" and
     * "This email address is already registered", allowing attackers to
     * verify valid accounts. This replaces those specific errors with
     * a single generic message.
     *
     * Other registration errors (empty fields, invalid email format,
     * breached passwords, CAPTCHA, etc.) are left intact.
     *
     * @param \WP_Error $errors               Registration errors.
     * @param string    $sanitized_user_login  Sanitized username.
     * @param string    $user_email            User email.
     * @return \WP_Error Filtered errors.
     */
    public function hide_registration_enumeration( \WP_Error $errors, string $sanitized_user_login, string $user_email ): \WP_Error {
        return $this->mask_registration_enumeration_errors(
            $errors,
            self::CORE_REGISTRATION_ENUMERATION_CODES,
            __( '<strong>Error:</strong> Registration could not be completed with the provided information. Please try a different username or email address.', 'securwp' )
        );
    }

    /**
     * Prevent user-existence enumeration via WooCommerce registration.
     *
     * WooCommerce uses its own error codes for duplicate username/email
     * conflicts. This mirrors the core WordPress masking behavior without
     * altering unrelated WooCommerce validation errors.
     */
    public function hide_woocommerce_registration_enumeration( \WP_Error $errors, string $sanitized_user_login, string $user_email ): \WP_Error {
        return $this->mask_registration_enumeration_errors(
            $errors,
            self::WOOCOMMERCE_REGISTRATION_ENUMERATION_CODES,
            __( 'Registration could not be completed with the provided information. Please try a different username or email address.', 'securwp' )
        );
    }

    /**
     * Capability map for simple /wp/v2/ endpoints that can be restricted
     * without inspecting controller-specific permission callbacks.
     *
     * @return array<string,string>
     */
    public static function get_restricted_rest_endpoint_capabilities(): array {
        return [
            'users'    => 'list_users',
            'comments' => 'moderate_comments',
            'statuses' => 'edit_posts',
            'types'    => 'edit_posts',
        ];
    }

    /**
     * Controllers whose permission callbacks should remain the source of truth.
     *
     * Keep this list explicit and filterable so compatibility exceptions are
     * deliberate instead of automatically widening as WordPress adds new
     * controller subclasses.
     *
     * @return array<int,string>
     */
    public static function get_rest_controller_permission_allowlist(): array {
        $classes = [
            'WP_REST_Global_Styles_Controller',
            'WP_REST_Global_Styles_Revisions_Controller',
            'WP_REST_Menu_Items_Controller',
            'WP_REST_Font_Families_Controller',
            'WP_REST_Font_Faces_Controller',
        ];

        /**
         * Filter core /wp/ REST controller classes that SecurWP should defer
         * to after WordPress has already passed permission_callback.
         *
         * @param string[] $classes Controller class names without a leading slash.
         */
        $classes = apply_filters( 'securwp_rest_api_controller_permission_allowlist', $classes );

        if ( ! is_array( $classes ) ) {
            return [];
        }

        $normalized = [];
        foreach ( $classes as $class ) {
            if ( ! is_string( $class ) ) {
                continue;
            }

            $class = ltrim( trim( $class ), '\\' );
            if ( $class !== '' ) {
                $normalized[] = $class;
            }
        }

        return array_values( array_unique( $normalized ) );
    }

    /**
     * Determine whether SecurWP should defer to the route controller's own
     * permission model instead of flattening access to a generic capability.
     *
     * rest_dispatch_request runs after permission_callback, so returning true
     * here preserves WordPress core as the source of truth for controllers
     * with custom access rules and prevents false 403s in the editor.
     */
    public static function should_defer_rest_controller_permission_check( object $controller ): bool {
        $class = self::rest_controller_class( $controller );

        return in_array( $class, self::get_rest_controller_permission_allowlist(), true );
    }

    private static function rest_controller_class( object $controller ): string {
        return ltrim( get_class( $controller ), '\\' );
    }

    /**
     * Restrict WordPress core REST API endpoints based on user capabilities.
     *
     * Only restricts routes within the /wp/ namespace. All other namespaces
     * (WooCommerce, Contact Form 7, Jetpack, oEmbed, SecurWP, etc.)
     * pass through automatically, eliminating ecosystem conflicts.
     *
     * Uses rest_dispatch_request (fires after authentication is resolved)
     * instead of rest_authentication_errors (fires before auth). This
     * ensures Application Passwords, cookie auth, and other auth methods
     * are properly processed before capability checks run.
     *
     * @param mixed            $result  Dispatch result, used if not null.
     * @param \WP_REST_Request $request Request used to generate the response.
     * @param string           $route   Route matched for the request.
     * @param array            $handler Route handler used for the request.
     * @return mixed|\WP_Error
     */
    public function filter_rest_dispatch_request( $result, $request, $route, $handler ) {
        $clean_route = strtolower( $request->get_route() );
        $parts       = explode( '/', trim( $clean_route, '/' ) );

        // Only restrict /wp/ namespace. All other namespaces (wc/, oembed/,
        // contact-form-7/, jetpack/, securwp/, elementor/, etc.) pass through.
        if ( 'wp' !== ( $parts[0] ?? '' ) ) {
            return $result;
        }

        // Allow REST API index/discovery endpoints (/wp-json/, /wp/v2/).
        // These are needed by WP-CLI remote, mobile app, and API clients.
        if ( ! isset( $parts[2] ) ) {
            return $result;
        }

        // /wp/v2/settings has its own strong capability checks (manage_options).
        if ( 'settings' === $parts[2] ) {
            return $result;
        }

        // /wp/v2/users/me has its own permissions checks in WordPress core.
        if ( 'users' === $parts[2] && isset( $parts[3] ) && 'me' === $parts[3] ) {
            return $result;
        }

        /**
         * Filter additional /wp/v2/ route segments to allow without capability checks.
         *
         * Useful for plugins that register endpoints under the /wp/ namespace
         * and need public access. Values are the third segment of the route
         * (e.g. 'media' would allow /wp/v2/media).
         *
         * @param string[] $allowed_routes Route segments to allow.
         */
        // Merge user-configured allowed routes from settings with filter.
        $user_routes    = $this->settings['restrict_rest_api_allowed_routes'] ?? [];
        $allowed_routes = apply_filters( 'securwp_rest_api_allowed_routes', $user_routes );
        if ( in_array( $parts[2], $allowed_routes, true ) ) {
            return $result;
        }

        $code = function_exists( 'rest_authorization_required_code' )
            ? rest_authorization_required_code()
            : ( is_user_logged_in() ? 403 : 401 );

        $error = new \WP_Error(
            'securwp_rest_api_restricted',
            __( 'You do not have sufficient permission to access this endpoint. Access is restricted by SecurWP.', 'securwp' ),
            [ 'status' => $code ]
        );

        // Simple capability checks for known endpoint types.
        // Note: /search is intentionally NOT restricted - it only returns
        // titles and URLs of publicly published content (same as Google).
        $endpoint_caps = self::get_restricted_rest_endpoint_capabilities();

        foreach ( $endpoint_caps as $endpoint => $cap ) {
            if ( $endpoint === $parts[2] ) {
                if ( current_user_can( $cap ) ) {
                    return $result;
                }
                FirewallLogger::log( [ 'ip' => \SecurWP\Includes\IpDetector::get_ip(), 'attack_type' => 'REST API Block', 'request_uri' => $request->get_route() ] );
                return $error;
            }
        }

        // Taxonomies list: filter individual items based on assign_terms capability.
        if ( 'taxonomies' === $parts[2] ) {
            add_filter( 'rest_request_after_callbacks', [ $this, 'filter_taxonomies_response' ], 10, 3 );
            return $result;
        }

        // Check handler callback object for post-type and taxonomy controllers.
        if ( is_array( $handler['callback'] ?? null )
            && isset( $handler['callback'][0] )
            && is_object( $handler['callback'][0] )
        ) {
            $controller = $handler['callback'][0];

            // The route permission_callback has already approved this request.
            // Do not override controllers that implement their own access model
            // with a generic post-type cap check.
            if ( self::should_defer_rest_controller_permission_check( $controller ) ) {
                return $result;
            }

            // Terms controller: check edit_terms capability for the specific taxonomy.
            if ( $controller instanceof \WP_REST_Terms_Controller ) {
                foreach ( get_taxonomies( [], 'objects' ) as $taxonomy ) {
                    if ( ( $taxonomy->rest_base ?? '' ) === $parts[2] || $taxonomy->name === $parts[2] ) {
                        if ( current_user_can( $taxonomy->cap->edit_terms ) ) {
                            return $result;
                        }
                        FirewallLogger::log( [ 'ip' => \SecurWP\Includes\IpDetector::get_ip(), 'attack_type' => 'REST API Block', 'request_uri' => $request->get_route() ] );
                        return $error;
                    }
                }
                return $result;
            }

            // Posts controller: check edit_posts capability for the specific post type.
            if ( $controller instanceof \WP_REST_Posts_Controller ) {
                foreach ( get_post_types( [], 'objects' ) as $post_type ) {
                    if ( ( $post_type->rest_base ?? '' ) === $parts[2] || $post_type->name === $parts[2] ) {
                        if ( current_user_can( $post_type->cap->edit_posts ) ) {
                            return $result;
                        }
                        FirewallLogger::log( [ 'ip' => \SecurWP\Includes\IpDetector::get_ip(), 'attack_type' => 'REST API Block', 'request_uri' => $request->get_route() ] );
                        return $error;
                    }
                }
                return $result;
            }
        }

        // Unknown endpoint type within /wp/v2/ - default-deny for
        // unauthenticated users. Logged-in users pass through so
        // WordPress core caps still apply. This prevents unrecognized
        // controllers from being publicly accessible.
        if ( is_user_logged_in() ) {
            return $result;
        }
        FirewallLogger::log( [ 'ip' => \SecurWP\Includes\IpDetector::get_ip(), 'attack_type' => 'REST API Block', 'request_uri' => $request->get_route() ] );
        return $error;
    }

    /**
     * Filter the /wp/v2/taxonomies response to exclude taxonomies
     * the user does not have assign_terms permission for.
     *
     * For the collection endpoint, strips individual taxonomy entries.
     * For a single taxonomy endpoint, returns an error if unauthorized.
     *
     * @param \WP_REST_Response|\WP_Error $response Response object.
     * @param array                       $handler  Route handler.
     * @param \WP_REST_Request            $request  Request object.
     * @return \WP_REST_Response|\WP_Error
     */
    public function filter_taxonomies_response( $response, $handler, $request ) {
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $clean_route = strtolower( $request->get_route() );
        $parts       = explode( '/', trim( $clean_route, '/' ) );

        if ( 'wp' !== ( $parts[0] ?? '' ) || ! isset( $parts[2] ) || 'taxonomies' !== $parts[2] ) {
            return $response;
        }

        $code = function_exists( 'rest_authorization_required_code' )
            ? rest_authorization_required_code()
            : ( is_user_logged_in() ? 403 : 401 );

        $error = new \WP_Error(
            'securwp_rest_api_restricted',
            __( 'You do not have sufficient permission to access this endpoint. Access is restricted by SecurWP.', 'securwp' ),
            [ 'status' => $code ]
        );

        // Single taxonomy request: /wp/v2/taxonomies/{slug}
        if ( isset( $parts[3] ) ) {
            $taxonomy = get_taxonomy( $parts[3] );
            if ( ! $taxonomy ) {
                return $response;
            }
            if ( current_user_can( $taxonomy->cap->assign_terms ) ) {
                return $response;
            }
            FirewallLogger::log( [ 'ip' => \SecurWP\Includes\IpDetector::get_ip(), 'attack_type' => 'REST API Block', 'request_uri' => $request->get_route() ] );
            return $error;
        }

        // Collection request: filter out taxonomies the user can't assign terms for.
        $data = $response->get_data();

        foreach ( $data as $i => $taxonomy_data ) {
            if ( ! isset( $taxonomy_data['slug'] ) ) {
                continue;
            }
            $taxonomy = get_taxonomy( $taxonomy_data['slug'] );
            if ( ! $taxonomy || ! current_user_can( $taxonomy->cap->assign_terms ) ) {
                unset( $data[ $i ] );
            }
        }

        if ( empty( $data ) ) {
            FirewallLogger::log( [ 'ip' => \SecurWP\Includes\IpDetector::get_ip(), 'attack_type' => 'REST API Block', 'request_uri' => $request->get_route() ] );
            return $error;
        }

        $response->set_data( array_values( $data ) );
        return $response;
    }


    /**
     * Block user enumeration via author archives.
     *
     * Covers all enumeration vectors that resolve to an author archive:
     *   - ?author=N   (numeric ID query parameter)
     *   - /author/slug/ (pretty permalink author archive)
     *
     * Hooked at template_redirect priority 0 so it fires BEFORE
     * redirect_canonical (priority 10). Without this, WordPress would
     * 301-redirect ?author=1 to /author/admin/ and leak the username
     * in the Location header before we ever get a chance to block.
     */
    public function block_user_enumeration() {
        if ( is_admin() || current_user_can( 'list_users' ) ) {
            return;
        }

        // is_author() catches both ?author=N (after WP resolves the ID to
        // a user) and /author/slug/ (resolved via rewrite rules).
        if ( is_author() ) {
            $this->render_author_enumeration_not_found();
        }

        // Belt-and-suspenders: catch the raw query parameter in edge cases
        // where is_author() may not be set (e.g. non-existent user IDs that
        // WordPress does not resolve into an author query).
        if ( $this->has_author_enumeration_request() ) {
            $this->render_author_enumeration_not_found();
        }
    }

    /**
     * Detect raw author-archive probes before WordPress resolves them into a query.
     *
     * Matches scalar numeric probes like `?author=1`, mixed invalid variants like
     * `?author=1foo`, and malformed array forms that some scanners use to probe
     * enumeration handling.
     */
    private function has_author_enumeration_request(): bool {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- public routing inspection for anonymous and low-privilege author-enumeration probes.
        $candidates = [];

        if ( isset( $_GET['author'] ) ) {
            $candidates[] = self::read_request_scalar( $_GET, 'author' );
        }
        if ( isset( $_POST['author'] ) ) {
            $candidates[] = self::read_request_scalar( $_POST, 'author' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- anonymous author-enumeration probe detection only.
        }
        if ( isset( $_REQUEST['author'] ) ) {
            $candidates[] = self::read_request_scalar( $_REQUEST, 'author' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- anonymous author-enumeration probe detection only.
        }
        // phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing

        foreach ( $candidates as $candidate ) {
            if ( is_array( $candidate ) ) {
                return true;
            }

            if ( ! is_scalar( $candidate ) ) {
                continue;
            }

            $value = (string) $candidate;
            $digits = preg_replace( '/[^0-9]/', '', $value );

            if ( is_string( $digits ) && $digits !== '' ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Return a standard 404 response for author-enumeration probes.
     */
    private function render_author_enumeration_not_found(): void {
        global $wp_query;

        if ( $wp_query instanceof \WP_Query ) {
            $wp_query->set_404();
        }

        status_header( 404 );
        nocache_headers();

        $template = get_404_template();
        if ( $template && file_exists( $template ) ) {
            include $template;
        }

        exit;
    }

    /**
     * Remove core REST users endpoints for callers who cannot list users.
     *
     * This prevents user enumeration via the REST API even when
     * restrict_rest_api is not enabled. Privileged users who can list
     * users still retain access.
     *
     * @param array $endpoints Registered REST endpoints.
     * @return array Filtered endpoints.
     */
    public function remove_user_rest_endpoints( array $endpoints ): array {
        if ( is_user_logged_in() && current_user_can( 'list_users' ) ) {
            return $endpoints;
        }

        $base_route = self::CORE_REST_USERS_ROUTE;

        $base_depth = substr_count( trim( $base_route, '/' ), '/' );

        foreach ( array_keys( $endpoints ) as $route ) {
            if ( ! is_string( $route ) ) {
                continue;
            }

            $normalized_route = rtrim( $route, '/' );
            if ( $normalized_route === $base_route ) {
                unset( $endpoints[ $route ] );
                continue;
            }

            // Remove only the direct single-user route and leave nested user
            // management endpoints such as /users/me and application-password
            // subroutes to their own core permission checks.
            $route_depth = substr_count( trim( $normalized_route, '/' ), '/' );
            if (
                $route_depth === ( $base_depth + 1 )
                && str_starts_with( $normalized_route, $base_route . '/(?P<' )
            ) {
                unset( $endpoints[ $route ] );
            }
        }

        return $endpoints;
    }

    /**
     * Block live requests to REST users endpoints even if another component
     * re-registers the route after SecurWP has pruned the endpoint index.
     *
     * @param mixed            $response Current pre-callback response.
     * @param array            $handler  Matched route handler.
     * @param \WP_REST_Request $request  Current REST request.
     * @return mixed
     */
    public function guard_user_rest_request( $response, array $handler, $request ) {
        if ( is_user_logged_in() && current_user_can( 'list_users' ) ) {
            return $response;
        }

        if ( ! ( $request instanceof \WP_REST_Request ) ) {
            return $response;
        }

        $base_route = self::CORE_REST_USERS_ROUTE;

        $route = rtrim( strtolower( $request->get_route() ), '/' );
        if ( $route === '' ) {
            return $response;
        }

        $base_route = strtolower( $base_route );
        $is_users_handler = $this->is_users_rest_handler( $handler );
        $is_core_users_route = ( $route === $base_route ) || str_starts_with( $route, $base_route . '/' );

        if ( ! $is_core_users_route && ! $is_users_handler ) {
            return $response;
        }

        if ( preg_match( '#/me$#', $route ) ) {
            return $response;
        }

        $requested_id = $this->get_rest_requested_user_id( $request, $route, $base_route );
        if ( $requested_id !== null ) {
            if ( get_current_user_id() !== $requested_id ) {
                return rest_ensure_response(
                    new \WP_Error(
                        'rest_user_invalid_id',
                        __( 'Invalid user ID.', 'securwp' ),
                        [ 'status' => 404 ]
                    )
                );
            }

            return $response;
        }

        if ( $route === $base_route || $is_users_handler ) {
            return rest_ensure_response(
                new \WP_Error(
                    'rest_user_cannot_view',
                    __( 'Sorry, you are not allowed to list users.', 'securwp' ),
                    [
                        'status' => function_exists( 'rest_authorization_required_code' )
                            ? rest_authorization_required_code()
                            : ( is_user_logged_in() ? 403 : 401 ),
                    ]
                )
            );
        }

        return $response;
    }

    /**
     * Detect whether the matched REST callback is handled by WP_REST_Users_Controller.
     */
    private function is_users_rest_handler( array $handler ): bool {
        $callback = $handler['callback'] ?? null;
        return is_array( $callback ) && isset( $callback[0] ) && $callback[0] instanceof \WP_REST_Users_Controller;
    }

    /**
     * Resolve the targeted user ID for a REST users request when available.
     */
    private function get_rest_requested_user_id( \WP_REST_Request $request, string $route, string $base_route ): ?int {
        $request_id = $request->get_param( 'id' );
        if ( is_scalar( $request_id ) && preg_match( '/^\d+$/', (string) $request_id ) ) {
            return (int) $request_id;
        }

        if ( preg_match( '#^' . preg_quote( $base_route, '#' ) . '/(\d+)$#', $route, $matches ) ) {
            return (int) $matches[1];
        }

        return null;
    }

    /**
     * Remove author name and author_url from oEmbed responses.
     *
     * oEmbed auto-discovery can leak the author's display name and
     * author URL (which contains the nicename/slug) to any site
     * that embeds our content.
     *
     * @param array $data oEmbed response data.
     * @return array Filtered data.
     */
    public function filter_oembed_author( $data ) {
        if ( is_array( $data ) ) {
            unset( $data['author_name'], $data['author_url'] );
        }
        return $data;
    }

    /**
     * Remove the 'users' provider from WordPress sitemaps to prevent
     * /wp-sitemap-users-1.xml from leaking usernames.
     */
    public function disable_user_sitemap( $provider, string $name ) {
        if ( 'users' === $name ) {
            return false;
        }
        return $provider;
    }

    /**
     * Replace author display names with the site name in RSS/Atom feeds.
     *
     * Feeds expose author identity in dc:creator (RSS 2.0) and
     * author/name (Atom) elements. Replacing the name with the site
     * title prevents username enumeration via feed scraping while
     * keeping the feed valid.
     *
     * @param string $display_name The author display name.
     * @return string Site name when rendering a feed; original name otherwise.
     */
    public function filter_feed_author( string $display_name ): string {
        if ( is_feed() ) {
            return (string) get_bloginfo( 'name' );
        }
        return $display_name;
    }

    /**
     * Replace author archive links with the site URL in feed output.
     *
     * Atom feeds include an author URI that points to /author/slug/,
     * leaking the nicename. Replace it with the home URL when the
     * request is a feed.
     *
     * @param string $link       The author posts URL.
     * @param int    $author_id  The author's user ID.
     * @param string $author_nicename The author's nicename (slug).
     * @return string Home URL when rendering a feed; original link otherwise.
     */
    public function filter_feed_author_link( string $link, int $author_id, string $author_nicename ): string {
        if ( is_feed() ) {
            return home_url( '/' );
        }
        return $link;
    }

    /**
     * Validate that nickname is distinct from the login username.
     *
     * This runs on profile update / user creation from admin. If the
     * nickname matches the login, the save is blocked with an error.
     * Falls back to first+last name if available.
     *
     * @param \WP_Error $errors
     * @param bool      $update
     * @param \stdClass $user
     */
    public function validate_unique_nicename( &$errors, $update, &$user ): void {
        $display_name = ! empty( $user->display_name ) ? $user->display_name : wp_generate_password( 14, false );

        if ( ! empty( $user->nickname ) ) {
            if ( sanitize_title( $user->nickname ) === sanitize_title( $user->user_login ) ) {
                $errors->add(
                    'user_error',
                    __( '<strong>ERROR</strong>: Your Nickname must be different from your login name. Please choose a different Nickname.', 'securwp' )
                );
            } else {
                $user->user_nicename = sanitize_title( $user->nickname, $display_name );
            }
        } elseif ( ! empty( $user->first_name ) && ! empty( $user->last_name ) ) {
            $full_name           = $user->first_name . ' ' . $user->last_name;
            $user->nickname      = $full_name;
            $user->user_nicename = sanitize_title( $full_name, $display_name );
        } else {
            $errors->add(
                'user_error',
                __( '<strong>ERROR</strong>: A Nickname is required. Please choose a nickname or fill out your first and last name.', 'securwp' )
            );
        }
    }

    /**
     * Enforce a nicename distinct from the login username across all user
     * insert/update paths, including REST and programmatic writes.
     *
     * @param array      $data     Sanitized users-table row about to be stored.
     * @param bool       $update   Whether this is an existing user update.
     * @param int|null   $user_id  Existing user ID for updates.
     * @param array      $userdata Raw wp_insert_user() input.
     * @return array Filtered users-table row.
     */
    public function enforce_unique_user_nicename_data( array $data, bool $update, ?int $user_id, array $userdata ): array {
        $existing_user = null;
        if ( $update && $user_id ) {
            $existing_user = get_userdata( $user_id );
        }

        $user_login = $this->resolve_user_login_for_nicename( $data, $userdata, $existing_user );
        if ( $user_login === '' ) {
            return $data;
        }

        $data['user_nicename'] = $this->ensure_unique_user_nicename(
            $this->resolve_safe_user_nicename(
                $userdata,
                $user_login,
                (string) ( $data['user_nicename'] ?? '' ),
                $existing_user instanceof \WP_User ? $existing_user : null
            ),
            $user_login,
            $existing_user instanceof \WP_User ? (int) $existing_user->ID : null
        );

        return $data;
    }

    /**
     * Repair existing users whose nicename still leaks the login username.
     *
     * @return int|null Last processed user ID, or null when remediation is complete.
     */
    public function remediate_user_nicenames_batch( int $cursor = 0, int $limit = 50 ): ?int {
        global $wpdb;

        $limit = max( 1, $limit );
        $last_id = $cursor;

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared query against the core users table for scheduled remediation.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT ID FROM {$wpdb->users} WHERE ID > %d ORDER BY ID ASC LIMIT %d",
                $cursor,
                $limit
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

        if ( ! is_array( $rows ) || $rows === [] ) {
            return null;
        }

        foreach ( $rows as $row ) {
            $user_id = isset( $row->ID ) ? (int) $row->ID : 0;
            if ( $user_id <= 0 ) {
                continue;
            }

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

            $safe_nicename = $this->ensure_unique_user_nicename(
                $this->resolve_safe_user_nicename( [], $user->user_login, (string) $user->user_nicename, $user ),
                $user->user_login,
                $user_id
            );
            if ( $safe_nicename === '' || $safe_nicename === $user->user_nicename ) {
                continue;
            }

            wp_update_user(
                [
                    'ID'            => $user_id,
                    'user_nicename' => $safe_nicename,
                ]
            );
        }

        return count( $rows ) < $limit ? null : $last_id;
    }

    /**
     * Ensure a candidate nicename remains unique after SecurWP overrides it.
     */
    private function ensure_unique_user_nicename( string $candidate, string $user_login, ?int $user_id = null ): string {
        $candidate = $this->normalize_user_nicename_value( $candidate );
        if ( $candidate === '' ) {
            $candidate = $this->build_opaque_user_nicename( $user_login );
        }

        if ( mb_strlen( $candidate ) > 50 ) {
            $candidate = mb_substr( $candidate, 0, 50 );
        }

        if ( ! $this->user_nicename_exists( $candidate, $user_id ) ) {
            return $candidate;
        }

        $base   = $candidate;
        $suffix = 2;

        do {
            $base_length = 49 - mb_strlen( (string) $suffix );
            $candidate   = mb_substr( $base, 0, $base_length ) . '-' . $suffix;
            ++$suffix;
        } while ( $this->user_nicename_exists( $candidate, $user_id ) );

        return $candidate;
    }

    /**
     * Check whether another user already owns the given nicename.
     */
    private function user_nicename_exists( string $candidate, ?int $user_id = null ): bool {
        global $wpdb;

        $candidate = $this->normalize_user_nicename_value( $candidate );
        if ( $candidate === '' ) {
            return false;
        }

        $query = $user_id
            ? $wpdb->prepare(
                "SELECT ID FROM {$wpdb->users} WHERE user_nicename = %s AND ID != %d LIMIT 1",
                $candidate,
                $user_id
            )
            : $wpdb->prepare(
                "SELECT ID FROM {$wpdb->users} WHERE user_nicename = %s LIMIT 1",
                $candidate
            );

        // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared query with the core users table name.
        $found = $wpdb->get_var( $query );
        // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

        return ! empty( $found );
    }

    /**
     * Resolve the login name to compare against the public nicename.
     */
    private function resolve_user_login_for_nicename( array $data, array $userdata, $existing_user ): string {
        if ( isset( $data['user_login'] ) && is_string( $data['user_login'] ) && $data['user_login'] !== '' ) {
            return $data['user_login'];
        }

        if ( isset( $userdata['user_login'] ) && is_scalar( $userdata['user_login'] ) ) {
            return sanitize_user( wp_unslash( (string) $userdata['user_login'] ), true );
        }

        if ( $existing_user instanceof \WP_User ) {
            return (string) $existing_user->user_login;
        }

        return '';
    }

    /**
     * Build a public nicename that does not disclose the login identifier.
     */
    private function resolve_safe_user_nicename( array $userdata, string $user_login, string $proposed_nicename = '', ?\WP_User $existing_user = null ): string {
        $login_slug = $this->normalize_user_login_slug( $user_login );
        if ( $login_slug === '' ) {
            return $this->normalize_user_nicename_value( $proposed_nicename );
        }

        $raw_nicename = isset( $userdata['user_nicename'] ) && is_scalar( $userdata['user_nicename'] )
            ? trim( wp_unslash( (string) $userdata['user_nicename'] ) )
            : '';

        if ( $this->is_user_nicename_safe( $raw_nicename, $login_slug ) ) {
            return $this->normalize_user_nicename_value( $raw_nicename );
        }

        if ( $this->is_user_nicename_safe( $proposed_nicename, $login_slug ) ) {
            return $this->normalize_user_nicename_value( $proposed_nicename );
        }

        $profile_fields = [ 'nickname', 'display_name', 'first_name', 'last_name', 'user_nicename' ];
        $profile_changed = false;
        foreach ( $profile_fields as $field ) {
            if ( array_key_exists( $field, $userdata ) ) {
                $profile_changed = true;
                break;
            }
        }

        $existing_safe_nicename = '';
        if ( $existing_user instanceof \WP_User && $this->is_user_nicename_safe( (string) $existing_user->user_nicename, $login_slug ) ) {
            $existing_safe_nicename = $this->normalize_user_nicename_value( (string) $existing_user->user_nicename );
        }

        if ( ! $profile_changed && $existing_safe_nicename !== '' ) {
            return $existing_safe_nicename;
        }

        $nickname     = $this->get_user_profile_value( $userdata, 'nickname', $existing_user, true );
        $display_name = $this->get_user_profile_value( $userdata, 'display_name', $existing_user, false );
        $first_name   = $this->get_user_profile_value( $userdata, 'first_name', $existing_user, true );
        $last_name    = $this->get_user_profile_value( $userdata, 'last_name', $existing_user, true );
        $full_name    = trim( $first_name . ' ' . $last_name );

        $candidates = array_values(
            array_unique(
                array_filter(
                    [
                        $nickname,
                        $display_name,
                        $full_name,
                        $first_name,
                        $last_name,
                        $existing_safe_nicename,
                    ],
                    static fn( $value ): bool => is_string( $value ) && trim( $value ) !== ''
                )
            )
        );

        foreach ( $candidates as $candidate ) {
            if ( $this->is_user_nicename_safe( $candidate, $login_slug ) ) {
                return $this->normalize_user_nicename_value( $candidate );
            }
        }

        return $this->build_opaque_user_nicename( $user_login );
    }

    /**
     * Retrieve a candidate profile value from raw input, user meta, or the current user object.
     */
    private function get_user_profile_value( array $userdata, string $key, ?\WP_User $existing_user, bool $meta_key ): string {
        if ( array_key_exists( $key, $userdata ) && is_scalar( $userdata[ $key ] ) ) {
            return trim( wp_unslash( (string) $userdata[ $key ] ) );
        }

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

        if ( $meta_key ) {
            return trim( (string) get_user_meta( $existing_user->ID, $key, true ) );
        }

        $value = $existing_user->$key ?? '';
        return is_string( $value ) ? trim( $value ) : '';
    }

    /**
     * Determine whether a candidate nicename is safe to expose publicly.
     */
    private function is_user_nicename_safe( string $candidate, string $login_slug ): bool {
        $candidate = trim( $candidate );
        if ( $candidate === '' ) {
            return false;
        }

        if ( str_contains( $candidate, '@' ) && is_email( $candidate ) ) {
            return false;
        }

        $normalized = $this->normalize_user_nicename_value( $candidate );
        return $normalized !== '' && $normalized !== $login_slug;
    }

    /**
     * Normalize a nicename candidate using WordPress slug rules.
     */
    private function normalize_user_nicename_value( string $value ): string {
        return sanitize_title( $value );
    }

    /**
     * Normalize the login value into the slug WordPress would expose by default.
     */
    private function normalize_user_login_slug( string $user_login ): string {
        return sanitize_title( sanitize_user( $user_login, true ) );
    }

    /**
     * Build a stable opaque fallback nicename when no safe profile-derived slug exists.
     */
    private function build_opaque_user_nicename( string $user_login ): string {
        $digest = hash_hmac( 'sha256', strtolower( $user_login ), wp_salt( 'secure_auth' ) );
        return 'user-' . substr( $digest, 0, 12 );
    }

    public function setup_login_type_filters() {
        if ( ! has_filter( 'gettext', array( $this, 'filter_login_labels' ) ) ) {
            add_filter( 'gettext', array( $this, 'filter_login_labels' ), 20, 3 );
        }
    }

    public function filter_login_labels( $translation, $text, $domain ) {
        if ( 'default' !== $domain ) return $translation;

        $type = $this->settings['login_type_restriction'];

        if ( 'Username or Email Address' === $text ) {
            if ( 'email' === $type ) return __( 'Email Address', 'securwp' );
            if ( 'username' === $type ) return __( 'Username', 'securwp' );
        }

        return $translation;
    }

    public function restrict_login_authenticate( $user, $username, $password ) {
        $type = $this->settings['login_type_restriction'];

        if ( 'email' === $type ) {
            remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
            if ( ! is_email( $username ) ) {
                 return new \WP_Error( 'invalid_email', __( '<strong>ERROR</strong>: You must log in with your email address.', 'securwp' ) );
            }
        } elseif ( 'username' === $type ) {
            remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
             if ( is_email( $username ) ) {
                 return new \WP_Error( 'invalid_username', __( '<strong>ERROR</strong>: You must log in with your username.', 'securwp' ) );
             }
        }
        return $user;
    }


    public function validate_strong_password( $errors, $update, $user ) {
        if ( empty( $_POST['pass1'] ) ) { // phpcs:ignore
            return;
        }
        $password = $_POST['pass1']; // phpcs:ignore
        $user_obj = isset( $user->ID ) ? get_userdata( $user->ID ) : null;

        $this->check_password_strength( $errors, $password, $user_obj );
    }

    public function validate_password_reset( $errors, $user ) {
        if ( empty( $_POST['pass1'] ) ) { // phpcs:ignore
            return $errors;
        }
        $password = $_POST['pass1']; // phpcs:ignore

        $this->check_password_strength( $errors, $password, $user instanceof \WP_User ? $user : null );
        return $errors;
    }

    public function validate_registration_password( \WP_Error $errors, string $sanitized_user_login, string $user_email ): \WP_Error {
        if ( empty( $_POST['user_pass'] ) ) { // phpcs:ignore
            return $errors;
        }
        $password = $_POST['user_pass']; // phpcs:ignore

        $this->validate_registration_password_policy( $errors, (string) $password, $sanitized_user_login, $user_email, false );
        return $errors;
    }

    /**
     * Validate WooCommerce "My Account" registration passwords.
     *
     * WooCommerce runs its own registration form handler and passes the raw
     * password directly to this filter before account creation.
     */
    public function validate_woocommerce_registration_password( \WP_Error $errors, string $username, string $password, string $email ): \WP_Error {
        $this->validate_registration_password_policy( $errors, $password, $username, $email, true );
        return $errors;
    }

    /**
     * Validate WooCommerce checkout account passwords when account creation is enabled.
     *
     * @param array     $data   Sanitized checkout data.
     * @param \WP_Error $errors Validation error collection.
     */
    public function validate_woocommerce_checkout_account_password( array $data, \WP_Error $errors ): void {
        $password = isset( $data['account_password'] ) && is_string( $data['account_password'] )
            ? $data['account_password']
            : '';

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

        $username = isset( $data['account_username'] ) && is_string( $data['account_username'] )
            ? $data['account_username']
            : '';
        $email = isset( $data['billing_email'] ) && is_string( $data['billing_email'] )
            ? $data['billing_email']
            : '';

        $this->validate_registration_password_policy( $errors, $password, $username, $email, true );
    }

    /**
     * Determine whether a password satisfies the current SecurWP policy.
     */
    public function is_password_strong_enough( string $password, ?\WP_User $user = null, ?\stdClass $context = null ): bool {
        $analysis = $this->analyze_password_strength( $password, $user, $context );
        return $analysis['score'] >= $analysis['required_score'];
    }

    /**
     * Build a stable signature for the current strong-password policy inputs.
     *
     * The signature includes the effective required score and the user/profile
     * strings that influence zxcvbn scoring, so cached evaluations are
     * invalidated when password policy thresholds or user identity fields change.
     */
    public function get_password_strength_signature( ?\WP_User $user = null, ?\stdClass $context = null ): string {
        $state = $this->build_password_strength_context( $user, $context );

        return hash(
            'sha256',
            wp_json_encode(
                [
                    'required_score'  => $state['required_score'],
                    'penalty_strings' => $state['penalty_strings'],
                ]
            ) ?: ''
        );
    }

    /**
     * Core password strength check using Zxcvbn.
     *
     * @param \WP_Error       $errors   Error object to append to.
     * @param string          $password Plaintext password.
     * @param \WP_User|null   $user     Existing user (for penalty strings & role check).
     * @param \stdClass|null  $context  Temporary context (registration) with user_login/user_email.
     */
    private function check_password_strength( \WP_Error $errors, string $password, ?\WP_User $user = null, ?\stdClass $context = null, bool $plain_message = false ): void {
        $analysis = $this->analyze_password_strength( $password, $user, $context );

        if ( $analysis['score'] < $analysis['required_score'] ) {
            $score_labels = [
                0 => __( 'Trivial', 'securwp' ),
                1 => __( 'Weak', 'securwp' ),
                2 => __( 'Fair', 'securwp' ),
                3 => __( 'Strong', 'securwp' ),
                4 => __( 'Very Strong', 'securwp' ),
            ];

            $current_label  = $score_labels[ $analysis['score'] ] ?? __( 'Unknown', 'securwp' );
            $required_label = $score_labels[ $analysis['required_score'] ] ?? __( 'Unknown', 'securwp' );

            if ( $plain_message ) {
                /* translators: 1: current password strength label, 2: required password strength label. */
                $message_template = __( 'Password strength is "%1$s" but "%2$s" or better is required.', 'securwp' );
            } else {
                /* translators: 1: current password strength label, 2: required password strength label. */
                $message_template = __( '<strong>ERROR</strong>: Password strength is "%1$s" but "%2$s" or better is required.', 'securwp' );
            }

            // Build error message with Zxcvbn feedback.
            $message = sprintf(
                $message_template,
                $current_label,
                $required_label
            );

            // Append Zxcvbn suggestions if available.
            if ( ! empty( $analysis['feedback']['suggestions'] ) ) {
                $message .= ' ' . implode( ' ', $analysis['feedback']['suggestions'] );
            } elseif ( ! empty( $analysis['feedback']['warning'] ) ) {
                $message .= ' ' . $analysis['feedback']['warning'];
            }

            $errors->add( 'password_too_weak', $message );
        }
    }

    /**
     * Apply SecurWP registration password policy across core and WooCommerce flows.
     */
    private function validate_registration_password_policy( \WP_Error $errors, string $password, string $user_login, string $user_email, bool $plain_message = false ): void {
        if ( $password === '' ) {
            return;
        }

        if ( ! empty( $this->settings['enable_strong_passwords'] ) ) {
            $tmp = new \stdClass();
            $tmp->user_login   = $user_login !== '' ? $user_login : $user_email;
            $tmp->user_email   = $user_email;
            $tmp->display_name = $tmp->user_login;

            $this->check_password_strength( $errors, $password, null, $tmp, $plain_message );
        }

        if ( ! empty( $this->settings['check_breached_passwords'] ) ) {
            $checker = new BreachedPasswordChecker( $this->settings );
            $checker->append_error_if_breached( $errors, $password );
        }
    }

    /**
     * Remove duplicate-account errors without hiding unrelated validation failures.
     *
     * @param \WP_Error $errors Registration errors.
     * @param string[]  $enumeration_codes Duplicate-account error codes to mask.
     * @param string    $replacement_message Generic message to show after masking.
     * @return \WP_Error
     */
    private function mask_registration_enumeration_errors( \WP_Error $errors, array $enumeration_codes, string $replacement_message ): \WP_Error {
        $found = false;

        foreach ( $enumeration_codes as $code ) {
            if ( $errors->get_error_message( $code ) ) {
                $errors->remove( $code );
                $found = true;
            }
        }

        if ( $found ) {
            $errors->add(
                'registration_conflict',
                $replacement_message
            );
        }

        return $errors;
    }

    /**
     * Resolve the correct masked lost-password success redirect.
     *
     * WooCommerce uses its own success URL on the My Account lost-password
     * form, so matching core's wp-login.php redirect would still leak account
     * existence there.
     */
    private function get_masked_lostpassword_redirect(): string {
        if ( $this->is_woocommerce_lostpassword_request() && function_exists( 'wc_get_account_endpoint_url' ) ) {
            return add_query_arg( 'reset-link-sent', 'true', wc_get_account_endpoint_url( 'lost-password' ) );
        }

        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- matches the core post-success redirect contract for public lost-password requests.
        return ! empty( $_REQUEST['redirect_to'] )
            ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) )
            : 'wp-login.php?checkemail=confirm';
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
    }

    /**
     * Detect the WooCommerce lost-password form submit path.
     */
    private function is_woocommerce_lostpassword_request(): bool {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- public form-shape inspection only.
        return isset( $_POST['wc_reset_password'], $_POST['user_login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- form-shape detection only; actual reset handlers validate independently.
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
    }

    /**
     * Determine whether WooCommerce-specific hardening adapters should be active.
     */
    private function has_woocommerce(): bool {
        return class_exists( 'WooCommerce' );
    }

    /**
     * Analyze password strength and return the effective policy requirement.
     *
     * @return array{score:int,required_score:int,feedback:array<string,mixed>}
     */
    private function analyze_password_strength( string $password, ?\WP_User $user = null, ?\stdClass $context = null ): array {
        $state  = $this->build_password_strength_context( $user, $context );
        $zxcvbn = new \ZxcvbnPhp\Zxcvbn();

        try {
            $result = $zxcvbn->passwordStrength( $password, $state['penalty_strings'] );
        } catch ( \Throwable $e ) {
            // Zxcvbn can exhaust memory on extremely long inputs. Treat as
            // minimum-score so the validation error is still shown.
            $result = [ 'score' => 0, 'feedback' => [ 'suggestions' => [], 'warning' => '' ] ];
        }

        return [
            'score'          => (int) ( $result['score'] ?? 0 ),
            'required_score' => $state['required_score'],
            'feedback'       => is_array( $result['feedback'] ?? null ) ? $result['feedback'] : [],
        ];
    }

    /**
     * Resolve the current strong-password policy inputs used by zxcvbn.
     *
     * @return array{required_score:int,penalty_strings:array<int,string>}
     */
    private function build_password_strength_context( ?\WP_User $user = null, ?\stdClass $context = null ): array {
        $penalty_strings = [ get_option( 'admin_email', '' ) ];
        $source          = $user ?? $context;

        if ( $source ) {
            $properties = [ 'user_login', 'first_name', 'last_name', 'nickname', 'display_name', 'user_email', 'user_url' ];
            foreach ( $properties as $prop ) {
                if ( ! empty( $source->$prop ) ) {
                    $penalty_strings[] = $source->$prop;
                }
            }
        }

        $normalized_penalty_strings = array_values(
            array_unique(
                array_filter(
                    array_map(
                        static function ( $value ): string {
                            return is_scalar( $value ) ? trim( strtolower( (string) $value ) ) : '';
                        },
                        $penalty_strings
                    ),
                    static function ( string $value ): bool {
                        return $value !== '';
                    }
                )
            )
        );

        return [
            'required_score' => $this->resolve_required_password_score( $user ),
            'penalty_strings' => $normalized_penalty_strings,
        ];
    }

    /**
     * Determine the effective minimum strength score for the current user.
     */
    private function resolve_required_password_score( ?\WP_User $user = null ): int {
        $min_score   = (int) ( $this->settings['strong_password_min_score'] ?? 3 );
        $admin_score = (int) ( $this->settings['strong_password_admin_score'] ?? 4 );

        if ( $user instanceof \WP_User ) {
            $admin_roles = [ 'administrator', 'editor' ];
            if ( ! empty( array_intersect( $admin_roles, $user->roles ) ) ) {
                return max( $min_score, $admin_score );
            }
        }

        return $min_score;
    }

    /**
     * Filter auto-generated passwords to ensure they meet strength requirements.
     */
    public function filter_random_password( string $password, int $length ): string {
        $zxcvbn    = new \ZxcvbnPhp\Zxcvbn();
        $min_score = (int) ( $this->settings['strong_password_min_score'] ?? 3 );

        try {
            $result = $zxcvbn->passwordStrength( $password );
        } catch ( \Throwable $e ) {
            return wp_generate_password( max( $length, 24 ), true, true );
        }

        if ( $result['score'] >= $min_score ) {
            return $password;
        }

        for ( $i = 0; $i < 5; $i++ ) {
            $password = wp_generate_password( max( $length, 24 ), true, true );
            try {
                $result = $zxcvbn->passwordStrength( $password );
            } catch ( \Throwable $e ) {
                return $password;
            }
            if ( $result['score'] >= $min_score ) {
                return $password;
            }
        }

        return $password;
    }


    /**
     * Prevent reuse of the current password on profile update.
     */
    public function check_password_reuse( $errors, $update, $user ): void {
        if ( empty( $_POST['pass1'] ) || empty( $user->ID ) ) { // phpcs:ignore
            return;
        }
        $password  = $_POST['pass1']; // phpcs:ignore
        $user_data = get_userdata( $user->ID );

        if ( $user_data && wp_check_password( $password, $user_data->user_pass, $user_data->ID ) ) {
            $errors->add( 'password_reuse', __( '<strong>ERROR</strong>: You cannot reuse your current password. Please choose a different one.', 'securwp' ) );
        }
    }

    /**
     * Prevent reuse of the current password on password reset.
     */
    public function check_password_reuse_on_reset( $errors, $user ): void {
        if ( empty( $_POST['pass1'] ) ) { // phpcs:ignore
            return;
        }
        $password = $_POST['pass1']; // phpcs:ignore

        if ( $user instanceof \WP_User && wp_check_password( $password, $user->user_pass, $user->ID ) ) {
            $errors->add( 'password_reuse', __( '<strong>ERROR</strong>: You cannot reuse your current password. Please choose a different one.', 'securwp' ) );
        }
    }
}
