# IP réservées (hors DHCP) — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Permettre de déclarer par subnet une liste d'IP/plages réservées hors DHCP (texte multiligne `ip[#description]`), stockée en `app_subnets.reserved_ips`, validée à l'enregistrement, affichée comme 4e type de bloc sur le bargraph, et bloquant la création de réservations DHCP tombant dedans.

**Architecture:** Une classe PHP pure `ReservedIps` (parse/validate/contains/toBlocks) est la source de vérité serveur, branchée dans `Subnet::validate` (+ vérif croisée réservations existantes) et `Reservation::validate` (blocage). Le formulaire subnet gagne un textarea avec gouttière d'erreurs (`!` + tooltip). Le bargraph JS parse le brut injecté et rend un type `reserved` (gris ardoise). La règle de forme raccourcie (octets de fin complétés par le préfixe réseau) est la même que le filtre `parseRange` existant, réimplémentée en PHP et en JS bargraph.

**Tech Stack:** PHP vanilla + PDO, JavaScript ES5 (IIFE, `var`), Bootstrap 5, MySQL. Aucune dépendance nouvelle. Offline.

---

## Structure des fichiers

| Fichier | Responsabilité |
|---------|----------------|
| `sql/add_reserved_ips.sql` | **nouveau** — migration : colonne `reserved_ips TEXT` sur `app_subnets` |
| `lib/ReservedIps.php` | **nouveau** — classe pure : `parse`, `validate`, `contains`, `toBlocks` |
| `lib/Subnet.php` | **modifié** — `add`/`update` persistent `reserved_ips` ; `validate` appelle `ReservedIps::validate` + vérif croisée réservations existantes |
| `lib/Reservation.php` | **modifié** — `checkReservedConflict` (via `$subnetContext`) + appel dans `validate` |
| `public/api_check_duplicate.php` | **modifié** — feedback AJAX rouge si IP réservée (type=ip) |
| `public/subnet_edit.php`, `public/subnet_add.php` | **modifié** — champ textarea `reserved_ips` + gouttière |
| `public/subnet.php` | **modifié** — injecte `reservedIps` dans l'objet `SUBNET` |
| `public/assets/js/subnet-bargraph.js` | **modifié** — parse `reservedIps`, type `reserved` (bloc, couleur, infobulle) |
| `public/assets/js/subnet-bargraph-demo.html` | **modifié** — fixture + légende avec le type réservé |
| `public/assets/js/subnet-bargraph.test.html` | **modifié** — tests type `reserved` |
| `public/assets/js/reserved-ips-form.js` | **nouveau** — validation live + gouttière du textarea (client) |
| `public/assets/css/app.css` | **modifié** — styles gouttière `.rip-*` + `--sbg-reserved` (clair/sombre) |
| `CLAUDE.md` | **modifié** — doc |

**Format de ligne (verrouillé, réutilisé partout) :** `<ip-ou-range>[#description]`.
- Description = tout après le 1er `#`, trimé (peut contenir `#`, `:`, espaces).
- Plage = avant le `#`, trimé. Vide → ligne ignorée.
- Formes acceptées : `192.168.2.5`, `192.168.2.20-192.168.2.30`, `30-40`, `2.50-3.10`
  (octets de fin complétés à gauche par le préfixe réseau du subnet). Range inversé → trié.

**Contrat `ReservedIps` (PHP, verrouillé) :**
```php
ReservedIps::parse(string $raw, int $networkInt): array
// → [ ['startInt'=>int,'endInt'=>int,'desc'=>string,'line'=>int], ... ] (lignes valides only)
ReservedIps::validate(string $raw, array $subnet): array
// → [ ['line'=>int,'message'=>string], ... ]  (vide = OK)
ReservedIps::contains(string $raw, int $networkInt, string $ip): ?array
// → ['startInt','endInt','desc','line'] contenant $ip, ou null
ReservedIps::toBlocks(string $raw, int $networkInt): array
// → [ ['startInt'=>int,'endInt'=>int,'desc'=>string], ... ] fusionnés/triés
```

**Contrat JS bargraph (verrouillé) :** un bloc réservé a la forme
`{ type:'reserved', startInt, endInt, count, groups:{}, shortLeaseCount:0, hostIds:[], desc:'...' }`.

**Comment lancer les tests :** ouvrir les `*.test.html` au navigateur (vert/rouge). La classe
PHP `ReservedIps` est vérifiée par des snippets `php -r` autonomes fournis à chaque tâche
(pas de framework PHP dans le projet).

---

## Task 1: Migration SQL — colonne `reserved_ips`

**Files:**
- Create: `sql/add_reserved_ips.sql`

- [ ] **Step 1: Écrire la migration**

Créer `sql/add_reserved_ips.sql` :

```sql
-- Migration v0.15 → v0.16 : IP réservées hors DHCP par subnet.
-- Liste texte multiligne (une IP ou plage par ligne, description optionnelle après #).
-- Purement applicative : jamais poussée à Kea.
ALTER TABLE app_subnets ADD COLUMN reserved_ips TEXT NULL AFTER notes;
```

- [ ] **Step 2: Appliquer la migration sur la base de dev**

Run :
```bash
mysql -h 192.168.1.2 -u claude -pclaudedev123 kea < /home/algalord/www/dhcpman/sql/add_reserved_ips.sql
mysql -h 192.168.1.2 -u claude -pclaudedev123 kea -e "SHOW COLUMNS FROM app_subnets LIKE 'reserved_ips';"
```
Expected : une ligne montrant `reserved_ips | text | YES`.

- [ ] **Step 3: Commit**

```bash
cd /home/algalord/www/dhcpman
git add sql/add_reserved_ips.sql
git commit -m "feat(reserved-ips): migration colonne app_subnets.reserved_ips"
```

---

## Task 2: `ReservedIps::parse` — parsing d'une ligne + formes raccourcies

**Files:**
- Create: `lib/ReservedIps.php`

- [ ] **Step 1: Créer la classe avec `parse`**

Créer `lib/ReservedIps.php` :

```php
<?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];
    }
}
```

- [ ] **Step 2: Vérifier `parse` avec un script php -r (doit passer)**

