<?php
declare(strict_types=1);

namespace SafeTun\Backend\Net;

final class IpNetwork
{
    private function __construct(
        public readonly string $canonical,
        public readonly int $version,
        public readonly int $prefixLength,
        private readonly string $networkBytes,
    ) {
    }

    public static function parse(string $input): self
    {
        if ($input === '' || trim($input) !== $input || substr_count($input, '/') > 1) {
            throw new \InvalidArgumentException("Invalid IP/CIDR: {$input}");
        }

        $parts = explode('/', $input, 2);
        $address = $parts[0];
        $packed = @inet_pton($address);
        if (!is_string($packed)) {
            throw new \InvalidArgumentException("Invalid IP address: {$input}");
        }

        $version = strlen($packed) === 4 ? 4 : (strlen($packed) === 16 ? 6 : 0);
        if ($version === 0) {
            throw new \InvalidArgumentException("Unsupported IP address: {$input}");
        }

        $maximum = $version === 4 ? 32 : 128;
        if (isset($parts[1])) {
            if (!preg_match('/^(0|[1-9][0-9]{0,2})$/D', $parts[1])) {
                throw new \InvalidArgumentException("Invalid CIDR prefix: {$input}");
            }
            $prefix = (int) $parts[1];
        } else {
            $prefix = $maximum;
        }
        if ($prefix < 0 || $prefix > $maximum) {
            throw new \InvalidArgumentException("CIDR prefix out of range: {$input}");
        }

        $networkBytes = self::mask($packed, $prefix);
        $networkAddress = inet_ntop($networkBytes);
        if (!is_string($networkAddress)) {
            throw new \InvalidArgumentException("Unable to canonicalize CIDR: {$input}");
        }

        return new self(strtolower($networkAddress) . '/' . $prefix, $version, $prefix, $networkBytes);
    }

    public function contains(string $address): bool
    {
        $packed = @inet_pton($address);
        if (!is_string($packed) || strlen($packed) !== strlen($this->networkBytes)) {
            return false;
        }
        return hash_equals($this->networkBytes, self::mask($packed, $this->prefixLength));
    }

    public function overlaps(self $other): bool
    {
        if ($this->version !== $other->version) {
            return false;
        }
        $prefix = min($this->prefixLength, $other->prefixLength);
        return hash_equals(self::mask($this->networkBytes, $prefix), self::mask($other->networkBytes, $prefix));
    }

    public function isUniversal(): bool
    {
        return $this->prefixLength === 0;
    }

    public function isPublicNetwork(): bool
    {
        foreach (self::reservedNetworks($this->version) as $reserved) {
            if ($this->overlaps($reserved)) {
                return false;
            }
        }
        return true;
    }

    public static function isPublicAddress(string $address): bool
    {
        try {
            return self::parse($address)->isPublicNetwork();
        } catch (\InvalidArgumentException) {
            return false;
        }
    }

    /** @return list<self> */
    private static function reservedNetworks(int $version): array
    {
        static $cache = [];
        if (isset($cache[$version])) {
            return $cache[$version];
        }

        $cidrs = $version === 4 ? [
            '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
            '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24',
            '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24',
            '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
        ] : [
            '::/128', '::1/128', '::ffff:0:0/96', '100::/64', '2001:db8::/32',
            '2001:10::/28', 'fc00::/7', 'fe80::/10', 'ff00::/8',
        ];

        $cache[$version] = array_map(static fn (string $cidr): self => self::parse($cidr), $cidrs);
        return $cache[$version];
    }

    private static function mask(string $packed, int $prefix): string
    {
        $bytes = strlen($packed);
        $result = '';
        for ($index = 0; $index < $bytes; $index++) {
            $remaining = $prefix - ($index * 8);
            $mask = $remaining >= 8 ? 0xff : ($remaining <= 0 ? 0 : (0xff << (8 - $remaining)) & 0xff);
            $result .= chr(ord($packed[$index]) & $mask);
        }
        return $result;
    }
}
