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

/**
 * SAFE template engine (v2 — tree parser).
 *
 * The AI (or the admin) writes free HTML/CSS/JS plus three kinds of annotation:
 *
 *   Prop placeholders:  {{t:title}} {{c:bg}} {{i:image}} {{u:link}}
 *      t = text (esc_html) | c = colour | i = image (esc_url) | u = url (esc_url)
 *      r = rich text (inline HTML) | h = a content's body (block HTML)
 *      s = shortcode ([form id="3"]: the syntax only, AIPB_Shortcodes expands it)
 *
 *   Current content:    {{t:post.title}} {{h:post.content}} {{i:post.thumbnail}} {{t:post.price}}
 *      The content the visitor is looking at — the product page, the article. The same fields as
 *      loop items, plus post.content, post.author, post.modified, post.post_type.
 *
 *   Current term:       {{t:term.title}} {{h:term.description}} {{t:term.count}}
 *      The archive the visitor is looking at — the category, the tag. On an archive THIS is the
 *      scope: post.* is empty, because the queried object is not a piece of content.
 *
 *   Dynamic loops:      {{#loop source="post" type="product" limit="6" orderby="date" order="DESC"}}
 *                          <h3>{{t:field.title}}</h3>
 *                       {{/loop}}
 *      Repeater (props): {{#loop source="prop" name="tabs"}} {{t:field.label}} {{/loop}}
 *      Nested repeater:  {{#loop source="prop" name="field.subitems"}} … {{/loop}}  (inside another loop)
 *      Terms: {{#loop source="term" taxonomy="category" post_type="product"}} — the item has
 *         field.name, field.slug, field.count, field.description, field.url, field.permalink,
 *         PLUS the term's custom fields: an ACF image becomes the URL directly
 *         ({{i:field.cover}}), with field.cover_alt / _w / _h alongside it.
 *
 *   Conditions:         {{#if field.on_sale}} … {{/if}}   or  {{#if promo_active}} … {{/if}}
 *                       With an alternative: {{#if field.photo}} <img…> {{else}} <span class="empty">…</span> {{/if}}
 *
 *   Filterable lists:   {{#filter id="catalog" type="product" limit="9"}}
 *                          {{#loop source="term" taxonomy="category"}}…chips…{{/loop}}
 *                          {{#results}} …one card… {{/results}}
 *                          {{#empty}} no results {{/empty}}
 *                       {{/filter}}
 *                       The state lives in the URL and the server rebuilds the query: see AIPB_Filters.
 *
 * Unlike v1 (regex-based), here the template is TOKENIZED and turned into a TREE: loops and
 * conditions are therefore NESTABLE and the parsing is robust on complex HTML. NO arbitrary PHP:
 * the directives are interpreted here, with whitelisted parameters.
 */
class AIPB_Template_Engine {

	/**
	 * Version of the DSL this engine interprets.
	 *
	 * It travels in every request to the service, which composes the prompts accordingly. It must be
	 * raised ONLY when the way a directive is written or behaves changes — not on every plugin
	 * release, otherwise it stops saying anything.
	 */
	const DSL = 6;

	/**
	 * The directives this engine can render.
	 *
	 * It is a PROMISE made to the service: what is in this list may appear in the prompts, what is
	 * not may not. Whoever adds a directive to the tokenizer below has to add it here too, otherwise
	 * the service will never teach it — and that is the right way round to be wrong: a feature that
	 * never arrives, instead of markup that lands on the page as text.
	 *
	 * They are not only block names: `filter-current` is a CAPABILITY — the filter that inherits the
	 * archive's term. An older engine receives `{{#filter term="current"}}` and, not knowing it,
	 * lists every content of the type instead of the category's: a wrong page that gives no sign of
	 * itself. Hence the separate declaration.
	 *
	 * `carousel` says that this engine SERVES Swiper from its own files (AIPB_Render::swiper_js).
	 * An engine without it would receive a section calling `new Swiper(…)` with nothing to call:
	 * the slides would stay stacked, silently.
	 *
	 * `reveal` is not a placeholder at all: it is the scroll-reveal trigger (`data-aura-reveal`,
	 * see AIPB_Render::motion_css). It is declared here for the same reason as the others — an
	 * engine without that runtime would receive markup whose entrance never fires. It degrades
	 * gently rather than visibly, which is exactly why it needs declaring: nobody would notice.
	 *
	 * `fx` says this engine compiles and prints the BRAND EFFECTS (AIPB_Effects → the #aipb-fx
	 * block): named entrances the prompts may reference by id (data-aura-reveal="image-reveal").
	 * An engine without the compiler would show elements that reveal with no animation at all —
	 * again gently, again invisibly, hence the declaration.
	 *
	 * `pagefx` is the page-layer runtime: a block with `scope: page` is wrapped outside the
	 * section flow, its JS sees the whole page, and `window.aura` is the bus those overlays
	 * listen on. An engine without it would receive a fixed overlay whose scripts stay confined
	 * to their own empty wrapper — another silent miss, hence the declaration. It is not a
	 * tokenizer directive.
	 */
	const DIRECTIVES = array( 'loop', 'if', 'filter', 'menu', 'post', 'term', 'shortcode', 'repeater', 'filter-current', 'filter-meta', 'reveal', 'carousel', 'fx', 'pagefx' );

	/** Id of a {{#filter}} that does not declare one. */
	const DEFAULT_FILTER_ID = 'filter';

	/**
	 * @param string $template  Raw template with annotations.
	 * @param array  $values    Prop values { key => value | array (repeater) }.
	 * @return string Final, safe HTML.
	 */
	public static function render( $template, $values ) {
		if ( ! is_string( $template ) || '' === $template ) {
			return '';
		}
		$values = is_array( $values ) ? $values : array();

		$tokens = self::tokenize( $template );
		$pos    = 0;
		$tree   = self::parse( $tokens, $pos, null );

		$html = self::render_nodes( $tree, array(), $values );
		$html = self::drop_empty_images( $html );

		// Resolves the <i data-lucide="name"></i> icons into inline SVG (only the ones used).
		return class_exists( 'AIPB_Icons' ) ? AIPB_Icons::resolve( $html ) : $html;
	}

	/**
	 * An image with no source is not an image: it goes.
	 *
	 * WHAT IT SOLVES. `<img src="{{i:field.thumbnail}}">` on a content with no featured image becomes
	 * `<img src="">`, and the browser draws the broken-file icon. A grid of twelve products with no
	 * photos becomes a grid of twelve broken icons: it looks like a faulty site, when it is only
	 * content waiting to be completed. Empty is more honest than broken.
	 *
	 * IT IS NOT A PATCH FOR THE PROMPT. The prompt now says to put a content's image under an
	 * {{#if}}, and that is where it is solved properly, because the template can also remove the
	 * frame and rebalance the layout. This is the seat belt for everything already generated — and
	 * for the day the model forgets.
	 *
	 * The responsive attributes left empty on the surviving images go too: a width="" is invalid
	 * markup, and a srcset="" makes the browser download the wrong image.
	 */
	protected static function drop_empty_images( $html ) {
		if ( false === stripos( $html, '<img' ) ) {
			return $html;
		}

		// Out with the <img>s that have no source: empty src, or no src at all.
		//
		// With a callback and not a lookahead: the «look for a non-empty src» version seems to work
		// and does not, because `\S` after the opening quote matches the CLOSING quote — so src=""
		// counts as full. Here the value itself is inspected.
		$html = preg_replace_callback( '#<img\b[^>]*>#i', static function ( $m ) {
			if ( ! preg_match( '#\bsrc\s*=\s*("([^"]*)"|\'([^\']*)\')#i', $m[0], $s ) ) {
				return ''; // no src: not an image, a hole with a broken icon on top
			}
			$val = isset( $s[3] ) && '' !== $s[3] ? $s[3] : ( isset( $s[2] ) ? $s[2] : '' );

			return '' === trim( $val ) ? '' : $m[0];
		}, $html );

		// And the attributes left empty on the ones that stay, <source> included.
		return preg_replace( '#\s(?:srcset|sizes|width|height)\s*=\s*(["\'])\s*\1#i', '', $html );
	}

	/* ------------------------------- Tokenizer ------------------------------- */

	/** Breaks the template into a sequence of tokens: text, opens/closes, placeholders. */
	protected static function tokenize( $tpl ) {
		$re     = '/\{\{\s*(#loop\b[^}]*?|\/loop|#if\b[^}]*?|\/if|else|#filter\b[^}]*?|\/filter|#results|\/results|#empty|\/empty|[tciurhs]:[a-zA-Z0-9_.]+(?:\\|[^}]*)?)\s*\}\}/';
		$tokens = array();
		$last   = 0;

		if ( preg_match_all( $re, $tpl, $mm, PREG_OFFSET_CAPTURE ) ) {
			foreach ( $mm[0] as $i => $full ) {
				$start = $full[1];
				if ( $start > $last ) {
					$tokens[] = array( 'type' => 'text', 'value' => substr( $tpl, $last, $start - $last ) );
				}
				$tokens[] = self::classify( trim( $mm[1][ $i ][0] ) );
				$last     = $start + strlen( $full[0] );
			}
		}
		if ( $last < strlen( $tpl ) ) {
			$tokens[] = array( 'type' => 'text', 'value' => substr( $tpl, $last ) );
		}
		return $tokens;
	}

