<?php
/**
 * Slim - a micro PHP 5 framework
 *
 * @author      Josh Lockhart
 * @link        http://www.slimframework.com
 * @copyright   2011 Josh Lockhart
 *
 * MIT LICENSE
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
 * "Software"), to deal in the Software without restriction, including
 * without limitation the rights to use, copy, modify, merge, publish,
 * distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to
 * the following conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

/**
 * Route
 *
 * @package Slim
 * @author  Josh Lockhart <info@joshlockhart.com>
 * @since   Version 1.0
 */
class Slim_Route {

    /**
     * @var string The route pattern (ie. "/books/:id")
     */
    protected $pattern;

    /**
     * @var mixed The callable associated with this route
     */
    protected $callable;

    /**
     * @var array Conditions for this route's URL parameters
     */
    protected $conditions = array();

    /**
     * @var array Default conditions applied to all Route instances
     */
    protected static $defaultConditions = array();

    /**
     * @var string The name of this route (optional)
     */
    protected $name;

    /**
     * @var array Key-value array of URL parameters
     */
    protected $params = array();

    /**
     * @var Slim_Router The Router to which this Route belongs
     */
    protected $router;

    /**
     * @var array[Callable] Middleware
     */
    protected $middleware = array();

    /**
     * Constructor
     *
     * @param   string  $pattern    The URL pattern (ie. "/books/:id")
     * @param   mixed   $callable   Anything that returns TRUE for is_callable()
     */
    public function __construct( $pattern, $callable ) {
        $this->setPattern($pattern);
        $this->setCallable($callable);
        $this->setConditions(self::getDefaultConditions());
    }

    /***** CLASS METHODS *****/

    /**
     * Set default route conditions for all instances
     *
     * @param   array $defaultConditions
     * @return  void
     */
    public static function setDefaultConditions( array $defaultConditions ) {
        self::$defaultConditions = $defaultConditions;
    }

    /**
     * Get default route conditions for all instances
     *
     * @return array
     */
    public static function getDefaultConditions() {
        return self::$defaultConditions;
    }

    /***** INSTANCE ACCESSORS *****/

    /**
     * Get route pattern
     *
     * @return string
     */
    public function getPattern() {
        return $this->pattern;
    }

    /**
     * Set route pattern
     *
     * @param   string $pattern
     * @return  void
     */
    public function setPattern( $pattern ) {
        $this->pattern = str_replace(')', ')?', (string)$pattern);
    }

    /**
     * Get route callable
     *
     * @return mixed
     */
    public function getCallable() {
        return $this->callable;
    }

    /**
     * Set route callable
     *
     * @param   mixed $callable
     * @return  void
     */
    public function setCallable($callable) {
        $this->callable = $callable;
    }

    /**
     * Get route conditions
     *
     * @return array
     */
    public function getConditions() {
        return $this->conditions;
    }

    /**
     * Set route conditions
     *
     * @param   array $conditions
     * @return  void
     */
    public function setConditions( array $conditions ) {
        $this->conditions = $conditions;
    }

    /**
     * Get route name
     *
     * @return string|null
     */
    public function getName() {
        return $this->name;
    }

    /**
     * Set route name
     *
     * @param   string $name
     * @return  void
     */
    public function setName( $name ) {
        $this->name = (string)$name;
        $this->getRouter()->cacheNamedRoute($name, $this);
    }

    /**
     * Get route parameters
     *
     * @return array
     */
    public function getParams() {
        return $this->params;
    }

    /**
     * Get router
     *
     * @return Slim_Router
     */
    public function getRouter() {
        return $this->router;
    }

    /**
     * Set router
     *
     * @param   Slim_Router $router
     * @return  void
     */
    public function setRouter( Slim_Router $router ) {
        $this->router = $router;
    }

    /**
     * Get middleware
     *
     * @return array[Callable]
     */
    public function getMiddleware() {
        return $this->middleware;
    }

    /**
     * Set middleware
     *
     * This method allows middleware to be assigned to a specific Route.
     * If the method argument `is_callable` (including callable arrays!),
     * we directly append the argument to `$this->middleware`. Else, we
     * assume the argument is an array of callables and merge the array
     * with `$this->middleware`. Even if non-callables are included in the
     * argument array, we still merge them; we lazily check each item
     * against `is_callable` during Route::dispatch().
     *
     * @param   Callable|array[Callable]
     * @return  Slim_Route
     * @throws  InvalidArgumentException If argument is not callable or not an array
     */
    public function setMiddleware( $middleware ) {
        if ( is_callable($middleware) ) {
            $this->middleware[] = $middleware;
        } else if ( is_array($middleware) ) {
            $this->middleware = array_merge($this->middleware, $middleware);
        } else {
            throw new InvalidArgumentException('Route middleware must be callable or an array of callables');
        }
        return $this;
    }

