<?php
/**
 * SHOW/HIDE IS DECIDED HERE, NOT BY THE MODEL.
 *
 * Every editable element on a section should be switchable from the Fields panel, and for a while
 * the way to get that was to ask: `prompts/dsl-core.txt` tells the model to wrap EVERY editable
 * element in `{{#if <head>_visible}}`. It does it sometimes. A real footer read back off a site had
 * five of its thirteen elements wrapped — logo, navigation heading, contact heading, e-mail, phone
 * and opening hours had no condition at all, so those rows had no switch and there was no way to
 * give them one short of editing the code by hand. Same prompt, same model, different answer: which
 * makes the panel look broken, because half of it behaves one way and half the other.
 *
 * It was never a good thing to ask for. Which element a field belongs to is not a judgement call —
 * it is written in the markup, unambiguously, and reading it is what this class does. The model
 * writes the section; the conditions are put in afterwards, the same way every time, from the
 * template itself.
 *
 * WHAT IT DOES. For every top-level field it finds the element the field lives in, and wraps that
 * element in `{{#if <head>_visible}}`. AIPB_AI_Provider::derive_schema then turns each of those
 * conditions into a boolean field with a default of «1», and the Studio hangs it on the row of the
 * field it belongs to (assets/studio.js, switchMap). Nothing downstream had to learn anything new:
 * the output is the markup the model was supposed to have written.
 *
 * IT WORKS FOR EVERY KIND OF FIELD, and this is the reason it operates on elements and not on
 * placeholders. A field is not always a piece of text: `{{i:hero_img}}` lives in a `src`,
 * `{{u:cta_url}}` in an `href`, `{{c:accent}}` in a `style`, a repeater is a `{{#loop}}` and not a
 * placeholder at all. There is nothing in common between the ways they are WRITTEN. What they have
 * in common is that each of them is displayed by some element — an `<img>`, an `<a>`, a `<ul>` —
 * and removing that element is what hiding means for all of them at once.
 *
 * WHAT IT WILL NOT DO, and each of these is a way of being wrong that leaves no trace:
 *
 *  - it never wraps an element that is ALREADY the body of an `{{#if}}`. The model's own conditions
 *    stand; a second switch over the first would put two controls on one element, and turning off
 *    the one that is not the outer one would appear to do nothing.
 *  - it never wraps the section's outermost element. That is the whole section, and hiding the
 *    whole section is what the card's own switch in the panel's header is for.
 *  - it never wraps an element that carries more than one field, unless every field in it belongs
 *    to the same element head. `<p>{{t:a}} — {{t:b}}</p>` gives a and b no switch of their own:
 *    hiding either would take the other with it, which is worse than not offering to.
 *  - it stays out of `{{#loop}}` and `{{#filter}}` bodies. What is in there is one row of a list,
 *    repeated: a switch would be the same control drawn once and applied to all of them.
 *
 * RUNNING IT TWICE CHANGES NOTHING. It is applied to every section the service returns, including
 * the result of every touch-up, which re-parses the whole template each time.
 *
 * @package AI_Page_Builder
 */

defined( 'ABSPATH' ) || exit;

class AIPB_Switches {

	/** The tail the prompts teach, and the one this writes. */
	const TAIL = '_visible';

	/**
	 * The three spellings that exist in the wild, all of which count as «already switched».
	 *
	 * `_visibile` is what the prompts taught while they were Italian and is saved on customers'
	 * sites; `_show` is what the model writes anyway, often enough to be the common case. Reading
	 * only `_visible` here would mean adding a second condition beside a perfectly good first one.
	 */
	const TAILS = array( '_visible', '_visibile', '_show' );

