# Copie de réservations entre subnets — 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 copier une sélection de réservations d'un subnet source vers un subnet cible, avec transposition automatique des IP/gateway/routes et validation des conflits comme un import.

**Architecture:** Extraction de la logique d'analyse de conflits (`analyzeRows` + helpers) de `subnet_import.php` vers une classe partagée `lib/ReservationImportAnalyzer` (avec un nouveau statut `reserved`). Une classe pure `lib/SubnetRemap` fait la réécriture d'IP. Une nouvelle page `subnet_copy.php` en 3 étapes réutilise l'analyzer. Le bargraph JS gagne un type `pending` (preview live) et un callback `onHoleClick`.

**Tech Stack:** PHP 8 vanilla + PDO, Bootstrap 5 (offline), JS ES5/IIFE, schéma Kea natif MySQL. Tests unitaires via harness PHP CLI en scratchpad, tests JS via page HTML navigateur.

**Spec de référence:** `docs/superpowers/specs/2026-07-16-copie-reservations-entre-subnets-design.md`

**Répertoire scratchpad pour les tests:** `/tmp/claude-1001/-home-algalord-www-dhcpman/5a1bc313-5f53-4e69-9dc0-4127624361c5/scratchpad`

**Après chaque commit** (rappel du CLAUDE.md) : `bash ~/sync.sh dhcpman`. Sync vers 192.168.1.2.

---

## Structure des fichiers

| Fichier | Responsabilité | Action |
|---------|----------------|--------|
| `lib/SubnetRemap.php` | Réécriture d'IP « conserver l'hôte, changer le réseau » | Créer |
| `lib/ReservationImportAnalyzer.php` | `analyzeRows`, `normalizeMac`, `inPool`, statuts (+`reserved`) | Créer (extrait) |
| `lib/sbg_helpers.php` | `buildBargraphSubnet()` — objet `$sbgSubnet` | Créer (extrait) |
| `public/subnet_import.php` | Consomme l'analyzer partagé, bargraph mono | Modifier |
| `public/subnet.php` | Bouton « Copier vers… », `onHoleClick`, `buildBargraphSubnet()` | Modifier |
| `public/subnet_copy.php` | Page copie 3 étapes | Créer |
| `public/assets/js/subnet-copy.js` | JS page copie/import (preview live, clic-trou) | Créer |
| `public/assets/js/subnet-bargraph.js` | Type `pending`, `onHoleClick` | Modifier |
| `public/assets/js/subnet-bargraph.test.html` | Tests type `pending` | Modifier |

---

## Task 1: SubnetRemap — réécriture d'IP

**Files:**
- Create: `lib/SubnetRemap.php`
- Test: scratchpad `test_subnet_remap.php`

- [ ] **Step 1: Write the failing test**

Créer `/tmp/claude-1001/-home-algalord-www-dhcpman/5a1bc313-5f53-4e69-9dc0-4127624361c5/scratchpad/test_subnet_remap.php` :

