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

/**
 * The bridge to the Aura service.
 *
 * This class no longer knows anything about prompts, the DSL or models. It sends the server what
 * only the site knows — the instruction, the section being worked on, the design system, its
 * contents — and receives the model's RAW TEXT back. Everything that comes afterwards stays here,
 * and it is half the job: interpreting that text, deriving the schema from the placeholders,
 * applying the patches to the code, putting the CSS back in order.
 *
 * The boundary runs there, and it is not a convenience: the parsing derives the schema from the
 * placeholders, so it is knowledge of the template ENGINE, and the engine runs here on every
 * visitor request. Parser and engine have to change together; prompts and models do not.
 *
 * It always returns an array — array( 'template' => string, 'schema' => array, 'values' => array )
 * for a section, { ops } for a restyle, { sections } for a plan — or a WP_Error, which the REST
 * layer forwards to the client as it is.
 */
class AIPB_AI_Provider {

	/**
	 * The accepted modes. 'section' is the default and is not listed: any unknown value falls back
	 * to it, because generating a section is the operation that can never be missing.
	 */
	const MODES = array( 'patch', 'global', 'plan', 'tokens', 'model', 'direction' );

	/** Fidelity modes: how closely to follow the reference (the server interprets it). */
	const FIDELITIES = array( 'faithful', 'balanced', 'creative' );

	/**
	 * @param string $prompt   The user's instruction (creation or refinement).
	 * @param array  $context  Context: { target, template, values, blocks, viewport }.
	 * @param string $mode     'section' | 'patch' | 'global' | 'plan' | 'tokens' | 'model'.
	 * @param array  $images   References (images/videos/PDFs) in base64.
	 * @param string $fidelity 'faithful' | 'balanced' | 'creative'.
	 * @return array|WP_Error
	 */
	public static function generate( $prompt, $context = array(), $mode = 'section', $images = array(), $fidelity = 'balanced', $reproduce = false ) {
		$context  = is_array( $context ) ? $context : array();
		$images   = is_array( $images ) ? $images : array();
		$mode     = in_array( $mode, self::MODES, true ) ? $mode : 'section';
		$fidelity = in_array( $fidelity, self::FIDELITIES, true ) ? $fidelity : 'balanced';

		return self::interpret(
			AIPB_Remote::generate( (string) $prompt, $context, $mode, $images, $fidelity, (bool) $reproduce ),
			$context,
			$mode
		);
	}

	/**
	 * Interprets the service's answer: records the usage, derives schema and values, applies the
	 * patches.
	 *
	 * It is public because the STREAMING route does not go through generate(): it receives the text
	 * as it is produced and only gets here at the end. Keeping a single implementation of the
	 * interpretation is what stops the two routes from diverging — and they would diverge precisely
	 * on the hard cases (the restyle's fallback, the global's no-op).
	 *
	 * @param array|WP_Error $answer  Answer from AIPB_Remote::generate()/generate_stream().
	 * @param array          $context The request's context (the patches need it).
	 * @param string         $mode    The request's mode.
	 *
	 * @return array|WP_Error
	 */
	public static function interpret( $answer, $context, $mode ) {
		if ( is_wp_error( $answer ) ) {
			return $answer;
		}

		// Usage is recorded HERE, before any interpretation: the service produced it and it has to
		// be paid for even if the text then turns out to be unreadable. Counting only successful
		// generations would give a total that does not match the invoice.
		if ( class_exists( 'AIPB_Usage' ) ) {
			AIPB_Usage::record( $answer, $mode );
		}

		$result = self::parse_result( (string) $answer['raw'], $mode );
		if ( is_wp_error( $result ) ) {
			// The request's id is the only thing the user can quote to support: without it, "it did
			// not work" stays an anecdote.
			if ( ! empty( $answer['request_id'] ) ) {
				return new WP_Error(
					$result->get_error_code(),
					$result->get_error_message() . ' [' . (string) $answer['request_id'] . ']',
					$result->get_error_data()
				);
			}

			return $result;
		}

		// Warnings from the service (attachments discarded, answer truncated by the token ceiling):
		// they are not errors — the generation happened — but they have to be said, otherwise the
		// user sees a section that does not resemble what they uploaded and has no idea why.
		if ( is_array( $result ) && ! empty( $answer['warning'] ) && empty( $result['_warning'] ) ) {
			$result['_warning'] = (string) $answer['warning'];
		}

		// The request's id travels when it went WELL too, and that is the case that matters most:
		// "this section came out badly" is not an error, it is a section that exists and is not
		// liked — and without the id support has no way of finding it among that day's generations.
		// Until now it was only attached to error messages, that is to the one case where there is
		// no section to look at.
		if ( is_array( $result ) && ! empty( $answer['request_id'] ) ) {
			$result['_request'] = (string) $answer['request_id'];
		}

		return self::finalize_patch( $result, $context, $mode );
	}

	/* --------------------------------- Patches -------------------------------- */

	/**
	 * Restyle (patch/global): applies the find/replace operations produced by the model to the
	 * section and RE-DERIVES template/schema/values (the same pipeline as a section refinement).
	 * When no operation can be applied UNAMBIGUOUSLY:
	 *  - mode 'patch'  → { _fallback: 'section' }: the client falls back to a full regeneration.
	 *  - mode 'global' → { _noop: true }: the section is not relevant, it stays as it is (fan-out).
	 * For any other mode (or a result already shaped as a section/page) it returns unchanged.
	 */
	protected static function finalize_patch( $result, $context, $mode ) {
		if ( ! in_array( $mode, array( 'patch', 'global' ), true ) || ! is_array( $result ) || ! isset( $result['ops'] ) ) {
			return $result;
		}
		$warning = isset( $result['_warning'] ) ? $result['_warning'] : '';
		// The intent survives every outcome, the give-ups included: on a no-op it is the ONLY thing
		// that says why nothing happened («this section is not about the header»), and that is
		// exactly the case where silence reads as a fault.
		$intent  = isset( $result['_intent'] ) ? $result['_intent'] : '';
		$label   = isset( $result['_name'] ) ? $result['_name'] : '';
		$ops     = is_array( $result['ops'] ) ? $result['ops'] : array();
		$give_up = ( 'global' === $mode )
			? array( '_noop' => true, '_warning' => $warning, '_intent' => $intent )
			: array( '_fallback' => 'section', '_warning' => $warning, '_intent' => $intent );

		$template = isset( $context['template'] ) ? (string) $context['template'] : '';
		$values   = isset( $context['values'] ) && is_array( $context['values'] ) ? $context['values'] : array();
		if ( '' === $template || empty( $ops ) ) {
			return $give_up;
		}

		// The model's anchors refer to the READABLE code (real contents inline), the very same we
		// showed it: we apply them there and then re-parse.
		$code   = self::inline_form( $template, $values );
		$before = self::directives( $code ); // the queries BEFORE: see below, the comparison is downstream

		list( $code, $applied ) = self::apply_ops( $code, $ops );
		if ( 0 === $applied ) {
			return $give_up;
		}

		// Style patches are additive (see append_section_css): the <style> grows on every round and
		// after twenty restyles half the declarations never reach the screen. Now that every anchor
		// has found its place, the code can be tidied up — without changing a comma of what is seen.
		if ( class_exists( 'AIPB_Tidy' ) ) {
			$tidy = AIPB_Tidy::section( $code );
			if ( ! empty( $tidy['code'] ) && false !== strpos( $tidy['code'], '<' ) ) {
				$code = $tidy['code'];
			}
		}

		$sec = self::parse_raw_section( $code );
		if ( ! $sec ) {
			return $give_up;
		}
		$sec['_applied'] = $applied; // how many find/replace operations hit their mark
		if ( '' !== $intent ) {
			$sec['_intent'] = $intent;
		}
		if ( '' !== $label ) {
			$sec['_name'] = $label;
		}

		// A patch touching a {{#loop}}/{{#filter}} directive does not change the STYLE: it changes
		// WHICH CONTENTS the section shows. All it takes is for the model to rewrite the filter's
		// opening and type="product" to become type="post": the page still says "products" but lists
		// posts, and nobody notices until they read the titles. We do not prevent it — sometimes
		// changing the query is exactly what was asked — but we say it.
		$changed = self::directive_changes( $before, self::directives( $code ) );
		if ( $changed ) {
			$note = sprintf(
				/* translators: %s: list of the directives that changed. */
				__( 'Careful: the restyle changed the content query — %s. If that was not intended, ask me to put it back.', 'aipb' ),
				implode( ' ; ', $changed )
			);
			$warning = '' !== $warning ? $warning . ' ' . $note : $note;
		}

		if ( '' !== $warning ) {
			$sec['_warning'] = $warning;
		}
		return $sec;
	}

	/**
	 * Applies the model's operations to the code, in order.
	 *
	 * @return array{0:string,1:int} the new code, and how many operations were applied.
	 */
	protected static function apply_ops( $code, $ops ) {
		$applied = 0;

		foreach ( $ops as $op ) {
			// An additive CSS rule: appended at the end of the <style> — it always applies.
			if ( isset( $op['css'] ) ) {
				$code = self::append_section_css( $code, (string) $op['css'] );
				$applied++;
				continue;
			}

			$find = isset( $op['find'] ) ? (string) $op['find'] : '';
			if ( '' === $find ) {
				continue;
			}
			$new = self::apply_find_replace( $code, $find, isset( $op['replace'] ) ? (string) $op['replace'] : '' );
			if ( false !== $new ) {
				$code = $new;
				$applied++;
			}
		}

		return array( $code, $applied );
	}

	/**
	 * Adds a CSS rule at the end of the section's <style> (it overrides the existing style thanks to
	 * cascade order). If the section has no <style>, one is created at the top. It is the mechanism
	 * behind targeted style restyles: additive, so it can never "miss its anchor".
	 *
	 * Additive does mean the <style> grows with every restyle, though. Putting it back in order is
	 * finalize_patch()'s job ONCE THE OPERATIONS ARE OVER: tidying here would rewrite the CSS while
	 * the following patches are still looking for their anchors in that very text.
	 */
	protected static function append_section_css( $code, $css ) {
		$block = "\n" . AIPB_Tidy::PATCH_MARKER . "\n" . trim( $css ) . "\n";
		$pos   = strripos( $code, '</style>' );
		if ( false !== $pos ) {
			return substr( $code, 0, $pos ) . $block . substr( $code, $pos );
		}
		return '<style>' . $block . "</style>\n" . $code;
	}

