/**
 * @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 engine/view/observer/bubblingemittermixin
 */

import {
	CKEditorError,
	EmitterMixin,
	EventInfo,
	toArray,
	type ArrayOrItem,
	type Emitter,
	type GetEventInfo,
	type GetNameOrEventInfo,
	type BaseEvent,
	type CallbackOptions,
	type Constructor,
	type Mixed
} from '@ckeditor/ckeditor5-utils';

import { BubblingEventInfo, type BubblingEventPhase } from './bubblingeventinfo.js';
import { type ViewDocument } from '../document.js';
import { type ViewNode } from '../node.js';
import { type ViewRange } from '../range.js';
import { type ViewElement } from '../element.js';
import { type ViewDocumentSelection } from '../documentselection.js';

const bubblingEmitterSymbol = Symbol( 'bubblingEmitter' );
const callbackMapSymbol = Symbol( 'bubblingCallbacks' );
const contextsSymbol = Symbol( 'bubblingContexts' );

/**
 * Bubbling emitter mixin for the view document as described in the {@link ~BubblingEmitter} interface.
 *
 * This function creates a class that inherits from the provided `base` and implements `Emitter` interface.
 * The base class must implement {@link module:utils/emittermixin~Emitter} interface.
 *
 * ```ts
 * class BaseClass extends EmitterMixin() {
 * 	// ...
 * }
 *
 * class MyClass extends BubblingEmitterMixin( BaseClass ) {
 * 	// This class derives from `BaseClass` and implements the `BubblingEmitter` interface.
 * }
 * ```
 */
export function BubblingEmitterMixin<Base extends Constructor<Emitter>>( base: Base ): Mixed<Base, BubblingEmitter> {
	abstract class Mixin extends base implements BubblingEmitter {
		public abstract get selection(): ViewDocumentSelection;

		public override fire<TEvent extends BaseEvent>(
			eventOrInfo: GetNameOrEventInfo<TEvent>,
			...eventArgs: TEvent[ 'args' ]
		): GetEventInfo<TEvent>[ 'return' ] {
			try {
				const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo );
				const bubblingEmitter = getBubblingEmitter( this );
				const customContexts = getCustomContexts( this );

				updateEventInfo( eventInfo, 'capturing', this );

				// The capture phase of the event.
				if ( fireListenerFor( bubblingEmitter, '$capture', eventInfo, ...eventArgs ) ) {
					return eventInfo.return;
				}

				const startRange = ( eventInfo as BubblingEventInfo ).startRange || this.selection.getFirstRange();
				const selectedElement = startRange ? startRange.getContainedElement() : null;
				const isCustomContext = selectedElement ? hasMatchingCustomContext( customContexts, selectedElement ) : false;

				let node: ViewNode | null = selectedElement || getDeeperRangeParent( startRange );

				updateEventInfo( eventInfo, 'atTarget', node );

				// For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected.
				if ( !isCustomContext ) {
					if ( fireListenerFor( bubblingEmitter, '$text', eventInfo, ...eventArgs ) ) {
						return eventInfo.return;
					}

					updateEventInfo( eventInfo, 'bubbling', node );
				}

				while ( node ) {
					if ( node.is( 'element' ) && fireListenerFor( bubblingEmitter, node, eventInfo, ...eventArgs ) ) {
						return eventInfo.return;
					}

					node = node.parent as ViewNode;

					updateEventInfo( eventInfo, 'bubbling', node );
				}

				updateEventInfo( eventInfo, 'bubbling', this );

				// Document context.
				fireListenerFor( bubblingEmitter, '$document', eventInfo, ...eventArgs );

				return eventInfo.return;
			} catch ( err: any ) {
				// @if CK_DEBUG // throw err;
				/* istanbul ignore next -- @preserve */
				CKEditorError.rethrowUnexpectedError( err, this );
			}
		}

		public _addEventListener(
			this: ViewDocument,
			event: string,
			callback: ( ev: EventInfo, ...args: Array<any> ) => void,
			options: BubblingCallbackOptions
		) {
			const contexts = toArray( options.context || '$document' );
			const bubblingEmitter = getBubblingEmitter( this );
			const callbacksMap = getCallbackMap( this );

			for ( const context of contexts ) {
				if ( typeof context == 'function' ) {
					getCustomContexts( this ).add( context );
				}
			}

			// Wrap callback with current target match.
			const wrappedCallback = wrapCallback( this, contexts, callback );

			// Store for later removing of listeners.
			callbacksMap.set( callback, wrappedCallback );

			// Listen for the event.
			this.listenTo<BubblingInternalEvent>( bubblingEmitter, event, wrappedCallback, options );
		}

		public _removeEventListener( this: ViewDocument, event: string, callback: Function ): void {
			const bubblingEmitter = getBubblingEmitter( this );
			const callbacksMap = getCallbackMap( this );
			const wrappedCallback = callbacksMap.get( callback );

			if ( wrappedCallback ) {
				callbacksMap.delete( callback );
				this.stopListening( bubblingEmitter, event, wrappedCallback );
			}
		}
	}

	return Mixin as any;
}

