'use strict';

/**
 * @author schukai GmbH
 */

import {internalSymbol} from "../constants.js";
import {diff} from "../data/diff.js";
import {Pathfinder} from "../data/pathfinder.js";
import {Pipe} from "../data/pipe.js";
import {
    ATTRIBUTE_ERRORMESSAGE,
    ATTRIBUTE_UPDATER_ATTRIBUTES,
    ATTRIBUTE_UPDATER_BIND,
    ATTRIBUTE_UPDATER_INSERT,
    ATTRIBUTE_UPDATER_INSERT_REFERENCE,
    ATTRIBUTE_UPDATER_REMOVE,
    ATTRIBUTE_UPDATER_REPLACE,
    ATTRIBUTE_UPDATER_SELECT_THIS
} from "../dom/constants.js";

import {Base} from "../types/base.js";
import {isArray, isInstance, isIterable} from "../types/is.js";
import {Observer} from "../types/observer.js";
import {ProxyObserver} from "../types/proxyobserver.js";
import {validateArray, validateInstance} from "../types/validate.js";
import {clone} from "../util/clone.js";
import {trimSpaces} from "../util/trimspaces.js";
import {findTargetElementFromEvent} from "./events.js";
import {findDocumentTemplate} from "./template.js";
import {getDocument} from "./util.js";


/**
 * The updater class connects an object with the dom. In this way, structures and contents in the DOM can be programmatically adapted via attributes.
 *
 * For example, to include a string from an object, the attribute `data-monster-replace` can be used.
 * a further explanation can be found under {@tutorial dom-based-templating-implementation}.
 *
 * Changes to attributes are made only when the direct values are changed. If you want to assign changes to other values
 * as well, you have to insert the attribute `data-monster-select-this`. This should be done with care, as it can reduce performance.
 *
 * ```
 * <script type="module">
 * import {Updater} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/dom/updater.js';
 * new Updater()
 * </script>
 * ```
 *
 * @example
 *
 * import {Updater} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/dom/updater.js';
 *
 * // First we prepare the html document.
 * // This is done here via script, but can also be inserted into the document as pure html.
 * // To do this, simply insert the tag <h1 data-monster-replace="path:headline"></h1>.
 * const body = document.querySelector('body');
 * const headline = document.createElement('h1');
 * headline.setAttribute('data-monster-replace','path:headline')
 * body.appendChild(headline);
 *
 * // the data structure
 * let obj = {
 *    headline: "Hello World",
 * };
 *
 * // Now comes the real magic. we pass the updater the parent HTMLElement
 * // and the desired data structure.
 * const updater = new Updater(body, obj);
 * updater.run();
 *
 * // Now you can change the data structure and the HTML will follow these changes.
 * const subject = updater.getSubject();
 * subject['headline'] = "Hello World!"
 *
 * @since 1.8.0
 * @copyright schukai GmbH
 * @memberOf Monster.DOM
 * @throws {Error} the value is not iterable
 * @throws {Error} pipes are not allowed when cloning a node.
 * @throws {Error} no template was found with the specified key.
 * @throws {Error} the maximum depth for the recursion is reached.
 * @throws {TypeError} value is not a object
 * @throws {TypeError} value is not an instance of HTMLElement
 * @summary The updater class connects an object with the dom
 */
export class Updater extends Base {

    /**
     * @since 1.8.0
     * @param {HTMLElement} element
     * @param {object|ProxyObserver|undefined} subject
     * @throws {TypeError} value is not a object
     * @throws {TypeError} value is not an instance of HTMLElement
     * @see {@link Monster.DOM.findDocumentTemplate}
     */
    constructor(element, subject) {
        super();

        /**
         * @type {HTMLElement}
         */
        if (subject === undefined) subject = {}
        if (!isInstance(subject, ProxyObserver)) {
            subject = new ProxyObserver(subject);
        }

        this[internalSymbol] = {
            element: validateInstance(element, HTMLElement),
            last: {},
            callbacks: new Map(),
            eventTypes: ['keyup', 'click', 'change', 'drop', 'touchend', 'input'],
            subject: subject
        }

        this[internalSymbol].callbacks.set('checkstate', getCheckStateCallback.call(this));

        this[internalSymbol].subject.attachObserver(new Observer(() => {

            const s = this[internalSymbol].subject.getRealSubject();

            const diffResult = diff(this[internalSymbol].last, s)
            this[internalSymbol].last = clone(s);

            for (const [, change] of Object.entries(diffResult)) {
                removeElement.call(this, change);
                insertElement.call(this, change);
                updateContent.call(this, change);
                updateAttributes.call(this, change);
            }
        }));

    }

