'use strict';
/* global $:off */

/**
 * @constant
 */
const NEW_TOPIC_COMMENT_ID = 'new|' + mw.config.get( 'wgRelevantPageName' );

/**
 * @param {Node} node
 * @return {boolean} Node is a block element
 */
function isBlockElement( node ) {
	return node instanceof HTMLElement && ve.isBlockElement( node );
}

const solTransparentLinkRegexp = /(?:^|\s)mw:PageProp\/(?:Category|redirect|Language)(?=$|\s)/;

/**
 * @param {Node} node
 * @return {boolean} Node is considered a rendering-transparent node in Parsoid
 */
function isRenderingTransparentNode( node ) {
	const nextSibling = node.nextSibling;
	return (
		node.nodeType === Node.COMMENT_NODE ||
		node.nodeType === Node.ELEMENT_NODE && (
			node.tagName.toLowerCase() === 'meta' ||
			(
				node.tagName.toLowerCase() === 'link' &&
				solTransparentLinkRegexp.test( node.getAttribute( 'rel' ) || '' )
			) ||
			// Empty inline templates, e.g. tracking templates
			(
				node.tagName.toLowerCase() === 'span' &&
				( node.getAttribute( 'typeof' ) || '' ).split( ' ' ).includes( 'mw:Transclusion' ) &&
				!htmlTrim( node.innerHTML ) &&
				(
					!nextSibling || nextSibling.nodeType !== Node.ELEMENT_NODE ||
					// Maybe we should be checking all of the about-grouped nodes to see if they're empty,
					// but that's prooobably not needed in practice, and it leads to a quadratic worst case.
					nextSibling.getAttribute( 'about' ) !== node.getAttribute( 'about' )
				)
			)
		)
	);
}

/**
 * @param {Node} node
 * @return {boolean} Node was added to the page by DiscussionTools
 */
function isOurGeneratedNode( node ) {
	return node.nodeType === Node.ELEMENT_NODE && (
		node.classList.contains( 'ext-discussiontools-init-replylink-buttons' ) ||
		node.hasAttribute( 'data-mw-comment-start' ) ||
		node.hasAttribute( 'data-mw-comment-end' )
	);
}

// Elements which can't have element children (but some may have text content).
const noElementChildrenElementTypes = [
	// https://html.spec.whatwg.org/multipage/syntax.html#elements-2
	// Void elements
	'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
	'link', 'meta', 'param', 'source', 'track', 'wbr',
	// Raw text elements
	'script', 'style',
	// Escapable raw text elements
	'textarea', 'title',
	// Foreign elements
	'math', 'svg',
	// Treated like text when scripting is enabled in the parser
	// https://html.spec.whatwg.org/#the-noscript-element
	'noscript',
	// Replaced elements (that aren't already included above)
	// https://html.spec.whatwg.org/multipage/rendering.html#replaced-elements
	// They might allow element children, but they aren't rendered on the page.
	'audio', 'canvas', 'iframe', 'object', 'video'
];

/**
 * @param {Node} node
 * @return {boolean} If true, node can't have element children. If false, it's complicated.
 */
function cantHaveElementChildren( node ) {
	return (
		node.nodeType === Node.COMMENT_NODE ||
		node.nodeType === Node.ELEMENT_NODE && (
			noElementChildrenElementTypes.includes( node.tagName.toLowerCase() ) ||
			// Thumbnail wrappers generated by MediaTransformOutput::linkWrap (T301427),
			// for compatibility with TimedMediaHandler.
			// There is no better way to detect them, and we can't insert markers here,
			// because the media DOM CSS depends on specific tag names and their order :(
			// TODO See if we can remove this condition when wgParserEnableLegacyMediaDOM=false
			// is enabled everywhere.
			(
				[ 'a', 'span' ].includes( node.tagName.toLowerCase() ) &&
				node.firstChild &&
				cantHaveElementChildren( node.firstChild )
			) ||
			// Do not insert anything inside figures when using wgParserEnableLegacyMediaDOM=false,
			// because their CSS can't handle it (T320285).
			node.tagName.toLowerCase() === 'figure'
		)
	);
}

