<?php
/**
 * Shared render core for the public visit-request form.
 *
 * This is the single source of the form markup. Every embedding surface
 * (shortcode, block, and later the native Elementor/DIVI wrappers) calls
 * render() and returns its string. The form is server-rendered so it always
 * exists for page builders and degrades gracefully without JavaScript.
 *
 * render() ALWAYS returns a string and NEVER echoes (the shortcode contract).
 * The markup carries a fresh nonce, an off-screen honeypot, a submit-time
 * timestamp field, the required consent checkbox, and an inert CAPTCHA slot
 * that plan 02-04 fills when CAPTCHA is enabled.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Visit_Form
 */
class WrenchPress_Visit_Form {

	/**
	 * Nonce action shared by the rendered form and the submission handler.
	 */
	const NONCE_ACTION = 'wrenchpress_submit_visit';

	/**
	 * The admin-post action slug the form posts to.
	 */
	const ACTION = 'wrenchpress_submit_visit';

	/**
	 * Registered style/script handle for the form assets.
	 */
	const ASSET_HANDLE = 'wrenchpress-visit-form';

	/**
	 * Register the form stylesheet and script and ensure both are enqueued, even
	 * when a builder renders after the normal wp_enqueue_scripts window (the DIVI
	 * 5 lazy-load case). Registration is idempotent; render() calls this so every
	 * embedding surface enqueues the assets, and WordPress prints late handles in
	 * the footer.
	 *
	 * @return void
	 */
	public static function enqueue_assets() {
		wp_register_script(
			self::ASSET_HANDLE,
			WRENCHPRESS_URL . 'assets/js/visit-form.js',
			array(),
			'0.3.0',
			true
		);

		wp_register_style(
			self::ASSET_HANDLE,
			WRENCHPRESS_URL . 'assets/css/visit-form.css',
			array(),
			'0.3.0'
		);

		/*
		 * Validation copy for the progressive-enhancement script. The script
		 * ships no English strings of its own: it has no build step, so it
		 * cannot call the JS i18n functions, and hardcoding copy there would
		 * put it beyond the reach of translators. When this object is missing
		 * the script degrades to its previous behaviour (focus the field, say
		 * nothing) rather than announcing an untranslated string.
		 */
		wp_localize_script(
			self::ASSET_HANDLE,
			'wrenchpressVisitForm',
			array(
				/* translators: %s: the visible label of the field that was left empty. */
				'required'     => __( '%s is required.', 'pitdesk-repair-crm' ),
				'invalidEmail' => __( 'Enter a valid email address.', 'pitdesk-repair-crm' ),
			)
		);

		if ( did_action( 'wp_enqueue_scripts' ) ) {
			// The builder rendered past the normal enqueue window; enqueue now and
			// WordPress prints the handles in the footer.
			wp_enqueue_script( self::ASSET_HANDLE );
			wp_enqueue_style( self::ASSET_HANDLE );
		} else {
			add_action(
				'wp_enqueue_scripts',
				static function () {
					wp_enqueue_script( self::ASSET_HANDLE );
					wp_enqueue_style( self::ASSET_HANDLE );
				}
			);
		}
	}

