/**
 * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */

/**
 * @module widget/widgetresize/resizer
 */

import View from '@ckeditor/ckeditor5-ui/src/view';
import Template from '@ckeditor/ckeditor5-ui/src/template';
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';

import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';

import ResizeState from './resizerstate';

/**
 * Represents a resizer for a single resizable object.
 *
 * @mixes module:utils/observablemixin~ObservableMixin
 */
export default class Resizer {
	/**
	 * @param {module:widget/widgetresize~ResizerOptions} options Resizer options.
	 */
	constructor( options ) {
		/**
		 * Stores the state of the resizable host geometry, such as the original width, the currently proposed height, etc.
		 *
		 * Note that a new state is created for each resize transaction.
		 *
		 * @readonly
		 * @member {module:widget/widgetresize/resizerstate~ResizerState} #state
		 */

		/**
		 * A view displaying the proposed new element size during the resizing.
		 *
		 * @protected
		 * @readonly
		 * @member {module:widget/widgetresize/resizer~SizeView} #_sizeUI
		 */

		/**
		 * Options passed to the {@link #constructor}.
		 *
		 * @private
		 * @type {module:widget/widgetresize~ResizerOptions}
		 */
		this._options = options;

		/**
		 * Container of the entire resize UI.
		 *
		 * Note that this property is initialized only after the element bound with the resizer is drawn
		 * so it will be a `null` when uninitialized.
		 *
		 * @private
		 * @type {HTMLElement|null}
		 */
		this._domResizerWrapper = null;

		/**
		 * A wrapper that is controlled by the resizer. This is usually a widget element.
		 *
		 * @private
		 * @type {module:engine/view/element~Element|null}
		 */
		this._viewResizerWrapper = null;

		/**
		 * The width of the resized {@link module:widget/widgetresize~ResizerOptions#viewElement viewElement} before the resizing started.
		 *
		 * @private
		 * @member {Number|String|undefined} #_initialViewWidth
		 */

		/**
		 * @observable
		 */
		this.set( 'isEnabled', true );

		this.decorate( 'begin' );
		this.decorate( 'cancel' );
		this.decorate( 'commit' );
		this.decorate( 'updateSize' );

		this.on( 'commit', event => {
			// State might not be initialized yet. In this case, prevent further handling and make sure that the resizer is
			// cleaned up (#5195).
			if ( !this.state.proposedWidth && !this.state.proposedWidthPercents ) {
				this._cleanup();
				event.stop();
			}
		}, { priority: 'high' } );
	}

	/**
	 * Attaches the resizer to the DOM.
	 */
	attach() {
		const that = this;
		const widgetElement = this._options.viewElement;
		const editingView = this._options.editor.editing.view;

		editingView.change( writer => {
			const viewResizerWrapper = writer.createUIElement( 'div', {
				class: 'ck ck-reset_all ck-widget__resizer'
			}, function( domDocument ) {
				const domElement = this.toDomElement( domDocument );

				that._appendHandles( domElement );
				that._appendSizeUI( domElement );

				that._domResizerWrapper = domElement;

				that.on( 'change:isEnabled', ( evt, propName, newValue ) => {
					domElement.style.display = newValue ? '' : 'none';
				} );

				domElement.style.display = that.isEnabled ? '' : 'none';

				return domElement;
			} );

			// Append the resizer wrapper to the widget's wrapper.
			writer.insert( writer.createPositionAt( widgetElement, 'end' ), viewResizerWrapper );
			writer.addClass( 'ck-widget_with-resizer', widgetElement );

			this._viewResizerWrapper = viewResizerWrapper;
		} );
	}

	/**
	 * Starts the resizing process.
	 *
	 * Creates a new {@link #state} for the current process.
	 *
	 * @fires begin
	 * @param {HTMLElement} domResizeHandle Clicked handle.
	 */
	begin( domResizeHandle ) {
		this.state = new ResizeState( this._options );

		this._sizeUI.bindToState( this._options, this.state );

		this._initialViewWidth = this._options.viewElement.getStyle( 'width' );

		this.state.begin( domResizeHandle, this._getHandleHost(), this._getResizeHost() );
	}

	/**
	 * Updates the proposed size based on `domEventData`.
	 *
	 * @fires updateSize
	 * @param {Event} domEventData
	 */
	updateSize( domEventData ) {
		const newSize = this._proposeNewSize( domEventData );
		const editingView = this._options.editor.editing.view;

		editingView.change( writer => {
			const unit = this._options.unit || '%';
			const newWidth = ( unit === '%' ? newSize.widthPercents : newSize.width ) + unit;

			writer.setStyle( 'width', newWidth, this._options.viewElement );
		} );

		// Get an actual image width, and:
		// * reflect this size to the resize wrapper
		// * apply this **real** size to the state
		const domHandleHost = this._getHandleHost();
		const domHandleHostRect = new Rect( domHandleHost );

		newSize.handleHostWidth = Math.round( domHandleHostRect.width );
		newSize.handleHostHeight = Math.round( domHandleHostRect.height );

		// Handle max-width limitation.
		const domResizeHostRect = new Rect( domHandleHost );

		newSize.width = Math.round( domResizeHostRect.width );
		newSize.height = Math.round( domResizeHostRect.height );

		this.redraw( domHandleHostRect );

		this.state.update( newSize );
	}

