<?php
/**
 * Coupé 406 Club — service e-mail de l'application Android (Novalys Apps).
 *
 * Posé dans le site Gears existant, il réutilise son envoi Brevo déjà configuré.
 * Il ne touche à aucun fichier de Gears et ne crée aucune table.
 *
 * Fonctionnement, comme sur Gears : le membre reçoit un e-mail avec un lien.
 * Il clique, une page de confirmation s'affiche, et l'application — restée
 * ouverte — le voit et continue toute seule.
 *
 * Actions (POST JSON) :
 *   {"action":"send_link","email":"…","purpose":"verify|reset","name":"…"}  → {ref}
 *   {"action":"link_status","ref":"…"}                                      → {confirmed}
 *   {"action":"contact","category":"…","message":"…","email":"…","name":"…"}
 * Lien cliqué (GET) : api.php?t=<jeton signé>
 */

require_once __DIR__ . '/secret.php';
require_once __DIR__ . '/moderation.php'; // C406_SECRET, C406_APP_KEY, C406_SENDER
require_once __DIR__ . '/stl.php';

const GEARS_ROOT = '/var/www/coupe406';
const CONTACT_TO = 'contact.novalysdev@gmail.com';
const LINK_TTL   = 3600;  // le lien reste valable une heure
const MAX_SENDS  = 3;     // envois autorisés par quart d'heure
// Nouvelle campagne propre : les anciennes confirmations de l'application
// n'étaient pas des validations Google Play et sont supprimées du serveur.
const PLAY_TEST_CAMPAIGN = 'google-play-ferme-2026-v2';
const PLAY_TEST_TARGET = 12;
// Build a partir duquel l app sait ouvrir la carte depuis une alerte de panne.
const PANNE_SUR_CARTE_DEPUIS = 212;
// Valeurs de secours. En fonctionnement normal, latest.json ci-dessous fait
// foi : dès qu'une version est publiée, elle devient automatiquement la
// version minimale proposée à tous les comptes.
const MINIMUM_APP_CODE = 199;
const MINIMUM_APP_VERSION = '1.05';
const MINIMUM_APP_URL = 'https://gears.hostline.fr/coupe406/Coupe-406-Club-1.05.apk';

function reply(array $body, int $status = 200): never
{
    header('Content-Type: application/json; charset=utf-8');
    header('Access-Control-Allow-Origin: *');
    header('Cache-Control: no-store');
    http_response_code($status);
    echo json_encode($body, JSON_UNESCAPED_UNICODE);
    exit;
}

/** Normalise les anciens noms 1.00–1.05 ainsi que les versions 0.x. */
function versionClientNormalisee(string $version): ?string
{
    $version = ltrim(strtolower(trim($version)), 'v');
    if ($version === '1.0') {
        return '1.0.0';
    }
    if (preg_match('/^1\.(\d{2})$/', $version, $correspondance) === 1) {
        return '1.0.' . (int)$correspondance[1];
    }
    if (preg_match('/^1\.0\.(\d+)$/', $version, $correspondance) === 1) {
        return '1.0.' . (int)$correspondance[1];
    }
    if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/', $version, $correspondance) === 1) {
        return (int)$correspondance[1] . '.'
            . (int)($correspondance[2] ?? 0) . '.'
            . (int)($correspondance[3] ?? 0);
    }
    return null;
}

function ancienneVersionAvecRappel(string $version): bool
{
    $normalisee = versionClientNormalisee($version);
    return $normalisee !== null
        && version_compare($normalisee, '1.0.5', '<=');
}

function page(string $title, string $message, bool $good = true): never
{
    header('Content-Type: text/html; charset=utf-8');
    header('Cache-Control: no-store');
    echo '<!doctype html><html lang="fr"><head><meta charset="utf-8">'
        . '<meta name="viewport" content="width=device-width,initial-scale=1">'
        . '<title>' . $title . ' — Coupé 406 Club</title></head>'
        . '<body style="margin:0;background:#edf0ef;font-family:system-ui,Arial,sans-serif;color:#172128;display:grid;place-items:center;min-height:100vh;padding:20px">'
        . '<main style="max-width:420px;width:100%;background:#fff;border-radius:22px;padding:30px;text-align:center;box-shadow:0 18px 40px #17222915">'
        . '<p style="margin:0;font-size:27px;font-weight:800">406 <span style="font-size:12px;letter-spacing:1.6px;color:#7c8e0b">COUPÉ CLUB</span></p>'
        . '<div style="margin:24px auto;width:58px;height:58px;border-radius:50%;display:grid;place-items:center;font-size:28px;background:'
        . ($good ? '#e6ff38' : '#f6ddda') . '">' . ($good ? '✓' : '!') . '</div>'
        . '<h1 style="margin:0 0 10px;font-size:21px">' . $title . '</h1>'
        . '<p style="margin:0;font-size:14px;line-height:1.6;color:#5b6669">' . $message . '</p>'
        . '<p style="margin:26px 0 0;font-size:11px;color:#959c9f">Développé par Novalys Apps</p>'
        . '</main></body></html>';
    exit;
}

function b64(string $raw): string
{
    return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}

function unb64(string $value): string
{
    return (string)base64_decode(strtr($value, '-_', '+/'), true);
}

/** Jeton autoporteur : il contient l'adresse, l'usage et sa date de péremption. */
function makeToken(string $email, string $purpose): string
{
    $payload = b64($email . '|' . $purpose . '|' . (time() + LINK_TTL) . '|' . bin2hex(random_bytes(6)));
    return $payload . '.' . b64(hash_hmac('sha256', $payload, C406_SECRET, true));
}

function readToken(string $token): ?array
{
    $parts = explode('.', $token);
    if (count($parts) !== 2) {
        return null;
    }
    [$payload, $signature] = $parts;
    if (!hash_equals(b64(hash_hmac('sha256', $payload, C406_SECRET, true)), $signature)) {
        return null;
    }
    $fields = explode('|', unb64($payload));
    if (count($fields) !== 4 || (int)$fields[2] < time()) {
        return null;
    }
    return ['email' => $fields[0], 'purpose' => $fields[1]];
}

/**
 * Référence rendue à l'application. Elle dérive du jeton par une fonction à sens
 * unique : l'app peut demander « est-ce confirmé ? » sans pouvoir fabriquer le
 * lien elle-même — sinon la vérification de l'adresse ne prouverait rien.
 */
function refOf(string $token): string
{
    return hash_hmac('sha256', 'ref|' . $token, C406_SECRET);
}

function markerPath(string $ref): string
{
    return sys_get_temp_dir() . '/c406_ok_' . preg_replace('/[^a-f0-9]/', '', $ref) . '.flag';
}

function throttle(string $key, int $limit): bool
{
    $path = sys_get_temp_dir() . '/c406_' . hash('sha256', $key) . '.cnt';
    $slot = intdiv(time(), 900);
    $data = @json_decode((string)@file_get_contents($path), true);
    if (!is_array($data) || ($data['slot'] ?? null) !== $slot) {
        $data = ['slot' => $slot, 'count' => 0];
    }
    if ($data['count'] >= $limit) {
        return false;
    }
    $data['count']++;
    @file_put_contents($path, json_encode($data), LOCK_EX);
    return true;
}

function validEmail(string $value): bool
{
    return (bool)filter_var($value, FILTER_VALIDATE_EMAIL);
}

/** Transport d'envoi : celui que Gears utilise déjà, lu dans son .env. */
function mailerDsn(): string
{
    foreach (['/.env.local', '/.env'] as $file) {
        $path = GEARS_ROOT . $file;
        if (!is_readable($path)) {
            continue;
        }
        foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
            if (preg_match('/^\s*MAILER_DSN\s*=\s*(.+)$/', $line, $found)) {
                return trim($found[1], " \t\"'");
            }
        }
    }
    return '';
}

function sendMail(string $to, string $subject, string $text, string $html, ?string $replyTo = null): array
{
    $dsn = mailerDsn();
    if ($dsn === '') {
        return [false, 'MAILER_MISSING'];
    }
    require_once GEARS_ROOT . '/vendor/autoload.php';
    try {
        $mailer = new Symfony\Component\Mailer\Mailer(Symfony\Component\Mailer\Transport::fromDsn($dsn));
        $message = (new Symfony\Component\Mime\Email())
            ->from(new Symfony\Component\Mime\Address(C406_SENDER, 'Coupé 406 Club'))
            ->to($to)
            ->subject($subject)
            ->text($text)
            ->html($html);
        if ($replyTo !== null && validEmail($replyTo)) {
            $message->replyTo($replyTo);
        }
        $mailer->send($message);
        return [true, null];
    } catch (Throwable $error) {
        error_log('[coupe406] envoi impossible : ' . $error->getMessage());
        return [false, 'SEND_FAILED'];
    }
}

function linkEmail(string $name, string $url, string $purpose): array
{
    $reset = $purpose === 'reset';
    $subject = $reset ? 'Réinitialiser votre mot de passe — Coupé 406 Club' : 'Confirmez votre compte — Coupé 406 Club';
    $intro = $reset
        ? 'Vous avez demandé à changer le mot de passe de votre compte du Coupé 406 Club. Cliquez sur le bouton pour le confirmer, puis retournez dans l’application.'
        : 'Bienvenue au Coupé 406 Club ! Il ne reste qu’à confirmer votre adresse e-mail : cliquez sur le bouton, puis retournez dans l’application.';
    $label = $reset ? 'Confirmer ma demande' : 'Confirmer mon adresse';
    $hello = $name !== '' ? 'Bonjour ' . htmlspecialchars($name, ENT_QUOTES) . ',' : 'Bonjour,';

    $html = '<!doctype html><html lang="fr"><body style="margin:0;background:#edf0ef;font-family:Arial,Helvetica,sans-serif;color:#172128">'
        . '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:28px 12px"><tr><td align="center">'
        . '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:500px;background:#fff;border-radius:20px;overflow:hidden">'
        . '<tr><td style="background:#172229;padding:18px 22px;color:#fff"><span style="font-size:23px;font-weight:800">406</span>'
        . '<span style="font-size:11px;font-weight:700;letter-spacing:1.6px;color:#e6ff38;padding-left:8px">COUPÉ CLUB</span></td></tr>'
        . '<tr><td style="padding:24px 22px">'
        . '<p style="margin:0 0 14px;font-size:14px">' . $hello . '</p>'
        . '<p style="margin:0 0 20px;font-size:14px;line-height:1.6;color:#4d585d">' . $intro . '</p>'
        . '<p style="margin:0 0 20px"><a href="' . $url . '" style="display:inline-block;background:#172229;color:#e6ff38;text-decoration:none;font-weight:700;font-size:15px;padding:14px 24px;border-radius:12px">' . $label . '</a></p>'
        . '<p style="margin:0;font-size:11px;line-height:1.6;color:#8b9498;word-break:break-all">Si le bouton ne fonctionne pas, copiez ce lien :<br>' . $url . '</p>'
        . '<p style="margin:16px 0 0;font-size:12px;line-height:1.6;color:#8b9498">Ce lien est valable 1 heure. '
        . 'Si vous n’êtes pas à l’origine de cette demande, ignorez ce message.</p></td></tr>'
        . '<tr><td style="padding:14px 22px 22px;border-top:1px solid #e7eae6;font-size:11px;color:#8b9498">'
        . 'Coupé 406 Club — développé par Novalys Apps.</td></tr></table></td></tr></table></body></html>';

    $text = $hello . "\n\n" . $intro . "\n\n" . $url . "\n\nCe lien est valable 1 heure.\n\n— Coupé 406 Club, par Novalys Apps";

    return [$subject, $text, $html];
}

// ---------------------------------------------------------------- lien cliqué
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
    $token = (string)($_GET['t'] ?? '');
    $data = $token !== '' ? readToken($token) : null;
    if ($data === null) {
        page('Lien expiré', 'Ce lien a dépassé une heure ou a été modifié. Redemandez-en un depuis l’application.', false);
    }
    @file_put_contents(markerPath(refOf($token)), (string)time(), LOCK_EX);
    // Le clic prouve que l'adresse existe : le compte devient confirmé.
    require_once __DIR__ . '/members.php';
    if ($data['purpose'] === 'verify' && ($pdo = db()) !== null) {
        ensureTables($pdo);
        // Avant de confirmer : le compte etait-il en attente, adresse non
        // validee ? C est ce cas, et lui seul, qui merite de prevenir
        // l administrateur — une demande sans adresse sure n existe pas.
        $avant = $pdo->prepare('SELECT id, name, city, region, verified_at, approved_at, refused_at
                                FROM c406_members WHERE email = ? LIMIT 1');
        $avant->execute([$data['email']]);
        $candidat = $avant->fetch();
        $pdo->prepare('UPDATE c406_members SET verified_at = COALESCE(verified_at, ?) WHERE email = ?')->execute([time(), $data['email']]);

        if ($candidat && $candidat['verified_at'] === null && $candidat['approved_at'] === null && $candidat['refused_at'] === null) {
            $attente = (int)$pdo->query('SELECT COUNT(*) FROM c406_members
                WHERE verified_at IS NOT NULL AND approved_at IS NULL AND refused_at IS NULL AND banned_at IS NULL')->fetchColumn();
            $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin' AND banned_at IS NULL")->fetchAll(PDO::FETCH_COLUMN);
            pushToMembers($pdo, $admins, 'Nouvelle demande d adhesion',
                $candidat['name'] . ' a confirme son adresse — ' . $attente . ' demande' . ($attente > 1 ? 's' : '') . ' en attente.',
                ['type' => 'request']);
            $ville = (string)$candidat['city'] !== '' ? (string)$candidat['city'] : 'non precisee';
            $region = (string)$candidat['region'] !== '' ? ' (' . (string)$candidat['region'] . ')' : '';
            $corps = 'Nom : ' . $candidat['name'] . PHP_EOL
                . 'E-mail : ' . $data['email'] . ' (confirme a l instant)' . PHP_EOL
                . 'Ville : ' . $ville . $region . PHP_EOL . PHP_EOL
                . 'A accepter ou refuser dans l application : Reglages > Administration du club > Demandes.';
            sendMail(CONTACT_TO, '[COUPÉ 406 CLUB] Demande d’adhésion — ' . $candidat['name'] . ' — ' . date('d/m H:i'),
                $corps,
                '<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.6;white-space:pre-wrap">'
                    . htmlspecialchars($corps, ENT_QUOTES) . '</p>');
        }
    }
    if ($data['purpose'] === 'reset') {
        page('Demande confirmée', 'Retournez dans l’application Coupé 406 Club : elle vous propose maintenant de choisir un nouveau mot de passe.');
    }
    page('Adresse confirmée', 'Votre compte est validé. Vous pouvez retourner dans l’application Coupé 406 Club.');
}

header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');

if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
    http_response_code(204);
    exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    reply(['ok' => false, 'error' => 'METHOD', 'message' => 'Requête invalide.'], 405);
}

// Dépôt d'un document depuis l'application : la requête est multipart, pas du
// JSON, et l'identité passe par la session admin transmise dans le formulaire.
if (($_POST['action'] ?? '') === 'upload_guide') {
    if (!hash_equals(C406_APP_KEY, (string)($_POST['app_key'] ?? ''))) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Application non reconnue.'], 403);
    }
    require_once __DIR__ . '/members.php';
    $membre = sessionMember((string)($_POST['session'] ?? ''));
    if ($membre === null || $membre['role'] !== 'admin') {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Réservé à l’administrateur du club.'], 403);
    }
    $dossier = dirname(__DIR__) . '/guides-fr';
    if (!is_dir($dossier)) {
        @mkdir($dossier, 0775, true);
    }
    $deposes = 0;
    $ignores = 0;
    $refuses = [];
    foreach ((array)($_FILES['fichiers']['name'] ?? []) as $index => $nom) {
        $temporaire = $_FILES['fichiers']['tmp_name'][$index] ?? '';
        if ($temporaire === '' || !is_uploaded_file($temporaire)) {
            continue;
        }
        if (!preg_match('/\.pdf$/i', (string)$nom) || strtolower((string)@mime_content_type($temporaire)) !== 'application/pdf') {
            $refuses[] = (string)$nom;
            continue;
        }
        $sansExtension = preg_replace('/\.pdf$/i', '', (string)$nom);
        $propre = str_replace(["/", chr(92), chr(0)], "", $sansExtension);
        $propre = preg_replace('/\s+/', ' ', trim($propre));
        // « guide(1).pdf », « guide (2).pdf », « guide - copie.pdf » désignent le
        // même document : on compare les noms débarrassés de ces suffixes.
        $propre = preg_replace('/\s*[-_]?\s*(\(\d+\)|\d+)$/', '', $propre);
        $propre = preg_replace('/\s*[-_]\s*(copie|copy)\s*$/i', '', $propre);
        $propre = trim($propre);
        $destination = $dossier . '/' . mb_substr($propre, 0, 120) . '.pdf';
        // Un document déjà présent n'est pas recopié : l'envoi peut être relancé
        // sans risque d'écraser ou de dupliquer la bibliothèque.
        $remplacer = ($_POST['replace'] ?? '') === '1';
        if (is_file($destination) && !$remplacer) {
            $ignores++;
            continue;
        }
        $empreinte = (string)@sha1_file($temporaire);
        $indexPath = $dossier . '/.empreintes.json';
        $index = is_readable($indexPath) ? (json_decode((string)file_get_contents($indexPath), true) ?: []) : [];
        // Les documents déposés avant la mise en place de l'index sont recensés
        // une seule fois, sinon un doublon renommé passerait au travers.
        $connus = array_flip($index);
        foreach (glob($dossier . '/*.pdf') ?: [] as $existant) {
            if (!isset($connus[basename($existant)])) {
                $index[(string)@sha1_file($existant)] = basename($existant);
            }
        }
        if (!$remplacer && $empreinte !== '' && isset($index[$empreinte]) && is_file($dossier . '/' . $index[$empreinte])) {
            $ignores++;
            continue;
        }
        if (@move_uploaded_file($temporaire, $destination)) {
            $deposes++;
            if ($empreinte !== '') {
                $index[$empreinte] = basename($destination);
                @file_put_contents($indexPath, json_encode($index), LOCK_EX);
            }
        } else {
            $refuses[] = (string)$nom;
        }
    }
    $message = match (true) {
        $deposes > 0 && $ignores > 0 => "{$deposes} ajouté(s), {$ignores} déjà présent(s).",
        $deposes > 0 => "{$deposes} document(s) ajouté(s) à la bibliothèque.",
        $ignores > 0 => "Déjà présent dans la bibliothèque.",
        default => 'Aucun document déposé : seuls les PDF sont acceptés.',
    };
    reply(['ok' => $deposes > 0 || $ignores > 0, 'deposes' => $deposes, 'ignores' => $ignores, 'message' => $message]
        + ($refuses !== [] ? ['refuses' => $refuses] : []));
}

// ------------------------------- proposition de document par un membre
/**
 * Le fichier attend dans un sous-dossier avec un nom imprevisible : il n est
 * pas dans la bibliotheque, mais l administrateur peut l ouvrir pour le lire
 * avant de trancher.
 */
if (($_POST['action'] ?? '') === 'guide_submit') {
    if (!hash_equals(C406_APP_KEY, (string)($_POST['app_key'] ?? ''))) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Application non reconnue.'], 403);
    }
    require_once __DIR__ . '/members.php';
    $member = sessionMember(isset($_POST['session']) ? (string)$_POST['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $nom = (string)($_FILES['fichier']['name'] ?? '');
    $temporaire = (string)($_FILES['fichier']['tmp_name'] ?? '');
    if ($temporaire === '' || !is_uploaded_file($temporaire)) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Aucun fichier recu.'], 400);
    }
    if (!preg_match('/\.pdf$/i', $nom) || strtolower((string)@mime_content_type($temporaire)) !== 'application/pdf') {
        reply(['ok' => false, 'error' => 'TYPE', 'message' => 'Seuls les documents PDF sont acceptes.'], 415);
    }
    if (filesize($temporaire) > 25 * 1024 * 1024) {
        reply(['ok' => false, 'error' => 'SIZE', 'message' => 'Document trop lourd : 25 Mo au maximum.'], 413);
    }
    $attente = $pdo->prepare("SELECT COUNT(*) FROM c406_guide_requests WHERE member_id = ? AND status = 'pending'");
    $attente->execute([$member['id']]);
    if ((int)$attente->fetchColumn() >= 5) {
        reply(['ok' => false, 'error' => 'TOO_MANY', 'message' => 'Vous avez deja cinq propositions en attente.'], 429);
    }

    $dossier = dirname(__DIR__) . '/guides-fr/en-attente';
    if (!is_dir($dossier)) {
        @mkdir($dossier, 0775, true);
    }
    $range = bin2hex(random_bytes(16)) . '.pdf';
    if (!@move_uploaded_file($temporaire, $dossier . '/' . $range)) {
        reply(['ok' => false, 'error' => 'WRITE', 'message' => 'Depot impossible pour le moment.'], 500);
    }
    $titre = trim((string)($_POST['title'] ?? '')) ?: preg_replace('/\.pdf$/i', '', $nom);
    $pdo->prepare('INSERT INTO c406_guide_requests (member_id, title, category, motors, note, stored_name, original, size_kb, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
        ->execute([
            $member['id'], mb_substr((string)$titre, 0, 160),
            mb_substr(trim((string)($_POST['category'] ?? 'Guide du club')), 0, 60) ?: 'Guide du club',
            mb_substr(trim((string)($_POST['motors'] ?? '')), 0, 120),
            mb_substr(trim((string)($_POST['note'] ?? '')), 0, 2000),
            $range, mb_substr($nom, 0, 190),
            (int)round(filesize($dossier . '/' . $range) / 1024), time(),
        ]);
    $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
    pushToMembers($pdo, $admins, 'Nouveau document propose', $member['name'] . ' propose : ' . $titre,
        ['type' => 'admin-guide-request']);
    reply(['ok' => true, 'message' => 'Document transmis a l administrateur du club.']);
}

if (($_POST['action'] ?? '') === 'stl_submit') {
    if (!hash_equals(C406_APP_KEY, (string)($_POST['app_key'] ?? ''))) {
        reply(['ok' => false, 'message' => 'Application non reconnue.'], 403);
    }
    require_once __DIR__ . '/members.php';
    $member = sessionMember((string)($_POST['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous avant de déposer un modèle.'], 403);
    }
    ensureTables($pdo);
    $title = trim((string)($_POST['title'] ?? ''));
    $purpose = trim((string)($_POST['purpose'] ?? ''));
    if ($title === '' || $purpose === '') {
        reply(['ok' => false, 'message' => 'Le titre et la fonction de la pièce sont obligatoires.'], 400);
    }
    if (mb_strlen($title) > 160 || mb_strlen($purpose) > 1000) {
        reply(['ok' => false, 'message' => 'Le titre ou la fonction est trop long.'], 400);
    }
    if (moderationVerdict($title . ' ' . $purpose)['action'] === 'block') {
        reply(['ok' => false, 'message' => 'Ce texte doit être corrigé avant publication.'], 400);
    }
    $phase = (string)($_POST['phase'] ?? '');
    if (!in_array($phase, ['', 'PH1', 'PH2', 'BOTH'], true)) {
        reply(['ok' => false, 'message' => 'Phase non reconnue.'], 400);
    }
    $motor = (string)($_POST['motorId'] ?? '');
    if (!in_array($motor, ['', 'xu10', 'ew10', 'ew12', 'es9j4', 'es9j4s', 'dw12'], true)) {
        reply(['ok' => false, 'message' => 'Motorisation non reconnue.'], 400);
    }
    $file = $_FILES['fichier'] ?? [];
    $name = (string)($file['name'] ?? '');
    $temporary = (string)($file['tmp_name'] ?? '');
    if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK
        || !preg_match('/\.(?:zip|7z|rar)$/i', $name) || !is_uploaded_file($temporary)) {
        reply(['ok' => false, 'message' => 'Choisissez une archive .zip, .7z ou .rar.'], 400);
    }
    $archiveType = stlArchiveType($temporary, $name);
    if ($archiveType === null) {
        reply(['ok' => false, 'message' => 'Archive invalide. Un ZIP doit contenir au moins un STL (64 Mo maximum).'], 415);
    }
    $limit = $pdo->prepare('SELECT COUNT(*) FROM c406_stl_models WHERE member_id = ? AND created_at >= ?');
    $limit->execute([$member['id'], time() - 86400]);
    if ((int)$limit->fetchColumn() >= 5) {
        reply(['ok' => false, 'message' => 'Vous pouvez déposer cinq modèles par jour. Réessayez demain.'], 429);
    }
    $directory = stlStorageDirectory();
    if (!is_dir($directory) && !@mkdir($directory, 0770, true)) {
        reply(['ok' => false, 'message' => 'Stockage indisponible pour le moment.'], 500);
    }
    $stored = bin2hex(random_bytes(16)) . '.' . $archiveType;
    if (!@move_uploaded_file($temporary, $directory . '/' . $stored)) {
        reply(['ok' => false, 'message' => 'Envoi impossible pour le moment.'], 500);
    }
    @chmod($directory . '/' . $stored, 0640);
    try {
        $pdo->prepare('INSERT INTO c406_stl_models
            (member_id, title, purpose, phase, motor_id, material, print_notes, tested,
             stored_name, original_name, size_bytes, archive_type, status, created_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([
                $member['id'], $title, $purpose, $phase, $motor,
                mb_substr(trim((string)($_POST['material'] ?? '')), 0, 80),
                mb_substr(trim((string)($_POST['printNotes'] ?? '')), 0, 2000),
                (string)($_POST['tested'] ?? '') === '1' ? 1 : 0,
                $stored, mb_substr(basename($name), 0, 190), filesize($directory . '/' . $stored),
                $archiveType, 'active', time(),
            ]);
    } catch (Throwable $error) {
        @unlink($directory . '/' . $stored);
        error_log('[coupe406] dépôt STL impossible : ' . $error->getMessage());
        reply(['ok' => false, 'message' => 'Publication impossible pour le moment.'], 500);
    }
    reply(['ok' => true, 'id' => (int)$pdo->lastInsertId(), 'message' => 'Modèle STL partagé avec le club.']);
}

if (($_POST['action'] ?? '') === 'oil_photo') {
    if (!hash_equals(C406_APP_KEY, (string)($_POST['app_key'] ?? ''))) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Application non reconnue.'], 403);
    }
    require_once __DIR__ . '/members.php';
    $member = sessionMember(isset($_POST['session']) ? (string)$_POST['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null || ($member['role'] ?? '') !== 'admin') {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Reserve a l administrateur du club.'], 403);
    }
    ensureTables($pdo);
    $identifiant = mb_substr((string)($_POST['id'] ?? ''), 0, 48);
    $temporaire = (string)($_FILES['photo']['tmp_name'] ?? '');
    if ($identifiant === '' || $temporaire === '' || !is_uploaded_file($temporaire)) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Aucune photo recue.'], 400);
    }
    $type = strtolower((string)@mime_content_type($temporaire));
    $extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
    if (!isset($extensions[$type])) {
        reply(['ok' => false, 'error' => 'TYPE', 'message' => 'Photo JPEG, PNG ou WebP seulement.'], 415);
    }
    $dossier = __DIR__ . '/photos';
    if (!is_dir($dossier)) {
        @mkdir($dossier, 0775, true);
    }
    // L ancienne photo du meme bidon quitte le disque : une seule par huile.
    $ancienne = $pdo->prepare('SELECT file_name FROM c406_oil_photos WHERE oil_id = ?');
    $ancienne->execute([$identifiant]);
    $precedente = (string)($ancienne->fetchColumn() ?: '');
    $fichier = bin2hex(random_bytes(16)) . '.' . $extensions[$type];
    if (!@move_uploaded_file($temporaire, $dossier . '/' . $fichier)) {
        reply(['ok' => false, 'error' => 'WRITE', 'message' => 'Depot impossible pour le moment.'], 500);
    }
    @chmod($dossier . '/' . $fichier, 0644);
    if ($precedente !== '' && is_file($dossier . '/' . basename($precedente))) {
        @unlink($dossier . '/' . basename($precedente));
    }
    $pdo->prepare('INSERT INTO c406_oil_photos (oil_id, file_name, updated_at) VALUES (?, ?, ?)
                   ON DUPLICATE KEY UPDATE file_name = VALUES(file_name), updated_at = VALUES(updated_at)')
        ->execute([$identifiant, $fichier, time()]);
    reply(['ok' => true, 'message' => 'Photo du bidon enregistree.']);
}