	/**
	 * Build and return the complete visit-request form markup.
	 *
	 * @param array $atts {
	 *     Optional. Render attributes.
	 *
	 *     @type string $source Originating surface recorded on the lead. Default
	 *                          'shortcode'.
	 * }
	 * @return string Form markup (or success/error notice plus form).
	 */
	public static function render( array $atts = array() ) {
		$atts   = wp_parse_args(
			$atts,
			array(
				'source' => 'shortcode',
			)
		);
		$source = sanitize_key( (string) $atts['source'] );

		$state  = self::current_state();
		$values = self::values_from_state( $state );

		// Ensure the structural stylesheet and progressive-enhancement script are
		// enqueued for every embedding surface (shortcode, block, Elementor, Divi),
		// including builders that render past the normal enqueue window.
		self::enqueue_assets();

		$html  = '';
		$html .= '<form class="egcrm-form" method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" aria-label="' . esc_attr__( 'Request a visit', 'pitdesk-repair-crm' ) . '">';

		// Hidden control fields: routing action, nonce, honeypot, timestamp, source.
		$html .= '<input type="hidden" name="action" value="' . esc_attr( self::ACTION ) . '" />';
		$html .= wp_nonce_field( self::NONCE_ACTION, '_wpnonce', true, false );
		$html .= self::honeypot_field();
		// The timestamp field name is the literal wrenchpress_ts (mirrors
		// WrenchPress_Anti_Abuse::TIMESTAMP_FIELD); the form is where it is minted.
		$html .= '<input type="hidden" name="wrenchpress_ts" value="' . esc_attr( (string) time() ) . '" data-egcrm-ts="1" />';
		$html .= '<input type="hidden" name="source" value="' . esc_attr( $source ) . '" />';
		$html .= '<input type="hidden" name="redirect_to" value="' . esc_attr( self::current_url() ) . '" />';

		// Header.
		$html .= '<div class="egcrm-form__header"><div>';
		$html .= '<h2 class="egcrm-form__title">' . esc_html__( 'Request a visit', 'pitdesk-repair-crm' ) . '</h2>';
		$html .= '<p class="egcrm-form__subtitle">' . esc_html__( 'Tell us about you and your vehicle and we will get back to you.', 'pitdesk-repair-crm' ) . '</p>';
		$html .= '</div></div>';

		// Status regions (success / error).
		$html .= self::status_region( $state );

		/*
		 * Owner section.
		 *
		 * The five owner fields carry autocomplete tokens (SC 1.3.5): they are
		 * information about the user, so a browser or assistive tool can fill
		 * them. The VEHICLE fields below deliberately carry none. They are not
		 * information about the user, there is no valid token for them, and an
		 * invented token is itself a conformance failure.
		 */
		$html .= '<section class="egcrm-form__section" aria-labelledby="egcrm_section_owner">';
		$html .= '<h3 class="egcrm-form__section-title" id="egcrm_section_owner">' . esc_html__( 'Owner', 'pitdesk-repair-crm' ) . '</h3>';
		$html .= '<div class="egcrm-form__grid">';
		$html .= self::text_field( 'first_name', __( 'First name', 'pitdesk-repair-crm' ), $values['first_name'], true, 'text', '', false, 'given-name' );
		$html .= self::text_field( 'last_name', __( 'Last name', 'pitdesk-repair-crm' ), $values['last_name'], true, 'text', '', false, 'family-name' );
		$html .= self::text_field( 'phone', __( 'Phone', 'pitdesk-repair-crm' ), $values['phone'], true, 'tel', '', false, 'tel' );
		$html .= self::text_field( 'email', __( 'Email', 'pitdesk-repair-crm' ), $values['email'], false, 'email', '', false, 'email' );
		$html .= self::text_field( 'address', __( 'Address', 'pitdesk-repair-crm' ), $values['address'], false, 'text', '', true, 'street-address' );
		$html .= '</div></section>';

		// Vehicle section.
		$html         .= '<section class="egcrm-form__section" aria-labelledby="egcrm_section_vehicle">';
		$html         .= '<h3 class="egcrm-form__section-title" id="egcrm_section_vehicle">' . esc_html__( 'Vehicle', 'pitdesk-repair-crm' ) . '</h3>';
		$html         .= '<div class="egcrm-form__grid egcrm-form__grid--three">';
		$html         .= self::text_field( 'make', __( 'Make', 'pitdesk-repair-crm' ), $values['make'], true );
		$html         .= self::text_field( 'model', __( 'Model', 'pitdesk-repair-crm' ), $values['model'], true );
		$html         .= self::text_field( 'year', __( 'Year', 'pitdesk-repair-crm' ), $values['year'], false, 'number' );
		$html         .= self::text_field( 'vin', __( 'VIN', 'pitdesk-repair-crm' ), $values['vin'], false, 'text', __( '17 characters', 'pitdesk-repair-crm' ) );
		$html         .= self::text_field( 'plate', __( 'License plate', 'pitdesk-repair-crm' ), $values['plate'], false );
		$mileage_unit  = WrenchPress_Settings_Page::mileage_unit();
		$mileage_label = sprintf(
			/* translators: %s: active mileage unit label (mi or km). */
			__( 'Mileage (%s)', 'pitdesk-repair-crm' ),
			$mileage_unit
		);
		$html .= self::text_field( 'mileage', $mileage_label, $values['mileage'], false, 'number' );
		$html .= self::select_field(
			'fuel_type',
			__( 'Fuel type', 'pitdesk-repair-crm' ),
			$values['fuel_type'],
			array( 'Gasoline', 'Diesel', 'Hybrid', 'Electric', 'LPG' )
		);
		$html .= self::select_field(
			'transmission',
			__( 'Transmission', 'pitdesk-repair-crm' ),
			$values['transmission'],
			array( 'Manual', 'Automatic', 'CVT', 'DSG' )
		);
		$html .= self::text_field( 'color', __( 'Color', 'pitdesk-repair-crm' ), $values['color'], false );
		$html .= '</div></section>';

		// Service request section.
		$html .= '<section class="egcrm-form__section" aria-labelledby="egcrm_section_service">';
		$html .= '<h3 class="egcrm-form__section-title" id="egcrm_section_service">' . esc_html__( 'Service request', 'pitdesk-repair-crm' ) . '</h3>';
		$html .= '<div class="egcrm-form__grid">';
		$html .= self::text_field( 'subject', __( 'Reason for visit', 'pitdesk-repair-crm' ), $values['subject'], false, 'text', '', true );
		$html .= self::textarea_field( 'description', __( 'Notes', 'pitdesk-repair-crm' ), $values['description'] );
		$html .= '</div></section>';

		// Consent.
		$html .= self::consent_field( ! empty( $values['consent'] ) );

		// CAPTCHA slot: inert/empty when CAPTCHA is disabled (the default), so the
		// form loads no provider script and makes no external call. When the owner
		// enables CAPTCHA the slot is filled with the provider widget markup and
		// the provider script is enqueued, both ONLY inside the enabled branch.
		$html .= self::captcha_slot();

		// Actions.
		$html .= '<div class="egcrm-form__actions">';
		$html .= '<button type="reset" class="egcrm-btn egcrm-btn--secondary">' . esc_html__( 'Clear form', 'pitdesk-repair-crm' ) . '</button>';
		$html .= '<button type="submit" class="egcrm-btn egcrm-btn--primary">' . esc_html__( 'Request visit', 'pitdesk-repair-crm' ) . '</button>';
		$html .= '</div>';

		$html .= '</form>';

		// Small "Powered by" attribution linking to the plugin directory page.
		$html .= '<p class="egcrm-form__powered">';
		$html .= '<small><a href="' . esc_url( 'https://wordpress.org/plugins/pitdesk-repair-crm/' ) . '" target="_blank" rel="noopener noreferrer">';
		$html .= esc_html__( 'Powered by PitDesk', 'pitdesk-repair-crm' );
		$html .= '</a></small>';
		$html .= '</p>';

		return $html;
	}

