/**
 * Template encapsulation happens in three steps.
 *
 * 1. findWrappableTemplateRanges
 *
 *    Locate start and end metas. Walk upwards towards the root from both and
 *    find a common ancestor A. The subtree rooted at A is now effectively the
 *    scope of the dom template ouput.
 *
 * 2. findTopLevelNonOverlappingRanges
 *
 *    Mark all nodes in a range and walk up to root from each range start to
 *    determine overlaps, nesting. Merge overlapping and nested ranges to find
 *    the subset of top-level non-overlapping ranges which will be wrapped as
 *    individual units.
 *
 *    range.startElem, range.endElem are the start/end meta tags for a transclusion
 *    range.start, range.end are the start/end DOM nodes after the range is
 *    expanded, merged with other ranges, etc. In the simple cases, they will
 *    be identical to startElem, endElem.
 *
 * 3. encapsulateTemplates
 *
 *    For each non-overlapping range,
 *    - compute a data-mw according to the DOM spec
 *    - replace the start / end meta markers with transclusion type and data-mw
 *      on the first DOM node
 *    - add about ids on all top-level nodes of the range
 *
 * This is a simple high-level overview of the 3 steps to help understand this
 * code.
 *
 * FIXME: At some point, more of the details should be extracted and documented
 * in pseudo-code as an algorithm.
 * @module
 */

'use strict';

const { DOMDataUtils } = require('../../../utils/DOMDataUtils.js');
const { DOMUtils } = require('../../../utils/DOMUtils.js');
const { JSUtils } = require('../../../utils/jsutils.js');
const { Util } = require('../../../utils/Util.js');
const { WTUtils } = require('../../../utils/WTUtils.js');

var arrayMap = JSUtils.arrayMap;
var lastItem = JSUtils.lastItem;

class WrapTemplates {
	expandRangeToAvoidSpanWrapping(range, startsWithText) {
		// SSS FIXME: Later on, if safe, we could consider expanding the
		// range unconditionally rather than only if a span is required.

		var mightAddSpan = startsWithText;
		if (startsWithText === undefined) {
			var n = range.start;
			if (WTUtils.isTplMarkerMeta(n)) {
				n = n.nextSibling;
			}
			mightAddSpan = DOMUtils.isText(n);
		}

		var expandable = false;
		if (mightAddSpan) {
			// See if we can expand the range to the parent node.
			// Eliminates useless spanning of wikitext of the form: {{echo|foo}}
			// where the the entire template content is contained in a paragraph.
			var contentParent = range.start.parentNode;
			expandable = true
				&& contentParent.nodeName === 'P'
				&& !WTUtils.isLiteralHTMLNode(contentParent)
				&& contentParent.firstChild === range.startElem
				&& contentParent.lastChild === range.endElem
				&& contentParent === range.end.parentNode;

			if (expandable) {
				range.start = contentParent;
				range.end = contentParent;
			}
		}

		return expandable;
	}

	updateDSRForFirstTplNode(target, source) {
		var srcDP = DOMDataUtils.getDataParsoid(source);
		var tgtDP = DOMDataUtils.getDataParsoid(target);

		// Since TSRs on template content tokens are cleared by the
		// template handler, all computed dsr values for template content
		// is always inferred from top-level content values and is safe.
		// So, do not overwrite a bigger end-dsr value.
		if (srcDP.dsr && (tgtDP.dsr && tgtDP.dsr[1] > srcDP.dsr[1])) {
			tgtDP.dsr[0] = srcDP.dsr[0];
		} else {
			tgtDP.dsr = Util.clone(srcDP.dsr);
			tgtDP.src = srcDP.src;
		}
	}

	getRangeEndDSR(range) {
		var endNode = range.end;
		if (DOMUtils.isElt(endNode)) {
			return (DOMDataUtils.getDataParsoid(endNode) || {}).dsr;
		} else {
			// In the rare scenario where the last element of a range is not an ELEMENT,
			// extrapolate based on DSR of first leftmost sibling that is an ELEMENT.
			// We don't try any harder than this for now.
			var offset = 0;
			var n = endNode.previousSibling;
			while (n && !DOMUtils.isElt(n)) {
				if (DOMUtils.isText(n)) {
					offset += n.data.length;
				} else {
					// A comment
					offset += WTUtils.decodedCommentLength(n);
				}
				n = n.previousSibling;
			}

			var dsr = null;
			if (n) {
				dsr = (DOMDataUtils.getDataParsoid(n) || {}).dsr;
			}

			if (dsr && typeof (dsr[1]) === 'number') {
				var len = DOMUtils.isText(endNode) ? endNode.data.length :
					WTUtils.decodedCommentLength(endNode);
				dsr = [dsr[1] + offset, dsr[1] + offset + len];
			}

			return dsr;
		}
	}

