<?php

declare(strict_types=1);

namespace SecurWP\Modules\Firewall\RateLimiter;

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

/**
 * Tiered sliding window counter for rate limiting.
 *
 * Uses a persistent external object cache (Redis/Memcached) as the primary
 * backend when available, with the {prefix}securwp_rate_limits table as the
 * durable fallback for environments without a real shared cache.
 *
 * Counters use minute-granularity buckets. get_weighted_count() implements a
 * true sliding window via weighted interpolation across the boundary bucket,
 * eliminating the "two-times burst" bypass that affects naive fixed-window
 * rate limiters.
 */
class SlidingWindowStore {
    private const CACHE_GROUP = 'securwp_rate_limits';
    private const CACHE_TTL = 3900;
    private const DB_FALLBACK_MARKER = 'rate-limit-db-fallback.php';

    /**
     * Request-local backend used after the first write.
     *
     * @var 'cache'|'db'|null
     */
    private ?string $request_backend = null;

    /**
     * Increment the hit counter for a given IP hash and hit type.
     *
     * Uses the current minute bucket (floor(time() / 60)) for grouping.
     * Uses the external object cache when available, otherwise the durable
     * database fallback store.
     *
     * @param string $ip_hash  SHA-256 hash of the IP address.
     * @param string $hit_type Rate-limit counter type.
     */
    public function increment( string $ip_hash, string $hit_type ): void {
        if ( $this->should_use_object_cache() && $this->increment_object_cache( $ip_hash, $hit_type ) ) {
            $this->request_backend = 'cache';
            return;
        }

        $this->request_backend = 'db';
        $this->mark_db_fallback_active();
        $this->increment_db( $ip_hash, $hit_type );
    }

    /**
     * Increment the counter in the database fallback store.
     *
     * @param string $ip_hash
     * @param string $hit_type
     */
    private function increment_db( string $ip_hash, string $hit_type ): void {
        global $wpdb;

        $table         = self::table_identifier( $wpdb->prefix . 'securwp_rate_limits' );
        $minute_bucket = (int) floor( time() / 60 );

        // Wrapped in try/catch for graceful degradation. If the rate limit
        // table is missing (e.g. plugin partially installed, DB migration
        // pending), the firewall continues without rate limiting rather
        // than causing a fatal error.
        try {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $result = $wpdb->query(
                $wpdb->prepare(
                    "INSERT INTO {$table} (ip_hash, hit_type, minute_bucket, hits)
                     VALUES (%s, %s, %d, 1)
                     ON DUPLICATE KEY UPDATE hits = hits + 1",
                    $ip_hash,
                    $hit_type,
                    $minute_bucket
                )
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

            if ( $result === false && ! empty( $wpdb->last_error ) ) {
                $this->log_db_error( 'increment', $wpdb->last_error );
            }
        } catch ( \Throwable $e ) {
            $this->log_db_error( 'increment', $e->getMessage() );
        }
    }

    /**
     * Increment the counter in the external object cache.
     *
     * Returns false when the shared cache is not usable so callers can fall
     * back to the durable database path.
     */
    private function increment_object_cache( string $ip_hash, string $hit_type ): bool {
        $key = $this->cache_key( $ip_hash, $hit_type, (int) floor( time() / 60 ) );

        if ( ! function_exists( 'wp_cache_add' ) || ! function_exists( 'wp_cache_incr' ) ) {
            return false;
        }

        try {
            wp_cache_add( $key, 0, self::CACHE_GROUP, self::CACHE_TTL );
            $result = wp_cache_incr( $key, 1, self::CACHE_GROUP );

            if ( $result === false ) {
                return false;
            }

            return true;
        } catch ( \Throwable $e ) {
            securwp_log( 'SecurWP RateLimiter: object cache error in increment - ' . $e->getMessage() . '. Falling back to DB.' );
            return false;
        }
    }

    /**
     * Get the weighted hit count using a sliding window.
     *
     * For a one-minute window, the current minute is counted in full and the
     * previous minute is blended by the fraction that still falls inside the
     * trailing 60-second window.
     *
     * For multi-minute windows, intermediate buckets are counted in full and
     * only the oldest boundary bucket is weighted.
     *
     * @param string $ip_hash  SHA-256 hash of the IP address.
     * @param string $hit_type Rate-limit counter type.
     * @param int    $minutes  Window length in minutes. Minimum 1.
     * @return int Weighted hit count for the sliding window.
     */
    public function get_weighted_count( string $ip_hash, string $hit_type, int $minutes = 1 ): int {
        $object_cache_enabled = $this->should_use_object_cache();
        $merge_db_fallback = $this->should_merge_db_fallback();

        if ( $object_cache_enabled ) {
            if ( $this->request_backend === 'db' ) {
                $count = $this->get_weighted_count_from_db( $ip_hash, $hit_type, $minutes );
                if ( $merge_db_fallback ) {
                    $count += $this->get_weighted_count_from_object_cache( $ip_hash, $hit_type, $minutes );
                }

                return $count;
            }

            $count = $this->get_weighted_count_from_object_cache( $ip_hash, $hit_type, $minutes );
            if ( $merge_db_fallback ) {
                $count += $this->get_weighted_count_from_db( $ip_hash, $hit_type, $minutes );
            }

            return $count;
        }

        return $this->get_weighted_count_from_db( $ip_hash, $hit_type, $minutes );
    }

    /**
     * Get the weighted count from the database fallback store.
     *
     * @param string $ip_hash
     * @param string $hit_type
     * @param int    $minutes
     * @return int
     */
    private function get_weighted_count_from_db( string $ip_hash, string $hit_type, int $minutes = 1 ): int {
        global $wpdb;

        $minutes        = max( 1, $minutes );
        $table          = self::table_identifier( $wpdb->prefix . 'securwp_rate_limits' );
        $now            = time();
        $current_bucket = (int) floor( $now / 60 );
        $boundary_bucket = $current_bucket - $minutes;
        $seconds_in     = $now % 60;

        try {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT minute_bucket, hits FROM {$table}
                     WHERE ip_hash = %s AND hit_type = %s AND minute_bucket >= %d
                     ORDER BY minute_bucket DESC",
                    $ip_hash,
                    $hit_type,
                    $boundary_bucket
                ),
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

            if ( ! empty( $wpdb->last_error ) ) {
                $this->log_db_error( 'get_weighted_count', $wpdb->last_error );
                return 0;
            }

            $total_hits = 0;
            $boundary_hits = 0;

            foreach ( (array) $rows as $row ) {
                $bucket = (int) $row['minute_bucket'];
                $hits   = (int) $row['hits'];

                if ( $bucket === $boundary_bucket ) {
                    $boundary_hits = $hits;
                    continue;
                }

                if ( $bucket > $boundary_bucket && $bucket <= $current_bucket ) {
                    $total_hits += $hits;
                }
            }

            $weighted_boundary = (int) floor( $boundary_hits * ( 60 - $seconds_in ) / 60 );

            return $total_hits + $weighted_boundary;
        } catch ( \Throwable $e ) {
            $this->log_db_error( 'get_weighted_count', $e->getMessage() );
            return 0;
        }
    }