Run :
```bash
cd /home/algalord/www/dhcpman
php -r '
require "lib/ReservedIps.php";
$net = (192<<24)+(168<<16)+(2<<8)+0; // 192.168.2.0
$raw = "192.168.2.5 # Imprimante\n30-40 # Caméras\n\n# commentaire pur\n2.50-2.60#colle\n80-70 # inversé";
$r = ReservedIps::parse($raw, $net);
assert(count($r) === 4); // 4 lignes valides (vide + commentaire ignorés)
assert($r[0]["startInt"] === (192<<24)+(168<<16)+(2<<8)+5);
assert($r[0]["desc"] === "Imprimante");
assert($r[1]["startInt"] === (192<<24)+(168<<16)+(2<<8)+30);
assert($r[1]["endInt"]   === (192<<24)+(168<<16)+(2<<8)+40);
assert($r[1]["line"] === 2);
assert($r[2]["desc"] === "colle"); // # collé sans espace
// range inversé 80-70 normalisé
assert($r[3]["startInt"] === (192<<24)+(168<<16)+(2<<8)+70);
assert($r[3]["endInt"]   === (192<<24)+(168<<16)+(2<<8)+80);
echo "parse OK\n";
'
```
Expected : `parse OK`.

- [ ] **Step 3: Commit**

```bash
cd /home/algalord/www/dhcpman
git add lib/ReservedIps.php
git commit -m "feat(reserved-ips): ReservedIps::parse (formes raccourcies + description)"
```

---

## Task 3: `ReservedIps::validate` — erreurs par ligne (format, CIDR, pools)

**Files:**
- Modify: `lib/ReservedIps.php`

- [ ] **Step 1: Ajouter `validate` et un helper pool**

Dans `lib/ReservedIps.php`, ajouter ces méthodes dans la classe (après `toInt`) :

```php
    /**
     * 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;
        $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;
    }

    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));
    }
```

- [ ] **Step 2: Vérifier `validate` avec php -r**

Run :
```bash
cd /home/algalord/www/dhcpman
php -r '
require "lib/ReservedIps.php";
$subnet = ["cidr"=>"192.168.2.0/23","has_dynamic_pool"=>1,"range_start"=>"192.168.2.200","range_end"=>"192.168.2.250","short_lease_pool_start"=>null,"short_lease_pool_end"=>null];
$raw = "192.168.2.5 # ok\n192.168.5.0 # hors CIDR\n2.200-2.210 # chevauche pool\nabc-def # format\n192.168.3.254 # ok bord haut";
$errs = ReservedIps::validate($raw, $subnet);
// lignes 2,3,4 en erreur ; 1 et 5 OK
$byLine = [];
foreach ($errs as $x) $byLine[$x["line"]] = $x["message"];
assert(count($errs) === 3);
assert(strpos($byLine[2], "Hors du subnet") === 0);
assert(strpos($byLine[3], "Chevauche le pool dynamique") === 0);
assert(strpos($byLine[4], "Format") === 0);
assert(!isset($byLine[1]) && !isset($byLine[5]));
echo "validate OK\n";
'
```
Expected : `validate OK`.

- [ ] **Step 3: Commit**

```bash
cd /home/algalord/www/dhcpman
git add lib/ReservedIps.php
git commit -m "feat(reserved-ips): ReservedIps::validate (format, CIDR, chevauchement pools)"
```

---

## Task 4: `ReservedIps::contains` et `toBlocks`

**Files:**
- Modify: `lib/ReservedIps.php`

- [ ] **Step 1: Ajouter `contains` et `toBlocks`**

Dans `lib/ReservedIps.php`, ajouter après `ipInt` :

```php
    /**
     * 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;
    }
```

- [ ] **Step 2: Vérifier avec php -r**

Run :
```bash
cd /home/algalord/www/dhcpman
php -r '
require "lib/ReservedIps.php";
$net = (192<<24)+(168<<16)+(2<<8)+0;
$raw = "192.168.2.10-192.168.2.20 # A\n192.168.2.15-192.168.2.25 # B\n192.168.2.50 # C";
// contains
$c = ReservedIps::contains($raw, $net, "192.168.2.18");
assert($c !== null && $c["desc"] === "A"); // dans la 1re entrée (avant fusion)
assert(ReservedIps::contains($raw, $net, "192.168.2.40") === null);
// toBlocks : les deux 1res fusionnent en .10-.25 (desc "A · B"), + .50
$b = ReservedIps::toBlocks($raw, $net);
assert(count($b) === 2);
assert($b[0]["startInt"] === (192<<24)+(168<<16)+(2<<8)+10);
assert($b[0]["endInt"]   === (192<<24)+(168<<16)+(2<<8)+25);
assert($b[0]["desc"] === "A · B");
assert($b[1]["startInt"] === (192<<24)+(168<<16)+(2<<8)+50);
echo "contains+toBlocks OK\n";
'
```
Expected : `contains+toBlocks OK`.

- [ ] **Step 3: Commit**

```bash
cd /home/algalord/www/dhcpman
git add lib/ReservedIps.php
git commit -m "feat(reserved-ips): ReservedIps::contains + toBlocks (fusion plages)"
```

---

## Task 5: Persistance `reserved_ips` dans `Subnet::add`/`update`

**Files:**
- Modify: `lib/Subnet.php`

- [ ] **Step 1: Ajouter la colonne dans `add`**

Dans `lib/Subnet.php`, méthode `add`, la requête INSERT liste les colonnes puis les valeurs.
Modifier la liste des colonnes de :
```php
             (kea_subnet_id, name, cidr, interface, gateway, relay_address, dns_servers, ntp_servers,
              range_start, range_end, has_dynamic_pool, is_public, notes,
              short_lease_pool_start, short_lease_pool_end)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
```
à :
```php
             (kea_subnet_id, name, cidr, interface, gateway, relay_address, dns_servers, ntp_servers,
              range_start, range_end, has_dynamic_pool, is_public, notes,
              short_lease_pool_start, short_lease_pool_end, reserved_ips)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
```
et ajouter la valeur juste après la ligne `trim($data['short_lease_pool_end'] ?? '') ?: null,` (dans le tableau `execute([...])` de `add`) :
```php
            trim($data['reserved_ips'] ?? '') ?: null,
```

- [ ] **Step 2: Ajouter la colonne dans `update`**

Dans la méthode `update`, modifier le SET de :
```php
                 has_dynamic_pool = ?, is_public = ?, notes = ?,
                 short_lease_pool_start = ?, short_lease_pool_end = ?
             WHERE id = ?'
```
à :
```php
                 has_dynamic_pool = ?, is_public = ?, notes = ?,
                 short_lease_pool_start = ?, short_lease_pool_end = ?, reserved_ips = ?
             WHERE id = ?'
```
et ajouter la valeur dans `execute([...])` de `update`, juste après `trim($data['short_lease_pool_end'] ?? '') ?: null,` et AVANT `$id,` :
```php
            trim($data['reserved_ips'] ?? '') ?: null,
```