/**
 * Update the event info bubbling fields.
 *
 * @param eventInfo The event info object to update.
 * @param eventPhase The current event phase.
 * @param currentTarget The current bubbling target.
 */
function updateEventInfo(
	eventInfo: EventInfo,
	eventPhase: BubblingEventPhase,
	currentTarget: unknown
) {
	if ( eventInfo instanceof BubblingEventInfo ) {
		( eventInfo as any )._eventPhase = eventPhase;
		( eventInfo as any )._currentTarget = currentTarget;
	}
}

/**
 * Fires the listener for the specified context. Returns `true` if event was stopped.
 *
 * @param eventInfo The `EventInfo` object.
 * @param eventArgs Additional arguments to be passed to the callbacks.
 * @returns True if event stop was called.
 */
function fireListenerFor(
	emitter: Emitter,
	currentTarget: string | ViewElement,
	eventInfo: EventInfo,
	...eventArgs: Array<unknown>
) {
	emitter.fire<BubblingInternalEvent>( eventInfo, {
		currentTarget,
		eventArgs
	} );

	// Similar to DOM Event#stopImmediatePropagation() this does not fire events
	// for other contexts on the same node.
	if ( eventInfo.stop.called ) {
		return true;
	}

	return false;
}

/**
 * Returns an event callback wrapped with context check condition.
 */
function wrapCallback(
	emitter: Emitter,
	contexts: Array<string | BubblingEventContextFunction>,
	callback: ( ev: EventInfo, ...args: Array<any> ) => void
) {
	return function( event: EventInfo, data: BubblingEventInternalData ) {
		const { currentTarget, eventArgs } = data;

		// Quick path for string based context ($capture, $text, $document).
		if ( typeof currentTarget == 'string' ) {
			if ( contexts.includes( currentTarget ) ) {
				callback.call( emitter, event, ...eventArgs );
			}

			return;
		}

		// The current target is a view element.

		// Special case for the root element as it could be handled ac $root context.
		// Note that it could also be matched later by custom context.
		if ( currentTarget.is( 'rootElement' ) && contexts.includes( '$root' ) ) {
			callback.call( emitter, event, ...eventArgs );

			return;
		}

		// Check if it is a context for this element name.
		if ( contexts.includes( currentTarget.name ) ) {
			callback.call( emitter, event, ...eventArgs );

			return;
		}

		// Check if dynamic context matches.
		for ( const context of contexts ) {
			if ( typeof context == 'function' && context( currentTarget ) ) {
				callback.call( emitter, event, ...eventArgs );

				return;
			}
		}
	};
}

/**
 * Returns bubbling emitter for the source (emitter).
 */
function getBubblingEmitter( source: { [ x: string ]: any; [ bubblingEmitterSymbol ]?: Emitter } ) {
	if ( !source[ bubblingEmitterSymbol ] ) {
		source[ bubblingEmitterSymbol ] = new ( EmitterMixin() )();
	}

	return source[ bubblingEmitterSymbol ];
}

/**
 * Returns map of callbacks (original to wrapped one).
 */
function getCallbackMap( source: { [ x: string ]: any; [ callbackMapSymbol ]?: Map<any, any> } ) {
	if ( !source[ callbackMapSymbol ] ) {
		source[ callbackMapSymbol ] = new Map();
	}

	return source[ callbackMapSymbol ];
}

/**
 * Returns the set of registered custom contexts.
 */
function getCustomContexts( source: { [ x: string ]: any; [ contextsSymbol ]?: Set<any> } ) {
	if ( !source[ contextsSymbol ] ) {
		source[ contextsSymbol ] = new Set();
	}

	return source[ contextsSymbol ];
}

/**
 * Returns true if any of custom context match the given element.
 */
function hasMatchingCustomContext( customContexts: Set<any>, element: ViewElement ) {
	for ( const context of customContexts ) {
		if ( context( element ) ) {
			return true;
		}
	}

	return false;
}

/**
 * Returns the deeper parent element for the range.
 */
function getDeeperRangeParent( range: ViewRange ): ViewNode | null {
	if ( !range ) {
		return null;
	}

	const startParent = range.start.parent as ViewElement;
	const endParent = range.end.parent as ViewElement;

	const startPath = startParent.getPath();
	const endPath = endParent.getPath();

	return startPath.length > endPath.length ? startParent : endParent;
}

