# data/MultiStairHandler.js

    /**
     * Multiple Staircase Trial Handler
     *
     * @author Alain Pitiot
     * @version 2021.2.3
     * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd.
     *   (https://opensciencetools.org)
     * @license Distributed under the terms of the MIT License
     */

    import {TrialHandler} from "./TrialHandler.js";
    import {QuestHandler} from "./QuestHandler.js";
    import * as util from "../util/Util.js";
    import seedrandom from "seedrandom";

    /**
     * <p>A handler dealing with multiple staircases, simultaneously.</p>
     *
     * <p>Note that, at the moment, using the MultiStairHandler requires the jsQuest.js
     * library to be loaded as a resource, at the start of the experiment.</p>
     *
     * @extends TrialHandler
     */
    export class MultiStairHandler extends TrialHandler
    {
    	/**
    	 * @memberof module:data
    	 * @param {Object} options - the handler options
    	 * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
    	 * @param {string} options.varName - the name of the variable / intensity / contrast
    	 * 	/ threshold manipulated by the staircases
    	 * @param {MultiStairHandler.StaircaseType} [options.stairType="simple"] - the
    	 * 	handler type
    	 * @param {Array.<Object> | String} [options.conditions= [undefined] ] - if it is a string,
    	 * 	we treat it as the name of a conditions resource
    	 * @param {module:data.TrialHandler.Method} options.method - the trial method
    	 * @param {number} [options.nTrials=50] - maximum number of trials
    	 * @param {number} options.randomSeed - seed for the random number generator
    	 * @param {string} options.name - name of the handler
    	 * @param {boolean} [options.autoLog= false] - whether or not to log
    	 */
    	constructor({
    		psychoJS,
    		varName,
    		stairType,
    		conditions,
    		method = TrialHandler.Method.RANDOM,
    		nTrials = 50,
    		randomSeed,
    		name,
    		autoLog
    	} = {})
    	{
    		super({
    			psychoJS,
    			name,
    			autoLog,
    			seed: randomSeed,
    			// note: multiStairHandler is a sequential TrialHandler, we deal with randomness
    			// in _nextTrial
    			method: TrialHandler.Method.SEQUENTIAL,
    			trialList: Array(nTrials),
    			nReps: 1
    		});

    		// now that we have initialised a sequential TrialHandler, we update method:
    		this._multiMethod = method;
    		this._addAttribute("varName", varName);
    		this._addAttribute("stairType", stairType, MultiStairHandler.StaircaseType.SIMPLE);
    		this._addAttribute("conditions", conditions, [undefined]);
    		this._addAttribute("nTrials", nTrials);

    		if (typeof randomSeed !== "undefined")
    		{
    			this._randomNumberGenerator = seedrandom(randomSeed);
    		}
    		else
    		{
    			this._randomNumberGenerator = seedrandom();
    		}

    		this._prepareStaircases();
    		this._nextTrial();
    	}

    	/**
    	 * Get the current staircase.
    	 *
    	 * @returns {TrialHandler} the current staircase, or undefined if the trial has ended
    	 */
    	get currentStaircase()
    	{
    		return this._currentStaircase;
    	}

    	/**
    	 * Get the current intensity.
    	 *
    	 * @returns {number} the intensity of the current staircase, or undefined if the trial has ended
    	 */
    	get intensity()
    	{
    		if (this._currentStaircase instanceof QuestHandler)
    		{
    			return this._currentStaircase.getQuestValue();
    		}

    		// TODO similar for simple staircase:
    		// if (this._currentStaircase instanceof StaircaseHandler)
    		// {
    		//    return this._currentStaircase.getStairValue();
    		// }

    		return undefined;
    	}

    	/**
    	 * Add a response to the current staircase.
    	 *
    	 * @param{number} response - the response to the trial, must be either 0 (incorrect or
    	 * non-detected) or 1 (correct or detected)
    	 * @param{number | undefined} [value] - optional intensity / contrast / threshold
    	 */
    	addResponse(response, value)
    	{
    		// check that response is either 0 or 1:
    		if (response !== 0 && response !== 1)
    		{
    			throw {
    				origin: "MultiStairHandler.addResponse",
    				context: "when adding a trial response",
    				error: `the response must be either 0 or 1, got: ${JSON.stringify(response)}`
    			};
    		}

    		this._psychoJS.experiment.addData(this._name+'.response', response);

    		if (!this._finished)
    		{
    			// update the current staircase, but do not add the response again:
    			this._currentStaircase.addResponse(response, value, false);

    			// move onto the next trial:
    			this._nextTrial();
    		}
    	}

    	/**
    	 * Validate the conditions.
    	 *
    	 * @protected
    	 */
    	_validateConditions()
    	{
    		try
    		{
    			// conditions must be a non empty array:
    			if (!Array.isArray(this._conditions) || this._conditions.length === 0)
    			{
    				throw "conditions should be a non empty array of objects";
    			}

    			// TODO this is temporary until we have implemented StairHandler:
    			if (this._stairType === MultiStairHandler.StaircaseType.SIMPLE)
    			{
    				throw "'simple' staircases are currently not supported";
    			}

    			for (const condition of this._conditions)
    			{
    				// each condition must be an object:
    				if (typeof condition !== "object")
    				{
    					throw "one of the conditions is not an object";
    				}

    				// each condition must include certain fields, such as startVal and label:
    				if (!("startVal" in condition))
    				{
    					throw "each condition should include a startVal field";
    				}
    				if (!("label" in condition))
    				{
    					throw "each condition should include a label field";
    				}

    				// for QUEST, we also need startValSd:
    				if (this._stairType === MultiStairHandler.StaircaseType.QUEST && !("startValSd" in condition))
    				{
    					throw "QUEST conditions must include a startValSd field";
    				}
    			}
    		}
    		catch (error)
    		{
    			throw {
    				origin: "MultiStairHandler._validateConditions",
    				context: "when validating the conditions",
    				error
    			};
    		}
    	}

    	/**
    	 * Setup the staircases, according to the conditions.
    	 *
    	 * @protected
    	 */
    	_prepareStaircases()
    	{
    		try
    		{
    			this._validateConditions();

    			this._staircases = [];

    			for (const condition of this._conditions)
    			{
    				let handler;

    				// QUEST handler:
    				if (this._stairType === MultiStairHandler.StaircaseType.QUEST)
    				{
    					const args = Object.assign({}, condition);
    					args.psychoJS = this._psychoJS;
    					args.varName = this._varName;
    					// label becomes name:
    					args.name = condition.label;
    					args.autoLog = this._autoLog;
    					if (typeof condition.nTrials === "undefined")
    					{
    						args.nTrials = this._nTrials;
    					}

    					handler = new QuestHandler(args);
    				}

    				// simple StairCase handler:
    				if (this._stairType === MultiStairHandler.StaircaseType.SIMPLE)
    				{
    					// TODO not supported just yet, an exception is raised in _validateConditions
    					continue;
    				}

    				this._staircases.push(handler);
    			}

    			this._currentPass = [];
    			this._currentStaircase = null;
    		}
    		catch (error)
    		{
    			throw {
    				origin: "MultiStairHandler._prepareStaircases",
    				context: "when preparing the staircases",
    				error
    			};
    		}
    	}

    	/**
    	 * Move onto the next trial.
    	 *
    	 * @protected
    	 */
    	_nextTrial()
    	{
    		try
    		{
    			// if the current pass is empty, get a new one:
    			if (this._currentPass.length === 0)
    			{
    				this._currentPass = this._staircases.filter( handler => !handler.finished );

    				if (this._multiMethod === TrialHandler.Method.SEQUENTIAL)
    				{
    					// nothing to do
    				}
    				else if (this._multiMethod === TrialHandler.Method.RANDOM)
    				{
    					this._currentPass = util.shuffle(this._currentPass, this._randomNumberGenerator);
    				}
    				else if (this._multiMethod === TrialHandler.Method.FULL_RANDOM)
    				{
    					if (this._currentPass.length > 0)
    					{
    						// select a handler at random:
    						const index = Math.floor(this._randomNumberGenerator() * this._currentPass.length);
    						const handler = this._currentPass[index];
    						this._currentPass = [handler];
    					}
    				}
    			}

    			// pick the next staircase in the pass:
    			this._currentStaircase = this._currentPass.shift();

    			// test for termination:
    			if (typeof this._currentStaircase === "undefined")
    			{
    				this._finished = true;

    				// update the snapshots associated with the current trial in the trial list:
    				for (let t = 0; t < this._snapshots.length - 1; ++t)
    				{
    					// the current trial is the last defined one:
    					if (typeof this._trialList[t + 1] === "undefined")
    					{
    						this._snapshots[t].finished = true;
    						break;
    					}
    				}

    				return;
    			}

    			// get the value, based on the type of the trial handler:
    			let value = Number.MIN_VALUE;
    			if (this._currentStaircase instanceof QuestHandler)
    			{
    				value = this._currentStaircase.getQuestValue();
    			}
    			// TODO add a test for simple staircase:
    			// if (this._currentStaircase instanceof StaircaseHandler)
    			// {
    			// value = this._currentStaircase.getStairValue();
    			// }

    			this._psychoJS.logger.debug(`selected staircase: ${this._currentStaircase.name}, estimated value for variable ${this._varName}: ${value}`);

    			// update the next undefined trial in the trial list, and the associated snapshot:
    			for (let t = 0; t < this._trialList.length; ++t)
    			{
    				if (typeof this._trialList[t] === "undefined")
    				{
    					this._trialList[t] = {
    						[this._name+"."+this._varName]: value,
    						[this._name+".intensity"]: value
    					};
    					for (const attribute of this._currentStaircase._userAttributes)
    					{
    						// "name" becomes "label" again:
    						if (attribute === "name")
    						{
    							this._trialList[t][this._name+".label"] = this._currentStaircase["_name"];
    						}
    						else if (attribute !== "trialList" && attribute !== "extraInfo")
    						{
    							this._trialList[t][this._name+"."+attribute] = this._currentStaircase["_" + attribute];
    						}
    					}

    					if (typeof this._snapshots[t] !== "undefined")
    					{
    						let fieldName = /*this._name + "." +*/ this._varName;
    						this._snapshots[t][fieldName] = value;
    						this._snapshots[t].trialAttributes.push(fieldName);
    						fieldName = /*this._name + ".*/ "intensity";
    						this._snapshots[t][fieldName] = value;
    						this._snapshots[t].trialAttributes.push(fieldName);

    						for (const attribute of this._currentStaircase._userAttributes)
    						{
    							// "name" becomes "label" again:
    							if (attribute === 'name')
    							{
    								fieldName = /*this._name + ".*/ "label";
    								this._snapshots[t][fieldName] = this._currentStaircase["_name"];
    								this._snapshots[t].trialAttributes.push(fieldName);
    							}
    							else if (attribute !== 'trialList' && attribute !== 'extraInfo')
    							{
    								fieldName = /*this._name+"."+*/ attribute;
    								this._snapshots[t][fieldName] = this._currentStaircase["_" + attribute];
    								this._snapshots[t].trialAttributes.push(fieldName);
    							}
    						}
    					}
    					break;
    				}
    			}
    		}
    		catch (error)
    		{
    			throw {
    				origin: "MultiStairHandler._nextTrial",
    				context: "when moving onto the next trial",
    				error
    			};
    		}
    	}
    }

    /**
     * MultiStairHandler staircase type.
     *
     * @enum {Symbol}
     * @readonly
     */
    MultiStairHandler.StaircaseType = {
    	/**
    	 * Simple staircase handler.
    	 */
    	SIMPLE: Symbol.for("SIMPLE"),

    	/**
    	 * QUEST handler.
    	 */
    	QUEST: Symbol.for("QUEST")
    };

    /**
     * Staircase status.
     *
     * @enum {Symbol}
     * @readonly
     */
    MultiStairHandler.StaircaseStatus = {
    	/**
    	 * The staircase is currently running.
    	 */
    	RUNNING: Symbol.for("RUNNING"),

    	/**
    	 * The staircase is now finished.
    	 */
    	FINISHED: Symbol.for("FINISHED")
    };