<?php
/**
 * @package ACF
 * @author  WP Engine
 *
 * © 2026 Advanced Custom Fields (ACF®). All rights reserved.
 * "ACF" is a trademark of WP Engine.
 * Licensed under the GNU General Public License v2 or later.
 * https://www.gnu.org/licenses/gpl-2.0.html
 */

/**
 * Applying auto inline editing to ACF blocks.
 *
 * @package ACF
 */

namespace ACF\Blocks\AutoInlineEditing;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
 * Returns an array of field type names which support contenteditable (allows typing text) attribute.
 *
 * @return array
 */
function get_allowed_contenteditable_fields(): array {
	return array( 'text', 'textarea' );
}

/**
 * Returns an array of field type names will be ignored by the automatic application of inline editing attributes.
 *
 * @return array
 */
function get_non_auto_inline_editing_fields(): array {
	return array( 'repeater', 'flexible-content' );
}

/**
 * This function populates a global variable called acf_fields_used_in_block_render_template, which is an array
 * where each key is the value entered for the field, and the value is the field data, including the current value.
 *
 * @param mixed  $field_value The field_value.
 * @param string $post_id     The post ID for this value.
 * @param array  $field       The field array.
 *
 * @return mixed
 */
function populate_auto_inline_editing_values( $field_value, $post_id, $field ) {

	global $acf_fields_used_in_block_render_template, $acf_blocks_doing_auto_inline_editing, $acf_blocks_doing_auto_inline_editing_block_id;

	if ( ! $acf_blocks_doing_auto_inline_editing || ! empty( $field['parent_repeater'] ) ) {
		return $field_value;
	}

	// Only modify values for fields fetched against the current block's own post id.
	// Calls like get_field( 'foo', $other_post_id ) target a different post and must be returned unmodified
	// so block templates can rely on real values (e.g. for conditional logic) instead of placeholder strings.
	if ( ! empty( $acf_blocks_doing_auto_inline_editing_block_id )
		&& (string) $post_id !== (string) $acf_blocks_doing_auto_inline_editing_block_id ) {
		return $field_value;
	}

	// Add this field and its value to the global variable so we can grab it when rendering later in apply_inline_editing_attributes_to_render_template.
	if ( ! is_array( $field_value ) ) {
		if ( empty( $field_value ) ) {
			$field_value = 'acf_auto_inline_editing_field_name_' . $field['name'];
		}

		$field['value'] = $field_value;

		// Note: If 2 fields happen to have the exact same value it's most-likely fine, but there are edge cases.
		// Because in the DOM they get applied top-to-bottom, we also check top-to-bottom when pulling them from this array.
		//
		// A small, known edge case exists here if someone calls $a = get_field('a') and $b = get_field('b'), but then renders $b before $a.
		// If BOTH field $a and field $b have the exact same value AND are rendered in a different order than they were called, you would end up
		// with a scenario where editing the inline value of $a actually edits the value for $b.
		// Regardless, it would likely be obvious to the block developer because you would see it,
		// both in the field value in the block sidebar, and also inline/preview, wherever field a/b are used.
		$acf_fields_used_in_block_render_template[] = $field;
	}

	return $field_value;
}
add_filter( 'acf/format_value', __NAMESPACE__ . '\populate_auto_inline_editing_values', 10, 3 );

/**
 * Applies inline editing attributes to dom elements if they contain field values.
 *
 * @param string        $path       The path to the render template for this block.
 * @param array         $block      The block data.
 * @param string        $content    The block content.
 * @param boolean       $is_preview Whether we are in the block editor or not.
 * @param integer       $post_id    The current post being edited or viewed.
 * @param WP_Block|null $wp_block   The block instance (since WP 5.5).
 * @param array         $context    The block context array.
 * @return string
 */
function apply_inline_editing_attributes_to_render_template( $path, $block, $content, $is_preview, $post_id, $wp_block, $context ): string {
	global $acf_fields_used_in_block_render_template, $acf_blocks_doing_auto_inline_editing, $acf_blocks_doing_auto_inline_editing_block_id;

	// Save the entire previous render context (fields collected, doing-flag, block id) so a
	// nested ACF block render does not clobber the parent's tracked fields or active block id.
	$previous_fields                    = is_array( $acf_fields_used_in_block_render_template ) ? $acf_fields_used_in_block_render_template : array();
	$previous_doing_auto_inline_editing = ! empty( $acf_blocks_doing_auto_inline_editing );
	$previous_block_id                  = $acf_blocks_doing_auto_inline_editing_block_id ?? null;

	$acf_fields_used_in_block_render_template      = array();
	$acf_blocks_doing_auto_inline_editing          = true;
	$acf_blocks_doing_auto_inline_editing_block_id = $block['id'] ?? null;

	ob_start();
	include $path;
	$html = ob_get_clean();

	// Process the HTML before restoring the parent context - apply_inline_editing_attributes_to_html_string()
	// reads $acf_fields_used_in_block_render_template via global, so it must see THIS block's field list.
	$processed_html = apply_inline_editing_attributes_to_html_string( $html, $block );

	$acf_fields_used_in_block_render_template      = $previous_fields;
	$acf_blocks_doing_auto_inline_editing          = $previous_doing_auto_inline_editing;
	$acf_blocks_doing_auto_inline_editing_block_id = $previous_block_id;

	return $processed_html;
}

