HEX
Server: LiteSpeed
System: Linux server315.web-hosting.com 4.18.0-553.54.1.lve.el8.x86_64 #1 SMP Wed Jun 4 13:01:13 UTC 2025 x86_64
User: globfdxw (6114)
PHP: 8.1.34
Disabled: NONE
Upload Files
File: //home/globfdxw/www/wp-content/plugins/wpforms-surveys-polls/src/Reporting/Fields.php
<?php

namespace WPFormsSurveys\Reporting;

use WPFormsSurveys\Reporting\Helpers as ReportingHelpers;

/**
 * Field related survey reporting methods.
 *
 * @since 1.0.0
 */
class Fields {

	/**
	 * Build and return the survey data for a given field.
	 *
	 * @since 1.0.0
	 *
	 * @param array $field       Field settings.
	 * @param int   $form_id     Form ID.
	 * @param int   $entry_count Total number of entries.
	 * @param array $form_data   Form data and settings.
	 *
	 * @return array
	 */
	public static function get_survey_field_data( $field, $form_id, $entry_count = 0, $form_data = [] ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded

		// Prepare optional filters (date range).
		$filters = [];
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( isset( $_REQUEST['filters'] ) && is_array( $_REQUEST['filters'] ) ) {
			$raw_filters = wp_unslash( $_REQUEST['filters'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			$filters     = is_array( $raw_filters ) ? ReportingHelpers::sanitize_filters_array( $raw_filters ) : [];
			$filters     = ReportingHelpers::ensure_array_fields( $filters );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$args = [
			'number'   => -1,
			'form_id'  => $form_id,
			'field_id' => $field['id'],
			'order'    => 'ASC',
		];

		// Field types that should not be constrained by the date/timespan filter.
		$skip_date_filter_types = [ 'likert_scale', 'text', 'textarea' ];

		// If a timespan/date filter is provided, pass it to the DB query.
		if ( ! empty( $filters ) && ! in_array( $field['type'], $skip_date_filter_types, true ) ) {
			try {
				$current = ( new Datepicker( $filters ) )->get_current_date();

				if ( $current !== '' ) {
					if ( strpos( $current, Datepicker::RANGE_DELIMITER ) !== false ) {
						$parts = array_map( 'trim', explode( Datepicker::RANGE_DELIMITER, $current ) );

						if ( count( $parts ) === 2 && $parts[0] !== '' && $parts[1] !== '' ) {
							$args['date'] = [ $parts[0], $parts[1] ];
						}
					} else {
						$args['date'] = $current;
					}
				}
			} catch ( \Throwable $e ) {
				unset( $e ); // Ignore date filter errors and proceed without date constraint.
			}
		}

		// Get answers for this field (optionally date constrained).
		$answers = wpforms()->obj( 'entry_fields' )->get_fields( $args );

		$ignored_entry_ids = self::get_ignored_entry_ids( $form_id );

		$answers = array_filter(
			$answers,
			static function ( $answer ) use ( $ignored_entry_ids ) {

				return ! in_array( $answer->entry_id, $ignored_entry_ids, true );
			}
		);

		// Labels of the selected filter choices; used later to strip non-selected
		// answers from the aggregated data for multi-select (checkbox) fields.
		$filter_allowed_labels = [];

		// Whether the field uses custom values instead of labels.
		$has_show_values = ! empty( $field['show_values'] );

		// Selected numeric keys for rating/NPS fields; used to limit pre-initialized slots.
		$filter_numeric_keys = null;

		$selected_key = 'field_' . $field['id'];

		// If specific choices were selected for this field via Filters, prefilter answers.
		if ( ! empty( $filters ) && isset( $filters[ $selected_key ] ) && is_array( $filters[ $selected_key ] ) ) {
			$selected_values_raw = $filters[ $selected_key ];

			// If the field group exists in filters but is an empty array, it explicitly means
			// "no answers selected" — return no results for this field.
			if ( $selected_values_raw === [] ) {
				$answers = [];
			} else {
				$selected_keys = array_map( 'strval', (array) $selected_values_raw );

				// Rating and NPS fields store raw numeric values in the DB.
				// Filter keys are also numeric, so match directly against the stored value.
				if ( in_array( $field['type'], [ 'rating', 'net_promoter_score' ], true ) ) {
					$filter_numeric_keys = $selected_keys;

					$answers = array_filter(
						$answers,
						static function ( $answer ) use ( $selected_keys ) {

							return in_array( (string) $answer->value, $selected_keys, true );
						}
					);
				} else {
					// Build a map from selected option keys to their display labels for comparison with stored values.
					$allowed_labels = [];
					$allowed_values = [];

					if ( ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) {
						foreach ( $field['choices'] as $idx => $choice ) {
							$opt_key   = isset( $choice['value'] ) && $choice['value'] !== '' ? (string) $choice['value'] : (string) $idx;
							$opt_label = isset( $choice['label'] ) ? sanitize_text_field( (string) $choice['label'] ) : ( '#' . (string) $idx );

							if ( in_array( (string) $opt_key, $selected_keys, true ) ) {
								$allowed_labels[] = $opt_label;
								$allowed_values[] = $opt_key;
							}
						}
					}

					// When show_values is enabled, entries store custom values instead of labels.
					$allowed_matches = $has_show_values ? $allowed_values : $allowed_labels;

					// Preserve for post-aggregation filtering of multi-select fields.
					$filter_allowed_labels = $allowed_matches;

					if ( ! empty( $allowed_matches ) ) {
						$answers = array_filter(
							$answers,
							static function ( $answer ) use ( $allowed_matches, $field, $has_show_values ) {
								$value = (string) $answer->value;

								if ( Fields::is_multiple( $field ) ) {
									$parts = array_map( 'trim', explode( "\n", $value ) );

									foreach ( $parts as $part ) {
										$normalized = $has_show_values ? sanitize_text_field( $part ) : Fields::normalize_answer_value( $part, $field );

										if ( in_array( $normalized, $allowed_matches, true ) ) {
											return true;
										}
									}

									return false;
								}

								$normalized_single = $has_show_values ? sanitize_text_field( $value ) : Fields::normalize_answer_value( $value, $field );

								return in_array( $normalized_single, $allowed_matches, true );
							}
						);
					} else {
						// If specific selections are provided but none match available choices, treat as no results.
						$answers = [];
					}
				}
			}
		}

		// Setup and define basic default data.
		$data = [
			'id'       => $field['id'],
			'type'     => $field['type'],
			'badge'    => self::get_field_badge_markup( $field['type'] ),
			'question' => $field['label'],
			'total'    => $entry_count,
			'answered' => count( $answers ),
			'skipped'  => ! empty( $answers ) && ! empty( $entry_count ) ? $entry_count - count( $answers ) : 0,
			'answers'  => [],
			'chart'    => [
				'supports' => [ 'bar', 'bar-h', 'pie', 'line' ],
				'default'  => 'line',
				'labels'   => [],
				'totals'   => [],
				'data'     => [],
			],
		];

		// If there are no answers, bail to prevent calculations.
		if ( empty( $answers ) ) {
			return $data;
		}

		// Get ready to process and calculate the data.
		switch ( $field['type'] ) {
			// Radio, Select, and Checkbox fields all share the same calculations.
			case 'radio':
			case 'select':
			case 'checkbox':
			case 'rating':

				$is_radio_field = $field['type'] === 'radio';

				// Loop through each answer for process.
				foreach ( $answers as $answer ) {

					if ( self::is_multiple( $field ) ) {

						// Checkbox and Multiple Select values are slightly different because
						// there can be multiple values in a single answer.
						// This requires adjusted logic.
						$checks = explode( "\n", $answer->value );

						// Checkbox and Multiple Select reporting does not support the pie graph
						// so remove it from the defaults graph types supported.
						$data['chart']['supports'] = [ 'bar', 'bar-h', 'line' ];

						// Process values.
						foreach ( $checks as $check_key => $check ) {

							$exists = false;

							// Normalize value for choices that include free text (e.g., Other: custom text).
							$normalized_check = $is_radio_field ? self::normalize_answer_value( $check, $field ) : $check;

							if ( ! empty( $data['answers'] ) ) {
								foreach ( $data['answers'] as $key => $item ) {
									if ( $normalized_check === $item['value'] ) {
										$data['answers'][ $key ]['count']++;

										$exists = true;

										break;
									}
								}
							}

							if ( ! $exists ) {
								$data['answers'][ $answer->id . '_' . $check_key ] = [
									'value' => $normalized_check,
									'count' => 1,
								];
							}
						}
					} else {

						$answer_value = $answer->value;
						$exists       = false;

						// For the rating field we adjust the values to make
						// them more human readable.
						if ( $field['type'] === 'rating' ) {
							$icon         = ! empty( $field['icon'] ) ? $field['icon'] : 'star';
							$scale        = ! empty( $field['scale'] ) ? (int) $field['scale'] : 5;
							$answer_value = self::get_rating_label( $icon, (int) $answer->value, $scale );
						}

						// Normalize value for choices that include free text (e.g., Other option).
						if ( $is_radio_field ) {
							$answer_value = self::normalize_answer_value( $answer_value, $field );
						}

						// Process values.
						if ( ! empty( $data['answers'] ) ) {
							foreach ( $data['answers'] as $key => $item ) {
								if ( $answer_value === $item['value'] ) {
									$data['answers'][ $key ]['count']++;

									$exists = true;

									break;
								}
							}
						}

						if ( ! $exists ) {
							$data['answers'][ $answer->id ] = [
								'value' => $answer_value,
								'count' => 1,
							];

							if ( $field['type'] === 'rating' ) {
								$data['answers'][ $answer->id ]['value_raw'] = $answer->value;
							}
						}
					}
				}

				// Rating field specific actions.
				if ( $field['type'] === 'rating' && ! empty( $data['answers'] ) ) {
					// Reorder answers by numeric value.
					usort(
						$data['answers'],
						function( $a, $b ) {
							return $a['value_raw'] - $b['value_raw'];
						}
					);

					// Calculate average rating.
					$total = 0;

					foreach ( $answers as $answer ) {
						$total += absint( $answer->value );
					}

					$data['average'] = round( $total / count( $answers ), 1 );
				}

				// For Radio, Checkboxes, and Select fields, reorder the answers
				// to match the order they are displayed in the survey.
				if ( in_array( $field['type'], [ 'radio', 'select', 'checkbox' ], true ) && ! empty( $data['answers'] ) ) {

					$answers_originals = $data['answers'];
					$answers_ordered   = [];

					foreach ( $field['choices'] as $choice_key => $choice ) {
						// When show_values is enabled, stored values are custom values, not labels.
						$match_value = $has_show_values && isset( $choice['value'] ) && $choice['value'] !== ''
							? (string) $choice['value']
							: sanitize_text_field( $choice['label'] );

						foreach ( $answers_originals as $key => $answers_original ) {
							if ( $match_value === $answers_original['value'] ) {
								$answers_ordered[ $key ]              = $answers_original;
								$answers_ordered[ $key ]['choice_id'] = $choice_key;

								unset( $answers_originals[ $key ] );
								break;
							}
						}
					}

					// If there are any answers remaining that means they are
					// choices that were available at some point, but have been
					// removed and no longer exist. In this case we add them to
					// the end.
					if ( ! empty( $answers_originals ) ) {
						$answers_ordered = array_replace_recursive( $answers_ordered, $answers_originals );
					}

					$data['answers'] = $answers_ordered;
				}

				// For multi-select (checkbox) fields with active answer-choice filters,
				// strip aggregated answers that were not selected in the filter.
				// Entry-level filtering keeps entries containing ANY selected choice,
				// but the chart should only display the selected choices.
				if ( ! empty( $filter_allowed_labels ) && self::is_multiple( $field ) ) {
					$data['answers'] = array_filter(
						$data['answers'],
						static function ( $item ) use ( $filter_allowed_labels ) {

							return in_array( $item['value'], $filter_allowed_labels, true );
						}
					);
				}

				// Loop through each answer and compile/format values needed for Chart JS.
				if ( ! empty( $data['answers'] ) ) {
					foreach ( $data['answers'] as $key => $item ) {
						$percent                            = round( ( $item['count'] / count( $answers ) ) * 100 );
						$data['answers'][ $key ]['percent'] = $percent;
						$data['chart']['labels'][]          = $item['value'];
						$data['chart']['totals'][]          = $item['count'];
						$data['chart']['data'][]            = $percent;
					}
				}
				break;

			case 'text':
			case 'textarea':
				// Text input and text area results.
				// Text based fields don't support charts so we disable.
				$data['chart']['supports'] = [];
				$data['chart']['default']  = false;

				// Loop through each answer.
				foreach ( $answers as $answer ) {

					$data['answers'][ $answer->id ] = [
						'value'     => $answer->value,
						'date'      => wpforms_datetime_format( $answer->date, '', true ),
						'date_unix' => strtotime( $answer->date ),
						'entry_id'  => absint( $answer->entry_id ),
					];
				}
				break;

			// Likert Scale results.
			case 'likert_scale':
				// Get the form data to have access to field rows and columns.
				if ( empty( $form_data ) ) {
					$form_data = wpforms()->obj( 'form' )->get(
						$form_id,
						[
							'content_only' => true,
						]
					);
				}

				// Likert fields don't support charts so we disable.
				$data['chart']['supports'] = [];
				$data['chart']['default']  = false;

				// Basic details for rendering the results table.
				$data['table']            = [];
				$data['table']['columns'] = [];
				$data['table']['rows']    = [];
				$data['table']['single']  = ! empty( $field['single_row'] ) ? true : false;
				$data['table']['width']   = $data['table']['single'] ? round( 100 / count( $form_data['fields'][ $field['id'] ]['columns'] ), 4 ) : round( 80 / count( $form_data['fields'][ $field['id'] ]['columns'] ), 4 );

				// Prefix the field rows and column keys to preserve the order
				// when looping in our javascript template.
				foreach ( $form_data['fields'][ $field['id'] ]['rows'] as $k => $v ) {
					$data['table']['rows'][ "r{$k}" ] = $v;
				}
				foreach ( $form_data['fields'][ $field['id'] ]['columns'] as $k => $v ) {
					$data['table']['columns'][ "c{$k}" ] = $v;
				}

				// Get the row and column IDs to use to verify data.
				$row_ids    = array_map( 'absint', array_keys( $form_data['fields'][ $field['id'] ]['rows'] ) );
				$column_ids = array_map( 'absint', array_keys( $form_data['fields'][ $field['id'] ]['columns'] ) );

				// Set all the initial counts to zero.
				$counts = [];

				foreach ( $row_ids as $r ) {
					foreach ( $column_ids as $c ) {
						$counts[ $r ][ $c ] = 0;
					}
				}

				// Loop through each answer to process counts.
				foreach ( $answers as $answer ) {

					// Fetch and decode the raw values (arrays).
					$values = json_decode( $answer->value, true );
					$values = ! empty( $values['value_raw'] ) ? $values['value_raw'] : false;

					if ( ! is_array( $values ) ) {
						continue;
					}

					foreach ( $values as $row_key => $column_keys ) {

						// If this row key is not found, that means the admin
						// has likely removed that row from the field settings,
						// so we discard it.
						if ( ! in_array( absint( $row_key ), $row_ids, true ) ) {
							continue;
						}

						$column_keys = (array) $column_keys;

						foreach ( $column_keys as $column_key ) {

							// If this column key is not found, that means the
							// admin has likely removed that column from the
							// field settings, so we discard it.
							if ( ! in_array( absint( $column_key ), $column_ids, true ) ) {
								continue;
							}

							// Increment the count.
							$counts[ $row_key ][ $column_key ]++;
						}
					}
				}

				// Compile the final answer data to return.
				foreach ( $row_ids as $r ) {
					foreach ( $column_ids as $c ) {

						$answer_key = "r{$r}_c{$c}";
						$total      = ! empty( $counts[ $r ] ) ? array_sum( $counts[ $r ] ) : 0;
						$count      = $counts[ $r ][ $c ];

						$data['answers'][ $answer_key ] = [
							'count'   => $count,
							'percent' => ! empty( $count ) && ! empty( $total ) ? round( ( $count / $total ) * 100 ) : 0,
							'highest' => ! empty( $count ) ? max( $counts[ $r ] ) === $count : false,
						];
					}
				}
				break;

			case 'net_promoter_score':
				// Define initial values.
				$data['nps'] = [
					'detractors' => [
						'count'   => 0,
						'percent' => 0,
					],
					'passives'   => [
						'count'   => 0,
						'percent' => 0,
					],
					'promoters'  => [
						'count'   => 0,
						'percent' => 0,
					],
					'score'      => 0,
				];

				for ( $i = 0; $i < 11; $i++ ) {
					// When filters are active, skip values that are not selected.
					if ( $filter_numeric_keys !== null && ! in_array( (string) $i, $filter_numeric_keys, true ) ) {
						continue;
					}

					$data['answers'][ $i ] = [
						'value'   => $i,
						'count'   => 0,
						'percent' => 0,
					];
				}

				// Loop through each answer for process.
				foreach ( $answers as $answer ) {
					$data['answers'][ (int) $answer->value ]['count']++;
				}

				// Loop through each answer.
				if ( ! empty( $data['answers'] ) ) {
					foreach ( $data['answers'] as $key => $item ) {
						$percent                            = round( ( $item['count'] / count( $answers ) ) * 100 );
						$data['answers'][ $key ]['percent'] = $percent;

						// Compile/format values needed for Chart JS.
						$data['chart']['labels'][] = $item['value'];
						$data['chart']['totals'][] = $item['count'];
						$data['chart']['data'][]   = $percent;

						// Assign NPS category.
						if ( $item['value'] >= 9 ) {
							$data['nps']['promoters']['count'] += $item['count'];
						} elseif ( $item['value'] >= 7 ) {
							$data['nps']['passives']['count'] += $item['count'];
						} else {
							$data['nps']['detractors']['count'] += $item['count'];
						}
					}
				}

				// Calculate NPS category percentages.
				foreach ( $data['nps'] as $key => $nps_category ) {
					if ( $key === 'score' ) {
						continue;
					}
					$data['nps'][ $key ]['percent'] = round( ( $nps_category['count'] / count( $answers ) ) * 100 );
				}

				// Calculate raw NPS score.
				$data['nps']['score'] = round( ( ( $data['nps']['promoters']['count'] - $data['nps']['detractors']['count'] ) / count( $answers ) ) * 100, 2 );
				break;
		}

		/**
		 * Return the final array of data, filterable.
		 *
		 * @since 1.11.0
		 *
		 * @param array $data    Survey data for a given field.
		 * @param int   $form_id Form ID.
		 */
		return apply_filters( 'wpforms_surveys_reporting_fields_get_survey_field_data', $data, $form_id );
	}

	/**
	 * Get ignored entry ids.
	 * Return ids of abandoned and partial entries.
	 *
	 * @since 1.7.0
	 *
	 * @param int $form_id Form id.
	 *
	 * @return array
	 */
	private static function get_ignored_entry_ids( $form_id ) {

		$entry_handler = wpforms()->obj( 'entry' );

		if ( ! $entry_handler ) {
			return [];
		}

		$abandoned = $entry_handler->get_entries(
			[
				'form_id' => $form_id,
				'status'  => 'abandoned',
				'select'  => 'entry_ids',
			]
		);

		$partial = $entry_handler->get_entries(
			[
				'form_id' => $form_id,
				'status'  => 'partial',
				'select'  => 'entry_ids',
			]
		);

		$trash = $entry_handler->get_entries(
			[
				'form_id' => $form_id,
				'status'  => 'trash',
				'select'  => 'entry_ids',
			]
		);

		$spam = $entry_handler->get_entries(
			[
				'form_id' => $form_id,
				'status'  => 'spam',
				'select'  => 'entry_ids',
			]
		);

		return wp_list_pluck( array_merge( $abandoned, $partial, $trash, $spam ), 'entry_id' );
	}

	/**
	 * Return array of fields in a form that have survey reporting enabled.
	 *
	 * @since 1.0.0
	 *
	 * @param array $form_data Form data and settings.
	 * @param bool  $ids       Return field IDs when true, otherwise field arrays.
	 *
	 * @return bool|array
	 */
	public static function get_survey_fields( $form_data, $ids = false ) {

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

		$fields    = $form_data['fields'];
		$field_ids = [];

		if ( ! empty( $form_data['settings']['survey_enable'] ) ) {

			foreach ( $fields as $id => $field ) {
				if ( ! in_array( $field['type'], self::get_survey_field_types(), true ) ) {
					unset( $fields[ $id ] );
				} else {
					$field_ids[] = $id;
				}
			}
		} else {
			foreach ( $fields as $id => $field ) {
				if ( ! self::field_has_survey( $field ) ) {
					unset( $fields[ $id ] );
				} else {
					$field_ids[] = $id;
				}
			}
		}

		if ( $ids ) {
			return $field_ids;
		}

		return $fields;
	}

	/**
	 * Output HTML markup for a badge that indicates the field type.
	 *
	 * @since 1.0.0
	 *
	 * @param string $type Field type slug.
	 *
	 * @return string
	 */
	public static function get_field_badge_markup( $type ) {

		$badge = [];

		switch ( $type ) {
			case 'text':
				$badge['name'] = esc_html__( 'Single Line Text', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-text-width';
				break;

			case 'textarea':
				$badge['name'] = esc_html__( 'Paragraph Text', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-paragraph';
				break;

			case 'select':
				$badge['name'] = esc_html__( 'Dropdown', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-caret-square-o-down';
				break;

			case 'radio':
				$badge['name'] = esc_html__( 'Multiple Choice', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-list-ul';
				break;

			case 'checkbox':
				$badge['name'] = esc_html__( 'Checkboxes', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-check-square-o';
				break;

			case 'rating':
				$badge['name'] = esc_html__( 'Rating', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-star';
				break;

			case 'likert_scale':
				$badge['name'] = esc_html__( 'Likert Scale', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-ellipsis-h';
				break;

			case 'net_promoter_score':
				$badge['name'] = esc_html__( 'Net Promoter Score', 'wpforms-surveys-polls' );
				$badge['icon'] = 'fa-tachometer';
				break;
		}

		$badge = apply_filters( 'wpforms_surveys_reporting_fields_get_field_badge_markup', $badge );

		return sprintf(
			'<span class="badge"><i class="fa %s" aria-hidden="true"></i> %s</span>',
			sanitize_html_class( $badge['icon'] ),
			esc_html( $badge['name'] )
		);
	}

	/**
	 * Check if the provided fields from a form contain survey.
	 *
	 * @since 1.0.0
	 *
	 * @param array $fields Found fields.
	 *
	 * @return bool
	 */
	public static function fields_has_survey( $fields ) {

		if ( ! empty( $fields ) && is_array( $fields ) ) {
			foreach ( $fields as $field ) {
				if ( self::field_has_survey( $field ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Check if a specific field has survey reporting enabled.
	 *
	 * @since 1.0.0
	 *
	 * @param array $field     Field data and settings.
	 * @param array $form_data Form data and settings.
	 *
	 * @return bool
	 */
	public static function field_has_survey( $field, $form_data = [] ) {

		if ( isset( $field['survey'] ) && $field['survey'] === '1' ) {
			return true;
		} elseif ( ! empty( $form_data['settings']['survey_enable'] ) && in_array( $field['type'], self::get_survey_field_types(), true ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get an array of field types that support Surveys.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public static function get_survey_field_types() {

		return apply_filters(
			'wpforms_surveys_reporting_fields_get_survey_field_types',
			[ 'text', 'textarea', 'select', 'radio', 'checkbox', 'rating', 'likert_scale', 'net_promoter_score' ]
		);
	}

	/**
	 * Determine if it's the field with multiple feature.
	 *
	 * @since 1.6.3
	 *
	 * @param array $field Field data.
	 *
	 * @return bool
	 */
	public static function is_multiple( $field ) {

		if ( empty( $field['type'] ) ) {
			return false;
		}

		if (
			$field['type'] === 'checkbox' ||
			( $field['type'] === 'select' && ! empty( $field['multiple'] )
		) ) {
			return true;
		}

		return false;
	}

	/**
	 * Normalize a stored answer value to a choice label for reporting.
	 *
	 * Collapses values like "Other: free text" into a single "Other" bucket
	 * and trims any dynamic suffix when the prefix matches an existing choice label.
	 *
	 * @since 1.16.0
	 *
	 * @param string $value Raw stored value.
	 * @param array  $field Field settings.
	 *
	 * @return string Normalized label to use for aggregation.
	 */
	private static function normalize_answer_value( string $value, array $field ): string {

		$value_trim = trim( $value );
		$sanitized  = sanitize_text_field( $value_trim );
		$labels     = self::get_choice_labels( $field );

		// Known choice? Return it.
		if ( in_array( $sanitized, $labels, true ) ) {
			return $sanitized;
		}

		// No valid choices array? Return default.
		if ( empty( $field['choices'] ) || ! is_array( $field['choices'] ) ) {
			return $value;
		}

		// Look for a defined the Other option.
		foreach ( $field['choices'] as $choice ) {
			if ( ! empty( $choice['other'] ) ) {
				return $choice['label'] ?? esc_html__( 'Other', 'wpforms-surveys-polls' );
			}
		}

		return $value;
	}

	/**
	 * Get a sanitized list of choice labels for a field.
	 *
	 * @since 1.16.0
	 *
	 * @param array $field Field settings.
	 *
	 * @return array
	 */
	private static function get_choice_labels( array $field ): array {

		if ( empty( $field['choices'] ) || ! is_array( $field['choices'] ) ) {
			return [];
		}

		$labels = [];

		foreach ( $field['choices'] as $choice ) {
			if ( isset( $choice['label'] ) ) {
				$labels[] = sanitize_text_field( $choice['label'] );
			}
		}

		return $labels;
	}

	/**
	 * Get a human-readable label for a rating scale value.
	 *
	 * Produces labels like "3 stars (3/5)" matching the format used
	 * by get_survey_field_data() for chart display.
	 *
	 * @since 1.18.0
	 *
	 * @param string $icon  Icon type (star, heart, thumb, smiley).
	 * @param int    $value Numeric rating value.
	 * @param int    $scale Total scale (e.g. 5).
	 *
	 * @return string
	 */
	public static function get_rating_label( string $icon, int $value, int $scale ): string {

		switch ( $icon ) {
			case 'star':
				/* translators: %s - number of stars. */
				$label = esc_html( sprintf( _n( '%s star', '%s stars', $value, 'wpforms-surveys-polls' ), number_format_i18n( $value ) ) );
				break;

			case 'heart':
				/* translators: %s - number of hearts. */
				$label = esc_html( sprintf( _n( '%s heart', '%s hearts', $value, 'wpforms-surveys-polls' ), number_format_i18n( $value ) ) );
				break;

			case 'thumb':
				/* translators: %s - number of thumbs. */
				$label = esc_html( sprintf( _n( '%s thumb', '%s thumbs', $value, 'wpforms-surveys-polls' ), number_format_i18n( $value ) ) );
				break;

			case 'smiley':
				/* translators: %s - number of smileys. */
				$label = esc_html( sprintf( _n( '%s smiley', '%s smileys', $value, 'wpforms-surveys-polls' ), number_format_i18n( $value ) ) );
				break;

			default:
				$label = (string) $value;
				break;
		}

		return $label . " ({$value}/{$scale})";
	}
}