/* @odoo-module */

import { isVisible } from "@web/core/utils/ui";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
    click as webClick,
    getFixture,
    makeDeferred,
    triggerEvents as webTriggerEvents,
} from "@web/../tests/helpers/utils";

/**
 * Create a file object, which can be used for drag-and-drop.
 *
 * @param {Object} data
 * @param {string} data.name
 * @param {string} data.content
 * @param {string} data.contentType
 * @returns {Promise<Object>} resolved with file created
 */
export function createFile(data) {
    // Note: this is only supported by Chrome, and does not work in Incognito mode
    return new Promise(function (resolve, reject) {
        var requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
        if (!requestFileSystem) {
            throw new Error("FileSystem API is not supported");
        }
        requestFileSystem(window.TEMPORARY, 1024 * 1024, function (fileSystem) {
            fileSystem.root.getFile(data.name, { create: true }, function (fileEntry) {
                fileEntry.createWriter(function (fileWriter) {
                    fileWriter.onwriteend = function (e) {
                        fileSystem.root.getFile(data.name, {}, function (fileEntry) {
                            fileEntry.file(function (file) {
                                resolve(file);
                            });
                        });
                    };
                    fileWriter.write(new Blob([data.content], { type: data.contentType }));
                });
            });
        });
    });
}

/**
 * Create a fake object 'dataTransfer', linked to some files,
 * which is passed to drag and drop events.
 *
 * @param {Object[]} files
 * @returns {Object}
 */
