<?php
/**
 * File to handle forms-conversion tracking shared functionality.
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Abstract base for forms tracking providers.
 *
 * Owns the public admin-ajax endpoint and the dedup transient. Concrete
 * subclasses (currently only HTML forms) layer on the frontend asset enqueue.
 */
abstract class SimpleStats_Forms_Tracking extends SimpleStats_Tracking_Base {

	/**
	 * admin-ajax action name. Used as both the wp_ajax_* hook suffix and the
	 * `action` field the JS posts.
	 */
	const AJAX_ACTION = 'simplestats_track_form';

	/**
	 * Nonce action used by `check_ajax_referer` / `wp_create_nonce`.
	 */
	const NONCE_ACTION = 'simplestats_track_form';

	/**
	 * Maximum length of identifier-style fields (form_id, form_name).
	 */
	const MAX_ID_LENGTH = 191;

	/**
	 * Per-session dedup window for repeat submissions.
	 */
	const DEDUP_TTL_SECONDS = 300; // 5 minutes.

	/**
	 * Throttle window, and the number of submissions accepted from one remote
	 * address inside it, for this public endpoint.
	 *
	 * Deliberately generous: behind a CDN or corporate NAT every visitor shares
	 * one REMOTE_ADDR, so the cap has to sit far above any plausible rate of
	 * genuine form submissions while still bounding a flood.
	 */
	const RATE_LIMIT_WINDOW = 60; // Seconds.
	const RATE_LIMIT_MAX    = 60;

