/**
 * Copyright schukai GmbH and contributors 2022. All Rights Reserved.
 * Node module: @schukai/monster
 * This file is licensed under the AGPLv3 License.
 * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html
 */

import {internalSymbol} from "../constants.mjs";
import {extend} from "../data/extend.mjs";
import {Pathfinder} from "../data/pathfinder.mjs";

import {parseDataURL} from "../types/dataurl.mjs";
import {getGlobalObject} from "../types/global.mjs";
import {isArray, isFunction, isObject, isString} from "../types/is.mjs";
import {Observer} from "../types/observer.mjs";
import {ProxyObserver} from "../types/proxyobserver.mjs";
import {validateFunction, validateInstance, validateObject, validateString} from "../types/validate.mjs";
import {clone} from "../util/clone.mjs";
import {addAttributeToken, addToObjectLink, getLinkedObjects, hasObjectLink} from "./attributes.mjs";
import {
    ATTRIBUTE_DISABLED,
    ATTRIBUTE_ERRORMESSAGE,
    ATTRIBUTE_OPTIONS,
    ATTRIBUTE_OPTIONS_SELECTOR,
    objectUpdaterLinkSymbol
} from "./constants.mjs";
import {findDocumentTemplate, Template} from "./template.mjs";
import {Updater} from "./updater.mjs";

export {CustomElement, initMethodSymbol, assembleMethodSymbol, attributeObserverSymbol, registerCustomElement, assignUpdaterToElement}

/**
 * @memberOf Monster.DOM
 * @type {symbol}
 */
const initMethodSymbol = Symbol('initMethodSymbol');

/**
 * @memberOf Monster.DOM
 * @type {symbol}
 */
const assembleMethodSymbol = Symbol('assembleMethodSymbol');

/**
 * this symbol holds the attribute observer callbacks. The key is the attribute name.
 * @memberOf Monster.DOM
 * @type {symbol}
 */
const attributeObserverSymbol = Symbol('attributeObserver');


/**
 * HTMLElement
 * @external HTMLElement
 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
 *
 * @startuml customelement-sequencediagram.png
 * skinparam monochrome true
 * skinparam shadowing false
 *
 * autonumber
 *
 * Script -> DOM: element = document.createElement('my-element')
 * DOM -> CustomElement: constructor()
 * CustomElement -> CustomElement: [initMethodSymbol]()
 *
 * CustomElement --> DOM: Element
 * DOM --> Script : element
 *
 *
 * Script -> DOM: document.querySelector('body').append(element)
 *
 * DOM -> CustomElement : connectedCallback()
 *
 * note right CustomElement: is only called at\nthe first connection
 * CustomElement -> CustomElement : [assembleMethodSymbol]()
 *
 * ... ...
 *
 * autonumber
 *
 * Script -> DOM: document.querySelector('monster-confirm-button').parentNode.removeChild(element)
 * DOM -> CustomElement: disconnectedCallback()
 *
 *
 * @enduml
 *
 * @startuml customelement-class.png
 * skinparam monochrome true
 * skinparam shadowing false
 * HTMLElement <|-- CustomElement
 * @enduml
 */


