 <?php
declare(strict_types=1);
/**
 * Inline Editor — front-end, click-to-edit text for Flexa blocks.
 *
 * Flexa blocks are dynamic (`save: () => null`); their editable text lives in the
 * block-comment JSON as an attribute, and render.php regenerates the HTML. So,
 * rather than touching innerHTML, this service:
 *   1. annotates each editable block's root element with `data-flexa-edit-id`
 *      (its unique blockId) + `data-flexa-edit-name` via WP_HTML_Tag_Processor on
 *      `render_block` — exactly like Animations::inject, whitelist-checked,
 *   2. ships a small front-end module (build/frontend/inline-editor.js) that turns
 *      the mapped child elements into contenteditable regions and POSTs edits,
 *   3. exposes a REST route that finds the block by blockId, rewrites the single
 *      attribute, re-serializes and saves — which regenerates the cached CSS via
 *      the existing save_post hook — and records the edit as a comment note.
 *
 * Editing UI and the REST write are gated by `should_render()` /
 * `user_can_edit_inline()`: the post must be editable by the user, inline editing
 * must be enabled, and the user's role must be allowed (admins always are).
 *
 * @package Flexa\Block
 */

namespace Flexa\Block;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Front-end inline text editor for Flexa blocks.
 */
final class Inline_Editor {

	const REST_NS     = 'flexa-block/v1';
	const OPTION_NAME = 'flexa_block_settings';

