import { IgniteProperty } from './ignite-html.js';
import { IgniteElement } from './ignite-element.js';

class IgniteTemplate {
    constructor(items) {
        this.siblings = [];
        this.children = [];
        this.attributes = {};
        this.classes = [];
        this.tagName = null;
        this.element = null;
        this.properties = {};
        this.refs = [];
        this.callbacks = [];
        this.events = {};
        this.styles = {};

        if (items) {
            for (var i = 0; i < items.length; i++) {
                if (items[i] instanceof IgniteProperty) {
                    this.children.push(new html(items[i]));
                } else if (items[i] instanceof String || typeof items[i] === 'string') {
                    this.children.push(new html(items[i]));
                } else if (items[i] instanceof Number || typeof items[i] === 'number') {
                    this.children.push(new html(items[i]));
                } else if (items[i] instanceof IgniteTemplate || items[i].prototype instanceof IgniteTemplate) {
                    this.children.push(items[i]);
                } else {
                    throw `Attempted to add a child for template: ${this.tagName} which is not supported. Child: ${items[i]}`;
                }
            }
        }
    }

    /**
     * Adds a single or series of classes to this template
     * to be added once this template is constructed.
     * @param {...any} items 
     */
    class(name, converter = null) {
        if (name instanceof IgniteProperty) {
            this.callbacks.push(name.attachOnChange((oldValue, newValue) => this.onClassChanged(oldValue, newValue, converter)));
            this.classes.push(converter != null ? converter(name.value) : name.value);
        } else {
            this.classes.push(converter != null ? converter(name) : name);
        }

        return this;
    }

    /**
     * Adds a attribute to this template to be added once this template is constructed.
     * @param {string} name 
     * @param {any} value 
     */
    attribute(name, value, converter = null) {
        if (value instanceof IgniteProperty) {
            this.callbacks.push(value.attachOnChange((oldValue, newValue) => this.onAttributeChanged(oldValue, newValue, name, converter)));
            this.attributes[name] = converter != null ? converter(value.value) : value.value;
        } else {
            this.attributes[name] = converter != null ? converter(value) : value;
        }

        return this;
    }

    /**
     * Adds a property to this template to be added once this template is constructed.
     * @param {string} name 
     * @param {any} value 
     */
    property(name, value, reflect = false, converter = null) {
        if (this.properties[name]) {
            throw `Attempted to set a property twice on a IgniteTemplate: ${this.tagName}. This is not allowed and should be avoided.`;
        }

        if (reflect && converter != null) {
            throw `Cannot add a property to an IgniteTemplate: ${this.tagName} with reflect and a converter used at the same time.`;
        }

        if (value instanceof IgniteProperty) {
            this.callbacks.push(value.attachOnChange((oldValue, newValue) => this.onPropertyChanged(oldValue, newValue, name, converter)));
            this.properties[name] = {
                value: (converter != null ? converter(value.value) : value.value),
                reflect: (reflect == true ? value : null)
            };
        } else {
            this.properties[name] = {
                value: (converter != null ? converter(value) : value),
                reflect: null
            };
        }

        return this;
    }

    /**
     * Adds a single or series of children to be added once this template
     * is constructed.
     * @param  {...any} items 
     */
    child(...items) {
        for (var i = 0; i < items.length; i++) {
            if (items[i] instanceof IgniteProperty) {
                this.children.push(new html(items[i]));
            } else if (items[i] instanceof String || typeof items[i] === 'string') {
                this.children.push(new html(items[i]));
            } else if (items[i] instanceof Number || typeof items[i] === 'number') {
                this.children.push(new html(items[i]));
            } else if (items[i] instanceof IgniteTemplate || items[i].prototype instanceof IgniteTemplate) {
                this.children.push(items[i]);
            } else {
                throw `Attempted to add a child for template: ${this.tagName} which is not supported. Child: ${items[i]}`;
            }
        }

        return this;
    }

