<?php
declare(strict_types=1);

namespace Help4BuilderSuite\Modules\Fields;

use Help4BuilderSuite\Contracts\ModuleContract;
use Help4BuilderSuite\Modules\Commerce\CustomerFieldFormula;
use Help4BuilderSuite\Modules\Commerce\ProductRepository;
use Help4BuilderSuite\Support\AdminRequest;
use WP_Error;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
use WP_Screen;

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

final class Module implements ModuleContract
{
    private const FIELD_SCHEMA_VERSION = '2.0';

    private DynamicDataResolver $resolver;
    private ProductRepository $products;

    public function __construct(?DynamicDataResolver $resolver = null, ?ProductRepository $products = null)
    {
        $this->products = $products ?? new ProductRepository();
        $this->resolver = $resolver ?? new DynamicDataResolver($this->products);
    }

    public function key(): string
    {
        return 'fields';
    }

    public function boot(): void
    {
        add_action('init', [$this, 'registerPostType']);
        add_action('admin_menu', [$this, 'registerAdminMenus']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']);
        add_action('add_meta_boxes_h4_field_group', [$this, 'registerFieldGroupMetaBoxes']);
        add_action('add_meta_boxes', [$this, 'registerPostMetaBox'], 20, 2);
        add_action('save_post_h4_field_group', [$this, 'saveFieldGroup']);
        add_action('save_post', [$this, 'savePostFields'], 20, 2);
        add_action('current_screen', [$this, 'setupAdminMenuHighlight']);
        add_action('rest_api_init', [$this, 'registerRestRoutes']);
        add_shortcode('h4_field', [$this, 'renderFieldShortcode']);
        add_shortcode('h4_dynamic', [$this, 'renderDynamicShortcode']);
        add_filter('h4bs_dynamic_data_value', [$this, 'filterPrivateDynamicField'], 10, 3);
    }

    public function enqueueAdminAssets(string $hookSuffix): void
    {
        if (! in_array($hookSuffix, ['post.php', 'post-new.php'], true)) {
            return;
        }

        $screen = get_current_screen();
        if (! $screen instanceof WP_Screen) {
            return;
        }

        $usesFields = $screen->post_type === 'h4_field_group'
            || ($screen->post_type !== '' && $this->groupsForType((string) $screen->post_type) !== []);
        if (! $usesFields) {
            return;
        }

        wp_enqueue_media();
        wp_enqueue_style(
            'h4bs-fields-admin',
            H4BS_URL . 'assets/css/fields-admin.css',
            [],
            H4BS_VERSION
        );
    }

    public function registerPostType(): void
    {
        register_post_type(
            'h4_field_group',
            [
                'label'           => __('Help4 Field Groups', 'help4-builder-suite'),
                'labels'          => [
                    'name'               => __('Help4 Field Groups', 'help4-builder-suite'),
                    'singular_name'      => __('Help4 Field Group', 'help4-builder-suite'),
                    'add_new'            => __('Add Field Group', 'help4-builder-suite'),
                    'add_new_item'       => __('Add Help4 Field Group', 'help4-builder-suite'),
                    'edit_item'          => __('Edit Help4 Field Group', 'help4-builder-suite'),
                    'new_item'           => __('New Help4 Field Group', 'help4-builder-suite'),
                    'view_item'          => __('View Help4 Field Group', 'help4-builder-suite'),
                    'search_items'       => __('Search Help4 Field Groups', 'help4-builder-suite'),
                    'not_found'          => __('No field groups found.', 'help4-builder-suite'),
                    'not_found_in_trash' => __('No field groups found in Trash.', 'help4-builder-suite'),
                ],
                'public'          => false,
                'show_ui'         => true,
                'show_in_rest'    => true,
                'menu_icon'       => 'dashicons-feedback',
                'supports'        => ['title'],
                'show_in_menu'    => false,
                'capability_type' => 'post',
                'map_meta_cap'    => true,
            ]
        );
    }

    public function registerAdminMenus(): void
    {
        add_submenu_page(
            'h4bs-settings',
            __('Custom Fields', 'help4-builder-suite'),
            __('Custom Fields', 'help4-builder-suite'),
            'edit_pages',
            'edit.php?post_type=h4_field_group'
        );

        add_submenu_page(
            'h4bs-settings',
            __('Fields Lab', 'help4-builder-suite'),
            __('Fields Lab', 'help4-builder-suite'),
            'edit_pages',
            'h4bs-fields-lab',
            [$this, 'renderFieldsLabScreen']
        );
    }

    public function registerFieldGroupMetaBoxes(): void
    {
        add_meta_box(
            'h4bs-field-group-builder',
            __('Field Group Builder', 'help4-builder-suite'),
            [$this, 'renderFieldGroupMetaBox'],
            'h4_field_group',
            'normal',
            'high'
        );
    }

    public function renderFieldGroupMetaBox(WP_Post $post): void
    {
        wp_nonce_field('h4bs_field_group', 'h4bs_field_group_nonce');

        $location = (string) get_post_meta($post->ID, '_h4_group_location', true);
        if ($location === '') {
            $location = 'post,page';
        }

        $fields = (string) get_post_meta($post->ID, '_h4_group_fields', true);
        if ($fields === '') {
            $fields = wp_json_encode(
                [
                    [
                        'key'         => 'hero_subtitle',
                        'label'       => 'Hero Subtitle',
                        'type'        => 'text',
                        'placeholder' => 'Optional subtitle',
                    ],
                ],
                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
            );
        }
        ?>
        <p>
            <label for="h4_group_location"><strong><?php esc_html_e('Location (comma separated post types or "all")', 'help4-builder-suite'); ?></strong></label><br>
            <input id="h4_group_location" class="widefat code" type="text" name="h4_group_location" value="<?php echo esc_attr($location); ?>">
        </p>
        <p><?php esc_html_e('Use the visual field builder below. Advanced JSON mode remains available for nested repeater/group payloads.', 'help4-builder-suite'); ?></p>

        <div class="h4bs-field-group-builder" data-h4bs-field-group-builder>
            <table class="widefat striped">
                <thead>
                    <tr>
                        <th><?php esc_html_e('Key', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Label', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Type', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Placeholder', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Options (comma)', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Conditional', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Required', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Actions', 'help4-builder-suite'); ?></th>
                    </tr>
                </thead>
                <tbody data-h4bs-field-group-rows></tbody>
            </table>
            <p>
                <button type="button" class="button button-secondary" data-h4bs-add-field><?php esc_html_e('Add Field', 'help4-builder-suite'); ?></button>
            </p>
        </div>

        <details style="margin-top:10px;">
            <summary><strong><?php esc_html_e('Advanced JSON Mode', 'help4-builder-suite'); ?></strong></summary>
            <p>
                <?php esc_html_e('You can still paste full JSON for advanced nested field structures (repeater/group sub_fields).', 'help4-builder-suite'); ?>
            </p>
            <textarea name="h4_group_fields" data-h4bs-field-json rows="16" style="width:100%;font-family:Consolas,Menlo,monospace;"><?php echo esc_textarea($fields); ?></textarea>
        </details>

        <p class="description">
            <?php esc_html_e('Dynamic output examples: [h4_field key="hero_subtitle"], [h4_field key="hero_image" format="image"], [h4_field key="feature_list" format="list"].', 'help4-builder-suite'); ?>
        </p>
        <script>
            (function () {
                const root = document.querySelector('[data-h4bs-field-group-builder]');
                const rowsWrap = root ? root.querySelector('[data-h4bs-field-group-rows]') : null;
                const addButton = root ? root.querySelector('[data-h4bs-add-field]') : null;
                const jsonInput = document.querySelector('[data-h4bs-field-json]');
                if (!root || !rowsWrap || !addButton || !jsonInput) {
                    return;
                }

                const fieldTypes = [
                    'text',
                    'textarea',
                    'number',
                    'range',
                    'email',
                    'url',
                    'link',
                    'select',
                    'multi_select',
                    'radio',
                    'button_group',
                    'checkbox',
                    'true_false',
                    'image',
                    'file',
                    'repeater',
                    'group',
                    'gallery',
                    'date_picker',
                    'date_time_picker',
                    'time_picker',
                    'color_picker',
                    'wysiwyg',
                    'relationship',
                    'post_object',
                    'taxonomy',
                    'user',
                    'oembed',
                    'map',
                    'icon',
                    'json',
                    'code',
                    'computed'
                ];
                const conditionOps = ['==', '!=', 'contains', '!contains'];

                const parseJson = function () {
                    try {
                        const decoded = JSON.parse(jsonInput.value || '[]');
                        return Array.isArray(decoded) ? decoded : [];
                    } catch (error) {
                        return [];
                    }
                };

                const makeDefault = function () {
                    return {
                        key: '',
                        label: '',
                        type: 'text',
                        placeholder: '',
                        instructions: '',
                        required: 0,
                        default_value: '',
                        options: [],
                        conditional: { field: '', operator: '==', value: '' }
                    };
                };

                const toOptionsString = function (value) {
                    if (!Array.isArray(value)) {
                        return '';
                    }
                    return value.map(function (item) { return String(item || '').trim(); }).filter(Boolean).join(',');
                };

                const normalize = function (field) {
                    const source = field && typeof field === 'object' ? field : {};
                    const conditional = source.conditional && typeof source.conditional === 'object' ? source.conditional : {};
                    return Object.assign({}, source, {
                        key: String(source.key || ''),
                        label: String(source.label || ''),
                        type: fieldTypes.includes(String(source.type || '')) ? String(source.type) : 'text',
                        placeholder: String(source.placeholder || ''),
                        instructions: String(source.instructions || ''),
                        required: source.required ? 1 : 0,
                        readonly: source.readonly ? 1 : 0,
                        public: Object.prototype.hasOwnProperty.call(source, 'public') && !source.public ? 0 : 1,
                        show_in_rest: Object.prototype.hasOwnProperty.call(source, 'show_in_rest') && !source.show_in_rest ? 0 : 1,
                        multiple: source.multiple ? 1 : 0,
                        unique: source.unique ? 1 : 0,
                        default_value: String(source.default_value || ''),
                        formula: String(source.formula || ''),
                        return_format: String(source.return_format || 'value'),
                        capability: String(source.capability || 'edit_posts'),
                        taxonomy: String(source.taxonomy || ''),
                        post_types: Array.isArray(source.post_types) ? source.post_types : [],
                        min: source.min === null || typeof source.min === 'undefined' ? '' : String(source.min),
                        max: source.max === null || typeof source.max === 'undefined' ? '' : String(source.max),
                        step: source.step === null || typeof source.step === 'undefined' ? '' : String(source.step),
                        min_length: String(source.min_length || ''),
                        max_length: String(source.max_length || ''),
                        max_rows: String(source.max_rows || ''),
                        pattern: String(source.pattern || ''),
                        options: Array.isArray(source.options) ? source.options : [],
                        conditional: {
                            field: String(conditional.field || ''),
                            operator: conditionOps.includes(String(conditional.operator || '')) ? String(conditional.operator) : '==',
                            value: String(conditional.value || '')
                        },
                        sub_fields: Array.isArray(source.sub_fields) ? source.sub_fields : []
                    });
                };

                const snapshot = function () {
                    const rows = Array.from(rowsWrap.querySelectorAll('tr[data-row]'));
                    return rows.map(function (row) {
                        const rowId = String(row.getAttribute('data-row-id') || '');
                        const advancedRow = rowId !== ''
                            ? rowsWrap.querySelector('tr[data-advanced-for="' + rowId + '"]')
                            : null;
                        const required = row.querySelector('[data-role="required"]');
                        const optionsRaw = String((row.querySelector('[data-role="options"]') || {}).value || '');
                        const options = optionsRaw.split(',').map(function (item) { return item.trim(); }).filter(Boolean);
                        const subFieldsRaw = String(((advancedRow && advancedRow.querySelector('[data-role="sub_fields"]')) || {}).value || '').trim();
                        let subFields = [];
                        if (subFieldsRaw !== '') {
                            try {
                                const decoded = JSON.parse(subFieldsRaw);
                                subFields = Array.isArray(decoded) ? decoded : [];
                            } catch (error) {
                                subFields = [];
                            }
                        }

                        let base = {};
                        try {
                            base = JSON.parse(row.getAttribute('data-h4bs-field-source') || '{}');
                        } catch (error) {
                            base = {};
                        }
                        Object.assign(base, {
                            key: String((row.querySelector('[data-role="key"]') || {}).value || '').trim(),
                            label: String((row.querySelector('[data-role="label"]') || {}).value || '').trim(),
                            type: String((row.querySelector('[data-role="type"]') || {}).value || 'text').trim(),
                            placeholder: String((row.querySelector('[data-role="placeholder"]') || {}).value || '').trim(),
                            instructions: String(((advancedRow && advancedRow.querySelector('[data-role="instructions"]')) || {}).value || '').trim(),
                            default_value: String(((advancedRow && advancedRow.querySelector('[data-role="default"]')) || {}).value || '').trim(),
                            options: options,
                            required: required && required.checked ? 1 : 0,
                            readonly: !!(advancedRow && advancedRow.querySelector('[data-role="readonly"]') && advancedRow.querySelector('[data-role="readonly"]').checked) ? 1 : 0,
                            public: !!(advancedRow && advancedRow.querySelector('[data-role="public"]') && advancedRow.querySelector('[data-role="public"]').checked) ? 1 : 0,
                            show_in_rest: !!(advancedRow && advancedRow.querySelector('[data-role="show_in_rest"]') && advancedRow.querySelector('[data-role="show_in_rest"]').checked) ? 1 : 0,
                            multiple: !!(advancedRow && advancedRow.querySelector('[data-role="multiple"]') && advancedRow.querySelector('[data-role="multiple"]').checked) ? 1 : 0,
                            unique: !!(advancedRow && advancedRow.querySelector('[data-role="unique"]') && advancedRow.querySelector('[data-role="unique"]').checked) ? 1 : 0,
                            formula: String(((advancedRow && advancedRow.querySelector('[data-role="formula"]')) || {}).value || '').trim(),
                            return_format: String(((advancedRow && advancedRow.querySelector('[data-role="return_format"]')) || {}).value || 'value').trim(),
                            capability: String(((advancedRow && advancedRow.querySelector('[data-role="capability"]')) || {}).value || 'edit_posts').trim(),
                            taxonomy: String(((advancedRow && advancedRow.querySelector('[data-role="taxonomy"]')) || {}).value || '').trim(),
                            post_types: String(((advancedRow && advancedRow.querySelector('[data-role="post_types"]')) || {}).value || '').split(',').map(function (value) { return value.trim(); }).filter(Boolean),
                            min: String(((advancedRow && advancedRow.querySelector('[data-role="min"]')) || {}).value || '').trim(),
                            max: String(((advancedRow && advancedRow.querySelector('[data-role="max"]')) || {}).value || '').trim(),
                            step: String(((advancedRow && advancedRow.querySelector('[data-role="step"]')) || {}).value || '').trim(),
                            min_length: String(((advancedRow && advancedRow.querySelector('[data-role="min_length"]')) || {}).value || '').trim(),
                            max_length: String(((advancedRow && advancedRow.querySelector('[data-role="max_length"]')) || {}).value || '').trim(),
                            max_rows: String(((advancedRow && advancedRow.querySelector('[data-role="max_rows"]')) || {}).value || '').trim(),
                            pattern: String(((advancedRow && advancedRow.querySelector('[data-role="pattern"]')) || {}).value || '').trim()
                        });

                        const conditionalField = String((row.querySelector('[data-role="cond_field"]') || {}).value || '').trim();
                        const conditionalValue = String((row.querySelector('[data-role="cond_value"]') || {}).value || '').trim();
                        const conditionalOperator = String((row.querySelector('[data-role="cond_operator"]') || {}).value || '==').trim();
                        if (conditionalField !== '') {
                            base.conditional = {
                                field: conditionalField,
                                operator: conditionalOperator,
                                value: conditionalValue
                            };
                        }
                        if (subFields.length) {
                            base.sub_fields = subFields;
                        }
                        return base;
                    }).filter(function (row) {
                        return row.key !== '';
                    });
                };

                const syncJson = function () {
                    const rows = snapshot();
                    jsonInput.value = JSON.stringify(rows, null, 2);
                };

                const makeText = function (value, role, placeholder, width) {
                    const input = document.createElement('input');
                    input.type = 'text';
                    input.value = String(value || '');
                    input.className = width ? width : 'regular-text';
                    input.setAttribute('data-role', role);
                    if (placeholder) {
                        input.placeholder = placeholder;
                    }
                    input.addEventListener('input', syncJson);
                    return input;
                };

                const makeTextarea = function (value, role, rows, placeholder) {
                    const input = document.createElement('textarea');
                    input.rows = rows || 2;
                    input.value = String(value || '');
                    input.className = 'widefat';
                    input.setAttribute('data-role', role);
                    if (placeholder) {
                        input.placeholder = placeholder;
                    }
                    input.addEventListener('input', syncJson);
                    return input;
                };

                const makeSelect = function (value, role, options) {
                    const select = document.createElement('select');
                    select.className = 'widefat';
                    select.setAttribute('data-role', role);
                    options.forEach(function (option) {
                        const opt = document.createElement('option');
                        opt.value = option;
                        opt.textContent = option;
                        if (String(value || '') === option) {
                            opt.selected = true;
                        }
                        select.appendChild(opt);
                    });
                    select.addEventListener('change', syncJson);
                    return select;
                };

                const makeCheckbox = function (checkedValue, role, labelText) {
                    const label = document.createElement('label');
                    const input = document.createElement('input');
                    input.type = 'checkbox';
                    input.checked = !!checkedValue;
                    input.setAttribute('data-role', role);
                    input.addEventListener('change', syncJson);
                    label.appendChild(input);
                    label.appendChild(document.createTextNode(' ' + labelText));
                    return label;
                };

                const makeLabeled = function (labelText, input) {
                    const label = document.createElement('label');
                    label.style.display = 'grid';
                    label.style.gap = '4px';
                    const strong = document.createElement('strong');
                    strong.textContent = labelText;
                    label.appendChild(strong);
                    label.appendChild(input);
                    return label;
                };

                const appendCell = function (row, node) {
                    const td = document.createElement('td');
                    td.appendChild(node);
                    row.appendChild(td);
                };

                const buildRow = function (field) {
                    const item = normalize(field);
                    const row = document.createElement('tr');
                    row.setAttribute('data-row', '1');
                    row.setAttribute('data-h4bs-field-source', JSON.stringify(item));
                    const rowId = 'h4row_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
                    row.setAttribute('data-row-id', rowId);

                    appendCell(row, makeText(item.key, 'key', 'hero_subtitle', 'small-text'));
                    appendCell(row, makeText(item.label, 'label', 'Hero Subtitle'));
                    appendCell(row, makeSelect(item.type, 'type', fieldTypes));
                    appendCell(row, makeText(item.placeholder, 'placeholder', 'Optional subtitle'));
                    appendCell(row, makeText(toOptionsString(item.options), 'options', 'basic,pro,vip'));

                    const conditionalWrap = document.createElement('div');
                    conditionalWrap.appendChild(makeText(item.conditional.field, 'cond_field', 'field_key'));
                    conditionalWrap.appendChild(makeSelect(item.conditional.operator, 'cond_operator', conditionOps));
                    conditionalWrap.appendChild(makeText(item.conditional.value, 'cond_value', 'expected value'));
                    appendCell(row, conditionalWrap);

                    const requiredWrap = document.createElement('label');
                    const required = document.createElement('input');
                    required.type = 'checkbox';
                    required.setAttribute('data-role', 'required');
                    required.checked = !!item.required;
                    required.addEventListener('change', syncJson);
                    requiredWrap.appendChild(required);
                    requiredWrap.appendChild(document.createTextNode(' required'));
                    appendCell(row, requiredWrap);

                    const actions = document.createElement('div');
                    const duplicate = document.createElement('button');
                    duplicate.type = 'button';
                    duplicate.className = 'button button-small';
                    duplicate.textContent = '<?php echo esc_js(__('Duplicate', 'help4-builder-suite')); ?>';
                    duplicate.addEventListener('click', function () {
                        const rows = Array.from(rowsWrap.querySelectorAll('tr[data-row]'));
                        const index = rows.indexOf(row);
                        const copy = normalize(snapshot()[index] || item);
                        const insertPoint = advanced.nextSibling;
                        rowsWrap.insertBefore(buildRow(copy), insertPoint);
                        syncJson();
                    });

                    const remove = document.createElement('button');
                    remove.type = 'button';
                    remove.className = 'button button-small';
                    remove.style.marginLeft = '6px';
                    remove.textContent = '<?php echo esc_js(__('Delete', 'help4-builder-suite')); ?>';
                    remove.addEventListener('click', function () {
                        row.remove();
                        advanced.remove();
                        syncJson();
                    });

                    actions.appendChild(duplicate);
                    actions.appendChild(remove);
                    appendCell(row, actions);

                    const advanced = document.createElement('tr');
                    advanced.setAttribute('data-advanced-for', rowId);
                    const advancedCell = document.createElement('td');
                    advancedCell.colSpan = 8;
                    advancedCell.style.background = '#f9fafb';
                    const advancedGrid = document.createElement('div');
                    advancedGrid.style.display = 'grid';
                    advancedGrid.style.gridTemplateColumns = 'repeat(auto-fit,minmax(180px,1fr))';
                    advancedGrid.style.gap = '10px';
                    advancedGrid.appendChild(makeLabeled('Instructions', makeText(item.instructions, 'instructions', 'Editor hint')));
                    advancedGrid.appendChild(makeLabeled('Default', makeText(item.default_value, 'default', 'Default value')));
                    advancedGrid.appendChild(makeLabeled('Formula', makeText(item.formula, 'formula', '{quantity} * {price}')));
                    advancedGrid.appendChild(makeLabeled('Return format', makeSelect(item.return_format, 'return_format', ['value', 'id', 'url', 'object', 'array', 'label'])));
                    advancedGrid.appendChild(makeLabeled('Edit capability', makeText(item.capability, 'capability', 'edit_posts')));
                    advancedGrid.appendChild(makeLabeled('Taxonomy', makeText(item.taxonomy, 'taxonomy', 'category')));
                    advancedGrid.appendChild(makeLabeled('Post types', makeText(item.post_types.join(','), 'post_types', 'post,page')));
                    advancedGrid.appendChild(makeLabeled('Minimum', makeText(item.min, 'min', '0')));
                    advancedGrid.appendChild(makeLabeled('Maximum', makeText(item.max, 'max', '100')));
                    advancedGrid.appendChild(makeLabeled('Step', makeText(item.step, 'step', '1')));
                    advancedGrid.appendChild(makeLabeled('Min length', makeText(item.min_length, 'min_length', '0')));
                    advancedGrid.appendChild(makeLabeled('Max length', makeText(item.max_length, 'max_length', '500')));
                    advancedGrid.appendChild(makeLabeled('Max rows', makeText(item.max_rows, 'max_rows', '20')));
                    advancedGrid.appendChild(makeLabeled('Validation pattern', makeText(item.pattern, 'pattern', '[A-Z0-9-]+')));
                    advancedGrid.appendChild(makeCheckbox(item.readonly, 'readonly', 'Read only'));
                    advancedGrid.appendChild(makeCheckbox(item.public, 'public', 'Public output'));
                    advancedGrid.appendChild(makeCheckbox(item.show_in_rest, 'show_in_rest', 'REST editable'));
                    advancedGrid.appendChild(makeCheckbox(item.multiple, 'multiple', 'Allow multiple'));
                    advancedGrid.appendChild(makeCheckbox(item.unique, 'unique', 'Require unique value'));
                    advancedCell.appendChild(advancedGrid);
                    advancedCell.appendChild(
                        makeTextarea(
                            item.sub_fields.length ? JSON.stringify(item.sub_fields) : '',
                            'sub_fields',
                            2,
                            '[{"key":"sub_item","label":"Sub Item","type":"text"}]'
                        )
                    );
                    advanced.appendChild(advancedCell);

                    const fragment = document.createDocumentFragment();
                    fragment.appendChild(row);
                    fragment.appendChild(advanced);
                    return fragment;
                };

                const mount = function (items) {
                    rowsWrap.innerHTML = '';
                    const safeItems = Array.isArray(items) && items.length ? items : [makeDefault()];
                    safeItems.forEach(function (item) {
                        rowsWrap.appendChild(buildRow(item));
                    });
                    syncJson();
                };

                addButton.addEventListener('click', function () {
                    rowsWrap.appendChild(buildRow(makeDefault()));
                    syncJson();
                });

                jsonInput.addEventListener('change', function () {
                    mount(parseJson());
                });

                mount(parseJson());
            }());
        </script>
        <?php
    }

    public function renderFieldsLabScreen(): void
    {
        if (! current_user_can('edit_pages')) {
            return;
        }

        $groups = get_posts(
            [
                'post_type'      => 'h4_field_group',
                'post_status'    => ['publish', 'draft', 'private'],
                'posts_per_page' => 200,
                'orderby'        => 'modified',
                'order'          => 'DESC',
            ]
        );
        ?>
        <div class="wrap h4bs-admin-wrap">
            <h1><?php esc_html_e('Help4 Fields Lab', 'help4-builder-suite'); ?></h1>
            <p><?php esc_html_e('Structured custom fields with conditional logic, repeater/group support, and dynamic rendering in the visual builder.', 'help4-builder-suite'); ?></p>
            <p>
                <a class="button button-primary" href="<?php echo esc_url(admin_url('post-new.php?post_type=h4_field_group')); ?>"><?php esc_html_e('Create Field Group', 'help4-builder-suite'); ?></a>
                <a class="button" href="<?php echo esc_url(admin_url('edit.php?post_type=h4_field_group')); ?>"><?php esc_html_e('Manage Field Groups', 'help4-builder-suite'); ?></a>
            </p>
            <ul>
                <li><code>[h4_field key="hero_subtitle"]</code> - <?php esc_html_e('Text output', 'help4-builder-suite'); ?></li>
                <li><code>[h4_field key="hero_image" format="image"]</code> - <?php esc_html_e('Image output', 'help4-builder-suite'); ?></li>
                <li><code>[h4_field key="feature_items" format="json"]</code> - <?php esc_html_e('Raw data output', 'help4-builder-suite'); ?></li>
            </ul>

            <table class="widefat striped">
                <thead>
                    <tr>
                        <th><?php esc_html_e('Group', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Location', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Fields', 'help4-builder-suite'); ?></th>
                        <th><?php esc_html_e('Actions', 'help4-builder-suite'); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php if (! $groups) : ?>
                        <tr>
                            <td colspan="4"><?php esc_html_e('No field groups created yet.', 'help4-builder-suite'); ?></td>
                        </tr>
                    <?php endif; ?>
                    <?php foreach ($groups as $group) : ?>
                        <?php if (! $group instanceof WP_Post) { continue; } ?>
                        <?php
                        $groupId = (int) $group->ID;
                        $location = (string) get_post_meta($groupId, '_h4_group_location', true);
                        $fields = json_decode((string) get_post_meta($groupId, '_h4_group_fields', true), true);
                        $fieldCount = is_array($fields) ? count($fields) : 0;
                        ?>
                        <tr>
                            <td>
                                <strong><?php echo esc_html(get_the_title($groupId) ?: __('(no title)', 'help4-builder-suite')); ?></strong><br>
                                <small><?php echo esc_html((string) $group->post_status); ?></small>
                            </td>
                            <td><code><?php echo esc_html($location !== '' ? $location : 'post,page'); ?></code></td>
                            <td><?php echo esc_html((string) $fieldCount); ?></td>
                            <td>
                                <a class="button button-primary" href="<?php echo esc_url(get_edit_post_link($groupId, '') ?: '#'); ?>"><?php esc_html_e('Edit Group', 'help4-builder-suite'); ?></a>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <?php
    }

    public function registerRestRoutes(): void
    {
        register_rest_route(
            'h4bs/v1',
            '/fields/groups',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'restListFieldGroups'],
                'permission_callback' => static fn (): bool => current_user_can('edit_posts'),
            ]
        );

        register_rest_route(
            'h4bs/v1',
            '/fields/post/(?P<id>\d+)',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'restListPostFields'],
                'permission_callback' => static function (WP_REST_Request $request): bool {
                    $postId = absint($request->get_param('id'));
                    return $postId > 0 && current_user_can('edit_post', $postId);
                },
            ]
        );

        register_rest_route(
            'h4bs/v1',
            '/fields/post/(?P<id>\d+)',
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'restUpdatePostFields'],
                'permission_callback' => static function (WP_REST_Request $request): bool {
                    $postId = absint($request->get_param('id'));
                    return $postId > 0 && current_user_can('edit_post', $postId);
                },
            ]
        );

        register_rest_route(
            'h4bs/v1',
            '/fields/resolve',
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'restResolveDynamicData'],
                'permission_callback' => static fn (): bool => current_user_can('edit_posts'),
            ]
        );

        register_rest_route(
            'h4bs/v1',
            '/fields/product/(?P<id>\d+)',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'restProductFields'],
                    'permission_callback' => static fn (): bool => current_user_can('manage_options'),
                ],
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'restUpdateProductFields'],
                    'permission_callback' => static fn (): bool => current_user_can('manage_options'),
                ],
            ]
        );
    }

    public function restListFieldGroups(): WP_REST_Response
    {
        $groups = get_posts(
            [
                'post_type'      => 'h4_field_group',
                'post_status'    => ['publish', 'draft'],
                'posts_per_page' => 250,
                'orderby'        => 'menu_order title',
                'order'          => 'ASC',
            ]
        );

        $items = [];
        foreach ($groups as $group) {
            if (! $group instanceof WP_Post) {
                continue;
            }
            $items[] = [
                'id'       => (int) $group->ID,
                'title'    => (string) $group->post_title,
                'status'   => (string) $group->post_status,
                'location' => (string) get_post_meta($group->ID, '_h4_group_location', true),
                'schema_version' => (string) get_post_meta($group->ID, '_h4_group_schema_version', true) ?: self::FIELD_SCHEMA_VERSION,
                'fields'   => json_decode((string) get_post_meta($group->ID, '_h4_group_fields', true), true),
            ];
        }

        return new WP_REST_Response(['items' => $items]);
    }

    public function restListPostFields(WP_REST_Request $request): WP_REST_Response
    {
        $postId = absint($request->get_param('id'));
        if ($postId <= 0) {
            return new WP_REST_Response(['message' => __('Invalid post ID.', 'help4-builder-suite')], 422);
        }

        $meta = get_post_meta($postId);
        $items = [];
        foreach ($meta as $metaKey => $values) {
            if (! str_starts_with((string) $metaKey, '_h4f_')) {
                continue;
            }
            $fieldKey = substr((string) $metaKey, 5);
            $raw = is_array($values) && isset($values[0]) ? $values[0] : '';
            $items[$fieldKey] = $this->decodePossibleJson($raw);
        }

        return new WP_REST_Response(
            [
                'post_id' => $postId,
                'fields'  => $items,
                'definitions' => array_values($this->fieldDefinitionsForPost($postId)),
                'revision' => $this->fieldRevision($postId, $items),
                'schema_version' => self::FIELD_SCHEMA_VERSION,
            ]
        );
    }

    public function restUpdatePostFields(WP_REST_Request $request): WP_REST_Response
    {
        $postId = absint($request->get_param('id'));
        $post = get_post($postId);
        if (! $post instanceof WP_Post) {
            return new WP_REST_Response(['message' => __('Post not found.', 'help4-builder-suite')], 404);
        }

        $definitions = $this->fieldDefinitionsForPost($postId);
        $incoming = $request->get_param('fields');
        if (! is_array($incoming)) {
            return new WP_REST_Response(['message' => __('Send a fields object.', 'help4-builder-suite')], 422);
        }

        $current = $this->postFieldValues($postId);
        $expectedRevision = sanitize_text_field((string) $request->get_param('revision'));
        $actualRevision = $this->fieldRevision($postId, $current);
        if ($expectedRevision !== '' && ! hash_equals($actualRevision, $expectedRevision)) {
            return new WP_REST_Response(
                [
                    'code' => 'h4bs_field_revision_conflict',
                    'message' => __('These fields changed in another editor. Refresh before saving.', 'help4-builder-suite'),
                    'revision' => $actualRevision,
                    'fields' => $current,
                ],
                409
            );
        }

        $changes = [];
        $errors = [];
        $working = $current;
        foreach ($incoming as $key => $value) {
            $key = sanitize_key((string) $key);
            if ($key === '' || ! isset($definitions[$key])) {
                continue;
            }
            $definition = $definitions[$key];
            if ((array_key_exists('show_in_rest', $definition) && empty($definition['show_in_rest'])) || ! empty($definition['readonly']) || (string) ($definition['type'] ?? '') === 'computed') {
                continue;
            }
            $capability = sanitize_key((string) ($definition['capability'] ?? 'edit_posts'));
            if ($capability !== '' && ! current_user_can($capability)) {
                $errors[$key] = __('You do not have permission to edit this field.', 'help4-builder-suite');
                continue;
            }

            $clean = $this->validateAndSanitizeFieldValue($definition, $value, $postId);
            if ($clean instanceof WP_Error) {
                $errors[$key] = $clean->get_error_message();
                continue;
            }
            $working[$key] = $this->decodePossibleJson($clean);
            $changes[$key] = $clean;
        }
        if ($errors) {
            return new WP_REST_Response(['message' => __('Some fields need attention.', 'help4-builder-suite'), 'errors' => $errors], 422);
        }

        foreach ($changes as $key => $clean) {
            $old = get_post_meta($postId, '_h4f_' . $key, true);
            update_post_meta($postId, '_h4f_' . $key, $clean);
            do_action('h4bs_field_value_saved', $postId, $key, $clean, $old, 'rest');
        }
        $computedChanges = $this->updateComputedFields($postId, $definitions, $working, 'rest');
        $changes = array_merge($changes, $computedChanges);
        $saved = $this->postFieldValues($postId);
        do_action('h4bs_field_values_saved', $postId, $changes, 'rest');

        return new WP_REST_Response(
            [
                'post_id' => $postId,
                'fields' => $saved,
                'changed' => array_keys($changes),
                'revision' => $this->fieldRevision($postId, $saved),
                'schema_version' => self::FIELD_SCHEMA_VERSION,
            ]
        );
    }

    public function restResolveDynamicData(WP_REST_Request $request): WP_REST_Response
    {
        $query = $request->get_json_params();
        if (! is_array($query)) {
            $query = [];
        }
        $postId = absint($query['post_id'] ?? ($query['object_id'] ?? 0));
        if ($postId > 0 && in_array(sanitize_key((string) ($query['source'] ?? 'field')), ['field', 'post', 'post_meta'], true) && ! current_user_can('edit_post', $postId)) {
            return new WP_REST_Response(['message' => __('You cannot preview data from this post.', 'help4-builder-suite')], 403);
        }

        $value = $this->resolver->resolve($query);
        return new WP_REST_Response(
            [
                'value' => $value,
                'empty' => $value === null || $value === '' || $value === [],
                'source' => sanitize_key((string) ($query['source'] ?? 'field')),
            ]
        );
    }

    public function restProductFields(WP_REST_Request $request): WP_REST_Response
    {
        $productId = absint($request->get_param('id'));
        $product = $this->products->get($productId);
        if (! is_array($product)) {
            return new WP_REST_Response(['message' => __('Product not found.', 'help4-builder-suite')], 404);
        }
        $payload = $this->decodePossibleJson($product['payload'] ?? '');
        $payload = is_array($payload) ? $payload : [];
        $fields = is_array($payload['_h4_fields'] ?? null) ? $payload['_h4_fields'] : [];
        ksort($fields);

        return new WP_REST_Response(
            [
                'product_id' => $productId,
                'fields' => $fields,
                'definitions' => array_values($this->fieldDefinitionsForType('product')),
                'revision' => $this->productFieldRevision($productId, $fields),
                'schema_version' => self::FIELD_SCHEMA_VERSION,
            ]
        );
    }

    public function restUpdateProductFields(WP_REST_Request $request): WP_REST_Response
    {
        $productId = absint($request->get_param('id'));
        $product = $this->products->get($productId);
        if (! is_array($product)) {
            return new WP_REST_Response(['message' => __('Product not found.', 'help4-builder-suite')], 404);
        }
        $incoming = $request->get_param('fields');
        if (! is_array($incoming)) {
            return new WP_REST_Response(['message' => __('Send a fields object.', 'help4-builder-suite')], 422);
        }

        $payload = $this->decodePossibleJson($product['payload'] ?? '');
        $payload = is_array($payload) ? $payload : [];
        $current = is_array($payload['_h4_fields'] ?? null) ? $payload['_h4_fields'] : [];
        ksort($current);
        $expectedRevision = sanitize_text_field((string) $request->get_param('revision'));
        $actualRevision = $this->productFieldRevision($productId, $current);
        if ($expectedRevision !== '' && ! hash_equals($actualRevision, $expectedRevision)) {
            return new WP_REST_Response(
                [
                    'code' => 'h4bs_product_field_revision_conflict',
                    'message' => __('These product fields changed in another editor. Refresh before saving.', 'help4-builder-suite'),
                    'revision' => $actualRevision,
                    'fields' => $current,
                ],
                409
            );
        }

        $definitions = $this->fieldDefinitionsForType('product');
        $changes = [];
        $errors = [];
        foreach ($incoming as $key => $value) {
            $key = sanitize_key((string) $key);
            if ($key === '' || ! isset($definitions[$key])) {
                continue;
            }
            $definition = $definitions[$key];
            if ((array_key_exists('show_in_rest', $definition) && empty($definition['show_in_rest'])) || ! empty($definition['readonly']) || (string) ($definition['type'] ?? '') === 'computed') {
                continue;
            }
            $clean = $this->validateAndSanitizeFieldValue($definition, $value, 0);
            if ($clean instanceof WP_Error) {
                $errors[$key] = $clean->get_error_message();
                continue;
            }
            $current[$key] = $this->decodePossibleJson($clean);
            $changes[$key] = $clean;
        }
        if ($errors) {
            return new WP_REST_Response(['message' => __('Some product fields need attention.', 'help4-builder-suite'), 'errors' => $errors], 422);
        }

        $computed = $this->calculateComputedValues($definitions, $current);
        foreach ($computed as $key => $value) {
            $current[$key] = $value;
            $changes[$key] = $value;
        }
        $payload['_h4_fields'] = $current;
        if (! $this->products->update($productId, ['payload' => $payload])) {
            return new WP_REST_Response(['message' => __('Product fields could not be saved.', 'help4-builder-suite')], 500);
        }
        do_action('h4bs_product_field_values_saved', $productId, $changes);

        return new WP_REST_Response(
            [
                'product_id' => $productId,
                'fields' => $current,
                'changed' => array_keys($changes),
                'revision' => $this->productFieldRevision($productId, $current),
                'schema_version' => self::FIELD_SCHEMA_VERSION,
            ]
        );
    }

    public function saveFieldGroup(int $postId): void
    {
        if (! isset($_POST['h4bs_field_group_nonce'])) {
            return;
        }
        if (! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['h4bs_field_group_nonce'])), 'h4bs_field_group')) {
            return;
        }
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        if (! current_user_can('edit_post', $postId)) {
            return;
        }

        $locationRaw = isset($_POST['h4_group_location']) ? sanitize_text_field(wp_unslash($_POST['h4_group_location'])) : '';
        $locationParts = array_filter(array_map('trim', explode(',', strtolower($locationRaw))));
        $cleanLocations = [];
        foreach ($locationParts as $location) {
            if ($location === 'all') {
                $cleanLocations[] = 'all';
                continue;
            }
            $clean = sanitize_key($location);
            if ($clean !== '') {
                $cleanLocations[] = $clean;
            }
        }
        update_post_meta($postId, '_h4_group_location', implode(',', array_unique($cleanLocations)));

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each decoded field definition is sanitized by sanitizeFieldDefinition() below.
        $rawFields = isset($_POST['h4_group_fields']) ? (string) wp_unslash($_POST['h4_group_fields']) : '[]';
        $decoded = json_decode($rawFields, true);
        if (! is_array($decoded)) {
            return;
        }

        $cleanFields = [];
        foreach ($decoded as $field) {
            if (! is_array($field)) {
                continue;
            }

            $clean = $this->sanitizeFieldDefinition($field);
            if ($clean !== null) {
                $cleanFields[] = $clean;
            }
        }

        update_post_meta(
            $postId,
            '_h4_group_fields',
            wp_json_encode($cleanFields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        );
        update_post_meta($postId, '_h4_group_schema_version', self::FIELD_SCHEMA_VERSION);
        do_action('h4bs_field_group_saved', $postId, $cleanFields, self::FIELD_SCHEMA_VERSION);
    }

    public function registerPostMetaBox(string $postType, WP_Post $post): void
    {
        if (in_array($postType, ['h4_field_group', 'attachment', 'revision', 'nav_menu_item'], true)) {
            return;
        }

        $groups = $this->groupsForType($postType);
        if (! $groups) {
            return;
        }

        $self = $this;
        add_meta_box(
            'h4bs-dynamic-fields',
            __('Help4 Custom Fields', 'help4-builder-suite'),
            static function (WP_Post $targetPost) use ($groups, $self): void {
                wp_nonce_field('h4bs_dynamic_fields', 'h4bs_dynamic_fields_nonce');
                echo '<div data-h4bs-dynamic-fields>';
                foreach ($groups as $group) {
                    echo '<hr>';
                    echo '<h3>' . esc_html((string) $group['title']) . '</h3>';

                    foreach ($group['fields'] as $field) {
                        if (! is_array($field) || empty($field['key'])) {
                            continue;
                        }

                        $self->renderFieldControl($field, $targetPost->ID);
                    }
                }
                echo '</div>';
                $self->renderConditionalLogicScript();
                $self->renderMediaPickerScript();
                $self->renderFieldEditorScript();
            },
            null,
            'normal',
            'default'
        );
    }

    public function savePostFields(int $postId, WP_Post $post): void
    {
        if ($post->post_type === 'h4_field_group') {
            return;
        }

        if (! isset($_POST['h4bs_dynamic_fields_nonce'])) {
            return;
        }
        if (! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['h4bs_dynamic_fields_nonce'])), 'h4bs_dynamic_fields')) {
            return;
        }
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        if (! current_user_can('edit_post', $postId)) {
            return;
        }

        $groups = $this->groupsForType($post->post_type);
        if (! $groups) {
            return;
        }

        $posted = [];
        if (isset($_POST['h4bs_fields']) && is_array($_POST['h4bs_fields'])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Values are sanitized by their trusted field definition and type below.
            $posted = wp_unslash($_POST['h4bs_fields']);
        }
        $definitions = [];
        foreach ($groups as $group) {
            foreach ($group['fields'] as $field) {
                if (is_array($field) && ! empty($field['key'])) {
                    $definitions[(string) $field['key']] = $field;
                }
            }
        }

        $changes = [];
        $working = $this->postFieldValues($postId);
        foreach ($definitions as $key => $definition) {
            $type = sanitize_key((string) ($definition['type'] ?? 'text'));
            if ($type === 'computed' || ! empty($definition['readonly'])) {
                continue;
            }
            $capability = sanitize_key((string) ($definition['capability'] ?? 'edit_posts'));
            if ($capability !== '' && ! current_user_can($capability)) {
                continue;
            }

            $hasValue = array_key_exists($key, $posted);
            $value = $hasValue ? $posted[$key] : null;
            if (! $hasValue && ! in_array($type, ['true_false', 'checkbox', 'multi_select'], true)) {
                continue;
            }
            $clean = $this->validateAndSanitizeFieldValue($definition, $value, $postId);
            if ($clean instanceof WP_Error) {
                continue;
            }
            $old = get_post_meta($postId, '_h4f_' . $key, true);
            update_post_meta($postId, '_h4f_' . $key, $clean);
            $working[$key] = $this->decodePossibleJson($clean);
            $changes[$key] = $clean;
            do_action('h4bs_field_value_saved', $postId, $key, $clean, $old, 'editor');
        }

        $changes = array_merge($changes, $this->updateComputedFields($postId, $definitions, $working, 'editor'));
        if ($changes) {
            do_action('h4bs_field_values_saved', $postId, $changes, 'editor');
        }
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function fieldDefinitionsForPost(int $postId): array
    {
        $post = get_post($postId);
        if (! $post instanceof WP_Post) {
            return [];
        }
        return $this->fieldDefinitionsForType((string) $post->post_type);
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function fieldDefinitionsForType(string $objectType): array
    {
        $definitions = [];
        foreach ($this->groupsForType($objectType) as $group) {
            foreach ((array) ($group['fields'] ?? []) as $field) {
                if (! is_array($field)) {
                    continue;
                }
                $key = sanitize_key((string) ($field['key'] ?? ''));
                if ($key !== '') {
                    $definitions[$key] = $field;
                }
            }
        }
        return $definitions;
    }

    /**
     * @return array<string, mixed>
     */
    private function postFieldValues(int $postId): array
    {
        $values = [];
        foreach (get_post_meta($postId) as $metaKey => $metaValues) {
            if (! str_starts_with((string) $metaKey, '_h4f_')) {
                continue;
            }
            $key = sanitize_key(substr((string) $metaKey, 5));
            if ($key === '') {
                continue;
            }
            $raw = is_array($metaValues) && array_key_exists(0, $metaValues) ? $metaValues[0] : '';
            $values[$key] = $this->decodePossibleJson($raw);
        }
        ksort($values);
        return $values;
    }

    /**
     * @param array<string, mixed> $values
     */
    private function fieldRevision(int $postId, array $values): string
    {
        ksort($values);
        return hash('sha256', $postId . '|' . self::FIELD_SCHEMA_VERSION . '|' . (string) wp_json_encode($values));
    }

    /**
     * @param array<string, mixed> $values
     */
    private function productFieldRevision(int $productId, array $values): string
    {
        ksort($values);
        return hash('sha256', 'product|' . $productId . '|' . self::FIELD_SCHEMA_VERSION . '|' . (string) wp_json_encode($values));
    }

    /**
     * @param array<string, mixed> $definition
     * @return mixed|WP_Error
     */
    private function validateAndSanitizeFieldValue(array $definition, mixed $value, int $postId): mixed
    {
        $type = sanitize_key((string) ($definition['type'] ?? 'text'));
        $key = sanitize_key((string) ($definition['key'] ?? ''));
        $clean = $this->sanitizeFieldValueByType($type, $value);
        $decoded = $this->decodePossibleJson($clean);
        $empty = $decoded === null || $decoded === '' || $decoded === [] || $decoded === false;

        if (! empty($definition['required']) && $empty) {
            return new WP_Error('h4bs_field_required', __('This field is required.', 'help4-builder-suite'));
        }
        if ($empty) {
            return $clean;
        }

        if (is_numeric($decoded)) {
            $number = (float) $decoded;
            if (is_numeric($definition['min'] ?? null) && $number < (float) $definition['min']) {
                /* translators: %s: configured minimum numeric value. */
                return new WP_Error('h4bs_field_min', sprintf(__('Enter a value of at least %s.', 'help4-builder-suite'), (string) $definition['min']));
            }
            if (is_numeric($definition['max'] ?? null) && $number > (float) $definition['max']) {
                /* translators: %s: configured maximum numeric value. */
                return new WP_Error('h4bs_field_max', sprintf(__('Enter a value no greater than %s.', 'help4-builder-suite'), (string) $definition['max']));
            }
        }

        if (is_scalar($decoded)) {
            $text = (string) $decoded;
            $length = mb_strlen(wp_strip_all_tags($text));
            $minLength = max(0, (int) ($definition['min_length'] ?? 0));
            $maxLength = max(0, (int) ($definition['max_length'] ?? 0));
            if ($minLength > 0 && $length < $minLength) {
                /* translators: %d: configured minimum character count. */
                return new WP_Error('h4bs_field_min_length', sprintf(__('Use at least %d characters.', 'help4-builder-suite'), $minLength));
            }
            if ($maxLength > 0 && $length > $maxLength) {
                /* translators: %d: configured maximum character count. */
                return new WP_Error('h4bs_field_max_length', sprintf(__('Use no more than %d characters.', 'help4-builder-suite'), $maxLength));
            }
            $pattern = substr((string) ($definition['pattern'] ?? ''), 0, 200);
            if ($pattern !== '' && @preg_match('~' . str_replace('~', '\\~', $pattern) . '~u', $text) !== 1) {
                return new WP_Error('h4bs_field_pattern', __('Use the format configured for this field.', 'help4-builder-suite'));
            }
        }

        if (is_array($decoded)) {
            $maxRows = max(0, (int) ($definition['max_rows'] ?? 0));
            if ($maxRows > 0 && count($decoded) > $maxRows) {
                /* translators: %d: configured maximum structured item count. */
                return new WP_Error('h4bs_field_max_rows', sprintf(__('Use no more than %d items.', 'help4-builder-suite'), $maxRows));
            }
        }

        $options = array_values(array_map('strval', (array) ($definition['options'] ?? [])));
        if ($options && in_array($type, ['select', 'radio', 'button_group'], true) && ! in_array((string) $decoded, $options, true)) {
            return new WP_Error('h4bs_field_choice', __('Choose one of the configured options.', 'help4-builder-suite'));
        }
        if ($options && in_array($type, ['checkbox', 'multi_select'], true)) {
            foreach ((array) $decoded as $selected) {
                if (! in_array((string) $selected, $options, true)) {
                    return new WP_Error('h4bs_field_choice', __('Choose only configured options.', 'help4-builder-suite'));
                }
            }
        }

        if ($postId > 0 && ! empty($definition['unique']) && $key !== '' && is_scalar($decoded)) {
            $duplicates = get_posts(
                [
                    'post_type' => get_post_type($postId) ?: 'any',
                    'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
                    'post__not_in' => [$postId], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- A unique field must exclude the record currently being edited.
                    'posts_per_page' => 1,
                    'fields' => 'ids',
                    'meta_key' => '_h4f_' . $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Exact meta lookup is required only for fields explicitly configured as unique.
                    'meta_value' => (string) $clean, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Query is bounded to one ID and runs only on a unique-field save.
                    'no_found_rows' => true,
                ]
            );
            if ($duplicates) {
                return new WP_Error('h4bs_field_unique', __('This value is already used by another item.', 'help4-builder-suite'));
            }
        }

        return apply_filters('h4bs_field_value_validated', $clean, $definition, $postId);
    }

    /**
     * @param array<string, array<string, mixed>> $definitions
     * @param array<string, mixed> $values
     * @return array<string, mixed>
     */
    private function calculateComputedValues(array $definitions, array $values): array
    {
        $pending = [];
        foreach ($definitions as $key => $definition) {
            if ((string) ($definition['type'] ?? '') === 'computed' && (string) ($definition['formula'] ?? '') !== '') {
                $pending[$key] = $definition;
            }
        }
        $computed = [];
        for ($pass = 0; $pass < count($pending) + 1 && $pending; $pass++) {
            $progress = false;
            foreach ($pending as $key => $definition) {
                $formula = (string) $definition['formula'];
                $references = CustomerFieldFormula::references($formula);
                $missing = array_filter($references, static fn (string $reference): bool => ! array_key_exists($reference, $values));
                if ($missing) {
                    continue;
                }
                $result = CustomerFieldFormula::evaluate($formula, $values);
                if ($result instanceof WP_Error) {
                    unset($pending[$key]);
                    continue;
                }
                $values[$key] = (string) $result;
                $computed[$key] = (string) $result;
                unset($pending[$key]);
                $progress = true;
            }
            if (! $progress) {
                break;
            }
        }
        return $computed;
    }

    /**
     * @param array<string, array<string, mixed>> $definitions
     * @param array<string, mixed> $values
     * @return array<string, mixed>
     */
    private function updateComputedFields(int $postId, array $definitions, array &$values, string $context): array
    {
        $pending = [];
        foreach ($definitions as $key => $definition) {
            if ((string) ($definition['type'] ?? '') === 'computed' && (string) ($definition['formula'] ?? '') !== '') {
                $pending[$key] = $definition;
            }
        }

        $changes = [];
        for ($pass = 0; $pass < count($pending) + 1 && $pending; $pass++) {
            $progress = false;
            foreach ($pending as $key => $definition) {
                $formula = (string) $definition['formula'];
                $references = CustomerFieldFormula::references($formula);
                $missing = array_filter($references, static fn (string $reference): bool => ! array_key_exists($reference, $values));
                if ($missing) {
                    continue;
                }
                $result = CustomerFieldFormula::evaluate($formula, $values);
                if ($result instanceof WP_Error) {
                    unset($pending[$key]);
                    continue;
                }
                $clean = (string) $result;
                $old = get_post_meta($postId, '_h4f_' . $key, true);
                update_post_meta($postId, '_h4f_' . $key, $clean);
                $values[$key] = $clean;
                $changes[$key] = $clean;
                do_action('h4bs_field_value_saved', $postId, $key, $clean, $old, $context);
                unset($pending[$key]);
                $progress = true;
            }
            if (! $progress) {
                break;
            }
        }
        return $changes;
    }

    /**
     * @param array<string, mixed> $field
     */
    private function renderFieldControl(array $field, int $postId): void
    {
        $key = sanitize_key((string) ($field['key'] ?? ''));
        if ($key === '') {
            return;
        }

        $label = sanitize_text_field((string) ($field['label'] ?? $key));
        $type = sanitize_key((string) ($field['type'] ?? 'text'));
        if (! in_array($type, $this->allowedFieldTypes(), true)) {
            $type = 'text';
        }

        $rawValue = get_post_meta($postId, '_h4f_' . $key, true);
        if (($rawValue === '' || $rawValue === null) && array_key_exists('default_value', $field)) {
            $rawValue = $field['default_value'];
        }
        $placeholder = sanitize_text_field((string) ($field['placeholder'] ?? ''));
        $instructions = sanitize_text_field((string) ($field['instructions'] ?? ''));
        $required = ! empty($field['required']);
        $readonly = ! empty($field['readonly']) || $type === 'computed';
        $inputFlags = ($required ? ' required' : '') . ($readonly ? ' readonly' : '');

        $conditionField = sanitize_key((string) ($field['conditional']['field'] ?? ''));
        $conditionOperator = sanitize_text_field((string) ($field['conditional']['operator'] ?? ''));
        $conditionValue = sanitize_text_field((string) ($field['conditional']['value'] ?? ''));

        $wrapperAttributes = '';
        if ($conditionField !== '') {
            $wrapperAttributes .= ' data-h4bs-condition-field="' . esc_attr($conditionField) . '"';
            $wrapperAttributes .= ' data-h4bs-condition-operator="' . esc_attr($conditionOperator ?: '==') . '"';
            $wrapperAttributes .= ' data-h4bs-condition-value="' . esc_attr($conditionValue) . '"';
        }

        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wrapperAttributes is assembled only from esc_attr() values above.
        echo '<p class="h4bs-field-control"' . $wrapperAttributes . '>';
        echo '<label for="h4f_' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br>';

        switch ($type) {
            case 'textarea':
                // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- inputFlags contains only fixed required/readonly attribute fragments derived from booleans.
                printf(
                    '<textarea id="h4f_%1$s" class="widefat" rows="4" name="h4bs_fields[%1$s]" placeholder="%2$s"%3$s>%4$s</textarea>',
                    esc_attr($key),
                    esc_attr($placeholder),
                    $inputFlags,
                    esc_textarea((string) $rawValue)
                );
                // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
                break;

            case 'wysiwyg':
                wp_editor(
                    (string) $rawValue,
                    'h4f_' . $key,
                    [
                        'textarea_name' => 'h4bs_fields[' . $key . ']',
                        'textarea_rows' => 8,
                        'media_buttons' => true,
                        'quicktags' => true,
                    ]
                );
                break;

            case 'select':
            case 'multi_select':
                $isMultiple = $type === 'multi_select' || ! empty($field['multiple']);
                $selectedValues = $isMultiple ? $this->normalizeOptionSelection($rawValue) : [];
                echo '<select id="h4f_' . esc_attr($key) . '" class="widefat" name="h4bs_fields[' . esc_attr($key) . ']' . ($isMultiple ? '[]' : '') . '"' . ($isMultiple ? ' multiple size="6"' : '') . ($required ? ' required' : '') . ($readonly ? ' disabled' : '') . '>';
                echo '<option value="">' . esc_html__('Select', 'help4-builder-suite') . '</option>';
                foreach ((array) ($field['options'] ?? []) as $option) {
                    if (! is_string($option)) {
                        continue;
                    }
                    printf(
                        '<option value="%1$s" %2$s>%3$s</option>',
                        esc_attr($option),
                        selected($isMultiple ? in_array($option, $selectedValues, true) : (string) $rawValue === $option, true, false),
                        esc_html($option)
                    );
                }
                echo '</select>';
                break;

            case 'radio':
            case 'button_group':
                $selectedValue = is_scalar($rawValue) ? (string) $rawValue : '';
                foreach ((array) ($field['options'] ?? []) as $option) {
                    if (! is_string($option)) {
                        continue;
                    }
                    echo '<label style="display:block;margin-bottom:4px;">';
                    printf(
                        '<input type="radio" name="h4bs_fields[%1$s]" value="%2$s" %3$s%4$s> %5$s',
                        esc_attr($key),
                        esc_attr($option),
                        checked($selectedValue, $option, false),
                        $required ? ' required' : '',
                        esc_html($option)
                    );
                    echo '</label>';
                }
                break;

            case 'checkbox':
                $selectedValues = $this->normalizeOptionSelection($rawValue);
                foreach ((array) ($field['options'] ?? []) as $option) {
                    if (! is_string($option)) {
                        continue;
                    }
                    echo '<label style="display:block;margin-bottom:4px;">';
                    printf(
                        '<input type="checkbox" name="h4bs_fields[%1$s][]" value="%2$s" %3$s> %4$s',
                        esc_attr($key),
                        esc_attr($option),
                        checked(in_array($option, $selectedValues, true), true, false),
                        esc_html($option)
                    );
                    echo '</label>';
                }
                break;

            case 'true_false':
                printf(
                    '<label><input id="h4f_%1$s" type="checkbox" name="h4bs_fields[%1$s]" value="1" %2$s%3$s> %4$s</label>',
                    esc_attr($key),
                    checked((string) $rawValue, '1', false),
                    $readonly ? ' disabled' : '',
                    esc_html__('Enabled', 'help4-builder-suite')
                );
                break;

            case 'color_picker':
                printf(
                    '<span style="display:flex;align-items:center;gap:8px;"><input id="h4f_%1$s" type="color" name="h4bs_fields[%1$s]" value="%2$s"%3$s><input class="regular-text code" type="text" value="%2$s" aria-label="%4$s" data-h4bs-color-hex></span>',
                    esc_attr($key),
                    esc_attr((string) $rawValue ?: '#000000'),
                    $readonly ? ' disabled' : '',
                    esc_attr__('Hex color value', 'help4-builder-suite')
                );
                break;

            case 'image':
            case 'gallery':
            case 'file':
                $this->renderMediaPickerControl($key, $type, $rawValue, $placeholder);
                break;

            case 'repeater':
            case 'group':
            case 'map':
            case 'json':
            case 'code':
                // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- inputFlags contains only fixed required/readonly attribute fragments derived from booleans.
                printf(
                    '<textarea id="h4f_%1$s" class="widefat code" rows="6" name="h4bs_fields[%1$s]" placeholder="%2$s"%3$s>%4$s</textarea>',
                    esc_attr($key),
                    esc_attr($placeholder !== '' ? $placeholder : '[{"key":"value"}]'),
                    $inputFlags,
                    esc_textarea(is_string($rawValue) ? $rawValue : wp_json_encode($rawValue))
                );
                // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
                if ($type !== 'code') {
                    echo '<br><small>' . esc_html__('Structured JSON is validated when the post is saved.', 'help4-builder-suite') . '</small>';
                }
                break;

            case 'relationship':
            case 'post_object':
                $selectedPostIds = array_map('absint', $this->normalizeOptionSelection($rawValue));
                $allowedPostTypes = array_values(array_filter(array_map('sanitize_key', (array) ($field['post_types'] ?? ['post', 'page']))));
                $relationshipPosts = get_posts(
                    [
                        'post_type' => $allowedPostTypes ?: ['post', 'page'],
                        'post_status' => ['publish', 'draft', 'private'],
                        'posts_per_page' => 200,
                        'orderby' => 'title',
                        'order' => 'ASC',
                    ]
                );
                $isMultiple = $type === 'relationship' || ! empty($field['multiple']);
                echo '<select id="h4f_' . esc_attr($key) . '" class="widefat" name="h4bs_fields[' . esc_attr($key) . ']' . ($isMultiple ? '[]' : '') . '"' . ($isMultiple ? ' multiple size="8"' : '') . ($required ? ' required' : '') . ($readonly ? ' disabled' : '') . '>';
                echo '<option value="">' . esc_html__('Select content', 'help4-builder-suite') . '</option>';
                foreach ($relationshipPosts as $relationshipPost) {
                    if (! $relationshipPost instanceof WP_Post) {
                        continue;
                    }
                    printf(
                        '<option value="%1$d" %2$s>%3$s</option>',
                        (int) $relationshipPost->ID,
                        selected(in_array((int) $relationshipPost->ID, $selectedPostIds, true), true, false),
                        esc_html((string) $relationshipPost->post_title . ' (' . (string) $relationshipPost->post_type . ')')
                    );
                }
                echo '</select>';
                break;

            case 'taxonomy':
                $taxonomy = sanitize_key((string) ($field['taxonomy'] ?? 'category'));
                wp_dropdown_categories(
                    [
                        'taxonomy' => taxonomy_exists($taxonomy) ? $taxonomy : 'category',
                        'name' => 'h4bs_fields[' . $key . ']',
                        'id' => 'h4f_' . $key,
                        'class' => 'widefat',
                        'selected' => absint($rawValue),
                        'hide_empty' => false,
                        'show_option_none' => __('Select a term', 'help4-builder-suite'),
                    ]
                );
                break;

            case 'user':
                wp_dropdown_users(
                    [
                        'name' => 'h4bs_fields[' . $key . ']',
                        'id' => 'h4f_' . $key,
                        'class' => 'widefat',
                        'selected' => absint($rawValue),
                        'show_option_none' => __('Select a user', 'help4-builder-suite'),
                    ]
                );
                break;

            case 'computed':
                echo '<output id="h4f_' . esc_attr($key) . '" class="widefat code" style="display:block;padding:8px 10px;background:#f6f7f7;border:1px solid #dcdcde;">' . esc_html((string) $rawValue) . '</output>';
                echo '<br><small>' . esc_html__('Calculated on the server from the field formula.', 'help4-builder-suite') . '</small>';
                break;

            default:
                $htmlType = 'text';
                if ($type === 'date_picker') {
                    $htmlType = 'date';
                } elseif ($type === 'date_time_picker') {
                    $htmlType = 'datetime-local';
                } elseif ($type === 'time_picker') {
                    $htmlType = 'time';
                } elseif ($type === 'range') {
                    $htmlType = 'range';
                } elseif (in_array($type, ['number', 'email', 'url', 'link', 'oembed'], true)) {
                    $htmlType = $type === 'link' ? 'url' : $type;
                    if ($type === 'oembed') {
                        $htmlType = 'url';
                    }
                }
                $min = is_numeric($field['min'] ?? null) ? ' min="' . esc_attr((string) $field['min']) . '"' : '';
                $max = is_numeric($field['max'] ?? null) ? ' max="' . esc_attr((string) $field['max']) . '"' : '';
                $step = is_numeric($field['step'] ?? null) ? ' step="' . esc_attr((string) $field['step']) . '"' : '';
                // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are fixed booleans or numeric values escaped with esc_attr() above.
                printf(
                    '<input id="h4f_%1$s" class="widefat" type="%2$s" name="h4bs_fields[%1$s]" value="%3$s" placeholder="%4$s"%5$s%6$s%7$s%8$s>',
                    esc_attr($key),
                    esc_attr($htmlType),
                    esc_attr((string) $rawValue),
                    esc_attr($placeholder),
                    $inputFlags,
                    $min,
                    $max,
                    $step
                );
                // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
                break;
        }

        if ($instructions !== '') {
            echo '<br><small>' . esc_html($instructions) . '</small>';
        }

        echo '</p>';
    }

    private function renderConditionalLogicScript(): void
    {
        ?>
        <script>
            (function () {
                const root = document.querySelector('[data-h4bs-dynamic-fields]');
                if (!root) {
                    return;
                }

                const controls = Array.from(root.querySelectorAll('[data-h4bs-condition-field]'));
                if (!controls.length) {
                    return;
                }

                const readValue = function (fieldKey) {
                    const checkbox = root.querySelector('[name="h4bs_fields[' + fieldKey + ']"][type="checkbox"]');
                    if (checkbox) {
                        return checkbox.checked ? '1' : '0';
                    }

                    const input = root.querySelector('[name="h4bs_fields[' + fieldKey + ']"]');
                    if (!input) {
                        return '';
                    }

                    return (input.value || '').toString();
                };

                const matches = function (actual, operator, expected) {
                    if (operator === '!=') {
                        return actual !== expected;
                    }
                    if (operator === 'contains') {
                        return actual.indexOf(expected) !== -1;
                    }
                    if (operator === '!contains') {
                        return actual.indexOf(expected) === -1;
                    }
                    return actual === expected;
                };

                const evaluate = function () {
                    controls.forEach(function (control) {
                        const field = (control.getAttribute('data-h4bs-condition-field') || '').trim();
                        const operator = (control.getAttribute('data-h4bs-condition-operator') || '==').trim();
                        const expected = (control.getAttribute('data-h4bs-condition-value') || '').trim();
                        const actual = readValue(field);
                        control.style.display = matches(actual, operator, expected) ? '' : 'none';
                    });
                };

                root.addEventListener('input', evaluate);
                root.addEventListener('change', evaluate);
                evaluate();
            }());
        </script>
        <?php
    }

    private function renderMediaPickerScript(): void
    {
        ?>
        <script>
            (function () {
                if (typeof window.wp === 'undefined' || !window.wp.media) {
                    return;
                }

                const controls = Array.from(document.querySelectorAll('[data-h4bs-media-control]'));
                if (!controls.length) {
                    return;
                }

                const parseIds = function (raw) {
                    if (!raw) {
                        return [];
                    }
                    try {
                        const decoded = JSON.parse(raw);
                        if (Array.isArray(decoded)) {
                            return decoded.map(function (value) { return parseInt(value, 10); }).filter(function (value) { return value > 0; });
                        }
                    } catch (error) {
                        // Fall through to comma parser.
                    }
                    return String(raw)
                        .split(',')
                        .map(function (value) { return parseInt(value.trim(), 10); })
                        .filter(function (value) { return value > 0; });
                };

                const renderPreview = function (control, selection) {
                    const preview = control.querySelector('[data-h4bs-media-preview]');
                    if (!preview) {
                        return;
                    }
                    preview.innerHTML = '';
                    if (!selection.length) {
                        preview.textContent = '<?php echo esc_js(__('No media selected.', 'help4-builder-suite')); ?>';
                        return;
                    }

                    selection.forEach(function (item) {
                        const thumb = item.sizes && item.sizes.thumbnail ? item.sizes.thumbnail.url : item.url;
                        const mime = String(item.mime || '');
                        if (mime.indexOf('image/') === 0) {
                            const image = document.createElement('img');
                            image.src = thumb || item.url || '';
                            image.alt = item.alt || item.title || '';
                            image.style.maxWidth = '72px';
                            image.style.maxHeight = '72px';
                            image.style.marginRight = '6px';
                            image.style.marginBottom = '6px';
                            image.style.border = '1px solid #dbe7e6';
                            image.style.borderRadius = '4px';
                            preview.appendChild(image);
                        } else {
                            const link = document.createElement('a');
                            link.href = item.url || '#';
                            link.target = '_blank';
                            link.rel = 'noopener';
                            link.textContent = item.filename || item.title || item.url || 'file';
                            link.style.display = 'block';
                            preview.appendChild(link);
                        }
                    });
                };

                controls.forEach(function (control) {
                    const openButton = control.querySelector('[data-h4bs-media-open]');
                    const clearButton = control.querySelector('[data-h4bs-media-clear]');
                    const input = control.querySelector('[data-h4bs-media-input]');
                    if (!openButton || !clearButton || !input) {
                        return;
                    }

                    const mediaType = String(control.getAttribute('data-h4bs-media-type') || 'image');
                    const isGallery = mediaType === 'gallery';
                    const libraryType = mediaType === 'image' || mediaType === 'gallery' ? { type: 'image' } : {};

                    let mediaFrame = null;
                    openButton.addEventListener('click', function (event) {
                        event.preventDefault();
                        if (mediaFrame) {
                            mediaFrame.open();
                            return;
                        }

                        mediaFrame = window.wp.media({
                            title: isGallery
                                ? '<?php echo esc_js(__('Select Gallery Images', 'help4-builder-suite')); ?>'
                                : '<?php echo esc_js(__('Select Media', 'help4-builder-suite')); ?>',
                            library: libraryType,
                            multiple: isGallery
                        });

                        mediaFrame.on('open', function () {
                            const selection = mediaFrame.state().get('selection');
                            selection.reset();
                            const ids = parseIds(input.value);
                            if (!ids.length) {
                                return;
                            }
                            ids.forEach(function (id) {
                                const attachment = window.wp.media.attachment(id);
                                if (!attachment) {
                                    return;
                                }
                                attachment.fetch();
                                selection.add(attachment);
                            });
                        });

                        mediaFrame.on('select', function () {
                            const selection = mediaFrame.state().get('selection').toJSON();
                            if (!selection.length) {
                                return;
                            }
                            if (isGallery) {
                                const ids = selection.map(function (item) { return parseInt(item.id, 10); }).filter(function (id) { return id > 0; });
                                input.value = JSON.stringify(ids);
                                renderPreview(control, selection);
                                return;
                            }

                            const first = selection[0];
                            const firstId = parseInt(first.id, 10);
                            input.value = firstId > 0 ? String(firstId) : String(first.url || '');
                            renderPreview(control, [first]);
                        });

                        mediaFrame.open();
                    });

                    clearButton.addEventListener('click', function (event) {
                        event.preventDefault();
                        input.value = isGallery ? '[]' : '';
                        renderPreview(control, []);
                    });
                });
            }());
        </script>
        <?php
    }

    private function renderFieldEditorScript(): void
    {
        ?>
        <script>
            (function () {
                document.querySelectorAll('.h4bs-field-control input[type="color"]').forEach(function (swatch) {
                    const wrapper = swatch.parentElement;
                    const hex = wrapper ? wrapper.querySelector('[data-h4bs-color-hex]') : null;
                    if (!hex) {
                        return;
                    }
                    swatch.addEventListener('input', function () {
                        hex.value = swatch.value;
                    });
                    hex.addEventListener('input', function () {
                        const value = String(hex.value || '').trim();
                        if (/^#[0-9a-f]{6}$/i.test(value)) {
                            swatch.value = value;
                            swatch.dispatchEvent(new Event('change', { bubbles: true }));
                        }
                    });
                });
            }());
        </script>
        <?php
    }

    /**
     * @param array<string, mixed> $field
     * @return array<string, mixed>|null
     */
    private function sanitizeFieldDefinition(array $field): ?array
    {
        $key = sanitize_key((string) ($field['key'] ?? ''));
        if ($key === '') {
            return null;
        }

        $type = sanitize_key((string) ($field['type'] ?? 'text'));
        if (! in_array($type, $this->allowedFieldTypes(), true)) {
            $type = 'text';
        }

        $clean = [
            'key'           => $key,
            'label'         => sanitize_text_field((string) ($field['label'] ?? $key)),
            'type'          => $type,
            'placeholder'   => sanitize_text_field((string) ($field['placeholder'] ?? '')),
            'instructions'  => sanitize_text_field((string) ($field['instructions'] ?? '')),
            'required'      => ! empty($field['required']) ? 1 : 0,
            'default_value' => $this->sanitizeFieldValueByType($type, $field['default_value'] ?? ''),
            'options'       => is_array($field['options'] ?? null) ? array_values(array_map('sanitize_text_field', (array) $field['options'])) : [],
            'return_format' => $this->enumValue((string) ($field['return_format'] ?? 'value'), ['value', 'id', 'url', 'object', 'array', 'label'], 'value'),
            'multiple'      => ! empty($field['multiple']) ? 1 : 0,
            'readonly'      => ! empty($field['readonly']) ? 1 : 0,
            'public'        => array_key_exists('public', $field) && empty($field['public']) ? 0 : 1,
            'show_in_rest'  => array_key_exists('show_in_rest', $field) && empty($field['show_in_rest']) ? 0 : 1,
            'unique'        => ! empty($field['unique']) ? 1 : 0,
            'capability'    => sanitize_key((string) ($field['capability'] ?? 'edit_posts')) ?: 'edit_posts',
            'taxonomy'      => sanitize_key((string) ($field['taxonomy'] ?? '')),
            'post_types'    => array_values(array_filter(array_map('sanitize_key', (array) ($field['post_types'] ?? [])))),
            'min'           => is_numeric($field['min'] ?? null) ? (float) $field['min'] : null,
            'max'           => is_numeric($field['max'] ?? null) ? (float) $field['max'] : null,
            'step'          => is_numeric($field['step'] ?? null) ? max(0.000001, (float) $field['step']) : null,
            'min_length'    => max(0, min(100000, (int) ($field['min_length'] ?? 0))),
            'max_length'    => max(0, min(100000, (int) ($field['max_length'] ?? 0))),
            'max_rows'      => max(0, min(1000, (int) ($field['max_rows'] ?? 0))),
            'pattern'       => substr(sanitize_text_field((string) ($field['pattern'] ?? '')), 0, 200),
        ];

        if ($type === 'computed') {
            $formula = substr(sanitize_text_field((string) ($field['formula'] ?? '')), 0, 500);
            if ($formula !== '' && CustomerFieldFormula::validate($formula) === true) {
                $clean['formula'] = $formula;
                $clean['readonly'] = 1;
            }
        }

        if (is_array($field['conditional'] ?? null)) {
            $conditional = (array) $field['conditional'];
            $conditionField = sanitize_key((string) ($conditional['field'] ?? ''));
            if ($conditionField !== '') {
                $operator = sanitize_text_field((string) ($conditional['operator'] ?? '=='));
                if (! in_array($operator, ['==', '!=', 'contains', '!contains'], true)) {
                    $operator = '==';
                }
                $clean['conditional'] = [
                    'field'    => $conditionField,
                    'operator' => $operator,
                    'value'    => sanitize_text_field((string) ($conditional['value'] ?? '')),
                ];
            }
        }

        if (in_array($type, ['repeater', 'group'], true) && is_array($field['sub_fields'] ?? null)) {
            $subFields = [];
            foreach ((array) $field['sub_fields'] as $subField) {
                if (! is_array($subField)) {
                    continue;
                }
                $cleanSubField = $this->sanitizeFieldDefinition($subField);
                if ($cleanSubField !== null) {
                    $subFields[] = $cleanSubField;
                }
            }
            $clean['sub_fields'] = $subFields;
        }

        if (is_array($field['extensions'] ?? null)) {
            $clean['extensions'] = $this->sanitizeRecursive((array) $field['extensions']);
        }

        $filtered = apply_filters('h4bs_field_definition_sanitized', $clean, $field, self::FIELD_SCHEMA_VERSION);
        return is_array($filtered) ? $filtered : $clean;
    }

    private function sanitizeFieldValueByType(string $type, mixed $value): mixed
    {
        if ($type === 'true_false') {
            return $value ? '1' : '0';
        }

        if (in_array($type, ['repeater', 'group', 'gallery', 'relationship', 'post_object', 'map', 'json'], true)) {
            if (is_string($value)) {
                $decoded = json_decode($value, true);
                if (is_array($decoded)) {
                    return wp_json_encode($this->sanitizeRecursive($decoded));
                }
                return sanitize_textarea_field($value);
            }
            if (is_array($value)) {
                return wp_json_encode($this->sanitizeRecursive($value));
            }
        }

        if ($type === 'image' || $type === 'file') {
            if (is_numeric($value)) {
                return (string) absint((int) $value);
            }
            return esc_url_raw((string) $value);
        }

        if (in_array($type, ['checkbox', 'multi_select'], true)) {
            if (is_array($value)) {
                $options = array_values(
                    array_filter(
                        array_map(
                            static fn ($item): string => sanitize_text_field((string) $item),
                            $value
                        ),
                        static fn (string $item): bool => $item !== ''
                    )
                );
                return wp_json_encode($options);
            }
            if (is_string($value)) {
                $parts = array_values(
                    array_filter(
                        array_map('trim', explode(',', $value)),
                        static fn (string $item): bool => $item !== ''
                    )
                );
                if ($parts) {
                    return wp_json_encode(array_map('sanitize_text_field', $parts));
                }
            }
            return '';
        }

        if (in_array($type, ['textarea', 'wysiwyg', 'code'], true)) {
            return wp_kses_post((string) $value);
        }

        if ($type === 'url' || $type === 'link') {
            return esc_url_raw((string) $value);
        }

        if ($type === 'email') {
            return sanitize_email((string) $value);
        }

        if ($type === 'number' || $type === 'range') {
            return (string) (is_numeric($value) ? $value : 0);
        }

        if ($type === 'color_picker') {
            $color = sanitize_hex_color((string) $value);
            return is_string($color) ? $color : '';
        }

        return sanitize_text_field((string) $value);
    }

    /**
     * @param array<int, string> $allowed
     */
    private function enumValue(string $value, array $allowed, string $fallback): string
    {
        $value = sanitize_key($value);
        return in_array($value, $allowed, true) ? $value : $fallback;
    }

    /**
     * @return array<int, string>
     */
    private function normalizeOptionSelection(mixed $rawValue): array
    {
        if (is_array($rawValue)) {
            return array_values(
                array_filter(
                    array_map(
                        static fn ($value): string => sanitize_text_field((string) $value),
                        $rawValue
                    ),
                    static fn (string $value): bool => $value !== ''
                )
            );
        }

        if (! is_string($rawValue) || trim($rawValue) === '') {
            return [];
        }

        $decoded = json_decode($rawValue, true);
        if (is_array($decoded)) {
            return array_values(
                array_filter(
                    array_map(
                        static fn ($value): string => sanitize_text_field((string) $value),
                        $decoded
                    ),
                    static fn (string $value): bool => $value !== ''
                )
            );
        }

        $parts = array_map('trim', explode(',', $rawValue));
        return array_values(
            array_filter(
                array_map('sanitize_text_field', $parts),
                static fn (string $value): bool => $value !== ''
            )
        );
    }

    private function renderMediaPickerControl(string $key, string $type, mixed $rawValue, string $placeholder): void
    {
        $value = is_scalar($rawValue) ? (string) $rawValue : '';
        $isGallery = $type === 'gallery';
        if ($isGallery && trim($value) === '') {
            $value = '[]';
        }

        echo '<div class="h4bs-media-control" data-h4bs-media-control data-h4bs-media-type="' . esc_attr($type) . '">';
        printf(
            '<input id="h4f_%1$s" type="hidden" name="h4bs_fields[%1$s]" value="%2$s" data-h4bs-media-input>',
            esc_attr($key),
            esc_attr($value)
        );
        echo '<div data-h4bs-media-preview style="margin:8px 0;min-height:32px;border:1px dashed #ccd4d9;padding:8px;border-radius:6px;background:#fff;">';
        $this->renderMediaPreviewMarkup($type, $value, $placeholder);
        echo '</div>';
        echo '<p style="margin:0;">';
        echo '<button type="button" class="button button-secondary" data-h4bs-media-open>' . esc_html__('Choose Media', 'help4-builder-suite') . '</button> ';
        echo '<button type="button" class="button" data-h4bs-media-clear>' . esc_html__('Remove', 'help4-builder-suite') . '</button>';
        echo '</p>';
        echo '</div>';
    }

    private function renderMediaPreviewMarkup(string $type, string $value, string $placeholder): void
    {
        if ($type === 'gallery') {
            $ids = [];
            $decoded = json_decode($value, true);
            if (is_array($decoded)) {
                $ids = array_values(
                    array_filter(
                        array_map('absint', $decoded),
                        static fn (int $id): bool => $id > 0
                    )
                );
            }

            if (! $ids) {
                echo esc_html($placeholder !== '' ? $placeholder : __('No gallery items selected.', 'help4-builder-suite'));
                return;
            }

            foreach ($ids as $id) {
                $thumb = wp_get_attachment_image($id, 'thumbnail', false, ['style' => 'max-width:72px;max-height:72px;margin-right:6px;margin-bottom:6px;border:1px solid #dbe7e6;border-radius:4px;']);
                if (is_string($thumb) && $thumb !== '') {
                    echo $thumb; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                }
            }
            return;
        }

        if ($value === '') {
            echo esc_html($placeholder !== '' ? $placeholder : __('No media selected.', 'help4-builder-suite'));
            return;
        }

        if (is_numeric($value)) {
            $attachmentId = absint((int) $value);
            if ($attachmentId > 0) {
                if ($type === 'image') {
                    $thumb = wp_get_attachment_image($attachmentId, 'thumbnail', false, ['style' => 'max-width:96px;max-height:96px;border:1px solid #dbe7e6;border-radius:4px;']);
                    if (is_string($thumb) && $thumb !== '') {
                        echo $thumb; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                        return;
                    }
                }
                $url = wp_get_attachment_url($attachmentId);
                if (is_string($url) && $url !== '') {
                    echo '<a href="' . esc_url($url) . '" target="_blank" rel="noopener">' . esc_html(basename((string) wp_parse_url($url, PHP_URL_PATH))) . '</a>';
                    return;
                }
            }
        }

        if (filter_var($value, FILTER_VALIDATE_URL)) {
            if ($type === 'image') {
                echo '<img src="' . esc_url($value) . '" alt="" loading="lazy" style="max-width:96px;max-height:96px;border:1px solid #dbe7e6;border-radius:4px;">';
            } else {
                echo '<a href="' . esc_url($value) . '" target="_blank" rel="noopener">' . esc_html(basename((string) wp_parse_url($value, PHP_URL_PATH))) . '</a>';
            }
            return;
        }

        echo esc_html($placeholder !== '' ? $placeholder : __('No media selected.', 'help4-builder-suite'));
    }

    /**
     * @param array<int|string, mixed> $value
     * @return array<int|string, mixed>
     */
    private function sanitizeRecursive(array $value): array
    {
        $clean = [];
        foreach ($value as $key => $item) {
            $cleanKey = is_int($key) ? $key : sanitize_key((string) $key);
            if (is_array($item)) {
                $clean[$cleanKey] = $this->sanitizeRecursive($item);
                continue;
            }
            $clean[$cleanKey] = sanitize_text_field((string) $item);
        }
        return $clean;
    }

    /**
     * @return array<int, string>
     */
    private function allowedFieldTypes(): array
    {
        return [
            'text',
            'textarea',
            'number',
            'range',
            'email',
            'url',
            'link',
            'select',
            'multi_select',
            'radio',
            'button_group',
            'checkbox',
            'true_false',
            'image',
            'file',
            'repeater',
            'group',
            'gallery',
            'date_picker',
            'date_time_picker',
            'time_picker',
            'color_picker',
            'wysiwyg',
            'relationship',
            'post_object',
            'taxonomy',
            'user',
            'oembed',
            'map',
            'icon',
            'json',
            'code',
            'computed',
        ];
    }

    /**
     * @param array<string, mixed> $atts
     */
    public function renderFieldShortcode(array $atts): string
    {
        $atts = shortcode_atts(
            [
                'source'   => 'field',
                'key'      => '',
                'post_id'  => 0,
                'object_id'=> 0,
                'product_id' => 0,
                'variant_id' => 0,
                'term_id' => 0,
                'user_id' => 0,
                'taxonomy' => '',
                'sku' => '',
                'fallback' => '',
                'format'   => 'text',
                'path'     => '',
                'transform'=> '',
                'currency' => 'USD',
                'separator'=> ', ',
                'before'   => '',
                'after'    => '',
                'image_size' => 'large',
            ],
            $atts,
            'h4_field'
        );

        $source = sanitize_key((string) $atts['source']);
        $key = sanitize_key((string) $atts['key']);
        if ($key === '' && ! in_array($source, ['post', 'product', 'variant', 'term', 'user', 'site'], true)) {
            return '';
        }

        $postId = absint($atts['post_id']);
        if ($postId <= 0 && $source === 'field') {
            $postId = absint($atts['object_id']);
        }
        if ($postId <= 0) {
            $postId = get_the_ID();
        }
        if ($source === 'field' && $postId > 0) {
            $definitions = $this->fieldDefinitionsForPost($postId);
            if (isset($definitions[$key]) && array_key_exists('public', $definitions[$key]) && empty($definitions[$key]['public'])) {
                return esc_html((string) $atts['fallback']);
            }
        }

        $query = [
            'source' => $source,
            'key' => $key,
            'post_id' => $postId,
            'object_id' => absint($atts['object_id']),
            'product_id' => absint($atts['product_id']),
            'variant_id' => absint($atts['variant_id']),
            'term_id' => absint($atts['term_id']),
            'user_id' => absint($atts['user_id']),
            'taxonomy' => sanitize_key((string) $atts['taxonomy']),
            'sku' => sanitize_text_field((string) $atts['sku']),
            'path' => sanitize_text_field((string) $atts['path']),
            'transform' => sanitize_text_field((string) $atts['transform']),
            'currency' => sanitize_text_field((string) $atts['currency']),
            'fallback' => (string) $atts['fallback'],
        ];
        if ($query['object_id'] <= 0 && $postId > 0 && in_array($source, ['field', 'post', 'post_meta'], true)) {
            $query['object_id'] = $postId;
        }
        $value = $this->resolver->resolve($query);
        if ($value === '' || $value === null || $value === []) {
            return esc_html((string) $atts['fallback']);
        }

        $format = sanitize_key((string) $atts['format']);
        $separator = (string) $atts['separator'];
        $before = sanitize_text_field((string) $atts['before']);
        $after = sanitize_text_field((string) $atts['after']);
        $imageSize = sanitize_key((string) $atts['image_size']);
        if ($imageSize === '') {
            $imageSize = 'large';
        }

        $output = '';
        if ($format === 'raw') {
            $output = is_scalar($value) ? (string) $value : (string) wp_json_encode($value);
            return $output === '' ? esc_html((string) $atts['fallback']) : wp_kses_post($output);
        }

        if ($format === 'json') {
            $output = esc_html((string) wp_json_encode($value));
            return $output !== '' ? $output : esc_html((string) $atts['fallback']);
        }

        if ($format === 'html') {
            if (is_array($value)) {
                $output = wp_kses_post((string) wp_json_encode($value));
            } else {
                $output = wp_kses_post((string) $value);
            }
        } elseif ($format === 'image') {
            if (is_numeric($value)) {
                $output = (string) wp_get_attachment_image((int) $value, $imageSize, false, ['loading' => 'lazy']);
            } elseif (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
                $output = '<img src="' . esc_url($value) . '" alt="" loading="lazy">';
            } elseif (is_array($value)) {
                $ids = $this->extractAttachmentIdsFromValue($value);
                if (! empty($ids[0])) {
                    $output = (string) wp_get_attachment_image((int) $ids[0], $imageSize, false, ['loading' => 'lazy']);
                } else {
                    $urls = $this->extractUrlsFromValue($value);
                    if (! empty($urls[0])) {
                        $output = '<img src="' . esc_url($urls[0]) . '" alt="" loading="lazy">';
                    }
                }
            }
        } elseif ($format === 'gallery') {
            $galleryItems = [];
            if (is_numeric($value)) {
                $galleryItems[] = (string) wp_get_attachment_image((int) $value, $imageSize, false, ['loading' => 'lazy']);
            } elseif (is_array($value)) {
                $ids = $this->extractAttachmentIdsFromValue($value);
                if ($ids) {
                    foreach ($ids as $id) {
                        $item = (string) wp_get_attachment_image($id, $imageSize, false, ['loading' => 'lazy']);
                        if ($item !== '') {
                            $galleryItems[] = $item;
                        }
                    }
                } else {
                    $urls = $this->extractUrlsFromValue($value);
                    foreach ($urls as $url) {
                        $galleryItems[] = '<img src="' . esc_url($url) . '" alt="" loading="lazy">';
                    }
                }
            } elseif (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
                $galleryItems[] = '<img src="' . esc_url($value) . '" alt="" loading="lazy">';
            }
            if ($galleryItems) {
                $output = '<div class="h4bs-field-gallery">' . implode('', $galleryItems) . '</div>';
            }
        } elseif ($format === 'file') {
            if (is_numeric($value)) {
                $fileUrl = (string) wp_get_attachment_url((int) $value);
                if ($fileUrl !== '') {
                    $label = basename((string) wp_parse_url($fileUrl, PHP_URL_PATH));
                    $output = '<a href="' . esc_url($fileUrl) . '" target="_blank" rel="noopener">' . esc_html($label) . '</a>';
                }
            } elseif (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
                $label = basename((string) wp_parse_url($value, PHP_URL_PATH));
                $output = '<a href="' . esc_url($value) . '" target="_blank" rel="noopener">' . esc_html($label) . '</a>';
            }
        } elseif ($format === 'link') {
            if (is_array($value)) {
                $url = esc_url((string) ($value['url'] ?? ''));
                $label = sanitize_text_field((string) ($value['label'] ?? ($value['title'] ?? $url)));
                $target = strtolower((string) ($value['target'] ?? ''));
                $targetAttr = $target === '_blank' ? ' target="_blank" rel="noopener"' : '';
                if ($url !== '') {
                    $output = '<a href="' . esc_url($url) . '"' . $targetAttr . '>' . esc_html($label !== '' ? $label : $url) . '</a>';
                }
            } else {
                $url = esc_url((string) $value);
                if ($url !== '') {
                    $output = '<a href="' . esc_url($url) . '">' . esc_html($url) . '</a>';
                }
            }
        } elseif ($format === 'list') {
            if (is_array($value)) {
                $flat = array_map('strval', $this->flattenArrayValues($value));
                $output = esc_html(implode($separator, $flat));
            } else {
                $output = esc_html((string) $value);
            }
        } elseif ($format === 'url') {
            $output = esc_url((string) $value);
        } else {
            if (is_array($value)) {
                $output = esc_html((string) wp_json_encode($value));
            } else {
                $output = esc_html((string) $value);
            }
        }

        if ($output === '') {
            return esc_html((string) $atts['fallback']);
        }

        if ($before !== '') {
            $output = esc_html($before) . $output;
        }
        if ($after !== '') {
            $output .= esc_html($after);
        }

        return $output;
    }

    /**
     * @param array<string, mixed> $atts
     */
    public function renderDynamicShortcode(array $atts): string
    {
        $key = sanitize_key((string) ($atts['key'] ?? ''));
        if ($key === '') {
            if (current_user_can('edit_posts')) {
                return '<span class="h4bs-dynamic-data-placeholder">' . esc_html__('Choose a dynamic data source and property.', 'help4-builder-suite') . '</span>';
            }
            return '<!-- Help4 Dynamic Data: choose a source and property. -->';
        }
        return $this->renderFieldShortcode($atts);
    }

    /**
     * @param array<string, mixed> $query
     */
    public function filterPrivateDynamicField(mixed $value, string $source, array $query): mixed
    {
        $key = sanitize_key((string) ($query['key'] ?? ''));
        if ($key === '') {
            return $value;
        }
        $definitions = [];
        if ($source === 'field') {
            $postId = absint($query['post_id'] ?? ($query['object_id'] ?? 0));
            $definitions = $this->fieldDefinitionsForPost($postId);
        } elseif ($source === 'product_field') {
            $definitions = $this->fieldDefinitionsForType('product');
        }
        if (isset($definitions[$key]) && array_key_exists('public', $definitions[$key]) && empty($definitions[$key]['public'])) {
            return null;
        }
        return $value;
    }

    private function decodePossibleJson(mixed $value): mixed
    {
        if (! is_string($value)) {
            return $value;
        }

        $trimmed = trim($value);
        if ($trimmed === '') {
            return '';
        }

        if (! in_array($trimmed[0], ['{', '['], true)) {
            return $value;
        }

        $decoded = json_decode($trimmed, true);
        return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
    }

    private function valueAtPath(mixed $value, string $path): mixed
    {
        $segments = array_filter(array_map('trim', explode('.', $path)));
        if (! $segments) {
            return $value;
        }

        $current = $value;
        foreach ($segments as $segment) {
            if (! is_array($current)) {
                return null;
            }

            if (array_key_exists($segment, $current)) {
                $current = $current[$segment];
                continue;
            }

            if (ctype_digit($segment) && array_key_exists((int) $segment, $current)) {
                $current = $current[(int) $segment];
                continue;
            }

            return null;
        }

        return $current;
    }

    /**
     * @param array<int|string, mixed> $value
     * @return array<int, string>
     */
    private function extractUrlsFromValue(array $value): array
    {
        $urls = [];
        foreach ($this->flattenArrayValues($value) as $item) {
            $text = (string) $item;
            if (filter_var($text, FILTER_VALIDATE_URL)) {
                $urls[] = esc_url_raw($text);
            }
        }
        return array_values(array_unique(array_filter($urls)));
    }

    /**
     * @param array<int|string, mixed> $value
     * @return array<int, int>
     */
    private function extractAttachmentIdsFromValue(array $value): array
    {
        $ids = [];
        foreach ($this->flattenArrayValues($value) as $item) {
            if (! is_scalar($item) || ! is_numeric($item)) {
                continue;
            }
            $id = absint((int) $item);
            if ($id > 0) {
                $ids[] = $id;
            }
        }
        return array_values(array_unique($ids));
    }

    /**
     * @param array<int|string, mixed> $value
     * @return array<int, mixed>
     */
    private function flattenArrayValues(array $value): array
    {
        $flattened = [];
        foreach ($value as $item) {
            if (is_array($item)) {
                $flattened = array_merge($flattened, $this->flattenArrayValues($item));
                continue;
            }
            $flattened[] = $item;
        }
        return $flattened;
    }

    public function setupAdminMenuHighlight(WP_Screen $screen): void
    {
        $page = AdminRequest::queryKey('page');
        if ($page === 'h4bs-fields-lab') {
            add_filter(
                'parent_file',
                static function ($parentFile): string {
                    return 'h4bs-settings';
                }
            );

            add_filter(
                'submenu_file',
                static function ($submenuFile): string {
                    return 'h4bs-fields-lab';
                }
            );
            return;
        }

        if ($screen->post_type !== 'h4_field_group') {
            return;
        }

        add_filter(
            'parent_file',
            static function ($parentFile): string {
                return 'h4bs-settings';
            }
        );

        add_filter(
            'submenu_file',
            static function ($submenuFile): string {
                return 'edit.php?post_type=h4_field_group';
            }
        );
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function groupsForType(string $postType): array
    {
        $groups = get_posts(
            [
                'post_type'      => 'h4_field_group',
                'post_status'    => 'publish',
                'posts_per_page' => -1,
                'orderby'        => 'menu_order title',
                'order'          => 'ASC',
            ]
        );

        $matched = [];
        foreach ($groups as $groupPost) {
            if (! $groupPost instanceof WP_Post) {
                continue;
            }

            $location = (string) get_post_meta($groupPost->ID, '_h4_group_location', true);
            $types = array_filter(array_map('trim', explode(',', strtolower($location))));
            if (! $types) {
                $types = ['post', 'page'];
            }

            if (! in_array('all', $types, true) && ! in_array($postType, $types, true)) {
                continue;
            }

            $fields = json_decode((string) get_post_meta($groupPost->ID, '_h4_group_fields', true), true);
            if (! is_array($fields) || ! $fields) {
                continue;
            }

            $matched[] = [
                'id'     => $groupPost->ID,
                'title'  => $groupPost->post_title,
                'fields' => $fields,
            ];
        }

        return $matched;
    }
}