    /**
     * Get the weighted count from the external object cache backend.
     *
     * @param string $ip_hash
     * @param string $hit_type
     * @param int    $minutes
     * @return int
     */
    private function get_weighted_count_from_object_cache( string $ip_hash, string $hit_type, int $minutes = 1 ): int {
        $minutes         = max( 1, $minutes );
        $now             = time();
        $current_bucket  = (int) floor( $now / 60 );
        $boundary_bucket = $current_bucket - $minutes;
        $seconds_in      = $now % 60;
        $total_hits      = 0;
        $boundary_hits   = 0;

        for ( $bucket = $current_bucket; $bucket >= $boundary_bucket; $bucket-- ) {
            $key  = $this->cache_key( $ip_hash, $hit_type, $bucket );
            $hits = wp_cache_get( $key, self::CACHE_GROUP );
            $hits = is_numeric( $hits ) ? (int) $hits : 0;

            if ( $bucket === $boundary_bucket ) {
                $boundary_hits = $hits;
                continue;
            }

            $total_hits += $hits;
        }

        $weighted_boundary = (int) floor( $boundary_hits * ( 60 - $seconds_in ) / 60 );

        return $total_hits + $weighted_boundary;
    }

    /**
     * Get the total hit count for a given IP hash and hit type within a sliding window.
     *
     * Delegates to get_weighted_count() for accurate sliding-window semantics.
     *
     * @param string $ip_hash  SHA-256 hash of the IP address.
     * @param string $hit_type Rate-limit counter type.
     * @param int    $minutes  Number of minutes in the sliding window. Default 1.
     * @return int Total number of hits within the window.
     */
    public function get_count( string $ip_hash, string $hit_type, int $minutes = 1 ): int {
        return $this->get_weighted_count( $ip_hash, $hit_type, $minutes );
    }

