import dialogPolyfill from '../lib/dialog-polyfill.esm.js';
import { shouldSendOnEnter } from './RossAscends-mods.js';
import { t } from './i18n.js';
import { power_user, toastPositionClasses } from './power-user.js';
import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';

/** @readonly */
/** @enum {Number} */
export const POPUP_TYPE = {
    /** Main popup type. Containing any content displayed, with buttons below. Can also contain additional input controls. */
    TEXT: 1,
    /** Popup mainly made to confirm something, answering with a simple Yes/No or similar. Focus on the button controls. */
    CONFIRM: 2,
    /** Popup who's main focus is the input text field, which is displayed here. Can contain additional content above. Return value for this is the input string. */
    INPUT: 3,
    /** Popup without any button controls. Used to simply display content, with a small X in the corner. */
    DISPLAY: 4,
    /** Popup that displays an image to crop. Returns a cropped image in result. */
    CROP: 5,
};

/** @readonly */
/** @enum {number?} */
export const POPUP_RESULT = {
    AFFIRMATIVE: 1,
    NEGATIVE: 0,
    CANCELLED: null,
    CUSTOM1: 1001,
    CUSTOM2: 1002,
    CUSTOM3: 1003,
    CUSTOM4: 1004,
    CUSTOM5: 1005,
    CUSTOM6: 1006,
    CUSTOM7: 1007,
    CUSTOM8: 1008,
    CUSTOM9: 1009,
};

/**
 * @typedef {object} PopupOptions
 * @property {string|boolean?} [okButton=null] - Custom text for the OK button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type.
 * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type.
 * @property {number?} [rows=1] - The number of rows for the input field
 * @property {string?} [placeholder=null] - Placeholder text for the main interactive element (input field for INPUT type). For other popup types, use tooltip for additional hints or to describe content elements.
 * @property {string?} [tooltip=null] - Tooltip text shown on hover for the main interactive element or content area
 * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
 * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling)
 * @property {boolean?} [large=false] - Whether to display the popup in large mode (90% of screen)
 * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
 * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
 * @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup
 * @property {boolean?} [leftAlign=false] - Whether the popup content should be left-aligned by default
 * @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...)
 * @property {POPUP_RESULT|number?} [defaultResult=POPUP_RESULT.AFFIRMATIVE] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`.
 * @property {CustomPopupButton[]|string[]?} [customButtons=null] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward.
 * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
 * @property {boolean} [allowEscapeClose=true] - If true, allows closing the popup with the Escape key, returning `POPUP_RESULT.CANCELLED`. If false, requires double-escape to force close with a confirmation to prevent accidental closure.
 * @property {(popup: Popup) => Promise<boolean?>|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
 * @property {(popup: Popup) => Promise<void?>|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
 * @property {(popup: Popup) => Promise<void?>|void?} [onOpen=null] - Handler called after the popup opens
 * @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
 * @property {string?} [cropImage=null] - Image URL to display in the crop popup
 */

/**
 * @typedef {object} CustomPopupButton
 * @property {string} text - The text of the button
 * @property {string?} [tooltip] - Optional tooltip text displayed when hovering over the button
 * @property {POPUP_RESULT|number?} [result] - The result of the button - can also be a custom result value to make be able to find out that this button was clicked. If no result is specified, this button will **not** close the popup.
 * @property {string[]|string?} [classes] - Optional custom CSS classes applied to the button
 * @property {string?} [icon] - Optional Font Awesome icon class (e.g. 'fa-wand-magic-sparkles') to display before the text
 * @property {()=>void?} [action] - Optional action to perform when the button is clicked
 * @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended
 */

/**
 * @typedef {object} CustomPopupInput
 * @property {string} id - The id for the html element
 * @property {string} label - The label text for the input
 * @property {string?} [tooltip=null] - Optional tooltip to be displayed. Default placeholder in input controls, tooltip icon behind the checkbox for those.
 * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
 * @property {('checkbox'|'text'|'textarea')?} [type='checkbox'] - The type of the input (default is checkbox)
 * @property {number?} [rows=1] - The number of rows for the input field, if the input is 'textarea'
 */

/**
 * @typedef {object} ShowPopupHelper
 * Local implementation of the helper functionality to show several popups.
 *
 * Should be called via `Popup.show.xxxx()`.
 */
