/**
 * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
 */

/**
 * @module list/legacylist/legacylistcommand
 */

import type { ModelElement, ModelNode, ModelSchema } from '@ckeditor/ckeditor5-engine';
import { Command, type Editor } from '@ckeditor/ckeditor5-core';

import { first } from '@ckeditor/ckeditor5-utils';
import { type ListType } from '../list/listediting.js';

/**
 * The list command. It is used by the {@link module:list/legacylist~LegacyList legacy list feature}.
 */
export class LegacyListCommand extends Command {
	/**
	 * The type of the list created by the command.
	 */
	public readonly type: ListType;

	/**
	 * A flag indicating whether the command is active, which means that the selection starts in a list of the same type.
	 *
	 * @readonly
	 */
	declare public value: boolean;

	/**
	 * Creates an instance of the command.
	 *
	 * @param editor The editor instance.
	 * @param type List type that will be handled by this command.
	 */
	constructor( editor: Editor, type: ListType ) {
		super( editor );

		this.type = type;
	}

	/**
	 * @inheritDoc
	 */
	public override refresh(): void {
		this.value = this._getValue();
		this.isEnabled = this._checkEnabled();
	}

	/**
	 * Executes the list command.
	 *
	 * @fires execute
	 * @param options Command options.
	 * @param options.forceValue If set, it will force the command behavior. If `true`, the command will try to convert the
	 * selected items and potentially the neighbor elements to the proper list items. If set to `false`, it will convert selected elements
	 * to paragraphs. If not set, the command will toggle selected elements to list items or paragraphs, depending on the selection.
	 */
	public override execute( options: { forceValue?: boolean } = {} ): void {
		const model = this.editor.model;
		const document = model.document;
		const blocks = Array.from( document.selection.getSelectedBlocks() )
			.filter( block => checkCanBecomeListItem( block, model.schema ) );

		// Whether we are turning off some items.
		const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value;

		// If we are turning off items, we are going to rename them to paragraphs.

		model.change( writer => {
			// If part of a list got turned off, we need to handle (outdent) all of sub-items of the last turned-off item.
			// To be sure that model is all the time in a good state, we first fix items below turned-off item.
			if ( turnOff ) {
				// Start from the model item that is just after the last turned-off item.
				let next = blocks[ blocks.length - 1 ].nextSibling as ModelElement;
				let currentIndent = Number.POSITIVE_INFINITY;
				let changes = [];

				// Correct indent of all items after the last turned off item.
				// Rules that should be followed:
				// 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
				//    will be the first item of a new list. Other items are at the same level, so should have same 0 index.
				// 2. All items with indent lower than indent of turned-off item should become indent 0, because they
				//    should not end up as a child of any of list items that they were not children of before.
				// 3. All other items should have their indent changed relatively to it's parent.
				//
				// For example:
				// 1  * --------
				// 2     * --------
				// 3        * --------			<-- this is turned off.
				// 4           * --------		<-- this has to become indent = 0, because it will be first item on a new list.
				// 5              * --------	<-- this should be still be a child of item above, so indent = 1.
				// 6        * --------			<-- this has to become indent = 0, because it should not be a child of any of items above.
				// 7           * --------		<-- this should be still be a child of item above, so indent = 1.
				// 8     * --------				<-- this has to become indent = 0.
				// 9        * --------			<-- this should still be a child of item above, so indent = 1.
				// 10          * --------		<-- this should still be a child of item above, so indent = 2.
				// 11          * --------		<-- this should still be at the same level as item above, so indent = 2.
				// 12 * --------				<-- this and all below are left unchanged.
				// 13    * --------
				// 14       * --------
				//
				// After turning off 3 the list becomes:
				//
				// 1  * --------
				// 2     * --------
				//
				// 3  --------
				//
				// 4  * --------
				// 5     * --------
				// 6  * --------
				// 7     * --------
				// 8  * --------
				// 9     * --------
				// 10       * --------
				// 11       * --------
				// 12 * --------
				// 13    * --------
				// 14       * --------
				//
				// Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
				// those parent-child connection which are possible to maintain are still maintained. It's worth noting
				// that this is the same effect that we would be get by multiple use of outdent command. However doing
				// it like this is much more efficient because it's less operation (less memory usage, easier OT) and
				// less conversion (faster).
				while ( next && next.name == 'listItem' && next.getAttribute( 'listIndent' ) !== 0 ) {
					// Check each next list item, as long as its indent is bigger than 0.
					// If the indent is 0 we are not going to change anything anyway.
					const indent = next.getAttribute( 'listIndent' ) as number;

					// We check if that's item indent is lower as current relative indent.
					if ( indent < currentIndent ) {
						// If it is, current relative indent becomes that indent.
						currentIndent = indent;
					}

					// Fix indent relatively to current relative indent.
					// Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
					const newIndent = indent - currentIndent;

					// Save the entry in changes array. We do not apply it at the moment, because we will need to
					// reverse the changes so the last item is changed first.
					// This is to keep model in correct state all the time.
					changes.push( { element: next, listIndent: newIndent } );

					// Find next item.
					next = next.nextSibling as ModelElement;
				}

				changes = changes.reverse();

				for ( const item of changes ) {
					writer.setAttribute( 'listIndent', item.listIndent, item.element );
				}
			}

			// If we are turning on, we might change some items that are already `listItem`s but with different type.
			// Changing one nested list item to other type should also trigger changing all its siblings so the
			// whole nested list is of the same type.
			// Example (assume changing to numbered list):
			// * ------				<-- do not fix, top level item
			//   * ------			<-- fix, because latter list item of this item's list is changed
			//      * ------		<-- do not fix, item is not affected (different list)
			//   * ------			<-- fix, because latter list item of this item's list is changed
			//      * ------		<-- fix, because latter list item of this item's list is changed
			//      * ---[--		<-- already in selection
			//   * ------			<-- already in selection
			//   * ------			<-- already in selection
			// * ------				<-- already in selection, but does not cause other list items to change because is top-level
			//   * ---]--			<-- already in selection
			//   * ------			<-- fix, because preceding list item of this item's list is changed
			//      * ------		<-- do not fix, item is not affected (different list)
			// * ------				<-- do not fix, top level item
			if ( !turnOff ) {
				// Find lowest indent among selected items. This will be indicator what is the indent of
				// top-most list affected by the command.
				let lowestIndent = Number.POSITIVE_INFINITY;

				for ( const item of blocks ) {
					if ( item.is( 'element', 'listItem' ) && ( item.getAttribute( 'listIndent' ) as number ) < lowestIndent ) {
						lowestIndent = item.getAttribute( 'listIndent' ) as number;
					}
				}

				// Do not execute the fix for top-level lists.
				lowestIndent = lowestIndent === 0 ? 1 : lowestIndent;

				// Fix types of list items that are "before" the selected blocks.
				_fixType( blocks, true, lowestIndent );

				// Fix types of list items that are "after" the selected blocks.
				_fixType( blocks, false, lowestIndent );
			}

			// Phew! Now it will be easier :).
			// For each block element that was in the selection, we will either: turn it to list item,
			// turn it to paragraph, or change it's type. Or leave it as it is.
			// Do it in reverse as there might be multiple blocks (same as with changing indents).
			for ( const element of blocks.reverse() ) {
				if ( turnOff && element.name == 'listItem' ) {
					// We are turning off and the element is a `listItem` - it should be converted to `paragraph`.
					// List item specific attributes are removed by post fixer.
					writer.rename( element, 'paragraph' );
				} else if ( !turnOff && element.name != 'listItem' ) {
					// We are turning on and the element is not a `listItem` - it should be converted to `listItem`.
					// The order of operations is important to keep model in correct state.
					writer.setAttributes( { listType: this.type, listIndent: 0 }, element );
					writer.rename( element, 'listItem' );
				} else if ( !turnOff && element.name == 'listItem' && element.getAttribute( 'listType' ) != this.type ) {
					// We are turning on and the element is a `listItem` but has different type - change it's type and
					// type of it's all siblings that have same indent.
					writer.setAttribute( 'listType', this.type, element );
				}
			}

			/**
			 * Event fired by the {@link #execute} method.
			 *
			 * It allows to execute an action after executing the {@link ~ListCommand#execute} method, for example adjusting
			 * attributes of changed blocks.
			 *
			 * @protected
			 * @event _executeCleanup
			 */
			this.fire( '_executeCleanup', blocks );
		} );
	}

