<?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

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;
// 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;
}

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'] ?? '') === '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.']);
}

$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'] ?? ''));
$versionPubliee = json_decode((string)@file_get_contents(__DIR__ . '/latest.json'), true);
$codeMinimum = max(MINIMUM_APP_CODE, (int)($versionPubliee['versionCode'] ?? 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]);
        } 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;
}

/** 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,
        'minimumVersionCode' => $codeMinimum,
    ];

    $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);
    }
    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'])]);
}

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 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'] ?? ''),
    ];
    // La fiche admin montre aussi le garage sans exposer le VIN ni la plaque.
    // Cela permet d'identifier la bonne personne avant de lui écrire.
    $voitures = $pdo->prepare('SELECT motor_id, year, color, gearbox, phase, trim_label
        FROM c406_registry WHERE member_id = ? ORDER BY id ASC');
    $voitures->execute([(string)$row['id']]);
    $fiche['cars'] = array_map(static fn(array $voiture): array => [
        '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'],
    ], $voitures->fetchAll());
    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.']);
}

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']);
    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_ideas WHERE member_id = ?',
        'DELETE FROM c406_members WHERE id = ?',
    ] as $requete) {
        $pdo->prepare($requete)->execute(array_fill(0, substr_count($requete, '?'), $member['id']));
    }
    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) VALUES (?, ?, ?)
                       ON DUPLICATE KEY UPDATE value = VALUES(value)')
            ->execute([$idee, $member['id'], $valeur > 0 ? 1 : -1]);
    }
    $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'],
            // 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'],
            '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'],
            '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,
            '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);
    if ($genre === 'service') {
        $pdo->prepare('INSERT INTO c406_services (member_id, title, category, description, city, region, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
            ->execute([$member['id'], mb_substr($titre, 0, 160), mb_substr((string)($payload['category'] ?? ''), 0, 60), mb_substr($texte, 0, 3000), $ville, $region, time()]);
    } 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) 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]);
    } elseif ($genre === 'event') {
        $pdo->prepare('INSERT INTO c406_events (member_id, title, event_date, city, region, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
            ->execute([$member['id'], mb_substr($titre, 0, 160), mb_substr((string)($payload['date'] ?? ''), 0, 20), $ville, $region, mb_substr($texte, 0, 3000), time()]);
        $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);
    }
    // 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.']);
}

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 member_id 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);
    }
    $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()]);
    reply(['ok' => true]);
}

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]);
}

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 = ?, show_city = ?, car_label = ? WHERE id = ?')
            ->execute([$visible, $ville, $voiture, $member['id']]);
    } else {
        $pdo->prepare('UPDATE c406_members SET lat = ?, lon = ?, location_at = ?, discoverable = ?, show_city = ?, car_label = ? WHERE id = ?')
            ->execute([$latitude, $longitude, time(), $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';
    // 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, (int)($payload['id'] ?? 0)];
    if ($condition !== '') {
        $parametres[] = $member['id'];
    }
    $requete = $pdo->prepare("UPDATE {$table} SET status = ?, closed_at = ? 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, 255), 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);
        }
    }
    $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)
        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)')
        ->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()]);
    $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 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'],
            '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);

    /** 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;
    };

    // Motorisations : ce que les membres ont reellement recense.
    $moteurs = [];
    $phases = ['PH1' => 0, 'PH2' => 0];
    foreach ($pdo->query('SELECT motor_id, phase FROM c406_registry') 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.
    $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") 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];
        }
    }

    reply(['ok' => true,
        'membres' => $membres,
        'voitures' => $voitures,
        'moteurs' => $parts($moteurs, $voitures),
        'phases' => $parts($phases, $voitures),
        'ages' => $agesTries,
        'genres' => $parts($genres, $membres),
    ]);
}

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;
    // 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,
           'role' => (string)($member['role'] ?? 'member'), 'reglages' => $reglages]);
}

reply(['ok' => false, 'error' => 'UNKNOWN_ACTION', 'message' => 'Action inconnue.'], 400);
