<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2007 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Router;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterInterface;
use Joomla\CMS\Component\Router\RouterLegacy;
use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\CMS\Uri\Uri;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Class to create and parse routes for the site application
 *
 * @since  1.5
 */
class SiteRouter extends Router
{
    /**
     * Component-router objects
     *
     * @var    array
     *
     * @since  3.3
     */
    protected $componentRouters = [];

    /**
     * @var    CMSApplication
     *
     * @since  3.4
     */
    protected $app;

    /**
     * Current Menu-Object
     *
     * @var    AbstractMenu
     *
     * @since  3.4
     */
    protected $menu;

    /**
     * Class constructor
     *
     * @param   ?CMSApplication  $app   Application Object
     * @param   ?AbstractMenu    $menu  Menu object
     *
     * @since   3.4
     */
    public function __construct(?CMSApplication $app = null, ?AbstractMenu $menu = null)
    {
        $this->app  = $app ?: Factory::getContainer()->get(SiteApplication::class);
        $this->menu = $menu ?: $this->app->getMenu();

        // Add core rules
        if ((int) $this->app->get('force_ssl') === 2) {
            $this->attachParseRule([$this, 'parseCheckSSL'], self::PROCESS_BEFORE);
        }

        $this->attachParseRule([$this, 'parseInit'], self::PROCESS_BEFORE);
        $this->attachBuildRule([$this, 'buildInit'], self::PROCESS_BEFORE);
        $this->attachBuildRule([$this, 'buildComponentPreprocess'], self::PROCESS_BEFORE);

        if ($this->app->get('sef', 1)) {
            if ($this->app->get('sef_suffix')) {
                $this->attachParseRule([$this, 'parseFormat'], self::PROCESS_BEFORE);
                $this->attachBuildRule([$this, 'buildFormat'], self::PROCESS_AFTER);
            }

            $this->attachParseRule([$this, 'parseSefRoute'], self::PROCESS_DURING);
            $this->attachBuildRule([$this, 'buildSefRoute'], self::PROCESS_DURING);
            $this->attachParseRule([$this, 'parsePaginationData'], self::PROCESS_AFTER);
            $this->attachBuildRule([$this, 'buildPaginationData'], self::PROCESS_AFTER);

            if ($this->app->get('sef_rewrite')) {
                $this->attachBuildRule([$this, 'buildRewrite'], self::PROCESS_AFTER);
            }
        }

        $this->attachParseRule([$this, 'parseRawRoute'], self::PROCESS_DURING);
        $this->attachBuildRule([$this, 'buildBase'], self::PROCESS_AFTER);
    }

    /**
     * Force to SSL
     *
     * @param   Router  &$router  Router object
     * @param   Uri     &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function parseCheckSSL(&$router, &$uri)
    {
        if (strtolower($uri->getScheme()) !== 'https') {
            // Forward to https
            $uri->setScheme('https');
            $this->app->redirect((string) $uri, 301);
        }
    }

    /**
     * Do some initial cleanup before parsing the URL
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function parseInit(&$router, &$uri)
    {
        // Get the path
        // Decode URL to convert percent-encoding to unicode so that strings match when routing.
        $path = urldecode($uri->getPath());

        /**
         * In some environments (e.g. CLI we can't form a valid base URL). In this case we catch the exception thrown
         * by URI and set an empty base URI for further work.
         * @todo: This should probably be handled better
         */
        try {
            $baseUri = Uri::base(true);
        } catch (\RuntimeException) {
            $baseUri = '';
        }

        // Remove the base URI path.
        $path = substr_replace($path, '', 0, \strlen($baseUri));

        // Check to see if a request to a specific entry point has been made.
        if (preg_match("#.*?\.php#u", $path, $matches)) {
            // Get the current entry point path relative to the site path.
            $scriptPath         = realpath($_SERVER['SCRIPT_FILENAME'] ?: str_replace('\\\\', '\\', $_SERVER['PATH_TRANSLATED']));
            $relativeScriptPath = str_replace('\\', '/', str_replace(JPATH_PUBLIC, '', $scriptPath));

            // If a php file has been found in the request path, check to see if it is a valid file.
            // Also verify that it represents the same file from the server variable for entry script.
            if (is_file(JPATH_PUBLIC . $matches[0]) && ($matches[0] === $relativeScriptPath)) {
                // Remove the entry point segments from the request path for proper routing.
                $path = str_replace($matches[0], '', $path);
            }
        }

