<?php

namespace App\Controller;

use App\Entity\ScanSession;
use App\Ocr\GeminiOcrClient;
use App\Ocr\OcrParser;
use App\Repository\ScanSessionRepository;
use App\Security\Voter\ScanVoter;
use App\Service\ImageStorage;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
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;

/**
 * Édition des paires plaque/VIN d'un inventaire : ajout/remplacement/suppression des photos
 * (avec re-OCR), ajout et suppression de véhicules. Autorisé uniquement tant que le statut est
 * « En cours » (ScanVoter::EDIT_DOCUMENTS), dans le périmètre de l'utilisateur. LECTEUR exclu.
 *
 * Appels via fetch côté client (comme le changement de statut) → réponses JSON. Pas de jeton CSRF
 * de session (cohérent avec les autres mutations JSON de l'app ; protégé par le cookie SameSite=Lax,
 * l'authentification et le voter).
 */
#[IsGranted('ROLE_USER')]
final class ScanPairController extends AbstractController
{
    private const MAX_SIZE = 20 * 1024 * 1024;
    /** slot d'URL => préfixe de champ dans le résultat. */
    private const SLOTS = ['plaque' => 'plate', 'vin' => 'vin'];

    #[Route('/inventaires/{id}/paires/{index}/photo/{slot}', name: 'app_pair_photo_upload', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+', 'slot' => 'plaque|vin'])]
    public function uploadPhoto(
        string $id,
        int $index,
        string $slot,
        Request $request,
        ScanSessionRepository $sessions,
        ImageStorage $storage,
        GeminiOcrClient $gemini,
        OcrParser $parser,
        EntityManagerInterface $em,
        LoggerInterface $logger,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }

        $file = $request->files->get('photo');
        if (!$file instanceof UploadedFile) {
            return $this->json(['error' => 'Aucune photo reçue.'], Response::HTTP_BAD_REQUEST);
        }
        if ($file->getSize() > self::MAX_SIZE) {
            return $this->json(['error' => 'Photo trop volumineuse (max 20 Mo).'], Response::HTTP_REQUEST_ENTITY_TOO_LARGE);
        }

        try {
            $jpegBase64 = $this->toJpegBase64($file);
        } catch (\Throwable $e) {
            $logger->warning('Conversion image échouée', ['exception' => $e]);

            return $this->json(['error' => 'Image illisible ou format non supporté (JPEG, PNG ou WebP).'], Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
        }

        $field = self::SLOTS[$slot];
        $creator = $session->getUserEmail();
        $newImageId = self::uuid4();

        try {
            $storage->store($creator, $id, $newImageId, $jpegBase64);
        } catch (\Throwable $e) {
            $logger->error('Échec stockage photo de paire', ['exception' => $e]);

            return $this->json(['error' => "Échec de l'enregistrement de la photo."], Response::HTTP_INTERNAL_SERVER_ERROR);
        }

        // On retient l'ancienne image pour ne la supprimer qu'APRÈS un flush réussi : si on la
        // supprimait avant et que le flush échouait, la base garderait l'ancien id → image cassée.
        $old = $results[$index][$field . 'ImageId'] ?? null;

        $results[$index][$field . 'ImageId'] = $newImageId;
        $results[$index][$field . 'Filename'] = $file->getClientOriginalName();

        // Re-OCR (non bloquant) : met à jour le texte plaque/VIN détecté.
        $ocr = null;
        try {
            ['raw' => $raw] = $gemini->recognize($jpegBase64, $field);
            $parsed = 'plate' === $field ? $parser->parsePlate($raw) : $parser->parseVin($raw);
            $results[$index][$field] = $parsed->corrected;
            $results[$index]['raw' . ucfirst($field)] = $raw;
            $results[$index][$field . 'Confidence'] = $parsed->confidence;
            $ocr = $parsed->corrected;
        } catch (\Throwable $e) {
            $logger->warning('OCR échoué (photo de paire)', ['exception' => $e]);
        }

        $this->persist($session, $results, $em);

        // Flush réussi : on peut maintenant supprimer l'ancien fichier (remplacement) sans risque d'orphelin.
        if (\is_string($old) && '' !== $old) {
            try {
                $storage->delete($creator, $id, $old);
            } catch (\Throwable $e) {
                $logger->warning('Suppression ancienne photo échouée', ['exception' => $e]);
            }
        }

        return $this->json([
            'success' => true,
            'message' => sprintf('Photo %s du véhicule #%d mise à jour.%s', $slot, $index + 1, null !== $ocr ? ' OCR : ' . $ocr : ''),
        ]);
    }

