<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\Router;

use Closure;
use CodeIgniter\Autoloader\FileLocatorInterface;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\HTTP\Method;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Router\Exceptions\RouterException;
use Config\App;
use Config\Modules;
use Config\Routing;

/**
 * @todo Implement nested resource routing (See CakePHP)
 * @see \CodeIgniter\Router\RouteCollectionTest
 */
class RouteCollection implements RouteCollectionInterface
{
    /**
     * The namespace to be added to any Controllers.
     * Defaults to the global namespaces (\).
     *
     * This must have a trailing backslash (\).
     *
     * @var string
     */
    protected $defaultNamespace = '\\';

    /**
     * The name of the default controller to use
     * when no other controller is specified.
     *
     * Not used here. Pass-thru value for Router class.
     *
     * @var string
     */
    protected $defaultController = 'Home';

    /**
     * The name of the default method to use
     * when no other method has been specified.
     *
     * Not used here. Pass-thru value for Router class.
     *
     * @var string
     */
    protected $defaultMethod = 'index';

    /**
     * The placeholder used when routing 'resources'
     * when no other placeholder has been specified.
     *
     * @var string
     */
    protected $defaultPlaceholder = 'any';

    /**
     * Whether to convert dashes to underscores in URI.
     *
     * Not used here. Pass-thru value for Router class.
     *
     * @var bool
     */
    protected $translateURIDashes = false;

    /**
     * Whether to match URI against Controllers
     * when it doesn't match defined routes.
     *
     * Not used here. Pass-thru value for Router class.
     *
     * @var bool
     */
    protected $autoRoute = false;

    /**
     * A callable that will be shown
     * when the route cannot be matched.
     *
     * @var (Closure(string): (ResponseInterface|string|void))|string
     */
    protected $override404;

    /**
     * An array of files that would contain route definitions.
     */
    protected array $routeFiles = [];

    /**
     * Defined placeholders that can be used
     * within the
     *
     * @var array<string, string>
     */
    protected $placeholders = [
        'any'      => '.*',
        'segment'  => '[^/]+',
        'alphanum' => '[a-zA-Z0-9]+',
        'num'      => '[0-9]+',
        'alpha'    => '[a-zA-Z]+',
        'hash'     => '[^/]+',
    ];

    /**
     * An array of all routes and their mappings.
     *
     * @var array
     *
     * [
     *     verb => [
     *         routeKey(regex) => [
     *             'name'    => routeName
     *             'handler' => handler,
     *             'from'    => from,
     *         ],
     *     ],
     *     // redirect route
     *     '*' => [
     *          routeKey(regex)(from) => [
     *             'name'     => routeName
     *             'handler'  => [routeKey(regex)(to) => handler],
     *             'from'     => from,
     *             'redirect' => statusCode,
     *         ],
     *     ],
     * ]
     */
    protected $routes = [
        '*'             => [],
        Method::OPTIONS => [],
        Method::GET     => [],
        Method::HEAD    => [],
        Method::POST    => [],
        Method::PATCH   => [],
        Method::PUT     => [],
        Method::DELETE  => [],
        Method::TRACE   => [],
        Method::CONNECT => [],
        'CLI'           => [],
    ];

    /**
     * Array of routes names
     *
     * @var array
     *
     * [
     *     verb => [
     *         routeName => routeKey(regex)
     *     ],
     * ]
     */
    protected $routesNames = [
        '*'             => [],
        Method::OPTIONS => [],
        Method::GET     => [],
        Method::HEAD    => [],
        Method::POST    => [],
        Method::PATCH   => [],
        Method::PUT     => [],
        Method::DELETE  => [],
        Method::TRACE   => [],
        Method::CONNECT => [],
        'CLI'           => [],
    ];

    /**
     * Array of routes options
     *
     * @var array
     *
     * [
     *     verb => [
     *         routeKey(regex) => [
     *             key => value,
     *         ]
     *     ],
     * ]
     */
    protected $routesOptions = [];

    /**
     * The current method that the script is being called by.
     *
     * @var string HTTP verb like `GET`,`POST` or `*` or `CLI`
     */
    protected $HTTPVerb = '*';

    /**
     * The default list of HTTP methods (and CLI for command line usage)
     * that is allowed if no other method is provided.
     *
     * @var list<string>
     */
    public $defaultHTTPMethods = Router::HTTP_METHODS;

    /**
     * The name of the current group, if any.
     *
     * @var string|null
     */
    protected $group;

    /**
     * The current subdomain.
     *
     * @var string|null
     */
    protected $currentSubdomain;

    /**
     * Stores copy of current options being
     * applied during creation.
     *
     * @var array|null
     */
    protected $currentOptions;

    /**
     * A little performance booster.
     *
     * @var bool
     */
    protected $didDiscover = false;

    /**
     * Handle to the file locator to use.
     *
     * @var FileLocatorInterface
     */
    protected $fileLocator;

    /**
     * Handle to the modules config.
     *
     * @var Modules
     */
    protected $moduleConfig;

    /**
     * Flag for sorting routes by priority.
     *
     * @var bool
     */
    protected $prioritize = false;

    /**
     * Route priority detection flag.
     *
     * @var bool
     */
    protected $prioritizeDetected = false;

    /**
     * The current hostname from $_SERVER['HTTP_HOST']
     */
    private ?string $httpHost = null;

    /**
     * Flag to limit or not the routes with {locale} placeholder to App::$supportedLocales
     */
    protected bool $useSupportedLocalesOnly = false;

