<?php
/**
 * Anonymous (nopriv) submission handler for the public visit-request form.
 *
 * This is the plugin's first untrusted public write surface. Every field is
 * attacker-controlled, so handle() runs the full anti-abuse gauntlet in a fixed
 * order before any persistence: verify nonce, honeypot, time-trap, per-IP and
 * per-email rate limit, CAPTCHA (inert by default), then sanitize-on-input and
 * validate the required fields. Only after all checks pass is a lead inserted
 * through WrenchPress_Leads_Repository, which uses prepared statements.
 *
 * Outcomes use Post/Redirect/Get back to the form page so the no-JS path works.
 * No response reveals which check tripped: honeypot and time-trap drop silently
 * (still a success redirect, so bots learn nothing), nonce/CAPTCHA/validation
 * return the generic error, and the rate limit returns the rate-limited copy.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Visit_Submission
 */
class WrenchPress_Visit_Submission {

	/**
	 * Register the nopriv (anonymous) and priv (logged-in) submission hooks.
	 *
	 * @return void
	 */
	public static function register() {
		add_action( 'admin_post_nopriv_' . WrenchPress_Visit_Form::ACTION, array( __CLASS__, 'handle' ) );
		add_action( 'admin_post_' . WrenchPress_Visit_Form::ACTION, array( __CLASS__, 'handle' ) );
	}

	/**
	 * Handle a visit-request submission.
	 *
	 * @return void
	 */
	public static function handle() {
		// 1. Nonce (CSRF). Generic, retryable failure; never a hard wp_die so a
		// legitimate visitor on a cached page can resubmit (RESEARCH Pitfall 3).
		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';

		if ( ! wp_verify_nonce( $nonce, WrenchPress_Visit_Form::NONCE_ACTION ) ) {
			self::redirect_error();
		}

		// Raw POST snapshot for the trap checks (wp_unslash handled per field on
		// sanitize). The trap helpers only inspect presence/emptiness/timing.
		$raw = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- trap inspection only; every persisted field is sanitized in read_fields() below.

		// 2. Honeypot: a filled hidden field means a bot. Drop silently.
		if ( WrenchPress_Anti_Abuse::honeypot_tripped( $raw ) ) {
			self::redirect_silent_drop();
		}

		// 3. Time-trap: an implausibly fast submission is automated. Drop silently.
		if ( WrenchPress_Anti_Abuse::too_fast( $raw ) ) {
			self::redirect_silent_drop();
		}

		// Sanitize on input before the rate limit so the email key is normalized.
		$fields = self::read_fields();

		// 4. Rate limit: per-IP AND per-email. Generic rate-limited message.
		$ip = WrenchPress_Anti_Abuse::client_ip();

		if ( WrenchPress_Anti_Abuse::is_rate_limited( $ip, $fields['email'] ) ) {
			self::redirect_error( 'rate_limited', $fields );
		}

		// 5. CAPTCHA: inert (returns true) until plan 02-04 enables it. Generic fail.
		if ( ! WrenchPress_Anti_Abuse::verify_captcha( $raw ) ) {
			self::redirect_error( '', $fields );
		}

		// 6. Validate required fields server-side (authoritative).
		if ( ! self::is_valid( $fields ) ) {
			self::redirect_error( '', $fields );
		}

		// Consent is recorded with a timestamp ONLY when the box was checked and
		// validated; never inferred from form presence (RESEARCH Pitfall 4).
		$consent_at = '0000-00-00 00:00:00';
		if ( ! empty( $fields['consent'] ) ) {
			$consent_at = current_time( 'mysql' );
		}

		$lead = array(
			'first_name'   => $fields['first_name'],
			'last_name'    => $fields['last_name'],
			'phone'        => $fields['phone'],
			'email'        => $fields['email'],
			'address'      => $fields['address'],
			'make'         => $fields['make'],
			'model'        => $fields['model'],
			'year'         => $fields['year'],
			'vin'          => $fields['vin'],
			'plate'        => $fields['plate'],
			'mileage'      => $fields['mileage'],
			'fuel_type'    => $fields['fuel_type'],
			'transmission' => $fields['transmission'],
			'color'        => $fields['color'],
			'subject'      => $fields['subject'],
			'description'  => $fields['description'],
			'consent'      => 1,
			'consent_at'   => $consent_at,
			'source'       => $fields['source'],
			'submit_ip'    => $ip,
			'submit_ua'    => self::user_agent(),
		);

		$inserted = WrenchPress_Leads_Repository::create( $lead );

		if ( false === $inserted ) {
			self::redirect_error( '', $fields );
		}

		self::redirect_success();
	}