	/**
	 * Register the admin-ajax handler. Called from
	 * SimpleStats_Tracking_Base::__construct().
	 */
	protected function hooks() {
		add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'handle_track_form' ) );
		add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION, array( $this, 'handle_track_form' ) );
	}

	/**
	 * Handle a form-submission POST from the frontend script.
	 *
	 * Reads form-encoded fields from $_POST and responds with wp_send_json_*.
	 * Always terminates via wp_send_json_* — never returns to the caller.
	 *
	 * @return void
	 */
	public function handle_track_form() {
		// 1. Nonce.
		//
		// Known limitation: the nonce is localized into the page, so on a
		// full-page-cached site a visitor can be served HTML whose nonce has
		// already rolled (WP nonces last ~12-24h). Those submissions are dropped
		// here as bad_nonce and the conversion is silently lost until the cache
		// refreshes. The nonce is kept anyway — it costs nothing on
		// non-cached sites and blocks trivial cross-site spam of this public
		// endpoint. Fixing it properly means fetching a fresh nonce at submit
		// time, which is an extra round trip per conversion; that trade-off is a
		// product decision, not a bug to paper over here.
		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error( array( 'reason' => 'bad_nonce' ), 403 );
		}

		// 2. Connection gate.
		if ( empty( $this->request_url ) ) {
			wp_send_json_success( array(
				'tracked' => false,
				'reason'  => 'not_connected',
			) );
		}

		// 2b. Throttle.
		//
		// An anonymous nonce is derived from user id 0 with an empty session
		// token, so a single harvested nonce is valid for every logged-out
		// visitor until the tick rolls. Without a cap, that one value is enough
		// to replay this endpoint indefinitely and have the site forward a
		// request upstream each time. Bail before doing any work.
		if ( $this->is_rate_limited() ) {
			wp_send_json_success( array(
				'tracked' => false,
				'reason'  => 'throttled',
			) );
		}

		// 3. Read + sanitize the form-encoded body.
		$form_id   = isset( $_POST['form_id'] ) ? sanitize_text_field( wp_unslash( $_POST['form_id'] ) ) : '';
		$form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
		$page_url  = isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '';

		if ( '' === $form_id || strlen( $form_id ) > self::MAX_ID_LENGTH ) {
			wp_send_json_error( array( 'reason' => 'invalid_form_id' ), 400 );
		}

		if ( strlen( $form_name ) > self::MAX_ID_LENGTH ) {
			$form_name = substr( $form_name, 0, self::MAX_ID_LENGTH );
		}

		// Origin check on page_url.
		$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
		$page_host = $page_url ? wp_parse_url( $page_url, PHP_URL_HOST ) : '';
		if ( empty( $page_url ) || $page_host !== $site_host ) {
			wp_send_json_error( array( 'reason' => 'bad_origin' ), 400 );
		}

		// 4. Dedup transient (keyed on session_event_uuid + form_id).
		//
		// The dedup window exists only to collapse repeat submits within a single
		// visitor session, so it is keyed off the pixel's own
		// `visitor_session_event_uuid` cookie. That cookie is attacker-supplied on
		// this public (nopriv) endpoint, so we only ever persist a transient when
		// it is a well-formed UUID — exactly what our pixel sets. Junk/forged
		// cookie values are still forwarded once, but can never seed an arbitrary
		// transient row, which closes the transient-table bloat vector.
		// This is an admin-ajax POST, never a cached response; the cookie only
		// scopes a dedup key.
		// phpcs:disable WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE
		$session_event_uuid = isset( $_COOKIE['visitor_session_event_uuid'] )
			? sanitize_text_field( wp_unslash( $_COOKIE['visitor_session_event_uuid'] ) )
			: '';
		// phpcs:enable WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE

		$can_dedup     = wp_is_uuid( $session_event_uuid );
		$transient_key = $can_dedup ? 'simplestats_form_seen_' . sha1( $session_event_uuid . '|' . $form_id ) : '';

		if ( $can_dedup && get_transient( $transient_key ) ) {
			wp_send_json_success( array(
				'tracked' => true,
				'dedup'   => true,
			) );
		}

		// 5. Forward to the app as a structured event (Goals V2). Users match it
		// with Event goals on Category "Forms" / Action "Submit"; the form's
		// human name (falling back to its id) travels as the label. This
		// replaces the legacy form_submit auto-goal, which the app no longer
		// auto-registers.
		$page_path = wp_parse_url( $page_url, PHP_URL_PATH );

		$result = $this->send_request( array(
			'type' => 'event',
			'url'  => $page_url,
			'data' => array(
				'category' => 'Forms',
				'action'   => 'Submit',
				'label'    => $form_name !== '' ? $form_name : $form_id,
				'value'    => null,
				'path'     => $page_path ? $page_path : '/',
			),
		) );

		// send_request() returns false on precondition failure (e.g. missing
		// cookies), WP_Error on HTTP error, or an array on a real HTTP response.
		// Treat the latter two as "we tried" and set the dedup transient
		// regardless to avoid retry storms from a flaky network.
		if ( false === $result ) {
			wp_send_json_success( array(
				'tracked' => false,
				'reason'  => 'forward_failed',
			) );
		}

		if ( $can_dedup ) {
			set_transient( $transient_key, 1, self::DEDUP_TTL_SECONDS );
		}

		wp_send_json_success( array( 'tracked' => true ) );
	}

	/**
	 * Whether this remote address has already used up its quota for the window.
	 *
	 * Keyed on REMOTE_ADDR and nothing else. simplestats_get_client_ip() must
	 * NOT be used here: it walks X-Forwarded-For and friends, which the client
	 * controls, so a limiter built on it is bypassed by rotating a header.
	 * REMOTE_ADDR is the only value the attacker cannot choose.
	 *
	 * Counting via a transient is inherently racy under concurrency, which is
	 * fine — the goal is to bound sustained abuse, not to enforce an exact quota.
	 *
	 * @return bool True when the request should be dropped.
	 */
	private function is_rate_limited() {
		$remote_addr = isset( $_SERVER['REMOTE_ADDR'] )
			? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
			: '';

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

		$key   = 'simplestats_form_rl_' . sha1( $remote_addr );
		$count = (int) get_transient( $key );

		if ( $count >= self::RATE_LIMIT_MAX ) {
			return true;
		}

		set_transient( $key, $count + 1, self::RATE_LIMIT_WINDOW );

		return false;
	}
}
