<?php
/**
 * i-doit - Documentation and CMDB solution for IT environments
 *
 * This file is part of the i-doit framework. Modify at your own risk.
 *
 * Please visit http://www.i-doit.com/license for a full copyright and license information.
 *
 * @version     1.10
 * @package     i-doit
 * @author      synetics GmbH
 * @copyright   synetics GmbH
 * @url         http://www.i-doit.com
 * @license     http://www.i-doit.com/license
 */

use \idoit\Component\ClassLoader\ModuleLoader;

/**
 * i-doit request controller
 *  responsible for matching routes
 *
 * @package     i-doit
 * @subpackage  Core
 * @author      Dennis Stücken <dstuecken@synetics.de>
 * @copyright   synetics GmbH
 * @license     http://www.i-doit.com/license
 */
class isys_request_controller
{
    /**
     * URL matching regex
     *
     * @const string
     */
    const URL_MATCH_REGEX = '`(\\\?(?:/|\.|))(\[([^:\]]*+)(?::([^:\]]*+))?\])(\?|)`';

    /**
     * Regex for escaping non-named params ([:named])
     *
     * @const string
     */
    const ESCAPE_REGEX = '`(?<=^|\])[^\]\[\?]+?(?=\[|$)`';
    /**
     * @var isys_request_controller
     */
    private static $m_instance = null;
    /**
     * @var isys_register
     */
    private $m_request = null;
    /**
     * @var isys_route[]
     */
    private $m_routes = null;

    /**
     * @return isys_request_controller
     */
    public static function instance()
    {
        if (!self::$m_instance)
        {
            self::$m_instance = new isys_request_controller();
        }

        return self::$m_instance;
    }

    /**
     * @return isys_route[]
     */
    public function routes()
    {
        return $this->m_routes;
    }

    /**
     * @return isys_register
     */
    public function request()
    {
        return $this->m_request;
    }

    /**
     * @param string   $p_methods
     * @param string   $p_pattern
     * @param callable $p_function
     *
     * @return isys_request_controller
     */
    public function route($p_methods, $p_pattern, $p_function)
    {
        $this->m_routes[$p_pattern] = new isys_route($p_function, $p_pattern, isys_string::split($p_methods));

        return $this;
    }

    /**
     * You can use it in modules file init.php for creating some routes for single module
     *
     * @param string $methods HTTP methods GET or POST, or GET|POST
     * @param string $pattern Router regular expression
     * @param string $module Module name
     * @param string $action Controller name. If it's null name will be taken from pattern's parameter named 'action'
     * @param string $method Called method name. If it's null name will be taken from pattern's parameter named 'method'
     *
     * @return $this
     */
    public function addModuleRoute($methods, $pattern, $module, $action = null, $method = null)
    {
        $callback = function (\isys_register $request) use ($module, $action, $method)
        {
            if($action !== null)
            {
                $request->action = $action;
            }
            if($method !== null)
            {
                $request->method = $method;
            }
            ModuleLoader::factory(isys_application::instance()->container)->boot($module, $request);
        };

        $this->m_routes[$pattern] = new isys_route($callback, $pattern, isys_string::split($methods));

        return $this;
    }

    /**
     * Get the request's path
     *
     * @access public
     * @return string
     */
    public function path()
    {
        $l_uri = $this->m_request->get('REQUEST', '');

        // Strip query string
        $l_uri = strstr($l_uri, '?', true) ?: $l_uri;

        return $l_uri;
    }