	/**
	 * Find the common DOM ancestor of two DOM nodes.
	 * @private
	 */
	getDOMRange(frame, doc, startElem, endMeta, endElem) {
		const env = frame.env;
		var range = {
			startElem: startElem,
			endElem: endMeta,
			id: Util.stripParsoidIdPrefix(startElem.getAttribute("about") || ''),
			startOffset: DOMDataUtils.getDataParsoid(startElem).tsr[0],
			flipped: false,
		};

		// Find common ancestor of startElem and endElem
		var startAncestors = arrayMap(DOMUtils.pathToRoot(startElem));
		var elem = endElem;
		var parentNode = endElem.parentNode;
		while (parentNode && parentNode.nodeType !== doc.DOCUMENT_NODE) {
			var i = startAncestors.get(parentNode);
			if (i === 0) {
				// widen the scope to include the full subtree
				range.root = startElem;
				range.start = startElem.firstChild;
				range.end = startElem.lastChild;
				break;
			} else if (i > 0) {
				range.root = parentNode;
				range.start = startAncestors.item(i - 1);
				range.end = elem;
				break;
			}
			elem = parentNode;
			parentNode = elem.parentNode;
		}

		// Detect empty content in unfosterable positions and
		// wrap them in spans.
		if (startElem.nodeName === "META"
			&& startElem.nextSibling === endElem
			&& !DOMUtils.isFosterablePosition(startElem)) {
			var emptySpan = doc.createElement('span');
			startElem.parentNode.insertBefore(emptySpan, endElem);
		}

		// Handle unwrappable content in fosterable positions
		// and expand template range, if required.
		if (DOMUtils.isFosterablePosition(range.start) && (
			!DOMUtils.isElt(range.start) ||
			// NOTE: These template marker meta tags are translated from comments
			// *after* the DOM has been built which is why they can show up in
			// fosterable positions in the DOM.
			(WTUtils.isTplMarkerMeta(range.start) && WTUtils.isTplMarkerMeta(range.start.nextSibling)) ||
			(WTUtils.isTplMarkerMeta(range.start) && !DOMUtils.isElt(range.start.nextSibling)))) {
			var rangeStartParent = range.start.parentNode;

			// 1. If we are in a table in a foster-element position, then all non-element
			//    nodes will be white-space and comments. Skip over all of them and find
			//    the first table content node
			var newStart = range.start;
			while (newStart && !DOMUtils.isElt(newStart)) {
				newStart = newStart.nextSibling;
			}

			// 2. Push leading comments and whitespace into the element node
			//    as long as it is a tr/tbody -- pushing whitespace into the
			//    other (th/td/caption) can change display semantics.
			if (newStart && newStart.nodeName in { TBODY: 1, TR: 1 }) {
				var insertPosition = newStart.firstChild;
				var n = range.start;
				while (n !== newStart) {
					var next = n.nextSibling;
					newStart.insertBefore(n, insertPosition);
					n = next;
				}
				range.start = newStart;
				// Update dsr to point to original start
				this.updateDSRForFirstTplNode(range.start, startElem);
			} else {
				range.start = rangeStartParent;
				range.end = rangeStartParent;
			}
		}

		// Ensure range.start is an element node since we want to
		// add/update the data-parsoid attribute to it.
		if (!DOMUtils.isElt(range.start) && !this.expandRangeToAvoidSpanWrapping(range, true)) {
			var span = doc.createElement('span');
			range.start.parentNode.insertBefore(span, range.start);
			span.appendChild(range.start);
			this.updateDSRForFirstTplNode(span, startElem);
			range.start = span;
		}

		if (range.start.nodeName === 'TABLE') {
			// If we have any fostered content, include it as well.
			while (DOMUtils.isElt(range.start.previousSibling) &&
					DOMDataUtils.getDataParsoid(range.start.previousSibling).fostered) {
				range.start = range.start.previousSibling;
			}
		}

		if (range.start === startElem && DOMUtils.isElt(range.start.nextSibling)) {
			// HACK!
			// The strip-double-tds pass has a HACK that requires DSR and src
			// information being set on this element node. So, this HACK here
			// is supporting that HACK there.
			//
			// (The parser test for T52603 will fail without this fix)
			this.updateDSRForFirstTplNode(range.start.nextSibling, startElem);
		}

		// Use the negative test since it doesn't mark the range as flipped
		// if range.start === range.end
		if (!DOMUtils.inSiblingOrder(range.start, range.end)) {
			// In foster-parenting situations, the end-meta tag (and hence range.end)
			// can show up before the range.start which would be the table itself.
			// So, we record this info for later analysis.
			range.flipped = true;
		}

		env.log("trace/tplwrap/findranges", function() {
			var msg = "";
			var dp1 = DOMDataUtils.getDataParsoid(range.start);
			var dp2 = DOMDataUtils.getDataParsoid(range.end);
			var tmp1 = dp1.tmp;
			var tmp2 = dp2.tmp;
			dp1.tmp = null;
			dp2.tmp = null;
			msg += "\n----------------------------------------------";
			msg += "\nFound range : " + range.id + "; flipped? " + range.flipped + "; offset: " + range.startOffset;
			msg += "\nstart-elem : " + range.startElem.outerHTML + "; DP: " + JSON.stringify(DOMDataUtils.getDataParsoid(range.startElem));
			msg += "\nend-elem : " + range.endElem.outerHTML + "; DP: " + JSON.stringify(DOMDataUtils.getDataParsoid(range.endElem));
			msg += "\nstart : [TAG_ID " + tmp1.tagId + "]: " + range.start.outerHTML + "; DP: " + JSON.stringify(dp1);
			msg += "\nend : [TAG_ID " + tmp2.tagId + "]: " + range.end.outerHTML + "; DP: " + JSON.stringify(dp2);
			msg += "\n----------------------------------------------";
			dp1.tmp = tmp1;
			dp2.tmp = tmp2;
			return msg;
		});

		return range;
	}

