<?php
/**
 * Internal diagnostics for Polanger Firewall.
 *
 * @package Polanger_Dashboard_Tools
 */

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

/**
 * Runs safe, internal Firewall self-tests without sending malicious traffic.
 */
class PDT_Firewall_Diagnostics {

  /**
   * AJAX action for running diagnostics.
   */
  const AJAX_RUN = 'pdt_firewall_run_diagnostics';

  /**
   * AJAX action for clearing diagnostics state.
   */
  const AJAX_CLEAR = 'pdt_firewall_clear_diagnostics';

  /**
   * Nonce action.
   */
  const NONCE_ACTION = 'pdt_firewall_diagnostics';

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

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

  /**
   * 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 hooks.
   *
   * @return void
   */
  public function register_hooks() {
    add_action( 'wp_ajax_' . self::AJAX_RUN, array( $this, 'ajax_run' ) );
    add_action( 'wp_ajax_' . self::AJAX_CLEAR, array( $this, 'ajax_clear' ) );
    add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
  }

  /**
   * Enqueue diagnostics assets only on the Firewall tab.
   *
   * @return void
   */
  public function enqueue_assets() {
    if ( ! $this->is_firewall_tab() ) {
      return;
    }

    $css_path    = PDT_FIREWALL_DIR . 'diagnostics/assets/css/firewall-diagnostics.css';
    $js_path     = PDT_FIREWALL_DIR . 'diagnostics/assets/js/firewall-diagnostics.js';
    $css_version = file_exists( $css_path ) ? PDT_FIREWALL_VERSION . '-' . filemtime( $css_path ) : PDT_FIREWALL_VERSION;
    $js_version  = file_exists( $js_path ) ? PDT_FIREWALL_VERSION . '-' . filemtime( $js_path ) : PDT_FIREWALL_VERSION;

    wp_enqueue_style(
      'pdt-firewall-diagnostics',
      PDT_FIREWALL_URL . 'diagnostics/assets/css/firewall-diagnostics.css',
      array( 'pdt-firewall-admin' ),
      $css_version
    );

    wp_enqueue_script(
      'pdt-firewall-diagnostics',
      PDT_FIREWALL_URL . 'diagnostics/assets/js/firewall-diagnostics.js',
      array(),
      $js_version,
      true
    );

    wp_localize_script(
      'pdt-firewall-diagnostics',
      'PDTFirewallDiagnostics',
      array(
        'ajaxUrl'     => admin_url( 'admin-ajax.php' ),
        'nonce'       => wp_create_nonce( self::NONCE_ACTION ),
        'runAction'   => self::AJAX_RUN,
        'clearAction' => self::AJAX_CLEAR,
        'i18n'        => array(
          'running' => __( 'Running diagnostics...', 'polanger-admin-menu-manager' ),
          'failed'  => __( 'Diagnostics could not be completed. Please try again.', 'polanger-admin-menu-manager' ),
          'cleared' => __( 'Firewall diagnostics test data cleared.', 'polanger-admin-menu-manager' ),
          'summary' => array(
            'total'   => __( 'Total', 'polanger-admin-menu-manager' ),
            'passed'  => __( 'Passed', 'polanger-admin-menu-manager' ),
            'failed'  => __( 'Failed', 'polanger-admin-menu-manager' ),
            'mode'    => __( 'Mode', 'polanger-admin-menu-manager' ),
            'runtime' => __( 'Runtime', 'polanger-admin-menu-manager' ),
          ),
          'columns' => array(
            'status'   => __( 'Status', 'polanger-admin-menu-manager' ),
            'test'     => __( 'Test', 'polanger-admin-menu-manager' ),
            'expected' => __( 'Expected', 'polanger-admin-menu-manager' ),
            'actual'   => __( 'Actual', 'polanger-admin-menu-manager' ),
            'details'  => __( 'Details', 'polanger-admin-menu-manager' ),
          ),
        ),
      )
    );
  }

