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

/**
 * Returns a Relevanssi_Taxonomy_Walker instance.
 *
 * Requires the class file and generates a new Relevanssi_Taxonomy_Walker instance.
 *
 * @return object A new Relevanssi_Taxonomy_Walker instance.
 */
function get_relevanssi_taxonomy_walker() {
	require_once 'class-relevanssi-taxonomy-walker.php';
	return new Relevanssi_Taxonomy_Walker();
}

/**
 * Adds apostrophes around a string.
 *
 * @param string $str The string.
 *
 * @return string The string with apostrophes around it.
 */
function relevanssi_add_apostrophes( $str ) {
	return "'" . $str . "'";
}

/**
 * Adds quotes around a string.
 *
 * @param string $str The string.
 *
 * @return string The string with quotes around it.
 */
function relevanssi_add_quotes( $str ) {
	return '"' . $str . '"';
}

/**
 * Wraps the relevanssi_mb_trim() function so that it can be used as a callback
 * for array_walk().
 *
 * @since 2.1.4
 *
 * @see relevanssi_mb_trim.
 *
 * @param string $str String to trim.
 */
function relevanssi_array_walk_trim( string &$str ) {
	$str = relevanssi_mb_trim( $str );
}

/**
 * Converts sums in an array to averages, based on an array containing counts.
 *
 * Both arrays need to have (key, value) pairs with the same keys. The values
 * in $arr are then divided by the matching values in $counts, so when we have
 * sums in $arr and counts in $counts, we end up with averages.
 *
 * @param array $arr    The array with sums, passed as reference.
 * @param array $counts The array with counts.
 */
function relevanssi_average_array( array &$arr, array $counts ) {
	array_walk(
		$arr,
		function ( &$value, $key ) use ( $counts ) {
			$value = round( $value / $counts[ $key ], 2 );
		}
	);
}

/**
 * Returns 'checked' if the option is enabled.
 *
 * @param string $option Value to check.
 *
 * @return string If the option is 'on', returns 'checked', otherwise returns an
 * empty string.
 */
function relevanssi_check( string $option ) {
	$checked = '';
	if ( 'on' === $option ) {
		$checked = 'checked';
	}
	return $checked;
}

/**
 * Closes tags in a bit of HTML code.
 *
 * Used to make sure no tags are left open in excerpts. This method is not
 * foolproof, but it's good enough for now.
 *
 * @param string $html The HTML code to analyze.
 *
 * @return string The HTML code, with tags closed.
 */
function relevanssi_close_tags( string $html ) {
	$result = array();
	preg_match_all(
		'#<(?!meta|img|br|hr|input\b)\b([a-z]+)(?: .*)?(?<![/|/ ])>#iU',
		$html,
		$result
	);
	$opened_tags = $result[1];
	preg_match_all( '#</([a-z]+)>#iU', $html, $result );
	$closed_tags = $result[1];
	$len_opened  = count( $opened_tags );
	if ( count( $closed_tags ) === $len_opened ) {
		return $html;
	}
	$opened_tags = array_reverse( $opened_tags );
	for ( $i = 0; $i < $len_opened; $i++ ) {
		if ( ! in_array( $opened_tags[ $i ], $closed_tags, true ) ) {
			$html .= '</' . $opened_tags[ $i ] . '>';
		} else {
			unset(
				$closed_tags[ array_search( $opened_tags[ $i ], $closed_tags, true ) ]
			);
		}
	}
	return $html;
}

/**
 * Counts search term occurrances in the Relevanssi index.
 *
 * @param string $query The search query. Will be split at spaces.
 * @param string $mode  Output mode. Possible values 'array' or 'string'.
 * Default is 'array'.
 *
 * @return array|string An array of search term occurrances, or a string with
 * the number of occurrances.
 */
function relevanssi_count_term_occurrances( string $query, string $mode = 'array' ) {
	global $wpdb, $relevanssi_variables;

	$terms  = explode( ' ', $query );
	$counts = array();

	foreach ( $terms as $term ) {
		$term = trim( $term );
		if ( empty( $term ) ) {
			continue;
		}
		$counts[ $term ] = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT SUM(content + title + comment + tag +
				link + author + category + excerpt + taxonomy + customfield
				+ mysqlcolumn) AS total FROM ' .
				$relevanssi_variables['relevanssi_table'] . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				' WHERE term = %s
				GROUP BY term',
				$term
			)
		);
	}

	if ( 'array' === $mode ) {
		return $counts;
	} elseif ( 'string' === $mode ) {
		$strings = array();
		foreach ( $counts as $term => $count ) {
			$strings[] = "<span class='search_term'>$term</span>: <span class='count'>$count</span>";
		}
		return implode( ', ', $strings );
	}
}

/**
 * Prints out debugging notices.
 *
 * If WP_CLI is available, prints out the debug notice as a WP_CLI::log(),
 * otherwise if debug mode is on, uses error_log(), otherwise just echo.
 *
 * @param string $notice The notice to print out.
 */