    /**
     * Defaults: 'keyup', 'click', 'change', 'drop', 'touchend'
     *
     * @see {@link https://developer.mozilla.org/de/docs/Web/Events}
     * @since 1.9.0
     * @param {Array} types
     * @return {Updater}
     */
    setEventTypes(types) {
        this[internalSymbol].eventTypes = validateArray(types);
        return this;
    }

    /**
     * With this method, the eventlisteners are hooked in and the magic begins.
     *
     * ```
     * updater.run().then(() => {
     *   updater.enableEventProcessing();
     * });
     * ```
     *
     * @since 1.9.0
     * @return {Updater}
     * @throws {Error} the bind argument must start as a value with a path
     */
    enableEventProcessing() {
        this.disableEventProcessing();

        for (const type of this[internalSymbol].eventTypes) {
            // @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
            this[internalSymbol].element.addEventListener(type, getControlEventHandler.call(this), {
                capture: true,
                passive: true
            });
        }

        return this;

    }

    /**
     * This method turns off the magic or who loves it more profane it removes the eventListener.
     *
     * @since 1.9.0
     * @return {Updater}
     */
    disableEventProcessing() {

        for (const type of this[internalSymbol].eventTypes) {
            this[internalSymbol].element.removeEventListener(type, getControlEventHandler.call(this));
        }

        return this;

    }

    /**
     * The run method must be called for the update to start working.
     * The method ensures that changes are detected.
     *
     * ```
     * updater.run().then(() => {
     *   updater.enableEventProcessing();
     * });
     * ```
     *
     * @summary Let the magic begin
     * @return {Promise}
     */
    run() {
        // the key __init__has no further meaning and is only 
        // used to create the diff for empty objects.
        this[internalSymbol].last = {'__init__': true};
        return this[internalSymbol].subject.notifyObservers();
    }

    /**
     * Gets the values of bound elements and changes them in subject
     *
     * @since 1.27.0
     * @return {Monster.DOM.Updater}
     */
    retrieve() {
        retrieveFromBindings.call(this);
        return this;
    }

    /**
     * If you have passed a ProxyObserver in the constructor, you will get the object that the ProxyObserver manages here.
     * However, if you passed a simple object, here you will get a proxy for that object.
     *
     * For changes the ProxyObserver must be used.
     *
     * @since 1.8.0
     * @return {Proxy}
     */
    getSubject() {
        return this[internalSymbol].subject.getSubject();
    }

    /**
     * This method can be used to register commands that can be called via call: instruction.
     * This can be used to provide a pipe with its own functionality.
     *
     * @param {string} name
     * @param {function} callback
     * @returns {Transformer}
     * @throws {TypeError} value is not a string
     * @throws {TypeError} value is not a function
     */
    setCallback(name, callback) {
        this[internalSymbol].callbacks.set(name, callback);
        return this;
    }

}

/**
 * @private
 * @since 1.9.0
 * @return {function
 * @this Updater
 */
function getCheckStateCallback() {
    const self = this;

    return function (current) {

        // this is a reference to the current object (therefore no array function here)
        if (this instanceof HTMLInputElement) {
            if (['radio', 'checkbox'].indexOf(this.type) !== -1) {
                return (this.value + "" === current + "") ? 'true' : undefined
            }
        } else if (this instanceof HTMLOptionElement) {

            if (isArray(current) && current.indexOf(this.value) !== -1) {
                return 'true'
            }

            return undefined;
        }
    }
}

/**
 * @private
 */
const symbol = Symbol('EventHandler');

/**
 * @private
 * @return {function}
 * @this Updater
 * @throws {Error} the bind argument must start as a value with a path
 */
function getControlEventHandler() {

    const self = this;

    if (self[symbol]) {
        return self[symbol];
    }

    /**
     * @throws {Error} the bind argument must start as a value with a path.
     * @throws {Error} unsupported object
     * @param {Event} event
     */
    self[symbol] = (event) => {
        const element = findTargetElementFromEvent(event, ATTRIBUTE_UPDATER_BIND);

        if (element === undefined) {
            return;
        }

        retrieveAndSetValue.call(self, element);

    }

    return self[symbol];


}

/**
 * @throws {Error} the bind argument must start as a value with a path
 * @param {HTMLElement} element
 * @return void
 * @memberOf Monster.DOM
 * @private
 */
