<?php
/**
 * Components REST API controller.
 *
 * Handles CRUD for reusable components (saved sections, templates, etc.).
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

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

/**
 * Class AMBLD_API_Components_Api
 *
 * REST API endpoints for the component library.
 *
 * @since 1.0.0
 */
class AMBLD_API_Components_Api {

	use AMBLD_API_Builder_Meta_Trait;

	/**
	 * Option key storing the list cache version.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	const LIST_CACHE_VERSION_OPTION = 'ambld_components_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() {
		register_rest_route(
			$this->namespace,
			'/components',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_components' ),
					'permission_callback' => array( $this, 'can_edit_pages' ),
					'args'                => array(
						'type'     => array(
							'default'           => '',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'category' => array(
							'default'           => '',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'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_component' ),
					'permission_callback' => array( $this, 'can_edit_pages' ),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/components/(?P<id>\d+)',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_component' ),
					'permission_callback' => array( $this, 'can_edit_specific_component' ),
				),
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_component' ),
					'permission_callback' => array( $this, 'can_edit_specific_component' ),
				),
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_component' ),
					'permission_callback' => array( $this, 'can_delete_specific_component' ),
				),
			)
		);

		// Where a component is embedded (pages + templates that contain its
		// instances) and how many instances are stale.
		register_rest_route(
			$this->namespace,
			'/components/(?P<id>\d+)/usage',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_component_usage' ),
				'permission_callback' => array( $this, 'can_edit_specific_component' ),
			)
		);

		// Push the current component revision into every (or selected) embedding
		// page, refreshing each instance's HTML + scoped CSS.
		register_rest_route(
			$this->namespace,
			'/components/(?P<id>\d+)/push',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'push_component' ),
				'permission_callback' => array( $this, 'can_edit_specific_component' ),
			)
		);

		// Component category terms for the insert-menu filter.
		register_rest_route(
			$this->namespace,
			'/component-categories',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_component_categories' ),
				'permission_callback' => array( $this, 'can_edit_pages' ),
			)
		);
	}

	/**
	 * Permission check for list / create endpoints.
	 *
	 * @since  1.0.0
	 * @return bool
	 */
	public function can_edit_pages() {
		return current_user_can( 'edit_pages' );
	}

