<?php
namespace Components\Resources\Controllers;

use Components\Form\Engine;
use Components\Resources\ResourceEditForm;
use Components\Resources\ResourcePresentation;
use Components\Support\Htmx;

/**
 * Generic CRUD modal controller for Engine-based models.
 *
 * Usage (from a page component):
 * echo CrudController::handle([
 *   'modelClass'   => \Components\Models\NewsletterCampaign::class,
 *   // 'blueprint' => '/absolute/path/to/blueprint.yaml', // optional; inferred from model::$blueprint
 *   'selfRoute'    => 'newsletter_campaign_modal',
 *   'oobListRoute' => 'newsletter_collection_list', // optional: include list OOB on save
 * ]);
 */
class CrudController extends BaseController
{
    /**
     * @param array $options
     *   - modelClass: FQCN of the BaseModel subclass (required)
     *   - blueprint: Absolute path to YAML blueprint (optional, will try to infer from modelClass::$blueprint located under components/Models)
     *   - selfRoute: Router name of the current modal route for hx-post (required)
     *   - oobListRoute: Router name of a list partial to refresh via OOB after save (optional)
     *   - oobListParams: array of extra GET params when including the list (optional)
     *   - autoclose: truthy string ('1','true','on','yes') to auto-close modal on save (default '1')
     * @return string HTML
     */
    public static function handle(array $options): string
    {
        $modelClass = (string)($options['modelClass'] ?? '');
        $selfRoute = (string)($options['selfRoute'] ?? '');
        $oobListRoute = (string)($options['oobListRoute'] ?? '');
        $oobListParams = is_array($options['oobListParams'] ?? null) ? $options['oobListParams'] : [];
        $autoCloseRaw = (string)($options['autoclose'] ?? '1');
        $noRenderAfterSave = (bool)($options['no_render_after_save'] ?? false);
        // Pattern A (HX-Trigger) event prefix, e.g. "item", "flow" — used to emit {prefix}:created/updated
        $eventPrefix = (string)($options['event_prefix'] ?? '');
        // Allow request-level overrides so callers can narrow or change the emitted event without
        // having to create a new wrapper. Useful for cases like refreshing only an expandable area.
        $reqEventPrefix = (string) self::getParam('event_prefix', '', 'string');
        if ($reqEventPrefix !== '') { $eventPrefix = $reqEventPrefix; }
        // UI propagation value: prefer explicit request value, otherwise fall back to effective option value
        $uiEventPrefix = ($reqEventPrefix !== '') ? $reqEventPrefix : $eventPrefix;
        // Full event override (emits this exact name), or just a suffix to be appended to the prefix
        // e.g. event_suffix=expandable:refresh -> newsletter_item:expandable:refresh
        $reqEventOverride = (string) self::getParam('event', '', 'string');
        $reqEventSuffix = (string) self::getParam('event_suffix', '', 'string');
        // Optional: override the id included in HX-Trigger payloads without affecting which model is edited.
        // This is useful when editing a child record but the UI listens for events on a parent/root id (e.g., flow id).
        $reqEventIdOverride = (int) self::getParam('ui_event_id', 0, 'int');
        if ($reqEventIdOverride <= 0) {
            $reqEventIdOverride = (int) self::getParam('flow_id', 0, 'int');
        }

        if ($modelClass === '' || $selfRoute === '') {
            http_response_code(400);
            return '<div class="text-sm text-red-600">Missing required options for CRUD controller</div>';
        }

        // Read request params
        $mode = (string) self::getParam('mode', 'view', 'key');
        $id = (int) self::getParam('id', 0, 'int');
        $initialIdParam = $id; // remember before saving to detect create vs update
        $drawer = (int) self::getParam('drawer', 0, 'int');
        $method = (string) \mh_server('REQUEST_METHOD', 'GET');
        // Panel configuration: determine which modal/drawer we are targeting.
        // We look for an explicit parameter first, then try to infer from HX-Target header.
        $panelType = $drawer ? 'drawer' : 'modal';
        $panelId = (string) self::getParam('panel_id', '', 'string');
        if ($panelId === '') {
            $panelId = Htmx::resolvePanelId(null, $drawer);
        }
        $panelTargetId = $panelId . '-body';

        // Optional: limit editable fields to a subset provided via query param
        $fieldsParam = (string) self::getParam('fields', '', 'string');

        // Resolve blueprint path, preferring model::$blueprint under components/Models, with fallback to resources/models
        $blueprintPath = (string)($options['blueprint'] ?? '');
        if ($blueprintPath === '') {
            try {
                $rc = new \ReflectionClass($modelClass);
                $defaults = $rc->getDefaultProperties();
                $bp = $defaults['blueprint'] ?? null; // may be file name or absolute path
                if (is_string($bp) && $bp !== '') {
                    // If model::$blueprint is an absolute or relative path that exists, use it directly
                    if (is_file($bp)) {
                        $blueprintPath = $bp;
                    } else {
                        // Try components/Models first (new location)
                        $candidate1 = BEEZUI_ROOT . '/components/Models/' . ltrim($bp, '/');
                        // Then legacy resources/models
                        $candidate2 = BEEZUI_ROOT . '/resources/models/' . ltrim($bp, '/');
                        if (is_file($candidate1)) {
                            $blueprintPath = $candidate1;
                        } elseif (is_file($candidate2)) {
                            $blueprintPath = $candidate2;
                        }
                    }
                }
            } catch (\Throwable $e) { /* ignore */ }
        }
        if ($blueprintPath === '' || !is_file($blueprintPath)) {
            http_response_code(500);
            return '<div class="text-sm text-red-600">Blueprint not found for model</div>';
        }

        // Load existing model when id > 0
        $model = null;
        $existing = [];
        if ($id > 0 && function_exists('mh_db_query')) {
            try {
                $model = $modelClass::find($id);
                $existing = $model ? $model->toArray() : [];
                if ($model && method_exists($model, 'augmentFormValues')) {
                    try { $existing = (array)$model->augmentFormValues($existing); } catch (\Throwable $e) { /* ignore */ }
                }
            } catch (\Throwable $e) { /* ignore */ }
        }

        $isPost = ($method === 'POST');
        $errors = [];
        $submitted = $existing;

        $fieldPresets = is_array($options['field_presets'] ?? null) ? $options['field_presets'] : [];

        // For GET/new: prefill defaults before engine so dynamic sources see them
        if (!$isPost && $id <= 0 && $mode === 'new') {
            if (method_exists($modelClass, 'getNewDefaults')) {
                try {
                    $defaults = $modelClass::getNewDefaults();
                    foreach ($defaults as $k => $v) {
                        if (self::isEmptyValue($submitted[$k] ?? null)) {
                            $submitted[$k] = $v;
                        }
                    }
                } catch (\Throwable $e) { /* ignore */ }
            }
            // Apply caller-supplied field presets (overwrite after defaults)
            foreach ($fieldPresets as $k => $v) {
                $submitted[(string)$k] = $v;
            }
            $submitted['__presets'] = array_keys($fieldPresets);
        }

        $saveSucceeded = false;
        $menuItems = [];

        $createEngine = static function (array $currentValues) use ($modelClass, $blueprintPath, $mode, $id, $method): Engine {
            $source = $blueprintPath;
            if (method_exists($modelClass, 'buildBlueprintSchema')) {
                try {
                    $dynamicSchema = $modelClass::buildBlueprintSchema($blueprintPath, [
                        'mode' => $mode,
                        'id' => $id,
                        'method' => $method,
                        'values' => $currentValues,
                    ]);
                    if (is_array($dynamicSchema) && !empty($dynamicSchema)) {
                        $source = $dynamicSchema;
                    }
                } catch (\Throwable $e) { /* ignore */ }
            }
            return new Engine($source, $currentValues);
        };

        if (!$isPost) {
            // Inject mode into submitted context for mode-based visibility
            $submitted['__mode'] = $mode;
            // Create engine with current (existing/defaulted) values for GET rendering
            $engine = $createEngine($submitted);
            $fields = $engine->getFields();
            $groups = $engine->getGroups();
            $ungrouped = $engine->getUngroupedFieldNames();

            // Compute allowed set for field-subset editing
            $allowedSet = self::applyFieldSubset($fieldsParam, $fields, $groups, $ungrouped);
        }

        if ($isPost) {
            $postAll = \Components\Common\Request::all('post');

            // Apply field presets to POST data for new records as well.
            // This ensures that values missing from the POST (e.g. because they were hidden via x-if)
            // are correctly persisted according to the caller's presets.
            if ($id <= 0 && $mode === 'new') {
                foreach ($fieldPresets as $k => $v) {
                    if (self::isEmptyValue($postAll[$k] ?? null)) {
                        $postAll[$k] = $v;
                    }
                }
            }

            unset($postAll['autoclose']);
            unset($postAll['drawer']);
            // Control-only params used for routing/events – never persist these to the model
            unset($postAll['event_prefix'], $postAll['event'], $postAll['event_suffix']);
            // Remove internal/meta fields that must not be persisted
            // Hidden helper field from ResourceEditForm
            if (array_key_exists('__form_submit', $postAll)) {
                unset($postAll['__form_submit']);
            }
            $submitted = array_merge($existing, $postAll);
            // Inject mode into submitted context for mode-based visibility
            $submitted['__mode'] = $mode;
            // Create engine with submitted values so dynamic sources can use current selection
            $engine = $createEngine($submitted);
            $fields = $engine->getFields();
            $groups = $engine->getGroups();
            $ungrouped = $engine->getUngroupedFieldNames();

            // Compute allowed set for field-subset editing on POST as well
            $allowedSet = self::applyFieldSubset($fieldsParam, $fields, $groups, $ungrouped);

            $normalized = $engine->normalizeSubmittedValues($postAll);
            // Always restrict persistence payload to fields defined in the blueprint
            // This prevents accidental DB writes of control params like event_prefix
            $normalized = array_intersect_key($normalized, $fields);
            $errors = $engine->validate($submitted);

            // When editing a subset of fields, only validate and persist those
            if (is_array($allowedSet) && !empty($allowedSet)) {
                $normalized = array_intersect_key($normalized, $allowedSet);
                $errors = array_intersect_key($errors, $allowedSet);
            }

            // Apply checkbox/toggle semantics based on full vs partial form
            $isFullForm = !is_array($allowedSet) || empty($allowedSet);
            $normalized = self::applyCheckboxSemantics($engine, $normalized, $isFullForm, $allowedSet ?? null);

            if (empty($errors)) {
                if (!$model) { $model = new $modelClass(); }

                if (is_object($model) && method_exists($model, 'validateBeforeSave')) {
                    try {
                        $preSaveErrors = (array)$model->validateBeforeSave($normalized, $submitted, [
                            'mode' => $mode,
                            'id' => $id,
                            'post' => $postAll,
                            'existing' => $existing,
                        ]);
                        foreach ($preSaveErrors as $fieldName => $messages) {
                            $fieldName = (string)$fieldName;
                            $msgs = is_array($messages) ? $messages : [$messages];
                            foreach ($msgs as $msg) {
                                $msg = trim((string)$msg);
                                if ($msg === '') {
                                    continue;
                                }
                                $errors[$fieldName][] = $msg;
                            }
                        }
                    } catch (\Throwable $e) { /* ignore */ }
                }
            }

            if (empty($errors)) {
                try {
                    if (is_object($model)) {
                        if ($id > 0) {
                            // ensure PK is set on update (best-effort: try known conventions)
                            try {
                                $pkGuess = property_exists($model, 'attributes') ? null : null; // placeholder
                                // If the model already had the id in $existing it will remain after fill below
                            } catch (\Throwable $e) { /* ignore */ }
                        }
                        // Ensure primary key on update: try to detect pk key from existing attributes
                        if ($id > 0) {
                            $pkKey = null;
                            if (method_exists($modelClass, 'pk')) {
                                $pkKey = $modelClass::pk();
                            } else {
                                foreach ($existing as $k => $v) {
                                    if (is_numeric($v) && (int)$v === $id && stripos((string)$k, 'id') !== false) {
                                        $pkKey = $k;
                                        break;
                                    }
                                }
                                if ($pkKey === null) {
                                    foreach ($existing as $k => $_v) {
                                        if (stripos((string)$k, '_id') !== false) {
                                            $pkKey = $k;
                                            break;
                                        }
                                    }
                                }
                            }
                            if ($pkKey && !isset($normalized[$pkKey])) { $normalized[$pkKey] = $id; }
                        }
                        if (method_exists($model, 'fill')) { $model->fill($normalized); }


                        if (method_exists($model, 'save')) {
                            $model->save();
                            // Update id after insert (best effort: detect first integer-looking id in attributes)
                            if ($id <= 0) {
                                try {
                                    if (method_exists($model, 'pk')) {
                                        $pk = (string)$model->pk();
                                        if ($pk !== '') {
                                            $arr = $model->toArray();
                                            if (isset($arr[$pk]) && is_numeric($arr[$pk])) {
                                                $id = (int)$arr[$pk];
                                            }
                                        }
                                    }

                                    // Fallback to guessing if still not found
                                    if ($id <= 0) {
                                        $arr = $model->toArray();
                                        foreach ($arr as $k => $v) {
                                            if (stripos((string)$k, 'id') !== false && is_numeric($v)) {
                                                $id = (int)$v;
                                                break;
                                            }
                                        }
                                    }
                                } catch (\Throwable $e) { /* ignore */ }
                            }
                            // Allow model to handle cross-table persistence (e.g., i18n translations)
                            if (method_exists($model, 'afterSave')) {
                                try { $model->afterSave($postAll); } catch (\Throwable $e) { /* ignore */ }
                            }
                        }
                        $existing = method_exists($model, 'toArray') ? $model->toArray() : $normalized;
                        // Allow model to augment form values (e.g., populate i18n arrays from DB)
                        if ($model && method_exists($model, 'augmentFormValues')) {
                            try { $existing = (array)$model->augmentFormValues($existing); } catch (\Throwable $e) { /* ignore */ }
                        }
                        $submitted = $existing;
                        $saveSucceeded = true;

                        // Optional: custom callback after successful save
                        if (isset($options['on_save']) && is_callable($options['on_save'])) {
                            try {
                                call_user_func($options['on_save'], $model, $postAll, $id);
                            } catch (\Throwable $e) { /* ignore */ }
                        }

                        $mode = 'view';
                    }
                } catch (\Throwable $e) {
                    $saveMessage = trim((string)$e->getMessage());
                    if (
                        stripos($saveMessage, 'duplicate entry') !== false
                        && stripos($saveMessage, 'idx_coupon_code') !== false
                    ) {
                        $saveMessage = (string)mh_lng(
                            'UI_COUPON_MODAL_CODE_ALREADY_EXISTS',
                            'This coupon code already exists. Please choose another code.'
                        );
                    }
                    if ($saveMessage === '') {
                        $saveMessage = (string)mh_lng('UI_ERROR_GENERIC', 'An error occurred.');
                    }

                    if (isset($fields['coupon_code_template']) && (string)($submitted['is_template'] ?? '') === '1') {
                        $errors['coupon_code_template'][] = $saveMessage;
                    } elseif (isset($fields['coupon_code'])) {
                        $errors['coupon_code'][] = $saveMessage;
                    } else {
                        $fallbackField = array_key_first($fields);
                        if (is_string($fallbackField) && $fallbackField !== '') {
                            $errors[$fallbackField][] = $saveMessage;
                        }
                    }
                }
            }
        }

        // Resolve optional CRUD menu items defined in the model (static or instance)
        try {
            $context = [
                'mode'      => $mode,
                'id'        => $id,
                'values'    => $submitted,
                'selfRoute' => $selfRoute,
            ];
            // Prefer a static method so items can be built without an instance
            if (is_string($modelClass) && method_exists($modelClass, 'getCrudMenuItems')) {
                /** @phpstan-ignore-next-line */
                $menu = $modelClass::getCrudMenuItems($context);
                if (is_array($menu)) { $menuItems = $menu; }
            } elseif (is_string($modelClass) && method_exists($modelClass, 'crudMenuItems')) {
                /** @phpstan-ignore-next-line */
                $menu = $modelClass::crudMenuItems($context);
                if (is_array($menu)) { $menuItems = $menu; }
            } elseif (is_object($model) && method_exists($model, 'getCrudMenuItems')) {
                $menu = $model->getCrudMenuItems($context);
                if (is_array($menu)) { $menuItems = $menu; }
            } else {
                // Look for a static property like protected static $crudMenu = [...]
                try {
                    if (is_string($modelClass)) {
                        $rc = new \ReflectionClass($modelClass);
                        $defaults = $rc->getDefaultProperties();
                        $prop = $defaults['crudMenu'] ?? ($defaults['menu'] ?? null);
                        if (is_array($prop)) { $menuItems = $prop; }
                    }
                } catch (\Throwable $e) { /* ignore */ }
            }
        } catch (\Throwable $e) { /* ignore */ }

        // Emit HX-Trigger events for Pattern A (before sending any output)
        if ($saveSucceeded && $id > 0) {
            $evName = '';
            if ($reqEventOverride !== '') {
                // Explicit full event name provided by caller
                $evName = $reqEventOverride;
            } elseif ($eventPrefix !== '') {
                if ($reqEventSuffix !== '') {
                    // Narrowed scope (e.g., only refresh an expandable area)
                    $evName = $eventPrefix . ':' . ltrim($reqEventSuffix, ':');
                } else {
                    // Default behavior: created/updated
                    $evName = ($initialIdParam > 0) ? ($eventPrefix . ':updated') : ($eventPrefix . ':created');
                }
            }

            if ($evName !== '') {
                try {
                    $emitId = ($reqEventIdOverride > 0) ? $reqEventIdOverride : $id;
                    Htmx::trigger($evName, ['id' => (string)$emitId]);
                } catch (\Throwable $e) { /* ignore header errors */ }
                // If we continue to return HTML, make sure headers are sent first
                try { Htmx::flushTriggers(); } catch (\Throwable $e) { /* ignore */ }
            }
        }

        // If autoclose is requested and the save succeeded, do not render any HTML.
        // Return a no-content response so htmx performs no swap; consumers rely on the HX-Trigger above.
        if ($saveSucceeded) {
            if (self::isTruthyFlag($autoCloseRaw, true)) {
                Htmx::parentClose($panelId);
                return '';
            }
            if ($noRenderAfterSave) {
                return '';
            }
        }

        $submitLabel = (string)($options['submit_label'] ?? self::getParam('submit_label', '', 'string'));
        $submitIcon = (string)($options['submit_icon'] ?? self::getParam('submit_icon', '', 'string'));
        $cancelLabel = (string)($options['cancel_label'] ?? self::getParam('cancel_label', '', 'string'));
        $cancelAttrs = $options['cancel_attrs'] ?? null;
        $splitFooterActions = (bool)($options['split_footer_actions'] ?? self::isTruthyFlag(self::getParam('split_footer_actions', '', 'string'), false));
        $headerHtml = (string)($options['header_html'] ?? '');

        // Render
        ob_start();
        ?>
        <div id="<?php echo \Components\Base::h($panelTargetId); ?>" class="space-y-4">
          <?php if ($headerHtml !== ''): ?>
            <?php echo $headerHtml; ?>
          <?php endif; ?>
          <?php if ($mode === 'view'): ?>
            <?php echo self::renderViewPanel($engine, $fields, $groups, $ungrouped, $submitted, $panelId, $panelTargetId, $selfRoute, $id, $drawer, $fieldsParam, $uiEventPrefix, $reqEventOverride, $reqEventSuffix, $reqEventIdOverride); ?>
          <?php else: ?>
            <?php echo self::renderEditFormPanel($engine, $fields, $groups, $ungrouped, $submitted, $errors, $selfRoute, $mode, $id, $fieldsParam, $drawer, $panelType, $panelId, $panelTargetId, $autoCloseRaw, isset($allowedSet) && is_array($allowedSet) ? $allowedSet : null, $uiEventPrefix, $reqEventOverride, $reqEventSuffix, $reqEventIdOverride, $saveSucceeded, $submitLabel, $submitIcon, $cancelLabel, is_array($cancelAttrs) ? $cancelAttrs : null, $splitFooterActions); ?>
          <?php endif; ?>
        </div>
        <?php
        // On successful save (non-autoclose): update an optional list via OOB and render close script
        if ($saveSucceeded) {
            if ($oobListRoute !== '') {
                try {
                    $_GET = array_merge($_GET, $oobListParams, ['oob' => 1]);
                    $path = \Components\Router::path($oobListRoute);
                    if (is_string($path) && $path !== '') {
                        include BEEZUI_ROOT . '/' . ltrim($path, '/');
                    }
                } catch (\Throwable $e) { /* ignore */ }
            }
            // Keep legacy inline close script when not in autoclose no-content mode
            if (self::isTruthyFlag($autoCloseRaw, true)) {
                Htmx::parentClose($panelId);
            }
        }

        return ob_get_clean();
    }
    
