<?php
/**
 * Shared frontend render pipeline.
 *
 * THE single place where stored builder HTML is prepared for output. All
 * frontend render paths — the canvas template (page.php), the standalone
 * clone template (page-standalone.php), and the theme-template mode (via
 * the `the_content` filter) — must run their HTML through prepare().
 *
 * Before this class existed each template re-implemented a different
 * subset of the steps (kses, shortcode/dynamic resolution, editor-id
 * stripping, styleMap emission), which produced a whole class of parity
 * bugs: pages that looked right in the editor but broke live, raw
 * {{tokens}} and shortcode markers leaking on standalone pages, and
 * editor-only attributes shipping to production.
 *
 * @package Antimanual_Builder
 * @since   0.4.0
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class AMBLD_Render_Pipeline
 *
 * @since 0.4.0
 */
class AMBLD_Render_Pipeline {

	/**
	 * Get the shared rendered-HTML kses allowlist.
	 *
	 * @since  0.4.0
	 * @return array Tag/attribute map in the shape expected by wp_kses().
	 */
	public static function allowed_tags() {
		return require AMBLD_PLUGIN_DIR . 'templates/frontend/rendered-allowed-tags.php';
	}

	/**
	 * Prepare stored builder HTML for frontend output.
	 *
	 * Steps, in order:
	 *  1. Strip editor-only artifacts (data-amb-eid, contenteditable, …) —
	 *     injected by the canvas at runtime, must never publish.
	 *  2. Strip inline <script> blocks (always) and <style> blocks (canvas
	 *     profile) together with their contents. CSS/JS ride the enqueue
	 *     pipeline; scripts in renderedHtml are never legitimate.
	 *  3. wp_kses defense-in-depth pass (canvas profile) with the shared
	 *     allowlist. Runs BEFORE shortcode/dynamic resolution — running it
	 *     after stripped the <script> tags that form plugins (CF7, Gravity
	 *     Forms) emit, leaving forms that render but don't submit.
	 *  4. Resolve shortcode markers.
	 *  5. Resolve dynamic regions ({{tokens}}, [data-amb-dynamic]); the
	 *     resolver escapes all token output itself.
	 *
	 * Profiles:
	 *  - 'canvas' (default): full pipeline. Used by page.php, the block
	 *    fallback, and the theme-template the_content filter.
	 *  - 'standalone': faithful-clone profile for imported pages. Skips
	 *    the kses pass and the <style> strip so verbatim markup saved by
	 *    an `unfiltered_html` user renders unchanged (the save-time
	 *    capability gate is the sanitization boundary on this path, same
	 *    as WordPress core's own unfiltered_html model). Scripts, editor
	 *    artifacts, and unresolved markers are still handled.
	 *
	 * @since  0.4.0
	 * @param  string $html    Stored builder HTML (`_ambld_rendered_html`).
	 * @param  int    $post_id Post ID, for dynamic-token context.
	 * @param  string $profile 'canvas' or 'standalone'.
	 * @return string Output-ready HTML.
	 */
	public static function prepare( $html, $post_id, $profile = 'canvas' ) {
		$html = (string) $html;
		if ( '' === trim( $html ) ) {
			return '';
		}

		$is_standalone = ( 'standalone' === $profile );

		// 1. Editor-only artifacts. The editor strips these client-side at
		// every iframe→store sync; this is the server-side safety net for
		// content saved by older clients.
		$html = preg_replace( '/\s*data-amb-eid="[^"]*"/', '', $html );
		$html = preg_replace( '/\s*data-amb-edited="[^"]*"/', '', $html );
		$html = preg_replace( '/\s*contenteditable="(?:true|false)"/i', '', $html );

		// 2. Inline code. Strip WITH contents — a kses pass alone removes
		// the tags but leaves the code behind as visible text.
		$html = preg_replace( '/<script\b[^>]*>[\s\S]*?<\/script>/i', '', $html );
		$html = preg_replace( '/<script\b[^>]*\/?>/i', '', $html );
		if ( ! $is_standalone ) {
			$html = preg_replace( '/<style\b[^>]*>[\s\S]*?<\/style>/i', '', $html );
		}

		// 3. Allowlist pass (canvas profile). Before resolution — see doc.
		if ( ! $is_standalone ) {
			$html = wp_kses( $html, self::allowed_tags() );
		}

		// 4. Shortcode markers.
		if ( class_exists( 'AMBLD_Render_Shortcode_Resolver' ) ) {
			$html = AMBLD_Render_Shortcode_Resolver::resolve( $html );
		}

		// 5. Dynamic regions. On the standalone profile only run when the
		// page actually has [data-amb-dynamic] regions: the resolver also
		// rewrites orphan {{tokens}} via a DOMDocument round-trip, which
		// would reformat (and de-tokenize) a faithfully-cloned import that
		// legitimately contains literal {{ }} template syntax.
		$resolve_dynamic = ! $is_standalone || false !== strpos( $html, 'data-amb-dynamic' );
		if ( $resolve_dynamic ) {
			// The full render engine ships in Antimanual Builder Pro. Without
			// Pro, the free fallback strips binding markers + {{tokens}} so a
			// dynamic section degrades to its static template (no leaked markup).
			if ( class_exists( '\AMBLD_Pro\Render\Dynamic_Resolver' ) ) {
				$html = \AMBLD_Pro\Render\Dynamic_Resolver::resolve( $html, $post_id );
			} elseif ( class_exists( 'AMBLD_Render_Dynamic_Fallback' ) ) {
				$html = AMBLD_Render_Dynamic_Fallback::strip( $html );
			}
		}

		return trim( $html );
	}

