<?php
// components/CampaignFlow/FlowBuilder.php
namespace Components\CampaignFlow {

    use Components\Base;

    /**
     * Psalm/PHPStan type aliases for Flow items
     *
     * @psalm-type FlowMarker = array{color:string,label?:string,icon?:string}
     * @psalm-type FlowDropdownItem = array{label?:string, href?:string, attrs?:array<string, string|int|float|bool|null>, icon?:string, divider?:bool}
     * @psalm-type FlowDropdown = array{trigger?:array{label?:string, icon?:string, class?:string, attrs?:array<string, string|int|float|bool|null>}, items?:list<FlowDropdownItem>}
     * @psalm-type FlowStep   = array{type:'step', id:string, title:string, subtitle?:string, icon?:string, color?:string, _color?:string, ring?:string, ring_color?:string, disabled?:bool, markers?:list<FlowMarker>|FlowMarker, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:FlowDropdown}
     * @psalm-type FlowWait   = array{type:'wait', label:string, icon?:string, index:int, disabled?:bool, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:FlowDropdown}
     * @psalm-type FlowCondition = array{type:'condition', label:string, icon?:string, index:int, disabled?:bool, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:FlowDropdown}
     * @psalm-type FlowDateRange = array{type:'date_range', from?:int|string|null, to?:int|string|null, label?:string, icon?:string, index:int, disabled?:bool, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:FlowDropdown}
     * @psalm-type FlowAdd    = array{type:'add', icon?:string, href?:string|null, attrs?:array<string, string|int|float|bool|null>}
     * @psalm-type FlowBranch = array{type:'branch', lanes?:list<FlowItems>, top?:FlowItems, bottom?:FlowItems, gap?:'sm'|'md'|'lg'}
     * @psalm-type FlowMerge  = array{type:'merge', lanes?:list<FlowItems>, top?:FlowItems, bottom?:FlowItems, gap?:'sm'|'md'|'lg'}
     * @psalm-type FlowItem   = FlowStep|FlowWait|FlowCondition|FlowDateRange|FlowAdd|FlowBranch|FlowMerge
     * @psalm-type FlowItems  = list<FlowItem>
     */

    /**
     * State interfaces for IDE/code analyzers.
     * These are used via intersection return types like `@return ($this&StepState)`
     * on builder methods to narrow the visible/allowed methods per state.
     */
    interface StepState
    {
        /** Step-only setters */
        /** @return $this */
        public function subtitle(string $text);

        /** @return $this */
        public function color(string $color);

        /** @return $this */
        public function marker(string $color, ?string $label = null, ?string $icon = null);

        /** @return $this */
        public function markers(array $markers);

        /** @return $this */
        public function id(string $id);

        /** @return $this */
        public function title(string $title);

        /** @return $this */
        public function icon(string $svg);

        /** @return $this */
        public function disabled(bool $disabled = true);

        /** Continue the flow (stateful returns for analyzers) */
        /** @return ($this&WaitState) */
        public function wait(string $label, array $options = []);

        /** Add a stopping condition displayed like a wait badge */
        /** @return ($this&WaitState) */
        public function condition(string $label, array $options = []);

        /**
         * Visualize a date range to be met to pass, rendered like a wait badge.
         * @return ($this&WaitState)
         */
        public function dateRange($from = null, $to = null, array $options = []);

        /**
         * Audience step – represents an audience used by newsletters.
         * @return ($this&StepState)
         */
        public function audience(string $title, array $options = []);

        /**
         * Action step – represents a follow-up action (e.g., set order status after a mail was sent).
         * @return ($this&StepState)
         */
        public function action(string $title, array $options = []);

        /** @return ($this&StepState) */
        public function step(string $id, ?string $title = null, array $options = []);

        /**
         * Mail step – convenience wrapper around step() with a preset mail icon and optional ring color.
         * Supports a ring color to indicate draft (orange) or ready (green) states.
         * You can pass either:
         *  - ring or ring_color: Tailwind ring utility (e.g., "ring-orange-500") or a palette token (e.g., "orange").
         *  - draft: bool true to force an orange ring.
         *  - ready: bool true to force a green ring.
         *  - status: 'draft'|'ready' to map to the respective ring color.
         * @return ($this&StepState)
         */
        public function mail(string $id, ?string $title = null, array $options = []);

        /**
         * Fork into multiple lanes after the current item.
         * Usage:
         *   ->branch([$lane1, $lane2, $lane3], ['gap' => 'md'])
         * Each lane is a callable: function (self $b): void
         *
         * @param array $lanes array of callables (FlowBuilder $b): void
         * @param array $options e.g. ['gap' => 'sm'|'md'|'lg']
         * @return $this
         */
        public function branch(array $lanes, array $options = []);