- [ ] **Step 3: Vérifier la syntaxe PHP**

Run :
```bash
cd /home/algalord/www/dhcpman
php -l lib/Subnet.php
```
Expected : `No syntax errors detected in lib/Subnet.php`.

- [ ] **Step 4: Vérifier le round-trip DB (add via SQL direct puis lecture)**

Run (insère un subnet de test avec reserved_ips, relit, supprime) :
```bash
cd /home/algalord/www/dhcpman
php -r '
require "lib/init.php";
$id = Subnet::add([
  "kea_subnet_id"=>9911,"name"=>"__tmp_reserved__","cidr"=>"10.9.9.0/24",
  "reserved_ips"=>"10.9.9.5 # test\n10.9.9.20-10.9.9.30 # plage"
]);
$s = Subnet::getById($id);
assert(strpos($s["reserved_ips"], "10.9.9.5") !== false);
assert(strpos($s["reserved_ips"], "plage") !== false);
Subnet::delete($id);
echo "round-trip OK\n";
'
```
Expected : `round-trip OK`.

- [ ] **Step 5: Commit**

```bash
cd /home/algalord/www/dhcpman
git add lib/Subnet.php
git commit -m "feat(reserved-ips): persistance reserved_ips dans Subnet add/update"
```

---

## Task 6: Validation subnet — `ReservedIps::validate` + vérif croisée réservations existantes

**Files:**
- Modify: `lib/Subnet.php`

- [ ] **Step 1: Brancher la validation dans `Subnet::validate`**

Dans `lib/Subnet.php`, méthode `validate`, juste AVANT le bloc final `// Unicité kea_subnet_id`
(la ligne `if (!empty($data['kea_subnet_id'])) {`), insérer :

```php
        // IP réservées hors DHCP (optionnel) — validation ligne par ligne + vérif croisée
        $reservedRaw = trim($data['reserved_ips'] ?? '');
        if ($reservedRaw !== '' && !empty($data['cidr'])
            && preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/', $data['cidr'])) {
            require_once __DIR__ . '/ReservedIps.php';
            foreach (ReservedIps::validate($reservedRaw, $data) as $err) {
                $errors[] = 'IP réservées, ligne ' . $err['line'] . ' : ' . $err['message'] . '.';
            }
            // Vérif croisée : une réservation DHCP existante ne doit pas tomber dans une plage réservée
            [$netStr, $bitsStr] = array_pad(explode('/', $data['cidr']), 2, '32');
            $bits = (int)$bitsStr;
            $mask = $bits >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF);
            $networkInt = (int)sprintf('%u', ip2long($netStr) & $mask);
            $keaId = (int)($data['kea_subnet_id'] ?? 0);
            if ($keaId > 0) {
                $stmt = Database::get()->prepare(
                    'SELECT hostname, INET_NTOA(ipv4_address) AS ip FROM hosts
                     WHERE dhcp4_subnet_id = ? AND ipv4_address IS NOT NULL AND ipv4_address <> 0'
                );
                $stmt->execute([$keaId]);
                foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $h) {
                    if ($h['ip'] && ReservedIps::contains($reservedRaw, $networkInt, $h['ip'])) {
                        $errors[] = 'La réservation ' . $h['hostname'] . ' (' . $h['ip']
                            . ') tombe dans une plage réservée. Supprimez-la ou ajustez la liste.';
                    }
                }
            }
        }

```

- [ ] **Step 2: Vérifier la syntaxe PHP**

Run :
```bash
cd /home/algalord/www/dhcpman
php -l lib/Subnet.php
```
Expected : `No syntax errors detected`.

- [ ] **Step 3: Vérifier validation + vérif croisée end-to-end**

Run (crée un subnet, une réservation dedans, puis tente une liste réservée qui la recouvre) :
```bash
cd /home/algalord/www/dhcpman
php -r '
require "lib/init.php";
$id = Subnet::add(["kea_subnet_id"=>9912,"name"=>"__tmp_x__","cidr"=>"10.9.12.0/24"]);
$kea = 9912;
// réservation DHCP à 10.9.12.50
Reservation::add($kea, ["mac"=>"bc:dd:c2:47:3d:b6","ip"=>"10.9.12.50","hostname"=>"srvx"]);
// validate : liste réservée hors-CIDR (erreur ligne) + recouvre la résa existante
$errs = Subnet::validate([
  "kea_subnet_id"=>9912,"name"=>"x","cidr"=>"10.9.12.0/24",
  "reserved_ips"=>"10.9.99.0 # hors\n10.9.12.40-10.9.12.60 # recouvre srvx"
], $id);
$joined = implode(" | ", $errs);
assert(strpos($joined, "ligne 1") !== false); // hors CIDR
assert(strpos($joined, "srvx") !== false);    // vérif croisée
echo "cross-check OK\n";
// cleanup
foreach (Reservation::getBySubnet($kea) as $r) Reservation::delete((int)$r["host_id"]);
Subnet::delete($id);
'
```
Expected : `cross-check OK`.

- [ ] **Step 4: Commit**

```bash
cd /home/algalord/www/dhcpman
git add lib/Subnet.php
git commit -m "feat(reserved-ips): validation subnet (lignes + vérif croisée réservations)"
```

---

## Task 7: Blocage à la création de réservation — `Reservation::checkReservedConflict`

**Files:**
- Modify: `lib/Reservation.php`

- [ ] **Step 1: Ajouter `checkReservedConflict` + l'appeler dans `validate`**

Dans `lib/Reservation.php`, `validate()` reçoit `$subnetContext` (le tableau subnet complet).
Juste après la ligne existante `$errors = array_merge($errors, self::checkPoolConflict($keaSubnetId, $ip));`
(dans le bloc `if (!empty($ip) && isValidIp($ip)) {`), ajouter :

```php
            $errors = array_merge($errors, self::checkReservedConflict($subnetContext, $ip));
```

Puis, ajouter la méthode privée juste après `checkPoolConflict` (repérer sa fin `}` puis insérer) :