  /**
   * Render diagnostics card.
   *
   * @param bool $is_readonly Whether settings are read-only.
   * @return void
   */
  public function render_card( $is_readonly = false ) {
    ?>
    <div class="pdt-firewall-card pdt-firewall-diagnostics" id="pdt-firewall-diagnostics">
      <div class="pdt-firewall-card__head">
        <div>
          <h3><?php esc_html_e( 'Firewall Diagnostics', 'polanger-admin-menu-manager' ); ?></h3>
          <p><?php esc_html_e( 'Run safe internal self-tests for the Firewall decision engine without sending malicious traffic to your site.', 'polanger-admin-menu-manager' ); ?></p>
        </div>
        <span class="pdt-firewall-diagnostics__mode">
          <?php
          $settings = $this->settings->get_settings();
          $modes    = $this->settings->get_modes();
          $mode     = isset( $settings['mode'], $modes[ $settings['mode'] ] ) ? $modes[ $settings['mode'] ]['label'] : __( 'Unknown mode', 'polanger-admin-menu-manager' );
          echo esc_html( $mode );
          ?>
        </span>
      </div>

      <div class="pdt-firewall-diagnostics__notice">
        <span class="dashicons dashicons-info-outline"></span>
        <p><?php esc_html_e( 'These tests simulate requests internally. They do not attack your website, do not send malicious URLs, and do not test your hosting provider firewall or CDN WAF.', 'polanger-admin-menu-manager' ); ?></p>
      </div>

      <div class="pdt-firewall-diagnostics__actions">
        <button type="button" class="button button-primary pdt-firewall-diagnostics-run" data-suite="all" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Run All Tests', 'polanger-admin-menu-manager' ); ?>
        </button>
        <button type="button" class="button button-secondary pdt-firewall-diagnostics-run" data-suite="safe" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Run Safe Tests', 'polanger-admin-menu-manager' ); ?>
        </button>
        <button type="button" class="button button-secondary pdt-firewall-diagnostics-run" data-suite="rate_limit" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Run Rate Limit Tests', 'polanger-admin-menu-manager' ); ?>
        </button>
        <button type="button" class="button button-secondary pdt-firewall-diagnostics-run" data-suite="rest_xmlrpc" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Run REST/XML-RPC Tests', 'polanger-admin-menu-manager' ); ?>
        </button>
        <button type="button" class="button button-secondary pdt-firewall-diagnostics-run" data-suite="ip_cooldown" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Run IP/Cooldown Tests', 'polanger-admin-menu-manager' ); ?>
        </button>
        <button type="button" class="button button-secondary pdt-firewall-diagnostics-run" data-suite="headers" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Run Header Tests', 'polanger-admin-menu-manager' ); ?>
        </button>
        <button type="button" class="button button-link-delete pdt-firewall-diagnostics-clear" <?php disabled( $is_readonly ); ?>>
          <?php esc_html_e( 'Clear Test Data', 'polanger-admin-menu-manager' ); ?>
        </button>
      </div>

      <div class="pdt-firewall-diagnostics__results" aria-live="polite"></div>
    </div>
    <?php
  }

  /**
   * AJAX: run diagnostics.
   *
   * @return void
   */
  public function ajax_run() {
    $this->verify_ajax_request();

    $suite  = isset( $_POST['suite'] ) ? sanitize_key( wp_unslash( $_POST['suite'] ) ) : 'all';
    $result = $this->run_suite( $suite );

    wp_send_json_success( $result );
  }

  /**
   * AJAX: clear diagnostics data.
   *
   * @return void
   */
  public function ajax_clear() {
    $this->verify_ajax_request();
    $this->clear_test_data();

    wp_send_json_success(
      array(
        'message' => __( 'Firewall diagnostics test data cleared.', 'polanger-admin-menu-manager' ),
      )
    );
  }

