<?php
/**
 * /lib/indexing.php
 *
 * @package Relevanssi
 * @author  Mikko Saari
 * @license https://wordpress.org/about/gpl/ GNU General Public License
 * @see     https://www.relevanssi.com/
 */

/**
 * Returns the total number of posts to index.
 *
 * Counts the total number of posts to index, considering post type restrictions and
 * the valid statuses.
 *
 * @return int The number of posts to index.
 */
function relevanssi_count_total_posts() {
	$extend = false;
	return relevanssi_indexing_post_counter( $extend );
}

/**
 * Returns the number of posts missing from the index.
 *
 * Counts the total number of posts to index, considering post type restrictions and
 * the valid statuses, and only looks at posts missing from the index.
 *
 * @return int The number of posts to index.
 */
function relevanssi_count_missing_posts() {
	$extend = true;
	return relevanssi_indexing_post_counter( $extend );
}

/**
 * Counts the total number of posts.
 *
 * Counts the total number of posts to index, considering post type restrictions and
 * the valid statuses.
 *
 * @global object $wpdb                 The WordPress database interface.
 * @global array  $relevanssi_variables The Relevanssi global variables array, used
 * for table names.
 *
 * @param boolean $extend If true, count only missing posts. If false, count all
 * posts. Default false.
 *
 * @return int The number of posts to index.
 */
function relevanssi_indexing_post_counter( $extend = false ) {
	global $wpdb, $relevanssi_variables;
	$relevanssi_table = $relevanssi_variables['relevanssi_table'];
	$restriction      = relevanssi_post_type_restriction();
	$valid_status     = relevanssi_valid_status_array();
	$limit            = '';

	$query = relevanssi_generate_indexing_query( $valid_status, $extend, $restriction, $limit );
	$query = str_replace( 'SELECT post.ID', 'SELECT COUNT(post.ID)', $query );

	/**
	 * Allows actions to happen before the indexing query is run.
	 *
	 * The indexing query fetches a list of posts to index (either all posts or only
	 * those missing from the index, depending on the case).
	 */
	do_action( 'relevanssi_pre_indexing_query' );
	$count = $wpdb->get_var( $query ); // WPCS: unprepared SQL ok.

	if ( empty( $count ) ) {
		$count = 0;
	}

	return $count;
}

/**
 * Generates the indexing query.
 *
 * Generates the query that fetches the list of posts to index. The parameters are
 * assumed to be safely escaped. In regular use, the values are generated by
 * Relevanssi functions which provide reliable source data.
 *
 * @global object $wpdb                 The WordPress database interface.
 * @global array  $relevanssi_variables The Relevanssi global variables array, used
 * for table names.
 *
 * @param string  $valid_status Comma-separated list of valid post statuses.
 * @param boolean $extend       If true, only care about posts missing from the
 * index. If false, take all posts. Default false.
 * @param string  $restriction  Query restrictions, MySQL code that restricts the
 * posts fetched in the desired way. Default ''.
 * @param string  $limit        MySQL code to set the LIMIT and OFFSET values.
 * Default ''.
 *
 * @return string MySQL query to fetch the posts.
 */
function relevanssi_generate_indexing_query( $valid_status, $extend = false, $restriction = '', $limit = '' ) {
	global $wpdb, $relevanssi_variables;
	$relevanssi_table = $relevanssi_variables['relevanssi_table'];

	/**
	 * Filters the WHERE restriction for indexing queries.
	 *
	 * This filter hook can be used to exclude posts from indexing as early as is
	 * possible.
	 *
	 * @since 4.0.9 / 2.1.5
	 *
	 * @param string The WHERE restriction.
	 *
	 * @return string The modified WHERE restriction.
	 */
	$restriction = apply_filters( 'relevanssi_indexing_restriction', $restriction );

	if ( ! $extend ) {
		$q = "SELECT post.ID
		FROM $wpdb->posts post
		LEFT JOIN $wpdb->posts parent ON (post.post_parent=parent.ID)
		WHERE
			(post.post_status IN ($valid_status)
			OR
			(post.post_status='inherit'
				AND(
					(parent.ID is not null AND (parent.post_status IN ($valid_status)))
					OR (post.post_parent=0)
				)
			))
		$restriction ORDER BY post.ID DESC $limit";
	} else {
		$q = "SELECT post.ID
		FROM $wpdb->posts post
		LEFT JOIN $wpdb->posts parent ON (post.post_parent=parent.ID)
		LEFT JOIN $relevanssi_table r ON (post.ID=r.doc)
		WHERE
		r.doc is null
		AND
			(post.post_status IN ($valid_status)
			OR
			(post.post_status='inherit'
				AND(
					(parent.ID is not null AND (parent.post_status IN ($valid_status)))
					OR (post.post_parent=0)
				)
			)
		)
		$restriction ORDER BY post.ID DESC $limit";
	}

	return $q;
}

/**
 * Generates a post type restriction.
 *
 * Generates a post type restriction for the MySQL query based on the
 * 'relevanssi_index_post_types' option.
 *
 * @return string MySQL code for the post type restriction.
 */
function relevanssi_post_type_restriction() {
	$post_types  = array();
	$restriction = '';

	$types = get_option( 'relevanssi_index_post_types' );
	if ( ! is_array( $types ) ) {
		$types = array();
	}
	foreach ( $types as $type ) {
		if ( 'bogus' === $type ) {
			// 'bogus' is not a real post type Relevanssi uses to make sure
			// the post type setting is saved, even if it's empty.
			continue;
		}
		if ( post_type_exists( $type ) ) {
			// Only accept post types that actually exist.
			array_push( $post_types, "'$type'" );
		}
	}

	if ( count( $post_types ) > 0 ) {
		$restriction = ' AND post.post_type IN (' . implode( ', ', $post_types ) . ') ';
	}

	return $restriction;
}

/**
 * Generates a list of valid post statuses.
 *
 * Generates a list of valid post statuses to use in indexing. By default, Relevanssi
 * accepts 'publish', 'draft', 'private', 'pending', and 'future'. If you need to use
 * a custom post status, you can use the 'relevanssi_valid_status' filter hook to add
 * your own post status to the list of valid statuses.
 *
 * @return string A comma-separated list of valid post statuses.
 */
