import Node from '../core/Node.js';
import { scriptableValue } from './ScriptableValueNode.js';
import { nodeProxy, float } from '../tsl/TSLBase.js';
import { hashArray, hashString } from '../core/NodeUtils.js';

/**
 * A Map-like data structure for managing resources of scriptable nodes.
 *
 * @augments Map
 */
class Resources extends Map {

	get( key, callback = null, ...params ) {

		if ( this.has( key ) ) return super.get( key );

		if ( callback !== null ) {

			const value = callback( ...params );
			this.set( key, value );
			return value;

		}

	}

}

class Parameters {

	constructor( scriptableNode ) {

		this.scriptableNode = scriptableNode;

	}

	get parameters() {

		return this.scriptableNode.parameters;

	}

	get layout() {

		return this.scriptableNode.getLayout();

	}

	getInputLayout( id ) {

		return this.scriptableNode.getInputLayout( id );

	}

	get( name ) {

		const param = this.parameters[ name ];
		const value = param ? param.getValue() : null;

		return value;

	}

}

/**
 * Defines the resources (e.g. namespaces) of scriptable nodes.
 *
 * @type {Resources}
 */
export const ScriptableNodeResources = new Resources();

/**
 * This type of node allows to implement nodes with custom scripts. The script
 * section is represented as an instance of `CodeNode` written with JavaScript.
 * The script itself must adhere to a specific structure.
 *
 * - main(): Executed once by default and every time `node.needsUpdate` is set.
 * - layout: The layout object defines the script's interface (inputs and outputs).
 *
 * ```js
 * ScriptableNodeResources.set( 'TSL', TSL );
 *
 * const scriptableNode = scriptable( js( `
 * 	layout = {
 * 		outputType: 'node',
 * 		elements: [
 * 			{ name: 'source', inputType: 'node' },
 * 		]
 * 	};
 *
 * 	const { mul, oscSine } = TSL;
 *
 * 	function main() {
 * 		const source = parameters.get( 'source' ) || float();
 * 		return mul( source, oscSine() ) );
 * 	}
 *
 * ` ) );
 *
 * scriptableNode.setParameter( 'source', color( 1, 0, 0 ) );
 *
 * const material = new THREE.MeshBasicNodeMaterial();
 * material.colorNode = scriptableNode;
 * ```
 *
 * @augments Node
 */
class ScriptableNode extends Node {

	static get type() {

		return 'ScriptableNode';

	}

	/**
	 * Constructs a new scriptable node.
	 *
	 * @param {?CodeNode} [codeNode=null] - The code node.
	 * @param {Object} [parameters={}] - The parameters definition.
	 */
	constructor( codeNode = null, parameters = {} ) {

		super();

		/**
		 * The code node.
		 *
		 * @type {?CodeNode}
		 * @default null
		 */
		this.codeNode = codeNode;

		/**
		 * The parameters definition.
		 *
		 * @type {Object}
		 * @default {}
		 */
		this.parameters = parameters;

		this._local = new Resources();
		this._output = scriptableValue( null );
		this._outputs = {};
		this._source = this.source;
		this._method = null;
		this._object = null;
		this._value = null;
		this._needsOutputUpdate = true;

		this.onRefresh = this.onRefresh.bind( this );

		/**
		 * This flag can be used for type testing.
		 *
		 * @type {boolean}
		 * @readonly
		 * @default true
		 */
		this.isScriptableNode = true;

	}

	/**
	 * The source code of the scriptable node.
	 *
	 * @type {string}
	 */
	get source() {

		return this.codeNode ? this.codeNode.code : '';

	}

	/**
	 * Sets the reference of a local script variable.
	 *
	 * @param {string} name - The variable name.
	 * @param {Object} value - The reference to set.
	 * @return {Resources} The resource map
	 */
	setLocal( name, value ) {

		return this._local.set( name, value );

	}

	/**
	 * Gets the value of a local script variable.
	 *
	 * @param {string} name - The variable name.
	 * @return {Object} The value.
	 */
	getLocal( name ) {

		return this._local.get( name );

	}

	/**
	 * Event listener for the `refresh` event.
	 */
	onRefresh() {

		this._refresh();

	}

	/**
	 * Returns an input from the layout with the given id/name.
	 *
	 * @param {string} id - The id/name of the input.
	 * @return {Object} The element entry.
	 */
	getInputLayout( id ) {

		for ( const element of this.getLayout() ) {

			if ( element.inputType && ( element.id === id || element.name === id ) ) {

				return element;

			}

		}

	}