function createFakeDataTransfer(files) {
    return {
        dropEffect: "all",
        effectAllowed: "all",
        files,
        items: [],
        types: ["Files"],
    };
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then clicks on it.
 *
 * @param {string} selector
 * @param {ContainsOptions} [options] forwarded to `contains`
 * @param {boolean} [options.shiftKey]
 */
export async function click(selector, options = {}) {
    const { shiftKey } = options;
    delete options.shiftKey;
    await contains(selector, { click: { shiftKey }, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then dragenters `files` on it.
 *
 * @param {string} selector
 * @param {Object[]} files
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function dragenterFiles(selector, files, options) {
    await contains(selector, { dragenterFiles: files, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then dragovers `files` on it.
 *
 * @param {string} selector
 * @param {Object[]} files
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function dragoverFiles(selector, files, options) {
    await contains(selector, { dragoverFiles: files, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then drops `files` on it.
 *
 * @param {string} selector
 * @param {Object[]} files
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function dropFiles(selector, files, options) {
    await contains(selector, { dropFiles: files, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then inputs `files` on it.
 *
 * @param {string} selector
 * @param {Object[]} files
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function inputFiles(selector, files, options) {
    await contains(selector, { inputFiles: files, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then pastes `files` on it.
 *
 * @param {string} selector
 * @param {Object[]} files
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function pasteFiles(selector, files, options) {
    await contains(selector, { pasteFiles: files, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then focuses on it.
 *
 * @param {string} selector
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function focus(selector, options) {
    await contains(selector, { setFocus: true, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then inserts the given `content`.
 *
 * @param {string} selector
 * @param {string} content
 * @param {ContainsOptions} [options] forwarded to `contains`
 * @param {boolean} [options.replace=false]
 */
export async function insertText(selector, content, options = {}) {
    const { replace = false } = options;
    delete options.replace;
    await contains(selector, { ...options, insertText: { content, replace } });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then sets its `scrollTop` to the given value.
 *
 * @param {string} selector
 * @param {number|"bottom"} scrollTop
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function scroll(selector, scrollTop, options) {
    await contains(selector, { setScroll: scrollTop, ...options });
}

/**
 * Waits until exactly one element matching the given `selector` is present in
 * `options.target` and then triggers `event` on it.
 *
 * @param {string} selector
 * @param {(import("@web/../tests/helpers/utils").EventType|[import("@web/../tests/helpers/utils").EventType, EventInit])[]} events
 * @param {ContainsOptions} [options] forwarded to `contains`
 */
export async function triggerEvents(selector, events, options) {
    await contains(selector, { triggerEvents: events, ...options });
}

function log(ok, message) {
    if (window.QUnit) {
        QUnit.assert.ok(ok, message);
    } else {
        if (ok) {
            console.log(message);
        } else {
            console.error(message);
        }
    }
}

let hasUsedContainsPositively = false;
if (window.QUnit) {
    QUnit.testStart(() => (hasUsedContainsPositively = false));
}
/**
 * @typedef {[string, ContainsOptions]} ContainsTuple tuple representing params of the contains
 *  function, where the first element is the selector, and the second element is the options param.
 * @typedef {Object} ContainsOptions
 * @property {ContainsTuple} [after] if provided, the found element(s) must be after the element
 *  matched by this param.
 * @property {ContainsTuple} [before] if provided, the found element(s) must be before the element
 *  matched by this param.
 * @property {Object} [click] if provided, clicks on the first found element
 * @property {ContainsTuple|ContainsTuple[]} [contains] if provided, the found element(s) must
 *  contain the provided sub-elements.
 * @property {number} [count=1] numbers of elements to be found to declare the contains check
 *  as successful. Elements are counted after applying all other filters.
 * @property {Object[]} [dragenterFiles] if provided, dragenters the given files on the found element
 * @property {Object[]} [dragoverFiles] if provided, dragovers the given files on the found element
 * @property {Object[]} [dropFiles] if provided, drops the given files on the found element
 * @property {Object[]} [inputFiles] if provided, inputs the given files on the found element
 * @property {{content:string, replace:boolean}} [insertText] if provided, adds to (or replace) the
 *  value of the first found element by the given content.
 * @property {ContainsTuple} [parent] if provided, the found element(s) must have as
 *  parent the node matching the parent parameter.
 * @property {Object[]} [pasteFiles] if provided, pastes the given files on the found element
 * @property {number|"bottom"} [scroll] if provided, the scrollTop of the found element(s)
 *  must match.
 *  Note: when using one of the scrollTop options, it is advised to ensure the height is not going
 *  to change soon, by checking with a preceding contains that all the expected elements are in DOM.
 * @property {boolean} [setFocus] if provided, focuses the first found element.
 * @property {boolean} [shadowRoot] if provided, targets the shadowRoot of the found elements.
 * @property {number|"bottom"} [setScroll] if provided, sets the scrollTop on the first found
 *  element.
 * @property {HTMLElement} [target=getFixture()]
 * @property {string[]} [triggerEvents] if provided, triggers the given events on the found element
 * @property {string} [text] if provided, the textContent of the found element(s) or one of their
 *  descendants must match. Use `textContent` option for a match on the found element(s) only.
 * @property {string} [textContent] if provided, the textContent of the found element(s) must match.
 *  Prefer `text` option for a match on the found element(s) or any of their descendants, usually
 *  allowing for a simpler and less specific selector.
 * @property {string} [value] if provided, the input value of the found element(s) must match.
 *  Note: value changes are not observed directly, another mutation must happen to catch them.
 * @property {boolean} [visible] if provided, the found element(s) must be (in)visible
 */
class Contains {
    /**
     * @param {string} selector
     * @param {ContainsOptions} [options={}]
     */
    constructor(selector, options = {}) {
        this.selector = selector;
        this.options = options;
        this.options.count ??= 1;
        this.options.targetParam = this.options.target;
        this.options.target ??= getFixture();
        let selectorMessage = `${this.options.count} of "${this.selector}"`;
        if (this.options.visible !== undefined) {
            selectorMessage = `${selectorMessage} ${
                this.options.visible ? "visible" : "invisible"
            }`;
        }
        if (this.options.targetParam) {
            selectorMessage = `${selectorMessage} inside a specific target`;
        }
        if (this.options.parent) {
            selectorMessage = `${selectorMessage} inside a specific parent`;
        }
        if (this.options.contains) {
            selectorMessage = `${selectorMessage} with a specified sub-contains`;
        }
        if (this.options.text !== undefined) {
            selectorMessage = `${selectorMessage} with text "${this.options.text}"`;
        }
        if (this.options.textContent !== undefined) {
            selectorMessage = `${selectorMessage} with textContent "${this.options.textContent}"`;
        }
        if (this.options.value !== undefined) {
            selectorMessage = `${selectorMessage} with value "${this.options.value}"`;
        }
        if (this.options.scroll !== undefined) {
            selectorMessage = `${selectorMessage} with scroll "${this.options.scroll}"`;
        }
        if (this.options.after !== undefined) {
            selectorMessage = `${selectorMessage} after a specified element`;
        }
        if (this.options.before !== undefined) {
            selectorMessage = `${selectorMessage} before a specified element`;
        }
        this.selectorMessage = selectorMessage;
        if (this.options.contains && !Array.isArray(this.options.contains[0])) {
            this.options.contains = [this.options.contains];
        }
        if (this.options.count) {
            hasUsedContainsPositively = true;
        } else if (!hasUsedContainsPositively) {
            throw new Error(
                `Starting a test with "contains" of count 0 for selector "${this.selector}" is useless because it might immediately resolve. Start the test by checking that an expected element actually exists.`
            );
        }
        /** @type {string} */
        this.successMessage = undefined;
        /** @type {function} */
        this.executeError = undefined;
    }

    /**
     * Starts this contains check, either immediately resolving if there is a
     * match, or registering appropriate listeners and waiting until there is a
     * match or a timeout (resolving or rejecting respectively).
     *
     * Success or failure messages will be logged with QUnit as well.
     *
     * @returns {Promise}
     */
    run() {
        this.done = false;
        this.def = makeDeferred();
        this.scrollListeners = new Set();
        this.onScroll = () => this.runOnce("after scroll");
        if (!this.runOnce("immediately")) {
            this.timer = setTimeout(
                () => this.runOnce("Timeout of 5 seconds", { crashOnFail: true }),
                5000
            );
            this.observer = new MutationObserver((mutations) => {
                try {
                    this.runOnce("after mutations");
                } catch (e) {
                    this.def.reject(e); // prevents infinite loop in case of programming error
                }
            });
            this.observer.observe(this.options.target, {
                attributes: true,
                childList: true,
                subtree: true,
            });
            registerCleanup(() => {
                if (!this.done) {
                    this.runOnce("Test ended", { crashOnFail: true });
                }
            });
        }
        return this.def;
    }

    /**
     * Runs this contains check once, immediately returning the result (or
     * undefined), and possibly resolving or rejecting the main promise
     * (and printing QUnit log) depending on options.
     * If undefined is returned it means the check was not successful.
     *
     * @param {string} whenMessage
     * @param {Object} [options={}]
     * @param {boolean} [options.crashOnFail=false]
     * @param {boolean} [options.executeOnSuccess=true]
     * @returns {HTMLElement[]|undefined}
     */
    runOnce(whenMessage, { crashOnFail = false, executeOnSuccess = true } = {}) {
        const res = this.select();
        if (res?.length === this.options.count || crashOnFail) {
            // clean before doing anything else to avoid infinite loop due to side effects
            this.observer?.disconnect();
            clearTimeout(this.timer);
            for (const el of this.scrollListeners ?? []) {
                el.removeEventListener("scroll", this.onScroll);
            }
            this.done = true;
        }
        if (res?.length === this.options.count) {
            this.successMessage = `Found ${this.selectorMessage} (${whenMessage})`;
            if (executeOnSuccess) {
                this.executeAction(res[0]);
            }
            return res;
        } else {
            this.executeError = () => {
                let message = `Failed to find ${this.selectorMessage} (${whenMessage}).`;
                message = res
                    ? `${message} Found ${res.length} instead.`
                    : `${message} Parent not found.`;
                if (this.parentContains) {
                    if (this.parentContains.successMessage) {
                        log(true, this.parentContains.successMessage);
                    } else {
                        this.parentContains.executeError();
                    }
                }
                log(false, message);
                this.def?.reject(new Error(message));
                for (const childContains of this.childrenContains || []) {
                    if (childContains.successMessage) {
                        log(true, childContains.successMessage);
                    } else {
                        childContains.executeError();
                    }
                }
            };
            if (crashOnFail) {
                this.executeError();
            }
        }
    }

    /**
     * Executes the action(s) given to this constructor on the found element,
     * prints the success messages, and resolves the main deferred.

     * @param {HTMLElement} el
     */
    executeAction(el) {
        let message = this.successMessage;
        if (this.options.click) {
            message = `${message} and clicked it`;
            webClick(el, undefined, {
                mouseEventInit: this.options.click,
                skipDisabledCheck: true,
                skipVisibilityCheck: true,
            });
        }
        if (this.options.dragenterFiles) {
            message = `${message} and dragentered ${this.options.dragenterFiles.length} file(s)`;
            const ev = new Event("dragenter", { bubbles: true });
            Object.defineProperty(ev, "dataTransfer", {
                value: createFakeDataTransfer(this.options.dragenterFiles),
            });
            el.dispatchEvent(ev);
        }
        if (this.options.dragoverFiles) {
            message = `${message} and dragovered ${this.options.dragoverFiles.length} file(s)`;
            const ev = new Event("dragover", { bubbles: true });
            Object.defineProperty(ev, "dataTransfer", {
                value: createFakeDataTransfer(this.options.dragoverFiles),
            });
            el.dispatchEvent(ev);
        }
        if (this.options.dropFiles) {
            message = `${message} and dropped ${this.options.dropFiles.length} file(s)`;
            const ev = new Event("drop", { bubbles: true });
            Object.defineProperty(ev, "dataTransfer", {
                value: createFakeDataTransfer(this.options.dropFiles),
            });
            el.dispatchEvent(ev);
        }
        if (this.options.inputFiles) {
            message = `${message} and inputted ${this.options.inputFiles.length} file(s)`;
            // could not use _createFakeDataTransfer as el.files assignation will only
            // work with a real FileList object.
            const dataTransfer = new window.DataTransfer();
            for (const file of this.options.inputFiles) {
                dataTransfer.items.add(file);
            }
            el.files = dataTransfer.files;
            /**
             * Changing files programatically is not supposed to trigger the event but
             * it does in Chrome versions before 73 (which is on runbot), so in that
             * case there is no need to make a manual dispatch, because it would lead to
             * the files being added twice.
             */
            const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
            const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
            if (!chromeVersion || chromeVersion >= 73) {
                el.dispatchEvent(new Event("change"));
            }
        }
        if (this.options.insertText !== undefined) {
            message = `${message} and inserted text "${this.options.insertText.content}" (replace: ${this.options.insertText.replace})`;
            el.focus();
            if (this.options.insertText.replace) {
                el.value = "";
                el.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace" }));
                el.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Backspace" }));
                el.dispatchEvent(new window.InputEvent("input"));
                el.dispatchEvent(new window.InputEvent("change"));
            }
            for (const char of this.options.insertText.content) {
                el.value += char;
                el.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
                el.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
                el.dispatchEvent(new window.InputEvent("input"));
                el.dispatchEvent(new window.InputEvent("change"));
            }
        }
        if (this.options.pasteFiles) {
            message = `${message} and pasted ${this.options.pasteFiles.length} file(s)`;
            const ev = new Event("paste", { bubbles: true });
            Object.defineProperty(ev, "clipboardData", {
                value: createFakeDataTransfer(this.options.pasteFiles),
            });
            el.dispatchEvent(ev);
        }
        if (this.options.setFocus) {
            message = `${message} and focused it`;
            el.focus();
        }
        if (this.options.setScroll !== undefined) {
            message = `${message} and set scroll to "${this.options.setScroll}"`;
            el.scrollTop =
                this.options.setScroll === "bottom" ? el.scrollHeight : this.options.setScroll;
        }
        if (this.options.triggerEvents) {
            message = `${message} and triggered "${this.options.triggerEvents.join(", ")}" events`;
            webTriggerEvents(el, null, this.options.triggerEvents, {
                skipVisibilityCheck: true,
            });
        }
        if (this.parentContains) {
            log(true, this.parentContains.successMessage);
        }
        log(true, message);
        for (const childContains of this.childrenContains) {
            log(true, childContains.successMessage);
        }
        this.def?.resolve();
    }

    /**
     * Returns the found element(s) according to this constructor setup.
     * If undefined is returned it means the parent cannot be found
     *
     * @returns {HTMLElement[]|undefined}
     */
    select() {
        const target = this.selectParent();
        if (!target) {
            return;
        }
        const baseRes = [...target.querySelectorAll(this.selector)]
            .map((el) => (this.options.shadowRoot ? el.shadowRoot : el))
            .filter((el) => el);
        /** @type {Contains[]} */
        this.childrenContains = [];
        const res = baseRes.filter((el, currentIndex) => {
            let condition =
                (this.options.textContent === undefined ||
                    el.textContent.trim() === this.options.textContent) &&
                (this.options.value === undefined || el.value === this.options.value) &&
                (this.options.scroll === undefined ||
                    (this.options.scroll === "bottom"
                        ? Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1
                        : Math.abs(el.scrollTop - this.options.scroll) <= 1));
            if (condition && this.options.text !== undefined) {
                if (
                    el.textContent.trim() !== this.options.text &&
                    [...el.querySelectorAll("*")].every(
                        (el) => el.textContent.trim() !== this.options.text
                    )
                ) {
                    condition = false;
                }
            }
            if (condition && this.options.contains) {
                for (const param of this.options.contains) {
                    const childContains = new Contains(param[0], { ...param[1], target: el });
                    if (
                        !childContains.runOnce(`as child of el ${currentIndex + 1})`, {
                            executeOnSuccess: false,
                        })
                    ) {
                        condition = false;
                    }
                    this.childrenContains.push(childContains);
                }
            }
            if (condition && this.options.visible !== undefined) {
                if (isVisible(el) !== this.options.visible) {
                    condition = false;
                }
            }
            if (condition && this.options.after) {
                const afterContains = new Contains(this.options.after[0], {
                    ...this.options.after[1],
                    target,
                });
                const afterEl = afterContains.runOnce(`as "after"`, {
                    executeOnSuccess: false,
                })?.[0];
                if (
                    !afterEl ||
                    !(el.compareDocumentPosition(afterEl) & Node.DOCUMENT_POSITION_PRECEDING)
                ) {
                    condition = false;
                }
                this.childrenContains.push(afterContains);
            }
            if (condition && this.options.before) {
                const beforeContains = new Contains(this.options.before[0], {
                    ...this.options.before[1],
                    target,
                });
                const beforeEl = beforeContains.runOnce(`as "before"`, {
                    executeOnSuccess: false,
                })?.[0];
                if (
                    !beforeEl ||
                    !(el.compareDocumentPosition(beforeEl) & Node.DOCUMENT_POSITION_FOLLOWING)
                ) {
                    condition = false;
                }
                this.childrenContains.push(beforeContains);
            }
            return condition;
        });
        if (
            this.options.scroll !== undefined &&
            this.scrollListeners &&
            baseRes.length === this.options.count &&
            res.length !== this.options.count
        ) {
            for (const el of baseRes) {
                if (!this.scrollListeners.has(el)) {
                    this.scrollListeners.add(el);
                    el.addEventListener("scroll", this.onScroll);
                }
            }
        }
        return res;
    }

    /**
     * Returns the found element that should act as the target (parent) for the
     * main selector.
     * If undefined is returned it means the parent cannot be found.
     *
     * @returns {HTMLElement|undefined}
     */
    selectParent() {
        if (this.options.parent) {
            this.parentContains = new Contains(this.options.parent[0], {
                ...this.options.parent[1],
                target: this.options.target,
            });
            return this.parentContains.runOnce(`as parent`, { executeOnSuccess: false })?.[0];
        }
        return this.options.target;
    }
}

/**
 * Waits until `count` elements matching the given `selector` are present in
 * `options.target`.
 *
 * @param {string} selector
 * @param {ContainsOptions} [options]
 * @returns {Promise}
 */
export async function contains(selector, options) {
    await new Contains(selector, options).run();
}

const stepState = {
    expectedSteps: null,
    deferred: null,
    timeout: null,
    currentSteps: [],

    clear() {
        clearTimeout(this.timeout);
        this.timeout = null;
        this.deferred = null;
        this.currentSteps = [];
        this.expectedSteps = null;
    },

    check({ crashOnFail = false } = {}) {
        const success =
            this.expectedSteps.length === this.currentSteps.length &&
            this.expectedSteps.every((s, i) => s === this.currentSteps[i]);
        if (!success && !crashOnFail) {
            return;
        }
        QUnit.config.current.assert.verifySteps(this.expectedSteps);
        if (success) {
            this.deferred.resolve();
        } else {
            this.deferred.reject(new Error("Steps do not match."));
        }
        this.clear();
    },
};

if (window.QUnit) {
    QUnit.testStart(() =>
        registerCleanup(() => {
            if (stepState.expectedSteps) {
                stepState.check({ crashOnFail: true });
            } else {
                stepState.clear();
            }
        })
    );
}

/**
 * Indicate the completion of a test step. This step must then be verified by
 * calling `assertSteps`.
 *
 * @param {string} step
 */
export function step(step) {
    stepState.currentSteps.push(step);
    QUnit.config.current.assert.step(step);
    if (stepState.expectedSteps) {
        stepState.check();
    }
}

/**
 * Wait for the given steps to be executed or for the timeout to be reached.
 *
 * @param {string[]} steps
 */
export function assertSteps(steps) {
    if (stepState.expectedSteps) {
        stepState.check({ crashOnFail: true });
    }
    stepState.expectedSteps = steps;
    stepState.deferred = makeDeferred();
    stepState.timeout = setTimeout(() => stepState.check({ crashOnFail: true }), 2000);
    stepState.check();
    return stepState.deferred;
}