# Plugin WordPress « wp-back » — 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:** Plugin WordPress de backup/restore incrémental qui pousse des blobs content-addressed vers Wasabi via des presigned URLs émises par notre API SaaS.

**Architecture:** Le plugin scanne les fichiers par lots (Action Scheduler), hashe en sha256, uploade uniquement les blobs inconnus via presigned PUT, dumpe les tables DB modifiées (CHECKSUM TABLE), puis committe un manifest de snapshot en dernier. Le restore diffe le manifest cible contre le disque et ne télécharge que le nécessaire. Aucune clé S3 côté plugin : uniquement `site_id` + `site_token` (JWT) et des URLs éphémères.

**Tech Stack:** PHP 8.1+, Composer (PSR-4 `WpBack\` → `plugin/src/`), Action Scheduler (`woocommerce/action-scheduler`), PHPUnit 10 avec stubs WP maison (tests unitaires sans WordPress), wp-env pour l'intégration.

## Global Constraints

- Préfixe global `wpb_` (options, tables, hooks, actions AS) ; namespace PHP `WpBack\`.
- PHP >= 8.1 ; multisite **exclu en V1** (bail à l'activation).
- Jamais de credentials S3 nulle part — uniquement `wpb_site_id`, `wpb_site_token` en `wp_options` + presigned URLs éphémères.
- Tout écran admin : capability `manage_options`, nonces, sanitization en entrée, escaping en sortie.
- Aucune requête SQL non préparée (`$wpdb->prepare` partout où une valeur est injectée).
- Base URL API : constante `WPB_API_URL` (défaut `https://api.wp-back.dev`). Auth `Authorization: Bearer <site_token>` sauf `/v1/sites/activate`. Erreurs JSON `{error:{code,message}}` ; 401 → réactivation à proposer ; 402 → quota plein affiché à l'admin.
- Un backup interrompu ne committe JAMAIS de manifest (le commit snapshot est la dernière opération).
- Jamais supposer qu'une requête PHP survit : tout travail long est batché via Action Scheduler avec curseur/état persisté.
- Commits au format Conventional Commits ; TDD systématique (test rouge avant code).

**Contrat API v1 (source de vérité partagée avec le plan `api/`) :**

| Endpoint | Requête | Réponse |
|---|---|---|
| `POST /v1/sites/activate` | `{license_key, site_url}` | 201 `{site_id, site_token, plan:{storage_bytes_limit, retention_days}}` |
| `POST /v1/storage/blobs/sign-upload` | `{sha256, size_bytes}` | 200 `{status:"upload", upload_url, expires_at}` ou `{status:"exists"}` |
| `POST /v1/storage/blobs/confirm` | `{sha256}` | 200 `{status:"stored"}` ; 409 = taille discordante → ré-uploader |
| `POST /v1/storage/blobs/sign-download` | `{sha256}` | 200 `{download_url, expires_at}` |
| `POST /v1/snapshots/commit` | `{ts, manifest_sha256, stats:{files_total, bytes_total, files_changed, bytes_uploaded, db_tables}}` | 201 `{snapshot_id}` |
| `GET /v1/snapshots` | — | 200 `{snapshots:[{snapshot_id, ts, manifest_sha256, stats}]}` (desc) |
| `GET /v1/usage` | — | 200 `{storage_bytes_used, storage_bytes_limit, snapshot_count, retention_days}` |

**Format du manifest (JSON uploadé comme blob) :**

```json
{"version":1,"ts":"2026-07-23T12:00:00Z","wp":{"core":"6.6","php":"8.2"},
 "files":[{"p":"wp-content/themes/x/style.css","h":"<sha256>","s":1234,"m":1721730000}],
 "db":[{"table":"wp_posts","h":"<sha256 du dump gzip>","s":99999,"checksum":"123456"}]}
```

**Carte des fichiers du sous-système :**

```
plugin/
  wp-back.php                 # header plugin, constantes, bootstrap
  composer.json  phpunit.xml.dist  .wp-env.json
  src/
    Install.php               # activation : dbDelta, bail multisite
    Plugin.php                # câblage hooks (admin, Action Scheduler)
    Support/Paths.php         # chemins relatifs + exclusions
    Api/ApiException.php      # erreur typée {apiCode, status}
    Api/Client.php            # les 7 endpoints, retries
    Admin/SettingsPage.php    # licence, connexion, usage, planification
    Admin/SnapshotsPage.php   # liste snapshots + bouton Restore
    Backup/FileScanner.php    # scan batché, diff size+mtime
    Backup/Hasher.php         # sha256 streaming batché
    Backup/Uploader.php       # sign-upload → PUT → confirm
    Backup/DbDumper.php       # CHECKSUM TABLE + dump chunké gzip
    Backup/ManifestBuilder.php# manifest JSON + commit snapshot
    Backup/BackupRunner.php   # machine à états scan→hash→upload→db→manifest
    Restore/RestoreRunner.php # diff manifest → download → apply → import DB
  tests/
    bootstrap.php  stubs/wp-functions.php
    Unit/…                    # un fichier de test par classe
```

---

### Task 1: Scaffold du plugin (activation, schéma, harnais de tests)

**Files:**
- Create: `plugin/wp-back.php`
- Create: `plugin/composer.json`
- Create: `plugin/phpunit.xml.dist`
- Create: `plugin/.wp-env.json`
- Create: `plugin/src/Install.php`
- Create: `plugin/src/Plugin.php`
- Create: `plugin/tests/bootstrap.php`
- Create: `plugin/tests/stubs/wp-functions.php`
- Test: `plugin/tests/Unit/InstallTest.php`

**Interfaces:**
- Consumes: rien (première tâche).
- Produces: constantes `WPB_VERSION`, `WPB_PLUGIN_FILE`, `WPB_PLUGIN_DIR`, `WPB_API_URL` ; `WpBack\Install::activate(): void`, `WpBack\Install::schema(string $prefix): array` (liste de `CREATE TABLE`) ; `WpBack\Plugin::boot(): void` ; harnais de tests : `$GLOBALS['wpb_test']` (clés `options`, `is_multisite`, `http_queue`, `http_log`, `died`) réinitialisé par `wpb_test_reset()`.

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

```php
<?php // plugin/tests/Unit/InstallTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit;

use PHPUnit\Framework\TestCase;
use WpBack\Install;
use WpBack\Tests\WpDieException;

final class InstallTest extends TestCase
{
    protected function setUp(): void
    {
        wpb_test_reset();
    }

    public function test_schema_contains_files_and_jobs_tables(): void
    {
        $sql = Install::schema('wp_');
        $this->assertCount(2, $sql);
        $this->assertStringContainsString('CREATE TABLE wp_wpb_files', $sql[0]);
        $this->assertStringContainsString('path VARCHAR(768) NOT NULL', $sql[0]);
        $this->assertStringContainsString('sha256 CHAR(64)', $sql[0]);
        $this->assertStringContainsString('CREATE TABLE wp_wpb_jobs', $sql[1]);
        $this->assertStringContainsString('state LONGTEXT', $sql[1]);
    }

    public function test_activate_bails_on_multisite(): void
    {
        $GLOBALS['wpb_test']['is_multisite'] = true;
        $this->expectException(WpDieException::class);
        Install::activate();
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && composer install && vendor/bin/phpunit --filter InstallTest`
Expected : `Error: Class "WpBack\Install" not found` (après que composer install a réussi — créer d'abord `composer.json`, `phpunit.xml.dist`, `tests/bootstrap.php` et `tests/stubs/wp-functions.php` ci-dessous, sinon PHPUnit ne démarre pas).

```json
{
  "name": "wp-back/wp-back",
  "description": "Incremental backups for WordPress on managed cloud storage.",
  "license": "GPL-2.0-or-later",
  "require": { "php": ">=8.1", "woocommerce/action-scheduler": "^3.8" },
  "require-dev": { "phpunit/phpunit": "^10.5" },
  "autoload": { "psr-4": { "WpBack\\": "src/" } },
  "autoload-dev": { "psr-4": { "WpBack\\Tests\\": "tests/" } },
  "scripts": { "test": "phpunit" },
  "config": { "sort-packages": true }
}
```

```xml
<?xml version="1.0"?>
<!-- plugin/phpunit.xml.dist -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="tests/bootstrap.php" colors="true" cacheDirectory=".phpunit.cache">
  <testsuites>
    <testsuite name="unit"><directory>tests/Unit</directory></testsuite>
  </testsuites>
</phpunit>
```

```php
<?php // plugin/tests/bootstrap.php
declare(strict_types=1);

$root = sys_get_temp_dir() . '/wpb-test-abspath/';
if (!is_dir($root)) { mkdir($root, 0777, true); }
define('ABSPATH', $root);
define('WPB_API_URL', 'https://api.test.invalid');
define('WPB_VERSION', '0.1.0-test');
define('WPB_PLUGIN_DIR', dirname(__DIR__) . '/');

require __DIR__ . '/stubs/wp-functions.php';
require dirname(__DIR__) . '/vendor/autoload.php';

wpb_test_reset();
```

```php
<?php // plugin/tests/stubs/wp-functions.php
declare(strict_types=1);

namespace WpBack\Tests { final class WpDieException extends \RuntimeException {} }

namespace {

use WpBack\Tests\WpDieException;

function wpb_test_reset(): void
{
    $GLOBALS['wpb_test'] = [
        'options'      => [],
        'is_multisite' => false,
        'http_queue'   => [],   // réponses FIFO : ['code'=>int,'body'=>string] ou WP_Error
        'http_log'     => [],   // [['url'=>…,'args'=>…], …]
        'died'         => null,
        'actions'      => [],   // add_action() enregistrées
        'scheduled'    => [],   // as_enqueue_async_action() capturées
    ];
}

class WP_Error
{
    public function __construct(
        public string $code = '',
        public string $message = '',
        public mixed $data = null
    ) {}
    public function get_error_message(): string { return $this->message; }
}

function is_wp_error(mixed $t): bool { return $t instanceof WP_Error; }
function is_multisite(): bool { return $GLOBALS['wpb_test']['is_multisite']; }
function wp_die(string $m = ''): never { $GLOBALS['wpb_test']['died'] = $m; throw new WpDieException($m); }
function get_option(string $k, mixed $d = false): mixed { return $GLOBALS['wpb_test']['options'][$k] ?? $d; }
function update_option(string $k, mixed $v): bool { $GLOBALS['wpb_test']['options'][$k] = $v; return true; }
function delete_option(string $k): bool { unset($GLOBALS['wpb_test']['options'][$k]); return true; }
function wp_json_encode(mixed $v, int $f = 0): string|false { return json_encode($v, $f); }
function sanitize_text_field(string $s): string { return trim(strip_tags($s)); }
function sanitize_key(string $s): string { return strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $s)); }
function esc_html(string $s): string { return htmlspecialchars($s, ENT_QUOTES); }
function esc_attr(string $s): string { return htmlspecialchars($s, ENT_QUOTES); }
function esc_url(string $s): string { return $s; }
function __(string $s, string $d = 'default'): string { return $s; }
function esc_html__(string $s, string $d = 'default'): string { return esc_html($s); }
function add_action(string $h, callable $cb, int $p = 10, int $a = 1): void { $GLOBALS['wpb_test']['actions'][$h][] = $cb; }
function add_filter(string $h, callable $cb, int $p = 10, int $a = 1): void { $GLOBALS['wpb_test']['actions'][$h][] = $cb; }
function do_action(string $h, mixed ...$args): void { foreach ($GLOBALS['wpb_test']['actions'][$h] ?? [] as $cb) { $cb(...$args); } }
function home_url(string $p = ''): string { return 'https://example.test' . $p; }
function current_time(string $type): string { return gmdate('Y-m-d H:i:s'); }
function size_format(int|float $bytes): string { return $bytes . ' B'; }
function wp_generate_uuid4(): string { return 'test-uuid-4'; }
function dbDelta(array|string $sql): array { $GLOBALS['wpb_test']['dbdelta'][] = $sql; return []; }

function wp_remote_request(string $url, array $args = []): array|WP_Error
{
    $GLOBALS['wpb_test']['http_log'][] = ['url' => $url, 'args' => $args];
    $next = array_shift($GLOBALS['wpb_test']['http_queue']);
    if ($next === null) { return new WP_Error('http_no_stub', 'No stubbed response for ' . $url); }
    return $next instanceof WP_Error ? $next : ['response' => ['code' => $next['code']], 'body' => $next['body']];
}
function wp_remote_get(string $url, array $args = []): array|WP_Error { return wp_remote_request($url, $args + ['method' => 'GET']); }
function wp_remote_post(string $url, array $args = []): array|WP_Error { return wp_remote_request($url, $args + ['method' => 'POST']); }
function wp_remote_retrieve_response_code(array|WP_Error $r): int { return is_wp_error($r) ? 0 : (int) $r['response']['code']; }
function wp_remote_retrieve_body(array|WP_Error $r): string { return is_wp_error($r) ? '' : $r['body']; }

function as_enqueue_async_action(string $hook, array $args = [], string $group = ''): int
{
    $GLOBALS['wpb_test']['scheduled'][] = ['hook' => $hook, 'args' => $args, 'group' => $group];
    return count($GLOBALS['wpb_test']['scheduled']);
}
function as_schedule_recurring_action(int $ts, int $interval, string $hook, array $args = [], string $group = ''): int
{
    $GLOBALS['wpb_test']['scheduled'][] = ['hook' => $hook, 'args' => $args, 'group' => $group, 'interval' => $interval];
    return count($GLOBALS['wpb_test']['scheduled']);
}
function as_unschedule_all_actions(string $hook, array $args = [], string $group = ''): void
{
    $GLOBALS['wpb_test']['scheduled'] = array_values(array_filter(
        $GLOBALS['wpb_test']['scheduled'],
        static fn(array $s): bool => $s['hook'] !== $hook
    ));
}

} // namespace global
```

```json
{
  "core": null,
  "phpVersion": "8.2",
  "plugins": ["."]
}
```
(`plugin/.wp-env.json` — `npx wp-env start` monte le plugin pour les tests manuels d'intégration.)

- [ ] **Step 3: Implémentation minimale**

```php
<?php // plugin/wp-back.php
/**
 * Plugin Name: WP Back
 * Description: Incremental backups and one-click restores to managed cloud storage.
 * Version: 0.1.0
 * Requires PHP: 8.1
 * Requires at least: 6.4
 * License: GPL-2.0-or-later
 * Text Domain: wp-back
 */

if ( ! defined( 'ABSPATH' ) ) { exit; }

define( 'WPB_VERSION', '0.1.0' );
define( 'WPB_PLUGIN_FILE', __FILE__ );
define( 'WPB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
if ( ! defined( 'WPB_API_URL' ) ) {
	define( 'WPB_API_URL', 'https://api.wp-back.dev' );
}

require_once WPB_PLUGIN_DIR . 'vendor/autoload.php';
require_once WPB_PLUGIN_DIR . 'vendor/woocommerce/action-scheduler/action-scheduler.php';

register_activation_hook( __FILE__, [ WpBack\Install::class, 'activate' ] );

add_action( 'plugins_loaded', static function (): void {
	( new WpBack\Plugin() )->boot();
} );
```

```php
<?php // plugin/src/Install.php
declare(strict_types=1);

namespace WpBack;

final class Install
{
    /** @return string[] Les CREATE TABLE pour dbDelta. */
    public static function schema(string $prefix): array
    {
        $files = "CREATE TABLE {$prefix}wpb_files (
path VARCHAR(768) NOT NULL,
size BIGINT UNSIGNED NOT NULL DEFAULT 0,
mtime BIGINT UNSIGNED NOT NULL DEFAULT 0,
sha256 CHAR(64) DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'new',
PRIMARY KEY  (path(191)),
KEY status (status)
)";
        $jobs = "CREATE TABLE {$prefix}wpb_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
type VARCHAR(30) NOT NULL,
state LONGTEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
PRIMARY KEY  (id),
KEY type_status (type,status)
)";
        return [$files, $jobs];
    }

    public static function activate(): void
    {
        if (is_multisite()) {
            wp_die('WP Back does not support WordPress multisite yet. Please use a single-site install.');
        }
        global $wpdb;
        $prefix = isset($wpdb) ? $wpdb->prefix : 'wp_';
        if (!function_exists('dbDelta')) {
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        }
        foreach (self::schema($prefix) as $sql) {
            dbDelta($sql);
        }
        update_option('wpb_version', WPB_VERSION);
    }
}
```

```php
<?php // plugin/src/Plugin.php
declare(strict_types=1);

namespace WpBack;

final class Plugin
{
    public function boot(): void
    {
        // Les tâches suivantes brancheront ici : admin pages, hooks Action Scheduler.
    }
}
```

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit --filter InstallTest`
Expected : `OK (2 tests)`

- [ ] **Step 5: Commit**

```bash
git add plugin/
git commit -m "feat(plugin): scaffold wp-back (activation, schema, test harness)"
```

---

### Task 2: Client API (`WpBack\Api\Client`)

**Files:**
- Create: `plugin/src/Api/ApiException.php`
- Create: `plugin/src/Api/Client.php`
- Test: `plugin/tests/Unit/Api/ClientTest.php`

**Interfaces:**
- Consumes: constantes `WPB_API_URL` ; options `wpb_site_token` (Task 3 les écrit ; ici lues via `get_option`).
- Produces:
  - `ApiException extends \RuntimeException` — propriétés publiques readonly `string $apiCode`, `int $status`.
  - `Client::__construct(?string $baseUrl = null)` (défaut `WPB_API_URL`).
  - `Client::activate(string $licenseKey, string $siteUrl): array` — retourne le JSON décodé `{site_id, site_token, plan}`.
  - `Client::signUpload(string $sha256, int $sizeBytes): array`
  - `Client::confirmBlob(string $sha256): array` — lève `ApiException` avec `apiCode='size_mismatch'` sur 409.
  - `Client::signDownload(string $sha256): array`
  - `Client::commitSnapshot(string $ts, string $manifestSha256, array $stats): array`
  - `Client::listSnapshots(): array` — retourne `$json['snapshots']`.
  - `Client::usage(): array`
  - Retries : 3 tentatives sur 5xx et `WP_Error` réseau, backoff `sleep(1)`, `sleep(2)` (injectable via `Client::$sleeper` callable pour les tests). Pas de retry sur 4xx.

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Unit/Api/ClientTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Api;

use PHPUnit\Framework\TestCase;
use WpBack\Api\ApiException;
use WpBack\Api\Client;

final class ClientTest extends TestCase
{
    private Client $client;

    protected function setUp(): void
    {
        wpb_test_reset();
        update_option('wpb_site_token', 'jwt-token-x');
        $this->client = new Client('https://api.test.invalid');
        Client::$sleeper = static fn(int $s) => null; // pas de vrai sleep en test
    }

    public function test_activate_posts_license_and_returns_json(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 201, 'body' => json_encode([
            'site_id' => 'site_1', 'site_token' => 'jwt', 'plan' => ['storage_bytes_limit' => 100, 'retention_days' => 30],
        ])];
        $out = $this->client->activate('LIC-123', 'https://example.test');
        $this->assertSame('site_1', $out['site_id']);
        $req = $GLOBALS['wpb_test']['http_log'][0];
        $this->assertSame('https://api.test.invalid/v1/sites/activate', $req['url']);
        $this->assertSame('POST', $req['args']['method']);
        $this->assertArrayNotHasKey('Authorization', $req['args']['headers']); // activate = sans Bearer
        $this->assertSame(['license_key' => 'LIC-123', 'site_url' => 'https://example.test'], json_decode($req['args']['body'], true));
    }

    public function test_sign_upload_sends_bearer_token(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'exists'])];
        $out = $this->client->signUpload(str_repeat('a', 64), 123);
        $this->assertSame('exists', $out['status']);
        $this->assertSame('Bearer jwt-token-x', $GLOBALS['wpb_test']['http_log'][0]['args']['headers']['Authorization']);
    }

    public function test_4xx_raises_typed_exception_without_retry(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 402, 'body' => json_encode(['error' => ['code' => 'quota_exceeded', 'message' => 'Plan full']])];
        try {
            $this->client->usage();
            $this->fail('expected ApiException');
        } catch (ApiException $e) {
            $this->assertSame('quota_exceeded', $e->apiCode);
            $this->assertSame(402, $e->status);
            $this->assertCount(1, $GLOBALS['wpb_test']['http_log']); // pas de retry sur 4xx
        }
    }

    public function test_5xx_is_retried_then_succeeds(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 500, 'body' => 'boom'];
        $GLOBALS['wpb_test']['http_queue'][] = new \WP_Error('http_request_failed', 'timeout');
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['storage_bytes_used' => 5])];
        $out = $this->client->usage();
        $this->assertSame(5, $out['storage_bytes_used']);
        $this->assertCount(3, $GLOBALS['wpb_test']['http_log']);
    }

    public function test_confirm_blob_409_maps_to_size_mismatch(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 409, 'body' => json_encode(['error' => ['code' => 'size_mismatch', 'message' => 'bad size']])];
        $this->expectException(ApiException::class);
        $this->client->confirmBlob(str_repeat('b', 64));
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter ClientTest`
Expected : `Error: Class "WpBack\Api\Client" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Api/ApiException.php
declare(strict_types=1);

namespace WpBack\Api;

final class ApiException extends \RuntimeException
{
    public function __construct(
        public readonly string $apiCode,
        string $message,
        public readonly int $status
    ) {
        parent::__construct($message, $status);
    }
}
```

```php
<?php // plugin/src/Api/Client.php
declare(strict_types=1);

namespace WpBack\Api;

final class Client
{
    private const MAX_ATTEMPTS = 3;

    /** @var callable(int):void Remplaçable en test pour éviter les vrais sleep. */
    public static $sleeper = 'sleep';

    public function __construct(private ?string $baseUrl = null)
    {
        $this->baseUrl = rtrim($baseUrl ?? WPB_API_URL, '/');
    }

    public function activate(string $licenseKey, string $siteUrl): array
    {
        return $this->request('POST', '/v1/sites/activate', ['license_key' => $licenseKey, 'site_url' => $siteUrl], withAuth: false);
    }

    public function signUpload(string $sha256, int $sizeBytes): array
    {
        return $this->request('POST', '/v1/storage/blobs/sign-upload', ['sha256' => $sha256, 'size_bytes' => $sizeBytes]);
    }

    public function confirmBlob(string $sha256): array
    {
        return $this->request('POST', '/v1/storage/blobs/confirm', ['sha256' => $sha256]);
    }

    public function signDownload(string $sha256): array
    {
        return $this->request('POST', '/v1/storage/blobs/sign-download', ['sha256' => $sha256]);
    }

    public function commitSnapshot(string $ts, string $manifestSha256, array $stats): array
    {
        return $this->request('POST', '/v1/snapshots/commit', ['ts' => $ts, 'manifest_sha256' => $manifestSha256, 'stats' => $stats]);
    }

    public function listSnapshots(): array
    {
        return $this->request('GET', '/v1/snapshots')['snapshots'] ?? [];
    }

    public function usage(): array
    {
        return $this->request('GET', '/v1/usage');
    }

    private function request(string $method, string $path, ?array $body = null, bool $withAuth = true): array
    {
        $headers = ['Content-Type' => 'application/json'];
        if ($withAuth) {
            $headers['Authorization'] = 'Bearer ' . (string) get_option('wpb_site_token', '');
        }
        $args = ['method' => $method, 'headers' => $headers, 'timeout' => 30];
        if ($body !== null) {
            $args['body'] = wp_json_encode($body);
        }

        $lastError = 'unknown';
        for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
            $res = wp_remote_request($this->baseUrl . $path, $args);
            if (is_wp_error($res)) {
                $lastError = $res->get_error_message();
            } else {
                $code = wp_remote_retrieve_response_code($res);
                $json = json_decode(wp_remote_retrieve_body($res), true) ?: [];
                if ($code >= 200 && $code < 300) {
                    return $json;
                }
                if ($code < 500) { // 4xx : erreur définitive typée, pas de retry
                    throw new ApiException(
                        (string) ($json['error']['code'] ?? 'http_' . $code),
                        (string) ($json['error']['message'] ?? 'API error'),
                        $code
                    );
                }
                $lastError = 'HTTP ' . $code;
            }
            if ($attempt < self::MAX_ATTEMPTS) {
                (self::$sleeper)($attempt); // backoff 1s puis 2s
            }
        }
        throw new ApiException('network_error', 'API unreachable after retries: ' . $lastError, 0);
    }
}
```

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit --filter ClientTest`
Expected : `OK (5 tests)`

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Api plugin/tests/Unit/Api
git commit -m "feat(plugin): typed API client with retries for the 7 v1 endpoints"
```

---

### Task 3: Support/Paths + page réglages (licence & activation)

**Files:**
- Create: `plugin/src/Support/Paths.php`
- Create: `plugin/src/Admin/SettingsPage.php`
- Modify: `plugin/src/Plugin.php` (câblage du menu admin)
- Test: `plugin/tests/Unit/Support/PathsTest.php`
- Test: `plugin/tests/Unit/Admin/SettingsPageTest.php`

**Interfaces:**
- Consumes: `WpBack\Api\Client` (Task 2), `ApiException`, options `wpb_site_id`/`wpb_site_token`/`wpb_plan`.
- Produces:
  - `Paths::relative(string $abs): string` — chemin relatif à `ABSPATH`, séparateurs `/`.
  - `Paths::absolute(string $rel): string`.
  - `Paths::isExcluded(string $rel): bool` — exclusions par défaut + option `wpb_custom_exclusions` (array de préfixes relatifs).
  - `Paths::tmpDir(): string` — `WP_CONTENT_DIR . '/wpb-tmp/'` créé si absent (avec `index.php` vide).
  - `SettingsPage::register(): void` (hooks `admin_menu` + `admin_post_wpb_activate`).
  - `SettingsPage::handleActivate(Client $client, array $post): array{ok: bool, message: string}` — logique pure testable : vérifie nonce hors de cette méthode (dans le handler WP), appelle `activate()`, persiste `wpb_site_id`, `wpb_site_token`, `wpb_plan`.

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Unit/Support/PathsTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Support;

use PHPUnit\Framework\TestCase;
use WpBack\Support\Paths;

final class PathsTest extends TestCase
{
    protected function setUp(): void { wpb_test_reset(); }

    public function test_relative_strips_abspath_and_normalizes_slashes(): void
    {
        $abs = ABSPATH . 'wp-content/themes/x/style.css';
        $this->assertSame('wp-content/themes/x/style.css', Paths::relative($abs));
        $this->assertSame($abs, Paths::absolute('wp-content/themes/x/style.css'));
    }

    public function test_default_exclusions(): void
    {
        $this->assertTrue(Paths::isExcluded('wp-content/cache/page.html'));
        $this->assertTrue(Paths::isExcluded('wp-content/upgrade/core.zip'));
        $this->assertTrue(Paths::isExcluded('wp-content/wpb-tmp/blob'));
        $this->assertTrue(Paths::isExcluded('wp-content/debug.log'));
        $this->assertTrue(Paths::isExcluded('wp-content/plugins/x/node_modules/y.js'));
        $this->assertFalse(Paths::isExcluded('wp-content/themes/x/style.css'));
        $this->assertFalse(Paths::isExcluded('wp-config.php'));
    }

    public function test_custom_exclusions_from_option(): void
    {
        update_option('wpb_custom_exclusions', ['wp-content/uploads/backups']);
        $this->assertTrue(Paths::isExcluded('wp-content/uploads/backups/old.zip'));
        $this->assertFalse(Paths::isExcluded('wp-content/uploads/2026/photo.jpg'));
    }
}
```

```php
<?php // plugin/tests/Unit/Admin/SettingsPageTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Admin;

use PHPUnit\Framework\TestCase;
use WpBack\Admin\SettingsPage;
use WpBack\Api\Client;

final class SettingsPageTest extends TestCase
{
    protected function setUp(): void { wpb_test_reset(); }

    public function test_handle_activate_persists_site_credentials(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 201, 'body' => json_encode([
            'site_id' => 'site_9', 'site_token' => 'jwt-9',
            'plan' => ['storage_bytes_limit' => 5000, 'retention_days' => 30],
        ])];
        $out = SettingsPage::handleActivate(new Client('https://api.test.invalid'), ['wpb_license_key' => '  LIC-9  ']);
        $this->assertTrue($out['ok']);
        $this->assertSame('site_9', get_option('wpb_site_id'));
        $this->assertSame('jwt-9', get_option('wpb_site_token'));
        $this->assertSame(30, get_option('wpb_plan')['retention_days']);
        // La licence est envoyée trimée (sanitize_text_field)
        $body = json_decode($GLOBALS['wpb_test']['http_log'][0]['args']['body'], true);
        $this->assertSame('LIC-9', $body['license_key']);
    }

    public function test_handle_activate_reports_api_error_without_persisting(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 403, 'body' => json_encode(['error' => ['code' => 'invalid_license', 'message' => 'Unknown license']])];
        $out = SettingsPage::handleActivate(new Client('https://api.test.invalid'), ['wpb_license_key' => 'BAD']);
        $this->assertFalse($out['ok']);
        $this->assertStringContainsString('Unknown license', $out['message']);
        $this->assertFalse(get_option('wpb_site_id'));
    }

    public function test_handle_activate_rejects_empty_key(): void
    {
        $out = SettingsPage::handleActivate(new Client('https://api.test.invalid'), ['wpb_license_key' => '   ']);
        $this->assertFalse($out['ok']);
        $this->assertCount(0, $GLOBALS['wpb_test']['http_log']);
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter 'PathsTest|SettingsPageTest'`
Expected : `Error: Class "WpBack\Support\Paths" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Support/Paths.php
declare(strict_types=1);

namespace WpBack\Support;

final class Paths
{
    /** Préfixes relatifs toujours exclus du backup. */
    private const EXCLUDED_PREFIXES = [
        'wp-content/cache/',
        'wp-content/upgrade/',
        'wp-content/wpb-tmp/',
    ];

    public static function relative(string $abs): string
    {
        $abs = str_replace('\\', '/', $abs);
        $root = str_replace('\\', '/', ABSPATH);
        return ltrim(str_starts_with($abs, $root) ? substr($abs, strlen($root)) : $abs, '/');
    }

    public static function absolute(string $rel): string
    {
        return rtrim(ABSPATH, '/') . '/' . ltrim($rel, '/');
    }

    public static function isExcluded(string $rel): bool
    {
        foreach (self::EXCLUDED_PREFIXES as $prefix) {
            if (str_starts_with($rel, $prefix)) { return true; }
        }
        if (str_ends_with($rel, '.log')) { return true; }
        if (str_contains($rel, '/node_modules/')) { return true; }
        foreach ((array) get_option('wpb_custom_exclusions', []) as $prefix) {
            $prefix = rtrim((string) $prefix, '/') . '/';
            if (str_starts_with($rel . '/', $prefix) || str_starts_with($rel, $prefix)) { return true; }
        }
        return false;
    }

    public static function tmpDir(): string
    {
        $dir = rtrim(defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : ABSPATH . 'wp-content', '/') . '/wpb-tmp/';
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
            file_put_contents($dir . 'index.php', "<?php // Silence.\n");
        }
        return $dir;
    }
}
```

```php
<?php // plugin/src/Admin/SettingsPage.php
declare(strict_types=1);

namespace WpBack\Admin;

use WpBack\Api\ApiException;
use WpBack\Api\Client;

final class SettingsPage
{
    public static function register(): void
    {
        add_action('admin_menu', static function (): void {
            add_menu_page('WP Back', 'WP Back', 'manage_options', 'wp-back', [self::class, 'render'], 'dashicons-backup');
        });
        add_action('admin_post_wpb_activate', [self::class, 'onActivatePost']);
    }

    /** Handler WP : nonce + capability, puis délégation à la logique pure. */
    public static function onActivatePost(): void
    {
        if (!current_user_can('manage_options')) { wp_die('Forbidden'); }
        check_admin_referer('wpb_activate');
        $result = self::handleActivate(new Client(), wp_unslash($_POST));
        set_transient('wpb_notice', $result, 60);
        wp_safe_redirect(admin_url('admin.php?page=wp-back'));
        exit;
    }

    /** @return array{ok: bool, message: string} */
    public static function handleActivate(Client $client, array $post): array
    {
        $license = sanitize_text_field((string) ($post['wpb_license_key'] ?? ''));
        if ($license === '') {
            return ['ok' => false, 'message' => 'License key is required.'];
        }
        try {
            $out = $client->activate($license, home_url());
        } catch (ApiException $e) {
            return ['ok' => false, 'message' => 'Activation failed: ' . $e->getMessage()];
        }
        update_option('wpb_site_id', (string) $out['site_id']);
        update_option('wpb_site_token', (string) $out['site_token']);
        update_option('wpb_plan', (array) $out['plan']);
        return ['ok' => true, 'message' => 'Site activated.'];
    }

    public static function render(): void
    {
        $siteId = (string) get_option('wpb_site_id', '');
        echo '<div class="wrap"><h1>' . esc_html__('WP Back', 'wp-back') . '</h1>';
        if ($siteId === '') {
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            wp_nonce_field('wpb_activate');
            echo '<input type="hidden" name="action" value="wpb_activate" />';
            echo '<p><label>' . esc_html__('License key', 'wp-back') . ' ';
            echo '<input type="text" name="wpb_license_key" class="regular-text" required /></label></p>';
            submit_button(__('Activate', 'wp-back'));
            echo '</form>';
        } else {
            echo '<p>' . esc_html__('Connected. Site ID:', 'wp-back') . ' <code>' . esc_html($siteId) . '</code></p>';
            // Les tâches 9/10/12 ajoutent ici : bouton Backup now, usage, planification.
        }
        echo '</div>';
    }
}
```

Ajouter au harnais de stubs (`plugin/tests/stubs/wp-functions.php`, dans le namespace global — nécessaires à `render()`/`onActivatePost()` même si non couverts unitairement) :

```php
function current_user_can(string $cap): bool { return $GLOBALS['wpb_test']['can'] ?? true; }
function check_admin_referer(string $action): bool { return true; }
function wp_nonce_field(string $action): void {}
function wp_unslash(mixed $v): mixed { return $v; }
function admin_url(string $p = ''): string { return 'https://example.test/wp-admin/' . $p; }
function wp_safe_redirect(string $url): void { $GLOBALS['wpb_test']['redirect'] = $url; }
function set_transient(string $k, mixed $v, int $ttl): bool { $GLOBALS['wpb_test']['options']['_transient_' . $k] = $v; return true; }
function get_transient(string $k): mixed { return $GLOBALS['wpb_test']['options']['_transient_' . $k] ?? false; }
function delete_transient(string $k): bool { unset($GLOBALS['wpb_test']['options']['_transient_' . $k]); return true; }
function submit_button(string $text = 'Save', string $type = 'primary', string $name = 'submit', bool $wrap = true): void { echo '<button type="submit">' . esc_html($text) . '</button>'; }
function add_menu_page(...$args): string { $GLOBALS['wpb_test']['menu_pages'][] = $args; return 'wp-back'; }
function add_submenu_page(...$args): string { $GLOBALS['wpb_test']['menu_pages'][] = $args; return 'wp-back-snapshots'; }
```

Et câbler dans `plugin/src/Plugin.php` (remplacer le corps de `boot()`) :

```php
    public function boot(): void
    {
        if (is_admin()) {
            \WpBack\Admin\SettingsPage::register();
        }
    }
```

avec le stub `function is_admin(): bool { return $GLOBALS['wpb_test']['is_admin'] ?? false; }` ajouté à `wp-functions.php`.

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit`
Expected : `OK (10 tests, …)` — la suite complète reste verte.

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Support plugin/src/Admin plugin/src/Plugin.php plugin/tests
git commit -m "feat(plugin): paths/exclusions helper and license activation settings page"
```

---

### Task 4: Index de fichiers + scanner batché

**Files:**
- Create: `plugin/src/Backup/FileIndexInterface.php`
- Create: `plugin/src/Backup/WpdbFileIndex.php`
- Create: `plugin/src/Backup/FileScanner.php`
- Modify: `plugin/src/Install.php` (colonne `last_seen BIGINT` dans `wpb_files`)
- Modify: `plugin/tests/Unit/InstallTest.php` (assertion `last_seen BIGINT`)
- Create: `plugin/tests/Support/InMemoryFileIndex.php`
- Test: `plugin/tests/Unit/Backup/FileScannerTest.php`

**Interfaces:**
- Consumes: `Paths::relative`, `Paths::isExcluded` (Task 3) ; table `{$prefix}wpb_files` (Task 1).
- Produces:
  - Statuts de `wpb_files` (cycle de vie) : `changed` → `hashed` → `uploading` → `synced` ; plus `deleted`.
  - `FileIndexInterface` :
    - `get(string $path): ?array` (row `{path,size,mtime,sha256,status,last_seen}`)
    - `upsert(array $row): void`
    - `touchSeen(string $path, int $scanId): void`
    - `markDeletedNotSeen(int $scanId): int`
    - `pending(string $status, int $limit): array` (liste de rows)
    - `setStatus(string $path, string $status, ?string $sha256 = null): void`
    - `liveRows(): \Generator` (rows non-`deleted`, pour le manifest)
    - `totals(): array{files_total:int, bytes_total:int}`
  - `WpdbFileIndex implements FileIndexInterface` (SQL 100 % préparé).
  - `FileScanner::__construct(FileIndexInterface $index)`
  - `FileScanner::scanBatch(?string $cursor, int $limit, int $scanId): array{cursor: ?string, scanned: int}` — `cursor === null` en retour ⇒ scan terminé (et `markDeletedNotSeen($scanId)` a été appelé).

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Support/InMemoryFileIndex.php
declare(strict_types=1);

namespace WpBack\Tests\Support;

use WpBack\Backup\FileIndexInterface;

final class InMemoryFileIndex implements FileIndexInterface
{
    /** @var array<string, array> */
    public array $rows = [];

    public function get(string $path): ?array { return $this->rows[$path] ?? null; }
    public function upsert(array $row): void { $this->rows[$row['path']] = $row + ($this->rows[$row['path']] ?? []); }
    public function touchSeen(string $path, int $scanId): void { $this->rows[$path]['last_seen'] = $scanId; }

    public function markDeletedNotSeen(int $scanId): int
    {
        $n = 0;
        foreach ($this->rows as $path => $row) {
            if (($row['last_seen'] ?? 0) < $scanId && $row['status'] !== 'deleted') {
                $this->rows[$path]['status'] = 'deleted';
                $n++;
            }
        }
        return $n;
    }

    public function pending(string $status, int $limit): array
    {
        $out = array_filter($this->rows, static fn(array $r): bool => $r['status'] === $status);
        return array_slice(array_values($out), 0, $limit);
    }

    public function setStatus(string $path, string $status, ?string $sha256 = null): void
    {
        $this->rows[$path]['status'] = $status;
        if ($sha256 !== null) { $this->rows[$path]['sha256'] = $sha256; }
    }

    public function liveRows(): \Generator
    {
        foreach ($this->rows as $row) {
            if ($row['status'] !== 'deleted') { yield $row; }
        }
    }

    public function totals(): array
    {
        $n = 0; $b = 0;
        foreach ($this->liveRows() as $r) { $n++; $b += (int) $r['size']; }
        return ['files_total' => $n, 'bytes_total' => $b];
    }
}
```

```php
<?php // plugin/tests/Unit/Backup/FileScannerTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Backup;

use PHPUnit\Framework\TestCase;
use WpBack\Backup\FileScanner;
use WpBack\Tests\Support\InMemoryFileIndex;

final class FileScannerTest extends TestCase
{
    private InMemoryFileIndex $index;

    protected function setUp(): void
    {
        wpb_test_reset();
        // Fixture : arbo propre sous ABSPATH (répertoire temp du bootstrap)
        $this->rmrf(ABSPATH);
        mkdir(ABSPATH . 'wp-content/themes/x', 0777, true);
        mkdir(ABSPATH . 'wp-content/cache', 0777, true);
        file_put_contents(ABSPATH . 'wp-config.php', 'a');
        file_put_contents(ABSPATH . 'wp-content/themes/x/style.css', 'body{}');
        file_put_contents(ABSPATH . 'wp-content/themes/x/index.php', '<?php');
        file_put_contents(ABSPATH . 'wp-content/cache/skip.html', 'cached'); // exclu
        $this->index = new InMemoryFileIndex();
    }

    private function rmrf(string $dir): void
    {
        foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
            $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
        }
    }

    public function test_full_scan_in_batches_indexes_all_non_excluded_files(): void
    {
        $scanner = new FileScanner($this->index);
        $r1 = $scanner->scanBatch(null, 2, scanId: 1);
        $this->assertSame(2, $r1['scanned']);
        $this->assertNotNull($r1['cursor']);
        $r2 = $scanner->scanBatch($r1['cursor'], 2, scanId: 1);
        $this->assertNull($r2['cursor']); // terminé
        $paths = array_keys($this->index->rows);
        sort($paths);
        $this->assertSame(['wp-config.php', 'wp-content/themes/x/index.php', 'wp-content/themes/x/style.css'], $paths);
        $this->assertSame('changed', $this->index->rows['wp-config.php']['status']); // nouveau = à hasher
    }

    public function test_unchanged_files_keep_status_changed_files_are_flagged(): void
    {
        $scanner = new FileScanner($this->index);
        $r = ['cursor' => null];
        do { $r = $scanner->scanBatch($r['cursor'], 100, 1); } while ($r['cursor'] !== null);
        foreach (array_keys($this->index->rows) as $p) { $this->index->setStatus($p, 'synced', str_repeat('0', 64)); }

        file_put_contents(ABSPATH . 'wp-content/themes/x/style.css', 'body{color:red}'); // taille change
        $scanner->scanBatch(null, 100, scanId: 2);
        $this->assertSame('changed', $this->index->rows['wp-content/themes/x/style.css']['status']);
        $this->assertSame('synced', $this->index->rows['wp-config.php']['status']); // intouché
    }

    public function test_deleted_files_are_marked_on_scan_completion(): void
    {
        $scanner = new FileScanner($this->index);
        $r = ['cursor' => null];
        do { $r = $scanner->scanBatch($r['cursor'], 100, 1); } while ($r['cursor'] !== null);
        unlink(ABSPATH . 'wp-config.php');
        $r2 = ['cursor' => null];
        do { $r2 = $scanner->scanBatch($r2['cursor'], 100, 2); } while ($r2['cursor'] !== null);
        $this->assertSame('deleted', $this->index->rows['wp-config.php']['status']);
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter FileScannerTest`
Expected : `Error: Interface "WpBack\Backup\FileIndexInterface" not found`

- [ ] **Step 3: Implémentation**

Dans `Install::schema()`, ligne `status VARCHAR(20) NOT NULL DEFAULT 'new',` → ajouter en dessous `last_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,` et ajouter dans `InstallTest::test_schema_contains_files_and_jobs_tables()` : `$this->assertStringContainsString('last_seen BIGINT', $sql[0]);`

```php
<?php // plugin/src/Backup/FileIndexInterface.php
declare(strict_types=1);

namespace WpBack\Backup;

interface FileIndexInterface
{
    public function get(string $path): ?array;
    public function upsert(array $row): void;
    public function touchSeen(string $path, int $scanId): void;
    public function markDeletedNotSeen(int $scanId): int;
    public function pending(string $status, int $limit): array;
    public function setStatus(string $path, string $status, ?string $sha256 = null): void;
    public function liveRows(): \Generator;
    /** @return array{files_total: int, bytes_total: int} */
    public function totals(): array;
}
```

```php
<?php // plugin/src/Backup/WpdbFileIndex.php
declare(strict_types=1);

namespace WpBack\Backup;

final class WpdbFileIndex implements FileIndexInterface
{
    private string $table;

    public function __construct(private \wpdb $db)
    {
        $this->table = $db->prefix . 'wpb_files';
    }

    public function get(string $path): ?array
    {
        $row = $this->db->get_row($this->db->prepare("SELECT * FROM {$this->table} WHERE path = %s", $path), ARRAY_A);
        return $row ?: null;
    }

    public function upsert(array $row): void
    {
        $this->db->query($this->db->prepare(
            "INSERT INTO {$this->table} (path, size, mtime, sha256, status, last_seen)
             VALUES (%s, %d, %d, %s, %s, %d)
             ON DUPLICATE KEY UPDATE size = VALUES(size), mtime = VALUES(mtime),
               sha256 = VALUES(sha256), status = VALUES(status), last_seen = VALUES(last_seen)",
            $row['path'], $row['size'], $row['mtime'], $row['sha256'] ?? '', $row['status'], $row['last_seen']
        ));
    }

    public function touchSeen(string $path, int $scanId): void
    {
        $this->db->query($this->db->prepare("UPDATE {$this->table} SET last_seen = %d WHERE path = %s", $scanId, $path));
    }

    public function markDeletedNotSeen(int $scanId): int
    {
        return (int) $this->db->query($this->db->prepare(
            "UPDATE {$this->table} SET status = 'deleted' WHERE last_seen < %d AND status != 'deleted'", $scanId
        ));
    }

    public function pending(string $status, int $limit): array
    {
        return $this->db->get_results($this->db->prepare(
            "SELECT * FROM {$this->table} WHERE status = %s ORDER BY path LIMIT %d", $status, $limit
        ), ARRAY_A) ?: [];
    }

    public function setStatus(string $path, string $status, ?string $sha256 = null): void
    {
        if ($sha256 !== null) {
            $this->db->query($this->db->prepare("UPDATE {$this->table} SET status = %s, sha256 = %s WHERE path = %s", $status, $sha256, $path));
        } else {
            $this->db->query($this->db->prepare("UPDATE {$this->table} SET status = %s WHERE path = %s", $status, $path));
        }
    }

    public function liveRows(): \Generator
    {
        $offset = 0;
        do {
            $rows = $this->db->get_results($this->db->prepare(
                "SELECT * FROM {$this->table} WHERE status != 'deleted' ORDER BY path LIMIT %d OFFSET %d", 1000, $offset
            ), ARRAY_A) ?: [];
            foreach ($rows as $row) { yield $row; }
            $offset += 1000;
        } while (count($rows) === 1000);
    }

    public function totals(): array
    {
        $row = $this->db->get_row("SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS b FROM {$this->table} WHERE status != 'deleted'", ARRAY_A);
        return ['files_total' => (int) $row['n'], 'bytes_total' => (int) $row['b']];
    }
}
```

```php
<?php // plugin/src/Backup/FileScanner.php
declare(strict_types=1);

namespace WpBack\Backup;

use WpBack\Support\Paths;

final class FileScanner
{
    public function __construct(private FileIndexInterface $index) {}

    /**
     * Un lot de scan. Les chemins relatifs sont triés ; le curseur est le dernier
     * chemin traité — la reprise est donc stateless et idempotente.
     *
     * @return array{cursor: ?string, scanned: int}
     */
    public function scanBatch(?string $cursor, int $limit, int $scanId): array
    {
        $paths = $this->allRelativePaths();
        $scanned = 0;
        $last = null;

        foreach ($paths as $rel) {
            if ($cursor !== null && strcmp($rel, $cursor) <= 0) { continue; }
            $this->indexOne($rel, $scanId);
            $last = $rel;
            if (++$scanned >= $limit) {
                return ['cursor' => $last, 'scanned' => $scanned];
            }
        }

        $this->index->markDeletedNotSeen($scanId); // fin de scan : tout ce qui n'a pas été vu a disparu
        return ['cursor' => null, 'scanned' => $scanned];
    }

    private function indexOne(string $rel, int $scanId): void
    {
        $abs = Paths::absolute($rel);
        $size = (int) @filesize($abs);
        $mtime = (int) @filemtime($abs);
        $row = $this->index->get($rel);

        if ($row !== null && (int) $row['size'] === $size && (int) $row['mtime'] === $mtime && $row['status'] !== 'deleted') {
            $this->index->touchSeen($rel, $scanId);
            return;
        }
        $this->index->upsert([
            'path' => $rel, 'size' => $size, 'mtime' => $mtime,
            'sha256' => null, 'status' => 'changed', 'last_seen' => $scanId,
        ]);
    }

    /** @return string[] chemins relatifs triés, exclusions appliquées */
    private function allRelativePaths(): array
    {
        $out = [];
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(ABSPATH, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS)
        );
        foreach ($it as $file) {
            if (!$file->isFile()) { continue; }
            $rel = Paths::relative($file->getPathname());
            if (!Paths::isExcluded($rel)) { $out[] = $rel; }
        }
        sort($out, SORT_STRING);
        return $out;
    }
}
```

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit`
Expected : `OK (13 tests, …)` — dont les 3 de `FileScannerTest`, et `InstallTest` mis à jour.

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Backup plugin/src/Install.php plugin/tests
git commit -m "feat(plugin): batched file scanner with resumable cursor and deletion tracking"
```

---

### Task 5: Hasher sha256 streaming batché

**Files:**
- Create: `plugin/src/Backup/Hasher.php`
- Test: `plugin/tests/Unit/Backup/HasherTest.php`

**Interfaces:**
- Consumes: `FileIndexInterface` (Task 4), `Paths` (Task 3), statut `changed`.
- Produces:
  - `Hasher::__construct(FileIndexInterface $index)`
  - `Hasher::$clock` — `callable(): int` statique injectable (défaut `'time'`).
  - `Hasher::hashPending(int $timeBudgetSeconds = 20): array{hashed: int, remaining: bool}` — hashe les rows `changed` par mini-lots de 50 jusqu'à épuisement du budget temps ; passe chaque row en `hashed` (sha256 rempli) ; un fichier disparu entre scan et hash passe en `deleted`.

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Unit/Backup/HasherTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Backup;

use PHPUnit\Framework\TestCase;
use WpBack\Backup\Hasher;
use WpBack\Tests\Support\InMemoryFileIndex;

final class HasherTest extends TestCase
{
    private InMemoryFileIndex $index;

    protected function setUp(): void
    {
        wpb_test_reset();
        Hasher::$clock = 'time';
        @mkdir(ABSPATH, 0777, true);
        file_put_contents(ABSPATH . 'a.txt', 'hello');
        $this->index = new InMemoryFileIndex();
        $this->index->upsert(['path' => 'a.txt', 'size' => 5, 'mtime' => 1, 'status' => 'changed', 'last_seen' => 1]);
    }

    public function test_hashes_changed_files_and_marks_hashed(): void
    {
        $out = (new Hasher($this->index))->hashPending(20);
        $this->assertSame(1, $out['hashed']);
        $this->assertFalse($out['remaining']);
        $this->assertSame(hash('sha256', 'hello'), $this->index->rows['a.txt']['sha256']);
        $this->assertSame('hashed', $this->index->rows['a.txt']['status']);
    }

    public function test_vanished_file_is_marked_deleted(): void
    {
        unlink(ABSPATH . 'a.txt');
        (new Hasher($this->index))->hashPending(20);
        $this->assertSame('deleted', $this->index->rows['a.txt']['status']);
    }

    public function test_time_budget_stops_between_minibatches(): void
    {
        for ($i = 0; $i < 60; $i++) { // 60 fichiers = 2 mini-lots de 50
            file_put_contents(ABSPATH . "f$i.txt", (string) $i);
            $this->index->upsert(['path' => "f$i.txt", 'size' => 1, 'mtime' => 1, 'status' => 'changed', 'last_seen' => 1]);
        }
        $t = 100;
        Hasher::$clock = static function () use (&$t): int { return $t += 25; }; // +25 s par appel → budget (20 s) crevé dès le contrôle après le 1er mini-lot
        $out = (new Hasher($this->index))->hashPending(20);
        $this->assertTrue($out['remaining']);
        $this->assertLessThan(61, $out['hashed']);
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter HasherTest`
Expected : `Error: Class "WpBack\Backup\Hasher" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Backup/Hasher.php
declare(strict_types=1);

namespace WpBack\Backup;

use WpBack\Support\Paths;

final class Hasher
{
    private const MINI_BATCH = 50;

    /** @var callable(): int Horloge injectable pour tester le budget temps. */
    public static $clock = 'time';

    public function __construct(private FileIndexInterface $index) {}

    /** @return array{hashed: int, remaining: bool} */
    public function hashPending(int $timeBudgetSeconds = 20): array
    {
        $start = (self::$clock)();
        $hashed = 0;

        while (true) {
            $rows = $this->index->pending('changed', self::MINI_BATCH);
            if ($rows === []) {
                return ['hashed' => $hashed, 'remaining' => false];
            }
            foreach ($rows as $row) {
                $abs = Paths::absolute($row['path']);
                $sha = @hash_file('sha256', $abs); // streaming : mémoire constante quel que soit le fichier
                if ($sha === false) {
                    $this->index->setStatus($row['path'], 'deleted');
                    continue;
                }
                $this->index->setStatus($row['path'], 'hashed', $sha);
                $hashed++;
            }
            if ((self::$clock)() - $start >= $timeBudgetSeconds) {
                $more = $this->index->pending('changed', 1) !== [];
                return ['hashed' => $hashed, 'remaining' => $more];
            }
        }
    }
}
```

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit --filter HasherTest`
Expected : `OK (3 tests)`

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Backup/Hasher.php plugin/tests/Unit/Backup/HasherTest.php
git commit -m "feat(plugin): streaming sha256 hasher with time-budgeted batches"
```

---

### Task 6: Uploader (sign-upload → PUT presigné → confirm)

**Files:**
- Create: `plugin/src/Backup/Uploader.php`
- Test: `plugin/tests/Unit/Backup/UploaderTest.php`

**Interfaces:**
- Consumes: `Client::signUpload/confirmBlob` (Task 2), `FileIndexInterface::pending('hashed', …)/setStatus` (Task 4), `Paths` (Task 3).
- Produces:
  - `Uploader::__construct(Client $client, FileIndexInterface $index)`
  - `Uploader::$putter` — `callable(string $url, string $absPath): int` statique injectable retournant le code HTTP ; défaut `Uploader::curlPut(...)` (PUT streaming via `CURLOPT_UPLOAD` + `CURLOPT_INFILE`, mémoire constante).
  - `Uploader::uploadPending(int $max = 20): array{uploaded: int, bytes: int, remaining: bool}` — pour chaque row `hashed` : `sign-upload` ; `status:"exists"` ⇒ `synced` direct (dédup) ; sinon PUT puis `confirm` ⇒ `synced`. `confirm` en 409 (`size_mismatch`) ⇒ retour en `hashed` pour ré-upload au prochain lot. PUT ≠ 200 ⇒ row laissée en `hashed` (retry naturel), l'erreur ne fait pas échouer le lot.

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Unit/Backup/UploaderTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Backup;

use PHPUnit\Framework\TestCase;
use WpBack\Api\Client;
use WpBack\Backup\Uploader;
use WpBack\Tests\Support\InMemoryFileIndex;

final class UploaderTest extends TestCase
{
    private InMemoryFileIndex $index;
    private array $puts = [];

    protected function setUp(): void
    {
        wpb_test_reset();
        update_option('wpb_site_token', 'jwt');
        Client::$sleeper = static fn(int $s) => null;
        @mkdir(ABSPATH, 0777, true);
        file_put_contents(ABSPATH . 'a.txt', 'hello');
        $this->index = new InMemoryFileIndex();
        $this->index->upsert(['path' => 'a.txt', 'size' => 5, 'mtime' => 1, 'sha256' => hash('sha256', 'hello'), 'status' => 'hashed', 'last_seen' => 1]);
        $this->puts = [];
        Uploader::$putter = function (string $url, string $abs): int {
            $this->puts[] = $url;
            return 200;
        };
    }

    private function uploader(): Uploader
    {
        return new Uploader(new Client('https://api.test.invalid'), $this->index);
    }

    public function test_uploads_then_confirms_and_marks_synced(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put/abc', 'expires_at' => 'later'])];
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'stored'])];
        $out = $this->uploader()->uploadPending();
        $this->assertSame(1, $out['uploaded']);
        $this->assertSame(5, $out['bytes']);
        $this->assertSame(['https://wasabi.test/put/abc'], $this->puts);
        $this->assertSame('synced', $this->index->rows['a.txt']['status']);
    }

    public function test_exists_skips_put_entirely(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'exists'])];
        $out = $this->uploader()->uploadPending();
        $this->assertSame([], $this->puts);
        $this->assertSame(0, $out['bytes']); // rien transféré
        $this->assertSame('synced', $this->index->rows['a.txt']['status']);
    }

    public function test_confirm_409_puts_row_back_to_hashed(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put/abc', 'expires_at' => 'later'])];
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 409, 'body' => json_encode(['error' => ['code' => 'size_mismatch', 'message' => 'bad']])];
        $out = $this->uploader()->uploadPending();
        $this->assertSame(0, $out['uploaded']);
        $this->assertSame('hashed', $this->index->rows['a.txt']['status']);
    }

    public function test_failed_put_leaves_row_hashed_for_retry(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put/abc', 'expires_at' => 'later'])];
        Uploader::$putter = static fn(string $u, string $a): int => 500;
        $out = $this->uploader()->uploadPending();
        $this->assertSame(0, $out['uploaded']);
        $this->assertSame('hashed', $this->index->rows['a.txt']['status']);
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter UploaderTest`
Expected : `Error: Class "WpBack\Backup\Uploader" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Backup/Uploader.php
declare(strict_types=1);

namespace WpBack\Backup;

use WpBack\Api\ApiException;
use WpBack\Api\Client;
use WpBack\Support\Paths;

final class Uploader
{
    /** @var callable(string, string): int PUT presigné ; retourne le code HTTP. Injectable en test. */
    public static $putter = [self::class, 'curlPut'];

    public function __construct(private Client $client, private FileIndexInterface $index) {}

    /** @return array{uploaded: int, bytes: int, remaining: bool} */
    public function uploadPending(int $max = 20): array
    {
        $uploaded = 0;
        $bytes = 0;

        foreach ($this->index->pending('hashed', $max) as $row) {
            $path = $row['path'];
            $abs = Paths::absolute($path);
            if (!is_file($abs)) {
                $this->index->setStatus($path, 'deleted');
                continue;
            }
            $this->index->setStatus($path, 'uploading');
            try {
                $sign = $this->client->signUpload($row['sha256'], (int) $row['size']);
                if (($sign['status'] ?? '') === 'exists') { // dédup : le blob est déjà chez nous
                    $this->index->setStatus($path, 'synced');
                    $uploaded++;
                    continue;
                }
                $code = (self::$putter)($sign['upload_url'], $abs);
                if ($code !== 200) {
                    $this->index->setStatus($path, 'hashed'); // retry au prochain lot
                    continue;
                }
                $this->client->confirmBlob($row['sha256']);
                $this->index->setStatus($path, 'synced');
                $uploaded++;
                $bytes += (int) $row['size'];
            } catch (ApiException $e) {
                // 409 size_mismatch comme les erreurs réseau : on rend la row réessayable.
                $this->index->setStatus($path, 'hashed');
                if ($e->status === 402) {
                    throw $e; // quota plein : inutile de continuer le lot, remonter à l'orchestrateur
                }
            }
        }

        $remaining = $this->index->pending('hashed', 1) !== [];
        return ['uploaded' => $uploaded, 'bytes' => $bytes, 'remaining' => $remaining];
    }

    /** PUT streaming mémoire-constante vers l'URL presignée. */
    public static function curlPut(string $url, string $absPath): int
    {
        $fh = fopen($absPath, 'rb');
        if ($fh === false) { return 0; }
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_UPLOAD => true,
            CURLOPT_INFILE => $fh,
            CURLOPT_INFILESIZE => (int) filesize($absPath),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 300,
        ]);
        curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        fclose($fh);
        return $code;
    }
}
```

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit --filter UploaderTest`
Expected : `OK (4 tests)`

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Backup/Uploader.php plugin/tests/Unit/Backup/UploaderTest.php
git commit -m "feat(plugin): presigned blob uploader with dedup, confirm and retryable failures"
```

---

### Task 7: Dump DB incrémental par table (CHECKSUM TABLE + gzip chunké)

**Files:**
- Create: `plugin/src/Backup/DbDumper.php`
- Modify: `plugin/src/Backup/Uploader.php` (ajout de `pushFile()` statique, réutilisé par le manifest en Task 8)
- Create: `plugin/tests/Support/FakeWpdb.php`
- Test: `plugin/tests/Unit/Backup/DbDumperTest.php`

**Interfaces:**
- Consumes: `Client` (Task 2), `Paths::tmpDir()` (Task 3), `Uploader::$putter` (Task 6), options `wpb_db_manifest` (map `table => {h, s, checksum}` du dernier backup).
- Produces:
  - `Uploader::pushFile(Client $client, string $absPath): array{sha256: string, size: int}` — sign-upload → PUT (`Uploader::$putter`) → confirm pour un fichier arbitraire (dump, manifest) ; `status:"exists"` court-circuite le PUT ; lève `\RuntimeException` si le PUT échoue.
  - `DbDumper::__construct(\wpdb $db, Client $client)`
  - `DbDumper::tables(): array` — tables du préfixe courant, moins `wpb_files`/`wpb_jobs`.
  - `DbDumper::changedTables(): array` — celles dont `CHECKSUM TABLE` diffère de `wpb_db_manifest`.
  - `DbDumper::dumpAndPush(string $table): array{table: string, h: string, s: int, checksum: string}` — dump SQL gzippé (DROP + SHOW CREATE TABLE + INSERTs par lots de 1000 lignes via LIMIT/OFFSET), pushFile, met à jour `wpb_db_manifest[table]`, supprime le temp.
  - `DbDumper::manifestEntries(): array` — la section `db` complète du manifest (tables inchangées = entrée précédente réutilisée).

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Support/FakeWpdb.php
declare(strict_types=1);

namespace WpBack\Tests\Support;

/** Fake wpdb minimal : les tests programment les réponses par motif de requête. */
final class FakeWpdb
{
    public string $prefix = 'wp_';
    /** @var array<string, mixed> motif (str_contains) => réponse */
    public array $answers = [];
    /** @var string[] */
    public array $queries = [];

    public function prepare(string $sql, mixed ...$args): string
    {
        foreach ($args as $a) {
            $sql = preg_replace('/%[sd]/', is_int($a) ? (string) $a : "'" . addslashes((string) $a) . "'", $sql, 1);
        }
        return $sql;
    }

    private function answer(string $sql): mixed
    {
        $this->queries[] = $sql;
        foreach ($this->answers as $needle => $value) {
            if (str_contains($sql, $needle)) { return is_callable($value) ? $value($sql) : $value; }
        }
        return null;
    }

    public function get_col(string $sql): array { return (array) ($this->answer($sql) ?? []); }
    public function get_row(string $sql, string $output = OBJECT): mixed { return $this->answer($sql); }
    public function get_results(string $sql, string $output = OBJECT): array { return (array) ($this->answer($sql) ?? []); }
    public function query(string $sql): int { $this->answer($sql); return 1; }
}
```

Ajouter dans `plugin/tests/stubs/wp-functions.php` (namespace global) : `if (!defined('OBJECT')) { define('OBJECT', 'OBJECT'); define('ARRAY_A', 'ARRAY_A'); }`

```php
<?php // plugin/tests/Unit/Backup/DbDumperTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Backup;

use PHPUnit\Framework\TestCase;
use WpBack\Api\Client;
use WpBack\Backup\DbDumper;
use WpBack\Backup\Uploader;
use WpBack\Tests\Support\FakeWpdb;

final class DbDumperTest extends TestCase
{
    private FakeWpdb $db;

    protected function setUp(): void
    {
        wpb_test_reset();
        update_option('wpb_site_token', 'jwt');
        Client::$sleeper = static fn(int $s) => null;
        Uploader::$putter = static fn(string $u, string $a): int => 200;
        if (!defined('WP_CONTENT_DIR')) { define('WP_CONTENT_DIR', ABSPATH . 'wp-content'); }

        $this->db = new FakeWpdb();
        $this->db->answers["SHOW TABLES LIKE 'wp\\_%'"] = ['wp_posts', 'wp_options', 'wp_wpb_files', 'wp_wpb_jobs'];
        $this->db->answers['CHECKSUM TABLE `wp_posts`'] = ['Table' => 'wp_posts', 'Checksum' => '111'];
        $this->db->answers['CHECKSUM TABLE `wp_options`'] = ['Table' => 'wp_options', 'Checksum' => '222'];
        $this->db->answers['SHOW CREATE TABLE `wp_posts`'] = ['Table' => 'wp_posts', 'Create Table' => 'CREATE TABLE `wp_posts` (`ID` bigint)'];
        $this->db->answers['SELECT * FROM `wp_posts`'] = static function (string $sql): array {
            return str_contains($sql, 'OFFSET 0') ? [['ID' => 1, 'post_title' => "l'article"]] : [];
        };
    }

    private function dumper(): DbDumper
    {
        return new DbDumper($this->db, new Client('https://api.test.invalid'));
    }

    public function test_tables_excludes_own_index_tables(): void
    {
        $this->assertSame(['wp_posts', 'wp_options'], $this->dumper()->tables());
    }

    public function test_changed_tables_compares_against_stored_manifest(): void
    {
        update_option('wpb_db_manifest', [
            'wp_posts'   => ['h' => 'old', 's' => 1, 'checksum' => '000'], // checksum différent → changé
            'wp_options' => ['h' => 'keep', 's' => 2, 'checksum' => '222'], // identique → inchangé
        ]);
        $this->assertSame(['wp_posts'], $this->dumper()->changedTables());
    }

    public function test_dump_and_push_produces_valid_gzip_sql_and_updates_manifest(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put/x', 'expires_at' => 'later'])];
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'stored'])];

        $captured = null;
        Uploader::$putter = static function (string $url, string $abs) use (&$captured): int {
            $captured = file_get_contents($abs); // le temp est supprimé après push
            return 200;
        };

        $entry = $this->dumper()->dumpAndPush('wp_posts');
        $sql = gzdecode($captured);
        $this->assertStringContainsString('DROP TABLE IF EXISTS `wp_posts`;', $sql);
        $this->assertStringContainsString('CREATE TABLE `wp_posts`', $sql);
        $this->assertStringContainsString("INSERT INTO `wp_posts`", $sql);
        $this->assertStringContainsString("l\\'article", $sql); // échappement des quotes
        $this->assertSame(hash('sha256', $captured), $entry['h']);
        $this->assertSame('111', $entry['checksum']);
        $this->assertSame($entry, get_option('wpb_db_manifest')['wp_posts']);
    }

    public function test_manifest_entries_merges_unchanged_previous_dumps(): void
    {
        update_option('wpb_db_manifest', [
            'wp_posts'   => ['table' => 'wp_posts', 'h' => 'hp', 's' => 1, 'checksum' => '111'],
            'wp_options' => ['table' => 'wp_options', 'h' => 'ho', 's' => 2, 'checksum' => '222'],
        ]);
        $entries = $this->dumper()->manifestEntries();
        $this->assertCount(2, $entries);
        $this->assertSame('hp', $entries[0]['h']);
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter DbDumperTest`
Expected : `Error: Class "WpBack\Backup\DbDumper" not found`

- [ ] **Step 3: Implémentation**

Ajouter à `plugin/src/Backup/Uploader.php` :

```php
    /**
     * Pousse un fichier arbitraire (dump DB, manifest) dans le store de blobs.
     * @return array{sha256: string, size: int}
     */
    public static function pushFile(Client $client, string $absPath): array
    {
        $sha = hash_file('sha256', $absPath);
        $size = (int) filesize($absPath);
        $sign = $client->signUpload($sha, $size);
        if (($sign['status'] ?? '') !== 'exists') {
            $code = (self::$putter)($sign['upload_url'], $absPath);
            if ($code !== 200) {
                throw new \RuntimeException("Blob PUT failed with HTTP $code for $absPath");
            }
            $client->confirmBlob($sha);
        }
        return ['sha256' => $sha, 'size' => $size];
    }
```

```php
<?php // plugin/src/Backup/DbDumper.php
declare(strict_types=1);

namespace WpBack\Backup;

use WpBack\Api\Client;
use WpBack\Support\Paths;

final class DbDumper
{
    private const CHUNK = 1000;

    public function __construct(private object $db, private Client $client) {}

    /** @return string[] */
    public function tables(): array
    {
        $like = str_replace('_', '\\_', $this->db->prefix) . '%';
        $all = $this->db->get_col("SHOW TABLES LIKE '{$like}'");
        $own = [$this->db->prefix . 'wpb_files', $this->db->prefix . 'wpb_jobs'];
        return array_values(array_diff($all, $own));
    }

    /** @return string[] tables dont le checksum a bougé depuis le dernier backup */
    public function changedTables(): array
    {
        $previous = (array) get_option('wpb_db_manifest', []);
        $changed = [];
        foreach ($this->tables() as $table) {
            $current = $this->checksum($table);
            if (($previous[$table]['checksum'] ?? null) !== $current) {
                $changed[] = $table;
            }
        }
        return $changed;
    }

    /** @return array{table: string, h: string, s: int, checksum: string} */
    public function dumpAndPush(string $table): array
    {
        $tmp = Paths::tmpDir() . 'dump-' . md5($table . microtime()) . '.sql.gz';
        $gz = gzopen($tmp, 'wb6');
        gzwrite($gz, "DROP TABLE IF EXISTS `{$table}`;\n");
        $create = (array) $this->db->get_row("SHOW CREATE TABLE `{$table}`", ARRAY_A);
        gzwrite($gz, (string) array_values($create)[1] . ";\n");

        for ($offset = 0; ; $offset += self::CHUNK) {
            $rows = $this->db->get_results(
                $this->db->prepare("SELECT * FROM `{$table}` LIMIT %d OFFSET %d", self::CHUNK, $offset),
                ARRAY_A
            );
            if ($rows === []) { break; }
            gzwrite($gz, $this->insertStatement($table, array_map(static fn($r) => (array) $r, $rows)));
        }
        gzclose($gz);

        $pushed = Uploader::pushFile($this->client, $tmp);
        @unlink($tmp);

        $entry = ['table' => $table, 'h' => $pushed['sha256'], 's' => $pushed['size'], 'checksum' => $this->checksum($table)];
        $manifest = (array) get_option('wpb_db_manifest', []);
        $manifest[$table] = $entry;
        update_option('wpb_db_manifest', $manifest);
        return $entry;
    }

    /** La section `db` complète du manifest : dernière entrée connue de chaque table. */
    public function manifestEntries(): array
    {
        $manifest = (array) get_option('wpb_db_manifest', []);
        $out = [];
        foreach ($this->tables() as $table) {
            if (isset($manifest[$table])) { $out[] = $manifest[$table]; }
        }
        return $out;
    }

    private function checksum(string $table): string
    {
        $row = (array) $this->db->get_row("CHECKSUM TABLE `{$table}`", ARRAY_A);
        return (string) ($row['Checksum'] ?? '');
    }

    /** @param array<int, array<string, mixed>> $rows */
    private function insertStatement(string $table, array $rows): string
    {
        $cols = '`' . implode('`,`', array_keys($rows[0])) . '`';
        $tuples = [];
        foreach ($rows as $row) {
            $vals = array_map(static function (mixed $v): string {
                if ($v === null) { return 'NULL'; }
                return "'" . addslashes((string) $v) . "'";
            }, array_values($row));
            $tuples[] = '(' . implode(',', $vals) . ')';
        }
        return "INSERT INTO `{$table}` ({$cols}) VALUES\n" . implode(",\n", $tuples) . ";\n";
    }
}
```

Note : `private object $db` (et non `\wpdb`) pour rester instanciable avec `FakeWpdb` sans charger WordPress ; en production c'est toujours le vrai `$wpdb` qui est injecté.

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit --filter DbDumperTest`
Expected : `OK (4 tests)`

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Backup plugin/tests
git commit -m "feat(plugin): per-table incremental db dumper with checksum diffing and gzip chunks"
```

---

### Task 8: ManifestBuilder (manifest JSON + commit du snapshot)

**Files:**
- Create: `plugin/src/Backup/ManifestBuilder.php`
- Modify: `plugin/tests/stubs/wp-functions.php` (stub `get_bloginfo`)
- Test: `plugin/tests/Unit/Backup/ManifestBuilderTest.php`

**Interfaces:**
- Consumes: `Client::commitSnapshot` (Task 2), `FileIndexInterface::liveRows/totals/pending` (Task 4), `Uploader::pushFile` (Task 7), `DbDumper::manifestEntries` (Task 7 — passé en paramètre), `Paths::tmpDir()`.
- Produces:
  - `ManifestBuilder::__construct(Client $client, FileIndexInterface $index)`
  - `ManifestBuilder::buildAndCommit(array $dbEntries, int $filesChanged, int $bytesUploaded): array{snapshot_id: string|int, manifest_sha256: string}`
  - **Invariant appliqué ici** : lève `\RuntimeException` si des rows sont encore en `changed`/`hashed`/`uploading` — un backup incomplet ne committe jamais de manifest.

- [ ] **Step 1: Écrire les tests qui échouent**

Ajouter le stub (namespace global de `wp-functions.php`) :

```php
function get_bloginfo(string $key): string { return $key === 'version' ? '6.6-test' : ''; }
```

```php
<?php // plugin/tests/Unit/Backup/ManifestBuilderTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Backup;

use PHPUnit\Framework\TestCase;
use WpBack\Api\Client;
use WpBack\Backup\ManifestBuilder;
use WpBack\Backup\Uploader;
use WpBack\Tests\Support\InMemoryFileIndex;

final class ManifestBuilderTest extends TestCase
{
    private InMemoryFileIndex $index;
    private ?string $manifestJson = null;

    protected function setUp(): void
    {
        wpb_test_reset();
        update_option('wpb_site_token', 'jwt');
        Client::$sleeper = static fn(int $s) => null;
        if (!defined('WP_CONTENT_DIR')) { define('WP_CONTENT_DIR', ABSPATH . 'wp-content'); }
        $this->index = new InMemoryFileIndex();
        $this->index->upsert(['path' => 'wp-config.php', 'size' => 10, 'mtime' => 5, 'sha256' => str_repeat('a', 64), 'status' => 'synced', 'last_seen' => 1]);
        $this->index->upsert(['path' => 'old.php', 'size' => 3, 'mtime' => 2, 'sha256' => str_repeat('b', 64), 'status' => 'deleted', 'last_seen' => 0]);
        Uploader::$putter = function (string $url, string $abs): int {
            $this->manifestJson = gzdecode(file_get_contents($abs)) ?: file_get_contents($abs);
            return 200;
        };
    }

    private function builder(): ManifestBuilder
    {
        return new ManifestBuilder(new Client('https://api.test.invalid'), $this->index);
    }

    public function test_refuses_to_commit_when_uploads_pending(): void
    {
        $this->index->upsert(['path' => 'pending.php', 'size' => 1, 'mtime' => 1, 'status' => 'hashed', 'last_seen' => 1]);
        $this->expectException(\RuntimeException::class);
        $this->builder()->buildAndCommit([], 0, 0);
    }

    public function test_builds_manifest_uploads_it_then_commits_snapshot(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put/m', 'expires_at' => 'later'])];
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'stored'])];
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 201, 'body' => json_encode(['snapshot_id' => 'snap_1'])];

        $db = [['table' => 'wp_posts', 'h' => str_repeat('c', 64), 's' => 99, 'checksum' => '111']];
        $out = $this->builder()->buildAndCommit($db, filesChanged: 1, bytesUploaded: 10);

        $this->assertSame('snap_1', $out['snapshot_id']);
        $m = json_decode($this->manifestJson, true);
        $this->assertSame(1, $m['version']);
        $this->assertSame('6.6-test', $m['wp']['core']);
        $this->assertSame([['p' => 'wp-config.php', 'h' => str_repeat('a', 64), 's' => 10, 'm' => 5]], $m['files']); // deleted exclu
        $this->assertSame($db, $m['db']);

        // Le commit référence bien le sha du manifest uploadé et les stats
        $commit = json_decode(end($GLOBALS['wpb_test']['http_log'])['args']['body'], true);
        $this->assertSame(hash('sha256', gzencode($this->manifestJson, 6)), $out['manifest_sha256']);
        $this->assertSame($out['manifest_sha256'], $commit['manifest_sha256']);
        $this->assertSame(['files_total' => 1, 'bytes_total' => 10, 'files_changed' => 1, 'bytes_uploaded' => 10, 'db_tables' => 1], $commit['stats']);
    }
}
```

Note : le manifest est gzippé avant upload (`gzencode(..., 6)`) — le sha committé est celui du **fichier gz uploadé**, exactement comme les dumps DB. Le test reproduit `gzencode($json, 6)` pour vérifier la correspondance ; l'implémentation doit donc utiliser le même niveau 6 de compression.

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter ManifestBuilderTest`
Expected : `Error: Class "WpBack\Backup\ManifestBuilder" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Backup/ManifestBuilder.php
declare(strict_types=1);

namespace WpBack\Backup;

use WpBack\Api\Client;
use WpBack\Support\Paths;

final class ManifestBuilder
{
    public function __construct(private Client $client, private FileIndexInterface $index) {}

    /** @return array{snapshot_id: string|int, manifest_sha256: string} */
    public function buildAndCommit(array $dbEntries, int $filesChanged, int $bytesUploaded): array
    {
        foreach (['changed', 'hashed', 'uploading'] as $status) {
            if ($this->index->pending($status, 1) !== []) {
                throw new \RuntimeException("Cannot commit snapshot: rows still in status '$status'.");
            }
        }

        $files = [];
        foreach ($this->index->liveRows() as $row) {
            $files[] = ['p' => $row['path'], 'h' => $row['sha256'], 's' => (int) $row['size'], 'm' => (int) $row['mtime']];
        }

        $ts = gmdate('Y-m-d\TH:i:s\Z');
        $manifest = [
            'version' => 1,
            'ts' => $ts,
            'wp' => ['core' => get_bloginfo('version'), 'php' => PHP_VERSION],
            'files' => $files,
            'db' => $dbEntries,
        ];

        $tmp = Paths::tmpDir() . 'manifest-' . md5($ts . microtime()) . '.json.gz';
        file_put_contents($tmp, gzencode(wp_json_encode($manifest), 6));
        try {
            $pushed = Uploader::pushFile($this->client, $tmp);
        } finally {
            @unlink($tmp);
        }

        $totals = $this->index->totals();
        $out = $this->client->commitSnapshot($ts, $pushed['sha256'], [
            'files_total' => $totals['files_total'],
            'bytes_total' => $totals['bytes_total'],
            'files_changed' => $filesChanged,
            'bytes_uploaded' => $bytesUploaded,
            'db_tables' => count($dbEntries),
        ]);

        return ['snapshot_id' => $out['snapshot_id'], 'manifest_sha256' => $pushed['sha256']];
    }
}
```

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit --filter ManifestBuilderTest`
Expected : `OK (2 tests)`

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Backup/ManifestBuilder.php plugin/tests
git commit -m "feat(plugin): snapshot manifest builder with commit-last invariant"
```

