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

/**
 * The builder's REST endpoints (namespace aipb/v1).
 *  POST   /generate         -> calls the AI (mode 'section' | 'patch' | 'global' | 'plan')
 *  POST   /generate/stream  -> the same generation, delivered as it is produced
 *  POST   /preview          -> renders a section { template, values } OR a stack { blocks:[…] }
 *  GET    /sections         -> list of the reusable AI Sections (CPT ai_section)
 *  POST   /sections         -> creates an empty content (whitelisted post type); returns the payload
 *  POST   /component        -> creates a reusable component (ai_section) from a block
 *  GET    /section/{id}     -> loads a content (a single section or a block page)
 *  POST   /save/{id}        -> saves: blocks for pages, template/schema/values for components
 *  DELETE /section/{id}     -> trashes the content
 *  POST   /font-validate    -> checks a Google font by name (Design Tokens)
 *  POST   /tokens/extract   -> measures the design tokens on a mockup's screenshots (Design Tokens)
 *  POST   /model/propose    -> proposes types, taxonomies and fields from the brief (Content model)
 *  POST   /model/apply      -> creates the requested structure: types, fields, taxonomies, terms
 *  POST   /model/contents   -> inserts the initial contents of a freshly created type
 *  POST   /model/menu       -> puts a type's archive into the site's menu
 *  GET    /usage            -> how much has been spent (locally + the service's accounting)
 */
class AIPB_REST {

	const NS = 'aipb/v1';

	public static function init() {
		add_action( 'rest_api_init', array( __CLASS__, 'routes' ) );
	}

	/**
	 * The route table.
	 *
	 * One row per endpoint, grouped by path: the paths that answer to more than one verb
	 * (/sections, /section/{id}) are registered in a single call, as WordPress expects.
	 *
	 * The permission callback is part of the row on purpose. When it lived inside the handlers it
	 * was one line among fifty, and the difference between "who can build" and "who can change the
	 * site's structure" is exactly the kind of thing that has to be readable at a glance.
	 */
	protected static function route_table() {
		$build  = array( __CLASS__, 'can_edit' );
		$owner  = array( __CLASS__, 'can_edit_post' );
		$admin  = static function () {
			return current_user_can( 'manage_options' );
		};
		$theme  = static function () {
			return current_user_can( 'edit_theme_options' );
		};
		$upload = static function () {
			return current_user_can( 'upload_files' );
		};

		return array(
			array( '/generate', 'POST', 'generate', $build ),

			// The same generation, delivered as it is produced. A separate route and not a
			// parameter of /generate: the answer has a different shape (a stream of events, not
			// JSON), and routes that answer in two different ways are the ones that break.
			array( '/generate/stream', 'POST', 'generate_stream', $build ),

			array( '/preview', 'POST', 'preview', $build ),

			array( '/sections', 'GET', 'list_sections', $build ),
			array( '/sections', 'POST', 'create_section', $build ),
			array( '/component', 'POST', 'create_component', $build ),

			array( '/section/(?P<id>\d+)', 'GET', 'get_section', $owner ),
			array( '/section/(?P<id>\d+)', 'DELETE', 'delete_section', $owner ),
			array( '/save/(?P<id>\d+)', 'POST', 'save', $owner ),

			// History: the content's revisions, and restoring one version.
			array( '/revisions/(?P<id>\d+)', 'GET', 'list_revisions', $owner ),
			array( '/restore/(?P<id>\d+)', 'POST', 'restore_revision', $owner ),

			// Moving sections from one site to another. Two routes and not one: exporting is a
			// read, importing is a proposal to write, and mixing them would make it impossible to
			// give each the right permission.
			array( '/export', 'POST', 'export', $build ),
			array( '/import', 'POST', 'import', $build ),

			// How much has been spent. It costs no token and consumes no quota: it is a read.
			array( '/usage', 'GET', 'usage', $build ),

			// The images missing from a type's contents. It costs no token: it looks at the Media
			// Library and then at openly licensed stock.
			array( '/images/fill', 'POST', 'images_fill', $upload ),

			// The site's art direction, when there was none until now: see direction().
			array( '/direction', 'POST', 'direction', $admin ),
			// A page plan's design notes, boiled down to something that holds site-wide, BEFORE
			// anybody is asked whether to keep it: see direction_distill().
			array( '/direction/distill', 'POST', 'direction_distill', $admin ),
			// A page on the web, opened by the service's browser and brought back as sections that
			// can be rebuilt: see capture().
			array( '/capture', 'POST', 'capture', $admin ),
			// The scale a plan measured off a reference, adopted site-wide: see adopt_tokens().
			array( '/tokens/adopt', 'POST', 'adopt_tokens', $admin ),

			// The check before publishing: no AI, no cost, no saving.
			array( '/check', 'POST', 'check', $build ),

			// Tidying a section's code: no AI, no saving — see tidy().
			array( '/tidy', 'POST', 'tidy', $build ),

			// Checking/installing a Google font by name (Design Tokens page).
			array( '/font-validate', 'POST', 'font_validate', $admin ),

			// Reading the design tokens off a mockup's screenshots (Design Tokens page).
			array( '/tokens/extract', 'POST', 'tokens_extract', $admin ),
			array( '/tokens/from-url', 'POST', 'tokens_from_url', $admin ),

			// The content model proposed from the brief (Content model page).
			array( '/model/propose', 'POST', 'model_propose', $admin ),

			// The content types requested while building: first the structure, then the contents.
			//
			// Two routes and not one because the Studio narrates them one at a time: the structure
			// is created in an instant, the contents are N insertions, and whoever has just written
			// a request deserves to see where it has got to instead of a mute wait followed by a
			// summary.
			//
			// They ask for manage_options like /model/propose and not for permission to build:
			// registering a type changes the site's STRUCTURE — it appears in WordPress's menu and
			// in the permalinks — and it is not the same decision as writing a section.
			array( '/model/apply', 'POST', 'model_apply', $admin ),
			array( '/model/contents', 'POST', 'model_contents', $admin ),
			array( '/model/menu', 'POST', 'model_menu', $theme ),

			// A type's templates: which ones are needed (GET-like) and creating one with its
			// content inside.
			array( '/model/templates', 'POST', 'model_templates', $theme ),
		);
	}

	public static function routes() {
		$grouped = array();

		foreach ( self::route_table() as $row ) {
			list( $path, $methods, $callback, $permission ) = $row;

			$grouped[ $path ][] = array(
				'methods'             => $methods,
				'permission_callback' => $permission,
				'callback'            => array( __CLASS__, $callback ),
			);
		}

		foreach ( $grouped as $path => $endpoints ) {
			register_rest_route( self::NS, $path, $endpoints );
		}
	}

	public static function can_edit() {
		return AIPB_Config::user_can_build();
	}

	public static function can_edit_post( $request ) {
		return AIPB_Config::user_can_build() && current_user_can( 'edit_post', (int) $request['id'] );
	}

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

	/**
	 * Which templates a content type needs, or creates one with its content.
	 *
	 * Two jobs in one route because they are two halves of the same question and travel together:
	 * without `slug` it answers «these are needed», with `slug` and `blocks` it creates that one.
	 * Separating them would have meant two routes that make no sense without each other.
	 *
	 * `edit_theme_options` as for the menu: a template is the site's appearance, not administration.
	 */
	public static function model_templates( $request ) {
		$post_type = sanitize_key( (string) $request->get_param( 'post_type' ) );
		if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
			return new WP_Error( 'aipb_unknown_type', __( 'Unknown content type.', 'aipb' ), array( 'status' => 400 ) );
		}

		$slug = sanitize_title( (string) $request->get_param( 'slug' ) );
		if ( '' === $slug ) {
			$out = array();
			foreach ( AIPB_Templates::wanted( $post_type ) as $s => $task ) {
				$out[] = array( 'slug' => $s, 'task' => $task, 'exists' => (bool) AIPB_Templates::find( $s ) );
			}

			return rest_ensure_response( array( 'templates' => $out ) );
		}