if (($_POST['action'] ?? '') === 'listing_photo') {
    if (!hash_equals(C406_APP_KEY, (string)($_POST['app_key'] ?? ''))) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Application non reconnue.'], 403);
    }
    require_once __DIR__ . '/members.php';
    $member = sessionMember(isset($_POST['session']) ? (string)$_POST['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $temporaire = (string)($_FILES['photo']['tmp_name'] ?? '');
    if ($temporaire === '' || !is_uploaded_file($temporaire)) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Aucune photo recue.'], 400);
    }
    $type = strtolower((string)@mime_content_type($temporaire));
    $extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
    if (!isset($extensions[$type])) {
        reply(['ok' => false, 'error' => 'TYPE', 'message' => 'Seules les photos JPEG, PNG ou WebP sont acceptees.'], 415);
    }
    if (filesize($temporaire) > 8 * 1024 * 1024) {
        reply(['ok' => false, 'error' => 'SIZE', 'message' => 'Photo trop lourde : 8 Mo au maximum.'], 413);
    }
    $dossier = __DIR__ . '/photos';
    if (!is_dir($dossier)) {
        @mkdir($dossier, 0775, true);
    }
    $fichier = bin2hex(random_bytes(16)) . '.' . $extensions[$type];
    if (!@move_uploaded_file($temporaire, $dossier . '/' . $fichier)) {
        reply(['ok' => false, 'error' => 'WRITE', 'message' => 'Depot impossible pour le moment.'], 500);
    }
    @chmod($dossier . '/' . $fichier, 0644);
    $maintenant = time();
    $pdo->prepare('INSERT INTO c406_listing_photos
        (member_id, kind, listing_id, file_name, status, created_at, handled_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)')
        ->execute([$member['id'], (string)($_POST['kind'] ?? 'part'),
            (int)($_POST['listingId'] ?? 0), $fichier, 'approved', $maintenant, $maintenant]);
    reply(['ok' => true, 'message' => 'Photo publiee immediatement avec votre annonce.']);
}

// Le lien est signé côté serveur : aucun jeton de session ne se retrouve dans
// l'historique du navigateur ni dans le nom du fichier téléchargé.
if (isset($_GET['stl_file'])) {
    require_once __DIR__ . '/members.php';
    $id = filter_var($_GET['stl_file'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
    $pdo = db();
    if (!$id || $pdo === null) {
        http_response_code(404);
        exit;
    }
    ensureTables($pdo);
    $statement = $pdo->prepare("SELECT stored_name FROM c406_stl_models WHERE id = ? AND status = 'active' LIMIT 1");
    $statement->execute([$id]);
    $stored = (string)($statement->fetchColumn() ?: '');
    $signature = (string)($_GET['sig'] ?? '');
    $expected = hash_hmac('sha256', 'stl|' . $id . '|' . $stored, C406_SECRET);
    $path = stlStorageDirectory() . '/' . $stored;
    if ($stored === '' || !preg_match('/^[a-f0-9]{32}\.(?:zip|7z|rar)$/', $stored)
        || !hash_equals($expected, $signature) || !is_file($path)) {
        http_response_code(404);
        exit;
    }
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="coupe406-modele-' . $id . '.' . pathinfo($stored, PATHINFO_EXTENSION) . '"');
    header('X-Content-Type-Options: nosniff');
    header('Content-Length: ' . filesize($path));
    readfile($path);
    exit;
}

$payload = json_decode(file_get_contents('php://input') ?: '', true);
if (!is_array($payload)) {
    reply(['ok' => false, 'error' => 'BAD_JSON', 'message' => 'Requête invalide.'], 400);
}
if (!hash_equals(C406_APP_KEY, (string)($payload['app_key'] ?? ''))) {
    reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Application non reconnue.'], 403);
}

$action = (string)($payload['action'] ?? '');

// Les anciennes versions gardent l'accès à la connexion et au contrôle de
// version afin de pouvoir afficher et télécharger la mise à jour. Les autres
// services en ligne restent fermés jusqu'à l'installation de la version
// minimale. Le garage local n'est jamais effacé.
$clientCode = (int)($payload['appCode'] ?? 0);
$clientConnu = isset($payload['appCode']) || isset($payload['appVersion']);
$clientVersion = trim((string)($payload['appVersion'] ?? ''));
$ancienneVersionAvecRappel = $clientConnu && ancienneVersionAvecRappel($clientVersion);
$versionPubliee = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
$versionPublieeObligatoire = ($versionPubliee['required'] ?? false) === true;
// Une version fraîchement publiée doit d'abord être proposée, sans enfermer
// les membres dans un écran obligatoire pendant la propagation Google Play.
// Elle ne devient minimale que si le manifeste la marque explicitement ainsi.
$codeMinimum = max(MINIMUM_APP_CODE, $versionPublieeObligatoire ? (int)($versionPubliee['versionCode'] ?? 0) : 0);
$nomMinimum = $codeMinimum === (int)($versionPubliee['versionCode'] ?? 0)
    ? (string)($versionPubliee['versionName'] ?? MINIMUM_APP_VERSION)
    : MINIMUM_APP_VERSION;
$urlMinimum = $codeMinimum === (int)($versionPubliee['versionCode'] ?? 0)
    ? (string)($versionPubliee['url'] ?? MINIMUM_APP_URL)
    : MINIMUM_APP_URL;
$miseAJourObligatoire = $clientVersion !== $nomMinimum && $clientCode < $codeMinimum;
$actionsMiseAJour = ['app_version', 'login', 'me', 'push_register', 'play_test_prompt', 'play_test_accept'];
if ($clientConnu && $miseAJourObligatoire && !in_array($action, $actionsMiseAJour, true)) {
    reply([
        'ok' => false,
        'error' => 'UPDATE_REQUIRED',
        'message' => 'La mise à jour ' . $nomMinimum . ' est obligatoire pour continuer.',
        'required' => true,
        'minimumVersionCode' => $codeMinimum,
        'versionCode' => $codeMinimum,
        'versionName' => $nomMinimum,
        'url' => $urlMinimum,
    ], 426);
}

// Les expressions autorisees par l administrateur sont chargees une fois pour
// toutes : sans cela, le filtre rebloquerait ce qui a deja ete valide.
// members.php doit etre charge ici : c est lui qui definit db().
require_once __DIR__ . '/members.php';
if (function_exists('moderationDefinirAutorises') && function_exists('db')) {
    $baseModeration = db();
    if ($baseModeration !== null) {
        try {
            $termes = $baseModeration->query('SELECT terme FROM c406_moderation_allow')->fetchAll(PDO::FETCH_COLUMN);
            moderationDefinirAutorises(is_array($termes) ? $termes : []);
        } catch (Throwable $ignore) {
            // La table arrive avec ensureTables : ne jamais bloquer un appel pour ca.
        }
    }
}

// La version de l application est notee sur la fiche du membre : sans elle,
// impossible de savoir depuis quelle version un probleme est signale.
if (isset($payload['appVersion'], $payload['session'])) {
    require_once __DIR__ . '/members.php';
    $connu = sessionMember((string)$payload['session']);
    $base = db();
    if ($connu !== null && $base !== null) {
        $version = mb_substr((string)$payload['appVersion'], 0, 16);
        try {
            $base->prepare('UPDATE c406_members SET app_version = ? WHERE id = ? AND app_version <> ?')
                ->execute([$version, $connu['id'], $version]);
            if ($clientCode > 0) {
                $base->prepare('UPDATE c406_members SET app_code = ? WHERE id = ? AND app_code <> ?')
                    ->execute([$clientCode, $connu['id'], $clientCode]);
            }
            // D ou vient l app du membre : « play » (installee depuis le Play
            // Store) ou « sideload » (APK du site). Seul Google voit l usage de
            // la version Play : c est elle qui compte pour l acces en production.
            // Seul l appel app_version transmet le canal ; la date avance au
            // plus une fois par heure.
            $canal = (string)($payload['distribution'] ?? '');
            if (in_array($canal, ['play', 'sideload'], true)) {
                $base->prepare('UPDATE c406_members SET distribution = ?, distribution_at = ?
                                WHERE id = ? AND (distribution <> ? OR distribution_at < ?)')
                    ->execute([$canal, time(), $connu['id'], $canal, time() - 3600]);
            }
        } catch (Throwable $ignore) {
            // La colonne arrive avec ensureTables : ne jamais bloquer un appel pour ca.
        }
    }
}

if ($action === 'send_link') {
    $email = strtolower(trim((string)($payload['email'] ?? '')));
    $purpose = ($payload['purpose'] ?? '') === 'reset' ? 'reset' : 'verify';
    $name = trim((string)($payload['name'] ?? ''));
    if (!validEmail($email)) {
        reply(['ok' => false, 'error' => 'INVALID_EMAIL', 'message' => 'Cette adresse e-mail est invalide.'], 400);
    }
    if (!throttle('send|' . $email, MAX_SENDS)) {
        reply(['ok' => false, 'error' => 'TOO_MANY', 'message' => 'Trop de demandes. Réessayez dans un quart d’heure.'], 429);
    }
    $token = makeToken($email, $purpose);
    $base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'gears.hostline.fr') . strtok((string)($_SERVER['REQUEST_URI'] ?? '/coupe406/api.php'), '?');
    [$subject, $text, $html] = linkEmail($name, $base . '?t=' . urlencode($token), $purpose);
    [$sent, $error] = sendMail($email, $subject, $text, $html, CONTACT_TO);
    if (!$sent) {
        reply(['ok' => false, 'error' => $error, 'message' => 'L’e-mail n’a pas pu partir. Réessayez dans quelques minutes.'], 502);
    }
    reply(['ok' => true, 'ref' => refOf($token), 'expires_in' => LINK_TTL]);
}

if ($action === 'link_status') {
    $ref = (string)($payload['ref'] ?? '');
    $path = markerPath($ref);
    $confirmed = $ref !== '' && is_file($path);
    // Le marqueur est conservé quelques minutes : l'écran « nouveau mot de
    // passe » s'en sert ensuite comme preuve du clic. Il expire de lui-même.
    if ($confirmed && time() - (int)@filemtime($path) > LINK_TTL) {
        @unlink($path);
        $confirmed = false;
    }
    reply(['ok' => true, 'confirmed' => $confirmed]);
}

if ($action === 'contact') {
    $message = trim((string)($payload['message'] ?? ''));
    $category = trim((string)($payload['category'] ?? 'Non précisée'));
    $from = strtolower(trim((string)($payload['email'] ?? '')));
    $name = trim((string)($payload['name'] ?? ''));
    if (mb_strlen($message) < 5) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Décrivez votre idée en quelques mots.'], 400);
    }
    if (!throttle('contact|' . ($from !== '' ? $from : ($_SERVER['REMOTE_ADDR'] ?? '?')), MAX_SENDS)) {
        reply(['ok' => false, 'error' => 'TOO_MANY', 'message' => 'Trop de messages envoyés. Réessayez plus tard.'], 429);
    }
    $body = "Catégorie : {$category}\n"
        . 'De : ' . ($name !== '' ? $name : 'Membre non identifié') . ($from !== '' ? " <{$from}>" : '') . "\n"
        . 'Date : ' . date('d/m/Y H:i') . "\n\n" . mb_substr($message, 0, 4000);
    [$sent, $error] = sendMail(
        CONTACT_TO,
        // L'heure rend chaque objet unique : sans elle, Gmail replie les
        // messages successifs dans une même conversation et on croit en perdre.
        '[COUPÉ 406 CLUB] ' . mb_substr($category, 0, 60) . ' — ' . date('d/m H:i'),
        $body . "\n\n— Envoyé depuis l’application Coupé 406 Club",
        '<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.6;white-space:pre-wrap">'
            . htmlspecialchars($body, ENT_QUOTES) . '</p>',
        $from !== '' ? $from : null,
    );
    if (!$sent) {
        reply(['ok' => false, 'error' => $error, 'message' => 'L’envoi a échoué. Réessayez dans quelques minutes.'], 502);
    }
    reply(['ok' => true]);
}

/**
 * Bibliothèque du club : tout PDF déposé dans /public/guides-fr/ apparaît dans
 * l'application, sans rien à configurer. Le nom du fichier fait le classement :
 *
 *   Freinage - Changer les plaquettes avant [es9j4s].pdf
 *   └ catégorie ┘ └────── titre ──────────┘ └ moteurs ┘
 *
 * La partie avant le premier « - » devient la catégorie, les codes entre
 * crochets limitent le guide à certaines motorisations (facultatif).
 */
if ($action === 'club_guides') {
    $folder = dirname(__DIR__) . '/guides-fr';
    if (!is_dir($folder)) {
        reply(['ok' => true, 'guides' => []]);
    }
    // Table des titres français : un fichier nommé « club-047-… » retrouve son
    // titre exact, sa catégorie, ses motorisations et ses boîtes compatibles.
    $catalogue = [];
    $titles = __DIR__ . '/guides-fr-titres.json';
    if (is_readable($titles)) {
        $catalogue = json_decode((string)file_get_contents($titles), true) ?: [];
    }

    $guides = [];
    foreach (glob($folder . '/*.pdf') ?: [] as $path) {
        $file = basename($path);
        $label = preg_replace('/\.pdf$/i', '', $file);
        $motors = [];
        $gearboxes = [];
        $category = 'Guide du club';
        $fromYear = 0;
        $toYear = 0;
        $enLigne = false;

        $source = '';
        if (preg_match('/^(club-\d{3})/', $label, $found) && isset($catalogue[$found[1]])) {
            // Un « club-NNN » est d ordinaire la traduction du guide anglais de
            // meme numero : l application les apparie et n affiche que la
            // francaise. Les documents ecrits pour le club n ont pas d original
            // anglais, et sans ce garde-fou ils disparaissaient de la liste.
            $known = $catalogue[$found[1]];
            $source = ($known['source'] ?? true) ? $found[1] : '';
            $label = $known['title'];
            $category = $known['category'];
            $motors = $known['motors'];
            $gearboxes = $known['gearboxes'];
            // Bornes d annees, pour les documents qui ne valent que d un cote
            // du restylage d avril 2003.
            $fromYear = (int)($known['fromYear'] ?? 0);
            $toYear = (int)($known['toYear'] ?? 0);
            // Un gros document de reference se consulte en ligne : le copier
            // dans chaque telephone n aurait pas de sens.
            $enLigne = !empty($known['online']);
        } else {
            // Fichier libre : « Catégorie - Titre [moteurs].pdf »
            if (preg_match('/\[([^\]]+)\]\s*$/', $label, $found)) {
                $motors = array_values(array_filter(array_map('trim', explode(',', strtolower($found[1])))));
                $label = trim(preg_replace('/\[[^\]]+\]\s*$/', '', $label));
            }
            if (str_contains($label, ' - ')) {
                [$category, $label] = array_map('trim', explode(' - ', $label, 2));
            }
            $label = trim(str_replace('-', ' ', $label));
        }

        $guides[] = [
            'fromYear' => $fromYear ?? 0,
            'onlineOnly' => (bool)($enLigne ?? false),
            'toYear' => $toYear ?? 0,
            'id' => 'clubfr-' . substr(sha1($file), 0, 10),
            'title' => $label !== '' ? $label : $file,
            'category' => $category,
            'motors' => $motors !== [] ? $motors : ['all'],
            'gearboxes' => $gearboxes,
            'sourceId' => $source,
            'file' => $file,
            'url' => 'https://' . ($_SERVER['HTTP_HOST'] ?? 'gears.hostline.fr') . '/guides-fr/' . rawurlencode($file),
            'size' => (int)round(filesize($path) / 1024),
        ];
    }
    usort($guides, static fn(array $a, array $b): int => [$a['category'], $a['title']] <=> [$b['category'], $b['title']]);
    reply(['ok' => true, 'guides' => $guides]);
}

// ------------------------------------------------------------------ comptes
require_once __DIR__ . '/members.php';

/** Compteur officiel relevé dans la Google Play Console. */
function playTestOfficialStatus(): array
{
    $statut = json_decode((string)@file_get_contents(__DIR__ . '/play-test-status.json'), true);
    $compte = max(0, min(PLAY_TEST_TARGET, (int)($statut['officialCount'] ?? 0)));
    return [
        'officialCount' => $compte,
        'target' => PLAY_TEST_TARGET,
        'checkedAt' => max(0, (int)($statut['checkedAt'] ?? 0)),
    ];
}

/**
 * La campagne ne se termine qu'avec le compteur officiel de Google Play.
 * Toucher un bouton dans l'application ne peut plus atteindre le seuil.
 */
function playTestCampaignComplete(PDO $pdo): bool
{
    ensureTables($pdo);
    $terminee = $pdo->prepare('SELECT completed_at FROM c406_play_test_campaigns WHERE campaign = ? LIMIT 1');
    $terminee->execute([PLAY_TEST_CAMPAIGN]);
    if ($terminee->fetchColumn() !== false) {
        return true;
    }

    $statut = playTestOfficialStatus();
    if ($statut['officialCount'] < PLAY_TEST_TARGET) {
        return false;
    }

    $completedAt = time();
    $insert = $pdo->prepare('INSERT IGNORE INTO c406_play_test_campaigns (campaign, target, completed_at)
                             VALUES (?, ?, ?)');
    $insert->execute([PLAY_TEST_CAMPAIGN, PLAY_TEST_TARGET, $completedAt]);
    // Une seule alerte, au premier passage officiel à 12. Elle reste aussi
    // dans l'application si la notification Android a été manquée.
    if ($insert->rowCount() > 0) {
        $admins = $pdo->query("SELECT id FROM c406_members
                               WHERE role = 'admin' AND banned_at IS NULL")->fetchAll(PDO::FETCH_COLUMN);
        $notice = $pdo->prepare('INSERT INTO c406_member_notices
            (member_id, kind, title, body, created_at, seen_at) VALUES (?, ?, ?, ?, ?, NULL)');
        foreach ($admins as $adminId) {
            $notice->execute([(string)$adminId, 'play-test-complete',
                'Objectif Google Play atteint !',
                'La Play Console compte désormais 12 testeurs. Le seuil du test fermé est atteint.',
                $completedAt]);
        }
        pushToMembers($pdo, $admins, 'Objectif Google Play atteint !',
            'Les 12 testeurs du test fermé sont maintenant réunis.',
            ['type' => 'play-test-complete']);
    }
    return true;
}

/** La confirmation est rattachee au compte, pas seulement au telephone. */
function playTestPending(PDO $pdo, string $memberId): bool
{
    if (playTestCampaignComplete($pdo)) {
        return false;
    }
    $statement = $pdo->prepare('SELECT 1 FROM c406_play_test_acceptances WHERE campaign = ? AND member_id = ? LIMIT 1');
    $statement->execute([PLAY_TEST_CAMPAIGN, $memberId]);
    return !$statement->fetchColumn();
}

/** Texte du popup lu sur le serveur : il peut évoluer sans nouvel APK. */
function playTestContent(): array
{
    $contenu = json_decode((string)@file_get_contents(__DIR__ . '/play-test-message.json'), true);
    $contenu = is_array($contenu) ? $contenu : [];
    $statut = playTestOfficialStatus();
    $reste = max(0, PLAY_TEST_TARGET - $statut['officialCount']);
    $contenu['subtitle'] = $statut['officialCount'] . ' testeurs réellement comptés par Google sur ' . PLAY_TEST_TARGET
        . ($reste > 0 ? ' · encore ' . $reste : ' · objectif atteint');
    return $contenu;
}

/**
 * Le parc français, servi depuis un fichier : quand le SDES enverra la série
 * annuelle ou la ventilation par moteur, il suffira de compléter
 * parc-france.json — l application les affichera sans nouvelle version.
 */
if ($action === 'parc_france') {
    $parc = json_decode((string)@file_get_contents(__DIR__ . '/parc-france.json'), true);
    if (!is_array($parc) || (int)($parc['total'] ?? 0) <= 0) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Aucun chiffre de parc disponible.'], 404);
    }
    reply(['ok' => true, 'parc' => $parc]);
}

/** Mise à jour de l'application : version publiée et lien de téléchargement. */
if ($action === 'app_version') {
    // Deux canaux : les testeurs voient la version d essai, les membres la
    // version publiee. Un testeur reste sur le canal stable si l essai est
    // plus ancien que ce qui est deja diffuse.
    $stable = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
    $stable = is_array($stable) ? $stable : ['versionCode' => 0];
    $obligatoire = [
        'required' => $miseAJourObligatoire || $ancienneVersionAvecRappel,
        'minimumVersionCode' => $codeMinimum,
    ];
    if ($ancienneVersionAvecRappel) {
        $obligatoire['requiredTitle'] = 'Votre application doit être mise à jour';
        $obligatoire['requiredMessage'] = 'Votre version est ancienne. Appuyez sur le bouton ci-dessous : Google Play s’ouvrira pour une installation Play Store, sinon l’APK sera téléchargé directement.';
    }

    // Deux distributions, deux verites. L app installee depuis le Play Store ne
    // peut rien telecharger elle-meme : lui annoncer une version que Google n a
    // pas encore publiee l envoie sur une fiche inchangee. Elle a donc son
    // propre latest-play.json, et tant qu il n annonce rien, elle ne voit rien.
    // Une app plus ancienne n envoie pas ce champ : elle garde l ancien
    // comportement, sinon la mise a jour disparaitrait de son ecran.
    if (($payload['distribution'] ?? '') === 'play') {
        $play = json_decode((string)@file_get_contents(__DIR__ . '/latest-play.json'), true);
        $play = is_array($play) ? $play : [];
        if ((int)($play['versionCode'] ?? 0) <= 0 && !$ancienneVersionAvecRappel) {
            reply(['ok' => true, 'channel' => 'play', 'versionCode' => 0] + $obligatoire);
        }
        if ((int)($play['versionCode'] ?? 0) <= 0) {
            $play = $stable;
        }
        // Une version déjà publiée sur Google Play peut devenir obligatoire
        // indépendamment du canal APK. C'est ce qui permet aux anciennes
        // installations Play (par exemple 1.0.16) d'afficher immédiatement
        // un écran avec le bouton Google Play au clic sur la notification.
        $playObligatoire = $obligatoire;
        if (($play['required'] ?? false) === true && $clientCode < (int)($play['versionCode'] ?? 0)) {
            $playObligatoire['required'] = true;
            $playObligatoire['minimumVersionCode'] = (int)$play['versionCode'];
            $playObligatoire['requiredTitle'] = (string)($play['requiredTitle'] ?? ('Installez la version ' . ($play['versionName'] ?? 'disponible')));
            $playObligatoire['requiredMessage'] = (string)($play['requiredMessage'] ?? 'Ouvrez Google Play pour installer la mise à jour.');
        }
        reply(['ok' => true, 'channel' => 'play'] + $playObligatoire + $play);
    }

    $membre = isset($payload['session']) ? sessionMember((string)$payload['session']) : null;
    $testeur = false;
    $administrateur = false;
    if ($membre !== null) {
        $pdo = db();
        if ($pdo !== null) {
            $statement = $pdo->prepare('SELECT role, tester FROM c406_members WHERE id = ? LIMIT 1');
            $statement->execute([$membre['id']]);
            $ligne = $statement->fetch();
            $administrateur = $ligne && (string)$ligne['role'] === 'admin';
            $testeur = $ligne && ($administrateur || (int)$ligne['tester'] === 1);
        }
    }

    if ($testeur) {
        $essai = json_decode((string)@file_get_contents(__DIR__ . '/latest-test.json'), true);
        if (is_array($essai) && (int)($essai['versionCode'] ?? 0) > (int)($stable['versionCode'] ?? 0)) {
            reply(['ok' => true, 'channel' => 'test'] + $obligatoire + $essai);
        }
    }
    reply(['ok' => true, 'channel' => 'stable'] + $obligatoire + $stable);
}

if ($action === 'register') {
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => false, 'error' => 'DB', 'message' => 'Serveur momentanément indisponible.'], 503);
    }
    ensureTables($pdo);
    $email = strtolower(trim((string)($payload['email'] ?? '')));
    $password = (string)($payload['password'] ?? '');
    $name = trim((string)($payload['name'] ?? ''));
    if (!validEmail($email) || $name === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Adresse e-mail ou nom invalide.'], 400);
    }
    if (strlen($password) < 6) {
        reply(['ok' => false, 'error' => 'WEAK', 'message' => 'Le mot de passe doit contenir au moins 6 caractères.'], 400);
    }
    $statement = $pdo->prepare('SELECT * FROM c406_members WHERE email = ? LIMIT 1');
    $statement->execute([$email]);
    $existing = $statement->fetch();
    if ($existing && $existing['banned_at'] !== null) {
        reply(['ok' => false, 'error' => 'BANNED', 'message' => 'Ce compte a été suspendu par l’administrateur du club.'], 403);
    }
    if ($existing && $existing['verified_at'] !== null) {
        reply(['ok' => false, 'error' => 'EMAIL_TAKEN', 'message' => 'Un compte confirmé existe déjà avec cette adresse.'], 409);
    }
    $hash = password_hash($password, PASSWORD_DEFAULT);
    $coupe = mb_substr((string)($payload['ownsCoupe'] ?? ''), 0, 80);
    $motivation = mb_substr(trim((string)($payload['motivation'] ?? '')), 0, 600);
    $charte = (bool)($payload['charter'] ?? false) ? time() : null;
    if ($charte === null) {
        reply(['ok' => false, 'error' => 'CHARTER', 'message' => 'La charte du club doit etre acceptee pour demander a rejoindre le club.'], 400);
    }
    if (mb_strlen($motivation) < 10) {
        reply(['ok' => false, 'error' => 'MOTIVATION', 'message' => 'Dites en quelques mots pourquoi vous souhaitez rejoindre le club.'], 400);
    }
    $genre = in_array((string)($payload['gender'] ?? ''), ['femme', 'homme', 'autre'], true) ? (string)$payload['gender'] : '';
    $tranche = in_array((string)($payload['ageBand'] ?? ''), ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], true) ? (string)$payload['ageBand'] : '';
    if ($existing) {
        $pdo->prepare('UPDATE c406_members SET name = ?, city = ?, region = ?, pass_hash = ?, owns_coupe = ?, motivation = ?, charter_at = ? WHERE id = ?')
            ->execute([$name, trim((string)($payload['city'] ?? '')), trim((string)($payload['region'] ?? '')), $hash, $coupe, $motivation, $charte, $existing['id']]);
    } else {
        // Le tout premier inscrit devient administrateur du club.
        $isFirst = (int)$pdo->query('SELECT COUNT(*) FROM c406_members')->fetchColumn() === 0;
        $pdo->prepare('INSERT INTO c406_members (id, email, name, city, region, pass_hash, role, created_at, owns_coupe, motivation, charter_at, approved_at, gender, age_band) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([bin2hex(random_bytes(12)), $email, $name, trim((string)($payload['city'] ?? '')), trim((string)($payload['region'] ?? '')), $hash, $isFirst ? 'admin' : 'member', time(), $coupe, $motivation, $charte, $isFirst ? time() : null, $genre, $tranche]);
    }
    reply(['ok' => true]);
}

if ($action === 'login') {
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => false, 'error' => 'DB', 'message' => 'Serveur momentanément indisponible.'], 503);
    }
    ensureTables($pdo);
    $email = strtolower(trim((string)($payload['email'] ?? '')));
    $statement = $pdo->prepare('SELECT * FROM c406_members WHERE email = ? LIMIT 1');
    $statement->execute([$email]);
    $member = $statement->fetch();
    if (!$member || !password_verify((string)($payload['password'] ?? ''), $member['pass_hash'])) {
        reply(['ok' => false, 'error' => 'BAD_CREDENTIALS', 'message' => 'E-mail ou mot de passe incorrect.'], 401);
    }
    if ($member['banned_at'] !== null) {
        reply(['ok' => false, 'error' => 'BANNED', 'message' => trim('Compte suspendu. ' . (string)$member['ban_reason'])], 403);
    }
    if ($member['verified_at'] === null) {
        reply(['ok' => false, 'error' => 'NOT_VERIFIED', 'message' => 'Confirmez d’abord votre adresse avec le lien reçu par e-mail.'], 403);
    }
    if ($member['refused_at'] !== null) {
        reply(['ok' => false, 'error' => 'REFUSED', 'message' => 'Votre demande d’adhésion n’a pas été retenue par l’administrateur du club.'], 403);
    }
    if ($member['approved_at'] === null) {
        reply(['ok' => false, 'error' => 'PENDING', 'message' => 'Votre demande d’adhésion est en attente : l’administrateur du club doit la valider. Vous recevrez une notification dès qu’elle sera traitée.'], 403);
    }
    $pdo->prepare('UPDATE c406_members SET last_seen_at = ? WHERE id = ?')->execute([time(), $member['id']]);
    recordDailyConnection($pdo, (string)$member['id']);
    reply(['ok' => true, 'session' => memberSession($member['id']),
        'profile' => publicMember($member) + ['email' => $member['email']],
        'playTestPrompt' => playTestPending($pdo, (string)$member['id'])]);
}

if ($action === 'set_password') {
    $pdo = db();
    $ref = (string)($payload['ref'] ?? '');
    if ($pdo === null || $ref === '' || !is_file(markerPath($ref))) {
        reply(['ok' => false, 'error' => 'LINK', 'message' => 'Lien de réinitialisation expiré.'], 400);
    }
    ensureTables($pdo);
    $password = (string)($payload['password'] ?? '');
    if (strlen($password) < 6) {
        reply(['ok' => false, 'error' => 'WEAK', 'message' => 'Le mot de passe doit contenir au moins 6 caractères.'], 400);
    }
    $pdo->prepare('UPDATE c406_members SET pass_hash = ?, verified_at = COALESCE(verified_at, ?) WHERE email = ?')
        ->execute([password_hash($password, PASSWORD_DEFAULT), time(), strtolower(trim((string)($payload['email'] ?? '')))]);
    @unlink(markerPath($ref));
    reply(['ok' => true]);
}

/** Rafraîchit le profil courant : rôle, suspension, informations à jour. */
if ($action === 'me') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    if ($member === null) {
        reply(['ok' => false, 'error' => 'SESSION', 'message' => 'Session expirée.'], 403);
    }
    $pdo = db();
    reply(['ok' => true, 'profile' => publicMember($member) + ['email' => $member['email']],
        'playTestPrompt' => $pdo !== null && playTestPending($pdo, (string)$member['id'])]);
}

/**
 * Voiture principale du compte pour le cockpit. Le garage sauvegardé est la
 * source prioritaire, car il reste à jour sans obliger le membre à publier sa
 * voiture dans le recensement public.
 */
function launcherCar(PDO $pdo, string $memberId): array
{
    $backup = $pdo->prepare('SELECT payload FROM c406_backups WHERE member_id = ? LIMIT 1');
    $backup->execute([$memberId]);
    $payload = $backup->fetchColumn();
    if (is_string($payload) && $payload !== '') {
        $data = json_decode($payload, true);
        if (is_array($data) && isset($data['cars']) && is_array($data['cars']) && $data['cars'] !== []) {
            $car = $data['cars'][0];
            if (is_array($car)) {
                return [
                    'color' => mb_substr(trim((string)($car['color'] ?? '')), 0, 60),
                    'year' => mb_substr(trim((string)($car['year'] ?? '')), 0, 8),
                    'motorId' => mb_substr(trim((string)($car['motorId'] ?? '')), 0, 24),
                    'trim' => mb_substr(trim((string)($car['trim'] ?? '')), 0, 60),
                ];
            }
        }
    }
    $registry = $pdo->prepare("SELECT color, year, motor_id, trim_label FROM c406_registry
        WHERE member_id = ? AND statut <> 'detruite' ORDER BY updated_at DESC, id ASC LIMIT 1");
    $registry->execute([$memberId]);
    $car = $registry->fetch();
    return $car ? [
        'color' => (string)$car['color'], 'year' => (string)$car['year'],
        'motorId' => (string)$car['motor_id'], 'trim' => (string)$car['trim_label'],
    ] : ['color' => '', 'year' => '', 'motorId' => '', 'trim' => ''];
}

/** Profil léger du launcher : compte, voiture et préférences synchronisées. */
if ($action === 'launcher_profile') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'SESSION', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    reply([
        'ok' => true,
        'profile' => publicMember($member) + ['email' => (string)$member['email']],
        'car' => launcherCar($pdo, (string)$member['id']),
        'nearby' => [
            'sharePosition' => (int)($member['nearby_share_enabled'] ?? 0) === 1,
            'alertsEnabled' => (int)($member['nearby_alerts_enabled'] ?? 1) === 1,
            'radiusKm' => (int)($member['nearby_radius'] ?? 100) === 50 ? 50 : 100,
        ],
    ]);
}

/**
 * Position approximative du cockpit et détection d'un membre actif alentour.
 * Le serveur impose lui-même la limite d'une alerte par tranche de 24 heures,
 * même si plusieurs appareils utilisent le même compte.
 */