/**
 * Check whether the node is a comment separator (instead of a part of the comment).
 *
 * @param {Node} node
 * @return {boolean}
 */
function isCommentSeparator( node ) {
	if ( node.nodeType !== Node.ELEMENT_NODE ) {
		return false;
	}

	const tagName = node.tagName.toLowerCase();
	if ( tagName === 'br' || tagName === 'hr' ) {
		return true;
	}

	// TemplateStyles followed by any of the others
	if ( node.nextSibling &&
		( tagName === 'link' || tagName === 'style' ) &&
		isCommentSeparator( node.nextSibling )
	) {
		return true;
	}

	const classList = node.classList;
	if (
		// Anything marked as not containing comments
		classList.contains( 'mw-notalk' ) ||
		// {{outdent}} templates
		classList.contains( 'outdent-template' ) ||
		// {{tracked}} templates (T313097)
		classList.contains( 'mw-trackedTemplate' )
	) {
		return true;
	}

	// Wikitext definition list term markup (`;`) when used as a fake heading (T265964)
	if ( tagName === 'dl' &&
		node.childNodes.length === 1 &&
		node.firstChild.nodeType === Node.ELEMENT_NODE &&
		node.firstChild.nodeName.toLowerCase() === 'dt'
	) {
		return true;
	}

	return false;
}

/**
 * Check whether the node is a comment content. It's a little vague what this means…
 *
 * @param {Node} node Node, should be a leaf node (a node with no children)
 * @return {boolean}
 */
function isCommentContent( node ) {
	return (
		( node.nodeType === Node.TEXT_NODE && htmlTrim( node.textContent ) !== '' ) ||
		( cantHaveElementChildren( node ) )
	);
}

/**
 * Get the index of a node in its parentNode's childNode list
 *
 * @param {Node} child
 * @return {number} Index in parentNode's childNode list
 */
function childIndexOf( child ) {
	let i = 0;
	while ( ( child = child.previousSibling ) ) {
		i++;
	}
	return i;
}

/**
 * Find closest ancestor element using one of the given tag names.
 *
 * @param {Node} node
 * @param {string[]} tagNames
 * @return {HTMLElement|null}
 */
function closestElement( node, tagNames ) {
	do {
		if (
			node.nodeType === Node.ELEMENT_NODE &&
			tagNames.includes( node.tagName.toLowerCase() )
		) {
			return node;
		}
		node = node.parentNode;
	} while ( node );
	return null;
}

/**
 * Find the transclusion node which rendered the current node, if it exists.
 *
 * 1. Find the closest ancestor with an 'about' attribute
 * 2. Find the main node of the about-group (first sibling with the same 'about' attribute)
 * 3. If this is an mw:Transclusion node, return it; otherwise, go to step 1
 *
 * @param {Node} node
 * @return {HTMLElement|null} Transclusion node, null if not found
 */
function getTranscludedFromElement( node ) {
	while ( node ) {
		// 1.
		if (
			node.nodeType === Node.ELEMENT_NODE &&
			node.getAttribute( 'about' ) &&
			/^#mwt\d+$/.test( node.getAttribute( 'about' ) )
		) {
			const about = node.getAttribute( 'about' );

			// 2.
			while (
				node.previousSibling &&
				node.previousSibling.nodeType === Node.ELEMENT_NODE &&
				node.previousSibling.getAttribute( 'about' ) === about
			) {
				node = node.previousSibling;
			}

			// 3.
			if (
				node.getAttribute( 'typeof' ) &&
				node.getAttribute( 'typeof' ).split( ' ' ).includes( 'mw:Transclusion' )
			) {
				break;
			}
		}

		node = node.parentNode;
	}
	return node;
}

/**
 * Given a heading node, return the node on which the ID attribute is set.
 *
 * @param {HTMLElement} heading Heading node (`<h1>`-`<h6>`)
 * @return {HTMLElement}
 */
