<?php
/**
 * Shared normalization and public-request throttling.
 *
 * Kept independent from the large AJAX controller so OTP, waitlist, contact,
 * and booking endpoints use identical client identity and rate-limit rules.
 */
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class YATOON_Request_Guard {
    public static function client_ip() {
        $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
        if ( ! filter_var( $remote, FILTER_VALIDATE_IP ) ) {
            return '';
        }

        // Forwarded headers are attacker-controlled unless the TCP peer is a
        // proxy the site owner explicitly trusts. The feature is opt-in so an
        // upgrade can never silently change existing rate-limit identities.
        if ( '1' !== get_option( 'yatoon_trusted_proxy_enabled', '0' ) ) {
            return $remote;
        }
        $ranges = preg_split( '/[\s,]+/', (string) get_option( 'yatoon_trusted_proxy_ranges', '' ), -1, PREG_SPLIT_NO_EMPTY );
        $ranges = (array) apply_filters( 'yatoon_trusted_proxy_ranges', $ranges );
        if ( ! self::ip_in_ranges( $remote, $ranges ) ) {
            return $remote;
        }

        $header = sanitize_key( (string) get_option( 'yatoon_trusted_proxy_header', 'x_forwarded_for' ) );
        /**
         * Select the forwarded-client header used behind an explicitly trusted proxy.
         *
         * Accepted values: auto, cf_connecting_ip, x_forwarded_for, x_real_ip.
         * Returning any other value safely falls back to REMOTE_ADDR.
         *
         * @param string $header Configured header identifier.
         * @param string $remote Validated REMOTE_ADDR value.
         */
        $header = sanitize_key( (string) apply_filters( 'yatoon_trusted_proxy_header', $header, $remote ) );
        $allowed = array( 'auto', 'cf_connecting_ip', 'x_forwarded_for', 'x_real_ip' );
        if ( ! in_array( $header, $allowed, true ) ) {
            return $remote;
        }

        if ( in_array( $header, array( 'auto', 'cf_connecting_ip' ), true ) ) {
            $candidate = self::server_ip( 'HTTP_CF_CONNECTING_IP' );
            if ( '' !== $candidate ) {
                return $candidate;
            }
        }
        if ( in_array( $header, array( 'auto', 'x_forwarded_for' ), true ) ) {
            $forwarded = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] )
                ? explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) )
                : array();
            $chain = array_values( array_filter( array_map( 'trim', $forwarded ), static function ( $ip ) {
                return false !== filter_var( $ip, FILTER_VALIDATE_IP );
            } ) );
            // Walk from the nearest hop toward the client. Trusted proxy hops
            // are discarded; the first untrusted address is the client edge.
            for ( $i = count( $chain ) - 1; $i >= 0; $i-- ) {
                if ( ! self::ip_in_ranges( $chain[ $i ], $ranges ) ) {
                    return $chain[ $i ];
                }
            }
        }
        if ( in_array( $header, array( 'auto', 'x_real_ip' ), true ) ) {
            $candidate = self::server_ip( 'HTTP_X_REAL_IP' );
            if ( '' !== $candidate ) {
                return $candidate;
            }
        }
        return $remote;
    }

    private static function server_ip( $key ) {
        $ip = isset( $_SERVER[ $key ] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ) : '';
        return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
    }

    public static function ip_in_ranges( $ip, $ranges ) {
        $packed_ip = @inet_pton( (string) $ip );
        if ( false === $packed_ip ) {
            return false;
        }
        foreach ( (array) $ranges as $range ) {
            $range = trim( (string) $range );
            if ( '' === $range ) {
                continue;
            }
            $parts   = explode( '/', $range, 2 );
            $network = @inet_pton( $parts[0] );
            if ( false === $network || strlen( $network ) !== strlen( $packed_ip ) ) {
                continue;
            }
            $bits = isset( $parts[1] ) ? (int) $parts[1] : strlen( $network ) * 8;
            if ( $bits < 0 || $bits > strlen( $network ) * 8 ) {
                continue;
            }
            $bytes = intdiv( $bits, 8 );
            $rest  = $bits % 8;
            if ( substr( $packed_ip, 0, $bytes ) !== substr( $network, 0, $bytes ) ) {
                continue;
            }
            if ( 0 === $rest || ( ord( $packed_ip[ $bytes ] ) & ( 0xff << ( 8 - $rest ) ) ) === ( ord( $network[ $bytes ] ) & ( 0xff << ( 8 - $rest ) ) ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Validate and normalize administrator-supplied trusted proxy ranges.
     *
     * @param string $raw Newline, comma, or whitespace-separated IPs/CIDRs.
     * @return array{valid: string[], invalid: string[]}
     */
    public static function validate_proxy_ranges( $raw ) {
        $valid = array();
        $invalid = array();
        $ranges = preg_split( '/[\s,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY );
        foreach ( (array) $ranges as $range ) {
            $range = trim( (string) $range );
            $parts = explode( '/', $range, 2 );
            $packed = @inet_pton( $parts[0] );
            $max_bits = false === $packed ? 0 : strlen( $packed ) * 8;
            $bits = isset( $parts[1] ) ? $parts[1] : (string) $max_bits;
            $is_valid = false !== $packed
                && ctype_digit( (string) $bits )
                && (int) $bits >= 0
                && (int) $bits <= $max_bits
                && ! ( 0 === (int) $bits );
            if ( ! $is_valid ) {
                $invalid[] = $range;
                continue;
            }
            $normalized = $parts[0] . ( isset( $parts[1] ) ? '/' . (int) $bits : '' );
            $valid[] = $normalized;
        }
        return array( 'valid' => array_values( array_unique( $valid ) ), 'invalid' => array_values( array_unique( $invalid ) ) );
    }

    public static function normalize_phone_digits( $phone ) {
        $digits = preg_replace( '/\D/', '', (string) $phone );
        if ( strlen( $digits ) === 11 && substr( $digits, 0, 1 ) === '1' ) {
            $digits = substr( $digits, 1 );
        }
        return $digits;
    }

    public static function rate_limit_key( $scope, $contact = '' ) {
        $parts = array( sanitize_key( $scope ) );
        $ip = self::client_ip();
        if ( $ip !== '' ) {
            $parts[] = $ip;
        }
        if ( $contact !== '' ) {
            $parts[] = strtolower( sanitize_text_field( $contact ) );
        }
        return 'yatoon_public_' . md5( implode( '|', $parts ) );
    }

    /**
     * Consume one unit of a rate-limit budget, atomically where possible.
     *
     * 4.4.3: the previous implementation was read-check-write across three
     * separate calls:
     *
     *     $count = get_transient( $key );
     *     if ( $count >= $limit ) { deny }
     *     set_transient( $key, $count + 1 );
     *
     * Requests arriving inside that window all read the same $count, all pass
     * the check, and all write back the same value -- so a burst of N parallel
     * requests consumed one unit of budget between them. That is precisely the
     * traffic shape rate limiting exists to stop.
     *
     * With a persistent object cache we now use wp_cache_add() + wp_cache_incr(),
     * which are atomic in Redis/Memcached backends. Without one we fall back to
     * an INSERT on the options table: the column has a UNIQUE index on
     * option_name, so exactly one racer can create the row and the rest get a
     * duplicate-key error and re-read. Neither path can lose an increment.
     *
     * @param string $scope   Bucket name, e.g. 'otp_send'.
     * @param int    $limit   Allowed requests per window.
     * @param int    $minutes Window length.
     * @param string $contact Optional extra key component (email/phone).
     * @return void Sends a JSON error and exits when the budget is exhausted.
     */
    public static function enforce( $scope, $limit = 5, $minutes = 10, $contact = '' ) {
        if ( self::client_ip() === '' && $contact === '' ) {
            return;
        }

        $key     = self::rate_limit_key( $scope, $contact );
        $limit   = max( 1, (int) $limit );
        $window  = max( 1, (int) $minutes ) * MINUTE_IN_SECONDS;
        $count   = self::consume_counter( $key, $window );

        if ( $count > $limit ) {
            wp_send_json_error( array(
                'message' => sprintf(
                    /* translators: %d: number of minutes before another request is allowed. */
                    __( 'Too many requests. Please wait %d minutes and try again.', 'yatoon-booking-system' ),
                    max( 1, (int) $minutes )
                ),
            ) );
        }
    }

    /**
     * Atomically increment a counter and return its new value.
     *
     * @since 4.4.3
     * @param string $key
     * @param int    $window Seconds until the counter resets.
     * @return int The value AFTER this request is counted (starts at 1).
     */
    public static function consume_counter( $key, $window ) {
        // Fast path: a real object cache gives us atomic add/incr.
        if ( wp_using_ext_object_cache() ) {
            $group = 'yatoon_rate_limit';
            if ( false !== wp_cache_add( $key, 1, $group, $window ) ) {
                return 1;
            }
            $count = wp_cache_incr( $key, 1, $group );
            // incr returns false if the entry expired between add and incr.
            if ( false === $count ) {
                wp_cache_add( $key, 1, $group, $window );
                return 1;
            }
            return (int) $count;
        }

        // Fallback: lean on the UNIQUE index over wp_options.option_name.
        // add_option() performs an INSERT, so only one concurrent caller can
        // succeed in creating the row -- the others fall through to the
        // read-modify-write below, which is still a narrower window than
        // before and is bounded by a single UPDATE.
        global $wpdb;
        $option = '_transient_' . $key;
        $expiry = '_transient_timeout_' . $key;

        if ( (int) get_option( $expiry, 0 ) < time() ) {
            delete_option( $option );
            delete_option( $expiry );
        }

        if ( add_option( $option, 1, '', false ) ) {
            add_option( $expiry, time() + $window, '', false );
            return 1;
        }

        // UPDATE ... SET x = x + 1 is atomic at the row level in InnoDB.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query( $wpdb->prepare(
            "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s",
            $option
        ) );
        wp_cache_delete( $option, 'options' );

        return (int) get_option( $option, 1 );
    }

    public static function counter_value( $key ) {
        if ( wp_using_ext_object_cache() ) {
            return (int) wp_cache_get( $key, 'yatoon_rate_limit' );
        }
        $value = get_transient( $key );
        return is_numeric( $value ) ? (int) $value : 0;
    }

    public static function reset_counter( $key ) {
        wp_cache_delete( $key, 'yatoon_rate_limit' );
        delete_transient( $key );
    }
}