	/** Classifies the content of a {{ … }} annotation. */
	protected static function classify( $inner ) {
		if ( 0 === strpos( $inner, '#loop' ) ) {
			return array( 'type' => 'open_loop', 'attrs' => self::parse_attrs( substr( $inner, 5 ) ) );
		}
		if ( '/loop' === $inner ) {
			return array( 'type' => 'close_loop' );
		}
		if ( 0 === strpos( $inner, '#if' ) ) {
			return array( 'type' => 'open_if', 'cond' => trim( substr( $inner, 3 ) ) );
		}
		if ( '/if' === $inner ) {
			return array( 'type' => 'close_if' );
		}
		if ( 'else' === $inner ) {
			return array( 'type' => 'else' );
		}
		// Dynamic filters: the container, the repeated part and the empty-list message.
		if ( 0 === strpos( $inner, '#filter' ) ) {
			return array( 'type' => 'open_filter', 'attrs' => self::parse_attrs( substr( $inner, 7 ) ) );
		}
		if ( '/filter' === $inner ) {
			return array( 'type' => 'close_filter' );
		}
		if ( '#results' === $inner ) {
			return array( 'type' => 'open_results' );
		}
		if ( '/results' === $inner ) {
			return array( 'type' => 'close_results' );
		}
		if ( '#empty' === $inner ) {
			return array( 'type' => 'open_empty' );
		}
		if ( '/empty' === $inner ) {
			return array( 'type' => 'close_empty' );
		}
		// "x:path" placeholder, with an optional inline default after a pipe: {{t:heading|Hello}}.
		//
		// The default is normally taken off upstream, when a generated section is read and the pipe
		// becomes the field's initial value. It is honoured here as well because a template does not
		// always arrive from there — one written by hand in the code editor, or carried in from
		// elsewhere, still reaches this engine with the pipe intact, and printing the annotation
		// verbatim on the page is the one outcome nobody meant.
		$body = substr( $inner, 2 );
		$def  = '';
		$bar  = strpos( $body, '|' );
		if ( false !== $bar ) {
			$def  = substr( $body, $bar + 1 );
			$body = substr( $body, 0, $bar );
		}

		return array( 'type' => 'ph', 'kind' => strtolower( $inner[0] ), 'path' => $body, 'default' => $def );
	}

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

	/**
	 * Builds the node tree. Recursive: it returns at the first close matching $stop; on an opening
	 * token it calls itself for the children. Tolerant: an unpaired close is ignored.
	 */
	protected static function parse( $tokens, &$pos, $stop ) {
		$nodes = array();
		$n     = count( $tokens );

		while ( $pos < $n ) {
			$t = $tokens[ $pos ];

			if ( $stop && $t['type'] === $stop ) {
				$pos++; // consume the close that belongs to us
				return $nodes;
			}
			if ( in_array( $t['type'], array( 'close_loop', 'close_if', 'close_filter', 'close_results', 'close_empty' ), true ) ) {
				$pos++; // unpaired close: ignore
				continue;
			}
			if ( 'open_loop' === $t['type'] ) {
				$pos++;
				$children = self::parse( $tokens, $pos, 'close_loop' );
				$nodes[]  = array( 'type' => 'loop', 'attrs' => $t['attrs'], 'children' => $children );
				continue;
			}
			if ( 'open_filter' === $t['type'] ) {
				$pos++;
				$children = self::parse( $tokens, $pos, 'close_filter' );
				$nodes[]  = array( 'type' => 'filter', 'attrs' => $t['attrs'], 'children' => $children );
				continue;
			}
			if ( 'open_results' === $t['type'] ) {
				$pos++;
				$children = self::parse( $tokens, $pos, 'close_results' );
				$nodes[]  = array( 'type' => 'results', 'children' => $children );
				continue;
			}
			if ( 'open_empty' === $t['type'] ) {
				$pos++;
				$children = self::parse( $tokens, $pos, 'close_empty' );
				$nodes[]  = array( 'type' => 'empty', 'children' => $children );
				continue;
			}
			if ( 'open_if' === $t['type'] ) {
				$pos++;
				$nodes[] = self::if_node( $t['cond'], self::parse( $tokens, $pos, 'close_if' ) );
				continue;
			}
			// text | ph
			$nodes[] = $t;
			$pos++;
		}
		return $nodes;
	}

	/**
	 * An {{#if}} with its two branches.
	 *
	 * {{else}} opens and closes nothing: it splits the if's body in two. The {{else}}s after the
	 * first are a writing mistake and stay where they are, that is in the negative branch — never
	 * printed, because the render ignores them.
	 */
	protected static function if_node( $cond, $children ) {
		$split = null;
		foreach ( $children as $i => $c ) {
			if ( 'else' === $c['type'] ) {
				$split = $i;
				break;
			}
		}

		return array(
			'type'     => 'if',
			'cond'     => $cond,
			'children' => ( null === $split ) ? $children : array_slice( $children, 0, $split ),
			'else'     => ( null === $split ) ? array() : array_slice( $children, $split + 1 ),
		);
	}

	/* -------------------------------- Rendering ------------------------------ */

	/**
	 * @param array $nodes  Nodes to render.
	 * @param array $scope  Stack of loop items (the last one = innermost loop).
	 * @param array $values Top-level props (always reachable).
	 */
	protected static function render_nodes( $nodes, $scope, $values ) {
		$out = '';
		foreach ( $nodes as $node ) {
			switch ( $node['type'] ) {
				case 'text':
					$out .= $node['value'];
					break;

				case 'ph':
					$val = self::resolve( $node['path'], $scope, $values );
					if ( ( '' === $val || null === $val ) && ! empty( $node['default'] ) ) {
						$val = $node['default'];
					}
					$out .= self::sanitize( $node['kind'], $val, $node['path'] );
					break;

				case 'if':
					$val    = self::resolve( $node['cond'], $scope, $values );
					$truthy = ! ( empty( $val ) || 'false' === $val || '0' === $val );
					$branch = $truthy ? $node['children'] : ( isset( $node['else'] ) ? $node['else'] : array() );
					if ( $branch ) {
						$out .= self::render_nodes( $branch, $scope, $values );
					}
					break;

				case 'loop':
					$items = self::get_loop_items( $node['attrs'], $scope, $values );
					foreach ( $items as $item ) {
						$inner   = $scope;
						$inner[] = $item; // push the item as the innermost context
						$out    .= self::render_nodes( $node['children'], $inner, $values );
					}
					break;

				case 'filter':
					$out .= self::render_filter( $node, $scope, $values );
					break;

				// Outside a {{#filter}} they have nothing to repeat and no list to declare empty:
				// they print nothing instead of spilling fields that do not exist.
				case 'results':
				case 'empty':
					break;
			}
		}
		return $out;
	}

	/* ----------------------------- Dynamic filters --------------------------- */

	/**
	 * The filter being rendered. It is a stack for hygiene (filters do not nest), so leaving one
	 * always restores the previous context, even if one day they did nest.
	 */
	protected static $filters = array();

	protected static function current_filter() {
		return empty( self::$filters ) ? null : self::$filters[ count( self::$filters ) - 1 ];
	}

	/**
	 * Renders a {{#filter}} block: queries WordPress with the active state, prints the controls just
	 * as the AI wrote them, and delimits the results with the markers the runtime will know how to
	 * replace.
	 */
	protected static function render_filter( $node, $scope, $values ) {
		$attrs = isset( $node['attrs'] ) ? $node['attrs'] : array();
		$id    = isset( $attrs['id'] ) ? sanitize_key( $attrs['id'] ) : self::DEFAULT_FILTER_ID;
		$state = AIPB_Filters::state( $id );
		$res   = self::query_filtered( $attrs, $state );
		$ctx   = self::build_filter_ctx( $id, $attrs, $state, $res );

		self::$filters[] = $ctx;

		$inner    = '';
		$rendered = false;
		foreach ( $node['children'] as $child ) {
			// The results (or the empty-list message) ALWAYS sit inside the markers: that is the
			// range the runtime empties and refills on every filtering.
			if ( 'results' === $child['type'] ) {
				$inner   .= self::wrapped_results( $node, $id, $res['items'], $scope, $values );
				$rendered = true;
				continue;
			}
			if ( 'empty' === $child['type'] ) {
				continue; // already printed in place of the results when needed
			}
			$inner .= self::render_nodes( array( $child ), $scope, $values );
		}

		// A template with no {{#results}}: we print the range anyway, otherwise the filter would be
		// a row of buttons that update nothing.
		if ( ! $rendered ) {
			$inner .= self::wrapped_results( $node, $id, $res['items'], $scope, $values );
		}

		array_pop( self::$filters );

		return AIPB_Filters::wrap( $id, $inner, $ctx );
	}

	/** The results between their two markers. */
	protected static function wrapped_results( $node, $id, $items, $scope, $values ) {
		return AIPB_Filters::marker( $id, 'start' )
			. self::render_results( $node, $items, $scope, $values )
			. AIPB_Filters::marker( $id, 'end' );
	}

	/** A filter's repeated part (or its empty block). */
	protected static function render_results( $filter_node, $items, $scope, $values ) {
		$results = null;
		$empty   = null;
		foreach ( $filter_node['children'] as $child ) {
			if ( 'results' === $child['type'] && null === $results ) {
				$results = $child;
			}
			if ( 'empty' === $child['type'] && null === $empty ) {
				$empty = $child;
			}
		}

		if ( ! $items ) {
			return $empty ? self::render_nodes( $empty['children'], $scope, $values ) : '';
		}
		if ( ! $results ) {
			return '';
		}

		$out = '';
		foreach ( $items as $item ) {
			$inner   = $scope;
			$inner[] = $item;
			$out    .= self::render_nodes( $results['children'], $inner, $values );
		}
		return $out;
	}

	/** The data exposed as {{t:filter.*}} and {{#if filter.*}}. */
	protected static function build_filter_ctx( $id, $attrs, $state, $res ) {
		$has_more = ( (int) $res['page'] < (int) $res['pages'] );
		$filtered = AIPB_Filters::is_filtered( $state );

		// The archive's term, when the filter inherits it: it travels in the container's attributes
		// because the next AJAX request has no archive around it — it starts from a browser, and
		// there the WordPress query no longer exists.
		$current = ( isset( $attrs['term'] ) && 'current' === $attrs['term'] )
			? self::filter_current_term( $state )
			: array();

		// The field filters' values go in at the FIRST level of the context: the DSL's placeholders
		// cannot go any deeper than that ({{t:filter.min_price}}, not {{t:filter.meta.price.min}}),
		// so a nested level would be unreadable.
		return array_merge( self::meta_ctx( $state ), array(
			'id'          => $id,
			'attrs'       => $attrs,
			'state'       => $state,
			'term'        => isset( $current['slug'] ) ? (string) $current['slug'] : '',
			'taxonomy'    => isset( $current['taxonomy'] ) ? (string) $current['taxonomy'] : '',
			'total'       => (int) $res['total'],
			'count'       => count( $res['items'] ),
			'page'        => (int) $res['page'],
			'pages'       => (int) $res['pages'],
			'has_more'    => $has_more ? '1' : '',
			'is_filtered' => $filtered ? '1' : '',
			// Meant to be printed AS AN ATTRIBUTE: they are worth "hidden" or "". They make "load
			// more" and "reset" appear already in the right state, without the instant of flicker
			// there would be while waiting for the runtime.
			'more_hidden'  => $has_more ? '' : 'hidden',
			'reset_hidden' => $filtered ? '' : 'hidden',
			'search'       => (string) $state['s'],
			'sort'         => (string) $state['sort'],
			'reset_url'    => AIPB_Filters::reset_url( $id ),
			'next_url'     => $has_more ? AIPB_Filters::page_url( $id, $state, (int) $res['page'] + 1 ) : '',
			'prev_url'     => ( (int) $res['page'] > 1 ) ? AIPB_Filters::page_url( $id, $state, (int) $res['page'] - 1 ) : '',
		) );
	}

