'use strict';

import {applyAttribute, convertListenerNamesToFns} from './attributes';
import {buildConfigFromCall, buildCallFromConfig} from '../callArgs';
import {
	captureChildren,
	getOwner,
	isChildTag,
	renderChildTree,
} from '../children/children';
import {clearChanges} from '../changes';
import {domData} from 'metal-dom';
import {getData} from '../data';
import {
	getCompatibilityModeData,
	getUid,
	isDef,
	isDefAndNotNull,
	isFunction,
	isServerSide,
	isString,
	object,
} from 'metal';
import {disposeUnused, schedule} from '../cleanup/unused';
import {
	getOriginalFn,
	startInterception,
	stopInterception,
} from '../incremental-dom-aop';
import {Component, ComponentRegistry} from 'metal-component';

const renderingComponents_ = [];
const emptyChildren_ = [];

/**
 * Adds the given css classes to the specified arguments for an incremental
 * dom call, merging with the existing value if there is one.
 * @param {string} elementClasses
 * @param {!Object} config
 * @private
 */
function addElementClasses_(elementClasses, config) {
	if (config.class) {
		config.class += ` ${elementClasses}`;
		config.class = removeDuplicateClasses_(config.class);
	} else {
		config.class = elementClasses;
	}
}

/**
 * Builds the "children" array to be passed to the current component.
 * @param {!Array<!Object>} children
 * @return {!Array<!Object>}
 * @private
 */
function buildChildren_(children) {
	return children.length === 0 ? emptyChildren_ : children;
}

/**
 * Finishes the render operation, doing some cleaups.
 * @param {!Component} component
 * @private
 */
function cleanUpRender_(component) {
	stopInterception();
	if (!getData(component).rootElementReached) {
		component.element = null;
	}
	component.informRendered();
	finishedRenderingComponent_();
}

/**
 * Removes the most recent component from the queue of rendering components.
 * @private
 */
function finishedRenderingComponent_() {
	renderingComponents_.pop();
	if (renderingComponents_.length === 0) {
		disposeUnused();
	}
}

/**
 * Generates a key for the next element to be rendered.
 * @param {!Component} component
 * @param {?string} key The key originally passed to the element.
 * @return {?string}
 * @private
 */
function generateKey_(component, key) {
	const data = getData(component);
	if (!data.rootElementReached && data.config.key) {
		key = data.config.key;
	}
	return component.getRenderer().generateKey(component, key);
}

/**
 * Gets the child components stored in the given object.
 * @param {!Object} data
 * @return {!Array<!Component>}
 * @private
 */
function getChildComponents_(data) {
	data.childComponents = data.childComponents || [];
	return data.childComponents;
}

/**
 * Gets the component being currently rendered.
 * @return {Component}
 */
export function getComponentBeingRendered() {
	return renderingComponents_[renderingComponents_.length - 1];
}

/**
 * Gets the data object that should be currently used. This object will either
 * come from the current element being rendered by incremental dom or from
 * the component instance being rendered (only when the current element is the
 * component's direct parent).
 * @return {!Object}
 * @private
 */
function getCurrentData() {
	const element = IncrementalDOM.currentElement();
	const comp = getComponentBeingRendered();
	let obj = getData(comp);
	if (
		obj.rootElementReached &&
		comp.element &&
		element !== comp.element.parentNode
	) {
		obj = domData.get(element);
	}
	obj.icComponentsData = obj.icComponentsData || {};
	return obj.icComponentsData;
}

/**
 * Returns the "ref" to be used for a component. Uses "key" as "ref" when
 * compatibility mode is on for the current renderer.
 * @param {!Component} owner
 * @param {!Object} config
 * @return {?string}
 * @private
 */
function getRef_(owner, config) {
	const compatData = getCompatibilityModeData();
	if (compatData) {
		const ownerRenderer = owner.getRenderer();
		const renderers = compatData.renderers;
		const useKey =
			!renderers ||
			renderers.indexOf(ownerRenderer) !== -1 ||
			renderers.indexOf(ownerRenderer.RENDERER_NAME) !== -1;
		if (useKey && config.key && !config.ref) {
			return config.key;
		}
	}
	return config.ref;
}

