<?php

declare(strict_types=1);

namespace SecurWP\Modules\Firewall\RateLimiter;

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

use SecurWP\Includes\IpDetector;

/**
 * Main rate limiting engine.
 *
 * Enforces per-IP request rate limits using sliding window counters backed by
 * a tiered store: persistent external object cache first, with the
 * {prefix}securwp_rate_limits database table as the durable fallback.
 * Supports separate limits for general requests and 404 responses, with a
 * configurable multiplier for classified automation.
 *
 */
class RateLimiter {

    /**
     * Firewall settings array.
     *
     * @var array
     */
    private array $settings;

    /**
     * Sliding window store instance.
     *
     * @var SlidingWindowStore
     */
    private SlidingWindowStore $store;

    /**
     * @param array $settings Firewall settings containing rate limit configuration.
     */
    public function __construct( array $settings ) {
        $this->settings = $settings;
        $this->store    = new SlidingWindowStore();
    }

    /**
     * Check the incoming request against rate limits.
     *
     * Called from Firewall::run_firewall() on every request when the firewall is enabled.
     * Hashes the IP, records a 'request' hit, then checks whether the global rate limit
     * has been exceeded.
     *
     * If the limit is exceeded, sends a 429 Too Many Requests response with a
     * Retry-After header and terminates execution.
     *
     * @param string $ip The visitor's IP address.
     * @param bool   $verified_search_exception Whether the request already passed
     *                                          verified-search trust checks.
     */
    public function check_request( string $ip, bool $verified_search_exception = false ): void {
        if ( empty( $this->settings['rate_limit_enabled'] ) ) {
            return;
        }

        // XML-RPC exposure is controlled by hardening, not firewall throttling.
        if ( $this->is_xmlrpc_request() ) {
            return;
        }

        if ( $verified_search_exception ) {
            return;
        }

        $ip_hash = hash( 'sha256', $ip );

        // Record the request hit.
        $this->store->increment( $ip_hash, 'request' );

        $limit = (int) ( $this->settings['rate_limit_requests'] ?? 60 );

        if ( $this->is_rate_limited( $ip_hash, 'request', $limit ) ) {
            $this->send_429();
        }
    }

    /**
     * Record a 404 response and check the 404-specific rate limit.
     *
     * Hooked to `template_redirect`. Only records a hit when the HTTP response
     * code is 404.
     *
     * @param string $ip The visitor's IP address.
     * @param bool   $verified_search_exception Whether the request already passed
     *                                          verified-search trust checks.
     */
    public function record_404( string $ip, bool $verified_search_exception = false ): void {
        if ( empty( $this->settings['rate_limit_enabled'] ) ) {
            return;
        }

        $is_404 = function_exists( 'is_404' ) && is_404();
        if ( ! $is_404 && http_response_code() !== 404 ) {
            return;
        }

        if ( $verified_search_exception ) {
            return;
        }

        $ip_hash = hash( 'sha256', $ip );

        $this->store->increment( $ip_hash, '404' );

        $limit = (int) ( $this->settings['rate_limit_404'] ?? 10 );

        if ( $this->is_rate_limited( $ip_hash, '404', $limit ) ) {
            $this->send_429(
                '404 Flood',
                'Too many not-found requests were detected from this IP. Please wait before trying again.'
            );
        }
    }

    /**
     * Cleanup old rate limit entries.
     *
     * Keeps enough history for both general rate limiting and sensitive-path
     * probe escalation, which shares the same counter table.
     */
    public function cleanup(): void {
        $sensitive_minutes = max( 1, (int) ceil( ( (int) ( $this->settings['sensitive_path_window'] ?? 600 ) ) / 60 ) );
        $retention_minutes = max( 5, $sensitive_minutes + 1 );

        $this->store->purge_old( $retention_minutes );
    }

    /**
     * Check whether the hit count exceeds the given limit.
     *
     * @param string $ip_hash  SHA-256 hash of the IP address.
     * @param string $hit_type The type of hit to check.
     * @param int    $limit    Maximum allowed hits per minute.
     * @return bool True if the rate limit has been exceeded.
     */
    private function is_rate_limited( string $ip_hash, string $hit_type, int $limit ): bool {
        $count = $this->store->get_count( $ip_hash, $hit_type, 1 );
        return $count > $limit;
    }

    /**
     * Detect whether the current request targets xmlrpc.php.
     */
    private function is_xmlrpc_request(): bool {
        if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
            return true;
        }

        $request_uri = (string) IpDetector::get_server_value( 'REQUEST_URI' );
        if ( $request_uri === '' ) {
            return false;
        }

        $path = (string) wp_parse_url( $request_uri, PHP_URL_PATH );
        if ( $path === '' ) {
            return false;
        }

        $normalized = preg_replace( '#/+#', '/', str_replace( '\\', '/', $path ) );
        if ( ! is_string( $normalized ) ) {
            $normalized = $path;
        }

        return strtolower( basename( $normalized ) ) === 'xmlrpc.php';
    }

    /**
     * Send a 429 Too Many Requests response and terminate.
     *
     * Includes a Retry-After header set to 60 seconds and fires the
     * `securwp_firewall_block` action for local logging/reporting purposes.
     */
    private function send_429( string $attack_type = 'Rate Limit', string $message = 'Request rate limit exceeded. Please wait before trying again.' ): void {
        do_action( 'securwp_firewall_block', [
            'site_url'    => home_url(),
            'ip'          => IpDetector::get_ip(),
            'http_status' => 429,
            'attack_type' => $attack_type,
            'request_uri' => IpDetector::get_server_value( 'REQUEST_URI' ),
        ] );

        if ( ! headers_sent() ) {
            http_response_code( 429 );
            header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
            header( 'Retry-After: 60' );
        }

        require_once dirname( __DIR__ ) . '/block-page.php';
        if ( ! headers_sent() ) {
            header( 'Content-Type: text/html; charset=UTF-8' );
        }
        securwp_render_block_page( 429, $message, IpDetector::get_ip() );
        exit;
    }

}
