const util = require( './util' );

let overlayManager = null;

// We pass this to history.pushState/replaceState to indicate that we're controlling the page URL.
// Then we look for this marker on page load so that if the page is refreshed, we don't generate an
// extra history entry (see #getSingleton below and T201852).
const MANAGED_STATE = 'MobileFrontend OverlayManager was here!';

/**
 * Manages opening and closing overlays when the URL hash changes to one
 * of the registered values (see OverlayManager.add()).
 *
 * This allows overlays to function like real pages, with similar browser back/forward
 * and refresh behavior.
 */
class OverlayManager {
	/**
	 * @param {Router} router
	 * @param {Element} container where overlays should be managed
	 */
	constructor( router, container ) {
		router.on( 'route', this._checkRoute.bind( this ) );
		this.router = router;
		// use an object instead of an array for entries so that we don't
		// duplicate entries that already exist
		this.entries = {};
		// stack of all the open overlays, stack[0] is the latest one
		this.stack = [];
		this.hideCurrent = true;
		// Set the element that overlays will be appended to
		this.container = container;
	}

	/**
	 * Don't try to hide the active overlay on a route change event triggered
	 * by hiding another overlay.
	 * Called when something other than OverlayManager calls Overlay.hide
	 * on an overlay that it itself managed by the OverlayManager.
	 * MUST be called when the stack is not empty.
	 *
	 * @private
	 */
	_onHideOverlayOutsideOverlayManager() {
		if ( !this.stack.length ) {
			return;
		}
		const currentRoute = this.stack[0].route,
			routeIsString = typeof currentRoute === 'string',
			currentPath = this.router.getPath(),
			// Since routes can be strings or regexes, it's important to do an equality
			// check BEFORE a match check.
			routeIsSame = ( routeIsString && currentPath === currentRoute ) ||
				currentPath.match( currentRoute );

		this.hideCurrent = false;

		// If the path hasn't changed then the user didn't close the overlay by
		// calling history.back() or triggering a route change. We must go back
		// to get out of the overlay. See T237677.
		if ( routeIsSame ) {
			// does the route need to change?
			this.router.back();
		}
	}

	/**
	 * Attach overlay to DOM
	 *
	 * @private
	 * @param {module:mobile.startup/Overlay} overlay to attach
	 */
	_attachOverlay( overlay ) {
		if ( !overlay.$el.parents().length ) {
			this.container.appendChild( overlay.$el[0] );
		}
	}

	/**
	 * Show the overlay and bind the '_om_hide' event to _onHideOverlay.
	 *
	 * @private
	 * @param {module:mobile.startup/Overlay} overlay to show
	 */
	_show( overlay ) {
		// Mark the state so that if the page is refreshed, we don't generate an extra history entry
		// (see #getSingleton below and T201852).
		// eslint-disable-next-line no-restricted-properties
		window.history.replaceState( MANAGED_STATE, null, window.location.href );

		// the _om_hide event is added to an overlay that is displayed.
		// It will fire if an Overlay emits a hide event (See attachHideEvent)
		// in the case where a route change has not occurred (this event is disabled
		// inside _hideOverlay which is called inside _checkRoute)
		overlay.once( '_om_hide', () => this._onHideOverlayOutsideOverlayManager() );

		this._attachOverlay( overlay );
		overlay.show();
	}

	/**
	 * Hide overlay
	 *
	 * @private
	 * @param {module:mobile.startup/Overlay} overlay to hide
	 * @param {Function} onBeforeExitCancel to pass to onBeforeExit
	 * @return {boolean} Whether the overlay has been hidden
	 */
	_hideOverlay( overlay, onBeforeExitCancel ) {
		let result;

		function exit() {
			result = true;
			overlay.hide();
		}

		// remove the callback for updating state when overlay closed using
		// overlay close button
		overlay.off( '_om_hide' );

		if ( overlay.options && overlay.options.onBeforeExit ) {
			overlay.options.onBeforeExit( exit, onBeforeExitCancel );
		} else {
			exit();
		}

		// if closing prevented, reattach the callback
		if ( !result ) {
			overlay.once( '_om_hide', () => this._onHideOverlayOutsideOverlayManager() );
		}

		return result;
	}

