import { INITIATOR_EXECUTION } from "@devowl-wp/headless-content-unblocker";
import { LISTEN_TO_READY_EVENTS } from "@devowl-wp/react-utils";

import type { consent } from "./consent.js";
import type { consentAll } from "./consentAll.js";
import type { consentSync } from "./consentSync.js";
import type { unblock } from "./unblock.js";
import type { CookieConsentManager } from "../apply/manager.js";
import type { OmitFirst } from "../types/types.js";

type Fn = (...args: any[]) => any;

type FnOverwriteObject = {
    /**
     * If you pass in a function, it automatically gets retried to overwrite in
     * `interactive` and `complete` `document.readyState`.
     */
    object: any;
    key: string;
    /**
     * Only for internal usage.
     */
    overwritten?: boolean;
};

type Apis = {
    consent: (...args: OmitFirst<Parameters<typeof consent>>) => ReturnType<typeof consent>;
    consentAll: (...args: OmitFirst<Parameters<typeof consentAll>>) => ReturnType<typeof consentAll>;
    consentSync: (...args: OmitFirst<Parameters<typeof consentSync>>) => ReturnType<typeof consentSync>;
    unblock: (...args: OmitFirst<Parameters<typeof unblock>>) => ReturnType<typeof unblock>;
};

/**
 * Wrap any function into a consent-awaiting function. This allows you to e.g. overwrite every function
 * a WordPress theme or plugin is exposing to `window` object.
 *
 * ### Learn usage by example
 *
 * A plugin exposes the following function which loads a Google Map to `window`:
 *
 * ```html
 * <script consent-skip-blocker="1">
 *   window.mynested = {
 *     another: {
 *       loadGoogleMaps: (when) => {
 *         console.log("I am loading https://google.com/maps");
 *       }
 *     }
 *   }
 *
 *   jQuery(() => window.mynested.another.loadGoogleMaps("now"));
 * </script>
 * ```
 *
 * We can now override this method with the help of `idx` and delay of the `console.log` until consent for
 * Google Maps is given:
 *
 * ```ts
 * wrapFn(
 *     {
 *         object: idx(window, (window) => window.mynested.another),
 *         key: "loadGoogleMaps"
 *     },
 *     ["unblock", "https://google.com/maps"]
 * );
 * ```
 *
 * The example above can also be used in the following way to check for blocker rules for the given function body:
 *
 * ```ts
 * wrapFn(
 *     {
 *         object: idx(window, (window) => window.mynested.another),
 *         key: "loadGoogleMaps"
 *     },
 *     "functionBody"
 * );
 * ```
 *
 * The above examples determine the consent by a defined Content Blocker. But you can also wait for consent by a defined
 * service with the help of the `consent` API to check for a technical definition (HTTP Cookie, LocalStorage, ...):
 *
 * ```ts
 * wrapFn(
 *     {
 *         object: idx(window, (window) => window.mynested.another),
 *         key: "loadGoogleMaps"
 *     },
 *     ["consent", "http", "__SECURE", "*"]
 * );
 * ```
 *
 * And for the advanced usages, you can simply return your own `boolean` or `Promise` by passing your custom checker:
 *
 * ```ts
 * wrapFn(
 *     {
 *         object: idx(window, (window) => window.mynested.another),
 *         key: "loadGoogleMaps"
 *     },
 *     () => {
 *         return new Promise((resolve) => {
 *             // [...]
 *         });
 *     }
 * );
 * ```
 *
 * If you are invoking `wrapFn` at the very beginning of your HTML and you do not know, when the function which should
 * be overwritten is available, you can pass a function to `object` so it gets retried to overwrite on `interactive`
 * and `complete` state:
 *
 * ```ts
 * wrapFn(
 *     {
 *         object: () => idx(window, (window) => window.mynested.another),
 *         key: "loadGoogleMaps"
 *     },
 *     () => {
 *         return new Promise((resolve) => {
 *             // [...]
 *         });
 *     }
 * );
 * ```
 *
 * @param fn The function(s) we want to override. If you pass a function you need to implement the overriding for the
 *        original function yourself. Otherwise you can pass an object defining the object itself and the appropriate
 *        key which holds the function so it gets overwritten automatically.
 * @param checkExecution Allows you to define a function which needs to return a `boolean` which allows synced execution.
 *        If the return is a `Promise` it will return the `Promise` immediately, when the `Promise` gets resolved the
 *        original function gets invoked.
 * @return Function which calls original function when consent is given (depending on `checkExecution`) or `undefined`
 *         if an undefined function was passed as argument.
 */
