<?php
/**
 * Built-in form frontend submission handler.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

use SUBIN\AutoReplies\AutoReplySender;
use SUBIN\AutoReplies\AutoReplyDeliverySnapshot;
use SUBIN\Leads\LeadCaptureService;
use SUBIN\Leads\LeadFieldRepository;
use SUBIN\Leads\LeadRepository;
use SUBIN\Mail\EmailErrorClassifier;
use SUBIN\Mail\LeadEmailStatusService;
use SUBIN\MailLog\MailEventRepository;
use SUBIN\MailLog\MailErrorSanitizer;
use SUBIN\Notifications\NotificationEnvelope;
use SUBIN\Notifications\NotificationJobRepository;

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

if ( ! class_exists( MailErrorSanitizer::class ) ) {
	require_once dirname( __DIR__ ) . '/MailLog/MailErrorSanitizer.php';
}

/**
 * Validates, saves, and sends notifications for built-in forms.
 */
class FormSubmissionHandler {
	const RESULT_QUERY_ARG = 'subin_form_result';
	const TRANSIENT_PREFIX = 'subin_form_result_';
	const NOTIFICATION_HOOK = 'subin_send_lead_notification_async';
	const NOTIFICATION_PAYLOAD_HOOK = 'subin_send_form_notification_payload_async';
	const NOTIFICATION_LOCK_PREFIX = 'subin_sending_notification_';
	const NOTIFICATION_PAYLOAD_PREFIX = 'subin_notification_payload_';
	const NOTIFICATION_TOKEN_PREFIX = 'subin_async_notification_token_';
	const NOTIFICATION_AJAX_ACTION = 'subin_send_lead_notification_now';
	const NOTIFICATION_WORKER_HOOK = 'subin_process_notification_jobs';
	const NOTIFICATION_DUE_HOOK = 'subin_process_due_notification_jobs';
	const NOTIFICATION_CRON_INTERVAL = 'subin_every_minute';
	const AJAX_SUBMIT_TOKEN_ACTION = 'subin_ajax_submit';
	const FAST_MAIL_TIMEOUT = 3;
	const EMAIL_TYPE_ADMIN_NOTIFICATION = 'admin_notification';
	const EMAIL_TYPE_VISITOR_AUTO_REPLY = 'visitor_auto_reply';
	const MAX_TEXT_LENGTH = 255;
	const MAX_EMAIL_LENGTH = 254;
	const MAX_PHONE_LENGTH = 100;
	const MAX_TEXTAREA_LENGTH = 10000;
	const MAX_TOTAL_VALUE_LENGTH = 100000;

	/**
	 * Settings repository.
	 *
	 * @var \SUBIN_SettingsRepository
	 */
	private $settings;

	/**
	 * Form repository.
	 *
	 * @var FormRepository
	 */
	private $forms;

	/**
	 * Form field repository.
	 *
	 * @var FormFieldRepository
	 */
	private $form_fields;

	/**
	 * Lead repository.
	 *
	 * @var LeadRepository
	 */
	private $leads;

	/**
	 * Lead field repository.
	 *
	 * @var LeadFieldRepository
	 */
	private $lead_fields;

	/**
	 * Transactional lead capture.
	 *
	 * @var LeadCaptureService
	 */
	private $lead_capture;

	/**
	 * Submission context.
	 *
	 * @var FormSubmissionContext
	 */
	private $context;

	/**
	 * Auto-reply sender.
	 *
	 * @var AutoReplySender|null
	 */
	private $auto_replies;

	/**
	 * Mail event repository.
	 *
	 * @var MailEventRepository|null
	 */
	private $mail_events;

	/**
	 * Email error classifier.
	 *
	 * @var EmailErrorClassifier
	 */
	private $error_classifier;

	/**
	 * reCAPTCHA verifier.
	 *
	 * @var RecaptchaVerifier|null
	 */
	private $recaptcha;

	/**
	 * Durable notification jobs.
	 *
	 * @var NotificationJobRepository
	 */
	private $notification_jobs;

	/**
	 * Public form rate limiter and replay guard.
	 *
	 * @var SubmissionRateLimiter
	 */
	private $submission_limiter;

	/**
	 * Atomic public submission acceptance.
	 *
	 * @var SubmissionAcceptanceService
	 */
	private $submission_acceptance;

	/**
	 * Constructor.
	 *
	 * @param \SUBIN_SettingsRepository $settings Settings.
	 * @param FormRepository          $forms Forms.
	 * @param FormFieldRepository     $form_fields Form fields.
	 * @param LeadRepository          $leads Leads.
	 * @param LeadFieldRepository     $lead_fields Lead fields.
	 * @param FormSubmissionContext   $context Context.
	 * @param AutoReplySender|null    $auto_replies Auto-reply sender.
	 * @param MailEventRepository|null $mail_events Mail events.
	 * @param RecaptchaVerifier|null   $recaptcha reCAPTCHA verifier.
	 * @param LeadCaptureService|null  $lead_capture Transactional lead capture.
	 * @param NotificationJobRepository|null $notification_jobs Durable notification jobs.
	 * @param SubmissionRateLimiter|null $submission_limiter Rate limiter and replay guard.
	 * @param SubmissionAcceptanceService|null $submission_acceptance Atomic submission acceptance.
	 */
	public function __construct(
		\SUBIN_SettingsRepository $settings,
		FormRepository $forms,
		FormFieldRepository $form_fields,
		LeadRepository $leads,
		LeadFieldRepository $lead_fields,
		FormSubmissionContext $context,
		?AutoReplySender $auto_replies = null,
		?MailEventRepository $mail_events = null,
		?RecaptchaVerifier $recaptcha = null,
		?LeadCaptureService $lead_capture = null,
		?NotificationJobRepository $notification_jobs = null,
		?SubmissionRateLimiter $submission_limiter = null,
		?SubmissionAcceptanceService $submission_acceptance = null
	) {
		$this->settings     = $settings;
		$this->forms        = $forms;
		$this->form_fields  = $form_fields;
		$this->leads        = $leads;
		$this->lead_fields  = $lead_fields;
		$this->context      = $context;
		$this->auto_replies = $auto_replies;
		$this->mail_events  = $mail_events;
		$this->recaptcha    = $recaptcha;
		$this->lead_capture = $lead_capture ? $lead_capture : new LeadCaptureService( $leads, $lead_fields );
		$this->notification_jobs = $notification_jobs ? $notification_jobs : new NotificationJobRepository();
		$this->submission_limiter = $submission_limiter ? $submission_limiter : new SubmissionRateLimiter();
		$this->submission_acceptance = $submission_acceptance
			? $submission_acceptance
			: new SubmissionAcceptanceService(
				new SubmissionReceiptRepository(),
				$leads,
				$lead_fields,
				$this->notification_jobs
			);
		$this->error_classifier = new EmailErrorClassifier();
	}

	/**
	 * Register hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'template_redirect', array( $this, 'maybe_handle_submission' ) );
		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
		add_filter( 'cron_schedules', array( __CLASS__, 'notification_cron_schedules' ) );
		add_action( self::NOTIFICATION_WORKER_HOOK, array( $this, 'process_notification_jobs' ) );
		add_action( self::NOTIFICATION_DUE_HOOK, array( $this, 'process_due_notification_jobs' ), 10, 1 );
		add_action( self::NOTIFICATION_HOOK, array( $this, 'send_scheduled_notification' ), 10, 2 );
		add_action( self::NOTIFICATION_PAYLOAD_HOOK, array( $this, 'send_scheduled_payload_notification' ), 10, 1 );
		add_action( 'subin_send_lead_notification', array( $this, 'send_scheduled_notification' ), 10, 2 );
		add_action( 'wp_ajax_' . self::NOTIFICATION_AJAX_ACTION, array( $this, 'handle_async_notification_request' ) );
		add_action( 'wp_ajax_nopriv_' . self::NOTIFICATION_AJAX_ACTION, array( $this, 'handle_async_notification_request' ) );
		add_action( 'init', array( __CLASS__, 'schedule_notification_worker' ), 5 );
	}

	/**
	 * Add the notification worker interval.
	 *
	 * @param array<string,array<string,mixed>> $schedules Cron schedules.
	 * @return array<string,array<string,mixed>>
	 */
	public static function notification_cron_schedules( array $schedules ) {
		if ( ! isset( $schedules[ self::NOTIFICATION_CRON_INTERVAL ] ) ) {
			$schedules[ self::NOTIFICATION_CRON_INTERVAL ] = array(
				'interval' => MINUTE_IN_SECONDS,
				'display'  => __( 'Every minute', 'submission-inbox' ),
			);
		}

		return $schedules;
	}

	/**
	 * Ensure the durable notification worker is scheduled.
	 *
	 * @return bool Whether the recurring worker is scheduled.
	 */
	public static function schedule_notification_worker() {
		add_filter( 'cron_schedules', array( __CLASS__, 'notification_cron_schedules' ) );
		if ( wp_next_scheduled( self::NOTIFICATION_WORKER_HOOK ) ) {
			return true;
		}

		$scheduled = wp_schedule_event( time() + MINUTE_IN_SECONDS, self::NOTIFICATION_CRON_INTERVAL, self::NOTIFICATION_WORKER_HOOK, array(), true );

		return ! is_wp_error( $scheduled ) && false !== $scheduled;
	}