	/**
	 * Renders ONLY a filter's results: this is the AJAX endpoint's answer.
	 *
	 * It re-reads the saved template and redoes everything from scratch — it trusts nothing coming
	 * from the browser except the filter state, which goes through the query's whitelist anyway.
	 *
	 * @return array|null { html, total, count, page, pages, has_more }
	 */
	public static function render_filter_results( $template, $values, $filter_id, $state ) {
		if ( ! is_string( $template ) || '' === $template ) {
			return null;
		}
		$tokens = self::tokenize( $template );
		$pos    = 0;
		$tree   = self::parse( $tokens, $pos, null );
		$node   = self::find_filter_node( $tree, sanitize_key( $filter_id ) );
		if ( ! $node ) {
			return null;
		}

		$values = is_array( $values ) ? $values : array();
		$res    = self::query_filtered( $node['attrs'], $state );
		$ctx    = self::build_filter_ctx( sanitize_key( $filter_id ), $node['attrs'], $state, $res );

		self::$filters[] = $ctx;
		$html            = self::render_results( $node, $res['items'], array(), $values );
		array_pop( self::$filters );

		// Icons and shortcodes are resolved at the end of a section: here there is no section — we
		// are only regenerating the cards — so it is up to us, otherwise on the second filtering the
		// cards would come back different from how they were born.
		if ( class_exists( 'AIPB_Icons' ) ) {
			$html = AIPB_Icons::resolve( $html );
		}
		$html = AIPB_Shortcodes::expand( $html );

		return array(
			'html'     => $html,
			'total'    => (int) $res['total'],
			'count'    => count( $res['items'] ),
			'page'     => (int) $res['page'],
			'pages'    => (int) $res['pages'],
			'has_more' => ( (int) $res['page'] < (int) $res['pages'] ),
		);
	}

	/** Depth-first search for the filter node with a given id. */
	protected static function find_filter_node( $nodes, $id ) {
		foreach ( $nodes as $node ) {
			if ( ! isset( $node['type'] ) ) {
				continue;
			}
			if ( 'filter' === $node['type'] ) {
				$nid = isset( $node['attrs']['id'] ) ? sanitize_key( $node['attrs']['id'] ) : self::DEFAULT_FILTER_ID;
				if ( $nid === $id ) {
					return $node;
				}
			}
			if ( ! empty( $node['children'] ) ) {
				$deep = self::find_filter_node( $node['children'], $id );
				if ( $deep ) {
					return $deep;
				}
			}
		}
		return null;
	}

	/* ---------------------------------- Loops -------------------------------- */

	/**
	 * Fetches a loop's items.
	 *   source="prop"  -> array from the values (repeater) or from the current item (name="field.xxx")
	 *   source="post"  -> WP_Query with whitelisted parameters
	 *   source="menu"  -> the site's navigation, as a tree
	 *   source="term"  -> a taxonomy's terms
	 */
	protected static function get_loop_items( $attrs, $scope, $values ) {
		$source = isset( $attrs['source'] ) ? $attrs['source'] : 'prop';

		if ( 'prop' === $source ) {
			return self::prop_rows( $attrs, $scope, $values );
		}
		if ( 'post' === $source ) {
			return self::query_posts( $attrs );
		}
		if ( 'menu' === $source ) {
			return self::query_menu( $attrs );
		}
		if ( 'term' === $source ) {
			return self::query_terms( $attrs );
		}

		return array();
	}

	/**
	 * The rows of a source="prop" loop: a repeater, or a list belonging to the current content.
	 *
	 * Repeaters stay ACF's business: it encodes them its own way and we do not get in the middle.
	 * Our fields are scalars — that is the choice that makes them filterable, because a value inside
	 * a JSON blob cannot be queried.
	 */
	protected static function prop_rows( $attrs, $scope, $values ) {
		$name = isset( $attrs['name'] ) ? $attrs['name'] : '';
		if ( '' === $name ) {
			return array();
		}

		if ( 0 === strpos( $name, 'field.' ) ) {
			$rows = self::resolve( $name, $scope, $values ); // nested repeater: from the current item
		} elseif ( 0 === strpos( $name, 'post.' ) ) {
			// Lists belonging to the current content: post.terms, or a repeater.
			$rows = self::owner_rows( self::current_post(), substr( $name, 5 ), false );
		} elseif ( 0 === strpos( $name, 'term.' ) ) {
			// Likewise for the archive's term: a repeater on the category.
			$rows = self::owner_rows( self::current_term(), substr( $name, 5 ), true );
		} else {
			$rows = isset( $values[ $name ] ) ? $values[ $name ] : array();
		}

		return is_array( $rows ) ? array_values( $rows ) : array();
	}

	/** A list read from the current content or term: a custom field, or a native key. */
	protected static function owner_rows( $item, $key, $is_term ) {
		$field = self::custom_field_name( $key );
		if ( '' !== $field && isset( $item['id'] ) ) {
			return AIPB_Fields::value( (int) $item['id'], $field, $is_term );
		}

		return isset( $item[ $key ] ) ? $item[ $key ] : array();
	}

	/**
	 * A taxonomy's terms: these are what a filter's chips are drawn with.
	 *
	 * Inside a {{#filter}} every term already knows whether it is active and which URL it leads to
	 * (with itself switched on or off). Outside a filter it stays useful all the same: `url` becomes
	 * the link to the term's archive, so a list of categories can be printed anywhere.
	 */
	protected static function query_terms( $attrs ) {
		$tax = isset( $attrs['taxonomy'] ) ? sanitize_key( $attrs['taxonomy'] ) : '';

		// Inside a filter the taxonomy can be omitted when the filter declares only one.
		if ( '' === $tax ) {
			$ctx = self::current_filter();
			if ( $ctx && ! empty( $ctx['attrs']['taxonomy'] ) ) {
				$tax = sanitize_key( $ctx['attrs']['taxonomy'] );
			}
		}
		if ( '' === $tax || ! taxonomy_exists( $tax ) ) {
			return array();
		}

		$terms = get_terms( self::term_query_args( $attrs, $tax ) );
		if ( is_wp_error( $terms ) || ! $terms ) {
			return array();
		}

		// Terms of WHICH content type. Inside a filter it is the filter's; it can be forced with
		// post_type="…". Without this, a product catalogue's chips show the categories (and the
		// counts) of the WHOLE site — posts included — and offer the visitor filters that cannot but
		// return zero results. It really happened: a catalogue of 23 products with the blog's
		// category chips, which no product used.
		$post_type = isset( $attrs['post_type'] ) ? sanitize_key( $attrs['post_type'] ) : '';
		if ( '' === $post_type ) {
			$ctx = self::current_filter();
			if ( $ctx && ! empty( $ctx['attrs']['type'] ) ) {
				$post_type = sanitize_key( $ctx['attrs']['type'] );
			}
		}

		// Counts restricted to the content type: they are the true ones for this list, and they also
		// serve to drop the terms that have nothing for THIS type.
		$counts = ( '' !== $post_type ) ? self::term_counts( $tax, $post_type ) : null;
		$hide   = ! self::attr_off( $attrs, 'hide_empty' );

		$ctx    = self::current_filter();
		$active = ( $ctx && isset( $ctx['state']['tax'][ $tax ] ) ) ? (array) $ctx['state']['tax'][ $tax ] : array();

		$out = array();
		foreach ( $terms as $t ) {
			if ( null !== $counts ) {
				$n = isset( $counts[ (int) $t->term_id ] ) ? $counts[ (int) $t->term_id ] : 0;
				if ( ! $n && $hide ) {
					continue;
				}
				$t->count = $n;
			}
			$out[] = array_merge( self::term_custom_fields( $t ), self::term_row( $t, $tax, $ctx, $active ) );
		}
		return $out;
	}

	/** get_terms() arguments, all whitelisted. */
	protected static function term_query_args( $attrs, $tax ) {
		$allowed_orderby = array( 'name', 'count', 'slug', 'term_order', 'none' );
		$orderby         = ( isset( $attrs['orderby'] ) && in_array( $attrs['orderby'], $allowed_orderby, true ) ) ? $attrs['orderby'] : 'name';

		$args = array(
			'taxonomy'   => $tax,
			'orderby'    => $orderby,
			'order'      => ( isset( $attrs['order'] ) && 'DESC' === strtoupper( $attrs['order'] ) ) ? 'DESC' : 'ASC',
			// By default terms with no content are not shown: a chip that gives zero results is just
			// a way of walking the visitor into a wall.
			'hide_empty' => ! self::attr_off( $attrs, 'hide_empty' ),
		);
		if ( isset( $attrs['limit'] ) ) {
			$args['number'] = max( 1, min( 200, (int) $attrs['limit'] ) );
		}
		if ( isset( $attrs['parent'] ) ) {
			$args['parent'] = (int) $attrs['parent'];
		}
		if ( ! empty( $attrs['include'] ) ) {
			$slugs = array_filter( array_map( 'sanitize_title', explode( ',', (string) $attrs['include'] ) ) );
			if ( $slugs ) {
				$args['slug'] = $slugs;
			}
		}

		return $args;
	}