```php
    /**
     * Bloque une IP tombant dans les IP réservées hors DHCP du subnet.
     * Utilise $subnetContext (déjà chargé par l'appelant) — pas d'accès DB.
     */
    private static function checkReservedConflict(?array $subnet, string $ip): array
    {
        if (!$subnet || empty($subnet['reserved_ips']) || empty($subnet['cidr'])) {
            return [];
        }
        require_once __DIR__ . '/ReservedIps.php';
        [$netStr, $bitsStr] = array_pad(explode('/', $subnet['cidr']), 2, '32');
        $bits = (int)$bitsStr;
        $mask = $bits >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF);
        $networkInt = (int)sprintf('%u', ip2long($netStr) & $mask);
        $hit = ReservedIps::contains($subnet['reserved_ips'], $networkInt, $ip);
        if ($hit) {
            $desc = $hit['desc'] !== '' ? ' : ' . $hit['desc'] : '';
            return ['L\'IP ' . $ip . ' est réservée (hors DHCP) sur ce subnet' . $desc . '.'];
        }
        return [];
    }
```

- [ ] **Step 2: Vérifier la syntaxe PHP**

Run :
```bash
cd /home/algalord/www/dhcpman
php -l lib/Reservation.php
```
Expected : `No syntax errors detected`.

- [ ] **Step 3: Vérifier le blocage end-to-end**

Run :
```bash
cd /home/algalord/www/dhcpman
php -r '
require "lib/init.php";
$id = Subnet::add(["kea_subnet_id"=>9913,"name"=>"__tmp_r__","cidr"=>"10.9.13.0/24",
  "reserved_ips"=>"10.9.13.5 # imprimante\n10.9.13.20-10.9.13.30 # switchs"]);
$subnet = Subnet::getById($id);
// IP dans la plage réservée → bloquée
$errs = Reservation::validate(9913, ["mac"=>"bc:dd:c2:47:3d:b6","ip"=>"10.9.13.25","hostname"=>"h1"], null, $subnet);
assert(in_array_substr($errs, "réservée (hors DHCP)"));
// IP hors plage réservée → pas ce blocage
$errs2 = Reservation::validate(9913, ["mac"=>"bc:dd:c2:47:3d:b7","ip"=>"10.9.13.100","hostname"=>"h2"], null, $subnet);
assert(!in_array_substr($errs2, "réservée (hors DHCP)"));
Subnet::delete($id);
echo "block OK\n";
function in_array_substr($arr,$needle){ foreach($arr as $s){ if(strpos($s,$needle)!==false) return true; } return false; }
'
```
Expected : `block OK`.

- [ ] **Step 4: Commit**

```bash
cd /home/algalord/www/dhcpman
git add lib/Reservation.php
git commit -m "feat(reserved-ips): blocage réservation DHCP dans une IP réservée"
```

---

## Task 8: Feedback AJAX — `api_check_duplicate.php`

**Files:**
- Modify: `public/api_check_duplicate.php`

- [ ] **Step 1: Ajouter la vérif réservée dans la branche IP**

Dans `public/api_check_duplicate.php`, dans la branche `else` (type ip), juste APRÈS le bloc
de vérification du pool dynamique (le `if ($subnet['has_dynamic_pool'] && ...) { ... }` qui se
termine par `}`), et AVANT la requête `$sql = 'SELECT host_id FROM hosts ...`, insérer :

```php
    // Vérification IP réservée hors DHCP
    if (!empty($subnet['reserved_ips'])) {
        require_once APP_ROOT . '/lib/ReservedIps.php';
        [$netStr, $bitsStr] = array_pad(explode('/', $subnet['cidr']), 2, '32');
        $bits = (int)$bitsStr;
        $mask = $bits >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF);
        $networkInt = (int)sprintf('%u', ip2long($netStr) & $mask);
        $hit = ReservedIps::contains($subnet['reserved_ips'], $networkInt, $value);
        if ($hit) {
            $msg = 'IP réservée (hors DHCP)' . ($hit['desc'] !== '' ? ' : ' . $hit['desc'] : '') . '.';
            echo json_encode(['duplicate' => true, 'message' => $msg]);
            exit;
        }
    }

```

- [ ] **Step 2: Vérifier la syntaxe PHP**

Run :
```bash
cd /home/algalord/www/dhcpman
php -l public/api_check_duplicate.php
```
Expected : `No syntax errors detected`.

- [ ] **Step 3: Vérifier que APP_ROOT est défini**

`api_check_duplicate.php` inclut `init.php` qui définit `APP_ROOT`. Confirmer :
```bash
cd /home/algalord/www/dhcpman
grep -n "APP_ROOT" lib/init.php | head -1
```
Expected : une ligne définissant `APP_ROOT` (ex: `define('APP_ROOT', ...)`).

- [ ] **Step 4: Commit**

```bash
cd /home/algalord/www/dhcpman
git add public/api_check_duplicate.php
git commit -m "feat(reserved-ips): feedback AJAX IP réservée (api_check_duplicate)"
```

---

## Task 9: Formulaire subnet — textarea + gouttière (client)

**Files:**
- Modify: `public/subnet_edit.php`
- Modify: `public/subnet_add.php`
- Create: `public/assets/js/reserved-ips-form.js`
- Modify: `public/assets/css/app.css`

- [ ] **Step 1: Ajouter le CSS de la gouttière**

Append à la fin de `public/assets/css/app.css` :

```css
/* ── Éditeur IP réservées (gouttière d'erreurs) ── */
.rip-editor { display:flex; border:1px solid var(--bs-border-color,#ced4da); border-radius:.375rem; overflow:hidden; }
.rip-editor.rip-invalid { border-color:#dc3545; }
.rip-gutter { flex:0 0 28px; background:#f1f4f8; border-right:1px solid #dde3ea; overflow:hidden; position:relative; }
.rip-gutter-inner { position:absolute; top:0; left:0; right:0; }
.rip-gutter .rip-g-line { display:flex; align-items:center; justify-content:center; color:#dc3545; font-weight:700; cursor:help; }
.rip-ta { flex:1 1 auto; border:0; outline:0; resize:vertical; padding:.375rem .5rem; margin:0;
    font-family:'Courier New',monospace; font-size:13px; line-height:1.5; min-height:110px; background:transparent; }
.rip-status { font-size:12.5px; font-weight:600; margin-top:.35rem; }
.rip-status.ok { color:#0f7b34; } .rip-status.ko { color:#b02a37; }
.rip-tip { position:fixed; z-index:3000; pointer-events:none; background:#dc3545; color:#fff;
    font-size:12px; padding:5px 9px; border-radius:6px; box-shadow:0 4px 14px rgba(0,0,0,.2); display:none; max-width:260px; }
@media (prefers-color-scheme: dark) {
    .rip-gutter { background:#161b22; border-color:#30363d; }
    .rip-ta { color:#c9d3df; }
    .rip-status.ok { color:#4ade80; } .rip-status.ko { color:#f1949c; }
}
```

