<?php
/**
 * Front-end search protection.
 *
 * @package TechDexSearchScrapeGuard
 */

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

final class TDSSG_Guard {
	const COOKIE_NAME = 'tdssg_search_verified';

	/**
	 * Current search term for bounded event summaries.
	 *
	 * @var string
	 */
	private static $current_search = '';

	/**
	 * Register hooks.
	 */
	public static function init() {
		add_action( 'parse_request', array( __CLASS__, 'inspect_request' ), 1 );
		add_action( 'admin_post_nopriv_tdssg_verify_search', array( __CLASS__, 'handle_verification' ) );
		add_action( 'admin_post_tdssg_verify_search', array( __CLASS__, 'handle_verification' ) );
		add_filter( 'wp_robots', array( __CLASS__, 'ensure_noindex' ) );
		add_filter( 'robots_txt', array( __CLASS__, 'add_robots_directives' ), 20, 2 );
	}

	/**
	 * Reject invalid or abusive searches before the main WP_Query is created.
	 *
	 * @param WP $wp WordPress environment instance.
	 */
	public static function inspect_request( $wp ) {
		if ( ! TDSSG_Settings::get( 'enabled' ) || self::is_excluded_context( $wp ) || ! self::is_search_request( $wp ) ) {
			return;
		}

		$term = self::search_term( $wp );
		$page = self::search_page( $wp );
		self::$current_search = $term;

		if ( '' === trim( $term ) ) {
			self::reject( 'empty_search', 404 );
		}

		if ( self::text_length( $term ) > (int) TDSSG_Settings::get( 'max_length' ) ) {
			self::reject( 'search_too_long', 404 );
		}

		if ( self::word_count( $term ) > (int) TDSSG_Settings::get( 'max_words' ) ) {
			self::reject( 'too_many_words', 404 );
		}

		if ( $page > self::maximum_search_page() ) {
			self::reject( 'deep_pagination', 404 );
		}

		if ( TDSSG_Settings::get( 'block_chrome_30' ) && self::is_chrome_30() ) {
			self::reject( 'chrome_30_automation', 404 );
		}

		$verified = self::has_valid_verification_cookie();

		if ( TDSSG_Settings::get( 'require_verification' ) && ! $verified && self::turnstile_is_configured() ) {
			self::challenge();
		}

		if ( ! $verified ) {
			$state = TDSSG_Rate_Limiter::check(
				self::request_identity(),
				(int) TDSSG_Settings::get( 'rate_limit' ),
				(int) TDSSG_Settings::get( 'rate_window' ),
				(int) TDSSG_Settings::get( 'cooldown' )
			);

			if ( ! empty( $state['limited'] ) ) {
				if ( self::turnstile_is_configured() ) {
					self::challenge( (int) $state['retry_after'] );
				}

				self::reject( 'rate_limited', 429, (int) $state['retry_after'] );
			}
		}
	}

	/**
	 * Handle a Turnstile verification submission.
	 */
	public static function handle_verification() {
		$nonce = isset( $_POST['tdssg_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['tdssg_nonce'] ) ) : '';

		if ( ! wp_verify_nonce( $nonce, 'tdssg_verify_search' ) ) {
			self::reject( 'verification_failed', 403 );
		}

		$token    = isset( $_POST['cf-turnstile-response'] ) ? sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ) ) : '';
		$redirect = isset( $_POST['tdssg_redirect'] ) ? esc_url_raw( wp_unslash( $_POST['tdssg_redirect'] ) ) : home_url( '/' );
		$redirect = wp_validate_redirect( $redirect, home_url( '/' ) );

		if ( '' === $token || ! self::turnstile_is_configured() ) {
			self::reject( 'verification_failed', 403 );
		}

		$response = wp_remote_post(
			'https://challenges.cloudflare.com/turnstile/v0/siteverify',
			array(
				'timeout' => 10,
				'body'    => array(
					'secret'   => TDSSG_Settings::get( 'turnstile_secret_key' ),
					'response' => $token,
					'remoteip' => self::remote_address(),
				),
			)
		);

		$body = is_wp_error( $response ) ? array() : json_decode( wp_remote_retrieve_body( $response ), true );

		if ( empty( $body['success'] ) ) {
			self::reject( 'verification_failed', 403 );
		}

		self::set_verification_cookie();
		wp_safe_redirect( $redirect, 303 );
		exit;
	}

	/**
	 * Ensure WordPress search pages are noindex.
	 *
	 * @param array $robots Robots directives.
	 * @return array
	 */
	public static function ensure_noindex( $robots ) {
		if ( TDSSG_Settings::get( 'ensure_search_noindex' ) && is_search() ) {
			$robots['noindex'] = true;
			unset( $robots['index'] );
		}

		return $robots;
	}

