/**
 * @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
 */

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

import {
	ModelLiveRange,
	ModelSchemaContext,
	type ModelDocumentChangeEvent,
	type ModelItem,
	type ModelText,
	type ModelWriter,
	type ModelDocumentSelection
} from '@ckeditor/ckeditor5-engine';

import { first } from '@ckeditor/ckeditor5-utils';

import { type Autoformat } from './autoformat.js';

import type { Delete } from '@ckeditor/ckeditor5-typing';

/**
 * The block autoformatting engine. It allows to format various block patterns. For example,
 * it can be configured to turn a paragraph starting with `*` and followed by a space into a list item.
 *
 * The autoformatting operation is integrated with the undo manager,
 * so the autoformatting step can be undone if the user's intention was not to format the text.
 *
 * See the {@link module:autoformat/blockautoformatediting~blockAutoformatEditing `blockAutoformatEditing`} documentation
 * to learn how to create custom block autoformatters. You can also use
 * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
 * (lists, headings, bold and italic).
 *
 * @module autoformat/blockautoformatediting
 */

/**
 * Creates a listener triggered on {@link module:engine/model/document~ModelDocument#event:change:data `change:data`} event in the document.
 * Calls the callback when inserted text matches the regular expression or the command name
 * if provided instead of the callback.
 *
 * Examples of usage:
 *
 * To convert a paragraph into heading 1 when `- ` is typed, using just the command name:
 *
 * ```ts
 * blockAutoformatEditing( editor, plugin, /^\- $/, 'heading1' );
 * ```
 *
 * To convert a paragraph into heading 1 when `- ` is typed, using just the callback:
 *
 * ```ts
 * blockAutoformatEditing( editor, plugin, /^\- $/, ( context ) => {
 * 	const { match } = context;
 * 	const headingLevel = match[ 1 ].length;
 *
 * 	editor.execute( 'heading', {
 * 		formatId: `heading${ headingLevel }`
 * 	} );
 * } );
 * ```
 *
 * @param editor The editor instance.
 * @param plugin The autoformat plugin instance.
 * @param pattern The regular expression to execute on just inserted text. The regular expression is tested against the text
 * from the beginning until the caret position.
 * @param callbackOrCommand The callback to execute or the command to run when the text is matched.
 * In case of providing the callback, it receives the following parameter:
 * * match RegExp.exec() result of matching the pattern to inserted text.
 */
export function blockAutoformatEditing(
	editor: Editor,
	plugin: Autoformat,
	pattern: RegExp,
	callbackOrCommand: string | ( ( context: { match: RegExpExecArray } ) => unknown )
): void {
	let callback: ( context: { match: RegExpExecArray } ) => unknown;
	let command: Command | null = null;

	if ( typeof callbackOrCommand == 'function' ) {
		callback = callbackOrCommand;
	} else {
		// We assume that the actual command name was provided.
		command = editor.commands.get( callbackOrCommand )!;

		callback = () => {
			editor.execute( callbackOrCommand );
		};
	}

	editor.model.document.on<ModelDocumentChangeEvent>( 'change:data', ( evt, batch ) => {
		if ( command && !command.isEnabled || !plugin.isEnabled ) {
			return;
		}

		const range = first( editor.model.document.selection.getRanges() )!;

		if ( !range.isCollapsed ) {
			return;
		}

		if ( batch.isUndo || !batch.isLocal ) {
			return;
		}

		const changes = Array.from( editor.model.document.differ.getChanges() );
		const entry = changes[ 0 ];

		// Typing is represented by only a single change.
		if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
			return;
		}

		const blockToFormat = entry.position.parent;

		// Block formatting should be disabled in codeBlocks (#5800).
		if ( blockToFormat.is( 'element', 'codeBlock' ) ) {
			return;
		}

		// Only list commands and custom callbacks can be applied inside a list.
		if ( blockToFormat.is( 'element', 'listItem' ) &&
			typeof callbackOrCommand !== 'function' &&
			![ 'numberedList', 'bulletedList', 'todoList' ].includes( callbackOrCommand )
		) {
			return;
		}

		// In case a command is bound, do not re-execute it over an existing block style which would result in a style removal.
		// Instead, just drop processing so that autoformat trigger text is not lost. E.g. writing "# " in a level 1 heading.
		if ( command && command.value === true ) {
			return;
		}

		const firstNode = blockToFormat.getChild( 0 ) as ModelText;

		const firstNodeRange = editor.model.createRangeOn( firstNode );

		// Range is only expected to be within or at the very end of the first text node.
		if ( !firstNodeRange.containsRange( range ) && !range.end.isEqual( firstNodeRange.end ) ) {
			return;
		}

		const match = pattern.exec( firstNode.data.substr( 0, range.end.offset ) );

		// ...and this text node's data match the pattern.
		if ( !match ) {
			return;
		}

		// Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
		editor.model.enqueueChange( writer => {
			const selection = editor.model.document.selection;

			// Matched range.
			const start = writer.createPositionAt( blockToFormat, 0 );
			const end = writer.createPositionAt( blockToFormat, match[ 0 ].length );
			const range = new ModelLiveRange( start, end );

			const wasChanged = callback( { match } );

			// Remove matched text.
			if ( wasChanged !== false ) {
				// Store selection attributes to restore them after matched text removed.
				const selectionAttributes = Array.from( selection.getAttributes() );

				writer.remove( range );

				const selectionRange = selection.getFirstRange()!;
				const blockRange = writer.createRangeIn( blockToFormat );

				// If the block is empty and the document selection has been moved when
				// applying formatting (e.g. is now in newly created block).
				if ( blockToFormat.isEmpty && !blockRange.isEqual( selectionRange ) && !blockRange.containsRange( selectionRange, true ) ) {
					writer.remove( blockToFormat as ModelItem );
				}

				// Restore selection attributes.
				restoreSelectionAttributes( writer, selection, selectionAttributes );
			}
			range.detach();

			editor.model.enqueueChange( () => {
				const deletePlugin: Delete = editor.plugins.get( 'Delete' );

				deletePlugin.requestUndoOnBackspace();
			} );
		} );
	} );
}

/**
 * Restore allowed selection attributes.
 */
function restoreSelectionAttributes(
	writer: ModelWriter,
	selection: ModelDocumentSelection,
	selectionAttributes: Array<[ string, unknown ]>
): void {
	const schema = writer.model.schema;
	const selectionPosition = selection.getFirstPosition()!;
	let selectionSchemaContext = new ModelSchemaContext( selectionPosition );

	if ( schema.checkChild( selectionSchemaContext, '$text' ) ) {
		selectionSchemaContext = selectionSchemaContext.push( '$text' );
	}

	for ( const [ attributeName, attributeValue ] of selectionAttributes ) {
		if ( schema.checkAttribute( selectionSchemaContext, attributeName ) ) {
			writer.setSelectionAttribute( attributeName, attributeValue );
		}
	}
}