/**
 * To define a new HTML element we need the power of CustomElement
 *
 * IMPORTANT: after defining a `CustomElement`, the `registerCustomElement` method must be called
 * with the new class name. only then will the tag defined via the `getTag` method be made known to the DOM.
 *
 * <img src="./images/customelement-class.png">
 *
 * You can create the object via the function `document.createElement()`.
 *
 *
 * ## Interaction
 *
 * <img src="./images/customelement-sequencediagram.png">
 *
 * ## Styling
 *
 * For optimal display of custom-elements the pseudo-class :defined can be used.
 *
 * To prevent the custom elements from being displayed and flickering until the control is registered, it is recommended to create a css directive.
 *
 * In the simplest case, you can simply hide the control.
 *
 * ```
 * <style>
 *
 * my-custom-element:not(:defined) {
 *     display: none;
 * }
 *
 * my-custom-element:defined {
 *     display: flex;
 * }
 *
 * </style>
 * ```
 *
 * Alternatively you can also display a loader
 *
 * ```
 * my-custom-element:not(:defined) {
 *            display: flex;
 *            box-shadow: 0 4px 10px 0 rgba(33, 33, 33, 0.15);
 *            border-radius: 4px;
 *            height: 200px;
 *            position: relative;
 *            overflow: hidden;
 *        }
 *
 * my-custom-element:not(:defined)::before {
 *            content: '';
 *            display: block;
 *            position: absolute;
 *            left: -150px;
 *            top: 0;
 *            height: 100%;
 *            width: 150px;
 *            background: linear-gradient(to right, transparent 0%, #E8E8E8 50%, transparent 100%);
 *            animation: load 1s cubic-bezier(0.4, 0.0, 0.2, 1) infinite;
 *        }
 *
 * @keyframes load {
 *            from {
 *                left: -150px;
 *            }
 *            to   {
 *                left: 100%;
 *            }
 *        }
 *
 * my-custom-element:defined {
 *           display: flex;
 *       }
 * ```
 *
 * @externalExample ../../example/dom/theme.mjs
 * @see https://github.com/WICG/webcomponents
 * @see https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements
 * @license AGPLv3
 * @since 1.7.0
 * @copyright schukai GmbH
 * @memberOf Monster.DOM
 * @extends external:HTMLElement
 * @summary A base class for HTML5 customcontrols
 */
class CustomElement extends HTMLElement {

    /**
     * A new object is created. First the `initOptions` method is called. Here the
     * options can be defined in derived classes. Subsequently, the shadowRoot is initialized.
     *
     * @throws {Error} the options attribute does not contain a valid json definition.
     * @since 1.7.0
     */
    constructor() {
        super();
        this[internalSymbol] = new ProxyObserver({'options': extend({}, this.defaults)});
        this[attributeObserverSymbol] = {};
        initOptionObserver.call(this);
        this[initMethodSymbol]();
    }

    /**
     * This method determines which attributes are to be monitored by `attributeChangedCallback()`.
     *
     * @return {string[]}
     * @since 1.15.0
     */
    static get observedAttributes() {
        return [ATTRIBUTE_OPTIONS, ATTRIBUTE_DISABLED];
    }

    /**
     * Derived classes can override and extend this method as follows.
     *
     * ```
     * get defaults() {
     *    return Object.assign({}, super.defaults, {
     *        myValue:true
     *    });
     * }
     * ```
     *
     * To set the options via the html tag the attribute data-monster-options must be set.
     * As value a JSON object with the desired values must be defined.
     *
     * Since 1.18.0 the JSON can be specified as a DataURI.
     *
     * ```
     * new Monster.Types.DataUrl(btoa(JSON.stringify({
     *        shadowMode: 'open',
     *        delegatesFocus: true,
     *        templates: {
     *            main: undefined
     *        }
     *    })),'application/json',true).toString()
     * ```
     *
     * The attribute data-monster-options-selector can be used to access a script tag that contains additional configuration.
     *
     * As value a selector must be specified, which belongs to a script tag and contains the configuration as json.
     *
     * ```
     * <script id="id-for-this-config" type="application/json">
     *    {
     *        "config-key": "config-value"
     *    }
     * </script>
     * ```
     *
     * The individual configuration values can be found in the table.
     *
     * @property {boolean} disabled=false Object The Boolean disabled attribute, when present, makes the element not mutable, focusable, or even submitted with the form.
     * @property {string} shadowMode=open `open` Elements of the shadow root are accessible from JavaScript outside the root, for example using. `close` Denies access to the node(s) of a closed shadow root from JavaScript outside it
     * @property {Boolean} delegatesFocus=true A boolean that, when set to true, specifies behavior that mitigates custom element issues around focusability. When a non-focusable part of the shadow DOM is clicked, the first focusable part is given focus, and the shadow host is given any available :focus styling.
     * @property {Object} templates Templates
     * @property {string} templates.main=undefined Main template
     *
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow
     * @since 1.8.0
     */
    get defaults() {
        return {
            ATTRIBUTE_DISABLED: this.getAttribute(ATTRIBUTE_DISABLED),
            shadowMode: 'open',
            delegatesFocus: true,
            templates: {
                main: undefined
            }
        };
    }

