// import $ from "jquery"; // not always available

import {
    HTML_ATTRIBUTE_BLOCKER_ID,
    HTML_ATTRIBUTE_JQUERY_HIJACK_FN,
    HTML_ATTRIBUTE_UNBLOCKED_TRANSACTION_COMPLETE,
} from "./htmlAttributes.js";
import { OPT_IN_CONTENT_BLOCKER_ALL } from "../events/optInContentBlockerAll.js";

const ALREADY_OVERWRITTEN_PROPERTY = "listenOptInJqueryFnForContentBlockerNow";

const BLOCKED_ELEMENT_SELECTOR = `[${HTML_ATTRIBUTE_BLOCKER_ID}]:not([${HTML_ATTRIBUTE_UNBLOCKED_TRANSACTION_COMPLETE}])`;

type OverrideOptions = {
    fn: string;
    /**
     * Allows to block something like this `$("body").gdlr_core_fluid_video(elements);`.
     */
    getElements?: (thisArg: JQuery<HTMLElement>, ...args: any[]) => JQuery<HTMLElement>;
    callOriginal?: (
        originalFunction: (...args: any) => any,
        thisArg: JQuery<HTMLElement>,
        args: any[],
        elements: JQuery<HTMLElement>,
    ) => any;
    /**
     * Allow to extend the check mechanism by custom functionality. E.g. this is great in combination
     * with `window.consentApi.unblock`.
     */
    customBlocked?: (element: HTMLElement, ...args: any[]) => Promise<void> | undefined;
};

function createOverride(
    $: JQueryStatic,
    originalFunction: (...args: any) => any,
    { customBlocked, getElements, callOriginal }: OverrideOptions,
) {
    return function (this: JQuery<HTMLElement>, ...args: any[]) {
        const useElements = getElements ? getElements(this, ...args) : this;
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        const thisArg = this;

        if (useElements.length) {
            const notBlockedElements: HTMLElement[] = [];
            const useCallOriginal = (elements: JQuery<HTMLElement>) => {
                if (callOriginal) {
                    return callOriginal(originalFunction, thisArg, args, elements);
                } else {
                    try {
                        return originalFunction.apply(elements, args);
                    } catch (error) {
                        console.warn(error);
                        // throw error; // We do not throw as we want to continue with the next element if the jQuery plugin does not support
                        // handling of zero elements (case when all elements are blocked).
                        // Example "failing" plugin: https://github.com/dimsemenov/Magnific-Popup/blob/45dd72ccd1a18f0626fb958f7c49e6313556d31f/src/js/core.js#L904-L951
                    }
                }
            };

            for (const element of useElements.get()) {
                const blockedElements = Array.prototype.slice.call(
                    element.querySelectorAll(BLOCKED_ELEMENT_SELECTOR),
                ) as HTMLElement[];

                // Also respect own element (element can also be `document` which does not have a `matches` function)
                if (element.matches?.(BLOCKED_ELEMENT_SELECTOR)) {
                    blockedElements.push(element);
                }

                const customPromise = element instanceof HTMLElement ? customBlocked?.(element, ...args) : undefined;

                // Call original function immediate if no content blocker found
                if (blockedElements.length || customPromise instanceof Promise) {
                    // Wait for all unblocked content
                    // TODO: should we also create chunks per `consent-id`?
                    Promise.all(
                        blockedElements
                            .map(
                                (node) =>
                                    new Promise((resolve) =>
                                        node.addEventListener(OPT_IN_CONTENT_BLOCKER_ALL, resolve),
                                    ),
                            )
                            .concat([customPromise].filter(Boolean)),
                    ).then(() => useCallOriginal($(element)));
                } else {
                    notBlockedElements.push(element);
                }
            }

            return useCallOriginal(jQuery(notBlockedElements));
        } else {
            // There is no jQuery object passed to the jQuery function. But we need to call the
            // original function anyway as some properties of e.g. `$.fn.prettyPhoto` can be
            // lazy loaded and expect a "first" call
            return originalFunction.apply($(this), args);
        }
    };
}

