<?php
/**
 * Centralized rate limiting for Mail Doctor sends.
 *
 * @package MailDoctor
 */

namespace MailDoctor;

use MailDoctor\Helpers\Settings;

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

/**
 * Implements a token bucket with provider pushback awareness.
 */
class RateLimiter {

	/**
	 * Rolling window storage key.
	 */
	const TRANSIENT_WINDOW = 'mail_doctor_rate_window';

	/**
	 * Provider pushback hold storage key.
	 */
	const TRANSIENT_HOLD = 'mail_doctor_rate_hold';

	/**
	 * Claim a rate limit slot.
	 *
	 * @return array{allow: bool, wait: int, reason: string|null}
	 */
	public static function claim_slot(): array {
		$limit = (int) Settings::get( 'rate_limit_per_minute', 0 );

		if ( $limit <= 0 ) {
			return array(
				'allow'  => true,
				'wait'   => 0,
				'reason' => null,
			);
		}

		$now  = time();
		$hold = self::get_hold();

		if ( ! empty( $hold['hold_until'] ) && $hold['hold_until'] > $now ) {
			return array(
				'allow'  => false,
				'wait'   => (int) ( $hold['hold_until'] - $now ),
				'reason' => $hold['reason'] ?? 'provider_pushback',
			);
		}

		$window = self::get_window();
		$window = self::prune_window( $window, $now );

		if ( count( $window ) >= $limit ) {
			$oldest = (int) reset( $window );
			$wait   = max( 1, ( $oldest + 60 ) - $now );

			return array(
				'allow'  => false,
				'wait'   => $wait,
				'reason' => 'rate_limit',
			);
		}

		$window[] = $now;
		self::save_window( $window );

		return array(
			'allow'  => true,
			'wait'   => 0,
			'reason' => null,
		);
	}

	/**
	 * Register a provider pushback (4xx) to impose cooldown.
	 *
	 * @param string|null $provider_code Provider code, if available.
	 * @param string      $message       Error message.
	 * @return void
	 */
	public static function record_pushback( ?string $provider_code, string $message = '' ): void {
		if ( ! self::is_soft_bounce( $provider_code, $message ) ) {
			return;
		}

		$base_cooldown = max( 30, (int) Settings::get( 'rate_limit_pushback_cooldown', 120 ) );
		$now           = time();
		$hold          = self::get_hold();
		$count         = isset( $hold['count'] ) ? (int) $hold['count'] : 0;

		++$count;

		$cooldown   = min( $base_cooldown * $count, 30 * MINUTE_IN_SECONDS );
		$hold_until = $now + $cooldown;

		$data = array(
			'hold_until' => $hold_until,
			'reason'     => 'provider_pushback',
			'count'      => $count,
			'code'       => $provider_code,
			'message'    => $message,
		);

		set_transient( self::TRANSIENT_HOLD, $data, $cooldown );
	}

	/**
	 * Relax pushback hold after successful sends.
	 *
	 * @return void
	 */
	public static function record_success(): void {
		$hold = self::get_hold();

		if ( empty( $hold ) || empty( $hold['hold_until'] ) ) {
			return;
		}

		$now = time();

		if ( $hold['hold_until'] <= $now ) {
			delete_transient( self::TRANSIENT_HOLD );
			return;
		}

		if ( isset( $hold['reason'] ) && 'provider_pushback' === $hold['reason'] ) {
			$hold['count'] = max( 0, (int) $hold['count'] - 1 );

			if ( $hold['count'] <= 0 ) {
				delete_transient( self::TRANSIENT_HOLD );
				return;
			}

			$ttl = max( 1, (int) ( $hold['hold_until'] - $now ) );
			set_transient( self::TRANSIENT_HOLD, $hold, $ttl );
		}
	}

	/**
	 * Retrieve the rolling window of timestamps.
	 *
	 * @return array<int, int>
	 */
	protected static function get_window(): array {
		$window = get_transient( self::TRANSIENT_WINDOW );

		return is_array( $window ) ? array_map( 'intval', $window ) : array();
	}

	/**
	 * Persist window data.
	 *
	 * @param array<int, int> $window Window timestamps.
	 * @return void
	 */
	protected static function save_window( array $window ): void {
		sort( $window, SORT_NUMERIC );
		set_transient( self::TRANSIENT_WINDOW, $window, 2 * MINUTE_IN_SECONDS );
	}

	/**
	 * Remove timestamps outside the one-minute window.
	 *
	 * @param array<int, int> $window Window entries.
	 * @param int             $now    Reference timestamp.
	 * @return array<int, int>
	 */
	protected static function prune_window( array $window, int $now ): array {
		return array_values(
			array_filter(
				$window,
				static function ( $stamp ) use ( $now ) {
					return (int) $stamp >= ( $now - 60 );
				}
			)
		);
	}

	/**
	 * Retrieve hold metadata.
	 *
	 * @return array<string, mixed>
	 */
	protected static function get_hold(): array {
		$hold = get_transient( self::TRANSIENT_HOLD );

		return is_array( $hold ) ? $hold : array();
	}

	/**
	 * Determine whether error indicates provider throttling (4xx).
	 *
	 * @param string|null $provider_code Provider code.
	 * @param string      $message       Error message.
	 * @return bool
	 */
	protected static function is_soft_bounce( ?string $provider_code, string $message ): bool {
		if ( $provider_code && strpos( (string) $provider_code, '4' ) === 0 ) {
			return true;
		}

		$message_lc = strtolower( $message );

		$keywords = array(
			'rate limit',
			'try again later',
			'too many',
			'backoff',
			'tempfail',
			'service not available',
			'greylist',
		);

		foreach ( $keywords as $keyword ) {
			if ( false !== strpos( $message_lc, $keyword ) ) {
				return true;
			}
		}

		return false;
	}
}