- [ ] **Step 2: Créer le JS de validation live**

Créer `public/assets/js/reserved-ips-form.js` :

```js
/*!
 * reserved-ips-form.js — validation live + gouttière d'erreurs du textarea "IP réservées".
 * Réutilise la même règle de forme raccourcie que parseRange (subnet.php) et ReservedIps (PHP).
 * ES5. Init : ReservedIpsForm.init({ textarea, gutter, editor, status, tip, subnet }).
 */
window.ReservedIpsForm = (function () {
    'use strict';
    function toInt(part, networkInt) {
        if (!/^\d{1,3}(?:\.\d{1,3}){0,3}$/.test(part)) return null;
        var oct = part.split('.').map(Number);
        if (oct.length < 4) {
            var b = networkInt >>> 0;
            var base = [(b >>> 24) & 255, (b >>> 16) & 255, (b >>> 8) & 255, b & 255];
            oct = base.slice(0, 4 - oct.length).concat(oct);
        }
        if (oct.length !== 4) return null;
        for (var i = 0; i < 4; i++) { if (isNaN(oct[i]) || oct[i] < 0 || oct[i] > 255) return null; }
        return (oct[0] * 16777216 + oct[1] * 65536 + oct[2] * 256 + oct[3]) >>> 0;
    }
    // Valide une ligne → message d'erreur ou null (OK / ignorée).
    function validateLine(raw, subnet) {
        var hash = raw.indexOf('#');
        var part = (hash >= 0 ? raw.slice(0, hash) : raw).trim();
        if (part === '') return null; // vide / commentaire pur
        var a, e;
        if (part.indexOf('-') >= 0) {
            var bits = part.split('-');
            a = toInt(bits[0].trim(), subnet.networkInt);
            e = toInt(bits[1] != null ? bits[1].trim() : '', subnet.networkInt);
        } else {
            a = toInt(part, subnet.networkInt); e = a;
        }
        if (a == null || e == null) return 'Format IP/plage invalide';
        if (a > e) { var t = a; a = e; e = t; }
        if (a < subnet.networkInt || e > subnet.broadcastInt) return 'Hors du subnet ' + subnet.cidr;
        var pools = [];
        if (subnet.dynStartInt != null && subnet.dynEndInt != null) pools.push([subnet.dynStartInt, subnet.dynEndInt, 'pool dynamique']);
        if (subnet.shortStartInt != null && subnet.shortEndInt != null) pools.push([subnet.shortStartInt, subnet.shortEndInt, 'pool short-lease']);
        for (var i = 0; i < pools.length; i++) {
            if (a <= pools[i][1] && pools[i][0] <= e) return 'Chevauche le ' + pools[i][2];
        }
        return null;
    }
    function init(cfg) {
        var ta = cfg.textarea, gutter = cfg.gutter, editor = cfg.editor,
            status = cfg.status, tip = cfg.tip, subnet = cfg.subnet;
        if (!ta) return;
        function lineHeightPx() {
            var lh = parseFloat(getComputedStyle(ta).lineHeight);
            return isNaN(lh) ? 19.5 : lh;
        }
        function refresh() {
            var lines = ta.value.split('\n');
            var lh = lineHeightPx();
            gutter.innerHTML = '';
            var nErr = 0;
            lines.forEach(function (ln) {
                var g = document.createElement('div');
                g.className = 'rip-g-line';
                g.style.height = lh + 'px';
                var err = validateLine(ln, subnet);
                if (err) {
                    nErr++;
                    g.textContent = '!';
                    g.addEventListener('mouseenter', function (ev) {
                        tip.textContent = err; tip.style.display = 'block';
                        tip.style.left = (ev.clientX + 12) + 'px'; tip.style.top = (ev.clientY + 12) + 'px';
                    });
                    g.addEventListener('mousemove', function (ev) {
                        tip.style.left = (ev.clientX + 12) + 'px'; tip.style.top = (ev.clientY + 12) + 'px';
                    });
                    g.addEventListener('mouseleave', function () { tip.style.display = 'none'; });
                }
                gutter.appendChild(g);
            });
            gutter.style.transform = 'translateY(' + (-ta.scrollTop) + 'px)';
            if (editor) editor.classList.toggle('rip-invalid', nErr > 0);
            if (status) {
                if (ta.value.trim() === '') { status.textContent = ''; status.className = 'rip-status'; }
                else if (nErr > 0) { status.textContent = '✗ ' + nErr + ' ligne(s) en erreur — enregistrement bloqué'; status.className = 'rip-status ko'; }
                else { status.textContent = '✓ Liste valide'; status.className = 'rip-status ok'; }
            }
        }
        ta.addEventListener('input', refresh);
        ta.addEventListener('scroll', function () { gutter.style.transform = 'translateY(' + (-ta.scrollTop) + 'px)'; });
        refresh();
    }
    return { init: init };
}());
```

- [ ] **Step 3: Ajouter le champ dans subnet_edit.php**

Dans `public/subnet_edit.php`, la variable `$data` (vers la ligne 32) contient les valeurs du
subnet. Ajouter la clé `reserved_ips` juste après `'notes' => $subnet['notes'] ?? '',` :
```php
    'reserved_ips'           => $subnet['reserved_ips'] ?? '',
```
Puis dans le tableau `$data` construit depuis `$_POST` (vers la ligne 55), après
`'notes' => trim($_POST['notes'] ?? ''),` ajouter :
```php
        'reserved_ips'           => trim($_POST['reserved_ips'] ?? ''),
```
Puis, dans le HTML du formulaire, juste AVANT le bloc `<div class="mb-4"> ... Notes ...`
(repérer `<label class="form-label fw-semibold">Notes</label>`), insérer le champ :
```php
            <div class="mb-3">
                <label class="form-label fw-semibold">
                    IP réservées (hors DHCP)
                    <small class="text-muted fw-normal">(optionnel — 1 IP ou plage par ligne, description après #)</small>
                </label>
                <div class="rip-editor" id="rip-editor">
                    <div class="rip-gutter"><div class="rip-gutter-inner" id="rip-gutter"></div></div>
                    <textarea class="rip-ta" name="reserved_ips" id="rip-ta" rows="5" spellcheck="false"
                              placeholder="192.168.1.5 # Imprimante&#10;30-40 # Caméras"><?= h($data['reserved_ips'] ?? '') ?></textarea>
                </div>
                <div class="rip-status" id="rip-status"></div>
                <div class="form-text">Ces IP apparaissent sur le bargraph et bloquent la création d'une réservation dans la plage.</div>
            </div>
```

