/**
 * @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 ui/panel/balloon/contextualballoon
 */

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import BalloonPanelView from './balloonpanelview';
import View from '../../view';
import ButtonView from '../../button/buttonview';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';

import prevIcon from '../../../theme/icons/previous-arrow.svg';
import nextIcon from '../../../theme/icons/next-arrow.svg';

import '../../../theme/components/panel/balloonrotator.css';
import '../../../theme/components/panel/fakepanel.css';

const toPx = toUnit( 'px' );

/**
 * Provides the common contextual balloon for the editor.
 *
 * The role of this plugin is to unify the contextual balloons logic, simplify views management and help
 * avoid the unnecessary complexity of handling multiple {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
 * instances in the editor.
 *
 * This plugin allows for creating single or multiple panel stacks.
 *
 * Each stack may have multiple views, with the one on the top being visible. When the visible view is removed from the stack,
 * the previous view becomes visible.
 *
 * It might be useful to implement nested navigation in a balloon. For instance, a toolbar view may contain a link button.
 * When you click it, a link view (which lets you set the URL) is created and put on top of the toolbar view, so the link panel
 * is displayed. When you finish editing the link and close (remove) the link view, the toolbar view is visible again.
 *
 * However, there are cases when there are multiple independent balloons to be displayed, for instance, if the selection
 * is inside two inline comments at the same time. For such cases, you can create two independent panel stacks.
 * The contextual balloon plugin will create a navigation bar to let the users switch between these panel stacks using the "Next"
 * and "Previous" buttons.
 *
 * If there are no views in the current stack, the balloon panel will try to switch to the next stack. If there are no
 * panels in any stack, the balloon panel will be hidden.
 *
 * **Note**: To force the balloon panel to show only one view, even if there are other stacks, use the `singleViewMode=true` option
 * when {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#add adding} a view to a panel.
 *
 * From the implementation point of view, the contextual ballon plugin is reusing a single
 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance to display multiple contextual balloon
 * panels in the editor. It also creates a special {@link module:ui/panel/balloon/contextualballoon~RotatorView rotator view},
 * used to manage multiple panel stacks. Rotator view is a child of the balloon panel view and the parent of the specific
 * view you want to display. If there is more than one panel stack to be displayed, the rotator view will add a
 * navigation bar. If there is only one stack, the rotator view is transparent (it does not add any UI elements).
 *
 * @extends module:core/plugin~Plugin
 */
export default class ContextualBalloon extends Plugin {
	/**
	 * @inheritDoc
	 */
	static get pluginName() {
		return 'ContextualBalloon';
	}

	/**
	 * @inheritDoc
	 */
	constructor( editor ) {
		super( editor );

		/**
		 * The {@link module:utils/dom/position~Options#limiter position limiter}
		 * for the {@link #view balloon}, used when no `limiter` has been passed into {@link #add}
		 * or {@link #updatePosition}.
		 *
		 * By default, a function that obtains the farthest DOM
		 * {@link module:engine/view/rooteditableelement~RootEditableElement}
		 * of the {@link module:engine/view/document~Document#selection}.
		 *
		 * @member {module:utils/dom/position~Options#limiter} #positionLimiter
		 */
		this.positionLimiter = () => {
			const view = this.editor.editing.view;
			const viewDocument = view.document;
			const editableElement = viewDocument.selection.editableElement;

			if ( editableElement ) {
				return view.domConverter.mapViewToDom( editableElement.root );
			}

			return null;
		};

		/**
		 * The currently visible view or `null` when there are no views in any stack.
		 *
		 * @readonly
		 * @observable
		 * @member {module:ui/view~View|null} #visibleView
		 */
		this.set( 'visibleView', null );

		/**
		 * The common balloon panel view.
		 *
		 * @readonly
		 * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #view
		 */
		this.view = new BalloonPanelView( editor.locale );
		editor.ui.view.body.add( this.view );
		editor.ui.focusTracker.add( this.view.element );

		/**
		 * The map of views and their stacks.
		 *
		 * @private
		 * @type {Map.<module:ui/view~View,Set>}
		 */
		this._viewToStack = new Map();

		/**
		 * The map of IDs and stacks.
		 *
		 * @private
		 * @type {Map.<String,Set>}
		 */
		this._idToStack = new Map();

		/**
		 * A total number of all stacks in the balloon.
		 *
		 * @private
		 * @readonly
		 * @observable
		 * @member {Number} #_numberOfStacks
		 */
		this.set( '_numberOfStacks', 0 );

		/**
		 * A flag that controls the single view mode.
		 *
		 * @private
		 * @readonly
		 * @observable
		 * @member {Boolean} #_singleViewMode
		 */
		this.set( '_singleViewMode', false );

		/**
		 * Rotator view embedded in the contextual balloon.
		 * Displays the currently visible view in the balloon and provides navigation for switching stacks.
		 *
		 * @private
		 * @type {module:ui/panel/balloon/contextualballoon~RotatorView}
		 */
		this._rotatorView = this._createRotatorView();

		/**
		 * Displays fake panels under the balloon panel view when multiple stacks are added to the balloon.
		 *
		 * @private
		 * @type {module:ui/view~View}
		 */
		this._fakePanelsView = this._createFakePanelsView();
	}

