<?php

// phpcs:disable Generic.Commenting.DocComment.MissingShort
/** @noinspection PhpIllegalPsrClassPathInspection */

use WPForms\Pro\Admin\Entries\ListTable;
use WPForms\Pro\AntiSpam\Helpers as AntiSpamHelpers;
use WPForms\Pro\Forms\Fields\FileUpload\Field as FileUploadField;

/** @noinspection AutoloadingIssuesInspection */
// phpcs:enable Generic.Commenting.DocComment.MissingShort

/**
 * Entry DB class.
 *
 * @since 1.0.0
 */
class WPForms_Entry_Handler extends WPForms_DB {

	/**
	 * Non-persistent cache group.
	 *
	 * @since 1.7.5
	 */
	private const CACHE_GROUP = __CLASS__;

	/**
	 * Primary class constructor.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {

		global $wpdb;

		parent::__construct();

		$this->table_name  = $wpdb->prefix . 'wpforms_entries';
		$this->primary_key = 'entry_id';
		$this->type        = 'entries';

		wp_cache_add_non_persistent_groups( self::CACHE_GROUP );
	}

	/**
	 * List of an editable fields type.
	 *
	 * @since 1.7.0
	 *
	 * @return array
	 */
	public function get_editable_field_types(): array {

		return [
			'checkbox',
			'email',
			'name',
			'number',
			'number-slider',
			'radio',
			'select',
			'text',
			'textarea',
			'address',
			'date-time',
			'phone',
			'rating',
			'richtext',
			'url',
			'file-upload',
			'camera',
		];
	}

	/**
	 * Get table columns.
	 *
	 * @since 1.0.0
	 * @since 1.5.7 Added an `Entry Notes` column.
	 */
	public function get_columns() {

		return [
			'entry_id'      => '%d',
			'notes_count'   => '%d',
			'form_id'       => '%d',
			'post_id'       => '%d',
			'user_id'       => '%d',
			'status'        => '%s',
			'type'          => '%s',
			'viewed'        => '%d',
			'starred'       => '%d',
			'fields'        => '%s',
			'meta'          => '%s',
			'date'          => '%s',
			'date_modified' => '%s',
			'ip_address'    => '%s',
			'user_agent'    => '%s',
			'user_uuid'     => '%s',
		];
	}