function relevanssi_debug_echo( string $notice ) {
	if ( defined( 'WP_CLI' ) && WP_CLI ) {
		WP_CLI::log( $notice );
	} elseif ( relevanssi_log_debug() ) {
		error_log( 'RELEVANSSI: ' . $notice ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
	} else {
		echo esc_html( $notice ) . "\n";
	}
}

/**
 * Runs do_shortcode() on content, but safeguards the global $post to make sure
 * it isn't changed by the shortcodes. If shortcode expansion is disabled in
 * Relevanssi settings, runs strip_shortcodes() on the content.
 *
 * @uses relevanssi_disable_shortcodes() Disables problem shortcodes.
 * @see do_shortcode()                   Expands shortcodes.
 * @see strip_shortcodes()               Strips off shortcodes.
 *
 * @param string $content The content where the shortcodes are expanded.
 *
 * @return string
 */
function relevanssi_do_shortcode( string $content ): string {
	if ( 'on' === get_option( 'relevanssi_expand_shortcodes' ) ) {
		relevanssi_disable_shortcodes();

		/**
		 * This needs to be global here, otherwise the safety mechanism doesn't
		 * work correctly.
		 */
		global $post;

		$global_post_before_shortcode = null;
		if ( isset( $post ) ) {
			$global_post_before_shortcode = $post;
		}

		$content = do_shortcode( $content );

		if ( $global_post_before_shortcode ) {
			$post = $global_post_before_shortcode; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		}

		unset( $tablepress_controller );
	} else {
		$content = strip_shortcodes( $content );
	}
	return $content;
}

/**
 * Recursively flattens a multidimensional array to produce a string.
 *
 * @param array $arr The source array.
 *
 * @return string The array contents as a string.
 */
function relevanssi_flatten_array( array $arr ) {
	$return_value = '';
	foreach ( new RecursiveIteratorIterator( new RecursiveArrayIterator( $arr ) ) as $value ) {
		$return_value .= ' ' . $value;
	}
	return trim( $return_value );
}

/**
 * Generates from and to date values from ranges.
 *
 * Possible values in the $request array: 'from' and 'to' for direct dates,
 * 'this_year' for Jan 1st to today, 'this_month' for 1st of month to today,
 * 'last_month' for 1st of previous month to last of previous month,
 * 'this_week' for Monday of this week to today (or Sunday, if the
 * relevanssi_week_starts_on_sunday returns `true`), 'last_week' for the
 * previous week, 'last_30' for from 30 days ago to today, 'last_7' for from
 * 7 days ago to today.
 *
 * @param array  $request The request array where the settings are.
 * @param string $from    The default 'from' date in "Y-m-d" format.
 * @return array The from date in 'from' and the to date in 'to' in "Y-m-d"
 * format.
 */
function relevanssi_from_and_to( array $request, string $from ): array {
	$today      = gmdate( 'Y-m-d' );
	$week_start = 'monday';
	$to         = $today;

	/**
	 * Controls whether the week starts on Sunday or Monday.
	 *
	 * @param boolean If `true`, week starts on Sunday. Default `false`, week
	 * starts on Monday.
	 */
	if ( apply_filters( 'relevanssi_week_starts_on_sunday', false ) ) {
		$week_start = 'sunday';
	}
	if ( ! isset( $request['everything'] ) && isset( $request['from'] ) && $request['from'] > $from ) {
		$from = $request['from'];
	}
	if ( ! isset( $request['everything'] ) && isset( $request['to'] ) && $request['to'] < $today ) {
		$to = $request['to'];
	}
	if ( isset( $request['this_year'] ) ) {
		$from = gmdate( 'Y-m-d', strtotime( 'first day of january this year' ) );
		$to   = gmdate( 'Y-m-d' );
	}
	if ( isset( $request['this_month'] ) ) {
		$from = gmdate( 'Y-m-d', strtotime( 'first day of this month' ) );
		$to   = gmdate( 'Y-m-d' );
	}
	if ( isset( $request['last_month'] ) ) {
		$from = gmdate( 'Y-m-d', strtotime( 'first day of previous month' ) );
		$to   = gmdate( 'Y-m-d', strtotime( 'last day of previous month' ) );
	}
	if ( isset( $request['this_week'] ) ) {
		$from = gmdate( 'Y-m-d', strtotime( 'previous ' . $week_start ) );
		$to   = gmdate( 'Y-m-d' );
	}
	if ( isset( $request['last_week'] ) ) {
		$start = 'sunday' === $week_start ? gmdate( 'w' ) + 7 : gmdate( 'w' ) + 6;
		$end   = 'sunday' === $week_start ? gmdate( 'w' ) + 1 : gmdate( 'w' );
		$from  = gmdate( 'Y-m-d', strtotime( '-' . $start . ' days' ) );
		$to    = gmdate( 'Y-m-d', strtotime( '-' . $end . ' days' ) );
	}
	if ( isset( $request['last_30'] ) ) {
		$from = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
		$to   = gmdate( 'Y-m-d' );
	}
	if ( isset( $request['last_7'] ) ) {
		$from = gmdate( 'Y-m-d', strtotime( '-7 days' ) );
		$to   = gmdate( 'Y-m-d' );
	}

	return array(
		'from' => $from,
		'to'   => $to,
	);
}

/**
 * Generates closing tags for an array of tags.
 *
 * @param array $tags Array of tag names.
 *
 * @return array $closing_tags Array of closing tags.
 */
function relevanssi_generate_closing_tags( array $tags ) {
	$closing_tags = array();
	foreach ( $tags as $tag ) {
		$a = str_replace( '<', '</', $tag );
		$b = str_replace( '>', '/>', $tag );

		$closing_tags[] = $a;
		$closing_tags[] = $b;
	}
	return $closing_tags;
}

/**
 * Returns a post object based on ID, **type**id notation or an object.
 *
 * @uses relevanssi_get_post_object() Fetches post objects.
 *
 * @param int|string|WP_Post $source The source identified to parse, either a
 * post ID integer, a **type**id string or a post object.
 *
 * @return array An array containing the actual object in 'object' and the
 * format of the original value in 'format'. The value can be 'object', 'id'
 * or 'id=>parent'.
 */
function relevanssi_get_an_object( $source ) {
	$object = $source;
	$format = 'object';

	if ( ! is_object( $source ) ) {
		// Convert from post ID to post.
		$object = relevanssi_get_post_object( $source );
		$format = 'id';
	} elseif ( is_object( $source ) && property_exists( $source, 'type' ) ) {
		// Convert from id=>type to post.
		$object = relevanssi_get_post_object( $source->ID );
		$format = 'id=>type';
	} elseif ( ! isset( $source->post_content ) ) {
		// Convert from id=>parent to post.
		$object = relevanssi_get_post_object( $source->ID );
		$format = 'id=>parent';
	}

	return array(
		'object' => $object,
		'format' => $format,
	);
}

/**
 * Returns the attachment filename suffix.
 *
 * Reads the filename from $post->guid and returns the file suffix.
 *
 * @param WP_Post|int $post The post object or post ID.
 * @return string The suffix if it is found, an empty string otherwise.
 */
function relevanssi_get_attachment_suffix( $post ): string {
	if ( ! is_object( $post ) ) {
		$post = relevanssi_get_post( $post );
		if ( is_wp_error( $post ) ) {
			return '';
		}
	}
	if ( 'attachment' !== $post->post_type ) {
		return '';
	}
	list( , $type ) = explode( '.', basename( $post->guid ) );
	return $type;
}

/**
 * Returns the locale or language code.
 *
 * If WPML or Polylang is not available, returns `get_locale()` value. With
 * WPML or Polylang, first this function checks to see if the global $post is
 * set. If it is, the function returns the language of the post, as we're
 * working on a post and need to use the correct language.
 *
 * If the global $post is not set, this function returns for Polylang the
 * results of `pll_current_language()`, for WPML it uses `wpml_current_language`
 * and `wpml_active_languages`.
 *
 * @param boolean $locale If true, return locale; if false, return language
 * code.
 *
 * @return string The locale or the language code.
 */
function relevanssi_get_current_language( bool $locale = true ) {
	$current_language = get_locale();
	if ( ! $locale ) {
		$current_language = substr( $current_language, 0, 2 );
	}
	if ( class_exists( 'Polylang', false ) ) {
		global $post;

		if ( isset( $post ) ) {
			if ( isset( $post->term_id ) && function_exists( 'pll_get_term_language' ) ) {
				$term_language = pll_get_term_language( $post->term_id, $locale ? 'locale' : 'slug' );
				if ( $term_language ) {
					$current_language = $term_language;
				}
			} elseif ( ! isset( $post->user_id ) && function_exists( 'pll_get_post_language' ) ) {
				$post_language = pll_get_post_language( $post->ID, $locale ? 'locale' : 'slug' );
				if ( $post_language ) {
					$current_language = $post_language;
				}
			}
		} elseif ( function_exists( 'pll_current_language' ) ) {
			$pll_language     = pll_current_language( $locale ? 'locale' : 'slug' );
			$current_language = $pll_language ? $pll_language : $current_language;
		}
	}
	if ( function_exists( 'icl_object_id' ) && ! function_exists( 'pll_is_translated_post_type' ) ) {
		global $post;

		if ( isset( $post ) ) {
			$language_details = array(
				'locale'        => '',
				'language_code' => '',
			);
			if ( isset( $post->term_id ) ) {
				// Terms don't have a locale, just a language code.
				$element       = array(
					'element_id'   => relevanssi_get_term_tax_id( $post->term_id, $post->post_type ),
					'element_type' => $post->post_type,
				);
				$language_code = apply_filters( 'wpml_element_language_code', null, $element );

				$language_details['language_code'] = $language_code;
			} elseif ( ! isset( $post->user_id ) && 'post_type' !== $post->post_type ) {
				// Users don't have language details.
				$language_details = apply_filters( 'wpml_post_language_details', null, $post->ID );
			}
			if ( is_wp_error( $language_details ) ) {
				$current_language = apply_filters( 'wpml_current_language', null );
			} else {
				$current_language = $language_details[ $locale ? 'locale' : 'language_code' ];
			}
		} elseif ( $locale ) {
			$languages = apply_filters( 'wpml_active_languages', null );
			foreach ( $languages as $l ) {
				if ( $l['active'] ) {
					$current_language = $l['default_locale'];
					break;
				}
			}
		} else {
			$current_language = apply_filters( 'wpml_current_language', null );
		}
	}
	return $current_language;
}

/**
 * Gets the permalink to the current post within Loop.
 *
 * Uses get_permalink() to get the permalink, then adds the 'highlight'
 * parameter if necessary using relevanssi_add_highlight().
 *
 * @param int|WP_Post $post Post ID or post object. Default is the global $post.
 *
 * @see get_permalink()
 *
 * @return string The permalink.
 */
function relevanssi_get_permalink( $post = 0 ) {
	/**
	 * Filters the permalink.
	 *
	 * @param string The permalink, generated by get_permalink().
	 */
	$permalink = apply_filters( 'relevanssi_permalink', get_permalink( $post ) );

	return $permalink;
}

/**
 * Replacement for get_post() that uses the Relevanssi post cache.
 *
 * Tries to fetch the post from the Relevanssi post cache. If that doesn't work,
 * gets the post using get_post().
 *
 * @param int|string $post_id The post ID. Usually an integer post ID, but can
 * also be a string (u_<user ID>, p_<post type name> or
 * **<taxonomy>**<term ID>).
 * @param int        $blog_id The blog ID, default -1. If -1, will be replaced
 * with the actual current blog ID from get_current_blog_id().
 *
 * @return object|WP_Error The post object or a WP_Error object if the post
 * doesn't exist.
 */
function relevanssi_get_post( $post_id, int $blog_id = -1 ) {
	if ( -1 === $blog_id ) {
		$blog_id = get_current_blog_id();
	}
	if ( function_exists( 'relevanssi_premium_get_post' ) ) {
		return relevanssi_premium_get_post( $post_id, $blog_id );
	}

	global $relevanssi_post_array;

	$post = null;
	if ( isset( $relevanssi_post_array[ $post_id ] ) ) {
		$post = $relevanssi_post_array[ $post_id ];
	}
	if ( ! $post ) {
		$post = get_post( $post_id );

		$relevanssi_post_array[ $post_id ] = $post;
	}
	if ( ! $post ) {
		$post = new WP_Error( 'post_not_found', __( 'The requested post does not exist.' ) );
	}
	return $post;
}

/**
 * Fetches post meta value for a large group of posts with just one query.
 *
 * This function can be used to reduce the number of database queries. Instead
 * of looping through an array of posts and calling get_post_meta() for each
 * individual post, you can get all the values with this function with just one
 * database query.
 *
 * @param array  $post_ids An array of post IDs.
 * @param string $field    The name of the field.
 *
 * @return array An array of post_id, meta_value pairs.
 */
function relevanssi_get_post_meta_for_all_posts( array $post_ids, string $field ): array {
	global $wpdb;

	$post_ids_string = implode( ',', $post_ids );
	$meta_values     = array();

	if ( $post_ids_string ) {
		$meta_values = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT post_id, meta_value FROM $wpdb->postmeta
                WHERE meta_key = %s
				AND post_id IN ( $post_ids_string )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
				$field
			)
		);
	}

	$results = array();
	foreach ( $meta_values as $row ) {
		$results[ $row->post_id ] = $row->meta_value;
	}

	return $results;
}