    /**
     * There is no check on the name by this class. the developer is responsible for assigning an appropriate tag.
     * if the name is not valid, registerCustomElement() will issue an error
     *
     * @link https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
     * @return {string}
     * @throws {Error} the method getTag must be overwritten by the derived class.
     * @since 1.7.0
     */
    static getTag() {
        throw new Error("the method getTag must be overwritten by the derived class.");
    }

    /**
     * At this point a `CSSStyleSheet` object can be returned. If the environment does not
     * support a constructor, then an object can also be built using the following detour.
     *
     * If `undefined` is returned then the shadowRoot does not get a stylesheet.
     *
     * ```
     * const doc = document.implementation.createHTMLDocument('title');
     *
     * let style = doc.createElement("style");
     * style.innerHTML="p{color:red;}";
     *
     * // WebKit Hack
     * style.appendChild(document.createTextNode(""));
     * // Add the <style> element to the page
     * doc.head.appendChild(style);
     * return doc.styleSheets[0];
     * ;
     * ```
     *
     * @return {CSSStyleSheet|CSSStyleSheet[]|string|undefined}
     */
    static getCSSStyleSheet() {
        return undefined;
    }

    /**
     * attach a new observer
     *
     * @param {Observer} observer
     * @returns {CustomElement}
     */
    attachObserver(observer) {
        this[internalSymbol].attachObserver(observer)
        return this;
    }

    /**
     * detach a observer
     *
     * @param {Observer} observer
     * @returns {CustomElement}
     */
    detachObserver(observer) {
        this[internalSymbol].detachObserver(observer)
        return this;
    }

    /**
     * @param {Observer} observer
     * @returns {ProxyObserver}
     */
    containsObserver(observer) {
        return this[internalSymbol].containsObserver(observer)
    }

    /**
     * nested options can be specified by path `a.b.c`
     *
     * @param {string} path
     * @param {*} defaultValue
     * @return {*}
     * @since 1.10.0
     */
    getOption(path, defaultValue) {
        let value;

        try {
            value = new Pathfinder(this[internalSymbol].getRealSubject()['options']).getVia(path);
        } catch (e) {

        }

        if (value === undefined) return defaultValue;
        return value;
    }

    /**
     * Set option and inform elements
     *
     * @param {string} path
     * @param {*} value
     * @return {CustomElement}
     * @since 1.14.0
     */
    setOption(path, value) {
        new Pathfinder(this[internalSymbol].getSubject()['options']).setVia(path, value);
        return this;
    }

    /**
     * @since 1.15.0
     * @param {string|object} options
     * @return {CustomElement}
     */
    setOptions(options) {

        if (isString(options)) {
            options = parseOptionsJSON.call(this, options)
        }

        const self = this;
        extend(self[internalSymbol].getSubject()['options'], self.defaults, options);

        return self;
    }

    /**
     * Is called once via the constructor
     *
     * @return {CustomElement}
     * @since 1.8.0
     */
    [initMethodSymbol]() {
        return this;
    }

    /**
     * Is called once when the object is included in the DOM for the first time.
     *
     * @return {CustomElement}
     * @since 1.8.0
     */
    [assembleMethodSymbol]() {

        const self = this;
        let elements, nodeList;

        const AttributeOptions = getOptionsFromAttributes.call(self);
        if (isObject(AttributeOptions) && Object.keys(AttributeOptions).length > 0) {
            self.setOptions(AttributeOptions);
        }

        const ScriptOptions = getOptionsFromScriptTag.call(self);
        if (isObject(ScriptOptions) && Object.keys(ScriptOptions).length > 0) {
            self.setOptions(ScriptOptions);
        }


        if (self.getOption('shadowMode', false) !== false) {
            try {
                initShadowRoot.call(self);
                elements = self.shadowRoot.childNodes;

            } catch (e) {

            }

            try {
                initCSSStylesheet.call(this);
            } catch (e) {
                addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, e.toString());
            }
        }

