<?php
/**
 * REST: Outside Visual Builder operations for AI agent tools.
 *
 * Theme Builder "templates" are Divi Theme Builder assignments (et_template posts), not WordPress navigation menus.
 *
 * @package Divi
 * @since ??
 */

namespace ET\Builder\VisualBuilder\REST\OutsideVb;

if ( ! defined( 'ABSPATH' ) ) {
	die( 'Direct access forbidden.' );
}

use ET\Builder\Framework\Controllers\RESTController;
use ET\Builder\VisualBuilder\REST\Portability\PortabilityController;
use WP_REST_Request;
use WP_REST_Response;

/**
 * Outside-VB REST controller for Theme Builder, theme options, layout export, and applying layouts to posts.
 */
class OutsideVbController extends RESTController {

	/**
	 * Option keys allowed for read/update via REST (Divi theme options / `et_get_option`).
	 *
	 * @var string[]
	 */
	private const THEME_OPTION_ALLOWLIST = [
		'divi_blog_style',
		'divi_disable_translations',
		'heading_font',
		'body_font',
		'heading_font_weight',
		'body_font_weight',
		'body_font_height',
		'body_font_size',
		'body_header_size',
		'content_width',
		'accent_color',
		'et_pb_static_css_file',
		'et_pb_css_in_footer',
		'gutter_width',
		'vertical_nav',
		'header_style',
		'color_schemes',
	];