---

### Task 9: BackupRunner (machine à états Action Scheduler)

**Files:**
- Create: `plugin/src/Backup/JobStoreInterface.php`
- Create: `plugin/src/Backup/WpdbJobStore.php`
- Create: `plugin/src/Backup/BackupRunner.php`
- Modify: `plugin/src/Plugin.php` (enregistrement des hooks)
- Create: `plugin/tests/Support/InMemoryJobStore.php`
- Test: `plugin/tests/Unit/Backup/BackupRunnerTest.php`

**Interfaces:**
- Consumes: toutes les briques des Tasks 4–8 ; table `wpb_jobs` (Task 1) ; fonctions `as_enqueue_async_action` (stubées Task 1).
- Produces:
  - `JobStoreInterface` : `create(string $type, array $state): int` ; `get(int $id): ?array` (row `{id,type,state:array,status}`) ; `update(int $id, array $state, string $status): void` ; `findRunning(string $type): ?array`.
  - `WpdbJobStore implements JobStoreInterface` (state JSON dans `wpb_jobs.state`, SQL préparé).
  - `BackupRunner::__construct(JobStoreInterface $jobs, FileScanner $scanner, Hasher $hasher, Uploader $uploader, DbDumper $dumper, ManifestBuilder $manifest)`
  - `BackupRunner::make(): self` — factory production (câble `$wpdb`, `Client`, `WpdbFileIndex`).
  - `BackupRunner::start(): int` — refuse si un job `backup` tourne déjà (retourne son id) ; sinon crée le job en phase `scan` et enqueue `wpb_backup_step`.
  - `BackupRunner::step(int $jobId): void` — exécute UNE tranche bornée puis ré-enqueue ; phases : `scan → hash → upload → db → manifest → done`. Statuts de job : `running`, `done`, `failed`.
  - `BackupRunner::register(): void` — `add_action('wpb_backup_step', …)` + handler `admin_post_wpb_backup_now` (nonce + `manage_options`).
  - Garde anti-boucle : 3 passes consécutives sans progrès dans une phase ⇒ job `failed`. `ApiException` 402 ⇒ `failed` immédiat (message quota dans le state).

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Support/InMemoryJobStore.php
declare(strict_types=1);

