<?php
/**
 * Public form rate limiting and replay protection.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

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

/**
 * Applies database-backed limits without persisting a visitor IP address.
 */
class SubmissionRateLimiter {
	const DEFAULT_MAX_ATTEMPTS = 10;
	const DEFAULT_WINDOW       = 600;
	const DEFAULT_ANALYTICS_MAX_ATTEMPTS = 60;
	const DEFAULT_ANALYTICS_WINDOW       = 600;
	const REPLAY_TTL           = DAY_IN_SECONDS;

	/**
	 * Consume one request from the current visitor's form-specific rate window.
	 *
	 * The update is atomic at the database layer. A failed/missing table is
	 * intentionally fail-open so a schema upgrade cannot take public forms down.
	 *
	 * @param int $form_id Form id.
	 * @return true|\WP_Error
	 */
	public function consume( $form_id ) {
		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return new \WP_Error( 'subin_invalid_form_rate_limit', __( 'This form is not available.', 'submission-inbox' ) );
		}

		$max_attempts = max(
			1,
			absint(
				apply_filters(
					'subin_submission_rate_limit',
					self::DEFAULT_MAX_ATTEMPTS,
					$form_id
				)
			)
		);
		$window       = max(
			MINUTE_IN_SECONDS,
			absint(
				apply_filters(
					'subin_submission_rate_window',
					self::DEFAULT_WINDOW,
					$form_id
				)
			)
		);

		if ( (bool) apply_filters( 'subin_bypass_submission_rate_limit', false, $form_id ) ) {
			return true;
		}

