<?php
/*
Filename: wp-url-routes.php
Plugin Name: WP URL Routes
Description: Robust and Flexible URL Routing replacement for WordPress' URL rewrite system of regular expression matching.
Author: Mike Schinkel
Author URL: http://mikeschinkel.com/
Version: 0.0.1
Globals: $wp_urls: Array with keys 'root', 'paths', 'query_vars', 'expansion_vars'
*/

/*
 * Extends the WP class in WordPress and assigns an instance to the global $wp variable.
 * Notes: This is needed because WordPress does not (yet?) have a hook for $wp->parse_request() as proposed in trac ticket #XXXXX
*/
class WP_Urls {
	/*
	 * If self::$fallback
	 *   ===true then WP_Urls_WP->parse_request() will fallback to call WP->parse_request()
	 *   ===false then WP_Urls_WP->parse_request() will issue a 404 is wp_parse_request returns false.
	 */
	static $fallback = true;
	/*
	 * self::$path_segments - Holds the exploded path segments from $_SERVER['REQUEST_URI'] during and after inspection
	 */
	static $path_segments = false;
	/*
	 * self::$index - Hold the index of the path segment during inspection, starting with 0.
	 * Reset to false at end of WP_Urls::wp_parse_request().
	 */
	static $index = false;

	static $result = false;

	static function on_load() {
		add_filter( 'wp_parse_request', array( __CLASS__, 'wp_parse_request' ), 10, 2 );
		add_action( 'init', array( __CLASS__, 'init' ), 0 ); // TODO: Verify if this should be 0. Why? Run before all other initializations			remove_
	}

	static function init() {
		global $wp_urls;
		$wp_urls = array(
			'root' => new WP_Url_Node(),
			'paths' => array(),
			'query_vars' => array(),
			'expansion_vars' => array(),
		);
	}
	static function wp_parse_request( $do_default, $extra_query_vars = '' ) {
		global $wp;
		if ( ! is_array( $wp->query_vars ) )
			$wp->query_vars = array();

		global $wp_urls;
		$matched = false;

		if ( self::$path_segments ) {
			self::$index = self::$path_segments = false;
		}

		$path = esc_url( $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
		$path = str_replace( esc_url( $_SERVER['HTTP_HOST'] ), '', $path );
		list( $path, $query ) = explode( '?', "{$path}?" );

		if ( empty( $path ) ||  $path == '/' ) {

			$wp->query_vars = array(); // Root is blank;
			$matched = true;

		} else {

			self::$path_segments = explode( '/', trim( $path, '/' ) );

			$node = $wp_urls['root'];
			for( self::$index = 0; self::$index < count( self::$path_segments ); self::$index++  ) {
				$this_path_segment = self::$path_segments[self::$index];
				$node = self::_match_path_segment( $node );
				if ( ! $node ) {
					$matched = false;
					break;
				} else {
					$matched = true;
					if ( $node->inherit )
						$wp->query_vars = array_merge( $wp->query_vars, $node->query_vars );
					else
						$wp->query_vars = $node->query_vars;
					$wp->query_vars[preg_replace( '#%([^%]+)%#', '$1', $node->matched_pattern )] = $node->matched_segment;
				}
			}
		}
		if ( $matched ) {
			if ( substr( $path, -1, 1 ) != '/' && $node->trailing_slash ) {
				$new_path = "{$path}/" . ( strlen( $query ) == 0 ? '' : "?{$query}" );
				if ( ! defined( 'SR_UNIT_TEST' ) ) {
					wp_safe_redirect( site_url() . $new_path, 301 );
				} else {
					$wp->query_vars['_new_path'] = $new_path;
					return $matched;
				}
				exit;
			}
		}
		do_action('parse_request', $wp);  // Mirror default WordPress

		if ( $matched ) {
			remove_action('template_redirect','redirect_canonical');  // TODO: Fix canonical redirects
		}
		return $matched;
	}
	function _match_path_segment( $parent_node ) {
		$matched = false;
		$path_segment = self::$path_segments[self::$index];

		foreach( $parent_node->child_segments as $match_segment => $child_node ) {

			if ( ! preg_match( '#%#', $match_segment ) ) { // Is it a literal? (vs a %var%)

				if ( $path_segment == $match_segment ) {
					$matched = true;
					break;
				}

			} else //TODO: Make work for partial segments, i.e. /x-%foo%-y/
			if ( preg_match( '#^%.+%$#', $match_segment, $the_match ) ) {

				if ( $child_node->match_path_segment( $path_segment ) ) {
					$matched = true;
					break;
				}

			}
		}

		// To this point $matched is boolean. If $matched==true then we convert to an object of class WP_Url_Node to be the new $root.
		if ( $matched ) {

			$matched = &$child_node;

			if ( ! $child_node->multi_segment ) {

				$child_node->_expand_query_vars( $path_segment );

			} else {

				// This is for multi-segment matches; scan through to see if their is a match. Start with longest first.
				$start_count = $segment_count = count( self::$path_segments );
				while ( self::$index < $segment_count ) {
					$path_segment = implode( '/', array_slice( self::$path_segments, self::$index, $segment_count ) );
					if ( $child_node->match_path_segment( $path_segment ) ) {
						$child_node->_expand_query_vars( $path_segment );
						break;
					}
					$segment_count--;
				}
				if ( self::$index == $segment_count ) {
					$matched = false;
				} else if ( $start_count == $segment_count ) {
					self::$index += $start_count;                 // All remaining segments were used.
				} else {
					self::$index += $segment_count - 1;           // Decrement by one so when outer function's loop increments it will be in line.
				}

			}
			$matched->matched_pattern = $match_segment;
			$matched->parent_node = &$parent_node;
			$matched->matched_segment = $path_segment;
		}

		return $matched;

	}
}
WP_Urls::on_load();

/*
 * Represents one path node on the tree of URL path nodes. Each node has meta data associated this
 * this plugin uses to determine how to route. Allow matches to be determined by a hierarchy of functionality
 *
 * TODO: Add all helpers needed to route WordPress' standard URLs.
 *
*/
class WP_Url_Node {
	var $matched_pattern = false;     // The pattern used to match $this->matched_segment
	var $matched_segment = '';        // The path that matched the $this->pattern
	var $parent_node = false;         // The parent node that was matched
	var $child_segments = array();    // The child path segments that are potential at this level
	var $multi_segment = false;       // Is this a multi-segment (multi-backslash) node such as for pages with subpages?
	var $inherit = false;             // Inherit query vars from prior path segments?
	var $regex = false;               // regex that allows for matching