	stripStartMeta(meta) {
		if (meta.nodeName === 'META') {
			meta.parentNode.removeChild(meta);
		} else {
			// Remove mw:* from the typeof.
			var type = meta.getAttribute("typeof") || '';
			type = type.replace(/(?:^|\s)mw:[^\/]*(\/[^\s]+|(?=$|\s))/g, '');
			meta.setAttribute("typeof", type);
		}
	}

	findToplevelEnclosingRange(nestingInfo, startId) {
		// Walk up the implicit nesting tree to find the
		// top-level range within which rId is nested.
		// No cycles can exist since they have been suppressed.
		var visited = {};
		var rId = startId;
		while (nestingInfo.has(rId)) {
			if (visited[rId]) {
				throw new Error("Found a cycle in tpl-range nesting where there shouldn't have been one.");
			}
			visited[rId] = true;
			rId = nestingInfo.get(rId);
		}
		return rId;
	}

	recordTemplateInfo(frame, compoundTpls, compoundTplId, tpl, argInfo) {
		if (!compoundTpls[compoundTplId]) {
			compoundTpls[compoundTplId] = [];
		}

		// Record template args info along with any intervening wikitext
		// between templates that are part of the same compound structure.
		var tplArray = compoundTpls[compoundTplId];
		var dp = DOMDataUtils.getDataParsoid(tpl.startElem);
		var dsr = dp.dsr;

		if (tplArray.length > 0) {
			var prevTplInfo = lastItem(tplArray);
			if (prevTplInfo.dsr[1] < dsr[0]) {
				tplArray.push({ wt: frame.srcText.substring(prevTplInfo.dsr[1], dsr[0]) });
			}
		}

		if (dp.unwrappedWT) {
			tplArray.push({ wt: dp.unwrappedWT });
		}

		// Get rid of src-offsets since they aren't needed anymore.
		argInfo.paramInfos.forEach(function(pi) { pi.srcOffsets = undefined; });
		tplArray.push({ dsr: dsr, args: argInfo.dict, paramInfos: argInfo.paramInfos });
	}

	// Nesting cycles with multiple ranges can show up because of foster
	// parenting scenarios if they are not detected and suppressed.
	introducesCycle(start, end, nestingInfo) {
		var visited = {};
		visited[start] = true;
		var elt = nestingInfo.get(end);
		while (elt) {
			if (visited[elt]) {
				return true;
			}
			elt = nestingInfo.get(elt);
		}
		return false;
	}

	// The `inSiblingOrder` check here is sufficient to determine overlaps
	// because the algorithm in `findWrappableTemplateRanges` will put the
	// start/end elements for intersecting ranges on the same plane and prev/
	// curr are in textual order (which hopefully translates to dom order).
	rangesOverlap(prev, curr) {
		var prevEnd   = !prev.flipped ? prev.end : prev.start;
		var currStart = !curr.flipped ? curr.start : curr.end;
		return DOMUtils.inSiblingOrder(currStart, prevEnd);
	}