	/**
	 * Build the CSS text for a page's styleMap (`_ambld_style_map`).
	 *
	 * THE single emitter, shared by the asset generator (compiled .css
	 * file) and the standalone template (inline <style> block). The asset
	 * generator previously HTML-entity-escaped every selector/value with
	 * esc_attr() — turning `.hero > .title` into `.hero &gt; .title` and
	 * `"DM Sans"` into `&quot;DM Sans&quot;` in the live stylesheet only,
	 * while the editor canvas rendered them raw. Values are sanitized
	 * structurally instead: braces and angle brackets are removed, which
	 * blocks both rule injection and `</style>` breakout when the result
	 * is printed inline.
	 *
	 * @since  0.4.0
	 * @param  int $post_id Post ID.
	 * @return string CSS text ('' when the page has no styleMap).
	 */
	public static function style_map_css( $post_id ) {
		$style_map = json_decode( (string) get_post_meta( $post_id, '_ambld_style_map', true ), true );
		if ( ! is_array( $style_map ) ) {
			return '';
		}

		$css = '';
		foreach ( $style_map as $selector => $properties ) {
			if ( ! is_array( $properties ) || empty( $properties ) ) {
				continue;
			}

			$selector = trim( preg_replace( '/[{}<]/', '', (string) $selector ) );
			if ( '' === $selector ) {
				continue;
			}

			$declarations = array();
			foreach ( $properties as $prop => $val ) {
				// Property names: standard properties and --custom-properties.
				$prop = preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $prop );
				$val  = trim( preg_replace( '/[{}<>]/', '', (string) $val ) );
				if ( '' === $prop || '' === $val ) {
					continue;
				}
				$declarations[] = $prop . ': ' . $val;
			}

			if ( ! empty( $declarations ) ) {
				$css .= $selector . " {\n  " . implode( ";\n  ", $declarations ) . ";\n}\n";
			}
		}