const showPopupHelper = {
    /**
     * Asynchronously displays an input popup with the given header and text, and returns the user's input.
     *
     * @param {string?} header - The header text for the popup.
     * @param {string?} [text] - The main text for the popup.
     * @param {string} [defaultValue=''] - The default value for the input field.
     * @param {PopupOptions} [popupOptions={}] - Options for the popup.
     * @return {Promise<string?>} A Promise that resolves with the user's input.
     */
    input: async (header, text, defaultValue = '', popupOptions = {}) => {
        const content = PopupUtils.BuildTextWithHeader(header, text);
        const popup = new Popup(content, POPUP_TYPE.INPUT, defaultValue, popupOptions);
        const value = await popup.show();
        // Return values: If empty string, we explicitly handle that as returning that empty string as "success" provided.
        // Otherwise, all non-truthy values (false, null, undefined) are treated as "cancel" and return null.
        if (value === '') return '';
        return value ? String(value) : null;
    },

    /**
     * Asynchronously displays a confirmation popup with the given header and text, returning the clicked result button value.
     *
     * @param {string?} header - The header text for the popup.
     * @param {string?} [text] - The main text for the popup.
     * @param {PopupOptions} [popupOptions={}] - Options for the popup.
     * @return {Promise<POPUP_RESULT?>} A Promise that resolves with the result of the user's interaction.
     */
    confirm: async (header, text, popupOptions = {}) => {
        const content = PopupUtils.BuildTextWithHeader(header, text);
        const popup = new Popup(content, POPUP_TYPE.CONFIRM, null, popupOptions);
        const result = await popup.show();
        if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
        return result;
    },
    /**
     * Asynchronously displays a text popup with the given header and text, returning the clicked result button value.
     *
     * @param {string?} header - The header text for the popup.
     * @param {string?} text - The main text for the popup.
     * @param {PopupOptions} [popupOptions={}] - Options for the popup.
     * @return {Promise<POPUP_RESULT?>} A Promise that resolves with the result of the user's interaction.
     */
    text: async (header, text, popupOptions = {}) => {
        const content = PopupUtils.BuildTextWithHeader(header, text);
        const popup = new Popup(content, POPUP_TYPE.TEXT, null, popupOptions);
        const result = await popup.show();
        if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. TEXT popups only support numbers, or null. Result: ${result}`);
        return result;
    },
};

export class Popup {
    /** @readonly @type {POPUP_TYPE} */ type;

    /** @readonly @type {string} */ id;

    /** @readonly @type {HTMLDialogElement} */ dlg;
    /** @readonly @type {HTMLDivElement} */ body;
    /** @readonly @type {HTMLDivElement} */ content;
    /** @readonly @type {HTMLTextAreaElement} */ mainInput;
    /** @readonly @type {HTMLDivElement} */ inputControls;
    /** @readonly @type {HTMLDivElement} */ buttonControls;
    /** @readonly @type {HTMLDivElement} */ okButton;
    /** @readonly @type {HTMLDivElement} */ cancelButton;
    /** @readonly @type {HTMLDivElement} */ closeButton;
    /** @readonly @type {HTMLDivElement} */ cropWrap;
    /** @readonly @type {HTMLImageElement} */ cropImage;
    /** @readonly @type {POPUP_RESULT|number?} */ defaultResult;
    /** @readonly @type {CustomPopupButton[]|string[]?} */ customButtons;
    /** @readonly @type {CustomPopupInput[]} */ customInputs;

    /** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing;
    /** @type {(popup: Popup) => Promise<void?>|void?} */ onClose;
    /** @type {(popup: Popup) => Promise<void?>|void?} */ onOpen;

    /** @type {POPUP_RESULT|number} */ result;
    /** @type {any} */ value;
    /** @type {Map<string,string|boolean>?} */ inputResults;
    /** @type {any} */ cropData;

    /** @type {HTMLElement} */ lastFocus;

    /** @type {Promise<any>} */ #promise;
    /** @type {(result: any) => any} */ #resolver;

    /** @type {boolean} */ #allowEscapeClose;
    /** @type {boolean} */ #isClosingPrevented;
    /** @type {number} */ #lastEscapePress = 0;
    /** @type {boolean} */ #isShowingForceCloseConfirm = false;

    /**
     * Constructs a new Popup object with the given text content, type, inputValue, and options
     *
     * @param {JQuery<HTMLElement>|string|Element} content - Text content to display in the popup
     * @param {POPUP_TYPE} type - The type of the popup
     * @param {string} [inputValue=''] - The initial value of the input field
     * @param {PopupOptions} [options={}] - Additional options for the popup
     */
    constructor(content, type, inputValue = '', {
        okButton = null,
        cancelButton = null,
        rows = 1,
        placeholder = null,
        tooltip = null,
        wide = false,
        wider = false,
        large = false,
        transparent = false,
        allowHorizontalScrolling = false,
        allowVerticalScrolling = false,
        leftAlign = false,
        animation = 'fast',
        defaultResult = POPUP_RESULT.AFFIRMATIVE,
        customButtons = null,
        customInputs = null,
        allowEscapeClose = true,
        onClosing = null,
        onClose = null,
        onOpen = null,
        cropAspect = null,
        cropImage = null,
    } = {}) {
        Popup.util.popups.push(this);

        // Make this popup uniquely identifiable
        this.id = uuidv4();
        this.type = type;

        // Setup some args being passed in as private properties
        this.#allowEscapeClose = allowEscapeClose;

        // Utilize event handlers being passed in
        this.onClosing = onClosing;
        this.onClose = onClose;
        this.onOpen = onOpen;

        /**@type {HTMLTemplateElement}*/
        const template = document.querySelector('#popup_template');
        // @ts-ignore
        this.dlg = template.content.cloneNode(true).querySelector('.popup');
        if (!this.dlg.showModal) {
            this.dlg.classList.add('poly_dialog');
            dialogPolyfill.registerDialog(this.dlg);
            // Force a vertical reposition after the content
            // (like crop image) has been set
            const resizeObserver = new ResizeObserver((entries) => {
                for (const entry of entries) {
                    dialogPolyfill.reposition(entry.target);
                }
            });
            resizeObserver.observe(this.dlg);
        }
        this.body = this.dlg.querySelector('.popup-body');
        this.content = this.dlg.querySelector('.popup-content');
        this.mainInput = this.dlg.querySelector('.popup-input');
        this.inputControls = this.dlg.querySelector('.popup-inputs');
        this.buttonControls = this.dlg.querySelector('.popup-controls');
        this.okButton = this.dlg.querySelector('.popup-button-ok');
        this.cancelButton = this.dlg.querySelector('.popup-button-cancel');
        this.closeButton = this.dlg.querySelector('.popup-button-close');
        this.cropWrap = this.dlg.querySelector('.popup-crop-wrap');
        this.cropImage = this.dlg.querySelector('.popup-crop-image');

        this.dlg.setAttribute('data-id', this.id);
        if (wide) this.dlg.classList.add('wide_dialogue_popup');
        if (wider) this.dlg.classList.add('wider_dialogue_popup');
        if (large) this.dlg.classList.add('large_dialogue_popup');
        if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
        if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
        if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
        if (leftAlign) this.dlg.classList.add('left_aligned_dialogue_popup');
        if (animation) this.dlg.classList.add('popup--animation-' + animation);

        // If custom button captions are provided, we set them beforehand
        this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';
        this.okButton.dataset.i18n = this.okButton.textContent;
        this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel');
        this.cancelButton.dataset.i18n = this.cancelButton.textContent;

        /** @param {HTMLElement} control @param {string} text Sets the title attribute and translation, if text is provided  */
        function setTitleFromTooltip(control, text) {
            if (!text) return;
            control.title = text;
            if (!control.dataset.i18n) {
                control.dataset.i18n = '[title]' + text; // Don't override an existing translation of main text with title translation
            }
        }

        this.defaultResult = defaultResult;
        this.customButtons = customButtons;
        this.customButtons?.forEach((x, index) => {
            /** @type {CustomPopupButton} */
            const button = typeof x === 'string' ? { text: x, result: index + 2 } : x;

            const buttonElement = document.createElement('div');
            buttonElement.classList.add('menu_button', 'popup-button-custom', 'result-control');
            buttonElement.classList.add(...(button.classes ?? []));
            buttonElement.dataset.result = String(button.result); // This is expected to also write 'null' or 'staging', to indicate cancel and no action respectively
            buttonElement.tabIndex = 0;

            if (button.icon) {
                const icon = document.createElement('i');
                icon.className = `fa-solid ${button.icon}`;
                buttonElement.appendChild(icon);
                const textSpan = document.createElement('span');
                textSpan.textContent = button.text;
                textSpan.dataset.i18n = button.text;
                buttonElement.classList.add('menu_button_icon');
                buttonElement.appendChild(textSpan);
            } else {
                buttonElement.textContent = button.text;
                buttonElement.dataset.i18n = buttonElement.textContent;
            }
            setTitleFromTooltip(buttonElement, button.tooltip);

            if (button.appendAtEnd) {
                this.buttonControls.appendChild(buttonElement);
            } else {
                this.buttonControls.insertBefore(buttonElement, this.okButton);
            }

            if (typeof button.action === 'function') {
                buttonElement.addEventListener('click', button.action);
            }
        });

        this.customInputs = customInputs;
        this.customInputs?.forEach(input => {
            if (!input.id || !(typeof input.id === 'string')) {
                console.warn('Given custom input does not have a valid id set');
                return;
            }

            if (!input.type || input.type === 'checkbox') {
                const label = document.createElement('label');
                label.classList.add('checkbox_label', 'justifyCenter');
                label.setAttribute('for', input.id);
                const inputElement = document.createElement('input');
                inputElement.type = 'checkbox';
                inputElement.id = input.id;
                inputElement.checked = Boolean(input.defaultState ?? false);
                label.appendChild(inputElement);
                const labelText = document.createElement('span');
                labelText.innerText = input.label;
                labelText.dataset.i18n = input.label;
                label.appendChild(labelText);

                if (input.tooltip) {
                    const tooltip = document.createElement('div');
                    tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
                    setTitleFromTooltip(tooltip, input.tooltip);
                    label.appendChild(tooltip);
                }

                this.inputControls.appendChild(label);
            } else if (input.type === 'text') {
                const label = document.createElement('label');
                label.classList.add('text_label', 'justifyCenter');
                label.setAttribute('for', input.id);

                const inputElement = document.createElement('input');
                inputElement.classList.add('text_pole', 'result-control');
                inputElement.type = 'text';
                inputElement.id = input.id;
                inputElement.value = String(input.defaultState ?? '');
                inputElement.placeholder = input.tooltip ?? '';
                setTitleFromTooltip(inputElement, input.tooltip);

                const labelText = document.createElement('span');
                labelText.innerText = input.label;
                labelText.dataset.i18n = input.label;

                label.appendChild(labelText);
                label.appendChild(inputElement);

                this.inputControls.appendChild(label);
            } else if (input.type === 'textarea') {
                const label = document.createElement('label');
                label.classList.add('text_label', 'justifyCenter');
                label.setAttribute('for', input.id);

                const inputElement = document.createElement('textarea');
                inputElement.classList.add('text_pole', 'result-control');
                inputElement.id = input.id;
                inputElement.value = String(input.defaultState ?? '');
                inputElement.rows = input.rows ?? 1;
                inputElement.placeholder = input.tooltip ?? '';
                setTitleFromTooltip(inputElement, input.tooltip);

                const labelText = document.createElement('span');
                labelText.innerText = input.label;
                labelText.dataset.i18n = input.label;

                label.appendChild(labelText);
                label.appendChild(inputElement);

                this.inputControls.appendChild(label);
            } else {
                console.warn('Unknown custom input type. Only checkbox, text and textarea are supported.', input);
                return;
            }
        });

        // Set the default button class
        const defaultButton = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`);
        if (defaultButton) defaultButton.classList.add('menu_button_default');

        // Styling differences depending on the popup type
        // General styling for all types first, that might be overridden for specific types below
        this.mainInput.style.display = 'none';
        this.inputControls.style.display = customInputs ? 'block' : 'none';
        this.closeButton.style.display = 'none';
        this.cropWrap.style.display = 'none';

        switch (type) {
            case POPUP_TYPE.TEXT: {
                //Text shows OK if not explicitly set to false, and CANCEL only if defined as true or with a caption
                if (okButton === false) this.okButton.style.display = 'none';
                if (!cancelButton) this.cancelButton.style.display = 'none';
                break;
            }
            case POPUP_TYPE.CONFIRM: {
                // Confirm shows OK if not explicitly set to false, and CANCEL if not explicitly set to false
                if (okButton === false) this.okButton.style.display = 'none';
                if (cancelButton === false) this.cancelButton.style.display = 'none';
                // Override default captions for confirm on OK->Yes, CANCEL->No
                if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes');
                if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no');
                break;
            }
            case POPUP_TYPE.INPUT: {
                this.mainInput.style.display = 'block';
                // Input shows OK if not explicitly set to false, and CANCEL if not explicitly set to false
                if (okButton === false) this.okButton.style.display = 'none';
                if (cancelButton === false) this.cancelButton.style.display = 'none';
                // Override default captions for input on OK->Save
                if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save');
                break;
            }
            case POPUP_TYPE.DISPLAY: {
                // Display hides OK and CANCEL and all main button controls
                this.buttonControls.style.display = 'none';
                this.closeButton.style.display = 'block';
                break;
            }
            case POPUP_TYPE.CROP: {
                this.cropWrap.style.display = 'block';
                this.cropImage.src = cropImage;
                $(this.cropImage).cropper({
                    aspectRatio: cropAspect ?? 2 / 3,
                    autoCropArea: 1,
                    viewMode: 2,
                    rotatable: false,
                    crop: (event) => {
                        this.cropData = event.detail;
                        this.cropData.want_resize = !power_user.never_resize_avatars;
                    },
                });
                // Crop shows OK if not explicitly set to false, and CANCEL if not explicitly set to false
                if (okButton === false) this.okButton.style.display = 'none';
                if (cancelButton === false) this.cancelButton.style.display = 'none';
                // Override default captions for crop on OK->Crop
                if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop');
                break;
            }
            default: {
                console.warn('Unknown popup type.', type);
                break;
            }
        }

        this.mainInput.value = inputValue;
        this.mainInput.rows = rows ?? 1;

        // Apply placeholder and tooltip based on popup type
        if (type === POPUP_TYPE.INPUT) {
            // For INPUT type, apply to the main input element
            this.mainInput.placeholder = placeholder ?? '';
            setTitleFromTooltip(this.mainInput, tooltip);
        } else {
            // For other types, apply tooltip to the content area
            setTitleFromTooltip(this.content, tooltip);
        }

        this.content.innerHTML = '';
        if (content instanceof jQuery) {
            $(this.content).append(content);
        } else if (content instanceof HTMLElement) {
            this.content.append(content);
        } else if (typeof content == 'string') {
            this.content.innerHTML = content;
        } else {
            console.warn('Unknown popup text type. Should be jQuery, HTMLElement or string.', content);
        }

        // Already prepare the auto-focus control by adding the "autofocus" attribute, this should be respected by showModal()
        this.setAutoFocus({ applyAutoFocus: true });

        // Set focus event that remembers the focused element
        this.dlg.addEventListener('focusin', (evt) => { if (evt.target instanceof HTMLElement && evt.target != this.dlg) this.lastFocus = evt.target; });

        // Bind event listeners for all result controls to their defined event type
        this.dlg.querySelectorAll('[data-result]').forEach(resultControl => {
            if (!(resultControl instanceof HTMLElement)) return;
            // If no value was set, we exit out and don't bind an action
            if (String(resultControl.dataset.result) === String(undefined)) return;

            // Make sure that both `POPUP_RESULT` numbers and also `null` as 'cancelled' are supported
            const result = String(resultControl.dataset.result) === String(null) ? null
                : Number(resultControl.dataset.result);

            if (result !== null && isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);
            const type = resultControl.dataset.resultEvent || 'click';
            resultControl.addEventListener(type, async () => await this.complete(result));
        });

        // Bind dialog listeners manually, so we can be sure context is preserved
        const cancelListener = async (evt) => {
            if (!this.#allowEscapeClose) {
                evt.preventDefault();
                evt.stopPropagation();
                // Set flag so closeListener also blocks the close event (browser may fire it after multiple Escape presses)
                this.#isClosingPrevented = true;

                // Check for double-escape within 500ms to allow force-closing
                const now = Date.now();
                const timeSinceLastEscape = now - this.#lastEscapePress;
                this.#lastEscapePress = now;

                if (timeSinceLastEscape < 500 && !this.#isShowingForceCloseConfirm) {
                    this.#isShowingForceCloseConfirm = true;

                    // Defer to next frame to escape the current event context,
                    // allowing the confirmation popup to stack properly on top
                    requestAnimationFrame(async () => {
                        const confirmPopup = new Popup(
                            PopupUtils.BuildTextWithHeader(
                                t`Force-close Blocking Popup`, `
                                <p>${t`This action is blocking and not meant to be closed manually.`}</p>
                                <p>${t`Force-closing may leave the application in an inconsistent state.`}</p>
                                <p><strong>${t`Are you sure you want to force-close?`}</strong></p>`),
                            POPUP_TYPE.CONFIRM,
                            '',
                            { okButton: t`Force Close`, cancelButton: t`Cancel` });

                        // If the the main popup closes while the force-close popup is still being displayed, we gracefully cancel that.
                        const originalOnClose = this.onClose;
                        this.onClose = async (x) => {
                            if (originalOnClose) await originalOnClose;
                            await confirmPopup.completeCancelled();
                        };


                        const result = await confirmPopup.show();
                        this.#isShowingForceCloseConfirm = false;
                        if (result === POPUP_RESULT.AFFIRMATIVE) {
                            // Force-close by bypassing the normal close prevention
                            this.#isClosingPrevented = false;
                            await this.complete(POPUP_RESULT.CANCELLED);
                        }
                    });
                }
                return;
            }

            evt.preventDefault();
            evt.stopPropagation();
            await this.complete(POPUP_RESULT.CANCELLED);
        };
        this.dlg.addEventListener('cancel', cancelListener.bind(this));

        // Don't ask me why this is needed. I don't get it. But we have to keep it.
        // We make sure that the modal on its own doesn't hide. Dunno why, if onClosing is triggered multiple times through the cancel event, and stopped,
        // it seems to just call 'close' on the dialog even if the 'cancel' event was prevented.
        // So here we just say that close should not happen if it was prevented.
        const closeListener = async (evt) => {
            if (this.#isClosingPrevented) {
                evt.preventDefault();
                evt.stopPropagation();
                this.dlg.showModal();
            }
        };
        this.dlg.addEventListener('close', closeListener.bind(this));

        const keyListener = async (evt) => {
            switch (evt.key) {
                case 'Enter': {
                    // CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
                    if (evt.altKey || evt.shiftKey)
                        return;

                    // Check if we are the currently active popup
                    if (this.dlg != document.activeElement?.closest('.popup'))
                        return;

                    // Check if the current focus is a result control. Only should we apply the complete action
                    const resultControl = document.activeElement?.closest('.result-control');
                    if (!resultControl)
                        return;

                    // Check if we are inside an input type text or a textarea field and send on enter is disabled
                    const textarea = document.activeElement?.closest('textarea');
                    if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter())
                        return;
                    const input = document.activeElement?.closest('input[type="text"]');
                    if (input instanceof HTMLInputElement && !shouldSendOnEnter())
                        return;

                    // If this is a multiline input popup, we should still not simply send on enter, that'd be weird.
                    // Let's still make it possible if CTRL is toggled though
                    if ((textarea instanceof HTMLTextAreaElement || input instanceof HTMLInputElement)
                        && !evt.ctrlKey && this.mainInput.rows > 1) {
                        return;
                    }

                    evt.preventDefault();
                    evt.stopPropagation();
                    const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);

                    // Call complete on the popup. Make sure that we handle `onClosing` cancels correctly and don't remove the listener then.
                    await this.complete(result);

                    break;
                }
            }
        };
        this.dlg.addEventListener('keydown', keyListener.bind(this));
    }

    /**
     * Asynchronously shows the popup element by appending it to the document body,
     * setting its display to 'block' and focusing on the input if the popup type is INPUT.
     *
     * @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed.
     */
    async show() {
        document.body.append(this.dlg);

        // Run opening animation
        this.dlg.setAttribute('opening', '');

        this.dlg.showModal();

        // We need to fix the toastr to be present inside this dialog
        fixToastrForDialogs();

        runAfterAnimation(this.dlg, () => {
            this.dlg.removeAttribute('opening');

            // If we have an onOpen handler, we run it now
            if (this.onOpen) {
                this.onOpen(this);
            }
        });

        this.#promise = new Promise((resolve) => {
            this.#resolver = resolve;
        });
        return this.#promise;
    }

    setAutoFocus({ applyAutoFocus = false } = {}) {
        /** @type {HTMLElement} */
        let control;

        // Try to find if we have an autofocus control already present
        control = this.dlg.querySelector('[autofocus]');

        // If not, find the default control for this popup type
        if (!control) {
            switch (this.type) {
                case POPUP_TYPE.INPUT: {
                    control = this.mainInput;
                    break;
                }
                default:
                    // Select default button
                    control = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`);
                    break;
            }
        }

        if (applyAutoFocus) {
            control.setAttribute('autofocus', '');
            // Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus
            // interactable only gets applied when inserted into the DOM
            control.tabIndex = 0;
        } else {
            control.focus();
        }
    }

    /**
     * Completes the popup and sets its result and value
     *
     * The completion handling will make the popup return the result to the original show promise.
     *
     * There will be two different types of result values:
     * - popup with `POPUP_TYPE.INPUT` will return the input value - or `false` on negative and `null` on cancelled
     * - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
     *
     * <b>IMPORTANT:</b> If the popup closing was cancelled via the `onClosing` handler, the return value will be `Promise<undefined>`.
     *
     * @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)
     *
     * @returns {Promise<string|number|boolean|undefined?>} A promise that resolves with the value of the popup when it is completed. <b>Returns `undefined` if the closing action was cancelled.</b>
     */
    async complete(result) {
        // In all cases besides INPUT the popup value should be the result
        /** @type {POPUP_RESULT|number|boolean|string?} */
        let value = result;
        // Input type have special results, so the input can be accessed directly without the need to save the popup and access both result and value
        if (this.type === POPUP_TYPE.INPUT) {
            if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.mainInput.value;
            else if (result === POPUP_RESULT.NEGATIVE) value = false;
            else if (result === POPUP_RESULT.CANCELLED) value = null;
            else value = false; // Might a custom negative value?
        }

        // Cropped image should be returned as a data URL
        if (this.type === POPUP_TYPE.CROP) {
            value = result >= POPUP_RESULT.AFFIRMATIVE
                ? $(this.cropImage).data('cropper').getCroppedCanvas().toDataURL('image/jpeg')
                : null;
        }

        if (this.customInputs?.length) {
            this.inputResults = new Map(this.customInputs.map(input => {
                /** @type {HTMLInputElement} */
                const inputControl = this.dlg.querySelector(`#${input.id}`);
                const value = ['text', 'textarea'].includes(input.type) ? inputControl.value : inputControl.checked;
                return [inputControl.id, value];
            }));
        }

        this.value = value;
        this.result = result;

        if (this.onClosing) {
            const shouldClose = await this.onClosing(this);
            if (!shouldClose) {
                this.#isClosingPrevented = true;
                // Set values back if we cancel out of closing the popup
                this.value = undefined;
                this.result = undefined;
                this.inputResults = undefined;
                return undefined;
            }
        }
        this.#isClosingPrevented = false;

        Popup.util.lastResult = { value, result, inputResults: this.inputResults };
        this.#hide();

        return this.#promise;
    }
    async completeAffirmative() {
        return await this.complete(POPUP_RESULT.AFFIRMATIVE);
    }
    async completeNegative() {
        return await this.complete(POPUP_RESULT.NEGATIVE);
    }
    async completeCancelled() {
        return await this.complete(POPUP_RESULT.CANCELLED);
    }

    /**
     * Hides the popup, using the internal resolver to return the value to the original show promise
     */
    #hide() {
        // We close the dialog, first running the animation
        this.dlg.setAttribute('closing', '');

        // Once the hiding starts, we need to fix the toastr to the layer below
        fixToastrForDialogs();

        // After the dialog is actually completely closed, remove it from the DOM
        runAfterAnimation(this.dlg, async () => {
            // Call the close on the dialog
            this.dlg.close();

            // Run a possible custom handler right before DOM removal
            if (this.onClose) {
                await this.onClose(this);
            }

            // Remove it from the dom
            this.dlg.remove();

            // Remove it from the popup references
            removeFromArray(Popup.util.popups, this);

            // If there is any popup below this one, see if we can set the focus
            if (Popup.util.popups.length > 0) {
                const activeDialog = document.activeElement?.closest('.popup');
                const id = activeDialog?.getAttribute('data-id');
                const popup = Popup.util.popups.find(x => x.id == id);
                if (popup) {
                    if (popup.lastFocus) popup.lastFocus.focus();
                    else popup.setAutoFocus();
                }
            }

            this.#resolver(this.value);
        });
    }

    /**
     * Show a popup with any of the given helper methods. Use `await` to make them blocking.
     */
    static show = showPopupHelper;

    /**
     * Utility for popup and popup management.
     *
     * Contains the list of all currently open popups, and it'll remember the result of the last closed popup.
     */
    static util = {
        /** @readonly @type {Popup[]} Remember all popups */
        popups: [],

        /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, string|boolean>?}?} Last popup result */
        lastResult: null,

        /** @returns {boolean} Checks if any modal popup dialog is open */
        isPopupOpen() {
            return Popup.util.popups.filter(x => x.dlg.hasAttribute('open')).length > 0;
        },

        /**
         * Returns the topmost modal layer in the document. If there is an open dialog popup,
         * it returns the dialog element. Otherwise, it returns the document body.
         *
         * @return {HTMLElement} The topmost modal layer element
         */
        getTopmostModalLayer() {
            return getTopmostModalLayer();
        },
    };
}

