<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Tidying up a section's code: same thing on screen, half the lines.
 *
 * A style restyle is ADDITIVE by construction — append_section_css() appends the new rule at the
 * end of the <style>, and it wins by cascade order. That is the right choice (an additive patch
 * cannot "miss its anchor" and break the section) but it has a price: after twenty restyles the
 * same selector shows up twelve times and half the declarations are dead code the browser throws
 * away on every load. On this site a real section had 117 rules of which 57 were duplicates, with
 * `.pasticcerie .grid` written thirteen times.
 *
 * There is NO AI here: that is the very thing that produced the mess, it is not deterministic, and
 * over 13 KB of CSS some visual detail would change without anyone noticing. Tidying up is an
 * exactly solvable problem, and this solves it exactly.
 *
 * What it does to the CSS:
 *  - merges rules with the SAME selector in the SAME context (inside the same @media, never
 *    across: merging a desktop rule with its mobile variant would destroy the layout);
 *  - inside the merged block it keeps the last declaration of each property — the one the browser
 *    was already applying — preserving the original order, which is also what keeps the relation
 *    between shorthand and longhand correct (padding after padding-left still wins);
 *  - drops the blocks left empty and the restyle marker comments.
 *
 * The two things it does NOT do, because they are how this operation really breaks:
 *  - it does not merge a rule past another one that could hit the same elements with the same
 *    properties: moving it would change its cascade order, and so the result (see can_move);
 *  - it does not touch two declarations of the same property that are ADJACENT in the original
 *    block: that is how a fallback is written (color: red; color: color-mix(…)), not a duplicate.
 *
 * On the HTML it stays extremely cautious — repeated classes and attributes within the same tag,
 * excess blank lines — because whitespace in HTML is significant. Classes that live in the CSS but
 * never in the HTML are only REPORTED: they can come from a {{#loop}} or from JS at runtime.
 */
class AIPB_Tidy {

	/** The comment a style restyle leaves behind, and that tidying takes away again. */
	const PATCH_MARKER = '/* restyle */';

	/** The template's annotations ({{t:…}}, inside the CSS too) put out of the parser's reach. */
	protected static $mask = array();

	/* -------------------------------- Entry ---------------------------------- */

	/**
	 * Tidies a section's complete code (HTML + <style> + <script>).
	 *
	 * @param string $code The section's readable code (the form seen in the editor).
	 * @return array { code: string, report: array } — the report is ready to display as is.
	 */
	public static function section( $code ) {
		$code = (string) $code;
		$out  = array(
			'css'   => array( 'rules_before' => 0, 'rules_after' => 0, 'merged' => 0, 'dropped' => 0, 'empty' => 0, 'kept' => 0, 'top' => array(), 'blocked' => array() ),
			'html'  => array( 'classes' => 0, 'attrs' => 0, 'blank' => 0 ),
			'js'    => array( 'blank' => 0, 'trailing' => 0 ),
			'notes' => array(),
			'bytes' => array( 'before' => strlen( $code ), 'after' => 0 ),
		);

		// The HTML is what the CSS needs: knowing which classes live together on the same tag is
		// what makes it possible to tell whether two selectors can hit the same element (see
		// can_move).
		$ctx = self::html_context( $code );

		$new = preg_replace_callback(
			'#(<style[^>]*>)(.*?)(</style>)#is',
			function ( $m ) use ( &$out, $ctx ) {
				$res = self::css( $m[2], $ctx );
				foreach ( $res['report'] as $k => $v ) {
					if ( 'top' === $k || 'blocked' === $k ) {
						$out['css'][ $k ] = array_merge( $out['css'][ $k ], $v );
					} elseif ( isset( $out['css'][ $k ] ) ) {
						$out['css'][ $k ] += $v;
					}
				}
				if ( ! empty( $res['notes'] ) ) {
					$out['notes'] = array_merge( $out['notes'], $res['notes'] );
				}
				return $m[1] . "\n" . $res['css'] . "\n" . $m[3];
			},
			$code
		);
		if ( null === $new ) {
			return array( 'code' => $code, 'report' => $out ); // preg failed: better to touch nothing
		}

		$new = self::tidy_html( $new, $out['html'] );
		$new = preg_replace_callback(
			'#(<script[^>]*>)(.*?)(</script>)#is',
			function ( $m ) use ( &$out ) {
				return $m[1] . self::tidy_js( $m[2], $out['js'] ) . $m[3];
			},
			$new
		);

		// Classes alive only in the CSS: suspect, not condemned.
		$orphans = self::orphan_classes( $new );
		if ( $orphans ) {
			$out['orphans'] = $orphans;
		}

		$out['bytes']['after'] = strlen( $new );
		return array( 'code' => $new, 'report' => $out );
	}

	/**
	 * Tidies a stylesheet. Also used after every restyle, so the code does not bloat: the restyle
	 * stays additive and infallible, but the tail of the <style> does not grow forever.
	 *
	 * @param string $css
	 * @param array  $ctx Context from the section's HTML: see html_context().
	 * @return array { css: string, report: array, notes: array }
	 */
	public static function css( $css, $ctx = null ) {
		$css    = (string) $css;
		$report = array( 'rules_before' => 0, 'rules_after' => 0, 'merged' => 0, 'dropped' => 0, 'empty' => 0, 'kept' => 0, 'top' => array(), 'blocked' => array() );
		$notes  = array();

		// A control directive inside the CSS ({{#if}} around some rules) would make the sheet
		// unparsable as CSS: in that case nothing is touched, and we say so.
		if ( preg_match( '/\{\{\s*(?:[#\/]|else)/', $css ) ) {
			$notes[] = __( 'The CSS contains {{#…}} directives: left untouched so as not to break them.', 'aipb' );
			return array( 'css' => $css, 'report' => $report, 'notes' => $notes );
		}

		self::$mask = array();
		$masked     = self::protect( $css );
		$masked     = preg_replace( '#/\*\s*restyle\s*\*/#i', '', $masked );

		$nodes = self::parse( $masked );
		if ( null === $nodes ) {
			self::$mask = array();
			$notes[]    = __( 'CSS could not be parsed: left untouched.', 'aipb' );
			return array( 'css' => $css, 'report' => $report, 'notes' => $notes );
		}

		$report['rules_before'] = self::count_rules( $nodes );
		$nodes                  = self::tidy_nodes( $nodes, $ctx, $report );
		$report['rules_after']  = self::count_rules( $nodes );

		$out        = self::restore( self::render( $nodes, 0 ) );
		self::$mask = array();

		arsort( $report['top'] );
		$report['top'] = array_slice( $report['top'], 0, 6, true );

		return array( 'css' => $out, 'report' => $report, 'notes' => $notes );
	}

	/* --------------------------- Annotations, masked ------------------------- */

	/**
	 * {{t:title}} inside a CSS value is perfectly normal (1459 templates on this site have one).
	 * The double braces would confuse the block counting: they become an opaque placeholder made of
	 * a control character, which no real CSS contains.
	 */
	protected static function protect( $css ) {
		return preg_replace_callback(
			'/\{\{.*?\}\}/s',
			function ( $m ) {
				self::$mask[] = $m[0];
				return "\x02" . ( count( self::$mask ) - 1 ) . "\x03";
			},
			$css
		);
	}

	protected static function restore( $s ) {
		if ( ! self::$mask ) {
			return $s;
		}
		$mask = self::$mask;
		return preg_replace_callback(
			'/\x02(\d+)\x03/',
			function ( $m ) use ( $mask ) {
				$i = (int) $m[1];
				return isset( $mask[ $i ] ) ? $mask[ $i ] : '';
			},
			$s
		);
	}

	/* -------------------------------- Parser --------------------------------- */

	/**
	 * From text to tree. Three node types:
	 *   rule    { sel, decls[] }
	 *   at      { prelude, children[] }  — @media/@supports/@container/@layer: recurse inside
	 *   raw     { text }                 — @keyframes/@font-face/@import/comments: untouchable
	 *
	 * @return array|null null when the sheet is malformed (unbalanced blocks).
	 */
	protected static function parse( $css ) {
		$nodes = array();
		$len   = strlen( $css );
		$i     = 0;
		$guard = 0;

		while ( $i < $len ) {
			if ( ++$guard > 100000 ) {
				return null;
			}
			if ( ctype_space( $css[ $i ] ) ) {
				$i++;
				continue;
			}

			// Comment: opaque node, it stays where it is.
			if ( '/' === $css[ $i ] && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$end = strpos( $css, '*/', $i + 2 );
				if ( false === $end ) {
					return null;
				}
				$text = trim( substr( $css, $i, $end + 2 - $i ) );
				if ( '' !== $text ) {
					$nodes[] = array( 'type' => 'raw', 'text' => $text );
				}
				$i = $end + 2;
				continue;
			}

			if ( '@' === $css[ $i ] ) {
				$j = $i;
				while ( $j < $len && '{' !== $css[ $j ] && ';' !== $css[ $j ] ) {
					$j++;
				}
				if ( $j >= $len ) {
					return null;
				}
				$prelude = trim( substr( $css, $i, $j - $i ) );
				if ( ';' === $css[ $j ] ) {
					$nodes[] = array( 'type' => 'raw', 'text' => $prelude . ';' );
					$i       = $j + 1;
					continue;
				}
				$block = self::extract_block( $css, $j );
				if ( null === $block ) {
					return null;
				}
				$name = strtolower( preg_replace( '/^@([a-zA-Z-]+).*/s', '$1', $prelude ) );
				$i    = $block['end'];

				// Inside @keyframes the "selectors" are 0%/from/to: merging them changes the
				// animation.
				if ( in_array( $name, array( 'media', 'supports', 'container', 'layer' ), true ) ) {
					$kids = self::parse( $block['inner'] );
					if ( null === $kids ) {
						return null;
					}
					$nodes[] = array( 'type' => 'at', 'prelude' => $prelude, 'children' => $kids );
				} else {
					$nodes[] = array( 'type' => 'raw', 'text' => $prelude . ' {' . rtrim( $block['inner'] ) . "\n}" );
				}
				continue;
			}

			// Ordinary rule.
			$j = $i;
			while ( $j < $len && '{' !== $css[ $j ] ) {
				if ( '}' === $css[ $j ] ) {
					return null; // closing brace with no opening one: not CSS we know how to read
				}
				$j++;
			}
			if ( $j >= $len ) {
				return null;
			}
			$block = self::extract_block( $css, $j );
			if ( null === $block ) {
				return null;
			}
			$sel = trim( substr( $css, $i, $j - $i ) );
			$i   = $block['end'];
			if ( '' === $sel ) {
				continue;
			}
			// A rule that itself contains a block is nested CSS: out of scope.
			if ( false !== strpos( $block['inner'], '{' ) ) {
				return null;
			}
			$nodes[] = array( 'type' => 'rule', 'sel' => $sel, 'decls' => self::decls( $block['inner'], count( $nodes ) ) );
		}

		return $nodes;
	}

	/** Balanced { … } block, respecting strings. null when it never closes. */
	protected static function extract_block( $css, $pos ) {
		$len   = strlen( $css );
		$depth = 0;
		$in    = false;
		$q     = '';
		for ( $i = $pos; $i < $len; $i++ ) {
			$c = $css[ $i ];
			if ( $in ) {
				if ( $c === $q && '\\' !== $css[ $i - 1 ] ) {
					$in = false;
				}
				continue;
			}
			if ( '"' === $c || "'" === $c ) {
				$in = true;
				$q  = $c;
				continue;
			}
			if ( '{' === $c ) {
				$depth++;
			} elseif ( '}' === $c ) {
				$depth--;
				if ( 0 === $depth ) {
					return array( 'inner' => substr( $css, $pos + 1, $i - $pos - 1 ), 'end' => $i + 1 );
				}
			}
		}
		return null;
	}

	/**
	 * A block's declarations. `origin` is the block they came from: it is what recognises
	 * fallbacks, which are adjacent WITHIN THE SAME block and must never be deduplicated.
	 */
	protected static function decls( $body, $origin ) {
		$out = array();
		foreach ( self::split_top( $body, ';' ) as $piece ) {
			$piece = trim( $piece );
			if ( '' === $piece ) {
				continue;
			}
			// Comments between declarations are lost here: they are noise, not structure.
			$piece = trim( preg_replace( '#/\*.*?\*/#s', '', $piece ) );
			if ( '' === $piece ) {
				continue;
			}
			$colon = strpos( $piece, ':' );
			if ( false === $colon || 0 === $colon ) {
				continue;
			}
			$prop = trim( substr( $piece, 0, $colon ) );
			$val  = trim( substr( $piece, $colon + 1 ) );
			if ( '' === $prop || '' === $val ) {
				continue;
			}
			$out[] = array(
				'prop'      => strtolower( $prop ),
				'name'      => $prop, // as written: --Var and --var are different properties
				'value'     => $val,
				'important' => (bool) preg_match( '/!\s*important\s*$/i', $val ),
				'origin'    => $origin,
			);
		}
		// Custom properties are case-sensitive: the comparison key has to account for that.
		foreach ( $out as &$d ) {
			if ( 0 === strpos( $d['name'], '--' ) ) {
				$d['prop'] = $d['name'];
			}
		}
		unset( $d );
		return $out;
	}

	/** Splits on the separator, ignoring the ones inside (), [], "" and ''. */
	protected static function split_top( $s, $sep ) {
		$parts = array();
		$buf   = '';
		$par   = 0;
		$brk   = 0;
		$in    = false;
		$q     = '';
		$len   = strlen( $s );
		for ( $i = 0; $i < $len; $i++ ) {
			$c = $s[ $i ];
			if ( $in ) {
				$buf .= $c;
				if ( $c === $q && ( 0 === $i || '\\' !== $s[ $i - 1 ] ) ) {
					$in = false;
				}
				continue;
			}
			if ( '"' === $c || "'" === $c ) {
				$in  = true;
				$q   = $c;
				$buf .= $c;
				continue;
			}
			if ( '(' === $c ) {
				$par++;
			} elseif ( ')' === $c ) {
				$par = max( 0, $par - 1 );
			} elseif ( '[' === $c ) {
				$brk++;
			} elseif ( ']' === $c ) {
				$brk = max( 0, $brk - 1 );
			}
			if ( $c === $sep && 0 === $par && 0 === $brk ) {
				$parts[] = $buf;
				$buf     = '';
				continue;
			}
			$buf .= $c;
		}
		$parts[] = $buf;
		return $parts;
	}

	/* -------------------------------- Tidying -------------------------------- */

	/** Applies merging and deduplication to a node list, recursing into at-rules. */
	protected static function tidy_nodes( $nodes, $ctx, &$report ) {
		foreach ( $nodes as &$n ) {
			if ( 'at' === $n['type'] ) {
				$n['children'] = self::tidy_nodes( $n['children'], $ctx, $report );
			}
		}
		unset( $n );

		$nodes = self::merge_rules( $nodes, $ctx, $report );

		$out = array();
		foreach ( $nodes as $n ) {
			if ( 'rule' === $n['type'] ) {
				$n['decls'] = self::dedupe_decls( $n['decls'], $report );
				if ( ! $n['decls'] ) {
					$report['empty']++;
					continue; // block left empty: no use to anyone
				}
			} elseif ( 'at' === $n['type'] && ! $n['children'] ) {
				$report['empty']++;
				continue;
			}
			$out[] = $n;
		}
		return $out;
	}

	/**
	 * Merges the occurrences of the same selector, DOWNWARDS: the surviving block is the last one,
	 * not the first. It is the only safe direction — declarations that were winning because they
	 * were written last go on doing so with respect to the whole rest of the sheet.
	 */
	protected static function merge_rules( $nodes, $ctx, &$report ) {
		$groups = array();
		foreach ( $nodes as $i => $n ) {
			if ( 'rule' === $n['type'] ) {
				$groups[ self::sel_key( $n['sel'] ) ][] = $i;
			}
		}

		$drop = array();
		foreach ( $groups as $key => $idxs ) {
			if ( count( $idxs ) < 2 ) {
				continue;
			}
			$target = end( $idxs );
			$moved  = array();
			foreach ( $idxs as $from ) {
				if ( $from === $target ) {
					continue;
				}
				$by = self::can_move( $nodes, $from, $target, $key, $ctx );
				if ( true === $by ) {
					$moved[] = $from;
				} else {
					$report['kept']++;
					$report['blocked'][] = self::sel_label( $nodes[ $from ]['sel'] ) . '  ✗  ' . $by;
				}
			}
			if ( ! $moved ) {
				continue;
			}
			$decls = array();
			foreach ( $moved as $from ) {
				$decls         = array_merge( $decls, $nodes[ $from ]['decls'] );
				$drop[ $from ] = true;
				$report['merged']++;
			}
			$nodes[ $target ]['decls'] = array_merge( $decls, $nodes[ $target ]['decls'] );

			$label                   = self::sel_label( $nodes[ $target ]['sel'] );
			$report['top'][ $label ] = isset( $report['top'][ $label ] ) ? $report['top'][ $label ] + count( $moved ) : count( $moved );
		}

		$out = array();
		foreach ( $nodes as $i => $n ) {
			if ( ! isset( $drop[ $i ] ) ) {
				$out[] = $n;
			}
		}
		return $out;
	}

	/**
	 * Can rule $from be moved down to $to without changing what is seen?
	 *
	 * Yes, provided no rule in between can hit the same elements with the same properties: that
	 * rule wins today (it comes later), and after the move it would lose. This is the case where a
	 * "harmless" clean-up shifts the layout, and the only way not to do it is to ask first. Other
	 * occurrences of the same selector do not count: they all converge into the same block.
	 *
	 * @return true|string true when the move is safe, otherwise the rule that prevents it.
	 */
	protected static function can_move( $nodes, $from, $to, $key, $ctx ) {
		// For each property, whether the last declaration touching it is !important: an important
		// declaration always beats a normal one, whatever the order.
		$props = array();
		foreach ( $nodes[ $from ]['decls'] as $d ) {
			$props[ $d['prop'] ] = $d['important'];
		}
		$mine = self::selector_parts( $nodes[ $from ]['sel'] );

		for ( $i = $from + 1; $i < $to; $i++ ) {
			$n = $nodes[ $i ];
			if ( 'raw' === $n['type'] ) {
				continue;
			}
			$between = ( 'at' === $n['type'] ) ? self::flatten_rules( $n['children'] ) : array( $n );
			foreach ( $between as $r ) {
				if ( self::sel_key( $r['sel'] ) === $key ) {
					continue;
				}
				$hit = '';
				foreach ( $r['decls'] as $d ) {
					// Same property AND same importance: only then does order decide.
					if ( isset( $props[ $d['prop'] ] ) && $props[ $d['prop'] ] === $d['important'] ) {
						$hit = $d['prop'];
						break;
					}
				}
				if ( '' !== $hit && self::rules_collide( $mine, self::selector_parts( $r['sel'] ), $ctx ) ) {
					return self::sel_label( $r['sel'] ) . ' (' . $hit . ')';
				}
			}
		}
		return true;
	}

	/** Every rule inside a subtree, at-rules included. */
	protected static function flatten_rules( $nodes ) {
		$out = array();
		foreach ( $nodes as $n ) {
			if ( 'rule' === $n['type'] ) {
				$out[] = $n;
			} elseif ( 'at' === $n['type'] ) {
				$out = array_merge( $out, self::flatten_rules( $n['children'] ) );
			}
		}
		return $out;
	}

	/**
	 * Breaks a selector list down into what is needed to know whether two rules really compete over
	 * something: the SUBJECT (the last compound, which decides which element the rule lands on — in
	 * ".pasticcerie .grid" it is ".grid") and the SPECIFICITY of the whole selector.
	 *
	 * @return array list of { classes, id, tag, pseudo, wide, spec }
	 */
	protected static function selector_parts( $sel ) {
		$out = array();
		foreach ( self::split_top( $sel, ',' ) as $one ) {
			$one = trim( $one );
			if ( '' === $one ) {
				continue;
			}

			$compound = substr( $one, self::last_combinator( $one ) );
			$spec     = self::specificity( $one );

			// Inside :is()/:where()/:not()/:has() there can be anything, and specificity follows
			// rules all of its own: no attempt is even made to guess.
			if ( preg_match( '/:(is|where|not|has)\(/i', $compound ) ) {
				$out[] = array( 'classes' => array(), 'id' => '', 'tag' => '', 'pseudo' => '', 'wide' => true, 'spec' => $spec );
				continue;
			}

			preg_match_all( '/\.([A-Za-z0-9_\x{00a0}-\x{ffff}-]+)/u', $compound, $cm );
			preg_match( '/#([A-Za-z0-9_-]+)/', $compound, $im );
			preg_match( '/^([a-zA-Z][a-zA-Z0-9-]*)/', $compound, $tm );
			preg_match( '/::([a-zA-Z-]+)/', $compound, $pm );

			$classes = isset( $cm[1] ) ? $cm[1] : array();
			$out[]   = array(
				'classes' => $classes,
				'id'      => isset( $im[1] ) ? $im[1] : '',
				'tag'     => isset( $tm[1] ) ? strtolower( $tm[1] ) : '',
				// A pseudo-element is not THE element: ".media" and ".media::after" never overlap,
				// however alike they look.
				'pseudo'  => isset( $pm[1] ) ? strtolower( $pm[1] ) : '',
				// Neither classes, nor id, nor tag ("*", ":root" with a pseudo, a lone attribute):
				// too broad to rule anything out.
				'wide'    => ( ! $classes && empty( $im[1] ) && empty( $tm[1] ) ),
				'spec'    => $spec,
			);
		}
		return $out;
	}

	/** Offset just after the last top-level combinator: where the subject compound begins. */
	protected static function last_combinator( $sel ) {
		$last = 0;
		$par  = 0;
		$brk  = 0;
		$len  = strlen( $sel );

		for ( $i = 0; $i < $len; $i++ ) {
			$c = $sel[ $i ];
			if ( '(' === $c ) {
				$par++;
			} elseif ( ')' === $c ) {
				$par = max( 0, $par - 1 );
			} elseif ( '[' === $c ) {
				$brk++;
			} elseif ( ']' === $c ) {
				$brk = max( 0, $brk - 1 );
			} elseif ( 0 === $par && 0 === $brk && ( ' ' === $c || '>' === $c || '+' === $c || '~' === $c ) ) {
				$last = $i + 1;
			}
		}

		return $last;
	}

	/**
	 * Specificity (ids, classes/attributes/pseudo-classes, tags/pseudo-elements). It serves one
	 * question only: do two rules settle it on ORDER? Only at equal specificity. If one is more
	 * specific it always wins, before or after — and then moving it changes nothing.
	 */
	protected static function specificity( $sel ) {
		$s = preg_replace( '/::[a-zA-Z-]+/', ' ', $sel, -1, $pseudo_el );
		$a = preg_match_all( '/#[A-Za-z0-9_-]+/', $s );
		$b = preg_match_all( '/\.[A-Za-z0-9_-]+|\[[^\]]*\]|:[a-zA-Z-]+(?:\([^)]*\))?/', $s );
		$c = preg_match_all( '/(?:^|[\s>+~])([a-zA-Z][a-zA-Z0-9-]*)/', $s ) + $pseudo_el;
		return array( (int) $a, (int) $b, (int) $c );
	}

	/**
	 * Can two rules compete over the same element? It takes subjects that can land on the same node
	 * AND the same specificity. When in doubt: yes.
	 */
	protected static function rules_collide( $a_list, $b_list, $ctx ) {
		foreach ( $a_list as $a ) {
			foreach ( $b_list as $b ) {
				if ( $a['spec'] !== $b['spec'] ) {
					continue; // the more specific one always wins: order is beside the point
				}
				if ( $a['pseudo'] !== $b['pseudo'] ) {
					continue; // different elements (or one is ::after and the other is not)
				}
				if ( self::same_target( $a, $b, $ctx ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/** Can the two subjects describe the same tag? */
	protected static function same_target( $a, $b, $ctx ) {
		if ( $a['wide'] || $b['wide'] ) {
			return true;
		}
		if ( '' !== $a['id'] && '' !== $b['id'] ) {
			return $a['id'] === $b['id'];
		}
		if ( array_intersect( $a['classes'], $b['classes'] ) ) {
			return true;
		}
		// A class JS assigns at runtime can show up anywhere: nothing is ruled out.
		foreach ( array_merge( $a['classes'], $b['classes'] ) as $c ) {
			if ( isset( $ctx['dyn'][ $c ] ) ) {
				return true;
			}
		}
		// What is left are the cases the section's HTML can settle: two disjoint sets of classes, or
		// a class against a tag. They touch only if a real element satisfies both.
		foreach ( $ctx['els'] as $e ) {
			if ( self::el_matches( $e, $a ) && self::el_matches( $e, $b ) ) {
				return true;
			}
		}
		return false;
	}

	protected static function el_matches( $el, $p ) {
		if ( '' !== $p['tag'] && $p['tag'] !== $el['tag'] ) {
			return false;
		}
		return ! array_diff( $p['classes'], $el['cls'] );
	}

	/**
	 * Within a block the last declaration of each property wins — with two exceptions the browser
	 * honours and we honour with it: !important does not get overtaken by a later one without it,
	 * and two adjacent declarations of the same property from the same original block are a
	 * fallback for browsers that do not understand the second, not a duplicate.
	 */
	protected static function dedupe_decls( $decls, &$report ) {
		$keep = array_fill( 0, count( $decls ), true );
		$last = array();

		foreach ( $decls as $i => $d ) {
			$p = $d['prop'];
			if ( ! isset( $last[ $p ] ) ) {
				$last[ $p ] = $i;
				continue;
			}
			$j    = $last[ $p ];
			$prev = $decls[ $j ];

			if ( $prev['important'] && ! $d['important'] ) {
				$keep[ $i ] = false; // the new one would never reach the screen
				$report['dropped']++;
				continue;
			}
			if ( $prev['origin'] === $d['origin'] && $j === $i - 1 && $prev['value'] !== $d['value'] ) {
				$last[ $p ] = $i; // fallback: both stay
				continue;
			}
			$keep[ $j ] = false;
			$report['dropped']++;
			$last[ $p ] = $i;
		}

		$out = array();
		foreach ( $decls as $i => $d ) {
			if ( $keep[ $i ] ) {
				$out[] = $d;
			}
		}
		return $out;
	}

	/* -------------------------------- Printing ------------------------------- */

	protected static function render( $nodes, $depth ) {
		$pad = str_repeat( '  ', $depth );
		$out = array();
		foreach ( $nodes as $n ) {
			if ( 'raw' === $n['type'] ) {
				$out[] = $pad . str_replace( "\n", "\n" . $pad, $n['text'] );
				continue;
			}
			if ( 'at' === $n['type'] ) {
				$out[] = $pad . $n['prelude'] . " {\n" . self::render( $n['children'], $depth + 1 ) . "\n" . $pad . '}';
				continue;
			}
			$lines = array();
			foreach ( $n['decls'] as $d ) {
				$lines[] = $pad . '  ' . $d['name'] . ': ' . $d['value'] . ';';
			}
			$out[] = $pad . self::pretty_selector( $n['sel'] ) . " {\n" . implode( "\n", $lines ) . "\n" . $pad . '}';
		}
		return implode( "\n", $out );
	}

	/** Selector list one per line: it reads better and copes with long lists. */
	protected static function pretty_selector( $sel ) {
		$parts = array_filter( array_map( 'trim', self::split_top( $sel, ',' ) ) );
		return implode( ",\n", $parts );
	}

	/* -------------------------------- Helpers -------------------------------- */

	/** Comparison key: differences in spacing or in list order do not make two rules. */
	protected static function sel_key( $sel ) {
		$parts = array_filter( array_map(
			function ( $s ) {
				return preg_replace( '/\s*([>+~,])\s*/', '$1', preg_replace( '/\s+/', ' ', trim( $s ) ) );
			},
			self::split_top( $sel, ',' )
		) );
		sort( $parts );
		return implode( ',', $parts );
	}

	protected static function sel_label( $sel ) {
		$s = preg_replace( '/\s+/', ' ', trim( self::restore( $sel ) ) );
		return ( strlen( $s ) > 48 ) ? substr( $s, 0, 45 ) . '…' : $s;
	}

	protected static function count_rules( $nodes ) {
		$n = 0;
		foreach ( $nodes as $node ) {
			if ( 'rule' === $node['type'] ) {
				$n++;
			} elseif ( 'at' === $node['type'] ) {
				$n += self::count_rules( $node['children'] );
			}
		}
		return $n;
	}

	/* --------------------------------- HTML ---------------------------------- */

	/**
	 * The elements the section really contains — tags and the classes that sit together on them —
	 * plus the classes the JS names. It is the context that lets can_move() answer "these two
	 * selectors never meet" instead of having to assume they might.
	 *
	 * @return array { els: [ {tag, cls[]} ], dyn: { class => true } }
	 */
	protected static function html_context( $code ) {
		$html = preg_replace( '#<(style|script)[^>]*>.*?</\1>#is', '', $code );
		$els  = array();
		if ( preg_match_all( '/<([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/s', $html, $mm, PREG_SET_ORDER ) ) {
			foreach ( $mm as $m ) {
				$cls = array();
				if ( preg_match( '/\sclass\s*=\s*"([^"]*)"/i', $m[2], $cm ) ) {
					// A class written by a placeholder is unknowable: it does not count.
					$cls = preg_split( '/\s+/', trim( preg_replace( '/\{\{.*?\}\}/s', ' ', $cm[1] ) ), -1, PREG_SPLIT_NO_EMPTY );
				}
				$els[] = array( 'tag' => strtolower( $m[1] ), 'cls' => $cls );
			}
		}

		// The JS string literals: classList.add('open') makes '.open' possible anywhere.
		$dyn = array();
		if ( preg_match_all( '#<script[^>]*>(.*?)</script>#is', $code, $sm ) ) {
			if ( preg_match_all( '/[\'"`]([A-Za-z_][A-Za-z0-9 _-]*)[\'"`]/', implode( "\n", $sm[1] ), $jm ) ) {
				foreach ( $jm[1] as $s ) {
					foreach ( preg_split( '/\s+/', trim( $s ), -1, PREG_SPLIT_NO_EMPTY ) as $c ) {
						$dyn[ $c ] = true;
					}
				}
			}
		}

		return array( 'els' => $els, 'dyn' => $dyn );
	}

	/**
	 * Little is done to the HTML, and by choice: whitespace counts (a space between two
	 * inline-blocks is visible) and classes can be born at runtime. What is left are the changes
	 * that have no way of altering the rendering.
	 */
	protected static function tidy_html( $html, &$report ) {
		$html = self::dedupe_classes( $html, $report );
		$html = self::dedupe_attributes( $html, $report );

		// Three or more blank lines in a row become one, outside the blocks where text is literal.
		return self::outside_literal( $html, function ( $chunk ) use ( &$report ) {
			return preg_replace_callback(
				'/\n[ \t]*(?:\n[ \t]*){2,}/',
				function ( $m ) use ( &$report ) {
					$report['blank'] += substr_count( $m[0], "\n" ) - 2;
					return "\n\n";
				},
				$chunk
			);
		} );
	}

	/** Classes repeated inside the same attribute. */
	protected static function dedupe_classes( $html, &$report ) {
		return preg_replace_callback(
			'/(\sclass\s*=\s*")([^"]*)(")/i',
			function ( $m ) use ( &$report ) {
				$parts = preg_split( '/(\s+)/', $m[2], -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
				$seen  = array();
				$out   = array();
				foreach ( $parts as $p ) {
					if ( '' === trim( $p ) ) {
						$out[] = $p;
						continue;
					}
					if ( isset( $seen[ $p ] ) ) {
						$report['classes']++;
						continue;
					}
					$seen[ $p ] = true;
					$out[]      = $p;
				}
				return $m[1] . trim( implode( '', $out ) ) . $m[3];
			},
			$html
		);
	}

	/** Attribute repeated within the same tag: the browser keeps the first, and so do we. */
	protected static function dedupe_attributes( $html, &$report ) {
		return preg_replace_callback(
			'/<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^<>"\']+|\s*"[^"]*"|\s*\'[^\']*\')*)\s*(\/?)>/s',
			function ( $m ) use ( &$report ) {
				if ( ! preg_match_all( '/\s+([a-zA-Z_:@#-][a-zA-Z0-9_:.-]*)(\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?/s', $m[2], $am, PREG_SET_ORDER ) ) {
					return $m[0];
				}
				$seen = array();
				$out  = '';
				$dupe = false;
				foreach ( $am as $a ) {
					$name = strtolower( $a[1] );
					if ( isset( $seen[ $name ] ) ) {
						$report['attrs']++;
						$dupe = true;
						continue;
					}
					$seen[ $name ] = true;
					$out          .= ' ' . trim( $a[0] );
				}
				return $dupe ? '<' . $m[1] . $out . ( $m[3] ? ' /' : '' ) . '>' : $m[0];
			},
			$html
		);
	}

	/** Applies $fn only outside pre/textarea/script/style, where spaces are not decorative. */
	protected static function outside_literal( $html, $fn ) {
		$parts = preg_split( '#(<(?:pre|textarea|script|style)\b.*?</(?:pre|textarea|script|style)>)#is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
		$out   = '';
		foreach ( $parts as $i => $p ) {
			$out .= ( $i % 2 ) ? $p : call_user_func( $fn, $p );
		}
		return $out;
	}

	/** Classes named by the CSS that never show up in the HTML. A warning only: see above. */
	protected static function orphan_classes( $code ) {
		if ( ! preg_match_all( '#<style[^>]*>(.*?)</style>#is', $code, $sm ) ) {
			return array();
		}
		$html = preg_replace( '#<style[^>]*>.*?</style>#is', '', $code );
		$used = array();
		if ( preg_match_all( '/\sclass\s*=\s*"([^"]*)"/i', $html, $mm ) ) {
			foreach ( $mm[1] as $raw ) {
				foreach ( preg_split( '/\s+/', trim( $raw ), -1, PREG_SPLIT_NO_EMPTY ) as $c ) {
					$used[ $c ] = true;
				}
			}
		}
		// A class the JS adds at runtime is used, even if the HTML does not show it.
		if ( preg_match_all( '/[\'"`]([A-Za-z0-9_-]+)[\'"`]/', $html, $jm ) ) {
			foreach ( $jm[1] as $c ) {
				$used[ $c ] = true;
			}
		}

		$orphans = array();
		if ( preg_match_all( '/\.([A-Za-z_][A-Za-z0-9_-]*)/', implode( "\n", $sm[1] ), $cm ) ) {
			foreach ( array_unique( $cm[1] ) as $c ) {
				if ( ! isset( $used[ $c ] ) ) {
					$orphans[] = $c;
				}
			}
		}
		sort( $orphans );
		return array_slice( $orphans, 0, 12 );
	}

	/* ---------------------------------- JS ----------------------------------- */

	/**
	 * On the JS it stops at trailing spaces and excess blank lines, and only outside strings,
	 * template literals and comments. Deduplicating imperative code is not something that can be
	 * done without understanding it, and understanding it is not a tidy-up's job.
	 */
	protected static function tidy_js( $js, &$report ) {
		$out   = '';
		$len   = strlen( $js );
		$i     = 0;
		$plain = '';
		$flush = function ( $chunk ) use ( &$report ) {
			$chunk = preg_replace_callback( '/[ \t]+\n/', function () use ( &$report ) {
				$report['trailing']++;
				return "\n";
			}, $chunk );
			return preg_replace_callback( '/\n[ \t]*(?:\n[ \t]*){2,}/', function ( $m ) use ( &$report ) {
				$report['blank'] += substr_count( $m[0], "\n" ) - 2;
				return "\n\n";
			}, $chunk );
		};

		while ( $i < $len ) {
			$c    = $js[ $i ];
			$next = ( $i + 1 < $len ) ? $js[ $i + 1 ] : '';

			if ( '/' === $c && '/' === $next ) {
				$end   = strpos( $js, "\n", $i );
				$end   = ( false === $end ) ? $len : $end;
				$out  .= $flush( $plain ) . substr( $js, $i, $end - $i );
				$plain = '';
				$i     = $end;
				continue;
			}
			if ( '/' === $c && '*' === $next ) {
				$end   = strpos( $js, '*/', $i + 2 );
				$end   = ( false === $end ) ? $len : $end + 2;
				$out  .= $flush( $plain ) . substr( $js, $i, $end - $i );
				$plain = '';
				$i     = $end;
				continue;
			}
			if ( '"' === $c || "'" === $c || '`' === $c ) {
				$j = $i + 1;
				while ( $j < $len && ( $js[ $j ] !== $c || '\\' === $js[ $j - 1 ] ) ) {
					$j++;
				}
				$j     = min( $j + 1, $len );
				$out  .= $flush( $plain ) . substr( $js, $i, $j - $i );
				$plain = '';
				$i     = $j;
				continue;
			}
			$plain .= $c;
			$i++;
		}
		return $out . $flush( $plain );
	}
}
