<?php
/**
 * Protection Engine for the Comment Security Layer addon.
 *
 * Implements layered protections that are applied to inbound WordPress
 * comments via stock comment hooks. The engine never modifies core; it
 * only listens to public APIs and influences comment status / aborts.
 *
 * @package Polanger_Dashboard_Tools
 */

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

/**
 * Comment Security Layer Engine.
 */
class PDT_Comment_Security_Engine {

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

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

  /**
   * Default minimum render time in seconds before submission is allowed.
   *
   * @var int
   */
  private $timing_grace = 1;

  /**
   * Candidate honeypot aliases used for site-specific fallback naming.
   *
   * @var array<int,string>
   */
  private $honeypot_aliases = array(
    'url2',
    'homepage_alt',
    'profile_site',
    'contact_site',
    'web_link',
    'resource_url',
    'author_site',
    'company_homepage',
  );

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

  /**
   * Register WordPress hooks.
   *
   * @return void
   */
  public function register_hooks() {
    // Render hidden honeypot + timing fields inside comment forms.
    add_action( 'comment_form_after_fields', array( $this, 'render_protection_fields' ) );
    add_action( 'comment_form_logged_in_after', array( $this, 'render_protection_fields' ) );

    // Inspect comments before they hit the database.
    add_filter( 'preprocess_comment', array( $this, 'preprocess_comment' ), 100 );
    add_filter( 'pre_comment_approved', array( $this, 'pre_comment_approved' ), 100, 2 );

    // Daily housekeeping for log retention.
    add_action( 'pdt_comment_security_daily_purge', array( $this, 'cron_purge' ) );
    add_action( 'init', array( $this, 'maybe_schedule_cron' ) );
  }