	/**
	 * @inheritDoc
	 */
	destroy() {
		super.destroy();

		this.view.destroy();
		this._rotatorView.destroy();
		this._fakePanelsView.destroy();
	}

	/**
	 * Returns `true` when the given view is in one of the stacks. Otherwise returns `false`.
	 *
	 * @param {module:ui/view~View} view
	 * @returns {Boolean}
	 */
	hasView( view ) {
		return Array.from( this._viewToStack.keys() ).includes( view );
	}

	/**
	 * Adds a new view to the stack and makes it visible if the current stack is visible
	 * or it is the first view in the balloon.
	 *
	 * @param {Object} data The configuration of the view.
	 * @param {String} [data.stackId='main'] The ID of the stack that the view is added to.
	 * @param {module:ui/view~View} [data.view] The content of the balloon.
	 * @param {module:utils/dom/position~Options} [data.position] Positioning options.
	 * @param {String} [data.balloonClassName] An additional CSS class added to the {@link #view balloon} when visible.
	 * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow.
	 * @param {Boolean} [data.singleViewMode=false] Whether the view should be the only visible view even if other stacks were added.
	 */
	add( data ) {
		if ( this.hasView( data.view ) ) {
			/**
			 * Trying to add configuration of the same view more than once.
			 *
			 * @error contextualballoon-add-view-exist
			 */
			throw new CKEditorError(
				'contextualballoon-add-view-exist',
				[ this, data ]
			);
		}

		const stackId = data.stackId || 'main';

		// If new stack is added, creates it and show view from this stack.
		if ( !this._idToStack.has( stackId ) ) {
			this._idToStack.set( stackId, new Map( [ [ data.view, data ] ] ) );
			this._viewToStack.set( data.view, this._idToStack.get( stackId ) );
			this._numberOfStacks = this._idToStack.size;

			if ( !this._visibleStack || data.singleViewMode ) {
				this.showStack( stackId );
			}

			return;
		}

		const stack = this._idToStack.get( stackId );

		if ( data.singleViewMode ) {
			this.showStack( stackId );
		}

		// Add new view to the stack.
		stack.set( data.view, data );
		this._viewToStack.set( data.view, stack );

		// And display it if is added to the currently visible stack.
		if ( stack === this._visibleStack ) {
			this._showView( data );
		}
	}

	/**
	 * Removes the given view from the stack. If the removed view was visible,
	 * the view preceding it in the stack will become visible instead.
	 * When there is no view in the stack, the next stack will be displayed.
	 * When there are no more stacks, the balloon will hide.
	 *
	 * @param {module:ui/view~View} view A view to be removed from the balloon.
	 */
	remove( view ) {
		if ( !this.hasView( view ) ) {
			/**
			 * Trying to remove the configuration of the view not defined in the stack.
			 *
			 * @error contextualballoon-remove-view-not-exist
			 */
			throw new CKEditorError(
				'contextualballoon-remove-view-not-exist',
				[ this, view ]
			);
		}

		const stack = this._viewToStack.get( view );

		if ( this._singleViewMode && this.visibleView === view ) {
			this._singleViewMode = false;
		}

		// When visible view will be removed we need to show a preceding view or next stack
		// if a view is the only view in the stack.
		if ( this.visibleView === view ) {
			if ( stack.size === 1 ) {
				if ( this._idToStack.size > 1 ) {
					this._showNextStack();
				} else {
					this.view.hide();
					this.visibleView = null;
					this._rotatorView.hideView();
				}
			} else {
				this._showView( Array.from( stack.values() )[ stack.size - 2 ] );
			}
		}

		if ( stack.size === 1 ) {
			this._idToStack.delete( this._getStackId( stack ) );
			this._numberOfStacks = this._idToStack.size;
		} else {
			stack.delete( view );
		}

		this._viewToStack.delete( view );
	}

