<?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);
            $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;
            $label = (string)($g['label'] ?? $g['title'] ?? $id);
            $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 = []): 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', $def['label'] ?? self::labelFromKey($name));
        $help = \mh_lng($cfgKey . '_DESC', $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),
        ];

        // 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;
        }
        $field = (string)($vis['field'] ?? '');
        if ($field === '') return $inner;

        // Build condition object for Alpine helper
        $cond = [];
        if (!empty($vis['any_of']) && is_array($vis['any_of'])) {
            // Preserve booleans if present so toggles can use [true]/[false]
            $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)) {
            // Keep original type (boolean/number/string) to allow correct client-side comparisons
            $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);
        $xShowExpr = 'visible(\'' . $fieldJs . '\', ' . $condJson . ')';

        // 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;
    }

    /**
     * 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 === 'coupon') {
            return 'coupon';
        }
        if ($type === 'i18n_text' || $type === 'i18n_textarea') {
            return $type;
        }
        if ($type === 'dynamic_select') {
            return 'select';
        }
        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),
            '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),
// 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),
            'input' => fn($name, $label, $value, $def, $opts) => self::renderInput($name, $label, $value, $def, $opts),
        ];
    }

    // Individual renderers
    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 = $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 = $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));
        }
        return ColorChoice::render($name, $label, $value, $opts);
    }

    protected static function renderRichSelect(string $name, string $label, $value, array $def, array $opts): string
    {
        $optSrc = $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'] = $def['addon_prefix']; }
        if (isset($def['addon_suffix'])) { $opts['addon_suffix'] = $def['addon_suffix']; }
        return Input::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'] = $def['addon_prefix']; }
        if (isset($def['addon_suffix']) && !isset($opts['addon_suffix'])) { $opts['addon_suffix'] = $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 focus/click handlers 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 . ' @focus="' . $pickerJs . '" @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 focus/click handlers 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 . ' @focus="' . $pickerJs . '" @click="' . $pickerJs . '"');
        $opts['input_type'] = 'datetime-local';
        return Input::render($name, $label, $valStr, $opts);
    }

    protected static function renderRadioCards(string $name, string $label, $value, array $def, array $opts): string
    {
        $optSrc = $def['options'] ?? null;
        if (!is_array($optSrc)) {
            $vals = $def['values'] ?? [];
            $optSrc = [];
            foreach ($vals as $v) {
                $vStr = (string)$v;
                // Build label & description like RichSelect when only values are provided
                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 RadioCards::render($name, $label, $optSrc, $value, $opts);
    }

    protected static function renderCoupon(string $name, string $label, $value, array $def, array $opts): string
    {
        // Prefer explicit options if provided in blueprint
        $optSrc = $def['options'] ?? null;
        if (!is_array($optSrc)) {
            // Build from TemplateCouponProvider
            try {
                $prov = \Components\Data\DataRegistry::templateCoupons();
                $rows = $prov ? $prov->templates() : [];
            } catch (\Throwable $e) {
                $rows = [];
            }
            $optSrc = [];
            foreach ((array)$rows as $row) {
                if (!is_array($row)) continue;

                $couponId = $row['id'];
                $couponCode = $row['code'];
                $couponName = $row['name'];
                $couponLabel = $row['label'];
                $couponTypeLabel= $row['typeLabel'];
                $couponType = $row['type'];
                $couponAmount = $row['amount'];

                $optSrc[$couponId] = [
                    'label' => $couponId < 1 ? mh_lng('MAILBEEZ_COUPON_TYPE_NONE', 'no coupon') : $couponCode . ' - ' .  $couponTypeLabel,
//                    'description' => $desc,
//                    'couponCode' => $couponId,
                ];
            }
        }
        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 i18n fields (array payloads)
            $defType = strtolower((string)($def['type'] ?? ''));
            if (in_array($defType, ['i18n_text','i18n_textarea'], 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}.";
                        }
                        $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 = $post;
        foreach ($this->fields as $name => $def) {
            $defType = strtolower((string)($def['type'] ?? ''));
            if (in_array($defType, ['toggle', 'checkbox'], true) && !isset($out[$name])) {
                $out[$name] = '';
            }
            // ensure multi-select style fields default to array
            if ($defType === 'order_status_multiple' && !isset($out[$name])) {
                $out[$name] = [];
            }
        }
        return $out;
    }

    protected function defaultValidators(): array
    {
        return [
            'string' => function ($value, array $v) {
                $value = (string)$value;
                $errors = [];
                if (($v['required'] ?? false) && trim($value) === '') {
                    $errors[] = 'This field is required.';
                }
                $min = isset($v['min']) && $v['min'] !== '' ? (int)$v['min'] : null;
                $max = isset($v['max']) && $v['max'] !== '' ? (int)$v['max'] : null;
                $len = strlen($value);
                if ($min !== null && $len < $min) $errors[] = "Minimum length is $min.";
                if ($max !== null && $len > $max) $errors[] = "Maximum length is $max.";
                return $errors;
            },
            'integer' => function ($value, array $v) {
                $errors = [];
                if (($v['required'] ?? false) && ($value === '' || $value === null)) {
                    $errors[] = 'This field is required.';
                    return $errors;
                }
                if ($value === '' || $value === null) return $errors; // not required and empty
                if (!is_numeric($value) || (string)(int)$value !== (string)$value) {
                    $errors[] = 'Please enter a valid integer.';
                    return $errors;
                }
                $int = (int)$value;
                $min = isset($v['min']) && $v['min'] !== '' ? (int)$v['min'] : null;
                $max = isset($v['max']) && $v['max'] !== '' ? (int)$v['max'] : null;
                if ($min !== null && $int < $min) $errors[] = "Minimum value is $min.";
                if ($max !== null && $int > $max) $errors[] = "Maximum value is $max.";
                return $errors;
            },
            'number' => function ($value, array $v, array $def = []) {
                $errors = [];
                if (($v['required'] ?? false) && ($value === '' || $value === null)) {
                    $errors[] = 'This field is required.';
                    return $errors;
                }
                if ($value === '' || $value === null) return $errors;
                if (!is_numeric($value)) {
                    $errors[] = 'Please enter a valid number.';
                    return $errors;
                }
                $num = (float)$value;
                $min = isset($v['min']) && $v['min'] !== '' ? (float)$v['min'] : null;
                $max = isset($v['max']) && $v['max'] !== '' ? (float)$v['max'] : null;
                if ($min !== null && $num < $min) $errors[] = "Minimum value is $min.";
                if ($max !== null && $num > $max) $errors[] = "Maximum value is $max.";
                return $errors;
            },
            'float' => function ($value, array $v) {
                // alias of number
                return (function ($value, $v) {
                    $errors = [];
                    if (($v['required'] ?? false) && ($value === '' || $value === null)) {
                        $errors[] = 'This field is required.';
                        return $errors;
                    }
                    if ($value === '' || $value === null) return $errors;
                    if (!is_numeric($value)) {
                        $errors[] = 'Please enter a valid number.';
                        return $errors;
                    }
                    $num = (float)$value;
                    $min = isset($v['min']) && $v['min'] !== '' ? (float)$v['min'] : null;
                    $max = isset($v['max']) && $v['max'] !== '' ? (float)$v['max'] : null;
                    if ($min !== null && $num < $min) $errors[] = "Minimum value is $min.";
                    if ($max !== null && $num > $max) $errors[] = "Maximum value is $max.";
                    return $errors;
                })($value, $v);
            },
            'boolean' => function ($value, array $v) {
                $errors = [];
                if (($v['required'] ?? false) && $value !== 'on' && $value !== '1' && $value !== 'true') {
                    $errors[] = 'This field is required.';
                }
                return $errors;
            },
            'select' => function ($value, array $v, array $def = []) {
                $errors = [];
                if (($v['required'] ?? false) && ($value === '' || $value === null)) {
                    $errors[] = 'Please select a value.';
                }
                // optional: validate option exists (support options or values)
                if ($value !== '' && (isset($def['options']) || isset($def['values']))) {
                    $values = [];
                    if (isset($def['options']) && is_array($def['options'])) {
                        $values = array_map(function ($opt) {
                            return is_array($opt) ? ($opt['value'] ?? null) : $opt;
                        }, $def['options']);
                    } elseif (isset($def['values']) && is_array($def['values'])) {
                        $values = array_map('strval', $def['values']);
                    }
                    if (!in_array((string)$value, array_map('strval', $values), true)) {
                        $errors[] = 'Invalid selection. (engine error)';
                    }
                }
                return $errors;
            },
            'checkbox' => function ($value, array $v) {
                $errors = [];
                // Support both single checkbox (boolean) and checkbox group (array)
                if (is_array($value)) {
                    if (($v['required'] ?? false) && count($value) < 1) {
                        $errors[] = 'Please select at least one option.';
                    }
                    return $errors;
                }
                if (($v['required'] ?? false) && $value !== 'on' && $value !== '1' && $value !== 'true') {
                    $errors[] = 'This field is required.';
                }
                return $errors;
            },
        ];
    }
    /**
     * 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;
    }
}