	/**
	 * Whether the current user may manage Theme Builder templates.
	 *
	 * @return bool
	 */
	public static function theme_builder_permission(): bool {
		if ( ! current_user_can( 'edit_others_posts' ) ) {
			return false;
		}

		if ( function_exists( 'et_pb_is_allowed' ) && ! et_pb_is_allowed( 'theme_builder' ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Whether the current user may read or update theme options.
	 *
	 * @return bool
	 */
	public static function theme_options_permission(): bool {
		return current_user_can( 'edit_theme_options' );
	}

	/**
	 * List Theme Builder templates (draft or live).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|\WP_Error
	 */
	public static function list_templates( WP_REST_Request $request ) {
		$live = (bool) $request->get_param( 'live' );

		$theme_builder_id = et_theme_builder_get_theme_builder_post_id( $live, false );

		if ( 0 === $theme_builder_id ) {
			return self::response_success(
				[
					'themeBuilderId' => 0,
					'templates'      => [],
				]
			);
		}

		$templates = et_theme_builder_get_theme_builder_templates( $live, $theme_builder_id );

		return self::response_success(
			[
				'themeBuilderId' => $theme_builder_id,
				'templates'      => array_values( $templates ),
			]
		);
	}

	/**
	 * Create a Theme Builder template.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function create_template( WP_REST_Request $request ) {
		$live             = (bool) $request->get_param( 'live' );
		$theme_builder_id = et_theme_builder_get_theme_builder_post_id( $live, true );
		$title            = $request->get_param( 'title' );
		$template         = [
			'id'                  => 0,
			'title'               => $title,
			'autogenerated_title' => '' === $title ? '1' : '0',
			'default'             => '0',
			'enabled'             => '1',
			'layouts'             => [
				'header' => [
					'id'      => 0,
					'enabled' => true,
				],
				'body'   => [
					'id'      => 0,
					'enabled' => true,
				],
				'footer' => [
					'id'      => 0,
					'enabled' => true,
				],
			],
			'use_on'              => [],
			'exclude_from'        => [],
		];
		$new_post_id      = et_theme_builder_store_template( $theme_builder_id, $template, true );

		if ( ! $new_post_id ) {
			return self::response_error( 'create_failed', esc_html__( 'Failed to create Theme Builder template.', 'et_builder_5' ) );
		}

		if ( ! self::_append_template_id( $theme_builder_id, (int) $new_post_id ) ) {
			// Roll back the orphan template post so the Theme Builder state stays consistent.
			wp_delete_post( (int) $new_post_id, true );

			return self::response_error(
				'create_attach_failed',
				esc_html__( 'Failed to attach Theme Builder template to the Theme Builder.', 'et_builder_5' )
			);
		}

		return self::response_success(
			[
				'id'             => (int) $new_post_id,
				'themeBuilderId' => $theme_builder_id,
				'template'       => et_theme_builder_get_template( (int) $new_post_id ),
			]
		);
	}

	/**
	 * Update a Theme Builder template.
	 *
	 * The template post is updated in place: `et_theme_builder_store_template` performs an
	 * `$exists` check against `$store['id']`, so we pin the target id to `$template_id` to
	 * guarantee in-place update semantics even if a caller tries to override `id` in the
	 * incoming payload. If the existing template cannot be loaded we fail fast rather than
	 * letting `array_replace_recursive` synthesize a new structure and insert a fresh post.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_template( WP_REST_Request $request ) {
		$live             = (bool) $request->get_param( 'live' );
		$template_id      = (int) $request->get_param( 'template_id' );
		$incoming         = $request->get_param( 'template' );
		$theme_builder_id = et_theme_builder_get_theme_builder_post_id( $live, true );

		$existing = et_theme_builder_get_template( $template_id );

		// TOCTOU guard (not a validation duplicate): the args `validate_callback` already
		// confirmed the template existed at request-validation time, but the template could
		// have been trashed or deleted between validation and this controller call. Falling
		// through with `empty( $existing )` would cause `et_theme_builder_store_template` to
		// treat the payload as a fresh insert and create an orphan post, so we fail closed.
		if ( empty( $existing ) || ! isset( $existing['id'] ) || (int) $existing['id'] !== $template_id ) {
			return self::response_error(
				'template_not_found',
				esc_html__( 'Theme Builder template was not found.', 'et_builder_5' ),
				[],
				404
			);
		}

		$merged       = array_replace_recursive( $existing, is_array( $incoming ) ? $incoming : [] );
		$merged['id'] = $template_id; // Pin id to prevent orphan creation via overridden id.
		$store        = self::_template_to_store_format( $merged );

		$new_id = et_theme_builder_store_template( $theme_builder_id, $store, true );

		if ( ! is_int( $new_id ) || $new_id !== $template_id ) {
			return self::response_error( 'update_failed', esc_html__( 'Failed to update Theme Builder template.', 'et_builder_5' ) );
		}

		return self::response_success(
			[
				'id'       => $new_id,
				'template' => et_theme_builder_get_template( $new_id ),
			]
		);
	}

	/**
	 * Delete (trash) a Theme Builder template and detach it from the Theme Builder post.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_template( WP_REST_Request $request ) {
		$live            = (bool) $request->get_param( 'live' );
		$template_id     = (int) $request->get_param( 'template_id' );
		$tb_id           = et_theme_builder_get_theme_builder_post_id( $live, false );
		$template_post   = get_post( $template_id );
		$original_status = $template_post instanceof \WP_Post ? $template_post->post_status : '';

		// Trash first; if it fails there is nothing to detach and no partial state.
		$trashed = wp_trash_post( $template_id );

		if ( empty( $trashed ) ) {
			return self::response_error(
				'delete_failed',
				esc_html__( 'Failed to trash Theme Builder template.', 'et_builder_5' )
			);
		}

		if ( $tb_id > 0 ) {
			$ids = get_post_meta( $tb_id, '_et_template', false );
			$ids = is_array( $ids ) ? array_map( 'intval', $ids ) : [];
			$ids = array_values(
				array_filter(
					$ids,
					static function ( $id ) use ( $template_id ) {
						return (int) $id !== $template_id;
					}
				)
			);

			if ( ! self::_replace_template_ids( $tb_id, $ids ) ) {
				/*
				 * Best-effort rollback: restore the trashed template post so the Theme
				 * Builder row is not dangling.
				 */
				$restored = wp_untrash_post( $template_id );

				if ( ! ( $restored instanceof \WP_Post ) ) {
					return self::response_error(
						'delete_detach_failed',
						esc_html__( 'Template was trashed but failed to detach from the Theme Builder and automatic rollback also failed.', 'et_builder_5' ),
						[
							'rollback' => 'failed',
						]
					);
				}

				/*
				 * WordPress 5.6+ untrashes posts to 'draft' by default. Always restore
				 * the original status explicitly. Pass `true` so wp_update_post returns
				 * a WP_Error (instead of 0) on failure, allowing proper error detection.
				 */
				if ( '' !== $original_status ) {
					$restored_status = wp_update_post(
						[
							'ID'          => $template_id,
							'post_status' => $original_status,
						],
						true
					);

					if ( is_wp_error( $restored_status ) || 0 === $restored_status ) {
						return self::response_error(
							'delete_detach_failed',
							esc_html__( 'Template was trashed and failed to detach from the Theme Builder; rollback status restore failed.', 'et_builder_5' ),
							[
								'rollback' => 'failed',
							],
						);
					}
				}

				return self::response_error(
					'delete_detach_failed',
					esc_html__( 'Failed to detach template from the Theme Builder; the template was restored.', 'et_builder_5' ),
					[
						'rollback' => 'restored',
					]
				);
			}
		}

		return self::response_success( [ 'id' => $template_id ] );
	}