	findTopLevelNonOverlappingRanges(document, frame, docRoot, tplRanges) {
		const env = frame.env;
		var n, r, ranges;
		var numRanges = tplRanges.length;

		// For each node, assign an attribute that is a record of all
		// tpl ranges it belongs to at the top-level.
		//
		// FIXME: Ideally we would have used a hash-table external to the
		// DOM, but we have no way of computing a hash-code on a dom-node
		// right now.  So, this is the next best solution (=hack) to use
		// node.data as hash-table storage.
		for (var i = 0; i < numRanges; i++) {
			r = tplRanges[i];
			n = !r.flipped ? r.start : r.end;
			var e = !r.flipped ? r.end : r.start;

			while (n) {
				if (DOMUtils.isElt(n)) {
					// Initialize tplRanges, if necessary.
					var dp = DOMDataUtils.getDataParsoid(n);
					ranges = dp.tmp.tplRanges;
					if (!ranges) {
						dp.tmp.tplRanges = ranges = {};
					}

					// Record 'r'
					ranges[r.id] = r;

					// Done
					if (n === e) {
						break;
					}
				}

				n = n.nextSibling;
			}
		}

		// In the first pass over `numRanges` below, `subsumedRanges` is used to
		// record purely the nested ranges.  However, in the second pass, we also
		// add the relationships between overlapping ranges so that
		// `findToplevelEnclosingRange` can use that information to add `argInfo`
		// to the right `compoundTpls`.  This scenario can come up when you have
		// three ranges, 1 intersecting with 2 but not 3, and 3 nested in 2.
		var subsumedRanges = new Map();

		// For each range r:(s, e), walk up from s --> docRoot and if any of
		// these nodes have tpl-ranges (besides r itself) assigned to them,
		// then r is nested in those other templates and can be ignored.
		for (var k = 0; k < numRanges; k++) {
			r = tplRanges[k];
			n = r.start;

			while (n !== docRoot) {
				ranges = DOMDataUtils.getDataParsoid(n).tmp.tplRanges || null;
				if (ranges) {
					if (n !== r.start) {
						// console.warn(" -> nested; n_tpls: " + Object.keys(ranges));

						// 'r' is nested for sure
						// Record the outermost range in which 'r' is nested.
						var rangeIds = Object.keys(ranges);
						var findOutermostRange = function(previous, next) {
							return ranges[next].startOffset < ranges[previous].startOffset ? next : previous;
						};
						subsumedRanges.set(r.id, rangeIds.reduce(findOutermostRange, rangeIds[0]));
						break;
					} else {
						// n === r.start
						//
						// We have to make sure this is not an overlap scenario.
						// Find the ranges that r.start and r.end belong to and
						// compute their intersection. If this intersection has
						// another tpl range besides r itself, we have a winner!
						//
						// The code below does the above check efficiently.
						var sTpls = ranges;
						var eTpls = DOMDataUtils.getDataParsoid(r.end).tmp.tplRanges;
						var sKeys = Object.keys(sTpls);
						var foundNesting = false;

						for (var j = 0; j < sKeys.length; j++) {
							// - Don't record nesting cycles.
							// - Record the outermost range in which 'r' is nested in.
							var otherId = sKeys[j];
							var other = sTpls[otherId];
							if (otherId !== r.id
								&& eTpls[otherId]
								// When we have identical ranges, pick the range with
								// the larger offset to be subsumed.
								&& (r.start !== other.start || r.end !== other.end || other.startOffset < r.startOffset)
								&& !this.introducesCycle(r.id, otherId, subsumedRanges)) {
								foundNesting = true;
								if (!subsumedRanges.has(r.id)
									|| other.startOffset < sTpls[subsumedRanges.get(r.id)].startOffset) {
									subsumedRanges.set(r.id, otherId);
								}
							}
						}

						if (foundNesting) {
							// 'r' is nested
							// console.warn(" -> nested: sTpls: " + Object.keys(sTpls) +
							// "; eTpls: " + Object.keys(eTpls) +
							// "; set to: " + subsumedRanges.get(r.id));
							break;
						}
					}
				}

				// Move up
				n = n.parentNode;
			}
		}

		// Sort by start offset in source wikitext
		tplRanges.sort(function(r1, r2) { return r1.startOffset - r2.startOffset; });

		// Since the tpl ranges are sorted in textual order (by start offset),
		// it is sufficient to only look at the most recent template to see
		// if the current one overlaps with the previous one.
		//
		// This works because we've already identify nested ranges and can ignore them.

		var newRanges = [];
		var prev = null;
		var compoundTpls = {};

		for (var l = 0; l < numRanges; l++) {
			var endTagToRemove = null;
			var startTagToStrip = null;

			r = tplRanges[l];

			// Extract argInfo
			var tmp = DOMDataUtils.getDataParsoid(r.startElem).tmp;
			var argInfo = tmp.tplarginfo;
			if (argInfo) {
				argInfo = JSON.parse(argInfo);
			} else {
				// An assertion here is probably an indication that we're
				// mistakenly doing template wrapping in a nested context.
				console.assert(tmp.fromFoster, "Template range without arginfo.");
			}

			env.log("trace/tplwrap/merge", function() {
				var msg = "";
				var dp1 = DOMDataUtils.getDataParsoid(r.start);
				var dp2 = DOMDataUtils.getDataParsoid(r.end);
				var tmp1 = dp1.tmp;
				var tmp2 = dp2.tmp;
				dp1.tmp = null;
				dp2.tmp = null;
				msg += "\n##############################################";
				msg += "\nrange " + r.id + "; r-start-elem: " + r.startElem.outerHTML + "; DP: " + JSON.stringify(DOMDataUtils.getDataParsoid(r.startElem));
				msg += "\nrange " + r.id + "; r-end-elem: " + r.endElem.outerHTML + "; DP: " + JSON.stringify(DOMDataUtils.getDataParsoid(r.endElem));
				msg += "\nrange " + r.id + "; r-start: [TAG_ID " + tmp1.tagId + "]: " + r.start.outerHTML + "; DP: " + JSON.stringify(dp1);
				msg += "\nrange " + r.id + "; r-end: [TAG_ID " + tmp2.tagId + "]: " + r.end.outerHTML + "; DP: " + JSON.stringify(dp2);
				msg += "\n----------------------------------------------";
				dp1.tmp = tmp1;
				dp2.tmp = tmp2;
				return msg;
			});

			var enclosingRangeId = this.findToplevelEnclosingRange(subsumedRanges, subsumedRanges.get(r.id));
			if (enclosingRangeId) {
				env.log("trace/tplwrap/merge", "--nested in ", enclosingRangeId, "--");

				// Nested -- ignore r
				startTagToStrip = r.startElem;
				endTagToRemove = r.endElem;
				if (argInfo) {
					// 'r' is nested in 'enclosingRange' at the top-level
					// So, enclosingRange gets r's argInfo
					this.recordTemplateInfo(frame, compoundTpls, enclosingRangeId, r, argInfo);
				}
			} else if (prev && this.rangesOverlap(prev, r)) {
				// In the common case, in overlapping scenarios, r.start is
				// identical to prev.end. However, in fostered content scenarios,
				// there can true overlap of the ranges.
				env.log("trace/tplwrap/merge", "--overlapped--");

				// See comment above, where `subsumedRanges` is defined.
				subsumedRanges.set(r.id, prev.id);

				// Overlapping ranges.
				// r is the regular kind
				// Merge r with prev

				// Note that if a table comes from a template, a foster box isn't
				// emitted so the enclosure isn't guaranteed.  In pathological
				// cases, like where the table end tag isn't emitted, we can still
				// end up with flipped ranges if the template end marker gets into
				// a fosterable position (which can still happen despite being
				// emitted as a comment).
				console.assert(!r.flipped,
					'Flipped range should have been enclosed.');

				startTagToStrip = r.startElem;
				endTagToRemove = prev.endElem;

				prev.end = r.end;
				prev.endElem = r.endElem;

				// Update compoundTplInfo
				if (argInfo) {
					this.recordTemplateInfo(frame, compoundTpls, prev.id, r, argInfo);
				}
			} else {
				env.log("trace/tplwrap/merge", "--normal--");

				// Default -- no overlap
				// Emit the merged range
				newRanges.push(r);
				prev = r;

				// Update compoundTpls
				if (argInfo) {
					this.recordTemplateInfo(frame, compoundTpls, r.id, r, argInfo);
				}
			}

			if (endTagToRemove) {
				// Remove start and end meta-tags
				// Not necessary to remove the start tag, but good to cleanup
				endTagToRemove.parentNode.removeChild(endTagToRemove);
				this.stripStartMeta(startTagToStrip);
			}
		}

		return { ranges: newRanges, tplArrays: compoundTpls };
	}

