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

/**
 * @module html-support/htmlcomment
 */

import { Plugin } from 'ckeditor5/src/core';
import { uid } from 'ckeditor5/src/utils';

/**
 * The HTML comment feature. It preserves the HTML comments (`<!-- -->`) in the editor data.
 *
 * For a detailed overview, check the {@glink features/general-html-support#html-comments HTML comment feature documentation}.
 *
 * @extends module:core/plugin~Plugin
 */
export default class HtmlComment extends Plugin {
	/**
	 * @inheritDoc
	 */
	static get pluginName() {
		return 'HtmlComment';
	}

	/**
	 * @inheritDoc
	 */
	init() {
		const editor = this.editor;

		// Allow storing comment's content as the $root attribute with the name `$comment:<unique id>`.
		editor.model.schema.addAttributeCheck( ( context, attributeName ) => {
			if ( context.endsWith( '$root' ) && attributeName.startsWith( '$comment' ) ) {
				return true;
			}
		} );

		// Convert the `$comment` view element to `$comment:<unique id>` marker and store its content (the comment itself) as a $root
		// attribute. The comment content is needed in the `dataDowncast` pipeline to re-create the comment node.
		editor.conversion.for( 'upcast' ).elementToMarker( {
			view: '$comment',
			model: ( viewElement, { writer } ) => {
				const root = this.editor.model.document.getRoot();
				const commentContent = viewElement.getCustomProperty( '$rawContent' );
				const markerName = `$comment:${ uid() }`;

				writer.setAttribute( markerName, commentContent, root );

				return markerName;
			}
		} );

		// Convert the `$comment` marker to `$comment` UI element with `$rawContent` custom property containing the comment content.
		editor.conversion.for( 'dataDowncast' ).markerToElement( {
			model: '$comment',
			view: ( modelElement, { writer } ) => {
				const root = this.editor.model.document.getRoot();
				const markerName = modelElement.markerName;
				const commentContent = root.getAttribute( markerName );
				const comment = writer.createUIElement( '$comment' );

				writer.setCustomProperty( '$rawContent', commentContent, comment );

				return comment;
			}
		} );

		// Remove comments' markers and their corresponding $root attributes, which are no longer present.
		editor.model.document.registerPostFixer( writer => {
			const root = editor.model.document.getRoot();

			const changedMarkers = editor.model.document.differ.getChangedMarkers();

			const changedCommentMarkers = changedMarkers.filter( marker => {
				return marker.name.startsWith( '$comment' );
			} );

			const removedCommentMarkers = changedCommentMarkers.filter( marker => {
				const newRange = marker.data.newRange;

				return newRange && newRange.root.rootName === '$graveyard';
			} );

			if ( removedCommentMarkers.length === 0 ) {
				return false;
			}

			for ( const marker of removedCommentMarkers ) {
				writer.removeMarker( marker.name );
				writer.removeAttribute( marker.name, root );
			}

			return true;
		} );

		// Delete all comment markers from the document before setting new data.
		editor.data.on( 'set', () => {
			for ( const commentMarker of editor.model.markers.getMarkersGroup( '$comment' ) ) {
				this.removeHtmlComment( commentMarker.name );
			}
		}, { priority: 'high' } );

		// Delete all comment markers that are within a removed range.
		// Delete all comment markers at the limit element boundaries if the whole content of the limit element is removed.
		editor.model.on( 'deleteContent', ( evt, [ selection ] ) => {
			for ( const range of selection.getRanges() ) {
				const limitElement = editor.model.schema.getLimitElement( range );
				const firstPosition = editor.model.createPositionAt( limitElement, 0 );
				const lastPosition = editor.model.createPositionAt( limitElement, 'end' );

				let affectedCommentIDs;

				if ( firstPosition.isTouching( range.start ) && lastPosition.isTouching( range.end ) ) {
					affectedCommentIDs = this.getHtmlCommentsInRange( editor.model.createRange( firstPosition, lastPosition ) );
				} else {
					affectedCommentIDs = this.getHtmlCommentsInRange( range, { skipBoundaries: true } );
				}

				for ( const commentMarkerID of affectedCommentIDs ) {
					this.removeHtmlComment( commentMarkerID );
				}
			}
		}, { priority: 'high' } );
	}

