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

/**
 * The brand's EFFECTS: named entrances, defined once and referenced everywhere.
 *
 * WHAT IT SOLVES. The reveal engine (see AIPB_Render::motion_css) gives every section the same
 * trigger, but a bespoke animation — the image that unclips itself, the heading that sharpens out
 * of a blur — used to be written INSIDE the section that wanted it. Two sections wanting the same
 * entrance got two copies, each with its own duration and easing, and the brand's most
 * recognisable gesture became the least consistent thing on the site. An effect defined here is
 * one @keyframes, one initial state, one place to change it — and a section uses it with a single
 * attribute: data-aura-reveal="image-reveal".
 *
 * THE CSS IS CONSTRAINED, and the constraints are the feature. An effect's CSS may only address
 * its own attribute and its own keyframes, and it may not write a literal duration or easing:
 * those must be the motion tokens (--aipb-motion-*). That is what makes a NEW effect sound like
 * the brand the moment it is born — it inherits the brand's tempo by construction — and what
 * makes "make the animations snappier" a one-token edit that retunes every effect at once.
 *
 * NOTHING IS HIDDEN UNLESS THE RUNTIME IS ALIVE. The compiler prefixes every rule with
 * [data-aura-motion], the guard the reveal JS writes only after checking it can reveal (same
 * contract as the presets, see motion_css). An effect author does not have to know that rule
 * exists — it is applied for them, because forgetting it does not look like a bug, it looks like
 * an empty page.
 *
 * Shipped effects live in code, like the structural token defaults: present on every site from
 * day one, and they move with the plugin. The user's own live in an option and are merged in —
 * a custom effect is not a second-class effect. `approved` and `created_by` are carried for the
 * flow where the AI proposes effects (not wired yet): everything here compiles regardless.
 */
class AIPB_Effects {

	/** The user-defined effects: a list of effect rows. */
	const OPT = 'aipb_brand_effects';

	/** One effect's CSS has no business being longer than this. */
	const CSS_MAX = 6000;

	/** How many effects a site can define. Enough for a vocabulary; not enough for a junk drawer. */
	const MAX = 24;

	/** The preset names of the reveal engine: an effect may not shadow them. */
	const RESERVED = array( 'fade', 'up', 'down', 'left', 'right', 'zoom' );

	/**
	 * The effects that ship with the plugin.
	 *
	 * Their durations and easings are the motion tokens with the token's own shipped value as the
	 * fallback, so they read correctly even on a page whose tokens block never rendered.
	 *
	 * @return array<string,array{label:string,applies_to:list<string>,usage:string,css:string}>
	 */
	protected static function shipped() {
		return array(
			'image-reveal' => array(
				'label'      => __( 'Image reveal', 'aipb' ),
				'applies_to' => array( 'img', 'figure', 'video' ),
				'usage'      => 'cinematic unclip for media entering the viewport; the signature entrance for images',
				'css'        => '@keyframes fx-image-reveal{from{clip-path:inset(100% 0 0 0);transform:scale(1.06)}to{clip-path:inset(0 0 0 0);transform:scale(1)}}'
					. '[data-aura-reveal="image-reveal"]{clip-path:inset(100% 0 0 0)}'
					. '[data-aura-reveal="image-reveal"][data-aura-in]{clip-path:inset(0 0 0 0);animation:fx-image-reveal var(--aipb-motion-duration-slow,.9s) var(--aipb-motion-ease,cubic-bezier(.22,.61,.36,1)) var(--aura-delay,0s) both}',
			),
			'blur-in' => array(
				'label'      => __( 'Blur in', 'aipb' ),
				'applies_to' => array( 'h1', 'h2', 'blockquote' ),
				'usage'      => 'a heading or pull-quote sharpening into focus; use sparingly, one per section at most',
				'css'        => '[data-aura-reveal="blur-in"]{opacity:0;filter:blur(12px);transition:opacity var(--aipb-motion-duration-base,.6s) var(--aipb-motion-ease,cubic-bezier(.22,.61,.36,1)) var(--aura-delay,0s),filter var(--aipb-motion-duration-base,.6s) var(--aipb-motion-ease,cubic-bezier(.22,.61,.36,1)) var(--aura-delay,0s)}'
					. '[data-aura-reveal="blur-in"][data-aura-in]{opacity:1;filter:blur(0)}',
			),
		);
	}