		return rest_ensure_response( AIPB_Templates::create(
			$slug,
			sanitize_text_field( (string) $request->get_param( 'title' ) ),
			self::normalize_blocks( $request->get_param( 'blocks' ) )
		) );
	}

	/**
	 * Puts a content type's archive into the site's menu.
	 *
	 * It asks for `edit_theme_options` and not `manage_options`: that is the capability WordPress
	 * protects menus with, and it is the one held by whoever looks after the site's appearance
	 * without administering it. Asking for more here would mean denying an editor something the
	 * admin lets them do by hand ten seconds later.
	 */
	public static function model_menu( $request ) {
		$post_type = sanitize_key( (string) $request->get_param( 'post_type' ) );
		if ( '' === $post_type ) {
			return new WP_Error( 'aipb_menu_no_type', __( 'A content type is required.', 'aipb' ), array( 'status' => 400 ) );
		}

		return rest_ensure_response( AIPB_Model_Apply::menu( $post_type ) );
	}

	/**
	 * The initial contents of a freshly created type.
	 *
	 * Separate from /model/apply because it is the LONG step — N insertions instead of two options —
	 * and separating it is what lets the Studio say «creating the contents…» while it happens,
	 * instead of once it is done.
	 */
	public static function model_contents( $request ) {
		$post_type = sanitize_key( (string) $request->get_param( 'post_type' ) );
		$items     = $request->get_param( 'items' );
		$items     = is_array( $items ) ? $items : array();

		if ( '' === $post_type || ! $items ) {
			return new WP_Error(
				'aipb_no_contents',
				__( 'A content type and at least one content to insert are required.', 'aipb' ),
				array( 'status' => 400 )
			);
		}

		return rest_ensure_response( AIPB_Model_Apply::contents( $post_type, $items ) );
	}

	/**
	 * Creates the content types the plan asked for and inserts their initial contents.
	 *
	 * It receives the proposal the plan already produced — and that AIPB_AI_Provider already cleaned
	 * — instead of regenerating it: between the plan and the button there is a screen where the user
	 * chose which types to keep, and regenerating would mean creating something different from what
	 * they saw. What arrives is revalidated in AIPB_Model_Apply anyway: this route is open on the
	 * network, and a proposal that has been round trip through the browser is no longer our proposal.
	 */
	public static function model_apply( $request ) {
		$model = $request->get_param( 'model' );
		$model = is_array( $model ) ? $model : array();

		$has_types      = ! empty( $model['types'] ) && is_array( $model['types'] );
		$has_taxonomies = ! empty( $model['taxonomies'] ) && is_array( $model['taxonomies'] );
		if ( ! $has_types && ! $has_taxonomies ) {
			return new WP_Error(
				'aipb_empty_model',
				__( 'No content type to create.', 'aipb' ),
				array( 'status' => 400 )
			);
		}

		return rest_ensure_response( AIPB_Model_Apply::apply( $model ) );
	}

	/**
	 * Proposes content types, taxonomies and fields starting from the site's description.
	 *
	 * As with the design tokens, it saves NOTHING: the proposal goes back to the form, where it is
	 * read, corrected and saved by hand. A slug ends up in the URL of every content and a key is the
	 * name of a meta: they are decisions that stay, and whoever takes them has to have seen them.
	 */
	public static function model_propose( $request ) {
		$brief = sanitize_textarea_field( (string) $request->get_param( 'brief' ) );
		if ( mb_strlen( trim( $brief ) ) < 20 ) {
			return new WP_Error(
				'aipb_short_brief',
				__( 'Describe the site in a few lines: two words are not enough to tell which contents are needed.', 'aipb' ),
				array( 'status' => 400 )
			);
		}

		self::no_time_limit();

		return rest_ensure_response(
			AIPB_AI_Provider::generate( $brief, array(), 'model', array(), 'balanced' )
		);
	}

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

	/**
	 * Measures the design tokens on a mockup's screenshots.
	 *
	 * It saves NOTHING: it returns the values, which the Design Tokens page pours into the form so
	 * that the user reviews them and presses Save. Overwriting a design system without going through
	 * a human review would be an excellent way of ruining a site in one click.
	 */
	public static function tokens_extract( $request ) {
		$images = self::read_images( $request );
		if ( empty( $images ) ) {
			return new WP_Error( 'aipb_no_images', __( 'At least one screenshot of the mockup is required.', 'aipb' ), array( 'status' => 400 ) );
		}

		self::no_time_limit();

		return rest_ensure_response( AIPB_AI_Provider::generate(
			sanitize_textarea_field( (string) $request->get_param( 'notes' ) ),
			array( 'images' => count( $images ) ),
			'tokens',
			$images,
			'faithful' // here it measures, it does not interpret
		) );
	}

	/**
	 * Measures a design system off a LIVE PAGE instead of a picture of one.
	 *
	 * WHY IT IS BETTER THAN A SCREENSHOT, and it is not a small margin. `/v1/capture` opens the
	 * address in a real Chromium and brings back both the full-page image AND the styles the cascade
	 * actually RESOLVED on every element. A screenshot makes the model measure a type scale by
	 * counting pixels in a JPEG; here the sizes, the radii and the shadows come back as the numbers
	 * the browser computed. The picture still goes with them, because a number does not say which of
	 * three sizes is the heading.
	 *
	 * It costs one generation, like the mockup import, and the capture itself costs nothing — no
	 * model runs inside it.
	 */
	public static function tokens_from_url( $request ) {
		$url = esc_url_raw( trim( (string) $request->get_param( 'url' ) ) );
		if ( '' === $url || ! wp_http_validate_url( $url ) ) {
			return new WP_Error( 'aipb_bad_url', __( 'That does not look like a public web address.', 'aipb' ), array( 'status' => 400 ) );
		}

		self::no_time_limit();

		$capture = AIPB_Remote::capture( $url, 1440, 8 );
		if ( is_wp_error( $capture ) ) {
			return $capture;
		}

		$images = array();
		if ( ! empty( $capture['screenshot'] ) ) {
			$images[] = array(
				'mime' => 'image/jpeg',
				'data' => preg_replace( '/[^A-Za-z0-9+\/=]/', '', (string) $capture['screenshot'] ),
			);
		}

		$notes  = sanitize_textarea_field( (string) $request->get_param( 'notes' ) );
		$digest = self::capture_digest( $capture );

		// The measured evidence goes in as NOTES, where the prompt already expects the user's own
		// remarks: it is the same kind of thing — «here is what I know about this design» — and it
		// needs no new field on the service.
		$prompt = trim( $notes . "\n\n" . $digest );

		$answer = AIPB_AI_Provider::generate(
			$prompt,
			array( 'images' => count( $images ) ),
			'tokens',
			$images,
			'faithful' // here it measures, it does not interpret
		);

		if ( is_array( $answer ) ) {
			$answer = self::fonts_from_capture( $answer, $capture );
		}

		return rest_ensure_response( $answer );
	}

	/**
	 * The fonts come from the BROWSER, not from the model.
	 *
	 * WHY THIS EXISTS. Asking a model to name a typeface from a picture is asking it to do the one
	 * thing it is worst at, and here the picture is the worst possible one: `/v1/capture` returns a
	 * FULL-PAGE screenshot, so a ten-screen landing page arrives as one very tall image whose
	 * letterforms survive no scaling. Meanwhile the capture already carries `fonts` — the families
	 * the browser actually PAINTED, read off `getComputedStyle`. That is not an inference, it is a
	 * measurement, and it was being thrown away.
	 *
	 * There is a second reason the model's answer came back empty even when it read the name right:
	 * {@see AIPB_AI_Provider::import_fonts()} drops any family outside the curated catalogue. That
	 * is correct there — a hallucinated family cannot be loaded — but a family measured off a real
	 * page is not a guess, so here it is checked against Google itself, which knows about thousands
	 * more than the catalogue lists.
	 *
	 * The model still names them: «Headings», «Body» is a judgement about ROLE, which it makes well
	 * from the layout. Only when it named none does the position decide.
	 *
	 * @param array $answer  the interpreted answer, with whatever fonts the model returned
	 * @param array $capture the capture, with the families the page painted
	 */
	protected static function fonts_from_capture( $answer, $capture ) {
		$measured = array();
		foreach ( (array) ( isset( $capture['fonts'] ) ? $capture['fonts'] : array() ) as $raw ) {
			$name = is_array( $raw ) && isset( $raw['family'] ) ? $raw['family'] : $raw;
			if ( ! is_string( $name ) ) {
				continue;
			}

			// The system stacks a page falls back to are not fonts anybody can install: `-apple-system`,
			// `BlinkMacSystemFont`, `ui-sans-serif`, and the generic families themselves.
			$name = trim( $name );
			if ( '' === $name || '-' === $name[0] || in_array( strtolower( $name ), array( 'inherit', 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'blinkmacsystemfont', 'segoe ui' ), true ) ) {
				continue;
			}

			$probe = AIPB_Design::probe_google_font( $name );
			if ( empty( $probe['ok'] ) ) {
				continue;
			}

			$measured[ $probe['family'] ] = $probe;
			if ( count( $measured ) >= 3 ) {
				break; // three families is already one more than most designs use
			}
		}

		if ( ! $measured ) {
			return $answer;
		}

		$fonts  = ( isset( $answer['fonts'] ) && is_array( $answer['fonts'] ) ) ? $answer['fonts'] : array();
		$named  = array();
		foreach ( $fonts as $font ) {
			if ( isset( $font['family'] ) ) {
				$named[ $font['family'] ] = isset( $font['name'] ) ? (string) $font['name'] : '';
			}
		}

		// The order the capture returns is the order the page paints in — headings before body text,
		// because that is the order the elements were queried in. It is the best guess available
		// when the model offered no names of its own.
		$roles = array( __( 'Headings', 'aipb' ), __( 'Body', 'aipb' ), __( 'Accents', 'aipb' ) );
		$out   = array();
		$i     = 0;

		foreach ( $measured as $family => $probe ) {
			$out[] = array(
				'slug'     => '',
				'name'     => isset( $named[ $family ] ) && '' !== $named[ $family ] ? $named[ $family ] : $roles[ min( $i, 2 ) ],
				'family'   => $family,
				'category' => $probe['category'],
				'weights'  => $probe['weights'],
			);
			$i++;
		}

		// A family the model saw and the browser did not is worth keeping: it may be a heading face
		// used once, below the fold, on an element the capture did not sample.
		foreach ( $fonts as $font ) {
			if ( isset( $font['family'] ) && ! isset( $measured[ $font['family'] ] ) ) {
				$out[] = $font;
			}
		}

		$answer['fonts'] = $out;

		return $answer;
	}

	/**
	 * The capture, boiled down to what a design system is measured from.
	 *
	 * The whole thing is far too big to hand to a model — a dozen sections of markup and resolved
	 * CSS — and most of it is about layout, not about the scale. What is kept is the CSS, capped,
	 * plus the fonts the page really loaded, which are the one thing a screenshot can never give
	 * with certainty.
	 */
	protected static function capture_digest( $capture ) {
		$lines = array( __( 'Values measured on the live page (resolved by the browser, not read off the image):', 'aipb' ) );

		if ( ! empty( $capture['title'] ) ) {
			$lines[] = 'PAGE: ' . sanitize_text_field( (string) $capture['title'] );
		}

		if ( ! empty( $capture['fonts'] ) && is_array( $capture['fonts'] ) ) {
			$families = array();
			foreach ( $capture['fonts'] as $font ) {
				$name = is_array( $font ) && isset( $font['family'] ) ? $font['family'] : $font;
				if ( is_string( $name ) && '' !== trim( $name ) ) {
					$families[] = sanitize_text_field( $name );
				}
			}
			if ( $families ) {
				$lines[] = 'FONT FAMILIES THE BROWSER PAINTED (exact names, do not guess them from the image; '
					. 'give each one a name describing its ROLE): ' . implode( ', ', array_slice( array_unique( $families ), 0, 12 ) );
			}
		}

		$budget = 24000; // characters of CSS: plenty for a scale, far short of a stylesheet
		$css    = array();

		foreach ( (array) ( isset( $capture['sections'] ) ? $capture['sections'] : array() ) as $section ) {
			if ( empty( $section['css'] ) || $budget <= 0 ) {
				continue;
			}

			$chunk   = substr( (string) $section['css'], 0, $budget );
			$budget -= strlen( $chunk );
			$css[]   = '/* ' . sanitize_text_field( isset( $section['name'] ) ? (string) $section['name'] : '' ) . " */\n" . $chunk;
		}

		if ( $css ) {
			$lines[] = "RESOLVED CSS:\n" . implode( "\n", $css );
		}

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

	/** Checks a Google font by name and returns its category + available weights. */
	public static function font_validate( $request ) {
		return rest_ensure_response( AIPB_Design::probe_google_font( (string) $request->get_param( 'family' ) ) );
	}

	/**
	 * Saves the site's art direction, but ONLY if there was not one already.
	 *
	 * WHY IT EXISTS. `direction()` is empty almost always — writing an art direction brief is a task
	 * nobody does spontaneously — and without it every generation receives a palette, some fonts and
	 * nothing else about the site's CHARACTER: the model falls back on its own default, which is
	 * everybody's default. Meanwhile the plan of a whole page most certainly does decide a
	 * character, and writes it in the "design" field. That prose is already an art direction:
	 * throwing it away at the end of a generation, and starting from scratch on the next page, is
	 * the reason why the second site resembles the first only by accident.
	 *
	 * IT NEVER OVERWRITES. If a direction is there, a person wrote it, and it is worth more than
	 * anything a model can deduce: the answer is «there was one already» and nothing is touched.
	 */
	public static function direction( $request ) {
		if ( '' !== trim( AIPB_Design::direction() ) ) {
			return rest_ensure_response( array( 'ok' => false, 'reason' => 'already-there' ) );
		}

		$text = AIPB_Design::sanitize_direction( (string) $request->get_param( 'direction' ) );
		if ( '' === trim( $text ) ) {
			return new WP_Error( 'aipb_empty_direction', __( 'No direction to save.', 'aipb' ), array( 'status' => 400 ) );
		}

		update_option( AIPB_Design::OPT_DIRECTION, $text );

		return rest_ensure_response( array( 'ok' => true, 'direction' => $text ) );
	}

	/**
	 * Turns a page plan's design notes into a brief that can hold for the WHOLE site.
	 *
	 * WHY IT IS NOT JUST direction() WITH THE RAW TEXT. It was, and the result is on the record: a
	 * plan for one page wrote «a full-viewport saturated safety-orange field with a colossal black
	 * wordmark behind a cutout of a hyper-car», that prose was accepted as the site's art direction,
	 * and from then on EVERY generated section received it under a heading that says «binding — apply
	 * it to EVERY section» and «prevails over the style defaults». A section asked only to line up
	 * with its neighbours came out orange on a site whose palette contains no orange. The notes were
	 * not wrong; they were a description of one page, promoted to a rule for all of them.
	 *
	 * So the notes are distilled first — the character kept, the hero and the hexes dropped — and
	 * what is offered is what will actually be saved. It costs one call, which is the price of not
	 * having a page colonise a site.
	 *
	 * The write goes through the same door as direction(): if a direction is already there, a person
	 * put it there and nothing is touched.
	 */
	public static function direction_distill( $request ) {
		$notes = sanitize_textarea_field( (string) $request->get_param( 'notes' ) );
		if ( '' === trim( $notes ) ) {
			return new WP_Error( 'aipb_no_notes', __( 'No design notes to distil.', 'aipb' ), array( 'status' => 400 ) );
		}

		self::no_time_limit();

		$result = AIPB_AI_Provider::generate( $notes, array(), 'direction', array(), 'balanced' );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$text = isset( $result['direction'] ) ? (string) $result['direction'] : '';

		// Nothing survived the subtraction: those notes were a layout, not a character. Saying so is
		// better than saving a brief that describes one page.
		if ( '' === trim( $text ) ) {
			return rest_ensure_response( array( 'ok' => false, 'reason' => 'nothing-to-keep', 'direction' => '' ) );
		}

		return rest_ensure_response( array( 'ok' => true, 'direction' => $text ) );
	}

	/**
	 * Adopts the scale a plan MEASURED off a reference as the site's own structural tokens.
	 *
	 * Same reasoning as direction() and the same manners. The planner measures a mockup — section
	 * padding, content width, type sizes, radii, shadows — and until now those numbers reached the
	 * prompts of that one page and then vanished: the next page measured nothing, and a section added
	 * later matched neither. Written into the design system they hold for everything that comes after,
	 * and they become editable in a place the user can find.
	 *
	 * IT NEVER OVERWRITES A DECISION. A token the user has already set keeps its value, one key at a
	 * time rather than all-or-nothing: adopting a mockup's shadows must not silently undo the content
	 * width somebody typed by hand. Values that are merely the plugin's shipped default are not
	 * decisions and do give way.
	 *
	 * IT IS ADOPTED WITHOUT ASKING, and `revert` is the price of that. Because of the rule above the
	 * adoption can only fill empty slots, so «yes» had no downside to weigh and «no» left the site
	 * with no scale at all — a question whose answer is known is not a question, it is an obstacle.
	 * The Studio now adopts and says so, and the way back has to be one click, not a trip to a
	 * settings page. See autoTokenize() in assets/studio.js: same reasoning, same manners.
	 */
	public static function adopt_tokens( $request ) {
		$revert = $request->get_param( 'revert' );
		if ( is_array( $revert ) && $revert ) {
			return self::revert_tokens( $revert );
		}

		$incoming = $request->get_param( 'tokens' );
		if ( ! is_array( $incoming ) || ! $incoming ) {
			return new WP_Error( 'aipb_no_tokens', __( 'No tokens to adopt.', 'aipb' ), array( 'status' => 400 ) );
		}

		$known    = AIPB_Design::flat_defaults();
		$existing = get_option( AIPB_Design::OPT_TOKENS, array() );
		$existing = is_array( $existing ) ? $existing : array();

		$saved = array();
		$kept  = array();

		foreach ( $incoming as $id => $value ) {
			$id = sanitize_text_field( (string) $id );
			if ( ! isset( $known[ $id ] ) ) {
				continue; // not a token of ours: the shape of the design system is not negotiable
			}
			if ( isset( $existing[ $id ] ) && '' !== $existing[ $id ] ) {
				$kept[] = $id; // somebody decided this one: it stands
				continue;
			}
			$clean = AIPB_Design::with_unit( AIPB_Design::sanitize_value( $value ), $id );
			if ( '' === $clean ) {
				continue;
			}
			$existing[ $id ] = $clean;
			$saved[]         = $id;
		}

		if ( $saved ) {
			update_option( AIPB_Design::OPT_TOKENS, $existing );
		}

		// The values as they were WRITTEN, not as they arrived: sanitise_value() and with_unit()
		// may have changed them, and an undo that compares against the raw input would find
		// nothing to undo.
		$written = array();
		foreach ( $saved as $id ) {
			$written[ $id ] = $existing[ $id ];
		}

		return rest_ensure_response( array(
			'ok'      => (bool) $saved,
			'saved'   => $saved,
			'kept'    => $kept,
			'applied' => count( $saved ),
			'values'  => $written,
		) );
	}

	/**
	 * Undoes an adoption: empties the tokens it had filled.
	 *
	 * IT CLEARS ONLY WHAT IT IS SHOWN, VALUE INCLUDED. The payload is the id => value map that
	 * adopt_tokens reported writing, and a key whose stored value no longer matches is left alone.
	 * Without that check this route would be a way to blank any token by naming it — including one
	 * somebody typed by hand between the adoption and the undo, which is precisely the decision the
	 * adoption itself refuses to touch.
	 *
	 * @param array<string,mixed> $revert id => the value that was written
	 */
	private static function revert_tokens( $revert ) {
		$known    = AIPB_Design::flat_defaults();
		$existing = get_option( AIPB_Design::OPT_TOKENS, array() );
		$existing = is_array( $existing ) ? $existing : array();

		$cleared = array();
		foreach ( $revert as $id => $value ) {
			$id = sanitize_text_field( (string) $id );
			if ( ! isset( $known[ $id ] ) || ! isset( $existing[ $id ] ) ) {
				continue;
			}
			if ( (string) $existing[ $id ] !== (string) AIPB_Design::with_unit( AIPB_Design::sanitize_value( $value ), $id ) ) {
				continue; // changed since: it is somebody's decision now, not our adoption
			}
			unset( $existing[ $id ] );
			$cleared[] = $id;
		}

		if ( $cleared ) {
			update_option( AIPB_Design::OPT_TOKENS, $existing );
		}

		return rest_ensure_response( array(
			'ok'      => (bool) $cleared,
			'cleared' => $cleared,
			'applied' => count( $cleared ),
		) );
	}

	/**
	 * Opens a page on the web and brings it back as sections to rebuild from.
	 *
	 * The real work — and the real risk — is on the service, which owns the browser and the checks
	 * on where that browser may be pointed. What this adds is the first refusal, for the two cases
	 * worth refusing before a request leaves the site at all: an address that is not http(s), and
	 * one that is plainly on this machine or this network. It saves a round trip, it says something
	 * accurate to whoever typed it, and it means a service that is one version behind on those
	 * checks is not the only thing standing between a pasted URL and the network the site sits in.
	 *
	 * It is deliberately NOT the whole guard: a name that resolves to a private address, and a
	 * public page that redirects to one, can only be caught where the DNS lookup and the browser
	 * are, which is the service.
	 */
	public static function capture( $request ) {
		$url = trim( (string) $request->get_param( 'url' ) );
		if ( '' === $url ) {
			return new WP_Error( 'aipb_no_url', __( 'No address to open.', 'aipb' ), array( 'status' => 400 ) );
		}

		$parts  = wp_parse_url( $url );
		$scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
		$host   = isset( $parts['host'] ) ? strtolower( trim( $parts['host'], '[]' ) ) : '';

		if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host ) {
			return new WP_Error(
				'aipb_bad_url',
				__( 'That does not look like a web address. Paste the full address of a page, starting with https://', 'aipb' ),
				array( 'status' => 400 )
			);
		}

		// The obvious private cases. WordPress already knows how to answer this question — it is
		// the same one it asks itself before letting a plugin fetch a URL — so we ask it rather
		// than write a second, worse copy of the ranges.
		$private = 'localhost' === $host
			|| preg_match( '/\.(local|internal|localhost|test|home\.arpa)$/', $host )
			|| ( function_exists( 'wp_http_validate_url' ) && ! wp_http_validate_url( $url ) );

		if ( $private ) {
			return new WP_Error(
				'aipb_private_url',
				__( 'That address is on a private network, so the service cannot reach it. Import a page that is published on the web.', 'aipb' ),
				array( 'status' => 400 )
			);
		}

		self::no_time_limit();

		$width = (int) $request->get_param( 'width' );
		$result = AIPB_Remote::capture( $url, $width > 0 ? $width : 1440, 12 );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response( $result );
	}

	/* -------------------------------- Generation ------------------------------ */

	public static function generate( $request ) {
		$args = self::generate_args( $request );
		if ( is_wp_error( $args ) ) {
			return $args;
		}

		return rest_ensure_response(
			AIPB_AI_Provider::generate( $args['prompt'], $args['context'], $args['mode'], $args['images'], $args['fidelity'], $args['reproduce'] )
		);
	}

	/**
	 * Like /generate, but the answer arrives as it is produced.
	 *
	 * The plugin acts as a BRIDGE: it consumes the service's stream and passes the pieces on to the
	 * browser, then interprets the complete text HERE — the parser derives the schema from the
	 * placeholders and is knowledge of the engine, so it cannot move into the browser just because
	 * that would be convenient. The final result travels in a `result` event with exactly the same
	 * shape as /generate's answer: the Studio runs the very same code as always.
	 *
	 * It does not go through rest_ensure_response: here we write onto the stream and exit. It is the
	 * only place in the plugin that does, and for a precise reason — an answer delivered in pieces
	 * cannot be a return value.
	 */
	public static function generate_stream( $request ) {
		$args = self::generate_args( $request );
		if ( is_wp_error( $args ) ) {
			return $args;
		}

		// If the service cannot speak in pieces, we answer on the same channel with a single block:
		// the Studio must not have two routes, and an old service must not break a new client.
		$streaming = AIPB_Remote::supports_streaming();

		self::open_stream();
		$emit = static function ( $event, $data ) {
			echo 'event: ' . $event . "\n";
			echo 'data: ' . wp_json_encode( $data ) . "\n\n";
			flush();
		};

		// The FIRST verdict, ours: can this site consume a stream at all (cURL, a service that
		// declares the capability)? It goes out before anything else, so the browser can open its
		// live view within the second instead of deducing it from a timer.
		$emit( 'meta', array( 'streaming' => $streaming ) );

		$answer = $streaming
			? AIPB_Remote::generate_stream(
				$args['prompt'],
				$args['context'],
				$args['mode'],
				$args['images'],
				$args['fidelity'],
				static function ( $text ) use ( $emit ) {
					$emit( 'delta', array( 'text' => $text ) );
				},
				// The SECOND verdict, the service's, and it can only arrive once the service has
				// been reached: it depends on the model provider behind it, and a provider that
				// answers in one block makes a live view impossible however well this end works.
				// Forwarded as it is, so the browser corrects what it is showing instead of waiting
				// for something that is not coming.
				static function ( $info ) use ( $emit ) {
					if ( is_array( $info ) && isset( $info['streaming'] ) && ! $info['streaming'] ) {
						$emit( 'meta', array( 'streaming' => false ) );
					}
				},
				$args['reproduce']
			)
			: AIPB_Remote::generate( $args['prompt'], $args['context'], $args['mode'], $args['images'], $args['fidelity'], $args['reproduce'] );

		$result = AIPB_AI_Provider::interpret( $answer, $args['context'], $args['mode'] );

		if ( is_wp_error( $result ) ) {
			$data = $result->get_error_data();
			$emit( 'error', array(
				'message' => $result->get_error_message(),
				'status'  => isset( $data['status'] ) ? (int) $data['status'] : 500,
			) );
			exit;
		}

		$emit( 'result', $result );
		exit;
	}

	/**
	 * Opens the streamed answer.
	 *
	 * WordPress's headers already say «application/json»: they are overridden here, because the body
	 * is a stream of events.
	 *
	 * The rest of this method is a list of the places a byte can get stuck between PHP and the
	 * browser, and every one of them fails the same way — the answer arrives whole, at the end, with
	 * no error anywhere. Streaming that silently is not streaming looks exactly like streaming that
	 * is broken, so each layer is turned off explicitly rather than hoped about:
	 *
	 *  - PHP's own output buffers, and any a theme or another plugin has opened;
	 *  - GZIP. This is the one that hid the longest. `flush()` does not push bytes to the browser
	 *    when a compressor sits in between: it pushes them INTO the compressor, which holds them
	 *    until it has a block worth emitting — which, for a stream of 40-byte events, is the end.
	 *    Both spellings have to go: PHP's zlib and Apache's mod_deflate;
	 *  - nginx's proxy buffer, via X-Accel-Buffering;
	 *  - a FIXED-SIZE buffer somewhere further along that will not pass anything on until it is
	 *    full. Nothing can switch that one off, so it gets fed: two kilobytes of SSE comment, which
	 *    the client ignores by specification, sent before anything that matters.
	 */
	protected static function open_stream() {
		self::no_time_limit();

		// Before any output: afterwards zlib has already decided what it is doing.
		@ini_set( 'zlib.output_compression', '0' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.PHP.IniSet
		@ini_set( 'output_buffering', '0' );        // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.PHP.IniSet
		@ini_set( 'implicit_flush', '1' );          // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.PHP.IniSet
		if ( function_exists( 'apache_setenv' ) ) {
			@apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
		}

		header( 'Content-Type: text/event-stream; charset=utf-8' );
		header( 'Cache-Control: no-cache, no-transform' );
		header( 'Connection: keep-alive' );
		header( 'X-Accel-Buffering: no' );
		nocache_headers();

		while ( ob_get_level() > 0 ) {
			ob_end_flush();
		}
		ob_implicit_flush( true );

		// The padding. A comment in SSE is a line opening with a colon and it is discarded by the
		// client, so this costs two kilobytes once and buys the certainty that whatever buffer sits
		// downstream is already over its threshold when the first real event goes through.
		echo ':' . str_repeat( ' ', 2048 ) . "\n\n";
		flush();
	}

	/**
	 * No timeout imposed by the PHP process on a generation: it is the call to the provider that
	 * decides the outcome (an answer, or an error). Pages are generated one section at a time (short
	 * calls) and the user has the STOP button on the client side anyway.
	 */
	protected static function no_time_limit() {
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
		}
	}

	/**
	 * The parameters of a generation request, cleaned up.
	 *
	 * One single place for both routes: if the synchronous route and the streaming one read the
	 * parameters each on their own, sooner or later they would generate different things for the
	 * same request — and it is the kind of difference nobody notices until it is in production.
	 *
	 * @return array{prompt:string,context:array,mode:string,images:array,fidelity:string}|WP_Error
	 */
	protected static function generate_args( $request ) {
		$prompt = sanitize_textarea_field( (string) $request->get_param( 'prompt' ) );
		if ( '' === $prompt ) {
			return new WP_Error( 'aipb_no_prompt', __( 'Missing prompt', 'aipb' ), array( 'status' => 400 ) );
		}

		self::no_time_limit();

		$req_mode = sanitize_key( (string) $request->get_param( 'mode' ) );
		$mode     = in_array( $req_mode, array( 'patch', 'global', 'plan' ), true ) ? $req_mode : 'section';
		$ctx      = $request->get_param( 'context' );
		$ctx      = is_array( $ctx ) ? $ctx : array();

		// Fidelity mode chosen in the chat: it governs the prompt nudge on the service's side.
		$fidelity = sanitize_key( (string) $request->get_param( 'fidelity' ) );
		if ( ! in_array( $fidelity, array( 'faithful', 'balanced', 'creative' ), true ) ) {
			$fidelity = 'balanced';
		}

		return array(
			'prompt'   => $prompt,
			'context'  => ( 'plan' === $mode ) ? self::plan_context( $ctx ) : self::section_context( $ctx, $request ),
			'mode'     => $mode,
			// Rebuilding an existing page rather than designing one. It inverts the design chapter
			// on the service — the measured values win over this site's tokens — so it is read
			// explicitly and never guessed from the fidelity mode, which says something else.
			'reproduce' => (bool) $request->get_param( 'reproduce' ),
			// Media references (base64): screenshots, videos and/or PDF documents. 6 at most.
			'images'   => self::read_images( $request ),
			'fidelity' => $fidelity,
		);
	}

	/** Planning context: only the existing blocks' names/summaries, to keep the tokens down. */
	protected static function plan_context( $ctx ) {
		$blocks = array();

		if ( isset( $ctx['blocks'] ) && is_array( $ctx['blocks'] ) ) {
			foreach ( $ctx['blocks'] as $b ) {
				$blocks[] = array(
					'name'    => isset( $b['name'] ) ? sanitize_text_field( (string) $b['name'] ) : __( 'Section', 'aipb' ),
					'summary' => isset( $b['summary'] ) ? sanitize_text_field( (string) $b['summary'] ) : '',
				);
			}
		}

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

	/** Section context: the code being worked on, plus what is being built. */
	protected static function section_context( $ctx, $request ) {
		$viewport = isset( $ctx['viewport'] ) ? sanitize_key( (string) $ctx['viewport'] ) : 'desktop';
		if ( ! in_array( $viewport, array( 'desktop', 'tablet', 'mobile' ), true ) ) {
			$viewport = 'desktop';
		}

		$out = array(
			'template' => isset( $ctx['template'] ) ? (string) $ctx['template'] : '',
			'schema'   => isset( $ctx['schema'] ) && is_array( $ctx['schema'] ) ? $ctx['schema'] : array( 'fields' => array() ),
			'values'   => isset( $ctx['values'] ) && is_array( $ctx['values'] ) ? $ctx['values'] : array(),
			'viewport' => $viewport,
			'target'   => self::editing_target( (int) $request->get_param( 'contentId' ) ),
		);

		// A page layer, not a section: the service joins the overlay chapter instead of the
		// section ones. Dropped here it would never reach the prompt, and the model would write
		// a layout band.
		if ( isset( $ctx['scope'] ) && 'page' === sanitize_key( (string) $ctx['scope'] ) ) {
			$out['scope'] = 'page';
		}
		if ( isset( $ctx['position'] ) && (int) $ctx['position'] > 0 ) {
			$out['position'] = (int) $ctx['position'];
		}

		return $out;
	}

	/**
	 * Visual references in base64 from the request: images, videos or PDFs, 6 at most.
	 * Shared by /generate and /tokens/extract, so the limit and the accepted types are one.
	 */
	protected static function read_images( $request ) {
		$out  = array();
		$imgs = $request->get_param( 'images' );
		if ( ! is_array( $imgs ) ) {
			return $out;
		}
		foreach ( $imgs as $im ) {
			if ( empty( $im['data'] ) ) {
				continue;
			}
			$mime = isset( $im['mime'] ) ? sanitize_text_field( (string) $im['mime'] ) : 'image/jpeg';
			if ( 0 !== strpos( $mime, 'image/' ) && 0 !== strpos( $mime, 'video/' ) && 'application/pdf' !== $mime ) {
				continue;
			}
			$out[] = array(
				'mime' => $mime,
				'data' => preg_replace( '/[^A-Za-z0-9+\/=]/', '', (string) $im['data'] ),
			);
			if ( count( $out ) >= 6 ) {
				break;
			}
		}
		return $out;
	}

	/* --------------------------- The preview's context ------------------------ */

	/**
	 * Chooses the content to show in place of "the one being looked at", while building in the
	 * Studio.
	 *
	 * - A PAGE or a post is being edited → the sample is the content itself.
	 * - An FSE TEMPLATE is being edited → its slug is read: `single-product` asks for a product,
	 *   `single` for a post, `page` for a page. The most recently published one is taken. ARCHIVES
	 *   (`category`, `tag-news`, `taxonomy-category`) ask for a TERM instead: without one, an archive
	 *   template would be built blind, with the category's title always empty in the preview and no
	 *   way of telling whether it works.
	 * - Everything else (template parts, patterns) → no sample.
	 */
	protected static function set_preview_sample( $content_id ) {
		if ( ! class_exists( 'AIPB_Template_Engine' ) ) {
			return;
		}
		AIPB_Template_Engine::set_sample_post( 0 );
		AIPB_Template_Engine::set_sample_term( 0 );
		if ( $content_id <= 0 ) {
			return;
		}

		$pt = get_post_type( $content_id );
		if ( ! $pt ) {
			return;
		}

		if ( 'wp_template' !== $pt ) {
			// A page, a post, a CPT: the current content is itself.
			if ( ! AIPB_Config::is_template_type( $pt ) && 'wp_block' !== $pt && AIPB_CPT::POST_TYPE !== $pt ) {
				AIPB_Template_Engine::set_sample_post( $content_id );
			}
			return;
		}

		$slug = (string) get_post_field( 'post_name', $content_id );

		$term = self::sample_term_for_template( $slug );
		if ( $term ) {
			AIPB_Template_Engine::set_sample_term( $term );
			return;
		}

		$type = self::type_from_template_slug( $slug );
		if ( '' === $type ) {
			return;
		}

		$sample = get_posts( array(
			'post_type'        => $type,
			'post_status'      => 'publish',
			'numberposts'      => 1,
			'orderby'          => 'date',
			'order'            => 'DESC',
			'fields'           => 'ids',
			'suppress_filters' => false,
		) );
		if ( $sample ) {
			AIPB_Template_Engine::set_sample_post( (int) $sample[0] );
		}
	}

	/** The content type an FSE single-item template draws, '' when the slug is not one. */
	protected static function type_from_template_slug( $slug ) {
		$type = '';

		if ( 0 === strpos( $slug, 'single-' ) ) {
			$type = substr( $slug, 7 );
		} elseif ( 'single' === $slug ) {
			$type = 'post';
		} elseif ( 'page' === $slug ) {
			$type = 'page';
		}

		return ( '' !== $type && post_type_exists( $type ) ) ? $type : '';
	}

	/**
	 * WHAT is being built, in one line to put at the top of the request.
	 *
	 * It cannot be deduced from a section's code alone: a hero with an <h1> is identical on a page,
	 * on a product page and on a category archive — but the placeholder that makes that title
	 * dynamic is different in each of the three cases (none, post.title, term.title). Without this
	 * line «make the title dynamic» is a request the model CANNOT answer well, and it used to get it
	 * wrong silently: it applied a change and left a fixed text. It also brings in the right chapter
	 * of the DSL, which is chosen by reading this very text.
	 *
	 * The sentence is written in the SAME LANGUAGE AS THE PROMPTS (Italian, see the service's
	 * `Aura\Prompt`): it is concatenated into the prompt the model reads, not shown to the user.
	 * Translating it would change what the model receives, with no golden test pinning the result.
	 *
	 * @return string empty when there is nothing useful to say (pages, parts, patterns).
	 */
	protected static function editing_target( $content_id ) {
		if ( $content_id <= 0 || 'wp_template' !== get_post_type( $content_id ) ) {
			return '';
		}
		$slug = (string) get_post_field( 'post_name', $content_id );
		if ( '' === $slug ) {
			return '';
		}

		$term = self::sample_term_for_template( $slug );
		if ( $term ) {
			$t     = get_term( $term );
			$tax   = ( $t instanceof WP_Term ) ? get_taxonomy( $t->taxonomy ) : null;
			$label = ( $tax && isset( $tax->labels->singular_name ) ) ? mb_strtolower( $tax->labels->singular_name ) : 'term';

			return sprintf(
				'You are building the FSE template `%s`: the ARCHIVE of a %s (the page listing the content of ONE term, e.g. «%s»). Here the dynamic context is term.* — post.* is EMPTY.',
				$slug,
				$label,
				( $t instanceof WP_Term ) ? $t->name : '…'
			);
		}

		$type = self::type_from_template_slug( $slug );
		if ( '' !== $type ) {
			$pt = get_post_type_object( $type );

			return sprintf(
				'You are building the FSE template `%s`: the page of ONE %s. Here the dynamic context is post.*.',
				$slug,
				( $pt && isset( $pt->labels->singular_name ) ) ? mb_strtolower( $pt->labels->singular_name ) : $type
			);
		}

		return sprintf( 'You are building the FSE template `%s`.', $slug );
	}

	/**
	 * The term to show while building an ARCHIVE template, 0 when the slug is not a taxonomy
	 * archive.
	 *
	 * It follows WordPress's template hierarchy: `category` and `category-desserts`, `tag` and
	 * `tag-news`, `taxonomy-<tax>` and `taxonomy-<tax>-<term>`. When the slug names a precise term
	 * that one is used — it is the archive the user has in mind; otherwise the first term with some
	 * content is taken, which is the most honest way of showing how it will look. `archive` and
	 * `archive-<type>` return NOTHING: there the queried object is a content type, not a term, and
	 * term.* stays legitimately empty.
	 *
	 * @return int term_id, or 0.
	 */
	protected static function sample_term_for_template( $slug ) {
		list( $tax, $term ) = self::taxonomy_from_template_slug( $slug );

		if ( '' === $tax || ! taxonomy_exists( $tax ) ) {
			return 0;
		}

		if ( '' !== $term ) {
			$found = get_term_by( 'slug', $term, $tax );
			if ( $found instanceof WP_Term ) {
				return (int) $found->term_id;
			}
		}

		// The first term that really has some content; if the taxonomy is still empty it falls back
		// to any one, which is still better than a mute preview.
		foreach ( array( true, false ) as $non_empty ) {
			$terms = get_terms( array(
				'taxonomy'   => $tax,
				'hide_empty' => $non_empty,
				'number'     => 1,
				'orderby'    => $non_empty ? 'count' : 'name',
				'order'      => $non_empty ? 'DESC' : 'ASC',
				'fields'     => 'ids',
			) );
			if ( ! is_wp_error( $terms ) && $terms ) {
				return (int) $terms[0];
			}
		}
		return 0;
	}

	/**
	 * The taxonomy (and possibly the term) named by an archive template's slug.
	 *
	 * @return array{0:string,1:string} taxonomy and term slug, either of which may be empty.
	 */
	protected static function taxonomy_from_template_slug( $slug ) {
		if ( 'category' === $slug || 0 === strpos( $slug, 'category-' ) ) {
			return array( 'category', ( 'category' === $slug ) ? '' : substr( $slug, 9 ) );
		}
		if ( 'tag' === $slug || 0 === strpos( $slug, 'tag-' ) ) {
			return array( 'post_tag', ( 'tag' === $slug ) ? '' : substr( $slug, 4 ) );
		}
		if ( 0 !== strpos( $slug, 'taxonomy-' ) ) {
			return array( '', '' );
		}

		$rest = substr( $slug, 9 );
		if ( taxonomy_exists( $rest ) ) {
			return array( $rest, '' );
		}

		// `taxonomy-<tax>-<term>`: a taxonomy's name can contain hyphens, so the longest split that
		// is a real taxonomy is tried.
		for ( $i = strrpos( $rest, '-' ); false !== $i && $i > 0; $i = strrpos( substr( $rest, 0, $i ), '-' ) ) {
			if ( taxonomy_exists( substr( $rest, 0, $i ) ) ) {
				return array( substr( $rest, 0, $i ), substr( $rest, $i + 1 ) );
			}
		}

		return array( '', '' );
	}

	/* --------------------------------- Preview -------------------------------- */

	/**
	 * The CURRENT design system, appended to every /preview answer.
	 *
	 * The Studio receives the tokens once, localised when the page loads (AIPB.tokensCss). But the
	 * design system is a GLOBAL option: if it changes while a Studio tab is open — which importing
	 * from a mockup makes normal, not exceptional — that tab goes on drawing the preview with the
	 * old variables, and every var(--aipb-*) of a new section stays undefined: the section looks
	 * unstyled even though it is correct. Sending them with every preview, the tab realigns on the
	 * first refresh without the user having to reload.
	 */
	protected static function design_payload() {
		return array(
			'tokensCss' => AIPB_Design::css_variables(),
			'fontsUrl'  => AIPB_Design::google_fonts_url(),
		);
	}

	public static function preview( $request ) {
		// Sample content for the post.* placeholders: in the Studio there is no query, so without
		// this a "single item" template would look empty while it is being built.
		self::set_preview_sample( (int) $request->get_param( 'contentId' ) );

		// A stack of blocks (a page) or a single section { template, values }.
		$blocks = $request->get_param( 'blocks' );
		if ( is_array( $blocks ) ) {
			$blocks = self::normalize_blocks( $blocks );
			// Per-block HTML (for click-to-select in the preview) + the whole HTML.
			$html = '';
			$per  = array();
			foreach ( $blocks as $b ) {
				$bh    = AIPB_Render::render_blocks( array( $b ) );
				$per[] = array( 'id' => isset( $b['id'] ) ? $b['id'] : '', 'html' => $bh );
				$html .= $bh;
			}
			return rest_ensure_response( array_merge( array( 'html' => $html, 'blocks' => $per ), self::design_payload() ) );
		}

		$values = $request->get_param( 'values' );
		$html   = AIPB_Render::light_wrap( AIPB_Template_Engine::render(
			(string) $request->get_param( 'template' ),
			is_array( $values ) ? $values : array()
		) );

		return rest_ensure_response( array_merge( array( 'html' => $html ), self::design_payload() ) );
	}

	/* ------------------------- Checks, tidying, transfer ---------------------- */

	/**
	 * Checks the page (or the single section) and returns the problems found.
	 *
	 * It does not save, does not block and does not call the model: these are checks decidable by
	 * reading the code, and a decidable check is not delegated to something that tomorrow may answer
	 * differently. Whoever publishes decides, with the problems in front of them.
	 */
	public static function check( $request ) {
		$blocks = $request->get_param( 'blocks' );

		// `blocks` = all the page's blocks; `template` = a single section, freshly generated. The
		// difference is not one of shape: the PAGE-level checks — the <h1>, which must be there once
		// and only once — make sense on the whole, and applied to one section they would fail almost
		// all of them, given that the prompt forbids an <h1> outside the hero.
		$whole = is_array( $blocks );

		if ( ! $whole ) {
			// A single section: it is wrapped as a stack of one, so there is only one code path.
			$blocks = array( array(
				'name'     => (string) $request->get_param( 'name' ),
				'template' => (string) $request->get_param( 'template' ),
				'values'   => (array) $request->get_param( 'values' ),
				'scope'    => ( 'page' === sanitize_key( (string) $request->get_param( 'scope' ) ) ) ? 'page' : 'section',
			) );
		}

		return rest_ensure_response( AIPB_Check::page( self::normalize_blocks( $blocks ), $whole ) );
	}

	/**
	 * Tidies a section's code and RETURNS the result: it does not save, it does not touch the
	 * section. It is the editor that decides whether to apply it, after showing the summary — a
	 * button that rewrites 13 KB of code without showing what changes first is a button that gets
	 * pressed only once.
	 */
	public static function tidy( $request ) {
		$code = (string) $request->get_param( 'code' );
		if ( '' === trim( $code ) ) {
			return new WP_Error( 'aipb_no_code', __( 'No code to tidy up.', 'aipb' ), array( 'status' => 400 ) );
		}

		$res = AIPB_Tidy::section( $code );
		return rest_ensure_response( array(
			'code'    => $res['code'],
			'changed' => ( $res['code'] !== $code ),
			'report'  => $res['report'],
		) );
	}

	/** Packs up the sections it receives. It does not touch the database: it is only a read. */
	public static function export( $request ) {
		$blocks = $request->get_param( 'blocks' );
		$blocks = is_array( $blocks ) ? $blocks : array( array(
			'name'     => (string) $request->get_param( 'name' ),
			'template' => (string) $request->get_param( 'template' ),
			'schema'   => (array) $request->get_param( 'schema' ),
			'values'   => (array) $request->get_param( 'values' ),
		) );

		return rest_ensure_response( AIPB_Portable::export(
			self::normalize_blocks( $blocks ),
			(string) $request->get_param( 'title' )
		) );
	}

	/**
	 * Reads a package and returns the sections ready to insert, plus what is missing on this site.
	 * It does not save: the decision to write stays with whoever pressed the button.
	 */
	public static function import( $request ) {
		$data = $request->get_param( 'package' );
		if ( ! is_array( $data ) ) {
			$data = (string) $request->get_param( 'json' );
		}

		return rest_ensure_response( AIPB_Portable::import( $data ) );
	}

	/**
	 * How much this customer has spent, for the Studio.
	 *
	 * TWO SOURCES, and the difference matters. The LOCAL count is what this site saw go past: it
	 * lives in an option, and a customer can empty it. The SERVICE's is the real accounting, the one
	 * that counts when an invoice is discussed. Both are sent and the second is shown when it is
	 * there: if the service does not answer, an approximate number beats no number — the point is
	 * that whoever generates knows they are spending.
	 *
	 * It does not call the model and consumes no quota: it is a read, and the service's answer is
	 * already in a short cache inside AIPB_Remote.
	 */
	public static function usage() {
		$out = array( 'local' => AIPB_Usage::totals(), 'server' => null );

		if ( class_exists( 'AIPB_Remote' ) && AIPB_Remote::configured() ) {
			$remote = AIPB_Remote::usage();
			if ( ! is_wp_error( $remote ) ) {
				$out['server'] = array(
					'month'            => isset( $remote['month'] ) ? (string) $remote['month'] : '',
					'generations'      => isset( $remote['generations'] ) ? (int) $remote['generations'] : 0,
					'used_micro'       => isset( $remote['used_micro'] ) ? (int) $remote['used_micro'] : 0,
					'credits_limit'    => isset( $remote['credits_limit'] ) ? (int) $remote['credits_limit'] : 0,
					'micro_per_credit' => isset( $remote['micro_per_credit'] ) ? (int) $remote['micro_per_credit'] : 1000000,
					'plan'             => isset( $remote['plan'] ) ? (string) $remote['plan'] : '',
				);
			}
		}

		return rest_ensure_response( $out );
	}

	/**
	 * Finds the image for the contents of a type that have none.
	 *
	 * It asks for `upload_files` and not `manage_options`: that is the capability of whoever looks
	 * after the media, and this does not change the site's structure — it fills holes.
	 */
	public static function images_fill( $request ) {
		$post_type = sanitize_key( (string) $request->get_param( 'post_type' ) );
		if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
			return new WP_Error( 'aipb_unknown_type', __( 'Unknown content type.', 'aipb' ), array( 'status' => 400 ) );
		}

		self::no_time_limit();

		return rest_ensure_response( AIPB_Images::fill_missing( $post_type ) );
	}

	/* -------------------------------- Sections -------------------------------- */

	public static function list_sections() {
		// Reusable patterns/components (our own content) + template parts built with the builder:
		// both are inserted into a page/template as a "ref" block.
		$part_types = array_values( array_intersect(
			array( 'wp_template_part' ),
			AIPB_Config::template_post_types()
		) );

		$posts = get_posts( array(
			'post_type'      => array_merge( AIPB_Config::component_post_types(), $part_types ),
			'post_status'    => array( 'publish', 'draft' ),
			'posts_per_page' => 200,
			'orderby'        => 'modified',
			'order'          => 'DESC',
		) );

		$labels = array(
			'wp_block'         => __( 'Pattern', 'aipb' ),
			'wp_template_part' => __( 'Template part', 'aipb' ),
		);

		$out = array();
		foreach ( $posts as $p ) {
			$is_part = ( 'wp_template_part' === $p->post_type );
			// Only the contents AUTHORED by the builder: components have the template, template
			// parts the block stack (it keeps out the "native" wp_block/part that are empty for us).
			$authored = $is_part
				? ( '' !== get_post_meta( $p->ID, AIPB_META_BLOCKS, true ) )
				: ( '' !== get_post_meta( $p->ID, AIPB_META_TEMPLATE, true ) );
			if ( ! $authored ) {
				continue;
			}

			$obj   = get_post_type_object( $p->post_type );
			$out[] = array(
				'id'        => $p->ID,
				'title'     => $p->post_title ? $p->post_title : __( '(no title)', 'aipb' ),
				'updated'   => get_the_modified_date( 'd/m/Y H:i', $p ),
				'type'      => $p->post_type,
				'kind'      => $is_part ? 'part' : 'pattern',
				'typeLabel' => isset( $labels[ $p->post_type ] ) ? $labels[ $p->post_type ] : ( $obj ? $obj->labels->singular_name : $p->post_type ),
			);
		}
		return rest_ensure_response( $out );
	}

	public static function create_section( $request ) {
		if ( ! current_user_can( 'edit_posts' ) ) {
			return new WP_Error( 'aipb_forbidden', __( 'Permission denied', 'aipb' ), array( 'status' => 403 ) );
		}

		// Content type: whitelist of the post types the builder handles.
		$post_type = sanitize_key( (string) $request->get_param( 'post_type' ) );
		if ( ! $post_type || ! AIPB_Config::is_builder_post_type( $post_type ) ) {
			$post_type = AIPB_CPT::POST_TYPE;
		}

		$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
		if ( '' === $title ) {
			$title = __( 'New content', 'aipb' );
		}

		// Pages are born as DRAFTS (you build without going online straight away); components and
		// patterns are born published, because they have to be public to be reused as references.
		$status = AIPB_Config::is_page_like( $post_type ) ? 'draft' : 'publish';

		$id = wp_insert_post( array(
			'post_type'   => $post_type,
			'post_status' => $status,
			'post_title'  => $title,
		), true );

		if ( is_wp_error( $id ) ) {
			return $id;
		}

		// Born in the Studio: it belongs to Aura, so Gutenberg will not open it.
		AIPB_Ownership::claim( (int) $id, AIPB_Ownership::AURA );

		return rest_ensure_response( self::section_payload( (int) $id ) );
	}

	/**
	 * Promotes an inline block to a reusable component (ai_section).
	 * Returns the new component's payload; the client replaces the block with a ref.
	 */
	public static function create_component( $request ) {
		if ( ! current_user_can( 'edit_posts' ) ) {
			return new WP_Error( 'aipb_forbidden', __( 'Permission denied', 'aipb' ), array( 'status' => 403 ) );
		}

		$template = (string) $request->get_param( 'template' );
		if ( '' === $template ) {
			return new WP_Error( 'aipb_no_template', __( 'Missing template', 'aipb' ), array( 'status' => 400 ) );
		}
		$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
		if ( '' === $title ) {
			$title = __( 'Component', 'aipb' );
		}

		// Reusable patterns live as WordPress synced patterns (wp_block).
		$id = wp_insert_post( array(
			'post_type'   => 'wp_block',
			'post_status' => 'publish',
			'post_title'  => $title,
		), true );
		if ( is_wp_error( $id ) ) {
			return $id;
		}

		// A pattern generated by the Studio: it is edited in the Studio only.
		AIPB_Ownership::claim( (int) $id, AIPB_Ownership::AURA );

		update_post_meta( $id, AIPB_META_TEMPLATE, wp_slash( $template ) );
		update_post_meta( $id, AIPB_META_SCHEMA, wp_slash( wp_json_encode( $request->get_param( 'schema' ) ) ) );
		update_post_meta( $id, AIPB_META_VALUES, wp_slash( wp_json_encode( $request->get_param( 'values' ) ) ) );

		// Mirrored into post_content: the preview in the Patterns grid.
		AIPB_Render::sync_post_content( (int) $id );

		return rest_ensure_response( self::section_payload( (int) $id ) );
	}

	public static function get_section( $request ) {
		$post = self::builder_post( $request );
		if ( is_wp_error( $post ) ) {
			return $post;
		}

		return rest_ensure_response( self::section_payload( (int) $request['id'] ) );
	}

	public static function delete_section( $request ) {
		$post = self::builder_post( $request );
		if ( is_wp_error( $post ) ) {
			return $post;
		}

		wp_trash_post( (int) $request['id'] );
		return rest_ensure_response( array( 'deleted' => true ) );
	}

	/**
	 * The request's post, when it is one the builder handles.
	 *
	 * @return WP_Post|WP_Error
	 */
	protected static function builder_post( $request ) {
		$post = get_post( (int) $request['id'] );
		if ( ! $post || ! AIPB_Config::is_builder_post_type( $post->post_type ) ) {
			return new WP_Error( 'aipb_not_found', __( 'Content not found', 'aipb' ), array( 'status' => 404 ) );
		}

		return $post;
	}

	/** A post the builder handles that is NOT a component: it uses the block stack. */
	protected static function is_page_like( $post_type ) {
		return AIPB_Config::is_page_like( $post_type );
	}

	/**
	 * A page's saved blocks; failing that, it synthesises an inline block from the legacy template
	 * (compatibility with the pages created before blocks existed).
	 */
	protected static function page_blocks( $id ) {
		$raw = get_post_meta( $id, AIPB_META_BLOCKS, true );
		if ( $raw ) {
			$blocks = json_decode( $raw, true );
			if ( is_array( $blocks ) ) {
				return array_map( array( __CLASS__, 'switchable' ), $blocks );
			}
		}
		$legacy = (string) get_post_meta( $id, AIPB_META_TEMPLATE, true );
		if ( '' !== $legacy ) {
			$schema = json_decode( get_post_meta( $id, AIPB_META_SCHEMA, true ), true );
			$values = json_decode( get_post_meta( $id, AIPB_META_VALUES, true ), true );
			return array( self::switchable( array(
				'type'     => 'inline',
				'id'       => 'b_legacy',
				'name'     => __( 'Section', 'aipb' ),
				'template' => $legacy,
				'schema'   => is_array( $schema ) ? $schema : array( 'fields' => array() ),
				'values'   => is_array( $values ) ? $values : array(),
			) ) );
		}
		return array();
	}

	/**
	 * ONE SECTION, WITH THE SWITCHES IT WOULD HAVE IF IT WERE GENERATED TODAY.
	 *
	 * Sections saved before AIPB_Switches existed have the conditions the model happened to write
	 * that day and no others — which is why a footer can come back with a switch on its paragraph
	 * and none on its logo. The class reads the markup and writes the rest, and the template, the
	 * fields and the values are brought up to date together (see AIPB_Switches::upgrade).
	 *
	 * HERE AND NOT AT RENDER TIME, deliberately. This is the READ path of the Studio: the section
	 * arrives at the panel already switchable, and NOTHING changes on the public page until somebody
	 * opens that section and saves it. A pass that rewrote templates on their way to a visitor would
	 * be changing live sites on their own, in the middle of the night, with no save to point at.
	 *
	 * A `ref` block is a pattern: it is edited from Patterns, and it is upgraded when it is opened
	 * THERE. Doing it here would upgrade a copy that is about to be thrown away.
	 */
	protected static function switchable( $b ) {
		if ( ! is_array( $b ) || ( isset( $b['type'] ) && 'ref' === $b['type'] ) || empty( $b['template'] ) ) {
			return $b;
		}
		list( $b['template'], $b['schema'], $b['values'] ) = AIPB_Switches::upgrade(
			$b['template'],
			isset( $b['schema'] ) ? $b['schema'] : array( 'fields' => array() ),
			isset( $b['values'] ) ? $b['values'] : array()
		);

		return $b;
	}

	/** Sanitises the block stack arriving from the client before saving it. */
	protected static function normalize_blocks( $blocks ) {
		$out = array();
		if ( ! is_array( $blocks ) ) {
			return $out;
		}
		$i = 0;
		foreach ( $blocks as $b ) {
			if ( ! is_array( $b ) ) {
				continue;
			}
			$i++;
			$id = isset( $b['id'] ) ? sanitize_text_field( (string) $b['id'] ) : ( 'b_' . $i );
			/* translators: %d: the block's position in the page. */
			$name = isset( $b['name'] ) ? sanitize_text_field( (string) $b['name'] ) : sprintf( __( 'Section %d', 'aipb' ), $i );

			if ( isset( $b['type'] ) && 'ref' === $b['type'] ) {
				$ref = isset( $b['ref'] ) ? (int) $b['ref'] : 0;
				if ( ! $ref ) {
					continue;
				}
				$out[] = array( 'type' => 'ref', 'id' => $id, 'ref' => $ref, 'name' => $name );
				continue;
			}

			// inline: the template is trusted (admin), the values are sanitized at render time by
			// the engine.
			$template = isset( $b['template'] ) ? (string) $b['template'] : '';
			if ( '' === $template ) {
				continue;
			}
			// The fallback value inside every var(--aipb-…), taken from the tokens as they are NOW:
			// it is what keeps the page standing the day that token is gone. It lives here because
			// normalize_blocks is the funnel for everything arriving from the Studio — saving,
			// preview and check — so what is seen is already what will be written.
			$template = AIPB_Design::add_fallbacks( $template );

			$out[] = array(
				'type'     => 'inline',
				'id'       => $id,
				'name'     => $name,
				'template' => $template,
				'schema'   => isset( $b['schema'] ) && is_array( $b['schema'] ) ? $b['schema'] : array( 'fields' => array() ),
				'values'   => isset( $b['values'] ) && is_array( $b['values'] ) ? $b['values'] : array(),
				// The ANCHOR: the readable target of an in-page link (#prices). It is born from the
				// block's name on the Studio side and never changes after that, because an anchor
				// that moves is a link that stops working without saying anything.
				'anchor'   => isset( $b['anchor'] ) ? sanitize_title( (string) $b['anchor'] ) : '',
				// The id of the generation that produced this section: it is what support will ask
				// for in a month's time, so it has to live in the save and not only in the memory of
				// a browser tab.
				'req'      => isset( $b['req'] ) ? sanitize_text_field( (string) $b['req'] ) : '',
				// The style touch-ups made by hand: the rendering does not need them, but they have
				// to survive the save, or the warning «you lose these by regenerating» would no
				// longer know what it is talking about the next day.
				'styleEdits' => isset( $b['styleEdits'] ) && is_array( $b['styleEdits'] ) ? $b['styleEdits'] : array(),
				// A page layer lives outside the section flow (fixed overlay). Default section so
				// every block saved before this field existed keeps behaving as it always has.
				'scope'    => ( isset( $b['scope'] ) && 'page' === $b['scope'] ) ? 'page' : 'section',
			);
		}
		return $out;
	}

	/** The common payload of a builder content. */
	protected static function section_payload( $id ) {
		$post    = get_post( $id );
		$schema  = json_decode( get_post_meta( $id, AIPB_META_SCHEMA, true ), true );
		$values  = json_decode( get_post_meta( $id, AIPB_META_VALUES, true ), true );
		$obj     = get_post_type_object( $post->post_type );
		$is_page = self::is_page_like( $post->post_type );

		// A component is one section held directly on the post, not a stack of blocks: it goes
		// through the same upgrade, by the same route and with the same promise — nothing reaches
		// the public page until this section is saved.
		list( $template, $schema, $values ) = AIPB_Switches::upgrade(
			(string) get_post_meta( $id, AIPB_META_TEMPLATE, true ),
			is_array( $schema ) ? $schema : array( 'fields' => array() ),
			is_array( $values ) ? $values : array()
		);

		return array(
			'id'        => (int) $id,
			'title'     => $post->post_title,
			'postType'  => $post->post_type,
			'typeLabel' => $obj ? $obj->labels->singular_name : $post->post_type,
			'isPage'    => $is_page,
			'status'    => get_post_status( $id ),
			// Informative: the Studio no longer changes it, AIPB_Render::use_canvas() decides.
			'canvas'    => AIPB_Render::use_canvas( $id ),
			'blocks'    => $is_page ? self::page_blocks( $id ) : array(),
			'template'  => $template,
			'schema'    => $schema,
			'values'    => $values,
			'shortcode' => '[ai_section id="' . (int) $id . '"]',
			'permalink' => get_permalink( $id ),
			// WordPress's NATIVE preview: the page drawn by the real theme, with the real query, as
			// a visitor will see it. It is a different thing from the Studio's preview, which shows
			// the sections outside their chrome — and on a draft it is the only way of really seeing
			// the result, because the public permalink does not exist yet.
			'previewLink' => get_preview_post_link( $id ),
			'editLink'    => get_edit_post_link( $id, 'raw' ),
		);
	}

	/* --------------------------------- History -------------------------------- */

	/**
	 * Lists the content's revisions that carry OUR meta with them (the ones created by the Studio):
	 * those without the meta would be restored empty, so we do not expose them.
	 */
	public static function list_revisions( $request ) {
		$post = self::builder_post( $request );
		if ( is_wp_error( $post ) ) {
			return $post;
		}

		$key = self::is_page_like( $post->post_type ) ? AIPB_META_BLOCKS : AIPB_META_TEMPLATE;

		$out = array();
		foreach ( wp_get_post_revisions( (int) $request['id'] ) as $rev ) {
			// Skip the autosaves and the revisions with none of our content.
			if ( wp_is_post_autosave( $rev ) ) {
				continue;
			}
			if ( '' === (string) get_metadata( 'post', $rev->ID, $key, true ) ) {
				continue;
			}
			$author = get_the_author_meta( 'display_name', $rev->post_author );
			$out[]  = array(
				'id'   => $rev->ID,
				'date' => get_the_modified_date( 'd/m/Y H:i', $rev ),
				/* translators: %s: a human readable duration, e.g. "3 hours". */
				'ago'    => sprintf( __( '%s ago', 'aipb' ), human_time_diff( strtotime( $rev->post_modified_gmt . ' GMT' ) ) ),
				'author' => $author ? $author : '',
			);
			if ( count( $out ) >= 50 ) {
				break;
			}
		}
		return rest_ensure_response( $out );
	}

	/**
	 * Restores a revision: WP brings back title/content/excerpt and (through our
	 * restore_revision_meta hook) the builder's meta; then we regenerate the snapshot and return the
	 * payload.
	 */
	public static function restore_revision( $request ) {
		$post = self::builder_post( $request );
		if ( is_wp_error( $post ) ) {
			return $post;
		}

		$id  = (int) $request['id'];
		$rev = (int) $request->get_param( 'revision' );

		$revision = wp_get_post_revision( $rev );
		if ( ! $revision || (int) $revision->post_parent !== $id ) {
			return new WP_Error( 'aipb_bad_revision', __( 'That revision does not belong to this content', 'aipb' ), array( 'status' => 400 ) );
		}
		wp_restore_post_revision( $rev );

		// Regenerate the post_content snapshot from the meta just restored (for pages and templates).
		if ( self::is_page_like( $post->post_type ) || AIPB_Config::is_component_type( $post->post_type ) ) {
			AIPB_Render::sync_post_content( $id );
		}

		$payload             = self::section_payload( $id );
		$payload['restored'] = true;
		return rest_ensure_response( $payload );
	}

	/* --------------------------------- Saving --------------------------------- */

	/**
	 * Extracts a textual excerpt from a page's compiled content: it first removes the
	 * <style>/<script> blocks (otherwise their text would end up in the excerpt), then strips the
	 * tags and shortens to ~30 words. Empty when there is no text.
	 */
	protected static function derive_excerpt( $id ) {
		$html = AIPB_Render::compile_html( (int) $id );
		if ( '' === $html ) {
			return '';
		}
		$html = preg_replace( '#<(style|script)\b[^>]*>.*?</\1>#is', ' ', $html );
		$text = trim( wp_strip_all_tags( (string) $html ) );
		return '' === $text ? '' : wp_trim_words( $text, 30, '…' );
	}

	public static function save( $request ) {
		$id    = (int) $request['id'];
		$post  = get_post( $id );
		$title = $request->get_param( 'title' );

		// First save from the Studio = the content passes to Aura. It has to happen BEFORE
		// sync_post_content(): that wp_update_post fires save_post, which on a content that is still
		// free would assign it to Gutenberg (see AIPB_Ownership::claim_on_editor_save).
		AIPB_Ownership::claim( $id, AIPB_Ownership::AURA );

		if ( $post && self::is_page_like( $post->post_type ) ) {
			self::save_page( $id, $request );
		} else {
			self::save_component( $id, $request );
		}

		// Always update the post (even just its date): that way WP creates a revision, and our meta
		// get hooked onto it (see AIPB_Render::save_revision_meta).
		$update = array( 'ID' => $id );
		if ( null !== $title ) {
			$update['post_title'] = sanitize_text_field( (string) $title );
		}
		// Automatic excerpt from the content (the meta description fallback for whoever does not use
		// an SEO plugin): only for pages, and only if one has not already been written by hand.
		if ( $post && self::is_page_like( $post->post_type ) && '' === trim( (string) $post->post_excerpt ) ) {
			$excerpt = self::derive_excerpt( $id );
			if ( '' !== $excerpt ) {
				$update['post_excerpt'] = $excerpt;
			}
		}
		// Draft/published state (pages only): it allows building without going online.
		if ( $post && self::is_page_like( $post->post_type ) ) {
			$status = sanitize_key( (string) $request->get_param( 'status' ) );
			if ( in_array( $status, array( 'draft', 'publish' ), true ) ) {
				$update['post_status'] = $status;
			}
		}
		wp_update_post( $update );

		$payload          = self::section_payload( $id );
		$payload['saved'] = true;
		return rest_ensure_response( $payload );
	}

	/** A page/post/template: the block stack (a set of sections). */
	protected static function save_page( $id, $request ) {
		$blocks = self::normalize_blocks( $request->get_param( 'blocks' ) );
		update_post_meta( $id, AIPB_META_BLOCKS, wp_slash( wp_json_encode( $blocks ) ) );

		// The canvas is no longer saved per page: AIPB_Render::use_canvas() decides. Any 'canvas' in
		// the body — sent by a Studio tab left open from before the update — is deliberately ignored,
		// and the old meta cleaned up: that was exactly how a page that was fine went back to loading
		// the theme's chrome.
		delete_post_meta( $id, AIPB_META_CANVAS );

		// A semantic snapshot in post_content for EVERY page (not only the FSE templates): it makes
		// the content visible to the SEO plugins and to the internal search, and lets the page
		// survive the plugin being deactivated (degrading to static HTML). With the plugin active the
		// frontend goes on rendering from the meta (live dynamic loops) through the_content.
		AIPB_Render::sync_post_content( $id );
	}

	/**
	 * A component (pattern/ai_section): a single section. The template is trusted (admin), the
	 * values are sanitized at render time by the engine, by type.
	 */
	protected static function save_component( $id, $request ) {
		// Same treatment as the page blocks: a component does not go through normalize_blocks, so
		// the fallback has to be sewn on here or nobody sews it on.
		$template = AIPB_Design::add_fallbacks( (string) $request->get_param( 'template' ) );

		update_post_meta( $id, AIPB_META_TEMPLATE, wp_slash( $template ) );
		update_post_meta( $id, AIPB_META_SCHEMA, wp_slash( wp_json_encode( $request->get_param( 'schema' ) ) ) );
		update_post_meta( $id, AIPB_META_VALUES, wp_slash( wp_json_encode( $request->get_param( 'values' ) ) ) );

		// Mirrored into post_content: it gives the patterns their preview in WordPress's grid.
		AIPB_Render::sync_post_content( $id );
	}
}
