<?php
/**
 * Anti-abuse helpers for the public visit-request form.
 *
 * Provides the baseline anti-abuse gauntlet that hardens the one anonymous
 * (nopriv) write surface in the plugin: a hidden honeypot field, a submit-time
 * trap, and per-IP AND per-email transient rate limiting. CAPTCHA verification
 * is stubbed to a pass-through here and stays inert until plan 02-04 wires the
 * settings; with the default off the form makes zero external calls.
 *
 * None of these helpers reveal which check tripped. The submission handler
 * either drops silently (honeypot / time-trap) or returns a generic message
 * (rate limit / nonce / CAPTCHA), so a bot never learns why it failed.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Anti_Abuse
 */
class WrenchPress_Anti_Abuse {

	/**
	 * Minimum number of seconds that must elapse between rendering the form and
	 * submitting it. A faster submission is treated as automated and dropped.
	 */
	const MIN_SECONDS = 3;

	/**
	 * Default maximum submissions allowed per hour, per IP and per email. The
	 * threshold is tunable from settings (plan 02-04); this is the fallback.
	 */
	const MAX_PER_HOUR = 5;

	/**
	 * Name of the hidden honeypot field rendered in the form. A real visitor
	 * never sees or fills it; bots that auto-fill every input trip it.
	 */
	const HONEYPOT_FIELD = 'wrenchpress_website';

	/**
	 * Name of the hidden timestamp field used by the submit-time trap.
	 */
	const TIMESTAMP_FIELD = 'wrenchpress_ts';

	/**
	 * Google reCAPTCHA v3 siteverify endpoint.
	 */
	const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';

	/**
	 * Cloudflare Turnstile siteverify endpoint.
	 */
	const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

	/**
	 * Minimum reCAPTCHA v3 score treated as human (range 0.0 - 1.0).
	 */
	const RECAPTCHA_MIN_SCORE = 0.5;

	/**
	 * Read the plugin settings array, always returning an array.
	 *
	 * @return array<string,mixed>
	 */
	private static function settings() {
		$settings = get_option( 'wrenchpress_settings', array() );

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

	/**
	 * Whether the optional CAPTCHA is enabled.
	 *
	 * Reads the wrenchpress_captcha_enabled flag held in the wrenchpress_settings
	 * option array. Defaults to false, so the form makes zero external calls and
	 * pulls no third-party dependency unless the owner explicitly turns CAPTCHA on
	 * (D-09 / RESEARCH Pitfall 5).
	 *
	 * @return bool
	 */
	public static function captcha_enabled() {
		$settings = self::settings();

		return ! empty( $settings['captcha_enabled'] );
	}

	/**
	 * Effective maximum submissions per hour, honouring a settings override when
	 * one is present and valid, otherwise the default.
	 *
	 * @return int
	 */
	public static function max_per_hour() {
		$settings = self::settings();

		if ( isset( $settings['rate_limit_per_hour'] ) ) {
			$value = absint( $settings['rate_limit_per_hour'] );

			if ( $value > 0 ) {
				return $value;
			}
		}

		return self::MAX_PER_HOUR;
	}

	/**
	 * True when the honeypot field is non-empty (a bot filled it).
	 *
	 * @param array $post Raw POST data.
	 * @return bool
	 */
	public static function honeypot_tripped( array $post ) {
		$value = isset( $post[ self::HONEYPOT_FIELD ] ) ? (string) $post[ self::HONEYPOT_FIELD ] : '';

		return '' !== trim( $value );
	}

	/**
	 * True when the form was submitted implausibly fast (under MIN_SECONDS),
	 * which indicates an automated submission rather than a human filling it in.
	 *
	 * @param array $post Raw POST data.
	 * @return bool
	 */
	public static function too_fast( array $post ) {
		$ts = isset( $post[ self::TIMESTAMP_FIELD ] ) ? absint( $post[ self::TIMESTAMP_FIELD ] ) : 0;

		if ( 0 === $ts ) {
			// No timestamp at all is itself suspicious; treat as too fast.
			return true;
		}

		return ( time() - $ts ) < self::MIN_SECONDS;
	}

	/**
	 * Check whether this IP or email has exceeded the hourly submission limit.
	 *
	 * Bumps both the per-IP and per-email transient counters (hashed keys so raw
	 * values never land in an option name) and returns true once either crosses
	 * the threshold. Counters expire after an hour.
	 *
	 * @param string $ip    Submitter IP (from REMOTE_ADDR).
	 * @param string $email Submitter email.
	 * @return bool True when the submission should be rate-limited.
	 */
	public static function is_rate_limited( $ip, $email ) {
		$max = self::max_per_hour();

		$ip_count    = self::bump( 'ip_' . md5( (string) $ip ) );
		$email_count = self::bump( 'em_' . md5( strtolower( (string) $email ) ) );

		return $ip_count > $max || $email_count > $max;
	}

	/**
	 * Increment and persist a transient counter, returning the new count.
	 *
	 * @param string $suffix Hashed key suffix (already namespaced by caller).
	 * @return int New counter value.
	 */
	private static function bump( $suffix ) {
		$key   = 'wrenchpress_rl_' . $suffix;
		$count = (int) get_transient( $key ) + 1;

		set_transient( $key, $count, HOUR_IN_SECONDS );

		return $count;
	}

	/**
	 * Resolve the submitter IP from REMOTE_ADDR only.
	 *
	 * Proxy headers (X-Forwarded-For etc.) are deliberately untrusted: they are
	 * trivially spoofable and the rate limit is a soft control for a single-shop
	 * CRM (assumption A8). A site behind a known reverse proxy can adjust this
	 * later.
	 *
	 * @return string
	 */
	public static function client_ip() {
		if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
			return '';
		}

		$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );

