<?php // phpcs:ignore WordPress.Files.FileName

/**
 * Calculates new offsets based on amount of words between ads.
 *
 * Key optimizations:
 *
 * 1. O(n²) → O(n): The original calc_offset() had two O(n) inner loops per
 *    outer iteration, totalling O(n²). For 100 paragraphs: ~10,000 ops.
 *    Now replaced with prefix-sum arrays built once in O(n), making each
 *    prev/next lookup O(1). For 100 paragraphs: ~300 ops. 33x speedup.
 *
 * 2. get_expression_for_existing_ads() cached as $this->ads_expr — was called
 *    up to 4 times per request, each time running sanitize_html_class() + wp_advads().
 *
 * 3. preg_split() on fixed sentinel strings replaced with str_replace + explode
 *    — regex engine entirely avoided for known fixed-string splitting.
 *
 * 4. add_after_insertion_point() uses direct array index instead of array_pop + push.
 *
 * 5. words_between count cached as $this->words_between_count — count() no longer
 *    called on every calc_offset invocation.
 *
 * 6. calc_words() uses /\s+/u (Unicode-aware) instead of str_word_count() which
 *    returns 0 for CJK, Arabic, and other non-ASCII scripts.
 *
 * 7. prepare_text() collapses three string operations into one preg_replace().
 *
 * 8. prepare_words_between() uses a running integer word count instead of
 *    accumulating string arrays (except in debug mode).
 *
 * 9. can_inject_*() short-circuits as soon as the word threshold is met.
 *
 * 10. DOM sentinel in can_inject_after_content_placement() always rolled back
 *     via remove_sentinel(), fixing a DOM mutation leak in the original.
 *
 * 11. from_html() correctly restores libxml error state after clearing errors.
 */
class Advanced_Ads_Pro_Offset_Shifter {

	/**
	 * Content dom
	 *
	 * @var DOMDocument
	 */
	private $dom = null;

	/**
	 *  Content xpath
	 *
	 * @var DOMXPath
	 */
	private $xpath = null;

	/**
	 * Placement options
	 *
	 * @var array
	 */
	private $options = null;

	/**
	 * Cached XPath expression for existing ads.
	 * Computed once on first call to get_expression_for_existing_ads().
	 *
	 * @var string|null
	 */
	private $ads_expr = null;

	/**
	 * Placement default options
	 *
	 * @var array
	 */
	protected $default_options = [
		'words_between_repeats'        => 20,
		'require_before_first'         => false,
		'require_after_last'           => false,
		'debug'                        => false,
		'before'                       => false,
		'paragraph_select_from_bottom' => false,
	];

	/**
	 * Words between data
	 *
	 * @var array
	 */
	protected $words_between = [];

	/**
	 * Cached count of $words_between — set once in get_words_between().
	 *
	 * @var int
	 */
	private $words_between_count = 0;

	/**
	 * Placement offset
	 *
	 * @var false|int
	 */
	protected $previous_offset = false;

	const START_EXISTING_AD = 'advads_amount_of_words_s';
	const END_EXISTING_AD   = 'advads_amount_of_words_e';
	const INSERTION_POINT   = 'advads_amount_of_words_i';
	const SPLIT_REGEXP      = '/(advads_amount_of_words_.)/';
	const PREV_WORDS        = 'prev_words';
	const PREV_NUMBER       = 'prev_number';
	const NEXT_WORDS        = 'next_words';
	const NEXT_NUMBER       = 'next_number';
	const PREV_IS_AD        = 'prev_is_ad';
	const NEXT_IS_AD        = 'next_is_ad';

	private const SENTINEL_SEP = "\x00";

	/**
	 * Create an instance from an HTML string.
	 *
	 * @param string $html    HTML string.
	 * @param array  $options Options.
	 *
	 * @return static
	 */
	public static function from_html( $html, $options ) {
		$prev_libxml = libxml_use_internal_errors( true );

		$dom = new DOMDocument( '1.0', 'UTF-8' );
		$dom->loadHtml( '<!DOCTYPE html><html><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /><body>' . $html );

		libxml_clear_errors();
		libxml_use_internal_errors( $prev_libxml );

		return new static( $dom, new DOMXPath( $dom ), $options );
	}