	/**
	 * Assign display conditions (use_on / exclude_from) for a Theme Builder template.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function assign_template( WP_REST_Request $request ) {
		$live             = (bool) $request->get_param( 'live' );
		$template_id      = (int) $request->get_param( 'template_id' );
		$theme_builder_id = et_theme_builder_get_theme_builder_post_id( $live, true );

		$existing = et_theme_builder_get_template( $template_id );

		// TOCTOU guard (same rationale as `update_template`): the args `validate_callback`
		// confirmed the template existed at validation time, but it could have been trashed
		// between then and this controller call. Calling `et_theme_builder_store_template`
		// without a concrete existing template would create an orphan post, so fail closed.
		if ( empty( $existing ) || ! isset( $existing['id'] ) || (int) $existing['id'] !== $template_id ) {
			return self::response_error(
				'template_not_found',
				esc_html__( 'Theme Builder template was not found.', 'et_builder_5' ),
				[],
				404
			);
		}

		$use_on       = $request->get_param( 'use_on' );
		$exclude_from = $request->get_param( 'exclude_from' );

		if ( null !== $use_on ) {
			$existing['use_on'] = array_map( 'sanitize_text_field', $use_on );
		}

		if ( null !== $exclude_from ) {
			$existing['exclude_from'] = array_map( 'sanitize_text_field', $exclude_from );
		}

		$existing['id'] = $template_id; // Pin id to prevent orphan creation.
		$store          = self::_template_to_store_format( $existing );
		$new_id         = et_theme_builder_store_template( $theme_builder_id, $store, true );

		if ( ! is_int( $new_id ) || $new_id !== $template_id ) {
			return self::response_error( 'assign_failed', esc_html__( 'Failed to assign Theme Builder template conditions.', 'et_builder_5' ) );
		}

		return self::response_success(
			[
				'id'       => $new_id,
				'template' => et_theme_builder_get_template( $new_id ),
			]
		);
	}

	/**
	 * Get allowlisted Divi theme options (`et_get_option`).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response
	 */
	public static function get_theme_options( WP_REST_Request $request ): WP_REST_Response {
		$keys = $request->get_param( 'keys' );
		$keys = is_array( $keys ) ? $keys : self::THEME_OPTION_ALLOWLIST;
		$keys = array_intersect( array_map( 'sanitize_text_field', $keys ), self::THEME_OPTION_ALLOWLIST );

		if ( empty( $keys ) ) {
			$keys = self::THEME_OPTION_ALLOWLIST;
		}

		$options = [];

		foreach ( $keys as $key ) {
			$options[ $key ] = et_get_option( $key, '', '', false );
		}

		return self::response_success( [ 'options' => $options ] );
	}

