<?php
/**
 * IP réservées hors DHCP par subnet (liste texte multiligne).
 * Classe PURE (aucun accès DB) — testable via `php -r`.
 * Format d'une ligne : "<ip-ou-range>[#description]".
 * Formes de plage : IP seule, "a-b", octets de fin complétés par le préfixe réseau
 * (même règle que le filtre parseRange de subnet.php).
 */
class ReservedIps
{
    /**
     * Parse le texte brut → entrées valides normalisées.
     * @return array [ ['startInt'=>int,'endInt'=>int,'desc'=>string,'line'=>int], ... ]
     * Les lignes vides, commentaires purs, et lignes invalides sont ignorées ici
     * (la validation détaillée est faite par validate()).
     */
    public static function parse(string $raw, int $networkInt): array
    {
        $out = [];
        $lines = preg_split('/\r\n|\r|\n/', $raw);
        foreach ($lines as $i => $line) {
            $entry = self::parseLine($line, $networkInt);
            if ($entry === null) continue;
            if (isset($entry['error'])) continue; // ignorée dans parse(), signalée par validate()
            $entry['line'] = $i + 1;
            $out[] = $entry;
        }
        return $out;
    }

    /**
     * Parse une ligne. Retourne :
     *  - null si ligne vide / commentaire pur (à ignorer),
     *  - ['error' => 'message'] si format/plage invalide,
     *  - ['startInt','endInt','desc'] si OK.
     */
    private static function parseLine(string $line, int $networkInt): ?array
    {
        // Séparer la description (après le 1er #)
        $desc = '';
        $hash = strpos($line, '#');
        if ($hash !== false) {
            $desc = trim(substr($line, $hash + 1));
            $line = substr($line, 0, $hash);
        }
        $part = trim($line);
        if ($part === '') return null; // vide ou commentaire pur

        // "a-b" ou "a"
        if (strpos($part, '-') !== false) {
            $bits = explode('-', $part, 2);
            $a = self::toInt(trim($bits[0]), $networkInt);
            $b = self::toInt(trim($bits[1]), $networkInt);
        } else {
            $a = self::toInt($part, $networkInt);
            $b = $a;
        }
        if ($a === null || $b === null) {
            return ['error' => 'Format IP/plage invalide'];
        }
        if ($a > $b) { $t = $a; $a = $b; $b = $t; } // range inversé → trié
        return ['startInt' => $a, 'endInt' => $b, 'desc' => $desc];
    }

    /**
     * Convertit une forme courte/complète en entier non signé, ou null si invalide.
     * Octets de fin complétés à gauche par le préfixe réseau (même règle que parseRange JS).
     */
    private static function toInt(string $s, int $networkInt): ?int
    {
        if ($s === '' || !preg_match('/^\d{1,3}(?:\.\d{1,3}){0,3}$/', $s)) return null;
        $oct = array_map('intval', explode('.', $s));
        if (count($oct) < 4) {
            $base = [($networkInt >> 24) & 255, ($networkInt >> 16) & 255, ($networkInt >> 8) & 255, $networkInt & 255];
            $oct = array_merge(array_slice($base, 0, 4 - count($oct)), $oct);
        }
        if (count($oct) !== 4) return null;
        foreach ($oct as $o) { if ($o < 0 || $o > 255) return null; }
        return ($oct[0] << 24) + ($oct[1] << 16) + ($oct[2] << 8) + $oct[3];
    }

