Source: loaders/Bundle.js

import { AstParser, ResolvablePromise } from '@uino/base-thing';
import { MathUtils } from '../math/MathUtils';
import { Utils } from '../common/Utils';
import { EventType } from '../const';


const __ = {
	private: Symbol('private'),
}

const State = {
	Ready: 0,
	Loaded: 1,
	Loading: 2,
}

const _varRegExp = /^([^\x00-\xff]|[a-zA-Z_$])([^\x00-\xff]|[a-zA-Z0-9_$])*$/;

const _defaultOptions = {};

// #region Private Functions

// Check variable name
function _checkVariableName(name) {
	if (!_varRegExp.test(name)) {
		return false;
	}

	return true;
}

// Parse static member variables.
function _parseStaticMemberVariables(code, classesNames) {
	let index = 0;
	let className = '';

	// Replace 'static' keyword
	while (true) {
		let classIndex = code.substring(index).indexOf('class ');
		if (classIndex !== -1) {
			className = Utils.scanf(code.substring(classIndex), 'class %s ');

			// Collect class name
			classesNames.push(className);
		}

		// Try to find static member variable location
		let startIndex = code.substring(index).indexOf('static ');
		if (startIndex === -1) {
			break;
		}

		startIndex += index;

		// Get static member content
		let content = '';

		let endIndex = code.substring(startIndex).indexOf(';');
		if (endIndex !== -1) {
			content = code.substring(startIndex, startIndex + endIndex + 1);

			// Keep going
			index = startIndex + endIndex + 1;
		}
		else {
			content = code.substring(startIndex);

			// Keep going
			index = startIndex + content.length;
		}

		// Get the static member variable name
		let strings = Utils.scanf(content, 'static %s=%s');
		let varName = strings[0]._trimBoth(' \t');
		if (varName) {
			// Check variable name
			if (_checkVariableName(varName)) {
				// Get variable value
				let varValue = strings[1]._trimBoth(' \t;');

				// Comment static variable declaration
				code = code._removeAt(startIndex, content.length);
				index -= content.length;

				// Move the static variable here ... (just use the class.variable format)
				code += `\n${className}_${_private.uuid}.${varName} = ${varValue};`;
			}
		}
	}

	return code;
}

// Parse code.
function _parseCode(code) {
	let classNames = [];

	code = _parseStaticMemberVariables(code, classNames);

	return code;
}

// Load code.
function _loadCodeInES6Mode(sourceURL, code) {
	return Utils.loadCodeAsync(code, {
		es6Mode: true,
		sourceURL
	});
}

// #endregion

/**
 * @class Bundle
 * The bundle.
 * @memberof THING
 */
class Bundle {

	/**
	 * The resource bundle object what supports js script by loading with specified loader.
	 * @param {Object} param The initial parameters.
	 */
	constructor(param = {}) {
		this[__.private] = {};
		let _private = this[__.private];

		_private.app = Utils.getCurrentApp();

		// Becareful: uuid would be used to genereate code, so we need to remove all '-' from it
		_private.uuid = MathUtils.generateUUID()._replaceAll('-', '');
		_private.url = Utils.parseValue(param['url'], '');
		_private.info = {};
		_private.options = {};

		_private.classesMappingTable = new Map();

		_private.classNameInfo = {
			main: '',
			others: [],
		};

		_private.state = State.Ready;

		_private.loadingPromise = new ResolvablePromise();
		_private.reloadingPromise = null;

		_private.callbacks = [];

		_private.cache = new Map();

		// Register bundle as global variables, in order to let script can get it
		Utils.regsiterVariable(_private.uuid, this);
	}

	// #region Private

	_resolveURL(relativeWoringPath, url) {
		let _private = this[__.private];

		if (url._startsWith('./')) {
			return relativeWoringPath._appendPath(url);
		}
		else {
			return _private.url._appendPath(url);
		}
	}

	_linkStaticFunctions(className, options) {
		let _private = this[__.private];

		let code = `
THING.Utils.registerClass('${className}', ${className}, THING.Utils.getGlobal());
${className}.prototype.getBundle = function() {
	return THING.Utils.getRegsiteredVariable('${_private.uuid}');
}
`;

		let onLinkStaticFunctions = options['onLinkStaticFunctions'];
		if (onLinkStaticFunctions) {
			let externalCode = onLinkStaticFunctions(this, className);
			if (externalCode) {
				code += externalCode;
			}
		}

		return code;
	}

	_buildClassName(node, options) {
		let _private = this[__.private];

		let className = node.id.name;
		let latestClassName = className + `_${_private.uuid}`;

		return latestClassName;
	}

