'use strict';

import {addListenersFromObj} from './events/events';
import {
	getStaticProperty,
	isBoolean,
	isDefAndNotNull,
	isElement,
	isObject,
	isServerSide,
	isString,
	object,
} from 'metal';
import {syncState} from './sync/sync';
import {DomEventEmitterProxy, enterDocument, toElement} from 'metal-dom';
import ComponentDataManager from './ComponentDataManager';
import ComponentRenderer from './ComponentRenderer';
import {EventEmitter, EventHandler} from 'metal-events';

/**
 * Component collects common behaviors to be followed by UI components, such
 * as Lifecycle, CSS classes management, events encapsulation and support for
 * different types of rendering.
 * Rendering logic can be done by either:
 *     - Listening to the `render` event inside the `created` lifecycle function
 *       and adding the rendering logic to the listener.
 *     - Using an existing implementation of `ComponentRenderer` like `Soy`,
 *       and following its patterns.
 *     - Building your own implementation of a `ComponentRenderer`.
 * Specifying the renderer that will be used can be done by setting the RENDERER
 * static variable to the renderer's constructor function.
 *
 * Example:
 *
 * <code>
 * class CustomComponent extends Component {
 *   created() {
 *   }
 *
 *   rendered() {
 *   }
 *
 *   willAttach() {
 *   }
 *
 *   attached() {
 *   }
 *
 *   willReceiveState() {
 *   }
 *
 *   // willReceiveProps is only available in JSX components
 *   willReceiveProps() {
 *   }
 *
 *   shouldUpdate() {
 *   }
 *
 *   willUpdate() {
 *   }
 *
 *   willDetach() {
 *   }
 *
 *   detached() {
 *   }
 *
 *   disposed() {
 *   }
 * }
 *
 * CustomComponent.RENDERER = MyRenderer;
 *
 * CustomComponent.STATE = {
 *   title: { value: 'Title' },
 *   fontSize: { value: '10px' }
 * };
 * </code>
 *
 * @extends {State}
 */
class Component extends EventEmitter {
	/**
	 * Constructor function for `Component`.
	 * @param {Object=} config An object with the initial values for this
	 *     component's state.
	 * @param {boolean|string|Element=} parentElement The element where the
	 *     component should be rendered. Can be given as a selector or an element.
	 *     If `false` is passed, the component won't be rendered automatically
	 *     after created.
	 * @constructor
	 */
	constructor(config, parentElement) {
		super();

		/**
		 * Instance of `DomEventEmitterProxy` which proxies events from the component's
		 * element to the component itself.
		 * @type {!DomEventEmitterProxy}
		 * @protected
		 */
		this.elementEventProxy_ = new DomEventEmitterProxy(
			null,
			this,
			proxyBlackList_
		);

		/**
		 * The `EventHandler` instance for events attached from the `events` state key.
		 * @type {EventHandler}
		 * @protected
		 */
		this.eventsStateKeyHandler_ = null;

		/**
		 * Stores the pending callback function for when `forceUpdate` is used
		 * to trigger a rerender.
		 * @type {?function}
		 */
		this.forceUpdateCallback_ = null;

		/**
		 * Whether the element is in document.
		 * @type {boolean}
		 */
		this.inDocument = false;

		/**
		 * The initial config option passed to this constructor.
		 * @type {!Object}
		 * @protected
		 */
		this.initialConfig_ = config || {};

		/**
		 * Indicates whether the component should be rendered as a Portal, outside
		 * of the parent component.
		 * @type {string|Element|boolean}
		 */
		this.portalElement = null;

		/**
		 * Whether the element was rendered.
		 * @type {boolean}
		 */
		this.wasRendered = false;

		/**
		 * The component's element will be appended to the element this variable is
		 * set to, unless the user specifies another parent when calling `render` or
		 * `attach`.
		 * @type {!Element}
		 */
		this.DEFAULT_ELEMENT_PARENT =
			typeof document !== 'undefined' ? document.body : null;

		this.setShouldUseFacade(true);
		this.element = this.initialConfig_.element;

		this.setUpRenderer_();
		this.setUpDataManager_();
		this.setUpSyncUpdates_();

		this.setUpPortal_(this.initialConfig_.portalElement);

		this.on('stateWillChange', this.handleStateWillChange_);
		this.on('stateChanged', this.handleComponentStateChanged_);
		this.on('eventsChanged', this.onEventsChanged_);
		this.addListenersFromObj_(this.dataManager_.get(this, 'events'));

		this.created();
		this.componentCreated_ = true;
		if (parentElement !== false) {
			this.renderComponent(parentElement);
		}
	}