function relevanssi_valid_status_array() {
	/**
	 * Filters the valid status array.
	 *
	 * Allows you to modify the array that contains all valid post statuses for
	 * Relevanssi post indexing.
	 *
	 * @return array Array of post statuses.
	 */
	$valid_status_array = apply_filters( 'relevanssi_valid_status', array( 'publish', 'draft', 'private', 'pending', 'future' ) );
	$valid_status       = array();

	if ( is_array( $valid_status_array ) && count( $valid_status_array ) > 0 ) {
		foreach ( $valid_status_array as $status ) {
			$valid_status[] = "'" . esc_sql( $status ) . "'";
		}
		$valid_status = implode( ',', $valid_status );
	} else {
		// If the filter makes the setting a non-array, fall back to reasonable
		// default values.
		$valid_status = "'publish', 'draft', 'private', 'pending', 'future'";
	}
	return $valid_status;
}

/**
 * Builds the index.
 *
 * @global object $wpdb                 The WordPress database interface.
 * @global array  $relevanssi_variables The Relevanssi global variables array, used
 * for table names.
 *
 * @param boolean|int $extend_offset If numeric, offsets the indexing by that
 * amount. If true, doesn't truncate the index before indexing. If false, truncates
 * index before indexing. Default false.
 * @param boolean     $verbose       If true, echoes out information. Default true.
 * @param int         $post_limit    How many posts to index. Default null, no limit.
 * @param boolean     $is_ajax       If true, indexing is done in AJAX context.
 * Default false.
 *
 * @return array In AJAX context, returns array with two values: 'indexing_complete'
 * tells whether indexing is completed or not, and 'indexed' returns the number of
 * posts indexed. Outside AJAX context, these values are returned as an array in
 * format of array(completed, posts indexed).
 */
function relevanssi_build_index( $extend_offset = false, $verbose = true, $post_limit = null, $is_ajax = false ) {
	global $wpdb, $relevanssi_variables;
	$relevanssi_table = $relevanssi_variables['relevanssi_table'];

	// Thanks to Julien Mession. This speeds up indexing a lot.
	wp_suspend_cache_addition( true );

	// The values generated by these functions are safe to use for MySQL.
	$restriction  = relevanssi_post_type_restriction();
	$valid_status = relevanssi_valid_status_array();

	$n    = 0;
	$size = 0;

	if ( false === $extend_offset ) {
		// Truncate the index first.
		relevanssi_truncate_index();

		// Premium feature: index taxonomy terms.
		if ( function_exists( 'relevanssi_index_taxonomies' ) ) {
			if ( 'on' === get_option( 'relevanssi_index_taxonomies' ) ) {
				relevanssi_index_taxonomies();
			}
		}

		// Premium feature: index user profiles.
		if ( function_exists( 'relevanssi_index_users' ) ) {
			if ( 'on' === get_option( 'relevanssi_index_users' ) ) {
				relevanssi_index_users();
			}
		}

		// If $post_limit parameter is present, numeric and > 0, use that.
		$limit = '';
		if ( isset( $post_limit ) && is_numeric( $post_limit ) && $post_limit > 0 ) {
			$size  = $post_limit;
			$limit = " LIMIT $post_limit";
		}

		$query = relevanssi_generate_indexing_query( $valid_status, $extend_offset, $restriction, $limit );

		update_option( 'relevanssi_index', '' );
	} elseif ( ! is_numeric( $extend_offset ) ) {
		// Extending, so do not truncate and skip the posts already in the index.
		$limit = get_option( 'relevanssi_index_limit', 200 );

		// If $post_limit parameter is present, numeric and > 0, use that.
		if ( isset( $post_limit ) && is_numeric( $post_limit ) && $post_limit > 0 ) {
			$limit = $post_limit;
		}

		if ( is_numeric( $limit ) && $limit > 0 ) {
			$size  = $limit;
			$limit = " LIMIT $limit";
		} else {
			$limit = '';
		}

		$extend = true;
		$query  = relevanssi_generate_indexing_query( $valid_status, $extend, $restriction, $limit );
	} else { // $extend_offset is numeric.
		// Extending, so do not truncate and skip the posts already in the index.
		$limit = get_option( 'relevanssi_index_limit', 200 );

		// If $post_limit parameter is present, numeric and > 0, use that.
		if ( isset( $post_limit ) && is_numeric( $post_limit ) && $post_limit > 0 ) {
			$limit = $post_limit;
		}

		if ( is_numeric( $limit ) && $limit > 0 ) {
			$size  = $limit;
			$limit = " LIMIT $limit OFFSET $extend_offset";
		} else {
			$limit = '';
		}

		// Extend is set to false, because $limit now has LIMIT and OFFSET.
		$extend = false;
		$query  = relevanssi_generate_indexing_query( $valid_status, $extend, $restriction, $limit );
	}

	$custom_fields = relevanssi_get_custom_fields();

	/* This action documented earlier in lib/indexing.php. */
	do_action( 'relevanssi_pre_indexing_query' );
	$content = $wpdb->get_results( $query ); // WPCS: unprepared SQL ok.

	if ( defined( 'WP_CLI' ) && WP_CLI && function_exists( 'relevanssi_generate_progress_bar' ) ) {
		$progress = relevanssi_generate_progress_bar( 'Indexing posts', count( $content ) );
	}

	$remove_first       = false;
	$bypass_global_post = true; // $bypassglobalpost set to true, because at this
	// point global $post should be null, but in some cases it is not.
	foreach ( $content as $post ) {
		$result = relevanssi_index_doc( $post->ID, $remove_first, $custom_fields, $bypass_global_post );
		if ( is_numeric( $result ) && $result > 0 ) {
			// $n calculates the number of posts indexed.
			$n++;
		}
		if ( defined( 'WP_CLI' ) && WP_CLI && $progress ) {
			$progress->tick();
		}
	}
	if ( defined( 'WP_CLI' ) && WP_CLI && $progress ) {
		$progress->finish();
	}

	// To prevent empty indices.
	$wpdb->query( "ANALYZE TABLE $relevanssi_table" ); // WPCS: unprepared SQL ok, just Relevanssi table name.

	$complete = false;
	if ( ( 0 === $size ) || ( count( $content ) < $size ) ) {
		$message  = __( 'Indexing complete!', 'relevanssi' );
		$complete = true;
	} else {
		$message = __( 'More to index...', 'relevanssi' );
	}
	if ( $verbose ) {
		printf( '<div id="message" class="updated fade"><p>%s</p></div>', esc_html( $message ) );
	}

	update_option( 'relevanssi_indexed', 'done' );

	// Update the document count variable.
	relevanssi_update_doc_count();

	wp_suspend_cache_addition( false );

	if ( $is_ajax ) {
		$response = array(
			'indexing_complete' => $complete,
			'indexed'           => $n,
		);
		return $response;
	}

	return array( $complete, $n );
}

