import * as rsPage from "./page.js";
import {Icon, STYLE_SUCCESS, STYLE_WARNING} from "./page.js";
import * as util from "./util.js";
import {EVENT_BOOKING, EVENT_REGISTER, RSSchedule, TickingCountdown} from "./util.js";
import {
	ApplicationError,
	JsonParseError,
	ResponseError,
	ROOM_SESSION_ATTENDANCE_FULL, ROOM_SESSION_ATTENDANCE_UNAVAILABLE,
	UNEXPECTED_CONTENT
} from "./errors.js";

import {ModeFSM} from "./mode.js";
import {EventCollection, FUTURE, PRESENT} from "../time/events.js";

/** Define Module Constants: **/

export const UPCOMING_LESSON_INDICATOR_MINS = 20;
// Same as above but for the "Hurry" message. Should be less than value above.
export const UPCOMING_LESSON_INDICATOR_HURRY_MINS = 10;

const POLL_FREQUENCY_MINS           = 20;     //- How often to poll Server for new room schedule / admin data.
const CAMERA_INITIALISE_GRACE_MS    = 1500;   //- How long to wait for the camera to be ready, with the video feed
                                              //  correctly loaded in HTML.

const CLEAROUT_MS_REPEAT_SCAN_CONFIRMED = 10000;
const CLEAROUT_MS_REGISTRATION = 15000;
const CLEAROUT_MS_SIMPLE_ERROR = 5000;
const CLEAROUT_MS_DETAIL_ERROR = 10000;

// Extra milliseconds added to globalConfig.screenTimeoutMs,
// when there is more info on screen.
const CLEAROUT_EXTRA_MS_HURRY = 5000;
const CLEAROUT_EXTRA_MS_OFFER_SHORT_BOOKING = 5000;

export const STUDY_SESSION_MODE_PROMPT   = 0;
export const STUDY_SESSION_MODE_INSTANT  = 1;
export const STUDY_SESSION_MODE_DISABLED = 2;

const MarkIconMap = {
	'present':Icon.TICK,
	'late': Icon.ALARM_CLOCK,
	'absent': Icon.CROSS,
	'unknown': null // NULL means to use the default static pageicon.
};

const MarkStyleMap = {
	'present': STYLE_SUCCESS,
	'late': STYLE_WARNING,
	'absent': STYLE_WARNING,
	'unknown': null
};

/** RoomScanner Class: **/

/**
 * Responsible for scanning student QR codes for register marking,
 * study support sessions and providing students or staff with information
 * about their next session.
 *
 * Methods below with name convention "{user}_{request}" are Screen Handlers, e.g. "staff_YourRoomBooking".
 * The screen handler is determined by the server and picked up in the scan response.
 *
 * See below:
 *  •  run()        For how the RS is started.
 *  •  _scan(..)  For what happens when a QR code is scanned
 *
 */
export class RoomScanner {

	/**
	 * @param {Element} video Camera preview <video> element
	 * @param {string} baseUrl E.g. "https://localhost". No trailing forward-slash.
	 * @param {Object} vocab App vocab key-vals e.g. {studySupport:'Study Support'}
	 * @param {Object} sessionData See ORM/RoomScannerSessionConfig
	 * @param {Object} globalConfig
	 * @param {int} version
	 * @param {int} sfxCount
	 * @param {Object} mapController See getMapController()
	 * @param {boolean} devEnvironment
	 */
	constructor( video,        baseUrl, vocab,    sessionData,
				 globalConfig, version, sfxCount, mapController, devEnvironment = false ) {

		this.baseUrl = baseUrl;
		this.vocab = vocab;
		this.sessionData = sessionData;
		this.globalConfig = this._parseGlobalConfig(globalConfig);
		this.version = version;
		this.mapController = mapController;
		this.devEnvironment = devEnvironment;

		this.devLog('Room Scanner: Running in Dev Environment');


		this.pollTimeMs = POLL_FREQUENCY_MINS * 60 * 1000;

		this.roomSchedule = new RSSchedule([]);
		this.roomScheduleMD5 = '';
		this.lastScanDate = new Date(0); // Set the date sometime earlier than now -> epoch start.
		this.lastScanContent = null;
		this.pollTimeout = null;
		this.scannerActive = false;
		//this.currDateShiftMs = null;

		/**
		 * Stores flags / data taken from the last scan, used in a "Scan Again" sequence.
		 * These will be set when a "Scan Again to ..." message appears on screen.
		 * If the same person scans again, they will be POSTed in that scan request. These will not last between
		 * Screen Handlers responses, and can only be used for POSTing data to the server.
		 * They are cleared either when a new Screen Handler is presented or if the screen timeout ends being set
		 * to `null`.
		 * @type {RepeatScan} repeatScan
		 */
		this.repeatScan = {
			studySessionState:      null,
			studySessionData:       null,
			roomBookingRequest:     null,
			roomBookingLengthType:  null,
			roomBookingId:          null,
		};

		this.page = new rsPage.Page(this);
		this.online = true;

		// Bind network status events
		window.addEventListener('online',  () => { this._checkOnline(); });
		window.addEventListener('offline', () => { this._checkOnline(); });

		/** Scan Clearout **/

		// "Clearout" runs after each scan is completed, or if the bottom-left timeout "SESSIONINFO" reaches 0.

		// Clear out any person-specific info on screen
		let clearout = () => {
			// Clear out all properties used in a "Scan Again" sequence
			this._clearRepeatScanProperties();

			// If we're showing a custom icon, reset it to the Static icon and roll
			if (this.page.hasTemporaryIcon()) {
				this.page.setIcon(null);
			}

			// Remove any map markers
			this.page.setMapData(null);

			this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {
				this.page.clearUserData();
			});
		};

		this.clearoutCountdown = new TickingCountdown();
		this.clearoutCountdown
			.onTick(secsRemaining => this.page.setDataTimeout(secsRemaining))
			.onCancel(() => {
				clearout();
			})
			.onComplete(() => {
				clearout();
			});

