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/Admin.php
<?php

namespace WPFormsSurveys\Reporting;

use WPForms_Entries_List;
use WPFormsSurveys\Helpers;
use WPFormsSurveys\Reporting\Helpers as ReportingHelpers;

/**
 * Survey reporting admin page.
 *
 * @since 1.0.0
 */
class Admin {

	/**
	 * All the forms.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	public $forms;

	/**
	 * Current form ID.
	 *
	 * @since 1.0.0
	 *
	 * @var int
	 */
	public $form_id;

	/**
	 * Current form data array.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	public $form_data;

	/**
	 * Total number of entries in the current form.
	 *
	 * @since 1.0.0
	 *
	 * @var int
	 */
	public $entry_count;

	/**
	 * Field IDs for the fields with survey reporting enabled.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	public $field_ids;

	/**
	 * Field ID for the specific survey field the user has selected to display
	 * in the survey preview area.
	 *
	 * If no specific field has been defined, it will be the first survey field
	 * in the form.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	public $field_id;

	/**
	 * If we are viewing the entries list table or the survey report.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	public $view = false;

	/**
	 * If we are viewing the survey report printable template.
	 *
	 * @since 1.0.0
	 *
	 * @var bool
	 */
	public $print = false;

	/**
	 * Abort. Bail on proceeding to process the page.
	 *
	 * @since 1.8.0
	 *
	 * @var bool
	 */
	private $abort = false;

	/**
	 * Various URLs.
	 *
	 * @since 1.0.0
	 *
	 * @var array
	 */
	public $urls = [];

	/**
	 * Current filters applied to the entries list.
	 *
	 * @since 1.18.0
	 *
	 * @var array
	 */
	private $filters;

	/**
	 * Current preset ID.
	 *
	 * @since 1.18.0
	 *
	 * @var string
	 */
	private $current_preset;

	/**
	 * Number of displayed graphs per page.
	 *
	 * @since 1.18.0
	 *
	 * @var int
	 */
	private const GRAPHS_PER_PAGE = 5;

	/**
	 * Datepicker object.
	 *
	 * @since 1.18.0
	 *
	 * @var Datepicker
	 */
	private $datepicker;

	/**
	 * Construct.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {

		// Fire init.
		$this->init();
	}

	/**
	 * Initialize.
	 *
	 * @since 1.0.0
	 */
	public function init() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh, WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks

		// Register AJAX handler for loading more graphs regardless of current admin page.
		add_action( 'wp_ajax_wpforms_surveys_graph_load_more', [ $this, 'ajax_load_more' ] );

		// Delete current cache when entry edited.
		if ( wpforms()->obj( 'entries_edit' )->is_admin_entry_editing_ajax() ) {
			add_action( 'wpforms_pro_admin_entries_edit_submit_completed', [ $this, 'entries_edit_submit_clear_cache' ], 10, 4 );
		}

		// Check page and view, determine if the user is viewing the survey reporting page.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$current_page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
		$current_view = isset( $_GET['view'] ) ? sanitize_key( $_GET['view'] ) : '';
		$is_deleted   = isset( $_GET['deleted'] ) ? sanitize_key( $_GET['deleted'] ) : '';
		$is_restored  = isset( $_GET['restored'] ) ? sanitize_key( $_GET['restored'] ) : '';
		$is_trashed   = isset( $_GET['trashed'] ) ? sanitize_key( $_GET['trashed'] ) : '';

		$this->view  = in_array( $current_view, [ 'survey', 'list' ], true ) ? $current_view : false;
		$this->print = ! empty( $_GET['print'] );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		if ( $current_page !== 'wpforms-entries' || ! $this->view ) {
			return;
		}

		// Survey results page processing and setup.
		$this->setup();

		// If there is no form ID, bail.
		if ( ! $this->form_id ) {
			return;
		}

		// Clear cache when entry is deleted, trashed or restored.
		if ( $is_deleted || $is_restored || $is_trashed ) {
			$this->entries_delete_clear_cache();
		}

		/**
		 * Filters whether to abort the survey reporting admin page initialization.
		 *
		 * @since 1.18.0
		 *
		 * @param bool  $abort     Whether to abort the page initialization.
		 * @param array $form_data Form data and settings.
		 * @param Admin $this      Current instance.
		 *
		 * @return bool
		 */
		$this->abort = (bool) apply_filters( 'wpforms_surveys_reporting_admin_abort', false, $this->form_data, $this );

		// No necessity to proceed.
		if ( $this->abort ) {
			return;
		}

		// phpcs:disable WPForms.PHP.ValidateHooks.InvalidHookName
		/**
		 * Fire when everything is ready for initializing addon page.
		 *
		 * @since 1.0.0
		 *
		 * @param Admin $instance Reporting admin page instance.
		 */
		do_action( 'wpforms_survey_report_init', $this );
		// phpcs:enable WPForms.PHP.ValidateHooks.InvalidHookName

		if ( $this->view === 'list' ) {

			// Entry List survey preview area.
			add_action( 'wpforms_entry_list_title', [ $this, 'entry_list_preview' ], 12, 2 );
			add_filter( 'wpforms_entry_table_column_value', [ $this, 'format_likert_scale_value' ], 10, 4 );

		} elseif ( $this->view === 'survey' ) {

			// Remove Screen Options tab from admin area header.
			add_filter( 'screen_options_show_screen', '__return_false' );

			// Survey results page output.
			add_action( 'wpforms_admin_page', [ $this, 'report_page' ] );
		}

		// Load the Underscores templates displaying question results.
		add_action( 'admin_print_scripts', [ $this, 'question_template' ] );

		// Enqueues.
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueues' ] );