	/**
	 * Returns an output from the layout with the given id/name.
	 *
	 * @param {string} id - The id/name of the output.
	 * @return {Object} The element entry.
	 */
	getOutputLayout( id ) {

		for ( const element of this.getLayout() ) {

			if ( element.outputType && ( element.id === id || element.name === id ) ) {

				return element;

			}

		}

	}

	/**
	 * Defines a script output for the given name and value.
	 *
	 * @param {string} name - The name of the output.
	 * @param {Node} value - The node value.
	 * @return {ScriptableNode} A reference to this node.
	 */
	setOutput( name, value ) {

		const outputs = this._outputs;

		if ( outputs[ name ] === undefined ) {

			outputs[ name ] = scriptableValue( value );

		} else {

			outputs[ name ].value = value;

		}

		return this;

	}

	/**
	 * Returns a script output for the given name.
	 *
	 * @param {string} name - The name of the output.
	 * @return {ScriptableValueNode} The node value.
	 */
	getOutput( name ) {

		return this._outputs[ name ];

	}

	/**
	 * Returns a parameter for the given name
	 *
	 * @param {string} name - The name of the parameter.
	 * @return {ScriptableValueNode} The node value.
	 */
	getParameter( name ) {

		return this.parameters[ name ];

	}

	/**
	 * Sets a value for the given parameter name.
	 *
	 * @param {string} name - The parameter name.
	 * @param {any} value - The parameter value.
	 * @return {ScriptableNode} A reference to this node.
	 */
	setParameter( name, value ) {

		const parameters = this.parameters;

		if ( value && value.isScriptableNode ) {

			this.deleteParameter( name );

			parameters[ name ] = value;
			parameters[ name ].getDefaultOutput().events.addEventListener( 'refresh', this.onRefresh );

		} else if ( value && value.isScriptableValueNode ) {

			this.deleteParameter( name );

			parameters[ name ] = value;
			parameters[ name ].events.addEventListener( 'refresh', this.onRefresh );

		} else if ( parameters[ name ] === undefined ) {

			parameters[ name ] = scriptableValue( value );
			parameters[ name ].events.addEventListener( 'refresh', this.onRefresh );

		} else {

			parameters[ name ].value = value;

		}

		return this;

	}

	/**
	 * Returns the value of this node which is the value of
	 * the default output.
	 *
	 * @return {Node} The value.
	 */
	getValue() {

		return this.getDefaultOutput().getValue();

	}

	/**
	 * Deletes a parameter from the script.
	 *
	 * @param {string} name - The parameter to remove.
	 * @return {ScriptableNode} A reference to this node.
	 */
	deleteParameter( name ) {

		let valueNode = this.parameters[ name ];

		if ( valueNode ) {

			if ( valueNode.isScriptableNode ) valueNode = valueNode.getDefaultOutput();

			valueNode.events.removeEventListener( 'refresh', this.onRefresh );

		}

		return this;

	}

	/**
	 * Deletes all parameters from the script.
	 *
	 * @return {ScriptableNode} A reference to this node.
	 */
	clearParameters() {

		for ( const name of Object.keys( this.parameters ) ) {

			this.deleteParameter( name );

		}

		this.needsUpdate = true;

		return this;

	}

	/**
	 * Calls a function from the script.
	 *
	 * @param {string} name - The function name.
	 * @param {...any} params - A list of parameters.
	 * @return {any} The result of the function call.
	 */
	call( name, ...params ) {

		const object = this.getObject();
		const method = object[ name ];

		if ( typeof method === 'function' ) {

			return method( ...params );

		}

	}

	/**
	 * Asynchronously calls a function from the script.
	 *
	 * @param {string} name - The function name.
	 * @param {...any} params - A list of parameters.
	 * @return {Promise<any>} The result of the function call.
	 */
	async callAsync( name, ...params ) {

		const object = this.getObject();
		const method = object[ name ];

		if ( typeof method === 'function' ) {

			return method.constructor.name === 'AsyncFunction' ? await method( ...params ) : method( ...params );

		}

	}

	/**
	 * Overwritten since the node types is inferred from the script's output.
	 *
	 * @param {NodeBuilder} builder - The current node builder
	 * @return {string} The node type.
	 */
	getNodeType( builder ) {

		return this.getDefaultOutputNode().getNodeType( builder );

	}

	/**
	 * Refreshes the script node.
	 *
	 * @param {?string} [output=null] - An optional output.
	 */
	refresh( output = null ) {

		if ( output !== null ) {

			this.getOutput( output ).refresh();

		} else {

			this._refresh();

		}

	}