/**
 * Returns an object based on ID.
 *
 * Wrapper to handle non-post cases (terms, user profiles). Regular posts are
 * passed on to relevanssi_get_post().
 *
 * @uses relevanssi_get_post() Used to fetch regular posts.
 *
 * @param int|string $post_id An ID, either an integer post ID or a
 * **type**id string for terms and users.
 *
 * @return WP_Post|WP_Term|WP_User|WP_Error An object, type of which depends on
 * the target object. If relevanssi_get_post() doesn't find the post, this
 * returns a WP_Error.
 */
function relevanssi_get_post_object( $post_id ) {
	$object = null;
	if ( ! $post_id ) {
		return new WP_Error( 'null_value', 'Null post ID' );
	}
	if ( '*' === substr( $post_id, 0, 1 ) ) {
		// Convert from **type**id to a user or a term object.
		$parts = explode( '**', $post_id );
		$type  = $parts[1] ?? null;
		$id    = $parts[2] ?? null;
		if ( $type && $id ) {
			if ( 'user' === $type ) {
				$object = get_user_by( 'id', $id );
			} else {
				$object = get_term( $id, $type );
			}
		}
	} else {
		$object = relevanssi_get_post( $post_id );
	}
	return $object;
}

/**
 * Returns the term taxonomy ID for a term based on term ID.
 *
 * @global object $wpdb The WordPress database interface.
 *
 * @param int    $term_id  The term ID.
 * @param string $taxonomy The taxonomy.
 *
 * @return int Term taxonomy ID.
 */
function relevanssi_get_term_tax_id( int $term_id, string $taxonomy ) {
	global $wpdb;
	return $wpdb->get_var(
		$wpdb->prepare(
			"SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s",
			$term_id,
			$taxonomy
		)
	);
}

