<?php

/**
 * A WP_Query condition.
 *
 * It allows for filtering the results of the association query by a WP_Query being applied on
 * elements (posts) of a selected association role.
 *
 * WARNINGS and limitations:
 *
 * - The process to generate the query abuses WP_Query and is rather expensive in terms of performance.
 * - This is untested and highly experimental.
 *   The WP_Query hack is so ugly that it's beautiful, if you ask me. But let's see if we actually
 *   put this to an use.
 * - If used on non-post elements, the results are unpredictable. Never assume you're dealing only
 *   with post relationships.
 * - Only subsets of WP_Query arguments are supported. Basically, anything that requires joining other
 *   tables than wp_posts should be considered unreliable (in need of extra testing) and if you use
 *   this query condition multiple times inside one association query, overreaching wp_posts
 *   will most definitely cause a collision of table aliases.
 * - If you intend to use this only for searching elements by a string, please don't.
 *   Use $query->search() instead, which is much lighter and will become domain-agnostic
 *   when another domains become supported.
 * - This usage of WP_Query doesn't support sticky posts, filter suppressing and WPML language queries.
 *
 * @since 2.5.8
 */
class Toolset_Association_Query_Condition_Wp_Query extends Toolset_Association_Query_Condition {


	/** @var IToolset_Relationship_Role */
	private $for_role;


	/** @var array */
	private $query_args;


	/** @var Toolset_Association_Query_Table_Join_Manager */
	private $join_manager;


	/** @var Toolset_Relationship_Query_Factory */
	private $query_factory;


	/** @var array|null Clauses generated by the WP_Query class. */
	private $wp_query_clauses;


	/** @var wpdb */
	private $wpdb;


	/** @var Toolset_Relationship_Database_Unique_Table_Alias */
	private $unique_table_alias;


	/**
	 * Toolset_Association_Query_Condition_Wp_Query constructor.
	 *
	 * @param IToolset_Relationship_Role $for_role
	 * @param array $query_args
	 * @param Toolset_Association_Query_Table_Join_Manager $join_manager
	 * @param Toolset_Relationship_Database_Unique_Table_Alias $unique_table_alias
	 * @param Toolset_Relationship_Query_Factory|null $query_factory_di
	 * @param wpdb|null $wpdb_di
	 * @throws InvalidArgumentException
	 */
	public function __construct(
		IToolset_Relationship_Role $for_role, $query_args,
		Toolset_Association_Query_Table_Join_Manager $join_manager,
		Toolset_Relationship_Database_Unique_Table_Alias $unique_table_alias,
		Toolset_Relationship_Query_Factory $query_factory_di = null,
		wpdb $wpdb_di = null
	) {
		if( ! is_array( $query_args ) ) {
			throw new InvalidArgumentException();
		}
		$this->for_role = $for_role;
		$this->query_args = $query_args;
		$this->join_manager = $join_manager;
		$this->query_factory = ( null === $query_factory_di ? new Toolset_Relationship_Query_Factory() : $query_factory_di );
		$this->unique_table_alias = $unique_table_alias;
		if( null === $wpdb_di ) {
			global $wpdb;
			$this->wpdb = $wpdb;
		} else {
			$this->wpdb = $wpdb_di;
		}
	}


	/**
	 * Get a part of the WHERE clause that applies the condition.
	 *
	 * @return string Valid part of a MySQL query, so that it can be
	 *     used in WHERE ( $condition1 ) AND ( $condition2 ) AND ( $condition3 ) ...
	 */
	public function get_where_clause() {
		$query_clauses = $this->get_wp_query_clauses();

		// The clause starts with AND. I'm not sure about the whitespaces, so this is easier than
		// parsing and removing the keyword.
		return ' 1 = 1 ' . $query_clauses['where'];
	}


	/**
	 * @inheritdoc
	 *
	 * @return string
	 */
	public function get_join_clause() {
		$query_clauses = $this->get_wp_query_clauses();
		$join_clauses = $query_clauses['join'];

		return $join_clauses;
	}


	/**
	 * Retrieve the generated clauses from WP_Query.
	 *
	 * @return string[]
	 */
	private function get_wp_query_clauses() {
		if( null === $this->wp_query_clauses ) {
			$this->wp_query_clauses = $this->build_wp_query_clauses( $this->query_args );
		}

		return $this->wp_query_clauses;
	}


