<?php
// components/Form/Engine.php
// Reusable form engine: loads blueprint.yaml, exposes fields, and validates values

namespace Components\Form;

use Symfony\Component\Yaml\Yaml;
use Throwable;
use Components\Permission;
use Components\Common\FieldVisibility;

class Engine
{
    protected array $schema = [];
    protected array $fields = [];
    protected array $groups = [];

    /** @var array<string, callable> */
    protected array $validators;

    /** @var array<string, callable> */
    protected array $ruleHandlers = [];

    /**
     * Construct the Engine from either a blueprint file path or an already parsed schema array.
     * @param string|array $blueprintOrSchema
     * @param array|null $currentValues
     */
    public function __construct($blueprintOrSchema, ?array $currentValues = null)
    {
        if (is_array($blueprintOrSchema)) {
            $this->schema = $blueprintOrSchema;
        } else {
            $this->schema = $this->loadSchema((string)$blueprintOrSchema);
        }
        // Enhance schema with dynamic field sources before extracting fields/groups
        $this->schema = $this->applyDynamicFieldSources($this->schema, $currentValues);

        $form = $this->schema['form'] ?? [];
        $this->fields = $form['fields'] ?? [];
        $this->groups = is_array($form['groups'] ?? null) ? $form['groups'] : [];

        $this->validators = \Components\Form\Validation::defaultValidators();
    }

    /**
     * Convenience factory to build an Engine from a parsed schema array.
     */
    public static function fromSchema(array $schema, ?array $currentValues = null): self
    {
        return new self($schema, $currentValues);
    }

    /**
     * Build an Engine from multiple blueprint YAML files by merging their fields and groups.
     * The first blueprint provides the base; subsequent ones contribute additional fields and groups.
     * On field name conflicts, the first occurrence wins to avoid accidental overrides.
     */
    public static function fromBlueprints(array $paths, ?array $currentValues = null): self
    {
        // Normalize paths
        $norm = [];
        foreach ($paths as $p) { $p = (string)$p; if ($p !== '' && is_file($p)) { $norm[] = $p; } }
        if (empty($norm)) {
            $fallback = (defined('BEEZUI_ROOT') ? BEEZUI_ROOT : dirname(__DIR__, 2)) . '/blueprint.yaml';
            $norm = [ $fallback ];
        }
        // Start with the first engine
        $base = new self($norm[0], $currentValues);
        $mergedFields = $base->fields;
        $mergedGroups = $base->groups; // raw groups as defined in YAML
        $mergedSchema = $base->schema;

        // Merge subsequent schemas
        for ($i = 1; $i < count($norm); $i++) {
            $tmp = new self($norm[$i], $currentValues);
            // Merge fields: keep existing keys (first blueprint wins)
            foreach ($tmp->fields as $name => $def) {
                if (!array_key_exists($name, $mergedFields)) {
                    $mergedFields[$name] = $def;
                }
            }
            // Merge groups: append raw groups; duplicates by id are allowed and rendered in order
            if (is_array($tmp->groups)) {
                foreach ($tmp->groups as $g) {
                    if (is_array($g)) { $mergedGroups[] = $g; }
                }
            }
        }

        // Apply merged values to base instance
        $mergedSchema['form']['fields'] = $mergedFields;
        $mergedSchema['form']['groups'] = $mergedGroups;
        $base->schema = $mergedSchema;
        $base->fields = $mergedFields;
        $base->groups = $mergedGroups;
        return $base;
    }

    /**
     * Determine if a field should be considered visible based on visible_if rules and current submitted values
     */
    protected function isVisible(array $def, array $submitted): bool
    {
        return FieldVisibility::isVisible($def, $submitted, $this->fields);
    }

    /**
     * Public helper to evaluate visibility in display layers
     */
    public function isFieldVisible(array $def, array $values): bool
    {
        return $this->isVisible($def, $values);
    }

    /**
     * Public helper to evaluate permission for a field in display/validation layers
     */
    public function isFieldPermitted(array $def): bool
    {
        return Permission::hasAccess($def);
    }

    /**
     * Load YAML schema safely
     */
    protected function loadSchema(string $blueprintPath): array
    {
        try {
            if (is_file($blueprintPath)) {
                return Yaml::parseFile($blueprintPath) ?? [];
            }
        } catch (Throwable $e) {
            // ignore
        }
        return [];
    }

    /**
     * Enhance blueprint by resolving any dynamic field sources into concrete options.
     * - Looks for fields with type `dynamic_select` and/or a `source` entry.
     * - The `source` can be:
     *   - Fully-qualified "Class::method" string (preferred)
     *   - [ClassName, methodName] callable array
     *   - Global function name string
     * - The resolver will call the method and normalize return values into
     *   an options array suitable for Select::render (key => label).
     */
    protected function applyDynamicFieldSources(array $schema, ?array $currentValues = null): array
    {
        $form = $schema['form'] ?? null;
        if (!is_array($form)) { return $schema; }
        $fields = $form['fields'] ?? null;
        if (!is_array($fields)) { return $schema; }

        foreach ($fields as $name => $def) {
            if (!is_array($def)) { continue; }
            $type = strtolower((string)($def['type'] ?? ''));
            $hasSource = array_key_exists('source', $def);
            if ($type !== 'dynamic_select' && !$hasSource) { continue; }

            $source = $def['source'] ?? null;
            $current = is_array($currentValues) ? ($currentValues[$name] ?? null) : null;
            $result = $this->callDynamicSource($source, $current);

            // Special handling for RadioCards: allow grouped options to pass through without flattening
            if ($type === 'radiocards' && is_array($result)) {
                $groups = null;
                if (isset($result['groups']) && is_array($result['groups'])) {
                    $groups = $result['groups'];
                } else {
                    // Heuristic: array of items each having an 'items' array
                    $isGroups = true;
                    foreach ($result as $g) {
                        if (!is_array($g) || !isset($g['items']) || !is_array($g['items'])) { $isGroups = false; break; }
                    }
                    if ($isGroups) { $groups = $result; }
                }

                if (is_array($groups)) {
                    $def['grouped_options'] = $groups;
                    $fields[$name] = $def;
                    continue; // skip default normalization
                }
            }

            // For rich_select: preserve the raw associative options (including dot_class etc.)
            // instead of flattening through normalizeOptionsFromSource.
            if ($type === 'rich_select') {
                if (is_array($result) && !empty($result)) {
                    $def['options'] = $result;
                }
                $fields[$name] = $def;
                continue;
            }

            // Default: normalize to flat options
            $options = $this->normalizeOptionsFromSource($result);
            if (!empty($options) && is_array($options)) {
                $def['options'] = $options;
            }
            // Normalize to standard select renderer to avoid special-casing elsewhere
            if ($type === 'dynamic_select') {
                $def['type'] = 'select';
            }
            $fields[$name] = $def;
        }

        $schema['form']['fields'] = $fields;
        return $schema;
    }