	/**
	 * Render the off-screen, non-focusable honeypot field. Visually hidden via
	 * an absolute off-screen position (not display:none) so well-behaved bots
	 * still see and fill it.
	 *
	 * @return string
	 */
	private static function honeypot_field() {
		// The field name is the literal wrenchpress_website (mirrors
		// WrenchPress_Anti_Abuse::HONEYPOT_FIELD); the form is where it is minted.
		$name = 'wrenchpress_website';

		$html  = '<div class="egcrm-form__hp" aria-hidden="true">';
		$html .= '<label for="' . esc_attr( $name ) . '">' . esc_html__( 'Leave this field empty', 'pitdesk-repair-crm' ) . '</label>';
		$html .= '<input type="text" id="' . esc_attr( $name ) . '" name="' . esc_attr( $name ) . '" value="" tabindex="-1" autocomplete="off" />';
		$html .= '</div>';

		return $html;
	}

	/**
	 * Build the CAPTCHA slot.
	 *
	 * When CAPTCHA is disabled (the default) this returns an inert empty wrapper
	 * and enqueues nothing, keeping the off path free of any external request or
	 * third-party dependency (D-09 / RESEARCH Pitfall 5). When enabled with a
	 * configured site key, it enqueues the provider widget script from the
	 * provider's official endpoint and emits the provider's widget container plus
	 * a hidden token field the submission handler reads.
	 *
	 * @return string
	 */
	private static function captcha_slot() {
		if ( ! WrenchPress_Anti_Abuse::captcha_enabled() ) {
			return '<div class="egcrm-form__captcha" data-egcrm-captcha="0"></div>';
		}

		$settings = get_option( 'wrenchpress_settings', array() );
		$settings = is_array( $settings ) ? $settings : array();
		$provider = isset( $settings['captcha_provider'] ) ? (string) $settings['captcha_provider'] : 'recaptcha_v3';
		$site_key = isset( $settings['captcha_site_key'] ) ? trim( (string) $settings['captcha_site_key'] ) : '';

		// Enabled but not yet configured: stay inert rather than enqueue a broken
		// widget. No external script loads.
		if ( '' === $site_key ) {
			return '<div class="egcrm-form__captcha" data-egcrm-captcha="0"></div>';
		}

		self::enqueue_captcha_script( $provider, $site_key );

		$html  = '<div class="egcrm-form__captcha" data-egcrm-captcha="1" data-egcrm-provider="' . esc_attr( $provider ) . '" data-egcrm-sitekey="' . esc_attr( $site_key ) . '">';
		$html .= '<input type="hidden" name="captcha_token" class="egcrm-form__captcha-token" value="" />';

		if ( 'turnstile' === $provider ) {
			// Turnstile renders its widget into this container and writes the token
			// into the named response field automatically.
			$html .= '<div class="cf-turnstile" data-sitekey="' . esc_attr( $site_key ) . '" data-response-field-name="captcha_token"></div>';
		}

		$html .= '</div>';

		return $html;
	}