        // Set the route
        $uri->setPath(trim($path, '/'));
    }

    /**
     * Parse the format of the request
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function parseFormat(&$router, &$uri)
    {
        $route = $uri->getPath();

        // Identify format
        if (!(str_ends_with($route, 'index.php') || str_ends_with($route, '/')) && $suffix = pathinfo($route, PATHINFO_EXTENSION)) {
            $uri->setVar('format', $suffix);
            $route = str_replace('.' . $suffix, '', $route);
            $uri->setPath($route);
        }
    }

    /**
     * Convert a sef route to an internal URI
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function parseSefRoute(&$router, &$uri)
    {
        $route = $uri->getPath();

        // If the URL is empty, we handle this in the non-SEF parse URL
        if (empty($route)) {
            return;
        }

        // Parse the application route
        $segments = explode('/', $route);

        if (\count($segments) > 1 && $segments[0] === 'component') {
            $uri->setVar('option', 'com_' . $segments[1]);
            $uri->setVar('Itemid', null);
            $route = implode('/', \array_slice($segments, 2));
        } else {
            // Get menu items.
            $items    = $this->menu->getItems(['parent_id', 'access'], [1, null]);
            $lang_tag = $this->app->getLanguage()->getTag();
            $found    = null;

            foreach ($segments as $segment) {
                $matched = false;

                foreach ($items as $item) {
                    if (
                        $item->alias == $segment
                        && (!$this->app->getLanguageFilter()
                        || ($item->language === '*'
                        || $item->language === $lang_tag))
                    ) {
                        $found   = $item;
                        $matched = true;
                        $items   = $item->getChildren();
                        break;
                    }
                }

                if (!$matched) {
                    break;
                }
            }

            // Menu links are not valid URLs. Find the first parent that isn't a menulink
            if ($found && $found->type === 'menulink') {
                while ($found->hasParent() && $found->type === 'menulink') {
                    $found = $found->getParent();
                }

                if ($found->type === 'menulink') {
                    $found = null;
                }
            }

            if (!$found) {
                $found = $this->menu->getDefault($lang_tag);
            } else {
                $route = trim(substr($route, \strlen($found->route)), '/');
            }

            if ($found) {
                if ($found->type === 'alias') {
                    $newItem = $this->menu->getItem($found->getParams()->get('aliasoptions'));

                    if ($newItem) {
                        $found->query     = array_merge($found->query, $newItem->query);
                        $found->component = $newItem->component;
                    }
                }

                $uri->setVar('Itemid', $found->id);
                $uri->setVar('option', $found->component);
            }
        }

        // Set the active menu item
        if ($uri->getVar('Itemid')) {
            $this->menu->setActive($uri->getVar('Itemid'));
        }

        // Parse the component route
        if (!empty($route) && $uri->getVar('option')) {
            $segments = explode('/', $route);

            if (\count($segments)) {
                // Handle component route
                $component = preg_replace('/[^A-Z0-9_\.-]/i', '', $uri->getVar('option'));
                $crouter   = $this->getComponentRouter($component);
                $uri->setQuery(array_merge($uri->getQuery(true), $crouter->parse($segments)));
            }

            $route = implode('/', $segments);
        }

        $uri->setPath($route);
    }

    /**
     * Convert a raw route to an internal URI
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function parseRawRoute(&$router, &$uri)
    {
        if ($uri->getVar('Itemid')) {
            $item = $this->menu->getItem($uri->getVar('Itemid'));
        } else {
            $item = $this->menu->getDefault($this->app->getLanguage()->getTag());

            if ($item->query['option'] !== $uri->getVar('option', $item->query['option'])) {
                // Set the active menu item
                $this->menu->setActive($item->id);
                $item = false;
            }
        }

        if ($item && $item->type === 'alias') {
            $newItem = $this->menu->getItem($item->getParams()->get('aliasoptions'));

            if ($newItem) {
                $item->query     = array_merge($item->query, $newItem->query);
                $item->component = $newItem->component;
            }
        }

        if (\is_object($item)) {
            // Set the active menu item
            $this->menu->setActive($item->id);

            $uri->setVar('Itemid', $item->id);
            $uri->setQuery(array_merge($item->query, $uri->getQuery(true)));
        }
    }

    /**
     * Convert limits for pagination
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function parsePaginationData(&$router, &$uri)
    {
        // Process the pagination support
        $start = $uri->getVar('start');

        if ($start !== null) {
            $uri->setVar('limitstart', $uri->getVar('start'));
            $uri->delVar('start');
        }
    }

    /**
     * Do some initial processing for building a URL
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildInit(&$router, &$uri)
    {
        $itemid = $uri->getVar('Itemid');

        // If no Itemid and option given, merge in the current requests data
        if (!$itemid && !$uri->getVar('option')) {
            $uri->setQuery(array_merge($this->getVars(), $uri->getQuery(true)));
        }

        // If Itemid is given, but no option, set the option from the menu item
        if ($itemid && !$uri->getVar('option')) {
            if ($item = $this->menu->getItem($itemid)) {
                $uri->setVar('option', $item->component);
            }
        }
    }

    /**
     * Run the component preprocess method
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildComponentPreprocess(&$router, &$uri)
    {
        // Get the query data
        $query = $uri->getQuery(true);

        if (!isset($query['option'])) {
            return;
        }

        $component = preg_replace('/[^A-Z0-9_\.-]/i', '', $query['option']);
        $crouter   = $this->getComponentRouter($component);
        $query     = $crouter->preprocess($query);

        // Make sure any menu vars are used if no others are specified
        if (
            isset($query['Itemid'])
            && (\count($query) === 2 || (\count($query) === 3 && isset($query['lang'])))
        ) {
            // Get the active menu item
            $item = $this->menu->getItem($query['Itemid']);

            if ($item !== null) {
                $query = array_merge($item->query, $query);
            }
        }

        $uri->setQuery($query);
    }

    /**
     * Build the SEF route
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildSefRoute(&$router, &$uri)
    {
        // Get the query data
        $query = $uri->getQuery(true);

        if (!isset($query['option'])) {
            return;
        }

        // Get Menu Item
        $item = empty($query['Itemid']) ? null : $this->menu->getItem($query['Itemid']);

        // Build the component route
        $component = preg_replace('/[^A-Z0-9_\.-]/i', '', $query['option']);
        $crouter   = $this->getComponentRouter($component);
        $parts     = $crouter->build($query);
        $tmp       = trim(implode('/', $parts));

        // Build the application route
        if ($item !== null && $query['option'] === $item->component) {
            if (!$item->home) {
                $tmp = $tmp ? $item->route . '/' . $tmp : $item->route;
            }

            unset($query['Itemid']);
        } else {
            $tmp = 'component/' . substr($query['option'], 4) . '/' . $tmp;
        }

        // Get the route
        if ($tmp) {
            $uri->setPath($uri->getPath() . '/' . $tmp);
        }

        // Unset unneeded query information
        unset($query['option']);

        // Set query again in the URI
        $uri->setQuery($query);
    }

    /**
     * Convert limits for pagination
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildPaginationData(&$router, &$uri)
    {
        $limitstart = $uri->getVar('limitstart');

        if ($limitstart !== null && $limitstart !== '') {
            $uri->setVar('start', (int) $limitstart);
        }

        $uri->delVar('limitstart');
    }

    /**
     * Build the format of the request
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildFormat(&$router, &$uri)
    {
        $route = $uri->getPath();

        // Identify format
        if (!(str_ends_with($route, 'index.php') || str_ends_with($route, '/')) && $format = $uri->getVar('format', 'html')) {
            $route .= '.' . $format;
            $uri->setPath($route);
            $uri->delVar('format');
        }
    }

    /**
     * Create a uri based on a full or partial URL string
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildRewrite(&$router, &$uri)
    {
        // Get the path data
        $route = $uri->getPath();

        // Transform the route
        if ($route === 'index.php') {
            $route = '';
        } else {
            $route = str_replace('index.php/', '', $route);
        }

        $uri->setPath($route);
    }

    /**
     * Add the basepath to the URI
     *
     * @param   SiteRouter  &$router  Router object
     * @param   Uri         &$uri     URI object to process
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function buildBase(&$router, &$uri)
    {
        // Add frontend basepath to the uri
        $uri->setPath(Uri::root(true) . '/' . $uri->getPath());
    }

    /**
     * Get component router
     *
     * @param   string  $component  Name of the component including com_ prefix
     *
     * @return  RouterInterface  Component router
     *
     * @since   3.3
     */
    public function getComponentRouter($component)
    {
        if (!isset($this->componentRouters[$component])) {
            $componentInstance = $this->app->bootComponent($component);

            if ($componentInstance instanceof RouterServiceInterface) {
                $this->componentRouters[$component] = $componentInstance->createRouter($this->app, $this->menu);
            }

            if (!isset($this->componentRouters[$component])) {
                $this->componentRouters[$component] = new RouterLegacy(ucfirst(substr($component, 4)));
            }
        }

        return $this->componentRouters[$component];
    }

    /**
     * Set a router for a component
     *
     * @param   string  $component  Component name with com_ prefix
     * @param   object  $router     Component router
     *
     * @return  boolean  True if the router was accepted, false if not
     *
     * @since   3.3
     */
    public function setComponentRouter($component, $router)
    {
        if ($router instanceof RouterInterface) {
            $this->componentRouters[$component] = $router;

            return true;
        }

        return false;
    }
}