function wrapFn(
    apis: Apis,
    blocker: Parameters<typeof unblock>[0],
    manager: CookieConsentManager,
    fn: Fn | FnOverwriteObject | Array<FnOverwriteObject>,
    checkExecution:
        | ((args: {
              original: Fn;
              callOriginal: () => any;
              blocker: Parameters<typeof unblock>[0];
              manager: CookieConsentManager;
              that: any;
              objectResolved?: any;
              args: any[];
          }) => boolean | Promise<any>)
        | ["consent", ...Parameters<Apis["consent"]>]
        | ["consentAll", ...Parameters<Apis["consentAll"]>]
        | ["consentSync", ...Parameters<Apis["consentSync"]>]
        | ["unblock", ...Parameters<Apis["unblock"]>]
        | "functionBody"
        | Promise<any>,
    settings: {
        failedSyncReturnValue?: boolean;
        skipRetry?: boolean;
    } = {},
) {
    const { failedSyncReturnValue, skipRetry } = settings;

    // Extract original function(s)
    const overwrittenFns: Fn[] = [];
    const retryWrap: FnOverwriteObject[] = [];
    const useFns = Array.isArray(fn) ? fn : [fn];

    while (useFns.length) {
        const passedFn = useFns.shift();
        const overwrite = !!(passedFn as any)?.key;

        // When the function should be overwritten directly in an object, resolve the object which should be addressed
        let objectResolved: any;
        let original: Fn;
        if (typeof passedFn === "function") {
            original = passedFn;
        } else if (passedFn.key) {
            if (passedFn.overwritten) {
                continue;
            }

            if (typeof passedFn.object === "function") {
                objectResolved = passedFn.object();
            } else {
                objectResolved = passedFn.object;
            }

            // When the `object` is an array, we allow to iterate over it
            if (Array.isArray(objectResolved)) {
                useFns.push(
                    ...objectResolved.map((object) => ({
                        object,
                        key: passedFn.key,
                    })),
                );
                continue;
            }

            if (objectResolved) {
                original = objectResolved[passedFn.key];
            }
        }

        if (typeof original === "function") {
            const originalFunctionBody = original.toString();
            const overwriteFn = function (this: any, ...args: any[]) {
                const callOriginal = () => original.apply(this, args);

                // Detect which function should be used to check for execution
                let checkResult: boolean | Promise<any> = true;
                if (typeof checkExecution === "function") {
                    checkResult = checkExecution({
                        original,
                        callOriginal,
                        blocker,
                        manager,
                        objectResolved,
                        that: this,
                        args,
                    });
                } else if (checkExecution instanceof Promise) {
                    checkResult = checkExecution;
                } else if (checkExecution === "functionBody") {
                    checkResult = apis.unblock(originalFunctionBody);
                } else if (Array.isArray(checkExecution)) {
                    const [apiFn, ...forwardArgs] = checkExecution;
                    checkResult = (apis[apiFn] as any)(...forwardArgs);
                }

                if (checkResult === false) {
                    // Function is not allowed to be called and check was sync-based, return a value
                    return failedSyncReturnValue;
                } else if (checkResult instanceof Promise) {
                    // Function is not yet sure bot be called, lets wait for the promise
                    return checkResult.then(callOriginal).catch(() => {
                        // Silence is golden. We do not unblock the function and also do not wait for consent, as when
                        // giving consent once (Accept all or Continue without consent), changing the consent always
                        // needs a page reload.
                    });
                } else {
                    // Function is allowed to be called, return original value
                    return callOriginal();
                }
            };

            // Do overwrite, finally
            if (overwrite && typeof passedFn === "object") {
                objectResolved[passedFn.key] = overwriteFn;
                passedFn.overwritten = true;
            }

            overwrittenFns.push(overwriteFn);
        } else {
            // When this function should be overwritten, add to the list of retry-ables
            if (overwrite && typeof passedFn === "object") {
                retryWrap.push(passedFn);
            }

            overwrittenFns.push(undefined);
        }
    }

    // Retry to overwrite failed functions if there are still pending scripts
    if (retryWrap.length && !skipRetry) {
        const retry = () => {
            wrapFn(apis, blocker, manager, retryWrap, checkExecution, { ...settings, skipRetry: true });
        };

        for (const event of LISTEN_TO_READY_EVENTS) {
            if (document.readyState === "complete" && ["DOMContentLoaded", "readystatechange"].indexOf(event) > -1) {
                continue;
            }

            document.addEventListener(event, retry);
        }

        // Listen to immediate "unblock" initiators from the content blocker
        document.addEventListener(INITIATOR_EXECUTION, retry);
    }

    return Array.isArray(fn) ? overwrittenFns : overwrittenFns?.[0];
}

export { wrapFn };