	_getParentClassName(node) {
		let loc = node.loc;
		if (!loc) {
			return '';
		}

		let startTokenIndex = node.loc.start.token;
		if (!startTokenIndex) {
			return '';
		}

		let tokens = node.loc.tokens;
		let startToken = tokens[startTokenIndex];

		// Search for 'extends' keyword to get the parent class name
		for (let i = startTokenIndex; i >= 0; i--) {
			let token = tokens[i];

			let type = token.type;
			let value = token.value;

			if (type == 'Keyword' && value == 'extends') {
				// Found it
				return startToken.value;
			}
			else if (value == '{' || value == '}') {
				// Miss-match
				return '';
			}
		}

		// Miss-match
		return '';
	}

	_parseMainCode(ast, selfClassesMappingTable, requiredFiles, options) {
		let _private = this[__.private];

		// The current require file when using require() code
		let curRequireFile = null;

		// Get the main class name
		let mainClassName = Utils.parseValue(_private.info.entrance, 'Main');

		// Get require files and replace Main class name
		let that = this;
		AstParser.visit(ast, {
			visitClassDeclaration: function (path) {
				let { node } = path;

				let className = node.id.name;
				let latestClassName = that._buildClassName(node, options);

				if (className == mainClassName) {
					_private.classNameInfo.main = latestClassName;
				}

				node.id.name = latestClassName;

				selfClassesMappingTable.set(className, latestClassName);
				_private.classesMappingTable.set(className, latestClassName);

				this.traverse(path);
			},
			visitIdentifier: function ({ node }) {
				if (node.name == 'require') {
					curRequireFile = {
						filePath: '',
					};

					requiredFiles.push(curRequireFile);

					// Make require as comment
					node.name = '// require';
				}
				else {
					// Try to get 'extends ParentClassName'
					let className = that._getParentClassName(node);
					if (className) {
						let mappedClassName = _private.classesMappingTable.get(className);
						if (mappedClassName) {
							node.name = mappedClassName;
						}
					}
				}

				return false;
			},
			visitLiteral: function ({ node }) {
				if (curRequireFile) {
					curRequireFile.filePath = node.value;
					curRequireFile = null;
				}

				return false;
			},
		});
	}

	_parseRequiredCode(ast, requiredClassesMappingTable, requiredFunctions, options) {
		let that = this;

		let _private = this[__.private];

		AstParser.visit(ast, {
			visitClassDeclaration: function (path) {
				let { node } = path;

				let className = node.id.name;
				let latestClassName = that._buildClassName(node, options);

				_private.classNameInfo.others.push(latestClassName);

				requiredClassesMappingTable.set(className, latestClassName);

				node.id.name = latestClassName;

				this.traverse(path);
			},
			visitFunction: function (path) {
				let { node } = path;

				if (node.id) {
					let latestFunctionName = node.id.name + `_${_private.uuid}`;

					requiredFunctions.set(node.id.name, latestFunctionName);

					node.id.name = latestFunctionName;
				}

				this.traverse(path);
			},
			visitIdentifier: function (path) {
				let { node } = path;

				let lastestFuncName = requiredFunctions.get(node.name);
				if (lastestFuncName) {
					node.name = lastestFuncName;
				}
				else {
					let latestClassName = requiredClassesMappingTable.get(node.name);
					if (latestClassName) {
						node.name = latestClassName;
					}
				}

				this.traverse(path);
			}
		});
	}

	_buildSourceURL(url, options) {
		return url;
	}

