# Copie de réservations entre subnets — Design

**Date :** 2026-07-16
**Statut :** validé (brainstorming)
**Version cible :** v0.18

## Problème

Aujourd'hui, pour reproduire des réservations d'un subnet vers un autre, l'utilisateur
fait manuellement : export CSV d'une sélection → édition des IP pour coller au nouveau
réseau → import CSV dans le subnet cible. Fastidieux et source d'erreurs (transposition
d'IP à la main, gateway/routes oubliées).

**Objectif :** automatiser ce parcours. Depuis la sélection multiple d'un subnet source,
proposer un tableau pré-rempli où les IP (et gateway/routes) sont automatiquement
transposées sur le subnet cible, avec la même validation de conflits qu'un import, puis
créer les réservations dans le cible.

## Décisions de conception

| Sujet | Décision |
|-------|----------|
| **Point de départ** | Barre de sélection de `subnet.php` (source), bouton « Copier vers… » à côté d'Exporter/Supprimer. admin\|tech uniquement. |
| **Réécriture IP** | Conserver l'hôte, changer le réseau : `192.168.2.50` (src /24) → `192.168.10.50` (dst /24). |
| **Options par hôte** | Gateway spécifique et gw de route transposées **si** dans le réseau source ; sinon inchangées. `no_gateway`, `short_lease`, hostname, description, group_name copiés tels quels. Le `dest` d'une route (CIDR) n'est jamais touché. |
| **Copie vs déplacement** | Copie pure : les originales du source sont conservées. Non destructif. |
| **Conflits** | Même logique que `subnet_import.php` : `clean` / `skip` / `ip_conflict` / `mac_conflict` / `invalid` / `pool` + nouveaux `reserved` et `overflow`. |
| **Architecture** | Extraction de `analyzeRows()` + helpers dans une classe partagée `lib/ReservationImportAnalyzer`, consommée par l'import CSV **et** la copie. |
| **IP réservées hors DHCP** | Statut `reserved` ajouté à l'analyzer (corrige un bug préexistant de l'import : une IP réservée passait en `clean`). Éditable (ressaisir une IP), comme `ip_conflict`, dans import **et** copie. |
| **Débordement de masque** | src /23 → dst /24 : un hôte qui déborde le masque cible devient statut `overflow` (propre à la copie), éditable, champ **vide** à saisir. |
| **Bargraph cible** | Affiché sur la page copie et sur l'import **mono-subnet** (pas multi-subnet). Interactif et **live**. |

## Architecture

Trois nouvelles classes pures/testables + une nouvelle page + un refactor ciblé de
l'import existant. Aucune table ni migration SQL.

```
lib/ReservationImportAnalyzer.php   (NOUVEAU — extrait de subnet_import.php)
    analyzeRows(), normalizeMac(), inPool(), statuts + statut 'reserved'
        ↑                              ↑
public/subnet_import.php          public/subnet_copy.php   (NOUVEAU)
    (CSV → rawRows)                   (getBySubnet + remap → rawRows)
                                          ↓
                                   lib/SubnetRemap.php   (NOUVEAU — réécriture IP)

lib/sbg_helpers.php   (NOUVEAU — construction de l'objet $sbgSubnet, extrait de subnet.php)
        ↑              ↑                    ↑
   subnet.php    subnet_copy.php    subnet_import.php (mono)

public/assets/js/subnet-bargraph.js   (MODIFIÉ — type 'pending', onHoleClick, live)
```

## Structure des fichiers

### `lib/ReservationImportAnalyzer.php` (nouveau)

Extraction depuis `subnet_import.php` — **classe pure, testable hors HTTP**.

```php
class ReservationImportAnalyzer {
    // Statuts possibles d'une ligne analysée
    const STATUSES = ['clean','skip','ip_conflict','mac_conflict','invalid','pool','reserved'];

    public static function normalizeMac(string $raw): string;   // → 'bc:dd:c2:47:3d:b6'
    public static function inPool(string $ip, array $subnet): bool;
    public static function analyzeRows(
        array  $rawRows,
        ?array $defaultSubnet,
        array  $subnetsByName,
        bool   $hasSubnetCol
    ): array;
}
```

`analyzeRows()` : logique **identique** à l'actuelle (résolution subnet, format MAC/IP,
hostname, CIDR, pool, doublons MAC/IP) **plus** une vérification `reserved` :

