<?php

declare(strict_types=1);

namespace App\Tests\Unit\Ip;

use App\Domain\Ip\InvalidIpException;
use App\Domain\Ip\IpAddress;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class IpAddressTest extends TestCase
{
    public function testParsesSimpleIpv4(): void
    {
        $ip = IpAddress::fromString('203.0.113.42');

        $this->assertTrue($ip->isIpv4());
        $this->assertSame('203.0.113.42', $ip->text());
        $this->assertSame(16, strlen($ip->binary()));
        $this->assertSame(
            "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xcb\x00\x71\x2a",
            $ip->binary()
        );
    }

    public function testParsesIpv4Zero(): void
    {
        $ip = IpAddress::fromString('0.0.0.0');

        $this->assertTrue($ip->isIpv4());
        $this->assertSame('0.0.0.0', $ip->text());
        $this->assertSame(
            "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00",
            $ip->binary()
        );
    }

    public function testParsesIpv4Max(): void
    {
        $ip = IpAddress::fromString('255.255.255.255');

        $this->assertTrue($ip->isIpv4());
        $this->assertSame('255.255.255.255', $ip->text());
    }

    public function testParsesFullIpv6(): void
    {
        $ip = IpAddress::fromString('2001:0db8:0000:0000:0000:0000:0000:0001');

        $this->assertFalse($ip->isIpv4());
        $this->assertSame('2001:db8::1', $ip->text());
        $this->assertSame(16, strlen($ip->binary()));
    }

    public function testParsesZeroCompressedIpv6(): void
    {
        $ip = IpAddress::fromString('2001:db8::1');

        $this->assertFalse($ip->isIpv4());
        $this->assertSame('2001:db8::1', $ip->text());
    }

    public function testParsesIpv6Loopback(): void
    {
        $ip = IpAddress::fromString('::1');

        $this->assertFalse($ip->isIpv4());
        $this->assertSame('::1', $ip->text());
    }

    public function testParsesIpv6Unspecified(): void
    {
        $ip = IpAddress::fromString('::');

        $this->assertFalse($ip->isIpv4());
        $this->assertSame('::', $ip->text());
    }

    public function testParsesV4MappedIpv6KeepsIpv6Form(): void
    {
        $ip = IpAddress::fromString('::ffff:1.2.3.4');

        $this->assertFalse($ip->isIpv4(), 'v4-mapped passed in IPv6 form should report as IPv6');
        $this->assertSame(
            "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x02\x03\x04",
            $ip->binary()
        );
    }

    public function testIpv4MapsIntoSameBinaryAsV4MappedIpv6(): void
    {
        $v4 = IpAddress::fromString('1.2.3.4');
        $v6 = IpAddress::fromString('::ffff:1.2.3.4');

        $this->assertSame($v4->binary(), $v6->binary());
    }

    public function testIpv6IsLowercased(): void
    {
        $ip = IpAddress::fromString('2001:DB8:ABCD::FFFF');

        $this->assertSame('2001:db8:abcd::ffff', $ip->text());
    }

    public function testIpv6FullyExpandedCanonicalizes(): void
    {
        $ip = IpAddress::fromString('fe80:0000:0000:0000:0000:0000:0000:0001');

        $this->assertSame('fe80::1', $ip->text());
    }

    public function testIpv6LinkLocalLowercased(): void
    {
        $ip = IpAddress::fromString('FE80::1');

        $this->assertSame('fe80::1', $ip->text());
    }

    public function testFromBinaryRoundtripIpv4(): void
    {
        $original = IpAddress::fromString('10.20.30.40');
        $copy = IpAddress::fromBinary($original->binary());

        $this->assertTrue($copy->isIpv4());
        $this->assertSame('10.20.30.40', $copy->text());
    }

    public function testFromBinaryRoundtripIpv6(): void
    {
        $original = IpAddress::fromString('2001:db8::dead:beef');
        $copy = IpAddress::fromBinary($original->binary());

        $this->assertFalse($copy->isIpv4());
        $this->assertSame('2001:db8::dead:beef', $copy->text());
    }

    public function testFromBinaryRejectsWrongLength(): void
    {
        $this->expectException(InvalidIpException::class);

        IpAddress::fromBinary("\x01\x02\x03");
    }

    public function testEqualsCompareByBinary(): void
    {
        $a = IpAddress::fromString('1.2.3.4');
        $b = IpAddress::fromString('::ffff:1.2.3.4');
        $c = IpAddress::fromString('1.2.3.5');

        $this->assertTrue($a->equals($b));
        $this->assertFalse($a->equals($c));
    }

    /**
     * @return iterable<string, array{0: string}>
     */
    public static function invalidProvider(): iterable
    {
        yield 'empty' => [''];
        yield 'whitespace only' => [' '];
        yield 'leading space' => [' 1.2.3.4'];
        yield 'trailing space' => ['1.2.3.4 '];
        yield 'inner whitespace' => ['1.2. 3.4'];
        yield 'integer-as-string' => ['1234567890'];
        yield 'three octets' => ['1.2.3'];
        yield 'five octets' => ['1.2.3.4.5'];
        yield 'octet > 255' => ['1.2.3.256'];
        yield 'negative octet' => ['1.2.3.-1'];
        yield 'leading zero v4' => ['010.0.0.1'];
        yield 'leading zero in last octet' => ['10.0.0.01'];
        yield 'empty octet' => ['10..0.1'];
        yield 'hex octet' => ['0x10.0.0.1'];
        yield 'alpha octet' => ['a.b.c.d'];
        yield 'just dots' => ['...'];
        yield 'incomplete v6' => ['2001:db8'];
        yield 'too many groups v6' => ['1:2:3:4:5:6:7:8:9'];
        yield 'invalid hex group v6' => ['ggggg::1'];
        yield 'double :: v6' => ['1::2::3'];
        yield 'bracketed v6' => ['[::1]'];
        yield 'zone id v6' => ['fe80::1%eth0'];
        yield 'random garbage' => ['hello world'];
        yield 'sql injection-ish' => ["1.2.3.4'; DROP TABLE--"];
        yield 'newline embedded' => ["1.2.3.4\n"];
        yield 'tab embedded' => ["1.2.3.4\t"];
        yield 'just slash' => ['/'];
        yield 'with prefix' => ['1.2.3.4/24'];
        yield 'just colon' => [':'];
        yield 'just dot' => ['.'];
        yield 'numeric overflow octet' => ['1.2.3.99999'];
    }

    #[DataProvider('invalidProvider')]
    public function testRejectsInvalidInput(string $input): void
    {
        $this->expectException(InvalidIpException::class);

        IpAddress::fromString($input);
    }
}
