# Libération de leases DHCP — Implementation Plan

> **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 libérer un lease DHCP actif (via l'API Kea `lease4-del`), depuis un bouton sur la page Leases et via un warning à 3 choix lors de la création/édition d'une réservation impactant un lease actif.

**Architecture:** Brique de service partagée (`LeaseService`) exposant `releaseLease`, `findActiveLeases`, `orphanLeasesForReservation`. Un handler POST-only (`lease_release.php`) pour la page Leases. Un flux de confirmation intra-page (marqueur POST `lease_action`) dans `reservation_add/edit.php` qui n'écrit rien avant décision. Tout passe par l'API kea-ctrl-agent, aucune écriture SQL sur les leases.

**Tech Stack:** PHP vanilla + PDO, API Kea Control Agent (JSON/HTTP via `httpPost` existant), Bootstrap 5, JS ES5 vanilla. Tests : PHP en isolation (mock `httpPost`) + Node pour la logique pure.

**Spec:** `docs/superpowers/specs/2026-07-15-lease-release-design.md`

---

## Contexte codebase (à lire avant de commencer)

- **`lib/LeaseService.php`** : contient déjà `getLeases()` (appel `lease4-get-all`) et
  `httpPost()` (curl + fallback stream, gère l'auth Basic). Réutiliser ces briques.
  - `getLeases()` retourne `['success'=>bool, 'leases'=>array, 'message'=>string]`.
  - Un lease Kea a les champs : `ip-address`, `hw-address` (MAC `bc:dd:...`), `hostname`,
    `subnet-id`, `cltt` (int), `valid-lft` (int), `state` (0=actif, 1=refusé, 2=expiré).
- **`Settings::get($key, $default)`** : lecture config (`kea_api_url`, `kea_api_user`, `kea_api_password`).
- **`AuditLog::log(string $action, ?string $objectType=null, ?int $objectId=null, ?string $details=null)`**.
- **Handler POST-only modèle** : `public/user_toggle.php` (requireLogin + requireRole + guard méthode + flash + redirect).
- **`public/reservation_edit.php`** : le bloc d'écriture est `if (!$errors) { Reservation::update(...); SyncService::sync(); AuditLog::log(...); redirect; }` (lignes ~71-91). Le HTML du formulaire commence ligne ~118. `$reservation['ip_address']` (chaîne, vide si 0) donne l'IP actuelle = `IP_old`.
- **`public/reservation_add.php`** : structure analogue (bloc `if (!$errors)` avec `Reservation::add(...)`), pas d'`IP_old`.
- **`public/leases.php`** : rendu 100% JS. `ALL_LEASES` injecté via `json_encode`. Fonction `rowHtml(l)` génère le `<tr>` d'un lease ; le bouton d'action réservation y est déjà (`resBtn`). Chaque lease JS a : `ip`, `mac`, `hostname`, `subnet_id`, `subnet_app_id`, `state`, `expiry`, `host_id`, `group_name`. Pas encore de gating de rôle côté JS.
- **Flash** : `$_SESSION['flash'] = ['type'=>'success|warning|danger', 'message'=>'...']`.
- **Sync obligatoire** : commit puis `bash ~/sync.sh dhcpman` (voir CLAUDE.md). Dans ce plan, les `git commit` sont indiqués ; lancer le sync après chaque commit.
- **Piège `grep` sur `public/leases.php`** : le fichier contient des caractères UTF-8 (`▾ ▸ é`)
  qui font que `file` le détecte comme `data` → `grep` sans `-a` renvoie 0 résultat (le traite
  comme binaire). **Toujours utiliser `grep -a`** sur ce fichier. Points d'ancrage Task 5 vérifiés :
  `const ALL_LEASES` (~l.216), `function rowHtml(l)` (~l.339), `'<td class="pe-2">' + resBtn`
  (~l.365), `getElementById('lease-tbody').addEventListener` (~l.461) suivi de
  `closest('[data-collapse]')` (~l.462).

---

## Task 1 : `LeaseService::releaseLease($ip)`

**Files:**
- Modify: `lib/LeaseService.php` (ajouter la méthode après `getLeases`)
- Test: `scratchpad/test_release_lease.php` (script PHP autonome, mock de `httpPost`)

- [ ] **Step 1: Écrire le test qui échoue**

Créer `scratchpad/test_release_lease.php`. Comme `httpPost` est privé, on teste via une
sous-classe qui l'override pour simuler les réponses Kea. Le test vérifie le mapping
`result` → retour.

```php
<?php
// Test de LeaseService::releaseLease — php scratchpad/test_release_lease.php
// Stubs minimaux pour charger LeaseService sans DB.
class Settings {
    public static $vals = ['kea_api_url' => 'http://localhost:8000', 'kea_api_user' => '', 'kea_api_password' => ''];
    public static function get($k, $d = null) { return self::$vals[$k] ?? $d; }
}
require __DIR__ . '/../lib/LeaseService.php';

// Sous-classe qui simule la réponse HTTP de Kea.
class FakeLeaseService extends LeaseService {
    public static $fakeResponse = null;   // string JSON, ou false pour "injoignable"
    protected static function httpPost(string $url, string $body, array $headers = []): string|false {
        self::$lastBody = $body;
        return self::$fakeResponse;
    }
    public static $lastBody = null;
}

$pass = 0; $fail = 0;
function check($label, $cond) {
    global $pass, $fail;
    if ($cond) { $pass++; echo "  OK   $label\n"; }
    else { $fail++; echo "  FAIL $label\n"; }
}

// 1. result 0 → success + existed
FakeLeaseService::$fakeResponse = json_encode([['result' => 0, 'text' => 'IPv4 lease deleted.']]);
$r = FakeLeaseService::releaseLease('192.168.1.50');
check('result 0 → success', $r['success'] === true);
check('result 0 → existed', $r['existed'] === true);
check('payload contient lease4-del', strpos(FakeLeaseService::$lastBody, 'lease4-del') !== false);
check('payload contient l\'IP', strpos(FakeLeaseService::$lastBody, '192.168.1.50') !== false);

// 2. result 3 → success mais existed=false (déjà parti)
FakeLeaseService::$fakeResponse = json_encode([['result' => 3, 'text' => 'IPv4 lease not found.']]);
$r = FakeLeaseService::releaseLease('192.168.1.50');
check('result 3 → success', $r['success'] === true);
check('result 3 → existed=false', $r['existed'] === false);

// 3. result 1 (erreur) → échec
FakeLeaseService::$fakeResponse = json_encode([['result' => 1, 'text' => 'boom']]);
$r = FakeLeaseService::releaseLease('192.168.1.50');
check('result 1 → échec', $r['success'] === false);
check('result 1 → message repris', strpos($r['message'], 'boom') !== false);

// 4. API injoignable → échec
FakeLeaseService::$fakeResponse = false;
$r = FakeLeaseService::releaseLease('192.168.1.50');
check('injoignable → échec', $r['success'] === false);

echo "\n$pass OK, $fail FAIL\n";
exit($fail === 0 ? 0 : 1);
```

- [ ] **Step 2: Lancer le test, vérifier l'échec**

Run: `php scratchpad/test_release_lease.php`
Expected: FAIL — `releaseLease` n'existe pas (ou erreur car `httpPost` est `private`, pas `protected`).

- [ ] **Step 3: Rendre `httpPost` `protected` et ajouter `releaseLease`**

Dans `lib/LeaseService.php` : changer la visibilité de `httpPost` de `private` à `protected`
(pour permettre l'override en test). Puis ajouter après `getLeases()` :

```php
    /**
     * Libère (supprime) un lease IPv4 via l'API Kea (lease4-del).
     * Retour ['success'=>bool, 'existed'=>bool, 'message'=>string] :
     *   result 0 → supprimé ; result 3 → déjà absent (pas une erreur) ; autre → échec.
     */
    public static function releaseLease(string $ip): array {
        $url = rtrim(Settings::get('kea_api_url', 'http://localhost:8000'), '/') . '/';
        if ($url === '/') {
            return ['success' => false, 'existed' => false, 'message' => 'URL Kea Control Agent non configurée.'];
        }
        $payload = json_encode([
            'command'   => 'lease4-del',
            'service'   => ['dhcp4'],
            'arguments' => ['ip-address' => $ip],
        ]);
        $headers = ['Content-Type: application/json'];
        $apiUser = Settings::get('kea_api_user', '');
        if ($apiUser !== '') {
            $headers[] = 'Authorization: Basic ' . base64_encode($apiUser . ':' . Settings::get('kea_api_password', ''));
        }

        $result = static::httpPost($url, $payload, $headers);
        if ($result === false) {
            return ['success' => false, 'existed' => false, 'message' => 'Impossible de joindre l\'API Kea : ' . $url];
        }
        $data = json_decode($result, true);
        if (!is_array($data) || !isset($data[0])) {
            return ['success' => false, 'existed' => false, 'message' => 'Réponse invalide de l\'API Kea.'];
        }
        $res = $data[0]['result'] ?? -1;
        if ($res === 0) {
            return ['success' => true, 'existed' => true, 'message' => 'Lease libéré.'];
        }
        if ($res === 3) {
            return ['success' => true, 'existed' => false, 'message' => 'Lease déjà absent (expiré).'];
        }
        return ['success' => false, 'existed' => false, 'message' => $data[0]['text'] ?? 'Erreur inconnue Kea API'];
    }
```

Note : `getLeases()` appelle `self::httpPost` — le laisser tel quel (fonctionne). Seule la
visibilité change (`private` → `protected`). Le mot-clé `static::` dans `releaseLease` permet
l'override en test.

- [ ] **Step 4: Lancer le test, vérifier le succès**

Run: `php scratchpad/test_release_lease.php`
Expected: PASS — `9 OK, 0 FAIL`

- [ ] **Step 5: Lint**

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

- [ ] **Step 6: Commit**

```bash
git add lib/LeaseService.php
git commit -m "feat(leases): LeaseService::releaseLease (lease4-del via API Kea)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 2 : `LeaseService::findActiveLeases($ips)`

**Files:**
- Modify: `lib/LeaseService.php` (ajouter après `releaseLease`)
- Test: `scratchpad/test_find_active_leases.php`

- [ ] **Step 1: Écrire le test qui échoue**

Créer `scratchpad/test_find_active_leases.php`. On override `getLeases()` pour fournir un jeu
de leases fictif, puis on vérifie le filtrage (par IP, état actif uniquement).

```php
<?php
class Settings { public static function get($k, $d = null) { return $d; } }
require __DIR__ . '/../lib/LeaseService.php';

class FakeLeaseService extends LeaseService {
    public static $leases = [];
    public static function getLeases(): array {
        return ['success' => true, 'leases' => self::$leases, 'message' => ''];
    }
}

$pass = 0; $fail = 0;
function check($l, $c) { global $pass,$fail; if ($c){$pass++;echo "  OK   $l\n";} else {$fail++;echo "  FAIL $l\n";} }

FakeLeaseService::$leases = [
    ['ip-address'=>'192.168.1.50', 'hw-address'=>'aa:aa:aa:aa:aa:aa', 'hostname'=>'old', 'state'=>0, 'cltt'=>1000, 'valid-lft'=>3600],
    ['ip-address'=>'192.168.1.51', 'hw-address'=>'bb:bb:bb:bb:bb:bb', 'hostname'=>'x',   'state'=>2, 'cltt'=>1000, 'valid-lft'=>3600], // expiré
    ['ip-address'=>'192.168.1.52', 'hw-address'=>'cc:cc:cc:cc:cc:cc', 'hostname'=>'y',   'state'=>0, 'cltt'=>1000, 'valid-lft'=>3600],
];

// Cherche .50 et .52 → 2 trouvés ; .51 (expiré) exclu même si demandé
$r = FakeLeaseService::findActiveLeases(['192.168.1.50', '192.168.1.52', '192.168.1.51']);
check('.50 trouvé', isset($r['192.168.1.50']));
check('.52 trouvé', isset($r['192.168.1.52']));
check('.51 expiré exclu', !isset($r['192.168.1.51']));
check('.50 porte la bonne MAC', $r['192.168.1.50']['mac'] === 'aa:aa:aa:aa:aa:aa');
check('.50 a un expiry', $r['192.168.1.50']['expiry'] === 4600);

// IP absente / vide → rien
$r2 = FakeLeaseService::findActiveLeases(['192.168.1.99']);
check('IP absente → vide', count($r2) === 0);
$r3 = FakeLeaseService::findActiveLeases(['', null]);
check('IP vide/null → vide', count($r3) === 0);

echo "\n$pass OK, $fail FAIL\n";
exit($fail === 0 ? 0 : 1);
```

- [ ] **Step 2: Lancer le test, vérifier l'échec**

Run: `php scratchpad/test_find_active_leases.php`
Expected: FAIL — `findActiveLeases` n'existe pas.

- [ ] **Step 3: Implémenter `findActiveLeases`**

Ajouter dans `lib/LeaseService.php` après `releaseLease` :

```php
    /**
     * Parmi les IP données, retourne les leases ACTIFS (state=0) trouvés.
     * Un seul appel getLeases(), filtrage côté PHP.
     * Retour : [ ip => ['mac'=>string, 'hostname'=>string, 'expiry'=>int], ... ]
     * Si l'API est injoignable, retourne [] (best-effort — ne bloque jamais l'appelant).
     */
    public static function findActiveLeases(array $ips): array {
        $wanted = [];
        foreach ($ips as $ip) {
            $ip = trim((string)$ip);
            if ($ip !== '') { $wanted[$ip] = true; }
        }
        if (!$wanted) { return []; }

        $all = static::getLeases();
        if (empty($all['success'])) { return []; }

        $out = [];
        foreach ($all['leases'] as $l) {
            $ip = $l['ip-address'] ?? '';
            if (!isset($wanted[$ip])) { continue; }
            if ((int)($l['state'] ?? 0) !== 0) { continue; } // actifs uniquement
            $out[$ip] = [
                'mac'      => $l['hw-address'] ?? '',
                'hostname' => $l['hostname']   ?? '',
                'expiry'   => (int)($l['cltt'] ?? 0) + (int)($l['valid-lft'] ?? 0),
            ];
        }
        return $out;
    }
```

- [ ] **Step 4: Lancer le test, vérifier le succès**

Run: `php scratchpad/test_find_active_leases.php`
Expected: PASS — `7 OK, 0 FAIL`

- [ ] **Step 5: Lint + commit**

```bash
php -l lib/LeaseService.php
git add lib/LeaseService.php
git commit -m "feat(leases): LeaseService::findActiveLeases (détection leases actifs par IP)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 3 : `LeaseService::orphanLeasesForReservation(...)`

**Files:**
- Modify: `lib/LeaseService.php` (ajouter après `findActiveLeases`)
- Test: `scratchpad/test_orphan_leases.php`

Règles (spec) : soit `MAC_new / IP_new`, et `IP_old` optionnel (édition) :
- `IP_new` : lease actif tenu par une MAC **≠ `MAC_new`** → l'IP cible est occupée par un autre appareil.
- `IP_old` (si différent de `IP_new` et non vide) : tout lease actif dessus → bail fantôme.

- [ ] **Step 1: Écrire le test qui échoue**

Créer `scratchpad/test_orphan_leases.php`. On override `findActiveLeases` pour contrôler l'entrée.

```php
<?php
class Settings { public static function get($k, $d = null) { return $d; } }
require __DIR__ . '/../lib/LeaseService.php';

class FakeLeaseService extends LeaseService {
    public static $active = [];  // ip => ['mac'=>..,'hostname'=>..,'expiry'=>..]
    public static function findActiveLeases(array $ips): array {
        $out = [];
        foreach ($ips as $ip) {
            $ip = trim((string)$ip);
            if ($ip !== '' && isset(self::$active[$ip])) { $out[$ip] = self::$active[$ip]; }
        }
        return $out;
    }
}

$pass=0; $fail=0;
function check($l,$c){ global $pass,$fail; if($c){$pass++;echo "  OK   $l\n";}else{$fail++;echo "  FAIL $l\n";} }
function ipsIn($res){ return array_map(function($e){ return $e['ip']; }, $res); }

// Cas édition : MAC change A→B sur la même IP .50, lease actif .50 tenu par A
FakeLeaseService::$active = ['192.168.1.50' => ['mac'=>'aa:aa:aa:aa:aa:aa','hostname'=>'old','expiry'=>4600]];
$r = FakeLeaseService::orphanLeasesForReservation('bb:bb:bb:bb:bb:bb', '192.168.1.50', '192.168.1.50');
check('MAC changée, IP identique : lease A détecté', in_array('192.168.1.50', ipsIn($r)));

// Cas : IP identique, lease tenu par la MÊME MAC que la résa → pas orphelin
FakeLeaseService::$active = ['192.168.1.50' => ['mac'=>'bb:bb:bb:bb:bb:bb','hostname'=>'me','expiry'=>4600]];
$r = FakeLeaseService::orphanLeasesForReservation('bb:bb:bb:bb:bb:bb', '192.168.1.50', '192.168.1.50');
check('même MAC → aucun orphelin', count($r) === 0);

// Cas édition : IP change X→Y, bail fantôme sur l'ancienne X
FakeLeaseService::$active = ['192.168.1.50' => ['mac'=>'aa:aa:aa:aa:aa:aa','hostname'=>'ghost','expiry'=>4600]];
$r = FakeLeaseService::orphanLeasesForReservation('bb:bb:bb:bb:bb:bb', '192.168.1.60', '192.168.1.50');
check('IP changée : ancien lease X détecté', in_array('192.168.1.50', ipsIn($r)));

// Cas création (IP_old = null), IP cible libre → rien
FakeLeaseService::$active = [];
$r = FakeLeaseService::orphanLeasesForReservation('bb:bb:bb:bb:bb:bb', '192.168.1.60', null);
check('création IP libre → aucun orphelin', count($r) === 0);

// Cas création : IP cible occupée par une autre MAC (bail résiduel)
FakeLeaseService::$active = ['192.168.1.60' => ['mac'=>'cc:cc:cc:cc:cc:cc','hostname'=>'res','expiry'=>4600]];
$r = FakeLeaseService::orphanLeasesForReservation('bb:bb:bb:bb:bb:bb', '192.168.1.60', null);
check('création IP occupée autre MAC → détecté', in_array('192.168.1.60', ipsIn($r)));

// Chaque entrée porte ip + mac + hostname + expiry
check('entrée a ip/mac/hostname/expiry',
    isset($r[0]['ip'], $r[0]['mac'], $r[0]['hostname'], $r[0]['expiry']));

// IP_new vide (short-lease sans IP) → aucune détection, pas d'erreur
FakeLeaseService::$active = ['192.168.1.50'=>['mac'=>'aa:aa:aa:aa:aa:aa','hostname'=>'x','expiry'=>1]];
$r = FakeLeaseService::orphanLeasesForReservation('bb:bb:bb:bb:bb:bb', '', null);
check('IP_new vide → aucun orphelin', count($r) === 0);

// Comparaison MAC insensible à la casse
FakeLeaseService::$active = ['192.168.1.50' => ['mac'=>'AA:BB:CC:DD:EE:FF','hostname'=>'x','expiry'=>1]];
$r = FakeLeaseService::orphanLeasesForReservation('aa:bb:cc:dd:ee:ff', '192.168.1.50', '192.168.1.50');
check('MAC identique casse différente → aucun orphelin', count($r) === 0);

echo "\n$pass OK, $fail FAIL\n";
exit($fail === 0 ? 0 : 1);
```

- [ ] **Step 2: Lancer le test, vérifier l'échec**

Run: `php scratchpad/test_orphan_leases.php`
Expected: FAIL — `orphanLeasesForReservation` n'existe pas.

- [ ] **Step 3: Implémenter `orphanLeasesForReservation`**

Ajouter dans `lib/LeaseService.php` :

```php
    /**
     * Leases actifs « orphelins » après écriture d'une réservation MAC_new / IP_new.
     * $ipOld : IP actuelle de la résa (édition) ou null (création).
     * Règles :
     *   - IP_new : lease actif tenu par une MAC ≠ MAC_new (IP cible occupée par un autre).
     *   - IP_old (si ≠ IP_new et non vide) : tout lease actif (bail fantôme).
     * Retour : liste [ ['ip'=>string,'mac'=>string,'hostname'=>string,'expiry'=>int], ... ].
     */
    public static function orphanLeasesForReservation(string $macNew, string $ipNew, ?string $ipOld = null): array {
        $ipNew = trim($ipNew);
        $ipOld = $ipOld !== null ? trim($ipOld) : '';
        $macNew = strtolower(trim($macNew));

        $toCheck = [];
        if ($ipNew !== '') { $toCheck[] = $ipNew; }
        if ($ipOld !== '' && $ipOld !== $ipNew) { $toCheck[] = $ipOld; }
        if (!$toCheck) { return []; }

        $active = static::findActiveLeases($toCheck);
        $out = [];
        foreach ($active as $ip => $info) {
            if ($ip === $ipNew) {
                // Orphelin seulement si tenu par une autre MAC
                if (strtolower($info['mac']) === $macNew) { continue; }
            }
            // Pour IP_old : tout lease actif est orphelin (déjà filtré actif par findActiveLeases)
            $out[] = ['ip' => $ip, 'mac' => $info['mac'], 'hostname' => $info['hostname'], 'expiry' => $info['expiry']];
        }
        return $out;
    }
```

- [ ] **Step 4: Lancer le test, vérifier le succès**

Run: `php scratchpad/test_orphan_leases.php`
Expected: PASS — `9 OK, 0 FAIL`

- [ ] **Step 5: Lint + commit**

```bash
php -l lib/LeaseService.php
git add lib/LeaseService.php
git commit -m "feat(leases): LeaseService::orphanLeasesForReservation (règles MAC/IP)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 4 : Handler `lease_release.php` (page Leases)

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

Pas de test automatisé pur ici (handler HTTP dépendant de session/DB) : vérification manuelle
via lint + revue. Modèle : `public/user_toggle.php`.

- [ ] **Step 1: Créer le handler**

Créer `public/lease_release.php` :

```php
<?php

require_once __DIR__ . '/../lib/init.php';
Auth::requireLogin();
Auth::requireRole('admin', 'tech');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: leases.php');
    exit;
}

$ip  = trim($_POST['ip']  ?? '');
$mac = trim($_POST['mac'] ?? '');

if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
    $_SESSION['flash'] = ['type' => 'danger', 'message' => 'Adresse IP invalide.'];
    header('Location: leases.php');
    exit;
}

$res = LeaseService::releaseLease($ip);

if ($res['success']) {
    $msg = $res['existed']
        ? "Lease $ip libéré."
        : "Lease $ip déjà expiré (rien à libérer).";
    $_SESSION['flash'] = ['type' => 'success', 'message' => $msg];
    AuditLog::log('lease.release', 'lease', null,
        "IP $ip" . ($mac !== '' ? " (MAC $mac)" : '') . ' — depuis leases.php');
} else {
    $_SESSION['flash'] = ['type' => 'danger', 'message' => 'Lease non libéré : ' . $res['message']];
}

header('Location: leases.php');
exit;
```

- [ ] **Step 2: Lint**

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

- [ ] **Step 3: Commit**

```bash
git add public/lease_release.php
git commit -m "feat(leases): handler POST lease_release.php (admin|tech, audit)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 5 : Bouton « Libérer » sur la page Leases

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

Injecter un flag `CAN_EDIT` (PHP → JS) et ajouter le bouton dans `rowHtml`, avec confirmation JS
et POST caché.

- [ ] **Step 1: Injecter `CAN_EDIT` côté JS**

Dans `public/leases.php`, repérer la ligne `const ALL_LEASES = <?= json_encode(...) ?>;`
(dans le `<script>`). Ajouter juste après :

```php
const CAN_EDIT = <?= (Auth::currentUserRole() !== 'viewer') ? 'true' : 'false' ?>;
```

- [ ] **Step 2: Ajouter le formulaire POST caché (une fois) dans le HTML**

Juste avant la balise fermante `<?php endif; ?>` qui suit le tableau (après `<div class="card">…</div>`
contenant `#lease-table`), ajouter un formulaire caché réutilisable :

```html
<form id="release-form" method="post" action="lease_release.php" class="d-none">
    <input type="hidden" name="ip"  id="release-ip">
    <input type="hidden" name="mac" id="release-mac">
</form>
```

- [ ] **Step 3: Ajouter le bouton dans `rowHtml`**

Dans la fonction `rowHtml(l)`, repérer la construction de `resBtn` (bouton réservation). Après
elle, avant le `return '<tr...`, ajouter la génération d'un bouton « libérer » conditionnel :

```javascript
    var relBtn = '';
    if (CAN_EDIT && l.state === 0) {
        relBtn = '<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1 ms-1 lease-release-btn" '
               + 'data-ip="' + l.ip + '" data-mac="' + l.mac + '" '
               + 'title="Libérer ce lease (rend l\'IP allouable)"><i class="bi bi-x-circle"></i></button>';
    }
```

Puis, dans le `return`, modifier la dernière cellule d'action pour inclure `relBtn` :
remplacer `+ '<td class="pe-2">' + resBtn + '</td>'`
par `+ '<td class="pe-2 text-nowrap">' + resBtn + relBtn + '</td>'`.

- [ ] **Step 4: Brancher le clic (confirmation + submit)**

Dans la section événements (près du listener de délégation `lease-tbody` existant), ajouter la
gestion du clic sur `.lease-release-btn` — on peut l'intégrer dans le handler de délégation
existant sur `#lease-tbody`. Repérer le listener `document.getElementById('lease-tbody').addEventListener('click', ...)`
et, en tête de sa fonction, ajouter :

```javascript
    var relBtn = e.target.closest('.lease-release-btn');
    if (relBtn) {
        var ip  = relBtn.dataset.ip;
        var mac = relBtn.dataset.mac;
        if (confirm('Libérer le lease ' + ip + ' (MAC ' + mac + ') ?\nL\'IP redeviendra allouable.')) {
            document.getElementById('release-ip').value  = ip;
            document.getElementById('release-mac').value = mac;
            document.getElementById('release-form').submit();
        }
        return;
    }
```

(placer ce bloc AVANT le `var hdr = e.target.closest('[data-collapse]');` pour court-circuiter
le collapse si on a cliqué le bouton).

- [ ] **Step 5: Lint**

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

- [ ] **Step 6: Commit**

```bash
git add public/leases.php
git commit -m "feat(leases): bouton Libérer par lease actif (admin|tech, confirmation JS)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 6 : Warning « lease actif » à l'édition (`reservation_edit.php`)

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

Insérer le flux de confirmation dans le bloc `if (!$errors)`. Logique :
- Calculer `$orphans` via `LeaseService::orphanLeasesForReservation($data['mac'], $data['ip'], $ipOld)`.
  `$ipOld = $reservation['ip_address']` (l'IP AVANT modification).
- `$leaseAction = $_POST['lease_action'] ?? ''` (`''` = premier submit, `release`/`ignore` = décidé).
- Si `$orphans` non vide ET `$leaseAction === ''` → **ne pas écrire**, poser `$pendingLeases = $orphans`,
  laisser le formulaire se réafficher en mode confirmation.
- Sinon → écrire, puis si `$leaseAction === 'release'` libérer chaque IP, audit, sync, redirect.

- [ ] **Step 1: Capturer `IP_old` avant le POST**

En haut du fichier, après `$reservation = Reservation::getById($hostId);` (et le bloc subnet),
`$reservation['ip_address']` contient déjà l'IP actuelle. On l'utilisera dans le POST. Ajouter
près des initialisations (avant `if ($_SERVER['REQUEST_METHOD'] === 'POST')`) :

```php
$ipOld         = $reservation['ip_address']; // IP avant modification (pour détection lease orphelin)
$pendingLeases = [];                         // leases orphelins en attente de décision
```

- [ ] **Step 2: Remplacer le bloc d'écriture par le flux de confirmation**

Repérer le bloc (lignes ~71-91) :

```php
    if (!$errors) {
        Reservation::update($hostId, $data);
        $sync = SyncService::sync();
        AuditLog::log( ... );
        if ($sync['success']) { ... } else { ... }
        header('Location: subnet.php?id=' . $subnetId);
        exit;
    }
```

Le remplacer par :

```php
    if (!$errors) {
        $leaseAction   = $_POST['lease_action'] ?? '';
        $orphans       = LeaseService::orphanLeasesForReservation($data['mac'], $data['ip'], $ipOld);

        if ($orphans && $leaseAction === '') {
            // Premier submit avec lease(s) actif(s) : demander confirmation, ne rien écrire.
            $pendingLeases = $orphans;
        } else {
            Reservation::update($hostId, $data);

            $releaseWarning = '';
            if ($leaseAction === 'release') {
                // Re-détection au moment d'agir (le lease a pu bouger) : on libère l'IP nouvelle + l'ancienne.
                foreach (LeaseService::orphanLeasesForReservation($data['mac'], $data['ip'], $ipOld) as $lz) {
                    $rel = LeaseService::releaseLease($lz['ip']);
                    AuditLog::log('lease.release', 'lease', $hostId,
                        "IP {$lz['ip']} (MAC {$lz['mac']}) — depuis reservation_edit");
                    if (!$rel['success']) {
                        $releaseWarning = ' Lease ' . $lz['ip'] . ' non libéré (' . $rel['message'] . ').';
                    }
                }
            }

            $sync = SyncService::sync();
            AuditLog::log('reservation.edit', 'host', $hostId,
                sprintf('%s → %s (%s) sur subnet %s', $data['mac'], $data['ip'], $data['hostname'], $subnet['name']));

            if ($sync['success'] && $releaseWarning === '') {
                $_SESSION['flash'] = ['type' => 'success', 'message' => 'Réservation modifiée et Kea rechargé.'];
            } elseif (!$sync['success']) {
                $_SESSION['flash'] = ['type' => 'warning',
                    'message' => 'Réservation modifiée mais le reload Kea a échoué : ' . $sync['message'] . $releaseWarning];
            } else {
                $_SESSION['flash'] = ['type' => 'warning',
                    'message' => 'Réservation modifiée.' . $releaseWarning];
            }
            header('Location: subnet.php?id=' . $subnetId);
            exit;
        }
    }
```

- [ ] **Step 3: Ajouter l'encart de confirmation dans le HTML du formulaire**

Dans le HTML, juste après le bloc d'affichage des erreurs (`<?php foreach ($errors as $e): ?>…<?php endforeach; ?>`,
vers ligne ~116) et avant `<form method="post" …>`, insérer l'encart affiché seulement si
`$pendingLeases` est non vide :

```php
        <?php if ($pendingLeases): ?>
            <div class="alert alert-warning">
                <div class="fw-semibold mb-2">
                    <i class="bi bi-exclamation-triangle-fill me-1"></i>Lease(s) DHCP actif(s) détecté(s)
                </div>
                <p class="mb-2 small">Un ou plusieurs baux sont encore actifs sur ces adresses.
                   Les libérer rend l'IP immédiatement réutilisable.</p>
                <ul class="small mb-2">
                    <?php foreach ($pendingLeases as $lz): ?>
                        <li><code><?= h($lz['ip']) ?></code> — MAC <code><?= h($lz['mac']) ?></code>
                            <?php if ($lz['hostname'] !== ''): ?>(<?= h($lz['hostname']) ?>)<?php endif; ?></li>
                    <?php endforeach; ?>
                </ul>
            </div>
        <?php endif; ?>
```

- [ ] **Step 4: Ajouter les boutons de décision dans le formulaire**

Le `<form method="post" autocomplete="off">` contient déjà tous les champs (MAC, IP, etc.) et
un bouton Enregistrer. Il faut : (a) quand `$pendingLeases` est non vide, afficher 3 boutons au
lieu du bouton normal ; (b) transporter `lease_action` via le bouton cliqué.

Repérer le bouton submit existant du formulaire (chercher `type="submit"` dans le fichier).
L'entourer d'une condition. Remplacer la zone du bouton par :

```php
                <?php if ($pendingLeases): ?>
                    <div class="d-flex flex-wrap gap-2">
                        <button type="submit" name="lease_action" value="release" class="btn btn-danger">
                            <i class="bi bi-x-circle me-1"></i>Libérer et enregistrer
                        </button>
                        <button type="submit" name="lease_action" value="ignore" class="btn btn-outline-secondary">
                            Enregistrer sans libérer
                        </button>
                        <a href="subnet.php?id=<?= $subnetId ?>" class="btn btn-link text-muted">Annuler</a>
                    </div>
                <?php else: ?>
                    <button type="submit" class="btn btn-primary">
                        <i class="bi bi-check-lg me-1"></i>Enregistrer
                    </button>
                <?php endif; ?>
```

Note : garder le libellé/markup exact du bouton original dans la branche `else` (adapter à ce
qui existe déjà dans le fichier — icône/classe). Le point clé : dans la branche `$pendingLeases`,
les deux boutons submit portent `name="lease_action"` avec les valeurs `release`/`ignore`.

Important : quand le formulaire est réaffiché en mode confirmation, tous les champs (MAC, IP,
hostname, routes…) sont déjà pré-remplis depuis `$data` (le POST courant), donc le resubmit
renvoie les mêmes valeurs. Vérifier que les inputs utilisent bien `$data[...]` (c'est le cas
dans le fichier existant).

- [ ] **Step 5: Lint**

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

- [ ] **Step 6: Vérification logique du flux (revue manuelle)**

Relire le bloc modifié et confirmer :
- Premier submit sans lease actif → écriture directe (comme avant). ✅
- Premier submit avec lease actif → `$pendingLeases` rempli, aucune écriture, formulaire réaffiché. ✅
- Resubmit `lease_action=release` → écrit + libère + audit. ✅
- Resubmit `lease_action=ignore` → écrit sans libérer. ✅
- Échec `releaseLease` → réservation quand même écrite, flash warning. ✅

- [ ] **Step 7: Commit**

```bash
git add public/reservation_edit.php
git commit -m "feat(reservation): warning lease actif à l'édition (3 choix, écriture après décision)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 7 : Warning « lease actif » à la création (`reservation_add.php`)

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

Même flux que Task 6 mais **sans `IP_old`** (création). Lire d'abord le fichier pour repérer le
bloc `if (!$errors)` (avec `Reservation::add(...)`) et le bouton submit — la structure est
analogue à `reservation_edit.php`.

- [ ] **Step 1: Lire `reservation_add.php` pour localiser les points d'insertion**

Run: `grep -n "if (!\$errors)\|Reservation::add\|type=\"submit\"\|foreach (\$errors\|REQUEST_METHOD" public/reservation_add.php`
Noter les numéros de ligne du bloc d'écriture, du bouton, et de l'affichage des erreurs.

- [ ] **Step 2: Initialiser `$pendingLeases`**

Avant le bloc `if ($_SERVER['REQUEST_METHOD'] === 'POST')`, ajouter :

```php
$pendingLeases = []; // leases orphelins en attente de décision
```

- [ ] **Step 3: Remplacer le bloc d'écriture par le flux de confirmation**

Le bloc existant ressemble à (adapter aux noms de variables réels du fichier) :

```php
    if (!$errors) {
        $hostId = Reservation::add($subnet['kea_subnet_id'], $data);
        $sync = SyncService::sync();
        AuditLog::log('reservation.add', ...);
        // flash + redirect
    }
```

Le remplacer par :

```php
    if (!$errors) {
        $leaseAction = $_POST['lease_action'] ?? '';
        $orphans     = LeaseService::orphanLeasesForReservation($data['mac'], $data['ip'], null);

        if ($orphans && $leaseAction === '') {
            $pendingLeases = $orphans;
        } else {
            $hostId = Reservation::add($subnet['kea_subnet_id'], $data);

            $releaseWarning = '';
            if ($leaseAction === 'release') {
                foreach (LeaseService::orphanLeasesForReservation($data['mac'], $data['ip'], null) as $lz) {
                    $rel = LeaseService::releaseLease($lz['ip']);
                    AuditLog::log('lease.release', 'lease', $hostId,
                        "IP {$lz['ip']} (MAC {$lz['mac']}) — depuis reservation_add");
                    if (!$rel['success']) {
                        $releaseWarning = ' Lease ' . $lz['ip'] . ' non libéré (' . $rel['message'] . ').';
                    }
                }
            }

            $sync = SyncService::sync();
            AuditLog::log('reservation.add', 'host', $hostId,
                sprintf('%s → %s (%s) sur subnet %s', $data['mac'], $data['ip'], $data['hostname'], $subnet['name']));

            if ($sync['success'] && $releaseWarning === '') {
                $_SESSION['flash'] = ['type' => 'success', 'message' => 'Réservation créée et Kea rechargé.'];
            } elseif (!$sync['success']) {
                $_SESSION['flash'] = ['type' => 'warning',
                    'message' => 'Réservation créée mais le reload Kea a échoué : ' . $sync['message'] . $releaseWarning];
            } else {
                $_SESSION['flash'] = ['type' => 'warning', 'message' => 'Réservation créée.' . $releaseWarning];
            }
            header('Location: subnet.php?id=' . $subnetId);
            exit;
        }
    }
```

Adapter : le message flash de succès et la cible de redirection doivent correspondre à ceux
déjà présents dans `reservation_add.php` (garder l'existant, n'ajouter que la logique lease).
Le `AuditLog::log('reservation.add', ...)` existe déjà — ne pas le dupliquer, réutiliser la
forme du fichier.

- [ ] **Step 4: Ajouter l'encart de confirmation (identique à Task 6)**

Après l'affichage des erreurs, avant le `<form>`, insérer le même bloc `<?php if ($pendingLeases): ?>…`
que dans Task 6, Step 3 (copier tel quel — le markup est identique).

- [ ] **Step 5: Ajouter les 3 boutons de décision (identique à Task 6)**

Remplacer la zone du bouton submit par le même bloc conditionnel que Task 6, Step 4 — en
adaptant le lien « Annuler » à la cible de `reservation_add.php` (ex. `subnet.php?id=<?= $subnetId ?>`
ou la page d'origine ; garder cohérent avec le bouton d'annulation déjà présent dans le fichier).

- [ ] **Step 6: Lint**

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

- [ ] **Step 7: Commit**

```bash
git add public/reservation_add.php
git commit -m "feat(reservation): warning lease actif à la création (3 choix)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 8 : Documentation

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

- [ ] **Step 1: Documenter dans la section leases.php**

Dans `CLAUDE.md`, section `## leases.php (page Leases)`, ajouter à la liste :

```markdown
- **Bouton « Libérer » par lease actif** (admin|tech, masqué pour viewer) : confirmation JS →
  POST `lease_release.php` → `LeaseService::releaseLease` (`lease4-del` via API Kea). Journalisé
  `lease.release`. Ne touche pas la réservation associée (bail runtime uniquement).
```

- [ ] **Step 2: Documenter LeaseService**

Dans `CLAUDE.md`, section `## LeaseService.php`, ajouter après `getLeases` :

```markdown
```php
LeaseService::releaseLease(string $ip): array
// lease4-del via API Kea. ['success','existed','message'] ; result 3 (déjà absent) = succès.
LeaseService::findActiveLeases(array $ips): array
// leases ACTIFS (state=0) parmi $ips → [ip => ['mac','hostname','expiry']]. [] si API KO.
LeaseService::orphanLeasesForReservation(string $macNew, string $ipNew, ?string $ipOld): array
// leases orphelins après écriture d'une résa (IP_new tenue par autre MAC ; IP_old = bail fantôme).
```
```

- [ ] **Step 3: Documenter le flux de confirmation résa**

Dans la section flux de modification de réservation, ajouter une note :

```markdown
### Warning lease actif (création/édition de réservation)

À la soumission, après validation et **avant écriture**, `LeaseService::orphanLeasesForReservation`
détecte les leases actifs impactés (IP cible occupée par une autre MAC ; ancienne IP à bail
fantôme en édition). S'il y en a et qu'aucune décision n'est prise (`$_POST['lease_action']` vide),
le formulaire se réaffiche en mode confirmation (valeurs conservées) avec 3 choix :
- **Libérer et enregistrer** (`lease_action=release`) : écrit la résa + `releaseLease` sur chaque IP.
- **Enregistrer sans libérer** (`lease_action=ignore`) : écrit la résa sans toucher aux leases.
- **Annuler** : rien n'est écrit.
Un échec de libération n'annule pas l'écriture (flash warning). Journalisé `lease.release`.
```

- [ ] **Step 4: Commit**

```bash
git add CLAUDE.md
git commit -m "docs(leases): libération de leases (bouton + warning résa)"
```
(puis `bash ~/sync.sh dhcpman`)

---

## Task 9 : Nettoyage + vérification finale

- [ ] **Step 1: Supprimer les scripts de test scratchpad**

```bash
rm -f scratchpad/test_release_lease.php scratchpad/test_find_active_leases.php scratchpad/test_orphan_leases.php
```

- [ ] **Step 2: Vérifier qu'aucun scratchpad n'est resté commité**

Run: `git status --short`
Expected: propre (les scratchpad ne sont pas suivis, `.gitignore` couvre `scratchpad/` ou ils
n'ont jamais été `git add`és — vérifier qu'aucun n'apparaît en modifié/ajouté).

- [ ] **Step 3: Vérification manuelle end-to-end (utilisateur ou API Kea joignable)**

Si l'API Kea est joignable depuis l'environnement de test :
1. Page Leases → un lease actif affiche le bouton « Libérer » (admin/tech). Cliquer → confirmation → libéré, la ligne disparaît/rafraîchit.
2. Éditer une résa en changeant la MAC alors qu'un lease actif existe → le warning 3 choix apparaît, aucune écriture avant clic. « Libérer et enregistrer » purge le lease.
3. Viewer : pas de bouton « Libérer ».

Sinon : signaler que la vérification navigateur reste à faire côté utilisateur (API Kea non
joignable depuis l'environnement d'implémentation).

---

## Self-review (rempli à l'écriture du plan)

- **Couverture spec** : brique (`releaseLease`/`findActiveLeases`/`orphanLeasesForReservation`) = Tasks 1-3 ; bouton page Leases = Tasks 4-5 ; warning add+edit = Tasks 6-7 ; gestion erreurs (result 3, API KO, viewer, IP vide) couverte dans les tasks concernées ; audit `lease.release` dans Tasks 4/6/7 ; doc = Task 8. Aucun élément de spec sans task.
- **Cohérence des types** : `releaseLease` retourne `['success','existed','message']` (utilisé partout ainsi) ; `findActiveLeases` → `[ip => ['mac','hostname','expiry']]` ; `orphanLeasesForReservation` → liste `[['ip','mac','hostname','expiry']]` (indexée numériquement, cohérent avec les `foreach ... as $lz` des Tasks 6/7 et le test `$r[0][...]` de Task 3). Signatures identiques entre définition (Tasks 1-3) et usages (Tasks 4-7).
- **Placeholders** : aucun TODO/TBD ; tout le code est fourni. Les seules adaptations demandées (libellé exact du bouton existant, cible de redirection de `reservation_add`) sont explicitées comme « garder l'existant du fichier », pas des placeholders.