		return $this->consume_window(
			$form_id,
			$max_attempts,
			$window,
			'rate',
			'subin_submission_rate_limit_storage_error',
			'subin_submission_rate_limited',
			__( 'Too many attempts. Please wait a few minutes and try again.', 'submission-inbox' )
		);
	}

	/**
	 * Consume one anonymous analytics request without sharing submit capacity.
	 *
	 * @param int $form_id Form id.
	 * @return true|\WP_Error
	 */
	public function consume_analytics( $form_id ) {
		$form_id = absint( $form_id );
		if ( $form_id <= 0 ) {
			return new \WP_Error( 'subin_invalid_form_analytics_rate_limit', __( 'This form is not available.', 'submission-inbox' ) );
		}

		$max_attempts = max( 1, absint( apply_filters( 'subin_form_analytics_rate_limit', self::DEFAULT_ANALYTICS_MAX_ATTEMPTS, $form_id ) ) );
		$window       = max( MINUTE_IN_SECONDS, absint( apply_filters( 'subin_form_analytics_rate_window', self::DEFAULT_ANALYTICS_WINDOW, $form_id ) ) );
		if ( (bool) apply_filters( 'subin_bypass_form_analytics_rate_limit', false, $form_id ) ) {
			return true;
		}

		return $this->consume_window(
			$form_id,
			$max_attempts,
			$window,
			'analytics',
			'subin_form_analytics_rate_limit_storage_error',
			'subin_form_analytics_rate_limited',
			__( 'Too many analytics events were received. Please try again later.', 'submission-inbox' )
		);
	}

	/**
	 * Atomically consume one scoped visitor rate window.
	 *
	 * @param int    $form_id Form id.
	 * @param int    $max_attempts Maximum requests in the window.
	 * @param int    $window Window length in seconds.
	 * @param string $scope Internal rate-key scope.
	 * @param string $storage_error_hook Storage failure action.
	 * @param string $error_code Public rate-limit error code.
	 * @param string $error_message Public rate-limit message.
	 * @return true|\WP_Error
	 */
	private function consume_window( $form_id, $max_attempts, $window, $scope, $storage_error_hook, $error_code, $error_message ) {
		global $wpdb;

		$fingerprint = $this->visitor_fingerprint();
		if ( '' === $fingerprint ) {
			return true;
		}

		$now          = time();
		$window_start = (int) floor( $now / $window ) * $window;
		$rate_key     = $this->key(
			sanitize_key( $scope ) . '|' . $form_id . '|' . $fingerprint . '|' . $window_start
		);
		$started_at   = $this->mysql_date( $window_start );
		$expires_at   = $this->mysql_date( $window_start + $window + MINUTE_IN_SECONDS );
		$table        = $wpdb->prefix . 'subin_rate_limits';

		$query = $wpdb->prepare(
			"INSERT INTO {$table} (rate_key, attempts, window_started_at, expires_at)
			VALUES (%s, 1, %s, %s)
			ON DUPLICATE KEY UPDATE attempts = attempts + 1",
			$rate_key,
			$started_at,
			$expires_at
		);
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Atomic upsert prepared immediately above.
		$result = $wpdb->query( $query );

		if ( false === $result ) {
			do_action( sanitize_key( $storage_error_hook ), sanitize_text_field( (string) $wpdb->last_error ) );
			return true;
		}

		$attempts = absint(
			$wpdb->get_var(
				$wpdb->prepare(
					"SELECT attempts FROM {$table} WHERE rate_key = %s",
					$rate_key
				)
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$this->maybe_cleanup();

		if ( $attempts > $max_attempts ) {
			return new \WP_Error(
				sanitize_key( $error_code ),
				sanitize_text_field( $error_message ),
				array( 'retry_after' => max( 1, ( $window_start + $window ) - $now ) )
			);
		}

		return true;
	}

	/**
	 * Atomically claim an optional client-generated submission id.
	 *
	 * @param int    $form_id Form id.
	 * @param string $submission_id Client-generated id.
	 * @return true|\WP_Error
	 */
	public function claim_submission( $form_id, $submission_id ) {
		global $wpdb;

		$form_id       = absint( $form_id );
		$submission_id = $this->sanitize_submission_id( $submission_id );
		if ( $form_id <= 0 || '' === $submission_id ) {
			return true;
		}

		$table      = $wpdb->prefix . 'subin_rate_limits';
		$now        = time();
		$rate_key   = $this->submission_key( $form_id, $submission_id );
		$started_at = $this->mysql_date( $now );
		$expires_at = $this->mysql_date( $now + self::REPLAY_TTL );
		$reclaimed  = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table}
				SET attempts = 1, window_started_at = %s, expires_at = %s
				WHERE rate_key = %s AND expires_at < %s",
				$started_at,
				$expires_at,
				$rate_key,
				current_time( 'mysql' )
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		if ( false === $reclaimed ) {
			do_action( 'subin_submission_replay_storage_error', sanitize_text_field( (string) $wpdb->last_error ) );
			return true;
		}
		if ( $reclaimed > 0 ) {
			return true;
		}

		$inserted = $wpdb->query(
			$wpdb->prepare(
				"INSERT IGNORE INTO {$table} (rate_key, attempts, window_started_at, expires_at)
				VALUES (%s, 1, %s, %s)",
				$rate_key,
				$started_at,
				$expires_at
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( false === $inserted ) {
			do_action( 'subin_submission_replay_storage_error', sanitize_text_field( (string) $wpdb->last_error ) );
			return true;
		}

		if ( 0 === $inserted ) {
			$state = absint(
				$wpdb->get_var(
					$wpdb->prepare(
						"SELECT attempts FROM {$table} WHERE rate_key = %s",
						$rate_key
					)
				)
			); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			if ( $state >= 2 ) {
				return new \WP_Error(
					'subin_duplicate_submission',
					__( 'This submission has already been received.', 'submission-inbox' )
				);
			}

			return new \WP_Error(
				'subin_submission_in_progress',
				__( 'This submission is already being processed. Please wait a moment and try again.', 'submission-inbox' )
			);
		}

		return true;
	}

	/**
	 * Mark an accepted submission claim as complete.
	 *
	 * @param int    $form_id Form id.
	 * @param string $submission_id Client-generated id.
	 * @return void
	 */
	public function complete_submission( $form_id, $submission_id ) {
		global $wpdb;

		$form_id       = absint( $form_id );
		$submission_id = $this->sanitize_submission_id( $submission_id );
		if ( $form_id <= 0 || '' === $submission_id ) {
			return;
		}

		$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'subin_rate_limits',
			array( 'attempts' => 2 ),
			array( 'rate_key' => $this->submission_key( $form_id, $submission_id ) ),
			array( '%d' ),
			array( '%s' )
		);
	}

	/**
	 * Release a claim when persistence failed before the submission was accepted.
	 *
	 * @param int    $form_id Form id.
	 * @param string $submission_id Client-generated id.
	 * @return void
	 */
	public function release_submission( $form_id, $submission_id ) {
		global $wpdb;

		$form_id       = absint( $form_id );
		$submission_id = $this->sanitize_submission_id( $submission_id );
		if ( $form_id <= 0 || '' === $submission_id ) {
			return;
		}

		$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'subin_rate_limits',
			array( 'rate_key' => $this->submission_key( $form_id, $submission_id ) ),
			array( '%s' )
		);
	}

	/**
	 * Read and validate a client-generated submission id.
	 *
	 * @param mixed $value Raw value.
	 * @return string
	 */
	public function sanitize_submission_id( $value ) {
		$value = trim( sanitize_text_field( wp_unslash( (string) $value ) ) );

		if ( strlen( $value ) < 16 || strlen( $value ) > 128 ) {
			return '';
		}

		return preg_match( '/^[A-Za-z0-9._:-]+$/', $value ) ? $value : '';
	}

	/**
	 * Build a privacy-preserving visitor fingerprint.
	 *
	 * @return string
	 */
	private function visitor_fingerprint() {
		$remote_addr = trim( (string) ( $_SERVER['REMOTE_ADDR'] ?? '' ) );
		if ( '' === $remote_addr ) {
			return '';
		}

		$packed = inet_pton( $remote_addr );
		$value  = false !== $packed ? bin2hex( $packed ) : sanitize_text_field( $remote_addr );

		return $this->key( 'visitor|' . $value );
	}

	/**
	 * Derive the replay database key.
	 *
	 * @param int    $form_id Form id.
	 * @param string $submission_id Submission id.
	 * @return string
	 */
	private function submission_key( $form_id, $submission_id ) {
		return $this->key( 'submission|' . absint( $form_id ) . '|' . $submission_id );
	}

	/**
	 * Generate a keyed, fixed-length database key.
	 *
	 * @param string $value Source value.
	 * @return string
	 */
	private function key( $value ) {
		return hash_hmac( 'sha256', (string) $value, wp_salt( 'nonce' ) );
	}

	/**
	 * Format an epoch using the WordPress site's database timezone convention.
	 *
	 * @param int $timestamp Unix timestamp.
	 * @return string
	 */
	private function mysql_date( $timestamp ) {
		return wp_date( 'Y-m-d H:i:s', absint( $timestamp ), wp_timezone() );
	}

	/**
	 * Probabilistically remove expired rows in bounded batches.
	 *
	 * @return void
	 */
	private function maybe_cleanup() {
		global $wpdb;

		if ( 1 !== wp_rand( 1, 100 ) ) {
			return;
		}

		$table = $wpdb->prefix . 'subin_rate_limits';
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE expires_at < %s LIMIT 500",
				current_time( 'mysql' )
			)
		); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}
}