	/**
	 * Editable registry: block name → editable fields.
	 *
	 * Each field: `attr` (block attribute), `selector` (rendered element class to
	 * click), `allowed` (wp_kses allowlist key). Repeater fields add `repeat` =>
	 * true and `key` — the Nth matched element maps to `items[N][key]`. This is the
	 * single source of truth for annotation, REST validation and JS click-targeting.
	 *
	 * @var array<string, array{fields: list<array<string, mixed>>}>
	 */
	const EDITABLE = [
		'flexa/text'    => [
			'fields' => [
				[ 'attr' => 'content', 'selector' => '.flexa-text__content', 'allowed' => 'rich' ],
			],
		],
		'flexa/heading' => [
			'fields' => [
				[ 'attr' => 'content', 'selector' => '.flexa-heading__title', 'allowed' => 'inline' ],
				[ 'attr' => 'subheadingContent', 'selector' => '.flexa-heading__subheading', 'allowed' => 'inline' ],
			],
		],
		'flexa/button'  => [
			'fields' => [
				[ 'attr' => 'text', 'selector' => '.flexa-button__text', 'allowed' => 'minimal' ],
			],
		],
		// Composite blocks — several top-level string attributes each. The two
		// promo button labels share a class, so they are scoped by the button
		// modifier class.
		'flexa/banner'  => [
			'fields' => [
				[ 'attr' => 'heading', 'selector' => '.flexa-promo__heading', 'allowed' => 'minimal' ],
				[ 'attr' => 'description', 'selector' => '.flexa-promo__description', 'allowed' => 'minimal-link' ],
				[ 'attr' => 'primaryText', 'selector' => '.flexa-promo__button--primary .flexa-promo__button-text', 'allowed' => 'minimal' ],
				[ 'attr' => 'secondaryText', 'selector' => '.flexa-promo__button--secondary .flexa-promo__button-text', 'allowed' => 'minimal' ],
			],
		],
		'flexa/cta'     => [
			'fields' => [
				[ 'attr' => 'heading', 'selector' => '.flexa-promo__heading', 'allowed' => 'minimal' ],
				[ 'attr' => 'description', 'selector' => '.flexa-promo__description', 'allowed' => 'minimal-link' ],
				[ 'attr' => 'primaryText', 'selector' => '.flexa-promo__button--primary .flexa-promo__button-text', 'allowed' => 'minimal' ],
				[ 'attr' => 'secondaryText', 'selector' => '.flexa-promo__button--secondary .flexa-promo__button-text', 'allowed' => 'minimal' ],
			],
		],
		'flexa/info-box' => [
			'fields' => [
				[ 'attr' => 'prefix', 'selector' => '.flexa-info-box__prefix', 'allowed' => 'minimal' ],
				[ 'attr' => 'title', 'selector' => '.flexa-info-box__title', 'allowed' => 'minimal' ],
				[ 'attr' => 'description', 'selector' => '.flexa-info-box__description', 'allowed' => 'minimal-link' ],
				[ 'attr' => 'buttonText', 'selector' => '.flexa-info-box__button-text', 'allowed' => 'minimal' ],
			],
		],
		'flexa/testimonial' => [
			'fields' => [
				[ 'attr' => 'title', 'selector' => '.flexa-testimonial__title', 'allowed' => 'minimal' ],
				[ 'attr' => 'quote', 'selector' => '.flexa-testimonial__quote', 'allowed' => 'minimal-link' ],
				[ 'attr' => 'authorName', 'selector' => '.flexa-testimonial__name', 'allowed' => 'minimal' ],
				[ 'attr' => 'authorJob', 'selector' => '.flexa-testimonial__job', 'allowed' => 'minimal' ],
			],
		],
		'flexa/team-member' => [
			'fields' => [
				[ 'attr' => 'name', 'selector' => '.flexa-team-member__name', 'allowed' => 'minimal' ],
				[ 'attr' => 'role', 'selector' => '.flexa-team-member__role', 'allowed' => 'minimal' ],
				[ 'attr' => 'bio', 'selector' => '.flexa-team-member__bio', 'allowed' => 'minimal-link' ],
			],
		],
		// Simple single-string blocks (plain text).
		'flexa/table-of-content' => [
			'fields' => [
				[ 'attr' => 'title', 'selector' => '.flexa-toc__title', 'allowed' => 'plain' ],
			],
		],
		'flexa/process-bar' => [
			'fields' => [
				[ 'attr' => 'title', 'selector' => '.flexa-process-bar__title', 'allowed' => 'plain' ],
			],
		],
		'flexa/before-after' => [
			'fields' => [
				[ 'attr' => 'beforeLabel', 'selector' => '.flexa-before-after__label--before', 'allowed' => 'plain' ],
				[ 'attr' => 'afterLabel', 'selector' => '.flexa-before-after__label--after', 'allowed' => 'plain' ],
			],
		],
		// Countdown unit labels live in a `labels` object attribute; each label
		// element is scoped by its unit modifier class.
		'flexa/countdown' => [
			'fields' => [
				[ 'attr' => 'labels', 'key' => 'days', 'object' => true, 'selector' => '.flexa-countdown__unit--days .flexa-countdown__label', 'allowed' => 'plain' ],
				[ 'attr' => 'labels', 'key' => 'hours', 'object' => true, 'selector' => '.flexa-countdown__unit--hours .flexa-countdown__label', 'allowed' => 'plain' ],
				[ 'attr' => 'labels', 'key' => 'minutes', 'object' => true, 'selector' => '.flexa-countdown__unit--minutes .flexa-countdown__label', 'allowed' => 'plain' ],
				[ 'attr' => 'labels', 'key' => 'seconds', 'object' => true, 'selector' => '.flexa-countdown__unit--seconds .flexa-countdown__label', 'allowed' => 'plain' ],
			],
		],
		// Subscribe-form field labels (each field is its own block). The trailing
		// required-marker span is stripped client-side before saving.
		'flexa/subscribe-form-name'     => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-email'    => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-phone'    => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-textarea' => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-url'      => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-date'     => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-select'   => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-radio'    => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-checkbox' => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-toggle'   => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		'flexa/subscribe-form-upload'   => [ 'fields' => [ [ 'attr' => 'label', 'selector' => '.flexa-field__label', 'allowed' => 'plain' ] ] ],
		// Repeater blocks — text inside an array attribute. The Nth rendered item
		// carries `data-flexa-item="<source index>"` (stamped in render.php), so
		// the front-end maps it back to the exact array entry.
		'flexa/faq' => [
			'fields' => [
				[ 'attr' => 'items', 'key' => 'question', 'repeat' => true, 'selector' => '.flexa-faq__question-text', 'allowed' => 'minimal' ],
				[ 'attr' => 'items', 'key' => 'answer', 'repeat' => true, 'selector' => '.flexa-faq__answer-content', 'allowed' => 'post' ],
			],
		],
		'flexa/tabs' => [
			'fields' => [
				[ 'attr' => 'tabs', 'key' => 'label', 'repeat' => true, 'selector' => '.flexa-tabs__label', 'allowed' => 'plain' ],
				[ 'attr' => 'tabs', 'key' => 'content', 'repeat' => true, 'selector' => '.flexa-tabs__panel', 'allowed' => 'post' ],
			],
		],
		'flexa/steps' => [
			'fields' => [
				[ 'attr' => 'items', 'key' => 'title', 'repeat' => true, 'selector' => '.flexa-steps__title', 'allowed' => 'minimal' ],
				[ 'attr' => 'items', 'key' => 'description', 'repeat' => true, 'selector' => '.flexa-steps__description', 'allowed' => 'minimal' ],
			],
		],
		'flexa/timeline' => [
			'fields' => [
				[ 'attr' => 'items', 'key' => 'date', 'repeat' => true, 'selector' => '.flexa-timeline__date', 'allowed' => 'minimal' ],
				[ 'attr' => 'items', 'key' => 'title', 'repeat' => true, 'selector' => '.flexa-timeline__title', 'allowed' => 'minimal' ],
				[ 'attr' => 'items', 'key' => 'description', 'repeat' => true, 'selector' => '.flexa-timeline__description', 'allowed' => 'minimal' ],
			],
		],
		'flexa/pricing-table' => [
			'fields' => [
				[ 'attr' => 'items', 'key' => 'name', 'repeat' => true, 'selector' => '.flexa-pricing-table__name', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'badge', 'repeat' => true, 'selector' => '.flexa-pricing-table__badge', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'priceMonthly', 'repeat' => true, 'selector' => '.flexa-pricing-table__price-group--monthly .flexa-pricing-table__amount', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'periodMonthly', 'repeat' => true, 'selector' => '.flexa-pricing-table__price-group--monthly .flexa-pricing-table__period', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'priceYearly', 'repeat' => true, 'selector' => '.flexa-pricing-table__price-group--yearly .flexa-pricing-table__amount', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'periodYearly', 'repeat' => true, 'selector' => '.flexa-pricing-table__price-group--yearly .flexa-pricing-table__period', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'features', 'repeat' => true, 'join' => "\n", 'selector' => '.flexa-pricing-table__feature', 'allowed' => 'plain' ],
				[ 'attr' => 'items', 'key' => 'ctaText', 'repeat' => true, 'selector' => '.flexa-pricing-table__cta', 'allowed' => 'plain' ],
			],
		],
		'flexa/counter' => [
			'fields' => [
				[ 'attr' => 'items', 'key' => 'label', 'repeat' => true, 'selector' => '.flexa-counter__label', 'allowed' => 'minimal' ],
			],
		],
		'flexa/icon-list' => [
			'fields' => [
				[ 'attr' => 'items', 'key' => 'text', 'repeat' => true, 'selector' => '.flexa-icon-list__text', 'allowed' => 'minimal' ],
			],
		],
		'flexa/star-rating' => [
			'fields' => [
				[ 'attr' => 'title', 'selector' => '.flexa-star-rating__title', 'allowed' => 'minimal' ],
			],
		],
		// The trigger text lives inside the trigger <button>; only the button/text
		// trigger types render it (icon/image triggers have no text element). The
		// label is plain text (esc_html in render.php).
		'flexa/modal' => [
			'fields' => [
				[ 'attr' => 'triggerText', 'selector' => '.flexa-modal__trigger-text', 'allowed' => 'plain' ],
			],
		],
		// Comparison table — 2-axis. Columns + row labels are single-axis
		// repeaters; feature cells write rows[row].values[col] (the `cell` flag),
		// with the column index carried on each editable cell's data-flexa-cell.
		'flexa/comparison-table' => [
			'fields' => [
				[ 'attr' => 'columns', 'key' => 'title', 'repeat' => true, 'selector' => '.flexa-comparison-table__col-title', 'allowed' => 'plain' ],
				[ 'attr' => 'columns', 'key' => 'subtitle', 'repeat' => true, 'selector' => '.flexa-comparison-table__col-subtitle', 'allowed' => 'plain' ],
				[ 'attr' => 'columns', 'key' => 'badge', 'repeat' => true, 'selector' => '.flexa-comparison-table__badge', 'allowed' => 'plain' ],
				[ 'attr' => 'columns', 'key' => 'ctaText', 'repeat' => true, 'selector' => '.flexa-comparison-table__cta', 'allowed' => 'plain' ],
				[ 'attr' => 'rows', 'key' => 'label', 'repeat' => true, 'selector' => '.flexa-comparison-table__label', 'allowed' => 'plain' ],
				[ 'attr' => 'rows', 'key' => 'values', 'repeat' => true, 'cell' => true, 'selector' => '.flexa-comparison-table__cell', 'allowed' => 'plain' ],
			],
		],
		// Notice — a title + message the user types (single-string attributes).
		'flexa/notice' => [
			'fields' => [
				[ 'attr' => 'title', 'selector' => '.flexa-notice__title', 'allowed' => 'minimal' ],
				[ 'attr' => 'content', 'selector' => '.flexa-notice__content', 'allowed' => 'rich' ],
			],
		],
		// Data Table — 2-axis like the comparison table: column header labels are a
		// single-axis repeater; body cells write rows[row].values[col] (the `cell`
		// flag), with the column index carried on each cell's data-flexa-cell.
		'flexa/data-table' => [
			'fields' => [
				[ 'attr' => 'columns', 'key' => 'label', 'repeat' => true, 'selector' => '.flexa-data-table__col-label', 'allowed' => 'plain' ],
				[ 'attr' => 'rows', 'key' => 'values', 'repeat' => true, 'cell' => true, 'selector' => '.flexa-data-table__cell', 'allowed' => 'plain' ],
			],
		],
	];

