import {AbstractValue, Value, VariablePointerValue, ListValue} from './Value';
import {VariableAssignment} from './VariableAssignment';
import {InkObject} from './Object';
import {ListDefinitionsOrigin} from './ListDefinitionsOrigin';
import {StoryException} from './StoryException';
import {JsonSerialisation} from './JsonSerialisation';
import {asOrThrows, asOrNull} from './TypeAssertion';
import {tryGetValueFromMap} from './TryGetResult';
import {throwNullException} from './NullException';
import {CallStack} from './CallStack';

export class VariablesState{
	// The way variableChangedEvent is a bit different than the reference implementation.
	// Originally it uses the C# += operator to add delegates, but in js we need to maintain
	// an actual collection of delegates (ie. callbacks) to register a new one, there is a
	// special ObserveVariableChange method below.
	public variableChangedEventCallbacks: Array<(variableName: string, newValue: InkObject) => void> = [];
	public variableChangedEvent(variableName: string, newValue: InkObject): void {
		for (let callback of this.variableChangedEventCallbacks) {
			callback(variableName, newValue);
		}
	}

	get batchObservingVariableChanges(){
		return this._batchObservingVariableChanges;
	}
	set batchObservingVariableChanges(value: boolean){
		this._batchObservingVariableChanges = value;
		if (value) {
			this._changedVariables = new Set();
		}

		else {
			if (this._changedVariables != null) {
				for (let variableName of this._changedVariables) {
					let currentValue = this._globalVariables.get(variableName);
					if (!currentValue) {
						throwNullException('currentValue');
					} else {
						this.variableChangedEvent(variableName, currentValue);
					}
				}
			}
		}
	}

	get callStack(){
		return this._callStack;
	}

	set callStack(callStack){
		this._callStack = callStack;
	}

	private _batchObservingVariableChanges: boolean = false;

	// the original code uses a magic getter and setter for global variables,
	// allowing things like variableState['varname]. This is not quite possible
	// in js without a Proxy, so it is replaced with this $ function.
	public readonly $ = (variableName: string, value: InkObject) => {
		if (!value) {
			const varContents = this._globalVariables.has(variableName) ?
				this._globalVariables.get(variableName) :
				this._defaultGlobalVariables.get(variableName);

			if (varContents) {
				return (varContents as AbstractValue).valueObject;
			}

			return null;
		} else {
			if (!this._defaultGlobalVariables.get(variableName)) {
				throw new StoryException('Cannot assign to a variable ('+variableName+") that hasn't been declared in the story");
			}

			const val = Value.Create(value);
			if (val == null) {
				if (value == null) {
					throw new StoryException('Cannot pass null to VariableState');
				} else {
					throw new StoryException('Invalid value passed to VariableState: ' + value.toString());
				}
			}

			this.SetGlobal(variableName, val);
		}
	};

	constructor(callStack: CallStack, listDefsOrigin: ListDefinitionsOrigin | null){
		this._globalVariables = new Map();
		this._callStack = callStack;
		this._listDefsOrigin = listDefsOrigin;

		// if es6 proxies are available, use them.
		try{
			// the proxy is used to allow direct manipulation of global variables.
			// It first tries to access the objects own property, and if none is
			// found it delegates the call to the $ method, defined below
			let p = new Proxy(this, {
				get(target: any, name){
					return (name in target) ? target[name] : target.$(name);
				},
				set(target: any, name, value){
					if (name in target) target[name] = value;
					else target.$(name, value);
					return true; // returning a falsy value make the trap fail
				},
			});

			return p;
		}
		catch(e){
			// thr proxy object is not available in this context. we should warn the
			// dev but writing to the console feels a bit intrusive.
			// console.log("ES6 Proxy not available - direct manipulation of global variables can't work, use $() instead.");
		}
	}

	// @ts-ignore
	public CopyFrom(toCopy: VariablesState){
		this._globalVariables = new Map(toCopy._globalVariables);
		this._defaultGlobalVariables = new Map(toCopy._defaultGlobalVariables);

		this.variableChangedEvent = toCopy.variableChangedEvent;
		this.variableChangedEventCallbacks = toCopy.variableChangedEventCallbacks; // inkjs specificity that has to be copied along the rest of the structure

		if (toCopy.batchObservingVariableChanges != this.batchObservingVariableChanges) {

			if (toCopy.batchObservingVariableChanges) {
				this._batchObservingVariableChanges = true;
				if (toCopy._changedVariables === null) { return throwNullException('toCopy._changedVariables'); }
				this._changedVariables = new Set(toCopy._changedVariables);
			} else {
				this._batchObservingVariableChanges = false;
				this._changedVariables = null;
			}
		}
	}

	get jsonToken(){
		return JsonSerialisation.DictionaryRuntimeObjsToJObject(this._globalVariables);
	}
	set jsonToken(value){
		this._globalVariables = JsonSerialisation.JObjectToDictionaryRuntimeObjs(value);
	}

	public TryGetDefaultVariableValue(name: string | null): InkObject | null
	{
		let val = tryGetValueFromMap(this._defaultGlobalVariables, name, null);
		return val.exists ? val.result : null;
	}

	public GlobalVariableExistsWithName(name: string){
		return this._globalVariables.has(name);
	}