	/** One term as the template consumes it, with its state inside the filter. */
	protected static function term_row( $t, $tax, $ctx, $active ) {
		$is_on = in_array( $t->slug, $active, true );
		$link  = get_term_link( $t );

		return array(
			'name'        => $t->name,
			'slug'        => $t->slug,
			'id'          => (int) $t->term_id,
			'count'       => (int) $t->count,
			'description' => $t->description,
			'taxonomy'    => $tax,
			// Inside a filter: the URL with this term toggled. Outside: the term's archive.
			'url'         => $ctx
				? AIPB_Filters::toggle_url( $ctx['id'], $ctx['state'], $tax, $t->slug )
				: ( is_wp_error( $link ) ? '' : $link ),
			'permalink'    => is_wp_error( $link ) ? '' : $link,
			'active'       => $is_on ? '1' : '',
			'aria_current' => $is_on ? 'true' : '',
			'aria_pressed' => $is_on ? 'true' : 'false',
			// For get_field(): a term is not addressed by its bare id — that is a POST's id — but by
			// "term_<id>". See resolve(), field.acf_…
			'_acf'         => 'term_' . (int) $t->term_id,
		);
	}

	/**
	 * A term's custom fields, ready to print inside the loop.
	 *
	 * A term is not only a name and a count: whoever curates a catalogue puts a cover photo, a
	 * strapline, a colour on the category. Without this, a list of categories could only be a row of
	 * text chips — and a template asking for {{i:field.cover}} printed nothing without saying why.
	 *
	 * ACF images (return format "array" or "id") become the URL, which is what {{i:…}} needs; width,
	 * height, alternative text and id stay alongside. The other arrays — the repeaters — stay
	 * arrays, loopable with {{#loop source="prop" name="field.xxx"}}.
	 *
	 * @param WP_Term $t
	 * @return array keys already shaped for field.<key>
	 */
	protected static function term_custom_fields( $t ) {
		if ( ! function_exists( 'get_fields' ) ) {
			return array();
		}
		$fields = get_fields( 'term_' . (int) $t->term_id );
		if ( ! is_array( $fields ) || ! $fields ) {
			return array();
		}

		$out = array();
		foreach ( $fields as $key => $v ) {
			$k = sanitize_key( (string) $key );
			if ( '' === $k ) {
				continue;
			}

			// Image/file with return format "array": the URL is the field, the rest sits alongside.
			if ( is_array( $v ) && isset( $v['url'] ) ) {
				$out[ $k ]          = (string) $v['url'];
				$out[ $k . '_id' ]  = isset( $v['ID'] ) ? (int) $v['ID'] : '';
				$out[ $k . '_alt' ] = isset( $v['alt'] ) ? (string) $v['alt'] : '';
				$out[ $k . '_w' ]   = isset( $v['width'] ) ? (int) $v['width'] : '';
				$out[ $k . '_h' ]   = isset( $v['height'] ) ? (int) $v['height'] : '';
				continue;
			}

			$out[ $k ] = $v;

			// Return format "id": the value stays the number (it could be any numeric field), but if
			// there really is an image behind it, its URL is added alongside.
			if ( is_scalar( $v ) && ctype_digit( (string) $v ) && (int) $v > 0 ) {
				$src = wp_get_attachment_image_src( (int) $v, 'large' );
				if ( $src ) {
					$out[ $k . '_url' ] = $src[0];
					$out[ $k . '_w' ]   = (int) $src[1];
					$out[ $k . '_h' ]   = (int) $src[2];
					$out[ $k . '_alt' ] = (string) get_post_meta( (int) $v, '_wp_attachment_image_alt', true );
				}
			}
		}
		return $out;
	}

	/* -------------------------- The current content -------------------------- */

	/** Sample content for the Studio preview, where there is no query at all. */
	protected static $sample_post = 0;

	public static function set_sample_post( $post_id ) {
		self::$sample_post  = (int) $post_id;
		self::$current_post = null; // the cache is per request: if the sample changes it must be redone
	}

	/** Sample term, same reason: in the Studio no archive is "open". */
	protected static $sample_term = 0;

	public static function set_sample_term( $term_id ) {
		self::$sample_term  = (int) $term_id;
		self::$current_term = null;
	}

	protected static $current_post = null;

	/**
	 * The fields of the content being looked at, in the same shape as loop items — so whoever has
	 * learnt field.title already knows how to write post.title.
	 *
	 * On the site it is the queried object. In the Studio there is no query, so a sample content is
	 * used (see set_sample_post): without it, the preview of a "single item" template would be an
	 * empty page and nobody could tell whether the template works.
	 */
	protected static function current_post() {
		if ( null !== self::$current_post ) {
			return self::$current_post;
		}
		self::$current_post = array();

		$post = null;
		if ( function_exists( 'get_queried_object' ) ) {
			$q = get_queried_object();
			if ( $q instanceof WP_Post ) {
				$post = $q;
			}
		}
		if ( ! $post && self::$sample_post ) {
			$post = get_post( self::$sample_post );
		}
		if ( ! $post instanceof WP_Post ) {
			return self::$current_post;
		}

		$item = self::build_item( $post, $post->post_type, array() );

		// The content's body, run through WordPress's filters as a theme would. The guard avoids the
		// edge case where the rendered content itself contains this template (infinite recursion).
		static $rendering = false;
		if ( ! $rendering ) {
			$rendering       = true;
			$item['content'] = apply_filters( 'the_content', $post->post_content );
			$rendering       = false;
		} else {
			$item['content'] = '';
		}

		$item['author']    = get_the_author_meta( 'display_name', (int) $post->post_author );
		$item['modified']  = get_the_modified_date( '', $post );
		$item['post_type'] = $post->post_type;

		self::$current_post = $item;
		return self::$current_post;
	}

	protected static $current_term = null;

	/**
	 * The TERM being looked at: the category on its archive, the tag on its own.
	 *
	 * It is the scope archives were missing, and it is the exact twin of current_post(): there the
	 * queried object is a WP_Post, here it is a WP_Term, and current_post() discards it on purpose
	 * (a term has no body, no author, no date). Without this, a "category archive" template could
	 * only have its title written by hand: {{t:post.title}} on an archive is EMPTY, because the
	 * current content simply is not a content.
	 *
	 * The key names are the same as the items of {{#loop source="term"}} — whoever has learnt
	 * field.name already knows how to write term.name — with the addition of title, which is the
	 * word everybody uses when asking for this ("put the category's title in").
	 */
	protected static function current_term() {
		if ( null !== self::$current_term ) {
			return self::$current_term;
		}
		self::$current_term = array();

		$term = null;
		if ( function_exists( 'get_queried_object' ) ) {
			$q = get_queried_object();
			if ( $q instanceof WP_Term ) {
				$term = $q;
			}
		}
		if ( ! $term && self::$sample_term ) {
			$t = get_term( self::$sample_term );
			if ( $t instanceof WP_Term ) {
				$term = $t;
			}
		}
		if ( ! $term instanceof WP_Term ) {
			return self::$current_term;
		}

		$link = get_term_link( $term );
		$tax  = get_taxonomy( $term->taxonomy );

		$item = array(
			'name'  => $term->name,
			'title' => $term->name, // alias: this is what whoever asks for it calls it
			'slug'  => $term->slug,
			'id'    => (int) $term->term_id,
			'count' => (int) $term->count,
			// Raw text, exactly like field.description inside loops: whoever writes
			// {{t:term.description}} wants the text. The paragraphs are added by {{h:…}} — see
			// sanitize().
			'description'    => $term->description,
			'taxonomy'       => $term->taxonomy,
			'taxonomy_label' => ( $tax && isset( $tax->labels->singular_name ) ) ? $tax->labels->singular_name : $term->taxonomy,
			'url'            => is_wp_error( $link ) ? '' : $link,
			'permalink'      => is_wp_error( $link ) ? '' : $link,
			// For get_field(): a term is addressed with "term_<id>", not with the bare id.
			'_acf'           => 'term_' . (int) $term->term_id,
		);

		// The parent, for breadcrumbs without inventing a query.
		if ( (int) $term->parent > 0 ) {
			$parent = get_term( (int) $term->parent, $term->taxonomy );
			if ( $parent instanceof WP_Term ) {
				$plink               = get_term_link( $parent );
				$item['parent_name'] = $parent->name;
				$item['parent_url']  = is_wp_error( $plink ) ? '' : $plink;
			}
		}

		// The custom fields go UNDER the native keys: an ACF field named "name" must not be able to
		// shadow the term's name.
		self::$current_term = array_merge( self::term_custom_fields( $term ), $item );
		return self::$current_term;
	}