/**
 * Indexes one document.
 *
 * Different cases:
 *
 * Build index:
 * - global $post is null, $index_post is a post object.
 *
 * Update post:
 * - global $post has the original $post, $index_post is the ID of revision.
 *
 * Quick edit:
 * - global $post is an array, $index_post is the ID of current revision.
 *
 * @global object $wpdb                 The WordPress database interface.
 * @global array  $relevanssi_variables The Relevanssi global variables array, used
 * for table names.
 * @global object $post                 The global post object.
 *
 * @param object|int $index_post         The post to index, either post object or
 * post ID.
 * @param boolean    $remove_first       If true, remove the post from the index
 * before indexing. Default false.
 * @param array      $custom_fields      The custom fields that are indexed for the
 * post.
 * @param boolean    $bypass_global_post If true, do not use the global $post object.
 * Default false.
 * @param boolean    $debug              If true, echo out debugging information.
 * Default false.
 */
function relevanssi_index_doc( $index_post, $remove_first = false, $custom_fields = false, $bypass_global_post = false, $debug = false ) {
	global $wpdb, $post, $relevanssi_variables;
	$relevanssi_table = $relevanssi_variables['relevanssi_table'];
	$post_was_null    = false;
	$previous_post    = null;

	// Check if this is a Jetpack Contact Form entry.
	if ( isset( $_REQUEST['contact-form-id'] ) ) { // WPCS: CSRF ok.
		return;
	}

	if ( $bypass_global_post ) {
		// If $bypass_global_post is set, relevanssi_index_doc() will index the post
		// object or post ID as specified in $index_post.
		if ( isset( $post ) ) {
			$previous_post = $post;
		} else {
			$post_was_null = true;
		}

		if ( is_object( $index_post ) ) {
			$post = $index_post; // WPCS: override ok.
		} else {
			$post = get_post( $index_post ); // WPCS: override ok.
		}
	} else {
		// Quick edit has an array in the global $post, so fetch the post ID for the
		// post to edit.
		if ( is_array( $post ) ) {
			$post = get_post( $post['ID'] ); // WPCS: override ok.
		}

		if ( empty( $post ) ) {
			// No $post set, so we need to use $indexpost, if it's a post object.
			$post_was_null = true;
			if ( is_object( $index_post ) ) {
				$post = $index_post; // WPCS: override ok.
			} else {
				$post = get_post( $index_post ); // WPCS: override ok.
			}
		} else {
			// $post was set, let's grab the previous value in case we need it
			$previous_post = $post;
		}
	}

	if ( null === $post ) {
		// At this point we should have something in $post; if not, quit.
		if ( $post_was_null ) {
			$post = null; // WPCS: override ok.
		}
		if ( $previous_post ) {
			$post = $previous_post; // WPCS: override ok.
		}
		return -1;
	}

	// Finally fetch the post again by ID. Complicated, yes, but unless we do this,
	// we might end up indexing the post before the updates come in.
	$post = get_post( $post->ID ); // WPCS: override ok.

	// Post exclusion feature from Relevanssi Premium.
	if ( function_exists( 'relevanssi_hide_post' ) ) {
		if ( relevanssi_hide_post( $post->ID ) ) {
			if ( $debug ) {
				relevanssi_debug_echo( 'relevanssi_hide_post() returned true.' );
			}
			if ( $post_was_null ) {
				$post = null; // WPCS: override ok.
			}
			if ( $previous_post ) {
				$post = $previous_post; // WPCS: override ok.
			}
			return 'hide';
		}
	}

	$index_this_post        = false;
	$post->indexing_content = true;

	$index_types = get_option( 'relevanssi_index_post_types' );
	if ( ! is_array( $index_types ) ) {
		$index_types = array();
	}
	if ( in_array( $post->post_type, $index_types, true ) ) {
		$index_this_post = true;
	}

	/**
	 * Filters whether a post is indexed or not.
	 *
	 * Allows you to filter whether a post is indexed or not.
	 *
	 * @param boolean If true, the post is not indexed. Default false.
	 * @param int     The post ID.
	 */
	if ( true === apply_filters( 'relevanssi_do_not_index', false, $post->ID ) ) {
		// Filter says no.
		if ( $debug ) {
			relevanssi_debug_echo( 'relevanssi_do_not_index returned true.' );
		}
		$index_this_post = false;
	}

	if ( $remove_first ) {
		// We are updating a post, so remove the old stuff first.
		relevanssi_remove_doc( $post->ID, true );
		if ( function_exists( 'relevanssi_remove_item' ) ) {
			relevanssi_remove_item( $post->ID, 'post' );
		}
		if ( $debug ) {
			relevanssi_debug_echo( 'Removed the post from the index.' );
		}
	}

	// This needs to be here, after the call to relevanssi_remove_doc(), because
	// otherwise a post that's in the index but shouldn't be there won't get removed.
	if ( ! $index_this_post ) {
		if ( $post_was_null ) {
			$post = null; // WPCS: override ok.
		}
		if ( $previous_post ) {
			$post = $previous_post; // WPCS: override ok.
		}
		return 'donotindex';
	}

	$n = 0;

	/**
	 * Allows filtering the indexed post before indexing.
	 *
	 * @param object $post The post object.
	 * @param object $post The post object again (in other uses for this filter, the
	 * second parameter actually makes sense).
	 */
	$post = apply_filters( 'relevanssi_post_to_index', $post, $post ); // WPCS: override ok.

	$min_word_length = get_option( 'relevanssi_min_word_length', 3 );
	$insert_data     = array();

	if ( 'none' !== get_option( 'relevanssi_index_comments' ) ) {
		if ( $debug ) {
			relevanssi_debug_echo( 'Indexing comments.' );
		}
		$post_comments = relevanssi_get_comments( $post->ID );
		if ( ! empty( $post_comments ) ) {
			$post_comments = relevanssi_strip_invisibles( $post_comments );
			$post_comments = preg_replace( '/<[a-zA-Z\/][^>]*>/', ' ', $post_comments );
			$post_comments = strip_tags( $post_comments );
			if ( $debug ) {
				relevanssi_debug_echo( "Comment content: $post_comments" );
			}
			$post_comments_tokens = relevanssi_tokenize( $post_comments, true, $min_word_length );
			if ( count( $post_comments_tokens ) > 0 ) {
				foreach ( $post_comments_tokens as $token => $count ) {
					$n++;
					$insert_data[ $token ]['comment'] = $count;
				}
			}
		}
	}

	// Process taxonomies.
	$taxonomies = get_option( 'relevanssi_index_taxonomies_list', array() );
	foreach ( $taxonomies as $taxonomy ) {
		if ( $debug ) {
			relevanssi_debug_echo( "Indexing taxonomy terms for $taxonomy" );
		}
		$insert_data = relevanssi_index_taxonomy_terms( $post, $taxonomy, $insert_data );
	}

	// Index post author.
	if ( 'on' === get_option( 'relevanssi_index_author' ) ) {
		$author_id    = $post->post_author;
		$display_name = get_the_author_meta( 'display_name', $author_id );
		$name_tokens  = relevanssi_tokenize( $display_name, false, $min_word_length );
		if ( $debug ) {
			relevanssi_debug_echo( 'Indexing post author as: ' . implode( ' ', array_keys( $name_tokens ) ) );
		}
		foreach ( $name_tokens as $token => $count ) {
			if ( isset( $insert_data[ $token ]['author'] ) ) {
				$insert_data[ $token ]['author'] += $count;
			} else {
				$insert_data[ $token ]['author'] = $count;
			}
		}
	}

	// Indexing custom fields.
	$remove_underscore_fields = false;
	if ( isset( $custom_fields ) && 'all' === $custom_fields ) {
		$custom_fields = get_post_custom_keys( $post->ID );
	}
	if ( isset( $custom_fields ) && 'visible' === $custom_fields ) {
		$custom_fields            = get_post_custom_keys( $post->ID );
		$remove_underscore_fields = true;
	}
	/**
	 * Filters the list of custom fields to index before indexing.
	 *
	 * @param array $custom_fields List of custom field names.
	 * @param int   $post->ID      The post ID.
	 */
	$custom_fields = apply_filters( 'relevanssi_index_custom_fields', $custom_fields, $post->ID );
	if ( is_array( $custom_fields ) ) {
		if ( $debug ) {
			relevanssi_debug_echo( 'Custom fields to index: ' . implode( ', ', $custom_fields ) );
		}
		$custom_fields = array_unique( $custom_fields );

		// Premium includes some support for ACF repeater fields.
		$repeater_fields = array();
		if ( function_exists( 'relevanssi_add_repeater_fields' ) ) {
			relevanssi_add_repeater_fields( $custom_fields, $post->ID );
		}

		foreach ( $custom_fields as $field ) {
			if ( $remove_underscore_fields ) {
				if ( '_relevanssi_pdf_content' !== $field && '_' === substr( $field, 0, 1 ) ) {
					// We always want to index _relevanssi_pdf_content.
					continue;
				}
			}

			/**
			 * Filters the custom field value before indexing.
			 *
			 * @param array            Custom field values.
			 * @param string $field    The custom field name.
			 * @param int    $post->ID The post ID.
			 */
			$values = apply_filters( 'relevanssi_custom_field_value', get_post_meta( $post->ID, $field, false ), $field, $post->ID );

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

			foreach ( $values as $value ) {
				// Quick hack : allow indexing of PODS relationship custom fields // TMV.
				if ( is_array( $value ) && isset( $value['post_title'] ) ) {
					$value = $value['post_title'];
				}

				if ( function_exists( 'relevanssi_index_acf' ) ) {
					// Handle ACF fields. Only defined when ACF is active.
					relevanssi_index_acf( $insert_data, $post->ID, $field, $value );
				}

				// Flatten other arrays.
				if ( is_array( $value ) ) {
					$value = relevanssi_flatten_array( $value );
				}

				if ( $debug ) {
					relevanssi_debug_echo( "\tKey: " . $field . ' – value: ' . $value );
				}

				$value_tokens = relevanssi_tokenize( $value, true, $min_word_length );
				foreach ( $value_tokens as $token => $count ) {
					if ( ! isset( $insert_data[ $token ]['customfield'] ) ) {
						$insert_data[ $token ]['customfield'] = 0;
					}
					$insert_data[ $token ]['customfield'] += $count;

					// Premium indexes more detail about custom fields.
					if ( function_exists( 'relevanssi_customfield_detail' ) ) {
						$insert_data = relevanssi_customfield_detail( $insert_data, $token, $count, $field );
					}
				}
			}
		}
	}

	// Indexing excerpts.
	if ( isset( $post->post_excerpt ) && ( 'on' === get_option( 'relevanssi_index_excerpt' ) || 'attachment' === $post->post_type ) ) {
		// Include excerpt for attachments which use post_excerpt for captions - modified by renaissancehack.
		if ( $debug ) {
			relevanssi_debug_echo( "Indexing post excerpt: $post->post_excerpt" );
		}
		$excerpt_tokens = relevanssi_tokenize( $post->post_excerpt, true, $min_word_length );
		foreach ( $excerpt_tokens as $token => $count ) {
			if ( ! isset( $insert_data[ $token ]['excerpt'] ) ) {
				$insert_data[ $token ]['excerpt'] = 0;
			}
			$insert_data[ $token ]['excerpt'] += $count;
		}
	}

	// Premium can index arbitrary MySQL columns.
	if ( function_exists( 'relevanssi_index_mysql_columns' ) ) {
		if ( $debug ) {
			relevanssi_debug_echo( 'Indexing MySQL columns.' );
		}
		$insert_data = relevanssi_index_mysql_columns( $insert_data, $post->ID );
	}

	// Premium can index PDF content for the parent post.
	if ( function_exists( 'relevanssi_index_pdf_for_parent' ) ) {
		if ( $debug ) {
			relevanssi_debug_echo( 'Indexing PDF content for parent post.' );
		}
		$insert_data = relevanssi_index_pdf_for_parent( $insert_data, $post->ID );
	}

	$index_titles = true;
	if ( ! empty( $post->post_title ) ) {
		/**
		 * If this filter returns false, titles are not indexed at all.
		 *
		 * @param boolean Return false to prevent titles from being indexed. Default true.
		 */
		if ( apply_filters( 'relevanssi_index_titles', $index_titles ) ) {
			if ( $debug ) {
				relevanssi_debug_echo( 'Indexing post title.' );
			}
			/** This filter is documented in wp-includes/post-template.php */
			$filtered_title = apply_filters( 'the_title', $post->post_title, $post->ID );
			/**
			 * Filters the title before tokenizing and indexing.
			 *
			 * @param string $post->post_title The title.
			 * @param object $post             The full post object.
			 */
			$filtered_title = apply_filters( 'relevanssi_post_title_before_tokenize', $filtered_title, $post );
			/**
			 * Filters whether stopwords should be removed from titles in tokenizing or not.
			 *
			 * @param boolean If true, remove stopwords. Default true.
			 */
			$title_tokens = relevanssi_tokenize( $filtered_title, apply_filters( 'relevanssi_remove_stopwords_in_titles', true ), $min_word_length );

			if ( $debug ) {
				relevanssi_debug_echo( "\tTitle, tokenized: " . implode( ' ', array_keys( $title_tokens ) ) );
			}

			if ( count( $title_tokens ) > 0 ) {
				foreach ( $title_tokens as $token => $count ) {
					$n++;
					if ( ! isset( $insert_data[ $token ]['title'] ) ) {
						$insert_data[ $token ]['title'] = 0;
					}
					$insert_data[ $token ]['title'] += $count;
				}
			}
		}
	}

	// Content indexing.
	$index_content = true;
	/**
	 * If this filter returns false, post content is not indexed at all.
	 *
	 * @param boolean Return false to prevent post content from being indexed. Default true.
	 */
	if ( apply_filters( 'relevanssi_index_content', $index_content ) ) {
		if ( $debug ) {
			relevanssi_debug_echo( 'Indexing post content.' );
		}
		remove_shortcode( 'noindex' );
		add_shortcode( 'noindex', 'relevanssi_noindex_shortcode_indexing' );

		/**
		 * Filters the post content before indexing.
		 *
		 * @param string $post->post_content The post content.
		 * @param object $post               The full post object.
		 */
		$contents = apply_filters( 'relevanssi_post_content', $post->post_content, $post );
		if ( $debug ) {
			relevanssi_debug_echo( "\tPost content after relevanssi_post_content:\n$contents" );
		}

		/**
		 * Can be used to add extra content to the post before indexing.
		 *
		 * @author Alexander Gieg
		 *
		 * @param string       The additional content.
		 * @param object $post The post object.
		 */
		$additional_content = trim( apply_filters( 'relevanssi_content_to_index', '', $post ) );
		if ( ! empty( $additional_content ) ) {
			$contents .= ' ' . $additional_content;
			if ( $debug ) {
				relevanssi_debug_echo( "\tAdditional content from relevanssi_content_to_index:\n$contents" );
			}
		}

		if ( 'on' === get_option( 'relevanssi_expand_shortcodes' ) ) {
			if ( function_exists( 'do_shortcode' ) ) {
				// WP Table Reloaded support.
				if ( defined( 'WP_TABLE_RELOADED_ABSPATH' ) ) {
					include_once WP_TABLE_RELOADED_ABSPATH . 'controllers/controller-frontend.php';
					$my_wp_table_reloaded = new WP_Table_Reloaded_Controller_Frontend();
				}
				// TablePress support.
				if ( defined( 'TABLEPRESS_ABSPATH' ) ) {
					if ( ! isset( TablePress::$model_options ) ) {
						include_once TABLEPRESS_ABSPATH . 'classes/class-model.php';
						include_once TABLEPRESS_ABSPATH . 'models/model-options.php';
						TablePress::$model_options = new TablePress_Options_Model();
					}
					$my_tablepress_controller = TablePress::load_controller( 'frontend' );
					$my_tablepress_controller->init_shortcodes();
				}

				$default_disables = array(
					'contact-form', // Jetpack Contact Form causes an error message.
					'starrater', // GD Star Rating rater shortcode causes problems.
					'responsive-flipbook', // Responsive Flipbook causes problems.
					'avatar_upload', // WP User Avatar is incompatible.
					'product_categories', // A problematic WooCommerce shortcode.
					'recent_products', // A problematic WooCommerce shortcode.
					'php', // PHP Code for Posts.
					'watupro', // Watu PRO doesn't co-operate.
					'starbox', // Starbox shortcode breaks Relevanssi.
					'cfdb-save-form-post', // Contact Form DB.
					'cfdb-datatable',
					'cfdb-table',
					'cfdb-json',
					'cfdb-value',
					'cfdb-count',
					'cfdb-html',
					'woocommerce_cart', // WooCommerce.
					'woocommerce_checkout',
					'woocommerce_order_tracking',
					'woocommerce_my_account',
					'woocommerce_edit_account',
					'woocommerce_change_password',
					'woocommerce_view_order',
					'woocommerce_logout',
					'woocommerce_pay',
					'woocommerce_thankyou',
					'woocommerce_lost_password',
					'woocommerce_edit_address',
					'tc_process_payment',
					'maxmegamenu', // Max Mega Menu.
					'searchandfilter', // Search and Filter.
					'downloads', // Easy Digital Downloads.
					'download_history',
					'purchase_history',
					'download_checkout',
					'purchase_link',
					'download_cart',
					'edd_profile_editor',
					'edd_login',
					'edd_register',
					'swpm_protected', // Simple Membership Partially Protected content.
					'gravityform', // Gravity Forms.
					'sdm_latest_downloads', // SDM Simple Download Monitor.
				);

				$disable_shortcodes = get_option( 'relevanssi_disable_shortcodes' );
				$shortcodes         = explode( ',', $disable_shortcodes );
				$shortcodes         = array_unique( array_merge( $shortcodes, $default_disables ) );
				foreach ( $shortcodes as $shortcode ) {
					if ( empty( $shortcode ) ) {
						continue;
					}
					remove_shortcode( trim( $shortcode ) );
					add_shortcode( $shortcode, '__return_empty_string' );
				}

				$post_before_shortcode = $post;
				$contents              = do_shortcode( $contents );
				$post                  = $post_before_shortcode; // WPCS: override ok.

				if ( defined( 'TABLEPRESS_ABSPATH' ) ) {
					unset( $my_tablepress_controller );
				}
				if ( defined( 'WP_TABLE_RELOADED_ABSPATH' ) ) {
					unset( $my_wp_table_reloaded );
				}
			}
		} else {
			$contents = strip_shortcodes( $contents );
		}

		remove_shortcode( 'noindex' );
		add_shortcode( 'noindex', 'relevanssi_noindex_shortcode' );

		$contents = relevanssi_strip_invisibles( $contents );

		// Premium feature for better control over internal links.
		if ( function_exists( 'relevanssi_process_internal_links' ) ) {
			$contents = relevanssi_process_internal_links( $contents, $post->ID );
		}

		$contents = preg_replace( '/<[a-zA-Z\/][^>]*>/', ' ', $contents );
		$contents = strip_tags( $contents );
		if ( function_exists( 'wp_encode_emoji' ) ) {
			$contents = wp_encode_emoji( $contents );
		}
		/**
		 * Filters the post content in indexing before tokenization.
		 *
		 * @param string $contents The post content.
		 * @param object $post     The full post object.
		 */
		$contents       = apply_filters( 'relevanssi_post_content_before_tokenize', $contents, $post );
		$content_tokens = relevanssi_tokenize( $contents, true, $min_word_length );
		if ( $debug ) {
			relevanssi_debug_echo( "\tContent, tokenized:\n" . implode( ' ', array_keys( $content_tokens ) ) );
		}

		if ( count( $content_tokens ) > 0 ) {
			foreach ( $content_tokens as $token => $count ) {
				$n++;
				if ( ! isset( $insert_data[ $token ]['content'] ) ) {
					$insert_data[ $token ]['content'] = 0;
				}
				$insert_data[ $token ]['content'] += $count;
			}
		}
	}

	$type = 'post';
	if ( 'attachment' === $post->post_type ) {
		$type = 'attachment';
	}

	/**
	 * Filters the indexing data before it is converted to INSERT queries.
	 *
	 * @param array  $insert_data All the tokens and their counts.
	 * @param object $post        The post object.
	 */
	$insert_data = apply_filters( 'relevanssi_indexing_data', $insert_data, $post );

	$values = array();
	foreach ( $insert_data as $term => $data ) {
		$fields = array( 'content', 'title', 'comment', 'tag', 'link', 'author', 'category', 'excerpt', 'taxonomy', 'customfield', 'mysqlcolumn' );
		foreach ( $fields as $field ) {
			if ( ! isset( $data[ $field ] ) ) {
				$data[ $field ] = 0;
			}
		}
		if ( ! isset( $data['taxonomy_detail'] ) ) {
			$data['taxonomy_detail'] = '';
		}
		if ( ! isset( $data['customfield_detail'] ) ) {
			$data['customfield_detail'] = '';
		}
		$content            = $data['content'];
		$title              = $data['title'];
		$comment            = $data['comment'];
		$tag                = $data['tag'];
		$link               = $data['link'];
		$author             = $data['author'];
		$category           = $data['category'];
		$excerpt            = $data['excerpt'];
		$taxonomy           = $data['taxonomy'];
		$customfield        = $data['customfield'];
		$mysqlcolumn        = $data['mysqlcolumn'];
		$taxonomy_detail    = $data['taxonomy_detail'];
		$customfield_detail = $data['customfield_detail'];

		$term = trim( $term );

		$value = $wpdb->prepare('(%d, %s, REVERSE(%s), %d, %d, %d, %d, %d, %d, %d, %d, %d, %d, %s, %s, %s, %d)',
		$post->ID, $term, $term, $content, $title, $comment, $tag, $link, $author, $category, $excerpt, $taxonomy, $customfield, $type, $taxonomy_detail, $customfield_detail, $mysqlcolumn);

		array_push( $values, $value );
	}

	/**
	 * Filters the INSERT query VALUES sections before they are inserted in the INSERT query.
	 *
	 * @param array  $values Value sets.
	 * @param object $post   The post object.
	 */
	$values = apply_filters( 'relevanssi_indexing_values', $values, $post );

	if ( ! empty( $values ) ) {
		$values = implode( ', ', $values );
		$query  = "INSERT IGNORE INTO $relevanssi_table (doc, term, term_reverse, content, title, comment, tag, link, author, category, excerpt, taxonomy, customfield, type, taxonomy_detail, customfield_detail, mysqlcolumn) VALUES $values";
		if ( $debug ) {
			relevanssi_debug_echo( "Final indexing query:\n\t$query" );
		}
		$wpdb->query( $query ); // WPCS: unprepared sql ok. The values are Relevanssi-generated and safe.
	}

	if ( $post_was_null ) {
		$post = null; // WPCS: override ok.
	}
	if ( $previous_post ) {
		$post = $previous_post; // WPCS: override ok.
	}

	return $n;
}