    /**
     * Render the read-only view panel for a resource, including the Edit button.
     */
    private static function renderViewPanel($engine, array $fields, array $groups, array $ungrouped, array $values, string $panelId, string $panelTargetId, string $selfRoute, int $id, int $drawer, string $fieldsParam, string $reqEventPrefix, string $reqEventOverride, string $reqEventSuffix, int $reqEventIdOverride = 0): string
    {
        ob_start();
        echo ResourcePresentation::render([
            'engine' => $engine,
            'fields' => $fields,
            'groups' => $groups,
            'ungrouped' => $ungrouped,
            'values' => $values,
            'container' => true,
        ]);
        ?>
        <div class="flex justify-end gap-2">
          <button type="button"
                  class="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90"
                  command="show-modal" commandfor="<?php echo \Components\Base::h($panelId); ?>"
                  data-title="Edit" data-size="xl"
                  hx-get="<?php 
                    $editBtnParams = ['partial' => 1, 'mode' => 'edit', 'id' => $id, 'drawer' => $drawer ? 1 : 0];
                    if ($fieldsParam !== '') { $editBtnParams['fields'] = $fieldsParam; }
                    if ($reqEventPrefix !== '') { $editBtnParams['event_prefix'] = $reqEventPrefix; }
                    if ($reqEventOverride !== '') { $editBtnParams['event'] = $reqEventOverride; }
                    if ($reqEventSuffix !== '') { $editBtnParams['event_suffix'] = $reqEventSuffix; }
                    if ($reqEventIdOverride > 0) { $editBtnParams['ui_event_id'] = $reqEventIdOverride; }
                    echo \Components\Router::url($selfRoute, $editBtnParams); ?>"
                  hx-target="#<?php echo \Components\Base::h($panelTargetId); ?>" hx-swap="innerHTML">Edit</button>
        </div>
        <?php
        return ob_get_clean();
    }