    /**
     * Remplacement de photo en mode S3 : le navigateur a déjà téléversé la nouvelle image
     * sur S3 (via /api/s3/sign + upload direct) ; il nous transmet son nouvel imageId + l'image
     * en base64 UNIQUEMENT pour relancer l'OCR (le serveur ne stocke rien et ne lit pas S3).
     * L'ancien objet S3 devient orphelin (pas de suppression possible côté serveur).
     */
    #[Route('/inventaires/{id}/paires/{index}/photo/{slot}/s3', name: 'app_pair_photo_set_s3', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+', 'slot' => 'plaque|vin'])]
    public function setPhotoS3(
        string $id,
        int $index,
        string $slot,
        Request $request,
        ScanSessionRepository $sessions,
        GeminiOcrClient $gemini,
        OcrParser $parser,
        EntityManagerInterface $em,
        LoggerInterface $logger,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);
        if (!$session->isOnS3()) {
            return $this->json(['error' => 'Cet inventaire n\'est pas stocké sur S3.'], Response::HTTP_CONFLICT);
        }

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }

        $payload = json_decode($request->getContent(), true);
        $newImageId = \is_array($payload) ? ($payload['imageId'] ?? null) : null;
        $image = \is_array($payload) ? ($payload['image'] ?? null) : null;
        if (!\is_string($newImageId) || !ImageStorage::isValidId($newImageId)) {
            return $this->json(['error' => 'Identifiant d\'image invalide.'], Response::HTTP_BAD_REQUEST);
        }

        $field = self::SLOTS[$slot];
        $results[$index][$field . 'ImageId'] = $newImageId;
        $results[$index][$field . 'Filename'] = \is_string($payload['filename'] ?? null) ? $payload['filename'] : null;
        // Une photo native de la session vit sous son s3Prefix → on retire toute clé héritée d'une fusion.
        unset($results[$index][$field . 'S3Key']);

        // Re-OCR (non bloquant) à partir de l'image base64 transmise pour l'occasion.
        $ocr = null;
        if (\is_string($image) && '' !== $image) {
            try {
                ['raw' => $raw] = $gemini->recognize($image, $field);
                $parsed = 'plate' === $field ? $parser->parsePlate($raw) : $parser->parseVin($raw);
                $results[$index][$field] = $parsed->corrected;
                $results[$index]['raw' . ucfirst($field)] = $raw;
                $results[$index][$field . 'Confidence'] = $parsed->confidence;
                $ocr = $parsed->corrected;
            } catch (\Throwable $e) {
                $logger->warning('OCR échoué (photo S3)', ['exception' => $e]);
            }
        }

        $this->persist($session, $results, $em);

        return $this->json([
            'success' => true,
            'message' => sprintf('Photo %s du véhicule #%d mise à jour.%s', $slot, $index + 1, null !== $ocr ? ' OCR : ' . $ocr : ''),
        ]);
    }

    #[Route('/inventaires/{id}/paires/{index}/valeur/{slot}', name: 'app_pair_value_edit', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+', 'slot' => 'plaque|vin'])]
    public function editValue(
        string $id,
        int $index,
        string $slot,
        Request $request,
        ScanSessionRepository $sessions,
        OcrParser $parser,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }

        $payload = json_decode($request->getContent(), true);
        $value = \is_array($payload) ? trim((string) ($payload['value'] ?? '')) : '';

        $field = self::SLOTS[$slot];

        if ('' === $value) {
            // Saisie vide → on efface la valeur (sans toucher à la photo).
            $results[$index][$field] = null;
            $results[$index]['raw' . ucfirst($field)] = '';
            $results[$index][$field . 'Confidence'] = 'none';
            $message = sprintf('Valeur %s du véhicule #%d effacée.', $slot, $index + 1);
        } else {
            // Saisie manuelle normalisée par le même correcteur que l'OCR (format + confiance cohérents).
            $parsed = 'plate' === $field ? $parser->parsePlate($value) : $parser->parseVin($value);
            $results[$index][$field] = $parsed->corrected;
            $results[$index]['raw' . ucfirst($field)] = $value;
            $results[$index][$field . 'Confidence'] = $parsed->confidence;
            $message = sprintf('Valeur %s du véhicule #%d enregistrée : %s', $slot, $index + 1, $parsed->corrected ?? $value);
        }

        $this->persist($session, $results, $em);

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

    #[Route('/inventaires/{id}/paires/{index}/photo/{slot}/supprimer', name: 'app_pair_photo_delete', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+', 'slot' => 'plaque|vin'])]
    public function deletePhoto(
        string $id,
        int $index,
        string $slot,
        ScanSessionRepository $sessions,
        ImageStorage $storage,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }

        $field = self::SLOTS[$slot];
        $old = $results[$index][$field . 'ImageId'] ?? null;
        if (\is_string($old) && '' !== $old) {
            $storage->delete($session->getUserEmail(), $id, $old);
        }
        $results[$index][$field . 'ImageId'] = null;
        $results[$index][$field . 'Filename'] = null;

        $this->persist($session, $results, $em);

        return $this->json(['success' => true, 'message' => sprintf('Photo %s du véhicule #%d supprimée.', $slot, $index + 1)]);
    }

    #[Route('/inventaires/{id}/paires/{from}/photo/{slot}/deplacer/{to}', name: 'app_pair_photo_move', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'from' => '\d+', 'to' => '\d+', 'slot' => 'plaque|vin'])]
    public function movePhoto(
        string $id,
        int $from,
        string $slot,
        int $to,
        ScanSessionRepository $sessions,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$from]) || !\is_array($results[$from]) || !isset($results[$to]) || !\is_array($results[$to])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }
        if ($from === $to) {
            return $this->json(['error' => 'La photo est déjà sur ce véhicule.'], Response::HTTP_BAD_REQUEST);
        }

        $field = self::SLOTS[$slot];
        if (empty($results[$from][$field . 'ImageId'])) {
            return $this->json(['error' => sprintf('Le véhicule #%d n\'a pas de photo %s.', $from + 1, $slot)], Response::HTTP_BAD_REQUEST);
        }
        if (!empty($results[$to][$field . 'ImageId'])) {
            return $this->json(['error' => sprintf('Le véhicule #%d a déjà une photo %s. Supprimez-la d\'abord.', $to + 1, $slot)], Response::HTTP_CONFLICT);
        }

        // Déplace tous les champs du slot (image, clé S3 éventuelle, texte OCR, confiance, date, GPS) vers la cible.
        $empty = self::emptyPair(0);
        foreach ([$field, 'raw' . ucfirst($field), $field . 'Confidence', $field . 'Filename', $field . 'Date', $field . 'Latitude', $field . 'Longitude', $field . 'ImageId', $field . 'S3Key'] as $key) {
            $results[$to][$key] = $results[$from][$key] ?? null;
            $results[$from][$key] = $empty[$key] ?? null;
        }

        // Si le véhicule source n'a plus aucune photo, on le supprime (fusion).
        $sourceRemoved = empty($results[$from]['plateImageId']) && empty($results[$from]['vinImageId']);
        if ($sourceRemoved) {
            unset($results[$from]);
        }

        $this->persist($session, $results, $em);

        return $this->json([
            'success' => true,
            'message' => sprintf(
                'Photo %s déplacée vers le véhicule #%d.%s',
                $slot,
                $to + 1,
                $sourceRemoved ? sprintf(' Le véhicule #%d, devenu vide, a été supprimé.', $from + 1) : ''
            ),
            // Infos pour le bouton « Annuler le déplacement » côté client :
            // index de la cible APRÈS réindexation (décalé si le véhicule source a été supprimé avant elle).
            'sourceRemoved' => $sourceRemoved,
            'targetIndex' => $sourceRemoved && $from < $to ? $to - 1 : $to,
        ]);
    }

    #[Route('/inventaires/{id}/paires/ajouter', name: 'app_pair_add', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function addPair(
        string $id,
        ScanSessionRepository $sessions,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        // Véhicule ajouté manuellement : marqué `manual` (seuls ces véhicules sont supprimables)
        // et pré-rempli d'un commentaire de justification.
        $new = self::emptyPair(\count($results));
        $new['manual'] = true;
        $new['comment'] = "Lieu :\nRelevé par : \nJustification non vu lors du relevé :";
        $results[] = $new;
        $this->persist($session, $results, $em);

        return $this->json(['success' => true, 'message' => 'Véhicule ajouté.']);
    }

    #[Route('/inventaires/{id}/paires/dedupe', name: 'app_pair_dedupe', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}'])]
    public function dedupe(
        string $id,
        ScanSessionRepository $sessions,
        ImageStorage $storage,
        EntityManagerInterface $em,
        \App\Service\VehicleErrorChecker $errors,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        $toRemove = $errors->redundantIndexes($results);
        if ([] === $toRemove) {
            return $this->json(['success' => true, 'removed' => 0, 'message' => 'Aucun doublon à supprimer.']);
        }

        $creator = $session->getUserEmail();
        foreach ($toRemove as $index) {
            if (!isset($results[$index]) || !\is_array($results[$index])) {
                continue;
            }
            foreach (['plateImageId', 'vinImageId'] as $key) {
                $img = $results[$index][$key] ?? null;
                if (\is_string($img) && '' !== $img) {
                    $storage->delete($creator, $id, $img);
                }
            }
            unset($results[$index]);
        }

        $this->persist($session, $results, $em);

        $n = \count($toRemove);

        return $this->json([
            'success' => true,
            'removed' => $n,
            'message' => sprintf('%d doublon%s supprimé%s.', $n, $n > 1 ? 's' : '', $n > 1 ? 's' : ''),
        ]);
    }

    #[Route('/inventaires/{id}/paires/{index}/commentaire', name: 'app_pair_comment', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+'])]
    public function editVehicleComment(
        string $id,
        int $index,
        Request $request,
        ScanSessionRepository $sessions,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }

        $payload = json_decode($request->getContent(), true);
        $comment = \is_array($payload) ? trim((string) ($payload['comment'] ?? '')) : '';
        $results[$index]['comment'] = '' === $comment ? null : mb_substr($comment, 0, 2000);

        $this->persist($session, $results, $em);

        return $this->json(['success' => true, 'message' => sprintf('Commentaire du véhicule #%d enregistré.', $index + 1)]);
    }

    /**
     * Correction manuelle de la marque / du modèle (déduits par l'IA depuis le VIN + les photos).
     * Une valeur vide efface le champ. La correction manuelle retire le % de confiance de l'IA
     * (la valeur est désormais validée par l'utilisateur).
     */
    #[Route('/inventaires/{id}/paires/{index}/marque-modele', name: 'app_pair_model_edit', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+'])]
    public function editModel(
        string $id,
        int $index,
        Request $request,
        ScanSessionRepository $sessions,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }

        $payload = json_decode($request->getContent(), true);
        $make = \is_array($payload) ? trim((string) ($payload['make'] ?? '')) : '';
        $model = \is_array($payload) ? trim((string) ($payload['model'] ?? '')) : '';
        $results[$index]['make'] = '' === $make ? null : mb_substr($make, 0, 60);
        $results[$index]['model'] = '' === $model ? null : mb_substr($model, 0, 60);
        $results[$index]['modelConfidence'] = null;

        $this->persist($session, $results, $em);

        return $this->json(['success' => true, 'message' => sprintf('Marque/modèle du véhicule #%d enregistré.', $index + 1)]);
    }

    #[Route('/inventaires/{id}/paires/{index}/supprimer', name: 'app_pair_delete', methods: ['POST'], requirements: ['id' => '[0-9a-f-]{36}', 'index' => '\d+'])]
    public function deletePair(
        string $id,
        int $index,
        ScanSessionRepository $sessions,
        ImageStorage $storage,
        EntityManagerInterface $em,
    ): JsonResponse {
        $session = $this->guard($id, $sessions);

        $results = $session->getResults();
        if (!isset($results[$index]) || !\is_array($results[$index])) {
            return $this->json(['error' => 'Véhicule introuvable.'], Response::HTTP_NOT_FOUND);
        }
        // Seuls les véhicules ajoutés manuellement sont supprimables individuellement
        // (les véhicules scannés ne le sont pas ; les doublons passent par l'action « Supprimer les doublons »).
        if (empty($results[$index]['manual'])) {
            return $this->json(['error' => 'Seuls les véhicules ajoutés manuellement peuvent être supprimés.'], Response::HTTP_FORBIDDEN);
        }

        $creator = $session->getUserEmail();
        foreach (['plateImageId', 'vinImageId'] as $key) {
            $img = $results[$index][$key] ?? null;
            if (\is_string($img) && '' !== $img) {
                $storage->delete($creator, $id, $img);
            }
        }
        unset($results[$index]);

        $this->persist($session, $results, $em);

        return $this->json(['success' => true, 'message' => sprintf('Véhicule #%d supprimé.', $index + 1)]);
    }

    /**
     * Charge le scan dans le périmètre et vérifie le droit d'édition (statut « En cours »).
     * Lève 404 (hors périmètre/inexistant) ou 403 (verrouillé / pas le droit).
     */
    private function guard(string $id, ScanSessionRepository $sessions): ScanSession
    {
        /** @var \App\Entity\User $user */
        $user = $this->getUser();
        $session = $sessions->findOneInScope($id, $user->scopeConcessions());
        if (null === $session) {
            throw $this->createNotFoundException();
        }
        $this->denyAccessUnlessGranted(ScanVoter::VIEW, $session);
        $this->denyAccessUnlessGranted(ScanVoter::EDIT_DOCUMENTS, $session);

        return $session;
    }

    /**
     * Réindexe les paires, recalcule le nombre de véhicules et de photos, puis persiste.
     *
     * @param array<int, array<string, mixed>> $results
     */
    private function persist(ScanSession $session, array $results, EntityManagerInterface $em): void
    {
        $results = array_values($results);
        $files = 0;
        foreach ($results as $i => &$r) {
            $r['pairIndex'] = $i;
            if (!empty($r['plateImageId'])) {
                ++$files;
            }
            if (!empty($r['vinImageId'])) {
                ++$files;
            }
        }
        unset($r);

        $session->setResults($results)->setCount(\count($results))->setTotalFiles($files);
        $em->flush();
    }

    /** @return array<string, mixed> */
    private static function emptyPair(int $i): array
    {
        return [
            'pairIndex' => $i,
            'plate' => null,
            'vin' => null,
            'rawPlate' => '',
            'rawVin' => '',
            'plateConfidence' => 'none',
            'vinConfidence' => 'none',
            'plateFilename' => null,
            'vinFilename' => null,
            'plateDate' => null,
            'vinDate' => null,
            'plateLatitude' => null,
            'plateLongitude' => null,
            'vinLatitude' => null,
            'vinLongitude' => null,
            'plateImageId' => null,
            'vinImageId' => null,
        ];
    }

    /** UUID v4 (format compatible avec la regex stricte des images/sessions). */
    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));
    }

    /**
     * Lit le fichier uploadé et le ré-encode en JPEG (base64, sans préfixe data:).
     * Imagick d'abord (gère JPEG/PNG/WebP/HEIC + orientation EXIF), GD en repli.
     */
    private function toJpegBase64(UploadedFile $file): string
    {
        $blob = file_get_contents($file->getPathname());
        if (false === $blob || '' === $blob) {
            throw new \RuntimeException('Fichier vide.');
        }

        if (class_exists(\Imagick::class)) {
            try {
                $im = new \Imagick();
                $im->readImageBlob($blob);
                $im->setIteratorIndex(0);
                try {
                    $im->autoOrient();
                } catch (\Throwable) {
                }
                $im->setImageFormat('jpeg');
                $im->setImageCompressionQuality(85);
                $jpeg = $im->getImageBlob();
                $im->clear();
                if ('' !== $jpeg) {
                    return base64_encode($jpeg);
                }
            } catch (\Throwable) {
            }
        }

        $img = @imagecreatefromstring($blob);
        if (false === $img) {
            throw new \RuntimeException('Format image non supporté.');
        }
        ob_start();
        imagejpeg($img, null, 85);
        $jpeg = (string) ob_get_clean();
        imagedestroy($img);
        if ('' === $jpeg) {
            throw new \RuntimeException('Échec de l\'encodage JPEG.');
        }

        return base64_encode($jpeg);
    }
}