function retrieveAndSetValue(element) {

    const self = this;

    const pathfinder = new Pathfinder(self[internalSymbol].subject.getSubject());

    let path = element.getAttribute(ATTRIBUTE_UPDATER_BIND);

    if (path.indexOf('path:') !== 0) {
        throw new Error('the bind argument must start as a value with a path');
    }

    path = path.substr(5);

    let value;

    if (element instanceof HTMLInputElement) {
        switch (element.type) {

            case 'checkbox':
                value = element.checked ? element.value : undefined;
                break;
            default:
                value = element.value;
                break;


        }
    } else if (element instanceof HTMLTextAreaElement) {
        value = element.value;

    } else if (element instanceof HTMLSelectElement) {

        switch (element.type) {
            case 'select-one':
                value = element.value;
                break;
            case 'select-multiple':
                value = element.value;

                let options = element?.selectedOptions;
                if (options === undefined) options = element.querySelectorAll(":scope option:checked");
                value = Array.from(options).map(({value}) => value);

                break;
        }


        // values from customelements 
    } else if ((element?.constructor?.prototype && !!Object.getOwnPropertyDescriptor(element.constructor.prototype, 'value')?.['get']) || element.hasOwnProperty('value')) {
        value = element?.['value'];
    } else {
        throw new Error("unsupported object");
    }

    const copy = clone(self[internalSymbol].subject.getRealSubject());
    const pf = new Pathfinder(copy);
    pf.setVia(path, value);

    const diffResult = diff(copy, self[internalSymbol].subject.getRealSubject());

    if (diffResult.length > 0) {
        pathfinder.setVia(path, value);
    }
}

/**
 * @since 1.27.0
 * @return void
 * @private
 */
function retrieveFromBindings() {
    const self = this;

    if (self[internalSymbol].element.matches('[' + ATTRIBUTE_UPDATER_BIND + ']')) {
        retrieveAndSetValue.call(self, element)
    }

    for (const [, element] of self[internalSymbol].element.querySelectorAll('[' + ATTRIBUTE_UPDATER_BIND + ']').entries()) {
        retrieveAndSetValue.call(self, element)
    }

}

/**
 * @private
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 */
function removeElement(change) {
    const self = this;

    for (const [, element] of self[internalSymbol].element.querySelectorAll(':scope [' + ATTRIBUTE_UPDATER_REMOVE + ']').entries()) {
        element.parentNode.removeChild(element);
    }
}

/**
 * @private
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 * @throws {Error} the value is not iterable
 * @throws {Error} pipes are not allowed when cloning a node.
 * @throws {Error} no template was found with the specified key.
 * @throws {Error} the maximum depth for the recursion is reached.
 * @this Updater
 */
function insertElement(change) {
    const self = this;
    const subject = self[internalSymbol].subject.getRealSubject();
    const document = getDocument();

    let mem = new WeakSet;
    let wd = 0;

    const container = self[internalSymbol].element;

    while (true) {
        let found = false;
        wd++;

        let p = clone(change?.['path']);
        if (!isArray(p)) return self;

        while (p.length > 0) {
            const current = p.join('.');

            let iterator = new Set;
            const query = '[' + ATTRIBUTE_UPDATER_INSERT + '*="path:' + current + '"]';

            const e = container.querySelectorAll(query);

            if (e.length > 0) {
                iterator = new Set(
                    [...e]
                )
            }

            if (container.matches(query)) {
                iterator.add(container);
            }

            for (const [, containerElement] of iterator.entries()) {

                if (mem.has(containerElement)) continue;
                mem.add(containerElement)

                found = true;

                const attributes = containerElement.getAttribute(ATTRIBUTE_UPDATER_INSERT);
                let def = trimSpaces(attributes);
                let i = def.indexOf(' ');
                let key = trimSpaces(def.substr(0, i));
                let refPrefix = key + '-';
                let cmd = trimSpaces(def.substr(i));

                // this case is actually excluded by the query but is nevertheless checked again here
                if (cmd.indexOf('|') > 0) {
                    throw new Error("pipes are not allowed when cloning a node.");
                }

                let pipe = new Pipe(cmd);
                self[internalSymbol].callbacks.forEach((f, n) => {
                    pipe.setCallback(n, f);
                })

                let value
                try {
                    containerElement.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
                    value = pipe.run(subject)
                } catch (e) {
                    containerElement.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
                }

                let dataPath = cmd.split(':').pop();

                let insertPoint;
                if (containerElement.hasChildNodes()) {
                    insertPoint = containerElement.lastChild;
                }

                if (!isIterable(value)) {
                    throw new Error('the value is not iterable');
                }

                let available = new Set;

                for (const [i, obj] of Object.entries(value)) {
                    let ref = refPrefix + i;
                    let currentPath = dataPath + "." + i;

                    available.add(ref);
                    let refElement = containerElement.querySelector('[' + ATTRIBUTE_UPDATER_INSERT_REFERENCE + '="' + ref + '"]');

                    if (refElement instanceof HTMLElement) {
                        insertPoint = refElement;
                        continue;
                    }

                    appendNewDocumentFragment(containerElement, key, ref, currentPath);
                }

                let nodes = containerElement.querySelectorAll('[' + ATTRIBUTE_UPDATER_INSERT_REFERENCE + '*="' + refPrefix + '"]');
                for (const [, node] of Object.entries(nodes)) {
                    if (!available.has(node.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE))) {
                        try {
                            containerElement.removeChild(node);
                        } catch (e) {
                            containerElement.setAttribute(ATTRIBUTE_ERRORMESSAGE, (containerElement.getAttribute(ATTRIBUTE_ERRORMESSAGE) + ", " + e.message).trim());
                        }

                    }
                }
            }

            p.pop();
        }

        if (found === false) break;
        if (wd++ > 200) {
            throw new Error('the maximum depth for the recursion is reached.');
        }

    }


}