    /**
     * Valide chaque ligne. Retourne [ ['line'=>int,'message'=>string], ... ] (vide = OK).
     * Vérifie : format, appartenance au CIDR, non-chevauchement pool dynamique + short-lease.
     * $subnet : tableau app_subnets (cidr, has_dynamic_pool, range_start/end,
     *           short_lease_pool_start/end).
     */
    public static function validate(string $raw, array $subnet): array
    {
        $errors = [];
        $cidr = $subnet['cidr'] ?? '';
        [$netStr, $bitsStr] = array_pad(explode('/', $cidr), 2, '32');
        $bits = (int)$bitsStr;
        if ($bits < 0 || $bits > 32) {
            return [['line' => 0, 'message' => 'CIDR du subnet invalide (préfixe hors 0–32)']];
        }
        $networkInt = self::cidrNetworkInt($netStr, $bits);
        $bcastInt   = $networkInt + (1 << (32 - $bits)) - 1;

        // Plages de pools pour le test de chevauchement
        $pools = [];
        if (!empty($subnet['has_dynamic_pool']) && !empty($subnet['range_start']) && !empty($subnet['range_end'])) {
            $pools[] = [self::ipInt($subnet['range_start']), self::ipInt($subnet['range_end']), 'pool dynamique'];
        }
        if (!empty($subnet['short_lease_pool_start']) && !empty($subnet['short_lease_pool_end'])) {
            $pools[] = [self::ipInt($subnet['short_lease_pool_start']), self::ipInt($subnet['short_lease_pool_end']), 'pool short-lease'];
        }

        $lines = preg_split('/\r\n|\r|\n/', $raw);
        foreach ($lines as $i => $line) {
            $entry = self::parseLine($line, $networkInt);
            if ($entry === null) continue; // vide / commentaire
            $ln = $i + 1;
            if (isset($entry['error'])) {
                $errors[] = ['line' => $ln, 'message' => $entry['error']];
                continue;
            }
            $s = $entry['startInt']; $e = $entry['endInt'];
            if ($s < $networkInt || $e > $bcastInt) {
                $errors[] = ['line' => $ln, 'message' => 'Hors du subnet ' . $cidr];
                continue;
            }
            foreach ($pools as $p) {
                if ($s <= $p[1] && $p[0] <= $e) {
                    $errors[] = ['line' => $ln, 'message' => 'Chevauche le ' . $p[2]];
                    break;
                }
            }
        }
        return $errors;
    }

    /**
     * Entier réseau (non signé) d'un CIDR "a.b.c.d/n", ou null si le préfixe est hors 0–32.
     * Helper public partagé par les appelants (Subnet::validate, Reservation::checkReservedConflict).
     */
    public static function networkIntFromCidr(string $cidr): ?int
    {
        [$netStr, $bitsStr] = array_pad(explode('/', $cidr), 2, '32');
        $bits = (int)$bitsStr;
        if ($bits < 0 || $bits > 32) return null;
        return self::cidrNetworkInt($netStr, $bits);
    }

    private static function cidrNetworkInt(string $netStr, int $bits): int
    {
        $mask = $bits >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF);
        return (int)(sprintf('%u', ip2long($netStr) & $mask));
    }
    private static function ipInt(string $ip): int
    {
        return (int)sprintf('%u', ip2long($ip));
    }

    /**
     * Retourne l'entrée réservée contenant $ip, ou null.
     * Utilisé pour bloquer la création d'une réservation DHCP.
     */
    public static function contains(string $raw, int $networkInt, string $ip): ?array
    {
        $ipInt = self::ipInt($ip);
        foreach (self::parse($raw, $networkInt) as $entry) {
            if ($ipInt >= $entry['startInt'] && $ipInt <= $entry['endInt']) {
                return $entry;
            }
        }
        return null;
    }

    /**
     * Plages fusionnées {startInt,endInt,desc} triées, pour le bargraph.
     * Les recouvrements sont fusionnés ; les descriptions distinctes concaténées par " · ".
     */
    public static function toBlocks(string $raw, int $networkInt): array
    {
        $entries = self::parse($raw, $networkInt);
        usort($entries, function ($a, $b) { return $a['startInt'] - $b['startInt']; });
        $blocks = [];
        foreach ($entries as $e) {
            $n = count($blocks);
            if ($n > 0 && $e['startInt'] <= $blocks[$n - 1]['endInt'] + 1) {
                $last = &$blocks[$n - 1];
                if ($e['endInt'] > $last['endInt']) $last['endInt'] = $e['endInt'];
                if ($e['desc'] !== '' && strpos($last['desc'], $e['desc']) === false) {
                    $last['desc'] = $last['desc'] === '' ? $e['desc'] : $last['desc'] . ' · ' . $e['desc'];
                }
                unset($last);
            } else {
                $blocks[] = ['startInt' => $e['startInt'], 'endInt' => $e['endInt'], 'desc' => $e['desc']];
            }
        }
        return $blocks;
    }
}
