<?php
/**
 * Page Effects framework — shared base for "animate existing page content" addons.
 *
 * A Page Effect is a Gutenberg block placed on a page that, on the front end, augments/animates
 * existing content (a scroll-drawn chalk line, a gallery turned into a filmstrip, …) WITHOUT a full
 * Slider Revolution module. This class is the GLOBAL half (the ADDON half lives in each effect addon):
 * it owns block registration, the per-page meta save/load, and front emission; addons register a
 * "type" that supplies the editor tool + the front runtime.
 *
 * Contract (see harmonization/PAGE-EFFECTS.md):
 *   PHP    : RevSliderPageEffects::register_type($type, $args)
 *   Editor : SR7.PE.registerType($type, {card, tool})        // admin/assets/js/page-effects.js
 *   Front  : SR7.PE.registerRuntime($type, {mount, unmount}) // public/js/page-effects.js
 *
 * Data: each effect instance is keyed by a per-block effectId and stored in the host POST's meta
 * (page-scoped, travels with the post) as { type, data }. Front emission scans the queried page's
 * blocks (works on ALL templates, incl. SR full-page templates that never render post_content).
 *
 * @author    ThemePunch <info@themepunch.com>
 * @copyright 2026 ThemePunch
 */

if(!defined('ABSPATH')) exit();

class RevSliderPageEffects {

	const META_PREFIX	= '_sr7_pe_';			// + effectId  → json { type, data }
	const SHELL_HANDLE	= 'sr7-page-effects';	// editor shell + registry + generic block edit
	const RT_HANDLE		= 'sr7-page-effects-rt';// front runtime bootstrap + registry

	/** @var array<string,array> registered effect types */
	private static $types = [];
	private static $booted = false;

	/**
	 * Register an effect type. Called by each addon (on plugins_loaded, before 'init').
	 *
	 * @param string $type   slug, e.g. 'chalkline'
	 * @param array  $args   block, title, icon, description, runtime (front JS url),
	 *                       editor (editor-tool JS url), sanitize (callable($data):array)
	 */
	public static function register_type($type, $args){
		$type = sanitize_key($type);
		if($type === '' || empty($args['block'])) return false;

		self::$types[$type] = array_merge([
			'type'			=> $type,
			'block'			=> '',
			'title'			=> ucfirst($type),
			'icon'			=> 'edit',
			'description'	=> '',
			'preview'		=> '',	// block-inserter hover-preview image url (falls back to the core SR7 placeholder)
			'runtime'		=> '',	// front runtime script url (registers SR7.PE.runtimes[type])
			'editor'		=> '',	// editor tool script url (registers SR7.PE.types[type])
			'sanitize'		=> null,	// callable to whitelist this type's data on save
			'summary'		=> null,	// callable($data):string → the block card's short label (else generic)
			// Inspector-style effects (e.g. Filmstrip) configure in the block inspector instead of the
			// on-page Recorder, own their block edit() (custom attributes + InspectorControls) and emit
			// their own front markup. Recorder-style effects (e.g. ChalkLine) leave these at the defaults.
			'self_block'	=> false,	// true: the type's editor JS registers the block client-side (skip the generic card)
			'attributes'	=> [],		// extra block attributes (merged with effectId/summary) for the server registration
			'supports'		=> [],		// block supports (e.g. ['align' => ['wide','full']]) — must match the client registration
			'render'		=> null		// front render_callback (defaults to empty: emission handled by front_enqueue)
		], $args);

		self::boot();
		return true;
	}

	public static function get_types(){ return self::$types; }

	private static function boot(){
		if(self::$booted) return;
		self::$booted = true;

		add_action('init', [self::class, 'register_blocks']);
		add_filter('block_categories_all', [self::class, 'category']);
		add_action('wp_enqueue_scripts', [self::class, 'front_enqueue']);
		add_action('enqueue_block_editor_assets', [self::class, 'editor_assets']);
	}

	/**
	 * Register each type's block server-side (dynamic, render '': the front output is emitted by
	 * front_enqueue()). Attributes parsed here so front_enqueue can read effectId from the markup.
	 */
	public static function register_blocks(){
		if(!function_exists('register_block_type')) return;

		foreach(self::$types as $t){
			if(empty($t['block']) || \WP_Block_Type_Registry::get_instance()->is_registered($t['block'])) continue;
			$attrs = array_merge([
				'effectId'	=> ['type' => 'string', 'default' => ''],
				'summary'	=> ['type' => 'string', 'default' => '']
			], (isset($t['attributes']) && is_array($t['attributes'])) ? $t['attributes'] : []);
			$args = [
				'api_version'		=> 3,
				'title'				=> $t['title'],
				'category'			=> 'themepunch',
				'icon'				=> $t['icon'],
				'description'		=> $t['description'],
				'attributes'		=> $attrs,
				// block supports (e.g. align) — must be registered server-side too so the align attribute exists
				// and the front render can honour it; kept in lock-step with the client registerBlockType.
				'supports'			=> (isset($t['supports']) && is_array($t['supports'])) ? $t['supports'] : [],
				// Recorder-style effects emit on the front via front_enqueue() (render stays empty); an
				// inspector-style type supplies its own render_callback (markup baked from its attributes).
				'render_callback'	=> is_callable($t['render'] ?? null) ? $t['render'] : '__return_empty_string'
			];
			register_block_type($t['block'], $args);
		}
	}