	/**
	 * Updates the position of the balloon using the position data of the first visible view in the stack.
	 * When new position data is given, the position data of the currently visible view will be updated.
	 *
	 * @param {module:utils/dom/position~Options} [position] position options.
	 */
	updatePosition( position ) {
		if ( position ) {
			this._visibleStack.get( this.visibleView ).position = position;
		}

		this.view.pin( this._getBalloonPosition() );
		this._fakePanelsView.updatePosition();
	}

	/**
	 * Shows the last view from the stack of a given ID.
	 *
	 * @param {String} id
	 */
	showStack( id ) {
		this.visibleStack = id;
		const stack = this._idToStack.get( id );

		if ( !stack ) {
			/**
			 * Trying to show a stack that does not exist.
			 *
			 * @error contextualballoon-showstack-stack-not-exist
			 */
			throw new CKEditorError(
				'contextualballoon-showstack-stack-not-exist',
				this
			);
		}

		if ( this._visibleStack === stack ) {
			return;
		}

		this._showView( Array.from( stack.values() ).pop() );
	}

	/**
	 * Returns the stack of the currently visible view.
	 *
	 * @private
	 * @type {Set}
	 */
	get _visibleStack() {
		return this._viewToStack.get( this.visibleView );
	}

	/**
	 * Returns the ID of the given stack.
	 *
	 * @private
	 * @param {Set} stack
	 * @returns {String}
	 */
	_getStackId( stack ) {
		const entry = Array.from( this._idToStack.entries() ).find( entry => entry[ 1 ] === stack );

		return entry[ 0 ];
	}

	/**
	 * Shows the last view from the next stack.
	 *
	 * @private
	 */
	_showNextStack() {
		const stacks = Array.from( this._idToStack.values() );

		let nextIndex = stacks.indexOf( this._visibleStack ) + 1;

		if ( !stacks[ nextIndex ] ) {
			nextIndex = 0;
		}

		this.showStack( this._getStackId( stacks[ nextIndex ] ) );
	}

	/**
	 * Shows the last view from the previous stack.
	 *
	 * @private
	 */
	_showPrevStack() {
		const stacks = Array.from( this._idToStack.values() );

		let nextIndex = stacks.indexOf( this._visibleStack ) - 1;

		if ( !stacks[ nextIndex ] ) {
			nextIndex = stacks.length - 1;
		}

		this.showStack( this._getStackId( stacks[ nextIndex ] ) );
	}

	/**
	 * Creates a rotator view.
	 *
	 * @private
	 * @returns {module:ui/panel/balloon/contextualballoon~RotatorView}
	 */
	_createRotatorView() {
		const view = new RotatorView( this.editor.locale );
		const t = this.editor.locale.t;

		this.view.content.add( view );

		// Hide navigation when there is only a one stack & not in single view mode.
		view.bind( 'isNavigationVisible' ).to( this, '_numberOfStacks', this, '_singleViewMode', ( value, isSingleViewMode ) => {
			return !isSingleViewMode && value > 1;
		} );

		// Update balloon position after toggling navigation.
		view.on( 'change:isNavigationVisible', () => ( this.updatePosition() ), { priority: 'low' } );

		// Update stacks counter value.
		view.bind( 'counter' ).to( this, 'visibleView', this, '_numberOfStacks', ( visibleView, numberOfStacks ) => {
			if ( numberOfStacks < 2 ) {
				return '';
			}

			const current = Array.from( this._idToStack.values() ).indexOf( this._visibleStack ) + 1;

			return t( '%0 of %1', [ current, numberOfStacks ] );
		} );

		view.buttonNextView.on( 'execute', () => {
			// When current view has a focus then move focus to the editable before removing it,
			// otherwise editor will lost focus.
			if ( view.focusTracker.isFocused ) {
				this.editor.editing.view.focus();
			}

			this._showNextStack();
		} );

		view.buttonPrevView.on( 'execute', () => {
			// When current view has a focus then move focus to the editable before removing it,
			// otherwise editor will lost focus.
			if ( view.focusTracker.isFocused ) {
				this.editor.editing.view.focus();
			}

			this._showPrevStack();
		} );

		return view;
	}