	_compileAndLoadCode(url, code, options) {
		// Get the relative working path for resources
		let relativeWoringPath = url._getPath();

		return new Promise((resolve, reject) => {
			// The required files
			let requiredFiles = [];

			// The self classes mapping table
			let selfClassesMappingTable = new Map();

			// Parse the main code
			let mainAst = AstParser.parse(_parseCode(code));
			this._parseMainCode(mainAst, selfClassesMappingTable, requiredFiles, options);

			// The required classes mapping table
			let requiredClassesMappingTable = new Map();

			// Load require files from bundle url
			requiredFiles.waitForEachAsync(async file => {
				let requiredFileUrl = this._resolveURL(relativeWoringPath, file.filePath);
				let requiredFileCode = await Utils.loadTextFileAsync(requiredFileUrl);

				let requiredFunctions = new Map();

				// Parse required file code
				let requiredFileAst = AstParser.parse(_parseCode(requiredFileCode));
				this._parseRequiredCode(requiredFileAst, requiredClassesMappingTable, requiredFunctions, options);

				// Get the final required file code
				let finalRequiredFileCode = AstParser.print(requiredFileAst);

				// Register required classes
				if (requiredClassesMappingTable.size) {
					finalRequiredFileCode += '\n';

					requiredClassesMappingTable.forEach(className => {
						finalRequiredFileCode += this._linkStaticFunctions(className, options);
					});
				}

				let sourceURL = this._buildSourceURL(requiredFileUrl, options);

				return _loadCodeInES6Mode(sourceURL, finalRequiredFileCode);
			}).then(() => {
				// Replace required class names
				AstParser.visit(mainAst, {
					visitIdentifier({ node }) {
						let latestClassName = requiredClassesMappingTable.get(node.name);
						if (latestClassName) {
							node.name = latestClassName;
						}

						return false;
					}
				});

				// Get the final main code
				let finalMainCode = AstParser.print(mainAst);

				// Register classes
				if (selfClassesMappingTable.size) {
					finalMainCode += '\n';

					selfClassesMappingTable.forEach(className => {
						finalMainCode += this._linkStaticFunctions(className, options);
					});
				}

				let sourceURL = this._buildSourceURL(url, options);

				_loadCodeInES6Mode(sourceURL, finalMainCode).then(
					() => {
						resolve();
					},
					(ev) => {
						reject(ev);
					},
				);
			});
		});
	}

	_buildConfigCodeFromOptions(options) {
		let _private = this[__.private];

		let config = 'const bundleOptions = {\r\n';
		config += `\tuuid: '${_private.uuid}',\r\n`;
		config += '};\r\n\r\n';

		return config;
	}

	_loadCode(url, code, options) {
		let _private = this[__.private];

		let sourceURL = this._buildSourceURL(url, options);

		if (_private.cache.get(sourceURL)) {
			return _private.cache.get(sourceURL)
		}

		let promise = new Promise((resolve, reject) => {
			// Set some options as local variable to let script check in runtime
			let config = this._buildConfigCodeFromOptions(options);
			if (config) {
				code = config + code;
			}

			// Load code in ES6 mode(it's compatible of es5 mode and below)
			_loadCodeInES6Mode(sourceURL, code).then(
				() => {
					resolve();
				},
				(ev) => {
					reject(ev);
				},
			);
		});

		_private.cache.set(sourceURL, promise);

		return promise;
	}

	// #endregion

	dispose() {
		let _private = this[__.private];

		let onDispose = _private.callbacks['onDispose'];
		if (onDispose) {
			onDispose.call(this);
		}

		_private.app = null;

		_private.info = {};
		_private.options = {};

		_private.classesMappingTable.clear();

		_private.callbacks = [];

		_private.cache.clear();

		_private.loadingPromise = null;
		_private.reloadingPromise = null;

		Utils.unregsiterVariable(_private.uuid, this);
	}

	/**
	 * Load code in bundle from data.
	 * @param {String} url The code url.
	 * @param {String} code The code string.
	 * @param {Object} options The options.
	 * @returns {Promise<any>}
	 * @private
	 */
	loadCodeFromData(url, code, options = _defaultOptions) {
		let _private = this[__.private];

		// Copy bundle options
		_private.options = Object.assign({}, options);

		// Compile and load script file in runtime
		let autoCompile = Utils.parseValue(options['autoCompile'], false);
		if (autoCompile) {
			return this._compileAndLoadCode(url, code, options);
		}
		else {
			return this._loadCode(url, code, options);
		}
	}

	/**
	 * Load code in bundle from url.
	 * @param {String} url The resource url.
	 * @param {Object} options The options.
	 * @returns {Promise<any>}
	 * @private
	 */
	loadCodeFromUrl(url, options) {
		return new Promise((resolve, reject) => {
			Utils.loadTextFileAsync(url).then(data => {
				this.loadCodeFromData(url, data, options).then(
					() => {
						resolve();
					},
					(ev) => {
						reject(ev);
					}
				);
			});
		});
	}

	/**
	 * Reload bundle resource.
	 * @param {Array<Object>} loaders The bundle loaders.
	 * @param {Object} options The options.
	 * @returns {Promise<any>}
	 * @private
	 */
	reload(loaders, options = {}) {
		let _private = this[__.private];

		let type = _private.info.type;
		let loader = loaders[type];

		// Reload
		if (loader && loader.onReload) {
			_private.reloadingPromise = new Promise((resolve, reject) => {
				loader.onReload(this, options,
					() => {
						let ev = { bundle: this };

						_private.app.trigger(EventType.LoadBundle, ev);

						resolve(ev);
					},
					(ev) => {
						reject(ev);
					}
				);
			});

			return _private.reloadingPromise;
		}
		// Load in normal mode
		else {
			return this.load(loaders, options);
		}
	}

