# Médias des pages — 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 au mainteneur de remplacer les images/photos des pages publiques (et les images d'entités) depuis la console admin, sans toucher au code.

**Architecture:** Chaque image remplaçable a une clé de slot stable. Un `MediaResolver` (injectable, donc testable sans BDD) résout *override BDD → défaut codé*. Les templates appellent des helpers globaux `media()` / `media_alt()` / `media_list()`. Un module admin sous « Site & contenu » gère les overlays (table `t_media_overrides`) via `MediaOverrideRepository`, en réutilisant l'`ImageUploadService` (P7). Les images d'entités sont éditées via les repositories existants.

**Tech Stack:** PHP 8.2+ strict, PDO MySQL (utf8mb4), PHPUnit 10.5, GD/WebP (P7), templates PHP natifs.

**Spec de référence :** `docs/superpowers/specs/2026-06-16-medias-pages-design.md`

**Conventions à respecter (CLAUDE.md) :** `declare(strict_types=1)` partout, PSR-12 (4 espaces), `e()` sur toute sortie, PDO préparé avec vérif `substr_count($sql,'?') === count($params)`, CSRF sur tout POST (déjà global via `CsrfMiddleware` — il suffit de mettre `csrf_field()` dans les formulaires), jamais hardcoder un role_ID, migrations idempotentes.

**Commandes projet :**
- Tests : `composer test` (ou `vendor/bin/phpunit`)
- Un seul test : `vendor/bin/phpunit --filter <NomDuTest>`
- Analyse : `composer analyse` · Lint : `composer lint`

---

## File Structure

**Créés :**
- `database/migrations/033_create_media_overrides.sql` — table `t_media_overrides`.
- `src/Services/MediaResolver.php` — logique de résolution override→défaut (cœur testable).
- `src/Repositories/MediaOverrideRepository.php` — CRUD `t_media_overrides`.
- `config/media_slots.php` — registre déclaratif des emplacements.
- `src/Controllers/Admin/MediaController.php` — module admin.
- `views/pages/admin/medias/index.php` — liste des pages + section entités.
- `views/pages/admin/medias/page.php` — édition des slots d'une page.
- `docs/audit-medias-pages.md` — recensement des emplacements (pilote le registre).
- `tests/Unit/Services/MediaResolverTest.php` — tests résolution.
- `tests/Unit/Config/MediaSlotsRegistryTest.php` — validation structure du registre.

**Modifiés :**
- `src/Helpers/functions.php` — ajout helpers `media()`, `media_alt()`, `media_list()`.
- `src/Services/ImageUploadService.php:28` — ajouter `'pages'` à `ENTITIES`.
- `config/routes.php` — routes `/admin/settings/medias/*`.
- `views/pages/admin/settings/index.php` — lien vers le module médias.
- `views/pages/emerald/home.php` — migration du carrousel vers `media_list()` (exemple pilote).
- Autres templates listés dans `docs/audit-medias-pages.md` (Task 11).
- `tests/Unit/Services/ImageUploadServiceTest.php:40` — ajouter `'pages'` au nettoyage.

---

## Task 1: Migration — table `t_media_overrides`

**Files:**
- Create: `database/migrations/033_create_media_overrides.sql`

- [ ] **Step 1: Écrire la migration (idempotente, CREATE TABLE IF NOT EXISTS)**

`CREATE TABLE IF NOT EXISTS` est idempotent sans pattern PREPARE/EXECUTE → ne déclenche pas la limitation connue de `migrate.php`.

```sql
-- ALDANA migration 033
-- Overrides des médias de page : remplace une image codée par défaut par une image
-- uploadée depuis l'admin. slot_key = '<theme>.<page>.<slot>'.
-- position = ordre dans une collection (carrousel) ; 0 pour une image unique.
-- Aucune ligne = la page affiche les images par défaut codées dans les templates.

CREATE TABLE IF NOT EXISTS t_media_overrides (
    override_ID  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    slot_key     VARCHAR(191)    NOT NULL,
    position     INT UNSIGNED    NOT NULL DEFAULT 0,
    image_url    VARCHAR(255)    NOT NULL,
    alt_text     VARCHAR(255)    NULL,
    created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (override_ID),
    UNIQUE KEY uq_slot_position (slot_key, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

- [ ] **Step 2: Appliquer la migration à la main (local)**

⚠ `migrate.php` étant fragile, appliquer manuellement sur `aldanadb` via phpMyAdmin ou :
Run: `mysql -u root aldanadb < database/migrations/033_create_media_overrides.sql`
Expected: pas d'erreur ; ré-exécution sans effet (idempotent).

- [ ] **Step 3: Vérifier la création**

Run (mysql) : `SHOW COLUMNS FROM t_media_overrides;`
Expected: 7 colonnes (override_ID, slot_key, position, image_url, alt_text, created_at, updated_at) + index unique `uq_slot_position`.

- [ ] **Step 4: Commit**

```bash
git add database/migrations/033_create_media_overrides.sql
git commit -m "feat(medias): migration 033 table t_media_overrides"
```

---

## Task 2: `MediaResolver` — logique de résolution (cœur testable)

Classe sans dépendance BDD directe : on lui **injecte** le tableau d'overlays (ou un loader paresseux). Cela permet de tester toute la sémantique sans base de données, comme `ImageUploadService` injecte son hook `is_uploaded_file`.

**Format des overrides en mémoire** : tableau indexé par `slot_key` →
liste ordonnée de `['url' => string, 'alt' => string|null, 'position' => int]`.

**Files:**
- Create: `src/Services/MediaResolver.php`
- Test: `tests/Unit/Services/MediaResolverTest.php`

- [ ] **Step 1: Écrire les tests d'abord**

```php
<?php
declare(strict_types=1);

namespace Tests\Unit\Services;

use App\Services\MediaResolver;
use PHPUnit\Framework\TestCase;

final class MediaResolverTest extends TestCase
{
    public function testSingleReturnsOverrideWhenPresent(): void
    {
        $r = new MediaResolver([
            'public.le-lieu.hero' => [['url' => '/uploads/pages/h.webp', 'alt' => 'Studio', 'position' => 0]],
        ]);
        $this->assertSame('/uploads/pages/h.webp', $r->single('public.le-lieu.hero', '/assets/def.jpg'));
    }

    public function testSingleFallsBackToDefault(): void
    {
        $r = new MediaResolver([]);
        $this->assertSame('/assets/def.jpg', $r->single('public.le-lieu.hero', '/assets/def.jpg'));
    }

    public function testAltReturnsOverrideThenDefault(): void
    {
        $r = new MediaResolver([
            'a.b.c' => [['url' => '/u.webp', 'alt' => 'Mon alt', 'position' => 0]],
        ]);
        $this->assertSame('Mon alt', $r->alt('a.b.c', 'défaut'));
        $this->assertSame('défaut', $r->alt('x.y.z', 'défaut'));
    }

    public function testAltFallsBackToDefaultWhenOverrideAltNull(): void
    {
        $r = new MediaResolver([
            'a.b.c' => [['url' => '/u.webp', 'alt' => null, 'position' => 0]],
        ]);
        $this->assertSame('défaut', $r->alt('a.b.c', 'défaut'));
    }

    public function testListReturnsOverridesOrderedByPosition(): void
    {
        $r = new MediaResolver([
            'home.carousel' => [
                ['url' => '/b.webp', 'alt' => 'B', 'position' => 1],
                ['url' => '/a.webp', 'alt' => 'A', 'position' => 0],
            ],
        ]);
        $list = $r->list('home.carousel', [['url' => '/def.jpg', 'alt' => 'D']]);
        $this->assertSame(['/a.webp', '/b.webp'], array_column($list, 'url'));
        $this->assertSame(['A', 'B'], array_column($list, 'alt'));
    }

