import { checkPropTypes } from './check-props';
import { options, Component } from 'preact';
import {
	ELEMENT_NODE,
	DOCUMENT_NODE,
	DOCUMENT_FRAGMENT_NODE
} from './constants';
import {
	getOwnerStack,
	setupComponentStack,
	getCurrentVNode,
	getDisplayName
} from './component-stack';
import { assign, isNaN } from './util';

const isWeakMapSupported = typeof WeakMap == 'function';

/**
 * @param {import('./internal').VNode} vnode
 * @returns {Array<string>}
 */
function getDomChildren(vnode) {
	let domChildren = [];

	if (!vnode._children) return domChildren;

	vnode._children.forEach(child => {
		if (child && typeof child.type === 'function') {
			domChildren.push.apply(domChildren, getDomChildren(child));
		} else if (child && typeof child.type === 'string') {
			domChildren.push(child.type);
		}
	});

	return domChildren;
}

/**
 * @param {import('./internal').VNode} parent
 * @returns {string}
 */
function getClosestDomNodeParentName(parent) {
	if (!parent) return '';
	if (typeof parent.type == 'function') {
		if (parent._parent == null) {
			if (parent._dom != null && parent._dom.parentNode != null) {
				return parent._dom.parentNode.localName;
			}
			return '';
		}
		return getClosestDomNodeParentName(parent._parent);
	}
	return /** @type {string} */ (parent.type);
}