/**
 * Applies inline editing attributes to dom elements if they contain field values.
 *
 * @param string        $render_callback The callback function that renders the block preview/output.
 * @param array         $block           The block data.
 * @param string        $content         The block content.
 * @param boolean       $is_preview      Whether we are in the block editor or not.
 * @param integer       $post_id         The current post being edited or viewed.
 * @param WP_Block|null $wp_block        The block instance (since WP 5.5).
 * @param array         $context         The block context array.
 * @return string
 */
function apply_inline_editing_attributes_to_render_callback( $render_callback, $block, $content, $is_preview, $post_id, $wp_block, $context ): string {
	global $acf_fields_used_in_block_render_template, $acf_blocks_doing_auto_inline_editing, $acf_blocks_doing_auto_inline_editing_block_id;

	// Save the entire previous render context (fields collected, doing-flag, block id) so a
	// nested ACF block render does not clobber the parent's tracked fields or active block id.
	$previous_fields                    = is_array( $acf_fields_used_in_block_render_template ) ? $acf_fields_used_in_block_render_template : array();
	$previous_doing_auto_inline_editing = ! empty( $acf_blocks_doing_auto_inline_editing );
	$previous_block_id                  = $acf_blocks_doing_auto_inline_editing_block_id ?? null;

	$acf_fields_used_in_block_render_template      = array();
	$acf_blocks_doing_auto_inline_editing          = true;
	$acf_blocks_doing_auto_inline_editing_block_id = $block['id'] ?? null;

	ob_start();
	call_user_func( $render_callback, $block, $content, $is_preview, $post_id, $wp_block, $context );
	$html = ob_get_clean();

	// Process the HTML before restoring the parent context - apply_inline_editing_attributes_to_html_string()
	// reads $acf_fields_used_in_block_render_template via global, so it must see THIS block's field list.
	$processed_html = apply_inline_editing_attributes_to_html_string( $html, $block );

	$acf_fields_used_in_block_render_template      = $previous_fields;
	$acf_blocks_doing_auto_inline_editing          = $previous_doing_auto_inline_editing;
	$acf_blocks_doing_auto_inline_editing_block_id = $previous_block_id;

	return $processed_html;
}

/**
 * Applies inline editing attributes to dom elements if they contain field values.
 *
 * @param string $html_string The HTML string being processed.
 * @param array  $block       The block data.
 * @return string
 */