    /**
     * Adds a ref callback function to be invoked once this template is constructed.
     * @param {function} item 
     */
    ref(item) {
        if (item instanceof IgniteProperty) {
            this.callbacks.push(item.attachOnChange((oldValue, newValue) => this.onRefChanged(oldValue, newValue, item)));
            this.refs.push((element) => item.value = element);
        } else {
            this.refs.push(item);
        }

        return this;
    }

    /**
     * Adds a event by its name and the func to invoke once the event fires.
     * @param {String} eventName 
     * @param {Any} func 
     */
    on(eventName, func) {
        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }

        if (func instanceof IgniteProperty) {
            this.callbacks.push(func.attachOnChange((oldValue, newValue) => this.onEventChanged(oldValue, newValue, eventName)));
            this.events[eventName].push(func.value);
        } else {
            this.events[eventName].push(func);
        }

        return this;
    }

    /**
     * Adds a on click event handler to this template.
     * @param {Any} func 
     */
    onClick(func) {
        this.on("click", func);

        return this;
    }

    /**
     * Adds a on enter key press event handler to this template.
     * @param {Any} func 
     */
    onEnter(func) {
        var eventName = "keydown";

        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }

        if (func instanceof IgniteProperty) {
            this.callbacks.push(func.attachOnChange((oldValue, newValue) => {
                //Create a new wrapped function to check for the enter key being pressed.
                var wrapped = (e) => {
                    if (e.key === 'Enter') {
                        newValue(e);
                    }
                };

                //Store the wrapped function so that it's the old value next time around
                //and the old event can be removed in the future
                func._value = wrapped;

                //Invoke the on event changed with the old value and our wrapped value.
                this.onEventChanged(oldValue, wrapped, eventName)
            }));

            //Create the initial wrapper
            var target = func._value;
            var wrapped = (e) => {
                if (e.key === 'Enter') {
                    target(e);
                }
            };

            //Store the wrapped so that it's the old value next time around.
            func._value = wrapped;

            this.events[eventName].push(wrapped);
        } else {
            this.on(eventName, (e) => {
                if (e.key === 'Enter') {
                    func(e);
                }
            });
        }

        return this;
    }

    /**
     * Sets a css style on this template and adds it to the element
     * once it's constructed.
     * @param {String} name 
     * @param {String} value 
     * @param {Boolean} priority
     */
    style(name, value, priority = null, converter = null) {
        if (value instanceof IgniteProperty) {
            this.callbacks.push(value.attachOnChange((oldValue, newValue) => this.onCssValueChanged(oldValue, newValue, name, converter)));
            this.styles[name] = { name: name, value: (converter != null ? converter(value.value) : value.value), priority: priority };
        } else {
            this.styles[name] = { name: name, value: (converter != null ? converter(value) : value), priority: priority };
        }

        return this;
    }

    /**
     * Constructs this template and adds it to the DOM if this template
     * has not already been constructed.
     * @param {HTMLElement} parent 
     * @param {HTMLElement} sibling 
     */
    construct(parent, sibling) {
        //Don't construct if we have no parent, no sibling and no element.
        if (!parent && !sibling && !this.element) {
            return;
        }

        //Construct this element if we haven't already
        if (!this.element) {
            this.element = window.document.createElement(this.tagName);

            //Pass back our template to the element we are creating if it's not a ignite element, since
            //it will have it's own template automatically.
            if (!(this.element instanceof IgniteElement)) {
                this.element.template = this;
            }

            //If the element has a onDisconnected function, attach to it
            //(This way if a custom element is removed we can deconstruct and cleanup)
            if (this.element.onDisconnected !== undefined) {
                this.element.onDisconnected = () => this.deconstruct();
            }

            //Invoke any refs we have and pass back the element reference.
            this.refs.forEach((ref) => ref(this.element));
        }

        //Set the classes on this element
        for (var i = 0; i < this.classes.length; i++) {
            if (this.classes[i] !== null && this.classes[i] !== undefined && this.classes[i] !== "") {
                this.element.classList.add(this.classes[i]);
            }
        }

        //Set the attributes on this element
        var keys = Object.keys(this.attributes);
        for (var i = 0; i < keys.length; i++) {
            if (this.attributes[keys[i]] !== null && this.attributes[keys[i]] !== undefined) {
                this.element.setAttribute(keys[i], this.attributes[keys[i]]);
            }
        }

        //Set the events on this element
        var keys = Object.keys(this.events);
        for (var i = 0; i < keys.length; i++) {
            this.events[keys[i]].forEach((event) => {
                if (event !== null && event !== undefined) {
                    this.element.addEventListener(keys[i], event);
                }
            });
        }

        //Set the styles on this element.
        var keys = Object.keys(this.styles);
        for (var i = 0; i < keys.length; i++) {
            var style = this.styles[keys[i]];
            this.element.style.setProperty(style.name, style.value, style.priority);
        }

        //Set the properties on this element
        var keys = Object.keys(this.properties);
        for (var i = 0; i < keys.length; i++) {
            this.element[keys[i]] = this.properties[keys[i]].value;

            if (this.properties[keys[i]].reflect != null) {
                this.element[keys[i]].reflected.push(this.properties[keys[i]].reflect);
            }
        }

        //Construct the children under this element
        for (var i = 0; i < this.children.length; i++) {
            this.children[i].construct(this.element);
        }

        //If our element has not been added to the dom yet, then add it.
        if (this.element.isConnected == false && this.element.parentElement == null) {
            if (sibling) {
                parent.insertBefore(this.element, sibling);
            } else {
                parent.appendChild(this.element);
            }
        }
    }

    /**
     * Deconstructs this template and cleans up all resources to make sure
     * there are no memory leaks.
     */
    deconstruct() {
        //Remove and disconnect all events
        if (this.element && this.events) {
            var keys = Object.keys(this.events);
            for (var i = 0; i < keys.length; i++) {
                this.element.removeEventListener(keys[i], this.events[keys[i]]);
            }
            this.events = null;
        }

        //Remove our element if we have one.
        if (this.element) {
            this.element.remove();
            this.element = null;
        }

        //Deconstruct all children elements.
        if (this.children) {
            for (var i = 0; i < this.children.length; i++) {
                if (this.children[i] instanceof IgniteTemplate || this.children[i].prototype instanceof IgniteTemplate) {
                    this.children[i].deconstruct();
                }
            }
            this.children = null;
        }

        //Disconnect all callbacks
        if (this.callbacks) {
            this.callbacks.forEach((item) => item.disconnect());
            this.callbacks = null;
        }

        //Remove any refs
        if (this.refs) {
            this.refs = null;
        }
    }

    /**
     * Called when a class on this template was changed and needs to be updated
     * on the template's element.
     * @param {any} oldValue 
     * @param {any} newValue 
     */
    onClassChanged(oldValue, newValue, converter) {
        if (converter !== null) {
            oldValue = converter(oldValue);
            newValue = converter(newValue);
        }

        if (this.element) {
            if (oldValue !== null && oldValue !== undefined && oldValue !== "" && oldValue !== " ") {
                this.element.classList.remove(oldValue);
            }

            if (newValue !== null && newValue !== undefined && newValue !== "" && newValue !== " ") {
                this.element.classList.add(newValue);
            }
        }

        //Remove the old value
        this.classes = this.classes.filter(cl => cl != oldValue && cl != newValue);

        //Add the new value if its valid.
        if (newValue !== null && newValue !== undefined) {
            this.classes.push(newValue);
        }
    }

    /**
     * Called when a attribute on this template was changed and needs to be updated
     * on the template's element.
     * @param {any} oldValue 
     * @param {any} newValue 
     * @param {string} attributeName 
     */
    onAttributeChanged(oldValue, newValue, attributeName, converter) {
        if (converter !== null) {
            newValue = converter(newValue);
        }

        if (this.element) {
            if (newValue == null || newValue == undefined) {
                this.element.removeAttribute(attributeName);
            } else {
                this.element.setAttribute(attributeName, newValue);
            }
        }

        this.attributes[attributeName] = newValue;
    }

    /**
     * Called when a property on this template was changed and needs to be updated
     * on the template's element.
     * @param {any} oldValue 
     * @param {any} newValue 
     * @param {string} propertyName 
     */
    onPropertyChanged(oldValue, newValue, propertyName, converter) {
        if (converter !== null) {
            newValue = converter(newValue);
        }

        if (this.element) {
            //Use the set value function and don't reflect the change because it came from above which
            //would be the reflected property, this reduces some functions being called twice that don't need to be.
            this.element[`_${propertyName}`].setValue(newValue, false);
        }

        this.properties[propertyName].value = newValue;
    }

    /**
     * Called when a ref was changed and we need to update the refs
     * value to match this elements reference.
     * @param {any} oldValue 
     * @param {any} newValue 
     * @param {any} ref 
     */
    onRefChanged(oldValue, newValue, ref) {
        //Only set the reference value to ourself if it's not our element.
        //Otherwise we will get a never ending loop.
        if (this.element != newValue) {
            ref.value = this.element;
        }
    }

    /**
     * Called when a event was changed and we need to update it.
     * @param {any} oldValue 
     * @param {any} newValue 
     * @param {any} eventName 
     */
    onEventChanged(oldValue, newValue, eventName) {
        if (this.element) {
            if (oldValue !== null && oldValue !== undefined) {
                this.element.removeEventListener(eventName, oldValue);
            }

            if (newValue !== null && newValue !== undefined) {
                this.element.addEventListener(eventName, newValue);
            }

            //Remove the old value from the events
            this.events[eventName] = this.events[eventName].filter(ev => ev != oldValue);

            //Add the new value if it's needed
            if (newValue !== null && newValue !== undefined) {
                this.events[eventName].push(newValue);
            }
        }
    }

    /**
     * Called when a css value was changed and we need to update the styling.
     * @param {any} oldValue 
     * @param {any} newValue 
     * @param {any} style 
     */
    onCssValueChanged(oldValue, newValue, name, converter) {
        if (this.element) {
            this.element.style.setProperty(name, (converter != null ? converter(newValue) : newValue), this.styles[name].priority);
        }

        this.styles[name].value = (converter != null ? converter(newValue) : newValue);
    }
}

