<?php
/**
 * Horde Routes package
 *
 * This package is heavily inspired by the Python "Routes" library
 * by Ben Bangert (http://routes.groovie.org).  Routes is based
 * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
 *
 * @author  Maintainable Software, LLC. (http://www.maintainable.com)
 * @author  Mike Naberezny <mike@maintainable.com>
 * @license http://www.horde.org/licenses/bsd BSD
 * @package Routes
 */

/**
 * The Route object holds a route recognition and generation routine.
 * See __construct() docs for usage.
 *
 * @package Routes
 */
class Horde_Routes_Route
{
    /**
     * The path for this route, such as ':controller/:action/:id'
     * @var string
     */
    public $routePath;

    /**
     * Encoding of this route (not yet supported)
     * @var string
     */
    public $encoding = 'utf-8';

    /**
     * What to do on decoding errors?  'ignore' or 'replace'
     * @var string
     */
    public $decodeErrors = 'replace';

    /**
     * Is this a static route?
     * @var string
     */
    public $static;

    /**
     * Filter function to operate on arguments before generation
     * @var callback
     */
    public $filter;

    /**
     * Is this an absolute path?  (Mapper will not prepend SCRIPT_NAME)
     * @var boolean
     */
    public $absolute;

    /**
     * Does this route use explicit mode (no implicit defaults)?
     * @var boolean
     */
    public $explicit;

    /**
     * Default keyword arguments for this route
     * @var array
     */
    public $defaults = array();

    /**
     * Array of keyword args for special conditions (method, subDomain, function)
     * @var array
     */
    public $conditions;

    /**
     * Maximum keys that this route could utilize.
     * @var array
     */
    public $maxKeys;

    /**
     * Minimum keys required to generate this route
     * @var array
     */
    public $minKeys;

    /**
     * Default keywords that don't exist in the path; can't be changed by an incomng URL.
     * @var array
     */
    public $hardCoded;

    /**
     * Requirements for this route
     * @var array
     */
    public $reqs;

    /**
     * Regular expression for matching this route
     * @var string
     */
    public $regexp;

    /**
     * Route path split by '/'
     * @var array
     */
    protected $_routeList;

    /**
     * Reverse of $routeList
     * @var array
     */
    protected $_routeBackwards;

    /**
     * Characters that split the parts of a URL
     * @var array
     */
    protected $_splitChars;

    /**
     * Last path part used by buildNextReg()
     * @var string
     */
    protected $_prior;

    /**
     * Requirements formatted as regexps suitable for preg_match()
     * @var array
     */
    protected $_reqRegs;

    /**
     * Member name if this is a RESTful route
     * @see resource()
     * @var null|string
     */
    protected $_memberName;

    /**
     * Collection name if this is a RESTful route
     * @see resource()
     * @var null|string
     */
    protected $_collectionName;

    /**
     * Name of the parent resource, if this is a RESTful route & has a parent
     * @see resource
     * @var string
     */
    protected $_parentResource;