/**
 * Modify a jQuery function to wait for consent if a content blocker is within the
 * given selector. It also supports waiting for functions when e.g. they get lazy loaded through
 * a cache plugin.
 *
 * Example of lazy loaded jQuery fn:
 *
 * ```html
 * <div class="my-content">i am blocked</div>
 * <script>
 * jQuery.fn.myFn = function() {
 *  console.log("i am not lazy loaded", this);
 * };
 * jQuery.fn.myFn.defaults = { str: 0 };
 *
 * jQuery(document).ready(function($) {
 *   $.fn.myFnLazy = function() {
 *     console.log("i am lazy loaded", this);
 *   };
 *   $.fn.myFnLazy.defaults = { str: 1 };
 *   $(".my-content").myFn();
 *   $(".my-content").myFnLazy();
 *
 *   console.log($.fn.myFn.defaults);
 *   console.log($.fn.myFnLazy.defaults);
 * });
 * </script>
 * ```
 *
 * This function has native support for the `jQueryHijackFn` selector syntax function. Given the following example:
 *
 * Rule:
 *
 * ```
 * div[id="test1":jQueryHijackFn(function=myFunction),keepAttributes(value=id)]
 * ```
 *
 * HTML:
 *
 * ```html
 * <div id="test1">Test1</div>
 * <div id="test2">Test2</div>
 * <script>
 * jQuery.fn.myFunction = function() {
 *     console.log(this);
 *     return this;
 * };
 * jQuery(function($) {
 *     $("#test1").myFunction();
 *     $("#test2").myFunction();
 * });
 * </script>
 * ```
 *
 * Output before giving consent: Only the element `#test2`.
 * After accepting all: Output the element `#test1`.
 */
function hijackJqueryFn(fns: Array<string | OverrideOptions>) {
    const $ = (window as any).jQuery;

    if (!$?.fn) {
        return;
    }

    // Also add those functions configured through the `jQueryHijackFn` selector syntax function
    const hijackedFns = [...document.querySelectorAll(`[${HTML_ATTRIBUTE_JQUERY_HIJACK_FN}]`)]
        .map<OverrideOptions[]>((el) => {
            const fnStringOrObject = JSON.parse(el.getAttribute(HTML_ATTRIBUTE_JQUERY_HIJACK_FN) || "{}") as Record<
                string,
                boolean
            >;
            return Object.keys(fnStringOrObject).map(
                (fn) =>
                    ({
                        fn,
                        // The default implementation should only be blocked for this specific node
                        customBlocked: (node) => (node === el ? Promise.resolve() : undefined),
                    }) as OverrideOptions,
            );
        })
        .flat();

    const jQueryFns = $.fn as any;
    for (const fnStringOrObject of [...fns, ...hijackedFns]) {
        const overrideOptions = typeof fnStringOrObject === "string" ? { fn: fnStringOrObject } : fnStringOrObject;
        const { fn } = overrideOptions;
        const originalFunction = jQueryFns[fn];

        // Already overwritten?
        const alreadyOverwritten = (jQueryFns[ALREADY_OVERWRITTEN_PROPERTY] =
            jQueryFns[ALREADY_OVERWRITTEN_PROPERTY] || []) as string[];
        if (alreadyOverwritten.indexOf(fn) > -1) {
            continue;
        }
        alreadyOverwritten.push(fn);

        // Memorize the static properties (e.g. `jQuery.fn.{fn}.defaults` (https://github.com/marioestrada/jQuery-gMap/blob/33b2771a5789b7531eb56878cfa3e4a0c30a8c43/jquery.gmap.js#L250))
        if (originalFunction) {
            const staticProps = Object.getOwnPropertyDescriptors(originalFunction);
            delete staticProps.length;
            delete staticProps.name;
            delete staticProps.prototype;

            jQueryFns[fn] = createOverride($, originalFunction, overrideOptions);

            // Apply memorized static properties
            Object.defineProperties(jQueryFns[fn], staticProps);
        } else {
            // Function not yet available, wait for assignment
            let overriden = undefined;
            Object.defineProperty(jQueryFns, fn, {
                get: () => overriden,
                set: (v) => {
                    overriden = createOverride($, v, overrideOptions);
                },
                enumerable: true,
                configurable: true,
            });
        }
    }
}

export { hijackJqueryFn };