        if (!(elements instanceof NodeList)) {
            if (!(elements instanceof NodeList)) {
                initHtmlContent.call(this);
                elements = this.childNodes;
            }
        }

        try {
            nodeList = new Set([
                ...elements,
                ...getSlottedElements.call(self)
            ])
        } catch (e) {
            nodeList = elements
        }

        assignUpdaterToElement.call(self, nodeList, clone(self[internalSymbol].getRealSubject()['options']));
        return self;
    }

    /**
     * Called every time the element is inserted into the DOM. Useful for running setup code, such as
     * fetching resources or rendering. Generally, you should try to delay work until this time.
     *
     * @return {void}
     * @since 1.7.0
     */
    connectedCallback() {
        let self = this;
        if (!hasObjectLink(self, objectUpdaterLinkSymbol)) {
            self[assembleMethodSymbol]()
        }
    }

    /**
     * Called every time the element is removed from the DOM. Useful for running clean up code.
     *
     * @return {void}
     * @since 1.7.0
     */
    disconnectedCallback() {

    }

    /**
     * The custom element has been moved into a new document (e.g. someone called document.adoptNode(el)).
     *
     * @return {void}
     * @since 1.7.0
     */
    adoptedCallback() {

    }

    /**
     * Called when an observed attribute has been added, removed, updated, or replaced. Also called for initial
     * values when an element is created by the parser, or upgraded. Note: only attributes listed in the observedAttributes
     * property will receive this callback.
     *
     * @param {string} attrName
     * @param {string} oldVal
     * @param {string} newVal
     * @return {void}
     * @since 1.15.0
     */
    attributeChangedCallback(attrName, oldVal, newVal) {
        const self = this;

        const callback = self[attributeObserverSymbol]?.[attrName];

        if (isFunction(callback)) {
            callback.call(self, newVal, oldVal);
        }

    }

    /**
     *
     * @param {Node} node
     * @return {boolean}
     * @throws {TypeError} value is not an instance of
     * @since 1.19.0
     */
    hasNode(node) {
        const self = this;

        if (containChildNode.call(self, validateInstance(node, Node))) {
            return true;
        }

        if (!(self.shadowRoot instanceof ShadowRoot)) {
            return false;
        }

        return containChildNode.call(self.shadowRoot, node);

    }

}

/**
 * @private
 * @param {String|undefined} query
 * @param {String|undefined|null} name name of the slot (if the parameter is undefined, all slots are searched, if the parameter has the value null, all slots without a name are searched. if a string is specified, the slots with this name are searched.)
 * @return {*}
 * @this CustomElement
 * @license AGPLv3
 * @since 1.23.0
 * @throws {Error} query must be a string
 */
function getSlottedElements(query, name) {
    const self = this;
    const result = new Set;

    if (!(self.shadowRoot instanceof ShadowRoot)) {
        return result;
    }

    let selector = 'slot';
    if (name !== undefined) {
        if (name === null) {
            selector += ':not([name])';
        } else {
            selector += '[name=' + validateString(name) + ']';
        }

    }

    const slots = self.shadowRoot.querySelectorAll(selector);

    for (const [, slot] of Object.entries(slots)) {
        slot.assignedElements().forEach(function (node) {

            if (!(node instanceof HTMLElement)) return;

            if (isString(query)) {
                node.querySelectorAll(query).forEach(function (n) {
                    result.add(n);
                });

                if (node.matches(query)) {
                    result.add(node);
                }

            } else if (query !== undefined) {
                throw new Error('query must be a string')
            } else {
                result.add(node);
            }
        })
    }

    return result;
}

/**
 * @this CustomElement
 * @private
 * @param {Node} node
 * @return {boolean}
 */
function containChildNode(node) {
    const self = this;

    if (self.contains(node)) {
        return true;
    }

    for (const [, e] of Object.entries(self.childNodes)) {
        if (e.contains(node)) {
            return true;
        }

        containChildNode.call(e, node);
    }


    return false;
}

/**
 * @license AGPLv3
 * @since 1.15.0
 * @private
 * @this CustomElement
 */
