<?php
/**
 * Spam Shield
 *
 * Self-contained, zero-configuration-required anti-spam helpers for public
 * front-end forms (signup, donation, invites). Deliberately avoids any
 * external service (CAPTCHA provider, Akismet, etc.) so the plugin keeps
 * working out of the box with no API keys and nothing "phones home" — see
 * the "Spam Protection" settings tab for the on/off switches and thresholds.
 *
 * Techniques used, all self-hosted:
 *  - Honeypot field: a field real visitors never see or fill in; bots that
 *    blindly fill every input trip it.
 *  - Timing check: a signed render timestamp; submissions faster than a
 *    human can plausibly fill the form are flagged.
 *  - Rate limiting: a short-lived transient counter per hashed IP address.
 *  - Disposable/blocked email domains: a small built-in list plus whatever
 *    the site admin adds.
 *  - Free-text content filter: flags an excessive number of links in
 *    optional message fields.
 *
 * IP addresses are never stored in the clear — only a one-way wp_hash() of
 * the address is ever persisted, in the rate-limit transient key and in the
 * diagnostic log, so this stays lightweight from a privacy standpoint.
 *
 * @package AmbassadorHub\Spam
 */

namespace AmbassadorHub\Spam;

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

/**
 * Static helper class providing honeypot, timing, rate-limit, and
 * content-filtering checks for public form handlers.
 */
final class SpamShield {

	/** Option name for the rolling diagnostic log. */
	const LOG_OPTION = 'ambh_spam_log';

	/** Maximum number of entries kept in the diagnostic log. */
	const LOG_MAX_ENTRIES = 50;

	/** Name of the honeypot input. Left generic so it reads as a plausible field to a bot. */
	const HONEYPOT_FIELD = 'ambh_website';

	/** Name of the signed render-timestamp input. */
	const TIMING_FIELD = 'ambh_ts';

	/**
	 * A small built-in list of well-known disposable/throwaway email domains.
	 * Merged with whatever the site admin adds in settings.
	 *
	 * @var string[]
	 */
	private const BUILTIN_DISPOSABLE_DOMAINS = array(
		'mailinator.com',
		'guerrillamail.com',
		'guerrillamail.info',
		'10minutemail.com',
		'10minutemail.net',
		'tempmail.com',
		'temp-mail.org',
		'throwawaymail.com',
		'yopmail.com',
		'trashmail.com',
		'getnada.com',
		'sharklasers.com',
		'dispostable.com',
		'fakeinbox.com',
		'maildrop.cc',
		'mintemail.com',
		'mailnesia.com',
	);

	/**
	 * Render the honeypot + timing hidden fields for a form.
	 *
	 * Call this once inside every public <form> this shield protects, then
	 * call passed() with the same $form_id at the top of the submission
	 * handler (after the existing nonce check).
	 *
	 * @param string $form_id Short identifier for the form, e.g. 'signup', 'donation'.
	 *
	 * @return void
	 */
	public static function render_protection_fields( string $form_id ): void {
		if ( ! self::honeypot_enabled() ) {
			return;
		}

		$timestamp = time();
		$signature = self::sign_timestamp( $form_id, $timestamp );
		?>
		<div class="ambh-spam-trap" aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;height:0;width:0;overflow:hidden;">
			<label for="<?php echo esc_attr( self::HONEYPOT_FIELD . '_' . $form_id ); ?>"><?php esc_html_e( 'Leave this field empty', 'ambassadorhub' ); ?></label>
			<input type="text" tabindex="-1" autocomplete="off" id="<?php echo esc_attr( self::HONEYPOT_FIELD . '_' . $form_id ); ?>" name="<?php echo esc_attr( self::HONEYPOT_FIELD ); ?>" value="" />
		</div>
		<input type="hidden" name="<?php echo esc_attr( self::TIMING_FIELD ); ?>" value="<?php echo esc_attr( $timestamp . '.' . $signature ); ?>" />
		<?php
	}