class div extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "div";
    }
}

class a extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "a";
    }
}

class input extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "input";
    }
}

class button extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "button";
    }
}

class h1 extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "h1";
    }
}

class h2 extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "h2";
    }
}

class h3 extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "h3";
    }
}

class h4 extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "h4";
    }
}

class h5 extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "h5";
    }
}

class p extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "p";
    }
}

class span extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "span";
    }
}

class i extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "i";
    }
}

class br extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "br";
    }
}

class img extends IgniteTemplate {
    constructor(...items) {
        super(items);

        this.tagName = "img";
    }
}

class html extends IgniteTemplate {
    constructor(code) {
        super([]);

        if (code instanceof IgniteProperty) {
            this.callbacks.push(code.attachOnChange((oldValue, newValue) => this.onPropertyChanged(oldValue, newValue)));
            this.code = code.value;
        } else {
            this.code = code;
        }

        this.tagName = "shadow html";
        this.elements = [];
    }

    construct(parent, sibling) {
        //Don't construct if we have no parent, no sibling and no element.
        if (!parent && !sibling && !this.element) {
            return;
        }

        if (!this.element) {
            this.element = window.document.createTextNode("");

            if (sibling) {
                sibling.parentElement.insertBefore(this.element, sibling);
            } else {
                parent.appendChild(this.element);
            }
        }

        //Create a template to hold the elements that will be created from the
        //properties value and then add them to the DOM and store their pointer.
        if (this.elements.length == 0 && this.code) {
            var template = window.document.createElement("template");
            template.innerHTML = this.code;
            while (template.content.childNodes.length > 0) {
                var item = template.content.childNodes[0];
                this.element.parentElement.insertBefore(item, this.element);
                this.elements.push(item);
            }
            template.remove();
        }
    }