function initOptionObserver() {
    const self = this;

    let lastDisabledValue = undefined;
    self.attachObserver(new Observer(function () {
        const flag = self.getOption('disabled');

        if (flag === lastDisabledValue) {
            return;
        }

        lastDisabledValue = flag;

        if (!(self.shadowRoot instanceof ShadowRoot)) {
            return;
        }

        const query = 'button, command, fieldset, keygen, optgroup, option, select, textarea, input, [data-monster-objectlink]';
        const elements = self.shadowRoot.querySelectorAll(query);

        let nodeList;
        try {
            nodeList = new Set([
                ...elements,
                ...getSlottedElements.call(self, query)
            ])
        } catch (e) {
            nodeList = elements
        }

        for (const element of [...nodeList]) {
            if (flag === true) {
                element.setAttribute(ATTRIBUTE_DISABLED, '');
            } else {
                element.removeAttribute(ATTRIBUTE_DISABLED);
            }
        }

    }));

    self.attachObserver(new Observer(function () {

        // not initialised
        if (!hasObjectLink(self, objectUpdaterLinkSymbol)) {
            return;
        }
        // inform every element
        const updaters = getLinkedObjects(self, objectUpdaterLinkSymbol);

        for (const list of updaters) {
            for (const updater of list) {
                let d = clone(self[internalSymbol].getRealSubject()['options']);
                Object.assign(updater.getSubject(), d);
            }
        }

    }));

    // disabled
    self[attributeObserverSymbol][ATTRIBUTE_DISABLED] = () => {
        if (self.hasAttribute(ATTRIBUTE_DISABLED)) {
            self.setOption(ATTRIBUTE_DISABLED, true);
        } else {
            self.setOption(ATTRIBUTE_DISABLED, undefined);
        }
    }

    // data-monster-options
    self[attributeObserverSymbol][ATTRIBUTE_OPTIONS] = () => {
        const options = getOptionsFromAttributes.call(self);
        if (isObject(options) && Object.keys(options).length > 0) {
            self.setOptions(options);
        }
    }

    // data-monster-options-selector
    self[attributeObserverSymbol][ATTRIBUTE_OPTIONS_SELECTOR] = () => {
        const options = getOptionsFromScriptTag.call(self);
        if (isObject(options) && Object.keys(options).length > 0) {
            self.setOptions(options);
        }
    }


}

/**
 * @private
 * @return {object}
 * @throws {TypeError} value is not a object
 */
function getOptionsFromScriptTag() {
    const self = this;

    if (!self.hasAttribute(ATTRIBUTE_OPTIONS_SELECTOR)) {
        return {};
    }

    const node = document.querySelector(self.getAttribute(ATTRIBUTE_OPTIONS_SELECTOR));
    if (!(node instanceof HTMLScriptElement)) {
        addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, 'the selector ' + ATTRIBUTE_OPTIONS_SELECTOR + ' for options was specified (' + self.getAttribute(ATTRIBUTE_OPTIONS_SELECTOR) + ') but not found.');
        return {};
    }

    let obj = {};

    try {
        obj = parseOptionsJSON.call(this, node.textContent.trim())
    } catch (e) {
        addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, 'when analyzing the configuration from the script tag there was an error. ' + e);
    }

    return obj;

}

/**
 * @private
 * @return {object}
 */
function getOptionsFromAttributes() {
    const self = this;

    if (this.hasAttribute(ATTRIBUTE_OPTIONS)) {
        try {
            return parseOptionsJSON.call(self, this.getAttribute(ATTRIBUTE_OPTIONS))
        } catch (e) {
            addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, 'the options attribute ' + ATTRIBUTE_OPTIONS + ' does not contain a valid json definition (actual: ' + this.getAttribute(ATTRIBUTE_OPTIONS) + ').' + e);
        }
    }

    return {};
}

/**
 * @private
 * @param data
 * @return {Object}
 */
function parseOptionsJSON(data) {

    const self = this, obj = {};

    if (!isString(data)) {
        return obj;
    }

    // the configuration can be specified as a data url.
    try {
        let dataUrl = parseDataURL(data);
        data = dataUrl.content;
    } catch (e) {

    }

    try {
        let obj = JSON.parse(data);
        return validateObject(obj);
    } catch (e) {
        throw e;
    }


    return obj;
}