	/**
	 * Constructor.
	 *
	 * @param DOMDocument $dom     DOMDocument object.
	 * @param DOMXPath    $xpath   DOMXPath object.
	 * @param array       $options Options.
	 */
	public function __construct( DOMDocument $dom, DOMXPath $xpath, array $options ) {
		$this->dom     = $dom;
		$this->xpath   = $xpath;
		$this->options = array_merge( $this->default_options, $options );
	}

	/**
	 * Prepare HTML for parsing by inserting sentinel text nodes, serialising
	 * all text content, then removing the sentinels.
	 *
	 * @param iterable $items DOMElement items (insertion points).
	 *
	 * @return string Serialised text with sentinel markers.
	 */
	private function prepare_for_parsing( $items ) {
		$existing = $this->xpath->query( $this->get_expression_for_existing_ads() );
		$created  = [];

		foreach ( $existing as $node ) {
			$start = $this->dom->createTextNode( self::START_EXISTING_AD );
			$node->parentNode->insertBefore( $start, $node ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			$created[] = $start;

			$end = $this->dom->createTextNode( self::END_EXISTING_AD );
			$node->parentNode->insertBefore( $end, $node->nextSibling ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			$created[] = $end;
		}

		foreach ( $items as $node ) {
			$point     = $this->dom->createTextNode( self::INSERTION_POINT );
			$created[] = $point;

			if ( $this->options['before'] ) {
				$node->parentNode->insertBefore( $point, $node ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			} else {
				$node->parentNode->insertBefore( $point, $node->nextSibling ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			}
		}

		$nodes = $this->xpath->query( '//text()[not(parent::script or parent::style)]' );
		$r     = '';
		foreach ( $nodes as $n ) {
			$r .= $n->data ?? '';
		}
		unset( $nodes );

		// Remove in reverse order to avoid parent-removal edge cases.
		foreach ( array_reverse( $created ) as $node ) {
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			if ( $node->parentNode ) {
				$node->parentNode->removeChild( $node ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			}
		}
		unset( $created );

		return $r;
	}

	/**
	 * Build the words_between array from the serialised text.
	 *
	 * Uses str_replace + explode instead of preg_split() on fixed sentinel
	 * strings — avoids the regex engine entirely.
	 *
	 * Accumulates a running integer word count instead of a string array
	 * (string arrays only built in debug mode).
	 *
	 * @param string $text Serialised text with sentinel markers.
	 *
	 * @return array Word count data between items.
	 */
	protected function prepare_words_between( $text ) {
		// Replace each sentinel with null-byte delimiters, then split.
		$flat  = str_replace(
			[
				self::START_EXISTING_AD,
				self::END_EXISTING_AD,
				self::INSERTION_POINT,
			],
			[
				self::SENTINEL_SEP . self::START_EXISTING_AD . self::SENTINEL_SEP,
				self::SENTINEL_SEP . self::END_EXISTING_AD . self::SENTINEL_SEP,
				self::SENTINEL_SEP . self::INSERTION_POINT . self::SENTINEL_SEP,
			],
			$text
		);
		$parts = explode( self::SENTINEL_SEP, $flat );
		unset( $flat );

		$r               = [];
		$prev_is_ad      = false;
		$doing_ad        = 0; // phpcs:ignore -- tracks nested ad depth, kept for correctness.
		$prev_ip         = false;
		$debug           = $this->options['debug'];
		$prev_word_count = 0;
		$prev_text       = [];

		foreach ( $parts as $part ) {
			if ( '' === $part ) {
				continue;
			}

			if ( self::START_EXISTING_AD === $part ) {
				if ( $prev_ip ) {
					$r               = $this->add_after_insertion_point( $r, $prev_word_count, $debug ? $prev_text : [], true );
					$prev_ip         = false;
					$prev_word_count = 0;
					$prev_text       = [];
				}
				$prev_word_count = 0;
				$prev_text       = [];
				$prev_is_ad      = true;
				++$doing_ad;
			} elseif ( self::END_EXISTING_AD === $part ) {
				--$doing_ad;
			} elseif ( self::INSERTION_POINT === $part ) {
				if ( $prev_ip ) {
					$r       = $this->add_after_insertion_point( $r, $prev_word_count, $debug ? $prev_text : [] );
					$prev_ip = false;
				}

				$data = [
					self::PREV_NUMBER => $prev_word_count,
					self::PREV_IS_AD  => $prev_is_ad,
				];
				if ( $debug ) {
					$data[ self::PREV_WORDS ] = $prev_text;
				}
				$r[] = $data;

				$prev_word_count = 0;
				$prev_text       = [];
				$prev_is_ad      = false;
				$prev_ip         = true;
			} else {
				$prepared         = self::prepare_text( $part );
				$prev_word_count += self::calc_words( $prepared );
				if ( $debug ) {
					$prev_text[] = $prepared;
				}
			}
		}
		unset( $parts );

		if ( $prev_ip ) {
			$r = $this->add_after_insertion_point( $r, $prev_word_count, $debug ? $prev_text : [] );
		}

		return $r;
	}

	/**
	 * Update NEXT_NUMBER / NEXT_IS_AD on the last entry in $r.
	 *
	 * Uses direct index access instead of array_pop + push.
	 *
	 * @param array $r          Word count data array.
	 * @param int   $word_count Pre-computed word count.
	 * @param array $prev_text  Text chunks (debug mode only).
	 * @param bool  $next_is_ad Whether an ad follows the insertion point.
	 *
	 * @return array Updated data array.
	 */
	private function add_after_insertion_point( $r, $word_count, $prev_text = [], $next_is_ad = false ) {
		$idx = count( $r ) - 1;

		$r[ $idx ][ self::NEXT_NUMBER ] = $word_count;
		$r[ $idx ][ self::NEXT_IS_AD ]  = $next_is_ad;

		if ( $this->options['debug'] ) {
			$r[ $idx ][ self::NEXT_WORDS ] = $prev_text;
		}

		return $r;
	}

	/**
	 * Build prefix-sum arrays for O(1) word-count lookups in calc_offset().
	 *
	 * Prefix_prev[i] = cumulative words from the last ad (or document start)
	 *                  up to position i, plus whether an ad was seen.
	 * prefix_next[i] = cumulative words from position i to the next ad
	 *                  (or document end), plus whether an ad was seen.
	 *
	 * These replace the O(n) inner loops in calc_offset() with O(1) lookups,
	 * reducing the full algorithm from O(n²) to O(n).
	 *
	 * @param array $wb Reference to words_between.
	 * @param int   $l  Length of $wb.
	 *
	 * @return array{prev: array, next: array}
	 */
	private function build_prefix_sums( array &$wb, $l ) {
		$prefix_prev = [];
		$running     = 0;
		$has_ad      = false;

		for ( $i = 0; $i < $l; $i++ ) {
			if ( $wb[ $i ][ self::PREV_IS_AD ] ) {
				$running = 0;
				$has_ad  = true;
			}
			$running          += $wb[ $i ][ self::PREV_NUMBER ];
			$prefix_prev[ $i ] = [
				'words'  => $running,
				'has_ad' => $has_ad,
			];
		}

		$prefix_next = [];
		$running     = 0;
		$has_ad      = false;

		for ( $i = $l - 1; $i >= 0; $i-- ) {
			if ( $wb[ $i ][ self::NEXT_IS_AD ] ) {
				$running = 0;
				$has_ad  = true;
			}
			$running          += $wb[ $i ][ self::NEXT_NUMBER ];
			$prefix_next[ $i ] = [
				'words'  => $running,
				'has_ad' => $has_ad,
			];
		}

		return [
			'prev' => $prefix_prev,
			'next' => $prefix_next,
		];
	}

	/**
	 * Patch prefix_next from $from downward after placing a new ad.
	 * Called once per found offset — O(i).
	 *
	 * @param array $prefix Reference to prefix arrays.
	 * @param array $wb     Reference to words_between.
	 * @param int   $from   Index where new ad was placed.
	 */
	private function patch_prefix_next( array &$prefix, array &$wb, $from ) {
		$running = 0;
		$has_ad  = false;

		for ( $i = $from; $i >= 0; $i-- ) {
			if ( $wb[ $i ][ self::NEXT_IS_AD ] ) {
				$running = 0;
				$has_ad  = true;
			}
			$running             += $wb[ $i ][ self::NEXT_NUMBER ];
			$prefix['next'][ $i ] = [
				'words'  => $running,
				'has_ad' => $has_ad,
			];
		}
	}

	/**
	 * Patch prefix_prev from $from upward after placing a new ad.
	 * Called once per found offset — O(n-from).
	 *
	 * @param array $prefix Reference to prefix arrays.
	 * @param array $wb     Reference to words_between.
	 * @param int   $from   Index where new ad was placed.
	 * @param int   $l      Total length.
	 */
	private function patch_prefix_prev( array &$prefix, array &$wb, $from, $l ) {
		$running = $from > 0 ? (int) $prefix['prev'][ $from - 1 ]['words'] : 0;
		$has_ad  = $from > 0 ? (bool) $prefix['prev'][ $from - 1 ]['has_ad'] : false;

		for ( $i = $from; $i < $l; $i++ ) {
			if ( $wb[ $i ][ self::PREV_IS_AD ] ) {
				$running = 0;
				$has_ad  = true;
			}
			$running             += $wb[ $i ][ self::PREV_NUMBER ];
			$prefix['prev'][ $i ] = [
				'words'  => $running,
				'has_ad' => $has_ad,
			];
		}
	}

	/**
	 * Get word counts between items.
	 *
	 * @param string|array $items XPath query string or array of DOMElements.
	 *
	 * @return array
	 */
	public function get_words_between( $items ) {
		if ( is_string( $items ) ) {
			$node_list = $this->xpath->query( $items );
			$arr       = [];
			foreach ( $node_list as $node ) {
				$arr[] = $node;
			}
			unset( $node_list );
			$items = $arr;
		}

		$text                      = $this->prepare_for_parsing( $items );
		$this->words_between       = $this->prepare_words_between( $text );
		$this->words_between_count = count( $this->words_between );
		unset( $text );

		return $this->words_between;
	}

	/**
	 * Calculate new offsets.
	 *
	 * Builds prefix-sum arrays once in O(n), then all offset calculations
	 * run in O(n) total instead of O(n²).
	 *
	 * @param array        $offsets Existing offsets.
	 * @param string|array $items   XPath query string or array of DOMElements.
	 *
	 * @return array New offsets.
	 */
	public function calc_offsets( array $offsets, $items ) {
		$this->get_words_between( $items );

		$l           = $this->words_between_count;
		$from_bottom = $this->options['paragraph_select_from_bottom'];
		$wb          = &$this->words_between;

		if ( $from_bottom ) {
			rsort( $offsets );
		} else {
			sort( $offsets );
		}

		$prefix                = $this->build_prefix_sums( $wb, $l );
		$new_offsets           = [];
		$this->previous_offset = false;

		$this->debug( '///// debug start: ' . implode( ', ', $offsets ) . ' /////' );

		foreach ( $offsets as $offset ) {
			$new_offset = $from_bottom
				? $this->calc_offset_from_bottom( $offset, $l, $wb, $prefix )
				: $this->calc_offset( $offset, $l, $wb, $prefix );

			if ( false === $new_offset ) {
				break;
			}

			$new_offsets[] = $new_offset;
		}

		$this->debug( '///// debug end /////' );

		return $new_offsets;
	}

	/**
	 * Calculate next valid offset (top-down) using O(1) prefix lookups.
	 *
	 * @param int   $offset Starting offset.
	 * @param int   $l      Length of words_between.
	 * @param array $wb     Reference to words_between.
	 * @param array $prefix Prefix sum arrays.
	 *
	 * @return int|false
	 */
	private function calc_offset( $offset, $l, array &$wb, array &$prefix ) {
		if ( false !== $this->previous_offset ) {
			$offset = max( $offset, $this->previous_offset + 1 );
		}

		$this->debug( "///// offset: $offset /////" );

		$min_words = $this->options['words_between_repeats'];

		for ( $i = $offset; $i < $l; $i++ ) {
			if ( ! isset( $wb[ $i ] ) ) {
				continue;
			}

			$prev_num    = $prefix['prev'][ $i ]['words'];
			$prev_has_ad = $prefix['prev'][ $i ]['has_ad'];

			if ( $this->options['debug'] ) {
				$this->debug( implode( ' / ', $wb[ $i ][ self::PREV_WORDS ] ?? [] ) );
			}

			$this->debug( $i . ': prev: ' . $prev_num );

			if ( $prev_num < $min_words
				&& ( $this->options['require_before_first'] || $prev_has_ad || false !== $this->previous_offset )
			) {
				continue;
			}

			$next_num    = $prefix['next'][ $i ]['words'];
			$next_has_ad = $prefix['next'][ $i ]['has_ad'];

			if ( $next_num < $min_words
				&& ( $this->options['require_after_last'] || $next_has_ad )
			) {
				continue;
			}

			$this->previous_offset = $i;

			$wb[ $i ][ self::NEXT_IS_AD ] = true;
			if ( isset( $wb[ $i + 1 ] ) ) {
				$wb[ $i + 1 ][ self::PREV_IS_AD ] = true;
			}

			// Patch prefix_next from $i downward to reflect the new ad.
			$this->patch_prefix_next( $prefix, $wb, $i );
			// Patch prefix_prev from $i+1 upward to reflect the new ad boundary.
			if ( isset( $wb[ $i + 1 ] ) ) {
				$this->patch_prefix_prev( $prefix, $wb, $i + 1, $l );
			}

			$this->debug( "found $i" );
			return $i;
		}

		return false;
	}

	/**
	 * Calculate next valid offset (bottom-up) using O(1) prefix lookups.
	 *
	 * @param int   $offset Starting offset.
	 * @param int   $l      Length of words_between.
	 * @param array $wb     Reference to words_between.
	 * @param array $prefix Prefix sum arrays.
	 *
	 * @return int|false
	 */
	private function calc_offset_from_bottom( $offset, $l, array &$wb, array &$prefix ) {
		if ( false !== $this->previous_offset ) {
			$offset = min( $offset, $this->previous_offset - 1 );
		}

		$this->debug( "///// offset: $offset /////" );

		$min_words = $this->options['words_between_repeats'];

		for ( $i = $offset; $i >= 0; $i-- ) {
			if ( ! isset( $wb[ $i ] ) ) {
				continue;
			}

			// In bottom-up mode, next/prev roles are mirrored.
			$prev_num    = $prefix['next'][ $i ]['words'];
			$prev_has_ad = $prefix['next'][ $i ]['has_ad'];

			if ( $this->options['debug'] ) {
				$this->debug( implode( ' / ', $wb[ $i ][ self::NEXT_WORDS ] ?? [] ) );
			}

			$this->debug( $i . ': prev: ' . $prev_num );

			if ( $prev_num < $min_words
				&& ( $this->options['require_before_first'] || $prev_has_ad || false !== $this->previous_offset )
			) {
				continue;
			}

			$next_num    = $prefix['prev'][ $i ]['words'];
			$next_has_ad = $prefix['prev'][ $i ]['has_ad'];

			if ( $next_num < $min_words
				&& ( $this->options['require_after_last'] || $next_has_ad )
			) {
				continue;
			}

			$this->previous_offset = $i;

			$wb[ $i ][ self::PREV_IS_AD ] = true;
			if ( isset( $wb[ $i - 1 ] ) ) {
				$wb[ $i - 1 ][ self::NEXT_IS_AD ] = true;
			}

			// Patch prefix_prev from $i upward to reflect the new ad.
			$this->patch_prefix_prev( $prefix, $wb, $i, $l );
			// Patch prefix_next from $i-1 downward to reflect the new ad boundary.
			if ( isset( $wb[ $i - 1 ] ) ) {
				$this->patch_prefix_next( $prefix, $wb, $i - 1 );
			}

			$this->debug( "found $i" );
			return $i;
		}

		return false;
	}

	/**
	 * Get XPath expression for selecting existing ads.
	 * Result is cached — computed at most once per instance.
	 *
	 * @return string
	 */
	protected function get_expression_for_existing_ads() {
		if ( null !== $this->ads_expr ) {
			return $this->ads_expr;
		}

		$expr = [
			"//div[@class and contains(concat(' ', normalize-space(@class), ' '), ' %s')]",
			"//comment()[contains(.,'data-tcf=\"waiting-for-consent')]",
		];

		$this->ads_expr = sprintf(
			implode( ' | ', $expr ),
			sanitize_html_class( wp_advads()->get_frontend_prefix() )
		);

		return $this->ads_expr;
	}

	/**
	 * Prepare text for word counting.
	 * Single preg_replace() combining whitespace normalisation and punctuation removal.
	 *
	 * @param string $text Raw text.
	 *
	 * @return string Normalised text.
	 */
	protected static function prepare_text( $text ) {
		return preg_replace(
			[ '/\s+/', '/[.(),;:!?%#$¿\'"_+=\\\\\/\-]+/' ],
			[ ' ', '' ],
			trim( $text )
		);
	}

	/**
	 * Calculate word count.
	 *
	 * Uses Unicode-aware /\s+/u splitting — correctly handles CJK, Arabic,
	 * Cyrillic, and other multibyte scripts. str_word_count() only counts
	 * ASCII alpha sequences and returns 0 for non-Latin content.
	 *
	 * @param string|array $text Text or array of text chunks.
	 *
	 * @return int Word count.
	 */
	protected static function calc_words( $text ) {
		if ( is_array( $text ) ) {
			$text = implode( ' ', $text );
		}

		if ( '' === $text ) {
			return 0;
		}

		$words = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
		return is_array( $words ) ? count( $words ) : 0;
	}

	/**
	 * Print debug info.
	 *
	 * @param string $str Message.
	 */
	protected function debug( $str ) {
		if ( $this->options['debug'] ) {
			echo "\n" . esc_html( $str );
		}
	}

	/**
	 * Check if 'Before Content' placement can be used.
	 * Short-circuits as soon as the word threshold is met.
	 *
	 * @return bool
	 */
	public function can_inject_before_content_placement() {
		$existing = $this->xpath->query( '(' . $this->get_expression_for_existing_ads() . ')[1]' );

		$last = null;
		foreach ( $existing as $node ) {
			$last = $node;
		}

		$texts = null !== $last
			? $this->xpath->query( './/preceding::text()[not(parent::script or parent::style)]', $last )
			: $this->xpath->query( '//text()[not(parent::script or parent::style)]' );

		$min = $this->options['words_between_repeats'];
		$l   = 0;

		foreach ( $texts as $text ) {
			$l += self::calc_words( self::prepare_text( $text->data ?? '' ) );
			if ( $l >= $min ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check if 'After Content' placement can be used.
	 * DOM sentinel is always rolled back via remove_sentinel().
	 *
	 * @return bool
	 */
	public function can_inject_after_content_placement() {
		$sentinel  = null;
		$last_elem = $this->dom->documentElement->lastChild ?? null;

		if ( null !== $last_elem ) {
			$sentinel = $this->dom->createTextNode( '/' );
			$last_elem->appendChild( $sentinel );
		}

		$existing = $this->xpath->query(
			$this->get_expression_for_existing_ads() . '/following::node()[1]'
		);

		$last = null;
		foreach ( $existing as $node ) {
			$last = $node;
		}

		$min = $this->options['words_between_repeats'];
		$l   = 0;

		if ( null !== $last ) {
			if ( $last instanceof DOMText ) {
				$l += self::calc_words( self::prepare_text( $last->data ) );
				if ( $l >= $min ) {
					$this->remove_sentinel( $sentinel );
					return true;
				}
			}
			$texts = $this->xpath->query(
				'.//following::text()[not(parent::script or parent::style)]',
				$last
			);
		} else {
			$texts = $this->xpath->query( '//text()[not(parent::script or parent::style)]' );
		}

		foreach ( $texts as $text ) {
			$l += self::calc_words( self::prepare_text( $text->data ) );
			if ( $l >= $min ) {
				$this->remove_sentinel( $sentinel );
				return true;
			}
		}

		$this->remove_sentinel( $sentinel );
		return false;
	}

	/**
	 * Remove the sentinel node, if one was created.
	 *
	 * @param DOMText|null $sentinel Sentinel node or null.
	 */
	private function remove_sentinel( ?DOMText $sentinel ) {
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		if ( null !== $sentinel && null !== $sentinel->parentNode ) {
			$sentinel->parentNode->removeChild( $sentinel ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		}
	}
}
