/*
Copyright The Infusion copyright holders
See the AUTHORS.md file at the top-level directory of this distribution and at
https://github.com/fluid-project/infusion/raw/main/AUTHORS.md.

Licensed under the Educational Community License (ECL), Version 2.0 or the New
BSD license. You may not use this file except in compliance with one these
Licenses.

You may obtain a copy of the ECL 2.0 License and BSD License at
https://github.com/fluid-project/infusion/raw/main/Infusion-LICENSE.txt
*/

/* This file contains functions which depend on the presence of a markup document
 * somehow represented and which depend on the contents of Fluid.js
 */

"use strict";

/** Invoke the supplied function on the supplied arguments
 * @param {Object} options - A structure encoding a function invocation
 * @param {Function} options.func - The function to be invoked
 * @param {Array} options.args - The arguments on which the function is to be invoked
 * @return {Any} The return value from the function invocation
 */
fluid.apply = function (options) {
    return options.func.apply(null, options.args);
};

// A "proto-viewComponent" which simply defines a DOM binder and is agnostic as to how its container is defined
// Temporary factoring artefact which will most likely go away/be improved once new renderer has stabilised
fluid.defaults("fluid.baseViewComponent", {
    gradeNames: "fluid.component",
    argumentMap: {
        container: 0,
        options: 1
    },
    events: {
        onDomBind: "promise"
    },
    // Note that we apply the same timing as the pref's framework's panels for old-style components, to avoid
    // confusing its timing before it is rewritten, which has a whole bunch of createOnEvent: "onDomBind" subcomponents.
    // The renderer and all modern components will fire this in a timely way when the DOM binder is actually constructed
    listeners: {
        "onCreate.onDomBind": "{that}.events.onDomBind"
    },
    selectors: {
    },
    members: {
        dom: "@expand:fluid.createDomBinder({that}.container, {that}.options.selectors)",
        locate: "{that}.dom.locate"
    },
    // mergePolicy allows these members to be cleanly overridden, avoiding FLUID-5668
    mergePolicy: {
        "members.dom": "replace",
        "members.container": "replace"
    }
});

fluid.defaults("fluid.viewComponent", {
    gradeNames: ["fluid.modelComponent", "fluid.baseViewComponent"],
    members: {
        container: "@expand:fluid.containerForViewComponent({that}, {that}.options.container)"
    }
});

// unsupported, NON-API function
fluid.dumpSelector = function (selectable) {
    return typeof(selectable) === "string" ? selectable :
        selectable.selector ? selectable.selector : "";
};

fluid.checkTryCatchParameter = function () {
    var location = window.location || { search: "", protocol: "file:" };
    var GETparams = location.search.slice(1).split("&");
    return fluid.find(GETparams, function (param) {
        if (param.indexOf("notrycatch") === 0) {
            return true;
        }
    }) === true;
};

fluid.notrycatch = fluid.checkTryCatchParameter();


/**
 * Wraps an object in a jQuery if it isn't already one. This function is useful since
 * it ensures to wrap a null or otherwise falsy argument to itself, rather than the
 * often unhelpful jQuery default of returning the overall document node.
 *
 * @param {Object} obj - the object to wrap in a jQuery
 * @param {jQuery} [userJQuery] - the jQuery object to use for the wrapping, optional - use the current jQuery if absent
 * @return {jQuery} - The wrapped object.
 */
fluid.wrap = function (obj, userJQuery) {
    userJQuery = userJQuery || $;
    return ((!obj || obj.jquery) ? obj : userJQuery(obj));
};

/**
 * If obj is a jQuery, this function will return the first DOM element within it. Otherwise, the object will be returned unchanged.
 *
 * @param {jQuery} obj - The jQuery instance to unwrap into a pure DOM element.
 * @return {Object} - The unwrapped object.
 */
fluid.unwrap = function (obj) {
    return obj && obj.jquery ? obj[0] : obj;
};

/**
 * Fetches a single container element and returns it as a jQuery.
 *
 * @param {String|jQuery|Element} containerSpec - an selector, a single-element jQuery, or a DOM element specifying a unique container
 * @param {Boolean} fallible - <code>true</code> if an empty container is to be reported as a valid condition
 * @param {jQuery} [userJQuery] - the jQuery object to use for the wrapping, optional - use the current jQuery if absent
 * @return {jQuery} - A single-element jQuery container.
 */