	/**
	 * @private
	 * @returns {module:ui/view~View}
	 */
	_createFakePanelsView() {
		const view = new FakePanelsView( this.editor.locale, this.view );

		view.bind( 'numberOfPanels' ).to( this, '_numberOfStacks', this, '_singleViewMode', ( number, isSingleViewMode ) => {
			const showPanels = !isSingleViewMode && number >= 2;

			return showPanels ? Math.min( number - 1, 2 ) : 0;
		} );

		view.listenTo( this.view, 'change:top', () => view.updatePosition() );
		view.listenTo( this.view, 'change:left', () => view.updatePosition() );

		this.editor.ui.view.body.add( view );

		return view;
	}

	/**
	 * Sets the view as the content of the balloon and attaches the balloon using position
	 * options of the first view.
	 *
	 * @private
	 * @param {Object} data Configuration.
	 * @param {module:ui/view~View} [data.view] The view to show in the balloon.
	 * @param {String} [data.balloonClassName=''] Additional class name which will be added to the {@link #view balloon}.
	 * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow.
	 */
	_showView( { view, balloonClassName = '', withArrow = true, singleViewMode = false } ) {
		this.view.class = balloonClassName;
		this.view.withArrow = withArrow;

		this._rotatorView.showView( view );
		this.visibleView = view;
		this.view.pin( this._getBalloonPosition() );
		this._fakePanelsView.updatePosition();

		if ( singleViewMode ) {
			this._singleViewMode = true;
		}
	}

	/**
	 * Returns position options of the last view in the stack.
	 * This keeps the balloon in the same position when the view is changed.
	 *
	 * @private
	 * @returns {module:utils/dom/position~Options}
	 */
	_getBalloonPosition() {
		let position = Array.from( this._visibleStack.values() ).pop().position;

		if ( position ) {
			// Use the default limiter if none has been specified.
			if ( !position.limiter ) {
				// Don't modify the original options object.
				position = Object.assign( {}, position, {
					limiter: this.positionLimiter
				} );
			}

			// Don't modify the original options object.
			position = Object.assign( {}, position, {
				viewportOffsetConfig: this.editor.ui.viewportOffset
			} );
		}

		return position;
	}
}

/**
 * Rotator view is a helper class for the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon}.
 * It is used for displaying the last view from the current stack and providing navigation buttons for switching stacks.
 * See the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon} documentation to learn more.
 *
 * @extends module:ui/view~View
 */
class RotatorView extends View {
	/**
	 * @inheritDoc
	 */
	constructor( locale ) {
		super( locale );

		const t = locale.t;
		const bind = this.bindTemplate;

		/**
		 * Defines whether navigation is visible or not.
		 *
		 * @member {Boolean} #isNavigationVisible
		 */
		this.set( 'isNavigationVisible', true );

		/**
		 * Used for checking if a view is focused or not.
		 *
		 * @type {module:utils/focustracker~FocusTracker}
		 */
		this.focusTracker = new FocusTracker();

		/**
		 * Navigation button for switching the stack to the previous one.
		 *
		 * @type {module:ui/button/buttonview~ButtonView}
		 */
		this.buttonPrevView = this._createButtonView( t( 'Previous' ), prevIcon );

		/**
		 * Navigation button for switching the stack to the next one.
		 *
		 * @type {module:ui/button/buttonview~ButtonView}
		 */
		this.buttonNextView = this._createButtonView( t( 'Next' ), nextIcon );

		/**
		 * A collection of the child views that creates the rotator content.
		 *
		 * @readonly
		 * @type {module:ui/viewcollection~ViewCollection}
		 */
		this.content = this.createCollection();

		this.setTemplate( {
			tag: 'div',
			attributes: {
				class: [
					'ck',
					'ck-balloon-rotator'
				],
				'z-index': '-1'
			},
			children: [
				{
					tag: 'div',
					attributes: {
						class: [
							'ck-balloon-rotator__navigation',
							bind.to( 'isNavigationVisible', value => value ? '' : 'ck-hidden' )
						]
					},
					children: [
						this.buttonPrevView,
						{
							tag: 'span',

							attributes: {
								class: [
									'ck-balloon-rotator__counter'
								]
							},

							children: [
								{
									text: bind.to( 'counter' )
								}
							]
						},
						this.buttonNextView
					]
				},
				{
					tag: 'div',
					attributes: {
						class: 'ck-balloon-rotator__content'
					},
					children: this.content
				}
			]
		} );
	}

