<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Routing;

use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;

/**
 * Internal class, which is similar to Symfony's Urlmatcher but without validating
 * - conditions / expression language
 * - host matches
 * - method checks
 * because this method only works in conjunction with PageRouter.
 *
 * @internal
 */
class PageUriMatcher
{
    /**
     * @var RouteCollection<string, Route>
     */
    protected $routes;

    /**
     * @var MappableProcessor
     */
    protected $mappableProcessor;

    /**
     * @param RouteCollection<string, Route> $routes
     */
    public function __construct(RouteCollection $routes)
    {
        $this->routes = $routes;
        $this->mappableProcessor = new MappableProcessor();
    }

    /**
     * Matches a path segment against the route collection
     *
     * @return array
     * @throws ResourceNotFoundException
     */
    public function match(string $urlPath)
    {
        if ($ret = $this->matchCollection(rawurldecode($urlPath), $this->routes)) {
            return $ret;
        }
        throw new ResourceNotFoundException(
            sprintf('No routes found for "%s".', $urlPath),
            1538156220
        );
    }

    /**
     * Tries to match a URL with a set of routes.
     *
     * @param string $urlPath The path info to be parsed
     * @param RouteCollection<string,Route> $routes The set of routes
     * @return array An array of parameters
     */
    protected function matchCollection(string $urlPath, RouteCollection $routes): ?array
    {
        foreach ($routes as $name => $route) {
            $urlPath = $this->getDecoratedRoutePath($route) ?? $urlPath;
            $compiledRoute = $route->compile();

            // check the static prefix of the URL first. Only use the more expensive preg_match when it matches
            if ($compiledRoute->getStaticPrefix() !== '' && !str_starts_with($urlPath, $compiledRoute->getStaticPrefix())) {
                continue;
            }

            if (!preg_match($compiledRoute->getRegex(), $urlPath, $matches)) {
                continue;
            }

            // custom handling of Mappable instances
            if (!$this->mappableProcessor->resolve($route, $matches)) {
                continue;
            }

            return $this->getAttributes($route, $name, $matches);
        }
        return null;
    }

    /**
     * Resolves an optional route specific decorated route path that has been
     * assigned by DecoratingEnhancerInterface instances.
     */
    protected function getDecoratedRoutePath(Route $route): ?string
    {
        if (!$route->hasOption('_decoratedRoutePath')) {
            return null;
        }
        $urlPath = $route->getOption('_decoratedRoutePath');
        return rawurldecode($urlPath);
    }

    /**
     * Returns an array of values to use as request attributes.
     *
     * As this method requires the Route object, it is not available
     * in matchers that do not have access to the matched Route instance
     * (like the PHP and Apache matcher dumpers).
     *
     * @param Route $route The route we are matching against
     * @param string $name The name of the route
     * @param array $attributes An array of attributes from the matcher
     * @return array An array of parameters
     */
    protected function getAttributes(Route $route, string $name, array $attributes): array
    {
        $defaults = $route->getDefaults();
        if (isset($defaults['_canonical_route'])) {
            $name = $defaults['_canonical_route'];
            unset($defaults['_canonical_route']);
        }
        $attributes['_route'] = $name;
        // store applied default values in route options
        $relevantDefaults = array_intersect_key($defaults, array_flip($route->compile()->getPathVariables()));
        // option '_appliedDefaults' contains internal(!) values (default values are not mapped when resolving)
        // (keys used are deflated and need to be inflated later using VariableProcessor)
        $route->setOption('_appliedDefaults', array_diff_key($relevantDefaults, $attributes));
        // side note: $defaults can contain e.g. '_controller'
        return $this->mergeDefaults($attributes, $defaults);
    }

    /**
     * Get merged default parameters.
     *
     * @param array $params The parameters
     * @param array $defaults The defaults
     * @return array Merged default parameters
     */
    protected function mergeDefaults(array $params, array $defaults): array
    {
        foreach ($params as $key => $value) {
            if (!is_int($key) && $value !== null) {
                $defaults[$key] = $value;
            }
        }
        return $defaults;
    }
}