	/**
	 * Editor: load the shared shell + each type's editor tool, and hand the shell its config
	 * (the registered types so it can register the blocks client-side).
	 *
	 * Save/get run through the shared SR7 _tpt.ajax helper (the same path every other api.class.php
	 * call uses), so tptools + the SR7.E config it reads in the block editor (ajaxurl + block nonce)
	 * are seeded here as well.
	 */
	public static function editor_assets(){
		if(empty(self::$types)) return;

		// shared SR7 ajax helper (_tpt.ajax) — the shell routes its save/get through the central api.class.php dispatcher
		wp_enqueue_script('_tpt', RS_PLUGIN_URL . 'public/js/libs/tptools.js', [], RS_REVISION, true);
		// shared reusable color picker (solid/alpha/gradient) — SHELL depends on it so SR7.PE.colorField exists
		wp_enqueue_script(self::SHELL_HANDLE . '-cp', RS_PLUGIN_URL . 'admin/assets/js/page-effects-colorpicker.js', [], RS_REVISION, true);
		wp_enqueue_script(self::SHELL_HANDLE, RS_PLUGIN_URL . 'admin/assets/js/page-effects.js', ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-i18n', '_tpt', self::SHELL_HANDLE . '-cp'], RS_REVISION, true);

		// _tpt.ajax reads the endpoint + block nonce from SR7.E in the block editor; seed them (idempotent with the shortcode wizard)
		wp_add_inline_script(self::SHELL_HANDLE, 'window.SR7=window.SR7||{};SR7.E=SR7.E||{};SR7.E.ajaxurl=SR7.E.ajaxurl||' . wp_json_encode(admin_url('admin-ajax.php')) . ';SR7.E.block_nonce=SR7.E.block_nonce||' . wp_json_encode(wp_create_nonce('revslider_actions')) . ';SR7.E.block_editor=true;', 'before');

		$types = [];
		foreach(self::$types as $type => $t){
			$types[$type] = ['type' => $type, 'block' => $t['block'], 'title' => $t['title'], 'icon' => $t['icon'], 'spriteIcon' => isset($t['spriteIcon']) ? $t['spriteIcon'] : '', 'description' => $t['description'], 'preview' => isset($t['preview']) ? $t['preview'] : '', 'self_block' => !empty($t['self_block'])];
			if(!empty($t['editor'])) wp_enqueue_script(self::SHELL_HANDLE . '-' . $type, $t['editor'], [self::SHELL_HANDLE], RS_REVISION, true);
		}
		wp_localize_script(self::SHELL_HANDLE, 'SR7PECfg', [
			'types'		=> $types,
			'fontsUrl'	=> RS_PLUGIN_URL . 'public/css/fonts/'	// bundled icon fonts (Font Awesome, …) for the icon picker / stamps
		]);
		// Inject the SR7 icon sprite into the block editor (outer document) so block-inserter icons can <use> the
		// addon symbols (e.g. #Addon_Filmstrip). The sprite is normally only printed on SR7's own editor pages.
		add_action('admin_footer', [self::class, 'print_sprite']);
	}

	/** Print the SR7 icon sprite (hidden) so Page-Effect block icons can reference its symbols by id. */
	public static function print_sprite(){
		static $done = false;
		if($done || !class_exists('\RevSliderFunctions')) return;
		$done = true;
		echo '<div style="display:none" aria-hidden="true">' . \RevSliderFunctions::get_sprite_svg() . '</div>';
	}

	/**
	 * Front: scan the queried page for any page-effect block, read each one's meta, emit
	 * SR7.E.pageEffects[effectId] = { type, data } and enqueue the bootstrap + each used runtime.
	 */
	public static function front_enqueue(){
		if(is_admin() || !is_singular() || empty(self::$types)) return;
		// Recorder preview: when the editor loads the page in its iframe, suppress the live effect so only
		// the editing overlay draws (otherwise the saved line + the edit preview both show, looking "stuck").
		if(isset($_GET['sr7pe_edit']) && current_user_can('edit_posts')) return;

		$post_id = get_queried_object_id();
		if(!$post_id) return;
		$post = get_post($post_id);
		if(!$post) return;

		$names = [];
		foreach(self::$types as $t) if(!empty($t['block'])) $names[$t['block']] = true;

		$payloads = [];
		foreach(self::collect_effects(parse_blocks($post->post_content), $names) as $eid){
			$eid = sanitize_key($eid);
			if($eid === '' || isset($payloads[$eid])) continue;
			$meta = get_post_meta($post_id, self::META_PREFIX . $eid, true);
			if(empty($meta)) continue;
			$stored = json_decode($meta, true);
			if(!is_array($stored) || empty($stored['type']) || !isset(self::$types[$stored['type']])) continue;
			if(!is_array($stored['data']) || empty($stored['data'])) continue;
			$payloads[$eid] = ['type' => $stored['type'], 'data' => $stored['data']];
		}
		if(empty($payloads)) return;

		wp_enqueue_script(self::RT_HANDLE, RS_PLUGIN_URL . 'public/js/page-effects.js', [], RS_REVISION, true);

		$used = [];
		foreach($payloads as $p) $used[$p['type']] = true;
		foreach(array_keys($used) as $type){
			if(!empty(self::$types[$type]['runtime'])) wp_enqueue_script(self::RT_HANDLE . '-' . $type, self::$types[$type]['runtime'], [self::RT_HANDLE], RS_REVISION, true);
		}

		$js = 'window.SR7=window.SR7||{};SR7.E=SR7.E||{};SR7.E.pageEffects=SR7.E.pageEffects||{};';
		$js .= 'SR7.E.peFontsUrl=' . wp_json_encode(RS_PLUGIN_URL . 'public/css/fonts/') . ';';   // for icon-font stamps
		foreach($payloads as $eid => $p){
			$js .= 'SR7.E.pageEffects[' . wp_json_encode($eid) . ']=' . wp_json_encode($p) . ';';
		}
		wp_add_inline_script(self::RT_HANDLE, $js, 'before');
	}

	/**
	 * Save an effect instance to the host post's meta. The type's own sanitize callable whitelists data.
	 * The AJAX entrypoint lives centrally in RevSliderApi::save_page_effect(); this stays here because
	 * it owns the effect-type registry (sanitize + summary).
	 *
	 * @return array|WP_Error  ['summary' => string] on success
	 */
	public static function save_effect($post_id, $effect_id, $type, $data){
		$post_id	= intval($post_id);
		$effect_id	= sanitize_key($effect_id);
		$type		= sanitize_key($type);
		if(!$post_id || $effect_id === '' || !isset(self::$types[$type])) return new WP_Error('bad_request', __('Bad Request', 'revslider'));
		if(!current_user_can('edit_post', $post_id)) return new WP_Error('forbidden', __('Function only available for administrators', 'revslider'));
		if(!is_array($data)) return new WP_Error('invalid_data', __('Invalid Data', 'revslider'));

		$sanitize	= self::$types[$type]['sanitize'];
		$clean		= is_callable($sanitize) ? call_user_func($sanitize, $data) : [];
		if(!is_array($clean)) $clean = [];

		update_post_meta($post_id, self::META_PREFIX . $effect_id, wp_slash(wp_json_encode(['type' => $type, 'data' => $clean])));
		return ['summary' => self::summarize($type, $clean)];
	}

	/**
	 * Load a stored effect instance from the host post's meta (RevSliderApi::get_page_effect()).
	 *
	 * @return array|WP_Error  ['stored' => array|null] on success
	 */
	public static function get_effect($post_id, $effect_id){
		$post_id	= intval($post_id);
		$effect_id	= sanitize_key($effect_id);
		if(!$post_id || $effect_id === '') return new WP_Error('bad_request', __('Bad Request', 'revslider'));
		if(!current_user_can('edit_post', $post_id)) return new WP_Error('forbidden', __('Function only available for administrators', 'revslider'));

		$meta	= get_post_meta($post_id, self::META_PREFIX . $effect_id, true);
		$stored	= $meta ? json_decode($meta, true) : null;
		return ['stored' => (is_array($stored) ? $stored : null)];
	}

	/** A short human label for the block card. Each type supplies its own summary callable; generic fallback. */
	private static function summarize($type, $data){
		$summ = isset(self::$types[$type]['summary']) ? self::$types[$type]['summary'] : null;
		if(is_callable($summ)){ $s = call_user_func($summ, $data); if(is_string($s) && $s !== '') return $s; }
		return __('configured', 'revslider');
	}

	public static function category($categories){
		foreach($categories as $c) if(isset($c['slug']) && $c['slug'] === 'themepunch') return $categories;
		return array_merge($categories, [['slug' => 'themepunch', 'title' => __('ThemePunch', 'revslider')]]);
	}

	/** Recursively collect effectId attributes from any registered page-effect block. */
	private static function collect_effects($blocks, $names){
		$ids = [];
		foreach((array)$blocks as $b){
			if(isset($b['blockName'], $names[$b['blockName']]) && !empty($b['attrs']['effectId'])) $ids[] = $b['attrs']['effectId'];
			if(!empty($b['innerBlocks'])) $ids = array_merge($ids, self::collect_effects($b['innerBlocks'], $names));
		}
		return $ids;
	}
}