	/**
	 * How many PUBLISHED contents of a given type each term of a taxonomy has.
	 *
	 * WordPress keeps a single count per term, which sums every type: on a site where the same
	 * taxonomy serves posts and products that number says nothing useful to whoever is looking at a
	 * catalogue. One query, with a join, and the numbers are the right ones again.
	 *
	 * @return array term_id => count
	 */
	protected static function term_counts( $taxonomy, $post_type ) {
		static $memo = array();
		$key = $taxonomy . '|' . $post_type;
		if ( isset( $memo[ $key ] ) ) {
			return $memo[ $key ];
		}

		global $wpdb;
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT tt.term_id AS term_id, COUNT(*) AS n
			 FROM {$wpdb->term_relationships} tr
			 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
			 INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
			 WHERE tt.taxonomy = %s AND p.post_type = %s AND p.post_status = 'publish'
			 GROUP BY tt.term_id",
			$taxonomy,
			$post_type
		) );

		$out = array();
		foreach ( (array) $rows as $r ) {
			$out[ (int) $r->term_id ] = (int) $r->n;
		}
		$memo[ $key ] = $out;
		return $out;
	}

	/**
	 * The site's menu entries, as a tree.
	 *
	 * Where the menu lives — a `wp_navigation` post in a block theme, a `nav_menu` term in a classic
	 * one, or the list of pages — is decided by AIPB_Nav, the one place where that knowledge lives.
	 * Only the loop's attributes are left here.
	 *
	 * Attributes: ref="12" | location="primary" | name="Main menu" | limit="6"
	 * Submenus are in field.children: {{#loop source="prop" name="field.children"}}.
	 */
	protected static function query_menu( $attrs ) {
		if ( ! class_exists( 'AIPB_Nav' ) ) {
			return array();
		}
		$tree  = AIPB_Nav::items( $attrs );
		$limit = isset( $attrs['limit'] ) ? max( 1, min( 100, (int) $attrs['limit'] ) ) : 0;
		return $limit ? array_slice( $tree, 0, $limit ) : $tree;
	}

	/* ------------------------------ Field filters ---------------------------- */

	/**
	 * The custom-field conditions, for WP_Query.
	 *
	 * Two gates, and both are needed. The first: the field must be among those the SECTION declared
	 * filterable (`meta="price,date"`). The second: it must be a field really declared for that
	 * content type, so the comparison uses the right type — a number compared as text puts «100»
	 * before «20», which is the most elegant way of getting a price filter wrong without anyone
	 * noticing.
	 *
	 * @return array
	 */
	protected static function meta_conditions( $attrs, $state, $post_type ) {
		$allowed = array();
		foreach ( explode( ',', (string) ( isset( $attrs['meta'] ) ? $attrs['meta'] : '' ) ) as $name ) {
			$name = sanitize_key( trim( $name ) );
			if ( '' !== $name ) {
				$allowed[ $name ] = true;
			}
		}
		if ( ! $allowed || empty( $state['meta'] ) ) {
			return array();
		}

		$out = array();

		foreach ( (array) $state['meta'] as $field => $cond ) {
			$field = sanitize_key( $field );
			if ( ! isset( $allowed[ $field ] ) ) {
				continue;
			}

			$type = class_exists( 'AIPB_Fields' ) ? AIPB_Fields::type_of( $post_type, $field ) : '';
			if ( '' === $type ) {
				continue; // not a field of this content type: as far as we are concerned it does not exist
			}

			$clause = self::meta_clause( $field, $cond, $type );
			if ( $clause ) {
				$out[] = $clause;
			}
		}

		if ( count( $out ) > 1 ) {
			// Conditions on different fields add up: under 30 euros **and** after 1 March.
			$out['relation'] = 'AND';
		}

		return $out;
	}

	/** One condition on one field: a range on what can be ordered, otherwise an equality. */
	protected static function meta_clause( $field, $cond, $type ) {
		$cast = 'number' === $type ? 'DECIMAL(20,4)' : ( 'date' === $type ? 'DATE' : 'CHAR' );
		$min  = isset( $cond['min'] ) ? trim( (string) $cond['min'] ) : '';
		$max  = isset( $cond['max'] ) ? trim( (string) $cond['max'] ) : '';

		// Ranges only make sense on something that can be ordered.
		if ( ( '' !== $min || '' !== $max ) && in_array( $type, array( 'number', 'date' ), true ) ) {
			$min = self::clean_bound( $min, $type );
			$max = self::clean_bound( $max, $type );

			if ( '' !== $min && '' !== $max ) {
				return array( 'key' => $field, 'value' => array( $min, $max ), 'type' => $cast, 'compare' => 'BETWEEN' );
			}
			if ( '' !== $min ) {
				return array( 'key' => $field, 'value' => $min, 'type' => $cast, 'compare' => '>=' );
			}
			if ( '' !== $max ) {
				return array( 'key' => $field, 'value' => $max, 'type' => $cast, 'compare' => '<=' );
			}

			return null;
		}

		$values = array_values( array_filter( array_map( 'sanitize_text_field', (array) ( isset( $cond['eq'] ) ? $cond['eq'] : array() ) ) ) );
		if ( ! $values ) {
			return null;
		}

		return array(
			'key'     => $field,
			'value'   => 1 === count( $values ) ? $values[0] : $values,
			'type'    => $cast,
			'compare' => 1 === count( $values ) ? '=' : 'IN',
		);
	}

	/**
	 * The current values of the field filters, flattened: min_price, max_price, m_colour.
	 *
	 * Flattened because the DSL's placeholders cannot go two levels down: {{t:filter.min_price}}
	 * exists, {{t:filter.meta.price.min}} does not.
	 */
	protected static function meta_ctx( $state ) {
		$out = array();

		foreach ( (array) ( isset( $state['meta'] ) ? $state['meta'] : array() ) as $field => $cond ) {
			$field = sanitize_key( $field );
			foreach ( array( 'min', 'max' ) as $side ) {
				if ( isset( $cond[ $side ] ) && '' !== $cond[ $side ] ) {
					$out[ $side . '_' . $field ] = (string) $cond[ $side ];
				}
			}
			if ( ! empty( $cond['eq'] ) ) {
				$out[ 'm_' . $field ] = implode( ',', (array) $cond['eq'] );
			}
		}

		return $out;
	}

	/** One end of a range, or '' when it is not of the type it claims to be. */
	protected static function clean_bound( $raw, $type ) {
		$raw = trim( (string) $raw );
		if ( '' === $raw ) {
			return '';
		}
		if ( 'date' === $type ) {
			return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ? $raw : '';
		}
		// A price written the continental way arrives from the URL too: «24,50» is a number.
		if ( false === strpos( $raw, '.' ) ) {
			$raw = str_replace( ',', '.', $raw );
		}

		return is_numeric( $raw ) ? (string) ( 0 + (float) $raw ) : '';
	}

	/**
	 * The term to use for a filter that inherits the archive.
	 *
	 * Two sources, in order: the endpoint's (already validated: public taxonomy really attached to
	 * the type, existing term) and WordPress's query, which applies on the first render. They are
	 * never mixed — if the browser said something, that something has already been through the
	 * checks, and the archive's query is not here.
	 *
	 * @param array $state The filter's state; it may contain 'current' => { taxonomy, slug }.
	 * @return array { taxonomy: string, slug: string } — empty when we are not on an archive.
	 */
	protected static function filter_current_term( $state ) {
		// The PRESENCE of the key says who is in charge: if it is there, the request comes from the
		// endpoint and the term is the one that passed the checks — empty included. Falling back to
		// WordPress's context in that case would be a hole: a term invented by the browser would be
		// discarded, and then the list would fill up all the same with another archive's contents,
		// instead of staying empty as it must.
		if ( array_key_exists( 'current', $state ) ) {
			$cur = is_array( $state['current'] ) ? $state['current'] : array();

			return empty( $cur['slug'] )
				? array()
				: array( 'taxonomy' => (string) $cur['taxonomy'], 'slug' => (string) $cur['slug'] );
		}

		// First render: WordPress names the term, because we are inside the archive's query.
		$cur = self::current_term();

		return empty( $cur['slug'] )
			? array()
			: array( 'taxonomy' => (string) $cur['taxonomy'], 'slug' => (string) $cur['slug'] );
	}

	/* -------------------------------- Resolving ------------------------------ */

	/**
	 * The name of the custom field behind a placeholder, '' when it is not one.
	 *
	 * `acf_` stays the prefix the service teaches and that is in every template already generated:
	 * changing it would be a breakage for nothing. It no longer means «an ACF field» though, it
	 * means «a custom field of this content» — and indeed it is read without ACF too. `meta_` is
	 * accepted as a synonym for whoever finds it absurd to write the name of a plugin they have not
	 * installed; it is not taught yet, so it breaks no older engine.
	 */
	protected static function custom_field_name( $key ) {
		foreach ( array( 'acf_', 'meta_' ) as $prefix ) {
			if ( 0 === strpos( $key, $prefix ) ) {
				return substr( $key, strlen( $prefix ) );
			}
		}

		return '';
	}

	/**
	 * Resolves a path.
	 *   "post.x"   -> the content being looked at
	 *   "term.x"   -> the term being looked at (archives)
	 *   "filter.x" -> the state of the filter being rendered
	 *   "field.x"  -> the innermost loop's item (with ACF support: field.acf_price)
	 *   "x"        -> top-level props (values)
	 */
	protected static function resolve( $path, $scope, $values ) {
		// The CONTENT being looked at: the product on its page, the article on its own. It is the
		// scope that was missing, and without which a "single item" template could only come out
		// static: there were loops to LIST contents and props for hand-written text, but nothing to
		// say «the title of THIS one».
		if ( 0 === strpos( $path, 'post.' ) ) {
			return self::resolve_owner( self::current_post(), substr( $path, 5 ), false );
		}

		// The TERM being looked at: it is the archives' scope, where post.* has nothing to say. Same
		// shape as post.*, custom fields included (term.acf_cover).
		if ( 0 === strpos( $path, 'term.' ) ) {
			return self::resolve_owner( self::current_term(), substr( $path, 5 ), true );
		}

		// State of the filter being rendered: totals, page, service URLs.
		if ( 0 === strpos( $path, 'filter.' ) ) {
			$ctx = self::current_filter();
			if ( ! $ctx ) {
				return '';
			}
			$key = substr( $path, 7 );
			return isset( $ctx[ $key ] ) && ! is_array( $ctx[ $key ] ) ? $ctx[ $key ] : '';
		}

		if ( 0 === strpos( $path, 'field.' ) ) {
			$key  = substr( $path, 6 );
			$item = empty( $scope ) ? array() : $scope[ count( $scope ) - 1 ];
			$name = self::custom_field_name( $key );
			if ( '' !== $name && isset( $item['id'] ) ) {
				// A taxonomy item carries '_acf': the sign that its owner is a term.
				return AIPB_Fields::value( (int) $item['id'], $name, isset( $item['_acf'] ) );
			}
			if ( isset( $item[ $key ] ) ) {
				return $item[ $key ];
			}
			// The same courtesy inside loops: a repeater's image or a term's ACF field
			// ({{i:field.cover}}) also expose field.cover_srcset and company. On content items
			// nothing changes: field.thumbnail_* already exists and has already been returned above.
			$companion = self::media_companion( $key, $item );
			return ( null !== $companion ) ? $companion : '';
		}

		if ( isset( $values[ $path ] ) ) {
			// ALTERNATIVE TEXT: the source of truth is the MEDIA LIBRARY, not the section's panel.
			// An image described once on the attachment is described everywhere it is used; an alt
			// written in the panel holds for that section only, and the same photo in three sections
			// ends up with three different descriptions — or two forgotten ones.
			//
			// It is the only field where the attachment beats the panel, and it is the right
			// exception: the alt describes THE IMAGE, not the use made of it here. The written value
			// stays as a FALLBACK until the attachment has its own, though: a Library that has not
			// been filled in yet must not empty the alts already present in the sections.
			if ( '_alt' === substr( $path, -4 ) ) {
				$lib = self::media_companion( $path, $values );
				if ( is_string( $lib ) && '' !== trim( $lib ) ) {
					return $lib;
				}
			}
			return $values[ $path ];
		}

		// An undeclared prop shaped like an image companion (photo_srcset, photo_w…): it is derived
		// from the attachment.
		$companion = self::media_companion( $path, $values );
		return ( null !== $companion ) ? $companion : '';
	}

	/** A scalar read from the current content or term: a custom field, or a native key. */
	protected static function resolve_owner( $item, $key, $is_term ) {
		$name = self::custom_field_name( $key );
		if ( '' !== $name && isset( $item['id'] ) ) {
			$v = AIPB_Fields::value( (int) $item['id'], $name, $is_term );
			return is_scalar( $v ) ? $v : '';
		}

		return ( isset( $item[ $key ] ) && ! is_array( $item[ $key ] ) ) ? $item[ $key ] : '';
	}

	/* --------------------------------- Queries ------------------------------- */

	/**
	 * WP_Query with a strict whitelist of parameters.
	 */
	protected static function query_posts( $attrs ) {
		$allowed_orderby = array( 'date', 'title', 'menu_order', 'rand', 'modified' );
		$orderby         = ( isset( $attrs['orderby'] ) && in_array( $attrs['orderby'], $allowed_orderby, true ) ) ? $attrs['orderby'] : 'date';
		$order           = ( isset( $attrs['order'] ) && 'ASC' === strtoupper( $attrs['order'] ) ) ? 'ASC' : 'DESC';
		$post_type       = isset( $attrs['type'] ) ? sanitize_key( $attrs['type'] ) : 'post';
		$limit           = isset( $attrs['limit'] ) ? max( 1, min( 50, (int) $attrs['limit'] ) ) : 6;

		$args = array(
			'post_type'           => $post_type,
			'posts_per_page'      => $limit,
			'orderby'             => $orderby,
			'order'               => $order,
			'post_status'         => 'publish',
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
		);

		// Optional taxonomy filter: taxonomy="category" term="offers".
		//
		// term="current" is the ARCHIVES' case: the contents OF THIS category. Without it, the loop
		// of an archive template lists every content of the type and shows the exact same list on
		// every category — a mistake that gives no sign of itself until two different archives are
		// opened. The taxonomy can be omitted: it is that of the term being looked at.
		if ( isset( $attrs['term'] ) && 'current' === $attrs['term'] ) {
			$cur = self::current_term();
			if ( empty( $cur['slug'] ) ) {
				// "The contents of this category" outside an archive means nothing, and listing them
				// all would be the wrong answer to the question asked.
				return array();
			}
			$args['tax_query'] = array(
				array(
					'taxonomy' => ! empty( $attrs['taxonomy'] ) ? sanitize_key( $attrs['taxonomy'] ) : $cur['taxonomy'],
					'field'    => 'slug',
					'terms'    => $cur['slug'],
				),
			);
		} elseif ( ! empty( $attrs['taxonomy'] ) && ! empty( $attrs['term'] ) ) {
			$args['tax_query'] = array(
				array(
					'taxonomy' => sanitize_key( $attrs['taxonomy'] ),
					'field'    => 'slug',
					'terms'    => sanitize_title( $attrs['term'] ),
				),
			);
		}

		$q     = new WP_Query( $args );
		$items = array();

		foreach ( $q->posts as $post ) {
			$items[] = self::build_item( $post, $post_type, $attrs );
		}
		wp_reset_postdata();
		return $items;
	}

	/**
	 * A post → the item the template consumes with {{t:field.*}}.
	 *
	 * Extracted because the plain loop and the filtered loop share it: two copies of this list would
	 * have drifted apart on the first field added.
	 */
	protected static function build_item( $post, $post_type, $attrs = array() ) {
		$size = 'large';
		// Featured image with responsive data (srcset/sizes/dimensions/alt) built from Bootstrap
		// breakpoints — see AIPB_Srcset — so the AI can compose an <img> that does not download
		// the original onto a phone.
		$thumb_id = get_post_thumbnail_id( $post );
		$src      = $thumb_id ? wp_get_attachment_image_src( $thumb_id, $size ) : false;

		// No featured image: we look at the FALLBACK, the remote address Aura keeps in a meta for
		// the contents it created (see AIPB_Images). It is not an attachment, so no srcset and no
		// dimensions — and that is fine: a remote image with no responsive data is still a page one
		// can look at, a hole is not. The day someone uploads the real image, that one wins on its
		// own, without anybody having to remove anything.
		$fallback = ( ! $src && class_exists( 'AIPB_Images' ) )
			? (string) get_post_meta( $post->ID, AIPB_Images::META, true )
			: '';

		$item = array(
			'title'     => get_the_title( $post ),
			'permalink' => get_permalink( $post ),
			// Alias of 'permalink'. On taxonomy items and menu entries the link is called 'url', so
			// writing {{u:field.url}} on a content card too is the most natural mistake in the world
			// — and it produces an EMPTY href, that is a card that cannot be clicked: a silent
			// failure, which nobody sees until they try to click. It costs one line to make it work
			// instead of leaving it to break.
			'url'              => get_permalink( $post ),
			'excerpt'          => get_the_excerpt( $post ),
			'thumbnail'        => $src ? $src[0] : $fallback,
			'thumbnail_w'      => $src ? (int) $src[1] : '',
			'thumbnail_h'      => $src ? (int) $src[2] : '',
			'thumbnail_srcset' => self::item_srcset( $thumb_id, $src ),
			'thumbnail_sizes'  => self::item_sizes( $thumb_id, $src ),
			'thumbnail_alt'    => $thumb_id ? (string) get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ) : '',
			'date'             => get_the_date( '', $post ),
			'id'               => $post->ID,
			'_post'            => $post, // for any extended getters (e.g. ACF).
		);

		$item = array_merge( $item, self::item_terms( $post, $post_type, $attrs ) );

		// Type-specific fields (WooCommerce prices, etc.). Kept out of the base block because a
		// plain post should not pay their cost.
		return self::enrich_item( $item, $post, $post_type );
	}

	/**
	 * The content's terms, to print their labels on the card.
	 *
	 * Which taxonomy: the one declared with terms="…", otherwise the type's first public one — which
	 * in practice is "category" for posts and the main taxonomy for a CPT. No extra query: WP_Query
	 * has already warmed the term cache for these posts.
	 */
	protected static function item_terms( $post, $post_type, $attrs ) {
		$empty = array( 'terms' => array(), 'term_names' => '', 'term_slugs' => '' );

		$tax = isset( $attrs['terms'] ) ? sanitize_key( $attrs['terms'] ) : '';
		if ( '' === $tax ) {
			foreach ( get_object_taxonomies( $post_type, 'objects' ) as $t ) {
				if ( ! empty( $t->public ) ) {
					$tax = $t->name;
					break;
				}
			}
		}
		if ( '' === $tax || ! taxonomy_exists( $tax ) ) {
			return $empty;
		}

		$terms = get_the_terms( $post, $tax );
		if ( is_wp_error( $terms ) || ! $terms ) {
			return $empty;
		}

		$rows  = array();
		$names = array();
		$slugs = array();
		foreach ( $terms as $t ) {
			$link    = get_term_link( $t );
			$rows[]  = array(
				'name' => $t->name,
				'slug' => $t->slug,
				'id'   => (int) $t->term_id,
				'url'  => is_wp_error( $link ) ? '' : $link,
			);
			$names[] = $t->name;
			$slugs[] = $t->slug;
		}

		return array(
			'terms'      => $rows,                   // loopable: {{#loop source="prop" name="field.terms"}}
			'term_names' => implode( ', ', $names ), // ready to print
			'term_slugs' => implode( ' ', $slugs ),  // handy as class="" for styling
		);
	}

	/**
	 * A {{#filter}}'s query: the template's constraints PLUS what the visitor chose.
	 *
	 * The difference from query_posts() is not only the pagination: public input comes in here, and
	 * the only thing it can do is NARROW. The content type, the per-page limit and the fixed
	 * constraints stay the ones written in the template; from the browser only slugs of public
	 * taxonomies attached to that type, a search and a whitelisted sort order arrive.
	 *
	 * @return array { items, total, pages, page }
	 */
	public static function query_filtered( $attrs, $state ) {
		$post_type = isset( $attrs['type'] ) ? sanitize_key( $attrs['type'] ) : 'post';
		$limit     = isset( $attrs['limit'] ) ? max( 1, min( 50, (int) $attrs['limit'] ) ) : 9;
		$page      = isset( $state['page'] ) ? max( 1, (int) $state['page'] ) : 1;

		$allowed_orderby = array( 'date', 'title', 'menu_order', 'rand', 'modified' );
		$orderby         = ( isset( $attrs['orderby'] ) && in_array( $attrs['orderby'], $allowed_orderby, true ) ) ? $attrs['orderby'] : 'date';
		$order           = ( isset( $attrs['order'] ) && 'ASC' === strtoupper( $attrs['order'] ) ) ? 'ASC' : 'DESC';

		// Sort order chosen by the visitor: "field-direction", e.g. "title-asc".
		if ( ! empty( $state['sort'] ) ) {
			$bits = explode( '-', (string) $state['sort'] );
			if ( in_array( $bits[0], $allowed_orderby, true ) ) {
				$orderby = $bits[0];
				$order   = ( isset( $bits[1] ) && 'asc' === strtolower( $bits[1] ) ) ? 'ASC' : 'DESC';
			}
		}

		$args = array(
			'post_type'           => $post_type,
			'posts_per_page'      => $limit,
			'paged'               => $page,
			'orderby'             => $orderby,
			'order'               => $order,
			'post_status'         => 'publish',
			'ignore_sticky_posts' => true,
			// Here the totals really are needed: without them, no "load more" and no counts.
			'no_found_rows'       => false,
		);

		$tax_query = self::filter_tax_query( $attrs, $state, $post_type );
		if ( null === $tax_query ) {
			// "The contents of this category" outside an archive means nothing, and listing them all
			// would be the wrong answer to the question asked.
			return array( 'items' => array(), 'total' => 0, 'pages' => 0, 'page' => $page );
		}
		if ( $tax_query ) {
			$args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
		}

		if ( ! empty( $state['s'] ) ) {
			$args['s'] = (string) $state['s'];
		}

		// Conditions on the FIELDS: a maximum price, a date from/to, an exact value.
		//
		// The filters' first principle applies: the template is in charge. A field can only be
		// filtered if the section declared it with meta="…" — without that attribute no URL
		// parameter touches the query, and the endpoint does not become a way of interrogating meta
		// nobody meant to expose.
		$meta_query = self::meta_conditions( $attrs, $state, $post_type );
		if ( $meta_query ) {
			$args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
		}

		$q     = new WP_Query( $args );
		$items = array();
		foreach ( $q->posts as $post ) {
			$items[] = self::build_item( $post, $post_type, $attrs );
		}
		wp_reset_postdata();

		return array(
			'items' => $items,
			'total' => (int) $q->found_posts,
			'pages' => (int) $q->max_num_pages,
			'page'  => $page,
		);
	}

	/**
	 * The taxonomy constraints of a filter: the template's, plus the visitor's choices.
	 *
	 * @return array|null null when the template asks for the current archive and there is none —
	 *                    which is not an empty constraint, it is "no result at all".
	 */
	protected static function filter_tax_query( $attrs, $state, $post_type ) {
		$tax_query = array();

		// term="current" — the contents OF THIS archive.
		//
		// It is what gives archives a pagination. A {{#loop}} on a category template prints the
		// first `limit` contents and stops there: the rest stay unreachable and nothing reports it,
		// neither in the preview nor on the page. The filter, on the other hand, counts the totals
		// and knows how to paginate, so on an archive the right list is IT.
		//
		// The term arrives by two routes, and rightly so: on the first render WordPress names it (we
		// are inside the archive's query), over AJAX the browser sends it — and then the endpoint has
		// already re-checked it, because nothing from there is taken on trust.
		if ( isset( $attrs['term'] ) && 'current' === $attrs['term'] ) {
			$cur = self::filter_current_term( $state );
			if ( empty( $cur['slug'] ) ) {
				return null;
			}
			$tax_query[] = array(
				'taxonomy' => ! empty( $attrs['taxonomy'] ) ? sanitize_key( $attrs['taxonomy'] ) : $cur['taxonomy'],
				'field'    => 'slug',
				'terms'    => $cur['slug'],
			);
		} elseif ( ! empty( $attrs['taxonomy'] ) && ! empty( $attrs['term'] ) ) {
			// The template's fixed constraint: always there, whatever the visitor chooses.
			$tax_query[] = array(
				'taxonomy' => sanitize_key( $attrs['taxonomy'] ),
				'field'    => 'slug',
				'terms'    => array_filter( array_map( 'sanitize_title', explode( ',', (string) $attrs['term'] ) ) ),
			);
		}

		// The visitor's choices. match="all" = they must have them ALL; default = at least one.
		$match = ( isset( $attrs['match'] ) && 'all' === strtolower( $attrs['match'] ) ) ? 'AND' : 'IN';

		foreach ( (array) $state['tax'] as $tax => $slugs ) {
			$tax = sanitize_key( $tax );
			if ( ! $slugs || ! taxonomy_exists( $tax ) ) {
				continue;
			}
			// It has to be public AND really attached to this type: otherwise the endpoint would
			// become a way of probing taxonomies that have nothing to do with this list.
			$obj = get_taxonomy( $tax );
			if ( ! $obj || empty( $obj->public ) || ! in_array( $tax, get_object_taxonomies( $post_type ), true ) ) {
				continue;
			}
			$tax_query[] = array(
				'taxonomy' => $tax,
				'field'    => 'slug',
				'terms'    => array_map( 'sanitize_title', $slugs ),
				'operator' => ( 'AND' === $match && count( $slugs ) > 1 ) ? 'AND' : 'IN',
			);
		}

		if ( count( $tax_query ) > 1 ) {
			// Different taxonomies always add up: category X **and** tag Y.
			$tax_query['relation'] = 'AND';
		}

		return $tax_query;
	}

	/**
	 * Enriches a loop item with the fields belonging to its post type.
	 *
	 * Prices are exposed as PLAIN TEXT (not price_html): that way the AI composes the markup it
	 * wants by itself — e.g. <del>{{t:field.price_regular}}</del> <ins>{{t:field.price}}</ins> —
	 * instead of receiving HTML that the {{t:}} placeholders would show literally.
	 *
	 * Extensible by other plugins with the 'aipb_loop_item' filter; the added fields also have to be
	 * declared in AIPB_Site_Context::extra_fields() so the AI knows they exist.
	 */
	protected static function enrich_item( $item, $post, $post_type ) {
		if ( 'product' === $post_type && function_exists( 'wc_get_product' ) ) {
			$product = wc_get_product( $post->ID );
			if ( $product ) {
				$strip = function ( $html ) {
					return trim( wp_strip_all_tags( (string) $html ) );
				};
				$item['price']           = $strip( function_exists( 'wc_price' ) ? wc_price( $product->get_price() ) : $product->get_price() );
				$item['price_regular']   = $strip( function_exists( 'wc_price' ) ? wc_price( $product->get_regular_price() ) : $product->get_regular_price() );
				$item['price_sale']      = $product->get_sale_price() ? $strip( wc_price( $product->get_sale_price() ) ) : '';
				$item['on_sale']         = $product->is_on_sale() ? '1' : '';
				$item['sku']             = (string) $product->get_sku();
				$item['stock_status']    = (string) $product->get_stock_status();
				$item['in_stock']        = $product->is_in_stock() ? '1' : '';
				$item['add_to_cart_url'] = (string) $product->add_to_cart_url();
				$item['rating']          = (string) $product->get_average_rating();
				$item['review_count']    = (string) $product->get_review_count();
			}
		}

		return apply_filters( 'aipb_loop_item', $item, $post, $post_type );
	}

	/**
	 * Srcset of a loop/post thumbnail, capped at the file actually in `src` (usually `large`).
	 *
	 * @param int         $thumb_id
	 * @param array|false $src      wp_get_attachment_image_src() result
	 * @return string
	 */
	protected static function item_srcset( $thumb_id, $src ) {
		if ( ! $thumb_id ) {
			return '';
		}
		if ( class_exists( 'AIPB_Srcset' ) && AIPB_Srcset::enabled() ) {
			$url = ( is_array( $src ) && ! empty( $src[0] ) ) ? (string) $src[0] : '';
			$max = ( is_array( $src ) && ! empty( $src[1] ) ) ? (int) $src[1] : 0;
			if ( '' !== $url ) {
				$built = AIPB_Srcset::for_src( $url, $max );
				return $built['srcset'];
			}
			return AIPB_Srcset::srcset( $thumb_id, $max );
		}

		return (string) wp_get_attachment_image_srcset( $thumb_id, 'large' );
	}

	/**
	 * Sizes companion of {@see item_srcset()}.
	 *
	 * @param int         $thumb_id
	 * @param array|false $src
	 * @return string
	 */
	protected static function item_sizes( $thumb_id, $src ) {
		if ( ! $thumb_id ) {
			return '';
		}
		if ( class_exists( 'AIPB_Srcset' ) && AIPB_Srcset::enabled() ) {
			$url = ( is_array( $src ) && ! empty( $src[0] ) ) ? (string) $src[0] : '';
			$max = ( is_array( $src ) && ! empty( $src[1] ) ) ? (int) $src[1] : 0;
			if ( '' !== $url ) {
				$built = AIPB_Srcset::for_src( $url, $max );
				return $built['sizes'];
			}
			return AIPB_Srcset::sizes( $thumb_id, $max );
		}

		return (string) wp_get_attachment_image_sizes( $thumb_id, 'large' );
	}

	/* --------------------- Companions of prop images ------------------------- */

	/**
	 * An image prop can say more about itself than its own URL.
	 *
	 * Loop items have always exposed `field.thumbnail_srcset`, `_sizes`, `_w`, `_h`, `_alt` — that
	 * is what lets the AI compose an <img> that does not make the layout jump and does not download
	 * 2 MB onto a phone. The images the user picks by hand, on the other hand, were a URL and
	 * nothing else: the same photo, put in the hero from the fields panel, came out at full
	 * resolution, with no srcset and no dimensions. That is the worst possible place for it, because
	 * the hero is almost always the page's LCP, and an <img> with no width/height is the first cause
	 * of CLS.
	 *
	 * Here the difference disappears: `{{t:photo_srcset}}` next to `{{i:photo}}` works, and the
	 * values come from the same attachment, through AIPB_Srcset (Bootstrap widths, not WordPress's
	 * dump). Nothing extra to save — old content gains the companions without being re-saved.
	 *
	 * @return string|int|null The companion's value, or null when $path is not a companion.
	 */
	protected static function media_companion( $path, $values ) {
		$suffixes = array( '_srcset', '_sizes', '_alt', '_id', '_w', '_h' );

		foreach ( $suffixes as $suffix ) {
			if ( substr( $path, -strlen( $suffix ) ) !== $suffix ) {
				continue;
			}
			$base = substr( $path, 0, -strlen( $suffix ) );
			if ( '' === $base || ! isset( $values[ $base ] ) ) {
				continue; // no prop behind it: not a companion, just a name that looks like one
			}

			$url = trim( (string) $values[ $base ] );
			$id  = self::attachment_id_for( $base, $values );
			if ( ! $id ) {
				return ''; // the image is not in the Media Library (external URL): no data to give
			}

			switch ( $suffix ) {
				case '_id':
					return $id;
				case '_alt':
					return (string) get_post_meta( $id, '_wp_attachment_image_alt', true );
				case '_srcset':
					if ( class_exists( 'AIPB_Srcset' ) && AIPB_Srcset::enabled() ) {
						$built = AIPB_Srcset::for_src( $url, self::companion_max_width( $url, $id ) );
						return $built['srcset'];
					}
					return (string) wp_get_attachment_image_srcset( $id, 'full' );
				case '_sizes':
					if ( class_exists( 'AIPB_Srcset' ) && AIPB_Srcset::enabled() ) {
						$built = AIPB_Srcset::for_src( $url, self::companion_max_width( $url, $id ) );
						return $built['sizes'];
					}
					return (string) wp_get_attachment_image_sizes( $id, 'full' );
				case '_w':
				case '_h':
					// The dimensions have to describe the file REALLY in the src, otherwise the
					// declared ratio is wrong and the reserved space does not match. If the URL is
					// one of WordPress's resized variants (…-1200x800.jpg) the measurements are
					// written in the file name; only otherwise is the original looked at.
					if ( preg_match( '/-(\d+)x(\d+)\.[a-z0-9]+$/i', $url, $m ) ) {
						return (int) ( '_w' === $suffix ? $m[1] : $m[2] );
					}
					$src = wp_get_attachment_image_src( $id, 'full' );
					if ( ! $src ) {
						return '';
					}
					return (int) ( '_w' === $suffix ? $src[1] : $src[2] );
			}
		}

		return null;
	}

	/**
	 * Ceiling for a companion srcset: the variant in the URL, otherwise the original.
	 *
	 * A `photo-1200x800.jpg` must not list a 1440. The hero that points at the original lists
	 * every Bootstrap width the file can actually pay for.
	 *
	 * @param string $url
	 * @param int    $id
	 * @return int
	 */
	protected static function companion_max_width( $url, $id ) {
		if ( preg_match( '/-(\d+)x(\d+)\.[a-z0-9]+$/i', (string) $url, $m ) ) {
			return (int) $m[1];
		}
		$src = wp_get_attachment_image_src( $id, 'full' );

		return $src ? (int) $src[1] : 0;
	}

	/** The attachment behind an image prop, 0 when it is not ours. */
	protected static function attachment_id_for( $base, $values ) {
		// Direct route: the ID the media picker recorded next to the URL. No query.
		if ( ! empty( $values[ $base . '_id' ] ) && ctype_digit( (string) $values[ $base . '_id' ] ) ) {
			return (int) $values[ $base . '_id' ];
		}
		$raw = isset( $values[ $base ] ) ? trim( (string) $values[ $base ] ) : '';
		if ( '' === $raw ) {
			return 0;
		}
		if ( ctype_digit( $raw ) ) {
			return (int) $raw; // the field holds the ID instead of the URL
		}
		return self::attachment_id_from_url( $raw );
	}

	/**
	 * An image URL → the attachment's ID, for whoever is outside the engine.
	 *
	 * AIPB_Render::enhance_images() uses it to get width/height from the Library: the cache and the
	 * resized-variant case are already solved below, and rewriting them elsewhere would mean two
	 * implementations that diverge at the first edge case.
	 */
	public static function attachment_id( $url ) {
		$url = trim( (string) $url );
		return '' === $url ? 0 : self::attachment_id_from_url( $url );
	}

	/**
	 * URL → attachment ID, with two precautions.
	 *
	 * 1. `attachment_url_to_postid()` is a query, and it has to be cached: prop images live in the
	 *    heroes, that is on every load of the site's most visited page. The transient keeps the
	 *    result for a week; if the image is replaced, the worst that happens is a missing srcset
	 *    until it expires — the `src` stays the right one regardless.
	 * 2. That function only knows the originals: on `photo-1200x800.jpg` — the shape a user gets by
	 *    copying the address of an image already on the page — it answers 0. It is retried without
	 *    the size suffix, which is how WordPress names its variants.
	 */
	protected static function attachment_id_from_url( $url ) {
		static $memo = array();
		if ( isset( $memo[ $url ] ) ) {
			return $memo[ $url ];
		}

		$key    = 'aipb_att_' . md5( $url );
		$cached = get_transient( $key );
		if ( false !== $cached ) {
			$memo[ $url ] = (int) $cached;
			return $memo[ $url ];
		}

		$id = function_exists( 'attachment_url_to_postid' ) ? (int) attachment_url_to_postid( $url ) : 0;
		if ( ! $id ) {
			$full = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $url );
			if ( $full !== $url ) {
				$id = (int) attachment_url_to_postid( $full );
			}
		}

		// The negative expires sooner: a freshly uploaded image must not stay "unknown" for a week
		// just because somebody opened its preview a moment earlier.
		set_transient( $key, $id, $id ? WEEK_IN_SECONDS : HOUR_IN_SECONDS );
		$memo[ $url ] = $id;
		return $id;
	}

	/* -------------------------------- Sanitizing ----------------------------- */

	/**
	 * Sanitizing BY TYPE: this is where the safety of the substituted data lives.
	 */
	protected static function sanitize( $type, $raw, $path = '' ) {
		if ( is_array( $raw ) ) {
			$raw = '';
		}
		switch ( $type ) {
			case 'i': // image
			case 'u': // url
				return esc_url( (string) $raw );

			case 'c': // colour
				$color = sanitize_hex_color( (string) $raw );
				if ( $color ) {
					return $color;
				}
				// Allows rgb()/rgba()/hsl()/simple CSS names.
				return preg_match( '/^[a-z0-9(),.%#\s]+$/i', (string) $raw ) ? (string) $raw : '';

			case 'h': // a content's body: block HTML (paragraphs, lists, images, embeds).
				// From post.* comes what WordPress itself has already rendered with its own filters,
				// written by someone who had permission to write it: it is printed as is, the way a
				// theme does. From anywhere else it is a value we may have received: it is sanitized.
				if ( 0 === strpos( (string) $path, 'post.' ) ) {
					return (string) $raw;
				}
				// A term's description, and its custom fields, are raw text with line breaks: no
				// WordPress filter has ever passed over them. But {{h:…}} asks precisely for the
				// "content body" shape, so the paragraphs are made here — otherwise a two-paragraph
				// description comes out as a single block of text.
				if ( 0 === strpos( (string) $path, 'term.' ) ) {
					return wp_kses_post( wpautop( (string) $raw ) );
				}
				return wp_kses_post( (string) $raw );

			case 's': // shortcode: a field whose value IS a shortcode, and nothing else.
				// It is not the RESULT that is sanitized (there is none yet: do_shortcode will
				// produce it at the end of the section), it is the FORM — see
				// AIPB_Shortcodes::is_bare. What is not shaped like a shortcode is not printed: a
				// "shortcode" field that also accepted HTML would be a way in for arbitrary markup.
				return AIPB_Shortcodes::is_bare( $raw ) ? trim( (string) $raw ) : '';

			case 'r': // rich text: inline HTML allowed, but sanitized (no XSS, no block tags that
				// would break the layout). Inline formatting + <br> only.
				return wp_kses( (string) $raw, self::inline_html_allowed() );

			case 't': // plain text
			default:
				return self::sanitize_text( $raw );
		}
	}

	/**
	 * Plain text, with two deliberate exceptions.
	 *
	 * Shortcode auto-heal: a text field whose value is ENTIRELY a shortcode REGISTERED on this site
	 * is, quite evidently, a shortcode somebody pasted in there — and escaping it would break it
	 * silently (id=&quot;3&quot; reaches the plugin as a junk attribute, and it answers "form not
	 * found"). We let it through: AIPB_Shortcodes will expand it. The accepted form is very strict
	 * and the tag really has to exist, so it is no shortcut for printing HTML from a value.
	 *
	 * <br> auto-heal: models tend to slip <br> into text values to break a line; we read it as a
	 * line break instead of showing it literally. Everything else stays escaped — a lone "<" (e.g.
	 * "Price < 100") and any <script> stay plain, harmless text. It covers <br>, <br/>, <br />,
	 * < br >.
	 */
	protected static function sanitize_text( $raw ) {
		if ( AIPB_Shortcodes::is_value( $raw ) ) {
			return trim( (string) $raw );
		}

		return preg_replace( '/&lt;\s*br\s*\/?\s*&gt;/i', '<br>', esc_html( (string) $raw ) );
	}

	/**
	 * Whitelist for the "rich text" fields ({{r:…}}): ONLY inline formatting tags plus <br>.
	 * wp_kses strips <script>, the on* handlers, the dangerous protocols (javascript:) and any tag
	 * not listed (div/h1/tables included), so values can neither run code nor break the section's
	 * layout.
	 */
	protected static function inline_html_allowed() {
		return array(
			'br'     => array(),
			'strong' => array(),
			'b'      => array(),
			'em'     => array(),
			'i'      => array(),
			'u'      => array(),
			's'      => array(),
			'mark'   => array(),
			'small'  => array(),
			'sup'    => array(),
			'sub'    => array(),
			'span'   => array( 'class' => true ),
			'a'      => array( 'href' => true, 'title' => true, 'target' => true, 'rel' => true ),
		);
	}

	/**
	 * HTML-like attribute parsing: key="value".
	 */
	protected static function parse_attrs( $str ) {
		$attrs = array();
		if ( preg_match_all( '/([a-z_]+)\s*=\s*"([^"]*)"/i', $str, $mm, PREG_SET_ORDER ) ) {
			foreach ( $mm as $pair ) {
				$attrs[ strtolower( $pair[1] ) ] = $pair[2];
			}
		}
		return $attrs;
	}

	/**
	 * A boolean attribute set to "no".
	 *
	 * Attributes are written by hand — in a generated template, or by whoever tweaks it — and "no"
	 * has many ways of being written. Recognising only one ("0") means hide_empty="false" is read as
	 * «yes, hide»: the template says one thing and the page does another, with no error anywhere.
	 * Here every reasonable form counts.
	 */
	protected static function attr_off( $attrs, $key ) {
		if ( ! isset( $attrs[ $key ] ) ) {
			return false;
		}
		$v = strtolower( trim( (string) $attrs[ $key ] ) );
		return in_array( $v, array( '0', '', 'false', 'no', 'off', 'null', 'none' ), true );
	}
}
