<?php

declare(strict_types=1);

namespace Apexl\Io\Tests\FeatureTests;

use Apexl\Io\Exceptions\MissingRouteValueException;
use Apexl\Io\Exceptions\RouteNotFoundException;
use Apexl\Io\Services\RouteUrls;
use Apexl\Io\TestInfrastructure\FeatureTest;
use PHPUnit\Framework\Attributes\Test;
use Slim\App;

/**
 * Reading routes back by name, without the io route table.
 *
 * The method this replaces defaulted to deleting placeholders it had no value for, so a
 * forgotten argument returned a plausible URL for a different route. Everything here is
 * about the two ways that should be asked instead — and about the promise that both read
 * the same registry Slim serves from, since that is what lets the table go.
 */
final class RouteUrlsTest extends FeatureTest
{
    private function urls(): RouteUrls
    {
        return \Apexl\Io\container()->get(RouteUrls::class);
    }

    private function register(string $suffix, string $pattern): string
    {
        $name = 'test.routeurls.' . $suffix;

        \Apexl\Io\container()->get(App::class)->get($pattern, fn() => null)->setName($name);

        return $name;
    }

    #[Test]
    public function aPatternComesBackAsRegistered(): void
    {
        $name = $this->register('pattern', '/jobs/{id}/edit');

        self::assertSame('/jobs/{id}/edit', $this->urls()->pattern($name));
    }

    /**
     * A template the front end finishes: what it knows filled in, what it does not left as
     * the route declares it.
     */
    #[Test]
    public function aPatternCanBePartlyFilled(): void
    {
        $name = $this->register('partial', '/company/{companyId}/sim/{simId}');

        self::assertSame(
            '/company/12/sim/{simId}',
            $this->urls()->pattern($name, ['companyId' => 12]),
        );
    }

    #[Test]
    public function aPartlyFilledPatternLosesItsOptionalBrackets(): void
    {
        $name = $this->register('partial-optional', '/reports/{year}[/{month}]');

        self::assertSame('/reports/2026/{month}', $this->urls()->pattern($name, ['year' => 2026]));
    }

    #[Test]
    public function aUrlIsFilledIn(): void
    {
        $name = $this->register('url', '/jobs/{id}/edit');

        self::assertSame('/jobs/12/edit', $this->urls()->url($name, ['id' => 12]));
    }

    /**
     * The replacement for `str_replace('{id}', …)`, which never matched a constrained
     * placeholder and so left the pattern — or, with stripping on, a hole where it was.
     */
    #[Test]
    public function aConstrainedPlaceholderIsFilledIn(): void
    {
        $name = $this->register('constrained', '/files/{id:\d+}/{path:.*}');

        self::assertSame('/files/7/a/b.pdf', $this->urls()->url($name, ['id' => 7, 'path' => 'a/b.pdf']));
    }

    /** Where the old default did its damage quietly. */
    #[Test]
    public function aMissingValueIsRefusedRatherThanCutOut(): void
    {
        $name = $this->register('missing', '/jobs/{id}/edit');

        $this->expectException(MissingRouteValueException::class);
        $this->expectExceptionMessage('needs a value for {id}');

        $this->urls()->url($name);
    }

    /**
     * An optional segment says the route means something without it — io-user's login
     * route, `/login[/{loginMessageType}]`, is the real example.
     */
    #[Test]
    public function anOptionalSegmentIsDroppedWhenThereIsNothingToSay(): void
    {
        $name = $this->register('optional', '/login[/{messageType}]');

        self::assertSame('/login', $this->urls()->url($name));
        self::assertSame('/login/expired', $this->urls()->url($name, ['messageType' => 'expired']));
    }

    #[Test]
    public function nestedOptionalSegmentsUnwrapFromTheInside(): void
    {
        $name = $this->register('nested', '/reports[/{year}[/{month}]]');

        self::assertSame('/reports', $this->urls()->url($name));
        self::assertSame('/reports/2026', $this->urls()->url($name, ['year' => 2026]));
        self::assertSame('/reports/2026/08', $this->urls()->url($name, ['year' => 2026, 'month' => '08']));
    }

    #[Test]
    public function absoluteIsTheSameThingFullyQualified(): void
    {
        $name = $this->register('absolute', '/jobs/{id}/edit');

        $absolute = $this->urls()->absolute()->url($name, ['id' => 12]);

        self::assertStringEndsWith('/jobs/12/edit', $absolute);
        self::assertStringStartsWith('http', $absolute);
    }

    /** A singleton that answered relatively must keep answering relatively. */
    #[Test]
    public function absoluteDoesNotLeakIntoTheSharedInstance(): void
    {
        $name = $this->register('no-leak', '/jobs/{id}/edit');
        $urls = $this->urls();

        $urls->absolute()->url($name, ['id' => 12]);

        self::assertSame('/jobs/12/edit', $urls->url($name, ['id' => 12]));
    }

    #[Test]
    public function severalPatternsComeBackKeyedByName(): void
    {
        $first = $this->register('batch-one', '/batch/one/{id}');
        $second = $this->register('batch-two', '/batch/two');

        self::assertSame(
            [$first => '/batch/one/{id}', $second => '/batch/two'],
            $this->urls()->patterns([$first, $second]),
        );
    }

    #[Test]
    public function anUnknownNameIsARouteNotFoundException(): void
    {
        $this->expectException(RouteNotFoundException::class);

        $this->urls()->pattern('test.routeurls.never-registered');
    }

    /**
     * Every route the application registered can be read back by name. There is one table
     * now — Slim's — so this is no longer about two of them agreeing, but it still catches
     * a route registered under a name nothing can look up.
     */
    #[Test]
    public function everyRegisteredRouteCanBeReadBackByName(): void
    {
        $routes = self::$application->getRouteCollector()->getRoutes();
        $urls = $this->urls();

        self::assertNotEmpty($routes, 'The application registered no routes.');

        foreach ($routes as $route) {
            $name = $route->getName();

            if ($name === null) {
                continue;
            }

            self::assertSame(
                $route->getPattern(),
                $urls->pattern($name),
                sprintf('Route "%s" reads back differently from Slim.', $name),
            );
        }
    }
}