function apply_inline_editing_attributes_to_html_string( $html_string, $block ): string {
	global $acf_fields_used_in_block_render_template;

	// Don't apply autoInlineEditing if the current PHP doesn't include DOMDocument or DOMXPath.
	if ( ! class_exists( 'DOMDocument' ) || ! class_exists( 'DOMXPath' ) ) {
		return $html_string;
	}

	$allowed_contenteditable_field_types = get_allowed_contenteditable_fields();
	$non_auto_inline_editing_fields      = get_non_auto_inline_editing_fields();

	$html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head>' . $html_string;

	// Load the HTML into DOMDocument
	$dom = new \DOMDocument();
	libxml_use_internal_errors( true ); // Suppress warnings for invalid HTML
	$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
	libxml_clear_errors();

	// Get all elements
	$xpath    = new \DOMXPath( $dom );
	$elements = $xpath->query( '//*' );

	// Iterate over elements and modify based on text content
	foreach ( $elements as $element ) {
		$field_names_for_popover = array();

		$top_level_text = '';

		// phpcs:ignore WordPress.NamingConventions.ValidVariableName
		if ( empty( $element->childNodes ) ) {
			continue;
		}

		// Loop through the child nodes of the current element
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName
		foreach ( $element->childNodes as $child ) {
			// Check if the child node is a text node
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName
			if ( $child->nodeType === XML_TEXT_NODE ) {
				// phpcs:ignore WordPress.NamingConventions.ValidVariableName
				$top_level_text .= $child->nodeValue;
			}
		}

		$top_level_text = trim( $top_level_text );

		if ( ! empty( $top_level_text ) ) {
			$acf_field_found = false;

			// Loop through each field used in this render template.
			foreach ( $acf_fields_used_in_block_render_template as $key => $field_data ) {
				if ( ! $field_data['name'] ) {
					continue;
				}

				// If the value for this field matches the text in the dom, apply the inline editing attributes.
				if ( $field_data['value'] === $top_level_text ) {
					$acf_field_found        = true;
					$field_slug             = $field_data['name'];
					$field_value            = $field_data['value'];
					$field_type             = $field_data['type'];
					$field_placeholder_text = ! empty( $field_data['placeholder'] ) ? $field_data['placeholder'] : __( 'Type to edit...', 'acf' );

					if ( ! in_array( $field_type, $non_auto_inline_editing_fields, true ) ) {
						if ( in_array( $field_type, $allowed_contenteditable_field_types, true ) ) {
							// Add the contenteditable things.
							if ( ! $element->getAttribute( 'data-acf-inline-contenteditable' ) ) {
								$element->setAttribute( 'role', 'button' );
								$element->setAttribute( 'data-acf-inline-contenteditable', true );

								$element->setAttribute( 'data-acf-inline-contenteditable-field-slug', str_replace( 'acf_auto_inline_editing_field_name_', '', $field_slug ) );
								$element->setAttribute( 'data-acf-placeholder', $field_placeholder_text );
							}

							// phpcs:ignore WordPress.NamingConventions.ValidVariableName
							if ( $field_value !== 'acf_auto_inline_editing_field_name_' . $field_slug ) {
								// phpcs:ignore WordPress.NamingConventions.ValidVariableName
								$element->nodeValue = $field_value;
							} else {
								// phpcs:ignore WordPress.NamingConventions.ValidVariableName
								$element->nodeValue = '';
							}
						} else {
							// Make the field popover instead of contenteditable.
							$field_names_for_popover[] = str_replace( 'acf_auto_inline_editing_field_name_', '', $field_slug );
						}
					}

					// We found a matching field so we can stop looping.
					break;
				}
			}

			if ( ! $acf_field_found ) {
				foreach ( $acf_fields_used_in_block_render_template as $key => $field_data ) {
					// Remove the acf_auto_inline_editing_field_name_ placeholder from the text node.
					if ( strpos( $top_level_text, 'acf_auto_inline_editing_field_name_' . $field_data['name'] ) !== false ) {
						// phpcs:ignore WordPress.NamingConventions.ValidVariableName
						$element->nodeValue = str_replace( 'acf_auto_inline_editing_field_name_' . $field_data['name'], '', $top_level_text );
					}
				}
			}
		}

		// If the value for this field matches the field slug, remove it.
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName
		if ( strpos( $top_level_text, 'acf_auto_inline_editing_field_name_' ) === 0 ) {
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName
			$element->textContent = '';
		}

		// Loop over each attribute. If an attribute comes from acf, make it popup when parent is selected.
		foreach ( $element->attributes as $attribute ) {
			if ( $attribute->name === 'data-acf-inline-contenteditable-field-slug' ) {
				continue;
			}
			$attribute_value = trim( $attribute->value );

			foreach ( $acf_fields_used_in_block_render_template as $field_data ) {
				if ( empty( $field_data['name'] ) ) {
					continue;
				}

				$field_slug  = $field_data['name'];
				$field_value = $field_data['value'];

				if ( ! is_array( $field_value ) && $attribute_value === $field_value ) {
					$field_names_for_popover[] = str_replace( 'acf_auto_inline_editing_field_name_', '', $field_slug );
				}

				if ( strpos( $attribute_value, 'acf_auto_inline_editing_field_name_' ) !== false ) {
					$attribute_value = str_replace( 'acf_auto_inline_editing_field_name_' . $field_slug, '', $attribute_value );
					$element->setAttribute( $attribute->name, $attribute_value );
				}
			}
		}

		$field_names_for_popover = array_unique( $field_names_for_popover );

		// Don't add popover fields to the top level element unless it has text content (as opposed to html/non-text content, which is what most top level elements contain).
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName
		$is_top_level = isset( $element->parentNode->tagName ) && $element->parentNode->tagName === 'body';

		if ( ! $is_top_level && ! empty( $field_names_for_popover ) ) {
			$preexisting_inline_fields_uid = $element->getAttribute( 'data-acf-inline-fields-uid' );
			if ( ! $preexisting_inline_fields_uid ) {
				$element->setAttribute( 'data-acf-inline-fields-uid', implode( '__', $field_names_for_popover ) . '__' . $block['id'] );
				$element->setAttribute(
					'data-acf-inline-fields',
					wp_json_encode( $field_names_for_popover ),
				);
			}
		}
	}
	// phpcs:ignore WordPress.NamingConventions.ValidVariableName
	$dom->preserveWhiteSpace = true;
	// phpcs:ignore WordPress.NamingConventions.ValidVariableName
	$dom->formatOutput = false;

	return str_replace( '<meta charset="UTF-8">', '', $dom->saveHTML() );
}