/**
 * Index taxonomy terms for given post and given taxonomy.
 *
 * @since 1.8
 *
 * @param object $post        Post object, default null.
 * @param string $taxonomy    Taxonomy name, default empty string.
 * @param array  $insert_data Insert query data array.
 *
 * @return array Updated insert query data array.
 */
function relevanssi_index_taxonomy_terms( $post = null, $taxonomy = '', $insert_data ) {
	global $wpdb, $relevanssi_variables;
	$relevanssi_table = $relevanssi_variables['relevanssi_table'];

	$n = 0;

	if ( null === $post || empty( $taxonomy ) ) {
		return $insert_data;
	}

	$min_word_length     = get_option( 'relevanssi_min_word_length', 3 );
	$post_taxonomy_terms = get_the_terms( $post->ID, $taxonomy );
	if ( false !== $post_taxonomy_terms ) {
		$tag_string = '';
		foreach ( $post_taxonomy_terms as $post_tag ) {
			if ( is_object( $post_tag ) ) {
				$tag_string .= $post_tag->name . ' ';
			}
		}
		$tag_string = apply_filters( 'relevanssi_tag_before_tokenize', trim( $tag_string ) );
		$tag_tokens = relevanssi_tokenize( $tag_string, true, $min_word_length );
		if ( count( $tag_tokens ) > 0 ) {
			foreach ( $tag_tokens as $token => $count ) {
				$n++;

				if ( 'post_tag' === $taxonomy ) {
					$insert_data[ $token ]['tag'] = $count;
				} elseif ( 'category' === $taxonomy ) {
					$insert_data[ $token ]['category'] = $count;
				} else {
					if ( ! isset( $insert_data[ $token ]['taxonomy'] ) ) {
						$insert_data[ $token ]['taxonomy'] = 0;
					}
					$insert_data[ $token ]['taxonomy'] += $count;
				}
				if ( isset( $insert_data[ $token ]['taxonomy_detail'] ) ) {
					$tax_detail = json_decode( $insert_data[ $token ]['taxonomy_detail'], true );
				} else {
					$tax_detail = array();
				}
				if ( isset( $tax_detail[ $taxonomy ] ) ) {
					$tax_detail[ $taxonomy ] += $count;
				} else {
					$tax_detail[ $taxonomy ] = $count;
				}
				$insert_data[ $token ]['taxonomy_detail'] = wp_json_encode( $tax_detail );
			}
		}
	}
	return $insert_data;
}

