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

/**
 * Extracts phrases from the search query.
 *
 * Finds all phrases wrapped in quotes (curly or straight) from the search
 * query.
 *
 * @param string $query The search query.
 *
 * @return array An array of phrases (strings).
 */
function relevanssi_extract_phrases( string $query ) {
	// iOS uses “” or „“ as the default quotes, so Relevanssi needs to
	// understand those as well.
	$normalized_query = str_replace( array( '”', '“', '„' ), '"', $query );
	$pos              = relevanssi_stripos( $normalized_query, '"' );

	$phrases = array();
	while ( false !== $pos ) {
		if ( $pos + 2 > relevanssi_strlen( $normalized_query ) ) {
			$pos = false;
			continue;
		}
		$start = relevanssi_stripos( $normalized_query, '"', $pos );
		$end   = false;
		if ( false !== $start ) {
			$end = relevanssi_stripos( $normalized_query, '"', $start + 2 );
		}
		if ( false === $end ) {
			// Just one " in the query.
			$pos = $end;
			continue;
		}
		$phrase = relevanssi_substr(
			$normalized_query,
			$start + 1,
			$end - $start - 1
		);
		$phrase = trim( $phrase );

		// Do not count single-word phrases as phrases.
		if ( relevanssi_is_multiple_words( $phrase ) ) {
			$phrases[] = $phrase;
		}
		$pos = $end + 1;
	}

	return $phrases;
}

/**
 * Generates the MySQL code for restricting the search to phrase hits.
 *
 * This function uses relevanssi_extract_phrases() to figure out the phrases in
 * the search query, then generates MySQL queries to restrict the search to the
 * posts containing those phrases in the title, content, taxonomy terms or meta
 * fields.
 *
 * @global array $relevanssi_variables The global Relevanssi variables.
 *
 * @param string $search_query The search query.
 * @param string $operator     The search operator (AND or OR).
 *
 * @return string $queries If not phrase hits are found, an empty string;
 * otherwise MySQL queries to restrict the search.
 */
function relevanssi_recognize_phrases( $search_query, $operator = 'AND' ) {
	global $relevanssi_variables;

	$phrases = relevanssi_extract_phrases( $search_query );

	$all_queries = array();
	if ( 0 === count( $phrases ) ) {
		return $all_queries;
	}

	/**
	 * Filters the custom fields for phrase matching.
	 *
	 * If you don't want the phrase matching to target custom fields, you can
	 * have this filter hook return an empty array.
	 *
	 * @param array $custom_fields An array of custom field names.
	 */
	$custom_fields = apply_filters( 'relevanssi_phrase_custom_fields', relevanssi_get_custom_fields() );

	/**
	 * Filters the taxonomies for phrase matching.
	 *
	 * If you don't want the phrase matching to target taxonomies, you can have
	 * this filter hook return an empty array.
	 *
	 * @param array $taxonomies An array of taxonomy names.
	 */
	$taxonomies     = apply_filters( 'relevanssi_phrase_taxonomies', get_option( 'relevanssi_index_taxonomies_list', array() ) );
	$excerpts       = get_option( 'relevanssi_index_excerpt', 'off' );
	$phrase_queries = array();
	$queries        = array();

	if (
		isset( $relevanssi_variables['phrase_targets'] ) &&
		is_array( $relevanssi_variables['phrase_targets'] )
		) {
		$non_targeted_phrases = array();
		foreach ( $phrases as $phrase ) {
			if (
				isset( $relevanssi_variables['phrase_targets'][ $phrase ] ) &&
				function_exists( 'relevanssi_targeted_phrases' )
			) {
				$queries = relevanssi_targeted_phrases( $phrase );
			} else {
				$non_targeted_phrases[] = $phrase;
			}
		}
		$phrases = $non_targeted_phrases;
	}

	$queries = array_merge(
		$queries,
		relevanssi_generate_phrase_queries(
			$phrases,
			$taxonomies,
			$custom_fields,
			$excerpts
		)
	);

	$phrase_queries = array();

	foreach ( $queries as $phrase => $p_queries ) {
		$pq_array = array();
		foreach ( $p_queries as $query ) {
			$pq_array[] = "relevanssi.{$query['target']} IN {$query['query']}";
		}
		$p_queries     = implode( ' OR ', $pq_array );
		$all_queries[] = "($p_queries)";

		$phrase_queries[ $phrase ] = $p_queries;
	}

	$operator = strtoupper( $operator );
	if ( 'AND' !== $operator && 'OR' !== $operator ) {
		$operator = 'AND';
	}

	if ( ! empty( $all_queries ) ) {
		$all_queries = ' AND ( ' . implode( ' ' . $operator . ' ', $all_queries ) . ' ) ';
	}

	return array(
		'and' => $all_queries,
		'or'  => $phrase_queries,
	);
}