	/** Elements that close themselves. A `<br>` never opens anything. */
	const VOID = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );

	/**
	 * @param string $template A section's template, placeholders already normalised to {{k:key}}.
	 * @return string The same template with the missing {{#if …_visible}} conditions in place.
	 */
	public static function wrap( $template ) {
		$tpl = (string) $template;
		if ( '' === $tpl || false === strpos( $tpl, '{{' ) || false === strpos( $tpl, '<' ) ) {
			return $tpl;
		}

		$skip = self::skip_ranges( $tpl );
		$spots = self::spots( $tpl, $skip );
		if ( ! $spots ) {
			return $tpl;
		}

		$els = self::elements( $tpl, $skip );
		if ( ! $els ) {
			return $tpl;
		}

		list( $heads, $head_of ) = self::by_head( $spots );
		$edits = array();

		foreach ( $heads as $head => $mine ) {
			if ( self::has_condition( $tpl, $head ) ) {
				continue;
			}
			$span = self::owner( $tpl, $els, $spots, $head, $head_of, $mine );
			if ( ! $span ) {
				continue;
			}
			$edits[] = array( $span[0], '{{#if ' . $head . self::TAIL . '}}', 0 );
			$edits[] = array( $span[1], '{{/if}}', 1 );
		}
		if ( ! $edits ) {
			return $tpl;
		}

		// FROM THE END OF THE STRING BACKWARDS, so that every offset still points where it did when
		// it was measured. Inserting front to back moves everything after the insertion by the
		// length of what was inserted, and the second condition lands inside the first one's tag.
		// TWO ELEMENTS THAT TOUCH share an offset: the `</li><li>` of two entries with no whitespace
		// between them is one position, and both a close and an open belong there. Inserting
		// backwards, whatever is written LAST at a position ends up leftmost — so the open goes
		// first and the close after it, which puts them on the page the other way round, which is
		// the way round they have to be. Sorted the other way you get `{{#if b}}{{/if}}` around
		// nothing, and the first entry's condition swallowing the second entry whole.
		usort( $edits, static function ( $a, $b ) {
			if ( $a[0] !== $b[0] ) {
				return $b[0] < $a[0] ? -1 : 1;
			}
			return $a[2] === $b[2] ? 0 : ( $a[2] < $b[2] ? -1 : 1 );
		} );
		foreach ( $edits as $ed ) {
			$tpl = substr_replace( $tpl, $ed[1], $ed[0], 0 );
		}

		return $tpl;
	}

	/**
	 * A SECTION THAT WAS SAVED BEFORE ANY OF THIS EXISTED, brought up to date.
	 *
	 * wrap() only writes markup. A section already on a site also has a schema — the list of fields
	 * the panel draws — and a set of values, and a condition added to the template without a field
	 * beside it is a condition nobody can reach. Worse: a condition whose value has never been set
	 * reads as FALSE, so an upgrade that stopped at the template would take the logo off the footer
	 * of a live site and put no switch anywhere to turn it back on.
	 *
	 * So the three move together, or not at all. It runs where the Studio READS a section, not where
	 * it renders one: nothing changes on the public page until somebody opens the section and saves
	 * it, and when they do, the template, the fields and the values go back together.
	 *
	 * @return array { template, schema, values }, whether or not anything needed doing.
	 */
	public static function upgrade( $template, $schema, $values ) {
		$tpl    = (string) $template;
		$schema = is_array( $schema ) ? $schema : array();
		$values = is_array( $values ) ? $values : array();
		$out    = self::wrap( $tpl );

		if ( $out === $tpl ) {
			return array( $tpl, $schema, self::seed( $schema, $values ) );
		}

		$fields = isset( $schema['fields'] ) && is_array( $schema['fields'] ) ? $schema['fields'] : array();
		$have   = array();
		foreach ( $fields as $f ) {
			if ( isset( $f['key'] ) ) {
				$have[ $f['key'] ] = true;
			}
		}

		if ( preg_match_all( '/\{\{#if\s+([a-zA-Z0-9_]+)\s*\}\}/', $out, $m ) ) {
			foreach ( $m[1] as $key ) {
				if ( isset( $have[ $key ] ) ) {
					continue;
				}
				$have[ $key ] = true;
				$fields[]     = array(
					'key'     => $key,
					'type'    => 'bool',
					'label'   => self::humanize( $key ),
					// VISIBLE, and it is the only safe default. A condition with no value is false:
					// starting from empty, a section that has been on a site for a year would come
					// back with half of itself missing and nobody having asked for it.
					'default' => '1',
				);
			}
		}
		$schema['fields'] = $fields;

		return array( $out, $schema, self::seed( $schema, $values ) );
	}

	/**
	 * Every switch that has never been touched gets the value its field says it should have.
	 *
	 * The engine reads a missing value as false, and the schema's `default` is a thing only the
	 * panel looks at. Between the two, a switch that nobody has ever flicked is ON in the Studio and
	 * OFF on the page — the section looks right while it is being edited and comes out with pieces
	 * missing, which is the hardest kind of difference to notice.
	 */
	public static function seed( $schema, $values ) {
		$fields = isset( $schema['fields'] ) && is_array( $schema['fields'] ) ? $schema['fields'] : array();
		$values = is_array( $values ) ? $values : array();

		foreach ( $fields as $f ) {
			if ( ! isset( $f['key'] ) || ! isset( $f['type'] ) || 'bool' !== $f['type'] ) {
				continue;
			}
			if ( ! array_key_exists( $f['key'], $values ) ) {
				$values[ $f['key'] ] = isset( $f['default'] ) ? (string) $f['default'] : '';
			}
		}

		return $values;
	}

	/** `footer_nav_title_visible` -> «Footer nav title visible». */
	protected static function humanize( $key ) {
		return ucfirst( trim( str_replace( '_', ' ', (string) $key ) ) );
	}

	/* ------------------------------ What is a field --------------------------- */

	/**
	 * Every place in the template where a top-level field is displayed.
	 *
	 * A REPEATER IS ONE OF THEM. `{{#loop source="prop" name="x"}}…{{/loop}}` is a field like any
	 * other — it is a list somebody filled in — and the element that holds it is switchable in
	 * exactly the same sense. It is recorded as a single spot spanning the whole block, so the
	 * element that «contains the field» is the one containing both ends of the loop.
	 *
	 * `field.*` paths are not top-level: they are a row's own columns, and there is one of them per
	 * entry. Neither are the keys with a dot in them (`post.title`, `term.name`): those come from
	 * the content, not from the section, and there is no field in the panel to switch.
	 *
	 * @return array List of { start, end, key, in_tag }.
	 */
	protected static function spots( $tpl, $skip ) {
		$out = array();

		if ( preg_match_all( '/\{\{\s*([tciurhs]):([a-zA-Z0-9_]+)\s*\}\}/', $tpl, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) {
			foreach ( $m as $p ) {
				$at = $p[0][1];
				if ( self::inside( $at, $skip ) ) {
					continue;
				}
				$out[] = array(
					'start' => $at,
					'end'   => $at + strlen( $p[0][0] ),
					'key'   => $p[2][0],
					'kind'  => strtolower( $p[1][0] ),
				);
			}
		}

		// The lists. Their body is a skip range — that is where `field.*` lives — but the block
		// itself is the field, so it is measured from outside.
		if ( preg_match_all( '/\{\{#loop\s+([^}]*?)\}\}(.*?)\{\{\/loop\}\}/s', $tpl, $lp, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) {
			foreach ( $lp as $l ) {
				$attrs  = $l[1][0];
				$source = self::attr( $attrs, 'source' );
				$name   = self::attr( $attrs, 'name' );
				if ( ( '' !== $source && 'prop' !== $source ) || '' === $name || false !== strpos( $name, '.' ) ) {
					continue; // a menu, an archive, a content query: nothing of the section's own
				}
				$at = $l[0][1];
				if ( self::inside( $at, $skip, true ) ) {
					continue;
				}
				$out[] = array(
					'start' => $at,
					'end'   => $at + strlen( $l[0][0] ),
					'key'   => $name,
					'kind'  => 'loop',
				);
			}
		}

		usort( $out, static function ( $a, $b ) {
			return $a['start'] === $b['start'] ? 0 : ( $a['start'] < $b['start'] ? -1 : 1 );
		} );

		return $out;
	}

	/**
	 * Which field belongs to which ELEMENT of the section: hero_cta and hero_cta_url are one button,
	 * and one button takes one switch.
	 *
	 * The head is the LONGEST key that is a prefix of the others — the same rule the Fields panel
	 * groups rows by (assets/studio.js, makeUnits), so a unit that reads as one thing in the panel
	 * is switched as one thing on the page. Anything that is nobody's continuation is its own head.
	 *
	 * @return array { head key -> the spots that belong to it, field key -> its head }.
	 */
	protected static function by_head( $spots ) {
		$keys = array();
		foreach ( $spots as $s ) {
			$keys[ $s['key'] ] = true;
		}
		$keys = array_keys( $keys );

		// 1. WHO EXTENDS WHOM, by the longest prefix. `hero_cta_url` continues `hero_cta`, which in
		//    turn continues `hero` if there is a field by that name. Resolved to the top below, so
		//    the three of them end up switched by one control and not by three nested ones.
		$head_of = array();
		foreach ( $keys as $k ) {
			$head = '';
			foreach ( $keys as $o ) {
				if ( $o === $k || 0 !== strpos( $k, $o . '_' ) ) {
					continue;
				}
				if ( strlen( $o ) > strlen( $head ) ) {
					$head = $o;
				}
			}
			if ( '' !== $head ) {
				$head_of[ $k ] = $head;
			}
		}

		// 2. THE ALT RIGHT AFTER AN IMAGE. Given the way keys are written it almost never inherits
		//    the image's name — `hero_img` and `hero_alt` — so no prefix binds them. Without this
		//    they are two heads inside one `<img>`, the element belongs to neither, and a picture
		//    ends up as the one thing on the section that cannot be switched off. It is the same
		//    rule the Fields panel pairs its rows by (assets/studio.js, makeUnits).
		$n = count( $spots );
		for ( $i = 0; $i < $n - 1; $i++ ) {
			$next = $spots[ $i + 1 ];
			if ( 'i' !== $spots[ $i ]['kind'] || 't' !== $next['kind'] ) {
				continue;
			}
			if ( ! isset( $head_of[ $next['key'] ] ) && preg_match( '/(^|_)alt$/', $next['key'] ) ) {
				$head_of[ $next['key'] ] = $spots[ $i ]['key'];
			}
		}

		$groups = array();
		$of_key = array();
		foreach ( $spots as $s ) {
			$root                  = self::root_head( $s['key'], $head_of );
			$of_key[ $s['key'] ]   = $root;
			$groups[ $root ][]     = $s;
		}

		return array( $groups, $of_key );
	}

	/** Up the chain of heads to the one that is nobody's continuation. Loop-proof. */
	protected static function root_head( $key, $head_of ) {
		$seen = array();
		while ( isset( $head_of[ $key ] ) && ! isset( $seen[ $key ] ) ) {
			$seen[ $key ] = true;
			$key          = $head_of[ $key ];
		}
		return $key;
	}

	/** Is there already a condition on this element, under any of the spellings? */
	protected static function has_condition( $tpl, $head ) {
		foreach ( self::TAILS as $tail ) {
			if ( preg_match( '/\{\{#if\s+' . preg_quote( $head . $tail, '/' ) . '\s*\}\}/', $tpl ) ) {
				return true;
			}
		}
		return false;
	}

	/* --------------------------- Which element it is -------------------------- */

	/**
	 * The element to wrap for one head, or null when there is not one to be had.
	 *
	 * It starts from the innermost element holding every one of the head's spots and then CLIMBS
	 * while the parent holds nothing else. That second half is the part that matters:
	 * `<li><a href="…">{{t:x}}</a></li>` has the `<a>` as its innermost element, and switching off
	 * the `<a>` alone leaves an empty bullet in the list — a gap with nothing in it, which reads as
	 * a fault rather than as something switched off.
	 *
	 * @return array|null { start of the element, end of the element }.
	 */
	protected static function owner( $tpl, $els, $spots, $head, $head_of, $mine ) {
		$lo = $mine[0]['start'];
		$hi = $mine[0]['end'];
		foreach ( $mine as $s ) {
			$lo = min( $lo, $s['start'] );
			$hi = max( $hi, $s['end'] );
		}

		$i = self::innermost( $els, $lo, $hi );
		if ( null === $i ) {
			return null; // out in the open, with no element around it: nothing to remove
		}

		// ONE ELEMENT, ONE FIELD. An element showing somebody else's field as well cannot be
		// switched by either of them: whoever turned it off would take the other's content with it,
		// with nothing in the panel to say so.
		if ( ! self::exclusive( $els[ $i ], $spots, $head, $head_of ) ) {
			return null;
		}

		while ( null !== $els[ $i ]['parent'] ) {
			$p = $els[ $i ]['parent'];
			if ( null === $els[ $p ]['parent'] ) {
				break; // the section itself: that is the card's own switch, not a field's
			}
			if ( ! self::only_child( $tpl, $els, $p, $i ) ) {
				break;
			}
			$i = $p;
		}

		// The outermost element IS the section. Wrapping it would hide everything, and the panel
		// would offer that under the name of whichever field happened to be the only one in it.
		if ( null === $els[ $i ]['parent'] ) {
			return null;
		}

		// ALREADY SWITCHED, under a name of the model's choosing. has_condition() catches the
		// conditions named after the field; this catches the ones that are not — a list declared
		// `footer_social_links` and wrapped, by hand, in `{{#if footer_social_visible}}`. Two
		// controls on one element, and turning off the inner one would appear to do nothing at all,
		// because the outer one is what decides whether any of it is printed.
		if ( self::already_body( $tpl, $els[ $i ]['start'], $els[ $i ]['end'] ) ) {
			return null;
		}

		return array( $els[ $i ]['start'], $els[ $i ]['end'] );
	}

	/** Is this element, on its own, the whole body of an {{#if}}? */
	protected static function already_body( $tpl, $start, $end ) {
		$before = rtrim( substr( $tpl, 0, $start ) );
		$after  = ltrim( substr( $tpl, $end ) );

		return (bool) preg_match( '/\{\{#if\s+[a-zA-Z0-9_]+\s*\}\}$/', $before )
			&& 0 === strpos( $after, '{{/if}}' );
	}

	/**
	 * The deepest element that covers [lo, hi) — TAGS INCLUDED, and that is the whole point.
	 *
	 * An image's field is not in the element's content, it is in the element's `src`; a link's is in
	 * its `href`, a colour's in a `style`. Measured from the inside, `<img src="{{i:hero_img}}">`
	 * has no element around its field at all and the nearest one is the wrapper — which usually
	 * holds other things and so belongs to nobody. Measured from `<` to `>`, every kind of field
	 * lands on the element that displays it, which is the one to remove.
	 */
	protected static function innermost( $els, $lo, $hi ) {
		$best = null;
		foreach ( $els as $k => $e ) {
			if ( $e['start'] > $lo || $e['end'] < $hi ) {
				continue;
			}
			if ( null === $best || $e['depth'] > $els[ $best ]['depth'] ) {
				$best = $k;
			}
		}
		return $best;
	}

	/**
	 * Does this element show any field that is not this head's?
	 *
	 * BY THE HEAD MAP AND NOT BY THE KEY'S SPELLING, which is the difference between an image that
	 * can be switched off and one that cannot: `hero_alt` is `hero_img`'s alternative text and
	 * shares its `<img>`, but it does not share a single letter of its name.
	 */
	protected static function exclusive( $e, $spots, $head, $head_of ) {
		foreach ( $spots as $s ) {
			if ( $s['start'] < $e['start'] || $s['end'] > $e['end'] ) {
				continue;
			}
			if ( ! isset( $head_of[ $s['key'] ] ) || $head_of[ $s['key'] ] !== $head ) {
				return false;
			}
		}
		return true;
	}

	/** Is $child everything the element $p contains, give or take whitespace? */
	protected static function only_child( $tpl, $els, $p, $child ) {
		foreach ( $els as $k => $e ) {
			if ( $k !== $child && $e['parent'] === $p ) {
				return false; // a sibling: the parent is a container, not a wrapper
			}
		}
		$before = substr( $tpl, $els[ $p ]['inner_start'], $els[ $child ]['start'] - $els[ $p ]['inner_start'] );
		$after  = substr( $tpl, $els[ $child ]['end'], $els[ $p ]['inner_end'] - $els[ $child ]['end'] );

		return '' === trim( $before ) && '' === trim( $after );
	}

	/* -------------------------------- The markup ------------------------------ */

	/**
	 * The template's elements, as a tree flattened into a list.
	 *
	 * WRITTEN BY HAND AND NOT WITH A REGEX because of the two things a regex gets wrong here, and
	 * both of them are in every section: a `>` inside an attribute value (`style="…"`, an `alt` with
	 * a comparison in it) ends the tag early, and an element that is never closed — which happens,
	 * a live section was found with a `{{t:…|…` whose default had swallowed three closing tags —
	 * leaves the nesting wrong for the whole rest of the file rather than for one element.
	 *
	 * Tolerant on purpose. An unmatched close is ignored; a close that matches something further
	 * down the stack closes everything above it; whatever is still open at the end ends with the
	 * template. A malformed section still gets the switches its well-formed parts deserve.
	 *
	 * @return array List of { tag, start, inner_start, inner_end, end, parent, depth }.
	 */
	protected static function elements( $tpl, $skip ) {
		$len   = strlen( $tpl );
		$els   = array();
		$stack = array();
		$i     = 0;

		while ( $i < $len ) {
			$lt = strpos( $tpl, '<', $i );
			if ( false === $lt ) {
				break;
			}
			if ( self::inside( $lt, $skip ) ) {
				$i = $lt + 1;
				continue;
			}
			if ( 0 === substr_compare( $tpl, '<!--', $lt, 4 ) ) {
				$close = strpos( $tpl, '-->', $lt );
				$i     = ( false === $close ) ? $len : $close + 3;
				continue;
			}
			if ( ! preg_match( '#^</?[a-zA-Z]#', substr( $tpl, $lt, 3 ) ) ) {
				$i = $lt + 1;
				continue;
			}

			$gt = self::tag_end( $tpl, $lt );
			if ( $gt < 0 ) {
				break;
			}
			$raw = substr( $tpl, $lt, $gt - $lt + 1 );
			preg_match( '#^</?([a-zA-Z][a-zA-Z0-9:-]*)#', $raw, $nm );
			$tag = strtolower( $nm[1] );
			$i   = $gt + 1;

			if ( '/' === $raw[1] ) {
				// Back down the stack to the matching open, closing whatever was left hanging above.
				for ( $s = count( $stack ) - 1; $s >= 0; $s-- ) {
					if ( $els[ $stack[ $s ] ]['tag'] !== $tag ) {
						continue;
					}
					for ( $u = count( $stack ) - 1; $u > $s; $u-- ) {
						$els[ $stack[ $u ] ]['inner_end'] = $lt;
						$els[ $stack[ $u ] ]['end']       = $lt;
						array_pop( $stack );
					}
					$els[ $stack[ $s ] ]['inner_end'] = $lt;
					$els[ $stack[ $s ] ]['end']       = $gt + 1;
					array_pop( $stack );
					break;
				}
				continue;
			}

			$self = ( '/' === substr( rtrim( substr( $raw, 0, -1 ) ), -1 ) ) || in_array( $tag, self::VOID, true );
			$k    = count( $els );

			$els[] = array(
				'tag'         => $tag,
				'start'       => $lt,
				'inner_start' => $gt + 1,
				'inner_end'   => $self ? $gt + 1 : $len,
				'end'         => $self ? $gt + 1 : $len,
				'parent'      => $stack ? $stack[ count( $stack ) - 1 ] : null,
				'depth'       => count( $stack ),
			);
			if ( ! $self ) {
				$stack[] = $k;
			}
		}

		return $els;
	}

	/** The `>` that closes the tag opening at $lt, ignoring the ones inside attribute values. */
	protected static function tag_end( $tpl, $lt ) {
		$len   = strlen( $tpl );
		$quote = '';
		for ( $i = $lt + 1; $i < $len; $i++ ) {
			$c = $tpl[ $i ];
			if ( '' !== $quote ) {
				if ( $c === $quote ) {
					$quote = '';
				}
				continue;
			}
			if ( '"' === $c || "'" === $c ) {
				$quote = $c;
				continue;
			}
			if ( '>' === $c ) {
				return $i;
			}
		}
		return -1;
	}

	/* --------------------------- Where not to look ---------------------------- */

	/**
	 * The stretches of template this pass has no business in.
	 *
	 * `<style>` and `<script>` are not markup: a `<` in a selector or a comparison in a condition
	 * would be read as a tag and put the nesting out for everything after it. A `{{#loop}}` or a
	 * `{{#filter}}` body is ONE ROW of something repeated: a condition in there is drawn once and
	 * applied to every row, which is not a switch, it is a filter written by mistake.
	 *
	 * @return array List of [ start, end ).
	 */
	protected static function skip_ranges( $tpl ) {
		$out = array();

		if ( preg_match_all( '#<(style|script)\b[^>]*>.*?</\1\s*>#is', $tpl, $m, PREG_OFFSET_CAPTURE ) ) {
			foreach ( $m[0] as $x ) {
				$out[] = array( $x[1], $x[1] + strlen( $x[0] ) );
			}
		}
		if ( preg_match_all( '/\{\{#(loop|filter)\b.*?\{\{\/\1\}\}/s', $tpl, $d, PREG_OFFSET_CAPTURE ) ) {
			foreach ( $d[0] as $x ) {
				$out[] = array( $x[1], $x[1] + strlen( $x[0] ) );
			}
		}

		return $out;
	}

	/**
	 * @param bool $strict True to count an offset that only TOUCHES a range's start as outside it —
	 *                     which is what a {{#loop}} needs, since it opens its own skip range.
	 */
	protected static function inside( $at, $ranges, $strict = false ) {
		foreach ( $ranges as $r ) {
			if ( $strict ? ( $at > $r[0] && $at < $r[1] ) : ( $at >= $r[0] && $at < $r[1] ) ) {
				return true;
			}
		}
		return false;
	}

	/** One attribute out of a directive's `a="b" c="d"`. */
	protected static function attr( $str, $name ) {
		return preg_match( '/\b' . preg_quote( $name, '/' ) . '\s*=\s*"([^"]*)"/i', (string) $str, $m ) ? $m[1] : '';
	}
}