    /**
     * Purge entries older than a specified number of minutes.
     *
     * Intended to be called via WP-Cron to keep the table small.
     *
     * @param int $older_than_minutes Delete buckets older than this many minutes. Default 5.
     * @return int Number of rows deleted.
     */
    public function purge_old( int $older_than_minutes = 5 ): int {
        global $wpdb;

        $table     = self::table_identifier( $wpdb->prefix . 'securwp_rate_limits' );
        $threshold = (int) floor( time() / 60 ) - $older_than_minutes;

        try {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $deleted = $wpdb->query(
                $wpdb->prepare(
                    "DELETE FROM {$table} WHERE minute_bucket < %d",
                    $threshold
                )
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

            return (int) $deleted;
        } catch ( \Throwable $e ) {
            $this->log_db_error( 'purge_old', $e->getMessage() );
            return 0;
        }
    }

    /**
     * Log a database error once per request to avoid flooding the error log.
     *
     * @param string $method The method that encountered the error.
     * @param string $error  The error message.
     */
    private function log_db_error( string $method, string $error ): void {
        static $logged = false;
        if ( ! $logged ) {
            securwp_log( 'SecurWP RateLimiter: DB error in ' . $method . ' - ' . $error . '. Rate limiting degraded.' );
            $logged = true;
        }
    }

    /**
     * Use the external object cache only when WordPress is backed by a real
     * shared cache such as Redis or Memcached.
     */
    private function should_use_object_cache(): bool {
        return function_exists( 'wp_using_ext_object_cache' )
            && wp_using_ext_object_cache()
            && function_exists( 'wp_cache_get' )
            && function_exists( 'wp_cache_set' )
            && function_exists( 'wp_cache_add' )
            && function_exists( 'wp_cache_incr' );
    }

    private function cache_key( string $ip_hash, string $hit_type, int $minute_bucket ): string {
        return $ip_hash . ':' . $hit_type . ':' . $minute_bucket;
    }

    /**
     * Mark that recent requests had to fall back to the database backend.
     *
     * While this marker is fresh, cache-backed reads merge in DB fallback
     * buckets so a transient cache failure cannot split one logical window
     * across two backends.
     */
    private function mark_db_fallback_active(): void {
        $file = $this->db_fallback_marker_path();
        if ( $file === '' ) {
            return;
        }

        $dir = dirname( $file );
        if ( ! is_dir( $dir ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Runtime firewall creates its own private data directory marker when cache fallback is needed.
            @mkdir( $dir, 0755, true );
        }

        if ( ! is_dir( $dir ) ) {
            return;
        }

        $guard = "<?php exit; ?>\n";
        try {
            $suffix = bin2hex( random_bytes( 4 ) );
        } catch ( \Throwable $e ) {
            $suffix = substr( md5( uniqid( '', true ) ), 0, 8 );
        }
        $temp  = $file . '.tmp.' . $suffix;
        $body  = $guard . (string) ( time() + self::CACHE_TTL );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writes a short-lived internal marker for DB fallback rate-limit windows.
        if ( @file_put_contents( $temp, $body, LOCK_EX ) === false ) {
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Matches the plugin's generated runtime marker file permissions.
        @chmod( $temp, 0644 );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace keeps the DB fallback marker coherent across requests.
        if ( ! @rename( $temp, $file ) ) {
            wp_delete_file( $temp );
        }
    }

    /**
     * Check whether cache-backed reads must merge in recent DB fallback hits.
     */
    private function should_merge_db_fallback(): bool {
        $file = $this->db_fallback_marker_path();
        if ( $file === '' || ! is_file( $file ) || ! is_readable( $file ) ) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- Reads the plugin-owned DB fallback marker for rate-limit reconciliation.
        $raw = @file_get_contents( $file );
        if ( ! is_string( $raw ) || $raw === '' ) {
            return false;
        }

        $guard = "<?php exit; ?>\n";
        if ( strncmp( $raw, $guard, strlen( $guard ) ) === 0 ) {
            $raw = substr( $raw, strlen( $guard ) );
        }

        $expires_at = (int) trim( $raw );
        if ( $expires_at <= time() ) {
            wp_delete_file( $file );
            return false;
        }

        return true;
    }

    /**
     * Resolve the shared fallback marker path.
     */
    private function db_fallback_marker_path(): string {
        if ( ! defined( 'WP_CONTENT_DIR' ) ) {
            return '';
        }

        return WP_CONTENT_DIR . '/securwp-data/' . self::DB_FALLBACK_MARKER;
    }

    /**
     * Escape a plugin-owned table identifier for direct interpolation.
     */
    private static function table_identifier( string $table ): string {
        if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
            throw new \InvalidArgumentException( 'Invalid table identifier.' );
        }

        return '`' . str_replace( '`', '``', $table ) . '`';
    }
}