    /**
     *  Initialize a route, with a given routepath for matching/generation
     *
     *  The set of keyword args will be used as defaults.
     *
     *  Usage:
     *      $route = new Horde_Routes_Route(':controller/:action/:id');
     *
     *      $route = new Horde_Routes_Route('date/:year/:month/:day',
     *                      array('controller'=>'blog', 'action'=>'view'));
     *
     *      $route = new Horde_Routes_Route('archives/:page',
     *                      array('controller'=>'blog', 'action'=>'by_page',
     *                            'requirements' => array('page'=>'\d{1,2}'));
     *
     *  Note:
     *      Route is generally not called directly, a Mapper instance connect()
     *      method should be used to add routes.
     */
    public function __construct($routePath, $kargs = array())
    {
        $this->routePath = $routePath;

        // Don't bother forming stuff we don't need if its a static route
        $this->static = isset($kargs['_static']) ? $kargs['_static'] : false;

        $this->filter = isset($kargs['_filter']) ? $kargs['_filter'] : null;
        unset($kargs['_filter']);

        $this->absolute = isset($kargs['_absolute']) ? $kargs['_absolute'] : false;
        unset($kargs['_absolute']);

        // Pull out the member/collection name if present, this applies only to
        // map.resource
        $this->_memberName = isset($kargs['_memberName']) ? $kargs['_memberName'] : null;
        unset($kargs['_memberName']);

        $this->_collectionName = isset($kargs['_collectionName']) ? $kargs['_collectionName'] : null;
        unset($kargs['_collectionName']);

        $this->_parentResource = isset($kargs['_parentResource']) ? $kargs['_parentResource'] : null;
        unset($kargs['_parentResource']);

        // Pull out route conditions
        $this->conditions = isset($kargs['conditions']) ? $kargs['conditions'] : null;
        unset($kargs['conditions']);

        // Determine if explicit behavior should be used
        $this->explicit = isset($kargs['_explicit']) ? $kargs['_explicit'] : false;
        unset($kargs['_explicit']);

        // Reserved keys that don't count
        $reservedKeys = array('requirements');

        // Name has been changed from the Python version
        // This is a list of characters natural splitters in a URL
        $this->_splitChars = array('/', ',', ';', '.', '#');

        // trim preceding '/' if present
        if (substr($this->routePath, 0, 1) == '/') {
            $routePath = substr($this->routePath, 1);
        }

        // Build our routelist, and the keys used in the route
        $this->_routeList = $this->_pathKeys($routePath);
        $routeKeys = array();
        foreach ($this->_routeList as $key) {
            if (is_array($key)) { $routeKeys[] = $key['name']; }
        }

        // Build a req list with all the regexp requirements for our args
        $this->reqs = isset($kargs['requirements']) ? $kargs['requirements'] : array();
        $this->_reqRegs = array();
        foreach ($this->reqs as $key => $value) {
            $this->_reqRegs[$key] = '@^' . str_replace('@', '\@', $value) . '$@';
        }

        // Update our defaults and set new default keys if needed. defaults
        // needs to be saved
        list($this->defaults, $defaultKeys) = $this->_defaults($routeKeys, $reservedKeys, $kargs);

        // Save the maximum keys we could utilize
        $this->maxKeys = array_keys(array_flip(array_merge($defaultKeys, $routeKeys)));
        list($this->minKeys, $this->_routeBackwards) = $this->_minKeys($this->_routeList);

        // Populate our hardcoded keys, these are ones that are set and don't
        // exist in the route
        $this->hardCoded = array();
        foreach ($this->maxKeys as $key) {
            if (!in_array($key, $routeKeys) && $this->defaults[$key] != null) {
                $this->hardCoded[] = $key;
            }
        }
    }

    /**
     * Utility method to walk the route, and pull out the valid
     * dynamic/wildcard keys
     *
     * @param  string  $routePath  Route path
     * @return array               Route list
     */
    protected function _pathKeys($routePath)
    {
        $collecting = false;
        $current = '';
        $doneOn = array();
        $varType = '';
        $justStarted = false;
        $routeList = array();

        foreach (preg_split('//', $routePath, -1, PREG_SPLIT_NO_EMPTY) as $char) {
            if (!$collecting && in_array($char, array(':', '*'))) {
                $justStarted = true;
                $collecting = true;
                $varType = $char;
                if (strlen($current) > 0) {
                   $routeList[] = $current;
                   $current = '';
                }
            } elseif ($collecting && $justStarted) {
                $justStarted = false;
                if ($char == '(') {
                    $doneOn = array(')');
                } else {
                    $current = $char;
                    // Basically appends '-' to _splitChars
                    // Helps it fall in line with the Python idioms.
                    $doneOn = $this->_splitChars + array('-');
                }
            } elseif ($collecting && !in_array($char, $doneOn)) {
                $current .= $char;
            } elseif ($collecting) {
                $collecting = false;
                $routeList[] = array('type' => $varType, 'name' => $current);
                if (in_array($char, $this->_splitChars)) {
                    $routeList[] = $char;
                }
                $doneOn = $varType = $current = '';
            } else {
                $current .= $char;
            }
        }
        if ($collecting) {
            $routeList[] = array('type' => $varType, 'name' => $current);
        } elseif (!empty($current)) {
            $routeList[] = $current;
        }
        return $routeList;
    }