	/**
	 * Every effect, shipped first, each row normalised.
	 *
	 * @return list<array{id:string,label:string,applies_to:list<string>,usage:string,css:string,approved:bool,created_by:string,shipped:bool}>
	 */
	public static function all() {
		$out = array();

		foreach ( self::shipped() as $id => $effect ) {
			$out[] = array(
				'id'         => $id,
				'label'      => (string) $effect['label'],
				'applies_to' => $effect['applies_to'],
				'usage'      => (string) $effect['usage'],
				'css'        => (string) $effect['css'],
				'approved'   => true,
				'created_by' => 'aura',
				'shipped'    => true,
			);
		}

		foreach ( self::user_effects() as $effect ) {
			$effect['shipped'] = false;
			$out[]             = $effect;
		}

		return apply_filters( 'aipb_brand_effects', $out );
	}

	/**
	 * The user's own effects, cleaned on the way out.
	 *
	 * @return list<array{id:string,label:string,applies_to:list<string>,usage:string,css:string,approved:bool,created_by:string}>
	 */
	public static function user_effects() {
		$raw = get_option( self::OPT, array() );

		return is_array( $raw ) ? self::sanitize( $raw, false ) : array();
	}

	/**
	 * Settings API callback for the option.
	 *
	 * A row whose CSS fails validation is DROPPED, not repaired: a repaired effect is an effect
	 * that behaves differently from what its author wrote, and nobody finds out until it plays.
	 * The rejection is said out loud through add_settings_error, so the save is not silent.
	 *
	 * @param mixed $input
	 * @param bool  $report whether to surface rejections as settings errors (false when reading)
	 */
	public static function sanitize( $input, $report = true ) {
		if ( ! is_array( $input ) ) {
			return array();
		}

		$taken = array_merge( self::RESERVED, array_keys( self::shipped() ) );
		$out   = array();

		foreach ( $input as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			if ( count( $out ) >= self::MAX ) {
				break;
			}

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

			$css    = (string) ( isset( $row['css'] ) ? $row['css'] : '' );
			$errors = self::validate_css( $id, $css );
			if ( $errors ) {
				if ( $report && function_exists( 'add_settings_error' ) ) {
					add_settings_error(
						self::OPT,
						'aipb_fx_' . $id,
						/* translators: 1: the effect's id, 2: what was wrong with it. */
						sprintf( __( 'The effect "%1$s" was not saved: %2$s', 'aipb' ), $id, implode( '; ', $errors ) )
					);
				}
				continue;
			}

			$taken[] = $id;

			// From the Brand form it arrives as one comma-separated string; from an import or a
			// filter as a list. Both are the same declaration.
			$applies_raw = isset( $row['applies_to'] ) ? $row['applies_to'] : array();
			if ( is_string( $applies_raw ) ) {
				$applies_raw = explode( ',', $applies_raw );
			}

			$applies = array();
			foreach ( (array) $applies_raw as $target ) {
				$target = trim( (string) preg_replace( '/[^a-z0-9 .\->_]/i', '', (string) $target ) );
				if ( '' !== $target && count( $applies ) < 5 ) {
					$applies[] = $target;
				}
			}

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

			$out[] = array(
				'id'         => $id,
				'label'      => function_exists( 'mb_substr' ) ? mb_substr( $label, 0, 60 ) : substr( $label, 0, 60 ),
				'applies_to' => $applies,
				'usage'      => function_exists( 'mb_substr' ) ? mb_substr( $usage, 0, 200 ) : substr( $usage, 0, 200 ),
				'css'        => trim( $css ),
				'approved'   => ! isset( $row['approved'] ) || ! empty( $row['approved'] ),
				'created_by' => in_array( isset( $row['created_by'] ) ? $row['created_by'] : '', array( 'ai', 'user' ), true ) ? $row['created_by'] : 'user',
			);
		}

		return $out;
	}

	/* -------------------------------- Validation ------------------------------- */