	/**
	 * Fool WP_Query into generating MySQL query clauses for given query arguments without actually executing the query.
	 *
	 * It also prevents WPML from modifying the query because filtering by language is handled elsewhere.
	 * It doesn't support sticky posts, filter suppressing and probably some complex use-cases.
	 *
	 * @param array $query_args Arguments for WP_Query.
	 * @return string[] MySQL clauses, for details see the posts_clauses filter.
	 * @since 2.5.8
	 */
	private function build_wp_query_clauses( $query_args ) {

		// Sticky posts are handled in a special way after the query takes place, so they would have no
		// effect in any case. This is a performance optimalization.
		$query_args['ignore_sticky_posts'] = true;

		// Without this, we won't be able to get the clauses because the posts_clauses filter would not be applied.
		$query_args['suppress_filters'] = false;

		if( ! isset( $query_args['post_type'] ) ) {
			// Get all associated post_types if none is defined
			$query_args['post_type'] = 'any';
		}

		// This will hold the mysql clauses after WP_Query pushes them through the posts_pre_query filter.
		$clauses_out = array();

		$catch_clauses = function( $clauses_in ) use( &$clauses_out ) {
			$clauses_out = $clauses_in;
		};

		// Filter priority
		$very_late = 10000;

		add_filter( 'posts_clauses', $catch_clauses, $very_late );

		// Returning a non-null value on the posts_pre_query filter (since WP 4.6) causes that no actual
		// mysql query takes place in WP_Query::get_posts().
		$dont_query_anyting = function() { return array(); };
		add_filter( 'posts_pre_query', $dont_query_anyting, $very_late );

		// Avoid WPML messing with the results because we already know in which language we want to query
		$current_language = apply_filters( 'wpml_current_language', '' );
		do_action( 'wpml_switch_language', 'all' );

		// Did you think it can't get any worse? Well... Now we have to override $wpdb
		// because we need it to use a different name of the wp_posts table. The wrapper object will
		// forward anything to the original $wpdb instance except the attempt to read the $posts
		// property. In that case, it will use the join manager to get a proper table alias.
		//
		// This hack allows us to use this query condition (at least in a limited way) multiple times
		// within a single query.
		global $wpdb;
		$original_wpdb_object = $wpdb;
		$wpdb_wrapper = new Toolset_Association_Query_Wpdb_Wrapper(
			$wpdb, $this->join_manager, $this->for_role
		);
		$wpdb = $wpdb_wrapper;

		// This will immediately run the query.
		$this->query_factory->wp_query( $query_args );

		// Like nothing has ever happened.
		$wpdb = $original_wpdb_object;

		// Switch back to the current language so that we don't break anything else down the road.
		do_action( 'wpml_switch_language', $current_language );

		// Clean up
		remove_filter( 'posts_clauses', $catch_clauses, $very_late );
		remove_filter( 'posts_pre_query', $dont_query_anyting, $very_late );

		return $this->adjust_table_aliases( $clauses_out );
	}


	/**
	 * Try to avoid table alias collisions if the WP_Query joins further tables.
	 *
	 * @param string[] $query_clauses Array with the query clauses coming from the WP_Query.
	 * @return string[] Updated query clauses array.
	 */
	private function adjust_table_aliases( $query_clauses ) {

		$join_clauses = $query_clauses['join'];
		$where_clauses = $query_clauses['where'];

		$matches = array();

		// Match all JOINs - table name in the second capturing group and optionally also
		// alias name in the fifth capturing group (it may not be present).
		preg_match_all(
			'/JOIN\s+(\w+)\s+(as\s+|AS\s+)?((\w+)\s+)?ON/',
			$join_clauses, $matches, PREG_SET_ORDER
		);

		foreach( $matches as $match ) {
			// Failsafe if something goes wrong.
			if( ! is_array( $match ) || count( $match ) < 2 ) {
				continue;
			}

			$table_name = $match[1];
			$replace_table_name_in_where = true;

			// wp_posts table needs special handling because its name has already been overridden
			// (it's always used, while additional JOINs will not be so frequent).
			if ( $table_name === $this->join_manager->wp_posts( $this->for_role ) ) {
				$join_clauses = str_replace( $table_name, $this->wpdb->posts, $join_clauses );
				$replace_table_name_in_where = false;
				$table_name = $this->wpdb->posts;
			}

			// This is unique across the whole association query.
			$unique_alias_name = $this->unique_table_alias->generate( $table_name, true );

			$has_alias = ( count( $match ) === 5 );

			if( $has_alias ) {
				// We already have an alias, so we keep the original table name
				// in the "JOIN $table_name AS..." as it is, and just replace the alias by
				// an unique one.
				$alias_name = $match[4];
				$join_clauses = preg_replace( '/'.$table_name.'\s+'.$alias_name.'/', $table_name . ' ' . $unique_alias_name, $join_clauses );
				$join_clauses = preg_replace( '/([^A-z\_])'.$alias_name.'\./', "$1$unique_alias_name.", $join_clauses );
				$where_clauses = preg_replace( '/'.$table_name.'\s+'.$alias_name.'/', $table_name . ' ' . $unique_alias_name, $where_clauses );
				$where_clauses = preg_replace( '/([^A-z\_])'.$alias_name.'\./', "$1$unique_alias_name.", $where_clauses );
			} else {
				// There is no alias the table name is used directly - we need to add it.
				// First, replace the table name with the alias everywhere.
				$join_clauses = str_replace( $table_name, $unique_alias_name, $join_clauses );
				// And now, replace back the "JOIN $unique_alias_name" with the actual table name
				// and add the "AS $unique_alias_name" string.
				$join_clauses = preg_replace(
					"/JOIN\s+$unique_alias_name\s+ON/",
					"JOIN $table_name AS $unique_alias_name ON",
					$join_clauses
				);

				// The table/alias usage in the WHERE clause needs to be adjusted as well.
				// (unless we're dealing with a wp_posts alias)
				if( $replace_table_name_in_where ) {
					$where_clauses = str_replace( $table_name, $unique_alias_name, $where_clauses );
				}
			}
		}

		$query_clauses['join'] = $join_clauses;
		$query_clauses['where'] = $where_clauses;
		return $query_clauses;
	}

}