    /**
     * Parse routes
     *
     * @throws Exception
     * @return boolean
     */
    public function parse()
    {
        // Initialize cache
        $l_cache = isys_cache::keyvalue();

        // Load Request method and path
        $l_req_method = $this->m_request->get('METHOD', '');
        $l_req_path   = $this->path();

        // Disable further processing for index.php requests (there is a problem with the legeacy boot when this is not handled)
        if ($l_req_path === '/index.php') return false;

        // Initialize match response
        $l_match = false;

        /**
         * @var $l_route isys_route
         */
        foreach ($this->routes() as $l_route)
        {
            // Grab the properties of the route handler
            $l_method = $l_route->method();

            // Check for http method matching
            if ((is_array($l_method) && !in_array($l_req_method, $l_method)) || (is_string($l_method) && !strcasecmp($l_req_method, $l_method)))
            {
                continue;
            }

            //Re-set matching
            $l_match  = false;
            $l_path   = $l_route->path();
            $l_params = [];

            if ($l_path === '*')
            {
                $l_match = true;
            }
            elseif (isset($l_path[0]) && $l_path[0] === '@')
            {
                // @ is used to specify custom regex
                $l_match = preg_match('`' . substr($l_path, 1) . '`', $l_req_path, $l_params);
            }
            else
            {
                $l_expression = '';
                $l_regex      = false;
                $j            = 0;
                $i            = 0;
                $n            = isset($l_path[$i]) ? $l_path[$i] : null;

                // Find the longest non-regex substring and match it against the URI
                while (true)
                {
                    if (!isset($l_path[$i]))
                    {
                        break;
                    }
                    elseif (false === $l_regex)
                    {
                        $c       = $n;
                        $l_regex = $c === '[' || $c === '(' || $c === '.';
                        if (false === $l_regex && false !== isset($l_path[$i + 1]))
                        {
                            $n       = $l_path[$i + 1];
                            $l_regex = $n === '?' || $n === '+' || $n === '*' || $n === '{';
                        } // if

                        if (false === $l_regex && $c !== '/' && (!isset($l_req_path[$j]) || $c !== $l_req_path[$j]))
                        {
                            continue 2;
                        } // if

                        $j++;
                    } // if

                    $l_expression .= $l_path[$i++];
                } // while

                // Catch regex route matching from cache or create it
                if (!($l_regex = $l_cache->get('route:' . $l_expression)))
                {
                    // Get regex string for matching it against the current route and cache it
                    $l_cache->set('route:' . $l_expression, ($l_regex = $this->compile($l_expression)));
                } // if

                // Is regex matching needed for this route?
                if ($l_match === false)
                {
                    $l_params = [];
                    $l_match  = preg_match($l_regex, $l_req_path, $l_params);
                } // if
            } // if

            if ($l_match)
            {
                try
                {
                    if (isset($l_params) && is_array($l_params))
                    {
                        /**
                         * URL Decode the params according to RFC 3986
                         *
                         * @link http://www.faqs.org/rfcs/rfc3986
                         */
                        $l_params = array_map('rawurldecode', $l_params);

                        // Merge matched params into request register
                        $this->m_request->merge($l_params);
                    }

                    $this->handle($l_route, $this->m_request);
                    break;
                }
                catch (Exception $e)
                {
                    throw $e;
                } // try
            } // if
        } // foreach

        return $l_match;
    } // function

    /**
     * Compiles a route string to a regular expression
     *
     * @param string $p_route The route string to compile
     *
     * @access protected
     * @return string
     */
    protected function compile($p_route)
    {
        // First escape all of the non-named params (non [block]s) for regex-chars
        $p_route = preg_replace_callback(
            static::ESCAPE_REGEX,
            function ($match)
            {
                return preg_quote($match[0]);
            },
            $p_route
        );

        // Now let's actually compile the path
        if (preg_match_all(static::URL_MATCH_REGEX, $p_route, $l_matches, PREG_SET_ORDER))
        {
            $l_match_types = [
                'i'  => '[0-9]++',
                'c'  => '[A-Za-z]++',
                'a'  => '[0-9A-Za-z]++',
                'h'  => '[0-9A-Fa-f]++',
                's'  => '[0-9A-Za-z-_]++',
                '*'  => '.+?',
                '**' => '.++',
                ''   => '[^/]+?'
            ];

            foreach ($l_matches as $l_match)
            {
                list($l_block, $l_pre, $l_inner_block, $l_type, $l_param, $l_optional) = $l_match;

                if (isset($l_match_types[$l_type]))
                {
                    $l_type = $l_match_types[$l_type];

                    // Older versions of PCRE require the 'P' in (?P<named>)
                    $l_pattern = '(?:' . ($l_pre !== '' ? $l_pre : null) . '(' . ($l_param !== '' ? "?P<$l_param>" : null) . $l_type . '))' . ($l_optional !== '' ? '?' : null);

                    $p_route = str_replace($l_block, $l_pattern, $p_route);
                }

            }
        }

        return "`^$p_route$`";
    }

    /**
     * @param isys_route $p_route
     */
    private function handle(isys_route $p_route, isys_register $p_request)
    {
        return call_user_func(
            $p_route->callback(),
            $p_request,
            $this
        );
    }

    /**
     * @param array $p_map
     */
    private function __construct()
    {
        global $g_config;

        /**
         * Initialize
         */
        $this->m_routes = new isys_array();

        // Request register
        $this->m_request = isys_register::factory('request')
            ->set(
                'REQUEST',
                '/' . ltrim(
                    rawurldecode(
                        (str_replace('index.php', '', $_SERVER['SCRIPT_NAME']) != '/') ? str_replace(
                            str_replace('index.php', '', $_SERVER['SCRIPT_NAME']),
                            '',
                            $_SERVER['REQUEST_URI']
                        ) : $_SERVER['REQUEST_URI']
                    ),
                    '/'
                )
            )
            ->set('BASE', $g_config['www_dir'])
            ->set('METHOD', isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET')
            ->set('SCRIPT_FILENAME', isset($_SERVER['SCRIPT_FILENAME']) ? $_SERVER['SCRIPT_FILENAME'] : '')
            ->set('REMOTE_ADDR', isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '')
            ->set('AJAX', isset($_SERVER['REMOTE_ADDR']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' ? true : false)
            ->set('POST', new isys_array($_POST ? $_POST : []))
            ->set('GET', new isys_array($_GET ? $_GET : []));
    }

}