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

/**
 * THE PLUGIN'S design system — independent of the theme.
 *
 * The tokens live here (the --aipb-* namespace), filterable: we do NOT read the active theme's
 * theme.json, so the output is consistent on any theme (block or classic) and the design stays in
 * the plugin's hands.
 *
 * The variables are injected:
 *  - into the front end of pages built with the builder (AIPB_Render::frontend_reset)
 *  - into the Studio's preview iframe (localised as AIPB.tokensCss)
 *  - into the payload sent to the service, as VALUES ({@see AIPB_Site_Payload}); the sentence that
 *    describes them to the model is composed there, not here.
 */
class AIPB_Design {

	/** The brand overrides set by the user (a "group:key" => value map). */
	const OPT_TOKENS = 'aipb_brand_tokens';

	/**
	 * The brand's PALETTE: an ordered list of colours the user defines freely. No predefined
	 * roles — the AI decides where to apply them.
	 *
	 * Shape: array of array( 'slug' => string, 'name' => string, 'value' => string ).
	 */
	const OPT_PALETTE = 'aipb_brand_palette';

	/**
	 * The brand's FONTS: an ordered list the user imports freely. No predefined roles — each has a
	 * name that hints at its use, and the AI decides where to apply it.
	 *
	 * Shape: array of array( 'slug', 'name', 'family', 'category', 'weights' ).
	 */
	const OPT_FONTS = 'aipb_brand_fonts';

	/**
	 * The site's ART DIRECTION: a free-text brief (mood, tone, density, style) the user writes
	 * once, which travels with every generation so all the sections speak the same visual language.
	 * It touches neither palette nor fonts — those stay the user's — it only steers HOW the AI
	 * designs and uses the tokens.
	 */
	const OPT_DIRECTION = 'aipb_brand_direction';

	/** A copy of the tokens from before a reset, so it can be undone. */
	const OPT_BACKUP = 'aipb_brand_backup';

	/**
	 * The tokens the USER invented: a list of array( group, key, label ).
	 *
	 * It is kept apart from OPT_TOKENS — which holds values — because these are declarations: a
	 * token has to exist before it can have a value, and it has to go on existing when its value is
	 * the group's default. Merged into groups() they become indistinguishable from the shipped ones
	 * everywhere downstream: CSS variables, the fallbacks sewn into saved sections, and the
	 * inventory the service receives. That is the point — a custom token is not a second-class
	 * token, it is a token.
	 */
	const OPT_CUSTOM = 'aipb_brand_custom_tokens';

	/** Where token_usage() parks its answer, so one page load is one query. */
	const USAGE_CACHE = 'aipb_token_usage';

	/**
	 * Named copies of the whole design system, newest first.
	 *
	 * OPT_BACKUP was one slot, written by the reset and readable only as «undo». That is the right
	 * shape for an undo and the wrong one for a design system, which is iterated: you try a warmer
	 * palette, you widen the container, and three days later you want the version from before the
	 * client changed their mind. One slot cannot hold that, and it is overwritten by the next reset
	 * anyway.
	 */
	const OPT_SNAPSHOTS = 'aipb_brand_snapshots';

	/** How many are kept. Enough to iterate; not enough to turn an option into an archive. */
	const SNAPSHOTS_MAX = 10;

	/**
	 * The brand's VERSION: an integer that goes up on every change to any part of the brand —
	 * tokens, palette, fonts, direction, manifest, effects.
	 *
	 * It exists so that nothing derived from the brand ever has to guess whether it is stale: the
	 * compiled effects CSS (see AIPB_Effects::compiled_css) keys its cache on it, the token usage
	 * cache is dropped with it, and anything added later (a compiled brand stylesheet, a payload
	 * checksum) gets its invalidation for free by comparing one number. The alternative — every
	 * cache watching every option — is exactly the wiring this constant replaces.
	 */
	const OPT_VERSION = 'aipb_brand_version';

	/** The maximum length of the direction brief, to keep the prompt lean. */
	const DIRECTION_MAX = 1500;

	/**
	 * The effective tokens: defaults, plus the brand overrides, plus the filter.
	 */
	public static function groups() {
		$groups    = self::defaults();
		$overrides = get_option( self::OPT_TOKENS, array() );

		// The user's own tokens join their group BEFORE the overrides are applied, so a value typed
		// for one of them lands on it exactly as it would on a shipped token.
		foreach ( self::custom_tokens() as $token ) {
			if ( ! isset( $groups[ $token['group'] ] ) ) {
				continue;
			}
			if ( isset( $groups[ $token['group'] ]['items'][ $token['key'] ] ) ) {
				continue; // a shipped key wins: it is the one the prompts know by name
			}

			$groups[ $token['group'] ]['items'][ $token['key'] ] = array( '', $token['label'] );
		}

		if ( is_array( $overrides ) ) {
			foreach ( $groups as $group_key => &$group ) {
				foreach ( $group['items'] as $item_key => &$item ) {
					$id = $group_key . ':' . $item_key;
					if ( isset( $overrides[ $id ] ) && '' !== $overrides[ $id ] ) {
						$item[0] = $overrides[ $id ];
					}
				}
				unset( $item );
			}
			unset( $group );
		}

		return apply_filters( 'aipb_design_tokens', $groups );
	}