    onPropertyChanged(oldValue, newValue) {
        //Update our code to the new value from the property.
        this.code = newValue;

        //Remove any elements that already exist.
        this.elements.forEach((item) => item.remove());
        this.elements = [];

        //Reconstruct the html
        this.construct(null, null);
    }
}

class list extends IgniteTemplate {
    constructor(list, forEach) {
        super([]);
        this.tagName = "shadow list";

        if (list instanceof IgniteProperty) {
            this.callbacks.push(list.attachOnChange((oldValue, newValue) => this.onListChanged(oldValue, newValue)));
            this.callbacks.push(list.attachOnPush((oldValue, newValue) => this.onListPush(oldValue, newValue)));
            this.callbacks.push(list.attachOnPop((oldValue, newValue) => this.onListPop(oldValue, newValue)));

            this.list = list.value;
        } else {
            this.list = list;
        }

        this.forEach = forEach;
        this.elements = [];
    }

    construct(parent, sibling) {
        //Don't construct if we have no parent, no sibling and no element.
        if (!parent && !sibling && !this.element) {
            return;
        }

        if (!this.element) {
            this.element = window.document.createTextNode(""); //Use a textnode as our placeholder

            if (sibling) {
                sibling.parentElement.insertBefore(this.element, sibling);
            } else {
                parent.appendChild(this.element);
            }
        } else {
            parent = this.element.parentElement;
        }

        //If we already have elements we created, destroy them.
        if (this.elements.length > 0) {
            for (var i = 0; i < this.elements.length; i++) {
                this.elements[i].remove();
            }
            this.elements = [];
        }

        //If we already have children elements, deconstruct them.
        //(Because we are about to recreate them)
        if (this.children.length > 0) {
            for (var i = 0; i < this.children.length; i++) {
                this.children[i].deconstruct();
            }
            this.children = [];
        }

        //Construct all the items in our list and use the container
        if (this.list) {
            for (var i = 0; i < this.list.length; i++) {
                var template = this.forEach(this.list[i]);
                template.construct(parent, this.element);

                this.children.push(template);
                this.elements.push(template.element);
            }
        }
    }

