<?php

declare(strict_types=1);

namespace Apexl\Io\Tests\FeatureTests;

use Apexl\Config\ConfigurationInterface;
use Apexl\Io\Enums\HttpMethod;
use Apexl\Io\Module;
use Apexl\Io\Routing\DeclaresRoutePermission;
use Apexl\Io\Routing\IoRoute;
use Apexl\Io\Routing\IoRouteGroup;
use Apexl\Io\TestInfrastructure\FeatureTest;
use PHPUnit\Framework\Attributes\Test;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\App;
use Slim\Interfaces\RouteCollectorInterface;
use Slim\Routing\RouteCollectorProxy;

/**
 * Modules register with Slim directly; there is no route manager in between any more.
 *
 * Three things went into Module when it went: a pattern starts with a slash whatever the
 * module's base route looks like, a name registered twice replaces rather than being
 * shadowed, and the module's name rides along as an argument for `routes:dump`. Each is
 * invisible when it works and hard to spot when it stops.
 */
final class ModuleRoutesTest extends FeatureTest
{
    private function module(): Module
    {
        return new readonly class (
            \Apexl\Io\container()->get(ConfigurationInterface::class),
            self::$application,
        ) extends Module {
            public function __construct(
                        ConfigurationInterface $config,
                App $app,
            ) {
                parent::__construct($config, $app);
            }

            public function getName(): string
            {
                return 'TestModule';
            }

            /** Module's routing methods are protected; these expose them to the test. */
            public function register(HttpMethod $verb, string $pattern, mixed $callable): IoRoute
            {
                return $this->route($verb, $pattern, $callable);
            }

            /** @param HttpMethod[] $verbs */
            public function registerMany(array $verbs, string $pattern, mixed $callable): IoRoute
            {
                return $this->route($verbs, $pattern, $callable);
            }

            public function registerGroup(string $pattern, callable $routes): IoRouteGroup
            {
                return $this->group($pattern, $routes);
            }
        };
    }

    private function collector(): RouteCollectorInterface
    {
        return self::$application->getRouteCollector();
    }

    /**
     * A route registered through a module's group carries the module that registered it,
     * which is what `routes:dump` groups by. One registered straight on Slim does not: the
     * module never sees it.
     */
    /** One route, several verbs — Slim takes a list, and this is where they are converted. */
    #[Test]
    public function aRouteCanAnswerSeveralVerbs(): void
    {
        $route = $this->module()
            ->registerMany([HttpMethod::GET, HttpMethod::POST], '/multi', fn() => null)
            ->setName('test.module.multi');

        self::assertSame(['GET', 'POST'], $route->getMethods());
    }

    /**
     * Slim compares the methods it was given to the request's, and normalises neither — a
     * lowercase 'get' registers happily and matches nothing, the same silent failure as a
     * pattern with no leading slash. HttpMethod is converted in exactly one place.
     */
    #[Test]
    public function verbsReachSlimInTheFormItCompares(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/verb', fn() => null)->setName('test.module.verb');

        self::assertSame(['GET'], $route->getMethods());
    }

    /** A route registered through a module carries the module, for `routes:dump`. */
    #[Test]
    public function aRouteCarriesTheModuleThatRegisteredIt(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/owned', fn() => null)->setName('test.module.owned');

        self::assertSame('testmodule', $route->getModule());
    }

    #[Test]
    public function aGroupedRouteCarriesTheModuleThatRegisteredIt(): void
    {
        $this->module()->registerGroup('/test-module-named', function (RouteCollectorProxy $routes): void {
            $routes->get('/thing', fn() => null)->setName('test.module.named');
        });

        $route = $this->collector()->getNamedRoute('test.module.named');
        self::assertInstanceOf(IoRoute::class, $route);
        self::assertSame('testmodule', $route->getModule());
    }

    /**
     * Slim resolves a name to the first route holding it, so a module re-registering a name
     * would leave the older route answering and the new one unreachable — the opposite of
     * what overriding a route intends.
     */
    /**
     * Naming a route takes the name from whatever held it before — wherever it is named.
     *
     * Slim keeps both and resolves the name to the first, so without this a module
     * overriding a route would leave the original answering and its replacement unreachable.
     */
    #[Test]
    public function namingARouteReplacesAnEarlierRouteOfTheSameName(): void
    {
        $module = $this->module();

        $module->register(HttpMethod::GET, '/first-home', fn() => null)->setName('test.module.home');
        $module->register(HttpMethod::GET, '/second-home', fn() => null)->setName('test.module.home');

        self::assertSame(
            '/second-home',
            $this->collector()->getNamedRoute('test.module.home')->getPattern(),
        );

        $patterns = array_map(
            static fn($route): string => $route->getPattern(),
            $this->collector()->getRoutes(),
        );

        self::assertNotContains('/first-home', $patterns, 'The replaced route must not still dispatch.');
    }