	/**
	 * Getter logic for the element property.
	 * @return {Element}
	 */
	get element() {
		return this.elementValue_;
	}

	/**
	 * Adds the listeners specified in the given object.
	 * @param {!Object} obj
	 * @protected
	 */
	addListenersFromObj_(obj) {
		if (!this.eventsStateKeyHandler_) {
			this.eventsStateKeyHandler_ = new EventHandler();
		}
		const handles = addListenersFromObj(this, obj);
		this.eventsStateKeyHandler_.add(...handles);
	}

	/**
	 * Invokes the attached Lifecycle. When attached, the component element is
	 * appended to the DOM and any other action to be performed must be
	 * implemented in this method, such as, binding DOM events. A component can
	 * be re-attached multiple times.
	 * @param {(string|Element)=} parentElement Optional parent element
	 *     to render the component.
	 * @param {(string|Element)=} siblingElement Optional sibling element
	 *     to render the component before it. Relevant when the component needs
	 *     to be rendered before an existing element in the DOM.
	 * @protected
	 * @chainable
	 * @return {Component}
	 */
	attach(parentElement, siblingElement) {
		if (!this.inDocument) {
			this.emit('willAttach');
			this.willAttach();
			this.attachElement(parentElement, siblingElement);
			this.inDocument = true;
			this.attachData_ = {
				parent: parentElement,
				sibling: siblingElement,
			};
			this.emit('attached', this.attachData_);
			this.attached();
		}
		return this;
	}

	/**
	 * Lifecycle. When attached, the component element is appended to the DOM
	 * and any other action to be performed must be implemented in this method,
	 * such as, binding DOM events. A component can be re-attached multiple
	 * times, therefore the undo behavior for any action performed in this phase
	 * must be implemented on the detach phase.
	 */
	attached() {}

	/**
	 * Attaches the component element into the DOM.
	 * @param {(string|Element)=} parentElement Optional parent element
	 *     to render the component.
	 * @param {(string|Element)=} siblingElement Optional sibling element
	 *     to render the component before it. Relevant when the component needs
	 *     to be rendered before an existing element in the DOM, e.g.
	 *     `component.attach(null, existingElement)`.
	 */
	attachElement(parentElement, siblingElement) {
		const element = this.element;
		if (element && (siblingElement || !element.parentNode)) {
			const parent =
				toElement(parentElement) || this.DEFAULT_ELEMENT_PARENT; // eslint-disable-line
			parent.insertBefore(element, toElement(siblingElement));
		}
	}

	/**
	 * Lifecycle. This is called when the component has just been created, before
	 * it's rendered.
	 */
	created() {}

	/**
	 * Listens to a delegate event on the component's element.
	 * @param {string} eventName The name of the event to listen to.
	 * @param {string} selector The selector that matches the child elements that
	 *   the event should be triggered for.
	 * @param {!function(!Object)} callback Function to be called when the event is
	 *   triggered. It will receive the normalized event object.
	 * @return {!EventHandle} Can be used to remove the listener.
	 */
	delegate(eventName, selector, callback) {
		return this.on(`delegate:${eventName}:${selector}`, callback);
	}

	/**
	 * Invokes the detached Lifecycle. When detached, the component element is
	 * removed from the DOM and any other action to be performed must be
	 * implemented in this method, such as, unbinding DOM events. A component
	 * can be detached multiple times.
	 * @chainable
	 * @return {Component}
	 */
	detach() {
		if (this.inDocument) {
			this.emit('willDetach');
			this.willDetach();
			if (this.element && this.element.parentNode) {
				this.element.parentNode.removeChild(this.element);
			}
			this.inDocument = false;
			this.detached();
		}
		this.emit('detached');
		return this;
	}

