<?php
// HttpUriTest.php
// Created: 2025-02-28
// Updated: 2025-03-08

declare(strict_types=1);

use Index\Http\{HttpRequest,HttpUri};
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,DataProvider};
use Psr\Http\Message\UriInterface;

// based on https://github.com/bakame-php/psr7-uri-interface-tests/blob/5a556fdfe668a6c6a14772efeba6134c0b7dae34/tests/AbstractUriTestCase.php
#[CoversClass(HttpRequest::class)]
#[CoversClass(HttpUri::class)]
final class HttpUriTest extends TestCase {
    private const string URI = 'http://username:pwd@secure.example.com:443/meow/soap.php?soup=beans#mewow';

    /** @return string[][] */
    public static function schemeProvider(): array {
        return [
            ['HtTpS', 'https'], // normalized scheme
            ['http', 'http'], // simple scheme
            ['', ''], // no scheme
        ];
    }

    #[DataProvider('schemeProvider')]
    public function testGetScheme(string $scheme, string $expected): void {
        $uri = HttpUri::createUri(self::URI)->withScheme($scheme);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getScheme());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->scheme);
    }

    /** @return array{string, ?string, string}[] */
    public static function userInfoProvider(): array {
        return [
            ['FreakyFurball', 'Express1', 'FreakyFurball:Express1'], // with userinfo
            ['', '', ''], // no userinfo
            ['Unko', '', 'Unko:'], // no pass
            ['flash', null, 'flash'], // pass is null
            ['Reemo', 'BuddyMan5', 'Reemo:BuddyMan5'], // case sensitive
        ];
    }

    #[DataProvider('userInfoProvider')]
    public function testGetUserInfo(string $user, ?string $pass, string $expected): void {
        $uri = HttpUri::createUri(self::URI)->withUserInfo($user, $pass);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getUserInfo());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->userInfo);
    }

    /** @return string[][] */
    public static function hostProvider(): array {
        return [
            ['MaStEr.eXaMpLe.CoM', 'master.example.com'], // normalized host
            ['www.example.com', 'www.example.com'], // simple host
            ['[::1]', '[::1]'], // IPv6 Host
        ];
    }

    #[DataProvider('hostProvider')]
    public function testGetHost(string $host, string $expected): void {
        $uri = HttpUri::createUri(self::URI)->withHost($host);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getHost());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->host);
    }

    /** @return array{string, ?int, ?int}[] */
    public static function portProvider(): array {
        return [
            ['http://www.example.com', 443, 443], // non standard port for http
            ['http://www.example.com', null, null], // remove port
            ['//www.example.com', 80, 80], // standard port on schemeless http url
        ];
    }

    #[DataProvider('portProvider')]
    public function testGetPort(string $uri, ?int $port, ?int $expected): void {
        $uri = HttpUri::createUri($uri)->withPort($port);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getPort());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->port);
    }

    /** @return array{scheme: string, user: string, pass: ?string, host: string, port: ?int, expected: string}[] */
    public static function authorityProvider(): array {
        return [
            [
                'scheme'    => 'http',
                'user'      => 'FreakyFurball',
                'pass'      => 'Express1',
                'host'      => 'example.com',
                'port'      => 443,
                'expected'  => 'FreakyFurball:Express1@example.com:443',
            ],
            [
                'scheme'    => 'http',
                'user'      => 'Reemo',
                'pass'      => 'BuddyMan5',
                'host'      => 'example.com',
                'port'      => null,
                'expected'  => 'Reemo:BuddyMan5@example.com',
            ],
            [
                'scheme'    => 'http',
                'user'      => 'Unko',
                'pass'      => 'SoapSoapSoapSoapSoap',
                'host'      => 'example.com',
                'port'      => 80,
                'expected'  => 'Unko:SoapSoapSoapSoapSoap@example.com:80',
            ],
            [
                'scheme'    => 'http',
                'user'      => 'flash',
                'pass'      => '',
                'host'      => 'example.com',
                'port'      => null,
                'expected'  => 'flash:@example.com',
            ],
            [
                'scheme'    => 'http',
                'user'      => 'Satori',
                'pass'      => null,
                'host'      => 'example.com',
                'port'      => null,
                'expected'  => 'Satori@example.com',
            ],
            [
                'scheme'    => 'http',
                'user'      => '',
                'pass'      => '',
                'host'      => 'example.com',
                'port'      => null,
                'expected'  => 'example.com',
            ],
        ];
    }

    #[DataProvider('authorityProvider')]
    public function testGetAuthority(string $scheme, string $user, ?string $pass, string $host, ?int $port, string $expected): void {
        $uri = HttpUri::createUri()->withHost($host)->withScheme($scheme)->withUserInfo($user, $pass)->withPort($port);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getAuthority());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->authority);
    }

    /** @return string[][] */
    public static function queryProvider(): array {
        return [
           ['foo.bar=%7evalue', 'foo.bar=%7evalue'], // normalized query
           ['', ''], // empty query
           ['foo.bar=1&foo.bar=1', 'foo.bar=1&foo.bar=1'], // same param query
           ['?foo=', '?foo='], // same param query, may include ?
        ];
    }

    #[DataProvider('queryProvider')]
    public function testGetQuery(string $query, string $expected): void {
        $uri = HttpUri::createUri(self::URI)->withQuery($query);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getQuery());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->query);
    }

    /** @return string[][] */
    public static function fragmentProvider(): array {
        return [
           ['fragment', 'fragment'], // all components
           ['azAZ0-9/?-._~!$&\'()*+,;=:@', 'azAZ0-9/?-._~!$&\'()*+,;=:@'], // non-encodable
        ];
    }

    #[DataProvider('fragmentProvider')]
    public function testGetFragment(string $fragment, string $expected): void {
        $uri = HttpUri::createUri(self::URI)->withFragment($fragment);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertSame($expected, $uri->getFragment());
        $this->assertInstanceOf(HttpUri::class, $uri);
        $this->assertSame($expected, $uri->fragment);
    }

    /** @return array{scheme: string, user: string, pass: ?string, host: string, port: ?int, path: string, query: string, fragment: string, expected: string}[] */
    public static function stringProvider(): array {
        return [
            [
                'scheme'   => 'HtTps',
                'user'     => 'FreakyFurball',
                'pass'     => 'Express1',
                'host'     => 'MeWow.eXaMpLe.CoM',
                'port'     => 443,
                'path'     => '/%7ejanedoe/%a1/index.php',
                'query'    => 'foo.bar=%7evalue',
                'fragment' => 'fragment',
                'expected' => 'https://FreakyFurball:Express1@mewow.example.com:443/%7ejanedoe/%a1/index.php?foo.bar=%7evalue#fragment'
            ],
            [
                'scheme'   => '',
                'user'     => '',
                'pass'     => '',
                'host'     => 'www.example.com',
                'port'     => 443,
                'path'     => '/foo/bar',
                'query'    => 'param=value',
                'fragment' => 'fragment',
                'expected' => '//www.example.com:443/foo/bar?param=value#fragment',
            ],
            [
                'scheme'   => '',
                'user'     => '',
                'pass'     => '',
                'host'     => '',
                'port'     => null,
                'path'     => 'foo/bar',
                'query'    => '',
                'fragment' => '',
                'expected' => 'foo/bar',
            ],
        ];
    }

    #[DataProvider('stringProvider')]
    public function testToString(
        string $scheme,
        string $user,
        string $pass,
        string $host,
        ?int $port,
        string $path,
        string $query,
        string $fragment,
        string $expected
    ): void {
        $uri = HttpUri::createUri()->withHost($host)->withScheme($scheme)
            ->withUserInfo($user, $pass)->withPort($port)
            ->withPath($path)->withQuery($query)->withFragment($fragment);
        $this->assertInstanceOf(UriInterface::class, $uri);
        $this->assertInstanceOf(Stringable::class, $uri); // HttpUri also implements this
        $this->assertSame($expected, (string)$uri);
    }

    public function testRemoveScheme(): void {
        $this->assertSame(
            '//example.com/this/is/a/path',
            (string)HttpUri::createUri('http://example.com/this/is/a/path')->withScheme('')
        );
    }

    public function testRemoveAuthority(): void {
        $uri = HttpUri::createUri('http://user:login@example.com:82/path?q=v#doc')
            ->withScheme('')->withUserInfo('')->withPort(null)->withHost('');

        $this->assertSame('/path?q=v#doc', (string)$uri);
    }

    public function testRemoveUserInfo(): void {
        $this->assertSame(
            'http://example.com/this/is/a/path',
            (string)HttpUri::createUri('http://user:pass@example.com/this/is/a/path')->withUserInfo('')
        );
    }

    public function testRemovePort(): void {
        $this->assertSame(
            'http://example.com/this/is/a/path',
            (string)HttpUri::createUri('http://example.com:9001/this/is/a/path')->withPort(null)
        );
    }

    public function testRemovePath(): void {
        $uri = 'http://example.com';
        $this->assertSame($uri, (string)HttpUri::createUri($uri . '/this/is/a/path')->withPath(''));
    }

    public function testRemoveQuery(): void {
        $uri = 'http://example.com/this/is/a/path';
        $this->assertSame($uri, (string)HttpUri::createUri($uri . '?name=value')->withQuery(''));
    }

    public function testRemoveFragment(): void {
        $uri = 'http://example.com/this/is/a/path';
        $this->assertSame($uri, (string)HttpUri::createUri($uri . '#soap')->withFragment(''));
    }

    /** @return string[][] */
    public static function withSchemeFailProvider(): array {
        return [
            ['un,acceptable'], // unacceptable char
            ['123'], // number string
        ];
    }

    #[DataProvider('withSchemeFailProvider')]
    public function testWithSchemeFail(string $scheme): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri(self::URI)->withScheme($scheme);
    }

    /** @return string[][] */
    public static function withUserInfoFailProvider(): array {
        return [
            ['mew:ow', 'Soap'],
            ['be@ns', 'Soup'],
            ['Meow', 'ok@y'],
        ];
    }

    #[DataProvider('withUserInfoFailProvider')]
    public function testWithUserInfoFail(string $user, ?string $pass): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri(self::URI)->withUserInfo($user, $pass);
    }

    /** @return string[][] */
    public static function withHostFailProvider(): array {
        return [
            ['.example.com'], // dot in front
            ['host.com-'], // hyphen suffix
            ['.......'], // multiple dot
            ['.'], // one dot
            ['tot.    .coucou.com'], // empty label
            ['re view'], // space in the label
            ['_bad.host.com'], // underscore in label
            [implode('', array_fill(0, 12, 'banana')).'.secure.example.com'], // label too long
            [implode('.', array_fill(0, 128, 'a'))], // too many labels
            ['[127.0.0.1]'], // Invalid IPv4 format
            ['[[::1]]'], // Invalid IPv6 format
            ['[::1'], // Invalid IPv6 format 2
            ['example. com'], // space character in starting label
            ["examp\0le.com"], // invalid character in host label
            ['[127.2.0.1%253]'], // invalid IP with scope
            ['ab23::1234%251'], // invalid scope IPv6
            ['fe80::1234%25?@'], // invalid scope ID
            ['fe80::1234%25€'], // invalid scope ID with utf8 character
        ];
    }

    #[DataProvider('withHostFailProvider')]
    public function testWithHostFail(string $host): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri(self::URI)->withHost($host);
    }

    public function testWithPathFailWithInvalidPathRelativeToAuthority(): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri('https://example.com')->withPath('me/ow');
    }

    public function testWithPathFailWithInvalidChars(): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri('https://example.com')->withPath('/?');
    }

    public function testWithQueryFailWithInvalidChars(): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri('https://example.com')->withQuery('#');
    }

    public function testWithPortFailWithTooLowPort(): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri('https://example.com')->withPort(0);
    }

    public function testWithPortFailWithTooHighPort(): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri('https://example.com')->withPort(0x10000);
    }

    public function testWithHostFailWithInvalidHost(): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri('https://example.com')->withHost('?');
    }

    /** @return string[][] */
    public static function invalidUriProvider(): array {
        return [
            ['https://user@:443'],
            [':'],
        ];
    }

    #[DataProvider('invalidUriProvider')]
    public function testCreateUriWithInvalidUri(string $uri): void {
        $this->expectException(InvalidArgumentException::class);
        HttpUri::createUri($uri);
    }

    public function testZeroValues(): void {
        $expected = '//0:0@host/0?0#0';
        $this->assertSame($expected, (string)HttpUri::createUri($expected));
    }

    public function testPathDetection(): void {
        $expected = 'foo/bar:';
        $this->assertSame($expected, HttpUri::createUri($expected)->path);
    }

    /** @return array<array{0: string, 1: array<string, list<?string>>}> */
    public static function queryStringProvider(): array {
        return [
            [
                'username=meow&password=beans',
                ['username' => ['meow'], 'password' => ['beans']]
            ],
            [
                'username=meow&password=beans&password=soap',
                ['username' => ['meow'], 'password' => ['beans', 'soap']]
            ],
            [
                'arg&arg&arg=maybe&arg&the=ok',
                ['arg' => [null, null, 'maybe', null], 'the' => ['ok']]
            ],
            [
                'array[]=old&array%5B%5D=syntax&array[meow]=soup',
                ['array[]' => ['old', 'syntax'], 'array[meow]' => ['soup']]
            ],
            [
                'plus=this+one+uses+plus+as+space&twenty=this%20uses%20percent%20encoding',
                ['plus' => ['this one uses plus as space'], 'twenty' => ['this uses percent encoding']]
            ],
            [
                '&&=&&=&', // there's no reason why this shouldn't be valid but it is quirky!
                ['' => [null, null, '', null, '', null]]
            ],
            [
                '',
                []
            ],
            [
                ' ',
                [' ' => [null]]
            ],
        ];
    }

    /** @param array<string, string[]> $expected */
    #[DataProvider('queryStringProvider')]
    public function testParseQueryString(string $queryString, array $expected): void {
        $this->assertEquals(HttpUri::parseQueryString($queryString), $expected);
    }

    /** @return array<array{0: array<string, Stringable|scalar|null|list<Stringable|scalar|null>>, 1: string}> */
    public static function queryParamsProvider(): array {
        return [
            [
                ['username' => ['meow'], 'password' => ['beans']],
                'username=meow&password=beans'
            ],
            [
                ['username' => ['meow'], 'password' => ['beans', 'soap']],
                'username=meow&password=beans&password=soap'
            ],
            [
                ['arg' => [null, null, 'maybe', null], 'the' => ['ok']],
                'arg&arg&arg=maybe&arg&the=ok'
            ],
            [
                ['array[]' => ['old', 'syntax'], 'array[meow]' => ['soup']],
                'array%5B%5D=old&array%5B%5D=syntax&array%5Bmeow%5D=soup'
            ],
            [
                ['twenty' => ['this one always uses percent'], 'twenty only' => ['this uses percent encoding']],
                'twenty=this%20one%20always%20uses%20percent&twenty%20only=this%20uses%20percent%20encoding'
            ],
            [
                ['' => [null, null, '', null, '', null]],
                '&&=&&=&' // there's no reason why this shouldn't be valid but it is quirky!
            ],
            [
                [],
                ''
            ],
            [
                [' ' => [null]],
                '%20'
            ],
            [ // scalar types
                ['null' => [null], 'int' => [1234], 'float' => [56.78], 'bool' => [true, false], 'string' => ['why not ig']],
                'null&int=1234&float=56.78&bool=1&bool=&string=why%20not%20ig'
            ],
            [ // stringable
                ['stringable' => [new class implements Stringable { public function __toString(): string { return 'a'; } }]],
                'stringable=a'
            ],
            [ // accept non-array
                ['null' => null, 'int' => 1234, 'float' => 56.78, 'bool' => true, 'string' => 'why not ig'],
                'null&int=1234&float=56.78&bool=1&string=why%20not%20ig'
            ],
        ];
    }

    /** @param array<string, list<Stringable|scalar|null>> $queryParams */
    #[DataProvider('queryParamsProvider')]
    public function testBuildQueryString(array $queryParams, string $expected): void {
        $this->assertEquals(HttpUri::buildQueryString($queryParams), $expected);
    }

    /** @return array<array{0: string, 1: array<string, string>}> */
    public static function cookieStringProvider(): array {
        return [
            [
                '',
                []
            ],
            [
                'soup=meow',
                ['soup' => 'meow']
            ],
            [
                'soup=meow;the=the1; the2=the3;       the4=the5;',
                ['soup' => 'meow', 'the' => 'the1', 'the2' => 'the3', 'the4' => 'the5']
            ],
            [
                'test%5B%5D=%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82',
                ['test[]' => 'あああああああ']
            ],
            [
                ' =empty',
                ['' => 'empty']
            ],
        ];
    }

    /** @param array<string, string> $expected */
    #[DataProvider('cookieStringProvider')]
    public function testParseCookieString(string $cookieString, array $expected): void {
        $this->assertEquals(HttpRequest::parseCookieString($cookieString), $expected);
    }
}