<?php
/**
 * WooCommerce rate limiting for customer auth flows.
 *
 * @package Polanger_Dashboard_Tools
 */

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

class PDT_WC_Security_Rate_Limiter {

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

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

  /**
   * Constructor.
   *
   * @param PDT_WC_Security_Settings $settings Settings service.
   */
  public function __construct( $settings ) {
    $this->settings = $settings;
  }

  /**
   * Register WooCommerce hooks.
   *
   * @return void
   */
  public function register_hooks() {
    add_filter( 'woocommerce_process_login_errors', array( $this, 'maybe_block_login' ), 5, 3 );
    add_action( 'woocommerce_login_failed', array( $this, 'record_login_failure' ) );
    add_action( 'wp_login', array( $this, 'handle_login_success' ), 10, 2 );

    add_filter( 'woocommerce_process_registration_errors', array( $this, 'maybe_block_registration' ), 5, 4 );
    add_filter( 'woocommerce_process_registration_errors', array( $this, 'record_registration_attempt' ), 20, 4 );

    add_action( 'lostpassword_post', array( $this, 'maybe_block_lostpassword' ), 1, 2 );
    add_action( 'lostpassword_post', array( $this, 'record_lostpassword_attempt' ), 20, 2 );
  }

  /**
   * Block login attempts when the current identity or IP has hit the limit.
   *
   * @param WP_Error $errors   Validation errors.
   * @param string   $username Submitted username.
   * @param string   $password Submitted password.
   * @return WP_Error
   */
  public function maybe_block_login( $errors, $username, $password ) {
    unset( $password );

    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_login_request() ) {
      return $errors;
    }

    $limit = $this->get_limit_state( 'login', $username );
    if ( empty( $limit['limited'] ) ) {
      return $errors;
    }

    $this->request_flags['login_rate_limited'] = true;

    $errors->add(
      'pdt_wc_rate_limit_login',
      $this->get_limit_message( 'login', $limit['remaining'] )
    );