	/**
	 * Lifecycle. When detached, the component element is removed from the DOM
	 * and any other action to be performed must be implemented in this method,
	 * such as, unbinding DOM events. A component can be detached multiple
	 * times, therefore the undo behavior for any action performed in this phase
	 * must be implemented on the attach phase.
	 */
	detached() {}

	/**
	 * Lifecycle. Called when the component is disposed. Should be overridden by
	 * sub classes to dispose of any internal data or events.
	 */
	disposed() {}

	/**
	 * @inheritDoc
	 */
	disposeInternal() {
		this.detach();
		this.disposed();
		this.emit('disposed');

		this.elementEventProxy_.dispose();
		this.elementEventProxy_ = null;

		this.dataManager_.dispose(this);
		this.dataManager_ = null;

		this.renderer_.dispose(this);
		this.renderer_ = null;

		super.disposeInternal();
	}

	/**
	 * Forces an update that ignores the `shouldUpdate` lifecycle method for
	 * components whose render depends on external variables.
	 * @param {function()} callback
	 */
	forceUpdate(callback) {
		this.forceUpdateCallback_ = callback;

		this.updateRenderer_({
			forceUpdate: true,
		});
	}

	/**
	 * Gets data about where this component was attached at.
	 * @return {!Object}
	 */
	getAttachData() {
		return this.attachData_;
	}

	/**
	 * Gets the `ComponentDataManager` being used.
	 * @return {!ComponentDataManager}
	 */
	getDataManager() {
		return this.dataManager_;
	}

	/**
	 * Gets the configuration object that was passed to this component's constructor.
	 * @return {!Object}
	 */
	getInitialConfig() {
		return this.initialConfig_;
	}

	/**
	 * Gets portalElement based on selector. If an id is passed and the element
	 * does not exist, the element is created with that id and appended to the body.
	 *
	 * @param {string|Element} portalElementSelector
	 * @return {?Element}
	 */
	getPortalElement_(portalElementSelector) {
		let portalElement = toElement(portalElementSelector);

		if (portalElement) {
			return portalElement;
		}

		if (
			portalElementSelector.indexOf('#') === 0 &&
			portalElementSelector.indexOf(' ') === -1
		) {
			portalElement = document.createElement('div');
			portalElement.setAttribute('id', portalElementSelector.slice(1));

			enterDocument(portalElement);
		}

		return portalElement;
	}

	/**
	 * Gets state data for this component.
	 * @return {!Object}
	 */
	getState() {
		return this.dataManager_.getState(this);
	}

	/**
	 * Gets the keys for the state data.
	 * @return {!Array<string>}
	 */
	getStateKeys() {
		return this.dataManager_.getStateKeys(this);
	}

	/**
	 * Gets the `ComponentRenderer` instance being used.
	 * @return {!ComponentRenderer}
	 */
	getRenderer() {
		return this.renderer_;
	}

	/**
	 * Handles a change in the component's element.
	 * @param {Element} prevVal
	 * @param {Element} newVal
	 * @protected
	 */
	handleComponentElementChanged_(prevVal, newVal) {
		this.elementEventProxy_.setOriginEmitter(newVal);
		if (this.componentCreated_) {
			this.emit('elementChanged', {
				prevVal,
				newVal,
			});
			if (newVal && this.wasRendered) {
				this.syncVisible(this.dataManager_.get(this, 'visible'));
			}
		}
	}

	/**
	 * Handles state batch changes. Calls any existing `sync` functions that
	 * match the changed state keys.
	 * @param {Event} event
	 * @protected
	 */
	handleComponentStateChanged_(event) {
		if (!this.hasSyncUpdates()) {
			this.updateRenderer_(event);
		}
		syncState(this, event.changes);
		this.emit('stateSynced', event);
	}

	/**
	 * Handles a `stateKeyChanged` event. This is only called for components that
	 * have requested updates to happen synchronously.
	 * @param {!{key: string, newVal: *, prevVal: *}} data
	 * @protected
	 */
	handleComponentStateKeyChanged_(data) {
		this.updateRenderer_({
			changes: {
				[data.key]: data,
			},
		});
	}

	/**
	 * Fires before state batch changes. Provides hook point for modifying
	 *     state.
	 * @param {Event} event
	 * @protected
	 */
	handleStateWillChange_(event) {
		this.willReceiveState(event.changes);
	}