	/**
	 * Show match's overlay if match is not null.
	 *
	 * @private
	 * @param {Object|null} match Object with factory function's result. null if no match.
	 */
	_processMatch( match ) {
		if ( match ) {
			if ( match.overlay ) {
				// if the match is an overlay that was previously opened, reuse it
				this._show( match.overlay );
			} else {
				// else create an overlay using the factory function result
				const factoryResult = match.factoryResult;
				// We were getting errors relating to no factoryResult.
				// This should never happen.
				// If it does an error should not be thrown.
				if ( factoryResult ) {
					match.overlay = factoryResult;
					attachHideEvent( match.overlay );
					this._show( factoryResult );
				}
			}
		}
	}

	/**
	 * A callback for Router's `route` event.
	 *
	 * @private
	 * @param {jQuery.Event} ev Event object.
	 */
	_checkRoute( ev ) {
		const current = this.stack[0];

		// When entering an overlay for the first time,
		// the manager should remember the user's scroll position
		// overlays always open at top of page
		// and we'll want to restore it later.
		// This should happen before the call to _matchRoute which will "show" the overlay.
		// The Overlay has similar logic for overlays that are not managed via the overlay.
		if ( !current ) {
			this.scrollTop = window.pageYOffset;
		}

		// if there is an overlay in the stack and it's opened, try to close it
		if (
			current &&
			current.overlay !== undefined &&
			this.hideCurrent &&
			!this._hideOverlay( current.overlay, () => {
				// if hide prevented, prevent route change event
				ev.preventDefault();
			} )
		) {
			return;
		}

		const match = Object.keys( this.entries ).reduce( ( m, id ) => m ||
			this._matchRoute( ev.path, this.entries[id] ), null );

		if ( !match ) {
			// if hidden and no new matches, reset the stack
			this.stack = [];
			// restore the scroll position.
			window.scrollTo( window.pageXOffset, this.scrollTop );
		}

		this.hideCurrent = true;
		this._processMatch( match );
	}

	/**
	 * Check if a given path matches one of the existing entries and
	 * remove it from the stack.
	 *
	 * @private
	 * @param {string} path Path (hash) to check.
	 * @param {Object} entry Entry object created in OverlayManager#add.
	 * @return {Object|null} Match object with factory function's result.
	 *  Returns null if no match.
	 */
	_matchRoute( path, entry ) {
		let
			didMatch,
			captures,
			match;

		const previous = this.stack[1];

		if ( typeof entry.route === 'string' ) {
			didMatch = entry.route === path;
			captures = [];
		} else {
			match = path.match( entry.route );
			didMatch = !!match;
			captures = didMatch ? match.slice( 1 ) : [];
		}

		/**
		 * Returns object to add to stack
		 *
		 * @method
		 * @ignore
		 * @return {Object}
		 */
		const getNext = () => ( {
			path,
			// Important for managing states of things such as the image overlay which change
			// overlay routing parameters during usage.
			route: entry.route,
			factoryResult: entry.factory.apply( this, captures )
		} );

		if ( didMatch ) {
			// if previous stacked overlay's path matches, assume we're going back
			// and reuse a previously opened overlay
			if ( previous && previous.path === path ) {
				this.stack.shift();
				return previous;
			} else {
				const next = getNext();
				if ( this.stack[0] && next.path === this.stack[0].path ) {
					// current overlay path is same as path to check which means overlay
					// is attempting to refresh so just replace current overlay with new
					// overlay
					this.stack[0] = next;
				} else {
					this.stack.unshift( next );
				}
				return next;
			}
		}

		return null;
	}

