Source: SmartModel.js

import { Shark } from "./Shark";
import { Utils } from "./Utils";
import $ from "jquery";
import moment from "moment";
import ModelRenderer from "./renderers/ModelRenderer";

// Dave.ToDo: devo implementare:
// A: un metodo unico tipo importField(field, value[, type]) oppure una roba tipo importDate(field, value), importBoolean(field, value), ecc... magari il primo che si appoggia ai secondi
// Il metodo get per ottenere valori, ma soprattutto per i valori multipli destrutturati.
 class SmartModel {
	/**
	 * @returns {object} The model instance.
	 * @version 1.0.0.3, 2019.03.03
	 *
	 * @constructor
	 * @param {object} data - If passed all the properties of the Model is assigned the value of property with same name in the data object passed.
	 * @param {object} options - Object with options to control the way data object's properties is passed into Model.
	 * @param {object} modelStructure - The structure of the Model to create.
	 */
	constructor (data, options, modelStructure) {
		// this.__shguid = Utils.generateGUID();
		Object.defineProperties(this, {
			__shguid: {
				value: Utils.generateGUID(),
				writable: false
			},
		});

		//Dave.Warn: Anziché fare il doppio giro (prima modelStructure e poi data) dovrei valutare se li ho entrambi e fare prima un match tra di loro. Ha senso?
		if (modelStructure) {
			Utils.matchProperties(this, modelStructure, { createMissingProperties: true, matchEmptyParameters: true });
		}

		//Questo non funziona, perché i super() viene chiamato prima di definire le proprietà nel constructor della classe che estende questa...
		// finché non abbiamo i field non si può fare
		// if (data) {
		// 	const parseOptions = Object.assign({ createMissingProperties: false, matchEmptyParameters: true }, options);
		// 	this.import(data, parseOptions);
		// }

		Object.defineProperties(this, {
			__clean: {
				// Dave.ToDo: Qui potremmo gestire in modo da usare lo spread e far si che funzioni sia con un array che con una lista di parametri
				value: (properties) => {
					if (!properties) {
						this.__dirtyProperties = [];
					}
					else {
						this.__dirtyProperties = this.__dirtyProperties.filter(item => properties.indexOf(item.property) === -1);
					}
			
					this.__isDirty = this.__dirtyProperties.length > 0;
			
					return this.__dirtyProperties;
				},
				writable: false
			},
			__export: {
				value: (options) => {
					const collectedData = {};
			
					if (options && options.hasOwnProperty("fields")) {
						options.fields.forEach(prop => {
							if (this.hasOwnProperty(prop)) {
								collectedData[prop] = this[prop];
							}
						});
					}
					else {
						for (let prop in this) {
							if (typeof this[prop] !== "function" && prop.substr(0, 2) !== "__" && prop.substr(0, 6) !== "jQuery") {
								collectedData[prop] = this[prop];
							}
						}
					}
			
					return collectedData;
				},
				writable: false
			},
			__import: {
				value: (data, options) => {
					const parseOptions = Object.assign({ createMissingProperties: false, matchEmptyParameters: true }, options);
					return Utils.matchProperties(this, data, parseOptions);
				},
				writable: false
			},
			__restore: {
				value: (properties) => {
					if (!properties) {
						this.__dirtyProperties.forEach(function (item) {
							//Dave.Warn: Valutare se usare il set (che triggera tutto a cascata) o invece impostare i valori e basta.
							this.set(item.property, item.originalValue);
						}, this);
			
						this.__dirtyProperties = [];
						this.__isDirty = false;
						// this.__clean();
					}
				},
				writable: false
			},
			__setPropertyDirtiness: {
				value: (propertyName, eventData) => {
					const propertyCheck = this.__dirtyProperties.findIndex(item => item.property === propertyName);
			
					if (propertyCheck === -1) {
						this.__dirtyProperties.push({ property: propertyName, originalValue: eventData.oldValue });
					}
					else if (eventData.newValue === this.__dirtyProperties[propertyCheck].originalValue) {
						this.__dirtyProperties.splice(propertyCheck, 1);
					}
					else if (Array.isArray(eventData.newValue) && Array.isArray(this.__dirtyProperties[propertyCheck].originalValue)) {
						try {
							//Dave: Presumo che a tendere possa essere un'idea quella di usare sempre l'hash per stabilire il cambio di valore
							if (Utils.computeHash(eventData.newValue) === Utils.computeHash(this.__dirtyProperties[propertyCheck].originalValue)) {
								this.__dirtyProperties.splice(propertyCheck, 1);
							}
						}
						catch (err) {
							console.error(err);
						}
					}
				},
				writable: false
			},
			__toJSON: {
				value: (forcePrivateProperties) => {
					if (typeof forcePrivateProperties === "boolean") {
			
					}
					else {
						forcePrivateProperties = false;
					}
			
					var tmp = {};
			
					for (var key in this) {
						if (typeof this[key] !== 'function' && (key.substr(0, 2) !== "__" || key === "__shguid" || forcePrivateProperties) && key.substr(0, 6) !== "jQuery") {
							tmp[key] = this[key];
						}
					}
			
					return tmp;
				},
				writable: false
			},
		});
	
		Object.defineProperties(this, {
			__dirtyProperties: {
				value: [],
				writable: true//Dovrebbe essere false, ma si rompe in giro, deov prima sistemare
			},
		});

		this.__boundProperties = [];
		this.__isDirty = false;
		this.__modelStructure = modelStructure;
		this.__renderer = new ModelRenderer(this);
	}

	addRenderer (renderer) {
		this.__renderer = this.__ = renderer;//Dave: Siamo sicuri di volerlo mantenere? A che serve?
		this.__renderer.setModel(this);
	}

	/**
	 * Clean the status of all dirty properties and set current status as the "valid" status, setting [__isDirty]{@link SmartModel#__isDirty} to false and accepting every properties value. If the properties parameter is passed only some properties will be cleaned and the [__isDirty]{@link SmartModel#__isDirty} status of the Model may remain true.
	 *
	 * @version 1.0.0.1, 2019.03.03
	 *
	 * @param {array} [properties = undefined] - If an array of property path, as string, is passed, only that properties is cleaned. If not all the dirty properties are cleaned than the [__isDirty]{@link SmartModel#__isDirty} property of the Model remains true.
	 */
	clean (properties) {
		return this.__clean(properties);
	};

	/**
	 * Makes this Model dispatch an event.
	 *
	 * @version 1.0.0.1, 2019.03.03
	 *
	 * @param {string} eventName - The name of the event that will be emitted.
	 * @param {object} [eventData = undefined] - The data that must be passed with the event. It could be retrieved by the listener accepting a second parameter after the "event".
	 */
	emit (eventName, eventData) {
		return $(this).trigger(eventName, eventData);
	};

	/**
	 * Export all properties from this Model to a generic JavaScript object that can be used to save the Model's data to localStorage o by server-side API.
	 * <br>The default behaviour of this method is to return an object with every property that name doesn't start with double underscore ("__"); no function is exported.
	 * <br>You can override this method to control wich data is exported and in what form and structure. If you want override this method, you can use the "private" <code>__export</code> method that will give you an object with defautl export result.
	 *
	 * @version 1.0.0.1, 2019.03.03
	 *
	 * @param {object} [options = undefined] - By default this parameter is unused. If you override it you can choose to have something in to change the behaviour of your method. You can pass a string to change the type of export; in a User model case, for example, you cold pass a "login" value to only retrieve the eMail and password property, vs a "register" value that will export all user property.
	 */
	export (options) {
		return this.__export(options);
	};

	exportArrayField (field, format = "JSON") {
		return this.__exportArrayField (field, format,);
	};

	__exportArrayField (field, format = "JSON") {
		const value = Utils.getDeepProperty(this, field);
		let exportValue;

		//Dave: Should use given format.
		switch (format) {
			case "JSON":
				exportValue = JSON.stringify(value);
				break;
		}

		return exportValue;
	};

	exportBooleanField (field, format = "number") {
		return this.__exportBooleanField (field, format);
	};

	__exportBooleanField (field, format = "number") {
		const value = Utils.getDeepProperty(this, field);
		let exportValue;

		switch (format) {
			case "number":
				exportValue = value === true ? 1 : 0;
				break;
			case "string":
				exportValue = value.toString();
				break;
		}

		return exportValue
	};

	exportDateField (field, format = "ISO", useUTC = true) {
		return this.__exportDateField (field, format, useUTC);
	};

	__exportDateField (field, format = "ISO", useUTC = true) {
		const value = Utils.getDeepProperty(this, field);
		let exportValue;

		//Dave: Should use given format and check if alredy is or not a Moment object.
		const parsedValue = moment(value);

		if (parsedValue.isValid()) {
			if (useUTC) {
				exportValue = parsedValue.utc().format("YYYY-MM-DD HH:mm:ss");
			}
			else {
				exportValue = parsedValue.format("YYYY-MM-DD HH:mm:ss");
			}
		}
		else {
			exportValue = "0000-00-00 00:00:00";
		}

		return exportValue;
	};

	exportNumberField (field, format) {
		return this.__exportNumberField (field, format);
	};

	__exportNumberField (field, format) {
		const value = Utils.getDeepProperty(this, field);
		let exportValue;

		//Dave:Format??
		if (typeof value === "number") {
			exportValue = value;
		}
		else if (typeof value === "string") {
			exportValue = parseFloat(value);
		}

		return exportValue;
	};
	
	exportObjectField (field, format = "JSON") {
		return this.__exportObjectField (field, format,);
	};

	__exportObjectField (field, format = "JSON") {
		const value = Utils.getDeepProperty(this, field);
		let exportValue;

		//Dave: Should use given format.
		switch (format) {
			case "JSON":
				exportValue = JSON.stringify(value);
				break;
		}

		return exportValue;
	};


	// exportField (field, value, options) {
	// 	options = Object.assign({
	// 		acceptUndefined: false,
	// 		dataType: "auto",
	// 		defaultValue: undefined,
	// 		useUTC: true,
	// 	}, options);

	// 	if ((value === undefined || typeof value === "undefined" || value === null) && !options.acceptUndefined) {
	// 		return false;
	// 	}

	// 	switch (options.dataType) {
	// 		case "auto":
	// 			break;
	// 		case "boolean"://Nel case di "auto"....?
	// 			return this.exportBooleanField(field, value);
	// 			break;
	// 		case "date"://Nel case di "auto", valuto === "0000-00-00 00:00:00" || typeof === "date" || moment
	// 			// return this.__exportDateField(field, value, options.useUTC);
	// 			return this.exportDateField(field, value, options.useUTC);
	// 			break;
	// 		case "number"://Nel case di "auto"....?
	// 			return this.exportNumberField(field, value, options.defaultValue);
	// 			break;
	// 	}

	// 	return true;
	// };

	/**
	 * Takes in a generic JavaScript object or another Model and assign to the properties of this Model the values of the corresponding properties in given data object.
	 * <br>This method is also used by default creator of a Model when using Shark.getInstance() method and pass in a second parameter with data. So overriding this method you can change the default behaviour of your Model when some data are passed in at instantiation moment, and do any parsing or change you need.
	 * <br>If you override this method you can call the "private" <code>__import</code> method that will do the default import so you can do only the changes or parse you need.
	 *
	 * @version 1.0.0.1, 2019.03.03
	 *
	 * @param {object} [data] - The object with data that should be copied on this Model.
	 * @param {object} [options = undefined] - 
	 */
	import (data, options) {
		return this.__import(data, options);
	};

	importBooleanField (field, value) {
		return this.__importBooleanField (field, value);
	};

	__importBooleanField (field, value) {
		if (value === undefined || typeof value === "undefined" || value === null) {
			return false;
		}

		let newValue;
		let valueParsed = false;

		if (typeof value === "number") {
			newValue = value === 1;
			valueParsed = true;
		}
		else if (typeof value === "string") {
			if (value.toLowerCase() === "false") {
				newValue = false;
			}
			else {
				newValue = parseInt(value) === 1 || value.toLowerCase() === "true";
			}
			valueParsed = true;
		}
		else if (typeof value === "boolean") {
			newValue = value;
			valueParsed = true;
		}

		if (valueParsed) {
			Utils.setDeepProperty(this, field, newValue);
		}

		return valueParsed;
	};

	importDateField (field, value, useUTC = true) {
		return this.__importDateField (field, value, useUTC);
	};

	__importDateField (field, value, useUTC) {
		if (value === undefined || typeof value === "undefined" || value === null) {
			return false;
		}
		let newValue;

		if (useUTC) {
			// this[field] = moment.utc(value).local();
			newValue = moment.utc(value).local();
		}
		else {
			// this[field] = moment(value);
			newValue = moment(value);
		}

		Utils.setDeepProperty(this, field, newValue);

		return newValue.isValid();
	};

	importNumberField (field, value, defaultValue) {
		return this.__importNumberField (field, value, defaultValue);
	};

	__importNumberField (field, value, defaultValue) {
		if (value === undefined || typeof value === "undefined" || value === null) {
			return false;
		}

		let newValue;

		if (typeof value === "number") {
			newValue = value;
		}
		else if (typeof value === "string") {
			newValue = parseFloat(value);
		}

		if (isNaN(newValue)) {
			if (defaultValue !== undefined && typeof defaultValue === "number") {
				Utils.setDeepProperty(this, field, defaultValue);
				return true;
			}
			else {
				return false;
			}
		}
		else {
			Utils.setDeepProperty(this, field, newValue);
			return true;
		}
	};

	importField (field, value, options) {
		options = Object.assign({
			acceptUndefined: false,
			dataType: "auto",
			defaultValue: undefined,
			useUTC: true,
		}, options);

		if ((value === undefined || typeof value === "undefined" || value === null) && !options.acceptUndefined) {
			return false;
		}

		switch (options.dataType) {
			case "auto":
				break;
			case "boolean"://Nel case di "auto"....?
				return this.importBooleanField(field, value);
				break;
			case "date"://Nel case di "auto", valuto === "0000-00-00 00:00:00" || typeof === "date" || moment
				// return this.__importDateField(field, value, options.useUTC);
				return this.importDateField(field, value, options.useUTC);
				break;
			case "number"://Nel case di "auto"....?
				return this.importNumberField(field, value, options.defaultValue);
				break;
		}

		return true;
	};

	/**
	 * Used to find if some properties of this Model is changed since the first assignment (usually the one assigned during instantiation) or the value they have at the moment you call the [clean]{@link SmartModel#clean} method.
	 *
	 * @returns {boolean}
	 * @version 1.0.0.1, 2019.03.03
	 */
	isDirty (properties = null, mode = "any") {
		if (properties == null) {
			return this.__isDirty;
		}
		else {
			if (!Array.isArray(properties)) {
				properties = [properties];
			}

			if (mode === "any") {
				return this.__dirtyProperties.filter(item => properties.includes(item.property)).length > 0;
			}
			else if (mode === "all") {
				return this.__dirtyProperties.filter(item => properties.includes(item.property)).length === properties.length;
			}
		}
	};

	off (eventName, eventHandler) {
		return $(this).off(eventName, eventHandler);
	}

	on (eventName, eventHandler) {
		return $(this).on(eventName, eventHandler);
	}

	/**
	 * Restore all original values for dirty properties and set [__isDirty]{@link SmartModel#__isDirty} to false. If the proprerty parameter is passed some property may not be restored.
	 *
	 * @version 1.0.0.1, 2019.03.03
	 *
	 * @param {array} [properties = undefined] - If an array of property path, as string, is passed, only that properties is restored. If not all the dirty properties are restored than the [__isDirty]{@link SmartModel#__isDirty} property of the Model remains true.
	 */
	restore (properties) {
		this.__restore(properties);
	};

	//Dave.Info: eventTargetNode è opzionale e serve per gestire l'informazione interna
	__singleSet (name, value, eventTargetNode) {
		let actualValue = Utils.getDeepProperty(this, name);
		const actualValueHash = Utils.computeHash(actualValue);
		let hashIsDifferent = true;//Serve per iniziare a fare un check sull'hash per valori tipo gli array che hanno contenuto uguale ma non sono ===

		//Dave.Warn: Should find a way to find if the property exist and warn the developer,
		// if (actualValue === undefined) {
		// 	Shark.trace(`Warning: you are triyng to set the property '${name}' `, Shark.TRACE_WARN);
		// }

		switch (typeof actualValue) {
			case "number":
				Shark.trace(`%cChiamato il metodo set e la propietà è di tipo ${typeof actualValue}`, "font-size: 16px; color: red;", Shark.TRACE_LOG);
				value = parseFloat(value);
				break;
			case "object":
				if (Array.isArray(actualValue) && !Array.isArray(value)) {
					let actualValueClone = Array.from(actualValue);

					let pos = actualValueClone.indexOf(value);

					if (pos > -1) {
						actualValueClone.splice(pos, 1);
					}
					else {
						//Dave: Questa roba non è bella, penso si possa migliorare... ma non so come, forse dovrei controllare i valori presenti e se ci sono numeri allora faccio la conversione.
						//If array contains numbers must check a number-converted value.
						let pos = actualValueClone.indexOf(parseFloat(value));

						if (pos > -1) {
							actualValueClone.splice(pos, 1);
						}
						else {
							actualValueClone.push(value);
						}
					}
					// value = Array.from(actualValueClone);
					value = actualValueClone;
					Shark.trace(`%cChiamato il metodo set e la propietà è di tipo ${typeof actualValueClone} ed è un array`, "font-size: 16px; color: red;", Shark.TRACE_LOG);
				}
				// else if (Array.isArray(actualValue) && Array.isArray(value)) {
				// 	Shark.trace(`%cChiamato il metodo set e la propietà è di tipo ${typeof actualValue} ed è un array a cui viene passato un array`, "font-size: 16px; color: red;", Shark.TRACE_LOG);
				// }
				else if (Shark.activeFeatures.moment && (actualValue._isAMomentObject || actualValue instanceof moment)) {
					Shark.trace(`%cChiamato il metodo set e la propietà è di tipo ${typeof actualValue} ed è un moment`, "font-size: 16px; color: red;", Shark.TRACE_LOG);
					value = moment(value);
				}
				else {
					Shark.trace(`%cChiamato il metodo set e la propietà è di tipo ${typeof actualValue}`, "font-size: 16px; color: red;", Shark.TRACE_LOG);
				}

				if (Array.isArray(actualValue) && Array.isArray(value)) {
					try {
						hashIsDifferent = actualValueHash !== Utils.computeHash(value);
					}
					catch (err) {
						console.error(err);
					}
				}
				break;
			case "string":
				Shark.trace(`%cChiamato il metodo set e la propietà è di tipo ${typeof actualValue}`, "font-size: 16px; color: red;", Shark.TRACE_LOG);
				if (value !== undefined){
					if (typeof value.toString === "function") {
						value = value.toString();
					}
				}
				else {
					value = "";
				}
				break;
		}

		var eventData = { modelName: this.__modelName, newValue: value, oldValue: Utils.getDeepProperty(this, name), property: name };
		Utils.setDeepProperty(this, name, value);

		//Il Change dovrebbe forse essere dispatchato anche se il valore non cambia? O almeno prevediamo un "dataSetted"?
		if (eventData.oldValue !== eventData.newValue && hashIsDifferent) {
			//Dave.ToDo: Dovrei gestire l'autolistener anche per gli eventi dei model
			this.__setPropertyDirtiness(name, eventData);
			this.__isDirty = this.__dirtyProperties.length > 0;

			if (Shark.settings.enableSmartModelSetUpdatesView) {
				document.querySelectorAll(`[data-sh-prop="${name}"]`).forEach(item => {
					if (item !== eventTargetNode) {
						const containerGuidNode = item.closest("[data-shguid]");
						let containerGuid = containerGuidNode ? containerGuidNode.dataset.shguid : "";
						// let containerGuid = item.closest("[data-__shguid]").dataset.__shguid;

						//Dave: Qui dovrei anche controllare se item è diverso da l'oggetto che ha triggerato il cambio, così in quel caso non lo aggiorno.
						if (containerGuid === "" || containerGuid === this.__shguid) {
							let updateValue = false;
							let updateInnerHTML = false;

							switch (item.nodeName.toUpperCase()) {
								case "INPUT":
									switch (item.type.toUpperCase()) {
										case "CHECKBOX":
										case "RADIO":
											//Devo gestire il checbox e i radio in base al data-sh-valuefromproperty
											break;
										default:
											updateValue = true;
											break;
									}
									break;
								case "SELECT":
								case "TEXTAREA":
									updateValue = true;
									break;
								default:
									updateInnerHTML = true;
									break;
							}

							if (updateValue) {
								item.value = value;
							}
							else if (updateInnerHTML) {
								item.innerHTML = value;
							}
							else {
								Shark.trace("Non aggiorno il valore", {eventData}, Shark.TRACE_LOG);
							}
						}
					}
				});
			}

			//Il Change dovrebbe forse essere dispatchato anche se il valore non cambia? O almeno prevediamo un "dataSetted"?

			//Dave.Warn: Qui bisogna assolutamente sistemare questa cosa: se la proprietà che è cambiata si chiamasse "data", verrebbe chiamato due volte "onDataChanged"...
			//Dave.Warn: using jQuery.trigger() should not require this check because jQuery itself have same behavior when emitting the event
			if (typeof this.onDataChanged === "function") {
				this.onDataChanged.call(this, eventData);
			}
			const titleCaseProperty = Utils.toTitleCase(name, false).replace(/\./g, "_");

			//Dave.Warn: using jQuery.trigger() should not require this check because jQuery itself have same behavior when emitting the event
			if (titleCaseProperty !== "Data" && titleCaseProperty !== name) {
				if (typeof this["on" + titleCaseProperty + "Changed"] === "function") {
					this["on" + titleCaseProperty + "Changed"].call(this, eventData);
				}
			}

			this.emit("dataChanged", eventData);
			if (name !== "data") {
				this.emit(name + "Changed", eventData);
			}
		}
	}

	/**
	* One of the main feature of the SmartModel, responsible for event dispatching on properties value changes and for maganage the dirty status of the model.
    * There is a lot of automation behind this method: auto-parsing is done, based on the type of property on the model and, if the passed value is different from the actual value, two events are dispatched (dataChanged and propertyNameChanged) and the original value is stored as a dirty property. Also the [__isDirty]{@link SmartModel#__isDirty} property of the Model is set to true.
	* <br><br><strong>Auto Parse</strong>
    * <br>If the property that is been setted has a strong type (string, number, array) than the passed value is casted or converted to that type. For array type a different parse is done: if passed value is an array, then the value is used as is. But if the passed value is of primitive type, then a toggle check is made and the value is added or removed from the property array.
	* <br>On value changes, also, a check is made for the presence of two function on the Model: "onDataChanged" and "onPropertyNameChanged". If one or both of these functions exists then they are called and passed the change data.
	*
	* @version 1.0.0.1, 2019.03.03
	*
	* @param {string} name - The name of the property that will be assigned the value.
	* @param {any} [value = undefined] - The value that will be assigned.
    * @fires SmartModel#onDataChanged
	*/
	set (name, value, eventTargetNode) {
		//Dave.ToDo: come nello store di Shark, dovrei valutare se e quante cose sono cambiate e triggerare il render se l'oggetto è "sullo schermo"
		if (Array.isArray(name)) {
			name.forEach(item => {
				this.__singleSet(item.name, item.value, eventTargetNode);
			})
		}
		else if (typeof name === "object") {
			for (let prop in name) {
				this.__singleSet(prop, name[prop], eventTargetNode);
			}
		}
		else if (typeof name === "string") {
			this.__singleSet(name, value, eventTargetNode);
		}
		else {
			console.error("Shark.set: This kind of data could not be used.");
		}

		if (this.__renderer && typeof this.__renderer.parseModelValues === "function") {
			this.__renderer.parseModelValues({name, value});
		}
	};

	toJSON (forcePrivateProperties) {
		return this.__toJSON(forcePrivateProperties);
	};

	toString (forcePrivateProperties) {
		return JSON.stringify(this.toJSON(forcePrivateProperties));
	};
};

/**
 * onDataChanged event is emitted every time a Model's property is setted a value different from the actual value.
 *
 * @event SmartModel#onDataChanged
 * @type {object}
 * @property {object} eventData - An object containing information about the change of a property value.
 * @property {string} eventData.modelName - The name of the Model that dispatched the event. (as is stored in the __modelName property)
 * @property {object} eventData.newValue - The new value that the property has been assigned.
 * @property {object} eventData.oldValue - The previous value that the property had just before this event was emitted.
 * @property {string} eventData.property - The path-name of the property that has changed.
 */

export default SmartModel;