    public function testListFallsBackToDefaultsWhenNoOverride(): void
    {
        $r = new MediaResolver([]);
        $list = $r->list('home.carousel', ['/s1.jpg', '/s2.jpg']);
        $this->assertSame(['/s1.jpg', '/s2.jpg'], array_column($list, 'url'));
        $this->assertSame(['', ''], array_column($list, 'alt'));
    }

    public function testListNormalizesStringAndArrayDefaults(): void
    {
        $r = new MediaResolver([]);
        $list = $r->list('k', ['/s1.jpg', ['url' => '/s2.jpg', 'alt' => 'Deux']]);
        $this->assertSame([['url' => '/s1.jpg', 'alt' => ''], ['url' => '/s2.jpg', 'alt' => 'Deux']], $list);
    }
}
```

- [ ] **Step 2: Lancer les tests, vérifier l'échec**

Run: `vendor/bin/phpunit --filter MediaResolverTest`
Expected: FAIL — `Class "App\Services\MediaResolver" not found`.

- [ ] **Step 3: Implémenter `MediaResolver`**

```php
<?php
declare(strict_types=1);

namespace App\Services;

/**
 * Résout chaque emplacement d'image : override BDD si présent, sinon défaut codé.
 *
 * Les overrides sont injectés (tableau indexé par slot_key → liste de lignes
 * ['url','alt','position']). Aucune dépendance BDD ici → unit-testable sans base.
 * Le câblage paresseux vers MediaOverrideRepository se fait via bootGlobal() (Task 5).
 */
final class MediaResolver
{
    /** @var array<string, list<array{url:string, alt:?string, position:int}>> */
    private array $overrides;

    /** @param array<string, list<array{url:string, alt:?string, position:int}>> $overrides */
    public function __construct(array $overrides = [])
    {
        $this->overrides = $overrides;
    }

    /** URL d'une image unique : override (position 0) sinon défaut codé. */
    public function single(string $slotKey, string $defaultUrl = ''): string
    {
        $rows = $this->overrides[$slotKey] ?? [];
        return $rows !== [] ? (string) $rows[0]['url'] : $defaultUrl;
    }

    /** Texte alternatif d'une image unique : override non vide sinon défaut. */
    public function alt(string $slotKey, string $defaultAlt = ''): string
    {
        $rows = $this->overrides[$slotKey] ?? [];
        $alt  = $rows !== [] ? ($rows[0]['alt'] ?? null) : null;
        return ($alt !== null && $alt !== '') ? $alt : $defaultAlt;
    }

    /**
     * Collection ordonnée. Si ≥1 override existe pour la clé → liste BDD (triée par
     * position) ; sinon → défauts normalisés.
     *
     * @param list<string|array{url:string, alt?:string}> $defaults
     * @return list<array{url:string, alt:string}>
     */
    public function list(string $slotKey, array $defaults = []): array
    {
        $rows = $this->overrides[$slotKey] ?? [];
        if ($rows !== []) {
            usort($rows, static fn($a, $b) => $a['position'] <=> $b['position']);
            return array_map(
                static fn($r) => ['url' => (string) $r['url'], 'alt' => (string) ($r['alt'] ?? '')],
                $rows
            );
        }
        return array_map(
            static function ($d): array {
                if (is_array($d)) {
                    return ['url' => (string) ($d['url'] ?? ''), 'alt' => (string) ($d['alt'] ?? '')];
                }
                return ['url' => (string) $d, 'alt' => ''];
            },
            $defaults
        );
    }
}
```

- [ ] **Step 4: Lancer les tests, vérifier le succès**

Run: `vendor/bin/phpunit --filter MediaResolverTest`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
git add src/Services/MediaResolver.php tests/Unit/Services/MediaResolverTest.php
git commit -m "feat(medias): MediaResolver + tests résolution override/défaut"
```

---

## Task 3: Helpers globaux `media()` / `media_alt()` / `media_list()`

Délèguent à une instance statique partagée de `MediaResolver`. La méthode `MediaResolver::global()` renvoie l'instance courante ; `MediaResolver::setGlobal()` permet de l'injecter (tests + boot). Le câblage BDD réel arrive en Task 5.

