/** @odoo-module */

import { isVisible as isElemVisible } from "@web/core/utils/ui";
import { fullTraceback, fullAnnotatedTraceback } from "@web/core/errors/error_utils";
import { registry } from "@web/core/registry";
import { Component, whenReady } from "@odoo/owl";

const consoleError = console.error;

function setQUnitDebugMode() {
    whenReady(() => document.body.classList.add("debug")); // make the test visible to the naked eye
    QUnit.config.debug = true; // allows for helper functions to behave differently (logging, the HTML element in which the test occurs etc...)
    QUnit.config.testTimeout = 60 * 60 * 1000;
    // Allows for interacting with the test when it is over
    // In fact, this will pause QUnit.
    // Also, logs useful info in the console.
    QUnit.testDone(async (...args) => {
        console.groupCollapsed("Debug Test output");
        console.log(...args);
        console.groupEnd();
        await new Promise(() => {});
    });
}

// need to do this outside of the setup function so the QUnit.debug is defined when we need it
QUnit.debug = (name, cb) => {
    setQUnitDebugMode();
    QUnit.only(name, cb);
};

// need to do this outside of the setup function so it is executed quickly
QUnit.config.autostart = false;

export function setupQUnit() {
    // -----------------------------------------------------------------------------
    // QUnit config
    // -----------------------------------------------------------------------------
    QUnit.config.testTimeout = 1 * 60 * 1000;
    QUnit.config.hidepassed = window.location.href.match(/[?&]testId=/) === null;

    // -----------------------------------------------------------------------------
    // QUnit assert
    // -----------------------------------------------------------------------------
    /**
     * Checks that the target contains exactly n matches for the selector.
     *
     * Example: assert.containsN(document.body, '.modal', 0)
     */
    function containsN(target, selector, n, msg) {
        let $el;
        if (target._widgetRenderAndInsert) {
            $el = target.$el; // legacy widget
        } else if (target instanceof Component) {
            if (!target.el) {
                throw new Error(
                    `containsN assert with selector '${selector}' called on an unmounted component`
                );
            }
            $el = $(target.el);
        } else {
            $el = target instanceof Element ? $(target) : target;
        }
        msg = msg || `Selector '${selector}' should have exactly ${n} matches inside the target`;
        QUnit.assert.strictEqual($el.find(selector).length, n, msg);
    }

    /**
     * Checks that the target contains exactly 0 match for the selector.
     *
     * @param {Element} el
     * @param {string} selector
     * @param {string} [msg]
     */
    function containsNone(target, selector, msg) {
        containsN(target, selector, 0, msg);
    }

    /**
     * Checks that the target contains exactly 1 match for the selector.
     *
     * @param {Element} el
     * @param {string} selector
     * @param {string} [msg]
     */
    function containsOnce(target, selector, msg) {
        containsN(target, selector, 1, msg);
    }

    /**
     * Helper function, to check if a given element has (or has not) classnames.
     *
     * @private
     * @param {Element | jQuery | Widget} el
     * @param {string} classNames
     * @param {boolean} shouldHaveClass
     * @param {string} [msg]
     */
    function _checkClass(el, classNames, shouldHaveClass, msg) {
        if (el) {
            if (el._widgetRenderAndInsert) {
                el = el.el; // legacy widget
            } else if (!(el instanceof Element)) {
                el = el[0];
            }
        }
        msg =
            msg ||
            `target should ${shouldHaveClass ? "have" : "not have"} classnames ${classNames}`;
        const isFalse = classNames.split(" ").some((cls) => {
            const hasClass = el.classList.contains(cls);
            return shouldHaveClass ? !hasClass : hasClass;
        });
        QUnit.assert.ok(!isFalse, msg);
    }

    /**
     * Checks that the target element has the given classnames.
     *
     * @param {Element} el
     * @param {string} classNames
     * @param {string} [msg]
     */
    function hasClass(el, classNames, msg) {
        _checkClass(el, classNames, true, msg);
    }

    /**
     * Checks that the target element does not have the given classnames.
     *
     * @param {Element} el
     * @param {string} classNames
     * @param {string} [msg]
     */
    function doesNotHaveClass(el, classNames, msg) {
        _checkClass(el, classNames, false, msg);
    }

    /**
     * Checks that the target element (described by widget/jquery or html element)
     * - exists
     * - is unique
     * - has the given attribute with the proper value
     *
     * @param {Component | Element | Widget | jQuery} w
     * @param {string} attr
     * @param {string} value
     * @param {string} [msg]
     */
    function hasAttrValue(target, attr, value, msg) {
        let $el;
        if (target._widgetRenderAndInsert) {
            $el = target.$el; // legacy widget
        } else if (target instanceof Component) {
            if (!target.el) {
                throw new Error(
                    `hasAttrValue assert with attr '${attr}' called on an unmounted component`
                );
            }
            $el = $(target.el);
        } else {
            $el = target instanceof Element ? $(target) : target;
        }

        if ($el.length !== 1) {
            const descr = `hasAttrValue (${attr}: ${value})`;
            QUnit.assert.ok(
                false,
                `Assertion '${descr}' targets ${$el.length} elements instead of 1`
            );
        } else {
            msg = msg || `attribute '${attr}' of target should be '${value}'`;
            QUnit.assert.strictEqual($el.attr(attr), value, msg);
        }
    }

    /**
     * Helper function, to check if a given element
     * - is unique (if it is a jquery node set)
     * - is (or not) visible
     *
     * @private
     * @param {Element | jQuery | Widget} el
     * @param {boolean} shouldBeVisible
     * @param {string} [msg]
     */
    function _checkVisible(el, shouldBeVisible, msg) {
        if (el) {
            if (el._widgetRenderAndInsert) {
                el = el.el; // legacy widget
            } else if (!(el instanceof Element)) {
                el = el[0];
            }
        }
        msg = msg || `target should ${shouldBeVisible ? "" : "not"} be visible`;
        const _isVisible = isElemVisible(el);
        const condition = shouldBeVisible ? _isVisible : !_isVisible;
        QUnit.assert.ok(condition, msg);
    }
    function isVisible(el, msg) {
        return _checkVisible(el, true, msg);
    }
    function isNotVisible(el, msg) {
        return _checkVisible(el, false, msg);
    }
    function expectErrors() {
        QUnit.config.current.expectErrors = true;
        QUnit.config.current.unverifiedErrors = [];
    }
    function verifyErrors(expectedErrors) {
        if (!QUnit.config.current.expectErrors) {
            QUnit.pushFailure(`assert.expectErrors() must be called at the beginning of the test`);
            return;
        }
        const unverifiedErrors = QUnit.config.current.unverifiedErrors;
        QUnit.config.current.assert.deepEqual(unverifiedErrors, expectedErrors, "verifying errors");
        QUnit.config.current.unverifiedErrors = [];
    }
    QUnit.assert.containsN = containsN;
    QUnit.assert.containsNone = containsNone;
    QUnit.assert.containsOnce = containsOnce;
    QUnit.assert.doesNotHaveClass = doesNotHaveClass;
    QUnit.assert.hasClass = hasClass;
    QUnit.assert.hasAttrValue = hasAttrValue;
    QUnit.assert.isVisible = isVisible;
    QUnit.assert.isNotVisible = isNotVisible;
    QUnit.assert.expectErrors = expectErrors;
    QUnit.assert.verifyErrors = verifyErrors;

    // -----------------------------------------------------------------------------
    // QUnit logs
    // -----------------------------------------------------------------------------

    /**
     * If we want to log several errors, we have to log all of them at once, as
     * browser_js is closed as soon as an error is logged.
     */
    let errorMessages = [];
    async function logErrors() {
        const messages = errorMessages.slice();
        errorMessages = [];
        const infos = await Promise.all(messages);
        consoleError(infos.map((info) => info.error || info).join("\n"));
        // Only log the source of the errors in "info" log level to allow matching the same
        // error with its log message, as source contains asset file name which changes
        console.info(
            infos
                .map((info) =>
                    info.source ? `${info.error}\n${info.source.replace(/^/gm, "\t")}\n` : info
                )
                .join("\n")
        );
    }

    /**
     * If we want to log several errors, we have to log all of them at once, as
     * browser_js is closed as soon as an error is logged.
     */
    QUnit.done(async (result) => {
        await odoo.loader.checkErrorProm;
        const moduleLoadingError = document.querySelector(".o_module_error");
        if (moduleLoadingError) {
            errorMessages.unshift(moduleLoadingError.innerText);
        }
        if (result.failed) {
            errorMessages.push(`${result.failed} / ${result.total} tests failed.`);
        }
        if (!result.failed && !moduleLoadingError) {
            console.log("QUnit test suite done.");
            console.log("test successful"); // for ChromeBowser to know it's over and ok
        } else {
            logErrors();
        }
    });

    /**
     * This is done mostly for the .txt log file generated by the runbot.
     */
    QUnit.moduleDone(async (result) => {
        if (!result.failed) {
            console.log('"' + result.name + '"', "passed", result.total, "tests.");
        } else {
            console.log(
                '"' + result.name + '"',
                "failed",
                result.failed,
                "tests out of",
                result.total,
                "."
            );
        }
    });

    /**
     * This logs various data in the console, which will be available in the log
     * .txt file generated by the runbot.
     */
    QUnit.log((result) => {
        if (result.result) {
            return;
        }
        errorMessages.push(
            Promise.resolve(result.annotateProm).then(() => {
                let info = `QUnit test failed: ${result.module} > ${result.name} :`;
                if (result.message) {
                    info += `\n\tmessage: "${result.message}"`;
                }
                if ("expected" in result) {
                    info += `\n\texpected: "${result.expected}"`;
                }
                if (result.actual !== null) {
                    info += `\n\tactual: "${result.actual}"`;
                }
                return {
                    error: info,
                    source: result.source,
                };
            })
        );
    });

    /**
     * The purpose of this function is to reset the timer nesting level of the execution context
     * to 0, to prevent situations where a setTimeout with a timeout of 0 may end up being
     * scheduled after another one that also has a timeout of 0 that was called later.
     * Example code:
     * (async () => {
     *     const timeout = () => new Promise((resolve) => setTimeout(resolve, 0));
     *     const animationFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
     *
     *     for (let i = 0; i < 4; i++) {
     *         await timeout();
     *     }
     *     timeout().then(() => console.log("after timeout"));
     *     await animationFrame()
     *     timeout().then(() => console.log("after animationFrame"));
     *     // logs "after animationFrame" before "after timeout"
     * })()
     *
     * When the browser runs a task that was the result of a timer (setTimeout or setInterval),
     * that task has an intrinsic "timer nesting level". If you schedule another task with
     * a timer from within such a task, the new task has the existing task's timer nesting level,
     * plus one. When the timer nesting level of a task is greater than 5, the `timeout` parameter
     * for setTimeout/setInterval will be forced to at least 4 (see step 5 in the timer initialization
     * steps in the HTML spec: https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps).
     *
     * In the above example, every `await timeout()` besides inside the loop schedules a new task
     * from within a task that was initiated by a timer, causing the nesting level to be 5 after
     * the loop. The first timeout after the loop is now forced to 4.
     *
     * When we await the animation frame promise, we create a task that is *not* initiated by a timer,
     * reseting the nesting level to 0, causing the timeout following it to properly be treated as 0,
     * as such the callback that was registered by it is oftentimes executed before the previous one.
     *
     * While we can't prevent this from happening within a given test, we want to at least prevent
     * the timer nesting level to propagate from one test to the next as this can be a cause of
     * indeterminism. To avoid slowing down the tests by waiting one frame after every test,
     * we instead use a MessageChannel to add a task with not nesting level to the event queue immediately.
     */
    QUnit.testDone(async () => {
        return new Promise((resolve) => {
            const channel = new MessageChannel();
            channel.port1.onmessage = () => {
                channel.port1.close();
                channel.port2.close();
                resolve();
            };
            channel.port2.postMessage("");
        });
    });

    // Append a "Rerun in debug" link.
    // Only works if the test is not hidden.
    QUnit.testDone(async ({ testId }) => {
        if (errorMessages.length > 0) {
            logErrors();
        }
        const testElement = document.getElementById(`qunit-test-output-${testId}`);
        if (!testElement) {
            // Is probably hidden because it passed
            return;
        }
        const reRun = testElement.querySelector("li a");
        const reRunDebug = document.createElement("a");
        reRunDebug.textContent = "Rerun in debug";
        const url = new URL(window.location);
        url.searchParams.set("testId", testId);
        url.searchParams.set("debugTest", "true");
        reRunDebug.setAttribute("href", url.href);
        reRun.parentElement.insertBefore(reRunDebug, reRun.nextSibling);
    });

    const debugTest = new URLSearchParams(location.search).get("debugTest");
    if (debugTest) {
        setQUnitDebugMode();
    }

    // Override global UnhandledRejection that is assigned wayyy before this file
    // Do not really crash on non-errors rejections
    const qunitUnhandledReject = QUnit.onUnhandledRejection;
    QUnit.onUnhandledRejection = (reason) => {
        const error = reason instanceof Error && "cause" in reason ? reason.cause : reason;
        if (error instanceof Error) {
            qunitUnhandledReject(reason);
        }
    };

    // Essentially prevents default error logging when the rejection was
    // not due to an actual error
    const windowUnhandledReject = window.onunhandledrejection;
    window.onunhandledrejection = (ev) => {
        const error =
            ev.reason instanceof Error && "cause" in ev.reason ? ev.reason.cause : ev.reason;
        if (!(error instanceof Error)) {
            ev.stopImmediatePropagation();
            ev.preventDefault();
        } else if (windowUnhandledReject) {
            windowUnhandledReject.call(window, ev);
        }
    };

    // -----------------------------------------------------------------------------
    // FailFast
    // -----------------------------------------------------------------------------
    /**
     * We add here a 'fail fast' feature: we often want to stop the test suite after
     * the first failed test.  This is also useful for the runbot test suites.
     */
    QUnit.config.urlConfig.push({
        id: "failfast",
        label: "Fail Fast",
        tooltip: "Stop the test suite immediately after the first failed test.",
    });

    QUnit.begin(function () {
        if (odoo.debug && odoo.debug.includes("assets")) {
            QUnit.annotateTraceback = fullAnnotatedTraceback;
        } else {
            QUnit.annotateTraceback = (err) => Promise.resolve(fullTraceback(err));
        }
        const config = QUnit.config;
        if (config.failfast) {
            QUnit.testDone(function (details) {
                if (details.failed > 0) {
                    config.queue.length = 0;
                }
            });
        }
    });

    // -----------------------------------------------------------------------------
    // Add sort button
    // -----------------------------------------------------------------------------

    let sortButtonAppended = false;
    /**
     * Add a sort button on top of the QUnit result page, so we can see which tests
     * take the most time.
     */
    function addSortButton() {
        sortButtonAppended = true;
        var $sort = $("<label> sort by time (desc)</label>").css({ float: "right" });
        $("h2#qunit-userAgent").append($sort);
        $sort.click(function () {
            var $ol = $("ol#qunit-tests");
            var $results = $ol.children("li").get();
            $results.sort(function (a, b) {
                var timeA = Number($(a).find("span.runtime").first().text().split(" ")[0]);
                var timeB = Number($(b).find("span.runtime").first().text().split(" ")[0]);
                if (timeA < timeB) {
                    return 1;
                } else if (timeA > timeB) {
                    return -1;
                } else {
                    return 0;
                }
            });
            $.each($results, function (idx, $itm) {
                $ol.append($itm);
            });
        });
    }

    QUnit.done(() => {
        if (!sortButtonAppended) {
            addSortButton();
        }
    });

    // -----------------------------------------------------------------------------
    // Add statistics
    // -----------------------------------------------------------------------------

    let passedEl;
    let failedEl;
    let skippedEl;
    let todoCompletedEl;
    let todoUncompletedEl;
    function insertStats() {
        const toolbar = document.querySelector("#qunit-testrunner-toolbar .qunit-url-config");
        const statsEl = document.createElement("label");
        passedEl = document.createElement("span");
        passedEl.classList.add("text-success", "ms-5", "me-3");
        statsEl.appendChild(passedEl);
        todoCompletedEl = document.createElement("span");
        todoCompletedEl.classList.add("text-warning", "me-3");
        statsEl.appendChild(todoCompletedEl);
        failedEl = document.createElement("span");
        failedEl.classList.add("text-danger", "me-3");
        statsEl.appendChild(failedEl);
        todoUncompletedEl = document.createElement("span");
        todoUncompletedEl.classList.add("text-primary", "me-3");
        statsEl.appendChild(todoUncompletedEl);
        skippedEl = document.createElement("span");
        skippedEl.classList.add("text-dark");
        statsEl.appendChild(skippedEl);
        toolbar.appendChild(statsEl);
    }

    let testPassedCount = 0;
    let testFailedCount = 0;
    let testSkippedCount = 0;
    let todoCompletedCount = 0;
    let todoUncompletedCount = 0;
    QUnit.testDone(({ skipped, failed, todo }) => {
        if (!passedEl) {
            insertStats();
        }
        if (!skipped) {
            if (failed > 0) {
                if (todo) {
                    todoUncompletedCount++;
                } else {
                    testFailedCount++;
                }
            } else {
                if (todo) {
                    todoCompletedCount++;
                } else {
                    testPassedCount++;
                }
            }
        } else {
            testSkippedCount++;
        }
        passedEl.innerText = `${testPassedCount} passed`;
        if (todoCompletedCount > 0) {
            todoCompletedEl.innerText = `${todoCompletedCount} todo completed`;
        }
        if (todoUncompletedCount > 0) {
            todoUncompletedEl.innerText = `${todoUncompletedCount} todo uncompleted`;
        }
        if (testFailedCount > 0) {
            failedEl.innerText = `${testFailedCount} failed`;
        }
        if (testSkippedCount > 0) {
            skippedEl.innerText = `${testSkippedCount} skipped`;
        }
    });

    // -----------------------------------------------------------------------------
    // FIXME: This sounds stupid, it feels stupid... but it fixes visibility check in folded <details> since Chromium 97+ 💩
    // Since https://bugs.chromium.org/p/chromium/issues/detail?id=1185950
    // See regression report https://bugs.chromium.org/p/chromium/issues/detail?id=1276028
    // -----------------------------------------------------------------------------

    QUnit.begin(() => {
        const el = document.createElement("style");
        el.innerText = "details:not([open]) > :not(summary) { display: none; }";
        document.head.appendChild(el);
    });

    // -----------------------------------------------------------------------------
    // Error management
    // -----------------------------------------------------------------------------

    QUnit.on("OdooAfterTestHook", (info) => {
        const { expectErrors, unverifiedErrors } = QUnit.config.current;
        if (expectErrors && unverifiedErrors.length) {
            QUnit.pushFailure(
                `Expected assert.verifyErrors() to be called before end of test. Unverified errors: ${unverifiedErrors}`
            );
        }
    });

    const { onUnhandledRejection } = QUnit;
    QUnit.onUnhandledRejection = () => {};
    QUnit.onError = () => {};

    console.error = function () {
        if (QUnit.config.current) {
            QUnit.pushFailure(`console.error called with "${arguments[0]}"`);
        } else {
            consoleError(...arguments);
        }
    };

    function onUncaughtErrorInTest(error) {
        if (!QUnit.config.current.expectErrors) {
            // we did not expect any error, so notify qunit to add a failure
            onUnhandledRejection(error);
        } else {
            // we expected errors, so store it, it will be checked later (see verifyErrors)
            while (error instanceof Error && "cause" in error) {
                error = error.cause;
            }
            QUnit.config.current.unverifiedErrors.push(error.message);
        }
    }

    // e.g. setTimeout(() => throw new Error()) (event handler crashes synchronously)
    window.addEventListener("error", async (ev) => {
        if (!QUnit.config.current) {
            return; // we are not in a test -> do nothing
        }
        // do not log to the console as this will kill python test early
        ev.preventDefault();
        // if the error service is deployed, we'll get to the patched default handler below if no
        // other handler handled the error, so do nothing here
        if (registry.category("services").get("error", false)) {
            return;
        }
        if (
            ev.message === "ResizeObserver loop limit exceeded" ||
            ev.message === "ResizeObserver loop completed with undelivered notifications."
        ) {
            return;
        }
        onUncaughtErrorInTest(ev.error);
    });

    // e.g. Promise.resolve().then(() => throw new Error()) (crash in event handler after async boundary)
    window.addEventListener("unhandledrejection", async (ev) => {
        if (!QUnit.config.current) {
            return; // we are not in a test -> do nothing
        }
        // do not log to the console as this will kill python test early
        ev.preventDefault();
        // if the error service is deployed, we'll get to the patched default handler below if no
        // other handler handled the error, so do nothing here
        if (registry.category("services").get("error", false)) {
            return;
        }
        onUncaughtErrorInTest(ev.reason);
    });

    // This is an approximation, but we can't directly import the default error handler, because
    // it's not the same in all tested environments (e.g. /web and /pos), so we get the last item
    // from the handler registry and assume it is the default one, which handles all "not already
    // handled" errors, like tracebacks.
    const errorHandlerRegistry = registry.category("error_handlers");
    const [defaultHandlerName, defaultHandler] = errorHandlerRegistry.getEntries().at(-1);
    const testDefaultHandler = (env, uncaughtError, originalError) => {
        onUncaughtErrorInTest(originalError);
        return defaultHandler(env, uncaughtError, originalError);
    };
    errorHandlerRegistry.add(defaultHandlerName, testDefaultHandler, {
        sequence: Number.POSITIVE_INFINITY,
        force: true,
    });
}