	/**
	 * Checks if this component has sync updates enabled.
	 * @return {boolean}
	 */
	hasSyncUpdates() {
		return this.syncUpdates_;
	}

	/**
	 * Informs that the component that the rendered has finished rendering it. The
	 * renderer is the one responsible for calling this when appropriate. This
	 * will emit events and run the appropriate lifecycle for the first render.
	 */
	informRendered() {
		const firstRender = !this.hasRendererRendered_;
		this.hasRendererRendered_ = true;

		if (this.forceUpdateCallback_) {
			this.forceUpdateCallback_();
			this.forceUpdateCallback_ = null;
		}

		this.rendered(firstRender);
		this.emit('rendered', firstRender);
	}

	/**
	 * Informs the component that the renderer is about to update. Calls the
	 * component's `willUpdate` lifecycle method.
	 * @param {Object} changes
	 */
	informWillUpdate(...args) {
		this.willUpdate(...args);
	}

	/**
	 * Checks if the given function is a component constructor.
	 * @param {!function()} fn Any function
	 * @return {boolean}
	 */
	static isComponentCtor(fn) {
		return fn.prototype && fn.prototype[Component.COMPONENT_FLAG];
	}

	/**
	 * Merges two values for the ELEMENT_CLASSES property into a single one.
	 * @param {string} class1
	 * @param {string} class2
	 * @return {string} The merged value.
	 * @protected
	 */
	mergeElementClasses_(class1, class2) {
		return class1 ? class1 + ' ' + (class2 || '') : class2;
	}

	/**
	 * Fired when the `events` state value is changed.
	 * @param {!Object} event
	 * @protected
	 */
	onEventsChanged_(event) {
		this.eventsStateKeyHandler_.removeAllListeners();
		this.addListenersFromObj_(event.newVal);
	}

	/**
	 * Creates and renders a component for the given constructor function. This
	 * will always make sure that the constructor runs without rendering the
	 * component, having the `render` step happen only after it has finished.
	 * @param {!function()} Ctor The component's constructor function.
	 * @param {Object|Element=} configOrElement Optional config data or parent
	 *     for the component.
	 * @param {Element=} element Optional parent for the component.
	 * @return {!Component} The rendered component's instance.
	 */
	static render(Ctor, configOrElement, element) {
		let config = configOrElement;
		if (isElement(configOrElement)) {
			config = null;
			element = configOrElement;
		}
		const instance = new Ctor(config, false);
		instance.renderComponent(element);
		return instance;
	}

	/**
	 * Creates and renders a component for the given constructor function as
	 * string. This will always make sure that the constructor runs without
	 * rendering the component, having the `render` step happen only after it
	 * has finished.
	 * @param {!function()} Ctor The component's constructor function.
	 * @param {Object|Element=} configOrElement Optional config data or
	 *     parent for the component.
	 * @return {!String} The rendered component's content as string.
	 */
	static renderToString(Ctor, configOrElement) {
		const rendererName = Ctor.RENDERER && Ctor.RENDERER.RENDERER_NAME;
		switch (rendererName) {
		case 'jsx':
		case 'soy':
		case 'incremental-dom': {
			if (typeof IncrementalDOM === 'undefined') {
				throw new Error(
					`Error. Trying to render incremental dom ` +
							`based component to string requires IncrementalDOM ` +
							`implementation to be loaded.`
				);
			}
			// Incremental dom patches for components or nested components are
			// isolated inside the component element. The following code intercepts
			// incremental dom patches and collect results into temporary stack in
			// order to successfully collect the final string of the outermost
			// component after all nested components stack rendered.
			const interceptedComponentStrings = [];
			const patch = IncrementalDOM.patch;
			const patchInterceptor = function(...args) {
				let currentElement = patch(...args);
				interceptedComponentStrings.push(currentElement.innerHTML);
				IncrementalDOM.patch = patch;
			};
			IncrementalDOM.patch = patchInterceptor;
			Component.render(Ctor, configOrElement).dispose();
			return interceptedComponentStrings[0];
		}
		default:
			throw new Error(
				`Error. Trying to render non incremental dom ` +
						`based component to string.`
			);
		}
	}