/**
 * Updates child posts when a parent post changes status.
 *
 * Called from 'transition_post_status' action hook when a post is edited, published,
 * or deleted. Will do the appropriate indexing action on the child posts and
 * attachments.
 *
 * @global object $wpdb The WP database interface.
 *
 * @author renaissancehack
 *
 * @param string $new_status The new status.
 * @param string $old_status The old status.
 * @param object $post       The post object.
 */
function relevanssi_update_child_posts( $new_status, $old_status, $post ) {
	global $wpdb;

	// Safety check, for WordPress Editorial Calendar incompatibility.
	if ( ! isset( $post ) || ! isset( $post->ID ) ) {
		return;
	}

	/** Documented in lib/indexing.php. */
	$index_statuses = apply_filters( 'relevanssi_valid_status', array( 'publish', 'private', 'draft', 'pending', 'future' ) );
	if ( ( $new_status === $old_status ) || ( in_array( $new_status, $index_statuses, true ) && in_array( $old_status, $index_statuses, true ) ) || ( in_array( $post->post_type, array( 'attachment', 'revision' ), true ) ) ) {
		/**
		 * Either:
		 *
		 * 1. New status equals old status.
		 * 2. Both new and old status are in the list of stati to index.
		 * 3. The post is an attachment or a revision.
		 *
		 * In any of these cases, do nothing.
		 */
		return;
	}

	$post_types  = get_option( 'relevanssi_index_post_types' );
	$args        = array(
		'post_parent' => $post->ID,
		'post_type'   => $post_types,
	);
	$child_posts = get_children( $args );
	if ( ! empty( $child_posts ) ) {
		if ( ! in_array( $new_status, $index_statuses, true ) ) {
			foreach ( $child_posts as $post ) {
				relevanssi_remove_doc( $post->ID );
			}
		} else {
			foreach ( $child_posts as $post ) {
				relevanssi_publish( $post->ID );
			}
		}
	}
}