/**
 *
 * @private
 * @since 1.8.0
 * @param {HTMLElement} container
 * @param {string} key
 * @param {string} ref
 * @param {string} path
 * @throws {Error} no template was found with the specified key.
 */
function appendNewDocumentFragment(container, key, ref, path) {

    let template = findDocumentTemplate(key, container);

    let nodes = template.createDocumentFragment();
    for (const [, node] of Object.entries(nodes.childNodes)) {
        if (node instanceof HTMLElement) {

            applyRecursive(node, key, path);
            node.setAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE, ref);
        }

        container.appendChild(node);
    }
}

/**
 * @private
 * @since 1.10.0
 * @param {HTMLElement} node
 * @param {string} key
 * @param {string} path
 * @return {void}
 */
function applyRecursive(node, key, path) {

    if (node instanceof HTMLElement) {

        if (node.hasAttribute(ATTRIBUTE_UPDATER_REPLACE)) {
            let value = node.getAttribute(ATTRIBUTE_UPDATER_REPLACE);
            node.setAttribute(ATTRIBUTE_UPDATER_REPLACE, value.replaceAll("path:" + key, "path:" + path));
        }

        if (node.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) {
            let value = node.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
            node.setAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES, value.replaceAll("path:" + key, "path:" + path));
        }

        for (const [, child] of Object.entries(node.childNodes)) {
            applyRecursive(child, key, path);
        }
    }
}

/**
 * @private
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 * @this Updater
 */
function updateContent(change) {
    const self = this;
    const subject = self[internalSymbol].subject.getRealSubject();

    let p = clone(change?.['path']);
    runUpdateContent.call(this, this[internalSymbol].element, p, subject);

    const slots = this[internalSymbol].element.querySelectorAll('slot');
    if (slots.length > 0) {
        for (const [, slot] of Object.entries(slots)) {
            for (const [, element] of Object.entries(slot.assignedNodes())) {
                runUpdateContent.call(this, element, p, subject);
            }
        }
    }


}

/**
 * @private
 * @since 1.8.0
 * @param {HTMLElement} container
 * @param {array} parts
 * @param {object} subject
 * @return {void}
 */
function runUpdateContent(container, parts, subject) {
    if (!isArray(parts)) return;
    if (!(container instanceof HTMLElement)) return;
    parts = clone(parts);

    let mem = new WeakSet;

    while (parts.length > 0) {
        const current = parts.join('.');
        parts.pop();

        // Unfortunately, static data is always changed as well, since it is not possible to react to changes here.
        const query = '[' + ATTRIBUTE_UPDATER_REPLACE + '^="path:' + current + '"], [' + ATTRIBUTE_UPDATER_REPLACE + '^="static:"]';
        const e = container.querySelectorAll('' + query);

        const iterator = new Set([
            ...e
        ])

        if (container.matches(query)) {
            iterator.add(container);
        }

        /**
         * @type {HTMLElement}
         */
        for (const [element] of iterator.entries()) {

            if (mem.has(element)) return;
            mem.add(element)

            const attributes = element.getAttribute(ATTRIBUTE_UPDATER_REPLACE)
            let cmd = trimSpaces(attributes);

            let pipe = new Pipe(cmd);
            this[internalSymbol].callbacks.forEach((f, n) => {
                pipe.setCallback(n, f);
            })

            let value
            try {
                element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
                value = pipe.run(subject)
            } catch (e) {
                element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
            }

            if (value instanceof HTMLElement) {
                while (element.firstChild) {
                    element.removeChild(element.firstChild);
                }
                
                try {
                    element.appendChild(value);
                } catch (e) {
                    element.setAttribute(ATTRIBUTE_ERRORMESSAGE, (element.getAttribute(ATTRIBUTE_ERRORMESSAGE) + ", " + e.message).trim());
                }

            } else {
                element.innerHTML = value;
            }

        }


    }

}

