<?php
/**
 * WHICH TEXT IS A FIELD IS DECIDED HERE, NOT BY THE MODEL.
 *
 * WHAT IT SOLVES. `prompts/dsl-core.txt` tells the model to expose every text as a placeholder —
 * «STANDARD KIT PER TYPE OF ELEMENT … ALWAYS expose all of its fields, even the ones that seem
 * obvious: they are what the user will want to change without having to ask you». It does it most
 * of the time. Most of the time is the worst of the three possible outcomes, because the half that
 * came out right makes the half that did not look like a bug in the panel rather than in the
 * section — the user sees six boxes for six things and no box for the seventh, and there is nothing
 * on screen to say why.
 *
 * The case that made this necessary was a tab slider: the loop's body carried `{{i:field.image}}`
 * and the four responsive attributes, and the pill, the heading, the paragraph, the mini-banner and
 * the button label were written as literal markup. Six slides, one editable thing each, and the same
 * five sentences on every one of them — because text written literally inside a loop is not data,
 * it is the same words repeated. Nothing had gone wrong loudly enough to notice.
 *
 * IT IS THE SAME ARGUMENT {@see AIPB_Switches} ALREADY MADE, and this class is its other half.
 * Which element a field belongs to is not a judgement call; neither is whether a piece of visible
 * text is content. A section's words are what the site says, and words the owner cannot change are
 * a defect however good they are. So the model writes the section, and the fields it did not expose
 * are put in afterwards, the same way every time, from the template itself.
 *
 * IT ADDS, IT NEVER REWRITES. A placeholder the model wrote stands exactly as written — its key,
 * its kind, its default. This pass only ever looks at text that is not a placeholder yet, so on a
 * section that came back complete it does nothing at all, and on one that came back half-done it
 * produces the markup the model was supposed to have written. That is also why it is safe on a
 * touch-up, which re-parses the whole template every time: sections already on customers' sites are
 * repaired the next time anybody touches them.
 *
 * WHAT IT WILL NOT TOUCH, and each of these is a way of being wrong that leaves no trace:
 *
 *  - anything that already contains a placeholder. Not «no placeholder of its own» — ANY `{{`,
 *    because `<p>{{t:a}} and more</p>` is a sentence the model chose to write half open, and
 *    guessing at the other half would fight it.
 *  - `<style>`, `<script>` and `<svg>`. The first two are not markup; the third is a drawing, and
 *    the text in a `<path>` or a `<title>` is not a sentence anybody edits.
 *  - the body of a loop that reads from WordPress (`source="post"`, `"term"`, `"menu"`). What is in
 *    there is one row drawn from the database: a literal in it is a LABEL that belongs to the
 *    layout — «Read more», «by» — and making it a field would put the same box on every row of a
 *    list whose length nobody controls.
 *  - text with no letter and no digit. An arrow, a bullet, a star, a currency sign on its own:
 *    those are ornaments the CSS could have drawn, and a panel that offers to edit «→» is a panel
 *    that has stopped meaning anything.
 *  - elements that are not text-bearing (see TEXT_TAGS). An `<option>` is a value, not a sentence;
 *    a `<title>` is metadata.
 *  - elements with a child tag inside. `<p>Some <b>bold</b> text</p>` is three nodes and one
 *    sentence, and the honest placeholder for it is a rich text over the whole paragraph — which
 *    means deciding where the sentence starts and ends. That is a real decision and it is not this
 *    pass's; it is left alone rather than half done.
 *
 * RUNNING IT TWICE CHANGES NOTHING: after the first pass the text it would look at is a placeholder,
 * and placeholders are the first thing it skips.
 *
 * @package AI_Page_Builder
 */

defined( 'ABSPATH' ) || exit;

class AIPB_Autofields {

	/**
	 * The elements whose text is a sentence somebody would want to change.
	 *
	 * An allow-list and not a ban-list, for the reason allow-lists are usually right here: the tags
	 * that hold prose are a short, closed set that has not changed in twenty years, while the ones
	 * that hold something else — `<option>`, `<title>`, `<textarea>`, a `<template>`, whatever
	 * arrives next — are open-ended. A ban-list would be wrong the first time the model reached for
	 * a tag nobody had thought of, and it would be wrong SILENTLY: a value turned into an editable
	 * sentence, in the panel, with no way to tell it should not be there.
	 */
	const TEXT_TAGS = array(
		'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
		'p', 'li', 'a', 'button', 'span', 'strong', 'em', 'b', 'i', 'small',
		'blockquote', 'figcaption', 'label', 'legend', 'caption', 'summary', 'cite',
		'dt', 'dd', 'td', 'th', 'div',
	);