namespace WpBack\Tests\Support;

use WpBack\Backup\JobStoreInterface;

final class InMemoryJobStore implements JobStoreInterface
{
    public array $jobs = [];
    private int $nextId = 1;

    public function create(string $type, array $state): int
    {
        $id = $this->nextId++;
        $this->jobs[$id] = ['id' => $id, 'type' => $type, 'state' => $state, 'status' => 'running'];
        return $id;
    }

    public function get(int $id): ?array { return $this->jobs[$id] ?? null; }

    public function update(int $id, array $state, string $status): void
    {
        $this->jobs[$id]['state'] = $state;
        $this->jobs[$id]['status'] = $status;
    }

    public function findRunning(string $type): ?array
    {
        foreach ($this->jobs as $job) {
            if ($job['type'] === $type && $job['status'] === 'running') { return $job; }
        }
        return null;
    }
}
```

```php
<?php // plugin/tests/Unit/Backup/BackupRunnerTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Backup;

use PHPUnit\Framework\TestCase;
use WpBack\Api\Client;
use WpBack\Backup\BackupRunner;
use WpBack\Backup\DbDumper;
use WpBack\Backup\FileScanner;
use WpBack\Backup\Hasher;
use WpBack\Backup\ManifestBuilder;
use WpBack\Backup\Uploader;
use WpBack\Tests\Support\FakeWpdb;
use WpBack\Tests\Support\InMemoryFileIndex;
use WpBack\Tests\Support\InMemoryJobStore;

