/**
 * The outline of a ignite property which is a managed property that
 * can be used to invoke call back functions when the value of the property changes.
 * @ignore
 */
class IgniteProperty {
    constructor(val, options = null) {
        this.onChangeCallbacks = [];
        this.onPushCallbacks = [];
        this.onPopCallbacks = [];
        this.onShiftCallbacks = [];
        this.onUnshiftCallbacks = [];
        this.onSpliceCallbacks = [];

        this.arrayCallbacks = [];
        this.reflected = [];
        this._value = val;
        this.ignoreValueChange = false;

        //If we were passed options, add any callbacks needed.
        if (options) {
            if (options.onChange) {
                this.attachOnChange(options.onChange);
            }

            if (options.onPush) {
                this.attachOnPush(options.onPush);
            }

            if (options.onPop) {
                this.attachOnPop(options.onPop);
            }

            if (options.onShift) {
                this.attachOnShift(options.onShift);
            }

            if (options.onUnshift) {
                this.attachOnUnshift(options.onUnshift);
            }

            if (options.onSplice) {
                this.attachOnSplice(options.onSplice);
            }
        }
        
        //Attempt to patch the value if it's a list.
        this.patchArray();
    }

    get value() {
        return this._value;
    }

    set value(val) {
        this.setValue(val, true);
    }

    setValue(val, reflect) {
        //If the ignore value change flag is set exit.
        if (this.ignoreValueChange) {
            return;
        }

        //If the current value is the same as the old value don't do anything.
        if (this._value === val) {
            return;
        }

        //Get the old value
        var old = this._value;

        //Based on the old value, see if we need to convert the new value to match the original type.
        if (typeof old === typeof true) {
            val = val != null && val != undefined ? val.toString().toLowerCase().trim() : val;
            val = val == "true" || val == "1" || val == "yes" || val == "t" || val == "y";
        } else if (typeof old === typeof 0) {
            val = Number(val != null && val != undefined ? val.toString().trim() : "0");
            val = isNaN(val) ? 0 : val;
        } else if (typeof old === typeof "") {
            val = val != null && val != undefined ? val.toString() : null;
        }

        //Set the new value
        this._value = val;

        //Attempt to patch the value if it's an array.
        this.patchArray();

        //Invoke any callbacks letting them know the value changed.
        this.invokeOnChange(old, val, reflect);
    }

    patchArray() {
        //Disconnect any existing array callbacks
        if (this.arrayCallbacks.length > 0) {
            this.arrayCallbacks.forEach(callback => callback.disconnect());
            this.arrayCallbacks = [];
        }

        //If our value is an array and it hasn't been patched, then patch it.
        if (Array.isArray(this._value) && this._value.onPushCallbacks == undefined) {
            this._value.onPushCallbacks = [];
            this._value.onPopCallbacks = [];
            this._value.onShiftCallbacks = [];
            this._value.onUnshiftCallbacks = [];
            this._value.onSpliceCallbacks = [];

            this._value.push = function () {
                var len = Array.prototype.push.apply(this, arguments);
                this.onPushCallbacks.forEach(callback => callback.invoke(Array.from(arguments)));
                return len;
            };

            this._value.pop = function () {
                var len = Array.prototype.pop.apply(this, arguments);
                this.onPopCallbacks.forEach(callback => callback.invoke());
                return len;
            }

            this._value.unshift = function () {
                var len = Array.prototype.unshift.apply(this, arguments);
                this.onUnshiftCallbacks.forEach(callback => callback.invoke(Array.from(arguments)));
                return len;
            }

            this._value.shift = function () {
                var len = Array.prototype.shift.apply(this, arguments);
                this.onShiftCallbacks.forEach(callback => callback.invoke());
                return len;
            };

            this._value.splice = function (start, deleteCount = 0, ...items) {
                var removed = Array.prototype.splice.apply(this, arguments);
                this.onSpliceCallbacks.forEach(callback => callback.invoke(start, deleteCount, items));
                return removed;
            }

            this._value.attachOnPush = function (func) {
                var callback = new IgniteCallback(func, detach => this.detachOnPush(detach));
                this.onPushCallbacks.push(callback);
                return callback;
            }

            this._value.attachOnPop = function (func) {
                var callback = new IgniteCallback(func, detach => this.detachOnPop(detach));
                this.onPopCallbacks.push(callback);
                return callback;
            }

            this._value.attachOnUnshift = function (func) {
                var callback = new IgniteCallback(func, detach => this.detachOnUnshift(detach));
                this.onUnshiftCallbacks.push(callback);
                return callback;
            }

            this._value.attachOnShift = function (func) {
                var callback = new IgniteCallback(func, detach => this.detachOnShift(detach));
                this.onShiftCallbacks.push(callback);
                return callback;
            }

            this._value.attachOnSplice = function (func) {
                var callback = new IgniteCallback(func, detach => this.detachOnSplice(detach));
                this.onSpliceCallbacks.push(callback);
                return callback;
            }

            this._value.detachOnPush = function (callback) {
                this.onPushCallbacks = this.onPushCallbacks.filter(push => push != callback);
            }

            this._value.detachOnPop = function (callback) {
                this.onPopCallbacks = this.onPopCallbacks.filter(pop => pop != callback);
            }

            this._value.detachOnUnshift = function (callback) {
                this.onUnshiftCallbacks = this.onUnshiftCallbacks.filter(unshift => unshift != callback);
            }

            this._value.detachOnShift = function (callback) {
                this.onShiftCallbacks = this.onShiftCallbacks.filter(shift => shift != callback);
            }

            this._value.detachOnSplice = function (callback) {
                this.onSpliceCallbacks = this.onSpliceCallbacks.filter(slice => slice != callback);
            }
        }

        if (Array.isArray(this._value) && this._value.onPushCallbacks) {
            //This array has already been patched but attach to it so we get callbacks.
            this.arrayCallbacks.push(this._value.attachOnPush((items) => this.invokeOnPush(items)));
            this.arrayCallbacks.push(this._value.attachOnPop(() => this.invokeOnPop()));
            this.arrayCallbacks.push(this._value.attachOnShift(() => this.invokeOnShift()));
            this.arrayCallbacks.push(this._value.attachOnUnshift((items) => this.invokeOnUnshift(items)));
            this.arrayCallbacks.push(this._value.attachOnSplice((start, deleteCount, items) => this.invokeOnSplice(start, deleteCount, items)));
        }
    }

