/**
 * @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
 */
import { Command } from 'ckeditor5/src/core.js';
import { logWarning, first } from 'ckeditor5/src/utils.js';
import StyleUtils from './styleutils.js';
/**
 * Style command.
 *
 * Applies and removes styles from selection and elements.
 */
export default class StyleCommand extends Command {
    /**
     * Creates an instance of the command.
     *
     * @param editor Editor on which this command will be used.
     * @param styleDefinitions Normalized definitions of the styles.
     */
    constructor(editor, styleDefinitions) {
        super(editor);
        this.set('value', []);
        this.set('enabledStyles', []);
        this._styleDefinitions = styleDefinitions;
        this._styleUtils = this.editor.plugins.get(StyleUtils);
    }
    /**
     * @inheritDoc
     */
    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;
        const value = new Set();
        const enabledStyles = new Set();
        // Inline styles.
        for (const definition of this._styleDefinitions.inline) {
            // Check if this inline style is enabled.
            if (this._styleUtils.isStyleEnabledForInlineSelection(definition, selection)) {
                enabledStyles.add(definition.name);
            }
            // Check if this inline style is active.
            if (this._styleUtils.isStyleActiveForInlineSelection(definition, selection)) {
                value.add(definition.name);
            }
        }
        // Block styles.
        const firstBlock = first(selection.getSelectedBlocks()) || selection.getFirstPosition().parent;
        if (firstBlock) {
            const ancestorBlocks = firstBlock.getAncestors({ includeSelf: true, parentFirst: true });
            for (const block of ancestorBlocks) {
                if (block.is('rootElement')) {
                    break;
                }
                for (const definition of this._styleDefinitions.block) {
                    // Check if this block style is enabled.
                    if (!this._styleUtils.isStyleEnabledForBlock(definition, block)) {
                        continue;
                    }
                    enabledStyles.add(definition.name);
                    // Check if this block style is active.
                    if (this._styleUtils.isStyleActiveForBlock(definition, block)) {
                        value.add(definition.name);
                    }
                }
                // E.g. reached a model table when the selection is in a cell. The command should not modify
                // ancestors of a table.
                if (model.schema.isObject(block)) {
                    break;
                }
            }
        }
        this.enabledStyles = Array.from(enabledStyles).sort();
        this.isEnabled = this.enabledStyles.length > 0;
        this.value = this.isEnabled ? Array.from(value).sort() : [];
    }
    /**
     * Executes the command &ndash; applies the style classes to the selection or removes it from the selection.
     *
     * If the command value already contains the requested style, it will remove the style classes. Otherwise, it will set it.
     *
     * The execution result differs, depending on the {@link module:engine/model/document~Document#selection} and the
     * style type (inline or block):
     *
     * * When applying inline styles:
     *   * If the selection is on a range, the command applies the style classes to all nodes in that range.
     *   * If the selection is collapsed in a non-empty node, the command applies the style classes to the
     * {@link module:engine/model/document~Document#selection}.
     *
     * * When applying block styles:
     *   * If the selection is on a range, the command applies the style classes to the nearest block parent element.
     *
     * @fires execute
     * @param options Command options.
     * @param options.styleName Style name matching the one defined in the
     * {@link module:style/styleconfig~StyleConfig#definitions configuration}.
     * @param options.forceValue Whether the command should add given style (`true`) or remove it (`false`) from the selection.
     * If not set (default), the command will toggle the style basing on the first selected node. Note, that this will not force
     * setting a style on an element that cannot receive given style.
     */
    execute({ styleName, forceValue }) {
        if (!this.enabledStyles.includes(styleName)) {
            /**
             * Style command can be executed only with a correct style name.
             *
             * This warning may be caused by:
             *
             * * passing a name that is not specified in the {@link module:style/styleconfig~StyleConfig#definitions configuration}
             * (e.g. a CSS class name),
             * * when trying to apply a style that is not allowed on a given element.
             *
             * @error style-command-executed-with-incorrect-style-name
             */
            logWarning('style-command-executed-with-incorrect-style-name');
            return;
        }
        const model = this.editor.model;
        const selection = model.document.selection;
        const htmlSupport = this.editor.plugins.get('GeneralHtmlSupport');
        const allDefinitions = [
            ...this._styleDefinitions.inline,
            ...this._styleDefinitions.block
        ];
        const activeDefinitions = allDefinitions.filter(({ name }) => this.value.includes(name));
        const definition = allDefinitions.find(({ name }) => name == styleName);
        const shouldAddStyle = forceValue === undefined ? !this.value.includes(definition.name) : forceValue;
        model.change(() => {
            let selectables;
            if (isBlockStyleDefinition(definition)) {
                selectables = this._findAffectedBlocks(getBlocksFromSelection(selection), definition);
            }
            else {
                selectables = [this._styleUtils.getAffectedInlineSelectable(definition, selection)];
            }
            for (const selectable of selectables) {
                if (shouldAddStyle) {
                    htmlSupport.addModelHtmlClass(definition.element, definition.classes, selectable);
                }
                else {
                    htmlSupport.removeModelHtmlClass(definition.element, getDefinitionExclusiveClasses(activeDefinitions, definition), selectable);
                }
            }
        });
    }
    /**
     * Returns a set of elements that should be affected by the block-style change.
     */
    _findAffectedBlocks(selectedBlocks, definition) {
        const blocks = new Set();
        for (const selectedBlock of selectedBlocks) {
            const ancestorBlocks = selectedBlock.getAncestors({ includeSelf: true, parentFirst: true });
            for (const block of ancestorBlocks) {
                if (block.is('rootElement')) {
                    break;
                }
                const affectedBlocks = this._styleUtils.getAffectedBlocks(definition, block);
                if (affectedBlocks) {
                    for (const affectedBlock of affectedBlocks) {
                        blocks.add(affectedBlock);
                    }
                    break;
                }
            }
        }
        return blocks;
    }
}
/**
 * Returns classes that are defined only in the supplied definition and not in any other active definition. It's used
 * to ensure that classes used by other definitions are preserved when a style is removed. See #11748.
 *
 * @param activeDefinitions All currently active definitions affecting selected element(s).
 * @param definition Definition whose classes will be compared with all other active definition classes.
 * @returns Array of classes exclusive to the supplied definition.
 */
function getDefinitionExclusiveClasses(activeDefinitions, definition) {
    return activeDefinitions.reduce((classes, currentDefinition) => {
        if (currentDefinition.name === definition.name) {
            return classes;
        }
        return classes.filter(className => !currentDefinition.classes.includes(className));
    }, definition.classes);
}
/**
 * Checks if provided style definition is of type block.
 */
function isBlockStyleDefinition(definition) {
    return 'isBlock' in definition;
}
/**
 * Gets block elements from selection. If there are none, returns first selected element.
 * @param selection Current document's selection.
 * @returns Selected blocks if there are any, first selected element otherwise.
 */
function getBlocksFromSelection(selection) {
    const blocks = Array.from(selection.getSelectedBlocks());
    if (blocks.length) {
        return blocks;
    }
    return [selection.getFirstPosition().parent];
}