	private $_trailing_slash = null;  // Will this node have a trailing slash? Really only matters on last segment
	private $_validate = false;       // callable function that validates
	private $_get_list = false;       // callable function that returns a valid list
	private $_this_list = false;      // Temp storage for that returned by the evaluated $_get_list
	private $_query_vars = array();   // The list of query vars

	function __construct( $query_vars = array() ) {
		$this->query_vars = $query_vars;
	}
	/*
	 * Attempt to match a path segment to this node
	 */
	function match_path_segment( $path_segment ) {
		$matched = false;
		// We could add other ways to match URLs in the future
		if ( $this->match_regex( $path_segment ) ||
				 $this->match_list( $path_segment ) ||
				 $this->match_validate( $path_segment ) ) {
			$matched = true;
		}
		return $matched;
	}

	function match_regex( $path_segment ) {
		$matched = false;
		if ( $this->regex ) {
			if ( preg_match( "#^{$this->regex}$#", $path_segment ) )
				$matched = true;
		}
		return $matched;
	}
	function match_list( $path_segment ) {
		$matched = false;
		if ( $this->_get_list && ! $this->_this_list ) {
			$this->_this_list = call_user_func( $this->get_list, $this->_get_args() );
			if ( in_array( $path_segment, $this->_this_list ) ) {
				$matched = true;
			}
		}
		return $matched;
	}
	function match_validate( $path_segment ) {
		$matched = false;
		if ( $this->_validate ) {
			if ( call_user_func( $this->validate, $this->_get_args( $path_segment ) ) ) {
				$matched = true;
			}
		}
		return $matched;
	}

