/**
 * Fichier  : assets/js/scanner/ScannerService.js
 * Auteur   : KREMER Régis
 * Version  : 2.6.90
 * Date     : 2026-07-20
 * Description : Noyau scanner unique MageStock — caméra singleton, image, résolution produit et adaptateurs métier.
 *
 * Changelog :
 *   v2.1.0 (2026-02-21) — Version initiale du service scanner.
 *   v2.2.0 (2026-02-21) — Stabilisation Html5Qrcode et sélection caméra arrière.
 *   v2.6.88 (2026-07-20) — SCANNER CORE UNIFICATION1 : session caméra réellement unique,
 *                           cycle de vie, erreurs normalisées, résolution EAN/SKU/QR interne,
 *                           contextes métier et façade rétrocompatible ScannerService.
 *   v2.6.90 (2026-07-20) — SCANNER CONSUMERS MIGRATION3 : adaptateurs inventory, movement,
 *                           order-receiving et product-search activés par les consommateurs communs.
 */
(function (global) {
    'use strict';

    const VERSION = '2.6.90';
    const DEFAULT_OWNER = 'legacy';
    const CAMERA_STORAGE_KEY = 'ms_scanner_preferred_camera_v1';
    const PRODUCT_CACHE_TTL = 30000;
    const VALID_MODES = new Set(['auto', 'barcode', 'qrcode']);
    const CONTEXT_NAMES = new Set([
        'general',
        'inventory',
        'movement',
        'order-receiving',
        'product-search',
        'location'
    ]);

    const listeners = new Map();
    const adapters = new Map();

    class MageStockScannerError extends Error {
        constructor(code, message, options) {
            super(message || code || 'Erreur scanner');
            this.name = 'MageStockScannerError';
            this.code = code || 'scanner_error';
            this.recoverable = options?.recoverable !== false;
            this.cause = options?.cause;
            this.details = options?.details || null;
        }
    }

    const now = () => Date.now();
    const toText = value => String(value ?? '').trim();
    const lower = value => toText(value).toLocaleLowerCase();
    const unique = values => Array.from(new Set(values.filter(value => value !== undefined && value !== null && value !== '')));
    const isElement = value => !!value && typeof value === 'object' && value.nodeType === 1;
    const isConnected = element => !element || element.isConnected !== false;

    function normalizeMode(mode) {
        const value = lower(mode).replace(/^qr$/, 'qrcode');
        return VALID_MODES.has(value) ? value : 'auto';
    }

    function normalizeContext(context) {
        const value = lower(context) || 'general';
        return CONTEXT_NAMES.has(value) || adapters.has(value) ? value : 'general';
    }

    function eventDetail(type, detail) {
        return {
            type,
            at: now(),
            version: VERSION,
            ...(detail || {})
        };
    }

    function emit(type, detail) {
        const payload = eventDetail(type, detail);
        const own = listeners.get(type);
        const all = listeners.get('*');
        [...(own || []), ...(all || [])].forEach(handler => {
            try { handler(payload); } catch (error) { console.error('[MageStockScanner] listener', error); }
        });
        try {
            if (typeof global.dispatchEvent === 'function' && typeof global.CustomEvent === 'function') {
                global.dispatchEvent(new global.CustomEvent('magestock:scanner:' + type, { detail: payload }));
            }
        } catch (_) {}
        return payload;
    }

    function on(type, handler) {
        if (typeof handler !== 'function') return () => {};
        if (!listeners.has(type)) listeners.set(type, new Set());
        listeners.get(type).add(handler);
        return () => off(type, handler);
    }

    function off(type, handler) {
        listeners.get(type)?.delete(handler);
    }

    function safeStorageGet(key) {
        try { return global.localStorage?.getItem(key) || ''; } catch (_) { return ''; }
    }

    function safeStorageSet(key, value) {
        try {
            if (value) global.localStorage?.setItem(key, value);
            else global.localStorage?.removeItem(key);
        } catch (_) {}
    }

    function html5Available() {
        return typeof global.Html5Qrcode !== 'undefined';
    }

    function supportedFormats() {
        return global.Html5QrcodeSupportedFormats || null;
    }

    function formatsForMode(mode) {
        const formats = supportedFormats();
        if (!formats) return undefined;
        const barcodes = [
            formats.EAN_13,
            formats.EAN_8,
            formats.UPC_A,
            formats.UPC_E,
            formats.CODE_128,
            formats.CODE_39,
            formats.CODE_93,
            formats.ITF,
            formats.CODABAR,
            formats.DATA_MATRIX
        ].filter(value => value !== undefined && value !== null);
        const qr = formats.QR_CODE;
        if (mode === 'qrcode') return qr !== undefined ? [qr] : undefined;
        if (mode === 'barcode') return barcodes.length ? barcodes : undefined;
        return unique([qr, ...barcodes]);
    }

    function qrboxForMode(mode) {
        if (mode === 'qrcode') {
            return (width, height) => {
                const available = Math.max(96, Math.min(width, height) - 8);
                const size = Math.min(available, Math.max(160, Math.floor(Math.min(width, height) * 0.68)));
                return { width: size, height: size };
            };
        }
        return (width, height) => {
            const availableWidth = Math.max(48, width - 8);
            const availableHeight = Math.max(48, height - 8);
            return {
                width: Math.min(availableWidth, Math.max(180, Math.floor(width * 0.82))),
                height: Math.min(availableHeight, Math.max(96, Math.floor(Math.min(height * 0.38, width * 0.34))))
            };
        };
    }

    function legacyQrbox(width, height) {
        const available = Math.max(96, Math.min(width, height) - 8);
        const size = Math.min(available, Math.max(160, Math.floor(Math.min(width, height) * 0.72)));
        return { width: size, height: size };
    }

    function ensureTarget(target) {
        let element = null;
        if (typeof target === 'string') element = global.document?.getElementById(target) || null;
        else if (isElement(target)) element = target;
        if (!element) throw new MageStockScannerError('target_missing', 'Zone caméra introuvable.', { recoverable: true });
        if (!element.id) element.id = 'msScannerTarget_' + Math.random().toString(36).slice(2, 10);
        try {
            const rect = element.getBoundingClientRect?.();
            if (!rect || rect.height < 120) {
                if (!element.style.minHeight) element.style.minHeight = '280px';
            }
        } catch (_) {}
        return element;
    }

    function cameraScore(camera, preferredId) {
        const id = toText(camera?.id);
        const label = lower(camera?.label);
        let score = id && id === preferredId ? 1000 : 0;
        if (/(back|rear|environment|arrière|arriere|trasera|traseira|hinten|posteriore)/.test(label)) score += 200;
        if (/(main|principal|wide|camera 0|caméra 0)/.test(label)) score += 20;
        if (/(front|user|avant|frontal|selfie)/.test(label)) score -= 300;
        if (/(ultra|tele|zoom|macro|depth)/.test(label)) score -= 15;
        return score;
    }

    function sortCameras(cameras, preferredId) {
        return (Array.isArray(cameras) ? cameras.slice() : [])
            .sort((a, b) => cameraScore(b, preferredId) - cameraScore(a, preferredId));
    }

    function normalizeError(error, fallbackCode) {
        if (error instanceof MageStockScannerError) return error;
        const name = lower(error?.name);
        const message = lower(error?.message || error);
        let code = fallbackCode || 'start_failed';
        let text = 'Impossible de démarrer le scanner.';
        let recoverable = true;

        if (!global.isSecureContext && !/^(localhost|127\.0\.0\.1)$/i.test(global.location?.hostname || '')) {
            code = 'insecure_context';
            text = 'La caméra exige une connexion HTTPS sécurisée.';
            recoverable = false;
        } else if (/notallowed|permission|denied|security/.test(name + ' ' + message)) {
            code = 'permission_denied';
            text = 'L’accès à la caméra a été refusé. Autorisez la caméra dans les réglages du navigateur.';
        } else if (/notfound|devicesnotfound|aucune caméra|no camera/.test(name + ' ' + message)) {
            code = 'no_camera';
            text = 'Aucune caméra compatible n’a été détectée.';
        } else if (/notreadable|trackstart|could not start|busy|in use/.test(name + ' ' + message)) {
            code = 'camera_busy';
            text = 'La caméra est déjà utilisée par une autre application ou un autre écran.';
        } else if (/overconstrained|constraint/.test(name + ' ' + message)) {
            code = 'camera_constraints';
            text = 'La caméra demandée n’est plus disponible. MageStock va essayer une autre caméra.';
        } else if (/abort/.test(name + ' ' + message)) {
            code = 'camera_aborted';
            text = 'Le démarrage de la caméra a été interrompu.';
        } else if (/html5|not loaded|non chargé/.test(name + ' ' + message)) {
            code = 'library_missing';
            text = 'La bibliothèque de scan n’est pas chargée.';
            recoverable = false;
        } else if (fallbackCode === 'image_failed') {
            code = 'image_failed';
            text = 'Aucun code lisible n’a été trouvé dans cette image.';
        }
        return new MageStockScannerError(code, text, { cause: error, recoverable, details: { originalName: error?.name || '', originalMessage: error?.message || toText(error) } });
    }

    function normalizeEans(raw) {
        const values = [];
        const add = value => {
            if (Array.isArray(value)) {
                value.forEach(add);
                return;
            }
            if (value && typeof value === 'object') {
                add(value.code ?? value.value ?? value.ean ?? value.barcode ?? '');
                return;
            }
            const text = toText(value);
            if (text) values.push(text);
        };
        if (typeof raw === 'string') {
            try {
                const parsed = JSON.parse(raw);
                if (Array.isArray(parsed)) add(parsed);
                else raw.split(/[;,\s]+/).forEach(add);
            } catch (_) {
                raw.split(/[;,\s]+/).forEach(add);
            }
        } else {
            add(raw);
        }
        return unique(values);
    }

    function normalizedDigits(value) {
        const text = toText(value);
        if (!/^\d+$/.test(text)) return text;
        return text.replace(/^0+(?=\d)/, '');
    }

    function parseCode(rawValue) {
        const raw = toText(rawValue);
        const parsed = {
            raw,
            kind: raw ? 'plain' : 'empty',
            payload: null,
            candidates: [],
            canonical: raw
        };
        if (!raw) return parsed;

        const add = (type, value, explicit) => {
            const text = toText(value);
            if (!text) return;
            if (!parsed.candidates.some(candidate => candidate.type === type && candidate.value === text)) {
                parsed.candidates.push({ type, value: text, explicit: !!explicit });
            }
        };

        if (raw.startsWith('{') && raw.endsWith('}')) {
            try {
                const payload = JSON.parse(raw);
                if (payload && typeof payload === 'object') {
                    parsed.kind = lower(payload.type) === 'product' ? 'magestock-product-qr' : 'json';
                    parsed.payload = payload;
                    add('barcode', payload.barcode, true);
                    add('ean', payload.ean, true);
                    add('sku', payload.sku, true);
                    add('id', payload.productId ?? payload.id, true);
                }
            } catch (_) {}
        }

        if (parsed.kind === 'plain' && /^magestock:/i.test(raw)) {
            parsed.kind = 'magestock-uri';
            const match = raw.match(/^magestock:\/\/(?:product|products)\/([^/?#]+)/i) || raw.match(/^magestock:product:([^/?#]+)/i);
            if (match) add('id', decodeURIComponent(match[1]), true);
            try {
                const asUrl = new URL(raw.replace(/^magestock:/i, 'https://magestock.local/'));
                add('id', asUrl.searchParams.get('productId') || asUrl.searchParams.get('id'), true);
                add('barcode', asUrl.searchParams.get('barcode'), true);
                add('ean', asUrl.searchParams.get('ean'), true);
                add('sku', asUrl.searchParams.get('sku'), true);
            } catch (_) {}
        }

        if (parsed.kind === 'plain' && /^(?:https?:\/\/|\/)/i.test(raw)) {
            try {
                const url = new URL(raw, global.location?.origin || 'https://magestock.local');
                const productMatch = url.pathname.match(/\/(?:product|products)\/(\d+)(?:\/|$)/i);
                parsed.kind = 'url';
                add('id', url.searchParams.get('productId') || url.searchParams.get('id') || productMatch?.[1], true);
                add('barcode', url.searchParams.get('barcode') || url.searchParams.get('code'), true);
                add('ean', url.searchParams.get('ean'), true);
                add('sku', url.searchParams.get('sku'), true);
            } catch (_) {}
        }

        if (!parsed.candidates.length) {
            if (/^\d{6,18}$/.test(raw)) {
                add('barcode', raw, false);
                add('ean', raw, false);
            } else {
                add('sku', raw, false);
                add('barcode', raw, false);
            }
        }
        parsed.canonical = parsed.candidates[0]?.value || raw;
        return parsed;
    }

    function matchProduct(product, candidate) {
        if (!product || !candidate) return 0;
        const value = toText(candidate.value);
        const productId = toText(product.id);
        const barcode = toText(product.barcode);
        const sku = toText(product.sku);
        const eans = normalizeEans(product.eans);

        if (candidate.type === 'id' && productId === value) return candidate.explicit ? 120 : 80;
        if (candidate.type === 'sku' && lower(sku) === lower(value)) return candidate.explicit ? 112 : 105;
        if ((candidate.type === 'barcode' || candidate.type === 'ean') && barcode === value) return 118;
        if ((candidate.type === 'barcode' || candidate.type === 'ean') && eans.includes(value)) return 117;
        if ((candidate.type === 'barcode' || candidate.type === 'ean') && normalizedDigits(barcode) === normalizedDigits(value) && barcode) return 108;
        if ((candidate.type === 'barcode' || candidate.type === 'ean') && eans.some(ean => normalizedDigits(ean) === normalizedDigits(value))) return 107;
        return 0;
    }

    function bestLocalMatches(products, parsed) {
        const matches = [];
        (Array.isArray(products) ? products : []).forEach(product => {
            let score = 0;
            let matchType = '';
            let matchedValue = '';
            parsed.candidates.forEach(candidate => {
                const candidateScore = matchProduct(product, candidate);
                if (candidateScore > score) {
                    score = candidateScore;
                    matchType = candidate.type;
                    matchedValue = candidate.value;
                }
            });
            if (score > 0) matches.push({ product, score, matchType, matchedValue });
        });
        return matches.sort((a, b) => b.score - a.score || String(a.product?.name || '').localeCompare(String(b.product?.name || '')));
    }

    function scopeFromOptions(options) {
        const values = [];
        const source = options || {};
        const keys = ['allowedProductIds', 'inventoryProductIds', 'orderProductIds', 'lines'];
        const provided = keys.some(key => Object.prototype.hasOwnProperty.call(source, key));
        const add = value => {
            if (Array.isArray(value)) value.forEach(add);
            else if (value && typeof value === 'object') add(value.productId ?? value.id);
            else if (value !== undefined && value !== null && value !== '') values.push(toText(value));
        };
        keys.forEach(key => add(source[key]));
        return { provided, ids: new Set(values.filter(Boolean)) };
    }

    const core = {
        version: VERSION,
        Error: MageStockScannerError,
        _state: {
            phase: 'idle',
            owner: null,
            context: null,
            mode: null,
            targetId: null,
            cameraId: null,
            sessionId: null,
            suspendedReason: null,
            error: null
        },
        _session: null,
        _tail: Promise.resolve(),
        _generation: 0,
        _lifecycleBound: false,
        _resumeTimer: null,
        _rotationTimer: null,
        _productCache: { at: 0, items: null, promise: null },

        on,
        off,

        getState() {
            return { ...this._state, running: this._state.phase === 'running' };
        },

        isRunning() {
            return this._state.phase === 'running';
        },

        get owner() {
            return this._state.owner;
        },

        _setState(phase, patch) {
            this._state = {
                ...this._state,
                ...(patch || {}),
                phase,
                error: patch && Object.prototype.hasOwnProperty.call(patch, 'error') ? patch.error : (phase === 'error' ? this._state.error : null)
            };
            emit('statechange', { state: this.getState() });
            try { this._session?.options?.onStateChange?.(this.getState()); } catch (_) {}
        },

        _queue(task) {
            const run = this._tail.then(task, task);
            this._tail = run.catch(() => {});
            return run;
        },

        _normalizeStartOptions(options) {
            const raw = options || {};
            return {
                ...raw,
                owner: toText(raw.owner) || DEFAULT_OWNER,
                context: normalizeContext(raw.context),
                mode: normalizeMode(raw.mode),
                fps: Math.min(30, Math.max(5, Number(raw.fps) || 12)),
                cooldownMs: Math.max(250, Number(raw.cooldownMs) || 850),
                duplicateWindowMs: Math.max(500, Number(raw.duplicateWindowMs) || 1600),
                managedLifecycle: raw.managedLifecycle === true,
                autoResume: raw.autoResume !== false,
                autoResolve: raw.autoResolve === true,
                continuous: raw.continuous !== false
            };
        },

        async getPermissionState() {
            try {
                if (!global.navigator?.permissions?.query) return 'unknown';
                const status = await global.navigator.permissions.query({ name: 'camera' });
                return status?.state || 'unknown';
            } catch (_) {
                return 'unknown';
            }
        },

        async listCameras() {
            if (!html5Available()) throw normalizeError(new Error('html5-qrcode non chargé.'), 'library_missing');
            try {
                const cameras = await global.Html5Qrcode.getCameras();
                const preferred = safeStorageGet(CAMERA_STORAGE_KEY);
                return sortCameras(cameras, preferred).map(camera => ({ id: toText(camera.id), label: toText(camera.label) || 'Caméra', preferred: toText(camera.id) === preferred }));
            } catch (error) {
                throw normalizeError(error);
            }
        },

        setPreferredCamera(cameraId) {
            safeStorageSet(CAMERA_STORAGE_KEY, toText(cameraId));
            emit('camerapreference', { cameraId: toText(cameraId) || null });
        },

        async start(options) {
            const normalized = this._normalizeStartOptions(options);
            return this._queue(() => this._startInternal(normalized));
        },

        async _startInternal(options, existingSession) {
            if (!html5Available()) {
                const error = normalizeError(new Error('html5-qrcode non chargé.'), 'library_missing');
                this._setState('error', { error, owner: options.owner, context: options.context });
                options.onError?.(error);
                throw error;
            }
            const target = ensureTarget(options.target);
            if (!isConnected(target)) throw new MageStockScannerError('target_missing', 'La zone caméra n’est plus affichée.');

            await this._stopHardware({ preserveSession: true });
            const generation = ++this._generation;
            const session = existingSession || {
                id: 'scan_' + now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
                startedAt: null,
                lastText: '',
                lastDetectedAt: 0,
                desired: true,
                suspended: false,
                resumeAfterLifecycle: false
            };
            session.options = options;
            session.target = target;
            session.targetId = target.id;
            session.generation = generation;
            session.desired = true;
            session.suspended = false;
            this._session = session;
            this._setState('starting', {
                owner: options.owner,
                context: options.context,
                mode: options.mode,
                targetId: target.id,
                sessionId: session.id,
                cameraId: null,
                suspendedReason: null,
                error: null
            });

            const formats = formatsForMode(options.mode);
            const config = {
                fps: options.fps,
                qrbox: options.qrbox || qrboxForMode(options.mode)
            };
            if (formats?.length) config.formatsToSupport = formats;
            if (options.aspectRatio) config.aspectRatio = options.aspectRatio;
            if (options.disableFlip !== undefined) config.disableFlip = !!options.disableFlip;

            const preferred = toText(options.cameraId) || safeStorageGet(CAMERA_STORAGE_KEY);
            let cameras = [];
            try { cameras = sortCameras(await global.Html5Qrcode.getCameras(), preferred); } catch (_) {}
            const cameraAttempts = [];
            if (preferred) cameraAttempts.push(preferred);
            cameras.forEach(camera => { if (camera?.id && !cameraAttempts.includes(camera.id)) cameraAttempts.push(camera.id); });
            cameraAttempts.push({ facingMode: { exact: 'environment' } });
            cameraAttempts.push({ facingMode: 'environment' });
            cameraAttempts.push({ facingMode: { ideal: 'environment' } });
            if (!cameraAttempts.length) cameraAttempts.push({ video: true });

            const detector = decodedText => {
                if (!this._session || this._session.generation !== generation || this._state.phase !== 'running') return;
                const raw = toText(decodedText);
                if (!raw) return;
                const detectedAt = now();
                if (raw === session.lastText && detectedAt - session.lastDetectedAt < options.duplicateWindowMs) return;
                if (detectedAt - session.lastDetectedAt < options.cooldownMs) return;
                session.lastText = raw;
                session.lastDetectedAt = detectedAt;
                emit('detected', { raw, sessionId: session.id, owner: options.owner, context: options.context });
                if (options.autoResolve) {
                    this.resolve(raw, { ...(options.resolverOptions || {}), context: options.context })
                        .then(result => {
                            emit('resolved', { result, sessionId: session.id, owner: options.owner, context: options.context });
                            const adapter = adapters.get(options.context);
                            if (result.status === 'found') {
                                options.onResolved?.(result);
                                adapter?.onResolved?.(result, options);
                            } else if (result.status === 'out_of_scope') {
                                options.onOutOfScope?.(result);
                                adapter?.onOutOfScope?.(result, options);
                            } else if (result.status === 'ambiguous') {
                                options.onAmbiguous?.(result);
                                adapter?.onAmbiguous?.(result, options);
                            } else {
                                options.onNotFound?.(result);
                                adapter?.onNotFound?.(result, options);
                            }
                        })
                        .catch(error => {
                            const normalized = normalizeError(error, 'resolve_failed');
                            options.onError?.(normalized);
                            adapters.get(options.context)?.onError?.(normalized, options);
                            emit('error', { error: normalized, phase: 'resolve', sessionId: session.id });
                        });
                } else {
                    try { options.onDetected?.(raw); } catch (error) { emit('error', { error: normalizeError(error), phase: 'callback', sessionId: session.id }); }
                }
            };

            let lastError = null;
            let selectedCameraId = null;
            for (const cameraConfig of cameraAttempts) {
                if (!this._session || this._session.generation !== generation) throw new MageStockScannerError('session_replaced', 'La session scanner a été remplacée.');
                const html5 = new global.Html5Qrcode(target.id);
                session.html5 = html5;
                try {
                    await html5.start(cameraConfig, config, detector, () => {});
                    selectedCameraId = typeof cameraConfig === 'string' ? cameraConfig : null;
                    session.startedAt = now();
                    session.detector = detector;
                    if (selectedCameraId) safeStorageSet(CAMERA_STORAGE_KEY, selectedCameraId);
                    this._setState('running', { cameraId: selectedCameraId, error: null });
                    emit('started', { sessionId: session.id, owner: options.owner, context: options.context, cameraId: selectedCameraId, mode: options.mode });
                    if (options.managedLifecycle) this._bindLifecycle();
                    return this.getState();
                } catch (error) {
                    lastError = error;
                    try { await html5.clear(); } catch (_) {}
                    if (session.html5 === html5) session.html5 = null;
                }
            }

            const normalizedError = normalizeError(lastError || new Error('Démarrage impossible.'));
            session.desired = false;
            this._setState('error', { error: normalizedError, cameraId: null });
            options.onError?.(normalizedError);
            emit('error', { error: normalizedError, phase: 'start', sessionId: session.id });
            throw normalizedError;
        },

        async _stopHardware(options) {
            const session = this._session;
            const html5 = session?.html5;
            if (html5) {
                try { await html5.stop(); } catch (_) {}
                try { await html5.clear(); } catch (_) {}
                if (session) session.html5 = null;
            }
            if (!options?.preserveSession && session) this._session = null;
        },

        async stop(options) {
            return this._queue(async () => {
                const session = this._session;
                if (!session && this._state.phase === 'idle') return this.getState();
                this._setState('stopping', {});
                if (session) {
                    session.desired = options?.preserveDesired === true;
                    session.resumeAfterLifecycle = false;
                }
                await this._stopHardware({ preserveSession: options?.preserveSession === true });
                if (!options?.preserveSession) this._session = null;
                this._setState('idle', {
                    owner: null,
                    context: null,
                    mode: null,
                    targetId: null,
                    cameraId: null,
                    sessionId: null,
                    suspendedReason: null,
                    error: null
                });
                emit('stopped', { reason: options?.reason || 'manual', previousSessionId: session?.id || null });
                return this.getState();
            });
        },

        async suspend(reason) {
            return this._queue(async () => {
                const session = this._session;
                if (!session || !['running', 'starting'].includes(this._state.phase)) return this.getState();
                session.resumeAfterLifecycle = session.options?.autoResume !== false && session.desired !== false;
                session.suspended = true;
                await this._stopHardware({ preserveSession: true });
                this._setState('suspended', { suspendedReason: reason || 'lifecycle', cameraId: null });
                emit('suspended', { reason: reason || 'lifecycle', sessionId: session.id });
                return this.getState();
            });
        },

        async resume(reason) {
            return this._queue(async () => {
                const session = this._session;
                if (!session || !session.options || !session.desired) return this.getState();
                if (!isConnected(session.target)) {
                    session.desired = false;
                    const error = new MageStockScannerError('target_missing', 'La zone caméra n’est plus affichée.');
                    this._setState('error', { error });
                    throw error;
                }
                emit('resuming', { reason: reason || 'manual', sessionId: session.id });
                return this._startInternal(session.options, session);
            });
        },

        async restart(reason) {
            const session = this._session;
            if (!session || !session.options || !session.desired) return this.getState();
            await this.suspend(reason || 'restart');
            return this.resume(reason || 'restart');
        },

        async scanImage(file, options) {
            const opts = options || {};
            if (!file) {
                const error = new MageStockScannerError('image_missing', 'Aucune image sélectionnée.');
                opts.onError?.(error);
                throw error;
            }
            if (!html5Available()) {
                const error = normalizeError(new Error('html5-qrcode non chargé.'), 'library_missing');
                opts.onError?.(error);
                throw error;
            }
            return this._queue(async () => {
                const priorSession = this._session;
                const shouldResume = this._state.phase === 'running' && priorSession?.desired && opts.resumeCamera !== false;
                if (shouldResume) {
                    priorSession.resumeAfterLifecycle = true;
                    await this._stopHardware({ preserveSession: true });
                    this._setState('paused', { suspendedReason: 'image', cameraId: null });
                }
                const temp = global.document.createElement('div');
                temp.id = 'msScannerImage_' + Math.random().toString(36).slice(2, 10);
                temp.hidden = true;
                temp.setAttribute('aria-hidden', 'true');
                global.document.body.appendChild(temp);
                const imageScanner = new global.Html5Qrcode(temp.id);
                try {
                    const raw = toText(await imageScanner.scanFile(file, true));
                    if (!raw) throw new Error('Aucun code détecté.');
                    emit('detected', { raw, source: 'image', owner: opts.owner || priorSession?.options?.owner || null, context: opts.context || priorSession?.options?.context || null });
                    opts.onDetected?.(raw);
                    if (opts.autoResolve) {
                        const result = await this.resolve(raw, { ...(opts.resolverOptions || {}), context: opts.context || priorSession?.options?.context || 'general' });
                        opts.onResolved?.(result);
                        return result;
                    }
                    return raw;
                } catch (error) {
                    const normalized = normalizeError(error, 'image_failed');
                    opts.onError?.(normalized);
                    emit('error', { error: normalized, phase: 'image' });
                    throw normalized;
                } finally {
                    try { await imageScanner.clear(); } catch (_) {}
                    temp.remove();
                    if (shouldResume && priorSession?.desired && isConnected(priorSession.target)) {
                        try { await this._startInternal(priorSession.options, priorSession); } catch (_) {}
                    }
                }
            });
        },

        async scanImageFile(file) {
            return this.scanImage(file, { resumeCamera: true });
        },

        parseCode,

        invalidateProductCache() {
            this._productCache = { at: 0, items: null, promise: null };
        },

        async _loadProducts(force) {
            const cache = this._productCache;
            if (!force && Array.isArray(cache.items) && now() - cache.at < PRODUCT_CACHE_TTL) return cache.items;
            if (!force && cache.promise) return cache.promise;
            if (!global.Api?.products) return [];
            cache.promise = Promise.resolve(global.Api.products())
                .then(items => {
                    cache.items = Array.isArray(items) ? items : [];
                    cache.at = now();
                    cache.promise = null;
                    return cache.items;
                })
                .catch(error => {
                    cache.promise = null;
                    throw error;
                });
            return cache.promise;
        },

        async resolve(rawValue, options) {
            const opts = options || {};
            const parsed = parseCode(rawValue);
            const context = normalizeContext(opts.context);
            if (!parsed.raw) return { status: 'invalid', raw: '', parsed, context, products: [], product: null, matchType: null };

            const adapter = adapters.get(context);
            const prepared = adapter?.prepareOptions ? await adapter.prepareOptions({ ...opts }, parsed) : { ...opts };
            let products = Array.isArray(prepared.products) ? prepared.products : null;
            let source = products ? 'provided' : 'api';
            let matches = products ? bestLocalMatches(products, parsed) : [];

            const explicitId = parsed.candidates.find(candidate => candidate.type === 'id' && candidate.explicit);
            if (!matches.length && global.Api?.get) {
                const exactCodes = parsed.candidates.filter(candidate => ['barcode', 'ean'].includes(candidate.type));
                for (const candidate of exactCodes) {
                    try {
                        const product = await global.Api.get('api/ean.php?code=' + encodeURIComponent(candidate.value));
                        if (product) {
                            matches = [{ product, score: 130, matchType: candidate.type, matchedValue: candidate.value }];
                            source = 'ean-api';
                            break;
                        }
                    } catch (error) {
                        if (Number(error?.status) !== 404) emit('resolvewarning', { error, candidate });
                    }
                }
            }

            if (!matches.length) {
                products = products || await this._loadProducts(prepared.forceRefresh === true);
                matches = bestLocalMatches(products, parsed);
            }

            if (!matches.length && global.Api?.products) {
                const searchCandidates = unique([
                    ...parsed.candidates.filter(candidate => candidate.type === 'sku').map(candidate => candidate.value),
                    explicitId?.value,
                    parsed.canonical
                ]);
                for (const term of searchCandidates) {
                    try {
                        const rows = await global.Api.products(term);
                        const exact = bestLocalMatches(rows, parsed);
                        if (exact.length) {
                            matches = exact;
                            source = 'products-api';
                            break;
                        }
                    } catch (_) {}
                }
            }

            if (!matches.length) {
                const result = { status: 'not_found', raw: parsed.raw, parsed, context, products: [], product: null, matchType: null, source };
                return adapter?.transformResult ? await adapter.transformResult(result, prepared) : result;
            }

            const bestScore = matches[0].score;
            const tied = matches.filter(match => match.score === bestScore);
            const scope = scopeFromOptions(prepared);
            const best = tied[0];
            let status = tied.length > 1 ? 'ambiguous' : 'found';
            if (scope.provided && !scope.ids.has(toText(best.product?.id))) status = 'out_of_scope';
            const result = {
                status,
                raw: parsed.raw,
                parsed,
                context,
                product: best.product,
                products: tied.map(match => match.product),
                matchType: best.matchType,
                matchedValue: best.matchedValue,
                source,
                scope: scope.provided ? Array.from(scope.ids) : null
            };
            return adapter?.transformResult ? await adapter.transformResult(result, prepared) : result;
        },

        registerAdapter(name, adapter) {
            const key = lower(name);
            if (!key) throw new Error('Nom d’adaptateur manquant.');
            adapters.set(key, adapter || {});
            CONTEXT_NAMES.add(key);
            emit('adapterregistered', { context: key });
            return () => adapters.delete(key);
        },

        getAdapter(name) {
            return adapters.get(normalizeContext(name)) || null;
        },

        createContext(name, defaults) {
            const context = normalizeContext(name);
            const base = { ...(defaults || {}), context };
            return Object.freeze({
                name: context,
                start: options => this.start({ ...base, ...(options || {}), context, managedLifecycle: options?.managedLifecycle !== false, autoResolve: options?.autoResolve !== false }),
                stop: options => this.stop(options),
                resolve: (raw, options) => this.resolve(raw, { ...base, ...(options || {}), context }),
                scanImage: (file, options) => this.scanImage(file, { ...base, ...(options || {}), context, autoResolve: options?.autoResolve !== false }),
                getState: () => this.getState()
            });
        },

        _bindLifecycle() {
            if (this._lifecycleBound || !global.document) return;
            this._lifecycleBound = true;
            global.document.addEventListener('visibilitychange', () => {
                const session = this._session;
                if (!session?.options?.managedLifecycle) return;
                if (global.document.visibilityState === 'hidden') {
                    if (['running', 'starting'].includes(this._state.phase)) this.suspend('visibility').catch(() => {});
                    return;
                }
                if (this._state.phase === 'suspended' && session.resumeAfterLifecycle && session.options.autoResume !== false) {
                    clearTimeout(this._resumeTimer);
                    this._resumeTimer = setTimeout(() => this.resume('visibility').catch(error => {
                        emit('error', { error: normalizeError(error), phase: 'resume' });
                    }), 240);
                }
            });
            global.addEventListener?.('pagehide', () => {
                if (this._session?.options?.managedLifecycle) this.stop({ reason: 'pagehide' }).catch(() => {});
            });
            global.addEventListener?.('freeze', () => {
                if (this._session?.options?.managedLifecycle) this.suspend('freeze').catch(() => {});
            });
            const rotationHandler = () => {
                const session = this._session;
                if (!session?.options?.managedLifecycle || !['running', 'starting'].includes(this._state.phase)) return;
                clearTimeout(this._rotationTimer);
                this._rotationTimer = setTimeout(() => this.restart('orientation').catch(error => {
                    emit('error', { error: normalizeError(error), phase: 'orientation' });
                }), 360);
            };
            global.addEventListener?.('orientationchange', rotationHandler);
            global.screen?.orientation?.addEventListener?.('change', rotationHandler);
        }
    };

    adapters.set('general', {});
    adapters.set('movement', {
        prepareOptions(options) {
            if (!options.allowedProductIds && Array.isArray(options.warehouseProductIds)) {
                options.allowedProductIds = options.warehouseProductIds;
            }
            return options;
        }
    });
    adapters.set('product-search', {
        prepareOptions(options) {
            // La recherche Produit reste volontairement globale sauf périmètre explicite.
            return options;
        }
    });
    adapters.set('location', {});
    adapters.set('inventory', {
        prepareOptions(options) {
            if (!options.allowedProductIds && Array.isArray(options.lines)) {
                options.allowedProductIds = options.lines.map(line => line.productId ?? line.id);
            }
            return options;
        }
    });
    adapters.set('order-receiving', {
        prepareOptions(options) {
            if (!options.allowedProductIds && Array.isArray(options.lines)) {
                options.allowedProductIds = options.lines.map(line => line.productId ?? line.id);
            }
            return options;
        }
    });

    const legacy = {
        start(options) {
            return core.start({
                ...(options || {}),
                owner: options?.owner || DEFAULT_OWNER,
                context: options?.context || 'general',
                managedLifecycle: options?.managedLifecycle === true,
                autoResolve: options?.autoResolve === true,
                qrbox: options?.qrbox || legacyQrbox
            });
        },
        stop(options) { return core.stop(options); },
        suspend(reason) { return core.suspend(reason); },
        resume(reason) { return core.resume(reason); },
        restart(reason) { return core.restart(reason); },
        scanImage(file, options) { return core.scanImage(file, options); },
        scanImageFile(file) { return core.scanImageFile(file); },
        listCameras() { return core.listCameras(); },
        setPreferredCamera(id) { return core.setPreferredCamera(id); },
        resolve(raw, options) { return core.resolve(raw, options); },
        parseCode(raw) { return core.parseCode(raw); },
        createContext(name, defaults) { return core.createContext(name, defaults); }
    };
    Object.defineProperties(legacy, {
        _running: { enumerable: true, get: () => core.isRunning() },
        _targetId: { enumerable: true, get: () => core.getState().targetId },
        _html5: { enumerable: false, get: () => core._session?.html5 || null },
        state: { enumerable: true, get: () => core.getState() }
    });

    global.MageStockScanner = core;
    global.ScannerService = legacy;
    emit('ready', { state: core.getState() });
})(window);
