<?php

declare(strict_types=1);

namespace App\Domain\Ip;

/**
 * Immutable CIDR (network + prefix length) value object.
 *
 * Internally the network is stored as a 16-byte binary plus a prefix length in
 * the [0, 128] range. IPv4 prefixes are stored as 96 + originalPrefix so that
 * containment math is uniform across families: an IPv4 /24 becomes a /120 over
 * the IPv4-mapped IPv6 representation.
 */
final class Cidr
{
    private function __construct(
        private readonly string $network,
        private readonly int $prefixLength,
        private readonly int $originalPrefix,
        private readonly bool $isIpv4,
    ) {
    }

    /**
     * Build a Cidr from a stored 16-byte network binary plus the unified
     * prefix length (0–128, with v4 prefixes pre-shifted by +96 per the
     * class invariant). Used when hydrating rows that already store the
     * canonical network — no input validation beyond the binary length and
     * range, since the database is the source.
     */
    public static function fromBinary(string $networkBin, int $unifiedPrefixLength): self
    {
        if (strlen($networkBin) !== 16) {
            throw new InvalidCidrException('Network binary must be exactly 16 bytes.');
        }
        if ($unifiedPrefixLength < 0 || $unifiedPrefixLength > 128) {
            throw new InvalidCidrException(sprintf('Prefix out of range 0-128: %d', $unifiedPrefixLength));
        }

        $isV4Mapped = substr($networkBin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"
            && $unifiedPrefixLength >= 96;
        $original = $isV4Mapped ? $unifiedPrefixLength - 96 : $unifiedPrefixLength;

        // Re-mask defensively in case the stored network has stray bits.
        $masked = self::applyMask($networkBin, $unifiedPrefixLength);

        return new self($masked, $unifiedPrefixLength, $original, $isV4Mapped);
    }

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

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

        if (!str_contains($input, '/')) {
            throw new InvalidCidrException(sprintf('CIDR must contain a "/" separator: %s', $input));
        }

        $parts = explode('/', $input);
        if (count($parts) !== 2) {
            throw new InvalidCidrException(sprintf('CIDR must have exactly one "/": %s', $input));
        }

        [$ipPart, $prefixPart] = $parts;

        if ($prefixPart === '' || !ctype_digit($prefixPart)) {
            throw new InvalidCidrException(sprintf('CIDR prefix must be a non-negative integer: %s', $input));
        }

        try {
            $ip = IpAddress::fromString($ipPart);
        } catch (InvalidIpException $e) {
            throw new InvalidCidrException(
                sprintf('Invalid IP in CIDR "%s": %s', $input, $e->getMessage()),
                0,
                $e
            );
        }

        $original = (int) $prefixPart;

        if ($ip->isIpv4()) {
            if ($original > 32) {
                throw new InvalidCidrException(sprintf('IPv4 prefix out of range 0-32: %s', $input));
            }
            $internal = 96 + $original;
        } else {
            if ($original > 128) {
                throw new InvalidCidrException(sprintf('IPv6 prefix out of range 0-128: %s', $input));
            }
            $internal = $original;
        }

        $network = self::applyMask($ip->binary(), $internal);

        return new self($network, $internal, $original, $ip->isIpv4());
    }

    public function contains(IpAddress $ip): bool
    {
        return self::applyMask($ip->binary(), $this->prefixLength) === $this->network;
    }

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

    /**
     * Prefix length in the unified IPv6 space (0-128).
     */
    public function prefixLength(): int
    {
        return $this->prefixLength;
    }

    /**
     * The prefix length the user provided (0-32 for IPv4, 0-128 for IPv6).
     */
    public function originalPrefix(): int
    {
        return $this->originalPrefix;
    }

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

    public function text(): string
    {
        if ($this->isIpv4) {
            $v4Bin = substr($this->network, 12, 4);
            $text = inet_ntop($v4Bin);
            if ($text === false) {
                $text = '0.0.0.0'; // @codeCoverageIgnore
            }

            return $text . '/' . $this->originalPrefix;
        }

        $text = inet_ntop($this->network);
        if ($text === false) {
            $text = '::'; // @codeCoverageIgnore
        }

        return strtolower($text) . '/' . $this->originalPrefix;
    }

    private static function applyMask(string $binary, int $prefix): string
    {
        if ($prefix < 0 || $prefix > 128) {
            throw new InvalidCidrException(sprintf('Prefix out of range: %d', $prefix)); // @codeCoverageIgnore
        }

        if ($prefix === 128) {
            return $binary;
        }

        $fullBytes = intdiv($prefix, 8);
        $extraBits = $prefix % 8;

        $masked = substr($binary, 0, $fullBytes);

        if ($extraBits > 0) {
            $byte = ord($binary[$fullBytes]);
            $mask = (0xff << (8 - $extraBits)) & 0xff;
            $masked .= chr($byte & $mask);
            $tailLength = 16 - $fullBytes - 1;
        } else {
            $tailLength = 16 - $fullBytes;
        }

        if ($tailLength > 0) {
            $masked .= str_repeat("\x00", $tailLength);
        }

        return $masked;
    }
}