function getHeadlineNode( heading ) {
	// This code assumes that $wgFragmentMode is [ 'html5', 'legacy' ] or [ 'html5' ]
	let headline = heading;

	if ( headline.hasAttribute( 'data-mw-comment-start' ) ) {
		// JS only: Support output from the PHP CommentFormatter
		headline = headline.parentNode;
	}

	if ( !headline.getAttribute( 'id' ) ) {
		// JS only: Support output after HandleSectionLinks OutputTransform has been applied
		headline = headline.querySelector( '.mw-headline' );
		if ( !headline ) {
			headline = heading;
		}
	}

	return headline;
}

/**
 * Trim ASCII whitespace, as defined in the HTML spec.
 *
 * @param {string} str
 * @return {string}
 */
function htmlTrim( str ) {
	// https://infra.spec.whatwg.org/#ascii-whitespace
	return str.replace( /^[\t\n\f\r ]+/, '' ).replace( /[\t\n\f\r ]+$/, '' );
}

/**
 * Get the indent level of the node, relative to rootNode.
 *
 * The indent level is the number of lists inside of which it is nested.
 *
 * @private
 * @param {Node} node
 * @param {Element} rootNode
 * @return {number}
 */
function getIndentLevel( node, rootNode ) {
	let indent = 0;
	while ( node ) {
		if ( node === rootNode ) {
			break;
		}
		const tagName = node instanceof HTMLElement ? node.tagName.toLowerCase() : null;
		if ( tagName === 'li' || tagName === 'dd' ) {
			indent++;
		}
		node = node.parentNode;
	}
	return indent;
}

/**
 * Get an array of sibling nodes that contain parts of the given range.
 *
 * @param {Range} range
 * @return {Node[]}
 */
function getCoveredSiblings( range ) {
	const ancestor = range.commonAncestorContainer;

	const siblings = ancestor.childNodes;
	let start = 0;
	let end = siblings.length - 1;

	// Find first of the siblings that contains the item
	if ( ancestor === range.startContainer ) {
		start = range.startOffset;
	} else {
		while ( !siblings[ start ].contains( range.startContainer ) ) {
			start++;
		}
	}

	// Find last of the siblings that contains the item
	if ( ancestor === range.endContainer ) {
		end = range.endOffset - 1;
	} else {
		while ( !siblings[ end ].contains( range.endContainer ) ) {
			end--;
		}
	}

	return Array.prototype.slice.call( siblings, start, end + 1 );
}

/**
 * Get the nodes (if any) that contain the given thread item, and nothing else.
 *
 * @param {ThreadItem} item Thread item
 * @param {Node} [excludedAncestorNode] Node that shouldn't be included in the result, even if it
 *     contains the item and nothing else. This is intended to avoid traversing outside of a node
 *     which is a container for all the thread items.
 * @return {Node[]|null}
 */
function getFullyCoveredSiblings( item, excludedAncestorNode ) {
	let siblings = getCoveredSiblings( item.getRange() );

	function makeRange( sibs ) {
		const range = sibs[ 0 ].ownerDocument.createRange();
		range.setStartBefore( sibs[ 0 ] );
		range.setEndAfter( sibs[ sibs.length - 1 ] );
		return range;
	}

	const matches = compareRanges( makeRange( siblings ), item.getRange() ) === 'equal';

	if ( matches ) {
		// If these are all of the children (or the only child), go up one more level
		let parent;
		while (
			( parent = siblings[ 0 ].parentNode ) &&
			parent !== excludedAncestorNode &&
			compareRanges( makeRange( [ parent ] ), item.getRange() ) === 'equal'
		) {
			siblings = [ parent ];
		}
		return siblings;
	}
	return null;
}

/**
 * Get a MediaWiki page title from a URL.
 *
 * @private
 * @param {string} url Absolute URL
 * @return {string|null} Page title, or null if this isn't a link to a page
 */