final class BackupRunnerTest extends TestCase
{
    private InMemoryJobStore $jobs;
    private InMemoryFileIndex $index;

    protected function setUp(): void
    {
        wpb_test_reset();
        update_option('wpb_site_token', 'jwt');
        Client::$sleeper = static fn(int $s) => null;
        Hasher::$clock = 'time';
        Uploader::$putter = static fn(string $u, string $a): int => 200;
        if (!defined('WP_CONTENT_DIR')) { define('WP_CONTENT_DIR', ABSPATH . 'wp-content'); }

        // Fixture disque : 1 fichier
        foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(ABSPATH, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
            $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
        }
        file_put_contents(ABSPATH . 'wp-config.php', 'hello');

        $this->jobs = new InMemoryJobStore();
        $this->index = new InMemoryFileIndex();
    }

    private function runner(): BackupRunner
    {
        $client = new Client('https://api.test.invalid');
        $db = new FakeWpdb();
        $db->answers["SHOW TABLES LIKE 'wp\\_%'"] = ['wp_posts'];
        $db->answers['CHECKSUM TABLE `wp_posts`'] = ['Table' => 'wp_posts', 'Checksum' => '111'];
        $db->answers['SHOW CREATE TABLE `wp_posts`'] = ['Table' => 'wp_posts', 'Create Table' => 'CREATE TABLE `wp_posts` (`ID` bigint)'];
        $db->answers['SELECT * FROM `wp_posts`'] = static fn(string $sql): array => str_contains($sql, 'OFFSET 0') ? [['ID' => 1]] : [];
        return new BackupRunner(
            $this->jobs,
            new FileScanner($this->index),
            new Hasher($this->index),
            new Uploader($client, $this->index),
            new DbDumper($db, $client),
            new ManifestBuilder($client, $this->index)
        );
    }