        /**
         * Converge multiple lanes into a single trunk that continues on the main flow.
         *
         * Usage:
         *   ->merge([$lane1, $lane2], ['gap' => 'md'])
         * Each lane is a callable: function (self $b): void
         *
         * @param array $lanes array of callables (FlowBuilder $b): void
         * @param array $options e.g. ['gap' => 'sm'|'md'|'lg']
         * @return $this
         */
        public function merge(array $lanes, array $options = []);

        /** @return ($this&AddState) */
        public function addButton(array $options = []);

        /** @return $this */
        public function addTail(bool $show);

        /** @return FlowItems */
        public function toArray(): array;
    }

    interface WaitState
    {
        /** Wait-only setters */
        /** @return $this */
        public function waitIndex(int $index);

        /** @return $this */
        public function icon(string $svg);

        /** @return $this */
        public function disabled(bool $disabled = true);

        /** Continue the flow */
        /** @return ($this&StepState) */
        public function step(string $id, ?string $title = null, array $options = []);

        /** @return ($this&WaitState) */
        public function wait(string $label, array $options = []);

        /** Add a stopping condition displayed like a wait badge */
        /** @return ($this&WaitState) */
        public function condition(string $label, array $options = []);

        /**
         * Visualize a date range to be met to pass, rendered like a wait badge.
         * @return ($this&WaitState)
         */
        public function dateRange($from = null, $to = null, array $options = []);

        /**
         * Audience step – represents an audience used by newsletters.
         * @return ($this&StepState)
         */
        public function audience(string $title, array $options = []);

        /**
         * Action step – represents a follow-up action (e.g., set order status after a mail was sent).
         * @return ($this&StepState)
         */
        public function action(string $title, array $options = []);

        /**
         * Fork into multiple lanes after the current item.
         * Usage:
         *   ->branch([$lane1, $lane2, $lane3], ['gap' => 'md'])
         * Each lane is a callable: function (self $b): void
         *
         * @param array $lanes array of callables (FlowBuilder $b): void
         * @param array $options e.g. ['gap' => 'sm'|'md'|'lg']
         * @return $this
         */
        public function branch(array $lanes, array $options = []);

        /**
         * Converge multiple lanes into a single trunk that continues on the main flow.
         *
         * Usage:
         *   ->merge([$lane1, $lane2], ['gap' => 'md'])
         * Each lane is a callable: function (self $b): void
         *
         * @param array $lanes array of callables (FlowBuilder $b): void
         * @param array $options e.g. ['gap' => 'sm'|'md'|'lg']
         * @return $this
         */
        public function merge(array $lanes, array $options = []);

        /** @return ($this&AddState) */
        public function addButton(array $options = []);

        /** @return $this */
        public function addTail(bool $show);

        /** @return FlowItems */
        public function toArray(): array;
    }

    interface AddState
    {
        /** Optional setters for the add button (icon works via shared icon()) */
        /** @return $this */
        public function icon(string $svg);

        /** End/flags */
        /** @return $this */
        public function addTail(bool $show);

        /** @return FlowItems */
        public function toArray(): array;
    }

    /**
     * FlowBuilder: fluent API to build FlowViz $items arrays reliably.
     *
     * Example usage:
     *
     * $items = FlowBuilder::make()
     *   ->trigger('Purchase', [
     *       'subtitle' => 'Trigger',
     *       'icon' => Base::SVG_TRIGGER,
     *       'color' => 'blue',
     *       'markers' => [['color' => 'bg-blue-300 text-blue-100', 'label' => 'Filter applied', 'icon' => Base::SVG_FILTER]],
     *   ])
     *   ->wait('30 days')
     *   ->step('step1', 'Reminder Email', [
     *       'subtitle' => 'Step 1',
     *       'icon' => Base::SVG_MAIL,
     *       'color' => 'indigo',
     *       'markers' => [
     *           ['color' => 'bg-orange-300', 'label' => 'Coupon', 'icon' => Base::SVG_VOUCHER],
     *           ['color' => 'bg-blue-300', 'label' => 'Filter applied', 'icon' => Base::SVG_FILTER],
     *       ],
     *   ])
     *   ->wait('60 days')
     *   ->step('step2', 'Follow-up Email', ['subtitle' => 'Step 2', 'icon' => Base::SVG_MAIL, 'color' => 'yellow'])
     *   ->wait('45 days')
     *   ->step('step3', 'Final Offer Email', ['subtitle' => 'Step 3', 'icon' => Base::SVG_MAIL, 'color' => 'green'])
     *   ->addButton() // optional explicit end button
     *   ->addTail(false) // or control implicit tail via FlowViz
     *   ->toArray();
     */
    class FlowBuilder implements StepState, WaitState, AddState
    {
        /** @var array<int,array<string,mixed>> */
        protected array $items = [];

        /** @var int auto-increment for wait indexes */
        protected int $waitIndex = 0;

        /** @var bool|null when set, will inject ['addTail' => <bool>] into first item on toArray() */
        protected ?bool $addTail = null;