if ($action === 'nearby_update') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'SESSION', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $share = array_key_exists('sharePosition', $payload)
        ? (bool)$payload['sharePosition'] : (int)($member['nearby_share_enabled'] ?? 0) === 1;
    $alerts = array_key_exists('alertsEnabled', $payload)
        ? (bool)$payload['alertsEnabled'] : (int)($member['nearby_alerts_enabled'] ?? 1) === 1;
    $radius = (int)($payload['radiusKm'] ?? ($member['nearby_radius'] ?? 100));
    $radius = $radius === 50 ? 50 : 100;
    $pdo->prepare('UPDATE c406_members SET nearby_share_enabled = ?, nearby_alerts_enabled = ?, nearby_radius = ? WHERE id = ?')
        ->execute([(int)$share, (int)$alerts, $radius, $member['id']]);

    $latitude = null;
    $longitude = null;
    if ($share && isset($payload['lat'], $payload['lon'])) {
        $lat = (float)$payload['lat'];
        $lon = (float)$payload['lon'];
        if ($lat >= -90 && $lat <= 90 && $lon >= -180 && $lon <= 180) {
            // Environ un kilomètre de précision, jamais les coordonnées GPS brutes.
            $latitude = round($lat, 2);
            $longitude = round($lon, 2);
            $pdo->prepare('UPDATE c406_members SET lat = ?, lon = ?, location_at = ? WHERE id = ?')
                ->execute([$latitude, $longitude, time(), $member['id']]);
        }
    }
    if (!$share) {
        reply(['ok' => true, 'nearby' => false, 'notify' => false]);
    }
    if ($latitude === null || $longitude === null) {
        $fresh = (int)($member['location_at'] ?? 0) >= time() - 1800;
        if ($fresh && $member['lat'] !== null && $member['lon'] !== null) {
            $latitude = (float)$member['lat'];
            $longitude = (float)$member['lon'];
        } else {
            reply(['ok' => true, 'nearby' => false, 'notify' => false, 'needsLocation' => true]);
        }
    }

    $activeAfter = time() - 1800;
    $query = $pdo->prepare("SELECT id, lat, lon FROM c406_members
        WHERE id <> ? AND banned_at IS NULL AND verified_at IS NOT NULL AND approved_at IS NOT NULL
          AND nearby_share_enabled = 1 AND lat IS NOT NULL AND lon IS NOT NULL
          AND location_at >= ? AND last_seen_at >= ? LIMIT 500");
    $query->execute([$member['id'], $activeAfter, $activeAfter]);
    $nearest = null;
    foreach ($query->fetchAll() as $candidate) {
        $lat1 = deg2rad($latitude);
        $lat2 = deg2rad((float)$candidate['lat']);
        $deltaLat = deg2rad((float)$candidate['lat'] - $latitude);
        $deltaLon = deg2rad((float)$candidate['lon'] - $longitude);
        $a = sin($deltaLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($deltaLon / 2) ** 2;
        $distance = 6371 * 2 * atan2(sqrt($a), sqrt(max(0.0, 1 - $a)));
        if ($distance <= $radius && ($nearest === null || $distance < $nearest['distance'])) {
            $nearest = ['id' => (string)$candidate['id'], 'distance' => $distance];
        }
    }
    if ($nearest === null) {
        reply(['ok' => true, 'nearby' => false, 'notify' => false]);
    }

    $notify = false;
    if ($alerts) {
        $now = time();
        $pdo->beginTransaction();
        try {
            $pdo->prepare('INSERT IGNORE INTO c406_nearby_alerts (member_id, last_alert_at, nearby_member_id) VALUES (?, 0, ?)')
                ->execute([$member['id'], $nearest['id']]);
            $lock = $pdo->prepare('SELECT last_alert_at FROM c406_nearby_alerts WHERE member_id = ? FOR UPDATE');
            $lock->execute([$member['id']]);
            $lastAlert = (int)$lock->fetchColumn();
            if ($lastAlert <= $now - 86400) {
                $pdo->prepare('UPDATE c406_nearby_alerts SET last_alert_at = ?, nearby_member_id = ? WHERE member_id = ?')
                    ->execute([$now, $nearest['id'], $member['id']]);
                $notify = true;
            }
            $pdo->commit();
        } catch (Throwable $error) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }
        }
    }
    $distanceBand = max(5, (int)(round($nearest['distance'] / 5) * 5));
    reply([
        'ok' => true, 'nearby' => true, 'notify' => $notify,
        'message' => 'Un membre de la famille 406 Coupé est dans votre secteur, à environ ' . $distanceBand . ' km.',
    ]);
}

if ($action === 'play_test_prompt') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $compte = $pdo->prepare('SELECT COUNT(*) FROM c406_play_test_acceptances WHERE campaign = ?');
    $compte->execute([PLAY_TEST_CAMPAIGN]);
    $declarations = (int)$compte->fetchColumn();
    $statut = playTestOfficialStatus();
    reply(['ok' => true, 'show' => playTestPending($pdo, (string)$member['id']),
        'accepted' => $statut['officialCount'], 'declared' => $declarations,
        'target' => PLAY_TEST_TARGET, 'checkedAt' => $statut['checkedAt'],
        'playTestContent' => playTestContent()]);
}

if ($action === 'play_test_accept') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $pdo->prepare('INSERT INTO c406_play_test_acceptances (campaign, member_id, accepted_at) VALUES (?, ?, ?)
                   ON DUPLICATE KEY UPDATE accepted_at = VALUES(accepted_at)')
        ->execute([PLAY_TEST_CAMPAIGN, $member['id'], time()]);
    reply(['ok' => true, 'verified' => false, 'complete' => playTestCampaignComplete($pdo),
        'message' => 'Déclaration enregistrée. Seul le compteur de la Play Console fait foi.']);
}

if ($action === 'update_profile') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée, reconnectez-vous.'], 403);
    }
    // Le membre peut completer ou retirer ce qu il dit de lui a tout moment.
    $genre = array_key_exists('gender', $payload)
        ? (in_array((string)$payload['gender'], ['femme', 'homme', 'autre'], true) ? (string)$payload['gender'] : '')
        : (string)($member['gender'] ?? '');
    $tranche = array_key_exists('ageBand', $payload)
        ? (in_array((string)$payload['ageBand'], ['18-24', '25-34', '35-44', '45-54', '55-64', '65+'], true) ? (string)$payload['ageBand'] : '')
        : (string)($member['age_band'] ?? '');
    $pdo->prepare('UPDATE c406_members SET name = ?, city = ?, region = ?, gender = ?, age_band = ? WHERE id = ?')->execute([
        trim((string)($payload['name'] ?? $member['name'])),
        trim((string)($payload['city'] ?? $member['city'])),
        trim((string)($payload['region'] ?? $member['region'])),
        $genre,
        $tranche,
        $member['id'],
    ]);
    reply(['ok' => true]);
}

if ($action === 'members') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    $rows = $pdo->query('SELECT * FROM c406_members WHERE banned_at IS NULL AND verified_at IS NOT NULL ORDER BY name LIMIT 500')->fetchAll();
    reply(['ok' => true, 'members' => array_map(static fn(array $row): array => publicMember($row), $rows)]);
}

if ($action === 'report_member') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    $pdo->prepare('INSERT INTO c406_reports (reporter_id, target_id, reason, message, created_at) VALUES (?, ?, ?, ?, ?)')
        ->execute([$member['id'], (string)($payload['targetId'] ?? ''), mb_substr((string)($payload['reason'] ?? 'Non précisé'), 0, 60), mb_substr((string)($payload['message'] ?? ''), 0, 2000), time()]);
    reply(['ok' => true, 'message' => 'Signalement transmis à l’administrateur.']);
}

if ($action === 'push_register') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    $token = trim((string)($payload['token'] ?? ''));
    if ($member === null || $pdo === null || $token === '') {
        reply(['ok' => false, 'error' => 'FORBIDDEN'], 403);
    }
    ensureTables($pdo);
    $pdo->prepare('INSERT INTO c406_push (token, member_id, platform, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE member_id = VALUES(member_id), updated_at = VALUES(updated_at)')
        ->execute([$token, $member['id'], mb_substr((string)($payload['platform'] ?? 'android'), 0, 16), time()]);
    reply(['ok' => true]);
}

// ----------------------------------------------------------- administration
if ($action === 'admin_members') {
    requireAdmin($payload);
    $pdo = db();
    $search = '%' . trim((string)($payload['query'] ?? '')) . '%';
    // Un compte en attente n est pas un membre : il n apparait que dans
    // l onglet Demandes, jamais dans la liste des membres du club.
    // Sans recherche : les membres du club. Avec une recherche : tous les
    // comptes, y compris celui qui attend encore de confirmer son adresse.
    $recherche = trim((string)($payload['query'] ?? ''));
    $filtre = $recherche === '' ? ' (approved_at IS NOT NULL OR refused_at IS NOT NULL) AND' : '';
    $statement = $pdo->prepare('SELECT * FROM c406_members
        WHERE' . $filtre . ' (name LIKE ? OR email LIKE ? OR city LIKE ?)
        ORDER BY CASE WHEN COALESCE(app_code, 0) > 0 THEN 0 ELSE 1 END,
                 app_code DESC, last_seen_at DESC, created_at DESC LIMIT 300');
    $statement->execute([$search, $search, $search]);
    $rows = $statement->fetchAll();
    reply([
        'ok' => true,
        'members' => array_map(static fn(array $row): array => publicMember($row, true), $rows),
        'stats' => [
            // Un candidat en attente n est pas encore un membre : il ne compte pas.
            'total' => (int)$pdo->query('SELECT COUNT(*) FROM c406_members WHERE approved_at IS NOT NULL')->fetchColumn(),
            'verified' => (int)$pdo->query('SELECT COUNT(*) FROM c406_members WHERE approved_at IS NOT NULL AND verified_at IS NOT NULL')->fetchColumn(),
            'pending' => (int)$pdo->query('SELECT COUNT(*) FROM c406_members WHERE approved_at IS NULL AND refused_at IS NULL AND banned_at IS NULL')->fetchColumn(),
            'banned' => (int)$pdo->query('SELECT COUNT(*) FROM c406_members WHERE banned_at IS NOT NULL')->fetchColumn(),
            'reports' => (int)$pdo->query("SELECT COUNT(*) FROM c406_reports WHERE status = 'open'")->fetchColumn(),
        ],
    ]);
}

if ($action === 'admin_member_info') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $statement = $pdo->prepare('SELECT * FROM c406_members WHERE id = ? LIMIT 1');
    $statement->execute([(string)($payload['id'] ?? '')]);
    $row = $statement->fetch();
    if (!$row) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Membre introuvable.'], 404);
    }
    $fiche = publicMember($row, true) + [
        'ownsCoupe' => (string)($row['owns_coupe'] ?? ''),
        'motivation' => (string)($row['motivation'] ?? ''),
        'gender' => (string)($row['gender'] ?? ''),
        'ageBand' => (string)($row['age_band'] ?? ''),
    ];
    // Le recensement est facultatif : afficher aussi les voitures du garage
    // sauvegardé, sans transmettre leur plaque, VIN ni photo privée.
    $voitures = $pdo->prepare('SELECT car_key, motor_id, year, color, gearbox, phase, trim_label
        FROM c406_registry WHERE member_id = ? ORDER BY id ASC');
    $voitures->execute([(string)$row['id']]);
    $recensees = $voitures->fetchAll();
    $clesRecensees = [];
    foreach ($recensees as $voiture) {
        $clesRecensees[(string)$voiture['car_key']] = true;
    }
    $fiche['cars'] = array_map(static fn(array $voiture): array => [
        'id' => (string)$voiture['car_key'],
        'motorId' => (string)$voiture['motor_id'],
        'year' => (string)$voiture['year'],
        'color' => (string)$voiture['color'],
        'gearbox' => (string)$voiture['gearbox'],
        'phase' => (string)($voiture['phase'] ?? ''),
        'trim' => (string)$voiture['trim_label'],
        'registered' => true,
    ], $recensees);
    $sauvegarde = $pdo->prepare('SELECT payload FROM c406_backups WHERE member_id = ? LIMIT 1');
    $sauvegarde->execute([(string)$row['id']]);
    $contenu = $sauvegarde->fetchColumn();
    $donnees = is_string($contenu) ? json_decode($contenu, true) : null;
    $garage = is_array($donnees) && is_array($donnees['cars'] ?? null) ? $donnees['cars'] : [];
    foreach ($garage as $index => $voiture) {
        if (!is_array($voiture)) continue;
        $cle = mb_substr((string)($voiture['id'] ?? ''), 0, 64);
        if ($cle !== '' && isset($clesRecensees[$cle])) continue;
        $specs = is_array($voiture['specs'] ?? null) ? $voiture['specs'] : [];
        $fiche['cars'][] = [
            'id' => $cle !== '' ? $cle : 'backup-' . $index,
            'motorId' => mb_substr((string)($voiture['motorId'] ?? ''), 0, 24),
            'year' => mb_substr((string)($voiture['year'] ?? ''), 0, 8),
            'color' => mb_substr((string)($voiture['color'] ?? ''), 0, 60),
            'gearbox' => mb_substr((string)($voiture['gearbox'] ?? ''), 0, 60),
            'phase' => mb_substr((string)($specs['phase'] ?? ''), 0, 8),
            'trim' => mb_substr((string)($voiture['trim'] ?? ''), 0, 60),
            'firstYear' => mb_substr((string)($voiture['firstYear'] ?? ''), 0, 8),
            'dam' => mb_substr((string)($voiture['dam'] ?? ''), 0, 12),
            'usage' => in_array(($voiture['usage'] ?? ''), ['Daily', 'Secondaire'], true) ? $voiture['usage'] : '',
            'registered' => false,
        ];
    }
    reply(['ok' => true, 'memberProfile' => $fiche]);
}

if ($action === 'admin_set_ban') {
    $admin = requireAdmin($payload);
    $pdo = db();
    $target = (string)($payload['id'] ?? '');
    if ($target === $admin['id']) {
        reply(['ok' => false, 'error' => 'SELF', 'message' => 'Vous ne pouvez pas vous suspendre vous-même.'], 400);
    }
    $banned = (bool)($payload['banned'] ?? true);
    $reason = mb_substr(trim((string)($payload['reason'] ?? '')), 0, 255);
    $pdo->prepare('UPDATE c406_members SET banned_at = ?, ban_reason = ? WHERE id = ?')
        ->execute([$banned ? time() : null, $banned ? $reason : null, $target]);
    $statement = $pdo->prepare('SELECT email, name FROM c406_members WHERE id = ? LIMIT 1');
    $statement->execute([$target]);
    if ($row = $statement->fetch()) {
        plainEmail(
            $row['email'],
            $banned ? 'Votre compte a été suspendu — Coupé 406 Club' : 'Votre compte est rétabli — Coupé 406 Club',
            $banned ? 'Compte suspendu' : 'Compte rétabli',
            $banned
                ? "Bonjour {$row['name']},\n\nVotre accès au Coupé 406 Club a été suspendu par l’administrateur." . ($reason !== '' ? "\n\nMotif : {$reason}" : '')
                : "Bonjour {$row['name']},\n\nVotre accès au Coupé 406 Club est rétabli. Vous pouvez vous reconnecter dans l’application.",
        );
    }
    reply(['ok' => true]);
}

if ($action === 'admin_update_member') {
    requireAdmin($payload);
    $pdo = db();
    $role = (string)($payload['role'] ?? 'member') === 'admin' ? 'admin' : 'member';
    $cible = (string)($payload['id'] ?? '');

    // L'adresse sert d'identifiant de connexion : on ne la change que si elle
    // est valide et libre, et le membre est prévenu sur sa nouvelle adresse.
    $nouvelleAdresse = strtolower(trim((string)($payload['email'] ?? '')));
    if ($nouvelleAdresse !== '') {
        if (!validEmail($nouvelleAdresse)) {
            reply(['ok' => false, 'error' => 'INVALID_EMAIL', 'message' => 'Cette adresse e-mail est invalide.'], 400);
        }
        $lecture = $pdo->prepare('SELECT id, email, name FROM c406_members WHERE id = ? LIMIT 1');
        $lecture->execute([$cible]);
        $avant = $lecture->fetch();
        if ($avant && strtolower($avant['email']) !== $nouvelleAdresse) {
            $occupee = $pdo->prepare('SELECT id FROM c406_members WHERE email = ? AND id <> ? LIMIT 1');
            $occupee->execute([$nouvelleAdresse, $cible]);
            if ($occupee->fetch()) {
                reply(['ok' => false, 'error' => 'EMAIL_TAKEN', 'message' => 'Cette adresse est déjà utilisée par un autre membre.'], 409);
            }
            $pdo->prepare('UPDATE c406_members SET email = ? WHERE id = ?')->execute([$nouvelleAdresse, $cible]);
            plainEmail(
                $nouvelleAdresse,
                'Votre adresse de connexion a changé — Coupé 406 Club',
                'Nouvelle adresse de connexion',
                "Bonjour {$avant['name']},

L’administrateur du club a remplacé votre adresse de connexion :

{$avant['email']}
→ {$nouvelleAdresse}

Utilisez désormais cette nouvelle adresse pour vous connecter. Votre mot de passe est inchangé.",
            );
        }
    }

    $pdo->prepare('UPDATE c406_members SET name = ?, city = ?, region = ?, role = ? WHERE id = ?')->execute([
        mb_substr(trim((string)($payload['name'] ?? '')), 0, 80),
        mb_substr(trim((string)($payload['city'] ?? '')), 0, 80),
        mb_substr(trim((string)($payload['region'] ?? '')), 0, 80),
        $role,
        $cible,
    ]);
    reply(['ok' => true, 'message' => 'Membre mis à jour.']);
}

if ($action === 'admin_reset_password') {
    requireAdmin($payload);
    $pdo = db();
    $statement = $pdo->prepare('SELECT * FROM c406_members WHERE id = ? LIMIT 1');
    $statement->execute([(string)($payload['id'] ?? '')]);
    $target = $statement->fetch();
    if (!$target) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Membre introuvable.'], 404);
    }
    // Mot de passe provisoire lisible, envoyé au membre : l'administrateur ne le voit pas.
    $temporary = strtoupper(bin2hex(random_bytes(4)));
    $pdo->prepare('UPDATE c406_members SET pass_hash = ? WHERE id = ?')->execute([password_hash($temporary, PASSWORD_DEFAULT), $target['id']]);
    [$sent] = plainEmail(
        $target['email'],
        'Votre nouveau mot de passe — Coupé 406 Club',
        'Mot de passe réinitialisé',
        "Bonjour {$target['name']},\n\nL’administrateur du club a réinitialisé votre mot de passe.\n\nVotre mot de passe provisoire : {$temporary}\n\nConnectez-vous avec ce mot de passe, puis changez-le depuis l’application.",
    );
    reply(['ok' => true, 'mailed' => $sent, 'message' => $sent ? 'Nouveau mot de passe envoyé au membre.' : 'Mot de passe changé, mais l’e-mail n’a pas pu partir.']);
}

if ($action === 'admin_delete_member') {
    $admin = requireAdmin($payload);
    $pdo = db();
    $target = (string)($payload['id'] ?? '');
    if ($target === $admin['id']) {
        reply(['ok' => false, 'error' => 'SELF', 'message' => 'Vous ne pouvez pas supprimer votre propre compte.'], 400);
    }
    $pdo->prepare('DELETE FROM c406_push WHERE member_id = ?')->execute([$target]);
    $pdo->prepare('DELETE FROM c406_members WHERE id = ?')->execute([$target]);
    reply(['ok' => true]);
}

/** Retrait d'un document de la bibliothèque du club. */
if ($action === 'admin_delete_guide') {
    requireAdmin($payload);
    $dossier = dirname(__DIR__) . '/guides-fr';
    $cible = $dossier . '/' . basename((string)($payload['file'] ?? ''));
    if (!is_file($cible) || !str_ends_with(strtolower($cible), '.pdf')) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Document introuvable.'], 404);
    }
    reply(@unlink($cible)
        ? ['ok' => true, 'message' => 'Document retiré de la bibliothèque.']
        : ['ok' => false, 'error' => 'DELETE', 'message' => 'Suppression impossible.']);
}

if ($action === 'admin_reports') {
    requireAdmin($payload);
    $pdo = db();
    $rows = $pdo->query('SELECT r.*, a.name AS reporter_name, b.name AS target_name, b.email AS target_email
        FROM c406_reports r
        LEFT JOIN c406_members a ON a.id = r.reporter_id
        LEFT JOIN c406_members b ON b.id = r.target_id
        ORDER BY r.status = "open" DESC, r.created_at DESC LIMIT 200')->fetchAll();
    reply(['ok' => true, 'reports' => array_map(static fn(array $row): array => [
        'id' => (int)$row['id'],
        'reporter' => $row['reporter_name'] ?? 'Membre supprimé',
        'reporterId' => (string)$row['reporter_id'],
        'target' => $row['target_name'] ?? 'Membre supprimé',
        'targetId' => $row['target_id'],
        'reason' => $row['reason'],
        'message' => $row['message'],
        'status' => $row['status'],
        'createdAt' => (int)$row['created_at'],
    ], $rows)]);
}

if ($action === 'admin_resolve_report') {
    requireAdmin($payload);
    $pdo = db();
    $pdo->prepare('UPDATE c406_reports SET status = ?, handled_at = ? WHERE id = ?')
        ->execute([(string)($payload['status'] ?? 'closed') === 'open' ? 'open' : 'closed', time(), (int)($payload['id'] ?? 0)]);
    reply(['ok' => true]);
}

/**
 * Annonce d'une nouvelle version : une notification poussée part vers tous les
 * appareils enregistrés, même application fermée. Le message est construit à
 * partir de latest.json pour éviter toute divergence.
 */
if ($action === 'admin_announce_update') {
    requireAdmin($payload);
    $pdo = db();
    $manifest = __DIR__ . '/latest.json';
    if (!is_readable($manifest)) {
        reply(['ok' => false, 'error' => 'NO_MANIFEST', 'message' => 'Aucune version publiée.'], 404);
    }
    $version = json_decode((string)file_get_contents($manifest), true) ?: [];
    $nom = (string)($version['versionName'] ?? '');
    // Annoncer une version que personne ne peut installer ne sert a rien : si
    // la version d essai est plus recente, c est qu elle n a pas ete publiee.
    $essai = json_decode((string)@file_get_contents(__DIR__ . '/latest-test.json'), true);
    if (is_array($essai) && (int)($essai['versionCode'] ?? 0) > (int)($version['versionCode'] ?? 0)) {
        reply(['ok' => false, 'error' => 'NON_PUBLIEE',
            'message' => 'Le club en est encore a la version ' . $nom . ', alors que la ' . (string)($essai['versionName'] ?? '')
                . ' attend en essai. Publiez-la d abord : onglet Notification, bouton « Publier pour tout le club ».'], 409);
    }
    // Le fichier annonce doit exister, sinon le lien de telechargement echoue.
    if (isset($version['url']) && !is_file(__DIR__ . '/' . basename((string)$version['url']))) {
        reply(['ok' => false, 'error' => 'FICHIER_ABSENT',
            'message' => 'L APK de la version ' . $nom . ' n est plus sur le serveur. Publiez la version d essai avant d annoncer.'], 409);
    }
    $ids = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL AND verified_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
    $devices = (int)$pdo->query('SELECT COUNT(*) FROM c406_push')->fetchColumn();
    $sent = pushToMembers($pdo, $ids, "Mise à jour {$nom} disponible", (string)($version['notes'] ?? 'Ouvrez l’application pour l’installer.'),
        ['type' => 'app-update']);
    reply(['ok' => true, 'sent' => $sent, 'devices' => $devices, 'versionName' => $nom, 'message' => $sent > 0
        ? "Notification envoyée à {$sent} appareil(s) pour la version {$nom}."
        : ($devices === 0
            ? 'Aucun appareil n’a encore enregistré les notifications : ouvrez l’application et autorisez-les.'
            : 'Firebase a refusé l’envoi.')]);
}

if ($action === 'admin_broadcast') {
    requireAdmin($payload);
    $pdo = db();
    $title = mb_substr(trim((string)($payload['title'] ?? '')), 0, 80);
    $body = mb_substr(trim((string)($payload['body'] ?? '')), 0, 300);
    if ($title === '' || $body === '') {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Titre et message sont nécessaires.'], 400);
    }
    $ids = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL AND verified_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
    $devices = (int)$pdo->query('SELECT COUNT(*) FROM c406_push')->fetchColumn();
    $configured = is_readable(GEARS_ROOT . '/var/coupe406/firebase-service-account.json');
    $sent = pushToMembers($pdo, $ids, $title, $body, ['type' => 'home']);
    // On distingue les trois cas, sinon l'administrateur ne sait pas quoi corriger.
    $message = match (true) {
        $sent > 0 => "Notification envoyée à {$sent} appareil(s).",
        !$configured => 'Firebase n’est pas configuré sur le serveur.',
        $devices === 0 => 'Aucun appareil n’a encore accepté les notifications : ouvrez l’application et autorisez-les.',
        default => 'Firebase a refusé l’envoi. Vérifiez le projet et les jetons enregistrés.',
    };
    reply(['ok' => true, 'sent' => $sent, 'devices' => $devices, 'message' => $message]);
}

// ------------------------------------------------- sauvegarde du garage
/**
 * Le garage vit sur le téléphone ; cette copie permet de le retrouver après un
 * changement d'appareil. Elle est rattachée au compte et n'est jamais partagée.
 */
if ($action === 'backup_put') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $contenu = (string)($payload['payload'] ?? '');
    if ($contenu === '' || strlen($contenu) > 6000000) {
        reply(['ok' => false, 'error' => 'SIZE', 'message' => 'Sauvegarde vide ou trop volumineuse.'], 400);
    }
    $pdo->prepare('INSERT INTO c406_backups (member_id, payload, updated_at) VALUES (?, ?, ?)
                   ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at = VALUES(updated_at)')
        ->execute([$member['id'], $contenu, time()]);
    reply(['ok' => true, 'updatedAt' => time()]);
}

if ($action === 'backup_get') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $statement = $pdo->prepare('SELECT payload, updated_at FROM c406_backups WHERE member_id = ? LIMIT 1');
    $statement->execute([$member['id']]);
    $ligne = $statement->fetch();
    reply(['ok' => true, 'payload' => $ligne ? (string)$ligne['payload'] : '', 'updatedAt' => $ligne ? (int)$ligne['updated_at'] : 0]);
}

// ------------------------------------------------------------ messagerie
if ($action === 'messages_send') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $cible = (string)($payload['targetId'] ?? '');
    $corps = trim((string)($payload['body'] ?? ''));
    if ($cible === '' || $cible === $member['id'] || $corps === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Message vide ou destinataire inconnu.'], 400);
    }
    $verif = $pdo->prepare('SELECT id, name, banned_at FROM c406_members WHERE id = ? LIMIT 1');
    $verif->execute([$cible]);
    $destinataire = $verif->fetch();
    if (!$destinataire || $destinataire['banned_at'] !== null) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Ce membre n est plus joignable.'], 404);
    }
    $verdict = moderationVerdict($corps);
    if ($verdict['action'] === 'block') {
        moderationSignale($pdo, $member['id'], 'message bloque : ' . $verdict['reason'], $corps);
        reply(['ok' => false, 'error' => 'BLOCKED', 'reason' => $verdict['reason'], 'message' => 'Message refuse : les propos haineux ne sont pas acceptes dans le club. Si vous pensez que c est une erreur, demandez une verification.'], 422);
    }
    if ($verdict['action'] === 'flag') {
        moderationSignale($pdo, $member['id'], $verdict['reason'], $corps);
    }
    // L annonce d ou part la conversation : elle emportera ces messages
    // quand elle sera vendue.
    $genre = in_array((string)($payload['listingKind'] ?? ''), ['part', 'service'], true) ? (string)$payload['listingKind'] : '';
    $annonce = $genre === '' ? 0 : (int)($payload['listingId'] ?? 0);
    $pdo->prepare('INSERT INTO c406_messages (sender_id, target_id, body, created_at, listing_kind, listing_id) VALUES (?, ?, ?, ?, ?, ?)')
        ->execute([$member['id'], $cible, mb_substr($corps, 0, 4000), time(), $genre, $annonce]);
    $messageId = (int)$pdo->lastInsertId();
    $pdo->prepare('DELETE FROM c406_typing WHERE member_id = ? AND target_id = ?')->execute([$member['id'], $cible]);
    pushToMembers($pdo, [$cible], 'Message de ' . $member['name'], mb_substr($corps, 0, 120),
        ['type' => 'message', 'senderId' => $member['id'], 'name' => $member['name'], 'messageId' => $messageId]);
    reply(['ok' => true, 'id' => $messageId]);
}

/** Modifie uniquement un message dont le membre est l'auteur. */
if ($action === 'messages_edit') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $id = (int)($payload['id'] ?? 0);
    $corps = trim((string)($payload['body'] ?? ''));
    if ($id <= 0 || $corps === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Le message modifie ne peut pas etre vide.'], 400);
    }
    $verif = $pdo->prepare('SELECT id, sender_id FROM c406_messages WHERE id = ? LIMIT 1');
    $verif->execute([$id]);
    $message = $verif->fetch();
    if (!$message) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Ce message n existe plus.'], 404);
    }
    if (!hash_equals((string)$message['sender_id'], (string)$member['id'])) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Seul l auteur peut modifier ce message.'], 403);
    }
    $verdict = moderationVerdict($corps);
    if ($verdict['action'] === 'block') {
        moderationSignale($pdo, $member['id'], 'modification de message bloquee : ' . $verdict['reason'], $corps);
        reply(['ok' => false, 'error' => 'BLOCKED', 'reason' => $verdict['reason'],
            'message' => 'Modification refusee : les propos haineux ne sont pas acceptes dans le club.'], 422);
    }
    if ($verdict['action'] === 'flag') {
        moderationSignale($pdo, $member['id'], $verdict['reason'], $corps);
    }
    $modifie = time();
    $pdo->prepare('UPDATE c406_messages SET body = ?, edited_at = ? WHERE id = ? AND sender_id = ?')
        ->execute([mb_substr($corps, 0, 4000), $modifie, $id, $member['id']]);
    reply(['ok' => true, 'id' => $id, 'body' => mb_substr($corps, 0, 4000), 'editedAt' => $modifie]);
}

/** Supprime un seul message, uniquement à la demande de son auteur. */
if ($action === 'messages_delete') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $id = (int)($payload['id'] ?? 0);
    if ($id <= 0) {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Message inconnu.'], 400);
    }
    $requete = $pdo->prepare('DELETE FROM c406_messages WHERE id = ? AND sender_id = ?');
    $requete->execute([$id, $member['id']]);
    if ($requete->rowCount() === 0) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Seul l auteur peut supprimer ce message.'], 403);
    }
    reply(['ok' => true, 'id' => $id]);
}

/**
 * Le meme message adresse a plusieurs membres a la fois, mais jamais en groupe :
 * chacun le recoit dans sa propre conversation et ignore les autres destinataires.
 */
if ($action === 'messages_broadcast') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $corps = trim((string)($payload['body'] ?? ''));
    $cibles = is_array($payload['targets'] ?? null) ? $payload['targets'] : [];
    $cibles = array_values(array_unique(array_filter(array_map('strval', $cibles))));
    $cibles = array_values(array_diff($cibles, [$member['id']]));
    if ($corps === '' || $cibles === []) {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Message vide ou aucun destinataire.'], 400);
    }
    // Une demande de coup de main s adresse au voisinage, pas au club entier.
    if (count($cibles) > 15) {
        reply(['ok' => false, 'error' => 'TOO_MANY', 'message' => 'Quinze membres au maximum a la fois.'], 400);
    }

    $verdict = moderationVerdict($corps);
    if ($verdict['action'] === 'block') {
        moderationSignale($pdo, $member['id'], 'message groupe bloque : ' . $verdict['reason'], $corps);
        reply(['ok' => false, 'error' => 'BLOCKED', 'reason' => $verdict['reason'], 'message' => 'Message refuse : les propos haineux ne sont pas acceptes dans le club.'], 422);
    }
    if ($verdict['action'] === 'flag') {
        moderationSignale($pdo, $member['id'], $verdict['reason'], $corps);
    }

    $texte = mb_substr($corps, 0, 4000);
    $marque = time();
    $ecrire = $pdo->prepare('INSERT INTO c406_messages (sender_id, target_id, body, created_at, listing_kind, listing_id) VALUES (?, ?, ?, ?, ?, 0)');
    $joignable = $pdo->prepare('SELECT id FROM c406_members WHERE id = ? AND banned_at IS NULL AND approved_at IS NOT NULL LIMIT 1');
    $recus = [];
    foreach ($cibles as $cible) {
        $joignable->execute([$cible]);
        if ($joignable->fetchColumn() === false) {
            continue;
        }
        $ecrire->execute([$member['id'], $cible, $texte, $marque, 'entraide']);
        $recus[] = $cible;
    }
    pushToMembers($pdo, $recus, 'Coup de main demande par ' . $member['name'], mb_substr($texte, 0, 120),
        ['type' => 'message', 'senderId' => $member['id'], 'name' => $member['name']]);
    reply(['ok' => true, 'sent' => count($recus)]);
}

/**
 * Efface une conversation, des deux cotes et pour de bon.
 *
 * Un message prive ne concerne que deux personnes : quand l une des deux
 * decide qu il n a plus lieu d etre, il quitte le serveur. Il n y a pas de
 * corbeille, et c est voulu — c est ce qui rend la promesse tenable.
 */