export class PopupUtils {
    /**
     * Builds popup content with header and text below
     *
     * @param {string?} header - The header to be added to the text
     * @param {string?} text - The main text content
     */
    static BuildTextWithHeader(header, text) {
        if (!header) {
            return text;
        }
        return `<h3>${header}</h3>
            ${text ?? ''}`; // Convert no text to empty string
    }
}

/**
 * Displays a blocking popup with a given content and type
 *
 * @param {JQuery<HTMLElement>|string|Element} content - Content or text to display in the popup
 * @param {POPUP_TYPE} type
 * @param {string} inputValue - Value to set the input to
 * @param {PopupOptions} [popupOptions={}] - Options for the popup
 * @returns {Promise<POPUP_RESULT|string|boolean?>} The value for this popup, which can either be the popup retult or the input value if chosen
 */
export function callGenericPopup(content, type, inputValue = '', popupOptions = {}) {
    const popup = new Popup(
        content,
        type,
        inputValue,
        popupOptions,
    );
    return popup.show();
}

/**
 * Returns the topmost modal layer in the document. If there is an open dialog,
 * it returns the dialog element. Otherwise, it returns the document body.
 *
 * @return {HTMLElement} The topmost modal layer element
 */
export function getTopmostModalLayer() {
    const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop();
    if (dlg instanceof HTMLElement) return dlg;
    return document.body;
}