/**
 * @private
 * @since 1.8.0
 * @param {string} path
 * @param {object} change
 * @return {void}
 */
function updateAttributes(change) {
    const subject = this[internalSymbol].subject.getRealSubject();
    let p = clone(change?.['path']);
    runUpdateAttributes.call(this, this[internalSymbol].element, p, subject);
}

/**
 * @private
 * @param {HTMLElement} container
 * @param {array} parts
 * @param {object} subject
 * @return {void}
 * @this Updater
 */
function runUpdateAttributes(container, parts, subject) {

    const self = this;

    if (!isArray(parts)) return;
    parts = clone(parts);

    let mem = new WeakSet;

    while (parts.length > 0) {
        const current = parts.join('.');
        parts.pop();

        let iterator = new Set;

        const query = '[' + ATTRIBUTE_UPDATER_SELECT_THIS + '], [' + ATTRIBUTE_UPDATER_ATTRIBUTES + '*="path:' + current + '"], [' + ATTRIBUTE_UPDATER_ATTRIBUTES + '^="static:"]';

        const e = container.querySelectorAll(query);

        if (e.length > 0) {
            iterator = new Set(
                [...e]
            )
        }

        if (container.matches(query)) {
            iterator.add(container);
        }

        for (const [element] of iterator.entries()) {

            if (mem.has(element)) return;
            mem.add(element)

            const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)

            for (let [, def] of Object.entries(attributes.split(','))) {
                def = trimSpaces(def);
                let i = def.indexOf(' ');
                let name = trimSpaces(def.substr(0, i));
                let cmd = trimSpaces(def.substr(i));

                let pipe = new Pipe(cmd);

                self[internalSymbol].callbacks.forEach((f, n) => {
                    pipe.setCallback(n, f, element);
                })

                let value
                try {
                    element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
                    value = pipe.run(subject)
                } catch (e) {
                    element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
                }


                if (value === undefined) {
                    element.removeAttribute(name)

                } else if (element.getAttribute(name) !== value) {
                    element.setAttribute(name, value);
                }

                handleInputControlAttributeUpdate.call(this, element, name, value);

            }
        }
    }

}

/**
 * @private
 * @param {HTMLElement|*} element
 * @param {string} name
 * @param {string|number|undefined} value
 * @return {void}
 * @this Updater
 */

function handleInputControlAttributeUpdate(element, name, value) {
    const self = this;

    if (element instanceof HTMLSelectElement) {


        switch (element.type) {
            case 'select-multiple':

                for (const [index, opt] of Object.entries(element.options)) {
                    if (value.indexOf(opt.value) !== -1) {
                        opt.selected = true;
                    } else {
                        opt.selected = false;
                    }
                }

                break;
            case 'select-one':
                // Only one value may be selected

                for (const [index, opt] of Object.entries(element.options)) {
                    if (opt.value === value) {
                        element.selectedIndex = index;
                        break;
                    }
                }

                break;
        }


    } else if (element instanceof HTMLInputElement) {
        switch (element.type) {

            case 'radio':
                if (name === 'checked') {

                    if (value !== undefined) {
                        element.checked = true;
                    } else {
                        element.checked = false;
                    }
                }

                break;

            case 'checkbox':

                if (name === 'checked') {

                    if (value !== undefined) {
                        element.checked = true;
                    } else {
                        element.checked = false;
                    }
                }

                break;
            case 'text':
            default:
                if (name === 'value') {
                    element.value = (value === undefined ? "" : value);
                }

                break;


        }
    } else if (element instanceof HTMLTextAreaElement) {
        if (name === 'value') {
            element.value = (value === undefined ? "" : value);
        }
    }

}