<?php
// RouterTest.php
// Created: 2022-01-20
// Updated: 2025-03-15

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri};
use Index\Http\Content\{FormContent,MultipartFormContent,UrlEncodedFormContent};
use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon,Router};
use Index\Http\Routing\Filters\{FilterInfo,PrefixFilter};
use Index\Http\Routing\Processors\{After,Before,Postprocessor,Preprocessor,ProcessorInfo};
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute,RouteInfo};
use Index\Http\Streams\Stream;

/**
 * This test isn't super representative of the current functionality
 *  it mostly just does the same tests that were done against the previous implementation
 */
#[CoversClass(Router::class)]
#[CoversClass(RouteInfo::class)]
#[CoversClass(PrefixFilter::class)]
#[CoversClass(ExactRoute::class)]
#[CoversClass(RouteHandler::class)]
#[CoversClass(RouteHandlerCommon::class)]
#[CoversClass(PatternRoute::class)]
#[CoversClass(FilterInfo::class)]
final class RouterTest extends TestCase {
    public function testRouter(): void {
        $router1 = new Router;

        $router1->route(RouteInfo::exact('GET', '/', fn() => 'get'));
        $router1->route(RouteInfo::exact('POST', '/', fn() => 'post'));
        $router1->route(RouteInfo::exact('DELETE', '/', fn() => 'delete'));
        $router1->route(RouteInfo::exact('PATCH', '/', fn() => 'patch'));
        $router1->route(RouteInfo::exact('PUT', '/', fn() => 'put'));
        $router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky'));

        $this->assertEquals('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
        $this->assertEquals('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody());

        $router1->filter(FilterInfo::prefix('/', function() { /* this one intentionally does nothing */ }));

        // registration order should matter
        $router1->filter(FilterInfo::prefix('/deep', fn() => 'deep'));

        $router1->filter(FilterInfo::pattern('#^/user/([A-Za-z0-9]+)/below#u', fn(string $user) => 'warioware below ' . $user));

        $router1->route(RouteInfo::exact('GET', '/user/static', fn() => 'the static one'));
        $router1->route(RouteInfo::exact('GET', '/user/static/below', fn() => 'below the static one'));
        $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)$#uD', fn(string $user) => $user));
        $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)/below$#uD', fn(string $user) => 'below ' . $user));

        $this->assertEquals('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody());

        $router2 = new Router;
        $router2->filter(FilterInfo::prefix('/', fn() => 'meow'));
        $router2->route(RouteInfo::exact('GET', '/rules', fn() => 'rules page'));
        $router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page'));
        $router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test'));

        $this->assertEquals('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody());
    }

    public function testAttribute(): void {
        $router = new Router;
        $handler = new class implements RouteHandler {
            use RouteHandlerCommon;

            #[ExactRoute('GET', '/')]
            public function getIndex(): string {
                return 'index';
            }

            #[ExactRoute('POST', '/avatar')]
            public function postAvatar(): string {
                return 'avatar';
            }

            #[ExactRoute('PUT', '/static')]
            public static function putStatic(): string {
                return 'static';
            }

            #[ExactRoute('GET', '/meow')]
            #[ExactRoute('POST', '/meow')]
            public function multiple(): string {
                return 'meow';
            }

            #[PrefixFilter('/filter')]
            public function useFilter(): string {
                return 'this intercepts';
            }

            #[ExactRoute('GET', '/filter')]
            public function getFilter(): string {
                return 'this is intercepted';
            }

            #[PatternRoute('GET', '/profile/([A-Za-z0-9]+)')]
            public function getPattern(string $beans): string {
                return sprintf('profile of %s', $beans);
            }

            #[PatternRoute('GET', '#^/profile-but-raw/([A-Za-z0-9]+)$#uD', raw: true)]
            public function getPatternRaw(string $beans): string {
                return sprintf('still the profile of %s', $beans);
            }

            public function hasNoAttr(): string {
                return 'not a route';
            }
        };

        $router->register($handler);

        $this->assertEquals('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
        $this->assertEquals('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody());
        $this->assertEquals('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody());
        $this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody());
        $this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody());
        $this->assertEquals('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody());
        $this->assertEquals('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody());
    }

    public function testEEPROMSituation(): void {
        $router = new Router;
        $router->route(RouteInfo::pattern('GET', '#^/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?$#uD', fn(string $id) => "Get {$id}"));
        $router->route(RouteInfo::pattern('DELETE', '#^/uploads/([A-Za-z0-9\-_]+)$#uD', fn(string $id) => "Delete {$id}"));

        // make sure both GET and DELETE are able to execute with a different pattern
        $this->assertEquals('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody());
        $this->assertEquals('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody());
    }

    public function testFilterInterceptionOnRoot(): void {
        $router = new Router;
        $router->filter(FilterInfo::prefix('/', fn() => 'expected'));
        $router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected'));
        $router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected'));

        $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
        $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody());
        $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody());
    }

    public function testDefaultOptionsImplementation(): void {
        $router = new Router;
        $router->route(RouteInfo::exact('GET', '/test', fn() => 'get'));
        $router->route(RouteInfo::exact('POST', '/test', fn() => 'post'));
        $router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch'));

        $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/test'));
        $this->assertEquals(204, $response->getStatusCode());
        $this->assertEquals('No Content', $response->getReasonPhrase());
        $this->assertEquals('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
    }

    public function testDefaultProcessorsNoRegister(): void {
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage(sprintf('preprocessor "%s" was not found', 'input:urlencoded:required'));

        $router = new Router(registerDefaultProcessors: false);
        $router->route(RouteInfo::exact(
            'GET', '/soap',
            #[Before('input:urlencoded:required')]
            function() {
                return 'test';
            },
        ));
        $router->handle(HttpRequest::createRequestWithoutBody('GET', '/soap'));
    }

    public function testDefaultProcessors(): void {
        $router = new Router;
        $router->register(new class implements RouteHandler {
            use RouteHandlerCommon;

            #[Before('input:urlencoded', required: false)]
            #[Before('input:multipart', required: false)]
            #[ExactRoute('POST', '/optional-form')]
            public function formOptional(?FormContent $content = null): string {
                if($content instanceof MultipartFormContent)
                    return 'multipart:' . (string)$content->getParam('test');
                if($content instanceof UrlEncodedFormContent)
                    return 'urlencoded:' . (string)$content->getParam('test');
                return 'none';
            }

            #[Before('input:multipart')]
            #[ExactRoute('POST', '/required-multipart')]
            public function multipartRequired(MultipartFormContent $content): string {
                return (string)$content->getParam('test');
            }

            #[Before('input:urlencoded')]
            #[ExactRoute('POST', '/required-urlencoded')]
            public function urlencodedRequired(UrlEncodedFormContent $content): string {
                return (string)$content->getParam('test');
            }
        });

        $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/optional-form'));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('none', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', [], Stream::createStream('test=mewow')));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('none', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=mewow')));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('urlencoded:mewow', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
            '----soap12345',
            'Content-Disposition: form-data; name="test"',
            '',
            'wowof',
            '----soap12345--',
            '',
        ]))));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('multipart:wowof', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-urlencoded'));
        $this->assertEquals(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-multipart'));
        $this->assertEquals(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=meow')));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('meow', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream(implode("\r\n", [
            '----soap56789',
            'Content-Disposition: form-data; name="test"',
            '',
            'woof',
            '----soap56789--',
            '',
        ]))));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('woof', (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream('test=meow')));
        $this->assertEquals(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream(implode("\r\n", [
            '----soap56789',
            'Content-Disposition: form-data; name="test"',
            '',
            'woof',
            '----soap56789--',
            '',
        ]))));
        $this->assertEquals(400, $response->getStatusCode());

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data']], Stream::createStream(implode("\r\n", [
            '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
            'Content-Disposition: form-data; name="test"',
            '',
            'the',
            '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--',
            '',
        ]))));
        $this->assertEquals(400, $response->getStatusCode());
    }

    public function testProcessors(): void {
        $router = new Router;

        $router->register(new class implements RouteHandler {
            use RouteHandlerCommon;

            #[Preprocessor('base64-decode-body')]
            public function decodePre(HandlerContext $context, HttpRequest $request): void {
                $context->addArgument(base64_decode((string)$request->getBody()));
            }

            #[Postprocessor('crash')]
            public function crashPost(): void {
                throw new RuntimeException('this shouldnt run!');
            }

            #[Postprocessor('json-encode')]
            public function encodePost(HandlerContext $context, int $flags): void {
                $context->halt();
                $context->response->setTypeJson();
                $context->response->body = Stream::createStream(
                    json_encode($context->result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | $flags)
                );
            }

            /** @return array{data: string} */
            #[ExactRoute('POST', '/test')]
            #[Before('base64-decode-body')]
            #[After('json-encode', flags: JSON_PRETTY_PRINT)]
            #[After('crash')]
            public function route(string $decoded): array {
                return ['data' => $decoded];
            }
        });

        $router->preprocessor(ProcessorInfo::pre('intercept', function(HttpResponseBuilder $response) {
            $response->statusCode = 403;
            return 'it can work like a filter too';
        }));

        $router->route(RouteInfo::exact(
            'PUT', '/alternate',
            #[Before('base64-decode-body')]
            #[After('json-encode', flags: JSON_PRETTY_PRINT)]
            function(HttpRequest $request, string $decoded): array {
                return ['path' => $request->uri->path, 'data' => $decoded];
            }
        ));

        $router->route(RouteInfo::exact(
            'GET', '/filtered',
            #[Before('intercept')]
            #[Before('base64-decode-body')]
            function(): string {
                return 'should not get here';
            }
        ));

        $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/test', [], Stream::createStream(base64_encode('mewow'))));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals("{\n    \"data\": \"mewow\"\n}", (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithBody('PUT', '/alternate', [], Stream::createStream(base64_encode('soap'))));
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals("{\n    \"path\": \"/alternate\",\n    \"data\": \"soap\"\n}", (string)$response->getBody());

        $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/filtered'));
        $this->assertEquals(403, $response->getStatusCode());
        $this->assertEquals('it can work like a filter too', (string)$response->getBody());
    }
}