/**
 * Fetches the taxonomy based on term ID.
 *
 * Fetches the taxonomy from wp_term_taxonomy based on term_id.
 *
 * @global object $wpdb The WordPress database interface.
 *
 * @param int $term_id The term ID.
 *
 * @deprecated Will be removed in future versions.
 *
 * @return string $taxonomy The term taxonomy.
 */
function relevanssi_get_term_taxonomy( int $term_id ) {
	global $wpdb;

	$taxonomy = $wpdb->get_var( $wpdb->prepare( "SELECT taxonomy FROM $wpdb->term_taxonomy WHERE term_id = %d", $term_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	return $taxonomy;
}

/**
 * Gets a list of tags for post.
 *
 * Replacement for get_the_tags() that does the same, but applies Relevanssi
 * search term highlighting on the results.
 *
 * @uses relevanssi_the_tags() Does the actual work.
 *
 * @param string $before    What is printed before the tags, default ''.
 * @param string $separator The separator between items, default ', '.
 * @param string $after     What is printed after the tags, default ''.
 * @param int    $post_id   The post ID. Default current post ID (in the Loop).
 */
function relevanssi_get_the_tags( string $before = '', string $separator = ', ', string $after = '', int $post_id = 0 ) {
	return relevanssi_the_tags( $before, $separator, $after, false, $post_id );
}

/**
 * Returns the post title with highlighting.
 *
 * Reads the highlighted title from $post->post_highlighted_title. Uses the
 * relevanssi_get_post() to fecth the post.
 *
 * @uses relevanssi_get_post() Fetches post objects.
 *
 * @param int|WP_Post $post The post ID or a post object.
 *
 * @return string The post title with highlights and an empty string, if the
 * post cannot be found.
 */
function relevanssi_get_the_title( $post ) {
	if ( is_numeric( $post ) ) {
		$post = relevanssi_get_post( $post );
	}
	if ( is_wp_error( $post ) ) {
		return '';
	}
	if ( empty( $post->post_highlighted_title ) ) {
		$post->post_highlighted_title = $post->post_title;
	}
	return $post->post_highlighted_title;
}

/**
 * Adds a soft hyphen to a string at every five characters.
 *
 * @param string $str The string to hyphenate.
 *
 * @return string The hyphenated string.
 */
function relevanssi_hyphenate( $str ) {
	$str = preg_replace( '/([^\s]{8})([^\s])/u', '$1&shy;$2', html_entity_decode( $str ) );
	return $str;
}

/**
 * Returns an imploded string if the option exists and is an array, an empty
 * string otherwise.
 *
 * @see implode()
 *
 * @param array  $request An array of option values.
 * @param string $option  The key to check.
 * @param string $glue    The glue string for implode(), default ','.
 *
 * @return string Imploded string or an empty string.
 */
function relevanssi_implode( array $request, string $option, string $glue = ',' ) {
	if ( isset( $request[ $option ] ) && is_array( $request[ $option ] ) ) {
		return implode( $glue, $request[ $option ] );
	}
	return '';
}

/**
 * Increases a value. If it's not set, sets it first to the default value.
 *
 * @param int $value     The value to increase (passed by reference).
 * @param int $increase  The amount to increase the value, default 1.
 * @param int $def_value The default value, default 0.
 */
function relevanssi_increase_value( &$value, $increase = 1, $def_value = 0 ) {
	if ( ! isset( $value ) ) {
		$value = $def_value;
	}
	$value += $increase;
}

/**
 * Returns the intval of the option if it exists, null otherwise.
 *
 * @see intval()
 *
 * @param array  $request An array of option values.
 * @param string $option  The key to check.
 *
 * @return int|null Integer value of the option, or null.
 */
function relevanssi_intval( array $request, string $option ) {
	if ( isset( $request[ $option ] ) ) {
		return intval( $request[ $option ] );
	}
	return null;
}

/**
 * Returns true if the search is from Relevanssi Live Ajax Search.
 *
 * Checks if $wp_query->query_vars['action'] is set to "relevanssi_live_search".
 *
 * @return bool True if the search is from Relevanssi Live Ajax Search, false
 * otherwise.
 */
function relevanssi_is_live_search() {
	global $wp_query;
	$relevanssi_live_search = false;
	if ( isset( $wp_query->query_vars['action'] ) && 'relevanssi_live_search' === $wp_query->query_vars['action'] ) {
		$relevanssi_live_search = true;
	}
	return $relevanssi_live_search;
}

/**
 * Checks if a string is a multiple-word phrase.
 *
 * Replaces hyphens, quotes and ampersands with spaces if necessary based on
 * the Relevanssi advanced indexing settings.
 *
 * @param string $str The string to check.
 *
 * @return boolean True if the string is a multiple-word phrase, false otherwise.
 */
function relevanssi_is_multiple_words( string $str ): bool {
	if ( empty( $str ) ) {
		return false;
	}
	$punctuation = get_option(
		'relevanssi_punctuation',
		array(
			'quotes'     => 'replace',
			'hyphens'    => 'replace',
			'ampersands' => 'replace',
		)
	);
	if ( 'replace' === $punctuation['hyphens'] ) {
		$str = str_replace(
			array(
				'-',
				'–',
				'—',
			),
			' ',
			$str
		);
	}
	if ( 'replace' === $punctuation['quotes'] ) {
		$str = str_replace(
			array(
				'&#8217;',
				"'",
				'’',
				'‘',
				'”',
				'“',
				'„',
				'´',
				'″',
			),
			' ',
			$str
		);
	}
	if ( 'replace' === $punctuation['ampersands'] ) {
		$str = str_replace(
			array(
				'&#038;',
				'&amp;',
				'&',
			),
			' ',
			$str
		);
	}

	if ( count( explode( ' ', $str ) ) > 1 ) {
		return true;
	}

	return false;
}

/**
 * Launches an asynchronous Ajax action.
 *
 * Makes a wp_remote_post() call with the specific action. Handles nonce
 * verification.
 *
 * @see wp_remove_post()
 * @see wp_create_nonce()
 *
 * @param string $action       The action to trigger (also the name of the
 * nonce).
 * @param array  $payload_args The parameters sent to the action. Defaults to
 * an empty array.
 *
 * @return WP_Error|array The wp_remote_post() response or WP_Error on failure.
 */
function relevanssi_launch_ajax_action( string $action, array $payload_args = array() ) {
	$cookies = array();
	foreach ( $_COOKIE as $name => $value ) {
		$cookies[] = "$name=" . rawurlencode(
			is_array( $value ) ? wp_json_encode( $value ) : $value
		);
	}
	$default_payload = array(
		'action' => $action,
		'_nonce' => wp_create_nonce( $action ),
	);
	$payload         = array_merge( $default_payload, $payload_args );
	$args            = array(
		'timeout'  => 0.01,
		'blocking' => false,
		'body'     => $payload,
		'headers'  => array(
			'cookie' => implode( '; ', $cookies ),
		),
	);
	$url             = admin_url( 'admin-ajax.php' );
	return wp_remote_post( $url, $args );
}

/**
 * Returns a legal value.
 *
 * @param array  $request An array of option values.
 * @param string $option  The key to check.
 * @param array  $values  The legal values.
 * @param string $def_val The default value.
 *
 * @return string|null A legal value or the default value, null if the option
 * isn't set.
 */
function relevanssi_legal_value( array $request, string $option, array $values, string $def_val ) {
	$value = null;
	if ( isset( $request[ $option ] ) ) {
		$value = $def_val;
		if ( in_array( $request[ $option ], $values, true ) ) {
			$value = $request[ $option ];
		}
	}
	return $value;
}

/**
 * Multibyte friendly case-insensitive string comparison.
 *
 * If multibyte string functions are available, do strnatcmp() after using
 * mb_strtoupper() to both strings. Otherwise use strnatcasecmp().
 *
 * @see strnatcasecmp() Falls back to this if multibyte functions are
 * not available.
 * @see strnatcmp()     Used to compare the strings.
 * @see mb_strtoupper() Used to convert strings to uppercase.
 *
 * @param string $str1     First string to compare.
 * @param string $str2     Second string to compare.
 * @param string $encoding The encoding to use, default mb_internal_encoding().
 *
 * @return int $val Returns < 0 if str1 is less than str2; > 0 if str1 is
 * greater than str2, and 0 if they are equal.
 */
function relevanssi_mb_strcasecmp( $str1, $str2, $encoding = '' ): int {
	if ( ! function_exists( 'mb_internal_encoding' ) ) {
		return strnatcasecmp( $str1, $str2 );
	} else {
		if ( empty( $encoding ) ) {
			$encoding = mb_internal_encoding();
		}
		return strnatcmp( mb_strtoupper( $str1, $encoding ), mb_strtoupper( $str2, $encoding ) );
	}
}

/**
 * Multibyte friendly case-insensitive string search.
 *
 * If multibyte string functions are available, do mb_stristr(). Otherwise,
 * do stristr().
 *
 * @see stristr()     Falls back to this if multibyte functions are not
 * available.
 *
 * @param string $haystack The string to search in.
 * @param string $needle   The string to search for.
 * @param string $encoding The encoding to use, default mb_internal_encoding().
 *
 * @return bool True if the needle was found in the haystack, false otherwise.
 */
function relevanssi_mb_stristr( $haystack, $needle, $encoding = '' ): bool {
	if ( ! function_exists( 'mb_internal_encoding' ) ) {
		return stristr( $haystack, $needle );
	} else {
		if ( empty( $encoding ) ) {
			$encoding = mb_internal_encoding();
		}
		return mb_stristr( $haystack, $needle, false, $encoding );
	}
}

/**
 * Trims multibyte strings.
 *
 * Removes the 194+160 non-breakable spaces, removes null bytes and removes
 * whitespace.
 *
 * @param string $str The source string.
 *
 * @return string Trimmed string.
 */
function relevanssi_mb_trim( string $str ) {
	$str = str_replace( chr( 194 ) . chr( 160 ), '', $str );
	$str = str_replace( "\0", '', $str );
	$str = preg_replace( '/(^\s+)|(\s+$)/us', '', $str );
	return $str;
}

/**
 * Returns 'on' if option exists and value is not 'off', otherwise 'off'.
 *
 * @param array  $request An array of option values.
 * @param string $option  The key to check.
 *
 * @return string 'on' or 'off'.
 */
function relevanssi_off_or_on( array $request, string $option ) {
	if ( isset( $request[ $option ] ) && 'off' !== $request[ $option ] ) {
		return 'on';
	}
	return 'off';
}

/**
 * Post password checker.
 * 
 * Determines whether the post requires password and whether a correct password
 * has been provided.
 * 
 * This is the same function as core post_password_required(), except this uses
 * relevanssi_get_post() instead of get_post().
 * 
 * @param int|WP_Post|null $post The post to check.
 *
 * @return bool false if a password is not required or the correct password
 * cookie is present, true otherwise.
 */
function relevanssi_post_password_required( $post ) : bool {
	$post = relevanssi_get_post( $post );

	if ( empty( $post->post_password ) ) {
		/** This filter is documented in wp-includes/post-template.php */
		return apply_filters( 'post_password_required', false, $post );
	}

	if ( ! isset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ) ) {
		/** This filter is documented in wp-includes/post-template.php */
		return apply_filters( 'post_password_required', true, $post );
	}

	require_once ABSPATH . WPINC . '/class-phpass.php';
	$hasher = new PasswordHash( 8, true );

	$hash = wp_unslash( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] );
	if ( ! str_starts_with( $hash, '$P$B' ) ) {
		$required = true;
	} else {
		$required = ! $hasher->CheckPassword( $post->post_password, $hash );
	}

	/**
	 * Filters whether a post requires the user to supply a password.
	 *
	 * @since 4.7.0
	 *
	 * @param bool    $required Whether the user needs to supply a password.
	 * 							True if password has not been provided or is
	 * 							incorrect, false if password has been supplied
	 * 							or is not required.
	 * @param WP_Post $post     Post object.
	 */
	return apply_filters( 'post_password_required', $required, $post );
}

