/**
 * @defgroup js_controllers
 */
/**
 * @file js/controllers/SiteHandler.js
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class SiteHandler
 * @ingroup js_controllers
 *
 * @brief Handle the site widget.
 */
(function($) {


	/**
	 * @constructor
	 *
	 * @extends $.pkp.classes.Handler
	 *
	 * @param {jQueryObject} $widgetWrapper An HTML element that this handle will
	 * be attached to.
	 * @param {{
	 *   fetchNotificationUrl: string,
	 *   requestOptions: Object
	 *   }} options Handler options.
	 */
	$.pkp.controllers.SiteHandler = function($widgetWrapper, options) {
		this.parent($widgetWrapper, options);

		this.options_ = options;
		this.unsavedFormElements_ = [];

		$('.go').button();

		this.bind('redirectRequested', this.redirectToUrl);
		this.bind('notifyUser', this.fetchNotificationHandler_);
		this.bind('reloadTab', this.reloadTabHandler_);
		this.bind('callWhenClickOutside', this.callWhenClickOutsideHandler_);
		this.bind('mousedown', this.mouseDownHandler_);

		// Bind the pageUnloadHandler_ method to the DOM so it is
		// called.
		$(window).bind('beforeunload', this.pageUnloadHandler_);

		// Avoid IE8 caching ajax results. If it does, widgets like
		// grids will not refresh correctly.
		$.ajaxSetup({cache: false});

		// Check if we have notifications to show.
		if (options.hasSystemNotifications) {
			this.trigger('notifyUser');
		}

		// bind event handlers for form status change events.
		this.bind('formChanged', this.callbackWrapper(
				this.registerUnsavedFormElement_));
		this.bind('unregisterChangedForm', this.callbackWrapper(
				this.unregisterUnsavedFormElement_));
		this.bind('unregisterAllForms', this.callbackWrapper(
				this.unregisterAllFormElements_));

		// React to a modal events
		this.bind('pkpModalOpen', this.callbackWrapper(this.openModal_));
		this.bind('pkpModalClose', this.callbackWrapper(this.closeModal_));

		this.bind('pkpObserveScrolling', this.callbackWrapper(
				this.registerScrollingObserver_));
		this.bind('pkpRemoveScrollingObserver', this.callbackWrapper(
				this.unregisterScrollingObserver_));

		this.outsideClickChecks_ = {};

		this.initializeTinyMCE();
	};
	$.pkp.classes.Helper.inherits(
			$.pkp.controllers.SiteHandler, $.pkp.classes.Handler);


	//
	// Private properties
	//
	/**
	 * Help context.
	 * @private
	 * @type {string?}
	 */
	$.pkp.controllers.SiteHandler.prototype.helpContext_ = null;


	/**
	 * Site handler options.
	 * @private
	 * @type {Object}
	 */
	$.pkp.controllers.SiteHandler.prototype.options_ = null;


	/**
	 * Object with data to be used when checking if user
	 * clicked outside a site element. See callWhenClickOutsideHandler_()
	 * to check the expected check options.
	 * @private
	 * @type {Object}
	 */
	$.pkp.controllers.SiteHandler.prototype.outsideClickChecks_ = null;


	/**
	 * A state variable to store the form elements that have unsaved data.
	 * @private
	 * @type {Array}
	 */
	$.pkp.controllers.SiteHandler.prototype.unsavedFormElements_ = null;


	//
	// Public static methods.
	//
	/**
	 * Initialize the tinyMCE plugin
	 */
	$.pkp.controllers.SiteHandler.prototype.initializeTinyMCE =
			function() {

		if (typeof tinyMCE !== 'undefined') {
			tinyMCE.PluginManager.load('pkpTags', $.pkp.app.baseUrl +
					'/plugins/generic/tinymce/plugins/pkpTags/plugin.js');
			tinyMCE.PluginManager.load('pkpwordcount', $.pkp.app.baseUrl +
					'/plugins/generic/tinymce/plugins/pkpWordcount/plugin.js');


			var contentCSS = $.pkp.app.tinyMceContentCSS,
					tinymceParams,
					tinymceParamDefaults;

			tinymceParamDefaults = {
				width: '100%',
				resize: 'both',
				entity_encoding: 'raw',
				plugins: 'paste,fullscreen,link,lists,code,' +
						'image,-pkpTags,noneditable',
				convert_urls: false,
				forced_root_block: 'p',
				paste_auto_cleanup_on_paste: true,
				apply_source_formatting: false,
				theme: 'modern',
				toolbar: 'copy paste | bold italic underline | link unlink ' +
						'code fullscreen | image | pkpTags',
				richToolbar: 'copy paste | bold italic underline | bullist numlist | ' +
						'superscript subscript | link unlink code fullscreen | ' +
						'image | pkpTags',
				statusbar: false,
				content_css: contentCSS
			};

			// Support image uploads
			if (typeof $.pkp.plugins.generic.tinymceplugin !== 'undefined' &&
					typeof $.pkp.plugins.generic.tinymceplugin.uploadUrl !== 'undefined') {
				tinymceParamDefaults.paste_data_images = true;
				tinymceParamDefaults.relative_urls = false;
				tinymceParamDefaults.remove_script_host = false;
				// https://www.tiny.cloud/docs/general-configuration-guide/upload-images/
				tinymceParamDefaults.images_upload_handler = function(
						blobInfo, success, failure) {

					/*global FormData: false */
					var data = new FormData();
					data.append('file', blobInfo.blob(), blobInfo.filename());
					$.ajax({
						method: 'POST',
						url: $.pkp.plugins.generic.tinymceplugin.uploadUrl,
						data: data,
						processData: false,
						contentType: false,
						headers: {
							'X-Csrf-Token': pkp.currentUser.csrfToken
						},
						success: function(r) {
							success(r.url);
						},
						error: function(r) {
							failure(r.responseJSON.errorMessage);
						}
					});
				};
			}

			// Allow default params to be overridden
			if (typeof $.pkp.plugins.generic.tinymceplugin !== 'undefined' &&
					typeof $.pkp.plugins.generic.tinymceplugin.tinymceParams) {
				tinymceParams = $.extend({}, tinymceParamDefaults,
						$.pkp.plugins.generic.tinymceplugin.tinymceParams);
			} else {
				tinymceParams = $.extend({}, tinymceParamDefaults);
			}

			// Don't allow the following settings to be overridden
			tinymceParams.init_instance_callback =
					$.pkp.controllers.SiteHandler.prototype.triggerTinyMCEInitialized;
			tinymceParams.setup =
					$.pkp.controllers.SiteHandler.prototype.triggerTinyMCESetup;

			tinyMCE.init(tinymceParams);
		}
	};


	/**
	 * Callback used by the tinyMCE plugin to trigger the tinyMCEInitialized
	 * event in the DOM.
	 * @param {Object} tinyMCEObject The tinyMCE object instance being
	 * initialized.
	 */
	$.pkp.controllers.SiteHandler.prototype.triggerTinyMCEInitialized =
			function(tinyMCEObject) {

		var $inputElement = $('#' +
				$.pkp.classes.Helper.escapeJQuerySelector(tinyMCEObject.id));
		$inputElement.trigger('tinyMCEInitialized', [tinyMCEObject]);
	};


	/**
	 * Callback used by the tinyMCE plugin upon setup.
	 * @param {Object} tinyMCEObject The tinyMCE object instance being
	 * set up.
	 */
	$.pkp.controllers.SiteHandler.prototype.triggerTinyMCESetup =
			function(tinyMCEObject) {
		var target = $('#' +
				$.pkp.classes.Helper.escapeJQuerySelector(tinyMCEObject.id)),
				height;

		// For read-only controls, set up TinyMCE read-only mode.
		if (target.attr('readonly')) {
			tinyMCEObject.settings.readonly = true;
		}

		if (target.attr('wordCount') && target.attr('wordCount') > 0) {
			tinyMCEObject.settings.plugins =
					tinyMCEObject.settings.plugins + ',pkpwordcount';
			tinyMCEObject.settings.statusbar = true;
		}

		if (target.attr('dir')) {
			tinyMCEObject.settings.directionality = target.attr('dir');
		}

		// Set height based on textarea rows
		height = target.attr('rows') || 10; // default: 10
		height *= 20; // 20 pixels per row
		tinyMCEObject.settings.height = height.toString() + 'px';

		// Add a fake HTML5 placeholder when the editor is intitialized
		tinyMCEObject.on('init', function(tinyMCEObject) {
			var $element = $('#' + tinyMCEObject.id),
					placeholderText,
					$placeholder,
					$placeholderParent;

			// Don't add anything if we don't have a placeholder
			placeholderText = $('#' + tinyMCEObject.id).attr('placeholder');
			if (placeholderText === '') {
				return;
			}

			// Create placeholder element
			$placeholder = $('<span></span>');
			$placeholder.html(/** @type {string} */ (placeholderText));
			$placeholder.addClass('mcePlaceholder');
			$placeholder.attr('id', 'mcePlaceholder-' + tinyMCEObject.id);

			if (tinyMCEObject.target.getContent().length) {
				$placeholder.hide();
			}

			// Create placeholder wrapper
			$placeholderParent = $('<div></div>');
			$placeholderParent.addClass('mcePlaceholderParent');
			$element.wrap($placeholderParent);
			$element.parent().append($placeholder);
		});

		tinyMCEObject.on('activate', function(tinyMCEObject) {
			// Hide the placeholder when the editor is activated
			$('#mcePlaceholder-' + tinyMCEObject.id).hide();
		});

		tinyMCEObject.on('deactivate', function(tinyMCEObject) {
			// Show the placholder when the editor is deactivated
			if (!tinyMCEObject.target.getContent().length) {
				$('#mcePlaceholder-' + tinyMCEObject.id).show();
			}
			tinyMCEObject.target.dom.addClass(
					tinyMCEObject.target.dom.select('li'), 'show');
		});

		tinyMCEObject.on('BeforeSetContent', function(e) {
			var variablesParsed = $.pkp.classes.TinyMCEHelper.prototype.getVariableMap(
					'#' + $.pkp.classes.Helper.escapeJQuerySelector(tinyMCEObject.id));

			e.content = e.content.replace(
					/\{\$([a-zA-Z]+)\}(?![^<]*>)/g, function(match, contents, offset, s) {
						if (variablesParsed[contents] !== undefined) {
							return $.pkp.classes.TinyMCEHelper.prototype.getVariableElement(
									contents, variablesParsed[contents], '#' + tinyMCEObject.id)
									.html();
						}
						return match;
					});
		});

		// When the field is being saved, replace any tag placeholders
		tinyMCEObject.on('SaveContent', function(e) {
			var $content = $('<div>' + e.content + '</div>');

			// Replace tag span elements with the raw tags
			$content.find('.pkpTag').replaceWith(function() {
				return '{$' + $(this).attr('data-symbolic') + '}';
			});
			e.content = $content.html();
		});

		// In fullscreen mode, also present the toolbar.
		tinyMCEObject.on('FullscreenStateChanged init', function(e) {
			var target = e.target, $container = $(target.editorContainer);
			if (target.plugins.fullscreen) {
				if (target.plugins.fullscreen.isFullscreen()) {
					$container.find('.mce-toolbar[role=\'menubar\']').show();
				} else {
					$container.find('.mce-toolbar[role=\'menubar\']').hide();
				}
			}
		});
	};


	/**
	 * Get the current window dimensions.
	 * @return {Object} The current window dimensions (height and width)
	 * in pixels.
	 */
	$.pkp.controllers.SiteHandler.prototype.getWindowDimensions =
			function() {
		var dimensions = {'height': $(window).height(),
			'width': $(window).width()};

		return dimensions;
	};


	//
	// Public methods
	//
	/**
	 * Callback that is triggered when the page should redirect.
	 *
	 * @param {HTMLElement} sourceElement The element that issued the
	 *  "redirectRequested" event.
	 * @param {Event} event The "redirect requested" event.
	 * @param {string} url The URL to redirect to.
	 */
	$.pkp.controllers.SiteHandler.prototype.redirectToUrl =
			function(sourceElement, event, url) {

		window.location = url;
	};


	/**
	 * Handler bound to 'formChanged' events propagated by forms
	 * that wish to have their form data tracked.
	 * @private
	 * @param {HTMLElement} siteHandlerElement The html element
	 * attached to this handler.
	 * @param {HTMLElement} sourceElement The element wishes to
	 * register.
	 * @param {Event} event The formChanged event.
	 */
	$.pkp.controllers.SiteHandler.prototype.registerUnsavedFormElement_ =
			function(siteHandlerElement, sourceElement, event) {
		var $formElement, formId, index;

		$formElement = $(event.target.lastElementChild);
		formId = $formElement.attr('id');
		index = $.inArray(formId, this.unsavedFormElements_);
		if (index == -1) {
			this.unsavedFormElements_.push(formId);
		}
	};


	/**
	 * Handler bound to 'unregisterChangedForm' events propagated by forms
	 * that wish to inform that they no longer wish to be tracked as 'unsaved'.
	 * @private
	 * @param {HTMLElement} siteHandlerElement The html element
	 * attached to this handler.
	 * @param {HTMLElement} sourceElement The element that wishes to
	 * unregister.
	 * @param {Event} event The unregisterChangedForm event.
	 */
	$.pkp.controllers.SiteHandler.prototype.unregisterUnsavedFormElement_ =
			function(siteHandlerElement, sourceElement, event) {
		var $formElement, formId, index;

		$formElement = $(event.target.lastElementChild);
		formId = $formElement.attr('id');
		index = $.inArray(formId, this.unsavedFormElements_);
		if (index !== -1) {
			delete this.unsavedFormElements_[index];
		}
	};


	/**
	 * Unregister all unsaved form elements.
	 * @private
	 */
	$.pkp.controllers.SiteHandler.prototype.unregisterAllFormElements_ =
			function() {
		this.unsavedFormElements_ = [];
	};


	//
	// Private methods.
	//
	/**
	 * Fetch the notification data.
	 * @private
	 * @param {HTMLElement} sourceElement The element that issued the
	 *  "fetchNotification" event.
	 * @param {Event} event The "fetch notification" event.
	 * @param {Object} jsonData The JSON content representing the
	 *  notification.
	 */
	$.pkp.controllers.SiteHandler.prototype.fetchNotificationHandler_ =
			function(sourceElement, event, jsonData) {

		if (jsonData !== undefined) {
			// This is an event that came from an inplace notification
			// widget that was not visible because of the scrolling.
			this.showNotification_(jsonData);
			return;
		}

		// Avoid race conditions with in place notifications.
		$.ajax({
			url: this.options_.fetchNotificationUrl,
			data: this.options_.requestOptions,
			success: this.callbackWrapper(this.showNotificationResponseHandler_),
			dataType: 'json',
			async: false
		});
	};


	/**
	 * Reload a tab.
	 * @private
	 * @param {HTMLElement} sourceElement The element that issued the
	 *  "reloadTab" event.
	 * @param {Event} event The "reload tab" event.
	 * @param {Object} jsonData The JSON content representing the
	 *  reload request.
	 */
	$.pkp.controllers.SiteHandler.prototype.reloadTabHandler_ =
			function(sourceElement, event, jsonData) {

		$(jsonData.tabsSelector).tabs('load', jsonData.tabSelector);
	};


	/**
	 * Call when click outside event handler. Stores the event
	 * parameters as checks to be used later by mouse down handler so we
	 * can track if user clicked outside the passed element or not.
	 * @private
	 * @param {HTMLElement} sourceElement The element that issued the
	 *  callWhenClickOutside event.
	 * @param {Event} event The "call when click outside" event.
	 * @param {{
	 *   container: jQueryObject,
	 *   callback: Function
	 *   }} eventParams The event parameters.
	 * - container: a jQuery element to be used to test if user click
	 * outside of it or not.
	 * - callback: a callback function in case test is true.
	 */
	$.pkp.controllers.SiteHandler.prototype.callWhenClickOutsideHandler_ =
			function(sourceElement, event, eventParams) {
		// Check the required parameters.
		if (eventParams.container === undefined) {
			return;
		}

		var id = eventParams.container.attr('id');

		if (eventParams.clear) {
			delete this.outsideClickChecks_[id];
		} else if (eventParams.callback !== undefined) {
			this.outsideClickChecks_[id] = eventParams;
		}
	};


	/**
	 * Mouse down event handler attached to the site element.
	 * @private
	 * @param {HTMLElement} sourceElement The element that issued the
	 *  click event.
	 * @param {Event} event The "mousedown" event.
	 * @return {?boolean} Event handling status.
	 */
	$.pkp.controllers.SiteHandler.prototype.mouseDownHandler_ =
			function(sourceElement, event) {

		var $container, callback, id;
		if (!$.isEmptyObject(this.outsideClickChecks_)) {
			for (id in this.outsideClickChecks_) {
				this.processOutsideClickCheck_(
						this.outsideClickChecks_[id], event);
			}
		}

		return true;
	};


	/**
	 * Check if the passed event target is outside the element
	 * inside the passed check data. If true and no other check
	 * option avoids it, use the callback.
	 * @private
	 * @param {{
	 *   container: Object,
	 *   callback: Function
	 *   }} checkOptions Object with data to be used to
	 * check the click.
	 * @param {Event} event The click event to be checked.
	 * @return {boolean} Whether the check was processed or not.
	 */
	$.pkp.controllers.SiteHandler.prototype.processOutsideClickCheck_ =
			function(checkOptions, event) {

		// Make sure we have a click event.
		if (event.type !== 'click' &&
				event.type !== 'mousedown' && event.type !== 'mouseup') {
			throw new Error('Can not check outside click with the passed event: ' +
					event.type + '.');
		}

		// Get the container element.
		var $container = checkOptions.container;

		// Doesn't make sense to check an outside click
		// with an invisible element, so skip test if
		// container is hidden.
		if ($container.is(':hidden')) {
			return false;
		}

		// Do the click origin checking.
		if ($container.has(event.target).length === 0) {

			// Once the check was processed, delete it.
			delete this.outsideClickChecks_[$container.attr('id')];

			checkOptions.callback();

			return true;
		}

		return false;
	};


	/**
	 * Internal callback called upon page unload. If it returns
	 * anything other than void, a message will be displayed to
	 * the user.
	 * @private
	 * @param {Object} object The window object.
	 * @param {Event} event The before unload event.
	 * @return {string|undefined} The warning message string, if needed.
	 */
	$.pkp.controllers.SiteHandler.prototype.pageUnloadHandler_ =
			function(object, event) {
		var handler, unsavedElementCount, element;

		// any registered and then unregistered forms will exist
		// as properties in the unsavedFormElements_ object. They
		// will just be undefined.  See if there are any that are
		// not.

		// we need to get the handler this way since this event is bound
		// to window, not to SiteHandler.
		handler = $.pkp.classes.Handler.getHandler($('body'));

		unsavedElementCount = 0;
		for (element in handler.unsavedFormElements_) {
			if (element) {
				unsavedElementCount++;
			}
		}
		if (unsavedElementCount > 0) {
			return pkp.localeKeys['form.dataHasChanged'];
		}
		return undefined;
	};


	/**
	 * Method to determine if a form is currently registered as having
	 * unsaved changes.
	 *
	 * @param {string} id the id of the form to check.
	 * @return {boolean} true if the form is unsaved.
	 */
	$.pkp.controllers.SiteHandler.prototype.isFormUnsaved =
			function(id) {

		if (this.unsavedFormElements_ !== null &&
				this.unsavedFormElements_[id] !== undefined) {
			return true;
		}
		return false;
	};


	/**
	 * Response handler to the notification fetch.
	 * @private
	 * @param {Object} ajaxContext The data returned from the server.
	 * @param {Object} jsonData A parsed JSON response object.
	 */
	$.pkp.controllers.SiteHandler.prototype.showNotificationResponseHandler_ =
			function(ajaxContext, jsonData) {
		this.showNotification_(jsonData);
	};


	/**
	 * Show the notification content.
	 * @private
	 * @param {Object} jsonData The JSON-encoded notification data.
	 */
	$.pkp.controllers.SiteHandler.prototype.showNotification_ =
			function(jsonData) {
		var workingJsonData, notificationsData, levelId, notificationId,
				addclass, type;

		workingJsonData = this.handleJson(jsonData);
		if (workingJsonData !== false) {
			if (workingJsonData.content.general) {
				notificationsData = workingJsonData.content.general;
				for (levelId in notificationsData) {
					for (notificationId in notificationsData[levelId]) {
						addclass = notificationsData[levelId][notificationId].addclass;
						type = 'notice';
						if (addclass == 'notifySuccess') {
							type = 'success';
						} else if (addclass == 'notifyWarning' || addclass == 'notifyError' ||
								addclass == 'notifyFormError' || addclass == 'notifyForbidden') {
							type = 'warning';
						}
						pkp.eventBus.$emit('notify',
								notificationsData[levelId][notificationId].text, type);
					}
				}
			}
		}
	};


	/**
	 * Reacts to a modal being opened. Adds a class to the body representing
	 * a modal open state.
	 * @private
	 * @param {HTMLElement} handledElement The modal that has been added
	 * @param {HTMLElement} siteHandlerElement The html element
	 * attached to this handler.
	 * @param {HTMLElement} sourceElement The element wishes to
	 * register.
	 * @param {Event} event The formChanged event.
	 */
	$.pkp.controllers.SiteHandler.prototype.openModal_ =
			function(handledElement, siteHandlerElement, sourceElement, event) {
		this.getHtmlElement().addClass('modal_is_visible');
	};


	/**
	 * Reacts to a modal being closed. Removes a class from the body
	 * representing a modal closed state, after checking if no other modals are
	 * open.
	 * @private
	 * @param {HTMLElement} handledElement The modal that has been added
	 * @param {HTMLElement} siteHandlerElement The html element
	 * attached to this handler.
	 * @param {HTMLElement} sourceElement The element wishes to
	 * register.
	 * @param {Event} event The formChanged event.
	 */
	$.pkp.controllers.SiteHandler.prototype.closeModal_ =
			function(handledElement, siteHandlerElement, sourceElement, event) {

		var $htmlElement = this.getHtmlElement();
		if (!$htmlElement.find('.pkp_modal.is_visible').length) {
			$htmlElement.removeClass('modal_is_visible');
		}
	};


	/**
	 * Register a function to observe the body scrolling event.
	 * @private
	 * @param {Object} siteHandler The site handler object.
	 * @param {HTMLElement} siteHandlerElement The html element
	 * attached to this handler.
	 * @param {Object} event The pkpObserveScrolling event object.
	 * @param {Function} observerFunction The observer function.
	 * @return {boolean}
	 */
	$.pkp.controllers.SiteHandler.prototype.registerScrollingObserver_ =
			function(siteHandler, siteHandlerElement, event, observerFunction) {
		$(document).scroll(observerFunction);
		return false;
	};


	/**
	 * Unregister a function that was observing the body scrolling event.
	 * @private
	 * @param {Object} siteHandler The site handler object.
	 * @param {HTMLElement} siteHandlerElement The html element
	 * attached to this handler.
	 * @param {Object} event The pkpRemoveScrollingObserver event object.
	 * @param {Function} observerFunction The observer function.
	 * @return {boolean}
	 */
	$.pkp.controllers.SiteHandler.prototype.unregisterScrollingObserver_ =
			function(siteHandler, siteHandlerElement, event, observerFunction) {
		var castObserverFunction = /** @type {function()} */ (observerFunction);
		$(document).unbind('scroll', castObserverFunction);
		return false;
	};

}(jQuery));