    /**
     * Utility function to walk the route backwards
     *
     * Will determine the minimum keys we must have to generate a
     * working route.
     *
     * @param  array  $routeList  Route path split by '/'
     * @return array              [minimum keys for route, route list reversed]
     */
    protected function _minKeys($routeList)
    {
        $minKeys = array();
        $backCheck = array_reverse($routeList);
        $gaps = false;
        foreach ($backCheck as $part) {
            if (!is_array($part) && !in_array($part, $this->_splitChars)) {
                $gaps = true;
                continue;
            } elseif (!is_array($part)) {
                continue;
            }
            $key = $part['name'];
            if (array_key_exists($key, $this->defaults) && !$gaps)
                continue;
            $minKeys[] = $key;
            $gaps = true;
        }
        return array($minKeys, $backCheck);
    }

    /**
     * Creates a default array of strings
     *
     * Puts together the array of defaults, turns non-null values to strings,
     * and add in our action/id default if they use and do not specify it
     *
     * Precondition: $this->_defaultKeys is an array of the currently assumed default keys
     *
     * @param  array  $routekeys     All the keys found in the route path
     * @param  array  $reservedKeys  Array of keys not in the route path
     * @param  array  $kargs         Keyword args passed to the Route constructor
     * @return array                 [defaults, new default keys]
     */
    protected function _defaults($routeKeys, $reservedKeys, $kargs)
    {
        $defaults = array();

        // Add in a controller/action default if they don't exist
        if ((!in_array('controller', $routeKeys)) &&
            (!in_array('controller', array_keys($kargs))) &&
            (!$this->explicit)) {
            $kargs['controller'] = 'content';
        }

        if (!in_array('action', $routeKeys) &&
            (!in_array('action', array_keys($kargs))) &&
            (!$this->explicit)) {
            $kargs['action'] = 'index';
        }

        $defaultKeys = array();
        foreach (array_keys($kargs) as $key) {
            if (!in_array($key, $reservedKeys)) {
                $defaultKeys[] = $key;
            }
        }

        foreach ($defaultKeys as $key) {
            if ($kargs[$key] !== null) {
                $defaults[$key] = (string)$kargs[$key];
            } else {
                $defaults[$key] = null;
            }
        }

        if (in_array('action', $routeKeys) &&
            (!array_key_exists('action', $defaults)) &&
            (!$this->explicit)) {
            $defaults['action'] = 'index';
        }

        if (in_array('id', $routeKeys) &&
            (!array_key_exists('id', $defaults)) &&
            (!$this->explicit)) {
            $defaults['id'] = null;
        }

        $newDefaultKeys = array();
        foreach (array_keys($defaults) as $key) {
            if (!in_array($key, $reservedKeys)) {
                $newDefaultKeys[] = $key;
            }
        }
        return array($defaults, $newDefaultKeys);
    }

    /**
     * Create the regular expression for matching.
     *
     * Note: This MUST be called before match can function properly.
     *
     * clist should be a list of valid controller strings that can be
     * matched, for this reason makeregexp should be called by the web
     * framework after it knows all available controllers that can be
     * utilized.
     *
     * @param  array  $clist  List of all possible controllers
     * @return void
     */
    public function makeRegexp($clist)
    {
        list($reg, $noreqs, $allblank) = $this->buildNextReg($this->_routeList, $clist);

        if (empty($reg)) {
            $reg = '/';
        }
        $reg = $reg . '(/)?$';
        if (substr($reg, 0, 1) != '/') {
            $reg = '/' . $reg;
        }
        $reg = '^' . $reg;

        $this->regexp = $reg;
    }