if ($action === 'messages_delete_thread') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $autre = (string)($payload['memberId'] ?? '');
    if ($autre === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Conversation inconnue.'], 400);
    }
    $requete = $pdo->prepare('DELETE FROM c406_messages
        WHERE (sender_id = ? AND target_id = ?) OR (sender_id = ? AND target_id = ?)');
    $requete->execute([$autre, $member['id'], $member['id'], $autre]);
    $pdo->prepare('DELETE FROM c406_typing WHERE (member_id = ? AND target_id = ?) OR (member_id = ? AND target_id = ?)')
        ->execute([$autre, $member['id'], $member['id'], $autre]);
    reply(['ok' => true, 'efface' => $requete->rowCount(),
           'message' => $requete->rowCount() . ' message' . ($requete->rowCount() > 1 ? 's supprimes' : ' supprime') . ' du serveur.']);
}

/**
 * Cherche une piece dans les references d origine du Coupe.
 *
 * La recherche est faite pour quelqu un qui tape vite et mal : on decoupe la
 * demande en mots, chacun doit se retrouver quelque part dans la designation,
 * la position, la fonction ou le titre de la planche. La reference elle-meme
 * est acceptee telle quelle, avec ou sans espace.
 *
 * L OPR du vehicule, quand il est fourni, ecarte les pieces qui ne valent pas
 * pour cette voiture : le constructeur exprime ses coupures en OPR, et le
 * numero DAM du membre le donne.
 */
if ($action === 'pieces_search') {
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => false, 'error' => 'DB', 'message' => 'Base indisponible.'], 503);
    }
    ensureTables($pdo);
    $mots = preg_split('/[^0-9a-zA-Z\x{00C0}-\x{024F}]+/u',
        mb_strtolower(trim((string)($payload['query'] ?? ''))), -1, PREG_SPLIT_NO_EMPTY) ?: [];
    $moteur = preg_replace('/[^a-z0-9]/', '', mb_strtolower((string)($payload['moteur'] ?? '')));
    $opr = (int)($payload['opr'] ?? 0);
    $phase = in_array((string)($payload['phase'] ?? ''), ['PH1', 'PH2'], true) ? (string)$payload['phase'] : '';
    // Le domaine : la premiere branche de l arbre des fonctions. Il separe la
    // mecanique de l electricite, de la carrosserie et des equipements.
    $domaine = (string)($payload['domaine'] ?? '');

    $conditions = [];
    $valeurs = [];
    if ($moteur !== '') {
        // Surtout pas de « OR moteur = '' » : une piece non rattachee a un
        // moteur n est pas une piece universelle, c est une piece dont on ne
        // sait pas a quoi elle va. Elle n a rien a faire dans cette liste.
        $conditions[] = 'moteur = ?';
        $valeurs[] = $moteur;
    }
    if ($phase !== '') {
        $conditions[] = 'phase = ?';
        $valeurs[] = $phase;
    }
    if ($domaine !== '') {
        // Une piece appartient souvent a plusieurs domaines : le cric est en
        // carrosserie et en mecanique. On cherche donc dans la liste complete.
        $conditions[] = "CONCAT('|', domaines, '|') LIKE ?";
        $valeurs[] = '%|' . $domaine . '|%';
    }
    if ($opr > 0) {
        // Dans le doute, on cache plutot que de montrer.
        //
        // Une meme reference apparait parfois deux fois : une ligne bornee en
        // OPR, et une ligne sans borne heritee d une autre planche. Se contenter
        // de la ligne sans borne ferait passer une piece d une autre periode.
        // Donc : des qu une reference porte une borne pour ce moteur, c est la
        // borne qui decide. Sans aucune borne, la piece reste affichee.
        $conditions[] = '(
            NOT EXISTS (SELECT 1 FROM c406_pieces b
                        WHERE b.reference = c406_pieces.reference AND b.moteur = c406_pieces.moteur
                          AND (b.opr_depuis > 0 OR b.opr_jusqua > 0))
            OR EXISTS (SELECT 1 FROM c406_pieces b
                       WHERE b.reference = c406_pieces.reference AND b.moteur = c406_pieces.moteur
                         AND (b.opr_depuis > 0 OR b.opr_jusqua > 0)
                         AND (b.opr_depuis = 0 OR b.opr_depuis <= ?)
                         AND (b.opr_jusqua = 0 OR b.opr_jusqua >= ?))
        )';
        $valeurs[] = $opr;
        $valeurs[] = $opr;
    }
    // Chaque mot doit se retrouver quelque part : c est ce qui permet de taper
    // « joint culasse » ou « sonde temperature eau » sans connaitre le libelle.
    foreach (array_slice($mots, 0, 6) as $mot) {
        $conditions[] = "(designation LIKE ? OR complement LIKE ? OR fonction LIKE ? OR document LIKE ? OR REPLACE(reference, ' ', '') LIKE ?)";
        $comme = '%' . $mot . '%';
        array_push($valeurs, $comme, $comme, $comme, $comme, str_replace(' ', '', $comme));
    }
    $ou = implode(' AND ', $conditions);
    $where = $ou !== '' ? ' WHERE ' . $ou : '';
    $total = $pdo->prepare('SELECT COUNT(DISTINCT reference, designation, complement) FROM c406_pieces' . $where);
    $total->execute($valeurs);
    $compte = (int)$total->fetchColumn();

    // Le classement par pertinence : la piece dont le NOM porte le mot cherche
    // passe devant celle qui ne le porte que dans sa fonction. Sans cela, taper
    // « faisceau » remonte d abord les agrafes de faisceau, par ordre
    // alphabetique, et le faisceau lui-meme se perd trente lignes plus bas.
    $score = ['0'];
    $valeursScore = [];
    foreach (array_slice($mots, 0, 6) as $mot) {
        $score[] = '(CASE WHEN designation LIKE ? THEN 6 WHEN designation LIKE ? THEN 4 ELSE 0 END)';
        $valeursScore[] = $mot . '%';
        $valeursScore[] = '%' . $mot . '%';
    }
    $pertinence = implode(' + ', $score);

    $requete = $pdo->prepare('SELECT reference, designation, complement,
               GROUP_CONCAT(DISTINCT applicabilite ORDER BY applicabilite SEPARATOR " · ") AS applicabilites,
               MIN(fonction) AS fonction, GROUP_CONCAT(DISTINCT fonctions SEPARATOR "|") AS fonctions,
               GROUP_CONCAT(DISTINCT moteur_libelle ORDER BY moteur_libelle SEPARATOR ", ") AS moteurs,
               GROUP_CONCAT(DISTINCT domaines SEPARATOR "|") AS tous_domaines,
               GROUP_CONCAT(DISTINCT document ORDER BY document SEPARATOR " · ") AS planches,
               GROUP_CONCAT(DISTINCT repere ORDER BY repere SEPARATOR ", ") AS reperes,
               ' . $pertinence . ' AS pertinence
        FROM c406_pieces' . $where . '
        GROUP BY reference, designation, complement
        ORDER BY pertinence DESC, designation ASC, reference ASC LIMIT 120');
    $requete->execute(array_merge($valeursScore, $valeurs));
    $pieces = [];
    foreach ($requete->fetchAll() as $l) {
        $pieces[] = [
            'reference' => (string)$l['reference'],
            'designation' => (string)$l['designation'],
            'complement' => (string)$l['complement'],
            'applicabilite' => trim((string)$l['applicabilites'], ' ·'),
            // On montre la branche du domaine demandé : une pièce rangée à la
            // fois en mécanique et en électricité doit s'afficher du bon côté.
            'fonction' => (static function () use ($l, $domaine) {
                $toutes = array_filter(explode('|', (string)($l['fonctions'] ?? '')));
                if ($domaine !== '') {
                    foreach ($toutes as $branche) {
                        if (str_starts_with($branche, $domaine)) {
                            return $branche;
                        }
                    }
                }
                // Aucune branche de ce domaine : plutot que d afficher le
                // chemin d un autre monde, on nomme simplement le domaine.
                if ($domaine !== '') {
                    return $domaine;
                }
                // Sans domaine choisi, la colonne fonction est parfois vide :
                // on prend alors la premiere branche connue, faute de quoi la
                // ligne s afficherait sans indiquer de quel monde elle vient.
                $courte = trim((string)$l['fonction']);
                return $courte !== '' ? $courte : (string)(reset($toutes) ?: '');
            })(),
            'domaines' => array_values(array_unique(array_filter(
                explode('|', (string)($l['tous_domaines'] ?? ''))))),
            'moteurLibelle' => (string)($l['moteurs'] ?? ''),
            'planche' => mb_substr((string)($l['planches'] ?? ''), 0, 220),
            'repere' => mb_substr((string)($l['reperes'] ?? ''), 0, 60),
        ];
    }
    reply(['ok' => true, 'pieces' => $pieces, 'total' => $compte]);
}

if ($action === 'messages_threads') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $statement = $pdo->prepare(
        'SELECT autre.id, autre.name, autre.city, autre.region,
                (SELECT body FROM c406_messages m WHERE (m.sender_id = autre.id AND m.target_id = :moi)
                     OR (m.sender_id = :moi2 AND m.target_id = autre.id) ORDER BY m.id DESC LIMIT 1) AS dernier,
                (SELECT created_at FROM c406_messages m WHERE (m.sender_id = autre.id AND m.target_id = :moi3)
                     OR (m.sender_id = :moi4 AND m.target_id = autre.id) ORDER BY m.id DESC LIMIT 1) AS date_dernier,
                (SELECT COUNT(*) FROM c406_messages m WHERE m.sender_id = autre.id AND m.target_id = :moi5 AND m.read_at IS NULL) AS non_lus
         FROM c406_members autre
         WHERE autre.id <> :moi6 AND EXISTS (
            SELECT 1 FROM c406_messages m WHERE (m.sender_id = autre.id AND m.target_id = :moi7)
                OR (m.sender_id = :moi8 AND m.target_id = autre.id))
         ORDER BY date_dernier DESC');
    $statement->execute(array_fill_keys(['moi', 'moi2', 'moi3', 'moi4', 'moi5', 'moi6', 'moi7', 'moi8'], $member['id']));
    $fils = [];
    foreach ($statement->fetchAll() as $ligne) {
        $fils[] = [
            'memberId' => $ligne['id'],
            'name' => $ligne['name'],
            'city' => $ligne['city'],
            'region' => $ligne['region'],
            'last' => (string)$ligne['dernier'],
            'at' => (int)$ligne['date_dernier'],
            'unread' => (int)$ligne['non_lus'],
        ];
    }
    reply(['ok' => true, 'threads' => $fils]);
}

if ($action === 'messages_thread') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $autre = (string)($payload['memberId'] ?? '');
    $statement = $pdo->prepare('SELECT id, sender_id, body, created_at, read_at, edited_at FROM c406_messages
        WHERE (sender_id = ? AND target_id = ?) OR (sender_id = ? AND target_id = ?)
        ORDER BY id ASC LIMIT 300');
    $statement->execute([$autre, $member['id'], $member['id'], $autre]);
    $messages = [];
    foreach ($statement->fetchAll() as $ligne) {
        $messages[] = ['id' => (int)$ligne['id'], 'mine' => $ligne['sender_id'] === $member['id'], 'body' => (string)$ligne['body'], 'at' => (int)$ligne['created_at'],
            'readAt' => $ligne['read_at'] === null ? null : (int)$ligne['read_at'],
            'editedAt' => $ligne['edited_at'] === null ? null : (int)$ligne['edited_at']];
    }
    $pdo->prepare('UPDATE c406_messages SET read_at = ? WHERE sender_id = ? AND target_id = ? AND read_at IS NULL')
        ->execute([time(), $autre, $member['id']]);

    // L application redemande cette conversation toutes les trois secondes tant
    // qu elle est ouverte : la meme requete dit « j ecris » et demande « ecrit-il ? ».
    if ((int)($payload['typing'] ?? 0) === 1) {
        $pdo->prepare('INSERT INTO c406_typing (member_id, target_id, at) VALUES (?, ?, ?)
                       ON DUPLICATE KEY UPDATE at = VALUES(at)')->execute([$member['id'], $autre, time()]);
    }
    $frappe = $pdo->prepare('SELECT at FROM c406_typing WHERE member_id = ? AND target_id = ? LIMIT 1');
    $frappe->execute([$autre, $member['id']]);
    $quand = (int)$frappe->fetchColumn();
    reply(['ok' => true, 'messages' => $messages, 'typing' => $quand > 0 && time() - $quand <= 7]);
}

// ----------------------------------------------------- actualites du club
if ($action === 'news_list') {
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => true, 'news' => []]);
    }
    ensureTables($pdo);
    $lignes = $pdo->query('SELECT id, title, body, created_at FROM c406_news ORDER BY id DESC LIMIT 20')->fetchAll();
    $news = [];
    foreach ($lignes as $ligne) {
        $news[] = ['id' => (int)$ligne['id'], 'title' => (string)$ligne['title'], 'body' => (string)$ligne['body'], 'at' => (int)$ligne['created_at']];
    }
    reply(['ok' => true, 'news' => $news]);
}

if ($action === 'admin_news_publish') {
    $admin = requireAdmin($payload);
    $pdo = db();
    $titre = trim((string)($payload['title'] ?? ''));
    $corps = trim((string)($payload['body'] ?? ''));
    if ($titre === '' || $corps === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Titre et texte sont necessaires.'], 400);
    }
    $pdo->prepare('INSERT INTO c406_news (title, body, author_id, created_at) VALUES (?, ?, ?, ?)')
        ->execute([mb_substr($titre, 0, 120), mb_substr($corps, 0, 4000), $admin['id'], time()]);
    $actualiteId = (int)$pdo->lastInsertId();
    $membres = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL')->fetchAll(PDO::FETCH_COLUMN);
    $envoyes = pushToMembers($pdo, $membres, $titre, mb_substr($corps, 0, 120),
        ['type' => 'news', 'newsId' => $actualiteId]);
    reply(['ok' => true, 'sent' => $envoyes, 'message' => 'Actualite publiee.']);
}

// La note publique de cette version se publie une seule fois, quand un membre
// ouvre réellement la 1.06. Les outils réservés à l'administration n'y sont
// volontairement pas détaillés.
if ($action === 'release_note_106') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $marque = $pdo->prepare("INSERT IGNORE INTO c406_settings (cle, valeur, updated_at) VALUES ('release_note_106', 'published', ?)");
    $marque->execute([time()]);
    $publie = $marque->rowCount() > 0;
    if ($publie) {
        $titre = 'Nouveautés de la version 1.06';
        $corps = "Vous pouvez désormais ajouter des liens YouTube utiles sous chaque guide PDF et signaler un lien incorrect.\n\nCorrections de bugs mineurs.";
        $auteur = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1")->fetchColumn();
        $pdo->prepare('INSERT INTO c406_news (title, body, author_id, created_at) VALUES (?, ?, ?, ?)')
            ->execute([$titre, $corps, (string)($auteur ?: $member['id']), time()]);
        $actualiteId = (int)$pdo->lastInsertId();
        $membres = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL AND approved_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
        pushToMembers($pdo, $membres, $titre, 'Liens YouTube sous les PDF et corrections de bugs mineurs.',
            ['type' => 'news', 'newsId' => $actualiteId]);
    }
    reply(['ok' => true, 'published' => $publie]);
}

// La note publique apparaît une seule fois, seulement quand la 1.0.7 est
// réellement déclarée publiée au club. Un APK d'essai ne l'annonce pas trop tôt.
if ($action === 'release_note_107') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $stable = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
    if ((int)($stable['versionCode'] ?? 0) < 203) {
        reply(['ok' => true, 'published' => false]);
    }
    $marque = $pdo->prepare("INSERT IGNORE INTO c406_settings (cle, valeur, updated_at) VALUES ('release_note_107', 'published', ?)");
    $marque->execute([time()]);
    $publie = $marque->rowCount() > 0;
    if ($publie) {
        $titre = 'Nouveautés de la version 1.0.7';
        $corps = "Un nouvel espace STL 3D est disponible dans Entraide 406. Vous pouvez y partager et télécharger des archives ZIP, 7z ou RAR contenant des modèles de pièces à imprimer pour le Coupé, les filtrer par Phase 1 ou Phase 2 et lire la fonction indiquée par l'auteur. Si vous n'avez pas d'imprimante 3D, une liste de membres volontaires est proposée par région pour les contacter en privé.\n\nCorrections de bugs mineurs.";
        $auteur = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1")->fetchColumn();
        $pdo->prepare('INSERT INTO c406_news (title, body, author_id, created_at) VALUES (?, ?, ?, ?)')
            ->execute([$titre, $corps, (string)($auteur ?: $member['id']), time()]);
        $actualiteId = (int)$pdo->lastInsertId();
        $membres = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL AND approved_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
        pushToMembers($pdo, $membres, $titre, 'Nouveaux modèles 3D dans Entraide 406.',
            ['type' => 'news', 'newsId' => $actualiteId]);
    }
    reply(['ok' => true, 'published' => $publie]);
}

// Actualité très courte demandée pour la 1.0.17. Elle ne paraît qu'après la
// publication stable : ouvrir l'APK d'essai ne doit jamais l'annoncer au club.
if ($action === 'release_note_1017') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $stable = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
    if ((int)($stable['versionCode'] ?? 0) < 213) {
        reply(['ok' => true, 'published' => false]);
    }
    $marque = $pdo->prepare("INSERT IGNORE INTO c406_settings (cle, valeur, updated_at) VALUES ('release_note_1017', 'published', ?)");
    $marque->execute([time()]);
    $publie = $marque->rowCount() > 0;
    if ($publie) {
        $titre = 'Nouveautés de la version 1.0.17';
        $corps = 'Correction de bug mineur.';
        $auteur = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1")->fetchColumn();
        $pdo->prepare('INSERT INTO c406_news (title, body, author_id, created_at) VALUES (?, ?, ?, ?)')
            ->execute([$titre, $corps, (string)($auteur ?: $member['id']), time()]);
        $actualiteId = (int)$pdo->lastInsertId();
        $membres = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL AND approved_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
        pushToMembers($pdo, $membres, $titre, $corps, ['type' => 'news', 'newsId' => $actualiteId]);
    }
    reply(['ok' => true, 'published' => $publie]);
}

if ($action === 'admin_news_delete') {
    requireAdmin($payload);
    $pdo = db();
    $identifiant = (int)($payload['id'] ?? 0);
    $pdo->prepare('DELETE FROM c406_news_reads WHERE news_id = ?')->execute([$identifiant]);
    $pdo->prepare('DELETE FROM c406_news WHERE id = ?')->execute([$identifiant]);
    reply(['ok' => true, 'message' => 'Actualite retiree.']);
}

if ($action === 'news_seen') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    // Ouvrir le volet présente les publications : toutes celles qui existent à
    // cet instant sont donc acquittées pour ce compte, jamais pour les autres.
    $pdo->prepare('INSERT IGNORE INTO c406_news_reads (news_id, member_id, seen_at)
                   SELECT id, ?, ? FROM c406_news')->execute([$member['id'], time()]);
    reply(['ok' => true, 'unreadNews' => 0]);
}

// ------------------------------------------- suppression de son propre compte
/** Le recensement perd le membre, pas la voiture : la fiche est anonymisee. */
function anonymiserRecensement(PDO $pdo, string $membre): void
{
    $pdo->prepare("UPDATE c406_registry SET member_id = '', note = '' WHERE member_id = ?")->execute([$membre]);
    $photos = $pdo->prepare("SELECT file_name FROM c406_listing_photos WHERE kind = 'registry' AND member_id = ?");
    $photos->execute([$membre]);
    foreach ($photos->fetchAll() as $photo) {
        $chemin = __DIR__ . '/photos/' . basename((string)$photo['file_name']);
        if (is_file($chemin)) {
            @unlink($chemin);
        }
    }
    $pdo->prepare("DELETE FROM c406_listing_photos WHERE kind = 'registry' AND member_id = ?")->execute([$membre]);
}

if ($action === 'delete_account') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    if (!password_verify((string)($payload['password'] ?? ''), (string)$member['pass_hash'])) {
        reply(['ok' => false, 'error' => 'BAD_CREDENTIALS', 'message' => 'Mot de passe incorrect.'], 401);
    }
    // La voiture reste au recensement du club, sans rien qui rattache au membre.
    anonymiserRecensement($pdo, (string)$member['id']);
    $ownedArchives = $pdo->prepare('SELECT stored_name FROM c406_stl_models WHERE member_id = ?');
    $ownedArchives->execute([$member['id']]);
    $archiveNames = $ownedArchives->fetchAll(PDO::FETCH_COLUMN);
    foreach ([
        'DELETE FROM c406_push WHERE member_id = ?',
        'DELETE FROM c406_backups WHERE member_id = ?',
        'DELETE FROM c406_messages WHERE sender_id = ? OR target_id = ?',
        'DELETE FROM c406_reports WHERE reporter_id = ? OR target_id = ?',
        'DELETE FROM c406_idea_votes WHERE member_id = ?',
        'DELETE FROM c406_idea_reads WHERE member_id = ?',
        'DELETE FROM c406_news_reads WHERE member_id = ?',
        'DELETE FROM c406_member_notices WHERE member_id = ?',
        'DELETE FROM c406_welcome_wishes WHERE newcomer_id = ? OR greeter_id = ?',
        'DELETE FROM c406_welcome_rounds WHERE newcomer_id = ?',
        'DELETE FROM c406_stl_reports WHERE model_id IN (SELECT id FROM c406_stl_models WHERE member_id = ?)',
        'DELETE FROM c406_stl_reports WHERE member_id = ?',
        'DELETE FROM c406_stl_models WHERE member_id = ?',
        'DELETE FROM c406_print_volunteers WHERE member_id = ?',
        'DELETE FROM c406_ideas WHERE member_id = ?',
        'DELETE FROM c406_members WHERE id = ?',
    ] as $requete) {
        $pdo->prepare($requete)->execute(array_fill(0, substr_count($requete, '?'), $member['id']));
    }
    foreach ($archiveNames as $archiveName) {
        if (preg_match('/^[a-f0-9]{32}\.(?:zip|7z|rar)$/', (string)$archiveName)) {
            @unlink(stlStorageDirectory() . '/' . $archiveName);
        }
    }
    reply(['ok' => true, 'message' => 'Compte et donnees supprimes.']);
}

// ----------------------------------------------------------- boite a idees
if ($action === 'idea_reaction_setting') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    if (array_key_exists('enabled', $payload)) {
        $pdo->prepare('UPDATE c406_members SET notify_idea_reactions = ? WHERE id = ?')
            ->execute([!empty($payload['enabled']) ? 1 : 0, $member['id']]);
    }
    $statement = $pdo->prepare('SELECT notify_idea_reactions FROM c406_members WHERE id = ?');
    $statement->execute([$member['id']]);
    reply(['ok' => true, 'enabled' => (int)$statement->fetchColumn() === 1]);
}

if ($action === 'member_notice') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    // Secours au cron : même si le planificateur a pris du retard, le message
    // de bienvenue devient disponible dès la première ouverture après 24 h.
    processWelcomeRounds($pdo);
    $statement = $pdo->prepare('SELECT id, kind, title, body FROM c406_member_notices
                                WHERE member_id = ? AND seen_at IS NULL ORDER BY id ASC LIMIT 1');
    $statement->execute([$member['id']]);
    $notice = $statement->fetch();
    reply(['ok' => true, 'notice' => $notice ? [
        'id' => (int)$notice['id'], 'kind' => (string)$notice['kind'],
        'title' => (string)$notice['title'], 'body' => (string)$notice['body'],
    ] : null]);
}

if ($action === 'member_notice_seen') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $pdo->prepare('UPDATE c406_member_notices SET seen_at = ? WHERE id = ? AND member_id = ?')
        ->execute([time(), (int)($payload['id'] ?? 0), $member['id']]);
    reply(['ok' => true]);
}

if ($action === 'welcome_wish') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $newcomerId = (string)($payload['newcomer'] ?? '');
    if ($newcomerId === '' || $newcomerId === (string)$member['id']) {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Souhait impossible.'], 400);
    }
    $round = $pdo->prepare('SELECT deliver_at, delivered_at FROM c406_welcome_rounds
                            WHERE newcomer_id = ? LIMIT 1');
    $round->execute([$newcomerId]);
    $welcome = $round->fetch();
    if (!$welcome || $welcome['delivered_at'] !== null || (int)$welcome['deliver_at'] <= time()) {
        reply(['ok' => false, 'error' => 'CLOSED', 'message' => 'La journée de bienvenue est terminée.'], 409);
    }
    $insert = $pdo->prepare('INSERT IGNORE INTO c406_welcome_wishes
                             (newcomer_id, greeter_id, wished_at) VALUES (?, ?, ?)');
    $insert->execute([$newcomerId, (string)$member['id'], time()]);
    reply(['ok' => true, 'already' => $insert->rowCount() === 0,
        'message' => $insert->rowCount() === 0
            ? 'Votre bienvenue était déjà enregistrée.'
            : 'Merci ! Votre bienvenue lui sera remise avec celles de la famille 406 Coupé.']);
}

if ($action === 'ideas_unread') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $statement = $pdo->prepare(
        "SELECT COUNT(*)
         FROM c406_ideas i
         WHERE i.status = 'pending' AND i.member_id <> ?
           AND NOT EXISTS (SELECT 1 FROM c406_idea_reads r WHERE r.idea_id = i.id AND r.member_id = ?)");
    $statement->execute([$member['id'], $member['id']]);
    reply(['ok' => true, 'unread' => (int)$statement->fetchColumn()]);
}

if ($action === 'ideas_list') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    // Reprendre aussi les votes administrateur déjà enregistrés avant la mise
    // en place de cet accusé de lecture. L'auteur n'a pas à attendre que
    // l'administrateur ouvre une seconde fois son idée.
    $pdo->prepare("UPDATE c406_ideas i
                   INNER JOIN c406_idea_votes v ON v.idea_id = i.id
                   INNER JOIN c406_members a ON a.id = v.member_id AND a.role = 'admin'
                   SET i.seen_at = ?
                   WHERE i.seen_at IS NULL AND i.status = 'pending'")
        ->execute([time()]);
    // Ouvrir réellement la boîte à idées avec un compte administrateur vaut
    // lecture : l'auteur verra automatiquement « Vue par l'admin » lors de sa
    // prochaine ouverture, sans bouton supplémentaire ni mise à jour de l'app.
    if (($member['role'] ?? '') === 'admin') {
        $pdo->prepare("UPDATE c406_ideas SET seen_at = ? WHERE seen_at IS NULL AND status = 'pending'")->execute([time()]);
    }
    $statement = $pdo->prepare(
        "SELECT i.id, i.category, i.body, i.created_at, i.seen_at, i.member_id,
                i.admin_reflection, i.admin_reflection_at, m.name,
                (SELECT COUNT(*) FROM c406_idea_votes v WHERE v.idea_id = i.id AND v.value = 1) AS pour,
                (SELECT COUNT(*) FROM c406_idea_votes v WHERE v.idea_id = i.id AND v.value = -1) AS contre,
                (SELECT v.value FROM c406_idea_votes v WHERE v.idea_id = i.id AND v.member_id = ?) AS mien
         FROM c406_ideas i LEFT JOIN c406_members m ON m.id = i.member_id
         WHERE i.status = 'pending'
         ORDER BY (pour - contre) DESC, i.id DESC LIMIT 100");
    $statement->execute([$member['id']]);
    $idees = [];
    foreach ($statement->fetchAll() as $ligne) {
        $idees[] = [
            'id' => (int)$ligne['id'],
            'category' => (string)$ligne['category'],
            'body' => (string)$ligne['body'],
            'at' => (int)$ligne['created_at'],
            'seen' => $ligne['seen_at'] !== null,
            'author' => (string)($ligne['name'] ?? 'Membre retire'),
            'authorId' => (string)$ligne['member_id'],
            'mine' => $ligne['member_id'] === $member['id'],
            'up' => (int)$ligne['pour'],
            'down' => (int)$ligne['contre'],
            'myVote' => $ligne['mien'] === null ? 0 : (int)$ligne['mien'],
            'reflection' => (string)($ligne['admin_reflection'] ?? ''),
            'reflectionAt' => $ligne['admin_reflection_at'] === null ? null : (int)$ligne['admin_reflection_at'],
        ];
    }
    // L'ouverture de la liste acquitte toutes les propositions pour ce compte
    // seulement. Les autres membres conservent leur propre pastille.
    $pdo->prepare("INSERT IGNORE INTO c406_idea_reads (idea_id, member_id, seen_at)
                   SELECT id, ?, ? FROM c406_ideas WHERE status = 'pending' AND member_id <> ?")
        ->execute([$member['id'], time(), $member['id']]);
    reply(['ok' => true, 'ideas' => $idees]);
}

if ($action === 'idea_add') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $corps = trim((string)($payload['body'] ?? ''));
    if (mb_strlen($corps) < 5) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Decrivez votre idee en quelques mots.'], 400);
    }
    $verdict = moderationVerdict($corps);
    if ($verdict['action'] === 'block') {
        moderationSignale($pdo, $member['id'], 'idee bloquee : ' . $verdict['reason'], $corps);
        reply(['ok' => false, 'error' => 'BLOCKED', 'reason' => $verdict['reason'], 'message' => 'Idee refusee : les propos haineux ne sont pas acceptes dans le club. Si vous pensez que c est une erreur, demandez une verification.'], 422);
    }
    if ($verdict['action'] === 'flag') {
        moderationSignale($pdo, $member['id'], $verdict['reason'], $corps);
    }
    $pdo->prepare('INSERT INTO c406_ideas (member_id, category, body, created_at) VALUES (?, ?, ?, ?)')
        ->execute([$member['id'], mb_substr((string)($payload['category'] ?? ''), 0, 60), mb_substr($corps, 0, 2000), time()]);
    $ideeId = (int)$pdo->lastInsertId();
    $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
    pushToMembers($pdo, $admins, 'Nouvelle idee de ' . $member['name'], mb_substr($corps, 0, 120),
        ['type' => 'idea', 'ideaId' => $ideeId]);
    reply(['ok' => true, 'message' => 'Idee publiee dans la boite a idees.']);
}

if ($action === 'idea_vote') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $idee = (int)($payload['id'] ?? 0);
    $valeur = (int)($payload['value'] ?? 0);
    $precedent = $pdo->prepare('SELECT value FROM c406_idea_votes WHERE idea_id = ? AND member_id = ?');
    $precedent->execute([$idee, $member['id']]);
    $ancienVote = $precedent->fetchColumn();
    $proposition = $pdo->prepare('SELECT i.member_id, i.body, m.notify_idea_reactions
                                  FROM c406_ideas i INNER JOIN c406_members m ON m.id = i.member_id
                                  WHERE i.id = ? AND i.status = \'pending\' LIMIT 1');
    $proposition->execute([$idee]);
    $auteur = $proposition->fetch();
    // Un vote de l'administrateur prouve lui aussi que l'idée a été lue.
    // COALESCE conserve la date de première lecture si elle était déjà posée.
    if (($member['role'] ?? '') === 'admin') {
        $pdo->prepare('UPDATE c406_ideas SET seen_at = COALESCE(seen_at, ?) WHERE id = ?')
            ->execute([time(), $idee]);
    }
    if ($valeur === 0) {
        $pdo->prepare('DELETE FROM c406_idea_votes WHERE idea_id = ? AND member_id = ?')->execute([$idee, $member['id']]);
    } else {
        $pdo->prepare('INSERT INTO c406_idea_votes (idea_id, member_id, value, voted_at) VALUES (?, ?, ?, ?)
                       ON DUPLICATE KEY UPDATE value = VALUES(value), voted_at = VALUES(voted_at)')
            ->execute([$idee, $member['id'], $valeur > 0 ? 1 : -1, time()]);
    }
    $nouveauVote = $valeur === 0 ? 0 : ($valeur > 0 ? 1 : -1);
    if ($auteur && $auteur['member_id'] !== $member['id'] && (int)$auteur['notify_idea_reactions'] === 1
        && $nouveauVote !== 0 && (int)$ancienVote !== $nouveauVote) {
        $symbole = $nouveauVote > 0 ? '👍' : '👎';
        pushToMembers($pdo, [(string)$auteur['member_id']], 'Nouvelle reaction a votre idee',
            $symbole . ' Une personne a reagi a votre proposition : ' . mb_substr((string)$auteur['body'], 0, 90),
            ['type' => 'idea-reaction', 'ideaId' => $idee]);
    }
    reply(['ok' => true]);
}

if ($action === 'admin_idea_decide') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $identifiant = (int)($payload['id'] ?? 0);
    $acceptee = !empty($payload['approve']);
    $statement = $pdo->prepare("SELECT member_id, body FROM c406_ideas WHERE id = ? AND status = 'pending' LIMIT 1");
    $statement->execute([$identifiant]);
    $idee = $statement->fetch();
    if (!$idee) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Cette proposition a deja ete traitee.'], 404);
    }
    $statut = $acceptee ? 'accepted' : 'refused';
    $pdo->prepare('UPDATE c406_ideas SET status = ?, decided_at = ?, seen_at = COALESCE(seen_at, ?) WHERE id = ?')
        ->execute([$statut, time(), time(), $identifiant]);
    $titre = 'Merci pour votre proposition !';
    $extrait = '« ' . mb_substr((string)$idee['body'], 0, 120) . (mb_strlen((string)$idee['body']) > 120 ? '…' : '') . ' »';
    $texte = $acceptee
        ? "Votre idée {$extrait} a été retenue et va être intégrée au développement de l’application. Merci de nous aider à faire évoluer le Coupé 406 Club."
        : "Votre idée {$extrait} a bien été étudiée, mais elle ne sera pas intégrée pour le moment. Merci d’avoir pris le temps de proposer une amélioration pour le Coupé 406 Club.";
    $pdo->prepare('INSERT INTO c406_member_notices (member_id, kind, title, body, created_at) VALUES (?, ?, ?, ?, ?)')
        ->execute([$idee['member_id'], 'idea-decision', $titre, $texte, time()]);
    pushToMembers($pdo, [(string)$idee['member_id']], $titre, $texte,
        ['type' => 'idea-decision', 'ideaId' => $identifiant]);
    reply(['ok' => true, 'message' => $acceptee
        ? 'Proposition acceptee : le membre a ete remercie et informe.'
        : 'Proposition refusee : le membre a ete remercie et informe.']);
}

