/*!
 * BitForgeNet Guest Tickets — door station engine.
 *
 * The black preview is a painting problem, not a camera problem: a track can be
 * live and delivering frames while the <video> element never draws anything. So
 * this engine does not trust the video element for either job.
 *
 * Frames are acquired by whichever of three mechanisms actually works on the
 * device, in order of independence from the video element:
 *
 *   1. ImageCapture.grabFrame()      — pulls an ImageBitmap straight off the
 *                                      MediaStreamTrack. No video element at all.
 *   2. MediaStreamTrackProcessor     — reads VideoFrames from the track as a
 *                                      stream. Again, no video element.
 *   3. <video> + drawImage           — the classic route, kept as a fallback.
 *
 * Whatever the source, every frame is painted onto a canvas we own, so what the
 * agent sees is literally what the decoder is reading. If the picture is black,
 * it is black in the camera, not in a CSS rule.
 *
 * Two more routes need no live camera at all: a still photo taken with the
 * phone's own camera app, and the ticket number typed on a keypad.
 */

( function () {
	'use strict';

	var cfg = window.BFNETDoorConfig || {};
	var $ = function ( sel ) { return document.querySelector( sel ); };

	var view = $( '[data-d-view]' );
	var vctx = view ? view.getContext( '2d' ) : null;
	var video = $( '[data-d-video]' );
	var statusEl = $( '[data-d-status]' );
	var noteEl = $( '[data-d-note]' );
	var feedEl = $( '[data-d-feed]' );
	var verdictEl = $( '[data-d-verdict]' );
	var sheetEl = $( '[data-d-sheet]' );
	var reportEl = $( '[data-d-report]' );
	var offlineEl = $( '[data-d-offline]' );

	var work = document.createElement( 'canvas' );
	var wctx = work.getContext( '2d', { willReadFrequently: true } );

	var track = null;
	var stream = null;
	var detector = null;
	var facing = 'environment';
	var cameras = [];
	var chosenCamera = ( function () {
		try { return window.localStorage.getItem( 'bfnet_door_camera' ) || ''; } catch ( e ) { return ''; }
	} )();
	var running = false;
	var path = '';
	var frames = 0;
	var lastCode = '';
	var lastAt = 0;
	var busy = false;
	var log = [];

	/* ------------------------------------------------------------------ */
	/* Small helpers                                                      */
	/* ------------------------------------------------------------------ */

	function say( text ) {
		if ( statusEl ) { statusEl.textContent = text; }
	}

	function note( text ) {
		log.push( text );
		if ( log.length > 60 ) { log.shift(); }
		if ( noteEl ) { noteEl.textContent = text; }
		if ( window.console && window.console.log ) { window.console.log( '[BitForgeNet Guest Tickets door] ' + text ); }
	}

	function gate() {
		var el = $( '[data-d-gate]' );
		return el ? el.value : '';
	}

	function buzz( state ) {
		if ( navigator.vibrate ) {
			navigator.vibrate( state === 'ok' ? 60 : [ 60, 70, 60 ] );
		}
		if ( ! cfg.sound ) { return; }
		try {
			var Ctx = window.AudioContext || window.webkitAudioContext;
			if ( ! Ctx ) { return; }
			var ac = new Ctx();
			var osc = ac.createOscillator();
			var gainNode = ac.createGain();
			osc.connect( gainNode );
			gainNode.connect( ac.destination );
			osc.frequency.value = state === 'ok' ? 900 : 320;
			gainNode.gain.value = 0.08;
			osc.start();
			window.setTimeout( function () { osc.stop(); ac.close(); }, state === 'ok' ? 110 : 260 );
		} catch ( e ) {}
	}

	/* ------------------------------------------------------------------ */
	/* Decoding                                                           */
	/* ------------------------------------------------------------------ */

	/**
	 * Read a QR code out of whatever is currently on the work canvas.
	 *
	 * Two passes: the whole frame, then the middle of it enlarged. A ticket held
	 * at arm's length puts the code in the centre and small, and the second pass
	 * is what catches it.
	 */
	function decodeOurs( w, h ) {
		if ( ! window.BFNETQR ) { return null; }

		try {
			var whole = window.BFNETQR.decode( wctx.getImageData( 0, 0, w, h ) );
			if ( whole ) { return whole; }
		} catch ( e ) {}

		// Crop tighter and tighter on the middle of the frame. A ticket held out
		// at a doorway puts the code in the centre and small; the reticle tells
		// the agent as much, and these are the crops it promises.
		var zooms = [ 0.62, 0.44 ];
		for ( var z = 0; z < zooms.length; z++ ) {
			try {
				var cw = Math.round( w * zooms[ z ] );
				var ch = Math.round( h * zooms[ z ] );
				var cx = Math.round( ( w - cw ) / 2 );
				var cy = Math.round( ( h - ch ) / 2 );
				var middle = window.BFNETQR.decode( wctx.getImageData( cx, cy, cw, ch ) );
				if ( middle ) { return middle; }
			} catch ( e ) {}
		}

		return null;
	}

	/**
	 * Ask the browser's own barcode engine first, then fall back to ours.
	 *
	 * Chrome on Android ships a hardware-assisted QR reader that copes with glare,
	 * curl and shallow angles far better than anything written in JavaScript. It
	 * was being detected and then never actually asked, which is why real tickets
	 * would not read while test images did.
	 */
	function decodeWork( w, h, source ) {
		if ( ! detector ) {
			return Promise.resolve( decodeOurs( w, h ) );
		}

		// Hand it the original frame wherever we still have one. Shrinking to fit
		// our own reader halves the size of every QR module, and a ticket held at
		// arm's length stops being readable — which is why the admin scanner,
		// which fed the detector the untouched video, was faster than this.
		var subject = source || work;

		return detector.detect( subject ).then( function ( codes ) {
			if ( codes && codes.length && codes[ 0 ].rawValue ) {
				return codes[ 0 ].rawValue;
			}
			return decodeOurs( w, h );
		} ).catch( function () {
			return detector.detect( work ).then( function ( codes ) {
				if ( codes && codes.length && codes[ 0 ].rawValue ) {
					return codes[ 0 ].rawValue;
				}
				return decodeOurs( w, h );
			} ).catch( function () {
				return decodeOurs( w, h );
			} );
		} );
	}

	/**
	 * Paint a frame for the agent, then read it.
	 *
	 * @param {*} source      Anything drawImage accepts.
	 * @param {number} width  Source width.
	 * @param {number} height Source height.
	 */
	function handleFrame( source, width, height ) {
		if ( ! width || ! height ) { return Promise.resolve( null ); }

		frames++;

		if ( vctx ) {
			if ( view.width !== width || view.height !== height ) {
				view.width = width;
				view.height = height;
			}
			try { vctx.drawImage( source, 0, 0, width, height ); } catch ( e ) {}
		}

		// With a native engine one full-size pass is plenty. Without one — Firefox
		// on Android has no BarcodeDetector at all — our own reader is doing the
		// work, and its result depends heavily on how many pixels each QR module
		// lands on. So the working size alternates frame by frame: sooner or
		// later one of them lines up with the code in front of the lens.
		var widths = detector ? [ 1000 ] : [ 1000, 640, 1280 ];
		var target = widths[ frames % widths.length ];

		var scale = Math.min( 1, target / width );
		var w = Math.round( width * scale );
		var h = Math.round( height * scale );

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

		try { wctx.drawImage( source, 0, 0, w, h ); } catch ( e ) { return Promise.resolve( null ); }

		// While a verdict is on screen the agent is looking at it, not at the
		// queue. Keep the picture live but stop reading, so a ticket left lying in
		// front of the lens cannot re-trigger itself over and over.
		if ( verdictEl && ! verdictEl.hidden ) { return Promise.resolve( null ); }

		return decodeWork( w, h, source );
	}

	/* ------------------------------------------------------------------ */
	/* Opening a camera                                                   */
	/* ------------------------------------------------------------------ */

	function ladder( which ) {
		return [
			{ name: 'rear, sized', c: { audio: false, video: { facingMode: { exact: which }, width: { ideal: 1280 }, height: { ideal: 960 } } } },
			{ name: 'rear, plain', c: { audio: false, video: { facingMode: which } } },
			{ name: 'any camera', c: { audio: false, video: true } }
		];
	}

	function openStream() {
		var steps = ladder( facing );

		// An explicit choice always wins over any guess.
		if ( chosenCamera ) {
			note( 'Opening the camera you chose…' );
			return navigator.mediaDevices.getUserMedia( { audio: false, video: { deviceId: { exact: chosenCamera } } } )
				.catch( function ( e ) {
					note( 'That camera is gone (' + ( e && e.name ? e.name : e ) + '). Falling back.' );
					chosenCamera = '';
					try { window.localStorage.removeItem( 'bfnet_door_camera' ); } catch ( err ) {}
					return openStream();
				} );
		}

		function attempt( i ) {
			if ( i >= steps.length ) {
				return navigator.mediaDevices.enumerateDevices().then( function ( devices ) {
					var cams = devices.filter( function ( d ) { return d.kind === 'videoinput'; } );
					note( 'Cameras listed by the browser: ' + cams.length );
					var back = cams.filter( function ( d ) { return /back|rear|arri|environment/i.test( d.label || '' ); } )[ 0 ];
					var pick = back || cams[ cams.length - 1 ];
					if ( ! pick ) { throw new Error( 'NotFoundError' ); }
					return navigator.mediaDevices.getUserMedia( { audio: false, video: { deviceId: { exact: pick.deviceId } } } );
				} );
			}

			note( 'Trying ' + steps[ i ].name + '…' );
			return navigator.mediaDevices.getUserMedia( steps[ i ].c ).catch( function ( e ) {
				note( steps[ i ].name + ' refused: ' + ( e && e.name ? e.name : e ) );
				if ( e && ( e.name === 'NotAllowedError' || e.name === 'SecurityError' ) ) { throw e; }
				return attempt( i + 1 );
			} );
		}

		return attempt( 0 );
	}

	function cameraMessage( e ) {
		var name = e && e.name ? e.name : '';
		if ( ! window.isSecureContext ) {
			return 'This page is not on https://, so no browser will open a camera. Use Photo or Keypad.';
		}
		if ( name === 'NotAllowedError' || name === 'SecurityError' ) {
			return 'Camera permission was refused. Allow it in the address bar, or use Photo.';
		}
		if ( name === 'NotReadableError' || name === 'AbortError' ) {
			return 'The camera is busy in another app. Close it, or use Photo.';
		}
		if ( e && e.message === 'TimeoutError' ) {
			return 'The camera did not answer. Use Photo — it always works.';
		}
		return 'No camera could be opened. Use Photo — it always works.';
	}

	/* ------------------------------------------------------------------ */
	/* The three acquisition paths                                        */
	/* ------------------------------------------------------------------ */

	/**
	 * Path 1 — pull bitmaps straight off the track. No video element involved.
	 */
	function runImageCapture() {
		if ( ! window.ImageCapture ) { return Promise.resolve( false ); }

		var grabber;
		try { grabber = new window.ImageCapture( track ); } catch ( e ) { return Promise.resolve( false ); }

		return grabber.grabFrame().then( function ( bitmap ) {
			path = 'ImageCapture';
			note( 'Reading frames with ImageCapture at ' + bitmap.width + '×' + bitmap.height + '.' );
			handleFrame( bitmap, bitmap.width, bitmap.height );
			if ( bitmap.close ) { bitmap.close(); }

			var pump = function () {
				if ( ! running || path !== 'ImageCapture' ) { return; }
				grabber.grabFrame().then( function ( frame ) {
					var reading = handleFrame( frame, frame.width, frame.height );
					if ( frame.close ) { frame.close(); }
					return reading;
				} ).then( function ( value ) {
					if ( value ) { submit( value ); }
					window.setTimeout( pump, 140 );
				} ).catch( function () {
					window.setTimeout( pump, 400 );
				} );
			};
			pump();
			return true;
		} ).catch( function ( e ) {
			note( 'ImageCapture unavailable: ' + ( e && e.name ? e.name : e ) );
			return false;
		} );
	}

	/**
	 * Path 2 — read VideoFrames from the track as a stream. Also video-free.
	 */
	function runTrackProcessor() {
		if ( ! window.MediaStreamTrackProcessor ) { return Promise.resolve( false ); }

		var reader;
		try {
			reader = new window.MediaStreamTrackProcessor( { track: track } ).readable.getReader();
		} catch ( e ) {
			note( 'TrackProcessor unavailable: ' + ( e && e.name ? e.name : e ) );
			return Promise.resolve( false );
		}

		path = 'TrackProcessor';
		note( 'Reading frames with MediaStreamTrackProcessor.' );

		var pump = function () {
			if ( ! running || path !== 'TrackProcessor' ) {
				reader.cancel().catch( function () {} );
				return;
			}
			reader.read().then( function ( res ) {
				if ( res.done ) { return null; }
				var frame = res.value;
				var reading = handleFrame( frame, frame.displayWidth || frame.codedWidth, frame.displayHeight || frame.codedHeight );
				frame.close();
				return reading;
			} ).then( function ( value ) {
				if ( value ) { submit( value ); }
				window.setTimeout( pump, 120 );
			} ).catch( function () {
				window.setTimeout( pump, 400 );
			} );
		};
		pump();

		return Promise.resolve( true );
	}

	/**
	 * Path 3 — the video element, used only as a decoding source.
	 *
	 * It is two pixels wide and all but invisible; the agent is looking at the
	 * canvas. So even here, a video element that refuses to lay out or composite
	 * cannot produce a black preview.
	 */
	function runVideoElement() {
		if ( ! video ) { return Promise.resolve( false ); }

		video.setAttribute( 'playsinline', '' );
		video.setAttribute( 'webkit-playsinline', '' );
		video.setAttribute( 'autoplay', '' );
		video.muted = true;
		video.defaultMuted = true;
		video.playsInline = true;

		try { video.srcObject = stream; } catch ( e ) { return Promise.resolve( false ); }

		// play() is started but never awaited. On a track that is live yet silent,
		// some builds return a promise that settles neither way, and awaiting it
		// hangs the whole station on "asking for the camera". What matters is
		// whether frames appear, so that is what we watch.
		try {
			var attempt = video.play();
			if ( attempt && attempt.catch ) { attempt.catch( function () {} ); }
		} catch ( e ) {}

		return new Promise( function ( resolve ) {
			var waited = 0;
			var timer = window.setInterval( function () {
				waited += 100;
				if ( video.videoWidth > 0 ) { window.clearInterval( timer ); resolve( true ); }
				else if ( waited >= 3500 ) { window.clearInterval( timer ); resolve( false ); }
			}, 100 );
		} ).then( function ( ok ) {
			if ( ! ok ) {
				note( 'The video element never produced a frame.' );
				return false;
			}
			path = 'video';
			note( 'Reading frames from the video element at ' + video.videoWidth + '×' + video.videoHeight + '.' );

			var pump = function () {
				if ( ! running || path !== 'video' ) { return; }
				handleFrame( video, video.videoWidth, video.videoHeight ).then( function ( value ) {
					if ( value ) { submit( value ); }
					window.setTimeout( pump, 140 );
				} ).catch( function () {
					window.setTimeout( pump, 300 );
				} );
			};
			pump();
			return true;
		} );
	}

	/**
	 * Try each acquisition path until one actually delivers pictures.
	 */
	function acquire() {
		frames = 0;

		// Order matters. The admin scanner read tickets instantly through a plain
		// video element on the very phone where this station struggled, so that
		// path goes first now. ImageCapture and TrackProcessor stay behind it,
		// for the devices whose video element never paints — and since the
		// preview is a canvas we draw ourselves, going video-first cannot bring
		// the black rectangle back.
		return runVideoElement()
			.then( function ( ok ) { return ok ? true : runImageCapture(); } )
			.then( function ( ok ) { return ok ? true : runTrackProcessor(); } )
			.then( function ( ok ) {
				if ( ! ok ) { return false; }

				// Confirm that frames are genuinely arriving, not just that a
				// mechanism reported itself available.
				return new Promise( function ( resolve ) {
					window.setTimeout( function () { resolve( frames > 0 ); }, 1600 );
				} );
			} );
	}

	/**
	 * Never let a promise that refuses to settle strand the station.
	 *
	 * @param {Promise} promise Promise to bound.
	 * @param {number}  ms      Milliseconds to allow.
	 * @param {*}       value   What to resolve with on timeout.
	 */
	function within( promise, ms, value ) {
		return Promise.race( [
			promise,
			new Promise( function ( resolve ) { window.setTimeout( function () { resolve( value ); }, ms ); } )
		] );
	}

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

		var startBtn = $( '[data-d-start]' );

		if ( ! window.isSecureContext ) {
			say( 'This page is not on https:// — cameras are blocked here.' );
			note( 'Not a secure context.' );
			showTab( 'photo' );
			return;
		}

		if ( ! navigator.mediaDevices || ! navigator.mediaDevices.getUserMedia ) {
			say( 'This browser will not open a camera. Use Photo or Keypad.' );
			note( 'getUserMedia is missing entirely.' );
			showTab( 'photo' );
			return;
		}

		say( 'Asking for the camera…' );
		if ( startBtn ) { startBtn.hidden = true; }

		within( openStream(), 12000, null ).then( function ( s ) {
			if ( ! s ) {
				throw new Error( 'TimeoutError' );
			}
			return s;
		} ).then( function ( s ) {
			stream = s;
			track = s.getVideoTracks()[ 0 ];
			running = true;

			var settings = track && track.getSettings ? track.getSettings() : {};
			note( 'Opened ' + ( ( track && track.label ) || 'a camera' ) + ' at ' + ( settings.width || '?' ) + '×' + ( settings.height || '?' ) + '.' );

			setUpTorch();

			// Wait for the detector decision before frames start arriving, so the
			// first frames are read by the best engine available rather than by
			// whatever happened to be ready.
			return pickDetector().then( function () {
				return listCameras();
			} ).then( function () {
				return within( acquire(), 11000, false );
			} );
		} ).then( function ( ok ) {
			if ( ok === undefined ) { return; }
			if ( ok ) {
				say( 'Point the camera at the ticket.' );
				note( 'Live — ' + path + ' path, ' + frames + ' frames in the first second and a half, ' + ( detector ? 'native detector' : 'built-in decoder' ) + '.' );
			} else {
				running = false;
				say( 'The camera opened but sent no picture. Use Photo — it always works.' );
				note( 'Every acquisition path failed to deliver a frame. This is a device or driver problem, not a permission one.' );
				stop();
				showTab( 'photo' );
			}
		} ).catch( function ( e ) {
			running = false;
			say( cameraMessage( e ) );
			note( 'Camera could not be opened: ' + ( e && e.name ? e.name : e ) );
			if ( startBtn ) { startBtn.hidden = false; }
			showTab( 'photo' );
		} );
	}

	function pickDetector() {
		if ( detector || ! ( 'BarcodeDetector' in window ) ) { return Promise.resolve(); }
		try {
			return window.BarcodeDetector.getSupportedFormats().then( function ( formats ) {
				if ( formats.indexOf( 'qr_code' ) !== -1 ) {
					detector = new window.BarcodeDetector( { formats: [ 'qr_code' ] } );
					note( 'Using the browser’s own QR engine, with ours as backup.' );
				}
			} ).catch( function () {} );
		} catch ( e ) {}
		return Promise.resolve();
	}

	function setUpTorch() {
		var btn = $( '[data-d-torch]' );
		if ( ! btn || ! track || ! track.getCapabilities ) { return; }
		var caps = {};
		try { caps = track.getCapabilities(); } catch ( e ) {}
		if ( ! caps || ! caps.torch ) { return; }
		btn.hidden = false;
		btn.addEventListener( 'click', function () {
			btn.dataset.on = btn.dataset.on === '1' ? '0' : '1';
			track.applyConstraints( { advanced: [ { torch: btn.dataset.on === '1' } ] } ).catch( function () {} );
		} );
	}

	/**
	 * Offer every camera by name, and pre-select the right one.
	 *
	 * Phones lie about facingMode. A Samsung with a wide, an ultra-wide and a
	 * telephoto lens can refuse `facingMode: exact` outright and then answer a
	 * plain `environment` hint with the selfie camera, because a hint is not a
	 * constraint. Guessing is therefore only ever the opening move: the labels
	 * become readable the moment permission is granted, so from then on the agent
	 * gets a list and the last word. The choice is remembered on the device.
	 */
	function listCameras() {
		var picker = $( '[data-d-camera]' );
		if ( ! picker || ! navigator.mediaDevices || ! navigator.mediaDevices.enumerateDevices ) {
			return Promise.resolve();
		}

		return navigator.mediaDevices.enumerateDevices().then( function ( devices ) {
			cameras = devices.filter( function ( d ) { return d.kind === 'videoinput'; } );
			note( 'Cameras on this device: ' + cameras.length );

			if ( cameras.length < 2 ) { return; }

			picker.innerHTML = '';
			cameras.forEach( function ( cam, i ) {
				var option = document.createElement( 'option' );
				option.value = cam.deviceId;
				option.textContent = cam.label || 'Camera ' + ( i + 1 );
				picker.appendChild( option );
			} );

			var current = track && track.getSettings ? track.getSettings().deviceId : '';
			if ( current ) { picker.value = current; }
			picker.hidden = false;

			// A camera that says it is not facing outward, or is named like a
			// selfie camera, means the guess missed. Correct it once, silently.
			var settings = track && track.getSettings ? track.getSettings() : {};
			var label = ( track && track.label ) || '';
			var wrongWay = 'environment' === facing
				&& ( ( settings.facingMode && settings.facingMode !== 'environment' )
					|| /front|user|selfie|face/i.test( label ) );

			if ( wrongWay && ! chosenCamera ) {
				var back = cameras.filter( function ( d ) { return /back|rear|arri|environment|world/i.test( d.label || '' ); } )[ 0 ];
				if ( back && back.deviceId !== current ) {
					note( 'The phone answered with “' + label + '”. Switching to “' + back.label + '”.' );
					useCamera( back.deviceId );
				}
			}
		} ).catch( function () {} );
	}

	/**
	 * Reopen on a named camera and remember the choice.
	 *
	 * @param {string} deviceId Device to use.
	 */
	function useCamera( deviceId ) {
		chosenCamera = deviceId;
		try { window.localStorage.setItem( 'bfnet_door_camera', deviceId ); } catch ( e ) {}
		stop();
		running = false;
		startCamera();
	}

	function setUpFlip() {
		var btn = $( '[data-d-flip]' );
		if ( ! btn ) { return; }
		btn.hidden = false;
		btn.addEventListener( 'click', function () {
			facing = facing === 'environment' ? 'user' : 'environment';
			chosenCamera = '';
			try { window.localStorage.removeItem( 'bfnet_door_camera' ); } catch ( e ) {}
			stop();
			running = false;
			startCamera();
		} );
	}

	function stop() {
		path = '';
		running = false;
		if ( stream ) {
			stream.getTracks().forEach( function ( t ) { t.stop(); } );
			stream = null;
			track = null;
		}
		if ( video ) { video.srcObject = null; }
	}

	/* ------------------------------------------------------------------ */
	/* Sending a scan                                                     */
	/* ------------------------------------------------------------------ */

	function queue() {
		try { return JSON.parse( window.localStorage.getItem( 'bfnet_door_queue' ) || '[]' ); } catch ( e ) { return []; }
	}

	function setQueue( items ) {
		try { window.localStorage.setItem( 'bfnet_door_queue', JSON.stringify( items ) ); } catch ( e ) {}
		var badge = $( '[data-d-queued]' );
		if ( badge ) { badge.textContent = String( items.length ); }
		if ( offlineEl ) { offlineEl.hidden = items.length === 0; }
	}

	function post( code, gateName, override ) {
		var body = new window.FormData();
		body.append( 'bfnet_action', 'scan' );
		body.append( 'code', code );
		body.append( 'gate', gateName );
		if ( override ) { body.append( 'override', '1' ); }

		return window.fetch( cfg.endpoint, {
			method: 'POST',
			credentials: 'same-origin',
			body: body
		} ).then( function ( r ) { return r.json(); } );
	}

	function submit( code ) {
		if ( busy || ! code ) { return; }

		var now = Date.now();
		if ( code === lastCode && now - lastAt < 6000 ) { return; }
		lastCode = code;
		lastAt = now;
		busy = true;

		post( code, gate() ).then( function ( data ) {
			show( data );
		} ).catch( function () {
			// The network went away mid-shift. Keep the guest moving and record
			// the scan locally; it is sent as soon as the signal returns.
			var pending = queue();
			pending.push( { code: code, gate: gate(), at: now } );
			setQueue( pending );
			show( {
				state: 'warn',
				title: 'Saved offline',
				message: 'No signal. This scan is stored and will be sent automatically.',
				name: ''
			} );
		} ).then( function () {
			window.setTimeout( function () { busy = false; }, 450 );
		} );
	}

	function flush() {
		var pending = queue();
		if ( ! pending.length ) { return; }

		note( 'Sending ' + pending.length + ' queued scan(s).' );
		var remaining = [];
		var done = 0;

		pending.forEach( function ( item ) {
			post( item.code, item.gate ).catch( function () {
				remaining.push( item );
			} ).then( function () {
				done++;
				if ( done === pending.length ) { setQueue( remaining ); }
			} );
		} );
	}

	/* ------------------------------------------------------------------ */
	/* Verdict                                                            */
	/* ------------------------------------------------------------------ */

	var dismissTimer = null;
	var pendingOverride = '';

	function show( data ) {
		var state = data.state || 'bad';
		var marks = { ok: '✓', warn: '!', bad: '✕', session: '✕', error: '✕' };

		verdictEl.className = 'd-verdict is-' + ( state === 'ok' || state === 'warn' ? state : 'bad' );
		verdictEl.hidden = false;

		$( '[data-d-mark]' ).textContent = marks[ state ] || '✕';
		$( '[data-d-title]' ).textContent = data.title || '';
		$( '[data-d-name]' ).textContent = data.name || '';

		var meta = [];
		if ( data.party ) { meta.push( data.party ); }
		if ( data.serial ) { meta.push( data.serial ); }
		if ( data.message ) { meta.push( data.message ); }
		if ( data.seen_at && state !== 'ok' ) { meta.push( 'First scanned at ' + data.seen_at ); }
		$( '[data-d-meta]' ).textContent = meta.join( ' · ' );

		var overrideBtn = $( '[data-d-override]' );
		if ( overrideBtn ) {
			overrideBtn.hidden = ! data.can_override;
			pendingOverride = data.can_override ? ( data.token || lastCode ) : '';
		}

		buzz( state );
		addToFeed( data );

		if ( typeof data.inside === 'number' ) {
			var inside = $( '[data-d-inside]' );
			var expected = $( '[data-d-expected]' );
			if ( inside ) { inside.textContent = String( data.inside ); }
			if ( expected ) { expected.textContent = String( data.expected ); }
		}

		if ( dismissTimer ) { window.clearTimeout( dismissTimer ); }

		// A decision the agent has to make must not disappear while they think.
		if ( ! data.can_override ) {
			dismissTimer = window.setTimeout( dismiss, state === 'ok' ? 1800 : 4200 );
		}
	}

	function dismiss() {
		verdictEl.hidden = true;
		pendingOverride = '';

		// lastCode is deliberately left in place: the guest has been dealt with,
		// and their ticket is very often still under the camera. The typed and
		// photographed routes clear it themselves when the agent asks again.
		lastAt = Date.now();
		var overrideBtn = $( '[data-d-override]' );
		if ( overrideBtn ) { overrideBtn.hidden = true; }
	}

	function addToFeed( data ) {
		if ( ! feedEl ) { return; }
		var li = document.createElement( 'li' );
		li.className = 'is-' + ( data.state === 'ok' || data.state === 'warn' ? data.state : 'bad' );

		var label = document.createElement( 'b' );
		label.textContent = data.name || data.title || '';
		var time = document.createElement( 'span' );
		var d = new Date();
		time.textContent = ( '0' + d.getHours() ).slice( -2 ) + ':' + ( '0' + d.getMinutes() ).slice( -2 );

		li.appendChild( label );
		li.appendChild( time );
		feedEl.insertBefore( li, feedEl.firstChild );

		while ( feedEl.children.length > 25 ) { feedEl.removeChild( feedEl.lastChild ); }
	}

	/* ------------------------------------------------------------------ */
	/* Photo and keypad                                                   */
	/* ------------------------------------------------------------------ */

	/**
	 * Read a still photograph, trying hard.
	 *
	 * A photograph taken at a doorway is nothing like a clean render: it is
	 * shot at an angle, under coloured light, with the phone's own processing
	 * on top. So rather than one pass, the picture is attacked at four scales,
	 * upright and at each quarter turn, and again with the contrast pushed —
	 * and every one of those is offered to the browser's own barcode engine
	 * before ours. Roughly forty attempts, all inside a second.
	 */
	function readPhoto( file ) {
		var img = new Image();

		img.onload = function () {
			say( 'Reading the photo…' );

			var preview = $( '[data-d-photo-view]' );
			if ( preview ) {
				var pw = Math.min( 900, img.naturalWidth );
				var ps = pw / img.naturalWidth;
				preview.hidden = false;
				preview.width = Math.round( img.naturalWidth * ps );
				preview.height = Math.round( img.naturalHeight * ps );
				preview.getContext( '2d' ).drawImage( img, 0, 0, preview.width, preview.height );
			}

			var passes = [];
			[ 1000, 700, 1400, 500 ].forEach( function ( wide ) {
				[ 0, 90, 180, 270 ].forEach( function ( turn ) {
					passes.push( { wide: wide, turn: turn, boost: false } );
				} );
				passes.push( { wide: wide, turn: 0, boost: true } );
			} );

			var i = 0;
			var next = function () {
				if ( i >= passes.length ) {
					window.URL.revokeObjectURL( img.src );
					show( {
						state: 'bad',
						title: 'No code found',
						message: 'Fill the frame with the QR code, hold steady, avoid glare — or use the Keypad.'
					} );
					return;
				}

				var pass = passes[ i++ ];
				var size = drawPhotoPass( img, pass );
				if ( ! size ) { next(); return; }

				decodeWork( size.w, size.h, null ).then( function ( value ) {
					if ( value ) {
						window.URL.revokeObjectURL( img.src );
						note( 'Photo read on pass ' + i + ' (' + pass.wide + 'px, ' + pass.turn + '°' + ( pass.boost ? ', boosted' : '' ) + ').' );
						lastCode = '';
						submit( value );
						return;
					}
					next();
				} ).catch( next );
			};

			next();
		};

		img.onerror = function () {
			show( { state: 'bad', title: 'Photo unreadable', message: 'Take it again.' } );
		};

		img.src = window.URL.createObjectURL( file );
	}

	/**
	 * Paint one attempt onto the work canvas.
	 *
	 * @param {Image}  img  Source photograph.
	 * @param {Object} pass Width, rotation and whether to push the contrast.
	 * @return {Object|null} The canvas size used.
	 */
	function drawPhotoPass( img, pass ) {
		var target = Math.min( pass.wide, img.naturalWidth );
		if ( target < 200 ) { return null; }

		var scale = target / img.naturalWidth;
		var w = Math.round( img.naturalWidth * scale );
		var h = Math.round( img.naturalHeight * scale );
		var turned = pass.turn === 90 || pass.turn === 270;

		work.width = turned ? h : w;
		work.height = turned ? w : h;

		wctx.save();
		wctx.translate( work.width / 2, work.height / 2 );
		wctx.rotate( pass.turn * Math.PI / 180 );
		wctx.drawImage( img, -w / 2, -h / 2, w, h );
		wctx.restore();

		if ( pass.boost ) {
			try {
				var data = wctx.getImageData( 0, 0, work.width, work.height );
				var px = data.data;
				for ( var i = 0; i < px.length; i += 4 ) {
					var grey = ( px[ i ] * 0.299 + px[ i + 1 ] * 0.587 + px[ i + 2 ] * 0.114 );
					var hard = grey < 118 ? 0 : 255;
					px[ i ] = px[ i + 1 ] = px[ i + 2 ] = hard;
				}
				wctx.putImageData( data, 0, 0 );
			} catch ( e ) {}
		}

		return { w: work.width, h: work.height };
	}

	/* ------------------------------------------------------------------ */
	/* Tabs                                                               */
	/* ------------------------------------------------------------------ */

	function showTab( name ) {
		Array.prototype.forEach.call( document.querySelectorAll( '[data-d-tab]' ), function ( b ) {
			b.classList.toggle( 'is-on', b.getAttribute( 'data-d-tab' ) === name );
		} );
		Array.prototype.forEach.call( document.querySelectorAll( '[data-d-pane]' ), function ( p ) {
			p.classList.toggle( 'is-on', p.getAttribute( 'data-d-pane' ) === name );
		} );

		if ( name === 'keypad' ) {
			var field = $( '[data-d-code]' );
			if ( field ) { field.focus(); }
		}
	}

	/* ------------------------------------------------------------------ */
	/* Self-test                                                          */
	/* ------------------------------------------------------------------ */

	function selfTest() {
		var lines = [];
		var add = function ( k, v ) { lines.push( k + ': ' + v ); };

		add( 'Station', cfg.title + ' (#' + cfg.event + ')' );
		add( 'Page', window.location.href );
		add( 'Secure context', window.isSecureContext ? 'yes' : 'NO — cameras blocked' );
		add( 'User agent', navigator.userAgent );
		add( 'getUserMedia', navigator.mediaDevices && navigator.mediaDevices.getUserMedia ? 'present' : 'MISSING' );
		add( 'ImageCapture', window.ImageCapture ? 'present' : 'absent' );
		add( 'TrackProcessor', window.MediaStreamTrackProcessor ? 'present' : 'absent' );
		add( 'BarcodeDetector', 'BarcodeDetector' in window ? 'present' : 'absent' );
		add( 'Built-in decoder', window.BFNETQR ? 'loaded' : 'MISSING' );
		add( 'Acquisition path', path || 'none running' );
		add( 'Frames seen', String( frames ) );
		add( 'Track', track ? ( track.label + ' / ' + track.readyState ) : 'none' );
		add( 'Queued scans', String( queue().length ) );
		add( 'Online', navigator.onLine ? 'yes' : 'no' );

		reportEl.hidden = false;
		reportEl.textContent = lines.join( '\n' ) + '\n\n--- log ---\n' + log.join( '\n' );

		if ( navigator.permissions && navigator.permissions.query ) {
			navigator.permissions.query( { name: 'camera' } ).then( function ( st ) {
				reportEl.textContent = 'Camera permission: ' + st.state + '\n' + reportEl.textContent;
			} ).catch( function () {} );
		}

		// Prove the transport works, rather than assuming it.
		post( 'BFNET-SELFTEST-000000', gate() ).then( function () {
			reportEl.textContent = 'Scan transport: reachable\n' + reportEl.textContent;
		} ).catch( function ( e ) {
			reportEl.textContent = 'Scan transport: UNREACHABLE (' + e + ')\n' + reportEl.textContent;
		} );
	}

	/* ------------------------------------------------------------------ */
	/* Wiring                                                             */
	/* ------------------------------------------------------------------ */

	document.addEventListener( 'click', function ( e ) {
		var el = e.target.closest ? e.target.closest( '[data-d-tab],[data-d-key],[data-d-photo],[data-d-start],[data-d-dismiss],[data-d-override],[data-d-menu],[data-d-sheet-close],[data-d-selftest],[data-d-retry]' ) : null;
		if ( ! el ) { return; }

		if ( el.hasAttribute( 'data-d-tab' ) ) {
			showTab( el.getAttribute( 'data-d-tab' ) );
			if ( el.getAttribute( 'data-d-tab' ) === 'camera' && ! running ) { startCamera(); }
			return;
		}

		if ( el.hasAttribute( 'data-d-key' ) ) {
			e.preventDefault();
			var field = $( '[data-d-code]' );
			var key = el.getAttribute( 'data-d-key' );
			if ( key === '⌫' ) { field.value = field.value.slice( 0, -1 ); }
			else { field.value += key; }
			field.value = field.value.toUpperCase();
			return;
		}

		if ( el.hasAttribute( 'data-d-photo' ) ) { e.preventDefault(); $( '[data-d-photo-input]' ).click(); return; }
		if ( el.hasAttribute( 'data-d-start' ) ) { e.preventDefault(); startCamera(); return; }
		if ( el.hasAttribute( 'data-d-dismiss' ) ) { dismiss(); return; }

		if ( el.hasAttribute( 'data-d-override' ) ) {
			e.preventDefault();
			if ( ! pendingOverride ) { return; }
			var code = pendingOverride;
			pendingOverride = '';
			el.hidden = true;
			busy = false;
			post( code, gate(), true )
				.then( function ( data ) { lastCode = ''; show( data ); } )
				.catch( function () {
					show( { state: 'bad', title: 'Not recorded', message: 'No signal — try again.' } );
				} );
			return;
		}
		if ( el.hasAttribute( 'data-d-menu' ) ) { sheetEl.hidden = false; return; }
		if ( el.hasAttribute( 'data-d-sheet-close' ) ) { sheetEl.hidden = true; return; }
		if ( el.hasAttribute( 'data-d-selftest' ) ) { selfTest(); return; }
		if ( el.hasAttribute( 'data-d-retry' ) ) { flush(); return; }
	} );

	var picker = $( '[data-d-camera]' );
	if ( picker ) {
		picker.addEventListener( 'change', function () {
			if ( picker.value ) { useCamera( picker.value ); }
		} );
	}

	var photoInput = $( '[data-d-photo-input]' );
	if ( photoInput ) {
		photoInput.addEventListener( 'change', function () {
			var file = photoInput.files && photoInput.files[ 0 ];
			photoInput.value = '';
			if ( file ) { readPhoto( file ); }
		} );
	}

	var codeField = $( '[data-d-code]' );
	if ( codeField ) {
		codeField.addEventListener( 'input', function () {
			var at = codeField.selectionStart;
			codeField.value = codeField.value.toUpperCase();
			try { codeField.setSelectionRange( at, at ); } catch ( e ) {}
		} );
	}

	var keyForm = $( '[data-d-keyform]' );
	if ( keyForm ) {
		keyForm.addEventListener( 'submit', function ( e ) {
			e.preventDefault();
			var field = $( '[data-d-code]' );
			var value = field.value.trim();
			field.value = '';
			field.focus();
			lastCode = '';
			if ( value ) { submit( value ); }
		} );
	}

	window.addEventListener( 'online', flush );
	window.addEventListener( 'beforeunload', stop );

	// Keep the screen awake — a station that sleeps mid-queue is useless.
	if ( navigator.wakeLock && navigator.wakeLock.request ) {
		navigator.wakeLock.request( 'screen' ).catch( function () {} );
		document.addEventListener( 'visibilitychange', function () {
			if ( document.visibilityState === 'visible' ) {
				navigator.wakeLock.request( 'screen' ).catch( function () {} );
			}
		} );
	}

	pickDetector();

	setQueue( queue() );
	flush();

	if ( ! ( 'BarcodeDetector' in window ) ) {
		note( 'This browser has no built-in QR engine, so the plugin\'s own reader is doing the work. It is slower and fussier about focus and glare — Chrome reads tickets noticeably faster.' );
	}

	note( ( window.isSecureContext ? 'Secure page' : 'INSECURE page' )
		+ ' · getUserMedia ' + ( navigator.mediaDevices && navigator.mediaDevices.getUserMedia ? 'yes' : 'NO' )
		+ ' · ImageCapture ' + ( window.ImageCapture ? 'yes' : 'no' )
		+ ' · TrackProcessor ' + ( window.MediaStreamTrackProcessor ? 'yes' : 'no' )
		+ ' · decoder ' + ( window.BFNETQR ? 'yes' : 'NO' ) );

	startCamera();
} )();
