/**
 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module mention/mentionui
 */
import { Plugin } from 'ckeditor5/src/core.js';
import { ButtonView, ContextualBalloon, clickOutsideHandler } from 'ckeditor5/src/ui.js';
import { CKEditorError, Collection, Rect, env, keyCodes, logWarning } from 'ckeditor5/src/utils.js';
import { TextWatcher } from 'ckeditor5/src/typing.js';
import { debounce } from 'lodash-es';
import MentionsView from './ui/mentionsview.js';
import DomWrapperView from './ui/domwrapperview.js';
import MentionListItemView from './ui/mentionlistitemview.js';
const VERTICAL_SPACING = 3;
// The key codes that mention UI handles when it is open (without commit keys).
const defaultHandledKeyCodes = [
    keyCodes.arrowup,
    keyCodes.arrowdown,
    keyCodes.esc
];
// Dropdown commit key codes.
const defaultCommitKeyCodes = [
    keyCodes.enter,
    keyCodes.tab
];
/**
 * The mention UI feature.
 */
export default class MentionUI extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'MentionUI';
    }
    /**
     * @inheritDoc
     */
    static get requires() {
        return [ContextualBalloon];
    }
    /**
     * @inheritDoc
     */
    constructor(editor) {
        super(editor);
        this._items = new Collection();
        this._mentionsView = this._createMentionView();
        this._mentionsConfigurations = new Map();
        this._requestFeedDebounced = debounce(this._requestFeed, 100);
        editor.config.define('mention', { feeds: [] });
    }
    /**
     * @inheritDoc
     */
    init() {
        const editor = this.editor;
        const commitKeys = editor.config.get('mention.commitKeys') || defaultCommitKeyCodes;
        const handledKeyCodes = defaultHandledKeyCodes.concat(commitKeys);
        this._balloon = editor.plugins.get(ContextualBalloon);
        // Key listener that handles navigation in mention view.
        editor.editing.view.document.on('keydown', (evt, data) => {
            if (isHandledKey(data.keyCode) && this._isUIVisible) {
                data.preventDefault();
                evt.stop(); // Required for Enter key overriding.
                if (data.keyCode == keyCodes.arrowdown) {
                    this._mentionsView.selectNext();
                }
                if (data.keyCode == keyCodes.arrowup) {
                    this._mentionsView.selectPrevious();
                }
                if (commitKeys.includes(data.keyCode)) {
                    this._mentionsView.executeSelected();
                }
                if (data.keyCode == keyCodes.esc) {
                    this._hideUIAndRemoveMarker();
                }
            }
        }, { priority: 'highest' }); // Required to override the Enter key.
        // Close the dropdown upon clicking outside of the plugin UI.
        clickOutsideHandler({
            emitter: this._mentionsView,
            activator: () => this._isUIVisible,
            contextElements: () => [this._balloon.view.element],
            callback: () => this._hideUIAndRemoveMarker()
        });
        const feeds = editor.config.get('mention.feeds');
        for (const mentionDescription of feeds) {
            const { feed, marker, dropdownLimit } = mentionDescription;
            if (!isValidMentionMarker(marker)) {
                /**
                 * The marker must be a single character.
                 *
                 * Correct markers: `'@'`, `'#'`.
                 *
                 * Incorrect markers: `'$$'`, `'[@'`.
                 *
                 * See {@link module:mention/mentionconfig~MentionConfig}.
                 *
                 * @error mentionconfig-incorrect-marker
                 * @param marker Configured marker
                 */
                throw new CKEditorError('mentionconfig-incorrect-marker', null, { marker });
            }
            const feedCallback = typeof feed == 'function' ? feed.bind(this.editor) : createFeedCallback(feed);
            const itemRenderer = mentionDescription.itemRenderer;
            const definition = { marker, feedCallback, itemRenderer, dropdownLimit };
            this._mentionsConfigurations.set(marker, definition);
        }
        this._setupTextWatcher(feeds);
        this.listenTo(editor, 'change:isReadOnly', () => {
            this._hideUIAndRemoveMarker();
        });
        this.on('requestFeed:response', (evt, data) => this._handleFeedResponse(data));
        this.on('requestFeed:error', () => this._hideUIAndRemoveMarker());
        /**
         * Checks if a given key code is handled by the mention UI.
         */
        function isHandledKey(keyCode) {
            return handledKeyCodes.includes(keyCode);
        }
    }
    /**
     * @inheritDoc
     */
    destroy() {
        super.destroy();
        // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
        this._mentionsView.destroy();
    }
    /**
     * Returns true when {@link #_mentionsView} is in the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} and it is
     * currently visible.
     */
    get _isUIVisible() {
        return this._balloon.visibleView === this._mentionsView;
    }
    /**
     * Creates the {@link #_mentionsView}.
     */
    _createMentionView() {
        const locale = this.editor.locale;
        const mentionsView = new MentionsView(locale);
        mentionsView.items.bindTo(this._items).using(data => {
            const { item, marker } = data;
            const { dropdownLimit: markerDropdownLimit } = this._mentionsConfigurations.get(marker);
            // Set to 10 by default for backwards compatibility. See: #10479
            const dropdownLimit = markerDropdownLimit || this.editor.config.get('mention.dropdownLimit') || 10;
            if (mentionsView.items.length >= dropdownLimit) {
                return null;
            }
            const listItemView = new MentionListItemView(locale);
            const view = this._renderItem(item, marker);
            view.delegate('execute').to(listItemView);
            listItemView.children.add(view);
            listItemView.item = item;
            listItemView.marker = marker;
            listItemView.on('execute', () => {
                mentionsView.fire('execute', {
                    item,
                    marker
                });
            });
            return listItemView;
        });
        mentionsView.on('execute', (evt, data) => {
            const editor = this.editor;
            const model = editor.model;
            const item = data.item;
            const marker = data.marker;
            const mentionMarker = editor.model.markers.get('mention');
            // Create a range on matched text.
            const end = model.createPositionAt(model.document.selection.focus);
            const start = model.createPositionAt(mentionMarker.getStart());
            const range = model.createRange(start, end);
            this._hideUIAndRemoveMarker();
            editor.execute('mention', {
                mention: item,
                text: item.text,
                marker,
                range
            });
            editor.editing.view.focus();
        });
        return mentionsView;
    }
    /**
     * Returns item renderer for the marker.
     */
    _getItemRenderer(marker) {
        const { itemRenderer } = this._mentionsConfigurations.get(marker);
        return itemRenderer;
    }
    /**
     * Requests a feed from a configured callbacks.
     */
    _requestFeed(marker, feedText) {
        // @if CK_DEBUG_MENTION // console.log( '%c[Feed]%c Requesting for', 'color: blue', 'color: black', `"${ feedText }"` );
        // Store the last requested feed - it is used to discard any out-of order requests.
        this._lastRequested = feedText;
        const { feedCallback } = this._mentionsConfigurations.get(marker);
        const feedResponse = feedCallback(feedText);
        const isAsynchronous = feedResponse instanceof Promise;
        // For synchronous feeds (e.g. callbacks, arrays) fire the response event immediately.
        if (!isAsynchronous) {
            this.fire('requestFeed:response', { feed: feedResponse, marker, feedText });
            return;
        }
        // Handle the asynchronous responses.
        feedResponse
            .then(response => {
            // Check the feed text of this response with the last requested one so either:
            if (this._lastRequested == feedText) {
                // It is the same and fire the response event.
                this.fire('requestFeed:response', { feed: response, marker, feedText });
            }
            else {
                // It is different - most probably out-of-order one, so fire the discarded event.
                this.fire('requestFeed:discarded', { feed: response, marker, feedText });
            }
        })
            .catch(error => {
            this.fire('requestFeed:error', { error });
            /**
             * The callback used for obtaining mention autocomplete feed thrown and error and the mention UI was hidden or
             * not displayed at all.
             *
             * @error mention-feed-callback-error
             */
            logWarning('mention-feed-callback-error', { marker });
        });
    }
    /**
     * Registers a text watcher for the marker.
     */
    _setupTextWatcher(feeds) {
        const editor = this.editor;
        const feedsWithPattern = feeds.map(feed => ({
            ...feed,
            pattern: createRegExp(feed.marker, feed.minimumCharacters || 0)
        }));
        const watcher = new TextWatcher(editor.model, createTestCallback(feedsWithPattern));
        watcher.on('matched', (evt, data) => {
            const markerDefinition = getLastValidMarkerInText(feedsWithPattern, data.text);
            const selection = editor.model.document.selection;
            const focus = selection.focus;
            const markerPosition = editor.model.createPositionAt(focus.parent, markerDefinition.position);
            if (isPositionInExistingMention(focus) || isMarkerInExistingMention(markerPosition)) {
                this._hideUIAndRemoveMarker();
                return;
            }
            const feedText = requestFeedText(markerDefinition, data.text);
            const matchedTextLength = markerDefinition.marker.length + feedText.length;
            // Create a marker range.
            const start = focus.getShiftedBy(-matchedTextLength);
            const end = focus.getShiftedBy(-feedText.length);
            const markerRange = editor.model.createRange(start, end);
            // @if CK_DEBUG_MENTION // console.group( '%c[TextWatcher]%c matched', 'color: red', 'color: black', `"${ feedText }"` );
            // @if CK_DEBUG_MENTION // console.log( 'data#text', `"${ data.text }"` );
            // @if CK_DEBUG_MENTION // console.log( 'data#range', data.range.start.path, data.range.end.path );
            // @if CK_DEBUG_MENTION // console.log( 'marker definition', markerDefinition );
            // @if CK_DEBUG_MENTION // console.log( 'marker range', markerRange.start.path, markerRange.end.path );
            if (checkIfStillInCompletionMode(editor)) {
                const mentionMarker = editor.model.markers.get('mention');
                // Update the marker - user might've moved the selection to other mention trigger.
                editor.model.change(writer => {
                    // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Updating the marker.', 'color: purple', 'color: black' );
                    writer.updateMarker(mentionMarker, { range: markerRange });
                });
            }
            else {
                editor.model.change(writer => {
                    // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Adding the marker.', 'color: purple', 'color: black' );
                    writer.addMarker('mention', { range: markerRange, usingOperation: false, affectsData: false });
                });
            }
            this._requestFeedDebounced(markerDefinition.marker, feedText);
            // @if CK_DEBUG_MENTION // console.groupEnd();
        });
        watcher.on('unmatched', () => {
            this._hideUIAndRemoveMarker();
        });
        const mentionCommand = editor.commands.get('mention');
        watcher.bind('isEnabled').to(mentionCommand);
        return watcher;
    }
    /**
     * Handles the feed response event data.
     */
    _handleFeedResponse(data) {
        const { feed, marker } = data;
        // eslint-disable-next-line max-len
        // @if CK_DEBUG_MENTION // console.log( `%c[Feed]%c Response for "${ data.feedText }" (${ feed.length })`, 'color: blue', 'color: black', feed );
        // If the marker is not in the document happens when the selection had changed and the 'mention' marker was removed.
        if (!checkIfStillInCompletionMode(this.editor)) {
            return;
        }
        // Reset the view.
        this._items.clear();
        for (const feedItem of feed) {
            const item = typeof feedItem != 'object' ? { id: feedItem, text: feedItem } : feedItem;
            this._items.add({ item, marker });
        }
        const mentionMarker = this.editor.model.markers.get('mention');
        if (this._items.length) {
            this._showOrUpdateUI(mentionMarker);
        }
        else {
            // Do not show empty mention UI.
            this._hideUIAndRemoveMarker();
        }
    }
    /**
     * Shows the mentions balloon. If the panel is already visible, it will reposition it.
     */
    _showOrUpdateUI(markerMarker) {
        if (this._isUIVisible) {
            // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Updating position.', 'color: green', 'color: black' );
            // Update balloon position as the mention list view may change its size.
            this._balloon.updatePosition(this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position));
        }
        else {
            // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Showing the UI.', 'color: green', 'color: black' );
            this._balloon.add({
                view: this._mentionsView,
                position: this._getBalloonPanelPositionData(markerMarker, this._mentionsView.position),
                singleViewMode: true
            });
        }
        this._mentionsView.position = this._balloon.view.position;
        this._mentionsView.selectFirst();
    }
    /**
     * Hides the mentions balloon and removes the 'mention' marker from the markers collection.
     */
    _hideUIAndRemoveMarker() {
        // Remove the mention view from balloon before removing marker - it is used by balloon position target().
        if (this._balloon.hasView(this._mentionsView)) {
            // @if CK_DEBUG_MENTION // console.log( '%c[UI]%c Hiding the UI.', 'color: green', 'color: black' );
            this._balloon.remove(this._mentionsView);
        }
        if (checkIfStillInCompletionMode(this.editor)) {
            // @if CK_DEBUG_MENTION // console.log( '%c[Editing]%c Removing marker.', 'color: purple', 'color: black' );
            this.editor.model.change(writer => writer.removeMarker('mention'));
        }
        // Make the last matched position on panel view undefined so the #_getBalloonPanelPositionData() method will return all positions
        // on the next call.
        this._mentionsView.position = undefined;
    }
    /**
     * Renders a single item in the autocomplete list.
     */
    _renderItem(item, marker) {
        const editor = this.editor;
        let view;
        let label = item.id;
        const renderer = this._getItemRenderer(marker);
        if (renderer) {
            const renderResult = renderer(item);
            if (typeof renderResult != 'string') {
                view = new DomWrapperView(editor.locale, renderResult);
            }
            else {
                label = renderResult;
            }
        }
        if (!view) {
            const buttonView = new ButtonView(editor.locale);
            buttonView.label = label;
            buttonView.withText = true;
            view = buttonView;
        }
        return view;
    }
    /**
     * Creates a position options object used to position the balloon panel.
     *
     * @param mentionMarker
     * @param preferredPosition The name of the last matched position name.
     */
    _getBalloonPanelPositionData(mentionMarker, preferredPosition) {
        const editor = this.editor;
        const editing = editor.editing;
        const domConverter = editing.view.domConverter;
        const mapper = editing.mapper;
        const uiLanguageDirection = editor.locale.uiLanguageDirection;
        return {
            target: () => {
                let modelRange = mentionMarker.getRange();
                // Target the UI to the model selection range - the marker has been removed so probably the UI will not be shown anyway.
                // The logic is used by ContextualBalloon to display another panel in the same place.
                if (modelRange.start.root.rootName == '$graveyard') {
                    modelRange = editor.model.document.selection.getFirstRange();
                }
                const viewRange = mapper.toViewRange(modelRange);
                const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange));
                return rangeRects.pop();
            },
            limiter: () => {
                const view = this.editor.editing.view;
                const viewDocument = view.document;
                const editableElement = viewDocument.selection.editableElement;
                if (editableElement) {
                    return view.domConverter.mapViewToDom(editableElement.root);
                }
                return null;
            },
            positions: getBalloonPanelPositions(preferredPosition, uiLanguageDirection)
        };
    }
}
/**
 * Returns the balloon positions data callbacks.
 */