    onListChanged(oldValue, newValue) {
        this.list = newValue;

        IgniteRenderingContext.enter();

        try {
            this.construct(null); //The list changed, reconstruct this template.
        } catch (error) {
            console.error(error);
        }

        IgniteRenderingContext.leave();
    }

    onListPush(oldValue, newValue) {
        IgniteRenderingContext.enter();

        try {
            var template = this.forEach(this.list[this.list.length - 1]);

            if (this.elements.length > 0) {
                template.construct(this.element.parentElement, this.elements[this.elements.length - 1].nextSibling);
            } else {
                template.construct(this.element.parentElement, null);
            }

            this.children.push(template);
            this.elements.push(template.element);
        } catch { }

        IgniteRenderingContext.leave();
    }

    onListPop(oldValue, newValue) {
        this.children[this.children.length - 1].deconstruct();
        this.children.pop();
        this.elements.pop();
    }
}

class slot extends IgniteTemplate {
    constructor(element) {
        super(null);

        this.parent = element;
    }

    construct(parent, sibling) {
        //Don't construct if we have no parent, no sibling and no element.
        if (!parent && !sibling && !this.element) {
            return;
        }

        if (!this.element) {
            this.element = window.document.createTextNode("");

            if (sibling) {
                sibling.parentElement.insertBefore(this.element, sibling);
            } else {
                parent.appendChild(this.element);
            }

            //Add any slot elements after this element.
            this.parent.elements.forEach((item) => this.element.parentElement.insertBefore(item, this.element));
        }
    }
}

export {
    IgniteTemplate,
    div,
    html,
    list,
    a,
    input,
    button,
    h1,
    h2,
    h3,
    h4,
    h5,
    p,
    span,
    i,
    br,
    img,
    slot
};