	/**
	 * Default inline-editor settings (merged under the stored option).
	 *
	 * @var array{enabled: bool, disabled_blocks: list<string>, roles: list<string>}
	 */
	const DEFAULTS = [
		'enabled'         => true,
		'disabled_blocks' => [],
		'roles'           => [ 'editor' ],
	];

	/**
	 * Memoized should_render() result for the current request.
	 *
	 * @var bool|null
	 */
	private static $should = null;

	/**
	 * Register hooks.
	 */
	public static function init(): void {
		add_filter( 'render_block', [ __CLASS__, 'annotate' ], 20, 2 );
		add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue' ], 25 );
		add_action( 'wp_footer', [ __CLASS__, 'render_toolbar' ] );
		add_action( 'rest_api_init', [ __CLASS__, 'register_routes' ] );
	}

	/* ---------------------------------------------------------------------
	 * Settings + gating
	 * ------------------------------------------------------------------ */

	/**
	 * Inline-editor settings merged over defaults.
	 *
	 * @return array{enabled: bool, disabled_blocks: list<string>, roles: list<string>}
	 */
	public static function settings(): array {
		$stored = get_option( self::OPTION_NAME, [] );
		$ie     = is_array( $stored ) && isset( $stored['inline_editor'] ) && is_array( $stored['inline_editor'] )
			? $stored['inline_editor']
			: [];

		return [
			'enabled'         => isset( $ie['enabled'] ) ? (bool) $ie['enabled'] : self::DEFAULTS['enabled'],
			'disabled_blocks' => isset( $ie['disabled_blocks'] ) && is_array( $ie['disabled_blocks'] )
				? array_values( array_map( 'strval', $ie['disabled_blocks'] ) )
				: self::DEFAULTS['disabled_blocks'],
			'roles'           => isset( $ie['roles'] ) && is_array( $ie['roles'] )
				? array_values( array_map( 'strval', $ie['roles'] ) )
				: self::DEFAULTS['roles'],
		];
	}