function getBalloonPanelPositions(preferredPosition, uiLanguageDirection) {
    const positions = {
        // Positions the panel to the southeast of the caret rectangle.
        'caret_se': (targetRect) => {
            return {
                top: targetRect.bottom + VERTICAL_SPACING,
                left: targetRect.right,
                name: 'caret_se',
                config: {
                    withArrow: false
                }
            };
        },
        // Positions the panel to the northeast of the caret rectangle.
        'caret_ne': (targetRect, balloonRect) => {
            return {
                top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
                left: targetRect.right,
                name: 'caret_ne',
                config: {
                    withArrow: false
                }
            };
        },
        // Positions the panel to the southwest of the caret rectangle.
        'caret_sw': (targetRect, balloonRect) => {
            return {
                top: targetRect.bottom + VERTICAL_SPACING,
                left: targetRect.right - balloonRect.width,
                name: 'caret_sw',
                config: {
                    withArrow: false
                }
            };
        },
        // Positions the panel to the northwest of the caret rect.
        'caret_nw': (targetRect, balloonRect) => {
            return {
                top: targetRect.top - balloonRect.height - VERTICAL_SPACING,
                left: targetRect.right - balloonRect.width,
                name: 'caret_nw',
                config: {
                    withArrow: false
                }
            };
        }
    };
    // Returns only the last position if it was matched to prevent the panel from jumping after the first match.
    if (Object.prototype.hasOwnProperty.call(positions, preferredPosition)) {
        return [
            positions[preferredPosition]
        ];
    }
    // By default, return all position callbacks ordered depending on the UI language direction.
    return uiLanguageDirection !== 'rtl' ? [
        positions.caret_se,
        positions.caret_sw,
        positions.caret_ne,
        positions.caret_nw
    ] : [
        positions.caret_sw,
        positions.caret_se,
        positions.caret_nw,
        positions.caret_ne
    ];
}
/**
 * Returns a marker definition of the last valid occurring marker in a given string.
 * If there is no valid marker in a string, it returns undefined.
 *
 * Example of returned object:
 *
 * ```ts
 * {
 * 	marker: '@',
 * 	position: 4,
 * 	minimumCharacters: 0
 * }
 * ````
 *
 * @param feedsWithPattern Registered feeds in editor for mention plugin with created RegExp for matching marker.
 * @param text String to find the marker in
 * @returns Matched marker's definition
 */