    /**
     * Recursively build a regexp given a path, and a controller list.
     *
     * Returns the regular expression string, and two booleans that can be
     * ignored as they're only used internally by buildnextreg.
     *
     * @param  array  $path   The RouteList for the path
     * @param  array  $clist  List of all possible controllers
     * @return array          [array, boolean, boolean]
     */
    public function buildNextReg($path, $clist)
    {
        if (!empty($path)) {
            $part = $path[0];
        } else {
            $part = '';
        }

        // noreqs will remember whether the remainder has either a string
        // match, or a non-defaulted regexp match on a key, allblank remembers
        // if the rest could possible be completely empty
        list($rest, $noreqs, $allblank) = array('', true, true);

        if (count($path) > 1) {
            $this->_prior = $part;
            list($rest, $noreqs, $allblank) = $this->buildNextReg(array_slice($path, 1), $clist);
        }

        if (is_array($part) && $part['type'] == ':') {
            $var = $part['name'];
            $partreg = '';

            // First we plug in the proper part matcher
            if (array_key_exists($var, $this->reqs)) {
                $partreg = '(?P<' . $var . '>' . $this->reqs[$var] . ')';
            } elseif ($var == 'controller') {
                $partreg = '(?P<' . $var . '>' . implode('|', array_map('preg_quote', $clist)) . ')';
            } elseif (in_array($this->_prior, array('/', '#'))) {
                $partreg = '(?P<' . $var . '>[^' . $this->_prior . ']+?)';
            } else {
                if (empty($rest)) {
                    $partreg = '(?P<' . $var . '>[^/]+?)';
                } else {
                    $partreg = '(?P<' . $var . '>[^' . implode('', $this->_splitChars) . ']+?)';
                }
            }

            if (array_key_exists($var, $this->reqs)) {
                $noreqs = false;
            }
            if (!array_key_exists($var, $this->defaults)) {
                $allblank = false;
                $noreqs = false;
            }

            // Now we determine if its optional, or required. This changes
            // depending on what is in the rest of the match. If noreqs is
            // true, then its possible the entire thing is optional as there's
            // no reqs or string matches.
            if ($noreqs) {
                // The rest is optional, but now we have an optional with a
                // regexp. Wrap to ensure that if we match anything, we match
                // our regexp first. It's still possible we could be completely
                // blank as we have a default
                if (array_key_exists($var, $this->reqs) && array_key_exists($var, $this->defaults)) {
                    $reg = '(' . $partreg . $rest . ')?';

                // Or we have a regexp match with no default, so now being
                // completely blank form here on out isn't possible
                } elseif (array_key_exists($var, $this->reqs)) {
                    $allblank = false;
                    $reg = $partreg . $rest;

                // If the character before this is a special char, it has to be
                // followed by this
                } elseif (array_key_exists($var, $this->defaults) && in_array($this->_prior, array(',', ';', '.'))) {
                    $reg = $partreg . $rest;

                // Or we have a default with no regexp, don't touch the allblank
                } elseif (array_key_exists($var, $this->defaults)) {
                    $reg = $partreg . '?' . $rest;

                // Or we have a key with no default, and no reqs. Not possible
                // to be all blank from here
                } else {
                    $allblank = false;
                    $reg = $partreg . $rest;
                }

            // In this case, we have something dangling that might need to be
            // matched
            } else {
                // If they can all be blank, and we have a default here, we know
                // its safe to make everything from here optional. Since
                // something else in the chain does have req's though, we have
                // to make the partreg here required to continue matching
                if ($allblank && array_key_exists($var, $this->defaults)) {
                    $reg = '(' . $partreg . $rest . ')?';

                // Same as before, but they can't all be blank, so we have to
                // require it all to ensure our matches line up right
                } else {
                    $reg = $partreg . $rest;
                }
            }
        } elseif (is_array($part) && $part['type'] == '*') {
            $var = $part['name'];
            if ($noreqs) {
                $reg = '(?P<' . $var . '>.*)' . $rest;
                if (!array_key_exists($var, $this->defaults)) {
                    $allblank = false;
                    $noreqs = false;
                }
            } else {
                if ($allblank && array_key_exists($var, $this->defaults)) {
                    $reg = '(?P<' . $var . '>.*)' . $rest;
                } elseif (array_key_exists($var, $this->defaults)) {
                    $reg = '(?P<' . $var . '>.*)' . $rest;
                } else {
                    $allblank = false;
                    $noreqs = false;
                    $reg = '(?P<' . $var . '>.*)' . $rest;
                }
            }
        } elseif ($part && in_array(substr($part, -1), $this->_splitChars)) {
            if ($allblank) {
                $reg = preg_quote(substr($part, 0, -1)) . '(' . preg_quote(substr($part, -1)) . $rest . ')?';
            } else {
                $allblank = false;
                $reg = preg_quote($part) . $rest;
            }

        // We have a normal string here, this is a req, and it prevents us from
        // being all blank
        } else {
            $noreqs = false;
            $allblank = false;
            $reg = preg_quote($part) . $rest;
        }

        return array($reg, $noreqs, $allblank);
    }

