/**
 * NOTE:
 * this `eslint` rule is disabled because of calling `getStyleSheetFactory` before it is defined (further below).
 */
/* eslint no-use-before-define: 0 */

/**
 * Given a rootNode - construct CSSOM
 * -> get all source nodes (document & document fragments) within given root node
 * -> recursively call `axe.utils.parseStylesheets` to resolve styles for each node
 *
 * @method preloadCssom
 * @memberof `axe.utils`
 * @param {Object} options composite options object
 * @property {Array<String>} options.assets array of preloaded assets requested, eg: [`cssom`]
 * @property {Number} options.timeout timeout
 * @property {Object} options.treeRoot (optional) the DOM tree to be inspected
 * @returns {Promise}
 */
axe.utils.preloadCssom = function preloadCssom({ treeRoot = axe._tree[0] }) {
	/**
	 * get all `document` and `documentFragment` with in given `tree`
	 */
	const rootNodes = getAllRootNodesInTree(treeRoot);

	if (!rootNodes.length) {
		return Promise.resolve();
	}

	const dynamicDoc = document.implementation.createHTMLDocument(
		'Dynamic document for loading cssom'
	);

	const convertDataToStylesheet = axe.utils.getStyleSheetFactory(dynamicDoc);

	return getCssomForAllRootNodes(
		rootNodes,
		convertDataToStylesheet
	).then(assets => flattenAssets(assets));
};

/**
 * Returns am array of source nodes containing `document` and `documentFragment` in a given `tree`.
 *
 * @param {Object} treeRoot tree
 * @returns {Array<Object>} array of objects, which each object containing a root and an optional `shadowId`
 */
function getAllRootNodesInTree(tree) {
	let ids = [];

	const rootNodes = axe.utils
		.querySelectorAllFilter(tree, '*', node => {
			if (ids.includes(node.shadowId)) {
				return false;
			}
			ids.push(node.shadowId);
			return true;
		})
		.map(node => {
			return {
				shadowId: node.shadowId,
				rootNode: axe.utils.getRootNode(node.actualNode)
			};
		});

	return axe.utils.uniqueArray(rootNodes, []);
}

/**
 * Process CSSOM on all root nodes
 *
 * @param {Array<Object>} rootNodes array of root nodes, where node  is an enhanced `document` or `documentFragment` object returned from `getAllRootNodesInTree`
 * @param {Function} convertDataToStylesheet fn to convert given data to Stylesheet object
 * @returns {Promise}
 */
function getCssomForAllRootNodes(rootNodes, convertDataToStylesheet) {
	const promises = [];

	rootNodes.forEach(({ rootNode, shadowId }, index) => {
		const sheets = getStylesheetsOfRootNode(
			rootNode,
			shadowId,
			convertDataToStylesheet
		);
		if (!sheets) {
			return Promise.all(promises);
		}

		const rootIndex = index + 1;
		const parseOptions = {
			rootNode,
			shadowId,
			convertDataToStylesheet,
			rootIndex
		};
		/**
		 * Note:
		 * `importedUrls` - keeps urls of already imported stylesheets, to prevent re-fetching
		 * eg: nested, cyclic or cross referenced `@import` urls
		 */
		const importedUrls = [];

		const p = Promise.all(
			sheets.map((sheet, sheetIndex) => {
				const priority = [rootIndex, sheetIndex];

				return axe.utils.parseStylesheet(
					sheet,
					parseOptions,
					priority,
					importedUrls
				);
			})
		);

		promises.push(p);
	});

	return Promise.all(promises);
}

/**
 * Flatten CSSOM assets
 *
 * @param {Array.<Object[]>} assets nested assets (varying depth)
 * @returns {Array<Object>} Array of CSSOM object
 */
function flattenAssets(assets) {
	return assets.reduce(
		(acc, val) =>
			Array.isArray(val) ? acc.concat(flattenAssets(val)) : acc.concat(val),
		[]
	);
}

/**
 * Get stylesheet(s) for root
 *
 * @param {Object} options.rootNode `document` or `documentFragment`
 * @param {String} options.shadowId an id if undefined denotes that given root is a document fragment/ shadowDOM
 * @param {Function} options.convertDataToStylesheet a utility function to generate a style sheet from given data (text)
 * @returns {Array<Object>} an array of stylesheets
 */
function getStylesheetsOfRootNode(rootNode, shadowId, convertDataToStylesheet) {
	let sheets;

	// nodeType === 11  -> DOCUMENT_FRAGMENT
	if (rootNode.nodeType === 11 && shadowId) {
		sheets = getStylesheetsFromDocumentFragment(
			rootNode,
			convertDataToStylesheet
		);
	} else {
		sheets = getStylesheetsFromDocument(rootNode);
	}

	return filterStylesheetsWithSameHref(sheets);
}

/**
 * Get stylesheets from `documentFragment`
 *
 * @property {Object} options.rootNode `documentFragment`
 * @property {Function} options.convertDataToStylesheet a utility function to generate a stylesheet from given data
 * @returns {Array<Object>}
 */
function getStylesheetsFromDocumentFragment(rootNode, convertDataToStylesheet) {
	return (
		Array.from(rootNode.children)
			.filter(filerStyleAndLinkAttributesInDocumentFragment)
			// Reducer to convert `<style></style>` and `<link>` references to `CSSStyleSheet` object
			.reduce((out, node) => {
				const nodeName = node.nodeName.toUpperCase();
				const data = nodeName === 'STYLE' ? node.textContent : node;
				const isLink = nodeName === 'LINK';
				const stylesheet = convertDataToStylesheet({
					data,
					isLink,
					root: rootNode
				});
				out.push(stylesheet.sheet);
				return out;
			}, [])
	);
}

/**
 * Get stylesheets from `document`
 * -> filter out stylesheet that are `media=print`
 *
 * @param {Object} rootNode `document`
 * @returns {Array<Object>}
 */
function getStylesheetsFromDocument(rootNode) {
	return Array.from(rootNode.styleSheets).filter(sheet =>
		filterMediaIsPrint(sheet.media.mediaText)
	);
}

/**
 * Get all `<style></style>` and `<link>` attributes
 * -> limit to only `style` or `link` attributes with `rel=stylesheet` and `media != print`
 *
 * @param {Object} node HTMLElement
 * @returns {Boolean}
 */
function filerStyleAndLinkAttributesInDocumentFragment(node) {
	const nodeName = node.nodeName.toUpperCase();
	const linkHref = node.getAttribute('href');
	const linkRel = node.getAttribute('rel');
	const isLink =
		nodeName === 'LINK' &&
		linkHref &&
		linkRel &&
		node.rel.toUpperCase().includes('STYLESHEET');
	const isStyle = nodeName === 'STYLE';
	return isStyle || (isLink && filterMediaIsPrint(node.media));
}

/**
 * Exclude `link[rel='stylesheet]` attributes where `media=print`
 *
 * @param {String} media media value eg: 'print'
 * @returns {Boolean}
 */
function filterMediaIsPrint(media) {
	if (!media) {
		return true;
	}
	return !media.toUpperCase().includes('PRINT');
}

/**
 * Exclude any duplicate `stylesheets`, that share the same `href`
 *
 * @param {Array<Object>} sheets stylesheets
 * @returns {Array<Object>}
 */
function filterStylesheetsWithSameHref(sheets) {
	let hrefs = [];
	return sheets.filter(sheet => {
		if (!sheet.href) {
			// include sheets without `href`
			return true;
		}
		// if `href` is present, ensure they are not duplicates
		if (hrefs.includes(sheet.href)) {
			return false;
		}
		hrefs.push(sheet.href);
		return true;
	});
}