	/**
	 * Creates an HTML comment on the specified position and returns its ID.
	 *
	 * *Note*: If two comments are created at the same position, the second comment will be inserted before the first one.
	 *
	 * @param {module:engine/model/position~Position} position
	 * @param {String} content
	 * @returns {String} Comment ID. This ID can be later used to e.g. remove the comment from the content.
	 */
	createHtmlComment( position, content ) {
		const id = uid();
		const editor = this.editor;
		const model = editor.model;
		const root = model.document.getRoot();
		const markerName = `$comment:${ id }`;

		return model.change( writer => {
			const range = writer.createRange( position );

			writer.addMarker( markerName, {
				usingOperation: true,
				affectsData: true,
				range
			} );

			writer.setAttribute( markerName, content, root );

			return markerName;
		} );
	}

	/**
	 * Removes an HTML comment with the given comment ID.
	 *
	 * It does nothing and returns `false` if the comment with the given ID does not exist.
	 * Otherwise it removes the comment and returns `true`.
	 *
	 * Note that a comment can be removed also by removing the content around the comment.
	 *
	 * @param {String} commentID The ID of the comment to be removed.
	 * @returns {Boolean} `true` when the comment with the given ID was removed, `false` otherwise.
	 */
	removeHtmlComment( commentID ) {
		const editor = this.editor;
		const root = editor.model.document.getRoot();

		const marker = editor.model.markers.get( commentID );

		if ( !marker ) {
			return false;
		}

		editor.model.change( writer => {
			writer.removeMarker( marker );
			writer.removeAttribute( commentID, root );
		} );

		return true;
	}

	/**
	 * Gets the HTML comment data for the comment with a given ID.
	 *
	 * Returns `null` if the comment does not exist.
	 *
	 * @param {String} commentID
	 * @returns {module:html-support/htmlcomment~HtmlCommentData}
	 */
	getHtmlCommentData( commentID ) {
		const editor = this.editor;
		const marker = editor.model.markers.get( commentID );
		const root = editor.model.document.getRoot();

		if ( !marker ) {
			return null;
		}

		return {
			content: root.getAttribute( commentID ),
			position: marker.getStart()
		};
	}

	/**
	 * Gets all HTML comments in the given range.
	 *
	 * By default it includes comments at the range boundaries.
	 *
	 * @param {module:engine/model/range~Range} range
	 * @param {Object} [options]
	 * @param {Boolean} [options.skipBoundaries=false] When set to `true` the range boundaries will be skipped.
	 * @returns {Array.<String>} HTML comment IDs
	 */
	getHtmlCommentsInRange( range, { skipBoundaries = false } = {} ) {
		const includeBoundaries = !skipBoundaries;

		// Unfortunately, MarkerCollection#getMarkersAtPosition() filters out collapsed markers.
		return Array.from( this.editor.model.markers.getMarkersGroup( '$comment' ) )
			.filter( marker => isCommentMarkerInRange( marker, range ) )
			.map( marker => marker.name );

		function isCommentMarkerInRange( commentMarker, range ) {
			const position = commentMarker.getRange().start;

			return (
				( position.isAfter( range.start ) || ( includeBoundaries && position.isEqual( range.start ) ) ) &&
				( position.isBefore( range.end ) || ( includeBoundaries && position.isEqual( range.end ) ) )
			);
		}
	}
}

/**
 * An interface for the HTML comments data.
 *
 * It consists of the {@link module:engine/model/position~Position `position`} and `content`.
 *
 * @typedef {Object} module:html-support/htmlcomment~HtmlCommentData
 *
 * @property {module:engine/model/position~Position} position
 * @property {String} content
 */