function getTitleFromUrl( url ) {
	if ( !url ) {
		return null;
	}
	const parsedUrl = new URL( url );
	if ( parsedUrl.searchParams.get( 'title' ) ) {
		return parsedUrl.searchParams.get( 'title' );
	}

	// wgArticlePath is site config so is trusted
	const articlePathRegexp = new RegExp(
		mw.util.escapeRegExp( mw.config.get( 'wgArticlePath' ) )
			.replace( '\\$1', '(.*)' )
	);
	let match;
	if ( ( match = parsedUrl.pathname.match( articlePathRegexp ) ) ) {
		return decodeURIComponent( match[ 1 ] );
	}

	return null;
}

/**
 * Traverse the document in depth-first order, calling the callback whenever entering and leaving
 * a node. The walk starts before the given node and ends when callback returns a truthy value, or
 * after reaching the end of the document.
 *
 * You might also think about this as processing XML token stream linearly (rather than XML
 * nodes), as if we were parsing the document.
 *
 * @param {Node} node Node to start at
 * @param {Function} callback Function accepting two arguments: `event` ('enter' or 'leave') and
 *     `node` (DOMNode)
 * @return {any} Final return value of the callback
 */
function linearWalk( node, callback ) {
	let
		result = null,
		withinNode = node.parentNode,
		beforeNode = node;

	while ( beforeNode || withinNode ) {
		if ( beforeNode ) {
			result = callback( 'enter', beforeNode );
			withinNode = beforeNode;
			beforeNode = beforeNode.firstChild;
		} else {
			result = callback( 'leave', withinNode );
			beforeNode = withinNode.nextSibling;
			withinNode = withinNode.parentNode;
		}

		if ( result ) {
			return result;
		}
	}
	return result;
}

/**
 * Like #linearWalk, but it goes backwards.
 *
 * @inheritdoc #linearWalk
 */
function linearWalkBackwards( node, callback ) {
	let
		result = null,
		withinNode = node.parentNode,
		beforeNode = node;

	while ( beforeNode || withinNode ) {
		if ( beforeNode ) {
			result = callback( 'enter', beforeNode );
			withinNode = beforeNode;
			beforeNode = beforeNode.lastChild;
		} else {
			result = callback( 'leave', withinNode );
			beforeNode = withinNode.previousSibling;
			withinNode = withinNode.parentNode;
		}

		if ( result ) {
			return result;
		}
	}
	return result;
}

/**
 * @param {Range} range
 * @return {Node}
 */
function getRangeFirstNode( range ) {
	return range.startContainer.childNodes.length ?
		range.startContainer.childNodes[ range.startOffset ] :
		range.startContainer;
}

/**
 * @param {Range} range
 * @return {Node}
 */
function getRangeLastNode( range ) {
	return range.endContainer.childNodes.length ?
		range.endContainer.childNodes[ range.endOffset - 1 ] :
		range.endContainer;
}

/**
 * Check whether two ranges overlap, and how.
 *
 * Includes a hack to check for "almost equal" ranges (whose start/end boundaries only differ by
 * "uninteresting" nodes that we ignore when detecting comments), and treat them as equal.
 *
 * Illustration of return values:
 *          [    equal    ]
 *          |[ contained ]|
 *        [ |  contains   | ]
 *  [overlap|start]       |
 *          |     [overlap|end]
 * [before] |             |
 *          |             | [after]
 *
 * @param {Range} a
 * @param {Range} b
 * @return {string} One of:
 *     - 'equal': Ranges A and B are equal
 *     - 'contains': Range A contains range B
 *     - 'contained': Range A is contained within range B
 *     - 'after': Range A is before range B
 *     - 'before': Range A is after range B
 *     - 'overlapstart': Start of range A overlaps range B
 *     - 'overlapend': End of range A overlaps range B
 */