- [ ] **Step 4: Charger le JS + init dans subnet_edit.php**

À la fin de `public/subnet_edit.php` (après le formulaire, avant/après le footer selon le
pattern du fichier — repérer la fin du HTML), ajouter le script d'init. D'abord vérifier
comment le fichier se termine :
```bash
cd /home/algalord/www/dhcpman
tail -20 public/subnet_edit.php
```
Puis ajouter, APRÈS l'inclusion du footer (ou en fin de fichier si le footer est inclus plus
haut), un bloc script. Le subnet a besoin de ses bornes entières : injecter un objet JS
calculé en PHP. Ajouter ce PHP juste avant le `</div>` de fermeture du formulaire ou en tête
du script — le plus simple : ajouter le script tout à la fin du fichier :
```php
<script src="assets/js/reserved-ips-form.js"></script>
<script>
(function () {
  <?php
    $cidrForm = $data['cidr'] ?? ($subnet['cidr'] ?? '');
    [$nStr, $bStr] = array_pad(explode('/', $cidrForm ?: '0.0.0.0/32'), 2, '32');
    $bb = (int)$bStr;
    $mm = $bb >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bb)) - 1) & 0xFFFFFFFF);
    $netI = (int)sprintf('%u', ip2long($nStr) & $mm);
    $bcI  = $netI + (1 << (32 - $bb)) - 1;
    $sub = [
      'cidr'         => $cidrForm,
      'networkInt'   => $netI,
      'broadcastInt' => $bcI,
      'firstUsable'  => $bb >= 31 ? $netI : $netI + 1,
      'lastUsable'   => $bb >= 31 ? $bcI : $bcI - 1,
      'dynStartInt'  => (!empty($data['has_dynamic_pool']) && !empty($data['range_start'])) ? (int)ipToInt($data['range_start']) : null,
      'dynEndInt'    => (!empty($data['has_dynamic_pool']) && !empty($data['range_end']))   ? (int)ipToInt($data['range_end'])   : null,
      'shortStartInt'=> !empty($data['short_lease_pool_start']) ? (int)ipToInt($data['short_lease_pool_start']) : null,
      'shortEndInt'  => !empty($data['short_lease_pool_end'])   ? (int)ipToInt($data['short_lease_pool_end'])   : null,
    ];
  ?>
  var tip = document.createElement('div'); tip.className = 'rip-tip'; document.body.appendChild(tip);
  ReservedIpsForm.init({
    textarea: document.getElementById('rip-ta'),
    gutter:   document.getElementById('rip-gutter'),
    editor:   document.getElementById('rip-editor'),
    status:   document.getElementById('rip-status'),
    tip:      tip,
    subnet:   <?= json_encode($sub, JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>
  });
}());
</script>
```

- [ ] **Step 5: Répliquer le champ dans subnet_add.php**

