import { Controller } from '@hotwired/stimulus';
import { s3Sign, s3FormUpload, base64ToBlob } from '../s3_upload.js';

/* Scan de véhicules : port de useScanSession.ts + composants scan + export. */

const CONCURRENCY = 5;
const MAX_IMAGE_DIM = 1280;
const JPEG_QUALITY = 0.82;
const MAX_FILES = 2000;
const MAX_FILE_SIZE = 15 * 1024 * 1024;
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.heic'];

const STEP_LABELS = {
    conversion: "Conversion de l'image…",
    upload: "Envoi vers l'API…",
    ocr: 'Lecture par IA (Gemini)…',
    model: 'Identification marque/modèle…',
    done: 'Terminé',
};

export default class extends Controller {
    static targets = [
        'concessionSection',
        'typeSection',
        'locationSection', 'locationNote',
        'signatureSection',
        'dropzoneSection', 'dropzone', 'fileInput', 'dropError', 'fileCounter',
        'queueSection', 'queueList', 'queueCount', 'queueBadges', 'oddWarning',
        'progressSection', 'progressBar', 'progressLabel', 'progressPercent',
        'progressFilename', 'progressStep', 'progressElapsed', 'progressEta',
        'actions', 'vehicleCount', 'emailStatus',
        'resultsSection', 'resultsSubtitle', 'resultsContent',
        'cancelledSection', 'helpBox',
    ];

    // s3 = true → les photos sont téléversées directement sur S3 par le navigateur (sans CORS,
    // via formulaire+iframe) pendant le scan ; le serveur ne stocke alors rien en local.
    static values = { s3: { type: Boolean, default: false } };

    connect() {
        this.files = [];
        this.results = [];
        this.status = 'idle';
        this.cancelled = false;
        this.ingesting = false;
        this._ingestEl = null;
        this._suppressQueueRender = false;
        this._abort = null;
        this.s3Failures = 0;
        this.updateView();
    }

    // ─── Sélection de fichiers ───
    onFileInput(event) {
        this.addFiles(event.target.files);
        event.target.value = '';
    }

    dragOver(event) {
        event.preventDefault();
        this.dropzoneTarget.classList.add('border-red-brand', 'bg-red-brand/5');
    }

    dragLeave() {
        this.dropzoneTarget.classList.remove('border-red-brand', 'bg-red-brand/5');
    }

    drop(event) {
        event.preventDefault();
        this.dropzoneTarget.classList.remove('border-red-brand', 'bg-red-brand/5');
        if (this.status === 'processing') return;
        this.addFiles(event.dataTransfer.files);
    }

    async addFiles(fileList) {
        const raw = Array.from(fileList || []);
        if (raw.length === 0 || this.ingesting) return;
        this.hideDropError();

        this.ingesting = true;
        const total = raw.length;

        // Phase 1 — validation par lots (type/taille), en rendant la main au navigateur
        // entre chaque lot pour qu'il rafraîchisse l'écran (sélectionner des centaines de
        // photos sur mobile est lent ; on évite le gel et on affiche une progression).
        this.showIngest(0, total, 'Préparation des photos…');
        const valid = [];
        let rejectedType = 0;
        let rejectedSize = 0;
        const CHUNK = 12;
        for (let i = 0; i < raw.length; i += CHUNK) {
            for (const f of raw.slice(i, i + CHUNK)) {
                if (!this.isValidType(f)) {
                    ++rejectedType;
                } else if (f.size > MAX_FILE_SIZE) {
                    ++rejectedSize;
                } else {
                    valid.push(f);
                }
            }
            this.showIngest(Math.min(i + CHUNK, total), total, 'Préparation des photos…');
            await this.nextFrame();
        }

        const errors = [];
        if (rejectedType > 0) errors.push(`${rejectedType} fichier(s) ignoré(s) : formats acceptés JPG, PNG, HEIC`);
        if (rejectedSize > 0) errors.push(`${rejectedSize} fichier(s) trop volumineux (max 15 Mo)`);

        let toAdd = valid;
        if (this.files.length + valid.length > MAX_FILES) {
            errors.push(`Limite de ${MAX_FILES.toLocaleString('fr-FR')} fichiers atteinte.`);
            toAdd = valid.slice(0, Math.max(0, MAX_FILES - this.files.length));
        }

        // L'EXIF (date + GPS) est extrait pendant le scan (worker), pas à la sélection.
        const newEntries = toAdd.map((file) => ({ file, id: crypto.randomUUID() }));
        const existing = this.files.map((e) => ({ file: e.file, exif: e.exif, id: e.id }));
        this.files = this.sortFiles([...existing, ...newEntries]);

        // Phase 2 — affiche les sections SANS rendre la file ici, puis remplit la liste
        // par paquets : la liste se remplit sous les yeux de l'utilisateur (compteur X/Y),
        // au lieu d'un gros rendu unique qui fige l'écran.
        this._suppressQueueRender = true;
        this.updateView();
        this._suppressQueueRender = false;
        await this.renderQueueProgressive();

        this.hideIngest();
        this.ingesting = false;
        if (errors.length) this.showDropError(errors.join(' — '));
    }