	public GetVariableWithName(name: string | null, contextIndex: number = -1): InkObject | null {
		let varValue = this.GetRawVariableWithName(name, contextIndex);

		// var varPointer = varValue as VariablePointerValue;
		let varPointer = asOrNull(varValue, VariablePointerValue);
		if (varPointer !== null) {
			varValue = this.ValueAtVariablePointer(varPointer);
		}

		return varValue;
	}

	public GetRawVariableWithName(name: string | null, contextIndex: number) {
		let varValue: InkObject | null = null;

		if (contextIndex == 0 || contextIndex == -1) {
			// this is a conditional assignment
			let variableValue = tryGetValueFromMap(this._globalVariables, name, null);
			if (variableValue.exists)
				return variableValue.result;

			if (this._listDefsOrigin === null) return throwNullException('VariablesState._listDefsOrigin');
			let listItemValue = this._listDefsOrigin.FindSingleItemListWithName(name);
			if (listItemValue)
				return listItemValue;
		}

		varValue = this._callStack.GetTemporaryVariableWithName(name, contextIndex);

		return varValue;
	}

	public ValueAtVariablePointer(pointer: VariablePointerValue){
		 return this.GetVariableWithName(pointer.variableName, pointer.contextIndex);
	}

	// @ts-ignore
	public Assign(varAss: VariableAssignment, value: InkObject){
		let name = varAss.variableName;
		if (name === null) { return throwNullException('name'); }
		let contextIndex = -1;

		let setGlobal = false;
		if (varAss.isNewDeclaration) {
			setGlobal = varAss.isGlobal;
		} else {
			setGlobal = this._globalVariables.has(name);
		}

		if (varAss.isNewDeclaration) {
			// var varPointer = value as VariablePointerValue;
			let varPointer = asOrNull(value, VariablePointerValue);
			if (varPointer !== null) {
				let fullyResolvedVariablePointer = this.ResolveVariablePointer(varPointer);
				value = fullyResolvedVariablePointer;
			}

		}

		else {

			let existingPointer = null;
			do {
				// existingPointer = GetRawVariableWithName (name, contextIndex) as VariablePointerValue;
				existingPointer = asOrNull(this.GetRawVariableWithName(name, contextIndex), VariablePointerValue);
				if (existingPointer != null) {
					name = existingPointer.variableName;
					contextIndex = existingPointer.contextIndex;
					setGlobal = (contextIndex == 0);
				}
			} while(existingPointer != null);
		}

		if (setGlobal) {
			this.SetGlobal(name, value);
		} else {
			this._callStack.SetTemporaryVariable(name, value, varAss.isNewDeclaration, contextIndex);
		}
	}

	public SnapshotDefaultGlobals(){
		this._defaultGlobalVariables = new Map(this._globalVariables);
	}

	public RetainListOriginsForAssignment(oldValue: InkObject, newValue: InkObject){
		let oldList = asOrThrows(oldValue, ListValue);
		let newList = asOrThrows(newValue, ListValue);

		if (oldList.value && newList.value && newList.value.Count == 0) {
			newList.value.SetInitialOriginNames(oldList.value.originNames);
		}
	}

	// @ts-ignore
	public SetGlobal(variableName: string | null, value: InkObject){
		let oldValue = tryGetValueFromMap(this._globalVariables, variableName, null);

		if (oldValue.exists) {
			ListValue.RetainListOriginsForAssignment(oldValue.result!, value);
		}

		if (variableName === null) { return throwNullException('variableName'); }
		this._globalVariables.set(variableName, value);

		// TODO: Not sure !== is equivalent to !value.Equals(oldValue)
		if (this.variableChangedEvent != null && value !== oldValue.result) {

			if (this.batchObservingVariableChanges) {
				if (this._changedVariables === null) { return throwNullException('this._changedVariables'); }
				this._changedVariables.add(variableName);
			} else {
				this.variableChangedEvent(variableName, value);
			}
		}
	}

	public ResolveVariablePointer(varPointer: VariablePointerValue){
		let contextIndex = varPointer.contextIndex;

		if( contextIndex == -1 )
			contextIndex = this.GetContextIndexOfVariableNamed(varPointer.variableName);

		let valueOfVariablePointedTo = this.GetRawVariableWithName(varPointer.variableName, contextIndex);

		// var doubleRedirectionPointer = valueOfVariablePointedTo as VariablePointerValue;
		let doubleRedirectionPointer = asOrNull(valueOfVariablePointedTo, VariablePointerValue);
		if (doubleRedirectionPointer != null) {
			return doubleRedirectionPointer;
		}

		else {
			return new VariablePointerValue(varPointer.variableName, contextIndex);
		}
	}

	public GetContextIndexOfVariableNamed(varName: string){
		if (this._globalVariables.get(varName))
			return 0;

		return this._callStack.currentElementIndex;
	}

		/**
		 * This function is specific to the js version of ink. It allows to register a
		 * callback that will be called when a variable changes. The original code uses
		 * `state.variableChangedEvent += callback` instead.
		 * @param {function} callback
		 */
	public ObserveVariableChange(callback: (variableName: string, newValue: InkObject) => void){
		this.variableChangedEventCallbacks.push(callback);
	}

	private _globalVariables: Map<string, InkObject>;
	private _defaultGlobalVariables: Map<string, InkObject> = new Map();

	private _callStack: CallStack;
	private _changedVariables: Set<string> | null = new Set();
	private _listDefsOrigin: ListDefinitionsOrigin | null;
}