	/**
	 * Add crawler guidance for search URLs.
	 *
	 * @param string $output Robots.txt output.
	 * @param bool   $public Whether the site is public.
	 * @return string
	 */
	public static function add_robots_directives( $output, $public ) {
		if ( ! $public || ! TDSSG_Settings::get( 'add_robots_directives' ) ) {
			return $output;
		}

		if ( false === strpos( $output, '# TechDex Search Scrape Guard' ) ) {
			$output .= "\n# TechDex Search Scrape Guard";
			$output .= "\nUser-agent: *";
			$output .= "\nDisallow: /search/";
			$output .= "\nDisallow: /*?s=";
		}

		return rtrim( $output ) . "\n";
	}

	/**
	 * Determine whether the request must be left alone.
	 *
	 * @param WP $wp WordPress environment instance.
	 * @return bool
	 */
	private static function is_excluded_context( $wp ) {
		if ( is_admin() ) {
			return true;
		}

		foreach ( array( 'REST_REQUEST', 'DOING_AJAX', 'DOING_CRON', 'WP_CLI' ) as $constant ) {
			if ( defined( $constant ) && constant( $constant ) ) {
				return true;
			}
		}

		if ( isset( $wp->query_vars['rest_route'] ) ) {
			return true;
		}

		$path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

		return false !== strpos( $path, '/wp-json/' );
	}

	/**
	 * Determine whether this is a public WordPress search request.
	 *
	 * @param WP $wp WordPress environment instance.
	 * @return bool
	 */
	private static function is_search_request( $wp ) {
		return array_key_exists( 's', $_GET ) || isset( $wp->query_vars['s'] );
	}

	/**
	 * Read the normalized search term.
	 *
	 * @param WP $wp WordPress environment instance.
	 * @return string
	 */
	private static function search_term( $wp ) {
		$value = array_key_exists( 's', $_GET ) ? $_GET['s'] : $wp->query_vars['s'];

		if ( is_array( $value ) || is_object( $value ) ) {
			return '';
		}

		return sanitize_text_field( wp_unslash( (string) $value ) );
	}

	/**
	 * Return the requested search page number.
	 *
	 * @param WP $wp WordPress environment instance.
	 * @return int
	 */
	private static function search_page( $wp ) {
		$page = 1;

		foreach ( array( 'paged', 'page' ) as $key ) {
			if ( isset( $wp->query_vars[ $key ] ) ) {
				$page = max( $page, absint( $wp->query_vars[ $key ] ) );
			}
		}

		return max( 1, $page );
	}

	/**
	 * Return the pagination ceiling for the current visitor.
	 *
	 * @return int
	 */
	private static function maximum_search_page() {
		return is_user_logged_in()
			? (int) TDSSG_Settings::get( 'logged_in_max_page' )
			: (int) TDSSG_Settings::get( 'max_page' );
	}