/**
 * Gets the sub component referenced by the given tag and config data,
 * creating it if it doesn't yet exist.
 * @param {string|!Function} tagOrCtor The tag name.
 * @param {!Object} config The config object for the sub component.
 * @param {!Component} owner
 * @return {!Component} The sub component.
 * @protected
 */
function getSubComponent_(tagOrCtor, config, owner) {
	let Ctor = tagOrCtor;
	if (isString(Ctor)) {
		Ctor = ComponentRegistry.getConstructor(tagOrCtor);
	}

	const ref = getRef_(owner, config);
	let comp;
	if (isDef(ref)) {
		comp = match_(owner.components[ref], Ctor, config, owner);
		owner.components[ref] = comp;
		owner.refs[ref] = comp;
	} else {
		const data = getCurrentData();
		let key = config.key;
		if (!isDef(key)) {
			const type = getUid(Ctor, true);
			data.currCount = data.currCount || {};
			data.currCount[type] = data.currCount[type] || 0;
			key = `__METAL_IC__${type}_${data.currCount[type]++}`;
		}
		comp = match_(
			data.prevComps ? data.prevComps[key] : null,
			Ctor,
			config,
			owner
		);
		data.currComps = data.currComps || {};
		data.currComps[key] = comp;
	}

	return comp;
}

/**
 * Handles the event of children having finished being captured.
 * @param {!Object} tree The captured children in tree format.
 * @private
 * @return {Object}
 */
function handleChildrenCaptured_(tree, {props, tag}) {
	props.children = buildChildren_(tree.props.children);
	return renderFromTag_(tag, props);
}

/**
 * Handles a child being rendered via `IncrementalDomChildren.render`. Skips
 * component nodes so that they can be rendered the correct way without
 * having to recapture both them and their children via incremental dom.
 * @param {!Object} node
 * @return {boolean}
 * @private
 */
function handleChildRender_(node) {
	if (node.tag && isComponentTag_(node.tag)) {
		node.props.children = buildChildren_(node.props.children);
		renderFromTag_(node.tag, node.props, getOwner(node));
		return true;
	}
}

/**
 * Handles an intercepted call to the attributes default handler from
 * incremental dom.
 * @param {!Element} element
 * @param {string} name
 * @param {*} value
 * @private
 */
function handleInterceptedAttributesCall_(element, name, value) {
	applyAttribute(getComponentBeingRendered(), element, name, value);
}

/**
 * Handles an intercepted call to the `elementOpen` function from incremental
 * dom.
 * @param {string} tag
 * @private
 * @return {Object}
 */
function handleInterceptedOpenCall_(tag) {
	if (isComponentTag_(tag)) {
		return handleSubComponentCall_(...arguments);
	} else {
		return handleRegularCall_(...arguments);
	}
}

/**
 * Handles an intercepted call to the `elementOpen` function from incremental
 * dom, done for a regular element. Among other things, adds any inline
 * listeners found on the first render and makes sure that component root
 * elements are always reused.
 * @param {!Component} owner
 * @param {!Array} args
 * @return {!Element} The rendered element.
 * @private
 */
function handleRegularCall_(...args) {
	const config = buildConfigFromCall(args);
	let tag = args[0];

	const comp = getComponentBeingRendered();
	let owner = comp;
	if (isChildTag(tag)) {
		owner = tag.owner;
		tag = tag.tag;
	}
	config.key = generateKey_(comp, config.key);

	if (!getData(comp).rootElementReached) {
		const elementClasses = comp
			.getDataManager()
			.get(comp, 'elementClasses');
		if (elementClasses) {
			addElementClasses_(elementClasses, config);
		}
	}
	convertListenerNamesToFns(comp, config);

	const call = buildCallFromConfig(tag, config);
	const node = getOriginalFn('elementOpen')(...call);
	resetNodeData_(node);
	updateElementIfNotReached_(comp, node);

	if (isDefAndNotNull(config.ref)) {
		owner.refs[config.ref] = node;
	}
	owner.getRenderer().handleNodeRendered(node);

	return node;
}