if ($action === 'admin_idea_seen') {
    requireAdmin($payload);
    $pdo = db();
    $pdo->prepare('UPDATE c406_ideas SET seen_at = ? WHERE id = ?')->execute([time(), (int)($payload['id'] ?? 0)]);
    reply(['ok' => true, 'message' => 'Idee marquee comme vue.']);
}

/** Courte réflexion de l'administrateur, affichée sous la proposition. */
if ($action === 'admin_idea_reflection') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $identifiant = (int)($payload['id'] ?? 0);
    $reflection = trim((string)($payload['reflection'] ?? ''));
    if (mb_strlen($reflection) > 500) {
        reply(['ok' => false, 'error' => 'TOO_LONG', 'message' => 'La reflexion est limitee a 500 caracteres.'], 400);
    }
    $statement = $pdo->prepare("UPDATE c406_ideas
        SET admin_reflection = ?, admin_reflection_at = ?, seen_at = COALESCE(seen_at, ?)
        WHERE id = ? AND status = 'pending'");
    $maintenant = time();
    $statement->execute([$reflection, $reflection === '' ? null : $maintenant, $maintenant, $identifiant]);
    if ($statement->rowCount() === 0) {
        $existe = $pdo->prepare("SELECT COUNT(*) FROM c406_ideas WHERE id = ? AND status = 'pending'");
        $existe->execute([$identifiant]);
        if ((int)$existe->fetchColumn() === 0) {
            reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Cette proposition n existe plus.'], 404);
        }
    }
    reply(['ok' => true, 'reflection' => $reflection,
        'reflectionAt' => $reflection === '' ? null : $maintenant,
        'message' => $reflection === '' ? 'Reflexion retiree.' : 'Reflexion enregistree.']);
}

if ($action === 'idea_delete') {
    // Un membre retire son idee ; un administrateur retire n importe laquelle.
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $identifiant = (int)($payload['id'] ?? 0);
    $statement = $pdo->prepare('SELECT member_id FROM c406_ideas WHERE id = ? LIMIT 1');
    $statement->execute([$identifiant]);
    $idee = $statement->fetch();
    if (!$idee) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Idee introuvable.'], 404);
    }
    if ($idee['member_id'] !== $member['id'] && $member['role'] !== 'admin') {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Seul l auteur ou l administrateur peut retirer cette idee.'], 403);
    }
    $pdo->prepare('DELETE FROM c406_idea_votes WHERE idea_id = ?')->execute([$identifiant]);
    $pdo->prepare('DELETE FROM c406_idea_reads WHERE idea_id = ?')->execute([$identifiant]);
    $pdo->prepare('DELETE FROM c406_ideas WHERE id = ?')->execute([$identifiant]);
    reply(['ok' => true, 'message' => 'Idee retiree.']);
}

if ($action === 'admin_idea_delete') {
    requireAdmin($payload);
    $pdo = db();
    $identifiant = (int)($payload['id'] ?? 0);
    $pdo->prepare('DELETE FROM c406_idea_votes WHERE idea_id = ?')->execute([$identifiant]);
    $pdo->prepare('DELETE FROM c406_idea_reads WHERE idea_id = ?')->execute([$identifiant]);
    $pdo->prepare('DELETE FROM c406_ideas WHERE id = ?')->execute([$identifiant]);
    reply(['ok' => true, 'message' => 'Idee retiree.']);
}

// ------------------------------------- contestation d un refus automatique
/**
 * Le filtre se trompe forcement un jour. Le texte refuse n est pas publie mais
 * mis de cote : un administrateur le lit et decide. S il valide, le message
 * part reellement, sans repasser par le filtre.
 */
if ($action === 'appeal_submit') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $genre = (string)($payload['kind'] ?? '') === 'message' ? 'message' : 'idea';
    $corps = trim((string)($payload['body'] ?? ''));
    if ($corps === '') {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Rien a verifier.'], 400);
    }
    $deja = $pdo->prepare("SELECT COUNT(*) FROM c406_appeals WHERE member_id = ? AND status = 'pending'");
    $deja->execute([$member['id']]);
    if ((int)$deja->fetchColumn() >= 5) {
        reply(['ok' => false, 'error' => 'TOO_MANY', 'message' => 'Vous avez deja cinq demandes en attente.'], 429);
    }
    $pdo->prepare('INSERT INTO c406_appeals (member_id, kind, target_id, category, body, reason, created_at, context) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
        ->execute([
            $member['id'], $genre,
            mb_substr((string)($payload['targetId'] ?? ''), 0, 32),
            mb_substr((string)($payload['category'] ?? ''), 0, 60),
            mb_substr($corps, 0, 4000),
            mb_substr((string)($payload['reason'] ?? 'propos haineux'), 0, 120),
            time(),
            mb_substr((string)($payload['context'] ?? ''), 0, 4000),
        ]);
    $verificationId = (int)$pdo->lastInsertId();
    $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
    pushToMembers($pdo, $admins, 'Demande de verification', $member['name'] . ' conteste un refus automatique.',
        ['type' => 'admin-appeal', 'appealId' => $verificationId]);
    reply(['ok' => true, 'message' => 'Demande transmise a l administrateur du club.']);
}

if ($action === 'admin_appeals') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $lignes = $pdo->query(
        "SELECT a.id, a.kind, a.category, a.body, a.reason, a.status, a.created_at, a.member_id, a.context, m.name
         FROM c406_appeals a LEFT JOIN c406_members m ON m.id = a.member_id
         ORDER BY (a.status = 'pending') DESC, a.id DESC LIMIT 60")->fetchAll();
    $demandes = [];
    foreach ($lignes as $ligne) {
        $demandes[] = [
            'id' => (int)$ligne['id'],
            'kind' => (string)$ligne['kind'],
            'category' => (string)$ligne['category'],
            'body' => (string)$ligne['body'],
            'reason' => (string)$ligne['reason'],
            'status' => (string)$ligne['status'],
            'at' => (int)$ligne['created_at'],
            'author' => (string)($ligne['name'] ?? 'Membre retire'),
            'memberId' => (string)$ligne['member_id'],
            'context' => (string)($ligne['context'] ?? ''),
        ];
    }
    reply(['ok' => true, 'appeals' => $demandes]);
}

if ($action === 'admin_appeal_decide') {
    requireAdmin($payload);
    $pdo = db();
    $identifiant = (int)($payload['id'] ?? 0);
    $valide = (bool)($payload['approve'] ?? false);
    $statement = $pdo->prepare("SELECT * FROM c406_appeals WHERE id = ? AND status = 'pending' LIMIT 1");
    $statement->execute([$identifiant]);
    $demande = $statement->fetch();
    if (!$demande) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Demande introuvable ou deja traitee.'], 404);
    }
    if ($valide) {
        // L administrateur a lu : le texte est publie tel quel.
        if ($demande['kind'] === 'message' && $demande['target_id'] !== '') {
            $pdo->prepare('INSERT INTO c406_messages (sender_id, target_id, body, created_at) VALUES (?, ?, ?, ?)')
                ->execute([$demande['member_id'], $demande['target_id'], $demande['body'], time()]);
        } else {
            $pdo->prepare('INSERT INTO c406_ideas (member_id, category, body, created_at) VALUES (?, ?, ?, ?)')
                ->execute([$demande['member_id'], $demande['category'], $demande['body'], time()]);
        }
    }
    $pdo->prepare('UPDATE c406_appeals SET status = ?, handled_at = ? WHERE id = ?')
        ->execute([$valide ? 'approved' : 'rejected', time(), $identifiant]);
    pushToMembers($pdo, [$demande['member_id']],
        $valide ? 'Votre texte a ete valide' : 'Votre demande a ete refusee',
        $valide ? 'L administrateur a verifie : votre texte est publie.' : 'L administrateur a maintenu le refus.',
        $demande['kind'] === 'message'
            ? ['type' => 'message', 'senderId' => (string)$demande['target_id'], 'name' => 'Membre du club']
            : ['type' => 'idea', 'ideaId' => $identifiant]);
    reply(['ok' => true, 'message' => $valide ? 'Texte publie.' : 'Refus maintenu.']);
}

if ($action === 'admin_guide_requests') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $lignes = $pdo->query(
        "SELECT r.id, r.title, r.category, r.motors, r.note, r.stored_name, r.original, r.size_kb, r.status, r.created_at, r.member_id, m.name
         FROM c406_guide_requests r LEFT JOIN c406_members m ON m.id = r.member_id
         ORDER BY (r.status = 'pending') DESC, r.id DESC LIMIT 60")->fetchAll();
    $base = rtrim((string)(getenv('C406_PUBLIC_BASE') ?: 'https://gears.hostline.fr/coupe406'), '/');
    $base = str_replace('/coupe406', '', $base);
    $demandes = [];
    foreach ($lignes as $ligne) {
        $demandes[] = [
            'id' => (int)$ligne['id'],
            'title' => (string)$ligne['title'],
            'category' => (string)$ligne['category'],
            'motors' => (string)$ligne['motors'],
            'note' => (string)($ligne['note'] ?? ''),
            'original' => (string)$ligne['original'],
            'sizeKb' => (int)$ligne['size_kb'],
            'status' => (string)$ligne['status'],
            'at' => (int)$ligne['created_at'],
            'author' => (string)($ligne['name'] ?? 'Membre retire'),
            'memberId' => (string)$ligne['member_id'],
            'url' => $base . '/guides-fr/en-attente/' . $ligne['stored_name'],
        ];
    }
    reply(['ok' => true, 'requests' => $demandes]);
}

if ($action === 'admin_guide_decide') {
    requireAdmin($payload);
    $pdo = db();
    $identifiant = (int)($payload['id'] ?? 0);
    $valide = (bool)($payload['approve'] ?? false);
    $statement = $pdo->prepare("SELECT * FROM c406_guide_requests WHERE id = ? AND status = 'pending' LIMIT 1");
    $statement->execute([$identifiant]);
    $demande = $statement->fetch();
    if (!$demande) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Proposition introuvable ou deja traitee.'], 404);
    }
    $attente = dirname(__DIR__) . '/guides-fr/en-attente/' . $demande['stored_name'];
    if ($valide) {
        // « Categorie - Titre [moteurs].pdf » : c'est ce nom qui permet a
        // l'application de proposer le document a la bonne voiture.
        $categorie = trim((string)($payload['category'] ?? $demande['category'])) ?: 'Guide du club';
        $titre = trim((string)($payload['title'] ?? $demande['title']));
        $moteurs = trim((string)($payload['motors'] ?? $demande['motors']));
        $propre = $categorie . ' - ' . $titre . ($moteurs !== '' ? ' [' . $moteurs . ']' : '');
        $propre = str_replace(['/', chr(92), chr(0)], '', $propre);
        $propre = trim((string)preg_replace('/\s+/', ' ', $propre));
        $destination = dirname(__DIR__) . '/guides-fr/' . mb_substr($propre, 0, 120) . '.pdf';
        if (is_file($destination)) {
            $destination = dirname(__DIR__) . '/guides-fr/' . mb_substr($propre, 0, 108) . ' ' . $identifiant . '.pdf';
        }
        if (!@rename($attente, $destination)) {
            reply(['ok' => false, 'error' => 'WRITE', 'message' => 'Publication impossible.'], 500);
        }
        @chmod($destination, 0644);
    } else {
        @unlink($attente);
    }
    $pdo->prepare('UPDATE c406_guide_requests SET status = ?, handled_at = ? WHERE id = ?')
        ->execute([$valide ? 'approved' : 'rejected', time(), $identifiant]);
    pushToMembers($pdo, [$demande['member_id']],
        $valide ? 'Votre document est publie' : 'Votre document a ete refuse',
        $valide ? $demande['title'] . ' rejoint la bibliotheque du club.' : 'L administrateur n a pas retenu : ' . $demande['title'],
        ['type' => 'guide-decision']);
    reply(['ok' => true, 'message' => $valide ? 'Document publie dans la bibliotheque.' : 'Proposition refusee.']);
}

// -------------------------------------------- entraide et sorties du club
/** Auteur d une annonce, pour l affichage et pour le droit de suppression. */
function annonceAuteur(PDO $pdo, string $id): array
{
    $statement = $pdo->prepare('SELECT id, name, city, region FROM c406_members WHERE id = ? LIMIT 1');
    $statement->execute([$id]);
    $ligne = $statement->fetch();
    return ['id' => $id, 'name' => (string)($ligne['name'] ?? 'Membre retire')];
}

/** Photo validee d une annonce, s il y en a une. */
function photoFor(PDO $pdo, string $kind, int $id): string
{
    $statement = $pdo->prepare("SELECT file_name FROM c406_listing_photos WHERE kind = ? AND listing_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1");
    $statement->execute([$kind, $id]);
    $ligne = $statement->fetch();
    return $ligne ? 'https://' . ($_SERVER['HTTP_HOST'] ?? 'gears.hostline.fr') . '/coupe406/photos/' . $ligne['file_name'] : '';
}

if ($action === 'community_list') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);

    $services = [];
    $lecture = $pdo->prepare("SELECT s.*, m.name FROM c406_services s LEFT JOIN c406_members m ON m.id = s.member_id
        WHERE s.status = 'open' OR s.member_id = ? ORDER BY s.id DESC LIMIT 200");
    $lecture->execute([$member['id']]);
    foreach ($lecture->fetchAll() as $l) {
        $services[] = ['id' => (int)$l['id'], 'title' => (string)$l['title'], 'category' => (string)$l['category'],
            'description' => (string)$l['description'], 'city' => (string)$l['city'], 'region' => (string)$l['region'],
            'lat' => $l['lat'] === null ? null : (float)$l['lat'], 'lon' => $l['lon'] === null ? null : (float)$l['lon'],
            // Le compte peut partir ou etre suspendu : la voiture reste comptee,
            // son ancien proprietaire n apparait plus.
            'author' => ($l['banned_at'] ?? null) !== null || (string)$l['member_id'] === ''
                ? 'Ancien membre' : (string)($l['name'] ?? 'Ancien membre'),
            'memberId' => ($l['banned_at'] ?? null) !== null ? '' : (string)$l['member_id'],
            'mine' => $l['member_id'] === $member['id'], 'at' => (int)$l['created_at'],
            'status' => (string)($l['status'] ?? 'open')];
    }
    $parts = [];
    $lecture = $pdo->prepare("SELECT p.*, m.name, m.role AS author_role,
            (SELECT COUNT(*) FROM c406_favorites f WHERE f.kind = 'part' AND f.listing_id = p.id AND f.member_id = ?) AS suivie
        FROM c406_parts p LEFT JOIN c406_members m ON m.id = p.member_id
        WHERE p.status = 'open' OR p.member_id = ? ORDER BY p.id DESC LIMIT 200");
    $lecture->execute([$member['id'], $member['id']]);
    foreach ($lecture->fetchAll() as $l) {
        // Question posee au vendeur et restee sans reponse : lui seul la voit.
        $relance = $l['member_id'] === $member['id']
            && (int)$l['asked_at'] > (int)$l['confirmed_at'] && (string)($l['status'] ?? 'open') === 'open';
        $parts[] = ['relance' => $relance, 'wanted' => ((int)($l['wanted'] ?? 0)) === 1,
            'favorite' => ((int)($l['suivie'] ?? 0)) > 0, 'authorRole' => (string)($l['author_role'] ?? 'member'),
            'phase' => (string)($l['phase'] ?? ''),
            'plate' => (string)($l['plate'] ?? ''),
            'vin' => (string)($l['vin'] ?? ''),
            'dam' => (string)($l['dam'] ?? ''), 'carYear' => (string)($l['car_year'] ?? ''), 'color' => (string)($l['color'] ?? ''),
            'id' => (int)$l['id'], 'title' => (string)$l['title'], 'price' => (string)$l['price'],
            'condition' => (string)$l['condition_label'], 'description' => (string)$l['description'],
            'city' => (string)$l['city'], 'region' => (string)$l['region'],
            'lat' => $l['lat'] === null ? null : (float)$l['lat'], 'lon' => $l['lon'] === null ? null : (float)$l['lon'],
            'author' => (string)($l['name'] ?? 'Membre retire'), 'memberId' => (string)$l['member_id'],
            'mine' => $l['member_id'] === $member['id'], 'at' => (int)$l['created_at'],
            'photo' => photoFor($pdo, 'part', (int)$l['id']), 'status' => (string)($l['status'] ?? 'open')];
    }
    $events = [];
    $requete = $pdo->prepare('SELECT e.*, m.name, m.role AS author_role,
            (SELECT COUNT(*) FROM c406_event_guests g WHERE g.event_id = e.id AND g.status = 0) AS invites,
            (SELECT g.status + 1 FROM c406_event_guests g WHERE g.event_id = e.id AND g.member_id = ?) AS present
         FROM c406_events e LEFT JOIN c406_members m ON m.id = e.member_id
         ORDER BY e.event_date ASC LIMIT 200');
    $requete->execute([$member['id']]);
    foreach ($requete->fetchAll() as $l) {
        $events[] = ['id' => (int)$l['id'], 'title' => (string)$l['title'], 'date' => (string)$l['event_date'],
            'city' => (string)$l['city'], 'region' => (string)$l['region'], 'description' => (string)$l['description'],
            'lat' => $l['lat'] === null ? null : (float)$l['lat'], 'lon' => $l['lon'] === null ? null : (float)$l['lon'],
            'author' => (string)($l['name'] ?? 'Membre retire'), 'memberId' => (string)$l['member_id'],
            'authorRole' => (string)($l['author_role'] ?? 'member'),
            'mine' => $l['member_id'] === $member['id'], 'attendees' => (int)$l['invites'],
            'joined' => ((int)$l['present']) === 1, 'ecarte' => ((int)$l['present']) === 2,
            'minGuests' => (int)($l['min_guests'] ?? 0),
            'photo' => photoFor($pdo, 'event', (int)$l['id'])];
    }
    reply(['ok' => true, 'services' => $services, 'parts' => $parts, 'events' => $events]);
}

if ($action === 'community_add') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $genre = (string)($payload['kind'] ?? '');
    $titre = trim((string)($payload['title'] ?? ''));
    $texte = trim((string)($payload['description'] ?? ''));
    if ($titre === '' || $texte === '') {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Titre et description sont necessaires.'], 400);
    }
    $verdict = moderationVerdict($titre . ' ' . $texte);
    if ($verdict['action'] === 'block') {
        moderationSignale($pdo, $member['id'], 'annonce bloquee : ' . $verdict['reason'], $titre . ' — ' . $texte);
        reply(['ok' => false, 'error' => 'BLOCKED', 'reason' => $verdict['reason'], 'message' => 'Annonce refusee : les propos haineux ne sont pas acceptes dans le club. Si vous pensez que c est une erreur, demandez une verification.'], 422);
    }
    if ($verdict['action'] === 'flag') {
        moderationSignale($pdo, $member['id'], $verdict['reason'], $titre . ' — ' . $texte);
    }
    $ville = mb_substr((string)($payload['city'] ?? $member['city']), 0, 80);
    $region = mb_substr((string)($payload['region'] ?? $member['region']), 0, 80);
    // La position vient de la ville annoncee, pas de celle du telephone : une
    // piece a vendre est la ou elle est, meme si son proprietaire publie
    // d ailleurs.
    $lieu = positionVille($pdo, $ville, $region);
    $lat = $lieu['lat'] ?? null;
    $lon = $lieu['lon'] ?? null;
    if ($genre === 'service') {
        $pdo->prepare('INSERT INTO c406_services (member_id, title, category, description, city, region, created_at, lat, lon) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([$member['id'], mb_substr($titre, 0, 160), mb_substr((string)($payload['category'] ?? ''), 0, 60), mb_substr($texte, 0, 3000), $ville, $region, time(), $lat, $lon]);
    } elseif ($genre === 'part') {
        $recherche = (int)(bool)($payload['wanted'] ?? false);
        $phase = in_array((string)($payload['phase'] ?? ''), ['PH1', 'PH2'], true) ? (string)$payload['phase'] : '';
        $annee = preg_match('/^(19|20)\d{2}$/', (string)($payload['carYear'] ?? '')) ? (string)$payload['carYear'] : '';
        $couleur = mb_substr((string)($payload['color'] ?? ''), 0, 40);
        $pdo->prepare('INSERT INTO c406_parts (member_id, title, price, condition_label, description, city, region, created_at, wanted, phase, car_year, color, lat, lon) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([$member['id'], mb_substr($titre, 0, 160), mb_substr((string)($payload['price'] ?? ''), 0, 40), mb_substr((string)($payload['condition'] ?? ''), 0, 60), mb_substr($texte, 0, 3000), $ville, $region, time(), $recherche, $phase, $annee, $couleur, $lat, $lon]);
    } elseif ($genre === 'event') {
        // L organisateur peut donner le point de rassemblement exact : un
        // parking, une aire d autoroute. Il prime alors sur le centre de la
        // commune, qui n est qu un repere par defaut.
        $latSaisie = isset($payload['lat']) && $payload['lat'] !== '' ? (float)$payload['lat'] : null;
        $lonSaisie = isset($payload['lon']) && $payload['lon'] !== '' ? (float)$payload['lon'] : null;
        if ($latSaisie !== null && $lonSaisie !== null
            && abs($latSaisie) <= 90 && abs($lonSaisie) <= 180
            && !($latSaisie === 0.0 && $lonSaisie === 0.0)) {
            $lat = round($latSaisie, 5);
            $lon = round($lonSaisie, 5);
        }
        $minimum = max(0, min(999, (int)($payload['minGuests'] ?? 0)));
        $pdo->prepare('INSERT INTO c406_events (member_id, title, event_date, city, region, description, created_at, lat, lon, min_guests) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([$member['id'], mb_substr($titre, 0, 160), mb_substr((string)($payload['date'] ?? ''), 0, 20), $ville, $region, mb_substr($texte, 0, 3000), time(), $lat, $lon, $minimum]);
        $identifiantSortie = (int)$pdo->lastInsertId();
        // Une sortie interesse tout le club : on previent.
        $membres = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL')->fetchAll(PDO::FETCH_COLUMN);
        pushToMembers($pdo, array_values(array_diff($membres, [$member['id']])), 'Nouvelle sortie : ' . $titre,
            $ville . ' — ' . (string)($payload['date'] ?? ''), ['type' => 'event', 'eventId' => $identifiantSortie]);
    } else {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Type d annonce inconnu.'], 400);
    }
    // Le club se parle tout seul : une piece mise en vente previent ceux qui la
    // cherchent, et une recherche previent ceux qui l ont deja en vente. Sans
    // ca, les deux annonces coexistent sans que personne ne les rapproche.
    if ($genre === 'part') {
        rapprocherAnnoncePiece($pdo, (int)$pdo->lastInsertId());
    }
    // L identifiant permet a l application d attacher une photo a l annonce.
    reply(['ok' => true, 'id' => (int)$pdo->lastInsertId(), 'message' => 'Publie dans le club.']);
}

/**
 * « Je suis en panne. » Previent les membres du rayon choisi, une fois par
 * heure au maximum. La notification ouvre directement la conversation avec
 * celui qui est en rade : sur le bord d une route, on n a pas le temps de
 * chercher comment repondre.
 */
if ($action === 'panne_alerte') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $lat = isset($payload['lat']) ? (float)$payload['lat'] : null;
    $lon = isset($payload['lon']) ? (float)$payload['lon'] : null;
    if ($lat === null || $lon === null || abs($lat) > 90 || abs($lon) > 180 || ($lat === 0.0 && $lon === 0.0)) {
        reply(['ok' => false, 'error' => 'NO_POSITION',
            'message' => 'Position introuvable. Activez la localisation pour que les membres sachent ou vous etes.'], 400);
    }
    $rayon = max(10, min(200, (int)($payload['radiusKm'] ?? 50)));
    $mot = mb_substr(trim((string)($payload['note'] ?? '')), 0, 160);

    // Un appui repete ne doit pas sonner chez tout le monde dix fois.
    $verrou = $pdo->prepare('SELECT last_at FROM c406_pannes WHERE member_id = ? LIMIT 1');
    $verrou->execute([$member['id']]);
    $dernier = (int)($verrou->fetchColumn() ?: 0);
    if ($dernier > time() - 3600) {
        reply(['ok' => false, 'error' => 'TROP_TOT', 'attendreMinutes' => (int)ceil((3600 - (time() - $dernier)) / 60),
            'message' => 'Une alerte a deja ete envoyee il y a moins d une heure. Repondez dans la conversation en attendant.'], 429);
    }

    // La panne existe meme si personne n est dans le rayon : la voiture est
    // en rade quoi qu il arrive, elle doit apparaitre au garage et sur la carte.
    $lieu = placeNameFromPosition($lat, $lon);
    // La panne est attachee a un vehicule : elle vivra sur la carte puis au
    // garage jusqu a ce que le proprietaire la dise depannee ou reparee. Une
    // voiture deja en panne met sa fiche a jour plutot que d en ouvrir une
    // seconde.
    $vehicule = mb_substr((string)($payload['carKey'] ?? ''), 0, 64);
    $precision = isset($payload['precision']) ? max(0, min(50000, (int)$payload['precision'])) : null;
    $libelle = mb_substr((string)($payload['carLabel'] ?? ''), 0, 80);
    if ($vehicule !== '') {
        $ouverte = $pdo->prepare("SELECT id FROM c406_pannes_vehicules
            WHERE member_id = ? AND car_key = ? AND etat <> 'repare' ORDER BY id DESC LIMIT 1");
        $ouverte->execute([$member['id'], $vehicule]);
        $existante = (int)($ouverte->fetchColumn() ?: 0);
        if ($existante > 0) {
            $pdo->prepare("UPDATE c406_pannes_vehicules SET etat = 'en_panne', lat = ?, lon = ?, city = ?, note = ?,
                           car_label = ?, precision_m = ?, updated_at = ? WHERE id = ?")
                ->execute([round($lat, 5), round($lon, 5), $lieu, $mot, $libelle, $precision, time(), $existante]);
        } else {
            $pdo->prepare("INSERT INTO c406_pannes_vehicules (member_id, car_key, car_label, lat, lon, city, note, etat, precision_m, created_at, updated_at)
                           VALUES (?, ?, ?, ?, ?, ?, ?, 'en_panne', ?, ?, ?)")
                ->execute([$member['id'], $vehicule, $libelle, round($lat, 5), round($lon, 5), $lieu, $mot, $precision, time(), time()]);
        }
    }

    $voisins = [];
    $codes = [];
    $lecture = $pdo->prepare("SELECT id, name, lat, lon, app_code FROM c406_members
        WHERE banned_at IS NULL AND approved_at IS NOT NULL AND lat IS NOT NULL
          AND id <> ? AND nearby_alerts_enabled = 1");
    $lecture->execute([$member['id']]);
    foreach ($lecture->fetchAll() as $ligne) {
        $distance = distanceEntreKm($lat, $lon, (float)$ligne['lat'], (float)$ligne['lon']);
        if ($distance <= $rayon) {
            $voisins[(string)$ligne['id']] = (int)round($distance);
            $codes[(string)$ligne['id']] = (int)($ligne['app_code'] ?? 0);
        }
    }
    if ($voisins === []) {
        reply(['ok' => true, 'prevenus' => 0,
            'message' => 'Aucun membre joignable dans ' . $rayon . ' km. Elargissez le rayon, ou ecrivez dans le club.']);
    }


    $envoyes = 0;
    foreach ($voisins as $identifiant => $distance) {
        $envoyes += pushToMembers($pdo, [$identifiant],
            // Le titre et la premiere phrase se reglent depuis l application ;
            // la commune et le mot du membre s ajoutent toujours a la suite.
            strtr(reglage($pdo, 'notif.panne.titre', '{nom} est en panne'), ['{nom}' => (string)$member['name']]),
            strtr(reglage($pdo, 'notif.panne.texte', 'A {distance} km de vous'), ['{distance}' => (string)$distance])
                . ($lieu !== '' ? ' — ' . $lieu : '') . ($mot !== '' ? ' · ' . $mot : ''),
            // A partir de la 1.0.16 (build 212), la notification ouvre la carte,
            // la ou est la voiture : on y decide d aider, puis on ecrit en
            // prive. Une version plus ancienne ne connait pas ce type et
            // retomberait sur l accueil : elle garde l ouverture de la
            // conversation, qui reste utile.
            ($codes[$identifiant] ?? 0) >= PANNE_SUR_CARTE_DEPUIS
                ? ['type' => 'panne', 'memberId' => (string)$member['id'], 'name' => (string)$member['name']]
                : ['type' => 'message', 'senderId' => (string)$member['id'], 'name' => (string)$member['name']]);
    }
    $pdo->prepare('INSERT INTO c406_pannes (member_id, last_at, count_total) VALUES (?, ?, 1)
                   ON DUPLICATE KEY UPDATE last_at = VALUES(last_at), count_total = count_total + 1')
        ->execute([$member['id'], time()]);
    $pdo->prepare('INSERT INTO c406_pannes_log (member_id, sent_at) VALUES (?, ?)')->execute([$member['id'], time()]);

    reply(['ok' => true, 'prevenus' => count($voisins), 'envoyes' => $envoyes, 'lieu' => $lieu,
        'message' => count($voisins) . ' membre' . (count($voisins) > 1 ? 's' : '') . ' dans ' . $rayon . ' km ' . (count($voisins) > 1 ? 'ont' : 'a') . ' ete prevenu' . (count($voisins) > 1 ? 's' : '') . '.']);
}

/**
 * Le proprietaire fait avancer sa panne : « depanne » (remorque, plus sur la
 * route) la retire de la carte ; « repare » la retire de partout.
 */
if ($action === 'panne_etat') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $etat = (string)($payload['etat'] ?? '');
    if (!in_array($etat, ['depanne', 'repare'], true)) {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Etat inconnu.'], 400);
    }
    $requete = $pdo->prepare("UPDATE c406_pannes_vehicules SET etat = ?, updated_at = ?
        WHERE id = ? AND member_id = ? AND etat <> 'repare'");
    $requete->execute([$etat, time(), (int)($payload['id'] ?? 0), $member['id']]);
    if ($requete->rowCount() === 0) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Panne introuvable ou deja close.'], 404);
    }
    reply(['ok' => true, 'etat' => $etat]);
}

/**
 * Les pannes a afficher : sur la carte, les voitures encore au bord de la
 * route depuis moins de 48 h ; pour le membre, ses propres pannes ouvertes,
 * quel que soit leur age, pour les voyants de son garage.
 */
if ($action === 'pannes') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $carte = [];
    $lecture = $pdo->prepare("SELECT p.id, p.member_id, p.car_label, p.lat, p.lon, p.city, p.note, p.created_at, p.precision_m, m.name
        FROM c406_pannes_vehicules p LEFT JOIN c406_members m ON m.id = p.member_id
        WHERE p.etat = 'en_panne' AND p.lat IS NOT NULL AND p.updated_at >= ? AND m.banned_at IS NULL
        ORDER BY p.updated_at DESC LIMIT 100");
    // La duree d affichage vient du reglage « panne.heures » : l administrateur
    // la change depuis son telephone, sans nouvelle version.
    $heures = (int)reglage($pdo, 'panne.heures', '48');
    $lecture->execute([time() - max(1, min(720, $heures)) * 3600]);
    foreach ($lecture->fetchAll() as $l) {
        $carte[] = [
            'id' => (int)$l['id'], 'memberId' => (string)$l['member_id'], 'name' => (string)($l['name'] ?? 'Membre'),
            'car' => (string)$l['car_label'], 'city' => (string)$l['city'], 'note' => (string)$l['note'],
            // Au metre pres : le membre a lui-meme demande qu on vienne l aider
            // la ou il est. Les positions des membres, elles, restent arrondies.
            'lat' => round((float)$l['lat'], 4), 'lon' => round((float)$l['lon'], 4),
            'precision' => $l['precision_m'] === null ? null : (int)$l['precision_m'],
            'at' => (int)$l['created_at'], 'mine' => (string)$l['member_id'] === (string)$member['id'],
        ];
    }
    $miennes = [];
    $lecture = $pdo->prepare("SELECT id, car_key, etat, created_at FROM c406_pannes_vehicules
        WHERE member_id = ? AND etat <> 'repare' ORDER BY id DESC");
    $lecture->execute([$member['id']]);
    foreach ($lecture->fetchAll() as $l) {
        $miennes[] = ['id' => (int)$l['id'], 'carKey' => (string)$l['car_key'], 'etat' => (string)$l['etat'],
            'at' => (int)$l['created_at']];
    }
    reply(['ok' => true, 'carte' => $carte, 'miennes' => $miennes]);
}

/**
 * Ce que deviennent les annonces : vendues par le club, vendues ailleurs, ou
 * restees sans suite. C est la seule mesure honnete de ce que l entraide
 * apporte vraiment aux membres.
 */
if ($action === 'admin_sales_stats') {
    requireAdmin($payload);
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => false, 'error' => 'DB', 'message' => 'Base indisponible.'], 503);
    }
    ensureTables($pdo);
    $par = ['app' => 0, 'ailleurs' => 0, 'sans-suite' => 0];
    foreach ($pdo->query("SELECT sold_via, COUNT(*) n FROM c406_sales_log GROUP BY sold_via") as $l) {
        $cle = (string)$l['sold_via'];
        if (isset($par[$cle])) {
            $par[$cle] = (int)$l['n'];
        }
    }
    $declarees = array_sum($par);
    $vendues = $par['app'] + $par['ailleurs'];
    // Combien de jours entre la publication et le retrait, quand on le sait.
    $delai = $pdo->query("SELECT AVG(closed_at - published_at) FROM c406_sales_log
        WHERE published_at > 0 AND closed_at > published_at AND sold_via IN ('app','ailleurs')")->fetchColumn();
    reply([
        'ok' => true,
        'parCanal' => $par,
        'declarees' => $declarees,
        'vendues' => $vendues,
        'partClub' => $vendues > 0 ? round($par['app'] / $vendues * 100) : null,
        'delaiMoyenJours' => $delai === null || $delai === false ? null : (int)round(((float)$delai) / 86400),
        'annoncesOuvertes' => (int)$pdo->query("SELECT COUNT(*) FROM c406_parts WHERE status = 'open'")->fetchColumn(),
        'rapprochements' => (int)$pdo->query("SELECT COUNT(*) FROM c406_part_matches")->fetchColumn(),
    ]);
}

if ($action === 'community_delete') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $tables = ['service' => 'c406_services', 'part' => 'c406_parts', 'event' => 'c406_events'];
    $table = $tables[(string)($payload['kind'] ?? '')] ?? '';
    $identifiant = (int)($payload['id'] ?? 0);
    if ($table === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Type d annonce inconnu.'], 400);
    }
    $statement = $pdo->prepare("SELECT * FROM {$table} WHERE id = ? LIMIT 1");
    $statement->execute([$identifiant]);
    $ligne = $statement->fetch();
    if (!$ligne) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Annonce introuvable.'], 404);
    }
    if ($ligne['member_id'] !== $member['id'] && $member['role'] !== 'admin') {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Seul l auteur ou l administrateur peut retirer cette annonce.'], 403);
    }
    // Le vendeur dit, en retirant son annonce, si la piece est partie par le
    // club ou ailleurs. L annonce disparait, la reponse reste : c est elle qui
    // dit a l administration si l entraide sert vraiment a quelque chose.
    ensureTables($pdo);
    $canalVente = in_array((string)($payload['soldVia'] ?? ''), ['app', 'ailleurs', 'sans-suite'], true)
        ? (string)$payload['soldVia'] : '';
    if ($canalVente !== '' && in_array($table, ['c406_parts', 'c406_services'], true)) {
        $pdo->prepare('INSERT INTO c406_sales_log (kind, listing_id, member_id, wanted, price, sold_via, published_at, closed_at)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([
                $table === 'c406_parts' ? 'part' : 'service', $identifiant, (string)$ligne['member_id'],
                (int)($ligne['wanted'] ?? 0), mb_substr((string)($ligne['price'] ?? ''), 0, 40),
                $canalVente, (int)($ligne['created_at'] ?? 0), time(),
            ]);
    }
    $pdo->prepare("DELETE FROM {$table} WHERE id = ?")->execute([$identifiant]);
    $genreFavori = ['c406_parts' => 'part', 'c406_services' => 'service', 'c406_events' => 'event'][$table] ?? '';
    if ($genreFavori !== '') {
        $pdo->prepare('DELETE FROM c406_favorites WHERE kind = ? AND listing_id = ?')->execute([$genreFavori, $identifiant]);
    }
    if ($table === 'c406_events') {
        $pdo->prepare('DELETE FROM c406_event_guests WHERE event_id = ?')->execute([$identifiant]);
    }
    reply(['ok' => true, 'message' => 'Annonce retiree.']);
}

if ($action === 'event_join') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $identifiant = (int)($payload['id'] ?? 0);
    $sortie = $pdo->prepare('SELECT member_id, title, event_date FROM c406_events WHERE id = ?');
    $sortie->execute([$identifiant]);
    $sortie = $sortie->fetch();
    if ($sortie === false) {
        reply(['ok' => false, 'error' => 'GONE', 'message' => 'Cette sortie n existe plus.'], 404);
    }
    if ((bool)($payload['join'] ?? true)) {
        // L organisateur a le dernier mot : un membre ecarte ne revient pas.
        $etat = $pdo->prepare('SELECT status FROM c406_event_guests WHERE event_id = ? AND member_id = ?');
        $etat->execute([$identifiant, $member['id']]);
        if ((int)($etat->fetchColumn() ?: 0) === 1) {
            reply(['ok' => false, 'error' => 'REFUSED',
                'message' => 'L organisateur vous a retire de cette sortie. Ecrivez-lui en message prive.'], 403);
        }
        $pdo->prepare('INSERT INTO c406_event_guests (event_id, member_id, status, joined_at) VALUES (?, ?, 0, ?)
                       ON DUPLICATE KEY UPDATE joined_at = VALUES(joined_at)')
            ->execute([$identifiant, $member['id'], time()]);
        // L organisateur suit sa liste sans avoir a ouvrir l application.
        if ((string)$sortie['member_id'] !== $member['id']) {
            $inscrits = $pdo->prepare('SELECT COUNT(*) FROM c406_event_guests WHERE event_id = ? AND status = 0');
            $inscrits->execute([$identifiant]);
            $total = (int)$inscrits->fetchColumn();
            pushToMembers($pdo, [(string)$sortie['member_id']], 'Inscription a votre sortie',
                $member['name'] . ' participe a ' . $sortie['title'] . ' — ' . $total . ' inscrit' . ($total > 1 ? 's' : '') . '.',
                ['type' => 'event-guests', 'eventId' => $identifiant]);
        }
    } else {
        $pdo->prepare('DELETE FROM c406_event_guests WHERE event_id = ? AND member_id = ? AND status = 0')
            ->execute([$identifiant, $member['id']]);
    }
    reply(['ok' => true]);
}

/** La liste des inscrits : elle appartient a l organisateur de la sortie. */
if ($action === 'event_guests' || $action === 'event_kick') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $identifiant = (int)($payload['id'] ?? 0);
    $sortie = $pdo->prepare('SELECT member_id, title FROM c406_events WHERE id = ?');
    $sortie->execute([$identifiant]);
    $sortie = $sortie->fetch();
    if ($sortie === false) {
        reply(['ok' => false, 'error' => 'GONE', 'message' => 'Cette sortie n existe plus.'], 404);
    }
    if ((string)$sortie['member_id'] !== $member['id'] && ($member['role'] ?? '') !== 'admin') {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Seul l organisateur voit la liste des inscrits.'], 403);
    }

    if ($action === 'event_kick') {
        $ecarte = (string)($payload['memberId'] ?? '');
        if ($ecarte === (string)$sortie['member_id']) {
            reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Vous organisez cette sortie.'], 400);
        }
        $touche = $pdo->prepare('UPDATE c406_event_guests SET status = 1 WHERE event_id = ? AND member_id = ?');
        $touche->execute([$identifiant, $ecarte]);
        if ($touche->rowCount() > 0) {
            pushToMembers($pdo, [$ecarte], 'Sortie ' . $sortie['title'],
                'L organisateur vous a retire de la liste des participants.',
                ['type' => 'event', 'eventId' => $identifiant]);
        }
    }

    $inscrits = [];
    $lecture = $pdo->prepare("SELECT g.member_id, g.status, g.joined_at, m.name, m.city, m.car_label
        FROM c406_event_guests g LEFT JOIN c406_members m ON m.id = g.member_id
        WHERE g.event_id = ? ORDER BY g.status ASC, g.joined_at ASC");
    $lecture->execute([$identifiant]);
    foreach ($lecture->fetchAll() as $l) {
        $inscrits[] = ['memberId' => (string)$l['member_id'], 'name' => (string)($l['name'] ?? 'Membre retire'),
            'city' => (string)($l['city'] ?? ''), 'car' => (string)($l['car_label'] ?? ''),
            'at' => (int)$l['joined_at'], 'ecarte' => ((int)$l['status']) === 1];
    }
    reply(['ok' => true, 'guests' => $inscrits, 'title' => (string)$sortie['title']]);
}

// ------------------------------------------- combien de fois un guide est lu
if ($action === 'guide_opened') {
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => true]);
    }
    ensureTables($pdo);
    $pdo->prepare('INSERT INTO c406_guide_views (guide_id, title, opens, last_at) VALUES (?, ?, 1, ?)
                   ON DUPLICATE KEY UPDATE opens = opens + 1, last_at = VALUES(last_at), title = VALUES(title)')
        ->execute([mb_substr((string)($payload['id'] ?? ''), 0, 40), mb_substr((string)($payload['title'] ?? ''), 0, 190), time()]);
    $pdo->prepare('INSERT INTO c406_guide_opens (guide_id, opened_at) VALUES (?, ?)')
        ->execute([mb_substr((string)($payload['id'] ?? ''), 0, 40), time()]);
    reply(['ok' => true]);
}

/**
 * « J aime » sur un document : un par membre, on rebascule en retouchant.
 * Le compteur d ouvertures dit la curiosite ; celui-ci dit si le document a
 * servi. Rend l etat et le total pour l affichage immediat.
 */
if ($action === 'guide_like') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $document = mb_substr((string)($payload['id'] ?? ''), 0, 40);
    if ($document === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Document inconnu.'], 400);
    }
    $existe = $pdo->prepare('SELECT 1 FROM c406_guide_likes WHERE guide_id = ? AND member_id = ? LIMIT 1');
    $existe->execute([$document, $member['id']]);
    $aime = (bool)$existe->fetchColumn();
    if ($aime) {
        $pdo->prepare('DELETE FROM c406_guide_likes WHERE guide_id = ? AND member_id = ?')
            ->execute([$document, $member['id']]);
    } else {
        $pdo->prepare('INSERT IGNORE INTO c406_guide_likes (guide_id, member_id, liked_at) VALUES (?, ?, ?)')
            ->execute([$document, $member['id'], time()]);
    }
    $total = $pdo->prepare('SELECT COUNT(*) FROM c406_guide_likes WHERE guide_id = ?');
    $total->execute([$document]);
    reply(['ok' => true, 'liked' => !$aime, 'total' => (int)$total->fetchColumn()]);
}