/**
 * Removes quotes (", ”, “) from a string.
 *
 * @param string $str The string to clean.
 *
 * @return string The cleaned string.
 */
function relevanssi_remove_quotes( string $str ) {
	return str_replace( array( '”', '“', '"' ), '', $str );
}

/**
 * Removes quotes from array keys. Does not keep array values.
 *
 * Used to remove phrase quotes from search term array, which have the format
 * of (term => hits). The number of hits is not needed, so this function
 * discards it as a side effect.
 *
 * @uses relevanssi_remove_quotes() This does the actual work.
 *
 * @param array $arr An array to process.
 *
 * @return array The same array with quotes removed from the keys.
 */
function relevanssi_remove_quotes_from_array_keys( array $arr ) {
	$arr = array_keys( $arr );
	array_walk(
		$arr,
		function ( &$key ) {
			$key = relevanssi_remove_quotes( $key );
		}
	);
	return array_flip( $arr );
}

/**
 * Returns an ID=>parent object from a post (or a term, or a user).
 *
 * @param WP_Post|WP_Term|WP_User $post_object The source object.
 *
 * @return object An object with the attributes ID and post_parent set. For
 * terms and users, ID is the term or user ID and post_parent is 0. For bad
 * inputs, returns 0 and 0.
 */
function relevanssi_return_id_parent( $post_object ) {
	$id_parent_object = new stdClass();

	if ( isset( $post_object->ID ) ) {
		$id_parent_object->ID          = $post_object->ID;
		$id_parent_object->post_parent = $post_object->post_parent;
	} elseif ( isset( $post_object->term_id ) ) {
		$id_parent_object->ID          = $post_object->term_id;
		$id_parent_object->post_parent = 0;
	} elseif ( isset( $post_object->user_id ) ) {
		$id_parent_object->ID          = $post_object->user_id;
		$id_parent_object->post_parent = 0;
	} else {
		$id_parent_object->ID          = 0;
		$id_parent_object->post_parent = 0;
	}

	return $id_parent_object;
}