    /**
     * Match a url to our regexp.
     *
     * While the regexp might match, this operation isn't
     * guaranteed as there's other factors that can cause a match to fail
     * even though the regexp succeeds (Default that was relied on wasn't
     * given, requirement regexp doesn't pass, etc.).
     *
     * Therefore the calling function shouldn't assume this will return a
     * valid dict, the other possible return is False if a match doesn't work
     * out.
     *
     * @param  string  $url  URL to match
     * @param  array         Keyword arguments
     * @return null|array    Array of match data if matched, Null otherwise
     */
    public function match($url, $kargs = array())
    {
        $defaultKargs = array('environ'          => array(),
                              'subDomains'       => false,
                              'subDomainsIgnore' => array(),
                              'domainMatch'      => '');
        $kargs = array_merge($defaultKargs, $kargs);

        // Static routes don't match, they generate only
        if ($this->static) {
            return false;
        }

        if (substr($url, -1) == '/' && strlen($url) > 1) {
            $url = substr($url, 0, -1);
        }

        // Match the regexps we generated
        $match = preg_match('@' . str_replace('@', '\@', $this->regexp) . '@', $url, $matches);
        if ($match == 0) {
            return false;
        }

        $host = isset($kargs['environ']['HTTP_HOST']) ? $kargs['environ']['HTTP_HOST'] : null;
        if ($host !== null && !empty($kargs['subDomains'])) {
            $host = substr($host, 0, strpos(':', $host));
            $subMatch = '@^(.+?)\.' . $kargs['domainMatch'] . '$';
            $subdomain = preg_replace($subMatch, '$1', $host);
            if (!in_array($subdomain, $kargs['subDomainsIgnore']) && $host != $subdomain) {
                $subDomain = $subdomain;
            }
        }

        if (!empty($this->conditions)) {
            if (isset($this->conditions['method'])) {
                if (empty($kargs['environ']['REQUEST_METHOD'])) { return false; }

                if (!in_array($kargs['environ']['REQUEST_METHOD'], $this->conditions['method'])) {
                    return false;
                }
            }

            // Check sub-domains?
            $use_sd = isset($this->conditions['subDomain']) ? $this->conditions['subDomain'] : null;
            if (!empty($use_sd) && empty($subDomain)) {
                return false;
            }
            if (is_array($use_sd) && !in_array($subDomain, $use_sd)) {
                return false;
            }
        }
        $matchDict = $matches;

        // Clear out int keys as PHP gives us both the named subgroups and numbered subgroups
        foreach ($matchDict as $key => $val) {
            if (is_int($key)) {
                unset($matchDict[$key]);
            }
        }
        $result = array();
        $extras = Horde_Routes_Utils::arraySubtract(array_keys($this->defaults), array_keys($matchDict));

        foreach ($matchDict as $key => $val) {
            // TODO: character set decoding
            if ($key != 'path_info' && $this->encoding) {
                $val = urldecode($val);
            }

            if (empty($val) && array_key_exists($key, $this->defaults) && !empty($this->defaults[$key])) {
                $result[$key] = $this->defaults[$key];
            } else {
                $result[$key] = $val;
            }
        }

        foreach ($extras as $key) {
            $result[$key] = $this->defaults[$key];
        }

        // Add the sub-domain if there is one
        if (!empty($kargs['subDomains'])) {
            $result['subDomain'] = $subDomain;
        }

        // If there's a function, call it with environ and expire if it
        // returns False
        if (!empty($this->conditions) && array_key_exists('function', $this->conditions) &&
            !call_user_func_array($this->conditions['function'], array($kargs['environ'], $result))) {
            return false;
        }

        return $result;
    }