- Après la vérif `pool` (et seulement si `$ip !== ''`), si le subnet porte des
  `reserved_ips` non vides, calculer `$networkInt = ReservedIps::networkIntFromCidr($subnet['cidr'])`
  puis `$hit = ReservedIps::contains($subnet['reserved_ips'], $networkInt, $ip)`.
- Si `$hit !== null` → statut `reserved`, note `'IP réservée (hors DHCP) : ' . $hit['desc']`
  (desc peut être vide → note sans desc). Ligne **éditable** (mêmes champs de sortie
  `ip_taken_by`/`mac_host_id`/`mac_existing_ip` que `ip_conflict`, tous `null` ici).

Priorité : `invalid` (format/CIDR) > `pool` > `reserved` > conflits MAC/IP > `clean`.
(Le pool l'emporte sur reserved : une IP simultanément en pool et réservée est `pool`.)

### `lib/SubnetRemap.php` (nouveau)

Réécriture d'IP « conserver l'hôte, changer le réseau ». **Pure, testable.**

```php
class SubnetRemap {
    /**
     * Transpose l'hôte de $ip du réseau source vers le réseau cible.
     * @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;
}
```

Algorithme :
```
srcMask = 0xFFFFFFFF << (32 - srcPrefix)        (0 si prefix 0)
host    = ip2long_u($ip) & ~srcMask & 0xFFFFFFFF
dstMask = 0xFFFFFFFF << (32 - dstPrefix)
si host > (~dstMask & 0xFFFFFFFF)  →  return null   (déborde le masque cible)
return long2ip($dstNet | host)
```
Toute l'arithmétique en entiers non signés 32 bits (`sprintf('%u', ...)` côté PHP).

### `lib/sbg_helpers.php` (nouveau)

Extraction de la construction de `$sbgSubnet` depuis `subnet.php` (lignes ~15-37) pour
éviter une 3ᵉ copie de l'arithmétique réseau.

```php
/**
 * Construit l'objet passé à SubnetBargraph.init (bornes, pools, reservedIps, total, addUrl).
 * @param array $subnet  ligne app_subnets (id, cidr, range_*, short_lease_pool_*, reserved_ips)
 */
function buildBargraphSubnet(array $subnet): array;
```

`subnet.php`, `subnet_copy.php` et `subnet_import.php` (mono) l'appellent.

### `public/subnet_copy.php` (nouveau)

Page en 3 étapes (calquée sur `subnet_import.php`). `Auth::requireRole('admin','tech')`.

**Étape 0 — choix de la cible** (POST depuis la barre de sélection de `subnet.php`) :
- Entrée : `subnet_id` (source, app id) + `host_ids[]` (cochés).
- **Garde-fou** : chaque `host_id` doit appartenir au subnet source
  (`(int)$r['dhcp4_subnet_id'] === (int)$sourceSubnet['kea_subnet_id']`) — anti-injection,
  comme `reservation_bulk.php`.
- Affiche : récap (N réservations, subnet source), dropdown des subnets cibles
  (`Subnet::getAll()` sauf la source), bouton « Prévisualiser ».

**Étape 1 — prévisualisation** (POST cible choisie) :
- Cible via `Subnet::getById($dstId)`. Rejet si inexistant, inactif, ou == source.
- Charger les réservations source : `Reservation::getBySubnet($srcKeaId)` filtré sur les
  `host_ids` cochés.
- Pour chaque : construire une rawRow en **remappant** `ip`, `gateway`, et la `gw` de
  chaque route via `SubnetRemap::remapIp` (gw remappée seulement si dans le réseau source ;
  sinon conservée). Un `remapIp` de l'IP réservation qui renvoie `null` → marquer la ligne
  `overflow` (hors analyzer). rawRow champs : `mac_raw, ip, hostname, description,
  group_name, gateway, routes_raw, short_lease, no_gateway, line` (+ marqueur `overflow`).
- `ReservationImportAnalyzer::analyzeRows($rawRows, $dstSubnet, [], false)` (mono, pas de
  colonne subnet). Les lignes `overflow` court-circuitent l'analyse (statut posé avant).
- Rendu : bandeau de comptes, **bargraph du cible** (`buildBargraphSubnet($dstSubnet)`,
  interactif live), tableau avec colonne « IP source → cible », résumé d'options,
  résolution inline. `hidden` : `subnet_id` source, `dst_subnet_id`, `host_ids`.

**Étape 2 — application** (POST des lignes + résolutions) :
- Re-résoudre source & cible ; re-valider l'appartenance des host_id source.
- Boucle par ligne cochée — **création dans la quasi-totalité des cas** (seul
  `mac_conflict`+`update` écrit sur un hôte cible existant, cf. note) :
  - `skip` / `pool` / non cochée / `keep` → ignorée.
  - IP finale = transposée (`clean`) | ressaisie (`ip_conflict`/`reserved`/`overflow`) |
    l'IP courante (`mac_conflict`+`update`) | ressaisie (`mac_conflict`+`custom_ip`).
  - **Garde-fous serveur** (le client ne fait pas foi) : `isValidIp` + `ipInCidr($dstCidr)`
    + `!inPool($dstSubnet)` + `ReservedIps::contains($dstReserved,...) === null` + pas de
    doublon (base cible ni `$batchIps[$dstKeaId]`). Échec → ligne sautée + message.
  - `Reservation::add($dstKeaId, $data)` avec `$data` remappé (mac, ip, hostname,
    description, group_name, gateway, routes `[{dest,gw}]`, short_lease, no_gateway).
- **Un seul** `SyncService::sync()` après la boucle.
- `AuditLog::log('reservation.copy', 'subnet', $dstAppId, "N copiée(s) de « src » vers « dst » (M ignorées)")`.
- Flash : succès vert, ou warning orange si `sync()['success']` faux (pas de rollback SQL).

> **Note `mac_conflict` en copie** : la MAC copiée existe déjà **dans le cible**. Comme la
> copie ne fait que des créations, `update` réécrirait la réservation cible existante
> (`Reservation::update($existingHostId, ...)`). On conserve ce comportement (identique à
> l'import) mais l'action par défaut proposée est `keep` (ne rien écraser), l'utilisateur
> choisit explicitement `update` ou `custom_ip`.

### `public/subnet_import.php` (modifié — refactor)

- Supprime `normalizeMac()`, `inPool()`, `analyzeRows()` (déplacés dans la classe).
- `require_once __DIR__ . '/../lib/ReservationImportAnalyzer.php'` et appelle
  `ReservationImportAnalyzer::analyzeRows(...)`, `::normalizeMac(...)`, `::inPool(...)`.
- **Comportement inchangé sauf** : le nouveau statut `reserved` apparaît (corrige le bug
  silencieux) et est traité comme éditable (champ IP à ressaisir) dans l'aperçu ET dans la
  boucle d'application ; garde-fou d'application ajoute la vérif `ReservedIps::contains`.
- **Bargraph** ajouté à l'étape 1 en mode **mono-subnet uniquement**
  (`if ($subnet && !$isGlobalMode)`), via `buildBargraphSubnet($subnet)`.

### `public/subnet.php` (modifié)

- Barre de sélection : bouton **« Copier vers… »** (admin\|tech), POST des `host_ids` +
  `subnet_id` vers `subnet_copy.php` (étape 0).
- Bargraph : le clic-sur-trou passe désormais par le nouveau callback `onHoleClick` (voir
  ci-dessous) ; comportement inchangé (navigue vers `addUrl`).

### `public/assets/js/subnet-bargraph.js` (modifié)

**Type `pending`** (5ᵉ type) : bleu clair pointillé, infobulle « à créer », non cliquable.
`computeBlocks` accepte des rows portant `_pending: true` → bloc de type `pending`.
Taux d'occupation : « X occupées + Y à créer ».

**`onHoleClick` callback** : `init({..., onHoleClick})`. `bindSegment` remplace le
`window.location` en dur du clic-trou par `onHoleClick(firstFreeIp)` (si fourni).
- `subnet.php` : `onHoleClick = ip => window.location.href = addUrl + '&ip=' + ip`.
- copie / import : `onHoleClick = ip => fillActiveIpField(ip)`.

**Clic bloc réservation** : `onFilter` inchangé (fourni par subnet.php ; absent en copie/import → pas de clic).

### JS de la page copie/import (`subnet_copy.php` inline ou asset dédié)

**Recalcul live du bargraph** :
- `collectPendingRows()` : parcourt le tableau, retourne les lignes **cochées et
  créables** :
  - `clean` → IP transposée.
  - `ip_conflict`/`reserved`/`overflow` → seulement si le champ IP ressaisi est **valide**
    (format + CIDR cible + hors pool + non réservé + non déjà pris dans le lot). Validation
    JS **optimiste** pour l'affichage du bargraph (pool/reserved connus côté client via
    `SUBNET.dyn*Int`, `SUBNET.short*Int`, `SUBNET.reservedIps` — mêmes règles que
    `parseReserved`) ; la barrière qui fait foi reste le garde-fou serveur de l'étape 2.
  - `mac_conflict` `update`/`custom_ip` → l'IP retenue.
  - décochée / `skip` / `pool` / `keep` / IP invalide → exclue.
- Ré-appelle `SubnetBargraph.init` (ou une méthode de re-render) avec
  `rows = existantesCible.concat(pendingRows.map(marquer _pending))`.
- Déclencheurs : `change` cases, `input`/`blur` champs IP (debounce ~150 ms), `change`
  selects d'action MAC.

**Clic-trou → champ IP actif** (règle resserrée) :
- Sur `mousedown` d'un segment trou : lire `document.activeElement`. Si c'est un
  `input.ip-field` du tableau → mémoriser comme **cible pour ce clic seulement**. Sinon
  cible = `null`.
- Sur `click` : cible ? → écrire l'IP + classe `.ip-filled-flash` (flash vert ~1 s,
  retirée après transition) + revalider (→ `.is-valid`/`.is-invalid`) + recalcul live ;
  cible `null` → hint bref « clique d'abord dans un champ IP » près du curseur.