	/**
	 * Enqueue the chosen CAPTCHA provider's widget script from its official
	 * endpoint. Called ONLY from the enabled branch of captcha_slot(); nothing
	 * here runs when CAPTCHA is off.
	 *
	 * @param string $provider Provider slug (recaptcha_v3 or turnstile).
	 * @param string $site_key Public site key.
	 * @return void
	 */
	private static function enqueue_captcha_script( $provider, $site_key ) {
		if ( 'turnstile' === $provider ) {
			$src    = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
			$handle = 'wrenchpress-turnstile';
		} else {
			$src    = 'https://www.google.com/recaptcha/api.js?render=' . rawurlencode( $site_key );
			$handle = 'wrenchpress-recaptcha';
		}

		wp_register_script( $handle, $src, array(), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- third-party provider endpoint; versioning is owned by the provider.

		if ( did_action( 'wp_enqueue_scripts' ) ) {
			wp_enqueue_script( $handle );
		} else {
			add_action(
				'wp_enqueue_scripts',
				static function () use ( $handle ) {
					wp_enqueue_script( $handle );
				}
			);
		}
	}

	/**
	 * Render the inline status region above the form body.
	 *
	 * The wrapper is ALWAYS emitted, even with nothing to say, and this is the
	 * point of the whole method. A live region announces on CHANGE, not on
	 * presence, so a region that is injected together with its message is never
	 * announced at all. Emitting the empty wrapper on every render means the
	 * region is registered before visit-form.js writes a client-side validation
	 * error into it, which is what makes that error actually reach a screen
	 * reader. Empty, it has no padding and no border and occupies no space.
	 *
	 * That fixes the CLIENT-side case. It cannot fix the SERVER-side one: after
	 * the Post/Redirect/Get the message is already in the parsed DOM at load, so
	 * it never "changes" and no amount of ARIA will announce it. The fix there
	 * is tabindex="-1" plus a focus() call on load from visit-form.js, which
	 * both announces the message and orients the visitor.
	 *
	 * Success and error are told apart by their bold text prefix, not by
	 * colour: the plugin paints no colour on this form.
	 *
	 * @param array $state Decoded submission state.
	 * @return string
	 */
	private static function status_region( array $state ) {
		$inner = '';
		$role  = 'success' === $state['status'] ? 'status' : 'alert';

		if ( 'success' === $state['status'] ) {
			$inner  = '<div class="egcrm-form__notice egcrm-form__notice--success">';
			$inner .= '<strong>' . esc_html__( 'Request received', 'pitdesk-repair-crm' ) . '</strong> ';
			$inner .= esc_html__( 'Thanks. We have your request and will contact you shortly. You can close this page.', 'pitdesk-repair-crm' );
			$inner .= '</div>';
		} elseif ( 'error' === $state['status'] ) {
			$message = 'rate_limited' === $state['reason']
				? __( 'You have sent several requests recently. Please wait a few minutes and try again.', 'pitdesk-repair-crm' )
				: __( 'We could not submit your request. Please check your details and try again.', 'pitdesk-repair-crm' );

			$inner  = '<div class="egcrm-form__notice egcrm-form__notice--error">';
			$inner .= '<strong>' . esc_html__( 'Request not sent', 'pitdesk-repair-crm' ) . '</strong> ';
			$inner .= esc_html( $message );
			$inner .= '</div>';
		}

		$html  = '<div class="egcrm-form__status" id="egcrm_form_status" role="' . esc_attr( $role ) . '" tabindex="-1">';
		$html .= $inner;
		$html .= '</div>';

		return $html;
	}

	/**
	 * Render the required consent checkbox with a privacy-policy link.
	 *
	 * @param bool $checked Whether to pre-check the box (preserved on re-render).
	 * @return string
	 */
	private static function consent_field( $checked ) {
		$policy_url = get_privacy_policy_url();
		$id         = 'egcrm_consent';

		$label_start = esc_html__( 'I agree to be contacted about this request and accept the', 'pitdesk-repair-crm' );
		$link_text   = esc_html__( 'privacy policy', 'pitdesk-repair-crm' );

		if ( $policy_url ) {
			$policy = '<a class="egcrm-consent__link" href="' . esc_url( $policy_url ) . '" target="_blank" rel="noopener">' . $link_text . '</a>';
		} else {
			$policy = $link_text;
		}

		$html  = '<div class="egcrm-form__consent">';
		$html .= '<div class="egcrm-consent">';
		$html .= '<input type="checkbox" id="' . esc_attr( $id ) . '" class="egcrm-consent__box" name="consent" value="1"';
		$html .= ' data-egcrm-label="' . esc_attr__( 'Consent', 'pitdesk-repair-crm' ) . '"';
		$html .= ' aria-describedby="' . esc_attr( $id . '_error' ) . '"';
		$html .= ' aria-required="true" required' . ( $checked ? ' checked' : '' ) . ' />';

		/*
		 * The privacy-policy link is a SIBLING of the <label>, never a child of
		 * it. Two reasons, and the second is a real functional bug rather than a
		 * theoretical one: the HTML content model forbids interactive content
		 * inside a <label>, and a link nested in a label ALSO toggles the
		 * checkbox when it is activated. The label is therefore explicit (for=)
		 * and wraps only its own text, which keeps the consent sentence flowing
		 * as one sentence while leaving the link independently activatable.
		 */
		$html .= '<span class="egcrm-consent__text">';
		$html .= '<label for="' . esc_attr( $id ) . '">' . $label_start . '</label> ';
		$html .= $policy . '.';
		$html .= '</span>';
		$html .= '</div>';
		$html .= self::error_slot( $id );
		$html .= '</div>';

		return $html;
	}

	/**
	 * Render one text/number/tel/email field column.
	 *
	 * @param string $name         Field name.
	 * @param string $label        Visible label.
	 * @param string $value        Current value.
	 * @param bool   $required     Whether the field is required.
	 * @param string $type         Input type attribute.
	 * @param string $helper       Optional helper text.
	 * @param bool   $full         Whether the field spans the full grid width.
	 * @param string $autocomplete Optional autocomplete token (SC 1.3.5).
	 * @return string
	 */
	private static function text_field( $name, $label, $value, $required, $type = 'text', $helper = '', $full = false, $autocomplete = '' ) {
		$id = 'egcrm_' . $name;

		$html  = '<div class="egcrm-field' . ( $full ? ' egcrm-field--full' : '' ) . '">';
		$html .= self::label( $id, $label, $required );
		$html .= '<input id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" type="' . esc_attr( $type ) . '" class="egcrm-field__input" value="' . esc_attr( $value ) . '"';
		$html .= ' data-egcrm-label="' . esc_attr( $label ) . '"';
		$html .= ' aria-describedby="' . esc_attr( self::described_by( $id, $helper ) ) . '"';

		if ( '' !== (string) $autocomplete ) {
			$html .= ' autocomplete="' . esc_attr( $autocomplete ) . '"';
		}

		if ( $required ) {
			$html .= ' required aria-required="true"';
		}

		$html .= ' />';
		$html .= self::helper( $id, $helper );
		$html .= self::error_slot( $id );
		$html .= '</div>';

		return $html;
	}

	/**
	 * Render a select field column.
	 *
	 * @param string   $name     Field name.
	 * @param string   $label    Visible label.
	 * @param string   $value    Current value.
	 * @param string[] $options  Option labels (value === label).
	 * @return string
	 */
	private static function select_field( $name, $label, $value, array $options ) {
		$id = 'egcrm_' . $name;

		$html  = '<div class="egcrm-field">';
		$html .= self::label( $id, $label, false );
		$html .= '<select id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" class="egcrm-field__select"';
		$html .= ' data-egcrm-label="' . esc_attr( $label ) . '"';
		$html .= ' aria-describedby="' . esc_attr( self::described_by( $id, '' ) ) . '">';
		$html .= '<option value="">' . esc_html__( 'Select', 'pitdesk-repair-crm' ) . '</option>';

		foreach ( $options as $option ) {
			$html .= '<option value="' . esc_attr( $option ) . '"' . selected( $value, $option, false ) . '>' . esc_html( $option ) . '</option>';
		}

		$html .= '</select>';
		$html .= self::error_slot( $id );
		$html .= '</div>';

		return $html;
	}

	/**
	 * Render the notes textarea field column (always full width).
	 *
	 * @param string $name  Field name.
	 * @param string $label Visible label.
	 * @param string $value Current value.
	 * @return string
	 */
	private static function textarea_field( $name, $label, $value ) {
		$id = 'egcrm_' . $name;

		$html  = '<div class="egcrm-field egcrm-field--full">';
		$html .= self::label( $id, $label, false );
		$html .= '<textarea id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" class="egcrm-field__textarea" rows="4"';
		$html .= ' data-egcrm-label="' . esc_attr( $label ) . '"';
		$html .= ' aria-describedby="' . esc_attr( self::described_by( $id, '' ) ) . '">' . esc_textarea( $value ) . '</textarea>';
		$html .= self::error_slot( $id );
		$html .= '</div>';

		return $html;
	}

	/**
	 * Build the aria-describedby value for a field.
	 *
	 * The aria-describedby attribute takes a space-separated list, so a field
	 * that carries helper text points at BOTH its helper and its error element.
	 * Dropping the helper id here would silently un-describe the only field
	 * that has one.
	 *
	 * Every id in the list is a plugin-authored literal derived from the known
	 * field name; no request data reaches this value.
	 *
	 * @param string $field_id Field id.
	 * @param string $helper   Helper text (empty when the field has none).
	 * @return string Space-separated id list.
	 */
	private static function described_by( $field_id, $helper ) {
		$ids = array();

		if ( '' !== (string) $helper ) {
			$ids[] = $field_id . '_helper';
		}

		$ids[] = $field_id . '_error';

		return implode( ' ', $ids );
	}

	/**
	 * Render the empty per-field error element.
	 *
	 * Server-rendered and empty by default; visit-form.js writes the message
	 * into it on a client-side validation failure. It is emitted even when
	 * empty so that aria-describedby always resolves to a real element, and it
	 * is collapsed by CSS while empty so it opens no gap under the field.
	 *
	 * @param string $field_id Field id.
	 * @return string
	 */
	private static function error_slot( $field_id ) {
		return '<span class="egcrm-field__error" id="' . esc_attr( $field_id . '_error' ) . '"></span>';
	}

	/**
	 * Render a field label with an optional required marker.
	 *
	 * @param string $field_id Target field id.
	 * @param string $label    Visible label text.
	 * @param bool   $required Whether to append the required marker.
	 * @return string
	 */
	private static function label( $field_id, $label, $required ) {
		$html = '<label class="egcrm-field__label" for="' . esc_attr( $field_id ) . '">' . esc_html( $label );

		if ( $required ) {
			$html .= '<span class="egcrm-field__required" aria-hidden="true">*</span>';
		}

		$html .= '</label>';

		return $html;
	}

	/**
	 * Render optional helper text under a field.
	 *
	 * Carries a stable id so the field's aria-describedby can point at it. The
	 * id is only ever referenced when the helper is non-empty (see
	 * described_by()), so it never dangles.
	 *
	 * @param string $field_id Field id.
	 * @param string $helper   Helper text (may be empty).
	 * @return string
	 */
	private static function helper( $field_id, $helper ) {
		if ( '' === (string) $helper ) {
			return '';
		}

		return '<span class="egcrm-field__helper" id="' . esc_attr( $field_id . '_helper' ) . '">' . esc_html( $helper ) . '</span>';
	}

	/**
	 * Decode the post-redirect submission state from the query string.
	 *
	 * Values come from our own PRG redirect flags, so reads are nonce-exempt.
	 *
	 * @return array{status:string,reason:string,values:array<string,string>}
	 */
	private static function current_state() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- status flags come from our own PRG redirect.
		$status = isset( $_GET['wrenchpress_visit'] ) ? sanitize_key( wp_unslash( $_GET['wrenchpress_visit'] ) ) : '';
		$reason = isset( $_GET['wrenchpress_reason'] ) ? sanitize_key( wp_unslash( $_GET['wrenchpress_reason'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$values = array();

		if ( 'error' === $status ) {
			$values = self::preserved_values();
		}

		return array(
			'status' => in_array( $status, array( 'success', 'error' ), true ) ? $status : '',
			'reason' => $reason,
			'values' => $values,
		);
	}

	/**
	 * Read submitted values preserved for a failed submission from a short-lived
	 * transient keyed by a token in the redirect URL.
	 *
	 * @return array<string,string>
	 */
	private static function preserved_values() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- token comes from our own PRG redirect; values are re-sanitized on render via esc_*.
		$token = isset( $_GET['wrenchpress_token'] ) ? sanitize_key( wp_unslash( $_GET['wrenchpress_token'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

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

		$stored = get_transient( 'wrenchpress_visit_' . $token );

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

	/**
	 * Merge preserved values over empty-field defaults.
	 *
	 * @param array $state Decoded submission state.
	 * @return array<string,string>
	 */
	private static function values_from_state( array $state ) {
		return wp_parse_args(
			$state['values'],
			array(
				'first_name'   => '',
				'last_name'    => '',
				'phone'        => '',
				'email'        => '',
				'address'      => '',
				'make'         => '',
				'model'        => '',
				'year'         => '',
				'vin'          => '',
				'plate'        => '',
				'mileage'      => '',
				'fuel_type'    => '',
				'transmission' => '',
				'color'        => '',
				'subject'      => '',
				'description'  => '',
				'consent'      => '',
			)
		);
	}

	/**
	 * Current page URL, used as the no-JS PRG return target.
	 *
	 * This value is baked into the hidden redirect_to field at render time. It
	 * must never be an admin URL: when the dynamic block render_callback runs
	 * inside the REST block-renderer (block-editor preview), is_admin() is true
	 * and REQUEST_URI/referer point at the editor, which would bounce a public
	 * visitor into wp-admin. In any admin or REST-admin context we therefore fall
	 * back to home_url('/') rather than emit the editor URL. A genuine front-end
	 * render keeps the real front-end page URL. The submit-time guard in
	 * WrenchPress_Visit_Submission::redirect_base() rejects admin targets again,
	 * so this is defense in depth.
	 *
	 * @return string
	 */
	private static function current_url() {
		// In any admin / REST-admin context (including the block-renderer) do not
		// bake REQUEST_URI or the editor referer; return a front-end URL.
		if ( is_admin() ) {
			return home_url( '/' );
		}

		$ref = wp_get_referer();

		if ( $ref && ! self::is_admin_url( $ref ) ) {
			return $ref;
		}

		$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
		$uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

		if ( '' === $host || '' === $uri ) {
			return home_url( '/' );
		}

		$built = ( is_ssl() ? 'https://' : 'http://' ) . $host . $uri;

		if ( self::is_admin_url( $built ) ) {
			return home_url( '/' );
		}

		return $built;
	}

	/**
	 * Determine whether a candidate URL points at wp-admin.
	 *
	 * Mirrors the submit-time guard so the baked redirect_to is never an admin
	 * URL. Compares the candidate path against admin_url() with wp_parse_url() so
	 * query strings do not cause false negatives.
	 *
	 * @param string $url Candidate URL.
	 * @return bool True when the URL points at wp-admin.
	 */
	private static function is_admin_url( $url ) {
		$url = (string) $url;

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

		$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 );

		if ( null !== $candidate_host && $candidate_host !== $admin_host ) {
			return false;
		}

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

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