		/**
		 * Stores the current scanner mode.
		 * @see {Mode} and its subclasses.
		 *
		 * This will also hold the current event taking place right now, via `getModeEvent`.
		 *
		 * @type {ModeFSM}
		 */
		this.modeFSM = new ModeFSM(this, this.page, this.vocab);

		this.scanner = new QrScanner(video, sfxCount, document.querySelector('#scan-sfx'));
		this.scanner.setMirroring(true);
		this.scanner.onScan(qr => this._scan(qr).catch(err  => this._handleScanError(err)));
	}

	/**
	 * Begin capturing qr scans and start the server polling loop.
	 * @async
	 * @public
	 */
	async run() {

		const activeCamId = this.sessionData.cameraId;

		try {
			// Request access to use the browser camera, start the video stream and listen for QR scans.
			await this.scanner.initialise(activeCamId);

			// Reset the ScreenSaver to begin its countdowns.
			this.page.screenSaver.reset();

			/** Start Poll Loop  **/

			// Pull in room scheduling data and begin the poll loop. This should populate variable `this.roomSchedule`.
			// NB: `scanner.initialise` above will resolve before the camera & video feed is (in practice) fully setup,
			// hence the CAMERA_INITIALISE_GRACE_MS delay below.
			try {
				await forcePromiseMinDelay(this._poll(), CAMERA_INITIALISE_GRACE_MS);
			} catch (e) {
				// TypeError is used when there is network failure.
				// If this is thrown, then just ignore it and persevere for now. `_checkOnline` below will
				// update the page as necessary if the network is down.
				if (e instanceof TypeError) {
					console.log('Initial poll failed', e);
				} else {
					// If the error is not network failure, then pass it on to the higher-level handler as it is
					// more serious.
					throw e;
				}
			}

			/** Other Setup Calls **/

			// Start the minutely cron job. This will change the screen state if e.g. a lesson is going on.
			this._startMinuteCron();

			this._checkOnline(); // Show online status.

			// Let any potential screen animations play before granting camera access
			getDelay(rsPage.ACTIVATE_ANIMATION_LENGTH_MS).then(() => {
				this.scannerActive = true;
			});

		} catch(err) {
			this.page.setIcon(Icon.BROKEN_HEART);
			this.page.setPageCss('warning');
			this.page.setScanMessage(
				'The scanner failed to initialise. Please contact a technician.',
				STYLE_WARNING
			);

			await this.page.setActive(false);
			util.logError('SCANNER_INITIALISE', err);
		}
	}

	/**
	 * Perform a single check for if the RS has an active internet connection.
	 * If not, then reflect this on the page.
	 * @private
	 */
	_checkOnline() {

		// Check online status
		let currOnline = true;
		if (navigator) {
			currOnline = navigator.onLine;
		}

		// Update page only if online status has changed
		if (this.online !== currOnline) {
			this.online = currOnline;
			this.page.setOnline(currOnline);
		}

		// return currOnline;
	}

	/**
	 * @param {Object} globalConfig
	 * @return {GlobalConfig}
	 * @private
	 */
	_parseGlobalConfig(globalConfig) {
		globalConfig.screenTimeoutMs           = globalConfig.screenTimeoutSecs * 1000;
		globalConfig.studySessionTimeMs        = globalConfig.studySessionTimeMins * 60 * 1000;
		globalConfig.startRegistersMsInAdvance = globalConfig.startRegistersMinsInAdvance * 60 * 1000;
		return globalConfig;
	}

	/**
	 * Screen handler for scanner errors.
	 * @param {(Error|*)} error Error/exception object.
	 * @private
	 */
	_handleScanError(error) {

		let errType = null;

		this.clearoutCountdown.cancel();

		if (error instanceof ApplicationError) {

			// If there is a scanner version update, then this will manually reload the page.
			if (this._checkVersion(error.appVersion)) {
				return false;
			}

			// Try to handle any known Application errors. Not all Application Errors can be handled however.
			if (this._handleApplicationError(error)) {
				return; // Handled.
			} else {
				// No specific screen handler for this error.
				errType = error.errType;
			}
		}

		// Check for connection errors:

		if (error instanceof TypeError && !this.online) {
			this.page.setIcon(Icon.ELECTRICAL_PLUG);

			this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {

				this.page.setPageCss(STYLE_WARNING);

				this.page.setScanMessage(
					'The scanner appears to be offline. Please try again later.',
					STYLE_WARNING
				);
				this.clearoutCountdown.start(CLEAROUT_MS_SIMPLE_ERROR); // Show error for 5 seconds.
			});

			// If it was a network error, but we know we're offline, then don't log the error
			// as to avoid polluting the "/errors" page with offline errors.
			return

		// Else - unknown error:

		} else {

			this.page.setIcon(Icon.BROKEN_HEART);

			this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {

				this.page.setPageCss(STYLE_WARNING);

				let scanMsg = 'Unknown Error - If the problem continues, please contact a technician.';
				let errLen = CLEAROUT_MS_SIMPLE_ERROR;
				if (errType) {
					scanMsg = `Scanner Error - If the problem continues, 
                              please contact a technician.<br>(${errType})`;
					errLen = CLEAROUT_MS_DETAIL_ERROR;
				}

				this.page.setScanMessage(scanMsg, STYLE_WARNING);
				this.clearoutCountdown.start(errLen); // Show error for 8 seconds.
			});

			util.logError('SCAN', error);
		}
	}


	/**
	 *
	 * @param {ApplicationError} error
	 * @return {boolean} true if handled, false if not and the page is unchanged.
	 * @private
	 */
	_handleApplicationError(error) {

		// Handle any known application errors here:
		switch(error.errType) {
			case UNEXPECTED_CONTENT:
				this.page.setIcon(Icon.PERSON_GESTURING_NO);
				this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES,() => {
					this.page.setScanMessage('Unrecognised QR Code', STYLE_WARNING);
					this.clearoutCountdown.start(CLEAROUT_MS_SIMPLE_ERROR); // Wait for 5 seconds
					// Remove any student activities visible
					this.page.setActivities(null, false)
				});

				// This is not an error worth auditing - don't use `Util.logError`.
				console.error('Unrecognised QR Requested: ' + error.content);
				return true;
		}

		return false;
	}

	/**
	 * @param {string} rawScanContent
	 * @async
	 * @private
	 */
	async _scan(rawScanContent) {

		// Only accept scans when it is ready and loaded.
		if (!this.scannerActive) {
			return;
		}

		// Show a scan confirmation indicator
		this.page.setScanConfirmationVisible(true);

		// Active the scanner screen

		// Update properties on scan
		this.lastScanDate = new Date();
		let scanContent = rawScanContent.toLowerCase();

		// If this is not the same person scanning, then clear out all the Repeat Scan variables as
		// they are no longer relevant.
		if (this.lastScanContent !== scanContent) {
			this._clearRepeatScanProperties();
		}

		// Wait another ~5 mins before we deactivate the screen again
		this.page.screenSaver.reset();


		/** Populate scan fetch POST Data **/

		let scanPOST = new FormData();

		let currEvent = this.modeFSM.getModeEvent();

		scanPOST.append('scanContent', scanContent);
		// If there is an event going on right now, then supply the Server with its details:
		scanPOST.append('eventId', currEvent ? currEvent.id : null);
		scanPOST.append('eventType', currEvent ? currEvent.type : null);

		scanPOST.append('roomScheduleMD5', this.roomScheduleMD5);

		// Add all "Scan Again" properties.
		// These will only remain populated if a user follows up a "Scan Again" request.
		scanPOST.append('studySessionState', this.repeatScan.studySessionState);
		scanPOST.append('studySessionData',  JSON.stringify(this.repeatScan.studySessionData));

		scanPOST.append('roomBookingRequest',    this.repeatScan.roomBookingRequest);
		scanPOST.append('roomBookingLengthType', this.repeatScan.roomBookingLengthType);
		scanPOST.append('roomBookingId',         ''+this.repeatScan.roomBookingId);

		// Room Session ID. Used for marking attendees on the session register.
		// NB: This is different from eventId which stores the room *booking* ID.
		scanPOST.append('roomSessionID', RSSchedule.isRoomSession(currEvent) ? currEvent.sessionID : null);

		if (this.devEnvironment) {
			this.devLog('POST data:', [...scanPOST]);
		}

		/** Send request to server and handle response **/

		let req = fetch(this.baseUrl+'/ScanListener', {method: "POST", body: scanPOST, credentials: 'include'});

		try {
			/*** Try to parse JSON scan response ***/
			let response;
			try {
				this.page.setLoading(true);
				// Wait for the page activation animation to finish before handling the response.
				// noinspection JSCheckFunctionSignatures Both array values are promises.
				[response] = await Promise.all([req, this.page.setActive()]);
			} finally {
				// Ensure the loading message is dismissed asap. Errors should be passed up the chain.
				this.page.setLoading(false);
			}

			// Is the HTTP status good? If not, throw a suitable error.
			await this._checkHTTPStatus(response);
			let json = await this._parseJSON(response);

			this.devLog('Response JSON: ', json);

			// If there is a version update, then this will manually reload the page.
			if (this._checkVersion(json.version)) {
				return false;
			}

			/*** Update any internal properties, separate from the Screen Handler ***/

			// If any Room Schedule data has been returned, try to load it up.
			// This will evaluate to `true` if Schedule alterations are made.
			if (json.hasOwnProperty('roomScheduleMD5') && this._loadRoomScheduleData(json)) {

				// Check for Mode & room schedule changes.
				this._minuteCron();
			}

			/*** Execute the Screen Handler ***/

			if (json.handler in this) {
				this[json.handler](json);
			} else {
				// Unrecognised response handler. This should be logged.
				throw new ResponseError(response, json, 'Unexpected handler: ' + json.handler);
			}

		} catch (err) {
			// Screen handler for displaying error messages.
			this._handleScanError(err);

		} finally {
			// Keep last scan updated.
			this.lastScanContent = scanContent;
		}
	}

	/** Student Screen Handlers **/

	student_Information(json) {

		// Clear any timeout content on screen
		this.clearoutCountdown.cancel();
		let studentDaySchedule = new RSSchedule(json.studentDaySchedule);

		// Will always be greater than or equal to UPCOMING_LESSON_INDICATOR_MINS
		let maxUpcomingLessonMins = this.getMaxUpcomingLessonMins();

		let now = new Date();

		let stuNextReg = studentDaySchedule.getNextEvent(false, EVENT_REGISTER);
		let roomNextReg = this.roomSchedule.getNextEvent(false, EVENT_REGISTER);
		// NOTE - `NaN` will always evaluate to `false` in comparision/equality checks
		let minsUntilStuNextRegOrNaN = stuNextReg ? Math.ceil((stuNextReg.start - now) / 60000) : NaN;
		let minsUntilRoomNextRegOrNaN = roomNextReg ? Math.ceil((roomNextReg.start - now) / 60000) : NaN;

		let roomNextBooking
			= this.roomSchedule.getNextEventWithinTime(UPCOMING_LESSON_INDICATOR_MINS * 60000, EVENT_BOOKING);

		/** Determine icon to show **/

		// If a study session already exists
		if (json.placeholderStudySession) {
			this.page.setIcon(Icon.CHEQUERED_FLAG);

		} else if (json.roomSessionStatus === ROOM_SESSION_ATTENDANCE_FULL) {
			this.page.setIcon(Icon.NO_ENTRY)

		// If the student has something going on right now
		} else if (minsUntilStuNextRegOrNaN <= 0) {
			this.page.setIcon(Icon.RUNNER);

		} else {
			this.page.setIcon(Icon.CALENDAR);
		}

		/** Update page content in screen fade **/

		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {
			// ScanMessage arguments:
			let msg = '', style = '';

			this.page.setHeadlineFromPerson(json.student);

			/** Student has lesson on right now, or starts *very* soon: **/

			// If their lesson is starting very soon
			if (minsUntilStuNextRegOrNaN <= UPCOMING_LESSON_INDICATOR_HURRY_MINS) {
				// Show user content the screen for a 5 seconds longer
				this.clearoutCountdown.start(this.globalConfig.screenTimeoutMs + CLEAROUT_EXTRA_MS_HURRY);

				this.page.mapUserActivities(studentDaySchedule, stuNextReg);

				style = STYLE_WARNING;

				// If the lesson has already started:
				if (minsUntilStuNextRegOrNaN <= 0) {
					this.page.setPageCss(STYLE_WARNING);
					msg = `You have a lesson going on right now in ${stuNextReg.room.reference}!`;
				} else {
					msg = `You have a lesson in ${stuNextReg.room.reference} starting soon!`;
				}

			/** Other conditions - full schedule view **/

			} else {
				this.clearoutCountdown.start(this.globalConfig.screenTimeoutMs);

				// Show their scheduling details
				this.page.setActivities(null, studentDaySchedule);

				// If the student has an upcoming register (in a different room).
				// We don't use `maxUpcomingLessonMins` as this exists as a (potentially large) value to allow registers
				// to be marked in advance.
				if (minsUntilStuNextRegOrNaN <= UPCOMING_LESSON_INDICATOR_MINS
					&& stuNextReg.room.reference !== this.sessionData.room.reference ) {

					msg = 'You have a lesson in ' + stuNextReg.room.reference + ' starting shortly.';

				// If the room has an upcoming register
				} else if (minsUntilRoomNextRegOrNaN <= maxUpcomingLessonMins) {

					// If this register is taking place right now:
					if (minsUntilRoomNextRegOrNaN <= 0) {
						msg = 'There is currently a lesson in progress for this room';
						style = STYLE_WARNING;
					} else {
						if (studentDaySchedule.has(roomNextReg) && !isNaN(minsUntilRoomNextRegOrNaN)) {
							let minsUntilStudentsCanRegister
								= minsUntilRoomNextRegOrNaN - this.globalConfig.startRegistersMinsInAdvance;

							msg = `You will be able to scan for registration 
								   in ${pluralize('minute', minsUntilStudentsCanRegister)}`;
							style =  STYLE_SUCCESS;

						} else {
							msg = 'A lesson will be starting shortly in this room';
							style = STYLE_WARNING;
						}

					}
				}
			}

			// If the student attempted to book themselves into a RoomSession, but it failed, respond with a suitable
			// message.
			if (json.roomSessionStatus === ROOM_SESSION_ATTENDANCE_FULL) {
				msg = 'This event is now fully booked.';
				style = STYLE_WARNING;
			}

			// A "placeholder study session" means the student has a study session they need to close off.
			// Allow for second scans to complete started Study Sessions, regardless of the previous state.
			// This is so students can close off their study sessions regardless of what priorities come up.
			// This will override any `msg` values set above.
			if (json.placeholderStudySession) {
				this.repeatScan.studySessionState = 'complete';
				this.repeatScan.studySessionData = json.placeholderStudySession;

				// Note - `.start` is an timestamp and not a Date object as it must be POSTed back to the server
				// during a repeat scan. An integer will make this simpler.
				let sessionStartMs = json.placeholderStudySession.start * 1000;

				let now = new Date();
				let minsSinceStart
					= Math.ceil((now.getTime() - sessionStartMs) / 60000);

				msg = `Scan again to complete your current ${this.vocab.studySupport} Session
				       <br>(started ${pluralize('minute', minsSinceStart)} ago)`;
				style = STYLE_SUCCESS;

			//  If enabled, allow the student to create a new study session.
			} else if (this.sessionData.studySessionMode !== STUDY_SESSION_MODE_DISABLED) {
				this.repeatScan.studySessionState = 'start';

				let studySessionMsg = `Scan again to log a ${this.vocab.studySupport}  Session.`;
				let roomBookingMsg = '';

				// Do not show booking warnings when there is already a message visible
				// regarding the RoomSession (i.e. roomSessionStatus is populated).
				let roomSessionMsgVisible = json.roomSessionStatus
					&& json.roomSessionStatus !== ROOM_SESSION_ATTENDANCE_UNAVAILABLE;

				if (roomNextBooking && !roomSessionMsgVisible) {
					if (EventCollection.eventStatus(roomNextBooking) === FUTURE) {
						roomBookingMsg = '<br>Note: A staff member has booked this room, which will begin shortly.';
					} else {
						roomBookingMsg = '<br>Note: A staff member has booked this room right now.';
					}
				}

				msg += (msg ? '<br>' : '') + studySessionMsg;
				let fullMsg = msg + '<br>' + roomBookingMsg;

				// If there is enough space in the ScanMessage, use the fullMsg. Else, use regular the msg.
				if (stripTags(fullMsg).length <= this.page.getScanMessageLimit()) {
					msg = fullMsg;
				}

			// Else - Study sessions are disabled.
			}

			if (!msg) {
				msg = 'Present your QR code to refresh';
			}

			this.page.setScanMessage(msg, style);
		});
	}

	student_StartStudySession(json) {
		this.clearoutCountdown.cancel();
		this.page.setIcon(Icon.THUMBS_UP);

		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {
			this.page.setHeadlineFromPerson(json.student);
			this.page.setPageCss(STYLE_SUCCESS);

			// If set, show student schedule.
			if (json.stuSchedule) {
				this._showEvents(new RSSchedule(json.stuSchedule), {urgencyWarning: false});
			}

			this.page.setScanMessage(
				`Successfully created a new ${this.vocab.studySupport} session.<br>
				 When you're finished with this room, scan once more to end the session.`,
				STYLE_SUCCESS
			);

			let timeout = this.sessionData.studySessionMode === STUDY_SESSION_MODE_INSTANT
				? this.globalConfig.screenTimeoutMs : CLEAROUT_MS_REPEAT_SCAN_CONFIRMED;
			this.clearoutCountdown.start(timeout);
		});
	}

	student_CompleteStudySession(json) {

		let instantStudySession = this.sessionData.studySessionMode === STUDY_SESSION_MODE_INSTANT;
		this.clearoutCountdown.cancel();
		this.page.setIcon(Icon.THUMBS_UP);

		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {
			this.page.setHeadlineFromPerson(json.student);
			this.page.setPageCss(STYLE_SUCCESS);

			// If set, show student schedule.
			if (json.stuSchedule) {
				this._showEvents(new RSSchedule(json.stuSchedule), {urgencyWarning: false});
			}

			// --- Determine Study Session Length ---

			// This is only necessary if "Enable Study Session Mode" is ticked, as previously the study session length
			// would show on the confirmation screen before a student scans again to confirm.

			let sessionStartMs = json.studySession.start * 1000; // Note - `.start` is an timestamp, not a Date object.
			let now = new Date();
			let minsSinceStart = Math.ceil((now.getTime() - sessionStartMs) / 60000);
			let timeHtml = instantStudySession ? `<br>(started ${pluralize('minute', minsSinceStart)} ago)` : '';

			// --- Set Scan Message ---

			this.page.setScanMessage(
				'Your ' + this.vocab.studySupport + ' session is now complete' + timeHtml + '.',
				STYLE_SUCCESS
			);

			let timeout = instantStudySession ? this.globalConfig.screenTimeoutMs : CLEAROUT_MS_REPEAT_SCAN_CONFIRMED;
			this.clearoutCountdown.start(timeout);
		});
	}

	/**
	 * Helper method:
	 * If a user has an event starting very soon, then show a map view for that event.
	 * Else - just show all their events in the standard view.
	 * @param {EventCollection} eventSchedule
	 * @private
	 */
	_showEvents(eventSchedule, options = {}) {
		let eventStartingSoon = eventSchedule.getNextEventWithinTime(UPCOMING_LESSON_INDICATOR_HURRY_MINS * 60000);
		if (eventStartingSoon) {
			this.page.mapUserActivities(eventSchedule, eventStartingSoon, options);
		} else {
			this.page.setActivities(null, eventSchedule, options);
		}
	}

	student_Registration(json) {

		this.clearoutCountdown.cancel();

		let markClass = this._determineMarkClass(json.mark, EVENT_REGISTER);

		this.page.updatePageContent(rsPage.INFOPANEL_STATE_REGISTRATION, () => {

			this.page.setHeadlineFromPerson(json.student);

			// Start the screen clearout prompt
			this.clearoutCountdown.start(CLEAROUT_MS_REGISTRATION);

			this.page.setIcon(MarkIconMap[markClass]);
			this.page.setPageCss(MarkStyleMap[markClass]);
			this.page.setScanMessage('Successfully marked on register');

			this.page.setRegistration(json.student, json.mark, json.markName, markClass);
			this.page.setRegistrationVisible();
		});
	}

	/** Admin Screen Handlers **/
	
	admin_TestResponse(json) {
		this.page.setIcon(Icon.WAVING_HAND);
		getDelay(2000).then(() => this.page.setIcon());
	}

	admin_Logout(json) {
		// The server will have already processed the logout if this response is delivered. Navigate to homepage.
		location.href = location.origin;
	}

	admin_Errors(json) {
		// Open errors page in a new tab.
		location.href = location.origin + '/errors';
	}

	/** Generic Screen Handlers **/

	person_RoomSessionRegistration(json) {

		this.clearoutCountdown.cancel();

		let markClass = this._determineMarkClass(json.mark, EVENT_BOOKING);

		let msg = 'Successfully marked on session register';
		let style = json.markIsNew ? STYLE_SUCCESS : null;
		let icon  = json.markIsNew ? Icon.THUMBS_UP : Icon.INFORMATION;

		this.page.setIcon(icon);
		this.page.updatePageContent(rsPage.INFOPANEL_STATE_REGISTRATION, () => {
			this.clearoutCountdown.start(CLEAROUT_MS_REGISTRATION);
			this.page.setPageCss(style);
			this.page.setScanMessage(msg, style);
			this.page.setHeadlineFromPerson(json.person);
			this.page.setRegistration(json.person, json.mark, json.markName, markClass);
			this.page.setRegistrationVisible();
		});
	}

	/** Staff Screen Handlers **/
	
	/**
	 * Fetch information for the staff member about this room.
	 * Offer them a room booking if one can be made.
	 * @param json
	 */
	staff_Information(json) {

		this.clearoutCountdown.cancel();

		let msg = null;
		let msgStyle = null;
		let icon = Icon.CALENDAR;
		let staffSchedule = new RSSchedule(json.staffSchedule);
		let nextStaffEvent = staffSchedule.getNextEventWithinTime(UPCOMING_LESSON_INDICATOR_HURRY_MINS);
		let offerBooking = json.offerBooking;

		/** Determine message & page appearance **/

		if (json.roomSessionStatus === ROOM_SESSION_ATTENDANCE_FULL) {
			msg = 'This event is now fully booked.';
			msgStyle = STYLE_WARNING;
			icon = Icon.NO_ENTRY;

		} else if (nextStaffEvent) {
			let hasStarted = EventCollection.eventStatus(nextStaffEvent) === PRESENT;
			let inThisRoom = this.roomSchedule.has(nextStaffEvent);
			let evType = util.RSSchedule.toFriendlyName(nextStaffEvent);

			if (hasStarted) {

				if (inThisRoom) {
					msg = `Your ${evType} is now in progress`;
					msgStyle = STYLE_SUCCESS;
				} else {
					msg = `You have a ${evType} occurring in ${nextStaffEvent.room.reference} right now`;
					msgStyle = STYLE_WARNING;
				}

			// Not started yet.
			} else {
				msg = `You have a ${evType} taking place shortly`;
				if (inThisRoom) {
					msg += msg + ' in ' + evType.room.reference;
					msgStyle = STYLE_WARNING;
				}
			}

			if (offerBooking) {
				msg += '.<br>You may scan again to book this room for ' + json.bookingLengthString + '.';
			}

		} else if (offerBooking) {
			msg = 'Scan again to book this room for ' + json.bookingLengthString + '.';
			msgStyle = STYLE_SUCCESS;
			icon = Icon.DOOR;
		}


		/** Set 'Repeat Scan' properties if booking is possible. **/

		if (offerBooking) {
			// Offer to scan again for bookings:
			this.repeatScan.roomBookingRequest    = 'new';
			this.repeatScan.roomBookingLengthType = 'max_possible';
		}

		this.page.setIcon(icon);
		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {
			this.clearoutCountdown.start(this.globalConfig.screenTimeoutMs);
			this.page.setHeadlineFromPerson(json.staff);
			this.page.setScanMessage(msg, msgStyle); // NB: `msg` may be null here.
			this.page.setActivities(null, staffSchedule);
		});
	}

	/**
	 * The room is free for bookings to be made.
	 * @param json
	 */
	staff_YourBooking(json) {
		this._handleClientOverride(json);
	}

	/**
	 * Screen handler for client screen overrides set on the server.
	 * See flx_RoomScanner_JSON_ClientOverride in PHP.
	 * @param {ClientOverride} json
	 * @private
	 */
	_handleClientOverride(json) {

		if (!json.clientOverride) {
			return false;
		}

		// Parse Icon
		let icon;
		if (json.iconName && Icon.hasOwnProperty(json.iconName)) {
			icon = Icon[json.iconName];
		} else {
			// Default: Memo
			icon = Icon.MEMO;
		}

		// Override client appearance

		this.clearoutCountdown.cancel();

		this.page.setIcon(icon);
		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {
			this.clearoutCountdown.start(this.globalConfig.screenTimeoutMs);

			this.page.setPageCss(json.pageStyle || null);
			this.page.setHeadlineFromPerson(json.person);
			this.page.setScanMessage(json.scanMessage, json.msgStyle || null);
			if (json.personSchedule) {
				let sch = new RSSchedule(json.personSchedule);
				// Do not show the staff schedule if all of their schedule is already visible on screen.
				this.page.setActivities(null, sch);
			}

			if (isObject(json.repeatScan)) {
				let populateRepeatScan = key => {
					// Only repopulate if the property is not NULL
					if (this.repeatScan.hasOwnProperty(key)) {
						this.repeatScan[key] = json.repeatScan[key];
					}
				};

				// Do not re-assign the whole `this.repeatScan` property itself. Instead, manually add
				// each sub-property to ensure the structure won't be corrupted.
				populateRepeatScan('studySessionState');
				populateRepeatScan('studySessionData');
				populateRepeatScan('roomBookingRequest');
				populateRepeatScan('roomBookingLengthType');
				populateRepeatScan('roomBookingId');
			}
		});

		return true;
	}

	/**
	 * A booking has been made. Present the details to the user on screen.
	 * @param {Object} json
	 */
	staff_MakeBooking(json) {

		this.clearoutCountdown.cancel();

		this.page.setIcon(Icon.THUMBS_UP);
		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {

			this.clearoutCountdown.start(CLEAROUT_MS_REPEAT_SCAN_CONFIRMED);

			// Get the booking start/end in 24hr time
			let start = formatDate(parseDate(json.start), false);
			let end   = formatDate(parseDate(json.end), false);
			let scanMsg = '';

			if (json.bookingRequest === 'extension') {
				scanMsg += `Your booking has been extended to ${end}`;
			} else {
				scanMsg += `Your booking has been created from ${start} to ${end} under name "${json.userDisplayName}"`;
			}

			// If there is a next event, then this is a short / limited booking.
			// if (json.nextEvent) {
			// 	scanMsg += ' Note: Another ';
			// }
			
			this.page.setScanMessage(scanMsg);
			this.page.setPageCss(STYLE_SUCCESS);
		});
	}

	/**
	 * Ask the staff if they would like a shorter room booking?
	 * @param json
	 */
	staff_OfferShortBooking(json) {

		this.clearoutCountdown.cancel();

		let scanMsg;
		let icon = Icon.HOURGLASS;

		let nowStr = formatDate(new Date(), false);
		let nextStartStr = formatDate(parseDate(json.nextEvent.start), false);
	
		if (json.nextEvent.type === EVENT_BOOKING) {
			if (json.isBookingAuthor) {
				icon = Icon.DOOR;
				scanMsg =
					`<p>Would you like to book this room from ${nowStr} to ${nextStartStr}?</p>
					<p>(Note: you have another starting later at ${nextStartStr}).</p>`;

			} else {
				scanMsg =
					`<p>Would you like to book this room from ${nowStr} to ${nextStartStr}?</p>
					<p>(Note: ${json.nextEvent.author.displayName} has a booking later starting at ${nextStartStr}).</p>`;
			}
		} else {
			let eventType = util.RSSchedule.toFriendlyName(json.nextEvent);

			scanMsg =
				`<p>There is a ${eventType} starting later at ${nextStartStr}.</p>
				<p>Would you like to book this room while it is available now 
				(from <b>${nowStr} to ${nextStartStr}</b>)?</p>`;
		}

		this.repeatScan.roomBookingRequest = 'new';
		this.repeatScan.roomBookingLengthType = 'limited';

		this.page.setIcon(icon);
		this.page.updatePageContent(rsPage.INFOPANEL_STATE_ACTIVITIES, () => {

			this.clearoutCountdown.start(this.globalConfig.screenTimeoutMs + CLEAROUT_EXTRA_MS_OFFER_SHORT_BOOKING);

			this.page.setScanMessage(scanMsg, STYLE_SUCCESS);
			this.page.setHeadlineFromPerson(json.staff);
			this.page.setActivities(null, new RSSchedule(json.staffSchedule));
		});
	}

	/** Helper methods **/
	
	/**
	 * Checks a mark against the stored set of present, absent and late marks.
	 * Returns an appropriate CSS class for this, or empty string if unrecognised.
	 * @param mark
	 * @return string
	 * @private
	 */
	_determineMarkClass(mark, eventType) {
		let map = {};
		if (eventType === EVENT_REGISTER) {
			map[this.sessionData.presentMark.id] = 'present';
			map[this.sessionData.lateMark.id]    = 'late';
			map[this.sessionData.absentMark.id]  = 'absent';

		} else { // Assume EVENT_BOOKING i.e. a Room booking session.
			map[this.globalConfig.roomSessionPresentMark] = 'present';
			map[this.globalConfig.roomSessionLateMark]    = 'late';
			map[this.globalConfig.roomSessionAbsentMark]  = 'absent';
		}

		return map[mark] || 'unknown';
	}

	/**
	 * Schedule the next Poll to fetch new room scheduling data for this room session.
	 * @param int waitMs Wait this many milliseconds until next poll.
	 * @private
	 */
	_scheduleNextPoll(waitMs) {
		clearTimeout(this.pollTimeout);
		this.pollTimeout = setTimeout(() => {
			this._poll().catch(e => util.logError('POLL', e));
		}, waitMs);
	}

	/**
	 * Poll Server for any room scheduling or API version updates.
	 * If the webpage has an update, then Server should re-load this page.
	 * @return {Promise} On resolve, a boolean flag indicating if any new events were added or not.
	 *                   On reject, a mixed error type. This error should be handled by the caller.
	 * @private
	 */
	async _poll() {

		this.devLog('Initiating poll');

		/** Check if it's safe to poll now **/

		// We deliberately hold off polling to avoid state changes if e.g. students are lining up to mark themselves
		// in for lessons, and multiple scans are happening in succession.

		// Ignore this behaviour in when in devEnvironment.
		if (!this.devEnvironment) {
			let now = new Date();

			const pollTimePaddingMs = 1*60*1000; // 1 min
			const tryAgainMs = 3*60*1000; // 3 mins

			// If the last scan was too recent then hold off the poll.
			// Subtracting Date instances will give you the difference between their epoch time in milliseconds.

			if ((now - this.lastScanDate) <= pollTimePaddingMs) {
				// Cancel this poll and re-schedule
				this._scheduleNextPoll(tryAgainMs);
				return;
			}
		}

		let newEventsAdded = false;

		/** Poll Server **/
		try {
			// XHR the Poll URL
			let resp = await fetch(this.baseUrl+'/PollListener', {method: "GET", credentials: 'include'});

			// Is the HTTP status good? If not, throw a suitable error.
			await this._checkHTTPStatus(resp);
			let json = await this._parseJSON(resp);

			if (json.handler === 'poll') {
				this.devLog('Poll Response:', json);
				this.devLog('Poll successful');
			} else {
				throw 'Poll: Unexpected JSON: ' + JSON.stringify(json);
			}

			// The poll response was correctly loaded.
			newEventsAdded = this._loadPollData(json);
			// Any new events downloaded will be made visible when the next _minuteCron runs, redrawing the page.

		} catch(err) {
			if (err instanceof JsonParseError) {
				util.logError('POLL_JSON_PARSE', 'Poll: Unexpected body: ' + err.body);
				// Process the root exception
				err = err.error;
			}
			throw err;

		// Another poll should ALWAYS be scheduled, even if it failed above.
		} finally {
			this._scheduleNextPoll(this.pollTimeMs);
		}

		return newEventsAdded;
	}

	/**
	 * Check if the response was a valid 200 - OK response. If not, then parse the error response and
	 * reject with a custom error object.
	 *
	 * @async
	 * @param {Response} resp
	 * @return {Response}
	 * @throws {JsonParseError} Non-JSON error response (i.e. a server error that is not a RoomScanner exception).
	 * @throws {ResponseError} If the error is JSON but does not have the keys expected from a RoomScanner exception.
	 * @throws {ApplicationError} If the HTTP error is an RoomScanner exception generated on the server
	 * @private
	 */
	async _checkHTTPStatus(resp) {

		// If the response is fine, then just return this - no further processing required.
		if (resp.ok) {
			return resp;

		} else {
			// Else - an error has occurred. Try to parse it.
			let err;

			// Application errors are JSON parsable
			let json = await this._parseJSON(resp);
			if (json.handler === 'error') {
				err = new ApplicationError(resp, json.data, json.version, json.errorType);
			} else {
				err = new ResponseError(resp, json,  'JSON response is not an error handler.');
			}

			throw err;
		}
	}

	/**
	 * Similar to calling `resp.json()` but throws a JsonParseError instead, which
	 * includes the original HTTP body.
	 * @param {Response} resp
	 * @async
	 * @return {Object} JSON.
	 * @throws {JsonParseError}
	 * @private
	 */
	async _parseJSON(resp) {

		// Clone a backup in case JSON parsing fails.
		let respClone = resp.clone();

		try {
			return await resp.json();
		} catch(jsonErr) {
			throw new JsonParseError(jsonErr, await respClone.text());
		}
	}

	/**
	 * If a poll request was successful, load the poll data and update the page script as necessary.
	 * @param {PollData} json
	 * @return {bool} If new event data was added or not.
	 * @private
	 */
	_loadPollData(json) {

		if (this._checkVersion(json.version)) {
			return false;
		}

		this.globalConfig = this._parseGlobalConfig(json.globalConfig);
		this.sessionData = json.sessionData;

		// Try to load any new Room Schedule information.
		// noinspection JSCheckFunctionSignatures PollData is valid here (it extends RoomScheduleResponse).
		return this._loadRoomScheduleData(json);
		// Any new events downloaded will be made visible when the next _minuteCron runs, redrawing the page.
	}

	_checkVersion(serverVersion) {
		// Should be a natural number greater than 0.
		if (!serverVersion) {
			return false;
		}

		// If this page script is out of date, then refresh the page.
		if (serverVersion !== this.version) {
			// Reload page (true should perform a 'hard' reload which will update static file cache).
			location.reload(true);
			return true;
		}
		return false;
	}

	/**
	 * @param {RoomScheduleResponse} json
	 * @return {bool} If new event data was added or not.
	 * @private
	 */
	_loadRoomScheduleData(json) {

		/** Confirm if new schedule data should be loaded: **/

		let newSchedule = json.roomSchedule;
		let md5 = json.roomScheduleMD5;

		// If the Schedule data object is unset then do not update.
		// Should the schedule data object be empty, then we expect `newSchedule` to be an empty array "[]".
		if (typeof newSchedule === "undefined" || newSchedule === null) {
			return false;
		}

		// If the MD5 matches then there is no change so do not update.
		if (this.roomScheduleMD5 === md5) {
			return false;
		}

		/** There is a change in current room scheduling. Load it in.  **/

		// Update the room scheduling for today, provided there is a change in scheduling
		this.roomSchedule = new RSSchedule(newSchedule || []);
		this.roomScheduleMD5 = md5;

		// NOTE - `this._minuteCron` will correctly update the current Mode in the next
		// minute interval - this is not required here.

		return true;
	}

	/**
	 * Cron loop which runs on the 1st second of each minute.
	 * @private
	 */
	_startMinuteCron() {

		let now = new Date();

		let delayUntilNextMinuteStart = (61 - now.getSeconds()) % 60;

		// Wrapper for the minute cron task. Used to manage errors here.
		let runCron = () => {
			try {
				this._minuteCron();
			} catch (err) {
				util.logError('MINUTE_CRON', err);
			}
		};

		// If it's going to take more than 3 seconds before we reach the 1st second of the next minute,
		// manually run an extra job here to ensure the initial data is correct.
		if (delayUntilNextMinuteStart > 3) {
			runCron();
		}

		setTimeout(() => {
			// `setInterval` does not run the first scheduled job immediately. Run the initial cron here:
			runCron();
			setInterval(() => runCron(), 60 * 1000);
		}, delayUntilNextMinuteStart * 1000);
	}

	/**
	 * Minutely Cron job. Current responsibilities:
	 * - Update page appearance if we're switching from e.g. "Activity" mode to "Registration" mode.
	 * - Re-draw activities if there are any changes mid-day to the current registers.
	 * @private
	 */
	_minuteCron() {
		this.modeFSM.refresh(this);

		// If necessary, update the display of registers for this minute
		// This will not fade the screen or show any animations on HTML update.
		if (!this.modeFSM.infoPanelUpdated) {
			// A re-draw is necessary every minute as to allow the "minutes until
			// next lesson" counter to tick.
			this.page.setActivities(this.roomSchedule, null, null); // NULL must be set to recycle activityOptions here.
		}
	}

	_clearRepeatScanProperties() {
		for (let prop in this.repeatScan) {
			this.repeatScan[prop] = null;
		}
	}

	/**
	 * Log debug messages.
	 * @param args
	 */
	devLog(...args) {
		// Only log debug messages if in dev environment
		if (this.devEnvironment) {
			console.log(...args);
		}
	}

	/** Setters / Getters **/
	
	/**
	 * Number of minutes before a lesson is considered 'starting soon'.
	 * This timing will be used for various indicators / checks within the system.
	 * @return {number}
	 */
	getMaxUpcomingLessonMins() {
		// Allow the UpcomingLessonIndicator visible time to be overridden in case e.g. settings such
		// as "Start Register in Advance" are longer than the default visible time.
		return Math.max(this.globalConfig.startRegistersMinsInAdvance, UPCOMING_LESSON_INDICATOR_MINS);
	}

	/**
	 * @return {GlobalConfig}
	 */
	getGlobalConfig() {
		return this.globalConfig;
	}

	/**
	 * @return {RSSchedule}
	 */
	getRoomSchedule() {
		return this.roomSchedule;
	}

	/**
	 * Get base URL for the Room Scanner, without an affixed "/".
	 * @return {string} e.g. "localhost:8080"
	 */
	getBaseUrl() {
		return this.baseUrl;
	}

	getRoom() {
		return this.sessionData.room;
	}

	/**
	 * Interacts with a map bound to the page.
	 * @return {Object}
	 */
	getMapController() {
		return this.mapController;
	}

	getVocab() {
		return this.vocab;
	}
}