    /**
     * Call a blueprint-declared dynamic source and return raw results.
     * @param mixed $source
     * @return mixed
     */
    protected function callDynamicSource($source, $currentValue = null)
    {
        try {
            if (is_array($source) && is_callable($source)) {
                // Try reflection to see if it accepts at least one parameter
                try {
                    $callableRef = is_array($source) ? new \ReflectionMethod($source[0], $source[1]) : new \ReflectionFunction($source);
                    if ($callableRef->getNumberOfParameters() >= 1) {
                        return call_user_func($source, $currentValue);
                    }
                } catch (\Throwable $e) { /* ignore and fall back */ }
                return call_user_func($source);
            }
            if (is_string($source) && $source !== '') {
                // Class::method
                if (strpos($source, '::') !== false) {
                    [$class, $method] = explode('::', $source, 2);
                    $class = trim($class);
                    $method = trim($method);
                    if ($class !== '' && $method !== '' && class_exists($class) && method_exists($class, $method)) {
                        try {
                            $ref = new \ReflectionMethod($class, $method);
                            if ($ref->isStatic()) {
                                if ($ref->getNumberOfParameters() >= 1) {
                                    return $ref->invoke(null, $currentValue);
                                }
                                return $ref->invoke(null);
                            }
                            // Try to instantiate without args
                            $obj = @new $class();
                            if ($ref->getNumberOfParameters() >= 1) {
                                return $ref->invoke($obj, $currentValue);
                            }
                            return $ref->invoke($obj);
                        } catch (\Throwable $e) {
                            // Fallback to call_user_func if reflection fails
                            return @call_user_func([$class, $method], $currentValue);
                        }
                    }
                }
                // Global function
                if (function_exists($source)) {
                    try {
                        $rf = new \ReflectionFunction($source);
                        if ($rf->getNumberOfParameters() >= 1) {
                            return call_user_func($source, $currentValue);
                        }
                    } catch (\Throwable $e) { /* ignore */ }
                    return call_user_func($source);
                }
            }
        } catch (\Throwable $e) {
            // ignore and return null
        }
        return null;
    }

    /**
     * Normalize various return shapes into key=>label options array.
     * Accepted shapes:
     * - ['K' => 'Label', ...]
     * - [['id'|'value'|'key' => 'K', 'text'|'label'|'name' => 'Label'], ...]
     * - ['K','V', ...] -> becomes ['K' => 'K', 'V' => 'V']
     */
    protected function normalizeOptionsFromSource($result): array
    {
        $out = [];
        if (!is_array($result)) {
            return $out;
        }

        // If associative and values are strings, assume already key=>label
        $isAssoc = array_keys($result) !== range(0, count($result) - 1);
        if ($isAssoc) {
            $allStrings = true;
            foreach ($result as $v) { if (!is_string($v)) { $allStrings = false; break; } }
            if ($allStrings) {
                foreach ($result as $k => $v) { $out[(string)$k] = (string)$v; }
                return $out;
            }
        }

        // Otherwise treat as list
        foreach ($result as $item) {
            if (is_array($item)) {
                $val = $item['id'] ?? $item['value'] ?? $item['key'] ?? null;
                $lbl = $item['text'] ?? $item['label'] ?? $item['name'] ?? null;
                if ($val === null && $lbl === null) {
                    continue;
                }
                $val = (string)($val ?? $lbl ?? '');
                $lbl = (string)($lbl ?? $val);
                if ($val !== '') {
                    $out[$val] = $lbl;
                }
            } else {
                // Scalar: use same for value and label
                $s = (string)$item;
                if ($s !== '') {
                    $out[$s] = $s;
                }
            }
        }
        return $out;
    }

    public function getSchema(): array
    {
        return $this->schema;
    }

    public function getFields(): array
    {
        return $this->fields;
    }

    /**
     * Expose client-side validation schema derived from YAML.
     */
    public function getClientValidationSchema(): array
    {
        return \Components\Form\Validation::toClientSchema($this->fields);
    }

    public function getGroups(): array
    {
        // Normalize groups: ensure id, label, and fields array
        $out = [];
        foreach ($this->groups as $g) {
            if (!is_array($g)) continue;
            $id = (string)($g['id'] ?? '');
            if ($id === '') continue;
            $labelRaw = (string)($g['label'] ?? $g['title'] ?? $id);
            $label = self::t($labelRaw);
            $fields = [];
            foreach ((array)($g['fields'] ?? []) as $fn) {
                $fname = (string)$fn;
                if ($fname !== '' && array_key_exists($fname, $this->fields)) {
                    $fields[] = $fname;
                }
            }
            $out[] = ['id' => $id, 'label' => $label, 'fields' => $fields];
        }
        return $out;
    }

    public function getUngroupedFieldNames(): array
    {
        $grouped = [];
        foreach ($this->getGroups() as $g) {
            foreach ($g['fields'] as $f) { $grouped[$f] = true; }
        }
        $out = [];
        foreach (array_keys($this->fields) as $name) {
            if (!isset($grouped[$name])) { $out[] = $name; }
        }
        return $out;
    }

    /**
     * Convert a config key into a human-friendly label.
     */
    public static function labelFromKey(string $key): string
    {
        $key = preg_replace('/^MAILBEEZ_/', '', $key);
        $key = str_replace(['_', '-'], ' ', $key);
        return ucwords(strtolower($key));
    }

    /**
     * Build a small red badge listing roles that are not permitted for this field.
     * Only shown when the current user has access and there are denied roles.
     */
    public static function permissionBadge(array $def): string
    {
        if (Permission::currentRole() == 'core_admin') return '';

        if (!\Components\Permission::hasAccess($def)) return '';
        $denied = \Components\Permission::deniedRoles($def);
        if (empty($denied)) return '';
        $parts = array_map(function($r){ return \Components\Base::h((string)$r); }, $denied);
        $text = \mh_lng('UI_NOT_FOR_PREFIX', 'Not for: ') . implode(', ', $parts);
        return <<<HTML
<div class="ml-2 inline-block rounded bg-red-50 px-0.5 py-0 text-[8px] font-medium text-red-700 border border-red-200 dark:bg-red-500/10 dark:text-red-300 dark:border-red-400/30">{$text}</div>
HTML;
    }

    /**
     * Render a single field to HTML using component classes via a type->renderer map.
     * Mirrors legacy behavior while improving readability & maintainability.
     */
    public static function renderField(string $name, array $def, $value, array $errors = [], array $context = [], array $fields = []): string
    {
        if (!\Components\Permission::hasAccess($def)) { return ''; }
        // Enforce mode-based visibility early (e.g., edit-only fields)
        $mode = (string)($context['__mode'] ?? '');
        if ($mode !== '') {
            if (!\Components\Common\FieldVisibility::isVisibleByMode($def, $mode)) {
                return '';
            }
        }

        // Common data
        $validate = $def['validate'] ?? [];
        // Resolve labels/help via mh_lng() based on config key
        $cfgKey = strtoupper($name);
        $label = \mh_lng($cfgKey . '_TITLE', isset($def['label']) ? self::t($def['label']) : self::labelFromKey($name));
        $help = \mh_lng($cfgKey . '_DESC', isset($def['help']) ? self::t($def['help']) : '');

        // Alpine: bind each input/select to form[name]
        $xModelName = htmlspecialchars($name, ENT_QUOTES);
        $isEmail = (strtolower((string)($validate['type'] ?? '')) === 'email');
        $onInput = $isEmail
            ? ('queueValidate(\'' . $xModelName . '\', 600)')
            : ('validateField(\'' . $xModelName . '\')');
        $xModel = 'x-model="form[\'' . $xModelName . '\']" @input="' . $onInput . '" @change="validateField(\'' . $xModelName . '\')" @blur="validateField(\'' . $xModelName . '\')"';

        $opts = [
            'help' => $help,
            'errors' => $errors,
            'validate' => $validate,
            'input_attrs' => $xModel,
            'label_badge_html' => self::permissionBadge($def),
            'fields' => $fields,
            'collapsible' => $def['collapsible'] ?? false,
        ];

        // Resolve normalized type and dispatch
        $type = self::normalizeType($def);
        $map = self::rendererMap();
        $renderer = $map[$type] ?? $map['input'];
        $inner = $renderer($name, $label, $value, $def, $opts);

        // Wrap with conditional visibility container if present
        $vis = $def['visible_if'] ?? null;
        if (!$vis || !is_array($vis)) {
            return $inner;
        }

        $xShowExpr = self::buildVisibleIfExpr($vis);
        if ($xShowExpr === null) { return $inner; }

        // Return wrapper using Alpine x-if so hidden required fields are removed from the DOM
        $xIfExpr = htmlspecialchars($xShowExpr, ENT_QUOTES);
        $innerHtml = $inner;
        return <<<HTML
<template x-if="{$xIfExpr}"><div class="conditional-field" x-init="\$el.classList.add('transition-opacity','duration-300','opacity-0'); requestAnimationFrame(() => { \$el.classList.remove('opacity-0'); });">{$innerHtml}</div></template>
HTML;
    }