	/**
	 * The DEFAULT token definition, by group. Each item: key => array( value, label ).
	 *
	 * THESE VALUES SHIP FILLED IN, and that is the whole point of them.
	 *
	 * They used to be empty, on the same reasoning as the palette and the fonts: no preset, the user
	 * decides. For colours and fonts that is right — they ARE the brand, and inventing them would be
	 * deciding in somebody's place. For the structural scale it was wrong, and silently so. A site
	 * where nobody has opened the Design Tokens page — which is every site on day one, and most sites
	 * for ever — sent the service a prompt whose structural chapter read, in full: «none defined by
	 * the user. Use sensible, consistent, elegant CSS values, decided by you.» So the model was
	 * INSTRUCTED to invent a spacing scale, a type scale, a corner radius and a shadow, once per
	 * section, with nothing carried over from the previous one. Ten sections, ten vertical rhythms.
	 * That is the single biggest source of pages that look as though three people had made them.
	 *
	 * With a value here, the strongest rule in the prompt comes into force instead — use
	 * `var(--aipb-*)`, never a hand-written number — on every generation, on every page, for ever,
	 * with nobody having had to configure anything. And it stays the user's design system all the
	 * same: a value typed on the Design Tokens page overrides the one below (see groups()), and
	 * {@see sanitize_tokens()} does not even store a field left equal to its default, so the default
	 * goes on being dynamic and moves with the plugin.
	 *
	 * A SECOND THING THEY BUY, for free: after a brand reset the palette and the fonts go (they were
	 * the user's), but the structural variables stay defined. The incident described in
	 * add_fallbacks() — 262 var() references losing their definition at once, and six pages emptying
	 * themselves — could not happen to a structural token any more, because it can no longer stop
	 * existing.
	 *
	 * THE SCALE ITSELF is the one the prompts ask for in words (see `dsl-bellezza.txt` on the
	 * service): few levels with an OBVIOUS jump between them, generous vertical air, and shadows so
	 * faint you would not name them. The text sizes and the two largest spacings are fluid, so a
	 * hero does not have to be walked down by hand for the phone.
	 */
	protected static function defaults() {
		return array(
			// Neither colours nor fonts live here: they are dynamic lists with no fixed roles,
			// defined by the user (see palette() and fonts()). What stays are the design system's
			// structural tokens — the part that is not the brand, but the rhythm.
			'text' => array(
				'label'  => __( 'Text sizes (fluid)', 'aipb' ),
				'prefix' => '--aipb-text-',
				// The jump from `base` to `4xl` is roughly 4.5×. A section heading at 32px above a
				// 16px body is a document; this scale is what makes it a page.
				'items'  => array(
					'sm'   => array( 'clamp(0.875rem, 0.85rem + 0.12vw, 0.9375rem)', __( 'small', 'aipb' ) ),
					'base' => array( 'clamp(1rem, 0.96rem + 0.2vw, 1.125rem)', __( 'body', 'aipb' ) ),
					'lg'   => array( 'clamp(1.125rem, 1.05rem + 0.35vw, 1.375rem)', __( 'subheading', 'aipb' ) ),
					'xl'   => array( 'clamp(1.375rem, 1.2rem + 0.7vw, 1.75rem)', __( 'small heading', 'aipb' ) ),
					'2xl'  => array( 'clamp(1.75rem, 1.45rem + 1.4vw, 2.5rem)', __( 'heading', 'aipb' ) ),
					'3xl'  => array( 'clamp(2.25rem, 1.7rem + 2.6vw, 3.5rem)', __( 'large heading', 'aipb' ) ),
					'4xl'  => array( 'clamp(2.75rem, 1.8rem + 4.4vw, 5rem)', __( 'display / hero', 'aipb' ) ),
				),
			),
			'space' => array(
				'label'  => __( 'Spacing', 'aipb' ),
				'prefix' => '--aipb-space-',
				// A regular progression, and the two at the top fluid: the commonest mistake in a
				// generated section is vertical padding that is too timid.
				'items'  => array(
					'2xs' => array( '0.25rem', '' ),
					'xs'  => array( '0.5rem', '' ),
					'sm'  => array( '0.75rem', '' ),
					'md'  => array( '1rem', '' ),
					'lg'  => array( '1.5rem', '' ),
					'xl'  => array( '2.5rem', '' ),
					'2xl' => array( 'clamp(4rem, 3rem + 5vw, 7rem)', __( 'vertical padding of sections', 'aipb' ) ),
					'3xl' => array( 'clamp(6rem, 4.5rem + 7vw, 10rem)', '' ),
				),
			),
			'radius' => array(
				'label'  => __( 'Radii', 'aipb' ),
				'prefix' => '--aipb-radius-',
				'items'  => array(
					'sm'   => array( '6px', '' ),
					'md'   => array( '10px', '' ),
					'lg'   => array( '18px', '' ),
					'pill' => array( '999px', __( 'buttons/chips', 'aipb' ) ),
				),
			),
			'shadow' => array(
				'label'  => __( 'Shadows', 'aipb' ),
				'prefix' => '--aipb-shadow-',
				// Faint on purpose: a shadow you can see is almost always too much.
				'items'  => array(
					'sm' => array( '0 1px 2px rgba(15, 23, 42, 0.06)', '' ),
					'md' => array( '0 4px 16px rgba(15, 23, 42, 0.08)', '' ),
					'lg' => array( '0 12px 40px rgba(15, 23, 42, 0.10)', '' ),
				),
			),
			'layout' => array(
				'label'  => __( 'Content widths', 'aipb' ),
				'prefix' => '--aipb-',
				'items'  => array(
					'container'        => array( '1200px', __( 'standard max width', 'aipb' ) ),
					'container-narrow' => array( '720px', __( 'max width for long text', 'aipb' ) ),
				),
			),
			'motion' => array(
				'label'  => __( 'Motion', 'aipb' ),
				'prefix' => '--aipb-motion-',
				// The brand's voice in movement. Before these existed, every generated section wrote
				// its own durations and easings by hand — ten sections, ten different entrances. The
				// values below are the ones the reveal engine had hardcoded, promoted to tokens: the
				// engine now reads them ({@see AIPB_Render::motion_css}), the prompts forbid writing
				// literal durations/easings, and changing one value here retunes every page at once.
				'items'  => array(
					'duration-fast' => array( '200ms', __( 'micro-interactions (hover, buttons)', 'aipb' ) ),
					'duration-base' => array( '600ms', __( 'standard entrances', 'aipb' ) ),
					'duration-slow' => array( '900ms', __( 'large/cinematic entrances', 'aipb' ) ),
					'ease'          => array( 'cubic-bezier(.22,.61,.36,1)', __( 'the brand easing curve', 'aipb' ) ),
					'distance'      => array( '24px', __( 'slide distance of entrances', 'aipb' ) ),
					'stagger'       => array( '80ms', __( 'interval between staggered children', 'aipb' ) ),
				),
			),
		);
	}

	/**
	 * The user's own token declarations, cleaned.
	 *
	 * @return list<array{group:string,key:string,label:string}>
	 */
	public static function custom_tokens() {
		return apply_filters( 'aipb_custom_tokens', self::sanitize_custom( get_option( self::OPT_CUSTOM, array() ) ) );
	}