Ouvrir `public/subnet_add.php`, repérer les mêmes ancres (le tableau `$data`, le champ Notes,
la fin du fichier). Appliquer les MÊMES 4 insertions qu'en Steps 3-4 :
- ajouter `'reserved_ips' => trim($_POST['reserved_ips'] ?? ''),` dans le `$data` POST ;
- (subnet_add n'a pas de valeurs pré-existantes → le textarea affiche `h($data['reserved_ips'] ?? '')` qui sera vide au 1er affichage, rempli si POST en erreur) ;
- insérer le même bloc HTML `<div class="mb-3"> ... rip-editor ...` avant le champ Notes ;
- ajouter le même bloc `<script src=...>` + init à la fin, en calculant `$sub` depuis `$data['cidr']`.

Vérifier d'abord la structure :
```bash
cd /home/algalord/www/dhcpman
grep -n "notes\|\\\$data\|cidr\|tail\|footer\|form-label" public/subnet_add.php | head -20
```
Adapter les ancres à ce fichier (même logique, mêmes noms de champs).

- [ ] **Step 6: Vérifier la syntaxe des deux formulaires**

Run :
```bash
cd /home/algalord/www/dhcpman
php -l public/subnet_edit.php && php -l public/subnet_add.php
```
Expected : `No syntax errors detected` pour les deux.

- [ ] **Step 7: Vérification manuelle (navigateur)**

Ouvrir `http://dhcpman.local/subnet_edit.php?id=1`. Vérifier :
- Le champ « IP réservées » apparaît avant Notes.
- Taper une ligne hors-CIDR → un `!` rouge apparaît en gouttière, survol = tooltip, bord rouge, statut « ✗ … ».
- Taper une ligne valide → `!` disparaît, statut « ✓ Liste valide ».
- Redimensionner + scroller le textarea → le `!` reste aligné sur sa ligne.
- Enregistrer avec une liste invalide → le serveur rejette (erreurs listées en haut) et conserve la saisie.

- [ ] **Step 8: Commit**

```bash
cd /home/algalord/www/dhcpman
git add public/subnet_edit.php public/subnet_add.php public/assets/js/reserved-ips-form.js public/assets/css/app.css
git commit -m "feat(reserved-ips): champ textarea + gouttière d'erreurs dans le formulaire subnet"
```

---

## Task 10: Bargraph — type `reserved` (parse, bloc, couleur, infobulle)

**Files:**
- Modify: `public/subnet.php`
- Modify: `public/assets/js/subnet-bargraph.js`
- Modify: `public/assets/css/app.css`
- Modify: `public/assets/js/subnet-bargraph.test.html`

- [ ] **Step 1: Injecter `reservedIps` dans SUBNET (subnet.php)**

Dans `public/subnet.php`, le tableau `$sbgSubnet` (inséré en Task 9 de la feature bargraph).
Ajouter une clé juste avant `'addUrl' => ...` :
```php
    'reservedIps'   => $subnet['reserved_ips'] ?? '',
```

- [ ] **Step 2: Ajouter le test bargraph (type reserved)**

Dans `public/assets/js/subnet-bargraph.test.html`, après les tests `computeBlocks`, ajouter
un cas dédié. D'abord repérer le bloc SUBNET23/ROWS23. Ajouter APRÈS le dernier test
`computeBlocks` et avant les tests `computeHoles` :

```js
  // ── Blocs réservés (type 'reserved') ──
  var SUBNET23R = JSON.parse(JSON.stringify(SUBNET23));
  SUBNET23R.reservedIps = '192.168.2.60-192.168.2.70 # Caméras\n192.168.2.100 # Imprimante';
  var blocksR = S.computeBlocks(ROWS23, SUBNET23R);
  var reservedBlocks = blocksR.filter(function (b) { return b.type === 'reserved'; });
  test('reserved: 2 blocs', reservedBlocks.length, 2);
  test('reserved: 1er range', [reservedBlocks[0].startInt, reservedBlocks[0].endInt], [3232236092, 3232236102]); // .60-.70
  test('reserved: desc', reservedBlocks[0].desc, 'Caméras');
  test('reserved: 2e est une IP seule', reservedBlocks[1].startInt === reservedBlocks[1].endInt, true);
  // Les réservées comptent comme occupées et ne sont jamais des trous
  var holesR = S.computeHoles(blocksR, SUBNET23R);
  var overlapR = false;
  holesR.forEach(function (h) { reservedBlocks.forEach(function (b) {
    if (h.startInt <= b.endInt && b.startInt <= h.endInt) overlapR = true; }); });
  test('reserved: pas de chevauchement trou/réservé', overlapR, false);
```

- [ ] **Step 3: Faire passer les tests — parser reservedIps dans computeBlocks**

Dans `public/assets/js/subnet-bargraph.js`, il faut : (a) un parseur de `reservedIps` réutilisant
la règle raccourcie, (b) l'injection de blocs `reserved` dans `computeBlocks`.

Ajouter, AVANT `computeBlocks` (près de `poolBlock`), un helper de parsing :

```js
    // Parse le texte "reservedIps" (même règle raccourcie) → blocs {startInt,endInt,desc} fusionnés.
    function parseReserved(raw, networkInt) {
        if (!raw) return [];
        function toInt(part) {
            if (!/^\d{1,3}(?:\.\d{1,3}){0,3}$/.test(part)) return null;
            var oct = part.split('.').map(Number);
            if (oct.length < 4) {
                var b = networkInt >>> 0;
                var base = [(b >>> 24) & 255, (b >>> 16) & 255, (b >>> 8) & 255, b & 255];
                oct = base.slice(0, 4 - oct.length).concat(oct);
            }
            if (oct.length !== 4) return null;
            for (var i = 0; i < 4; i++) { if (isNaN(oct[i]) || oct[i] < 0 || oct[i] > 255) return null; }
            return (oct[0] * 16777216 + oct[1] * 65536 + oct[2] * 256 + oct[3]) >>> 0;
        }
        var entries = [];
        raw.split(/\r\n|\r|\n/).forEach(function (line) {
            var hash = line.indexOf('#');
            var desc = hash >= 0 ? line.slice(hash + 1).trim() : '';
            var part = (hash >= 0 ? line.slice(0, hash) : line).trim();
            if (part === '') return;
            var a, e;
            if (part.indexOf('-') >= 0) {
                var bits = part.split('-'); a = toInt(bits[0].trim()); e = toInt(bits[1] != null ? bits[1].trim() : '');
            } else { a = toInt(part); e = a; }
            if (a == null || e == null) return; // invalide → ignoré côté rendu (le serveur bloque déjà)
            if (a > e) { var t = a; a = e; e = t; }
            entries.push({ startInt: a, endInt: e, desc: desc });
        });
        entries.sort(function (x, y) { return x.startInt - y.startInt; });
        var merged = [];
        entries.forEach(function (en) {
            var n = merged.length;
            if (n > 0 && en.startInt <= merged[n - 1].endInt + 1) {
                if (en.endInt > merged[n - 1].endInt) merged[n - 1].endInt = en.endInt;
                if (en.desc && merged[n - 1].desc.indexOf(en.desc) === -1)
                    merged[n - 1].desc = merged[n - 1].desc ? merged[n - 1].desc + ' · ' + en.desc : en.desc;
            } else {
                merged.push({ startInt: en.startInt, endInt: en.endInt, desc: en.desc });
            }
        });
        return merged;
    }
    function reservedBlock(startInt, endInt, desc) {
        return { type: 'reserved', startInt: startInt >>> 0, endInt: endInt >>> 0,
                 count: (endInt >>> 0) - (startInt >>> 0) + 1,
                 groups: {}, shortLeaseCount: 0, hostIds: [], desc: desc };
    }
```

Puis, dans `computeBlocks`, juste APRÈS l'injection des pools (après le bloc
`if (subnet.shortStartInt != null ...) { ... }`) et AVANT `blocks.sort(...)`, ajouter :

```js
        // Blocs réservés hors DHCP
        parseReserved(subnet.reservedIps, subnet.networkInt >>> 0).forEach(function (r) {
            blocks.push(reservedBlock(r.startInt, r.endInt, r.desc));
        });
```

NOTE : `subnet.networkInt` est fourni par l'objet SUBNET injecté (déjà présent depuis la
feature bargraph). Pour les fixtures de test, SUBNET23 contient `networkInt: 3232236032`.

- [ ] **Step 4: Ajouter la couleur + l'infobulle du type reserved**

Dans `public/assets/js/subnet-bargraph.js`, dans `TYPE_LABEL`, ajouter l'entrée :
```js
        'reserved': 'Réservé (hors DHCP)'
```
(ajouter une virgule après la ligne `'short-pool': 'Pool short-lease'` puis la nouvelle ligne).

Dans `tipHtml`, le bloc réservé doit afficher sa description. Repérer, dans la partie « block »
de `tipHtml`, la fin (avant `return '<div class="sbg-tip-title">' + title + ...`). Juste avant
ce `return`, ajouter :
```js
        if (ref.type === 'reserved' && ref.desc) {
            rows += row('Description', escapeHtml(ref.desc));
        }
```

- [ ] **Step 5: Ajouter le CSS de la couleur réservée**

