<?php
/**
 * Fixed-slot search rate limiter.
 *
 * @package TechDexSearchScrapeGuard
 */

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

final class TDSSG_Rate_Limiter {
	const SLOT_COUNT = 256;

	/**
	 * Create the bounded rate-limit table.
	 */
	public static function activate() {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table_name      = self::table_name();
		$charset_collate = $wpdb->get_charset_collate();
		$sql             = "CREATE TABLE {$table_name} (
			slot_id smallint(5) unsigned NOT NULL,
			identity_hash char(64) NOT NULL DEFAULT '',
			window_start bigint(20) unsigned NOT NULL DEFAULT 0,
			hit_count int(10) unsigned NOT NULL DEFAULT 0,
			cooldown_until bigint(20) unsigned NOT NULL DEFAULT 0,
			PRIMARY KEY  (slot_id)
		) {$charset_collate};";

		dbDelta( $sql );

		if ( false === get_option( TDSSG_Settings::OPTION_KEY, false ) ) {
			add_option( TDSSG_Settings::OPTION_KEY, TDSSG_Settings::defaults(), '', false );
		}
	}

	/**
	 * Record an attempt and return its rate state.
	 *
	 * Storage is capped at SLOT_COUNT rows. Hash collisions intentionally evict
	 * the previous occupant instead of creating an unbounded per-IP key.
	 *
	 * @param string $identity Anonymous request identity.
	 * @param int    $limit    Allowed attempts per window.
	 * @param int    $window   Window in seconds.
	 * @param int    $cooldown Cooldown in seconds.
	 * @return array
	 */
	public static function check( $identity, $limit, $window, $cooldown ) {
		global $wpdb;

		$now          = time();
		$window_start = $now - ( $now % $window );
		$hash         = hash_hmac( 'sha256', $identity, wp_salt( 'nonce' ) );
		$slot         = self::slot_for_hash( $hash );
		$table_name   = self::table_name();

		$sql = $wpdb->prepare(
			"INSERT INTO {$table_name}
				(slot_id, identity_hash, window_start, hit_count, cooldown_until)
			VALUES (%d, %s, %d, 1, 0)
			ON DUPLICATE KEY UPDATE
				hit_count = IF(
					identity_hash <> VALUES(identity_hash) OR window_start <> VALUES(window_start),
					1,
					hit_count + 1
				),
				cooldown_until = IF(
					identity_hash <> VALUES(identity_hash),
					0,
					IF(
						cooldown_until > %d,
						cooldown_until,
						IF(window_start = VALUES(window_start) AND hit_count > %d, %d, 0)
					)
				),
				window_start = IF(
					identity_hash <> VALUES(identity_hash) OR window_start <> VALUES(window_start),
					VALUES(window_start),
					window_start
				),
				identity_hash = VALUES(identity_hash)",
			$slot,
			$hash,
			$window_start,
			$now,
			$limit,
			$now + $cooldown
		);

		$result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		if ( false === $result ) {
			return array(
				'limited'     => false,
				'retry_after' => 0,
				'available'   => false,
			);
		}

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT identity_hash, hit_count, cooldown_until
				FROM {$table_name}
				WHERE slot_id = %d",
				$slot
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		if ( ! is_array( $row ) || ! hash_equals( $hash, (string) $row['identity_hash'] ) ) {
			return array(
				'limited'     => false,
				'retry_after' => 0,
				'available'   => false,
			);
		}

		$cooldown_until = (int) $row['cooldown_until'];

		return array(
			'limited'     => $cooldown_until > $now,
			'retry_after' => max( 0, $cooldown_until - $now ),
			'available'   => true,
			'count'       => (int) $row['hit_count'],
		);
	}

	/**
	 * Return the table name.
	 *
	 * @return string
	 */
	public static function table_name() {
		global $wpdb;

		return $wpdb->prefix . 'tdssg_rate_slots';
	}

	/**
	 * Map a hash to a fixed slot.
	 *
	 * @param string $hash Hex-encoded SHA-256 hash.
	 * @return int
	 */
	private static function slot_for_hash( $hash ) {
		$bytes  = hex2bin( substr( $hash, 0, 8 ) );
		$number = unpack( 'Nvalue', $bytes );

		return (int) ( $number['value'] % self::SLOT_COUNT );
	}
}