	/**
	 * Returns an object representation of the script.
	 *
	 * @return {Object} The result object.
	 */
	getObject() {

		if ( this.needsUpdate ) this.dispose();
		if ( this._object !== null ) return this._object;

		//

		const refresh = () => this.refresh();
		const setOutput = ( id, value ) => this.setOutput( id, value );

		const parameters = new Parameters( this );

		const THREE = ScriptableNodeResources.get( 'THREE' );
		const TSL = ScriptableNodeResources.get( 'TSL' );

		const method = this.getMethod();
		const params = [ parameters, this._local, ScriptableNodeResources, refresh, setOutput, THREE, TSL ];

		this._object = method( ...params );

		const layout = this._object.layout;

		if ( layout ) {

			if ( layout.cache === false ) {

				this._local.clear();

			}

			// default output
			this._output.outputType = layout.outputType || null;

			if ( Array.isArray( layout.elements ) ) {

				for ( const element of layout.elements ) {

					const id = element.id || element.name;

					if ( element.inputType ) {

						if ( this.getParameter( id ) === undefined ) this.setParameter( id, null );

						this.getParameter( id ).inputType = element.inputType;

					}

					if ( element.outputType ) {

						if ( this.getOutput( id ) === undefined ) this.setOutput( id, null );

						this.getOutput( id ).outputType = element.outputType;

					}

				}

			}

		}

		return this._object;

	}

	deserialize( data ) {

		super.deserialize( data );

		for ( const name in this.parameters ) {

			let valueNode = this.parameters[ name ];

			if ( valueNode.isScriptableNode ) valueNode = valueNode.getDefaultOutput();

			valueNode.events.addEventListener( 'refresh', this.onRefresh );

		}

	}

	/**
	 * Returns the layout of the script.
	 *
	 * @return {Object} The script's layout.
	 */
	getLayout() {

		return this.getObject().layout;

	}

	/**
	 * Returns default node output of the script.
	 *
	 * @return {Node} The default node output.
	 */
	getDefaultOutputNode() {

		const output = this.getDefaultOutput().value;

		if ( output && output.isNode ) {

			return output;

		}

		return float();

	}

	/**
	 * Returns default output of the script.
	 *
	 * @return {ScriptableValueNode} The default output.
	 */
	getDefaultOutput()	{

		return this._exec()._output;

	}

	/**
	 * Returns a function created from the node's script.
	 *
	 * @return {Function} The function representing the node's code.
	 */
	getMethod() {

		if ( this.needsUpdate ) this.dispose();
		if ( this._method !== null ) return this._method;

		//

		const parametersProps = [ 'parameters', 'local', 'global', 'refresh', 'setOutput', 'THREE', 'TSL' ];
		const interfaceProps = [ 'layout', 'init', 'main', 'dispose' ];

		const properties = interfaceProps.join( ', ' );
		const declarations = 'var ' + properties + '; var output = {};\n';
		const returns = '\nreturn { ...output, ' + properties + ' };';

		const code = declarations + this.codeNode.code + returns;

		//

		this._method = new Function( ...parametersProps, code );

		return this._method;

	}

	/**
	 * Frees all internal resources.
	 */
	dispose() {

		if ( this._method === null ) return;

		if ( this._object && typeof this._object.dispose === 'function' ) {

			this._object.dispose();

		}

		this._method = null;
		this._object = null;
		this._source = null;
		this._value = null;
		this._needsOutputUpdate = true;
		this._output.value = null;
		this._outputs = {};

	}

	setup() {

		return this.getDefaultOutputNode();

	}

	getCacheKey( force ) {

		const values = [ hashString( this.source ), this.getDefaultOutputNode().getCacheKey( force ) ];

		for ( const param in this.parameters ) {

			values.push( this.parameters[ param ].getCacheKey( force ) );

		}

		return hashArray( values );

	}

	set needsUpdate( value ) {

		if ( value === true ) this.dispose();

	}

	get needsUpdate() {

		return this.source !== this._source;

	}

	/**
	 * Executes the `main` function of the script.
	 *
	 * @private
	 * @return {ScriptableNode} A reference to this node.
	 */
	_exec()	{

		if ( this.codeNode === null ) return this;

		if ( this._needsOutputUpdate === true ) {

			this._value = this.call( 'main' );

			this._needsOutputUpdate = false;

		}

		this._output.value = this._value;

		return this;

	}

	/**
	 * Executes the refresh.
	 *
	 * @private
	 */
	_refresh() {

		this.needsUpdate = true;

		this._exec();

		this._output.refresh();

	}

}

export default ScriptableNode;

/**
 * TSL function for creating a scriptable node.
 *
 * @tsl
 * @function
 * @param {CodeNode} [codeNode] - The code node.
 * @param {?Object} [parameters={}] - The parameters definition.
 * @returns {ScriptableNode}
 */
export const scriptable = /*@__PURE__*/ nodeProxy( ScriptableNode ).setParameterLength( 1, 2 );