/** Ce que le membre a deja aime, pour colorer les coeurs a l ouverture. */
if ($action === 'guide_likes') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $totaux = [];
    foreach ($pdo->query('SELECT guide_id, COUNT(*) n FROM c406_guide_likes GROUP BY guide_id') as $l) {
        $totaux[(string)$l['guide_id']] = (int)$l['n'];
    }
    $miens = $pdo->prepare('SELECT guide_id FROM c406_guide_likes WHERE member_id = ?');
    $miens->execute([$member['id']]);
    reply(['ok' => true, 'totaux' => $totaux, 'miens' => $miens->fetchAll(PDO::FETCH_COLUMN)]);
}

if ($action === 'admin_guide_views') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $vues = [];
    foreach ($pdo->query('SELECT guide_id, title, opens, last_at FROM c406_guide_views ORDER BY opens DESC LIMIT 60')->fetchAll() as $l) {
        $vues[] = ['id' => (string)$l['guide_id'], 'title' => (string)$l['title'], 'opens' => (int)$l['opens'], 'at' => (int)$l['last_at']];
    }
    reply(['ok' => true, 'views' => $vues]);
}

/** Accepte uniquement une vraie vidéo YouTube et renvoie une adresse propre. */
function normalizeYoutubeUrl(string $raw): ?string
{
    $url = trim($raw);
    if ($url === '') {
        return null;
    }
    if (!preg_match('~^https?://~i', $url)) {
        $url = 'https://' . $url;
    }
    $parts = parse_url($url);
    if (!is_array($parts) || !isset($parts['host'])) {
        return null;
    }
    $host = strtolower(trim((string)$parts['host'], '.'));
    $identifiant = '';
    if ($host === 'youtu.be') {
        $identifiant = trim((string)($parts['path'] ?? ''), '/');
        $identifiant = explode('/', $identifiant)[0] ?? '';
    } elseif (in_array($host, ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com'], true)) {
        parse_str((string)($parts['query'] ?? ''), $query);
        $identifiant = (string)($query['v'] ?? '');
        if ($identifiant === '' && preg_match('~^/(?:shorts|embed|live)/([A-Za-z0-9_-]+)~', (string)($parts['path'] ?? ''), $trouve)) {
            $identifiant = $trouve[1];
        }
    } else {
        return null;
    }
    if (!preg_match('/^[A-Za-z0-9_-]{6,20}$/', $identifiant)) {
        return null;
    }
    return 'https://www.youtube.com/watch?v=' . $identifiant;
}

// ------------------------------------------------ vidéos utiles sous les PDF
if ($action === 'printer_list') {
    $member = sessionMember((string)($payload['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous pour voir les imprimeurs.'], 403);
    }
    ensureTables($pdo);
    $statement = $pdo->query("SELECT v.member_id, v.city, v.region, v.printer_model, v.materials, v.note,
        m.name FROM c406_print_volunteers v JOIN c406_members m ON m.id = v.member_id
        WHERE v.active = 1 AND m.banned_at IS NULL AND m.approved_at IS NOT NULL
        ORDER BY v.region ASC, v.city ASC, m.name ASC LIMIT 200");
    $printers = [];
    foreach ($statement->fetchAll() as $row) {
        $printers[] = [
            'memberId' => (string)$row['member_id'], 'name' => (string)$row['name'],
            'city' => (string)$row['city'], 'region' => (string)$row['region'],
            'printer' => (string)$row['printer_model'], 'materials' => (string)$row['materials'],
            'note' => (string)$row['note'], 'mine' => $row['member_id'] === $member['id'],
        ];
    }
    reply(['ok' => true, 'printers' => $printers]);
}

if ($action === 'printer_offer') {
    $member = sessionMember((string)($payload['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous avant de vous inscrire.'], 403);
    }
    ensureTables($pdo);
    $region = trim((string)($payload['region'] ?? $member['region'] ?? ''));
    if ($region === '' || mb_strlen($region) > 80) {
        reply(['ok' => false, 'message' => 'Indiquez votre région pour apparaître dans le bon secteur.'], 400);
    }
    $city = mb_substr(trim((string)($payload['city'] ?? $member['city'] ?? '')), 0, 80);
    $printer = mb_substr(trim((string)($payload['printer'] ?? '')), 0, 100);
    $materials = mb_substr(trim((string)($payload['materials'] ?? '')), 0, 160);
    $note = mb_substr(trim((string)($payload['note'] ?? '')), 0, 1000);
    if (moderationVerdict($printer . ' ' . $materials . ' ' . $note)['action'] === 'block') {
        reply(['ok' => false, 'message' => 'Corrigez votre présentation avant publication.'], 400);
    }
    $pdo->prepare('INSERT INTO c406_print_volunteers
        (member_id, city, region, printer_model, materials, note, active, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, 1, ?) ON DUPLICATE KEY UPDATE
        city = VALUES(city), region = VALUES(region), printer_model = VALUES(printer_model),
        materials = VALUES(materials), note = VALUES(note), active = 1, updated_at = VALUES(updated_at)')
        ->execute([$member['id'], $city, $region, $printer, $materials, $note, time()]);
    reply(['ok' => true, 'message' => 'Vous apparaissez dans la liste des imprimeurs du club.']);
}

if ($action === 'printer_remove') {
    $member = sessionMember((string)($payload['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous.'], 403);
    }
    ensureTables($pdo);
    $pdo->prepare('UPDATE c406_print_volunteers SET active = 0, updated_at = ? WHERE member_id = ?')
        ->execute([time(), $member['id']]);
    reply(['ok' => true, 'message' => 'Vous n apparaissez plus dans la liste.']);
}

if ($action === 'stl_list') {
    $member = sessionMember((string)($payload['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous pour consulter les modèles.'], 403);
    }
    ensureTables($pdo);
    $statement = $pdo->prepare("SELECT s.*, m.name AS author,
        (SELECT COUNT(*) FROM c406_stl_reports r WHERE r.model_id = s.id AND r.status = 'pending') AS reports,
        (SELECT COUNT(*) FROM c406_stl_reports r2 WHERE r2.model_id = s.id AND r2.member_id = ?) AS reported
        FROM c406_stl_models s LEFT JOIN c406_members m ON m.id = s.member_id
        WHERE s.status = 'active' ORDER BY s.created_at DESC LIMIT 200");
    $statement->execute([$member['id']]);
    reply(['ok' => true, 'models' => array_map(
        fn(array $row): array => stlPublicItem($row, (string)$member['id']), $statement->fetchAll()
    )]);
}

if ($action === 'stl_report') {
    $member = sessionMember((string)($payload['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous pour signaler ce modèle.'], 403);
    }
    ensureTables($pdo);
    $id = (int)($payload['id'] ?? 0);
    $reason = trim((string)($payload['reason'] ?? ''));
    if ($id < 1 || $reason === '' || mb_strlen($reason) > 300) {
        reply(['ok' => false, 'message' => 'Précisez le problème rencontré.'], 400);
    }
    $exists = $pdo->prepare("SELECT member_id FROM c406_stl_models WHERE id = ? AND status = 'active' LIMIT 1");
    $exists->execute([$id]);
    $owner = (string)($exists->fetchColumn() ?: '');
    if ($owner === '' || $owner === $member['id']) {
        reply(['ok' => false, 'message' => 'Ce modèle n est plus disponible ou vous en êtes l auteur.'], 400);
    }
    $pdo->prepare("INSERT INTO c406_stl_reports (model_id, member_id, reason, status, created_at)
        VALUES (?, ?, ?, 'pending', ?) ON DUPLICATE KEY UPDATE reason = VALUES(reason),
        status = 'pending', created_at = VALUES(created_at)")
        ->execute([$id, $member['id'], $reason, time()]);
    $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
    pushToMembers($pdo, $admins, 'Modèle 3D à vérifier', 'Un membre a signalé un fichier STL.',
        ['type' => 'admin-stl-report', 'stlId' => $id]);
    reply(['ok' => true, 'message' => 'Signalement transmis à l administrateur.']);
}

if ($action === 'stl_remove') {
    $member = sessionMember((string)($payload['session'] ?? ''));
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'message' => 'Reconnectez-vous.'], 403);
    }
    ensureTables($pdo);
    $id = (int)($payload['id'] ?? 0);
    $statement = $pdo->prepare("SELECT member_id FROM c406_stl_models WHERE id = ? AND status = 'active' LIMIT 1");
    $statement->execute([$id]);
    $owner = (string)($statement->fetchColumn() ?: '');
    if ($owner === '' || ($owner !== $member['id'] && $member['role'] !== 'admin')) {
        reply(['ok' => false, 'message' => 'Ce modèle n existe pas ou ne vous appartient pas.'], 403);
    }
    $pdo->prepare("UPDATE c406_stl_models SET status = 'removed', removed_at = ? WHERE id = ?")
        ->execute([time(), $id]);
    $pdo->prepare("UPDATE c406_stl_reports SET status = 'handled', handled_at = ? WHERE model_id = ? AND status = 'pending'")
        ->execute([time(), $id]);
    reply(['ok' => true, 'message' => 'Modèle retiré du club.']);
}

if ($action === 'admin_stl_reports') {
    $admin = requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $statement = $pdo->query("SELECT s.*, m.name AS author,
        (SELECT COUNT(*) FROM c406_stl_reports r WHERE r.model_id = s.id AND r.status = 'pending') AS reports,
        (SELECT GROUP_CONCAT(r2.reason SEPARATOR ' | ') FROM c406_stl_reports r2
            WHERE r2.model_id = s.id AND r2.status = 'pending') AS reasons
        FROM c406_stl_models s LEFT JOIN c406_members m ON m.id = s.member_id
        WHERE s.status = 'active' AND EXISTS
            (SELECT 1 FROM c406_stl_reports r3 WHERE r3.model_id = s.id AND r3.status = 'pending')
        ORDER BY reports DESC, s.created_at DESC LIMIT 100");
    reply(['ok' => true, 'models' => array_map(
        fn(array $row): array => stlPublicItem($row, (string)$admin['id'])
            + ['reasons' => (string)($row['reasons'] ?? '')], $statement->fetchAll()
    )]);
}

if ($action === 'admin_stl_decide') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $id = (int)($payload['id'] ?? 0);
    if ($id < 1) {
        reply(['ok' => false, 'message' => 'Modèle introuvable.'], 404);
    }
    if (($payload['keep'] ?? false) !== true) {
        $pdo->prepare("UPDATE c406_stl_models SET status = 'removed', removed_at = ? WHERE id = ?")
            ->execute([time(), $id]);
    }
    $pdo->prepare("UPDATE c406_stl_reports SET status = 'handled', handled_at = ? WHERE model_id = ? AND status = 'pending'")
        ->execute([time(), $id]);
    reply(['ok' => true, 'message' => ($payload['keep'] ?? false) === true
        ? 'Signalements classés. Le modèle reste disponible.' : 'Modèle retiré.']);
}

if ($action === 'guide_videos') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $guideId = mb_substr(trim((string)($payload['guideId'] ?? '')), 0, 120);
    if ($guideId === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Document introuvable.'], 400);
    }
    $lecture = $pdo->prepare("SELECT v.id, v.guide_id, v.member_id, v.title, v.url, v.created_at,
        m.name, m.email,
        (SELECT COUNT(*) FROM c406_guide_video_reports r WHERE r.video_id = v.id AND r.status = 'pending') AS reports,
        (SELECT COUNT(*) FROM c406_guide_video_reports r2 WHERE r2.video_id = v.id AND r2.member_id = ?) AS reported_by_me
        FROM c406_guide_videos v LEFT JOIN c406_members m ON m.id = v.member_id
        WHERE v.guide_id = ? AND v.status = 'active' ORDER BY v.id DESC");
    $lecture->execute([$member['id'], $guideId]);
    $admin = ($member['role'] ?? '') === 'admin';
    $videos = [];
    foreach ($lecture->fetchAll() as $ligne) {
        $video = [
            'id' => (int)$ligne['id'], 'guideId' => (string)$ligne['guide_id'],
            'title' => (string)$ligne['title'], 'url' => (string)$ligne['url'],
            'at' => (int)$ligne['created_at'], 'mine' => (string)$ligne['member_id'] === (string)$member['id'],
            'reported' => (int)$ligne['reported_by_me'] > 0,
        ];
        if ($admin) {
            $video += ['memberId' => (string)$ligne['member_id'], 'author' => (string)($ligne['name'] ?? 'Membre retiré'),
                'authorEmail' => (string)($ligne['email'] ?? ''), 'reports' => (int)$ligne['reports']];
        }
        $videos[] = $video;
    }
    reply(['ok' => true, 'videos' => $videos]);
}

if ($action === 'guide_video_add') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $guideId = mb_substr(trim((string)($payload['guideId'] ?? '')), 0, 120);
    $titre = mb_substr(trim((string)($payload['title'] ?? '')), 0, 160);
    $url = normalizeYoutubeUrl((string)($payload['url'] ?? ''));
    if ($guideId === '' || $url === null) {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Collez un lien YouTube valide.'], 400);
    }
    if ($titre === '') {
        $titre = 'Vidéo liée à ce guide';
    }
    $existe = $pdo->prepare("SELECT id FROM c406_guide_videos WHERE guide_id = ? AND url = ? AND status = 'active' LIMIT 1");
    $existe->execute([$guideId, $url]);
    if ($existe->fetchColumn()) {
        reply(['ok' => false, 'error' => 'DUPLICATE', 'message' => 'Cette vidéo est déjà liée à ce PDF.'], 409);
    }
    $pdo->prepare("INSERT INTO c406_guide_videos (guide_id, member_id, title, url, status, created_at)
                   VALUES (?, ?, ?, ?, 'active', ?)
                   ON DUPLICATE KEY UPDATE member_id = VALUES(member_id), title = VALUES(title),
                       status = 'active', created_at = VALUES(created_at), handled_at = NULL")
        ->execute([$guideId, $member['id'], $titre, $url, time()]);
    $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin' AND banned_at IS NULL")->fetchAll(PDO::FETCH_COLUMN);
    pushToMembers($pdo, $admins, 'Nouveau lien vidéo',
        'Un membre a ajouté une vidéo sous un guide PDF.', ['type' => 'admin-video-link']);
    reply(['ok' => true, 'message' => 'Lien YouTube ajouté sous le PDF.']);
}

if ($action === 'guide_video_report') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expirée.'], 403);
    }
    ensureTables($pdo);
    $identifiant = (int)($payload['id'] ?? 0);
    $raison = mb_substr(trim((string)($payload['reason'] ?? '')), 0, 300);
    $video = $pdo->prepare("SELECT id, title FROM c406_guide_videos WHERE id = ? AND status = 'active' LIMIT 1");
    $video->execute([$identifiant]);
    $video = $video->fetch();
    if (!$video) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Ce lien n’existe plus.'], 404);
    }
    if ($raison === '') {
        $raison = 'Lien incorrect, indisponible ou sans rapport avec le guide.';
    }
    $pdo->prepare("INSERT INTO c406_guide_video_reports (video_id, member_id, reason, status, created_at)
                   VALUES (?, ?, ?, 'pending', ?)
                   ON DUPLICATE KEY UPDATE reason = VALUES(reason), status = 'pending', created_at = VALUES(created_at), handled_at = NULL")
        ->execute([$identifiant, $member['id'], $raison, time()]);
    $admins = $pdo->query("SELECT id FROM c406_members WHERE role = 'admin' AND banned_at IS NULL")->fetchAll(PDO::FETCH_COLUMN);
    pushToMembers($pdo, $admins, 'Lien vidéo signalé',
        'Un lien YouTube sous un PDF doit être vérifié.', ['type' => 'admin-video-report', 'videoId' => $identifiant]);
    reply(['ok' => true, 'message' => 'Merci. Le lien a été signalé à l’administrateur.']);
}

if ($action === 'admin_guide_videos') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $lecture = $pdo->query("SELECT v.id, v.guide_id, v.member_id, v.title, v.url, v.created_at, m.name, m.email,
        (SELECT COUNT(*) FROM c406_guide_video_reports r WHERE r.video_id = v.id AND r.status = 'pending') AS reports,
        (SELECT GROUP_CONCAT(r2.reason SEPARATOR ' | ') FROM c406_guide_video_reports r2 WHERE r2.video_id = v.id AND r2.status = 'pending') AS reasons
        FROM c406_guide_videos v LEFT JOIN c406_members m ON m.id = v.member_id
        WHERE v.status = 'active' ORDER BY reports DESC, v.id DESC LIMIT 200");
    $videos = [];
    foreach ($lecture->fetchAll() as $ligne) {
        $videos[] = ['id' => (int)$ligne['id'], 'guideId' => (string)$ligne['guide_id'],
            'title' => (string)$ligne['title'], 'url' => (string)$ligne['url'], 'at' => (int)$ligne['created_at'],
            'memberId' => (string)$ligne['member_id'], 'author' => (string)($ligne['name'] ?? 'Membre retiré'),
            'authorEmail' => (string)($ligne['email'] ?? ''), 'reports' => (int)$ligne['reports'],
            'reportReasons' => (string)($ligne['reasons'] ?? '')];
    }
    $attente = (int)$pdo->query("SELECT COUNT(DISTINCT video_id) FROM c406_guide_video_reports WHERE status = 'pending'")->fetchColumn();
    reply(['ok' => true, 'videos' => $videos, 'pending' => $attente]);
}

if ($action === 'admin_guide_video_decide') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $identifiant = (int)($payload['id'] ?? 0);
    $conserver = (bool)($payload['keep'] ?? false);
    $lecture = $pdo->prepare("SELECT member_id, title FROM c406_guide_videos WHERE id = ? AND status = 'active' LIMIT 1");
    $lecture->execute([$identifiant]);
    $video = $lecture->fetch();
    if (!$video) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Lien introuvable.'], 404);
    }
    if (!$conserver) {
        $pdo->prepare("UPDATE c406_guide_videos SET status = 'removed', handled_at = ? WHERE id = ?")
            ->execute([time(), $identifiant]);
        pushToMembers($pdo, [(string)$video['member_id']], 'Lien vidéo retiré',
            'Le lien ajouté sous un guide a été retiré après vérification.', ['type' => 'guide-video-decision']);
    }
    $pdo->prepare("UPDATE c406_guide_video_reports SET status = 'handled', handled_at = ? WHERE video_id = ? AND status = 'pending'")
        ->execute([time(), $identifiant]);
    reply(['ok' => true, 'message' => $conserver ? 'Signalement classé, lien conservé.' : 'Lien retiré.']);
}