	/**
	 * Default column values.
	 *
	 * @since 1.0.0
	 */
	public function get_column_defaults() {

		return [
			'form_id'       => '',
			'post_id'       => '',
			'user_id'       => '',
			'status'        => '',
			'type'          => '',
			'fields'        => '',
			'meta'          => '',
			'date'          => date( 'Y-m-d H:i:s' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
			'date_modified' => date( 'Y-m-d H:i:s' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
			'ip_address'    => '',
			'user_agent'    => '',
			'user_uuid'     => '',
		];
	}

	/**
	 * Retrieve an entry from the database based on a given entry ID.
	 *
	 * @since 1.5.8
	 *
	 * @param int   $entry_id Entry ID.
	 * @param array $args     Additional arguments.
	 *
	 * @return object|null
	 * @noinspection PhpParameterNameChangedDuringInheritanceInspection
	 */
	public function get( $entry_id, $args = [] ) {

		$access = wpforms()->obj( 'access' );

		if ( ! isset( $args['cap'] ) && $access && $access->init_allowed() ) {
			$args['cap'] = 'view_entry_single';
		}

		if ( ! empty( $args['cap'] ) && ! wpforms_current_user_can( $args['cap'], $entry_id ) ) {
			return null;
		}

		return parent::get( $entry_id );
	}

	/**
	 * Update an existing entry in the database.
	 *
	 * @since 1.5.8
	 *
	 * @param string $id    Entry ID.
	 * @param array  $data  Array of columns and associated data to update.
	 * @param string $where Column to match against in the WHERE clause. If empty, $primary_key will be used.
	 * @param string $type  Data type context.
	 * @param array  $args  Additional arguments.
	 *
	 * @return bool|null
	 * @noinspection PhpParameterNameChangedDuringInheritanceInspection
	 */
	public function update( $id, $data = [], $where = '', $type = '', $args = [] ) {

		if ( ! isset( $args['cap'] ) ) {
			$args['cap'] = ( array_key_exists( 'viewed', $data ) || array_key_exists( 'starred', $data ) ) ? 'view_entry_single' : 'edit_entry_single';
		}

		if ( ! empty( $args['cap'] ) && ! wpforms_current_user_can( $args['cap'], $id ) ) {
			return null;
		}

		return parent::update( $id, $data, $where, $type );
	}

	/**
	 * Delete an entry from the database, also removes entry meta.
	 *
	 * Please note: successfully deleting a record flushes the cache.
	 *
	 * @since 1.1.6
	 *
	 * @param int   $entry_id Entry ID.
	 * @param array $args     Additional arguments.
	 *
	 * @return bool False if the record could not be deleted, true otherwise.
	 * @noinspection PhpParameterNameChangedDuringInheritanceInspection
	 */
	public function delete( $entry_id = 0, $args = [] ): bool {

		if ( ! isset( $args['cap'] ) ) {
			$args['cap'] = 'delete_entry_single';
		}

		if ( ! empty( $args['cap'] ) && ! wpforms_current_user_can( $args['cap'], $entry_id ) ) {
			return false;
		}

		FileUploadField::delete_uploaded_files_from_entry( $entry_id );

		$entry        = parent::delete( $entry_id );
		$entry_meta   = wpforms()->obj( 'entry_meta' );
		$entry_fields = wpforms()->obj( 'entry_fields' );
		$meta         = null;
		$fields       = null;

		if ( $entry_meta ) {
			$meta = $entry_meta->delete_by( 'entry_id', $entry_id );
		}

		if ( $entry_fields ) {
			$fields = $entry_fields->delete_by( 'entry_id', $entry_id );
		}

		WPForms\Pro\Admin\DashboardWidget::clear_widget_cache();

		return ( $entry && $meta && $fields );
	}

	/**
	 * Purge spam entries.
	 *
	 * @since 1.9.1
	 */
	public function purge_spam() {

		global $wpdb;

		$last_n_days = AntiSpamHelpers::get_delete_spam_entries_days();

		if ( ! $last_n_days ) {
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$entry_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT entry_id FROM {$wpdb->prefix}wpforms_entries WHERE date < DATE_SUB(NOW(), INTERVAL %d DAY) AND status = 'spam'",
				$last_n_days
			)
		);

		if ( ! $entry_ids ) {
			return;
		}

		foreach ( $entry_ids as $entry_id ) {
			FileUploadField::delete_uploaded_files_from_entry( $entry_id );
		}

		wpforms()->obj( 'entry_meta' )->delete_where_in( 'entry_id', $entry_ids );
		wpforms()->obj( 'entry_fields' )->delete_where_in( 'entry_id', $entry_ids );

		$where_ids = wpforms_wpdb_prepare_in( $entry_ids, '%d' );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->prefix}wpforms_entries WHERE entry_id IN ( {$where_ids} ) AND status = %s",
				'spam'
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Get the next entry.
	 *
	 * @since 1.1.5
	 * @since 1.8.3 Added $status parameter.
	 *
	 * @param int    $entry_id Entry ID.
	 * @param int    $form_id  Form ID.
	 * @param string $status   Entry status.
	 *
	 * @return object|null Object from DB values or null.
	 */
	public function get_next( $entry_id, $form_id, $status ) {

		global $wpdb;

		if ( empty( $entry_id ) || empty( $form_id ) ) {
			return null;
		}

		$where_status = $this->get_status_where_clause( $status );

		// Note: we're disabling InterpolatedNotPrepared sniff because it triggers
		// a false positive when using operator (= or !=) in the query. The
		// prepare() method does not support placeholders for operators.

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $this->get_row(
			$wpdb->prepare(
				"SELECT * FROM $this->table_name
				WHERE `form_id` = %d
  				  AND {$this->primary_key} > %d
				  {$where_status}
				ORDER BY {$this->primary_key}
				LIMIT 1;",
				absint( $form_id ),
				absint( $entry_id )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get previous entry.
	 *
	 * @since 1.1.5
	 * @since 1.8.3 Added $status parameter.
	 *
	 * @param int    $entry_id Entry ID.
	 * @param int    $form_id  Form ID.
	 * @param string $status   Entry status.
	 *
	 * @return object|null Object from DB values or null.
	 * @noinspection PhpUnused
	 */
	public function get_prev( $entry_id, $form_id, $status ) {

		global $wpdb;

		if ( empty( $entry_id ) || empty( $form_id ) ) {
			return null;
		}

		$where_status = $this->get_status_where_clause( $status );

		// Note: we're disabling InterpolatedNotPrepared sniff because it triggers
		// a false positive when using operator (= or !=) in the query. The
		// prepare() method does not support placeholders for operators.

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $this->get_row(
			$wpdb->prepare(
				"SELECT * FROM $this->table_name
				WHERE `form_id` = %d
				  AND {$this->primary_key} < %d
				  {$where_status}
				ORDER BY {$this->primary_key} DESC
				LIMIT 1;",
				absint( $form_id ),
				absint( $entry_id )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get the last entry of a specific form.
	 *
	 * @since 1.5.0
	 * @since 1.8.6 Added `$status` and `$order_by` parameters.
	 *
	 * @param int    $form_id  Form ID.
	 * @param string $status   Entry status.
	 * @param string $order_by Order by.
	 *
	 * @return object|null Object from DB values or null.
	 * @noinspection PhpUnused
	 */
	public function get_last( $form_id, string $status = '', string $order_by = '' ) {

		global $wpdb;

		if ( empty( $form_id ) ) {
			return null;
		}

		if ( empty( $order_by ) ) {
			$order_by = $this->primary_key;
		}

		$where_status = $this->get_status_where_clause( $status );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
		return $this->get_row(
			$wpdb->prepare(
				"SELECT * FROM $this->table_name
				WHERE `form_id` = %d
				{$where_status}
				ORDER BY $order_by DESC
				LIMIT 1;",
				[
					(int) $form_id,
				]
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Mark all entries read for a form.
	 *
	 * @since 1.1.6
	 *
	 * @param int $form_id Form ID.
	 *
	 * @return bool
	 * @noinspection PhpUnused
	 */
	public function mark_all_read( $form_id = 0 ): bool {

		global $wpdb;

		if ( empty( $form_id ) ) {
			return false;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$status       = isset( $_GET['status'] ) ? sanitize_key( $_GET['status'] ) : '';
		$where_status = $this->get_status_where_clause( $status );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
		$result = $wpdb->query(
			$wpdb->prepare(
				"UPDATE $this->table_name
				SET `viewed` = '1'
				WHERE `form_id` = %d
				{$where_status}",
				(int) $form_id
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching

		return (bool) $result;
	}

	/**
	 * Get the next entries count.
	 *
	 * @since 1.5.0
	 * @since 1.8.3 Added $status parameter.
	 *
	 * @param int    $entry_id Entry ID.
	 * @param int    $form_id  Form ID.
	 * @param string $status   Entry status.
	 *
	 * @return int
	 * @noinspection PhpUnused
	 */
	public function get_next_count( $entry_id, $form_id, $status ): int {

		global $wpdb;

		if ( empty( $form_id ) ) {
			return 0;
		}

		$where_status = $this->get_status_where_clause( $status );

		// Note: we're disabling InterpolatedNotPrepared sniff because it triggers
		// a false positive when using operator (= or !=) in the query. The
		// prepare() method does not support placeholders for operators.

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$prev_count = $this->get_var(
			$wpdb->prepare(
				"SELECT COUNT({$this->primary_key}) FROM {$this->table_name}
				WHERE `form_id` = %d AND {$this->primary_key} > %d
				  {$where_status}
				ORDER BY {$this->primary_key}",
				absint( $form_id ),
				absint( $entry_id )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return absint( $prev_count );
	}

	/**
	 * Get previous entries count.
	 *
	 * @since 1.1.5
	 * @since 1.5.0 Changed the return type to always be an integer.
	 * @since 1.8.3 Added $status parameter.
	 *
	 * @param int    $entry_id Entry ID.
	 * @param int    $form_id  Form ID.
	 * @param string $status   Entry status.
	 *
	 * @return int
	 * @noinspection PhpUnused
	 */
	public function get_prev_count( $entry_id, $form_id, $status ): int {

		global $wpdb;

		if ( empty( $entry_id ) || empty( $form_id ) ) {
			return 0;
		}

		$where_status = $this->get_status_where_clause( $status );

		// Note: we're disabling InterpolatedNotPrepared sniff because it triggers
		// a false positive when using operator (= or !=) in the query. The
		// prepare() method does not support placeholders for operators.

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$prev_count = $this->get_var(
			$wpdb->prepare(
				"SELECT COUNT({$this->primary_key}) FROM {$this->table_name}
				WHERE `form_id` = %d AND {$this->primary_key} < %d
				  {$where_status}
				ORDER BY {$this->primary_key}",
				absint( $form_id ),
				absint( $entry_id )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return absint( $prev_count );
	}

	/**
	 * Get all counts by one request.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args Arguments.
	 *
	 * @return array
	 */
	public function get_counts( array $args ): array {

		$args = wp_parse_args(
			$args,
			[ 'status' => 'any' ]
		);
		$args = $this->prepare_args( $args );

		$where     = $this->prepare_where( $args );
		$where_sql = implode( ' AND ', array_unique( array_filter( $where ) ) );

		$publish = "$this->table_name.status NOT IN ('spam', 'trash')";

		$results = $this->get_results(
			"SELECT COUNT(CASE WHEN $publish THEN 1 END) as total,
			        COUNT(CASE WHEN $this->table_name.status = '' THEN 1 END) AS published,
					COUNT(CASE WHEN $this->table_name.viewed = 0 AND $publish THEN 1 END) AS unread,
                    COUNT(CASE WHEN $this->table_name.type = 'payment' AND $publish THEN 1 END) AS payment,
       				COUNT(CASE WHEN $this->table_name.starred = 1 AND $publish THEN 1 END) AS starred,
       				COUNT(CASE WHEN $this->table_name.status = 'spam' THEN 1 END)  AS spam,
       				COUNT(CASE WHEN $this->table_name.status = 'trash' THEN 1 END)  AS trash
						FROM $this->table_name
						WHERE $where_sql",
			ARRAY_A
		);

		return array_map( 'intval', $results[0] ?? [] );
	}

	/**
	 * Get entries from the database.
	 *
	 * @since 1.0.0
	 * @since 1.5.7 Added a `notes_count` argument to request the count of notes for each entry.
	 * @since 1.6.9 Implemented filtering by `entry_id`, `ip_address` and `notes` in case of "any fields" search.
	 *
	 * @param array $args  Redefine query parameters by providing own arguments.
	 * @param bool  $count Whether to just count entries or get the list of them. True to just count.
	 *
	 * @return array|int
	 */
	public function get_entries( $args = [], $count = false ) {

		$args      = $this->prepare_args( $args );
		$from_sql  = $this->prepare_from_sql( $args );
		$where_sql = $this->prepare_where_sql( $args );

		/*
		 * Retrieve the results.
		 */
		if ( $count === true ) {
			return absint(
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$this->get_var(
					"SELECT COUNT( $this->table_name.$this->primary_key )
					FROM $from_sql
					WHERE $where_sql"
				)
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			);
		}

		$select = $this->prepare_select( $args );

		$sql = "
			SELECT $select
			FROM $from_sql
			WHERE $where_sql
			ORDER BY {$args['orderby']} {$args['order']}
			LIMIT {$args['offset']}, {$args['number']}
		";

		return $this->get_results( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Compile and perform an additional SQL query if needed.
	 *
	 * We need to perform the additional "second" query to prepare the where clause for the main query.
	 * Unfortunately, compiling all the logic to the one query appeared an overcomplicated solution that is hard to
	 * debug and maintain.
	 *
	 * @since 1.6.9
	 *
	 * @param array $args  Arguments of the main `get_entries()` method.
	 * @param array $where Main `get_entries()` query WHERE array.
	 *
	 * @return array Updated WHERE. An array is needed to perform the main get_entries() query.
	 */
	protected function second_query_update_where( array $args, array $where ): array {

		$second_where = $this->second_query_where( $args );

		if ( empty( $second_where ) ) {
			return $where;
		}

		unset( $where['arg_entry_id'] );

		$second_where     = array_merge( $where, array_filter( $second_where ) );
		$second_where_sql = implode( ' AND ', $second_where );
		$second_sql_from  = $this->table_name;

		// Join the fields' table only if we need to search the fields.
		if (
			empty( $args['advanced_search'] ) &&
			! empty( $args['value_compare'] ) &&
			$args['value_compare'] !== 'empty' &&
			(
				( isset( $args['value'] ) && ! wpforms_is_empty_string( $args['value'] ) ) ||
				( isset( $args['field_id'] ) && is_numeric( $args['field_id'] ) ) ||
				$args['value_compare'] === 'not_empty'
			)
		) {
			$fields_table     = wpforms()->obj( 'entry_fields' )->table_name;
			$second_sql_from .= " JOIN $fields_table ON $this->table_name.`entry_id` = $fields_table.`entry_id`";
		}

		$second_sql = "
			SELECT $this->table_name.`entry_id`
			FROM $second_sql_from
			WHERE $second_where_sql
			GROUP BY `entry_id`
		";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
		$second_result    = $this->get_results( $second_sql );
		$second_entry_ids = ! empty( $second_result ) ? wp_list_pluck( $second_result, 'entry_id' ) : [];

		if ( empty( $second_entry_ids ) ) {
			$where['arg_entry_id'] = "$this->table_name.`entry_id` IN ( 0 )";

			return $where;
		}

		$ids = array_map( 'intval', (array) $args['entry_id'] );

		// `Any form field - does not contain, is not and is not empty` are special cases.
		$any_field_not =
			empty( $args['advanced_search'] ) &&
			( empty( $args['field_id'] ) || $args['field_id'] === 'any' ) &&
			in_array( $args['value_compare'], [ 'is_not', 'contains_not', 'not_empty' ], true );

		if (
			$any_field_not ||
			! empty( $args['advanced_search'] ) ||
			in_array( $args['value_compare'], [ 'is', 'contains', 'empty' ], true )
		) {
			$ids = array_merge( $ids, $second_entry_ids );
		} else {
			$ids = array_intersect( $second_entry_ids, $ids );
		}

		$ids = array_unique( $ids );
		$ids = empty( $ids ) ? [ 0 ] : $ids;
		$ids = implode( ',', array_map( 'intval', $ids ) );

		$where['arg_entry_id'] = "$this->table_name.`entry_id` IN ($ids)";

		return $where;
	}

	/**
	 * Prepare the second query WHERE, needed to perform the main get_entries() query.
	 *
	 * @since 1.6.9
	 *
	 * @param array $args Arguments of the main `get_entries()` method.
	 *
	 * @return array Updated WHERE array.
	 */
	protected function second_query_where( array $args ): array {

		$fields_table = wpforms()->obj( 'entry_fields' )->table_name;

		$second_where = [
			'arg_value' => $this->second_query_where_arg_value( $args ),
		];

		// Handle 'empty' comparison.
		if ( $args['value_compare'] === 'empty' ) {
			return $this->second_query_where_value_compare_is_empty( $args, $second_where, $fields_table );
		}

		if ( empty( $args['advanced_search'] ) && is_numeric( $args['field_id'] ) ) {
			$args['field_id']             = wpforms_validate_field_id( $args['field_id'] );
			$second_where['arg_field_id'] = "$fields_table.field_id = '{$args['field_id']}'";
		}

		if (
			! isset( $args['value'] ) ||
			wpforms_is_empty_string( $args['value'] ) ||
			in_array( $args['value_compare'], [ 'is', 'contains' ], true )
		) {
			return $second_where;
		}

		// For `inverse` logic (is_not or contains_not), we should use a different approach.
		if (
			empty( $args['advanced_search'] ) &&
			( is_numeric( $args['field_id'] ) || empty( $args['field_id'] ) || $args['field_id'] === 'any' )
		) {
			if ( empty( $args['field_id'] ) || $args['field_id'] === 'any' ) {
				unset( $second_where['arg_value'] );
			}

			$escaped_value   = esc_sql( $args['value'] );
			$condition_value = $args['value_compare'] === 'is_not' ? " = '$escaped_value'" : " LIKE '%$escaped_value%'";
			$form_ids        = implode( ',', array_map( 'intval', (array) $args['form_id'] ) );
			$form_ids_where  = ! empty( $form_ids ) ? "AND `form_id` IN ( $form_ids )" : '';

			if ( wpforms_is_empty_string( $escaped_value ) ) {

				$second_where['fields_entry_not_in'] = "$this->table_name.`entry_id` NOT IN ( 0 )";

			} else {

				$second_where['fields_entry_not_in'] = "$this->table_name.`entry_id` NOT IN (
					SELECT `entry_id`
					FROM {$fields_table}
					WHERE
						`value` {$condition_value}
						{$form_ids_where}
				)";

			}
		}

		if ( $args['advanced_search'] !== 'entry_notes' ) {
			return $second_where;
		}

		$entry_ids                         = $this->second_query_where_entry_notes_ids( $args );
		$second_where['meta_entry_not_in'] = "$this->table_name.`entry_id` NOT IN ( $entry_ids )";

		return $second_where;
	}

	/**
	 * Prepare a second query WHERE with arg_value item for `empty` comparison.
	 * Specific field: entries where there is no non-empty value row for that field (missing row or empty string considered empty).
	 *
	 * @since 1.9.9
	 *
	 * @param array  $args         Arguments of the main `get_entries()` method.
	 * @param array  $second_where WHERE array for the second query.
	 * @param string $fields_table Fields table name.
	 *
	 * @return array Updated WHERE array.
	 */
	private function second_query_where_value_compare_is_empty( array $args, array $second_where, string $fields_table ): array {

		$second_where['arg_value'] = '';
		$form_ids                  = implode( ',', array_map( 'intval', (array) $args['form_id'] ) );

		if ( empty( $args['advanced_search'] ) && is_numeric( $args['field_id'] ) ) {
			$args['field_id']                          = wpforms_validate_field_id( $args['field_id'] );
			$second_where['fields_entry_not_in_empty'] = "$this->table_name.`entry_id` NOT IN (
					SELECT ef.`entry_id`
					FROM {$fields_table} ef
					WHERE ef.`field_id` = '{$args['field_id']}' AND TRIM(ef.`value`) <> '' AND ef.`value` IS NOT NULL " . ( ! empty( $form_ids ) ? "AND ef.`form_id` IN ( $form_ids )" : '' ) . '
			)';

			return $second_where;
		}

		return $this->second_query_where_value_compare_is_empty_any_field( $args, $fields_table, $form_ids );
	}

	/**
	 * Prepare a second query WHERE with arg_value item for `empty` comparison.
	 * Any field: entries where there is at least one field value that is empty ("" or NULL).
	 *
	 * @since 1.9.9
	 *
	 * @param array  $args         Arguments of the main `get_entries()` method.
	 * @param string $fields_table Fields table name.
	 * @param string $form_ids     Form IDs.
	 *
	 * @return array Updated WHERE array.
	 */
	private function second_query_where_value_compare_is_empty_any_field( array $args, string $fields_table, string $form_ids ): array {

		$searchable_field_ids = [];
		$form_ids_where       = ! empty( $form_ids ) ? "AND ef.`form_id` IN ( $form_ids )" : '';

		if ( ! empty( $args['form_id'] ) && ! is_array( $args['form_id'] ) ) {
			$form_data  = wpforms()->obj( 'form' )->get( (int) $args['form_id'], [ 'content_only' => true ] );
			$fields     = is_array( $form_data ) && ! empty( $form_data['fields'] ) ? wp_list_pluck( $form_data['fields'], 'type', 'id' ) : [];
			$disallowed = ListTable::get_columns_form_disallowed_fields();

			$searchable_field_ids = array_keys( array_diff( $fields, $disallowed ) );
		}

		// Any form field: an entry should match if at least one searchable form field is empty.
		if ( ! empty( $searchable_field_ids ) ) {
			$field_ids_sql                         = implode( ',', array_map( 'intval', $searchable_field_ids ) );
			$fields_count                          = count( $searchable_field_ids );
			$second_where['fields_entry_in_empty'] = "(
					(
						SELECT COUNT( DISTINCT ef2.`field_id` )
						FROM {$fields_table} ef2
						WHERE ef2.`entry_id` = $this->table_name.`entry_id`
							AND TRIM(ef2.`value`) <> '' AND ef2.`value` IS NOT NULL
							" . ( ! empty( $form_ids ) ? "AND ef2.`form_id` IN ( $form_ids )" : '' ) . "
							AND ef2.`field_id` IN ( {$field_ids_sql} )
					) < {$fields_count}
				)";

			return $second_where;
		}

		// Fallback to previous logic if we can't determine searchable fields.
		$second_where['fields_entry_in_empty'] = "(
				EXISTS (
					SELECT 1 FROM {$fields_table} ef
					WHERE ef.`entry_id` = $this->table_name.`entry_id`
						AND ( TRIM(ef.`value`) = '' OR ef.`value` IS NULL )
						{$form_ids_where}
					)
					OR
					NOT EXISTS (
						SELECT 1 FROM {$fields_table} ef2
						WHERE ef2.`entry_id` = $this->table_name.`entry_id`
							AND TRIM(ef2.`value`) <> '' AND ef2.`value` IS NOT NULL
 						" . ( ! empty( $form_ids ) ? "AND ef2.`form_id` IN ( $form_ids )" : '' ) . '
					)
				)';

		return $second_where;
	}

	/**
	 * Prepare a second query WHERE with arg_value item.
	 *
	 * @since 1.6.9
	 *
	 * @param array $args Arguments of the main `get_entries()` method.
	 *
	 * @return string
	 */
	protected function second_query_where_arg_value( array $args ): string {

		if ( empty( $args['value_compare'] ) ) {
			return '';
		}

		if ( isset( $args['value'] ) && ! wpforms_is_empty_string( $args['value'] ) ) {
			return $this->second_query_where_arg_value_not_empty( $args );
		}

		// Support comparisons that don't require a term.
		if ( $args['value_compare'] === 'not_empty' ) {
			return $this->second_query_where_arg_value_compare_not_empty( $args );
		}

		// Support comparisons that don't require a term.
		if ( $args['value_compare'] === 'empty' ) {
			return '';
		}

		// If the sanitized search term is empty, we should return nothing in the case of direct logic.
		if ( in_array( $args['value_compare'], [ 'is', 'contains' ], true ) ) {
			return "$this->table_name.`entry_id` IN ( 0 )";
		}

		return '';
	}

	/**
	 * Prepare a second query WHERE arg_value with not_empty comparison.
	 *
	 * @since 1.9.9
	 *
	 * @param array $args Arguments of the main `get_entries()` method.
	 *
	 * @return string
	 */
	private function second_query_where_arg_value_compare_not_empty( array $args ): string {

		$fields_table = wpforms()->obj( 'entry_fields' )->table_name;
		$field_scope  = '';

		if ( empty( $args['advanced_search'] ) && isset( $args['field_id'] ) && is_numeric( $args['field_id'] ) ) {
			$field_id    = wpforms_validate_field_id( $args['field_id'] );
			$field_scope = " AND $fields_table.`field_id` = '{$field_id}'";
		}

		return "TRIM($fields_table.`value`) <> '' AND $fields_table.`value` IS NOT NULL" . $field_scope;
	}

	/**
	 * Prepare a second query WHERE arg_value.
	 *
	 * @since 1.6.9
	 *
	 * @param array $args Arguments of the main `get_entries()` method.
	 *
	 * @return string
	 */
	protected function second_query_where_arg_value_not_empty( $args ): string {

		$value_compare   = empty( $args['value_compare'] ) ? 'is' : $args['value_compare'];
		$condition_value = $this->get_condition_value( $args );

		if ( empty( $args['advanced_search'] ) ) {
			$fields_table = wpforms()->obj( 'entry_fields' )->table_name;

			return "$fields_table.`value` $condition_value";
		}

		// In the case of searching the `entry_id` with `is` or `is_not `,
		// we should prepare the value and generate different WHERE part.
		if (
			$args['advanced_search'] === 'entry_id' &&
			in_array( $value_compare, [ 'is', 'is_not' ], true )
		) {
			return $this->second_query_where_arg_value_for_entry_id_is( $args );
		}

		return $this->second_query_where_arg_value_advanced_search( $args, $condition_value );
	}

	/**
	 * Prepare a second query WHERE arg_value element for advanced search.
	 *
	 * @since 1.6.9
	 *
	 * @param array  $args            Arguments of the main `get_entries()` method.
	 * @param string $condition_value Condition with escaped value.
	 *
	 * @return string
	 */
	private function second_query_where_arg_value_advanced_search( $args, $condition_value ): string {

		if ( empty( $args['advanced_search'] ) ) {
			return '';
		}

		if ( $args['advanced_search'] === 'entry_id' ) {
			return "$this->table_name.`entry_id` $condition_value";
		}

		if ( $args['advanced_search'] === 'ip_address' ) {
			return "$this->table_name.`ip_address` $condition_value";
		}

		if ( $args['advanced_search'] === 'user_agent' ) {
			return "$this->table_name.`user_agent` $condition_value";
		}

		if ( $args['advanced_search'] !== 'entry_notes' ) {
			return '';
		}

		if ( ! in_array( $args['value_compare'], [ 'is', 'contains' ], true ) ) {
			return '';
		}

		$entry_ids = $this->second_query_where_entry_notes_ids( $args );

		return "{$this->table_name}.`entry_id` IN ( {$entry_ids} )";
	}

	/**
	 * Prepare a second query WHERE arg_value element for searching entry_id.
	 *
	 * @since 1.6.9
	 *
	 * @param array $args Arguments of the main `get_entries()` method.
	 *
	 * @return string
	 */
	private function second_query_where_arg_value_for_entry_id_is( $args ): string {

		$value_compare = empty( $args['value_compare'] ) ? 'is' : $args['value_compare'];
		$escaped_value = esc_sql( $args['value'] );

		// Convert all non-numeric chars to commas.
		$escaped_value = preg_replace( '/\D+/', ',', $escaped_value );

		// Convert several commas to one comma.
		$escaped_value = preg_replace( '/,+/', ',', $escaped_value );

		// Strip comma from the beginning of the string.
		$escaped_value = preg_replace( '/^,/', '', $escaped_value );

		// Strip comma from the end of the string.
		$escaped_value = preg_replace( '/,$/', '', $escaped_value );

		$escaped_value = ! empty( $escaped_value ) ? $escaped_value : '0';

		$operator = $value_compare === 'is' ? 'IN' : 'NOT IN';

		return "$this->table_name.`entry_id` $operator ( $escaped_value )";
	}

	/**
	 * Advanced search by Entry Notes.
	 *
	 * @since 1.8.4
	 *
	 * @param array $args Arguments.
	 *
	 * @return string Comma separated a list of entry ids.
	 */
	private function second_query_where_entry_notes_ids( $args ): string {

		// We have to cache it, as the same request is executed 4 times on entry search.
		$form_ids        = implode( ',', array_map( 'intval', (array) $args['form_id'] ) );
		$condition_value = $this->get_condition_value( $args );
		$key             = md5( $args['advanced_search'] . $form_ids . $condition_value );
		$entry_ids_str   = wp_cache_get( $key, self::CACHE_GROUP, false, $found );

		if ( $found ) {
			return $entry_ids_str;
		}

		$entry_ids_str = $args['advanced_search'] === 'entry_notes'
			? $this->second_query_where_entry_notes_result_ids( $args, $form_ids )
			: '0';

		wp_cache_set( $key, $entry_ids_str, self::CACHE_GROUP );

		return $entry_ids_str;
	}

	/**
	 * Advanced search by Entry Notes.
	 *
	 * @since 1.6.9
	 *
	 * @param array  $args     Arguments.
	 * @param string $form_ids Form ids.
	 *
	 * @return string Comma separated a list of entry ids.
	 */
	private function second_query_where_entry_notes_result_ids( $args, $form_ids ): string {

		$meta_table     = wpforms()->obj( 'entry_meta' )->table_name;
		$escaped_value  = strtolower( esc_sql( $args['value'] ) );
		$data           = str_replace( [ '  ', ' ' ], [ ' ', '%' ], $escaped_value );
		$data           = "AND data LIKE '%$data%'";
		$form_ids_where = ! empty( $form_ids ) ? "AND form_id IN ( $form_ids )" : '';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$notes = $this->get_results(
			"SELECT entry_id, data
			FROM $meta_table
			WHERE
				type = 'note'
				$data
				$form_ids_where"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $notes ) ) {
			return '0';
		}

		foreach ( $notes as $key => $note ) {

			$clean_value = strtolower( trim( wp_strip_all_tags( $note->data ) ) );

			if (
				$clean_value !== $escaped_value &&
				in_array( $args['value_compare'], [ 'is', 'is_not' ], true )
			) {
				unset( $notes[ $key ] );
			}

			if (
				strpos( $clean_value, $escaped_value ) === false &&
				in_array( $args['value_compare'], [ 'contains', 'contains_not' ], true )
			) {
				unset( $notes[ $key ] );
			}
		}

		$entry_ids = ! empty( $notes ) ? wp_list_pluck( $notes, 'entry_id' ) : [ 0 ];

		return implode( ',', array_unique( $entry_ids ) );
	}

	/**
	 * Create custom entry database table.
	 *
	 * @since 1.0.0
	 */
	public function create_table() {

		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$this->table_name} (
			entry_id bigint(20) NOT NULL AUTO_INCREMENT,
			form_id bigint(20) NOT NULL,
			post_id bigint(20) NOT NULL,
			user_id bigint(20) NOT NULL,
			status varchar(30) NOT NULL,
			type varchar(30) NOT NULL,
			viewed tinyint(1) DEFAULT 0,
			starred tinyint(1) DEFAULT 0,
			fields longtext NOT NULL,
			meta longtext NOT NULL,
			date datetime NOT NULL,
			date_modified datetime NOT NULL,
			ip_address varchar(128) NOT NULL,
			user_agent varchar(256) NOT NULL,
			user_uuid varchar(36) NOT NULL,
			PRIMARY KEY  (entry_id),
			KEY form_id (form_id)
		) {$charset_collate};";

		dbDelta( $sql );
	}

	/**
	 * Get entries' count per page.
	 *
	 * @since 1.6.5
	 *
	 * @return int
	 */
	public function get_count_per_page(): int {

		// phpcs:disable WPForms.PHP.ValidateHooks.InvalidHookName
		/**
		 * Allow developers to modify the number of entries per page.
		 *
		 * @since 1.6.5
		 *
		 * @param array $count Entries' count per page.
		 */
		return (int) apply_filters( 'wpforms_entries_per_page', 30 );
		// phpcs:enable WPForms.PHP.ValidateHooks.InvalidHookName
	}

	/**
	 * Check if the entry has editable fields.
	 *
	 * @since 1.6.9
	 *
	 * @param object $entry Submitted entry values.
	 *
	 * @return bool True if an editable field is found.
	 */
	public function has_editable_fields( $entry ): bool {

		$entry_fields = wpforms_decode( $entry->fields );

		if ( empty( $entry_fields ) ) {
			return false;
		}

		$form      = wpforms()->obj( 'form' );
		$form_data = $form ? $form->get( (int) $entry->form_id, [ 'content_only' => true ] ) : null;

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

		foreach ( $form_data['fields'] as $id => $form_field ) {
			// phpcs:disable WPForms.PHP.ValidateHooks.InvalidHookName
			/** This filter is documented in src/Pro/Admin/Entries/Edit.php */
			$is_editable = (bool) apply_filters(
				'wpforms_pro_admin_entries_edit_field_output_editable',
				$this->is_field_editable( $form_field['type'], $form_field, $form_data ),
				$form_field,
				$entry_fields,
				$form_data
			);
			// phpcs:enable WPForms.PHP.ValidateHooks.InvalidHookName

			if ( ! $is_editable ) {
				unset( $form_data['fields'][ $id ] );
			}
		}

		return ! empty( $form_data['fields'] );
	}

	/**
	 * Determine whether the field type is editable.
	 *
	 * @since 1.7.0
	 * @since 1.8.4 Added $field and $form_data parameters.
	 *
	 * @param string $type      Field type.
	 * @param array  $field     Field data.
	 * @param array  $form_data Form data.
	 *
	 * @return bool True if editable.
	 */
	private function is_field_editable( $type, $field, $form_data ): bool {

		$editable = in_array( $type, $this->get_editable_field_types(), true );

		// phpcs:disable WPForms.PHP.ValidateHooks.InvalidHookName
		/** This filter is documented in src/Pro/Admin/Entries/Edit.php */
		return (bool) apply_filters( 'wpforms_pro_admin_entries_edit_field_editable', $editable, $type, $field, $form_data );
		// phpcs:enable WPForms.PHP.ValidateHooks.InvalidHookName
	}

	/**
	 * Insert payment data into the wpforms_entry_meta table.
	 *
	 * @since 1.7.5
	 *
	 * @param int   $entry_id     Entry ID.
	 * @param array $payment_data Payment data to be inserted.
	 *
	 * @return void
	 */
	public function insert_payment_meta( $entry_id, $payment_data ) {

		global $wpdb;

		$entry_meta_handler = wpforms()->obj( 'entry_meta' );
		$wpforms_entry      = $this->get( $entry_id );

		if ( ! $entry_meta_handler || ! $wpforms_entry ) {
			return;
		}

		$values = [];

		// @todo: Remove after deprecate this method and all payment addons updated to be compatible with 1.8.2.
		$this->insert_legacy_payment( $wpforms_entry, $payment_data );

		foreach ( $payment_data as $meta_key => $meta_value ) {
			// If meta_key doesn't begin with `payment_`, prefix it.
			$meta_key = strpos( $meta_key, 'payment_' ) === 0 ? $meta_key : "payment_$meta_key";

			$values[] = sprintf(
				"( %d, %d, %d, '%s', '%s', '%s', '%s' )",
				$entry_id,
				$wpforms_entry->form_id,
				$wpforms_entry->user_id,
				$wpforms_entry->status,
				$meta_key,
				$meta_value,
				$wpforms_entry->date
			);
		}

		if ( ! $values ) {
			return;
		}

		$values = implode( ', ', $values );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			"INSERT INTO {$entry_meta_handler->table_name}
			( entry_id, form_id, user_id, status, type, data, date )
			VALUES {$values}"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Create payment when legacy payment addon is used.
	 *
	 * @since 1.8.2
	 *
	 * @param object $entry        Entry.
	 * @param array  $payment_meta Payment meta.
	 */
	private function insert_legacy_payment( $entry, $payment_meta ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$required_keys = [ 'payment_total', 'payment_currency', 'payment_type' ];

		if ( 0 !== count( array_diff( $required_keys, array_keys( $payment_meta ) ) ) ) {
			return;
		}

		$is_subscription = ! empty( $payment_meta['payment_subscription'] );

		$payment_data = [
			'form_id'             => absint( $entry->form_id ),
			'status'              => strtolower( $entry->status ),
			'subtotal_amount'     => $payment_meta['payment_total'],
			'total_amount'        => $payment_meta['payment_total'],
			'currency'            => $payment_meta['payment_currency'],
			'entry_id'            => absint( $entry->entry_id ),
			'gateway'             => $payment_meta['payment_type'],
			'type'                => $is_subscription ? 'subscription' : 'one-time',
			'mode'                => isset( $payment_meta['payment_mode'] ) && $payment_meta['payment_mode'] !== 'test' ? 'live' : 'test',
			'transaction_id'      => isset( $payment_meta['payment_transaction'] ) ? substr( $payment_meta['payment_transaction'], 0, 40 ) : '',
			'customer_id'         => isset( $payment_meta['payment_customer'] ) ? substr( $payment_meta['payment_customer'], 0, 40 ) : '',
			'subscription_id'     => $is_subscription ? substr( $payment_meta['payment_subscription'], 0, 40 ) : '',
			'subscription_status' => $is_subscription ? strtolower( $entry->status ) : '',
			'date_created_gmt'    => $entry->date,
			'date_updated_gmt'    => $entry->date_modified,
		];

		if ( strtolower( $payment_data['status'] ) === 'completed' ) {
			$payment_data['status'] = 'processed';
		}

		if ( in_array( strtolower( $payment_data['subscription_status'] ), [ 'active', 'completed' ], true ) ) {
			$payment_data['subscription_status'] = 'not-synced';
			$payment_data['status']              = 'processed';
		}

		// Create payment.
		$payment    = wpforms()->obj( 'payment' );
		$payment_id = $payment ? $payment->add( $payment_data ) : null;

		if ( ! $payment_id ) {
			return;
		}

		$payment_meta = [
			'subscription_period' => $is_subscription ? $payment_meta['payment_period'] : '',
			'payment_note'        => $payment_meta['payment_note'] ?? '',
			'payment_recipient'   => $payment_meta['payment_recipient'] ?? '',
			'receipt_number'      => $payment_meta['receipt_number'] ?? '',
			'user_id'             => $entry->user_id,
			'user_agent'          => $entry->user_agent,
			'user_uuid'           => $entry->user_uuid,
			'ip_address'          => $entry->ip_address,
		];

		// Insert payment meta.
		$payment_meta_object = wpforms()->obj( 'payment_meta' );

		if ( $payment_meta_object ) {
			$payment_meta_object->bulk_add( $payment_id, $payment_meta );
		}
	}

	/**
	 * Get condition value.
	 *
	 * @since 1.7.5
	 *
	 * @param array $args Arguments.
	 *
	 * @return string
	 */
	private function get_condition_value( array $args ): string {

		$escaped_value = esc_sql( $args['value'] );

		$condition_values = [
			'is'           => " = '$escaped_value'",
			'is_not'       => " <> '$escaped_value'",
			'contains'     => " LIKE '%$escaped_value%'",
			'contains_not' => " NOT LIKE '%$escaped_value%'",
		];

		$value_compare = empty( $args['value_compare'] ) ? 'is' : $args['value_compare'];

		return empty( $condition_values[ $value_compare ] )
			? $condition_values['is']
			: $condition_values[ $value_compare ];
	}

	/**
	 * Get where clause for filtering by status.
	 *
	 * @since 1.8.5
	 *
	 * @param string $status Entry status.
	 *
	 * @return string WHERE clause.
	 */
	private function get_status_where_clause( $status = '' ): string {

		// Check the status to determine the WHERE clause.
		// If status is spam or trash, we get the previous spam/trash entry. Otherwise, the previous non-spam/trash entry with any status.
		if ( empty( $status ) || ! in_array( $status, [ 'spam', 'trash' ], true ) ) {
			return "AND `status` NOT IN ( 'spam', 'trash' )";
		}

		return "AND `status` = '{$status}'";
	}

	/**
	 * Get trashed entries count.
	 * This function is not supposed to be affected by search filters.
	 *
	 * @since 1.8.5
	 *
	 * @param int $form_id Form ID.
	 *
	 * @return int Count of trashed entries.
	 */
	public function get_trash_count( $form_id ): int {

		global $wpdb;

		if ( empty( $form_id ) ) {
			return 0;
		}

		// Note: we don't use `get_entries()` method here, because it's affected by search filters.

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$count = $this->get_var(
			$wpdb->prepare(
				"SELECT COUNT({$this->primary_key}) FROM {$this->table_name}
				WHERE `form_id` = %d AND `status` = 'trash'",
				absint( $form_id )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return absint( $count );
	}

	/**
	 * Prepare arguments for the main get_entries() query.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args Arguments.
	 *
	 * @return array
	 */
	private function prepare_args( array $args ): array {

		$defaults = [
			'select'          => 'all',
			'number'          => $this->get_count_per_page(),
			'offset'          => 0,
			'form_id'         => 0,
			'entry_id'        => 0,
			'is_filtered'     => false,
			'post_id'         => '',
			'user_id'         => '',
			'status'          => '',
			'type'            => '',
			'viewed'          => '',
			'starred'         => '',
			'user_uuid'       => '',
			'field_id'        => '',
			'value'           => '',
			'value_compare'   => '',
			'date'            => '',
			'date_modified'   => '',
			'ip_address'      => '',
			'advanced_search' => '',
			'notes_count'     => false,
			'orderby'         => 'entry_id',
			'order'           => 'DESC',
		];

		/**
		 * Allow developers to filter the `get_entries()` method arguments.
		 *
		 * @since 1.4.4
		 *
		 * @param array  $args              {
		 *                                  Entries query arguments.
		 *
		 * @type string  $select            Table field. Possible values: 'all', 'entry_ids'. By default, 'all'.
		 * @type integer $number            Number of the entries.
		 * @type integer $offset            Offset.
		 * @type integer $form_id           Form ID.
		 * @type mixed   $entry_id          Entry ID. Integer or array of integers.
		 * @type bool    $is_filtered       Skip filtering by entry IDs.
		 * @type mixed   $post_id           Post ID.
		 * @type mixed   $user_id           User ID.
		 * @type mixed   $status            Entry status.
		 * @type string  $type              Not used, rudimentary key.
		 * @type string  $viewed            Viewed flag.
		 * @type string  $starred           Starred flag.
		 * @type string  $user_uuid         Unique user ID.
		 * @type mixed   $field_id          Field ID.
		 * @type string  $value             Field value.
		 * @type string  $value_compare     Possible values: 'is', 'is_not', 'contains', 'contains_not'.
		 * @type mixed   $date              Created date. Array with two items, start and end date.
		 *                                  String value is considered as the same value for start and end.
		 *                                  Format: `Y-m-d H:i:s`.
		 * @type string  $date_modified     Modified date. See details for `date`.
		 * @type string  $ip_address        IP address.
		 * @type string  $notes_count       Notes' count.
		 * @type string  $orderby           Order by.
		 * @type string  $order             Order: 'ASC' or 'DESC', Default: 'DESC'.
		 *                                  }
		 */
		$args = (array) apply_filters(
			'wpforms_entry_handler_get_entries_args',
			wp_parse_args( $args, $defaults )
		);

		/**
		 * Modify the ORDER BY.
         */
		if ( ! in_array( $args['orderby'], [ 'payment_total', 'notes_count' ], true ) ) {
			$args['orderby'] = ! array_key_exists( $args['orderby'], $this->get_columns() )
				? $this->primary_key
				: $args['orderby'];
			$args['orderby'] = "$this->table_name.{$args['orderby']}";
		}

		if ( 'ASC' === strtoupper( $args['order'] ) ) {
			$args['order'] = 'ASC';
		} else {
			$args['order'] = 'DESC';
		}

		/**
		 * Modify the OFFSET / NUMBER.
		 */
		$args['offset'] = absint( $args['offset'] );

		if ( $args['number'] < 1 ) {
			$args['number'] = PHP_INT_MAX;
		}

		$args['number'] = absint( $args['number'] );

		return $args;
	}

	/**
	 * Prepare WHERE SQL for the main get_entries() query.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args Arguments.
	 *
	 * @return string
	 */
	private function prepare_where_sql( array $args ): string {

		$where = $this->prepare_where( $args );

		return implode( ' AND ', array_unique( array_filter( $where ) ) );
	}

	/**
	 * Prepare the WHERE clause for the main get_entries() query.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args Arguments.
	 *
	 * @return array
	 */
	private function prepare_where( array $args ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$where = [
			'default' => '1=1',
		];

		// Allowed int $args items.
		foreach ( [ 'entry_id', 'form_id', 'post_id', 'user_id', 'viewed', 'starred' ] as $key ) {
			// The value of `$args[ $key ]` can be an integer and a numeric string, or an array of the same.

			$values = array_filter(
				(array) $args[ $key ],
				static function ( $value ) {

					// String '0' is allowed.
					return is_numeric( $value ) && $value !== 0;
				}
			);

			// We should skip empty values.
			if ( ! $values ) {
				continue;
			}

			$ids                    = implode( ',', array_map( 'intval', $values ) );
			$where[ 'arg_' . $key ] = "$this->table_name.$key IN ( $ids )";
		}

		// Allowed string arg items.
		foreach ( [ 'type', 'user_uuid' ] as $key ) {
			if ( $args[ $key ] !== '' ) {
				$where[ 'arg_' . $key ] = "$this->table_name.$key = '" . esc_sql( $args[ $key ] ) . "'";
			}
		}

		$where = $this->process_status( $args, $where );

		// Process dates.
		foreach ( [ 'date', 'date_modified' ] as $key ) {
			if ( empty( $args[ $key ] ) ) {
				continue;
			}

			// We can pass an array and treat it as a range from:to.
			if ( is_array( $args[ $key ] ) && count( $args[ $key ] ) === 2 ) {
				$date_start = wpforms_get_day_period_date( 'start_of_day', strtotime( $args[ $key ][0] ), 'Y-m-d H:i:s', true );
				$date_end   = wpforms_get_day_period_date( 'end_of_day', strtotime( $args[ $key ][1] ), 'Y-m-d H:i:s', true );

				if ( ! empty( $date_start ) && ! empty( $date_end ) ) {
					$where[ 'arg_' . $key . '_start' ] = "$this->table_name.$key >= '$date_start'";
					$where[ 'arg_' . $key . '_end' ]   = "$this->table_name.$key <= '$date_end'";
				}
			} elseif ( is_string( $args[ $key ] ) ) {
				/*
				 * If we pass the only string representation of a date -
				 * that means we want to get records of that day only.
				 * So we generate start and end MySQL dates for the specified day.
				 */
				$timestamp  = strtotime( $args[ $key ] );
				$date_start = wpforms_get_day_period_date( 'start_of_day', $timestamp, 'Y-m-d H:i:s', true );
				$date_end   = wpforms_get_day_period_date( 'end_of_day', $timestamp, 'Y-m-d H:i:s', true );

				if ( ! empty( $date_start ) && ! empty( $date_end ) ) {
					$where[ 'arg_' . $key . '_start' ] = "$this->table_name.$key >= '$date_start'";
					$where[ 'arg_' . $key . '_end' ]   = "$this->table_name.$key <= '$date_end'";
				}
			}
		}

		// Remove filtering by entry_id if it is not a filtered query.
		if ( ! $args['is_filtered'] ) {
			unset( $where['arg_entry_id'] );
		}

		// In the case of search, we maybe need to run an additional query first.
		if ( ! empty( $args['value_compare'] ) ) {
			$where = $this->second_query_update_where( $args, $where );
		}

		/**
		 * Allow developers to modify WHERE (unset clauses, add new, etc.).
		 *
		 * @since 1.4.4
		 *
		 * @param array $where The array of the WHERE clause chunks. Each chunk will be added to the SQL query using AND logical operator.
		 * @param array $args  Entries query arguments (arguments of the \WPForms_Entry_Handler::`get_entries( $args )` method).
		 */
		return (array) apply_filters( 'wpforms_entry_handler_get_entries_where', $where, $args );
	}

	/**
	 * Prepare a SELECT clause for the main get_entries() query.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args Arguments.
	 *
	 * @return string
	 */
	private function prepare_select( array $args ): string {

		/**
		 * Modify the SELECT.
		 */
		$select = '*';

		/**
		 * Allow developers to filter the array of the possible values of the $args['select'] argument.
		 *
		 * @since 1.4.4
		 *
		 * @param array $possible_select_values Array of key/value pairs that defines how to interpret 'select' argument in the real SQL query.
		 */
		$possible_select_values = apply_filters(
			'wpforms_entry_handler_get_entries_select',
			[
				'all'       => '*',
				'entry_ids' => "$this->table_name.entry_id",
			]
		);

		if ( array_key_exists( $args['select'], $possible_select_values ) ) {
			$select = esc_sql( $possible_select_values[ $args['select'] ] );
		}

		return $select;
	}

	/**
	 * Prepare FROM SQL for the main get_entries() query.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args Arguments.
	 *
	 * @return string
	 */
	private function prepare_from_sql( array $args ): string {

		$from_sql   = $this->table_name;
		$meta_table = wpforms()->obj( 'entry_meta' )->table_name;

		// Add a LEFT OUTER JOIN to retrieve a notes' count.
		if ( $args['notes_count'] ) {
			$from_sql .= "
			LEFT JOIN (
				SELECT
					$meta_table.entry_id AS meta_entry_id,
					COUNT( $meta_table.id ) AS notes_count
				FROM
					$meta_table
				WHERE
					$meta_table.type = 'note'
					GROUP BY
						meta_entry_id
				) notes_counts
				ON
					notes_counts.meta_entry_id = $this->table_name.entry_id";
		}

		// In the case of ordering by payment_total.
		if ( $args['orderby'] === 'payment_total' ) {
			$from_sql .= "
			LEFT JOIN (
				SELECT
					entry_id AS meta_entry_id,
					data AS payment_total
				FROM
					$meta_table
				WHERE
					$meta_table.type = 'payment_total'
			) AS payment_totals
			ON
				payment_totals.meta_entry_id = $this->table_name.entry_id";
		}

		/**
		 * Allow developers to modify FROM (add new tables, etc.).
		 *
		 * @since 1.8.4
		 *
		 * @param string $from_sql The SQL FROM clause.
		 */
		return apply_filters( 'wpforms_entry_handler_get_entries_sql_from', $from_sql );
	}

	/**
	 * Process status.
	 *
	 * @since 1.9.0
	 *
	 * @param array $args  Arguments.
	 * @param array $where Where clause.
	 *
	 * @return array
	 */
	private function process_status( array $args, array $where ): array {

		if ( ! empty( $args['status'] ) ) {
			$status = (array) $args['status'];

			if ( in_array( 'any', $status, true ) ) {
				return $where;
			}

			// Sanitize and escape.
			$status = array_map( 'esc_sql', array_map( 'sanitize_text_field', $status ) );

			// Filter duplicate values.
			$status = array_unique( $status ); // Empty status is valid for published entries.

			if ( ! empty( $status ) ) {
				$status = implode( "','", $status );

				$where['arg_status'] = "{$this->table_name}.status IN ( '{$status}' )";
			}
		} else {
			$where['arg_status'] = "{$this->table_name}.status NOT IN ( 'spam', 'trash' )";
		}

		return $where;
	}
}