/**
 * Handles an intercepted call to the `elementOpen` function from incremental
 * dom, done for a sub component element. Creates and updates the appropriate
 * sub component.
 * @private
 */
function handleSubComponentCall_(...args) {
	captureChildren(getComponentBeingRendered(), handleChildrenCaptured_, {
		props: buildConfigFromCall(args),
		tag: args[0],
	});
}

/**
 * Passes down elementClasses to a child component if the parent component
 * returns another component at the top level (HOC).
 * @param {*} parent The parent component
 * @param {*} config The config of the subcomponent
 */
function inheritElementClasses_(parent, config) {
	const parentData = getData(parent);
	const parentConfig = parentData.config;

	if (
		!parentData.rootElementReached &&
		parentConfig &&
		isString(parentConfig.elementClasses)
	) {
		let currentClasses = '';
		if (isString(config.elementClasses)) {
			currentClasses = `${config.elementClasses} `;
		}

		config.elementClasses = currentClasses + parentConfig.elementClasses;
	}
}

/**
 * Checks if the given tag represents a metal component.
 * @param {string} tag
 * @return {boolean}
 * @private
 */
export function isComponentTag_(tag) {
	return (
		isFunction(tag) || (isString(tag) && tag[0] === tag[0].toUpperCase())
	); // eslint-disable-line
}

/**
 * Checks if the given component can be a match for a constructor.
 * @param {!Component} comp
 * @param {!function()} Ctor
 * @param {!Component} owner
 * @return {boolean}
 * @private
 */
function isMatch_(comp, Ctor, owner) {
	if (!comp || comp.constructor !== Ctor || comp.isDisposed()) {
		return false;
	}
	return getData(comp).owner === owner;
}

/**
 * Returns the given component if it matches the specified constructor
 * function. Otherwise, returns a new instance of the given constructor. On
 * both cases the component's state and config will be updated.
 * @param {Component} comp
 * @param {!function()} Ctor
 * @param {!Object} config
 * @param {!Component} owner
 * @return {!Component}
 * @private
 */
function match_(comp, Ctor, config, owner) {
	if (isMatch_(comp, Ctor, owner)) {
		comp.startSkipUpdates();
		comp.getDataManager().replaceNonInternal(comp, config);
		comp.stopSkipUpdates();
	} else {
		comp = new Ctor(config, false);
	}
	getData(comp).config = config;
	return comp;
}

/**
 * Prepares the render operation, resetting the component's data and starting
 * the incremental dom interception.
 * @param {!Component} component
 * @private
 */
function prepareRender_(component) {
	renderingComponents_.push(component);

	const data = getData(component);
	resetComponentsData_(data.icComponentsData);
	clearChanges(data);
	data.rootElementReached = false;
	component.refs = {};

	if (data.childComponents) {
		schedule(data.childComponents);
		data.childComponents = null;
	}

	startInterception({
		attributes: handleInterceptedAttributesCall_,
		elementOpen: handleInterceptedOpenCall_,
	});
}

/**
 * Removes duplicate css classes from the given string.
 * @param {string} classString
 * @return {string}
 * @private
 */
function removeDuplicateClasses_(classString) {
	const classes = [];
	const all = classString.split(/\s+/);
	const used = {};
	for (let i = 0; i < all.length; i++) {
		if (!used[all[i]]) {
			used[all[i]] = true;
			classes.push(all[i]);
		}
	}
	return classes.join(' ');
}

/**
 * Renders the component with incremental dom function calls. This assumes that
 * an incremental dom `patch` is already running, and that this function has
 * been called inside it.
 * @param {!Component} component
 */
export function render(component) {
	prepareRender_(component);
	component.getRenderer().renderIncDom(component);
	cleanUpRender_(component);
}

/**
 * Renders the given child node.
 * @param {!Object} child
 */
export function renderChild(child) {
	renderChildTree(child, handleChildRender_);
}