	/**
	 * Return a multibyte-safe text length.
	 *
	 * @param string $text Text to measure.
	 * @return int
	 */
	private static function text_length( $text ) {
		return function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );
	}

	/**
	 * Count whitespace-delimited search words.
	 *
	 * @param string $text Search text.
	 * @return int
	 */
	private static function word_count( $text ) {
		$parts = preg_split( '/\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );

		return is_array( $parts ) ? count( $parts ) : 0;
	}

	/**
	 * Match the obsolete automation signature from the incident.
	 *
	 * @return bool
	 */
	private static function is_chrome_30() {
		return 1 === preg_match( '/\bChrome\/30(?:\.|\s|$)/i', self::user_agent() );
	}

	/**
	 * Return a privacy-preserving input for the fixed-slot limiter.
	 *
	 * @return string
	 */
	private static function request_identity() {
		return self::remote_address();
	}

	/**
	 * Use only the connection address. Proxy headers are not trusted here.
	 *
	 * @return string
	 */
	private static function remote_address() {
		$address = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';

		return filter_var( $address, FILTER_VALIDATE_IP ) ? $address : 'unknown';
	}

	/**
	 * Determine whether Turnstile can be used.
	 *
	 * @return bool
	 */
	private static function turnstile_is_configured() {
		return '' !== (string) TDSSG_Settings::get( 'turnstile_site_key' )
			&& '' !== (string) TDSSG_Settings::get( 'turnstile_secret_key' );
	}

	/**
	 * Render a minimal Turnstile page without loading the theme.
	 *
	 * @param int $retry_after Optional retry interval.
	 */
	private static function challenge( $retry_after = 0 ) {
		self::record_event( 'challenge', 429 );
		status_header( 429 );
		nocache_headers();
		header( 'X-Robots-Tag: noindex, nofollow', true );
		header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset', 'UTF-8' ) );

		if ( $retry_after > 0 ) {
			header( 'Retry-After: ' . $retry_after );
		}

		$action   = admin_url( 'admin-post.php' );
		$redirect = self::current_url();
		$site_key = TDSSG_Settings::get( 'turnstile_site_key' );

		// phpcs:ignore PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Turnstile is an optional disclosed external service whose client API must be loaded from Cloudflare.
		wp_enqueue_script(
			'tdssg-turnstile',
			'https://challenges.cloudflare.com/turnstile/v0/api.js',
			array(),
			null,
			false
		);
		?>
		<!doctype html>
		<html lang="en">
		<head>
			<meta charset="utf-8">
			<meta name="viewport" content="width=device-width, initial-scale=1">
			<meta name="robots" content="noindex,nofollow">
			<title><?php esc_html_e( 'Verify search request', 'techdex-search-scrape-guard' ); ?></title>
			<style>body{font:16px/1.5 system-ui,sans-serif;margin:0;background:#f6f7f7;color:#1d2327}.box{max-width:460px;margin:12vh auto;padding:28px;background:#fff;border:1px solid #dcdcde;border-radius:8px}button{margin-top:18px;padding:10px 16px}</style>
			<?php wp_print_scripts( 'tdssg-turnstile' ); ?>
		</head>
		<body>
			<main class="box">
				<h1><?php esc_html_e( 'Verify your search', 'techdex-search-scrape-guard' ); ?></h1>
				<p><?php esc_html_e( 'Please complete the browser check to continue searching.', 'techdex-search-scrape-guard' ); ?></p>
				<form method="post" action="<?php echo esc_url( $action ); ?>" autocomplete="off">
					<input type="hidden" name="action" value="tdssg_verify_search">
					<?php wp_nonce_field( 'tdssg_verify_search', 'tdssg_nonce' ); ?>
					<input type="hidden" name="tdssg_redirect" value="<?php echo esc_attr( $redirect ); ?>">
					<div class="cf-turnstile" data-sitekey="<?php echo esc_attr( $site_key ); ?>"></div>
					<button type="submit"><?php esc_html_e( 'Continue', 'techdex-search-scrape-guard' ); ?></button>
				</form>
			</main>
		</body>
		</html>
		<?php
		exit;
	}

	/**
	 * Emit a lightweight rejection without theme rendering.
	 *
	 * @param string $reason      Counter reason.
	 * @param int    $status      HTTP status.
	 * @param int    $retry_after Optional retry interval.
	 */
	private static function reject( $reason, $status, $retry_after = 0 ) {
		self::record_event( $reason, $status );
		status_header( $status );
		nocache_headers();
		header( 'X-Robots-Tag: noindex, nofollow', true );
		header( 'Content-Type: text/plain; charset=' . get_option( 'blog_charset', 'UTF-8' ) );

		if ( $retry_after > 0 ) {
			header( 'Retry-After: ' . $retry_after );
		}

		echo esc_html( 429 === $status ? __( 'Search rate limit exceeded.', 'techdex-search-scrape-guard' ) : __( 'Search request rejected.', 'techdex-search-scrape-guard' ) );
		exit;
	}

	/**
	 * Add the request to the bounded, coalesced event table.
	 *
	 * @param string $reason Rejection or challenge reason.
	 * @param int    $status HTTP status.
	 */
	private static function record_event( $reason, $status ) {
		TDSSG_Logger::write(
			$reason,
			$status,
			self::remote_address(),
			self::user_agent(),
			self::$current_search
		);
	}

	/**
	 * Return the reported browser user-agent string.
	 *
	 * @return string
	 */
	private static function user_agent() {
		return isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
	}

	/**
	 * Create a signed, short-lived verification cookie.
	 */
	private static function set_verification_cookie() {
		$expires   = time() + (int) TDSSG_Settings::get( 'verified_cookie_lifetime' );
		$payload   = (string) $expires;
		$signature = hash_hmac( 'sha256', self::cookie_message( $payload ), wp_salt( 'auth' ) );
		$value     = $payload . '.' . $signature;

		setcookie(
			self::COOKIE_NAME,
			$value,
			array(
				'expires'  => $expires,
				'path'     => self::cookie_path(),
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Lax',
			)
		);
	}

	/**
	 * Validate the signed verification cookie.
	 *
	 * @return bool
	 */
	private static function has_valid_verification_cookie() {
		if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) {
			return false;
		}

		$parts = explode( '.', sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ), 2 );

		if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) || (int) $parts[0] < time() ) {
			return false;
		}

		$expected = hash_hmac( 'sha256', self::cookie_message( $parts[0] ), wp_salt( 'auth' ) );

		return hash_equals( $expected, $parts[1] );
	}

	/**
	 * Bind a verification cookie to its browser user-agent.
	 *
	 * @param string $payload Expiry payload.
	 * @return string
	 */
	private static function cookie_message( $payload ) {
		$user_agent = self::user_agent();

		return $payload . '|' . $user_agent;
	}

	/**
	 * Return the site path for the cookie.
	 *
	 * @return string
	 */
	private static function cookie_path() {
		$path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );

		return $path ? trailingslashit( $path ) : '/';
	}

	/**
	 * Return the current same-origin URL.
	 *
	 * @return string
	 */
	private static function current_url() {
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';

		return home_url( $request_uri );
	}
}