function getLastValidMarkerInText(feedsWithPattern, text) {
    let lastValidMarker;
    for (const feed of feedsWithPattern) {
        const currentMarkerLastIndex = text.lastIndexOf(feed.marker);
        if (currentMarkerLastIndex > 0 && !text.substring(currentMarkerLastIndex - 1).match(feed.pattern)) {
            continue;
        }
        if (!lastValidMarker || currentMarkerLastIndex >= lastValidMarker.position) {
            lastValidMarker = {
                marker: feed.marker,
                position: currentMarkerLastIndex,
                minimumCharacters: feed.minimumCharacters,
                pattern: feed.pattern
            };
        }
    }
    return lastValidMarker;
}
/**
 * Creates a RegExp pattern for the marker.
 *
 * Function has to be exported to achieve 100% code coverage.
 */
export function createRegExp(marker, minimumCharacters) {
    const numberOfCharacters = minimumCharacters == 0 ? '*' : `{${minimumCharacters},}`;
    const openAfterCharacters = env.features.isRegExpUnicodePropertySupported ? '\\p{Ps}\\p{Pi}"\'' : '\\(\\[{"\'';
    const mentionCharacters = '.';
    // I wanted to make an util out of it, but since this regexp uses "u" flag, it became difficult.
    // When "u" flag is used, the regexp has "strict" escaping rules, i.e. if you try to escape a character that does not need
    // to be escaped, RegExp() will throw. It made it difficult to write a generic util, because different characters are
    // allowed in different context. For example, escaping "-" sometimes was correct, but sometimes it threw an error.
    marker = marker.replace(/[.*+?^${}()\-|[\]\\]/g, '\\$&');
    // The pattern consists of 3 groups:
    //
    // - 0 (non-capturing): Opening sequence - start of the line, space or an opening punctuation character like "(" or "\"",
    // - 1: The marker character,
    // - 2: Mention input (taking the minimal length into consideration to trigger the UI),
    //
    // The pattern matches up to the caret (end of string switch - $).
    //               (0:      opening sequence       )(1:   marker  )(2:                typed mention              )$
    const pattern = `(?:^|[ ${openAfterCharacters}])([${marker}])(${mentionCharacters}${numberOfCharacters})$`;
    return new RegExp(pattern, 'u');
}
/**
 * Creates a test callback for the marker to be used in the text watcher instance.
 *
 * @param feedsWithPattern Feeds of mention plugin configured in editor with RegExp to match marker in text
 */