	/**
	 * Renders the component into the DOM via its `ComponentRenderer`. Stores the
	 * given parent element to be used when the renderer is done (`informRendered`).
	 * @param {(string|Element|boolean)=} parentElement Optional parent element
	 *     to render the component. If set to `false`, the element won't be
	 *     attached to any element after rendering. In this case, `attach` should
	 *     be called manually later to actually attach it to the dom.
	 */
	renderComponent(parentElement) {
		if (!this.hasRendererRendered_) {
			if (!isServerSide() && window.__METAL_DEV_TOOLS_HOOK__) {
				window.__METAL_DEV_TOOLS_HOOK__(this);
			}
			this.getRenderer().render(this);
		}
		this.emit('render');
		syncState(this);
		this.attach(parentElement);
		this.wasRendered = true;
	}

	/**
	 * Setter logic for the element property.
	 * @param {?string|Element} val
	 */
	set element(val) {
		if (!isElement(val) && !isString(val) && isDefAndNotNull(val)) {
			return;
		}

		if (val) {
			val = toElement(val) || this.elementValue_;
		}

		if (this.elementValue_ !== val) {
			const prev = this.elementValue_;
			this.elementValue_ = val;
			this.handleComponentElementChanged_(prev, val);
		}
	}

	/**
	 * Sets the value of all the specified state keys.
	 * @param {!Object.<string,*>} state A map of state keys to the values they
	 *   should be set to.
	 * @param {function()=} callback An optional function that will be run
	 *   after the next batched update is triggered.
	 */
	setState(state, callback) {
		this.dataManager_.setState(this, state, callback);
	}

	/**
	 * Setter for the `elementClasses` data property. Appends given value with
	 * the one specified in `ELEMENT_CLASSES`.
	 * @param {string} val
	 * @return {string}
	 * @protected
	 */
	setterElementClassesFn_(val) {
		const elementClasses = getStaticProperty(
			this.constructor,
			'ELEMENT_CLASSES',
			this.mergeElementClasses_
		);
		if (elementClasses) {
			val += ` ${elementClasses}`;
		}
		return val.trim();
	}

	/**
	 * Sets up the component's data manager.
	 * @protected
	 */
	setUpDataManager_() {
		this.dataManager_ = getStaticProperty(this.constructor, 'DATA_MANAGER');
		this.dataManager_.setUp(
			this,
			object.mixin(
				{},
				this.renderer_.getExtraDataConfig(this),
				Component.DATA
			) // eslint-disable-line
		);
	}

	/**
	 * Overwrites element property if portalElement is passed. Creates
	 * a nested placeholder so that portalElement is not removed from the
	 * DOM when component first renders. When portalElement is equal to true,
	 * component is appeneded to the body.
	 *
	 * @param {string|Element|boolean} portalElement
	 */
	setUpPortal_(portalElement) {
		if (
			!portalElement ||
			(!isElement(portalElement) &&
				!isString(portalElement) &&
				!isBoolean(portalElement))
		) {
			return;
		} else if (isBoolean(portalElement) && portalElement) {
			portalElement = 'body';
		}

		if (isServerSide()) {
			this.portalElement = true;
			return;
		}

		portalElement = this.getPortalElement_(portalElement);

		if (portalElement) {
			const placeholder = document.createElement('div');

			portalElement.appendChild(placeholder);

			this.element = placeholder;
			this.portalElement = portalElement;
		}
	}

	/**
	 * Sets up the component's renderer.
	 * @protected
	 */
	setUpRenderer_() {
		this.renderer_ = getStaticProperty(this.constructor, 'RENDERER');
		this.renderer_.setUp(this);
	}

	/**
	 * Sets up the component to use sync updates when `SYNC_UPDATES` is `true`.
	 * @protected
	 */
	setUpSyncUpdates_() {
		this.syncUpdates_ = getStaticProperty(this.constructor, 'SYNC_UPDATES');
		if (this.hasSyncUpdates()) {
			this.on(
				'stateKeyChanged',
				this.handleComponentStateKeyChanged_.bind(this)
			);
		}
	}

	/**
	 * Skips renderer updates until `stopSkipUpdates` is called.
	 */
	startSkipUpdates() {
		this.skipUpdates_ = true;
	}