    /**
     * Constructor
     */
    public function __construct(FileLocatorInterface $locator, Modules $moduleConfig, Routing $routing)
    {
        $this->fileLocator  = $locator;
        $this->moduleConfig = $moduleConfig;

        $this->httpHost = service('request')->getServer('HTTP_HOST');

        // Setup based on config file. Let routes file override.
        $this->defaultNamespace   = rtrim($routing->defaultNamespace, '\\') . '\\';
        $this->defaultController  = $routing->defaultController;
        $this->defaultMethod      = $routing->defaultMethod;
        $this->translateURIDashes = $routing->translateURIDashes;
        $this->override404        = $routing->override404;
        $this->autoRoute          = $routing->autoRoute;
        $this->routeFiles         = $routing->routeFiles;
        $this->prioritize         = $routing->prioritize;

        // Normalize the path string in routeFiles array.
        foreach ($this->routeFiles as $routeKey => $routesFile) {
            $realpath                    = realpath($routesFile);
            $this->routeFiles[$routeKey] = ($realpath === false) ? $routesFile : $realpath;
        }
    }

    /**
     * Loads main routes file and discover routes.
     *
     * Loads only once unless reset.
     *
     * @return $this
     */
    public function loadRoutes(string $routesFile = APPPATH . 'Config/Routes.php')
    {
        if ($this->didDiscover) {
            return $this;
        }

        // Normalize the path string in routesFile
        $realpath   = realpath($routesFile);
        $routesFile = ($realpath === false) ? $routesFile : $realpath;

        // Include the passed in routesFile if it doesn't exist.
        // Only keeping that around for BC purposes for now.
        $routeFiles = $this->routeFiles;
        if (! in_array($routesFile, $routeFiles, true)) {
            $routeFiles[] = $routesFile;
        }

        // We need this var in local scope
        // so route files can access it.
        $routes = $this;

        foreach ($routeFiles as $routesFile) {
            if (! is_file($routesFile)) {
                log_message('warning', sprintf('Routes file not found: "%s"', $routesFile));

                continue;
            }

            require $routesFile;
        }

        $this->discoverRoutes();

        return $this;
    }

    /**
     * Will attempt to discover any additional routes, either through
     * the local PSR4 namespaces, or through selected Composer packages.
     *
     * @return void
     */
    protected function discoverRoutes()
    {
        if ($this->didDiscover) {
            return;
        }

        // We need this var in local scope
        // so route files can access it.
        $routes = $this;

        if ($this->moduleConfig->shouldDiscover('routes')) {
            $files = $this->fileLocator->search('Config/Routes.php');

            foreach ($files as $file) {
                // Don't include our main file again...
                if (in_array($file, $this->routeFiles, true)) {
                    continue;
                }

                include $file;
            }
        }

        $this->didDiscover = true;
    }

    /**
     * Registers a new constraint with the system. Constraints are used
     * by the routes as placeholders for regular expressions to make defining
     * the routes more human-friendly.
     *
     * You can pass an associative array as $placeholder, and have
     * multiple placeholders added at once.
     *
     * @param array|string $placeholder
     */
    public function addPlaceholder($placeholder, ?string $pattern = null): RouteCollectionInterface
    {
        if (! is_array($placeholder)) {
            $placeholder = [$placeholder => $pattern];
        }

        $this->placeholders = array_merge($this->placeholders, $placeholder);

        return $this;
    }

    /**
     * For `spark routes`
     *
     * @return array<string, string>
     *
     * @internal
     */
    public function getPlaceholders(): array
    {
        return $this->placeholders;
    }

    /**
     * Sets the default namespace to use for Controllers when no other
     * namespace has been specified.
     */
    public function setDefaultNamespace(string $value): RouteCollectionInterface
    {
        $this->defaultNamespace = esc(strip_tags($value));
        $this->defaultNamespace = rtrim($this->defaultNamespace, '\\') . '\\';

        return $this;
    }

    /**
     * Sets the default controller to use when no other controller has been
     * specified.
     */
    public function setDefaultController(string $value): RouteCollectionInterface
    {
        $this->defaultController = esc(strip_tags($value));

        return $this;
    }

    /**
     * Sets the default method to call on the controller when no other
     * method has been set in the route.
     */
    public function setDefaultMethod(string $value): RouteCollectionInterface
    {
        $this->defaultMethod = esc(strip_tags($value));

        return $this;
    }

    /**
     * Tells the system whether to convert dashes in URI strings into
     * underscores. In some search engines, including Google, dashes
     * create more meaning and make it easier for the search engine to
     * find words and meaning in the URI for better SEO. But it
     * doesn't work well with PHP method names....
     */
    public function setTranslateURIDashes(bool $value): RouteCollectionInterface
    {
        $this->translateURIDashes = $value;

        return $this;
    }

    /**
     * If TRUE, the system will attempt to match the URI against
     * Controllers by matching each segment against folders/files
     * in APPPATH/Controllers, when a match wasn't found against
     * defined routes.
     *
     * If FALSE, will stop searching and do NO automatic routing.
     */
    public function setAutoRoute(bool $value): RouteCollectionInterface
    {
        $this->autoRoute = $value;

        return $this;
    }

    /**
     * Sets the class/method that should be called if routing doesn't
     * find a match. It can be either a closure or the controller/method
     * name exactly like a route is defined: Users::index
     *
     * This setting is passed to the Router class and handled there.
     *
     * @param callable|string|null $callable
     */
    public function set404Override($callable = null): RouteCollectionInterface
    {
        $this->override404 = $callable;

        return $this;
    }

    /**
     * Returns the 404 Override setting, which can be null, a closure
     * or the controller/string.
     *
     * @return (Closure(string): (ResponseInterface|string|void))|string|null
     */
    public function get404Override()
    {
        return $this->override404;
    }

    /**
     * Sets the default constraint to be used in the system. Typically
     * for use with the 'resource' method.
     */
    public function setDefaultConstraint(string $placeholder): RouteCollectionInterface
    {
        if (array_key_exists($placeholder, $this->placeholders)) {
            $this->defaultPlaceholder = $placeholder;
        }

        return $this;
    }