**Files:**
- Modify: `src/Services/MediaResolver.php` (ajout registre statique)
- Modify: `src/Helpers/functions.php` (3 helpers)
- Test: `tests/Unit/Services/MediaResolverTest.php` (ajout d'un test helpers)

- [ ] **Step 1: Ajouter le test du registre statique + helpers**

Ajouter dans `MediaResolverTest` :

```php
    public function testGlobalRegistryDrivesHelpers(): void
    {
        MediaResolver::setGlobal(new MediaResolver([
            'a.b.c' => [['url' => '/o.webp', 'alt' => 'O', 'position' => 0]],
        ]));

        $this->assertSame('/o.webp', media('a.b.c', '/def.jpg'));
        $this->assertSame('O', media_alt('a.b.c', 'défaut'));
        $this->assertSame('/def.jpg', media('x.y.z', '/def.jpg'));

        MediaResolver::setGlobal(new MediaResolver([])); // reset pour les autres tests
    }
```

- [ ] **Step 2: Lancer, vérifier l'échec**

Run: `vendor/bin/phpunit --filter testGlobalRegistryDrivesHelpers`
Expected: FAIL — `Call to undefined method ...::setGlobal()` puis `Call to undefined function media()`.

- [ ] **Step 3: Ajouter le registre statique à `MediaResolver`**

Ajouter ces membres dans la classe `MediaResolver` :

```php
    private static ?self $global = null;

    public static function setGlobal(self $resolver): void
    {
        self::$global = $resolver;
    }

    /**
     * Instance globale. Si aucune n'a été câblée, renvoie un resolver vide
     * (le site retombe alors sur les défauts codés). Le boot BDD réel : voir
     * MediaResolver::bootGlobal() (Task 5).
     */
    public static function global(): self
    {
        if (self::$global === null) {
            self::$global = new self([]);
        }
        return self::$global;
    }
```

- [ ] **Step 4: Ajouter les helpers dans `functions.php`**

Ajouter à la fin de `src/Helpers/functions.php` (avant la fin de fichier) :

```php
if (!function_exists('media')) {
    /** URL d'une image de page : override admin si présent, sinon le défaut codé. */
    function media(string $slotKey, string $defaultUrl = ''): string
    {
        return \App\Services\MediaResolver::global()->single($slotKey, $defaultUrl);
    }
}

if (!function_exists('media_alt')) {
    /** Texte alternatif d'une image de page (override admin sinon défaut). */
    function media_alt(string $slotKey, string $defaultAlt = ''): string
    {
        return \App\Services\MediaResolver::global()->alt($slotKey, $defaultAlt);
    }
}

if (!function_exists('media_list')) {
    /**
     * Liste ordonnée d'images (carrousel/galerie). Override admin si présent,
     * sinon défauts codés.
     *
     * @param list<string|array{url:string, alt?:string}> $defaults
     * @return list<array{url:string, alt:string}>
     */
    function media_list(string $slotKey, array $defaults = []): array
    {
        return \App\Services\MediaResolver::global()->list($slotKey, $defaults);
    }
}
```

- [ ] **Step 5: Lancer toute la classe de test, vérifier le succès**

Run: `vendor/bin/phpunit --filter MediaResolverTest`
Expected: PASS (8 tests).

- [ ] **Step 6: Commit**

```bash
git add src/Services/MediaResolver.php src/Helpers/functions.php tests/Unit/Services/MediaResolverTest.php
git commit -m "feat(medias): helpers globaux media()/media_alt()/media_list()"
```

---

## Task 4: `MediaOverrideRepository` — CRUD `t_media_overrides`

Repository PDO classique (cf. `PracticeRepository`). Renvoie aussi les overrides au format attendu par `MediaResolver` (`loadAllGrouped()`).

**Files:**
- Create: `src/Repositories/MediaOverrideRepository.php`

- [ ] **Step 1: Implémenter le repository**

```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Helpers\Database;
use PDO;

final class MediaOverrideRepository
{
    private PDO $pdo;

    public function __construct()
    {
        $this->pdo = Database::getInstance()->getConnection();
    }

    /**
     * Tous les overrides groupés par slot_key, triés par position.
     * Format consommé par MediaResolver.
     *
     * @return array<string, list<array{url:string, alt:?string, position:int}>>
     */
    public function loadAllGrouped(): array
    {
        $rows = $this->pdo
            ->query('SELECT slot_key, position, image_url, alt_text
                     FROM t_media_overrides ORDER BY slot_key, position')
            ->fetchAll();

        $grouped = [];
        foreach ($rows as $r) {
            $grouped[$r['slot_key']][] = [
                'url'      => (string) $r['image_url'],
                'alt'      => $r['alt_text'] !== null ? (string) $r['alt_text'] : null,
                'position' => (int) $r['position'],
            ];
        }
        return $grouped;
    }

    /** @return list<array<string,mixed>> Lignes brutes d'un slot, triées par position. */
    public function findBySlot(string $slotKey): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM t_media_overrides WHERE slot_key = ? ORDER BY position'
        );
        $stmt->execute([$slotKey]);
        return $stmt->fetchAll();
    }

    /** Upsert d'une image unique (position 0) pour un slot. */
    public function upsertSingle(string $slotKey, string $imageUrl, ?string $alt): void
    {
        $sql = 'INSERT INTO t_media_overrides (slot_key, position, image_url, alt_text)
                VALUES (?, 0, ?, ?)
                ON DUPLICATE KEY UPDATE image_url = VALUES(image_url), alt_text = VALUES(alt_text)';
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$slotKey, $imageUrl, $alt]);
    }

    /** Ajoute une image en fin de collection. Retourne l'override_ID créé. */
    public function addToCollection(string $slotKey, string $imageUrl, ?string $alt): int
    {
        $next = $this->nextPosition($slotKey);
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_media_overrides (slot_key, position, image_url, alt_text)
             VALUES (?, ?, ?, ?)'
        );
        $stmt->execute([$slotKey, $next, $imageUrl, $alt]);
        return (int) $this->pdo->lastInsertId();
    }

    /** Met à jour l'alt d'une ligne existante. */
    public function updateAlt(int $overrideID, ?string $alt): void
    {
        $this->pdo->prepare('UPDATE t_media_overrides SET alt_text = ? WHERE override_ID = ?')
                  ->execute([$alt, $overrideID]);
    }

    /** Retourne image_url d'une ligne (pour suppression du fichier), ou null. */
    public function imageUrlOf(int $overrideID): ?string
    {
        $stmt = $this->pdo->prepare('SELECT image_url FROM t_media_overrides WHERE override_ID = ?');
        $stmt->execute([$overrideID]);
        $url = $stmt->fetchColumn();
        return $url === false ? null : (string) $url;
    }

    public function deleteByID(int $overrideID): void
    {
        $this->pdo->prepare('DELETE FROM t_media_overrides WHERE override_ID = ?')
                  ->execute([$overrideID]);
    }

    /** @return list<string> image_url de toutes les lignes d'un slot (pour purge fichiers). */
    public function imageUrlsOfSlot(string $slotKey): array
    {
        $stmt = $this->pdo->prepare('SELECT image_url FROM t_media_overrides WHERE slot_key = ?');
        $stmt->execute([$slotKey]);
        return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
    }

    public function deleteBySlot(string $slotKey): void
    {
        $this->pdo->prepare('DELETE FROM t_media_overrides WHERE slot_key = ?')->execute([$slotKey]);
    }

    /**
     * Réordonne une collection : $orderedIDs = override_ID dans l'ordre voulu.
     * Réécrit position = 0,1,2… Utilise un décalage temporaire pour éviter les
     * collisions sur l'index unique (slot_key, position) pendant la réécriture.
     */
    public function reorder(string $slotKey, array $orderedIDs): void
    {
        $this->pdo->beginTransaction();
        try {
            $bump = $this->pdo->prepare(
                'UPDATE t_media_overrides SET position = position + 1000
                 WHERE slot_key = ?'
            );
            $bump->execute([$slotKey]);

            $set = $this->pdo->prepare(
                'UPDATE t_media_overrides SET position = ? WHERE override_ID = ? AND slot_key = ?'
            );
            $pos = 0;
            foreach ($orderedIDs as $id) {
                $set->execute([$pos, (int) $id, $slotKey]);
                $pos++;
            }
            $this->pdo->commit();
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    private function nextPosition(string $slotKey): int
    {
        $stmt = $this->pdo->prepare(
            'SELECT COALESCE(MAX(position), -1) + 1 FROM t_media_overrides WHERE slot_key = ?'
        );
        $stmt->execute([$slotKey]);
        return (int) $stmt->fetchColumn();
    }
}
```

- [ ] **Step 2: Vérifier l'analyse statique**

Run: `composer analyse`
Expected: pas de nouvelle erreur sur `MediaOverrideRepository.php`.

> Pas de test unitaire automatisé ici (pas de BDD de test dans le harnais — cf `tests/bootstrap.php` qui ne connecte pas). La correction du repo est validée en recette manuelle (Task 10) ; la logique pure est couverte par `MediaResolverTest`.

- [ ] **Step 3: Commit**

```bash
git add src/Repositories/MediaOverrideRepository.php
git commit -m "feat(medias): MediaOverrideRepository (CRUD t_media_overrides)"
```

---

## Task 5: Câblage paresseux MediaResolver ↔ BDD (bootGlobal)

Le front controller doit câbler le resolver global sur la BDD au démarrage, **sans casser** quand la table n'existe pas encore (graceful degradation, cf. pattern projet) → on retombe sur les défauts.

**Files:**
- Modify: `src/Services/MediaResolver.php` (méthode `bootGlobal()`)
- Modify: `public/index.php` (appel au boot)

- [ ] **Step 1: Repérer le point d'amorçage dans `public/index.php`**

Run: `grep -n "session_start\|require\|Database\|Router\|dispatch" public/index.php`
Expected: identifier l'endroit après le chargement de l'autoload/config et avant le dispatch des routes.

- [ ] **Step 2: Ajouter `bootGlobal()` à `MediaResolver`**

```php
    /**
     * Câble l'instance globale sur les overrides en base. Tolérant aux pannes :
     * si la table n'existe pas encore (migration non appliquée) ou si la BDD est
     * indisponible, installe un resolver vide → le site affiche les défauts codés.
     */
    public static function bootGlobal(): void
    {
        try {
            $grouped = (new \App\Repositories\MediaOverrideRepository())->loadAllGrouped();
            self::setGlobal(new self($grouped));
        } catch (\Throwable $e) {
            self::setGlobal(new self([]));
        }
    }
```

- [ ] **Step 3: Appeler `bootGlobal()` dans `public/index.php`**

Juste avant le dispatch des routes (après l'init session/config), ajouter :

```php
\App\Services\MediaResolver::bootGlobal();
```

- [ ] **Step 4: Vérifier que le site charge (table présente et absente)**

Run: `composer serve` puis visiter `http://localhost:8000/`
Expected: page d'accueil s'affiche sans erreur (avec table 033 appliquée OU absente).

- [ ] **Step 5: Commit**

```bash
git add src/Services/MediaResolver.php public/index.php
git commit -m "feat(medias): boot du resolver global sur la BDD (graceful)"
```

---

## Task 6: Étendre `ImageUploadService` à l'entité `pages`

**Files:**
- Modify: `src/Services/ImageUploadService.php:28`
- Modify: `tests/Unit/Services/ImageUploadServiceTest.php:40`

- [ ] **Step 1: Ajouter `'pages'` à `ENTITIES`**

Dans `src/Services/ImageUploadService.php`, remplacer la constante :

```php
    public const ENTITIES = ['rooms', 'teachers', 'events', 'practices', 'videos', 'pages'];
```

- [ ] **Step 2: Mettre à jour le nettoyage des tests**

Dans `tests/Unit/Services/ImageUploadServiceTest.php`, étendre la liste du `tearDown` :

```php
        foreach (['rooms', 'teachers', 'events', 'pages'] as $entity) {
```

- [ ] **Step 3: Lancer les tests d'upload, vérifier qu'ils passent toujours**

Run: `vendor/bin/phpunit --filter ImageUploadServiceTest`
Expected: PASS (aucune régression).

- [ ] **Step 4: Commit**

```bash
git add src/Services/ImageUploadService.php tests/Unit/Services/ImageUploadServiceTest.php
git commit -m "feat(medias): autorise l'entité d'upload 'pages'"
```

---

## Task 7: Audit des emplacements + registre `config/media_slots.php`

**Files:**
- Create: `docs/audit-medias-pages.md`
- Create: `config/media_slots.php`
- Test: `tests/Unit/Config/MediaSlotsRegistryTest.php`

- [ ] **Step 1: Auditer tous les templates de pages**

Run: `grep -rnE "assets/img|/uploads/|background-image|<img|url\(" views/pages/emerald views/pages/public`
Pour chaque résultat, noter dans `docs/audit-medias-pages.md` une ligne :
`| slot_key | page | type (single/collection) | chemin(s) actuel(s) | dimensions conseillées |`.

Démarrer le doc ainsi :

```markdown
# Audit des médias de page — 2026-06-16

Recensement de toutes les images référencées dans les templates publics, pour
peupler `config/media_slots.php`. Chaque image migre vers un appel `media*()` en
conservant le chemin actuel comme défaut.

| slot_key | page (route) | type | chemin(s) actuel(s) | dimensions |
|----------|--------------|------|---------------------|------------|
| emerald.home.carousel | / | collection | /assets/img/emerald/slide-1..10.jpg | 1920×1080 |
```

Compléter avec **toutes** les occurrences trouvées par le grep.

- [ ] **Step 2: Écrire le test de validation du registre**

```php
<?php
declare(strict_types=1);

namespace Tests\Unit\Config;

use PHPUnit\Framework\TestCase;

final class MediaSlotsRegistryTest extends TestCase
{
    /** @return array<string,mixed> */
    private function registry(): array
    {
        return require dirname(__DIR__, 3) . '/config/media_slots.php';
    }

    public function testEveryPageHasLabelRouteAndSlots(): void
    {
        foreach ($this->registry() as $pageKey => $page) {
            $this->assertIsString($pageKey);
            $this->assertArrayHasKey('label', $page, "page {$pageKey}");
            $this->assertArrayHasKey('route', $page, "page {$pageKey}");
            $this->assertArrayHasKey('slots', $page, "page {$pageKey}");
            $this->assertNotEmpty($page['slots'], "page {$pageKey} sans slot");
        }
    }

    public function testEverySlotIsWellFormed(): void
    {
        foreach ($this->registry() as $pageKey => $page) {
            foreach ($page['slots'] as $slotName => $slot) {
                $key = "{$pageKey}.{$slotName}";
                $this->assertContains($slot['type'], ['single', 'collection'], "slot {$key} type");
                $this->assertArrayHasKey('label', $slot, "slot {$key} label");
                if ($slot['type'] === 'single') {
                    $this->assertArrayHasKey('default', $slot, "slot single {$key} default");
                } else {
                    $this->assertArrayHasKey('defaults', $slot, "slot collection {$key} defaults");
                    $this->assertIsArray($slot['defaults'], "slot {$key} defaults array");
                }
            }
        }
    }
}
```

- [ ] **Step 3: Lancer le test, vérifier l'échec**

Run: `vendor/bin/phpunit --filter MediaSlotsRegistryTest`
Expected: FAIL — fichier `config/media_slots.php` introuvable.

- [ ] **Step 4: Écrire le registre (au moins l'accueil emerald + toutes les entrées de l'audit)**

```php
<?php
declare(strict_types=1);

/**
 * Registre des emplacements d'images remplaçables depuis l'admin.
 * Clé de slot = '<pageKey>.<slotName>'. Pilote le module « Médias des pages ».
 * Toute clé non listée ici est refusée par le contrôleur (anti-écriture arbitraire).
 */
return [
    'emerald.home' => [
        'label' => 'Accueil (thème Emerald)',
        'route' => '/',
        'slots' => [
            'carousel' => [
                'label'       => 'Carrousel principal',
                'type'        => 'collection',
                'recommended' => '1920×1080',
                'defaults'    => [
                    ['url' => '/assets/img/emerald/slide-1.jpg',  'alt' => 'Forêt de pins'],
                    ['url' => '/assets/img/emerald/slide-2.jpg',  'alt' => 'Brume en forêt'],
                    ['url' => '/assets/img/emerald/slide-3.jpg',  'alt' => 'Canopée'],
                    ['url' => '/assets/img/emerald/slide-4.jpg',  'alt' => 'Fougères'],
                    ['url' => '/assets/img/emerald/slide-5.jpg',  'alt' => 'Forêt moussue'],
                    ['url' => '/assets/img/emerald/slide-6.jpg',  'alt' => 'Brouillard en forêt'],
                    ['url' => '/assets/img/emerald/slide-7.jpg',  'alt' => 'Lac calme'],
                    ['url' => '/assets/img/emerald/slide-8.jpg',  'alt' => 'Forêt zen'],
                    ['url' => '/assets/img/emerald/slide-9.jpg',  'alt' => 'Montagne dans la brume'],
                    ['url' => '/assets/img/emerald/slide-10.jpg', 'alt' => 'Paysage automnal'],
                ],
            ],
        ],
    ],
    // … Ajouter ici toutes les entrées recensées dans docs/audit-medias-pages.md
    // (un bloc par page ; 'single' avec 'default' => '/chemin.jpg', 'collection' avec 'defaults').
];
```

- [ ] **Step 5: Lancer le test, vérifier le succès**

Run: `vendor/bin/phpunit --filter MediaSlotsRegistryTest`
Expected: PASS (2 tests).

- [ ] **Step 6: Commit**

```bash
git add docs/audit-medias-pages.md config/media_slots.php tests/Unit/Config/MediaSlotsRegistryTest.php
git commit -m "feat(medias): audit des emplacements + registre media_slots + tests"
```

---

## Task 8: `MediaController` — index + page (slots) + actions POST

CSRF déjà global (`CsrfMiddleware`). Le contrôleur **valide chaque `slot_key` contre le registre** (anti-écriture arbitraire) et le **type** du slot (single vs collection). Réutilise `ImageUploadService` pour l'upload (entité `pages`).

**Files:**
- Create: `src/Controllers/Admin/MediaController.php`

- [ ] **Step 1: Implémenter le contrôleur (section pages)**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Repositories\MediaOverrideRepository;
use App\Services\ImageUploadService;
use RuntimeException;

final class MediaController extends BaseController
{
    /** @return array<string,mixed> */
    private function registry(): array
    {
        return require base_path('config/media_slots.php');
    }

    /** Retourne la définition d'un slot via sa clé complète, ou null si inconnue. */
    private function slotDef(string $slotKey): ?array
    {
        foreach ($this->registry() as $pageKey => $page) {
            foreach ($page['slots'] as $slotName => $slot) {
                if ("{$pageKey}.{$slotName}" === $slotKey) {
                    return $slot + ['_pageKey' => $pageKey, '_slotName' => $slotName];
                }
            }
        }
        return null;
    }

    public function index(): void
    {
        echo view('layouts.admin', [
            'title'       => 'Médias des pages',
            'currentPath' => '/admin/settings',
            'content'     => view('pages.admin.medias.index', [
                'registry' => $this->registry(),
            ]),
        ]);
    }

    public function page(): void
    {
        $pageKey  = (string) ($_GET['key'] ?? '');
        $registry = $this->registry();
        if (!isset($registry[$pageKey])) {
            http_response_code(404);
            echo 'Page inconnue';
            return;
        }
        $repo      = new MediaOverrideRepository();
        $overrides = [];
        foreach ($registry[$pageKey]['slots'] as $slotName => $_) {
            $overrides["{$pageKey}.{$slotName}"] = $repo->findBySlot("{$pageKey}.{$slotName}");
        }
        echo view('layouts.admin', [
            'title'       => 'Médias — ' . ($registry[$pageKey]['label'] ?? $pageKey),
            'currentPath' => '/admin/settings',
            'content'     => view('pages.admin.medias.page', [
                'pageKey'   => $pageKey,
                'page'      => $registry[$pageKey],
                'overrides' => $overrides,
            ]),
        ]);
    }

    public function saveSingle(): void
    {
        $slotKey = (string) ($_POST['slot_key'] ?? '');
        $def     = $this->slotDef($slotKey);
        if ($def === null || $def['type'] !== 'single') {
            flash('error', 'Emplacement inconnu.');
            redirect('/admin/settings/medias');
        }
        $repo     = new MediaOverrideRepository();
        $existing = $repo->findBySlot($slotKey);
        $current  = $existing[0]['image_url'] ?? null;
        $uploader = new ImageUploadService();

        try {
            // Suppression demandée → revert au défaut codé.
            if (!empty($_POST['image_delete'])) {
                $this->purgeSlotFiles($repo, $uploader, $slotKey);
                $repo->deleteBySlot($slotKey);
                flash('success', 'Image réinitialisée à l\'originale.');
                redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
            }

            $alt    = trim((string) ($_POST['alt_text'] ?? '')) ?: null;
            $newUrl = $current;

            if (isset($_FILES['image_file'])
                && (int) ($_FILES['image_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
                $newUrl = $uploader->upload($_FILES['image_file'], 'pages', str_replace('.', '-', $slotKey));
                if ($current !== null && $current !== $newUrl) {
                    $uploader->delete($current);
                }
            } elseif (isset($_POST['image_url']) && trim((string) $_POST['image_url']) !== '') {
                $newUrl = trim((string) $_POST['image_url']);
            }

            if ($newUrl === null) {
                flash('error', 'Aucune image fournie.');
                redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
            }
            $repo->upsertSingle($slotKey, $newUrl, $alt);
            flash('success', 'Image mise à jour.');
        } catch (RuntimeException $e) {
            flash('error', 'Image : ' . $e->getMessage());
        }
        redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
    }

    public function collectionAdd(): void
    {
        $slotKey = (string) ($_POST['slot_key'] ?? '');
        $def     = $this->slotDef($slotKey);
        if ($def === null || $def['type'] !== 'collection') {
            flash('error', 'Galerie inconnue.');
            redirect('/admin/settings/medias');
        }
        $repo     = new MediaOverrideRepository();
        $uploader = new ImageUploadService();
        try {
            $alt = trim((string) ($_POST['alt_text'] ?? '')) ?: null;
            if (isset($_FILES['image_file'])
                && (int) ($_FILES['image_file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
                $url = $uploader->upload($_FILES['image_file'], 'pages', str_replace('.', '-', $slotKey));
            } elseif (isset($_POST['image_url']) && trim((string) $_POST['image_url']) !== '') {
                $url = trim((string) $_POST['image_url']);
            } else {
                flash('error', 'Aucune image fournie.');
                redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
            }
            $repo->addToCollection($slotKey, $url, $alt);
            flash('success', 'Image ajoutée à la galerie.');
        } catch (RuntimeException $e) {
            flash('error', 'Image : ' . $e->getMessage());
        }
        redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
    }

    public function collectionDelete(): void
    {
        $id      = (int) ($_POST['override_ID'] ?? 0);
        $slotKey = (string) ($_POST['slot_key'] ?? '');
        $def     = $this->slotDef($slotKey);
        if ($def === null) {
            flash('error', 'Galerie inconnue.');
            redirect('/admin/settings/medias');
        }
        if ($id > 0) {
            $repo = new MediaOverrideRepository();
            $url  = $repo->imageUrlOf($id);
            if ($url !== null) {
                (new ImageUploadService())->delete($url);
            }
            $repo->deleteByID($id);
            flash('success', 'Image retirée.');
        }
        redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
    }

    public function collectionReorder(): void
    {
        $slotKey = (string) ($_POST['slot_key'] ?? '');
        $def     = $this->slotDef($slotKey);
        if ($def === null || $def['type'] !== 'collection') {
            flash('error', 'Galerie inconnue.');
            redirect('/admin/settings/medias');
        }
        $ids = array_map('intval', (array) ($_POST['order'] ?? []));
        if ($ids !== []) {
            (new MediaOverrideRepository())->reorder($slotKey, $ids);
            flash('success', 'Ordre mis à jour.');
        }
        redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
    }

    public function revert(): void
    {
        $slotKey = (string) ($_POST['slot_key'] ?? '');
        $def     = $this->slotDef($slotKey);
        if ($def === null) {
            flash('error', 'Emplacement inconnu.');
            redirect('/admin/settings/medias');
        }
        $repo = new MediaOverrideRepository();
        $this->purgeSlotFiles($repo, new ImageUploadService(), $slotKey);
        $repo->deleteBySlot($slotKey);
        flash('success', 'Emplacement réinitialisé aux images d\'origine.');
        redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
    }

    /** Supprime les fichiers locaux uploadés d'un slot avant purge des lignes. */
    private function purgeSlotFiles(
        MediaOverrideRepository $repo,
        ImageUploadService $uploader,
        string $slotKey
    ): void {
        foreach ($repo->imageUrlsOfSlot($slotKey) as $url) {
            $uploader->delete($url);
        }
    }
}
```

- [ ] **Step 2: Vérifier l'analyse statique**

Run: `composer analyse`
Expected: pas de nouvelle erreur sur `MediaController.php`.

- [ ] **Step 3: Commit**

```bash
git add src/Controllers/Admin/MediaController.php
git commit -m "feat(medias): MediaController (slots single + collections)"
```

---

## Task 9: Routes + entrée dans « Site & contenu »

**Files:**
- Modify: `config/routes.php` (après les routes `/admin/settings`)
- Modify: `views/pages/admin/settings/index.php` (bouton vers le module)

- [ ] **Step 1: Ajouter les routes**

Après la ligne `'POST /admin/settings/save' => ...` dans `config/routes.php` :

```php
    'GET  /admin/settings/medias'                  => ['App\Controllers\Admin\MediaController', 'index',            ['middleware' => 'admin']],
    'GET  /admin/settings/medias/page'             => ['App\Controllers\Admin\MediaController', 'page',             ['middleware' => 'admin']],
    'POST /admin/settings/medias/single'           => ['App\Controllers\Admin\MediaController', 'saveSingle',       ['middleware' => 'admin']],
    'POST /admin/settings/medias/collection/add'   => ['App\Controllers\Admin\MediaController', 'collectionAdd',    ['middleware' => 'admin']],
    'POST /admin/settings/medias/collection/del'   => ['App\Controllers\Admin\MediaController', 'collectionDelete', ['middleware' => 'admin']],
    'POST /admin/settings/medias/collection/order' => ['App\Controllers\Admin\MediaController', 'collectionReorder',['middleware' => 'admin']],
    'POST /admin/settings/medias/revert'           => ['App\Controllers\Admin\MediaController', 'revert',           ['middleware' => 'admin']],
```

> Vérifier le séparateur exact des routes existantes (espaces autour de la méthode). S'aligner sur le format déjà présent dans le fichier — ajuster `GET ` / `POST ` au format réel constaté (`grep -n "GET /admin/settings" config/routes.php`).

- [ ] **Step 2: Ajouter le lien dans la page Settings**

Dans `views/pages/admin/settings/index.php`, ajouter en tête de contenu :

```php
<p style="margin:0 0 1.5rem;">
    <a class="btn btn-primary" href="/admin/settings/medias">🖼️ Gérer les médias des pages</a>
</p>
```

- [ ] **Step 3: Vérifier que la route répond**

Run: `composer serve` puis visiter `http://localhost:8000/admin/settings/medias` (connecté en admin)
Expected: la page index médias s'affiche (après Task 10 pour les vues).

- [ ] **Step 4: Commit**

```bash
git add config/routes.php views/pages/admin/settings/index.php
git commit -m "feat(medias): routes + accès depuis « Site & contenu »"
```

---

## Task 10: Vues admin — index + page (slots, upload, galerie, réordonnancement)

**Files:**
- Create: `views/pages/admin/medias/index.php`
- Create: `views/pages/admin/medias/page.php`

- [ ] **Step 1: Vue index (liste des pages)**

```php
<?php
declare(strict_types=1);
/** @var array<string,mixed> $registry */
?>
<h1>Médias des pages</h1>
<p class="text-muted">Remplacez les images de chaque page. Les images par défaut
restent en place tant que vous n'en importez pas de nouvelles.</p>

<div class="card-grid">
    <?php foreach ($registry as $pageKey => $page): ?>
        <div class="card">
            <h3><?= e($page['label'] ?? $pageKey) ?></h3>
            <p class="text-muted"><?= e($page['route'] ?? '') ?>
                — <?= (int) count($page['slots']) ?> emplacement(s)</p>
            <a class="btn btn-secondary"
               href="/admin/settings/medias/page?key=<?= e(urlencode((string) $pageKey)) ?>">
                Gérer
            </a>
        </div>
    <?php endforeach; ?>
</div>
```

- [ ] **Step 2: Vue page (un bloc par slot)**

```php
<?php
declare(strict_types=1);
/**
 * @var string $pageKey
 * @var array<string,mixed> $page
 * @var array<string, list<array<string,mixed>>> $overrides  indexé par slot_key
 */
$maxMb = \App\Services\ImageUploadService::MAX_BYTES >> 20;
?>
<p><a href="/admin/settings/medias">← Toutes les pages</a></p>
<h1>Médias — <?= e($page['label'] ?? $pageKey) ?></h1>

<?php foreach ($page['slots'] as $slotName => $slot): ?>
    <?php
    $slotKey = $pageKey . '.' . $slotName;
    $rows    = $overrides[$slotKey] ?? [];
    ?>
    <fieldset class="form-group" style="margin-bottom:2rem;">
        <legend><strong><?= e($slot['label'] ?? $slotName) ?></strong>
            <?php if (!empty($slot['recommended'])): ?>
                <small class="text-muted">(conseillé : <?= e($slot['recommended']) ?>)</small>
            <?php endif; ?>
        </legend>

        <?php if ($slot['type'] === 'single'): ?>
            <?php $cur = $rows[0] ?? null; ?>
            <form method="post" action="/admin/settings/medias/single" enctype="multipart/form-data">
                <?= csrf_field() ?>
                <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">

                <?php if ($cur !== null): ?>
                    <img src="<?= e($cur['image_url']) ?>" alt="Aperçu"
                         style="max-width:240px;max-height:160px;border:1px solid #ddd;border-radius:4px;display:block;margin-bottom:.5rem;">
                <?php else: ?>
                    <p class="text-muted">Image par défaut :
                        <code><?= e((string) ($slot['default'] ?? '')) ?></code></p>
                <?php endif; ?>

                <label>Nouvelle image
                    <input type="file" name="image_file"
                           accept="image/jpeg,image/png,image/webp,image/gif"></label>
                <small class="text-muted">JPG/PNG/WebP/GIF — max <?= (int) $maxMb ?> Mo, converti en WebP.</small>

                <label>Ou URL externe
                    <input type="url" name="image_url" placeholder="https://..."></label>

                <label>Texte alternatif (accessibilité / SEO)
                    <input type="text" name="alt_text"
                           value="<?= e((string) ($cur['alt_text'] ?? ($slot['default_alt'] ?? ''))) ?>"></label>

                <div style="margin-top:.5rem;">
                    <button type="submit" class="btn btn-primary">Enregistrer</button>
                    <?php if ($cur !== null): ?>
                        <label style="margin-left:1rem;">
                            <input type="checkbox" name="image_delete" value="1">
                            Revenir à l'image d'origine
                        </label>
                    <?php endif; ?>
                </div>
            </form>

        <?php else: /* collection */ ?>
            <?php if ($rows !== []): ?>
                <form method="post" action="/admin/settings/medias/collection/order">
                    <?= csrf_field() ?>
                    <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                    <ul style="list-style:none;padding:0;">
                        <?php foreach ($rows as $row): ?>
                            <li style="display:flex;align-items:center;gap:.75rem;margin-bottom:.5rem;">
                                <input type="hidden" name="order[]" value="<?= (int) $row['override_ID'] ?>">
                                <img src="<?= e($row['image_url']) ?>" alt=""
                                     style="width:96px;height:64px;object-fit:cover;border:1px solid #ddd;border-radius:4px;">
                                <input type="number" name="pos_display" value="<?= (int) $row['position'] ?>"
                                       style="width:4rem;" disabled title="Position (réordonnez avec les flèches)">
                                <span class="text-muted"><?= e((string) ($row['alt_text'] ?? '')) ?></span>
                            </li>
                        <?php endforeach; ?>
                    </ul>
                    <button type="submit" class="btn btn-secondary">Enregistrer l'ordre</button>
                    <small class="text-muted">L'ordre suit l'ordre des lignes ci-dessus.</small>
                </form>

                <ul style="list-style:none;padding:0;margin-top:.75rem;">
                    <?php foreach ($rows as $row): ?>
                        <li style="display:inline-block;margin:0 .5rem .5rem 0;">
                            <form method="post" action="/admin/settings/medias/collection/del"
                                  onsubmit="return confirm('Retirer cette image ?');" style="display:inline;">
                                <?= csrf_field() ?>
                                <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                                <input type="hidden" name="override_ID" value="<?= (int) $row['override_ID'] ?>">
                                <button type="submit" class="btn btn-danger btn-sm">Retirer #<?= (int) $row['override_ID'] ?></button>
                            </form>
                        </li>
                    <?php endforeach; ?>
                </ul>
            <?php else: ?>
                <p class="text-muted">Aucune image personnalisée — la galerie affiche les
                    <?= (int) count($slot['defaults'] ?? []) ?> images par défaut.</p>
            <?php endif; ?>

            <form method="post" action="/admin/settings/medias/collection/add" enctype="multipart/form-data"
                  style="margin-top:1rem;">
                <?= csrf_field() ?>
                <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                <label>Ajouter une image
                    <input type="file" name="image_file"
                           accept="image/jpeg,image/png,image/webp,image/gif"></label>
                <label>Ou URL externe
                    <input type="url" name="image_url" placeholder="https://..."></label>
                <label>Texte alternatif
                    <input type="text" name="alt_text"></label>
                <button type="submit" class="btn btn-primary">Ajouter</button>
            </form>

            <?php if ($rows !== []): ?>
                <form method="post" action="/admin/settings/medias/revert"
                      onsubmit="return confirm('Revenir aux images d\'origine ? Les images importées seront supprimées.');"
                      style="margin-top:.75rem;">
                    <?= csrf_field() ?>
                    <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                    <button type="submit" class="btn btn-secondary">Revenir aux images d'origine</button>
                </form>
            <?php endif; ?>
        <?php endif; ?>
    </fieldset>
<?php endforeach; ?>
```

- [ ] **Step 3: Vérifier le rendu**

Run: `composer serve` puis visiter `/admin/settings/medias` et `/admin/settings/medias/page?key=emerald.home`
Expected: la liste des pages s'affiche ; la page emerald.home montre le slot « Carrousel principal » avec formulaire d'ajout.

- [ ] **Step 4: Commit**

```bash
git add views/pages/admin/medias/index.php views/pages/admin/medias/page.php
git commit -m "feat(medias): vues admin index + édition des slots"
```

---

## Task 11: Migration des templates vers les helpers (carrousel accueil = pilote)

Transformation mécanique : remplacer chaque chemin codé par un appel `media*()` **en
gardant l'ancien chemin comme défaut**. On traite ici l'accueil emerald comme exemple
complet, puis on applique le **même motif** à chaque entrée de `docs/audit-medias-pages.md`.

**Files:**
- Modify: `views/pages/emerald/home.php:7-18` (et boucles lignes 26-41)

- [ ] **Step 1: Remplacer le tableau `$slides` par `media_list()`**

Remplacer le bloc `$slides = [ ... ];` (lignes ~7-18) par :

```php
<?php
$slides = media_list('emerald.home.carousel', [
    ['url' => '/assets/img/emerald/slide-1.jpg',  'alt' => 'Forêt de pins'],
    ['url' => '/assets/img/emerald/slide-2.jpg',  'alt' => 'Brume en forêt'],
    ['url' => '/assets/img/emerald/slide-3.jpg',  'alt' => 'Canopée'],
    ['url' => '/assets/img/emerald/slide-4.jpg',  'alt' => 'Fougères'],
    ['url' => '/assets/img/emerald/slide-5.jpg',  'alt' => 'Forêt moussue'],
    ['url' => '/assets/img/emerald/slide-6.jpg',  'alt' => 'Brouillard en forêt'],
    ['url' => '/assets/img/emerald/slide-7.jpg',  'alt' => 'Lac calme'],
    ['url' => '/assets/img/emerald/slide-8.jpg',  'alt' => 'Forêt zen'],
    ['url' => '/assets/img/emerald/slide-9.jpg',  'alt' => 'Montagne dans la brume'],
    ['url' => '/assets/img/emerald/slide-10.jpg', 'alt' => 'Paysage automnal'],
]);
?>
```

- [ ] **Step 2: Adapter les deux boucles d'affichage**

Les boucles utilisent désormais `$slide['url']` (l'ancien `$url` devient `$slide['url']`).
Remplacer les deux occurrences (lignes ~26-29 et ~38-41) par :

```php
<?php foreach ($slides as $i => $slide): ?>
    <div class="em-carousel-slide em-carousel-slide-<?= $i + 1 ?>"
         style="background-image: url('<?= e($slide['url']) ?>');"
         role="img" aria-label="<?= e($slide['alt']) ?>"></div>
<?php endforeach; ?>
```

> Note accessibilité : le conteneur portait `aria-hidden="true"`. Comme on ajoute
> maintenant un `aria-label` par slide, retirer `aria-hidden="true"` du `.em-carousel`
> parent **uniquement si** ces images véhiculent du sens ; sinon (purement décoratif)
> garder `aria-hidden` et omettre `role/aria-label`. Décision par défaut : garder
> décoratif (laisser `aria-hidden`, ne pas ajouter `role/aria-label`) pour ne pas
> changer le comportement lecteur d'écran. Adapter le Step 2 en retirant
> `role`/`aria-label` si on garde décoratif.

- [ ] **Step 3: Vérifier le rendu accueil (BDD vide → défauts)**

Run: `composer serve` puis visiter `http://localhost:8000/?theme=emerald`
Expected: carrousel identique à avant (10 slides par défaut), aucune erreur.

- [ ] **Step 4: Appliquer le même motif aux autres entrées de l'audit**

Pour chaque ligne de `docs/audit-medias-pages.md` non encore traitée :
- `single` → `<?= e(media('<slotKey>', '<chemin défaut>')) ?>` (+ `media_alt()` si `<img alt>`).
- `collection` → `media_list('<slotKey>', [ … défauts … ])` + boucle sur `$item['url']` / `$item['alt']`.

- [ ] **Step 5: Recette manuelle complète avec BDD**

Migration 033 appliquée. En admin : importer une image sur `emerald.home.carousel`
(collection : ajouter), vérifier qu'elle apparaît sur l'accueil ; tester suppression,
réordonnancement, « revenir aux images d'origine ». Importer une image sur un slot `single`,
vérifier le rendu et le revert.
Expected: comportements conformes aux critères d'acceptation §8 de la spec.

- [ ] **Step 6: Commit**

```bash
git add views/pages/emerald/home.php
git commit -m "feat(medias): migration du carrousel accueil vers media_list()"
# puis un commit par page migrée
```

---

## Task 12: Section « Médias d'entités » (vue agrégée, colonnes existantes)

Édition centralisée des images de fiches (salles, profs, événements) **via les repositories
existants** — pas de nouveau stockage. Réutilise le trait `HandlesPhotoUpload`.

**Files:**
- Modify: `src/Controllers/Admin/MediaController.php` (méthode `entityImage()` + données dans `index()`)
- Modify: `views/pages/admin/medias/index.php` (section entités)
- Modify: `config/routes.php` (route `POST /admin/settings/medias/entity`)

- [ ] **Step 1: Étendre `MediaController` avec la section entités**

Ajouter le trait et la méthode dans `MediaController` :

```php
    use HandlesPhotoUpload; // en haut de la classe, avec les autres `use`

    /**
     * Édite l'image d'une fiche entité existante (salle/prof/évènement) en écrivant
     * dans sa colonne image via le repository dédié. Pas de t_media_overrides ici.
     */
    public function entityImage(): void
    {
        $type = (string) ($_POST['entity_type'] ?? '');
        $id   = (int) ($_POST['entity_ID'] ?? 0);

        // Mappe le type vers (repository, entité upload, colonne image, slug).
        $map = [
            'rooms'    => [\App\Repositories\RoomRepository::class,    'rooms',    'photo_url'],
            'teachers' => [\App\Repositories\TeacherRepository::class, 'teachers', 'photo_url'],
            'events'   => [\App\Repositories\EventRepository::class,   'events',   'image_url'],
        ];
        if (!isset($map[$type]) || $id <= 0) {
            flash('error', 'Entité inconnue.');
            redirect('/admin/settings/medias');
        }
        [$repoClass, $entity, $col] = $map[$type];
        $repo     = new $repoClass();
        $existing = $repo->findByID($id);
        if ($existing === null) {
            flash('error', 'Fiche introuvable.');
            redirect('/admin/settings/medias');
        }
        try {
            $newUrl = $this->resolvePhotoField(
                $entity,
                $existing[$col] ?? null,
                (string) ($existing['slug'] ?? (string) $id),
                'image_file',
                'image_url',
                'image_delete'
            );
            $repo->update($id, [$col => $newUrl] + $existing);
            flash('success', 'Image de la fiche mise à jour.');
        } catch (RuntimeException $e) {
            flash('error', 'Image : ' . $e->getMessage());
        }
        redirect('/admin/settings/medias');
    }
```

> ⚠ Vérifier les signatures réelles de `update()` de chaque repository avant d'écrire ce
> Step. Si `update()` exige un tableau complet de colonnes (cas de `PracticeRepository`),
> passer `array_merge($existing, [$col => $newUrl])` filtré sur les colonnes attendues, ou
> ajouter une méthode `updateImage(int $id, ?string $url)` dédiée à chaque repo. Choisir la
> méthode dédiée si `update()` n'accepte pas un sous-ensemble (plus sûr). Run d'abord :
> `grep -n "function update" src/Repositories/RoomRepository.php src/Repositories/TeacherRepository.php src/Repositories/EventRepository.php`

- [ ] **Step 2: Ajouter `updateImage()` aux repos si nécessaire**

Si `update()` n'accepte pas un sous-ensemble de colonnes, ajouter à chaque repo concerné :

```php
    public function updateImage(int $id, ?string $url): void
    {
        // Adapter le nom de la colonne et de la PK à l'entité.
        $this->pdo->prepare('UPDATE t_rooms SET photo_url = ? WHERE room_ID = ?')
                  ->execute([$url, $id]);
    }
```

…et appeler `$repo->updateImage($id, $newUrl)` au lieu de `$repo->update(...)` dans `entityImage()`.

- [ ] **Step 3: Charger les entités dans `index()` et afficher la section**

Dans `MediaController::index()`, enrichir les données passées à la vue :

```php
        $entities = [
            'rooms'    => (new \App\Repositories\RoomRepository())->listAll(),
            'teachers' => (new \App\Repositories\TeacherRepository())->listAll(),
            'events'   => (new \App\Repositories\EventRepository())->listAll(),
        ];
        echo view('layouts.admin', [
            'title'       => 'Médias des pages',
            'currentPath' => '/admin/settings',
            'content'     => view('pages.admin.medias.index', [
                'registry' => $this->registry(),
                'entities' => $entities,
            ]),
        ]);
```

> Vérifier le nom réel de la méthode de listing de chaque repo (`listAll()` vs autre) :
> `grep -n "function list" src/Repositories/RoomRepository.php src/Repositories/TeacherRepository.php src/Repositories/EventRepository.php`. Aligner les colonnes nom/image affichées.

Dans `views/pages/admin/medias/index.php`, ajouter après la grille des pages :

```php
<h2 style="margin-top:2.5rem;">Médias des fiches (salles, profs, évènements)</h2>
<p class="text-muted">Ces images sont liées aux fiches et modifiables aussi depuis leurs
écrans respectifs.</p>

<?php
$entityLabels = ['rooms' => 'Salles', 'teachers' => 'Profs', 'events' => 'Évènements'];
$entityCols   = ['rooms' => 'photo_url', 'teachers' => 'photo_url', 'events' => 'image_url'];
$entityPk     = ['rooms' => 'room_ID', 'teachers' => 'teacher_ID', 'events' => 'event_ID'];
$maxMb        = \App\Services\ImageUploadService::MAX_BYTES >> 20;
?>
<?php foreach (($entities ?? []) as $type => $rows): ?>
    <h3><?= e($entityLabels[$type] ?? $type) ?></h3>
    <?php foreach ($rows as $row): ?>
        <?php $col = $entityCols[$type]; $pk = $entityPk[$type]; ?>
        <form method="post" action="/admin/settings/medias/entity" enctype="multipart/form-data"
              style="display:flex;gap:.75rem;align-items:center;margin-bottom:.75rem;flex-wrap:wrap;">
            <?= csrf_field() ?>
            <input type="hidden" name="entity_type" value="<?= e((string) $type) ?>">
            <input type="hidden" name="entity_ID" value="<?= (int) $row[$pk] ?>">
            <strong style="min-width:12rem;"><?= e((string) ($row['name'] ?? $row['title'] ?? ('#' . $row[$pk]))) ?></strong>
            <?php if (!empty($row[$col])): ?>
                <img src="<?= e((string) $row[$col]) ?>" alt=""
                     style="width:96px;height:64px;object-fit:cover;border:1px solid #ddd;border-radius:4px;">
            <?php endif; ?>
            <input type="file" name="image_file" accept="image/jpeg,image/png,image/webp,image/gif">
            <button type="submit" class="btn btn-primary btn-sm">Remplacer</button>
            <?php if (!empty($row[$col])): ?>
                <label><input type="checkbox" name="image_delete" value="1"> Supprimer</label>
            <?php endif; ?>
        </form>
    <?php endforeach; ?>
<?php endforeach; ?>
```

> Vérifier les noms de colonnes affichées (`name`/`title`) et PK réels de chaque table via
> le dump SQL ou `INFORMATION_SCHEMA` avant de finaliser (cf. règle « jamais inventer une
> colonne »). Ajuster `$entityCols`, `$entityPk`, et le champ titre en conséquence.

- [ ] **Step 4: Ajouter la route**

Dans `config/routes.php`, ajouter aux routes médias :

```php
    'POST /admin/settings/medias/entity' => ['App\Controllers\Admin\MediaController', 'entityImage', ['middleware' => 'admin']],
```

- [ ] **Step 5: Recette manuelle**

Run: `composer serve`, visiter `/admin/settings/medias`, remplacer la photo d'une salle.
Expected: l'image change sur la fiche salle existante (même colonne, pas de doublon).

- [ ] **Step 6: Commit**

```bash
git add src/Controllers/Admin/MediaController.php views/pages/admin/medias/index.php config/routes.php src/Repositories/RoomRepository.php src/Repositories/TeacherRepository.php src/Repositories/EventRepository.php
git commit -m "feat(medias): section médias d'entités (vue agrégée sur colonnes existantes)"
```

---

## Task 13: Validation finale

- [ ] **Step 1: Suite de tests complète**

Run: `composer test`
Expected: tous verts (MediaResolverTest, MediaSlotsRegistryTest, ImageUploadServiceTest, + existants).

- [ ] **Step 2: Analyse statique + lint**

Run: `composer analyse && composer lint`
Expected: pas de nouvelle erreur sur les fichiers créés/modifiés.

- [ ] **Step 3: Revue des critères d'acceptation (spec §8)**

Cocher manuellement les 8 critères d'acceptation de la spec en parcourant l'admin et le site.

- [ ] **Step 4: RAPPEL migration prod**

⚠ Avant tout déploiement : appliquer `033_create_media_overrides.sql` sur la BDD de prod
(à la main, vu la limitation `migrate.php`) **avant** de tester l'admin médias en prod.

---

## Notes de cohérence (auto-revue)

- **Couverture spec :** résolution override→défaut (T2/T3), registre (T7), table (T1),
  repo CRUD (T4), boot graceful (T5), entité upload `pages` (T6), module admin pages
  (T8/T9/T10), migration templates (T11), médias d'entités (T12), CSRF global (toutes les
  vues utilisent `csrf_field()`), sécurité P7 réutilisée (T8 via `ImageUploadService`),
  tests (T2/T7), critères d'acceptation (T13).
- **Noms cohérents :** `MediaResolver::single/alt/list`, helpers `media/media_alt/media_list`,
  repo `loadAllGrouped/findBySlot/upsertSingle/addToCollection/deleteByID/deleteBySlot/reorder/imageUrlOf/imageUrlsOfSlot/updateAlt`.
- **Points à vérifier au fil de l'eau** (signalés inline) : format exact des lignes de
  `config/routes.php`, signatures `update()`/`listAll()` des repos d'entités, noms réels des
  colonnes/PK des tables `t_rooms`/`t_teachers`/`t_events` (règle « jamais inventer une colonne »).
```