	/**
	 * Settings API callback for the custom tokens.
	 *
	 * THE KEY IS THE HARD PART. It becomes a CSS custom property name by concatenation
	 * (`--aipb-space-` . key), so anything outside [a-z0-9-] would produce a variable that silently
	 * does not exist: not an error, just a design system with a hole in it. It is lowercased,
	 * stripped, and refused when nothing survives.
	 *
	 * A key that collides with a SHIPPED one is dropped rather than renamed: `--aipb-space-md`
	 * already means something to every prompt and to every section already generated, and letting a
	 * user redefine it from here would change the meaning of pages nobody is looking at.
	 */
	public static function sanitize_custom( $input ) {
		if ( ! is_array( $input ) ) {
			return array();
		}

		$defaults = self::defaults();
		$out      = array();
		$seen     = array();

		foreach ( $input as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$group = isset( $row['group'] ) ? sanitize_key( (string) $row['group'] ) : '';
			if ( ! isset( $defaults[ $group ] ) ) {
				continue;
			}

			$key = isset( $row['key'] ) ? strtolower( trim( (string) $row['key'] ) ) : '';
			$key = preg_replace( '/[^a-z0-9-]+/', '-', $key );
			$key = trim( (string) $key, '-' );
			if ( '' === $key ) {
				continue;
			}

			// Not over a shipped token, and not twice.
			$id = $group . ':' . $key;
			if ( isset( $defaults[ $group ]['items'][ $key ] ) || isset( $seen[ $id ] ) ) {
				continue;
			}
			$seen[ $id ] = true;

			$label = isset( $row['label'] ) ? trim( wp_strip_all_tags( (string) $row['label'] ) ) : '';

			$out[] = array(
				'group' => $group,
				'key'   => $key,
				'label' => function_exists( 'mb_substr' ) ? mb_substr( $label, 0, 60 ) : substr( $label, 0, 60 ),
			);
		}

		return $out;
	}

	/* ------------------------------- Brand manifest ---------------------------- */

	/**
	 * The brand's MANIFEST: the direction's structured half.
	 *
	 * The direction (below) is a free brief — mood, tone, whatever the user wants to say. The
	 * manifest is the part of that character that has a SHAPE: rules about motion, the rhythm of
	 * the surfaces, how imagery behaves, and the things this brand never does. Separated because
	 * they are used differently — prose is guidance, a "never" list is a prohibition the prompt
	 * can state as one — and because tools can write fields where they could not safely rewrite
	 * somebody's paragraph.
	 *
	 * Shape: personality (string), rhythm (string), imagery (string),
	 *        motion_rules (list of strings), never (list of strings).
	 */
	const OPT_MANIFEST = 'aipb_brand_manifest';

	/** How long one manifest line may be. These travel in every prompt: brevity is the format. */
	const MANIFEST_LINE_MAX = 200;

	/** How many rules each list may hold. */
	const MANIFEST_LINES_MAX = 10;

	/** The manifest, cleaned. Empty strings/lists where the user wrote nothing. */
	public static function manifest() {
		$value = get_option( self::OPT_MANIFEST, array() );

		return apply_filters( 'aipb_brand_manifest', self::sanitize_manifest( is_array( $value ) ? $value : array() ) );
	}

	/** Settings API callback for the manifest option. */
	public static function sanitize_manifest( $input ) {
		if ( ! is_array( $input ) ) {
			return array();
		}

		$line = static function ( $text ) {
			$text = trim( wp_strip_all_tags( (string) $text ) );

			return function_exists( 'mb_substr' )
				? mb_substr( $text, 0, self::MANIFEST_LINE_MAX )
				: substr( $text, 0, self::MANIFEST_LINE_MAX );
		};

		$out = array();

		foreach ( array( 'personality', 'rhythm', 'imagery' ) as $key ) {
			$value = $line( isset( $input[ $key ] ) ? $input[ $key ] : '' );
			if ( '' !== $value ) {
				$out[ $key ] = $value;
			}
		}

		foreach ( array( 'motion_rules', 'never' ) as $key ) {
			// From the form it arrives as one textarea, one rule per line; from an import as a list.
			$raw = isset( $input[ $key ] ) ? $input[ $key ] : array();
			if ( is_string( $raw ) ) {
				$raw = preg_split( '/\r\n|\r|\n/', $raw );
			}

			$list = array();
			foreach ( (array) $raw as $rule ) {
				$rule = $line( $rule );
				if ( '' !== $rule && count( $list ) < self::MANIFEST_LINES_MAX ) {
					$list[] = $rule;
				}
			}
			if ( $list ) {
				$out[ $key ] = $list;
			}
		}

		return $out;
	}

	/* ------------------------------- Art direction ----------------------------- */

	/** The site's art direction brief (free text, already sanitised). */
	public static function direction() {
		$value = get_option( self::OPT_DIRECTION, '' );

		return apply_filters( 'aipb_brand_direction', is_string( $value ) ? $value : '' );
	}

	/** Settings API callback for the direction option: multi-line text, no tags, capped. */
	public static function sanitize_direction( $input ) {
		$text = trim( wp_strip_all_tags( is_string( $input ) ? $input : '' ) );

		return function_exists( 'mb_substr' )
			? mb_substr( $text, 0, self::DIRECTION_MAX )
			: substr( $text, 0, self::DIRECTION_MAX );
	}

	/* -------------------------------- Brand palette ---------------------------- */

	/** The brand's palette: an ordered list of user-defined colours, normalised on the way out. */
	public static function palette() {
		return apply_filters( 'aipb_brand_palette', self::normalize_palette( get_option( self::OPT_PALETTE, array() ) ) );
	}

	/** Settings API callback for the palette option. */
	public static function sanitize_palette( $input ) {
		return self::normalize_palette( $input );
	}

	/**
	 * Normalises a raw colour list: drops invalid values, cleans the names, derives and guarantees
	 * unique CSS slugs (used as --aipb-color-SLUG).
	 *
	 * A supplied slug is preserved when valid: that way renaming a colour does not change the CSS
	 * variable already used across the generated pages.
	 */
	protected static function normalize_palette( $raw ) {
		if ( ! is_array( $raw ) ) {
			return array();
		}

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

		foreach ( $raw as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$value = isset( $row['value'] ) ? self::sanitize_value( $row['value'] ) : '';

			// Accepts hex (#rgb/#rrggbb/#rrggbbaa) or simple CSS colour functions.
			if ( '' === $value || ! preg_match( '/^[#a-zA-Z0-9(),.%\s\/-]+$/', $value ) ) {
				continue;
			}

			$name = isset( $row['name'] ) ? trim( wp_strip_all_tags( (string) $row['name'] ) ) : '';
			$slug = self::unique_slug(
				isset( $row['slug'] ) ? (string) $row['slug'] : '',
				array( $name ),
				'colour',
				$seen
			);

			$out[] = array( 'slug' => $slug, 'name' => $name, 'value' => $value );
		}

		return $out;
	}

	/* ----------------------------- Typography / Google ------------------------- */

	/**
	 * The full CSS stack for an imported family: "'Family', <category fallback>".
	 *
	 * Public because it also goes into the payload sent to the service: the fallbacks depend on the
	 * font's category, which is a choice of THIS design system, not of the service.
	 */
	public static function font_stack( $family, $category ) {
		$fallbacks = self::font_fallbacks();
		$tail      = isset( $fallbacks[ $category ] ) ? $fallbacks[ $category ] : $fallbacks['sans-serif'];

		return "'" . $family . "', " . $tail;
	}