    /** A route need not be named; nothing looks it up, and dispatch does not care. */
    #[Test]
    public function aRouteNeedNotBeNamed(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/unnamed', fn() => null);

        self::assertNull($route->getName());
        self::assertSame('testmodule', $route->getModule());
    }

    #[Test]
    public function aGroupReplacesAnEarlierRouteOfTheSameName(): void
    {
        $module = $this->module();

        $module->registerGroup('/test-module-first', function (RouteCollectorProxy $routes): void {
            $routes->get('/original', fn() => null)->setName('test.module.replaced');
        });
        $module->registerGroup('/test-module-second', function (RouteCollectorProxy $routes): void {
            $routes->get('/replacement', fn() => null)->setName('test.module.replaced');
        });

        self::assertSame(
            '/test-module-second/replacement',
            $this->collector()->getNamedRoute('test.module.replaced')->getPattern(),
        );

        $patterns = array_map(
            static fn($route): string => $route->getPattern(),
            $this->collector()->getRoutes(),
        );

        self::assertNotContains('/test-module-first/original', $patterns, 'The replaced route must not still dispatch.');
    }

    /**
     * How permissions reach dispatch. They are kept beside the route rather than on it —
     * Slim's route arguments are deprecated, hold strings only, and are merged into the
     * arguments a request is validated against.
     */
    /**
     * The permission is the middleware that enforces it, and the route can be asked for it —
     * which is the whole reason routes are IoRoute rather than Slim's own. Slim keeps
     * middleware as a chain of anonymous handlers over private state, so a permission
     * attached to a plain route would be a permission nothing could read back.
     */
    #[Test]
    public function aRouteCanBeAskedWhatGatesIt(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/protected', fn() => null)
            ->setName('test.module.protected');
        $route->add(new TestPermissionMiddleware(TestPermission::MustBeSignedIn));

        self::assertSame(
            TestPermission::MustBeSignedIn,
            $route->getMiddleware()->find(DeclaresRoutePermission::class)?->getPermission(),
        );
    }

    /** Nothing is written to the route's arguments; those are for path placeholders. */
    #[Test]
    public function aProtectedRouteCarriesNoRouteArguments(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/clean', fn() => null)->setName('test.module.clean');
        $route->add(new TestPermissionMiddleware(TestPermission::MustBeSignedIn));

        self::assertSame([], $this->collector()->getNamedRoute('test.module.clean')->getArguments());
    }

    /** Nothing attached is not the same as "nobody may see this". */
    #[Test]
    public function aRouteWithNothingAttachedIsUnrestricted(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/open', fn() => null)->setName('test.module.open');

        self::assertNull($route->getMiddleware()->find(DeclaresRoutePermission::class));
    }

    /**
     * Only instances can be found. Slim's deferred path stores a class-string and resolves it
     * at dispatch, so there is no object to hold a permission — a route added that way cannot
     * be asked, and the menu would offer a page it should hide.
     */
    #[Test]
    public function middlewareAddedByClassStringCannotBeAsked(): void
    {
        $route = $this->module()->register(HttpMethod::GET, '/deferred', fn() => null)->setName('test.module.deferred');
        $route->add(TestPermissionMiddleware::class);

        self::assertNull($route->getMiddleware()->find(DeclaresRoutePermission::class));
    }
}

/**
 * A permission need only be an object here. io's own tests do not reach into io-user for one
 * — the registry stores whatever was declared and hands the same thing back.
 */
enum TestPermission
{
    case MustBeSignedIn;
}

/** io has no permissions of its own; the contract is all it needs to ask. */
final readonly class TestPermissionMiddleware implements DeclaresRoutePermission
{
    public function __construct(private TestPermission $permission) {}

    public function getPermission(): object|array
    {
        return $this->permission;
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        return $handler->handle($request);
    }
}
