<?php

declare(strict_types=1);

namespace Apexl\Io\Services;

use Apexl\Io\Exceptions\MissingRouteValueException;
use Apexl\Io\Exceptions\RouteNotFoundException;
use InvalidArgumentException;
use RuntimeException;
use Slim\App;

/**
 * URLs and patterns for routes, by name.
 *
 * Two ways to ask, because there are only two things anyone wants. `pattern()` gives the
 * route as registered, placeholders and all, which is what the front end needs in order to
 * build its own URLs from data it holds and the server does not. `url()` gives a finished
 * URL, and refuses to give a broken one.
 *
 * That refusal is the point. The method this replaces defaulted to deleting any placeholder
 * it had no value for, so a forgotten argument produced a perfectly plausible URL pointing
 * at a different route — no error, no warning, just the wrong link. Optional segments are
 * different and are still dropped: `/login[/{messageType}]` with nothing to say is `/login`,
 * which is what the route means.
 *
 * Reads Slim's named routes; holds no table of its own.
 */
final readonly class RouteUrls
{
    /**
     * `{name}`, `{name:regex}`, and constraints carrying their own braces, as
     * `{id:[0-9]{1,3}}` does.
     */
    private const string PLACEHOLDER = '/\{(\w+)(?::(?:[^{}]|\{[^{}]*\})*)?\}/';

    public function __construct(
        private App $app,
        private HttpPaths $httpPaths,
        private bool $absolute = false,
    ) {}

    /**
     * The same thing, fully qualified: `$routeUrls->absolute()->url('user.login')`.
     *
     * A copy rather than a flag, because this is a container singleton — flipping a mode on
     * it would leave every later caller in that mode.
     */
    public function absolute(): self
    {
        return new self($this->app, $this->httpPaths, true);
    }

    /**
     * The route as registered, placeholders intact — optionally with some of them filled.
     *
     * Partial filling is for a template the front end finishes: a button that knows the
     * company it belongs to but not yet which row was clicked wants
     * `/company/12/sim/{simId}`, not a finished URL and not a bare pattern. Anything not
     * given is left exactly as the route declares it; brackets around optional segments are
     * dropped, since a template is no place for routing syntax.
     *
     * @param array<string, string|int> $values
     *
     * @throws RouteNotFoundException
     */
    public function pattern(string $name, array $values = []): string
    {
        $pattern = $this->registered($name);

        if ($values !== []) {
            $pattern = str_replace(['[', ']'], '', preg_replace_callback(
                self::PLACEHOLDER,
                static fn (array $placeholder): string => isset($values[$placeholder[1]])
                    ? (string) $values[$placeholder[1]]
                    : $placeholder[0],
                $pattern,
            ));
        }

        return $this->qualify($pattern);
    }

    /**
     * Several at once, keyed by name — a page's worth of routes to hand to the front end.
     *
     * @param string[] $names
     *
     * @return array<string, string>
     *
     * @throws RouteNotFoundException
     */
    public function patterns(array $names): array
    {
        return array_combine($names, array_map($this->pattern(...), $names));
    }

    /**
     * A finished URL.
     *
     * Slim's parser does the substituting. It expands the pattern into one expression per
     * combination of optional parameters and takes the most specific one the given values
     * satisfy, so an optional segment with nothing to say drops out — `/login` for
     * `/login[/{messageType}]` — while a missing required value satisfies nothing and
     * raises. Same parser the dispatcher uses, which is the argument for not writing a
     * second one.
     *
     * @param array<string, string|int> $values
     * @param array<string, string|int> $queryParams
     *
     * @throws RouteNotFoundException|MissingRouteValueException
     */
    public function url(string $name, array $values = [], array $queryParams = []): string
    {
        try {
            $url = $this->app->getRouteCollector()
                ->getRouteParser()
                ->urlFor($name, array_map(strval(...), $values), $queryParams);
        } catch (InvalidArgumentException $exception) {
            throw MissingRouteValueException::from($name, $exception);
        } catch (RuntimeException $exception) {
            // getNamedRoute(), reached through the parser, for a name nobody registered.
            throw $this->unknown($name, $exception);
        }

        return $this->qualify($url);
    }

    /**
     * @throws RouteNotFoundException
     */
    private function registered(string $name): string
    {
        try {
            return $this->app->getRouteCollector()->getNamedRoute($name)->getPattern();
        } catch (RuntimeException $exception) {
            throw $this->unknown($name, $exception);
        }
    }

    private function unknown(string $name, RuntimeException $exception): RouteNotFoundException
    {
        return new RouteNotFoundException(
            sprintf('No route found for \'%s\'', $name),
            previous: $exception,
        );
    }

    private function qualify(string $path): string
    {
        return $this->absolute ? $this->httpPaths->backendUrl($path) : $path;
    }
}