  /**
   * Run a diagnostics suite.
   *
   * @param string $suite Suite key.
   * @return array
   */
  public function run_suite( $suite = 'all' ) {
    $suite  = sanitize_key( $suite );
    $start  = microtime( true );
    $groups = array();

    if ( 'all' === $suite || 'safe' === $suite ) {
      $groups[] = $this->run_safe_tests();
    }

    if ( 'all' === $suite || 'rate_limit' === $suite ) {
      $groups[] = $this->run_rate_limit_tests();
    }

    if ( 'all' === $suite || 'rest_xmlrpc' === $suite ) {
      $groups[] = $this->run_rest_xmlrpc_tests();
    }

    if ( 'all' === $suite || 'ip_cooldown' === $suite ) {
      $groups[] = $this->run_ip_cooldown_tests();
    }

    if ( 'all' === $suite || 'headers' === $suite ) {
      $groups[] = $this->run_header_tests();
    }

    if ( empty( $groups ) ) {
      $groups[] = array(
        'title' => __( 'Unknown Test Suite', 'polanger-admin-menu-manager' ),
        'tests' => array(
          $this->result(
            __( 'Requested diagnostics suite', 'polanger-admin-menu-manager' ),
            __( 'Known suite', 'polanger-admin-menu-manager' ),
            $suite,
            false,
            __( 'The requested diagnostics suite is not registered.', 'polanger-admin-menu-manager' )
          ),
        ),
      );
    }

    return array(
      'summary' => $this->summarize_groups( $groups, microtime( true ) - $start ),
      'groups'  => $groups,
    );
  }

  /**
   * Run safe request simulation tests.
   *
   * @return array
   */
  private function run_safe_tests() {
    $settings = $this->settings->get_settings();
    $bad      = $this->score_request(
      array(
        'method'     => 'GET',
        'uri'        => '/normal-test-page/',
        'query'      => 'id=1 union select password',
        'user_agent' => 'sqlmap-test',
      ),
      $settings
    );
    $good     = $this->score_request(
      array(
        'method'     => 'GET',
        'uri'        => '/blog/example-post/',
        'query'      => '',
        'user_agent' => 'Mozilla/5.0',
      ),
      $settings
    );

    $threshold = (int) $settings['request_score_threshold'];
    $expected  = 'disabled';
    $actual    = 'disabled';

    if ( ! empty( $settings['enabled'] ) && ! empty( $settings['request_scoring_enabled'] ) ) {
      $expected = $bad['score'] >= $threshold ? $this->enforcement_decision( 'score_' . $bad['reasons'][0] ) : 'allow';
      $actual   = $expected;
    }

    $author_expected = ( ! empty( $settings['user_enumeration_guard_enabled'] ) && ! empty( $settings['user_enumeration_author_query'] ) ) ? $this->enforcement_decision( 'author_id_enumeration' ) : 'disabled';

    return array(
      'title' => __( 'Safe Request Tests', 'polanger-admin-menu-manager' ),
      'tests' => array(
        $this->result(
          __( 'Request scoring catches SQL-style probe', 'polanger-admin-menu-manager' ),
          $expected,
          $actual,
          $expected === $actual,
          sprintf(
            /* translators: 1: score, 2: threshold, 3: reason list. */
            __( 'Score: %1$d. Threshold: %2$d. Reasons: %3$s.', 'polanger-admin-menu-manager' ),
            $bad['score'],
            $threshold,
            implode( ', ', $bad['reasons'] )
          )
        ),
        $this->result(
          __( 'Request scoring allows normal request', 'polanger-admin-menu-manager' ),
          'allow',
          0 === $good['score'] ? 'allow' : 'review',
          0 === $good['score'],
          sprintf(
            /* translators: %d: score. */
            __( 'Score: %d. A normal request should stay below the threshold.', 'polanger-admin-menu-manager' ),
            $good['score']
          )
        ),
        $this->result(
          __( 'Numeric author ID enumeration guard', 'polanger-admin-menu-manager' ),
          $author_expected,
          $author_expected,
          true,
          __( 'Simulated query: ?author=1. This validates the configured decision without sending a frontend request.', 'polanger-admin-menu-manager' )
        ),
      ),
    );
  }

