/**
 * BitForgeNet Guest Tickets — admin behaviour: media pickers, repeaters, and the door scanner.
 */
( function () {
	'use strict';

	var cfg = window.BFNetAdmin || {};
	var i18n = cfg.i18n || {};

	/* ------------------------------------------------------------------ */
	/* Confirmations                                                      */
	/* ------------------------------------------------------------------ */

	document.addEventListener( 'click', function ( event ) {
		var el = event.target.closest( '[data-bfnet-confirm]' );
		if ( ! el ) {
			return;
		}
		var message = el.getAttribute( 'data-bfnet-confirm' ) || i18n.confirmDelete;
		if ( ! window.confirm( message ) ) {
			event.preventDefault();
		}
	} );

	/* ------------------------------------------------------------------ */
	/* CSV column picker                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Presets and a running count for the export screen.
	 *
	 * The form works without any of this — the checkboxes are checkboxes, and
	 * the server has its own idea of what an empty choice means. This only saves
	 * the manager from ticking forty boxes to get everything, or hunting for the
	 * six he started with after changing his mind.
	 */
	function initFieldPicker() {
		var form = document.querySelector( '[data-bfnet-fields]' );
		if ( ! form ) {
			return;
		}

		var boxes = form.querySelectorAll( 'input[name="fields[]"]' );
		var count = form.querySelector( '[data-bfnet-fields-count]' );
		var essentials = [];

		try {
			essentials = JSON.parse( form.getAttribute( 'data-bfnet-fields-default' ) || '[]' );
		} catch ( e ) {
			essentials = [];
		}

		var template = count ? count.textContent.trim() : '';

		function tally() {
			if ( ! count ) {
				return;
			}
			var on = 0;
			Array.prototype.forEach.call( boxes, function ( box ) {
				if ( box.checked ) {
					on++;
				}
			} );
			count.textContent = template.replace( /^\d+/, String( on ) );
		}

		form.addEventListener( 'change', tally );

		form.addEventListener( 'click', function ( event ) {
			var preset = event.target.closest( '[data-bfnet-fields-preset]' );
			var group  = event.target.closest( '[data-bfnet-fields-group]' );

			if ( preset ) {
				event.preventDefault();
				var mode = preset.getAttribute( 'data-bfnet-fields-preset' );

				Array.prototype.forEach.call( boxes, function ( box ) {
					if ( 'all' === mode ) {
						box.checked = true;
					} else if ( 'none' === mode ) {
						box.checked = false;
					} else {
						box.checked = essentials.indexOf( box.value ) !== -1;
					}
				} );

				tally();
				return;
			}

			if ( group ) {
				event.preventDefault();
				var on = 'on' === group.getAttribute( 'data-bfnet-fields-group' );
				var box = group.closest( 'details' );

				if ( box ) {
					Array.prototype.forEach.call( box.querySelectorAll( 'input[name="fields[]"]' ), function ( item ) {
						item.checked = on;
					} );
				}

				tally();
			}
		} );

		tally();
	}

	/* ------------------------------------------------------------------ */
	/* Copy to clipboard                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Copy a string, and say so.
	 *
	 * navigator.clipboard is refused on plain HTTP and inside some embedded
	 * browsers, so the old selection trick stays as a fallback rather than
	 * leaving the manager pressing a button that silently does nothing.
	 */
	function copyText( text, button ) {
		var done = function () {
			if ( ! button ) {
				return;
			}
			var was = button.getAttribute( 'data-bfnet-was' ) || button.textContent;
			button.setAttribute( 'data-bfnet-was', was );
			button.textContent = i18n.copied || 'Copied';
			window.setTimeout( function () {
				button.textContent = was;
			}, 1600 );
		};

		if ( navigator.clipboard && window.isSecureContext ) {
			navigator.clipboard.writeText( text ).then( done, function () {
				legacyCopy( text, done );
			} );
			return;
		}

		legacyCopy( text, done );
	}

	function legacyCopy( text, done ) {
		var pad = document.createElement( 'textarea' );
		pad.value = text;
		pad.setAttribute( 'readonly', 'readonly' );
		pad.style.position = 'fixed';
		pad.style.opacity = '0';
		document.body.appendChild( pad );
		pad.select();
		try {
			document.execCommand( 'copy' );
			done();
		} catch ( e ) {
			window.prompt( i18n.copyManually || 'Copy this link:', text );
		}
		document.body.removeChild( pad );
	}

	document.addEventListener( 'click', function ( event ) {
		var el = event.target.closest( '[data-bfnet-copy]' );
		if ( ! el ) {
			return;
		}
		event.preventDefault();
		copyText( el.getAttribute( 'data-bfnet-copy' ) || '', el );
	} );

	/* ------------------------------------------------------------------ */
	/* Venue map link                                                     */
	/* ------------------------------------------------------------------ */

	/**
	 * Offer the map link the venue already implies.
	 *
	 * A venue that has coordinates has, in effect, already got a Google Maps
	 * link — it just has not been written down. This builds it as the fields are
	 * typed, shows it, and offers to copy it or drop it into the saved link
	 * field. Coordinates win over the written address because they drop a guest
	 * on the door rather than on the street.
	 */
	function initVenueMap() {
		var box = document.querySelector( '[data-bfnet-map]' );
		if ( ! box ) {
			return;
		}

		var out    = box.querySelector( '[data-bfnet-map-suggest]' );
		var panel  = box.querySelector( '[data-bfnet-map-box]' );
		var from   = box.querySelector( '[data-bfnet-map-from]' );
		var open   = box.querySelector( '[data-bfnet-map-open]' );
		var field  = box.querySelector( '[data-bfnet-map-url]' );
		var form   = box.closest( 'form' );
		var lat    = form ? form.querySelector( '[data-bfnet-map-lat]' ) : null;
		var lng    = form ? form.querySelector( '[data-bfnet-map-lng]' ) : null;
		var parts  = form ? form.querySelectorAll( '[data-bfnet-map-part]' ) : [];

		function value( el ) {
			return el && el.value ? String( el.value ).trim() : '';
		}

		function build() {
			var la = value( lat );
			var ln = value( lng );

			if ( la && ln && ! isNaN( parseFloat( la ) ) && ! isNaN( parseFloat( ln ) ) ) {
				return {
					url: 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent( la + ',' + ln ),
					from: i18n.mapFromCoords || 'from the coordinates'
				};
			}

			var written = [];
			var named   = false;

			Array.prototype.forEach.call( parts, function ( el ) {
				var v = value( el );
				if ( ! v ) {
					return;
				}
				written.push( v );
				if ( 'key' === el.getAttribute( 'data-bfnet-map-part' ) ) {
					named = true;
				}
			} );

			// A province code and the word Canada are not a place. Nothing is
			// suggested until there is a name, a street or a city to point at.
			if ( ! named ) {
				return null;
			}

			return {
				url: 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent( written.join( ' ' ) ),
				from: i18n.mapFromAddress || 'from the address'
			};
		}

		function refresh() {
			var made = build();

			if ( ! made ) {
				panel.hidden = true;
				return;
			}

			panel.hidden = false;
			out.textContent = made.url;
			open.href = made.url;
			if ( from ) {
				from.textContent = made.from;
			}
		}

		if ( form ) {
			form.addEventListener( 'input', refresh );
			form.addEventListener( 'change', refresh );
		}

		box.addEventListener( 'click', function ( event ) {
			var copy = event.target.closest( '[data-bfnet-map-copy]' );
			var use  = event.target.closest( '[data-bfnet-map-use]' );

			if ( copy ) {
				event.preventDefault();
				copyText( out.textContent, copy );
			}

			if ( use && field ) {
				event.preventDefault();
				field.value = out.textContent;
				field.focus();
			}
		} );

		refresh();
	}

	/* ------------------------------------------------------------------ */
	/* Section bar                                                        */
	/* ------------------------------------------------------------------ */

	/**
	 * Bring the screen you are on into view.
	 *
	 * On a phone the bar becomes a scrolling rail, and a rail that opens at its
	 * left edge hides the one pill that matters — the one you are standing on.
	 * This nudges it into the middle. It runs without animation and only when
	 * the bar actually scrolls, so on a desktop it does nothing at all.
	 */
	function centreSection() {
		var bar = document.querySelector( '.bfnet-sections' );
		if ( ! bar ) {
			return;
		}

		var here = bar.querySelector( '.bfnet-sections__link.is-here' );
		if ( ! here || bar.scrollWidth <= bar.clientWidth ) {
			return;
		}

		bar.scrollLeft = here.offsetLeft - ( bar.clientWidth - here.offsetWidth ) / 2;
	}

	/* ------------------------------------------------------------------ */
	/* Responsive tables                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Copy every column heading onto the cells beneath it.
	 *
	 * Below 782px the stylesheet turns each row into a card and prints these
	 * labels down the left-hand side, so a booking reads as a list of named
	 * facts instead of ten columns fighting over a phone screen. Doing it here
	 * rather than in every view means a table added later is responsive the
	 * moment it exists, and a column can never appear without its label.
	 */
	function labelTables( scope ) {
		var tables = ( scope || document ).querySelectorAll( '.bfnet-table' );

		Array.prototype.forEach.call( tables, function ( table ) {
			var heads = Array.prototype.map.call(
				table.querySelectorAll( 'thead th' ),
				function ( th ) {
					return ( th.textContent || '' ).replace( /\s+/g, ' ' ).trim();
				}
			);

			if ( ! heads.length ) {
				return;
			}

			Array.prototype.forEach.call( table.querySelectorAll( 'tbody tr' ), function ( row ) {
				var index = 0;

				Array.prototype.forEach.call( row.children, function ( cell ) {
					var span = parseInt( cell.getAttribute( 'colspan' ) || '1', 10 );

					if ( ! cell.hasAttribute( 'data-label' ) ) {
						cell.setAttribute( 'data-label', ( span > 1 ? '' : heads[ index ] ) || '' );
					}

					index += span;
				} );
			} );
		} );
	}

	function bfnetBoot() {
		centreSection();
		labelTables();
		initVenueMap();
		initFieldPicker();
	}

	if ( 'loading' === document.readyState ) {
		document.addEventListener( 'DOMContentLoaded', bfnetBoot );
	} else {
		bfnetBoot();
	}

	window.BFNetLabelTables = labelTables;

	/* ------------------------------------------------------------------ */
	/* WordPress media picker                                             */
	/* ------------------------------------------------------------------ */

	document.addEventListener( 'click', function ( event ) {
		var trigger = event.target.closest( '[data-bfnet-media]' );
		if ( ! trigger || ! window.wp || ! window.wp.media ) {
			return;
		}
		event.preventDefault();

		var wrap = trigger.closest( '.bfnet-media' );
		var input = wrap.querySelector( 'input[type="hidden"]' );
		var preview = wrap.querySelector( '.bfnet-media__preview' );

		var frame = window.wp.media( {
			title: i18n.chooseImage,
			button: { text: i18n.useImage },
			library: { type: 'image' },
			multiple: false
		} );

		frame.on( 'select', function () {
			var attachment = frame.state().get( 'selection' ).first().toJSON();
			input.value = attachment.id;
			var url = ( attachment.sizes && attachment.sizes.thumbnail ) ? attachment.sizes.thumbnail.url : attachment.url;
			preview.innerHTML = '<img src="' + url + '" alt="">';
		} );

		frame.open();
	} );

	document.addEventListener( 'click', function ( event ) {
		var clear = event.target.closest( '[data-bfnet-media-clear]' );
		if ( ! clear ) {
			return;
		}
		event.preventDefault();
		var wrap = clear.closest( '.bfnet-media' );
		wrap.querySelector( 'input[type="hidden"]' ).value = '0';
		wrap.querySelector( '.bfnet-media__preview' ).innerHTML = '';
	} );

	/* ------------------------------------------------------------------ */
	/* Repeatable rows                                                    */
	/* ------------------------------------------------------------------ */

	document.addEventListener( 'click', function ( event ) {
		var add = event.target.closest( '[data-bfnet-add]' );
		if ( add ) {
			event.preventDefault();
			var list = document.querySelector( add.getAttribute( 'data-bfnet-add' ) );
			var tpl = document.querySelector( add.getAttribute( 'data-bfnet-template' ) );
			if ( ! list || ! tpl ) {
				return;
			}
			var index = list.children.length;
			var html = tpl.innerHTML.replace( /__INDEX__/g, String( index ) );
			var holder = document.createElement( 'div' );
			holder.innerHTML = html.trim();
			list.appendChild( holder.firstElementChild );
			return;
		}

		var remove = event.target.closest( '.bfnet-remove' );
		if ( remove ) {
			event.preventDefault();
			var row = remove.closest( '.bfnet-repeat__row' );
			if ( row ) {
				row.remove();
			}
		}
	} );

	/* ------------------------------------------------------------------ */
	/* Slug helper                                                        */
	/* ------------------------------------------------------------------ */

	var titleField = document.getElementById( 'bfnet-title' );
	var slugField = document.getElementById( 'bfnet-slug' );

	if ( titleField && slugField && ! slugField.value ) {
		titleField.addEventListener( 'blur', function () {
			if ( slugField.value ) {
				return;
			}
			slugField.value = titleField.value
				.toLowerCase()
				.normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' )
				.replace( /[^a-z0-9]+/g, '-' )
				.replace( /^-+|-+$/g, '' )
				.slice( 0, 80 );
		} );
	}

	/* ------------------------------------------------------------------ */
	/* Saving the event editor without leaving the page                   */
	/* ------------------------------------------------------------------ */

	( function () {
		var form = document.querySelector( '[data-bfnet-event-form]' );
		if ( ! form ) {
			return;
		}

		var state = form.querySelector( '[data-bfnet-save-state]' );
		var button = form.querySelector( '[data-bfnet-save]' );
		var dirty = false;
		var saving = false;

		function show( text, kind ) {
			if ( ! state ) {
				return;
			}
			state.textContent = text;
			state.className = 'bfnet-savebar__state' + ( kind ? ' is-' + kind : '' );
		}

		form.addEventListener( 'input', function () {
			if ( ! saving ) {
				dirty = true;
				show( i18n.unsaved || 'Unsaved changes', 'dirty' );
			}
		} );
		form.addEventListener( 'change', function () {
			if ( ! saving ) {
				dirty = true;
				show( i18n.unsaved || 'Unsaved changes', 'dirty' );
			}
		} );

		form.addEventListener( 'submit', function ( event ) {
			// No fetch, no FormData: let the browser post the form the old way and
			// nothing is lost. Everywhere else, save in place.
			if ( ! window.fetch || ! window.FormData || ! cfg.ajaxUrl ) {
				return;
			}

			event.preventDefault();
			if ( saving ) {
				return;
			}

			saving = true;
			if ( button ) {
				button.disabled = true;
			}
			show( i18n.saving || 'Saving…', 'saving' );

			var body = new window.FormData( form );
			body.append( 'action', 'bfnet_save_event' );

			window.fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } )
				.then( function ( r ) { return r.json(); } )
				.then( function ( data ) {
					if ( ! data || ! data.ok ) {
						show( ( data && data.message ) || 'Not saved', 'error' );
						return;
					}

					dirty = false;
					show( data.message || 'Saved', 'saved' );

					// A brand new event has an id now; keep the form pointed at it so
					// the next save updates rather than creating a second one.
					var idField = form.querySelector( '[name="event_id"]' );
					if ( idField && data.id && ! parseInt( idField.value, 10 ) ) {
						idField.value = String( data.id );
						if ( window.history && window.history.replaceState ) {
							window.history.replaceState( {}, '', cfg.eventUrl ? cfg.eventUrl + data.id : window.location.href );
						}
					}
				} )
				.catch( function () {
					// Rather than lose the work, fall back to the ordinary post.
					show( 'Saving the usual way…', 'saving' );
					form.submit();
				} )
				.then( function () {
					saving = false;
					if ( button ) {
						button.disabled = false;
					}
				} );
		} );

		window.addEventListener( 'beforeunload', function ( event ) {
			if ( ! dirty ) {
				return;
			}
			event.preventDefault();
			event.returnValue = '';
		} );

		// Ctrl/Cmd+S saves, because on a screen this long nobody wants to scroll
		// to the bottom for every change.
		document.addEventListener( 'keydown', function ( event ) {
			if ( ( event.ctrlKey || event.metaKey ) && 's' === event.key.toLowerCase() ) {
				event.preventDefault();
				if ( button ) {
					button.click();
				}
			}
		} );
	} )();

	/* ------------------------------------------------------------------ */
	/* Copy to clipboard                                                  */
	/* ------------------------------------------------------------------ */

	document.addEventListener( 'click', function ( event ) {
		var el = event.target.closest ? event.target.closest( '[data-bfnet-copy]' ) : null;
		if ( ! el ) {
			return;
		}
		event.preventDefault();

		var value = el.getAttribute( 'data-bfnet-copy' );
		var done = function () {
			var was = el.textContent;
			el.textContent = 'Copied';
			window.setTimeout( function () { el.textContent = was; }, 1400 );
		};

		if ( navigator.clipboard && navigator.clipboard.writeText ) {
			navigator.clipboard.writeText( value ).then( done ).catch( function () {} );
			return;
		}

		var field = document.createElement( 'input' );
		field.value = value;
		document.body.appendChild( field );
		field.select();
		try { document.execCommand( 'copy' ); done(); } catch ( e ) {}
		document.body.removeChild( field );
	} );

	/* ------------------------------------------------------------------ */
	/* Door scanner                                                       */
	/* ------------------------------------------------------------------ */

	function initScanner() {
		var root = document.getElementById( 'bfnet-scanner' );
		if ( ! root ) {
			return;
		}

		var video = root.querySelector( 'video' );
		var hint = root.querySelector( '[data-bfnet-hint]' );
		var verdict = document.getElementById( 'bfnet-verdict' );
		var feed = document.getElementById( 'bfnet-feed' );
		var manual = document.getElementById( 'bfnet-manual-form' );
		var startBtn = root.querySelector( '[data-bfnet-start]' );
		var eventId = parseInt( root.getAttribute( 'data-event' ), 10 ) || 0;
		var gateSelect = document.getElementById( 'bfnet-gate' );
		var stats = document.getElementById( 'bfnet-live-stats' );

		var busy = false;
		var lastToken = '';
		var lastAt = 0;
		var stream = null;
		var loopHandle = null;

		function gate() {
			return gateSelect ? gateSelect.value : '';
		}

		function beep( ok ) {
			if ( ! root.getAttribute( 'data-sound' ) ) {
				return;
			}
			try {
				var ctx = new ( window.AudioContext || window.webkitAudioContext )();
				var osc = ctx.createOscillator();
				var gain = ctx.createGain();
				osc.connect( gain );
				gain.connect( ctx.destination );
				osc.frequency.value = ok ? 880 : 220;
				gain.gain.setValueAtTime( 0.12, ctx.currentTime );
				gain.gain.exponentialRampToValueAtTime( 0.001, ctx.currentTime + 0.28 );
				osc.start();
				osc.stop( ctx.currentTime + 0.3 );
			} catch ( e ) {
				/* Audio is a nicety, never a requirement. */
			}
		}

		function esc( value ) {
			var div = document.createElement( 'div' );
			div.textContent = value == null ? '' : String( value );
			return div.innerHTML;
		}

		function paint( data ) {
			var state = data.ok ? 'ok' : ( data.result === 'already_used' || data.result === 'outside_window' ? 'warn' : 'bad' );
			verdict.className = 'bfnet-verdict bfnet-verdict--' + state;

			var cells = '';
			if ( data.guest ) {
				cells += '<div class="bfnet-verdict__cell"><span>Guest</span><b>' + esc( data.guest ) + '</b></div>';
			}
			if ( data.serial ) {
				cells += '<div class="bfnet-verdict__cell"><span>Ticket</span><b>' + esc( data.serial ) + '</b></div>';
			}
			if ( data.party_size > 1 ) {
				cells += '<div class="bfnet-verdict__cell"><span>Party</span><b>' + esc( data.position ) + ' / ' + esc( data.party_size ) + '</b></div>';
			}
			if ( data.first_seen ) {
				cells += '<div class="bfnet-verdict__cell"><span>First scan</span><b>' + esc( data.first_seen ) + '</b></div>';
			}

			verdict.innerHTML =
				'<p class="bfnet-verdict__title">' + esc( data.ok ? 'Welcome in' : ( data.result || '' ).replace( /_/g, ' ' ) ) + '</p>' +
				'<p class="bfnet-verdict__msg">' + esc( data.message ) + '</p>' +
				( cells ? '<div class="bfnet-verdict__grid">' + cells + '</div>' : '' );

			if ( feed ) {
				var item = document.createElement( 'div' );
				item.className = 'bfnet-feed__item';
				item.innerHTML = '<span class="bfnet-feed__dot bfnet-feed__dot--' + state + '"></span>' +
					'<span>' + esc( data.guest || data.serial || '—' ) + '</span>' +
					'<span style="margin-left:auto;opacity:.6">' + esc( new Date().toLocaleTimeString() ) + '</span>';
				feed.insertBefore( item, feed.firstChild );
				while ( feed.children.length > 40 ) {
					feed.removeChild( feed.lastChild );
				}
			}

			beep( data.ok );
			refreshStats();
		}

		function submitToken( token ) {
			if ( busy || ! token ) {
				return;
			}

			var now = Date.now();
			if ( token === lastToken && now - lastAt < 3500 ) {
				return;
			}
			lastToken = token;
			lastAt = now;
			busy = true;

			window.fetch( cfg.restUrl + '/scan', {
				method: 'POST',
				credentials: 'same-origin',
				headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
				body: JSON.stringify( { token: token, event_id: eventId, gate: gate() } )
			} )
				.then( function ( response ) { return response.json(); } )
				.then( function ( data ) {
					paint( data );
				} )
				.catch( function () {
					verdict.className = 'bfnet-verdict bfnet-verdict--bad';
					verdict.innerHTML = '<p class="bfnet-verdict__title">Error</p><p class="bfnet-verdict__msg">' + esc( i18n.networkError ) + '</p>';
				} )
				.finally( function () {
					window.setTimeout( function () { busy = false; }, 500 );
				} );
		}

		function refreshStats() {
			if ( ! stats || ! eventId ) {
				return;
			}
			window.fetch( cfg.restUrl + '/event/' + eventId + '/stats', {
				credentials: 'same-origin',
				headers: { 'X-WP-Nonce': cfg.nonce }
			} )
				.then( function ( r ) { return r.json(); } )
				.then( function ( d ) {
					stats.querySelectorAll( '[data-stat]' ).forEach( function ( node ) {
						var key = node.getAttribute( 'data-stat' );
						if ( d[ key ] !== undefined ) {
							node.textContent = d[ key ];
						}
					} );
					var bar = stats.querySelector( '.bfnet-progress span' );
					if ( bar && d.expected > 0 ) {
						bar.style.width = Math.min( 100, Math.round( ( d.checked_in / d.expected ) * 100 ) ) + '%';
					}
				} )
				.catch( function () {} );
		}

		var decoder = null;
		var canvas = document.createElement( 'canvas' );
		var cctx = canvas.getContext( '2d', { willReadFrequently: true } );
		var diag = root.querySelector( '[data-bfnet-diag]' );
		var photoInput = root.querySelector( '[data-bfnet-photo-input]' );
		var photoBtn = root.querySelector( '[data-bfnet-photo]' );
		var running = false;

		function say( message ) {
			if ( hint ) {
				hint.textContent = message;
			}
		}

		/**
		 * Technical line under the stage.
		 *
		 * The door is the one place where "it does not work" has to become an
		 * answerable question, so every step of opening a camera is reported both
		 * on screen and in the console.
		 */
		function note( message ) {
			if ( diag ) {
				diag.hidden = false;
				diag.textContent = message;
			}
			if ( window.console && window.console.log ) {
				window.console.log( '[BitForgeNet Guest Tickets scanner] ' + message );
			}
		}

		/**
		 * Grab a frame and hand it to whichever decoder is available.
		 *
		 * The native BarcodeDetector is preferred where it exists. It does not exist on
		 * desktop Chrome for Windows or on Firefox, which is why the bundled decoder is
		 * always loaded as well — the door still works on whatever laptop is to hand.
		 */
		async function readFrame() {
			if ( ! video.videoWidth || video.readyState < 2 ) {
				return null;
			}

			if ( decoder ) {
				try {
					var codes = await decoder.detect( video );
					if ( codes && codes.length ) {
						return codes[ 0 ].rawValue;
					}
				} catch ( e ) {
					// A single bad frame, or the detector died: fall through to the bundled one.
					decoder = null;
				}
			}

			if ( ! window.BFNETQR ) {
				return null;
			}

			// Downscale wide frames: more than ~720px buys no accuracy and costs frames.
			var scale = Math.min( 1, 720 / video.videoWidth );
			var w = Math.round( video.videoWidth * scale );
			var h = Math.round( video.videoHeight * scale );

			if ( canvas.width !== w || canvas.height !== h ) {
				canvas.width = w;
				canvas.height = h;
			}

			cctx.drawImage( video, 0, 0, w, h );

			try {
				return window.BFNETQR.decode( cctx.getImageData( 0, 0, w, h ) );
			} catch ( e ) {
				return null;
			}
		}

		function cameraError( e ) {
			var name = e && e.name ? e.name : '';

			if ( ! window.isSecureContext ) {
				return 'Cameras only work over HTTPS. Open this page with https:// and try again.';
			}
			if ( name === 'NotAllowedError' || name === 'SecurityError' ) {
				return i18n.cameraDenied || 'Camera permission was refused. Allow it in the browser address bar, then tap Start camera again.';
			}
			if ( name === 'NotFoundError' || name === 'OverconstrainedError' ) {
				return 'No camera was found on this device. Use “Take a photo” or the manual code box below.';
			}
			if ( name === 'NotReadableError' || name === 'AbortError' ) {
				return 'The camera is busy in another app or tab. Close it, then tap Start camera again.';
			}
			return 'The camera could not be started (' + ( name || 'unknown error' ) + '). Use “Take a photo” below — it always works.';
		}

		/**
		 * Open a video stream, trying progressively looser constraints.
		 *
		 * Android phones differ wildly in what they accept: some reject
		 * facingMode exact, some reject a resolution hint, some only answer to an
		 * explicit deviceId. Rather than assume, we ask four different ways and
		 * keep the first camera that answers.
		 */
		async function openStream() {
			var ladder = [
				{ label: 'rear camera', constraints: { audio: false, video: { facingMode: { exact: 'environment' }, width: { ideal: 1280 }, height: { ideal: 960 } } } },
				{ label: 'rear camera, no size hint', constraints: { audio: false, video: { facingMode: 'environment' } } },
				{ label: 'any camera', constraints: { audio: false, video: true } }
			];
			var lastError = null;
			var i;

			for ( i = 0; i < ladder.length; i++ ) {
				try {
					note( 'Opening the ' + ladder[ i ].label + '…' );
					return await navigator.mediaDevices.getUserMedia( ladder[ i ].constraints );
				} catch ( e ) {
					lastError = e;
					note( ladder[ i ].label + ' refused: ' + ( e && e.name ? e.name : e ) );
					if ( e && ( e.name === 'NotAllowedError' || e.name === 'SecurityError' ) ) {
						throw e;
					}
				}
			}

			// Last resort: name a device explicitly.
			try {
				var devices = await navigator.mediaDevices.enumerateDevices();
				var cams = devices.filter( function ( d ) { return d.kind === 'videoinput'; } );
				note( 'Cameras seen by the browser: ' + cams.length );
				var back = cams.filter( function ( d ) { return /back|rear|arri|environment/i.test( d.label || '' ); } )[ 0 ] || cams[ cams.length - 1 ];
				if ( back ) {
					return await navigator.mediaDevices.getUserMedia( { audio: false, video: { deviceId: { exact: back.deviceId } } } );
				}
			} catch ( e ) {
				lastError = e;
			}

			throw lastError || new Error( 'NotFoundError' );
		}

		function waitForFrames( ms ) {
			return new Promise( function ( resolve ) {
				var waited = 0;
				var timer = window.setInterval( function () {
					waited += 100;
					if ( video.videoWidth > 0 && video.readyState >= 2 ) {
						window.clearInterval( timer );
						resolve( true );
					} else if ( waited >= ms ) {
						window.clearInterval( timer );
						resolve( false );
					}
				}, 100 );
			} );
		}

		/**
		 * Bind the stream to the element and make sure a picture actually arrives.
		 *
		 * A granted permission and a black rectangle is the classic Android
		 * symptom: the track is live but the element never started painting. The
		 * cure is to reset the element and attach a second time.
		 */
		async function attachStream( s ) {
			video.setAttribute( 'playsinline', '' );
			video.setAttribute( 'webkit-playsinline', '' );
			video.setAttribute( 'autoplay', '' );
			video.setAttribute( 'muted', '' );
			video.muted = true;
			video.defaultMuted = true;
			video.playsInline = true;

			try {
				video.srcObject = s;
			} catch ( e ) {
				video.src = window.URL.createObjectURL( s );
			}

			try {
				await video.play();
			} catch ( e ) {
				note( 'play() was refused: ' + ( e && e.name ? e.name : e ) );
			}

			if ( await waitForFrames( 3000 ) ) {
				return true;
			}

			note( 'No picture yet — re-attaching the stream.' );
			video.srcObject = null;
			await new Promise( function ( r ) { window.setTimeout( r, 150 ); } );
			video.srcObject = s;
			video.load();
			try {
				await video.play();
			} catch ( e ) {}

			return await waitForFrames( 4000 );
		}

		function enableTorch() {
			var torch = root.querySelector( '[data-bfnet-torch]' );
			if ( ! torch || ! stream ) {
				return;
			}
			var track = stream.getVideoTracks()[ 0 ];
			var caps = track && track.getCapabilities ? track.getCapabilities() : {};
			if ( caps && caps.torch ) {
				torch.hidden = false;
				torch.addEventListener( 'click', function ( e ) {
					e.preventDefault();
					torch.dataset.on = torch.dataset.on === '1' ? '0' : '1';
					track.applyConstraints( { advanced: [ { torch: torch.dataset.on === '1' } ] } ).catch( function () {} );
				} );
			}
		}

		async function pickDecoder() {
			if ( ! ( 'BarcodeDetector' in window ) ) {
				return;
			}
			try {
				var formats = await window.BarcodeDetector.getSupportedFormats();
				if ( formats.indexOf( 'qr_code' ) !== -1 ) {
					decoder = new window.BarcodeDetector( { formats: [ 'qr_code' ] } );
				}
			} catch ( e ) {
				decoder = null;
			}
		}

		async function tick() {
			if ( ! running ) {
				return;
			}
			var value = await readFrame();
			if ( value ) {
				submitToken( value );
			}
			loopHandle = window.setTimeout( tick, 180 );
		}

		async function startCamera() {
			if ( running ) {
				return;
			}

			if ( ! window.isSecureContext ) {
				say( 'Cameras only work over HTTPS. Open this page with https:// and try again.' );
				note( 'This page is not a secure context, so no browser will open a camera here.' );
				return;
			}

			if ( ! navigator.mediaDevices || ! navigator.mediaDevices.getUserMedia ) {
				say( 'This browser cannot open a camera. Use “Take a photo” below.' );
				note( 'navigator.mediaDevices.getUserMedia is missing — most often an embedded webview or a Permissions-Policy header blocking the camera.' );
				return;
			}

			say( 'Requesting the camera…' );

			try {
				stream = await openStream();
			} catch ( e ) {
				say( cameraError( e ) );
				note( 'The camera could not be opened: ' + ( e && e.name ? e.name : e ) );
				return;
			}

			var track = stream.getVideoTracks()[ 0 ];
			var settings = track && track.getSettings ? track.getSettings() : {};
			note( 'Got ' + ( ( track && track.label ) || 'a camera' ) + ' at ' + ( settings.width || '?' ) + '×' + ( settings.height || '?' ) + '. Waiting for the picture…' );

			if ( ! await attachStream( stream ) ) {
				say( 'The camera opened but sent no picture. Use “Take a photo” below — it always works.' );
				note( 'No frames after 7s. track=' + ( track ? track.readyState : '?' ) + ' readyState=' + video.readyState + ' size=' + video.videoWidth + '×' + video.videoHeight );
				return;
			}

			await pickDecoder();

			running = true;
			say( i18n.scanning || 'Point the camera at the QR code.' );
			note( 'Live at ' + video.videoWidth + '×' + video.videoHeight + ' using the ' + ( decoder ? 'browser’s own detector' : 'built-in decoder' ) + '.' );

			if ( startBtn ) {
				startBtn.style.display = 'none';
			}

			enableTorch();
			tick();
		}

		/**
		 * Decode a still photograph.
		 *
		 * This path never touches getUserMedia: the file input opens the phone's own
		 * camera app, which no browser policy, webview or missing permission can
		 * block. It is the guaranteed way in when a live preview will not start.
		 */
		function decodeImage( img ) {
			var widths = [ 900, 640, 1280, 1800 ];
			var i;
			for ( i = 0; i < widths.length; i++ ) {
				var target = Math.min( widths[ i ], img.naturalWidth );
				if ( target < 200 ) {
					continue;
				}
				var scale = target / img.naturalWidth;
				var w = Math.round( img.naturalWidth * scale );
				var h = Math.round( img.naturalHeight * scale );
				canvas.width = w;
				canvas.height = h;
				cctx.drawImage( img, 0, 0, w, h );
				try {
					var value = window.BFNETQR ? window.BFNETQR.decode( cctx.getImageData( 0, 0, w, h ) ) : null;
					if ( value ) {
						return value;
					}
				} catch ( e ) {}
			}
			return null;
		}

		if ( photoBtn && photoInput ) {
			photoBtn.addEventListener( 'click', function ( e ) {
				e.preventDefault();
				photoInput.click();
			} );

			photoInput.addEventListener( 'change', function () {
				var file = photoInput.files && photoInput.files[ 0 ];
				if ( ! file ) {
					return;
				}
				say( 'Reading the photo…' );
				var img = new Image();
				img.onload = function () {
					var value = decodeImage( img );
					window.URL.revokeObjectURL( img.src );
					photoInput.value = '';
					if ( value ) {
						lastToken = '';
						submitToken( value );
						say( 'Decoded. Tap “Take a photo” for the next guest.' );
					} else {
						say( 'No QR code found in that photo. Fill the frame with the code and try again.' );
					}
				};
				img.onerror = function () {
					photoInput.value = '';
					say( 'That photo could not be read.' );
				};
				img.src = window.URL.createObjectURL( file );
			} );
		}

		if ( startBtn ) {
			startBtn.addEventListener( 'click', function ( e ) {
				e.preventDefault();
				startCamera();
			} );
		}

		// Report the starting conditions straight away, so a refused camera is
		// never a mystery, and start by itself when permission is already granted.
		note( ( window.isSecureContext ? 'Secure page' : 'INSECURE page (http) — cameras are blocked' )
			+ ' · getUserMedia ' + ( navigator.mediaDevices && navigator.mediaDevices.getUserMedia ? 'available' : 'MISSING' )
			+ ' · decoder ' + ( window.BFNETQR ? 'loaded' : 'MISSING' ) );

		if ( navigator.permissions && navigator.permissions.query ) {
			navigator.permissions.query( { name: 'camera' } ).then( function ( status ) {
				note( 'Camera permission: ' + status.state + '. Tap “Start camera”.' );
				if ( status.state === 'granted' ) {
					startCamera();
				}
			} ).catch( function () {} );
		}
		if ( manual ) {
			manual.addEventListener( 'submit', function ( e ) {
				e.preventDefault();
				var field = manual.querySelector( 'input[name="token"]' );
				lastToken = '';
				submitToken( field.value.trim() );
				field.value = '';
				field.focus();
			} );
		}

		window.addEventListener( 'beforeunload', function () {
			if ( loopHandle ) {
				window.clearTimeout( loopHandle );
			}
			if ( stream ) {
				stream.getTracks().forEach( function ( track ) { track.stop(); } );
			}
		} );

		refreshStats();
		window.setInterval( refreshStats, 20000 );
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', initScanner );
	} else {
		initScanner();
	}
} )();