if ($action === 'admin_photos') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $lignes = $pdo->query(
        "SELECT p.id, p.kind, p.listing_id, p.file_name, p.status, p.created_at, p.member_id, m.name
         FROM c406_listing_photos p LEFT JOIN c406_members m ON m.id = p.member_id
         ORDER BY (p.status = 'pending') DESC, p.id DESC LIMIT 60")->fetchAll();
    $base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'gears.hostline.fr') . '/coupe406/photos/';
    $piece = $pdo->prepare('SELECT title, price, condition_label, description, city, region FROM c406_parts WHERE id = ? LIMIT 1');
    $sortie = $pdo->prepare('SELECT title, event_date, description, city, region FROM c406_events WHERE id = ? LIMIT 1');
    $voiture = $pdo->prepare('SELECT motor_id, year, color, gearbox, trim_label, serial, note, phase, plate FROM c406_registry WHERE id = ? LIMIT 1');
    $photos = [];
    foreach ($lignes as $l) {
        $genre = (string)$l['kind'];
        $titreAnnonce = '';
        $metaAnnonce = '';
        $descriptionAnnonce = '';
        $nomGenre = 'annonce';
        if ($genre === 'part') {
            $piece->execute([(int)$l['listing_id']]);
            $annonce = $piece->fetch();
            $nomGenre = 'pièce';
            if ($annonce) {
                $titreAnnonce = (string)$annonce['title'];
                $metaAnnonce = implode(' · ', array_filter([(string)$annonce['price'], (string)$annonce['condition_label'],
                    (string)$annonce['city'], (string)$annonce['region']]));
                $descriptionAnnonce = (string)$annonce['description'];
            }
        } elseif ($genre === 'event') {
            $sortie->execute([(int)$l['listing_id']]);
            $annonce = $sortie->fetch();
            $nomGenre = 'sortie';
            if ($annonce) {
                $titreAnnonce = (string)$annonce['title'];
                $metaAnnonce = implode(' · ', array_filter([(string)$annonce['event_date'],
                    (string)$annonce['city'], (string)$annonce['region']]));
                $descriptionAnnonce = (string)$annonce['description'];
            }
        } elseif ($genre === 'registry') {
            $voiture->execute([(int)$l['listing_id']]);
            $annonce = $voiture->fetch();
            $nomGenre = 'voiture recensée';
            if ($annonce) {
                $titreAnnonce = trim('406 Coupé ' . (string)$annonce['year'] . ' · ' . (string)$annonce['color'], ' ·');
                $metaAnnonce = implode(' · ', array_filter([(string)$annonce['motor_id'], (string)$annonce['phase'],
                    (string)$annonce['trim_label'], (string)$annonce['gearbox'],
                    (string)$annonce['plate'], (string)$annonce['serial']]));
                $descriptionAnnonce = (string)$annonce['note'];
            }
        }
        $photos[] = ['id' => (int)$l['id'], 'kind' => (string)$l['kind'], 'listingId' => (int)$l['listing_id'],
            'status' => (string)$l['status'], 'at' => (int)$l['created_at'],
            'author' => (string)($l['name'] ?? 'Membre retire'), 'memberId' => (string)$l['member_id'],
            'url' => $base . $l['file_name'],
            'listingKind' => $nomGenre, 'listingTitle' => $titreAnnonce,
            'listingMeta' => $metaAnnonce, 'listingDescription' => $descriptionAnnonce];
    }
    reply(['ok' => true, 'photos' => $photos]);
}

if ($action === 'admin_photo_decide') {
    requireAdmin($payload);
    $pdo = db();
    $identifiant = (int)($payload['id'] ?? 0);
    $valide = (bool)($payload['approve'] ?? false);
    $statement = $pdo->prepare("SELECT * FROM c406_listing_photos WHERE id = ? AND status = 'pending' LIMIT 1");
    $statement->execute([$identifiant]);
    $photo = $statement->fetch();
    if (!$photo) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Photo introuvable ou deja traitee.'], 404);
    }
    if (!$valide) {
        @unlink(__DIR__ . '/photos/' . $photo['file_name']);
    }
    $pdo->prepare('UPDATE c406_listing_photos SET status = ?, handled_at = ? WHERE id = ?')
        ->execute([$valide ? 'approved' : 'rejected', time(), $identifiant]);
    pushToMembers($pdo, [$photo['member_id']],
        $valide ? 'Votre photo est publiee' : 'Votre photo a ete refusee',
        $valide ? 'Elle accompagne desormais votre annonce.' : 'L administrateur ne l a pas retenue.',
        ['type' => 'listing', 'kind' => (string)$photo['kind'], 'listingId' => (int)$photo['listing_id']]);
    reply(['ok' => true, 'message' => $valide ? 'Photo publiee.' : 'Photo refusee et supprimee.']);
}

/**
 * Centre approximatif de chaque region : un membre qui n a jamais partage sa
 * position reste visible dans l annuaire, au bon endroit de la carte.
 */
function regionCenter(string $region): array
{
    $centres = [
        'Auvergne-Rhone-Alpes' => [45.35, 4.55], 'Bourgogne-Franche-Comte' => [47.28, 4.81],
        'Bretagne' => [48.18, -2.84], 'Centre-Val de Loire' => [47.48, 1.68],
        'Corse' => [42.15, 9.10], 'Grand Est' => [48.70, 5.62],
        'Hauts-de-France' => [49.97, 2.78], 'Ile-de-France' => [48.71, 2.50],
        'Normandie' => [49.12, 0.10], 'Nouvelle-Aquitaine' => [45.19, 0.20],
        'Occitanie' => [43.70, 2.19], 'Pays de la Loire' => [47.47, -0.82],
        'Provence-Alpes-Cote d Azur' => [43.96, 6.05],
    ];
    $sansAccent = strtr($region, ['é' => 'e', 'è' => 'e', 'ê' => 'e', 'î' => 'i', 'ô' => 'o', 'û' => 'u', 'ç' => 'c', '’' => ' ', "'" => ' ']);
    return $centres[$sansAccent] ?? [46.60, 2.35];
}

if ($action === 'profile_position') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    // Sans accord explicite, la position est arrondie a environ un kilometre.
    $precis = (bool)($payload['precise'] ?? false);
    $latitude = isset($payload['lat']) ? round((float)$payload['lat'], $precis ? 4 : 2) : null;
    $longitude = isset($payload['lon']) ? round((float)$payload['lon'], $precis ? 4 : 2) : null;
    $visible = array_key_exists('discoverable', $payload) ? (int)(bool)$payload['discoverable'] : 1;
    $ville = array_key_exists('showCity', $payload) ? (int)(bool)$payload['showCity'] : 1;
    $voiture = mb_substr((string)($payload['car'] ?? ''), 0, 80);
    // La fiche du membre n est jamais reecrite ici : seule sa vitrine change.
    if ($latitude === null || $longitude === null) {
        $pdo->prepare('UPDATE c406_members SET discoverable = ?, nearby_share_enabled = ?, show_city = ?, car_label = ? WHERE id = ?')
            ->execute([$visible, $visible, $ville, $voiture, $member['id']]);
    } else {
        $pdo->prepare('UPDATE c406_members SET lat = ?, lon = ?, location_at = ?, discoverable = ?, nearby_share_enabled = ?, show_city = ?, car_label = ? WHERE id = ?')
            ->execute([$latitude, $longitude, time(), $visible, $visible, $ville, $voiture, $member['id']]);
    }
    reply(['ok' => true]);
}

if ($action === 'members_list') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $estAdmin = (string)$member['role'] === 'admin';
    $statement = $pdo->prepare(
        "SELECT id, name, city, region, car_label, lat, lon, show_city, role, last_seen_at FROM c406_members
         WHERE banned_at IS NULL AND discoverable = 1 AND approved_at IS NOT NULL AND id <> ?
         ORDER BY name LIMIT 500");
    $statement->execute([$member['id']]);
    $annuaire = [];
    foreach ($statement->fetchAll() as $ligne) {
        $centre = regionCenter((string)$ligne['region']);
        $mots = preg_split('/\s+/u', trim((string)$ligne['name'])) ?: [];
        $initiales = mb_strtoupper(mb_substr($mots[0] ?? '?', 0, 1) . (isset($mots[1]) ? mb_substr($mots[1], 0, 1) : ''));
        $fiche = [
            'id' => (string)$ligne['id'], 'name' => (string)$ligne['name'],
            'city' => (int)$ligne['show_city'] === 1 ? (string)$ligne['city'] : '', 'region' => (string)$ligne['region'],
            'car' => (string)$ligne['car_label'], 'initials' => $initiales, 'role' => (string)$ligne['role'],
            'lat' => $ligne['lat'] === null ? $centre[0] : (float)$ligne['lat'],
            'lon' => $ligne['lon'] === null ? $centre[1] : (float)$ligne['lon'],
            'approximate' => $ligne['lat'] === null,
        ];
        // La date de présence est privée : seul un administrateur peut la voir.
        if ($estAdmin) {
            $fiche['lastSeenAt'] = $ligne['last_seen_at'] === null ? null : (int)$ligne['last_seen_at'];
        }
        $annuaire[] = $fiche;
    }
    $nombreMembres = (int)$pdo->query("SELECT COUNT(*) FROM c406_members
        WHERE banned_at IS NULL AND verified_at IS NOT NULL AND approved_at IS NOT NULL")->fetchColumn();
    reply(['ok' => true, 'directory' => $annuaire, 'memberCount' => $nombreMembres]);
}

if ($action === 'community_close') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    $table = ($payload['kind'] ?? '') === 'service' ? 'c406_services' : 'c406_parts';
    $ferme = ($payload['reopen'] ?? false) ? 'open' : 'closed';
    // Comment ca s est termine : par le club, ou ailleurs. Une remise en ligne
    // efface la reponse — l annonce recommence sa vie.
    $canal = in_array((string)($payload['soldVia'] ?? ''), ['app', 'ailleurs'], true)
        ? (string)$payload['soldVia'] : '';
    if ($ferme === 'open') {
        $canal = '';
    }
    // Un membre ne clot que ses propres annonces ; l administrateur peut toutes les clore.
    $condition = $member['role'] === 'admin' ? '' : ' AND member_id = ?';
    $parametres = [$ferme, $ferme === 'closed' ? time() : null, $canal, (int)($payload['id'] ?? 0)];
    if ($condition !== '') {
        $parametres[] = $member['id'];
    }
    $requete = $pdo->prepare("UPDATE {$table} SET status = ?, closed_at = ?, sold_via = ? WHERE id = ?" . $condition);
    $requete->execute($parametres);
    if ($requete->rowCount() === 0) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Annonce introuvable.'], 404);
    }
    reply(['ok' => true, 'message' => $ferme === 'closed' ? 'Annonce cloturee.' : 'Annonce remise en ligne.']);
}

/** Notification d essai : le membre verifie que son telephone recoit bien. */
if ($action === 'push_test') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $appareils = $pdo->prepare('SELECT COUNT(*) FROM c406_push WHERE member_id = ?');
    $appareils->execute([$member['id']]);
    $nombre = (int)$appareils->fetchColumn();
    if ($nombre === 0) {
        reply(['ok' => false, 'error' => 'NO_DEVICE',
            'message' => 'Aucun appareil enregistre pour votre compte. Fermez puis rouvrez l application.'], 400);
    }
    $envoyes = pushToMembers($pdo, [$member['id']], 'Essai de notification',
        'Si vous lisez ceci, votre telephone recoit bien les alertes du club.', ['type' => 'home']);
    reply(['ok' => $envoyes > 0, 'sent' => $envoyes, 'message' => $envoyes > 0
        ? 'Notification envoyee sur ' . $envoyes . ' appareil(s). Elle arrive dans quelques secondes.'
        : 'Aucun appareil n a accepte la notification : Firebase a refuse le jeton, il sera renouvele a la prochaine ouverture.']);
}

/** Prix relevés des huiles, tels que l administrateur les a saisis. */
if ($action === 'oil_prices') {
    $pdo = db();
    if ($pdo === null) {
        reply(['ok' => true, 'prices' => []]);
    }
    ensureTables($pdo);
    $prix = [];
    foreach ($pdo->query('SELECT oil_id, price, pack, source, updated_at FROM c406_oil_prices') as $ligne) {
        $prix[] = ['id' => (string)$ligne['oil_id'], 'prix' => (string)$ligne['price'],
            'bidon' => (string)$ligne['pack'], 'source' => (string)$ligne['source'],
            'at' => (int)$ligne['updated_at']];
    }
    $photos = [];
    foreach ($pdo->query('SELECT oil_id, file_name FROM c406_oil_photos') as $ligne) {
        $photos[(string)$ligne['oil_id']] = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'gears.hostline.fr')
            . '/coupe406/photos/' . (string)$ligne['file_name'];
    }
    reply(['ok' => true, 'prices' => $prix, 'photos' => $photos]);
}

/** L administrateur note le prix constate, avec sa source et sa date. */
if ($action === 'admin_oil_price') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $identifiant = mb_substr((string)($payload['id'] ?? ''), 0, 48);
    if ($identifiant === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Huile inconnue.'], 400);
    }
    $prix = trim((string)($payload['prix'] ?? ''));
    if ($prix === '') {
        $pdo->prepare('DELETE FROM c406_oil_prices WHERE oil_id = ?')->execute([$identifiant]);
        reply(['ok' => true, 'message' => 'Prix retire.']);
    }
    $pdo->prepare('INSERT INTO c406_oil_prices (oil_id, price, pack, source, updated_at) VALUES (?, ?, ?, ?, ?)
                   ON DUPLICATE KEY UPDATE price = VALUES(price), pack = VALUES(pack), source = VALUES(source), updated_at = VALUES(updated_at)')
        ->execute([$identifiant, mb_substr($prix, 0, 16), mb_substr((string)($payload['bidon'] ?? '5 L'), 0, 16),
            mb_substr((string)($payload['source'] ?? ''), 0, 80), time()]);
    reply(['ok' => true, 'message' => 'Prix enregistre.']);
}

/** Un reglage du club, par exemple l adresse de la page Facebook. */
if ($action === 'admin_setting') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $cle = mb_substr((string)($payload['cle'] ?? ''), 0, 40);
    if ($cle === '') {
        reply(['ok' => false, 'error' => 'INVALID', 'message' => 'Reglage inconnu.'], 400);
    }
    $pdo->prepare('INSERT INTO c406_settings (cle, valeur, updated_at) VALUES (?, ?, ?)
                   ON DUPLICATE KEY UPDATE valeur = VALUES(valeur), updated_at = VALUES(updated_at)')
        ->execute([$cle, mb_substr(trim((string)($payload['valeur'] ?? '')), 0, 4000), time()]);
    reply(['ok' => true, 'message' => 'Reglage enregistre.']);
}

/** L etoile d une annonce : on la met de cote, ou on la retire. */
if ($action === 'listing_favorite') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $genre = in_array((string)($payload['kind'] ?? ''), ['part', 'service', 'event'], true) ? (string)$payload['kind'] : 'part';
    $identifiant = (int)($payload['id'] ?? 0);
    if ((bool)($payload['on'] ?? true)) {
        $pdo->prepare('INSERT IGNORE INTO c406_favorites (member_id, kind, listing_id, created_at) VALUES (?, ?, ?, ?)')
            ->execute([$member['id'], $genre, $identifiant, time()]);
    } else {
        $pdo->prepare('DELETE FROM c406_favorites WHERE member_id = ? AND kind = ? AND listing_id = ?')
            ->execute([$member['id'], $genre, $identifiant]);
    }
    reply(['ok' => true]);
}

/** Un membre ecrit au sujet d une piece : le vendeur sera relance. */
if ($action === 'listing_interest') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $pdo->prepare("UPDATE c406_parts SET interest_at = ? WHERE id = ? AND status = 'open' AND member_id <> ?")
        ->execute([time(), (int)($payload['id'] ?? 0), $member['id']]);
    reply(['ok' => true]);
}

/** Le vendeur repond a la relance : la piece est toujours a vendre. */
if ($action === 'listing_still_open') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $pdo->prepare('UPDATE c406_parts SET confirmed_at = ? WHERE id = ? AND member_id = ?')
        ->execute([time(), (int)($payload['id'] ?? 0), $member['id']]);
    reply(['ok' => true, 'message' => 'Merci : votre annonce reste en ligne.']);
}

/**
 * Met a jour la fiche du recensement quand le membre corrige sa voiture.
 *
 * Le recensement prenait l identite de la voiture au moment de l inscription,
 * une seule fois. Un membre qui ajoutait ensuite son numero DAM, son VIN ou sa
 * plaque le voyait dans son garage, mais le club continuait d afficher une
 * fiche vide. On ne touche ni au texte libre ni a la photo : ils ont ete
 * moderes, ils ne repassent pas par la.
 */
if ($action === 'registry_refresh') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $cle = mb_substr((string)($payload['carKey'] ?? ''), 0, 64);
    if ($cle === '') {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Vehicule inconnu.'], 400);
    }
    $requete = $pdo->prepare('UPDATE c406_registry
        SET motor_id = ?, year = ?, color = ?, gearbox = ?, trim_label = ?, serial = ?,
            phase = ?, plate = ?, vin = ?, dam = ?, updated_at = ?
        WHERE member_id = ? AND car_key = ?');
    $requete->execute([
        mb_substr((string)($payload['motorId'] ?? ''), 0, 24),
        mb_substr((string)($payload['year'] ?? ''), 0, 8),
        mb_substr((string)($payload['color'] ?? ''), 0, 60),
        mb_substr((string)($payload['gearbox'] ?? ''), 0, 60),
        mb_substr((string)($payload['trim'] ?? ''), 0, 60),
        preg_replace('/\D/', '', (string)($payload['serial'] ?? '')),
        mb_substr((string)($payload['phase'] ?? ''), 0, 8),
        mb_substr(strtoupper(trim((string)($payload['plate'] ?? ''))), 0, 16),
        mb_substr(strtoupper(preg_replace('/[^0-9A-Za-z]/', '', (string)($payload['vin'] ?? ''))), 0, 17),
        mb_substr(strtoupper(trim((string)($payload['dam'] ?? ''))), 0, 12),
        time(), $member['id'], $cle,
    ]);
    reply(['ok' => true, 'misAJour' => $requete->rowCount() > 0]);
}

if ($action === 'registry_publish') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    // Un meme numero de serie ne peut pas etre recense deux fois : le but du
    // recensement est de compter des voitures, pas des fiches.
    $serie = trim((string)($payload['serial'] ?? ''));
    if ($serie !== '') {
        $doublon = $pdo->prepare('SELECT id FROM c406_registry WHERE serial = ? AND (member_id <> ? OR car_key <> ?) LIMIT 1');
        $doublon->execute([$serie, $member['id'], mb_substr((string)($payload['carKey'] ?? ''), 0, 64)]);
        if ($doublon->fetchColumn() !== false) {
            reply(['ok' => false, 'error' => 'DEJA_RECENSEE',
                'message' => 'Ce numero d exemplaire est deja recense au club. Si vous venez d acheter cette voiture, ecrivez a l administrateur : il vous transmettra la fiche existante, avec son numero et son histoire.'], 409);
        }
    }
    $cle = mb_substr((string)($payload['carKey'] ?? ''), 0, 64);
    if ($cle === '') {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Vehicule inconnu.'], 400);
    }
    $note = trim((string)($payload['note'] ?? ''));
    if ($note !== '') {
        $verdict = moderationVerdict($note);
        if ($verdict['action'] === 'block') {
            moderationSignale($pdo, $member['id'], 'registre bloque : ' . $verdict['reason'], $note);
            reply(['ok' => false, 'error' => 'BLOCKED', 'reason' => $verdict['reason'], 'message' => 'Texte refuse : les propos haineux ne sont pas acceptes dans le club.'], 422);
        }
    }
    // Une voiture habite chez son proprietaire : sa position vient de la ville
    // du compte, et n est enregistree que si le membre accepte de montrer sa
    // ville. Sinon la voiture est comptee mais n apparait sur aucune carte.
    $lieuVoiture = ((int)($member['show_city'] ?? 0) === 1)
        ? positionVille($pdo, (string)($member['city'] ?? ''), (string)($member['region'] ?? ''))
        : null;
    $pdo->prepare('INSERT INTO c406_registry (member_id, car_key, motor_id, year, color, gearbox, trim_label, serial, note, phase, plate, vin, dam, created_at, updated_at, lat, lon)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON DUPLICATE KEY UPDATE motor_id = VALUES(motor_id), year = VALUES(year), color = VALUES(color),
            gearbox = VALUES(gearbox), trim_label = VALUES(trim_label), serial = VALUES(serial),
            note = VALUES(note), phase = VALUES(phase), plate = VALUES(plate), vin = VALUES(vin), dam = VALUES(dam),
            updated_at = VALUES(updated_at), lat = VALUES(lat), lon = VALUES(lon)')
        ->execute([$member['id'], $cle,
            mb_substr((string)($payload['motorId'] ?? ''), 0, 24), mb_substr((string)($payload['year'] ?? ''), 0, 8),
            mb_substr((string)($payload['color'] ?? ''), 0, 60), mb_substr((string)($payload['gearbox'] ?? ''), 0, 60),
            mb_substr((string)($payload['trim'] ?? ''), 0, 60), mb_substr((string)($payload['serial'] ?? ''), 0, 12),
            mb_substr($note, 0, 300), mb_substr((string)($payload['phase'] ?? ''), 0, 8),
            mb_substr((string)($payload['plate'] ?? ''), 0, 16), mb_substr((string)($payload['vin'] ?? ''), 0, 20),
            mb_substr((string)($payload['dam'] ?? ''), 0, 16), time(), time(),
            $lieuVoiture['lat'] ?? null, $lieuVoiture['lon'] ?? null]);
    $statement = $pdo->prepare('SELECT id FROM c406_registry WHERE member_id = ? AND car_key = ? LIMIT 1');
    $statement->execute([$member['id'], $cle]);
    reply(['ok' => true, 'id' => (int)$statement->fetchColumn(), 'message' => 'Voiture inscrite au registre.']);
}

/** Le proprietaire declare l etat de sa voiture : en circulation, immobilisee, disparue. */
if ($action === 'registry_status') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $etats = ['circulation', 'immobilisee', 'detruite'];
    $statut = in_array((string)($payload['statut'] ?? ''), $etats, true) ? (string)$payload['statut'] : 'circulation';
    $condition = ($member['role'] ?? '') === 'admin' ? '' : ' AND member_id = ?';
    $parametres = [$statut, time(), (int)($payload['id'] ?? 0)];
    if ($condition !== '') {
        $parametres[] = $member['id'];
    }
    $requete = $pdo->prepare('UPDATE c406_registry SET statut = ?, statut_at = ?, confirme_at = UNIX_TIMESTAMP(), relance_at = 0 WHERE id = ?' . $condition);
    $requete->execute($parametres);
    if ($requete->rowCount() === 0) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Voiture introuvable.'], 404);
    }
    reply(['ok' => true, 'message' => $statut === 'detruite'
        ? 'Voiture classee parmi les Coupes disparus. Elle reste comptee dans le recensement.'
        : 'Etat mis a jour.']);
}

/** « Je l ai vendue » : la voiture reste comptee, sans proprietaire attitre. */
if ($action === 'registry_release') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    $requete = $pdo->prepare("UPDATE c406_registry SET member_id = '', note = '', confirme_at = UNIX_TIMESTAMP()
                              WHERE id = ? AND member_id = ?");
    $requete->execute([(int)($payload['id'] ?? 0), $member['id']]);
    if ($requete->rowCount() === 0) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Voiture introuvable.'], 404);
    }
    reply(['ok' => true, 'message' => 'Voiture detachee de votre garage. Elle reste comptee au recensement : c est une 406 Coupe de plus qui roule encore.']);
}

if ($action === 'registry_list') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);
    // Numerotation dans l ordre d inscription : la premiere voiture recensee
    // porte le numero 1 et le garde, meme si une autre est retiree plus tard.
    $lignes = $pdo->query('SELECT r.*, m.name, m.banned_at, m.region AS member_region, m.show_city FROM c406_registry r
        LEFT JOIN c406_members m ON m.id = r.member_id
        ORDER BY r.id ASC LIMIT 2000')->fetchAll();
    $voitures = [];
    $numero = 0;
    $parMoteur = [];
    $parCouleur = [];
    foreach ($lignes as $l) {
        $numero++;
        $voitures[] = ['id' => (int)$l['id'], 'numero' => $numero, 'carKey' => (string)$l['car_key'], 'motorId' => (string)$l['motor_id'],
            'year' => (string)$l['year'], 'color' => (string)$l['color'], 'gearbox' => (string)$l['gearbox'],
            'trim' => (string)$l['trim_label'], 'serial' => (string)$l['serial'], 'note' => (string)$l['note'],
            'phase' => (string)($l['phase'] ?? ''),
            'statut' => (string)($l['statut'] ?? 'circulation'),
            'statutAt' => (int)($l['statut_at'] ?? 0),
            'author' => (string)($l['name'] ?? 'Membre retire'), 'memberId' => (string)$l['member_id'],
            'mine' => $l['member_id'] === $member['id'],
            'region' => (string)($l['member_region'] ?? ''),
            // La position ne sort que si le membre montre sa ville, et reste au
            // centre de la commune : jamais son adresse.
            'lat' => ($l['lat'] === null || (int)($l['show_city'] ?? 0) !== 1) ? null : (float)$l['lat'],
            'lon' => ($l['lon'] === null || (int)($l['show_city'] ?? 0) !== 1) ? null : (float)$l['lon'],
            'photo' => photoFor($pdo, 'registry', (int)$l['id'])];
        $parMoteur[(string)$l['motor_id']] = ($parMoteur[(string)$l['motor_id']] ?? 0) + 1;
        if ((string)$l['color'] !== '') {
            $parCouleur[(string)$l['color']] = ($parCouleur[(string)$l['color']] ?? 0) + 1;
        }
    }
    arsort($parCouleur);
    $parPhase = ['PH1' => 0, 'PH2' => 0, '' => 0];
    foreach ($voitures as $voiture) {
        $cle = isset($parPhase[$voiture['phase']]) ? $voiture['phase'] : '';
        $parPhase[$cle]++;
    }
    $parStatut = ['circulation' => 0, 'immobilisee' => 0, 'detruite' => 0];
    foreach ($voitures as $voiture) {
        $cle = isset($parStatut[$voiture['statut']]) ? $voiture['statut'] : 'circulation';
        $parStatut[$cle]++;
    }
    reply(['ok' => true, 'cars' => $voitures, 'byMotor' => $parMoteur, 'byPhase' => $parPhase,
        'byStatut' => $parStatut, 'byColor' => array_slice($parCouleur, 0, 8, true)]);
}

if ($action === 'registry_delete') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    // Une voiture recensee ne s efface plus sur un coup de tete : le
    // proprietaire declare son etat, seul l administrateur corrige une erreur
    // ou un doublon.
    if (($member['role'] ?? '') !== 'admin') {
        reply(['ok' => false, 'error' => 'FORBIDDEN',
            'message' => 'Une voiture recensee reste au recensement du club. Declarez son etat — immobilisee, detruite — depuis votre garage. Pour corriger une erreur, ecrivez a l administrateur.'], 403);
    }
    $pdo->prepare('DELETE FROM c406_registry WHERE id = ?')->execute([(int)($payload['id'] ?? 0)]);
    reply(['ok' => true, 'message' => 'Fiche retiree du recensement.']);
}

if ($action === 'place_name') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    if ($member === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    // Le nom de commune est demande par le serveur, avec la position deja
    // arrondie : le service exterieur ne voit ni le membre ni son telephone.
    $latitude = round((float)($payload['lat'] ?? 0), 2);
    $longitude = round((float)($payload['lon'] ?? 0), 2);
    if ($latitude === 0.0 && $longitude === 0.0) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Position absente.'], 400);
    }
    $url = 'https://api-adresse.data.gouv.fr/reverse/?limit=1&lat=' . rawurlencode((string)$latitude) . '&lon=' . rawurlencode((string)$longitude);
    $reponse = @file_get_contents($url, false, stream_context_create(['http' => [
        'method' => 'GET', 'timeout' => 8, 'ignore_errors' => true,
        'header' => "User-Agent: Coupe406Club/1.0

",
    ]]));
    $donnees = json_decode((string)$reponse, true);
    $premier = $donnees['features'][0]['properties'] ?? null;
    reply([
        'ok' => true,
        'city' => is_array($premier) ? (string)($premier['city'] ?? $premier['name'] ?? '') : '',
        'postcode' => is_array($premier) ? (string)($premier['postcode'] ?? '') : '',
        'lat' => $latitude, 'lon' => $longitude,
    ]);
}

if ($action === 'admin_allow_list') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $termes = [];
    foreach ($pdo->query('SELECT id, terme, created_at FROM c406_moderation_allow ORDER BY terme') as $ligne) {
        $termes[] = ['id' => (int)$ligne['id'], 'terme' => (string)$ligne['terme'], 'at' => (int)$ligne['created_at']];
    }
    reply(['ok' => true, 'allowed' => $termes]);
}

if ($action === 'admin_allow_add') {
    $member = requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $terme = mb_strtolower(trim((string)($payload['terme'] ?? '')));
    if (mb_strlen($terme) < 2) {
        reply(['ok' => false, 'error' => 'EMPTY', 'message' => 'Expression trop courte.'], 400);
    }
    $pdo->prepare('INSERT IGNORE INTO c406_moderation_allow (terme, added_by, created_at) VALUES (?, ?, ?)')
        ->execute([mb_substr($terme, 0, 80), is_array($member) ? (string)$member['id'] : '', time()]);
    reply(['ok' => true, 'message' => 'Expression autorisee : elle ne bloquera plus.']);
}

if ($action === 'admin_allow_delete') {
    requireAdmin($payload);
    $pdo = db();
    $pdo->prepare('DELETE FROM c406_moderation_allow WHERE id = ?')->execute([(int)($payload['id'] ?? 0)]);
    reply(['ok' => true, 'message' => 'Expression retiree de la liste blanche.']);
}

if ($action === 'admin_requests') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $demandes = [];
    $lignes = $pdo->query("SELECT id, name, email, city, region, owns_coupe, motivation, gender, age_band, created_at, charter_at, verified_at
        FROM c406_members WHERE verified_at IS NOT NULL
          AND approved_at IS NULL AND refused_at IS NULL AND banned_at IS NULL
        ORDER BY created_at ASC LIMIT 100")->fetchAll();
    foreach ($lignes as $l) {
        $demandes[] = [
            'id' => (string)$l['id'], 'name' => (string)$l['name'], 'email' => (string)$l['email'],
            'city' => (string)$l['city'], 'region' => (string)$l['region'],
            'ownsCoupe' => (string)$l['owns_coupe'], 'motivation' => (string)$l['motivation'],
            'gender' => (string)$l['gender'], 'ageBand' => (string)$l['age_band'],
            'at' => (int)$l['created_at'], 'charter' => $l['charter_at'] !== null,
            'verified' => $l['verified_at'] !== null,
        ];
    }
    reply(['ok' => true, 'requests' => $demandes]);
}

/**
 * L adresse d un membre, confirmee a la main par l administrateur.
 *
 * Les messages partis vers free.fr, orange.fr ou outlook finissent souvent en
 * indesirables : quand l administrateur connait la personne, il vaut mieux
 * debloquer que laisser un membre dehors.
 */