    /**
     * Build a single Alpine visible() expression from a visible_if condition array.
     * Returns null when the condition is invalid (no field key).
     */
    public static function buildVisibleExpr(array $vis): ?string
    {
        $field = (string)($vis['field'] ?? '');
        if ($field === '') return null;

        $cond = [];
        if (!empty($vis['any_of']) && is_array($vis['any_of'])) {
            $any = array_values($vis['any_of']);
            $hasBool = array_reduce($any, function($c,$v){ return $c || is_bool($v); }, false);
            $cond['any_of'] = $hasBool ? $any : array_values(array_map('strval', $any));
        } elseif (!empty($vis['in']) && is_array($vis['in'])) {
            $any = array_values($vis['in']);
            $hasBool = array_reduce($any, function($c,$v){ return $c || is_bool($v); }, false);
            $cond['any_of'] = $hasBool ? $any : array_values(array_map('strval', $any));
        }
        if (array_key_exists('eq', $vis)) {
            $cond['eq'] = $vis['eq'];
        }
        if (isset($vis['gt']) && $vis['gt'] !== '') {
            $cond['gt'] = is_numeric($vis['gt']) ? 0 + $vis['gt'] : (string)$vis['gt'];
        }
        if (isset($vis['gte']) && $vis['gte'] !== '') {
            $cond['gte'] = is_numeric($vis['gte']) ? 0 + $vis['gte'] : (string)$vis['gte'];
        }
        $fieldJs = addslashes($field);
        $condJson = json_encode($cond);
        return "visible('{$fieldJs}', {$condJson})";
    }

    /**
     * Build Alpine expression from visible_if definition.
     *
     * Supported forms:
     * - Single condition object: { field: ..., eq/any_of/gt/gte ... }
     * - OR list (legacy): [ {...}, {...} ]
     * - Explicit AND group: { all_of: [ {...}, {...} ] }
     */
    public static function buildVisibleIfExpr(array $vis): ?string
    {
        $allOf = null;
        if (isset($vis['all_of']) && is_array($vis['all_of'])) {
            $allOf = $vis['all_of'];
        } elseif (isset($vis['and']) && is_array($vis['and'])) {
            $allOf = $vis['and'];
        }

        if ($allOf !== null) {
            $andExprs = [];
            foreach ($allOf as $subVis) {
                if (!is_array($subVis)) { continue; }
                $expr = self::buildVisibleIfExpr($subVis);
                if ($expr !== null) { $andExprs[] = $expr; }
            }
            if (empty($andExprs)) { return null; }
            return '(' . implode(' && ', $andExprs) . ')';
        }

        // Support array of OR'd conditions: visible_if: [ {field:..., eq:...}, {field:..., eq:...} ]
        if (isset($vis[0]) && is_array($vis[0])) {
            $orExprs = [];
            foreach ($vis as $subVis) {
                if (!is_array($subVis)) { continue; }
                $expr = self::buildVisibleIfExpr($subVis);
                if ($expr !== null) { $orExprs[] = $expr; }
            }
            if (empty($orExprs)) { return null; }
            return '(' . implode(' || ', $orExprs) . ')';
        }

        return self::buildVisibleExpr($vis);
    }

    /**
     * Normalize the configured type to an internal renderer key.
     */
    protected static function normalizeType(array $def): string
    {
        $type = strtolower((string)($def['type'] ?? 'input'));
        $vType = strtolower((string)($def['validate']['type'] ?? ''));
        if ($type === 'input' && $vType === 'boolean') {
            return 'toggle';
        }
        if ($type === 'workflow_steps') {
            return 'richselect';
        }
        if ($type === 'radio_cards' || $type === 'radio-cards') {
            return 'radiocards';
        }
        if ($type === 'radio_tiny_cards' || $type === 'radio-tiny-cards' || $type === 'radiotinycards') {
            return 'radiotinycards';
        }
        if ($type === 'radio_list' || $type === 'radio-list' || $type === 'radiolist') {
            return 'radiolist';
        }
        if ($type === 'coupon') {
            return 'coupon';
        }
        if ($type === 'i18n_text' || $type === 'i18n_textarea') {
            return $type;
        }
        if ($type === 'dynamic_select') {
            return 'select';
        }
        if ($type === 'rich_select') {
            return 'richselect';
        }
        if ($type === 'notification') {
            return 'notification';
        }
        if ($type === 'schedule_summary') {
            return 'schedule_summary';
        }
        if ($type === 'schedule_config') {
            return 'schedule_config';
        }
        if ($type === 'date-time' || $type === 'datetime') {
            return 'date_time';
        }
        if ($type === 'colorchoice' || $type === 'color_choice') {
            return 'color_choice';
        }
        return $type ?: 'input';
    }

    /**
     * Map normalized type => renderer callable
     * @return array<string, callable>
     */
    protected static function rendererMap(): array
    {
        return [
            'toggle' => fn($name, $label, $value, $def, $opts) => self::renderToggle($name, $label, $value, $def, $opts),
            'checkbox' => fn($name, $label, $value, $def, $opts) => self::renderCheckbox($name, $label, $value, $def, $opts),
            'select' => fn($name, $label, $value, $def, $opts) => self::renderSelect($name, $label, $value, $def, $opts),
            // Single order status (dropdown)
            'order_status' => fn($name, $label, $value, $def, $opts) => self::renderOrderStatus($name, $label, $value, $def, $opts),
            'coupon' => fn($name, $label, $value, $def, $opts) => self::renderCoupon($name, $label, $value, $def, $opts),
            'richselect' => fn($name, $label, $value, $def, $opts) => self::renderRichSelect($name, $label, $value, $def, $opts),
            'radiocards' => fn($name, $label, $value, $def, $opts) => self::renderRadioCards($name, $label, $value, $def, $opts),
            'radiotinycards' => fn($name, $label, $value, $def, $opts) => self::renderRadioTinyCards($name, $label, $value, $def, $opts),
            'radiolist' => fn($name, $label, $value, $def, $opts) => self::renderRadioList($name, $label, $value, $def, $opts),
            'order_status_multiple' => fn($name, $label, $value, $def, $opts) => self::renderOrderStatusMultiple($name, $label, $value, $def, $opts),
            'delay_days' => fn($name, $label, $value, $def, $opts) => self::renderDelayDays($name, $label, $value, $def, $opts),
            'days' => fn($name, $label, $value, $def, $opts) => self::renderDelayDays($name, $label, $value, $def, $opts),
            'date' => fn($name, $label, $value, $def, $opts) => self::renderDate($name, $label, $value, $def, $opts),
            'date_time' => fn($name, $label, $value, $def, $opts) => self::renderDateTime($name, $label, $value, $def, $opts),
            'password' => fn($name, $label, $value, $def, $opts) => self::renderPassword($name, $label, $value, $def, $opts),
            'i18n_text' => fn($name, $label, $value, $def, $opts) => self::renderI18nText($name, $label, $value, $def, $opts),
            'i18n_textarea' => fn($name, $label, $value, $def, $opts) => self::renderI18nTextarea($name, $label, $value, $def, $opts),
            'currency_amount' => fn($name, $label, $value, $def, $opts) => self::renderCurrencyAmount($name, $label, $value, $def, $opts),
            'textarea' => fn($name, $label, $value, $def, $opts) => self::renderTextarea($name, $label, $value, $def, $opts),
// el-select popup closes automatically on mouse over - seems to be a bug ?
//            'color' => fn($name, $label, $value, $def, $opts) => self::renderColor($name, $label, $value, $def, $opts),
            'color_choice' => fn($name, $label, $value, $def, $opts) => self::renderColorChoice($name, $label, $value, $def, $opts),
            'colorchoice' => fn($name, $label, $value, $def, $opts) => self::renderColorChoice($name, $label, $value, $def, $opts),
            'notification' => fn($name, $label, $value, $def, $opts) => self::renderNotification($name, $label, $value, $def, $opts),
            'schedule_summary' => fn($name, $label, $value, $def, $opts) => self::renderScheduleSummary($name, $label, $value, $def, $opts),
            'schedule_config' => fn($name, $label, $value, $def, $opts) => ScheduleConfig::render($name, $label, $value, $def, $opts),
            'input' => fn($name, $label, $value, $def, $opts) => self::renderInput($name, $label, $value, $def, $opts),
        ];
    }