fluid.container = function (containerSpec, fallible, userJQuery) {
    if (!containerSpec) {
        fluid.fail("fluid.container argument is empty");
    }
    var selector = containerSpec.selector || containerSpec;
    if (userJQuery) {
        containerSpec = fluid.unwrap(containerSpec);
    }
    var container = fluid.wrap(containerSpec, userJQuery);
    if (fallible && (!container || container.length === 0)) {
        return null;
    }

    if (!container || !container.jquery || container.length !== 1) {
        // TODO: This boneheaded overwriting of our arguments prevents plain view components appearing as children of renderer components -
        // unless of course it has already overwritten the DOM binder's cache value - doesn't seem possible. A renderer component could
        // of course broadcast an addon grade to all its viewComponent children overriding their container resolver
        if (typeof(containerSpec) !== "string") {
            containerSpec = container.selector;
        }
        var count = container.length !== undefined ? container.length : 0;
        // TODO: "in context" confusingly reports "undefined" in the usual case that containerSpec is just a selector
        var extraMessage = container.selectorName ? " with selector name " + container.selectorName +
            " in context " + fluid.dumpEl(containerSpec.context) : "";
        fluid.fail((count > 1 ? "More than one (" + count + ") container elements were" :
            "No container element was") + " found for selector " + containerSpec + extraMessage );
    }
    if (!fluid.isDOMNode(container[0])) {
        fluid.fail("fluid.container was supplied a non-jQueryable element");
    }

    // To address FLUID-5966, manually adding back the selector and context properties that were removed from jQuery v3.0.
    // ( see: https://jquery.com/upgrade-guide/3.0/#breaking-change-deprecated-context-and-selector-properties-removed )
    // In most cases the "selector" property will already be restored through the DOM binder;
    // however, when a selector or pure jQuery element is supplied directly as a component's container, we need to add them
    // if it is possible to infer them. This feature is rarely used but is crucial for the prefs framework infrastructure
    // in Panels.js fluid.prefs.subPanel.resetDomBinder
    container.selector = selector;
    container.context = container.context || containerSpec.ownerDocument || document;

    return container;
};

/**
 * Creates a new DOM Binder instance, used to locate elements in the DOM by name.
 *
 * @param {Object} container - the root element in which to locate named elements
 * @param {Object} selectors - a collection of named jQuery selectors
 * @return {Object} - The new DOM binder.
 */
fluid.createDomBinder = function (container, selectors) {
    var userJQuery = container.constructor;
    var that = {
        container: container,
        id: fluid.allocateGuid(),
        doQuery: function (selector) {
            return userJQuery(selector, that.container);
        },
        cache: {}
    };

    that.locate = function (selectorName) {
        var selector = selectorName === "container" ? "" : selectors[selectorName];
        if (selector === undefined) {
            fluid.fail("DOM binder request for selector " + selectorName + " which is not registered");
        }
        var togo;
        if (selector === "") {
            togo = that.container;
        } else {
            togo = that.doQuery(selector, selectorName);
        }
        // These hacks are still required since fluid.prefs.subPanel.resetDomBinder egregiously reads them off the panel container
        togo.selector = selector;
        togo.context = that.container;

        togo.selectorName = selectorName;
        that.cache[selectorName] = togo;
        return togo;
    };
    that.fastLocate = function (selectorName) {
        return that.cache[selectorName] || that.locate(selectorName);
    };
    that.resetContainer = function (container) {
        that.container = container;
        that.clear();
    };
    that.clear = function () {
        that.cache = {};
    };
    that.resolvePathSegment = that.locate;

    return that;
};

/**
 * Creates a new "local container"-capable DOM Binder instance, used to locate elements in the DOM by name. This
 * is a historical contract for the DOM binder which was used by two components, the FileQueueView and the Reorderer.
 * A simpler contract has been extracted in order to be compatible with future notions of the DOM binder used by
 * the "new" FLUID-6580 renderer.
 *
 * @param {Object} container - the root element in which to locate named elements
 * @param {Object} selectors - a collection of named jQuery selectors
 * @return {Object} - The new DOM binder.
 */