/**
 * Returns an ID=>type object from a post (or a term, or a user).
 *
 * @param WP_Post|WP_Term|WP_User $post_object The source object.
 *
 * @return object An object with the attributes ID and type set. Type is
 * 'post', 'user', 'term' or 'post_type'. For terms, also fills in 'taxonomy',
 * for post types 'name'.
 */
function relevanssi_return_id_type( $post_object ) {
	$id_type_object = new stdClass();

	if ( isset( $post_object->ID ) ) {
		$id_type_object->ID   = $post_object->ID;
		$id_type_object->type = 'post';
	} elseif ( isset( $post_object->term_id ) ) {
		$id_type_object->ID       = $post_object->term_id;
		$id_type_object->type     = 'term';
		$id_type_object->taxonomy = $post_object->taxonomy;
	} elseif ( isset( $post_object->user_id ) ) {
		$id_type_object->ID   = $post_object->user_id;
		$id_type_object->type = 'user';
	} else {
		$id_type_object->ID          = 0;
		$id_type_object->post_parent = 0;
	}

	return $id_type_object;
}

/**
 * Returns "off".
 *
 * Useful for returning "off" to filters easily.
 *
 * @return string A string with value "off".
 */
function relevanssi_return_off() {
	return 'off';
}

/**
 * Returns "OR".
 *
 * @return string A string with value "OR".
 */
function relevanssi_return_or() {
	return 'OR';
}

/**
 * Gets a post object, returns ID, ID=>parent or the post object.
 *
 * @uses relevanssi_return_id_type()   Used to return ID=>type results.
 * @uses relevanssi_return_id_parent() Used to return ID=>parent results.
 *
 * @param object $post         The post object.
 * @param string $return_value The value to return, possible values are 'id'
 * for returning the ID and 'id=>parent' for returning the ID=>parent object,
 * otherwise the post object is returned.
 *
 * @return int|object|WP_Post The post object in the desired format.
 */
function relevanssi_return_value( $post, string $return_value ) {
	if ( 'id' === $return_value ) {
		return $post->ID;
	} elseif ( 'id=>type' === $return_value ) {
		return relevanssi_return_id_type( $post );
	} elseif ( 'id=>parent' === $return_value ) {
		return relevanssi_return_id_parent( $post );
	}
	return $post;
}

/**
 * Sanitizes hex color strings.
 *
 * A copy of sanitize_hex_color(), because that isn't always available.
 *
 * @param string $color A hex color string to sanitize.
 *
 * @return string Sanitized hex string, or an empty string.
 */
function relevanssi_sanitize_hex_color( string $color ) {
	if ( '' === $color ) {
		return '';
	}

	if ( '#' !== substr( $color, 0, 1 ) ) {
		$color = '#' . $color;
	}

	// 3 or 6 hex digits, or the empty string.
	if ( preg_match( '|^#([A-Fa-f0-9]{3}){1,2}$|', $color ) ) {
		return $color;
	}

	return '';
}

/**
 * Returns 'selected' if the option matches a value.
 *
 * @param string $option Value to check.
 * @param string $value  The 'selected' value.
 *
 * @return string If the option matches the value, returns 'selected', otherwise
 * returns an empty string.
 */
function relevanssi_select( string $option, string $value ) {
	$selected = '';
	if ( $option === $value ) {
		$selected = 'selected';
	}
	return $selected;
}

/**
 * Strips all tags from content, keeping non-tags that look like tags.
 *
 * Strips content that matches <[!a-zA-Z\/]*> to remove HTML tags and HTML
 * comments, but not things like "<30 grams, 4>1".
 *
 * @param string $content The content.
 *
 * @return string The content with tags stripped.
 */
function relevanssi_strip_all_tags( $content ): string {
	if ( ! is_string( $content ) ) {
		$content = '';
	}
	$content = preg_replace( '/<!--.*?-->/ums', '', $content );
	$content = preg_replace( '/<[!a-zA-Z\/][^>].*?>/ums', ' ', $content );

	return $content ?? '';
}

/**
 * Strips invisible elements from text.
 *
 * Strips <style>, <script>, <object>, <embed>, <applet>, <noscript>, <noembed>,
 * <iframe>, <del> and <svg> tags and their contents and comments from the text.
 *
 * @param string $text The source text.
 *
 * @return string The processed text.
 */
function relevanssi_strip_invisibles( $text ) {
	if ( ! is_string( $text ) ) {
		$text = strval( $text );
	}
	$text = preg_replace(
		array(
			'@<style[^>]*?>.*?</style>@siu',
			'@<script[^>]*?.*?</script>@siu',
			'@<object[^>]*?.*?</object>@siu',
			'@<embed[^>]*?.*?</embed>@siu',
			'@<applet[^>]*?.*?</applet>@siu',
			'@<noscript[^>]*?.*?</noscript>@siu',
			'@<noembed[^>]*?.*?</noembed>@siu',
			'@<iframe[^>]*?.*?</iframe>@siu',
			'@<del[^>]*?.*?</del>@siu',
			'@<svg[^>]*?.*?</svg>@siu',
			'@<!--.*?-->@siu',
		),
		' ',
		$text
	);
	return $text;
}

/**
 * Strips tags from contents, keeping the allowed tags.
 *
 * The allowable tags are read from the relevanssi_excerpt_allowable_tags
 * option. Relevanssi also adds extra spaces after some tags to make sure words
 * are not stuck together after the tags are removed. The function also removes
 * invisible content.
 *
 * @uses relevanssi_strip_invisibles() Used to remove scripts and other tags.
 * @see  strip_tags()                  Used to remove tags.
 *
 * @param string|null $content The content.
 *
 * @return string The content without tags.
 */
function relevanssi_strip_tags( $content ) {
	if ( ! is_string( $content ) ) {
		try {
			$content = strval( $content );
		} catch ( Exception $e ) {
			// Likely an object without a toString method.
			return $content;
		}
	}
	$content = relevanssi_strip_invisibles( $content );

	$space_tags = array(
		'/(<\/?p.*?>)/',
		'/(<\/?br.*?>)/',
		'/(<\/?h[1-6].*?>)/',
		'/(<\/?div.*?>)/',
		'/(<\/?blockquote.*?>)/',
		'/(<\/?hr.*?>)/',
		'/(<\/?li.*?>)/',
		'/(<img.*?>)/',
		'/(<\/td>)/',
	);

	$content = preg_replace( $space_tags, '$1 ', $content );
	return strip_tags(
		$content,
		get_option( 'relevanssi_excerpt_allowable_tags', '' )
	);
}

