plugins/configurator.js

'use strict';

const _isEqual = require('lodash/isEqual');
const _isNil = require('lodash/isNil');
const _isObject = require('lodash/isObject');
const _has = require('lodash/has');
const events = require('events');
const callbackManager = require('async');
const {default: PromiseQueue} = require('p-queue');

let TimingUtility;
let MappingChangeMonitor;
let ModelChangeMonitor;

/**
 * @class
 * @description Plugin to manage setting device configuration values.
 */

class Configurator {
	/**
	 * @constructor
	 * @param {Object} [opts] General settings for Configurator
	 * @param {Adapter} adapter Adapter for which this is a plugin
	 */
	constructor(opts, adapter) {
		this._opts = opts;
		this.adapter = adapter;
		this.emitter = new events.EventEmitter();

		this._shouldReinitializeOnBridgeReady = false;
		if (this._opts &&
			this._opts.shouldReinitializeOnBridgeReady === true) {
			this._shouldReinitializeOnBridgeReady = true;
		}

		TimingUtility = this._adapter.bridge.classes.TimingUtility;
		MappingChangeMonitor = this._adapter.bridge.classes.MappingChangeMonitor;
		ModelChangeMonitor = this._adapter.bridge.classes.ModelChangeMonitor;

		this.mappingChangeMonitor = new MappingChangeMonitor(adapter);
		this.modelChangeMonitor = new ModelChangeMonitor(adapter);
	}

	get shouldReinitializeOnBridgeReady() {
		return this._shouldReinitializeOnBridgeReady;
	}

	/**
	 * @description The adapter instance
	 * @type {Adapter}
	 */
	set adapter(adapter) {
		this._adapter = adapter;
	}

	get adapter() {
		return this._adapter;
	}

	/**
	 * @property {Object} constants
	 * @static
	 */
	static get constants() {
		return {
			errorTypes: {
				RESPONSE_FALSE: new Error('RESPONSE_FALSE'),
				TIMEOUT: new Error('TIMEOUT')
			},
			eventNames: {
				REAL_TIME_CONFIG_RESPONSE: 'received--realTimeResponseHandler',
				CONFIGURE_DEVICE_METHOD_RESPONSE: 'received--configureDevice-method'
			},
			misc: {
				REAL_TIME_RECONFIGURATION_DEBOUNCE_MSEC: 2000,
				DEFAULT_RETRY_LIMIT: 3,
				DEFAULT_CONFIG_COMMAND_TIMEOUT_MSEC: 5000,
				DEFAULT_RETRY_OVERHANG_MSEC: 30
			},
			modelFieldNames: {
				CONFIGURATION_SYNCED_VARIABLE_NAME: 'configurationSynced'
			}
		};
	}

	/**
	 * @description This method should be named as 'init' because Adapter
	 * instance calls the 'init' methods (if they exist) for all Adapter
	 * plugins
	 */
	async init() {
		// Run tear down routine to ensure clean working state
		await this.close();

		this.configParamSyncedStatusesByDevice = new Map();
		this.setupAllDevicesSyncStatusTrackers();

		// FIXME: Mapping and model changes
		this.mappingChangeMonitor.init();
		this.mappingChangeHandler = this.onMappingChanged.bind(this);
		this.mappingChangeMonitor.emitter.on(MappingChangeMonitor.constants.MAPPING_CHANGED_EVENT_NAME, this.mappingChangeHandler);

		this.modelChangeMonitor.init();
		this.modelChangeHandler = this.onModelChanged.bind(this);
		this.modelChangeMonitor.emitter.on(ModelChangeMonitor.constants.MODEL_NODES_CHANGED_EVENT_NAME, this.modelChangeHandler);

		this.asyncTaskQueue = new PromiseQueue({
			concurrency: 1,
			autoStart: true
		});

		this.paramsInProgressOfBeingConfigured = new Map();

		// NOTE: Making the real-time configuration syncing optional
		let enableRealTimeConfigSyncing = false;
		if (!_isNil(this._opts) &&
			!_isNil(this._opts.enableRealTimeConfigSyncing)) {
			enableRealTimeConfigSyncing = this._opts.enableRealTimeConfigSyncing;
		}

		if (enableRealTimeConfigSyncing === true) {
			this.realTimeConfigResponseHandler = this.onConfigResponseReceived.bind(this);
			this.emitter.on(Configurator.constants.eventNames.REAL_TIME_CONFIG_RESPONSE, this.realTimeConfigResponseHandler);
		}
	}

	/**
	 * @description Tear down method for all configurator listeners
	 */
	async close() {
		if (!_isNil(this.paramsInProgressOfBeingConfigured)) {
			this.paramsInProgressOfBeingConfigured.clear();
		}

		if (!_isNil(this.realTimeConfigResponseHandler)) {
			this.emitter.removeListener(Configurator.constants.eventNames.REAL_TIME_CONFIG_RESPONSE, this.realTimeConfigResponseHandler);
			this.realTimeConfigResponseHandler = null;
		}

		this.emitter.removeAllListeners(Configurator.constants.eventNames.CONFIGURE_DEVICE_METHOD_RESPONSE);

		if (!_isNil(this.mappingChangeHandler)) {
			this.mappingChangeMonitor.emitter.removeListener(MappingChangeMonitor.constants.MAPPING_CHANGED_EVENT_NAME, this.mappingChangeHandler);
			this.mappingChangeHandler = null;
		}

		if (!_isNil(this.modelChangeHandler)) {
			this.modelChangeMonitor.emitter.removeListener(ModelChangeMonitor.constants.MODEL_NODES_CHANGED_EVENT_NAME, this.modelChangeHandler);
			this.modelChangeHandler = null;
		}

		this.mappingChangeMonitor.close();
		this.modelChangeMonitor.close();

		if (!_isNil(this.configParamSyncedStatusesByDevice)) {
			this.configParamSyncedStatusesByDevice.clear();
		}

		if (!_isNil(this.asyncTaskQueue)) {
			this.asyncTaskQueue.pause();
			this.asyncTaskQueue.clear();
			return this.asyncTaskQueue.onIdle();
		}
	}

