/**
 * Live count screen.
 *  - Hardware scanner: type code + Enter -> exact lookup.
 *  - Type SKU/name -> debounced suggestion list (thumbnail, price, status).
 *  - Phone camera button -> live barcode scanning with inline qty confirm.
 *  - Item found -> counted instantly with the default quantity (1 / +1 per
 *    scan in tally modes); corrections happen on the editable recent list.
 *
 * Camera decoder: native BarcodeDetector (Android) first, ZXing fallback (iOS),
 * plus a guaranteed "photo scan" path. Approach reused from the store's POS.
 */
( function () {
	'use strict';

	var cfg = window.STWC || {};
	var i18n = cfg.i18n || {};
	var ACCUM = false;
	var BLIND = Number( cfg.blind ) === 1;

	var scan = document.getElementById( 'stwc-scan' );
	var camBtn = document.getElementById( 'stwc-cam-btn' );
	var suggest = document.getElementById( 'stwc-suggest' );
	var stage = document.getElementById( 'stwc-stage' );
	var recent = document.getElementById( 'stwc-recent' );
	var barFill = document.getElementById( 'stwc-bar-fill' );
	var countedEl = document.getElementById( 'stwc-counted' );
	var totalEl = document.getElementById( 'stwc-total' );
	var pctEl = document.getElementById( 'stwc-pct' );
	var remainingEl = document.getElementById( 'stwc-remaining' );
	var scanCard = scan ? scan.closest( '.stwc-scan-card' ) : null;

	if ( ! scan ) {
		return;
	}

	var suggestions = [];
	var activeIndex = -1;
	var searchTimer = null;
	var searchToken = 0;

	/* ---------- feedback ---------- */
	var actx = null;
	function beep( ok ) {
		try {
			actx = actx || new ( window.AudioContext || window.webkitAudioContext )();
			var o = actx.createOscillator(), g = actx.createGain();
			o.type = 'sine'; o.frequency.value = ok ? 880 : 220; g.gain.value = 0.05;
			o.connect( g ); g.connect( actx.destination );
			o.start(); o.stop( actx.currentTime + ( ok ? 0.08 : 0.18 ) );
		} catch ( e ) {}
	}
	function pulse( cls ) {
		if ( ! scanCard ) { return; }
		scanCard.classList.remove( 'is-ok', 'is-err' );
		void scanCard.offsetWidth;
		scanCard.classList.add( cls );
		setTimeout( function () { scanCard.classList.remove( cls ); }, 600 );
	}

	function api( path, opts ) {
		opts = opts || {};
		opts.headers = Object.assign( { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }, opts.headers || {} );
		// Bust every intermediary cache on reads: LiteSpeed's "private cache"
		// has been observed serving stale lookup GETs despite no-store
		// (x-litespeed-cache: hit,private) — a stale "not counted yet" would
		// turn a re-scan into an overwrite. A unique URL can never hit.
		if ( ! opts.method || 'GET' === opts.method ) {
			path += ( path.indexOf( '?' ) >= 0 ? '&' : '?' ) + '_=' + Date.now();
		}
		return fetch( cfg.root + path, opts ).then( function ( r ) {
			return r.json().then( function ( j ) { return { status: r.status, body: j }; } );
		} );
	}
	function postCount( payload ) {
		return api( 'count', { method: 'POST', body: JSON.stringify( payload ) } );
	}
	function postUncount( payload ) {
		return api( 'uncount', { method: 'POST', body: JSON.stringify( payload ) } );
	}

	function esc( s ) {
		return String( s ).replace( /[&<>"']/g, function ( c ) {
			return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
		} );
	}

	function resetScan() {
		stage.innerHTML = '';
		scan.value = '';
		scan.disabled = false;
		closeSuggest();
		scan.focus();
	}
	function showError( msg ) {
		stage.innerHTML = '<div class="stwc-stage__error"></div>';
		stage.firstChild.textContent = msg;
		// Never leave the scanner bricked after an error (expired nonce, network
		// blip, session closed mid-count): re-enable input so the user can retry
		// without reloading the page.
		scan.disabled = false;
		closeSuggest();
		scan.focus();
		pulse( 'is-err' ); beep( false );
	}

	// Parse a quantity accepting both dot and comma decimals (tr_TR/EU locales
	// type "12,5"); returns NaN for anything non-numeric.
	function num( v ) {
		return parseFloat( String( v ).replace( ',', '.' ) );
	}

	// Sanity ceiling for a hand-counted quantity. Its real job is to stop a
	// barcode that slipped past the burst detector (slow scanner, short code)
	// from being saved as a count — every EAN/UPC is at least 7 digits, far
	// above any real shelf count.
	// Mirrored from STWC_REST::MAX_QTY, which enforces the same ceiling; the
	// literal is only a fallback if the config never arrived.
	var MAX_QTY = cfg.maxQty || 1000000;
	function qtyValid( q ) {
		return ! isNaN( q ) && q >= 0 && q <= MAX_QTY;
	}

	// Hardware scanners are keyboard wedges: they type wherever the focus is,
	// including quantity fields. Watch a field for their signature — a run of
	// printable keys with tiny gaps, finished by Enter (or Tab, a common
	// scanner suffix) — and hand the code to onScan instead of letting it
	// corrupt the field: the field is restored to its pre-burst value first.
	// Humans can't sustain five keystrokes at under 150 ms apiece; key
	// auto-repeat can, so repeats reset the buffer.
	var BURST_GAP = 150, BURST_MIN = 4;
	function burstWatch( input, onScan ) {
		var buf = '', last = 0, pre = '', undoTimer = null, armed = false;
		// A fast run that dies without a terminator (a scanner stalling
		// mid-code, e.g. Bluetooth wake-up) is not human typing: revert it, or
		// its digits get committed as a quantity. Runs on the undo timer AND
		// inline on the next keydown, so a stall of exactly GAP..GAP+30ms can't
		// slip the stranded prefix past the timer into `pre`.
		function undoRun() {
			input.value = pre;
			buf = ''; armed = false;
			input.classList.add( 'is-invalid' );
			setTimeout( function () { input.classList.remove( 'is-invalid' ); }, 700 );
		}
		input.addEventListener( 'keydown', function ( e ) {
			var now = Date.now();
			clearTimeout( undoTimer );
			if ( armed && now - last > BURST_GAP ) { undoRun(); }
			if ( e.repeat ) { if ( armed ) { undoRun(); } buf = ''; return; }
			if ( e.key && e.key.length === 1 ) {
				if ( now - last > BURST_GAP ) { buf = ''; pre = input.value; armed = false; }
				buf += e.key; last = now;
				if ( buf.length >= BURST_MIN ) {
					armed = true;
					undoTimer = setTimeout( undoRun, BURST_GAP + 30 );
				}
				return;
			}
			if ( ( e.key === 'Enter' || e.key === 'Tab' ) && buf.length >= BURST_MIN && now - last <= BURST_GAP ) {
				e.preventDefault();
				e.stopImmediatePropagation();
				input.value = pre;
				var code = buf; buf = ''; armed = false;
				onScan( code );
			}
		} );
	}

	function updateProgress( summary ) {
		if ( ! summary ) { return; }
		var total = summary.total_items || 0, counted = summary.counted_items || 0;
		var pct = total ? Math.round( counted / total * 100 ) : 0;
		if ( countedEl ) { countedEl.textContent = counted; }
		if ( totalEl ) { totalEl.textContent = total; }
		if ( pctEl ) { pctEl.textContent = pct + '%'; }
		if ( barFill ) { barFill.style.width = pct + '%'; }
		if ( remainingEl ) { remainingEl.textContent = Math.max( 0, total - counted ); }
	}
	function chipFor( item ) {
		var cls = 'stwc-chip', text;
		if ( item.variance !== null && typeof item.variance !== 'undefined' ) {
			cls += item.variance < 0 ? ' stwc-chip--neg' : ( item.variance > 0 ? ' stwc-chip--pos' : ' stwc-chip--zero' );
			text = ( item.variance > 0 ? '+' : '' ) + item.variance;
		} else {
			text = i18n.counted + ': ' + item.counted_qty;
		}
		return { cls: cls, text: text };
	}
	// Signed values need bidi isolation (<bdi>) or RTL locales render "+5" as
	// "5+" — the PHP-rendered rows do the same.
	function setChip( chipEl, chip ) {
		chipEl.className = chip.cls;
		chipEl.textContent = '';
		var bdi = document.createElement( 'bdi' );
		bdi.textContent = chip.text;
		chipEl.appendChild( bdi );
	}
	function addRecent( item ) {
		var empty = recent.querySelector( '.stwc-recent__empty' );
		if ( empty ) { empty.remove(); }
		// One row per item: a re-scan/edit refreshes and re-tops its row
		// instead of stacking duplicates. The old row's armed debounce timer
		// dies with it — an orphaned saveRow on a detached row would POST a
		// stale overwrite.
		var dupe = recent.querySelector( 'li[data-item="' + item.id + '"]' );
		if ( dupe ) { clearTimeout( dupe._rowTimer ); dupe._removed = true; dupe.remove(); }

		var chip = chipFor( item );
		var li = document.createElement( 'li' );
		li.setAttribute( 'data-item', item.id );
		li.setAttribute( 'data-qty', item.counted_qty );

		var main = document.createElement( 'div' ); main.className = 'stwc-recent__main';
		var name = document.createElement( 'span' ); name.className = 'stwc-recent__name'; name.textContent = item.name;
		var chipEl = document.createElement( 'span' );
		setChip( chipEl, chip );
		main.appendChild( name ); main.appendChild( chipEl );

		var tools = document.createElement( 'div' ); tools.className = 'stwc-recent__tools';
		var minus = document.createElement( 'button' ); minus.type = 'button'; minus.className = 'stwc-rstep'; minus.setAttribute( 'data-d', '-1' ); minus.innerHTML = '&minus;';
		var qty = document.createElement( 'input' ); qty.type = 'number'; qty.step = 'any'; qty.inputMode = 'decimal'; qty.className = 'stwc-recent__qty'; qty.value = item.counted_qty; qty.setAttribute( 'aria-label', i18n.countedQty );
		var plus = document.createElement( 'button' ); plus.type = 'button'; plus.className = 'stwc-rstep'; plus.setAttribute( 'data-d', '1' ); plus.textContent = '+';
		var del = document.createElement( 'button' ); del.type = 'button'; del.className = 'stwc-recent__del'; del.textContent = '✕'; del.setAttribute( 'aria-label', i18n.remove ); del.title = i18n.remove;
		tools.appendChild( minus ); tools.appendChild( qty ); tools.appendChild( plus ); tools.appendChild( del );

		li.appendChild( main ); li.appendChild( tools );
		recent.insertBefore( li, recent.firstChild );
		burstWatch( qty, lookup ); // a scan aimed at a row's qty box is still a scan
	}

	/* ---------- recent list: inline edit + remove ---------- */
	function rowFlash( li, cls ) {
		li.classList.remove( 'is-saved', 'is-err' );
		void li.offsetWidth;
		li.classList.add( cls );
		setTimeout( function () { li.classList.remove( cls ); }, 900 );
	}
	function saveRow( li ) {
		var input = li.querySelector( '.stwc-recent__qty' );
		// A removed/replaced row must never save: its timer may still be armed
		// when the row leaves the DOM, and a late POST would resurrect a count
		// the user deleted (or clobber the row that replaced it).
		if ( ! input || li._rowSaving || li._removed || ! li.isConnected ) { return; }
		var qty = num( input.value );
		if ( ! qtyValid( qty ) ) { input.classList.add( 'is-invalid' ); return; }
		input.classList.remove( 'is-invalid' );
		if ( String( qty ) === String( li.getAttribute( 'data-qty' ) ) ) { return; }
		li._rowSaving = true;
		var sent = input.value;
		// `base` = the total this edit was made against. In multi-counter tally
		// modes the server refuses the overwrite (409 + fresh item) if someone
		// else moved the total in the meantime — a stale edit must not erase
		// their scans.
		li._savePromise = postCount( { session_id: cfg.sessionId, item_id: parseInt( li.getAttribute( 'data-item' ), 10 ), qty: qty, set: true, base: parseFloat( li.getAttribute( 'data-qty' ) ) } )
			.then( function ( res ) {
				li._rowSaving = false;
				if ( li._removed ) { return; }
				if ( res.status === 409 && res.body && res.body.item ) {
					// Conflict: show the fresh total instead of our stale edit —
					// unless the user typed a NEWER value meanwhile; retry that
					// one against the fresh base instead of destroying it.
					li.setAttribute( 'data-qty', res.body.item.counted_qty );
					var stwcTyped = input.value !== sent;
					if ( ! stwcTyped ) { input.value = res.body.item.counted_qty; }
					var freshChip = li.querySelector( '.stwc-chip' );
					if ( freshChip ) { setChip( freshChip, chipFor( res.body.item ) ); }
					if ( res.body.summary ) { updateProgress( res.body.summary ); }
					rowFlash( li, 'is-err' ); beep( false );
					if ( stwcTyped ) { queueRow( li ); }
					return;
				}
				if ( res.status >= 400 || ! res.body || ! res.body.ok ) {
					rowFlash( li, 'is-err' ); beep( false );
					// Only roll the field back if the user hasn't typed since;
					// a newer value gets retried instead of destroyed.
					if ( input.value === sent ) { input.value = li.getAttribute( 'data-qty' ); } else { queueRow( li ); }
					return;
				}
				li.setAttribute( 'data-qty', res.body.item.counted_qty );
				// The server may normalize the quantity (integer stock stores
				// round 2.5 to 2) — reflect what was actually saved.
				if ( input.value === sent ) { input.value = res.body.item.counted_qty; }
				var chipEl = li.querySelector( '.stwc-chip' );
				if ( chipEl ) { setChip( chipEl, chipFor( res.body.item ) ); }
				updateProgress( res.body.summary );
				// Keep the last-item panel honest if it shows this same line.
				if ( lastCard && lastCard.itemId === parseInt( li.getAttribute( 'data-item' ), 10 ) && String( num( lastCard.input.value ) ) === String( lastCard.qty ) ) {
					lastCard.qty = String( res.body.item.counted_qty );
					lastCard.input.value = res.body.item.counted_qty;
				}
				rowFlash( li, 'is-saved' );
				// The value moved again while this save was in flight — save the newest.
				if ( input.value !== sent && String( num( input.value ) ) !== String( li.getAttribute( 'data-qty' ) ) ) { queueRow( li ); }
			} )
			.catch( function () {
				li._rowSaving = false;
				if ( li._removed ) { return; }
				rowFlash( li, 'is-err' ); beep( false );
				if ( input.value !== sent ) { queueRow( li ); }
			} );
	}
	function queueRow( li ) {
		clearTimeout( li._rowTimer );
		li._rowTimer = setTimeout( function () { saveRow( li ); }, 600 );
	}
	function removeRow( li ) {
		// Cancel any queued edit and let an in-flight one settle first, so the
		// uncount is guaranteed to be the last write for this line.
		clearTimeout( li._rowTimer );
		li._removed = true;
		var settled = li._savePromise || Promise.resolve();
		settled.then( function () {}, function () {} ).then( function () {
			return postUncount( { session_id: cfg.sessionId, item_id: parseInt( li.getAttribute( 'data-item' ), 10 ) } );
		} ).then( function ( res ) {
				if ( res.status >= 400 || ! res.body || ! res.body.ok ) {
					li._removed = false;
					rowFlash( li, 'is-err' ); beep( false );
					// The remove cancelled a queued edit; the row survives, so
					// that edit must not be lost.
					var stwcIn = li.querySelector( '.stwc-recent__qty' );
					if ( stwcIn && String( num( stwcIn.value ) ) !== String( li.getAttribute( 'data-qty' ) ) ) { queueRow( li ); }
					return;
				}
				li.remove();
				updateProgress( res.body.summary );
				// A removed line's panel must not linger (its edits would 409
				// against a NULL total anyway).
				if ( lastCard && lastCard.itemId === parseInt( li.getAttribute( 'data-item' ), 10 ) ) {
					clearTimeout( lastCard.timer );
					lastCard = null;
					stage.innerHTML = '';
				}
				if ( ! recent.querySelector( 'li' ) ) {
					var empty = document.createElement( 'li' );
					empty.className = 'stwc-recent__empty';
					empty.textContent = i18n.emptyList;
					recent.appendChild( empty );
				}
			} )
			.catch( function () {
				li._removed = false;
				rowFlash( li, 'is-err' ); beep( false );
				var stwcIn = li.querySelector( '.stwc-recent__qty' );
				if ( stwcIn && String( num( stwcIn.value ) ) !== String( li.getAttribute( 'data-qty' ) ) ) { queueRow( li ); }
			} );
	}
	recent.addEventListener( 'click', function ( e ) {
		var li = e.target.closest ? e.target.closest( 'li[data-item]' ) : null;
		if ( ! li ) { return; }
		var step = e.target.closest( '.stwc-rstep' );
		if ( step ) {
			var input = li.querySelector( '.stwc-recent__qty' );
			var v = ( num( input.value ) || 0 ) + parseFloat( step.getAttribute( 'data-d' ) );
			if ( v < 0 ) { v = 0; }
			input.value = v;
			queueRow( li );
			return;
		}
		var del = e.target.closest( '.stwc-recent__del' );
		if ( del ) {
			// Two-tap remove: first tap arms the button, second tap deletes.
			if ( del.classList.contains( 'is-armed' ) ) {
				clearTimeout( del._arm );
				removeRow( li );
				return;
			}
			del.classList.add( 'is-armed' );
			del.textContent = i18n.confirmRemove;
			del._arm = setTimeout( function () { del.classList.remove( 'is-armed' ); del.textContent = '✕'; }, 2500 );
		}
	} );
	recent.addEventListener( 'input', function ( e ) {
		if ( e.target.classList && e.target.classList.contains( 'stwc-recent__qty' ) ) {
			var li = e.target.closest( 'li[data-item]' );
			if ( li ) { queueRow( li ); }
		}
	} );
	recent.addEventListener( 'keydown', function ( e ) {
		if ( e.key === 'Enter' && e.target.classList && e.target.classList.contains( 'stwc-recent__qty' ) ) {
			e.preventDefault();
			var li = e.target.closest( 'li[data-item]' );
			if ( li ) { clearTimeout( li._rowTimer ); saveRow( li ); }
		}
	} );
	// Server-rendered rows need the same scanner guard as JS-built ones.
	recent.querySelectorAll( '.stwc-recent__qty' ).forEach( function ( el ) { burstWatch( el, lookup ); } );

	/* ============================================================
	   Suggestions
	   ============================================================ */
	function closeSuggest() {
		suggestions = []; activeIndex = -1; suggest.innerHTML = '';
		scan.setAttribute( 'aria-expanded', 'false' );
		scan.removeAttribute( 'aria-activedescendant' );
		// Picking or dismissing the list resolves the pending choice; only the
		// next queued SCAN (drainScans runs before this) flags it as lost.
		choicePending = false;
	}
	function debounceSearch( q ) { clearTimeout( searchTimer ); searchTimer = setTimeout( function () { doSearch( q ); }, 220 ); }
	function doSearch( q ) {
		var token = ++searchToken;
		api( 'search?session_id=' + encodeURIComponent( cfg.sessionId ) + '&q=' + encodeURIComponent( q ) )
			.then( function ( res ) {
				// Belt and braces: the token dies on every scan/pick, and the
				// box must still show this very query — a reply landing after
				// the box was consumed must never reopen the dropdown.
				if ( token !== searchToken || scan.value.trim() !== q ) { return; }
				renderSuggest( ( res.body && res.body.results ) || [] );
			} ).catch( function () {} );
	}
	function renderSuggest( list ) {
		suggestions = list; activeIndex = -1; suggest.innerHTML = '';
		if ( ! list.length ) { scan.setAttribute( 'aria-expanded', 'false' ); return; }
		list.forEach( function ( s, i ) {
			var li = document.createElement( 'li' ); li.className = 'stwc-suggest__item'; li.setAttribute( 'role', 'option' ); li.id = 'stwc-sg-' + i; li.setAttribute( 'aria-selected', 'false' );
			var img = document.createElement( 'img' ); img.className = 'stwc-suggest__img'; img.src = s.image || ''; img.alt = ''; img.loading = 'lazy';
			var body = document.createElement( 'div' ); body.className = 'stwc-suggest__body';
			var name = document.createElement( 'div' ); name.className = 'stwc-suggest__name'; name.textContent = s.name;
			body.appendChild( name );
			// Variation attributes as always-visible chips — a long product name
			// ellipsizes, and the "Red, S" tail is exactly what tells rows apart.
			if ( s.variant ) {
				var chips = document.createElement( 'div' ); chips.className = 'stwc-suggest__variants';
				s.variant.split( ', ' ).forEach( function ( part ) {
					if ( ! part ) { return; }
					var chip = document.createElement( 'span' ); chip.className = 'stwc-vchip'; chip.textContent = part;
					chips.appendChild( chip );
				} );
				body.appendChild( chips );
			}
			var meta = document.createElement( 'div' ); meta.className = 'stwc-suggest__meta';
			var metaTxt = s.sku ? s.sku : '—';
			if ( s.in_session && s.expected !== null && typeof s.expected !== 'undefined' ) { metaTxt += ' · ' + i18n.expected + ' ' + s.expected; }
			if ( s.counted !== null && typeof s.counted !== 'undefined' ) { metaTxt += ' · ' + i18n.counted + ' ' + s.counted; }
			meta.textContent = metaTxt;
			body.appendChild( meta );
			var right = document.createElement( 'div' ); right.className = 'stwc-suggest__right';
			var price = document.createElement( 'div' ); price.className = 'stwc-suggest__price'; price.textContent = s.price || '';
			var tag = document.createElement( 'span' ); tag.className = 'stwc-suggest__tag ' + ( s.in_session ? 'is-in' : 'is-add' ); tag.textContent = s.in_session ? i18n.inCount : i18n.addNew;
			right.appendChild( price ); right.appendChild( tag );
			li.appendChild( img ); li.appendChild( body ); li.appendChild( right );
			li.addEventListener( 'mousedown', function ( e ) { e.preventDefault(); selectSuggestion( s ); } );
			li.addEventListener( 'mousemove', function () { setActive( i ); } );
			suggest.appendChild( li );
		} );
		scan.setAttribute( 'aria-expanded', 'true' );
	}
	function setActive( i ) {
		activeIndex = i;
		var items = suggest.children;
		for ( var k = 0; k < items.length; k++ ) {
			var on = k === i;
			items[ k ].classList.toggle( 'is-active', on );
			items[ k ].setAttribute( 'aria-selected', on ? 'true' : 'false' );
		}
		if ( i >= 0 && items[ i ] ) { scan.setAttribute( 'aria-activedescendant', items[ i ].id ); }
		else { scan.removeAttribute( 'aria-activedescendant' ); }
	}
	function move( dir ) {
		if ( ! suggestions.length ) { return; }
		var i = activeIndex + dir;
		if ( i < 0 ) { i = suggestions.length - 1; }
		if ( i >= suggestions.length ) { i = 0; }
		setActive( i );
		var el = suggest.children[ i ]; if ( el ) { el.scrollIntoView( { block: 'nearest' } ); }
	}
	function selectSuggestion( s ) {
		closeSuggest();
		if ( s.in_session && s.item_id ) {
			countItem( { id: s.item_id, counted_qty: s.counted } );
		} else {
			countItem( { _addProductId: s.add_id } );
		}
	}

	/* ============================================================
	   Exact lookup
	   ============================================================ */
	// Scans are processed strictly FIFO: code N is looked up AND counted before
	// code N+1 starts. A latest-wins scheme would silently DROP a code scanned
	// while the previous lookup was still in flight — a lost count.
	var scanQueue = [], scanBusy = false;
	// An ambiguous code (several variations matched) needs a human pick; a new
	// scan arriving first closes that picker. Never silently — the operator
	// must hear that the ambiguous item was NOT counted.
	var choicePending = false;
	function lookup( code ) {
		scanQueue.push( code );
		drainScans();
	}
	function drainScans() {
		if ( scanBusy || ! scanQueue.length ) { return; }
		scanBusy = true;
		if ( choicePending ) { choicePending = false; pulse( 'is-err' ); beep( false ); }
		var code = scanQueue.shift();
		doLookup( code ).then( function () { scanBusy = false; drainScans(); }, function () { scanBusy = false; drainScans(); } );
	}
	function doLookup( code ) {
		closeSuggest();
		clearTimeout( searchTimer ); // cancel any PENDING typeahead...
		searchToken++; // ...and invalidate one already in flight: its late reply
		// would reopen the dropdown over the fresh panel, reading as "the scan
		// wasn't recognized" — and picking from it would count the item AGAIN.
		// Flush a pending panel edit first: a same-item re-scan must add on
		// top of the corrected total, not the stale one.
		return cardSync().then( function () {
			return api( 'lookup?session_id=' + encodeURIComponent( cfg.sessionId ) + '&code=' + encodeURIComponent( code ) );
		} )
			.then( function ( res ) {
				var body = res.body || {};
				if ( body.found ) { return countItem( body.item ); }
				if ( body.out_of_scope ) {
					// The product exists in the catalog — the counter is holding
					// it, so it IS here: add it to the session and count it, no
					// blocking question. The panel shows an info band instead.
					return countItem( { _addProductId: body.product_id, _outside: true } );
				}
				if ( body.suggestions && body.suggestions.length ) {
					// A shared/parent code matched several variations — let the
					// counter pick the exact one instead of dead-ending.
					choicePending = true;
					renderSuggest( body.suggestions );
					scan.focus();
				} else {
					// Truly unknown code: in-page error (never a browser popup),
					// echoing the code so a mis-read is obvious at a glance.
					showError( ( body.message || i18n.notFound ) + ' — ' + code );
					scan.value = ''; scan.focus();
				}
			} ).catch( function () { showError( i18n.saveError ); } );
	}

	/* ============================================================
	   Instant count + last-item edit panel
	   ============================================================ */
	// A resolved item (exact scan, suggestion pick, camera read) is counted
	// IMMEDIATELY — 1 on a first count; a RE-scan adds one ON TOP in every
	// mode (with a loud "5 + 1 = 6" band so an accidental double-read is
	// never silent). The cursor never leaves the scan box. The stage then
	// shows a big edit panel for that LAST item — not a prompt: the count is
	// already saved; touching the stepper corrects it (auto-saved), scanning
	// on simply replaces the panel.
	function countItem( item ) {
		closeSuggest();
		clearTimeout( searchTimer );
		searchToken++; // a suggestion pick also supersedes any in-flight search
		scan.value = '';
		var prev = ( item.counted_qty !== null && typeof item.counted_qty !== 'undefined' ) ? parseFloat( item.counted_qty ) : null;
		var payload = { session_id: cfg.sessionId, qty: 1 };
		if ( item._addProductId ) {
			payload.add_product_id = item._addProductId;
		} else if ( prev !== null && ! ACCUM ) {
			// Overwrite modes accumulate on re-scan via an explicit set with a
			// CAS base: two devices re-scanning at once can't lose a unit (the
			// free build plain-overwrites; the guard ships in Pro).
			payload.item_id = item.id; payload.qty = prev + 1; payload.set = true; payload.base = prev;
		} else {
			payload.item_id = item.id; // first count, or a tally mode's +1
		}
		return postCount( payload ).then( function ( res ) {
			if ( res.status === 409 && res.body && res.body.item && ! item._addProductId ) {
				// The total moved between lookup and save — retry once on top
				// of the fresh total (or as a first count if it was removed).
				var f = res.body.item.counted_qty;
				var retry = ( f === null || typeof f === 'undefined' )
					? { session_id: cfg.sessionId, item_id: item.id, qty: 1 }
					: { session_id: cfg.sessionId, item_id: item.id, qty: parseFloat( f ) + 1, set: true, base: parseFloat( f ) };
				return postCount( retry ).then( function ( res2 ) {
					return countedResponse( res2, ( f === null || typeof f === 'undefined' ) ? null : parseFloat( f ), false );
				} );
			}
			return countedResponse( res, prev, !! item._outside );
		} ).catch( function () { showError( i18n.saveError ); return null; } );
	}
	function countedResponse( res, prev, outside ) {
		if ( res.status >= 400 || ! res.body || ! res.body.ok ) {
			showError( ( res.body && res.body.message ) || i18n.saveError );
			return null;
		}
		updateProgress( res.body.summary );
		addRecent( res.body.item );
		renderLastCard( res.body.item, prev, outside );
		pulse( 'is-ok' ); beep( true );
		scan.focus();
		var saved = res.body.item;
		saved._prev = prev;       // the camera status line reuses the "5 + 1 = 6" story
		saved._outside = outside; // ...and the "added from outside the scope" note
		return saved;
	}

	/* ---------- last-item edit panel ---------- */
	var lastCard = null; // { itemId, input, qty, timer, saving, again, savePromise }

	function renderLastCard( item, prev, outside ) {
		var warn = '';
		if ( prev !== null ) {
			warn = '<div class="stwc-last__warn">⚠ ' + esc( i18n.alreadyCounted ) + ' <b>' + esc( prev ) + ' + ' + esc( parseFloat( item.counted_qty ) - prev ) + ' = ' + esc( item.counted_qty ) + '</b></div>';
		} else if ( outside ) {
			warn = '<div class="stwc-last__info">ℹ ' + esc( i18n.addedOutside ) + '</div>';
		}
		var thumb = item.image ? '<img class="stwc-qty__img" src="' + esc( item.image ) + '" alt="">' : '';
		var sku = item.sku ? ' <small class="stwc-muted">(' + esc( item.sku ) + ')</small>' : '';
		var meta = '';
		if ( ! BLIND && item.expected_qty !== null && typeof item.expected_qty !== 'undefined' ) { meta = esc( i18n.expected ) + ': <b>' + esc( item.expected_qty ) + '</b>'; }
		stage.innerHTML =
			warn +
			'<div class="stwc-qty stwc-last">' +
				'<div class="stwc-qty__head">' + thumb +
					'<div><div class="stwc-qty__name">✓ ' + esc( item.name ) + sku + '</div>' +
					'<div class="stwc-qty__meta">' + ( meta || '&nbsp;' ) + '</div></div>' +
				'</div>' +
				'<div class="stwc-qty__stepper">' +
					'<button type="button" class="stwc-step" data-d="-1">&minus;</button>' +
					'<input type="number" step="any" inputmode="decimal" class="stwc-qty__input" aria-label="' + esc( i18n.countedQty ) + '" />' +
					'<button type="button" class="stwc-step" data-d="1">+</button>' +
				'</div>' +
				'<div class="stwc-last__note">' + esc( i18n.savesAuto ) + '</div>' +
			'</div>';

		var input = stage.querySelector( '.stwc-qty__input' );
		input.value = item.counted_qty;
		var card = { itemId: parseInt( item.id, 10 ), input: input, qty: String( item.counted_qty ), timer: null, saving: false, again: false, savePromise: null };
		lastCard = card;

		stage.querySelectorAll( '.stwc-step' ).forEach( function ( btn ) {
			btn.addEventListener( 'click', function () {
				var v = ( num( input.value ) || 0 ) + parseFloat( btn.getAttribute( 'data-d' ) );
				if ( v < 0 ) { v = 0; }
				input.value = v;
				queueCard( card );
			} );
		} );
		input.addEventListener( 'input', function () { queueCard( card ); } );
		burstWatch( input, lookup ); // a scan into the panel's qty box is still a scan
		input.addEventListener( 'keydown', function ( e ) {
			if ( e.key === 'Enter' ) { e.preventDefault(); saveCardNow( card ); scan.focus(); }
			else if ( e.key === 'Escape' ) { e.preventDefault(); scan.focus(); }
		} );
	}
	function cardFlash( ok ) {
		var el = stage.querySelector( '.stwc-last' );
		if ( ! el ) { return; }
		el.classList.remove( 'is-saved', 'is-err' );
		void el.offsetWidth;
		el.classList.add( ok ? 'is-saved' : 'is-err' );
		setTimeout( function () { el.classList.remove( 'is-saved', 'is-err' ); }, 900 );
	}
	function queueCard( card ) {
		clearTimeout( card.timer );
		card.timer = setTimeout( function () { saveCardNow( card ); }, 600 );
	}
	function saveCardNow( card ) {
		clearTimeout( card.timer );
		if ( card.saving ) { card.again = true; return card.savePromise || Promise.resolve(); }
		var qty = num( card.input.value );
		if ( ! qtyValid( qty ) ) { card.input.classList.add( 'is-invalid' ); return Promise.resolve(); }
		card.input.classList.remove( 'is-invalid' );
		if ( String( qty ) === String( card.qty ) ) { return card.savePromise || Promise.resolve(); }
		card.saving = true;
		var sent = card.input.value;
		card.savePromise = postCount( { session_id: cfg.sessionId, item_id: card.itemId, qty: qty, set: true, base: parseFloat( card.qty ) } )
			.then( function ( res ) {
				card.saving = false;
				if ( res.status === 409 && res.body && res.body.item ) {
					// Someone else moved this line: show the fresh total unless
					// the user typed again meanwhile (then retry on fresh base).
					card.qty = String( res.body.item.counted_qty );
					if ( card.input.value === sent ) { card.input.value = res.body.item.counted_qty; } else { queueCard( card ); }
					addRecent( res.body.item );
					if ( res.body.summary ) { updateProgress( res.body.summary ); }
					cardFlash( false ); beep( false );
					return;
				}
				if ( res.status >= 400 || ! res.body || ! res.body.ok ) {
					cardFlash( false ); beep( false );
					if ( card.input.value === sent ) { card.input.value = card.qty; } else { queueCard( card ); }
					return;
				}
				card.qty = String( res.body.item.counted_qty );
				if ( card.input.value === sent ) { card.input.value = res.body.item.counted_qty; }
				addRecent( res.body.item );
				updateProgress( res.body.summary );
				cardFlash( true );
				if ( card.again || ( card.input.value !== sent && String( num( card.input.value ) ) !== String( card.qty ) ) ) { card.again = false; queueCard( card ); }
			} )
			.catch( function () {
				card.saving = false;
				cardFlash( false ); beep( false );
				if ( card.input.value !== sent ) { queueCard( card ); }
			} );
		return card.savePromise;
	}
	// Flush a pending panel edit BEFORE the next lookup, so a same-item
	// re-scan reads the corrected total it must add on top of.
	function cardSync() {
		var card = lastCard;
		if ( ! card ) { return Promise.resolve(); }
		return saveCardNow( card ).then( function () {}, function () {} );
	}

	/* ============================================================
	   Input wiring
	   ============================================================ */
	scan.addEventListener( 'keydown', function ( e ) {
		if ( e.key === 'ArrowDown' ) { if ( suggestions.length ) { e.preventDefault(); move( 1 ); } return; }
		if ( e.key === 'ArrowUp' ) { if ( suggestions.length ) { e.preventDefault(); move( -1 ); } return; }
		if ( e.key === 'Escape' ) { closeSuggest(); return; }
		if ( e.key === 'Enter' ) {
			e.preventDefault();
			if ( suggestions.length && activeIndex >= 0 ) { selectSuggestion( suggestions[ activeIndex ] ); return; }
			var code = scan.value.trim();
			// Consume the code synchronously — the box stays live during the
			// lookup, and a second scan must not concatenate onto this one.
			scan.value = '';
			if ( code ) { lookup( code ); }
		}
	} );
	scan.addEventListener( 'input', function () {
		var q = scan.value.trim();
		if ( q.length < 2 ) { closeSuggest(); return; }
		debounceSearch( q );
	} );
	document.addEventListener( 'mousedown', function ( e ) {
		if ( scanCard && ! scanCard.contains( e.target ) ) { closeSuggest(); }
	} );

	/* ============================================================
	   Phone camera scanner
	   ============================================================ */
	if ( camBtn ) {
		camBtn.addEventListener( 'click', function ( e ) { e.preventDefault(); openCamera(); } );
	}

	function openCamera() {
		var ov = document.createElement( 'div' );
		ov.className = 'stwc-cam-ov';
		ov.innerHTML =
			'<div class="stwc-cam" role="dialog" aria-modal="true" tabindex="-1" aria-label="' + esc( i18n.camTitle ) + '">' +
				'<div class="stwc-cam-head"><span>' + esc( i18n.camTitle ) + ' <b class="stwc-cam-headbeta">' + esc( i18n.beta ) + '</b></span><button type="button" class="stwc-cam-x">✕ ' + esc( i18n.camClose ) + '</button></div>' +
				'<div class="stwc-cam-stage"><video id="stwc-cam-video" autoplay muted playsinline></video><div class="stwc-cam-guide"></div></div>' +
				'<div class="stwc-cam-tools">' +
					'<button type="button" id="stwc-cam-zo" hidden>−</button>' +
					'<button type="button" id="stwc-cam-zi" hidden>+ ' + esc( i18n.camZoomIn ) + '</button>' +
					'<button type="button" id="stwc-cam-torch" hidden>🔦 ' + esc( i18n.camLight ) + '</button>' +
					'<label class="stwc-cam-shot">📸 ' + esc( i18n.camPhoto ) + '<input type="file" accept="image/*" capture="environment" hidden></label>' +
				'</div>' +
				'<div class="stwc-cam-status" id="stwc-cam-status" aria-live="polite">' + esc( i18n.camStart ) + '</div>' +
				'<div class="stwc-cam-note">' + esc( i18n.camNote ) + '</div>' +
			'</div>';
		document.body.appendChild( ov );

		var stopped = false, paused = false, lastCode = '', lastTime = 0, stream = null, track = null;
		var video = ov.querySelector( '#stwc-cam-video' );
		function byId( id ) { return ov.querySelector( '#' + id ); }
		// The status line is the only confirmation a count gets, read at arm's
		// length against a shelf. `kind` colours the outcome so it reads at a
		// glance instead of word by word; the words still say everything.
		function setStatus( t, kind ) {
			var s = byId( 'stwc-cam-status' );
			if ( ! s ) { return; }
			s.textContent = t;
			s.className = 'stwc-cam-status' + ( kind ? ' is-' + kind : '' );
		}
		// The beta note is onboarding: worth reading before the first scan,
		// clutter under a live status line once counting has started. Retire
		// it as soon as an item lands, so the one line that changes stands
		// alone.
		function noteOff() { var n = ov.querySelector( '.stwc-cam-note' ); if ( n ) { n.remove(); } }
		function stamp() { try { return ( window.performance && performance.now ) ? performance.now() : 0; } catch ( e ) { return 0; } }

		// Keep the screen awake while the camera is open. A stocktake is scan,
		// reach for the next item, scan again — and the phone locks in the gap,
		// which stops the camera and costs a re-open every time. The lock is a
		// request, not a guarantee: browsers drop it whenever the page is hidden,
		// so it is taken again on the way back.
		var wakeLock = null;
		function wakeOn() {
			try {
				if ( ! navigator.wakeLock || ! navigator.wakeLock.request ) { return; }
				navigator.wakeLock.request( 'screen' ).then( function ( l ) {
					if ( stopped ) { try { l.release(); } catch ( e ) {} return; }
					wakeLock = l;
				} ).catch( function () {} );
			} catch ( e ) {}
		}
		function wakeOff() {
			if ( ! wakeLock ) { return; }
			try { wakeLock.release(); } catch ( e ) {}
			wakeLock = null;
		}
		// Hidden tab: drop the lock (the browser drops it anyway) and stop asking
		// the decoder to work on a frame nobody can see. Visible again: take it back.
		function visHandler() {
			if ( document.hidden ) { wakeOff(); } else if ( ! stopped ) { wakeOn(); }
		}

		function escHandler( e ) { if ( e.key === 'Escape' ) { close(); } }
		function close() {
			if ( ! stopped ) { stopped = true; if ( stream ) { stream.getTracks().forEach( function ( t ) { try { t.stop(); } catch ( e ) {} } ); } }
			document.removeEventListener( 'keydown', escHandler );
			wakeOff();
			document.removeEventListener( 'visibilitychange', visHandler );
			ov.remove();
			scan.focus();
		}
		var closeBtn = ov.querySelector( '.stwc-cam-x' );
		closeBtn.onclick = close;
		// No tap-to-close on the backdrop. Measured on a 375x812 phone the
		// backdrop is 16% of the screen -- a 38px band above and below the
		// dialog and a 14px rail down each side -- and a stocktake is done
		// one-handed, tapping zoom and light while the other hand holds the
		// item. A stray touch there killed the stream, and reopening costs a
		// camera restart plus the zoom and torch settings. Closing stays
		// deliberate: the labelled Close button, or Escape.
		document.addEventListener( 'keydown', escHandler );
		document.addEventListener( 'visibilitychange', visHandler );
		wakeOn();
		// Focus the dialog, not the Close button. A handheld scanner types into
		// whatever has focus and ends with Enter -- with Close focused that
		// Enter pressed it, so a merchant who reached for their scanner while
		// the camera was open shut the camera and lost the scan. Escape is
		// bound on the document, so it still closes from here.
		ov.querySelector( '.stwc-cam' ).focus();

		function onDetect( text ) {
			if ( ! text || paused ) { return; }
			var now = stamp();
			if ( text === lastCode && now - lastTime < 1800 ) { return; }
			lastCode = text; lastTime = now;
			if ( navigator.vibrate ) { try { navigator.vibrate( 60 ); } catch ( e ) {} }
			paused = true;
			handleCode( text );
		}

		function handleCode( code ) {
			setStatus( '… ' + code );
			// Same instant-count flow as the scan box: a recognized item is
			// saved right away (default qty); the status line confirms it and
			// scanning continues. Corrections happen on the recent list.
			api( 'lookup?session_id=' + encodeURIComponent( cfg.sessionId ) + '&code=' + encodeURIComponent( code ) )
				.then( function ( res ) {
					var body = res.body || {};
					if ( body.found ) { return camCount( body.item ); }
					if ( body.out_of_scope ) {
						// Same no-question policy as the scan box: the product is
						// physically in hand — add it and count it.
						return camCount( { _addProductId: body.product_id, _outside: true } );
					}
					setStatus( i18n.notFound + ' — ' + code, 'err' ); beep( false );
					setTimeout( function () { paused = false; setStatus( i18n.camAlign ); }, 1200 );
				} ).catch( function () { setStatus( i18n.saveError, 'err' ); paused = false; } );
		}

		function camCount( item ) {
			return countItem( item ).then( function ( saved ) {
				if ( saved && saved._prev !== null && typeof saved._prev !== 'undefined' ) {
					setStatus( '⚠ ' + saved.name + ' — ' + i18n.alreadyCounted + ' ' + saved._prev + ' + ' + ( parseFloat( saved.counted_qty ) - saved._prev ) + ' = ' + saved.counted_qty, 'warn' );
				} else if ( saved && saved._outside ) {
					setStatus( 'ℹ ' + saved.name + ' — ' + i18n.addedOutside, 'info' );
				} else if ( saved ) {
					setStatus( '✓ ' + saved.name + ' — ' + i18n.counted + ': ' + saved.counted_qty, 'ok' );
				} else { setStatus( i18n.saveError, 'err' ); }
				if ( saved ) { noteOff(); }
				// Restart the duplicate-read window from save time, so the same
				// label still in frame doesn't instantly count again.
				lastTime = stamp(); paused = false;
			} );
		}

		/* decoders */
		var nativeDet = null, zxReader = null;
		function initNative( cb ) {
			if ( ! ( 'BarcodeDetector' in window ) ) { cb(); return; }
			var WANT = [ 'code_128', 'ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_39', 'itf', 'codabar', 'qr_code' ];
			window.BarcodeDetector.getSupportedFormats().then( function ( sup ) {
				var f = WANT.filter( function ( x ) { return sup.indexOf( x ) >= 0; } );
				try { nativeDet = f.length ? new window.BarcodeDetector( { formats: f } ) : new window.BarcodeDetector(); } catch ( e ) { nativeDet = null; }
				cb();
			} ).catch( function () { try { nativeDet = new window.BarcodeDetector(); } catch ( e ) { nativeDet = null; } cb(); } );
		}
		function zx() {
			if ( zxReader !== null ) { return zxReader; }
			if ( typeof ZXing === 'undefined' ) { zxReader = false; return false; }
			try {
				var hints = new Map();
				hints.set( ZXing.DecodeHintType.POSSIBLE_FORMATS, [ ZXing.BarcodeFormat.CODE_128, ZXing.BarcodeFormat.EAN_13, ZXing.BarcodeFormat.EAN_8, ZXing.BarcodeFormat.UPC_A, ZXing.BarcodeFormat.UPC_E, ZXing.BarcodeFormat.CODE_39, ZXing.BarcodeFormat.ITF, ZXing.BarcodeFormat.QR_CODE ] );
				hints.set( ZXing.DecodeHintType.TRY_HARDER, true );
				var r = new ZXing.MultiFormatReader(); r.setHints( hints ); zxReader = r;
			} catch ( e ) { zxReader = false; }
			return zxReader;
		}
		function decodeCanvas( canvas ) {
			if ( nativeDet ) { return nativeDet.detect( canvas ).then( function ( codes ) { return ( codes && codes.length ) ? codes[ 0 ].rawValue : null; } ).catch( function () { return null; } ); }
			var r = zx();
			if ( r ) {
				try {
					var src = new ZXing.HTMLCanvasElementLuminanceSource( canvas );
					var bmp = new ZXing.BinaryBitmap( new ZXing.HybridBinarizer( src ) );
					var res = r.decodeWithState( bmp );
					return Promise.resolve( res ? ( res.getText ? res.getText() : res.text ) : null );
				} catch ( e ) { return Promise.resolve( null ); }
			}
			return Promise.resolve( null );
		}

		var work = document.createElement( 'canvas' );
		var wctx = work.getContext( '2d', { willReadFrequently: true } );
		function grab( rot ) {
			var vw = video.videoWidth, vh = video.videoHeight;
			if ( ! vw || ! vh ) { return null; }
			var cw = Math.round( vw * 0.86 ), ch = Math.round( vh * 0.5 ), cx = ( vw - cw ) / 2, cy = ( vh - ch ) / 2;
			var scale = Math.min( 1.6, 1280 / cw ), dw = Math.round( cw * scale ), dh = Math.round( ch * scale );
			if ( ! rot ) { work.width = dw; work.height = dh; wctx.drawImage( video, cx, cy, cw, ch, 0, 0, dw, dh ); }
			else { work.width = dh; work.height = dw; wctx.save(); wctx.translate( dh / 2, dw / 2 ); wctx.rotate( Math.PI / 2 ); wctx.drawImage( video, cx, cy, cw, ch, -dw / 2, -dh / 2, dw, dh ); wctx.restore(); }
			return work;
		}
		function setupCaps() {
			if ( ! track || ! track.getCapabilities ) { return; }
			var caps = {}; try { caps = track.getCapabilities() || {}; } catch ( e ) {}
			if ( caps.focusMode && caps.focusMode.indexOf && caps.focusMode.indexOf( 'continuous' ) >= 0 ) { track.applyConstraints( { advanced: [ { focusMode: 'continuous' } ] } ).catch( function () {} ); }
			if ( caps.zoom && typeof caps.zoom.max === 'number' && caps.zoom.max > caps.zoom.min ) {
				var zi = byId( 'stwc-cam-zi' ), zo = byId( 'stwc-cam-zo' );
				var step = ( caps.zoom.step && caps.zoom.step > 0 ) ? caps.zoom.step * 2 : ( ( caps.zoom.max - caps.zoom.min ) / 6 || 0.5 );
				var z = Math.min( caps.zoom.max, caps.zoom.min + ( caps.zoom.max - caps.zoom.min ) * 0.3 );
				function applyZ() { track.applyConstraints( { advanced: [ { zoom: z } ] } ).catch( function () {} ); }
				applyZ(); zi.hidden = false; zo.hidden = false;
				zi.onclick = function () { z = Math.min( caps.zoom.max, z + step ); applyZ(); };
				zo.onclick = function () { z = Math.max( caps.zoom.min, z - step ); applyZ(); };
			}
			if ( caps.torch ) {
				var tb = byId( 'stwc-cam-torch' ); tb.hidden = false; var on = false;
				tb.onclick = function () { on = ! on; track.applyConstraints( { advanced: [ { torch: on } ] } ).catch( function () {} ); tb.classList.toggle( 'on', on ); };
			}
		}

		var shotInput = ov.querySelector( '.stwc-cam-shot input' );
		shotInput.onchange = function () {
			var f = shotInput.files && shotInput.files[ 0 ]; if ( ! f ) { return; }
			setStatus( '…' );
			var img = new Image();
			img.onload = function () {
				decodePhoto( img ).then( function ( t ) {
					if ( t ) { onDetect( t ); } else { setStatus( i18n.camAlign ); }
					try { URL.revokeObjectURL( img.src ); } catch ( e ) {}
					shotInput.value = '';
				} );
			};
			img.onerror = function () { setStatus( i18n.saveError, 'err' ); shotInput.value = ''; };
			img.src = URL.createObjectURL( f );
		};
		function decodePhoto( img ) {
			var pc = document.createElement( 'canvas' ), pctx = pc.getContext( '2d', { willReadFrequently: true } );
			var tries = [];
			[ 1600, 1100, 2400 ].forEach( function ( w ) { [ 0, 90, 180, 270 ].forEach( function ( r ) { tries.push( { w: w, r: r } ); } ); } );
			function attempt( i ) {
				if ( i >= tries.length ) { return Promise.resolve( null ); }
				var t = tries[ i ];
				var scale = Math.min( 1, t.w / ( img.naturalWidth || 1 ) );
				var w = Math.max( 1, Math.round( img.naturalWidth * scale ) ), h = Math.max( 1, Math.round( img.naturalHeight * scale ) );
				if ( t.r % 180 === 0 ) { pc.width = w; pc.height = h; } else { pc.width = h; pc.height = w; }
				pctx.save(); pctx.translate( pc.width / 2, pc.height / 2 ); pctx.rotate( t.r * Math.PI / 180 );
				pctx.drawImage( img, -w / 2, -h / 2, w, h ); pctx.restore();
				return decodeCanvas( pc ).then( function ( res ) { return res || attempt( i + 1 ); } );
			}
			return attempt( 0 );
		}

		initNative( function () {
			if ( ! nativeDet && ! zx() ) { setStatus( i18n.camNoDec, 'err' ); }
			// Guard the API: on an insecure context or a browser without camera
			// support, `navigator.mediaDevices` is undefined and calling
			// getUserMedia on it throws a SYNCHRONOUS TypeError the .catch below
			// never sees — leaving the modal stuck. Fail with a clear message.
			var md = navigator.mediaDevices;
			if ( ! md || ! md.getUserMedia ) { setStatus( i18n.camNoCam, 'err' ); return; }
			md.getUserMedia( { audio: false, video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } } } )
				.then( function ( s ) { stream = s; track = s.getVideoTracks()[ 0 ]; video.srcObject = s; return video.play().catch( function () {} ); } )
				.then( function () {
					setupCaps();
					setStatus( i18n.camAlign );
					var rot = false, busy = false;
					( function tick() {
						if ( stopped ) { return; }
						if ( ! busy && ! paused ) {
							busy = true;
							var c = grab( rot ); rot = ! rot;
							if ( c ) { decodeCanvas( c ).then( function ( t ) { busy = false; if ( t ) { onDetect( t ); } } ); }
							else { busy = false; }
						}
						setTimeout( tick, 150 );
					}() );
				} )
				.catch( function ( e ) { setStatus( i18n.camNoCam, 'err' ); } );
		} );
	}
}() );