/**
 * Indexes a published post.
 *
 * @param int     $post_id            The post ID.
 * @param boolean $bypass_global_post If tru, bypass the global $post object. Default false.
 */
function relevanssi_publish( $post_id, $bypass_global_post = false ) {
	$post_status = get_post_status( $post_id );
	if ( 'auto-draft' === $post_status ) {
		return;
	}

	$custom_fields = relevanssi_get_custom_fields();
	relevanssi_index_doc( $post_id, true, $custom_fields, $bypass_global_post );
}

/**
 * Indexes a post after publishing or modification.
 *
 * Hooks on to 'wp_insert_post' action hook and triggers when wp_insert_post() is
 * used to add a post into the database. Doesn't use the global $post object, because
 * that doesn't have the correct post.
 *
 * @author Lumpysimon.
 *
 * @global object $wpdb The WP database interface.
 *
 * @param int $post_id The post ID.
 */
function relevanssi_insert_edit( $post_id ) {
	global $wpdb;

	$post_status = get_post_status( $post_id );
	if ( 'auto-draft' === $post_status ) {
		return;
	}

	if ( 'inherit' === $post_status ) {
		// Get the post status from the parent post.
		$parent_id   = wp_get_post_parent_id( $post_id );
		$post_status = get_post_status( $parent_id );
	}

	$index_this_post = true;

	/* Documented in lib/indexing.php. */
	$restriction = apply_filters( 'relevanssi_indexing_restriction', '' );
	if ( ! empty( $restriction ) ) {
		// Check the indexing restriction filter: if the post passes the filter, this
		// should return the post ID.
		$is_unrestricted = $wpdb->get_var( "SELECT ID FROM $wpdb->posts AS post WHERE ID = $post_id $restriction" ); // WPCS: unprepared SQL ok.
		if ( ! $is_unrestricted ) {
			$index_this_post = false;
		}
	}

	$index_statuses = apply_filters( 'relevanssi_valid_status', array( 'publish', 'private', 'draft', 'future', 'pending' ) );
	if ( ! in_array( $post_status, $index_statuses, true ) ) {
		$index_this_post = false;
	}

	if ( $index_this_post ) {
		$bypass_global_post = true;
		relevanssi_publish( $post_id, $bypass_global_post );
	} else {
		// The post isn't supposed to be indexed anymore, remove it from index.
		relevanssi_remove_doc( $post_id );
	}

	relevanssi_update_doc_count();
}