- **Aucun état persistant** : la cible est jetée après le clic (pas de « dernier focus »).

## Réutilisation de la logique existante

| Existant | Réutilisé pour |
|----------|----------------|
| `analyzeRows()` + statuts | extrait → analyzer partagé, +`reserved` |
| `Reservation::getBySubnet()` (gateway/routes/short_lease/group inclus) | source des rawRows de copie |
| `Reservation::add()` | création dans le cible |
| `ReservedIps::contains/networkIntFromCidr` | statut `reserved` + garde-fou |
| `SubnetBargraph.init` | bargraph cible (+ type pending, onHoleClick, live) |
| `$sbgSubnet` (subnet.php) | extrait → `buildBargraphSubnet()` |
| Garde-fou appartenance host_id (reservation_bulk/mass_delete) | étape 0 & 2 de la copie |
| `SyncService::sync()`, `AuditLog::log()`, flash success/warning | application |

## Tests

**Unitaires (harness PHP en scratchpad) :**
- `SubnetRemap::remapIp` : /24→/24, /24→/23, /23→/24 (débordement → null), /24→/25,
  gateway interne (remappée) vs externe (conservée), prefix 0, /32.
- `ReservationImportAnalyzer::analyzeRows` : chaque statut, dont `reserved` ; priorité
  pool > reserved ; IP vide short-lease ; multi-subnet inchangé.

