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

namespace SUBIN\Forms;

use SUBIN\AutoReplies\AutoReplySender;
use SUBIN\Leads\LeadFieldRepository;
use SUBIN\Leads\LeadRepository;
use SUBIN\Mail\EmailErrorClassifier;
use SUBIN\MailLog\MailEventRepository;

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

/**
 * 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 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';

	/**
	 * 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;

	/**
	 * 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;

	/**
	 * 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.
	 */
	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
	) {
		$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->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_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' ) );
	}

	/**
	 * 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';
		$_POST['subin_company_website'] = '';

		// 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 );
		delete_transient( $key );

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

		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' );
		}

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

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

		$validated = $this->validate_fields( $fields, $posted, $messages );
		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']
			);
		}

		$lead_id = 0;
		$settings_all = $this->settings->all();
		$lead_fields = $this->build_lead_fields( $fields, $validated['values'] );
		$detected = $this->detect_lead_values( $lead_fields );

		if ( ! empty( $settings_all['store_leads'] ) && ! empty( $form['save_leads'] ) ) {
			$lead_id = $this->leads->create(
				array(
					'provider'        => 'submission_inbox_form',
					'form_id'         => (string) $form_id,
					'form_name'       => $form['title'] ?? '',
					'source_url'      => $this->current_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'    => current_time( 'mysql' ),
					'expires_at'      => $this->get_expires_at( $settings_all ),
				)
			);

			if ( $lead_id > 0 ) {
				$this->lead_fields->insert_fields( $lead_id, $lead_fields );
			}
		}

		$notification_enabled = $this->can_generate_notification( $form );
		$auto_reply_enabled   = $this->auto_replies && $lead_id > 0 && $this->auto_replies->has_enabled_rule_for_form( $form );
		$email_status         = $notification_enabled ? LeadRepository::EMAIL_STATUS_UNKNOWN : LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED;
		if ( $lead_id > 0 ) {
			if ( $notification_enabled ) {
				$fast_result = $this->send_fast_notification( $form, $lead_id, $lead_fields, $detected, $this->current_url() );
				$email_status = $this->email_status_from_related_events( $lead_id, $fast_result['status'] );
				$this->leads->update_email_status( $lead_id, $email_status );

				if ( ! empty( $fast_result['queue_fallback'] ) ) {
					$this->schedule_notification( $lead_id, array(), self::EMAIL_TYPE_ADMIN_NOTIFICATION );
				}

				if ( $auto_reply_enabled ) {
					$this->schedule_notification( $lead_id, array(), self::EMAIL_TYPE_VISITOR_AUTO_REPLY );
					$this->register_auto_reply_shutdown_fallback( $lead_id );
				}
			} else {
				$this->leads->update_email_status( $lead_id, $email_status );
			}

			if ( ! $notification_enabled && $auto_reply_enabled ) {
				$this->schedule_notification( $lead_id, array(), self::EMAIL_TYPE_VISITOR_AUTO_REPLY );
				$this->register_auto_reply_shutdown_fallback( $lead_id );
			}
		} elseif ( $notification_enabled ) {
			$this->schedule_notification(
				0,
				array(
					'form_id'     => $form_id,
					'lead_fields' => $lead_fields,
					'detected'    => $detected,
					'source_url'  => $this->current_url(),
				)
			);
		}

		return 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(),
		);
	}

	/**
	 * Send a scheduled built-in form notification.
	 *
	 * @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;
		}

		$email_type = $this->sanitize_email_type( $email_type );
		delete_transient( self::NOTIFICATION_TOKEN_PREFIX . $this->notification_key_suffix( $lead_id, $email_type ) );
		$this->send_notification_with_lock( $lead_id, $email_type );
	}

	/**
	 * Send a scheduled notification for a non-stored submission payload.
	 *
	 * @param string $payload_token Payload token.
	 * @return void
	 */
	public function send_scheduled_payload_notification( $payload_token ) {
		$payload_token = sanitize_key( (string) $payload_token );
		if ( '' === $payload_token ) {
			return;
		}

		$lock_key = self::NOTIFICATION_LOCK_PREFIX . $payload_token;
		if ( get_transient( $lock_key ) ) {
			return;
		}

		set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS );

		try {
			$this->send_notification_for_payload( $payload_token );
		} catch ( \Throwable $throwable ) {
			unset( $throwable );
		} finally {
			delete_transient( $lock_key );
		}
	}

	/**
	 * Handle non-blocking background notification request.
	 *
	 * @return void
	 */
	public function handle_async_notification_request() {
		$lead_id = absint( wp_unslash( $_POST['lead_id'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Async callback is authorized by a per-lead token below.
		$token   = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Async callback is authorized by this per-lead 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 -- Async callback is authorized by a per-lead token below.
		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->send_notification_with_lock( $lead_id, $email_type );
		wp_die( '1', '', array( 'response' => 200 ) );
	}

	/**
	 * Send a saved lead notification with duplicate protection.
	 *
	 * @param int $lead_id Lead id.
	 * @param string $email_type Email type.
	 * @return void
	 */
	private function send_notification_with_lock( $lead_id, $email_type = self::EMAIL_TYPE_ADMIN_NOTIFICATION ) {
		$lead_id  = absint( $lead_id );
		$email_type = $this->sanitize_email_type( $email_type );
		$lock_key = self::NOTIFICATION_LOCK_PREFIX . $this->notification_key_suffix( $lead_id, $email_type );

		if ( $lead_id <= 0 || get_transient( $lock_key ) ) {
			return;
		}
		set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS );

		try {
			$this->send_notification_for_saved_lead( $lead_id, $email_type );
		} catch ( \Throwable $throwable ) {
			if ( self::EMAIL_TYPE_ADMIN_NOTIFICATION === $email_type ) {
				$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_FAILED );
			}
			unset( $throwable );
		} finally {
			delete_transient( $lock_key );
		}
	}

	/**
	 * Send notification for a saved lead.
	 *
	 * @param int $lead_id Lead id.
	 * @param string $email_type Email type.
	 * @return void
	 */
	private function send_notification_for_saved_lead( $lead_id, $email_type = self::EMAIL_TYPE_ADMIN_NOTIFICATION ) {
		$lead_id = absint( $lead_id );
		$email_type = $this->sanitize_email_type( $email_type );
		$lead    = $this->leads->get( $lead_id );
		if ( ! $lead || 'submission_inbox_form' !== sanitize_key( $lead->provider ?? '' ) ) {
			return;
		}

		$form = $this->forms->get( absint( $lead->form_id ) );
		if ( ! $form ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED );
			return;
		}

		$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 );
		$current_email_status = sanitize_key( $lead->email_status ?? '' );
		$already_sent         = LeadRepository::EMAIL_STATUS_SENT === $current_email_status;
		$already_failed       = LeadRepository::EMAIL_STATUS_FAILED === $current_email_status;

		if ( self::EMAIL_TYPE_VISITOR_AUTO_REPLY === $email_type ) {
			$this->send_visitor_auto_reply_for_lead( $form, $lead_id, $lead_fields, $detected, $settings );
			return;
		}

		$this->send_admin_notification_for_lead( $form, $lead_id, $lead_fields, $detected, $lead, $already_sent, $already_failed );
	}

	/**
	 * Send the admin notification email for a saved lead.
	 *
	 * @param array<string,mixed>  $form Form row.
	 * @param int                  $lead_id Lead id.
	 * @param array<int,array>     $lead_fields Lead fields loaded from storage.
	 * @param array<string,string> $detected Detected lead values.
	 * @param object               $lead Lead row.
	 * @param bool                 $already_sent Whether admin notification is already sent.
	 * @param bool                 $already_failed Whether admin notification already failed.
	 * @return void
	 */
	private function send_admin_notification_for_lead( array $form, $lead_id, array $lead_fields, array $detected, $lead, $already_sent, $already_failed ) {
		if ( $this->can_generate_notification( $form ) && ! $already_sent && ! $already_failed ) {
			$email_status = $this->send_notification( $form, $lead_id, $lead_fields, $detected, (string) $lead->source_url );
			$this->leads->update_email_status( $lead_id, $email_status );
			return;
		}

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

	/**
	 * Send the visitor auto-reply email for a saved lead.
	 *
	 * @param array<string,mixed>  $form Form row.
	 * @param int                  $lead_id Lead id.
	 * @param array<int,array>     $lead_fields Lead fields loaded from storage.
	 * @param array<string,string> $detected Detected lead values.
	 * @param array<string,mixed>  $settings Form settings.
	 * @return void
	 */
	private function send_visitor_auto_reply_for_lead( array $form, $lead_id, array $lead_fields, array $detected, array $settings ) {
		if ( ! $this->auto_replies ) {
			return;
		}

		if ( ! $this->auto_replies->has_enabled_rule_for_form( $form ) ) {
			return;
		}

		if ( $this->has_auto_reply_event( $lead_id ) ) {
			return;
		}

		$this->auto_replies->send_for_submission( $form, $lead_id, $lead_fields, $detected, $settings );
	}

	/**
	 * Send notification for a submission that was not stored as a lead.
	 *
	 * @param string $payload_token Payload token.
	 * @return void
	 */
	private function send_notification_for_payload( $payload_token ) {
		if ( '' === $payload_token ) {
			return;
		}

		$key     = self::NOTIFICATION_PAYLOAD_PREFIX . $payload_token;
		$payload = get_transient( $key );
		delete_transient( $key );

		if ( ! is_array( $payload ) ) {
			return;
		}

		$form = $this->forms->get( absint( $payload['form_id'] ?? 0 ) );
		if ( ! $form || ! $this->can_generate_notification( $form ) ) {
			return;
		}

		$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'] ?? '' );

		$this->send_notification( $form, 0, $lead_fields, $this->sanitize_detected_values( $detected ), $source_url );
	}

	/**
	 * Validate submitted fields.
	 *
	 * @param array<int,array<string,mixed>> $fields Fields.
	 * @param array<string,mixed>            $posted Posted values.
	 * @param array<string,mixed>            $messages Messages.
	 * @return array{values:array<string,mixed>,errors:array<string,string>}
	 */
	private function validate_fields( array $fields, array $posted, array $messages ) {
		$values = array();
		$errors = array();

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

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

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

			$raw = $posted[ $key ] ?? '';
			$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;
			}

			if ( 'email' === $type && '' !== $value && ! is_email( $value ) ) {
				$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' ), true ) && $this->has_invalid_choice( $field, $raw ) ) {
				$errors[ $key ] = __( 'Please choose a valid option.', 'submission-inbox' );
			}
		}

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

	/**
	 * 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 ! empty( $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 ! empty( $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 : '' );
	}

	/**
	 * 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 ) ) {
			return false;
		}

		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;
	}

	/**
	 * Schedule background notification sending.
	 *
	 * @param int                 $lead_id Lead id.
	 * @param array<string,mixed> $payload Optional non-stored submission payload.
	 * @param string              $email_type Email type.
	 * @return void
	 */
	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 );
		$token   = '';

		if ( $lead_id <= 0 ) {
			$token = wp_generate_password( 20, false, false );
			set_transient( self::NOTIFICATION_PAYLOAD_PREFIX . $token, $this->sanitize_notification_payload( $payload ), HOUR_IN_SECONDS );
			$args = array( $token );
			if ( ! wp_next_scheduled( self::NOTIFICATION_PAYLOAD_HOOK, $args ) ) {
				wp_schedule_single_event( time(), self::NOTIFICATION_PAYLOAD_HOOK, $args );
			}
			return;
		}

		$args = array( $lead_id, $email_type );
		$already_scheduled = wp_next_scheduled( self::NOTIFICATION_HOOK, $args );
		if ( ! wp_next_scheduled( self::NOTIFICATION_HOOK, $args ) ) {
			wp_schedule_single_event( time(), self::NOTIFICATION_HOOK, $args );
			if ( function_exists( 'spawn_cron' ) ) {
				spawn_cron( time() );
			}
		}
		if ( $lead_id > 0 ) {
			$this->trigger_non_blocking_notification( $lead_id, $email_type );
		}
	}

	/**
	 * Trigger notification sending without blocking the visitor request.
	 *
	 * @param int $lead_id Lead id.
	 * @param string $email_type Email type.
	 * @return void
	 */
	private function trigger_non_blocking_notification( $lead_id, $email_type = self::EMAIL_TYPE_ADMIN_NOTIFICATION ) {
		$lead_id = absint( $lead_id );
		$email_type = $this->sanitize_email_type( $email_type );
		if ( $lead_id <= 0 ) {
			return;
		}

		$token = wp_generate_password( 32, false, false );
		set_transient( self::NOTIFICATION_TOKEN_PREFIX . $this->notification_key_suffix( $lead_id, $email_type ), $token, 10 * MINUTE_IN_SECONDS );
		wp_remote_post(
			admin_url( 'admin-ajax.php' ),
			array(
				'timeout'  => 0.5,
				'blocking' => false,
				'body'     => array(
					'action'  => self::NOTIFICATION_AJAX_ACTION,
					'lead_id' => $lead_id,
					'email_type' => $email_type,
					'token'   => $token,
				),
			)
		);
	}

	/**
	 * 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'] ?? '' ),
		);
	}

	/**
	 * 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 ?? '' ) ) {
				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'] ) . '>';
		}

		if ( $lead_id > 0 ) {
			$this->context->set_current_lead_id( $lead_id );
			$this->context->set_current_source( 'submission_inbox_form' );
		}

		$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, $this->notification_body( $form, $lead_id, $lead_fields, $source_url ), $headers );
		} catch ( \Throwable $throwable ) {
			$error_message = $this->sanitize_mail_error( $throwable->getMessage() );
			$this->record_failed_notification_event( $lead_id, $recipient, $subject, $error_message );
			$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 ) {
		if ( ! $this->mail_events ) {
			return;
		}

		$event_id = $this->mail_events->create_pending(
			array(
				'lead_id'      => absint( $lead_id ),
				'source'       => 'submission_inbox_form',
				'to_email'     => sanitize_email( $recipient ),
				'from_email'   => $this->logged_from_email(),
				'subject'      => sanitize_text_field( $subject ),
				'message_hash' => '',
			)
		);

		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();
		$message  = wp_strip_all_tags( (string) $message );

		foreach ( array( 'smtp_password', 'smtp_username' ) as $key ) {
			if ( ! empty( $settings[ $key ] ) ) {
				$message = str_replace( (string) $settings[ $key ], '[redacted]', $message );
			}
		}

		return sanitize_text_field( $message );
	}

	/**
	 * 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 = '' ) {
		$form_name = sanitize_text_field( $form['title'] ?? '' );
		$source_url = '' !== $source_url ? esc_url_raw( $source_url ) : $this->current_url();
		$lead_url = $lead_id > 0 ? admin_url( 'admin.php?page=subin-inbox&action=view&lead_id=' . absint( $lead_id ) ) : '';

		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( current_time( 'mysql' ) ); ?></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 ) {
		return (bool) preg_match( '/(password|pass|pwd|captcha|recaptcha|h-captcha|turnstile|nonce|token|card|credit_card|cc|cvv|secret|api_key|access_key|private_key)/i', $key );
	}

	/**
	 * 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 = is_array( $settings['design_studio']['grapesjs_project'] ?? null ) ? $settings['design_studio']['grapesjs_project'] : array();
		$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 ) );
	}
}