/** Define Custom Object Types: **/

/**
 * @typedef {Object} RepeatScan
 * @property {string}     studySessionState
 * @property {*}          studySessionData
 * @property {string}     roomBookingRequest     Can be NULL, 'new' or 'extension'
 * @property {string}     roomBookingLengthType  Can be NULL, 'limited' or 'max_possible'
 * @property {(int|null)} roomBookingId
 */

/**
 * @typedef {Object} ClientOverride
 * @property {boolean} clientOverride
 * @property {string} handler
 * @property {Object} person
 * @property {string} scanMessage
 * @property {(string|null)} iconName
 * @property {(string|null)} msgStyle
 * @property {(string|null)} pageStyle
 * @property {(Array|null)} personSchedule
 * @property {(RepeatScan|null)} repeatScan
 */

/**
 * @typedef {Object} RoomScheduleResponse
 * @property {Object} roomSchedule
 * @property {string} roomScheduleMD5
 */

/**
 * @typedef {RoomScheduleResponse} PollData
 * @property {object} globalConfig
 * @property {int} version
 */

/**
 * Globals scanner config settings picked up by the server.
 *
 * @typedef {Object} GlobalConfig
 * @property {number} screenTimeoutSecs
 * @property {number} studySessionTimeMins
 * @property {number} startRegistersMinsInAdvance
 * These conversions are very helpful as JS time functions are based in milliseconds:
 * @property {number} screenTimeoutMs
 * @property {number} startRegistersMsInAdvance
 *
 * @property {string} roomSessionPresentMark
 * @property {string} roomSessionLateMark
 * @property {string} roomSessionAbsentMark
 * @property {string} roomSessionLateAfterMins
 */