/**
 * @private
 * @return {initHtmlContent}
 */
function initHtmlContent() {

    try {
        let template = findDocumentTemplate(this.constructor.getTag());
        this.appendChild(template.createDocumentFragment());
    } catch (e) {

        let html = this.getOption('templates.main', '');
        if (isString(html) && html.length > 0) {
            this.innerHTML = html;
        }

    }

    return this;

}

/**
 * @private
 * @return {CustomElement}
 * @memberOf Monster.DOM
 * @this CustomElement
 * @license AGPLv3
 * @since 1.16.0
 * @throws {TypeError} value is not an instance of
 */
function initCSSStylesheet() {
    const self = this;

    if (!(this.shadowRoot instanceof ShadowRoot)) {
        return self;
    }

    const styleSheet = this.constructor.getCSSStyleSheet();

    if (styleSheet instanceof CSSStyleSheet) {
        if (styleSheet.cssRules.length > 0) {
            this.shadowRoot.adoptedStyleSheets = [styleSheet];
        }
    } else if (isArray(styleSheet)) {
        const assign = [];
        for (let s of styleSheet) {

            if (isString(s)) {
                let trimedStyleSheet = s.trim()
                if (trimedStyleSheet !== '') {
                    const style = document.createElement('style')
                    style.innerHTML = trimedStyleSheet;
                    self.shadowRoot.prepend(style);
                }
                continue;
            }

            validateInstance(s, CSSStyleSheet);

            if (s.cssRules.length > 0) {
                assign.push(s);
            }

        }

        if (assign.length > 0) {
            this.shadowRoot.adoptedStyleSheets = assign;
        }

    } else if (isString(styleSheet)) {

        let trimedStyleSheet = styleSheet.trim()
        if (trimedStyleSheet !== '') {
            const style = document.createElement('style')
            style.innerHTML = styleSheet;
            self.shadowRoot.prepend(style);
        }

    }

    return self;

}

/**
 * @private
 * @return {CustomElement}
 * @throws {Error} html is not set.
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow
 * @memberOf Monster.DOM
 * @license AGPLv3
 * @since 1.8.0
 */
function initShadowRoot() {

    let template, html;

    try {
        template = findDocumentTemplate(this.constructor.getTag());
    } catch (e) {

        html = this.getOption('templates.main', '');
        if (!isString(html) || html === undefined || html === "") {
            throw new Error("html is not set.");
        }

    }

    this.attachShadow({
        mode: this.getOption('shadowMode', 'open'),
        delegatesFocus: this.getOption('delegatesFocus', true)
    });

    if (template instanceof Template) {
        this.shadowRoot.appendChild(template.createDocumentFragment());
        return this;
    }

    this.shadowRoot.innerHTML = html;
    return this;
}

/**
 * This method registers a new element. The string returned by `CustomElement.getTag()` is used as the tag.
 *
 * @param {CustomElement} element
 * @return {void}
 * @license AGPLv3
 * @since 1.7.0
 * @copyright schukai GmbH
 * @memberOf Monster.DOM
 * @throws {DOMException} Failed to execute 'define' on 'CustomElementRegistry': is not a valid custom element name
 */
function registerCustomElement(element) {
    validateFunction(element);
    getGlobalObject('customElements').define(element.getTag(), element);
}


/**
 *
 * @param element
 * @param object
 * @return {Promise[]}
 * @license AGPLv3
 * @since 1.23.0
 * @memberOf Monster.DOM
 */
function assignUpdaterToElement(elements, object) {

    const updaters = new Set;

    if (elements instanceof NodeList) {
        elements = new Set([
            ...elements
        ])
    }

    let result = [];

    elements.forEach((element) => {
        if (!(element instanceof HTMLElement)) return;
        if ((element instanceof HTMLTemplateElement)) return;

        const u = new Updater(element, object)
        updaters.add(u);

        result.push(u.run().then(() => {
            return u.enableEventProcessing();
        }));

    });

    if (updaters.size > 0) {
        addToObjectLink(this, objectUpdaterLinkSymbol, updaters);
    }

    return result;
}