    /**
     * Render the edit/create form panel for a resource.
     */
    private static function renderEditFormPanel($engine, array $fields, array $groups, array $ungrouped, array $submitted, array $errors, string $selfRoute, string $mode, int $id, string $fieldsParam, int $drawer, string $panelType, string $panelId, string $panelTargetId, string $autoCloseRaw, ?array $allowedSet, string $reqEventPrefix, string $reqEventOverride, string $reqEventSuffix, int $reqEventIdOverride = 0, bool $saveSucceeded = false, string $submitLabel = '', string $submitIcon = '', string $cancelLabel = '', ?array $cancelAttrs = null, bool $splitFooterActions = false): string
    {
        $hxParams = ['partial' => 1, 'mode' => $mode, 'id' => $id];
        if ($reqEventIdOverride > 0) { $hxParams['ui_event_id'] = $reqEventIdOverride; }
        if ($fieldsParam !== '') { $hxParams['fields'] = $fieldsParam; }
        $hxPost = \Components\Router::url($selfRoute, $hxParams);

        // Build a safe pass-through map for arbitrary request flags so they survive
        // the GET->POST roundtrip (e.g., updateStatusBadge=1). We exclude reserved
        // control params and only include scalar values.
        $carry = [];
        try {
            $reserved = [
                'partial','mode','id','drawer','panel_id','panel_type','panel_target_id',
                'autoclose','fields','event_prefix','event','event_suffix','ui_event_id',
                'keys','return','app','app_path','module','ui_open','flow_id'
            ];
            foreach ((array)($_GET ?? []) as $k => $v) {
                if (!is_string($k) || $k === '') { continue; }
                if (in_array($k, $reserved, true)) { continue; }
                if (strpos($k, 'hx-') === 0) { continue; }
                if (is_array($v)) { continue; }
                // Preserve the raw string value (including '0' or empty string)
                $carry[$k] = (string)$v;
            }
        } catch (\Throwable $e) { /* ignore */ }
        return ResourceEditForm::render([
            'engine' => $engine,
            'fields' => $fields,
            'groups' => $groups,
            'ungrouped' => $ungrouped,
            'submitted' => $submitted,
            'errors' => $errors,
            'hx_post' => $hxPost,
            'persist_params' =>
                ['autoclose' => $autoCloseRaw]
                + ($drawer ? ['drawer' => 1] : [])
                + ($fieldsParam !== '' ? ['fields' => $fieldsParam] : [])
                + ($reqEventPrefix !== '' ? ['event_prefix' => $reqEventPrefix] : [])
                + ($reqEventOverride !== '' ? ['event' => $reqEventOverride] : [])
                + ($reqEventSuffix !== '' ? ['event_suffix' => $reqEventSuffix] : [])
                + ($reqEventIdOverride > 0 ? ['ui_event_id' => (string)$reqEventIdOverride] : [])
                + (!empty($carry) ? ['carry' => $carry] : []),
            'drawer' => $drawer,
            'panel_type' => $panelType,
            'panel_id' => $panelId,
            'panel_target_id' => $panelTargetId,
            'autoclose' => $autoCloseRaw,
            'allowed_set' => $allowedSet,
            'saved' => $saveSucceeded,
            'submit_label' => $submitLabel !== '' ? $submitLabel : null,
            'submit_icon' => $submitIcon !== '' ? $submitIcon : null,
            'cancel_label' => $cancelLabel !== '' ? $cancelLabel : null,
            'cancel_attrs' => $cancelAttrs,
            'split_footer_actions' => $splitFooterActions,
        ]);
    }