	/**
	 * Schedule a one-off wake-up near a delayed job's due time.
	 *
	 * The recurring worker remains the recovery path if loopback cron is delayed.
	 *
	 * @param string|int $available_at Local MySQL datetime or Unix timestamp.
	 * @return bool
	 */
	public static function schedule_notification_at( $available_at ) {
		if ( is_numeric( $available_at ) ) {
			$timestamp = absint( $available_at );
		} else {
			$date = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) $available_at, wp_timezone() );
			$timestamp = $date instanceof \DateTimeImmutable ? $date->getTimestamp() : 0;
		}
		if ( $timestamp <= time() + MINUTE_IN_SECONDS ) {
			return true;
		}

		$args = array( $timestamp );
		if ( wp_next_scheduled( self::NOTIFICATION_DUE_HOOK, $args ) ) {
			return true;
		}

		$scheduled = wp_schedule_single_event( $timestamp, self::NOTIFICATION_DUE_HOOK, $args, true );

		return ! is_wp_error( $scheduled ) && false !== $scheduled;
	}

	/**
	 * Process jobs after a one-off delayed-delivery wake-up.
	 *
	 * @param int $scheduled_for Scheduled Unix timestamp.
	 * @return int
	 */
	public function process_due_notification_jobs( $scheduled_for = 0 ) {
		unset( $scheduled_for );

		return $this->process_notification_jobs();
	}

	/**
	 * Register frontend REST endpoints.
	 *
	 * @return void
	 */
	public function register_rest_routes() {
		register_rest_route(
			'submission-inbox/v1',
			'/forms/(?P<form_id>\d+)/submit',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'handle_rest_submission' ),
				'permission_callback' => '__return_true',
				'args'                => array(
					'form_id' => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
					),
				),
			)
		);
	}

	/**
	 * Handle AJAX/REST form submission without a page reload.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_rest_submission( \WP_REST_Request $request ) {
		$form_id       = absint( $request['form_id'] ?? 0 );
		$previous_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST adapter snapshots form data before validating the form-specific submit token in process_submission().
		$body_params   = $request->get_body_params();

		if ( ! is_array( $body_params ) ) {
			$body_params = array();
		}
		if ( empty( $body_params ) ) {
			$body_params = $request->get_params();
		}

		$_POST = array_merge( $previous_post, $body_params );
		$_POST['subin_form_id']        = $form_id;
		$_POST['subin_form_submitted'] = '1';
		$_POST['subin_ajax_submission'] = '1';

		// File uploads are intentionally unsupported in v1; ignore any uploaded data.
		$previous_files = $_FILES; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST adapter snapshots uploads before discarding them; file uploads are unsupported.
		$_FILES         = array();

		try {
			$result = $this->process_submission( $form_id );
		} finally {
			$_POST  = $previous_post;
			$_FILES = $previous_files;
		}

		return rest_ensure_response(
			array(
				'success' => 'success' === sanitize_key( $result['status'] ?? '' ),
				'result'  => $result,
			)
		);
	}

	/**
	 * Handle POST submission if present.
	 *
	 * @return void
	 */
	public function maybe_handle_submission() {
		$request_method = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) );
		if ( 'POST' !== strtoupper( $request_method ) ) {
			return;
		}

		if ( empty( $_POST['subin_form_submitted'] ) || empty( $_POST['subin_form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission is validated with a form-specific nonce during processing.
			return;
		}

		// File uploads are intentionally unsupported in v1; ignore any uploaded data.
		if ( ! empty( $_FILES ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission is validated before accepted data is persisted.
			$_FILES = array();
		}

		$form_id = absint( wp_unslash( $_POST['subin_form_id'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission is validated with a form-specific nonce during processing.
		$result  = $this->process_submission( $form_id );
		$token   = wp_generate_password( 20, false, false );

		set_transient( self::TRANSIENT_PREFIX . $token, $result, 10 * MINUTE_IN_SECONDS );
		wp_safe_redirect( add_query_arg( self::RESULT_QUERY_ARG, rawurlencode( $token ), $this->current_url() ) );
		exit;
	}

	/**
	 * Consume a transient result for a form.
	 *
	 * @param int $form_id Form id.
	 * @return array<string,mixed>
	 */
	public function consume_result_for_form( $form_id ) {
		$token = isset( $_GET[ self::RESULT_QUERY_ARG ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::RESULT_QUERY_ARG ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( '' === $token ) {
			return array();
		}

		$key    = self::TRANSIENT_PREFIX . $token;
		$result = get_transient( $key );

		if ( ! is_array( $result ) || absint( $result['form_id'] ?? 0 ) !== absint( $form_id ) ) {
			return array();
		}

		delete_transient( $key );

		return $result;
	}

	/**
	 * Process a submission.
	 *
	 * @param int $form_id Form id.
	 * @return array<string,mixed>
	 */
	private function process_submission( $form_id ) {
		$form_id = absint( $form_id );
		$form    = $this->forms->get( $form_id );
		$posted  = $this->get_posted_fields();

		if ( ! $form || FormRepository::STATUS_ACTIVE !== sanitize_key( $form['status'] ?? '' ) ) {
			return $this->error_result( $form_id, __( 'This form is not available.', 'submission-inbox' ), array(), $posted );
		}

		$settings = $this->forms->decode_settings( $form );
		$messages = is_array( $settings['messages'] ?? null ) ? $settings['messages'] : array();
		$messages['_field_required_messages'] = $this->field_required_messages_from_settings( $settings );

		if ( ! $this->valid_submission_token( $form_id ) ) {
			return $this->error_result( $form_id, $this->message( $messages, 'spam_protection_error', __( 'Your submission could not be accepted. Please refresh the page and try again.', 'submission-inbox' ) ), array(), $posted, 'invalid_nonce' );
		}

		if ( $this->is_honeypot_filled() ) {
			return $this->error_result( $form_id, $this->message( $messages, 'spam_protection_error', __( 'Your submission could not be accepted.', 'submission-inbox' ) ), array(), $posted, 'honeypot' );
		}

		if ( $this->submitted_too_quickly() ) {
			return $this->error_result( $form_id, $this->message( $messages, 'spam_protection_error', __( 'Your submission could not be accepted. Please try again.', 'submission-inbox' ) ), array(), $posted, 'submitted_too_quickly' );
		}

		$fields = $this->form_fields->get_by_form_id( $form_id );
		if ( empty( $fields ) ) {
			return $this->error_result( $form_id, __( 'This form is not configured yet.', 'submission-inbox' ), array(), $posted );
		}

		$hidden_defaults = $this->hidden_defaults_from_settings( $settings );
		$logic_visibility = $this->conditional_logic_field_visibility(
			$settings,
			$fields,
			$posted,
			$hidden_defaults
		);
		$validated = $this->validate_fields(
			$fields,
			$posted,
			$messages,
			$hidden_defaults,
			$logic_visibility
		);
		$submission_id = $this->submission_limiter->sanitize_submission_id( $_POST['subin_submission_id'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The form-specific token has already been verified.
		$receipt_payload = array( 'values' => $validated['values'] );

		if ( empty( $validated['errors'] ) ) {
			$replay_result = $this->completed_submission_result( $form_id, $submission_id, $receipt_payload );
			if ( is_wp_error( $replay_result ) ) {
				$is_mismatch = 'subin_submission_payload_mismatch' === $replay_result->get_error_code();

				return $this->error_result(
					$form_id,
					$is_mismatch
						? $replay_result->get_error_message()
						: __( 'The saved submission result could not be verified. Please try again.', 'submission-inbox' ),
					array(),
					$validated['values'],
					$is_mismatch ? 'submission_id_conflict' : 'receipt_unavailable'
				);
			}

			if ( is_array( $replay_result ) ) {
				return $replay_result;
			}
		}

		$rate_limit = $this->submission_limiter->consume( $form_id );
		if ( is_wp_error( $rate_limit ) ) {
			return $this->error_result(
				$form_id,
				$rate_limit->get_error_message(),
				array(),
				$validated['values'],
				'rate_limited'
			);
		}

		if ( ! $this->passes_recaptcha() ) {
			return $this->error_result( $form_id, __( 'Could not verify the submission. Please try again.', 'submission-inbox' ), array(), $validated['values'], 'recaptcha_failed' );
		}

		if ( ! empty( $validated['errors'] ) ) {
			return $this->error_result(
				$form_id,
				$this->message( $messages, 'validation_error', __( 'Please check the highlighted fields and try again.', 'submission-inbox' ) ),
				$validated['errors'],
				$validated['values']
			);
		}

		$settings_all          = $this->settings->all();
		$lead_fields           = $this->build_lead_fields( $fields, $validated['values'] );
		$detected              = $this->detect_lead_values( $lead_fields );
		$should_store          = ! empty( $settings_all['store_leads'] ) && ! empty( $form['save_leads'] );
		$notification_enabled  = $this->can_generate_notification( $form );
		$submitted_at          = current_time( 'mysql' );
		$source_url            = $this->current_url();
		$success_result        = array(
			'form_id' => $form_id,
			'status'  => 'success',
			'message' => sanitize_textarea_field( $form['success_message'] ?: __( 'Thanks, your message has been sent.', 'submission-inbox' ) ),
			'values'  => array(),
			'errors'  => array(),
		);
		$notifications         = array();
		$auto_reply_scheduled  = false;

		if ( $notification_enabled ) {
			$admin_envelope = $this->prepare_admin_notification_envelope(
				$form,
				$lead_fields,
				$detected,
				$source_url,
				$submitted_at,
				$should_store
			);
			if ( is_wp_error( $admin_envelope ) ) {
				return $this->error_result(
					$form_id,
					__( 'The form notification could not be prepared. Please contact the site administrator.', 'submission-inbox' ),
					array(),
					$validated['values'],
					'notification_configuration_invalid'
				);
			}

			$notifications[] = array(
				'email_type' => self::EMAIL_TYPE_ADMIN_NOTIFICATION,
				'payload'    => array( 'envelope' => $admin_envelope ),
			);
		}
		$auto_reply_envelope = $this->auto_replies
			? $this->auto_replies->prepare_notification_envelope( $form, $lead_fields, $detected, $submitted_at )
			: null;
		if ( is_wp_error( $auto_reply_envelope ) ) {
			return $this->error_result(
				$form_id,
				__( 'The visitor auto-reply could not be prepared. Please contact the site administrator.', 'submission-inbox' ),
				array(),
				$validated['values'],
				'auto_reply_configuration_invalid'
			);
		}
		if ( is_array( $auto_reply_envelope ) ) {
			$auto_reply_scheduled = true;
			$notifications[] = array(
				'email_type'   => self::EMAIL_TYPE_VISITOR_AUTO_REPLY,
				'payload'      => array( 'envelope' => $auto_reply_envelope ),
				'available_at' => sanitize_text_field( $auto_reply_envelope['meta']['scheduled_at'] ?? '' ),
			);
		}

		if ( ! $should_store && empty( $notifications ) ) {
			return $this->error_result(
				$form_id,
				__( 'This form has no enabled delivery destination. Please contact the site administrator.', 'submission-inbox' ),
				array(),
				$validated['values'],
				'no_delivery_destination'
			);
		}

		$claim = $this->submission_limiter->claim_submission( $form_id, $submission_id );
		if ( is_wp_error( $claim ) ) {
			$replay_result = $this->completed_submission_result( $form_id, $submission_id, $receipt_payload );
			if ( is_array( $replay_result ) ) {
				return $replay_result;
			}
			if ( is_wp_error( $replay_result ) ) {
				$is_mismatch = 'subin_submission_payload_mismatch' === $replay_result->get_error_code();

				return $this->error_result(
					$form_id,
					$is_mismatch
						? $replay_result->get_error_message()
						: __( 'The saved submission result could not be verified. Please try again.', 'submission-inbox' ),
					array(),
					$validated['values'],
					$is_mismatch ? 'submission_id_conflict' : 'receipt_unavailable'
				);
			}

			return $this->error_result(
				$form_id,
				$claim->get_error_message(),
				array(),
				$validated['values'],
				'subin_duplicate_submission' === $claim->get_error_code()
					? 'duplicate_submission'
					: 'submission_in_progress'
			);
		}

		$accepted = $this->submission_acceptance->accept(
			array(
				'provider'          => 'submission_inbox_form',
				'form_id'           => (string) $form_id,
				'submission_id'     => $submission_id,
				'payload'           => $receipt_payload,
				'store_lead'        => $should_store,
				'lead_data'         => array(
					'provider'        => 'submission_inbox_form',
					'form_id'         => (string) $form_id,
					'form_name'       => $form['title'] ?? '',
					'source_url'      => $source_url,
					'referrer'        => $this->get_referrer(),
					'customer_name'   => $detected['customer_name'],
					'customer_email'  => $detected['customer_email'],
					'customer_phone'  => $detected['customer_phone'],
					'message_preview' => $detected['message_preview'],
					'submitted_at'    => $submitted_at,
					'expires_at'      => $this->get_expires_at( $settings_all ),
				),
				'lead_fields'       => $lead_fields,
				'lead_email_status' => $notification_enabled
					? LeadRepository::EMAIL_STATUS_UNKNOWN
					: LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED,
				'lead_auto_reply_status' => $auto_reply_scheduled
					? LeadRepository::AUTO_REPLY_STATUS_SCHEDULED
					: LeadRepository::AUTO_REPLY_STATUS_NOT_CONFIGURED,
				'notifications'     => $notifications,
				'success_result'    => $success_result,
			)
		);
		if ( is_wp_error( $accepted ) ) {
			$this->submission_limiter->release_submission( $form_id, $submission_id );
			$is_mismatch = 'subin_submission_payload_mismatch' === $accepted->get_error_code();

			return $this->error_result(
				$form_id,
				$is_mismatch
					? $accepted->get_error_message()
					: __( 'Your submission could not be saved completely. Please try again.', 'submission-inbox' ),
				array(),
				$validated['values'],
				$is_mismatch ? 'submission_id_conflict' : 'storage_failed'
			);
		}

		$this->submission_limiter->complete_submission( $form_id, $submission_id );
		if ( absint( $accepted['notification_job_count'] ?? 0 ) > 0 ) {
			self::schedule_notification_worker();
			foreach ( $notifications as $notification ) {
				if ( ! empty( $notification['available_at'] ) ) {
					self::schedule_notification_at( $notification['available_at'] );
				}
			}
		}
		$this->record_successful_submission_event( $form_id, $submission_id, absint( $accepted['receipt_id'] ?? 0 ) );

		return $accepted['result'];
	}

	/**
	 * Return a completed receipt response without consuming spam-gate capacity.
	 *
	 * @param int                 $form_id Form id.
	 * @param string              $submission_id Client submission id.
	 * @param array<string,mixed> $receipt_payload Effective validated payload.
	 * @return array<string,mixed>|null|\WP_Error
	 */
	private function completed_submission_result( $form_id, $submission_id, array $receipt_payload ) {
		$replay = $this->submission_acceptance->find_completed(
			'submission_inbox_form',
			(string) absint( $form_id ),
			$submission_id,
			$receipt_payload
		);
		if ( is_wp_error( $replay ) || ! is_array( $replay ) ) {
			return $replay;
		}

		if ( absint( $replay['notification_job_count'] ?? 0 ) > 0 ) {
			self::schedule_notification_worker();
		}
		$this->record_successful_submission_event( $form_id, $submission_id, absint( $replay['receipt_id'] ?? 0 ) );

		return $replay['response'];
	}

	/**
	 * Emit an authoritative, idempotent submit signal after durable acceptance.
	 *
	 * Analytics is deliberately outside the acceptance transaction and may never
	 * turn a successful submission into a public error.
	 *
	 * @param int    $form_id Form id.
	 * @param string $submission_id Client submission id.
	 * @param int    $receipt_id Durable receipt id.
	 * @return void
	 */
	private function record_successful_submission_event( $form_id, $submission_id, $receipt_id ) {
		try {
			do_action( FormAnalyticsService::SUBMIT_HOOK, absint( $form_id ), (string) $submission_id, absint( $receipt_id ) );
		} catch ( \Throwable $ignored ) {
			unset( $ignored );
		}
	}

	/**
	 * Migrate a legacy scheduled lead notification into the durable queue.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $email_type Email type.
	 * @return void
	 */
	public function send_scheduled_notification( $lead_id, $email_type = self::EMAIL_TYPE_ADMIN_NOTIFICATION ) {
		$lead_id = absint( $lead_id );
		if ( $lead_id <= 0 ) {
			return;
		}

		$this->schedule_notification( $lead_id, array(), $email_type );
		$this->process_notification_jobs();
	}

	/**
	 * Migrate a legacy transient payload into the durable queue.
	 *
	 * @param string $payload_token Legacy transient token.
	 * @return void
	 */
	public function send_scheduled_payload_notification( $payload_token ) {
		$payload_token = sanitize_key( (string) $payload_token );
		if ( '' === $payload_token ) {
			return;
		}

		$key     = self::NOTIFICATION_PAYLOAD_PREFIX . $payload_token;
		$payload = get_transient( $key );
		if ( ! is_array( $payload ) ) {
			return;
		}

		$result = $this->notification_jobs->enqueue(
			'legacy_payload_' . $payload_token,
			0,
			self::EMAIL_TYPE_ADMIN_NOTIFICATION,
			$this->sanitize_notification_payload( $payload )
		);

		if ( ! is_wp_error( $result ) ) {
			delete_transient( $key );
			$this->process_notification_jobs();
		}
	}

	/**
	 * Handle a legacy non-blocking callback safely.
	 *
	 * @return void
	 */
	public function handle_async_notification_request() {
		$lead_id    = absint( wp_unslash( $_POST['lead_id'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authorized by one-time token below.
		$token      = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authorized by this one-time token.
		$email_type = $this->sanitize_email_type( sanitize_text_field( wp_unslash( $_POST['email_type'] ?? self::EMAIL_TYPE_ADMIN_NOTIFICATION ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing

		if ( $lead_id <= 0 || '' === $token ) {
			wp_die( '0', '', array( 'response' => 400 ) );
		}

		$token_key    = self::NOTIFICATION_TOKEN_PREFIX . $this->notification_key_suffix( $lead_id, $email_type );
		$stored_token = (string) get_transient( $token_key );
		if ( '' === $stored_token || ! hash_equals( $stored_token, $token ) ) {
			wp_die( '0', '', array( 'response' => 403 ) );
		}

		delete_transient( $token_key );
		wp_clear_scheduled_hook( self::NOTIFICATION_HOOK, array( $lead_id, $email_type ) );
		$this->schedule_notification( $lead_id, array(), $email_type );
		$this->process_notification_jobs();
		wp_die( '1', '', array( 'response' => 200 ) );
	}

	/**
	 * Process a leased batch of notification jobs.
	 *
	 * @param int $limit Maximum jobs.
	 * @return int Number of completed jobs.
	 */
	public function process_notification_jobs( $limit = 10 ) {
		$completed = 0;
		$limit     = max( 1, min( 50, absint( $limit ) ) );

		for ( $processed = 0; $processed < $limit; ++$processed ) {
			// Lease immediately before delivery so later jobs cannot expire while an earlier email is still sending.
			$claim = $this->notification_jobs->claim_batch( 1 );
			if ( is_wp_error( $claim ) ) {
				do_action( 'subin_notification_worker_failed', $claim );
				break;
			}
			foreach ( is_array( $claim['exhausted_jobs'] ?? null ) ? $claim['exhausted_jobs'] : array() as $exhausted_job ) {
				$error = new \WP_Error(
					'subin_notification_worker_crash_exhausted',
					__( 'The notification worker stopped before acknowledging the final delivery attempt.', 'submission-inbox' ),
					array( 'retryable' => false )
				);
				$this->project_notification_job_failure( $exhausted_job );
				do_action( 'subin_notification_job_exhausted', absint( $exhausted_job->id ?? 0 ), $error );
				do_action( 'subin_notification_job_failed', absint( $exhausted_job->id ?? 0 ), $error );
			}

			if ( empty( $claim['jobs'] ) ) {
				break;
			}

			$token = sanitize_text_field( (string) ( $claim['token'] ?? '' ) );
			$job   = reset( $claim['jobs'] );
			try {
				$result = $this->deliver_notification_job( $job );
			} catch ( \Throwable $throwable ) {
				$result = new \WP_Error(
					'subin_notification_exception',
					$this->sanitize_mail_error( $throwable->getMessage() )
				);
			}

			if ( is_wp_error( $result ) ) {
				$error_data = $result->get_error_data();
				$retryable  = ! is_array( $error_data ) || ! array_key_exists( 'retryable', $error_data ) || false !== $error_data['retryable'];
				$released   = $this->notification_jobs->mark_failed( absint( $job->id ?? 0 ), $token, $result->get_error_message(), $retryable );
				if ( ! $released ) {
					do_action( 'subin_notification_job_release_failed', absint( $job->id ?? 0 ), $result );
				} else {
					$this->project_notification_job_failure( $job );
					if ( ! $retryable || absint( $job->attempts ?? 0 ) >= NotificationJobRepository::MAX_ATTEMPTS ) {
						do_action( 'subin_notification_job_exhausted', absint( $job->id ?? 0 ), $result );
					}
				}
				do_action( 'subin_notification_job_failed', absint( $job->id ?? 0 ), $result );
				continue;
			}

			if ( $this->notification_jobs->mark_sent( absint( $job->id ?? 0 ), $token ) ) {
				++$completed;
			} else {
				do_action( 'subin_notification_job_ack_failed', absint( $job->id ?? 0 ) );
			}
		}

		return $completed;
	}

	/**
	 * Deliver one notification job.
	 *
	 * @param object $job Job row.
	 * @return true|\WP_Error
	 */
	private function deliver_notification_job( $job ) {
		$lead_id    = absint( $job->lead_id ?? 0 );
		$email_type = sanitize_key( (string) ( $job->email_type ?? '' ) );
		if ( ! in_array( $email_type, array( self::EMAIL_TYPE_ADMIN_NOTIFICATION, self::EMAIL_TYPE_VISITOR_AUTO_REPLY ), true ) ) {
			return new \WP_Error(
				'subin_notification_email_type_invalid',
				__( 'The queued notification type is invalid.', 'submission-inbox' ),
				array( 'retryable' => false )
			);
		}

		$payload = $this->notification_jobs->decode_payload( $job );
		if ( is_wp_error( $payload ) ) {
			return $payload;
		}

		$payload_version = absint( $job->payload_version ?? 0 );
		if ( 1 === $payload_version ) {
			$envelope = NotificationEnvelope::decode(
				$payload,
				$email_type,
				absint( $job->receipt_id ?? 0 ),
				$lead_id
			);
			if ( is_wp_error( $envelope ) ) {
				return $envelope;
			}

			return $this->deliver_notification_envelope( $job, $envelope );
		}
		if ( AutoReplyDeliverySnapshot::JOB_PAYLOAD_VERSION === $payload_version ) {
			$snapshot = AutoReplyDeliverySnapshot::decode( $payload, $lead_id, $email_type );
			if ( is_wp_error( $snapshot ) ) {
				return $snapshot;
			}
			$envelope = AutoReplyDeliverySnapshot::as_envelope( $snapshot );
			if ( is_wp_error( $envelope ) ) {
				return $envelope;
			}

			return $this->deliver_notification_envelope( $job, $envelope );
		}

		if ( 0 !== $payload_version ) {
			return new \WP_Error(
				'subin_notification_payload_version_unsupported',
				__( 'The queued notification version is not supported.', 'submission-inbox' ),
				array( 'retryable' => false )
			);
		}

		if ( $lead_id > 0 ) {
			return $this->deliver_saved_lead_notification( $lead_id, $email_type );
		}

		return $this->deliver_payload_notification( $payload );
	}

	/**
	 * Deliver one finalized immutable notification envelope.
	 *
	 * @param object              $job Job row.
	 * @param array<string,mixed> $envelope Validated envelope.
	 * @return true|\WP_Error
	 */
	private function deliver_notification_envelope( $job, array $envelope ) {
		if ( ! $this->mail_events ) {
			return new \WP_Error(
				'subin_notification_delivery_history_unavailable',
				__( 'Notification delivery history is unavailable.', 'submission-inbox' ),
				array( 'retryable' => false )
			);
		}
		$this->context->clear();

		$delivery     = $envelope['delivery'];
		$context      = $envelope['context'];
		$state        = sanitize_key( $delivery['state'] ?? '' );
		$lead_id      = absint( $job->lead_id ?? 0 );
		$source       = sanitize_key( $context['source'] ?? '' );
		$delivery_key = sanitize_text_field( $context['delivery_key'] ?? '' );
		$message_hash = NotificationEnvelope::STATE_READY === $state
			? sanitize_text_field( $envelope['meta']['body_sha256'] ?? '' )
			: '';
		$metadata = $this->notification_event_metadata( $job, $envelope );
		$completed    = $this->mail_events->find_completed_delivery( $delivery_key, $source, $message_hash );
		if ( is_wp_error( $completed ) ) {
			return $completed;
		}
		if ( $completed ) {
			$this->recalculate_notification_lead_status(
				$lead_id,
				NotificationEnvelope::STATE_NO_EMAIL_GENERATED === $state
					? LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED
					: LeadRepository::EMAIL_STATUS_SENT,
				$source
			);
			return true;
		}

		if ( NotificationEnvelope::STATE_NO_EMAIL_GENERATED === $state ) {
			$event_id = $this->mail_events->create_no_email_generated(
				array_merge(
					$metadata,
					array(
						'lead_id'        => $lead_id,
						'correlation_id' => $delivery_key,
						'source'         => $source,
						'to_email'       => sanitize_email( $delivery['to'] ?? '' ),
						'subject'        => sanitize_text_field( $delivery['subject'] ?? '' ),
						'error_message'  => sanitize_textarea_field( $delivery['reason_message'] ?? '' ),
					)
				)
			);
			if ( $event_id <= 0 ) {
				return new \WP_Error(
					'subin_notification_no_email_event_failed',
					__( 'The no-email notification outcome could not be recorded.', 'submission-inbox' ),
					array( 'retryable' => true )
				);
			}
			$this->recalculate_notification_lead_status( $lead_id, LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED, $source );
			return true;
		}

		$recipient = sanitize_email( $delivery['to'] ?? '' );
		$subject   = sanitize_text_field( $delivery['subject'] ?? '' );
		$body      = (string) ( $delivery['body_html'] ?? '' );
		$headers   = array( 'Content-Type: text/html; charset=UTF-8' );
		$from      = is_array( $delivery['from'] ?? null ) ? $delivery['from'] : array();
		$from_email = is_email( $from['email'] ?? '' ) ? sanitize_email( $from['email'] ) : '';
		if ( '' !== $from_email ) {
			$from_name = sanitize_text_field( $from['name'] ?? '' );
			$headers[] = 'From: ' . ( '' !== $from_name ? $from_name . ' ' : '' ) . '<' . $from_email . '>';
		}
		$reply_to  = is_array( $delivery['reply_to'] ?? null ) ? $delivery['reply_to'] : array();
		if ( is_email( $reply_to['email'] ?? '' ) ) {
			$reply_name = sanitize_text_field( $reply_to['name'] ?? '' );
			$headers[] = 'Reply-To: ' . ( '' !== $reply_name ? $reply_name . ' ' : '' ) . '<' . sanitize_email( $reply_to['email'] ) . '>';
		}

		$retry_of_event_id = absint( $job->retry_of_event_id ?? 0 );
		if ( $retry_of_event_id > 0 ) {
			$provider_evidence = $this->mail_events->has_authoritative_provider_evidence( $retry_of_event_id );
			if ( is_wp_error( $provider_evidence ) ) {
				return new \WP_Error(
					'subin_notification_retry_evidence_unverified',
					$provider_evidence->get_error_message(),
					array( 'retryable' => false )
				);
			}
			if ( $provider_evidence ) {
				return new \WP_Error(
					'subin_notification_retry_provider_evidence_present',
					__( 'The retry was cancelled because the email provider reported the original message after it was queued.', 'submission-inbox' ),
					array( 'retryable' => false )
				);
			}
		}

		$this->context->set_current_lead_id( $lead_id );
		$this->context->set_current_source( $source );
		$this->context->set_correlation_id( $delivery_key );
		$this->context->set_delivery_metadata( $metadata );
		$this->context->set_current_from( $from_email, $from_name ?? '' );
		$error_message = '';
		$capture_error = static function ( $wp_error ) use ( &$error_message ) {
			if ( is_wp_error( $wp_error ) ) {
				$error_message = sanitize_text_field( wp_strip_all_tags( $wp_error->get_error_message() ) );
			}
		};
		add_action( 'wp_mail_failed', $capture_error, 20 );
		try {
			$sent = wp_mail( $recipient, $subject, $body, $headers );
		} catch ( \Throwable $throwable ) {
			$error_message = $this->sanitize_mail_error( $throwable->getMessage() );
			$sent = false;
		} finally {
			remove_action( 'wp_mail_failed', $capture_error, 20 );
			$this->context->clear();
		}

		if ( $sent ) {
			return $this->ensure_sent_notification_event(
				$lead_id,
				$source,
				$delivery_key,
				$recipient,
				$subject,
				$message_hash,
				$metadata,
				$from_email
			);
		}

		$completed_after_transport = $this->mail_events->find_completed_delivery( $delivery_key, $source, $message_hash );
		if ( is_wp_error( $completed_after_transport ) ) {
			return $completed_after_transport;
		}
		if ( $completed_after_transport ) {
			$this->recalculate_notification_lead_status( $lead_id, LeadRepository::EMAIL_STATUS_SENT, $source );
			return true;
		}

		$error_message = '' !== $error_message ? $error_message : __( 'WordPress mail returned false.', 'submission-inbox' );
		$recorded = $this->ensure_failed_notification_event(
			$lead_id,
			$source,
			$delivery_key,
			$recipient,
			$subject,
			$message_hash,
			$error_message,
			$metadata,
			$from_email
		);
		if ( is_wp_error( $recorded ) ) {
			return $recorded;
		}
		$issue = $this->error_classifier->classify( $error_message );
		return new \WP_Error(
			sanitize_key( $issue['code'] ?? 'subin_notification_failed' ),
			$this->sanitize_mail_error( $error_message ),
			array( 'retryable' => true )
		);
	}

	/**
	 * Build the compact Email Log linkage for one immutable job.
	 *
	 * @param object              $job Queue row.
	 * @param array<string,mixed> $envelope Delivery envelope/snapshot adapter.
	 * @return array<string,mixed>
	 */
	private function notification_event_metadata( $job, array $envelope ) {
		$context  = is_array( $envelope['context'] ?? null ) ? $envelope['context'] : array();
		$meta     = is_array( $envelope['meta'] ?? null ) ? $envelope['meta'] : array();
		$delivery = is_array( $envelope['delivery'] ?? null ) ? $envelope['delivery'] : array();

		return FormSubmissionContext::sanitize_delivery_metadata(
			array(
				'job_id'        => absint( $job->id ?? 0 ),
				'retry_of_event_id' => absint( $job->retry_of_event_id ?? 0 ),
				'event_kind'    => self::EMAIL_TYPE_VISITOR_AUTO_REPLY === sanitize_key( (string) ( $job->email_type ?? '' ) )
					? \SUBIN\MailLog\MailEventRepository::EVENT_KIND_VISITOR_CONFIRMATION
					: \SUBIN\MailLog\MailEventRepository::EVENT_KIND_ADMIN_NOTIFICATION,
				'rule_id'       => absint( $meta['rule_id'] ?? 0 ),
				'rule_name'     => sanitize_text_field( $meta['rule_name'] ?? '' ),
				'template_id'   => absint( $meta['template_id'] ?? 0 ),
				'template_name' => sanitize_text_field( $meta['template_name'] ?? '' ),
				'rule_version'  => absint( $meta['rule_version'] ?? 0 ),
				'provider'      => sanitize_key( $context['provider'] ?? '' ),
				'form_id'       => sanitize_text_field( $context['form_id'] ?? '' ),
				'form_name'     => sanitize_text_field( $context['form_name'] ?? '' ),
				'delivery_mode' => sanitize_key( $meta['delivery_mode'] ?? '' ),
				'scheduled_at'  => sanitize_text_field( $meta['scheduled_at'] ?? '' ),
				'reason_code'   => sanitize_key( $delivery['reason_code'] ?? '' ),
			)
		);
	}

	/**
	 * Ensure a successful transport outcome has a durable recovery event.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $source Mail source.
	 * @param string $delivery_key Durable delivery key.
	 * @param string $recipient Recipient.
	 * @param string $subject Subject.
	 * @param string $message_hash Accepted body hash.
	 * @param array<string,mixed> $metadata Delivery metadata.
	 * @param string $from_email Effective policy From address.
	 * @return true|\WP_Error
	 */
	private function ensure_sent_notification_event( $lead_id, $source, $delivery_key, $recipient, $subject, $message_hash, array $metadata = array(), $from_email = '' ) {
		$completed = $this->mail_events->find_completed_delivery( $delivery_key, $source, $message_hash );
		if ( is_wp_error( $completed ) ) {
			return $completed;
		}

		if ( ! $completed ) {
			$event = $this->mail_events->find_delivery_event( $delivery_key, $source );
			if ( is_wp_error( $event ) ) {
				return $event;
			}

			$event_id = $event && MailEventRepository::STATUS_PENDING === sanitize_key( $event->status ?? '' )
				? absint( $event->id ?? 0 )
				: $this->mail_events->create_pending(
					array_merge(
						$metadata,
						array(
							'lead_id'        => absint( $lead_id ),
							'correlation_id' => sanitize_text_field( $delivery_key ),
							'source'         => sanitize_key( $source ),
							'to_email'       => sanitize_email( $recipient ),
							'from_email'     => is_email( $from_email ) ? sanitize_email( $from_email ) : $this->logged_from_email(),
							'subject'        => sanitize_text_field( $subject ),
							'message_hash'   => sanitize_text_field( $message_hash ),
						)
					)
				);
			if ( $event_id > 0 && ! empty( $metadata ) ) {
				$this->mail_events->update_metadata( $event_id, $metadata );
			}
			if ( $event_id <= 0 || ! $this->mail_events->mark_sent( $event_id ) ) {
				return new \WP_Error(
					'subin_notification_sent_event_failed',
					__( 'The successful notification outcome could not be recorded.', 'submission-inbox' ),
					array( 'retryable' => true )
				);
			}
		}

		$this->recalculate_notification_lead_status( $lead_id, LeadRepository::EMAIL_STATUS_SENT, $source );
		return true;
	}

	/**
	 * Ensure an unsuccessful transport outcome has a durable recovery event.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $source Mail source.
	 * @param string $delivery_key Durable delivery key.
	 * @param string $recipient Recipient.
	 * @param string $subject Subject.
	 * @param string $message_hash Accepted body hash.
	 * @param string $error_message Safe failure.
	 * @param array<string,mixed> $metadata Delivery metadata.
	 * @param string $from_email Effective policy From address.
	 * @return true|\WP_Error
	 */
	private function ensure_failed_notification_event( $lead_id, $source, $delivery_key, $recipient, $subject, $message_hash, $error_message, array $metadata = array(), $from_email = '' ) {
		$event = $this->mail_events->find_delivery_event( $delivery_key, $source );
		if ( is_wp_error( $event ) ) {
			return $event;
		}

		if ( ! $event || MailEventRepository::STATUS_FAILED !== sanitize_key( $event->status ?? '' ) ) {
			$event_id = $event && MailEventRepository::STATUS_PENDING === sanitize_key( $event->status ?? '' )
				? absint( $event->id ?? 0 )
				: $this->mail_events->create_pending(
					array_merge(
						$metadata,
						array(
							'lead_id'        => absint( $lead_id ),
							'correlation_id' => sanitize_text_field( $delivery_key ),
							'source'         => sanitize_key( $source ),
							'to_email'       => sanitize_email( $recipient ),
							'from_email'     => is_email( $from_email ) ? sanitize_email( $from_email ) : $this->logged_from_email(),
							'subject'        => sanitize_text_field( $subject ),
							'message_hash'   => sanitize_text_field( $message_hash ),
						)
					)
				);
			if ( $event_id > 0 && ! empty( $metadata ) ) {
				$this->mail_events->update_metadata( $event_id, $metadata );
			}
			if ( $event_id <= 0 || ! $this->mail_events->mark_failed( $event_id, $this->sanitize_mail_error( $error_message ) ) ) {
				return new \WP_Error(
					'subin_notification_failed_event_failed',
					__( 'The failed notification outcome could not be recorded.', 'submission-inbox' ),
					array( 'retryable' => true )
				);
			}
		}

		$this->recalculate_notification_lead_status( $lead_id, LeadRepository::EMAIL_STATUS_FAILED, $source );
		return true;
	}

	/**
	 * Refresh the aggregate email status after a durable delivery outcome.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $fallback Status when no event can be derived.
	 * @param string $source Mail event source.
	 * @return void
	 */
	private function recalculate_notification_lead_status( $lead_id, $fallback, $source = 'submission_inbox_form' ) {
		$lead_id = absint( $lead_id );
		if ( $lead_id > 0 ) {
			( new LeadEmailStatusService( $this->leads, $this->mail_events ) )->recalculate_for_source( $lead_id, $source, $fallback );
		}
	}

	/**
	 * Expose queue failures that happen before a Mail Event can be created.
	 *
	 * Durable payload validation, missing infrastructure, and similar early
	 * failures otherwise leave Inbox showing an indefinite unknown/scheduled
	 * projection even though the queue already holds a concrete failure.
	 *
	 * @param object $job Claimed notification job.
	 * @return void
	 */
	private function project_notification_job_failure( $job ) {
		$lead_id    = absint( $job->lead_id ?? 0 );
		$email_type = sanitize_key( (string) ( $job->email_type ?? '' ) );
		if ( $lead_id <= 0 ) {
			return;
		}

		if ( self::EMAIL_TYPE_VISITOR_AUTO_REPLY === $email_type ) {
			$this->leads->update_auto_reply_status( $lead_id, LeadRepository::AUTO_REPLY_STATUS_FAILED );
			return;
		}
		if ( self::EMAIL_TYPE_ADMIN_NOTIFICATION === $email_type ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_FAILED );
		}
	}

	/**
	 * Deliver a notification for a saved lead.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $email_type Email type.
	 * @return true|\WP_Error
	 */
	private function deliver_saved_lead_notification( $lead_id, $email_type ) {
		$lead = $this->leads->get( $lead_id );
		if ( ! $lead || 'submission_inbox_form' !== sanitize_key( $lead->provider ?? '' ) ) {
			return true;
		}

		$form = $this->forms->get( absint( $lead->form_id ) );
		if ( ! $form ) {
			if ( self::EMAIL_TYPE_VISITOR_AUTO_REPLY === $email_type ) {
				$this->leads->update_auto_reply_status( $lead_id, LeadRepository::AUTO_REPLY_STATUS_NO_EMAIL_GENERATED );
			} else {
				$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED );
			}
			return true;
		}

		$settings    = $this->forms->decode_settings( $form );
		$lead_fields = $this->lead_fields_for_saved_lead( $lead_id, absint( $lead->form_id ) );
		$detected    = $this->detected_values_for_saved_lead( $lead, $lead_fields );

		if ( self::EMAIL_TYPE_VISITOR_AUTO_REPLY === $email_type ) {
			if ( $this->has_sent_notification_event( $lead_id, 'submission_inbox_auto_reply' ) ) {
				$this->leads->update_auto_reply_status( $lead_id, LeadRepository::AUTO_REPLY_STATUS_SENT );
				return true;
			}

			if ( ! $this->auto_replies || ! $this->auto_replies->has_enabled_rule_for_form( $form ) ) {
				$this->leads->update_auto_reply_status( $lead_id, LeadRepository::AUTO_REPLY_STATUS_NOT_CONFIGURED );
				return true;
			}

			$status = $this->auto_replies->send_for_submission( $form, $lead_id, $lead_fields, $detected, $settings );
			$this->leads->update_auto_reply_status( $lead_id, $status );
			return LeadRepository::EMAIL_STATUS_FAILED === $status
				? new \WP_Error( 'subin_auto_reply_failed', __( 'The visitor auto-reply could not be sent.', 'submission-inbox' ) )
				: true;
		}

		if ( $this->has_sent_notification_event( $lead_id, 'submission_inbox_form' ) ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_SENT );
			return true;
		}

		if ( ! $this->can_generate_notification( $form ) ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED );
			return true;
		}

		$result = $this->send_notification_attempt( $form, $lead_id, $lead_fields, $detected, (string) $lead->source_url, 0 );
		if ( ! empty( $result['sent'] ) ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_SENT );
			return true;
		}

		if ( ! empty( $result['no_email_generated'] ) ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED );
			return true;
		}

		$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_FAILED );
		return new \WP_Error(
			sanitize_key( $result['issue_code'] ?? 'subin_notification_failed' ),
			$this->sanitize_mail_error( $result['error'] ?? __( 'WordPress mail returned false.', 'submission-inbox' ) )
		);
	}

	/**
	 * Deliver a notification for a non-stored submission.
	 *
	 * @param array<string,mixed> $payload Job payload.
	 * @return true|\WP_Error
	 */
	private function deliver_payload_notification( array $payload ) {
		$form = $this->forms->get( absint( $payload['form_id'] ?? 0 ) );
		if ( ! $form || ! $this->can_generate_notification( $form ) ) {
			return true;
		}

		$lead_fields = is_array( $payload['lead_fields'] ?? null ) ? $payload['lead_fields'] : array();
		$detected    = is_array( $payload['detected'] ?? null ) ? $payload['detected'] : array();
		$source_url  = esc_url_raw( $payload['source_url'] ?? '' );
		$result      = $this->send_notification_attempt(
			$form,
			0,
			$lead_fields,
			$this->sanitize_detected_values( $detected ),
			$source_url,
			0
		);

		if ( ! empty( $result['sent'] ) || ! empty( $result['no_email_generated'] ) ) {
			return true;
		}

		return new \WP_Error(
			sanitize_key( $result['issue_code'] ?? 'subin_notification_failed' ),
			$this->sanitize_mail_error( $result['error'] ?? __( 'WordPress mail returned false.', 'submission-inbox' ) )
		);
	}

	/**
	 * Check for a completed mail event.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $source Mail source.
	 * @return bool
	 */
	private function has_sent_notification_event( $lead_id, $source ) {
		if ( ! $this->mail_events ) {
			return false;
		}

		foreach ( $this->mail_events->get_by_lead_id( $lead_id ) as $event ) {
			if (
				sanitize_key( $source ) === sanitize_key( $event->source ?? '' )
				&& MailEventRepository::STATUS_SENT === sanitize_key( $event->status ?? '' )
			) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Validate submitted fields.
	 *
	 * @param array<int,array<string,mixed>> $fields Fields.
	 * @param array<string,mixed>            $posted Posted values.
	 * @param array<string,mixed>            $messages Messages.
	 * @param array<string,string>           $hidden_defaults Server-owned hidden defaults.
	 * @param array<string,bool>             $logic_visibility Conditional visibility by field key.
	 * @return array{values:array<string,mixed>,errors:array<string,string>}
	 */
	private function validate_fields( array $fields, array $posted, array $messages, array $hidden_defaults = array(), array $logic_visibility = array() ) {
		$values = array();
		$errors = array();
		$total_length = 0;

		foreach ( $fields as $field ) {
			$key  = sanitize_key( $field['field_key'] ?? '' );
			$type = sanitize_key( $field['field_type'] ?? 'text' );

			if ( '' === $key ) {
				continue;
			}

			if ( array_key_exists( $key, $logic_visibility ) && ! $logic_visibility[ $key ] ) {
				continue;
			}

			if ( $this->is_sensitive_field( $key, sanitize_text_field( $field['field_label'] ?? '' ) ) ) {
				continue;
			}

			$raw = 'hidden' === $type
				? ( $hidden_defaults[ $key ] ?? '' )
				: ( $posted[ $key ] ?? '' );
			if ( $this->has_invalid_field_shape( $field, $raw ) ) {
				$values[ $key ] = $this->sanitize_value( $field, '' );
				$errors[ $key ] = __( 'Please submit a valid field value.', 'submission-inbox' );
				continue;
			}

			$value = $this->sanitize_value( $field, $raw );
			$values[ $key ] = $value;

			if ( ! empty( $field['is_required'] ) && $this->is_empty_value( $value ) ) {
				$errors[ $key ] = $this->required_message( $messages, $key );
				continue;
			}

			$value_length = $this->value_length( $value );
			if ( $value_length > $this->max_length_for_type( $type ) ) {
				$errors[ $key ] = __( 'This value is too long.', 'submission-inbox' );
				continue;
			}

			$total_length += $value_length;
			if ( $total_length > self::MAX_TOTAL_VALUE_LENGTH ) {
				$errors[ $key ] = __( 'The submitted form is too large.', 'submission-inbox' );
				continue;
			}

			if ( 'email' === $type ) {
				$raw_email = is_scalar( $raw ) ? trim( (string) $raw ) : '';
				if (
					'' !== $raw_email
					&& (
						! is_email( $raw_email )
						|| sanitize_email( $raw_email ) !== $raw_email
					)
				) {
					$errors[ $key ] = __( 'Please enter a valid email address.', 'submission-inbox' );
				}
			}

			if ( 'date' === $type && '' !== $value && ! $this->is_valid_date_value( $value ) ) {
				$errors[ $key ] = __( 'Please enter a valid date.', 'submission-inbox' );
			}

			if ( in_array( $type, array( 'select', 'radio', 'checkbox', 'consent' ), true ) && $this->has_invalid_choice( $field, $raw ) ) {
				$errors[ $key ] = __( 'Please choose a valid option.', 'submission-inbox' );
			}
		}

		return array(
			'values' => $values,
			'errors' => $errors,
		);
	}

	/**
	 * Read the public project from schema v3 with legacy fallbacks.
	 *
	 * @param array<string,mixed> $settings Form settings.
	 * @return array<string,mixed>
	 */
	private function published_design_studio_project( array $settings ) {
		if ( is_array( $settings['design_studio']['published']['project'] ?? null ) ) {
			return $settings['design_studio']['published']['project'];
		}
		if ( is_array( $settings['design_studio']['grapesjs_project'] ?? null ) ) {
			return $settings['design_studio']['grapesjs_project'];
		}
		if ( is_array( $settings['design_studio_grapesjs_project'] ?? null ) ) {
			return $settings['design_studio_grapesjs_project'];
		}

		return array();
	}

	/**
	 * Read server-owned hidden values from the saved Design Studio project.
	 *
	 * @param array<string,mixed> $settings Form settings.
	 * @return array<string,string>
	 */
	private function hidden_defaults_from_settings( array $settings ) {
		$project  = $this->published_design_studio_project( $settings );
		$defaults = array();
		$this->collect_hidden_defaults( $project, $defaults );

		return $defaults;
	}

	/**
	 * Evaluate published field visibility from sanitized candidate values.
	 *
	 * Hidden fields are removed from subsequent evaluation passes so forged or
	 * stale browser values cannot drive downstream rules.
	 *
	 * @param array<string,mixed>            $settings Form settings.
	 * @param array<int,array<string,mixed>> $fields Stored fields.
	 * @param array<string,mixed>            $posted Posted values.
	 * @param array<string,string>           $hidden_defaults Server-owned hidden defaults.
	 * @return array<string,bool>
	 */
	private function conditional_logic_field_visibility( array $settings, array $fields, array $posted, array $hidden_defaults ) {
		$project = $this->published_design_studio_project( $settings );
		$evaluator = new ConditionalLogicEvaluator();
		$logic = $evaluator->sanitize( is_array( $project['subinLogic'] ?? null ) ? $project['subinLogic'] : array() );
		$visibility = array();
		foreach ( $fields as $field ) {
			$key = sanitize_key( $field['field_key'] ?? '' );
			if ( '' !== $key ) {
				$visibility[ $key ] = true;
			}
		}
		if ( empty( $logic['rules'] ) ) {
			return $visibility;
		}

		$chains = array();
		$this->collect_conditional_logic_field_targets( $project, array(), $chains );
		$candidates = array();
		foreach ( $fields as $field ) {
			$key  = sanitize_key( $field['field_key'] ?? '' );
			$type = sanitize_key( $field['field_type'] ?? 'text' );
			if ( '' === $key ) {
				continue;
			}
			$raw = 'hidden' === $type
				? ( $hidden_defaults[ $key ] ?? '' )
				: ( $posted[ $key ] ?? '' );
			$candidates[ $key ] = $this->has_invalid_field_shape( $field, $raw )
				? ''
				: $this->conditional_logic_candidate_value( $field, $raw );
			if ( empty( $chains[ $key ] ) ) {
				$chains[ $key ] = array(
					array( 'type' => 'field', 'id' => $key ),
				);
			}
		}

		$effective = $candidates;
		$converged = false;
		$limit = max( 2, count( $logic['rules'] ) + 2 );
		for ( $pass = 0; $pass < $limit; ++$pass ) {
			$targets = $evaluator->evaluate( $logic, $effective );
			$next = $candidates;
			foreach ( $visibility as $key => $unused ) {
				$is_visible = true;
				foreach ( $chains[ $key ] ?? array() as $target ) {
					$type = sanitize_key( $target['type'] ?? '' );
					$id   = (string) ( $target['id'] ?? '' );
					if ( isset( $targets[ $type ] ) && array_key_exists( $id, $targets[ $type ] ) && ! $targets[ $type ][ $id ] ) {
						$is_visible = false;
						break;
					}
				}
				$visibility[ $key ] = $is_visible;
				if ( ! $is_visible ) {
					unset( $next[ $key ] );
				}
			}

			if ( $next === $effective ) {
				$converged = true;
				break;
			}
			$effective = $next;
		}

		if ( ! $converged ) {
			foreach ( $visibility as $key => $unused ) {
				$visibility[ $key ] = true;
			}
		}

		return $visibility;
	}

	/**
	 * Normalize a source value with browser-equivalent form semantics.
	 *
	 * Logic is evaluated before persistence sanitization so the server makes the
	 * same visibility decision the visitor saw. Choice values remain constrained
	 * to the server-owned option list; stored values are sanitized separately by
	 * validate_fields().
	 *
	 * @param array<string,mixed> $field Stored field.
	 * @param mixed               $raw Raw submitted or server-owned value.
	 * @return mixed
	 */
	private function conditional_logic_candidate_value( array $field, $raw ) {
		$type = sanitize_key( $field['field_type'] ?? 'text' );

		if ( in_array( $type, array( 'select', 'radio' ), true ) ) {
			$value = is_scalar( $raw ) ? (string) $raw : '';
			return in_array( $value, $this->allowed_option_values( $field ), true ) ? $value : '';
		}

		if ( 'checkbox' === $type ) {
			$allowed = $this->allowed_option_values( $field );
			if ( empty( $allowed ) ) {
				return '1' === (string) $raw ? '1' : '';
			}

			$values = array();
			foreach ( is_array( $raw ) ? $raw : array() as $item ) {
				$value = is_scalar( $item ) ? (string) $item : '';
				if ( in_array( $value, $allowed, true ) ) {
					$values[] = $value;
				}
			}
			return $values;
		}

		if ( 'consent' === $type ) {
			return '1' === (string) $raw ? '1' : '';
		}

		return is_scalar( $raw ) ? (string) $raw : '';
	}

	/**
	 * Map every controlled field to its field, row, and block targets.
	 *
	 * @param mixed                                    $node Project node.
	 * @param array<int,array{type:string,id:string}>   $ancestors Ancestor targets.
	 * @param array<string,array<int,array{type:string,id:string}>> $chains Mutable field target chains.
	 * @return void
	 */
	private function collect_conditional_logic_field_targets( $node, array $ancestors, array &$chains ) {
		if ( ! is_array( $node ) ) {
			return;
		}

		$attributes = is_array( $node['attributes'] ?? null ) ? $node['attributes'] : array();
		$type = $this->conditional_logic_component_type( $node, $attributes );
		$next = $ancestors;
		if ( 'row' === $type ) {
			$id = (string) ( $attributes['data-subin-row-id'] ?? ( $attributes['data-subin-style-id'] ?? '' ) );
			$id = substr( (string) preg_replace( '/[^A-Za-z0-9_.:-]/', '', $id ), 0, 128 );
			if ( '' !== $id ) {
				$next[] = array( 'type' => 'row', 'id' => $id );
			}
		} elseif ( in_array( $type, array( 'text-block', 'html-block', 'notice', 'divider', 'privacy', 'section', 'spacer', 'image-block' ), true ) ) {
			$id = (string) ( $attributes['data-subin-logic-id'] ?? ( $attributes['data-subin-style-id'] ?? '' ) );
			$id = substr( (string) preg_replace( '/[^A-Za-z0-9_.:-]/', '', $id ), 0, 128 );
			if ( '' !== $id ) {
				$next[] = array( 'type' => 'block', 'id' => $id );
			}
		}

		if ( 'field' === $type ) {
			$key = sanitize_key( $attributes['data-subin-field-key'] ?? '' );
			if ( '' !== $key ) {
				$chains[ $key ] = array_merge(
					$next,
					array( array( 'type' => 'field', 'id' => $key ) )
				);
			}
		}

		foreach ( $node as $key => $child ) {
			if ( in_array( $key, array( 'attributes', 'style', 'traits', 'subinLogic' ), true ) ) {
				continue;
			}
			if ( is_array( $child ) ) {
				$this->collect_conditional_logic_field_targets( $child, $next, $chains );
			}
		}
	}

	/**
	 * Collect controlled hidden field defaults recursively.
	 *
	 * @param mixed                $node Project node.
	 * @param array<string,string> $defaults Defaults by field key.
	 * @return void
	 */
	private function collect_hidden_defaults( $node, array &$defaults ) {
		if ( ! is_array( $node ) ) {
			return;
		}

		$attributes = is_array( $node['attributes'] ?? null )
			? $node['attributes']
			: array();
		$component_type = $this->conditional_logic_component_type( $node, $attributes );
		$field_type     = sanitize_key( $attributes['data-subin-field-type'] ?? '' );

		if ( 'field' === $component_type && 'hidden' === $field_type ) {
			$key = sanitize_key( $attributes['data-subin-field-key'] ?? '' );
			if ( '' !== $key ) {
				$defaults[ $key ] = sanitize_text_field( $attributes['data-subin-default-value'] ?? '' );
			}
		}

		foreach ( $node as $child ) {
			$this->collect_hidden_defaults( $child, $defaults );
		}
	}

	/**
	 * Resolve a controlled component name from attributes or GrapesJS type.
	 *
	 * Older canonical projects may only contain the GrapesJS type. Conditional
	 * Logic must interpret those projects exactly like rendering and preflight.
	 *
	 * @param array<string,mixed> $node Project node.
	 * @param array<string,mixed> $attributes Project node attributes.
	 * @return string
	 */
	private function conditional_logic_component_type( array $node, array $attributes ) {
		$component = sanitize_key( (string) ( $attributes['data-subin-component'] ?? '' ) );
		if ( '' !== $component ) {
			return $component;
		}

		$map = array(
			'wrapper'             => 'form-wrapper',
			'subin-form-wrapper'  => 'form-wrapper',
			'subin-row'           => 'row',
			'subin-field'         => 'field',
			'subin-submit-button' => 'submit-button',
			'subin-text-block'    => 'text-block',
			'subin-html-block'    => 'html-block',
			'subin-notice-block'  => 'notice',
			'subin-divider-block' => 'divider',
			'subin-privacy-block' => 'privacy',
			'subin-section-block' => 'section',
			'subin-spacer-block'  => 'spacer',
			'subin-image-block'   => 'image-block',
		);
		$type = sanitize_key( (string) ( $node['type'] ?? '' ) );

		return $map[ $type ] ?? '';
	}

	/**
	 * Resolve the maximum accepted value length for one public field type.
	 *
	 * @param string $type Field type.
	 * @return int
	 */
	private function max_length_for_type( $type ) {
		$type = sanitize_key( $type );

		if ( 'textarea' === $type ) {
			return self::MAX_TEXTAREA_LENGTH;
		}

		if ( 'email' === $type ) {
			return self::MAX_EMAIL_LENGTH;
		}

		if ( 'phone' === $type ) {
			return self::MAX_PHONE_LENGTH;
		}

		if ( 'date' === $type ) {
			return 10;
		}

		if ( 'consent' === $type ) {
			return 1;
		}

		return self::MAX_TEXT_LENGTH;
	}

	/**
	 * Count Unicode characters in scalar or multi-value field data.
	 *
	 * @param mixed $value Field value.
	 * @return int
	 */
	private function value_length( $value ) {
		if ( is_array( $value ) ) {
			$length = 0;
			foreach ( $value as $item ) {
				$length += $this->string_length( (string) $item );
			}
			return $length;
		}

		return $this->string_length( (string) $value );
	}

	/**
	 * Count characters with a safe fallback when mbstring is unavailable.
	 *
	 * @param string $value Value.
	 * @return int
	 */
	private function string_length( $value ) {
		return function_exists( 'mb_strlen' )
			? mb_strlen( $value, 'UTF-8' )
			: strlen( $value );
	}

	/**
	 * Sanitize a field value.
	 *
	 * @param array<string,mixed> $field Field.
	 * @param mixed               $raw Raw value.
	 * @return mixed
	 */
	private function sanitize_value( array $field, $raw ) {
		$type = sanitize_key( $field['field_type'] ?? 'text' );

		if ( in_array( $type, array( 'select', 'radio' ), true ) ) {
			$value = sanitize_text_field( is_scalar( $raw ) ? (string) $raw : '' );
			return in_array( $value, $this->allowed_option_values( $field ), true ) ? $value : '';
		}

		if ( 'checkbox' === $type ) {
			$allowed = $this->allowed_option_values( $field );
			if ( empty( $allowed ) ) {
				return '1' === (string) $raw ? '1' : '';
			}

			$raw_values = is_array( $raw ) ? $raw : array();
			$values = array();
			foreach ( $raw_values as $item ) {
				$item = sanitize_text_field( is_scalar( $item ) ? (string) $item : '' );
				if ( in_array( $item, $allowed, true ) ) {
					$values[] = $item;
				}
			}
			return $values;
		}

		if ( 'consent' === $type ) {
			return '1' === (string) $raw ? '1' : '';
		}

		if ( 'email' === $type ) {
			return sanitize_email( is_scalar( $raw ) ? (string) $raw : '' );
		}

		if ( 'date' === $type ) {
			return sanitize_text_field( is_scalar( $raw ) ? (string) $raw : '' );
		}

		if ( 'textarea' === $type ) {
			return sanitize_textarea_field( is_scalar( $raw ) ? (string) $raw : '' );
		}

		return sanitize_text_field( is_scalar( $raw ) ? (string) $raw : '' );
	}

	/**
	 * Reject manipulated scalar/array shapes before sanitization loses them.
	 *
	 * @param array<string,mixed> $field Field.
	 * @param mixed               $raw Raw value.
	 * @return bool
	 */
	private function has_invalid_field_shape( array $field, $raw ) {
		$type = sanitize_key( $field['field_type'] ?? 'text' );

		if ( 'checkbox' === $type && ! empty( $this->allowed_option_values( $field ) ) ) {
			return '' !== $raw && ! is_array( $raw );
		}

		return is_array( $raw ) || is_object( $raw );
	}

	/**
	 * Check for invalid submitted choice values.
	 *
	 * @param array<string,mixed> $field Field.
	 * @param mixed               $raw Raw value.
	 * @return bool
	 */
	private function has_invalid_choice( array $field, $raw ) {
		$type    = sanitize_key( $field['field_type'] ?? '' );
		$allowed = $this->allowed_option_values( $field );

		if ( 'checkbox' === $type && empty( $allowed ) ) {
			$value = sanitize_text_field( is_scalar( $raw ) ? (string) $raw : '' );
			return '' !== $value && '1' !== $value;
		}

		if ( 'consent' === $type ) {
			$value = sanitize_text_field( is_scalar( $raw ) ? (string) $raw : '' );
			return '' !== $value && '1' !== $value;
		}

		if ( empty( $allowed ) ) {
			return ! $this->is_empty_value( $raw );
		}

		$values = is_array( $raw ) ? $raw : array( $raw );
		foreach ( $values as $value ) {
			$value = sanitize_text_field( is_scalar( $value ) ? (string) $value : '' );
			if ( '' !== $value && ! in_array( $value, $allowed, true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Build fields for lead storage.
	 *
	 * @param array<int,array<string,mixed>> $fields Fields.
	 * @param array<string,mixed>            $values Values.
	 * @return array<int,array<string,mixed>>
	 */
	private function build_lead_fields( array $fields, array $values ) {
		$lead_fields = array();

		foreach ( $fields as $field ) {
			$key = sanitize_key( $field['field_key'] ?? '' );
			$label = sanitize_text_field( $field['field_label'] ?? '' );
			if ( '' === $key || $this->is_sensitive_field( $key, $label ) || ! array_key_exists( $key, $values ) ) {
				continue;
			}

			$value = $this->value_for_storage( $field, $values[ $key ] );
			if ( '' === $value ) {
				continue;
			}

			$lead_fields[] = array(
				'field_id'    => absint( $field['id'] ?? 0 ),
				'field_key'   => $key,
				'field_label' => $label ? $label : $key,
				'field_type'  => sanitize_key( $field['field_type'] ?? '' ),
				'field_value' => $value,
			);
		}

		return $lead_fields;
	}

	/**
	 * Enqueue a durable background notification.
	 *
	 * @param int                 $lead_id Lead id.
	 * @param array<string,mixed> $payload Optional non-stored submission payload.
	 * @param string              $email_type Email type.
	 * @return int|\WP_Error
	 */
	private function schedule_notification( $lead_id, array $payload = array(), $email_type = self::EMAIL_TYPE_ADMIN_NOTIFICATION ) {
		$lead_id    = absint( $lead_id );
		$email_type = $this->sanitize_email_type( $email_type );

		if ( $lead_id > 0 ) {
			$idempotency_key = 'lead_' . $lead_id . '_' . $email_type;
			$payload         = array();
		} else {
			$payload         = $this->sanitize_notification_payload( $payload );
			$idempotency_key = 'payload_' . str_replace( '-', '', wp_generate_uuid4() );
		}

		$result = $this->notification_jobs->enqueue( $idempotency_key, $lead_id, $email_type, $payload );
		if ( is_wp_error( $result ) ) {
			do_action( 'subin_notification_job_failed', 0, $result );
			return $result;
		}

		self::schedule_notification_worker();

		return absint( $result );
	}

	/**
	 * Sanitize background email type.
	 *
	 * @param string $email_type Email type.
	 * @return string
	 */
	private function sanitize_email_type( $email_type ) {
		$email_type = sanitize_key( (string) $email_type );

		return in_array( $email_type, array( self::EMAIL_TYPE_ADMIN_NOTIFICATION, self::EMAIL_TYPE_VISITOR_AUTO_REPLY ), true )
			? $email_type
			: self::EMAIL_TYPE_ADMIN_NOTIFICATION;
	}

	/**
	 * Build a transient/scheduled-event suffix for a lead email type.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $email_type Email type.
	 * @return string
	 */
	private function notification_key_suffix( $lead_id, $email_type ) {
		return absint( $lead_id ) . '_' . $this->sanitize_email_type( $email_type );
	}

	/**
	 * Whether the form should generate an admin notification.
	 *
	 * @param array<string,mixed> $form Form.
	 * @return bool
	 */
	private function can_generate_notification( array $form ) {
		return ! empty( $form['notification_enabled'] ) && is_email( $this->notification_recipient( $form ) );
	}

	/**
	 * Whether visitor auto-reply is enabled for the form settings.
	 *
	 * @param array<string,mixed> $settings Form settings.
	 * @return bool
	 */
	private function is_auto_reply_enabled( array $settings ) {
		$config = is_array( $settings['auto_reply'] ?? null ) ? $settings['auto_reply'] : array();

		return ! empty( $config['enabled'] );
	}

	/**
	 * Build notification field array from saved lead fields.
	 *
	 * @param int $lead_id Lead id.
	 * @param int $form_id Form id.
	 * @return array<int,array>
	 */
	private function lead_fields_for_saved_lead( $lead_id, $form_id ) {
		$saved_fields = $this->lead_fields->get_by_lead_id( $lead_id );
		$form_fields  = $this->form_fields->get_by_form_id( $form_id );
		$type_map     = array();
		$id_map       = array();
		$fields       = array();

		foreach ( $form_fields as $field ) {
			$key = sanitize_key( $field['field_key'] ?? '' );
			if ( '' !== $key ) {
				$type_map[ $key ] = sanitize_key( $field['field_type'] ?? '' );
				$id_map[ $key ]   = absint( $field['id'] ?? 0 );
			}
		}

		foreach ( $saved_fields as $field ) {
			$key = sanitize_key( $field->field_key ?? '' );
			if ( '' === $key ) {
				continue;
			}

			$fields[] = array(
				'field_id'      => $id_map[ $key ] ?? 0,
				'field_key'    => $key,
				'field_label'  => sanitize_text_field( $field->field_label ?? $key ),
				'field_type'   => $type_map[ $key ] ?? '',
				'field_value'  => sanitize_textarea_field( $field->field_value ?? '' ),
				'is_sensitive' => ! empty( $field->is_sensitive ),
			);
		}

		return $fields;
	}

	/**
	 * Build detected values from saved lead data.
	 *
	 * @param object           $lead Lead row.
	 * @param array<int,array> $lead_fields Lead fields.
	 * @return array<string,string>
	 */
	private function detected_values_for_saved_lead( $lead, array $lead_fields ) {
		$detected = $this->detect_lead_values( $lead_fields );

		foreach (
			array(
				'customer_name'   => 'customer_name',
				'customer_email'  => 'customer_email',
				'customer_phone'  => 'customer_phone',
				'message_preview' => 'message_preview',
			) as $property => $key
		) {
			if ( ! empty( $lead->{$property} ) ) {
				$detected[ $key ] = 'customer_email' === $key ? sanitize_email( $lead->{$property} ) : sanitize_text_field( $lead->{$property} );
			}
		}

		return $detected;
	}

	/**
	 * Sanitize transient payload for a non-stored notification.
	 *
	 * @param array<string,mixed> $payload Payload.
	 * @return array<string,mixed>
	 */
	private function sanitize_notification_payload( array $payload ) {
		$fields = array();
		foreach ( is_array( $payload['lead_fields'] ?? null ) ? $payload['lead_fields'] : array() as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$fields[] = array(
				'field_id'      => absint( $field['field_id'] ?? 0 ),
				'field_key'    => sanitize_key( $field['field_key'] ?? '' ),
				'field_label'  => sanitize_text_field( $field['field_label'] ?? '' ),
				'field_type'   => sanitize_key( $field['field_type'] ?? '' ),
				'field_value'  => sanitize_textarea_field( $field['field_value'] ?? '' ),
				'is_sensitive' => ! empty( $field['is_sensitive'] ),
			);
		}

		return array(
			'form_id'     => absint( $payload['form_id'] ?? 0 ),
			'lead_fields' => $fields,
			'detected'    => $this->sanitize_detected_values( is_array( $payload['detected'] ?? null ) ? $payload['detected'] : array() ),
			'source_url'  => esc_url_raw( $payload['source_url'] ?? '' ),
		);
	}

	/**
	 * Build an immutable admin notification draft from accepted request data.
	 *
	 * @param array<string,mixed>  $form Form snapshot.
	 * @param array<int,array>     $lead_fields Accepted lead fields.
	 * @param array<string,string> $detected Detected lead values.
	 * @param string               $source_url Accepted source URL.
	 * @param string               $submitted_at Accepted submission time.
	 * @param bool                 $include_lead_link Whether a lead will be stored.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function prepare_admin_notification_envelope( array $form, array $lead_fields, array $detected, $source_url, $submitted_at, $include_lead_link ) {
		$recipient = $this->notification_recipient( $form );
		$form_name = sanitize_text_field( $form['title'] ?? '' );
		$subject   = sanitize_text_field( $form['notification_subject'] ?: __( 'New lead from {form_name}', 'submission-inbox' ) );
		$subject   = str_replace( '{form_name}', $form_name, $subject );
		$reply_to  = null;
		$settings  = $this->forms->decode_settings( $form );
		$notify    = is_array( $settings['notification'] ?? null ) ? $settings['notification'] : array();
		if ( ! empty( $notify['use_lead_email_reply_to'] ) && is_email( $detected['customer_email'] ?? '' ) ) {
			$reply_to = array(
				'email' => sanitize_email( $detected['customer_email'] ),
				'name'  => sanitize_text_field( $detected['customer_name'] ?? '' ),
			);
		}
		$lead_id_token = $include_lead_link ? NotificationEnvelope::create_lead_id_token() : '';

		return NotificationEnvelope::create_draft(
			self::EMAIL_TYPE_ADMIN_NOTIFICATION,
			'submission_inbox_form',
			array(
				'state'     => NotificationEnvelope::STATE_READY,
				'to'        => $recipient,
				'subject'   => $subject,
				'body_html' => $this->notification_body(
					$form,
					'' !== $lead_id_token ? $lead_id_token : 0,
					$lead_fields,
					$source_url,
					$submitted_at
				),
				'reply_to'  => $reply_to,
			),
			array(
				'provider'     => 'submission_inbox_form',
				'form_id'      => (string) absint( $form['id'] ?? 0 ),
				'form_name'    => $form_name,
				'submitted_at' => sanitize_text_field( $submitted_at ),
			),
			array( 'lead_id_token' => $lead_id_token )
		);
	}

	/**
	 * Sanitize detected lead values.
	 *
	 * @param array<string,mixed> $detected Detected values.
	 * @return array<string,string>
	 */
	private function sanitize_detected_values( array $detected ) {
		return array(
			'customer_name'   => sanitize_text_field( $detected['customer_name'] ?? '' ),
			'customer_email'  => sanitize_email( $detected['customer_email'] ?? '' ),
			'customer_phone'  => sanitize_text_field( $detected['customer_phone'] ?? '' ),
			'message_preview' => sanitize_textarea_field( $detected['message_preview'] ?? '' ),
		);
	}

	/**
	 * Check whether a visitor auto-reply event already exists for a lead.
	 *
	 * @param int $lead_id Lead id.
	 * @return bool
	 */
	private function has_auto_reply_event( $lead_id ) {
		if ( ! $this->mail_events ) {
			return false;
		}

		foreach ( $this->mail_events->get_by_lead_id( $lead_id ) as $event ) {
			if (
				'submission_inbox_auto_reply' === sanitize_key( $event->source ?? '' )
				&& MailEventRepository::STATUS_SENT === sanitize_key( $event->status ?? '' )
			) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Send queued auto-reply at shutdown if the non-blocking request does not run.
	 *
	 * @param int $lead_id Lead id.
	 * @return void
	 */
	private function register_auto_reply_shutdown_fallback( $lead_id ) {
		$lead_id = absint( $lead_id );
		if ( $lead_id <= 0 ) {
			return;
		}

		static $registered = array();
		if ( ! empty( $registered[ $lead_id ] ) ) {
			return;
		}
		$registered[ $lead_id ] = true;

		add_action(
			'shutdown',
			function () use ( $lead_id ) {
				if ( $this->has_auto_reply_event( $lead_id ) ) {
					return;
				}

				$this->send_scheduled_notification( $lead_id, self::EMAIL_TYPE_VISITOR_AUTO_REPLY );
			}
		);
	}

	/**
	 * Prefer the actual linked mail event status when the fast attempt created one.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $fallback Fallback status.
	 * @return string
	 */
	private function email_status_from_related_events( $lead_id, $fallback ) {
		$fallback = sanitize_key( $fallback );
		if ( ! $this->mail_events ) {
			return $fallback;
		}

		$has_sent = false;
		foreach ( $this->mail_events->get_by_lead_id( $lead_id ) as $event ) {
			$status = sanitize_key( $event->status ?? '' );
			if ( MailEventRepository::STATUS_FAILED === $status ) {
				return LeadRepository::EMAIL_STATUS_FAILED;
			}
			if ( MailEventRepository::STATUS_SENT === $status ) {
				$has_sent = true;
			}
		}

		return $has_sent ? LeadRepository::EMAIL_STATUS_SENT : $fallback;
	}

	/**
	 * Attempt admin notification in the visitor request with a short timeout.
	 *
	 * @param array<string,mixed>  $form Form.
	 * @param int                  $lead_id Lead id.
	 * @param array<int,array>     $lead_fields Lead fields.
	 * @param array<string,string> $detected Detected values.
	 * @param string               $source_url Source URL.
	 * @return array{status:string,queue_fallback:bool}
	 */
	private function send_fast_notification( array $form, $lead_id, array $lead_fields, array $detected, $source_url = '' ) {
		$result = $this->send_notification_attempt( $form, $lead_id, $lead_fields, $detected, $source_url, self::FAST_MAIL_TIMEOUT );

		if ( ! empty( $result['sent'] ) ) {
			return array(
				'status'         => LeadRepository::EMAIL_STATUS_SENT,
				'queue_fallback' => false,
			);
		}

		$code = sanitize_key( $result['issue_code'] ?? 'unknown_email_error' );
		if ( in_array( $code, array( 'smtp_connection_failed', 'smtp_encryption_failed' ), true ) || ( 'unknown_email_error' === $code && ! empty( $result['timed_out'] ) ) ) {
			return array(
				'status'         => LeadRepository::EMAIL_STATUS_UNKNOWN,
				'queue_fallback' => true,
			);
		}

		return array(
			'status'         => LeadRepository::EMAIL_STATUS_FAILED,
			'queue_fallback' => false,
		);
	}

	/**
	 * Send notification email.
	 *
	 * @param array<string,mixed> $form Form.
	 * @param int                 $lead_id Lead id.
	 * @param array<int,array>    $lead_fields Lead fields.
	 * @param array<string,string> $detected Detected values.
	 * @param string              $source_url Source URL.
	 * @return string
	 */
	private function send_notification( array $form, $lead_id, array $lead_fields, array $detected, $source_url = '' ) {
		$result = $this->send_notification_attempt( $form, $lead_id, $lead_fields, $detected, $source_url, 0 );

		if ( ! empty( $result['no_email_generated'] ) ) {
			return LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED;
		}

		return ! empty( $result['sent'] ) ? LeadRepository::EMAIL_STATUS_SENT : LeadRepository::EMAIL_STATUS_FAILED;
	}

	/**
	 * Send notification email and return detailed attempt metadata.
	 *
	 * @param array<string,mixed>  $form Form.
	 * @param int                  $lead_id Lead id.
	 * @param array<int,array>     $lead_fields Lead fields.
	 * @param array<string,string> $detected Detected values.
	 * @param string               $source_url Source URL.
	 * @param int                  $timeout Optional SMTP timeout override.
	 * @return array{sent:bool,no_email_generated:bool,error:string,issue_code:string,duration:float,timed_out:bool}
	 */
	private function send_notification_attempt( array $form, $lead_id, array $lead_fields, array $detected, $source_url = '', $timeout = 0 ) {
		if ( empty( $form['notification_enabled'] ) ) {
			return array(
				'sent'               => false,
				'no_email_generated' => true,
				'error'              => '',
				'issue_code'         => '',
				'duration'           => 0.0,
				'timed_out'          => false,
			);
		}

		$recipient = $this->notification_recipient( $form );
		if ( ! is_email( $recipient ) ) {
			return array(
				'sent'               => false,
				'no_email_generated' => true,
				'error'              => '',
				'issue_code'         => '',
				'duration'           => 0.0,
				'timed_out'          => false,
			);
		}

		$form_name = sanitize_text_field( $form['title'] ?? '' );
		$subject = sanitize_text_field( $form['notification_subject'] ?: __( 'New lead from {form_name}', 'submission-inbox' ) );
		$subject = str_replace( '{form_name}', $form_name, $subject );
		$headers = array( 'Content-Type: text/html; charset=UTF-8' );

		$settings = $this->forms->decode_settings( $form );
		$notify   = is_array( $settings['notification'] ?? null ) ? $settings['notification'] : array();
		if ( ! empty( $notify['use_lead_email_reply_to'] ) && is_email( $detected['customer_email'] ) ) {
			$reply_name = sanitize_text_field( $detected['customer_name'] );
			$headers[] = 'Reply-To: ' . ( $reply_name ? $reply_name . ' ' : '' ) . '<' . sanitize_email( $detected['customer_email'] ) . '>';
		}

		$body           = $this->notification_body( $form, $lead_id, $lead_fields, $source_url );
		$message_hash   = hash( 'sha256', $body );
		$correlation_id = str_replace( '-', '', wp_generate_uuid4() );
		$event_metadata = array(
			'event_kind' => MailEventRepository::EVENT_KIND_ADMIN_NOTIFICATION,
			'provider'   => 'submission_inbox_form',
			'form_id'    => (string) absint( $form['id'] ?? 0 ),
			'form_name'  => $form_name,
		);
		if ( $lead_id > 0 ) {
			$this->context->set_current_lead_id( $lead_id );
		}
		$this->context->set_current_source( 'submission_inbox_form' );
		$this->context->set_correlation_id( $correlation_id );
		$this->context->set_delivery_metadata( $event_metadata );

		$error_message = '';
		$capture_error = static function ( $wp_error ) use ( &$error_message ) {
			if ( is_wp_error( $wp_error ) ) {
				$error_message = sanitize_text_field( wp_strip_all_tags( $wp_error->get_error_message() ) );
			}
		};
		$timeout_filter = null;
		if ( $timeout > 0 ) {
			$timeout_filter = static function () use ( $timeout ) {
				return absint( $timeout );
			};
			add_filter( 'subin_smtp_timeout', $timeout_filter );
		}
		add_action( 'wp_mail_failed', $capture_error, 20 );

		$started_at = microtime( true );
		try {
			$sent = wp_mail( $recipient, $subject, $body, $headers );
		} catch ( \Throwable $throwable ) {
			$error_message = $this->sanitize_mail_error( $throwable->getMessage() );
			$this->record_failed_notification_event(
				$lead_id,
				$recipient,
				$subject,
				$error_message,
				'submission_inbox_form',
				$correlation_id,
				$message_hash,
				$event_metadata
			);
			$sent = false;
		} finally {
			remove_action( 'wp_mail_failed', $capture_error, 20 );
			if ( $timeout_filter ) {
				remove_filter( 'subin_smtp_timeout', $timeout_filter );
			}
			$this->context->clear();
		}

		$duration = microtime( true ) - $started_at;
		$issue    = ( ! $sent && '' !== $error_message ) ? $this->error_classifier->classify( $error_message ) : array();

		return array(
			'sent'               => (bool) $sent,
			'no_email_generated' => false,
			'error'              => $error_message,
			'issue_code'         => sanitize_key( $issue['code'] ?? 'unknown_email_error' ),
			'duration'           => $duration,
			'timed_out'          => $timeout > 0 && $duration >= (float) $timeout,
		);
	}

	/**
	 * Record a failed notification when wp_mail throws before normal logging completes.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $recipient Recipient email.
	 * @param string $subject Email subject.
	 * @param string $error_message Error message.
	 * @return void
	 */
	private function record_failed_notification_event( $lead_id, $recipient, $subject, $error_message, $source = 'submission_inbox_form', $correlation_id = '', $message_hash = '', array $metadata = array() ) {
		if ( ! $this->mail_events ) {
			return;
		}

		$event = '' !== (string) $correlation_id
			? $this->mail_events->find_delivery_event( $correlation_id, $source )
			: null;
		if ( is_wp_error( $event ) ) {
			return;
		}
		if ( $event && in_array( sanitize_key( (string) ( $event->status ?? '' ) ), array( MailEventRepository::STATUS_SENT, MailEventRepository::STATUS_FAILED ), true ) ) {
			return;
		}

		$event_id = $event
			? absint( $event->id ?? 0 )
			: $this->mail_events->create_pending(
				array_merge(
					$metadata,
					array(
						'lead_id'        => absint( $lead_id ),
						'correlation_id' => sanitize_text_field( $correlation_id ),
						'source'         => sanitize_key( $source ),
						'to_email'       => sanitize_email( $recipient ),
						'from_email'     => $this->logged_from_email(),
						'subject'        => sanitize_text_field( $subject ),
						'message_hash'   => sanitize_text_field( $message_hash ),
					)
				)
			);
		if ( $event_id > 0 && ! empty( $metadata ) ) {
			$this->mail_events->update_metadata( $event_id, $metadata );
		}

		if ( $event_id > 0 ) {
			$this->mail_events->mark_failed( $event_id, $this->sanitize_mail_error( $error_message ) );
		}
	}

	/**
	 * Resolve the From email stored in fallback failed events.
	 *
	 * @return string
	 */
	private function logged_from_email() {
		$settings = $this->settings->all();
		$from     = sanitize_email( (string) ( $settings['from_email'] ?? '' ) );
		if ( '' !== $from && is_email( $from ) ) {
			return $from;
		}

		$admin_email = sanitize_email( (string) get_option( 'admin_email' ) );
		return is_email( $admin_email ) ? $admin_email : '';
	}

	/**
	 * Sanitize mail errors before storing them.
	 *
	 * @param string $message Error message.
	 * @return string
	 */
	private function sanitize_mail_error( $message ) {
		$settings = $this->settings->all();
		return MailErrorSanitizer::sanitize(
			$message,
			array( $settings['smtp_password'] ?? '', $settings['smtp_username'] ?? '' )
		);
	}

	/**
	 * Build notification body.
	 *
	 * @param array<string,mixed> $form Form.
	 * @param int                 $lead_id Lead id.
	 * @param array<int,array>    $lead_fields Lead fields.
	 * @return string
	 */
	private function notification_body( array $form, $lead_id, array $lead_fields, $source_url = '', $submitted_at = '' ) {
		$form_name = sanitize_text_field( $form['title'] ?? '' );
		$source_url = '' !== $source_url ? esc_url_raw( $source_url ) : $this->current_url();
		$lead_reference = NotificationEnvelope::is_lead_id_token( $lead_id )
			? (string) $lead_id
			: (string) absint( $lead_id );
		$lead_url = '0' !== $lead_reference ? admin_url( 'admin.php?page=subin-inbox&action=view&lead_id=' . $lead_reference ) : '';
		$submitted_at = sanitize_text_field( '' !== (string) $submitted_at ? $submitted_at : current_time( 'mysql' ) );

		ob_start();
		?>
		<?php /* translators: %s: form name. */ ?>
		<h2><?php echo esc_html( sprintf( __( 'New lead from %s', 'submission-inbox' ), $form_name ) ); ?></h2>
		<p><strong><?php esc_html_e( 'Submitted at', 'submission-inbox' ); ?>:</strong> <?php echo esc_html( $submitted_at ); ?></p>
		<p><strong><?php esc_html_e( 'Source page', 'submission-inbox' ); ?>:</strong> <a href="<?php echo esc_url( $source_url ); ?>"><?php echo esc_html( $source_url ); ?></a></p>
		<table cellspacing="0" cellpadding="8" border="1" style="border-collapse:collapse;border-color:#dcdcde;">
			<tbody>
				<?php foreach ( $lead_fields as $field ) : ?>
					<tr>
						<th align="left"><?php echo esc_html( $field['field_label'] ?? $field['field_key'] ?? '' ); ?></th>
						<td><?php echo nl2br( esc_html( $field['field_value'] ?? '' ) ); ?></td>
					</tr>
				<?php endforeach; ?>
			</tbody>
		</table>
		<?php if ( $lead_url ) : ?>
			<p><a href="<?php echo esc_url( $lead_url ); ?>"><?php esc_html_e( 'Open lead in WordPress', 'submission-inbox' ); ?></a></p>
		<?php endif; ?>
		<p><?php esc_html_e( 'Sent by InboxMend.', 'submission-inbox' ); ?></p>
		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Get notification recipient.
	 *
	 * @param array<string,mixed> $form Form.
	 * @return string
	 */
	private function notification_recipient( array $form ) {
		$settings = $this->forms->decode_settings( $form );
		$notify   = is_array( $settings['notification'] ?? null ) ? $settings['notification'] : array();
		$mode     = sanitize_key( $notify['recipient_mode'] ?? '' );

		if ( '' === $mode ) {
			$mode = is_email( sanitize_email( $form['notification_email'] ?? '' ) ) ? 'custom' : 'default';
		}

		if ( 'custom' === $mode ) {
			$recipient = sanitize_email( $form['notification_email'] ?? '' );
			return is_email( $recipient ) ? $recipient : '';
		}

		$settings  = $this->settings->all();
		$recipient = sanitize_email( $settings['notification_recipient_email'] ?? '' );

		return is_email( $recipient ) ? $recipient : '';
	}

	/**
	 * Detect lead summary values.
	 *
	 * @param array<int,array> $lead_fields Lead fields.
	 * @return array<string,string>
	 */
	private function detect_lead_values( array $lead_fields ) {
		$detected = array(
			'customer_name'   => '',
			'customer_email'  => '',
			'customer_phone'  => '',
			'message_preview' => '',
		);
		$fallback_message = '';
		$fallback_email   = '';

		foreach ( $lead_fields as $field ) {
			$key = strtolower( (string) ( $field['field_key'] ?? '' ) );
			$label = strtolower( (string) ( $field['field_label'] ?? '' ) );
			$type = sanitize_key( $field['field_type'] ?? '' );
			$value = sanitize_text_field( $field['field_value'] ?? '' );

			if ( '' === $detected['customer_email'] && 'email' === $type && is_email( $value ) ) {
				$detected['customer_email'] = sanitize_email( $value );
			}

			if ( '' === $fallback_email && is_email( $value ) ) {
				$fallback_email = sanitize_email( $value );
			}

			if ( '' === $detected['customer_name'] && $this->matches_any( $key . ' ' . $label, array( 'name', 'full_name', 'your_name' ) ) ) {
				$detected['customer_name'] = $value;
			}

			if ( '' === $detected['customer_phone'] && $this->matches_any( $key . ' ' . $label, array( 'phone', 'tel', 'mobile' ) ) ) {
				$detected['customer_phone'] = $value;
			}

			if ( '' === $detected['message_preview'] && $this->matches_any( $key . ' ' . $label, array( 'message', 'comments', 'enquiry', 'inquiry', 'details' ) ) ) {
				$detected['message_preview'] = $value;
			}

			if ( '' === $fallback_message && 'date' !== $type && strlen( $value ) > 30 ) {
				$fallback_message = $value;
			}
		}

		if ( '' === $detected['message_preview'] ) {
			$detected['message_preview'] = $fallback_message;
		}

		if ( '' === $detected['customer_email'] ) {
			$detected['customer_email'] = $fallback_email;
		}

		$detected['message_preview'] = wp_html_excerpt( $detected['message_preview'], 240, '...' );

		return $detected;
	}

	/**
	 * Get posted subin_fields values.
	 *
	 * @return array<string,mixed>
	 */
	private function get_posted_fields() {
		$fields = isset( $_POST['subin_fields'] ) ? wp_unslash( $_POST['subin_fields'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Public form fields are validated after the form-specific nonce and sanitized per field type.

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

	/**
	 * Validate nonce.
	 *
	 * @param int $form_id Form id.
	 * @return bool
	 */
	private function valid_nonce( $form_id ) {
		$nonce = isset( $_POST['_subin_form_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_subin_form_nonce'] ) ) : '';

		return (bool) wp_verify_nonce( $nonce, 'subin_submit_form_' . absint( $form_id ) );
	}

	/**
	 * Validate the submission token for fallback POST or AJAX REST requests.
	 *
	 * @param int $form_id Form id.
	 * @return bool
	 */
	private function valid_submission_token( $form_id ) {
		if ( ! empty( $_POST['subin_ajax_submission'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This branch validates the AJAX token below.
			return $this->valid_ajax_submit_token( $form_id );
		}

		return $this->valid_nonce( $form_id );
	}

	/**
	 * Validate the cache-tolerant AJAX submit token.
	 *
	 * @param int $form_id Form id.
	 * @return bool
	 */
	private function valid_ajax_submit_token( $form_id ) {
		$form_id    = absint( $form_id );
		$started_at = isset( $_POST['subin_started_at'] ) ? absint( wp_unslash( $_POST['subin_started_at'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission token input.
		$token      = isset( $_POST['subin_ajax_token'] ) ? sanitize_text_field( wp_unslash( $_POST['subin_ajax_token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission token input.

		if ( $form_id <= 0 || $started_at <= 0 || '' === $token ) {
			return false;
		}

		if ( abs( time() - $started_at ) > DAY_IN_SECONDS ) {
			return false;
		}

		$expected = wp_hash( self::AJAX_SUBMIT_TOKEN_ACTION . '|' . $form_id . '|' . $started_at );

		return hash_equals( $expected, $token );
	}

	/**
	 * Check honeypot.
	 *
	 * @return bool
	 */
	private function is_honeypot_filled() {
		return '' !== sanitize_text_field( wp_unslash( $_POST['subin_company_website'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission is validated with a form-specific nonce before persistence.
	}

	/**
	 * Check minimum submit time.
	 *
	 * @return bool
	 */
	private function submitted_too_quickly() {
		$started_at = isset( $_POST['subin_started_at'] ) ? absint( wp_unslash( $_POST['subin_started_at'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission is validated with a form-specific nonce before persistence.
		if ( $started_at <= 0 ) {
			return false;
		}

		return time() - $started_at < 2;
	}

	/**
	 * Verify Google reCAPTCHA when configured for built-in forms.
	 *
	 * @return bool
	 */
	private function passes_recaptcha() {
		if ( ! $this->recaptcha || ! $this->recaptcha->is_configured() ) {
			return true;
		}

		$token  = isset( $_POST['subin_recaptcha_token'] ) ? sanitize_text_field( wp_unslash( $_POST['subin_recaptcha_token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission token verified by Google.
		$action = isset( $_POST['subin_recaptcha_action'] ) ? sanitize_key( wp_unslash( $_POST['subin_recaptcha_action'] ) ) : 'submission_inbox_form'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public form submission action verified by Google.

		if ( 'submission_inbox_form' !== $action ) {
			return false;
		}

		$result = $this->recaptcha->verify( $token, 'submission_inbox_form' );

		return ! empty( $result['success'] );
	}

	/**
	 * Build an error result.
	 *
	 * @param int                 $form_id Form id.
	 * @param string              $message Message.
	 * @param array<string,string> $errors Field errors.
	 * @param array<string,mixed> $values Values.
	 * @return array<string,mixed>
	 */
	private function error_result( $form_id, $message, array $errors, array $values, $reason = '' ) {
		return array(
			'form_id' => absint( $form_id ),
			'status'  => 'error',
			'message' => sanitize_textarea_field( $message ),
			'errors'  => $errors,
			'values'  => $this->sanitize_preserved_values( $values ),
			'reason'  => sanitize_key( $reason ),
		);
	}

	/**
	 * Sanitize preserved values.
	 *
	 * @param array<string,mixed> $values Values.
	 * @return array<string,mixed>
	 */
	private function sanitize_preserved_values( array $values ) {
		$clean = array();
		foreach ( $values as $key => $value ) {
			$key = sanitize_key( $key );
			if ( '' === $key || $this->is_sensitive_key( $key ) ) {
				continue;
			}

			if ( is_array( $value ) ) {
				$clean_values = array();
				foreach ( $value as $item ) {
					if ( is_scalar( $item ) ) {
						$clean_values[] = sanitize_text_field( (string) $item );
					}
				}
				$clean[ $key ] = $clean_values;
			} else {
				$clean[ $key ] = sanitize_textarea_field( (string) $value );
			}
		}

		return $clean;
	}

	/**
	 * Get storage string.
	 *
	 * @param array<string,mixed> $field Field.
	 * @param mixed               $value Value.
	 * @return string
	 */
	private function value_for_storage( array $field, $value ) {
		if ( is_array( $value ) ) {
			$labels = array();
			foreach ( $value as $item ) {
				$labels[] = $this->option_label_for_value( $field, sanitize_text_field( (string) $item ) );
			}
			return implode( ', ', array_filter( $labels ) );
		}

		if ( '1' === (string) $value && 'consent' === sanitize_key( $field['field_type'] ?? '' ) ) {
			return __( 'Yes', 'submission-inbox' );
		}

		if ( in_array( sanitize_key( $field['field_type'] ?? '' ), array( 'select', 'radio', 'checkbox' ), true ) ) {
			return $this->option_label_for_value( $field, sanitize_text_field( (string) $value ) );
		}

		return sanitize_textarea_field( (string) $value );
	}

	/**
	 * Get label for option value.
	 *
	 * @param array<string,mixed> $field Field.
	 * @param string              $value Value.
	 * @return string
	 */
	private function option_label_for_value( array $field, $value ) {
		foreach ( $this->options( $field ) as $option ) {
			if ( $option['value'] === $value ) {
				return $option['label'];
			}
		}

		return sanitize_text_field( $value );
	}

	/**
	 * Get allowed option values.
	 *
	 * @param array<string,mixed> $field Field.
	 * @return array<int,string>
	 */
	private function allowed_option_values( array $field ) {
		return wp_list_pluck( $this->options( $field ), 'value' );
	}

	/**
	 * Get options.
	 *
	 * @param array<string,mixed> $field Field.
	 * @return array<int,array{label:string,value:string}>
	 */
	private function options( array $field ) {
		if ( empty( $field['field_options'] ) || ! is_string( $field['field_options'] ) ) {
			return array();
		}

		$decoded = json_decode( $field['field_options'], true );
		if ( ! is_array( $decoded ) ) {
			return array();
		}

		$options = array();
		foreach ( $decoded as $option ) {
			if ( ! is_array( $option ) ) {
				continue;
			}

			$label = sanitize_text_field( $option['label'] ?? '' );
			$value = sanitize_key( $option['value'] ?? $label );
			if ( '' !== $label && '' !== $value ) {
				$options[] = array( 'label' => $label, 'value' => $value );
			}
		}

		return $options;
	}

	/**
	 * Check empty value.
	 *
	 * @param mixed $value Value.
	 * @return bool
	 */
	private function is_empty_value( $value ) {
		return is_array( $value ) ? empty( $value ) : '' === trim( (string) $value );
	}

	/**
	 * Validate YYYY-MM-DD date values.
	 *
	 * @param string $value Date value.
	 * @return bool
	 */
	private function is_valid_date_value( $value ) {
		$value = sanitize_text_field( $value );
		if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
			return false;
		}

		$parts = explode( '-', $value );

		return checkdate( absint( $parts[1] ), absint( $parts[2] ), absint( $parts[0] ) );
	}

	/**
	 * Check sensitive key.
	 *
	 * @param string $key Field key.
	 * @return bool
	 */
	private function is_sensitive_key( $key ) {
		$normalized = strtolower( remove_accents( (string) $key ) );
		$normalized = trim( (string) preg_replace( '/[^a-z0-9]+/', '_', $normalized ), '_' );
		if ( '' === $normalized ) {
			return false;
		}

		$sensitive_tokens = array(
			'password',
			'passwd',
			'pass',
			'pwd',
			'passcode',
			'passphrase',
			'captcha',
			'recaptcha',
			'hcaptcha',
			'turnstile',
			'nonce',
			'token',
			'card',
			'cardholder',
			'cardnumber',
			'creditcard',
			'creditcardnumber',
			'cc',
			'cvv',
			'cvc',
			'secret',
		);

		foreach ( explode( '_', $normalized ) as $token ) {
			if ( in_array( $token, $sensitive_tokens, true ) ) {
				return true;
			}
		}

		return (bool) preg_match( '/(?:^|_)(?:api|access|private)_key(?:_|$)/', $normalized );
	}

	/**
	 * Check sensitive field key or label.
	 *
	 * @param string $key Field key.
	 * @param string $label Field label.
	 * @return bool
	 */
	private function is_sensitive_field( $key, $label ) {
		return $this->is_sensitive_key( $key ) || $this->is_sensitive_key( $label );
	}

	/**
	 * Match haystack against terms.
	 *
	 * @param string            $haystack Haystack.
	 * @param array<int,string> $terms Terms.
	 * @return bool
	 */
	private function matches_any( $haystack, array $terms ) {
		foreach ( $terms as $term ) {
			if ( false !== strpos( $haystack, $term ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get configured message.
	 *
	 * @param array<string,mixed> $messages Messages.
	 * @param string              $key Key.
	 * @param string              $fallback Fallback.
	 * @return string
	 */
	private function message( array $messages, $key, $fallback ) {
		return sanitize_textarea_field( $messages[ $key ] ?? $fallback );
	}

	/**
	 * Get required message with field-specific override support.
	 *
	 * @param array<string,mixed> $messages Messages.
	 * @param string              $field_key Field key.
	 * @return string
	 */
	private function required_message( array $messages, $field_key ) {
		$field_key = sanitize_key( $field_key );
		$field_messages = is_array( $messages['_field_required_messages'] ?? null ) ? $messages['_field_required_messages'] : array();

		if ( '' !== $field_key && ! empty( $field_messages[ $field_key ] ) ) {
			return sanitize_textarea_field( $field_messages[ $field_key ] );
		}

		return $this->message( $messages, 'required_field_error', __( 'This field is required.', 'submission-inbox' ) );
	}

	/**
	 * Extract field-specific required messages from saved GrapesJS project data.
	 *
	 * @param array<string,mixed> $settings Form settings.
	 * @return array<string,string>
	 */
	private function field_required_messages_from_settings( array $settings ) {
		$project  = $this->published_design_studio_project( $settings );
		$messages = array();

		$this->collect_field_required_messages( $project, $messages );

		return $messages;
	}

	/**
	 * Recursively collect controlled field required messages.
	 *
	 * @param mixed                $node Project node.
	 * @param array<string,string> $messages Messages by field key.
	 * @return void
	 */
	private function collect_field_required_messages( $node, array &$messages ) {
		if ( ! is_array( $node ) ) {
			return;
		}

		$attributes = is_array( $node['attributes'] ?? null ) ? $node['attributes'] : array();
		$subin_type   = sanitize_key( (string) ( $attributes['data-subin-component'] ?? '' ) );
		if ( 'field' === $subin_type && ! empty( $attributes['data-subin-required-message'] ) ) {
			$key = sanitize_key( $attributes['data-subin-field-key'] ?? '' );
			if ( '' !== $key ) {
				$messages[ $key ] = sanitize_textarea_field( $attributes['data-subin-required-message'] );
			}
		}

		foreach ( $node as $child ) {
			$this->collect_field_required_messages( $child, $messages );
		}
	}

	/**
	 * Get expiry date.
	 *
	 * @param array<string,mixed> $settings Settings.
	 * @return string|null
	 */
	private function get_expires_at( array $settings ) {
		$days = absint( $settings['retention_days'] ?? 90 );
		if ( $days <= 0 ) {
			return null;
		}

		return gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) + ( DAY_IN_SECONDS * $days ) );
	}

	/**
	 * Get current URL.
	 *
	 * @return string
	 */
	private function current_url() {
		$posted_source_url = isset( $_POST['subin_source_url'] ) ? esc_url_raw( wp_unslash( $_POST['subin_source_url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public source URL is metadata for an already nonce-protected submission.
		if ( '' !== $posted_source_url ) {
			return $posted_source_url;
		}

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

		return esc_url_raw( remove_query_arg( self::RESULT_QUERY_ARG, home_url( $request_uri ) ) );
	}

	/**
	 * Get referrer.
	 *
	 * @return string
	 */
	private function get_referrer() {
		$referrer = wp_get_referer();
		if ( $referrer ) {
			return $this->clean_submission_url( $referrer );
		}

		return isset( $_SERVER['HTTP_REFERER'] ) ? $this->clean_submission_url( sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) ) : '';
	}

	/**
	 * Remove transient form result parameters from stored submission URLs.
	 *
	 * @param string $url Raw URL.
	 * @return string
	 */
	private function clean_submission_url( $url ) {
		return esc_url_raw( remove_query_arg( self::RESULT_QUERY_ARG, (string) $url ) );
	}
}