function createTestCallback(feedsWithPattern) {
    const textMatcher = (text) => {
        const markerDefinition = getLastValidMarkerInText(feedsWithPattern, text);
        if (!markerDefinition) {
            return false;
        }
        let splitStringFrom = 0;
        if (markerDefinition.position !== 0) {
            splitStringFrom = markerDefinition.position - 1;
        }
        const textToTest = text.substring(splitStringFrom);
        return markerDefinition.pattern.test(textToTest);
    };
    return textMatcher;
}
/**
 * Creates a text matcher from the marker.
 */
function requestFeedText(markerDefinition, text) {
    let splitStringFrom = 0;
    if (markerDefinition.position !== 0) {
        splitStringFrom = markerDefinition.position - 1;
    }
    const regExp = createRegExp(markerDefinition.marker, 0);
    const textToMatch = text.substring(splitStringFrom);
    const match = textToMatch.match(regExp);
    return match[2];
}
/**
 * The default feed callback.
 */
function createFeedCallback(feedItems) {
    return (feedText) => {
        const filteredItems = feedItems
            // Make the default mention feed case-insensitive.
            .filter(item => {
            // Item might be defined as object.
            const itemId = typeof item == 'string' ? item : String(item.id);
            // The default feed is case insensitive.
            return itemId.toLowerCase().includes(feedText.toLowerCase());
        });
        return filteredItems;
    };
}
/**
 * Checks if position in inside or right after a text with a mention.
 */
function isPositionInExistingMention(position) {
    // The text watcher listens only to changed range in selection - so the selection attributes are not yet available
    // and you cannot use selection.hasAttribute( 'mention' ) just yet.
    // See https://github.com/ckeditor/ckeditor5-engine/issues/1723.
    const hasMention = position.textNode && position.textNode.hasAttribute('mention');
    const nodeBefore = position.nodeBefore;
    return hasMention || nodeBefore && nodeBefore.is('$text') && nodeBefore.hasAttribute('mention');
}
/**
 * Checks if the closest marker offset is at the beginning of a mention.
 *
 * See https://github.com/ckeditor/ckeditor5/issues/11400.
 */
function isMarkerInExistingMention(markerPosition) {
    const nodeAfter = markerPosition.nodeAfter;
    return nodeAfter && nodeAfter.is('$text') && nodeAfter.hasAttribute('mention');
}
/**
 * Checks if string is a valid mention marker.
 */
function isValidMentionMarker(marker) {
    return marker && marker.length == 1;
}
/**
 * Checks the mention plugins is in completion mode (e.g. when typing is after a valid mention string like @foo).
 */
function checkIfStillInCompletionMode(editor) {
    return editor.model.markers.has('mention');
}