    /**
     * Generate a URL from ourself given a set of keyword arguments
     *
     * @param  array  $kargs   Keyword arguments
     * @param  null|string     Null if generation failed, URL otherwise
     */
    public function generate($kargs)
    {
        $defaultKargs = array('_ignoreReqList' => false,
                              '_appendSlash'   => false);
        $kargs = array_merge($defaultKargs, $kargs);

        $_appendSlash = $kargs['_appendSlash'];
        unset($kargs['_appendSlash']);

        $_ignoreReqList = $kargs['_ignoreReqList'];
        unset($kargs['_ignoreReqList']);

        // Verify that our args pass any regexp requirements
        if (!$_ignoreReqList) {
            foreach ($this->reqs as $key => $v) {
                $value = (isset($kargs[$key])) ? $kargs[$key] : null;

                if (!empty($value) && !preg_match($this->_reqRegs[$key], $value)) {
                    return null;
                }
            }
        }

        // Verify that if we have a method arg, it's in the method accept list.
        // Also, method will be changed to _method for route generation.
        $meth = (isset($kargs['method'])) ? $kargs['method'] : null;

        if ($meth) {
            if ($this->conditions && isset($this->conditions['method']) &&
                (!in_array(Horde_String::upper($meth), $this->conditions['method']))) {

                return null;
            }
            unset($kargs['method']);
        }

        $routeList = $this->_routeBackwards;
        $urlList = array();
        $gaps = false;
        foreach ($routeList as $part) {
            if (is_array($part) && $part['type'] == ':') {
                $arg = $part['name'];

                // For efficiency, check these just once
                $hasArg = array_key_exists($arg, $kargs);
                $hasDefault = array_key_exists($arg, $this->defaults);

                // Determine if we can leave this part off
                // First check if the default exists and wasn't provided in the
                // call (also no gaps)
                if ($hasDefault && !$hasArg && !$gaps) {
                    continue;
                }

                // Now check to see if there's a default and it matches the
                // incoming call arg
                if (($hasDefault && $hasArg) && $kargs[$arg] == $this->defaults[$arg] && !$gaps) {
                    continue;
                }

                // We need to pull the value to append, if the arg is NULL and
                // we have a default, use that
                if ($hasArg && $kargs[$arg] === null && $hasDefault && !$gaps) {
                    continue;

                // Otherwise if we do have an arg, use that
                } elseif ($hasArg) {
                    $val = ($kargs[$arg] === null) ? 'null' : $kargs[$arg];
                } elseif ($hasDefault && $this->defaults[$arg] != null) {
                    $val = $this->defaults[$arg];

                // No arg at all? This won't work
                } else {
                    return null;
                }

                $urlList[] = Horde_Routes_Utils::urlQuote($val, $this->encoding);
                if ($hasArg) {
                    unset($kargs[$arg]);
                }
                $gaps = true;
            } elseif (is_array($part) && $part['type'] == '*') {
                $arg = $part['name'];
                $kar = (isset($kargs[$arg])) ? $kargs[$arg] : null;
                if ($kar != null) {
                    $urlList[] = Horde_Routes_Utils::urlQuote($kar, $this->encoding);
                    $gaps = true;
                }
            } elseif (!empty($part) && in_array(substr($part, -1), $this->_splitChars)) {
                if (!$gaps && in_array($part, $this->_splitChars)) {
                    continue;
                } elseif (!$gaps) {
                    $gaps = true;
                    $urlList[] = substr($part, 0, -1);
                } else {
                    $gaps = true;
                    $urlList[] = $part;
                }
            } else {
                $gaps = true;
                $urlList[] = $part;
            }
        }

        $urlList = array_reverse($urlList);
        $url = implode('', $urlList);
        if (substr($url, 0, 1) != '/') {
            $url = '/' . $url;
        }

        $extras = $kargs;
        foreach ($this->maxKeys as $key) {
            unset($extras[$key]);
        }
        $extras = array_keys($extras);

        if (!empty($extras)) {
            if ($_appendSlash && substr($url, -1) != '/') {
                $url .= '/';
            }
            $url .= '?';
            $newExtras = array();
            foreach ($kargs as $key => $value) {
                if (in_array($key, $extras) && ($key != 'action' || $key != 'controller')) {
                    $newExtras[$key] = $value;
                }
            }
            $url .= http_build_query($newExtras);
        } elseif ($_appendSlash && substr($url, -1) != '/') {
            $url .= '/';
        }
        return $url;
    }

}