	/**
	 * What an effect's CSS is allowed to be. Empty list = valid.
	 *
	 * The rules, and why each one is a rejection rather than a repair:
	 *
	 *  - SCOPE. Every selector must address [data-aura-reveal="ID"], every @keyframes must be
	 *    named fx-ID*: an effect that styles anything else is not an effect, it is a stylesheet
	 *    wearing one's name, and it would reach into sections that never asked for it.
	 *  - TEMPO. No literal durations and no literal cubic-bezier curves — those are the motion
	 *    tokens' job. This single rule is what keeps a library of twenty effects sounding like one
	 *    brand instead of twenty. Literals are allowed only INSIDE var() fallbacks, where they are
	 *    inert for as long as the token exists.
	 *  - SAFETY. No @import, no url(), nothing that closes the style element: this CSS is printed
	 *    into the page of every visitor.
	 *
	 * @return list<string> the problems, in words meant for the person who wrote the CSS
	 */
	public static function validate_css( $id, $css ) {
		$errors = array();
		$css    = trim( (string) $css );

		if ( '' === $css ) {
			return array( __( 'the CSS is empty', 'aipb' ) );
		}
		if ( strlen( $css ) > self::CSS_MAX ) {
			$errors[] = __( 'the CSS is too long', 'aipb' );
		}

		// Comments out first: everything below reasons about code, not about prose.
		$clean = (string) preg_replace( '~/\*.*?\*/~s', ' ', $css );

		foreach ( array( '@import', 'url(', 'expression(', 'behavior:', '</' ) as $forbidden ) {
			if ( false !== stripos( $clean, $forbidden ) ) {
				/* translators: %s: the construct that is not allowed, e.g. "@import". */
				$errors[] = sprintf( __( '"%s" is not allowed in an effect', 'aipb' ), $forbidden );
			}
		}

		// Literal tempo, with var() contents (one nesting level, enough for a cubic-bezier
		// fallback) removed so a fallback does not read as a violation.
		$bare = (string) preg_replace( '/var\((?:[^()]|\([^()]*\))*\)/', ' ', $clean );
		if ( preg_match( '/(^|[\s:,(])\.?\d+(?:\.\d+)?(?:ms|s)\b/i', $bare ) ) {
			$errors[] = __( 'literal durations are not allowed: use the motion tokens (var(--aipb-motion-…))', 'aipb' );
		}
		if ( preg_match( '/cubic-bezier\s*\(/i', $bare ) ) {
			$errors[] = __( 'literal easing curves are not allowed: use var(--aipb-motion-ease)', 'aipb' );
		}

		return array_merge( $errors, self::structure_errors( $id, $clean ) );
	}

	/**
	 * Walks the braces and checks every block is one of the shapes an effect may contain.
	 *
	 * @return list<string>
	 */
	protected static function structure_errors( $id, $css ) {
		$errors = array();
		$stack  = array(); // 'group' (@media/@supports) | 'keyframes' | 'frame' | 'rule'
		$buffer = '';
		$length = strlen( $css );

		for ( $i = 0; $i < $length; $i++ ) {
			$char = $css[ $i ];

			if ( '{' === $char ) {
				$prelude = trim( $buffer );
				$buffer  = '';
				$context = $stack ? $stack[ count( $stack ) - 1 ] : 'root';

				if ( 'keyframes' === $context ) {
					$stack[] = 'frame'; // from / to / 50%
					continue;
				}
				if ( 'frame' === $context || 'rule' === $context ) {
					$errors[] = __( 'nested rules are not supported in effects', 'aipb' );
					$stack[]  = 'rule';
					continue;
				}

				if ( 0 === strpos( $prelude, '@' ) ) {
					if ( preg_match( '/^@keyframes\s+(\S+)$/i', $prelude, $m ) ) {
						if ( 0 !== strpos( $m[1], 'fx-' . $id ) ) {
							/* translators: %s: the required prefix of the keyframes name, e.g. "fx-image-reveal". */
							$errors[] = sprintf( __( 'keyframes must be named "%s…"', 'aipb' ), 'fx-' . $id );
						}
						$stack[] = 'keyframes';
					} elseif ( preg_match( '/^@(media|supports)\b/i', $prelude ) ) {
						$stack[] = 'group';
					} else {
						/* translators: %s: the at-rule that was found, e.g. "@font-face". */
						$errors[] = sprintf( __( '"%s" is not allowed in an effect', 'aipb' ), strtok( $prelude, ' ' ) );
						$stack[]  = 'group';
					}
					continue;
				}

				// A plain rule: every selector in the list must address the effect's own attribute.
				foreach ( explode( ',', $prelude ) as $selector ) {
					if ( false === strpos( $selector, '[data-aura-reveal="' . $id . '"' ) ) {
						/* translators: %s: the attribute every selector must contain. */
						$errors[] = sprintf( __( 'every selector must target %s', 'aipb' ), '[data-aura-reveal="' . $id . '"]' );
						break;
					}
				}
				$stack[] = 'rule';
				continue;
			}

			if ( '}' === $char ) {
				if ( ! $stack ) {
					$errors[] = __( 'unbalanced braces', 'aipb' );
					break;
				}
				array_pop( $stack );
				$buffer = '';
				continue;
			}

			$buffer .= $char;
		}

		if ( $stack ) {
			$errors[] = __( 'unbalanced braces', 'aipb' );
		}

		return array_values( array_unique( $errors ) );
	}

