<?php
/**
 * Builder Meta API Trait.
 *
 * Provides shared methods for validating and saving common builder meta data
 * (like blocks and rendered HTML) across different post types.
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

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

/**
 * Trait AMBLD_API_Builder_Meta_Trait
 *
 * @since 1.0.0
 */
trait AMBLD_API_Builder_Meta_Trait {

	/**
	 * Sanitize builder HTML based on the current user's capabilities.
	 *
	 * Non-privileged users are filtered through the SAME allowlist the
	 * frontend render uses (wp_kses_post + SVG/iframe/form tags), not bare
	 * wp_kses_post. With the narrower save-time list, an Editor's save
	 * silently stripped SVG icons and embeds that the canvas had just
	 * shown them and that the render allowlist would have permitted —
	 * the same page kept or lost content depending on WHO saved it last.
	 *
	 * @since 1.0.0
	 * @param mixed $html Raw HTML.
	 * @return string
	 */
	protected function sanitize_builder_html_value( $html ) {
		$html = is_string( $html ) ? $html : '';

		if ( current_user_can( 'unfiltered_html' ) ) {
			return $html;
		}

		if ( class_exists( 'AMBLD_Render_Pipeline' ) ) {
			return wp_kses( $html, AMBLD_Render_Pipeline::allowed_tags() );
		}

		return wp_kses_post( $html );
	}

	/**
	 * Sanitize page-scoped CSS extracted from builder-rendered HTML.
	 *
	 * Page-scoped CSS (the `<style>` block the AI emits inline with the
	 * generated HTML) is page content, not arbitrary author CSS. It is
	 * stored as `_ambld_custom_css` so the editor canvas and the
	 * front-end stylesheet can reproduce the same look the AI generated.
	 *
	 * Hard blocks: `<script>` tags, `@import` (no remote fetching),
	 * `expression(`, `behavior:` (legacy IE attack vectors),
	 * `javascript:` URLs, and any `url(...)` pointing at remote font /
	 * CSS hosts. Tag wrappers (`<style>...</style>`) are stripped so
	 * raw CSS is stored.
	 *
	 * @since 1.0.0
	 * @param mixed $css Raw CSS.
	 * @return string Sanitized CSS, empty on invalid input.
	 */
	protected function sanitize_builder_css_value( $css ) {
		$css = is_string( $css ) ? $css : '';
		if ( '' === $css ) {
			return '';
		}

		// Strip any wrapping <style> tags so the meta only ever holds raw CSS.
		$css = preg_replace( '/<\/?style\b[^>]*>/i', '', $css );

		// Defence-in-depth: remove any inline <script> blocks that might
		// have been concatenated into a CSS payload.
		$css = preg_replace( '/<script\b[^>]*>[\s\S]*?<\/script>/i', '', $css );
		$css = preg_replace( '/<script\b[^>]*\/?>/i', '', $css );

		// Block legacy attack vectors and remote fetches.
		$css = preg_replace( '/@import\b[^;]*;?/i', '', $css );
		$css = preg_replace( '/expression\s*\(/i', '/* blocked */(', $css );
		$css = preg_replace( '/\bbehavior\s*:/i', '/* blocked */:', $css );
		$css = preg_replace( '/javascript\s*:/i', '/* blocked */:', $css );

		// Strip remote font/CSS references via the shared static helper.
		if ( is_callable( array( 'AMBLD_API_Pages_Api', 'strip_remote_font_refs' ) ) ) {
			$css = \AMBLD_API_Pages_Api::strip_remote_font_refs( $css );
		}

		return trim( $css );
	}

	/**
	 * Sanitize a custom JavaScript payload for storage.
	 *
	 * The JS is served as a standalone static file via `wp_enqueue_script`
	 * (never echoed inline into the page HTML), so there is no `</script>`
	 * breakout vector to defend against at render time. We strip any
	 * wrapping `<script>` tags so the meta only ever holds raw JavaScript,
	 * and otherwise preserve the source verbatim.
	 *
	 * @since 1.3.0
	 * @param mixed $js Raw JavaScript source.
	 * @return string Sanitized JavaScript.
	 */
	protected function sanitize_builder_js_value( $js ) {
		$js = is_string( $js ) ? $js : '';
		if ( '' === $js ) {
			return '';
		}

		// Strip any wrapping <script> tags so the meta only ever holds raw JS.
		$js = preg_replace( '/<\/?script\b[^>]*>/i', '', $js );

		return trim( $js );
	}