/**
 * Triggers comment indexing when a comment is edited.
 *
 * @author OdditY
 *
 * @param int $comment_id Comment id.
 */
function relevanssi_comment_edit( $comment_id ) {
	$action = 'update';
	relevanssi_index_comment( $comment_id, $action );
}

/**
 * Triggers comment indexing when a comment is deleted.
 *
 * @author OdditY
 *
 * @param int $comment_id Comment ID.
 */
function relevanssi_comment_remove( $comment_id ) {
	$action = 'remove';
	relevanssi_index_comment( $comment_id, $action );
}

/**
 * Updates comment indexing when comments are added, edited or deleted.
 *
 * @author OdditY
 *
 * @param int    $comment_id Commend ID.
 * @param string $action     What to do: 'add', 'update', 'remove'. Default 'add'.
 */
function relevanssi_index_comment( $comment_id, $action = 'add' ) {
	global $wpdb;

	$comment_indexing_type = get_option( 'relevanssi_index_comments' );
	$no_pingbacks          = false;
	$post_id               = null;

	switch ( $comment_indexing_type ) {
		case 'all':
			// All.
			break;
		case 'normal':
			// Exclude trackbacks and pingbacks.
			$no_pingbacks = true;
			break;
		default:
			// No indexing.
			return;
	}
	switch ( $action ) {
		case 'update':
			// Update, reindex the post.
			$comment = get_comment( $comment_id );
			if ( $no_pingbacks && ! empty( $comment->comment_type ) ) {
				break;
			}
			$post_id = $comment->comment_post_ID;
			break;
		case 'remove':
			// Remove, empty the comment and reindex the post.
			$comment = get_comment( $comment_id );
			if ( $no_pingbacks && ! empty( $comment->comment_type ) ) {
				break;
			}
			$post_id = $comment->comment_post_ID;
			if ( $post_id ) {
				// Empty comment_content and reindex, then let WP delete the empty comment.
				$wpdb->query( $wpdb->prepare( "UPDATE $wpdb->comments SET comment_content='' WHERE comment_ID=%d", $comment_id ) );
			}
			break;
		default:
			// Add new comment.
			$comment = get_comment( $comment_id );
			if ( $no_pingbacks && ! empty( $comment->comment_type ) ) {
				break;
			}
			if ( 1 !== intval( $comment->comment_approved ) ) {
				// Comment isn't approved, do not index.
				break;
			}
			$post_id = $comment->comment_post_ID;
			break;
	}
	if ( $post_id ) {
		relevanssi_publish( $post_id );
	}
}