    // Promesse résolue à la prochaine frame (laisse le navigateur peindre entre deux lots).
    nextFrame() {
        return new Promise((resolve) => {
            if ('function' === typeof requestAnimationFrame) {
                requestAnimationFrame(() => resolve());
            } else {
                setTimeout(resolve, 0);
            }
        });
    }

    // Bandeau de progression de l'intégration des photos (créé à la volée, pas de cible Stimulus).
    showIngest(current, total, label = 'Intégration des photos…') {
        if (!this._ingestEl) {
            const el = document.createElement('div');
            el.className = 'mt-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3';
            el.innerHTML = `
                <div class="flex items-center justify-between mb-1.5">
                    <span data-ingest="label" class="text-sm font-medium text-blue-900"></span>
                    <span data-ingest="count" class="text-xs text-blue-700 font-mono"></span>
                </div>
                <div class="h-2 bg-blue-100 rounded-full overflow-hidden">
                    <div data-ingest="bar" class="h-full bg-red-brand rounded-full transition-all duration-150" style="width:0%"></div>
                </div>`;
            const host = this.hasDropzoneSectionTarget ? this.dropzoneSectionTarget : this.element;
            host.prepend(el);
            this._ingestEl = el;
        }
        const pct = total > 0 ? Math.round((current / total) * 100) : 0;
        this._ingestEl.querySelector('[data-ingest="label"]').textContent = label;
        this._ingestEl.querySelector('[data-ingest="bar"]').style.width = `${pct}%`;
        this._ingestEl.querySelector('[data-ingest="count"]').textContent = `${current} / ${total}`;
    }

    hideIngest() {
        if (this._ingestEl) {
            this._ingestEl.remove();
            this._ingestEl = null;
        }
    }

    removeFile(event) {
        const idx = Number(event.params.index);
        const entries = this.files.filter((_, i) => i !== idx).map((e) => ({ file: e.file, exif: e.exif, id: e.id }));
        this.files = this.sortFiles(entries);
        this.updateView();
    }

    clearFiles() {
        this.files = [];
        this.results = [];
        this.status = 'idle';
        this.updateView();
    }

    isValidType(file) {
        const name = file.name.toLowerCase();
        return ALLOWED_EXT.some((ext) => name.endsWith(ext))
            || ['image/jpeg', 'image/jpg', 'image/png', 'image/heic'].includes(file.type.toLowerCase());
    }

    sortFiles(entries) {
        const sorted = [...entries].sort((a, b) =>
            a.file.name.localeCompare(b.file.name, 'fr', { numeric: true, sensitivity: 'base' }));
        return sorted.map((entry, index) => ({
            file: entry.file,
            exif: entry.exif,
            id: entry.id,
            index,
        }));
    }

    async extractExif(file) {
        try {
            const exifr = (await import('exifr')).default;
            // 2 appels distincts : pick ne sait pas piocher `latitude`/`longitude`
            // (ce sont des champs synthétisés, pas des tags EXIF). exifr.gps() est dédié.
            const [exif, gps] = await Promise.all([
                exifr.parse(file, { pick: ['DateTimeOriginal', 'CreateDate'] }).catch(() => null),
                exifr.gps(file).catch(() => null),
            ]);
            return {
                date: exif?.DateTimeOriginal || exif?.CreateDate || null,
                latitude: typeof gps?.latitude === 'number' ? gps.latitude : null,
                longitude: typeof gps?.longitude === 'number' ? gps.longitude : null,
            };
        } catch {
            return { date: null, latitude: null, longitude: null };
        }
    }

    // ─── Lancement ───
    startScan() {
        this.runScan();
    }

