/**
 * 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;
        this.name = null;

        //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();
    }

    /**
     * Creates a new ignite property on the target object that responds to rendering states.
     * @param {Any} target 
     * @param {String} name 
     * @param {Any} value
     * @returns {Any} The target passed to the function.
     */
    static create(target, name, value) {
        var property = new IgniteProperty(value);

        Object.defineProperty(target, name, {
            get: () => { return (IgniteRendering.rendering ? property : property.value); },

            set: (value) => { property.value = value; }
        });

        return target;
    }

    /**
     * Gets the value of this IgniteProperty.
     * @returns {Any} The value of this IgniteProperty.
     */
    get value() {
        return this._value;
    }

    /**
     * Sets the value of this IgniteProperty, this will reflect the value to any listeners on this property.
     * @param {Any} val The value to set.
     */
    set value(val) {
        this.setValue(val, true);
    }

    /**
     * Sets the value of this IngiteProperty with control of reflection.
     * @param {Any} val The value to set for this IgniteProperty.
     * @param {Boolean} reflect If true, this will reflect the value onto any listeners on this property.
     */
    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.
        IgniteRendering.push();
        if (reflect) {
            this.reflect();
        }
        this.onChangeCallbacks.forEach(callback => callback.invoke(oldValue, newValue));
        IgniteRendering.pop();
    }

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

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

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

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

    invokeOnSplice(start, deleteCount, items, reflect = true) {
        IgniteRendering.push();
        if (reflect) {
            this.reflect();
        }
        this.onSpliceCallbacks.forEach(callback => callback.invoke(this._value, start, deleteCount, items));
        IgniteRendering.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 () {
    if (this.value) {
        return this.value.toString();
    }

    return null;
}

/**
 * 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 an IgniteObject which contains IgniteProperties.
 */
class IgniteObject {
    /**
     * Creates a new IgniteObject from an object and returns it.
     * @param {Any} obj The object to create an IgniteObject out of. 
     * @returns {IgniteObject} The ignite object created.
     */
    constructor(obj) {
        //Only do this if the object is not an ignite object already.
        if (!(obj instanceof IgniteObject)) {
            Object.keys(obj).forEach(name => {
                var prop = new IgniteProperty(obj[name]);
                Object.defineProperty(this, name, {
                    get: () => { return (IgniteRendering.rendering ? prop : prop.value); },
                    set: (value) => { prop.value = value; }
                });
            });
        } else {
            return obj;
        }
    }

    /**
     * Checks this IgniteObject for any properties not converted and automatically
     * converts them.
     */
    update() {
        Object.keys(this).forEach(name => {
            if (!(this[name] instanceof IgniteProperty)) {
                var prop = new IgniteProperty(this[name]);
                delete this[name];
                Object.defineProperty(this, name, {
                    get: () => { return (IgniteRendering.rendering ? prop : prop.value); },
                    set: (value) => { prop.value = value; }
                });
            }
        });
    }

    /**
     * Sets the values of properties on this ignite object.
     * @param {Object|IgniteObject} props The properties to set onto this ignite object. 
     */
    setProperties(props) {
        if (props == null || props == undefined) {
            return;
        }

        if (props instanceof IgniteObject) {
            props.update();
            Object.getOwnPropertyNames(props).forEach(name => this[name] = props[name]);
        } else {
            Object.keys(props).forEach(name => this[name] = props[name]);
        }
    }

    /**
     * Creates an IgniteObject or Array of IgniteObjects and returns it.
     * @param {Object|Array} obj The object to create an IgniteObject from. 
     * @returns {IgniteObject} The created IgniteObject.
     */
    static create(obj) {
        if (obj instanceof Array) {
            var converted = [];
            obj.forEach(item => converted.push(new IgniteObject(item)));
            return converted;
        } else if (!(obj instanceof IgniteObject)) {
            return new IgniteObject(obj);
        } else {
            return obj;
        }
    }
}

/**
 * The outline of a ignite callback that can be invoked and disconnected
 * to help maintain and cleanup callbacks.
 * @ignore
 */
class IgniteCallback {
    /**
     * Creates a new IgniteCallback with a callback and optional detach callback.
     * @param {Function(...)} callback The callback function to invoke when the callback is invoked.
     * @param {Function(IgniteCallback)} detach The detach function that is called when the callback is disconnected.
     */
    constructor(callback, detach = null) {
        this.callback = callback;
        this.detach = detach;
    }

    /**
     * Invokes this callback.
     * @param  {...any} params Any params to pass to the target function.
     */
    async invoke(...params) {
        if (this.callback) {
            await this.callback(...params);
        }
    }

    /**
     * Invokes the detach callback for this callback if there is one.
     */
    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 IgniteRendering {
    /**
     * Increments the rendering counter. Must call leave() for each enter().
     */
    static enter() {
        if (!IgniteRendering.RenderCount) {
            IgniteRendering.RenderCount = 0;
        }

        IgniteRendering.RenderCount++;
    }

    /**
     * Decrements the rendering counter, if 0, rendering() will return false.
     */
    static leave() {
        if (IgniteRendering.RenderCount) {
            IgniteRendering.RenderCount--;
        }
    }

    /**
     * Saves the current rendering state.
     */
    static push() {
        if (IgniteRendering.Stack == null) {
            IgniteRendering.Stack = [];
        }

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

    /**
     * Restores the last rendering state.
     */
    static pop() {
        if (IgniteRendering.Stack && IgniteRendering.Stack.length > 0) {
            IgniteRendering.RenderCount = IgniteRendering.Stack.pop();
        }
    }

    /**
     * Registers a callback function that will be invoked once rendering is complete.
     * @param {IgniteCallback|Function} callback The callback function to invoke once rendering is complete.
     */
    static ready(callback) {
        //Setup the callbacks if it's not init'd.
        if (!IgniteRendering.ReadyCallbacks) {
            IgniteRendering.ReadyCallbacks = [];
        }

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

        //Clear the existing timer if there is one.
        if (IgniteRendering.ReadyTimer && !IgniteRendering.ReadyTimerRunning) {
            clearTimeout(IgniteRendering.ReadyTimer);
        }

        //Set a new timeout, it will only run once all elements are ready because
        //of the way single threaded timers work.
        if (!IgniteRendering.ReadyTimerRunning) {
            IgniteRendering.ReadyTimer = setTimeout(async () => {
                IgniteRendering.ReadyTimerRunning = true;
                while (IgniteRendering.ReadyCallbacks.length > 0) {
                    var callback = IgniteRendering.ReadyCallbacks.shift();

                    if (callback instanceof IgniteCallback) {
                        callback.invoke();
                    } else if (callback instanceof Function) {
                        callback();
                    }
                }

                IgniteRendering.ReadyCallbacks = [];
                IgniteRendering.ReadyTimer = null;
                IgniteRendering.ReadyTimerRunning = false;
            }, 1);
        }
    }

    /**
     * Returns whether or not we are currently in a rendering state.
     */
    static get rendering() {
        return IgniteRendering.RenderCount && IgniteRendering.RenderCount > 0;
    }
}

/**
 * Main helper class to access ignite html functions.
 */
class IgniteHtml {
    /**
     * Registers a new element to be rendered when render() is called.
     * @param {String} name The tag name of the element to register.
     * @param {Any} definition The class definition of the element.
     */
    static register(name, definition) {
        if (!IgniteHtml.registered) {
            IgniteHtml.registered = { };
        }

        if (!IgniteHtml.registered[name]) {
            IgniteHtml.registered[name] = definition;
        }
    }

    /**
     * Begins rendering all registered elements.
     */
    static render() {
        if (IgniteHtml.registered) {
            var elements = Object.keys(IgniteHtml.registered);

            for (var i = 0; i < elements.length; i++) {
                customElements.define(elements[i], IgniteHtml.registered[elements[i]]);
            }
        }
    }
}

window.IgniteProperty = IgniteProperty;
window.IgniteRendering = IgniteRendering;
window.IgniteObject = IgniteObject;
window.IgniteCallback = IgniteCallback;
window.IgniteHtml = IgniteHtml;

export {
    IgniteProperty,
    IgniteObject,
    IgniteRendering,
    IgniteCallback,
    IgniteHtml
};