	/**
	 * @inheritDoc
	 */
	render() {
		super.render();

		this.focusTracker.add( this.element );
	}

	/**
	 * @inheritDoc
	 */
	destroy() {
		super.destroy();

		this.focusTracker.destroy();
	}

	/**
	 * Shows a given view.
	 *
	 * @param {module:ui/view~View} view The view to show.
	 */
	showView( view ) {
		this.hideView();
		this.content.add( view );
	}

	/**
	 * Hides the currently displayed view.
	 */
	hideView() {
		this.content.clear();
	}

	/**
	 * Creates a navigation button view.
	 *
	 * @private
	 * @param {String} label The button label.
	 * @param {String} icon The button icon.
	 * @returns {module:ui/button/buttonview~ButtonView}
	 */
	_createButtonView( label, icon ) {
		const view = new ButtonView( this.locale );

		view.set( {
			label,
			icon,
			tooltip: true
		} );

		return view;
	}
}

// Displays additional layers under the balloon when multiple stacks are added to the balloon.
//
// @private
// @extends module:ui/view~View
class FakePanelsView extends View {
	// @inheritDoc
	constructor( locale, balloonPanelView ) {
		super( locale );

		const bind = this.bindTemplate;

		// Fake panels top offset.
		//
		// @observable
		// @member {Number} #top
		this.set( 'top', 0 );

		// Fake panels left offset.
		//
		// @observable
		// @member {Number} #left
		this.set( 'left', 0 );

		// Fake panels height.
		//
		// @observable
		// @member {Number} #height
		this.set( 'height', 0 );

		// Fake panels width.
		//
		// @observable
		// @member {Number} #width
		this.set( 'width', 0 );

		// Number of rendered fake panels.
		//
		// @observable
		// @member {Number} #numberOfPanels
		this.set( 'numberOfPanels', 0 );

		// Collection of the child views which creates fake panel content.
		//
		// @readonly
		// @type {module:ui/viewcollection~ViewCollection}
		this.content = this.createCollection();

		// Context.
		//
		// @private
		// @type {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
		this._balloonPanelView = balloonPanelView;

		this.setTemplate( {
			tag: 'div',
			attributes: {
				class: [
					'ck-fake-panel',
					bind.to( 'numberOfPanels', number => number ? '' : 'ck-hidden' )
				],
				style: {
					top: bind.to( 'top', toPx ),
					left: bind.to( 'left', toPx ),
					width: bind.to( 'width', toPx ),
					height: bind.to( 'height', toPx )
				}
			},
			children: this.content
		} );

		this.on( 'change:numberOfPanels', ( evt, name, next, prev ) => {
			if ( next > prev ) {
				this._addPanels( next - prev );
			} else {
				this._removePanels( prev - next );
			}

			this.updatePosition();
		} );
	}

	// @private
	// @param {Number} number
	_addPanels( number ) {
		while ( number-- ) {
			const view = new View();

			view.setTemplate( { tag: 'div' } );

			this.content.add( view );
			this.registerChild( view );
		}
	}

	// @private
	// @param {Number} number
	_removePanels( number ) {
		while ( number-- ) {
			const view = this.content.last;

			this.content.remove( view );
			this.deregisterChild( view );
			view.destroy();
		}
	}

	// Updates coordinates of fake panels.
	updatePosition() {
		if ( this.numberOfPanels ) {
			const { top, left } = this._balloonPanelView;
			const { width, height } = new Rect( this._balloonPanelView.element );

			Object.assign( this, { top, left, width, height } );
		}
	}
}