	/**
	 * Sanitize markup fragments stored in page settings.
	 *
	 * @since 1.0.0
	 * @param mixed $markup Raw markup.
	 * @return string
	 */
	protected function sanitize_builder_markup_value( $markup ) {
		$markup = is_string( $markup ) ? $markup : '';

		return current_user_can( 'unfiltered_html' )
			? $markup
			: wp_kses_post( $markup );
	}

	/**
	 * Sanitize a map of HTML attributes.
	 *
	 * @since 1.0.0
	 * @param mixed $attributes Raw attribute map.
	 * @return array
	 */
	protected function sanitize_builder_attribute_values( $attributes ) {
		if ( ! is_array( $attributes ) ) {
			return array();
		}

		$sanitized_attributes = array();

		foreach ( $attributes as $name => $attribute_value ) {
			$name = sanitize_key( (string) $name );

			if ( '' === $name || ! is_scalar( $attribute_value ) ) {
				continue;
			}

			$sanitized_attributes[ $name ] = sanitize_text_field( (string) $attribute_value );
		}

		return $sanitized_attributes;
	}

	/**
	 * Sanitize a page settings array while preserving trusted import markup.
	 *
	 * @since 1.0.0
	 * @param mixed $settings Raw page settings.
	 * @return array
	 */
	protected function sanitize_builder_page_settings_array( $settings ) {
		if ( ! is_array( $settings ) ) {
			return array();
		}

		foreach ( $settings as $key => $value ) {
			switch ( $key ) {
				case 'importedHeadMarkup':
				case 'importedFooterMarkup':
					$markup = is_string( $value ) ? $value : '';
					// Always strip <script> and <style> blocks from imported
					// markup. CSS is served via the enqueued stylesheet generated
					// by the asset pipeline; JS files are enqueued separately.
					$markup = preg_replace( '/<script\b[^>]*>[\s\S]*?<\/script>/i', '', $markup );
					$markup = preg_replace( '/<style\b[^>]*>[\s\S]*?<\/style>/i', '', $markup );
					$settings[ $key ] = $this->sanitize_builder_markup_value( $markup );
					break;

				case 'importedHtmlAttributes':
				case 'importedBodyAttributes':
					$settings[ $key ] = $this->sanitize_builder_attribute_values( $value );
					break;

				case 'renderMode':
					// 'standalone' = render the imported document verbatim
					// (faithful clone, no theme wrap); 'theme' = the legacy
					// theme-wrapped render. Anything else is dropped.
					$raw = is_scalar( $value ) ? trim( (string) $value ) : '';
					if ( in_array( $raw, array( 'standalone', 'theme' ), true ) ) {
						$settings[ $key ] = $raw;
					} else {
						unset( $settings[ $key ] );
					}
					break;

				case 'pageTemplate':
					// Whitelist: built-in presets, or a `.php` filename.
					// `.php` filenames are validated against the active theme's
					// registered templates at save time before being written to
					// `_wp_page_template`, but here we keep the raw string so
					// the editor round-trips selections it made before a theme
					// switch (the field-level validator drops invalid ones).
					$raw = is_scalar( $value ) ? (string) $value : '';
					$raw = trim( $raw );
					if ( in_array( $raw, array( '', 'default', 'canvas', 'full-width' ), true ) ) {
						$settings[ $key ] = $raw;
					} elseif ( '.php' === substr( $raw, -4 ) ) {
						$settings[ $key ] = sanitize_file_name( $raw );
					} else {
						unset( $settings[ $key ] );
					}
					break;

				default:
					if ( is_array( $value ) ) {
						$settings[ $key ] = $this->sanitize_builder_page_settings_array( $value );
					} elseif ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) {
						$settings[ $key ] = $value;
					} elseif ( is_scalar( $value ) ) {
						$settings[ $key ] = sanitize_text_field( (string) $value );
					} else {
						unset( $settings[ $key ] );
					}
			}
		}