	/** System fallbacks by category, appended to the imported family. */
	protected static function font_fallbacks() {
		return array(
			'sans-serif'  => "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
			'serif'       => "Georgia, 'Times New Roman', serif",
			'monospace'   => 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
			'display'     => "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
			'handwriting' => "'Segoe Script', cursive",
		);
	}

	/**
	 * A curated catalogue of Google fonts (no API key). name => [category, weights].
	 *
	 * The listed weights are the ones that really exist, so the css2 URL does not fail. It is the
	 * shared source of truth between the editor (localised) and server-side validation.
	 */
	public static function google_font_catalog() {
		return array(
			// Sans-serif
			'Inter'             => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Roboto'            => array( 'category' => 'sans-serif', 'weights' => array( 100, 300, 400, 500, 700, 900 ) ),
			'Open Sans'         => array( 'category' => 'sans-serif', 'weights' => array( 300, 400, 500, 600, 700, 800 ) ),
			'Montserrat'        => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Poppins'           => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Lato'              => array( 'category' => 'sans-serif', 'weights' => array( 100, 300, 400, 700, 900 ) ),
			'Nunito'            => array( 'category' => 'sans-serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Nunito Sans'       => array( 'category' => 'sans-serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Work Sans'         => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Raleway'           => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Manrope'           => array( 'category' => 'sans-serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800 ) ),
			'DM Sans'           => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Plus Jakarta Sans' => array( 'category' => 'sans-serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800 ) ),
			'Outfit'            => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Figtree'           => array( 'category' => 'sans-serif', 'weights' => array( 300, 400, 500, 600, 700, 800, 900 ) ),
			'Sora'              => array( 'category' => 'sans-serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800 ) ),
			'Rubik'             => array( 'category' => 'sans-serif', 'weights' => array( 300, 400, 500, 600, 700, 800, 900 ) ),
			'Mulish'            => array( 'category' => 'sans-serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Karla'             => array( 'category' => 'sans-serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800 ) ),
			'Space Grotesk'     => array( 'category' => 'sans-serif', 'weights' => array( 300, 400, 500, 600, 700 ) ),
			// Serif
			'Playfair Display'   => array( 'category' => 'serif', 'weights' => array( 400, 500, 600, 700, 800, 900 ) ),
			'Merriweather'       => array( 'category' => 'serif', 'weights' => array( 300, 400, 700, 900 ) ),
			'Lora'               => array( 'category' => 'serif', 'weights' => array( 400, 500, 600, 700 ) ),
			'Roboto Slab'        => array( 'category' => 'serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Source Serif 4'     => array( 'category' => 'serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'PT Serif'           => array( 'category' => 'serif', 'weights' => array( 400, 700 ) ),
			'Cormorant Garamond' => array( 'category' => 'serif', 'weights' => array( 300, 400, 500, 600, 700 ) ),
			'EB Garamond'        => array( 'category' => 'serif', 'weights' => array( 400, 500, 600, 700, 800 ) ),
			'Libre Baskerville'  => array( 'category' => 'serif', 'weights' => array( 400, 700 ) ),
			'Bitter'             => array( 'category' => 'serif', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'Spectral'           => array( 'category' => 'serif', 'weights' => array( 200, 300, 400, 500, 600, 700, 800 ) ),
			'Crimson Text'       => array( 'category' => 'serif', 'weights' => array( 400, 600, 700 ) ),
			// Monospace
			'JetBrains Mono'  => array( 'category' => 'monospace', 'weights' => array( 100, 200, 300, 400, 500, 600, 700, 800 ) ),
			'Fira Code'       => array( 'category' => 'monospace', 'weights' => array( 300, 400, 500, 600, 700 ) ),
			'Roboto Mono'     => array( 'category' => 'monospace', 'weights' => array( 100, 200, 300, 400, 500, 600, 700 ) ),
			'Source Code Pro' => array( 'category' => 'monospace', 'weights' => array( 200, 300, 400, 500, 600, 700, 800, 900 ) ),
			'IBM Plex Mono'   => array( 'category' => 'monospace', 'weights' => array( 100, 200, 300, 400, 500, 600, 700 ) ),
			'Space Mono'      => array( 'category' => 'monospace', 'weights' => array( 400, 700 ) ),
			'Inconsolata'     => array( 'category' => 'monospace', 'weights' => array( 200, 300, 400, 500, 600, 700, 800, 900 ) ),
		);
	}

	/**
	 * Checks a Google font BY NAME and discovers its available weights by asking css2.
	 *
	 * It exists so that ANY font can be used (not only the curated catalogue) without producing
	 * broken URLs: css2 answers 400 if an unavailable weight is requested, so we try from the
	 * widest weight ladder down until the response is valid.
	 *
	 * @return array{ok:bool,family:string,category?:string,weights?:int[]}
	 */
	public static function probe_google_font( $raw_family ) {
		$family = self::clean_family( $raw_family );
		if ( '' === $family ) {
			return array( 'ok' => false, 'family' => '' );
		}

		// Already in the curated catalogue: use its data, with no HTTP call.
		$catalog = self::google_font_catalog();
		if ( isset( $catalog[ $family ] ) ) {
			return array(
				'ok'       => true,
				'family'   => $family,
				'category' => $catalog[ $family ]['category'],
				'weights'  => $catalog[ $family ]['weights'],
			);
		}

		$ladders = array(
			array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ),
			array( 300, 400, 500, 600, 700 ),
			array( 400, 700 ),
			array( 400 ),
		);

		foreach ( $ladders as $weights ) {
			if ( self::css2_available( $family, $weights ) ) {
				return array(
					'ok'       => true,
					'family'   => $family,
					'category' => self::guess_category( $family ),
					'weights'  => $weights,
				);
			}
		}

		// It exists, but with non-standard weights: at least the regular.
		if ( self::css2_available( $family, array() ) ) {
			return array(
				'ok'       => true,
				'family'   => $family,
				'category' => self::guess_category( $family ),
				'weights'  => array( 400 ),
			);
		}

		return array( 'ok' => false, 'family' => $family );
	}

	/** The brand's fonts: an ordered, normalised list. */
	public static function fonts() {
		return apply_filters( 'aipb_brand_fonts', self::sanitize_fonts( get_option( self::OPT_FONTS, array() ) ) );
	}

	/**
	 * Settings API callback for the fonts option.
	 *
	 * Cleans names and weights, and derives unique CSS slugs (used as --aipb-font-SLUG). A supplied
	 * slug is preserved when valid: renaming a font does not change the variable already used in
	 * the pages.
	 */
	public static function sanitize_fonts( $input ) {
		if ( ! is_array( $input ) ) {
			return array();
		}

		$catalog    = self::google_font_catalog();
		$categories = array_keys( self::font_fallbacks() );
		$out        = array();
		$seen       = array();

		foreach ( $input as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			// Family: a safe charset (letters, digits, spaces) — it keeps injection out of the css2
			// URL and out of the CSS.
			$family = self::clean_family( isset( $row['family'] ) ? $row['family'] : '' );
			if ( '' === $family ) {
				continue;
			}

			$known = isset( $catalog[ $family ] );

			// Category: from the catalogue when known; otherwise the supplied one (allow-listed) or
			// the heuristic.
			if ( $known ) {
				$category = $catalog[ $family ]['category'];
			} else {
				$category = isset( $row['category'] ) ? (string) $row['category'] : '';
				if ( ! in_array( $category, $categories, true ) ) {
					$category = self::guess_category( $family );
				}
			}

			$name = isset( $row['name'] ) ? trim( wp_strip_all_tags( (string) $row['name'] ) ) : '';

			$out[] = array(
				'slug'     => self::unique_slug( isset( $row['slug'] ) ? (string) $row['slug'] : '', array( $name, $family ), 'font', $seen ),
				'name'     => $name,
				'family'   => $family,
				'category' => $category,
				'weights'  => self::clean_weights( isset( $row['weights'] ) ? $row['weights'] : array(), $known ? $catalog[ $family ]['weights'] : null ),
			);
		}

		return $out;
	}

	/**
	 * The Google Fonts (css2) stylesheet URL for every imported family, or '' when there are none.
	 *
	 * Families are deduplicated and weights sorted.
	 */
	public static function google_fonts_url() {
		$families = array();

		foreach ( self::fonts() as $font ) {
			if ( ! isset( $families[ $font['family'] ] ) ) {
				$families[ $font['family'] ] = array();
			}
			foreach ( $font['weights'] as $weight ) {
				$families[ $font['family'] ][ $weight ] = true;
			}
		}

		if ( ! $families ) {
			return '';
		}

		$parts = array();
		foreach ( $families as $family => $weight_set ) {
			$weights = array_keys( $weight_set );
			sort( $weights );

			$parts[] = 'family=' . str_replace( ' ', '+', $family ) . ':wght@' . implode( ';', $weights );
		}

		return 'https://fonts.googleapis.com/css2?' . implode( '&', $parts ) . '&display=swap';
	}

	/* ---------------------------------- Tokens --------------------------------- */

	/** The flat map of default values: "group:key" => value. */
	public static function flat_defaults() {
		$out = array();

		foreach ( self::defaults() as $group_key => $group ) {
			foreach ( $group['items'] as $item_key => $item ) {
				$out[ $group_key . ':' . $item_key ] = $item[0];
			}
		}

		return $out;
	}

	/**
	 * Which areas of the design system the user has already decided.
	 *
	 * It exists for whoever produces COMPETING guidance — typically the values measured off a
	 * reference, which end up in every section's prompt. Between the two, this one wins: the design
	 * system is the user's explicit choice, a measurement is an inference over an image. Where the
	 * user defined a token, the measurement has to keep quiet and let the variable be used.
	 *
	 * "DECIDED" MEANS "DIFFERENT FROM WHAT WE SHIP", and the distinction is the whole point. Since the
	 * structural tokens now carry a scale of their own ({@see defaults()}), every group HAS a value —
	 * so asking merely whether one is present would answer "the user decided everything" on a site
	 * where the user has decided nothing, and the numbers measured off a mockup would be dropped in
	 * silence. That would quietly break the one feature whose entire promise is fidelity: «reproduce
	 * this reference». A default is what we fall back on, not a choice somebody made.
	 *
	 * The comparison is against the effective value rather than against the stored option so that a
	 * token set in code, through the `aipb_design_tokens` filter, counts as decided too: it is a
	 * developer's explicit choice just as much as a value typed on the page.
	 *
	 * @return array group => bool, plus 'colors' and 'fonts'.
	 */
	public static function defined_areas() {
		$shipped = self::defaults();
		$out     = array();

		foreach ( self::groups() as $group_key => $group ) {
			$has = false;
			foreach ( $group['items'] as $item_key => $item ) {
				$default = isset( $shipped[ $group_key ]['items'][ $item_key ][0] )
					? $shipped[ $group_key ]['items'][ $item_key ][0]
					: '';
				if ( '' !== $item[0] && $item[0] !== $default ) {
					$has = true;
					break;
				}
			}
			$out[ $group_key ] = $has;
		}

		$out['colors'] = ! empty( self::palette() );
		$out['fonts']  = ! empty( self::fonts() );

		return $out;
	}

	/** Sanitises a token's CSS value (keeps it from breaking out of the <style> block). */
	public static function sanitize_value( $value ) {
		$value = wp_strip_all_tags( (string) $value );
		$value = preg_replace( '/[<>{};]/', '', $value );

		return trim( $value );
	}

	/**
	 * Normalises a STRUCTURAL token value by adding the unit a bare number is missing.
	 *
	 * Every structural token is a CSS dimension: a bare number ("1200", "16") is invalid as
	 * `max-width`/`font-size` and the browser throws it away. Anything that already carries a unit
	 * or a function (px, rem, %, clamp()…) is left untouched.
	 *
	 * WHICH unit depends on which token, and that is why the id travels with the value: a naked
	 * number is px for a length, ms for a motion time (a "400" typed for duration-base means
	 * 400ms, and "400px" would invalidate the declaration just as surely as no unit at all). The
	 * easing token gets nothing sewn on — no unit could make a bare number a valid timing
	 * function. Call sites that have no id fall back to px, which is every token that existed
	 * before motion did.
	 *
	 * @param string $value the raw value
	 * @param string $id    the "group:key" id of the token, when the caller knows it
	 */
	public static function with_unit( $value, $id = '' ) {
		$value = trim( (string) $value );

		if ( '' === $value || ! preg_match( '/^-?\d+(?:\.\d+)?$/', $value ) ) {
			return $value;
		}
		if ( 'motion:ease' === $id ) {
			return $value;
		}
		if ( preg_match( '/^motion:(duration|stagger)/', (string) $id ) ) {
			return $value . 'ms';
		}

		return $value . 'px';
	}

	/**
	 * Every token with its value: '--aipb-color-brand' => '#1f3a5f'.
	 *
	 * The single source for the `:root` block and for the fallback values sewn into the sections
	 * (see add_fallbacks). Keeping only one avoids the case where the page's CSS declares a
	 * variable and the fallback stitched into the section says something else — a fault that stays
	 * invisible until the token is deleted, which is exactly when it needs to be right.
	 *
	 * @return array<string,string>
	 */
	public static function token_map() {
		$out = array();

		// Brand palette -> --aipb-color-SLUG.
		foreach ( self::palette() as $colour ) {
			$out[ '--aipb-color-' . $colour['slug'] ] = $colour['value'];
		}

		// Brand fonts -> --aipb-font-SLUG.
		foreach ( self::fonts() as $font ) {
			$out[ '--aipb-font-' . $font['slug'] ] = self::font_stack( $font['family'], $font['category'] );
		}

		// Structural tokens. The ones with no value (empty default and no override) are NOT
		// emitted: no empty declarations.
		foreach ( self::groups() as $group_key => $group ) {
			foreach ( $group['items'] as $key => $item ) {
				if ( '' !== $item[0] ) {
					$out[ $group['prefix'] . $key ] = self::with_unit( $item[0], $group_key . ':' . $key );
				}
			}
		}

		return $out;
	}

	/** The ':root{ --aipb-*: value; }' block, with every token. */
	public static function css_variables() {
		$declarations = array();

		foreach ( self::token_map() as $variable => $value ) {
			$declarations[] = $variable . ':' . $value;
		}

		return ':root{' . implode( ';', $declarations ) . '}';
	}

	/**
	 * Sews into every `var(--aipb-…)` the value the token has RIGHT NOW, as a fallback.
	 *
	 * WHAT IT SOLVES, and it is not a hypothesis. On 9 August 2026 a brand reset wiped the tokens
	 * of a site with six published pages. Those pages held 262 references to `var(--aipb-…)` and
	 * ZERO fallbacks: in CSS an undefined var() with no fallback invalidates the whole declaration,
	 * so the spacings went to 0, the colours went out, the body text collapsed. The pages did not
	 * degrade: they evaporated. And regenerating them did not help, because the model writes the
	 * tokens even when the site has none.
	 *
	 * WHY THE FALLBACK DOES NO HARM. `var(--x, fallback)` uses the fallback ONLY when `--x` does
	 * not exist. While the token is there, changing the palette keeps repainting every page as
	 * before: the sewn-in value stays inert. It does not freeze the design, it freezes the safety
	 * net.
	 *
	 * WHY ON SAVE AND NOT AT RENDER TIME. At render time the value would be taken from the tokens
	 * of that moment — which, in the case that matters, are gone. A fallback is only worth anything
	 * if it is fixed while the section and the design system still agree, that is, now.
	 *
	 * It touches ONLY bare var()s: one that already has its own fallback is left alone, because
	 * whoever wrote the section chose that and it is worth more than this.
	 */
	public static function add_fallbacks( $code ) {
		$code = (string) $code;
		if ( false === strpos( $code, 'var(' ) ) {
			return $code;
		}

		$map = self::token_map();
		if ( ! $map ) {
			// No tokens defined: there is nothing to sew in. It happens on a brand new site — and
			// it also happens right after a reset, which is the moment NOT to write empty fallbacks
			// over the good ones.
			return $code;
		}

		return preg_replace_callback( '/var\(\s*(--aipb-[a-z0-9_-]+)\s*\)/i', function ( $m ) use ( $map ) {
			return isset( $map[ $m[1] ] ) ? 'var(' . $m[1] . ', ' . $map[ $m[1] ] . ')' : $m[0];
		}, $code );
	}

	/* ------------------- Resetting: who depends on it, and the undo ------------- */

	/**
	 * The content that leans on the tokens, and how many BARE references each has.
	 *
	 * "Bare" is the word that matters: after add_fallbacks() a `var(--x, value)` survives a reset
	 * without moving a pixel, while a `var(--x)` with no fallback invalidates the whole
	 * declaration. Counting every reference would say "262 uses!" even about a perfectly safe site
	 * — and an alarm that always shouts is an alarm nobody reads.
	 *
	 * @return list<array{id:int,title:string,type:string,bare:int}> only what is really at risk, most exposed first
	 */
	public static function dependents() {
		global $wpdb;

		$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
			"SELECT pm.post_id, pm.meta_value, p.post_title, p.post_type
			   FROM {$wpdb->postmeta} pm
			   JOIN {$wpdb->posts} p ON p.ID = pm.post_id
			  WHERE pm.meta_key IN ('_aipb_blocks','_aipb_template')
			    AND pm.meta_value LIKE '%--aipb-%'
			    AND p.post_type <> 'revision'
			    AND p.post_status NOT IN ('trash','auto-draft')"
		);

		$out = array();
		foreach ( (array) $rows as $row ) {
			$bare = preg_match_all( '/var\(\s*--aipb-[a-z0-9_-]+\s*\)/i', (string) $row->meta_value );
			if ( ! $bare ) {
				continue;
			}

			$out[] = array(
				'id'    => (int) $row->post_id,
				'title' => (string) $row->post_title,
				'type'  => (string) $row->post_type,
				'bare'  => (int) $bare,
			);
		}

		usort( $out, static function ( $a, $b ) {
			return $b['bare'] <=> $a['bare'];
		} );

		return $out;
	}

	/**
	 * How many contents refer to each token, and which ones.
	 *
	 * WHY IT IS WORTH A QUERY. Editing `--aipb-space-2xl` is either a typo away from nothing or a
	 * change to the vertical rhythm of forty published sections, and until now the page gave the
	 * same silence for both. The number beside the field is the difference between changing a value
	 * and changing a site.
	 *
	 * It counts BOTH kinds of reference — bare and with a fallback — because both repaint when the
	 * token moves. That is the opposite of {@see dependents()}, which counts only the bare ones: it
	 * answers a different question («what breaks if this disappears»), and there a fallback is
	 * precisely what makes a reference safe.
	 *
	 * Cached for a few minutes: it is read once per page load, over content that changes when
	 * somebody generates a section, and a stale count of 12 where the truth is 13 misleads nobody.
	 *
	 * @return array<string,array{n:int,posts:list<array{id:int,title:string}>}> keyed by variable
	 */
	public static function token_usage() {
		$cached = get_transient( self::USAGE_CACHE );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		global $wpdb;

		$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
			"SELECT pm.post_id, pm.meta_value, p.post_title
			   FROM {$wpdb->postmeta} pm
			   JOIN {$wpdb->posts} p ON p.ID = pm.post_id
			  WHERE pm.meta_key IN ('_aipb_blocks','_aipb_template')
			    AND pm.meta_value LIKE '%--aipb-%'
			    AND p.post_type <> 'revision'
			    AND p.post_status NOT IN ('trash','auto-draft')"
		);

		$out = array();

		foreach ( (array) $rows as $row ) {
			if ( ! preg_match_all( '/var\(\s*(--aipb-[a-z0-9_-]+)/i', (string) $row->meta_value, $found ) ) {
				continue;
			}

			// One content that uses a token forty times is still ONE content: the number people act
			// on is «how many pages does this touch», not «how many declarations».
			foreach ( array_unique( $found[1] ) as $variable ) {
				$variable = strtolower( $variable );

				if ( ! isset( $out[ $variable ] ) ) {
					$out[ $variable ] = array( 'n' => 0, 'posts' => array() );
				}

				$out[ $variable ]['n']++;

				// Enough to name names in a tooltip without carrying a whole site in an option.
				if ( count( $out[ $variable ]['posts'] ) < 8 ) {
					$out[ $variable ]['posts'][] = array(
						'id'    => (int) $row->post_id,
						'title' => (string) $row->post_title,
					);
				}
			}
		}

		set_transient( self::USAGE_CACHE, $out, 5 * MINUTE_IN_SECONDS );

		return $out;
	}

	/* ------------------------------- Brand version ----------------------------- */

	/** The brand's version. Starts at 1 on a site that has never touched anything. */
	public static function version() {
		return max( 1, (int) get_option( self::OPT_VERSION, 1 ) );
	}

	/**
	 * Watches the brand's options. Registered once at boot (see aipb_init): every write to any of
	 * them — from the Brand form, a REST adoption, an import, a reset's delete — bumps the version
	 * through the same three hooks, whoever the writer was. That is the property that makes the
	 * version trustworthy: there is no code path that changes the brand and forgets to say so.
	 */
	public static function init() {
		$bump = array( __CLASS__, 'maybe_bump_version' );
		add_action( 'added_option', $bump );
		add_action( 'updated_option', $bump );
		add_action( 'deleted_option', $bump );
	}

	/** @param string $option the option WordPress just wrote or deleted */
	public static function maybe_bump_version( $option ) {
		$watched = array( self::OPT_TOKENS, self::OPT_CUSTOM, self::OPT_PALETTE, self::OPT_FONTS, self::OPT_DIRECTION, self::OPT_MANIFEST );
		if ( class_exists( 'AIPB_Effects' ) ) {
			$watched[] = AIPB_Effects::OPT;
		}

		if ( in_array( (string) $option, $watched, true ) ) {
			self::bump_version();
		}
	}

	/** One more. Everything keyed on the old number is stale from this line on. */
	public static function bump_version() {
		// Autoloaded: the effects cache compares against it on every front-end page.
		update_option( self::OPT_VERSION, self::version() + 1, true );
		delete_transient( self::USAGE_CACHE );
	}

	/* --------------------------------- Snapshots ------------------------------- */

	/**
	 * Everything that makes up the design system, in one array.
	 *
	 * The single definition of "the whole thing", used by the snapshots, the export and the import.
	 * Anything added to the brand later has to be added here once and is then carried by all three.
	 */
	public static function bundle() {
		return array(
			'tokens'    => get_option( self::OPT_TOKENS, array() ),
			'custom'    => get_option( self::OPT_CUSTOM, array() ),
			'palette'   => get_option( self::OPT_PALETTE, array() ),
			'fonts'     => get_option( self::OPT_FONTS, array() ),
			'direction' => get_option( self::OPT_DIRECTION, '' ),
			// Only the USER'S effects: the shipped ones live in code and move with the plugin —
			// exporting them would freeze today's copy into every file.
			'effects'   => class_exists( 'AIPB_Effects' ) ? AIPB_Effects::user_effects() : array(),
			'manifest'  => get_option( self::OPT_MANIFEST, array() ),
			// Informational in a file, decisive in memory: it is what the caches key on.
			'version'   => self::version(),
		);
	}

	/**
	 * Writes a bundle back, through the same sanitisers a form submission goes through.
	 *
	 * NOT A BLIND update_option: a bundle can come from a file somebody was given, and a design
	 * system read off disk gets exactly as much trust as one typed into the page — which is none
	 * until it has been cleaned.
	 */
	public static function restore_bundle( $bundle ) {
		if ( ! is_array( $bundle ) ) {
			return false;
		}

		if ( isset( $bundle['custom'] ) ) {
			update_option( self::OPT_CUSTOM, self::sanitize_custom( $bundle['custom'] ), false );
		}
		if ( isset( $bundle['tokens'] ) && is_array( $bundle['tokens'] ) ) {
			// The values are filtered against the declarations that have just been written, so a
			// bundle cannot smuggle in a token nobody declared.
			$known = self::flat_defaults();
			foreach ( self::custom_tokens() as $token ) {
				$known[ $token['group'] . ':' . $token['key'] ] = '';
			}

			$values = array();
			foreach ( $bundle['tokens'] as $id => $value ) {
				$id    = sanitize_text_field( (string) $id );
				$value = self::with_unit( self::sanitize_value( $value ), $id );
				if ( '' !== $value && isset( $known[ $id ] ) ) {
					$values[ $id ] = $value;
				}
			}

			update_option( self::OPT_TOKENS, $values, false );
		}
		if ( isset( $bundle['palette'] ) ) {
			update_option( self::OPT_PALETTE, self::sanitize_palette( $bundle['palette'] ), false );
		}
		if ( isset( $bundle['fonts'] ) ) {
			update_option( self::OPT_FONTS, self::sanitize_fonts( $bundle['fonts'] ), false );
		}
		if ( isset( $bundle['direction'] ) ) {
			update_option( self::OPT_DIRECTION, self::sanitize_direction( $bundle['direction'] ), false );
		}
		// The new sections of the bundle. An old file simply has not got them, and the isset()
		// leaves what the site has alone — importing a 2025 export must not delete 2026's effects.
		if ( isset( $bundle['effects'] ) && class_exists( 'AIPB_Effects' ) ) {
			update_option( AIPB_Effects::OPT, AIPB_Effects::sanitize( $bundle['effects'], false ), false );
		}
		if ( isset( $bundle['manifest'] ) ) {
			update_option( self::OPT_MANIFEST, self::sanitize_manifest( $bundle['manifest'] ), false );
		}

		return true;
	}

	/** The snapshots, newest first. */
	public static function snapshots() {
		$list = get_option( self::OPT_SNAPSHOTS, array() );

		return is_array( $list ) ? $list : array();
	}

	/**
	 * Takes one, named.
	 *
	 * @return string the snapshot's id
	 */
	public static function snapshot( $label = '' ) {
		$label = trim( wp_strip_all_tags( (string) $label ) );
		$label = function_exists( 'mb_substr' ) ? mb_substr( $label, 0, 60 ) : substr( $label, 0, 60 );

		$list = self::snapshots();
		$id   = uniqid( 's', false );

		array_unshift( $list, array(
			'id'     => $id,
			'label'  => $label,
			'when'   => time(),
			'user'   => get_current_user_id(),
			'bundle' => self::bundle(),
		) );

		update_option( self::OPT_SNAPSHOTS, array_slice( $list, 0, self::SNAPSHOTS_MAX ), false );

		return $id;
	}

	/**
	 * Goes back to one.
	 *
	 * It takes a snapshot of where you are FIRST. Restoring is itself a change worth undoing, and
	 * finding that out afterwards is the whole reason this feature exists.
	 */
	public static function restore_snapshot( $id ) {
		foreach ( self::snapshots() as $snap ) {
			if ( isset( $snap['id'] ) && $snap['id'] === $id ) {
				self::snapshot( __( 'before restoring', 'aipb' ) );

				return self::restore_bundle( $snap['bundle'] );
			}
		}

		return false;
	}

	/** Throws one away. */
	public static function forget_snapshot( $id ) {
		$list = array_values( array_filter( self::snapshots(), static function ( $snap ) use ( $id ) {
			return ! isset( $snap['id'] ) || $snap['id'] !== $id;
		} ) );

		update_option( self::OPT_SNAPSHOTS, $list, false );

		return true;
	}

	/** Puts the current tokens aside, so a reset can be undone. */
	public static function save_backup() {
		self::snapshot( __( 'before the reset', 'aipb' ) );

		update_option( self::OPT_BACKUP, array(
			'when'    => time(),
			'tokens'  => get_option( self::OPT_TOKENS, array() ),
			'palette' => get_option( self::OPT_PALETTE, array() ),
			'fonts'   => get_option( self::OPT_FONTS, array() ),
			'dir'     => get_option( self::OPT_DIRECTION, '' ),
			'custom'  => get_option( self::OPT_CUSTOM, array() ),
		), false );
	}

	/** The copy that was put aside, or null when there is none. */
	public static function backup() {
		$backup = get_option( self::OPT_BACKUP, null );

		return ( is_array( $backup ) && isset( $backup['when'] ) ) ? $backup : null;
	}

	/**
	 * Puts the tokens back as they were before the reset.
	 *
	 * @return bool whether there was anything to put back
	 */
	public static function restore_backup() {
		$backup = self::backup();
		if ( ! $backup ) {
			return false;
		}

		update_option( self::OPT_TOKENS, $backup['tokens'], false );
		update_option( self::OPT_PALETTE, $backup['palette'], false );
		update_option( self::OPT_FONTS, $backup['fonts'], false );
		update_option( self::OPT_DIRECTION, $backup['dir'], false );

		// Older copies do not carry the declarations: putting an empty list back would delete
		// tokens the reset never touched.
		if ( isset( $backup['custom'] ) ) {
			update_option( self::OPT_CUSTOM, $backup['custom'], false );
		}

		delete_option( self::OPT_BACKUP );

		return true;
	}

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

	/**
	 * A unique CSS slug, preferring the one that was supplied.
	 *
	 * @param string       $wanted    the slug from the input, if any
	 * @param list<string> $fallbacks what to derive one from when there is none
	 * @param string       $last      the last resort, when everything else is empty
	 * @param array        $seen      slugs already taken, updated in place
	 */
	protected static function unique_slug( $wanted, $fallbacks, $last, &$seen ) {
		$slug = sanitize_title( $wanted );

		foreach ( $fallbacks as $candidate ) {
			if ( '' !== $slug ) {
				break;
			}
			$slug = sanitize_title( (string) $candidate );
		}

		if ( '' === $slug ) {
			$slug = $last;
		}

		$base   = $slug;
		$suffix = 2;
		while ( isset( $seen[ $slug ] ) ) {
			$slug = $base . '-' . $suffix;
			$suffix++;
		}

		$seen[ $slug ] = true;

		return $slug;
	}

	/**
	 * Font weights: integers 100..900.
	 *
	 * @param mixed           $raw     what came in
	 * @param list<int>|null  $catalog the catalogue's weights, when the family is known: outside
	 *                                 the catalogue we trust what was supplied, since it was
	 *                                 validated at install time
	 */
	protected static function clean_weights( $raw, $catalog ) {
		$out = array();

		if ( is_array( $raw ) ) {
			foreach ( $raw as $weight ) {
				$weight = (int) $weight;

				if ( $weight < 100 || $weight > 900 ) {
					continue;
				}
				if ( null !== $catalog && ! in_array( $weight, $catalog, true ) ) {
					continue;
				}
				if ( ! in_array( $weight, $out, true ) ) {
					$out[] = $weight;
				}
			}
		}

		if ( ! $out ) {
			$out = array( 400 );
		}

		sort( $out );

		return $out;
	}

	/** A font family reduced to a safe charset. */
	protected static function clean_family( $raw ) {
		return trim( preg_replace( '/\s+/', ' ', preg_replace( '/[^A-Za-z0-9 ]/', '', (string) $raw ) ) );
	}

	/** True when the css2 sheet for (family, weights) exists and contains @font-face. */
	protected static function css2_available( $family, $weights ) {
		$url = 'https://fonts.googleapis.com/css2?family=' . str_replace( ' ', '+', $family );
		if ( ! empty( $weights ) ) {
			$url .= ':wght@' . implode( ';', $weights );
		}

		$response = wp_remote_get( $url, array(
			'timeout' => 10,
			'headers' => array( 'User-Agent' => class_exists( 'AIPB_Fonts' ) ? AIPB_Fonts::UA : 'Mozilla/5.0' ),
		) );

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return false;
		}

		return false !== strpos( wp_remote_retrieve_body( $response ), '@font-face' );
	}

	/** A best-effort category for a font outside the catalogue, only for the fallback stack. */
	protected static function guess_category( $family ) {
		$lower = strtolower( $family );

		if ( false !== strpos( $lower, 'mono' ) ) {
			return 'monospace';
		}
		if ( false !== strpos( $lower, 'slab' ) ) {
			return 'serif';
		}
		if ( false !== strpos( $lower, 'serif' ) && false === strpos( $lower, 'sans' ) ) {
			return 'serif';
		}
		if ( false !== strpos( $lower, 'script' ) || false !== strpos( $lower, 'hand' ) ) {
			return 'handwriting';
		}

		return 'sans-serif';
	}
}