    /**
     * Returns true when a submitted/preset value is considered empty.
     *
     * Safely handles array values (e.g. multi-selects / checkbox groups) to avoid
     * "Array to string conversion" warnings from casting arrays to string.
     *
     * @param mixed $value
     * @return bool
     */
    private static function isEmptyValue($value): bool
    {
        if ($value === null) {
            return true;
        }
        if (is_array($value)) {
            return count($value) === 0;
        }
        return trim((string)$value) === '';
    }

    /**
     * Parses a comma/space separated list of field names from $fieldsParam and filters
     * the provided $fields, $groups and $ungrouped in-place to that subset.
     * Returns the associative allowed set map or null when no valid subset provided.
     *
     * @param string $fieldsParam
     * @param array $fields
     * @param array $groups
     * @param array $ungrouped
     * @return array|null
     */
    private static function applyFieldSubset($fieldsParam, &$fields, &$groups, &$ungrouped)
    {
        $allowedSet = null;
        if ((string)$fieldsParam === '') {
            return $allowedSet;
        }

        // Build requested list without anonymous functions
        $requested = [];
        $parts = preg_split('/[\s,]+/', (string)$fieldsParam);
        foreach ((array)$parts as $s) {
            $s = trim((string)$s);
            if ($s !== '') { $requested[] = $s; }
        }

        // Composite alias expansion
        // Some UI fields are composites that render underlying atomic inputs which must be
        // persisted. When a composite is addressed via `fields=` we transparently expand it
        // to include the underlying field names so normalization and persistence keep them.
        // Current composites:
        //  - schedule_config → newsletter_item_start, newsletter_item_end, start_mode, automation_end_mode
        if (!empty($requested)) {
            $reqLc = array_map('strtolower', $requested);
            if (in_array('schedule_config', $reqLc, true)) {
                // Append underlying atomic keys; they will be mapped to real field names below
                $requested[] = 'newsletter_item_start';
                $requested[] = 'newsletter_item_end';
                $requested[] = 'start_mode';
                $requested[] = 'automation_end_mode';
            }
        }
        if (empty($requested)) {
            return $allowedSet;
        }

        // Map lower-case to real field names
        $map = [];
        foreach (array_keys($fields) as $fname) {
            $map[strtolower($fname)] = $fname;
        }
        $allowedSet = [];
        foreach ($requested as $r) {
            $key = strtolower($r);
            if (isset($map[$key])) {
                $allowedSet[$map[$key]] = true;
            }
        }
        if (empty($allowedSet)) {
            return null;
        }

        // Filter fields
        $fields = array_intersect_key($fields, $allowedSet);

        // Filter groups to only include allowed fields and drop empty groups
        $newGroups = [];
        foreach ($groups as $g) {
            if (!is_array($g)) { continue; }
            $names = [];
            foreach ((array)($g['fields'] ?? []) as $fn) {
                if (is_array($fn)) {
                    $rowOptions = $fn['_row_options'] ?? null;
                    $row = [];
                    foreach ($fn as $k => $subFn) {
                        if (!is_int($k)) continue;
                        $n = (string)$subFn;
                        if ($n !== '' && isset($allowedSet[$n])) { $row[] = $n; }
                    }
                    if (!empty($row)) {
                        if ($rowOptions) {
                            $row['_row_options'] = $rowOptions;
                        }
                        $names[] = $row;
                    }
                } else {
                    $n = (string)$fn;
                    if ($n !== '' && isset($allowedSet[$n])) { $names[] = $n; }
                }
            }
            if (!empty($names)) {
                $g['fields'] = array_values($names);
                $newGroups[] = $g;
            }
        }
        $groups = array_values($newGroups);

        // Filter ungrouped
        $newUngrouped = [];
        foreach ($ungrouped as $n) {
            $n = (string)$n;
            if ($n !== '' && isset($allowedSet[$n])) { $newUngrouped[] = $n; }
        }
        $ungrouped = array_values($newUngrouped);

        return $allowedSet;
    }
}