fluid.createLocalContainerDomBinder = function (container, selectors) {
    var that = {
        container: container,
        id: fluid.allocateGuid(),
        cache: {}
    };
    var userJQuery = container.constructor;

    function cacheKey(name, thisContainer) {
        return fluid.allocateSimpleId(thisContainer) + "-" + name;
    }

    function record(name, thisContainer, result) {
        that.cache[cacheKey(name, thisContainer)] = result;
    }

    that.locate = function (name, localContainer) {
        var selector, thisContainer, togo;

        selector = selectors[name];
        if (selector === undefined) {
            if (name === "container") {
                selector = "";
            } else {
                fluid.fail("DOM binder request for selector " + name + " which is not registered");
            }
        }
        thisContainer = localContainer || that.container;
        if (!thisContainer) {
            fluid.fail("DOM binder invoked for selector " + name + " without container");
        }
        if (selector === "") {
            togo = userJQuery(thisContainer);
        }
        else {
            if (typeof(selector) === "function") {
                togo = userJQuery(selector.call(null, fluid.unwrap(thisContainer)));
            } else {
                togo = userJQuery(selector, thisContainer);
            }
        }

        if (!togo.selector) {
            togo.selector = selector;
            togo.context = thisContainer;
        }
        togo.selectorName = name;
        record(name, thisContainer, togo);
        return togo;
    };
    that.fastLocate = function (name, localContainer) {
        var thisContainer = localContainer ? localContainer : that.container;
        var key = cacheKey(name, thisContainer);
        var togo = that.cache[key];
        return togo ? togo : that.locate(name, localContainer);
    };
    that.resetContainer = function (container) {
        that.container = container;
        that.clear();
    };
    that.clear = function () {
        that.cache = {};
    };
    that.refresh = function (names, localContainer) {
        var thisContainer = localContainer ? localContainer : that.container;
        if (typeof names === "string") {
            names = [names];
        }
        if (thisContainer.length === undefined) {
            thisContainer = [thisContainer];
        }
        for (var i = 0; i < names.length; ++i) {
            for (var j = 0; j < thisContainer.length; ++j) {
                that.locate(names[i], thisContainer[j]);
            }
        }
    };
    that.resolvePathSegment = that.locate;

    return that;
};

/* Expect that jQuery selector query has resulted in a non-empty set of
 * results. If none are found, this function will fail with a diagnostic message,
 * with the supplied message prepended.
 */
fluid.expectFilledSelector = function (result, message) {
    if (result && result.length === 0 && result.jquery) {
        fluid.fail(message + ": selector \"" + result.selector + "\" with name " + result.selectorName +
                   " returned no results in context " + fluid.dumpEl(result.context));
    }
};

fluid.containerForViewComponent = function (that, containerSpec) {
    var container = fluid.container(containerSpec);
    fluid.expectFilledSelector(container, "Error instantiating viewComponent at path \"" + fluid.pathForComponent(that));
    return container;
};


/**
 * Returns the id attribute from a jQuery or pure DOM element.
 *
 * @param {jQuery|Element} element - the element to return the id attribute for.
 * @return {String} - The id attribute of the element.
 */
fluid.getId = function (element) {
    return fluid.unwrap(element).id;
};

/*
 * Allocate an id to the supplied element if it has none already, by a simple
 * scheme resulting in ids "fluid-id-nnnn" where nnnn is an increasing integer.
 */
fluid.allocateSimpleId = function (element) {
    element = fluid.unwrap(element);
    if (!element || fluid.isPrimitive(element)) {
        return null;
    }

    if (!element.id) {
        var simpleId = "fluid-id-" + fluid.allocateGuid();
        element.id = simpleId;
    }
    return element.id;
};

fluid.registerNamespace("fluid.materialisers");

fluid.makeDomMaterialiserManager = function () {
    // In future we will want to track listeners and other elements in order to invalidate them, but currently
    // there are no use cases
    var that = {
        idToModelListeners: {}
    };
    return that;
};

fluid.checkMaterialisedElement = function (element, selectorName, that) {
    if (!element || !element.length) {
        fluid.fail("Could not locate element for selector " + selectorName + " for component " + fluid.dumpComponentAndPath(that));
    }
};

fluid.domMaterialiserManager = fluid.makeDomMaterialiserManager();

