Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.81% covered (success)
93.81%
288 / 307
85.71% covered (success)
85.71%
30 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
Http
93.81% covered (success)
93.81%
288 / 307
85.71% covered (success)
85.71%
30 / 35
148.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 seed
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 getBasePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRoute
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 applyMethodToNestedRoutes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 normalizeMethods
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isMethodGroupKey
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
10
 prepare
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
16
 hasRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 hasMethodMismatch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedMethods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 noRouteFound
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 methodNotAllowed
38.10% covered (warning)
38.10%
8 / 21
0.00% covered (danger)
0.00%
0 / 1
10.93
 acceptsHtml
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 head
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 put
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 trace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 patch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addVerbRoute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addCustomMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addCustomMethods
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasCustomMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __call
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 flattenRoutes
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
11.07
 getRouteRegex
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
7
 getRouteSpecificity
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 parseRouteParams
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
13
 getUrl
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
15.07
 resolveNamedParamValue
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Router\Match;
16
17use Pop\Http\Server\AcceptHeader;
18use Pop\Http\Server\AcceptSpecificity;
19
20/**
21 * Pop router HTTP match class
22 *
23 * @category   Pop
24 * @package    Pop\Router
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    5.0.0
29 */
30class Http extends AbstractMatch
31{
32
33    /**
34     * Base path
35     * @var ?string
36     */
37    protected ?string $basePath = null;
38
39    /**
40     * Map of synthetic method-scoped route keys (see addRoute()) back to their
41     * real route string, used only internally by flattenRoutes() to recover the
42     * real path for regex generation
43     * @var array
44     */
45    protected array $methodRoutes = [];
46
47    /**
48     * Whitelisted custom HTTP methods, for use with __call()
49     * @var array
50     */
51    protected array $customMethods = [];
52
53    /**
54     * Specificity score per prepared route, keyed the same as preparedRoutes.
55     * Kept out of the preparedRoutes entries themselves so it doesn't leak into
56     * getRouteConfig()/getPreparedRoutes() public output.
57     * @var array
58     */
59    protected array $routeSpecificity = [];
60
61    /**
62     * Prepared route keys grouped by their literal route path, so a direct
63     * (literal, non-regex) match only has to scan that path's method
64     * variants instead of every prepared route.
65     * @var array
66     */
67    protected array $preparedRoutesByLiteral = [];
68
69    /**
70     * Flag set by match(): true when the request path matched at least one
71     * route whose method constraint rejected the request, and nothing else
72     * (no wildcard/dynamic fallback) ended up handling it
73     * @var bool
74     */
75    protected bool $methodMismatch = false;
76
77    /**
78     * Methods accepted by at least one path-matching route, populated by
79     * match() for use in a 405 response's Allow header
80     * @var array
81     */
82    protected array $allowedMethods = [];
83
84    /**
85     * Constructor
86     *
87     * Instantiate the HTTP match object
88     */
89    public function __construct()
90    {
91        $basePath       = str_replace([realpath($_SERVER['DOCUMENT_ROOT']), '\\'], ['', '/'], realpath(getcwd()));
92        $this->basePath = !empty($basePath) ? $basePath : '';
93
94        $path = ($this->basePath != '') ?
95            substr($_SERVER['REQUEST_URI'], strlen($this->basePath)) : $_SERVER['REQUEST_URI'];
96
97        $this->seed($path);
98    }
99
100    /**
101     * Seed the parsing inputs (segments and route string) from either
102     * pre-split path segments or a raw path string
103     *
104     * @param  string|array $input
105     * @return void
106     */
107    protected function seed(string|array $input): void
108    {
109        $trailingSlash = null;
110
111        if (is_array($input)) {
112            $segments = array_values($input);
113        } else {
114            $path = $input;
115
116            // Trim query string, if present
117            if (strpos($path, '?')) {
118                $path = substr($path, 0, strpos($path, '?'));
119            }
120
121            // Trim trailing slash, if present
122            if (str_ends_with($path, '/')) {
123                $path          = substr($path, 0, -1);
124                $trailingSlash = '/';
125            }
126
127            $segments = ($path == '') ? [] : explode('/', substr($path, 1));
128        }
129
130        if (count($segments) === 0) {
131            $this->segments    = ['index'];
132            $this->routeString = '/';
133        } else {
134            $this->segments    = $segments;
135            $this->routeString = '/' . implode('/', $segments) . $trailingSlash;
136        }
137    }
138
139    /**
140     * Get the base path
141     *
142     * @return string
143     */
144    public function getBasePath(): string
145    {
146        return $this->basePath;
147    }
148
149    /**
150     * Add a route
151     *
152     * Overrides AbstractMatch::addRoute() only for a regular route (not
153     * wildcard, not dynamic) that carries a 'method' constraint - those are
154     * stored under a synthetic composite key (route + a null-byte-separated
155     * method signature) instead of the plain route string, so registering the
156     * same path for multiple methods with different controllers (GET and POST
157     * on the same URI) doesn't collide with AbstractMatch::addRoute()'s
158     * merge-on-duplicate-key behavior, which would otherwise silently
159     * overwrite the earlier registration. Every other case (no method key,
160     * wildcard routes, dynamic routes, nested route arrays) falls through to
161     * the parent implementation unchanged.
162     *
163     * @param  string $route
164     * @param  mixed  $controller
165     * @return AbstractMatch
166     */
167    public function addRoute(string $route, mixed $controller): AbstractMatch
168    {
169        // Popcorn-style method-grouped nested routes, e.g.:
170        //   'options,get' => ['/users' => [...], '/roles' => [...]]
171        // Each nested route is re-registered under its own key with the group's
172        // method list injected at every leaf config underneath it - overriding
173        // any 'method' key a leaf already carries, since the group is
174        // authoritative (matches Popcorn's original grouping semantics). The
175        // injection recurses through arbitrarily-nested route sub-trees (e.g.
176        // '/users' => ['[/]' => [...], '/count' => [...]]) rather than only the
177        // group's immediate children, since a shallow injection would plant
178        // 'method' as a sibling key indistinguishable from a route segment.
179        if ($this->isMethodGroupKey($route) && is_array($controller) && !isset($controller['controller'])) {
180            foreach ($controller as $nestedRoute => $nestedController) {
181                $this->addRoute($nestedRoute, $this->applyMethodToNestedRoutes($nestedController, $route));
182            }
183
184            return $this;
185        }
186
187        $controller = $this->normalizeController($controller);
188
189        if (!empty($controller['method']) && isset($controller['controller']) && ($route !== '*') &&
190            !str_ends_with($route, '/*') && !str_contains($route, ':controller')) {
191            $methods = $this->normalizeMethods($controller['method']);
192            $key     = $route . "\0" . implode(',', $methods);
193
194            $this->methodRoutes[$key] = $route;
195            $this->routes[$key]       = $controller;
196
197            return $this;
198        }
199
200        return parent::addRoute($route, $controller);
201    }
202
203    /**
204     * Recursively apply a method-group's method list to every leaf config in a
205     * (possibly further-nested) route sub-tree, without disturbing the
206     * sub-tree's own nested route keys
207     *
208     * @param  mixed  $controller
209     * @param  string $method
210     * @return mixed
211     */
212    protected function applyMethodToNestedRoutes(mixed $controller, string $method): mixed
213    {
214        $controller = $this->normalizeController($controller);
215
216        if (is_array($controller) && !isset($controller['controller'])) {
217            foreach ($controller as $key => $value) {
218                $controller[$key] = $this->applyMethodToNestedRoutes($value, $method);
219            }
220            return $controller;
221        }
222
223        $controller['method'] = $method;
224
225        return $controller;
226    }
227
228    /**
229     * Normalize a 'method' config value (a method string, a comma-separated
230     * string, or an array of method strings) into a sorted, deduplicated,
231     * lowercase array. Returns null for an empty/absent value ("any method").
232     *
233     * @param  mixed $method
234     * @return ?array
235     */
236    protected function normalizeMethods(mixed $method): ?array
237    {
238        if (empty($method)) {
239            return null;
240        }
241
242        if (is_string($method)) {
243            $method = array_map('trim', explode(',', $method));
244        }
245
246        $method = array_unique(array_map('strtolower', (array)$method));
247        sort($method);
248
249        return $method;
250    }
251
252    /**
253     * Determine if a route key is a Popcorn-style method-group key, e.g.
254     * 'get', 'options,get', 'options,post' - a bare, comma-separated list of
255     * recognized HTTP methods only, never a real route path (which always
256     * starts with '/', is '*', or contains ':controller').
257     *
258     * @param  string $route
259     * @return bool
260     */
261    protected function isMethodGroupKey(string $route): bool
262    {
263        if (($route === '') || ($route === '*') || str_starts_with($route, '/') ||
264            str_ends_with($route, '/*') || str_contains($route, ':controller')) {
265            return false;
266        }
267
268        $standardMethods = ['get', 'head', 'post', 'put', 'delete', 'trace', 'options', 'connect', 'patch'];
269
270        foreach (explode(',', strtolower($route)) as $method) {
271            $method = trim($method);
272            if (($method === '') || (!in_array($method, $standardMethods) && !$this->hasCustomMethod($method))) {
273                return false;
274            }
275        }
276
277        return true;
278    }
279
280    /**
281     * Prepare the routes
282     *
283     * @return static
284     */
285    public function prepare(): static
286    {
287        $this->flattenRoutes($this->routes);
288
289        uksort($this->preparedRoutes, function($keyA, $keyB) {
290            $scoreA = $this->routeSpecificity[$keyA] ?? '';
291            $scoreB = $this->routeSpecificity[$keyB] ?? '';
292            return strcmp($scoreB, $scoreA);
293        });
294
295        return $this;
296    }
297
298    /**
299     * Match the route
300     *
301     * @param  string|array|null $forceRoute
302     * @return bool
303     */
304    public function match(mixed $forceRoute = null): bool
305    {
306        if (count($this->preparedRoutes) == 0) {
307            $this->prepare();
308        }
309
310        $this->route                = null;
311        $this->methodMismatch       = false;
312        $this->allowedMethods       = [];
313        $this->routeParams          = [];
314        $this->dispatchableResolved = false;
315
316        if ($forceRoute !== null) {
317            $this->seed($forceRoute);
318        }
319
320        $routeToMatch  = $this->routeString;
321        $requestMethod = strtolower((string)($_SERVER['REQUEST_METHOD'] ?? 'get'));
322        $pathMatched   = false;
323        $directMatch   = null;
324
325        if (array_key_exists($routeToMatch, $this->routes)) {
326            $directMatch = $routeToMatch;
327        } else if (array_key_exists($routeToMatch . '/', $this->routes)) {
328            $directMatch = $routeToMatch . '/';
329        } else if (array_key_exists($routeToMatch . '[/]', $this->routes)) {
330            $directMatch = $routeToMatch . '[/]';
331        }
332
333        if ($directMatch !== null) {
334            foreach (($this->preparedRoutesByLiteral[$directMatch] ?? []) as $key) {
335                $controller  = $this->preparedRoutes[$key];
336                $pathMatched = true;
337                if (($controller['method'] === null) || in_array($requestMethod, $controller['method'])) {
338                    $this->route = $key;
339                    break;
340                }
341                $this->allowedMethods = array_merge($this->allowedMethods, $controller['method']);
342            }
343        }
344
345        if ($this->route === null) {
346            foreach ($this->preparedRoutes as $key => $controller) {
347                if (preg_match($controller['regex'], $routeToMatch) == 0) {
348                    continue;
349                }
350                $pathMatched = true;
351                if (($controller['method'] === null) || in_array($requestMethod, $controller['method'])) {
352                    $this->route = $key;
353                    break;
354                }
355                $this->allowedMethods = array_merge($this->allowedMethods, $controller['method']);
356            }
357        }
358
359        $this->allowedMethods = array_values(array_unique($this->allowedMethods));
360        $this->methodMismatch = ($this->route === null) && $pathMatched;
361
362        $this->parseRouteParams();
363
364        return $this->hasRoute();
365    }
366
367    /**
368     * Determine if the route has been matched
369     *
370     * @return bool
371     */
372    public function hasRoute(): bool
373    {
374        return ($this->route !== null) || $this->matchesDynamicRoute() || ($this->defaultRoute !== null);
375    }
376
377    /**
378     * Determine if match() found a route whose path matched but whose method
379     * constraint rejected the request, with nothing else available to handle it
380     *
381     * @return bool
382     */
383    public function hasMethodMismatch(): bool
384    {
385        return $this->methodMismatch;
386    }
387
388    /**
389     * Get the methods accepted by at least one path-matching route from the
390     * last match() call, for use in a 405 response's Allow header
391     *
392     * @return array
393     */
394    public function getAllowedMethods(): array
395    {
396        return $this->allowedMethods;
397    }
398
399    /**
400     * Method to process if a route was not found
401     *
402     * @param  bool $exit
403     * @return void
404     */
405    public function noRouteFound(bool $exit = true): void
406    {
407        if ($this->acceptsHtml()) {
408            if (!headers_sent()) {
409                header('HTTP/1.1 404 Not Found');
410            }
411            echo '<!DOCTYPE html>' . PHP_EOL;
412            echo '<html>' . PHP_EOL;
413            echo '    <head>' . PHP_EOL;
414            echo '        <title>Page Not Found</title>' . PHP_EOL;
415            echo '    </head>' . PHP_EOL;
416            echo '<body>' . PHP_EOL;
417            echo '    <h1>Page Not Found</h1>' . PHP_EOL;
418            echo '</body>' . PHP_EOL;
419            echo '</html>'. PHP_EOL;
420        } else {
421            if (!headers_sent()) {
422                header('HTTP/1.1 404 Not Found');
423                header('Content-Type: application/json');
424            }
425            echo json_encode(['error' => 'Not Found'], JSON_PRETTY_PRINT) . PHP_EOL;
426        }
427
428        if ($exit) {
429            exit();
430        }
431    }
432
433    /**
434     * Method to process if a route matched the path but not the HTTP method
435     *
436     * @param  array $allowedMethods
437     * @param  bool  $exit
438     * @return void
439     */
440    public function methodNotAllowed(array $allowedMethods, bool $exit = true): void
441    {
442        $allowed = implode(', ', array_map('strtoupper', $allowedMethods));
443
444        if ($this->acceptsHtml()) {
445            if (!headers_sent()) {
446                header('HTTP/1.1 405 Method Not Allowed');
447                header('Allow: ' . $allowed);
448            }
449            echo '<!DOCTYPE html>' . PHP_EOL;
450            echo '<html>' . PHP_EOL;
451            echo '    <head>' . PHP_EOL;
452            echo '        <title>Method Not Allowed</title>' . PHP_EOL;
453            echo '    </head>' . PHP_EOL;
454            echo '<body>' . PHP_EOL;
455            echo '    <h1>Method Not Allowed</h1>' . PHP_EOL;
456            echo '</body>' . PHP_EOL;
457            echo '</html>'. PHP_EOL;
458        } else {
459            if (!headers_sent()) {
460                header('HTTP/1.1 405 Method Not Allowed');
461                header('Allow: ' . $allowed);
462                header('Content-Type: application/json');
463            }
464            echo json_encode(['error' => 'Method Not Allowed', 'allowed' => array_map('strtoupper', $allowedMethods)], JSON_PRETTY_PRINT) . PHP_EOL;
465        }
466
467        if ($exit) {
468            exit();
469        }
470    }
471
472    /**
473     * Determine if the inbound request has a real preference for an HTML response
474     *
475     * HTML only wins on a real preference (AcceptSpecificity::Loose - a bare '*\/*'
476     * doesn't count); everything else falls back to JSON. That's the opposite of
477     * checking for an explicit JSON preference - a real browser always states
478     * 'text/html' explicitly in its Accept header, while non-browser HTTP clients
479     * (curl, most API callers) are wildly inconsistent about declaring
480     * 'application/json' and commonly send a bare '*\/*' or no Accept header at all.
481     *
482     * @return bool
483     */
484    public function acceptsHtml(): bool
485    {
486        $accept = new AcceptHeader($_SERVER['HTTP_ACCEPT'] ?? null);
487        return $accept->accepts('text/html', AcceptSpecificity::Loose);
488    }
489
490    /**
491     * Add a GET route
492     *
493     * @param  string $route
494     * @param  mixed  $controller
495     * @return static
496     */
497    public function get(string $route, mixed $controller): static
498    {
499        return $this->addVerbRoute('get', $route, $controller);
500    }
501
502    /**
503     * Add a HEAD route
504     *
505     * @param  string $route
506     * @param  mixed  $controller
507     * @return static
508     */
509    public function head(string $route, mixed $controller): static
510    {
511        return $this->addVerbRoute('head', $route, $controller);
512    }
513
514    /**
515     * Add a POST route
516     *
517     * @param  string $route
518     * @param  mixed  $controller
519     * @return static
520     */
521    public function post(string $route, mixed $controller): static
522    {
523        return $this->addVerbRoute('post', $route, $controller);
524    }
525
526    /**
527     * Add a PUT route
528     *
529     * @param  string $route
530     * @param  mixed  $controller
531     * @return static
532     */
533    public function put(string $route, mixed $controller): static
534    {
535        return $this->addVerbRoute('put', $route, $controller);
536    }
537
538    /**
539     * Add a DELETE route
540     *
541     * @param  string $route
542     * @param  mixed  $controller
543     * @return static
544     */
545    public function delete(string $route, mixed $controller): static
546    {
547        return $this->addVerbRoute('delete', $route, $controller);
548    }
549
550    /**
551     * Add a TRACE route
552     *
553     * @param  string $route
554     * @param  mixed  $controller
555     * @return static
556     */
557    public function trace(string $route, mixed $controller): static
558    {
559        return $this->addVerbRoute('trace', $route, $controller);
560    }
561
562    /**
563     * Add an OPTIONS route
564     *
565     * @param  string $route
566     * @param  mixed  $controller
567     * @return static
568     */
569    public function options(string $route, mixed $controller): static
570    {
571        return $this->addVerbRoute('options', $route, $controller);
572    }
573
574    /**
575     * Add a CONNECT route
576     *
577     * @param  string $route
578     * @param  mixed  $controller
579     * @return static
580     */
581    public function connect(string $route, mixed $controller): static
582    {
583        return $this->addVerbRoute('connect', $route, $controller);
584    }
585
586    /**
587     * Add a PATCH route
588     *
589     * @param  string $route
590     * @param  mixed  $controller
591     * @return static
592     */
593    public function patch(string $route, mixed $controller): static
594    {
595        return $this->addVerbRoute('patch', $route, $controller);
596    }
597
598    /**
599     * Register a route constrained to a single HTTP method
600     *
601     * @param  string $method
602     * @param  string $route
603     * @param  mixed  $controller
604     * @return static
605     */
606    protected function addVerbRoute(string $method, string $route, mixed $controller): static
607    {
608        $controller = $this->normalizeController($controller);
609        $controller['method'] = $method;
610
611        $this->addRoute($route, $controller);
612
613        return $this;
614    }
615
616    /**
617     * Add a custom HTTP method to the whitelist
618     *
619     * @param  string $method
620     * @return static
621     */
622    public function addCustomMethod(string $method): static
623    {
624        $method = strtolower($method);
625        if (!in_array($method, $this->customMethods)) {
626            $this->customMethods[] = $method;
627        }
628        return $this;
629    }
630
631    /**
632     * Add multiple custom HTTP methods to the whitelist
633     *
634     * @param  array $methods
635     * @return static
636     */
637    public function addCustomMethods(array $methods): static
638    {
639        foreach ($methods as $method) {
640            $this->addCustomMethod($method);
641        }
642        return $this;
643    }
644
645    /**
646     * Determine if a custom HTTP method has been whitelisted
647     *
648     * @param  string $method
649     * @return bool
650     */
651    public function hasCustomMethod(string $method): bool
652    {
653        return in_array(strtolower($method), $this->customMethods);
654    }
655
656    /**
657     * Magic method to register a route for a whitelisted custom HTTP method
658     *
659     * @param  string $name
660     * @param  array  $arguments
661     * @throws Exception
662     * @return static
663     */
664    public function __call(string $name, array $arguments): static
665    {
666        $method = strtolower($name);
667
668        if (!$this->hasCustomMethod($method)) {
669            throw new Exception("Error: The custom method '" . strtoupper($name) . "' is not allowed.");
670        }
671        if (count($arguments) != 2) {
672            throw new Exception('Error: You must pass a route and a controller.');
673        }
674
675        [$route, $controller] = $arguments;
676
677        return $this->addVerbRoute($method, $route, $controller);
678    }
679
680    /**
681     * Flatten the nested routes
682     *
683     * @param  array|string $route
684     * @param  mixed        $controller
685     * @return void
686     */
687    protected function flattenRoutes(array|string $route, mixed $controller = null): void
688    {
689        if (is_array($route)) {
690            foreach ($route as $r => $c) {
691                $this->flattenRoutes($r, $c);
692            }
693        } else if ($controller !== null) {
694            if (!isset($controller['controller'])) {
695                foreach ($controller as $r => $c) {
696                    $this->flattenRoutes($route . $r, $c);
697                }
698            } else {
699                $realRoute  = $this->methodRoutes[$route] ?? $route;
700                $routeRegex = $this->getRouteRegex($realRoute);
701                $methods    = isset($this->methodRoutes[$route]) ?
702                    $this->normalizeMethods($controller['method'] ?? null) : null;
703                $key        = $routeRegex['regex'] . (($methods !== null) ? "\0" . implode(',', $methods) : '');
704
705                $this->preparedRoutes[$key] = array_merge($controller, [
706                    'route'  => $realRoute,
707                    'params' => $routeRegex['params'],
708                    'regex'  => $routeRegex['regex'],
709                    'method' => $methods,
710                ]);
711                $this->routeSpecificity[$key] = $routeRegex['specificity'];
712                $this->preparedRoutesByLiteral[$realRoute][] = $key;
713
714                if (isset($controller['default']) && ($controller['default'])) {
715                    if (isset($controller['action'])) {
716                        unset($controller['action']);
717                    }
718                    $this->defaultRoute['*'] = $controller;
719                }
720            }
721        }
722    }
723
724    /**
725     * Get the REGEX pattern for the route string
726     *
727     * @param  string $route
728     * @return array
729     */
730    protected function getRouteRegex(string $route): array
731    {
732        $specificity = $this->getRouteSpecificity($route);
733
734        $required   = [];
735        $optional   = [];
736        $params     = [];
737        $offsets    = [];
738        $paramArray = false;
739
740        if (str_contains($route, '*')) {
741            $paramArray = true;
742            $route      = str_replace('*', '', $route);
743        }
744
745        preg_match_all('/\[\/\:[^\[]+\]/', $route, $optional, PREG_OFFSET_CAPTURE);
746        preg_match_all('/(?<!\[)\/\:+\w*/', $route, $required, PREG_OFFSET_CAPTURE);
747
748        foreach ($required[0] as $req) {
749            $name      = substr($req[0], (strpos($req[0], ':') + 1));
750            $route     = str_replace($req[0], '/.[a-zA-Z0-9_\.\-\p{L}]*', $route);
751            $offsets[] = $req[1];
752            $params[]  = [
753                'param'    => $req[0],
754                'name'     => $name,
755                'offset'   => $req[1],
756                'required' => true,
757                'array'    => false
758            ];
759        }
760
761        foreach ($optional[0] as $opt) {
762            $name      = substr($opt[0], (strpos($opt[0], ':') + 1), -1);
763            $route     = str_replace($opt[0], '(|/[a-zA-Z0-9_\.\-\p{L}]*)', $route);
764            $offsets[] = $opt[1];
765            $params[]  = [
766                'param'    => $opt[0],
767                'name'     => $name,
768                'offset'   => $opt[1],
769                'required' => false,
770                'array'    => false
771            ];
772        }
773
774        $route = '^' . str_replace('/', '\/', $route) . '$';
775        if (str_ends_with($route, '[\/]$')) {
776            $route = str_replace('[\/]$', '(|\/)$', $route);
777        }
778
779        array_multisort($offsets, SORT_ASC, $params);
780
781        if (($paramArray) && (count($params) > 0)) {
782            $params[(count($params) - 1)]['array'] = true;
783            $route = str_replace('$', '.*', $route);
784        }
785
786        return [
787            'regex'       => '/' . $route . '/u',
788            'params'      => $params,
789            'specificity' => $specificity,
790        ];
791    }
792
793    /**
794     * Score a route's specificity for match-order sorting
795     *
796     * Ranks each segment left to right - a static segment beats a required
797     * param beats an optional param beats an array/wildcard param - as digits
798     * in a fixed-width string, so a plain string comparison gives leftmost-
799     * segment precedence. Catch-all/array-param routes sort below everything
800     * else, matching their existing last-resort role.
801     *
802     * @param  string $route
803     * @return string
804     */
805    protected function getRouteSpecificity(string $route): string
806    {
807        $catchAll = str_contains($route, '*');
808        $route    = str_replace(['*', '[/]'], '', $route);
809        $route    = preg_replace('/\[\/\:[^\[\]]+\]/', "/\x01", $route);
810
811        $digits = '';
812        foreach (array_filter(explode('/', $route), fn($segment) => $segment !== '') as $segment) {
813            $digits .= ($segment === "\x01") ? '1' : (str_starts_with($segment, ':') ? '2' : '3');
814        }
815
816        return ($catchAll ? '0' : '1') . str_pad($digits, 24, '0');
817    }
818
819    /**
820     * Parse route dispatch parameters
821     *
822     * @return void
823     */
824    protected function parseRouteParams(): void
825    {
826        if (($this->route !== null) && isset($this->preparedRoutes[$this->route]['params']) &&
827            (count($this->preparedRoutes[$this->route]['params']) > 0)) {
828            $offset = 0;
829            foreach ($this->preparedRoutes[$this->route]['params'] as $param) {
830                $value = substr($this->routeString, ($param['offset'] + $offset + 1));
831                if ($param['array']) {
832                    if (!$value) {
833                        $value = [];
834                    } else {
835                        $value = (str_contains($value, '/')) ? explode('/', $value) : [$value];
836                    }
837                } else {
838                    if (str_contains($value, '/')) {
839                        $value   = substr($value, 0, strpos($value, '/'));
840                        $offset += strlen($value) - strlen($param['param']) + 1;
841                    } else {
842                        $offset += strlen($value) - strlen($param['param']) + 1;
843                    }
844                }
845                if ($value != '') {
846                    $this->routeParams[$param['name']] = $value;
847                }
848            }
849        } else if ($this->matchesDynamicRoute()) {
850            $offset = $this->getDynamicRouteParamOffset();
851            if (count($this->segments) > $offset) {
852                $this->routeParams = (str_contains((string)$this->dynamicRoute, '/:param*')) ?
853                    [array_slice($this->segments, $offset)] : array_slice($this->segments, $offset);
854            }
855        }
856    }
857
858    /**
859     * Get URL for the named route
860     *
861     * @param  string $routeName
862     * @param  mixed  $params
863     * @param  bool   $fqdn
864     * @return string
865     */
866    public function getUrl(string $routeName, mixed $params = null, bool $fqdn = false): string
867    {
868        $url     = '';
869        $baseUrl = '';
870
871        if ($fqdn) {
872            $baseUrl .= (isset($_SERVER['SERVER_PORT']) && ($_SERVER['SERVER_PORT'] == 443)) ? 'https://' : 'http://';
873            if (isset($_SERVER['HTTP_HOST'])) {
874                $baseUrl .= $_SERVER['HTTP_HOST'];
875            }
876        }
877
878        $baseUrl .= $this->basePath;
879
880        if (isset($this->routeNames[$routeName]) && isset($this->routes[$this->routeNames[$routeName]])) {
881            $route         = $this->routeNames[$routeName];
882            $preparedRoute = null;
883
884            if (count($this->preparedRoutes) == 0) {
885                $this->prepare();
886            }
887
888            foreach ($this->preparedRoutes as $prepRoute) {
889                if ($prepRoute['route'] == $route) {
890                    $preparedRoute = $prepRoute;
891                    break;
892                }
893            }
894
895            if (!empty($params) && !empty($preparedRoute['params'])) {
896                foreach ($preparedRoute['params'] as $param) {
897                    $paramValue = $this->resolveNamedParamValue($params, $param['name']);
898
899                    if ($paramValue !== null) {
900                        if (is_array($paramValue)) {
901                            $paramString  = implode('/', $paramValue);
902                            $paramUrlName = $param['param'] . '*';
903                        } else {
904                            $paramString  = $paramValue;
905                            $paramUrlName = $param['param'];
906                        }
907                    } else {
908                        $paramString  = null;
909                        $paramUrlName = null;
910                    }
911
912                    $route = $baseUrl . str_replace($paramUrlName, '/' . $paramString, $route);
913                }
914            }
915
916            $url = $route;
917        }
918
919        return $url;
920    }
921
922    /**
923     * Resolve a named param's raw value from either an object or an array of params
924     *
925     * @param  mixed  $params
926     * @param  string $paramName
927     * @return mixed
928     */
929    protected function resolveNamedParamValue(mixed $params, string $paramName): mixed
930    {
931        if (is_object($params) && isset($params->{$paramName})) {
932            return $params->{$paramName};
933        } else if (is_array($params) && isset($params[$paramName])) {
934            return $params[$paramName];
935        }
936
937        return null;
938    }
939
940}