<?php
/**
 * Rate limiting for OTP requests.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use TextTango\Support\Clock;
use TextTango\Support\Settings;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Caps how often codes can be requested (spec §5, mandatory anti-abuse).
 *
 * Two independent limits, because they stop different attacks. The per-phone
 * limit stops someone using the login form to bombard one person with texts —
 * every one of which the site pays for. The per-IP limit stops one attacker
 * walking through many numbers from the same place.
 *
 * Counters are transients that expire on their own, so no cleanup job is needed.
 */
final class Throttle {

	/**
	 * Prefix for per-phone counters.
	 */
	private const PHONE_PREFIX = 'texttango_otp_rl_p_';

	/**
	 * Prefix for per-IP counters.
	 */
	private const IP_PREFIX = 'texttango_otp_rl_i_';

	/**
	 * Codes allowed to one number per window.
	 */
	public const PHONE_LIMIT = 3;

	/**
	 * Length of the per-phone window, in seconds.
	 */
	public const PHONE_WINDOW = 900;

	/**
	 * Codes allowed from one address per window.
	 */
	public const IP_LIMIT = 10;

	/**
	 * Length of the per-IP window, in seconds.
	 */
	public const IP_WINDOW = 3600;

	/**
	 * Whether another code may be sent right now.
	 *
	 * @param string $phone Recipient in E.164.
	 * @return true|WP_Error True when allowed; an error with `retry_after` otherwise.
	 */
	public static function check( string $phone ) {
		$phone_state = self::state( self::phone_key( $phone ) );

		if ( $phone_state['count'] >= self::phone_limit() ) {
			return self::too_many( $phone_state['expires_in'] );
		}

		$ip = self::client_ip();

		if ( '' !== $ip ) {
			$ip_state = self::state( self::ip_key( $ip ) );

			if ( $ip_state['count'] >= self::ip_limit() ) {
				return self::too_many( $ip_state['expires_in'] );
			}
		}

		return true;
	}

	/**
	 * Record that a code was sent.
	 *
	 * Called only after a successful send, so a failed API call does not consume
	 * the user's allowance.
	 *
	 * @param string $phone Recipient in E.164.
	 */
	public static function record( string $phone ): void {
		self::increment( self::phone_key( $phone ), self::PHONE_WINDOW );

		$ip = self::client_ip();

		if ( '' !== $ip ) {
			self::increment( self::ip_key( $ip ), self::IP_WINDOW );
		}
	}

	/**
	 * Clear a number's counter, after a successful sign-in.
	 *
	 * @param string $phone Recipient in E.164.
	 */
	public static function clear( string $phone ): void {
		delete_transient( self::phone_key( $phone ) );
	}

	/**
	 * Current count and remaining window for a counter.
	 *
	 * @param string $key Transient key.
	 * @return array{count: int, expires_in: int}
	 */
	private static function state( string $key ): array {
		$stored = get_transient( $key );

		if ( ! is_array( $stored ) ) {
			return array(
				'count'      => 0,
				'expires_in' => 0,
			);
		}

		return array(
			'count'      => (int) ( $stored['count'] ?? 0 ),
			'expires_in' => max( 0, (int) ( $stored['resets_at'] ?? 0 ) - Clock::now() ),
		);
	}

	/**
	 * Bump a counter, keeping the window fixed rather than sliding.
	 *
	 * A sliding window would let a persistent caller keep extending their own
	 * lockout window forever; a fixed one always drains.
	 *
	 * @param string $key    Transient key.
	 * @param int    $window Window length in seconds.
	 */
	private static function increment( string $key, int $window ): void {
		$stored = get_transient( $key );

		if ( is_array( $stored ) && isset( $stored['resets_at'] ) && (int) $stored['resets_at'] > Clock::now() ) {
			$stored['count'] = (int) ( $stored['count'] ?? 0 ) + 1;
			$remaining       = max( 1, (int) $stored['resets_at'] - Clock::now() );

			set_transient( $key, $stored, $remaining );

			return;
		}

		set_transient(
			$key,
			array(
				'count'     => 1,
				'resets_at' => Clock::now() + $window,
			),
			$window
		);
	}

	/**
	 * Build the "slow down" error.
	 *
	 * @param int $retry_after Seconds until the window resets.
	 */
	private static function too_many( int $retry_after ): WP_Error {
		return new WP_Error(
			'texttango_otp_throttled',
			__( 'Too many codes requested. Please try again shortly.', 'texttango' ),
			array( 'retry_after' => max( 1, $retry_after ) )
		);
	}

	/**
	 * Per-phone counter key, with the number hashed rather than stored.
	 *
	 * @param string $phone Recipient in E.164.
	 */
	private static function phone_key( string $phone ): string {
		return self::PHONE_PREFIX . hash_hmac( 'sha256', $phone, wp_salt() );
	}

	/**
	 * Per-IP counter key, with the address hashed rather than stored.
	 *
	 * @param string $ip Client address.
	 */
	private static function ip_key( string $ip ): string {
		return self::IP_PREFIX . hash_hmac( 'sha256', $ip, wp_salt() );
	}

	/**
	 * The client's address.
	 *
	 * Only REMOTE_ADDR is trusted. Forwarded-for headers are attacker-controlled
	 * unless a known proxy is in front, and believing them would let anyone reset
	 * their own limit by inventing a new address per request. Sites behind a
	 * reverse proxy can supply the real value through the filter.
	 */
	public static function client_ip(): string {
		$remote = isset( $_SERVER['REMOTE_ADDR'] )
			? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
			: '';

		$ip = filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : '';

		/**
		 * Filters the client address used for rate limiting.
		 *
		 * Only override this if a trusted proxy sits in front of the site.
		 *
		 * @param string $ip Address derived from REMOTE_ADDR.
		 */
		return (string) apply_filters( 'texttango_client_ip', $ip );
	}

	/**
	 * Configured per-phone limit, clamped to something sane.
	 */
	public static function phone_limit(): int {
		$limit = (int) Settings::get( 'otp_phone_limit', self::PHONE_LIMIT );

		return max( 1, min( 20, $limit ) );
	}

	/**
	 * Configured per-IP limit, clamped to something sane.
	 */
	public static function ip_limit(): int {
		$limit = (int) Settings::get( 'otp_ip_limit', self::IP_LIMIT );

		return max( 1, min( 100, $limit ) );
	}
}