    async startScanAndEmail() {
        let scan = null;
        let errorLog = null;
        try {
            scan = await this.runScan();
        } catch (err) {
            errorLog = err instanceof Error ? `${err.message}\n\n${err.stack || ''}` : String(err);
        }
        if (this.cancelled) return;

        // runScan a renvoyé null sans lever d'exception = validation non passée
        // (lieu ou type de scan manquant, aucune photo). Le message d'erreur a déjà
        // été affiché par runScan via showDropError → on n'enchaîne pas sur l'envoi
        // (sinon scan.results lèverait une erreur technique « Cannot read properties of null »).
        if (!scan && !errorLog) return;

        this.setEmailStatus('sending');
        try {
            const locationNote = this.hasLocationNoteTarget ? this.locationNoteTarget.value.trim() : '';
            const concessions = this.getSelectedConcessions();
            const payload = errorLog
                ? { errorLog }
                : { results: scan.results, label: scan.label, locationNote, concessions };
            const res = await fetch('/api/send-report', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload),
            });
            if (!res.ok) {
                // Remonte la vraie cause : 401 (session expirée), 500 (erreur serveur/Mailjet), etc.
                let detail = '';
                try {
                    const body = await res.json();
                    detail = typeof body?.error === 'string' ? body.error : '';
                } catch { /* réponse non JSON (HTML d'erreur, redirection login…) */ }
                if (res.status === 401 || res.status === 403) {
                    detail = detail || 'Session expirée — reconnectez-vous puis réessayez.';
                }
                throw new Error(`[${res.status}] ${detail || 'réponse serveur invalide'}`);
            }
            this.setEmailStatus('sent');
        } catch (err) {
            const reason = err instanceof Error ? err.message : String(err);
            this.setEmailStatus('error', reason);
        }
    }

    async runScan() {
        if (this.files.length === 0) return null;

        // Type d'inventaire obligatoire : VN, VO ou VN & VO.
        const scanType = this.getScanType();
        if (scanType === '') {
            this.showDropError('Choisissez le type d’inventaire : VN (véhicules neufs), VO (véhicules d’occasion) ou VN & VO.');
            return null;
        }

        // Lieu obligatoire : on exige une annotation de lieu avant de démarrer le scan.
        const locationNote = this.hasLocationNoteTarget ? this.locationNoteTarget.value.trim() : '';
        if (locationNote === '') {
            this.showDropError('Le lieu est obligatoire : renseignez le champ « Lieu » avant de lancer le scan.');
            if (this.hasLocationNoteTarget) this.locationNoteTarget.focus();
            return null;
        }

        // Signature obligatoire : apposée au lancement du scan, elle figure à la fin du
        // rapport PDF par photo. On lit l'image (data-URI) du pad de signature.
        const signature = this.getSignatureDataUrl();
        if (!signature) {
            this.showDropError('La signature est obligatoire : signez dans le cadre avant de lancer le scan.');
            if (this.hasSignatureSectionTarget) this.signatureSectionTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
            return null;
        }

        this.cancelled = false;
        this._abort = new AbortController();
        this.status = 'processing';
        this.results = [];
        this.setEmailStatus('idle');
        this.startTime = Date.now();
        // Généré ici pour que /api/ocr puisse stocker chaque image sous {sessionId}/{imageId}.jpg.
        this.sessionId = crypto.randomUUID();
        this.s3Failures = 0;
        this.updateView();

        const now = new Date();
        const dateStr = now.toLocaleDateString('fr-FR');
        const timeStr = now.toLocaleTimeString('fr-FR');
        // Libellé + date figés dès le départ : nécessaires AVANT le scan pour signer les uploads
        // S3 (le dossier S3 de la session en dépend) et réutilisés tels quels à l'enregistrement,
        // afin que la clé uploadée corresponde exactement à celle que le serveur recalculera.
        const hm0 = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
        this.scanLabel = `Scan du ${dateStr} à ${hm0}`;
        this.scanCreatedAt = now.toISOString();

        const cache = new Map();
        const total = this.files.length;
        let processed = 0;
        const queue = [...this.files];

        const worker = async () => {
            while (queue.length > 0 && !this.cancelled) {
                const entry = queue.shift();
                if (!entry) break;
                this.renderProgress(processed, total, entry.file.name, 'conversion');
                try {
                    // EXIF + OCR en parallèle (l'EXIF n'est plus fait à la sélection).
                    const [exif, result] = await Promise.all([
                        this.extractExif(entry.file),
                        this.processFile(entry, (step) =>
                            this.renderProgress(processed, total, entry.file.name, step)),
                    ]);
                    entry.exif = exif;
                    cache.set(entry.index, result);
                } catch {
                    cache.set(entry.index, { raw: 'ERREUR', corrected: '', confidence: 'low', type: null });
                }
                processed += 1;
                this.renderProgress(processed, total, entry.file.name, 'done');
            }
        };

        await Promise.all(Array(Math.min(CONCURRENCY, this.files.length)).fill(null).map(worker));

        if (this.cancelled) {
            this.status = 'cancelled';
            this.updateView();
            return null;
        }

        // Position des véhicules issue UNIQUEMENT des métadonnées GPS des photos :
        // aucune géolocalisation du navigateur n'est demandée à l'utilisateur.
        const sessLat = null;
        const sessLon = null;

        const emptyVehicle = () => ({
            plate: null, vin: null, rawPlate: '', rawVin: '',
            plateConfidence: 'none', vinConfidence: 'none',
            date: dateStr, time: timeStr,
            plateFilename: undefined, vinFilename: undefined,
            plateDate: null, vinDate: null,
            plateLatitude: null, plateLongitude: null,
            vinLatitude: null, vinLongitude: null,
            plateImageId: null, vinImageId: null,
            // 3e photo optionnelle (vue du véhicule) + marque/modèle déduits du VIN + photos non-VIN.
            photoFilename: undefined, photoDate: null,
            photoLatitude: null, photoLongitude: null,
            photoImageId: null,
            make: null, model: null, modelConfidence: null,
        });
        const fillSlot = (veh, kind, entry, ocr) => {
            const ex = entry.exif || {};
            if (kind === 'vin') {
                veh.vin = ocr && ocr.corrected ? ocr.corrected : null;
                veh.rawVin = ocr ? ocr.raw : '';
                veh.vinConfidence = ocr ? ocr.confidence : 'none';
                veh.vinFilename = entry.file.name;
                veh.vinDate = this.fmtExifDate(ex.date);
                veh.vinLatitude = ex.latitude ?? sessLat;
                veh.vinLongitude = ex.longitude ?? sessLon;
                veh.vinImageId = entry.id || null;
            } else if (kind === 'photo') {
                // 2e photo non-VIN : on ne garde que l'image (servira à l'identification
                // marque/modèle), pas de lecture de plaque (déjà remplie par la 1re).
                veh.photoFilename = entry.file.name;
                veh.photoDate = this.fmtExifDate(ex.date);
                veh.photoLatitude = ex.latitude ?? sessLat;
                veh.photoLongitude = ex.longitude ?? sessLon;
                veh.photoImageId = entry.id || null;
            } else {
                veh.plate = ocr && ocr.corrected ? ocr.corrected : null;
                veh.rawPlate = ocr ? ocr.raw : '';
                veh.plateConfidence = ocr ? ocr.confidence : 'none';
                veh.plateFilename = entry.file.name;
                veh.plateDate = this.fmtExifDate(ex.date);
                veh.plateLatitude = ex.latitude ?? sessLat;
                veh.plateLongitude = ex.longitude ?? sessLon;
                veh.plateImageId = entry.id || null;
            }
        };

        // Regroupement ancré sur le VIN, dans l'ordre de prise :
        // - un VIN ouvre un nouveau véhicule ;
        // - une photo non-VIN se rattache au véhicule courant : la 1re remplit la plaque,
        //   la 2e remplit la « photo véhicule » (jusqu'à 3 photos / véhicule). Au-delà (3e
        //   photo non-VIN), elle ouvre un véhicule « plaque seule » (à vérifier) ;
        // - un type indéterminé (OCR illisible) est ancré comme un nouveau véhicule (slot VIN).
        const results = [];
        let current = null;
        for (const entry of this.files) {
            const ocr = cache.get(entry.index) || null;
            const kind = ocr && (ocr.type === 'plate' || ocr.type === 'vin') ? ocr.type : null;
            if (kind === 'plate') {
                if (current && !current.plateImageId) {
                    fillSlot(current, 'plate', entry, ocr);
                } else if (current && current.plateImageId && !current.photoImageId) {
                    fillSlot(current, 'photo', entry, ocr);
                } else {
                    current = emptyVehicle();
                    fillSlot(current, 'plate', entry, ocr);
                    results.push(current);
                }
            } else {
                current = emptyVehicle();
                fillSlot(current, 'vin', entry, ocr);
                results.push(current);
            }
        }
        results.forEach((v, i) => { v.pairIndex = i; });

        this.results = results;

        // Identification marque + modèle (VIN + photos non-VIN) via Gemini — non bloquant.
        await this.recognizeModels(results);

        const label = this.scanLabel;
        const session = {
            id: this.sessionId,
            createdAt: this.scanCreatedAt,
            label,
            count: results.length,
            totalFiles: this.files.length,
            results,
            status: 'en_cours',
            locationNote: this.hasLocationNoteTarget ? this.locationNoteTarget.value.trim() : '',
            scanType: this.getScanType(),
            concessions: this.getSelectedConcessions(),
            // Signature apposée au lancement + sa date (= moment du lancement du scan).
            signature,
            signatureDate: this.scanCreatedAt,
        };
        // Enregistre l'inventaire AVANT d'afficher les résultats, pour que le lien
        // "Voir dans l'historique" pointe vers un détail déjà persisté.
        await this.saveSession(session);

        this.status = 'completed';
        this.updateView();

        return { results, label };
    }

    // Appel OCR /api/ocr avec relances : sur réseau mobile instable, un appel qui échoue
    // renvoyait « ERREUR » (type indéterminé) → la photo était ancrée par défaut comme un VIN
    // (une vraie plaque devenait alors un faux « VIN »). On réessaie donc sur échec réseau / 5xx /
    // 429 (les 4xx déterministes ne sont pas réessayés ; une annulation interrompt sans retry).
    async ocrRequest(base64, entry, onStep) {
        const retries = 2; // 3 tentatives max
        let lastErr = null;
        for (let attempt = 0; attempt <= retries; attempt++) {
            if (this.cancelled) throw new Error('cancelled');
            if (attempt > 0) await new Promise((r) => setTimeout(r, 600 * attempt));
            try {
                const res = await fetch('/api/ocr', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        image: base64,
                        filename: entry.file.name,
                        type: 'auto',
                        sessionId: this.sessionId,
                        imageId: entry.id,
                    }),
                    // Interrompt la requête en vol si l'utilisateur annule le scan.
                    signal: this._abort ? this._abort.signal : undefined,
                });
                if (!res.ok) {
                    const err = await res.json().catch(() => ({}));
                    // 4xx (hors 429) = déterministe → inutile de retenter.
                    if (res.status < 500 && res.status !== 429) {
                        throw new Error(err.error || 'OCR API error');
                    }
                    lastErr = new Error(err.error || `OCR ${res.status}`);
                    continue;
                }
                onStep('ocr');
                return await res.json();
            } catch (e) {
                if (e && e.name === 'AbortError') throw e; // annulation : pas de retry
                lastErr = e;
            }
        }
        throw lastErr || new Error('OCR API error');
    }

    async processFile(entry, onStep) {
        onStep('conversion');
        const base64 = await this.prepareImage(entry.file);
        onStep('upload');
        // OCR (serveur) et upload S3 (navigateur→S3) en parallèle. En mode S3, le serveur
        // ne stocke rien : la photo n'existe que si l'upload S3 réussit (échecs comptés).
        const ocrPromise = this.ocrRequest(base64, entry, onStep);
        const s3Promise = this.s3Value
            ? this.uploadImageToS3(entry.id, base64).catch(() => { this.s3Failures += 1; })
            : Promise.resolve();

        const [data] = await Promise.all([ocrPromise, s3Promise]);
        // On conserve le base64 des SEULES photos non-VIN (type 'plate') : elles serviront,
        // une fois les véhicules regroupés, à l'identification marque/modèle (VIN + photos).
        // Les photos VIN ne sont pas conservées (mémoire) — seul le VIN texte est utile.
        entry._b64 = data.type === 'plate' ? base64 : null;
        return {
            raw: data.raw || '',
            corrected: data.corrected || '',
            confidence: data.confidence || 'low',
            type: data.type === 'plate' || data.type === 'vin' ? data.type : null,
        };
    }

    // Identifie marque + modèle de chaque véhicule à partir de son VIN (texte) et de ses
    // photos non-VIN (1 ou 2). Le navigateur transmet les images (base64) qu'il a en mémoire
    // au serveur (qui appelle Gemini), car le serveur OVH ne peut pas relire les octets sur S3.
    // Étape non bloquante : un échec laisse make/model à null (corrigeable plus tard).
    async recognizeModels(results) {
        const b64 = new Map();
        for (const e of this.files) { if (e._b64) b64.set(e.id, e._b64); }
        const targets = results.filter((v) => v.plateImageId || v.photoImageId);
        const total = targets.length;
        if (total === 0) return;

        let done = 0;
        this.renderProgress(0, total, 'Identification marque/modèle…', 'model');
        const queue = [...targets];
        const worker = async () => {
            while (queue.length > 0 && !this.cancelled) {
                const v = queue.shift();
                const imgs = [v.plateImageId, v.photoImageId]
                    .map((id) => (id ? b64.get(id) : null))
                    .filter(Boolean);
                if (imgs.length > 0) {
                    try {
                        const res = await fetch('/api/vehicle-model', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ vin: v.vin || v.rawVin || '', images: imgs }),
                            signal: this._abort ? this._abort.signal : undefined,
                        });
                        if (res.ok) {
                            const d = await res.json();
                            v.make = d.make || null;
                            v.model = d.model || null;
                            v.modelConfidence = typeof d.confidence === 'number' ? d.confidence : null;
                        }
                    } catch { /* non bloquant */ }
                }
                done += 1;
                this.renderProgress(done, total, 'Identification marque/modèle…', 'model');
            }
        };
        await Promise.all(Array(Math.min(CONCURRENCY, total)).fill(null).map(worker));
        for (const e of this.files) e._b64 = null; // libère la mémoire
    }

    // Téléverse une image (base64) vers S3 : demande une POST policy signée au serveur,
    // puis upload direct navigateur→S3 (POST multipart no-cors, sans CORS). Les 2 appels
    // (signature + dépôt) réessaient en interne en cas d'aléa réseau mobile.
    async uploadImageToS3(imageId, base64) {
        const { url, fields } = await s3Sign({
            sessionId: this.sessionId,
            imageId,
            label: this.scanLabel,
            createdAt: this.scanCreatedAt,
        });
        const file = new File([base64ToBlob(base64, 'image/jpeg')], `${imageId}.jpg`, { type: 'image/jpeg' });
        await s3FormUpload({ url, fields, file });
    }

    async prepareImage(file) {
        if (file.type === 'image/heic' || file.name.toLowerCase().endsWith('.heic')) {
            const heic2any = (await import('heic2any')).default;
            const jpeg = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.85 });
            return this.compressImage(Array.isArray(jpeg) ? jpeg[0] : jpeg);
        }
        return this.compressImage(file);
    }

    compressImage(blob) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            const url = URL.createObjectURL(blob);
            img.onload = () => {
                let { width, height } = img;
                if (width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM) {
                    if (width >= height) {
                        height = Math.round((height * MAX_IMAGE_DIM) / width);
                        width = MAX_IMAGE_DIM;
                    } else {
                        width = Math.round((width * MAX_IMAGE_DIM) / height);
                        height = MAX_IMAGE_DIM;
                    }
                }
                const canvas = document.createElement('canvas');
                canvas.width = width;
                canvas.height = height;
                canvas.getContext('2d').drawImage(img, 0, 0, width, height);
                URL.revokeObjectURL(url);
                resolve(canvas.toDataURL('image/jpeg', JPEG_QUALITY).split(',')[1]);
            };
            img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
            img.src = url;
        });
    }

    cancel() {
        this.cancelled = true;
        // Interrompt immédiatement les requêtes OCR en vol (sinon l'annulation n'a lieu
        // qu'après la fin des images en cours — long avec un modèle lent).
        this.abortInFlight();
        // Retour visuel immédiat.
        if (this.hasProgressStepTarget) this.progressStepTarget.textContent = 'Annulation…';
        if (this.hasProgressFilenameTarget) this.progressFilenameTarget.textContent = '';
    }

    abortInFlight() {
        if (this._abort) {
            try {
                this._abort.abort();
            } catch (e) { /* ignore */ }
        }
    }

    reset() {
        this.cancelled = true;
        this.abortInFlight();
        this.files = [];
        this.results = [];
        this.status = 'idle';
        this.setEmailStatus('idle');
        this.updateView();
    }

    /** Type d'inventaire sélectionné (VN/VO/VNVO) ou chaîne vide si aucun. */
    getScanType() {
        const el = this.element.querySelector('input[name="scanType"]:checked');
        return el ? el.value : '';
    }

    /** Data-URI de la signature (ou null si vide), lue depuis le contrôleur signature. */
    getSignatureDataUrl() {
        if (!this.hasSignatureSectionTarget) return null;
        const el = this.signatureSectionTarget.querySelector('[data-controller~="signature"]');
        if (!el) return null;
        const ctrl = this.application.getControllerForElementAndIdentifier(el, 'signature');
        return ctrl ? ctrl.dataUrl() : null;
    }

    /** Lit la sélection courante du picker inline ; fallback à liste vide. */
    getSelectedConcessions() {
        if (!this.hasConcessionSectionTarget) return [];
        const el = this.concessionSectionTarget.querySelector('[data-controller~="concession-picker-inline"]');
        if (!el) return [];
        const ctrl = this.application.getControllerForElementAndIdentifier(el, 'concession-picker-inline');
        return ctrl ? ctrl.selected() : [];
    }

    fmtExifDate(d) {
        if (!d) return null;
        const dt = d instanceof Date ? d : new Date(d);
        if (Number.isNaN(dt.getTime())) return null;
        return dt.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
    }

    async saveSession(session) {
        try {
            await fetch('/api/history', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(session),
            });
        } catch { /* non bloquant */ }
    }

    // ─── Vue ───
    updateView() {
        const isProcessing = this.status === 'processing';
        const isCompleted = this.status === 'completed';
        const hasFiles = this.files.length > 0;

        if (this.hasLocationSectionTarget) {
            this.toggle(this.locationSectionTarget, !isProcessing && !isCompleted);
        }
        if (this.hasConcessionSectionTarget) {
            this.toggle(this.concessionSectionTarget, !isProcessing && !isCompleted);
        }
        if (this.hasTypeSectionTarget) {
            this.toggle(this.typeSectionTarget, !isProcessing && !isCompleted);
        }
        if (this.hasSignatureSectionTarget) {
            this.toggle(this.signatureSectionTarget, !isProcessing && !isCompleted);
        }
        this.toggle(this.dropzoneSectionTarget, !isCompleted);
        this.toggle(this.queueSectionTarget, hasFiles && !isProcessing && !isCompleted);
        this.toggle(this.progressSectionTarget, isProcessing);
        this.toggle(this.actionsTarget, hasFiles && !isProcessing && !isCompleted, 'flex');
        this.toggle(this.resultsSectionTarget, isCompleted && this.results.length > 0);
        this.toggle(this.cancelledSectionTarget, this.status === 'cancelled');
        this.toggle(this.helpBoxTarget, !hasFiles && !isCompleted);

        this.toggle(this.fileCounterTarget, hasFiles);
        if (hasFiles) {
            this.fileCounterTarget.textContent = `${this.files.length} fichier${this.files.length > 1 ? 's' : ''}`;
        }

        if (hasFiles && !isProcessing && !isCompleted) {
            if (!this._suppressQueueRender) this.renderQueue();
            this.vehicleCountTarget.textContent =
                `${this.files.length} fichier${this.files.length > 1 ? 's' : ''} — type (VIN/plaque) détecté à l'analyse`;
        }

        if (isCompleted && this.results.length > 0) this.renderResults();
    }

    queueRow(entry, idx) {
        return `
            <li class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 transition-colors">
                <span class="text-xs text-gray-400 font-mono w-6 text-right shrink-0">${entry.index + 1}.</span>
                <span class="text-xs text-gray-500 truncate flex-1 font-mono" title="${this.esc(entry.file.name)}">${this.esc(entry.file.name)}</span>
                <span class="text-xs text-gray-400 shrink-0">${Math.round(entry.file.size / 1024)} Ko</span>
                <button data-action="scan#removeFile" data-scan-index-param="${idx}" class="shrink-0 text-gray-400 hover:text-red-brand transition-colors" aria-label="Supprimer">
                    <svg class="w-4 h-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
                </button>
            </li>`;
    }

    renderQueue() {
        this.queueCountTarget.textContent = `${this.files.length} fichier${this.files.length > 1 ? 's' : ''}`;
        this.queueBadgesTarget.innerHTML = this.badge('VIN / plaque détecté à l\'analyse', 'blue');
        this.queueListTarget.innerHTML = this.files.map((entry, idx) => this.queueRow(entry, idx)).join('');
        if (this.hasOddWarningTarget) this.toggle(this.oddWarningTarget, false);
    }

    // Remplit la file par paquets, en cédant la main entre chaque, pour montrer la
    // « prise en compte » des photos progressivement (et ne pas figer l'écran sur un gros rendu).
    async renderQueueProgressive() {
        this.queueCountTarget.textContent = `${this.files.length} fichier${this.files.length > 1 ? 's' : ''}`;
        this.queueBadgesTarget.innerHTML = this.badge('VIN / plaque détecté à l\'analyse', 'blue');
        this.queueListTarget.innerHTML = '';
        const total = this.files.length;
        const BATCH = 25;
        for (let i = 0; i < total; i += BATCH) {
            const html = this.files.slice(i, i + BATCH).map((entry, k) => this.queueRow(entry, i + k)).join('');
            this.queueListTarget.insertAdjacentHTML('beforeend', html);
            this.showIngest(Math.min(i + BATCH, total), total, 'Prise en compte des photos…');
            await this.nextFrame();
        }
        if (this.hasOddWarningTarget) this.toggle(this.oddWarningTarget, false);
    }

    renderProgress(current, total, filename, step) {
        const pct = total > 0 ? Math.round((current / total) * 100) : 0;
        const elapsed = Date.now() - this.startTime;
        this.progressBarTarget.style.width = `${pct}%`;
        this.progressLabelTarget.textContent = `${current} / ${total}`;
        this.progressPercentTarget.textContent = `${pct}%`;
        this.progressFilenameTarget.textContent = filename ? `→ ${filename}` : 'Initialisation…';
        this.progressStepTarget.textContent = step ? (STEP_LABELS[step] || '') : '';
        this.progressElapsedTarget.textContent = `Écoulé : ${this.fmtMs(elapsed)}`;
        const eta = current > 0 && total > current ? this.fmtMs((elapsed / current) * (total - current)) : '';
        this.progressEtaTarget.textContent = eta ? `Restant : ~${eta}` : '';
    }

    renderResults() {
        const r = this.results;
        const lowConf = r.filter((x) => x.plateConfidence === 'low' || x.vinConfidence === 'low').length;
        const noVin = r.filter((x) => !x.vinImageId).length;
        const vinFmt = r.filter((x) => x.vin && !this.isVinValue(x.vin)).length;
        const plateFmt = r.filter((x) => x.plate && !this.isPlateValue(x.plate)).length;

        this.resultsSubtitleTarget.textContent =
            `Scan terminé · ${r.length} véhicule${r.length > 1 ? 's' : ''}`;

        const rows = r.map((x) => `
            <tr class="hover:bg-gray-50 transition-colors">
                <td class="px-4 py-3 text-gray-400 font-mono text-xs">${x.pairIndex + 1}</td>
                <td class="px-4 py-3">${x.vinImageId
                    ? (x.vin
                        ? `<span class="font-mono text-gray-600 text-xs" title="OCR brut : ${this.esc(x.rawVin)}">${this.esc(x.vin)}</span>${!this.isVinValue(x.vin) ? this.badge('format ?', 'amber') : (x.vinConfidence === 'low' ? this.badge('~', 'amber') : '')}`
                        : `<span class="text-gray-400 text-xs italic">photo illisible</span>${this.badge('format ?', 'amber')}`)
                    : this.badge('VIN manquant', 'red')}</td>
                <td class="px-4 py-3">${x.plate
                    ? `<span class="font-mono font-semibold text-gray-900 text-sm tracking-wider" title="OCR brut : ${this.esc(x.rawPlate)}">${this.esc(x.plate)}</span>${!this.isPlateValue(x.plate) ? this.badge('format ?', 'amber') : (x.plateConfidence === 'low' ? this.badge('~', 'amber') : '')}`
                    : '<span class="text-gray-400 font-mono">-</span>'}</td>
                <td class="px-4 py-3">${(x.make || x.model)
                    ? `<span class="text-gray-900 text-sm">${this.esc([x.make, x.model].filter(Boolean).join(' '))}</span>${typeof x.modelConfidence === 'number' ? this.badge(`${x.modelConfidence}%`, x.modelConfidence >= 70 ? 'green' : 'amber') : ''}`
                    : '<span class="text-gray-400 text-xs">-</span>'}</td>
                <td class="px-4 py-3 text-gray-500 text-xs font-mono">${this.esc(x.date)}</td>
                <td class="px-4 py-3 text-gray-500 text-xs font-mono">${this.esc(x.time)}</td>
            </tr>`).join('');

        this.resultsContentTarget.innerHTML = `
            <div class="space-y-4">
                <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
                    <div class="flex items-center gap-2 flex-wrap">
                        ${this.badge(`${r.length} véhicule${r.length > 1 ? 's' : ''}`, 'green')}
                        ${noVin > 0 ? this.badge(`${noVin} sans VIN`, 'red') : ''}
                        ${vinFmt > 0 ? this.badge(`${vinFmt} format VIN ?`, 'amber') : ''}
                        ${plateFmt > 0 ? this.badge(`${plateFmt} format plaque ?`, 'amber') : ''}
                        ${lowConf > 0 ? this.badge(`${lowConf} faible confiance`, 'amber') : ''}
                    </div>
                    <div class="flex items-center gap-2 flex-wrap">
                        ${this.actionBtn('xlsx', 'Excel', 'primary')}
                        <a href="/inventaires/${this.sessionId}" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors text-gray-700 border border-gray-300 hover:bg-gray-100">
                            <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
                            Ouvrir l'inventaire
                        </a>
                    </div>
                </div>
                ${noVin > 0 ? `<div class="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700">⚠ ${noVin} véhicule${noVin > 1 ? 's' : ''} sans VIN. Le VIN est obligatoire : complétez-${noVin > 1 ? 'les' : 'le'} dans l'inventaire (bouton « Ouvrir l'inventaire ») avant de pouvoir passer le scan « À valider ».</div>` : ''}
                ${this.s3Failures > 0 ? `<div class="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700">⚠ ${this.s3Failures} photo${this.s3Failures > 1 ? 's' : ''} n'${this.s3Failures > 1 ? 'ont' : 'a'} pas pu être envoyée${this.s3Failures > 1 ? 's' : ''} vers le stockage. ${this.s3Failures > 1 ? 'Elles' : 'Elle'} apparaîtra${this.s3Failures > 1 ? 'ront' : ''} manquante${this.s3Failures > 1 ? 's' : ''} : remplacez-${this.s3Failures > 1 ? 'les' : 'la'} depuis l'inventaire.</div>` : ''}
                <div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
                    <div class="overflow-x-auto">
                        <table class="w-full text-sm">
                            <thead><tr class="bg-gray-50 border-b border-gray-200">
                                <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-8">#</th>
                                <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">VIN</th>
                                <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Plaque</th>
                                <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Marque / Modèle</th>
                                <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Date</th>
                                <th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Heure</th>
                            </tr></thead>
                            <tbody class="divide-y divide-gray-100">${rows}</tbody>
                        </table>
                    </div>
                </div>
                <p class="text-xs text-gray-400 text-center">Passez votre curseur sur une valeur pour voir le texte OCR brut.</p>
            </div>`;
    }

    actionBtn(action, label, variant) {
        const styles = {
            ghost: 'text-gray-600 hover:bg-gray-100 border border-transparent',
            secondary: 'text-gray-700 border border-gray-300 hover:bg-gray-100',
            primary: 'bg-red-brand text-white hover:bg-red-brand-dark',
        };
        return `<button data-action="scan#export" data-scan-format-param="${action}" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${styles[variant]}">${label}</button>`;
    }

    // ─── Export ───
    async export(event) {
        if (event.params.format === 'xlsx') {
            return this.download('/api/export/xlsx', 'scan-vehicules.xlsx');
        }
    }

    async download(url, filename) {
        const res = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ results: this.results }),
        });
        const blob = await res.blob();
        this.triggerDownload(blob, filename);
    }

    triggerDownload(blob, filename) {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    }

    // ─── Statut email ───
    setEmailStatus(state, detail = '') {
        const el = this.emailStatusTarget;
        if (state === 'idle') { el.classList.add('hidden'); return; }
        el.className = 'flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ' + ({
            sending: 'bg-gray-100 text-gray-600',
            sent: 'bg-green-50 text-green-700 border border-green-200',
            error: 'bg-red-50 text-red-700 border border-red-200',
        }[state]);
        const base = { sending: 'Envoi du rapport par email…', sent: 'Rapport envoyé par email !', error: "Échec de l'envoi email" }[state];
        el.textContent = state === 'error' && detail ? `${base} — ${detail}` : base;
    }

    // ─── Helpers ───
    toggle(el, show, display = 'block') {
        el.classList.toggle('hidden', !show);
        if (show && display === 'flex') el.classList.add('flex');
        else if (display === 'flex') el.classList.remove('flex');
    }

    // VIN : 17 caractères alphanumériques sans I, O, Q.
    isVinValue(v) {
        return typeof v === 'string' && /^[A-HJ-NPR-Z0-9]{17}$/i.test(v.replace(/[\s-]/g, ''));
    }

    // Plaque FR (SIV) : AA-123-AA (tirets optionnels). Les anciens formats ne sont pas signalés faux ici.
    isPlateValue(v) {
        const s = (v || '').replace(/[\s-]/g, '').toUpperCase();
        return /^[A-Z]{2}\d{3}[A-Z]{2}$/.test(s) || /^\d{1,4}[A-Z]{1,3}\d{2,3}$/.test(s);
    }

    badge(text, color) {
        const colors = {
            green: 'bg-green-50 text-green-700 border-green-200',
            red: 'bg-red-50 text-red-700 border-red-200',
            amber: 'bg-amber-50 text-amber-700 border-amber-200',
            blue: 'bg-blue-50 text-blue-700 border-blue-200',
            purple: 'bg-purple-50 text-purple-700 border-purple-200',
        };
        return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${colors[color]} ml-1">${text}</span>`;
    }

    fmtMs(ms) {
        if (ms < 1000) return `${Math.round(ms)}ms`;
        const s = Math.floor(ms / 1000);
        if (s < 60) return `${s}s`;
        return `${Math.floor(s / 60)}m ${s % 60}s`;
    }

    esc(str) {
        return String(str ?? '').replace(/[&<>"']/g, (c) =>
            ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' }[c]));
    }

    showDropError(msg) {
        this.dropErrorTarget.textContent = `⚠ ${msg}`;
        this.dropErrorTarget.classList.remove('hidden');
    }

    hideDropError() {
        this.dropErrorTarget.classList.add('hidden');
    }
}