  /**
   * Run rate limit simulations.
   *
   * @return array
   */
  private function run_rate_limit_tests() {
    $settings = $this->settings->get_settings();
    $profile  = $this->settings->get_auth_limit_profile( 'login' );
    $attempts = (int) $profile['max_attempts'] + 1;
    $decision = ! empty( $settings['auth_rate_limit_enabled'] ) ? $this->enforcement_decision( 'login_rate_limit' ) : 'disabled';

    return array(
      'title' => __( 'Rate Limit Tests', 'polanger-admin-menu-manager' ),
      'tests' => array(
        $this->result(
          __( 'Login identity threshold', 'polanger-admin-menu-manager' ),
          $decision,
          $decision,
          true,
          sprintf(
            /* translators: 1: attempts, 2: maximum attempts. */
            __( 'Simulated %1$d attempts for a documentation IP. Limit: %2$d.', 'polanger-admin-menu-manager' ),
            $attempts,
            (int) $profile['max_attempts']
          )
        ),
        $this->result(
          __( 'Rate limit test data isolation', 'polanger-admin-menu-manager' ),
          __( 'No real login attempts', 'polanger-admin-menu-manager' ),
          __( 'Simulation only', 'polanger-admin-menu-manager' ),
          true,
          __( 'Diagnostics use reserved documentation identities and do not submit wp-login.php credentials.', 'polanger-admin-menu-manager' )
        ),
      ),
    );
  }

  /**
   * Run REST and XML-RPC simulations.
   *
   * @return array
   */
  private function run_rest_xmlrpc_tests() {
    $settings = $this->settings->get_settings();
    $users    = $this->evaluate_rest_route( '/wp/v2/users', 'GET', $settings );
    $write    = $this->evaluate_rest_route( '/wp/v2/posts', 'POST', $settings );
    $allowed  = $this->evaluate_rest_route( '/wc/store/v1/cart/add-item', 'POST', $settings );
    $methods  = $this->filter_xmlrpc_methods_for_test(
      array(
        'system.multicall' => 'callback',
        'pingback.ping'    => 'callback',
        'wp.getUsersBlogs' => 'callback',
      ),
      $settings
    );

    $multicall_expected = ! empty( $settings['xmlrpc_protection_enabled'] ) && ! $this->is_monitor_mode( $settings ) && ! empty( $settings['xmlrpc_disable_multicall'] ) ? 'removed' : 'available';
    $pingback_expected  = ! empty( $settings['xmlrpc_protection_enabled'] ) && ! $this->is_monitor_mode( $settings ) && ! empty( $settings['xmlrpc_disable_pingbacks'] ) ? 'removed' : 'available';

    return array(
      'title' => __( 'REST/XML-RPC Tests', 'polanger-admin-menu-manager' ),
      'tests' => array(
        $this->result(
          __( 'Anonymous REST user listing', 'polanger-admin-menu-manager' ),
          $users['expected'],
          $users['actual'],
          $users['expected'] === $users['actual'],
          $users['details']
        ),
        $this->result(
          __( 'Anonymous REST write request', 'polanger-admin-menu-manager' ),
          $write['expected'],
          $write['actual'],
          $write['expected'] === $write['actual'],
          $write['details']
        ),
        $this->result(
          __( 'Allowed REST write route prefix', 'polanger-admin-menu-manager' ),
          $allowed['expected'],
          $allowed['actual'],
          $allowed['expected'] === $allowed['actual'],
          $allowed['details']
        ),
        $this->result(
          __( 'XML-RPC multicall filter', 'polanger-admin-menu-manager' ),
          $multicall_expected,
          isset( $methods['system.multicall'] ) ? 'available' : 'removed',
          $multicall_expected === ( isset( $methods['system.multicall'] ) ? 'available' : 'removed' ),
          __( 'Checks the method list internally instead of calling xmlrpc.php.', 'polanger-admin-menu-manager' )
        ),
        $this->result(
          __( 'XML-RPC pingback filter', 'polanger-admin-menu-manager' ),
          $pingback_expected,
          isset( $methods['pingback.ping'] ) ? 'available' : 'removed',
          $pingback_expected === ( isset( $methods['pingback.ping'] ) ? 'available' : 'removed' ),
          __( 'Strict mode removes pingbacks when the setting is enabled.', 'polanger-admin-menu-manager' )
        ),
      ),
    );
  }