/**
 * Renders the contents for the given tag.
 * @param {!function()|string} tag
 * @param {!Object} config
 * @param {Component=} owner
 * @private
 * @return {Object}
 */
function renderFromTag_(tag, config, owner) {
	if (isString(tag) || tag.prototype.getRenderer) {
		const comp = renderSubComponent_(tag, config, owner);
		updateElementIfNotReached_(getComponentBeingRendered(), comp.element);
		return comp.element;
	} else {
		return tag(config);
	}
}

/**
 * Creates and renders the given function, which can either be a simple
 * incremental dom function or a component constructor.
 * @param {!IncrementalDomRenderer} renderer
 * @param {!function()} fnOrCtor Either a simple incremental dom function or a
 *     component constructor.
 * @param {Object|Element=} dataOrElement Optional config data for the
 *     function or parent for the rendered content.
 * @param {Element=} parent Optional parent for the rendered content.
 * @return {!Component} The rendered component's instance.
 */
export function renderFunction(renderer, fnOrCtor, dataOrElement, parent) {
	if (!Component.isComponentCtor(fnOrCtor)) {
		const fn = fnOrCtor;
		class TempComponent extends Component {
			created() {
				const parent = getComponentBeingRendered();
				if (parent) {
					updateContext_(this, parent);
				}
			}

			render() {
				fn(this.getInitialConfig());
			}
		}
		TempComponent.RENDERER = renderer;
		fnOrCtor = TempComponent;
	}
	return Component.render(fnOrCtor, dataOrElement, parent);
}

/**
 * This updates the sub component that is represented by the given data.
 * The sub component is created, added to its parent and rendered. If it
 * had already been rendered before though, it will only have its state
 * updated instead.
 * @param {string|!function()} tagOrCtor The tag name or constructor function.
 * @param {!Object} config The config object for the sub component.
 * @param {ComponentRenderer=} owner
 * @return {!Component} The updated sub component.
 * @private
 */
function renderSubComponent_(tagOrCtor, config, owner) {
	const parent = getComponentBeingRendered();
	owner = owner || parent;

	inheritElementClasses_(parent, config);

	const comp = getSubComponent_(tagOrCtor, config, owner);
	updateContext_(comp, parent);

	const data = getData(comp);
	data.parent = parent;
	data.owner = owner;

	const parentData = getData(parent);
	getChildComponents_(parentData).push(comp);
	if (!config.key && !parentData.rootElementReached) {
		config.key = parentData.config.key;
	}

	if (comp.portalElement && isServerSide()) {
		return comp;
	}

	if (!comp.portalElement) {
		comp.getRenderer().renderInsidePatch(comp);
	}

	if (!comp.wasRendered) {
		comp.renderComponent();
	}
	return comp;
}

/**
 * Resets the given incremental dom data object, preparing it for the next pass.
 * @param {Object} data
 * @private
 */
function resetComponentsData_(data) {
	if (data) {
		data.prevComps = data.currComps;
		data.currComps = null;
		data.currCount = null;
	}
}
/**
 * Resets all data stored in the given node.
 * @param {!Element} node
 * @private
 */
function resetNodeData_(node) {
	if (domData.has(node)) {
		resetComponentsData_(domData.get(node).icComponentsData);
	}
}

/**
 * Updates the given component's context according to the data from the
 * component that is currently being rendered.
 * @param {!Component} comp
 * @param {!Component} parent
 * @protected
 */
function updateContext_(comp, parent) {
	const context = comp.context;
	const childContext = parent.getChildContext
		? parent.getChildContext()
		: null; // eslint-disable-line
	object.mixin(context, parent.context, childContext);
	comp.context = context;
}

/**
 * Updates this renderer's component's element with the given values, unless
 * it has already been reached by an earlier call.
 * @param {!Component} component
 * @param {!Element} node
 * @private
 */
function updateElementIfNotReached_(component, node) {
	const data = getData(component);
	if (!data.rootElementReached) {
		data.rootElementReached = true;
		if (component.element !== node) {
			component.element = node;
		}
	}
}