    /** Vide la file Action Scheduler stubée en exécutant chaque step. */
    private function drain(BackupRunner $runner, int $max = 50): void
    {
        while ($GLOBALS['wpb_test']['scheduled'] !== [] && $max-- > 0) {
            $action = array_shift($GLOBALS['wpb_test']['scheduled']);
            $this->assertSame('wpb_backup_step', $action['hook']);
            $runner->step($action['args'][0]);
        }
    }

    private function queueBlobRoundtrip(): void
    {
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put', 'expires_at' => 'x'])];
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'stored'])];
    }

    public function test_start_is_idempotent_while_running(): void
    {
        $id1 = $this->runner()->start();
        $id2 = $this->runner()->start();
        $this->assertSame($id1, $id2);
        $this->assertCount(1, $GLOBALS['wpb_test']['scheduled']);
    }

    public function test_full_run_commits_snapshot_last_and_finishes_done(): void
    {
        $this->queueBlobRoundtrip(); // blob wp-config.php
        $this->queueBlobRoundtrip(); // dump wp_posts
        $this->queueBlobRoundtrip(); // manifest
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 201, 'body' => json_encode(['snapshot_id' => 'snap_1'])];

        $runner = $this->runner();
        $id = $runner->start();
        $this->drain($runner);

        $job = $this->jobs->get($id);
        $this->assertSame('done', $job['status']);
        $this->assertSame('snap_1', $job['state']['snapshot_id']);
        $this->assertSame(1, $job['state']['files_changed']);
        $lastCall = end($GLOBALS['wpb_test']['http_log']);
        $this->assertStringContainsString('/v1/snapshots/commit', $lastCall['url']); // commit = dernière opération
        $this->assertSame([], $GLOBALS['wpb_test']['http_queue']); // tout a été consommé
    }

    public function test_stalled_upload_fails_after_three_passes_without_commit(): void
    {
        Uploader::$putter = static fn(string $u, string $a): int => 500; // PUT échoue toujours
        // sign-upload répondra à chaque passe (3 passes max)
        for ($i = 0; $i < 3; $i++) {
            $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['status' => 'upload', 'upload_url' => 'https://wasabi.test/put', 'expires_at' => 'x'])];
        }
        $runner = $this->runner();
        $id = $runner->start();
        $this->drain($runner);

        $this->assertSame('failed', $this->jobs->get($id)['status']);
        foreach ($GLOBALS['wpb_test']['http_log'] as $call) {
            $this->assertStringNotContainsString('/v1/snapshots/commit', $call['url']); // jamais de manifest committé
        }
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter BackupRunnerTest`
Expected : `Error: Interface "WpBack\Backup\JobStoreInterface" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Backup/JobStoreInterface.php
declare(strict_types=1);