// Passive - pushes out to single-arg jQuery method, active in acquiring initial markup value for booleanAttr
fluid.materialisers.domOutput = function (that, segs, type, options) {
    fluid.freezeRecursive(segs);
    var selectorName = segs[1];
    var listener = function (value) {
        if (that.dom) {
            var element = that.dom.locate(selectorName);
            fluid.checkMaterialisedElement(element, selectorName, that);

            if (type === "jQuery") {
                var model = {
                    value: value,
                    segs: segs
                };
                var args = options.makeArgs ? options.makeArgs(model) : [model.value];
                element[options.method].apply(element, args);
            } else if (type === "booleanAttr") {
                if (value === undefined) {
                    var markupValue = !!element.attr(options.attr);
                    that.applier.change(segs, options.negate ? !markupValue : markupValue);
                } else {
                    var attrValue = options.negate ? !value : value;
                    if (attrValue) {
                        element.attr(options.attr, options.attr);
                    } else {
                        element.removeAttr(options.attr);
                    }
                }
            }
        }
    };
    //        fluid.pushArray(fluid.domMaterialiserManager.idToModelListeners, that.id, listener);
    that.applier.modelChanged.addListener({segs: segs}, listener);
    that.events.onDomBind.addListener(function () {
        var modelValue = fluid.getImmediate(that.model, segs);
        listener(modelValue);
    });
    // For "read" materialisers, if the DOM has shorter lifetime than the component, the binder will still function
};

fluid.incrementModel = function (that, segs) {
    var oldValue = fluid.getImmediate(that.model, segs) || 0;
    that.applier.change(segs, oldValue + 1);
};

// Active - count of received clicks
fluid.materialisers.domClick = function (that, segs) {
    // Note that we no longer supply an initial value to avoid confusing non-integral modelListeners
    // that.applier.change(segs, 0);
    var listener = function () {
        fluid.incrementModel(that, segs);
        // TODO: Add a change source, and stick "event" on the stack somehow
    };
    // TODO: ensure that we don't miss the initial DOM bind event - unlikely, since models are resolved first
    // We assume that an outdated DOM will cease to generate events and be GCed
    that.events.onDomBind.addListener(function () {
        that.dom.locate(segs[1]).click(listener);
    });
};

// Active - hover state
fluid.materialisers.hover = function (that, segs) {
    var makeListener = function (state) {
        return function () {
            // TODO: Add a change source, and stick "event" on the stack somehow
            that.applier.change(segs, state);
        };
    };
    // TODO: For this, click and focusin, integralise over the entire document and add just one single listener - also, preferably eliminate jQuery
    // Perhaps a giant WeakMap of all DOM binder cache contents?
    that.events.onDomBind.addListener(function () {
        that.dom.locate(segs[1]).hover(makeListener(true), makeListener(false));
    });
};

// Active - focusin state
fluid.materialisers.focusin = function (that, segs) {
    var makeListener = function (state) {
        return function () {
            // TODO: Add a change source, and stick "event" on the stack somehow
            that.applier.change(segs, state);
        };
    };
    that.events.onDomBind.addListener(function () {
        that.dom.locate(segs[1]).focusin(makeListener(true)).focusout(makeListener(false));
    });
};

// Bidirectional - reads existing id or allocates simple if not present, and also allows it to be rewritten from the model
fluid.materialisers.id = function (that, segs) {
    that.events.onDomBind.addListener(function () {
        var element = that.dom.locate(segs[1])[0];
        var modelValue = fluid.getImmediate(that.model, segs);
        if (modelValue === undefined) {
            var id = fluid.allocateSimpleId(element);
            that.applier.change(segs, id, "ADD", "DOM");
        } else {
            element.id = modelValue;
        }
        var modelListener = function (value) {
            if (value !== undefined) {
                element.id = value;
            }
        };

        that.applier.modelChanged.addListener({segs: segs}, modelListener);
    });
};


// Bidirectional - pushes and receives values
fluid.materialisers.domValue = function (that, segs) {
    that.events.onDomBind.addListener(function () {
        var element = that.dom.locate(segs[1]);

        var domListener = function () {
            var val = fluid.value(element);
            that.applier.change(segs, val, "ADD", "DOM");
        };

        var modelListener = function (value) {
            if (value !== undefined) {
                fluid.value(element, value);
            }
        };

        that.applier.modelChanged.addListener({segs: segs}, modelListener);

        var options = fluid.getImmediate(that, ["options", "bindingOptions", fluid.model.composeSegments.apply(null, segs)]);
        var changeEvent = options && options.changeEvent || "change";

        element.on(changeEvent, domListener);
        // Pull the initial value from the model
        modelListener(fluid.getImmediate(that.model, segs));
        // TODO: How do we know not to pull the value from the DOM on startup? Are we expected to introspect into
        // the relay rule connecting it? Surely not. In practice this should use the same rule as "outlying init
        // values" in the main ChangeApplier which we have done, but so far there is no mechanism to override it.
    });
};