	/**
	 * Applies the geometry proposed with the resizer.
	 *
	 * @fires commit
	 */
	commit() {
		const unit = this._options.unit || '%';
		const newValue = ( unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth ) + unit;

		// Both cleanup and onCommit callback are very likely to make view changes. Ensure that it is made in a single step.
		this._options.editor.editing.view.change( () => {
			this._cleanup();
			this._options.onCommit( newValue );
		} );
	}

	/**
	 * Cancels and rejects the proposed resize dimensions, hiding the UI.
	 *
	 * @fires cancel
	 */
	cancel() {
		this._cleanup();
	}

	/**
	 * Destroys the resizer.
	 */
	destroy() {
		this.cancel();
	}

	/**
	 * Redraws the resizer.
	 *
	 * @param {module:utils/dom/rect~Rect} [handleHostRect] Handle host rectangle might be given to improve performance.
	 */
	redraw( handleHostRect ) {
		const domWrapper = this._domResizerWrapper;

		if ( existsInDom( domWrapper ) ) {
			this._options.editor.editing.view.change( writer => {
				// Refresh only if resizer exists in the DOM.
				const widgetWrapper = domWrapper.parentElement;
				const handleHost = this._getHandleHost();
				const clientRect = handleHostRect || new Rect( handleHost );

				writer.setStyle( 'width', clientRect.width + 'px', this._viewResizerWrapper );
				writer.setStyle( 'height', clientRect.height + 'px', this._viewResizerWrapper );

				const offsets = {
					left: handleHost.offsetLeft,
					top: handleHost.offsetTop,
					height: handleHost.offsetHeight,
					width: handleHost.offsetWidth
				};

				// In case a resizing host is not a widget wrapper, we need to compensate
				// for any additional offsets the resize host might have. E.g. wrapper padding
				// or simply another editable. By doing that the border and resizers are shown
				// only around the resize host.
				if ( !widgetWrapper.isSameNode( handleHost ) ) {
					writer.setStyle( 'left', offsets.left + 'px', this._viewResizerWrapper );
					writer.setStyle( 'top', offsets.top + 'px', this._viewResizerWrapper );

					writer.setStyle( 'height', offsets.height + 'px', this._viewResizerWrapper );
					writer.setStyle( 'width', offsets.width + 'px', this._viewResizerWrapper );
				}
			} );
		}

		function existsInDom( element ) {
			return element && element.ownerDocument && element.ownerDocument.contains( element );
		}
	}

	containsHandle( domElement ) {
		return this._domResizerWrapper.contains( domElement );
	}

	static isResizeHandle( domElement ) {
		return domElement.classList.contains( 'ck-widget__resizer__handle' );
	}

	/**
	 * Cleans up the context state.
	 *
	 * @protected
	 */
	_cleanup() {
		this._sizeUI.dismiss();
		this._sizeUI.isVisible = false;

		const editingView = this._options.editor.editing.view;

		editingView.change( writer => {
			writer.setStyle( 'width', this._initialViewWidth, this._options.viewElement );
		} );
	}

	/**
	 * Calculates the proposed size as the resize handles are dragged.
	 *
	 * @private
	 * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
	 * @returns {Object} return
	 * @returns {Number} return.width Proposed width.
	 * @returns {Number} return.height Proposed height.
	 */
	_proposeNewSize( domEventData ) {
		const state = this.state;
		const currentCoordinates = extractCoordinates( domEventData );
		const isCentered = this._options.isCentered ? this._options.isCentered( this ) : true;

		// Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
		// meaning that it has been shrunk.
		//
		// +----------------+--+
		// |                |  |
		// |       img      |  |
		// |  /handle host  |  |
		// +----------------+  | ^
		// |                   | | - enlarge y
		// +-------------------+ v
		// 					<-->
		// 					 enlarge x
		const enlargement = {
			x: state._referenceCoordinates.x - ( currentCoordinates.x + state.originalWidth ),
			y: ( currentCoordinates.y - state.originalHeight ) - state._referenceCoordinates.y
		};

		if ( isCentered && state.activeHandlePosition.endsWith( '-right' ) ) {
			enlargement.x = currentCoordinates.x - ( state._referenceCoordinates.x + state.originalWidth );
		}

		// Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
		// one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
		if ( isCentered ) {
			enlargement.x *= 2;
		}

		// const resizeHost = this._getResizeHost();

		// The size proposed by the user. It does not consider the aspect ratio.
		const proposedSize = {
			width: Math.abs( state.originalWidth + enlargement.x ),
			height: Math.abs( state.originalHeight + enlargement.y )
		};

		// Dominant determination must take the ratio into account.
		proposedSize.dominant = proposedSize.width / state.aspectRatio > proposedSize.height ? 'width' : 'height';
		proposedSize.max = proposedSize[ proposedSize.dominant ];

		// Proposed size, respecting the aspect ratio.
		const targetSize = {
			width: proposedSize.width,
			height: proposedSize.height
		};

		if ( proposedSize.dominant == 'width' ) {
			targetSize.height = targetSize.width / state.aspectRatio;
		} else {
			targetSize.width = targetSize.height * state.aspectRatio;
		}

		return {
			width: Math.round( targetSize.width ),
			height: Math.round( targetSize.height ),
			widthPercents: Math.min( Math.round( state.originalWidthPercents / state.originalWidth * targetSize.width * 100 ) / 100, 100 )
		};
	}