namespace WpBack\Backup;

interface JobStoreInterface
{
    public function create(string $type, array $state): int;
    public function get(int $id): ?array;
    public function update(int $id, array $state, string $status): void;
    public function findRunning(string $type): ?array;
}
```

```php
<?php // plugin/src/Backup/WpdbJobStore.php
declare(strict_types=1);

namespace WpBack\Backup;

final class WpdbJobStore implements JobStoreInterface
{
    private string $table;

    public function __construct(private object $db)
    {
        $this->table = $db->prefix . 'wpb_jobs';
    }

    public function create(string $type, array $state): int
    {
        $this->db->query($this->db->prepare(
            "INSERT INTO {$this->table} (type, state, status, created_at) VALUES (%s, %s, 'running', %s)",
            $type, wp_json_encode($state), current_time('mysql')
        ));
        return (int) $this->db->insert_id;
    }

    public function get(int $id): ?array
    {
        $row = $this->db->get_row($this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id), ARRAY_A);
        if (!$row) { return null; }
        $row['state'] = (array) json_decode((string) $row['state'], true);
        $row['id'] = (int) $row['id'];
        return $row;
    }

    public function update(int $id, array $state, string $status): void
    {
        $this->db->query($this->db->prepare(
            "UPDATE {$this->table} SET state = %s, status = %s WHERE id = %d",
            wp_json_encode($state), $status, $id
        ));
    }

    public function findRunning(string $type): ?array
    {
        $row = $this->db->get_row($this->db->prepare(
            "SELECT * FROM {$this->table} WHERE type = %s AND status = 'running' ORDER BY id DESC LIMIT 1", $type
        ), ARRAY_A);
        if (!$row) { return null; }
        $row['state'] = (array) json_decode((string) $row['state'], true);
        $row['id'] = (int) $row['id'];
        return $row;
    }
}
```

```php
<?php // plugin/src/Backup/BackupRunner.php
declare(strict_types=1);

namespace WpBack\Backup;

use WpBack\Api\ApiException;
use WpBack\Api\Client;

final class BackupRunner
{
    private const MAX_STALLS = 3;

    public function __construct(
        private JobStoreInterface $jobs,
        private FileScanner $scanner,
        private Hasher $hasher,
        private Uploader $uploader,
        private DbDumper $dumper,
        private ManifestBuilder $manifest
    ) {}

    public static function make(): self
    {
        global $wpdb;
        $client = new Client();
        $index = new WpdbFileIndex($wpdb);
        return new self(
            new WpdbJobStore($wpdb),
            new FileScanner($index),
            new Hasher($index),
            new Uploader($client, $index),
            new DbDumper($wpdb, $client),
            new ManifestBuilder($client, $index)
        );
    }

    public static function register(): void
    {
        add_action('wpb_backup_step', static function (int $jobId): void {
            self::make()->step($jobId);
        });
        add_action('admin_post_wpb_backup_now', static function (): void {
            if (!current_user_can('manage_options')) { wp_die('Forbidden'); }
            check_admin_referer('wpb_backup_now');
            self::make()->start();
            wp_safe_redirect(admin_url('admin.php?page=wp-back'));
            exit;
        });
    }

    public function start(): int
    {
        $running = $this->jobs->findRunning('backup');
        if ($running !== null) {
            return $running['id']; // un seul backup à la fois
        }
        $id = $this->jobs->create('backup', [
            'phase' => 'scan', 'scan_id' => time(), 'cursor' => null,
            'files_changed' => 0, 'bytes_uploaded' => 0, 'db_tables' => null, 'stalls' => 0,
        ]);
        as_enqueue_async_action('wpb_backup_step', [$id], 'wp-back');
        return $id;
    }

    public function step(int $jobId): void
    {
        $job = $this->jobs->get($jobId);
        if ($job === null || $job['status'] !== 'running') { return; }
        $s = $job['state'];

        try {
            switch ($s['phase']) {
                case 'scan':
                    $r = $this->scanner->scanBatch($s['cursor'], 500, (int) $s['scan_id']);
                    $s['cursor'] = $r['cursor'];
                    if ($r['cursor'] === null) { $s['phase'] = 'hash'; }
                    break;

                case 'hash':
                    $r = $this->hasher->hashPending(20);
                    if (!$r['remaining']) { $s['phase'] = 'upload'; }
                    break;

                case 'upload':
                    $r = $this->uploader->uploadPending(20);
                    $s['files_changed'] += $r['uploaded'];
                    $s['bytes_uploaded'] += $r['bytes'];
                    if (!$r['remaining']) {
                        $s['phase'] = 'db';
                        $s['stalls'] = 0;
                    } elseif ($r['uploaded'] === 0) {
                        if (++$s['stalls'] >= self::MAX_STALLS) {
                            $this->jobs->update($jobId, $s + ['error' => 'Upload stalled after ' . self::MAX_STALLS . ' passes'], 'failed');
                            return;
                        }
                    } else {
                        $s['stalls'] = 0;
                    }
                    break;

                case 'db':
                    if ($s['db_tables'] === null) { $s['db_tables'] = $this->dumper->changedTables(); }
                    $table = array_shift($s['db_tables']);
                    if ($table !== null) {
                        $this->dumper->dumpAndPush($table); // une table par step : borné
                    }
                    if ($s['db_tables'] === []) { $s['phase'] = 'manifest'; }
                    break;

                case 'manifest':
                    $out = $this->manifest->buildAndCommit(
                        $this->dumper->manifestEntries(),
                        (int) $s['files_changed'],
                        (int) $s['bytes_uploaded']
                    );
                    $s['snapshot_id'] = $out['snapshot_id'];
                    $this->jobs->update($jobId, $s, 'done');
                    return;
            }
        } catch (ApiException $e) {
            if ($e->status === 402 || ++$s['stalls'] >= self::MAX_STALLS) {
                $this->jobs->update($jobId, $s + ['error' => $e->getMessage()], 'failed');
                return;
            }
        } catch (\RuntimeException $e) {
            if (++$s['stalls'] >= self::MAX_STALLS) {
                $this->jobs->update($jobId, $s + ['error' => $e->getMessage()], 'failed');
                return;
            }
        }

        $this->jobs->update($jobId, $s, 'running');
        as_enqueue_async_action('wpb_backup_step', [$jobId], 'wp-back');
    }
}
```

Dans `Plugin::boot()`, ajouter `\WpBack\Backup\BackupRunner::register();` (hors du bloc `is_admin()` — les steps tournent aussi en cron/CLI).

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit`
Expected : `OK (…)` — suite complète verte, dont les 3 tests de `BackupRunnerTest`.

- [ ] **Step 5: Commit**

```bash
git add plugin/src plugin/tests
git commit -m "feat(plugin): backup state machine on action scheduler with stall guard"
```

---

### Task 10: Page Snapshots (calendrier des points de restauration)

**Files:**
- Create: `plugin/src/Admin/SnapshotsPage.php`
- Modify: `plugin/src/Plugin.php` (enregistrement)
- Test: `plugin/tests/Unit/Admin/SnapshotsPageTest.php`

**Interfaces:**
- Consumes: `Client::listSnapshots()` (Task 2).
- Produces:
  - `SnapshotsPage::register(): void` — sous-menu « Snapshots » sous `wp-back` + handler `admin_post_wpb_restore` (nonce `wpb_restore`, capability `manage_options`) qui déclenche `do_action('wpb_start_restore', string $manifestSha256)` — le découplage avec le RestoreRunner (Task 11) passe par ce hook.
  - `SnapshotsPage::rows(array $snapshots): string` — HTML des lignes (logique pure testable) : date, stats, formulaire Restore par snapshot avec champ caché `wpb_manifest_sha256`.

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

