/**
 * @package         Regular.js
 * @description     A light and simple JavaScript Library
 *
 * @author          Peter van Westen <info@regularlabs.com>
 * @link            https://github.com/regularlabs/regularjs
 * @copyright       Copyright © 2024 Regular Labs - All Rights Reserved
 * @license         https://github.com/regularlabs/regularjs/blob/master/LICENCE MIT
 */

"use strict";

if (typeof window.Regular === 'undefined'
    || typeof Regular.version === 'undefined'
    || Regular.version < 1.5) {

    window.Regular = new function() {
        /**
         *
         * PUBLIC PROPERTIES
         *
         */

        this.version = 1.5;

        /**
         *
         * PUBLIC METHODS
         *
         */

        /**
         * Sets a global alias for the Regular class.
         *
         * @param word  A string (character or word) representing the alias for the Regular class.
         *
         * @return boolean
         */
        this.alias = function(word) {
            if (typeof window[word] !== 'undefined') {
                console.error(`Cannot set '${word}' as an alias of Regular, as it already exists.`);

                return false;
            }

            window[word] = $;

            return true;
        };

        /**
         * Returns a boolean based on whether the element contains one or more of the given class names.
         *
         * @param selector  A CSS selector string or a HTMLElement object.
         * @param classes   A string or array of class names.
         * @param matchAll  Optional boolean whether the element should have all given classes (true) or at least one (false).
         *
         * @return boolean
         */
        this.hasClasses = function(selector, classes, matchAll = true) {
            if ( ! selector) {
                return false;
            }

            const element = typeof selector === 'string'
                ? document.querySelector(selector)
                : selector;

            if ( ! element) {
                return false;
            }

            if (typeof classes === 'string') {
                classes = classes.split(' ');
            }

            let hasClass = false;

            for (const clss of classes) {
                hasClass = element.classList.contains(clss);

                if (matchAll && ! hasClass) {
                    return false;
                }

                if ( ! matchAll && hasClass) {
                    return true;
                }
            }

            return hasClass;
        };

        /**
         * Adds given class name(s) to the element(s).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param classes   A string or array of class names.
         */
        this.addClasses = function(selector, classes) {
            doClasses('add', selector, classes);
        };

        /**
         * Removes given class name(s) from the element(s).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param classes   A string or array of class names.
         */
        this.removeClasses = function(selector, classes) {
            doClasses('remove', selector, classes);
        };

        /**
         * Toggles given class name(s) of the element(s).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param classes   A string or array of class names.
         * @param force     An optional boolean value that forces the class to be added or removed.
         */
        this.toggleClasses = function(selector, classes, force) {
            switch (force) {
                case true:
                    doClasses('add', selector, classes);
                    break;

                case false:
                    doClasses('remove', selector, classes);
                    break;

                default:
                    doClasses('toggle', selector, classes);
                    break;
            }
        };

        /**
         * Makes the given element(s) visible (changes visibility and display attributes).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         */
        this.makeVisible = function(selector) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            if ('forEach' in element) {
                element.forEach(subElement => $.makeVisible(subElement));
                return;
            }

            let computedDisplay = getComputedStyle(element, 'display');

            if ( ! ('origDisplay' in element)) {
                element.origDisplay = computedDisplay === 'none'
                    ? getDefaultComputedStyle(element, 'display')
                    : computedDisplay;
            }

            if (element.origDisplay) {
                element.style.display = element.origDisplay;
                element.style.visibility = 'visible';
                return;
            }

            if (computedDisplay === 'none') {
                element.style.display = ('origDisplay' in element) ? element.origDisplay : '';
            }

            let elementType = element.nodeName.toLowerCase();

            switch (elementType) {
                case 'tr':
                    elementType = 'table-row';
                    break;
                case 'td':
                case 'th':
                    elementType = 'table-cell';
                    break;
                case 'caption':
                    elementType = 'table-caption';
                    break;
                case 'col':
                    elementType = 'table-column';
                    break;
                case 'colgroup':
                    elementType = 'table-column-group';
                    break;
                case 'table':
                    elementType = 'table';
                    break;
                case 'thead':
                    elementType = 'table-header-group';
                    break;
                case 'tbody':
                    elementType = 'table-row-group';
                    break;
                case 'tfoot':
                    elementType = 'table-footer-group';
                    break;
                default:
                    elementType = 'block';
                    break;
            }

            element.style.display    = elementType;
            element.style.visibility = 'visible';
        };

        /**
         * Shows the given element(s) (makes visible and changes opacity attribute).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         */
        this.show = function(selector) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            if ('forEach' in element) {
                element.forEach(subElement => $.show(subElement));
                return;
            }

            this.makeVisible(element);

            element.style.opacity = 1;
        };

        /**
         * Hides the given element(s) (changes opacity and display attributes).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         */
        this.hide = function(selector) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            if ('forEach' in element) {
                element.forEach(subElement => $.hide(subElement));
                return;
            }

            const computedDisplay = getComputedStyle(element, 'display');

            if (computedDisplay !== 'none' && ! ('origDisplay' in element)) {
                element.origDisplay = computedDisplay;
            }

            element.style.display    = 'none';
            element.style.visibility = 'hidden';
            element.style.opacity    = 0;
        };

        /**
         * Shows or hides the given element(s).
         *
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param force     An optional boolean value that forces the class to be added or removed.
         */
        this.toggle = function(selector, force) {
            if ( ! selector) {
                return;
            }

            switch (force) {
                case true:
                    $.show(selector);
                    break;

                case false:
                    $.hide(selector);
                    break;

                default:
                    const element = typeof selector === 'string'
                        ? document.querySelectorAll(selector)
                        : selector;

                    if ('forEach' in element) {
                        element.forEach(subElement => $.toggle(subElement));
                        return;
                    }

                    element.style.display === 'none' ? $.show(selector) : $.hide(selector);
                    break;
            }
        };

        /**
         * Fades in the given element(s).
         *
         * @param selector    A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param duration    Optional duration of the effect in milliseconds.
         * @param oncomplete  Optional callback function to execute when effect is completed.
         */
        this.fadeIn = function(selector, duration = 250, oncomplete) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            this.makeVisible(element);

            $.fadeTo(
                element,
                1,
                duration,
                () => {
                    $.show(element);
                    if (oncomplete) {
                        oncomplete.call(element);
                    }
                }
            );
        };

        /**
         * Fades out the given element(s).
         *
         * @param selector    A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param duration    Optional duration of the effect in milliseconds.
         * @param oncomplete  Optional callback function to execute when effect is completed.
         */
        this.fadeOut = function(selector, duration = 250, oncomplete) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            $.fadeTo(
                element,
                0,
                duration,
                () => {
                    $.hide(element);
                    if (oncomplete) {
                        oncomplete.call(element);
                    }
                }
            );
        };

        /**
         * Fades out the given element(s).
         *
         * @param selector    A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param opacity     Opacity Value to fade to
         * @param duration    Optional duration of the effect in milliseconds.
         * @param oncomplete  Optional callback function to execute when effect is completed.
         */
        this.fadeTo = function(selector, opacity, duration = 250, oncomplete) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            if ('forEach' in element) {
                element.forEach(subElement => $.fadeTo(subElement, opacity, duration));
                return;
            }

            const wait        = 50; // amount of time between steps
            const nr_of_steps = duration / wait;
            const change      = 1 / nr_of_steps; // time to wait before next step

            element.style.opacity = getComputedStyle(element, 'opacity');

            if (opacity === element.style.opacity) {
                element.setAttribute('data-fading', '');

                if (oncomplete) {
                    oncomplete.call(element);
                }

                return;
            }

            this.makeVisible(element);

            const direction = opacity > element.style.opacity ? 'in' : 'out';

            element.setAttribute('data-fading', direction);

            (function fade() {
                if (element.getAttribute('data-fading')
                    && element.getAttribute('data-fading') !== direction
                ) {
                    return;
                }

                const new_opacity = direction === 'out'
                    ? parseFloat(element.style.opacity) - change
                    : parseFloat(element.style.opacity) + change;

                if ((direction === 'in' && new_opacity >= opacity)
                    || (direction === 'out' && new_opacity <= opacity)
                ) {
                    element.style.opacity = opacity;

                    element.setAttribute('data-fading', '');

                    if (oncomplete) {
                        oncomplete.call(element);
                    }

                    return;
                }

                element.style.opacity = new_opacity;

                setTimeout(() => {
                    fade.call();
                }, wait);
            })();
        };

        /**
         * Runs a function when the document is loaded (on ready state).
         *
         * @param func  Callback function to execute when document is ready.
         */
        this.onReady = function(func) {
            document.addEventListener('DOMContentLoaded', func);
        };

        /**
         * Converts a string with HTML code to 'DOM' elements.
         *
         * @param html  String with HTML code.
         *
         * @return element
         */
        this.createElementFromHTML = function(html) {
            return document.createRange().createContextualFragment(html);
        };

        /**
         * Loads a url with optional POST data and optionally calls a function on success or fail.
         *
         * @param url      String containing the url to load.
         * @param data     Optional string representing the POST data to send along.
         * @param success  Optional callback function to execute when the url loads successfully (status 200).
         * @param fail     Optional callback function to execute when the url fails to load.
         */
        this.loadUrl = function(url, data, success, fail) {
            const request = new XMLHttpRequest();

            request.open('POST', url, true);

            request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');

            request.onreadystatechange = function() {
                if (this.readyState !== 4) {
                    return;
                }

                if (this.status === 200) {
                    success && success.call(null, this.responseText, this.status, this);
                    return;
                }

                fail && fail.call(null, this.responseText, this.status, this);
            };


            request.send(this.toUrlQueryString(data));
        };

        /**
         * Converts a data object (key, value) to a serialized query string.
         *
         * @param data    The object with the data to serialize.
         * @param prefix  An Optional prefix.
         */
        this.toUrlQueryString = function(data, prefix) {
            if (typeof data !== 'object') {
                return data;
            }

            const parts = [];

            if ( ! (Symbol.iterator in Object(data))) {
                data = Object.entries(data);
            }

            for (let i in data) {
                let value = data[i];
                let name  = '';

                if (value instanceof Array) {
                    [name, value] = value;
                }

                let key = name ? (prefix ? `${prefix}[${name}]` : name) : prefix;

                if ( ! key) {
                    continue;
                }

                if (value !== null && typeof value === 'object') {
                    if (value instanceof Array) {
                        key += '[]';
                    }

                    parts.push(this.toUrlQueryString(value, key));
                    continue;
                }

                parts.push(`${key}=${value}`);
            }

            return parts.join('&');
        };

        /**
         *
         * ALIASES
         *
         */

        this.as          = this.alias;
        this.hasClass    = this.hasClasses;
        this.addClass    = this.addClasses;
        this.removeClass = this.removeClasses;
        this.toggleClass = this.toggleClasses;

        /**
         *
         * PRIVATE FUNCTIONS
         *
         */

        /**
         * Executes an action on the element(s) to add/remove/toggle classes.
         *
         * @param action    A string that identifies the action: add|remove|toggle.
         * @param selector  A CSS selector string, a HTMLElement object or a collection of HTMLElement objects.
         * @param classes   A string or array of class names.
         */
        const doClasses = function(action, selector, classes) {
            if ( ! selector) {
                return;
            }

            const element = typeof selector === 'string'
                ? document.querySelectorAll(selector)
                : selector;

            if ('forEach' in element) {
                element.forEach(subElement => doClasses(action, subElement, classes));
                return;
            }

            if (typeof classes === 'string') {
                classes = classes.split(' ');
            }

            element.classList[action](...classes);
        };

        /**
         * Finds the computed style of an element.
         *
         * @param element   A HTMLElement object.
         * @param property  The style property that needs to be returned.
         *
         * @returns mixed
         */
        const getComputedStyle = function(element, property) {
            if ( ! element) {
                return null;
            }

            return window.getComputedStyle(element).getPropertyValue(property);
        };

        /**
         * Finds the default computed style of an element by its type.
         *
         * @param element   A HTMLElement object.
         * @param property  The style property that needs to be returned.
         *
         * @returns mixed
         */
        const getDefaultComputedStyle = function(element, property) {
            if ( ! element) {
                return null;
            }

            const defaultElement = document.createElement(element.nodeName);

            document.body.append(defaultElement);
            let propertyValue = window.getComputedStyle(defaultElement).getPropertyValue(property);
            defaultElement.remove();

            return propertyValue;
        };

        /**
         *
         * PRIVATE VARIABLES
         *
         */

        /**
         * @param  $  internal shorthand for the 'this' keyword.
         */
        const $ = this;
    };
}