Dans `public/assets/css/app.css`, dans le bloc `:root` du bargraph (celui avec `--sbg-reservation`),
ajouter :
```css
    --sbg-reserved:    #64748b;
```
Puis, après la règle `.sbg-seg.sbg-short-pool { ... }`, ajouter :
```css
.sbg-seg.sbg-reserved    { background: var(--sbg-reserved); }
```
(Le bloc réservé n'a pas de `cursor: pointer` → clic inerte, comme les pools.)

- [ ] **Step 6: Vérifier tests + parse en node**

Ouvrir `public/assets/js/subnet-bargraph.test.html` au navigateur → doit être tout vert
(anciens tests + nouveaux « reserved »). En l'absence de navigateur, vérifier en node :
```bash
cd /home/algalord/www/dhcpman
node -e "
global.window={}; global.console=console;
require('./public/assets/js/subnet-bargraph.js');
var S=global.window.SubnetBargraph;
var assert=require('assert');
var SUBNET={ networkInt:3232236032, firstUsable:3232236033, lastUsable:3232237566, broadcastInt:3232237567, total:1534, dynStartInt:null,dynEndInt:null,shortStartInt:null,shortEndInt:null, reservedIps:'192.168.2.60-192.168.2.70 # Caméras\n192.168.2.100 # Imprimante' };
var b=S.computeBlocks([], SUBNET);
var res=b.filter(function(x){return x.type==='reserved';});
assert.strictEqual(res.length,2);
assert.deepStrictEqual([res[0].startInt,res[0].endInt],[3232236092,3232236102]);
assert.strictEqual(res[0].desc,'Caméras');
var h=S.computeHoles(b,SUBNET);
var ov=false; h.forEach(function(x){res.forEach(function(y){if(x.startInt<=y.endInt&&y.startInt<=x.endInt)ov=true;});});
assert.strictEqual(ov,false);
console.log('bargraph reserved OK');
"
```
Expected : `bargraph reserved OK`.

- [ ] **Step 7: Commit**

```bash
cd /home/algalord/www/dhcpman
git add public/subnet.php public/assets/js/subnet-bargraph.js public/assets/css/app.css public/assets/js/subnet-bargraph.test.html
git commit -m "feat(reserved-ips): type 'reserved' sur le bargraph (parse, couleur, infobulle)"
```

---

## Task 11: Démo + doc + sync

**Files:**
- Modify: `public/assets/js/subnet-bargraph-demo.html`
- Modify: `CLAUDE.md`

- [ ] **Step 1: Ajouter des IP réservées à la démo**

Dans `public/assets/js/subnet-bargraph-demo.html`, l'objet `SUBNET23` de la démo. Ajouter une
clé `reservedIps` (juste avant `addUrl`) :
```js
    reservedIps: '192.168.2.60-192.168.2.70 # Caméras\n192.168.2.120 # Imprimante compta',
```
Et compléter le texte d'aide sous la barre pour mentionner le type réservé (gris ardoise).
Repérer le `<p class="text-muted small ...">` de la démo et y ajouter une phrase :
```
 Les blocs gris = IP réservées hors DHCP (survol = description).
```

- [ ] **Step 2: Vérification visuelle démo**

Ouvrir `http://dhcpman.local/assets/js/subnet-bargraph-demo.html`. Vérifier :
- Un bloc gris ardoise apparaît pour `.60-.70` et un pour `.120`.
- Survol → infobulle « Réservé (hors DHCP) » avec la description.
- Le clic sur un bloc gris ne fait rien (pas de filtre, pas d'ajout).

- [ ] **Step 3: Documenter dans CLAUDE.md**

Dans `CLAUDE.md`, section « Base de données » → sous-section `app_subnets`, ajouter la colonne
après `short_lease_pool_end` :
```
reserved_ips           TEXT     -- v0.16 : IP/plages réservées hors DHCP (texte multiligne, informatif + garde-fou)
```

Ajouter une nouvelle section après « Bargraph de remplissage de subnet (v0.15) » :
```markdown
## IP réservées hors DHCP (v0.16)

Liste par subnet d'IP/plages fixes configurées hors DHCP, purement applicative (jamais
poussée à Kea). Colonne `app_subnets.reserved_ips` (TEXT), une entrée par ligne :
`<ip-ou-range>[#description]`. Formes de plage identiques au filtre (`30-40`, `2.50-3.10`,
octets de fin complétés par le préfixe réseau).

- Classe pure `lib/ReservedIps.php` : `parse`, `validate` (format, CIDR, chevauchement pools),
  `contains` (blocage réservation), `toBlocks` (bargraph). Source de vérité serveur.
- **Validation à l'enregistrement du subnet** (`Subnet::validate`) : erreurs par ligne +
  **vérif croisée bloquante** si une réservation DHCP existante tombe dans une plage réservée.
- **Blocage réservation** : `Reservation::checkReservedConflict` (via `$subnetContext`) +
  feedback AJAX (`api_check_duplicate.php`).
- **Formulaire** : textarea avec gouttière d'erreurs (`!` + tooltip au survol),
  `public/assets/js/reserved-ips-form.js`, validation live.
- **Bargraph** : 4e type `reserved` (gris ardoise `--sbg-reserved`), infobulle avec
  description, clic inerte, compté comme occupé. Migration : `sql/add_reserved_ips.sql`.
- ⚠️ **Règle de forme raccourcie dupliquée en 3 points** (filtre JS `parseRange`, parseur JS
  du bargraph `parseReserved`, `ReservedIps` PHP) — à garder synchrones.
```

- [ ] **Step 4: Commit + sync**

```bash
cd /home/algalord/www/dhcpman
git add public/assets/js/subnet-bargraph-demo.html CLAUDE.md
git commit -m "docs(reserved-ips): démo + documentation CLAUDE.md (v0.16)"
bash ~/sync.sh dhcpman
```

---

## Récapitulatif des tâches

1. Migration SQL `reserved_ips`
2. `ReservedIps::parse` (formes raccourcies + description)
3. `ReservedIps::validate` (format, CIDR, pools)
4. `ReservedIps::contains` + `toBlocks`
5. Persistance dans `Subnet::add`/`update`
6. Validation subnet + vérif croisée réservations existantes
7. Blocage réservation (`Reservation::checkReservedConflict`)
8. Feedback AJAX (`api_check_duplicate.php`)
9. Formulaire subnet (textarea + gouttière, JS + CSS)
10. Bargraph type `reserved`
11. Démo + doc + sync

Tâches 2-4 couvertes par des scripts `php -r` ; 5-8 par des scripts end-to-end DB ; 9-11 par vérification navigateur + node. Chaque tâche = un commit autonome.