```php
<?php // plugin/tests/Unit/Admin/SnapshotsPageTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Admin;

use PHPUnit\Framework\TestCase;
use WpBack\Admin\SnapshotsPage;

final class SnapshotsPageTest extends TestCase
{
    protected function setUp(): void { wpb_test_reset(); }

    public function test_rows_renders_escaped_snapshot_data_and_restore_form(): void
    {
        $html = SnapshotsPage::rows([[
            'snapshot_id' => 'snap_1',
            'ts' => '2026-07-23T12:00:00Z',
            'manifest_sha256' => str_repeat('a', 64),
            'stats' => ['files_total' => 12, 'bytes_total' => 3456, 'files_changed' => 2, 'bytes_uploaded' => 100, 'db_tables' => 5],
        ]]);
        $this->assertStringContainsString('2026-07-23T12:00:00Z', $html);
        $this->assertStringContainsString('12', $html); // files_total
        $this->assertStringContainsString('name="wpb_manifest_sha256" value="' . str_repeat('a', 64) . '"', $html);
        $this->assertStringContainsString('value="wpb_restore"', $html); // action admin-post
    }

    public function test_rows_empty_state(): void
    {
        $this->assertStringContainsString('No snapshots yet', SnapshotsPage::rows([]));
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter SnapshotsPageTest`
Expected : `Error: Class "WpBack\Admin\SnapshotsPage" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Admin/SnapshotsPage.php
declare(strict_types=1);

namespace WpBack\Admin;

use WpBack\Api\ApiException;
use WpBack\Api\Client;

final class SnapshotsPage
{
    public static function register(): void
    {
        add_action('admin_menu', static function (): void {
            add_submenu_page('wp-back', 'Snapshots', 'Snapshots', 'manage_options', 'wp-back-snapshots', [self::class, 'render']);
        });
        add_action('admin_post_wpb_restore', static function (): void {
            if (!current_user_can('manage_options')) { wp_die('Forbidden'); }
            check_admin_referer('wpb_restore');
            $sha = sanitize_key((string) ($_POST['wpb_manifest_sha256'] ?? ''));
            if (strlen($sha) === 64) {
                do_action('wpb_start_restore', $sha);
            }
            wp_safe_redirect(admin_url('admin.php?page=wp-back-snapshots'));
            exit;
        });
    }

    public static function render(): void
    {
        try {
            $snapshots = (new Client())->listSnapshots();
        } catch (ApiException $e) {
            echo '<div class="wrap"><div class="notice notice-error"><p>' . esc_html($e->getMessage()) . '</p></div></div>';
            return;
        }
        echo '<div class="wrap"><h1>' . esc_html__('Restore points', 'wp-back') . '</h1>';
        echo '<table class="widefat striped"><thead><tr><th>Date</th><th>Files</th><th>Size</th><th>DB tables</th><th></th></tr></thead><tbody>';
        echo self::rows($snapshots); // phpcs:ignore -- rows() n'émet que du HTML échappé
        echo '</tbody></table></div>';
    }

    /** Logique pure : lignes du tableau, tout est échappé ici. */
    public static function rows(array $snapshots): string
    {
        if ($snapshots === []) {
            return '<tr><td colspan="5">' . esc_html__('No snapshots yet. Run your first backup.', 'wp-back') . '</td></tr>';
        }
        $out = '';
        foreach ($snapshots as $snap) {
            $stats = (array) ($snap['stats'] ?? []);
            $out .= '<tr>'
                . '<td>' . esc_html((string) $snap['ts']) . '</td>'
                . '<td>' . esc_html((string) ($stats['files_total'] ?? '?')) . '</td>'
                . '<td>' . esc_html(size_format((int) ($stats['bytes_total'] ?? 0))) . '</td>'
                . '<td>' . esc_html((string) ($stats['db_tables'] ?? '?')) . '</td>'
                . '<td><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">'
                . wp_nonce_field('wpb_restore', '_wpnonce', true, false)
                . '<input type="hidden" name="action" value="wpb_restore" />'
                . '<input type="hidden" name="wpb_manifest_sha256" value="' . esc_attr((string) $snap['manifest_sha256']) . '" />'
                . '<button type="submit" class="button">' . esc_html__('Restore', 'wp-back') . '</button>'
                . '</form></td></tr>';
        }
        return $out;
    }
}
```

Mettre à jour le stub `wp_nonce_field` dans `wp-functions.php` pour la signature 4 args :

```php
function wp_nonce_field(string $action, string $name = '_wpnonce', bool $referer = true, bool $display = true): string
{
    $field = '<input type="hidden" name="' . $name . '" value="nonce-' . $action . '" />';
    if ($display) { echo $field; }
    return $field;
}
```

Dans `Plugin::boot()`, à côté de `SettingsPage::register()` (bloc `is_admin()`) : `\WpBack\Admin\SnapshotsPage::register();`

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit`
Expected : `OK (…)` — suite complète verte, dont les 2 tests de `SnapshotsPageTest`.

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Admin plugin/src/Plugin.php plugin/tests
git commit -m "feat(plugin): snapshots list page with per-snapshot restore action"
```

---

### Task 11: RestoreRunner (restore incrémental batché)

**Files:**
- Create: `plugin/src/Restore/SqlSplitter.php`
- Create: `plugin/src/Restore/RestoreRunner.php`
- Modify: `plugin/src/Plugin.php` (enregistrement)
- Test: `plugin/tests/Unit/Restore/SqlSplitterTest.php`
- Test: `plugin/tests/Unit/Restore/RestoreRunnerTest.php`

**Interfaces:**
- Consumes: `Client::signDownload` (Task 2), `JobStoreInterface` (Task 9), `Paths` (Task 3), hook `wpb_start_restore` (Task 10), manifest (format Task 8).
- Produces:
  - `SqlSplitter::split(string $sql): array` — découpe un dump en requêtes exécutables en respectant quotes/échappements (jamais de split naïf sur `;`).
  - `RestoreRunner::__construct(JobStoreInterface $jobs, Client $client, object $db)`
  - `RestoreRunner::make(): self` ; `RestoreRunner::register(): void` — hooke `wpb_start_restore` (→ `start`) et `wpb_restore_step` (→ `step`).
  - `RestoreRunner::$getter` — `callable(string $url, string $destPath): int` statique injectable (code HTTP), défaut GET curl streaming vers fichier.
  - `RestoreRunner::start(string $manifestSha256): int`
  - `RestoreRunner::step(int $jobId): void` — phases : `fetch_manifest → plan → download → apply_files → import_db → done`. Maintenance mode (`.maintenance` à la racine) activé au début d'`apply_files`, retiré en fin d'`import_db` **et** en cas d'échec. Remplacements atomiques : écrire `chemin.wpb-new` puis `rename()`.
  - Plan de restore (phase `plan`, batchée par 200 entrées) : pour chaque entrée du manifest, blob à télécharger si le fichier local manque ou si son sha256 recalculé diffère ; les fichiers locaux non exclus absents du manifest sont supprimés en `apply_files`.

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Unit/Restore/SqlSplitterTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Restore;

use PHPUnit\Framework\TestCase;
use WpBack\Restore\SqlSplitter;

final class SqlSplitterTest extends TestCase
{
    public function test_splits_simple_statements(): void
    {
        $this->assertSame(['A', 'B'], SqlSplitter::split("A;\nB;"));
    }

    public function test_does_not_split_inside_quoted_strings(): void
    {
        $sql = "INSERT INTO `t` (`c`) VALUES ('a;\nb');\nINSERT INTO `t` (`c`) VALUES ('it\\'s;fine');";
        $out = SqlSplitter::split($sql);
        $this->assertCount(2, $out);
        $this->assertStringContainsString("a;\nb", $out[0]);
        $this->assertStringContainsString("it\\'s;fine", $out[1]);
    }
}
```

```php
<?php // plugin/tests/Unit/Restore/RestoreRunnerTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit\Restore;

use PHPUnit\Framework\TestCase;
use WpBack\Api\Client;
use WpBack\Restore\RestoreRunner;
use WpBack\Tests\Support\FakeWpdb;
use WpBack\Tests\Support\InMemoryJobStore;

final class RestoreRunnerTest extends TestCase
{
    private InMemoryJobStore $jobs;
    private FakeWpdb $db;
    /** @var array<string, string> sha256 => contenu (déjà gzippé pour manifest/dumps) */
    private array $blobs = [];

    protected function setUp(): void
    {
        wpb_test_reset();
        update_option('wpb_site_token', 'jwt');
        Client::$sleeper = static fn(int $s) => null;
        if (!defined('WP_CONTENT_DIR')) { define('WP_CONTENT_DIR', ABSPATH . 'wp-content'); }
        foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(ABSPATH, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
            $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
        }
        @mkdir(ABSPATH . 'wp-content', 0777, true);

        $this->jobs = new InMemoryJobStore();
        $this->db = new FakeWpdb();

        // Le "réseau" : sign-download renvoie une URL blob://<sha>, le getter écrit le contenu correspondant.
        RestoreRunner::$getter = function (string $url, string $dest): int {
            $sha = substr($url, strlen('blob://'));
            if (!isset($this->blobs[$sha])) { return 404; }
            file_put_contents($dest, $this->blobs[$sha]);
            return 200;
        };
    }

    private function addBlob(string $content): string
    {
        $sha = hash('sha256', $content);
        $this->blobs[$sha] = $content;
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['download_url' => 'blob://' . $sha, 'expires_at' => 'x'])];
        return $sha;
    }

    private function drain(RestoreRunner $runner, int $max = 60): void
    {
        while ($GLOBALS['wpb_test']['scheduled'] !== [] && $max-- > 0) {
            $action = array_shift($GLOBALS['wpb_test']['scheduled']);
            $runner->step($action['args'][0]);
        }
    }

    public function test_full_restore_downloads_diff_applies_files_and_imports_db(): void
    {
        // État du disque : good.php intact, bad.php modifié, extra.php à supprimer
        file_put_contents(ABSPATH . 'good.php', 'GOOD');
        file_put_contents(ABSPATH . 'bad.php', 'TAMPERED');
        file_put_contents(ABSPATH . 'extra.php', 'EXTRA');

        $goodSha = hash('sha256', 'GOOD');
        $badSha = hash('sha256', 'ORIGINAL');
        $dumpGz = gzencode("DROP TABLE IF EXISTS `wp_posts`;\nCREATE TABLE `wp_posts` (`ID` bigint);\nINSERT INTO `wp_posts` (`ID`) VALUES ('1');\n", 6);
        $dumpSha = hash('sha256', $dumpGz);

        $manifest = gzencode(json_encode([
            'version' => 1, 'ts' => '2026-07-23T12:00:00Z', 'wp' => ['core' => '6.6', 'php' => '8.2'],
            'files' => [
                ['p' => 'good.php', 'h' => $goodSha, 's' => 4, 'm' => 1],
                ['p' => 'bad.php', 'h' => $badSha, 's' => 8, 'm' => 1],
            ],
            'db' => [['table' => 'wp_posts', 'h' => $dumpSha, 's' => strlen($dumpGz), 'checksum' => '111']],
        ]), 6);
        $manifestSha = hash('sha256', $manifest);
        $this->blobs[$manifestSha] = $manifest;

        // Ordre des sign-download attendu : manifest, puis bad.php (seul fichier différent), puis dump
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['download_url' => 'blob://' . $manifestSha, 'expires_at' => 'x'])];
        $this->blobs[$badSha] = 'ORIGINAL';
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['download_url' => 'blob://' . $badSha, 'expires_at' => 'x'])];
        $this->blobs[$dumpSha] = $dumpGz;
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['download_url' => 'blob://' . $dumpSha, 'expires_at' => 'x'])];

        $runner = new RestoreRunner($this->jobs, new Client('https://api.test.invalid'), $this->db);
        $id = $runner->start($manifestSha);
        $this->drain($runner);

        $job = $this->jobs->get($id);
        $this->assertSame('done', $job['status']);
        $this->assertSame('ORIGINAL', file_get_contents(ABSPATH . 'bad.php'));   // restauré
        $this->assertSame('GOOD', file_get_contents(ABSPATH . 'good.php'));      // pas re-téléchargé
        $this->assertFileDoesNotExist(ABSPATH . 'extra.php');                    // supprimé
        $this->assertFileDoesNotExist(ABSPATH . '.maintenance');                 // maintenance retirée
        $this->assertSame(1, $job['state']['downloaded']);                       // un seul blob fichier téléchargé
        $imported = implode("\n", $this->db->queries);
        $this->assertStringContainsString('DROP TABLE IF EXISTS `wp_posts`', $imported);
        $this->assertStringContainsString("INSERT INTO `wp_posts`", $imported);
    }

    public function test_failed_download_fails_job_and_removes_maintenance(): void
    {
        file_put_contents(ABSPATH . 'bad.php', 'TAMPERED');
        $badSha = hash('sha256', 'ORIGINAL');
        $manifest = gzencode(json_encode([
            'version' => 1, 'ts' => 't', 'wp' => [], 'files' => [['p' => 'bad.php', 'h' => $badSha, 's' => 8, 'm' => 1]], 'db' => [],
        ]), 6);
        $manifestSha = hash('sha256', $manifest);
        $this->blobs[$manifestSha] = $manifest;
        $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['download_url' => 'blob://' . $manifestSha, 'expires_at' => 'x'])];
        // sign-download du blob renverra 404 via getter (blob absent) — 3 tentatives
        for ($i = 0; $i < 3; $i++) {
            $GLOBALS['wpb_test']['http_queue'][] = ['code' => 200, 'body' => json_encode(['download_url' => 'blob://' . $badSha, 'expires_at' => 'x'])];
        }

        $runner = new RestoreRunner($this->jobs, new Client('https://api.test.invalid'), $this->db);
        $id = $runner->start($manifestSha);
        $this->drain($runner);

        $this->assertSame('failed', $this->jobs->get($id)['status']);
        $this->assertFileDoesNotExist(ABSPATH . '.maintenance');
        $this->assertSame('TAMPERED', file_get_contents(ABSPATH . 'bad.php')); // rien d'appliqué à moitié
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter 'SqlSplitterTest|RestoreRunnerTest'`
Expected : `Error: Class "WpBack\Restore\SqlSplitter" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Restore/SqlSplitter.php
declare(strict_types=1);

namespace WpBack\Restore;

final class SqlSplitter
{
    /** @return string[] requêtes sans le `;` final, chaînes quotées respectées */
    public static function split(string $sql): array
    {
        $out = [];
        $buf = '';
        $inString = false;
        $len = strlen($sql);
        for ($i = 0; $i < $len; $i++) {
            $ch = $sql[$i];
            if ($inString) {
                $buf .= $ch;
                if ($ch === '\\') { $buf .= $sql[++$i] ?? ''; continue; } // séquence échappée
                if ($ch === "'") { $inString = false; }
                continue;
            }
            if ($ch === "'") { $inString = true; $buf .= $ch; continue; }
            if ($ch === ';') {
                $stmt = trim($buf);
                if ($stmt !== '') { $out[] = $stmt; }
                $buf = '';
                continue;
            }
            $buf .= $ch;
        }
        $stmt = trim($buf);
        if ($stmt !== '') { $out[] = $stmt; }
        return $out;
    }
}
```

```php
<?php // plugin/src/Restore/RestoreRunner.php
declare(strict_types=1);

namespace WpBack\Restore;

use WpBack\Api\Client;
use WpBack\Backup\JobStoreInterface;
use WpBack\Backup\WpdbJobStore;
use WpBack\Support\Paths;

final class RestoreRunner
{
    private const PLAN_BATCH = 200;
    private const DOWNLOAD_BATCH = 20;
    private const MAX_ATTEMPTS = 3;

    /** @var callable(string, string): int GET streaming vers fichier ; injectable en test. */
    public static $getter = [self::class, 'curlGet'];

    public function __construct(private JobStoreInterface $jobs, private Client $client, private object $db) {}

    public static function make(): self
    {
        global $wpdb;
        return new self(new WpdbJobStore($wpdb), new Client(), $wpdb);
    }

    public static function register(): void
    {
        add_action('wpb_start_restore', static function (string $sha): void { self::make()->start($sha); });
        add_action('wpb_restore_step', static function (int $jobId): void { self::make()->step($jobId); });
    }

    public function start(string $manifestSha256): int
    {
        $running = $this->jobs->findRunning('restore');
        if ($running !== null) { return $running['id']; }
        $id = $this->jobs->create('restore', [
            'phase' => 'fetch_manifest', 'manifest_sha256' => $manifestSha256,
            'manifest_path' => null, 'plan_cursor' => 0, 'to_download' => [], 'to_delete' => [],
            'downloaded' => 0, 'attempts' => 0,
        ]);
        as_enqueue_async_action('wpb_restore_step', [$id], 'wp-back');
        return $id;
    }