    return $errors;
  }

  /**
   * Record a failed WooCommerce login attempt.
   *
   * @return void
   */
  public function record_login_failure() {
    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_login_request() ) {
      return;
    }

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

    $username = isset( $_POST['username'] ) ? sanitize_text_field( wp_unslash( $_POST['username'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
    $this->record_attempt( 'login', $username );
  }

  /**
   * Clear the login identity bucket after a successful WooCommerce login.
   *
   * @param string  $user_login Logged-in user login.
   * @param WP_User $user       Logged-in user object.
   * @return void
   */
  public function handle_login_success( $user_login, $user ) {
    unset( $user );

    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_login_request() ) {
      return;
    }

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

  /**
   * Block registration requests when the current identity or IP is limited.
   *
   * @param WP_Error $errors   Validation errors.
   * @param string   $username Submitted username.
   * @param string   $password Submitted password.
   * @param string   $email    Submitted email.
   * @return WP_Error
   */
  public function maybe_block_registration( $errors, $username, $password, $email ) {
    unset( $password );

    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_registration_request() ) {
      return $errors;
    }

    $identity = $this->resolve_identity( $email, $username );
    $limit    = $this->get_limit_state( 'register', $identity );

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

    $this->request_flags['registration_rate_limited'] = true;

    $errors->add(
      'pdt_wc_rate_limit_register',
      $this->get_limit_message( 'register', $limit['remaining'] )
    );

    return $errors;
  }

  /**
   * Record each non-blocked registration request.
   *
   * @param WP_Error $errors   Validation errors.
   * @param string   $username Submitted username.
   * @param string   $password Submitted password.
   * @param string   $email    Submitted email.
   * @return WP_Error
   */
  public function record_registration_attempt( $errors, $username, $password, $email ) {
    unset( $password );

    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_registration_request() ) {
      return $errors;
    }

    if ( $this->has_rate_limit_error( $errors ) || ! empty( $this->request_flags['registration_recorded'] ) ) {
      return $errors;
    }

    $this->request_flags['registration_recorded'] = true;
    $this->record_attempt( 'register', $this->resolve_identity( $email, $username ) );

    return $errors;
  }

  /**
   * Block lost password abuse before WooCommerce starts processing it.
   *
   * @param WP_Error     $errors    Current errors object.
   * @param WP_User|bool $user_data Resolved user or false.
   * @return void
   */
  public function maybe_block_lostpassword( $errors, $user_data ) {
    unset( $user_data );

    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_lostpassword_request() ) {
      return;
    }

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

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

    $this->request_flags['lostpassword_rate_limited'] = true;

    $errors->add(
      'pdt_wc_rate_limit_lostpassword',
      $this->get_limit_message( 'lostpassword', $limit['remaining'] )
    );
  }

  /**
   * Record each non-blocked lost password request.
   *
   * @param WP_Error     $errors    Current errors object.
   * @param WP_User|bool $user_data Resolved user or false.
   * @return void
   */
  public function record_lostpassword_attempt( $errors, $user_data ) {
    unset( $user_data );

    if ( ! $this->settings->is_rate_limit_enabled() || ! $this->is_woocommerce_lostpassword_request() ) {
      return;
    }

    if ( $this->has_rate_limit_error( $errors ) || ! empty( $this->request_flags['lostpassword_recorded'] ) ) {
      return;
    }

    $this->request_flags['lostpassword_recorded'] = true;

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

  /**
   * Resolve whether the current action is limited.
   *
   * @param string $action   Action key.
   * @param string $identity Identity key.
   * @return array
   */
  private function get_limit_state( $action, $identity ) {
    $profile        = $this->settings->get_rate_limit_profile( $action );
    $identity_state = $this->get_bucket_state( $action, 'identity', $identity );
    $ip_state       = $this->get_bucket_state( $action, 'ip', $this->get_client_ip() );

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

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

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

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

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

  /**
   * Clear the identity bucket for a successful login.
   *
   * @param string $action   Action key.
   * @param string $identity Identity key.
   * @return void
   */
  private function clear_identity_bucket( $action, $identity ) {
    $identity = $this->normalize_identity( $identity );

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

    delete_transient( $this->get_bucket_key( $action, 'identity', $identity ) );
  }

  /**
   * Get one transient bucket state.
   *
   * @param string $action    Action key.
   * @param string $bucket    Bucket type.
   * @param string $identifier Bucket identifier.
   * @return array
   */
  private function get_bucket_state( $action, $bucket, $identifier ) {
    $identifier = 'identity' === $bucket ? $this->normalize_identity( $identifier ) : trim( (string) $identifier );

    if ( '' === $identifier ) {
      return array();
    }

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

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

  /**
   * Increment one transient bucket state.
   *
   * @param string $action     Action key.
   * @param string $bucket     Bucket type.
   * @param string $identifier Bucket identifier.
   * @param int    $window     Window in seconds.
   * @return void
   */
  private function increment_bucket_state( $action, $bucket, $identifier, $window ) {
    $identifier = 'identity' === $bucket ? $this->normalize_identity( $identifier ) : trim( (string) $identifier );

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

    $key   = $this->get_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 ) );
  }

  /**
   * Check whether a state has reached its configured threshold.
   *
   * @param array $state     Bucket state.
   * @param int   $threshold Max allowed attempts.
   * @return bool
   */
  private function state_reached_limit( $state, $threshold ) {
    return ! empty( $state )
      && isset( $state['count'] )
      && absint( $state['count'] ) >= absint( $threshold );
  }

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

    $remaining = absint( $state['window'] ) - ( time() - absint( $state['created'] ) );

    return max( 0, $remaining );
  }

  /**
   * Build a deterministic transient key for one rate-limit bucket.
   *
   * @param string $action     Action key.
   * @param string $bucket     Bucket type.
   * @param string $identifier Bucket identifier.
   * @return string
   */
  private function get_bucket_key( $action, $bucket, $identifier ) {
    return 'pdt_wc_rl_' . md5( sanitize_key( $action ) . '|' . sanitize_key( $bucket ) . '|' . strtolower( (string) $identifier ) );
  }

  /**
   * Normalize an identity value into a stable lookup key.
   *
   * @param string $identity Identity candidate.
   * @return string
   */
  private function normalize_identity( $identity ) {
    $identity = sanitize_text_field( (string) $identity );
    $identity = strtolower( trim( $identity ) );

    return $identity;
  }

  /**
   * Resolve the best identity key from email/username input.
   *
   * @param string $email    Email candidate.
   * @param string $username Username candidate.
   * @return string
   */
  private function resolve_identity( $email, $username ) {
    $email    = sanitize_email( $email );
    $username = sanitize_text_field( (string) $username );

    return '' !== $email ? $email : $username;
  }

  /**
   * Check whether our rate-limit error has already been added.
   *
   * @param WP_Error $errors Errors object.
   * @return bool
   */
  private function has_rate_limit_error( $errors ) {
    if ( ! ( $errors instanceof WP_Error ) ) {
      return false;
    }

    foreach ( array( 'pdt_wc_rate_limit_login', 'pdt_wc_rate_limit_register', 'pdt_wc_rate_limit_lostpassword' ) as $code ) {
      if ( $errors->get_error_code() === $code || $errors->get_error_message( $code ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Build the customer-facing rate limit message.
   *
   * @param string $action            Action key.
   * @param int    $remaining_seconds Remaining lock window in seconds.
   * @return string
   */
  private function get_limit_message( $action, $remaining_seconds ) {
    $remaining_minutes = max( 1, (int) ceil( absint( $remaining_seconds ) / MINUTE_IN_SECONDS ) );

    switch ( $action ) {
      case 'register':
        return sprintf(
          /* translators: %d: remaining wait time in minutes */
          __( 'Too many account registration requests were received. Please try again in %d minutes.', 'polanger-admin-menu-manager' ),
          $remaining_minutes
        );
      case 'lostpassword':
        return sprintf(
          /* translators: %d: remaining wait time in minutes */
          __( 'Too many password reset requests were received. Please try again in %d minutes.', 'polanger-admin-menu-manager' ),
          $remaining_minutes
        );
      case 'login':
      default:
        return sprintf(
          /* translators: %d: remaining wait time in minutes */
          __( 'Too many failed login attempts were detected. Please try again in %d minutes.', 'polanger-admin-menu-manager' ),
          $remaining_minutes
        );
    }
  }

  /**
   * Whether the current request is a WooCommerce login request.
   *
   * @return bool
   */
  private function is_woocommerce_login_request() {
    if ( ! isset( $_POST['login'], $_POST['username'], $_POST['password'] ) ) {
      return false;
    }

    $nonce_value = '';

    if ( isset( $_REQUEST['woocommerce-login-nonce'] ) ) {
      $nonce_value = wp_unslash( $_REQUEST['woocommerce-login-nonce'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    } elseif ( isset( $_REQUEST['_wpnonce'] ) ) {
      $nonce_value = wp_unslash( $_REQUEST['_wpnonce'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    }

    return (bool) wp_verify_nonce( $nonce_value, 'woocommerce-login' );
  }

  /**
   * Whether the current request is a WooCommerce registration request.
   *
   * @return bool
   */
  private function is_woocommerce_registration_request() {
    if ( ! isset( $_POST['register'], $_POST['email'] ) ) {
      return false;
    }

    $nonce_value = isset( $_POST['woocommerce-register-nonce'] )
      ? wp_unslash( $_POST['woocommerce-register-nonce'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
      : ( isset( $_POST['_wpnonce'] ) ? wp_unslash( $_POST['_wpnonce'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

    return (bool) wp_verify_nonce( $nonce_value, 'woocommerce-register' );
  }

  /**
   * Whether the current request is a WooCommerce lost password request.
   *
   * @return bool
   */
  private function is_woocommerce_lostpassword_request() {
    if ( ! isset( $_POST['wc_reset_password'], $_POST['user_login'] ) ) {
      return false;
    }

    $nonce_value = '';

    if ( isset( $_REQUEST['woocommerce-lost-password-nonce'] ) ) {
      $nonce_value = wp_unslash( $_REQUEST['woocommerce-lost-password-nonce'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    } elseif ( isset( $_REQUEST['_wpnonce'] ) ) {
      $nonce_value = wp_unslash( $_REQUEST['_wpnonce'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    }

    return (bool) wp_verify_nonce( $nonce_value, 'lost_password' );
  }

  /**
   * Resolve the client IP address.
   *
   * @return string
   */
  private function get_client_ip() {
    if ( empty( $_SERVER['REMOTE_ADDR'] ) ) {
      return '';
    }

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

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