    /**
     * Helper to translate a string if it's potentially a language key.
     */
    protected static function t($val)
    {
        if (is_string($val) && $val !== '') {
            return \mh_lng($val, $val);
        }
        return $val;
    }

    /**
     * Helper to translate labels/descriptions within an options array.
     */
    protected static function tOptions(array $options)
    {
        foreach ($options as $val => &$def) {
            if (is_array($def)) {
                // Support RichSelect/RadioCards shapes
                if (isset($def['subheader'])) $def['subheader'] = self::t($def['subheader']);
                if (isset($def['label'])) $def['label'] = self::t($def['label']);
                if (isset($def['text'])) $def['text'] = self::t($def['text']);
                if (isset($def['description'])) $def['description'] = self::t($def['description']);
                if (isset($def['desc'])) $def['desc'] = self::t($def['desc']);
            } else {
                // Simple Select shape (value => label)
                $def = self::t($def);
            }
        }
        return $options;
    }

    /**
     * If options contain dot_class or icon, wrap labels with CollectionIdentity HTML.
     */
    protected static function enrichOptionsWithIdentity(array $options): array
    {
        $hasIdentityData = false;
        foreach ($options as $opt) {
            if (is_array($opt) && (isset($opt['dot_class']) || isset($opt['icon']) || isset($opt['identity']))) {
                $hasIdentityData = true;
                break;
            }
        }
        if (!$hasIdentityData) {
            return $options;
        }

        $richOpts = [];
        foreach ($options as $val => $opt) {
            // Preserve subheaders/separators.
            if (is_array($opt) && array_key_exists('subheader', $opt)) {
                $richOpts[$val] = ['subheader' => (string)$opt['subheader']];
                continue;
            }
            if (is_array($opt)) {
                $rawLabel = (string)($opt['label'] ?? $opt['text'] ?? $val);
                $identity = $opt['identity'] ?? (string)($opt['dot_class'] ?? 'bg-gray-400');

                $identityOpts = [];
                if (isset($opt['icon'])) {
                    $identityOpts['icon'] = $opt['icon'];
                }
                if (isset($opt['icon_class'])) {
                    $identityOpts['icon_class'] = $opt['icon_class'];
                }
                if (isset($opt['size'])) {
                    $identityOpts['size'] = $opt['size'];
                }
                if (isset($opt['dot_size_class'])) {
                    $identityOpts['dot_size_class'] = $opt['dot_size_class'];
                }

                $identityHtml = \Components\Ui\CollectionIdentity::render(
                    \Components\Base::h($rawLabel),
                    $identity,
                    $identityOpts
                );
                $richOpts[$val] = [
                    'label'       => $rawLabel,
                    'label_html'  => $identityHtml,
                    'description' => (string)($opt['description'] ?? $opt['desc'] ?? ''),
                ];
            } else {
                $richOpts[$val] = ['label' => (string)$opt, 'description' => ''];
            }
        }
        return $richOpts;
    }

    // Individual renderers
    protected static function renderNotification(string $name, string $label, $value, array $def, array $opts): string
    {
        // Allow blueprint to override message, id, dismissible, variant, class
        if (isset($def['message'])) { $opts['message'] = self::t($def['message']); }
        if (isset($def['id'])) { $opts['id'] = (string)$def['id']; }
        if (isset($def['dismissible'])) { $opts['dismissible'] = (bool)$def['dismissible']; }
        if (isset($def['variant'])) { $opts['variant'] = (string)$def['variant']; }
        if (isset($def['class'])) { $opts['class'] = (string)$def['class']; }
        if (isset($def['minimizedClass'])) { $opts['minimizedClass'] = (string)$def['minimizedClass']; }
        return Notification::render($name, $label, $value, $opts);
    }

    protected static function renderToggle(string $name, string $label, $value, array $def, array $opts): string
    {
        // Pass through blueprint-driven size/variant for semantic control of toggle dimensions
        if (isset($def['variant']) && !isset($opts['variant'])) {
            $opts['variant'] = (string)$def['variant'];
        }
        if (isset($def['size']) && !isset($opts['size'])) {
            $opts['size'] = (string)$def['size'];
        }
        // Pass through optional icon, may be raw SVG or a class constant reference
        if (isset($def['icon']) && !isset($opts['icon'])) {
            $opts['icon'] = (string)$def['icon'];
        }
        return Toggle::render($name, $label, $value, $opts);
    }

    protected static function renderCheckbox(string $name, string $label, $value, array $def, array $opts): string
    {
        return Checkbox::render($name, $label, $value, $opts);
    }

    protected static function renderSelect(string $name, string $label, $value, array $def, array $opts): string
    {
        $options = self::tOptions($def['options'] ?? []);
        return Select::render($name, $label, $options, $value, $opts);
    }

    /**
     * Render a single-select dropdown for order status using centralized options provider.
     */
    protected static function renderOrderStatus(string $name, string $label, $value, array $def, array $opts): string
    {
        // Try to get live order status options via the same provider used by the multi-select field
        try {
            $options = \Components\Form\OrderStatusMultiple::getOptions();
        } catch (\Throwable $e) {
            $options = [];
        }
        // Allow explicit options in blueprint to override provider when given
        if (isset($def['options']) && is_array($def['options']) && !empty($def['options'])) {
            $options = self::tOptions($def['options']);
        }
        return Select::render($name, $label, $options, $value, $opts);
    }

    protected static function renderColor(string $name, string $label, $value, array $def, array $opts): string
    {
        // Allow restricting palette tokens via blueprint using either 'tokens' or 'values'
        $tokens = $def['tokens'] ?? ($def['values'] ?? null);
        if (is_array($tokens)) {
            $opts['tokens'] = array_values(array_map('strval', $tokens));
        }
        return ColorSelect::render($name, $label, $value, $opts);
    }

    protected static function renderColorChoice(string $name, string $label, $value, array $def, array $opts): string
    {
        // Allow restricting palette tokens via blueprint using either 'tokens' or 'values'
        $tokens = $def['tokens'] ?? ($def['values'] ?? null);
        if (is_array($tokens)) {
            $opts['tokens'] = array_values(array_map('strval', $tokens));
        }
        // Allow blueprint to specify a sibling field name for the live name preview
        if (isset($def['preview_name_field']) && !isset($opts['preview_name_field'])) {
            $opts['preview_name_field'] = (string)$def['preview_name_field'];
        }
        return ColorChoice::render($name, $label, $value, $opts);
    }