// Passive
fluid.materialisers.style = function (that, segs) {
    var selectorName = segs[1];
    that.events.onDomBind.addListener(function () {
        var element = that.dom.locate(selectorName);
        fluid.checkMaterialisedElement(element, selectorName, that);
        var modelValue = fluid.getImmediate(that.model, segs);
        element[0].style[segs[3]] = modelValue;
    });
};

// Remember, naturally all this stuff will go into defaults when we can afford it
fluid.registerNamespace("fluid.materialiserRegistry");


fluid.materialiserRegistry["fluid.viewComponent"] = {
    "dom": {
        "*": {
            "text": {
                materialiser: "fluid.materialisers.domOutput",
                args: ["jQuery", {method: "text"}]
            },
            "attr": {
                materialiser: "fluid.materialisers.domOutput",
                args: ["jQuery", {method: "attr", makeArgs: function (model) {
                    return [ model.segs[3], model.value];
                }}]
            },
            "visible": {
                materialiser: "fluid.materialisers.domOutput",
                args: ["jQuery", {method: "toggle"}]
            },
            "enabled": {
                materialiser: "fluid.materialisers.domOutput",
                args: ["booleanAttr", {attr: "disabled", negate: true}]
            },
            "click": {
                materialiser: "fluid.materialisers.domClick"
            },
            "hover": {
                materialiser: "fluid.materialisers.hover"
            },
            "focusin": {
                materialiser: "fluid.materialisers.focusin"
            },
            "value": {
                materialiser: "fluid.materialisers.domValue"
            },
            "class": {
                materialiser: "fluid.materialisers.domOutput",
                args: ["jQuery", {method: "toggleClass", makeArgs: function (model) {
                    return [ model.segs[3], !!model.value];
                }}]
            },
            "style": {
                materialiser: "fluid.materialisers.style"
            },
            "id": {
                materialiser: "fluid.materialisers.id"
            }
        }
    }
};

/** A generalisation of jQuery.val to correctly handle the case of acquiring and setting the value of clustered
 * radio button/checkbox sets, potentially, given a node corresponding to just one element.
 * @param {jQuery} nodeIn - The node whose value is to be read or written
 * @param {Any} newValue - If `undefined`, the value will be read, otherwise, the supplied value will be applied to the node
 * @return {Any|undefined} The queried value, if `newValue` was undefined, otherwise `undefined`.
 */
fluid.value = function (nodeIn, newValue) {
    var node = fluid.unwrap(nodeIn);
    var isMultiple = false;
    if (node.nodeType === undefined && node.length > 1) {
        node = node[0];
        isMultiple = true;
    }
    if ("input" !== node.nodeName.toLowerCase() || !/radio|checkbox/.test(node.type)) {
        // resist changes to contract of jQuery.val() in jQuery 1.5.1 (see FLUID-4113)
        return newValue === undefined ? $(node).val() : $(node).val(newValue);
    }
    var name = node.name;
    var elements;
    if (isMultiple || name === "") {
        elements = nodeIn;
    } else {
        elements = node.ownerDocument.getElementsByName(name);
        var scope = fluid.findForm(node);
        elements = $.grep(elements, function (element) {
            if (element.name !== name) {
                return false;
            }
            return !scope || scope.contains(element);
        });
        // TODO: "Code to the test" for old renderer - remove all HTML 1.0 behaviour when old renderer is abolished
        isMultiple = elements.length > 1;
    }
    if (newValue !== undefined) {
        if (typeof(newValue) === "boolean") {
            newValue = (newValue ? "true" : "false");
        }
        // jQuery gets this partially right, but when dealing with radio button array will
        // set all of their values to "newValue" rather than setting the checked property
        // of the corresponding control.
        $.each(elements, function () {
            this.checked = (newValue instanceof Array ?
                newValue.indexOf(this.value) !== -1 : newValue === this.value);
        });
    } else { // it is a checkbox - jQuery fails on this - see https://stackoverflow.com/questions/5621324/jquery-val-and-checkboxes
        var checked = $.map(elements, function (element) {
            return element.checked ? element.value : null;
        });
        return node.type === "radio" ? checked[0] :
            isMultiple ? checked : !!checked[0]; // else it's a checkbox
    }
};

// The current implementation of fluid.ariaLabeller consists 100% of side-effects on the document and so
// this grade is supplied only so that instantiation on the server is not an error
fluid.defaults("fluid.ariaLabeller", {
    gradeNames: "fluid.viewComponent"
});