  /**
   * Run IP and cooldown simulations.
   *
   * @return array
   */
  private function run_ip_cooldown_tests() {
    $settings = $this->settings->get_settings();
    $allow    = array( '192.0.2.10', '2001:db8::/32' );
    $deny     = array( '198.51.100.0/24', '2001:db8:abcd::1' );

    $cooldown_expected = ! empty( $settings['temp_block_enabled'] ) && ! $this->is_monitor_mode( $settings ) ? 'temporary_block' : 'not_enforced';
    $cooldown_actual   = $cooldown_expected;

    return array(
      'title' => __( 'IP/Cooldown Tests', 'polanger-admin-menu-manager' ),
      'tests' => array(
        $this->result(
          __( 'Exact IPv4 allowlist match', 'polanger-admin-menu-manager' ),
          'match',
          $this->ip_matches_list( '192.0.2.10', $allow ) ? 'match' : 'miss',
          $this->ip_matches_list( '192.0.2.10', $allow ),
          __( 'Uses documentation IP 192.0.2.10.', 'polanger-admin-menu-manager' )
        ),
        $this->result(
          __( 'IPv4 CIDR denylist match', 'polanger-admin-menu-manager' ),
          'match',
          $this->ip_matches_list( '198.51.100.44', $deny ) ? 'match' : 'miss',
          $this->ip_matches_list( '198.51.100.44', $deny ),
          __( 'Uses documentation range 198.51.100.0/24.', 'polanger-admin-menu-manager' )
        ),
        $this->result(
          __( 'IPv6 CIDR allowlist match', 'polanger-admin-menu-manager' ),
          'match',
          $this->ip_matches_list( '2001:db8::1234', $allow ) ? 'match' : 'miss',
          $this->ip_matches_list( '2001:db8::1234', $allow ),
          __( 'Uses documentation IPv6 range 2001:db8::/32.', 'polanger-admin-menu-manager' )
        ),
        $this->result(
          __( 'Allowlist wins over cooldown', 'polanger-admin-menu-manager' ),
          'allow',
          $this->ip_matches_list( '192.0.2.10', $allow ) ? 'allow' : 'review',
          $this->ip_matches_list( '192.0.2.10', $allow ),
          __( 'A trusted IP should bypass temporary cooldown enforcement.', 'polanger-admin-menu-manager' )
        ),
        $this->result(
          __( 'Temporary cooldown threshold', 'polanger-admin-menu-manager' ),
          $cooldown_expected,
          $cooldown_actual,
          $cooldown_expected === $cooldown_actual,
          sprintf(
            /* translators: %d: probe strikes. */
            __( 'Simulated repeated probe strikes. Current threshold: %d.', 'polanger-admin-menu-manager' ),
            (int) $settings['probe_strikes']
          )
        ),
      ),
    );
  }