    protected static function renderRichSelect(string $name, string $label, $value, array $def, array $opts): string
    {
        // For rich_select fields with dot_class or icon, build CollectionIdentity HTML as the option label.
        if (isset($def['options']) && is_array($def['options'])) {
            $def['options'] = self::enrichOptionsWithIdentity($def['options']);
        }
        $optSrc = isset($def['options']) ? self::tOptions($def['options']) : null;
        if (!is_array($optSrc)) {
            $vals = $def['values'] ?? [];
            $optSrc = [];
            foreach ($vals as $v) {
                $vStr = (string)$v;
                if (is_numeric($vStr)) {
                    $n = (int)$vStr;
                    $optSrc[$vStr] = [
                        'label' => $n === 1 ? ('1 ' . \mh_lng('UI_STEP','Step')) : ($n . ' ' . \mh_lng('UI_STEPS','Steps')),
                        'description' => $n === 1 ? \mh_lng('UI_SINGLE_STEP_WORKFLOW','A single-step workflow.') : (\mh_lng('UI_WORKFLOW_WITH','A workflow with ') . $n . ' ' . \mh_lng('UI_STEPS','steps') . '.'),
                    ];
                } else {
                    $lbl = ucfirst($vStr);
                    if ($vStr === 'loop') {
                        $lbl = \mh_lng('UI_LOOP','Loop');
                    }
                    $optSrc[$vStr] = [
                        'label' => $lbl,
                        'description' => ($vStr === 'loop') ? \mh_lng('UI_LOOP_DESC','Repeat after last step (loop).') : '',
                    ];
                }
            }
        }
        return RichSelect::render($name, $label, $optSrc, $value, $opts);
    }

    protected static function renderOrderStatusMultiple(string $name, string $label, $value, array $def, array $opts): string
    {
        return OrderStatusMultiple::render($name, $label, $value, $opts);
    }

    protected static function renderDelayDays(string $name, string $label, $value, array $def, array $opts): string
    {
        $opts['addon_suffix'] = \mh_lng('UI_DAYS','days');
        return Input::render($name, $label, $value, $opts);
    }

    protected static function renderCurrencyAmount(string $name, string $label, $value, array $def, array $opts): string
    {
        // Ensure numeric validation by default
        if (!isset($opts['validate']) || !is_array($opts['validate']) || empty($opts['validate']['type']) || strtolower((string)$opts['validate']['type']) === 'string') {
            $opts['validate']['type'] = 'number';
        }
        // Determine currency label from mh_price(5) if available, else fallback to 'Eur'
        $prefix = null; $suffix = null;
        try {
            if (\function_exists('mh_price')) {
                $example = (string)\mh_price(5);
                // Try to extract suffix after the numeric amount
                if (preg_match('/^\s*[-+]?\d+(?:[\.,]\d+)?\s*(.+)$/u', $example, $m)) {
                    $suffix = trim($m[1]);
                } elseif (preg_match('/^(.*?)\s*[-+]?\d+(?:[\.,]\d+)?\s*$/u', $example, $m)) {
                    $prefix = trim($m[1]);
                }
            }
        } catch (\Throwable $e) {
            // ignore and use fallback
        }
        if (!$prefix && !$suffix) {
            $suffix = 'Eur';
        }
        if (!isset($opts['addon_prefix']) && $prefix) { $opts['addon_prefix'] = $prefix; }
        if (!isset($opts['addon_suffix']) && $suffix) { $opts['addon_suffix'] = $suffix; }
        // Allow blueprints to override via def
        if (isset($def['addon_prefix'])) { $opts['addon_prefix'] = self::t($def['addon_prefix']); }
        if (isset($def['addon_suffix'])) { $opts['addon_suffix'] = self::t($def['addon_suffix']); }
        return Input::render($name, $label, $value, $opts);
    }

    protected static function renderTextarea(string $name, string $label, $value, array $def, array $opts): string
    {
        return Textarea::render($name, $label, $value, $opts);
    }

    protected static function renderInput(string $name, string $label, $value, array $def, array $opts): string
    {
        // Allow blueprints to specify simple input addons (prefix/suffix) like "%" or currency labels
        if (isset($def['addon_prefix']) && !isset($opts['addon_prefix'])) { $opts['addon_prefix'] = self::t($def['addon_prefix']); }
        if (isset($def['addon_suffix']) && !isset($opts['addon_suffix'])) { $opts['addon_suffix'] = self::t($def['addon_suffix']); }
        return Input::render($name, $label, $value, $opts);
    }

    protected static function renderPassword(string $name, string $label, $value, array $def, array $opts): string
    {
        return Password::render($name, $label, $value, $opts);
    }

    protected static function renderI18nText(string $name, string $label, $value, array $def, array $opts): string
    {
        return I18nText::render($name, $label, $value, $opts);
    }

    protected static function renderI18nTextarea(string $name, string $label, $value, array $def, array $opts): string
    {
        return I18nTextarea::render($name, $label, $value, $opts);
    }