		// Report Print Page.
		add_action( 'current_screen', [ $this, 'report_print_page' ] );
	}

	/**
	 * Format the Likert Scale entries in Entries list view
	 * to a more readable format.
	 *
	 * @since 1.9.0
	 *
	 * @param string $value       Value.
	 * @param object $entry       Current entry data.
	 * @param string $column_name Current column name.
	 * @param string $field_type  Field type.
	 *
	 * @return string
	 */
	public function format_likert_scale_value( $value, $entry, $column_name, $field_type ) {

		if ( $field_type !== 'likert_scale' ) {
			return $value;
		}

		return Helpers::format_likert_scale_entry( $value, '<br />' );
	}

	/**
	 * Setup and process form data.
	 *
	 * @since 1.0.0
	 */
	public function setup() {

		// Fetch all forms, for the form dropdown toggle nav. We only need this
		// for the survey reporting page.
		if ( $this->view === 'survey' ) {
			$this->forms = wpforms()->obj( 'form' )->get(
				'',
				[
					'orderby' => 'ID',
					'order'   => 'ASC',
				]
			);
		}

		// Get current form ID.
		$this->form_id = ! empty( $_GET['form_id'] ) ? absint( $_GET['form_id'] ) : false; // phpcs:ignore

		// If there is no form ID, stop.
		if ( ! $this->form_id ) {
			wp_safe_redirect( admin_url( 'admin.php?page=wpforms-entries' ) );
			exit;
		}

		// Get current form details.
		$this->form_data = wpforms()->obj( 'form' )->get(
			$this->form_id,
			[
				'content_only' => true,
				'cap'          => 'view_entries_form_single',
			]
		);

		// Get number of current entries.
		$this->entry_count = wpforms()->obj( 'entry' )->get_entries(
			[
				'form_id' => $this->form_id,
			],
			true
		);

		// Various URLs needed.
		$this->urls = [
			'survey-report'       => add_query_arg(
				[
					'page'    => 'wpforms-entries',
					'view'    => 'survey',
					'form_id' => $this->form_id,
				],
				admin_url( 'admin.php' )
			),
			'survey-report-print' => add_query_arg(
				[
					'page'    => 'wpforms-entries',
					'view'    => 'survey',
					'form_id' => $this->form_id,
					'print'   => '1',
				],
				admin_url( 'admin.php' )
			),
			'form-edit'           => add_query_arg(
				[
					'page'    => 'wpforms-builder',
					'view'    => 'fields',
					'form_id' => $this->form_id,
				],
				admin_url( 'admin.php' )
			),
			'form-preview'        => add_query_arg(
				[
					'wpforms_form_preview' => $this->form_id,
				],
				home_url()
			),
			'entries-export'      => wp_nonce_url(
				add_query_arg(
					[
						'page'   => 'wpforms-tools',
						'view'   => 'export',
						'form'   => absint( $this->form_id ),
						'search' => ! empty( $_GET['search'] ) ? $_GET['search'] : [], // phpcs:ignore
						'date'   => ! empty( $_GET['date'] ) ? $_GET['date'] : [], // phpcs:ignore
					],
					admin_url( 'admin.php' )
				),
				'wpforms_entry_list_export'
			),
			'entries'             => add_query_arg(
				[
					'page'    => 'wpforms-entries',
					'view'    => 'list',
					'form_id' => $this->form_id,
				],
				admin_url( 'admin.php' )
			),
		];

		// Return earlier when the form is not found or have no entries.
		if (
			$this->view === 'survey'
			&& ( empty( $this->forms ) || ! $this->entry_count )
		) {
			wp_safe_redirect( $this->urls['entries'] );
			exit;
		}

		$this->set_filters();

		$this->datepicker = new Datepicker( $this->filters );

		// Get details about fields with survey reporting enabled.
		$this->field_ids = Fields::get_survey_fields( $this->form_data, true );
		$this->field_id  = [];

		// For the entry list overview page, the survey preview only displays
		// a report for 1 field, so reflect this in the field IDs returned.
		if ( ! empty( $this->field_ids ) && ( 'list' === $this->view || ( 'survey' === $this->view && ! empty( $_GET['field_id'] ) ) ) ) { // phpcs:ignore

			$specific_id = false;

			if ( ! empty( $_GET['field_id'] ) ) { // phpcs:ignore

				// Show specific field.
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$specific_id = absint( $_GET['field_id'] );

			} elseif ( ! empty( $this->form_data['meta']['survey_preview'] ) ) {

				// Check the form meta and see if the user as set a specific
				// field they want to use in the preview area.
				$specific_id = absint( $this->form_data['meta']['survey_preview'] );
			}

			if ( $specific_id && ! empty( $this->form_data['fields'][ $specific_id ] ) ) {
				$this->field_id = (array) $specific_id;
			} else {
				$this->field_id = (array) $this->field_ids[0];
			}
		}

		// Easter egg to delete current cache.
		if ( isset( $_GET['wpforms_surveys_polls_delete_cache'] ) && wpforms_current_user_can( 'view_entries_form_single', $this->form_id ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$this->entries_delete_clear_cache();
		}
	}

	/**
	 * Receive and sanitize filters from the URL.
	 *
	 * @param array|string $raw_filters Raw filters from the URL.
	 *
	 * @since 1.18.0
	 */
	private function set_filters( $raw_filters = null ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		// All elements are sanitized in the `sanitize_filters` method.
		// phpcs:disable WordPress.Security
		if ( is_null( $raw_filters ) ) {
			// Back-compat: when no argument is provided, read from the request.
			$raw_filters = $_REQUEST['filters'] ?? null;
		}

		if ( is_string( $raw_filters ) && $raw_filters !== '' ) {
			$decoded = json_decode( wp_unslash( $raw_filters ), true );
			$filters = is_array( $decoded ) ? $decoded : [];
		} elseif ( is_array( $raw_filters ) ) {
			$filters = $raw_filters;
		} else {
			$filters = [];
		}

		foreach ( $filters as $key => $value ) {
			$key   = sanitize_text_field( $key );
			$value = $this->sanitize_filters( $value );

			$filters[ $key ] = $value;

			if ( strpos( $key, 'field_' ) === 0 ) {
				$filters[ $key ] = (array) $value;
			}
		}

		// phpcs:enable WordPress.Security
		$normalized_filters = ReportingHelpers::normalize_filters( $filters );

		$presets = $this->get_presets();

		foreach ( $presets as $key => $preset ) {
			$preset_filters = isset( $preset['filters'] ) && is_array( $preset['filters'] ) ? ReportingHelpers::normalize_filters( $preset['filters'] ) : [];

			if ( $preset_filters === $normalized_filters ) {
				$this->current_preset = $key;

				break;
			}
		}

		$this->filters = $normalized_filters;
	}

	/**
	 * Get the preset list used in Filters UI.
	 *
	 * @since 1.18.0
	 *
	 * @return array[]
	 */
	private function get_presets(): array {

		$all_presets = (array) get_option( 'wpforms_survey_filter_presets', [] );

		return array_filter(
			$all_presets,
			function ( $preset ) {
				return isset( $preset['form_id'] ) && (int) $preset['form_id'] === $this->form_id;
			}
		);
	}

	/**
	 * Sanitize filters.
	 *
	 * @since 1.18.0
	 *
	 * @param mixed $value Value to sanitize.
	 *
	 * @return int|mixed|string
	 */
	private function sanitize_filters( $value ) {

		if ( is_numeric( $value ) ) {
			return (int) $value;
		}

		if ( is_string( $value ) ) {
			return sanitize_text_field( $value );
		}

		if ( is_array( $value ) ) {
			foreach ( $value as $key => $val ) {
				$value[ sanitize_key( $key ) ] = $this->sanitize_filters( $val );
			}
		}

		return $value;
	}

	/**
	 * Output entry list reporting preview area with a link to view the full
	 * survey reporting.
	 *
	 * @since 1.0.0
	 *
	 * @param array                $form_data    Form data and settings.
	 * @param WPForms_Entries_List $entries_list WPForms_Entries_List object.
	 *
	 * @noinspection PhpUnusedParameterInspection
	 * @noinspection PhpMissingParamTypeInspection
	 */
	public function entry_list_preview( $form_data, $entries_list ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

		// Check if the form has fields with survey reporting enabled. If not
		// do not display.
		if ( empty( $this->field_ids ) ) {
			return;
		}
		?>
		<div class="wpforms-survey-preview-wrapper">
			<div class="wpforms-survey-preview-list"
				data-all-field-choices="<?php echo esc_attr( wp_json_encode( $this->get_fields_choices() ) ); ?>">
				<?php
				$selected_question = $this->get_question( $this->field_id[0] );

				// Enable the Filters menu whenever at least one field in the dropdown is filterable.
				$any_filterable = false;

				foreach ( $this->field_ids as $fid ) {
					if ( ! empty( $this->form_data['fields'][ $fid ]['type'] ) && $this->field_is_filterable( $this->form_data['fields'][ $fid ]['type'] ) ) {
						$any_filterable = true;

						break;
					}
				}

				$current_is_filterable = isset( $selected_question['type'] ) && $this->field_is_filterable( $selected_question['type'] );

				$this->render_graph(
					$selected_question,
					[
						'is_question_dropdown' => true,
						'filter'               => $any_filterable,
						// Initially hide filters when the selected field is not filterable.
						'filter_hidden'        => $any_filterable && ! $current_is_filterable,
						'legend_pagination'    => true,
						// On the Entries list page, include only the current field in the Questions & Answers list.
						'only_current_field'   => true,
						// Hide "Apply To All Graphs" on the Entries overview — only one graph is visible.
						'hide_apply_all'       => true,
					]
				);
				?>
			</div>
			<?php
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo wpforms_render( WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/loader' );
			?>
		</div>
		<?php
	}

	/**
	 * Get question data.
	 *
	 * @since 1.18.0
	 *
	 * @param int|string $field_id Field ID.
	 *
	 * @return array
	 */
	private function get_question( $field_id ): array {

		if ( ! is_numeric( $field_id ) || empty( $this->form_data['fields'][ $field_id ] ) ) {
			return [];
		}

		$selected_field = $this->form_data['fields'][ $field_id ];

		return [
			'id'    => $field_id,
			'type'  => $selected_field['type'],
			'label' => ! empty( $selected_field['label'] ) ? wp_strip_all_tags( $selected_field['label'] ) : ( '#' . $field_id ),
		];
	}

	/**
	 * Get list of questions for the preview dropdown.
	 *
	 * @since 1.18.0
	 *
	 * @return array
	 */
	private function get_questions(): array {

		$questions = [];

		if ( empty( $this->field_ids ) || empty( $this->form_data['fields'] ) ) {
			return $questions;
		}

		foreach ( (array) $this->field_ids as $fid ) {
			if ( empty( $this->form_data['fields'][ $fid ]['type'] ) ) {
				continue;
			}

			$field      = $this->form_data['fields'][ $fid ];
			$field_type = $field['type'];
			$label      = ! empty( $field['label'] ) ? $field['label'] : ( '#' . $fid );

			$questions[ $fid ] = [
				'type'  => $field_type,
				'label' => wp_strip_all_tags( $label ),
			];
		}

		return $questions;
	}

	/**
	 * Render graph.
	 *
	 * @since 1.18.0
	 *
	 * @param array $question_data  Question data.
	 * @param array $graph_settings Graph configuration.
	 */
	private function render_graph( array $question_data, array $graph_settings = [] ): void {

		if ( ! isset( $question_data['id'], $question_data['type'], $question_data['label'] ) ) {
			return;
		}

		$graph_args = [
			'question_settings' => [
				'id'              => $question_data['id'],
				'type'            => $question_data['type'],
				'label'           => $question_data['label'],
				'options'         => ! empty( $graph_settings['is_question_dropdown'] ) ? $this->get_questions() : [],
				// When the question dropdown is present, always render settings
				// because the user can switch to a chart-capable field dynamically.
				'show_settings'   => ! empty( $graph_settings['is_question_dropdown'] ) || $this->field_has_chart( $question_data['type'] ),
				// Initially hide settings for non-chart fields; JS will toggle on field switch.
				'settings_hidden' => ! empty( $graph_settings['is_question_dropdown'] ) && ! $this->field_has_chart( $question_data['type'] ),
				// Optionally hide the "Apply To All Graphs" control in Settings (e.g., single-field print view).
				'hide_apply_all'  => ! empty( $graph_settings['hide_apply_all'] ),
			],
			'filter_settings'   => [
				'enabled' => false,
				'applied' => $this->filters,
			],
			'legend_settings'   => [
				'pagination'          => ! empty( $graph_settings['legend_pagination'] ),
				'is_list'             => $this->view === 'survey',
				'all_result_page_url' => add_query_arg(
					[
						'page'    => 'wpforms-entries',
						'view'    => 'survey',
						'form_id' => $this->form_id,
					],
					admin_url( 'admin.php' )
				),
			],
			'show_toggle'       => ! empty( $graph_settings['show_toggle'] ),
		];

		if ( ! empty( $graph_settings['filter'] ) ) {
			// Build fields list for Questions & Answers. Optionally limit to current field (entries list page behavior).
			$fields_for_filters = $this->get_fields_choices();

			if ( ! empty( $graph_settings['only_current_field'] ) ) {
				$current_id = $question_data['id'];

				if ( is_numeric( $current_id ) && isset( $fields_for_filters[ $current_id ] ) ) {
					$fields_for_filters = [ $current_id => $fields_for_filters[ $current_id ] ];
				}
			}

			$graph_args['filter_settings'] = array_merge(
				$graph_args['filter_settings'],
				[
					'enabled'           => true,
					'hidden'            => ! empty( $graph_settings['filter_hidden'] ),
					'survey_type_field' => [],
					'fields'            => $fields_for_filters,
					'date_picker'       => $this->get_datepicker_settings(),
					'presets'           => $this->get_presets(),
					'current_preset'    => $this->current_preset,
				]
			);
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo wpforms_render(
			WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/graph',
			$graph_args,
			true
		);
	}

	/**
	 * Get datepicker settings.
	 *
	 * @since 1.18.0
	 *
	 * @return array
	 */
	private function get_datepicker_settings(): array {

		return [
			'timespan' => $this->datepicker->get_active_timespan(),
			'date'     => $this->datepicker->get_current_date(),
			'choices'  => $this->datepicker->get_date_choices(),
			'ranges'   => $this->datepicker->get_ranges(),
		];
	}

	/**
	 * Get checkboxes settings.
	 *
	 * @since 1.18.0
	 *
	 * @return array
	 */
	private function get_fields_choices(): array {

		$fields = [];

		foreach ( $this->field_ids as $field_id ) {
			if ( empty( $this->form_data['fields'][ $field_id ]['type'] ) ) {
				continue;
			}

			$type = $this->form_data['fields'][ $field_id ]['type'];

			// Only include fields that support answer-choice filtering. Skip non-filterable types.
			if ( ! $this->field_is_filterable( $type ) ) {
				continue;
			}

			$fields[ $field_id ] = $this->get_field_choices( $this->form_data['fields'][ $field_id ] );
		}

		return $fields;
	}

	/**
	 * Get field choices to render filter checkboxes.
	 *
	 * Split into smaller helpers for readability and CS compliance.
	 *
	 * @since 1.18.0
	 *
	 * @param array $field Field data.
	 *
	 * @return array
	 */
	private function get_field_choices( array $field ): array {

		if ( empty( $field['type'] ) || ! isset( $field['id'] ) ) {
			return [];
		}

		[ $has_filter, $selected_values ] = $this->get_selected_values_for_field( $field );
		$primary_checked                  = $this->is_primary_checked_for_field( $field, $selected_values, $has_filter );
		$choices                          = [ $this->build_primary_choice( $field, $primary_checked ) ];

		// Text-like fields (virtual correct/incorrect options).
		if ( in_array( $field['type'], Helpers::supported_text_field_types(), true ) ) {
			return $this->build_text_field_choices( $choices, $has_filter, $selected_values );
		}

		// Rating fields use a numeric scale instead of a choices array.
		if ( $field['type'] === 'rating' ) {
			return $this->build_rating_field_choices( $choices, $field, $has_filter, $selected_values );
		}

		// NPS fields have a fixed 0-10 scale.
		if ( $field['type'] === 'net_promoter_score' ) {
			return $this->build_nps_field_choices( $choices, $has_filter, $selected_values );
		}

		// Choice-capable fields.
		return $this->build_choice_field_choices( $choices, $field, $has_filter, $selected_values );
	}

	/**
	 * Get filter presence and normalize selected values as strings.
	 *
	 * @since 1.18.0
	 *
	 * @param array $field Field data.
	 *
	 * @return array
	 */
	private function get_selected_values_for_field( array $field ): array {

		$has_filter      = isset( $this->filters[ 'field_' . $field['id'] ] );
		$selected_values = $has_filter ? array_map( 'strval', (array) $this->filters[ 'field_' . $field['id'] ] ) : [];

		return [ $has_filter, $selected_values ];
	}

	/**
	 * Determine if the primary ("All") checkbox should be checked.
	 *
	 * @since 1.18.0
	 *
	 * @param array $field           Field data.
	 * @param array $selected_values Selected values as strings.
	 * @param bool  $has_filter      Whether filter is present for the field.
	 *
	 * @return bool
	 */
	private function is_primary_checked_for_field( array $field, array $selected_values, bool $has_filter ): bool {
		// Checked when no filter is applied.
		if ( ! $has_filter ) {
			return true;
		}

		// Text-like fields: primary is checked when both virtual options are selected.
		if ( in_array( $field['type'], Helpers::supported_text_field_types(), true ) ) {
			return in_array( 'correct', $selected_values, true ) && in_array( 'incorrect', $selected_values, true );
		}

		// Rating fields: primary is checked when all scale values are selected.
		if ( $field['type'] === 'rating' ) {
			$scale    = ! empty( $field['scale'] ) ? (int) $field['scale'] : 5;
			$all_keys = array_map( 'strval', range( 1, $scale ) );

			return count( array_diff( $all_keys, $selected_values ) ) === 0;
		}

		// NPS fields: primary is checked when all 0-10 values are selected.
		if ( $field['type'] === 'net_promoter_score' ) {
			$all_keys = array_map( 'strval', range( 0, 10 ) );

			return count( array_diff( $all_keys, $selected_values ) ) === 0;
		}

		// Choice-capable: primary is checked when all options are selected.
		if ( ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) {
			$all_keys = [];

			foreach ( $field['choices'] as $idx => $choice ) {
				$opt_key    = isset( $choice['value'] ) && $choice['value'] !== '' ? (string) $choice['value'] : (string) $idx;
				$all_keys[] = (string) $opt_key;
			}

			return count( array_diff( $all_keys, $selected_values ) ) === 0;
		}

		return false;
	}

	/**
	 * Build the primary ("All") list item.
	 *
	 * @since 1.18.0
	 *
	 * @param array $field           Field data.
	 * @param bool  $primary_checked Checked state.
	 *
	 * @return array
	 */
	private function build_primary_choice( array $field, bool $primary_checked ): array {

		return [
			'key'        => '',
			'label'      => wp_strip_all_tags( $field['label'] ) !== '' ? wp_strip_all_tags( $field['label'] ) : ( '#' . $field['id'] ),
			'is_checked' => $primary_checked,
		];
	}

	/**
	 * Build choices for text-like fields (Correct/Incorrect).
	 *
	 * @since 1.18.0
	 *
	 * @param array $choices         Existing choices (contains primary).
	 * @param bool  $has_filter      Whether filter is applied.
	 * @param array $selected_values Selected values as strings.
	 *
	 * @return array
	 */
	private function build_text_field_choices( array $choices, bool $has_filter, array $selected_values ): array { // phpcs:ignore Generic.Metrics.NestingLevel.TooHigh

		return array_merge(
			$choices,
			[
				[
					'key'        => 'correct',
					'label'      => esc_html__( 'Correct', 'wpforms-surveys-polls' ),
					'is_checked' => ! $has_filter || in_array( 'correct', $selected_values, true ),
				],
				[
					'key'        => 'incorrect',
					'label'      => esc_html__( 'Incorrect', 'wpforms-surveys-polls' ),
					'is_checked' => ! $has_filter || in_array( 'incorrect', $selected_values, true ),
				],
			]
		);
	}

	/**
	 * Build choices for rating fields from the numeric scale.
	 *
	 * Rating fields do not have a choices array; instead they store a scale
	 * (e.g. 5) and an icon type (star, heart, etc.). Each scale value from
	 * 1 to N becomes a filterable option.
	 *
	 * @since 1.18.0
	 *
	 * @param array $choices         Existing choices (contains primary).
	 * @param array $field           Field data.
	 * @param bool  $has_filter      Whether filter is applied.
	 * @param array $selected_values Selected values as strings.
	 *
	 * @return array
	 */
	private function build_rating_field_choices( array $choices, array $field, bool $has_filter, array $selected_values ): array {

		$scale = ! empty( $field['scale'] ) ? (int) $field['scale'] : 5;
		$icon  = ! empty( $field['icon'] ) ? $field['icon'] : 'star';

		for ( $i = 1; $i <= $scale; $i++ ) {
			$choices[] = [
				'key'        => (string) $i,
				'label'      => Fields::get_rating_label( $icon, $i, $scale ),
				'is_checked' => ! $has_filter || in_array( (string) $i, $selected_values, true ),
			];
		}

		return $choices;
	}

	/**
	 * Build choices for Net Promoter Score fields.
	 *
	 * NPS has a fixed 0-10 numeric scale. Each value becomes a
	 * filterable option labelled plainly with its number.
	 *
	 * @since 1.18.0
	 *
	 * @param array $choices         Existing choices (contains primary).
	 * @param bool  $has_filter      Whether filter is applied.
	 * @param array $selected_values Selected values as strings.
	 *
	 * @return array
	 */
	private function build_nps_field_choices( array $choices, bool $has_filter, array $selected_values ): array {

		for ( $i = 0; $i <= 10; $i++ ) {
			$choices[] = [
				'key'        => (string) $i,
				'label'      => (string) $i,
				'is_checked' => ! $has_filter || in_array( (string) $i, $selected_values, true ),
			];
		}

		return $choices;
	}

	/**
	 * Build choices for choice-capable fields from field choices.
	 *
	 * @since 1.18.0
	 *
	 * @param array $choices         Existing choices (contains primary).
	 * @param array $field           Field data.
	 * @param bool  $has_filter      Whether filter is applied.
	 * @param array $selected_values Selected values as strings.
	 *
	 * @return array
	 */
	private function build_choice_field_choices( array $choices, array $field, bool $has_filter, array $selected_values ): array {

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

		foreach ( $field['choices'] as $idx => $choice ) {
			$opt_key   = isset( $choice['value'] ) && $choice['value'] !== '' ? (string) $choice['value'] : (string) $idx;
			$opt_label = isset( $choice['label'] ) ? wp_strip_all_tags( (string) $choice['label'] ) : ( '#' . (string) $idx );
			$choices[] = [
				'key'        => $opt_key,
				'label'      => $opt_label,
				'is_checked' => ! $has_filter || in_array( (string) $opt_key, $selected_values, true ),
			];
		}

		return $choices;
	}

	/**
	 * Enqueue assets.
	 *
	 * @since 1.0.0
	 */
	public function enqueues() {

		// Check if the form has fields with survey reporting enabled. If not, do not proceed.
		if ( empty( $this->field_ids ) ) {
			return;
		}

		$min = wpforms_get_min_suffix();

		/*
		 * JavaScript.
		 */
		wp_enqueue_script( 'wp-util' );

		// The PDF libraries are quite large. Originally, we restricted exporting to the
		// full survey report view only, but the preview (list) view also needs PDF export.
		// Load PDF libraries for both 'survey' and 'list' views.
		if ( in_array( $this->view, [ 'survey', 'list' ], true ) ) {
			wp_enqueue_script(
				'pdfmake',
				wpforms_surveys_polls()->url . 'assets/js/vendor/pdfmake.min.js',
				[],
				'0.1.35',
				true
			);

			wp_enqueue_script(
				'pdfmake-font',
				wpforms_surveys_polls()->url . 'assets/js/vendor/vfs_fonts.min.js',
				[],
				'0.1.35',
				true
			);
		}

		wp_enqueue_script(
			'wpforms-chart',
			WPFORMS_PLUGIN_URL . 'assets/lib/chart.min.js',
			[],
			'4.5.1',
			true
		);

		wp_enqueue_script(
			'randomColor',
			wpforms_surveys_polls()->url . "assets/js/vendor/randomColor{$min}.js",
			[],
			'0.5.2',
			false
		);

		wp_enqueue_script(
			'stupidtable',
			wpforms_surveys_polls()->url . "assets/js/vendor/stupidtable{$min}.js",
			[ 'jquery' ],
			'1.1.3',
			false
		);

		wp_enqueue_script(
			'jquery-minicolors',
			WPFORMS_PLUGIN_URL . 'assets/lib/jquery.minicolors/jquery.minicolors.min.js',
			[ 'jquery' ],
			'2.3.6',
			true
		);

		// Flatpickr for date range calendar in Filters popover.
		wp_enqueue_script(
			'wpforms-flatpickr',
			WPFORMS_PLUGIN_URL . 'assets/lib/flatpickr/flatpickr.min.js',
			[ 'jquery' ],
			'4.6.9',
			true
		);

		// Enqueue modern-screenshot.
		wp_enqueue_script(
			'modern-screenshot',
			wpforms_surveys_polls()->url . 'assets/js/vendor/modern-screenshot.min.js',
			[],
			'4.6.6',
			true
		);

		wp_enqueue_script(
			'wpforms-survey-reporting',
			wpforms_surveys_polls()->url . "assets/js/admin-survey-reporting{$min}.js",
			[ 'jquery', 'wp-color-picker', 'wpforms-chart', 'randomColor', 'stupidtable', 'jquery-minicolors', 'modern-screenshot', 'wpforms-flatpickr' ],
			WPFORMS_SURVEYS_POLLS_VERSION,
			true
		);

		wp_enqueue_script(
			'wpforms-survey-reporting-charts',
			wpforms_surveys_polls()->url . "assets/js/admin-survey-reporting-charts{$min}.js",
			[ 'wpforms-survey-reporting' ],
			WPFORMS_SURVEYS_POLLS_VERSION,
			true
		);

		wp_enqueue_script(
			'wpforms-survey-reporting-preview',
			wpforms_surveys_polls()->url . "assets/js/admin-survey-reporting-preview{$min}.js",
			[ 'wpforms-survey-reporting', 'wpforms-survey-reporting-charts' ],
			WPFORMS_SURVEYS_POLLS_VERSION,
			true
		);

		wp_localize_script(
			'wpforms-survey-reporting',
			'wpforms_surveys',
			[
				'type'        => $this->view,
				'form_id'     => $this->form_id,
				'entry_count' => $this->entry_count,
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				'field_ids'   => $this->view === 'survey' && empty( $_GET['field_id'] ) ? wp_json_encode( $this->field_ids ) : wp_json_encode( $this->field_id ),
				'field_id'    => ! empty( $this->field_id ) ? $this->field_id[0] : '',
				'field_nums'  => array_flip( $this->field_ids ),
				'loader'      => $this->display_loader( $this->view !== 'survey' ),
				'print'       => esc_url_raw( $this->urls['survey-report-print'] ),
				'cache'       => $this->get_report_cache_data(),
				'view_entry'  => esc_html__( 'View Entry', 'wpforms-surveys-polls' ),
				'entry_url'   => esc_url_raw( admin_url( 'admin.php?page=wpforms-entries&view=details&entry_id=' ) ),
				'filters'     => $this->filters,
				'date_field'  => [
					'delimiter' => $this->datepicker::RANGE_DELIMITER,
					'locale'    => sanitize_key( wpforms_get_language_code() ),
				],
				'i18n'        => [
					'save_title'         => esc_html__( 'Save your filter so you can easily reuse it in the future.', 'wpforms-surveys-polls' ),
					'save_button'        => esc_html__( 'Save filter', 'wpforms-surveys-polls' ),
					'cancel_button'      => esc_html__( 'Cancel', 'wpforms-surveys-polls' ),
					'edit_title'         => esc_html__( 'Save your changes to the existing filter or create a new one?', 'wpforms-surveys-polls' ),
					'edit_save_button'   => esc_html__( 'Save Changes', 'wpforms-surveys-polls' ),
					'edit_cancel_button' => esc_html__( 'Create New', 'wpforms-surveys-polls' ),
					'questions_answers'  => esc_html__( 'Questions & Answers', 'wpforms-surveys-polls' ),
					'delete_preset'      => esc_html__( 'Delete this preset?', 'wpforms-surveys-polls' ),
					'nps_detractors'     => esc_html__( 'Detractors (0-6)', 'wpforms-surveys-polls' ),
					'nps_passives'       => esc_html__( 'Passives (7-8)', 'wpforms-surveys-polls' ),
					'nps_promoters'      => esc_html__( 'Promoters (9-10)', 'wpforms-surveys-polls' ),
					'nps_score'          => esc_html__( 'Net Promoter Score', 'wpforms-surveys-polls' ),
				],
			]
		);

		// CSS.
		wp_enqueue_style( 'wp-color-picker' );
		wp_enqueue_style(
			'jquery-minicolors',
			WPFORMS_PLUGIN_URL . 'assets/lib/jquery.minicolors/jquery.minicolors.min.css',
			[],
			'2.3.6'
		);
		// Flatpickr styles (calendar UI for date range popover).
		wp_enqueue_style(
			'wpforms-flatpickr',
			WPFORMS_PLUGIN_URL . 'assets/lib/flatpickr/flatpickr.min.css',
			[],
			'4.6.9'
		);
		wp_enqueue_style(
			'wpforms-survey-reporting',
			wpforms_surveys_polls()->url . "assets/css/admin-survey-reporting{$min}.css",
			[ 'wp-color-picker', 'jquery-minicolors', 'wpforms-flatpickr' ],
			WPFORMS_SURVEYS_POLLS_VERSION
		);
	}

	/**
	 * Output the report cache data.
	 *
	 * @since 1.15.1
	 *
	 * @return array|bool
	 */
	private function get_report_cache_data() {

		// Output cache data if we have it, but provide a filter to disable survey report caching.
		$cache = false;

		/**
		 * Allow caching of survey report data.
		 *
		 * @since 1.0.0
		 *
		 * @param bool $is_cache_enabled  Whether to cache survey report data.
		 * @param int  $form_id           Form ID.
		 */
		if ( (bool) apply_filters( 'wpforms_surveys_polls_report_caching', true, $this->form_id ) ) { // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			// Build a cache key that is aware of the current filters to avoid reusing unfiltered data.
			$filter_hash = '';

			if ( ! empty( $this->filters ) && is_array( $this->filters ) ) {
				$normalized = $this->filters;

				ksort( $normalized );
				$filter_hash = '_' . substr( wp_hash( wp_json_encode( $normalized ) ), 0, 12 );
			}
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$base_key = $this->view === 'list' || ( $this->view === 'survey' && ! empty( $_GET['field_id'] ) )
				? "wpforms_survey_report_{$this->form_id}_{$this->entry_count}_{$this->field_id[0]}"
				: "wpforms_survey_report_{$this->form_id}_{$this->entry_count}";

			$cache_key = $base_key . $filter_hash;
			$cache     = get_transient( $cache_key );
		}

		return $cache ? json_decode( $cache, true ) : false;
	}

	/**
	 * Display abort message if form no longer available.
	 *
	 * @since 1.8.0
	 * @deprecated 1.11.0
	 */
	public function display_abort_message() {

		_deprecated_function( __METHOD__, '1.11.0 of the WPForms Surveys and Polls addon' );
		?>
		<div id="wpforms-entries-list" class="wrap wpforms-admin-wrap">
			<h1 class="page-title">
				<?php esc_html_e( 'Entries', 'wpforms-surveys-polls' ); ?>
			</h1>
			<div class="wpforms-admin-content">
				<?php
				// Output empty state screen.
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo wpforms_render(
					'admin/empty-states/no-entries',
					[
						'message' => esc_html__( 'It looks like the form you are trying to access is no longer available.', 'wpforms-surveys-polls' ),
					],
					true
				);
				?>
			</div>
		</div>
		<?php
	}

	/**
	 * Survey report page.
	 *
	 * @since 1.0.0
	 */
	public function report_page() {

		?>
		<div id="wpforms-entries-list" class="wrap wpforms-admin-wrap">
			<h1 class="page-title">
				<?php esc_html_e( 'Survey Results', 'wpforms-surveys-polls' ); ?>
				<a href="<?php echo esc_url( $this->urls['entries'] ); ?>" class="page-title-action wpforms-btn wpforms-btn-orange">
					<svg viewBox="0 0 16 14" class="page-title-action-icon">
						<path d="M16 6v2H4l4 4-1 2-7-7 7-7 1 2-4 4h12Z"/>
					</svg>
					<span class="page-title-action-text"><?php esc_html_e( 'Back to All Entries', 'wpforms-surveys-polls' ); ?></span>
				</a>
			</h1>
			<div class="wpforms-admin-content">
				<div class="form-details">
					<span class="form-details-sub"><?php esc_html_e( 'Select Form', 'wpforms-surveys-polls' ); ?></span>
					<h3 class="form-details-title">
						<?php
						if ( ! empty( $this->form_data['settings']['form_title'] ) ) {
							echo esc_html( sanitize_text_field( $this->form_data['settings']['form_title'] ) );
						}
						$this->form_selector_html();
						?>
					</h3>
					<div class="form-details-actions">
					<?php if ( wpforms_current_user_can( 'edit_form_single', $this->form_id ) ) : ?>
						<a href="<?php echo esc_url( $this->urls['form-edit'] ); ?>" class="form-details-actions-edit">
							<span class="dashicons dashicons-edit"></span>
							<?php esc_html_e( 'Edit This Form', 'wpforms-surveys-polls' ); ?>
						</a>
					<?php endif; ?>
					<?php if ( wpforms_current_user_can( 'view_form_single', $this->form_id ) ) : ?>
						<a href="<?php echo esc_url( $this->urls['form-preview'] ); ?>" class="form-details-actions-preview" target="_blank" rel="noopener">
							<span class="dashicons dashicons-visibility"></span>
							<?php esc_html_e( 'Preview Form', 'wpforms-surveys-polls' ); ?>
						</a>
					<?php endif; ?>
					<a href="<?php echo esc_url( $this->urls['entries-export'] ); ?>" class="form-details-actions-export">
						<span class="dashicons dashicons-migrate"></span>
						<?php esc_html_e( 'Export All', 'wpforms-surveys-polls' ); ?>
					</a>
					</div>
				</div>
				<div id="wpforms-survey-report">
					<?php
					$this->render_questions();
					echo $this->display_loader(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
					?>
				</div>
			</div>
		</div>
		<?php
	}

	/**
	 * Display form selector HTML.
	 *
	 * @since 1.5.8
	 */
	protected function form_selector_html() {

		if ( ! wpforms_current_user_can( 'view_forms' ) ) {
			return;
		}

		if ( empty( $this->forms ) ) {
			return;
		}

		?>
		<div class="form-selector">
			<a href="#" title="<?php esc_attr_e( 'Open form selector', 'wpforms-surveys-polls' ); ?>" class="toggle dashicons dashicons-arrow-down-alt2"></a>
			<div class="form-list">
				<ul>
					<?php
					foreach ( $this->forms as $key => $form ) {
						$form_url = add_query_arg(
							[
								'page'    => 'wpforms-entries',
								'view'    => 'list',
								'form_id' => absint( $form->ID ),
							],
							admin_url( 'admin.php' )
						);

						echo '<li><a href="' . esc_url( $form_url ) . '">' . esc_html( $form->post_title ) . '</a></li>';
					}
					?>
				</ul>
			</div>
		</div>
		<?php
	}

	/**
	 * Survey report printable page.
	 *
	 * @since 1.0.0
	 */
	public function report_print_page() {

		// Check if we should show the survey report print template.
		if ( ! $this->print ) {
			return;
		}

		?>
		<!doctype html>
		<html>
		<head>
			<meta charset="utf-8">
			<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
			<title><?php esc_html_e( 'WPForms Survey Print Preview', 'wpforms-surveys-polls' ); ?> - <?php echo esc_html( sanitize_text_field( $this->form_data['settings']['form_title'] ) ); ?></title>
			<meta name="viewport" content="width=device-width, initial-scale=1">
			<meta name="robots" content="noindex,nofollow,noarchive">
			<?php
			// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
			do_action( 'admin_enqueue_scripts' );
			// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
			do_action( 'admin_print_scripts' );
			// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
			do_action( 'admin_head' );
			?>
		</head>
		<body id="wpforms-survey-print-preview">
			<h1 class="header">
				<?php echo esc_html( sanitize_text_field( $this->form_data['settings']['form_title'] ) ); ?>
				<div class="buttons">
					<button type="button" id="wpforms-survey-print-close"><?php esc_html_e( 'Close', 'wpforms-surveys-polls' ); ?></button>
					<button type="button" id="wpforms-survey-print"><?php esc_html_e( 'Print', 'wpforms-surveys-polls' ); ?></button>
				</div>
			</h1>
			<div id="wpforms-survey-report">
				<?php
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$single_field = ! empty( $_GET['field_id'] );

				// Show toggle buttons only for the full report print (not single-field view).
				$this->render_questions( ! $single_field );
				echo $this->display_loader( $single_field ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				?>
			</div>
			<?php
			// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
			do_action( 'admin_print_footer_scripts' );
			?>
		</body>
		</html>
		<?php
		exit();
	}

	/**
	 * Output HTML markup for our loading animation indicator.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $single If we are loading a single field or all results.
	 *
	 * @return string
	 */
	public function display_loader( $single = false ) {

		return wpforms_render(
			WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/loader',
			[ 'single' => $single ],
			true
		);
	}

	/**
	 * Render pre-built question card containers with controls.
	 *
	 * @since 1.18.0
	 *
	 * @param bool $show_toggles Whether to render print-preview toggle buttons.
	 */
	private function render_questions( bool $show_toggles = false ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded, Generic.Metrics.CyclomaticComplexity.TooHigh

		if ( empty( $this->field_ids ) || empty( $this->form_data ) ) {
			return;
		}

		// In print view, render questions without pagination.
		// When a single field is requested via field_id param, print only that field.
		if ( $this->print ) {
			$print_fields       = ! empty( $this->field_id ) ? $this->field_id : $this->field_ids;
			$question_num       = 1;
			$filter_rendered    = false;
			$only_current_field = count( (array) $print_fields ) === 1;

			foreach ( $print_fields as $field_id ) {
				if ( empty( $this->form_data['fields'][ $field_id ] ) ) {
					continue;
				}

				$type         = $this->form_data['fields'][ $field_id ]['type'] ?? '';
				$show_filters = ! $filter_rendered; // Filters must appear on the very first question only.

				// In print view for a single field, hide Filters if the current field doesn't support filtering.
				if ( $only_current_field && ! $this->field_is_filterable( $type ) ) {
					$show_filters = false;
				}

				if ( $this->field_has_chart( $type ) ) {
					$this->render_graph(
						$this->get_question( $field_id ),
						[
							'is_question_dropdown' => false,
							'filter'               => $show_filters,
							'only_current_field'   => $only_current_field,
							'legend_pagination'    => false,
							'show_toggle'          => $show_toggles,
							// In print view for a single question, hide the "Apply To All Graphs" option.
							'hide_apply_all'       => (bool) $only_current_field,
						]
					);

					if ( $show_filters ) {
						$filter_rendered = true;
					}
				} else {
					// Non-chart question. Still render Filters on the very first question.
					$filter_settings = null;

					if ( $show_filters ) {
						$fields_for_filters = $this->get_fields_choices();

						if ( $only_current_field ) {
							if ( is_numeric( $field_id ) && isset( $fields_for_filters[ $field_id ] ) ) {
								$fields_for_filters = [ $field_id => $fields_for_filters[ $field_id ] ];
							} else {
								$fields_for_filters = [];
							}
						}

						$filter_settings = [
							'enabled'           => true,
							'hidden'            => false,
							'survey_type_field' => [],
							'fields'            => $fields_for_filters,
							'date_picker'       => $this->get_datepicker_settings(),
							'presets'           => $this->get_presets(),
							'current_preset'    => $this->current_preset,
							'applied'           => $this->filters,
						];
						$filter_rendered = true;
					}
					$this->render_question_card( $field_id, $question_num, $show_toggles, false, $filter_settings );
				}

				++$question_num;
			}

			return;
		}

		// Render only the first page worth of graphs initially; others are loaded via AJAX.
		$this->display_graph_fields( 1 );
	}

	/**
	 * Display graph fields on the Survey Results page with pagination support.
	 *
	 * @since 1.18.0
	 *
	 * @param int $page Page number to render.
	 */
	private function display_graph_fields( int $page ): void {

		/**
		 * Filter the number of graphs displayed per page on the Survey Results page.
		 *
		 * @since 1.18.0
		 *
		 * @param int   $per_page  Number of graphs per page.
		 * @param int   $form_id   Current form ID.
		 * @param array $form_data Current form data.
		 */
		$graphs_per_page = (int) apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
			'wpforms_surveys_admin_entries_all_results_page_graphs_per_page',
			self::GRAPHS_PER_PAGE,
			$this->form_id,
			$this->form_data
		);

		$has_load_more = false;
		$skip          = $page === 1 ? 0 : $graphs_per_page * ( $page - 1 );
		$displaying    = 0;
		$question_num  = $skip + 1;

		foreach ( $this->field_ids as $field_id ) {
			if ( $skip ) {
				--$skip;
				continue;
			}

			if ( $displaying >= $graphs_per_page ) {
				$has_load_more = true;

				break;
			}

			$type = $this->form_data['fields'][ $field_id ]['type'] ?? '';

			$show_filters = ( $page === 1 && $displaying === 0 );

			if ( $this->field_has_chart( $type ) ) {
				// Show Filters menu only for the very first question on the Survey Results page.
				$this->render_graph(
					$this->get_question( $field_id ),
					[
						'is_question_dropdown' => false,
						'filter'               => $show_filters,
						'legend_pagination'    => false,
					]
				);
			} else {
				// Non-chart question; still show the Filters button if this is the very first question.
				$filter_settings = null;

				if ( $show_filters ) {
					$filter_settings = [
						'enabled'           => true,
						'hidden'            => false,
						'survey_type_field' => [],
						'fields'            => $this->get_fields_choices(),
						'date_picker'       => $this->get_datepicker_settings(),
						'presets'           => $this->get_presets(),
						'current_preset'    => $this->current_preset,
						'applied'           => $this->filters,
					];
				}
				$this->render_question_card( $field_id, $question_num, false, false, $filter_settings );
			}

			++$displaying;
			++$question_num;
		}

		if ( $has_load_more && ! $this->print ) {
			?>
			<div class="wpforms-survey-graph-preview wpforms-survey-graph-preview-load-more">
				<button class="button wpforms-survey-button-load-more" data-next-page="<?php echo absint( $page + 1 ); ?>"><?php esc_html_e( 'Load More', 'wpforms-surveys-polls' ); ?></button>
			</div>
			<?php
		}
	}

	/**
	 * AJAX: Load more survey result graphs.
	 *
	 * @since 1.18.0
	 */
	public function ajax_load_more(): void {

		check_ajax_referer( 'wpforms-admin', 'nonce' );

		$page    = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Missing
		$form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing

		if ( ! $form_id || ! wpforms_current_user_can( 'view_entries_form_single', $form_id ) ) {
			wp_send_json_error();
		}

		$form_data = (array) wpforms()->obj( 'form' )->get(
			$form_id,
			[
				'content_only' => true,
				'cap'          => 'view_entries_form_single',
			]
		);

		if ( empty( $form_data ) ) {
			wp_send_json_error();
		}

		// Prepare context needed by display_graph_fields().
		$this->form_id   = $form_id;
		$this->form_data = $form_data;
		$this->field_ids = Fields::get_survey_fields( $this->form_data, true );

		// Initialize runtime dependencies used by render_graph() when called via AJAX.
		// Ensure filters, datepicker, and presets context exist to avoid fatal errors.
		// If filters were provided via AJAX, decode and apply them so newly loaded graphs respect current selections.
		$this->filters    = [];
		$incoming_filters = null;

		if ( ! empty( $_POST['filters'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			// phpcs:disable WordPress.Security
			$raw_filters      = wp_unslash( $_POST['filters'] );
			$decoded          = json_decode( $raw_filters, true );
			$incoming_filters = is_array( $decoded ) ? $decoded : null;
			// phpcs:enable WordPress.Security
		}
		// Normalize and store filters; also determines current preset when applicable.
		$this->set_filters( $incoming_filters );

		$this->current_preset = $this->current_preset ?? '';
		$this->datepicker     = new Datepicker( $this->filters );
		$this->view           = 'survey';

		ob_start();
		$this->display_graph_fields( $page );
		wp_send_json_success( ob_get_clean() );
	}

	/**
	 * Determine whether a field type supports chart rendering.
	 *
	 * @since 1.18.0
	 *
	 * @param string $type Field type.
	 *
	 * @return bool
	 */
	private function field_has_chart( string $type ): bool {

		// Only fields with selectable choices should render charts.
		// Textual inputs (single line, paragraph, etc.) and Likert Scale should be table-only.
		if ( in_array( $type, Helpers::supported_text_field_types(), true ) ) {
			return false;
		}

		if ( $type === 'likert_scale' ) {
			return false;
		}

		return true;
	}

	/**
	 * Check whether a field type supports answer-choice filtering.
	 *
	 * Fields like text, textarea, and likert_scale do not have user-defined
	 * choices that can be meaningfully filtered, so they should be excluded
	 * from the Questions & Answers filter menu and should not be refreshed
	 * when filters change.
	 *
	 * @since 1.18.0
	 *
	 * @param string $type Field type.
	 *
	 * @return bool True if the field type supports filtering.
	 */
	private function field_is_filterable( string $type ): bool {

		static $non_filterable = null;

		if ( $non_filterable === null ) {
			$non_filterable = array_merge(
				Helpers::supported_text_field_types(),
				[ 'likert_scale' ]
			);
		}

		return ! in_array( $type, $non_filterable, true );
	}

	/**
	 * Render a question wrapper card for non-chart fields (table-only, likert, text, etc.).
	 *
	 * @since 1.18.0
	 *
	 * @param int        $field_id        Field ID.
	 * @param int        $question_num    Question index number.
	 * @param bool       $show_toggles    Whether to render print toggle.
	 * @param bool       $is_hidden       Whether to hide initially.
	 * @param array|null $filter_settings Filter settings to render the Filters menu for this question.
	 */
	private function render_question_card( int $field_id, int $question_num, bool $show_toggles, bool $is_hidden, ?array $filter_settings = null ): void {

		if ( empty( $this->form_data['fields'][ $field_id ] ) ) {
			return;
		}

		$field      = $this->form_data['fields'][ $field_id ];
		$field_type = sanitize_key( $field['type'] ?? '' );
		$label      = ! empty( $field['label'] ) ? wp_strip_all_tags( $field['label'] ) : ( '#' . $field_id );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo wpforms_render(
			WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/question',
			[
				'field_id'        => $field_id,
				'field_type'      => $field_type,
				'question_num'    => $question_num,
				'label'           => $label,
				'badge'           => '',
				'has_chart'       => false,
				'is_list'         => ( $this->view === 'survey' ),
				'show_toggle'     => $show_toggles,
				'is_hidden'       => $is_hidden,
				'filter_settings' => $filter_settings,
			],
			true
		);
	}

	/**
	 * Underscore template for question data content (chart, table, stats).
	 *
	 * Question card containers and controls are pre-rendered in PHP via
	 * render_questions(). This template only handles the dynamic data content
	 * populated into .wpforms-survey-question-content after AJAX fetch.
	 *
	 * @since 1.0.0
	 */
	public function question_template() {

		// Check if the form has fields with survey reporting enabled.
		// If not do not proceed.
		if ( empty( $this->field_ids ) ) {
			return;
		}
		?>
		<script type="text/html" id="tmpl-wpforms-question-content">
			<# var fieldData = data; #>
			<# if ( _.isEmpty( fieldData.answers ) ) { #>
				<div class="no-answers">
					<i class="fa fa-exclamation-circle" aria-hidden="true"></i>
					<p><?php esc_html_e( 'There are no answers to this question yet.', 'wpforms-surveys-polls' ); ?></p>
				</div>
			<# } else { #>
 			<# if ( ! _.isEmpty( fieldData.chart.supports ) ) { #>
					<div class="wpforms-survey-graph-content-chart {{ fieldData.type }}">
						<canvas id="chart-{{ fieldData.id }}"></canvas>
					</div>
					<!-- High-quality canvas for exports -->
					<div class="wpforms-hidden">
						<canvas id="chart-{{ fieldData.id }}-hq" width="1200" height="600"></canvas>
					</div>
					<# if ( 'net_promoter_score' === fieldData.type ) { #>
						<div class="wpforms-survey-graph-content-legend">
							<div class="wpforms-survey-graph-content-legend-table-wrapper">
								<table class="wpforms-survey-graph-content-legend-table">
									<thead>
										<tr>
											<th><?php esc_html_e( 'Detractors (0-6)', 'wpforms-surveys-polls' ); ?></th>
											<th><?php esc_html_e( 'Passives (7-8)', 'wpforms-surveys-polls' ); ?></th>
											<th><?php esc_html_e( 'Promoters (9-10)', 'wpforms-surveys-polls' ); ?></th>
											<th class="score"><?php esc_html_e( 'Net Promoter Score', 'wpforms-surveys-polls' ); ?></th>
										</tr>
									</thead>
									<tbody>
										<tr>
											<td><span class="count">{{ fieldData.nps.detractors.count }}</span><span class="percent">{{ fieldData.nps.detractors.percent }}%</span></td>
											<td><span class="count">{{ fieldData.nps.passives.count }}</span><span class="percent">{{ fieldData.nps.passives.percent }}%</span></td>
											<td><span class="count">{{ fieldData.nps.promoters.count }}</span><span class="percent">{{ fieldData.nps.promoters.percent }}%</span></td>
											<td>{{ fieldData.nps.score }}</td>
										</tr>
									</tbody>
								</table>
							</div>
						</div>
					<# } else { #>
						<div class="wpforms-survey-graph-content-legend">
							<div class="wpforms-survey-graph-content-legend-table-wrapper">
								<table class="wpforms-survey-graph-content-legend-table wpforms-table-sorting">
									<thead>
										<tr>
											<th><?php esc_html_e( 'Answers', 'wpforms-surveys-polls' ); ?></th>
											<th data-sort="int" colspan="2"><span><?php esc_html_e( 'Responses', 'wpforms-surveys-polls' ); ?></span></th>
										</tr>
									</thead>
									<tbody>
										<# _.each( fieldData.answers, function( answer ) { #>
											<tr>
												<td><span>{{ answer.value }}</span></td>
												<td data-sort-value="{{ answer.percent }}"><span>{{ answer.percent }}%</span></td>
												<td><span class="total">{{ answer.count }}</span></td>
											</tr>
										<# }) #>
									</tbody>
								</table>
								<div class="wpforms-survey-graph-content-legend-info wpforms-hidden">
									<div class="wpforms-survey-graph-content-legend-info-items">
										<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-answered"><span></span>&nbsp;<?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?></div>
										<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-skipped"><span></span>&nbsp;<?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?></div>
									</div>
								</div>
							</div>
						</div>
					<# } #>
				<# } else if ( 'likert_scale' === fieldData.type ) { #>
					<# var rowCount = 1; #>
					<div class="wpforms-survey-graph-content-legend">
						<div class="wpforms-survey-graph-content-legend-table-wrapper">
							<table class="wpforms-survey-graph-content-legend-table likert-results<# if ( fieldData.table.single ) { print( ' single' ); } else { print( ' wpforms-table-sorting' ); } #>">
								<thead>
									<tr>
									<# if ( ! fieldData.table.single ) { #>
										<th style="width:20%;"></th>
									<# } #>
									<# _.each( fieldData.table.columns, function( columnLabel, key ) {  #>
										<th data-sort="int" data-sort-default="desc" style="width:{{ fieldData.table.width }}%;"<# if ( ! fieldData.table.single ) { print( ' class="sortable"' ); } #>><span>{{ columnLabel }}</span></th>
									<# }) #>
									</tr>
								</thead>
								<tbody>
									<# _.each( fieldData.table.rows, function( rowLabel, rowKey ) {  #>
										<# if ( ! fieldData.table.single || ( fieldData.table.single && rowCount === 1 ) ) { #>
											<tr>
											<# if ( ! fieldData.table.single ) { #>
												<td class="th">{{ rowLabel }}</td>
											<# } #>
											<# _.each( fieldData.table.columns, function( columnLabel, columnKey ) {  #>
												<td data-sort-value="{{ fieldData.answers[rowKey + '_' + columnKey].count }}"<# if ( fieldData.answers[rowKey + '_' + columnKey].highest ) { print( ' class="highest"' ); } #>>
													<# if ( fieldData.answers[rowKey + '_' + columnKey].count > 0 ) { #>
														<span class="count">{{ fieldData.answers[rowKey + '_' + columnKey].count }}</span>
														<span class="percent">{{ fieldData.answers[rowKey + '_' + columnKey].percent }}%</span>
													<# } #>
												</td>
											<# }) #>
											</tr>
										<# } #>
										<# rowCount++ #>
									<# }) #>
								</tbody>
							</table>
							<div class="wpforms-survey-graph-content-legend-info">
								<div class="wpforms-survey-graph-content-legend-info-items">
									<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-answered"><span>{{ fieldData.answered }}</span>&nbsp;<?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?></div>
									<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-skipped"><span>{{ fieldData.skipped }}</span>&nbsp;<?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?></div>
								</div>
							</div>
						</div>
					</div>
				<# } else { #>
					<div class="wpforms-survey-graph-content-legend">
						<div class="wpforms-survey-graph-content-legend-table-wrapper">
							<table class="wpforms-survey-graph-content-legend-table wpforms-table-sorting text-results">
								<thead>
									<tr>
										<th><?php esc_html_e( 'Answers', 'wpforms-surveys-polls' ); ?></th>
										<th data-sort="int" data-sort-default="desc" data-sort-onload="yes" class="date"><span><?php esc_html_e( 'Date', 'wpforms-surveys-polls' ); ?></span></th>
									</tr>
								</thead>
								<tbody>
									<# _.each( fieldData.answers, function( answer, key ) { #>
										<tr>
											<td><span>{{ answer.value }}</span></td>
											<td class="date" data-sort-value="{{ answer.date_unix }}">
												{{ answer.date }}
												<a href="<?php echo esc_url( admin_url( 'admin.php?page=wpforms-entries&view=details&entry_id=' ) ); ?>{{ answer.entry_id }}" title="<?php esc_html_e( "View respondent's answers", 'wpforms-surveys-polls' ); ?>" target="_blank" rel="noopener noreferrer" class="view-entry"><i class="fa fa-external-link" aria-hidden="true"></i></a>
											</td>
										</tr>
									<# }) #>
								</tbody>
							</table>
							<div class="wpforms-survey-graph-content-legend-info">
								<div class="wpforms-survey-graph-content-legend-info-items">
									<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-answered"><span>{{ fieldData.answered }}</span>&nbsp;<?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?></div>
									<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-skipped"><span>{{ fieldData.skipped }}</span>&nbsp;<?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?></div>
								</div>
							</div>
						</div>
					</div>
				<# } #>
				<# if ( ! _.isEmpty( fieldData.chart.supports ) ) { #>
					<div class="stats <# if ( fieldData.average ) { print( 'has-average' ); } #>">
						<div class="answered">
							<strong>{{ fieldData.answered }}</strong>
							<?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?>
						</div>
						<# if ( fieldData.average ) { #>
							<div class="average">
								<strong>{{ fieldData.average }}</strong>
								<?php esc_html_e( 'Average', 'wpforms-surveys-polls' ); ?>
							</div>
						<# } #>
						<div class="skipped">
							<strong>{{ fieldData.skipped }}</strong>
							<?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?>
						</div>
					</div>
				<# } #>
			<# } #>
		</script>
		<?php
	}

	/**
	 * Delete current cache when entry edited.
	 *
	 * @since 1.6.3
	 *
	 * @param array  $form_data      Form data.
	 * @param mixed  $response       Entries edit process response.
	 * @param array  $updated_fields Updated fields data.
	 * @param object $entry          Existing entry data.
	 */
	public function entries_edit_submit_clear_cache( $form_data, $response, $updated_fields, $entry ) {

		if ( empty( $response['modified'] ) || empty( $updated_fields ) ) {
			return;
		}

		$entry = (array) $entry;

		if ( ! wpforms_current_user_can( 'edit_entry_single', $entry['entry_id'] ) ) {
			return;
		}

		$fields      = ! empty( $form_data['fields'] ) ? $form_data['fields'] : [];
		$entry_count = wpforms()->obj( 'entry' )->get_entries( [ 'form_id' => $form_data['id'] ], true );
		$deleted     = false;

		foreach ( $fields as $field ) {

			if ( ! Fields::field_has_survey( $field, $form_data ) ) {
				continue;
			}

			if ( ! isset( $field['id'] ) || ! array_key_exists( $field['id'], $updated_fields ) ) {
				continue;
			}

			delete_transient( "wpforms_survey_report_{$form_data['id']}_{$entry_count}_{$field['id']}" );
			$deleted = true;
		}

		if ( $deleted ) {
			delete_transient( "wpforms_survey_report_{$form_data['id']}_{$entry_count}" );
		}
	}

	/**
	 * Drop cache for current count and all previous.
	 *
	 * @since 1.11.0
	 */
	private function entries_delete_clear_cache() {

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$option_names = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s",
				'_transient_%' . $wpdb->esc_like( "wpforms_survey_report_{$this->form_id}_" ) . '%'
			)
		);

		if ( empty( $option_names ) ) {
			return;
		}

		$option_names = array_map(
			static function ( $option_name ) {

				return str_replace( [ '_transient_timeout_', '_transient_' ], '', $option_name );
			},
			$option_names
		);

		$option_names = array_unique( $option_names );

		foreach ( $option_names as $option_name ) {
			delete_transient( $option_name );
		}
	}
}