    reflect() {
        this.ignoreValueChange = true; //Ignore changes incase we are connected to any reflected properties.
        this.reflected.forEach(reflect => reflect instanceof Function ? reflect(this._value) : (reflect.value = this._value));
        this.ignoreValueChange = false;
    }

    invokeOnChange(oldValue, newValue, reflect = true) {
        //Enter a new rendering context since this event may contain code that expects a new context.
        IgniteRenderingContext.push();
        if (reflect) {
            this.reflect();
        }
        this.onChangeCallbacks.forEach(callback => callback.invoke(oldValue, newValue));
        IgniteRenderingContext.pop();
    }

    invokeOnPush(items, reflect = true) {
        IgniteRenderingContext.push();
        if (reflect) {
            this.reflect();
        }
        this.onPushCallbacks.forEach(callback => callback.invoke(this._value, items));
        IgniteRenderingContext.pop();
    }

    invokeOnPop(reflect = true) {
        IgniteRenderingContext.push();
        if (reflect) {
            this.reflect();
        }
        this.onPopCallbacks.forEach(callback => callback.invoke(this._value));
        IgniteRenderingContext.pop();
    }

    invokeOnShift(reflect = true) {
        IgniteRenderingContext.push();
        if (reflect) {
            this.reflect();
        }
        this.onShiftCallbacks.forEach(callback => callback.invoke(this._value));
        IgniteRenderingContext.pop();
    }

    invokeOnUnshift(items, reflect = true) {
        IgniteRenderingContext.push();
        if (reflect) {
            this.reflect();
        }
        this.onUnshiftCallbacks.forEach(callback => callback.invoke(this._value, items));
        IgniteRenderingContext.pop();
    }

    invokeOnSplice(start, deleteCount, items, reflect = true) {
        IgniteRenderingContext.push();
        if (reflect) {
            this.reflect();
        }
        this.onSpliceCallbacks.forEach(callback => callback.invoke(this._value, start, deleteCount, items));
        IgniteRenderingContext.pop();
    }

    attachOnChange(onChange) {
        var callback = new IgniteCallback(onChange, detach => this.detachOnChange(detach));
        this.onChangeCallbacks.push(callback);
        return callback;
    }

    attachOnPush(onPush) {
        var callback = new IgniteCallback(onPush, detach => this.detachOnPush(detach));
        this.onPushCallbacks.push(callback);
        return callback;
    }

    attachOnPop(onPop) {
        var callback = new IgniteCallback(onPop, detach => this.detachOnPop(detach));
        this.onPopCallbacks.push(callback);
        return callback;
    }

    attachOnShift(onShift) {
        var callback = new IgniteCallback(onShift, detach => this.detachOnShift(detach));
        this.onShiftCallbacks.push(callback);
        return callback;
    }

    attachOnUnshift(onUnshift) {
        var callback = new IgniteCallback(onUnshift, detach => this.detachOnUnshift(detach));
        this.onUnshiftCallbacks.push(callback);
        return callback;
    }