    protected static function renderDate(string $name, string $label, $value, array $def, array $opts): string
    {
        // Convert incoming value to YYYY-MM-DD for HTML date input
        $valStr = '';
        if (is_int($value)) {
            $valStr = date('Y-m-d', $value);
        } elseif (is_string($value) && trim($value) !== '') {
            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
                $valStr = $value;
            } else {
                $ts = self::parseDateToTs($value);
                if ($ts !== null) { $valStr = date('Y-m-d', $ts); }
            }
        }
        // Ensure validator type defaults to 'date' if not specified
        if (!isset($opts['validate']) || !is_array($opts['validate']) || empty($opts['validate']['type']) || strtolower((string)$opts['validate']['type']) === 'string') {
            $opts['validate']['type'] = 'date';
        }
        // Normalize min/max to YYYY-MM-DD for HTML attribute compatibility
        if (isset($opts['validate']['min']) && $opts['validate']['min'] !== '') {
            $minTs = self::parseDateToTs($opts['validate']['min']);
            if ($minTs !== null) { $opts['validate']['min'] = date('Y-m-d', $minTs); }
        }
        if (isset($opts['validate']['max']) && $opts['validate']['max'] !== '') {
            $maxTs = self::parseDateToTs($opts['validate']['max']);
            if ($maxTs !== null) { $opts['validate']['max'] = date('Y-m-d', $maxTs); }
        }
        // Add click handler to open the native date picker when supported
        $pickerJs = 'if ($event.target && typeof $event.target.showPicker === \'function\') { $event.target.showPicker() }';
        $existing = (string)($opts['input_attrs'] ?? '');
        $opts['input_attrs'] = trim($existing . ' @click="' . $pickerJs . '"');
        // HTML5 input type override
        $opts['input_type'] = 'date';
        return Input::render($name, $label, $valStr, $opts);
    }

    protected static function renderDateTime(string $name, string $label, $value, array $def, array $opts): string
    {
        // Convert incoming value to YYYY-MM-DDTHH:MM for HTML datetime-local
        $valStr = '';
        $format = 'Y-m-d\\TH:i';
        if (is_int($value)) {
            $valStr = date($format, $value);
        } elseif (is_string($value) && trim($value) !== '') {
            if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/', $value)) {
                $valStr = substr($value, 0, 16);
            } else {
                $ts = self::parseDateToTs($value);
                if ($ts !== null) { $valStr = date($format, $ts); }
            }
        }
        if (!isset($opts['validate']) || !is_array($opts['validate']) || empty($opts['validate']['type']) || strtolower((string)$opts['validate']['type']) === 'string') {
            $opts['validate']['type'] = 'date_time';
        }
        // Normalize min/max to YYYY-MM-DDTHH:MM for HTML attribute compatibility
        if (isset($opts['validate']['min']) && $opts['validate']['min'] !== '') {
            $minTs = self::parseDateToTs($opts['validate']['min']);
            if ($minTs !== null) { $opts['validate']['min'] = date($format, $minTs); }
        }
        if (isset($opts['validate']['max']) && $opts['validate']['max'] !== '') {
            $maxTs = self::parseDateToTs($opts['validate']['max']);
            if ($maxTs !== null) { $opts['validate']['max'] = date($format, $maxTs); }
        }
        // Add click handler to open the native picker when supported
        $pickerJs = 'if ($event.target && typeof $event.target.showPicker === \'function\') { $event.target.showPicker() }';
        $existing = (string)($opts['input_attrs'] ?? '');
        $opts['input_attrs'] = trim($existing . ' @click="' . $pickerJs . '"');
        $opts['input_type'] = 'datetime-local';
        return Input::render($name, $label, $valStr, $opts);
    }

    /**
     * Render a reactive schedule summary line.
     * Pure Alpine.js — reads form.start_mode, form.newsletter_item_start,
     * form.schedule_type, form.newsletter_item_end and builds a human-readable sentence.
     */
    protected static function renderScheduleSummary(string $name, string $label, $value, array $def, array $opts): string
    {
        // Collect translated tokens for the unified JS helper
        $tokens = [
            'starts_now'        => (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STARTS_NOW', 'Starts immediately'),
            'starts_on'         => (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STARTS_ON', 'Starts'),
            'stops_finished'    => (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STOPS_FINISHED', 'Stops when finished'),
            'stops_on'          => (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STOPS_ON', 'Stops'),
            'runs_indefinitely' => (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_RUNS_INDEFINITELY', 'Runs indefinitely'),
        ];
        $tokensJson = htmlspecialchars(json_encode($tokens, JSON_UNESCAPED_UNICODE), ENT_QUOTES);

        // Read optional field-name mappings from YAML def (with sensible defaults)
        $fieldMap = [
            'schedule_type'   => (string)($def['schedule_type_field'] ?? 'schedule_type'),
            'start_mode'      => (string)($def['start_mode_field'] ?? 'start_mode'),
            'start_date'      => (string)($def['start_date_field'] ?? 'newsletter_item_start'),
            'end_date'        => (string)($def['end_date_field'] ?? 'newsletter_item_end'),
            'automation_end'  => (string)($def['automation_end_mode_field'] ?? 'automation_end_mode'),
        ];
        $mapJson = htmlspecialchars(json_encode($fieldMap, JSON_UNESCAPED_UNICODE), ENT_QUOTES);

        // Seed start/end dates using the shared helper (fallback to inline seeding if BeezUI isn't ready)
        $xInit = htmlspecialchars(
            "(function(){var s='" . $fieldMap['start_date'] . "', e='" . $fieldMap['end_date'] . "'; if(window.BeezUI && BeezUI.seedDefaultStartEnd){BeezUI.seedDefaultStartEnd(s,e);} else if (typeof form!=='undefined' && form && !(form[s]||'') && !(form[e]||'')) {var d=new Date();d.setHours(0,0,0,0);d.setDate(d.getDate()+1);var fmt=function(dd){return dd.getFullYear()+'-'+String(dd.getMonth()+1).padStart(2,'0')+'-'+String(dd.getDate()).padStart(2,'0')};var sd=fmt(d);var ed=new Date(d);ed.setDate(ed.getDate()+1);form[s]=sd;form[e]=fmt(ed);} })()",
            ENT_QUOTES
        );

        return <<<HTML
<div class="text-sm text-secondary leading-relaxed"
     x-data="{ m: {$mapJson}, t: {$tokensJson} }"
     x-init="{$xInit}"
     x-text="(() => {
        // Touch dependent fields to make Alpine re-evaluate on change
        if (typeof form !== 'undefined' && form) {
            void form[m.start_mode]; void form[m.start_date]; void form[m.end_date]; void form[m.schedule_type]; void form[m.automation_end];
        }
        if (window.BeezUI && typeof BeezUI.scheduleSummary==='function') { return BeezUI.scheduleSummary(m, t, form); }
        // Fallback when BeezUI isn't loaded yet (non-localized)
        const get = (k)=> (typeof form!=='undefined' && form) ? (form[k]||'') : '';
        const sm = get(m.start_mode) || 'now';
        const sd = get(m.start_date) || '';
        const st = get(m.schedule_type) || '';
        const ed = get(m.end_date) || '';
        const ae = get(m.automation_end) || 'never';
        const fmt = (v)=>{ try{ const d=new Date(String(v)); return isNaN(d.getTime())? String(v||'') : d.toLocaleDateString(); }catch(_){ return String(v||''); } };
        const parts = [];
        if (sm==='date' && sd) parts.push((t.starts_on||'Starts') + ' ' + fmt(sd)); else parts.push(t.starts_now||'Starts immediately');
        if (st==='bulk_mail') { if (ed) parts.push((t.stops_finished||'Stops when finished') + ', ' + (t.stops_on||'Stops') + ' ' + fmt(ed)); else parts.push(t.stops_finished||'Stops when finished'); }
        else if (st==='scheduled_window') { if (ed) parts.push((t.stops_on||'Stops') + ' ' + fmt(ed)); }
        else if (st==='automation') { if (ae==='date' && ed) parts.push((t.stops_on||'Stops') + ' ' + fmt(ed)); else parts.push(t.runs_indefinitely||'Runs indefinitely'); }
        return parts.join('. ') + '.';
     })()"></div>
HTML;
    }

    protected static function renderRadioCards(string $name, string $label, $value, array $def, array $opts): string
    {
        $grid = self::radioGridConfig($def);
        if (!empty($grid)) { $opts['grid'] = $grid; }

        // Pass through an optional custom locked message from blueprint/model definition
        // so domain contexts (e.g., Audience) can provide specific copy for edit-mode lock state.
        // Accept either a literal string or a language constant (resolved via mh_lng).
        if (isset($def['locked_message']) && $def['locked_message'] !== '') {
            // Use t() to resolve language constants while preserving literals as-is
            $opts['locked_message'] = (string) self::t($def['locked_message']);
        }

        // Support grouped options if provided by dynamic source
        if (isset($def['grouped_options']) && is_array($def['grouped_options'])) {
            $groups = $def['grouped_options'];
            return RadioCards::renderGrouped($name, $label, $groups, $value, $opts);
        }
        $optSrc = self::radioOptionSource($def);

        return RadioCards::render($name, $label, $optSrc, $value, $opts);
    }

    protected static function renderRadioList(string $name, string $label, $value, array $def, array $opts): string
    {
        $optSrc = self::radioOptionSource($def);
        return RadioList::render($name, $label, $optSrc, $value, $opts);
    }

    protected static function renderRadioTinyCards(string $name, string $label, $value, array $def, array $opts): string
    {
        $opts['grid'] = self::radioGridConfig($def, 3, 6);

        $optSrc = self::radioOptionSource($def);
        return RadioTinyCards::render($name, $label, $optSrc, $value, $opts);
    }

    protected static function radioGridConfig(array $def, int $defaultBase = 1, int $defaultSm = 3): array
    {
        // Optional layout: allow blueprint to define cards-per-row per breakpoint
        // Supported keys on $def: cards_per_row, cards_per_row_base, cards_per_row_sm, cards_per_row_md, cards_per_row_lg, cards_per_row_xl
        // Defaults (when not configured): base/sm from function defaults
        $grid = [];
        $base = isset($def['cards_per_row_base']) ? (int)$def['cards_per_row_base'] : $defaultBase;
        $sm   = isset($def['cards_per_row_sm'])   ? (int)$def['cards_per_row_sm']   : 0;
        $md   = isset($def['cards_per_row_md'])   ? (int)$def['cards_per_row_md']   : 0;
        $lg   = isset($def['cards_per_row_lg'])   ? (int)$def['cards_per_row_lg']   : 0;
        $xl   = isset($def['cards_per_row_xl'])   ? (int)$def['cards_per_row_xl']   : 0;
        // Shorthand: cards_per_row -> sm when specific sm not provided
        if (!isset($def['cards_per_row_sm']) && isset($def['cards_per_row'])) {
            $sm = (int)$def['cards_per_row'];
        }
        if ($sm <= 0) {
            $sm = $defaultSm;
        }
        $grid['base'] = max(1, (int)$base);
        if ($sm > 0) { $grid['sm'] = (int)$sm; }
        if ($md > 0) { $grid['md'] = (int)$md; }
        if ($lg > 0) { $grid['lg'] = (int)$lg; }
        if ($xl > 0) { $grid['xl'] = (int)$xl; }

        return $grid;
    }

    protected static function radioOptionSource(array $def): array
    {
        // Prefer explicit options, then overlay label/description maps when provided in blueprint
        $optSrc = isset($def['options']) ? self::tOptions($def['options']) : null;

        // Normalize maps from blueprint: labels/descriptions keyed by value
        $labelsMap = [];
        $descriptionsMap = [];
        if (isset($def['labels']) && is_array($def['labels'])) {
            foreach ($def['labels'] as $k => $v) { $labelsMap[(string)$k] = (string)$v; }
        }
        if (isset($def['descriptions']) && is_array($def['descriptions'])) {
            foreach ($def['descriptions'] as $k => $v) { $descriptionsMap[(string)$k] = (string)$v; }
        }

        if (is_array($optSrc)) {
            if ($labelsMap || $descriptionsMap) {
                foreach ($optSrc as $k => $opt) {
                    $key = (string)$k;
                    if (!is_array($opt)) { $opt = ['label' => (string)$opt]; }
                    if (isset($labelsMap[$key]) && $labelsMap[$key] !== '') { $opt['label'] = $labelsMap[$key]; }
                    if (array_key_exists($key, $descriptionsMap)) { $opt['description'] = $descriptionsMap[$key]; }
                    $optSrc[$k] = $opt;
                }
            }
        } else {
            $vals = $def['values'] ?? [];
            if (empty($vals) && $labelsMap) { $vals = array_keys($labelsMap); }
            $optSrc = [];

            foreach ($vals as $k => $v) {
                $isScalarIndex = is_int($k) || (is_string($k) && ctype_digit($k));

                if (is_array($v)) {
                    $vStr = (string)$k;
                    if ($vStr === '') { continue; }
                    $lbl = (string)($v['label'] ?? ($labelsMap[$vStr] ?? ''));
                    $desc = (string)($v['description'] ?? ($v['desc'] ?? ($descriptionsMap[$vStr] ?? '')));
                    if ($lbl === '') {
                        $lbl = is_numeric($vStr)
                            ? ((int)$vStr === 1 ? ('1 ' . \mh_lng('UI_STEP','Step')) : ((int)$vStr . ' ' . \mh_lng('UI_STEPS','Steps')))
                            : (strtolower($vStr) === 'loop' ? \mh_lng('UI_LOOP','Loop') : ucfirst($vStr));
                    }
                    if ($desc === '' && strtolower($vStr) === 'loop') {
                        $desc = \mh_lng('UI_LOOP_DESC','Repeat after last step (loop).');
                    }

                    $entry = ['label' => $lbl, 'description' => $desc];
                    if (isset($v['meta']) && (string)$v['meta'] !== '') { $entry['meta'] = (string)$v['meta']; }
                    if (isset($v['icon']) && (string)$v['icon'] !== '') { $entry['icon'] = (string)$v['icon']; }
                    if (isset($v['token']) && (string)$v['token'] !== '') {
                        $entry['token'] = (string)$v['token'];
                    } elseif (isset($v['color']) && (string)$v['color'] !== '') {
                        $entry['token'] = (string)$v['color'];
                    }
                    if (array_key_exists('disabled', $v)) { $entry['disabled'] = $v['disabled']; }
                    $optSrc[$vStr] = $entry;
                    continue;
                }

                $vStr = $isScalarIndex ? (string)$v : (string)$k;
                $lbl = $labelsMap[$vStr] ?? ($isScalarIndex ? '' : (string)$v);
                $desc = $descriptionsMap[$vStr] ?? '';

                if ($lbl === '') {
                    if (is_numeric($vStr)) {
                        $n = (int)$vStr;
                        $lbl = $n === 1 ? ('1 ' . \mh_lng('UI_STEP','Step')) : ($n . ' ' . \mh_lng('UI_STEPS','Steps'));
                        if ($desc === '') {
                            $desc = $n === 1
                                ? \mh_lng('UI_SINGLE_STEP_WORKFLOW','A single-step workflow.')
                                : (\mh_lng('UI_WORKFLOW_WITH','A workflow with ') . $n . ' ' . \mh_lng('UI_STEPS','steps') . '.');
                        }
                    } else {
                        $lbl = (strtolower($vStr) === 'loop') ? \mh_lng('UI_LOOP','Loop') : ucfirst($vStr);
                        if ($desc === '' && strtolower($vStr) === 'loop') {
                            $desc = \mh_lng('UI_LOOP_DESC','Repeat after last step (loop).');
                        }
                    }
                } elseif ($desc === '' && strtolower($vStr) === 'loop') {
                    $desc = \mh_lng('UI_LOOP_DESC','Repeat after last step (loop).');
                }

                $optSrc[$vStr] = [
                    'label' => $lbl,
                    'description' => $desc,
                ];
            }
        }

        $icons = self::radioIconsFromDef($def);
        if (!empty($icons)) {
            $optSrc = self::mergeRadioIcons($optSrc, $icons);
        }

        return is_array($optSrc) ? $optSrc : [];
    }

    protected static function radioIconsFromDef(array $def): array
    {
        $icons = [];
        if (isset($def['icons']) && is_array($def['icons'])) {
            $icons = $def['icons'];
        }
        if (isset($def['icon_source']) && is_string($def['icon_source']) && $def['icon_source'] !== '') {
            // Lightweight dynamic invoker (static context); supports Class::method or global function
            $src = (string)$def['icon_source'];
            try {
                if (strpos($src, '::') !== false) {
                    [$cls, $m] = explode('::', $src, 2);
                    $cls = trim($cls); $m = trim($m);
                    if ($cls !== '' && $m !== '' && class_exists($cls) && method_exists($cls, $m)) {
                        $ref = new \ReflectionMethod($cls, $m);
                        $iconsFrom = $ref->isStatic() ? $ref->invoke(null) : $ref->invoke(new $cls());
                        if (is_array($iconsFrom)) { $icons = $iconsFrom; }
                    }
                } elseif (function_exists($src)) {
                    $iconsFrom = call_user_func($src);
                    if (is_array($iconsFrom)) { $icons = $iconsFrom; }
                }
            } catch (\Throwable $e) { /* ignore */ }
        }

        return is_array($icons) ? $icons : [];
    }

    protected static function mergeRadioIcons(array $optSrc, array $icons): array
    {
        if (empty($optSrc) || empty($icons)) {
            return $optSrc;
        }

        foreach ($optSrc as $k => $opt) {
            $key = (string)$k;
            $entry = $icons[$key] ?? null;
            if (!$entry) { continue; }

            if (!is_array($opt)) { $opt = ['label' => (string)$opt]; }
            if (is_array($entry)) {
                if (isset($entry['icon'])) { $opt['icon'] = (string)$entry['icon']; }
                if (isset($entry['token'])) { $opt['token'] = (string)$entry['token']; }
            } else {
                // Scalar: treat as raw SVG string
                $opt['icon'] = (string)$entry;
            }
            $optSrc[$k] = $opt;
        }

        return $optSrc;
    }

    protected static function renderCoupon(string $name, string $label, $value, array $def, array $opts): string
    {
        // Prefer explicit options if provided in blueprint
        $optSrc = isset($def['options']) ? self::tOptions($def['options']) : null;
        if (!is_array($optSrc)) {
            $optSrc = \Components\Models\Coupon::listOptions();
        }
        $optSrc = self::enrichOptionsWithIdentity($optSrc);
        return \Components\Form\Coupon::render($name, $label, $optSrc, $value, $opts);
    }

    /**
     * Register or override a validator for a given type
     */
    public function setValidator(string $type, callable $handler): void
    {
        $this->validators[strtolower($type)] = $handler;
    }

    /**
     * Register a named rule handler used via validate.rule
     */
    public function setRuleHandler(string $rule, callable $handler): void
    {
        $this->ruleHandlers[$rule] = $handler;
    }

    /**
     * Validate a payload against field definitions
     * Returns array fieldName => [errors]
     */
    public function validate(array $post): array
    {
        $errors = [];
        foreach ($this->fields as $name => $def) {
            // Skip if field not permitted for current user
            if (!$this->isFieldPermitted($def)) {
                continue;
            }
            // Skip validation if field is not visible per visible_if rules
            if (!$this->isVisible($def, $post)) {
                continue;
            }

            // Skip server-side validation for display-only and i18n fields
            $defType = strtolower((string)($def['type'] ?? ''));
            if (in_array($defType, ['i18n_text','i18n_textarea','notification','schedule_summary','schedule_config'], true)) {
                continue;
            }

            $validate = $def['validate'] ?? [];
            $vType = strtolower((string)($validate['type'] ?? 'string'));
            $value = $post[$name] ?? '';

            // checkbox/toggle not sent when unchecked
            $defType = strtolower((string)($def['type'] ?? ''));
            // currency_amount defaults to numeric validation when not specified
            if ($defType === 'currency_amount' && ($vType === '' || $vType === 'string')) {
                $vType = 'number';
            }
            // order_status defaults to select validation when not specified
            if ($defType === 'order_status' && ($vType === '' || $vType === 'string')) {
                $vType = 'select';
            }
            if (in_array($defType, ['toggle', 'checkbox'], true) && !isset($post[$name])) {
                $value = '';
            }
            // i18n fields default to 'i18n' validator when not specified
            if (in_array($defType, ['i18n_text','i18n_textarea'], true) && ($vType === '' || $vType === 'string')) {
                $vType = 'i18n';
            }

            $handler = $this->validators[$vType] ?? $this->validators['string'];
            $errs = (array)call_user_func($handler, $value, $validate, $def);

            $rule = (string)($validate['rule'] ?? '');
            if ($rule !== '' && isset($this->ruleHandlers[$rule])) {
                $errs = array_merge($errs, (array)call_user_func($this->ruleHandlers[$rule], $value, $def));
            }

            // Cross-field comparison support via validate.compare
            $compare = $validate['compare'] ?? null;
            if ($compare) {
                $cmpArr = (array)$compare;
                $comparisons = (isset($cmpArr['field']) && is_string($cmpArr['field'])) ? [$cmpArr] : array_values($cmpArr);
                foreach ($comparisons as $cmp) {
                    if (!is_array($cmp)) continue;
                    $otherField = (string)($cmp['field'] ?? '');
                    if ($otherField === '' || !array_key_exists($otherField, $this->fields)) continue;
                    $op = strtolower((string)($cmp['op'] ?? 'gte'));
                    $offsetRaw = $cmp['offset'] ?? 0;
                    $message = (string)($cmp['message'] ?? '');

                    $otherVal = $post[$otherField] ?? null;
                    $left = $value;
                    $right = $otherVal;
                    // Try numeric comparison first
                    $isLeftNum = is_numeric($left) && (string)(int)$left === (string)$left;
                    $isRightNum = is_numeric($right) && (string)(int)$right === (string)$right;
                    if ($isLeftNum && $isRightNum) {
                        $left = (int)$left;
                        $right = (int)$right + (int)$offsetRaw;
                    } else {
                        // Attempt date comparison (YYYY-MM-DD or parseable date)
                        $lTs = self::parseDateToTs($left);
                        $rTs = self::parseDateToTs($right);
                        if ($lTs !== null && $rTs !== null) {
                            // offset treated as days when numeric
                            $days = is_numeric($offsetRaw) ? (int)$offsetRaw : 0;
                            $right = $rTs + ($days * 86400);
                            $left = $lTs;
                        } else {
                            // Unsupported types; skip comparison
                            continue;
                        }
                    }

                    $ok = true;
                    switch ($op) {
                        case 'gt': case '>':
                            $ok = ($left > $right);
                            break;
                        case 'gte': case '>=':
                            $ok = ($left >= $right);
                            break;
                        case 'lt': case '<':
                            $ok = ($left < $right);
                            break;
                        case 'lte': case '<=':
                            $ok = ($left <= $right);
                            break;
                        case 'eq': case '==':
                            $ok = ($left == $right);
                            break;
                        case 'ne': case '!=':
                            $ok = ($left != $right);
                            break;
                        default:
                            $ok = true;
                    }

                    if (!$ok) {
                        if ($message === '') {
                            $offTxt = (string)(is_numeric($offsetRaw) ? (' + ' . (int)$offsetRaw) : '');
                            $message = "Value must be {$op} {$otherField}{$offTxt}.";
                        } else {
                            $message = \Components\Form\Validation::translateCompareMessage($message, $offsetRaw, $otherField, $this->fields);
                        }
                        $errs[] = $message;
                    }
                }
            }

            if (!empty($errs)) {
                $errors[$name] = $errs;
            }
        }
        return $errors;
    }

    /**
     * Provide submitted values with proper defaults for unchecked checkboxes/toggles
     */
    public function normalizeSubmittedValues(array $post): array
    {
        $out = [];
        foreach ($this->fields as $name => $def) {
            $defType = strtolower((string)($def['type'] ?? ''));
            if (array_key_exists($name, $post)) {
                $out[$name] = $post[$name];
            } elseif (in_array($defType, ['toggle', 'checkbox'], true)) {
                $out[$name] = '';
            } elseif ($defType === 'order_status_multiple') {
                $out[$name] = [];
            }
        }
        return $out;
    }

    /**
     * Parse a date-like value to a Unix timestamp (00:00 local time if date-only)
     * Supports formats like YYYY-MM-DD or anything strtotime() can handle.
     */
    protected static function parseDateToTs($val): ?int
    {
        if ($val === '' || $val === null) return null;
        if (is_int($val)) return $val;
        $s = is_string($val) ? trim($val) : '';
        if ($s === '') return null;
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
            $ts = strtotime($s . ' 00:00:00');
            if ($ts !== false) return $ts;
        }
        $ts = strtotime($s);
        if ($ts !== false) return $ts;
        return null;
    }
}
