<?php

use TEC\Common\Translations_Loader;
use Tribe\Traits\Cache_User;
use Tribe__Cache_Listener as Listener;
use Tribe__Utils__Array as Arr;

/**
 * Class Tribe__Rewrite
 *
 * Utilities to generate and manipulate rewrite rules.
 *
 * @since 4.3
 */
class Tribe__Rewrite {
	use Cache_User;

	/**
	 * If we wish to setup a rewrite rule that uses percent symbols, we'll need
	 * to make use of this placeholder.
	 */
	const PERCENT_PLACEHOLDER = '~~TRIBE~PC~~';

	/**
	 * Static singleton variable.
	 *
	 * @var static
	 */
	public static $instance;
	/**
	 * A delimiter used to separate  a localized matcher from its base in the format `<loc_matcher><delim><base>`.
	 *
	 * @since 4.11.5
	 *
	 * @var string
	 */
	public static $localized_matcher_delimiter = '~';

	/**
	 * WP_Rewrite Instance
	 *
	 * @var WP_Rewrite
	 */
	public $rewrite;

	/**
	 * Rewrite rules Holder
	 *
	 * @var array
	 */
	public $rules = [];

	/**
	 * Base slugs for rewrite urls
	 *
	 * @var array
	 */
	public $bases = [];

	/**
	 * After creating the Hooks on WordPress we lock the usage of the function.
	 *
	 * @var boolean
	 */
	protected $hook_lock = false;

	/**
	 * An array cache of resolved canonical URLs in the shape `[ <url> => <canonical_url> ]`.
	 *
	 * @since 4.9.11
	 *
	 * @var array
	 */
	protected $canonical_url_cache = null;

	/**
	 * An array cache of parsed URLs in the shape `[ <url> => <parsed_vars> ]`.
	 *
	 * @since 4.9.11
	 *
	 * @var array
	 */
	protected $parse_request_cache = null;

	/**
	 * And array cache of cleaned URLs.
	 *
	 * @since 4.9.11
	 *
	 * @var array
	 */
	protected $clean_url_cache = null;
	/**
	 * A reference to the Locale Switcher instance.
	 *
	 * @since 5.0.8
	 *
	 * @var Translations_Loader
	 */
	protected $translations_loader;