    public function step(int $jobId): void
    {
        $job = $this->jobs->get($jobId);
        if ($job === null || $job['status'] !== 'running') { return; }
        $s = $job['state'];

        try {
            switch ($s['phase']) {
                case 'fetch_manifest':
                    $dest = Paths::tmpDir() . 'restore-manifest.json.gz';
                    $this->download($s['manifest_sha256'], $dest);
                    $s['manifest_path'] = $dest;
                    $s['phase'] = 'plan';
                    break;

                case 'plan':
                    $manifest = $this->manifest($s);
                    $files = $manifest['files'];
                    $slice = array_slice($files, $s['plan_cursor'], self::PLAN_BATCH);
                    foreach ($slice as $entry) {
                        $abs = Paths::absolute($entry['p']);
                        if (!is_file($abs) || hash_file('sha256', $abs) !== $entry['h']) {
                            $s['to_download'][] = ['p' => $entry['p'], 'h' => $entry['h']];
                        }
                    }
                    $s['plan_cursor'] += count($slice);
                    if ($s['plan_cursor'] >= count($files)) {
                        $s['to_delete'] = $this->extraneousFiles($files);
                        $s['phase'] = 'download';
                    }
                    break;

                case 'download':
                    // Retirer chaque item SEULEMENT après téléchargement réussi : en cas
                    // d'exception, l'item reste dans to_download pour la reprise/échec propre.
                    $n = min(self::DOWNLOAD_BATCH, count($s['to_download']));
                    for ($i = 0; $i < $n; $i++) {
                        $item = $s['to_download'][0];
                        $this->download($item['h'], Paths::tmpDir() . 'blobs-' . $item['h']);
                        array_shift($s['to_download']);
                        $s['downloaded']++;
                    }
                    if ($s['to_download'] === []) { $s['phase'] = 'apply_files'; }
                    break;

                case 'apply_files':
                    file_put_contents(ABSPATH . '.maintenance', "<?php \$upgrading = time();\n");
                    $manifest = $this->manifest($s);
                    foreach ($manifest['files'] as $entry) {
                        $blob = Paths::tmpDir() . 'blobs-' . $entry['h'];
                        if (!is_file($blob)) { continue; } // fichier local déjà correct : rien à faire
                        $abs = Paths::absolute($entry['p']);
                        @mkdir(dirname($abs), 0755, true);
                        copy($blob, $abs . '.wpb-new');
                        rename($abs . '.wpb-new', $abs); // atomique sur le même filesystem
                        @unlink($blob);
                    }
                    foreach ($s['to_delete'] as $rel) { @unlink(Paths::absolute($rel)); }
                    $s['phase'] = 'import_db';
                    break;

                case 'import_db':
                    $manifest = $this->manifest($s);
                    foreach ($manifest['db'] as $entry) {
                        $tmp = Paths::tmpDir() . 'restore-db-' . $entry['h'] . '.sql.gz';
                        $this->download($entry['h'], $tmp);
                        foreach (SqlSplitter::split((string) gzdecode((string) file_get_contents($tmp))) as $stmt) {
                            $this->db->query($stmt); // dump généré par nous : DROP/CREATE/INSERT
                        }
                        @unlink($tmp);
                    }
                    @unlink(ABSPATH . '.maintenance');
                    @unlink((string) $s['manifest_path']);
                    $this->jobs->update($jobId, $s, 'done');
                    return;
            }
            $s['attempts'] = 0;
        } catch (\Throwable $e) {
            if (++$s['attempts'] >= self::MAX_ATTEMPTS) {
                @unlink(ABSPATH . '.maintenance'); // ne jamais laisser le site verrouillé
                $this->jobs->update($jobId, $s + ['error' => $e->getMessage()], 'failed');
                return;
            }
        }

        $this->jobs->update($jobId, $s, 'running');
        as_enqueue_async_action('wpb_restore_step', [$jobId], 'wp-back');
    }

    private function manifest(array $s): array
    {
        return (array) json_decode((string) gzdecode((string) file_get_contents((string) $s['manifest_path'])), true);
    }

    /** Fichiers locaux non exclus absents du manifest → à supprimer. */
    private function extraneousFiles(array $manifestFiles): array
    {
        $known = array_flip(array_column($manifestFiles, 'p'));
        $extra = [];
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(ABSPATH, \FilesystemIterator::SKIP_DOTS));
        foreach ($it as $file) {
            if (!$file->isFile()) { continue; }
            $rel = Paths::relative($file->getPathname());
            if (!Paths::isExcluded($rel) && !isset($known[$rel])) { $extra[] = $rel; }
        }
        return $extra;
    }

    private function download(string $sha256, string $dest): void
    {
        $sign = $this->client->signDownload($sha256);
        $code = (self::$getter)($sign['download_url'], $dest);
        if ($code !== 200) {
            throw new \RuntimeException("Blob download failed with HTTP $code for $sha256");
        }
    }

    public static function curlGet(string $url, string $dest): int
    {
        $fh = fopen($dest, 'wb');
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_FILE => $fh, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 300]);
        curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        fclose($fh);
        return $code;
    }
}
```

Dans `Plugin::boot()` : `\WpBack\Restore\RestoreRunner::register();` (hors `is_admin()`).

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit`
Expected : `OK (…)` — suite complète verte, dont `SqlSplitterTest` (2) et `RestoreRunnerTest` (2).

- [ ] **Step 5: Commit**

```bash
git add plugin/src/Restore plugin/src/Plugin.php plugin/tests
git commit -m "feat(plugin): incremental restore runner with atomic apply and safe sql import"
```

---

### Task 12: Planification, exclusions personnalisées et affichage d'usage

**Files:**
- Create: `plugin/src/Schedule.php`
- Modify: `plugin/src/Admin/SettingsPage.php` (formulaire réglages + panneau usage + bouton « Backup now »)
- Modify: `plugin/src/Plugin.php` (enregistrement)
- Test: `plugin/tests/Unit/ScheduleTest.php`

**Interfaces:**
- Consumes: `as_schedule_recurring_action`/`as_unschedule_all_actions` (stubs Task 1), `BackupRunner::start` (Task 9), `Client::usage` (Task 2), option `wpb_custom_exclusions` (lue par `Paths`, Task 3).
- Produces:
  - `Schedule::$clock` — `callable(): int` statique injectable (défaut `'time'`).
  - `Schedule::apply(array $input): array{frequency: string, hour: int}` — sanitize (`daily`|`hourly`, heure 0–23), persiste `wpb_schedule`, déprogramme `wpb_scheduled_backup` puis reprogramme : hourly ⇒ prochain top d'heure, interval 3600 ; daily ⇒ prochaine occurrence de l'heure choisie (UTC), interval 86400.
  - `Schedule::sanitizeExclusions(string $raw): array` — une entrée par ligne, trim, slashes normalisés, lignes vides et traversées `..` rejetées ; persistée dans `wpb_custom_exclusions` par le handler.
  - `Schedule::register(): void` — `add_action('wpb_scheduled_backup', …)` → `BackupRunner::make()->start()` ; handler `admin_post_wpb_save_settings` (nonce `wpb_save_settings`, `manage_options`).

- [ ] **Step 1: Écrire les tests qui échouent**

```php
<?php // plugin/tests/Unit/ScheduleTest.php
declare(strict_types=1);

namespace WpBack\Tests\Unit;

use PHPUnit\Framework\TestCase;
use WpBack\Schedule;

final class ScheduleTest extends TestCase
{
    protected function setUp(): void
    {
        wpb_test_reset();
        Schedule::$clock = static fn(): int => gmmktime(10, 30, 0, 7, 23, 2026); // 2026-07-23 10:30:00 UTC
    }

    public function test_daily_schedule_targets_next_occurrence_of_hour(): void
    {
        $out = Schedule::apply(['wpb_frequency' => 'daily', 'wpb_hour' => '3']);
        $this->assertSame(['frequency' => 'daily', 'hour' => 3], $out);
        $this->assertSame($out, get_option('wpb_schedule'));
        $entry = end($GLOBALS['wpb_test']['scheduled']);
        $this->assertSame('wpb_scheduled_backup', $entry['hook']);
        $this->assertSame(86400, $entry['interval']);
        // 03:00 est déjà passé le 23 → prochain run le 24 à 03:00 UTC
        $this->assertSame(gmmktime(3, 0, 0, 7, 24, 2026), $entry['timestamp'] ?? null);
    }

    public function test_hourly_schedule_replaces_previous_one(): void
    {
        Schedule::apply(['wpb_frequency' => 'daily', 'wpb_hour' => '3']);
        Schedule::apply(['wpb_frequency' => 'hourly', 'wpb_hour' => '0']);
        $hooks = array_column($GLOBALS['wpb_test']['scheduled'], 'hook');
        $this->assertSame(['wpb_scheduled_backup'], $hooks); // l'ancien a été déprogrammé
        $this->assertSame(3600, end($GLOBALS['wpb_test']['scheduled'])['interval']);
    }

    public function test_invalid_input_falls_back_to_daily_at_2(): void
    {
        $out = Schedule::apply(['wpb_frequency' => 'weekly', 'wpb_hour' => '99']);
        $this->assertSame(['frequency' => 'daily', 'hour' => 2], $out);
    }

    public function test_sanitize_exclusions_rejects_traversal_and_blank_lines(): void
    {
        $raw = " wp-content/uploads/big \n\n../etc/passwd\nwp-content\\cache2\n";
        $this->assertSame(['wp-content/uploads/big', 'wp-content/cache2'], Schedule::sanitizeExclusions($raw));
    }
}
```

Mettre à jour le stub `as_schedule_recurring_action` (Task 1) pour capturer le timestamp :

```php
function as_schedule_recurring_action(int $ts, int $interval, string $hook, array $args = [], string $group = ''): int
{
    $GLOBALS['wpb_test']['scheduled'][] = ['hook' => $hook, 'args' => $args, 'group' => $group, 'interval' => $interval, 'timestamp' => $ts];
    return count($GLOBALS['wpb_test']['scheduled']);
}
```

- [ ] **Step 2: Vérifier l'échec**

Run : `cd plugin && vendor/bin/phpunit --filter ScheduleTest`
Expected : `Error: Class "WpBack\Schedule" not found`

- [ ] **Step 3: Implémentation**

```php
<?php // plugin/src/Schedule.php
declare(strict_types=1);

namespace WpBack;

use WpBack\Backup\BackupRunner;

final class Schedule
{
    /** @var callable(): int */
    public static $clock = 'time';

    public static function register(): void
    {
        add_action('wpb_scheduled_backup', static function (): void {
            BackupRunner::make()->start();
        });
        add_action('admin_post_wpb_save_settings', static function (): void {
            if (!current_user_can('manage_options')) { wp_die('Forbidden'); }
            check_admin_referer('wpb_save_settings');
            $post = wp_unslash($_POST);
            self::apply($post);
            update_option('wpb_custom_exclusions', self::sanitizeExclusions((string) ($post['wpb_exclusions'] ?? '')));
            wp_safe_redirect(admin_url('admin.php?page=wp-back'));
            exit;
        });
    }

    /** @return array{frequency: string, hour: int} */
    public static function apply(array $input): array
    {
        $frequency = in_array($input['wpb_frequency'] ?? '', ['daily', 'hourly'], true) ? $input['wpb_frequency'] : 'daily';
        $hour = (int) ($input['wpb_hour'] ?? 2);
        if ($hour < 0 || $hour > 23) { $hour = 2; }
        $settings = ['frequency' => $frequency, 'hour' => $hour];
        update_option('wpb_schedule', $settings);

        as_unschedule_all_actions('wpb_scheduled_backup');
        $now = (self::$clock)();
        if ($frequency === 'hourly') {
            $next = $now - ($now % 3600) + 3600; // prochain top d'heure
            as_schedule_recurring_action($next, 3600, 'wpb_scheduled_backup', [], 'wp-back');
        } else {
            $today = strtotime(gmdate('Y-m-d', $now) . sprintf(' %02d:00:00 UTC', $hour));
            $next = $today > $now ? $today : $today + 86400;
            as_schedule_recurring_action($next, 86400, 'wpb_scheduled_backup', [], 'wp-back');
        }
        return $settings;
    }

    /** @return string[] */
    public static function sanitizeExclusions(string $raw): array
    {
        $out = [];
        foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
            $line = trim(str_replace('\\', '/', $line));
            if ($line === '' || str_contains($line, '..')) { continue; }
            $out[] = ltrim($line, '/');
        }
        return $out;
    }
}
```

Dans `SettingsPage::render()`, remplacer le commentaire `// Les tâches 9/10/12 ajoutent ici …` par le bloc connecté :

```php
            try {
                $usage = (new Client())->usage();
                echo '<p>' . esc_html__('Storage used:', 'wp-back') . ' <strong>'
                    . esc_html(size_format((int) $usage['storage_bytes_used'])) . '</strong> / '
                    . esc_html(size_format((int) $usage['storage_bytes_limit']))
                    . ' — ' . esc_html((string) $usage['snapshot_count']) . ' snapshots, '
                    . esc_html((string) $usage['retention_days']) . ' ' . esc_html__('days retention', 'wp-back') . '</p>';
            } catch (ApiException $e) {
                echo '<div class="notice notice-warning"><p>' . esc_html($e->getMessage()) . '</p></div>';
            }

            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-bottom:2em">';
            wp_nonce_field('wpb_backup_now');
            echo '<input type="hidden" name="action" value="wpb_backup_now" />';
            submit_button(__('Backup now', 'wp-back'), 'primary', 'submit', false);
            echo '</form>';

            $schedule = (array) get_option('wpb_schedule', ['frequency' => 'daily', 'hour' => 2]);
            $exclusions = implode("\n", (array) get_option('wpb_custom_exclusions', []));
            echo '<h2>' . esc_html__('Settings', 'wp-back') . '</h2>';
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            wp_nonce_field('wpb_save_settings');
            echo '<input type="hidden" name="action" value="wpb_save_settings" />';
            echo '<p><label>' . esc_html__('Frequency', 'wp-back') . ' <select name="wpb_frequency">';
            foreach (['daily', 'hourly'] as $f) {
                echo '<option value="' . esc_attr($f) . '"' . ($schedule['frequency'] === $f ? ' selected' : '') . '>' . esc_html($f) . '</option>';
            }
            echo '</select></label> ';
            echo '<label>' . esc_html__('Hour (UTC, daily only)', 'wp-back') . ' <input type="number" name="wpb_hour" min="0" max="23" value="' . esc_attr((string) $schedule['hour']) . '" /></label></p>';
            echo '<p><label>' . esc_html__('Extra exclusions (one relative path per line)', 'wp-back') . '<br/>';
            echo '<textarea name="wpb_exclusions" rows="4" class="large-text">' . esc_html($exclusions) . '</textarea></label></p>';
            submit_button(__('Save settings', 'wp-back'));
            echo '</form>';
```

Dans `Plugin::boot()` : `\WpBack\Schedule::register();` (hors `is_admin()`).

- [ ] **Step 4: Vérifier que les tests passent**

Run : `cd plugin && vendor/bin/phpunit`
Expected : `OK (…)` — suite complète verte, dont les 4 tests de `ScheduleTest`.

- [ ] **Step 5: Commit**

```bash
git add plugin/src plugin/tests
git commit -m "feat(plugin): backup scheduling, custom exclusions and usage panel"
```

---

## Vérification finale du sous-système (après Task 12)

- [ ] Suite unitaire complète : `cd plugin && composer test` → `OK`, zéro warning.
- [ ] Smoke test d'intégration manuel : `cd plugin && npx wp-env start`, activer le plugin dans `http://localhost:8888/wp-admin` — l'activation crée les tables `wp_wpb_files`/`wp_wpb_jobs` et la page « WP Back » s'affiche sans notice PHP. (Le flux complet backup→restore nécessite l'API de `api/` — testé dans le plan E2E, hors périmètre de ce plan.)
- [ ] `git log --oneline` : un commit Conventional Commits par tâche, aucune clé S3 ni secret dans le diff (`git log -p | grep -iE 'aws|wasabi|secret_key'` ne matche que de la doc).
