<?php
/**
 * Pages REST API controller.
 *
 * Handles CRUD operations for builder pages via REST API.
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

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

/**
 * Class AMBLD_API_Pages_Api
 *
 * REST API endpoints for builder page management.
 *
 * @since 1.0.0
 */
class AMBLD_API_Pages_Api {

	use AMBLD_API_Builder_Meta_Trait;

	/**
	 * Native post type used by the builder pages endpoint.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	const POST_TYPE = 'page';

	/**
	 * Option key storing the list cache version.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	const LIST_CACHE_VERSION_OPTION = 'ambld_pages_list_cache_version';

	/**
	 * Lifetime for cached list responses in seconds.
	 *
	 * @since 1.0.0
	 * @var   int
	 */
	const LIST_CACHE_TTL = 60;

	/**
	 * REST namespace.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	private $namespace = 'ambld/v1';

	/**
	 * Register routes.
	 *
	 * @since  1.0.0
	 * @return void
	 */
	public function register_routes() {
		// List all pages.
		register_rest_route(
			$this->namespace,
			'/pages',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_pages' ),
					'permission_callback' => array( $this, 'can_edit_pages' ),
					'args'                => array(
						'per_page' => array(
							'default'           => 20,
							'sanitize_callback' => 'absint',
						),
						'page'     => array(
							'default'           => 1,
							'sanitize_callback' => 'absint',
						),
						'status'   => array(
							'default'           => 'any',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'search'   => array(
							'default'           => '',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'orderby'  => array(
							'default'           => 'modified',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'order'    => array(
							'default'           => 'DESC',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'context'  => array(
							'default'           => 'list',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_page' ),
					'permission_callback' => array( $this, 'can_edit_pages' ),
				),
			)
		);

		// Single page operations — verb-specific per-post permission
		// checks. The broad `edit_pages` cap is necessary but not
		// sufficient (private posts, password-protected, multisite
		// restrictions, etc. need the meta-cap mapping).
		register_rest_route(
			$this->namespace,
			'/pages/(?P<id>\d+)',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_page' ),
					'permission_callback' => array( $this, 'can_edit_specific_page' ),
				),
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_page' ),
					'permission_callback' => array( $this, 'can_edit_specific_page' ),
				),
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_page' ),
					'permission_callback' => array( $this, 'can_delete_specific_page' ),
				),
			)
		);

		// Duplicate a page — needs both `edit_post` on the source AND
		// the broad create capability. We check the source here; the
		// callback creates a new draft via wp_insert_post which itself
		// re-checks publish/edit caps.
		register_rest_route(
			$this->namespace,
			'/pages/(?P<id>\d+)/duplicate',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'duplicate_page' ),
				'permission_callback' => array( $this, 'can_edit_specific_page' ),
			)
		);

		// Serve the stored import document as a standalone HTML page.
		register_rest_route(
			$this->namespace,
			'/pages/(?P<id>\d+)/document',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_page_document' ),
				'permission_callback' => array( $this, 'can_view_page_document' ),
			)
		);

		// Builder revision snapshots. WP-core revisions don't capture the
		// meta that actually drives rendering, so the builder keeps its own
		// rolling snapshot stack. The list returns metadata only; fetching a
		// single index returns its full content so the editor can LOAD it
		// into the canvas — restoring is a client-side, unsaved edit the user
		// reviews and saves, not a server-side write.
		register_rest_route(
			$this->namespace,
			'/pages/(?P<id>\d+)/revisions',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_page_revisions' ),
				'permission_callback' => array( $this, 'can_edit_specific_page' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/pages/(?P<id>\d+)/revisions/(?P<index>\d+)',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_page_revision' ),
				'permission_callback' => array( $this, 'can_edit_specific_page' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/check-url',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'check_url' ),
				'permission_callback' => array( $this, 'can_edit_pages' ),
				'args'                => array(
					'slug' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);
	}

	/**
	 * Check whether a given slug/path is already occupied by any post or
	 * WordPress built-in URL (archives, categories, tags, etc.).
	 *
	 * Uses url_to_postid() so it handles all permalink structures and post
	 * types — unlike a plain /wp/v2/pages?slug= query which only sees pages.
	 *
	 * @since  1.0.0
	 * @param  \WP_REST_Request $request The request.
	 * @return \WP_REST_Response
	 */
	public function check_url( $request ) {
		$slug    = trim( (string) $request->get_param( 'slug' ), '/' );
		$url     = trailingslashit( home_url( '/' . $slug ) );
		$post_id = url_to_postid( $url );

		if ( ! $post_id ) {
			// url_to_postid misses non-post destinations (archives, tax,
			// author pages).  A cheap HEAD request to the front-end would
			// be too slow; instead, also check get_page_by_path so we
			// catch draft/private pages that url_to_postid skips.
			$page = get_page_by_path( $slug, OBJECT, get_post_types( array( 'public' => true ) ) );
			if ( $page ) {
				$post_id = $page->ID;
			}
		}

		return rest_ensure_response( array( 'exists' => $post_id > 0 ) );
	}

	/**
	 * Permission check: can the current user edit pages?
	 *
	 * Used for list / create endpoints where there's no specific post
	 * to check against.
	 *
	 * @since  1.0.0
	 * @return bool
	 */
	public function can_edit_pages() {
		return current_user_can( 'edit_pages' );
	}

	/**
	 * Per-post permission check used by single-page routes (/pages/{id}).
	 *
	 * Verifies the user can perform the specific verb on the specific
	 * post — `edit_pages` is necessary but not sufficient. WordPress
	 * meta-cap mapping handles private posts, password protection, and
	 * multisite restrictions for us when we use the verb-specific cap.
	 *
	 * @since  1.5.0
	 * @param  WP_REST_Request $request The request.
	 * @return true|WP_Error
	 */
	public function can_edit_specific_page( $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		if ( $post_id <= 0 || ! get_post( $post_id ) ) {
			return new \WP_Error( 'rest_post_not_found', __( 'Post not found.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to edit this page.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}
		return true;
	}

	/**
	 * Per-post delete permission check used by single-page DELETE routes.
	 *
	 * @since  1.5.0
	 * @param  WP_REST_Request $request The request.
	 * @return true|WP_Error
	 */
	public function can_delete_specific_page( $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		if ( $post_id <= 0 || ! get_post( $post_id ) ) {
			return new \WP_Error( 'rest_post_not_found', __( 'Post not found.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}
		if ( ! current_user_can( 'delete_post', $post_id ) ) {
			return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to delete this page.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}
		return true;
	}

	/**
	 * Get a list of builder pages.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function get_pages( $request ) {
		$cached_response = $this->get_cached_list_response( $request );
		if ( null !== $cached_response ) {
			return new \WP_REST_Response( $cached_response, 200 );
		}

		$per_page = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ) );
		$page     = max( 1, (int) $request->get_param( 'page' ) );
		$context  = $this->normalize_response_context( $request->get_param( 'context' ) );

		$args = array(
			'post_type'      => self::POST_TYPE,
			'posts_per_page' => $per_page,
			'paged'          => $page,
			'post_status'    => $request->get_param( 'status' ),
			'orderby'        => $this->normalize_list_orderby( $request->get_param( 'orderby' ) ),
			'order'          => $this->normalize_list_order( $request->get_param( 'order' ) ),
			'meta_key'       => '_ambld_builder_enabled',
			'meta_value'     => '1',
			'ignore_sticky_posts'    => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'cache_results'          => true,
		);

		$search = $request->get_param( 'search' );
		if ( ! empty( $search ) ) {
			$args['s'] = $search;
		}

		$query = new \WP_Query( $args );
		$pages = array();

		foreach ( $query->posts as $post ) {
			$pages[] = $this->format_page( $post, $context );
		}

		$response = array(
			'pages'      => $pages,
			'total'      => (int) $query->found_posts,
			'totalPages' => (int) $query->max_num_pages,
		);

		$this->set_cached_list_response( $request, $response );

		return new \WP_REST_Response( $response, 200 );
	}

	/**
	 * Get a single builder page.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_page( $request ) {
		$post = get_post( $request['id'] );

		$supported_types = array( self::POST_TYPE );

		if ( ! $post || ! in_array( $post->post_type, $supported_types, true ) ) {
			return new \WP_Error(
				'not_found',
				__( 'Page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		if ( ! current_user_can( 'edit_post', $post->ID ) ) {
			return new \WP_Error(
				'forbidden',
				__( 'You do not have permission to edit this page.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}

		return new \WP_REST_Response( $this->format_page( $post ), 200 );
	}

	/**
	 * Permission check: can the current user view the page document?
	 * Published pages are publicly readable; drafts require edit_post.
	 */
	public function can_view_page_document( $request ) {
		$post = get_post( $request['id'] );
		if ( ! $post ) {
			return false;
		}
		if ( 'publish' === $post->post_status ) {
			return true;
		}
		return current_user_can( 'edit_post', $post->ID );
	}

	/**
	 * Serve the stored import document as a standalone HTML response.
	 *
	 * @param  WP_REST_Request $request Request object.
	 * @return void Outputs HTML directly and exits.
	 */
	public function get_page_document( $request ) {
		$post = get_post( $request['id'] );

		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			wp_die( esc_html__( 'Page not found.', 'antimanual-builder' ), 404 );
		}

		$document = get_post_meta( $post->ID, '_ambld_import_document', true );

		if ( empty( $document ) ) {
			wp_die( esc_html__( 'No import document stored for this page.', 'antimanual-builder' ), 404 );
		}

		// Defence-in-depth: run the same kses allowlist applied at save, so a
		// legacy document stored before save-time sanitization (or one that
		// slipped past it) cannot serve active content.
		$document = $this->sanitize_import_document( $document );

		// wp_kses strips the doctype; restore it so the document renders in
		// standards mode.
		if ( ! preg_match( '/^\s*<!doctype/i', $document ) ) {
			$document = "<!DOCTYPE html>\n" . $document;
		}

		// Output as a standalone HTML document, bypassing WordPress template.
		header( 'Content-Type: text/html; charset=UTF-8' );
		header( 'X-Frame-Options: SAMEORIGIN' );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $document;
		exit;
	}

	/**
	 * Create a new builder page.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function create_page( $request ) {
		$params = $request->get_json_params();
		$params = is_array( $params ) ? $params : $request->get_params();
		$params = is_array( $params ) ? $params : array();

		$is_import = ! empty( $params['importDocument'] );

		// For native builder pages, lift any inline <style> blocks out of
		// renderedHtml and into customCss so they survive sanitization and
		// the editor canvas / asset pipeline can re-apply them. Imported
		// pages keep their styles in the importDocument and head markup.
		if ( ! $is_import && isset( $params['renderedHtml'] ) ) {
			$extracted              = $this->extract_styles_from_html( $params['renderedHtml'] );
			$params['renderedHtml'] = $extracted['html'];
			$existing_css           = isset( $params['customCss'] ) ? (string) $params['customCss'] : '';
			$params['customCss']    = $this->merge_css( $existing_css, $extracted['css'] );
		}

		if ( isset( $params['renderedHtml'] ) ) {
			// Final defence-in-depth on the body markup — strips any
			// stray <script> or inline event handlers but preserves the
			// HTML structure (and any inline `style=""` attributes).
			$params['renderedHtml'] = $this->strip_inline_code_from_html( $params['renderedHtml'] );
		}

		$params['renderedHtml'] = isset( $params['renderedHtml'] )
			? $this->sanitize_builder_html_value( $params['renderedHtml'] )
			: '';

		$post_data = array(
			'post_type'    => self::POST_TYPE,
			'post_title'   => isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : __( 'Untitled Page', 'antimanual-builder' ),
			'post_status'  => $this->normalize_builder_post_status( isset( $params['status'] ) ? $params['status'] : 'draft' ),
			'post_author'  => get_current_user_id(),
			'post_content' => $params['renderedHtml'],
		);

		// wp_insert_post expects slashed data (it unslashes internally) —
		// without wp_slash every literal backslash in the content is stripped.
		// Suppress the save_post-driven asset pass: it would run with stale
		// meta, and this method fires ambld_page_saved itself after save_page_meta.
		AMBLD_Page_Integration::$suppress_save_hook = true;
		$post_id = wp_insert_post( wp_slash( $post_data ), true );
		AMBLD_Page_Integration::$suppress_save_hook = false;

		if ( is_wp_error( $post_id ) ) {
			return $post_id;
		}

		// Save meta data.
		$this->save_page_meta( $post_id, $params );

		// Generate versioned CSS/JS assets immediately for new pages.
		do_action( 'ambld_page_saved', $post_id, get_post( $post_id ) );
		$this->bump_list_cache_version();

		$post = get_post( $post_id );
		return new \WP_REST_Response( $this->format_page( $post ), 201 );
	}

	/**
	 * Update an existing builder page.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_page( $request ) {
		$post = get_post( $request['id'] );

		$supported_types = array( self::POST_TYPE );

		if ( ! $post || ! in_array( $post->post_type, $supported_types, true ) ) {
			return new \WP_Error(
				'not_found',
				__( 'Page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		if ( ! current_user_can( 'edit_post', $post->ID ) ) {
			return new \WP_Error(
				'forbidden',
				__( 'You do not have permission to edit this page.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}

		$params = $request->get_json_params();
		$params = is_array( $params ) ? $params : $request->get_params();
		$params = is_array( $params ) ? $params : array();

		$is_import = ! empty( $params['importDocument'] );

		// For native builder pages, lift inline <style> blocks out of
		// renderedHtml and into customCss so they survive sanitization
		// and the editor canvas / asset pipeline can re-apply them.
		if ( ! $is_import && isset( $params['renderedHtml'] ) ) {
			$extracted              = $this->extract_styles_from_html( $params['renderedHtml'] );
			$params['renderedHtml'] = $extracted['html'];
			$existing_css           = isset( $params['customCss'] ) ? (string) $params['customCss'] : '';
			$params['customCss']    = $this->merge_css( $existing_css, $extracted['css'] );
		}

		if ( isset( $params['renderedHtml'] ) ) {
			$params['renderedHtml'] = $this->strip_inline_code_from_html( $params['renderedHtml'] );
			$params['renderedHtml'] = $this->sanitize_builder_html_value( $params['renderedHtml'] );
		}

		$post_data = array( 'ID' => $post->ID );

		if ( isset( $params['title'] ) ) {
			$post_data['post_title'] = sanitize_text_field( $params['title'] );
		}

		if ( isset( $params['excerpt'] ) ) {
			$post_data['post_excerpt'] = sanitize_textarea_field( $params['excerpt'] );
		}

		// Permalink slug. Empty string ⇒ let WordPress auto-derive from the
		// title on the next save. Non-empty values are run through
		// sanitize_title() so user input can't smuggle invalid URL characters.
		if ( isset( $params['slug'] ) ) {
			$raw_slug             = (string) $params['slug'];
			$sanitized_slug       = sanitize_title( $raw_slug );
			$post_data['post_name'] = $sanitized_slug;
		}

		if ( isset( $params['status'] ) ) {
			$post_data['post_status'] = $this->normalize_builder_post_status( $params['status'], $post->post_status );
		}

		if ( isset( $params['renderedHtml'] ) ) {
			$post_data['post_content'] = $params['renderedHtml'];
		}

		$page_template = $this->resolve_page_template_for_save( $post, $params );
		if ( null !== $page_template ) {
			$post_data['page_template'] = $page_template;
		}

		// wp_update_post expects slashed data (it unslashes internally) —
		// without wp_slash every literal backslash in the content is stripped.
		// Suppress the save_post-driven asset pass: it would run with stale
		// meta, and this method fires ambld_page_saved itself after save_page_meta.
		AMBLD_Page_Integration::$suppress_save_hook = true;
		$result = wp_update_post( wp_slash( $post_data ), true );
		AMBLD_Page_Integration::$suppress_save_hook = false;

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Preserve the pre-save state as a revision snapshot BEFORE the
		// meta writes below overwrite it. One AI prompt can rewrite the
		// whole page in a single save — this is the recovery path.
		if ( isset( $params['renderedHtml'] ) || array_key_exists( 'customCss', $params ) || array_key_exists( 'customJs', $params ) || isset( $params['styleMap'] ) ) {
			$this->maybe_push_revision_snapshot( $post->ID );
		}

		// Save meta data.
		$this->save_page_meta( $post->ID, $params );

		// Trigger asset regeneration.
		do_action( 'ambld_page_saved', $post->ID, get_post( $post->ID ) );
		$this->bump_list_cache_version();

		$post = get_post( $post->ID );
		return new \WP_REST_Response( $this->format_page( $post ), 200 );
	}

	/**
	 * Read the builder revision-snapshot stack for a page.
	 *
	 * @since  0.4.0
	 * @param  int $post_id Post ID.
	 * @return array Snapshot entries, newest first.
	 */
	private function read_revision_snapshots( $post_id ) {
		$raw = get_post_meta( $post_id, '_ambld_revisions', true );
		if ( '' === $raw || ! is_string( $raw ) ) {
			return array();
		}
		$decoded = json_decode( $raw, true );
		return is_array( $decoded ) ? array_values( $decoded ) : array();
	}

	/**
	 * Capture the page's CURRENT builder state into the revision stack.
	 *
	 * Stores the raw meta strings as saved (already sanitized at their
	 * original save), newest first, capped at `ambld_max_revisions` (5).
	 * Skips empty states and exact duplicates of the newest snapshot.
	 *
	 * @since  0.4.0
	 * @param  int $post_id Post ID.
	 * @return void
	 */
	private function maybe_push_revision_snapshot( $post_id ) {
		$snapshot = array(
			'savedAt'      => time(),
			'userId'       => get_current_user_id(),
			'title'        => get_the_title( $post_id ),
			'renderedHtml' => (string) get_post_meta( $post_id, '_ambld_rendered_html', true ),
			'customCss'    => (string) get_post_meta( $post_id, '_ambld_custom_css', true ),
			'customJs'     => (string) get_post_meta( $post_id, '_ambld_custom_js', true ),
			'styleMap'     => (string) get_post_meta( $post_id, '_ambld_style_map', true ),
			'pageSettings' => (string) get_post_meta( $post_id, '_ambld_page_settings', true ),
		);

		// Nothing worth preserving (e.g. the very first save of a page).
		if ( '' === trim( $snapshot['renderedHtml'] ) && '' === trim( $snapshot['customCss'] ) && '' === trim( $snapshot['customJs'] ) ) {
			return;
		}

		$snapshots = $this->read_revision_snapshots( $post_id );

		// Duplicate of the newest snapshot — nothing changed since.
		if ( ! empty( $snapshots ) ) {
			$newest = $snapshots[0];
			$same   = true;
			foreach ( array( 'renderedHtml', 'customCss', 'customJs', 'styleMap', 'pageSettings' ) as $field ) {
				if ( ( isset( $newest[ $field ] ) ? (string) $newest[ $field ] : '' ) !== $snapshot[ $field ] ) {
					$same = false;
					break;
				}
			}
			if ( $same ) {
				return;
			}
		}

		array_unshift( $snapshots, $snapshot );

		/**
		 * Filter the number of builder revision snapshots kept per page.
		 *
		 * @param int $max Maximum snapshot count (default 5).
		 */
		$max       = max( 1, (int) apply_filters( 'ambld_max_revisions', 5 ) );
		$snapshots = array_slice( $snapshots, 0, $max );

		update_post_meta( $post_id, '_ambld_revisions', wp_slash( wp_json_encode( $snapshots ) ) );
	}

	/**
	 * List builder revision snapshots (metadata only, no content).
	 *
	 * @since  0.4.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_page_revisions( $request ) {
		$post = get_post( $request['id'] );

		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return new \WP_Error(
				'not_found',
				__( 'Page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		$items = array();
		foreach ( $this->read_revision_snapshots( $post->ID ) as $index => $snapshot ) {
			$user = get_userdata( isset( $snapshot['userId'] ) ? (int) $snapshot['userId'] : 0 );

			$items[] = array(
				'index'     => $index,
				'savedAt'   => isset( $snapshot['savedAt'] ) ? (int) $snapshot['savedAt'] : 0,
				'title'     => isset( $snapshot['title'] ) ? (string) $snapshot['title'] : '',
				'author'    => $user ? $user->display_name : __( 'Unknown', 'antimanual-builder' ),
				'htmlChars' => isset( $snapshot['renderedHtml'] ) ? strlen( (string) $snapshot['renderedHtml'] ) : 0,
				'cssChars'  => isset( $snapshot['customCss'] ) ? strlen( (string) $snapshot['customCss'] ) : 0,
				'jsChars'   => isset( $snapshot['customJs'] ) ? strlen( (string) $snapshot['customJs'] ) : 0,
			);
		}

		return new \WP_REST_Response( array( 'revisions' => $items ), 200 );
	}

	/**
	 * Return one revision snapshot's full content.
	 *
	 * Read-only: this does NOT write anything. The editor loads the
	 * returned content into the canvas as an unsaved change; the user
	 * reviews it and saves through the normal save path (which itself
	 * pushes the pre-save state onto the stack, keeping the restore
	 * reversible). styleMap/pageSettings are decoded to match the shape
	 * the editor store consumes.
	 *
	 * @since  0.4.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_page_revision( $request ) {
		$post = get_post( $request['id'] );

		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return new \WP_Error(
				'not_found',
				__( 'Page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		$snapshots = $this->read_revision_snapshots( $post->ID );
		$index     = (int) $request['index'];

		if ( ! isset( $snapshots[ $index ] ) ) {
			return new \WP_Error(
				'not_found',
				__( 'Revision not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		$snapshot = $snapshots[ $index ];

		$style_map     = json_decode( isset( $snapshot['styleMap'] ) ? (string) $snapshot['styleMap'] : '', true );
		$page_settings = json_decode( isset( $snapshot['pageSettings'] ) ? (string) $snapshot['pageSettings'] : '', true );

		return new \WP_REST_Response(
			array(
				'index'        => $index,
				'title'        => isset( $snapshot['title'] ) ? (string) $snapshot['title'] : '',
				'renderedHtml' => isset( $snapshot['renderedHtml'] ) ? (string) $snapshot['renderedHtml'] : '',
				'customCss'    => isset( $snapshot['customCss'] ) ? (string) $snapshot['customCss'] : '',
				'customJs'     => isset( $snapshot['customJs'] ) ? (string) $snapshot['customJs'] : '',
				'styleMap'     => is_array( $style_map ) ? $style_map : (object) array(),
				'pageSettings' => is_array( $page_settings ) ? $page_settings : (object) array(),
			),
			200
		);
	}

	/**
	 * Delete a builder page.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete_page( $request ) {
		$post = get_post( $request['id'] );

		$supported_types = array( self::POST_TYPE );

		if ( ! $post || ! in_array( $post->post_type, $supported_types, true ) ) {
			return new \WP_Error(
				'not_found',
				__( 'Page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		if ( ! current_user_can( 'delete_post', $post->ID ) ) {
			return new \WP_Error(
				'forbidden',
				__( 'You do not have permission to delete this page.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}

		$result = wp_trash_post( $post->ID );

		if ( ! $result ) {
			return new \WP_Error(
				'delete_failed',
				__( 'Failed to delete page.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		$this->bump_list_cache_version();

		return new \WP_REST_Response( array( 'deleted' => true ), 200 );
	}

	/**
	 * Duplicate a builder page.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function duplicate_page( $request ) {
		$source = get_post( $request['id'] );

		$supported_types = array( self::POST_TYPE );

		if ( ! $source || ! in_array( $source->post_type, $supported_types, true ) ) {
			return new \WP_Error(
				'not_found',
				__( 'Source page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		if ( ! current_user_can( 'read_post', $source->ID ) ) {
			return new \WP_Error(
				'forbidden',
				__( 'You do not have permission to duplicate this page.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}

		$new_post_id = wp_insert_post(
			wp_slash(
				array(
					'post_type'   => self::POST_TYPE,
					/* translators: %s: Original page title. */
					'post_title'  => sprintf( __( '%s (Copy)', 'antimanual-builder' ), $source->post_title ),
					'post_status' => 'draft',
					'post_author' => get_current_user_id(),
				)
			),
			true
		);

		if ( is_wp_error( $new_post_id ) ) {
			return $new_post_id;
		}

		// Copy all meta. get_post_meta returns unslashed values, so re-slash
		// before writing or backslashes (e.g. escaped quotes in the styleMap
		// and pageSettings JSON) are stripped on every duplicate.
		$meta_keys = array( '_ambld_builder_enabled', '_ambld_blocks', '_ambld_rendered_html', '_ambld_style_map', '_ambld_page_settings' );
		foreach ( $meta_keys as $key ) {
			$value = get_post_meta( $source->ID, $key, true );
			if ( $value ) {
				update_post_meta( $new_post_id, $key, is_string( $value ) ? wp_slash( $value ) : $value );
			}
		}

		// Copy CSS/JS source files (stored in uploads, not meta).
		AMBLD_Render_Asset_Generator::copy_source_files( $source->ID, $new_post_id );

		$this->bump_list_cache_version();

		$post = get_post( $new_post_id );
		return new \WP_REST_Response( $this->format_page( $post ), 201 );
	}

	/**
	 * Save page meta fields.
	 *
	 * @since  1.0.0
	 * @param  int   $post_id Post ID.
	 * @param  array $params  Request parameters.
	 * @return void
	 */
	private function save_page_meta( $post_id, $params ) {
		// Save generic builder meta (blocks, renderedHtml, customCss, customJs).
		$this->save_builder_meta( $post_id, $params );

		$meta_map = array(
			'pageSettings' => '_ambld_page_settings',
		);

		// Store the full import document. Script/style code is stripped and the
		// remaining markup is run through a kses allowlist before persistence —
		// the plugin no longer permits arbitrary code insertion via the import
		// surface, per WP.org guidelines.
		if ( isset( $params['importDocument'] ) ) {
			$document = $this->sanitize_import_document( $params['importDocument'] );
			update_post_meta( $post_id, '_ambld_import_document', wp_slash( $document ) );
		}

		foreach ( $meta_map as $param_key => $meta_key ) {
			if ( isset( $params[ $param_key ] ) ) {
				$value = $params[ $param_key ];

				if ( 'pageSettings' === $param_key ) {
					$value = $this->sanitize_page_settings_value( $value );

					// Scripts are stripped from imported markup by the
					// sanitizer (sanitize_builder_page_settings_array),
					// so we no longer need the authorization gate.
					// Clean up the legacy meta key if it exists.
					delete_post_meta( $post_id, '_ambld_imported_markup_authorized_by' );

					// Mirror `pageTemplate` to WordPress's native
					// `_wp_page_template` post meta so theme template
					// selection round-trips with the WP block editor and the
					// frontend template hierarchy can pick it up. Built-in
					// builder presets (canvas/full-width/default) clear the
					// meta — we don't want WP's loader to override our
					// template_include filter for those.
					$this->sync_native_page_template( $post_id, $value );
				}

				// JSON-encode arrays/objects.
				if ( is_array( $value ) || is_object( $value ) ) {
					$value = wp_json_encode( $value );
				}

				// wp_slash prevents update_post_meta's internal wp_unslash from corrupting
				// JSON strings that contain escaped double-quotes (e.g. HTML attributes).
				update_post_meta( $post_id, $meta_key, is_string( $value ) ? wp_slash( $value ) : $value );
			}
		}
	}

	/**
	 * Sanitize an import document with a kses allowlist.
	 *
	 * Well-formed <script>/<style> blocks are regex-stripped first so their
	 * CONTENTS go with them — wp_kses removes only the tags and would leave
	 * the code behind as visible text. wp_kses then enforces a strict
	 * allowlist for everything a denylist cannot catch: unquoted event
	 * handlers (`onerror=alert(1)`), javascript:/data: URIs, SVG event
	 * attributes, malformed script tags. The allowlist is the shared
	 * rendered-HTML allowlist plus document-structure tags (html/head/body/
	 * meta/title/base), mirroring the head allowlist in page-standalone.php.
	 *
	 * Applied at save AND at output (`get_page_document`), so documents
	 * stored before save-time sanitization existed are covered too.
	 *
	 * @since 0.4.0
	 * @param mixed $document Raw document HTML.
	 * @return string Sanitized document.
	 */
	private function sanitize_import_document( $document ) {
		$document = (string) $document;
		if ( '' === $document ) {
			return '';
		}

		$document = preg_replace( '/<script\b[^>]*>[\s\S]*?<\/script>/i', '', $document );
		$document = preg_replace( '/<script\b[^>]*\/?>/i', '', $document );
		$document = preg_replace( '/<style\b[^>]*>[\s\S]*?<\/style>/i', '', $document );

		$allowed = require AMBLD_PLUGIN_DIR . 'templates/frontend/rendered-allowed-tags.php';

		$doc_structure = array(
			'html'  => array(
				'lang'   => true,
				'dir'    => true,
				'class'  => true,
				'id'     => true,
				'prefix' => true,
				'xmlns'  => true,
				'data-*' => true,
			),
			'head'  => array(),
			'body'  => array(
				'class'  => true,
				'id'     => true,
				'style'  => true,
				'dir'    => true,
				'data-*' => true,
			),
			'meta'  => array(
				'name'       => true,
				'content'    => true,
				'charset'    => true,
				'property'   => true,
				'http-equiv' => true,
				'itemprop'   => true,
			),
			'title' => array(),
			'base'  => array(
				'href'   => true,
				'target' => true,
			),
		);

		return wp_kses( $document, array_merge( $allowed, $doc_structure ) );
	}

	/**
	 * Sync `pageTemplate` from page settings to WordPress's `_wp_page_template`.
	 *
	 * @param int   $post_id  Post id being saved.
	 * @param mixed $settings Sanitized page settings (array or scalar).
	 * @return void
	 */
	private function sync_native_page_template( $post_id, $settings ) {
		if ( ! is_array( $settings ) ) {
			return;
		}
		$value = isset( $settings['pageTemplate'] ) ? (string) $settings['pageTemplate'] : '';
		if ( '' === $value || in_array( $value, array( 'default', 'canvas', 'full-width' ), true ) ) {
			// Built-in preset (or unset) — let our template_include filter own
			// rendering. Empty `_wp_page_template` is WP's "default" sentinel.
			update_post_meta( $post_id, '_wp_page_template', '' );
			return;
		}
		if ( '.php' !== substr( $value, -4 ) ) {
			return;
		}
		// Verify the theme actually registers this template before writing —
		// keeps stale selections (after a theme switch) from polluting WP's
		// template hierarchy.
		$post     = get_post( $post_id );
		$registered = wp_get_theme()->get_page_templates( $post, 'page' );
		if ( is_array( $registered ) && isset( $registered[ $value ] ) ) {
			update_post_meta( $post_id, '_wp_page_template', $value );
		}
	}

	/**
	 * Sanitize page settings while preserving import asset markup for trusted users.
	 *
	 * @since  1.0.0
	 * @param  mixed $value Raw page settings value.
	 * @return mixed
	 */
	private function sanitize_page_settings_value( $value ) {
		return $this->sanitize_builder_page_settings_meta_value( $value );
	}

	/**
	 * Resolve the page template that should be used for a save operation.
	 *
	 * Existing native pages can hold a template slug that is no longer registered
	 * by the active theme. Explicitly normalizing it prevents wp_update_post()
	 * from rejecting otherwise valid content updates with `invalid_page_template`.
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post   Post being saved.
	 * @param  array   $params Request parameters.
	 * @return string|null Template slug or null when templates do not apply.
	 */
	private function resolve_page_template_for_save( $post, $params ) {
		if ( ! ( $post instanceof \WP_Post ) || ! $this->post_type_supports_page_templates( $post->post_type ) ) {
			return null;
		}

		$template = isset( $params['pageTemplate'] )
			? sanitize_text_field( $params['pageTemplate'] )
			: get_page_template_slug( $post->ID );

		if ( empty( $template ) || 'default' === $template ) {
			return 'default';
		}

		if ( $this->is_valid_page_template( $template, $post ) ) {
			return $template;
		}

		return 'default';
	}

	/**
	 * Determine whether a post type can use theme page templates.
	 *
	 * @since  1.0.0
	 * @param  string $post_type Post type slug.
	 * @return bool
	 */
	private function post_type_supports_page_templates( $post_type ) {
		return 'page' === $post_type || post_type_supports( $post_type, 'page-attributes' );
	}

	/**
	 * Check whether the provided page template slug is currently registered.
	 *
	 * @since  1.0.0
	 * @param  string  $template Template slug.
	 * @param  WP_Post $post     Post being saved.
	 * @return bool
	 */
	private function is_valid_page_template( $template, $post ) {
		$templates = wp_get_theme()->get_page_templates( $post, $post->post_type );

		return isset( $templates[ $template ] ) || in_array( $template, $templates, true );
	}

	/**
	 * Format a page post into an API response array.
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post Post object.
	 * @return array Formatted page data.
	 */
	private function format_page( $post, $context = 'detail' ) {
		if ( 'list' === $context ) {
			return array(
				'id'        => $post->ID,
				'title'     => $post->post_title,
				'slug'      => $post->post_name,
				'excerpt'   => $post->post_excerpt,
				'status'    => $post->post_status,
				'date'      => $post->post_date_gmt,
				'modified'  => $post->post_modified_gmt,
				'permalink' => get_permalink( $post->ID ),
				'editUrl'   => admin_url( 'admin.php?page=ambld-editor&post_id=' . $post->ID ),
			);
		}

		$import_document  = get_post_meta( $post->ID, '_ambld_import_document', true );
		$imported_js_urls = array();
		$raw_js_urls      = get_post_meta( $post->ID, '_ambld_imported_js_urls', true );
		if ( ! empty( $raw_js_urls ) ) {
			$decoded = json_decode( $raw_js_urls, true );
			if ( is_array( $decoded ) ) {
				$imported_js_urls = array_values( array_filter( $decoded, 'is_string' ) );
			}
		}

		// `customJs` is the canonical inline-JS field. Fall back to the legacy
		// `_ambld_imported_inline_js` meta so pages imported before unification
		// still surface their JS in the editor (migrated to `_ambld_custom_js`
		// on the next save).
		$custom_js = (string) get_post_meta( $post->ID, '_ambld_custom_js', true );
		if ( '' === trim( $custom_js ) ) {
			$custom_js = (string) get_post_meta( $post->ID, '_ambld_imported_inline_js', true );
		}

		// Per-script manifest for faithfully-cloned imported pages (the editable
		// per-file JS the editor/AI work with).
		$imported_scripts = array();
		$raw_scripts      = get_post_meta( $post->ID, '_ambld_imported_scripts', true );
		if ( ! empty( $raw_scripts ) ) {
			$decoded_scripts = json_decode( $raw_scripts, true );
			if ( is_array( $decoded_scripts ) ) {
				$imported_scripts = $decoded_scripts;
			}
		}

		return array(
			'id'               => $post->ID,
			'title'            => $post->post_title,
			'slug'             => $post->post_name,
			'status'           => $post->post_status,
			'excerpt'          => $post->post_excerpt,
			'author'           => (int) $post->post_author,
			'authorName'       => get_the_author_meta( 'display_name', $post->post_author ),
			'date'             => $post->post_date,
			'modified'         => $post->post_modified,
			'permalink'        => get_permalink( $post->ID ),
			'editUrl'          => admin_url( 'admin.php?page=ambld-editor&post_id=' . $post->ID ),
			'blocks'           => get_post_meta( $post->ID, '_ambld_blocks', true ),
			'renderedHtml'     => get_post_meta( $post->ID, '_ambld_rendered_html', true ),
			'customCss'        => (string) get_post_meta( $post->ID, '_ambld_custom_css', true ),
			'customJs'         => $custom_js,
			'styleMap'         => get_post_meta( $post->ID, '_ambld_style_map', true ),
			'pageSettings'     => get_post_meta( $post->ID, '_ambld_page_settings', true ),
			'thumbnail'        => get_the_post_thumbnail_url( $post->ID, 'medium' ),
			'importDocument'   => $import_document ?: null,
			'importedJsUrls'   => $imported_js_urls,
			'importedInlineJs' => (string) get_post_meta( $post->ID, '_ambld_imported_inline_js', true ),
			'importedScripts'  => $imported_scripts,
		);
	}

	/**
	 * Normalize the list response context.
	 *
	 * @since  1.0.0
	 * @param  string $context Requested response context.
	 * @return string
	 */
	private function normalize_response_context( $context ) {
		return 'detail' === $context ? 'detail' : 'list';
	}

	/**
	 * Normalize list orderby values for WP_Query.
	 *
	 * @since  1.0.0
	 * @param  string $orderby Requested orderby value.
	 * @return string
	 */
	private function normalize_list_orderby( $orderby ) {
		$orderby = sanitize_key( (string) $orderby );

		if ( 'title' === $orderby ) {
			return 'title';
		}

		if ( 'date' === $orderby ) {
			return 'date';
		}

		return 'modified';
	}

	/**
	 * Normalize list sort direction.
	 *
	 * @since  1.0.0
	 * @param  string $order Requested order direction.
	 * @return string
	 */
	private function normalize_list_order( $order ) {
		return 'ASC' === strtoupper( (string) $order ) ? 'ASC' : 'DESC';
	}

	/**
	 * Build the transient key for a cached list response.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return string
	 */
	private function get_list_cache_key( $request ) {
		$version = (int) get_option( self::LIST_CACHE_VERSION_OPTION, 1 );

		return 'ambld_pages_list_' . $version . '_' . md5(
			wp_json_encode(
				array(
					'per_page' => (int) $request->get_param( 'per_page' ),
					'page'     => (int) $request->get_param( 'page' ),
					'status'   => (string) $request->get_param( 'status' ),
					'search'   => (string) $request->get_param( 'search' ),
					'orderby'  => $this->normalize_list_orderby( $request->get_param( 'orderby' ) ),
					'order'    => $this->normalize_list_order( $request->get_param( 'order' ) ),
					'context'  => $this->normalize_response_context( $request->get_param( 'context' ) ),
				)
			)
		);
	}

	/**
	 * Fetch a cached list response when available.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return array|null
	 */
	private function get_cached_list_response( $request ) {
		$cached = get_transient( $this->get_list_cache_key( $request ) );

		return is_array( $cached ) ? $cached : null;
	}

	/**
	 * Cache a list response for repeated admin requests.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request  Request object.
	 * @param  array           $response Response payload.
	 * @return void
	 */
	private function set_cached_list_response( $request, $response ) {
		set_transient( $this->get_list_cache_key( $request ), $response, self::LIST_CACHE_TTL );
	}

	/**
	 * Invalidate cached list responses after writes.
	 *
	 * @since  1.0.0
	 * @return void
	 */
	private function bump_list_cache_version() {
		update_option( self::LIST_CACHE_VERSION_OPTION, time(), false );
	}

	/**
	 * Strip <style>, <script>, and inline event-handler attributes from HTML.
	 *
	 * Arbitrary author-supplied CSS/JS storage was removed in 2.1.0. Any
	 * code that arrives inside the rendered HTML body is removed rather
	 * than promoted to storage.
	 *
	 * @since  2.1.0
	 * @param  string $html HTML content.
	 * @return string HTML with <style>/<script>/inline handlers removed.
	 */
	private function strip_inline_code_from_html( $html ) {
		if ( ! is_string( $html ) || '' === $html ) {
			return '';
		}

		$html = preg_replace( '/<style\b[^>]*>[\s\S]*?<\/style>/i', '', $html );
		$html = preg_replace( '/<script\b[^>]*>[\s\S]*?<\/script>/i', '', $html );
		$html = preg_replace( '/<script\b[^>]*\/?>/i', '', $html );
		$html = preg_replace( '/\son[a-z]+\s*=\s*"[^"]*"/i', '', $html );
		$html = preg_replace( "/\son[a-z]+\s*=\s*'[^']*'/i", '', $html );

		// Strip third-party font/CSS references to comply with WP.org no-phone-home policy.
		$html = self::strip_remote_font_links( $html );

		return trim( (string) $html );
	}

	/**
	 * Strip @import / url() statements pointing at known font/CSS CDNs from CSS.
	 *
	 * @since  1.4.0
	 * @param  string $css CSS source.
	 * @return string CSS without remote font CDN references.
	 */
	public static function strip_remote_font_refs( $css ) {
		if ( empty( $css ) ) {
			return $css;
		}
		// Drop @import rules that reference common font/CSS CDNs.
		$css = preg_replace( '/@import\s+(?:url\()?\s*[\'"]?[^\'")]*(?:fonts\.googleapis\.com|fonts\.gstatic\.com|fonts\.bunny\.net|use\.typekit\.net|use\.fontawesome\.com)[^\'"\;]*[\'"]?\s*\)?\s*;?/i', '', (string) $css );
		// Drop bare url() references to the same hosts inside @font-face or similar.
		$css = preg_replace( '/url\(\s*[\'"]?[^\'")]*(?:fonts\.googleapis\.com|fonts\.gstatic\.com|fonts\.bunny\.net|use\.typekit\.net|use\.fontawesome\.com)[^\'")]*[\'"]?\s*\)/i', 'url()', (string) $css );
		return $css;
	}

	/**
	 * Strip <link> tags pointing at known font/CSS CDNs from HTML.
	 *
	 * @since  1.4.0
	 * @param  string $html HTML source.
	 * @return string HTML without remote font CDN <link> tags.
	 */
	public static function strip_remote_font_links( $html ) {
		if ( empty( $html ) ) {
			return $html;
		}
		return preg_replace( '#<link[^>]+(?:fonts\.googleapis\.com|fonts\.gstatic\.com|fonts\.bunny\.net|use\.typekit\.net|use\.fontawesome\.com)[^>]*>#i', '', (string) $html );
	}

	/**
	 * Extract <style> blocks from a rendered HTML body.
	 *
	 * The AI emits page-scoped CSS as a single `<style>` tag at the top
	 * of the generated HTML. To survive the body-level sanitizer (which
	 * strips `<style>`) and to feed the front-end asset pipeline, those
	 * blocks are lifted into a separate CSS string and the HTML is
	 * returned without them.
	 *
	 * @since  1.0.0
	 * @param  string $html HTML content that may contain <style> tags.
	 * @return array { 'html' => string, 'css' => string }
	 */
	private function extract_styles_from_html( $html ) {
		if ( ! is_string( $html ) || '' === $html ) {
			return array(
				'html' => '',
				'css'  => '',
			);
		}

		$css = '';

		if ( preg_match_all( '/<style[^>]*>([\s\S]*?)<\/style>/i', $html, $matches ) ) {
			foreach ( $matches[1] as $style_content ) {
				$css .= trim( $style_content ) . "\n";
			}
			$html = preg_replace( '/<style[^>]*>[\s\S]*?<\/style>/i', '', $html );
			$html = trim( (string) $html );
		}

		return array(
			'html' => (string) $html,
			'css'  => trim( $css ),
		);
	}

	/**
	 * Merge previously stored CSS with newly extracted CSS.
	 *
	 * Replaces the AI-generated block (delimited by markers) so repeated
	 * saves don't accumulate duplicate copies of the same styles. Any
	 * non-AI CSS the user has carried over is preserved.
	 *
	 * @since  1.0.0
	 * @param  string $existing Existing customCss.
	 * @param  string $new      Newly extracted CSS.
	 * @return string Merged CSS.
	 */
	private function merge_css( $existing, $new ) {
		$existing = is_string( $existing ) ? $existing : '';
		$new      = is_string( $new ) ? $new : '';

		if ( '' === $new ) {
			return $existing;
		}
		if ( '' === $existing ) {
			return $new;
		}

		$marker_start = '/* --- AI Generated Styles --- */';
		$marker_end   = '/* --- End AI Generated Styles --- */';

		if ( false !== strpos( $existing, $marker_start ) ) {
			$pattern  = '/' . preg_quote( $marker_start, '/' ) . '[\s\S]*?' . preg_quote( $marker_end, '/' ) . '/';
			$existing = trim( (string) preg_replace( $pattern, '', $existing ) );
		}

		$merged = $marker_start . "\n" . $new . "\n" . $marker_end;
		if ( '' !== $existing ) {
			$merged = $existing . "\n\n" . $merged;
		}

		return $merged;
	}
}