function compareRanges( a, b ) {
	// Compare the positions of: start of A to start of B, start of A to end of B, and so on.
	// Watch out, the constant names are the opposite of what they should be.
	let startToStart = a.compareBoundaryPoints( Range.START_TO_START, b );
	const startToEnd = a.compareBoundaryPoints( Range.END_TO_START, b );
	const endToStart = a.compareBoundaryPoints( Range.START_TO_END, b );
	let endToEnd = a.compareBoundaryPoints( Range.END_TO_END, b );

	// Check for almost equal ranges (boundary points only differing by uninteresting nodes)
	if (
		( startToStart < 0 && compareRangesAlmostEqualBoundaries( a, b, 'start' ) ) ||
		( startToStart > 0 && compareRangesAlmostEqualBoundaries( b, a, 'start' ) )
	) {
		startToStart = 0;
	}
	if (
		( endToEnd < 0 && compareRangesAlmostEqualBoundaries( a, b, 'end' ) ) ||
		( endToEnd > 0 && compareRangesAlmostEqualBoundaries( b, a, 'end' ) )
	) {
		endToEnd = 0;
	}

	if ( startToStart === 0 && endToEnd === 0 ) {
		return 'equal';
	}
	if ( startToStart <= 0 && endToEnd >= 0 ) {
		return 'contains';
	}
	if ( startToStart >= 0 && endToEnd <= 0 ) {
		return 'contained';
	}
	if ( startToEnd >= 0 ) {
		return 'after';
	}
	if ( endToStart <= 0 ) {
		return 'before';
	}
	if ( startToStart > 0 && startToEnd < 0 && endToEnd >= 0 ) {
		return 'overlapstart';
	}
	if ( endToEnd < 0 && endToStart > 0 && startToStart <= 0 ) {
		return 'overlapend';
	}

	throw new Error( 'Unreachable' );
}

/**
 * Check if the given boundary points of ranges A and B are almost equal (only differing by
 * uninteresting nodes).
 *
 * Boundary of A must be before the boundary of B in the tree.
 *
 * @param {Range} a
 * @param {Range} b
 * @param {string} boundary 'start' or 'end'
 * @return {boolean}
 */
function compareRangesAlmostEqualBoundaries( a, b, boundary ) {
	// This code is awful, but several attempts to rewrite it made it even worse.
	// You're welcome to give it a try.

	const from = boundary === 'end' ? getRangeLastNode( a ) : getRangeFirstNode( a );
	const to = boundary === 'end' ? getRangeLastNode( b ) : getRangeFirstNode( b );

	let skipNode = null;
	if ( boundary === 'end' ) {
		skipNode = from;
	}

	let foundContent = false;
	linearWalk(
		from,
		( event, n ) => {
			if ( n === to && event === ( boundary === 'end' ? 'leave' : 'enter' ) ) {
				return true;
			}
			if ( skipNode ) {
				if ( n === skipNode && event === 'leave' ) {
					skipNode = null;
				}
				return;
			}

			if ( event === 'enter' ) {
				if (
					isCommentSeparator( n ) ||
					isRenderingTransparentNode( n ) ||
					isOurGeneratedNode( n )
				) {
					skipNode = n;

				} else if (
					isCommentContent( n )
				) {
					foundContent = true;
					return true;
				}
			}
		}
	);

	return !foundContent;
}

/**
 * Get the ID for a new topics subscription from a page title
 *
 * @param {mw.Title} title Page title
 * @return {string} ID for a new topics subscription
 */
function getNewTopicsSubscriptionId( title ) {
	return 'p-topics-' + title.getNamespaceId() + ':' + title.getMain();
}

module.exports = {
	NEW_TOPIC_COMMENT_ID: NEW_TOPIC_COMMENT_ID,
	isBlockElement: isBlockElement,
	isRenderingTransparentNode: isRenderingTransparentNode,
	isCommentSeparator: isCommentSeparator,
	isCommentContent: isCommentContent,
	cantHaveElementChildren: cantHaveElementChildren,
	childIndexOf: childIndexOf,
	closestElement: closestElement,
	getIndentLevel: getIndentLevel,
	getCoveredSiblings: getCoveredSiblings,
	getFullyCoveredSiblings: getFullyCoveredSiblings,
	getTranscludedFromElement: getTranscludedFromElement,
	getHeadlineNode: getHeadlineNode,
	htmlTrim: htmlTrim,
	getTitleFromUrl: getTitleFromUrl,
	linearWalk: linearWalk,
	linearWalkBackwards: linearWalkBackwards,
	compareRanges: compareRanges,
	getNewTopicsSubscriptionId: getNewTopicsSubscriptionId
};