	/**
	 * Whether inline editing is enabled at all.
	 */
	public static function is_enabled(): bool {
		return self::settings()['enabled'];
	}

	/**
	 * Block names that carry at least one editable field.
	 *
	 * @return list<string>
	 */
	public static function editable_block_names(): array {
		return array_keys( self::EDITABLE );
	}

	/**
	 * The editable fields for a block (empty if none).
	 *
	 * @param string $name Block name.
	 * @return list<array<string, mixed>>
	 */
	private static function fields_for( string $name ): array {
		return isset( self::EDITABLE[ $name ] ) ? self::EDITABLE[ $name ]['fields'] : [];
	}

	/**
	 * Whether the current user's role is allowed to edit inline.
	 *
	 * Administrators are always allowed; every other role must be listed in the
	 * `roles` setting. This is an additional gate on top of the per-post
	 * `edit_post` capability checked elsewhere.
	 */
	public static function user_can_edit_inline(): bool {
		if ( ! is_user_logged_in() ) {
			return false;
		}
		if ( current_user_can( 'manage_options' ) ) {
			return true;
		}
		$roles = self::settings()['roles'];
		if ( empty( $roles ) ) {
			return false;
		}
		$user = wp_get_current_user();
		return ! empty( array_intersect( (array) $user->roles, $roles ) );
	}

	/**
	 * Whether the editing UI should render on the current request.
	 */
	public static function should_render(): bool {
		if ( null !== self::$should ) {
			return self::$should;
		}

		$should = false;
		if ( is_singular() && is_user_logged_in() && self::is_enabled() ) {
			$post_id = get_queried_object_id();
			if ( $post_id && current_user_can( 'edit_post', $post_id ) && self::user_can_edit_inline() ) {
				$should = true;
			}
		}

		self::$should = $should;
		return $should;
	}

	/**
	 * A `data-flexa-item` marker for a repeater item's wrapper element.
	 *
	 * Repeater render.php loops call this with the ORIGINAL array index so the
	 * front-end can map a rendered item back to its exact `items[N]` entry even
	 * when empty items are skipped. Returns '' (nothing) for non-editors, so it
	 * never bloats the public markup.
	 *
	 * @param int $index Original array index of the item.
	 * @return string Leading-space attribute string, or ''.
	 */
	public static function item_attr( int $index ): string {
		if ( ! self::should_render() ) {
			return '';
		}
		return ' data-flexa-item="' . $index . '"';
	}

	/**
	 * A `data-flexa-cell` marker for a matrix cell's column index.
	 *
	 * Used for 2-axis blocks (comparison table): the row index comes from the
	 * row's `data-flexa-item`, the column index from this. Only text cells get
	 * one (boolean check/cross cells are not text-editable).
	 *
	 * @param int $index Column index within the row's values array.
	 * @return string Leading-space attribute string, or ''.
	 */
	public static function cell_attr( int $index ): string {
		if ( ! self::should_render() ) {
			return '';
		}
		return ' data-flexa-cell="' . $index . '"';
	}