	/**
	 * Per-component edit permission check.
	 *
	 * @since  1.5.0
	 * @param  WP_REST_Request $request The request.
	 * @return true|WP_Error
	 */
	public function can_edit_specific_component( $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		if ( $post_id <= 0 || ! get_post( $post_id ) ) {
			return new \WP_Error( 'rest_post_not_found', __( 'Component 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 component.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}
		return true;
	}

	/**
	 * Per-component delete permission check.
	 *
	 * @since  1.5.0
	 * @param  WP_REST_Request $request The request.
	 * @return true|WP_Error
	 */
	public function can_delete_specific_component( $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		if ( $post_id <= 0 || ! get_post( $post_id ) ) {
			return new \WP_Error( 'rest_post_not_found', __( 'Component 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 component.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}
		return true;
	}

	/**
	 * Get components list.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function get_components( $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'      => AMBLD_Post_Type_Builder_Component::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' ) ),
			'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;
		}

		$type = $request->get_param( 'type' );
		if ( ! empty( $type ) ) {
			$args['meta_query'] = array(
				array(
					'key'   => '_ambld_component_type',
					'value' => sanitize_text_field( $type ),
				),
			);
		}

		$category = $request->get_param( 'category' );
		if ( ! empty( $category ) ) {
			$args['tax_query'] = array(
				array(
					'taxonomy' => 'ambld_component_cat',
					'field'    => 'slug',
					'terms'    => sanitize_text_field( $category ),
				),
			);
		}

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

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

		$response = array(
			'items'      => $components,
			'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 component.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_component( $request ) {
		$post = get_post( $request['id'] );

		if ( ! $post || AMBLD_Post_Type_Builder_Component::POST_TYPE !== $post->post_type ) {
			return new \WP_Error( 'not_found', __( 'Component 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 view this component.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}

		return new \WP_REST_Response( $this->format_component( $post, 'detail' ), 200 );
	}

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

		// Page-scoped CSS embedded inside the rendered HTML is lifted into
		// customCss so the asset pipeline and editor canvas can replay the
		// look the AI generated. `customJs` is persisted by the meta trait.
		if ( 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'] );
			$params['renderedHtml'] = $this->strip_inline_code_from_html( $params['renderedHtml'] );
		}

		$post_id = wp_insert_post(
			array(
				'post_type'   => AMBLD_Post_Type_Builder_Component::POST_TYPE,
				'post_title'  => isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : __( 'Untitled Component', 'antimanual-builder' ),
				'post_status' => 'publish',
				'post_author' => get_current_user_id(),
			),
			true
		);

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

		// Every component must carry a type so it surfaces in the editor insert
		// menu. Default to `custom_block` (the only insertable kind today) when
		// the client doesn't specify one — this is what makes components created
		// from the admin "New Component" button reusable on pages.
		$component_type = isset( $params['componentType'] ) && '' !== trim( (string) $params['componentType'] )
			? sanitize_text_field( $params['componentType'] )
			: 'custom_block';
		update_post_meta( $post_id, '_ambld_component_type', $component_type );

		// Start the revision counter so embedded instances can detect updates.
		update_post_meta( $post_id, '_ambld_component_rev', 1 );

		if ( array_key_exists( 'customCss', $params ) ) {
			$params['customCss'] = $this->scope_component_css( (string) $params['customCss'], $post_id );
		}

		$this->save_builder_meta( $post_id, $params );

		if ( isset( $params['category'] ) ) {
			wp_set_object_terms( $post_id, sanitize_text_field( $params['category'] ), 'ambld_component_cat' );
		}

		$this->bump_list_cache_version();

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

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

		if ( ! $post || AMBLD_Post_Type_Builder_Component::POST_TYPE !== $post->post_type ) {
			return new \WP_Error( 'not_found', __( 'Component 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 component.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}

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

		if ( 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'] );
			$params['renderedHtml'] = $this->strip_inline_code_from_html( $params['renderedHtml'] );
		}

		if ( isset( $params['title'] ) ) {
			$result = wp_update_post(
				array(
					'ID'         => $post->ID,
					'post_title' => sanitize_text_field( $params['title'] ),
				),
				true
			);

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

		if ( isset( $params['componentType'] ) && '' !== trim( (string) $params['componentType'] ) ) {
			update_post_meta( $post->ID, '_ambld_component_type', sanitize_text_field( $params['componentType'] ) );
		} elseif ( '' === (string) get_post_meta( $post->ID, '_ambld_component_type', true ) ) {
			// Backfill legacy/admin-created components that never got a type so
			// they appear in the insert menu after their next save.
			update_post_meta( $post->ID, '_ambld_component_type', 'custom_block' );
		}

		// Bump the revision so embedded instances can detect this update and the
		// author can push it everywhere (see push_component()).
		$current_rev = (int) get_post_meta( $post->ID, '_ambld_component_rev', true );
		update_post_meta( $post->ID, '_ambld_component_rev', $current_rev + 1 );

		if ( array_key_exists( 'customCss', $params ) ) {
			$params['customCss'] = $this->scope_component_css( (string) $params['customCss'], $post->ID );
		}

		if ( array_key_exists( 'category', $params ) ) {
			if ( '' === trim( (string) $params['category'] ) ) {
				wp_set_object_terms( $post->ID, array(), 'ambld_component_cat' );
			} else {
				wp_set_object_terms( $post->ID, sanitize_text_field( $params['category'] ), 'ambld_component_cat' );
			}
		}

		$this->save_builder_meta( $post->ID, $params );

		$this->bump_list_cache_version();

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

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

		if ( ! $post || AMBLD_Post_Type_Builder_Component::POST_TYPE !== $post->post_type ) {
			return new \WP_Error( 'not_found', __( 'Component 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 component.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}

		wp_delete_post( $post->ID, true );
		$this->bump_list_cache_version();
		return new \WP_REST_Response( array( 'deleted' => true ), 200 );
	}

	/**
	 * Report where a component is embedded and how stale each placement is.
	 *
	 * @since  2.3.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_component_usage( $request ) {
		$post = get_post( $request['id'] );
		if ( ! $post || AMBLD_Post_Type_Builder_Component::POST_TYPE !== $post->post_type ) {
			return new \WP_Error( 'not_found', __( 'Component not found.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}

		$component_id = (int) $post->ID;
		$current_rev  = max( 1, (int) get_post_meta( $component_id, '_ambld_component_rev', true ) );
		$source_norm  = $this->normalize_html_for_compare( $this->component_source_node_html( $component_id ) );

		$pages           = array();
		$instances_total = 0;
		$outdated_total  = 0;

		foreach ( $this->find_pages_referencing_component( $component_id ) as $page_id ) {
			$stats = $this->component_page_stats( $page_id, $component_id, $current_rev, $source_norm );
			if ( 0 === $stats['instances'] ) {
				continue;
			}

			$page = get_post( $page_id );
			$pages[] = array(
				'id'        => $page_id,
				'title'     => $page ? $page->post_title : '',
				'type'      => $page ? $page->post_type : '',
				'editUrl'   => $this->entity_edit_url( $page ),
				'instances' => $stats['instances'],
				'outdated'  => $stats['outdated'],
				'modified'  => $stats['modified'],
			);
			$instances_total += $stats['instances'];
			$outdated_total  += $stats['outdated'];
		}

		return new \WP_REST_Response(
			array(
				'componentId' => $component_id,
				'rev'         => $current_rev,
				'total'       => count( $pages ),
				'instances'   => $instances_total,
				'outdated'    => $outdated_total,
				'pages'       => $pages,
			),
			200
		);
	}

	/**
	 * Push the current component revision into embedding pages.
	 *
	 * @since  2.3.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function push_component( $request ) {
		$post = get_post( $request['id'] );
		if ( ! $post || AMBLD_Post_Type_Builder_Component::POST_TYPE !== $post->post_type ) {
			return new \WP_Error( 'not_found', __( 'Component not found.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}

		$component_id = (int) $post->ID;
		$current_rev  = max( 1, (int) get_post_meta( $component_id, '_ambld_component_rev', true ) );
		$source_html  = (string) get_post_meta( $component_id, '_ambld_rendered_html', true );
		$source_css   = (string) get_post_meta( $component_id, '_ambld_custom_css', true );

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

		$requested_ids = null;
		if ( isset( $params['pageIds'] ) && is_array( $params['pageIds'] ) ) {
			$requested_ids = array_map( 'absint', $params['pageIds'] );
		}
		$overwrite = ! empty( $params['overwriteModified'] );

		$updated_pages = array();
		$skipped_pages = array();
		$updated_count = 0;
		$skipped_count = 0;

		foreach ( $this->find_pages_referencing_component( $component_id ) as $page_id ) {
			if ( null !== $requested_ids && ! in_array( $page_id, $requested_ids, true ) ) {
				continue;
			}
			if ( ! current_user_can( 'edit_post', $page_id ) ) {
				continue;
			}

			$result = $this->apply_component_to_page(
				$page_id,
				$component_id,
				$current_rev,
				$source_html,
				$source_css,
				$overwrite
			);

			$page = get_post( $page_id );
			$entry = array(
				'id'      => $page_id,
				'title'   => $page ? $page->post_title : '',
				'updated' => $result['updated'],
				'skipped' => $result['skipped'],
			);

			if ( $result['updated'] > 0 ) {
				$updated_pages[] = $entry;
				$updated_count  += $result['updated'];
			}
			if ( $result['skipped'] > 0 ) {
				$skipped_pages[] = $entry;
				$skipped_count  += $result['skipped'];
			}
		}

		return new \WP_REST_Response(
			array(
				'rev'           => $current_rev,
				'updatedPages'  => $updated_pages,
				'skippedPages'  => $skipped_pages,
				'updatedCount'  => $updated_count,
				'skippedCount'  => $skipped_count,
			),
			200
		);
	}

	/**
	 * List component category terms for the insert-menu filter.
	 *
	 * @since  2.3.0
	 * @return WP_REST_Response
	 */
	public function get_component_categories() {
		$terms = get_terms(
			array(
				'taxonomy'   => 'ambld_component_cat',
				'hide_empty' => false,
			)
		);

		$out = array();
		if ( ! is_wp_error( $terms ) ) {
			foreach ( $terms as $term ) {
				$out[] = array(
					'id'    => (int) $term->term_id,
					'name'  => $term->name,
					'slug'  => $term->slug,
					'count' => (int) $term->count,
				);
			}
		}

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

	/**
	 * Find page/template IDs that embed instances of a component.
	 *
	 * Mirrors the template usage scan: a fast LIKE pre-filter on the rendered
	 * HTML meta, then a strict strpos confirm so `id="5"` can't match `id="50"`.
	 *
	 * @since  2.3.0
	 * @param  int $component_id Component post ID.
	 * @return int[]
	 */
	private function find_pages_referencing_component( $component_id ) {
		$component_id = absint( $component_id );
		if ( $component_id <= 0 ) {
			return array();
		}

		$needle = 'data-amb-component-id="' . $component_id . '"';

		$query = new \WP_Query(
			array(
				'post_type'              => 'any',
				'post_status'            => 'any',
				'posts_per_page'         => 500,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				'meta_query'             => array(
					array(
						'key'     => '_ambld_rendered_html',
						'value'   => $needle,
						'compare' => 'LIKE',
					),
				),
			)
		);

		$matches = array();
		foreach ( $query->posts as $page_id ) {
			$html = (string) get_post_meta( $page_id, '_ambld_rendered_html', true );
			if ( false !== strpos( $html, $needle ) ) {
				$matches[] = (int) $page_id;
			}
		}

		return $matches;
	}

	/**
	 * Count a page's instances of a component and classify them.
	 *
	 * @since  2.3.0
	 * @param  int    $page_id      Page post ID.
	 * @param  int    $component_id Component post ID.
	 * @param  int    $current_rev  Component's current revision.
	 * @param  string $source_norm  Normalized source markup for edit detection.
	 * @return array{instances:int,outdated:int,modified:int}
	 */
	private function component_page_stats( $page_id, $component_id, $current_rev, $source_norm ) {
		$stats = array( 'instances' => 0, 'outdated' => 0, 'modified' => 0 );

		$page_html = (string) get_post_meta( $page_id, '_ambld_rendered_html', true );
		if ( '' === $page_html ) {
			return $stats;
		}

		$frag = $this->load_html_fragment( $page_html );
		if ( ! $frag ) {
			return $stats;
		}

		$xpath = new \DOMXPath( $frag['doc'] );
		$nodes = $xpath->query( './/*[@data-amb-component-id="' . (int) $component_id . '"]', $frag['root'] );
		if ( ! $nodes ) {
			return $stats;
		}

		foreach ( $nodes as $node ) {
			$stats['instances']++;
			$node_rev = (int) $node->getAttribute( 'data-amb-component-rev' );

			if ( $node_rev < $current_rev ) {
				$stats['outdated']++;
				continue;
			}

			$node_norm = $this->normalize_html_for_compare( $frag['doc']->saveHTML( $node ) );
			if ( $node_norm !== $source_norm ) {
				$stats['modified']++;
			}
		}

		return $stats;
	}

	/**
	 * Replace a component's instances inside a single page with the current
	 * source revision.
	 *
	 * @since  2.3.0
	 * @param  int    $page_id      Target page ID.
	 * @param  int    $component_id Component post ID.
	 * @param  int    $current_rev  Component's current revision.
	 * @param  string $source_html  Component rendered HTML.
	 * @param  string $source_css   Component scoped CSS.
	 * @param  bool   $overwrite    Overwrite locally-modified instances.
	 * @return array{updated:int,skipped:int,instances:int}
	 */
	private function apply_component_to_page( $page_id, $component_id, $current_rev, $source_html, $source_css, $overwrite ) {
		$result = array( 'updated' => 0, 'skipped' => 0, 'instances' => 0 );

		$page_html = (string) get_post_meta( $page_id, '_ambld_rendered_html', true );
		if ( '' === $page_html ) {
			return $result;
		}

		$frag = $this->load_html_fragment( $page_html );
		$src  = $this->load_html_fragment( $source_html );
		if ( ! $frag || ! $src ) {
			return $result;
		}

		$src_root = $this->first_element_child( $src['root'] );
		if ( ! $src_root ) {
			return $result;
		}
		$source_norm = $this->normalize_html_for_compare( $src['doc']->saveHTML( $src_root ) );

		$doc   = $frag['doc'];
		$xpath = new \DOMXPath( $doc );
		$found = $xpath->query( './/*[@data-amb-component-id="' . (int) $component_id . '"]', $frag['root'] );
		if ( ! $found || 0 === $found->length ) {
			return $result;
		}

		// Snapshot to a static array — replaceChild mutates the live list.
		$instances = array();
		foreach ( $found as $node ) {
			$instances[] = $node;
		}

		foreach ( $instances as $node ) {
			$result['instances']++;
			$node_rev    = (int) $node->getAttribute( 'data-amb-component-rev' );
			$section_id  = $node->getAttribute( 'data-amb-section' );
			$is_outdated = $node_rev < $current_rev;
			$node_norm   = $this->normalize_html_for_compare( $doc->saveHTML( $node ) );
			$is_modified = ! $is_outdated && $node_norm !== $source_norm;

			if ( $is_modified && ! $overwrite ) {
				$result['skipped']++;
				continue;
			}
			if ( ! $is_outdated && ! $is_modified ) {
				continue; // Already current.
			}

			$replacement = $doc->importNode( $src_root, true );
			if ( '' !== $section_id ) {
				$replacement->setAttribute( 'data-amb-section', $section_id );
			}
			$replacement->setAttribute( 'data-amb-component-id', (string) $component_id );
			$replacement->setAttribute( 'data-amb-component-rev', (string) $current_rev );
			$node->parentNode->replaceChild( $replacement, $node );
			$result['updated']++;
		}

		if ( $result['updated'] > 0 ) {
			$new_html = $this->fragment_inner_html( $frag['root'], $doc );
			update_post_meta( $page_id, '_ambld_rendered_html', wp_slash( $this->sanitize_builder_html_value( $new_html ) ) );

			$page_css = (string) get_post_meta( $page_id, '_ambld_custom_css', true );
			$new_css  = $this->upsert_component_css_region( $page_css, $component_id, $current_rev, $source_css );
			if ( '' !== trim( $new_css ) ) {
				update_post_meta( $page_id, '_ambld_custom_css', wp_slash( $new_css ) );
			}

			wp_update_post(
				array(
					'ID'                => $page_id,
					'post_modified'     => current_time( 'mysql' ),
					'post_modified_gmt' => current_time( 'mysql', true ),
				)
			);

			if ( class_exists( 'AMBLD_Render_Asset_Generator' ) ) {
				AMBLD_Render_Asset_Generator::delete_source_files( $page_id );
			}
		}

		return $result;
	}

	/**
	 * Get the component's source root element markup (its first element child).
	 *
	 * @since  2.3.0
	 * @param  int $component_id Component post ID.
	 * @return string
	 */
	private function component_source_node_html( $component_id ) {
		$src = $this->load_html_fragment( (string) get_post_meta( $component_id, '_ambld_rendered_html', true ) );
		if ( ! $src ) {
			return '';
		}
		$root = $this->first_element_child( $src['root'] );
		return $root ? (string) $src['doc']->saveHTML( $root ) : '';
	}

	/**
	 * Load an HTML fragment into a DOMDocument wrapped in a sentinel root.
	 *
	 * @since  2.3.0
	 * @param  string $html Fragment HTML.
	 * @return array{doc:DOMDocument,root:DOMElement}|null
	 */
	private function load_html_fragment( $html ) {
		if ( ! class_exists( 'DOMDocument' ) || '' === trim( (string) $html ) ) {
			return null;
		}

		$doc  = new \DOMDocument( '1.0', 'UTF-8' );
		$prev = libxml_use_internal_errors( true );
		$ok   = $doc->loadHTML(
			'<?xml encoding="UTF-8"?><div id="ambld-frag-root">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );

		if ( ! $ok ) {
			return null;
		}

		$root = $doc->getElementById( 'ambld-frag-root' );
		return $root ? array( 'doc' => $doc, 'root' => $root ) : null;
	}

	/**
	 * Serialize the inner HTML of a fragment root.
	 *
	 * @since  2.3.0
	 * @param  DOMElement  $root Sentinel root element.
	 * @param  DOMDocument $doc  Owning document.
	 * @return string
	 */
	private function fragment_inner_html( $root, $doc ) {
		$html = '';
		foreach ( $root->childNodes as $child ) {
			$html .= $doc->saveHTML( $child );
		}
		return trim( $html );
	}

	/**
	 * Return the first element child of a node (skipping text nodes).
	 *
	 * @since  2.3.0
	 * @param  DOMNode $node Parent node.
	 * @return DOMElement|null
	 */
	private function first_element_child( $node ) {
		foreach ( $node->childNodes as $child ) {
			if ( XML_ELEMENT_NODE === $child->nodeType ) {
				return $child;
			}
		}
		return null;
	}

	/**
	 * Normalize instance/source markup for edit detection: drop the per-instance
	 * amb attributes and collapse whitespace so only meaningful content differs.
	 *
	 * @since  2.3.0
	 * @param  string $html Markup.
	 * @return string
	 */
	private function normalize_html_for_compare( $html ) {
		$html = (string) $html;
		$html = preg_replace( '/\s+(?:data-amb-section|data-amb-component-id|data-amb-component-rev)="[^"]*"/i', '', $html );
		$html = preg_replace( '/>\s+</', '><', (string) $html );
		$html = preg_replace( '/\s+/', ' ', (string) $html );
		return trim( (string) $html );
	}

	/**
	 * Insert or replace a component's managed CSS region in a page's custom CSS.
	 * PHP twin of upsertComponentCssRegion() in component-css.ts — the markers
	 * MUST stay byte-identical so insert (client) and push (server) agree.
	 *
	 * @since  2.3.0
	 * @param  string $page_css     Page custom CSS.
	 * @param  int    $component_id Component post ID.
	 * @param  int    $rev          Revision being written.
	 * @param  string $scoped_css   Already-scoped component CSS.
	 * @return string
	 */
	private function upsert_component_css_region( $page_css, $component_id, $rev, $scoped_css ) {
		$page_css   = is_string( $page_css ) ? trim( $page_css ) : '';
		$id         = absint( $component_id );
		$scoped_css = trim( (string) $scoped_css );
		$start      = '/* AMB Component ' . $id . ' rev ' . (int) $rev . ' start */';
		$end        = '/* AMB Component ' . $id . ' end */';
		$region     = $start . "\n" . $scoped_css . "\n" . $end;
		$pattern    = '#/\* AMB Component ' . $id . ' rev \d+ start \*/[\s\S]*?/\* AMB Component ' . $id . ' end \*/#';

		if ( preg_match( $pattern, $page_css ) ) {
			return trim( (string) preg_replace( $pattern, $region, $page_css ) );
		}

		if ( '' === $scoped_css ) {
			return $page_css;
		}

		return '' !== $page_css ? $page_css . "\n\n" . $region : $region;
	}

	/**
	 * Build the builder-editor URL for a page/template/component post.
	 *
	 * @since  2.3.0
	 * @param  WP_Post|null $post Post object.
	 * @return string
	 */
	private function entity_edit_url( $post ) {
		if ( ! $post ) {
			return '';
		}

		$args = array(
			'page'    => 'ambld-editor',
			'post_id' => $post->ID,
		);

		if ( AMBLD_Post_Type_Builder_Template::POST_TYPE === $post->post_type ) {
			$args['entity_type'] = AMBLD_Post_Type_Builder_Template::POST_TYPE;
		} elseif ( AMBLD_Post_Type_Builder_Component::POST_TYPE === $post->post_type ) {
			$args['entity_type'] = AMBLD_Post_Type_Builder_Component::POST_TYPE;
		}

		return admin_url( 'admin.php?' . http_build_query( $args ) );
	}

	/**
	 * Format a component post into an API response array.
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post    Post object.
	 * @param  string  $context Requested response context.
	 * @return array
	 */
	private function format_component( $post, $context = 'detail' ) {
		$edit_url = admin_url(
			'admin.php?page=ambld-editor&entity_type=' . rawurlencode( AMBLD_Post_Type_Builder_Component::POST_TYPE ) . '&post_id=' . $post->ID
		);

		if ( 'list' === $context ) {
			return array(
				'id'        => $post->ID,
				'title'     => $post->post_title,
				'status'    => $post->post_status,
				'date'      => $post->post_date_gmt,
				'modified'  => $post->post_modified_gmt,
				'editUrl'   => $edit_url,
				'permalink' => null,
			);
		}

		$category_terms = get_the_terms( $post->ID, 'ambld_component_cat' );
		$category_slug  = ( is_array( $category_terms ) && ! empty( $category_terms ) )
			? $category_terms[0]->slug
			: '';

		return array(
			'id'            => $post->ID,
			'title'         => $post->post_title,
			'status'        => $post->post_status,
			'editUrl'       => $edit_url,
			'componentType' => get_post_meta( $post->ID, '_ambld_component_type', true ),
			'rev'           => max( 1, (int) get_post_meta( $post->ID, '_ambld_component_rev', true ) ),
			'category'      => $category_slug,
			'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'      => (string) get_post_meta( $post->ID, '_ambld_custom_js', true ),
			'thumbnail'     => get_the_post_thumbnail_url( $post->ID, 'medium' ),
			'date'          => $post->post_date,
			'modified'      => $post->post_modified,
		);
	}

	/**
	 * 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_components_list_' . $version . '_' . md5(
			wp_json_encode(
				array(
					'type'     => (string) $request->get_param( 'type' ),
					'category' => (string) $request->get_param( 'category' ),
					'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.
	 *
	 * @since 2.1.0
	 * @param string $html Raw component HTML.
	 * @return string
	 */
	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 );
		$html = AMBLD_API_Pages_Api::strip_remote_font_links( $html );

		return trim( (string) $html );
	}

	/**
	 * Extract <style> blocks out of the rendered HTML so they survive
	 * the body sanitizer and can be persisted as page-scoped CSS.
	 *
	 * @since 2.2.0
	 * @param string $html Raw rendered HTML.
	 * @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, preserving
	 * any non-AI rules carried over by the user.
	 *
	 * @since 2.2.0
	 * @param string $existing Existing CSS.
	 * @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;
	}

	/**
	 * Scope a component's CSS under its own instance attribute so it can't bleed
	 * into the pages that embed it.
	 *
	 * Every selector is prefixed with `[data-amb-component-id="<id>"]`. Two
	 * variants are emitted per selector so rules targeting the block's root
	 * element work as well as rules targeting descendants: an ancestor form
	 * (`[scope] sel`) and a root form (`[scope]<first-compound> rest`). On a
	 * live page the instance root carries the attribute (root form matches it,
	 * ancestor form matches descendants); in the component editor the content
	 * wrapper carries it (ancestor form matches everything).
	 *
	 * Idempotent: selectors already containing the scope attribute are left
	 * untouched, so re-saving a component never double-scopes it.
	 *
	 * @since 2.3.0
	 * @param string $css          Raw (possibly already-scoped) component CSS.
	 * @param int    $component_id Component post ID.
	 * @return string Scoped CSS.
	 */
	private function scope_component_css( $css, $component_id ) {
		$css = is_string( $css ) ? trim( $css ) : '';
		if ( '' === $css ) {
			return '';
		}

		// Drop comments so they can't be mistaken for selector text while
		// walking rules (component CSS carries no meaningful comments).
		$css = (string) preg_replace( '#/\*[\s\S]*?\*/#', '', $css );

		$scope = '[data-amb-component-id="' . absint( $component_id ) . '"]';

		return $this->scope_css_rules( $css, $scope );
	}

	/**
	 * Walk top-level CSS rules and scope each, recursing into grouping at-rules.
	 *
	 * @since 2.3.0
	 * @param string $css   CSS source.
	 * @param string $scope Scope selector prefix.
	 * @return string
	 */
	private function scope_css_rules( $css, $scope ) {
		$out = '';
		$len = strlen( $css );
		$i   = 0;

		while ( $i < $len ) {
			$brace = strpos( $css, '{', $i );
			if ( false === $brace ) {
				break;
			}

			$prelude = trim( substr( $css, $i, $brace - $i ) );

			// Find the matching close brace for this block.
			$depth = 1;
			$j     = $brace + 1;
			while ( $j < $len && $depth > 0 ) {
				if ( '{' === $css[ $j ] ) {
					$depth++;
				} elseif ( '}' === $css[ $j ] ) {
					$depth--;
				}
				$j++;
			}
			$body = substr( $css, $brace + 1, max( 0, $j - $brace - 2 ) );
			$i    = $j;

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

			if ( '@' === $prelude[0] ) {
				$lower = strtolower( $prelude );
				if (
					0 === strpos( $lower, '@media' ) ||
					0 === strpos( $lower, '@supports' ) ||
					0 === strpos( $lower, '@container' ) ||
					0 === strpos( $lower, '@document' )
				) {
					$out .= $prelude . " {\n" . $this->scope_css_rules( $body, $scope ) . "\n}\n";
				} else {
					// @keyframes, @font-face, @page, … — keep verbatim.
					$out .= $prelude . ' {' . $body . "}\n";
				}
				continue;
			}

			$out .= $this->scope_selector_list( $prelude, $scope ) . ' {' . $body . "}\n";
		}

		return trim( $out );
	}

	/**
	 * Scope a comma-separated selector list.
	 *
	 * @since 2.3.0
	 * @param string $selectors Selector list.
	 * @param string $scope     Scope selector prefix.
	 * @return string
	 */
	private function scope_selector_list( $selectors, $scope ) {
		$scoped = array();

		foreach ( $this->split_selectors( $selectors ) as $selector ) {
			$selector = trim( $selector );
			if ( '' === $selector ) {
				continue;
			}

			// Already scoped — keep as-is (idempotent re-saves).
			if ( false !== strpos( $selector, '[data-amb-component-id' ) ) {
				$scoped[] = $selector;
				continue;
			}

			$scoped[] = $scope . ' ' . $selector;

			list( $first, $rest ) = $this->split_first_compound( $selector );
			if ( '' !== $first && '*' !== $first ) {
				$scoped[] = $scope . $first . $rest;
			}
		}

		return implode( ', ', $scoped );
	}

	/**
	 * Split a selector list on top-level commas (ignoring commas inside
	 * parentheses or brackets, e.g. `:is(a, b)` or `[x="a,b"]`).
	 *
	 * @since 2.3.0
	 * @param string $list Selector list.
	 * @return string[]
	 */
	private function split_selectors( $list ) {
		$result = array();
		$buffer = '';
		$depth  = 0;
		$len    = strlen( $list );

		for ( $i = 0; $i < $len; $i++ ) {
			$ch = $list[ $i ];
			if ( '(' === $ch || '[' === $ch ) {
				$depth++;
			} elseif ( ')' === $ch || ']' === $ch ) {
				$depth = max( 0, $depth - 1 );
			}

			if ( ',' === $ch && 0 === $depth ) {
				$result[] = $buffer;
				$buffer   = '';
			} else {
				$buffer .= $ch;
			}
		}

		if ( '' !== trim( $buffer ) ) {
			$result[] = $buffer;
		}

		return $result;
	}

	/**
	 * Split a complex selector into its first compound selector and the
	 * remainder (including the leading combinator), ignoring parens/brackets.
	 *
	 * @since 2.3.0
	 * @param string $selector Complex selector.
	 * @return array{0:string,1:string}
	 */
	private function split_first_compound( $selector ) {
		$selector = trim( $selector );
		$len      = strlen( $selector );
		$depth    = 0;

		for ( $i = 0; $i < $len; $i++ ) {
			$ch = $selector[ $i ];
			if ( '(' === $ch || '[' === $ch ) {
				$depth++;
				continue;
			}
			if ( ')' === $ch || ']' === $ch ) {
				$depth = max( 0, $depth - 1 );
				continue;
			}
			if ( 0 === $depth && ( ' ' === $ch || "\t" === $ch || "\n" === $ch || '>' === $ch || '+' === $ch || '~' === $ch ) ) {
				return array( substr( $selector, 0, $i ), substr( $selector, $i ) );
			}
		}

		return array( $selector, '' );
	}
}