    /***** ROUTE PARSING AND MATCHING *****/

    /**
     * Matches URI?
     *
     * Parse this route's pattern, and then compare it to an HTTP resource URI
     * This method was modeled after the techniques demonstrated by Dan Sosedoff at:
     *
     * http://blog.sosedoff.com/2009/09/20/rails-like-php-url-router/
     *
     * @param   string  $resourceUri A Request URI
     * @return  bool
     */
    public function matches( $resourceUri ) {

        //Extract URL params
        preg_match_all('@:([\w]+)@', $this->getPattern(), $paramNames, PREG_PATTERN_ORDER);
        $paramNames = $paramNames[0];

        //Convert URL params into regex patterns, construct a regex for this route
        $patternAsRegex = preg_replace_callback('@:[\w]+@', array($this, 'convertPatternToRegex'), $this->getPattern());
        if ( substr($this->getPattern(), -1) === '/' ) {
            $patternAsRegex = $patternAsRegex . '?';
        }
        $patternAsRegex = '@^' . $patternAsRegex . '$@';

        //Cache URL params' names and values if this route matches the current HTTP request
        if ( preg_match($patternAsRegex, $resourceUri, $paramValues) ) {
            array_shift($paramValues);
            foreach ( $paramNames as $index => $value ) {
                $val = substr($value, 1);
                if ( isset($paramValues[$val]) ) {
                    $this->params[$val] = urldecode($paramValues[$val]);
                }
            }
            return true;
        } else {
            return false;
        }

    }

    /**
     * Convert a URL parameter (ie. ":id") into a regular expression
     *
     * @param   array   URL parameters
     * @return  string  Regular expression for URL parameter
     */
    private function convertPatternToRegex( $matches ) {
        $key = str_replace(':', '', $matches[0]);
        if ( array_key_exists($key, $this->conditions) ) {
            return '(?P<' . $key . '>' . $this->conditions[$key] . ')';
        } else {
            return '(?P<' . $key . '>[a-zA-Z0-9_\-\.\!\~\*\\\'\(\)\:\@\&\=\$\+,%]+)';
        }
    }

    /***** HELPERS *****/

    /**
     * Set route name
     *
     * @param   string $name The name of the route
     * @return  Route
     */
    public function name( $name ) {
        $this->setName($name);
        return $this;
    }

    /**
     * Merge route conditions
     *
     * @param   array $conditions Key-value array of URL parameter conditions
     * @return  Route
     */
    public function conditions( array $conditions ) {
        $this->conditions = array_merge($this->conditions, $conditions);
        return $this;
    }

    /***** DISPATCHING *****/

    /**
     * Dispatch route
     *
     * This method invokes the this route's callable. If middleware is
     * registered for this route, each callable middleware is invoked in
     *  the order specified.
     *
     * This method is smart about trailing slashes on the route pattern. 
     * If this route's pattern is defined with a trailing slash, and if the 
     * current request URI does not have a trailing slash but otherwise 
     * matches this route's pattern, a SlimRequestSlashException
     * will be thrown triggering an HTTP 301 Permanent Redirect to the same 
     * URI _with_ a trailing slash. This Exception is caught in the 
     * `Slim::run` loop. If this route's pattern is defined without a 
     * trailing slash, and if the current request URI does have a trailing 
     * slash, this route will not be matched and a 404 Not Found
     * response will be sent if no subsequent matching routes are found.
     *
     * @return  bool Was route callable invoked successfully?
     * @throws  SlimRequestSlashException
     */
    public function dispatch() {
        if ( substr($this->getPattern(), -1) === '/' && substr($this->getRouter()->getRequest()->getResourceUri(), -1) !== '/' ) {
            throw new Slim_Exception_RequestSlash();
        }
        //Invoke middleware
        foreach ( $this->middleware as $mw ) {
            if ( is_callable($mw) ) {
                call_user_func($mw);
            }
        }
        //Invoke callable
        if ( is_callable($this->getCallable()) ) {
            call_user_func_array($this->getCallable(), array_values($this->getParams()));
            return true;
        }
        return false;
    }

}
?>