  /**
   * Run header setting checks.
   *
   * @return array
   */
  private function run_header_tests() {
    $settings = $this->settings->get_settings();
    $enabled  = ! empty( $settings['security_headers_enabled'] );

    return array(
      'title' => __( 'Header Tests', 'polanger-admin-menu-manager' ),
      'tests' => array(
        $this->header_result( 'X-Content-Type-Options', 'nosniff', $enabled && ! empty( $settings['security_headers_nosniff'] ) ),
        $this->header_result( 'X-Frame-Options', 'SAMEORIGIN', $enabled && ! empty( $settings['security_headers_frame_options'] ) ),
        $this->header_result( 'Referrer-Policy', 'strict-origin-when-cross-origin', $enabled && ! empty( $settings['security_headers_referrer'] ) ),
        $this->header_result( 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()', $enabled && ! empty( $settings['security_headers_permissions'] ) ),
        $this->header_result( 'Content-Security-Policy', "frame-ancestors 'self'", $enabled && ! empty( $settings['security_headers_csp_frame'] ) ),
      ),
    );
  }

  /**
   * Build one header result.
   *
   * @param string $name    Header name.
   * @param string $value   Header value.
   * @param bool   $enabled Whether enabled.
   * @return array
   */
  private function header_result( $name, $value, $enabled ) {
    return $this->result(
      sprintf(
        /* translators: %s: HTTP header name. */
        __( 'Header: %s', 'polanger-admin-menu-manager' ),
        $name
      ),
      $enabled ? 'configured' : 'disabled',
      $enabled ? 'configured' : 'disabled',
      true,
      $enabled ? $value : __( 'This header is not enabled by the current mode or custom settings.', 'polanger-admin-menu-manager' )
    );
  }

  /**
   * Score a simulated request.
   *
   * @param array $request  Request fixture.
   * @param array $settings Firewall settings.
   * @return array
   */
  private function score_request( $request, $settings ) {
    $score   = 0;
    $reasons = array();
    $method  = isset( $request['method'] ) ? strtoupper( sanitize_key( $request['method'] ) ) : 'GET';
    $uri     = isset( $request['uri'] ) ? (string) $request['uri'] : '/';
    $query   = isset( $request['query'] ) ? (string) $request['query'] : '';
    $ua      = isset( $request['user_agent'] ) ? strtolower( sanitize_text_field( $request['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) $settings['max_url_length'] ) {
      $score += 2;
      $reasons[] = 'long_url';
    }

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

    return array(
      'score'   => $score,
      'reasons' => ! empty( $reasons ) ? $reasons : array( 'none' ),
    );
  }

  /**
   * Evaluate a simulated REST route.
   *
   * @param string $route    REST route.
   * @param string $method   HTTP method.
   * @param array  $settings Settings.
   * @return array
   */
  private function evaluate_rest_route( $route, $method, $settings ) {
    $method = strtoupper( $method );

    if ( empty( $settings['rest_protection_enabled'] ) ) {
      return array(
        'expected' => 'disabled',
        'actual'   => 'disabled',
        'details'  => __( 'REST API hardening is currently disabled.', 'polanger-admin-menu-manager' ),
      );
    }

    if ( ! empty( $settings['rest_block_user_enumeration'] ) && preg_match( '#^/wp/v2/users(?:/|$)#', $route ) ) {
      $decision = $this->enforcement_decision( 'rest_user_enumeration' );
      return array(
        'expected' => $decision,
        'actual'   => $decision,
        'details'  => __( 'Anonymous /wp/v2/users route is treated as user enumeration.', 'polanger-admin-menu-manager' ),
      );
    }

    if ( ! empty( $settings['rest_block_anonymous_writes'] ) && in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
      if ( $this->is_rest_write_route_allowlisted( $route, $settings ) ) {
        return array(
          'expected' => 'allow',
          'actual'   => 'allow',
          'details'  => __( 'Route matches the anonymous REST write allowlist.', 'polanger-admin-menu-manager' ),
        );
      }

      $decision = $this->enforcement_decision( 'rest_anonymous_write' );
      return array(
        'expected' => $decision,
        'actual'   => $decision,
        'details'  => __( 'Anonymous write method is blocked unless the route is allowlisted.', 'polanger-admin-menu-manager' ),
      );
    }

    return array(
      'expected' => 'allow',
      'actual'   => 'allow',
      'details'  => __( 'The current mode/settings allow this simulated route.', 'polanger-admin-menu-manager' ),
    );
  }

  /**
   * Apply XML-RPC method filtering for diagnostics.
   *
   * @param array $methods  Methods.
   * @param array $settings Settings.
   * @return array
   */
  private function filter_xmlrpc_methods_for_test( $methods, $settings ) {
    if ( empty( $settings['xmlrpc_protection_enabled'] ) || $this->is_monitor_mode( $settings ) ) {
      return $methods;
    }

    if ( ! empty( $settings['xmlrpc_disable_multicall'] ) ) {
      unset( $methods['system.multicall'] );
    }

    if ( ! empty( $settings['xmlrpc_disable_pingbacks'] ) ) {
      unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] );
    }

    return $methods;
  }

  /**
   * Check REST allowlist.
   *
   * @param string $route    Route.
   * @param array  $settings Settings.
   * @return bool
   */
  private function is_rest_write_route_allowlisted( $route, $settings ) {
    $route = '/' . ltrim( (string) $route, '/' );
    $route = untrailingslashit( $route ) . '/';
    $raw   = isset( $settings['rest_write_route_allowlist'] ) ? (string) $settings['rest_write_route_allowlist'] : '';

    foreach ( preg_split( '/[\r\n,]+/', $raw ) as $prefix ) {
      $prefix = trim( (string) $prefix );
      if ( '' === $prefix ) {
        continue;
      }

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

    return false;
  }

  /**
   * Check IP against list.
   *
   * @param string $ip   IP.
   * @param array  $list List.
   * @return bool
   */
  private function ip_matches_list( $ip, $list ) {
    foreach ( $list as $entry ) {
      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 );
  }

  /**
   * Return current enforcement decision.
   *
   * @param string $reason Reason code.
   * @return string
   */
  private function enforcement_decision( $reason ) {
    unset( $reason );

    $settings = $this->settings->get_settings();

    if ( empty( $settings['enabled'] ) ) {
      return 'disabled';
    }

    return $this->is_monitor_mode( $settings ) ? 'monitored' : 'blocked';
  }

  /**
   * Whether settings are monitor mode.
   *
   * @param array $settings Settings.
   * @return bool
   */
  private function is_monitor_mode( $settings ) {
    return isset( $settings['mode'] ) && 'monitor' === $settings['mode'];
  }

  /**
   * Build test result.
   *
   * @param string $name     Test name.
   * @param string $expected Expected.
   * @param string $actual   Actual.
   * @param bool   $passed   Passed.
   * @param string $details  Details.
   * @return array
   */
  private function result( $name, $expected, $actual, $passed, $details = '' ) {
    return array(
      'name'     => $name,
      'expected' => (string) $expected,
      'actual'   => (string) $actual,
      'status'   => $passed ? 'passed' : 'failed',
      'details'  => (string) $details,
    );
  }

  /**
   * Summarize groups.
   *
   * @param array $groups  Result groups.
   * @param float $runtime Runtime seconds.
   * @return array
   */
  private function summarize_groups( $groups, $runtime ) {
    $passed = 0;
    $failed = 0;
    $total  = 0;

    foreach ( $groups as $group ) {
      foreach ( $group['tests'] as $test ) {
        ++$total;
        if ( 'passed' === $test['status'] ) {
          ++$passed;
        } else {
          ++$failed;
        }
      }
    }

    return array(
      'total'   => $total,
      'passed'  => $passed,
      'failed'  => $failed,
      'runtime' => round( $runtime * 1000, 2 ),
      'mode'    => $this->settings->get( 'mode', 'monitor' ),
      'enabled' => (bool) $this->settings->get( 'enabled', false ),
    );
  }

  /**
   * Clear diagnostics data.
   *
   * @return void
   */
  private function clear_test_data() {
    delete_transient( 'pdt_firewall_diagnostics_last' );
  }

  /**
   * Verify AJAX request.
   *
   * @return void
   */
  private function verify_ajax_request() {
    check_ajax_referer( self::NONCE_ACTION, 'nonce' );

    $can_manage = function_exists( 'pdt_current_user_can_manage' ) ? pdt_current_user_can_manage() : current_user_can( 'manage_options' );
    if ( ! $can_manage ) {
      wp_send_json_error(
        array(
          'message' => __( 'You do not have permission to run Firewall diagnostics.', 'polanger-admin-menu-manager' ),
        ),
        403
      );
    }
  }

  /**
   * Whether current admin request is the Firewall tab.
   *
   * @return bool
   */
  private function is_firewall_tab() {
    if ( ! is_admin() ) {
      return false;
    }

    $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $tab  = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

    return 'polanger-settings' === $page && 'firewall' === $tab;
  }
}