	/**
	 * Update a single allowlisted theme option.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_theme_option( WP_REST_Request $request ) {
		$key   = $request->get_param( 'key' );
		$value = $request->get_param( 'value' );

		/*
		 * `et_update_option` is historically void/mixed; verify the write by re-reading the
		 * option and comparing to the sanitized value. Using loose comparison here because
		 * `et_get_option` may coerce types across storage layers (e.g. serialized scalars).
		 */
		et_update_option( $key, $value );

		$stored = et_get_option( $key, '', '', false );

		if ( (string) $stored !== (string) $value ) {
			return self::response_error(
				'update_failed',
				esc_html__( 'Theme option write could not be verified.', 'et_builder_5' ),
				[
					'key'      => $key,
					'expected' => $value,
					'stored'   => $stored,
				]
			);
		}

		return self::response_success(
			[
				'key'   => $key,
				'value' => $stored,
			]
		);
	}

	/**
	 * Export layout JSON for a post via portability (wraps `PortabilityController::show`).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function export_layout( WP_REST_Request $request ) {
		$post_id = (int) $request->get_param( 'post_id' );
		$post    = get_post( $post_id );

		if ( ! ( $post instanceof \WP_Post ) ) {
			return self::response_error(
				'post_not_found',
				esc_html__( 'Post not found.', 'et_builder_5' )
			);
		}

		$sub = new WP_REST_Request( 'POST' );
		$sub->set_param( 'context', 'et_builder' );
		$sub->set_param( 'post', (string) $post_id );
		$sub->set_param( 'content', $post->post_content );
		$sub->set_param( 'portability_type', 'post' );

		return PortabilityController::show( $sub );
	}

	/**
	 * Apply Divi builder content to a post or page (does not create posts; use dedicated page/post tools for CRUD).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function set_post_layout( WP_REST_Request $request ) {
		$post_id   = (int) $request->get_param( 'post_id' );
		$source_id = (int) $request->get_param( 'source_layout_post_id' );
		$content   = $request->get_param( 'layout_content' );

		// Args layer (set_post_layout_args) already enforced: post exists, source post
		// exists when provided, and exactly one of source/content is set. So the controller
		// only needs to pick the branch.
		if ( $source_id > 0 ) {
			$source = get_post( $source_id );
			if ( ! ( $source instanceof \WP_Post ) ) {
				return self::response_error(
					'source_layout_not_found',
					esc_html__( 'Source layout post was removed before applying the layout.', 'et_builder_5' ),
					[],
					404
				);
			}
			$new_content = $source->post_content;
		} else {
			$new_content = (string) $content;
		}

		/*
		 * Snapshot the original state before mutating so we can roll back if the
		 * builder-flag write fails. `wp_update_post` + `update_post_meta` cannot be done as a
		 * single transaction, so the best we can do is restore the prior `post_content` and
		 * meta value to avoid leaving the post in an inconsistent state (content updated but
		 * builder flag unset would render Divi shortcodes as raw text on the frontend).
		 * `post_id` was already validated as an existing WP_Post by the args layer.
		 */
		$original_post    = get_post( $post_id );
		$original_content = $original_post->post_content;
		$original_flag    = get_post_meta( $post_id, '_et_pb_use_builder', true );

		$updated = wp_update_post(
			[
				'ID'           => $post_id,
				'post_content' => wp_slash( $new_content ),
			],
			true
		);

		if ( is_wp_error( $updated ) ) {
			return self::response_error( 'update_failed', esc_html__( 'Failed to update post content.', 'et_builder_5' ) );
		}

		/*
		 * Verify the builder flag write succeeds before returning success.
		 * `update_post_meta` returns false both on DB failure AND when the new value matches
		 * the current one, so skip the write when the flag is already set to avoid a false
		 * negative.
		 */
		$current_flag = get_post_meta( $post_id, '_et_pb_use_builder', true );

		if ( 'on' !== $current_flag && false === update_post_meta( $post_id, '_et_pb_use_builder', 'on' ) ) {
			// Roll back the post_content update so the post is not left half-migrated.
			wp_update_post(
				[
					'ID'           => $post_id,
					'post_content' => wp_slash( $original_content ),
				]
			);

			if ( '' !== $original_flag ) {
				update_post_meta( $post_id, '_et_pb_use_builder', $original_flag );
			} else {
				delete_post_meta( $post_id, '_et_pb_use_builder' );
			}

			return self::response_error(
				'builder_flag_failed',
				esc_html__( 'Builder flag could not be set; post was rolled back to its previous state.', 'et_builder_5' ),
				[
					'rollback' => 'restored',
				]
			);
		}

		if ( function_exists( 'et_builder_enable_for_post' ) ) {
			et_builder_enable_for_post( $post_id, false );
		}

		return self::response_success(
			[
				'post_id' => $post_id,
			]
		);
	}

	/**
	 * Args for list_templates.
	 *
	 * @return array
	 */
	public static function list_templates_args(): array {
		return [
			'live' => [
				'required'          => false,
				'default'           => false,
				'sanitize_callback' => function ( $value ) {
					return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
				},
			],
		];
	}

	/**
	 * Args for create_template.
	 *
	 * @return array
	 */
	public static function create_template_args(): array {
		return [
			'live'  => [
				'required'          => false,
				'default'           => false,
				'sanitize_callback' => function ( $value ) {
					return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
				},
			],
			'title' => [
				'required'          => false,
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
			],
		];
	}

	/**
	 * Args for update_template.
	 *
	 * @return array
	 */
	public static function update_template_args(): array {
		return [
			'live'        => [
				'required' => false,
				'default'  => false,
				'type'     => 'boolean',
			],
			'template_id' => [
				'required'          => true,
				'type'              => 'integer',
				'minimum'           => 1,
				'validate_callback' => function ( $value ) {
					return ! empty( et_theme_builder_get_template( (int) $value ) ) && 'publish' === get_post_status( (int) $value );
				},
				'sanitize_callback' => 'absint',
			],
			'template'    => [
				'required'          => true,
				'type'              => 'object',
				'validate_callback' => [ self::class, '_validate_template_shape' ],
			],
		];
	}

	/**
	 * Args for delete_template.
	 *
	 * @return array
	 */
	public static function delete_template_args(): array {
		return [
			'live'        => [
				'required' => false,
				'default'  => false,
				'type'     => 'boolean',
			],
			'template_id' => [
				'required'          => true,
				'type'              => 'integer',
				'minimum'           => 1,
				'validate_callback' => function ( $value ) {
					$template_post = get_post( (int) $value );
					return $template_post instanceof \WP_Post && ET_THEME_BUILDER_TEMPLATE_POST_TYPE === $template_post->post_type;
				},
				'sanitize_callback' => 'absint',
			],
		];
	}

	/**
	 * Args for assign_template.
	 *
	 * @return array
	 */
	public static function assign_template_args(): array {
		return [
			'live'         => [
				'required' => false,
				'default'  => false,
				'type'     => 'boolean',
			],
			'template_id'  => [
				'required'          => true,
				'type'              => 'integer',
				'minimum'           => 1,
				'validate_callback' => function ( $value ) {
					return ! empty( et_theme_builder_get_template( (int) $value ) ) && 'publish' === get_post_status( (int) $value );
				},
				'sanitize_callback' => 'absint',
			],
			'use_on'       => [
				'required' => false,
				'type'     => 'array',
				'items'    => [
					'type' => 'string',
				],
			],
			'exclude_from' => [
				'required' => false,
				'type'     => 'array',
				'items'    => [
					'type' => 'string',
				],
			],
		];
	}

	/**
	 * Args for get_theme_options.
	 *
	 * @return array
	 */
	public static function get_theme_options_args(): array {
		return [
			'keys' => [
				'required' => false,
			],
		];
	}

	/**
	 * Args for update_theme_option.
	 *
	 * @return array
	 */
	public static function update_theme_option_args(): array {
		return [
			'key'   => [
				'required'          => true,
				'validate_callback' => function ( $value ) {
					return in_array( $value, self::THEME_OPTION_ALLOWLIST, true );
				},
				'sanitize_callback' => 'sanitize_text_field',
			],
			'value' => [
				'required'          => true,
				'type'              => 'string',
				'validate_callback' => function ( $value ) {
					return is_string( $value );
				},
				'sanitize_callback' => 'sanitize_text_field',
			],
		];
	}

	/**
	 * Args for export_layout.
	 *
	 * @return array
	 */
	public static function export_layout_args(): array {
		return [
			'post_id' => [
				'required'          => true,
				'type'              => 'integer',
				'minimum'           => 1,
				'validate_callback' => function ( $value ) {
					return get_post( (int) $value ) instanceof \WP_Post;
				},
				'sanitize_callback' => 'absint',
			],
		];
	}

	/**
	 * Args for set_post_layout.
	 *
	 * @return array
	 */
	public static function set_post_layout_args(): array {
		return [

			/*
			 * Cross-field "exactly one of" validation (PHP-1.5) is attached to `post_id`
			 * because WP_REST_Server only runs validate_callback for params that are present
			 * in the request, and `post_id` is the one `required` key that is guaranteed to
			 * be set. Hanging the XOR check off `source_layout_post_id` would silently
			 * bypass when only `layout_content` is sent, and vice versa. Resource existence
			 * checks for the source post live here too so the args layer fully owns
			 * validation (business logic stays in the controller per the architecture rule).
			 */
			'post_id'               => [
				'required'          => true,
				'type'              => 'integer',
				'minimum'           => 1,
				'validate_callback' => function ( $value, $request ) {
					if ( ! ( get_post( (int) $value ) instanceof \WP_Post ) ) {
						return new \WP_Error(
							'rest_invalid_param',
							esc_html__( 'Post not found.', 'et_builder_5' ),
							[ 'status' => 400 ]
						);
					}

					$source_id   = (int) $request->get_param( 'source_layout_post_id' );
					$content     = $request->get_param( 'layout_content' );
					$has_source  = $source_id > 0;
					$has_content = is_string( $content ) && '' !== $content;

					if ( ! $has_source && ! $has_content ) {
						return new \WP_Error(
							'rest_invalid_param',
							esc_html__( 'Provide source_layout_post_id or layout_content.', 'et_builder_5' ),
							[ 'status' => 400 ]
						);
					}

					if ( $has_source && $has_content ) {
						return new \WP_Error(
							'rest_invalid_param',
							esc_html__( 'Provide only one of source_layout_post_id or layout_content.', 'et_builder_5' ),
							[ 'status' => 400 ]
						);
					}

					if ( $has_source && ! ( get_post( $source_id ) instanceof \WP_Post ) ) {
						return new \WP_Error(
							'rest_invalid_param',
							esc_html__( 'Source layout post not found.', 'et_builder_5' ),
							[ 'status' => 400 ]
						);
					}

					return true;
				},
				'sanitize_callback' => 'absint',
			],
			'source_layout_post_id' => [
				'required'          => false,
				'type'              => 'integer',
				'minimum'           => 0,
				'sanitize_callback' => 'absint',
			],
			'layout_content'        => [
				'required' => false,
				'type'     => 'string',
			],
		];
	}

	/**
	 * Permission for export_layout (aligned with `PortabilityController::show_permission` for `et_builder`).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return bool
	 */
	public static function export_layout_permission( WP_REST_Request $request ): bool {
		$post_id = (int) $request->get_param( 'post_id' );

		if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
			return false;
		}

		$sub_request = new WP_REST_Request( 'POST' );
		$sub_request->set_param( 'context', 'et_builder' );

		$result = PortabilityController::show_permission( $sub_request );

		return true === $result;
	}

	/**
	 * Permission for set_post_layout.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return bool
	 */
	public static function set_post_layout_permission( WP_REST_Request $request ): bool {
		$post_id = (int) $request->get_param( 'post_id' );

		if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
			return false;
		}

		$source_id = (int) $request->get_param( 'source_layout_post_id' );

		if ( $source_id > 0 && ! current_user_can( 'read_post', $source_id ) ) {
			return false;
		}

		return current_user_can( 'edit_posts' );
	}

	/**
	 * Permission for delete_template. Requires the coarse Theme Builder feature
	 * capability PLUS the resource-scoped `delete_post` capability for the target
	 * template (PHP-1.13).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return bool
	 */
	public static function delete_template_permission( WP_REST_Request $request ): bool {
		if ( ! self::theme_builder_permission() ) {
			return false;
		}

		$template_id = (int) $request->get_param( 'template_id' );

		if ( $template_id > 0 && ! current_user_can( 'delete_post', $template_id ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Permission for update_template. Requires the coarse Theme Builder feature
	 * capability PLUS the resource-scoped `edit_post` capability for the target
	 * template (PHP-1.13). Coarse-only checks would grant any Theme Builder user
	 * write access to every template on the site.
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return bool
	 */
	public static function update_template_permission( WP_REST_Request $request ): bool {
		if ( ! self::theme_builder_permission() ) {
			return false;
		}

		$template_id = (int) $request->get_param( 'template_id' );

		if ( $template_id > 0 && ! current_user_can( 'edit_post', $template_id ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Permission for assign_template. Same resource-scoped model as
	 * `update_template_permission` — assigning display conditions mutates the
	 * template post's meta via `et_theme_builder_store_template`, so the caller
	 * must be able to `edit_post` that specific template (PHP-1.13).
	 *
	 * @param WP_REST_Request $request Request.
	 *
	 * @return bool
	 */
	public static function assign_template_permission( WP_REST_Request $request ): bool {
		if ( ! self::theme_builder_permission() ) {
			return false;
		}

		$template_id = (int) $request->get_param( 'template_id' );

		if ( $template_id > 0 && ! current_user_can( 'edit_post', $template_id ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Replace Theme Builder template ID list on the Theme Builder post.
	 *
	 * Only writes deltas (removed/added IDs) rather than delete-then-reinsert to
	 * minimize database writes and reduce the window for partial-failure states.
	 * Returns `false` on the first failed meta write so callers can surface the error.
	 *
	 * @param int   $theme_builder_id Theme Builder post ID.
	 * @param int[] $template_ids     Desired template post IDs.
	 *
	 * @return bool True on success, false if any meta write failed.
	 */
	private static function _replace_template_ids( int $theme_builder_id, array $template_ids ): bool {
		$existing = get_post_meta( $theme_builder_id, '_et_template', false );
		$existing = is_array( $existing ) ? array_map( 'intval', $existing ) : [];

		// Dedupe both sides so the diff yields one delete/add per distinct ID.
		// `delete_post_meta( $pid, $key, $value )` already removes every row matching
		// that value in a single call, and duplicate adds are never desired.
		$existing_unique = array_values( array_unique( $existing ) );
		$target_unique   = array_values( array_unique( array_map( 'intval', $template_ids ) ) );

		$to_remove = array_diff( $existing_unique, $target_unique );
		$to_add    = array_diff( $target_unique, $existing_unique );

		foreach ( $to_remove as $tid ) {
			if ( false === delete_post_meta( $theme_builder_id, '_et_template', (int) $tid ) ) {
				return false;
			}
		}

		foreach ( $to_add as $tid ) {
			if ( false === add_post_meta( $theme_builder_id, '_et_template', (int) $tid ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Append a template ID to the Theme Builder post.
	 *
	 * @param int $theme_builder_id Theme Builder post ID.
	 * @param int $template_id      Template post ID.
	 *
	 * @return bool True on success, false if the meta write failed.
	 */
	private static function _append_template_id( int $theme_builder_id, int $template_id ): bool {
		$ids   = get_post_meta( $theme_builder_id, '_et_template', false );
		$ids   = is_array( $ids ) ? array_map( 'intval', $ids ) : [];
		$ids[] = $template_id;
		return self::_replace_template_ids( $theme_builder_id, $ids );
	}

	/**
	 * Validate the shape of an incoming `template` payload to `update_template`.
	 *
	 * We keep this conservative: reject obviously wrong types and unknown top-level
	 * keys so callers cannot feed arbitrary values into `array_replace_recursive` and
	 * persist them as template meta.
	 *
	 * @param mixed $value Incoming value.
	 *
	 * @return bool|\WP_Error
	 */
	public static function _validate_template_shape( $value ) {
		if ( ! is_array( $value ) ) {
			return false;
		}

		$allowed_top = [
			'id',
			'item_id',
			'title',
			'default',
			'enabled',
			'autogenerated_title',
			'layouts',
			'use_on',
			'exclude_from',
		];

		foreach ( array_keys( $value ) as $key ) {
			if ( ! in_array( $key, $allowed_top, true ) ) {
				return new \WP_Error(
					'rest_invalid_param',
					/* translators: %s: disallowed key */
					sprintf( esc_html__( 'Unknown template key "%s".', 'et_builder_5' ), $key ),
					[ 'status' => 400 ]
				);
			}
		}

		if ( isset( $value['title'] ) && ! is_string( $value['title'] ) ) {
			return false;
		}

		foreach ( [ 'use_on', 'exclude_from' ] as $list_key ) {
			if ( isset( $value[ $list_key ] ) && ! is_array( $value[ $list_key ] ) ) {
				return false;
			}
		}

		if ( isset( $value['layouts'] ) ) {
			if ( ! is_array( $value['layouts'] ) ) {
				return false;
			}

			foreach ( [ 'header', 'body', 'footer' ] as $slot ) {
				if ( ! isset( $value['layouts'][ $slot ] ) ) {
					continue;
				}

				if ( ! is_array( $value['layouts'][ $slot ] ) ) {
					return false;
				}

				if ( isset( $value['layouts'][ $slot ]['id'] ) && ! is_numeric( $value['layouts'][ $slot ]['id'] ) ) {
					return false;
				}

				if ( isset( $value['layouts'][ $slot ]['enabled'] ) && ! is_bool( $value['layouts'][ $slot ]['enabled'] ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Convert a merged template array (from `et_theme_builder_get_template` + updates) to the structure expected by `et_theme_builder_store_template`.
	 *
	 * @param array $t Template.
	 *
	 * @return array
	 */
	private static function _template_to_store_format( array $t ): array {
		$title = isset( $t['title'] ) ? sanitize_text_field( (string) $t['title'] ) : '';

		$default = ! empty( $t['default'] ) && '0' !== $t['default'];
		$enabled = ! empty( $t['enabled'] ) && '0' !== $t['enabled'];
		$header  = isset( $t['layouts']['header'] ) ? $t['layouts']['header'] : [];
		$body    = isset( $t['layouts']['body'] ) ? $t['layouts']['body'] : [];
		$footer  = isset( $t['layouts']['footer'] ) ? $t['layouts']['footer'] : [];

		return [
			'id'                  => isset( $t['id'] ) ? (int) $t['id'] : 0,
			'title'               => $title,
			'autogenerated_title' => '' === $title ? '1' : '0',
			'default'             => $default ? '1' : '0',
			'enabled'             => $enabled ? '1' : '0',
			'layouts'             => [
				'header' => [
					'id'      => isset( $header['id'] ) ? (int) $header['id'] : 0,
					'enabled' => ! empty( $header['enabled'] ),
				],
				'body'   => [
					'id'      => isset( $body['id'] ) ? (int) $body['id'] : 0,
					'enabled' => ! empty( $body['enabled'] ),
				],
				'footer' => [
					'id'      => isset( $footer['id'] ) ? (int) $footer['id'] : 0,
					'enabled' => ! empty( $footer['enabled'] ),
				],
			],
			'use_on'              => isset( $t['use_on'] ) && is_array( $t['use_on'] ) ? array_map( 'sanitize_text_field', $t['use_on'] ) : [],
			'exclude_from'        => isset( $t['exclude_from'] ) && is_array( $t['exclude_from'] ) ? array_map( 'sanitize_text_field', $t['exclude_from'] ) : [],
		];
	}
}