/**
 * Generates the phrase queries from phrases.
 *
 * Takes in phrases and a bunch of parameters and generates the MySQL queries
 * that restrict the main search query to only posts that have the phrase.
 *
 * @param array        $phrases          A list of phrases to handle.
 * @param array        $taxonomies       An array of taxonomy names to use.
 * @param array|string $custom_fields    A list of custom field names to use,
 * "visible", or "all".
 * @param string       $excerpts         If 'on', include excerpts.
 *
 * @global object $wpdb The WordPress database interface.
 *
 * @return array An array of queries sorted by phrase.
 */
function relevanssi_generate_phrase_queries(
	array $phrases,
	array $taxonomies,
	$custom_fields,
	string $excerpts
): array {
	global $wpdb;

	$status = relevanssi_valid_status_array();

	// Add "inherit" to the list of allowed statuses to include attachments.
	if ( ! strstr( $status, 'inherit' ) ) {
		$status .= ",'inherit'";
	}

	$phrase_queries = array();

	foreach ( $phrases as $phrase ) {
		$queries      = array();
		$phrase       = $wpdb->esc_like( $phrase );
		$phrase       = str_replace( array( '‘', '’', "'", '"', '”', '“', '“', '„', '´' ), '_', $phrase );
		$title_phrase = $phrase;
		$phrase       = htmlspecialchars( $phrase );

		/**
		 * Filters each phrase before it's passed through esc_sql() and used in
		 * the MySQL query. You can use this filter hook to for example run
		 * htmlentities() on the phrase in case your database needs that.
		 *
		 * @param string $phrase The phrase after quotes are replaced with a
		 * MySQL wild card and the phrase has been passed through esc_like() and
		 * htmlspecialchars().
		 */
		$phrase = esc_sql( apply_filters( 'relevanssi_phrase', $phrase ) );

		$excerpt = '';
		if ( 'on' === $excerpts ) {
			$excerpt = "OR post_excerpt LIKE '%$phrase%'";
		}

		$query = "(SELECT ID FROM $wpdb->posts
			WHERE (post_content LIKE '%$phrase%'
			OR post_title LIKE '%$title_phrase%' $excerpt)
			AND post_status IN ($status))";

		$queries[] = array(
			'query'  => $query,
			'target' => 'doc',
		);

		if ( ! empty( $taxonomies ) ) {
			$taxonomies_escaped = implode( "','", array_map( 'esc_sql', $taxonomies ) );
			$taxonomies_sql     = "AND s.taxonomy IN ('$taxonomies_escaped')";

			$query = "(SELECT ID FROM
				$wpdb->posts as p,
				$wpdb->term_relationships as r,
				$wpdb->term_taxonomy as s, $wpdb->terms as t
				WHERE r.term_taxonomy_id = s.term_taxonomy_id
				AND s.term_id = t.term_id AND p.ID = r.object_id
				$taxonomies_sql
				AND t.name LIKE '%$phrase%' AND p.post_status IN ($status))";

			$queries[] = array(
				'query'  => $query,
				'target' => 'doc',
			);
		}

		if ( ! empty( $custom_fields ) ) {
			$keys = '';

			if ( is_array( $custom_fields ) ) {
				if ( ! in_array( '_relevanssi_pdf_content', $custom_fields, true ) ) {
					array_push( $custom_fields, '_relevanssi_pdf_content' );
				}

				if ( strpos( implode( ' ', $custom_fields ), '%' ) ) {
					// ACF repeater fields involved.
					$custom_fields_regexp = str_replace( '%', '.+', implode( '|', $custom_fields ) );
					$keys                 = "AND m.meta_key REGEXP ('$custom_fields_regexp')";
				} else {
					$custom_fields_escaped = implode(
						"','",
						array_map(
							'esc_sql',
							$custom_fields
						)
					);
					$keys                  = "AND m.meta_key IN ('$custom_fields_escaped')";
				}
			}

			if ( 'visible' === $custom_fields ) {
				$keys = "AND (m.meta_key NOT LIKE '\_%' OR m.meta_key = '_relevanssi_pdf_content')";
			}

			$query = "(SELECT ID
				FROM $wpdb->posts AS p, $wpdb->postmeta AS m
				WHERE p.ID = m.post_id
				$keys
				AND m.meta_value LIKE '%$phrase%'
				AND p.post_status IN ($status))";

			$queries[] = array(
				'query'  => $query,
				'target' => 'doc',
			);
		}

		/**
		 * Filters the phrase queries.
		 *
		 * Relevanssi Premium uses this filter hook to add Premium-specific
		 * phrase queries.
		 *
		 * @param array  $queries The MySQL queries for phrase matching.
		 * @param string $phrase  The current phrase.
		 * @param string $status  A string containing post statuses.
		 *
		 * @return array An array of phrase queries, where each query is an
		 * array that has the actual MySQL query in 'query' and the target
		 * column ('doc' or 'item') in the Relevanssi index table in 'target'.
		 */
		$queries = apply_filters( 'relevanssi_phrase_queries', $queries, $phrase, $status );

		$phrase_queries[ $phrase ] = $queries;
	}

	return $phrase_queries;
}