	/**
	 * Whether a block slug has been disabled in settings.
	 */
	private static function is_block_disabled( string $block_name ): bool {
		$slug = str_replace( 'flexa/', '', $block_name );
		return in_array( $slug, self::settings()['disabled_blocks'], true );
	}

	/* ---------------------------------------------------------------------
	 * Render-time annotation
	 * ------------------------------------------------------------------ */

	/**
	 * Mark an editable block's root element with its blockId + name.
	 *
	 * @param string $content Rendered block HTML.
	 * @param array  $block   Parsed block (name + attrs).
	 * @return string
	 */
	public static function annotate( $content, $block ) {
		if ( ! self::should_render() ) {
			return $content;
		}
		if ( ! is_string( $content ) || '' === trim( $content ) ) {
			return $content;
		}

		$name = $block['blockName'] ?? '';
		if ( ! isset( self::EDITABLE[ $name ] ) || self::is_block_disabled( $name ) ) {
			return $content;
		}

		$block_id = (string) ( $block['attrs']['blockId'] ?? '' );
		if ( '' === $block_id ) {
			return $content;
		}

		if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) {
			return $content;
		}

		$processor = new \WP_HTML_Tag_Processor( $content );
		if ( ! $processor->next_tag() ) {
			return $content;
		}

		$processor->set_attribute( 'data-flexa-edit-id', $block_id );
		$processor->set_attribute( 'data-flexa-edit-name', $name );