	/**
	 * Add an overlay that should be shown for a specific fragment identifier.
	 *
	 * The following code will display an overlay whenever a user visits a URL that
	 * ends with '#/hi/name'. The value of `name` will be passed to the overlay.
	 * Note the factory must return an Overlay.
	 * If the overlay needs to load code asynchronously that should be done inside
	 * the overlay.
	 *
	 *     @example
	 *     overlayManager.add( /\/hi\/(.*)/, function ( name ) {
	 *         var HiOverlay = M.require( 'HiOverlay' );
	 *         return new HiOverlay( { name: name } ) );
	 *     } );
	 *
	 * @param {RegExp|string} route definition that can be a regular
	 * expression (optionally with parameters) or a string literal.
	 *
	 * T238364: Routes should only contain characters allowed by RFC3986 to ensure
	 * compatibility across browsers. Encode the route with `encodeURIComponent()`
	 * prior to registering it with OverlayManager if necessary (should probably
	 * be done with all routes containing user generated characters) to avoid
	 * inconsistencies with how different browsers encode illegal URI characters:
	 *
	 * ```
	 *   var encodedRoute = encodeURIComponent('ugc < " ` >');
	 *
	 *   overlayManager.add(
	 *     encodedRoute,
	 *     function () { return new Overlay(); }
	 *   );
	 *
	 *   window.location.hash = '#' + encodedRoute;
	 * ```
	 * The above example shows how to register a string literal route with illegal
	 * URI characters. Routes registered as a regex will likely NOT have to
	 * perform any encoding (unless they explicitly contain illegal URI
	 * characters) as their user generated content portion will likely just be a
	 * capturing group (e.g. `/\/hi\/(.*)/`).
	 * @param {Function} factory a function returning an overlay
	 */
	add( route, factory ) {
		const entry = {
			route,
			factory
		};

		this.entries[route] = entry;
		// Check if overlay should be shown for the current path.
		// The DOM must fully load before we can show the overlay because Overlay relies on it.
		util.docReady( () => this._processMatch( this._matchRoute( this.router.getPath(),
			entry ) ) );
	}

	/**
	 * Replace the currently displayed overlay with a new overlay without changing the
	 * URL. This is useful for when you want to switch overlays, but don't want to
	 * change the back button or close box behavior.
	 *
	 * @param {Object} overlay The overlay to display
	 */
	replaceCurrent( overlay ) {
		if ( this.stack.length === 0 ) {
			throw new Error( "Trying to replace OverlayManager's current overlay, but stack is empty" );
		}
		const stackOverlay = this.stack[0].overlay;
		if ( stackOverlay ) {
			this._hideOverlay( stackOverlay );
		}
		this.stack[0].overlay = overlay;
		attachHideEvent( overlay );
		this._show( overlay );
	}

	/**
	 * Retrieve a singleton instance using 'mediawiki.router'.
	 *
	 * @memberof module:mobile.startup/OverlayManager
	 * @return {module:mobile.startup/OverlayManager}
	 */
	static getSingleton() {
		if ( !overlayManager ) {
			const
				router = __non_webpack_require__( 'mediawiki.router' ),
				container = document.createElement( 'div' ),
				// Note getPath returns hash minus the '#' character:
				hash = router.getPath(),
				// eslint-disable-next-line no-restricted-properties
				state = window.history.state;
			container.className = 'mw-overlays-container';
			document.body.appendChild( container );
			// If an overlay was loaded by directly navigating to an URL with a hash (e.g. linked from
			// another page or browser bookmark), generate an extra history entry to allow closing the
			// overlay without leaving the page (see T201852). Put our marker into the entry state so
			// that we can detect it if the page is refreshed and do not generate another entry.
			if ( hash && state !== MANAGED_STATE ) {
				// eslint-disable-next-line no-restricted-properties
				window.history.replaceState( null, null, '#' );
				// eslint-disable-next-line no-restricted-properties
				window.history.pushState( MANAGED_STATE, null, `#${ hash }` );
			}
			overlayManager = new OverlayManager( router, container );
		}
		return overlayManager;
	}
}

/**
 * Attach an event to the overlays hide event
 *
 * @param {module:mobile.startup/Overlay} overlay
 * @private
 */
function attachHideEvent( overlay ) {
	overlay.on( 'hide', () => overlay.emit( '_om_hide' ) );
}

OverlayManager.test = {
	MANAGED_STATE,
	__clearCache: () => {
		overlayManager = null;
	}
};
module.exports = OverlayManager;
