<?php
/**
 * Ninja Forms integration.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

use SUBIN\Leads\LeadFieldRepository;
use SUBIN\Leads\LeadRepository;
use SUBIN\MailLog\MailEventRepository;
use SUBIN\AutoReplies\AutoReplyRuleSender;

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

/**
 * Captures successful Ninja Forms submissions as leads.
 */
class NinjaFormsIntegration {
	/**
	 * Metadata field key used for duplicate checks.
	 */
	const ENTRY_ID_FIELD_KEY = '_ninja_forms_submission_id';

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

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

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

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

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

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

	/**
	 * Field data captured before Ninja Forms saves the entry.
	 *
	 * @var array<string,array<string,mixed>>
	 */
	private $pending_submissions = array();

	/**
	 * Timestamp captured while Ninja Forms is processing.
	 *
	 * @var string
	 */
	private $submission_started_at = '';

	/**
	 * Constructor.
	 *
	 * @param \SUBIN_SettingsRepository $settings Settings repository.
	 * @param LeadRepository          $leads Lead repository.
	 * @param LeadFieldRepository     $fields Lead field repository.
	 * @param MailEventRepository     $mail_events Mail event repository.
	 * @param FormSubmissionContext   $context Submission context.
	 * @param AutoReplyRuleSender|null $auto_replies Auto-reply sender.
	 */
	public function __construct(
		\SUBIN_SettingsRepository $settings,
		LeadRepository $leads,
		LeadFieldRepository $fields,
		MailEventRepository $mail_events,
		FormSubmissionContext $context,
		?AutoReplyRuleSender $auto_replies = null
	) {
		$this->settings    = $settings;
		$this->leads       = $leads;
		$this->fields      = $fields;
		$this->mail_events = $mail_events;
		$this->context     = $context;
		$this->auto_replies = $auto_replies;
	}

	/**
	 * Register Ninja Forms hooks only when Ninja Forms is available.
	 *
	 * @return void
	 */
	public function register() {
		if ( ! self::is_available() ) {
			return;
		}

		add_filter( 'ninja_forms_submit_data', array( $this, 'prepare_submission_context' ), 5, 1 );
		add_action( 'ninja_forms_after_submission', array( $this, 'handle_after_submission' ), 10, 1 );
	}

	/**
	 * Detect Ninja Forms availability without referencing plugin classes eagerly.
	 *
	 * @return bool
	 */
	public static function is_available() {
		return defined( 'NINJA_FORMS_VERSION' ) || class_exists( 'Ninja_Forms' ) || function_exists( 'Ninja_Forms' ) || class_exists( 'NF_Database_Models_Form' );
	}

	/**
	 * Whether Ninja Forms compatibility is enabled.
	 *
	 * @param array<string,mixed>|null $settings Optional settings.
	 * @return bool
	 */
	private function is_enabled( $settings = null ) {
		$settings = is_array( $settings ) ? $settings : $this->settings->all();

		return ! empty( $settings['enable_ninja_forms_integration'] );
	}

	/**
	 * Capture submitted fields and set source context before notifications may be sent.
	 *
	 * @param array<string,mixed> $form_data Ninja Forms submission data.
	 * @return array<string,mixed>
	 */
	public function prepare_submission_context( $form_data = array() ) {
		$settings = $this->settings->all();

		if ( empty( $settings['store_leads'] ) || ! self::is_available() || ! is_array( $form_data ) ) {
			return $form_data;
		}

		if ( ! $this->is_enabled( $settings ) ) {
			$this->context->suppress_mail_logging();
			add_action( 'shutdown', array( $this->context, 'clear' ), PHP_INT_MAX );
			return $form_data;
		}

		$form_id  = $this->resolve_form_id( $form_data );
		$entry_id = $this->resolve_entry_id( $form_data );
		$fields   = $this->fields_from_form_data( $form_data );

		$this->submission_started_at = current_time( 'mysql' );
		$this->context->set_current_source( 'ninja_forms' );

		$payload = array(
			'entry'      => $form_data,
			'form_id'    => $form_id,
			'entry_id'   => $entry_id,
			'fields'     => $fields,
			'source_url' => $this->get_source_url( $fields ),
			'referrer'   => $this->get_referrer(),
			'started_at' => $this->submission_started_at,
		);

		$this->pending_submissions[ $this->submission_key( $form_id, $entry_id ) ] = $payload;
		if ( $form_id > 0 ) {
			$this->pending_submissions[ 'form:' . $form_id ] = $payload;
		}

		return $form_data;
	}