        /** @var string|null current module code (e.g., 'winback_advanced') */
        protected ?string $module = null;

        public function __construct(?string $module = null)
        {
            $this->module = $module ?? $this->detectModule();
        }

        public static function make($module = null): self
        {
            return new self($module);
        }

        /**
         * Add the audience source (first step) – similar to trigger(), but tailored to audience flows.
         *
         * Supports the same click actions as step() and wait():
         *  - href: string URL to make the card a link
         *  - attrs: array of HTML attributes (e.g., command/hx-* for modal/htmx)
         *  - dropdown: Tailwind Plus Elements dropdown config
         *  - hover_icon: override the hover icon ('click', 'menu', 'view')
         *
         * @param array{id?:string,subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string} $options
         * @return ($this&StepState)
         */
        public function audienceSource(string $title, array $options = []): self
        {
            $id = (string)($options['id'] ?? 'source');
            // StepCard preferred explicit keys:
            // - steptype/stepType: primary line
            // - content: secondary line
            // Keep legacy title/subtitle in sync for compatibility.
            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? ($options['subtitle'] ?? 'Source')));
            $content = (string)($options['content'] ?? ($options['title'] ?? $title));
            $icon = (string)($options['icon'] ?? Base::SVG_OUTLINE_DB);
            // Mark variant so StepCard can style it specially (same as trigger for now)
            if (!isset($options['variant'])) {
                $options['variant'] = 'source';
            }

            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $id,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Add the audience segmentation step with sensible defaults.
         *
         * @param array{id?:string,subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string} $options
         * @return ($this&StepState)
         */
        public function audienceSegmentation(string $title = 'Segmentation', array $options = []): self
        {
            $id = (string)($options['id'] ?? 'segmentation');
            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? ($options['subtitle'] ?? '')));
            $content = (string)($options['content'] ?? ($options['title'] ?? $title));
            $icon = (string)($options['icon'] ?? Base::SVG_OUTLINE_FILTER);
            if (!isset($options['color'])) {
                $options['color'] = 'indigo';
            }

            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $id,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Add the audience result set step with placeholder count by default.
         *
         * @param array{id?:string,subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string} $options
         * @return ($this&StepState)
         */
        public function audienceResult(string $subtitle = 'Result set', array $options = []): self
        {
            $id = (string)($options['id'] ?? 'result');
            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? $subtitle));
            // Backwards compatibility:
            // historically this method used $options['subtitle'] as the *count/content* value.
            $content = (string)($options['content'] ?? ($options['title'] ?? ($options['subtitle'] ?? (string)\mh_lng('MAILBEEZ_AUDIENCE_RESULT_COUNT_PLACEHOLDER', ' —'))));
            $icon = (string)($options['icon'] ?? Base::SVG_OUTLINE_USERS);
            if (!isset($options['color'])) {
                $options['color'] = 'gray';
            }

            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $id,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Add the trigger (first step).
         *
         * Supports the same click actions as step() and wait():
         *  - href: string URL to make the trigger card a link
         *  - attrs: array of HTML attributes (e.g., command/hx-* for modal/htmx)
         *  - dropdown: Tailwind Plus Elements dropdown config
         *
         * @param array{id?:string,subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string} $options
         * @return ($this&StepState)
         */
        public function trigger(string $title, array $options = []): self
        {
            $id = (string)($options['id'] ?? 'trigger');
            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? ($options['subtitle'] ?? 'Trigger')));
            $content = (string)($options['content'] ?? ($options['title'] ?? $title));
            $icon = (string)($options['icon'] ?? Base::SVG_TRIGGER);

            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $id,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Audience step – represents an audience used by newsletters.
         * Supports same click actions as step(): href, attrs, dropdown.
         *
         * @param array{id?:string,subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string} $options
         * @return ($this&StepState)
         */
        public function audience(string $title, array $options = []): self
        {
            $id = (string)($options['id'] ?? 'audience');
            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? ($options['subtitle'] ?? 'Audience')));
            $content = (string)($options['content'] ?? ($options['title'] ?? $title));
            $icon = (string)($options['icon'] ?? Base::SVG_OUTLINE_USERS);
            // Audience should look like a trigger: rounded-full card and round icon background.
            // StepCard applies this when variant is 'trigger' (or id='trigger').
            // We keep color default explicitly to avoid StepCard's green default for triggers.
            if (!isset($options['variant'])) {
                $options['variant'] = 'trigger';
            }
            if (!isset($options['color'])) {
                $options['color'] = 'blue';
            }

            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $id,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Action step – represents a follow-up action (e.g., set order status after mail was sent).
         * Supports same click actions as step(): href, attrs, dropdown.
         *
         * @param array{id?:string,subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string, actionType?:string} $options
         * @return ($this&StepState)
         */
        public function action(string $title, array $options = []): self
        {
            $id = (string)($options['id'] ?? 'action');
            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? ($options['subtitle'] ?? 'Action')));
            $content = (string)($options['content'] ?? ($options['title'] ?? $title));
            $icon = (string)($options['icon'] ?? Base::SVG_GEAR);
            // Mark variant for potential special styling in StepCard (non-breaking if unused)
            if (!isset($options['variant'])) {
                $options['variant'] = 'action';
            }
            if (!isset($options['color'])) {
                $options['color'] = 'orange';
            }

            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $id,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Add a step item.
         *
         * @param array{subtitle?:string,stepType?:string,steptype?:string,title?:string,content?:string,icon?:string,color?:string,_color?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string, highlight?:bool, highlight_label?:string} $options
         * @return ($this&StepState)
         */
        public function step(string $id, ?string $title = null, array $options = []): self
        {
            $finalId = $id;
            if ($this->module && 0 !== strpos($id, 'step-') && 0 !== strpos($id, 'flow-')) {
                $identifier = $id;
                if (0 === strpos($id, 'step') && is_numeric(substr($id, 4))) {
                    $identifier = substr($id, 4);
                }
                $finalId = 'step-' . $this->module . '-' . $identifier;
            }

            $stepType = (string)($options['stepType'] ?? ($options['steptype'] ?? ($options['subtitle'] ?? '')));
            $content = (string)($options['content'] ?? ($options['title'] ?? $title));
            $item = array_merge(
                $options,
                [
                    'type' => 'step',
                    'id' => $finalId,
                    // explicit
                    'steptype' => $stepType,
                    'content' => $content,
                    // legacy
                    'title' => $content,
                    'subtitle' => $stepType,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Mail step – convenience wrapper around step() with preset mail icon and ring color support.
         * Draft/Ready helpers:
         *  - options['draft'] === true -> orange ring
         *  - options['ready'] === true -> green ring
         *  - options['status'] in {'draft','ready'} -> maps accordingly
         *  - options['ring'] or ['ring_color'] override the ring class or palette token directly
         *  - options['highlight']: bool true to add a pulsating highlight effect
         *  - options['highlight_label']: string label to show above the card
         *
         * @param array{subtitle?:string,icon?:string,color?:string,_color?:string,ring?:string,ring_color?:string,draft?:bool,ready?:bool,status?:string,disabled?:bool,markers?:list<array{color:string,label?:string,icon?:string}>|array{color:string,label?:string,icon?:string}, href?:string, attrs?:array<string, string|int|float|bool|null>, dropdown?:array, hover_icon?:string, highlight?:bool, highlight_label?:string} $options
         * @return ($this&StepState)
         */
        public function mail(string $id, ?string $title = null, array $options = []): self
        {
            // icon preset
            if (!isset($options['icon'])) {
                $options['icon'] = Base::SVG_MAIL;
            }
            // ring helpers + status detection
            // ring helpers
            $status = isset($options['status']) ? strtolower((string)$options['status']) : '';
            $isDraft = !empty($options['draft']) || $status === 'draft';
            $isReady = !empty($options['ready']) || $status === 'ready';

            // Only set ring token when no explicit ring override is provided
            if (!isset($options['ring']) && !isset($options['ring_color'])) {
                if ($isDraft) {
                    // Draft -> orange ring
                    $options['ring'] = 'orange';
                } elseif ($isReady) {
                    // Ready -> green ring; StepCard will resolve token to ring-green-500
                    $options['ring'] = 'green';
                }
            }

            // Create the step first
            $this->step($id, $title, $options);

            // Add a small badge/marker depending on status
            // - Draft: orange marker with label "draft"
            // - Ready: green marker with a micro check icon
            if ($isDraft) {
                $this->marker('orange', 'draft', null);
            } elseif ($isReady) {
                $this->marker('green', null, Base::SVG_MICRO_CHECK);
            }

            return $this;
        }

        /**
         * Add a wait connector with label. The index auto-increments unless provided in $options.
         *
         * @param array{index?:int,icon?:string,disabled?:bool,href?:string,attrs?:array<string, string|int|float|bool|null>,dropdown?:array} $options
         * @return ($this&WaitState)
         */
        public function wait(string $label, array $options = []): self
        {
            $index = array_key_exists('index', $options) ? (int)$options['index'] : $this->waitIndex++;
            $icon = (string)($options['icon'] ?? Base::SVG_WAIT);

            // If a numeric day offset is provided (e.g., 0, 2, -3), convert to a localized label
            // Accepts coerced numeric strings like "0", "2", "-3" due to PHP's weak typing.
            if ($label !== '' && preg_match('/^-?\d+$/', $label)) {
                $days = (int)$label;
                $label = $this->formatWaitLabelFromDays($days);
            }

            $item = array_merge(
                $options,
                [
                    'type' => 'wait',
                    'label' => $label,
                    'index' => $index,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Add a condition connector with label. Behaves like wait visually but indicates a stopping condition.
         * @param array{index?:int,icon?:string,disabled?:bool,href?:string,attrs?:array<string, string|int|float|bool|null>,dropdown?:array} $options
         * @return ($this&WaitState)
         */
        public function condition(string $label, $label_upper = null, $label_lower = null, array $options = []): self
        {
            $index = array_key_exists('index', $options) ? (int)$options['index'] : $this->waitIndex++;
            // Fallback icon if none provided: use a stop/exclamation icon inline
            $defaultIcon = Base::SVG_TRIGGER;  $icon = (string)($options['icon'] ?? $defaultIcon);

            $item = array_merge(
                $options,
                [
                    'type' => 'condition',
                    'label' => $label,

                    'label_upper' => $label_upper,
                    'label_lower' => $label_lower,

                    'index' => $index,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Visualize a date range to be met to pass, rendered like a wait badge.
         * @param int|string|null $from
         * @param int|string|null $to
         * @param array{index?:int,icon?:string,label?:string,disabled?:bool,href?:string,attrs?:array<string, string|int|float|bool|null>,dropdown?:array} $options
         * @return ($this&WaitState)
         */
        public function dateRange($from = null, $to = null, array $options = []): self
        {
            $index = array_key_exists('index', $options) ? (int)$options['index'] : $this->waitIndex++;
            $icon = (string)($options['icon'] ?? Base::SVG_OUTLINE_CALENDAR);
            $label = (string)($options['label'] ?? '');

            $item = array_merge(
                $options,
                [
                    'type' => 'date_range',
                    'from' => $from,
                    'to' => $to,
                    'label' => $label,
                    'index' => $index,
                    'icon' => $icon,
                ]
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Build a localized wait label based on a day offset.
         * - 0 ? "on date"
         * - >0 ? "%d day|%d days" (e.g., 2 ? "2 days")
         * - <0 ? "%d day before|%d days before" (e.g., -2 ? "2 days before")
         * Uses mh_lng() constants so labels are translatable, with sensible English fallbacks.
         */
        private function formatWaitLabelFromDays(int $days): string
        {
            // Zero days: exact date
            if ($days === 0) {
                /** @var string $onDate */
                $onDate = (string)(\mh_lng('MAILBEEZ_FLOW_WAIT_ON_DATE', 'on date'));
                return $onDate;
            }

            $abs = abs($days);
            $tpl = $days > 0
                ? (string)(\mh_lng('MAILBEEZ_FLOW_WAIT_DAYS', '%d day|%d days'))
                : (string)(\mh_lng('MAILBEEZ_FLOW_WAIT_DAYS_BEFORE', '%d day before|%d days before'));

            // Support singular|plural forms via pipe-separated templates.
            $parts = explode('|', $tpl, 2);
            if (count($parts) === 2) {
                $fmt = ($abs === 1) ? $parts[0] : $parts[1];
                return sprintf($fmt, $abs);
            }
            // If only a single template is provided, use it for all counts.
            return sprintf($tpl, $abs);
        }

        /**
         * Add an explicit AddTail button element.
         *
         * @param array{href?:string|null,attrs?:array<string, string|int|float|bool|null>,icon?:string} $options
         * @return ($this&AddState)
         */
        public function addButton(array $options = []): self
        {
            $icon = (string)($options['icon'] ?? Base::SVG_ADD);
            $item = array_merge(
                [
                    'type' => 'add',
                    'icon' => $icon,
                ],
                $options
            );
            $this->items[] = $item;
            return $this;
        }

        /**
         * Control FlowViz implicit tail visibility. When set, toArray() injects ['addTail' => <bool>] into first item.
         */
        public function addTail(bool $show): self
        {
            $this->addTail = $show;
            return $this;
        }

        /**
         * Convenience: set subtitle on the last step item.
         */
        public function subtitle(string $text): self
        {
            $i = $this->lastStepIndex();
            if ($i !== null) {
                $this->items[$i]['subtitle'] = $text;
                // keep explicit key in sync
                $this->items[$i]['steptype'] = $text;
            }
            return $this;
        }

        /**
         * Convenience: set icon on the last step or wait item.
         */
        public function icon(string $svg): self
        {
            $i = $this->lastItemIndex();
            if ($i !== null) {
                $this->items[$i]['icon'] = $svg;
            }
            return $this;
        }

        /**
         * Convenience: set color on the last step. Accepts Tailwind class string or palette token.
         */
        public function color(string $color): self
        {
            $i = $this->lastStepIndex();
            if ($i !== null) {
                // Prefer new 'color' key; keep legacy compatibility if needed elsewhere
                $this->items[$i]['color'] = $color;
            }
            return $this;
        }

        /**
         * Convenience: set disabled on the last item (step or wait). Visually applied in FlowViz/StepCard/WaitBadge.
         */
        public function disabled(bool $disabled = true): self
        {
            $i = $this->lastItemIndex();
            if ($i !== null) {
                $type = $this->items[$i]['type'] ?? null;
                if ($type === 'step' || $type === 'wait' || $type === 'condition' || $type === 'date_range') {
                    $this->items[$i]['disabled'] = $disabled;
                }
            }
            return $this;
        }

        /**
         * Convenience: append a marker to the last step.
         */
        public function marker(string $color, ?string $label = null, ?string $icon = null): self
        {
            $i = $this->lastStepIndex();
            if ($i !== null) {
                if (!isset($this->items[$i]['markers']) || !is_array($this->items[$i]['markers'])) {
                    $this->items[$i]['markers'] = [];
                }
                $mk = ['color' => $color];
                if ($label !== null) {
                    $mk['label'] = $label;
                }
                if ($icon !== null) {
                    $mk['icon'] = $icon;
                }
                $this->items[$i]['markers'][] = $mk;
            }
            return $this;
        }

        /**
         * Return the built items array suitable for FlowViz::render($items, ...)
         *
         * @return FlowItems
         */
        public function toArray(): array
        {
            $items = $this->items;
            if ($this->addTail !== null && !empty($items)) {
                // Propagate the addTail flag into the first item so FlowViz can pick it up
                if (!array_key_exists('addTail', $items[0])) {
                    $items[0]['addTail'] = $this->addTail;
                }
            }
            return $items;
        }

        /**
         * Create a branch with multiple lanes (2 or more).
         * Usage examples:
         *   ->branch([$lane1, $lane2])
         *   ->branch([$lane1, $lane2, $lane3], ['gap' => 'lg'])
         * Each lane is a callable receiving a fresh FlowBuilder bound to the same module.
         * Within lanes implicit tails are disabled; use addButton() inside lanes if needed.
         *
         * @param array $lanes array of callables (function (FlowBuilder $b): void)
         * @param array $options e.g. ['gap' => 'sm'|'md'|'lg']
         * @return $this
         */
        public function branch(array $lanes, array $options = []): self
        {
            // Filter to callables only
            $laneClosures = [];
            foreach ($lanes as $ln) {
                if (is_callable($ln)) {
                    $laneClosures[] = $ln;
                }
            }

            // If no lanes, do nothing
            if (count($laneClosures) === 0) {
                return $this;
            }

            $gap = isset($options['gap']) ? (string)$options['gap'] : 'md';

            // Build each lane with its own builder instance
            $lanesItems = [];
            foreach ($laneClosures as $cb) {
                $b = new self($this->module);
                $b->addTail(false);
                $cb($b);
                $lanesItems[] = $b->toArray();
            }

            $branch = [
                'type' => 'branch',
                'lanes' => $lanesItems,
                'gap' => $gap,
            ];
            if (count($lanesItems) === 2) {
                $branch['top'] = $lanesItems[0];
                $branch['bottom'] = $lanesItems[1];
            }

            $this->items[] = $branch;
            return $this;
        }

        /**
         * Create a merge with multiple lanes (2 or more) converging into the main flow.
         *
         * Usage examples:
         *   ->merge([$lane1, $lane2])
         *   ->merge([$lane1, $lane2, $lane3], ['gap' => 'lg'])
         *
         * When `merge()` is called immediately after `branch()`, the merge is treated as the
         * convergence point for the preceding branch lanes. In that case, `merge([])` will
         * create a merge connector for the same number of lanes without requiring you to
         * repeat the branch steps.
         *
         * If lane callables are provided to `merge([...])` directly after `branch()`, they are
         * interpreted as lane *continuations* (additional items appended inside each lane
         * before the merge connector), not as a second copy of the branch lane content.
         *
         * Each lane is a callable receiving a fresh FlowBuilder bound to the same module.
         * Within lanes implicit tails are disabled; use addButton() inside lanes if needed.
         *
         * @param array $lanes array of callables (function (FlowBuilder $b): void)
         * @param array $options e.g. ['gap' => 'sm'|'md'|'lg']
         * @return $this
         */
        public function merge(array $lanes, array $options = []): self
        {
            // Filter to callables only
            $laneClosures = [];
            foreach ($lanes as $ln) {
                if (is_callable($ln)) {
                    $laneClosures[] = $ln;
                }
            }

            $gap = isset($options['gap']) ? (string)$options['gap'] : 'md';

            // If merge is called right after a branch, render it as a convergence for that branch.
            // We only need the lane count here; the branch already owns the lane items.
            $prevBranchLanes = null;
            $lastIdx = $this->lastItemIndex();
            if ($lastIdx !== null) {
                $prev = $this->items[$lastIdx] ?? null;
                if (is_array($prev) && (($prev['type'] ?? null) === 'branch')) {
                    if (isset($prev['lanes']) && is_array($prev['lanes'])) {
                        $prevBranchLanes = [];
                        foreach ($prev['lanes'] as $li) {
                            $prevBranchLanes[] = is_array($li) ? $li : [];
                        }
                    } else {
                        $topItems = is_array($prev['top'] ?? null) ? $prev['top'] : [];
                        $bottomItems = is_array($prev['bottom'] ?? null) ? $prev['bottom'] : [];
                        if ($topItems !== [] || $bottomItems !== []) {
                            $prevBranchLanes = [$topItems, $bottomItems];
                        }
                    }
                }
            }

            $mergeFromBranch = is_array($prevBranchLanes) && count($prevBranchLanes) > 0;
            $branchLaneCount = $mergeFromBranch ? count($prevBranchLanes) : 0;

            // If no lanes were provided:
            // - when preceded by a branch: create a merge for the same number of lanes
            // - otherwise: do nothing (backwards compatible)
            if (count($laneClosures) === 0) {
                if ($mergeFromBranch) {
                    $laneClosures = array_fill(0, $branchLaneCount, static function (FlowBuilder $b): void {
                        // no-op lane continuation
                    });
                } else {
                    return $this;
                }
            }

            // If we are merging from the previous branch, enforce matching lane count.
            // - fewer provided: pad with no-op continuations
            // - more provided: truncate
            if ($mergeFromBranch) {
                if (count($laneClosures) < $branchLaneCount) {
                    $pad = array_fill(0, $branchLaneCount - count($laneClosures), static function (FlowBuilder $b): void {
                        // no-op lane continuation
                    });
                    $laneClosures = array_merge($laneClosures, $pad);
                } elseif (count($laneClosures) > $branchLaneCount) {
                    $laneClosures = array_slice($laneClosures, 0, $branchLaneCount);
                }
            }

            // Build each lane with its own builder instance
            $lanesItems = [];
            foreach ($laneClosures as $cb) {
                $b = new self($this->module);
                $b->addTail(false);
                $cb($b);
                $lanesItems[] = $b->toArray();
            }

            $merge = [
                'type' => 'merge',
                // If merging from a preceding branch, lanesItems are lane continuations only.
                // Rendering will combine the branch lanes + these continuations into a single block.
                'lanes' => $mergeFromBranch ? [] : $lanesItems,
                'continuations' => $mergeFromBranch ? $lanesItems : [],
                'from_branch' => $mergeFromBranch,
                'gap' => $gap,
            ];
            if (!$mergeFromBranch && count($lanesItems) === 2) {
                $merge['top'] = $lanesItems[0];
                $merge['bottom'] = $lanesItems[1];
            }

            $this->items[] = $merge;
            return $this;
        }

        /**
         * Helpers
         */
        protected function lastItemIndex(): ?int
        {
            $n = count($this->items);
            return $n ? $n - 1 : null;
        }

        protected function lastStepIndex(): ?int
        {
            for ($i = count($this->items) - 1; $i >= 0; $i--) {
                if (($this->items[$i]['type'] ?? null) === 'step') {
                    return $i;
                }
            }
            return null;
        }

        /**
         * Get the detected/current module code if available
         */
        public function getModule(): ?string
        {
            return $this->module;
        }

        /**
         * Detect current module code from request or call context.
         */
        protected function detectModule(): ?string
        {
            // 1) explicit request param
            if (function_exists('mh_get')) {
                $m = \mh_get('module', '');
                if (is_string($m) && $m !== '') {
                    return $m;
                }
                $appPath = (string)\mh_get('app_path', '');
                if ($appPath !== '') {
                    if (preg_match('~/(?:mailbeez|modules)/([a-z0-9_]+)/~i', $appPath, $m2)) {
                        return $m2[1];
                    }
                }
            }
            // 2) backtrace: find an object with ->module
            $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 16);
            foreach ($bt as $fr) {
                if (isset($fr['object']) && is_object($fr['object']) && property_exists($fr['object'], 'module')) {
                    $mod = $fr['object']->module;
                    if (is_string($mod) && $mod !== '') {
                        return $mod;
                    }
                }
            }
            return null;
        }

        /**
         * @param string|array $keys Single config key, comma-separated string, or array of keys
         * @param array $opts Optional settings: module, title, size, attrs, icon, disabled, href, dropdown, index
         * @return array
         */
        public function cfgModal($keys, array $opts = []): array
        {
            // Normalize keys
            if (is_array($keys)) {
                $keys = array_values(array_filter(array_map('strval', $keys), static fn($v) => $v !== ''));
                $keysStr = implode(',', $keys);
                $firstKey = $keys[0] ?? '';
            } else {
                $keysStr = (string)$keys;
                $firstKey = $keysStr;
                if (strpos($firstKey, ',') !== false) {
                    $firstKey = trim(strtok($firstKey, ','));
                }
            }


            // Build a localized title from the first config key
            $title = $opts['title'] ?? null;
            if ($title === null) {
                $fallbackTitle = \mh_lng('UI_EDIT_CONFIGURATION');
                $fieldTitle = ($firstKey !== '')
                    ? \mh_lng((string)$firstKey . '_TITLE', '')
                    : '';

                if ((string)$fieldTitle !== '') {
                    $title = trim((string)\mh_lng('UI_EDIT_PREFIX') . ' ' . (string)$fieldTitle);
                } else {
                    $title = $fallbackTitle;
                }
            }

            $size = (string)($opts['size'] ?? '2xl');

            // Build HTMX URL to edit the provided keys for the resolved module
            $url = \Components\Router::url('module_config', [
                'module' => $this->module,
                'mode' => 'edit',
                'partial' => 1,
                'keys' => $keysStr,
                'return' => 'module_card',
                'autoclose' => 'true'
            ]);

            $attrs = [
                'command' => 'show-modal',
                'commandfor' => 'config-modal',
                'data-title' => $title,
                'data-size' => $size,
                'hx-get' => $url,
                'hx-target' => '#config-modal-body',
                'hx-swap' => 'innerHTML',
            ];

            // Pre-set an initial height to avoid jump when HTMX content loads.
            // Height estimate is based on the number of config keys (i.e., input fields).
            // This is just a conservative guess to minimize layout shift; users may still override via $opts['attrs']['data-height'].
            try {
                // Build a normalized array of keys
                $keysArr = [];
                if (is_array($keys)) {
                    $keysArr = array_values(array_filter(array_map('trim', $keys), static fn($v) => $v !== ''));
                } else {
                    $keysArr = array_values(array_filter(array_map('trim', explode(',', (string)$keysStr)), static fn($v) => $v !== ''));
                }

                // Heuristic: base space + per-field space, with min and an upper cap switching to viewport height
                $basePx = 240;   // header, padding, spacing
                $defaultPerPx  = 84;   // approx. per normal field (label + input + gap)
                $orderStatusPerPx = 220; // special height for *_ORDER_STATUS fields
                $minPx  = 200;   // never smaller than this
                $vhCap  = 80;    // switch to 80vh for larger forms
                $switchToVhAtPx = 720; // threshold where we prefer a viewport-relative height

                // Sum per-field estimated heights, using 220px for keys ending in _ORDER_STATUS
                $fieldsSum = 0;
                if (empty($keysArr)) {
                    // assume at least one normal field if nothing provided
                    $fieldsSum = $defaultPerPx;
                } else {
                    foreach ($keysArr as $k) {
                        $fieldsSum += (preg_match('/_ORDER_STATUS$/', $k) === 1) ? $orderStatusPerPx : $defaultPerPx;
                    }
                }

                $estPx = max($minPx, $basePx + $fieldsSum);
                $heightStr = ($estPx >= $switchToVhAtPx) ? ($vhCap . 'vh') : ($estPx . 'px');

                // Only set if not already specified by caller
                // Note: we set a default now; if $opts['attrs'] contains data-height it will override below.
                $attrs['data-height'] = $heightStr;
            } catch (\Throwable $e) {
                // ignore any estimation errors and omit data-height
            }

            if (!empty($opts['attrs']) && is_array($opts['attrs'])) {
                foreach ($opts['attrs'] as $k => $v) {
                    $attrs[$k] = $v;
                }
            }

            $result = ['attrs' => $attrs];
            foreach (['icon', 'disabled', 'href', 'dropdown', 'index'] as $passthru) {
                if (array_key_exists($passthru, $opts)) {
                    $result[$passthru] = $opts[$passthru];
                }
            }

            return $result;
        }

        /**
         * Set the id on the last step item.
         */
        public function id(string $id): self
        {
            $i = $this->lastStepIndex();
            if ($i !== null) {
                $this->items[$i]['id'] = $id;
            }
            return $this;
        }

        /**
         * Set the title on the last step item.
         */
        public function title(string $title): self
        {
            $i = $this->lastStepIndex();
            if ($i !== null) {
                $this->items[$i]['title'] = $title;
                // keep explicit key in sync
                $this->items[$i]['content'] = $title;
            }
            return $this;
        }

        /**
         * Replace markers on the last step item.
         */
        public function markers(array $markers): self
        {
            $i = $this->lastStepIndex();
            if ($i !== null) {
                $this->items[$i]['markers'] = [];
                foreach ($markers as $mk) {
                    if (is_array($mk)) {
                        $this->items[$i]['markers'][] = $mk;
                    } elseif (is_string($mk)) {
                        $this->items[$i]['markers'][] = ['color' => $mk];
                    }
                }
            }
            return $this;
        }

        /**
         * Set the index on the last wait item.
         */
        public function waitIndex(int $index): self
        {
            $i = $this->lastItemIndex();
            if ($i !== null && in_array(($this->items[$i]['type'] ?? null), ['wait','condition','date_range'], true)) {
                $this->items[$i]['index'] = $index;
            }
            return $this;
        }
    }
}