	/**
	 * Read and sanitize every submittable field from the POST body.
	 *
	 * Sanitize-on-input at the correct boundary per field: text fields via
	 * sanitize_text_field, email via sanitize_email, free text via
	 * sanitize_textarea_field, and numeric fields via absint.
	 *
	 * @return array<string,mixed>
	 */
	private static function read_fields() {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- the nonce is verified in handle() before this runs.
		$fields = array(
			'first_name'   => isset( $_POST['first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['first_name'] ) ) : '',
			'last_name'    => isset( $_POST['last_name'] ) ? sanitize_text_field( wp_unslash( $_POST['last_name'] ) ) : '',
			'phone'        => isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '',
			'email'        => isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '',
			'address'      => isset( $_POST['address'] ) ? sanitize_text_field( wp_unslash( $_POST['address'] ) ) : '',
			'make'         => isset( $_POST['make'] ) ? sanitize_text_field( wp_unslash( $_POST['make'] ) ) : '',
			'model'        => isset( $_POST['model'] ) ? sanitize_text_field( wp_unslash( $_POST['model'] ) ) : '',
			'year'         => isset( $_POST['year'] ) ? absint( wp_unslash( $_POST['year'] ) ) : 0,
			'vin'          => isset( $_POST['vin'] ) ? sanitize_text_field( wp_unslash( $_POST['vin'] ) ) : '',
			'plate'        => isset( $_POST['plate'] ) ? sanitize_text_field( wp_unslash( $_POST['plate'] ) ) : '',
			'mileage'      => isset( $_POST['mileage'] ) ? absint( wp_unslash( $_POST['mileage'] ) ) : 0,
			'fuel_type'    => isset( $_POST['fuel_type'] ) ? sanitize_text_field( wp_unslash( $_POST['fuel_type'] ) ) : '',
			'transmission' => isset( $_POST['transmission'] ) ? sanitize_text_field( wp_unslash( $_POST['transmission'] ) ) : '',
			'color'        => isset( $_POST['color'] ) ? sanitize_text_field( wp_unslash( $_POST['color'] ) ) : '',
			'subject'      => isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : '',
			'description'  => isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '',
			'consent'      => ! empty( $_POST['consent'] ) ? 1 : 0,
			'source'       => isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : 'shortcode',
		);
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		return $fields;
	}

	/**
	 * Validate the required fields: first name, last name, phone, make, model,
	 * and consent. Email, when supplied, must be a valid address.
	 *
	 * @param array $fields Sanitized fields.
	 * @return bool
	 */
	private static function is_valid( array $fields ) {
		$required = array( 'first_name', 'last_name', 'phone', 'make', 'model' );

		foreach ( $required as $key ) {
			if ( '' === (string) $fields[ $key ] ) {
				return false;
			}
		}

		if ( empty( $fields['consent'] ) ) {
			return false;
		}

		if ( '' !== (string) $fields['email'] && ! is_email( $fields['email'] ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Resolve the submitting user agent for the audit snapshot.
	 *
	 * @return string
	 */
	private static function user_agent() {
		if ( ! isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return '';
		}

		return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
	}

	/**
	 * Resolve the safe redirect target back to the form page.
	 *
	 * A public submission must always land on a front-end confirmation, never in
	 * wp-admin. wp_validate_redirect() alone is not enough: it enforces same-host
	 * only, so a same-host /wp-admin URL passes through. We therefore reject any
	 * admin-URL candidate before validating. The candidate is chosen in priority
	 * order: the POSTed redirect_to (only if it is a non-admin front-end URL),
	 * then wp_get_referer() (same rule), then home_url('/'). The final return is
	 * run through wp_validate_redirect() with a front-end fallback, so the
	 * function can never return an admin URL; worst case it returns home_url('/').
	 *
	 * @return string
	 */
	private static function redirect_base() {
		$candidate = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in handle(); value is rejected if admin and run through wp_validate_redirect below.

		// Reject a posted target that points at wp-admin; it must not bounce a
		// public visitor into the editor or any admin screen.
		if ( '' === $candidate || self::is_admin_url( $candidate ) ) {
			$candidate = '';
		}

		// Next preference: the referer, again only if it is a front-end URL.
		if ( '' === $candidate ) {
			$referer = wp_get_referer();

			if ( $referer && ! self::is_admin_url( $referer ) ) {
				$candidate = $referer;
			}
		}

		// Last resort: the site home, guaranteed front-end.
		if ( '' === $candidate ) {
			$candidate = home_url( '/' );
		}

		// Same-host enforcement with a front-end fallback. Combined with the
		// admin-URL rejection above, this can never return an admin URL.
		return wp_validate_redirect( $candidate, home_url( '/' ) );
	}

	/**
	 * Determine whether a candidate URL points at wp-admin.
	 *
	 * Compares the candidate path against the admin path from admin_url(), using
	 * wp_parse_url() so query strings do not cause false negatives. An empty or
	 * non-same-host candidate is treated as not usable (returns true so callers
	 * discard it in favor of a front-end target).
	 *
	 * @param string $url Candidate URL.
	 * @return bool True when the URL is an admin URL or otherwise unusable.
	 */
	private static function is_admin_url( $url ) {
		$url = (string) $url;

		if ( '' === $url ) {
			return true;
		}

		$candidate_host = wp_parse_url( $url, PHP_URL_HOST );
		$candidate_path = (string) wp_parse_url( $url, PHP_URL_PATH );

		$admin_url  = admin_url();
		$admin_host = wp_parse_url( $admin_url, PHP_URL_HOST );
		$admin_path = (string) wp_parse_url( $admin_url, PHP_URL_PATH );

		// A relative candidate (no host) is judged on its path alone.
		if ( null !== $candidate_host && $candidate_host !== $admin_host ) {
			return true;
		}

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

		return 0 === strpos( $candidate_path, $admin_path );
	}

	/**
	 * Redirect back to the form with a success flag and exit (PRG).
	 *
	 * @return void
	 */
	private static function redirect_success() {
		wp_safe_redirect(
			add_query_arg( array( 'wrenchpress_visit' => 'success' ), self::redirect_base() )
		);
		exit;
	}

	/**
	 * Silent drop for honeypot / time-trap hits.
	 *
	 * Redirects with the SAME success flag as a real submission so an automated
	 * client cannot distinguish a drop from a genuine acceptance. Nothing is
	 * persisted.
	 *
	 * @return void
	 */
	private static function redirect_silent_drop() {
		self::redirect_success();
	}

	/**
	 * Redirect back to the form with an error flag, preserving submitted values
	 * in a short-lived transient, and exit (PRG).
	 *
	 * @param string $reason Error reason: 'rate_limited' or '' for generic.
	 * @param array  $fields Sanitized values to preserve (optional).
	 * @return void
	 */
	private static function redirect_error( $reason = '', array $fields = array() ) {
		$args = array( 'wrenchpress_visit' => 'error' );

		if ( 'rate_limited' === $reason ) {
			$args['wrenchpress_reason'] = 'rate_limited';
		}

		if ( ! empty( $fields ) ) {
			$token = substr( md5( uniqid( (string) wp_rand(), true ) ), 0, 16 );
			set_transient( 'wrenchpress_visit_' . $token, $fields, 5 * MINUTE_IN_SECONDS );
			$args['wrenchpress_token'] = $token;
		}

		wp_safe_redirect( add_query_arg( $args, self::redirect_base() ) );
		exit;
	}
}