/**
 * Bubbling emitter for the view document.
 *
 * Bubbling emitter is triggering events in the context of specified {@link module:engine/view/element~ViewElement view element} name,
 * predefined `'$text'`, `'$root'`, `'$document'` and `'$capture'` contexts, and context matchers provided as a function.
 *
 * Before bubbling starts, listeners for `'$capture'` context are triggered. Then the bubbling starts from the deeper selection
 * position (by firing event on the `'$text'` context) and propagates the view document tree up to the `'$root'` and finally
 * the listeners at `'$document'` context are fired (this is the default context).
 *
 * Examples:
 *
 * ```ts
 * // Listeners registered in the context of the view element names:
 * this.listenTo( viewDocument, 'enter', ( evt, data ) => {
 * 	// ...
 * }, { context: 'blockquote' } );
 *
 * this.listenTo( viewDocument, 'enter', ( evt, data ) => {
 * 	// ...
 * }, { context: 'li' } );
 *
 * // Listeners registered in the context of the '$text' and '$root' nodes.
 * this.listenTo( view.document, 'arrowKey', ( evt, data ) => {
 * 	// ...
 * }, { context: '$text', priority: 'high' } );
 *
 * this.listenTo( view.document, 'arrowKey', ( evt, data ) => {
 * 	// ...
 * }, { context: '$root' } );
 *
 * // Listeners registered in the context of custom callback function.
 * this.listenTo( view.document, 'arrowKey', ( evt, data ) => {
 * 	// ...
 * }, { context: isWidget } );
 *
 * this.listenTo( view.document, 'arrowKey', ( evt, data ) => {
 * 	// ...
 * }, { context: isWidget, priority: 'high' } );
 * ```
 *
 * Example flow for selection in text:
 *
 * ```xml
 * <blockquote><p>Foo[]bar</p></blockquote>
 * ```
 *
 * Fired events on contexts:
 * 1. `'$capture'`
 * 2. `'$text'`
 * 3. `'p'`
 * 4. `'blockquote'`
 * 5. `'$root'`
 * 6. `'$document'`
 *
 * Example flow for selection on element (i.e., Widget):
 *
 * ```xml
 * <blockquote><p>Foo[<widget/>]bar</p></blockquote>
 * ```
 *
 * Fired events on contexts:
 * 1. `'$capture'`
 * 2. *widget* (custom matcher)
 * 3. `'p'`
 * 4. `'blockquote'`
 * 5. `'$root'`
 * 6. `'$document'`
 *
 * There could be multiple listeners registered for the same context and at different priority levels:
 *
 * ```html
 * <p>Foo[]bar</p>
 * ```
 *
 * 1. `'$capture'` at priorities:
 *    1. `'highest'`
 *    2. `'high'`
 *    3. `'normal'`
 *    4. `'low'`
 *    5. `'lowest'`
 * 2. `'$text'` at priorities:
 *    1. `'highest'`
 *    2. `'high'`
 *    3. `'normal'`
 *    4. `'low'`
 *    5. `'lowest'`
 * 3. `'p'` at priorities:
 *    1. `'highest'`
 *    2. `'high'`
 *    3. `'normal'`
 *    4. `'low'`
 *    5. `'lowest'`
 * 4. `'$root'` at priorities:
 *    1. `'highest'`
 *    2. `'high'`
 *    3. `'normal'`
 *    4. `'low'`
 *    5. `'lowest'`
 * 5. `'$document'` at priorities:
 *    1. `'highest'`
 *    2. `'high'`
 *    3. `'normal'`
 *    4. `'low'`
 *    5. `'lowest'`
 */
export type BubblingEmitter = Emitter;

/**
 * A context matcher function.
 *
 * Should return true for nodes that that match the custom context.
 */
export type BubblingEventContextFunction = ( node: ViewNode ) => boolean;

/**
 * Helper type that allows describing bubbling event. Extends `TEvent` so that:
 *
 * * the event is called with {@link module:engine/view/observer/bubblingeventinfo~BubblingEventInfo}`
 * instead of {@link module:utils/eventinfo~EventInfo}, and
 * * {@link ~BubblingCallbackOptions} can be specified as additional options.
 *
 * @typeParam TEvent The event description to extend.
 */
export type BubblingEvent<TEvent extends BaseEvent> = TEvent & {
	eventInfo: BubblingEventInfo<TEvent[ 'name' ], ( TEvent extends { return: infer TReturn } ? TReturn : unknown )>;
	callbackOptions: BubblingCallbackOptions;
};

/**
 * Additional options for registering a callback.
 */
export interface BubblingCallbackOptions extends CallbackOptions {

	/**
	 * Specifies the context in which the event should be triggered to call the callback.
	 *
	 * @see ~BubblingEmitter
	 */
	context?: ArrayOrItem<string | BubblingEventContextFunction>;
}

/**
 * Internal emitter event type.
 */
type BubblingInternalEvent = {
	name: string;
	args: [ data: BubblingEventInternalData ];
};

/**
 * Internal emitter data type.
 */
type BubblingEventInternalData = {
	currentTarget: string | ViewElement;
	eventArgs: Array<unknown>;
};