		return $settings;
	}

	/**
	 * Sanitize page settings for post meta storage.
	 *
	 * @since 1.0.0
	 * @param mixed $value Raw page settings value.
	 * @return mixed
	 */
	protected function sanitize_builder_page_settings_meta_value( $value ) {
		$decoded  = $value;
		$was_json = false;

		if ( is_string( $value ) ) {
			$decoded_value = json_decode( $value, true );

			if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded_value ) ) {
				$decoded  = $decoded_value;
				$was_json = true;
			}
		}

		if ( ! is_array( $decoded ) ) {
			return is_string( $value ) ? sanitize_text_field( $value ) : $value;
		}

		$decoded = $this->sanitize_builder_page_settings_array( $decoded );

		return $was_json ? wp_json_encode( $decoded ) : $decoded;
	}

	/**
	 * Normalize a post status value for builder page writes.
	 *
	 * @since 1.0.0
	 * @param mixed  $status  Requested post status.
	 * @param string $default Default status.
	 * @return string
	 */
	protected function normalize_builder_post_status( $status, $default = 'draft' ) {
		$allowed_statuses = array( 'draft', 'publish', 'private', 'pending' );
		$status           = sanitize_key( (string) $status );

		if ( ! in_array( $status, $allowed_statuses, true ) ) {
			return $default;
		}

		if ( 'publish' === $status && ! current_user_can( 'publish_pages' ) ) {
			return 'draft';
		}

		if ( 'private' === $status && ! current_user_can( 'publish_pages' ) ) {
			return 'draft';
		}

		return $status;
	}

	/**
	 * Save common builder meta data (blocks, HTML, custom CSS) from request parameters.
	 *
	 * @since 1.0.0
	 * @param int   $post_id The ID of the post to update.
	 * @param array $params  The request parameters.
	 * @return void
	 */
	protected function save_builder_meta( $post_id, $params ) {
		update_post_meta( $post_id, '_ambld_builder_enabled', 1 );

		// Save blocks.
		if ( isset( $params['blocks'] ) ) {
			$blocks = is_array( $params['blocks'] ) ? wp_json_encode( $params['blocks'] ) : sanitize_text_field( $params['blocks'] );
			update_post_meta( $post_id, '_ambld_blocks', wp_slash( $blocks ) );
		}

		// Save rendered HTML with proper sanitization based on capabilities.
		// wp_slash compensates for update_post_meta's internal wp_unslash —
		// without it every literal backslash in page HTML is stripped on save.
		if ( isset( $params['renderedHtml'] ) ) {
			$html = $this->sanitize_builder_html_value( $params['renderedHtml'] );
			update_post_meta( $post_id, '_ambld_rendered_html', wp_slash( $html ) );
		}

		// Persist the page-scoped CSS that gets extracted from the
		// AI-generated `<style>` block. This is page content (it lives
		// alongside the page markup and only ever styles that page's own
		// elements), not arbitrary author CSS — the sanitizer above
		// blocks @import / javascript: / behavior / remote font fetches.
		if ( array_key_exists( 'customCss', $params ) ) {
			$clean_css = $this->sanitize_builder_css_value( $params['customCss'] );
			if ( '' !== $clean_css ) {
				update_post_meta( $post_id, '_ambld_custom_css', wp_slash( $clean_css ) );
			} else {
				delete_post_meta( $post_id, '_ambld_custom_css' );
			}
		}

		// Persist custom JavaScript. It is compiled into the page-scoped JS
		// asset by the asset generator and served as a standalone file via
		// wp_enqueue_script. `customJs` is the single canonical inline-JS
		// field, so writing it also migrates any legacy `_ambld_imported_inline_js`
		// (the editor folds that meta into `customJs` on load) and clears it.
		if ( array_key_exists( 'customJs', $params ) ) {
			$clean_js = $this->sanitize_builder_js_value( $params['customJs'] );
			if ( '' !== $clean_js ) {
				update_post_meta( $post_id, '_ambld_custom_js', wp_slash( $clean_js ) );
				// Record the author so the standalone render can re-check the
				// capability at output (mirrors ambld_unfiltered_authorized()).
				if ( current_user_can( 'unfiltered_html' ) ) {
					update_post_meta( $post_id, '_ambld_custom_js_authorized_by', get_current_user_id() );
				} else {
					delete_post_meta( $post_id, '_ambld_custom_js_authorized_by' );
				}
			} else {
				delete_post_meta( $post_id, '_ambld_custom_js' );
				delete_post_meta( $post_id, '_ambld_custom_js_authorized_by' );
			}
			delete_post_meta( $post_id, '_ambld_imported_inline_js' );
			if ( class_exists( 'AMBLD_Render_Asset_Generator' ) ) {
				AMBLD_Render_Asset_Generator::delete_source_files( $post_id );
			}
		}

		// Save styleMap JSON if present.
		if ( isset( $params['styleMap'] ) ) {
			$style_map = $this->sanitize_style_map_value( $params['styleMap'] );
			update_post_meta( $post_id, '_ambld_style_map', wp_slash( $style_map ) );
		}

		// Persist the imported-script manifest (per-file JS for faithfully
		// cloned imported pages). Each entry preserves a script's kind,
		// position, attributes, and either inline/local content or a remote
		// src — so the standalone render can emit it exactly where and how it
		// appeared in the original document. Inline/local `content` is only
		// kept for users with `unfiltered_html`; the render gate
		// (`ambld_unfiltered_authorized`) re-checks that capability at output.
		if ( array_key_exists( 'importedScripts', $params ) ) {
			$manifest = $this->sanitize_imported_scripts_value( $params['importedScripts'] );
			if ( ! empty( $manifest ) ) {
				update_post_meta( $post_id, '_ambld_imported_scripts', wp_slash( wp_json_encode( $manifest ) ) );
				if ( current_user_can( 'unfiltered_html' ) ) {
					update_post_meta( $post_id, '_ambld_imported_scripts_authorized_by', get_current_user_id() );
				}
			} else {
				delete_post_meta( $post_id, '_ambld_imported_scripts' );
				delete_post_meta( $post_id, '_ambld_imported_scripts_authorized_by' );
			}
		}

		// External CDN resources (CSS/JS attached via the CDN tab or the AI's
		// attach_cdn tool). CSS links are always emitted; CDN <script src>
		// execution is gated on `unfiltered_html` at render time (mirrors the
		// custom-JS authorization model), so we stamp the authorizing user.
		if ( array_key_exists( 'cdnResources', $params ) ) {
			$cdn = $this->sanitize_cdn_resources_value( $params['cdnResources'] );
			if ( ! empty( $cdn ) ) {
				update_post_meta( $post_id, '_ambld_cdn_resources', wp_slash( wp_json_encode( $cdn ) ) );
				if ( current_user_can( 'unfiltered_html' ) ) {
					update_post_meta( $post_id, '_ambld_cdn_resources_authorized_by', get_current_user_id() );
				}
			} else {
				delete_post_meta( $post_id, '_ambld_cdn_resources' );
				delete_post_meta( $post_id, '_ambld_cdn_resources_authorized_by' );
			}
		}
	}

	/**
	 * Sanitize the imported-script manifest.
	 *
	 * Shape: an ordered array of entries, each:
	 *   {
	 *     id, label,
	 *     kind:     'inline' | 'local' | 'remote',
	 *     position: 'head'   | 'body',
	 *     content:  string (inline/local source — only retained for
	 *               `unfiltered_html` users),
	 *     src:      string (remote/local URL),
	 *     attrs:    { name: value }  (async/defer/type/integrity/… and data-*)
	 *   }
	 *
	 * @since 2.2.0
	 * @param mixed $value Raw manifest (JSON string or array).
	 * @return array Sanitized manifest (re-indexed; preserves input order).
	 */
	protected function sanitize_imported_scripts_value( $value ) {
		if ( is_string( $value ) ) {
			$value = json_decode( $value, true );
		}
		if ( ! is_array( $value ) ) {
			return array();
		}

		$can_unfiltered = current_user_can( 'unfiltered_html' );
		$allowed_attrs  = array( 'async', 'defer', 'type', 'integrity', 'crossorigin', 'nomodule', 'referrerpolicy', 'fetchpriority', 'id' );
		$clean          = array();
		$index          = 0;

		foreach ( $value as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$kind = isset( $entry['kind'] ) ? (string) $entry['kind'] : '';
			if ( ! in_array( $kind, array( 'inline', 'local', 'remote' ), true ) ) {
				continue;
			}

			$position = isset( $entry['position'] ) && 'head' === $entry['position'] ? 'head' : 'body';

			$out = array(
				'id'       => isset( $entry['id'] ) ? sanitize_text_field( (string) $entry['id'] ) : (string) $index,
				'label'    => isset( $entry['label'] ) ? sanitize_text_field( (string) $entry['label'] ) : '',
				'kind'     => $kind,
				'position' => $position,
			);

			// Source URL for remote (and local files served by URL).
			if ( in_array( $kind, array( 'remote', 'local' ), true ) && ! empty( $entry['src'] ) ) {
				$out['src'] = esc_url_raw( (string) $entry['src'] );
			}

			// Inline/local source — privileged users only.
			if ( in_array( $kind, array( 'inline', 'local' ), true ) && isset( $entry['content'] ) && $can_unfiltered ) {
				$out['content'] = (string) $entry['content'];
			}

			// Script attributes (whitelist + data-*).
			if ( isset( $entry['attrs'] ) && is_array( $entry['attrs'] ) ) {
				$attrs = array();
				foreach ( $entry['attrs'] as $name => $val ) {
					$name = sanitize_key( (string) $name );
					if ( '' === $name ) {
						continue;
					}
					if ( in_array( $name, $allowed_attrs, true ) || 0 === strpos( $name, 'data-' ) ) {
						$attrs[ $name ] = is_bool( $val ) ? $val : sanitize_text_field( (string) $val );
					}
				}
				if ( ! empty( $attrs ) ) {
					$out['attrs'] = $attrs;
				}
			}

			$clean[] = $out;
			$index++;
		}

		return $clean;
	}

	/**
	 * Sanitize the external-CDN resource list.
	 *
	 * Shape: an ordered array of entries, each:
	 *   {
	 *     id, type: 'css'|'js', url,
	 *     position: 'head'|'body' (js only),
	 *     async: bool, defer: bool (js only)
	 *   }
	 * URLs are restricted to http/https. Deduped by (type,url).
	 *
	 * @since 1.2.0
	 * @param mixed $value Raw list (JSON string or array).
	 * @return array Sanitized list (re-indexed).
	 */
	protected function sanitize_cdn_resources_value( $value ) {
		if ( is_string( $value ) ) {
			$value = json_decode( $value, true );
		}
		if ( ! is_array( $value ) ) {
			return array();
		}

		$clean = array();
		$seen  = array();
		$index = 0;

		foreach ( $value as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
			if ( ! in_array( $type, array( 'css', 'js' ), true ) ) {
				continue;
			}

			$url = isset( $entry['url'] )
				? esc_url_raw( trim( (string) $entry['url'] ), array( 'http', 'https' ) )
				: '';
			if ( '' === $url ) {
				continue;
			}

			$key = $type . '|' . $url;
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;

			$out = array(
				'id'   => ( isset( $entry['id'] ) && '' !== $entry['id'] )
					? sanitize_text_field( (string) $entry['id'] )
					: 'cdn-' . $index,
				'type' => $type,
				'url'  => $url,
			);

			if ( 'js' === $type ) {
				$out['position'] = ( isset( $entry['position'] ) && 'body' === $entry['position'] ) ? 'body' : 'head';
				$out['async']    = ! empty( $entry['async'] );
				$out['defer']    = ! empty( $entry['defer'] );
			}

			$clean[] = $out;
			$index++;
		}

		return $clean;
	}

	/**
	 * Sanitize a styleMap JSON string.
	 *
	 * Validates structure: { "selector": { "property": "value" } }
	 * Rejects any data that doesn't match this shape.
	 *
	 * @since 1.2.0
	 * @param mixed $value Raw styleMap value (string or array).
	 * @return string Sanitized JSON string.
	 */
	protected function sanitize_style_map_value( $value ) {
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
		} elseif ( is_array( $value ) ) {
			$decoded = $value;
		} else {
			return '{}';
		}

		if ( ! is_array( $decoded ) ) {
			return '{}';
		}

		$sanitized = [];

		foreach ( $decoded as $selector => $properties ) {
			$selector = sanitize_text_field( (string) $selector );

			if ( '' === $selector || ! is_array( $properties ) ) {
				continue;
			}

			$clean_props = [];

			foreach ( $properties as $prop => $val ) {
				$prop = sanitize_key( (string) $prop );
				$val  = sanitize_text_field( (string) $val );

				if ( '' === $prop ) {
					continue;
				}

				$clean_props[ $prop ] = $val;
			}

			if ( ! empty( $clean_props ) ) {
				$sanitized[ $selector ] = $clean_props;
			}
		}

		return wp_json_encode( $sanitized );
	}
}