/**
 * Returns the position of substring in the string.
 *
 * Uses mb_stripos() if possible, falls back to mb_strpos() and mb_strtoupper()
 * if that cannot be found, and falls back to just strpos() if even that is not
 * possible.
 *
 * @param string $haystack String where to look.
 * @param string $needle   The string to look for.
 * @param int    $offset   Where to start, default 0.
 *
 * @return mixed False, if no result or $offset outside the length of $haystack,
 * otherwise the position (which can be non-false 0!).
 */
function relevanssi_stripos( $haystack, $needle, int $offset = 0 ) {
	if ( ! is_string( $haystack ) ) {
		$haystack = strval( $haystack );
	}
	if ( ! is_string( $needle ) ) {
		$needle = strval( $needle );
	}
	if ( $offset > relevanssi_strlen( $haystack ) ) {
		return false;
	}

	if ( preg_match( '/[\?\*]/', $needle ) ) {
		// There's a ? or an * in the string, which means it's a wildcard search
		// query (a Premium feature) and requires some extra steps.
		$needle_regex = str_replace(
			array( '?', '*' ),
			array( '.', '.*' ),
			preg_quote( $needle, '/' )
		);
		$pos_found    = false;
		while ( ! $pos_found ) {
			preg_match(
				"/$needle_regex/i",
				$haystack,
				$matches,
				PREG_OFFSET_CAPTURE,
				$offset
			);
			/**
			 * This trickery is necessary, because PREG_OFFSET_CAPTURE gives
			 * wrong offsets for multibyte strings. The mb_strlen() gives the
			 * correct offset, the rest of this is because the $offset received
			 * as a parameter can be before the first $position, leading to an
			 * infinite loop.
			 */
			$pos = isset( $matches[0][1] )
				? mb_strlen( substr( $haystack, 0, $matches[0][1] ) )
				: false;
			if ( $pos && $pos > $offset ) {
				$pos_found = true;
			} elseif ( $pos ) {
				++$offset;
			} else {
				$pos_found = true;
			}
		}
	} elseif ( function_exists( 'mb_stripos' ) ) {
		if ( '' === $haystack ) {
			$pos = false;
		} else {
			$pos = mb_stripos( $haystack, $needle, $offset );
		}
	} elseif ( function_exists( 'mb_strpos' ) && function_exists( 'mb_strtoupper' ) && function_exists( 'mb_substr' ) ) {
		$pos = mb_strpos(
			mb_strtoupper( $haystack ),
			mb_strtoupper( $needle ),
			$offset
		);
	} else {
		$pos = strpos( strtoupper( $haystack ), strtoupper( $needle ), $offset );
	}
	return $pos;
}

/**
 * Returns the length of the string.
 *
 * Uses mb_strlen() if available, otherwise falls back to strlen().
 *
 * @param string $s The string to measure.
 *
 * @return int The length of the string.
 */
function relevanssi_strlen( $s ) {
	if ( ! is_string( $s ) ) {
		$s = strval( $s );
	}
	if ( function_exists( 'mb_strlen' ) ) {
		return mb_strlen( $s );
	}
	return strlen( $s );
}

/**
 * Multibyte friendly strtolower.
 *
 * If multibyte string functions are available, returns mb_strtolower() and
 * falls back to strtolower() if multibyte functions are not available.
 *
 * @param string $str The string to lowercase.
 *
 * @return string $str The string in lowercase.
 */
function relevanssi_strtolower( $str ) {
	if ( ! is_string( $str ) ) {
		$str = strval( $str );
	}
	if ( ! function_exists( 'mb_strtolower' ) ) {
		return strtolower( $str );
	} else {
		return mb_strtolower( $str );
	}
}

/**
 * Multibyte friendly substr.
 *
 * If multibyte string functions are available, returns mb_substr() and falls
 * back to substr() if multibyte functions are not available.
 *
 * @param string   $str The source string.
 * @param int      $start  If start is non-negative, the returned string will
 * start at the start'th position in str, counting from zero. If start is
 * negative, the returned string will start at the start'th character from the
 * end of string.
 * @param int|null $length Maximum number of characters to use from string. If
 * omitted or null is passed, extract all characters to the end of the string.
 *
 * @return string $str The string in lowercase.
 */
function relevanssi_substr( $str, int $start, $length = null ) {
	if ( ! is_string( $str ) ) {
		$str = strval( $str );
	}
	if ( ! function_exists( 'mb_substr' ) ) {
		return substr( $str, $start, $length );
	} else {
		return mb_substr( $str, $start, $length );
	}
}

/**
 * Prints out the post excerpt.
 *
 * Prints out the post excerpt from $post->post_excerpt, unless the post is
 * protected. Only works in the Loop.
 *
 * @see post_password_required() Used to check for password requirements.
 *
 * @global $post The global post object.
 */
function relevanssi_the_excerpt() {
	global $post;
	if ( ! post_password_required( $post ) ) {
		echo '<p>' . $post->post_excerpt . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	} else {
		esc_html_e( 'There is no excerpt because this is a protected post.', 'relevanssi' );
	}
}

/**
 * Echoes out the permalink to the current post within Loop.
 *
 * Uses get_permalink() to get the permalink, then adds the 'highlight'
 * parameter if necessary using relevanssi_add_highlight(), then echoes it out.
 *
 * @param int|WP_Post $post Post ID or post object. Default is the global $post.
 *
 * @uses relevanssi_get_permalink() Fetches the current post permalink.
 */
function relevanssi_the_permalink( $post = 0 ) {
	echo esc_url( relevanssi_get_permalink( $post ) );
}

/**
 * Prints out a list of tags for post.
 *
 * Replacement for the_tags() that does the same, but applies Relevanssi search term
 * highlighting on the results.
 *
 * @param string  $before    What is printed before the tags, default ''.
 * @param string  $separator The separator between items, default ', '.
 * @param string  $after     What is printed after the tags, default ''.
 * @param boolean $echoed    If true, echo, otherwise return the result. Default true.
 * @param int     $post_id   The post ID. Default current post ID (in the Loop).
 */
