<?php
namespace Bookaro\Utils;

defined( 'ABSPATH' ) || exit;

class RateLimiter
{
    public static function client_ip(): string
    {
        // self::sanitize_ip() runs FILTER_VALIDATE_IP and returns '' for
        // anything that isn't a valid IPv4/IPv6 address — strictly
        // tighter than sanitize_text_field(). The lint can't see across
        // the custom sanitizer name so it flags this.
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $remote_addr = self::sanitize_ip( (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
        if ( $remote_addr === '' ) {
            return 'unknown';
        }

        $client_ip = $remote_addr;
        if ( self::is_trusted_proxy( $remote_addr ) ) {
            $forwarded_for = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '' ) );
            $forwarded_ip = self::extract_forwarded_for_ip( $forwarded_for );
            if ( $forwarded_ip !== '' ) {
                $client_ip = $forwarded_ip;
            }
        }

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

    private static function extract_forwarded_for_ip( string $forwarded_for ): string
    {
        if ( $forwarded_for === '' ) {
            return '';
        }

        $parts = array_map( 'trim', explode( ',', $forwarded_for ) );
        foreach ( $parts as $part ) {
            $ip = self::sanitize_ip( $part );
            if ( $ip !== '' ) {
                return $ip;
            }
        }

        return '';
    }

    private static function is_trusted_proxy( string $remote_addr ): bool
    {
        $trusted = apply_filters( 'bookaro_trusted_proxies', [] );
        if ( is_string( $trusted ) ) {
            $trusted = array_map( 'trim', explode( ',', $trusted ) );
        }
        if ( ! is_array( $trusted ) || empty( $trusted ) ) {
            return false;
        }

        foreach ( $trusted as $candidate ) {
            if ( ! is_scalar( $candidate ) ) {
                continue;
            }
            $rule = trim( (string) $candidate );
            if ( $rule === '' ) {
                continue;
            }
            if ( self::ip_matches_rule( $remote_addr, $rule ) ) {
                return true;
            }
        }

        return false;
    }

    private static function ip_matches_rule( string $ip, string $rule ): bool
    {
        $exact = self::sanitize_ip( $rule );
        if ( $exact !== '' ) {
            return $ip === $exact;
        }

        if ( ! str_contains( $rule, '/' ) ) {
            return false;
        }

        [ $network_raw, $prefix_raw ] = explode( '/', $rule, 2 );
        $network = self::sanitize_ip( $network_raw );
        if ( $network === '' || ! ctype_digit( trim( $prefix_raw ) ) ) {
            return false;
        }

        // Validate first so inet_pton() never receives malformed input (avoids the @ error-suppression operator).
        $ip_bin      = ( false !== filter_var( $ip, FILTER_VALIDATE_IP ) ) ? inet_pton( $ip ) : false;
        $network_bin = ( false !== filter_var( $network, FILTER_VALIDATE_IP ) ) ? inet_pton( $network ) : false;
        if ( $ip_bin === false || $network_bin === false || strlen( $ip_bin ) !== strlen( $network_bin ) ) {
            return false;
        }

        $prefix = (int) trim( $prefix_raw );
        $max_bits = strlen( $ip_bin ) * 8;
        if ( $prefix < 0 || $prefix > $max_bits ) {
            return false;
        }

        $full_bytes = intdiv( $prefix, 8 );
        $remaining_bits = $prefix % 8;

        if ( $full_bytes > 0 && substr( $ip_bin, 0, $full_bytes ) !== substr( $network_bin, 0, $full_bytes ) ) {
            return false;
        }

        if ( $remaining_bits === 0 ) {
            return true;
        }

        $mask = ( 0xFF << ( 8 - $remaining_bits ) ) & 0xFF;
        return ( ord( $ip_bin[ $full_bytes ] ) & $mask ) === ( ord( $network_bin[ $full_bytes ] ) & $mask );
    }

    private static function sanitize_ip( string $value ): string
    {
        $value = trim( $value, " \t\n\r\0\x0B\"'" );
        if ( $value === '' ) {
            return '';
        }

        if ( preg_match( '/^\[([0-9a-fA-F:.]+)\](?::\d+)?$/', $value, $matches ) ) {
            $value = $matches[1];
        } elseif ( preg_match( '/^([0-9.]+):\d+$/', $value, $matches ) ) {
            $value = $matches[1];
        }

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

    public static function build_identifier( array $parts ): string
    {
        $clean = [];
        foreach ( $parts as $key => $value ) {
            $key = preg_replace( '/[^A-Za-z0-9_\\-]/', '', (string) $key );
            if ( $key === '' ) {
                continue;
            }
            $value = is_scalar( $value ) ? (string) $value : '';
            $value = preg_replace( '/\\s+/', '-', trim( $value ) );
            if ( $value === '' ) {
                continue;
            }
            $clean[] = $key . ':' . $value;
        }
        return implode( '|', $clean );
    }

    public static function check( string $bucket, string $identifier, int $limit, int $window ): array
    {
        $limit = max( 1, $limit );
        $window = max( 1, $window );
        $bucket = preg_replace( '/[^A-Za-z0-9_\\-]/', '', $bucket );
        $raw_key = $bucket . '|' . $identifier;
        $key = 'bookaro_rl_' . md5( $raw_key );
        $now = time();

        $state = get_transient( $key );
        if ( ! is_array( $state ) || ! isset( $state['count'], $state['reset'] ) || (int) $state['reset'] <= $now ) {
            $state = [
                'count' => 0,
                'reset' => $now + $window,
            ];
        }

        $state['count'] = (int) $state['count'] + 1;
        $ttl = max( 1, (int) $state['reset'] - $now );
        set_transient( $key, $state, $ttl );

        $allowed = $state['count'] <= $limit;
        $retry_after = $allowed ? 0 : max( 1, (int) $state['reset'] - $now );
        $remaining = max( 0, $limit - (int) $state['count'] );

        return [
            'allowed' => $allowed,
            'retry_after' => $retry_after,
            'remaining' => $remaining,
            'reset' => (int) $state['reset'],
        ];
    }
}