		return $processor->get_updated_html();
	}

	/* ---------------------------------------------------------------------
	 * Front-end assets
	 * ------------------------------------------------------------------ */

	/**
	 * Enqueue the front-end module + styles and expose the bootstrap object.
	 */
	public static function enqueue(): void {
		if ( ! self::should_render() ) {
			return;
		}

		$rel = 'build/frontend/inline-editor.js';
		if ( ! file_exists( FLEXA_BLOCK_DIR . $rel ) ) {
			return;
		}

		$asset_path = FLEXA_BLOCK_DIR . 'build/frontend/inline-editor.asset.php';
		$asset      = file_exists( $asset_path ) ? require $asset_path : [ 'dependencies' => [], 'version' => FLEXA_BLOCK_VER ];
		$version    = $asset['version'] ?? FLEXA_BLOCK_VER;

		wp_enqueue_script(
			'flexa-block-inline-editor',
			FLEXA_BLOCK_URL . $rel,
			$asset['dependencies'] ?? [],
			$version,
			true
		);

		$css_rel = 'build/frontend/inline-editor.css';
		if ( file_exists( FLEXA_BLOCK_DIR . $css_rel ) ) {
			wp_enqueue_style( 'flexa-block-inline-editor', FLEXA_BLOCK_URL . $css_rel, [], $version );
		}

		wp_set_script_translations( 'flexa-block-inline-editor', 'flexa-block', FLEXA_BLOCK_DIR . 'languages' );

		wp_localize_script(
			'flexa-block-inline-editor',
			'flexaBlockInlineEditor',
			[
				'postId'   => get_queried_object_id(),
				'nonce'    => wp_create_nonce( 'wp_rest' ),
				'endpoint' => esc_url_raw( rest_url( self::REST_NS . '/inline-edit' ) ),
				'fields'   => self::js_fields(),
				'i18n'     => [
					'editing' => __( 'Editing', 'flexa-block' ),
					'save'    => __( 'Save', 'flexa-block' ),
					'cancel'  => __( 'Cancel', 'flexa-block' ),
					'saved'   => __( 'Saved', 'flexa-block' ),
					'saving'  => __( 'Saving…', 'flexa-block' ),
					'error'   => __( 'Could not save', 'flexa-block' ),
				],
			]
		);
	}

	/**
	 * The JS-facing slice of the registry (no server-only allowlist keys).
	 *
	 * @return array<string, list<array<string, mixed>>>
	 */
	private static function js_fields(): array {
		$out = [];
		foreach ( self::EDITABLE as $name => $def ) {
			if ( self::is_block_disabled( $name ) ) {
				continue;
			}
			$fields = [];
			foreach ( self::fields_for( $name ) as $field ) {
				$entry = [
					'selector' => $field['selector'],
					'attr'     => $field['attr'],
				];
				if ( ! empty( $field['repeat'] ) ) {
					$entry['repeat'] = true;
					$entry['key']    = $field['key'];
					if ( ! empty( $field['join'] ) ) {
						$entry['join'] = (string) $field['join'];
					}
					if ( ! empty( $field['cell'] ) ) {
						$entry['cell'] = true;
					}
				} elseif ( ! empty( $field['object'] ) ) {
					$entry['object'] = true;
					$entry['key']    = $field['key'];
				}
				$fields[] = $entry;
			}
			$out[ $name ] = $fields;
		}
		return $out;
	}

	/**
	 * Print the editing toolbar (hidden until a block is being edited).
	 */
	public static function render_toolbar(): void {
		if ( ! self::should_render() ) {
			return;
		}
		?>
		<div class="flexa-fee-toolbar" hidden>
			<div class="flexa-fee-toolbar__inner">
				<span class="flexa-fee-toolbar__label"><?php echo esc_html__( 'Editing', 'flexa-block' ); ?></span>
				<button type="button" class="flexa-fee-btn flexa-fee-btn--save"><?php echo esc_html__( 'Save', 'flexa-block' ); ?></button>
				<button type="button" class="flexa-fee-btn flexa-fee-btn--cancel"><?php echo esc_html__( 'Cancel', 'flexa-block' ); ?></button>
				<span class="flexa-fee-toolbar__msg" aria-live="polite"></span>
			</div>
		</div>
		<?php
	}

	/* ---------------------------------------------------------------------
	 * REST
	 * ------------------------------------------------------------------ */

	/**
	 * Register the inline-edit route.
	 */
	public static function register_routes(): void {
		register_rest_route(
			self::REST_NS,
			'/inline-edit',
			[
				'methods'             => 'POST',
				'callback'            => [ __CLASS__, 'handle_save' ],
				'permission_callback' => [ __CLASS__, 'rest_permission' ],
				'args'                => [
					'postId'    => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'blockId'   => [
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'blockName' => [
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'attr'      => [
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'index'     => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'subindex'  => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'key'       => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					// Sanitized in the handler against the field's own allowlist.
					'value'     => [
						'required' => true,
						'type'     => 'string',
					],
				],
			]
		);
	}

	/**
	 * Only users who may edit the target post AND pass the role gate.
	 *
	 * Returns a descriptive WP_Error (not just false) so the front-end toolbar
	 * can tell the user exactly why a save was refused.
	 *
	 * @param \WP_REST_Request $request Incoming request.
	 * @return bool|\WP_Error
	 */
	public static function rest_permission( $request ) {
		if ( ! self::is_enabled() ) {
			return new \WP_Error( 'flexa_disabled', __( 'Front-end editing is turned off. Enable it under Flexa Block → Editing.', 'flexa-block' ), [ 'status' => 403 ] );
		}
		$post_id = absint( $request->get_param( 'postId' ) );
		if ( $post_id < 1 || ! current_user_can( 'edit_post', $post_id ) ) {
			return new \WP_Error( 'flexa_forbidden', __( 'You do not have permission to edit this content.', 'flexa-block' ), [ 'status' => 403 ] );
		}
		if ( ! self::user_can_edit_inline() ) {
			return new \WP_Error( 'flexa_role_denied', __( 'Your user role is not allowed to edit on the front end.', 'flexa-block' ), [ 'status' => 403 ] );
		}
		return true;
	}

	/**
	 * Apply a single-attribute edit to a block and save the post.
	 *
	 * @param \WP_REST_Request $request Incoming request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function handle_save( $request ) {
		$post_id   = (int) $request->get_param( 'postId' );
		$block_id  = (string) $request->get_param( 'blockId' );
		$name      = (string) $request->get_param( 'blockName' );
		$attr      = (string) $request->get_param( 'attr' );
		$index_raw = $request->get_param( 'index' );
		$sub_raw   = $request->get_param( 'subindex' );
		$key       = $request->get_param( 'key' );
		$key       = ( null === $key ) ? null : (string) $key;
		$value     = (string) $request->get_param( 'value' );

		if ( self::is_block_disabled( $name ) ) {
			return new \WP_Error(
				'flexa_disabled',
				sprintf(
					/* translators: %s: block type slug. */
					__( 'Front-end editing is turned off for the “%s” block. Turn it on under Flexa Block → Editing.', 'flexa-block' ),
					str_replace( 'flexa/', '', $name )
				),
				[ 'status' => 403 ]
			);
		}

		$has_index = ( null !== $index_raw );
		$field     = self::find_field( $name, $attr, $key, $has_index );
		if ( null === $field ) {
			return new \WP_Error( 'flexa_invalid_field', __( 'This text cannot be edited from the front end.', 'flexa-block' ), [ 'status' => 400 ] );
		}

		$post = get_post( $post_id );
		if ( ! $post ) {
			return new \WP_Error( 'flexa_not_found', __( 'The post could not be found. Try reloading the page.', 'flexa-block' ), [ 'status' => 404 ] );
		}

		$blocks = parse_blocks( $post->post_content );
		$flat   = [];
		self::flatten( $blocks, $flat );

		$ref = null;
		foreach ( $flat as &$candidate ) {
			if ( ( $candidate['blockName'] ?? '' ) === $name
				&& (string) ( $candidate['attrs']['blockId'] ?? '' ) === $block_id ) {
				$ref = &$candidate;
				break;
			}
		}
		unset( $candidate );

		if ( null === $ref ) {
			return new \WP_Error( 'flexa_block_not_found', __( 'This block is no longer in the post — it may have been moved or removed in the editor. Reload the page and try again.', 'flexa-block' ), [ 'status' => 404 ] );
		}

		$clean = wp_kses( $value, self::allowed_html( (string) $field['allowed'] ) );

		if ( ! empty( $field['cell'] ) ) {
			// 2-axis matrix cell: attr[row]['values'][col] (comparison table).
			$row_i = absint( $index_raw );
			$col_i = absint( $sub_raw );
			if ( ! isset( $ref['attrs'][ $attr ] ) || ! is_array( $ref['attrs'][ $attr ] ) ) {
				$ref['attrs'][ $attr ] = self::default_attr_array( $name, $attr );
			}
			if ( ! isset( $ref['attrs'][ $attr ][ $row_i ] ) || ! is_array( $ref['attrs'][ $attr ][ $row_i ] ) ) {
				return new \WP_Error( 'flexa_bad_index', __( 'This list item could not be matched. Reload the page and try again.', 'flexa-block' ), [ 'status' => 400 ] );
			}
			if ( ! isset( $ref['attrs'][ $attr ][ $row_i ]['values'] ) || ! is_array( $ref['attrs'][ $attr ][ $row_i ]['values'] ) ) {
				$ref['attrs'][ $attr ][ $row_i ]['values'] = [];
			}
			$old                                             = (string) ( $ref['attrs'][ $attr ][ $row_i ]['values'][ $col_i ] ?? '' );
			$ref['attrs'][ $attr ][ $row_i ]['values'][ $col_i ] = $clean;
		} elseif ( ! empty( $field['repeat'] ) ) {
			$i        = absint( $index_raw );
			$item_key = (string) $field['key'];
			// The array attribute is omitted from the block markup while it still
			// equals the block.json default (Gutenberg only serializes changed
			// attributes), so seed it from the registered default before indexing.
			if ( ! isset( $ref['attrs'][ $attr ] ) || ! is_array( $ref['attrs'][ $attr ] ) ) {
				$ref['attrs'][ $attr ] = self::default_attr_array( $name, $attr );
			}
			if ( ! isset( $ref['attrs'][ $attr ][ $i ] ) || ! is_array( $ref['attrs'][ $attr ][ $i ] ) ) {
				return new \WP_Error( 'flexa_bad_index', __( 'This list item could not be matched. Reload the page and try again.', 'flexa-block' ), [ 'status' => 400 ] );
			}
			$old                                      = (string) ( $ref['attrs'][ $attr ][ $i ][ $item_key ] ?? '' );
			$ref['attrs'][ $attr ][ $i ][ $item_key ] = $clean;
		} elseif ( ! empty( $field['object'] ) ) {
			$obj_key = (string) $field['key'];
			if ( ! isset( $ref['attrs'][ $attr ] ) || ! is_array( $ref['attrs'][ $attr ] ) ) {
				$ref['attrs'][ $attr ] = self::default_attr_array( $name, $attr );
			}
			$old                               = (string) ( $ref['attrs'][ $attr ][ $obj_key ] ?? '' );
			$ref['attrs'][ $attr ][ $obj_key ] = $clean;
		} else {
			$old                   = (string) ( $ref['attrs'][ $attr ] ?? '' );
			$ref['attrs'][ $attr ] = $clean;
		}

		$updated = serialize_blocks( $blocks );

		// wp_slash: wp_update_post runs wp_unslash on the content, so pre-slash it
		// to keep backslashes/quotes in the serialized block markup intact.
		$result = wp_update_post(
			[
				'ID'           => $post_id,
				'post_content' => wp_slash( $updated ),
			],
			true
		);

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

		self::log_edit( $post_id, $name, $old, $clean );

		return rest_ensure_response( [ 'success' => true ] );
	}

	/* ---------------------------------------------------------------------
	 * Helpers
	 * ------------------------------------------------------------------ */

	/**
	 * Resolve a registry field for a block by attribute (+ repeater key).
	 *
	 * @param string      $name Block name.
	 * @param string      $attr Attribute name.
	 * @param string|null $key  Repeater item key, or null for a top-level field.
	 * @return array<string, mixed>|null
	 */
	private static function find_field( string $name, string $attr, ?string $key, bool $has_index ): ?array {
		$key = ( null === $key || '' === $key ) ? null : $key;

		foreach ( self::fields_for( $name ) as $field ) {
			if ( ( $field['attr'] ?? '' ) !== $attr ) {
				continue;
			}
			$fkey = isset( $field['key'] ) ? (string) $field['key'] : null;

			if ( ! empty( $field['repeat'] ) ) {
				// Array item: items[index][key].
				if ( $has_index && null !== $key && $fkey === $key ) {
					return $field;
				}
			} elseif ( ! empty( $field['object'] ) ) {
				// Object sub-key: attr[key].
				if ( ! $has_index && null !== $key && $fkey === $key ) {
					return $field;
				}
			} elseif ( ! $has_index && null === $key ) {
				// Plain top-level attribute.
				return $field;
			}
		}
		return null;
	}

	/**
	 * The block.json default for an array/object attribute (empty array if none).
	 *
	 * Blocks omit attributes that still equal their default from the serialized
	 * markup, so an edit to a still-default list/object must rebuild from this.
	 *
	 * @param string $name Block name.
	 * @param string $attr Attribute name.
	 * @return array<int|string, mixed>
	 */
	private static function default_attr_array( string $name, string $attr ): array {
		if ( ! class_exists( '\WP_Block_Type_Registry' ) ) {
			return [];
		}
		$type = \WP_Block_Type_Registry::get_instance()->get_registered( $name );
		if ( ! $type || ! is_array( $type->attributes ) ) {
			return [];
		}
		$schema  = $type->attributes[ $attr ] ?? null;
		$default = is_array( $schema ) ? ( $schema['default'] ?? null ) : null;
		return is_array( $default ) ? $default : [];
	}

	/**
	 * Flatten a parsed block tree into a list of references (depth-first).
	 *
	 * @param array<int, array<string, mixed>> $blocks Parsed blocks (by ref).
	 * @param array<int, array<string, mixed>> $flat   Accumulator (by ref).
	 */
	private static function flatten( array &$blocks, array &$flat ): void {
		foreach ( $blocks as &$block ) {
			$flat[] = &$block;
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				self::flatten( $block['innerBlocks'], $flat );
			}
		}
		unset( $block );
	}

	/**
	 * The wp_kses allowlist for a field, mirroring the block's render.php.
	 *
	 * @param string $key One of: rich (text), inline (heading), minimal (button).
	 * @return array<string, array<string, bool|array<int, string>>>
	 */
	public static function allowed_html( string $key ): array {
		$inline = [
			'strong' => [],
			'em'     => [],
			'b'      => [],
			'i'      => [],
			'br'     => [],
			'span'   => [ 'style' => true, 'class' => true ],
			'mark'   => [ 'style' => true, 'class' => true ],
			'sub'    => [],
			'sup'    => [],
		];

		$minimal = [
			'strong' => [],
			'em'     => [],
			'b'      => [],
			'i'      => [],
			'br'     => [],
			'span'   => [],
		];

		switch ( $key ) {
			case 'plain':
				// Plain text only — strips every tag, keeps the text.
				return [];
			case 'post':
				// The full post allowlist (e.g. FAQ answers use wp_kses_post).
				return wp_kses_allowed_html( 'post' );
			case 'rich':
				$inline['a'] = [ 'href' => [], 'target' => [], 'rel' => [] ];
				return $inline;
			case 'minimal':
				return $minimal;
			case 'minimal-link':
				$minimal['a'] = [ 'href' => [], 'target' => [], 'rel' => [] ];
				return $minimal;
			case 'inline':
			default:
				return $inline;
		}
	}

	/**
	 * Record an edit as a WordPress comment note (audit trail).
	 *
	 * @param int    $post_id Post ID.
	 * @param string $name    Block name.
	 * @param string $old     Previous value.
	 * @param string $new     New value.
	 */
	private static function log_edit( int $post_id, string $name, string $old, string $new ): void {
		$user  = wp_get_current_user();
		$type  = str_replace( 'flexa/', '', $name );
		$old_s = mb_strimwidth( wp_strip_all_tags( $old ), 0, 80, '…' );
		$new_s = mb_strimwidth( wp_strip_all_tags( $new ), 0, 80, '…' );

		$note = sprintf(
			/* translators: 1: block type, 2: old text, 3: new text. */
			__( 'Edited %1$s from front end: "%2$s" → "%3$s"', 'flexa-block' ),
			$type,
			$old_s,
			$new_s
		);

		wp_insert_comment(
			[
				'comment_post_ID'      => $post_id,
				'comment_content'      => $note,
				'comment_type'         => 'note',
				'user_id'              => $user->ID,
				'comment_author'       => $user->display_name,
				'comment_author_email' => $user->user_email,
				'comment_approved'     => 1,
			]
		);
	}
}