	findFirstTemplatedNode(range) {
		var firstNode = range.start;

		// Skip tpl marker meta
		if (WTUtils.isTplMarkerMeta(firstNode)) {
			firstNode = firstNode.nextSibling;
		}

		// Walk past fostered nodes since they came from within a table
		// Note that this is not foolproof because in some scenarios,
		// fostered content is not marked up. Ex: when a table is templated,
		// and content from the table is fostered.
		var dp = DOMDataUtils.getDataParsoid(firstNode);
		while (dp && dp.fostered) {
			firstNode = firstNode.nextSibling;
			dp = DOMDataUtils.getDataParsoid(firstNode);
		}

		// FIXME: It is harder to use META as a node name since this is a generic
		// placeholder for a whole bunch of things each of which has its own
		// newline constraint requirements. So, for now, I am skipping that
		// can of worms to prevent confusing the serializer with an overloaded
		// tag name.
		if (firstNode.nodeName === 'META') { return undefined; }

		return dp.stx ? firstNode.nodeName + '_' + dp.stx : firstNode.nodeName;
	}

	encapsulateTemplates(doc, frame, tplRanges, tplArrays) {
		const env = frame.env;
		var numRanges = tplRanges.length;
		for (var i = 0; i < numRanges; i++) {
			var range = tplRanges[i];

			// We should never have flipped overlapping ranges, and indeed that's
			// asserted in `findTopLevelNonOverlappingRanges`.  Flipping results
			// in either completely nested ranges, or non-intersecting ranges.
			//
			// If the table causing the fostering is not transcluded, we emit a
			// foster box and wrap the whole table+fb in metas, producing nested
			// ranges.  For ex,
			//
			//   <table>
			//   {{1x|<div>}}
			//
			// The tricky part is when the table *is* transcluded, and we omit the
			// foster box.  The common case (for some definition of common) might
			// be like,
			//
			//   {{1x|<table>}}
			//   {{1x|<div>}}
			//
			// Here, #mwt1 leaves a table open and the end meta from #mwt2 is
			// fostered, since it gets closed into the div.  The range for #mwt1
			// is the entire table, which thankfully contains #mwt2, so we still
			// have the expected entire neseting.  Any tricks to extend the range
			// of #mwt2 beyond the table so that we have an overlapping range will
			// ineviatbly result in the end meta not being fostered, and we avoid
			// this situation altogether.
			//
			// The very edgy case is as follows,
			//
			//   {{1x|<table><div>}}</div>
			//   {{1x|<div>}}
			//
			// where both end metas are fostered.  Ignoring that we don't even
			// roundtrip the first transclusion properly on its own, here we have
			// a flipped range where, since the end meta for the first range was
			// also fostered, the ranges still don't overlap.

			if (range.flipped) {
				// FIXME: The code below needs to be aware of flipped ranges.
				env.log('warn/template',
					'Encapsulating a flipped range: ' + range.id);
			}

			this.expandRangeToAvoidSpanWrapping(range);

			var n = range.start;
			var e = range.end;
			var startElem = range.startElem;
			var about = startElem.getAttribute('about') || '';

			while (n) {
				var next = n.nextSibling;
				if (!DOMUtils.isElt(n)) {
					// Don't add span-wrappers in fosterable positions
					//
					// NOTE: there cannot be any non-IEW text in fosterable position
					// since the HTML tree builder would already have fostered it out.
					if (!DOMUtils.isFosterablePosition(n)) {
						var span = doc.createElement('span');
						span.setAttribute('about', about);
						n.parentNode.replaceChild(span, n);
						span.appendChild(n);
						n = span;
					}
				} else {
					n.setAttribute('about', about);
				}

				if (n === e) {
					break;
				}

				n = next;
			}

			// Encap. info for the range
			var encapInfo = {
				valid: false,
				target: range.start,
				tplArray: tplArrays[range.id],
				datamw: null,
				dp: null,
			};

			// Skip template-marker meta-tags.
			// Also, skip past comments/text nodes found in fosterable positions
			// which wouldn't have been span-wrapped in the while-loop above.
			while (WTUtils.isTplMarkerMeta(encapInfo.target) || !DOMUtils.isElt(encapInfo.target)) {
				// Detect unwrappable template and bail out early.
				if (encapInfo.target === range.end ||
					(!DOMUtils.isElt(encapInfo.target) && !DOMUtils.isFosterablePosition(encapInfo.target))) {
					throw new Error("Cannot encapsulate transclusion. Start=" + startElem.outerHTML);
				}
				encapInfo.target = encapInfo.target.nextSibling;
			}
			encapInfo.dp = DOMDataUtils.getDataParsoid(encapInfo.target);

			// Update type-of (always even if tpl-encap below will fail).
			// This ensures that VE will still "edit-protect" this template
			// and not allow its content to be edited directly.
			if (startElem !== encapInfo.target) {
				var t1 = startElem.getAttribute("typeof") || '';
				var t2 = encapInfo.target.getAttribute("typeof") || '';
				encapInfo.target.setAttribute("typeof", t2 ? t1 + " " + t2 : t1);
			}

			/* ----------------------------------------------------------------
			 * We'll attempt to update dp1.dsr to reflect the entire range of
			 * the template.  This relies on a couple observations:
			 *
			 * 1. In the common case, dp2.dsr[1] will be > dp1.dsr[1]
			 *    If so, new range = dp1.dsr[0], dp2.dsr[1]
			 *
			 * 2. But, foster parenting can complicate this when range.end is a table
			 *    and range.start has been fostered out of the table (range.end).
			 *    But, we need to verify this assumption.
			 *
			 *    2a. If dp2.dsr[0] is smaller than dp1.dsr[0], this is a
			 *        confirmed case of range.start being fostered out of range.end.
			 *
			 *    2b. If dp2.dsr[0] is unknown, we rely on fostered flag on
			 *        range.start, if any.
			 * ---------------------------------------------------------------- */
			var dp1 = Util.clone(DOMDataUtils.getDataParsoid(range.start));
			var dp2DSR = this.getRangeEndDSR(range);

			if (dp1.dsr) {
				if (dp2DSR) {
					// Case 1. above
					if (dp2DSR[1] > dp1.dsr[1]) {
						dp1.dsr[1] = dp2DSR[1];
					}

					// Case 2. above
					var endDsr = dp2DSR[0];
					if (range.end.nodeName === 'TABLE' &&
						endDsr !== null &&
						(endDsr < dp1.dsr[0] || dp1.fostered)) {
						dp1.dsr[0] = endDsr;
					}
				}

				// encapsulation possible only if dp1.dsr is valid
				encapInfo.valid = dp1.dsr[0] !== null && dp1.dsr[1] !== null;
			}

			var tplArray = encapInfo.tplArray;
			console.assert(!!tplArray, "No parts for template range!");
			if (encapInfo.valid) {
				// Find transclusion info from the array (skip past a wikitext element)
				var firstTplInfo = tplArray[0].wt ? tplArray[1] : tplArray[0];

				// Add any leading wikitext
				if (firstTplInfo.dsr[0] > dp1.dsr[0]) {
					// This gap in dsr (between the final encapsulated content, and the
					// content that actually came from a template) is indicative of this
					// being a mixed-template-content-block and/or multi-template-content-block
					// scenario.
					//
					// In this case, record the name of the first node in the encapsulated
					// content. During html -> wt serialization, newline constraints for
					// this entire block has to be determined relative to this node.
					encapInfo.dp.firstWikitextNode = this.findFirstTemplatedNode(range);
					tplArray = [{ wt: frame.srcText.substring(dp1.dsr[0], firstTplInfo.dsr[0]) }].concat(tplArray);
				}

				// Add any trailing wikitext
				var lastTplInfo = lastItem(tplArray);
				if (lastTplInfo.dsr[1] < dp1.dsr[1]) {
					tplArray.push({ wt: frame.srcText.substring(lastTplInfo.dsr[1], dp1.dsr[1]) });
				}

				// Extract the key orders for the templates
				var paramInfoArrays = [];
				tplArray.forEach(function(a) {
					if (a.paramInfos) {
						paramInfoArrays.push(a.paramInfos);
					}
				});

				// Map the array of { dsr: .. , args: .. } objects to just the args property
				var infoIndex = 0;
				tplArray = tplArray.map(function(a) {
					if (a.wt) {
						return a.wt;
					} else {
						// Remember the position of the transclusion relative
						// to other transclusions. Should match the index of
						// the corresponding private metadata in paramInfoArrays
						// above.
						if (a.args) {
							a.args.i = infoIndex;
						}
						infoIndex++;
						return DOMUtils.hasTypeOf(startElem, 'mw:Param')
							? { templatearg: a.args }
							: { template: a.args };
					}
				});

				// Set up dsr[0], dsr[1], and data-mw on the target node
				encapInfo.datamw = { parts: tplArray };
				if (WTUtils.isGeneratedFigure(encapInfo.target)) {
					// Preserve attributes for media since those will be used
					// when adding info, which only happens after this pass.
					// FIXME: There's a question here about whether we should
					// be doing this unconditionally, which is T214241
					const oldMw = DOMDataUtils.getDataMw(encapInfo.target);
					encapInfo.datamw.attribs = oldMw.attribs;
				}
				DOMDataUtils.setDataMw(encapInfo.target, encapInfo.datamw);
				encapInfo.dp.pi = paramInfoArrays;

				// Special case when mixed-attribute-and-content templates are
				// involved. This information is reliable and comes from the
				// AttributeExpander and gets around the problem of unmarked
				// fostered content that findFirstTemplatedNode runs into.
				var firstWikitextNode = DOMDataUtils.getDataParsoid(range.startElem).firstWikitextNode;
				if (!encapInfo.dp.firstWikitextNode && firstWikitextNode) {
					encapInfo.dp.firstWikitextNode = firstWikitextNode;
				}
			} else {
				var errors = [ "Do not have necessary info. to encapsulate Tpl: " + i ];
				errors.push("Start Elt : " + startElem.outerHTML);
				errors.push("End Elt   : " + range.endElem.outerHTML);
				errors.push("Start DSR : " + JSON.stringify((dp1 || {}).dsr || "no-start-dsr"));
				errors.push("End   DSR : " + JSON.stringify(dp2DSR || []));
				env.log("error", errors.join("\n"));
			}

			// Make DSR range zero-width for fostered templates after
			// setting up data-mw. However, since template encapsulation
			// sometimes captures both fostered content as well as the table
			// from which it was fostered from, in those scenarios, we should
			// leave DSR info untouched.
			//
			// SSS FIXME:
			// 1. Should we remove the fostered flag from the entire
			//    encapsulated block if we dont set dsr width range to zero
			//    since only part of the block is fostered, not the entire
			//    encapsulated block?
			//
			// 2. In both cases, should we mark these uneditable by adding
			//    mw:Placeholder to the typeof?
			if (dp1.fostered) {
				encapInfo.datamw = DOMDataUtils.getDataMw(encapInfo.target);
				if (!encapInfo.datamw || !encapInfo.datamw.parts || encapInfo.datamw.parts.length === 1) {
					dp1.dsr[1] = dp1.dsr[0];
				}
			}

			// Update DSR after fostering-related fixes are done.
			if (encapInfo.valid) {
				if (!encapInfo.dp) {
					// This wouldn't have been initialized if tplArray was null
					encapInfo.dp = DOMDataUtils.getDataParsoid(encapInfo.target);
				}
				// encapInfo.dp points to DOMDataUtils.getDataParsoid(encapInfo.target)
				// and all updates below update properties in that object tree.
				if (!encapInfo.dp.dsr) {
					encapInfo.dp.dsr = dp1.dsr;
				} else {
					encapInfo.dp.dsr[0] = dp1.dsr[0];
					encapInfo.dp.dsr[1] = dp1.dsr[1];
				}
				encapInfo.dp.src = frame.srcText.substring(encapInfo.dp.dsr[0], encapInfo.dp.dsr[1]);
			}

			// Remove startElem (=range.startElem) if a meta.  If a meta,
			// it is guaranteed to be a marker meta added to mark the start
			// of the template.
			if (WTUtils.isTplMarkerMeta(startElem)) {
				startElem.parentNode.removeChild(startElem);
			}

			range.endElem.parentNode.removeChild(range.endElem);
		}
	}