	/**
	 * Applies ONE find→replace operation to the code, SAFELY (a single occurrence).
	 * Two attempts, in order:
	 *  1) EXACT match: the anchor appears literally exactly once → direct replacement.
	 *  2) WHITESPACE-TOLERANT match: the anchor's whitespace runs match \s+. It covers the most
	 *     common failure — the model reproduces the fragment with different indentation or line
	 *     breaks from the code's. We only replace if the occurrence is still UNIQUE (otherwise it is
	 *     ambiguous and we touch nothing).
	 * Returns the new code, or false when the operation cannot be applied unambiguously.
	 */
	protected static function apply_find_replace( $code, $find, $replace ) {
		// 1) Exact and unique.
		$count = substr_count( $code, $find );
		if ( 1 === $count ) {
			return str_replace( $find, $replace, $code );
		}
		if ( $count > 1 ) {
			return false; // ambiguous: several identical occurrences
		}

		// 2) Whitespace-tolerant. Trim the anchor (the edge spaces are the most volatile) and turn
		//    every internal whitespace run into \s+.
		$trimmed = trim( $find );
		if ( '' === $trimmed ) {
			return false;
		}
		$rx      = preg_replace( '/\s+/', '\\\\s+', preg_quote( $trimmed, '/' ) );
		$pattern = '/' . $rx . '/';

		$n = @preg_match_all( $pattern, $code ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
		if ( 1 !== $n ) {
			return false; // 0 (not found) or >1 (ambiguous)
		}
		// substr_replace inserts $replace LITERALLY (no interpretation of $1/\1).
		if ( preg_match( $pattern, $code, $m, PREG_OFFSET_CAPTURE ) ) {
			return substr_replace( $code, $replace, $m[0][1], strlen( $m[0][0] ) );
		}
		return false;
	}

	/* ------------------------------ Parsing output ---------------------------- */

	/**
	 * Interprets the model's RAW output (code, not JSON) and derives { template, schema, values }
	 * (a section) or { blocks:[…] } (a page).
	 * The SCHEMA is DERIVED by us from the placeholders: the model does not have to produce it.
	 * Fallback: if the model answered in JSON (the old format), we interpret that too.
	 */
	protected static function parse_result( $raw, $mode ) {
		$raw = is_string( $raw ) ? $raw : '';

		// The PREAMBLE comes off FIRST, before any mode looks at the text.
		//
		// Two declarations live there for the humans, and neither for the parser. The INTENT
		// is shown while the answer is still arriving — the only thing that says «it understood
		// you» before the section is finished, without which a generation is a timer. The NAME is
		// what the block will be called in the page's list, which the section's code has never
		// carried: for a long time every section generated outside a plan landed there called
		// «Section», and three of those in a row is a list you cannot navigate.
		//
		// A third block, LOCK, is a scratchpad the faithful-from-image prompt asks for: colours,
		// type metrics, the copy strings. It is stripped and discarded — it must not land in the
		// template.
		//
		// Downstream all three are only in the way — a JSON that no longer starts with `{`, a
		// section whose first line is not a <style> — so they come off in ONE place, for every
		// mode, and INTENT/NAME travel on as `_intent` and `_name`.
		list( $intent, $name, $raw ) = self::split_preamble( $raw );

		$result = self::parse_result_body( $raw, $mode );
		if ( is_array( $result ) ) {
			if ( '' !== $intent ) {
				$result['_intent'] = $intent;
			}
			if ( '' !== $name ) {
				$result['_name'] = $name;
			}
		}

		return $result;
	}

	/**
	 * Separates the `<<<INTENT>>>`, `<<<NAME>>>` and `<<<LOCK>>>` declarations from the answer.
	 *
	 * TOLERANT on purpose, and in both directions. A model that forgets them must not have its
	 * section thrown away, so a missing marker simply means a missing declaration; and a model that
	 * opens one and never closes it must not have its whole answer swallowed, so the closing marker
	 * is only honoured when it turns up within a line or so. These are a courtesy to whoever is
	 * waiting: they can never cost them the thing they were waiting for.
	 *
	 * Order is not enforced — NAME before INTENT parses just the same, and LOCK may sit anywhere
	 * among them. The prompt asks for one order; a model that picks another has still said them.
	 *
	 * LOCK is discarded: it is a measurement scratchpad, not a field the UI shows.
	 *
	 * @return array{0:string,1:string,2:string} intent, name (either possibly ''), and the rest
	 */
	protected static function split_preamble( $raw ) {
		$intent = '';
		$name   = '';

		// Up to three blocks, whichever way round, each taken only from the very front of the answer.
		for ( $i = 0; $i < 3; $i++ ) {
			if ( preg_match( '/^\s*<<<LOCK>>>(.{0,4000}?)<<<END>>>\s*/su', $raw, $m ) ) {
				$raw = substr( $raw, strlen( $m[0] ) );
				continue;
			}
			if ( ! preg_match( '/^\s*<<<(INTENT|NAME)>>>(.{0,600}?)<<<END>>>\s*/su', $raw, $m ) ) {
				break;
			}
			$value = trim( preg_replace( '/\s+/u', ' ', $m[2] ) );
			if ( 'INTENT' === $m[1] ) {
				$intent = '' !== $intent ? $intent : $value;
			} else {
				// A name is a label in a list, not a sentence: what arrives longer than that is a
				// heading copied out, and it gets cut rather than allowed to stretch the list.
				$name = '' !== $name ? $name : mb_substr( $value, 0, 60 );
			}
			$raw = substr( $raw, strlen( $m[0] ) );
		}

		return array( $intent, $name, $raw );
	}

	/** The body of the answer, once the intent line is off: one parser per mode. */
	protected static function parse_result_body( $raw, $mode ) {
		// Restyle/global: the output is find/replace blocks, not a section. The operations are
		// applied downstream in finalize_patch() (fallback or no-op when they do not match).
		if ( 'patch' === $mode || 'global' === $mode ) {
			return array( 'ops' => self::parse_patch_ops( $raw ) );
		}

		// Planning: the output is JSON with the list of sections.
		if ( 'plan' === $mode ) {
			return self::parse_plan( $raw );
		}

		// Design token import: JSON with the palette, fonts and structural tokens.
		if ( 'tokens' === $mode ) {
			return self::parse_tokens_import( $raw );
		}

		// Content model: JSON with the PROPOSED types, taxonomies and fields. Nothing is saved here
		// — the proposal ends up in the form, and whoever reads it decides.
		if ( 'model' === $mode ) {
			return self::parse_model_proposal( $raw );
		}

		// Art direction: plain prose, the site-wide brief distilled out of a page's design notes.
		if ( 'direction' === $mode ) {
			return self::parse_direction( $raw );
		}

		// If the model answered in JSON (it starts with "{"), try that format first.
		$trimmed = ltrim( self::strip_fences( $raw ) );
		if ( '' !== $trimmed && '{' === $trimmed[0] ) {
			$json = self::parse_json_fallback( $raw );
			if ( ! is_wp_error( $json ) ) {
				return $json;
			}
		}

		$sec = self::parse_raw_section( $raw );
		if ( $sec ) {
			return $sec;
		}

		return self::parse_json_fallback( $raw );
	}

	/**
	 * Restyle: extracts the find/replace pairs from the markers
	 *   <<<FIND>>> …exact text… <<<REPLACE>>> …new text… <<<END>>>
	 * Tolerant parsing: it ignores malformed blocks and a single line break next to the markers.
	 */
	protected static function parse_patch_ops( $raw ) {
		$raw = self::strip_fences( is_string( $raw ) ? $raw : '' );
		$ops = array();

		// find→replace pairs (changes to text/structure/attributes).
		if ( preg_match_all( '/<<<FIND>>>\R?(.*?)\R?<<<REPLACE>>>\R?(.*?)\R?<<<END>>>/s', $raw, $m, PREG_SET_ORDER ) ) {
			foreach ( $m as $op ) {
				$find = $op[1];
				if ( '' === trim( $find ) ) {
					continue;
				}
				$ops[] = array( 'find' => $find, 'replace' => $op[2] );
			}
		}

		// Additive CSS rules (style restyles on an element): they are ADDED at the end of the
		// section's <style>, so they can never "miss the isolation". It does not match the
		// find/replace blocks (those start with <<<FIND>>>).
		if ( preg_match_all( '/<<<CSS>>>\R?(.*?)\R?<<<END>>>/s', $raw, $mc, PREG_SET_ORDER ) ) {
			foreach ( $mc as $op ) {
				$css = trim( self::strip_fences( $op[1] ) );
				if ( '' !== $css ) {
					$ops[] = array( 'css' => $css );
				}
			}
		}

		return $ops;
	}

	/** The dynamic directives present in the code, normalised: "loop|filter" => attributes. */
	protected static function directives( $code ) {
		$out = array();
		if ( preg_match_all( '/\{\{#(loop|filter)\s+([^}]*)\}\}/', (string) $code, $m, PREG_SET_ORDER ) ) {
			foreach ( $m as $x ) {
				$out[] = $x[1] . ' ' . trim( preg_replace( '/\s+/', ' ', $x[2] ) );
			}
		}
		sort( $out );
		return $out;
	}

	/** A readable description of what changed between two lists of directives. Empty if nothing. */
	protected static function directive_changes( $before, $after ) {
		$removed = array_values( array_diff( $before, $after ) );
		$added   = array_values( array_diff( $after, $before ) );
		if ( ! $removed && ! $added ) {
			return array();
		}

		$out = array();
		$n   = max( count( $removed ), count( $added ) );
		for ( $i = 0; $i < $n && $i < 3; $i++ ) {
			$from  = isset( $removed[ $i ] ) ? '{{#' . $removed[ $i ] . '}}' : __( '(nothing)', 'aipb' );
			$to    = isset( $added[ $i ] ) ? '{{#' . $added[ $i ] . '}}' : __( '(removed)', 'aipb' );
			$out[] = $from . ' → ' . $to;
		}
		return $out;
	}

	/* --------------------------------- The plan ------------------------------- */

	/**
	 * The distilled art direction: prose, or nothing.
	 *
	 * The mode exists because the alternative was worse. A page plan writes its design notes for THAT
	 * page — they name its hero, its one big object, the exact orange of its background — and those
	 * notes used to be offered, verbatim, as the art direction of the whole SITE. Accepted once, they
	 * reach every future generation under a heading that reads «binding — apply it to EVERY section»,
	 * and a contact form is asked to be a poster. That is not a hypothesis: it happened, and the
	 * section that came out of it was orange on a site whose palette has no orange in it.
	 *
	 * «NONE» is a real answer and not a failure: some plans decide a layout and no character, and
	 * there is nothing to keep. An empty direction is the state the site was already in.
	 *
	 * @return array{direction:string}
	 */
	protected static function parse_direction( $raw ) {
		$text = trim( self::strip_fences( is_string( $raw ) ? $raw : '' ) );

		// Models like to wrap a brief in quotes when asked for one. The quotes would be saved, shown
		// in the settings field and read back to the model as part of the brief.
		$text = trim( $text, "\"'\u{201C}\u{201D}\u{00AB}\u{00BB}" );

		if ( '' === $text || 0 === strcasecmp( $text, 'NONE' ) ) {
			return array( 'direction' => '' );
		}

		return array( 'direction' => AIPB_Design::sanitize_direction( $text ) );
	}

	/**
	 * Planning: interprets the JSON { "sections": [ { name, brief }, … ], "model": {…} }.
	 * Tolerant: it cleans up any markdown fences, discards malformed entries, caps the count.
	 */
	protected static function parse_plan( $raw ) {
		$parsed = self::decode_json( $raw );

		$list   = array();
		$design = '';
		$tokens = array();
		$model  = array( 'types' => array(), 'notes' => array() );

		if ( is_array( $parsed ) ) {
			if ( isset( $parsed['model'] ) && is_array( $parsed['model'] ) ) {
				$model = self::parse_plan_model( $parsed['model'] );
			}
			if ( isset( $parsed['design'] ) && is_string( $parsed['design'] ) ) {
				$design = trim( $parsed['design'] );
			}
			if ( isset( $parsed['tokens'] ) && is_array( $parsed['tokens'] ) ) {
				$tokens = $parsed['tokens'];
			}
			// It accepts both { "sections": [...] } and an array of sections directly.
			$rows = isset( $parsed['sections'] ) && is_array( $parsed['sections'] ) ? $parsed['sections'] : ( isset( $parsed[0] ) ? $parsed : array() );
			foreach ( $rows as $s ) {
				$row = self::plan_section( $s );
				if ( $row ) {
					$list[] = $row;
				}
				if ( count( $list ) >= 20 ) {
					break; // safety ceiling against out-of-scale plans
				}
			}
		}

		return array(
			'design'      => $design,
			'tokens_spec' => self::tokens_to_spec( $tokens ),
			'tokens_map'  => self::plan_tokens_map( $tokens ),
			'sections'    => $list,
			'model'       => $model,
		);
	}

	/**
	 * The plan's measurements, in the shape the site's design system stores.
	 *
	 * WHY IT EXISTS. The planner is asked for a full scale — type sizes, section padding, content
	 * width, radii, shadows — and until now that scale was turned into a sentence for the prompts
	 * and then dropped. It held the page it was made for together and was gone by the next one, so
	 * a section added a month later started from nothing again.
	 *
	 * IT IS ONLY WORTH ADOPTING WHEN IT WAS MEASURED, that is when there was a reference to measure.
	 * With no reference the planner invents a scale — and since the structural tokens now ship with
	 * one of their own ({@see AIPB_Design::defaults()}), adopting an invented scale would swap a
	 * coherent set of values for another set, chosen no better. The caller decides; this only does
	 * the translating.
	 *
	 * COLOURS ARE LEFT OUT on purpose. The palette is the brand's, it has no fixed roles, and the
	 * prompts already say the site wins over anything a plan proposes about colour.
	 *
	 * @return array<string,string> 'group:key' => value, ready for AIPB_Design's overrides.
	 */
	protected static function plan_tokens_map( $tokens ) {
		if ( ! is_array( $tokens ) || ! $tokens ) {
			return array();
		}

		$out = array();

		// The type scale. The planner speaks in roles (h1, body…), the design system in steps: the
		// steps in between keep whatever they had, which is what "filling the gaps" means.
		$roles = array( 'h1' => 'text:4xl', 'h2' => 'text:3xl', 'h3' => 'text:2xl', 'body' => 'text:base', 'small' => 'text:sm' );
		if ( isset( $tokens['type_scale'] ) && is_array( $tokens['type_scale'] ) ) {
			foreach ( $tokens['type_scale'] as $step ) {
				if ( ! is_array( $step ) ) {
					continue;
				}
				$role = isset( $step['role'] ) ? strtolower( trim( (string) $step['role'] ) ) : '';
				$size = isset( $step['size_px'] ) ? $step['size_px'] : null;
				if ( isset( $roles[ $role ] ) && self::plausible_px( $size, 8, 240 ) ) {
					$out[ $roles[ $role ] ] = (int) $size . 'px';
				}
			}
		}

		if ( isset( $tokens['spacing'] ) && is_array( $tokens['spacing'] ) ) {
			$sp = $tokens['spacing'];
			if ( self::plausible_px( $sp['section_padding_y_px'] ?? null, 8, 400 ) ) {
				$out['space:2xl'] = (int) $sp['section_padding_y_px'] . 'px';
			}
			if ( self::plausible_px( $sp['content_max_width_px'] ?? null, 320, 2400 ) ) {
				$out['layout:container'] = (int) $sp['content_max_width_px'] . 'px';
			}
			if ( self::plausible_px( $sp['gap_px'] ?? null, 2, 160 ) ) {
				$out['space:lg'] = (int) $sp['gap_px'] . 'px';
			}
		}

		if ( isset( $tokens['radii'] ) && is_array( $tokens['radii'] ) ) {
			// The token's label says what it is FOR ("buttons/chips"), not what shape it is: a
			// measured 8px belongs in it just as much as a 999px does.
			if ( self::plausible_px( $tokens['radii']['button_px'] ?? null, 0, 999 ) ) {
				$out['radius:pill'] = (int) $tokens['radii']['button_px'] . 'px';
			}
			if ( self::plausible_px( $tokens['radii']['card_px'] ?? null, 0, 200 ) ) {
				$out['radius:lg'] = (int) $tokens['radii']['card_px'] . 'px';
			}
		}

		if ( isset( $tokens['shadows'] ) && is_array( $tokens['shadows'] ) ) {
			$steps = array( 'shadow:sm', 'shadow:md', 'shadow:lg' );
			$i     = 0;
			foreach ( $tokens['shadows'] as $shadow ) {
				if ( ! is_string( $shadow ) || ! isset( $steps[ $i ] ) ) {
					continue;
				}
				$value = AIPB_Design::sanitize_value( $shadow );
				// A shadow is a length list plus a colour; anything with a brace or a semicolon in it
				// is not one, and sanitize_value having stripped them does not make it one either.
				if ( '' !== $value && strlen( $value ) < 200 ) {
					$out[ $steps[ $i ] ] = $value;
					++$i;
				}
			}
		}

		return $out;
	}

	/** A measurement is usable when it is a number and the number is not absurd. */
	protected static function plausible_px( $value, $min, $max ) {
		if ( ! is_numeric( $value ) ) {
			return false;
		}
		$n = (float) $value;

		return $n >= $min && $n <= $max;
	}

	/** One entry of the plan, or null when there is nothing usable in it. */
	protected static function plan_section( $s ) {
		if ( ! is_array( $s ) ) {
			return null;
		}
		$name  = isset( $s['name'] ) ? trim( wp_strip_all_tags( (string) $s['name'] ) ) : '';
		$brief = isset( $s['brief'] ) ? trim( (string) $s['brief'] ) : '';
		if ( '' === $name && '' === $brief ) {
			return null;
		}

		$row = array(
			'name'  => '' !== $name ? $name : __( 'Section', 'aipb' ),
			'brief' => $brief,
		);

		if ( isset( $s['scope'] ) && 'page' === $s['scope'] ) {
			$row['scope'] = 'page';

			return $row;
		}

		// Vertical band (optional): valid only when 0<=start<end<=100. The client-side crop uses it
		// with padding; when it is missing or inconsistent, the whole image is used.
		if ( isset( $s['y_start_pct'], $s['y_end_pct'] ) && is_numeric( $s['y_start_pct'] ) && is_numeric( $s['y_end_pct'] ) ) {
			$ys = (float) $s['y_start_pct'];
			$ye = (float) $s['y_end_pct'];
			if ( $ys >= 0 && $ye > $ys && $ye <= 100 ) {
				$row['y_start_pct'] = $ys;
				$row['y_end_pct']   = $ye;
			}
		}

		return $row;
	}

	/**
	 * The content types the PLAN asks to create before building the sections.
	 *
	 * IT COMES FROM A REAL CASE. «Make me a slider with all the planets» produced eight cards
	 * hand-written inside the section's code: nobody had got it wrong, because the generator
	 * receives the site's inventory, «planet» was not in it, and no loop can be made over a type
	 * that does not exist. The missing piece was that nobody created it. The plan is the only moment
	 * at which creating it is not too late: it sees the whole page BEFORE the sections exist.
	 *
	 * NOTHING IS SAVED HERE, and for the same reason as the «Content model» page: a slug is in the
	 * URL of every content of that type and a key is the name of a meta in the database. This
	 * function prepares a PROPOSAL; AIPB_Model_Apply is what actually creates it, after somebody has
	 * read it and pressed a button.
	 *
	 * The strictness is the content model's: what does not pass is discarded and SAID in "notes".
	 * With one extra rule, the only one really specific to here — a type WITHOUT initial content is
	 * discarded. Registering it and leaving it empty would be the worst of the three possibilities:
	 * the section builds a list on top of it and the page comes out BLANK, which is worse than the
	 * eight hand-written cards we started from.
	 *
	 * @return array { types, taxonomies, notes }
	 */
	protected static function parse_plan_model( $raw ) {
		$out  = array( 'types' => array(), 'taxonomies' => array(), 'notes' => array() );
		$seen = array();

		// The TAXONOMIES first: the types' contents are classified inside their terms, and to
		// validate them we already have to know which terms exist.
		$terms = array();
		foreach ( self::plan_model_taxonomies( $raw, $out['notes'] ) as $tx ) {
			$out['taxonomies'][]    = $tx;
			$terms[ $tx['slug'] ] = $tx['terms'];
		}

		foreach ( (array) ( isset( $raw['types'] ) ? $raw['types'] : array() ) as $t ) {
			// Four and not eight as on the content model page: here ONE page is being planned, and a
			// page demanding five new content types has not understood the request — it is turning
			// every list it sees into an archive.
			if ( ! is_array( $t ) || count( $out['types'] ) >= 4 ) {
				continue;
			}
			$slug = AIPB_Types::clean_slug( isset( $t['slug'] ) ? $t['slug'] : '' );
			if ( '' === $slug || isset( $seen[ $slug ] ) ) {
				continue;
			}
			if ( post_type_exists( $slug ) ) {
				// Not a problem worth reporting: the type is there, and the section will loop over it
				// as it always has. Saying so would only suggest something is missing.
				continue;
			}
			$seen[ $slug ] = true;

			$fields = self::plan_model_fields( $t, $slug, $out['notes'] );
			$items  = self::plan_model_items( $t, $fields, $terms );

			if ( ! $items ) {
				/* translators: %s: slug of the proposed content type. */
				$out['notes'][] = sprintf( __( 'The type «%s» carried no initial content: I am not proposing it, an empty type would leave the section with nothing to list.', 'aipb' ), $slug );
				continue;
			}

			$out['types'][] = array(
				'slug'     => $slug,
				'singular' => self::clean_label( isset( $t['singular'] ) ? $t['singular'] : '' ),
				'plural'   => self::clean_label( isset( $t['plural'] ) ? $t['plural'] : '' ),
				'archive'  => ! empty( $t['archive'] ),
				'why'      => self::shorten( isset( $t['why'] ) ? $t['why'] : '', 160 ),
				'fields'   => $fields,
				'items'    => $items,
			);
		}

		return $out;
	}

	/**
	 * The proposed taxonomies, with their terms.
	 *
	 * A taxonomy is discarded in two cases, and they are the same case seen from two sides: if it
	 * already exists (recreating it does nothing, and saying so would suggest something was added)
	 * and if it does not say which types it applies to — that one attaches to nothing, appears on no
	 * screen, and nobody would ever connect its absence to this line.
	 *
	 * The TERMS are the difference between a taxonomy and a useful taxonomy: without them, the
	 * filter bar comes out with no buttons. They are not compulsory — some people want only the
	 * container and to fill it by hand — but an empty taxonomy has to be said, not left to be
	 * discovered.
	 *
	 * @param array $raw   The raw block containing "taxonomies".
	 * @param array $notes Passed by reference: what gets discarded gets said.
	 *
	 * @return list<array{slug:string,singular:string,plural:string,types:list<string>,hierarchical:bool,terms:list<array>}>
	 */
	protected static function plan_model_taxonomies( $raw, &$notes ) {
		$out  = array();
		$seen = array();

		// The types proposed in the same block count as targets: the taxonomy and the type it
		// attaches to are born together, and neither of them exists yet.
		$incoming = array();
		foreach ( (array) ( isset( $raw['types'] ) ? $raw['types'] : array() ) as $t ) {
			if ( is_array( $t ) ) {
				$s = AIPB_Types::clean_slug( isset( $t['slug'] ) ? $t['slug'] : '' );
				if ( '' !== $s ) {
					$incoming[ $s ] = true;
				}
			}
		}

		foreach ( (array) ( isset( $raw['taxonomies'] ) ? $raw['taxonomies'] : array() ) as $tx ) {
			if ( ! is_array( $tx ) || count( $out ) >= 4 ) {
				continue;
			}
			$slug = AIPB_Types::clean_slug( isset( $tx['slug'] ) ? $tx['slug'] : '' );
			if ( '' === $slug || isset( $seen[ $slug ] ) || taxonomy_exists( $slug ) ) {
				continue;
			}

			$for = array();
			foreach ( (array) ( isset( $tx['types'] ) ? $tx['types'] : array() ) as $pt ) {
				$pt = AIPB_Types::clean_slug( $pt );
				if ( '' !== $pt && ( isset( $incoming[ $pt ] ) || post_type_exists( $pt ) ) ) {
					$for[] = $pt;
				}
			}
			if ( ! $for ) {
				/* translators: %s: taxonomy slug. */
				$notes[] = sprintf( __( 'The taxonomy «%s» did not say which content type it applies to: discarded.', 'aipb' ), $slug );
				continue;
			}
			$seen[ $slug ] = true;

			// The GROUP's fields, not the content's: they are printed with term.acf_fieldname and are
			// what makes a category's page presentable (a cover, a strapline).
			$fields = self::plan_model_fields( $tx, $slug, $notes );
			$groups = self::plan_model_terms( $tx, $fields );

			if ( ! $groups ) {
				/* translators: %s: taxonomy slug. */
				$notes[] = sprintf( __( 'The taxonomy «%s» is born with no groups: I am creating it, but until you add some, its filters will have no buttons.', 'aipb' ), $slug );
			}

			$out[] = array(
				'slug'         => $slug,
				'singular'     => self::clean_label( isset( $tx['singular'] ) ? $tx['singular'] : '' ),
				'plural'       => self::clean_label( isset( $tx['plural'] ) ? $tx['plural'] : '' ),
				'types'        => array_values( array_unique( $for ) ),
				'hierarchical' => ! empty( $tx['hierarchical'] ),
				'fields'       => $fields,
				'terms'        => $groups,
			);
		}

		return $out;
	}

	/**
	 * A taxonomy's terms, with the values of their own fields.
	 *
	 * A group is either a string or { name, fields }. The two forms coexist because almost always
	 * only the name is needed, and forcing the object form would have made the normal case
	 * unreadable.
	 */
	protected static function plan_model_terms( $tx, $fields ) {
		$type_of = array();
		foreach ( $fields as $f ) {
			$type_of[ $f['key'] ] = $f['type'];
		}

		$groups = array();
		$seen   = array();

		foreach ( (array) ( isset( $tx['terms'] ) ? $tx['terms'] : array() ) as $g ) {
			if ( count( $groups ) >= 20 ) {
				break;
			}
			$name = self::clean_label( is_array( $g ) ? ( isset( $g['name'] ) ? $g['name'] : '' ) : $g );
			$key  = mb_strtolower( $name );
			if ( '' === $name || isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;

			$values = array();
			if ( is_array( $g ) ) {
				foreach ( (array) ( isset( $g['fields'] ) ? $g['fields'] : array() ) as $k => $v ) {
					$k = AIPB_Fields::clean_key( (string) $k );
					if ( isset( $type_of[ $k ] ) ) {
						$values[ $k ] = AIPB_Fields::sanitize_value( $type_of[ $k ], $v );
					}
				}
			}

			$groups[] = array( 'name' => $name, 'fields' => $values );
		}

		return $groups;
	}

	/**
	 * The fields of a type proposed by the plan.
	 *
	 * @param array  $t     The raw "types" entry.
	 * @param string $slug  The already cleaned slug, for the notes.
	 * @param array  $notes Passed by reference: what gets discarded gets said.
	 *
	 * @return list<array{key:string,type:string,label:string}>
	 */
	protected static function plan_model_fields( $t, $slug, &$notes ) {
		$out  = array();
		$seen = array();

		foreach ( (array) ( isset( $t['fields'] ) ? $t['fields'] : array() ) as $f ) {
			if ( ! is_array( $f ) || count( $out ) >= 8 ) {
				continue;
			}
			$key = AIPB_Fields::clean_key( isset( $f['key'] ) ? (string) $f['key'] : '' );
			if ( '' === $key || isset( $seen[ $key ] ) ) {
				continue;
			}
			$type = isset( $f['type'] ) ? sanitize_key( (string) $f['type'] ) : '';
			if ( ! AIPB_Fields::is_type( $type ) ) {
				/* translators: 1: proposed field type, 2: content type slug. */
				$notes[] = sprintf( __( 'Unknown field type «%1$s» on «%2$s»: discarded.', 'aipb' ), $type, $slug );
				continue;
			}
			$seen[ $key ] = true;
			$out[]        = array(
				'key'   => $key,
				'type'  => $type,
				'label' => self::clean_label( isset( $f['label'] ) ? $f['label'] : '' ),
			);
		}

		return $out;
	}

	/**
	 * The initial contents of a type proposed by the plan.
	 *
	 * The title is the only compulsory thing: a content with no title is an empty row in the admin
	 * list, and nobody would know which one it is any more. The field values are cleaned with the
	 * DECLARED type — a `number` arriving as «57.9 million km» comes out empty, and rightly so:
	 * better an empty field than a number invented by the conversion.
	 *
	 * The TERMS are validated the same way as the fields, against the declared ones: a name the
	 * taxonomy does not have would create an extra group nobody asked for, and it would also be the
	 * only content in there — a filter with one button that selects a single thing.
	 *
	 * @param array $t      The raw "types" entry.
	 * @param array $fields The already validated fields: values outside these are not kept.
	 * @param array $terms  The accepted terms per taxonomy { slug => [ names ] }.
	 *
	 * @return list<array{title:string,excerpt:string,fields:array,terms:array}>
	 */
	protected static function plan_model_items( $t, $fields, $terms = array() ) {
		$type_of = array();
		foreach ( $fields as $f ) {
			$type_of[ $f['key'] ] = $f['type'];
		}

		$allowed = self::allowed_term_names( $terms );

		$out  = array();
		$seen = array();

		foreach ( (array) ( isset( $t['items'] ) ? $t['items'] : array() ) as $item ) {
			if ( ! is_array( $item ) || count( $out ) >= 12 ) {
				continue;
			}
			$title = self::clean_label( isset( $item['title'] ) ? $item['title'] : '' );
			// Two entries with the same title are almost always the model repeating itself, and they
			// would become two indistinguishable contents in the list.
			if ( '' === $title || isset( $seen[ mb_strtolower( $title ) ] ) ) {
				continue;
			}
			$seen[ mb_strtolower( $title ) ] = true;

			$values = array();
			foreach ( (array) ( isset( $item['fields'] ) ? $item['fields'] : array() ) as $k => $v ) {
				$k = AIPB_Fields::clean_key( (string) $k );
				if ( isset( $type_of[ $k ] ) ) {
					$values[ $k ] = AIPB_Fields::sanitize_value( $type_of[ $k ], $v );
				}
			}

			$out[] = array(
				'title'   => $title,
				'excerpt' => self::shorten( isset( $item['excerpt'] ) ? $item['excerpt'] : '', 300 ),
				'fields'  => $values,
				'terms'   => self::item_terms( $item, $allowed ),
				// What to search its image with. The title alone is often not enough: «Mercury» finds
				// a thermometer, «planet Mercury space» finds a planet. Whoever writes the content
				// knows what it refers to; we, from outside, do not.
				'image_query' => self::clean_label( isset( $item['image_query'] ) ? $item['image_query'] : '' ),
			);
		}

		return $out;
	}

	/**
	 * The term names a content may be filed under, indexed by their normalised form.
	 *
	 * The comparison is on normalised names: the model writes «desserts» inside the item and
	 * «Desserts» in the taxonomy, and it is the same group. The DECLARED form is kept, because that
	 * is the one the visitor will read on the filter's button.
	 */
	protected static function allowed_term_names( $terms ) {
		$allowed = array();

		foreach ( (array) $terms as $tx => $names ) {
			foreach ( (array) $names as $n ) {
				// A group can be a string or { name, fields }: here only the name matters.
				$name = is_array( $n ) ? ( isset( $n['name'] ) ? $n['name'] : '' ) : $n;
				$name = trim( (string) $name );
				if ( '' !== $name ) {
					$allowed[ $tx ][ mb_strtolower( $name ) ] = $name;
				}
			}
		}

		return $allowed;
	}

	/** The terms of one proposed content, kept only where they match a declared one. */
	protected static function item_terms( $item, $allowed ) {
		$out = array();

		foreach ( (array) ( isset( $item['terms'] ) ? $item['terms'] : array() ) as $tx => $names ) {
			$tx = AIPB_Types::clean_slug( (string) $tx );
			if ( '' === $tx || ! isset( $allowed[ $tx ] ) ) {
				continue;
			}
			foreach ( (array) $names as $n ) {
				$key = mb_strtolower( trim( (string) $n ) );
				if ( isset( $allowed[ $tx ][ $key ] ) ) {
					$out[ $tx ][] = $allowed[ $tx ][ $key ];
				}
			}
			if ( isset( $out[ $tx ] ) ) {
				$out[ $tx ] = array_values( array_unique( $out[ $tx ] ) );
			}
		}

		return $out;
	}

	/* ------------------------------ Content model ----------------------------- */

	/**
	 * Content model: interprets the JSON with the types, taxonomies and fields proposed from the
	 * brief.
	 *
	 * IT SAVES NOTHING. It returns a proposal that the «Content model» page puts into the form's
	 * fields, where whoever pressed the button reads it, corrects it and decides whether to save it.
	 * That is deliberate: a slug is in the URL of every content of that type and a key is the name
	 * of a meta in the database — things chosen once and for all, and not a decision the model can
	 * take on its own.
	 *
	 * The strictness here matches the tokens'. Everything that does not pass is discarded and SAID
	 * in "notes": a slug that is too long or an invented field type, coming in silently, would
	 * become a field the panel cannot draw and the engine cannot print.
	 *
	 * @return array { types, taxonomies, fields, notes }
	 */
	protected static function parse_model_proposal( $raw ) {
		$out = array( 'types' => array(), 'taxonomies' => array(), 'fields' => array(), 'notes' => array() );

		$parsed = self::decode_json( $raw );
		if ( ! is_array( $parsed ) ) {
			$out['notes'][] = __( 'The model\'s answer was not readable as JSON: no proposal.', 'aipb' );

			return $out;
		}

		/* ----------------------------- Content types ----------------------------- */
		$new = array();
		$raw_types = array();
		foreach ( (array) ( isset( $parsed['types'] ) ? $parsed['types'] : array() ) as $t ) {
			if ( ! is_array( $t ) || count( $out['types'] ) >= 8 ) {
				continue;
			}
			$slug = AIPB_Types::clean_slug( isset( $t['slug'] ) ? $t['slug'] : '' );
			if ( '' === $slug || isset( $new[ $slug ] ) ) {
				continue;
			}
			// A type that already exists is not proposed again: registering a second one with the
			// same slug does nothing, but the extra row on the page suggests otherwise.
			if ( post_type_exists( $slug ) ) {
				/* translators: %s: content type slug. */
				$out['notes'][] = sprintf( __( 'The type «%s» already exists on this site: not proposed again.', 'aipb' ), $slug );
				continue;
			}
			$new[ $slug ] = true;
			// The initial contents are cleaned LATER, at the end: they need the fields, which in this
			// format arrive further down, in a separate object indexed by type.
			$raw_types[ $slug ] = $t;

			$out['types'][] = array(
				'slug'     => $slug,
				'singular' => self::clean_label( isset( $t['singular'] ) ? $t['singular'] : '' ),
				'plural'   => self::clean_label( isset( $t['plural'] ) ? $t['plural'] : '' ),
				'archive'  => ! empty( $t['archive'] ),
				// The why is not saved anywhere: it is read on the page, and it is what makes it
				// possible to say "not this one" to a type the brief did not justify.
				'why'      => self::shorten( isset( $t['why'] ) ? $t['why'] : '', 160 ),
				// The initial contents, filled in at the end. The «Content model» page ignores them —
				// that is where you design, not populate — but the Studio builds the section on top
				// of them, and a type with no content would give it an empty list.
				'items'    => array(),
			);
		}

		/* ------------------------------- Taxonomies ------------------------------ */
		// The same parser as the plan's route: the two JSON shapes differ on where the fields sit,
		// not on what a taxonomy is. Keeping two copies meant two sets of rules that over time say
		// different things about the same thing.
		$out['taxonomies'] = self::plan_model_taxonomies( $parsed, $out['notes'] );

		$terms = array();
		foreach ( $out['taxonomies'] as $tx ) {
			$terms[ $tx['slug'] ] = $tx['terms'];
		}

		/* --------------------------------- Fields -------------------------------- */
		$by_type = isset( $parsed['fields'] ) && is_array( $parsed['fields'] ) ? $parsed['fields'] : array();
		foreach ( $by_type as $pt => $list ) {
			$pt = AIPB_Types::clean_slug( $pt );
			if ( '' === $pt || ! is_array( $list ) ) {
				continue;
			}
			if ( ! isset( $new[ $pt ] ) && ! post_type_exists( $pt ) ) {
				/* translators: %s: content type slug. */
				$out['notes'][] = sprintf( __( 'Fields proposed for «%s», which is not a content type of this site: discarded.', 'aipb' ), $pt );
				continue;
			}

			$clean = self::model_fields_for( $pt, $list, $out['notes'] );
			if ( $clean ) {
				$out['fields'][ $pt ] = $clean;
			}
		}

		/* ---------------------------- Initial contents --------------------------- */
		// At the end, and not together with the type, because the values are cleaned with the field's
		// TYPE (a `number` arriving as «57.9 million km» has to come out empty, not truncated to 57)
		// and the fields are only known now.
		foreach ( $out['types'] as $i => $type ) {
			$slug = $type['slug'];
			if ( ! isset( $raw_types[ $slug ] ) ) {
				continue;
			}
			$fields                      = isset( $out['fields'][ $slug ] ) ? $out['fields'][ $slug ] : array();
			$out['types'][ $i ]['items'] = self::plan_model_items( $raw_types[ $slug ], $fields, $terms );
		}

		foreach ( (array) ( isset( $parsed['notes'] ) ? $parsed['notes'] : array() ) as $n ) {
			$note = self::shorten( is_string( $n ) ? $n : '', 220 );
			if ( '' !== $note ) {
				$out['notes'][] = $note;
			}
		}

		if ( ! $out['types'] && ! $out['taxonomies'] && ! $out['fields'] ) {
			$out['notes'][] = __( 'Nothing came out of the brief worth adding: the site already has the types and fields it needs.', 'aipb' );
		}

		return $out;
	}

	/**
	 * The proposed fields of one content type, validated against the ones it already declares.
	 *
	 * Proposing a field that already exists would overwrite its label and — worse — the type of a
	 * field somebody has already written values into.
	 *
	 * @param array $notes Passed by reference: what gets discarded gets said.
	 */
	protected static function model_fields_for( $post_type, $list, &$notes ) {
		$existing = array();
		foreach ( AIPB_Fields::for_type( $post_type ) as $f ) {
			$existing[ $f['key'] ] = true;
		}

		$clean = array();
		foreach ( $list as $f ) {
			if ( ! is_array( $f ) || count( $clean ) >= 12 ) {
				continue;
			}
			$key = AIPB_Fields::clean_key( isset( $f['key'] ) ? (string) $f['key'] : '' );
			if ( '' === $key || isset( $clean[ $key ] ) ) {
				continue;
			}
			if ( isset( $existing[ $key ] ) ) {
				/* translators: 1: field key, 2: content type slug. */
				$notes[] = sprintf( __( 'The field «%1$s» of «%2$s» already exists: not proposed again.', 'aipb' ), $key, $post_type );
				continue;
			}
			$type = isset( $f['type'] ) ? sanitize_key( (string) $f['type'] ) : '';
			if ( ! AIPB_Fields::is_type( $type ) ) {
				/* translators: 1: proposed field type, 2: field key. */
				$notes[] = sprintf( __( 'Unknown field type «%1$s» for «%2$s»: discarded.', 'aipb' ), $type, $key );
				continue;
			}

			$clean[ $key ] = array(
				'key'   => $key,
				'type'  => $type,
				'label' => self::clean_label( isset( $f['label'] ) ? $f['label'] : '' ),
			);
		}

		return array_values( $clean );
	}

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

	/**
	 * The JSON of an answer, tolerant of prose written around it.
	 *
	 * @return array|null
	 */
	protected static function decode_json( $raw ) {
		$text   = self::strip_fences( is_string( $raw ) ? $raw : '' );
		$parsed = json_decode( $text, true );

		// Tolerant: if the model added prose around it, isolate the first JSON object.
		if ( ! is_array( $parsed ) && preg_match( '/\{.*\}/s', $text, $m ) ) {
			$parsed = json_decode( $m[0], true );
		}

		return is_array( $parsed ) ? $parsed : null;
	}

	/**
	 * A sentence written by the model, shortened AT A WORD BOUNDARY.
	 *
	 * Cutting at a character leaves notes that end mid-syllable («…of the project pag»), and a line
	 * like that does not read as a deliberate length: it reads as a defect, and it casts doubt on
	 * the rest of the proposal.
	 */
	protected static function shorten( $text, $max ) {
		$text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( (string) $text ) ) );
		if ( '' === $text || mb_strlen( $text ) <= $max ) {
			return $text;
		}

		$short = mb_substr( $text, 0, $max );
		$space = mb_strrpos( $short, ' ' );

		return rtrim( false !== $space && $space > $max / 2 ? mb_substr( $short, 0, $space ) : $short, " ,;:." ) . '…';
	}