	/**
	 * Obtains the resize host.
	 *
	 * Resize host is an object that receives dimensions which are the result of resizing.
	 *
	 * @protected
	 * @returns {HTMLElement}
	 */
	_getResizeHost() {
		const widgetWrapper = this._domResizerWrapper.parentElement;

		return this._options.getResizeHost( widgetWrapper );
	}

	/**
	 * Obtains the handle host.
	 *
	 * Handle host is an object that the handles are aligned to.
	 *
	 * Handle host will not always be an entire widget itself. Take an image as an example. The image widget
	 * contains an image and a caption. Only the image should be surrounded with handles.
	 *
	 * @protected
	 * @returns {HTMLElement}
	 */
	_getHandleHost() {
		const widgetWrapper = this._domResizerWrapper.parentElement;

		return this._options.getHandleHost( widgetWrapper );
	}

	/**
	 * Renders the resize handles in the DOM.
	 *
	 * @private
	 * @param {HTMLElement} domElement The resizer wrapper.
	 */
	_appendHandles( domElement ) {
		const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];

		for ( const currentPosition of resizerPositions ) {
			domElement.appendChild( ( new Template( {
				tag: 'div',
				attributes: {
					class: `ck-widget__resizer__handle ${ getResizerClass( currentPosition ) }`
				}
			} ).render() ) );
		}
	}

	/**
	 * Sets up the {@link #_sizeUI} property and adds it to the passed `domElement`.
	 *
	 * @private
	 * @param {HTMLElement} domElement
	 */
	_appendSizeUI( domElement ) {
		const sizeUI = new SizeView();

		// Make sure icon#element is rendered before passing to appendChild().
		sizeUI.render();

		this._sizeUI = sizeUI;

		domElement.appendChild( sizeUI.element );
	}

	/**
	 * @event begin
	 */

	/**
	 * @event updateSize
	 */

	/**
	 * @event commit
	 */

	/**
	 * @event cancel
	 */
}

mix( Resizer, ObservableMixin );

/**
 * A view displaying the proposed new element size during the resizing.
 *
 * @extends {module:ui/view~View}
 */
class SizeView extends View {
	constructor() {
		super();

		const bind = this.bindTemplate;

		this.setTemplate( {
			tag: 'div',
			attributes: {
				class: [
					'ck',
					'ck-size-view',
					bind.to( 'activeHandlePosition', value => value ? `ck-orientation-${ value }` : '' )
				],
				style: {
					display: bind.if( 'isVisible', 'none', visible => !visible )
				}
			},
			children: [ {
				text: bind.to( 'label' )
			} ]
		} );
	}

	bindToState( options, resizerState ) {
		this.bind( 'isVisible' ).to( resizerState, 'proposedWidth', resizerState, 'proposedHeight', ( width, height ) =>
			width !== null && height !== null );

		this.bind( 'label' ).to(
			resizerState, 'proposedHandleHostWidth',
			resizerState, 'proposedHandleHostHeight',
			resizerState, 'proposedWidthPercents',
			( width, height, widthPercents ) => {
				if ( options.unit === 'px' ) {
					return `${ width }×${ height }`;
				} else {
					return `${ widthPercents }%`;
				}
			}
		);

		this.bind( 'activeHandlePosition' ).to( resizerState );
	}

	dismiss() {
		this.unbind();
		this.isVisible = false;
	}
}

// @private
// @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
// @returns {String} A prefixed HTML class name for the resizer element
function getResizerClass( resizerPosition ) {
	return `ck-widget__resizer__handle-${ resizerPosition }`;
}

function extractCoordinates( event ) {
	return {
		x: event.pageX,
		y: event.pageY
	};
}