	/**
	 * Load bundle resource.
	 * @param {Array<Object>} loaders The bundle loaders.
	 * @param {Object} options The options.
	 * @returns {Promise<any>}
	 * @private
	 */
	load(loaders, options = {}) {
		let _private = this[__.private];

		// If we had loaded then return pending promise(should be completed state)
		if (_private.state == State.Loaded) {
			return _private.loadingPromise;
		}

		// We are loading now
		_private.state = State.Loading;

		let bundleInfo = options['bundleInfo'];
		if (bundleInfo) {
			this._load(bundleInfo, loaders, options);
		}
		else {
			// Get bundle file url
			let bundleFileName = Utils.parseValue(options['bundleFileName'], 'bundle.json');
			let bundleUrl = _private.url._appendPath(bundleFileName);

			// Load main json file
			Utils.loadJSONFile(bundleUrl,
				// Load
				(ev) => {
					this._load(ev.data, loaders, options);
				},
				// Progress
				() => { },
				// Error
				(ev) => {
					Utils.error(`Load bundle '${bundleUrl}' resource failed, please make sure 'bundle.json' is existing`);
					_private.loadingPromise.reject(ev);
				}
			);
		}

		return _private.loadingPromise;
	}

	_load(data, loaders, options) {
		let _private = this[__.private];
		_private.info = data;
		let type = _private.info.type || 'scene';
		let loader = loaders[type];
		if (loader) {
			let onCompolete = options['onComplete'] || options['complete'];
			options['onComplete'] = options['complete'] = () => {
				onCompolete && onCompolete(this);

				_private.state = State.Loaded;

				// Prevent the disposed bundle
				if (_private.loadingPromise) {
					_private.loadingPromise.resolve(this);

					_private.app.trigger(EventType.LoadBundle, { bundle: this });
				}
			}

			let onProgress = options['onProgress'] || options['progress'];
			options['onProgress'] = options['progress'] = (progress) => {
				onProgress && onProgress(progress);
			}

			let onError = options['onError'] || options['error'];
			options['onError'] = options['error'] = (error) => {
				onError && onError(error);

				_private.loadingPromise.reject(error);
			}

			loader.onLoad(this, options);
		}
		else {
			_private.loadingPromise.reject(`The bundle type '${type}' is unknown, should register bundler loader for it`);
		}
	}

	waitForComplete() {
		let _private = this[__.private];

		if (_private.reloadingPromise) {
			return _private.reloadingPromise;
		}

		return _private.loadingPromise;
	}

	/**
	 * Resolve URL from bundle root path.
	 * @param {String} url The url string.
	 * @returns {String}
	 * @private
	 */
	resolveURL(url) {
		let _private = this[__.private];

		return _private.url._appendURL(url);
	}

	/**
	 * Check whether had been loaded.
	 * @type {Boolean}
	 * @example
	 * if (bundle.loaded) {
	 * 	console.log('bundle has loaded');
	 * }
	 */
	get loaded() {
		let _private = this[__.private];

		return _private.state == State.Loaded;
	}

	/**
	 * Check whether it's loading.
	 * @type {Boolean}
	 * @example
	 * if (bundle.loading) {
	 * 	console.log('bundle is loading');
	 * }
	 */
	get loading() {
		let _private = this[__.private];

		return _private.state == State.Loading;
	}

	/**
	 * Get the unique ID.
	 * @type {String}
	 * @example
	 * console.log(bundle.uuid);
	 */
	get uuid() {
		let _private = this[__.private];

		return _private.uuid;
	}

	/**
	 * Get the resource url.
	 * @type {String}
	 * @example
	 * console.log(bundle.url);
	 */
	get url() {
		let _private = this[__.private];

		return _private.url;
	}

	/**
	 * Get the options.
	 * @type {Object}
	 * @example
	 * console.log(bundle.options);
	 */
	get options() {
		let _private = this[__.private];

		return _private.options;
	}

	/**
	 * Get the main info.
	 * @type {Object}
	 * @example
	 * let info = bundle.info;
	 * let name = bundle.name;
	 * console.log(`Bundle's name is '${name$}'`);
	 */
	get info() {
		let _private = this[__.private];

		return _private.info;
	}

	get classNameInfo() {
		let _private = this[__.private];

		return _private.classNameInfo;
	}

	get callbacks() {
		let _private = this[__.private];

		return _private.callbacks;
	}

	get isBundle() {
		return true;
	}

}

export { Bundle };