/**
 * Returns the comment text for a post.
 *
 * @global object $wpdb The WordPress database interface.
 *
 * @param int $post_id The post ID.
 *
 * @return string All the comment content as a string that has the comment author
 * and the comment text.
 */
function relevanssi_get_comments( $post_id ) {
	global $wpdb;

	/**
	 * If this filter returns true, the comments for the post are not indexed.
	 *
	 * @param boolean Return true to block the comment indexing. Default false.
	 * @param int     $post_id The post ID.
	 */
	if ( apply_filters( 'relevanssi_index_comments_exclude', false, $post_id ) ) {
		return '';
	}

	$comment_indexing = get_option( 'relevanssi_index_comments' );
	$comment_types    = array( 'comment' );
	$comment_string   = '';

	if ( 'all' === $comment_indexing ) {
		$comment_types[] = 'pings';
	}
	if ( 'none' === $comment_indexing ) {
		return '';
	}

	$offset = 0;
	$limit  = 20;

	while ( true ) {
		// Posts may have lots of comments. Do 20 at the time to avoid memory issues.
		$args     = array(
			'offset' => $offset,
			'number' => $limit,
			'type'   => $comment_types,
		);
		$comments = get_approved_comments( $post_id, $args );

		if ( count( $comments ) === 0 ) {
			break;
		}
		foreach ( $comments as $comment ) {
			/**
			 * Filters the comment author before indexing.
			 *
			 * @param string Comment author display name.
			 * @param int    The comment ID.
			 */
			$comment_string .= ' ' . apply_filters( 'relevanssi_comment_author_to_index', $comment->comment_author, $comment->comment_ID );
			/**
			 * Filters the comment content before indexing.
			 *
			 * @param string Comment content.
			 * @param int    The comment ID.
			 */
			$comment_string .= ' ' . apply_filters( 'relevanssi_comment_content_to_index', $comment->comment_content, $comment->comment_ID );
		}
		$offset += $limit;
	}

	return $comment_string;
}

/**
 * Truncates the Relevanssi index.
 *
 * @global object $wpdb                 The WordPress database interface.
 * @global array  $relevanssi_variables The Relevanssi global variables array, used
 * for table names.
 *
 * @return boolean True on success, false on failure.
 */
function relevanssi_truncate_index() {
	global $wpdb, $relevanssi_variables;
	$relevanssi_table = $relevanssi_variables['relevanssi_table'];
	return $wpdb->query( "TRUNCATE TABLE $relevanssi_table" ); // WPCS: unprepared SQL ok, Relevanssi table name.
}

/**
 * Remove post from the Relevanssi index.
 *
 * @global object $wpdb                 The WordPress database interface.
 * @global array  $relevanssi_variables The Relevanssi global variables array, used
 * for table names.
 *
 * @param int     $post_id             The post ID.
 * @param boolean $keep_internal_links If true, keep internal link indexing (a
 * Premium feature). Default false.
 */
function relevanssi_remove_doc( $post_id, $keep_internal_links = false ) {
	if ( function_exists( 'relevanssi_premium_remove_doc' ) ) {
		// Premium has a different method, because the index can include taxonomy
		// terms and user profiles.
		relevanssi_premium_remove_doc( $post_id, $keep_internal_links );
	} else {
		global $wpdb, $relevanssi_variables;

		$post_id = intval( $post_id );
		if ( empty( $post_id ) ) {
			// No post ID specified.
			return;
		}

		$doc_count = get_option( 'relevanssi_doc_count' );

		$rows_updated = $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $relevanssi_variables['relevanssi_table'] . ' WHERE doc=%d', $post_id ) ); // WPCS: unprepared SQL ok, Relevanssi table name.

		if ( $rows_updated && $rows_updated > 0 ) {
			update_option( 'relevanssi_doc_count', $doc_count - $rows_updated );
		}
	}
}