	/**
	 * Recursive worker.
	 * @private
	 */
	findWrappableTemplateRanges(doc, frame, rootNode, tpls) {
		const env = frame.env;
		var tplRanges = [];
		var elem = rootNode.firstChild;
		var about;
		var aboutRef;

		while (elem) {
			// get the next sibling before doing anything since
			// we may delete elem as part of encapsulation
			var nextSibling = elem.nextSibling;

			if (DOMUtils.isElt(elem)) {
				const metaType = WTUtils.matchTplType(elem);

				// Ignore templates without tsr.
				//
				// These are definitely nested in other templates / extensions
				// and need not be wrapped themselves since they
				// can never be edited directly.
				//
				// NOTE: We are only testing for tsr presence on the start-elem
				// because wikitext errors can lead to parse failures and no tsr
				// on end-meta-tags.
				//
				// Ex: "<ref>{{echo|bar}}<!--bad-></ref>"
				if (metaType && (DOMDataUtils.getDataParsoid(elem).tsr || metaType.endsWith('/End'))) {
					about = elem.getAttribute('about') || '';
					aboutRef = tpls[about];
					// Is this a start marker?
					if (!metaType.endsWith('/End')) {
						if (aboutRef) {
							aboutRef.start = elem;
							// content or end marker existed already
							if (aboutRef.end) {
								// End marker was foster-parented.
								// Found actual start tag.
								env.log("warn/template", 'end marker was foster-parented for', about);
								tplRanges.push(this.getDOMRange(frame, doc, elem, aboutRef.end, aboutRef.end));
							} else {
								// should not happen!
								console.assert(false, 'start found after content for', about);
							}
						} else {
							tpls[about] = { start: elem };
						}
					} else {
						// elem is the end-meta tag
						if (aboutRef) {
							/* ------------------------------------------------------------
							 * Special case: In some cases, the entire template content can
							 * get fostered out of a table, not just the start/end marker.
							 *
							 * Simplest example:
							 *
							 *   {|
							 *   {{echo|foo}}
							 *   |}
							 *
							 * More complex example:
							 *
							 *   {|
							 *   {{echo|
							 *   a
							 *    b
							 *
							 *     c
							 *   }}
							 *   |}
							 *
							 * Since meta-tags dont normally get fostered out, this scenario
							 * only arises when the entire content including meta-tags was
							 * wrapped in p-tags.  So, we look to see if:
							 * 1. the end-meta-tag's parent has a table sibling,
							 * 2. the DSR of the start-meta-tag's parent is nested inside
							 *    that table's DSR
							 * If so, we recognize this as a adoption scenario and fix up
							 * DSR of start-meta-tag's parent to include the table's DSR.
							 * ------------------------------------------------------------*/
							var sm  = aboutRef.start;
							var em  = elem;
							var ee  = em;
							var tbl = em.parentNode.nextSibling;

							// Dont get distracted by a newline node -- skip over it
							// Unsure why it shows up occasionally
							if (tbl && DOMUtils.isText(tbl) && tbl.data.match(/^\n$/)) {
								tbl = tbl.nextSibling;
							}

							var dp = DOMDataUtils.getDataParsoid(sm.parentNode);
							if (tbl &&
								tbl.nodeName === 'TABLE' &&
								dp.fostered) {
								var tblDP = DOMDataUtils.getDataParsoid(tbl);
								if (dp.tsr && dp.tsr[0] !== null && tblDP.dsr[0] === null) {
									tblDP.dsr[0] = dp.tsr[0];
								}
								tbl.setAttribute('about', about); // set about on elem
								ee = tbl;
							}
							tplRanges.push(this.getDOMRange(frame, doc, sm, em, ee));
						} else {
							tpls[about] = { end: elem };
						}
					}
				} else {
					tplRanges = tplRanges.concat(this.findWrappableTemplateRanges(doc, frame, elem, tpls));
				}
			}

			elem = nextSibling;
		}

		return tplRanges;
	}

	wrapTemplatesInTree(document, frame, node) {
		var tplRanges = this.findWrappableTemplateRanges(document, frame, node, {});
		if (tplRanges.length > 0) {
			tplRanges = this.findTopLevelNonOverlappingRanges(document, frame, node, tplRanges);
			this.encapsulateTemplates(document, frame, tplRanges.ranges, tplRanges.tplArrays);
		}
	}

	/**
	 * Encapsulate template-affected DOM structures by wrapping text nodes into
	 * spans and adding RDFa attributes to all subtree roots according to
	 * http://www.mediawiki.org/wiki/Parsoid/RDFa_vocabulary#Template_content
	 */
	run(body, env, options) {
		this.wrapTemplatesInTree(body.ownerDocument, options.frame, body);
	}
}

if (typeof module === "object") {
	module.exports.WrapTemplates = WrapTemplates;
}