    attachOnSplice(onSplice) {
        var callback = new IgniteCallback(onSplice, detach => this.detachOnSplice(detach));
        this.onSpliceCallbacks.push(callback);
        return callback;
    }

    detachOnChange(callback) {
        this.onChangeCallbacks = this.onChangeCallbacks.filter(change => change != callback);
    }

    detachOnPush(callback) {
        this.onPushCallbacks = this.onPushCallbacks.filter(push => push != callback);
    }

    detachOnPop(callback) {
        this.onPopCallbacks = this.onPopCallbacks.filter(pop => pop != callback);
    }

    detachOnShift(callback) {
        this.onShiftCallbacks = this.onShiftCallbacks.filter(shift => shift != callback);
    }

    detachOnUnshift(callback) {
        this.onUnshiftCallbacks = this.onUnshiftCallbacks.filter(unshift => unshift != callback);
    }

    detachOnSplice(callback) {
        this.onSpliceCallbacks = this.onSpliceCallbacks.filter(slice => slice != callback);
    }
}

/**
 * Return the value of the property if we try to convert
 * the property to a string.
 */
IgniteProperty.prototype.toString = function () {
    return this.value.toString();
}

/**
 * Add a prototype to help get property values from an array
 */
Array.prototype.getPropertyValues = function () {
    var ret = [];
    this.forEach(prop => {
        if (prop instanceof IgniteProperty) {
            ret.push(prop.value);
        } else {
            ret.push(prop);
        }
    });
    return ret;
}

/**
 * Add a prototype to help get old property values from an array
 */
Array.prototype.getOldPropertyValues = function (property, oldValue) {
    var ret = [];
    this.forEach(prop => {
        if (prop == property) {
            ret.push(oldValue);
        } else {
            if (prop instanceof IgniteProperty) {
                ret.push(prop.value);
            } else {
                ret.push(prop);
            }
        }
    });
    return ret;
}

/**
 * The outline of a ignite callback that can be invoked and disconnected
 * to help maintain and cleanup callbacks.
 * @ignore
 */
class IgniteCallback {
    constructor(callback, detach) {
        this.callback = callback;
        this.detach = detach;
    }

    invoke(...params) {
        if (this.callback) {
            this.callback(...params);
        }
    }

    disconnect() {
        this.callback = null;

        if (this.detach) {
            this.detach(this);
        }
    }
}

/**
 * The outline of a simple rendering context which allows us to 
 * know if we are currently rendering anything ignite related. This works
 * because Javascript is single threaded, if events could break the current execution
 * this would fail. But it's safe since events cannot do that.
 * @ignore
 */
class IgniteRenderingContext {
    static enter() {
        if (!IgniteRenderingContext.RenderCount) {
            IgniteRenderingContext.RenderCount = 0;
        }

        IgniteRenderingContext.RenderCount++;
    }

    static leave() {
        if (IgniteRenderingContext.RenderCount) {
            IgniteRenderingContext.RenderCount--;
        }
    }

    static push() {
        if (IgniteRenderingContext.Stack == null) {
            IgniteRenderingContext.Stack = [];
        }

        IgniteRenderingContext.Stack.push(IgniteRenderingContext.RenderCount);
        IgniteRenderingContext.RenderCount = 0;
    }

    static pop() {
        if (IgniteRenderingContext.Stack && IgniteRenderingContext.Stack.length > 0) {
            IgniteRenderingContext.RenderCount = IgniteRenderingContext.Stack.pop();
        }
    }

    static ready(callback) {
        //Setup the callbacks if it's not init'd.
        if (!IgniteRenderingContext.ReadyCallbacks) {
            IgniteRenderingContext.ReadyCallbacks = [];
        }

        //Add this ignite callback.
        IgniteRenderingContext.ReadyCallbacks.push(callback);

        //Clear the existing timer if there is one.
        if (IgniteRenderingContext.ReadyTimer) {
            clearTimeout(IgniteRenderingContext.ReadyTimer);
        }

        //Set a new timeout, it will only run once all elements are ready because
        //of the way single threaded timers work.
        IgniteRenderingContext.ReadyTimer = setTimeout(() => {
            IgniteRenderingContext.ReadyCallbacks.forEach((ready) => ready.invoke());
            IgniteRenderingContext.ReadyCallbacks = [];
            IgniteRenderingContext.ReadyTimer = null;
        }, 1);
    }

    static get rendering() {
        if (IgniteRenderingContext.RenderCount && IgniteRenderingContext.RenderCount > 0) {
            return true;
        }

        return false;
    }
}

window.IgniteRenderingContext = IgniteRenderingContext;

export {
    IgniteProperty,
    IgniteRenderingContext,
    IgniteCallback
};