    /**
     * Returns the name of the default controller. With Namespace.
     */
    public function getDefaultController(): string
    {
        return $this->defaultController;
    }

    /**
     * Returns the name of the default method to use within the controller.
     */
    public function getDefaultMethod(): string
    {
        return $this->defaultMethod;
    }

    /**
     * Returns the default namespace as set in the Routes config file.
     */
    public function getDefaultNamespace(): string
    {
        return $this->defaultNamespace;
    }

    /**
     * Returns the current value of the translateURIDashes setting.
     */
    public function shouldTranslateURIDashes(): bool
    {
        return $this->translateURIDashes;
    }

    /**
     * Returns the flag that tells whether to autoRoute URI against Controllers.
     */
    public function shouldAutoRoute(): bool
    {
        return $this->autoRoute;
    }

    /**
     * Returns the raw array of available routes.
     *
     * @param non-empty-string|null $verb            HTTP verb like `GET`,`POST` or `*` or `CLI`.
     * @param bool                  $includeWildcard Whether to include '*' routes.
     */
    public function getRoutes(?string $verb = null, bool $includeWildcard = true): array
    {
        if ((string) $verb === '') {
            $verb = $this->getHTTPVerb();
        }

        // Since this is the entry point for the Router,
        // take a moment to do any route discovery
        // we might need to do.
        $this->discoverRoutes();

        $routes = [];

        if (isset($this->routes[$verb])) {
            // Keep current verb's routes at the beginning, so they're matched
            // before any of the generic, "add" routes.
            $collection = $includeWildcard ? $this->routes[$verb] + ($this->routes['*'] ?? []) : $this->routes[$verb];

            foreach ($collection as $routeKey => $r) {
                $routes[$routeKey] = $r['handler'];
            }
        }

        // sorting routes by priority
        if ($this->prioritizeDetected && $this->prioritize && $routes !== []) {
            $order = [];

            foreach ($routes as $key => $value) {
                $key                    = $key === '/' ? $key : ltrim($key, '/ ');
                $priority               = $this->getRoutesOptions($key, $verb)['priority'] ?? 0;
                $order[$priority][$key] = $value;
            }

            ksort($order);
            $routes = array_merge(...$order);
        }

        return $routes;
    }

    /**
     * Returns one or all routes options
     *
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
     *
     * @return array<string, int|string> [key => value]
     */
    public function getRoutesOptions(?string $from = null, ?string $verb = null): array
    {
        $options = $this->loadRoutesOptions($verb);

        return ((string) $from !== '') ? $options[$from] ?? [] : $options;
    }

    /**
     * Returns the current HTTP Verb being used.
     */
    public function getHTTPVerb(): string
    {
        return $this->HTTPVerb;
    }

    /**
     * Sets the current HTTP verb.
     * Used primarily for testing.
     *
     * @param string $verb HTTP verb
     *
     * @return $this
     */
    public function setHTTPVerb(string $verb)
    {
        if ($verb !== '*' && $verb === strtolower($verb)) {
            @trigger_error(
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
                E_USER_DEPRECATED,
            );
        }

        /**
         * @deprecated 4.5.0
         * @TODO Remove strtoupper() in the future.
         */
        $this->HTTPVerb = strtoupper($verb);

        return $this;
    }

    /**
     * A shortcut method to add a number of routes at a single time.
     * It does not allow any options to be set on the route, or to
     * define the method used.
     */
    public function map(array $routes = [], ?array $options = null): RouteCollectionInterface
    {
        foreach ($routes as $from => $to) {
            $this->add($from, $to, $options);
        }

        return $this;
    }

    /**
     * Adds a single route to the collection.
     *
     * Example:
     *      $routes->add('news', 'Posts::index');
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function add(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create('*', $from, $to, $options);

        return $this;
    }

    /**
     * Adds a temporary redirect from one route to another. Used for
     * redirecting traffic from old, non-existing routes to the new
     * moved routes.
     *
     * @param string $from   The pattern to match against
     * @param string $to     Either a route name or a URI to redirect to
     * @param int    $status The HTTP status code that should be returned with this redirect
     *
     * @return RouteCollection
     */
    public function addRedirect(string $from, string $to, int $status = 302)
    {
        // Use the named route's pattern if this is a named route.
        if (array_key_exists($to, $this->routesNames['*'])) {
            $routeName  = $to;
            $routeKey   = $this->routesNames['*'][$routeName];
            $redirectTo = [$routeKey => $this->routes['*'][$routeKey]['handler']];
        } elseif (array_key_exists($to, $this->routesNames[Method::GET])) {
            $routeName  = $to;
            $routeKey   = $this->routesNames[Method::GET][$routeName];
            $redirectTo = [$routeKey => $this->routes[Method::GET][$routeKey]['handler']];
        } else {
            // The named route is not found.
            $redirectTo = $to;
        }

        $this->create('*', $from, $redirectTo, ['redirect' => $status]);

        return $this;
    }

    /**
     * Determines if the route is a redirecting route.
     *
     * @param string $routeKey routeKey or route name
     */
    public function isRedirect(string $routeKey): bool
    {
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
            return true;
        }

        // This logic is not used. Should be deprecated?
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
        if ($routeName === $routeKey) {
            $routeKey = $this->routesNames['*'][$routeName];

            return isset($this->routes['*'][$routeKey]['redirect']);
        }

