<?php
/**
 * Runtime guards for Polanger Firewall.
 *
 * @package Polanger_Dashboard_Tools
 */

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

/**
 * WordPress-aware request firewall.
 */
class PDT_Firewall_Runtime {

  /**
   * Settings service.
   *
   * @var PDT_Firewall_Settings
   */
  private $settings;

  /**
   * Logger service.
   *
   * @var PDT_Firewall_Logger
   */
  private $logger;

  /**
   * Request flags.
   *
   * @var array
   */
  private $flags = array();

  /**
   * Cached client IP for current request.
   *
   * @var string|null
   */
  private $client_ip = null;

  /**
   * Constructor.
   *
   * @param PDT_Firewall_Settings $settings Settings service.
   * @param PDT_Firewall_Logger   $logger   Logger service.
   */
  public function __construct( $settings, $logger ) {
    $this->settings = $settings;
    $this->logger   = $logger;
  }

  /**
   * Register WordPress hooks.
   *
   * @return void
   */
  public function register_hooks() {
    add_action( 'init', array( $this, 'maybe_block_request_surface' ), 0 );
    add_action( 'send_headers', array( $this, 'send_security_headers' ), 20 );

    add_filter( 'authenticate', array( $this, 'maybe_block_wp_login' ), 5, 3 );
    add_action( 'wp_login_failed', array( $this, 'record_wp_login_failure' ), 10, 1 );
    add_action( 'wp_login', array( $this, 'clear_wp_login_identity' ), 10, 2 );
    add_action( 'lostpassword_post', array( $this, 'maybe_block_lostpassword' ), 1, 2 );
    add_action( 'lostpassword_post', array( $this, 'record_lostpassword_attempt' ), 20, 2 );
    add_filter( 'registration_errors', array( $this, 'maybe_block_registration' ), 5, 3 );
    add_filter( 'registration_errors', array( $this, 'record_registration_attempt' ), 20, 3 );

    add_filter( 'xmlrpc_enabled', array( $this, 'filter_xmlrpc_enabled' ) );
    add_filter( 'xmlrpc_methods', array( $this, 'filter_xmlrpc_methods' ) );

    add_filter( 'rest_authentication_errors', array( $this, 'guard_rest_authentication' ), 5 );
    add_filter( 'rest_request_before_callbacks', array( $this, 'guard_rest_request' ), 5, 3 );

    add_filter( 'wp_is_application_passwords_available_for_user', array( $this, 'filter_application_passwords_available_for_user' ), 10, 2 );
    add_action( 'application_password_did_authenticate', array( $this, 'record_application_password_success' ), 10, 2 );
    add_action( 'application_password_failed_authentication', array( $this, 'record_application_password_failure' ), 10, 1 );
  }

  /**
   * Block obvious probes, denylisted IPs, and temporary cooldowns.
   *
   * @return void
   */
  public function maybe_block_request_surface() {
    if ( ! $this->should_run() ) {
      return;
    }

    $ip = $this->get_client_ip();

    if ( $this->is_allowlisted_ip( $ip ) ) {
      return;
    }

    if ( $this->ip_matches_list( $ip, $this->settings->get_ip_list( 'denylist_ips' ) ) ) {
      $this->block_or_monitor( 'denylisted_ip', 403, __( 'Access denied by firewall policy.', 'polanger-admin-menu-manager' ) );
      return;
    }

    if ( $this->should_bypass_current_request() ) {
      return;
    }

    if ( $this->is_temp_blocked( $ip ) ) {
      $this->block_or_monitor( 'temporary_ip_block', 429, __( 'Too many suspicious requests were detected. Please try again later.', 'polanger-admin-menu-manager' ) );
      return;
    }

    $enumeration_reason = $this->detect_user_enumeration_reason();
    if ( '' !== $enumeration_reason ) {
      $this->block_or_monitor( $enumeration_reason, 403, __( 'This request was blocked by the firewall.', 'polanger-admin-menu-manager' ) );
      return;
    }

    if ( $this->maybe_handle_request_score( $ip ) ) {
      return;
    }

    if ( ! $this->settings->get( 'probe_protection_enabled', true ) ) {
      return;
    }

    $probe_reason = $this->detect_probe_reason();
    if ( '' === $probe_reason ) {
      return;
    }

    $this->record_probe_strike( $ip, $probe_reason );
    $this->block_or_monitor( $probe_reason, 403, __( 'This request was blocked by the firewall.', 'polanger-admin-menu-manager' ) );
  }

  /**
   * Block native wp-login.php login attempts when rate limited.
   *
   * @param WP_User|WP_Error|null $user     Current auth result.
   * @param string                $username Username.
   * @param string                $password Password.
   * @return WP_User|WP_Error|null
   */
  public function maybe_block_wp_login( $user, $username, $password ) {
    unset( $password );

    if ( ! $this->should_run() || ! $this->settings->get( 'auth_rate_limit_enabled', true ) || ! $this->is_native_login_attempt() ) {
      return $user;
    }

    if ( $this->should_bypass_current_request() ) {
      return $user;
    }

    $state = $this->get_auth_limit_state( 'login', $username );
    if ( empty( $state['limited'] ) ) {
      return $user;
    }

    if ( $this->settings->is_monitor_mode() ) {
      $this->log( 'monitored', 'login_rate_limit', array( 'details' => $username ) );
      return $user;
    }

    $this->flags['login_rate_limited'] = true;
    $this->log( 'blocked', 'login_rate_limit', array( 'details' => $username ) );

    return new WP_Error(
      'pdt_firewall_login_rate_limit',
      $this->format_rate_limit_message( __( 'Too many failed login attempts were detected. Please try again in %d minutes.', 'polanger-admin-menu-manager' ), $state['remaining'] )
    );
  }

