<?php

namespace App\Controller;

use App\Entity\ActivityLog;
use App\Entity\ScanSession;
use App\Entity\User;
use App\Repository\ScanSessionRepository;
use App\Security\Voter\ScanVoter;
use App\Service\ActivityLogger;
use App\Service\AttachmentStorage;
use App\Service\ConcessionsProvider;
use App\Service\ImageStorage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
 * API du jeu de travail personnel (scans créés par l'utilisateur, modèle offline-first du client)
 * + actions serveur sur les scans visibles (changement de statut, archivage, suppression),
 * protégées par le ScanVoter.
 */
#[IsGranted('ROLE_USER')]
final class HistoryController extends AbstractController
{
    #[Route('/api/history', name: 'app_history_list', methods: ['GET'])]
    public function list(ScanSessionRepository $repo): JsonResponse
    {
        // Jeu personnel : uniquement les scans créés par l'utilisateur (sync client).
        $sessions = $repo->findRecentByEmail($this->getUserEmail(), 100);

        $data = array_map(static fn (ScanSession $s) => [
            'id' => $s->getId(),
            'createdAt' => $s->getCreatedAt()->format(\DateTimeInterface::ATOM),
            'label' => $s->getLabel(),
            'count' => $s->getCount(),
            'totalFiles' => $s->getTotalFiles(),
            'results' => $s->getResults(),
            'status' => $s->getStatus(),
            'locationNote' => $s->getLocationNote(),
        ], $sessions);

        return $this->json($data);
    }

    #[Route('/api/history', name: 'app_history_save', methods: ['POST'])]
    public function save(Request $request, ScanSessionRepository $repo, EntityManagerInterface $em, ActivityLogger $activity, \App\Service\S3Settings $s3): JsonResponse
    {
        $this->denyAccessUnlessGranted(ScanVoter::CREATE);

        $data = json_decode($request->getContent(), true);

        if (!\is_array($data)
            || empty($data['id']) || !\is_string($data['id'])
            || empty($data['createdAt']) || !\is_string($data['createdAt'])
            || !\is_array($data['results'] ?? null)
        ) {
            return $this->json(['error' => 'Session invalide'], Response::HTTP_BAD_REQUEST);
        }

        try {
            $createdAt = new \DateTimeImmutable($data['createdAt']);
        } catch (\Exception) {
            return $this->json(['error' => 'Session invalide'], Response::HTTP_BAD_REQUEST);
        }

        // Upsert par id, restreint au créateur authentifié (l'email vient de la session, jamais du client).
        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneForUser($data['id'], $user->getUserIdentifier());
        $isNew = false;
        if (null === $session) {
            // L'id n'appartient pas à l'utilisateur : s'il existe déjà (autre créateur), on refuse
            // proprement (sinon collision de clé primaire → 500). Sinon, création.
            if (null !== $repo->findOneById($data['id'])) {
                return $this->json(['error' => 'Conflit d\'identifiant de session'], Response::HTTP_CONFLICT);
            }
            $session = new ScanSession($data['id'], $user->getUserIdentifier());
            $isNew = true;
        }

        // Concessions par session (priorité au choix du scan), fallback sur celles du profil.
        $clientConcessions = $data['concessions'] ?? null;
        $sessionConcessions = \is_array($clientConcessions) ? $clientConcessions : $user->getConcessions();

        // Rôles à portée limitée : un scan ne peut concerner que leurs propres concessions.
        if (!$user->seesAllConcessions()) {
            $allowed = $user->getConcessions();
            $sessionConcessions = array_values(array_filter(
                $sessionConcessions,
                static fn ($c) => \is_string($c) && \in_array($c, $allowed, true),
            ));
        }

        // Normalisation serveur : on recalcule count / totalFiles / pairIndex à partir des résultats
        // plutôt que de faire confiance aux compteurs du client (sinon stats du dashboard faussées).
        $results = array_values($data['results']);
        $files = 0;
        foreach ($results as $i => &$r) {
            if (\is_array($r)) {
                $r['pairIndex'] = $i;
                if (!empty($r['plateImageId'])) {
                    ++$files;
                }
                if (!empty($r['vinImageId'])) {
                    ++$files;
                }
                if (!empty($r['photoImageId'])) {
                    ++$files;
                }
            }
        }
        unset($r);

        $session->setCreatedAt($createdAt)
            ->setLabel((string) ($data['label'] ?? ''))
            ->setCount(\count($results))
            ->setTotalFiles($files)
            ->setResults($results)
            ->setLocationNote(\is_string($data['locationNote'] ?? null) ? $data['locationNote'] : null)
            ->setScanType(\is_string($data['scanType'] ?? null) ? $data['scanType'] : null)
            ->setConcessions($sessionConcessions);

        // Signature manuscrite (apposée au lancement du scan) + sa date. On ne l'écrase que
        // si le client fournit une nouvelle image valide (ne perd pas une signature existante
        // lors d'une éventuelle resynchronisation sans signature).
        $signature = $data['signature'] ?? null;
        if (\is_string($signature) && str_starts_with($signature, 'data:image/')) {
            $session->setSignature($signature);
            $signedAt = $data['signatureDate'] ?? null;
            if (\is_string($signedAt) && '' !== $signedAt) {
                try {
                    $session->setSignatureDate(new \DateTimeImmutable($signedAt));
                } catch (\Exception) {
                    $session->setSignatureDate($createdAt);
                }
            } else {
                $session->setSignatureDate($createdAt);
            }
        }

        // Statut : le changement d'état est réservé à l'endpoint dédié PATCH /api/history/{id}/status,
        // qui applique les voters (SUBMIT / CHANGE_STATUS) et la règle « VIN obligatoire ». Cet endpoint
        // de synchronisation (offline-first) ne peut donc PAS servir à valider/annuler/rouvrir un scan :
        // un nouveau scan naît « En cours » ; un scan existant conserve son statut serveur.
        if ($isNew) {
            $session->setStatus(ScanSession::STATUS_EN_COURS);
            // Mode S3 : on fige le dossier S3 de la session (les photos y ont été
            // téléversées par le navigateur). Clé de chaque image = <s3Prefix>/<imageId>.jpg.
            if ($s3->isStorageEnabled()) {
                $session->setS3Prefix($s3->sessionPrefix(
                    $user->getUserIdentifier(),
                    $createdAt,
                    (string) ($data['label'] ?? ''),
                    $session->getId(),
                ));
            }
        }

        $em->persist($session);
        $em->flush();

        if ($isNew) {
            $activity->log(ActivityLog::SCAN_CREATE, $session, [
                'concessions' => $session->getConcessions(),
                'vehicules' => $session->getCount(),
            ]);
        }

        return $this->json(['success' => true]);
    }

    /**
     * Fusionne plusieurs inventaires visibles en un NOUVEAU scan « En cours » :
     * concatène leurs véhicules (en copiant les photos vers le nouveau dossier de session)
     * et l'union de leurs concessions. Les inventaires source sont conservés intacts.
     */
    #[Route('/api/history/merge', name: 'app_history_merge', methods: ['POST'])]
    public function merge(Request $request, ScanSessionRepository $repo, EntityManagerInterface $em, ImageStorage $images, ActivityLogger $activity, \App\Service\S3Settings $s3): JsonResponse
    {
        $this->denyAccessUnlessGranted(ScanVoter::CREATE);

        $data = json_decode($request->getContent(), true);
        $ids = \is_array($data) ? ($data['ids'] ?? null) : null;
        if (!\is_array($ids)) {
            return $this->json(['error' => 'Sélection invalide.'], Response::HTTP_BAD_REQUEST);
        }
        $ids = array_values(array_unique(array_filter($ids, static fn ($v) => \is_string($v) && '' !== $v)));
        if (\count($ids) < 2) {
            return $this->json(['error' => 'Sélectionnez au moins deux inventaires à fusionner.'], Response::HTTP_BAD_REQUEST);
        }
        if (\count($ids) > 50) {
            return $this->json(['error' => 'Trop d’inventaires sélectionnés (50 maximum).'], Response::HTTP_BAD_REQUEST);
        }

        /** @var User $user */
        $user = $this->getUser();
        $scope = $user->scopeConcessions();

        $sources = [];
        foreach ($ids as $sid) {
            $s = $repo->findOneInScope($sid, $scope);
            if (null !== $s) {
                $sources[] = $s;
            }
        }
        if (\count($sources) < 2) {
            return $this->json(['error' => 'Inventaires introuvables ou hors de votre périmètre.'], Response::HTTP_NOT_FOUND);
        }

        $newId = self::uuid4();
        $newEmail = $user->getUserIdentifier();
        $merged = new ScanSession($newId, $newEmail);
        // Date en UTC pour rester cohérent avec les scans (le client les date via toISOString,
        // donc en UTC) : sinon la fusion, datée à l'heure locale serveur (Paris), apparaîtrait
        // mal placée dans la liste triée par date de création.
        $merged->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));

        $results = [];
        $concessions = [];
        foreach ($sources as $src) {
            $creator = $src->getUserEmail();
            $srcOnS3 = $src->isOnS3();
            foreach ($src->getResults() as $r) {
                if (!\is_array($r)) {
                    continue;
                }
                foreach (['plateImageId' => 'plateS3Key', 'vinImageId' => 'vinS3Key'] as $idKey => $s3KeyField) {
                    $imgId = $r[$idKey] ?? null;
                    if (!\is_string($imgId) || '' === $imgId) {
                        continue;
                    }
                    if ($srcOnS3) {
                        // Source sur S3 : on ne peut pas copier l'objet côté serveur (pas d'accès
                        // réseau S3). On RÉFÉRENCE la clé S3 de la source (objet partagé) ; la photo
                        // reste résolue par image_url(session, imageId, s3Key). Si la source portait
                        // déjà une clé héritée (fusion de fusion), on la conserve.
                        $r[$s3KeyField] = \is_string($r[$s3KeyField] ?? null) && '' !== $r[$s3KeyField]
                            ? $r[$s3KeyField]
                            : $s3->objectKey((string) $src->getS3Prefix(), $imgId);
                    } else {
                        // Source locale : copie classique vers le dossier de la nouvelle session.
                        $images->copyImage($creator, $src->getId(), $imgId, $newEmail, $newId);
                    }
                }
                $results[] = $r;
            }
            foreach ($src->getConcessions() as $c) {
                if (\is_string($c) && '' !== $c && !\in_array($c, $concessions, true)) {
                    $concessions[] = $c;
                }
            }
        }

        // Rôles à portée limitée : on ne conserve que leurs propres concessions (cohérent avec save()).
        if (!$user->seesAllConcessions()) {
            $allowed = $user->getConcessions();
            $concessions = array_values(array_filter($concessions, static fn ($c) => \in_array($c, $allowed, true)));
        }

        // Réindexe pairIndex + recompte véhicules / fichiers.
        $files = 0;
        foreach ($results as $i => &$r) {
            $r['pairIndex'] = $i;
            if (!empty($r['plateImageId'])) {
                ++$files;
            }
            if (!empty($r['vinImageId'])) {
                ++$files;
            }
        }
        unset($r);

        $label = \is_string($data['label'] ?? null) && '' !== trim((string) $data['label'])
            ? trim((string) $data['label'])
            : sprintf('Fusion de %d inventaires', \count($sources));

        $merged->setLabel($label)
            ->setResults($results)
            ->setCount(\count($results))
            ->setTotalFiles($files)
            ->setConcessions($concessions)
            ->setStatus(ScanSession::STATUS_EN_COURS);

        $em->persist($merged);
        $em->flush();

        $activity->log(ActivityLog::SCAN_CREATE, $merged, [
            'fusion_de' => array_map(static fn (ScanSession $s) => $s->getId(), $sources),
            'vehicules' => $merged->getCount(),
        ]);

        return $this->json(['success' => true, 'id' => $newId]);
    }

    #[Route('/api/history/{id}/status', name: 'app_history_status', methods: ['PATCH'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function updateStatus(string $id, Request $request, ScanSessionRepository $repo, EntityManagerInterface $em, ActivityLogger $activity, \App\Service\VehicleErrorChecker $errors): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $status = \is_array($data) ? ($data['status'] ?? null) : null;
        if (!\is_string($status) || !\in_array($status, ScanSession::ALL_STATUSES, true)) {
            return $this->json(['error' => 'Statut invalide'], Response::HTTP_BAD_REQUEST);
        }

        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            return $this->json(['error' => 'Session introuvable'], Response::HTTP_NOT_FOUND);
        }
        // L'OPERATEUR peut soumettre son travail à la relecture (en_cours → a_valider).
        // Réouverture (retour à « En cours ») :
        //  - depuis « À valider » → autorisée aux porteurs de SCAN_CHANGE_STATUS
        //    (SUPERVISEUR en périmètre, AUDIT, ADMIN) : on peut renvoyer un inventaire soumis
        //    en correction (l'OPERATEUR, lui, ne peut pas revenir en arrière) ;
        //  - depuis « Validé » ou « Annulé » → réservée à l'ADMIN (plus sensible).
        // Toutes les autres transitions restent réservées à SCAN_CHANGE_STATUS.
        if (ScanSession::STATUS_A_VALIDER === $status && ScanSession::STATUS_EN_COURS === $session->getStatus()) {
            $this->denyAccessUnlessGranted(ScanVoter::SUBMIT, $session);
        } elseif (ScanSession::STATUS_EN_COURS === $status && ScanSession::STATUS_A_VALIDER === $session->getStatus()) {
            $this->denyAccessUnlessGranted(ScanVoter::CHANGE_STATUS, $session);
        } elseif (ScanSession::STATUS_EN_COURS === $status && ScanSession::STATUS_EN_COURS !== $session->getStatus()) {
            $this->denyAccessUnlessGranted('ROLE_ADMIN');
        } else {
            $this->denyAccessUnlessGranted(ScanVoter::CHANGE_STATUS, $session);
        }

        // Aucune erreur tolérée pour soumettre (« À valider ») ou valider : un véhicule est en
        // erreur si sa plaque ou son VIN est manquant/illisible, de format invalide ou en faible
        // confiance (mêmes règles que la surbrillance jaune). Vaut pour TOUS les véhicules, y
        // compris ceux ajoutés manuellement.
        if (\in_array($status, [ScanSession::STATUS_A_VALIDER, ScanSession::STATUS_VALIDE], true)) {
            $bad = [];
            foreach ($session->getResults() as $i => $r) {
                if (\is_array($r) && ($errors->plateInError($r) || $errors->vinInError($r))) {
                    $bad[] = '#' . ($i + 1);
                }
            }
            if ([] !== $bad) {
                return $this->json([
                    'error' => sprintf(
                        'Des véhicules sont en erreur (VIN manquant/illisible, plaque ou VIN au format invalide, ou faible confiance) : %s. Corrigez-les avant de %s.',
                        implode(', ', $bad),
                        ScanSession::STATUS_VALIDE === $status ? 'valider' : 'soumettre à la relecture',
                    ),
                ], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
        }

        $previous = $session->getStatus();
        $session->setStatus($status);
        $em->flush();

        // Trace qui a validé / rouvert / annulé / soumis, et depuis quel statut.
        if ($previous !== $status) {
            $activity->log(ActivityLog::SCAN_STATUS, $session, ['from' => $previous, 'to' => $status]);
        }

        return $this->json(['success' => true, 'status' => $status]);
    }

    /**
     * Renommage d'un inventaire (libellé). Autorisé si l'utilisateur peut éditer le scan
     * (EDIT_DOCUMENTS, donc « En cours » et en périmètre — opérateur inclus) OU s'il peut
     * changer son statut (CHANGE_STATUS, donc valideur sur n'importe quel statut). Journalisé.
     */
    #[Route('/api/history/{id}/label', name: 'app_history_label', methods: ['PATCH'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function updateLabel(string $id, Request $request, ScanSessionRepository $repo, EntityManagerInterface $em, ActivityLogger $activity): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $label = \is_array($data) ? trim((string) ($data['label'] ?? '')) : '';
        if ('' === $label) {
            return $this->json(['error' => 'Le nom ne peut pas être vide.'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }
        $label = mb_substr($label, 0, 255);

        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            return $this->json(['error' => 'Session introuvable'], Response::HTTP_NOT_FOUND);
        }
        if (!$this->isGranted(ScanVoter::EDIT_DOCUMENTS, $session) && !$this->isGranted(ScanVoter::CHANGE_STATUS, $session)) {
            throw $this->createAccessDeniedException();
        }

        $previous = $session->getLabel();
        if ($previous === $label) {
            return $this->json(['success' => true, 'label' => $label]);
        }

        $session->setLabel($label);
        $em->flush();

        $activity->log(ActivityLog::SCAN_RENAME, $session, ['from' => $previous, 'to' => $label]);

        return $this->json(['success' => true, 'label' => $label]);
    }

    /**
     * Modification du lieu d'un inventaire, possible tant qu'il n'est pas validé
     * (ScanVoter::EDIT_CONCESSIONS). Le lieu est obligatoire : il ne peut pas être vidé.
     */
    #[Route('/api/history/{id}/location', name: 'app_history_location', methods: ['PATCH'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function updateLocation(string $id, Request $request, ScanSessionRepository $repo, EntityManagerInterface $em): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $location = \is_array($data) ? trim((string) ($data['locationNote'] ?? '')) : '';
        if ('' === $location) {
            return $this->json(['error' => 'Le lieu est obligatoire.'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            return $this->json(['error' => 'Session introuvable'], Response::HTTP_NOT_FOUND);
        }
        $this->denyAccessUnlessGranted(ScanVoter::EDIT_CONCESSIONS, $session);

        $session->setLocationNote(mb_substr($location, 0, 255));
        $em->flush();

        return $this->json(['success' => true, 'locationNote' => $session->getLocationNote()]);
    }

    /**
     * Modification du type de scan (VN/VO), possible tant que l'inventaire n'est pas
     * validé (ScanVoter::EDIT_CONCESSIONS).
     */
    #[Route('/api/history/{id}/type', name: 'app_history_type', methods: ['PATCH'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function updateScanType(string $id, Request $request, ScanSessionRepository $repo, EntityManagerInterface $em): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $type = \is_array($data) ? ($data['scanType'] ?? null) : null;
        if (!\in_array($type, ScanSession::SCAN_TYPES, true)) {
            return $this->json(['error' => 'Type invalide (VN, VO ou VN & VO).'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            return $this->json(['error' => 'Session introuvable'], Response::HTTP_NOT_FOUND);
        }
        $this->denyAccessUnlessGranted(ScanVoter::EDIT_CONCESSIONS, $session);

        $session->setScanType($type);
        $em->flush();

        return $this->json(['success' => true, 'scanType' => $session->getScanType()]);
    }

    /**
     * Modification des concessions associées à un scan, possible uniquement avant
     * validation (ScanVoter::EDIT_CONCESSIONS). Chaque changement est journalisé.
     */
    #[Route('/api/history/{id}/concessions', name: 'app_history_concessions', methods: ['PATCH'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function updateConcessions(
        string $id,
        Request $request,
        ScanSessionRepository $repo,
        EntityManagerInterface $em,
        ConcessionsProvider $provider,
        ActivityLogger $activity,
    ): JsonResponse {
        $data = json_decode($request->getContent(), true);
        $requested = \is_array($data) ? ($data['concessions'] ?? null) : null;
        if (!\is_array($requested)) {
            return $this->json(['error' => 'Liste de concessions invalide'], Response::HTTP_BAD_REQUEST);
        }

        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            return $this->json(['error' => 'Session introuvable'], Response::HTTP_NOT_FOUND);
        }
        $this->denyAccessUnlessGranted(ScanVoter::EDIT_CONCESSIONS, $session);

        // Valeurs acceptées : IDs du référentiel + valeurs déjà portées par le scan
        // (anciens noms hérités d'avant la bascule aux IDs, conservables).
        $current = $session->getConcessions();
        $cleaned = [];
        foreach ($requested as $c) {
            if (!\is_string($c) || '' === trim($c)) {
                continue;
            }
            $c = trim($c);
            if (($provider->isValid($c) || \in_array($c, $current, true)) && !\in_array($c, $cleaned, true)) {
                $cleaned[] = $c;
            }
        }

        // Rôles à portée limitée : uniquement leurs propres concessions, et le scan
        // doit rester dans leur périmètre (sinon ils se le rendraient invisible).
        if (!$user->seesAllConcessions()) {
            $allowed = $user->getConcessions();
            $kept = array_values(array_filter($current, static fn (string $c) => !\in_array($c, $allowed, true)));
            $cleaned = array_values(array_unique(array_merge(
                $kept, // concessions hors périmètre déjà présentes : conservées telles quelles
                array_values(array_filter($cleaned, static fn (string $c) => \in_array($c, $allowed, true))),
            )));
            if ([] === array_intersect($cleaned, $allowed)) {
                return $this->json(['error' => 'Le scan doit conserver au moins une de vos concessions.'], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
        }

        if ([] === $cleaned) {
            return $this->json(['error' => 'Sélectionnez au moins une concession.'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        if ($cleaned === $current) {
            return $this->json(['success' => true, 'concessions' => $current]);
        }

        $session->setConcessions($cleaned);
        $em->flush();

        $activity->log(ActivityLog::SCAN_CONCESSIONS, $session, ['from' => $current, 'to' => $cleaned]);

        return $this->json(['success' => true, 'concessions' => $session->getConcessions()]);
    }

    #[Route('/api/history/{id}/archive', name: 'app_history_archive', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function archive(string $id, ScanSessionRepository $repo, EntityManagerInterface $em, ActivityLogger $activity): JsonResponse
    {
        /** @var User $user */
        $user = $this->getUser();
        $session = $repo->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            return $this->json(['error' => 'Session introuvable'], Response::HTTP_NOT_FOUND);
        }
        $this->denyAccessUnlessGranted(ScanVoter::ARCHIVE, $session);

        $session->isArchived() ? $session->unarchive() : $session->archive();
        $em->flush();

        $activity->log($session->isArchived() ? ActivityLog::SCAN_ARCHIVE : ActivityLog::SCAN_UNARCHIVE, $session);

        return $this->json(['success' => true, 'archived' => $session->isArchived()]);
    }

    #[Route('/api/history', name: 'app_history_delete', methods: ['DELETE'])]
    public function delete(
        Request $request,
        ScanSessionRepository $repo,
        EntityManagerInterface $em,
        ImageStorage $images,
        AttachmentStorage $attachments,
        ActivityLogger $activity,
    ): JsonResponse {
        $id = $request->query->get('id');

        if (\is_string($id) && '' !== $id) {
            // Suppression unitaire (ScanVoter::DELETE) : non validé → ADMIN/AUDIT partout,
            // SUPERVISEUR/OPERATEUR sur leurs concessions ; validé → ADMIN uniquement.
            $session = $repo->findOneById($id);
            if (null === $session) {
                return $this->json(['success' => true]);
            }
            $this->denyAccessUnlessGranted(ScanVoter::DELETE, $session);

            $creator = $session->getUserEmail();
            $label = $session->getLabel();
            $status = $session->getStatus();
            $em->remove($session); // cascade → supprime les ScanAttachment
            $em->flush();
            $images->deleteSession($creator, $id);
            $attachments->deleteForScan($id);

            // Le scan n'existe plus : on journalise avec ses métadonnées dénormalisées.
            $activity->log(ActivityLog::SCAN_DELETE, null, [
                'scanId' => $id,
                'label' => $label,
                'createur' => $creator,
                'statut' => $status,
            ]);

            return $this->json(['success' => true]);
        }

        // "Tout effacer" : limité au jeu de travail personnel (scans créés par l'utilisateur).
        $email = $this->getUserEmail();
        $repo->deleteAllForUser($email);
        $images->deleteAllForUser($email);

        $activity->log(ActivityLog::SCAN_DELETE_ALL, null, ['createur' => $email]);

        return $this->json(['success' => true]);
    }

    private function getUserEmail(): string
    {
        /** @var User $user */
        $user = $this->getUser();

        return $user->getUserIdentifier();
    }

    /** UUID v4 (format compatible avec la regex stricte des sessions/images). */
    private static function uuid4(): string
    {
        $d = random_bytes(16);
        $d[6] = \chr((\ord($d[6]) & 0x0F) | 0x40);
        $d[8] = \chr((\ord($d[8]) & 0x3F) | 0x80);

        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($d), 4));
    }
}