	/**
	 * @description Update configuration if the device mapping
	 * has changed. Assumes this is an existing device whose mapping has been
	 * modified. Also, device should have been configured at least once before.
	 * @param {Object[]} devicesWithChangedMappings
	 */
	onMappingChanged(devicesWithChangedMappings) {
		devicesWithChangedMappings
			.forEach((deviceChangeObj) => {
				try {
					switch (deviceChangeObj.changeType) {
						case MappingChangeMonitor.constants.DEVICE_ADDED:
							this.adapter.k4.logging.info(`Configurator -- Device mapping was added: ${deviceChangeObj.devicePath}`);

							let configureDeviceOnAdditionToBridge = false;
							if (!_isNil(this._opts) &&
								!_isNil(this._opts.configureDeviceOnAdditionToBridge)) {
								configureDeviceOnAdditionToBridge = this._opts.configureDeviceOnAdditionToBridge;
							}

							if (configureDeviceOnAdditionToBridge === true) {
								const currentDevice = this.adapter.findBridgeDeviceByNode(deviceChangeObj.devicePath);
								this.configParamSyncStatusRenewal(currentDevice);
								this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
									.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceChangeObj.devicePath} on mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
							}
							break;
						case MappingChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE: {
							this.adapter.k4.logging.info(`Configurator -- Device mapping was changed: ${deviceChangeObj.devicePath}`);
							const currentDevice = this.adapter.findBridgeDeviceByNode(deviceChangeObj.devicePath);
							this.configParamSyncStatusRenewal(currentDevice);
							this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
								.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceChangeObj.devicePath} on mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
						} break;
						case MappingChangeMonitor.constants.DEVICE_REMOVED:
							this.adapter.k4.logging.info(`Configurator -- Device mapping was removed: ${deviceChangeObj.devicePath}`);
							break;
						default:
							break;
					}
				} catch (e) {
					this.adapter.k4.logging.system(`Unable to prepare and/or issue device re-configuration ${deviceChangeObj.devicePath} on mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
				}
			}, this);
	}

	/**
	 * @description Handler/wrapper for tasks on device nodes
	 * changes. Currently triggers re-configure if the commands, properties,
	 * events, or variable field names change.
	 * @param {Object[]} deviceNodesThatChanged The device to configure
	 */
	onModelChanged(deviceNodesThatChanged) {
		deviceNodesThatChanged
			.forEach((deviceNodeChangeObj) => {
				try {
					switch (deviceNodeChangeObj.changeType) {
						case ModelChangeMonitor.constants.DEVICE_ADDED: {
							this.adapter.k4.logging.info(`Configurator -- Device node was added: ${deviceNodeChangeObj.devicePath}`);
							const currentDevice = this.adapter.findBridgeDeviceByNode(deviceNodeChangeObj.devicePath);
							this.adapter.k4.logging.debug('Attempting to re-configure device on model change...');
							this.configParamSyncStatusRenewal(currentDevice);
							this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
								.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceNodeChangeObj.devicePath} on model change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
						} break;
						case ModelChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE: {
							this.adapter.k4.logging.info(`Configurator -- Device node was changed: ${deviceNodeChangeObj.devicePath}`);
							const currentDevice = this.adapter.findBridgeDeviceByNode(deviceNodeChangeObj.devicePath);

							// NOTE: Making the trigger for re-configuring on device node change
							// stricter, so that only changes in model properties will cause it
							if (!_isNil(deviceNodeChangeObj.fieldsThatChanged.find(modelField => modelField === 'properties'))) {
								this.adapter.k4.logging.debug('Attempting to re-configure device on model change...');
								this.configParamSyncStatusRenewal(currentDevice);
								this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
									.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceNodeChangeObj.devicePath} on model change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
							}
						} break;
						case ModelChangeMonitor.constants.DEVICE_REMOVED:
							this.adapter.k4.logging.info(`Configurator -- Device node was removed: ${deviceNodeChangeObj.devicePath}`);
							break;
						default:
							break;
					}
				} catch (e) {
					this.adapter.k4.logging.system(`Unable to prepare and/or issue device re-configuration ${deviceNodeChangeObj.devicePath} on device nodes change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
				}
			});
	}

	setupAllDevicesSyncStatusTrackers() {
		const devicesOnBridge = this.adapter.getBridgeDevicesList();
		Object.keys(devicesOnBridge)
			.map(devicePath => devicesOnBridge[devicePath])
			.forEach((device) => {
				this.configParamSyncStatusRenewal(device);
			});
	}

	/**
	 * @description This is the entry point for a consumer of
	 * configurator services. It is only run once, because listeners are set
	 * up and are able to self-manage from then onwards.
	 * @param {Device} device The device to configure
	 * @returns {Promise} If configuration succeeds, resolves to true
	 */
	async setupDeviceConfig(device) {
		// Send appropriate configuration commands with arguments
		const retObj = await this.asyncTaskQueue.add(() => this.configureDevice(device));
		this.configParamSyncStatusRenewal(device);
		return retObj.configSucceeded;
	}

	/**
	 * @description Acts as beacon; on a response, adapter will call
	 * this, which will then notify other configurator listeners
	 * @param {Response} response The pre-initialized response object
	 * @param {Device} device The device associated with the response
	 */
	signalReceived(response, device) {
		this.emitter.emit(Configurator.constants.eventNames.REAL_TIME_CONFIG_RESPONSE, response, device);
		this.emitter.emit(Configurator.constants.eventNames.CONFIGURE_DEVICE_METHOD_RESPONSE, response, device);
	}

	/**
	 * @description The real-time handler for configuration
	 * related responses. Will update configuration sync statuses, and attempt
	 * to reconfigure if necessary.
	 * @param {Response} response The pre-initialized response object
	 * @param {Device} device The device associated with the response
	 */
	async onConfigResponseReceived(response, device) {
		try {
			if (_isNil(device) ||
				_isNil(device._mapping) ||
				_isNil(device._mapping.config) ||
				_isNil(device._mapping.config.commands)) {
					this.adapter.k4.logging.warning(`[Configurator] Skipping real-time response check for response ${response.name} because Device ${device.devicePath || 'NIL'} does not have any config mapping commands`);
					return;
			}

			const matchingConfigParam = this.extractConfigCommands(device)
				.find(configParam => this.checkIfConfigParamMatchesResponse(response, configParam), this);

			if (_isNil(matchingConfigParam)) {
				this.adapter.k4.logging.warning(`Did not find a matching configuration parameter for response name ${response.name} of device ${device.devicePath}`);
				return;
			}

			const currentParamSyncStatus = this.verifyConfigResponse(response, device, [matchingConfigParam]).configWasCorrect;
			this.configParamSyncStatusRenewal(device);
			this.updateConfigParamSyncStatus(device, matchingConfigParam, currentParamSyncStatus, response.value);
			const allConfigParamsSyncStatus = this.checkAllConfigParamsSynced(device);

			if (_isNil(allConfigParamsSyncStatus)) {
				this.adapter.k4.logging.warning(`The holistic config parameter sync status was not defined for device ${device.devicePath}`);
				return;
			}

			const allParamsSynced = allConfigParamsSyncStatus[0];
			const aberrantConfigParams = allConfigParamsSyncStatus[1];

			// NOTE: Update the K4Model with the current overall configuration sync status
			this.adapter.k4.model.child(`${device.devicePath}/variables/${Configurator.constants.modelFieldNames.CONFIGURATION_SYNCED_VARIABLE_NAME}`).set(allParamsSynced);

			if (allParamsSynced !== false) {
				return;
			}

			this.adapter.k4.logging.info(`[Configurator] On config response received, found aberrant config params to resync: ${aberrantConfigParams.map(p => JSON.stringify(p, null, '\t'))}`);

			// NOTE: Goal here is to not re-issue configuration for
			// config parameters that are unsynced, but are already
			// being re-configured at this very instant.

			let paramsToReconfigure = aberrantConfigParams;
			if (!_isNil(this.paramsInProgressOfBeingConfigured) && this.paramsInProgressOfBeingConfigured.has(device.devicePath) && !_isNil(this.paramsInProgressOfBeingConfigured.get(device.devicePath))) {
				const paramsInProgressForCurrentDevice = this.paramsInProgressOfBeingConfigured.get(device.devicePath)
					.map(confParam => this.getParedConfigParamFromFull(confParam));

				const whichAberrantParamsAreNotAlreadyBeingConfigured = aberrantConfigParams
					.map(aberrantCfgParam => this.getParedConfigParamFromFull(aberrantCfgParam))
					.filter((paredAberrantParam) => {
						const isAlreadyBeingConfigured = paramsInProgressForCurrentDevice.find(paramInProgress => _isEqual(paredAberrantParam, paramInProgress));
						return !isAlreadyBeingConfigured;
					});

				if (!_isNil(whichAberrantParamsAreNotAlreadyBeingConfigured)) {
					paramsToReconfigure = whichAberrantParamsAreNotAlreadyBeingConfigured;
				}
			}

			if (_isNil(paramsToReconfigure) ||
				paramsToReconfigure.length === 0) {
				this.adapter.k4.logging.info('Config parameters are not all synced, but the unsynced parameters are already in the process of re-configuration');
				return;
			}

			await TimingUtility.setTimeoutPromise(Configurator.constants.misc.REAL_TIME_RECONFIGURATION_DEBOUNCE_MSEC);
			await this.asyncTaskQueue.add(async () => {
				const allParamsSyncedCheck = this.checkAllConfigParamsSynced(device);
				if (!_isNil(allParamsSyncedCheck) &&
					allParamsSyncedCheck[0] === true) {
					return true;
				}

				return this.configureDevice(
					device,
					paramsToReconfigure.map(paredAberrantParam => this.getFullConfigParamFromPared(paredAberrantParam, device), this)
				);
			});
		} catch (err) {
			this.adapter.k4.logging.error(`Error on re-configuring device ${device.devicePath} on config response received`, err instanceof Error ? err.stack.split('\n') : err.toString());
		}
	}

	getFullConfigParamFromPared(paredConfigParam, device) {
		return this.extractConfigCommands(device)
			.find(fullConfigParam => (fullConfigParam.command === paredConfigParam.command) &&
					(fullConfigParam.response === paredConfigParam.response) &&
					_isEqual(fullConfigParam.arguments, paredConfigParam.arguments), this);
	}

	getParedConfigParamFromFull(fullConfigParam) {
		return {
			command: fullConfigParam.command,
			response: fullConfigParam.response,
			arguments: fullConfigParam.arguments
		};
	}

	// NOTE: This does not refresh the config parameters sync statuses, it only
	// reduces the existing statuses to an overall status
	checkAllConfigParamsSynced(device) {
		if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
			this.adapter.k4.logging.system(`Device ${device.devicePath} config parameter statuses tracker does not exist`);
			return null;
		}

		if (this.configParamSyncedStatusesByDevice.get(device.devicePath).length === 0) {
			this.adapter.k4.logging.system(`Device ${device.devicePath} has ZERO config parameters statuses tracked`);
			return null;
		}

		return this.configParamSyncedStatusesByDevice.get(device.devicePath)
			.reduce((syncStatusAccumulator, configParamStatusObj) => {
				const allSynced = syncStatusAccumulator[0];
				const deviantConfigParams = syncStatusAccumulator[1];

				const currentConfigParamStatus = configParamStatusObj.syncStatus;
				const currentConfigParam = configParamStatusObj.configParam;

				if (currentConfigParamStatus === false) {
					deviantConfigParams.push(currentConfigParam);
				}

				return [allSynced && currentConfigParamStatus, deviantConfigParams];
			}, [true, []]);
	}

	getConfigParamSyncStatus(device, configParamToRetrieve) {
		if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
			this.configParamSyncedStatusesByDevice.set(device.devicePath, []);
		}

		const paredDownConfigParam = this.getParedConfigParamFromFull(configParamToRetrieve);

		const targetParamStatusObj = this.configParamSyncedStatusesByDevice.get(device.devicePath)
			.find(paramStatusObj => _isEqual(paramStatusObj.configParam, paredDownConfigParam), this);

		if (_isNil(targetParamStatusObj)) {
			throw new Error(`Sync status object for config param ${JSON.stringify(paredDownConfigParam)} was not found`);
		}

		return targetParamStatusObj;
	}

	configParamSyncStatusRenewal(device) {
		this.pruneConfigParamSyncStatuses(device);
		this.addUntrackedConfigParamSyncStatuses(device);
		this.refreshAllConfigParamSyncStatuses(device);
	}

	pruneConfigParamSyncStatuses(device) {
		if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
			this.adapter.k4.logging.info(`Device ${device.devicePath} config parameter statuses tracker does not exist. Will not prune.`);
			return;
		}

		if (this.configParamSyncedStatusesByDevice.get(device.devicePath).length === 0) {
			this.adapter.k4.logging.info(`Device ${device.devicePath} has ZERO config parameters statuses tracked. Will not prune.`);
			return;
		}

		const syncStatusesForCurrentDevice = this.configParamSyncedStatusesByDevice.get(device.devicePath);
		const syncStatusObjsToKeep = [];

		// NOTE: Preserving the statuses of only the config parameters that are
		// in the mapping. Will discard any sync status objects that do not
		// exist on the mapping.
		if (_isNil(device) ||
			_isNil(device._mapping) ||
			_isNil(device._mapping.config) ||
			_isNil(device._mapping.config.commands)) {
				if (!_isNil(device) && !_isNil(device.model)) {
					this.adapter.k4.logging.warning(`[Configurator] Skipping pruneConfigParamSyncStatuses() because Device ${device.devicePath} mapping does not have any config commands`);
				} else {
					this.adapter.k4.logging.warning(`[Configurator] Skipping pruneConfigParamSyncStatuses() because mapping for NIL Device does not have any config commands`);
				}
				return;
		}

		try {
			this.extractConfigCommands(device)
				.map(mappingConfigParam => this.getParedConfigParamFromFull(mappingConfigParam))
				.forEach((paredMappingCfgParam) => {
					const getSyncStatusObjOrNull = syncStatusesForCurrentDevice.find(syncStatusObj => _isEqual(syncStatusObj.configParam, paredMappingCfgParam));
					if (!_isNil(getSyncStatusObjOrNull)) {
						syncStatusObjsToKeep.push(getSyncStatusObjOrNull);
					}
				});

			this.configParamSyncedStatusesByDevice.set(device.devicePath, syncStatusObjsToKeep);
		} catch (e) {
			this.adapter.k4.logging.info(`Issues encountered when pruning config paramters to sync status tracker for Device ${device.devicePath}. Error is: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
		}
	}

	addUntrackedConfigParamSyncStatuses(device) {
		if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
			this.configParamSyncedStatusesByDevice.set(device.devicePath, []);
			this.adapter.k4.logging.info(`Device ${device.devicePath} config parameter statuses tracker does not exist. Will create the tracker.`);
		}

		const syncStatusesForCurrentDevice = this.configParamSyncedStatusesByDevice.get(device.devicePath);

		if (_isNil(device) ||
			_isNil(device._mapping) ||
			_isNil(device._mapping.config) ||
			_isNil(device._mapping.config.commands)) {
				if (!_isNil(device) && !_isNil(device.model)) {
					this.adapter.k4.logging.warning(`[Configurator] Skipping addUntrackedConfigParamSyncStatuses() because Device ${device.devicePath} mapping does not have any config commands`);
				} else {
					this.adapter.k4.logging.warning(`[Configurator] Skipping addUntrackedConfigParamSyncStatuses() because mapping for NIL Device does not have any config commands`);
				}
				return;
		}

		try {
			this.extractConfigCommands(device)
				.map(mappingConfigParam => this.getParedConfigParamFromFull(mappingConfigParam))
				.forEach((paredMappingCfgParam) => {
				// If current config parameter from mapping does not exist in
				// the sync statuses tracker, then add it and set its sync
				// status to false with an unknown (null) mostRecentValue.
					const getSyncStatusObjOrNull = syncStatusesForCurrentDevice.find(syncStatusObj => _isEqual(syncStatusObj.configParam, paredMappingCfgParam));
					if (_isNil(getSyncStatusObjOrNull)) {
						this.updateConfigParamSyncStatus(device, paredMappingCfgParam, false, null);
					}
				});
		} catch (e) {
			this.adapter.k4.logging.info(`Issues encountered when adding new config paramters to sync status tracker for Device ${device.devicePath}. Error is: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
		}
	}

	refreshAllConfigParamSyncStatuses(device) {
		if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
			this.adapter.k4.logging.info(`Device ${device.devicePath} config parameter statuses tracker does not exist. Will not prune.`);
			return;
		}

		if (this.configParamSyncedStatusesByDevice.get(device.devicePath).length === 0) {
			this.adapter.k4.logging.info(`Device ${device.devicePath} has ZERO config parameters statuses tracked. Will not refresh.`);
			return;
		}

		const syncStatusesForCurrentDevice = this.configParamSyncedStatusesByDevice.get(device.devicePath);

		// NOTE: This assumes that the config param sync statuses are pruned,
		// and one-to-one with the config params that exist in the mapping.
		const refreshedSyncStatusesForDevice = syncStatusesForCurrentDevice
			.map((syncStatusObj) => {
				const fullConfigParam = this.getFullConfigParamFromPared(syncStatusObj.configParam, device);
				const updatedSyncStatusObj = syncStatusObj;
				updatedSyncStatusObj.syncStatus = this.validateConfigParamValue(device, fullConfigParam, syncStatusObj.mostRecentValue).allSyncedForParam;

				return updatedSyncStatusObj;
			});

		this.configParamSyncedStatusesByDevice.set(device.devicePath, refreshedSyncStatusesForDevice);
	}

	updateConfigParamSyncStatus(device, configParamToUpdate, syncStatusToUpdate, latestValueSet) {
		if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
			this.configParamSyncedStatusesByDevice.set(device.devicePath, []);
		}

		const paredDownConfigParam = this.getParedConfigParamFromFull(configParamToUpdate);
		const trackedConfigParamsList = this.configParamSyncedStatusesByDevice.get(device.devicePath);

		const paramStatusObj = {
			configParam: paredDownConfigParam,
			syncStatus: syncStatusToUpdate,
			mostRecentValue: latestValueSet
		};

		// Keep all the config parameter sync trackers that are not the
		// one whose status is being updated
		let updatedTrackedConfigParamsList = trackedConfigParamsList
			.filter(parameterStatusObj => !_isEqual(parameterStatusObj.configParam, paredDownConfigParam), this);

		updatedTrackedConfigParamsList.push(paramStatusObj);

		this.configParamSyncedStatusesByDevice.set(device.devicePath, updatedTrackedConfigParamsList);
	}

	checkIfConfigParamMatchesResponse(configResponse, configParam) {
		if (configParam.response !== configResponse.name) {
			return false;
		}

		if (_isNil(configResponse.arguments)) {
			this.adapter.k4.logging.warning(`Config Response object (${configResponse.name}) does not have required arguments getter`);
			return false;
		}

		// NOTE: Small caveat here. The ordering of key-value pairs in the
		// response arguments object and the configParam arguments object
		// is not necessarily the same. Should not affect the current usage,
		// however.
		const configParamNonValueArguments = {};
		const configResponseNonValueArguments = {};

		Object.keys(configResponse.arguments)
			.filter(argName => (argName !== 'configurationValue' && argName !== 'default'), this)
			.forEach((argName) => {
				configResponseNonValueArguments[argName] = configResponse.arguments[argName];
			});

		Object.keys(configParam.arguments)
			.filter(argName => (argName !== 'configurationValue' && argName !== 'default'), this)
			.forEach((argName) => {
				configParamNonValueArguments[argName] = configParam.arguments[argName];
			});

		const doesConfigFormatMatch = _isEqual(
			Object.keys(configParamNonValueArguments).sort(),
			Object.keys(configResponseNonValueArguments).sort()
		);

		const doConfigResponseArgsMatch = Object.keys(configResponseNonValueArguments)
			.reduce((allMatched, currentArgName) => {
				return allMatched &&
				(configResponseNonValueArguments[currentArgName] === configParamNonValueArguments[currentArgName])
		}, true);

		return (doesConfigFormatMatch && doConfigResponseArgsMatch);
	}

	/**
	 * @description Checks that the specified configuration
	 * parameter value is correct, as defined by the mapping and model settings
	 * @param {Device} device
	 * @param {Object} configParam
	 * @param {any} actualValue
	 * @returns {Object}
	 */
	validateConfigParamValue(device, configParam, actualValue) {
		let allSyncedForParam = null;
		const cmdArgs = configParam.arguments;

		if (_has(cmdArgs, 'configurationValue')) {
			const expectedConfigValue = this.obtainConfigArgVal('configurationValue', device, configParam);

			if (_isNil(expectedConfigValue)) {
				allSyncedForParam = true;
			} else {
				allSyncedForParam = (actualValue === expectedConfigValue);
			}

			return {
				allSyncedForParam: allSyncedForParam,
				configParam: configParam,
				actualConfigValue: actualValue,
				expectedConfigValue: expectedConfigValue
			};
		}

		this.adapter.k4.logging.info(`Skipping config verification because mapping does not specify config value for this device: ${device.devicePath}`);
		return {
			allSyncedForParam: allSyncedForParam,
			configParam: configParam
		};
	}

	/**
	 * @description Helper function to check latest
	 * configuration against the K4Model
	 * @param response The pre-initialized response object
	 * @param device The device associated with the response
	 * @param {Array} [relevantConfigParams] The specific config parameters to
	 * inspect. Will default to all config parameters for this device.
	 * @returns {Object} Contains the Boolean checked status of whether the actual
	 * and expected config values matched, and a list of Objects containing the
	 * raw expected and actual config values.
	 */
	verifyConfigResponse(response, device, relevantConfigParams) {
		const configParams = this.extractConfigCommands(device);

		let relevantParams = relevantConfigParams;
		if (_isNil(relevantParams)) {
			relevantParams = configParams
				.filter(configParam => this.checkIfConfigParamMatchesResponse(response, configParam), this);
		}

		const configParamValidatedStatusList = relevantParams
			.map(configParam => this.validateConfigParamValue(device, configParam, response.value), this);

		const configWasCorrect = configParamValidatedStatusList
			.reduce((accumulator, currentVal) => {
				if (accumulator === null) {
					return currentVal.allSyncedForParam;
				}

				return accumulator && currentVal.allSyncedForParam;
			}, null);

		return {
			configWasCorrect: configWasCorrect,
			configParamValidatedStatusList: configParamValidatedStatusList
		};
	}

	/**
	 * @description Set configuration for this device and checks
	 * whether configuration was set correctly. Bridge must have already been
	 * populated with this device.
	 * @param {Device} device The device to configure
	 * @param {Object[]} [configCommandsList] Specific config commands to call
	 * @returns {Promise} Resolves to true if configuration was set correctly
	 */
	async configureDevice(device, configCommandsList) {
		this.adapter.k4.logging.system(`Attempting to configure device: ${device.devicePath}`);
		let configCommands;

		if (_isNil(configCommandsList) &&
			(_isNil(device._mapping) ||
			_isNil(device._mapping.config) ||
			_isNil(device._mapping.config.commands))) {
			if (device) {
				if (device.devicePath) {
					this.adapter.k4.logging.warning(`[Configurator] Skipping configureDevice because Device ${device.devicePath} was not supplied any config commands`);
				} else {
					this.adapter.k4.logging.warning('[Configurator] Skipping configureDevice because Device NIL was not supplied any config commands');
				}
			}

			return {
				configSucceeded: true,
				successfulConfigCommands: [],
				failedConfigCommands: []
			};
		}

		configCommands = configCommandsList || this.extractConfigCommands(device);
		this.paramsInProgressOfBeingConfigured.set(device.devicePath, configCommands.map(configCmd => this.getParedConfigParamFromFull(configCmd)));

		const succeededConfigCommands = [];
		const failedConfigCommands = [];

		const generateCmdQueueOpts = {
			configCommandTimeoutMsec: Configurator.constants.misc.DEFAULT_CONFIG_COMMAND_TIMEOUT_MSEC,
			retryLimit: Configurator.constants.misc.DEFAULT_RETRY_LIMIT,
			retryOverhangMsec: Configurator.constants.misc.DEFAULT_RETRY_OVERHANG_MSEC
		};

		if (!_isNil(device._mapping) && !_isNil(device._mapping.config)) {
			if (!_isNil(device._mapping.config.commandTimeoutMsec)) {
				generateCmdQueueOpts.configCommandTimeoutMsec = device._mapping.config.commandTimeoutMsec;
			}

			if (!_isNil(device._mapping.config.allowedNumConfigAttempts)) {
				generateCmdQueueOpts.retryLimit = device._mapping.config.allowedNumConfigAttempts;
			}

			if (!_isNil(device._mapping.config.retryOverhangMsec)) {
				generateCmdQueueOpts.retryOverhangMsec = device._mapping.config.commandRetryOverhangMsec;
			}
		}

		const configCommandQueue = await this.generateConfigCommandQueue(device, configCommands, generateCmdQueueOpts);
		let cumulativeConfigSucceeded = true;

		for (let i = 0; (cumulativeConfigSucceeded && i < configCommandQueue.length); i += 1) {
			const currentConfigCmdFunc = configCommandQueue[i];
			const currentConfigCmdRetObj = await currentConfigCmdFunc();
			let currentConfigSuccessBoolean = currentConfigCmdRetObj.configSucceeded;
			switch (currentConfigSuccessBoolean) {
				case true:
					if (!_isNil(currentConfigCmdRetObj.commandToSend)) {
						succeededConfigCommands.push(currentConfigCmdRetObj.commandToSend);
					}
					cumulativeConfigSucceeded = cumulativeConfigSucceeded && true;
					break;
				case false:
					failedConfigCommands.push(currentConfigCmdRetObj.commandToSend);
					cumulativeConfigSucceeded = false;
					break;
				default:
					this.adapter.k4.logging.warning(`[Configurator] Undefined behavior. Config command did not succeed, fail, or timeout deterministically: ${JSON.stringify(currentConfigCmdRetObj)}`);
					throw new Error('Undefined behavior. Config command did not succeed, fail, or timeout deterministically');
			}
		}

		this.paramsInProgressOfBeingConfigured.set(device.devicePath, null);

		// NOTE: Update the K4Model with the latest overall configuration
		// sync status
		try {
			const configurationSyncedModelNode = this.adapter.k4.model.child(`${device.devicePath}/variables/configurationSynced`);
			if (_isNil(configurationSyncedModelNode)) {
				throw new Error('configurationSynced K4Model variable node does not exist for this device');
			}
			configurationSyncedModelNode.set(cumulativeConfigSucceeded);
		} catch (configurationSyncedStatusUpdateErr) {
			this.adapter.k4.logging.warning(`[Configurator] Unable to set the configurationSynced variable on the model for ${device.devicePath}. Value would have been: ${cumulativeConfigSucceeded}`, configurationSyncedStatusUpdateErr);
		}

		return {
			configSucceeded: cumulativeConfigSucceeded,
			successfulConfigCommands: succeededConfigCommands,
			failedConfigCommands: failedConfigCommands
		};
	}

	/**
	 * @description Creates the list of functions that
	 * will be invoked to issue the configuration for a device.
	 * @param {Device} device
	 * @param {Object[]} configCommands
	 * @param {Object} retryOptions
	 * @returns {Array<function(): Promise>} An Array of Functions that return Promises
	 */
	generateConfigCommandQueue(device, configCommands, retryOptions) {
		let configCommandQueue = [];
		let configCommandTimeoutMsec = Configurator.constants.misc.DEFAULT_CONFIG_COMMAND_TIMEOUT_MSEC;
		let retryLimit = Configurator.constants.misc.DEFAULT_RETRY_LIMIT;
		let retryOverhangMsec = Configurator.constants.misc.DEFAULT_RETRY_OVERHANG_MSEC;

		if (!_isNil(retryOptions)) {
			if (!_isNil(retryOptions.configCommandTimeoutMsec)) {
				configCommandTimeoutMsec = retryOptions.configCommandTimeoutMsec;
			}

			if (!_isNil(retryOptions.retryLimit)) {
				retryLimit = retryOptions.retryLimit;
			}

			if (!_isNil(retryOptions.retryOverhangMsec)) {
				retryOverhangMsec = retryOptions.retryOverhangMsec;
			}
		}

		configCommandQueue = configCommands.map(originalConfigParam => () => {
			// Use K4Model properties to override config args
			const cmdArgs = originalConfigParam.arguments;
			Object.keys(cmdArgs)
				.forEach((argName) => {
					cmdArgs[argName] = this.obtainConfigArgVal(argName, device, originalConfigParam);
				}, this);

			const configParamWithOverrides = Object.assign(
				originalConfigParam,
				{
					arguments: cmdArgs
				}
			);

			// NOTE: Do not send configuration command if the configuration
			// value itself is never defined or is set to null, to reduce
			// unnecessary traffic
			if (_isNil(cmdArgs) ||
				_isNil(cmdArgs.configurationValue)) {
				return Promise.resolve({
					configSucceeded: true,
					commandToSend: null
				});
			}

			// NOTE: Determine which command should be issued
			const cmdToSend = configParamWithOverrides.command;

			const attemptToConfigure = (callback) => {
				const configurationTimer = setTimeout(() => {
					callback(Configurator.constants.errorTypes.TIMEOUT);
					this.emitter.removeAllListeners('received--configureDevice-method');
				}, configCommandTimeoutMsec);

				function onReceivedHandler(resp, rcvDevice) {
					if (device.devicePath === rcvDevice.devicePath
						&& resp.name === configParamWithOverrides.response) {
						this.emitter.removeAllListeners('received--configureDevice-method');
						clearTimeout(configurationTimer);

						const matchingConfigParam = this.extractConfigCommands(rcvDevice)
							.find(cfgParam => this.checkIfConfigParamMatchesResponse(resp, cfgParam), this);

						const verificationInfoObj = this.verifyConfigResponse(resp, rcvDevice, [matchingConfigParam]);
						const verified = verificationInfoObj.configWasCorrect;

						this.updateConfigParamSyncStatus(device, matchingConfigParam, verified, resp.value);

						if (verified === true) {
							callback(null, verificationInfoObj);
						} else {
							callback([Configurator.constants.errorTypes.RESPONSE_FALSE, verificationInfoObj]);
						}
					}
				}

				this.emitter.on('received--configureDevice-method', onReceivedHandler.bind(this));

				// Call appropriate configuration command
				this.adapter.k4.logging.info(`Starting configuration attempt with command ${cmdToSend} for device ${device.devicePath}`);
				this.adapter.sendCommand(cmdToSend, device, cmdArgs)
					.catch(e => this.adapter.k4.logging.error(`There was an error on issuing the configuration command ${cmdToSend} for device ${device.devicePath}`, e instanceof Error ? e.stack.split('\n') : e.toString()));
			};

			return new Promise((resolve, reject) => {
				callbackManager.retry(
					{
						times: retryLimit,
						interval: retryOverhangMsec,
						errorFilter: (err) => {
							return (err.message === Configurator.constants.errorTypes.RESPONSE_FALSE.message ||
								(err.message === Configurator.constants.errorTypes.TIMEOUT.message));
						}
					},
					attemptToConfigure,
					(err, successObj) => {
						if (!err) {
							this.adapter.k4.logging.info(`Successful configuration command ${cmdToSend} to device ${device.devicePath}.`, successObj);
							return resolve({
								configSucceeded: successObj.configWasCorrect,
								commandToSend: configParamWithOverrides
							});
						}

						const errObj = Array.isArray(err) ? err[0] : err;
						if (errObj.message === Configurator.constants.errorTypes.TIMEOUT.message) {
							this.adapter.k4.logging.system(`Configuration send-receive timed out for command ${cmdToSend} to device ${device.devicePath}`);
							return resolve({
								configSucceeded: false,
								commandToSend: configParamWithOverrides
							});
						}

						if (errObj.message === Configurator.constants.errorTypes.RESPONSE_FALSE.message) {
							const errFeedback = Array.isArray(err) ? err[1] : null;
							if (!_isNil(errFeedback)) {
								this.adapter.k4.logging.system(`Config command ${cmdToSend} sent, but reported current config value does not match expected, for device ${device.devicePath}. Please refer to the following: `, errFeedback);
							} else {
								this.adapter.k4.logging.system(`Config command ${cmdToSend} sent, but reported current config value does not match expected, for device ${device.devicePath}`);
							}
							return resolve({
								configSucceeded: false,
								commandToSend: configParamWithOverrides
							});
						}

						reject(err);
					}
				);
			});
		}, this);

		return configCommandQueue;
	}

	/**
	 * @description Obtain the set of configuration commands
	 * for a given device
	 * @param {Device} device The device to configure
	 * @throws {Error}
	 * @returns {Array} The set of configuration commands
	 */
	extractConfigCommands(device) {
		if (!device._mapping || !device._mapping.config || !device._mapping.config.commands) {
			throw new Error(`Unable to access mapping for configuration commands for device: ${device.devicePath}`);
		}

		return device._mapping.config.commands;
	}

	/**
	 * @description Determine the value to set for a specific
	 * configuration parameter argument, giving primacy to K4Model property
	 * overrides
	 * @param {String} argName Name of the argument belonging to a config
	 * parameter
	 * @param {Device} device The device to configure
	 * @param {Object} configParam The configuration parameter itself
	 * @returns {any} The appropriate value the argument should be set to
	 */
	obtainConfigArgVal(argName, device, configParam) {
		if (device.isTemporaryDevice) {
			return configParam.arguments[argName];
		}

		const modelOverrideVal = this.obtainConfigPropOverrideForArgVal(argName, device, configParam);
		if (!_isNil(modelOverrideVal)) {
			return modelOverrideVal;
		}

		// Return default argument value if there are no model property
		// overrides
		return configParam.arguments[argName];
	}

	/**
	 * @description Determine, if available, the
	 * K4Model property override value for a configuration argument
	 * overrides
	 * @param {String} argName Name of the argument belonging to a config
	 * parameter
	 * @param {Device} device The device to configure
	 * @param {Object} configParam The configuration parameter itself
	 * @returns {any} The appropriate value the argument should be set to
	 */
	obtainConfigPropOverrideForArgVal(argName, device, configParam) {
		let propOverrides = null;

		if (!_isNil(configParam) && !_isNil(configParam.property)) {
			propOverrides = configParam.property
				.find(prop => prop.argument === argName, this);
		}

		if (!_isNil(propOverrides)) {
			if (!device.model.child(`properties/${propOverrides.name}`)) {
				throw new Error(`K4Model property node to override with does not exist: ${propOverrides.name}`);
			}

			const propOverrideValue = device.model.child(`properties/${propOverrides.name}`).value();

			if (!_isNil(propOverrides) &&
				!_isNil(propOverrides.options) &&
				_isObject(propOverrides.options)) {
				const translatedPropOverrideValue = propOverrides.options[`${propOverrideValue}`];
				if (!_isNil(translatedPropOverrideValue)) {
					return translatedPropOverrideValue;
				}
			}

			return propOverrideValue;
		}
	}

	/**
	 * @description Given a K4Model property that overrides a
	 * config parameter argument, determine the configuration parameter(s)
	 * which make use of that K4Model property
	 * @param {String} propName Name of the property node on K4Model
	 * @param {Device} device The device to configure
	 * @returns {Array}
	 */
	findConfigParamsByProp(propName, device) {
		const configCommands = this.extractConfigCommands(device);
		return configCommands
			.filter(cmd => cmd.property.find(prop => prop.name === propName, this), this);
	}
};

module.exports = Configurator;