if ($action === 'admin_member_verify') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $requete = $pdo->prepare('UPDATE c406_members SET verified_at = COALESCE(verified_at, ?) WHERE id = ?');
    $requete->execute([time(), (string)($payload['id'] ?? '')]);
    if ($requete->rowCount() === 0) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Membre introuvable, ou adresse deja confirmee.'], 404);
    }
    reply(['ok' => true, 'message' => 'Adresse confirmee : le membre peut se connecter.']);
}

/** Renvoie le lien de confirmation a un membre qui ne l a pas recu. */
if ($action === 'admin_resend_link') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    $lecture = $pdo->prepare('SELECT name, email FROM c406_members WHERE id = ? LIMIT 1');
    $lecture->execute([(string)($payload['id'] ?? '')]);
    $membre = $lecture->fetch();
    if (!$membre) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Membre introuvable.'], 404);
    }
    $jeton = makeToken((string)$membre['email'], 'verify');
    $base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'gears.hostline.fr') . '/coupe406/api.php';
    [$sujet, $texte, $html] = linkEmail((string)$membre['name'], $base . '?t=' . urlencode($jeton), 'verify');
    [$parti, $erreur] = sendMail((string)$membre['email'], $sujet, $texte, $html, CONTACT_TO);
    reply(['ok' => (bool)$parti, 'message' => $parti
        ? 'Lien renvoye a ' . $membre['email'] . '. Demandez-lui de regarder ses indesirables.'
        : 'Envoi refuse par le serveur de messagerie (' . $erreur . ').']);
}

if ($action === 'admin_request_decide') {
    $admin = requireAdmin($payload);
    $pdo = db();
    $identifiant = (string)($payload['id'] ?? '');
    $accepte = (bool)($payload['approve'] ?? false);
    $statement = $pdo->prepare('SELECT id, name, email, approved_at FROM c406_members WHERE id = ? LIMIT 1');
    $statement->execute([$identifiant]);
    $membre = $statement->fetch();
    if (!$membre) {
        reply(['ok' => false, 'error' => 'NOT_FOUND', 'message' => 'Demande introuvable.'], 404);
    }
    $nouvelleAdhesion = $accepte && $membre['approved_at'] === null;
    if ($accepte) {
        $pdo->prepare('UPDATE c406_members SET approved_at = ?, refused_at = NULL WHERE id = ?')->execute([time(), $identifiant]);
    } else {
        $pdo->prepare('UPDATE c406_members SET refused_at = ?, approved_at = NULL WHERE id = ?')->execute([time(), $identifiant]);
    }
    pushToMembers($pdo, [$identifiant],
        $accepte ? 'Bienvenue au club' : 'Demande non retenue',
        $accepte ? 'Votre adhesion est validee : connectez-vous a l application.' : 'L administrateur n a pas retenu votre demande d adhesion.',
        ['type' => 'membership']);
    // Un candidat n'a pas encore de session, donc généralement aucun jeton de
    // notification. L'e-mail garantit qu'il apprend la décision et sait qu'il
    // doit revenir se connecter dans l'application.
    $titre = $accepte ? 'Votre adhésion est acceptée' : 'Réponse à votre demande d’adhésion';
    $texte = $accepte
        ? 'Bienvenue au Coupé 406 Club. Ouvrez maintenant l’application et connectez-vous avec votre adresse e-mail et votre mot de passe.'
        : 'Votre demande d’adhésion au Coupé 406 Club n’a pas été retenue par l’administrateur.';
    sendMail((string)$membre['email'], '[COUPÉ 406 CLUB] ' . $titre,
        $texte,
        '<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.6">'
            . htmlspecialchars($texte, ENT_QUOTES) . '</p>');
    if ($nouvelleAdhesion) {
        startWelcomeRound($pdo, $identifiant, (string)$membre['name'], (string)$admin['id']);
    }
    reply(['ok' => true, 'message' => $accepte ? 'Membre accepte.' : 'Demande refusee.']);
}

/** Les chiffres du club : motorisations recensees, ages, femmes et hommes. */
if ($action === 'admin_stats') {
    requireAdmin($payload);
    $pdo = db();
    ensureTables($pdo);
    // Les jours, mois et annees des statistiques sont ceux de Paris : le
    // serveur vit en UTC, et une visite a 1 h du matin doit compter pour le
    // jour qui commence, pas pour la veille. Ce reglage ne vit que le temps
    // de cette requete.
    date_default_timezone_set('Europe/Paris');
    $pdo->exec("SET time_zone = '" . date('P') . "'");

    /** Transforme des comptes en parts, la plus fournie en tete. */
    $parts = static function (array $comptes, int $total): array {
        $lignes = [];
        foreach ($comptes as $cle => $nombre) {
            $lignes[] = ['cle' => (string)$cle, 'nombre' => (int)$nombre,
                'part' => $total > 0 ? round($nombre * 100 / $total, 1) : 0.0];
        }
        usort($lignes, static fn ($a, $b) => $b['nombre'] <=> $a['nombre']);
        return $lignes;
    };

    // La periode des trois boutons de la page : aujourd hui, ce mois-ci ou
    // cette annee, comptee a l heure de Paris (le serveur vit en UTC). Tous
    // les chiffres de la page la suivent. Une app qui n envoie pas de periode
    // (avant la 1.0.17) garde ses totaux depuis l ouverture du club.
    $periodeDemandee = (string)($payload['periode'] ?? '');
    $parPeriode = in_array($periodeDemandee, ['jour', 'mois', 'annee'], true);
    $periode = $parPeriode ? $periodeDemandee : 'jour';
    $maintenant = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
    $debutPeriode = match ($periode) {
        'annee' => $maintenant->setDate((int)$maintenant->format('Y'), 1, 1)->setTime(0, 0),
        'mois' => $maintenant->setDate((int)$maintenant->format('Y'), (int)$maintenant->format('n'), 1)->setTime(0, 0),
        default => $maintenant->setTime(0, 0),
    };
    $debut = $parPeriode ? $debutPeriode->getTimestamp() : 0;
    $fin = $parPeriode
        ? $debutPeriode->modify(match ($periode) { 'annee' => '+1 year', 'mois' => '+1 month', default => '+1 day' })->getTimestamp()
        : time() + 86400;
    // Deux entiers calcules ici : ils entrent tels quels dans le SQL.
    $dans = static fn (string $colonne): string => "{$colonne} >= {$debut} AND {$colonne} < {$fin}";

    // Motorisations : ce que les membres ont reellement recense (sur une
    // periode, les coupes recenses pendant celle-ci).
    $moteurs = [];
    $phases = ['PH1' => 0, 'PH2' => 0];
    foreach ($pdo->query('SELECT motor_id, phase FROM c406_registry WHERE ' . $dans('created_at')) as $ligne) {
        $cle = (string)$ligne['motor_id'] !== '' ? (string)$ligne['motor_id'] : 'inconnue';
        $moteurs[$cle] = ($moteurs[$cle] ?? 0) + 1;
        $phase = (string)$ligne['phase'] === 'PH2' ? 'PH2' : 'PH1';
        $phases[$phase]++;
    }
    $voitures = array_sum($moteurs);

    // Membres acceptes : eux seuls comptent dans les chiffres du club. Sur une
    // periode, ce sont ceux qui sont venus dans l app pendant celle-ci :
    // last_seen_at avance a chaque appel, donc un membre venu pendant la
    // periode en cours a forcement sa derniere visite dedans.
    $venus = $parPeriode ? ' AND ' . $dans('last_seen_at') : '';
    $ages = [];
    $genres = [];
    $membres = 0;
    foreach ($pdo->query("SELECT gender, age_band FROM c406_members WHERE approved_at IS NOT NULL AND banned_at IS NULL{$venus}") as $ligne) {
        $membres++;
        $tranche = (string)$ligne['age_band'] !== '' ? (string)$ligne['age_band'] : 'non renseigne';
        $ages[$tranche] = ($ages[$tranche] ?? 0) + 1;
        $genre = (string)$ligne['gender'] !== '' ? (string)$ligne['gender'] : 'non renseigne';
        $genres[$genre] = ($genres[$genre] ?? 0) + 1;
    }

    // Les tranches d age restent dans l ordre de la vie, pas du nombre.
    $ordre = ['18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'non renseigne'];
    $agesTries = [];
    foreach ($ordre as $tranche) {
        if (isset($ages[$tranche])) {
            $agesTries[] = ['cle' => $tranche, 'nombre' => $ages[$tranche],
                'part' => $membres > 0 ? round($ages[$tranche] * 100 / $membres, 1) : 0.0];
        }
    }

    // Courbes de l activite, au choix par jour (30 derniers jours), par mois
    // (12 derniers mois) ou par annee (5 dernieres). Les connexions comptent
    // des membres UNIQUES sur la periode ; les autres series comptent les
    // actions reellement creees. Avant l historisation des connexions,
    // last_seen fournit un minimum pour les periodes plus anciennes.
    $activite = [];
    if ($periode === 'mois') {
        $format = '%Y-%m';
        for ($rang = 11; $rang >= 0; $rang--) {
            $cle = date('Y-m', strtotime("first day of -{$rang} months"));
            $activite[$cle] = ['date' => $cle, 'connections' => 0, 'posts' => 0, 'messages' => 0, 'other' => 0];
        }
        $depuis = strtotime('first day of -11 months midnight');
    } elseif ($periode === 'annee') {
        $format = '%Y';
        $anneeCourante = (int)date('Y');
        for ($annee = $anneeCourante - 4; $annee <= $anneeCourante; $annee++) {
            $activite[(string)$annee] = ['date' => (string)$annee, 'connections' => 0, 'posts' => 0, 'messages' => 0, 'other' => 0];
        }
        $depuis = mktime(0, 0, 0, 1, 1, $anneeCourante - 4);
    } else {
        $format = '%Y-%m-%d';
        for ($jour = 29; $jour >= 0; $jour--) {
            $cle = date('Y-m-d', strtotime('-' . $jour . ' days'));
            $activite[$cle] = ['date' => $cle, 'connections' => 0, 'posts' => 0, 'messages' => 0, 'other' => 0];
        }
        $depuis = strtotime('-29 days midnight');
    }
    // Le format vient d une liste fermee : il peut entrer tel quel dans le SQL.
    $cleDe = static fn (string $colonne): string => "DATE_FORMAT(FROM_UNIXTIME({$colonne}), '{$format}')";
    $remplir = static function (PDO $pdo, array &$periodes, string $sql, string $champ): void {
        foreach ($pdo->query($sql)->fetchAll() as $ligne) {
            $cle = (string)($ligne['jour'] ?? '');
            if (isset($periodes[$cle])) {
                $periodes[$cle][$champ] = (int)$ligne['nombre'];
            }
        }
    };
    // activity_day est une date UTC : on range plutot la premiere et la
    // derniere visite de chaque ligne a l heure de Paris.
    $remplir($pdo, $activite,
        "SELECT jour, COUNT(DISTINCT member_id) AS nombre FROM (
            SELECT member_id, {$cleDe('first_at')} AS jour FROM c406_daily_connections
             WHERE activity_day >= DATE(FROM_UNIXTIME({$depuis})) - INTERVAL 1 DAY AND first_at >= {$depuis}
            UNION ALL SELECT member_id, {$cleDe('last_at')} FROM c406_daily_connections
             WHERE activity_day >= DATE(FROM_UNIXTIME({$depuis})) - INTERVAL 1 DAY AND last_at >= {$depuis}
         ) visites GROUP BY jour", 'connections');
    foreach ($pdo->query("SELECT {$cleDe('last_seen_at')} AS jour, COUNT(*) AS nombre
                          FROM c406_members WHERE last_seen_at >= {$depuis}
                          GROUP BY jour")->fetchAll() as $ligne) {
        $cle = (string)$ligne['jour'];
        if (isset($activite[$cle])) {
            $activite[$cle]['connections'] = max($activite[$cle]['connections'], (int)$ligne['nombre']);
        }
    }
    $remplir($pdo, $activite,
        "SELECT jour, COUNT(*) AS nombre FROM (
            SELECT {$cleDe('created_at')} AS jour FROM c406_services WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_parts WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_events WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_ideas WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_news WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_guide_requests WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_guide_videos WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_registry WHERE created_at >= {$depuis}
         ) publications GROUP BY jour", 'posts');
    $remplir($pdo, $activite,
        "SELECT {$cleDe('created_at')} AS jour, COUNT(*) AS nombre FROM c406_messages
         WHERE created_at >= {$depuis} GROUP BY jour", 'messages');
    $remplir($pdo, $activite,
        "SELECT jour, COUNT(*) AS nombre FROM (
            SELECT {$cleDe('created_at')} AS jour FROM c406_reports WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('created_at')} FROM c406_guide_video_reports WHERE created_at >= {$depuis}
            UNION ALL SELECT {$cleDe('joined_at')} FROM c406_event_guests WHERE joined_at >= {$depuis}
            UNION ALL SELECT {$cleDe('wished_at')} FROM c406_welcome_wishes WHERE wished_at >= {$depuis}
         ) actions GROUP BY jour", 'other');

    // Les documents les plus ouverts : c est la mesure la plus parlante de ce
    // que les membres viennent chercher. On garde les quinze premiers.
    // Les ouvertures ne sont datees une a une que depuis le journal
    // c406_guide_opens. Une periode qui couvre toute la vie du club reprend
    // le total par document ; une periode plus courte lit le journal, et la
    // page dit depuis quand il existe si la periode commence avant lui.
    $compte = static fn (PDO $base, string $sql): int => (int)$base->query($sql)->fetchColumn();
    $clubDepuis = $compte($pdo, 'SELECT COALESCE(MIN(created_at), 0) FROM c406_members');
    $journalDepuis = $compte($pdo, 'SELECT COALESCE(MIN(opened_at), 0) FROM c406_guide_opens') ?: time();
    $documents = [];
    $documentsDepuis = 0;
    if ($debut <= $clubDepuis) {
        $lignes = $pdo->query("SELECT guide_id, title, opens, last_at FROM c406_guide_views
                               WHERE opens > 0 ORDER BY opens DESC, title ASC LIMIT 15")->fetchAll();
        $documentsOuverts = $compte($pdo, 'SELECT COALESCE(SUM(opens),0) FROM c406_guide_views');
        $documentsDistincts = $compte($pdo, 'SELECT COUNT(*) FROM c406_guide_views WHERE opens > 0');
    } else {
        $lignes = $pdo->query("SELECT o.guide_id, COALESCE(NULLIF(v.title, ''), o.guide_id) AS title,
                                      COUNT(*) AS opens, MAX(o.opened_at) AS last_at
                               FROM c406_guide_opens o LEFT JOIN c406_guide_views v ON v.guide_id = o.guide_id
                               WHERE " . $dans('o.opened_at') . "
                               GROUP BY o.guide_id, v.title ORDER BY opens DESC, title ASC LIMIT 15")->fetchAll();
        $documentsOuverts = $compte($pdo, 'SELECT COUNT(*) FROM c406_guide_opens WHERE ' . $dans('opened_at'));
        $documentsDistincts = $compte($pdo, 'SELECT COUNT(DISTINCT guide_id) FROM c406_guide_opens WHERE ' . $dans('opened_at'));
        if ($debut < $journalDepuis) {
            $documentsDepuis = $journalDepuis;
        }
    }
    foreach ($lignes as $ligne) {
        $documents[] = [
            'id' => (string)$ligne['guide_id'],
            'titre' => (string)($ligne['title'] ?? $ligne['guide_id']),
            'ouvertures' => (int)$ligne['opens'],
            'dernier' => (int)($ligne['last_at'] ?? 0),
            'jaime' => 0,
        ];
    }
    // Les « j aime » de la periode se greffent sur les documents deja listes.
    $aimes = [];
    foreach ($pdo->query('SELECT guide_id, COUNT(*) n FROM c406_guide_likes WHERE ' . $dans('liked_at') . ' GROUP BY guide_id') as $ligne) {
        $aimes[(string)$ligne['guide_id']] = (int)$ligne['n'];
    }
    foreach ($documents as $rang => $document) {
        $documents[$rang]['jaime'] = $aimes[$document['id']] ?? 0;
    }

    $ventes = ['app' => 0, 'ailleurs' => 0, 'sans-suite' => 0];
    foreach ($pdo->query('SELECT sold_via, COUNT(*) n FROM c406_sales_log WHERE ' . $dans('closed_at') . ' GROUP BY sold_via') as $ligne) {
        $cle = (string)$ligne['sold_via'];
        if (isset($ventes[$cle])) {
            $ventes[$cle] = (int)$ligne['n'];
        }
    }

    reply(['ok' => true,
        'membres' => $membres,
        'voitures' => $voitures,
        'moteurs' => $parts($moteurs, $voitures),
        'phases' => $parts($phases, $voitures),
        'ages' => $agesTries,
        'genres' => $parts($genres, $membres),
        'activite' => array_values($activite),
        'periode' => $periode,
        'debut' => $debut,
        // Les nouveaux membres de la periode, et la taille du club, toujours
        // affichee pour que les chiffres d un jour gardent leur echelle.
        'nouveaux' => $compte($pdo, 'SELECT COUNT(*) FROM c406_members WHERE banned_at IS NULL AND ' . $dans('approved_at')),
        'totaux' => [
            'membres' => $compte($pdo, 'SELECT COUNT(*) FROM c406_members WHERE approved_at IS NOT NULL AND banned_at IS NULL'),
            'voitures' => $compte($pdo, 'SELECT COUNT(*) FROM c406_registry'),
        ],
        'documents' => $documents,
        'documentsOuverts' => $documentsOuverts,
        'documentsDistincts' => $documentsDistincts,
        'documentsAimes' => $compte($pdo, 'SELECT COUNT(*) FROM c406_guide_likes WHERE ' . $dans('liked_at')),
        'documentsDepuis' => $documentsDepuis,
        'etats' => $parts(array_column($pdo->query("SELECT COALESCE(NULLIF(statut,''),'circulation') AS k, COUNT(*) AS n
            FROM c406_registry WHERE " . $dans('created_at') . " GROUP BY k")->fetchAll(), 'n', 'k'), $voitures),
        'couleurs' => $parts(array_column($pdo->query("SELECT color AS k, COUNT(*) AS n FROM c406_registry
            WHERE color <> '' AND " . $dans('created_at') . " GROUP BY color ORDER BY n DESC LIMIT 8")->fetchAll(), 'n', 'k'), $voitures),
        'entraide' => [
            // Toujours l etat present : les annonces encore ouvertes.
            'annoncesOuvertes' => $compte($pdo, "SELECT COUNT(*) FROM c406_parts WHERE status = 'open'"),
            // Le reste compte ce qui a ete publie pendant la periode (sans
            // periode, recherches et services restent ceux encore ouverts).
            'annonces' => $compte($pdo, 'SELECT COUNT(*) FROM c406_parts WHERE ' . $dans('created_at')),
            'recherches' => $compte($pdo, $parPeriode
                ? 'SELECT COUNT(*) FROM c406_parts WHERE wanted = 1 AND ' . $dans('created_at')
                : "SELECT COUNT(*) FROM c406_parts WHERE status = 'open' AND wanted = 1"),
            'services' => $compte($pdo, $parPeriode
                ? 'SELECT COUNT(*) FROM c406_services WHERE ' . $dans('created_at')
                : "SELECT COUNT(*) FROM c406_services WHERE status = 'open'"),
            'rapprochements' => $compte($pdo, 'SELECT COUNT(*) FROM c406_part_matches WHERE ' . $dans('notified_at')),
            'sorties' => $compte($pdo, 'SELECT COUNT(*) FROM c406_events WHERE ' . $dans('created_at')),
            'inscriptions' => $compte($pdo, 'SELECT COUNT(*) FROM c406_event_guests WHERE status = 0 AND ' . $dans('joined_at')),
            'stl' => $compte($pdo, 'SELECT COUNT(*) FROM c406_stl_models WHERE ' . $dans('created_at')),
        ],
        'ventes' => $ventes,
        'vie' => [
            'messages' => $compte($pdo, 'SELECT COUNT(*) FROM c406_messages WHERE ' . $dans('created_at')),
            'idees' => $compte($pdo, 'SELECT COUNT(*) FROM c406_ideas WHERE ' . $dans('created_at')),
            // Un vote sans date (d avant voted_at) compte a la date de son idee.
            'votes' => $compte($pdo, 'SELECT COUNT(*) FROM c406_idea_votes v LEFT JOIN c406_ideas i ON i.id = v.idea_id
                WHERE ' . $dans('COALESCE(v.voted_at, i.created_at, 0)')),
            'pannes' => $compte($pdo, 'SELECT COUNT(*) FROM c406_pannes_log WHERE ' . $dans('sent_at')),
            // L etat du club en ce moment, quelle que soit la periode.
            'garages' => $compte($pdo, 'SELECT COUNT(*) FROM c406_backups'),
            'appareils' => $compte($pdo, 'SELECT COUNT(*) FROM c406_push'),
            'positions' => $compte($pdo, 'SELECT COUNT(*) FROM c406_members WHERE lat IS NOT NULL'),
            'villeVisible' => $compte($pdo, 'SELECT COUNT(*) FROM c406_members WHERE show_city = 1'),
        ],
    ]);
}

if ($action === 'admin_version_state') {
    requireAdmin($payload);
    $stable = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
    $essai = json_decode((string)@file_get_contents(__DIR__ . '/latest-test.json'), true);
    $pdo = db();
    ensureTables($pdo);
    $statutPlay = playTestOfficialStatus();
    $testeurs = $statutPlay['officialCount'];
    $membres = (int)$pdo->query('SELECT COUNT(*) FROM c406_members WHERE banned_at IS NULL AND verified_at IS NOT NULL AND approved_at IS NOT NULL')->fetchColumn();
    $validation = $pdo->prepare('SELECT m.id, m.name, m.email, a.accepted_at
        FROM c406_play_test_acceptances a
        INNER JOIN c406_members m ON m.id = a.member_id
        WHERE a.campaign = ?
        ORDER BY a.accepted_at DESC');
    $validation->execute([PLAY_TEST_CAMPAIGN]);
    $declarations = array_map(static fn(array $ligne): array => [
        'memberId' => (string)$ligne['id'],
        'name' => (string)$ligne['name'],
        'email' => (string)$ligne['email'],
        'acceptedAt' => (int)$ligne['accepted_at'],
    ], $validation->fetchAll());
    // L'interface 1.04 attend une ligne par testeur confirmé. Google ne donne
    // pas leurs identités : on montre uniquement les places réellement
    // comptées, sans attribuer ces validations à de faux membres.
    $confirmes = [];
    for ($numero = 1; $numero <= $statutPlay['officialCount']; $numero++) {
        $confirmes[] = [
            'memberId' => '',
            'name' => 'Inscription comptée par Google n°' . $numero,
            'email' => 'Adresse non communiquée · n°' . $numero,
            'acceptedAt' => $statutPlay['checkedAt'],
        ];
    }
    reply([
        'ok' => true,
        'stable' => is_array($stable) ? $stable : null,
        'test' => is_array($essai) ? $essai : null,
        'testers' => $testeurs,
        'members' => $membres,
        'playTestAccepted' => $confirmes,
        'playTestDeclarations' => $declarations,
        'officialPlayTestCount' => $statutPlay['officialCount'],
        'officialPlayTestCheckedAt' => $statutPlay['checkedAt'],
    ]);
}

if ($action === 'admin_publish_version') {
    requireAdmin($payload);
    $essai = json_decode((string)@file_get_contents(__DIR__ . '/latest-test.json'), true);
    if (!is_array($essai) || !isset($essai['versionCode'], $essai['url'])) {
        reply(['ok' => false, 'error' => 'NO_TEST', 'message' => 'Aucune version de test a publier.'], 404);
    }
    $stable = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
    if (is_array($stable) && (int)($stable['versionCode'] ?? 0) >= (int)$essai['versionCode']) {
        reply(['ok' => false, 'error' => 'ALREADY', 'message' => 'Cette version est deja publiee.'], 409);
    }
    if (@file_put_contents(__DIR__ . '/latest.json', json_encode($essai, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) === false) {
        reply(['ok' => false, 'error' => 'WRITE', 'message' => 'Ecriture impossible sur le serveur.'], 500);
    }

    $pdo = db();
    $ids = $pdo->query('SELECT id FROM c406_members WHERE banned_at IS NULL AND verified_at IS NOT NULL AND approved_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
    $envoyes = pushToMembers($pdo, $ids, 'Mise a jour ' . (string)($essai['versionName'] ?? '') . ' disponible',
        (string)($essai['notes'] ?? 'Ouvrez l application pour l installer.'), ['type' => 'app-update']);
    reply(['ok' => true, 'sent' => $envoyes, 'message' => 'Version publiee pour tout le club (' . $envoyes . ' notification(s)).']);
}

if ($action === 'club_feed') {
    $member = sessionMember(isset($payload['session']) ? (string)$payload['session'] : null);
    $pdo = db();
    if ($member === null || $pdo === null) {
        reply(['ok' => false, 'error' => 'FORBIDDEN', 'message' => 'Session expiree.'], 403);
    }
    ensureTables($pdo);

    // Le fil rassemble ce qui vient d arriver dans le club, toutes rubriques
    // confondues : sorties, pieces, coups de main, actualites, voitures recensees.
    $fil = [];

    // Une sortie a venir est l information la plus utile du fil : on la garde
    // en tete, de la plus proche a la plus lointaine.
    foreach ($pdo->query("SELECT title, city, event_date, created_at FROM c406_events ORDER BY event_date ASC LIMIT 6") as $l) {
        $date = (string)$l['event_date'];
        $avenir = $date !== '' && strtotime($date) !== false && strtotime($date) >= strtotime('today');
        $fil[] = ['genre' => 'sortie', 'titre' => (string)$l['title'],
            'detail' => trim((string)$l['city'] . ' · ' . $date, ' ·'),
            'at' => (int)$l['created_at'],
            'epingle' => $avenir ? 1 : 0,
            'quand' => $date !== '' && strtotime($date) !== false ? strtotime($date) : PHP_INT_MAX];
    }
    // On annonce qui vend ou qui cherche : le prenom suffit entre membres.
    $piecesLues = $pdo->query("SELECT p.id, p.title, p.price, p.city, p.region, p.created_at, p.wanted, m.name
        FROM c406_parts p LEFT JOIN c406_members m ON m.id = p.member_id
        WHERE p.status = 'open' ORDER BY p.id DESC LIMIT 5");
    foreach ($piecesLues as $l) {
        $recherche = ((int)$l['wanted']) === 1;
        $prenom = preg_split('/\s+/', trim((string)($l['name'] ?? '')))[0] ?? '';
        // « Raphael recherche : Capot » se lit mieux que « Capot — recherche ».
        // Sans prenom connu, la phrase tient quand meme.
        $sujet = $prenom !== ''
            ? $prenom . ($recherche ? ' recherche' : ' vend')
            : ($recherche ? 'Recherche' : 'En vente');
        // Une annonce sans prix ne doit pas laisser de separateur vide.
        $morceaux = array_filter([(string)$l['price'], (string)$l['city']],
            static fn ($morceau) => trim((string)$morceau) !== '');
        $fil[] = ['genre' => $recherche ? 'recherche' : 'piece',
            'titre' => $sujet . ' : ' . (string)$l['title'],
            'detail' => implode(' · ', $morceaux), 'at' => (int)$l['created_at'],
            // De quoi ouvrir l annonce exacte quand on touche la ligne du fil.
            'id' => (int)$l['id'], 'region' => (string)$l['region']];
    }
    foreach ($pdo->query("SELECT title, category, city, created_at FROM c406_services WHERE status = 'open' ORDER BY id DESC LIMIT 5") as $l) {
        $fil[] = ['genre' => 'service', 'titre' => (string)$l['title'],
            'detail' => trim((string)$l['category'] . ' · ' . (string)$l['city'], ' ·'), 'at' => (int)$l['created_at']];
    }
    foreach ($pdo->query("SELECT id, title, body, created_at FROM c406_news ORDER BY id DESC LIMIT 3") as $l) {
        $corps = trim((string)$l['body']);
        $resume = mb_strlen($corps) > 100 ? mb_substr($corps, 0, 100) . '…' : $corps;
        $lecture = $pdo->prepare('SELECT 1 FROM c406_news_reads WHERE news_id = ? AND member_id = ? LIMIT 1');
        $lecture->execute([(int)$l['id'], $member['id']]);
        $fil[] = ['genre' => 'actualite', 'id' => (int)$l['id'],
            'titre' => (string)$l['title'], 'detail' => $resume,
            'body' => $corps, 'at' => (int)$l['created_at'], 'unread' => !$lecture->fetchColumn()];
    }
    $compteNouvelles = $pdo->prepare('SELECT COUNT(*) FROM c406_news n
        WHERE NOT EXISTS (SELECT 1 FROM c406_news_reads r WHERE r.news_id = n.id AND r.member_id = ?)');
    $compteNouvelles->execute([$member['id']]);
    $nouvellesNonLues = (int)$compteNouvelles->fetchColumn();
    // D abord les sorties a venir, la plus proche en premier ; ensuite le reste
    // par ordre d arrivee, la nouveaute en haut.
    usort($fil, static function ($a, $b) {
        $epingle = static fn ($item) => (int)($item['epingle'] ?? 0);
        if ($epingle($a) !== $epingle($b)) {
            return $epingle($b) <=> $epingle($a);
        }
        if ($epingle($a) === 1) {
            return ($a['quand'] ?? PHP_INT_MAX) <=> ($b['quand'] ?? PHP_INT_MAX);
        }
        return $b['at'] <=> $a['at'];
    });
    // Pour l administrateur : combien de demandes attendent une reponse.
    $demandes = ($member['role'] ?? '') === 'admin'
        ? (int)$pdo->query('SELECT COUNT(*) FROM c406_members
             WHERE verified_at IS NOT NULL AND approved_at IS NULL AND refused_at IS NULL AND banned_at IS NULL')->fetchColumn()
        : 0;
    $photosEnAttente = ($member['role'] ?? '') === 'admin'
        ? (int)$pdo->query("SELECT COUNT(*) FROM c406_listing_photos WHERE status = 'pending'")->fetchColumn()
        : 0;
    $liensVideoEnAttente = ($member['role'] ?? '') === 'admin'
        ? (int)$pdo->query("SELECT COUNT(DISTINCT video_id) FROM c406_guide_video_reports WHERE status = 'pending'")->fetchColumn()
        : 0;
    $stlReportsPending = ($member['role'] ?? '') === 'admin'
        ? (int)$pdo->query("SELECT COUNT(DISTINCT model_id) FROM c406_stl_reports WHERE status = 'pending'")->fetchColumn()
        : 0;
    // Reglages utiles a l ecran d accueil, la page Facebook du club notamment.
    $reglages = [];
    foreach ($pdo->query('SELECT cle, valeur FROM c406_settings') as $ligne) {
        $reglages[(string)$ligne['cle']] = (string)$ligne['valeur'];
    }
    // Le role voyage avec le fil : si le telephone garde en memoire un profil
    // perime, l application s aligne sur ce que dit le serveur, et l ecran
    // d administration disparait aussitot pour qui n y a pas droit.
    reply(['ok' => true, 'feed' => array_slice($fil, 0, 4), 'unreadNews' => $nouvellesNonLues, 'demandes' => $demandes,
           'photosEnAttente' => $photosEnAttente,
           'liensVideoEnAttente' => $liensVideoEnAttente,
           'stlReportsPending' => $stlReportsPending,
           'role' => (string)($member['role'] ?? 'member'), 'reglages' => $reglages]);
}

reply(['ok' => false, 'error' => 'UNKNOWN_ACTION', 'message' => 'Action inconnue.'], 400);