	/** What the tag says the text IS, when the class does not say it better. */
	const ROLES = array(
		'h1' => 'title', 'h2' => 'title', 'h3' => 'title', 'h4' => 'title', 'h5' => 'title', 'h6' => 'title',
		'p' => 'text', 'blockquote' => 'text', 'figcaption' => 'caption',
		'a' => 'cta', 'button' => 'cta',
		'li' => 'item', 'dt' => 'term', 'dd' => 'description',
		'label' => 'label', 'legend' => 'label', 'caption' => 'caption', 'summary' => 'summary',
		'span' => 'label', 'strong' => 'label', 'em' => 'label', 'b' => 'label', 'i' => 'label',
		'small' => 'note', 'cite' => 'source', 'td' => 'cell', 'th' => 'heading', 'div' => 'text',
	);

	/** Elements that close themselves: they have no inner text to look at. */
	const VOID = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );

	/**
	 * Puts in the placeholders the model did not write.
	 *
	 * @param string $template       The section's markup, placeholders already normalised to {{k:key}}.
	 * @param array  $values         Top-level values, and the repeaters' rows.
	 * @param array  $field_defaults Defaults for the keys inside repeaters.
	 *
	 * @return array{0:string,1:array,2:array} The three, changed.
	 */
	public static function apply( $template, $values, $field_defaults ) {
		$template = (string) $template;
		if ( '' === $template || false === strpos( $template, '<' ) ) {
			return array( $template, $values, $field_defaults );
		}

		$values         = is_array( $values ) ? $values : array();
		$field_defaults = is_array( $field_defaults ) ? $field_defaults : array();

		$dead  = self::dead_zones( $template );
		$loops = self::prop_loops( $template );

		// Every key already spoken for, so a new one never lands on top of an existing field. The
		// repeaters' keys live in their own namespace: two lists may each have a `title`.
		$taken = array( '' => self::keys_in( $template, '' ) );
		foreach ( $loops as $loop ) {
			$taken[ $loop['name'] ] = self::keys_in( $loop['body'], 'field.' );
		}

		$edits = array();
		foreach ( self::elements( $template ) as $el ) {
			$scope = self::scope_of( $el['inner_start'], $loops );
			$plan  = self::plan_for( $template, $el, $dead, $scope, $taken );
			if ( null === $plan ) {
				continue;
			}
			$taken[ $plan['scope'] ][] = $plan['key'];
			$edits[]                   = $plan;
		}

		if ( ! $edits ) {
			return array( $template, $values, $field_defaults );
		}

		// From the end: an edit rewrites a stretch of the template, and every offset after it would
		// move. The elements come out of the walker in opening order, so the list is reversed rather
		// than sorted — nesting keeps the order stable where two starts coincide.
		foreach ( array_reverse( $edits ) as $edit ) {
			$template = substr( $template, 0, $edit['from'] )
				. '{{t:' . ( '' === $edit['scope'] ? '' : 'field.' ) . $edit['key'] . '}}'
				. substr( $template, $edit['to'] );

			if ( '' === $edit['scope'] ) {
				if ( ! isset( $values[ $edit['key'] ] ) || '' === $values[ $edit['key'] ] ) {
					$values[ $edit['key'] ] = $edit['text'];
				}
				continue;
			}

			// Inside a list the words were the same on every row — that is what being written in the
			// markup MEANT — so every row starts from them. It invents nothing: it makes visible,
			// and separately editable, a repetition that was already there.
			$field_defaults[ $edit['key'] ] = $edit['text'];
			$values                         = self::seed_rows( $values, $edit['scope'], $edit['key'], $edit['text'] );
		}

		return array( $template, $values, $field_defaults );
	}

	/* ------------------------------- The decision ----------------------------- */

	/**
	 * Should this element's text become a field, and under what name?
	 *
	 * @return array|null null whenever any of the refusals in the class docblock applies.
	 */
	protected static function plan_for( $template, $el, $dead, $scope, $taken ) {
		if ( in_array( $el['tag'], self::VOID, true ) ) {
			return null;
		}
		if ( ! in_array( $el['tag'], self::TEXT_TAGS, true ) ) {
			return null;
		}
		if ( self::inside( $el['start'], $dead ) ) {
			return null;
		}

		$inner = substr( $template, $el['inner_start'], $el['inner_end'] - $el['inner_start'] );

		// A child tag means the sentence is split across nodes: see the docblock.
		if ( false !== strpos( $inner, '<' ) || false !== strpos( $inner, '{{' ) ) {
			return null;
		}

		// AND NEITHER IS THE INSIDE OF ONE. `<p>Some <b>bold</b> text</p>` is refused above, but the
		// `<b>` in the middle of it is a perfectly good leaf and would be taken on its own — leaving
		// «bold» editable between two words that are not. Half a sentence in a box is worse than no
		// box: the panel offers a change that cannot be made properly, and the two halves drift.
		if ( self::in_a_sentence( $template, $el ) ) {
			return null;
		}
		if ( ! self::is_content( $inner ) ) {
			return null;
		}

		$key = self::key_for( $template, $el, $taken[ $scope ] );
		if ( '' === $key ) {
			return null;
		}

		return array(
			'from'  => $el['inner_start'],
			'to'    => $el['inner_end'],
			'text'  => trim( preg_replace( '/\s+/', ' ', $inner ) ),
			'key'   => $key,
			'scope' => $scope,
		);
	}

	/**
	 * Is this element sitting INSIDE somebody else's sentence?
	 *
	 * The test is on the parent, and it is about text rather than about tags: a parent that holds
	 * words of its own alongside this element is a sentence, and this element is a piece of it. A
	 * parent that holds only elements — the ordinary `<div>` wrapping a heading and a paragraph — is
	 * a container, and its children are separate things that each deserve a field.
	 *
	 * That distinction cannot be made from the tag name, which is why it is not made there: `<div>`
	 * is a container nine times out of ten and a `<div class="stat">7</div>` the tenth, and `<p>` is
	 * a sentence nine times out of ten and a wrapper the tenth. What settles it is whether there are
	 * words directly in it.
	 */
	protected static function in_a_sentence( $template, $el ) {
		$parent = $el['parent'];
		if ( null === $parent || ! isset( self::$walk_cache[ $parent ] ) ) {
			return false;
		}

		$up    = self::$walk_cache[ $parent ];
		$inner = substr( $template, $up['inner_start'], $up['inner_end'] - $up['inner_start'] );

		// Blank out the children, whole: what is left is the parent's own words.
		$inner = preg_replace( '#<([a-zA-Z][a-zA-Z0-9:-]*)\b[^>]*>.*?</\1\s*>#s', ' ', $inner );
		$inner = preg_replace( '#<[^>]*>#', ' ', $inner );
		$inner = preg_replace( '/\{\{[^}]*\}\}/', ' ', $inner );

		return self::is_content( $inner );
	}

	/**
	 * Is this a sentence, or an ornament?
	 *
	 * A letter or a digit is the whole test, and it is deliberately that blunt. Anything finer —
	 * a minimum length, a word count — would start refusing real content: «7», «€», «IT» and «New»
	 * are all things a site says, and a panel that will not let you change the number in a statistic
	 * because it is one character long is worse than one that offers a box for an arrow.
	 */
	protected static function is_content( $text ) {
		$text = trim( html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );

		return '' !== $text && (bool) preg_match( '/[\p{L}\p{N}]/u', $text );
	}

	/**
	 * The name the field takes.
	 *
	 * THE CLASS COMES FIRST, because the model has already named the thing: `.sectors-pill` is a
	 * pill, `.hero-subtitle` is a subtitle, and a name taken from the markup reads in the panel the
	 * way the section reads in the code. It is also the convention the DSL teaches — «ONE PREFIX PER
	 * ELEMENT … hero_title, hero_text, hero_cta» — so a key derived this way sits in the same group
	 * as the fields the model did write.
	 *
	 * A BEM modifier is dropped (`card__title--big` → `card_title`): it is a variant of the same
	 * thing, and two variants of one element are not two fields.
	 *
	 * When there is no class, the nearest ancestor that has one lends its prefix and the tag says
	 * the rest: `<div class="hero"><h2>…` → `hero_title`. When there is no ancestor either, the tag
	 * stands alone — `title`, `text` — which is poor but honest, and only happens on markup that
	 * carries no class at all.
	 */
	protected static function key_for( $template, $el, $taken ) {
		$own = self::class_key( $template, $el );
		$base = '';

		if ( '' !== $own ) {
			$base = $own;
		} else {
			$parent = $el['parent'];
			$els    = self::$walk_cache;
			while ( null !== $parent && isset( $els[ $parent ] ) ) {
				$up = self::class_key( $template, $els[ $parent ] );
				if ( '' !== $up ) {
					$base = $up . '_' . self::role( $el['tag'] );
					break;
				}
				$parent = $els[ $parent ]['parent'];
			}
			if ( '' === $base ) {
				$base = self::role( $el['tag'] );
			}
		}

		$base = self::normalise( $base );
		if ( '' === $base ) {
			return '';
		}

		// A collision is not a problem to report, it is a second thing of the same kind: three
		// `<li class="step">` are step, step_2, step_3.
		$key = $base;
		$n   = 2;
		while ( in_array( $key, $taken, true ) ) {
			$key = $base . '_' . $n;
			++$n;
		}

		return $key;
	}

	/** The element's first class, without its BEM modifier. '' when it has none. */
	protected static function class_key( $template, $el ) {
		$open = substr( $template, $el['start'], $el['inner_start'] - $el['start'] );
		if ( ! preg_match( '/\sclass\s*=\s*("([^"]*)"|\'([^\']*)\')/i', $open, $m ) ) {
			return '';
		}
		$classes = preg_split( '/\s+/', trim( '' !== $m[2] ? $m[2] : ( isset( $m[3] ) ? $m[3] : '' ) ) );
		$first   = isset( $classes[0] ) ? $classes[0] : '';
		if ( '' === $first ) {
			return '';
		}

		// `card__title--big` → `card__title` → `card_title`.
		$first = preg_replace( '/--.*$/', '', $first );

		return $first;
	}

	protected static function role( $tag ) {
		return isset( self::ROLES[ $tag ] ) ? self::ROLES[ $tag ] : 'text';
	}

	/** A class or a tag name, as a placeholder key: lowercase, letters, digits and single underscores. */
	protected static function normalise( $name ) {
		$name = strtolower( (string) $name );
		$name = preg_replace( '/[^a-z0-9]+/', '_', $name );
		$name = trim( preg_replace( '/_+/', '_', $name ), '_' );
		// A key has to start with a letter: `{{t:2col}}` is not a placeholder the engine reads.
		if ( '' !== $name && ! preg_match( '/^[a-z]/', $name ) ) {
			$name = 'f_' . $name;
		}

		return $name;
	}

	/* -------------------------------- The map --------------------------------- */

	/** The keys already used at a level, so a new field never shadows one the model wrote. */
	protected static function keys_in( $tpl, $prefix ) {
		$out = array();
		$re  = '/\{\{\s*[tciurhs]:' . preg_quote( $prefix, '/' ) . '([a-zA-Z0-9_]+)\s*\}\}/';
		if ( preg_match_all( $re, (string) $tpl, $m ) ) {
			$out = array_values( array_unique( $m[1] ) );
		}
		// A switch is a key too: `{{#if hero_title}}` and a field called hero_title are one name.
		if ( preg_match_all( '/\{\{#if\s+' . preg_quote( $prefix, '/' ) . '([a-zA-Z0-9_]+)\s*\}\}/', (string) $tpl, $m2 ) ) {
			$out = array_values( array_unique( array_merge( $out, $m2[1] ) ) );
		}

		return $out;
	}

	/**
	 * The hand-curated lists, with where their bodies begin and end.
	 *
	 * Only `source="prop"` — the default when no source is written. A loop over posts, terms or the
	 * menu is a different thing entirely: see the docblock.
	 *
	 * @return array List of { name, from, to, body }.
	 */
	protected static function prop_loops( $tpl ) {
		$out = array();
		if ( ! preg_match_all( '/\{\{#loop\s+([^}]*?)\}\}(.*?)\{\{\/loop\}\}/s', $tpl, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) {
			return $out;
		}

		foreach ( $m as $lp ) {
			$attrs  = $lp[1][0];
			$source = preg_match( '/source\s*=\s*"([^"]*)"/', $attrs, $s ) ? $s[1] : 'prop';
			$name   = preg_match( '/name\s*=\s*"([^"]*)"/', $attrs, $n ) ? $n[1] : '';
			if ( 'prop' !== $source || '' === $name || 0 === strpos( $name, 'field.' ) || 0 === strpos( $name, 'post.' ) ) {
				continue;
			}
			$out[] = array(
				'name' => $name,
				'from' => $lp[2][1],
				'to'   => $lp[2][1] + strlen( $lp[2][0] ),
				'body' => $lp[2][0],
			);
		}

		return $out;
	}

	/**
	 * Which level an offset belongs to: '' for the section itself, the loop's name inside a list.
	 *
	 * The LAST match wins, and that is what makes nesting come out right: a loop inside a loop is
	 * found second, and its rows are the ones the text belongs to. The loops this pass does not own
	 * never reach here — they are dead zones, refused before the name is asked for.
	 */
	protected static function scope_of( $at, $loops ) {
		$found = '';
		foreach ( $loops as $loop ) {
			if ( $at >= $loop['from'] && $at < $loop['to'] ) {
				$found = $loop['name'];
			}
		}

		return $found;
	}

	/**
	 * The stretches this pass has no business in.
	 *
	 * `<style>` and `<script>` are not markup — a `<` inside a selector would be read as a tag and
	 * put the nesting out for everything after it. `<svg>` is a drawing. A loop that is not a
	 * hand-curated list takes its whole body out of reach.
	 *
	 * @return array List of [ start, end ).
	 */
	protected static function dead_zones( $tpl ) {
		$out = array();

		if ( preg_match_all( '#<(style|script|svg)\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 | PREG_SET_ORDER ) ) {
			foreach ( $d as $lp ) {
				$source = preg_match( '/source\s*=\s*"([^"]*)"/', $lp[2][0], $s ) ? $s[1] : 'prop';
				$name   = preg_match( '/name\s*=\s*"([^"]*)"/', $lp[2][0], $n ) ? $n[1] : '';
				$ours   = 'loop' === $lp[1][0] && 'prop' === $source && '' !== $name
					&& 0 !== strpos( $name, 'field.' ) && 0 !== strpos( $name, 'post.' );
				if ( ! $ours ) {
					$out[] = array( $lp[0][1], $lp[0][1] + strlen( $lp[0][0] ) );
				}
			}
		}

		return $out;
	}

	protected static function inside( $at, $ranges ) {
		foreach ( $ranges as $r ) {
			if ( $at >= $r[0] && $at < $r[1] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * The same words on every row of a list.
	 *
	 * The rows are the ones the model supplied in the values block. A row that already has the key
	 * keeps what it has — this pass adds, it does not rewrite — and a list with no rows yet gets
	 * none invented for it: an empty repeater is a repeater waiting for entries, and filling it with
	 * one made-up row would put a slide on the page that nobody asked for.
	 */
	protected static function seed_rows( $values, $loop, $key, $text ) {
		if ( ! isset( $values[ $loop ] ) || ! is_array( $values[ $loop ] ) ) {
			return $values;
		}

		foreach ( $values[ $loop ] as $i => $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			if ( ! isset( $row[ $key ] ) || '' === $row[ $key ] ) {
				$values[ $loop ][ $i ][ $key ] = $text;
			}
		}

		return $values;
	}

	/* ------------------------------- The walker ------------------------------- */

	/** The elements of the template being processed, so key_for() can climb to the ancestors. */
	protected static $walk_cache = array();

	/**
	 * The template's elements, with their offsets and their parent.
	 *
	 * The same shape and the same tokenizer as {@see AIPB_Switches::elements()}, and the duplication
	 * is deliberate for now: that one is coupled to its own skip ranges (it must stay OUT of loop
	 * bodies, this one must go IN), and pulling a shared walker out of a class that is working is a
	 * change to make once both callers have settled, not while the second is being written.
	 */
	protected static function elements( $tpl ) {
		$len   = strlen( $tpl );
		$els   = array();
		$stack = array();
		$i     = 0;

		while ( $i < $len ) {
			$lt = strpos( $tpl, '<', $i );
			if ( false === $lt ) {
				break;
			}
			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] ) {
				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;
						array_pop( $stack );
					}
					$els[ $stack[ $s ] ]['inner_end'] = $lt;
					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,
				'parent'      => $stack ? $stack[ count( $stack ) - 1 ] : null,
			);
			if ( ! $self ) {
				$stack[] = $k;
			}
		}

		self::$walk_cache = $els;

		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;
	}
}