	/** A label written by a model: plain text, short, no tags. */
	protected static function clean_label( $value ) {
		return mb_substr( sanitize_text_field( wp_strip_all_tags( (string) $value ) ), 0, 60 );
	}

	/* ------------------------------ Design tokens ----------------------------- */

	/**
	 * Token import: interprets the JSON measured off the mockups and normalises it into the shape
	 * the Design Tokens page can put into the form.
	 *
	 * The rules are strict here. The model measured by looking at images: anything that fails
	 * validation is DISCARDED and said in "notes", because a wrong token entering the design system
	 * silently then dirties every generated section. Font families in particular are checked against
	 * the Google catalogue, which is the only thing the plugin can actually load afterwards.
	 *
	 * @return array { palette, fonts, tokens, direction, notes }
	 */
	protected static function parse_tokens_import( $raw ) {
		$out = array(
			'palette'   => array(),
			'fonts'     => array(),
			'tokens'    => array(),
			'direction' => '',
			'notes'     => array(),
		);

		$parsed = self::decode_json( $raw );
		if ( ! is_array( $parsed ) ) {
			$out['notes'][] = __( 'The model\'s answer was not readable as JSON: no token imported.', 'aipb' );
			return $out;
		}

		$out['palette'] = self::import_palette( $parsed );
		$out['fonts']   = self::import_fonts( $parsed, $out['notes'] );
		$out['tokens']  = self::import_tokens( $parsed );

		/* --------------------------- Direction + notes --------------------------- */
		if ( isset( $parsed['direction'] ) && is_string( $parsed['direction'] ) ) {
			$max              = class_exists( 'AIPB_Design' ) ? AIPB_Design::DIRECTION_MAX : 1500;
			$out['direction'] = mb_substr( trim( wp_strip_all_tags( $parsed['direction'] ) ), 0, $max );
		}
		if ( isset( $parsed['notes'] ) && is_array( $parsed['notes'] ) ) {
			foreach ( $parsed['notes'] as $note ) {
				if ( is_string( $note ) && '' !== trim( $note ) ) {
					$out['notes'][] = mb_substr( trim( wp_strip_all_tags( $note ) ), 0, 300 );
				}
				if ( count( $out['notes'] ) >= 12 ) {
					break;
				}
			}
		}

		return $out;
	}