/**
 * Fixes the issue with toastr not displaying on top of the dialog by moving the toastr container inside the dialog or back to the main body
 */
export function fixToastrForDialogs() {
    // Hacky way of getting toastr to actually display on top of the popup...
    const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop();

    let toastContainer = document.getElementById('toast-container');
    const isAlreadyPresent = !!toastContainer;
    if (!toastContainer) {
        toastContainer = document.createElement('div');
        toastContainer.setAttribute('id', 'toast-container');
        if (toastr.options.positionClass) toastContainer.classList.add(toastr.options.positionClass);
    }

    // Check if toastr is already a child. If not, we need to move it inside this dialog.
    // This is either the existing toastr container or the newly created one.
    if (dlg && !dlg.contains(toastContainer)) {
        dlg?.appendChild(toastContainer);
        return;
    }

    // Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
    // but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds.
    // To prevent new toasts from being showing up in there and then vanish in an instant,
    // we move the toastr back to the main body, or delete if its empty
    if (!dlg && isAlreadyPresent) {
        if (!toastContainer.childNodes.length) {
            toastContainer.remove();
        } else {
            document.body.appendChild(toastContainer);
            toastContainer.classList.remove(...toastPositionClasses);
            toastContainer.classList.add(toastr.options.positionClass);
        }
    }
}