  /**
   * Record a failed native login attempt.
   *
   * @param string $username Submitted username.
   * @return void
   */
  public function record_wp_login_failure( $username ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'auth_rate_limit_enabled', true ) || ! $this->is_native_login_attempt() ) {
      return;
    }

    if ( $this->should_bypass_current_request() || ! empty( $this->flags['login_rate_limited'] ) ) {
      return;
    }

    if ( $this->settings->is_monitor_mode() ) {
      $this->log( 'monitored', 'login_failure', array( 'details' => $username ) );
      return;
    }

    $this->record_auth_attempt( 'login', $username );
  }

  /**
   * Clear native login identity bucket on success.
   *
   * @param string  $user_login User login.
   * @param WP_User $user       User object.
   * @return void
   */
  public function clear_wp_login_identity( $user_login, $user ) {
    unset( $user );

    $this->clear_auth_identity_bucket( 'login', $user_login );
  }

  /**
   * Block native lost password requests when limited.
   *
   * @param WP_Error     $errors    Errors object.
   * @param WP_User|bool $user_data User data.
   * @return void
   */
  public function maybe_block_lostpassword( $errors, $user_data ) {
    unset( $user_data );

    if ( ! $this->should_run() || ! $this->settings->get( 'auth_rate_limit_enabled', true ) || ! $this->is_native_lostpassword_request() ) {
      return;
    }

    if ( $this->should_bypass_current_request() ) {
      return;
    }

    $identity = isset( $_POST['user_login'] ) ? sanitize_text_field( wp_unslash( $_POST['user_login'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $state    = $this->get_auth_limit_state( 'lostpassword', $identity );

    if ( empty( $state['limited'] ) || ! ( $errors instanceof WP_Error ) ) {
      return;
    }

    if ( $this->settings->is_monitor_mode() ) {
      $this->log( 'monitored', 'lostpassword_rate_limit', array( 'details' => $identity ) );
      return;
    }

    $this->flags['lostpassword_rate_limited'] = true;
    $this->log( 'blocked', 'lostpassword_rate_limit', array( 'details' => $identity ) );

    $errors->add(
      'pdt_firewall_lostpassword_rate_limit',
      $this->format_rate_limit_message( __( 'Too many password reset requests were received. Please try again in %d minutes.', 'polanger-admin-menu-manager' ), $state['remaining'] )
    );
  }

  /**
   * Record native lost password requests.
   *
   * @param WP_Error     $errors    Errors object.
   * @param WP_User|bool $user_data User data.
   * @return void
   */
  public function record_lostpassword_attempt( $errors, $user_data ) {
    unset( $user_data );

    if ( ! $this->should_run() || ! $this->settings->get( 'auth_rate_limit_enabled', true ) || ! $this->is_native_lostpassword_request() ) {
      return;
    }

    if ( ! empty( $this->flags['lostpassword_rate_limited'] ) || $this->has_firewall_error( $errors ) ) {
      return;
    }

    $identity = isset( $_POST['user_login'] ) ? sanitize_text_field( wp_unslash( $_POST['user_login'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
    if ( $this->settings->is_monitor_mode() ) {
      $this->log( 'monitored', 'lostpassword_attempt', array( 'details' => $identity ) );
      return;
    }

    $this->record_auth_attempt( 'lostpassword', $identity );
  }

  /**
   * Block native registrations when limited.
   *
   * @param WP_Error $errors              Errors.
   * @param string   $sanitized_user_login Login.
   * @param string   $user_email          Email.
   * @return WP_Error
   */
  public function maybe_block_registration( $errors, $sanitized_user_login, $user_email ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'auth_rate_limit_enabled', true ) || ! $this->is_native_registration_request() ) {
      return $errors;
    }

    if ( $this->should_bypass_current_request() ) {
      return $errors;
    }

    $identity = '' !== sanitize_email( $user_email ) ? sanitize_email( $user_email ) : sanitize_text_field( $sanitized_user_login );
    $state    = $this->get_auth_limit_state( 'register', $identity );

    if ( empty( $state['limited'] ) ) {
      return $errors;
    }

    if ( $this->settings->is_monitor_mode() ) {
      $this->log( 'monitored', 'registration_rate_limit', array( 'details' => $identity ) );
      return $errors;
    }

    $this->flags['registration_rate_limited'] = true;
    $this->log( 'blocked', 'registration_rate_limit', array( 'details' => $identity ) );
    $errors->add(
      'pdt_firewall_registration_rate_limit',
      $this->format_rate_limit_message( __( 'Too many account registration requests were received. Please try again in %d minutes.', 'polanger-admin-menu-manager' ), $state['remaining'] )
    );

    return $errors;
  }

  /**
   * Record native registration requests.
   *
   * @param WP_Error $errors              Errors.
   * @param string   $sanitized_user_login Login.
   * @param string   $user_email          Email.
   * @return WP_Error
   */
  public function record_registration_attempt( $errors, $sanitized_user_login, $user_email ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'auth_rate_limit_enabled', true ) || ! $this->is_native_registration_request() ) {
      return $errors;
    }

    if ( ! empty( $this->flags['registration_rate_limited'] ) || $this->has_firewall_error( $errors ) ) {
      return $errors;
    }

    $identity = '' !== sanitize_email( $user_email ) ? sanitize_email( $user_email ) : sanitize_text_field( $sanitized_user_login );
    if ( $this->settings->is_monitor_mode() ) {
      $this->log( 'monitored', 'registration_attempt', array( 'details' => $identity ) );
      return $errors;
    }

    $this->record_auth_attempt( 'register', $identity );

    return $errors;
  }

  /**
   * Disable XML-RPC authenticated methods when configured.
   *
   * @param bool $enabled Current XML-RPC auth method state.
   * @return bool
   */
  public function filter_xmlrpc_enabled( $enabled ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'xmlrpc_protection_enabled', true ) ) {
      return $enabled;
    }

    if ( $this->settings->get( 'xmlrpc_disable_auth', true ) && ! $this->should_bypass_current_request() ) {
      if ( $this->settings->is_monitor_mode() ) {
        $this->log( 'monitored', 'xmlrpc_auth_disabled' );
        return $enabled;
      }

      $this->log( 'blocked', 'xmlrpc_auth_disabled' );
      return false;
    }

    return $enabled;
  }

  /**
   * Remove risky XML-RPC methods when configured.
   *
   * @param array $methods XML-RPC methods.
   * @return array
   */
  public function filter_xmlrpc_methods( $methods ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'xmlrpc_protection_enabled', true ) || ! is_array( $methods ) || $this->settings->is_monitor_mode() ) {
      return $methods;
    }

    if ( $this->settings->get( 'xmlrpc_disable_multicall', true ) ) {
      unset( $methods['system.multicall'] );
    }

    if ( $this->settings->get( 'xmlrpc_disable_pingbacks', false ) ) {
      unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] );
    }

    return $methods;
  }

  /**
   * Rate-limit unauthenticated REST requests.
   *
   * @param WP_Error|null|true $result Authentication result.
   * @return WP_Error|null|true
   */
  public function guard_rest_authentication( $result ) {
    if ( null !== $result || ! $this->should_run() || ! $this->settings->get( 'rest_protection_enabled', true ) || ! $this->is_rest_request() ) {
      return $result;
    }

    if ( is_user_logged_in() || $this->should_bypass_current_request() || ! $this->settings->get( 'rest_rate_limit_enabled', true ) ) {
      return $result;
    }

    if ( $this->settings->is_monitor_mode() ) {
      return $result;
    }

    $ip = $this->get_client_ip();
    if ( '' === $ip ) {
      return $result;
    }

    $state = $this->get_rate_bucket_state( 'rest', 'ip', $ip );
    if ( $this->bucket_reached_limit( $state, (int) $this->settings->get( 'rest_rate_limit', 120 ) ) ) {
      if ( $this->settings->is_monitor_mode() ) {
        $this->log( 'monitored', 'rest_rate_limit' );
        return $result;
      }

      $this->log( 'blocked', 'rest_rate_limit' );
      return new WP_Error(
        'pdt_firewall_rest_rate_limit',
        __( 'Too many REST API requests were received. Please try again later.', 'polanger-admin-menu-manager' ),
        array( 'status' => 429 )
      );
    }

    $this->increment_rate_bucket( 'rest', 'ip', $ip, max( 1, (int) $this->settings->get( 'rest_rate_window_minutes', 10 ) ) * MINUTE_IN_SECONDS );

    return $result;
  }

  /**
   * Harden anonymous REST routes.
   *
   * @param mixed           $response Existing response.
   * @param array           $handler  Handler.
   * @param WP_REST_Request $request  Request object.
   * @return mixed
   */
  public function guard_rest_request( $response, $handler, $request ) {
    unset( $handler );

    if ( is_wp_error( $response ) || ! $this->should_run() || ! $this->settings->get( 'rest_protection_enabled', true ) || ! ( $request instanceof WP_REST_Request ) ) {
      return $response;
    }

    if ( is_user_logged_in() || $this->should_bypass_current_request() ) {
      return $response;
    }

    $route  = (string) $request->get_route();
    $method = strtoupper( (string) $request->get_method() );

    if (
      $this->settings->get( 'rest_block_anonymous_writes', false ) &&
      in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) &&
      ! $this->is_rest_write_route_allowlisted( $route )
    ) {
      if ( $this->settings->is_monitor_mode() ) {
        $this->log( 'monitored', 'rest_anonymous_write', array( 'details' => $route ) );
        return $response;
      }

      $this->log( 'blocked', 'rest_anonymous_write', array( 'details' => $route ) );
      return new WP_Error(
        'pdt_firewall_rest_write_blocked',
        __( 'Anonymous REST API write requests are blocked by the firewall.', 'polanger-admin-menu-manager' ),
        array( 'status' => 401 )
      );
    }

    if ( $this->settings->get( 'rest_block_user_enumeration', true ) && preg_match( '#^/wp/v2/users(?:/|$)#', $route ) ) {
      if ( $this->settings->is_monitor_mode() ) {
        $this->log( 'monitored', 'rest_user_enumeration', array( 'details' => $route ) );
        return $response;
      }

      $this->log( 'blocked', 'rest_user_enumeration', array( 'details' => $route ) );
      return new WP_Error(
        'pdt_firewall_rest_users_blocked',
        __( 'Anonymous user listing is blocked by the firewall.', 'polanger-admin-menu-manager' ),
        array( 'status' => 401 )
      );
    }

    return $response;
  }

  /**
   * Add low-risk security response headers.
   *
   * @return void
   */
  public function send_security_headers() {
    if ( ! $this->should_run() || ! $this->settings->get( 'security_headers_enabled', false ) || headers_sent() ) {
      return;
    }

    if ( $this->settings->get( 'security_headers_nosniff', true ) ) {
      header( 'X-Content-Type-Options: nosniff' );
    }

    if ( $this->settings->get( 'security_headers_frame_options', true ) ) {
      header( 'X-Frame-Options: SAMEORIGIN' );
    }

    if ( $this->settings->get( 'security_headers_referrer', true ) ) {
      header( 'Referrer-Policy: strict-origin-when-cross-origin' );
    }

    if ( $this->settings->get( 'security_headers_permissions', true ) ) {
      header( 'Permissions-Policy: camera=(), microphone=(), geolocation=()' );
    }

    if ( $this->settings->get( 'security_headers_csp_frame', false ) ) {
      header( "Content-Security-Policy: frame-ancestors 'self'" );
    }
  }

  /**
   * Restrict Application Password availability without touching browser login.
   *
   * @param bool        $available Current availability.
   * @param int|WP_User $user      User object or ID.
   * @return bool
   */
  public function filter_application_passwords_available_for_user( $available, $user ) {
    if ( ! $available || ! $this->should_run() || ! $this->settings->get( 'app_passwords_guard_enabled', true ) ) {
      return $available;
    }

    if ( $this->settings->is_monitor_mode() || ! $this->settings->get( 'app_passwords_admin_only', false ) ) {
      return $available;
    }

    $user_id = $user instanceof WP_User ? (int) $user->ID : absint( $user );
    if ( $user_id > 0 && user_can( $user_id, 'manage_options' ) ) {
      return $available;
    }

    $this->log( 'blocked', 'application_password_non_admin' );

    return false;
  }

  /**
   * Log successful Application Password use.
   *
   * @param WP_User $user User.
   * @param array   $item Password item.
   * @return void
   */
  public function record_application_password_success( $user, $item ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'app_passwords_guard_enabled', true ) || ! $this->settings->get( 'app_passwords_log_usage', true ) ) {
      return;
    }

    $details = $user instanceof WP_User ? $user->user_login : '';
    if ( is_array( $item ) && ! empty( $item['name'] ) ) {
      $details .= '' !== $details ? ' / ' . sanitize_text_field( $item['name'] ) : sanitize_text_field( $item['name'] );
    }

    $this->log( 'monitored', 'application_password_success', array( 'details' => $details ) );
  }

  /**
   * Log failed Application Password authentication.
   *
   * @param WP_Error $error Error object.
   * @return void
   */
  public function record_application_password_failure( $error ) {
    if ( ! $this->should_run() || ! $this->settings->get( 'app_passwords_guard_enabled', true ) || ! $this->settings->get( 'app_passwords_log_usage', true ) ) {
      return;
    }

    $details = $error instanceof WP_Error ? $error->get_error_code() : '';
    $this->log( 'monitored', 'application_password_failure', array( 'details' => $details ) );
  }

  /**
   * Whether a REST write route is explicitly allowed.
   *
   * @param string $route REST route.
   * @return bool
   */
  private function is_rest_write_route_allowlisted( $route ) {
    $route = '/' . ltrim( (string) $route, '/' );
    $route = untrailingslashit( $route ) . '/';

    $prefixes = array_merge(
      $this->settings->get_path_prefix_list( 'rest_write_route_allowlist' ),
      $this->get_compatibility_rest_write_allowlist()
    );

    foreach ( array_unique( $prefixes ) as $prefix ) {
      $prefix = untrailingslashit( '/' . ltrim( (string) $prefix, '/' ) ) . '/';
      if ( 0 === strpos( $route, $prefix ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Return REST write prefixes that should stay compatible even in Strict mode.
   *
   * @return array
   */
  private function get_compatibility_rest_write_allowlist() {
    $prefixes = array();

    if ( class_exists( 'WooCommerce' ) || defined( 'WC_VERSION' ) || function_exists( 'WC' ) ) {
      $prefixes[] = '/wc/store/';
    }

    /**
     * Filter compatibility REST write prefixes that bypass anonymous write blocking.
     *
     * @param array $prefixes Path prefixes.
     */
    return (array) apply_filters( 'pdt_firewall_compat_rest_write_allowlist', $prefixes );
  }

  /**
   * Whether runtime should run.
   *
   * @return bool
   */
  private function should_run() {
    return $this->settings->is_enabled();
  }

  /**
   * Whether current request should bypass firewall enforcement.
   *
   * @return bool
   */
  private function should_bypass_current_request() {
    $ip = $this->get_client_ip();

    if ( $this->is_allowlisted_ip( $ip ) ) {
      return true;
    }

    if ( $this->settings->get( 'admin_bypass', true ) && is_user_logged_in() && current_user_can( 'manage_options' ) ) {
      return true;
    }

    return false;
  }

  /**
   * Whether an IP is explicitly allowlisted.
   *
   * @param string $ip Client IP.
   * @return bool
   */
  private function is_allowlisted_ip( $ip ) {
    return '' !== $ip && $this->ip_matches_list( $ip, $this->settings->get_ip_list( 'allowlist_ips' ) );
  }

  /**
   * Block current request or only monitor in monitor mode.
   *
   * @param string $reason  Reason code.
   * @param int    $status  HTTP status.
   * @param string $message Public message.
   * @return void
   */
  private function block_or_monitor( $reason, $status, $message ) {
    $decision = $this->settings->is_monitor_mode() ? 'monitored' : 'blocked';
    $this->log( $decision, $reason );

    if ( $this->settings->is_monitor_mode() ) {
      return;
    }

    if ( wp_doing_ajax() ) {
      wp_send_json_error( array( 'message' => $message ), $status );
    }

    status_header( $status );
    nocache_headers();

    wp_die(
      esc_html( $message ),
      esc_html__( 'Request blocked', 'polanger-admin-menu-manager' ),
      array( 'response' => $status )
    );
  }

  /**
   * Detect lightweight user enumeration attempts.
   *
   * @return string
   */
  private function detect_user_enumeration_reason() {
    if ( ! $this->settings->get( 'user_enumeration_guard_enabled', true ) || ! $this->settings->get( 'user_enumeration_author_query', true ) ) {
      return '';
    }

    if ( is_admin() || wp_doing_ajax() || $this->is_rest_request() ) {
      return '';
    }

    if ( ! isset( $_GET['author'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
      return '';
    }

    $author = sanitize_text_field( wp_unslash( $_GET['author'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( ctype_digit( $author ) && (int) $author > 0 ) {
      return 'author_id_enumeration';
    }

    return '';
  }

  /**
   * Score suspicious request signals and block/monitor when thresholds match.
   *
   * @param string $ip Client IP.
   * @return bool Whether request handling is complete.
   */
  private function maybe_handle_request_score( $ip ) {
    if ( ! $this->settings->get( 'request_scoring_enabled', true ) ) {
      return false;
    }

    $result = $this->calculate_request_score();
    $score  = isset( $result['score'] ) ? absint( $result['score'] ) : 0;
    $reason = ! empty( $result['reason'] ) ? sanitize_key( $result['reason'] ) : 'request_score';

    if ( $score < (int) $this->settings->get( 'request_score_threshold', 5 ) ) {
      return false;
    }

    if (
      ! $this->settings->is_monitor_mode() &&
      $score >= (int) $this->settings->get( 'request_score_temp_threshold', 8 ) &&
      '' !== $ip &&
      $this->settings->get( 'temp_block_enabled', true )
    ) {
      $minutes = max( 1, (int) $this->settings->get( 'temp_block_minutes', 30 ) );
      set_transient( $this->get_temp_block_key( $ip ), array( 'reason' => $reason, 'created' => time() ), $minutes * MINUTE_IN_SECONDS );
    }

    $this->block_or_monitor( $reason, 403, __( 'This request was blocked by the firewall.', 'polanger-admin-menu-manager' ) );

    return true;
  }

  /**
   * Calculate a simple request anomaly score.
   *
   * @return array
   */
  private function calculate_request_score() {
    $score   = 0;
    $reasons = array();
    $method  = $this->get_request_method();
    $uri     = $this->get_request_uri();
    $query   = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : '';
    $ua      = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';
    $decoded = strtolower( rawurldecode( $uri . '?' . $query ) );

    if ( in_array( $method, array( 'TRACE', 'TRACK' ), true ) ) {
      $score += 5;
      $reasons[] = 'dangerous_method';
    }

    if ( '' === $ua ) {
      $score += 1;
      $reasons[] = 'empty_user_agent';
    } elseif ( preg_match( '#(?:sqlmap|nikto|acunetix|nessus|wpscan|masscan|nmap|curl|python-requests|libwww-perl|go-http-client)#i', $ua ) ) {
      $score += 3;
      $reasons[] = 'suspicious_user_agent';
    }

    if ( preg_match( '#(?:/\.env|/\.git|wp-config\.php|/phpmyadmin|/adminer|/vendor/phpunit|/composer\.(?:json|lock))#i', $decoded ) ) {
      $score += 4;
      $reasons[] = 'sensitive_path';
    }

    if ( preg_match( '#(?:\.\./|%2e%2e|%00|<script|base64_decode|eval\(|GLOBALS\[|_REQUEST\[|/etc/passwd)#i', $decoded ) ) {
      $score += 4;
      $reasons[] = 'injection_pattern';
    }

    if ( preg_match( '#(?:union(?:\\s|%20)+select|sleep\\s*\\(|benchmark\\s*\\(|information_schema|load_file\\s*\\()#i', $decoded ) ) {
      $score += 3;
      $reasons[] = 'sql_probe';
    }

    if ( strlen( $uri ) > (int) $this->settings->get( 'max_url_length', 2048 ) ) {
      $score += 2;
      $reasons[] = 'long_url';
    }

    if ( strlen( $query ) > (int) $this->settings->get( 'max_query_length', 1500 ) ) {
      $score += 2;
      $reasons[] = 'long_query';
    }

    return array(
      'score'  => $score,
      'reason' => ! empty( $reasons ) ? 'score_' . sanitize_key( $reasons[0] ) : 'request_score',
    );
  }

  /**
   * Detect obvious probe/anomaly reason.
   *
   * @return string
   */
  private function detect_probe_reason() {
    $method = $this->get_request_method();
    if ( in_array( $method, array( 'TRACE', 'TRACK' ), true ) ) {
      return 'dangerous_http_method';
    }

    $uri        = $this->get_request_uri();
    $query      = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : '';
    $uri_length = strlen( $uri );

    if ( $uri_length > (int) $this->settings->get( 'max_url_length', 2048 ) ) {
      return 'url_too_long';
    }

    if ( strlen( $query ) > (int) $this->settings->get( 'max_query_length', 1500 ) ) {
      return 'query_too_long';
    }

    $decoded_uri = rawurldecode( $uri );
    if ( false !== strpos( $decoded_uri, "\0" ) || preg_match( '#(?:^|/)\.\.(?:/|$)#', $decoded_uri ) || false !== stripos( $decoded_uri, '%00' ) ) {
      return 'path_traversal_probe';
    }

    $path = wp_parse_url( home_url( $uri ), PHP_URL_PATH );
    $path = is_string( $path ) ? strtolower( $path ) : strtolower( $uri );

    $probe_patterns = array(
      '#/(?:\.env|\.git|\.svn)(?:/|$)#',
      '#/(?:wp-config|composer|package-lock|yarn\.lock|phpunit)(?:\.|$)#',
      '#/(?:vendor/phpunit|phpmyadmin|pma|adminer)(?:/|$)#',
      '#/(?:backup|database|db|dump|site|wordpress|wp)(?:[-_\.].*)?\.(?:sql|zip|tar|gz|rar|7z)$#',
      '#/(?:shell|cmd|wso|c99|r57|leaf|mini|up)\.php$#',
    );

    foreach ( $probe_patterns as $pattern ) {
      if ( preg_match( $pattern, $path ) ) {
        return 'known_probe_path';
      }
    }

    return '';
  }

  /**
   * Record one probe strike and set temporary block when threshold is reached.
   *
   * @param string $ip     Client IP.
   * @param string $reason Reason.
   * @return void
   */
  private function record_probe_strike( $ip, $reason ) {
    if ( '' === $ip || $this->settings->is_monitor_mode() || ! $this->settings->get( 'temp_block_enabled', true ) ) {
      return;
    }

    $window = max( 1, (int) $this->settings->get( 'probe_window_minutes', 10 ) ) * MINUTE_IN_SECONDS;
    $state  = $this->increment_rate_bucket( 'probe', 'ip', $ip, $window );

    if ( ! empty( $state['count'] ) && (int) $state['count'] >= (int) $this->settings->get( 'probe_strikes', 3 ) ) {
      $minutes = max( 1, (int) $this->settings->get( 'temp_block_minutes', 30 ) );
      set_transient( $this->get_temp_block_key( $ip ), array( 'reason' => sanitize_key( $reason ), 'created' => time() ), $minutes * MINUTE_IN_SECONDS );
    }
  }

  /**
   * Check if IP is temporarily blocked.
   *
   * @param string $ip Client IP.
   * @return bool
   */
  private function is_temp_blocked( $ip ) {
    if ( '' === $ip || ! $this->settings->get( 'temp_block_enabled', true ) ) {
      return false;
    }

    return false !== get_transient( $this->get_temp_block_key( $ip ) );
  }

  /**
   * Resolve current auth limit state.
   *
   * @param string $action   Action key.
   * @param string $identity Identity.
   * @return array
   */
  private function get_auth_limit_state( $action, $identity ) {
    $profile      = $this->settings->get_auth_limit_profile( $action );
    $identity_key = $this->normalize_identity( $identity ) . '|' . $this->get_client_ip();
    $ip           = $this->get_client_ip();

    $identity_state = '' !== trim( $identity_key, '|' ) ? $this->get_rate_bucket_state( $action, 'identity_ip', $identity_key ) : array();
    $ip_state       = '' !== $ip ? $this->get_rate_bucket_state( $action, 'ip', $ip ) : array();

    $identity_limited = ! empty( $identity_state ) && $this->bucket_reached_limit( $identity_state, $profile['max_attempts'] );
    $ip_limited       = ! empty( $ip_state ) && $this->bucket_reached_limit( $ip_state, $profile['ip_max_attempts'] );

    return array(
      'limited'   => $identity_limited || $ip_limited,
      'remaining' => max( $this->bucket_remaining_seconds( $identity_state ), $this->bucket_remaining_seconds( $ip_state ) ),
    );
  }

  /**
   * Record one auth attempt.
   *
   * @param string $action   Action key.
   * @param string $identity Identity.
   * @return void
   */
  private function record_auth_attempt( $action, $identity ) {
    $profile = $this->settings->get_auth_limit_profile( $action );
    $window  = max( 1, absint( $profile['window_minutes'] ) ) * MINUTE_IN_SECONDS;
    $ip      = $this->get_client_ip();

    $identity_key = $this->normalize_identity( $identity ) . '|' . $ip;
    if ( '' !== trim( $identity_key, '|' ) ) {
      $this->increment_rate_bucket( $action, 'identity_ip', $identity_key, $window );
    }

    if ( '' !== $ip ) {
      $this->increment_rate_bucket( $action, 'ip', $ip, $window );
    }
  }

  /**
   * Clear identity+IP bucket for successful login.
   *
   * @param string $action   Action key.
   * @param string $identity Identity.
   * @return void
   */
  private function clear_auth_identity_bucket( $action, $identity ) {
    $ip           = $this->get_client_ip();
    $identity_key = $this->normalize_identity( $identity ) . '|' . $ip;

    if ( '' !== trim( $identity_key, '|' ) ) {
      delete_transient( $this->get_rate_bucket_key( $action, 'identity_ip', $identity_key ) );
    }
  }

  /**
   * Get rate bucket state.
   *
   * @param string $action     Action key.
   * @param string $bucket     Bucket type.
   * @param string $identifier Identifier.
   * @return array
   */
  private function get_rate_bucket_state( $action, $bucket, $identifier ) {
    $identifier = trim( (string) $identifier );
    if ( '' === $identifier ) {
      return array();
    }

    $state = get_transient( $this->get_rate_bucket_key( $action, $bucket, $identifier ) );

    return is_array( $state ) ? $state : array();
  }

  /**
   * Increment rate bucket state.
   *
   * @param string $action     Action key.
   * @param string $bucket     Bucket type.
   * @param string $identifier Identifier.
   * @param int    $window     Window in seconds.
   * @return array
   */
  private function increment_rate_bucket( $action, $bucket, $identifier, $window ) {
    $identifier = trim( (string) $identifier );
    if ( '' === $identifier ) {
      return array();
    }

    $key   = $this->get_rate_bucket_key( $action, $bucket, $identifier );
    $state = get_transient( $key );

    if ( ! is_array( $state ) || empty( $state['window'] ) || absint( $state['window'] ) !== absint( $window ) ) {
      $state = array(
        'count'   => 0,
        'created' => time(),
        'window'  => absint( $window ),
      );
    }

    $state['count'] = isset( $state['count'] ) ? absint( $state['count'] ) + 1 : 1;
    set_transient( $key, $state, absint( $window ) );

    return $state;
  }

  /**
   * Check if bucket reached limit.
   *
   * @param array $state Bucket state.
   * @param int   $limit Limit.
   * @return bool
   */
  private function bucket_reached_limit( $state, $limit ) {
    return ! empty( $state['count'] ) && absint( $state['count'] ) >= absint( $limit );
  }

  /**
   * Get bucket remaining seconds.
   *
   * @param array $state Bucket state.
   * @return int
   */
  private function bucket_remaining_seconds( $state ) {
    if ( empty( $state['created'] ) || empty( $state['window'] ) ) {
      return 0;
    }

    return max( 0, absint( $state['window'] ) - ( time() - absint( $state['created'] ) ) );
  }

  /**
   * Get rate bucket key.
   *
   * @param string $action     Action key.
   * @param string $bucket     Bucket type.
   * @param string $identifier Identifier.
   * @return string
   */
  private function get_rate_bucket_key( $action, $bucket, $identifier ) {
    return 'pdt_fw_rl_v2_' . md5( sanitize_key( $action ) . '|' . sanitize_key( $bucket ) . '|' . strtolower( (string) $identifier ) );
  }

  /**
   * Get temporary block transient key.
   *
   * @param string $ip IP.
   * @return string
   */
  private function get_temp_block_key( $ip ) {
    return 'pdt_fw_block_v2_' . md5( strtolower( (string) $ip ) );
  }

  /**
   * Format rate-limit message.
   *
   * @param string $template          Message template.
   * @param int    $remaining_seconds Remaining seconds.
   * @return string
   */
  private function format_rate_limit_message( $template, $remaining_seconds ) {
    $minutes = max( 1, (int) ceil( absint( $remaining_seconds ) / MINUTE_IN_SECONDS ) );

    return sprintf( $template, $minutes );
  }

  /**
   * Whether errors contain a firewall rate-limit error.
   *
   * @param mixed $errors Errors.
   * @return bool
   */
  private function has_firewall_error( $errors ) {
    if ( ! ( $errors instanceof WP_Error ) ) {
      return false;
    }

    foreach ( array( 'pdt_firewall_lostpassword_rate_limit', 'pdt_firewall_registration_rate_limit' ) as $code ) {
      if ( $errors->get_error_message( $code ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Whether current request is native wp-login submit.
   *
   * @return bool
   */
  private function is_native_login_attempt() {
    return isset( $_POST['log'], $_POST['pwd'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
  }

  /**
   * Whether current request is native lost password submit.
   *
   * @return bool
   */
  private function is_native_lostpassword_request() {
    if ( isset( $_POST['wc_reset_password'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
      return false;
    }

    $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

    return 'lostpassword' === $action && isset( $_POST['user_login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
  }

  /**
   * Whether current request is native registration submit.
   *
   * @return bool
   */
  private function is_native_registration_request() {
    $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

    return 'register' === $action && isset( $_POST['user_login'], $_POST['user_email'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
  }

  /**
   * Whether current request targets REST API.
   *
   * @return bool
   */
  private function is_rest_request() {
    if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
      return true;
    }

    $prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/';
    $uri    = $this->get_request_uri();

    return false !== strpos( $uri, $prefix );
  }

  /**
   * Log if enabled.
   *
   * @param string $decision Decision.
   * @param string $reason   Reason.
   * @param array  $context  Context.
   * @return void
   */
  private function log( $decision, $reason, $context = array() ) {
    if ( ! $this->settings->get( 'log_events', true ) ) {
      return;
    }

    $context['ip']     = $this->get_client_ip();
    $context['uri']    = $this->get_request_uri();
    $context['method'] = $this->get_request_method();

    $this->logger->log_event( $decision, $reason, $context );
  }

  /**
   * Get normalized request URI.
   *
   * @return string
   */
  private function get_request_uri() {
    if ( empty( $_SERVER['REQUEST_URI'] ) ) {
      return '/';
    }

    $uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] );
    $uri = str_replace( "\0", '%00', $uri );
    $uri = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $uri );

    return '' !== $uri ? $uri : '/';
  }

  /**
   * Get request method.
   *
   * @return string
   */
  private function get_request_method() {
    if ( empty( $_SERVER['REQUEST_METHOD'] ) ) {
      return 'GET';
    }

    return strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) );
  }

  /**
   * Get client IP.
   *
   * @return string
   */
  private function get_client_ip() {
    if ( null !== $this->client_ip ) {
      return $this->client_ip;
    }

    $remote_addr = $this->get_remote_addr();
    $ip          = $remote_addr;

    if (
      '' !== $remote_addr &&
      $this->settings->get( 'trusted_proxy_headers_enabled', false ) &&
      $this->ip_matches_list( $remote_addr, $this->settings->get_ip_list( 'trusted_proxy_ips' ) )
    ) {
      $header_ip = $this->get_trusted_proxy_header_ip( $this->settings->get( 'proxy_ip_header', 'none' ) );
      if ( '' !== $header_ip ) {
        $ip = $header_ip;
      }
    }

    $this->client_ip = filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';

    return $this->client_ip;
  }

  /**
   * Get direct remote address.
   *
   * @return string
   */
  private function get_remote_addr() {
    if ( empty( $_SERVER['REMOTE_ADDR'] ) ) {
      return '';
    }

    $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );

    return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
  }

  /**
   * Resolve client IP from a trusted proxy header.
   *
   * @param string $header Header key.
   * @return string
   */
  private function get_trusted_proxy_header_ip( $header ) {
    switch ( sanitize_key( $header ) ) {
      case 'cf_connecting_ip':
        return $this->get_server_ip_header( 'HTTP_CF_CONNECTING_IP' );
      case 'x_forwarded_for':
        return $this->get_forwarded_for_ip();
      case 'x_real_ip':
        return $this->get_server_ip_header( 'HTTP_X_REAL_IP' );
      case 'none':
      default:
        return '';
    }
  }

  /**
   * Get a single IP header from server vars.
   *
   * @param string $server_key Server key.
   * @return string
   */
  private function get_server_ip_header( $server_key ) {
    if ( empty( $_SERVER[ $server_key ] ) ) {
      return '';
    }

    $ip = sanitize_text_field( wp_unslash( $_SERVER[ $server_key ] ) );

    return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
  }

  /**
   * Get first valid IP from X-Forwarded-For.
   *
   * @return string
   */
  private function get_forwarded_for_ip() {
    if ( empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
      return '';
    }

    $raw = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
    foreach ( explode( ',', $raw ) as $candidate ) {
      $candidate = trim( $candidate );
      if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
        return $candidate;
      }
    }

    return '';
  }

  /**
   * Normalize identity key.
   *
   * @param string $identity Identity.
   * @return string
   */
  private function normalize_identity( $identity ) {
    $identity = sanitize_text_field( (string) $identity );

    return strtolower( trim( $identity ) );
  }

  /**
   * Check IP against list of exact IPs/CIDRs.
   *
   * @param string $ip   IP.
   * @param array  $list List.
   * @return bool
   */
  private function ip_matches_list( $ip, $list ) {
    if ( '' === $ip || empty( $list ) || ! is_array( $list ) ) {
      return false;
    }

    foreach ( $list as $entry ) {
      $entry = trim( (string) $entry );
      if ( '' === $entry ) {
        continue;
      }

      if ( false === strpos( $entry, '/' ) && $ip === $entry ) {
        return true;
      }

      if ( false !== strpos( $entry, '/' ) && $this->ip_matches_cidr( $ip, $entry ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Check IP against CIDR.
   *
   * @param string $ip   IP.
   * @param string $cidr CIDR.
   * @return bool
   */
  private function ip_matches_cidr( $ip, $cidr ) {
    list( $subnet, $bits ) = array_pad( explode( '/', $cidr, 2 ), 2, '' );
    $bits = absint( $bits );

    $ip_bin     = @inet_pton( $ip );
    $subnet_bin = @inet_pton( $subnet );

    if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
      return false;
    }

    $bytes = (int) floor( $bits / 8 );
    $remainder = $bits % 8;

    if ( $bytes > 0 && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) {
      return false;
    }

    if ( 0 === $remainder ) {
      return true;
    }

    $mask = chr( ( 0xff << ( 8 - $remainder ) ) & 0xff );

    $ip_byte     = ord( $ip_bin[ $bytes ] );
    $subnet_byte = ord( $subnet_bin[ $bytes ] );
    $mask_byte   = ord( $mask );

    return ( $ip_byte & $mask_byte ) === ( $subnet_byte & $mask_byte );
  }
}