	/**
	 * Checks the command's {@link #value}.
	 *
	 * @returns The current value.
	 */
	private _getValue(): boolean {
		// Check whether closest `listItem` ancestor of the position has a correct type.
		const listItem = first( this.editor.model.document.selection.getSelectedBlocks() );

		return !!listItem && listItem.is( 'element', 'listItem' ) && listItem.getAttribute( 'listType' ) == this.type;
	}

	/**
	 * Checks whether the command can be enabled in the current context.
	 *
	 * @returns Whether the command should be enabled.
	 */
	private _checkEnabled() {
		// If command value is true it means that we are in list item, so the command should be enabled.
		if ( this.value ) {
			return true;
		}

		const selection = this.editor.model.document.selection;
		const schema = this.editor.model.schema;

		const firstBlock = first( selection.getSelectedBlocks() );

		if ( !firstBlock ) {
			return false;
		}

		// Otherwise, check if list item can be inserted at the position start.
		return checkCanBecomeListItem( firstBlock, schema );
	}
}

/**
 * Helper function used when one or more list item have their type changed. Fixes type of other list items
 * that are affected by the change (are in same lists) but are not directly in selection. The function got extracted
 * not to duplicated code, as same fix has to be performed before and after selection.
 *
 * @param blocks Blocks that are in selection.
 * @param isBackward Specified whether fix will be applied for blocks before first selected block (`true`)
 * or blocks after last selected block (`false`).
 * @param lowestIndent Lowest indent among selected blocks.
 */