	/**
	 * Stops skipping renderer updates.
	 */
	stopSkipUpdates() {
		this.skipUpdates_ = false;
	}

	/**
	 * State synchronization logic for `visible` state key.
	 * Updates the element's display value according to its visibility.
	 * @param {boolean} newVal
	 */
	syncVisible(newVal) {
		if (this.element) {
			this.element.style.display = newVal ? '' : 'none';
		}
	}

	/**
	 * Lifecycle. Called whenever the component has just been rendered.
	 * @param {boolean} firstRender Flag indicating if this was the component's
	 *     first render.
	 */
	rendered() {}

	/**
	 * Calls "update" on the renderer, passing it the changed data.
	 * @param {!{changes: !Object}} data
	 * @protected
	 */
	updateRenderer_(data) {
		if (!data.forceUpdate) {
			this.forceUpdateCallback_ = null;
		}

		if (!this.skipUpdates_ && this.hasRendererRendered_) {
			this.getRenderer().update(this, data);
		}
	}

	/**
	 * Validator logic for the `events` state key.
	 * @param {Object} val
	 * @return {boolean}
	 * @protected
	 */
	validatorEventsFn_(val) {
		return !isDefAndNotNull(val) || isObject(val);
	}

	/**
	 * Lifecycle. Fires before the component has been attached to the DOM.
	 */
	willAttach() {}

	/**
	 * Lifecycle. Fires before component is detached from the DOM.
	 */
	willDetach() {}

	/**
	 * Lifecycle. Called when the component is about to receive state changes.
	 * Provides a hook point for modifying state that can be used in the next
	 * rerender.
	 * @param {Object} changes Changes made to this.state
	 */
	willReceiveState() {}

	/**
	 * Lifecycle. Called when the component's renderer is about to update.
	 * @param {Object} changes
	 */
	willUpdate() {}
}

/**
 * Component data definition.
 * @type {Object}
 * @static
 */
Component.DATA = {
	/**
	 * Objects describing children elements that were passed to be rendered inside
	 * this component.
	 * @type {!Array<!Object>}
	 */
	children: {
		validator: Array.isArray,
		value: [],
	},

	/**
	 * CSS classes to be applied to the element.
	 * @type {string}
	 */
	elementClasses: {
		setter: 'setterElementClassesFn_',
		validator: isString,
		value: '',
	},

	/**
	 * Listeners that should be attached to this component. Should be provided as
	 * an object, where the keys are event names and the values are the listener
	 * functions (or function names).
	 * @type {Object<string, (function()|string|{selector: string, fn: function()|string})>}
	 */
	events: {
		validator: 'validatorEventsFn_',
		value: null,
	},

	/**
	 * Indicates if the component is visible or not.
	 * @type {boolean}
	 */
	visible: {
		validator: isBoolean,
		value: true,
	},
};

/**
 * Name of the flag used to identify component constructors via their prototype.
 * @type {string}
 */
Component.COMPONENT_FLAG = '__metal_component__';

/**
 * The `ComponentDataManager` class that should be used. This class will be
 * responsible for handling the component's data. Each component may have its
 * own implementation.
 * @type {!ComponentDataManager}
 */
Component.DATA_MANAGER = ComponentDataManager;

/**
 * CSS classes to be applied to the element.
 * @type {string}
 */
Component.ELEMENT_CLASSES = '';

/**
 * The `ComponentRenderer` that should be used. Components need to set this
 * to a subclass of `ComponentRenderer` that has the rendering logic, like
 * `SoyRenderer`.
 * @type {!ComponentRenderer}
 */
Component.RENDERER = ComponentRenderer;

/**
 * Flag indicating if component updates will happen synchronously. Updates are
 * done asynchronously by default, which allows changes to be batched and
 * applied together.
 * @type {boolean}
 */
Component.SYNC_UPDATES = false;

/**
 * Sets a prototype flag to easily determine if a given constructor is for
 * a component or not.
 */
Component.prototype[Component.COMPONENT_FLAG] = true;

const proxyBlackList_ = {
	eventsChanged: true,
	stateChanged: true,
	stateKeyChanged: true,
};

export default Component;