        return false;
    }

    /**
     * Grabs the HTTP status code from a redirecting Route.
     *
     * @param string $routeKey routeKey or route name
     */
    public function getRedirectCode(string $routeKey): int
    {
        if (isset($this->routes['*'][$routeKey]['redirect'])) {
            return $this->routes['*'][$routeKey]['redirect'];
        }

        // This logic is not used. Should be deprecated?
        $routeName = $this->routes['*'][$routeKey]['name'] ?? null;
        if ($routeName === $routeKey) {
            $routeKey = $this->routesNames['*'][$routeName];

            return $this->routes['*'][$routeKey]['redirect'];
        }

        return 0;
    }

    /**
     * Group a series of routes under a single URL segment. This is handy
     * for grouping items into an admin area, like:
     *
     * Example:
     *     // Creates route: admin/users
     *     $route->group('admin', function() {
     *            $route->resource('users');
     *     });
     *
     * @param string         $name      The name to group/prefix the routes with.
     * @param array|callable ...$params
     *
     * @return void
     */
    public function group(string $name, ...$params)
    {
        $oldGroup   = $this->group;
        $oldOptions = $this->currentOptions;

        // To register a route, we'll set a flag so that our router
        // will see the group name.
        // If the group name is empty, we go on using the previously built group name.
        $this->group = $name !== '' ? trim($oldGroup . '/' . $name, '/') : $oldGroup;

        $callback = array_pop($params);

        if ($params !== [] && is_array($params[0])) {
            $options = array_shift($params);

            if (isset($options['filter'])) {
                // Merge filters.
                $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
                $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
            }

            // Merge options other than filters.
            $this->currentOptions = array_merge(
                $this->currentOptions ?? [],
                $options,
            );
        }

        if (is_callable($callback)) {
            $callback($this);
        }

        $this->group          = $oldGroup;
        $this->currentOptions = $oldOptions;
    }

    /*
     * --------------------------------------------------------------------
     *  HTTP Verb-based routing
     * --------------------------------------------------------------------
     * Routing works here because, as the routes Config file is read in,
     * the various HTTP verb-based routes will only be added to the in-memory
     * routes if it is a call that should respond to that verb.
     *
     * The options array is typically used to pass in an 'as' or var, but may
     * be expanded in the future. See the docblock for 'add' method above for
     * current list of globally available options.
     */

    /**
     * Creates a collections of HTTP-verb based routes for a controller.
     *
     * Possible Options:
     *      'controller'    - Customize the name of the controller used in the 'to' route
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
     *      'websafe'   -	- '1' if only GET and POST HTTP verbs are supported
     *
     * Example:
     *
     *      $route->resource('photos');
     *
     *      // Generates the following routes:
     *      HTTP Verb | Path        | Action        | Used for...
     *      ----------+-------------+---------------+-----------------
     *      GET         /photos             index           an array of photo objects
     *      GET         /photos/new         new             an empty photo object, with default properties
     *      GET         /photos/{id}/edit   edit            a specific photo object, editable properties
     *      GET         /photos/{id}        show            a specific photo object, all properties
     *      POST        /photos             create          a new photo object, to add to the resource
     *      DELETE      /photos/{id}        delete          deletes the specified photo object
     *      PUT/PATCH   /photos/{id}        update          replacement properties for existing photo
     *
     *  If 'websafe' option is present, the following paths are also available:
     *
     *      POST		/photos/{id}/delete delete
     *      POST        /photos/{id}        update
     *
     * @param string     $name    The name of the resource/controller to route to.
     * @param array|null $options A list of possible ways to customize the routing.
     */
    public function resource(string $name, ?array $options = null): RouteCollectionInterface
    {
        // In order to allow customization of the route the
        // resources are sent to, we need to have a new name
        // to store the values in.
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));

        // If a new controller is specified, then we replace the
        // $name value with the name of the new controller.
        if (isset($options['controller'])) {
            $newName = ucfirst(esc(strip_tags($options['controller'])));
        }

        // In order to allow customization of allowed id values
        // we need someplace to store them.
        $id = $options['placeholder'] ?? $this->placeholders[$this->defaultPlaceholder] ?? '(:segment)';

        // Make sure we capture back-references
        $id = '(' . trim($id, '()') . ')';

        $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'create', 'update', 'delete', 'new', 'edit'];

        if (isset($options['except'])) {
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);

            foreach ($methods as $i => $method) {
                if (in_array($method, $options['except'], true)) {
                    unset($methods[$i]);
                }
            }
        }

        if (in_array('index', $methods, true)) {
            $this->get($name, $newName . '::index', $options);
        }
        if (in_array('new', $methods, true)) {
            $this->get($name . '/new', $newName . '::new', $options);
        }
        if (in_array('edit', $methods, true)) {
            $this->get($name . '/' . $id . '/edit', $newName . '::edit/$1', $options);
        }
        if (in_array('show', $methods, true)) {
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
        }
        if (in_array('create', $methods, true)) {
            $this->post($name, $newName . '::create', $options);
        }
        if (in_array('update', $methods, true)) {
            $this->put($name . '/' . $id, $newName . '::update/$1', $options);
            $this->patch($name . '/' . $id, $newName . '::update/$1', $options);
        }
        if (in_array('delete', $methods, true)) {
            $this->delete($name . '/' . $id, $newName . '::delete/$1', $options);
        }

        // Web Safe? delete needs checking before update because of method name
        if (isset($options['websafe'])) {
            if (in_array('delete', $methods, true)) {
                $this->post($name . '/' . $id . '/delete', $newName . '::delete/$1', $options);
            }
            if (in_array('update', $methods, true)) {
                $this->post($name . '/' . $id, $newName . '::update/$1', $options);
            }
        }

        return $this;
    }

    /**
     * Creates a collections of HTTP-verb based routes for a presenter controller.
     *
     * Possible Options:
     *      'controller'    - Customize the name of the controller used in the 'to' route
     *      'placeholder'   - The regex used by the Router. Defaults to '(:any)'
     *
     * Example:
     *
     *      $route->presenter('photos');
     *
     *      // Generates the following routes:
     *      HTTP Verb | Path        | Action        | Used for...
     *      ----------+-------------+---------------+-----------------
     *      GET         /photos             index           showing all array of photo objects
     *      GET         /photos/show/{id}   show            showing a specific photo object, all properties
     *      GET         /photos/new         new             showing a form for an empty photo object, with default properties
     *      POST        /photos/create      create          processing the form for a new photo
     *      GET         /photos/edit/{id}   edit            show an editing form for a specific photo object, editable properties
     *      POST        /photos/update/{id} update          process the editing form data
     *      GET         /photos/remove/{id} remove          show a form to confirm deletion of a specific photo object
     *      POST        /photos/delete/{id} delete          deleting the specified photo object
     *
     * @param string     $name    The name of the controller to route to.
     * @param array|null $options A list of possible ways to customize the routing.
     */
    public function presenter(string $name, ?array $options = null): RouteCollectionInterface
    {
        // In order to allow customization of the route the
        // resources are sent to, we need to have a new name
        // to store the values in.
        $newName = implode('\\', array_map(ucfirst(...), explode('/', $name)));

        // If a new controller is specified, then we replace the
        // $name value with the name of the new controller.
        if (isset($options['controller'])) {
            $newName = ucfirst(esc(strip_tags($options['controller'])));
        }

        // In order to allow customization of allowed id values
        // we need someplace to store them.
        $id = $options['placeholder'] ?? $this->placeholders[$this->defaultPlaceholder] ?? '(:segment)';

        // Make sure we capture back-references
        $id = '(' . trim($id, '()') . ')';

        $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'new', 'create', 'edit', 'update', 'remove', 'delete'];

        if (isset($options['except'])) {
            $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']);

            foreach ($methods as $i => $method) {
                if (in_array($method, $options['except'], true)) {
                    unset($methods[$i]);
                }
            }
        }

        if (in_array('index', $methods, true)) {
            $this->get($name, $newName . '::index', $options);
        }
        if (in_array('show', $methods, true)) {
            $this->get($name . '/show/' . $id, $newName . '::show/$1', $options);
        }
        if (in_array('new', $methods, true)) {
            $this->get($name . '/new', $newName . '::new', $options);
        }
        if (in_array('create', $methods, true)) {
            $this->post($name . '/create', $newName . '::create', $options);
        }
        if (in_array('edit', $methods, true)) {
            $this->get($name . '/edit/' . $id, $newName . '::edit/$1', $options);
        }
        if (in_array('update', $methods, true)) {
            $this->post($name . '/update/' . $id, $newName . '::update/$1', $options);
        }
        if (in_array('remove', $methods, true)) {
            $this->get($name . '/remove/' . $id, $newName . '::remove/$1', $options);
        }
        if (in_array('delete', $methods, true)) {
            $this->post($name . '/delete/' . $id, $newName . '::delete/$1', $options);
        }
        if (in_array('show', $methods, true)) {
            $this->get($name . '/' . $id, $newName . '::show/$1', $options);
        }
        if (in_array('create', $methods, true)) {
            $this->post($name, $newName . '::create', $options);
        }

        return $this;
    }

    /**
     * Specifies a single route to match for multiple HTTP Verbs.
     *
     * Example:
     *  $route->match( ['GET', 'POST'], 'users/(:num)', 'users/$1);
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function match(array $verbs = [], string $from = '', $to = '', ?array $options = null): RouteCollectionInterface
    {
        if ($from === '' || empty($to)) {
            throw new InvalidArgumentException('You must supply the parameters: from, to.');
        }

        foreach ($verbs as $verb) {
            if ($verb === strtolower($verb)) {
                @trigger_error(
                    'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
                    . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
                    E_USER_DEPRECATED,
                );
            }

            /**
             * @TODO We should use correct uppercase verb.
             * @deprecated 4.5.0
             */
            $verb = strtolower($verb);

            $this->{$verb}($from, $to, $options);
        }

        return $this;
    }

    /**
     * Specifies a route that is only available to GET requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function get(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::GET, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to POST requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function post(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::POST, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to PUT requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function put(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::PUT, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to DELETE requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function delete(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::DELETE, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to HEAD requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function head(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::HEAD, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to PATCH requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function patch(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::PATCH, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to OPTIONS requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function options(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create(Method::OPTIONS, $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that is only available to command-line requests.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     */
    public function cli(string $from, $to, ?array $options = null): RouteCollectionInterface
    {
        $this->create('CLI', $from, $to, $options);

        return $this;
    }

    /**
     * Specifies a route that will only display a view.
     * Only works for GET requests.
     */
    public function view(string $from, string $view, ?array $options = null): RouteCollectionInterface
    {
        $to = static fn (...$data) => service('renderer')
            ->setData(['segments' => $data], 'raw')
            ->render($view, $options);

        $routeOptions = $options ?? [];
        $routeOptions = array_merge($routeOptions, ['view' => $view]);

        $this->create(Method::GET, $from, $to, $routeOptions);

        return $this;
    }

    /**
     * Limits the routes to a specified ENVIRONMENT or they won't run.
     *
     * @param Closure(RouteCollection): void $callback
     */
    public function environment(string $env, Closure $callback): RouteCollectionInterface
    {
        if ($env === ENVIRONMENT) {
            $callback($this);
        }

        return $this;
    }

    /**
     * Attempts to look up a route based on its destination.
     *
     * If a route exists:
     *
     *      'path/(:any)/(:any)' => 'Controller::method/$1/$2'
     *
     * This method allows you to know the Controller and method
     * and get the route that leads to it.
     *
     *      // Equals 'path/$param1/$param2'
     *      reverseRoute('Controller::method', $param1, $param2);
     *
     * @param string     $search    Route name or Controller::method
     * @param int|string ...$params One or more parameters to be passed to the route.
     *                              The last parameter allows you to set the locale.
     *
     * @return false|string The route (URI path relative to baseURL) or false if not found.
     */
    public function reverseRoute(string $search, ...$params)
    {
        if ($search === '') {
            return false;
        }

        // Named routes get higher priority.
        foreach ($this->routesNames as $verb => $collection) {
            if (array_key_exists($search, $collection)) {
                $routeKey = $collection[$search];

                $from = $this->routes[$verb][$routeKey]['from'];

                return $this->buildReverseRoute($from, $params);
            }
        }

        // Add the default namespace if needed.
        $namespace = trim($this->defaultNamespace, '\\') . '\\';
        if (
            ! str_starts_with($search, '\\')
            && ! str_starts_with($search, $namespace)
        ) {
            $search = $namespace . $search;
        }

        // If it's not a named route, then loop over
        // all routes to find a match.
        foreach ($this->routes as $collection) {
            foreach ($collection as $route) {
                $to   = $route['handler'];
                $from = $route['from'];

                // ignore closures
                if (! is_string($to)) {
                    continue;
                }

                // Lose any namespace slash at beginning of strings
                // to ensure more consistent match.
                $to     = ltrim($to, '\\');
                $search = ltrim($search, '\\');

                // If there's any chance of a match, then it will
                // be with $search at the beginning of the $to string.
                if (! str_starts_with($to, $search)) {
                    continue;
                }

                // Ensure that the number of $params given here
                // matches the number of back-references in the route
                if (substr_count($to, '$') !== count($params)) {
                    continue;
                }

                return $this->buildReverseRoute($from, $params);
            }
        }

        // If we're still here, then we did not find a match.
        return false;
    }

    /**
     * Replaces the {locale} tag with the current application locale
     *
     * @deprecated Unused.
     */
    protected function localizeRoute(string $route): string
    {
        return strtr($route, ['{locale}' => service('request')->getLocale()]);
    }

    /**
     * Checks a route (using the "from") to see if it's filtered or not.
     *
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
     */
    public function isFiltered(string $search, ?string $verb = null): bool
    {
        $options = $this->loadRoutesOptions($verb);

        return isset($options[$search]['filter']);
    }

    /**
     * Returns the filters that should be applied for a single route, along
     * with any parameters it might have. Parameters are found by splitting
     * the parameter name on a colon to separate the filter name from the parameter list,
     * and the splitting the result on commas. So:
     *
     *    'role:admin,manager'
     *
     * has a filter of "role", with parameters of ['admin', 'manager'].
     *
     * @param string      $search routeKey
     * @param string|null $verb   HTTP verb like `GET`,`POST` or `*` or `CLI`.
     *
     * @return list<string> filter_name or filter_name:arguments like 'role:admin,manager'
     */
    public function getFiltersForRoute(string $search, ?string $verb = null): array
    {
        $options = $this->loadRoutesOptions($verb);

        if (! array_key_exists($search, $options) || ! array_key_exists('filter', $options[$search])) {
            return [];
        }

        if (is_string($options[$search]['filter'])) {
            return [$options[$search]['filter']];
        }

        return $options[$search]['filter'];
    }

    /**
     * Given a
     *
     * @throws RouterException
     *
     * @deprecated Unused. Now uses buildReverseRoute().
     */
    protected function fillRouteParams(string $from, ?array $params = null): string
    {
        // Find all of our back-references in the original route
        preg_match_all('/\(([^)]+)\)/', $from, $matches);

        if (empty($matches[0])) {
            return '/' . ltrim($from, '/');
        }

        /**
         * Build our resulting string, inserting the $params in
         * the appropriate places.
         *
         * @var list<string> $patterns
         */
        $patterns = $matches[0];

        foreach ($patterns as $index => $pattern) {
            if (preg_match('#^' . $pattern . '$#u', $params[$index]) !== 1) {
                throw RouterException::forInvalidParameterType();
            }

            // Ensure that the param we're inserting matches
            // the expected param type.
            $pos  = strpos($from, $pattern);
            $from = substr_replace($from, $params[$index], $pos, strlen($pattern));
        }

        return '/' . ltrim($from, '/');
    }

    /**
     * Builds reverse route
     *
     * @param array $params One or more parameters to be passed to the route.
     *                      The last parameter allows you to set the locale.
     */
    protected function buildReverseRoute(string $from, array $params): string
    {
        $locale = null;

        // Find all of our back-references in the original route
        preg_match_all('/\(([^)]+)\)/', $from, $matches);

        if (empty($matches[0])) {
            if (str_contains($from, '{locale}')) {
                $locale = $params[0] ?? null;
            }

            $from = $this->replaceLocale($from, $locale);

            return '/' . ltrim($from, '/');
        }

        // Locale is passed?
        $placeholderCount = count($matches[0]);
        if (count($params) > $placeholderCount) {
            $locale = $params[$placeholderCount];
        }

        /**
         * Build our resulting string, inserting the $params in
         * the appropriate places.
         *
         * @var list<string> $placeholders
         */
        $placeholders = $matches[0];

        foreach ($placeholders as $index => $placeholder) {
            if (! isset($params[$index])) {
                throw new InvalidArgumentException(
                    'Missing argument for "' . $placeholder . '" in route "' . $from . '".',
                );
            }

            // Remove `(:` and `)` when $placeholder is a placeholder.
            $placeholderName = substr($placeholder, 2, -1);
            // or maybe $placeholder is not a placeholder, but a regex.
            $pattern = $this->placeholders[$placeholderName] ?? $placeholder;

            if (preg_match('#^' . $pattern . '$#u', (string) $params[$index]) !== 1) {
                throw RouterException::forInvalidParameterType();
            }

            // Ensure that the param we're inserting matches
            // the expected param type.
            $pos  = strpos($from, $placeholder);
            $from = substr_replace($from, (string) $params[$index], $pos, strlen($placeholder));
        }

        $from = $this->replaceLocale($from, $locale);

        return '/' . ltrim($from, '/');
    }

    /**
     * Replaces the {locale} tag with the locale
     */
    private function replaceLocale(string $route, ?string $locale = null): string
    {
        if (! str_contains($route, '{locale}')) {
            return $route;
        }

        // Check invalid locale
        if ((string) $locale !== '') {
            $config = config(App::class);
            if (! in_array($locale, $config->supportedLocales, true)) {
                $locale = null;
            }
        }

        if ((string) $locale === '') {
            $locale = service('request')->getLocale();
        }

        return strtr($route, ['{locale}' => $locale]);
    }

    /**
     * Does the heavy lifting of creating an actual route. You must specify
     * the request method(s) that this route will work for. They can be separated
     * by a pipe character "|" if there is more than one.
     *
     * @param array|(Closure(mixed...): (ResponseInterface|string|void))|string $to
     *
     * @return void
     */
    protected function create(string $verb, string $from, $to, ?array $options = null)
    {
        $overwrite = false;
        $prefix    = $this->group === null ? '' : $this->group . '/';

        $from = esc(strip_tags($prefix . $from));

        // While we want to add a route within a group of '/',
        // it doesn't work with matching, so remove them...
        if ($from !== '/') {
            $from = trim($from, '/');
        }

        // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`.
        if (is_array($to) && isset($to[0])) {
            $to = $this->processArrayCallableSyntax($from, $to);
        }

        // Merge group filters.
        if (isset($options['filter'])) {
            $currentFilter     = (array) ($this->currentOptions['filter'] ?? []);
            $options['filter'] = array_merge($currentFilter, (array) $options['filter']);
        }

        $options = array_merge($this->currentOptions ?? [], $options ?? []);

        // Route priority detect
        if (isset($options['priority'])) {
            $options['priority'] = abs((int) $options['priority']);

            if ($options['priority'] > 0) {
                $this->prioritizeDetected = true;
            }
        }

        // Hostname limiting?
        if (! empty($options['hostname'])) {
            // @todo determine if there's a way to whitelist hosts?
            if (! $this->checkHostname($options['hostname'])) {
                return;
            }

            $overwrite = true;
        }
        // Limiting to subdomains?
        elseif (! empty($options['subdomain'])) {
            // If we don't match the current subdomain, then
            // we don't need to add the route.
            if (! $this->checkSubdomains($options['subdomain'])) {
                return;
            }

            $overwrite = true;
        }

        // Are we offsetting the binds?
        // If so, take care of them here in one
        // fell swoop.
        if (isset($options['offset']) && is_string($to)) {
            // Get a constant string to work with.
            $to = preg_replace('/(\$\d+)/', '$X', $to);

            for ($i = (int) $options['offset'] + 1; $i < (int) $options['offset'] + 7; $i++) {
                $to = preg_replace_callback(
                    '/\$X/',
                    static fn ($m): string => '$' . $i,
                    $to,
                    1,
                );
            }
        }

        $routeKey = $from;

        // Replace our regex pattern placeholders with the actual thing
        // so that the Router doesn't need to know about any of this.
        foreach ($this->placeholders as $tag => $pattern) {
            $routeKey = str_ireplace(':' . $tag, $pattern, $routeKey);
        }

        // If is redirect, No processing
        if (! isset($options['redirect']) && is_string($to)) {
            // If no namespace found, add the default namespace
            if (! str_contains($to, '\\') || strpos($to, '\\') > 0) {
                $namespace = $options['namespace'] ?? $this->defaultNamespace;
                $to        = trim($namespace, '\\') . '\\' . $to;
            }
            // Always ensure that we escape our namespace so we're not pointing to
            // \CodeIgniter\Routes\Controller::method.
            $to = '\\' . ltrim($to, '\\');
        }

        $name = $options['as'] ?? $routeKey;

        helper('array');

        // Don't overwrite any existing 'froms' so that auto-discovered routes
        // do not overwrite any app/Config/Routes settings. The app
        // routes should always be the "source of truth".
        // this works only because discovered routes are added just prior
        // to attempting to route the request.
        $routeKeyExists = isset($this->routes[$verb][$routeKey]);
        if ((isset($this->routesNames[$verb][$name]) || $routeKeyExists) && ! $overwrite) {
            return;
        }

        $this->routes[$verb][$routeKey] = [
            'name'    => $name,
            'handler' => $to,
            'from'    => $from,
        ];
        $this->routesOptions[$verb][$routeKey] = $options;
        $this->routesNames[$verb][$name]       = $routeKey;

        // Is this a redirect?
        if (isset($options['redirect']) && is_numeric($options['redirect'])) {
            $this->routes['*'][$routeKey]['redirect'] = $options['redirect'];
        }
    }

    /**
     * Compares the hostname passed in against the current hostname
     * on this page request.
     *
     * @param list<string>|string $hostname Hostname in route options
     */
    private function checkHostname($hostname): bool
    {
        // CLI calls can't be on hostname.
        if (! isset($this->httpHost)) {
            return false;
        }

        // Has multiple hostnames
        if (is_array($hostname)) {
            $hostnameLower = array_map(strtolower(...), $hostname);

            return in_array(strtolower($this->httpHost), $hostnameLower, true);
        }

        return strtolower($this->httpHost) === strtolower($hostname);
    }

    private function processArrayCallableSyntax(string $from, array $to): string
    {
        // [classname, method]
        // eg, [Home::class, 'index']
        if (is_callable($to, true, $callableName)) {
            // If the route has placeholders, add params automatically.
            $params = $this->getMethodParams($from);

            return '\\' . $callableName . $params;
        }

        // [[classname, method], params]
        // eg, [[Home::class, 'index'], '$1/$2']
        if (
            isset($to[0], $to[1])
            && is_callable($to[0], true, $callableName)
            && is_string($to[1])
        ) {
            $to = '\\' . $callableName . '/' . $to[1];
        }

        return $to;
    }

    /**
     * Returns the method param string like `/$1/$2` for placeholders
     */
    private function getMethodParams(string $from): string
    {
        preg_match_all('/\(.+?\)/', $from, $matches);
        $count = count($matches[0]);

        $params = '';

        for ($i = 1; $i <= $count; $i++) {
            $params .= '/$' . $i;
        }

        return $params;
    }

    /**
     * Compares the subdomain(s) passed in against the current subdomain
     * on this page request.
     *
     * @param list<string>|string $subdomains
     */
    private function checkSubdomains($subdomains): bool
    {
        // CLI calls can't be on subdomain.
        if (! isset($this->httpHost)) {
            return false;
        }

        if ($this->currentSubdomain === null) {
            $this->currentSubdomain = $this->determineCurrentSubdomain();
        }

        if (! is_array($subdomains)) {
            $subdomains = [$subdomains];
        }

        // Routes can be limited to any sub-domain. In that case, though,
        // it does require a sub-domain to be present.
        if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) {
            return true;
        }

        return in_array($this->currentSubdomain, $subdomains, true);
    }

    /**
     * Examines the HTTP_HOST to get the best match for the subdomain. It
     * won't be perfect, but should work for our needs.
     *
     * It's especially not perfect since it's possible to register a domain
     * with a period (.) as part of the domain name.
     *
     * @return false|string the subdomain
     */
    private function determineCurrentSubdomain()
    {
        // We have to ensure that a scheme exists
        // on the URL else parse_url will mis-interpret
        // 'host' as the 'path'.
        $url = $this->httpHost;
        if (! str_starts_with($url, 'http')) {
            $url = 'http://' . $url;
        }

        $parsedUrl = parse_url($url);

        $host = explode('.', $parsedUrl['host']);

        if ($host[0] === 'www') {
            unset($host[0]);
        }

        // Get rid of any domains, which will be the last
        unset($host[count($host) - 1]);

        // Account for .co.uk, .co.nz, etc. domains
        if (end($host) === 'co') {
            $host = array_slice($host, 0, -1);
        }

        // If we only have 1 part left, then we don't have a sub-domain.
        if (count($host) === 1) {
            // Set it to false so we don't make it back here again.
            return false;
        }

        return array_shift($host);
    }

    /**
     * Reset the routes, so that a test case can provide the
     * explicit ones needed for it.
     *
     * @return void
     */
    public function resetRoutes()
    {
        $this->routes = $this->routesNames = ['*' => []];

        foreach ($this->defaultHTTPMethods as $verb) {
            $this->routes[$verb]      = [];
            $this->routesNames[$verb] = [];
        }

        $this->routesOptions = [];

        $this->prioritizeDetected = false;
        $this->didDiscover        = false;
    }

    /**
     * Load routes options based on verb
     *
     * @return array<
     *     string,
     *     array{
     *         filter?: list<string>|string, namespace?: string, hostname?: string,
     *         subdomain?: string, offset?: int, priority?: int, as?: string,
     *         redirect?: int
     *     }
     * >
     */
    protected function loadRoutesOptions(?string $verb = null): array
    {
        $verb ??= $this->getHTTPVerb();

        $options = $this->routesOptions[$verb] ?? [];

        if (isset($this->routesOptions['*'])) {
            foreach ($this->routesOptions['*'] as $key => $val) {
                if (isset($options[$key])) {
                    $extraOptions  = array_diff_key($val, $options[$key]);
                    $options[$key] = array_merge($options[$key], $extraOptions);
                } else {
                    $options[$key] = $val;
                }
            }
        }

        return $options;
    }

    /**
     * Enable or Disable sorting routes by priority
     *
     * @param bool $enabled The value status
     *
     * @return $this
     */
    public function setPrioritize(bool $enabled = true)
    {
        $this->prioritize = $enabled;

        return $this;
    }

    /**
     * Get all controllers in Route Handlers
     *
     * @param string|null $verb HTTP verb like `GET`,`POST` or `*` or `CLI`.
     *                          `'*'` returns all controllers in any verb.
     *
     * @return list<string> controller name list
     *
     * @interal
     */
    public function getRegisteredControllers(?string $verb = '*'): array
    {
        if ($verb !== '*' && $verb === strtolower($verb)) {
            @trigger_error(
                'Passing lowercase HTTP method "' . $verb . '" is deprecated.'
                . ' Use uppercase HTTP method like "' . strtoupper($verb) . '".',
                E_USER_DEPRECATED,
            );
        }

        /**
         * @deprecated 4.5.0
         * @TODO Remove this in the future.
         */
        $verb = strtoupper($verb);

        $controllers = [];

        if ($verb === '*') {
            foreach ($this->defaultHTTPMethods as $tmpVerb) {
                foreach ($this->routes[$tmpVerb] as $route) {
                    $controller = $this->getControllerName($route['handler']);
                    if ($controller !== null) {
                        $controllers[] = $controller;
                    }
                }
            }
        } else {
            $routes = $this->getRoutes($verb);

            foreach ($routes as $handler) {
                $controller = $this->getControllerName($handler);
                if ($controller !== null) {
                    $controllers[] = $controller;
                }
            }
        }

        return array_unique($controllers);
    }

    /**
     * @param (Closure(mixed...): (ResponseInterface|string|void))|string $handler Handler
     *
     * @return string|null Controller classname
     */
    private function getControllerName($handler)
    {
        if (! is_string($handler)) {
            return null;
        }

        [$controller] = explode('::', $handler, 2);

        return $controller;
    }

    /**
     * Set The flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
     */
    public function useSupportedLocalesOnly(bool $useOnly): self
    {
        $this->useSupportedLocalesOnly = $useOnly;

        return $this;
    }

    /**
     * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales
     */
    public function shouldUseSupportedLocalesOnly(): bool
    {
        return $this->useSupportedLocalesOnly;
    }
}
