/**
 * @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 restricted-editing/restrictededitingmode/converters
 */

import type { Editor } from '@ckeditor/ckeditor5-core';
import {
	Matcher,
	type ViewDowncastWriter,
	type MatcherPattern,
	type ModelPostFixer,
	type ModelPosition,
	type UpcastDispatcher,
	type ModelWriter,
	type ViewElement
} from '@ckeditor/ckeditor5-engine';

import { getMarkerAtPosition } from './utils.js';

const HIGHLIGHT_CLASS = 'restricted-editing-exception_selected';

/**
 * Adds a visual highlight style to a restricted editing exception that the selection is anchored to.
 *
 * The highlight is turned on by adding the `.restricted-editing-exception_selected` class to the
 * exception in the view:
 *
 * * The class is removed before the conversion starts, as callbacks added with the `'highest'` priority
 * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events.
 * * The class is added in the view post-fixer, after other changes in the model tree are converted to the view.
 *
 * This way, adding and removing the highlight does not interfere with conversion.
 *
 * @internal
 */
export function setupExceptionHighlighting( editor: Editor ): void {
	const view = editor.editing.view;
	const model = editor.model;
	const highlightedMarkers = new Set<ViewElement>();

	// Adding the class.
	view.document.registerPostFixer( ( writer: ViewDowncastWriter ): boolean => {
		const modelSelection = model.document.selection;

		const marker = getMarkerAtPosition( editor, modelSelection.anchor! );

		if ( !marker ) {
			return false;
		}

		const modelWrapperElement = marker.getRange().getContainedElement();

		if ( modelWrapperElement && modelWrapperElement.is( 'element', 'restrictedEditingException' ) ) {
			const viewElement = editor.editing.mapper.toViewElement( modelWrapperElement )!;

			writer.addClass( HIGHLIGHT_CLASS, viewElement );
			highlightedMarkers.add( viewElement );
		} else {
			for ( const viewElement of editor.editing.mapper.markerNameToElements( marker.name )! ) {
				writer.addClass( HIGHLIGHT_CLASS, viewElement );
				highlightedMarkers.add( viewElement );
			}
		}

		return false;
	} );

	// Removing the class.
	editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
		// Make sure the highlight is removed on every possible event, before conversion is started.
		dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
		dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
		dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
		dispatcher.on( 'cleanSelection', removeHighlight );

		function removeHighlight() {
			view.change( writer => {
				for ( const item of highlightedMarkers.values() ) {
					writer.removeClass( HIGHLIGHT_CLASS, item );
					highlightedMarkers.delete( item );
				}
			} );
		}
	} );
}

/**
 * A post-fixer that prevents removing a collapsed marker from the document.
 *
 * @internal
 */
export function resurrectCollapsedMarkerPostFixer( editor: Editor ): ModelPostFixer {
	// This post-fixer shouldn't be necessary after https://github.com/ckeditor/ckeditor5/issues/5778.
	return writer => {
		let changeApplied = false;

		for ( const { name, data } of editor.model.document.differ.getChangedMarkers() ) {
			if ( name.startsWith( 'restrictedEditingException' ) && data.newRange && data.newRange.root.rootName == '$graveyard' ) {
				writer.updateMarker( name, {
					range: writer.createRange( writer.createPositionAt( data.oldRange!.start ) )
				} );

				changeApplied = true;
			}
		}

		return changeApplied;
	};
}

/**
 * A post-fixer that extends a marker when the user types on its boundaries.
 *
 * @internal
 */
export function extendMarkerOnTypingPostFixer( editor: Editor ): ModelPostFixer {
	// This post-fixer shouldn't be necessary after https://github.com/ckeditor/ckeditor5/issues/5778.
	return writer => {
		let changeApplied = false;
		const schema = editor.model.schema;

		for ( const change of editor.model.document.differ.getChanges() ) {
			if ( change.type == 'insert' && schema.checkChild( '$block', change.name ) ) {
				changeApplied = _tryExtendMarkerStart( editor, change.position, change.length, writer ) || changeApplied;
				changeApplied = _tryExtendMarkedEnd( editor, change.position, change.length, writer ) || changeApplied;
			}
		}

		return changeApplied;
	};
}

/**
 * A view highlight-to-marker conversion helper.
 *
 * @param config Conversion configuration.
 * @internal
 */
export function upcastHighlightToMarker( config: { view: MatcherPattern; model: () => string; useWrapperElement?: boolean } ) {
	return ( dispatcher: UpcastDispatcher ): void => dispatcher.on( 'element', ( evt, data, conversionApi ) => {
		const { writer } = conversionApi;

		const matcher = new Matcher( config.view );
		const matcherResult = matcher.match( data.viewItem );

		// If there is no match, this callback should not do anything.
		if ( !matcherResult ) {
			return;
		}

		const match = matcherResult.match;

		// Force consuming element's name (taken from upcast helpers elementToElement converter).
		match.name = true;

		if ( !conversionApi.consumable.test( data.viewItem, match ) ) {
			return;
		}

		let position = data.modelCursor;
		let wrapperElement = null;

		if ( config.useWrapperElement ) {
			if ( !conversionApi.schema.checkChild( position, 'restrictedEditingException' ) ) {
				return;
			}

			wrapperElement = writer.createElement( 'restrictedEditingException' );
			writer.insert( wrapperElement, position );
			position = writer.createPositionAt( wrapperElement, 0 );
		}

		const { modelRange: convertedChildrenRange } = conversionApi.convertChildren( data.viewItem, position );

		conversionApi.consumable.consume( data.viewItem, match );

		const markerName = config.model();
		const fakeMarkerStart = writer.createElement( '$marker', { 'data-name': markerName } );
		const fakeMarkerEnd = writer.createElement( '$marker', { 'data-name': markerName } );

		if ( wrapperElement ) {
			writer.insert( fakeMarkerStart, writer.createPositionBefore( wrapperElement ) );
			writer.insert( fakeMarkerEnd, writer.createPositionAfter( wrapperElement ) );
		} else {
			// Insert in reverse order to use converter content positions directly (without recalculating).
			writer.insert( fakeMarkerEnd, convertedChildrenRange.end );
			writer.insert( fakeMarkerStart, convertedChildrenRange.start );
		}

		data.modelRange = writer.createRange(
			writer.createPositionBefore( fakeMarkerStart ),
			writer.createPositionAfter( fakeMarkerEnd )
		);
		data.modelCursor = data.modelRange.end;
	} );
}

/**
 * Extend marker if change detected on marker's start position.
 */
function _tryExtendMarkerStart( editor: Editor, position: ModelPosition, length: number, writer: ModelWriter ): boolean {
	const markerAtStart = getMarkerAtPosition( editor, position.getShiftedBy( length ) );

	if ( markerAtStart && markerAtStart.getStart().isEqual( position.getShiftedBy( length ) ) ) {
		writer.updateMarker( markerAtStart, {
			range: writer.createRange( markerAtStart.getStart().getShiftedBy( -length ), markerAtStart.getEnd() )
		} );

		return true;
	}

	return false;
}

/**
 * Extend marker if change detected on marker's end position.
 */
function _tryExtendMarkedEnd( editor: Editor, position: ModelPosition, length: number, writer: ModelWriter ): boolean {
	const markerAtEnd = getMarkerAtPosition( editor, position );

	if ( markerAtEnd && markerAtEnd.getEnd().isEqual( position ) ) {
		writer.updateMarker( markerAtEnd, {
			range: writer.createRange( markerAtEnd.getStart(), markerAtEnd.getEnd().getShiftedBy( length ) )
		} );

		return true;
	}

	return false;
}