	/**
	 * Static Singleton Factory Method
	 *
	 * @return self
	 */
	public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static;
		}

		return static::$instance;
	}

	/**
	 * Tribe__Rewrite constructor.
	 *
	 * @since 5.1.7 Removed type hinting. This causes issues with Dependency Injection passing empty objects.
	 *
	 * @param WP_Rewrite|null          $wp_rewrite          An instance of the `WP_Rewrite` class.
	 * @param Translations_Loader|null $translations_loader An instance of the translations loader.
	 */
	public function __construct( $wp_rewrite = null, Translations_Loader $translations_loader = null ) {
		$this->rewrite             = $wp_rewrite;
		$this->translations_loader = $translations_loader ?? tribe( Translations_Loader::class );
	}

	/**
	 * When you are going to use any of the functions to create new rewrite rules you need to setup first
	 *
	 * @param WP_Rewrite|null $wp_rewrite Pass the WP_Rewrite if you have it
	 *
	 * @return Tribe__Rewrite       The modified version of the class with the required variables in place
	 */
	public function setup( $wp_rewrite = null ) {
		if ( ! $wp_rewrite instanceof WP_Rewrite ) {
			global $wp_rewrite;
		}

		$this->rewrite = $wp_rewrite;
		$this->bases   = $this->get_bases( 'regex' );

		return $this;
	}

	/**
	 * Generate the Rewrite Rules
	 *
	 * @param WP_Rewrite $wp_rewrite WordPress Rewrite that will be modified, pass it by reference (&$wp_rewrite)
	 */
	public function filter_generate( WP_Rewrite $wp_rewrite ) {
		// Gets the rewrite bases and completes any other required setup work
		$this->setup( $wp_rewrite );

		/**
		 * Use this to change the Tribe__Rewrite instance before new rules
		 * are committed.
		 *
		 * Should be used when you want to add more rewrite rules without having to
		 * deal with the array merge, noting that rules for The Events Calendar are
		 * themselves added via this hook (default priority).
		 *
		 * @var Tribe__Rewrite $rewrite
		 */
		do_action( 'tribe_pre_rewrite', $this );
	}


	/**
	 * Do not allow people to Hook methods twice by mistake
	 */
	public function hooks( $remove = false ) {
		if ( false === $this->hook_lock ) {
			// Don't allow people do Double the hooks
			$this->hook_lock = true;

			$this->add_hooks();
		} elseif ( true === $remove ) {
			$this->remove_hooks();
		}
	}

	/**
	 * Converts any percentage placeholders in the array keys back to % symbols.
	 *
	 * @param array $rules
	 *
	 * @return array
	 */
	public function remove_percent_placeholders( array $rules ) {
		foreach ( $rules as $key => $value ) {
			$this->replace_array_key( $rules, $key, str_replace( self::PERCENT_PLACEHOLDER, '%', $key ) );
		}

		return $rules;
	}

	/**
	 * Filter for the `rewrite_rules_array` hook.
	 *
	 * @since 5.0.10
	 *
	 * @param array|mixed $rules The rules to be filtered.
	 *
	 * @return array|mixed Rules after filtering.
	 */
	public function filter_rewrite_rules_array( $rules ) {
		if ( ! is_array( $rules ) ) {
			return $rules;
		}

		return $this->remove_percent_placeholders( $rules );
	}

	protected function add_hooks() {
		add_filter( 'generate_rewrite_rules', [ $this, 'filter_generate' ] );

		// Remove percent Placeholders on all items
		add_filter( 'rewrite_rules_array', [ $this, 'filter_rewrite_rules_array' ], 25 );

		add_action( 'shutdown', [ $this, 'dump_cache' ] );
	}

	protected function remove_hooks() {
		remove_filter( 'generate_rewrite_rules', [ $this, 'filter_generate' ] );
		remove_filter( 'rewrite_rules_array', [ $this, 'filter_rewrite_rules_array' ], 25 );

		remove_action( 'shutdown', [ $this, 'dump_cache' ] );
	}

	/**
	 * Determines if we have plain permalink.
	 *
	 * @since 4.11.2
	 *
	 * @return bool If we use plain permalink or not.
	 */
	public static function is_plain_permalink() {
		return tribe_context()->is( 'plain_permalink' );
	}

	/**
	 * Get the base slugs for the rewrite rules.
	 *
	 * WARNING: Don't mess with the filters below if you don't know what you are doing
	 *
	 * @param string $method Use "regex" to return a Regular Expression with the possible Base Slugs using l10n
	 *
	 * @return object         Return Base Slugs with l10n variations
	 */
	public function get_bases( $method = 'regex' ) {
		return new stdClass();
	}

	/**
	 * The base method for creating a new Rewrite rule
	 *
	 * @param array|string $regex The regular expression to catch the URL
	 * @param array        $args  The arguments in which the regular expression "alias" to
	 *
	 * @return Tribe__Events__Rewrite
	 */
	public function add( $regex, $args = [] ) {
		$regex = (array) $regex;

		$default = [];
		$args    = array_filter( wp_parse_args( $args, $default ) );

		$url = add_query_arg( $args, 'index.php' );

		// Optional Trailing Slash
		$regex[] = '?$';

		// Glue the pieces with slashes
		$regex = implode( '/', array_filter( $regex ) );

		// Add the Bases to the regex
		foreach ( $this->bases as $key => $value ) {
			$regex = str_replace( [ '{{ ' . $key . ' }}', '{{' . $key . '}}' ], $value, $regex );
		}

		// Apply the Preg Indexes to the URL
		preg_match_all( '/%([0-9])/', $url, $matches );
		foreach ( end( $matches ) as $index ) {
			$url = str_replace( '%' . $index, $this->rewrite->preg_index( $index ), $url );
		}

		// Add the rule
		$this->rules[ $regex ] = $url;

		return $this;
	}

	/**
	 * Returns a sanitized version of $slug that can be used in rewrite rules.
	 *
	 * This is ideal for those times where we wish to support internationalized
	 * URLs (ie, where "venue" in "venue/some-slug" may be rendered in non-ascii
	 * characters).
	 *
	 * In the case of registering new post types, $permastruct_name should
	 * generally match the CPT name itself.
	 *
	 * @param string $slug
	 * @param string $permastruct_name
	 * @param string $is_regular_exp
	 *
	 * @return string
	 */
	public function prepare_slug( $slug, $permastruct_name, $is_regular_exp = true ) {
		$needs_handling = false;
		$sanitized_slug = sanitize_title( $slug );

		// Was UTF8 encoding required for the slug? %a0 type entities are a tell-tale of this
		if ( preg_match( '/(%[0-9a-f]{2})+/', $sanitized_slug ) ) {
			/**
			 * Controls whether special UTF8 URL handling is setup for the set of
			 * rules described by $permastruct_name.
			 *
			 * This only fires if Tribe__Events__Rewrite::prepare_slug() believes
			 * handling is required.
			 *
			 * @var string $permastruct_name
			 * @var string $slug
			 */
			$needs_handling = apply_filters(
				'tribe_events_rewrite_utf8_handling', true, $permastruct_name, $slug
			);
		}

		if ( $needs_handling ) {
			// User agents encode things the same way but in uppercase
			$sanitized_slug = strtoupper( $sanitized_slug );

			// UTF8 encoding results in lots of "%" chars in our string which play havoc
			// with WP_Rewrite::generate_rewrite_rules(), so we swap them out temporarily
			$sanitized_slug = str_replace( '%', Tribe__Rewrite::PERCENT_PLACEHOLDER, $sanitized_slug );
		}

		$prepared_slug = $is_regular_exp ? preg_quote( $sanitized_slug ) : $sanitized_slug;

		/**
		 * Provides an opportunity to modify the sanitized slug which will be used
		 * in rewrite rules relating to $permastruct_name.
		 *
		 * @var string $prepared_slug
		 * @var string $permastruct_name
		 * @var string $original_slug
		 */
		return apply_filters( 'tribe_rewrite_prepared_slug', $prepared_slug, $permastruct_name, $slug );
	}

	/**
	 * A way to replace an Array key without destroying the array ordering
	 *
	 * @since 4.0.6
	 *
	 * @param array  &$array   The Rules Array should be used here
	 * @param string  $search  Search for this Key
	 * @param string  $replace Replace with this key]
	 *
	 * @return bool            Did we replace anything?
	 */
	protected function replace_array_key( &$array, $search, $replace ) {
		$keys  = array_keys( $array );
		$index = array_search( $search, $keys );

		if ( false !== $index ) {
			$keys[ $index ] = $replace;
			$array          = array_combine( $keys, $array );

			return true;
		}

		return false;
	}

	/**
	 * Returns the canonical URLs associated with a ugly link.
	 *
	 * This method will handle "our" URLs to go from their ugly form, filled with query vars, to the "pretty" one, if
	 * possible.
	 *
	 * @since 4.9.11
	 *
	 * @param string $url   The URL to try and translate into its canonical form.
	 * @param bool   $force Whether to try and use the cache or force a new canonical URL conversion.
	 *
	 * @return string The canonical URL, or the input URL if it could not be resolved to a canonical one.
	 */
	public function get_canonical_url( $url, $force = false ) {
		if ( get_class( $this ) === Tribe__Rewrite::class ) {
			throw new BadMethodCallException(
				'Method get_canonical_url should only be called on extending classes.'
			);
		}

		if ( null === $this->rewrite ) {
			// We re-do this check here as the object might have been initialized before the global rewrite was set.
			$this->setup();
		}

		/**
		 * Filters the canonical URL for an input URL before any kind of logic runs.
		 *
		 * @since 4.9.11
		 *
		 * @param string|null    $canonical_url The canonical URL, defaults to `null`; returning a non `null` value will
		 *                                      make the logic bail and return the value.
		 * @param string         $url           The input URL to resolve to a canonical one.
		 * @param Tribe__Rewrite $instance      The rewrite object.
		 */
		$canonical_url = apply_filters( 'tribe_rewrite_pre_canonical_url', null, $url );
		if ( null !== $canonical_url ) {
			return $canonical_url;
		}

		$home_url = home_url();

		// It's not a path we, or WP, could possibly handle.
		$has_http_scheme = (bool) parse_url( $url, PHP_URL_SCHEME );
		if (
			$home_url === $url
			|| ( $has_http_scheme && false === strpos( $url, $home_url ) )
		) {
			return $url;
		}

		$canonical_url = $url;
		// To avoid issues with missing `path` component let's always add a trailing '/'.
		if ( false !== strpos( $url, '?' ) ) {
			$canonical_url = preg_replace( '~(\\/)*\\?~', '/?', $canonical_url );
		} elseif ( false !== strpos( $url, '#' ) ) {
			$canonical_url = preg_replace( '~(\\/)*#~', '/#', $canonical_url );
		}

		// Canonical URLs are supposed to contain the home URL.
		if ( false === strpos( $canonical_url, $home_url ) ) {
			$canonical_url = home_url( $canonical_url );
		}

		if ( empty( $canonical_url ) ) {
			return $home_url;
		}

		// Passthru vars are additional salts for the cache that would render it useless: parse them here.
		$query = (string) parse_url( $url, PHP_URL_QUERY );
		wp_parse_str( $query, $query_vars );
		// Non-scalar value query vars should not be handled, but they should survive the resolution and not be cached.
		$scalar_query_vars = array_filter( $query_vars, 'is_scalar' );
		$passthru_vars     = array_diff_key( $query_vars, $scalar_query_vars );
		// Remove the passthru query vars from the URL to match the correct cache.
		$url = remove_query_arg( array_keys( $passthru_vars ), $url );
		// Normalize the URL to make sure there's a trailing slash at the end of the path, before the query or fragment.
		$url = preg_replace( '~(?<!/)([?#])~', '/$1', $url );

		if ( ! $force ) {
			$this->warmup_cache(
				'canonical_url',
				WEEK_IN_SECONDS,
				Listener::TRIGGER_GENERATE_REWRITE_RULES
			);
			if ( isset( $this->canonical_url_cache[ $url ] ) ) {
				// Re-apply passthru vars now, if any.
				return add_query_arg( $passthru_vars, $this->canonical_url_cache[ $url ] );
			}
		}

		$query_vars = array_intersect_key( $query_vars, $scalar_query_vars );

		if ( isset( $query_vars['paged'] ) && 1 === (int) $query_vars['paged'] ) {
			// Remove the `paged` query var if it's 1.
			unset( $query_vars['paged'] );
		}

		ksort( $query_vars );

		$our_rules          = $this->get_handled_rewrite_rules();
		$handled_query_vars = $this->get_rules_query_vars( $our_rules );
		$handled_post_types = $this->get_post_types();

		if (
			// The rules we handle should not be empty.
			empty( $our_rules )
			|| ! (
				// Supported post types should be either keys or values, of the `post_type` argument, in the query vars.
				count( array_intersect_key( array_flip( $handled_post_types ), $query_vars ) )
				|| in_array( Arr::get( $query_vars, 'post_type', 'post' ), $handled_post_types, true )
			)
		) {
			$wp_canonical = redirect_canonical( $canonical_url, false );
			if ( empty( $wp_canonical ) ) {
				$wp_canonical = $canonical_url;
			}

			$this->canonical_url_cache[ $url ] = $wp_canonical;

			return $wp_canonical;
		}

		$bases = (array) $this->get_bases();
		ksort( $bases );

		$localized_matchers = $this->get_localized_matchers();
		$dynamic_matchers   = $this->get_dynamic_matchers( $query_vars );

		// Try to match only on the query vars we're actually handling.
		$matched_vars   = array_intersect_key( $query_vars, array_combine( $handled_query_vars, $handled_query_vars ) );
		$unmatched_vars = array_diff_key( $query_vars, array_combine( $handled_query_vars, $handled_query_vars ) );

		if ( empty( $matched_vars ) ) {
			// The URL does contain query vars, but none we handle.
			$wp_canonical                      = trailingslashit( redirect_canonical( $url, false ) );
			$this->canonical_url_cache[ $url ] = $wp_canonical;

			return $wp_canonical;
		}

		$found = false;

		foreach ( $our_rules as $link_template => $index_path ) {
			wp_parse_str( (string) parse_url( $index_path, PHP_URL_QUERY ), $link_vars );
			ksort( $link_vars );

			if ( array_keys( $link_vars ) !== array_keys( $matched_vars ) ) {
				continue;
			}

			if ( ! (
				Arr::get( $matched_vars, 'post_type', '' ) === Arr::get( $link_vars, 'post_type', '' )
				&& Arr::get( $matched_vars, 'eventDisplay', '' ) === Arr::get( $link_vars, 'eventDisplay', '' )
			) ) {
				continue;
			}

			$replace = array_map( function ( $localized_matcher ) use ( $matched_vars ) {
				if ( ! is_array( $localized_matcher ) ) {
					// For the dates.
					return isset( $matched_vars[ $localized_matcher ] )
						? $matched_vars[ $localized_matcher ]
						: '';
				}

				$query_var  = $localized_matcher['query_var'];
				$query_vars = [ $query_var ];

				if ( $query_var === 'name' ) {
					$query_vars = array_merge( $query_vars, $this->get_post_types() );
				}

				if ( ! array_intersect( array_keys( $matched_vars ), $query_vars ) ) {
					return '';
				}

				if ( isset( $localized_matcher['localized_slug'] ) ) {
					// If available, then return the localized slug instead of inferring it as we do below.
					return $localized_matcher['localized_slug'];
				}

				/*
				 * We use `end` as, by default, the localized version of the slug in the current language will be at the
				 * end of the array.
				 */

				return end( $localized_matcher['localized_slugs'] );
			}, $localized_matchers );

			// Include dynamic matchers now.
			$replace = array_merge( $dynamic_matchers, $replace );

			/*
			 * Prune from the replacements the empty values. This will resolve conflicts (e.g. single and archive w/
			 * same slug) as no two can be true at the same time.
			 * Remove the `<delim><base>` prefix added to localized matchers, if any.
			 */
			$replace = array_filter( $replace );
			$replace = array_combine(
				array_map( static function ( $key ) {
					return preg_replace(
						'/' . preg_quote( Tribe__Rewrite::$localized_matcher_delimiter, '/' ) . '\\w*$/',
						'',
						$key
					);
				}, array_keys( $replace ) ),
				$replace
			);

			// Use case-insensitive replace to make sure to work with some decoding using uppercase escaped chars.
			$replaced = str_ireplace( array_keys( $replace ), $replace, $link_template );

			// Remove trailing chars.
			$path     = rtrim( $replaced, '?$' );
			$resolved = trailingslashit( home_url( $path ) );
			$found    = true;

			break;
		}

		if ( empty( $resolved ) ) {
			$wp_canonical = redirect_canonical( $canonical_url, false );
			$resolved     = empty( $wp_canonical ) ? $canonical_url : $wp_canonical;
		}

		if ( $canonical_url !== $resolved ) {
			// Be sure to add a trailing slash to the URL; before `?` or `#`.
			$resolved = preg_replace( '/(?<!\\/)(#|\\?)/u', '/$1', $resolved );
		}

		if ( count( $unmatched_vars ) ) {
			$resolved = add_query_arg( $unmatched_vars, $resolved );
		}

		/**
		 * Filters the resolved canonical URL to allow third party code to modify it.
		 *
		 * Mind that the value will be cached and hence this filter will fire once per URL per request and, second, this
		 * filter will fire after all the logic to resolve the URL ran. If you want to filter the canonical URL before
		 * the logic runs then use the `tribe_rewrite_pre_canonical_url` filter.
		 *
		 * @since 4.9.11
		 *
		 * @param string         $resolved The resolved, canonical URL.
		 * @param string         $url      The original URL to resolve.
		 * @param Tribe__Rewrite $instance The rewrite object.
		 */
		$resolved = apply_filters( 'tribe_rewrite_canonical_url', $resolved, $url, $this );

		if ( $found ) {
			// Since we're caching let's not cache unmatched rules to allow for their later, valid resolution.
			$this->canonical_url_cache[ $url ] = $resolved;
		}

		// Re-apply passthru vars now, if any. After the caching to allow salting the cache key too much.
		$resolved = add_query_arg( $passthru_vars, $resolved );

		return $resolved;
	}

	/**
	 * Returns an array of rewrite rules handled by the implementation.
	 *
	 * @since 4.9.11
	 *
	 * @return array An array of rewrite rules handled by the implementation in the shape `[ <regex> => <path> ]`.
	 */
	protected function get_handled_rewrite_rules() {
		// Try and pull it from memoized values.
		$cache     = tribe_cache();
		$our_rules = $cache['handled_rewrite_rules'] ?? null;

		// We need to make sure we are have WP_Rewrite setup
		if ( ! $this->rewrite || empty( $this->rewrite->rules ) ) {
			$this->setup();
		}

		$all_rules = isset( $this->rewrite->rules ) ? (array) $this->rewrite->rules : [];

		if ( ! is_array( $our_rules ) ) {
			// While this is specific to The Events Calendar we're handling a small enough post type base to keep it here.
			$pattern = '/post_type=tribe_(events|venue|organizer)/';
			// Reverse the rules to try and match the most complex first.
			$our_rules = array_filter( $all_rules,
				static function ( $rule_query_string ) use ( $pattern ) {
					return is_string( $rule_query_string ) && preg_match( $pattern, $rule_query_string );
				}
			);

			$cache['handled_rewrite_rules'] = $our_rules;
		}

		/**
		 * Filters the list of rewrite rules handled by our code to add or remove some as required.
		 *
		 * @since 4.9.18
		 *
		 * @param array                $our_rules An array of rewrite rules handled by our code, in the shape
		 *                                        `[ <rewrite_rule_regex_pattern> => <query_string> ]`.
		 *                                        E.g. `[ '(?:events)/(?:list)/?$' => 'index.php?post_type=tribe_events&eventDisplay=list' ]`.
		 * @param array<string,string> $all_rules All the current rewrite rules, before any filtering is applied; these
		 *                                        have the same `<pattern => rewrite >` format as the previous argument,
		 *                                        which is the format used by WordPress rewrite rules.
		 */
		$our_rules = apply_filters( 'tribe_rewrite_handled_rewrite_rules', $our_rules, $all_rules );

		return $our_rules;
	}

	/**
	 * Returns a map relating localized regex matchers to query vars.
	 *
	 * @since 4.9.11
	 *
	 * @return array A map of localized regex matchers in the shape `[ <localized_regex> => <query_var> ]`.
	 */
	protected function get_localized_matchers() {
		$cache_key = __METHOD__;
		$cache     = tribe_cache();

		$localized_matchers = $cache[ $cache_key ];

		if ( ! empty( $localized_matchers ) ) {
			return $localized_matchers;
		}

		$bases              = (array) $this->get_bases();
		$query_var_map      = $this->get_matcher_to_query_var_map();
		$localized_matchers = [];

		foreach ( $bases as $base => $localized_matcher ) {
			// Use the base too to allow possible conflicts if the slugs are the same for single and archive.
			$localized_matcher_key = $localized_matcher . static::$localized_matcher_delimiter . $base;

			if ( isset( $localized_matchers[ $localized_matcher_key ] ) ) {
				continue;
			}

			if ( isset( $query_var_map[ $base ] ) ) {
				$localized_matchers[ $localized_matcher_key ] = [
					'base'            => $base,
					'query_var'       => $query_var_map[ $base ],
					'en_slug'         => $base,
					'localized_slugs' => [ $base ],
				];
				// If we have the localized slug version then let's parse it.
				preg_match( '/^\\(\\?:(?<slugs>[^\\)]+)\\)$/u', $localized_matcher, $buffer );
				if ( ! empty( $buffer['slugs'] ) ) {
					$slugs = explode( '|', $buffer['slugs'] );

					$localized_matchers[ $localized_matcher_key ]['localized_slugs'] = array_map(
						static function ( $localized_slug ) {
							return str_replace( '\-', '-', $localized_slug );
						},
						$slugs
					);

					// The English version is the first.
					$localized_matchers[ $localized_matcher_key ]['en_slug'] = reset( $slugs );

					$localized_slug = $this->filter_matcher( null, $base );

					if ( $localized_slug ) {
						$localized_matchers[ $localized_matcher_key ]['localized_slug'] = $localized_slug;
					}
				}
			}
		}

		$cache[ $cache_key ] = $localized_matchers;

		return $localized_matchers;
	}

	/**
	 * Returns a map relating localized matcher slugs to the corresponding query var.
	 *
	 * @since 4.9.11
	 *
	 * @return array A map relating localized matcher slugs to the corresponding query var.
	 */
	protected function get_matcher_to_query_var_map() {
		throw new BadMethodCallException(
			'This method should not be called on the base class (' . __CLASS__ . '); only on extending classes.'
		);
	}

	/**
	 * Return a list of the query vars handled in the input rewrite rules.
	 *
	 * @since 4.9.11
	 *
	 * @param array $rules A set of rewrite rules in the shape `[ <regex> => <path> ]`.
	 *
	 * @return array A list of all the query vars handled in the rules.
	 */
	protected function get_rules_query_vars( array $rules ) {
		static $cache_var_name = __METHOD__;

		$cached_rules = tribe_get_var( $cache_var_name, [] );
		$cache_key    = md5( json_encode( $rules ) );

		if ( ! isset( $cached_rules[ $cache_key ] ) ) {
			$cached_rules[ $cache_key ] = array_unique(
				array_filter(
					array_merge(
						[],
						...array_values(
							array_map(
								static function ( $rule_string ) {
									wp_parse_str( parse_url( $rule_string, PHP_URL_QUERY ), $vars );

									return array_keys( $vars );
								},
								array_filter( $rules, 'is_string' )
							)
						)
					)
				)
			);

			tribe_set_var( $cache_var_name, $cached_rules );
		}

		return $cached_rules[ $cache_key ];
	}

	/**
	 * Sets up the dynamic matchers based on the link query vars.
	 *
	 * @since 4.9.11
	 *
	 * @param array $query_vars An map of query vars and their values.
	 *
	 * @return array A map of dynamic matchers in the shape `[ <regex> => <value> ]`.
	 */
	protected function get_dynamic_matchers( array $query_vars ) {
		$bases            = (array) $this->get_bases();
		$dynamic_matchers = [];

		/*
		 * In some instance we use the `page` (w/o `d`) to paginate a dynamic archive.
		 * Let's support that too.
		 * It's important to add `page` after `paged` to try and match the longest (`paged`) first.
		 */
		foreach ( [ 'paged', 'page' ] as $page_var ) {
			if ( isset( $query_vars[ $page_var ] ) ) {
				$page_regex = $bases['page'];
				preg_match( '/^\(\?:(?<slugs>[^\\)]+)\)/', $page_regex, $matches );
				if ( isset( $matches['slugs'] ) ) {
					$slugs = explode( '|', $matches['slugs'] );
					// The localized version is the last, by default.
					$en_slug        = end( $slugs );
					$localized_slug = $this->filter_matcher( null, 'page' );

					// We use two different regular expressions to read pages, let's add both.
					if ( $localized_slug ) {
						$dynamic_matchers["{$page_regex}/(\d+)"]       = "{$localized_slug}/{$query_vars[$page_var]}";
						$dynamic_matchers["{$page_regex}/([0-9]{1,})"] = "{$localized_slug}/{$query_vars[$page_var]}";
					} else {
						$dynamic_matchers["{$page_regex}/(\d+)"]       = "{$en_slug}/{$query_vars[$page_var]}";
						$dynamic_matchers["{$page_regex}/([0-9]{1,})"] = "{$en_slug}/{$query_vars[$page_var]}";
					}
				}
			}
		}

		if ( isset( $query_vars['tag'] ) ) {
			$tag      = $query_vars['tag'];
			$tag_term = get_term_by( 'slug', $tag, 'post_tag' );

			if ( $tag_term instanceof WP_Term ) {
				// Let's actually add the matcher only if the tag exists.
				$tag_regex = $bases['tag'];
				preg_match( '/^\(\?:(?<slugs>[^\\)]+)\)/', $tag_regex, $matches );
				if ( isset( $matches['slugs'] ) ) {
					$slugs = explode( '|', $matches['slugs'] );
					// The localized version is the last, by default.
					$en_slug        = end( $slugs );
					$localized_slug = $this->filter_matcher( null, 'tag' );

					if ( $localized_slug ) {
						$dynamic_matchers["{$tag_regex}/([^/]+)"] = "{$localized_slug}/{$tag}";
					} else {
						$dynamic_matchers["{$tag_regex}/([^/]+)"] = "{$en_slug}/{$tag}";
					}
				}
			}
		}

		if ( isset( $query_vars['feed'] ) ) {
			$feed_regex                      = 'feed/(feed|rdf|rss|rss2|atom)';
			$dynamic_matchers[ $feed_regex ] = "feed/{$query_vars['feed']}";
		}

		/**
		 * Allow extending classes to add their own dynamic matchers.
		 *
		 * @since 5.1.5
		 *
		 * @param array<string, string> $dynamic_matchers An array of dynamic matchers in the shape `[ <regex> => <value> ]`.
		 * @param array<string, mixed>  $query_vars       A map of query vars and their values.
		 * @param Tribe__Rewrite        $rewrite          The rewrite instance.
		 */
		return apply_filters( 'tec_common_rewrite_dynamic_matchers', $dynamic_matchers, $query_vars, $this );
	}

	/**
	 * Returns a list of post types supported by the implementation.
	 *
	 * @since 4.9.11
	 *
	 * @return array<string> An array of post types supported and handled by the rewrite implementation.
	 */
	protected function get_post_types() {
		throw new BadMethodCallException( 'Method get_post_types should be implemented by extending classes.' );
	}

	/**
	 * Parses a URL to produce an array of query variables.
	 *
	 * Most of this functionality was copied from `WP::parse_request()` method
	 * with some changes to avoid conflicts and removing non-required behaviors.
	 *
	 * @since 4.9.11
	 *
	 * @param string $url              The URLto parse.
	 * @param array  $extra_query_vars An associative array of extra query vars to use for the parsing. These vars will
	 *                                 be read before the WordPress defined ones overriding them.
	 * @param bool   $force            Whether to try and use the cache or force a new canonical URL conversion.
	 *
	 * @return array An array of query vars, as parsed from the input URL.
	 */
	public function parse_request( $url, array $extra_query_vars = [], $force = false ) {

		if ( null === $this->rewrite ) {
			// We re-do this check here as the object might have been initialized before the global rewrite was set.
			$this->setup();
		}

		/**
		 * Allows short-circuiting the URL parsing.
		 *
		 * This filter will run before any logic runs, its result will not be cached and this filter will be called on
		 * each call to this method.
		 * Returning a non `null` value here will short-circuit this logic.
		 *
		 * @since 4.9.11
		 *
		 * @param array  $query_vars       The parsed query vars array.
		 * @param array  $extra_query_vars An associative array of extra query vars that will be processed before the
		 *                                 WordPress defined ones.
		 * @param string $url              The URL to parse.
		 */
		$parsed = apply_filters( 'tribe_rewrite_pre_parse_query_vars', null, $extra_query_vars, $url );
		if ( null !== $parsed ) {
			return $parsed;
		}

		if ( ! $force ) {
			$this->warmup_cache(
				'parse_request',
				WEEK_IN_SECONDS,
				Listener::TRIGGER_GENERATE_REWRITE_RULES
			);
			if ( isset( $this->parse_request_cache[ $url ] ) ) {
				return $this->parse_request_cache[ $url ];
			}
		}

		$query_vars           = [];
		$post_type_query_vars = [];
		$perma_query_vars     = [];
		$url_components       = parse_url( $url );
		$url_path             = Arr::get( $url_components, 'path', '/' );
		$site_path            = parse_url( home_url(), PHP_URL_PATH );
		if ( ! empty( $site_path ) && '/' !== $site_path ) {
			// The current site is in a sub-directory: the site path should be dropped from the request path.
			$url_path = str_replace( $site_path, '', $url_path );
		}
		$url_query = Arr::get( $url_components, 'query', '' );
		parse_str( $url_query, $url_query_vars );
		// Look for matches, removing leading `/` char.
		$request_match         = ltrim( $url_path, '/' );
		$decoded_request_match = urldecode( $request_match );

		// Fetch the rewrite rules.
		$rewrite_rules = $this->rewrite->wp_rewrite_rules();
		$matched_rule  = false;

		if ( ! empty( $rewrite_rules ) ) {
			foreach ( (array) $rewrite_rules as $match => $query ) {
				$matches_regex = preg_match( "#^$match#", $request_match, $matches )
				                 || preg_match( "#^$match#", $decoded_request_match, $matches );

				if ( ! $matches_regex ) {
					continue;
				}

				if (
					$this->rewrite->use_verbose_page_rules
					&& preg_match( '/pagename=\$matches\[([0-9]+)\]/', $query, $varmatch )
				) {
					// This is a verbose page match, let's check to be sure about it.
					$page = get_page_by_path( $matches[ $varmatch[1] ] );
					if ( ! $page ) {
						continue;
					}
					$post_status_obj = get_post_status_object( $page->post_status );
					if (
						! $post_status_obj->public
						&& ! $post_status_obj->protected
						&& ! $post_status_obj->private
						&& $post_status_obj->exclude_from_search
					) {
						continue;
					}
				}

				// Got a match.
				$matched_rule = $match;
				break;
			}

			if ( false !== $matched_rule ) {
				// Trim the query of everything up to the '?'.
				$query = preg_replace( '!^.+\?!', '', $query );
				// Substitute the substring matches into the query.
				$query = addslashes( WP_MatchesMapRegex::apply( $query, $matches ) );
				// Parse the query.
				parse_str( $query, $perma_query_vars );
			}
		}

		foreach ( get_post_types( [], 'objects' ) as $post_type => $t ) {
			if (
				is_post_type_viewable( $t )
				&& $t->query_var
			) {
				$post_type_query_vars[ $t->query_var ] = $post_type;
			}
		}

		global $wp;

		/*
		 * WordPress would apply this filter in the `parse_request` method to allow the registration of additional query
		 * vars. They might not have been registered at this point so we do this again making sure to avoid duplicates.
		 */
		$public_query_vars = array_unique( apply_filters( 'query_vars', $wp->public_query_vars ) );

		foreach ( $public_query_vars as $wpvar ) {
			if ( isset( $extra_query_vars[ $wpvar ] ) ) {
				$query_vars[ $wpvar ] = $extra_query_vars[ $wpvar ];
			} elseif ( isset( $perma_query_vars[ $wpvar ] ) ) {
				$query_vars[ $wpvar ] = $perma_query_vars[ $wpvar ];
			}
			if ( ! empty( $query_vars[ $wpvar ] ) ) {
				if ( ! is_array( $query_vars[ $wpvar ] ) ) {
					$query_vars[ $wpvar ] = (string) $query_vars[ $wpvar ];
				} else {
					foreach ( $query_vars[ $wpvar ] as $vkey => $v ) {
						if ( is_scalar( $v ) ) {
							$query_vars[ $wpvar ][ $vkey ] = (string) $v;
						}
					}
				}
				if ( isset( $post_type_query_vars[ $wpvar ] ) ) {
					$query_vars['post_type'] = $post_type_query_vars[ $wpvar ];
					$query_vars['name']      = $query_vars[ $wpvar ];
				}
			}
		}

		// Convert urldecoded spaces back into `+`.
		foreach ( get_taxonomies( [], 'objects' ) as $taxonomy => $t ) {
			if ( $t->query_var && isset( $query_vars[ $t->query_var ] ) ) {
				$query_vars[ $t->query_var ] = str_replace( ' ', '+', $query_vars[ $t->query_var ] );
			}
		}

		// Don't allow non-publicly queryable taxonomies to be queried from the front end.
		if ( ! is_admin() ) {
			foreach ( get_taxonomies( [ 'publicly_queryable' => false ], 'objects' ) as $taxonomy => $t ) {
				/*
				 * Disallow when set to the 'taxonomy' query var.
				 * Non-publicly queryable taxonomies cannot register custom query vars. See register_taxonomy().
				 */
				if ( isset( $query_vars['taxonomy'] ) && $taxonomy === $query_vars['taxonomy'] ) {
					unset( $query_vars['taxonomy'], $query_vars['term'] );
				}
			}
		}

		// Limit publicly queried post_types to those that are publicly_queryable
		if ( isset( $query_vars['post_type'] ) ) {
			$queryable_post_types = get_post_types( [ 'publicly_queryable' => true ] );
			if ( ! is_array( $query_vars['post_type'] ) ) {
				if ( ! in_array( $query_vars['post_type'], $queryable_post_types ) ) {
					unset( $query_vars['post_type'] );
				}
			} else {
				$query_vars['post_type'] = array_intersect( $query_vars['post_type'], $queryable_post_types );
			}
		}

		// Resolve conflicts between posts with numeric slugs and date archive queries.
		$query_vars = wp_resolve_numeric_slug_conflicts( $query_vars );

		foreach ( (array) $wp->private_query_vars as $var ) {
			if ( isset( $extra_query_vars[ $var ] ) ) {
				$query_vars[ $var ] = $extra_query_vars[ $var ];
			}
		}

		/*
		 * If we have both the `name` query var and the post type one, then let's remove the `name` one.
		 */
		if ( array_intersect( array_keys( $query_vars ), $this->get_post_types() ) ) {
			unset( $query_vars['name'] );
		}

		if ( ! empty( $url_query_vars ) ) {
			// If the URL did have query vars keep them if not overridden by our resolution.
			$query_vars = array_merge( $url_query_vars, $query_vars );
		}

		// Prune the query vars to drop the empty `page` or `paged` ones.
		$query_vars = array_filter( $query_vars, static function ( $value, $key ) {
			return ! in_array( $key, [ 'paged', 'page' ] ) || (int) $value !== 0;
		}, ARRAY_FILTER_USE_BOTH );

		/**
		 * Filters the array of parsed query variables after the class logic has been applied to it.
		 *
		 * Due to the costly nature of this operation the results will be cached. The logic, and this filter, will
		 * not run a second time for the same URL in the context of the same request.
		 *
		 * @since 4.9.11
		 *
		 * @param array  $query_vars       The parsed query vars array.
		 * @param array  $extra_query_vars An associative array of extra query vars that will be processed before the
		 *                                 WordPress defined ones.
		 * @param string $url              The URL to parse.
		 */
		$query_vars = apply_filters( 'tribe_rewrite_parse_query_vars', $query_vars, $extra_query_vars, $url );

		if ( $matched_rule ) {
			// Since we're caching let's not cache unmatchec URLs to allow for their later, valid matching.
			$this->parse_request_cache[ $url ] = $query_vars;
		}

		return $query_vars;
	}

	/**
	 * Returns the "clean" version of a URL.
	 *
	 * The URL is first parsed then resolved to a canonical URL.
	 * As an example the URL `/events/list/?post_type=tribe_events` is "dirty" in that the `post_type` query variable
	 * is redundant. The clean version of the URL is `/events/list/`, where the query variable is removed.
	 *
	 * @since 4.9.11
	 *
	 * @param string $url   The URL to clean.
	 * @param bool   $force Whether to try and use the cache or force a new URL cleaning run.
	 *
	 * @return string The cleaned URL, or the input URL if it could not be resolved to a clean one.
	 */
	public function get_clean_url( $url, $force = false ) {
		if ( ! $force ) {
			$this->warmup_cache(
				'clean_url',
				WEEK_IN_SECONDS,
				Listener::TRIGGER_GENERATE_REWRITE_RULES
			);
			if ( isset( $this->clean_url_cache[ $url ] ) ) {
				return $this->clean_url_cache[ $url ];
			}
		}

		$parsed_vars = $this->parse_request( $url );

		if ( empty( $parsed_vars ) ) {
			return home_url();
		}

		$clean = $this->get_canonical_url( add_query_arg( $parsed_vars, home_url( '/' ) ), $force );

		$this->clean_url_cache[ $url ] = $clean;

		return $clean;
	}

	/**
	 * Filters the localized matcher to allow integrations to provider contextual translations of the matcher.
	 *
	 * @since 5.0.17
	 *
	 * @param string|null $localized_matcher The localized matcher.
	 * @param string      $base              The base the localized matcher is for.
	 *
	 * @return string The localized matcher.
	 */
	protected function filter_matcher( ?string $localized_matcher, string $base ): string {
		return (string) apply_filters( 'tec_common_rewrite_localize_matcher', $localized_matcher, $base );
	}
}