	/* -------------------------------- Compilation ------------------------------ */

	/** Where the compiled block is parked, with the brand version it was compiled against. */
	const CSS_CACHE = 'aipb_brand_fx_css';

	/**
	 * Every effect's CSS, guarded, in one block — what #aipb-fx prints.
	 *
	 * The compiler does exactly one transformation: it prefixes every top-level rule with
	 * [data-aura-motion], the attribute the reveal JS writes only after checking it can reveal.
	 * An effect hides content in order to bring it back; behind the guard, a page where the JS
	 * never ran simply shows everything (same contract as motion_css, tested the same way).
	 *
	 * CACHED against the brand's version (see AIPB_Design::OPT_VERSION): validating and walking
	 * every effect on every page load is work whose answer only changes when the brand does, and
	 * the version is the one number that says so. No time-based expiry — a compile keyed on the
	 * right version cannot go stale, only obsolete.
	 *
	 * The fallback sewing (AIPB_Design::add_fallbacks) is applied by the caller at print time,
	 * not here: the values sewn in must be the tokens of that moment.
	 */
	public static function compiled_css() {
		$versioned = class_exists( 'AIPB_Design' ) && function_exists( 'update_option' );
		$version   = $versioned ? AIPB_Design::version() : 0;

		if ( $versioned ) {
			$cached = get_option( self::CSS_CACHE, null );
			if ( is_array( $cached ) && isset( $cached['v'], $cached['css'] ) && (int) $cached['v'] === $version ) {
				return (string) $cached['css'];
			}
		}

		$out = '';
		foreach ( self::all() as $effect ) {
			$out .= self::guard( $effect['css'] ) . "\n";
		}
		$out = trim( $out );

		if ( $versioned ) {
			// Autoloaded: it is read on every front-end page, that is what the cache is for.
			update_option( self::CSS_CACHE, array( 'v' => $version, 'css' => $out ), true );
		}

		return $out;
	}

	/** One effect's CSS with every top-level rule put behind [data-aura-motion]. */
	protected static function guard( $css ) {
		$out    = '';
		$stack  = array();
		$buffer = '';
		$length = strlen( $css );

		for ( $i = 0; $i < $length; $i++ ) {
			$char = $css[ $i ];

			if ( '{' === $char ) {
				$prelude = trim( $buffer );
				$buffer  = '';
				$context = $stack ? $stack[ count( $stack ) - 1 ] : 'root';

				if ( ( 'root' === $context || 'group' === $context ) && '' !== $prelude && 0 !== strpos( $prelude, '@' ) ) {
					$selectors = array();
					foreach ( explode( ',', $prelude ) as $selector ) {
						$selector    = trim( $selector );
						$selectors[] = ( false === strpos( $selector, 'data-aura-motion' ) )
							? '[data-aura-motion] ' . $selector
							: $selector;
					}
					$out    .= implode( ',', $selectors ) . '{';
					$stack[] = 'rule';
					continue;
				}

				$out    .= $prelude . '{';
				$stack[] = preg_match( '/^@keyframes\b/i', $prelude ) ? 'keyframes'
					: ( preg_match( '/^@(media|supports)\b/i', $prelude ) ? 'group'
					: ( 'keyframes' === $context ? 'frame' : 'rule' ) );
				continue;
			}

			if ( '}' === $char ) {
				$out .= trim( $buffer ) . '}';
				$buffer = '';
				if ( $stack ) {
					array_pop( $stack );
				}
				continue;
			}

			$buffer .= $char;
		}

		return $out;
	}

	/* --------------------------------- The index ------------------------------- */

	/**
	 * What the model gets to know about the effects: the id, where it belongs, when to use it.
	 * NEVER the CSS — a model that sees the code copies the code, and a copy is the drift this
	 * whole registry exists to end.
	 *
	 * @return list<array{id:string,applies_to:list<string>,usage:string}>
	 */
	public static function index() {
		$out = array();

		foreach ( self::all() as $effect ) {
			$out[] = array(
				'id'         => $effect['id'],
				'applies_to' => $effect['applies_to'],
				'usage'      => $effect['usage'],
			);
		}

		return $out;
	}
}
