<?php
namespace Luracast\Restler;

use Luracast\Restler\Data\ApiMethodInfo;
use Luracast\Restler\Data\String;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;
use Exception;

/**
 * Router class that routes the urls to api methods along with parameters
 *
 * @category   Framework
 * @package    Restler
 * @author     R.Arul Kumaran <arul@luracast.com>
 * @copyright  2010 Luracast
 * @license    http://www.opensource.org/licenses/lgpl-license.php LGPL
 * @link       http://luracast.com/products/restler/
 * @version    3.0.0rc5
 */
class Routes
{
    public static $prefixingParameterNames = array(
        'id'
    );
    protected static $routes = array();

    protected static $models = array();

    /**
     * Route the public and protected methods of an Api class
     *
     * @param string $className
     * @param string $resourcePath
     * @param int    $version
     *
     * @throws RestException
     */
    public static function addAPIClass($className, $resourcePath = '', $version = 1)
    {

        /*
         * Mapping Rules
         * =============
         *
         * - Optional parameters should not be mapped to URL
         * - If a required parameter is of primitive type
         *      - If one of the self::$prefixingParameterNames
         *              - Map it to URL
         *      - Else If request method is POST/PUT/PATCH
         *              - Map it to body
         *      - Else If request method is GET/DELETE
         *              - Map it to body
         * - If a required parameter is not primitive type
         *      - Do not include it in URL
         */
        $class = new ReflectionClass($className);
        try {
            $classMetadata = CommentParser::parse($class->getDocComment());
        } catch (Exception $e) {
            throw new RestException(500, "Error while parsing comments of `$className` class. " . $e->getMessage());
        }
        $classMetadata['scope'] = $scope = static::scope($class);
        $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC +
            ReflectionMethod::IS_PROTECTED);
        foreach ($methods as $method) {
            $methodUrl = strtolower($method->getName());
            //method name should not begin with _
            if ($methodUrl{0} == '_') {
                continue;
            }
            $doc = $method->getDocComment();

            try {
                $metadata = CommentParser::parse($doc) + $classMetadata;
            } catch (Exception $e) {
                throw new RestException(500, "Error while parsing comments of `{$className}::{$method->getName()}` method. " . $e->getMessage());
            }
            //@access should not be private
            if (isset($metadata['access'])
                && $metadata['access'] == 'private'
            ) {
                continue;
            }
            $arguments = array();
            $defaults = array();
            $params = $method->getParameters();
            $position = 0;
            $pathParams = array();
            $allowAmbiguity
                = (isset($metadata['smart-auto-routing'])
                    && $metadata['smart-auto-routing'] != 'true')
                || !Defaults::$smartAutoRouting;
            $metadata['resourcePath'] = $resourcePath;
            if (isset($classMetadata['description'])) {
                $metadata['classDescription'] = $classMetadata['description'];
            }
            if (isset($classMetadata['classLongDescription'])) {
                $metadata['classLongDescription']
                    = $classMetadata['longDescription'];
            }
            if (!isset($metadata['param'])) {
                $metadata['param'] = array();
            }
            if (isset($metadata['return']['type'])) {
                if ($qualified = Scope::resolve($metadata['return']['type'], $scope))
                    list($metadata['return']['type'], $metadata['return']['children']) =
                        static::getTypeAndModel(new ReflectionClass($qualified), $scope);
            } else {
                //assume return type is array
                $metadata['return']['type'] = 'array';
            }
            foreach ($params as $param) {
                $children = array();
                $type =
                    $param->isArray() ? 'array' : $param->getClass();
                $arguments[$param->getName()] = $position;
                $defaults[$position] = $param->isDefaultValueAvailable() ?
                    $param->getDefaultValue() : null;
                if (!isset($metadata['param'][$position])) {
                    $metadata['param'][$position] = array();
                }
                $m = & $metadata ['param'] [$position];
                $m ['name'] = $param->getName();
                if (empty($m['label']))
                    $m['label'] = static::label($m['name']);
                if (is_null($type) && isset($m['type'])) {
                    $type = $m['type'];
                }
                if ($m['name'] == 'email' && empty($m[CommentParser::$embeddedDataName]['type']) && $type == 'string')
                    $m[CommentParser::$embeddedDataName]['type'] = 'email';
                $m ['default'] = $defaults [$position];
                $m ['required'] = !$param->isOptional();
                $contentType = Util::nestedValue(
                    $m,
                    CommentParser::$embeddedDataName,
                    'type'
                );
                if ($contentType && $qualified = Scope::resolve($contentType, $scope)) {
                    list($m[CommentParser::$embeddedDataName]['type'], $children) = static::getTypeAndModel(
                        new ReflectionClass($qualified), $scope
                    );
                }
                if ($type instanceof ReflectionClass) {
                    list($type, $children) = static::getTypeAndModel($type, $scope);
                } elseif ($type && is_string($type) && $qualified = Scope::resolve($type, $scope)) {
                    list($type, $children)
                        = static::getTypeAndModel(new ReflectionClass($qualified), $scope);
                }
                if (isset($type)) {
                    $m['type'] = $type;
                }
                $m['children'] = $children;

                if ($m['name'] == Defaults::$fullRequestDataName) {
                    $from = 'body';
                    if (!isset($m['type'])) {
                        $type = $m['type'] = 'array';
                    }

                } elseif (isset($m[CommentParser::$embeddedDataName]['from'])) {
                    $from = $m[CommentParser::$embeddedDataName]['from'];
                } else {
                    if ((isset($type) && Util::isObjectOrArray($type))
                    ) {
                        $from = 'body';
                        if (!isset($type)) {
                            $type = $m['type'] = 'array';
                        }
                    } elseif ($m['required'] && in_array($m['name'], static::$prefixingParameterNames)) {
                        $from = 'path';
                    } else {
                        $from = 'body';
                    }
                }
                $m[CommentParser::$embeddedDataName]['from'] = $from;
                if (!isset($m['type'])) {
                    $type = $m['type'] = static::type($defaults[$position]);
                }

                if ($allowAmbiguity || $from == 'path') {
                    $pathParams [] = $position;
                }
                $position++;
            }
            $accessLevel = 0;
            if ($method->isProtected()) {
                $accessLevel = 3;
            } elseif (isset($metadata['access'])) {
                if ($metadata['access'] == 'protected') {
                    $accessLevel = 2;
                } elseif ($metadata['access'] == 'hybrid') {
                    $accessLevel = 1;
                }
            } elseif (isset($metadata['protected'])) {
                $accessLevel = 2;
            }
            /*
            echo " access level $accessLevel for $className::"
            .$method->getName().$method->isProtected().PHP_EOL;
            */

            // take note of the order
            $call = array(
                'url' => null,
                'className' => $className,
                'path' => rtrim($resourcePath, '/'),
                'methodName' => $method->getName(),
                'arguments' => $arguments,
                'defaults' => $defaults,
                'metadata' => $metadata,
                'accessLevel' => $accessLevel,
            );
            // if manual route
            if (preg_match_all(
                '/@url\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)'
                . '[ \t]*\/?(\S*)/s',
                $doc, $matches, PREG_SET_ORDER
            )
            ) {
                foreach ($matches as $match) {
                    $httpMethod = $match[1];
                    $url = rtrim($resourcePath . $match[2], '/');
                    //deep copy the call, as it may change for each @url
                    $copy = unserialize(serialize($call));
                    foreach ($copy['metadata']['param'] as $i => $p) {
                        $inPath =
                            strpos($url, '{' . $p['name'] . '}') ||
                            strpos($url, ':' . $p['name']);
                        if ($inPath) {
                            $copy['metadata']['param'][$i][CommentParser::$embeddedDataName]['from'] = 'path';
                        } elseif ($httpMethod == 'GET' || $httpMethod == 'DELETE') {
                            $copy['metadata']['param'][$i][CommentParser::$embeddedDataName]['from'] = 'query';
                        } elseif ($p[CommentParser::$embeddedDataName]['from'] == 'path') {
                            $copy['metadata']['param'][$i][CommentParser::$embeddedDataName]['from'] = 'body';
                        }
                    }
                    $url = preg_replace_callback('/{[^}]+}|:[^\/]+/',
                        function ($matches) use ($call) {
                            $match = trim($matches[0], '{}:');
                            $index = $call['arguments'][$match];
                            return '{' .
                            Routes::typeChar(isset(
                            $call['metadata']['param'][$index]['type'])
                                ? $call['metadata']['param'][$index]['type']
                                : null)
                            . $index . '}';
                        }, $url);
                    static::addPath($url, $copy, $httpMethod, $version);
                }
                //if auto route enabled, do so
            } elseif (Defaults::$autoRoutingEnabled) {
                // no configuration found so use convention
                if (preg_match_all(
                    '/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)/i',
                    $methodUrl, $matches)
                ) {
                    $httpMethod = strtoupper($matches[0][0]);
                    $methodUrl = substr($methodUrl, strlen($httpMethod));
                } else {
                    $httpMethod = 'GET';
                }
                if ($methodUrl == 'index') {
                    $methodUrl = '';
                }
                $url = empty($methodUrl) ? rtrim($resourcePath, '/')
                    : $resourcePath . $methodUrl;
                $lastPathParam = array_keys($pathParams);
                $lastPathParam = end($lastPathParam);
                for ($position = 0; $position < count($params); $position++) {
                    $from = $metadata['param'][$position][CommentParser::$embeddedDataName]['from'];
                    if ($from == 'body' && ($httpMethod == 'GET' ||
                            $httpMethod == 'DELETE')
                    ) {
                        $call['metadata']['param'][$position][CommentParser::$embeddedDataName]['from']
                            = 'query';
                    }
                }
                if (empty($pathParams) || $allowAmbiguity) {
                    static::addPath($url, $call, $httpMethod, $version);
                }
                foreach ($pathParams as $position) {
                    if (!empty($url))
                        $url .= '/';
                    $url .= '{' .
                        static::typeChar(isset($call['metadata']['param'][$position]['type'])
                            ? $call['metadata']['param'][$position]['type']
                            : null)
                        . $position . '}';
                    if ($allowAmbiguity || $position == $lastPathParam) {
                        static::addPath($url, $call, $httpMethod, $version);
                    }
                }
            }
        }
    }

    /**
     * @access private
     */
    public static function typeChar($type = null)
    {
        if (!$type) {
            return 's';
        }
        switch ($type{0}) {
            case 'i':
            case 'f':
                return 'n';
        }
        return 's';
    }

    protected static function addPath($path, array $call,
                                      $httpMethod = 'GET', $version = 1)
    {
        $call['url'] = preg_replace_callback(
            "/\{\S(\d+)\}/",
            function ($matches) use ($call) {
                return '{' .
                $call['metadata']['param'][$matches[1]]['name'] . '}';
            },
            $path
        );
        //check for wildcard routes
        if (substr($path, -1, 1) == '*') {
            $path = rtrim($path, '/*');
            static::$routes["v$version"]['*'][$path][$httpMethod] = $call;
        } else {
            static::$routes["v$version"][$path][$httpMethod] = $call;
            //create an alias with index if the method name is index
            if ($call['methodName'] == 'index')
                static::$routes["v$version"][ltrim("$path/index", '/')][$httpMethod] = $call;
        }
    }

    /**
     * Find the api method for the given url and http method
     *
     * @param string $path       Requested url path
     * @param string $httpMethod GET|POST|PUT|PATCH|DELETE etc
     * @param int    $version    Api Version number
     * @param array  $data       Data collected from the request
     *
     * @throws RestException
     * @return ApiMethodInfo
     */
    public static function find($path, $httpMethod,
                                $version = 1, array $data = array())
    {
        $p = Util::nestedValue(static::$routes, "v$version");
        if (!$p) {
            throw new RestException(
                404,
                $version == 1 ? '' : "Version $version is not supported"
            );
        }
        $status = 404;
        $message = null;
        $methods = array();
        if (isset($p[$path][$httpMethod])) {
            //================== static routes ==========================
            return static::populate($p[$path][$httpMethod], $data);
        } elseif (isset($p['*'])) {
            //================== wildcard routes ========================
            uksort($p['*'], function ($a, $b) {
                return strlen($b) - strlen($a);
            });
            foreach ($p['*'] as $key => $value) {
                if (strpos($path, $key) === 0 && isset($value[$httpMethod])) {
                    //path found, convert rest of the path to parameters
                    $path = substr($path, strlen($key) + 1);
                    $call = ApiMethodInfo::__set_state($value[$httpMethod]);
                    $call->parameters = empty($path)
                        ? array()
                        : explode('/', $path);
                    return $call;
                }
            }
        }
        //================== dynamic routes =============================
        //add newline char if trailing slash is found
        if (substr($path, -1) == '/')
            $path .= PHP_EOL;
        //if double slash is found fill in newline char;
        $path = str_replace('//', '/' . PHP_EOL . '/', $path);
        ksort($p);
        foreach ($p as $key => $value) {
            if (!isset($value[$httpMethod])) {
                continue;
            }
            $regex = str_replace(array('{', '}'),
                array('(?P<', '>[^/]+)'), $key);
            if (preg_match_all(":^$regex$:i", $path, $matches, PREG_SET_ORDER)) {
                $matches = $matches[0];
                $found = true;
                foreach ($matches as $k => $v) {
                    if (is_numeric($k)) {
                        unset($matches[$k]);
                        continue;
                    }
                    $index = intval(substr($k, 1));
                    $details = $value[$httpMethod]['metadata']['param'][$index];
                    if ($k{0} == 's' || strpos($k, static::pathVarTypeOf($v)) === 0) {
                        //remove the newlines
                        $data[$details['name']] = trim($v, PHP_EOL);
                    } else {
                        $status = 400;
                        $message = 'invalid value specified for `'
                            . $details['name'] . '`';
                        $found = false;
                        break;
                    }
                }
                if ($found) {
                    return static::populate($value[$httpMethod], $data);
                }
            }
        }
        if ($status == 404) {
            //check if other methods are allowed
            if (isset($p[$path])) {
                $status = 405;
                $methods = array_keys($p[$path]);
            }
        }
        if ($status == 405) {
            header('Allow: ' . implode(', ', $methods));
        }
        throw new RestException($status, $message);
    }

    /**
     * Populates the parameter values
     *
     * @param array $call
     * @param       $data
     *
     * @return ApiMethodInfo
     *
     * @access private
     */
    protected static function populate(array $call, $data)
    {
        $call['parameters'] = $call['defaults'];
        $p = & $call['parameters'];
        foreach ($data as $key => $value) {
            if (isset($call['arguments'][$key])) {
                $p[$call['arguments'][$key]] = $value;
            }
        }
        if (Defaults::$smartParameterParsing && 'post' != (string)Util::$restler->requestFormat) {
            if (
                count($p) == 1 &&
                ($m = Util::nestedValue($call, 'metadata', 'param', 0)) &&
                !array_key_exists($m['name'], $data) &&
                array_key_exists(Defaults::$fullRequestDataName, $data) &&
                !is_null($d = $data[Defaults::$fullRequestDataName]) &&
                isset($m['type']) &&
                static::typeMatch($m['type'], $d)
            ) {
                $p[0] = $d;
            } else {
                $bodyParamCount = 0;
                $lastBodyParamIndex = -1;
                $lastM = null;
                foreach ($call['metadata']['param'] as $k => $m) {
                    if ($m[CommentParser::$embeddedDataName]['from'] == 'body') {
                        $bodyParamCount++;
                        $lastBodyParamIndex = $k;
                        $lastM = $m;
                    }
                }
                if (
                    $bodyParamCount == 1 &&
                    !array_key_exists($lastM['name'], $data) &&
                    array_key_exists(Defaults::$fullRequestDataName, $data) &&
                    !is_null($d = $data[Defaults::$fullRequestDataName])
                ) {
                    $p[$lastBodyParamIndex] = $d;
                }
            }
        }
        $r = ApiMethodInfo::__set_state($call);
        $modifier = "_modify_{$r->methodName}_api";
        if (method_exists($r->className, $modifier)) {
            $stage = end(Scope::get('Restler')->getEvents());
            if (empty($stage))
                $stage = 'setup';
            $r = Scope::get($r->className)->$modifier($r, $stage) ? : $r;
        }
        return $r;
    }

    /**
     * @access private
     */
    protected static function pathVarTypeOf($var)
    {
        if (is_numeric($var)) {
            return 'n';
        }
        if ($var === 'true' || $var === 'false') {
            return 'b';
        }
        return 's';
    }

    protected static function typeMatch($type, $var)
    {
        switch ($type) {
            case 'boolean':
            case 'bool':
                return is_bool($var);
            case 'array':
            case 'object':
                return is_array($var);
            case 'string':
            case 'int':
            case 'integer':
            case 'float':
            case 'number':
                return is_scalar($var);
        }
        return true;
    }

    /**
     * Get the type and associated model
     *
     * @param ReflectionClass $class
     * @param array           $scope
     *
     * @throws RestException
     * @throws \Exception
     * @return array
     *
     * @access protected
     */
    protected static function getTypeAndModel(ReflectionClass $class, array $scope)
    {
        $className = $class->getName();
        if (isset(static::$models[$className])) {
            return static::$models[$className];
        }
        $children = array();
        try {
            $props = $class->getProperties(ReflectionProperty::IS_PUBLIC);
            foreach ($props as $prop) {
                $name = $prop->getName();
                $child = array('name' => $name);
                if ($c = $prop->getDocComment()) {
                    $child += Util::nestedValue(CommentParser::parse($c), 'var');
                } else {
                    $o = $class->newInstance();
                    $p = $prop->getValue($o);
                    if (is_object($p)) {
                        $child['type'] = get_class($p);
                    } elseif (is_array($p)) {
                        $child['type'] = 'array';
                        if (count($p)) {
                            $pc = reset($p);
                            if (is_object($pc)) {
                                $child['contentType'] = get_class($pc);
                            }
                        }
                    }
                }
                $child += array(
                    'type' => $child['name'] == 'email' ? 'email' : 'string',
                    'label' => static::label($child['name'])
                );
                isset($child[CommentParser::$embeddedDataName])
                    ? $child[CommentParser::$embeddedDataName] += array('required' => true)
                    : $child[CommentParser::$embeddedDataName]['required'] = true;
                if ($qualified = Scope::resolve($child['type'], $scope)) {
                    list($child['type'], $child['children'])
                        = static::getTypeAndModel(new ReflectionClass($qualified), $scope);
                } elseif (
                    ($contentType = Util::nestedValue($child, CommentParser::$embeddedDataName, 'type')) &&
                    ($qualified = Scope::resolve($contentType, $scope))
                ) {
                    list($child['contentType'], $child['children'])
                        = static::getTypeAndModel(new ReflectionClass($qualified), $scope);
                }
                $children[$name] = $child;
            }
        } catch (Exception $e) {
            if (String::endsWith($e->getFile(), 'CommentParser.php')) {
                throw new RestException(500, "Error while parsing comments of `$className` class. " . $e->getMessage());
            }
            throw $e;
        }
        static::$models[$className] = array($className, $children);
        return static::$models[$className];
    }

    /**
     * Import previously created routes from cache
     *
     * @param array $routes
     */
    public static function fromArray(array $routes)
    {
        static::$routes = $routes;
    }

    /**
     * Export current routes for cache
     *
     * @return array
     */
    public static function toArray()
    {
        return static::$routes;
    }

    public static function type($var)
    {
        if (is_object($var)) return get_class($var);
        if (is_array($var)) return 'array';
        if (is_bool($var)) return 'boolean';
        if (is_numeric($var)) return is_float($var) ? 'float' : 'int';
        return 'string';
    }

    /**
     * Create a label from name of the parameter or property
     *
     * Convert `camelCase` style names into proper `Title Case` names
     *
     * @param string $name
     *
     * @return string
     */
    public static function label($name)
    {
        return ucfirst(preg_replace(array('/(?<=[^A-Z])([A-Z])/', '/(?<=[^0-9])([0-9])/'), ' $0', $name));
    }

    public static function scope(ReflectionClass $class)
    {
        $namespace = $class->getNamespaceName();
        $imports = array(
            '*' => empty($namespace) ? '' : $namespace . '\\'
        );
        $file = file_get_contents($class->getFileName());
        $tokens = token_get_all($file);
        $namespace = '';
        $alias = '';
        $reading = false;
        $last = 0;
        foreach ($tokens as $token) {
            if (is_string($token)) {
                if ($reading && ',' == $token) {
                    //===== STOP =====//
                    $reading = false;
                    if (!empty($namespace))
                        $imports[$alias] = trim($namespace, '\\');
                    //===== START =====//
                    $reading = true;
                    $namespace = '';
                    $alias = '';
                } else {
                    //===== STOP =====//
                    $reading = false;
                    if (!empty($namespace))
                        $imports[$alias] = trim($namespace, '\\');
                }
            } elseif (T_USE == $token[0]) {
                //===== START =====//
                $reading = true;
                $namespace = '';
                $alias = '';
            } elseif ($reading) {
                //echo token_name($token[0]) . ' ' . $token[1] . PHP_EOL;
                switch ($token[0]) {
                    case T_WHITESPACE:
                        continue 2;
                    case T_STRING:
                        $alias = $token[1];
                        if (T_AS == $last) {
                            break;
                        }
                    //don't break;
                    case T_NS_SEPARATOR:
                        $namespace .= $token[1];
                        break;
                }
                $last = $token[0];
            }
        }
        return $imports;
    }
}