function _fixType(
	blocks: Array<ModelNode>,
	isBackward: boolean,
	lowestIndent: number
) {
	// We need to check previous sibling of first changed item and next siblings of last changed item.
	const startingItem = isBackward ? blocks[ 0 ] : blocks[ blocks.length - 1 ];

	if ( startingItem.is( 'element', 'listItem' ) ) {
		let item = startingItem[ isBackward ? 'previousSibling' : 'nextSibling' ];
		// During processing items, keeps the lowest indent of already processed items.
		// This saves us from changing too many items.
		// Following example is for going forward as it is easier to read, however same applies to going backward.
		// * ------
		//   * ------
		//     * --[---
		//   * ------		<-- `lowestIndent` should be 1
		//     * --]---		<-- `startingItem`, `currentIndent` = 2, `lowestIndent` == 1
		//     * ------		<-- should be fixed, `indent` == 2 == `currentIndent`
		//   * ------		<-- should be fixed, set `currentIndent` to 1, `indent` == 1 == `currentIndent`
		//     * ------		<-- should not be fixed, item is in different list, `indent` = 2, `indent` != `currentIndent`
		//   * ------		<-- should be fixed, `indent` == 1 == `currentIndent`
		// * ------			<-- break loop (`indent` < `lowestIndent`)
		let currentIndent = startingItem.getAttribute( 'listIndent' ) as number;

		// Look back until a list item with indent lower than reference `lowestIndent`.
		// That would be the parent of nested sublist which contains item having `lowestIndent`.
		while ( item && item.is( 'element', 'listItem' ) && ( item.getAttribute( 'listIndent' ) as number ) >= lowestIndent ) {
			if ( currentIndent > ( item.getAttribute( 'listIndent' ) as number ) ) {
				currentIndent = item.getAttribute( 'listIndent' ) as number;
			}

			// Found an item that is in the same nested sublist.
			if ( item.getAttribute( 'listIndent' ) == currentIndent ) {
				// Just add the item to selected blocks like it was selected by the user.
				blocks[ isBackward ? 'unshift' : 'push' ]( item );
			}

			item = item[ isBackward ? 'previousSibling' : 'nextSibling' ];
		}
	}
}

/**
 * Checks whether the given block can be replaced by a listItem.
 *
 * @param block A block to be tested.
 * @param schema The schema of the document.
 */
function checkCanBecomeListItem( block: ModelElement, schema: ModelSchema ) {
	return schema.checkChild( block.parent as any, 'listItem' ) && !schema.isObject( block );
}