```php
<?php
require_once __DIR__ . '/../../../www/dhcpman/lib/SubnetRemap.php';

function u(string $ip): int { return (int)sprintf('%u', ip2long($ip)); }
$fail = 0;
function check($label, $got, $want) {
    global $fail;
    $ok = $got === $want;
    if (!$ok) $fail++;
    printf("[%s] %s : got=%s want=%s\n", $ok ? 'OK' : 'KO', $label, var_export($got, true), var_export($want, true));
}

// /24 → /24 : même hôte
check('/24→/24',
    SubnetRemap::remapIp('192.168.2.50', u('192.168.2.0'), 24, u('192.168.10.0'), 24),
    '192.168.10.50');

// /24 → /23 : l'hôte .50 rentre toujours
check('/24→/23',
    SubnetRemap::remapIp('192.168.2.50', u('192.168.2.0'), 24, u('10.20.0.0'), 23),
    '10.20.0.50');

// /23 → /24 : hôte .300 (2e moitié du /23) déborde le /24 → null
check('/23→/24 débordement',
    SubnetRemap::remapIp('192.168.2.44', u('192.168.2.0'), 23, u('10.0.0.0'), 24),
    null); // 192.168.2.0/23 : .2.44 = offset 300 > 255 → déborde

// /23 → /24 : hôte dans la 1re moitié rentre
check('/23→/24 ok',
    SubnetRemap::remapIp('192.168.2.44', u('192.168.2.0'), 23, u('10.0.0.0'), 23),
    '10.0.2.44');

// /24 → /25 : .200 déborde le /25 (0-127) → null
check('/24→/25 débordement',
    SubnetRemap::remapIp('192.168.2.200', u('192.168.2.0'), 24, u('10.0.0.0'), 25),
    null);

// prefix cible /32 : impossible sauf offset 0
check('/24→/32 offset non nul',
    SubnetRemap::remapIp('192.168.2.5', u('192.168.2.0'), 24, u('10.0.0.9'), 32),
    null);

exit($fail ? 1 : 0);
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php /tmp/claude-1001/-home-algalord-www-dhcpman/5a1bc313-5f53-4e69-9dc0-4127624361c5/scratchpad/test_subnet_remap.php`
Expected: FAIL — `Failed opening required 'lib/SubnetRemap.php'` (le fichier n'existe pas encore).

- [ ] **Step 3: Write minimal implementation**

Créer `lib/SubnetRemap.php` :

```php
<?php
/**
 * Réécriture d'IP « conserver l'hôte, changer le réseau ».
 * Toute l'arithmétique en entiers non signés 32 bits.
 */
class SubnetRemap
{
    /**
     * Transpose la partie hôte de $ip du réseau source vers le réseau cible.
     *
     * @param string $ip        IP à transposer (doit appartenir au réseau source)
     * @param int    $srcNet    entier réseau source (non signé)
     * @param int    $srcPrefix préfixe source (0-32)
     * @param int    $dstNet    entier réseau cible (non signé)
     * @param int    $dstPrefix préfixe cible (0-32)
     * @return string|null  null si l'hôte déborde le masque cible
     */
    public static function remapIp(string $ip, int $srcNet, int $srcPrefix, int $dstNet, int $dstPrefix): ?string
    {
        $ipInt = (int)sprintf('%u', ip2long($ip));

        $srcMask = $srcPrefix <= 0 ? 0 : (0xFFFFFFFF << (32 - $srcPrefix)) & 0xFFFFFFFF;
        $host    = $ipInt & (~$srcMask & 0xFFFFFFFF);

        $dstMask     = $dstPrefix <= 0 ? 0 : (0xFFFFFFFF << (32 - $dstPrefix)) & 0xFFFFFFFF;
        $maxHost     = ~$dstMask & 0xFFFFFFFF;
        if ($host > $maxHost) {
            return null; // l'hôte ne rentre pas dans le masque cible
        }

        $result = ($dstNet & $dstMask) | $host;
        return long2ip($result);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php /tmp/claude-1001/-home-algalord-www-dhcpman/5a1bc313-5f53-4e69-9dc0-4127624361c5/scratchpad/test_subnet_remap.php`
Expected: PASS — toutes les lignes `[OK]`, exit 0.

- [ ] **Step 5: Commit**

```bash
git add lib/SubnetRemap.php
git commit -m "feat(copie-resa): SubnetRemap — réécriture d'IP conserver-hôte/changer-réseau"
bash ~/sync.sh dhcpman
```

---

## Task 2: Extraction de l'analyzer dans une classe partagée

Déplace `analyzeRows`, `normalizeMac`, `inPool` de `subnet_import.php` vers `lib/ReservationImportAnalyzer.php`, **sans changement de comportement** (le statut `reserved` viendra en Task 3). `subnet_import.php` appelle la classe.

**Files:**
- Create: `lib/ReservationImportAnalyzer.php`
- Modify: `public/subnet_import.php` (retirer les 3 fonctions locales lignes 9-22 pour normalizeMac/inPool + 94-269 pour analyzeRows ; ajouter require + préfixe de classe)
- Test: scratchpad `test_analyzer.php`

- [ ] **Step 1: Write the failing test**

Créer scratchpad `test_analyzer.php`. Ce test nécessite la DB (Reservation::getBySubnet) ; on teste `normalizeMac` et `inPool` (purs) et on vérifie que la classe se charge.

```php
<?php
require_once __DIR__ . '/../../../www/dhcpman/lib/ReservationImportAnalyzer.php';

$fail = 0;
function check($l, $g, $w) { global $fail; $ok = $g === $w; if (!$ok) $fail++; printf("[%s] %s\n", $ok?'OK':'KO', $l); }

check('normalizeMac', ReservationImportAnalyzer::normalizeMac('BC-DD-C2-47-3D-B6'), 'bc:dd:c2:47:3d:b6');

$subnet = ['has_dynamic_pool'=>1, 'range_start'=>'192.168.1.100', 'range_end'=>'192.168.1.200'];
check('inPool dedans', ReservationImportAnalyzer::inPool('192.168.1.150', $subnet), true);
check('inPool dehors', ReservationImportAnalyzer::inPool('192.168.1.50',  $subnet), false);
check('inPool sans pool', ReservationImportAnalyzer::inPool('192.168.1.150', ['has_dynamic_pool'=>0,'range_start'=>'','range_end'=>'']), false);

check('constante statuses contient reserved', in_array('reserved', ReservationImportAnalyzer::STATUSES, true), true);

exit($fail ? 1 : 0);
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php .../scratchpad/test_analyzer.php`
Expected: FAIL — classe introuvable.

- [ ] **Step 3: Créer la classe (copie conforme depuis subnet_import.php)**

Créer `lib/ReservationImportAnalyzer.php`. **Copier intégralement** le corps actuel de `normalizeMac` (subnet_import.php:10-13), `inPool` (16-22) et `analyzeRows` (94-269) dans des méthodes `public static`. Remplacer les appels internes `normalizeMac(` → `self::normalizeMac(`, `inPool(` → `self::inPool(`. Le corps d'`analyzeRows` appelle `Reservation::getBySubnet`, `isValidMac`, `isValidIp`, `ipInCidr` (globaux/statiques déjà chargés par init.php) — inchangés.

```php
<?php
/**
 * Analyse de conflits d'un lot de réservations (import CSV & copie inter-subnet).
 * Extrait de subnet_import.php pour partage. Logique inchangée + statut 'reserved' (Task 3).
 */
require_once __DIR__ . '/Reservation.php';
require_once __DIR__ . '/ReservedIps.php';

class ReservationImportAnalyzer
{
    const STATUSES = ['clean','skip','ip_conflict','mac_conflict','invalid','pool','reserved'];

    /** Normalise un MAC en minuscules séparés par ':'. */
    public static function normalizeMac(string $raw): string {
        $hex = strtolower(preg_replace('/[^0-9a-fA-F]/', '', $raw));
        return implode(':', str_split($hex, 2));
    }

    /** Vérifie qu'une IP est dans le pool dynamique d'un subnet. */
    public static function inPool(string $ip, array $subnet): bool {
        if (!$subnet['has_dynamic_pool'] || !$subnet['range_start'] || !$subnet['range_end']) {
            return false;
        }
        $n = ip2long($ip);
        return $n >= ip2long($subnet['range_start']) && $n <= ip2long($subnet['range_end']);
    }

    /**
     * @param array      $rawRows       Lignes brutes (mac_raw, ip, hostname, description,
     *                                  subnet_name, gateway, routes_raw, short_lease,
     *                                  no_gateway, group_name, line)
     * @param array|null $defaultSubnet Subnet par défaut (null en mode global)
     * @param array      $subnetsByName Subnets indexés par name lowercase
     * @param bool       $hasSubnetCol  Colonne subnet présente ?
     */
    public static function analyzeRows(array $rawRows, ?array $defaultSubnet, array $subnetsByName, bool $hasSubnetCol): array {
        // ⇩ COLLER ICI le corps exact d'analyzeRows() de subnet_import.php:95-268,
        //   en remplaçant normalizeMac( → self::normalizeMac( et inPool( → self::inPool(
    }
}
```

> IMPORTANT : copier le corps réel, ne pas paraphraser. Le corps commence à la ligne 95 (`// Cache des réservations existantes...`) et finit à `return $analyzed;` (ligne 268).
>
> **Vérification anti-erreur de copie** : le corps original d'`analyzeRows` contient **13**
> occurrences de `'status'=>` (compté avant extraction : `grep -c "'status'=>" public/subnet_import.php` → 13).
> Après le collage dans la classe (Task 2, avant l'ajout de `reserved` en Task 3) :
> ```bash
> grep -c "'status'=>" lib/ReservationImportAnalyzer.php   # doit valoir 13
> grep -c "'status'=>" public/subnet_import.php            # doit valoir 0 (fonction supprimée)
> ```
> (Après Task 3, la classe passera à 14 : la nouvelle branche `reserved`.)

- [ ] **Step 4: Modifier subnet_import.php pour consommer la classe**

Dans `public/subnet_import.php` :
1. Supprimer les fonctions locales `normalizeMac` (lignes 9-13), `inPool` (15-22), `analyzeRows` (86-269). **Garder** `parseCsv` (spécifique à l'import).
2. Après `require_once __DIR__ . '/../lib/init.php';` (ligne 3), ajouter :
   ```php
   require_once __DIR__ . '/../lib/ReservationImportAnalyzer.php';
   ```
3. Remplacer les appels :
   - `analyzeRows(` → `ReservationImportAnalyzer::analyzeRows(`
   - `normalizeMac(` → `ReservationImportAnalyzer::normalizeMac(`
   - `inPool(` → `ReservationImportAnalyzer::inPool(`

Rechercher tous les usages : `grep -n 'analyzeRows\|normalizeMac(\|inPool(' public/subnet_import.php`.

- [ ] **Step 5: Run tests + non-régression syntaxe**

Run: `php .../scratchpad/test_analyzer.php`
Expected: PASS.

Run: `php -l public/subnet_import.php && php -l lib/ReservationImportAnalyzer.php`
Expected: `No syntax errors detected` sur les deux.

- [ ] **Step 6: Commit**

```bash
git add lib/ReservationImportAnalyzer.php public/subnet_import.php
git commit -m "refactor(import): extraction de analyzeRows/normalizeMac/inPool dans ReservationImportAnalyzer"
bash ~/sync.sh dhcpman
```

---

## Task 3: Statut `reserved` dans l'analyzer

Ajoute la détection des IP réservées hors DHCP. Corrige le bug préexistant de l'import (une IP réservée passait `clean`).

**Files:**
- Modify: `lib/ReservationImportAnalyzer.php` (dans `analyzeRows`, bloc « vérifications IP non vide »)
- Test: scratchpad `test_analyzer_reserved.php` (avec un subnet fictif, sans DB pour la partie reserved — on teste `ReservedIps::contains` directement puis l'intégration via un subnet mocké)

- [ ] **Step 1: Write the failing test**

Créer scratchpad `test_analyzer_reserved.php`. On teste la brique `ReservedIps::contains` (déjà existante) telle qu'utilisée, pour figer le contrat attendu par l'analyzer :

```php
<?php
require_once __DIR__ . '/../../../www/dhcpman/lib/ReservedIps.php';
$fail = 0;
function check($l,$g,$w){global $fail;$ok=$g===$w;if(!$ok)$fail++;printf("[%s] %s\n",$ok?'OK':'KO',$l);}

$net = ReservedIps::networkIntFromCidr('192.168.2.0/24');
check('net non null', $net !== null, true);

$raw = "192.168.2.90 # imprimante fixe\n192.168.2.100-192.168.2.110";
$hit = ReservedIps::contains($raw, $net, '192.168.2.90');
check('contains .90', $hit !== null, true);
check('desc .90', $hit['desc'] ?? '', 'imprimante fixe');
check('contains .105 (range)', ReservedIps::contains($raw, $net, '192.168.2.105') !== null, true);
check('contains .50 absent', ReservedIps::contains($raw, $net, '192.168.2.50'), null);

exit($fail?1:0);
```

- [ ] **Step 2: Run test to verify it passes déjà (contrat ReservedIps)**

Run: `php .../scratchpad/test_analyzer_reserved.php`
Expected: PASS (ReservedIps existe déjà). Ce test verrouille le contrat consommé à l'étape suivante.

- [ ] **Step 3: Ajouter la détection `reserved` dans analyzeRows**

Dans `lib/ReservationImportAnalyzer.php`, méthode `analyzeRows`, juste **après** le bloc `if (inPool($ip, $subnet)) { ... continue; }` et **avant** la fermeture `} // fin vérifications IP non vide`, insérer :

```php
        // ── IP réservée hors DHCP (v0.18) ──────────────────────────────────────
        if (!empty($subnet['reserved_ips'])) {
            $rNet = ReservedIps::networkIntFromCidr($subnet['cidr']);
            if ($rNet !== null) {
                $rHit = ReservedIps::contains($subnet['reserved_ips'], $rNet, $ip);
                if ($rHit !== null) {
                    $desc = trim((string)($rHit['desc'] ?? ''));
                    $note = 'IP réservée (hors DHCP)' . ($desc !== '' ? ' : ' . $desc : '');
                    $analyzed[] = ['line'=>$lineNum,'mac'=>$mac,'ip'=>$ip,'hostname'=>$host,'description'=>$desc,
                        'gateway'=>$gateway,'routes_raw'=>$routesRaw,'short_lease'=>$shortLease,'no_gateway'=>$noGateway,'group_name'=>$group,
                        'status'=>'reserved','note'=>$note,
                        'subnet_id'=>$subnetAppId,'subnet_name'=>$subnetLabel,'kea_subnet_id'=>$keaSubnetId,
                        'mac_host_id'=>null,'ip_taken_by'=>null,'mac_existing_ip'=>null];
                    continue;
                }
            }
        }
```

> Attention : ce bloc utilise `$desc` comme variable locale de description réservée — ne pas confondre avec `$desc` de la ligne (qui est la description de la réservation, ici nommée `$desc` dans le scope de la boucle via `$r['description']`). **Renommer la variable locale** en `$rDesc` pour éviter l'écrasement :

```php
                    $rDesc = trim((string)($rHit['desc'] ?? ''));
                    $note  = 'IP réservée (hors DHCP)' . ($rDesc !== '' ? ' : ' . $rDesc : '');
                    $analyzed[] = ['line'=>$lineNum,'mac'=>$mac,'ip'=>$ip,'hostname'=>$host,'description'=>$desc,
                        ...
                        'status'=>'reserved','note'=>$note, ...];
```

(garder `'description'=>$desc` = la description de la réservation, pas `$rDesc`.)

- [ ] **Step 4: Vérifier la syntaxe**

Run: `php -l lib/ReservationImportAnalyzer.php`
Expected: `No syntax errors detected`.

- [ ] **Step 5: Gérer le statut `reserved` dans l'aperçu ET l'application de subnet_import.php**

Dans `public/subnet_import.php` :

1. **Comptes** (vers ligne 493-496) : ajouter `reserved` aux conflits éditables :
   ```php
   $cConflict = count(array_filter($analyzed, fn($r) => in_array($r['status'], ['ip_conflict','mac_conflict','reserved'])));
   ```

2. **Rendu du tableau** (vers ligne 610, `$rowCls = match(...)`) : donner à `reserved` la même classe visuelle que `ip_conflict` (ligne orange). Ajouter la branche `'reserved' =>` avec la même valeur que `'ip_conflict' =>`.

3. **Contrôle de résolution** (vers ligne 688-713) : ajouter une branche pour `reserved` identique à `ip_conflict` — un champ `name="r[<i>][new_ip]"` éditable (pré-rempli avec l'IP transposée courante).
   ```php
   <?php elseif ($row['status'] === 'reserved'): ?>
       <input type="text" name="r[<?= $i ?>][new_ip]" value="<?= h($row['ip']) ?>"
              class="form-control form-control-sm new-ip-input" data-subnet="<?= (int)$row['kea_subnet_id'] ?>"
              placeholder="Nouvelle IP">
   ```

4. **Boucle d'application** (vers ligne 313-443) : traiter `reserved` comme `ip_conflict` (créer avec `new_ip`). Dupliquer le bloc `if ($status === 'ip_conflict') { ... }` en `if ($status === 'reserved') { ... }` OU élargir la condition : `if ($status === 'ip_conflict' || $status === 'reserved') {`. **Ajouter le garde-fou reserved** dans ce bloc, après la vérif doublon IP :
   ```php
   // Garde-fou : la nouvelle IP ne doit pas elle-même tomber sur une IP réservée
   $rNet = ReservedIps::networkIntFromCidr($subnetCidr);
   if ($rNet !== null && !empty($subnetReserved)
       && ReservedIps::contains($subnetReserved, $rNet, $targetIp) !== null) {
       $errMessages[] = "Ligne $lineNum : IP « $targetIp » réservée (hors DHCP).";
       $skipped++; continue;
   }
   ```
   où `$subnetCidr` / `$subnetReserved` sont récupérés du subnet cible de la ligne (via `$allSubnets` par `$subnetAppId`, comme le CIDR est déjà récupéré ligne 378).

   > Note : `require_once __DIR__ . '/../lib/ReservedIps.php';` — déjà chargé transitivement par ReservationImportAnalyzer, mais l'ajouter explicitement en tête de subnet_import.php pour la lisibilité.

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

Run: `php -l public/subnet_import.php`
Expected: `No syntax errors detected`.

- [ ] **Step 7: Commit**

```bash
git add lib/ReservationImportAnalyzer.php public/subnet_import.php
git commit -m "feat(import): statut 'reserved' pour les IP réservées hors DHCP (aperçu + garde-fou application)"
bash ~/sync.sh dhcpman
```

---

## Task 4: buildBargraphSubnet — helper partagé

Extrait la construction de `$sbgSubnet` de `subnet.php` pour réemploi.

**Files:**
- Create: `lib/sbg_helpers.php`
- Modify: `public/subnet.php` (remplacer lignes 15-37 par un appel)
- Test: scratchpad `test_sbg_helpers.php`

- [ ] **Step 1: Write the failing test**

Créer scratchpad `test_sbg_helpers.php` :

```php
<?php
require_once __DIR__ . '/../../../www/dhcpman/lib/helpers.php';
require_once __DIR__ . '/../../../www/dhcpman/lib/sbg_helpers.php';
$fail=0; function check($l,$g,$w){global $fail;$ok=$g===$w;if(!$ok)$fail++;printf("[%s] %s got=%s\n",$ok?'OK':'KO',$l,var_export($g,true));}

$subnet = [
  'id'=>7,'cidr'=>'192.168.2.0/24','has_dynamic_pool'=>1,
  'range_start'=>'192.168.2.100','range_end'=>'192.168.2.200',
  'short_lease_pool_start'=>null,'short_lease_pool_end'=>null,
  'reserved_ips'=>"192.168.2.90 # x",
];
$s = buildBargraphSubnet($subnet);
check('total /24 usable', $s['total'], 254);
check('networkInt', $s['networkInt'], (int)sprintf('%u', ip2long('192.168.2.0')));
check('dynStartInt', $s['dynStartInt'], (int)sprintf('%u', ip2long('192.168.2.100')));
check('shortStartInt null', $s['shortStartInt'], null);
check('reservedIps', $s['reservedIps'], "192.168.2.90 # x");
check('addUrl', $s['addUrl'], 'reservation_add.php?subnet_id=7');
exit($fail?1:0);
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php .../scratchpad/test_sbg_helpers.php`
Expected: FAIL — `sbg_helpers.php` introuvable.

- [ ] **Step 3: Créer lib/sbg_helpers.php**

```php
<?php
require_once __DIR__ . '/helpers.php';

/**
 * Construit l'objet passé à SubnetBargraph.init (bornes entières, pools, reservedIps,
 * total, addUrl). Extrait de subnet.php pour réemploi (subnet_copy, subnet_import mono).
 *
 * @param array $subnet ligne app_subnets (id, cidr, has_dynamic_pool, range_start/end,
 *                       short_lease_pool_start/end, reserved_ips)
 */
function buildBargraphSubnet(array $subnet): array {
    [$netStr, $bitsStr] = array_pad(explode('/', $subnet['cidr']), 2, '32');
    $bits        = (int)$bitsStr;
    $maskLong    = $bits >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF);
    $networkInt  = (int)sprintf('%u', ip2long($netStr) & $maskLong);
    $hostCount   = 1 << (32 - $bits);
    $broadcast   = $networkInt + $hostCount - 1;
    $firstUsable = $bits >= 31 ? $networkInt : $networkInt + 1;
    $lastUsable  = $bits >= 31 ? $broadcast : $broadcast - 1;
    return [
        'cidr'          => $subnet['cidr'],
        'networkInt'    => $networkInt,
        'broadcastInt'  => $broadcast,
        'firstUsable'   => $firstUsable,
        'lastUsable'    => $lastUsable,
        'total'         => $lastUsable - $firstUsable + 1,
        'dynStartInt'   => ($subnet['has_dynamic_pool'] && $subnet['range_start']) ? (int)ipToInt($subnet['range_start']) : null,
        'dynEndInt'     => ($subnet['has_dynamic_pool'] && $subnet['range_end'])   ? (int)ipToInt($subnet['range_end'])   : null,
        'shortStartInt' => !empty($subnet['short_lease_pool_start']) ? (int)ipToInt($subnet['short_lease_pool_start']) : null,
        'shortEndInt'   => !empty($subnet['short_lease_pool_end'])   ? (int)ipToInt($subnet['short_lease_pool_end'])   : null,
        'reservedIps'   => $subnet['reserved_ips'] ?? '',
        'addUrl'        => 'reservation_add.php?subnet_id=' . (int)$subnet['id'],
    ];
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php .../scratchpad/test_sbg_helpers.php`
Expected: PASS (total=254, etc.).

- [ ] **Step 5: Modifier subnet.php pour utiliser le helper**

Dans `public/subnet.php`, remplacer les lignes 15-37 (le bloc `// Bornes entières...` jusqu'à la fin du tableau `$sbgSubnet = [...]`) par :

```php
require_once __DIR__ . '/../lib/sbg_helpers.php';
$sbgSubnet = buildBargraphSubnet($subnet);
```

- [ ] **Step 6: Vérifier la syntaxe + non-régression visuelle**

Run: `php -l public/subnet.php`
Expected: `No syntax errors detected`.

Vérification manuelle : ouvrir un subnet dans l'app, le bargraph doit être identique à avant (taux, blocs, trous).

- [ ] **Step 7: Commit**

```bash
git add lib/sbg_helpers.php public/subnet.php
git commit -m "refactor(bargraph): buildBargraphSubnet() partagé (extrait de subnet.php)"
bash ~/sync.sh dhcpman
```

---

## Task 5: Bargraph JS — type `pending` + callback onHoleClick

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

- [ ] **Step 1: Ajouter le cas de test `pending` dans test.html**

Dans `public/assets/js/subnet-bargraph.test.html`, ajouter un test de `computeBlocks` avec une row portant `_pending: true`. Repérer un test `computeBlocks` existant et ajouter à sa suite :

```javascript
test('computeBlocks : une row _pending devient un bloc type pending', function () {
    var subnet = { firstUsable: ip('192.168.1.1'), lastUsable: ip('192.168.1.254'),
                   networkInt: ip('192.168.1.0'), total: 254,
                   dynStartInt: null, dynEndInt: null, shortStartInt: null, shortEndInt: null,
                   reservedIps: '' };
    var rows = [
        { ipInt: ip('192.168.1.10'), hostname: 'a', mac: 'aa', groupName: '', shortLease: 0 },
        { ipInt: ip('192.168.1.11'), hostname: 'b', mac: 'bb', groupName: '', shortLease: 0, _pending: true }
    ];
    var blocks = SBG._computeBlocks(rows, subnet);
    var pending = blocks.filter(function (b) { return b.type === 'pending'; });
    assertEqual(pending.length, 1, 'un bloc pending');
});
```

> `ip()`, `test()`, `assertEqual()`, `SBG._computeBlocks` : suivre les helpers déjà présents dans le fichier de test. Si `computeBlocks` n'est pas exposé sous `SBG._computeBlocks`, l'exposer (voir Step 3).

- [ ] **Step 2: Ouvrir test.html et constater l'échec**

Ouvrir `public/assets/js/subnet-bargraph.test.html` au navigateur. Le nouveau test est rouge (type `pending` non géré).

- [ ] **Step 3: Gérer le type `pending` dans computeBlocks**

Dans `public/assets/js/subnet-bargraph.js`, fonction `computeBlocks`, à l'endroit où chaque row est transformée en bloc de type `reservation`, dériver le type depuis `_pending` :

```javascript
// dans la boucle sur rows, là où le type est posé :
var blockType = r._pending ? 'pending' : 'reservation';
// ... utiliser blockType au lieu de la constante 'reservation'
```

Ajouter la couleur du type `pending`. Repérer la table des couleurs par type (ex. objet `TYPE_COLOR` ou classes CSS `--sbg-*`). Ajouter :
- couleur : bleu clair, ex. `#93c5fd` (variable `--sbg-pending`)
- libellé infobulle : `TYPE_LABEL.pending = 'À créer';`

Le type `pending` ne fusionne qu'avec `pending` (la fusion est déjà par type — vérifier que `pending` est un type distinct de `reservation`, donc pas de fusion croisée : OK par construction).

Rendre `pending` **non cliquable** : dans `bindSegment`, la branche `if (s.kind === 'block' && s.ref.type === 'reservation')` ne matchera pas `pending` → déjà non cliquable. Bien.

- [ ] **Step 4: Ajouter le callback onHoleClick**

Dans `init(cfg)`, lire le callback :
```javascript
var onHoleClick = cfg.onHoleClick || null;
```
Passer `onHoleClick` à `bindSegment` (ajouter le paramètre). Dans `bindSegment`, remplacer la branche trou :

```javascript
} else if (s.kind === 'hole') {
    seg.style.cursor = (canEdit || onHoleClick) ? 'pointer' : '';
    seg.addEventListener('click', function () {
        var firstFreeIp = intToIp(s.ref.startInt);
        if (onHoleClick) {
            onHoleClick(firstFreeIp);
        } else if (canEdit) {
            window.location.href = subnet.addUrl + '&ip=' + encodeURIComponent(firstFreeIp);
        }
    });
}
```

Mettre à jour l'appel `bindSegment(seg, s, tip, subnet, onFilter, canEdit)` → ajouter `onHoleClick` en dernier argument, et la signature de `bindSegment`.

- [ ] **Step 5: Exposer computeBlocks pour les tests (si pas déjà fait)**

Si le fichier expose un objet public (`window.SubnetBargraph = { init: init }`), ajouter les fonctions testées sous un sous-objet `_` :
```javascript
window.SubnetBargraph = { init: init };
window.SBG = { _computeBlocks: computeBlocks, _computeHoles: computeHoles }; // pour les tests
```
(vérifier le nom déjà utilisé par le fichier de test existant et s'y conformer.)

- [ ] **Step 6: Ouvrir test.html et vérifier le vert**

Ouvrir `public/assets/js/subnet-bargraph.test.html`. Tous les tests verts, dont le nouveau `pending`.

- [ ] **Step 7: Non-régression subnet.php**

Modifier l'appel dans `public/subnet.php` (ligne ~832) pour passer `onHoleClick` en conservant le comportement actuel via le callback :

```javascript
        SubnetBargraph.init({
            mount: document.getElementById('subnet-bargraph'),
            rateMount: document.getElementById('subnet-bargraph-rate'),
            rows: ROWS,
            subnet: SUBNET,
            canEdit: CAN_EDIT,
            onFilter: function (q) { /* inchangé */ },
            onHoleClick: CAN_EDIT ? function (ip) {
                window.location.href = SUBNET.addUrl + '&ip=' + encodeURIComponent(ip);
            } : null
        });
```

Vérifier en ouvrant un subnet : clic sur un trou → navigue vers `reservation_add.php` (comportement identique à avant).

- [ ] **Step 8: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/js/subnet-bargraph.test.html public/subnet.php
git commit -m "feat(bargraph): type 'pending' (preview) + callback onHoleClick"
bash ~/sync.sh dhcpman
```

---

## Task 6: Bouton « Copier vers… » dans la barre de sélection

**Files:**
- Modify: `public/subnet.php` (markup barre de sélection ~178-196 + JS handler ~565)

- [ ] **Step 1: Ajouter le bouton + le formulaire de copie**

Dans `public/subnet.php`, dans `<div id="selection-bar">` (après le bouton Exporter, ligne 185), ajouter :

```php
            <button type="button" class="btn btn-sm btn-outline-primary" id="btn-mass-copy">
                <i class="bi bi-files me-1"></i>Copier vers…
            </button>
```

Après le `<form id="mass-delete-form">` (ligne 196), ajouter un formulaire vers `subnet_copy.php` :

```php
        <form id="mass-copy-form" method="post" action="subnet_copy.php" class="d-none">
            <input type="hidden" name="subnet_id" value="<?= (int)$subnet['id'] ?>">
            <input type="hidden" name="host_ids" id="mass-copy-hostids">
        </form>
```

- [ ] **Step 2: Ajouter le handler JS**

Dans le JS de `subnet.php`, près du handler `btn-mass-export` (ligne ~565), ajouter :

```javascript
    var btnMassCopy = document.getElementById('btn-mass-copy');
    if (btnMassCopy) {
        btnMassCopy.addEventListener('click', function () {
            var rows = selectedRows();
            if (!rows.length) return;
            var ids = rows.map(function (r) { return r.host_id; }).join(',');
            document.getElementById('mass-copy-hostids').value = ids;
            document.getElementById('mass-copy-form').submit();
        });
    }
```

> `selectedRows()` existe déjà dans le JS de subnet.php (retourne les rows cochées).

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

Run: `php -l public/subnet.php`
Expected: `No syntax errors detected`.

Vérification : sélectionner des réservations → le bouton « Copier vers… » apparaît dans la barre. (Le POST échouera tant que `subnet_copy.php` n'existe pas — normal, Task 7.)

- [ ] **Step 4: Commit**

```bash
git add public/subnet.php
git commit -m "feat(copie-resa): bouton « Copier vers… » dans la barre de sélection"
bash ~/sync.sh dhcpman
```

---

## Task 7: subnet_copy.php — étape 0 (choix de la cible)

**Files:**
- Create: `public/subnet_copy.php`

- [ ] **Step 1: Créer le squelette + étape 0**

Créer `public/subnet_copy.php`. Étape 0 : réception du POST de la barre de sélection, validation d'appartenance, formulaire de choix cible.

```php
<?php
require_once __DIR__ . '/../lib/init.php';
Auth::requireLogin();
Auth::requireRole('admin', 'tech');
require_once __DIR__ . '/../lib/ReservationImportAnalyzer.php';
require_once __DIR__ . '/../lib/SubnetRemap.php';
require_once __DIR__ . '/../lib/ReservedIps.php';
require_once __DIR__ . '/../lib/sbg_helpers.php';

// ── Subnet source ───────────────────────────────────────────────────────────
$sourceId     = (int)($_POST['subnet_id'] ?? 0);
$sourceSubnet = Subnet::getById($sourceId);
if (!$sourceSubnet) { header('Location: dashboard.php'); exit; }

// ── host_ids cochés → validation d'appartenance au subnet source ─────────────
$rawIds = array_filter(array_map('intval', explode(',', (string)($_POST['host_ids'] ?? ''))));
$hostIds = [];
foreach (array_unique($rawIds) as $hid) {
    $r = Reservation::getById($hid);
    if ($r && (int)$r['dhcp4_subnet_id'] === (int)$sourceSubnet['kea_subnet_id']) {
        $hostIds[] = $hid;
    }
}
if (!$hostIds) {
    $_SESSION['flash'] = ['type'=>'danger','message'=>'Aucune réservation valide sélectionnée.'];
    header('Location: subnet.php?id=' . $sourceId); exit;
}

$step = $_POST['step'] ?? '0';

// ── Cibles possibles (tous les subnets sauf la source) ───────────────────────
$allSubnets = Subnet::getAll();
$targets = array_filter($allSubnets, fn($s) => (int)$s['id'] !== $sourceId);

// ÉTAPE 0 : formulaire de choix de la cible
require_once __DIR__ . '/../lib/layout_header.php';
?>
<div class="container-fluid">
  <h1 class="h4 mb-3">Copier des réservations</h1>
  <p class="text-muted"><?= count($hostIds) ?> réservation(s) depuis
     <strong><?= h($sourceSubnet['name']) ?></strong> (<?= h($sourceSubnet['cidr']) ?>)</p>

  <form method="post" action="subnet_copy.php">
    <input type="hidden" name="step" value="1">
    <input type="hidden" name="subnet_id" value="<?= (int)$sourceId ?>">
    <input type="hidden" name="host_ids" value="<?= h(implode(',', $hostIds)) ?>">
    <div class="mb-3" style="max-width:480px">
      <label class="form-label">Subnet de destination</label>
      <select name="dst_subnet_id" class="form-select" required>
        <option value="">— choisir —</option>
        <?php foreach ($targets as $t): ?>
          <option value="<?= (int)$t['id'] ?>"><?= h($t['name']) ?> (<?= h($t['cidr']) ?>)</option>
        <?php endforeach; ?>
      </select>
    </div>
    <button type="submit" class="btn btn-primary">Prévisualiser</button>
    <a href="subnet.php?id=<?= (int)$sourceId ?>" class="btn btn-outline-secondary">Annuler</a>
  </form>
</div>
<?php require_once __DIR__ . '/../lib/layout_footer.php';
```

> Vérifier le nom exact de la clé de flash de session utilisée ailleurs (grep `\$_SESSION\['flash'\]` dans public/) et s'y conformer.

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

Run: `php -l public/subnet_copy.php`
Expected: `No syntax errors detected`.

- [ ] **Step 3: Test manuel étape 0**

Depuis un subnet, sélectionner des réservations → « Copier vers… » → l'écran de choix cible s'affiche avec la liste des autres subnets. Choisir une cible + Prévisualiser → page blanche/erreur attendue (étape 1 pas encore codée).

- [ ] **Step 4: Commit**

```bash
git add public/subnet_copy.php
git commit -m "feat(copie-resa): subnet_copy.php étape 0 (choix de la cible + garde-fou appartenance)"
bash ~/sync.sh dhcpman
```

---

## Task 8: subnet_copy.php — étape 1 (prévisualisation + remap + analyse)

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

- [ ] **Step 1: Construire les rawRows remappées + analyser**

Dans `subnet_copy.php`, **avant** le `require layout_header` de l'étape 0, insérer le traitement de l'étape 1 (`if ($step === '1')`). Récupérer la cible, remapper, analyser, rendre le tableau.

```php
// ── ÉTAPE 1 : prévisualisation ───────────────────────────────────────────────
if ($step === '1') {
    $dstId     = (int)($_POST['dst_subnet_id'] ?? 0);
    $dstSubnet = Subnet::getById($dstId);
    if (!$dstSubnet || $dstId === $sourceId || (int)($dstSubnet['is_active'] ?? 1) !== 1) {
        $_SESSION['flash'] = ['type'=>'danger','message'=>'Subnet de destination invalide.'];
        header('Location: subnet.php?id=' . $sourceId); exit;
    }

    // Bornes réseau source & cible pour le remap
    [, $srcBits] = array_pad(explode('/', $sourceSubnet['cidr']), 2, '32');
    [$dstNetStr, $dstBits] = array_pad(explode('/', $dstSubnet['cidr']), 2, '32');
    $dstNet = (int)sprintf('%u', ip2long($dstNetStr));
    $srcPrefix = (int)$srcBits; $dstPrefix = (int)$dstBits;

    // Charger les réservations source cochées
    $srcRes = Reservation::getBySubnet($sourceSubnet['kea_subnet_id']);
    $srcById = [];
    foreach ($srcRes as $r) $srcById[(int)$r['host_id']] = $r;

    $rawRows = [];
    $overflowLines = []; // numéros de ligne en débordement de masque
    $line = 1;
    foreach ($hostIds as $hid) {
        $r = $srcById[$hid] ?? null;
        if (!$r) continue;
        $line++;

        // Remap IP réservation
        $newIp = '';
        $overflow = false;
        if ($r['ip_address'] !== '' && $r['ip_address'] !== '0.0.0.0') {
            $mapped = SubnetRemap::remapIp($r['ip_address'], $srcPrefix, $dstNet, $dstPrefix);
            if ($mapped === null) { $overflow = true; }
            else { $newIp = $mapped; }
        }

        // Remap gateway (si interne au réseau source)
        $gw = $r['gateway'] ?? '';
        if ($gw !== '' && ipInCidr($gw, $sourceSubnet['cidr'])) {
            $mgw = SubnetRemap::remapIp($gw, $srcPrefix, $dstNet, $dstPrefix);
            if ($mgw !== null) $gw = $mgw;
        }

        // Remap gw de chaque route (dest inchangé), gw interne uniquement
        $routes = $r['routes'] ?? [];
        $routesStr = [];
        foreach ($routes as $rt) {
            $rgw = $rt['gw'];
            if ($rgw !== '' && ipInCidr($rgw, $sourceSubnet['cidr'])) {
                $m = SubnetRemap::remapIp($rgw, $srcPrefix, $dstNet, $dstPrefix);
                if ($m !== null) $rgw = $m;
            }
            $routesStr[] = $rt['dest'] . ' - ' . $rgw;
        }

        $rawRows[] = [
            'line'        => $line,
            'mac_raw'     => $r['mac'],
            'ip'          => $newIp,
            'hostname'    => $r['hostname'],
            'description' => $r['description'] ?? '',
            'subnet_name' => '',
            'gateway'     => $gw,
            'routes_raw'  => implode(', ', $routesStr),
            'short_lease' => (int)($r['short_lease'] ?? 0),
            'no_gateway'  => (int)($r['no_gateway'] ?? 0),
            'group_name'  => $r['group_name'] ?? '',
            'src_ip'      => $r['ip_address'], // pour l'affichage source → cible
        ];
        if ($overflow) $overflowLines[$line] = true;
    }

    // Analyse via la classe partagée (mono-subnet : defaultSubnet = cible)
    $analyzed = ReservationImportAnalyzer::analyzeRows($rawRows, $dstSubnet, [], false);

    // Réinjecter src_ip (perdu par analyzeRows) + forcer statut 'overflow'
    $srcIpByLine = [];
    foreach ($rawRows as $rr) $srcIpByLine[$rr['line']] = $rr['src_ip'];
    foreach ($analyzed as &$a) {
        $a['src_ip'] = $srcIpByLine[$a['line']] ?? '';
        if (!empty($overflowLines[$a['line']])) {
            $a['status'] = 'overflow';
            $a['note']   = 'Hôte hors du masque cible — saisir une IP';
        }
    }
    unset($a);

    $sbgSubnet = buildBargraphSubnet($dstSubnet);
    // → rendu du tableau (Step 2)
}
```

> `routesStr` reconstruit le format Kea `"dest - gw"` attendu par le parseur de routes en aval. Vérifier que la chaîne `routes_raw` est bien re-parsée à l'application (Task 9) au format `[{dest,gw}]` avant `Reservation::add`.

- [ ] **Step 2: Rendu du tableau de prévisualisation**

Toujours dans le bloc `if ($step === '1')`, après le calcul de `$sbgSubnet`, rendre la page (header, bargraph, tableau, formulaire étape 2). Le tableau reprend la structure de la maquette validée (colonne « IP source → cible », statut, contrôle inline). Comptes :

```php
$cClean    = count(array_filter($analyzed, fn($r) => $r['status'] === 'clean'));
$cConflict = count(array_filter($analyzed, fn($r) => in_array($r['status'], ['ip_conflict','mac_conflict','reserved','overflow'])));
$cIgnored  = count(array_filter($analyzed, fn($r) => in_array($r['status'], ['skip','pool','invalid'])));
```

Rendu (extrait clé — colonne source→cible + contrôle par statut) :

```php
require_once __DIR__ . '/../lib/layout_header.php';
?>
<div class="container-fluid">
  <h1 class="h4 mb-1">Copier vers <?= h($dstSubnet['name']) ?> · <?= h($dstSubnet['cidr']) ?></h1>
  <p class="text-muted"><span class="text-success"><?= $cClean ?></span> à créer ·
     <span class="text-warning"><?= $cConflict ?></span> conflit(s) ·
     <span class="text-secondary"><?= $cIgnored ?></span> ignorée(s)</p>

  <div id="subnet-bargraph" class="mb-3"></div>

  <form method="post" action="subnet_copy.php" id="copy-apply-form">
    <input type="hidden" name="step" value="2">
    <input type="hidden" name="subnet_id" value="<?= (int)$sourceId ?>">
    <input type="hidden" name="dst_subnet_id" value="<?= (int)$dstId ?>">
    <input type="hidden" name="host_ids" value="<?= h(implode(',', $hostIds)) ?>">
    <table class="table table-sm align-middle res-copy-table">
      <thead><tr>
        <th style="width:34px"></th><th>Hostname</th><th>MAC</th>
        <th>IP source → cible</th><th>Options</th><th>Statut / action</th>
      </tr></thead>
      <tbody>
      <?php foreach ($analyzed as $i => $row):
        $editable = in_array($row['status'], ['ip_conflict','reserved','overflow'], true);
        $ignored  = in_array($row['status'], ['skip','pool','invalid'], true);
        $checked  = !$ignored;
      ?>
        <tr class="copy-row status-<?= h($row['status']) ?>">
          <td>
            <input type="checkbox" name="r[<?= $i ?>][do]" value="1" <?= $checked ? 'checked' : '' ?> <?= $ignored ? 'disabled' : '' ?>>
            <input type="hidden" name="r[<?= $i ?>][status]"      value="<?= h($row['status']) ?>">
            <input type="hidden" name="r[<?= $i ?>][mac]"         value="<?= h($row['mac']) ?>">
            <input type="hidden" name="r[<?= $i ?>][hostname]"    value="<?= h($row['hostname']) ?>">
            <input type="hidden" name="r[<?= $i ?>][description]" value="<?= h($row['description']) ?>">
            <input type="hidden" name="r[<?= $i ?>][group_name]"  value="<?= h($row['group_name']) ?>">
            <input type="hidden" name="r[<?= $i ?>][gateway]"     value="<?= h($row['gateway']) ?>">
            <input type="hidden" name="r[<?= $i ?>][routes_raw]"  value="<?= h($row['routes_raw']) ?>">
            <input type="hidden" name="r[<?= $i ?>][short_lease]" value="<?= (int)$row['short_lease'] ?>">
            <input type="hidden" name="r[<?= $i ?>][no_gateway]"  value="<?= (int)$row['no_gateway'] ?>">
            <input type="hidden" name="r[<?= $i ?>][ip_transposed]" value="<?= h($row['ip']) ?>">
          </td>
          <td><?= h($row['hostname']) ?></td>
          <td class="font-monospace small"><?= h($row['mac']) ?></td>
          <td class="font-monospace small">
            <span class="text-muted"><?= h($row['src_ip']) ?></span> →
            <?php if ($editable): ?>
              <input type="text" name="r[<?= $i ?>][ip]" value="<?= h($row['ip']) ?>"
                     class="form-control form-control-sm d-inline-block ip-field" style="width:130px"
                     placeholder="IP cible">
            <?php else: ?>
              <input type="hidden" name="r[<?= $i ?>][ip]" value="<?= h($row['ip']) ?>">
              <span class="<?= $ignored ? 'text-decoration-line-through text-muted' : 'text-success' ?>"><?= h($row['ip']) ?></span>
            <?php endif; ?>
          </td>
          <td class="small text-muted"><?= h(copyOptionsSummary($row)) ?></td>
          <td class="small"><?= h($row['note']) ?></td>
        </tr>
      <?php endforeach; ?>
      </tbody>
    </table>
    <button type="submit" class="btn btn-primary">Copier les réservations cochées</button>
    <a href="subnet.php?id=<?= (int)$sourceId ?>" class="btn btn-outline-secondary">Annuler</a>
  </form>
</div>

<script>
  var SUBNET = <?= json_encode($sbgSubnet, JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>;
  var EXISTING_ROWS = <?= json_encode(array_map(function($r){
      return ['ipInt'=>($r['ip_address']!==''&&$r['ip_address']!=='0.0.0.0')?(int)ipToInt($r['ip_address']):null,
              'hostname'=>$r['hostname'],'mac'=>$r['mac'],'groupName'=>$r['group_name']??'','shortLease'=>(int)($r['short_lease']??0)];
  }, $srcRes), JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>;
</script>
<script src="assets/js/subnet-bargraph.js"></script>
<script src="assets/js/subnet-copy.js"></script>
<?php
require_once __DIR__ . '/../lib/layout_footer.php';
exit;
}
```

> `EXISTING_ROWS` doit contenir les réservations **existantes du subnet cible**, pas du source. Corriger : charger `Reservation::getBySubnet($dstSubnet['kea_subnet_id'])` et l'utiliser pour `EXISTING_ROWS`. (Le bargraph montre l'état actuel du cible.)

Ajouter en haut du fichier la fonction `copyOptionsSummary` :

```php
function copyOptionsSummary(array $row): string {
    $bits = [];
    if ((int)$row['no_gateway'])            $bits[] = 'no-gateway';
    elseif (trim($row['gateway']) !== '')   $bits[] = 'gw ' . $row['gateway'];
    if (trim($row['routes_raw']) !== '')    $bits[] = 'routes';
    if ((int)$row['short_lease'])           $bits[] = 'short-lease';
    return $bits ? implode(' · ', $bits) : '—';
}
```

- [ ] **Step 3: Corriger EXISTING_ROWS (cible, pas source)**

Dans le bloc étape 1, ajouter avant le rendu :
```php
$dstRes = Reservation::getBySubnet($dstSubnet['kea_subnet_id']);
```
et utiliser `$dstRes` (non `$srcRes`) dans le `json_encode` de `EXISTING_ROWS`.

- [ ] **Step 4: Vérifier la syntaxe**

Run: `php -l public/subnet_copy.php`
Expected: `No syntax errors detected`.

- [ ] **Step 5: Test manuel étape 1**

Sélectionner des réservations → Copier vers → choisir une cible → Prévisualiser.
Vérifier : colonne « source → cible » remplie, IP transposées, lignes de conflit avec champ éditable, bargraph du cible affiché, comptes cohérents. (Le bargraph live et le clic-trou dépendent de subnet-copy.js — Task 10. Le bouton Copier échouera tant que l'étape 2 n'existe pas — Task 9.)

- [ ] **Step 6: Commit**

```bash
git add public/subnet_copy.php
git commit -m "feat(copie-resa): étape 1 — remap IP/gateway/routes + prévisualisation + bargraph cible"
bash ~/sync.sh dhcpman
```

---

## Task 9: subnet_copy.php — étape 2 (application)

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

- [ ] **Step 1: Traiter l'application avec garde-fous serveur**

Dans `subnet_copy.php`, ajouter le bloc `if ($step === '2')` **avant** le bloc étape 1 (ordre : 2, puis 1, puis 0). Re-résout la cible, re-valide l'appartenance (déjà fait en tête), applique.

```php
// ── ÉTAPE 2 : application ────────────────────────────────────────────────────
if ($step === '2') {
    $dstId     = (int)($_POST['dst_subnet_id'] ?? 0);
    $dstSubnet = Subnet::getById($dstId);
    if (!$dstSubnet || $dstId === $sourceId || (int)($dstSubnet['is_active'] ?? 1) !== 1) {
        $_SESSION['flash'] = ['type'=>'danger','message'=>'Subnet de destination invalide.'];
        header('Location: subnet.php?id=' . $sourceId); exit;
    }
    $dstKeaId  = (int)$dstSubnet['kea_subnet_id'];
    $dstCidr   = $dstSubnet['cidr'];
    $dstReserved = $dstSubnet['reserved_ips'] ?? '';
    $rNet      = ReservedIps::networkIntFromCidr($dstCidr);

    // IP déjà réservées dans le cible (base) — pour la détection de doublon
    $existingIps = [];
    foreach (Reservation::getBySubnet($dstKeaId) as $er) {
        if ($er['ip_address'] !== '') $existingIps[$er['ip_address']] = true;
    }
    $batchIps = [];

    $rows    = $_POST['r'] ?? [];
    $applied = 0; $skipped = 0; $errMessages = [];

    foreach ($rows as $row) {
        if (empty($row['do'])) { $skipped++; continue; }
        $status = $row['status'] ?? '';
        if (in_array($status, ['skip','pool','invalid'], true)) { $skipped++; continue; }

        $ip = trim((string)($row['ip'] ?? ''));

        // Garde-fous serveur (le client ne fait pas foi)
        if ($ip === '') { $errMessages[] = 'Une ligne sans IP a été ignorée.'; $skipped++; continue; }
        if (!isValidIp($ip)) { $errMessages[] = "IP « $ip » invalide."; $skipped++; continue; }
        if (!ipInCidr($ip, $dstCidr)) { $errMessages[] = "IP « $ip » hors subnet."; $skipped++; continue; }
        if (ReservationImportAnalyzer::inPool($ip, $dstSubnet)) { $errMessages[] = "IP « $ip » dans le pool dynamique."; $skipped++; continue; }
        if ($rNet !== null && $dstReserved !== '' && ReservedIps::contains($dstReserved, $rNet, $ip) !== null) {
            $errMessages[] = "IP « $ip » réservée (hors DHCP)."; $skipped++; continue;
        }
        if (isset($existingIps[$ip]) || isset($batchIps[$ip])) { $errMessages[] = "IP « $ip » déjà utilisée."; $skipped++; continue; }

        // Reconstituer les routes [{dest,gw}] depuis routes_raw
        $routes = [];
        foreach (explode(',', (string)($row['routes_raw'] ?? '')) as $part) {
            $part = trim($part);
            if ($part === '') continue;
            $pieces = strpos($part, ' - ') !== false ? explode(' - ', $part, 2) : preg_split('/\s+/', $part, 2);
            if (count($pieces) === 2) $routes[] = ['dest'=>trim($pieces[0]),'gw'=>trim($pieces[1])];
        }

        $data = [
            'mac'         => (string)$row['mac'],
            'ip'          => $ip,
            'hostname'    => (string)$row['hostname'],
            'description' => (string)($row['description'] ?? ''),
            'group_name'  => (string)($row['group_name'] ?? ''),
            'gateway'     => (int)($row['no_gateway'] ?? 0) ? '' : (string)($row['gateway'] ?? ''),
            'no_gateway'  => (int)($row['no_gateway'] ?? 0),
            'routes'      => $routes,
            'short_lease' => (int)($row['short_lease'] ?? 0),
        ];
        try {
            Reservation::add($dstKeaId, $data);
            $batchIps[$ip] = true;
            $applied++;
        } catch (Throwable $e) {
            $errMessages[] = 'Erreur : ' . $e->getMessage();
        }
    }

    // Un seul sync
    $sync = SyncService::sync();
    AuditLog::log('reservation.copy', 'subnet', (int)$dstSubnet['id'],
        "$applied copiée(s) de « {$sourceSubnet['name']} » vers « {$dstSubnet['name']} »"
        . ($skipped ? " ($skipped ignorée(s))" : ''));

    $msg = "$applied réservation(s) copiée(s) vers « {$dstSubnet['name']} »"
         . ($skipped ? ", $skipped ignorée(s)" : '') . '.';
    if ($errMessages) $msg .= ' ' . implode(' ', array_slice($errMessages, 0, 5));
    if (!$sync['success']) {
        $_SESSION['flash'] = ['type'=>'warning','message'=>$msg . ' ⚠ ' . $sync['message']];
    } else {
        $_SESSION['flash'] = ['type'=>'success','message'=>$msg];
    }
    header('Location: subnet.php?id=' . (int)$dstSubnet['id']); exit;
}
```

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

Run: `php -l public/subnet_copy.php`
Expected: `No syntax errors detected`.

- [ ] **Step 3: Test E2E (Kea/DB réels)**

Sur `testclaude` ou un subnet de test :
1. Créer 3-4 réservations dans un subnet source /24 (dont une avec gateway spécifique, une avec route, une short-lease).
2. Copier vers un subnet cible /24 vide.
3. Vérifier en base :
   ```bash
   mysql -h 192.168.1.2 -u claude -pclaudedev123 kea -e \
   "SELECT h.hostname, INET_NTOA(h.ipv4_address) ip, h.dhcp4_subnet_id FROM hosts h WHERE h.dhcp4_subnet_id = <DST_KEA_ID>;"
   ```
   IP transposées (même hôte), gateway/routes remappées (option 3/121 avec IP cible).
4. Vérifier l'audit : `reservation.copy` présent.
5. Vérifier que le subnet **source est intact** (copie non destructive).

- [ ] **Step 4: Commit**

```bash
git add public/subnet_copy.php
git commit -m "feat(copie-resa): étape 2 — application avec garde-fous serveur + audit + sync"
bash ~/sync.sh dhcpman
```

---

## Task 10: subnet-copy.js — preview live + clic-trou

**Files:**
- Create: `public/assets/js/subnet-copy.js`

- [ ] **Step 1: Écrire le module (ES5/IIFE)**

Créer `public/assets/js/subnet-copy.js`. Branche le bargraph live sur le tableau + le clic-trou → champ IP actif.

```javascript
/* Page copie/import : bargraph live + clic-trou → champ IP actif. ES5/IIFE. */
(function () {
    if (!window.SubnetBargraph || typeof SUBNET === 'undefined') return;
    var mount = document.getElementById('subnet-bargraph');
    if (!mount) return;

    function u(ip) { // IP → int non signé
        var p = ip.split('.'); if (p.length !== 4) return null;
        return (((+p[0]) << 24) >>> 0) + ((+p[1]) << 16) + ((+p[2]) << 8) + (+p[3]);
    }

    // Validation optimiste d'une IP cible (format + CIDR + pool + reserved connus côté client)
    function ipValidForPreview(ip) {
        var n = u(ip); if (n === null) return false;
        if (n < SUBNET.firstUsable || n > SUBNET.lastUsable) return false;
        if (SUBNET.dynStartInt != null && n >= SUBNET.dynStartInt && n <= SUBNET.dynEndInt) return false;
        if (SUBNET.shortStartInt != null && n >= SUBNET.shortStartInt && n <= SUBNET.shortEndInt) return false;
        // reserved : réutiliser le parseur du bargraph si exposé (parseReserved), sinon skip
        if (window.SBG && SBG._parseReserved) {
            var blocks = SBG._parseReserved(SUBNET.reservedIps || '', SUBNET.networkInt >>> 0);
            for (var i = 0; i < blocks.length; i++) {
                if (n >= blocks[i].startInt && n <= blocks[i].endInt) return false;
            }
        }
        return true;
    }

    // Collecte les lignes cochées & créables → rows _pending
    function collectPendingRows() {
        var pend = [];
        var seen = {};
        var trs = document.querySelectorAll('.copy-row');
        for (var i = 0; i < trs.length; i++) {
            var tr = trs[i];
            var cb = tr.querySelector('input[type=checkbox][name$="[do]"]');
            if (!cb || cb.disabled || !cb.checked) continue;
            var status = (tr.querySelector('input[name$="[status]"]') || {}).value || '';
            if (status === 'skip' || status === 'pool' || status === 'invalid') continue;
            var ipField = tr.querySelector('.ip-field');
            var ip = ipField ? ipField.value.trim()
                             : (tr.querySelector('input[name$="[ip]"]') || {}).value || '';
            if (!ip || !ipValidForPreview(ip)) continue;
            var n = u(ip);
            if (seen[n]) continue; seen[n] = true;
            pend.push({ ipInt: n, hostname: '(à créer)', mac: '', groupName: '', shortLease: 0, _pending: true });
        }
        return pend;
    }

    var activeIpField = null; // cible du prochain clic-trou (capturée au mousedown)

    function render() {
        var rows = (window.EXISTING_ROWS || []).slice();
        rows = rows.concat(collectPendingRows());
        SubnetBargraph.init({
            mount: mount,
            rows: rows,
            subnet: SUBNET,
            canEdit: false,
            onHoleClick: function (ip) {
                if (!activeIpField) { hint('Clique d\'abord dans un champ IP'); return; }
                activeIpField.value = ip;
                flash(activeIpField);
                activeIpField.dispatchEvent(new Event('input', { bubbles: true }));
                activeIpField.dispatchEvent(new Event('blur',  { bubbles: true }));
            }
        });
        // Capturer le champ actif AVANT que le clic-trou ne fasse blur
        mount.querySelectorAll('.sbg-seg').forEach(function (seg) {
            seg.addEventListener('mousedown', function () {
                var el = document.activeElement;
                activeIpField = (el && el.classList && el.classList.contains('ip-field')) ? el : null;
            });
        });
    }

    function flash(el) {
        el.classList.add('ip-filled-flash');
        setTimeout(function () { el.classList.remove('ip-filled-flash'); }, 1000);
    }
    var hintEl = null;
    function hint(msg) {
        if (!hintEl) { hintEl = document.createElement('div'); hintEl.className = 'copy-hint'; document.body.appendChild(hintEl); }
        hintEl.textContent = msg; hintEl.style.opacity = '1';
        setTimeout(function () { hintEl.style.opacity = '0'; }, 1500);
    }

    // Re-render sur interactions du tableau (debounce sur la frappe)
    var t = null;
    function schedule() { clearTimeout(t); t = setTimeout(render, 150); }
    var form = document.getElementById('copy-apply-form');
    if (form) {
        form.addEventListener('change', function (e) {
            if (e.target.matches('input[type=checkbox], select')) render();
        });
        form.addEventListener('input', function (e) {
            if (e.target.classList.contains('ip-field')) schedule();
        });
    }

    render();
}());
```

> `SBG._parseReserved` : exposer `parseReserved` dans subnet-bargraph.js sous `window.SBG` (comme `_computeBlocks` en Task 5). Si non exposé, la validation reserved côté preview est simplement omise (la barrière serveur reste). Décider en Task 5 d'exposer `parseReserved` pour cohérence — l'ajouter à `window.SBG`.
>
> `.sbg-seg` : vérifier la classe réelle des segments cliquables dans subnet-bargraph.js (`el('div', 'sbg-seg')` ou similaire) et l'utiliser.

- [ ] **Step 2: Exposer parseReserved + vérifier la classe des segments**

Dans `public/assets/js/subnet-bargraph.js`, ajouter à `window.SBG` : `_parseReserved: parseReserved`. Confirmer le nom de classe des segments (`grep "sbg-seg\|'sbg-" public/assets/js/subnet-bargraph.js`) et ajuster `subnet-copy.js` si nécessaire.

- [ ] **Step 3: Styles CSS (flash + hint)**

Dans `public/assets/css/app.css`, ajouter :

```css
.ip-filled-flash { border-color:#198754 !important; background:#d1e7dd !important; transition:background .8s ease, border-color .8s ease; }
.copy-hint { position:fixed; bottom:1rem; left:50%; transform:translateX(-50%); background:#333; color:#fff; padding:.4rem .8rem; border-radius:4px; font-size:.85rem; opacity:0; transition:opacity .3s ease; z-index:1080; pointer-events:none; }
.res-copy-table .status-reserved, .res-copy-table .status-ip_conflict, .res-copy-table .status-overflow { background:rgba(253,126,20,.08); }
.res-copy-table .status-clean { background:rgba(25,135,84,.06); }
```

- [ ] **Step 4: Test manuel (navigateur)**

Prévisualiser une copie. Vérifier :
- Le bargraph montre les futures réservations (bleu clair) par-dessus l'existant du cible.
- Décocher une ligne → le bloc pending disparaît en direct.
- Ressaisir une IP de conflit valide → un bloc pending apparaît ; invalide → rien.
- Cliquer dans un champ IP éditable puis cliquer un trou du bargraph → l'IP du trou remplit **ce** champ, flash vert, le trou devient pending.
- Cliquer un trou sans champ focalisé → hint « Clique d'abord dans un champ IP », aucun champ modifié.

- [ ] **Step 5: Commit**

```bash
git add public/assets/js/subnet-copy.js public/assets/js/subnet-bargraph.js public/assets/css/app.css
git commit -m "feat(copie-resa): subnet-copy.js — bargraph live + clic-trou → champ IP actif"
bash ~/sync.sh dhcpman
```

---

## Task 11: Bargraph sur l'import mono-subnet

**Files:**
- Modify: `public/subnet_import.php` (étape 1, mode mono uniquement)

- [ ] **Step 1: Injecter le bargraph + subnet-copy.js à l'étape 1 mono**

Dans `public/subnet_import.php`, repérer le rendu de l'étape 1 (aperçu). En mode **mono-subnet** uniquement (`$subnet` défini et pas de mode global — vérifier la variable exacte, ex. `$subnetId` non nul), ajouter avant le tableau :

```php
<?php if ($subnet): // import mono-subnet : bargraph du cible ?>
  <?php require_once __DIR__ . '/../lib/sbg_helpers.php'; $sbgSubnet = buildBargraphSubnet($subnet); ?>
  <div id="subnet-bargraph" class="mb-3"></div>
<?php endif; ?>
```

Et avant `</body>` / après le tableau, si `$subnet` :

```php
<?php if ($subnet): ?>
<script>
  var SUBNET = <?= json_encode($sbgSubnet, JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>;
  var EXISTING_ROWS = <?= json_encode(array_map(function($r){
      return ['ipInt'=>($r['ip_address']!==''&&$r['ip_address']!=='0.0.0.0')?(int)ipToInt($r['ip_address']):null,
              'hostname'=>$r['hostname'],'mac'=>$r['mac'],'groupName'=>$r['group_name']??'','shortLease'=>(int)($r['short_lease']??0)];
  }, Reservation::getBySubnet($subnet['kea_subnet_id'])), JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>;
</script>
<script src="assets/js/subnet-bargraph.js"></script>
<script src="assets/js/subnet-copy.js"></script>
<?php endif; ?>
```

> Adapter les sélecteurs dans subnet-copy.js pour qu'ils marchent aussi sur l'import : le tableau d'import utilise `name="r[<i>][...]"` (déjà) mais pas la classe `.copy-row` ni `.ip-field`. **Ajouter ces classes** aux `<tr>` et aux champs `new_ip` du tableau d'import (les inputs de résolution) pour que subnet-copy.js les reconnaisse. Alternativement, généraliser les sélecteurs. Choisir : ajouter `class="copy-row"` sur les `<tr>` de données et `ip-field` sur les inputs `new_ip` de l'import.

- [ ] **Step 2: Harmoniser les classes du tableau d'import**

Dans le rendu du tableau d'aperçu de `subnet_import.php` :
- `<tr ...>` de chaque ligne → ajouter `class="copy-row status-<?= h($row['status']) ?>"`.
- Les inputs `name="r[<i>][new_ip]"` → ajouter la classe `ip-field`.
- Ajouter les hidden `[status]` et `[ip]` si absents pour que `collectPendingRows` lise l'IP courante. (L'import a déjà `[status]` en hidden ligne 681 ; pour les lignes `clean`, exposer l'IP via un hidden `[ip]` = l'IP transposée/CSV afin que le preview la compte.)

- [ ] **Step 3: Test manuel import mono**

Import CSV mono-subnet (`subnet_import.php?subnet_id=X`) : à l'aperçu, le bargraph du cible apparaît et se remplit en live selon les lignes cochées/éditées. Import **global** (sans subnet_id) : **pas** de bargraph.

- [ ] **Step 4: Commit**

```bash
git add public/subnet_import.php
git commit -m "feat(import): bargraph du subnet cible en mode mono-subnet (live)"
bash ~/sync.sh dhcpman
```

---

## Task 12: Documentation CLAUDE.md + version

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 1: Documenter la fonctionnalité**

Dans `CLAUDE.md`, ajouter une section « Copie de réservations entre subnets (v0.18) » décrivant :
- le point de départ (barre de sélection, bouton « Copier vers… »)
- la règle de réécriture d'IP (conserver l'hôte) + gateway/routes internes remappées
- la classe partagée `lib/ReservationImportAnalyzer` (import + copie) et le statut `reserved`
- `lib/SubnetRemap`, `lib/sbg_helpers` (`buildBargraphSubnet`)
- le bargraph live (type `pending`, `onHoleClick`) sur copie + import mono
- les 3 étapes de `subnet_copy.php`, garde-fous serveur, audit `reservation.copy`

Mettre à jour la liste des fichiers `public/` et `lib/` en tête de CLAUDE.md avec les nouveaux fichiers. Ajouter la ligne de migration : « v0.17 → v0.18 : aucune migration SQL requise ».

- [ ] **Step 2: Commit**

```bash
git add CLAUDE.md
git commit -m "docs(copie-resa): documentation v0.18 (copie de réservations entre subnets)"
bash ~/sync.sh dhcpman
```

---

## Task 13: Revue finale + mémoire

- [ ] **Step 1: Revue de code complète**

Relire le diff de la branche. Vérifier :
- Aucun `innerHTML` avec de la description/hostname utilisateur (piège XSS reserved_ips — commit c262d56). Descriptions rendues via `h()` (PHP) et textContent/valeurs d'input (JS).
- Appartenance host_id vérifiée aux étapes 0 et 2 de subnet_copy.php.
- Un seul `SyncService::sync()` par application.
- Non-régression import : `php -l` sur tous les fichiers modifiés, test manuel import mono & global.

- [ ] **Step 2: Écrire la mémoire de session**

Créer `memory/copie_reservations.md` (type project) : résumé de la fonctionnalité, classes créées (`SubnetRemap`, `ReservationImportAnalyzer`, `sbg_helpers`), le statut `reserved`/`overflow`, le bargraph live. Lier `[[reserved_ips]]`, `[[bargraph_component]]`. Ajouter la ligne d'index dans `memory/MEMORY.md`.

- [ ] **Step 3: Commit final (mémoire non versionnée mais synced)**

```bash
git add CLAUDE.md docs/
git commit -m "docs(copie-resa): finalisation v0.18" --allow-empty
bash ~/sync.sh dhcpman
```

---

## Notes d'exécution

- **Ordre des étapes dans subnet_copy.php** : le fichier teste `$step` et traite 2 → 1 → 0 (les `exit` après chaque bloc évitent de retomber sur l'étape 0). Écrire les blocs dans cet ordre.
- **Parité JS/PHP de la forme courte des ranges** : subnet-copy.js réutilise `parseReserved` du bargraph (déjà éprouvé), pas de nouvelle implémentation.
- **Tests DB** : `mysql -h 192.168.1.2 -u claude -pclaudedev123 kea`. Kea API à 192.168.1.2:8000.
- **Rappel sync** : `bash ~/sync.sh dhcpman` après **chaque** commit.
