<?php

declare(strict_types=1);

namespace App\Domain\Ip;

/**
 * Immutable IP address value object.
 *
 * Internally every address is stored as a 16-byte big-endian binary string.
 * IPv4 inputs are mapped into the IPv4-mapped IPv6 prefix `::ffff:0:0/96`
 * (RFC 4291 §2.5.5.2) so that all containment math is uniform across families.
 */
final class IpAddress
{
    private const V4_MAPPED_PREFIX = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff";

    private function __construct(
        private readonly string $binary,
        private readonly string $text,
        private readonly bool $isIpv4,
    ) {
    }

    public static function fromString(string $input): self
    {
        if ($input === '') {
            throw new InvalidIpException('IP address cannot be empty.');
        }

        if ($input !== trim($input)) {
            throw new InvalidIpException('IP address must not contain surrounding whitespace.');
        }

        if (preg_match('/\s/', $input) === 1) {
            throw new InvalidIpException('IP address must not contain whitespace.');
        }

        if (str_contains($input, '.') && !str_contains($input, ':')) {
            return self::fromIpv4($input);
        }

        if (str_contains($input, ':')) {
            return self::fromIpv6($input);
        }

        throw new InvalidIpException(sprintf('Not a recognizable IP address: %s', $input));
    }

    public static function fromBinary(string $binary): self
    {
        if (strlen($binary) !== 16) {
            throw new InvalidIpException('Binary IP must be exactly 16 bytes.');
        }

        $isV4Mapped = substr($binary, 0, 12) === self::V4_MAPPED_PREFIX;

        if ($isV4Mapped) {
            $textV4 = inet_ntop(substr($binary, 12, 4));
            if ($textV4 === false) {
                throw new InvalidIpException('Failed to render binary as IPv4.'); // @codeCoverageIgnore
            }

            return new self($binary, $textV4, true);
        }

        $textV6 = inet_ntop($binary);
        if ($textV6 === false) {
            throw new InvalidIpException('Failed to render binary as IPv6.'); // @codeCoverageIgnore
        }

        return new self($binary, strtolower($textV6), false);
    }

    public function binary(): string
    {
        return $this->binary;
    }

    public function text(): string
    {
        return $this->text;
    }

    public function isIpv4(): bool
    {
        return $this->isIpv4;
    }

    public function equals(self $other): bool
    {
        return $this->binary === $other->binary;
    }

    private static function fromIpv4(string $input): self
    {
        $parts = explode('.', $input);

        if (count($parts) !== 4) {
            throw new InvalidIpException(sprintf('IPv4 must have four octets: %s', $input));
        }

        foreach ($parts as $part) {
            if ($part === '') {
                throw new InvalidIpException(sprintf('IPv4 octet must not be empty: %s', $input));
            }

            if (!ctype_digit($part)) {
                throw new InvalidIpException(sprintf('IPv4 octet must be decimal digits: %s', $input));
            }

            // RFC 6943 / strict parsers: reject leading zeros (e.g. "010.0.0.1").
            if (strlen($part) > 1 && $part[0] === '0') {
                throw new InvalidIpException(
                    sprintf('IPv4 octet must not have leading zeros: %s', $input)
                );
            }

            if ((int) $part > 255) {
                throw new InvalidIpException(sprintf('IPv4 octet out of range 0-255: %s', $input));
            }
        }

        $packed = inet_pton($input);
        if ($packed === false) {
            throw new InvalidIpException(sprintf('inet_pton failed for %s', $input)); // @codeCoverageIgnore
        }

        $mapped = self::V4_MAPPED_PREFIX . $packed;

        return new self($mapped, $input, true);
    }

    private static function fromIpv6(string $input): self
    {
        $bracketed = preg_match('/^\[.*\]$/', $input) === 1;
        if ($bracketed) {
            throw new InvalidIpException(sprintf('Bracketed IPv6 not accepted: %s', $input));
        }

        if (str_contains($input, '%')) {
            throw new InvalidIpException(sprintf('Zone identifiers not accepted: %s', $input));
        }

        $packed = @inet_pton($input);
        if ($packed === false || strlen($packed) !== 16) {
            throw new InvalidIpException(sprintf('Invalid IPv6 address: %s', $input));
        }

        $canonical = inet_ntop($packed);
        if ($canonical === false) {
            throw new InvalidIpException(sprintf('Failed to canonicalize IPv6: %s', $input)); // @codeCoverageIgnore
        }

        return new self($packed, strtolower($canonical), false);
    }
}