		return $css;
	}

	/**
	 * Build the imported-script `<script>` tags for a faithfully-cloned page.
	 *
	 * THE single emitter for the `_ambld_imported_scripts` manifest (per-file JS
	 * captured from a ZIP import: inline blocks, the page's own local script
	 * files, and remote/CDN/module references — each preserving its original
	 * head/body position and load attributes). Shared by the standalone clone
	 * template (page-standalone.php, echoed inline in document order) and the
	 * theme-template render path (AMBLD_Render_Template_Loader, printed on
	 * wp_head / wp_footer). Centralizing it guarantees the two paths emit
	 * byte-identical scripts — kind, position, attributes (type=module / defer
	 * / async / integrity / …), and the unfiltered_html capability gate — so
	 * an imported page keeps working when its Page template is switched
	 * between the builder's standalone clone and the active theme. Previously
	 * the theme path read only the remote-only `_ambld_imported_js_urls` subset,
	 * silently dropping every inline and local script on a template switch.
	 *
	 * Inline + local author script content is emitted only when the user who
	 * saved it still holds `unfiltered_html` (mirrors core's enforcement);
	 * remote references always load.
	 *
	 * @since  2.3.0
	 * @param  int $post_id Page post ID.
	 * @return array{head:string[],body:string[]} Ready-to-print `<script>` tag
	 *         strings grouped by document position. Empty arrays when the page
	 *         has no manifest.
	 */
	public static function imported_script_tags( $post_id ) {
		$empty = array(
			'head' => array(),
			'body' => array(),
		);

		$manifest = json_decode( (string) get_post_meta( $post_id, '_ambld_imported_scripts', true ), true );
		if ( ! is_array( $manifest ) || empty( $manifest ) ) {
			return $empty;
		}

		// Map of manifest-index → served local-file URL (written by the asset
		// generator's generate_imported_script_files()).
		$script_urls = json_decode( (string) get_post_meta( $post_id, '_ambld_imported_script_urls', true ), true );
		$script_urls = is_array( $script_urls ) ? $script_urls : array();

		// Author inline/local content is gated on the saver still holding the
		// capability — same check the standalone template applied inline.
		$authorized = function_exists( 'ambld_unfiltered_authorized' )
			? ambld_unfiltered_authorized( $post_id, '_ambld_imported_scripts_authorized_by' )
			: false;

		$tags = $empty;
		foreach ( $manifest as $idx => $entry ) {
			$tag = self::render_imported_script_tag( $entry, $idx, $script_urls, $authorized );
			if ( '' === $tag ) {
				continue;
			}
			$position = ( is_array( $entry ) && isset( $entry['position'] ) && 'head' === $entry['position'] )
				? 'head'
				: 'body';
			$tags[ $position ][] = $tag;
		}

		return $tags;
	}

	/**
	 * Render one manifest entry into a `<script>` tag (or '' when gated/empty).
	 *
	 * @since  2.3.0
	 * @param  mixed              $entry       Manifest entry.
	 * @param  int|string        $idx         Manifest index (keys $script_urls).
	 * @param  array<string,string> $script_urls Manifest-index → local file URL.
	 * @param  bool               $authorized  Whether inline/local content may render.
	 * @return string
	 */
	private static function render_imported_script_tag( $entry, $idx, $script_urls, $authorized ) {
		if ( ! is_array( $entry ) ) {
			return '';
		}

		$kind = isset( $entry['kind'] ) ? (string) $entry['kind'] : '';
		$attr = self::script_attr_string( isset( $entry['attrs'] ) && is_array( $entry['attrs'] ) ? $entry['attrs'] : array() );

		// Remote/CDN references (and module references) always load.
		if ( 'remote' === $kind ) {
			$src = isset( $entry['src'] ) ? (string) $entry['src'] : '';
			return '' === $src ? '' : '<script src="' . esc_url( $src ) . '"' . $attr . '></script>';
		}

		// Inline + local author content is capability-gated.
		if ( ! $authorized ) {
			return '';
		}

		if ( 'local' === $kind ) {
			$url = isset( $script_urls[ (string) $idx ] ) ? (string) $script_urls[ (string) $idx ] : '';
			return '' === $url ? '' : '<script src="' . esc_url( $url ) . '"' . $attr . '></script>';
		}

		if ( 'inline' === $kind ) {
			$content = isset( $entry['content'] ) ? (string) $entry['content'] : '';
			// Authored JS served verbatim (gated above); not HTML-escaped by design.
			return '' === trim( $content ) ? '' : '<script' . $attr . '>' . $content . '</script>';
		}

		return '';
	}

	/**
	 * Render a manifest entry's attribute map into an HTML attribute string.
	 *
	 * Boolean attributes (value `true` or empty string) render bare. Mirrors
	 * the standalone template's $attr_string so both paths preserve
	 * type=module / defer / async / integrity / data-* identically.
	 *
	 * @since  2.3.0
	 * @param  mixed $attrs Attribute map.
	 * @return string
	 */
	private static function script_attr_string( $attrs ) {
		$out = '';
		foreach ( (array) $attrs as $name => $value ) {
			$name = preg_replace( '/[^A-Za-z0-9_:-]/', '', (string) $name );
			if ( '' === $name ) {
				continue;
			}
			if ( true === $value || '' === $value ) {
				$out .= ' ' . $name;
			} else {
				$out .= ' ' . $name . '="' . esc_attr( (string) $value ) . '"';
			}
		}
		return $out;
	}
}