  /**
   * Schedule the daily purge cron once.
   *
   * @return void
   */
  public function maybe_schedule_cron() {
    if ( ! wp_next_scheduled( 'pdt_comment_security_daily_purge' ) ) {
      wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'pdt_comment_security_daily_purge' );
    }
  }

  /**
   * Run the daily log retention purge.
   *
   * @return void
   */
  public function cron_purge() {
    $settings = $this->settings->get_settings();
    if ( empty( $settings['logging_enabled'] ) ) {
      return;
    }
    $this->logger->purge( (int) $settings['log_retention_days'] );
  }

  /**
   * Render hidden protection fields in comment forms.
   *
   * @return void
   */
  public function render_protection_fields() {
    $settings = $this->settings->get_settings();
    if ( empty( $settings['enabled'] ) ) {
      return;
    }

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

    $honeypot_name = $this->resolve_honeypot_field_name( $settings['honeypot_field_name'] );

    echo '<input type="hidden" name="pdt_cs_present" value="1" />';

    if ( ! empty( $settings['honeypot_enabled'] ) ) {
      $this->render_honeypot_field( $honeypot_name );
    }

    if ( ! empty( $settings['timing_enabled'] ) ) {
      $token = $this->generate_timing_token( $this->resolve_render_post_id() );
      echo '<input type="hidden" name="pdt_cs_t" value="' . esc_attr( $token ) . '" />';
    }

    /**
     * Fires after the Comment Security engine outputs its protection fields.
     *
     * Addons (e.g. reCAPTCHA) can hook this to append extra form fields
     * inside the same comment form pass. The `$post_id` argument is
     * provided so addons can scope output per post if needed.
     *
     * @param int $post_id Current post ID (0 if not determinable).
     */
    do_action( 'pdt_comment_security_extra_fields', $this->resolve_render_post_id() );
  }

  /**
   * Render the honeypot field. Markup is filterable so themes / sites
   * with strict CSP can swap in a class-only version and ship the
   * visibility CSS via their own stylesheet.
   *
   * @param string $field_name Field name attribute.
   * @return void
   */
  private function render_honeypot_field( $field_name ) {
    $label  = __( 'Leave this field empty', 'polanger-admin-menu-manager' );
    $markup = sprintf(
      '<p class="pdt-cs-honeypot screen-reader-text" aria-hidden="true" style="position:absolute !important;left:-99999px !important;top:-99999px !important;height:0 !important;width:0 !important;overflow:hidden !important;">'
      . '<label for="%1$s">%2$s</label>'
      . '<input type="text" name="%1$s" id="%1$s" value="" tabindex="-1" autocomplete="new-password" />'
      . '</p>',
      esc_attr( $field_name ),
      esc_html( $label )
    );

    /**
     * Filter the honeypot field markup.
     *
     * Allows sites with strict CSP (no inline styles) to provide a
     * class-only version. The default markup keeps inline style for
     * maximum out-of-the-box compatibility.
     *
     * @param string $markup     Default markup.
     * @param string $field_name Field name attribute.
     */
    $markup = apply_filters( 'pdt_comment_security_honeypot_markup', $markup, $field_name );

    echo wp_kses(
      $markup,
      array(
        'p'     => array(
          'class'       => array(),
          'style'       => array(),
          'aria-hidden' => array(),
        ),
        'label' => array(
          'for'   => array(),
          'class' => array(),
        ),
        'input' => array(
          'type'         => array(),
          'name'         => array(),
          'id'           => array(),
          'value'        => array(),
          'tabindex'     => array(),
          'autocomplete' => array(),
          'class'        => array(),
        ),
      )
    );
  }

  /**
   * Whether the engine should run for the current request user.
   *
   * @param array $settings Effective settings.
   * @return bool
   */
  private function should_protect_current_user( $settings ) {
    if ( is_user_logged_in() && empty( $settings['protect_logged_in'] ) ) {
      return false;
    }
    return true;
  }

  /**
   * Pre-process the inbound comment data.
   *
   * @param array $comment Comment data.
   * @return array
   */
  public function preprocess_comment( $comment ) {
    $settings = $this->settings->get_settings();
    if ( empty( $settings['enabled'] ) ) {
      return $comment;
    }

    if ( ! $this->should_protect_current_user( $settings ) ) {
      return $comment;
    }

    // Skip non-public comment types (pingback / trackback are handled by core).
    $type = isset( $comment['comment_type'] ) ? $comment['comment_type'] : '';
    if ( ! in_array( $type, array( '', 'comment' ), true ) ) {
      return $comment;
    }

    /**
     * Filter to let addons hard-block a comment before the engine's own checks run.
     *
     * Return a non-empty string with a human-readable error message to abort the
     * comment. Return false (the default) to let the engine continue normally.
     *
     * Example usage (reCAPTCHA addon or any third-party addon):
     *
     *   add_filter( 'pdt_comment_security_preprocess_extra', function( $error, $comment ) {
     *     if ( ! my_verify_token() ) {
     *       return __( 'Token verification failed.', 'my-plugin' );
     *     }
     *     return $error;
     *   }, 10, 2 );
     *
     * @param false|string $error   false or an error message string.
     * @param array        $comment Incoming comment data.
     */
    $extra_error = apply_filters( 'pdt_comment_security_preprocess_extra', false, $comment );
    if ( false !== $extra_error && '' !== $extra_error ) {
      $message = is_string( $extra_error ) ? $extra_error : __( 'Your comment could not be posted.', 'polanger-admin-menu-manager' );
      wp_die(
        esc_html( $message ),
        esc_html__( 'Comment Error', 'polanger-admin-menu-manager' ),
        array(
          'response'  => 403,
          'back_link' => true,
        )
      );
    }

    $reasons = $this->collect_violations( $comment, $settings );
    if ( empty( $reasons ) ) {
      // Optional opt-in: log clean comments so the "Allowed" stat reflects reality.
      if ( ! empty( $settings['log_allowed'] ) ) {
        $this->log_decision( 'allow', 'clean', 0, $comment );
      }
      return $comment;
    }

    $score = $this->score_for_reasons( $reasons );
    $decision = 'allow';

    // Hard block reasons always win, regardless of score or scoring mode.
    if ( $this->has_immediate_block_reason( $reasons ) ) {
      $decision = $settings['silent_mode'];
    } elseif ( ! empty( $settings['score_enabled'] ) ) {
      if ( $score >= (int) $settings['score_threshold_drop'] ) {
        $decision = $settings['silent_mode'];
      } elseif ( $score >= (int) $settings['score_threshold_spam'] ) {
        $decision = 'spam';
      }
      // else: score below spam threshold → allow
    } else {
      // Scoring disabled: any violation → silent_mode.
      $decision = $settings['silent_mode'];
    }

    $this->log_decision( $decision, implode( ',', $reasons ), $score, $comment );

    if ( 'drop' === $decision ) {
      $this->handle_silent_drop( $comment, $settings, $reasons, $score );
    }

    // Pass score+decision via temporary stash for pre_comment_approved,
    // keyed by a per-comment fingerprint so concurrent inserts (REST API
    // batches, WP-CLI seeds) don't blend into a shared static slot.
    $this->stash_set( $this->fingerprint_comment( $comment ), $decision, $score, $reasons );

    return $comment;
  }

  /**
   * Adjust comment approval status based on engine decision.
   *
   * @param int|string $approved    Existing approval status (0,1,'spam','trash').
   * @param array      $commentdata Comment data.
   * @return int|string
   */
  public function pre_comment_approved( $approved, $commentdata ) {
    $stash = $this->stash_pop( $this->fingerprint_comment( $commentdata ) );
    if ( empty( $stash ) ) {
      return $approved;
    }

    switch ( $stash['decision'] ) {
      case 'spam':
        return 'spam';
      case 'trash':
        return 'trash';
      default:
        return $approved;
    }
  }

  /**
   * Terminate the request for a "drop" decision.
   *
   * Three behaviours are supported:
   *  - `show_human_message=true`: render a wp_die screen so the user sees feedback.
   *  - Otherwise: redirect back to the post with `?pdt_cs_blocked=1` appended
   *    so themes can detect a blocked attempt and render a friendly notice.
   *
   * Themes / addons can override the entire response with the
   * `pdt_comment_security_drop_response` filter (return false to skip core
   * handling, in which case they own the request termination).
   *
   * @param array $comment  Comment data.
   * @param array $settings Effective settings.
   * @param array $reasons  Reason codes.
   * @param int   $score    Score.
   * @return void
   */
  private function handle_silent_drop( $comment, $settings, $reasons, $score ) {
    $post_id = ! empty( $comment['comment_post_ID'] ) ? (int) $comment['comment_post_ID'] : 0;
    $post_url = $post_id ? get_permalink( $post_id ) : home_url( '/' );
    if ( empty( $post_url ) ) {
      $post_url = home_url( '/' );
    }

    $context = array(
      'post_id'  => $post_id,
      'post_url' => $post_url,
      'reasons'  => $reasons,
      'score'    => (int) $score,
      'mode'     => empty( $settings['show_human_message'] ) ? 'silent' : 'visible',
    );

    /**
     * Allow themes / addons to fully own the drop response.
     *
     * Return any non-null value (e.g. true) to indicate the response was
     * already handled (the addon will then exit). Return null to fall back
     * to default behavior.
     *
     * @param mixed $handled Default null.
     * @param array $context Drop context (post_id, post_url, reasons, score, mode).
     */
    $handled = apply_filters( 'pdt_comment_security_drop_response', null, $context );
    if ( null !== $handled ) {
      exit;
    }

    if ( ! empty( $settings['show_human_message'] ) ) {
      wp_die(
        esc_html__( 'Your comment could not be processed. Please try again later.', 'polanger-admin-menu-manager' ),
        esc_html__( 'Comment rejected', 'polanger-admin-menu-manager' ),
        array(
          'response'  => 403,
          'back_link' => true,
        )
      );
    }

    /**
     * Filter the redirect URL for silent drops.
     *
     * Default appends `?pdt_cs_blocked=1` so themes can render a friendly
     * notice without leaking the rejection reason.
     *
     * @param string $redirect Redirect URL.
     * @param array  $context  Drop context.
     */
    $redirect = apply_filters(
      'pdt_comment_security_drop_redirect',
      add_query_arg( 'pdt_cs_blocked', '1', $post_url ),
      $context
    );

    wp_safe_redirect( esc_url_raw( $redirect ) );
    exit;
  }

  /**
   * Collect the list of violation reason codes for a comment.
   *
   * @param array $comment  Comment data.
   * @param array $settings Effective settings.
   * @return array<string>
   */
  private function collect_violations( $comment, $settings ) {
    $reasons = array();
    $has_form_marker = $this->has_form_marker();
    $require_form_marker = $this->should_require_form_marker( $settings, $comment );

    // Marker stripping is a common bot bypass attempt. In strict mode (default),
    // missing marker is itself a hard signal for the bot-protection layer.
    if ( $require_form_marker && ! $has_form_marker && ( ! empty( $settings['honeypot_enabled'] ) || ! empty( $settings['timing_enabled'] ) ) ) {
      $reasons[] = 'form_marker_missing';
    }

    // Honeypot.
    if ( ! empty( $settings['honeypot_enabled'] ) && $has_form_marker ) {
      $field      = $this->resolve_honeypot_field_name( $settings['honeypot_field_name'] );
      $candidates = array_unique( array_merge( array( $field ), $this->get_honeypot_grace_candidates() ) );

      $present_field = '';
      foreach ( $candidates as $candidate ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- comment forms aren't required to use nonces; we add our own honeypot/timing instead.
        if ( array_key_exists( $candidate, $_POST ) ) {
          $present_field = $candidate;
          break;
        }
      }

      if ( '' === $present_field ) {
        $reasons[] = 'honeypot_missing';
        // phpcs:ignore WordPress.Security.NonceVerification.Missing
      } elseif ( ! empty( $_POST[ $present_field ] ) ) {
        $reasons[] = 'honeypot';
      }
    }

    // Timing.
    if ( ! empty( $settings['timing_enabled'] ) && $has_form_marker ) {
      // phpcs:ignore WordPress.Security.NonceVerification.Missing
      $token = isset( $_POST['pdt_cs_t'] ) ? sanitize_text_field( wp_unslash( $_POST['pdt_cs_t'] ) ) : '';
      $post_id = isset( $comment['comment_post_ID'] ) ? absint( $comment['comment_post_ID'] ) : 0;
      $age   = $this->verify_timing_token( $token, $post_id );
      if ( false === $age ) {
        $reasons[] = 'timing_invalid';
      } elseif ( $age < (int) $settings['timing_min_seconds'] ) {
        $reasons[] = 'timing_min';
      } elseif ( $age > (int) $settings['timing_max_seconds'] ) {
        $reasons[] = 'timing_max';
      }
    }

    // Flood control.
    if ( ! empty( $settings['flood_enabled'] ) ) {
      $ip = $this->get_client_ip();
      if ( '' !== $ip ) {
        $signature = $this->build_flood_signature( $ip, $comment );
        if ( '' !== $signature ) {
          if ( $this->is_flooding( $signature, 'm', (int) $settings['flood_per_minute'], MINUTE_IN_SECONDS ) ) {
            $reasons[] = 'flood_minute';
          }
          if ( $this->is_flooding( $signature, 'h', (int) $settings['flood_per_hour'], HOUR_IN_SECONDS ) ) {
            $reasons[] = 'flood_hour';
          }
        }
      }
    }

    // Content rules.
    $content     = isset( $comment['comment_content'] ) ? (string) $comment['comment_content'] : '';
    $author_url  = isset( $comment['comment_author_url'] ) ? (string) $comment['comment_author_url'] : '';
    $author_name = isset( $comment['comment_author'] ) ? (string) $comment['comment_author'] : '';

    $link_count = preg_match_all( '@https?://@i', $content );
    if ( $author_url ) {
      $link_count++;
    }
    if ( (int) $settings['max_links'] > 0 && $link_count > (int) $settings['max_links'] ) {
      $reasons[] = 'too_many_links';
    }

    if ( ! empty( $settings['block_non_latin_only'] ) ) {
      // Whitelist Latin letters, all numerics, whitespace, punctuation,
      // any symbol (covers emoji), combining marks, and format characters.
      // Anything left is a non-Latin script (Cyrillic / CJK / Arabic / ...).
      $stripped = preg_replace( '/[\p{Latin}\s\p{N}\p{P}\p{S}\p{M}\p{Cf}]+/u', '', $content );
      if ( '' !== $stripped && mb_strlen( $stripped ) > mb_strlen( $content ) * 0.5 ) {
        $reasons[] = 'non_latin';
      }
    }

    if ( ! empty( $settings['keyword_blocklist'] ) ) {
      $haystack = $author_name . ' ' . $content . ' ' . $author_url;
      $hit      = false;
      foreach ( (array) $settings['keyword_blocklist'] as $needle ) {
        if ( '' === $needle ) {
          continue;
        }
        $hit = $this->keyword_match( $haystack, $needle, $settings['keyword_match_mode'] );
        if ( $hit ) {
          break;
        }
      }
      if ( $hit ) {
        $reasons[] = 'keyword';
      }
    }

    if ( ! empty( $settings['url_blocklist'] ) ) {
      $candidates = $this->extract_hosts_from_text( $content );
      $url_host   = wp_parse_url( $author_url, PHP_URL_HOST );
      if ( $url_host ) {
        $candidates[] = strtolower( preg_replace( '/^www\./', '', $url_host ) );
      }
      $candidates = array_unique( array_filter( $candidates ) );
      foreach ( $candidates as $host ) {
        if ( in_array( $host, (array) $settings['url_blocklist'], true ) ) {
          $reasons[] = 'url_blocklist';
          break;
        }
      }
    }

    return array_values( array_unique( $reasons ) );
  }

  /**
   * Score the violations.
   *
   * @param array<string> $reasons Reason codes.
   * @return int 0..100
   */
  private function score_for_reasons( $reasons ) {
    $weights = array(
      'honeypot'        => 100,
      'honeypot_missing'=> 95,
      'form_marker_missing' => 95,
      'timing_min'      => 90,
      'timing_invalid'  => 60,
      'timing_max'      => 30,
      'flood_minute'    => 80,
      'flood_hour'      => 50,
      'too_many_links'  => 30,
      'non_latin'       => 20,
      'keyword'         => 50,
      'url_blocklist'   => 70,
    );

    $score = 0;
    foreach ( $reasons as $reason ) {
      if ( isset( $weights[ $reason ] ) ) {
        $score += (int) $weights[ $reason ];
      }
    }

    if ( $score > 100 ) {
      $score = 100;
    }
    return $score;
  }

  /**
   * Per-request decision stash storage shared by the two helpers below.
   *
   * @return array<string,array<string,mixed>>
   */
  private function &stash_store() {
    static $store = array();
    return $store;
  }

  /**
   * Stash a decision keyed by a comment fingerprint.
   *
   * @param string $key      Fingerprint key.
   * @param string $decision Decision (allow|spam|trash|drop).
   * @param int    $score    Score.
   * @param array  $reasons  Reasons.
   * @return void
   */
  private function stash_set( $key, $decision, $score, $reasons ) {
    if ( '' === $key ) {
      return;
    }
    $store = &$this->stash_store();

    if ( count( $store ) > 32 ) {
      // Trim to the 16 most recently added entries instead of flushing everything,
      // so concurrent batch inserts don't lose all pending decisions at once.
      $store = array_slice( $store, -16, null, true );
    }

    $store[ $key ] = array(
      'decision' => (string) $decision,
      'score'    => (int) $score,
      'reasons'  => is_array( $reasons ) ? $reasons : array(),
    );
  }

  /**
   * Pop a previously stashed decision (consumed once).
   *
   * @param string $key Fingerprint key.
   * @return array|null
   */
  private function stash_pop( $key ) {
    if ( '' === $key ) {
      return null;
    }
    $store = &$this->stash_store();
    if ( ! isset( $store[ $key ] ) ) {
      return null;
    }
    $value = $store[ $key ];
    unset( $store[ $key ] );
    return $value;
  }

  /**
   * Build a stable per-comment fingerprint for stash lookup.
   *
   * @param array $comment Comment data.
   * @return string
   */
  private function fingerprint_comment( $comment ) {
    if ( ! is_array( $comment ) ) {
      return '';
    }
    $parts = array(
      isset( $comment['comment_post_ID'] ) ? (int) $comment['comment_post_ID'] : 0,
      isset( $comment['comment_author'] ) ? (string) $comment['comment_author'] : '',
      isset( $comment['comment_author_email'] ) ? (string) $comment['comment_author_email'] : '',
      isset( $comment['comment_author_IP'] ) ? (string) $comment['comment_author_IP'] : '',
      isset( $comment['comment_content'] ) ? substr( (string) $comment['comment_content'], 0, 256 ) : '',
    );
    return md5( implode( '|', $parts ) );
  }

  /**
   * Log a decision row.
   *
   * @param string $decision Decision.
   * @param string $reason   Reason text.
   * @param int    $score    Score.
   * @param array  $comment  Comment data.
   * @return void
   */
  private function log_decision( $decision, $reason, $score, $comment ) {
    $settings = $this->settings->get_settings();
    if ( empty( $settings['logging_enabled'] ) ) {
      return;
    }

    // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';

    $this->logger->log_event(
      array(
        'decision'        => $decision,
        'reason'          => $reason,
        'score'           => $score,
        'ip'              => $this->get_client_ip(),
        'ua'              => $ua,
        'author'          => isset( $comment['comment_author'] ) ? $comment['comment_author'] : '',
        'author_email'    => isset( $comment['comment_author_email'] ) ? $comment['comment_author_email'] : '',
        'post_id'         => isset( $comment['comment_post_ID'] ) ? (int) $comment['comment_post_ID'] : 0,
        'content_excerpt' => isset( $comment['comment_content'] ) ? (string) $comment['comment_content'] : '',
      )
    );
  }

  /**
   * Generate a signed timing token containing the current timestamp.
   *
   * In stateful mode (default) a transient is stored server-side so the
   * token is single-use and the server-side data (post_id, ua_hash,
   * issued_at) is re-verified on submission — preventing replay attacks.
   *
   * In stateless mode (pdt_comment_security_timing_stateless = true) the
   * transient is intentionally omitted. This is the correct setting for
   * sites with full-page caching: a cached page serves the same HTML
   * (and therefore the same token) to every visitor — the first visitor's
   * submission would consume the transient, causing timing_invalid for
   * everyone else who loaded that cached page. Stateless mode verifies the
   * token purely by HMAC + age; authenticity is preserved but one-time-use
   * enforcement is relaxed.
   *
   * @param int $post_id Post to bind the token to.
   * @return string
   */
  private function generate_timing_token( $post_id = 0 ) {
    $ts      = time();
    $post_id = absint( $post_id );
    $nonce   = wp_generate_password( 20, false, false );
    $ua_hash = $this->get_user_agent_hash();
    $payload = $ts . '|' . $post_id . '|' . $nonce . '|' . $ua_hash;
    $sig     = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) );

    if ( ! $this->is_timing_stateless() ) {
      $stored = set_transient(
        $this->get_timing_transient_key( $nonce ),
        array(
          'post_id'   => $post_id,
          'ua_hash'   => $ua_hash,
          'issued_at' => $ts,
        ),
        $this->get_timing_transient_ttl()
      );

      // If storage failed (full object cache, transient DB error, etc.) the
      // emitted token will produce timing_invalid on submission. This is an
      // infrastructure failure; log it so admins can investigate. Sites that
      // experience this regularly should enable stateless mode via the filter.
      if ( ! $stored && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
        error_log( 'PDT Comment Security: set_transient failed for timing token nonce. Affected users will receive timing_invalid. Enable pdt_comment_security_timing_stateless if this occurs regularly.' );
      }
    }

    return $ts . '.' . $post_id . '.' . $nonce . '.' . substr( $sig, 0, 32 );
  }

  /**
   * Verify a timing token and return age in seconds, or false on tamper.
   *
   * Stateful mode (default): looks up the server-side transient keyed by the
   * token's nonce, cross-checks post_id / ua_hash / issued_at, then deletes
   * it so the token is single-use. Provides replay protection.
   *
   * Stateless mode (pdt_comment_security_timing_stateless = true): skips
   * the transient entirely and verifies purely by HMAC + age. The HMAC
   * payload already encodes ts, post_id, nonce, and ua_hash so authenticity
   * is maintained; one-time-use enforcement is not. Use this mode on sites
   * with full-page caching where every visitor loading a cached page would
   * otherwise share and then contend for the same transient.
   *
   * @param string $token   Timing token.
   * @param int    $post_id Expected post ID.
   * @return int|false Age in seconds on success, false on any failure.
   */
  private function verify_timing_token( $token, $post_id = 0 ) {
    if ( '' === $token || 3 !== substr_count( $token, '.' ) ) {
      return false;
    }
    list( $ts, $token_post_id, $nonce, $sig ) = array_pad( explode( '.', $token, 4 ), 4, '' );
    if ( ! ctype_digit( (string) $ts ) || ! ctype_digit( (string) $token_post_id ) ) {
      return false;
    }
    if ( '' === $nonce ) {
      return false;
    }
    if ( absint( $token_post_id ) !== absint( $post_id ) ) {
      return false;
    }

    $current_ua_hash = $this->get_user_agent_hash();

    if ( ! $this->is_timing_stateless() ) {
      // ── Stateful path: single-use transient verification ──────────────────
      $token_state = get_transient( $this->get_timing_transient_key( $nonce ) );
      if ( ! is_array( $token_state ) ) {
        return false;
      }

      // Burn the transient immediately so it cannot be reused even if a
      // subsequent check below fails (prevents probing attacks).
      delete_transient( $this->get_timing_transient_key( $nonce ) );

      if ( ! array_key_exists( 'post_id', $token_state ) || absint( $token_state['post_id'] ) !== absint( $post_id ) ) {
        return false;
      }
      if ( empty( $token_state['ua_hash'] ) || ! hash_equals( (string) $token_state['ua_hash'], $current_ua_hash ) ) {
        return false;
      }
      if ( ! empty( $token_state['issued_at'] ) && absint( $token_state['issued_at'] ) !== (int) $ts ) {
        return false;
      }
    }
    // ── Shared path: HMAC integrity check (stateful and stateless) ──────────
    // The payload encodes ts, post_id, nonce, and ua_hash so the signature
    // covers all mutable fields regardless of whether the transient was used.
    $payload  = (int) $ts . '|' . absint( $post_id ) . '|' . $nonce . '|' . $current_ua_hash;
    $expected = substr( hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) ), 0, 32 );
    if ( ! hash_equals( $expected, (string) $sig ) ) {
      return false;
    }

    $age = time() - (int) $ts;
    /**
     * Filter the minimum age (in seconds) returned for a freshly-issued token.
     *
     * Useful when fronting WordPress with a CDN that adds latency between
     * page render and form submit, or to soften timing checks during QA.
     *
     * @param int $grace Default grace seconds.
     */
    $grace = (int) apply_filters( 'pdt_comment_security_timing_grace', $this->timing_grace );
    if ( $age < $grace ) {
      $age = $grace;
    }
    return $age;
  }

  /**
   * Increment counter for the current IP and check if rate limit exceeded.
   *
   * @param string $ip       Client IP.
   * @param string $bucket   m|h.
   * @param int    $limit    Max events.
   * @param int    $expires  Window in seconds.
   * @return bool
   */
  private function is_flooding( $signature, $bucket, $limit, $expires ) {
    if ( $limit <= 0 || '' === $signature ) {
      return false;
    }

    $key   = 'pdt_cs_fl_' . $bucket . '_' . md5( $signature );
    $count = (int) get_transient( $key );
    $count++;

    set_transient( $key, $count, $expires );

    return $count > $limit;
  }

  /**
   * Build a flood-control signature.
   *
   * Default signature is a hash of `IP|UA-prefix` so different clients
   * sitting behind the same NAT/CDN egress IP (with distinct user agents)
   * are not lumped together. Sites can swap in any custom signature via
   * the `pdt_comment_security_flood_signature` filter.
   *
   * @param string $ip      Client IP.
   * @param array  $comment Comment data.
   * @return string
   */
  private function build_flood_signature( $ip, $comment ) {
    if ( '' === $ip ) {
      return '';
    }

    // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $ua_raw = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
    $ua_short = substr( $ua_raw, 0, 64 );

    $signature = $ip . '|' . $ua_short;

    /**
     * Filter the flood-control signature for a request.
     *
     * Useful for sites with shared egress IPs (NAT, corporate proxies)
     * that want to widen the signature with author email or session.
     *
     * @param string $signature Default `ip|ua` signature.
     * @param string $ip        Resolved client IP.
     * @param array  $comment   Comment data.
     */
    $signature = apply_filters( 'pdt_comment_security_flood_signature', $signature, $ip, $comment );

    return is_string( $signature ) ? $signature : $ip;
  }

  /**
   * Whether the current comment submission includes addon-generated fields.
   *
   * @return bool
   */
  private function has_form_marker() {
    // phpcs:ignore WordPress.Security.NonceVerification.Missing
    return ! empty( $_POST['pdt_cs_present'] );
  }

  /**
   * Resolve the post ID to bind timing tokens to during form rendering.
   *
   * @return int
   */
  private function resolve_render_post_id() {
    $post_id = get_the_ID();
    if ( $post_id ) {
      return absint( $post_id );
    }

    $post = get_post();
    if ( $post instanceof WP_Post ) {
      return absint( $post->ID );
    }

    return absint( get_queried_object_id() );
  }

  /**
   * Get sanitized previous honeypot field names still inside the grace window.
   *
   * @return array<string>
   */
  private function get_honeypot_grace_candidates() {
    if ( ! method_exists( $this->settings, 'get_honeypot_grace_aliases' ) ) {
      return array();
    }
    $aliases = $this->settings->get_honeypot_grace_aliases();
    $clean   = array();
    foreach ( (array) $aliases as $alias ) {
      $alias = sanitize_key( (string) $alias );
      if ( '' !== $alias ) {
        $clean[] = $alias;
      }
    }
    return $clean;
  }

  /**
   * Resolve a site-specific honeypot field name.
   *
   * Keeps custom admin-provided names intact, while upgrading the legacy
   * default field name to a less predictable alias.
   *
   * @param string $configured Configured field name.
   * @return string
   */
  private function resolve_honeypot_field_name( $configured ) {
    $configured = sanitize_key( $configured );
    if ( '' === $configured ) {
      $configured = 'pdt_cs_url2';
    }

    if ( 'pdt_cs_url2' !== $configured ) {
      return $configured;
    }

    $seed = wp_salt( 'nonce' ) . '|' . home_url( '/' );
    $hash = hash( 'sha256', $seed );
    $index = hexdec( substr( $hash, 0, 4 ) ) % count( $this->honeypot_aliases );

    return sanitize_key( $this->honeypot_aliases[ $index ] );
  }

  /**
   * Whether timing tokens should be verified without server-side transients.
   *
   * Returns true when the pdt_comment_security_timing_stateless filter is
   * truthy. Site owners should enable this when full-page caching is active
   * (WP Super Cache, W3 Total Cache, LiteSpeed Cache, etc.); see the Bot
   * Protection settings for more context.
   *
   * @return bool
   */
  private function is_timing_stateless() {
    /**
     * Skip transient-based one-time-use enforcement for timing tokens.
     *
     * When true, tokens are verified by HMAC + age only. Authenticity is
     * fully preserved; replay protection within the token's validity window
     * is not. Enable on sites with full-page caching to prevent legitimate
     * visitors from receiving timing_invalid because they loaded a cached
     * page after another user already consumed the shared transient.
     *
     * Usage: add_filter( 'pdt_comment_security_timing_stateless', '__return_true' );
     *
     * @param bool $stateless Default false (stateful / single-use).
     */
    return (bool) apply_filters( 'pdt_comment_security_timing_stateless', false );
  }

  /**
   * Build the transient key for a timing token nonce.
   *
   * @param string $nonce Token nonce.
   * @return string
   */
  private function get_timing_transient_key( $nonce ) {
    return 'pdt_cs_tok_' . md5( (string) $nonce );
  }

  /**
   * Get the storage TTL for timing token state.
   *
   * @return int
   */
  private function get_timing_transient_ttl() {
    $settings = $this->settings->get_settings();
    $max_age = ! empty( $settings['timing_max_seconds'] ) ? absint( $settings['timing_max_seconds'] ) : HOUR_IN_SECONDS;
    return max( HOUR_IN_SECONDS, $max_age + HOUR_IN_SECONDS );
  }

  /**
   * Get a stable user agent hash for timing-token binding.
   *
   * @return string
   */
  private function get_user_agent_hash() {
    // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
    return substr( hash( 'sha256', $ua ), 0, 24 );
  }

  /**
   * Resolve the client IP using a trusted-proxy aware policy.
   *
   * @return string
   */
  private function get_client_ip() {
    $remote_addr = '';
    if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
      $remote_addr = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
      if ( ! filter_var( $remote_addr, FILTER_VALIDATE_IP ) ) {
        $remote_addr = '';
      }
    }

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

    /**
     * Trusted reverse proxies (IPs) for the Comment Security addon.
     *
     * Only when the immediate REMOTE_ADDR is in this list will we accept
     * X-Forwarded-For values. Defaults to empty (no trust).
     */
    $trusted = apply_filters( 'pdt_comment_security_trusted_proxies', array() );

    if ( is_array( $trusted ) && in_array( $remote_addr, $trusted, true ) && ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
      $forwarded_for = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
      $candidates    = array_map( 'trim', explode( ',', $forwarded_for ) );
      $candidates    = array_reverse( $candidates );
      foreach ( $candidates as $candidate ) {
        if ( ! filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
          continue;
        }
        if ( ! in_array( $candidate, $trusted, true ) ) {
          return $candidate;
        }
      }
    }

    return $remote_addr;
  }

  /**
   * Match a needle inside a haystack using the configured mode.
   *
   * @param string $haystack Haystack text.
   * @param string $needle   Needle.
   * @param string $mode     word | substring.
   * @return bool
   */
  private function keyword_match( $haystack, $needle, $mode ) {
    $haystack = $this->normalize_text_case( $haystack );
    $needle   = $this->normalize_text_case( $needle );
    if ( '' === $needle ) {
      return false;
    }
    if ( 'word' === $mode ) {
      $quoted = preg_quote( $needle, '/' );

      $whole_word_pattern = '/(?:^|[^\p{L}\p{N}_])' . $quoted . '(?:$|[^\p{L}\p{N}_])/u';
      if ( preg_match( $whole_word_pattern, $haystack ) ) {
        return true;
      }

      $repeated_prefix_pattern = '/(?:^|[^\p{L}\p{N}_])(?:' . $quoted . '){2,}/u';
      if ( preg_match( $repeated_prefix_pattern, $haystack ) ) {
        return true;
      }

      $repeated_suffix_pattern = '/(?:' . $quoted . '){2,}(?:$|[^\p{L}\p{N}_])/u';
      return (bool) preg_match( $repeated_suffix_pattern, $haystack );
    }
    return false !== strpos( $haystack, $needle );
  }

  /**
   * Determine whether the violation list contains a rule that should
   * immediately trigger the configured block action.
   *
   * The list is filterable so site owners can opt specific reasons out
   * of the hard-block path (e.g. demote `keyword` so a single match goes
   * through the score thresholds instead of straight to silent mode).
   *
   * @param array<string> $reasons Reason codes.
   * @return bool
   */
  private function has_immediate_block_reason( $reasons ) {
    $defaults = array(
      'form_marker_missing',
      'honeypot',
      'honeypot_missing',
      'timing_min',
      'timing_invalid',
      'keyword',
      'url_blocklist',
    );

    /**
     * Filter the list of reason codes that bypass behavior scoring and go
     * straight to the configured silent_mode action.
     *
     * @param array<string> $hard_reasons Default hard-block reason codes.
     */
    $hard_reasons = apply_filters( 'pdt_comment_security_hard_block_reasons', $defaults );
    if ( ! is_array( $hard_reasons ) ) {
      $hard_reasons = $defaults;
    }

    foreach ( $hard_reasons as $reason ) {
      if ( in_array( $reason, $reasons, true ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Whether bot-protection marker is required on incoming submissions.
   *
   * Default is true to prevent bypasses where bots strip unknown fields
   * (including our marker) and thereby skip honeypot/timing checks.
   *
   * For custom comment form stacks that cannot include `pdt_cs_present`,
   * site owners may opt out:
   *   add_filter( 'pdt_comment_security_require_form_marker', '__return_false' );
   *
   * @param array $settings Effective settings.
   * @param array $comment  Incoming comment payload.
   * @return bool
   */
  private function should_require_form_marker( $settings, $comment ) {
    /**
     * Require the hidden form marker for honeypot/timing protections.
     *
     * @param bool  $required Default true.
     * @param array $settings Effective addon settings.
     * @param array $comment  Incoming comment data.
     */
    return (bool) apply_filters( 'pdt_comment_security_require_form_marker', true, $settings, $comment );
  }

  /**
   * Extract distinct hostnames from a free-form text body.
   *
   * @param string $text Body text.
   * @return array<string>
   */
  private function extract_hosts_from_text( $text ) {
    $hosts = array();
    if ( preg_match_all( '@https?://[^\s<>"\']+@i', $text, $matches ) ) {
      foreach ( $matches[0] as $url ) {
        $host = wp_parse_url( $url, PHP_URL_HOST );
        if ( $host ) {
          $host = strtolower( preg_replace( '/^www\./', '', $host ) );
          if ( '' !== $host ) {
            $hosts[] = $host;
          }
        }
      }
    }
    return array_values( array_unique( $hosts ) );
  }

  /**
   * Normalize text for Unicode-aware case-insensitive comparison.
   *
   * @param string $text Input text.
   * @return string
   */
  private function normalize_text_case( $text ) {
    if ( function_exists( 'mb_strtolower' ) ) {
      return mb_strtolower( $text, 'UTF-8' );
    }

    return strtolower( $text );
  }
}