		return (string) $ip;
	}

	/**
	 * Verify a CAPTCHA response.
	 *
	 * When CAPTCHA is disabled (the default) this returns true immediately and
	 * makes NO external request and enqueues NO provider script: the off path
	 * stays fully dependency-free (D-09 / RESEARCH Pitfall 5). Only when the owner
	 * has enabled CAPTCHA and configured a secret does this contact the provider's
	 * siteverify endpoint via wp_remote_post. A transport error or an
	 * unsuccessful / low-score response is treated as a generic failure.
	 *
	 * @param array $post Raw POST data.
	 * @return bool True when CAPTCHA passes or is disabled.
	 */
	public static function verify_captcha( array $post ) {
		// Disabled: pass through without touching any provider (zero external
		// calls when off). This guard MUST come first.
		if ( ! self::captcha_enabled() ) {
			return true;
		}

		$settings = self::settings();
		$secret   = isset( $settings['captcha_secret_key'] ) ? trim( (string) $settings['captcha_secret_key'] ) : '';
		$provider = isset( $settings['captcha_provider'] ) ? (string) $settings['captcha_provider'] : 'recaptcha_v3';

		// Enabled but not configured: fail closed (the form cannot pass an
		// unconfigured challenge). No external call is made.
		if ( '' === $secret ) {
			return false;
		}

		$token = isset( $post['captcha_token'] ) ? sanitize_text_field( (string) $post['captcha_token'] ) : '';

		if ( '' === $token ) {
			return false;
		}

		$verify_url = ( 'turnstile' === $provider )
			? self::TURNSTILE_VERIFY_URL
			: self::RECAPTCHA_VERIFY_URL;

		$response = wp_remote_post(
			$verify_url,
			array(
				'timeout' => 5,
				'body'    => array(
					'secret'   => $secret,
					'response' => $token,
					'remoteip' => self::client_ip(),
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( ! is_array( $body ) || empty( $body['success'] ) ) {
			return false;
		}

		// reCAPTCHA v3 returns a score; gate on the minimum. Turnstile has no
		// score, so a successful response is sufficient there.
		if ( 'turnstile' !== $provider && isset( $body['score'] ) ) {
			return (float) $body['score'] >= self::RECAPTCHA_MIN_SCORE;
		}

		return true;
	}
}