	function _expand_query_vars( $path_segment = false, $args = false ) {
		if ( ! $args )
			$args = $this->_get_args( $path_segment );
		foreach( $this->query_vars as $var_name => $var_value ) {
			foreach( $args as $arg_name => $arg_value ) {
				if ( strpos( $var_value, "%{$arg_name}%" ) !== false ) {
					$this->_query_vars[$var_name] = str_replace(	"%{$arg_name}%", $arg_value, $var_value );
				}
			}
		}
		return;
	}

	function _get_args( $path_segment = false ) {
		$index = WP_Urls::$index;
		$path_segments = WP_Urls::$path_segments;
		if ( ! $path_segment )
			$path_segment = $path_segments[$index];

		$args = array( 'this' => $path_segment );

		// Remove the path segment to test (which could be a multi-segment) and replace with a single dummy segment
		$path_segments = implode( '/', $path_segments );
		$path_segments = trim( str_replace( "/{$path_segment}/", '/~/', "/{$path_segments}/" ), '/' );
		$path_segments = explode( '/', $path_segments );

		// Now add the other args, as appropriate
		if ( 0 < $index ) $args['parent'] = $path_segments[$index - 1];
		if ( 1 < $index ) $args['grandparent'] = $path_segments[$index - 2];
		if ( 2 < $index ) $args['greatgrandparent'] = $path_segments[$index - 3];
		if ( $index + 1 < count( $path_segments ) ) $args['child'] = $path_segments[$index + 1];
		if ( $index + 2 < count( $path_segments ) ) $args['grandchild'] = $path_segments[$index + 2];
		if ( $index + 3 < count( $path_segments ) ) $args['greatgrandchild'] = $path_segments[$index + 3];

		$args['ordered'] = $args['offset'] = array();
		for( $segment = 0; $segment < count( $path_segments ); $segment++ ) {
			$offset = $segment - $index;
			$args['ordered'][$segment] = $args['offset'][$offset] = $segment == $index ? $path_segment : $path_segments[$segment];
		}

		return $args;
	}
	function __get( $name ) {
		$value = null;
		switch( $name ) {
			case 'query_vars':
				$value = &$this->_query_vars;
				break;
			case 'get_list':
			case 'validate':
				$property = "_{$name}";
				$value =  $this->$property;
				$is_immediate_func = $value[0] == '%';
				$is_delayed_func = $is_immediate_func ? false : ! empty( $value );
				if ( $is_immediate_func || $is_delayed_func ) {
					$callable = self::_locate_callable( strpos( $value, '::' ) > 0 ? explode( '::', $value ) : $value );
					if ( $is_delayed_func )
						$value = $callable;
					else if ( $is_immediate_func ) {
						$value = call_user_func( $callable, $this->_get_args() );
					}
				}
				break;
			case 'trailing_slash':
				if ( is_null( $this->_trailing_slash ) ) {
					$last_segment = WP_Urls::$path_segments[count(WP_Urls::$path_segments)-1];
					$this->_trailing_slash = strpos( $last_segment, '.' ) === false;
				}
				$value = $this->_trailing_slash;
				break;
		}
		return $value;
	}
	function __set( $name, $value ) {
		switch ( $name ) {
			case 'query_vars':
				/*
				 * Convert query vars that are prefixed with '@' into properties of the Query Vars object
				 * This is a bit unorthodox, allowing other properties to be set by assigning one property
				 * specially formatted array, but this makes specification very easy and this class it
				 * very purpose built; it is not meant for reuse in other areas.
				 */
				foreach( $value as $attribute_name => $property_value ) {
					if ( $attribute_name[0] == '@' ) {
						$property_name = substr( $attribute_name, 1 );   // Strip off the '@'
						if ( property_exists( $this, $property_name ) ) {
							 $this->$property_name = $property_value;
						} else if ( property_exists( $this, "_{$property_name}" )) {
							$property_name = "_{$property_name}";
							$this->$property_name = $property_value;
						} else {
							wp_die( "ERROR: Attempting to set a property named '{$property_name}' and " . __CLASS__ . ' does not contain that property.' );
						}
						// Remove it from the $query_vars array
						unset( $value[$attribute_name] );
					}
				}
				// Assign the remaining real query vars to the query_vars property.
				$this->_query_vars = $value;
				break;
		}
	}
	static function _locate_callable( $callable ) {
		// Check to see is this is a function name or an array representing a method call.
		if ( ! function_exists( $callable ) ) {
			foreach( array( 'WP_Url_Helpers' ) as $class ) {
				// Check to see if it happens to be a method is the WP_Url_Helpers class.
				if ( method_exists( $class, $callable ) ) {
					$callable = array( $class, $callable );
					break;
				}
			}
		}
		return $callable;
	}
}

/*
 * Class containing helper functions that can be used for lookups, etc.
 * There's a lot of work that can be done here, what's here is just a starting point.
 *
 * TODO: Add all helpers needed to route WordPress' standard URLs.
 *
*/
class WP_Url_Helpers {
	static function is_category_slug( $args ) {
		$term = get_term_by('slug', $args['this'], 'category' );
		return $term !== false;
	}
	static function is_post_in_category( $args ) {
		global $wpdb;
		$query = new WP_Query( array( 'name' => $args['this'] ) );
		$found = false;
		if ( $query->post_count == 1 ) {
			$category_slug = $args['parent'];
			$term = get_term_by('slug', $category_slug, 'category' );
			$sql = "SELECT ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.term_id = %d AND p.ID = %d";
			$found = $query->post->ID == $wpdb->get_var( $wpdb->prepare( $sql, $term->term_id, $query->post->ID ) );
		}
		return $found;
	}
	static function get_post_type_by_slug( $args ) {
		$post_type_archive_slugs = array_flip( self::get_post_type_archive_slug_list( $args ) );
		if ( ! isset($post_type_archive_slugs[ $args['this'] ]) )
			return false;
		else
			return $post_type_archive_slugs[ $args['this'] ];
	}
	static function get_page_list( $args ) { //TODO: Make this work with child pages and private posts or other ones that should match
		global $wpdb;
		static $parents = array();
		$parent_path = $args['parent'];
		if ( ! isset( $parents[$parent_path] ) ) {
			$parent_id = 0;
			if ( ! empty( $parent_path ) ) {
				$parts = explode( '/', $parent_path );
				foreach( $parts AS $index => $part ) {
					$sql = "SELECT ID FROM {$wpdb->posts} WHERE post_name='%s' AND post_parent=%d AND post_type='page' AND post_status='publish'";
					$parent_id = $wpdb->get_var( $wpdb->prepare( $sql, $part, $parent_id ) );
				}
			}
			$parents[ $parent_path ] = $parent_id;
		}
		// TODO: We will cache these after the logic is robust
		$sql = "SELECT post_name FROM {$wpdb->posts} WHERE post_parent={$parents[$parent_path]} AND post_type='page' AND post_status='publish'";
		$page_names = $wpdb->get_col($sql);
		return $page_names;
	}
	static function get_post_type_archive_slug_list( $args ) {
		global $wp_post_types;
		static $post_type_archive_slugs;
		if ( ! isset( $post_type_archive_slugs ) ) {
			$post_type_archive_slugs = array();
			foreach( array_keys( $wp_post_types ) as $post_type) {
				$slug = get_post_type_archive_link( $post_type );   // TODO: This is missing because of wp31
				if ( $slug ) {
					$slug = end( explode( '/', trim( $slug, '/' ) ) );
					$post_type_archive_slugs[$post_type] = $slug;
				}
			}
		}
		return $post_type_archive_slugs;
	}
	static function is_valid_page( $args ) { //TODO: Make this work with child pages and private posts or other ones that should match
		$page = get_page_by_path( $args['this'] );
		return ( is_object( $page ) );
	}
	static function is_valid_post_type_slug( $args ) {
		$post_type_archive_slugs = array_flip( self::get_post_type_archive_slug_list( $args ) );
		return isset( $post_type_archive_slugs[ $args['this'] ] );
	}
	static function is_valid_post( $args ) {
		global $wp_post_types;
		$is_valid = false;
		$post_type_slug = $args['parent'];
		if ( $post_type_slug = self::get_post_type_by_slug($post_type_slug)) {
			global $wpdb;
			$post_type_object = $wp_post_types[$post_type_slug];
			$sql = "SELECT COUNT(*) AS match_count FROM {$wpdb->posts} WHERE post_parent=0 AND post_type='{$post_type_slug}' AND post_name='%s' AND post_status='publish'";
			$sql = $wpdb->prepare($sql,$post_name);
			$match = $wpdb->get_var($sql);
			$is_valid = ($match>0);
		}
		return $is_valid;
	}
}

/*
 * Extends the WP class in WordPress and assigns an instance to the global $wp variable.
 * Notes: This is needed because WordPress does not (yet?) have a hook for $wp->parse_request() as proposed in trac ticket #XXXXX
*/
class WP_Urls_WP extends WP {
	static function on_load() {
		// 'setup_theme' is 1st hook run after WP is created.
		add_action( 'setup_theme', array( __CLASS__, 'setup_theme' ) );
		add_filter( 'template_include', array( __CLASS__, 'template_include' ) );
	}
	static function setup_theme() {
		global $wp;
		$wp = new WP_Urls_WP();  // Replace the global $wp
	}
	static function template_include( $template ) {
		if ( WP_DEBUG ) {
      //TODO: Come up with a better way to handle showing developers if routing matched or not.
			$result = WP_Urls::$result;
			echo "<div id=\"url-routing-results\">URL Routing Result: {$result}</div>";
		}
		return $template;
	}
	function parse_request( $extra_query_vars = '' ) {
		if ( apply_filters( 'wp_parse_request', false, $extra_query_vars ) ) {
			WP_Urls::$result = 'routed';
		} else {
			WP_Urls::$result = 'fallback';
			if ( WP_Urls::$fallback ) {
				parent::parse_request($extra_query_vars); // Delegate to WP class
			} else {
				wp_die( 'URL Routing failed.' );
			}
		}
		return;
	}
}
WP_Urls_WP::on_load();

/*
 * Register query variables so that we can associate meta data needed to route URLs.
 *
 * Attributes starting with '@' are  keys that should end up on the resultant $wp->query_vars
 *
 * TODO: Define all the remaining query variables built into WordPress
 */
function register_query_var( $var, $args = array() ) {
	global $wp_urls;

	if ( strpos( $var, '%' ) !== false )
		$var = str_replace( '%', '', $var );

	switch ( $var ) {
		case 'name':
			$defaults = array(
				'@inherit'      => true,          // WordPress does not (typically?) have any other query vars for posts
				'name'          => '%this%',
				//'page'          => '',          // This is what WordPress has for a /year/mon/day/post URL

			);
			break;

		case 'category_name':
			$defaults = array(
				'@validate'       => 'is_category_slug',
				'category_name'   => '%this%',
			);
			break;

		case 'pagename':
			$defaults = array(
				'@validate'       => 'is_valid_page',
				'@multi_segment'  => true,
				'page'            => '',          // This is match WordPress' behavior
				'pagename'        => '%this%',
			);
			break;

		case 'post_type_slug':
			$defaults = array(
				'@validate'       => 'is_valid_post_type_slug',
				'@get_list'       => 'get_post_type_archive_slug_list',
				'post_type'       => '%get_post_type_by_slug%',
			);
			break;

		case 'year':
			$defaults = array(
				'@regex'       => '([0-9]{4})',
				'year'          => '%this%',
				'post_type'     => 'post',
			);
			break;
	}

	if ( count( $args ) == 0 )
		$args = $defaults;
	else
		$args = wp_parse_args( $args, $defaults );

	$wp_urls['query_vars']["%{$var}%"] = &$args;

	return $args;
}
/*
 * Register a complete URL path using permalink structure format, i.e.
 *
 *   '%category_name%/%name%'
 *
 * TODO: Define all the remaining url paths built into WordPress
 *
 */
function register_url_path( $path, $query_vars = array() ) {
	global $wp_urls;

	// Register the query vars for this path.
	__register_query_vars_from_path( $path );

	// Split URL path on '/' into 'path segments'
	$path_segments = explode( '/', trim( $path, '/' ) );

	// Create a local reference to the anchoring 'root' URL
	$root = &$wp_urls['root'];

	// For each path segment
	$parent_path = array();
	foreach( $path_segments as $path_segment ) {

		$parent_path[] = $path_segment; // Grab this for defining parent path of subnodes

		// Grab the query predefined for this path segment, assuming it is full a query_var
		$this_path_segment_query_vars = isset( $wp_urls['query_vars'][$path_segment] ) ? $wp_urls['query_vars'][$path_segment] : array();

		// If this post segment for this URL route does not already has some query vars defined
		if ( ! isset ( $root->child_segments[$path_segment] ) ) {
			// Set them.
			$root->child_segments[$path_segment] = new WP_Url_Node( $this_path_segment_query_vars );

		}

		// Set the child to be the root and continue down the URL path tree.
		$root = &$root->child_segments[$path_segment];

	}

	// For the last path segment, merge the path segment's default query vars with the ones passed to this function taking the passed ones as prioprity.
	$query_vars = array_merge( $this_path_segment_query_vars, $query_vars );

	// Now look for the default query vars for this path, merge them with the ones passed to this function taking the passed ones as priority.
	$root->query_vars = __register_url_path( $path, $query_vars );

	// Finally set the query_vars for this path as well as for this segment.
	$wp_urls['paths'][$path] = $root->query_vars;
}
/*
 * Define the metadata for every pre-defined path so that these routes can be specified simply like this:
 *
 *   register_url_path( '%category_name%/%name%' );
 *
 * This function will also provides a roadmap for hwo to define custom URL routes.
 *
 */
function __register_url_path( $path, $query_vars = array() ) {

	switch ( $path ) {

		case '%category_name%/%name%':
			$defaults = array(
				'@validate'     => 'is_post_in_category',
				'@inherit'      => true,
			);
			break;

		case '%post_type_slug%/%name%':
			$defaults = array(
				'@validate'     => 'is_valid_post',
				'@get_list'     => 'get_post_name_list',
			);
			break;

		case '%year%':
			$defaults = array(
				'@regex'      => '[0..9]{4}',
				'year'          => '%this%',
				'post_type'     => 'post',
			);
			break;

		case '%year%/%monthnum%':
			$defaults = array(
				'@regex'      => '(0[1..9]|[1][0..2])',
				'monthnum'      => '%this%',
			);
			break;

		case '%year%/%monthnum%/%day%':
			$defaults = array(
				'@validate'     => 'is_valid_day', //TODO: Can we make this work, using the query vars?
				'day'           => '%this%',
			);
			break;

		case '%year%/%monthnum%/%day%/%name%':   // TODO: Allow cumulative, but allow clearing/resetting of prior vars
			$defaults = array(
				'@validate'     => 'is_valid_post_for_date', //TODO: Can we make this work, using the query vars?
				'post_name'     => '%this%',
			);
			break;

		case '%pagename%':
			$defaults = array(
				'@multi_segment'=> true,
		  );
			break;

		case 'robots.txt':
			$defaults = array(
				'robots'        => 1,
			);
			break;

	}

	if ( count( $query_vars ) == 0 )
		$query_vars = $defaults;
	else
		$query_vars = array_merge( $defaults, $query_vars );

	return $query_vars;
}

/*
 * Take variables found in a path like '%category_name%/%name%'
 * and register them as query variables if not already registered.
 *
 */
function __register_query_vars_from_path( $path ) {
	global $wp_urls;
	preg_match_all( '#%([^%])+%#', $path, $matches, PREG_SET_ORDER );
	foreach( $matches as $match ) {
		if ( ! isset( $wp_urls[$match[0]] ) ) // Only register if not already registered. Why do twice?
			register_query_var( $match[0] );
	}
}

/*
 * Expansion variables are double percent quoted that can expand,
 * i.e. a '%%date_path%%' might be expanded to '%year%/%month%/%day%'
 *
 * TODO: Implement this
 *
 */
function register_expansion_var( $var, $args = array() ) {
	global $wp_urls;
	$wp_urls['expansion_vars'][$var] = $args;
}

/*
 * Define OMIT_URL_ROUTES_TEST_CONFIG if you want to omit this test configuration.
 */
if ( ! defined( 'OMIT_URL_ROUTES_TEST_CONFIG') ) {
	add_action( 'init', '_wp_url_routes_test_config' );
	function _wp_url_routes_test_config() {
		register_url_path( '%category_name%/%name%' );
		register_url_path( '%pagename%' );
	}
}