	/**
	 * Capture a successful Ninja Forms submission after Ninja Forms has processed it.
	 *
	 * @param array<string,mixed> $form_data Ninja Forms submission data.
	 * @return void
	 */
	public function handle_after_submission( $form_data = array() ) {
		$settings = $this->settings->all();

		if ( empty( $settings['store_leads'] ) || ! self::is_available() || ! is_array( $form_data ) ) {
			$this->context->clear();
			return;
		}

		if ( ! $this->is_enabled( $settings ) ) {
			add_action( 'shutdown', array( $this->context, 'clear' ), PHP_INT_MAX );
			return;
		}

		if ( $this->is_failed_response( $form_data ) ) {
			$this->context->clear();
			return;
		}

		$form_id     = $this->resolve_form_id( $form_data );
		$entry_id    = $this->resolve_entry_id( $form_data );
		$pending     = $this->find_pending_submission( $form_id, $entry_id );
		$field_data  = $this->resolve_field_data( $form_data, $pending );
		$form_title  = $this->get_form_title( $form_data, $form_id );
		$field_meta  = $this->field_meta_from_form_data( $form_data );
		$saved_fields = $this->prepare_fields( $field_data, $field_meta, $entry_id, ! empty( $settings['mask_sensitive_fields'] ) );

		if ( empty( $saved_fields ) ) {
			$this->context->clear();
			return;
		}

		if ( '' !== $entry_id ) {
			$existing = $this->leads->find_by_provider_external_entry_id( 'ninja_forms', $entry_id, self::ENTRY_ID_FIELD_KEY );
			if ( $existing ) {
				$this->link_existing_lead( absint( $existing->id ?? 0 ) );
				return;
			}
		} elseif ( ! $this->claim_fallback_duplicate_lock( $form_id, $saved_fields ) ) {
			$this->context->clear();
			return;
		}

		$detected = $this->detect_lead_values( $saved_fields );
		$lead_id  = $this->leads->create(
			array(
				'provider'        => 'ninja_forms',
				'form_id'         => $form_id > 0 ? (string) $form_id : '',
				'form_name'       => $form_title,
				'source_url'      => $pending['source_url'] ?? $this->get_source_url( $field_data ),
				'referrer'        => $pending['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 ),
			)
		);

		if ( $lead_id <= 0 ) {
			$this->context->clear();
			return;
		}

		$this->fields->insert_fields( $lead_id, $saved_fields );
		$this->context->set_current_lead_id( $lead_id );
		$this->context->set_current_source( 'ninja_forms' );
		$this->mail_events->attach_recent_unlinked_events( $lead_id, 'ninja_forms', $this->get_event_link_window(), $this->event_recipient_candidates( $detected ) );
		$this->sync_lead_email_status( $lead_id );
		if ( $this->auto_replies ) {
			$this->auto_replies->send_for_lead(
				array(
					'id'             => $lead_id,
					'provider'       => 'ninja_forms',
					'form_id'        => $form_id > 0 ? (string) $form_id : '',
					'form_name'      => $form_title,
					'customer_email' => $detected['customer_email'],
				),
				$saved_fields
			);
		}

		add_action( 'shutdown', array( $this->context, 'clear' ), PHP_INT_MAX );
	}

	/**
	 * Link a duplicate hook call to the existing lead when possible.
	 *
	 * @param int $lead_id Lead id.
	 * @return void
	 */
	private function link_existing_lead( $lead_id ) {
		if ( $lead_id <= 0 ) {
			$this->context->clear();
			return;
		}

		$this->context->set_current_lead_id( $lead_id );
		$this->context->set_current_source( 'ninja_forms' );
		$this->mail_events->attach_recent_unlinked_events( $lead_id, 'ninja_forms', $this->get_event_link_window() );
		$this->sync_lead_email_status( $lead_id );
		add_action( 'shutdown', array( $this->context, 'clear' ), PHP_INT_MAX );
	}

	/**
	 * Prepare submitted Ninja Forms fields for storage.
	 *
	 * @param array<int|string,mixed> $field_data_array Submitted field data.
	 * @param array<string,array>     $field_meta Field metadata keyed by field name.
	 * @param string                  $entry_id Ninja Forms entry id.
	 * @param bool                    $mask_sensitive_fields Whether to mask questionable fields.
	 * @return array<int,array<string,mixed>>
	 */
	private function prepare_fields( array $field_data_array, array $field_meta, $entry_id, $mask_sensitive_fields ) {
		$fields = array();

		foreach ( $this->field_items_from_data( $field_data_array ) as $item ) {
			$field_key = $this->field_key_from_item( $item );
			$meta      = $field_meta[ $field_key ] ?? array();
			$type      = sanitize_key( $item['type'] ?? $item['field_type'] ?? $item['settings']['type'] ?? $meta['type'] ?? '' );
			$label     = sanitize_text_field( $item['label'] ?? $item['field_label'] ?? $item['settings']['label'] ?? $meta['label'] ?? $this->fields->label_from_key( $field_key ) );
			$value     = $this->value_from_item( $item );

			if ( '' === $type ) {
				$type = $this->field_type_from_key_label_value( $field_key, $label, $value );
			}

			if ( '' === $field_key || $this->should_skip_field( $field_key, $type, $label, $value ) ) {
				continue;
			}

			$is_sensitive = $this->is_questionable_sensitive_field( $field_key, $label, $type );
			$value        = $this->normalize_field_value( $value, $type );

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

			if ( $is_sensitive && $mask_sensitive_fields ) {
				$value = '[masked]';
			}

			$fields[] = array(
				'field_key'    => $field_key,
				'field_label'  => $label,
				'field_value'  => $value,
				'is_sensitive' => $is_sensitive ? 1 : 0,
				'field_type'   => $type,
			);
		}

		if ( '' !== $entry_id ) {
			$fields[] = array(
				'field_key'    => self::ENTRY_ID_FIELD_KEY,
				'field_label'  => __( 'Ninja Forms submission ID', 'submission-inbox' ),
				'field_value'  => $entry_id,
				'is_sensitive' => 1,
				'field_type'   => 'metadata',
			);
		}

		return $fields;
	}

	/**
	 * Convert Ninja Forms field data into a uniform list of field items.
	 *
	 * @param array<int|string,mixed> $field_data_array Submitted field data.
	 * @return array<int,array<string,mixed>>
	 */
	private function field_items_from_data( array $field_data_array ) {
		$items = array();

		foreach ( $field_data_array as $key => $field ) {
			if ( is_array( $field ) ) {
				$item = $field;
				if ( empty( $item['name'] ) && ! empty( $key ) && ! is_int( $key ) ) {
					$item['name'] = (string) $key;
				}
				$items[] = $item;
				continue;
			}

			if ( is_scalar( $field ) ) {
				$items[] = array(
					'name'  => (string) $key,
					'value' => $field,
				);
			}
		}

		return $items;
	}

	/**
	 * Read a Ninja Forms field key from a field item.
	 *
	 * @param array<string,mixed> $item Field item.
	 * @return string
	 */
	private function field_key_from_item( array $item ) {
		foreach ( array( 'key', 'name', 'field_key', 'field_name', 'slug', 'id' ) as $key ) {
			if ( ! empty( $item[ $key ] ) && is_scalar( $item[ $key ] ) ) {
				return sanitize_key( (string) $item[ $key ] );
			}
		}

		return '';
	}

	/**
	 * Read a Ninja Forms field value from a field item.
	 *
	 * @param array<string,mixed> $item Field item.
	 * @return mixed
	 */
	private function value_from_item( array $item ) {
		foreach ( array( 'value', 'field_value', 'raw_value' ) as $key ) {
			if ( array_key_exists( $key, $item ) ) {
				return $item[ $key ];
			}
		}

		return '';
	}

	/**
	 * Determine whether a field must not be stored.
	 *
	 * @param string $key Field key.
	 * @param string $type Field type.
	 * @param string $label Field label.
	 * @param mixed  $value Field value.
	 * @return bool
	 */
	private function should_skip_field( $key, $type, $label, $value ) {
		$text = strtolower( $key . ' ' . $type . ' ' . $label );

		foreach ( array( 'captcha', 'recaptcha', 'hcaptcha', 'h-captcha', 'turnstile', 'honeypot', 'nonce', 'security', 'csrf', 'token', 'password', 'credit', 'card', 'payment', 'stripe', 'paypal', 'signature', 'file', 'upload', 'hidden', '_wpnonce', '_ninja_forms', 'ninja_forms_nonce', 'ninja_forms_user_ip', 'nf_resume' ) as $needle ) {
			if ( false !== strpos( $text, $needle ) ) {
				return true;
			}
		}

		if ( in_array( sanitize_key( $key ), array( 'source_url', 'page_url', 'referer', 'referrer', '_wp_http_referer' ), true ) ) {
			return true;
		}

		if ( is_array( $value ) ) {
			foreach ( array( 'tmp_name', 'error', 'size', 'path' ) as $file_key ) {
				if ( array_key_exists( $file_key, $value ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Detect questionable sensitive fields that may be masked.
	 *
	 * @param string $key Field key.
	 * @param string $label Field label.
	 * @param string $type Field type.
	 * @return bool
	 */
	private function is_questionable_sensitive_field( $key, $label, $type ) {
		$text = strtolower( $key . ' ' . $label . ' ' . $type );

		foreach ( array( 'secret', 'private', 'sensitive' ) as $needle ) {
			if ( false !== strpos( $text, $needle ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Normalize a Ninja Forms field value.
	 *
	 * @param mixed  $value Field value.
	 * @param string $type Field type.
	 * @return string
	 */
	private function normalize_field_value( $value, $type = '' ) {
		if ( is_object( $value ) ) {
			return '';
		}

		if ( is_array( $value ) ) {
			$flat = array();
			array_walk_recursive(
				$value,
				static function ( $item ) use ( &$flat ) {
					if ( is_scalar( $item ) ) {
						$item = sanitize_text_field( wp_unslash( (string) $item ) );
						if ( '' !== $item ) {
							$flat[] = $item;
						}
					}
				}
			);
			$value = implode( ', ', array_filter( $flat, 'strlen' ) );
		} elseif ( in_array( sanitize_key( $type ), array( 'textarea', 'message' ), true ) ) {
			$value = sanitize_textarea_field( wp_unslash( (string) $value ) );
		} else {
			$value = sanitize_text_field( wp_unslash( (string) $value ) );
		}

		return trim( wp_html_excerpt( (string) $value, 2000, '...' ) );
	}

	/**
	 * Detect lead summary values.
	 *
	 * @param array<int,array<string,mixed>> $fields Saved fields.
	 * @return array{customer_name:string,customer_email:string,customer_phone:string,message_preview:string}
	 */
	private function detect_lead_values( array $fields ) {
		return array(
			'customer_name'   => $this->detect_customer_name( $fields ),
			'customer_email'  => $this->detect_email( $fields ),
			'customer_phone'  => $this->detect_by_type_or_label( $fields, array( 'phone', 'tel' ), array( 'phone', 'mobile', 'tel' ) ),
			'message_preview' => $this->detect_message_preview( $fields ),
		);
	}

	/**
	 * Detect a customer name, combining first and last name fields when present.
	 *
	 * @param array<int,array<string,mixed>> $fields Saved fields.
	 * @return string
	 */
	private function detect_customer_name( array $fields ) {
		$name = $this->detect_by_type_or_label( $fields, array( 'name' ), array( 'full name', 'your name' ) );
		if ( '' !== $name ) {
			return $name;
		}

		$first = $this->detect_by_type_or_label( $fields, array( 'firstname', 'first_name' ), array( 'first name', 'firstname' ) );
		$last  = $this->detect_by_type_or_label( $fields, array( 'lastname', 'last_name' ), array( 'last name', 'lastname' ) );
		$full  = trim( $first . ' ' . $last );

		return '' !== $full ? $full : $this->detect_by_type_or_label( $fields, array( 'name' ), array( 'name' ) );
	}

	/**
	 * Detect first valid email.
	 *
	 * @param array<int,array<string,mixed>> $fields Saved fields.
	 * @return string
	 */
	private function detect_email( array $fields ) {
		$email = $this->detect_by_type_or_label( $fields, array( 'email' ), array( 'email', 'e-mail' ) );
		if ( is_email( $email ) ) {
			return $email;
		}

		foreach ( $fields as $field ) {
			$value = (string) ( $field['field_value'] ?? '' );
			if ( is_email( $value ) ) {
				return $value;
			}
		}

		return '';
	}

	/**
	 * Detect a value by field type or label fragments.
	 *
	 * @param array<int,array<string,mixed>> $fields Saved fields.
	 * @param array<int,string>             $types Preferred field types.
	 * @param array<int,string>             $label_needles Label fragments.
	 * @return string
	 */
	private function detect_by_type_or_label( array $fields, array $types, array $label_needles ) {
		foreach ( $fields as $field ) {
			if ( in_array( sanitize_key( $field['field_type'] ?? '' ), $types, true ) ) {
				return (string) $field['field_value'];
			}
		}

		foreach ( $fields as $field ) {
			$haystack = strtolower( (string) ( $field['field_label'] ?? '' ) . ' ' . (string) ( $field['field_key'] ?? '' ) );
			foreach ( $label_needles as $needle ) {
				if ( false !== strpos( $haystack, $needle ) ) {
					return (string) $field['field_value'];
				}
			}
		}

		return '';
	}

	/**
	 * Detect message preview.
	 *
	 * @param array<int,array<string,mixed>> $fields Saved fields.
	 * @return string
	 */
	private function detect_message_preview( array $fields ) {
		foreach ( $fields as $field ) {
			$type     = sanitize_key( $field['field_type'] ?? '' );
			$haystack = strtolower( (string) ( $field['field_label'] ?? '' ) . ' ' . (string) ( $field['field_key'] ?? '' ) );
			if ( 'textarea' === $type || false !== strpos( $haystack, 'message' ) || false !== strpos( $haystack, 'comment' ) || false !== strpos( $haystack, 'details' ) || false !== strpos( $haystack, 'enquiry' ) || false !== strpos( $haystack, 'inquiry' ) ) {
				return wp_trim_words( (string) $field['field_value'], 35, '...' );
			}
		}

		foreach ( $fields as $field ) {
			$value = (string) $field['field_value'];
			if ( strlen( $value ) > 40 ) {
				return wp_trim_words( $value, 35, '...' );
			}
		}

		return '';
	}

	/**
	 * Infer a field type from key/label/value.
	 *
	 * @param string $key Field key.
	 * @param string $label Field label.
	 * @param mixed  $value Field value.
	 * @return string
	 */
	private function field_type_from_key_label_value( $key, $label, $value ) {
		$text = strtolower( $key . ' ' . $label );

		if ( false !== strpos( $text, 'email' ) || false !== strpos( $text, 'e-mail' ) ) {
			return 'email';
		}
		if ( false !== strpos( $text, 'phone' ) || false !== strpos( $text, 'mobile' ) || false !== strpos( $text, 'tel' ) ) {
			return 'phone';
		}
		if ( false !== strpos( $text, 'message' ) || false !== strpos( $text, 'comment' ) || false !== strpos( $text, 'details' ) ) {
			return 'textarea';
		}
		if ( false !== strpos( $text, 'url' ) || false !== strpos( $text, 'website' ) ) {
			return 'url';
		}
		if ( false !== strpos( $text, 'date' ) || false !== strpos( $text, 'time' ) ) {
			return 'date';
		}
		if ( false !== strpos( $text, 'name' ) ) {
			return 'name';
		}
		if ( is_array( $value ) ) {
			return 'checkbox';
		}
		if ( is_numeric( $value ) ) {
			return 'number';
		}

		return 'text';
	}

	/**
	 * Get Ninja Forms field labels/types from submitted form data.
	 *
	 * @param array<string,mixed> $form_data Ninja Forms submission data.
	 * @return array<string,array{label:string,type:string}>
	 */
	private function field_meta_from_form_data( array $form_data ) {
		$meta  = array();

		foreach ( $this->fields_from_form_data( $form_data ) as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$key   = sanitize_key( $field['key'] ?? $field['name'] ?? $field['field_key'] ?? $field['id'] ?? '' );
			$label = sanitize_text_field( $field['label'] ?? $field['field_label'] ?? $field['settings']['label'] ?? $field['title'] ?? '' );
			$type  = sanitize_key( $field['type'] ?? $field['field_type'] ?? $field['settings']['type'] ?? '' );

			if ( '' !== $key ) {
				$meta[ $key ] = array(
					'label' => '' !== $label ? $label : $this->fields->label_from_key( $key ),
					'type'  => $type,
				);
			}
		}

		return $meta;
	}

	/**
	 * Extract submitted fields from common Ninja Forms data shapes.
	 *
	 * @param array<string,mixed> $form_data Ninja Forms submission data.
	 * @return array<int,mixed>
	 */
	private function fields_from_form_data( array $form_data ) {
		$candidates = array(
			$form_data['fields'] ?? null,
			$form_data['field_data'] ?? null,
			$form_data['extra']['fields'] ?? null,
		);

		$fields = array();
		foreach ( $candidates as $candidate ) {
			if ( is_string( $candidate ) ) {
				$decoded   = json_decode( $candidate, true );
				$candidate = is_array( $decoded ) ? $decoded : array();
			}
			if ( is_array( $candidate ) ) {
				foreach ( $candidate as $field ) {
					$fields[] = $field;
				}
			}
		}

		return $fields;
	}

	/**
	 * Resolve Ninja Forms form title.
	 *
	 * @param array<string,mixed> $form_data Ninja Forms submission data.
	 * @param int $form_id Form id.
	 * @return string
	 */
	private function get_form_title( array $form_data, $form_id ) {
		$title = $form_data['settings']['title'] ?? $form_data['settings']['form_title'] ?? $form_data['title'] ?? $form_data['form_title'] ?? '';

		$title = sanitize_text_field( $title );
		if ( '' !== $title ) {
			return $title;
		}

		/* translators: %d: Ninja Forms form ID. */
		return $form_id > 0 ? sprintf( __( 'Ninja Forms form #%d', 'submission-inbox' ), $form_id ) : __( 'Ninja Forms form', 'submission-inbox' );
	}

	/**
	 * Resolve form id from flexible hook arguments.
	 *
	 * @param mixed $entry Entry/form data.
	 * @param mixed $form_id Form id/response.
	 * @param mixed $field_data_array Field data.
	 * @return int
	 */
	private function resolve_form_id( $entry, $form_id = 0, $field_data_array = array() ) {
		if ( is_numeric( $form_id ) ) {
			$form_id = absint( $form_id );
			if ( $form_id > 0 ) {
				return $form_id;
			}
		}

		if ( is_numeric( $entry ) ) {
			$form_id = absint( $entry );
			if ( $form_id > 0 ) {
				return $form_id;
			}
		}

		foreach ( array( $form_id, $entry, $field_data_array ) as $candidate ) {
			$id = $this->extract_numeric_value( $candidate, array( 'form_id', 'formId', 'form-id', 'id' ) );
			if ( $id > 0 ) {
				return $id;
			}
		}

		return 0;
	}

	/**
	 * Resolve entry id from flexible hook arguments.
	 *
	 * @param mixed $entry Entry/form data.
	 * @param mixed $fallback Fallback argument.
	 * @return string
	 */
	private function resolve_entry_id( $entry, $fallback = null ) {
		foreach ( array( $entry, $fallback ) as $candidate ) {
			if ( is_array( $candidate ) ) {
				foreach ( array( $candidate['actions']['save']['sub_id'] ?? null, $candidate['actions']['save']['submission_id'] ?? null, $candidate['extra']['submission_id'] ?? null, $candidate['extra']['entry_id'] ?? null ) as $nested_id ) {
					if ( is_numeric( $nested_id ) ) {
						return (string) absint( $nested_id );
					}
				}
			}
			$id = $this->extract_keyed_numeric_value( $candidate, array( 'entry_id', 'entryId', 'entry-id', 'id' ) );
			if ( $id > 0 ) {
				return (string) $id;
			}
		}

		return '';
	}

	/**
	 * Extract a keyed numeric id from arrays/objects.
	 *
	 * @param mixed             $candidate Candidate value.
	 * @param array<int,string> $keys Candidate keys.
	 * @return int
	 */
	private function extract_keyed_numeric_value( $candidate, array $keys ) {
		foreach ( $keys as $key ) {
			if ( is_array( $candidate ) && isset( $candidate[ $key ] ) && is_numeric( $candidate[ $key ] ) ) {
				return absint( $candidate[ $key ] );
			}
			if ( is_object( $candidate ) && isset( $candidate->{$key} ) && is_numeric( $candidate->{$key} ) ) {
				return absint( $candidate->{$key} );
			}
		}

		return 0;
	}

	/**
	 * Extract a numeric id from arrays/objects/scalars.
	 *
	 * @param mixed             $candidate Candidate value.
	 * @param array<int,string> $keys Candidate keys.
	 * @return int
	 */
	private function extract_numeric_value( $candidate, array $keys ) {
		if ( is_numeric( $candidate ) ) {
			return absint( $candidate );
		}

		foreach ( $keys as $key ) {
			if ( is_array( $candidate ) && isset( $candidate[ $key ] ) && is_numeric( $candidate[ $key ] ) ) {
				return absint( $candidate[ $key ] );
			}
			if ( is_object( $candidate ) && isset( $candidate->{$key} ) && is_numeric( $candidate->{$key} ) ) {
				return absint( $candidate->{$key} );
			}
		}

		return 0;
	}

	/**
	 * Resolve field data from flexible hook arguments and pending context.
	 *
	 * @param mixed                   $entry Entry/form data.
	 * @param mixed                   $form_id Form id/response.
	 * @param array<int|string,mixed> $field_data_array Field data argument.
	 * @param array<string,mixed>     $pending Pending payload.
	 * @return array<int|string,mixed>
	 */
	private function resolve_field_data( $form_data, array $pending ) {
		if ( ! empty( $pending['fields'] ) && is_array( $pending['fields'] ) ) {
			return $pending['fields'];
		}
		if ( is_array( $form_data ) ) {
			return $this->fields_from_form_data( $form_data );
		}

		return array();
	}

	/**
	 * Detect an unsuccessful response shape defensively.
	 *
	 * @param mixed $response Hook response candidate.
	 * @return bool
	 */
	private function is_failed_response( $response ) {
		if ( is_array( $response ) ) {
			if ( array_key_exists( 'success', $response ) && false === rest_sanitize_boolean( $response['success'] ) ) {
				return true;
			}
			if ( ! empty( $response['status'] ) && in_array( sanitize_key( $response['status'] ), array( 'error', 'failed', 'invalid' ), true ) ) {
				return true;
			}
		}

		if ( is_object( $response ) ) {
			if ( isset( $response->success ) && false === rest_sanitize_boolean( $response->success ) ) {
				return true;
			}
			if ( ! empty( $response->status ) && in_array( sanitize_key( $response->status ), array( 'error', 'failed', 'invalid' ), true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Find pending submission context.
	 *
	 * @param int    $form_id Form id.
	 * @param string $entry_id Entry id.
	 * @return array<string,mixed>
	 */
	private function find_pending_submission( $form_id, $entry_id ) {
		$keys = array( $this->submission_key( $form_id, $entry_id ), 'form:' . absint( $form_id ) );

		foreach ( $keys as $key ) {
			if ( isset( $this->pending_submissions[ $key ] ) && is_array( $this->pending_submissions[ $key ] ) ) {
				return $this->pending_submissions[ $key ];
			}
		}

		return array();
	}

	/**
	 * Build pending submission key.
	 *
	 * @param int    $form_id Form id.
	 * @param string $entry_id Entry id.
	 * @return string
	 */
	private function submission_key( $form_id, $entry_id ) {
		return absint( $form_id ) . ':' . sanitize_text_field( (string) $entry_id );
	}

	/**
	 * Claim a short fallback duplicate lock when Ninja Forms entry id is unavailable.
	 *
	 * @param int                             $form_id Form id.
	 * @param array<int,array<string,mixed>> $fields Saved fields.
	 * @return bool
	 */
	private function claim_fallback_duplicate_lock( $form_id, array $fields ) {
		$hash_source = (string) absint( $form_id );
		foreach ( $fields as $field ) {
			$key = sanitize_key( $field['field_key'] ?? '' );
			if ( in_array( $key, array( self::ENTRY_ID_FIELD_KEY ), true ) ) {
				continue;
			}
			$hash_source .= '|' . $key . ':' . (string) ( $field['field_value'] ?? '' );
		}

		$transient_key = 'subin_ninja_forms_' . md5( $hash_source );
		if ( get_transient( $transient_key ) ) {
			return false;
		}

		set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS );
		return true;
	}

	/**
	 * Get source URL from field data or current request.
	 *
	 * @param array<int|string,mixed> $field_data_array Submitted field data.
	 * @return string
	 */
	private function get_source_url( array $field_data_array ) {
		foreach ( $this->field_items_from_data( $field_data_array ) as $item ) {
			$key = $this->field_key_from_item( $item );
			if ( in_array( $key, array( 'source_url', 'page_url', 'referer', 'referrer', '_wp_http_referer' ), true ) ) {
				$value = $this->value_from_item( $item );
				if ( is_scalar( $value ) ) {
					return esc_url_raw( (string) $value );
				}
			}
		}

		return $this->current_url();
	}

	/**
	 * Get referrer.
	 *
	 * @return string
	 */
	private function get_referrer() {
		$referrer = wp_get_referer();

		return $referrer ? esc_url_raw( $referrer ) : '';
	}

	/**
	 * Current URL fallback.
	 *
	 * @return string
	 */
	private function current_url() {
		$host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) );
		$uri  = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );

		if ( '' === $host ) {
			return '';
		}

		$scheme = is_ssl() ? 'https://' : 'http://';
		return esc_url_raw( $scheme . $host . $uri );
	}

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

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

	/**
	 * Get the safest lower bound for linking Ninja Forms mail events.
	 *
	 * @return string
	 */
	private function get_event_link_window() {
		if ( '' !== $this->submission_started_at ) {
			return $this->submission_started_at;
		}

		return gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - ( 5 * MINUTE_IN_SECONDS ) );
	}

	/**
	 * Recipient candidates used to link recent mail events when available.
	 *
	 * @param array<string,string> $detected Detected lead values.
	 * @return array<int,string>
	 */
	private function event_recipient_candidates( array $detected ) {
		$email = sanitize_email( $detected['customer_email'] ?? '' );

		return is_email( $email ) ? array( $email ) : array();
	}

	/**
	 * Update lead email status from linked events.
	 *
	 * @param int $lead_id Lead id.
	 * @return void
	 */
	private function sync_lead_email_status( $lead_id ) {
		$events = $this->mail_events->get_by_lead_id( $lead_id );

		if ( empty( $events ) ) {
			$this->leads->update_email_status( $lead_id, LeadRepository::EMAIL_STATUS_UNKNOWN );
			return;
		}

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

		$this->leads->update_email_status( $lead_id, $has_sent ? LeadRepository::EMAIL_STATUS_SENT : LeadRepository::EMAIL_STATUS_UNKNOWN );
	}
}