function relevanssi_the_tags( string $before = '', string $separator = ', ', string $after = '', bool $echoed = true, int $post_id = 0 ) {
	$tag_list = get_the_tag_list( $before, $separator, $after, $post_id );
	$found    = preg_match_all( '~<a href=".*?" rel="tag">(.*?)</a>~', $tag_list, $matches );
	if ( $found ) {
		$originals   = $matches[0];
		$tag_names   = $matches[1];
		$highlighted = array();

		$count = count( $matches[0] );
		for ( $i = 0; $i < $count; $i++ ) {
			$highlighted_tag_name = relevanssi_highlight_terms( $tag_names[ $i ], get_search_query(), true );
			$highlighted[ $i ]    = str_replace( '>' . $tag_names[ $i ] . '<', '>' . $highlighted_tag_name . '<', $originals[ $i ] );
		}

		$tag_list = str_replace( $originals, $highlighted, $tag_list );
	}

	if ( $echoed ) {
		echo $tag_list; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	} else {
		return $tag_list;
	}
}

/**
 * Prints out post title with highlighting.
 *
 * Uses the global $post object. Reads the highlighted title from
 * $post->post_highlighted_title. This used to accept one parameter, the
 * `$echo` boolean, but in 2.12.3 / 4.10.3 the function signature was matched
 * to copy `the_title()` function in WordPress core. The original behaviour is
 * still supported: `relevanssi_the_title()` without arguments works exactly as
 * before and `relevanssi_the_title( false )` returns the title.
 *
 * @global object $post The global post object.
 *
 * @param boolean|string $before Markup to prepend to the title. Can also be a
 * boolean for whether to echo or return the title.
 * @param string         $after  Markup to append to the title.
 * @param boolean        $echoed   Whether to echo or return the title. Default
 * true for echo.
 *
 * @return void|string Void if $echoed argument is true, current post title with
 * highlights if $echoed is false.
 */
function relevanssi_the_title( $before = true, string $after = '', bool $echoed = true ) {
	if ( true === $before ) {
		$before = '';
		$echoed = true;
	} elseif ( false === $before ) {
		$before = '';
		$echoed = false;
	}
	global $post;
	if ( empty( $post->post_highlighted_title ) ) {
		$post->post_highlighted_title = $post->post_title;
	}
	if ( relevanssi_strlen( $post->post_highlighted_title ) === 0 ) {
		return;
	}
	$title = $before . $post->post_highlighted_title . $after;
	if ( $echoed ) {
		echo $title; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	} else {
		return $title;
	}
}

/**
 * Turns off options, ie. sets them to "off".
 *
 * If the specified options don't exist in the request array, they are set to
 * "off".
 *
 * @param array $request The _REQUEST array, passed as reference.
 * @param array $options An array of option names.
 */
function relevanssi_turn_off_options( array &$request, array $options ) {
	array_walk(
		$options,
		function ( $option ) use ( &$request ) {
			if ( ! isset( $request[ $option ] ) ) {
				$request[ $option ] = 'off';
			}
		}
	);
}

/**
 * Sets an option after doing floatval.
 *
 * @param array   $request  An array of option values.
 * @param string  $option   The key to check.
 * @param boolean $autoload Should the option autoload, default true.
 * @param float   $def_val  The default value if floatval() fails, default 0.
 * @param boolean $positive If true, replace negative values and zeroes with
 * $def_val.
 */
function relevanssi_update_floatval( array $request, string $option, bool $autoload = true, float $def_val = 0, bool $positive = false ) {
	if ( isset( $request[ $option ] ) ) {
		$value = floatval( $request[ $option ] );
		if ( ! $value ) {
			$value = $def_val;
		}
		if ( $positive && $value <= 0 ) {
			$value = $def_val;
		}
		update_option( $option, $value, $autoload );
	}
}

/**
 * Sets an option after doing intval.
 *
 * @param array   $request  An array of option values.
 * @param string  $option   The key to check.
 * @param boolean $autoload Should the option autoload, default true.
 * @param int     $def_val  The default value if intval() fails, default 0.
 */
function relevanssi_update_intval( array $request, string $option, bool $autoload = true, int $def_val = 0 ) {
	if ( isset( $request[ $option ] ) ) {
		$value = intval( $request[ $option ] );
		if ( ! $value ) {
			$value = $def_val;
		}
		update_option( $option, $value, $autoload );
	}
}

/**
 * Sets an option with one of the listed legal values.
 *
 * @param array   $request  An array of option values.
 * @param string  $option   The key to check.
 * @param array   $values   The legal values.
 * @param string  $def_val  The default value.
 * @param boolean $autoload Should the option autoload, default true.
 */
function relevanssi_update_legal_value( array $request, string $option, array $values, string $def_val, bool $autoload = true ) {
	if ( isset( $request[ $option ] ) ) {
		$value = $def_val;
		if ( in_array( $request[ $option ], $values, true ) ) {
			$value = $request[ $option ];
		}
		update_option( $option, $value, $autoload );
	}
}

/**
 * Sets an on/off option according to the request value.
 *
 * @param array   $request  An array of option values.
 * @param string  $option   The key to check.
 * @param boolean $autoload Should the option autoload, default true.
 */
function relevanssi_update_off_or_on( array $request, string $option, bool $autoload = true ) {
	relevanssi_update_legal_value(
		$request,
		$option,
		array( 'off', 'on' ),
		'off',
		$autoload
	);
}

/**
 * Sets an option after sanitizing and unslashing the value.
 *
 * @param array   $request  An array of option values.
 * @param string  $option   The key to check.
 * @param boolean $autoload Should the option autoload, default true.
 */
function relevanssi_update_sanitized( array $request, string $option, bool $autoload = true ) {
	if ( isset( $request[ $option ] ) ) {
		$value = sanitize_text_field( wp_unslash( $request[ $option ] ) );
		update_option( $option, $value, $autoload );
	}
}

/**
 * Returns true if $_SERVER['HTTP_USER_AGENT'] is on the bot block list.
 *
 * Looks for bot user agents in the $_SERVER['HTTP_USER_AGENT'] and returns true
 * if a match is found.
 *
 * @return bool True if $_SERVER['HTTP_USER_AGENT'] is a bot.
 */
function relevanssi_user_agent_is_bot(): bool {
	if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
		/**
		 * Filters the bots Relevanssi should block from search queries.
		 *
		 * Lets you filter the bots that are blocked from Relevanssi search
		 * queries.
		 *
		 * @param array $bots An array of bot user agents.
		 */
		$bots = apply_filters( 'relevanssi_bots_to_block', relevanssi_bot_block_list() );
		foreach ( array_values( $bots ) as $lookfor ) {
			if ( false !== stristr( $_SERVER['HTTP_USER_AGENT'], $lookfor ) ) {
				return true;
			}
		}
	}
	return false;
}

/**
 * Validates that the parameter is a valid taxonomy type.
 *
 * @parameter string $taxonomy The taxonomy to validate.
 *
 * @return string The validated taxonomy, empty string if invalid.
 */
function relevanssi_validate_taxonomy( $taxonomy ) {
	$taxonomy = sanitize_text_field( $taxonomy );
	if ( taxonomy_exists( $taxonomy ) ) {
		return $taxonomy;
	}
	return '';
}