export function initDebug() {
	setupComponentStack();

	let hooksAllowed = false;

	/* eslint-disable no-console */
	let oldBeforeDiff = options._diff;
	let oldDiffed = options.diffed;
	let oldVnode = options.vnode;
	let oldRender = options._render;
	let oldCatchError = options._catchError;
	let oldRoot = options._root;
	let oldHook = options._hook;
	const warnedComponents = !isWeakMapSupported
		? null
		: {
				useEffect: new WeakMap(),
				useLayoutEffect: new WeakMap(),
				lazyPropTypes: new WeakMap()
			};
	const deprecations = [];

	options._catchError = (error, vnode, oldVNode, errorInfo) => {
		let component = vnode && vnode._component;
		if (component && typeof error.then == 'function') {
			const promise = error;
			error = new Error(
				`Missing Suspense. The throwing component was: ${getDisplayName(vnode)}`
			);

			let parent = vnode;
			for (; parent; parent = parent._parent) {
				if (parent._component && parent._component._childDidSuspend) {
					error = promise;
					break;
				}
			}

			// We haven't recovered and we know at this point that there is no
			// Suspense component higher up in the tree
			if (error instanceof Error) {
				throw error;
			}
		}

		try {
			errorInfo = errorInfo || {};
			errorInfo.componentStack = getOwnerStack(vnode);
			oldCatchError(error, vnode, oldVNode, errorInfo);

			// when an error was handled by an ErrorBoundary we will nonetheless emit an error
			// event on the window object. This is to make up for react compatibility in dev mode
			// and thus make the Next.js dev overlay work.
			if (typeof error.then != 'function') {
				setTimeout(() => {
					throw error;
				});
			}
		} catch (e) {
			throw e;
		}
	};

	options._root = (vnode, parentNode) => {
		if (!parentNode) {
			throw new Error(
				'Undefined parent passed to render(), this is the second argument.\n' +
					'Check if the element is available in the DOM/has the correct id.'
			);
		}

		let isValid;
		switch (parentNode.nodeType) {
			case ELEMENT_NODE:
			case DOCUMENT_FRAGMENT_NODE:
			case DOCUMENT_NODE:
				isValid = true;
				break;
			default:
				isValid = false;
		}

		if (!isValid) {
			let componentName = getDisplayName(vnode);
			throw new Error(
				`Expected a valid HTML node as a second argument to render.	Received ${parentNode} instead: render(<${componentName} />, ${parentNode});`
			);
		}

		if (oldRoot) oldRoot(vnode, parentNode);
	};

	options._diff = vnode => {
		let { type } = vnode;

		hooksAllowed = true;

		if (type === undefined) {
			throw new Error(
				'Undefined component passed to createElement()\n\n' +
					'You likely forgot to export your component or might have mixed up default and named imports' +
					serializeVNode(vnode) +
					`\n\n${getOwnerStack(vnode)}`
			);
		} else if (type != null && typeof type == 'object') {
			if (type._children !== undefined && type._dom !== undefined) {
				throw new Error(
					`Invalid type passed to createElement(): ${type}\n\n` +
						'Did you accidentally pass a JSX literal as JSX twice?\n\n' +
						`  let My${getDisplayName(vnode)} = ${serializeVNode(type)};\n` +
						`  let vnode = <My${getDisplayName(vnode)} />;\n\n` +
						'This usually happens when you export a JSX literal and not the component.' +
						`\n\n${getOwnerStack(vnode)}`
				);
			}

			throw new Error(
				'Invalid type passed to createElement(): ' +
					(Array.isArray(type) ? 'array' : type)
			);
		}

		if (
			vnode.ref !== undefined &&
			typeof vnode.ref != 'function' &&
			typeof vnode.ref != 'object' &&
			!('$$typeof' in vnode) // allow string refs when preact-compat is installed
		) {
			throw new Error(
				`Component's "ref" property should be a function, or an object created ` +
					`by createRef(), but got [${typeof vnode.ref}] instead\n` +
					serializeVNode(vnode) +
					`\n\n${getOwnerStack(vnode)}`
			);
		}

		if (typeof vnode.type == 'string') {
			for (const key in vnode.props) {
				if (
					key[0] === 'o' &&
					key[1] === 'n' &&
					typeof vnode.props[key] != 'function' &&
					vnode.props[key] != null
				) {
					throw new Error(
						`Component's "${key}" property should be a function, ` +
							`but got [${typeof vnode.props[key]}] instead\n` +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				}
			}
		}

		// Check prop-types if available
		if (typeof vnode.type == 'function' && vnode.type.propTypes) {
			if (
				vnode.type.displayName === 'Lazy' &&
				warnedComponents &&
				!warnedComponents.lazyPropTypes.has(vnode.type)
			) {
				const m =
					'PropTypes are not supported on lazy(). Use propTypes on the wrapped component itself. ';
				try {
					const lazyVNode = vnode.type();
					warnedComponents.lazyPropTypes.set(vnode.type, true);
					console.warn(
						m + `Component wrapped in lazy() is ${getDisplayName(lazyVNode)}`
					);
				} catch (promise) {
					console.warn(
						m + "We will log the wrapped component's name once it is loaded."
					);
				}
			}

			let values = vnode.props;
			if (vnode.type._forwarded) {
				values = assign({}, values);
				delete values.ref;
			}

			checkPropTypes(
				vnode.type.propTypes,
				values,
				'prop',
				getDisplayName(vnode),
				() => getOwnerStack(vnode)
			);
		}

		if (oldBeforeDiff) oldBeforeDiff(vnode);
	};

	let renderCount = 0;
	let currentComponent;
	options._render = vnode => {
		if (oldRender) {
			oldRender(vnode);
		}
		hooksAllowed = true;

		const nextComponent = vnode._component;
		if (nextComponent === currentComponent) {
			renderCount++;
		} else {
			renderCount = 1;
		}

		if (renderCount >= 25) {
			throw new Error(
				`Too many re-renders. This is limited to prevent an infinite loop ` +
					`which may lock up your browser. The component causing this is: ${getDisplayName(
						vnode
					)}`
			);
		}

		currentComponent = nextComponent;
	};

	options._hook = (comp, index, type) => {
		if (!comp || !hooksAllowed) {
			throw new Error('Hook can only be invoked from render methods.');
		}

		if (oldHook) oldHook(comp, index, type);
	};

	// Ideally we'd want to print a warning once per component, but we
	// don't have access to the vnode that triggered it here. As a
	// compromise and to avoid flooding the console with warnings we
	// print each deprecation warning only once.
	const warn = (property, message) => ({
		get() {
			const key = 'get' + property + message;
			if (deprecations && deprecations.indexOf(key) < 0) {
				deprecations.push(key);
				console.warn(`getting vnode.${property} is deprecated, ${message}`);
			}
		},
		set() {
			const key = 'set' + property + message;
			if (deprecations && deprecations.indexOf(key) < 0) {
				deprecations.push(key);
				console.warn(`setting vnode.${property} is not allowed, ${message}`);
			}
		}
	});

	const deprecatedAttributes = {
		nodeName: warn('nodeName', 'use vnode.type'),
		attributes: warn('attributes', 'use vnode.props'),
		children: warn('children', 'use vnode.props.children')
	};

	const deprecatedProto = Object.create({}, deprecatedAttributes);

	options.vnode = vnode => {
		const props = vnode.props;
		if (
			vnode.type !== null &&
			props != null &&
			('__source' in props || '__self' in props)
		) {
			const newProps = (vnode.props = {});
			for (let i in props) {
				const v = props[i];
				if (i === '__source') vnode.__source = v;
				else if (i === '__self') vnode.__self = v;
				else newProps[i] = v;
			}
		}

		// eslint-disable-next-line
		vnode.__proto__ = deprecatedProto;
		if (oldVnode) oldVnode(vnode);
	};

	options.diffed = vnode => {
		const { type, _parent: parent } = vnode;
		// Check if the user passed plain objects as children. Note that we cannot
		// move this check into `options.vnode` because components can receive
		// children in any shape they want (e.g.
		// `<MyJSONFormatter>{{ foo: 123, bar: "abc" }}</MyJSONFormatter>`).
		// Putting this check in `options.diffed` ensures that
		// `vnode._children` is set and that we only validate the children
		// that were actually rendered.
		if (vnode._children) {
			vnode._children.forEach(child => {
				if (typeof child === 'object' && child && child.type === undefined) {
					const keys = Object.keys(child).join(',');
					throw new Error(
						`Objects are not valid as a child. Encountered an object with the keys {${keys}}.` +
							`\n\n${getOwnerStack(vnode)}`
					);
				}
			});
		}

		if (vnode._component === currentComponent) {
			renderCount = 0;
		}

		if (
			typeof type === 'string' &&
			(isTableElement(type) ||
				type === 'p' ||
				type === 'a' ||
				type === 'button')
		) {
			// Avoid false positives when Preact only partially rendered the
			// HTML tree. Whilst we attempt to include the outer DOM in our
			// validation, this wouldn't work on the server for
			// `preact-render-to-string`. There we'd otherwise flood the terminal
			// with false positives, which we'd like to avoid.
			let domParentName = getClosestDomNodeParentName(parent);
			if (domParentName !== '' && isTableElement(type)) {
				if (
					type === 'table' &&
					// Tables can be nested inside each other if it's inside a cell.
					// See https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Advanced#nesting_tables
					domParentName !== 'td' &&
					isTableElement(domParentName)
				) {
					console.log(domParentName, parent._dom);
					console.error(
						'Improper nesting of table. Your <table> should not have a table-node parent.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				} else if (
					(type === 'thead' || type === 'tfoot' || type === 'tbody') &&
					domParentName !== 'table'
				) {
					console.error(
						'Improper nesting of table. Your <thead/tbody/tfoot> should have a <table> parent.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				} else if (
					type === 'tr' &&
					domParentName !== 'thead' &&
					domParentName !== 'tfoot' &&
					domParentName !== 'tbody'
				) {
					console.error(
						'Improper nesting of table. Your <tr> should have a <thead/tbody/tfoot> parent.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				} else if (type === 'td' && domParentName !== 'tr') {
					console.error(
						'Improper nesting of table. Your <td> should have a <tr> parent.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				} else if (type === 'th' && domParentName !== 'tr') {
					console.error(
						'Improper nesting of table. Your <th> should have a <tr>.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				}
			} else if (type === 'p') {
				let illegalDomChildrenTypes = getDomChildren(vnode).filter(childType =>
					ILLEGAL_PARAGRAPH_CHILD_ELEMENTS.test(childType)
				);
				if (illegalDomChildrenTypes.length) {
					console.error(
						'Improper nesting of paragraph. Your <p> should not have ' +
							illegalDomChildrenTypes.join(', ') +
							' as child-elements.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				}
			} else if (type === 'a' || type === 'button') {
				if (getDomChildren(vnode).indexOf(type) !== -1) {
					console.error(
						`Improper nesting of interactive content. Your <${type}>` +
							` should not have other ${type === 'a' ? 'anchor' : 'button'}` +
							' tags as child-elements.' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);
				}
			}
		}

		hooksAllowed = false;

		if (oldDiffed) oldDiffed(vnode);

		if (vnode._children != null) {
			const keys = [];
			for (let i = 0; i < vnode._children.length; i++) {
				const child = vnode._children[i];
				if (!child || child.key == null) continue;

				const key = child.key;
				if (keys.indexOf(key) !== -1) {
					console.error(
						'Following component has two or more children with the ' +
							`same key attribute: "${key}". This may cause glitches and misbehavior ` +
							'in rendering process. Component: \n\n' +
							serializeVNode(vnode) +
							`\n\n${getOwnerStack(vnode)}`
					);

					// Break early to not spam the console
					break;
				}

				keys.push(key);
			}
		}

		if (vnode._component != null && vnode._component.__hooks != null) {
			// Validate that none of the hooks in this component contain arguments that are NaN.
			// This is a common mistake that can be hard to debug, so we want to catch it early.
			const hooks = vnode._component.__hooks._list;
			if (hooks) {
				for (let i = 0; i < hooks.length; i += 1) {
					const hook = hooks[i];
					if (hook._args) {
						for (let j = 0; j < hook._args.length; j++) {
							const arg = hook._args[j];
							if (isNaN(arg)) {
								const componentName = getDisplayName(vnode);
								console.warn(
									`Invalid argument passed to hook. Hooks should not be called with NaN in the dependency array. Hook index ${i} in component ${componentName} was called with NaN.`
								);
							}
						}
					}
				}
			}
		}
	};
}

const setState = Component.prototype.setState;
Component.prototype.setState = function (update, callback) {
	if (this._vnode == null) {
		// `this._vnode` will be `null` during componentWillMount. But it
		// is perfectly valid to call `setState` during cWM. So we
		// need an additional check to verify that we are dealing with a
		// call inside constructor.
		if (this.state == null) {
			console.warn(
				`Calling "this.setState" inside the constructor of a component is a ` +
					`no-op and might be a bug in your application. Instead, set ` +
					`"this.state = {}" directly.\n\n${getOwnerStack(getCurrentVNode())}`
			);
		}
	}

	return setState.call(this, update, callback);
};

function isTableElement(type) {
	return (
		type === 'table' ||
		type === 'tfoot' ||
		type === 'tbody' ||
		type === 'thead' ||
		type === 'td' ||
		type === 'tr' ||
		type === 'th'
	);
}

const ILLEGAL_PARAGRAPH_CHILD_ELEMENTS =
	/^(address|article|aside|blockquote|details|div|dl|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|main|menu|nav|ol|p|pre|search|section|table|ul)$/;

const forceUpdate = Component.prototype.forceUpdate;
Component.prototype.forceUpdate = function (callback) {
	if (this._vnode == null) {
		console.warn(
			`Calling "this.forceUpdate" inside the constructor of a component is a ` +
				`no-op and might be a bug in your application.\n\n${getOwnerStack(
					getCurrentVNode()
				)}`
		);
	} else if (this._parentDom == null) {
		console.warn(
			`Can't call "this.forceUpdate" on an unmounted component. This is a no-op, ` +
				`but it indicates a memory leak in your application. To fix, cancel all ` +
				`subscriptions and asynchronous tasks in the componentWillUnmount method.` +
				`\n\n${getOwnerStack(this._vnode)}`
		);
	}
	return forceUpdate.call(this, callback);
};

/**
 * Serialize a vnode tree to a string
 * @param {import('./internal').VNode} vnode
 * @returns {string}
 */
export function serializeVNode(vnode) {
	let { props } = vnode;
	let name = getDisplayName(vnode);

	let attrs = '';
	for (let prop in props) {
		if (props.hasOwnProperty(prop) && prop !== 'children') {
			let value = props[prop];

			// If it is an object but doesn't have toString(), use Object.toString
			if (typeof value == 'function') {
				value = `function ${value.displayName || value.name}() {}`;
			}

			value =
				Object(value) === value && !value.toString
					? Object.prototype.toString.call(value)
					: value + '';

			attrs += ` ${prop}=${JSON.stringify(value)}`;
		}
	}

	let children = props.children;
	return `<${name}${attrs}${
		children && children.length ? '>..</' + name + '>' : ' />'
	}`;
}

options._hydrationMismatch = (newVNode, excessDomChildren) => {
	const { type } = newVNode;
	const availableTypes = excessDomChildren
		.map(child => child && child.localName)
		.filter(Boolean);
	console.error(
		`Expected a DOM node of type "${type}" but found "${availableTypes.join(', ')}" as available DOM-node(s), this is caused by the SSR'd HTML containing different DOM-nodes compared to the hydrated one.\n\n${getOwnerStack(newVNode)}`
	);
};