	/**
	 * Verify the honeypot + timing fields for a submitted form.
	 *
	 * Silently returns true (no-op pass) when the honeypot/timing feature is
	 * turned off in settings. Logs a diagnostic entry on failure so the admin
	 * can see blocked attempts under Settings → Spam Protection.
	 *
	 * @param string $form_id Same identifier passed to render_protection_fields().
	 *
	 * @return bool True when the submission looks human, false when it should be rejected.
	 */
	public static function passed( string $form_id ): bool {
		if ( ! self::honeypot_enabled() ) {
			return true;
		}

		// Honeypot: any value at all means a bot filled every field.
		$honeypot_value = isset( $_POST[ self::HONEYPOT_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::HONEYPOT_FIELD ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- honeypot check, not a nonce; the form's own nonce is verified separately by the caller.
		if ( '' !== $honeypot_value ) {
			self::log( $form_id, 'honeypot' );
			return false;
		}

		// Timing: the signed timestamp must be present, correctly signed, and
		// old enough that a human could plausibly have filled the form.
		$raw = isset( $_POST[ self::TIMING_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::TIMING_FIELD ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- timing trap, not a nonce.
		if ( ! preg_match( '/^(\d+)\.([a-f0-9]{64})$/', $raw, $matches ) ) {
			self::log( $form_id, 'timing_missing' );
			return false;
		}

		$timestamp = (int) $matches[1];
		$signature = $matches[2];
		if ( ! hash_equals( self::sign_timestamp( $form_id, $timestamp ), $signature ) ) {
			self::log( $form_id, 'timing_invalid' );
			return false;
		}

		$min_seconds = self::min_submit_seconds();
		if ( $min_seconds > 0 && ( time() - $timestamp ) < $min_seconds ) {
			self::log( $form_id, 'timing_too_fast' );
			return false;
		}

		return true;
	}

	/**
	 * Sign a form-id + timestamp pair with the site's auth salt so the
	 * timing field can't be forged or replayed against a different form.
	 *
	 * @param string $form_id   Form identifier.
	 * @param int    $timestamp Unix timestamp the field was rendered at.
	 *
	 * @return string 64-character hex HMAC.
	 */
	private static function sign_timestamp( string $form_id, int $timestamp ): string {
		return hash_hmac( 'sha256', $form_id . '|' . $timestamp, wp_salt( 'auth' ) );
	}

	/**
	 * Check and consume one hit of a rate limit.
	 *
	 * Uses a transient counter, the same pattern already used by
	 * InviteManager::check_rate_limit()/generate_token() — a sliding window
	 * that extends by $window_seconds on every hit rather than a fixed
	 * calendar window. Good enough for abuse throttling.
	 *
	 * @param string $key            Unique bucket name, e.g. 'signup_' . ip hash.
	 * @param int    $max_attempts   Attempts allowed within the window.
	 * @param int    $window_seconds Window length in seconds.
	 *
	 * @return bool True when still within the limit (and the hit was recorded), false when over the limit.
	 */
	public static function throttle( string $key, int $max_attempts, int $window_seconds ): bool {
		if ( ! self::rate_limiting_enabled() ) {
			return true;
		}

		$transient_key = 'ambh_spam_rl_' . $key;
		$count         = (int) get_transient( $transient_key );

		if ( $count >= $max_attempts ) {
			return false;
		}

		set_transient( $transient_key, $count + 1, $window_seconds );
		return true;
	}

	/**
	 * Convenience wrapper: throttle by the current visitor's hashed IP address.
	 *
	 * @param string $action         Short action name, e.g. 'signup', 'donation'.
	 * @param int    $max_attempts   Attempts allowed within the window.
	 * @param int    $window_seconds Window length in seconds.
	 *
	 * @return bool True when allowed, false when the IP is over its limit for this action.
	 */
	public static function throttle_by_ip( string $action, int $max_attempts, int $window_seconds ): bool {
		$allowed = self::throttle( $action . '_' . self::ip_hash(), $max_attempts, $window_seconds );
		if ( ! $allowed ) {
			self::log( $action, 'rate_limited' );
		}
		return $allowed;
	}

	/**
	 * Convenience wrapper: throttle by a privacy-preserving email hash.
	 *
	 * @param string $action         Short action name, e.g. 'donation'.
	 * @param string $email          Email address submitted with the form.
	 * @param int    $max_attempts   Attempts allowed within the window.
	 * @param int    $window_seconds Window length in seconds.
	 * @return bool True when still within the limit.
	 */
	public static function throttle_by_email( string $action, string $email, int $max_attempts, int $window_seconds ): bool {
		$email_hash = hash_hmac( 'sha256', strtolower( trim( $email ) ), wp_salt( 'auth' ) );
		$allowed    = self::throttle( $action . '_email_' . $email_hash, $max_attempts, $window_seconds );

		if ( ! $allowed ) {
			self::log( $action, 'email_rate_limited' );
		}

		return $allowed;
	}

	/**
	 * Whether an email address uses a known disposable/throwaway domain, or
	 * one the site admin has explicitly blocked.
	 *
	 * @param string $email Email address to check.
	 *
	 * @return bool
	 */
	public static function is_blocked_email( string $email ): bool {
		if ( ! self::email_filter_enabled() ) {
			return false;
		}

		$at_pos = strrpos( $email, '@' );
		if ( false === $at_pos ) {
			return false;
		}
		$domain = strtolower( trim( substr( $email, $at_pos + 1 ) ) );
		if ( '' === $domain ) {
			return false;
		}

		if ( in_array( $domain, self::BUILTIN_DISPOSABLE_DOMAINS, true ) ) {
			return true;
		}

		return in_array( $domain, self::get_admin_blocked_domains(), true );
	}

	/**
	 * Parse the admin-configured blocked-domains textarea setting into a
	 * lowercase, trimmed array.
	 *
	 * @return string[]
	 */
	private static function get_admin_blocked_domains(): array {
		$raw = (string) get_option( 'ambh_spam_blocked_email_domains', '' );
		if ( '' === trim( $raw ) ) {
			return array();
		}

		$lines = preg_split( '/[\r\n,]+/', $raw );
		$lines = is_array( $lines ) ? $lines : array();

		return array_values(
			array_filter(
				array_map(
					static fn( $line ) => strtolower( trim( $line ) ),
					$lines
				)
			)
		);
	}

	/**
	 * Whether a free-text field (e.g. a donation message) contains more
	 * links than allowed — a common signal for link-spam.
	 *
	 * @param string $text Text to inspect.
	 *
	 * @return bool
	 */
	public static function has_too_many_links( string $text ): bool {
		if ( ! self::content_filter_enabled() || '' === trim( $text ) ) {
			return false;
		}

		$max_links = self::max_links_in_message();
		if ( $max_links < 0 ) {
			return false;
		}

		$link_count = preg_match_all( '~(https?://|www\.)~i', $text );
		return false !== $link_count && $link_count > $max_links;
	}

	// -------------------------------------------------------------------------
	// Settings accessors
	// -------------------------------------------------------------------------

	/**
	 * @return bool
	 */
	public static function honeypot_enabled(): bool {
		return (bool) get_option( 'ambh_spam_honeypot_enabled', true );
	}

	/**
	 * @return bool
	 */
	public static function rate_limiting_enabled(): bool {
		return (bool) get_option( 'ambh_spam_rate_limiting_enabled', true );
	}

	/**
	 * @return bool
	 */
	public static function email_filter_enabled(): bool {
		return (bool) get_option( 'ambh_spam_email_filter_enabled', true );
	}

	/**
	 * @return bool
	 */
	public static function content_filter_enabled(): bool {
		return (bool) get_option( 'ambh_spam_content_filter_enabled', true );
	}

	/**
	 * @return int Minimum number of seconds that must elapse between form render and submission.
	 */
	public static function min_submit_seconds(): int {
		return max( 0, (int) get_option( 'ambh_spam_min_submit_seconds', 3 ) );
	}

	/**
	 * @return int Max signup attempts allowed per IP per hour.
	 */
	public static function signup_rate_limit(): int {
		return max( 1, (int) get_option( 'ambh_spam_signup_rate_limit', 5 ) );
	}

	/**
	 * @return int Max donation attempts allowed per IP per 10 minutes.
	 */
	public static function donation_rate_limit(): int {
		return max( 1, (int) get_option( 'ambh_spam_donation_rate_limit', 50 ) );
	}

	/**
	 * @return int Max links allowed in a free-text message field before it's flagged.
	 */
	public static function max_links_in_message(): int {
		return max( 0, (int) get_option( 'ambh_spam_max_links_in_message', 2 ) );
	}

	// -------------------------------------------------------------------------
	// IP hashing + diagnostic log
	// -------------------------------------------------------------------------

	/**
	 * Resolve the current visitor's IP address.
	 *
	 * Uses REMOTE_ADDR only, since proxy headers (X-Forwarded-For, etc.) are
	 * trivially spoofed by the client sending the request and would let a
	 * bot bypass the per-IP rate limit outright. Sites running behind a
	 * trusted reverse proxy that already sanitizes REMOTE_ADDR are
	 * unaffected; sites that need a different source can filter it.
	 *
	 * @return string
	 */
	private static function get_ip(): string {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';

		/**
		 * Filter the IP address used for spam rate-limiting.
		 *
		 * @since 0.8.0
		 *
		 * @param string $ip Resolved IP address (defaults to REMOTE_ADDR).
		 */
		return (string) apply_filters( 'ambh_spam_client_ip', $ip );
	}

	/**
	 * A one-way, site-salted hash of the current visitor's IP address.
	 *
	 * Never store or log the raw IP — only this hash — so the rate-limit
	 * transient keys and the diagnostic log stay privacy-friendly.
	 *
	 * @return string
	 */
	public static function ip_hash(): string {
		return substr( wp_hash( self::get_ip() ), 0, 16 );
	}

	/**
	 * Append an entry to the rolling diagnostic log, capped at LOG_MAX_ENTRIES.
	 *
	 * @param string $form_id Form/action identifier.
	 * @param string $reason  Short machine-readable reason code.
	 *
	 * @return void
	 */
	public static function log( string $form_id, string $reason ): void {
		$log   = get_option( self::LOG_OPTION, array() );
		$log   = is_array( $log ) ? $log : array();
		$log[] = array(
			'time'    => time(),
			'form'    => $form_id,
			'reason'  => $reason,
			'ip_hash' => self::ip_hash(),
		);

		if ( count( $log ) > self::LOG_MAX_ENTRIES ) {
			$log = array_slice( $log, -1 * self::LOG_MAX_ENTRIES );
		}

		update_option( self::LOG_OPTION, $log, false );
	}

	/**
	 * Get the diagnostic log, most recent first.
	 *
	 * @return array<int,array{time:int,form:string,reason:string,ip_hash:string}>
	 */
	public static function get_log(): array {
		$log = get_option( self::LOG_OPTION, array() );
		$log = is_array( $log ) ? $log : array();
		return array_reverse( $log );
	}

	/**
	 * Clear the diagnostic log.
	 *
	 * @return void
	 */
	public static function clear_log(): void {
		delete_option( self::LOG_OPTION );
	}
}