	/** The measured palette: valid hex values only. */
	protected static function import_palette( $parsed ) {
		$out = array();
		if ( ! isset( $parsed['palette'] ) || ! is_array( $parsed['palette'] ) ) {
			return $out;
		}

		foreach ( $parsed['palette'] as $c ) {
			if ( ! is_array( $c ) ) {
				continue;
			}
			$hex = isset( $c['hex'] ) ? trim( (string) $c['hex'] ) : '';
			if ( ! preg_match( '/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $hex ) ) {
				continue;
			}
			$name  = isset( $c['name'] ) ? trim( wp_strip_all_tags( (string) $c['name'] ) ) : '';
			$out[] = array(
				'slug'  => '', // the option's sanitize derives it from the name
				'name'  => mb_substr( $name, 0, 60 ),
				'value' => strtolower( $hex ),
				'usage' => isset( $c['usage'] ) ? mb_substr( trim( wp_strip_all_tags( (string) $c['usage'] ) ), 0, 120 ) : '',
			);
			if ( count( $out ) >= 12 ) {
				break;
			}
		}

		return $out;
	}

	/** The measured fonts, checked against the Google catalogue. */
	protected static function import_fonts( $parsed, &$notes ) {
		$out = array();
		if ( ! isset( $parsed['fonts'] ) || ! is_array( $parsed['fonts'] ) ) {
			return $out;
		}

		$catalog = class_exists( 'AIPB_Design' ) ? AIPB_Design::google_font_catalog() : array();

		foreach ( $parsed['fonts'] as $f ) {
			if ( ! is_array( $f ) ) {
				continue;
			}
			$family = isset( $f['family'] ) ? trim( preg_replace( '/\s+/', ' ', preg_replace( '/[^A-Za-z0-9 ]/', '', (string) $f['family'] ) ) ) : '';
			if ( '' === $family ) {
				continue;
			}
			if ( $catalog && ! isset( $catalog[ $family ] ) ) {
				// Off catalogue: we do not import it at random, we say so.
				/* translators: %s: font family name. */
				$notes[] = sprintf( __( 'Font «%s» not found in the Google catalogue: not imported, add it by hand if it exists.', 'aipb' ), $family );
				continue;
			}

			$weights = array();
			if ( isset( $f['weights'] ) && is_array( $f['weights'] ) ) {
				foreach ( $f['weights'] as $w ) {
					$w = (int) $w;
					if ( $w >= 100 && $w <= 900 && ! in_array( $w, $weights, true ) ) {
						$weights[] = $w;
					}
				}
			}
			sort( $weights );

			$out[] = array(
				'slug'     => '',
				'name'     => isset( $f['name'] ) ? mb_substr( trim( wp_strip_all_tags( (string) $f['name'] ) ), 0, 60 ) : '',
				'family'   => $family,
				'category' => isset( $catalog[ $family ] ) ? $catalog[ $family ]['category'] : ( isset( $f['category'] ) ? sanitize_key( $f['category'] ) : 'sans-serif' ),
				'weights'  => $weights ? $weights : array( 400 ),
			);
			if ( count( $out ) >= 4 ) {
				break;
			}
		}

		return $out;
	}

	/** The structural tokens: only the keys the design system really knows, the rest is noise. */
	protected static function import_tokens( $parsed ) {
		$out   = array();
		$known = class_exists( 'AIPB_Design' ) ? AIPB_Design::flat_defaults() : array();

		foreach ( array( 'text', 'space', 'radius', 'shadow', 'layout' ) as $gk ) {
			if ( ! isset( $parsed[ $gk ] ) || ! is_array( $parsed[ $gk ] ) ) {
				continue;
			}
			foreach ( $parsed[ $gk ] as $ik => $value ) {
				$id = $gk . ':' . sanitize_key( str_replace( '_', '-', (string) $ik ) );
				if ( ! array_key_exists( $id, $known ) || ! is_scalar( $value ) ) {
					continue;
				}
				$clean = AIPB_Design::with_unit( AIPB_Design::sanitize_value( (string) $value ), $id );
				if ( '' === $clean || mb_strlen( $clean ) > 120 ) {
					continue;
				}
				$out[ $id ] = $clean;
			}
		}

		return $out;
	}

	/**
	 * Turns the design tokens MEASURED off the reference into an exact, compact textual spec, to be
	 * injected into EVERY section generation: concrete values (hex/px/weights) instead of lossy
	 * prose. Returns '' when nothing was measured, so the flow falls back cleanly onto the
	 * description in words ("design") alone.
	 *
	 * The lines are written in the SAME LANGUAGE AS THE PROMPTS (Italian, see the service's
	 * `Aura\Prompt`): they are concatenated into the prompt the model reads, not shown to the user.
	 * Translating them would change what the model receives, with no golden test pinning the result.
	 */
	protected static function tokens_to_spec( $tokens ) {
		if ( ! is_array( $tokens ) || empty( $tokens ) ) {
			return '';
		}
		$lines = array();

		// Areas the user has already decided in the design system: there the measurement does NOT
		// speak. Otherwise a number deduced from an image (or, worse, copied from the format's
		// example) would end up hand-written in every section in place of the variable — and the
		// design system, which is the user's explicit choice, would be silently overridden.
		$owned = class_exists( 'AIPB_Design' ) ? AIPB_Design::defined_areas() : array();
		$has   = function ( $area ) use ( $owned ) {
			return ! empty( $owned[ $area ] );
		};

		// Colours: role = #hex (valid hex only).
		if ( isset( $tokens['colors'] ) && is_array( $tokens['colors'] ) ) {
			$cols = array();
			foreach ( $tokens['colors'] as $c ) {
				if ( ! is_array( $c ) ) {
					continue;
				}
				$hex  = isset( $c['hex'] ) ? trim( (string) $c['hex'] ) : '';
				$role = isset( $c['role'] ) ? trim( wp_strip_all_tags( (string) $c['role'] ) ) : '';
				if ( preg_match( '/^#[0-9a-fA-F]{6}$/', $hex ) ) {
					$cols[] = ( '' !== $role ? $role . ': ' : '' ) . strtolower( $hex );
				}
				if ( count( $cols ) >= 8 ) {
					break;
				}
			}
			if ( $cols ) {
				$lines[] = 'Colours (role: exact hex): ' . implode( ' · ', $cols );
			}
		}

		// Fonts by role.
		if ( isset( $tokens['fonts'] ) && is_array( $tokens['fonts'] ) ) {
			$fs = array();
			foreach ( $tokens['fonts'] as $f ) {
				if ( ! is_array( $f ) ) {
					continue;
				}
				$fam  = isset( $f['family'] ) ? trim( wp_strip_all_tags( (string) $f['family'] ) ) : '';
				$role = isset( $f['role'] ) ? trim( wp_strip_all_tags( (string) $f['role'] ) ) : '';
				if ( '' !== $fam ) {
					$w    = ( isset( $f['weights'] ) && is_array( $f['weights'] ) ) ? array_map( 'intval', $f['weights'] ) : array();
					$w    = array_values( array_filter( $w, function ( $x ) {
						return $x >= 100 && $x <= 900;
					} ) );
					$fs[] = ( '' !== $role ? $role . ': ' : '' ) . $fam . ( $w ? ' (weights ' . implode( '/', $w ) . ')' : '' );
				}
			}
			if ( $fs ) {
				$lines[] = 'Typography (families): ' . implode( ' · ', $fs );
			}
		}

		// Type scale: role NNpx/weightNNN/lhN.N. Silent when the design system has its own.
		if ( isset( $tokens['type_scale'] ) && is_array( $tokens['type_scale'] ) && ! $has( 'text' ) ) {
			$ts = array();
			foreach ( $tokens['type_scale'] as $t ) {
				if ( ! is_array( $t ) ) {
					continue;
				}
				$role = isset( $t['role'] ) ? trim( wp_strip_all_tags( (string) $t['role'] ) ) : '';
				$size = isset( $t['size_px'] ) ? (int) $t['size_px'] : 0;
				if ( '' === $role || $size <= 0 ) {
					continue;
				}
				$part = $role . ' ' . $size . 'px';
				if ( isset( $t['weight'] ) && (int) $t['weight'] > 0 ) {
					$part .= '/' . (int) $t['weight'];
				}
				if ( isset( $t['line_height'] ) && (float) $t['line_height'] > 0 ) {
					$part .= ' lh' . round( (float) $t['line_height'], 2 );
				}
				$ts[] = $part;
				if ( count( $ts ) >= 8 ) {
					break;
				}
			}
			if ( $ts ) {
				$lines[] = 'Typographic scale: ' . implode( ' · ', $ts );
			}
		}

		// Spacing.
		if ( isset( $tokens['spacing'] ) && is_array( $tokens['spacing'] ) ) {
			$sp   = $tokens['spacing'];
			$bits = array();
			if ( isset( $sp['section_padding_y_px'] ) && (int) $sp['section_padding_y_px'] > 0 && ! $has( 'space' ) ) {
				$bits[] = 'section vertical padding ~' . (int) $sp['section_padding_y_px'] . 'px';
			}
			if ( isset( $sp['content_max_width_px'] ) && (int) $sp['content_max_width_px'] > 0 && ! $has( 'layout' ) ) {
				$bits[] = 'max content width ~' . (int) $sp['content_max_width_px'] . 'px';
			}
			if ( isset( $sp['gap_px'] ) && (int) $sp['gap_px'] > 0 && ! $has( 'space' ) ) {
				$bits[] = 'gap ~' . (int) $sp['gap_px'] . 'px';
			}
			if ( $bits ) {
				$lines[] = 'Spacing: ' . implode( ', ', $bits );
			}
		}

		// Radii. Silent when the design system defines its own.
		if ( isset( $tokens['radii'] ) && is_array( $tokens['radii'] ) && ! $has( 'radius' ) ) {
			$r    = $tokens['radii'];
			$bits = array();
			if ( isset( $r['button_px'] ) && (int) $r['button_px'] >= 0 ) {
				$bits[] = 'buttons ' . (int) $r['button_px'] . 'px';
			}
			if ( isset( $r['card_px'] ) && (int) $r['card_px'] >= 0 ) {
				$bits[] = 'cards ' . (int) $r['card_px'] . 'px';
			}
			if ( $bits ) {
				$lines[] = 'Radii (border-radius): ' . implode( ', ', $bits );
			}
		}

		// Shadows (raw CSS strings, cleaned of dangerous characters). Likewise.
		if ( isset( $tokens['shadows'] ) && is_array( $tokens['shadows'] ) && ! $has( 'shadow' ) ) {
			$sh = array();
			foreach ( $tokens['shadows'] as $s ) {
				$s = trim( (string) $s );
				if ( '' !== $s && preg_match( '/^[0-9a-zA-Z#.,()%\s\/-]+$/', $s ) ) {
					$sh[] = $s;
				}
				if ( count( $sh ) >= 3 ) {
					break;
				}
			}
			if ( $sh ) {
				$lines[] = 'Shadows: ' . implode( ' ; ', $sh );
			}
		}

		if ( $lines && ( $has( 'text' ) || $has( 'space' ) || $has( 'radius' ) || $has( 'shadow' ) || $has( 'layout' ) || $has( 'colors' ) || $has( 'fonts' ) ) ) {
			$lines[] = 'These values only fill the GAPS in the design system: where an --aipb-* variable exists (listed further down) ALWAYS use that one, never a hand-written value.';
		}

		return implode( "\n", $lines );
	}

	/* ------------------------------ Section output ---------------------------- */

	/** Removes any markdown fence ```…``` around the code. */
	protected static function strip_fences( $text ) {
		$t = trim( (string) $text );
		if ( 0 === strpos( $t, '```' ) ) {
			$t = preg_replace( '/^```[a-zA-Z]*\s*\n?/', '', $t );
			$t = preg_replace( '/\n?```\s*$/', '', $t );
		}
		return $t;
	}

	/**
	 * Section: separates the template from the (optional) repeater values block, extracts the inline
	 * contents from the placeholders and DERIVES the schema. Returns null when it does not look like
	 * code.
	 */
	protected static function parse_raw_section( $text ) {
		$text = self::strip_fences( $text );

		$values_block = array();
		$parts        = preg_split( '/<!--\s*AIPB-VALUES\s*-->/', $text, 2 );
		if ( isset( $parts[1] ) ) {
			$decoded = json_decode( trim( $parts[1] ), true );
			if ( is_array( $decoded ) ) {
				$values_block = $decoded;
			}
		}
		$template = trim( $parts[0] );
		if ( '' === $template || false === strpos( $template, '<' ) ) {
			return null;
		}

		list( $template, $inline_values, $field_defaults ) = self::process_inline_defaults( $template );
		$values = array_merge( $inline_values, is_array( $values_block ) ? $values_block : array() );

		// THE FIELDS ARE NOT ASKED FOR EITHER. The prompt tells the model to expose every text as a
		// placeholder and it complies most of the time, which leaves a panel with a box for six
		// things out of seven and nothing on screen to say why. AIPB_Autofields reads the markup —
		// which says without any ambiguity what is a sentence and what is an ornament — and writes
		// the placeholders the model did not. It runs BEFORE the switches on purpose: a field that
		// arrives here has to be switchable like the ones that came from the model, and
		// AIPB_Switches works off the placeholders it can see.
		if ( class_exists( 'AIPB_Autofields' ) ) {
			list( $template, $values, $field_defaults ) = AIPB_Autofields::apply( $template, $values, $field_defaults );
		}

		// SHOW/HIDE IS NOT ASKED FOR, IT IS PUT IN. The prompt tells the model to wrap every
		// editable element in `{{#if <head>_visible}}` and it complies about half the time, which is
		// the worst of the three possible outcomes: the panel then has switches on some rows and not
		// on others, for no reason anybody looking at it can see. AIPB_Switches reads the markup —
		// which says without any ambiguity which element a field is displayed by — and writes the
		// conditions the model did not. It leaves the ones it did alone, so this costs nothing on a
		// section that came back complete, and derive_schema below turns all of them, however they
		// got there, into the same boolean field.
		$template = AIPB_Switches::wrap( $template );

		// The stand-in photographs become real ones HERE, once, while the section is being read —
		// not at render time, where it would mean a network call inside a visitor's page load, and
		// not in the browser, where only half the flows would go through it. Every generated section
		// passes through this function: a plan's, a single one's, and a touch-up that adds an image.
		if ( class_exists( 'AIPB_Images' ) ) {
			$values = AIPB_Images::resolve_placeholders( $values, $template );
		}

		$schema = self::derive_schema( $template, $values, $field_defaults );
		// A switch whose value was never written reads as false at render time while the panel,
		// which looks at the schema instead, draws it on. Same section, two answers.
		$values = AIPB_Switches::seed( $schema, $values );

		return array( 'template' => $template, 'schema' => $schema, 'values' => $values );
	}

	/**
	 * Turns the placeholders with an inline default {{t:key|content}} into {{t:key}}, collecting the
	 * contents as initial values (and the repeaters' subfield defaults).
	 */
	protected static function process_inline_defaults( $template ) {
		$values         = array();
		$field_defaults = array();

		$out = preg_replace_callback(
			'/\{\{([tciurs]):([a-zA-Z0-9_\.]+)\|(.*?)\}\}/s',
			function ( $m ) use ( &$values, &$field_defaults ) {
				$kind = strtolower( $m[1] );
				$path = $m[2];
				$def  = $m[3];
				if ( 0 === strpos( $path, 'field.' ) ) {
					$field_defaults[ substr( $path, 6 ) ] = $def; // subfield default
				} else {
					$values[ $path ] = $def;
				}
				return '{{' . $kind . ':' . $path . '}}';
			},
			$template
		);

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

	/** Derives the schema (the editable fields) from the template's placeholders. */
	protected static function derive_schema( $template, $values, $field_defaults ) {
		$fields = array();
		$seen   = array();

		// Repeaters: {{#loop source="prop" name="X"}} … {{/loop}} with X not "field.*".
		if ( preg_match_all( '/\{\{#loop\s+([^}]*?)\}\}(.*?)\{\{\/loop\}\}/s', $template, $loops, PREG_SET_ORDER ) ) {
			foreach ( $loops as $lp ) {
				$source = self::attr( $lp[1], 'source' );
				$source = '' !== $source ? $source : 'prop';
				$name   = self::attr( $lp[1], 'name' );
				if ( 'prop' !== $source || '' === $name || 0 === strpos( $name, 'field.' ) || isset( $seen[ $name ] ) ) {
					continue;
				}
				$seen[ $name ] = true;

				$fields[] = array(
					'key'       => $name,
					'type'      => 'repeater',
					'label'     => self::humanize( $name ),
					'subfields' => self::repeater_subfields( $lp[2], $field_defaults ),
				);
			}
		}

		// Top-level scalar placeholders (a key with no dot).
		if ( preg_match_all( '/\{\{([tciurs]):([a-zA-Z0-9_]+)\}\}/', $template, $ph, PREG_SET_ORDER ) ) {
			foreach ( $ph as $p ) {
				$key = $p[2];
				if ( isset( $seen[ $key ] ) ) {
					continue;
				}
				// An image's responsive data (photo_srcset, photo_w…) is computed by the engine from
				// the attachment: in the panel they would be four boxes not to touch next to the one
				// that matters. They stay valid placeholders, they simply do not become fields.
				$seen[ $key ] = true;
				if ( self::is_derived_media_key( $key, $template ) ) {
					continue;
				}

				$type    = self::kind_to_type( $p[1] );
				$default = isset( $values[ $key ] ) ? $values[ $key ] : '';
				if ( 'text' === $type && ( strlen( (string) $default ) > 60 || false !== strpos( (string) $default, "\n" ) ) ) {
					$type = 'textarea';
				}
				$fields[] = array( 'key' => $key, 'type' => $type, 'label' => self::humanize( $key ), 'default' => $default );
			}
		}

		// Switches: every top-level {{#if key}} becomes a boolean field, so the element it wraps can
		// be shown or hidden without touching the code.
		//
		// The default is "1" — VISIBLE. It is the only safe default: a condition with no value is
		// false, so starting from empty a freshly generated section would lose its conditional parts
		// without anyone having asked to hide them.
		if ( preg_match_all( '/\{\{#if\s+([a-zA-Z0-9_]+)\s*\}\}/', $template, $ifs, PREG_SET_ORDER ) ) {
			foreach ( $ifs as $m ) {
				$key = $m[1];
				if ( isset( $seen[ $key ] ) ) {
					continue;
				}
				$seen[ $key ] = true;
				$fields[]     = array(
					'key'     => $key,
					'type'    => 'bool',
					'label'   => self::humanize( $key ),
					'default' => isset( $values[ $key ] ) && '' !== $values[ $key ] ? $values[ $key ] : '1',
				);
			}
		}

		return array( 'fields' => $fields );
	}

	/**
	 * The subfields of a repeater: its placeholders, plus its own switches.
	 *
	 * Switches INSIDE the repeater: `{{#if field.featured}}` becomes a checkbox on every entry. It
	 * is what makes it possible to say WHICH entry is special while leaving the CSS to say what
	 * being special consists of — without it, the difference would have to be written as a colour in
	 * a field (style disguised as data) or as a rule on :nth-of-type (tied to the POSITION, which
	 * changes as soon as an entry is added or removed).
	 *
	 * Only for keys that are not already subfields: `{{#if field.photo}}` guarding an
	 * `{{i:field.photo}}` is interrogating that image, not a separate flag.
	 *
	 * Default OFF, unlike the top-level switches: those hide an optional element (and starting
	 * hidden would lose parts), this one marks an exception, and an exception switched on for every
	 * entry is no longer an exception.
	 */
	protected static function repeater_subfields( $body, $field_defaults ) {
		$subs = array();
		$seen = array();

		if ( preg_match_all( '/\{\{([tciurs]):field\.([a-zA-Z0-9_]+)\}\}/', $body, $sm, PREG_SET_ORDER ) ) {
			foreach ( $sm as $s ) {
				if ( isset( $seen[ $s[2] ] ) ) {
					continue;
				}
				$seen[ $s[2] ] = true;
				// An entry's responsive data (field.photo_srcset, field.photo_w…) is computed by the
				// engine from the attachment, exactly as at the top level — see
				// AIPB_Template_Engine::resolve(), «the same courtesy inside loops». The filter was
				// missing HERE and only here, so the four boxes the panel is careful not to show
				// beside an image came back on EVERY entry of every list: four per slide, ten slides,
				// forty boxes nobody may touch around the one that matters. The DSL promises they
				// will not appear — «the engine computes them from the chosen image, and they do not
				// become fields in the panel» (prompts/dsl-core.txt) — and a promise kept in one
				// place and broken in the other is worse than one never made.
				if ( self::is_derived_media_key( $s[2], $body, 'field.' ) ) {
					continue;
				}
				$subs[]        = array(
					'key'     => $s[2],
					'type'    => self::kind_to_type( $s[1] ),
					'label'   => self::humanize( $s[2] ),
					'default' => isset( $field_defaults[ $s[2] ] ) ? $field_defaults[ $s[2] ] : '',
				);
			}
		}

		if ( preg_match_all( '/\{\{#if\s+field\.([a-zA-Z0-9_]+)\s*\}\}/', $body, $ifs, PREG_SET_ORDER ) ) {
			foreach ( $ifs as $fi ) {
				if ( isset( $seen[ $fi[1] ] ) ) {
					continue;
				}
				$seen[ $fi[1] ] = true;
				$subs[]         = array(
					'key'     => $fi[1],
					'type'    => 'bool',
					'label'   => self::humanize( $fi[1] ),
					'default' => '',
				);
			}
		}

		return $subs;
	}

	/**
	 * Is the key a responsive datum derived from the image, rather than a field to show?
	 *
	 * Suspicion is not enough: `photo_w` is only excluded if the same template really has an
	 * `{{i:photo}}` to derive it from (see AIPB_Template_Engine::media_companion). Otherwise a key
	 * ending in _w — `logo_w`, `banner_h` — would vanish from the panel for no reason.
	 *
	 * `_alt` is deliberately left out of the list: it is the alternative text, and whoever knows the
	 * page writes it better than the Media Library does. That one has to stay a field.
	 *
	 * `$prefix` is `field.` when the question is asked about a repeater's entry, where the image the
	 * datum comes from is written `{{i:field.photo}}`. Without it the search would look for
	 * `{{i:photo}}`, never find it, and keep every companion as a field — which is exactly what used
	 * to happen inside loops.
	 */
	protected static function is_derived_media_key( $key, $template, $prefix = '' ) {
		if ( ! preg_match( '/^(.+)_(srcset|sizes|w|h)$/', $key, $m ) ) {
			return false;
		}
		return (bool) preg_match( '/\{\{i:' . preg_quote( $prefix . $m[1], '/' ) . '[|}]/', $template );
	}

	/** Rebuilds the readable code (real contents inline) for refinement. */
	protected static function inline_form( $template, $values ) {
		$out = preg_replace_callback(
			'/\{\{([tciurs]):([a-zA-Z0-9_]+)\}\}/',
			function ( $m ) use ( $values ) {
				$k = $m[2];
				if ( isset( $values[ $k ] ) && ! is_array( $values[ $k ] ) && '' !== (string) $values[ $k ] ) {
					return '{{' . $m[1] . ':' . $k . '|' . $values[ $k ] . '}}';
				}
				return $m[0];
			},
			$template
		);

		$rep = array();
		foreach ( $values as $k => $v ) {
			if ( is_array( $v ) ) {
				$rep[ $k ] = $v;
			}
		}
		if ( ! empty( $rep ) ) {
			$out .= "\n<!--AIPB-VALUES-->\n" . wp_json_encode( $rep );
		}
		return $out;
	}

	/** Extracts the value of a key="value" attribute from a loop's attribute string. */
	protected static function attr( $str, $key ) {
		return preg_match( '/\b' . preg_quote( $key, '/' ) . '\s*=\s*"([^"]*)"/i', $str, $m ) ? $m[1] : '';
	}

	protected static function kind_to_type( $k ) {
		$map = array( 't' => 'text', 'c' => 'color', 'i' => 'image', 'u' => 'url', 'r' => 'richtext', 's' => 'shortcode' );
		return isset( $map[ $k ] ) ? $map[ $k ] : 'text';
	}

	protected static function humanize( $key ) {
		return ucfirst( str_replace( array( '_', '-' ), ' ', $key ) );
	}

	/** Fallback: the model answered in JSON (the old format) instead of raw code. */
	protected static function parse_json_fallback( $raw ) {
		$parsed = json_decode( self::strip_fences( $raw ), true );
		if ( ! is_array( $parsed ) ) {
			return new WP_Error( 'aipb_ai', __( 'The AI answer could not be interpreted.', 'aipb' ) );
		}

		if ( empty( $parsed['template'] ) && isset( $parsed['blocks'][0]['template'] ) ) {
			$parsed = $parsed['blocks'][0];
		}
		if ( empty( $parsed['template'] ) ) {
			return new WP_Error( 'aipb_ai', __( 'Invalid section.', 'aipb' ) );
		}
		return self::normalize_json_section( $parsed );
	}

	protected static function normalize_json_section( $s ) {
		return array(
			'template' => (string) $s['template'],
			'schema'   => isset( $s['schema'] ) && is_array( $s['schema'] ) ? $s['schema'] : array( 'fields' => array() ),
			'values'   => isset( $s['values'] ) && is_array( $s['values'] ) ? $s['values'] : array(),
		);
	}
}