**JS (navigateur) :**
- `computeBlocks` avec rows `_pending` → blocs pending, taux « occupées + à créer ».
- `collectPendingRows` sur un tableau simulé (coché/décoché, IP valide/invalide, actions MAC).

**E2E (Kea/DB réels) :**
- Copie /24→/24 avec conflits mélangés (clean, ip_conflict résolu, reserved, pool ignoré,
  overflow /23→/24) → vérifier `hosts`, options (gateway/routes remappées), audit, sync.
- Non-régression import CSV (mono & global) après extraction de l'analyzer.

## Sécurité

- `Auth::requireRole('admin','tech')` en tête de `subnet_copy.php`.
- Appartenance de chaque `host_id` source au subnet source (étapes 0 & 2).
- Subnet cible : existe, actif, ≠ source.
- Garde-fous d'application re-vérifiés serveur (CIDR/pool/reserved/doublons) — le client ne
  fait pas foi.
- Descriptions/hostnames rendus via `h()`/textContent (jamais innerHTML) — cf. piège XSS
  reserved_ips (commit c262d56).

## Hors périmètre (YAGNI)

- Déplacement (suppression du source) : l'utilisateur supprime via la sélection existante.
- Copie multi-cible simultanée : une cible à la fois.
- Copie inter-instances / cross-serveur.
- Preview projeté sur l'import multi-subnet (pas de bargraph en mode global).
