<?php

namespace Components\Models;

use mb_newsletter;
use Components\Newsletter\PageConfig;

require_once(MH_DIR_FS_CATALOG . MH_ROOT_PATH . 'mailbeez/mb_newsletter.php');

class NewsletterItem extends BaseModel
{
    protected static string $table = 'TABLE_MAILBEEZ_NEWSLETTER_ITEMS';
    protected static string $primaryKey = 'newsletter_item_id';

    protected static string $blueprint = 'newsletter_item.yaml';

    protected static ?string $cacheColumn = 'newsletter_item_meta';

    private static ?\mb_newsletter $mbNewsletterModule = null;
    private bool $flowStatusTouchedInSave = false;

    private const SENTINEL_NEWSLETTER_ITEM_START = '1000-01-01';
    private const SENTINEL_NEWSLETTER_ITEM_END = '9999-12-31';
    private const SENTINEL_COMPLETED_AT = '9999-12-31';

    public static function mbNewsletter(): \mb_newsletter
    {
        if (static::$mbNewsletterModule instanceof \mb_newsletter) {
            return static::$mbNewsletterModule;
        }

        static::$mbNewsletterModule = new mb_newsletter();
        return static::$mbNewsletterModule;
    }

    // Router is used to build URLs for UI resources (ItemList options)
    // Import here to allow static helpers to provide unified render options.
    // Layering note: this is intentional per issue requirement to centralize UI options in the model.

    /**
     * Default values for new newsletter item records (used by CrudController on GET/new).
     */
    public static function getNewDefaults(): array
    {
        $defaults = [];
        try {
            // Read optional URL parameter to choose a preset schedule
            // Accepted names (first match wins): schedule, preset, type, schedule_type
            $preset = (string) (\Components\Common\Request::get('schedule', '', 'string')
                ?: \Components\Common\Request::get('preset', '', 'string')
                ?: \Components\Common\Request::get('type', '', 'string')
                ?: \Components\Common\Request::get('schedule_type', '', 'string'));

            $pageMode = (string) \Components\Common\Request::get('page_mode', '', 'string');
            if ($preset === '' && $pageMode === 'automation') {
                $preset = 'automation';
            }

            $p = strtolower(trim($preset));

            // Compute simple date defaults (date-only to match <input type="date">)
            $tomorrow = new \DateTime('tomorrow');
            $dayAfter = (clone $tomorrow)->modify('+7 days');
            $dStart = $tomorrow->format('Y-m-d');
            $dEnd   = $dayAfter->format('Y-m-d');

            if (in_array($p, ['bulk', 'bulk_mail', 'bulk-mail', 'mass', 'blast'], true)) {
                // Bulk mail: starts now, ends when completed (no end date)
                $defaults['schedule_type'] = 'bulk_mail';
                $defaults['start_mode'] = 'now';
                // keep a reasonable start date value (not shown when start_mode=now)
                $defaults['newsletter_item_start'] = $dStart;
                $defaults['newsletter_item_end'] = '';
                $defaults['automation_end_mode'] = 'never';
            } elseif (in_array($p, ['scheduled', 'schedule', 'window', 'scheduled_window'], true)) {
                // Scheduled window: explicit start and end dates
                $defaults['schedule_type'] = 'scheduled_window';
                $defaults['start_mode'] = 'date';
                $defaults['newsletter_item_start'] = $dStart;
                $defaults['newsletter_item_end'] = $dEnd;
                $defaults['automation_end_mode'] = 'never';
            } elseif (in_array($p, ['automation', 'auto', 'automated'], true)) {
                // Automation: starts now, end never
                $defaults['schedule_type'] = 'automation';
                $defaults['start_mode'] = 'now';
                $defaults['newsletter_item_start'] = $dStart;
                $defaults['newsletter_item_end'] = '';
                $defaults['automation_end_mode'] = 'never';
            } else {
                // Backward-compatible fallback (previous behavior)
                // Default should match BeezUI screenshot: Bulk_mail, Start Now, End Completed
                $defaults['newsletter_item_start'] = $dStart; // keep a reasonable date value
                $defaults['newsletter_item_end'] = '';        // empty => "Completed" selected for bulk
                $defaults['start_mode'] = 'now';
                $defaults['schedule_type'] = 'bulk_mail';
                $defaults['automation_end_mode'] = 'never';
            }
        } catch (\Throwable $e) { /* ignore */ }
        return $defaults;
    }

    /**
     * By default return only items of type mb_newsletter::ITEM_TYPE_DEFAULT.
     * Pass $onlyDefault = false to fetch all items (legacy BaseModel::all behavior).
     * @param bool $onlyDefault
     * @return array<static>
     */
    public function initializeFromTemplate(string $templateId): void
    {
        $mb = static::mbNewsletter();
        $id = (int)$this->newsletter_item_id;
        if ($id <= 0) return;

        $moduleId = $mb::get_newsletter_module_id($id);

        if (!class_exists('template_manager')) {
            require_once(MH_DIR_FS_CATALOG . MH_ROOT_PATH . 'configbeez/config_editor/classes/template_manager.php');
        }
        $tm = new \template_manager();
        $tm->set_template_location($mb->template_dir);

        $shared_path = MH_DIR_FS_CATALOG . MH_ROOT_PATH . 'configbeez/config_editor/template_library/body/basic/shared/';
        $tm->smarty->assign('shared_dir', $shared_path);

        if (function_exists('mh_get_language_list')) {
            $cfg_languages_array = mh_get_language_list();
        } else {
            $cfg_languages_array = [['id' => (int)mh_session('languages_id', 1), 'directory' => $_SESSION['language']]];
        }

        // Body
        list($bodyContentAll,) = $tm->read_file_template($templateId, 'template', true);
        // Subject
        list($subjectContentAll,) = $tm->read_file_template($templateId, 'subject', true);
        // Preheader
        list($preheaderContentAll,) = $tm->read_file_template($templateId, 'preheader', true);

        foreach ($cfg_languages_array as $cfg_lng_item) {
            $lngId = (int)$cfg_lng_item['id'];

            // Body
            $htmlTemplate = is_array($bodyContentAll) && isset($bodyContentAll[$lngId]) && $bodyContentAll[$lngId] !== -39 ? $bodyContentAll[$lngId] : '';

            // Subject
            $subject = is_array($subjectContentAll) && isset($subjectContentAll[$lngId]) && $subjectContentAll[$lngId] !== -39 ? $subjectContentAll[$lngId] : \mh_lng('TEXT_NEWSLETTER_CREATE_SUBJECT', 'Newsletter');

            // Preheader
            $preheader = is_array($preheaderContentAll) && isset($preheaderContentAll[$lngId]) && $preheaderContentAll[$lngId] !== -39 ? $preheaderContentAll[$lngId] : \mh_lng('TEXT_NEWSLETTER_CREATE_PREHEADER', '');

            $mb->update_newsletter_object($moduleId, $lngId, 'subjectTemplate', $subject);
            $mb->update_newsletter_object($moduleId, $lngId, 'preheaderTemplate', $preheader);
//            $mb->update_newsletter_object($moduleId, $lngId, 'txtBodyTemplate', $txtTemplate); // do not prefill txtBodyTemplate, it is generated unless edited
            $mb->update_newsletter_object($moduleId, $lngId, 'htmlBodyTemplate', $htmlTemplate);
        }
    }

    public static function all(bool $onlyDefault = true): array
    {
        if (!$onlyDefault) {
            return parent::all();
        }
        if (self::isDev()) return [];

        $types = static::itemTypeDefs();
        $defaultType = (int)($types['default'] ?? 0);

        $sql = "SELECT * FROM " . static::table() .
            " WHERE newsletter_item_type = {$defaultType} ORDER BY " . static::pk() . " DESC";
        $q = \mh_db_query($sql);

        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new static($row);
        }
        \mh_db_free_result($q);

        return $items;
    }

    /**
     * Find items that have at least one schedule boundary set.
     * Performance: only selects columns needed for calendar rendering.
     *
     * @return static[]
     */
    public static function findForCalendar(): array
    {
        if (self::isDev()) return [];

        $table = static::table();

        // Performance: select only required columns for calendar events.
        // BaseModel::update() handles missing columns safely (only present attributes are updated).
        $columns = [
            'i.newsletter_item_id',
            'i.newsletter_campaign_id',
            'i.newsletter_item_name',
            'i.newsletter_item_start',
            'i.newsletter_item_end',
            'i.newsletter_followup_id',
            'i.newsletter_item_type',
            'i.status',
            'i.flow_status',
            'i.stop_when_finished',
            'i.completed_at',
            'i.date_added',
            'i.last_modified',
        ];

        // Check for optional schedule_type column
        static $hasScheduleType = null;
        if ($hasScheduleType === null) {
            $hasScheduleType = false;
            try {
                $q = \mh_db_query("SHOW COLUMNS FROM {$table} LIKE 'schedule_type'");
                if ($q) {
                    $row = \mh_db_fetch_array($q);
                    $hasScheduleType = is_array($row) && !empty($row);
                    \mh_db_free_result($q);
                }
            } catch (\Throwable $e) {}
        }
        if ($hasScheduleType) {
            $columns[] = 'i.schedule_type';
        }

        $conditions = [
            // Roots only (consistent with fetchRoots smart filters)
            "COALESCE(i.newsletter_followup_id, 0) = 0",
            // Exclude items in archived campaigns (consistent with fetchRoots default)
            "COALESCE(c.newsletter_campaign_archived, 0) = 0",
            // Only items that are active, scheduled, or completed
            '(' . implode(' OR ', [
                self::sqlIsActiveItemCondition('i'),
                self::sqlIsScheduledItemCondition('i'),
                self::sqlIsCompletedItemCondition('i'),
            ]) . ')',
        ];

        $sql = "SELECT " . implode(', ', $columns) . " FROM {$table} i
                LEFT JOIN " . NewsletterCampaign::table() . " c ON (i.newsletter_campaign_id = c.newsletter_campaign_id)
                WHERE " . implode(' AND ', $conditions) . "
                ORDER BY i.newsletter_item_id DESC";

        $q = \mh_db_query($sql);
        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new static($row);
        }
        \mh_db_free_result($q);

        return $items;
    }

    /**
     * SQL helper: active/runnable newsletter item condition for a given table alias.
     *
     * Active means:
     * - status is enabled,
     * - not completed,
     * - start date is unset or <= now,
     * - end date is unset or >= now (or schedule type is automation).
     */
    private static function sqlParsedDateExpression(string $fieldExpr): string
    {
        return "COALESCE(STR_TO_DATE({$fieldExpr}, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE({$fieldExpr}, '%Y-%m-%d'))";
    }

    private static function sqlDateIsSentinel(string $fieldExpr, string $sentinel): string
    {
        $sentinelWithTime = $sentinel . ' 00:00:00';
        return "(CAST({$fieldExpr} AS CHAR(19)) IN ('{$sentinel}', '{$sentinelWithTime}'))";
    }

    private static function sqlDateIsNotSentinel(string $fieldExpr, string $sentinel): string
    {
        return 'NOT ' . self::sqlDateIsSentinel($fieldExpr, $sentinel);
    }

    private static function sentinelValueForDateColumn(string $column): ?string
    {
        switch ($column) {
            case 'newsletter_item_start':
                return self::SENTINEL_NEWSLETTER_ITEM_START;
            case 'newsletter_item_end':
                return self::SENTINEL_NEWSLETTER_ITEM_END;
            case 'completed_at':
                return self::SENTINEL_COMPLETED_AT;
            default:
                return null;
        }
    }

    private static function isSentinelDateValue($value, string $sentinel): bool
    {
        if ($value === null) {
            return false;
        }

        $s = trim((string)$value);
        if ($s === '') {
            return false;
        }

        return $s === $sentinel || strpos($s, $sentinel . ' ') === 0;
    }

    private static function normalizeDateColumnForStorage(string $column, $value)
    {
        $sentinel = self::sentinelValueForDateColumn($column);
        if ($sentinel === null) {
            return $value;
        }

        if ($value === null || $value === '') {
            return $sentinel;
        }

        if (self::isSentinelDateValue($value, $sentinel)) {
            return $sentinel;
        }

        return $value;
    }

    private static function normalizeDateColumnForUi($value, string $column): string
    {
        $sentinel = self::sentinelValueForDateColumn($column);
        if ($sentinel !== null && self::isSentinelDateValue($value, $sentinel)) {
            return '';
        }

        $normalized = static::normalizeDateYmd($value);
        return $normalized ?? '';
    }

    protected static function parseScheduleBoundaryTimestamp($v, bool $isEnd = false): ?int
    {
        if ($v === null || $v === '') {
            return null;
        }
        if (is_int($v)) {
            return $v;
        }
        if (is_string($v)) {
            $s = trim($v);
            if ($s === '') {
                return null;
            }

            if ($isEnd) {
                if (self::isSentinelDateValue($s, self::SENTINEL_NEWSLETTER_ITEM_END)) {
                    return null;
                }
            } else {
                if (self::isSentinelDateValue($s, self::SENTINEL_NEWSLETTER_ITEM_START)) {
                    return null;
                }
            }

            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
                $ts = @strtotime($s . ($isEnd ? ' 23:59:59' : ' 00:00:00'));
                return $ts !== false ? (int)$ts : null;
            }
            $ts = @strtotime($s);
            return $ts !== false ? (int)$ts : null;
        }
        return null;
    }

    private static function sqlIsActiveItemCondition(string $alias = ''): string
    {
        $p = $alias !== '' ? (rtrim($alias, '.') . '.') : '';
        $startExpr = self::sqlParsedDateExpression("{$p}newsletter_item_start");
        $conditions = [
            "{$p}status = 'True'",
            "{$p}flow_status = 'True'",
            self::sqlDateIsSentinel("{$p}completed_at", self::SENTINEL_COMPLETED_AT),
            "{$startExpr} <= NOW()",
            self::sqlIsEndValidCondition($alias),
        ];

        return '(' . implode(' AND ', $conditions) . ')';
    }

    private static function sqlIsScheduledItemCondition(string $alias = ''): string
    {
        $p = $alias !== '' ? (rtrim($alias, '.') . '.') : '';
        $startExpr = self::sqlParsedDateExpression("{$p}newsletter_item_start");
        $conditions = [
            "{$p}status = 'True'",
            "{$p}flow_status = 'True'",
            self::sqlDateIsSentinel("{$p}completed_at", self::SENTINEL_COMPLETED_AT),
            "{$startExpr} > NOW()",
            self::sqlIsEndValidCondition($alias),
        ];

        return '(' . implode(' AND ', $conditions) . ')';
    }

    private static function sqlIsCompletedItemCondition(string $alias = '', int $timeframeDays = 0): string
    {
        $p = $alias !== '' ? (rtrim($alias, '.') . '.') : '';
        $endExpr = self::sqlParsedDateExpression("{$p}newsletter_item_end");
        $completedAtExpr = self::sqlParsedDateExpression("{$p}completed_at");

        $completedPart = self::sqlDateIsNotSentinel("{$p}completed_at", self::SENTINEL_COMPLETED_AT);
        $endedPart = sprintf('(%s AND %s < NOW())',
            self::sqlDateIsNotSentinel("{$p}newsletter_item_end", self::SENTINEL_NEWSLETTER_ITEM_END),
            $endExpr
        );

        if ($timeframeDays > 0) {
            $dateLimit = sprintf("DATE_SUB(NOW(), INTERVAL %d DAY)", $timeframeDays);
            $completedPart = sprintf('(%s AND %s >= %s)', $completedPart, $completedAtExpr, $dateLimit);
            $endedPart = sprintf('(%s AND %s >= %s)', $endedPart, $endExpr, $dateLimit);
        }

        $completedOrEnded = '(' . implode(' OR ', [$completedPart, $endedPart]) . ')';

        return '(' . implode(' AND ', [
            $completedOrEnded,
            'NOT ' . self::sqlIsAggregatedRootFlowActiveCondition($alias)
        ]) . ')';
    }

    private static function sqlIsEndValidCondition(string $alias = ''): string
    {
        $p = $alias !== '' ? (rtrim($alias, '.') . '.') : '';
        $endExpr = self::sqlParsedDateExpression("{$p}newsletter_item_end");
        return sprintf(
            '(%s OR %s >= NOW())',
            self::sqlDateIsSentinel("{$p}newsletter_item_end", self::SENTINEL_NEWSLETTER_ITEM_END),
            $endExpr
        );
    }

    /**
     * SQL helper: root flow toggle is enabled (NULL/empty treated as enabled).
     */
    private static function sqlIsRootFlowEnabledCondition(string $alias = ''): string
    {
        $p = $alias !== '' ? (rtrim($alias, '.') . '.') : '';
        return "({$p}flow_status = 'True')";
    }

    /**
     * SQL helper: aggregated root flow activity.
     *
     * A root flow is considered active when the root flow toggle is enabled and either:
     * - the root item itself is active, or
     * - at least one follow-up item for this root (`newsletter_root_id`) is active.
     */
    private static function sqlIsAggregatedRootFlowActiveCondition(string $rootAlias = ''): string
    {
        $p = $rootAlias !== '' ? (rtrim($rootAlias, '.') . '.') : '';
        $rootId = $p . static::pk();
        $table = static::table();
        $existsFollowupActive = sprintf(
            'EXISTS (SELECT 1 FROM %s f WHERE f.newsletter_root_id = %s AND COALESCE(f.newsletter_followup_id, 0) > 0 AND %s)',
            $table,
            $rootId,
            self::sqlIsActiveItemCondition('f')
        );
        $activeInRootOrFollowup = '(' . implode(' OR ', [
            self::sqlIsActiveItemCondition($rootAlias),
            $existsFollowupActive,
        ]) . ')';

        return '(' . implode(' AND ', [
            self::sqlIsRootFlowEnabledCondition($rootAlias),
            $activeInRootOrFollowup,
        ]) . ')';
    }

    private static function dbTruthy($value): bool
    {
        if (is_bool($value)) {
            return $value;
        }

        if (is_int($value) || is_float($value)) {
            return ((int)$value) > 0;
        }

        $s = strtolower(trim((string)$value));
        return in_array($s, ['1', 'true', 'on', 'yes', 'y'], true);
    }

    /**
     * Resolve aggregated root-flow activity for a given root item.
     * Reuses preselected `aggregated_flow_active` when present to avoid extra queries.
     */
    private static function isAggregatedRootFlowActive(self $item): bool
    {
        if (!$item->isInitial()) {
            return false;
        }

        $precomputed = $item->attributes['aggregated_flow_active'] ?? ($item->aggregated_flow_active ?? null);
        if ($precomputed !== null && $precomputed !== '') {
            return self::dbTruthy($precomputed);
        }

        if (self::isDev()) {
            return false;
        }

        $id = (int)($item->{static::pk()} ?? 0);
        if ($id <= 0) {
            return false;
        }

        $sql = sprintf(
            'SELECT CASE WHEN %s THEN 1 ELSE 0 END AS aggregated_flow_active FROM %s r WHERE r.%s = %d LIMIT 1',
            self::sqlIsAggregatedRootFlowActiveCondition('r'),
            static::table(),
            static::pk(),
            $id
        );

        $q = \mh_db_query($sql);
        $row = \mh_db_fetch_array($q);
        \mh_db_free_result($q);

        return self::dbTruthy($row['aggregated_flow_active'] ?? 0);
    }

    /**
     * Fetch a filtered set of root Newsletter Items directly from the database (no in-PHP filtering).
     * This is used by the BeezUI list to avoid loading the full dataset when a group/smart filter
     * is active.
     *
     * Options (all optional):
     *  - campaign_id: int|null   Only items belonging to this campaign id
     *  - smart: 'recent'|'active'|'scheduled'|'draft'|null   Apply a pre-defined smart filter
     *  - limit: int|null         Max rows to return (defaults to 10 for 'recent')
     *  - onlyDefault: bool       Limit to ITEM_TYPE_DEFAULT (default true)
     *
     * Notes on smart filters (DB-level approximations of schedule state):
     *  - recent:    order by id desc limit N (recently created/modified by id)
     *  - active:    status='True' AND flow_status='True' AND completed_at is unset AND start <= NOW() AND (end is unset OR end >= NOW())
     *  - scheduled: same as active, but start date is in the future
     *  - draft:     status != 'True' (disabled/inactive)
     *  - paused:    status='True' AND flow_status='False'
     *
     * @param array{campaign_id?:?int, list_id?:?int, schedule_type?:?string|array, smart?:?string, limit?:?int, onlyDefault?:bool} $opts
     * @return array<static>
     */
    public static function fetchRoots(array $opts = []): array
    {
        $campaignId   = isset($opts['campaign_id']) ? (int)$opts['campaign_id'] : null;
        $listId       = isset($opts['list_id']) ? (int)$opts['list_id'] : null;
        $scheduleType = isset($opts['schedule_type']) ? $opts['schedule_type'] : null;
        $smart        = isset($opts['smart']) ? (string)$opts['smart'] : null;
        $limitOpt     = isset($opts['limit']) ? (int)$opts['limit'] : null;
        $timeframe    = isset($opts['timeframe']) ? (int)$opts['timeframe'] : 0;
        $onlyDefault = array_key_exists('onlyDefault', $opts) ? (bool)$opts['onlyDefault'] : true;

        $types = static::itemTypeDefs();
        $defaultType = (int)($types['default'] ?? 0);

        $where = [];
        // Only default (root) items: type default and no followup id
        if ($onlyDefault) {
            $where[] = "i.newsletter_item_type = {$defaultType}";
        }
        $where[] = "COALESCE(i.newsletter_followup_id, 0) = 0"; // ensure roots

        if ($campaignId !== null && $campaignId > 0) {
            $where[] = "i.newsletter_campaign_id = " . (int)$campaignId;
        } else {
            // Exclude items in archived campaigns unless we explicitly requested a specific campaign
            $where[] = "COALESCE(c.newsletter_campaign_archived, 0) = 0";
        }

        if ($listId !== null && $listId > 0) {
            $where[] = "i.newsletter_list_id = " . (int)$listId;
        }

        if ($scheduleType !== null) {
            if (is_array($scheduleType)) {
                $where[] = "i.schedule_type IN ('" . implode("','", array_map('\\mh_db_input', $scheduleType)) . "')";
            } else {
                $where[] = "i.schedule_type = '" . \mh_db_input((string)$scheduleType) . "'";
            }
        }

        $order = "i." . static::pk() . " DESC";
        $limit = null;

        // Smart collections
        switch ($smart) {
            case 'recent':
                // Recently edited: ordered by last_modified (or date_added if never edited) DESC, limited to N rows
                $order = "COALESCE(i.last_modified, i.date_added) DESC";
                $limit = $limitOpt !== null && $limitOpt > 0 ? $limitOpt : 50;
                if ($timeframe > 0) {
                    $where[] = sprintf("COALESCE(i.last_modified, i.date_added) >= DATE_SUB(NOW(), INTERVAL %d DAY)", $timeframe);
                }
                break;
            case 'active':
                // Active: status enabled, flow running, not completed, started, and not ended
                $order = "i." . static::pk() . " DESC";
                $where[] = self::sqlIsActiveItemCondition('i');
                break;
            case 'scheduled':
                // Scheduled: same base criteria as active, but start date is in the future
                $order = "i." . static::pk() . " DESC";
                $where[] = self::sqlIsScheduledItemCondition('i');
                break;
            case 'draft':
                // Draft: status is not enabled
                $order = "COALESCE(i.last_modified, i.date_added) DESC";
                $where[] = "i.status <> 'True'";
                if ($timeframe > 0) {
                    $where[] = sprintf("COALESCE(i.last_modified, i.date_added) >= DATE_SUB(NOW(), INTERVAL %d DAY)", $timeframe);
                }
                break;
            case 'paused':
                // Paused: status enabled but flow is paused
                $order = "i." . static::pk() . " DESC";
                $where[] = "i.status = 'True'";
                $where[] = "i.flow_status = 'False'";
                break;
            case 'completed':
                // Completed/ended: completed_at is set OR end date is in the past
                $order = "i." . static::pk() . ' DESC';
                $where[] = self::sqlIsCompletedItemCondition('i', $timeframe);
                break;
            case 'favorite':
                // Starred/favorite items
                $order = "COALESCE(i.last_modified, i.date_added) DESC";
                $where[] = "i.favorite = 1";
                break;
        }

        $sqlParts = [
            "SELECT i.*, CASE WHEN " . self::sqlIsAggregatedRootFlowActiveCondition('i') . " THEN 1 ELSE 0 END AS aggregated_flow_active",
            'FROM ' . static::table() . ' i',
            'LEFT JOIN ' . NewsletterCampaign::table() . ' c ON (i.newsletter_campaign_id = c.newsletter_campaign_id)',
        ];
        if ($where) {
            $sqlParts[] = 'WHERE ' . implode(' AND ', $where);
        }
        $sqlParts[] = 'ORDER BY ' . $order;
        if ($limit !== null) {
            $sqlParts[] = 'LIMIT ' . (int)$limit;
        }
        $sql = implode(' ', $sqlParts);

        $q = \mh_db_query($sql);
        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new static($row);
        }
        \mh_db_free_result($q);
        return $items;
    }

    /**
     * Return the COUNT of root default items matching a given smart filter using a single SQL COUNT query.
     * Mirrors the WHERE clauses used in fetchRoots() for consistent counts without loading full rows.
     *
     * Supported smart types: 'recent', 'active', 'scheduled', 'draft', 'paused', 'completed', 'favorite'
     * For 'recent' the count equals min($limit, total) — we do a plain COUNT and cap at the limit.
     *
     * @param string $smart      One of 'recent'|'active'|'scheduled'|'draft'|'paused'|'completed'|'favorite'
     * @param int    $limit      Only used for 'recent' to cap the result
     * @param int    $timeframe  Optional timeframe in days to filter 'recent', 'draft', 'completed'
     * @return int
     */
    public static function countSmart(string $smart, int $limit = 10, int $timeframe = 0, ?array $allowedScheduleTypes = null): int
    {
        $types = static::itemTypeDefs();
        $defaultType = (int)($types['default'] ?? 0);

        $where = [];
        $where[] = "i.newsletter_item_type = {$defaultType}";
        $where[] = "COALESCE(i.newsletter_followup_id, 0) = 0";

        // Exclude items in archived campaigns from smart collections
        $where[] = "COALESCE(c.newsletter_campaign_archived, 0) = 0";

        if ($allowedScheduleTypes !== null && count($allowedScheduleTypes) > 0) {
            $where[] = "i.schedule_type IN ('" . implode("','", array_map('\\mh_db_input', $allowedScheduleTypes)) . "')";
        }

        switch ($smart) {
            case 'recent':
                if ($timeframe > 0) {
                    $where[] = sprintf("COALESCE(i.last_modified, i.date_added) >= DATE_SUB(NOW(), INTERVAL %d DAY)", $timeframe);
                }
                // Count all roots, then cap at $limit
                $sql = sprintf(
                        'SELECT COUNT(*) as cnt FROM %s i LEFT JOIN %s c ON (i.newsletter_campaign_id = c.newsletter_campaign_id) WHERE %s',
                        static::table(),
                        NewsletterCampaign::table(),
                        implode(' AND ', $where)
                );
                $q = \mh_db_query($sql);
                $row = \mh_db_fetch_array($q);
                \mh_db_free_result($q);
                return (int)($row['cnt'] ?? 0);

            case 'active':
                $where[] = self::sqlIsActiveItemCondition('i');
                break;

            case 'scheduled':
                $where[] = self::sqlIsScheduledItemCondition('i');
                break;

            case 'draft':
                $where[] = "i.status <> 'True'";
                if ($timeframe > 0) {
                    $where[] = sprintf("COALESCE(i.last_modified, i.date_added) >= DATE_SUB(NOW(), INTERVAL %d DAY)", $timeframe);
                }
                break;

            case 'paused':
                $where[] = "i.status = 'True'";
                $where[] = "i.flow_status = 'False'";
                break;

            case 'completed':
                // Completed/ended: completed_at is set OR end date is in the past
                $where[] = self::sqlIsCompletedItemCondition('i', $timeframe);
                break;

            case 'favorite':
                $where[] = "i.favorite = 1";
                break;

            default:
                return 0;
        }

        $sql = sprintf(
            'SELECT COUNT(*) as cnt FROM %s i LEFT JOIN %s c ON (i.newsletter_campaign_id = c.newsletter_campaign_id) WHERE %s',
            static::table(),
            NewsletterCampaign::table(),
            implode(' AND ', $where)
        );


        $q = \mh_db_query($sql);
        $row = \mh_db_fetch_array($q);
        \mh_db_free_result($q);
        return (int)($row['cnt'] ?? 0);
    }

    /**
     * Return total count of all root default items using a single SQL COUNT query.
     * @return int
     */
    public static function countAll(?array $allowedScheduleTypes = null): int
    {
        if (self::isDev()) return 0;

        $types = static::itemTypeDefs();
        $defaultType = (int)($types['default'] ?? 0);

        $where = [];
        $where[] = "i.newsletter_item_type = {$defaultType}";
        $where[] = "COALESCE(i.newsletter_followup_id, 0) = 0";

        // Exclude items in archived campaigns from total count
        $where[] = "COALESCE(c.newsletter_campaign_archived, 0) = 0";

        if ($allowedScheduleTypes !== null && count($allowedScheduleTypes) > 0) {
            $where[] = "i.schedule_type IN ('" . implode("','", array_map('\\mh_db_input', $allowedScheduleTypes)) . "')";
        }

        $sql = sprintf(
            'SELECT COUNT(*) FROM %s i LEFT JOIN %s c ON (i.newsletter_campaign_id = c.newsletter_campaign_id) WHERE %s',
            static::table(),
            NewsletterCampaign::table(),
            implode(' AND ', $where)
        );
        $q = \mh_db_query($sql);
        $row = \mh_db_fetch_array($q);
        \mh_db_free_result($q);
        return (int)($row[0] ?? 0);
    }

    /**
     * Centralized timeframe options for smart collections.
     * @return array<int, string>
     */
    public static function smartTimeframeOptions(): array
    {
        return [
            7   => mh_lng('UI_TIMEFRAME_7_DAYS', '7 days'),
            30  => mh_lng('UI_TIMEFRAME_30_DAYS', '30 days'),
            90  => mh_lng('UI_TIMEFRAME_90_DAYS', '90 days'),
            180 => mh_lng('UI_TIMEFRAME_180_DAYS', '180 days'),
            365 => mh_lng('UI_TIMEFRAME_1_YEAR', '1 year'),
        ];
    }

    /**
     * Get the metadata for smart collections and status styles.
     * Centralizing these here ensures consistency across Sidebar, ItemList, and Calendar.
     *
     * @return array<string, array{id:int, slug:string, name_key:string, name_fallback:string, color_token:string, icon:string}>
     */
    public static function smartCollectionDefs(): array
    {
        return [
            'favorite' => [
                'id'            => -1,
                'slug'          => 'favorite',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_FAVORITE',
                'name_fallback' => 'Favorite',
                'color_token'   => 'yellow',
                'icon'          => \Components\Base::SVG_STAR,
            ],
            'recent' => [
                'id'            => -2,
                'slug'          => 'smart-recent',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_RECENTLY_EDITED',
                'name_fallback' => 'Recently edited',
                'color_token'   => 'purple',
                'icon'          => \Components\Base::SVG_WAIT_1,
            ],
            'active' => [
                'id'            => -3,
                'slug'          => 'smart-active',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_ACTIVE',
                'name_fallback' => 'Active',
                'color_token'   => 'green',
                'icon'          => \Components\Base::SVG_PLAY,
            ],
            'scheduled' => [
                'id'            => -4,
                'slug'          => 'smart-scheduled',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_SCHEDULED',
                'name_fallback' => 'Scheduled',
                'color_token'   => 'blue',
                'icon'          => \Components\Base::SVG_CALENDAR_RANGE,
            ],
            'draft' => [
                'id'            => -5,
                'slug'          => 'smart-draft',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_DRAFT',
                'name_fallback' => 'Draft',
                'color_token'   => 'orange',
                'icon'          => \Components\Base::SVG_EDIT,
            ],
            'paused' => [
                'id'            => -6,
                'slug'          => 'smart-paused',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_PAUSED',
                'name_fallback' => 'Paused',
                'color_token'   => 'red',
                'icon'          => \Components\Base::SVG_OUTLINE_PAUSE,
            ],
            'completed' => [
                'id'            => -7,
                'slug'          => 'smart-completed',
                'name_key'      => 'UI_NEWSLETTER_COLLECTION_SMART_COMPLETED',
                'name_fallback' => 'Completed',
                'color_token'   => 'gray',
                'icon'          => \Components\Base::SVG_MICRO_CHECK,
            ],
        ];
    }

    /**
     * Return the count of root default items for a specific schedule type, optionally filtered by audience.
     *
     * @param string $scheduleType 'automation' | 'bulk_mail' | 'scheduled_window'
     * @param int    $listId       Optional audience ID filter
     * @return int
     */
    public static function countByScheduleType(string $scheduleType, int $listId = 0): int
    {
        if (self::isDev()) return 0;

        $types = static::itemTypeDefs();
        $defaultType = (int)($types['default'] ?? 0);

        $where = [];
        $where[] = "newsletter_item_type = {$defaultType}";
        $where[] = "COALESCE(newsletter_followup_id, 0) = 0";
        if ($listId > 0) {
            $where[] = "newsletter_list_id = " . (int)$listId;
        }

        $where[] = "schedule_type = '" . \mh_db_input($scheduleType) . "'";

        $sql = sprintf('SELECT COUNT(*) as cnt FROM %s WHERE %s', static::table(), implode(' AND ', $where));
        $q = \mh_db_query($sql);
        $row = \mh_db_fetch_array($q);
        \mh_db_free_result($q);

        return (int)($row['cnt'] ?? 0);
    }

    /**
     * Return count of automations for a given audience.
     */
    public static function countAutomations(int $listId = 0): int
    {
        return self::countByScheduleType('automation', $listId);
    }

    /**
     * Return count of bulk mails for a given audience.
     */
    public static function countBulkMails(int $listId = 0): int
    {
        return self::countByScheduleType('bulk_mail', $listId);
    }

    /**
     * Return count of scheduled windows for a given audience.
     */
    public static function countScheduledWindows(int $listId = 0): int
    {
        return self::countByScheduleType('scheduled_window', $listId);
    }

    /**
     * Return count of newsletters (bulk + scheduled) for a given audience.
     */
    public static function countNewsletters(int $listId = 0): int
    {
        if (self::isDev()) return 0;

        $types = static::itemTypeDefs();
        $defaultType = (int)($types['default'] ?? 0);

        $where = [];
        $where[] = "newsletter_item_type = {$defaultType}";
        $where[] = "COALESCE(newsletter_followup_id, 0) = 0";
        if ($listId > 0) {
            $where[] = "newsletter_list_id = " . (int)$listId;
        }

        $where[] = "schedule_type IN ('bulk_mail', 'scheduled_window')";

        $sql = sprintf('SELECT COUNT(*) as cnt FROM %s WHERE %s', static::table(), implode(' AND ', $where));
        $q = \mh_db_query($sql);
        $row = \mh_db_fetch_array($q);
        \mh_db_free_result($q);

        return (int)($row['cnt'] ?? 0);
    }

    /**
     * Fetch all default items and attach a nested followups tree to each item.
     * The tree is provided on the dynamic attribute $item->followups as an array of NewsletterItem,
     * each of which also contains its own ->followups array, recursively.
     *
     * @return array<static> Default items with ->followups tree attached
     */
    public static function allWithFollowup(): array
    {
        // Use existing model methods and relations instead of manual SQL
        $roots = static::all(true); // default items only
        if (!$roots) return [];

        foreach ($roots as $root) {
            if ($root instanceof self) {
                $root->loadFollowupsTree();
            }
        }

        return $roots;
    }

    /**
     * Provide unified default options for Components\Resources\ItemList::render when listing NewsletterItems.
     * Pages can pass overrides to customize title, items, outer_attrs, etc.
     *
     * @param array $overrides
     * @return array
     */
    public static function  itemListOptions(array $overrides = []): array
    {
        // Preserve current Newsletter group selection (smart-* / favorite / campaign-*) when
        // building HTMX refresh URLs so row self-refresh fragments can render context-aware UI.
        // This is best-effort and safe when mh_get() is unavailable.
        $currentGroup = '';
        try {
            $currentGroup = (string)\mh_get('group', '', 'string');
        } catch (\Throwable $e) {
            $currentGroup = '';
        }

        $currentListId = 0;
        try {
            $currentListId = (int)\mh_get('list_id', 0, 'int');
        } catch (\Throwable $e) {
            $currentListId = 0;
        }

        $currentScheduleType = '';
        try {
            $currentScheduleType = (string)\mh_get('schedule_type', '', 'string');
        } catch (\Throwable $e) {
            $currentScheduleType = '';
        }

        $pageMode = 'newsletter';
        try {
            $pageMode = (string)\mh_get('page_mode', 'newsletter', 'string');
        } catch (\Throwable $e) { $pageMode = 'newsletter'; }

        $filterParams = [];
        if ($currentGroup !== '') $filterParams['group'] = $currentGroup;
        if ($currentListId > 0) $filterParams['list_id'] = $currentListId;
        if ($currentScheduleType !== '') $filterParams['schedule_type'] = $currentScheduleType;

        // Build defaults identical to those used in newsletter_items_list.php and newsletter_item_row.php
        $domMode = ($pageMode === 'automation') ? 'custom-automation' : 'newsletter';
        $defaults = [
            'title' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_LIST_TITLE_ALL_FLOWS', 'All Flows'),
            'items' => [],
            'id_key' => 'id',
            'name_key' => 'name',
//            'status_key' => 'status',
            'outer_attrs' => 'id="' . $pageMode . '-items-list"',
            'empty_text' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_LIST_EMPTY', 'No items found.'),
            'panel_type' => 'drawer',
            'expandable' => true,
            'expand_refresh_button' => true,
            'open_key_prefix' => $pageMode . '-flow',
            'perf_context' => ($pageMode === 'automation') ? \Components\Ui\PerformanceDashboard::CONTEXT_AUTOMATION : \Components\Ui\PerformanceDashboard::CONTEXT_NEWSLETTER,
            'row_id_prefix' => 'flow-' . $domMode . '-',
            // Pattern A wiring: resource-specific events (newsletter_item:updated, etc.)
            'event_prefix' => $pageMode . ':newsletter_item',
            'expand_refresh_url' => \Components\Router::url('newsletter_item_flow', ['partial' => 1, 'id' => '{id}', 'page_mode' => $pageMode]),
            // Append no_loader=1 so that ItemList's placeholder skeleton is suppressed on row refresh.
            // Also include the currently selected group (when present) so `newsletter_item_row.php`
            // can render smart-collection context (e.g., campaign badge) consistently.
            'row_refresh_url' => \Components\Router::url(
                'newsletter_item_row',
                array_merge(
                    ['partial' => 1, 'id' => '{id}', 'no_loader' => 1, 'page_mode' => $pageMode],
                    $filterParams
                )
            ),
            // Expose a stable DOM id for the row title so we can OOB-update after save
            'row_title_id_prefix' => 'newsletter-item-title-',
            'new_action' => [
                'label' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_ACTION_NEW', 'New'),
                'url' => \Components\Router::url('newsletter_item_modal', ['partial' => 1, 'mode' => 'new', 'page_mode' => $pageMode]),
                'attrs' => [
                    'data-title' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_MODAL_NEW_TITLE', 'New Newsletter Item'),
                    'data-size' => '5xl',
                    // Open the "New" action in the config-modal (not the drawer)
                    'command' => 'show-modal',
                    'commandfor' => 'config-modal',
                    'hx-target' => '#config-modal-body',
                    'hx-swap' => 'innerHTML',
                ],
                'variant' => 'primary',
            ],
            // Gear-style config action (replaces inline Edit button)
            'config_action' => [
                'url' => \Components\Router::url('newsletter_item_modal', ['partial' => 1, 'mode' => 'edit', 'page_mode' => $pageMode]) . '&id={id}',
                'attrs' => [
                    'data-title' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_MODAL_CONFIGURE_TITLE', 'Configure Item'),
                    'data-size' => 'xl',
                    // panel_type is drawer, defaults will target config-drawer/body
                    'command' => 'show-modal',
                    'commandfor' => 'config-drawer',
                    'hx-target' => '#config-drawer-body',
                    'hx-swap' => 'innerHTML',
                ],
                'variant' => 'ghost',
                'size' => 'sm',
            ],
            'row_actions' => [
                'analytics' => [
                    'label' => (string)\mh_lng('MAILBEEZ_BUTTON_ANALYTICS_LABEL', 'Analytics'),
                    'icon' => \Components\Base::SVG_OUTLINE_CHART_PIE,
//                    'url' => \Components\Legacy\LegacyRouter::url('reportAnalyticsAdvanced', [
//                        'selected_module' => 'mb_newsletter',
//                        'selected_campaign' => '{newsletter_campaign_id}',
//                        'selected_campaign_item' => '{id}',
//                    ]),
                    'attrs' => [
                        'command' => 'show-modal',
                        'commandfor' => 'config-modal',
                        'data-title' => (string)\mh_lng('MAILBEEZ_BUTTON_ANALYTICS_LABEL', 'Analytics'),
                        'data-size' => 'full-margin',
                        'data-url' =>  \Components\Legacy\LegacyRouter::url('reportAnalyticsAdvanced', [
                                'selected_module' => 'mb_newsletter',
                                'selected_campaign' => '{newsletter_campaign_id}',
                                'selected_campaign_item' => '{id}',
                        ]),
                        'hx-target' => '#config-modal-body',
                        'hx-swap' => 'innerHTML',
                    ],
                ],
                'd_move' => ['divider' => true],
                'move' =>   [
                            'label' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_ACTION_MOVE', 'Move…'),
                            'icon' => \Components\Base::SVG_OUTLINE_FOLDER,
                            'url' => \Components\Router::url('newsletter_item_modal', ['partial' => 1, 'mode' => 'edit', 'fields' => 'newsletter_campaign_id', 'sub_action' => 'move', 'autoclose' => 'true', 'event_suffix' => 'newsletter_item_list:refresh', 'page_mode' => $pageMode]) . '&id={id}&ui_event_id={id}',
                            'attrs' => [
                                    'data-title' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_MODAL_MOVE_TITLE', 'Move to collection'),
                                    'data-size' => 'md',
                                // Force modal target (not drawer)
                                    'command' => 'show-modal',
                                    'commandfor' => 'config-modal',
                                    'hx-target' => '#config-modal-body',
                                    'hx-swap' => 'innerHTML',
                            ],
                    ],
                'archive' =>   [
                            'label' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_ACTION_ARCHIVE', 'Archive…'),
                            'icon' => \Components\Base::SVG_OUTLINE_ARCHIVE,
                            'url' => \Components\Router::url('newsletter_item_modal', ['partial' => 1, 'mode' => 'edit', 'fields' => 'newsletter_campaign_id', 'sub_action' => 'archive', 'autoclose' => 'true', 'event_suffix' => 'newsletter_item_list:refresh', 'page_mode' => $pageMode]) . '&id={id}&ui_event_id={id}',
                            'attrs' => [
                                    'data-title' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_MODAL_ARCHIVE_TITLE', 'Move to archive collection'),
                                    'data-size' => 'md',
                                // Force modal target (not drawer)
                                    'command' => 'show-modal',
                                    'commandfor' => 'config-modal',
                                    'hx-target' => '#config-modal-body',
                                    'hx-swap' => 'innerHTML',
                            ],
                    ],
                'd_delete' => ['divider' => true],
                'delete' => [
                        'url' => \Components\Router::url('newsletter_item_delete', array_merge(['partial' => 1, 'id' => '{id}', 'page_mode' => $pageMode], $filterParams)),
                        'label' => (string)\mh_lng('BUTTON_DELETE', 'Delete'),
                        'icon' => \Components\Base::SVG_TRASH,
                        'attrs' => [
                                'command' => 'show-modal',
                                'commandfor' => 'confirm-modal',
                                'data-title' => (string)\mh_lng('TEXT_INFO_HEADING_DELETE_CAMPAIGN_OBJECT', 'Delete Newsletter'),
                                'data-type' => 'warning',
                                'hx-target' => '#confirm-modal-body',
                                'hx-swap' => 'innerHTML',
                        ],
                ],

                // Deep copy flow (opens modal to choose target campaign)
                /*
                 NOT WORKING CORRECTLY - smart list configurations are not copied.
review and improve \mb_newsletter_items::deepCopy - read smart list configurations directly from database Select the target collection to copy this flow into.

                [
                    'label' => 'Deep copy…',
                    'url' => \Components\Router::url('newsletter_item_deepcopy', ['partial' => 1, 'id' => '{id}']),
                    'attrs' => [
                        'data-title' => 'Deep copy flow',
                        'data-size' => 'md',
                        // Force modal target (not drawer)
                        'command' => 'show-modal',
                        'commandfor' => 'config-modal',
                        'hx-target' => '#config-modal-body',
                        'hx-swap' => 'innerHTML',
                    ],
                ],
                */
            ],
            // Inline toggle to pause/resume the flow (affects parent/root items only)



            'toggle' => [
                'value_key' => 'flow_status',
                'name' => 'flow_status',
                'size' => 'sm',
                'active_color' => 'brand',
                'wrapper_id' => 'nl-item-flow-toggle-{id}',
                // Post directly when clicking the dropdown item; default to swapping the outer row like trigger_module
                // ItemList will add hx-vals={flow_status:"on"} when the current state is OFF
                // and will preserve @click.stop to avoid toggling the row's expand/collapse.
                'input_attrs' => 'hx-post="' . \Components\Base::h(\Components\Router::url('newsletter_item_toggle', ['partial' => 1, 'id' => '{id}', 'page_mode' => $pageMode])) . '" '
                    . 'hx-target="[id=\'flow-' . $pageMode . '-{id}\']" hx-swap="outerHTML" '
                    . 'data-no-global-loader="1" data-no-local-loader="0"',
                'disabled_key' => 'toggle_disabled',
            ],
        ];

        // Allow nested overrides (e.g., changing attrs inside actions)
        return array_replace_recursive($defaults, $overrides);
    }

    // ---------------- Favorite (star) helpers ----------------

    /**
     * Returns true when this item is marked as favorite.
     * Primary source: dedicated DB column `favorite` (tinyint/bool).
     * Fallback: legacy JSON meta key `favorite` for backwards compatibility.
     */
    public function isFavorite(): bool
    {
        try {
            // Prefer dedicated column when available on the loaded record
            if (array_key_exists('favorite', $this->attributes)) {
                $v = $this->attributes['favorite'];
                return ($v === 1) || ($v === '1') || ($v === true) || ($v === 'Y') || ($v === 'y');
            }
            // Fallback to legacy meta flag
            $meta = $this->getMeta();
            $v = $meta['favorite'] ?? false;
            return ($v === true) || ($v === 1) || ($v === '1') || ($v === 'true') || ($v === 'on');
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Toggle or set favorite flag and persist to the dedicated column `favorite`.
     * Falls back to in-memory set (and legacy meta) when persistence is unavailable.
     */
    public function setFavorite(bool $favorite, bool $persist = true): bool
    {
        try {
            $val = $favorite ? 1 : 0;
            // Keep attribute in sync immediately for UI correctness
            $this->attributes['favorite'] = $val;

            if (!$persist) {
                return true;
            }

            // In dev environments, avoid DB writes but consider it successful for UI flow
            if (static::isDev()) {
                return true;
            }

            $id = (int)($this->attributes[static::pk()] ?? 0);
            if ($id <= 0) { return false; }

            // Narrow update touching only the `favorite` column
            \mh_db_perform(static::table(), ['favorite' => $val, 'last_modified' => date('Y-m-d H:i:s')], 'update', static::pk() . " = {$id}");
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Resolve the wrapper id used for the inline star button so OOB swaps can target it.
     * @param int|self $itemOrId
     */
    public static function favoriteOobId($itemOrId): string
    {
        $id = is_object($itemOrId) ? (int)($itemOrId->newsletter_item_id ?? 0) : (int)$itemOrId;
        return 'nl-item-fav-' . $id;
    }

    /**
     * Render the inline star control HTML. Includes a stable wrapper with id for OOB updates.
     */
    public static function renderFavoriteStarHtml(self $item, bool $animateOnMount = false, bool $readOnly = false): string
    {
        try {
            $id = (int)($item->newsletter_item_id ?? 0);
            if ($id <= 0) { return ''; }
            $oobId = self::favoriteOobId($id);
            $isFav = $item->isFavorite();

            if ($readOnly) {
                if (!$isFav) {
                    return '';
                }
                ob_start();
                ?>
                <span id="<?php echo \Components\Base::h($oobId); ?>" class="inline-flex items-center text-yellow-500" title="<?php echo (string)\mh_lng('UI_FAVORITE', 'Favorite'); ?>">
                    <?php echo \Components\Base::SVG_STAR; ?>
                </span>
                <?php
                return (string) ob_get_clean();
            }

            $pageConfig = \Components\Newsletter\PageConfig::fromRequest();
            $hxUrl = $pageConfig->getBaseUrl('newsletter_item_favorite_toggle', ['partial' => 1, 'id' => $id]);
            $lngFav = (string)\mh_lng('UI_FAVORITE', 'Mark as favorite');
            $lngUnfav = (string)\mh_lng('UI_UNFAVORITE', 'Remove favorite');

            $xData = [
                'isFav' => $isFav,
                'pop' => (bool)$animateOnMount,
                'lngFav' => $lngFav,
                'lngUnfav' => $lngUnfav
            ];
            $xDataJson = json_encode($xData);
            $xInit = 'if (pop) setTimeout(() => { pop = false }, 280)';

            ob_start();
            ?>
            <span id="<?php echo \Components\Base::h($oobId); ?>" class="inline-flex items-center"
                  x-data='<?php echo \Components\Base::h($xDataJson, false); ?>'
                  x-init="<?php echo \Components\Base::h($xInit, false); ?>"
                  :title="isFav ? lngUnfav : lngFav">
              <button type="button"
                      class="inline-flex items-center align-middle leading-none transition-colors transition-transform duration-200 [&_svg]:size-4 [&_svg]:align-middle [&_svg]:shrink-0"
                      :class="{ 'text-yellow-500': isFav, 'text-secondary/70 hover:text-yellow-500': !isFav, 'scale-125': pop }"
                      :aria-label="isFav ? lngUnfav : lngFav"
                      :aria-pressed="isFav ? 'true' : 'false'"
                      @click.stop="isFav = !isFav; pop = true; setTimeout(() => { pop = false }, 280)"
                      onclick="event.stopPropagation()"
                      hx-post="<?php echo \Components\Base::h($hxUrl); ?>"
                      hx-target="#<?php echo \Components\Base::h($oobId); ?>"
                      hx-swap="outerHTML"
                      data-no-global-loader="1"
                      data-no-local-loader="1"
                >
                    <span x-show="isFav" x-cloak><?php echo \Components\Base::SVG_STAR; ?></span>
                    <span x-show="!isFav" x-cloak><?php echo \Components\Base::SVG_STAR_OUTLINE; ?></span>
                </button>
            </span>
            <?php
            return (string)ob_get_clean();
        } catch (\Throwable $e) {
            return '';
        }
    }


    /**
     * Render an out-of-band (OOB) fragment that replaces the status badge wrapper in lists.
     * The wrapper id matches the one produced by ItemList for NewsletterItems: nl-item-status-{id}
     */
    public static function renderStatusBadgeOob(self $item): string
    {
        try {
            $id = (int)($item->newsletter_item_id ?? 0);
            if ($id <= 0) { return ''; }
            $inner = self::renderStatusBadgeHtml($item);
            $idAttr = \Components\Base::h(self::statusOobId($id));
            return '<div id="' . $idAttr . '" hx-swap-oob="innerHTML">' . $inner . '</div>';
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Build a normalized badge config array for the current status of a NewsletterItem.
     * This structure is compatible with ItemList's badge adapter and Ui\Badge::render().
     *
     * @param self $item
     * @return array{label:string,title:string,class:string}
     */
    public static function buildStatusBadgeConfig(self $item): array
    {
        try {
            $st = $item->scheduleState();
            $state = isset($st['state']) ? (string)$st['state'] : 'active';
            $title = isset($st['label']) ? (string)$st['label'] : ucfirst($state);

            // Safety net: when an initial/root item has active follow-ups,
            // badge state must reflect aggregated flow activity.
            if ($state !== 'active' && $item->isInitial() && self::isAggregatedRootFlowActive($item)) {
                $state = 'active';
                $title = (string)\mh_lng('MAILBEEZ_SCHEDULE_ACTIVE', 'Active');
            }

            $short = self::scheduleStateBadgeLabel($state);
            $bg = \Components\Support\Palette::newsletterStateBadgeClass($state);
            return [
                'label' => $short,
                'title' => $title,
                'class' => $bg,
            ];
        } catch (\Throwable $e) {
            return [ 'label' => '—', 'title' => 'Unknown', 'class' => 'bg-slate-500' ];
        }
    }

    /**
     * Resolve a short, localized badge label for a schedule state key.
     */
    public static function scheduleStateBadgeLabel(string $state): string
    {
        $state = strtolower(trim($state));
        switch ($state) {
            case 'draft':
                return (string)\mh_lng('MAILBEEZ_SCHEDULE_DRAFT', 'Draft');
            case 'completed':
                return (string)\mh_lng('MAILBEEZ_SCHEDULE_COMPLETED_SHORT', (string)\mh_lng('MAILBEEZ_SCHEDULE_COMPLETED', 'Completed'));
            case 'paused':
                return (string)\mh_lng('MAILBEEZ_SCHEDULE_PAUSED', 'Paused');
            case 'scheduled':
            case 'pending':
                return rtrim((string)\mh_lng('MAILBEEZ_SCHEDULE_PENDING', 'Starts on '));
            case 'ended':
            case 'past':
                return rtrim((string)\mh_lng('MAILBEEZ_SCHEDULE_PAST', 'Ended on '));
            case 'active':
                return (string)\mh_lng('MAILBEEZ_SCHEDULE_ACTIVE', 'Active');
        }
        return ucfirst($state);
    }

    /**
     * Resolve the stable HTMX OOB wrapper id used in lists for a given item or id.
     * @param int|self $itemOrId
     */
    public static function statusOobId($itemOrId): string
    {
        $id = is_object($itemOrId) ? (int)($itemOrId->newsletter_item_id ?? 0) : (int)$itemOrId;
        return 'nl-item-status-' . $id;
    }

    /**
     * Render the status badge HTML using the shared Ui\Badge component so the
     * modal OOB fragment and ItemList display are visually identical.
     *
     * @param self $item
     * @param array $options Optional overrides: wrapper_class, inner_class, class, panel_id, panel_target_id
     */
    public static function renderStatusBadgeHtml(self $item, array $options = []): string
    {
        try {
            $cfg = self::buildStatusBadgeConfig($item);
            $label = (string)($cfg['label'] ?? '');
            $title = (string)($cfg['title'] ?? $label);
            $klass = trim((string)($cfg['class'] ?? ''));
            // Ensure text color if class from older sources lacks it (kept as safe-guard)
            if ($klass !== '' && stripos($klass, 'text-') === false) {
                $klass .= ' text-white';
            }
            $wrapperClass = isset($options['wrapper_class']) ? (string)$options['wrapper_class'] : '';// 'shrink-0 inline-flex items-center';
            $innerClass   = isset($options['inner_class']) ? (string)$options['inner_class'] : ''; // 'inline-flex items-center transform rounded-full px-1.5 text-sm';

            // Enrich with Alpine.js bindings so the badge updates automatically when
            // scheduled windows elapse (scheduled -> active -> ended) without reload.
            // This augmentation is safe: server-rendered label/color remain the
            // fallback when JS is unavailable.
            $attrs = isset($options['attrs']) && is_array($options['attrs']) ? $options['attrs'] : [];

            try {
                // Determine initial state and time boundaries
                $st = $item->scheduleState();
                $state = isset($st['state']) ? (string)$st['state'] : 'active';

                $startTs = self::parseScheduleBoundaryTimestamp($item->newsletter_item_start ?? null, false);
                $endTs   = self::parseScheduleBoundaryTimestamp($item->newsletter_item_end ?? null, true);
                try {
                    if ($item->scheduleTypeCode() === 'automation') {
                        // For automation, ignore end window (runs continuously)
                        $endTs = null;
                    }
                } catch (\Throwable $e) { /* ignore */ }

                // Only attach dynamic behavior when time-driven states are relevant
                $timeDriven = in_array($state, ['scheduled', 'active'], true) && ($startTs !== null || $endTs !== null);

                if ($timeDriven) {
                    // Alpine component as inline object literal; keeps dependencies minimal.
                    // It updates the inner badge label and replaces prior bg/text classes.
                    $stateLabel = self::scheduleStateBadgeLabel($state);
                    $classInit = $klass; // e.g. "bg-green-600 text-white"

                    // Build removable class list from centralized palette
                    $knownStates = ['active','scheduled','pending','draft','completed','ended','past','paused'];
                    $stateClassMapPhp = [];
                    $removeTokens = [];
                    foreach ($knownStates as $ks) {
                        $cls = \Components\Support\Palette::newsletterStateBadgeClass($ks);
                        $stateClassMapPhp[$ks] = $cls;
                        foreach (preg_split('/\s+/', trim($cls)) as $tok) {
                            if ($tok !== '') { $removeTokens[$tok] = true; }
                        }
                    }
                    $removable = implode(' ', array_keys($removeTokens));

                    // Build JS maps from the same source
                    $stateLabelMapPhp = [];
                    foreach ($knownStates as $ks) {
                        $stateLabelMapPhp[$ks] = self::scheduleStateBadgeLabel($ks);
                    }

                    $jsonFlags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
                    $stateJson = json_encode(strtolower($state), $jsonFlags);
                    if ($stateJson === false) { $stateJson = '"active"'; }
                    $stateLabelJson = json_encode($stateLabel, $jsonFlags);
                    if ($stateLabelJson === false) { $stateLabelJson = '""'; }
                    $classInitJson = json_encode($classInit, $jsonFlags);
                    if ($classInitJson === false) { $classInitJson = '""'; }
                    $removeTokensJson = json_encode(array_values(array_keys($removeTokens)), $jsonFlags);
                    if ($removeTokensJson === false) { $removeTokensJson = '[]'; }
                    $labelsJson = json_encode($stateLabelMapPhp, $jsonFlags);
                    if ($labelsJson === false) { $labelsJson = '{}'; }
                    $classMapJson = json_encode($stateClassMapPhp, $jsonFlags);
                    if ($classMapJson === false) { $classMapJson = '{}'; }
                    $defaultKlass = \Components\Support\Palette::newsletterStateBadgeClass('unknown');
                    $defaultKlassJson = json_encode($defaultKlass, $jsonFlags);
                    if ($defaultKlassJson === false) { $defaultKlassJson = '""'; }

                    $startTsJs = $startTs !== null ? (string)(int)$startTs : 'null';
                    $endTsJs   = $endTs   !== null ? (string)(int)$endTs   : 'null';

                    // Build the x-data payload and lifecycle methods
                    ob_start();
                    ?>
                    {
                        state: <?= $stateJson ?>,
                        label: <?= $stateLabelJson ?>,
                        klass: <?= $classInitJson ?>,
                        startTs: <?= $startTsJs ?>,
                        endTs: <?= $endTsJs ?>,
                        _rm: <?= $removeTokensJson ?>,
                        _labels: <?= $labelsJson ?>,
                        _classMap: <?= $classMapJson ?>,
                        _defaultKlass: <?= $defaultKlassJson ?>,
                        _maxTimerMs: 2147483647,
                        _startTimer: null,
                        _endTimer: null,
                        _apply() {
                            const el = this.$el.querySelector('div');
                            if (!el) return;
                            const keep = el.className.split(/\s+/).filter(c => !this._rm.includes(c)).join(' ');
                            el.className = (keep + ' ' + this.klass).trim();
                        },
                        _applyLabel() {
                            const sp = this.$el.querySelector("span[data-role='status-label']");
                            if (sp) { sp.textContent = this.label; }
                        },
                        _setState(s) {
                            this.state = s;
                            this.label = this._labels[s] || (s.charAt(0).toUpperCase() + s.slice(1));
                            this.klass = this._classMap[s] || this._defaultKlass;
                            this._apply();
                            this._applyLabel();
                        },
                        _clearTimer(key) {
                            if (this[key]) {
                                clearTimeout(this[key]);
                                this[key] = null;
                            }
                        },
                        _clearTimers() {
                            this._clearTimer('_startTimer');
                            this._clearTimer('_endTimer');
                        },
                        _computeNow() {
                            const now = Math.floor(Date.now() / 1000);
                            if (this.state === 'scheduled' && this.startTs && now >= this.startTs) {
                                this._setState('active');
                            }
                            if (this.state === 'active' && this.endTs && now >= this.endTs) {
                                this._setState('ended');
                            }
                        },
                        _schedule() {
                            this._clearTimers();
                            const now = Math.floor(Date.now() / 1000);

                            if (this.state === 'scheduled' && this.startTs && this.startTs > now) {
                                const startDelayMs = Math.min((this.startTs - now + 1) * 1000, this._maxTimerMs);
                                this._startTimer = setTimeout(() => {
                                    this._startTimer = null;
                                    this._computeNow();
                                    this._schedule();
                                }, startDelayMs);
                            }

                            if (this.state === 'active' && this.endTs) {
                                const nowEnd = Math.floor(Date.now() / 1000);
                                const secsUntilEnd = this.endTs - nowEnd + 1;
                                if (secsUntilEnd <= 0) {
                                    this._setState('ended');
                                    return;
                                }

                                const endDelayMs = Math.min(secsUntilEnd * 1000, this._maxTimerMs);
                                this._endTimer = setTimeout(() => {
                                    this._endTimer = null;
                                    this._computeNow();
                                    if (this.state === 'active') {
                                        this._schedule();
                                    }
                                }, endDelayMs);
                            }
                        },
                        init() {
                            this._apply();
                            this._applyLabel();
                            this._computeNow();
                            this._schedule();
                            if (this.$watch) {
                                this.$watch('startTs', () => { this._computeNow(); this._schedule(); });
                                this.$watch('endTs', () => { this._computeNow(); this._schedule(); });
                                this.$watch('klass', () => { this._apply(); });
                            }
                        }
                    }
                    <?php
                    $xData = trim((string)ob_get_clean());

                    // Merge into wrapper attrs; prefer existing user-provided attributes if set
                    if (!isset($attrs['x-data'])) {
                        $attrs['x-data'] = $xData;
                    }
                    if (!isset($attrs['x-init'])) { $attrs['x-init'] = 'init()'; }
                    // Keep tooltip/ARIA in sync with the live-updated label
                    if (!isset($attrs[':title'])) { $attrs[':title'] = 'label'; }
                    if (!isset($attrs[':aria-label'])) { $attrs[':aria-label'] = 'label'; }
                }
            } catch (\Throwable $e) {
                // Best-effort only — keep static badge on failure
            }

            $badgeOpts = [
                'wrapper_class' => $wrapperClass,
                'inner_class'   => $innerClass,
                'class'         => trim($klass),
                'title'         => $title,
                'panel_id'      => isset($options['panel_id']) ? (string)$options['panel_id'] : null,
                'panel_target_id'=> isset($options['panel_target_id']) ? (string)$options['panel_target_id'] : null,
                // Inject Alpine bindings (if added above) while preserving any caller-provided attrs
                'attrs'         => $attrs,
                // Replace inner content with a span that binds to Alpine label only when
                // x-data is present. Otherwise, render plain text to avoid Alpine errors.
                'content_html'  => (function() use ($attrs, $label) {
                    $hasXData = isset($attrs['x-data']) && is_string($attrs['x-data']) && trim($attrs['x-data']) !== '';
                    return $hasXData
                        ? '<span data-role="status-label" x-text="label">' . \Components\Base::h($label) . '</span> '
                        : \Components\Base::h($label);
                })(),
            ];

            return \Components\Ui\Badge::render('default', $label, $badgeOpts);
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Dynamic select options for newsletter_campaign_id field.
     * Returns rich option data including dot_class for CollectionIdentity rendering.
     * @return array<int|string, array{label: string, dot_class: string}>
     */
    public static function campaignOptions(): array
    {
        $active   = [];
        $archived = [];
        // Use the model layer which gracefully returns [] when legacy DB isn't available.

        $applicability = '';
        try {
            // Support page_mode context: only filter by applicability if explicitly requested.
            // This prevents over-filtering on general pages where page_mode is unset.
            $requestMode = \mh_get('page_mode', '', 'string');
            if ($requestMode === '') {
                $requestMode = \mh_post('page_mode', '', 'string');
            }
            if ($requestMode !== '') {
                $pageConfig = new PageConfig($requestMode);
                $applicability = $pageConfig->mode;
            }
        } catch (\Throwable $e) { /* best-effort only */ }

        $id = (int)\mh_get('id', 0, 'int');
        if ($id <= 0) $id = (int)\mh_post('id', 0, 'int');
        $mode = \mh_get('mode', '', 'string');
        if ($mode === '') $mode = \mh_post('mode', '', 'string');
        $action = \mh_get('action', '', 'string');
        $fields = \mh_get('fields', '', 'string');
        if ($fields === '') $fields = \mh_post('fields', '', 'string');
        $subAction = \mh_get('sub_action', '', 'string');
        if ($subAction === '') $subAction = \mh_post('sub_action', '', 'string');

        // Selection context: when rendering a form/modal for selection (create/edit/deepcopy),
        // we want to filter out archived campaigns.
        // List/label context: when rendering lists or rows, we want all labels to be available.
        $isSelectionContext = (strpos($action, '_modal') !== false || strpos($action, '_deepcopy') !== false || $mode !== '');

        // Move context: when specifically editing just the campaign field (e.g. from Move action),
        // we allow selecting from archived campaigns.
        $isMoveContext = ($fields === 'newsletter_campaign_id');

        $isNew = ($mode === 'new' || ($id <= 0 && $isSelectionContext && $mode !== 'edit'));
        $currentCampaignId = 0;
        if ($isSelectionContext && !$isNew) {
            try {
                // If editing, we want to know the current campaign to keep it in the list if it's archived
                $item = static::find($id);
                if ($item) {
                    $currentCampaignId = (int)($item->newsletter_campaign_id ?? 0);
                }
            } catch (\Throwable $e) { /* best-effort */ }
        }

        foreach (NewsletterCampaign::all($applicability) as $c) {
            $cid = (int)($c->newsletter_campaign_id ?? 0);
            if ($cid <= 0) {
                continue;
            }
            $name     = (string)($c->newsletter_campaign_name ?? ('Campaign ' . $cid));
            $dotClass = (string)($c->newsletter_campaign_color ?? 'bg-gray-400');
            if ($dotClass === '') {
                $dotClass = 'bg-gray-400';
            }
            $isArchived = $c->newsletter_campaign_archived !== '0';
            $entry = [
                'label'       => $name,
                'dot_class'   => $dotClass,
                'is_archived' => $isArchived,
            ];
            if ($isArchived) {
                // Always show in lists to ensure labels are correct.
                // In Move context, allow selecting ANY archived campaign.
                $showArchived = !$isSelectionContext || $isMoveContext;

                if ($isSelectionContext && $isMoveContext) {
                    if ($subAction === 'move') {
                        // Move action: ONLY active collections
                        $showArchived = false;
                    } elseif ($subAction === 'archive') {
                        // Archive action: ONLY archived collections
                        $showArchived = true;
                    }
                } elseif ($isSelectionContext && !$isMoveContext) {
                    // In general selection context (modal), only show if it's the one currently assigned to the item being edited.
                    // For NEW items, archived campaigns are never shown.
                    if (!$isNew && $cid === $currentCampaignId) {
                        $showArchived = true;
                    }
                }

                if ($showArchived) {
                    $archived[$cid] = $entry;
                }
            } else {
                if ($isSelectionContext && $isMoveContext && $subAction === 'archive') {
                    // Archive action: ONLY archived collections, so skip active ones
                    continue;
                }
                $active[$cid] = $entry;
            }
        }

        $options = [];
        if ($isSelectionContext) {
            $options[''] = [
                'label'    => mh_lng('UI_SELECT_CAMPAIGN', 'Select Campaign'),
                'disabled' => true,
            ];
        }

        foreach ($active as $cid => $entry) {
            $options[$cid] = $entry;
        }

        if (!empty($archived)) {
            // Append a subheader separator before the archived group
            $options['__subheader_archived__'] = ['subheader' => mh_lng('newsletter_campaign_archived', 'Archived')];
            foreach ($archived as $cid => $entry) {
                $options[$cid] = $entry;
            }
        }


        return $options;
    }



    /**
     * Prevent changing the audience to a different base source type on update.
     * Handle UI-only scheduling fields (start_mode, schedule_type) and map to DB columns.
     */
    protected function filterAttributesForSave(array $data): array
    {
        try {
            $isCreate = (int)($this->attributes[static::pk()] ?? 0) <= 0;
        } catch (\Throwable $e) {
            $isCreate = true;
        }

        try {
            // Only enforce on update when both old and new list ids are available
            $oldId = (int)($this->attributes[static::pk()] ?? 0);
            if ($oldId > 0) {
                $oldList = (int)($this->attributes['newsletter_list_id'] ?? 0);
                $newList = isset($data['newsletter_list_id']) ? (int)$data['newsletter_list_id'] : $oldList;

                if ($oldList > 0 && $newList > 0 && $oldList !== $newList) {
                    $oldType = Audience::resolveAudienceType($oldList);
                    $newType = Audience::resolveAudienceType($newList);
                    if ($oldType !== '' && $newType !== '' && $oldType !== $newType) {
                        // Mismatch detected: keep the old audience to avoid issues
                        $data['newsletter_list_id'] = $oldList;
                    }
                }
            }
        } catch (\Throwable $e) { /* best-effort only */
        }

        // --- Scheduling: map UI-only fields to DB columns ---

        // start_mode: 'now' => newsletter_item_start sentinel, 'date' => keep submitted value
        if (isset($data['start_mode'])) {
            if ($data['start_mode'] === 'now') {
                $data['newsletter_item_start'] = self::SENTINEL_NEWSLETTER_ITEM_START;
            }
            unset($data['start_mode']);
        }

        // schedule_type: 'bulk_mail'         => stop_when_finished=1, end from UI (optional "not later than")
        //                'scheduled_window'  => stop_when_finished=0, keep end value
        //                'automation'        => stop_when_finished=0, end from automation_end_mode
        if (isset($data['schedule_type'])) {
            $selectedScheduleType = (string)$data['schedule_type'];

            switch ($selectedScheduleType) {
                case 'bulk_mail':
                    $data['stop_when_finished'] = 1;
                    // Keep end date if user set a "not later than" date, otherwise sentinel
                    if (empty($data['newsletter_item_end'])) {
                        $data['newsletter_item_end'] = self::SENTINEL_NEWSLETTER_ITEM_END;
                    }
                    break;
                case 'scheduled_window':
                    $data['stop_when_finished'] = 0;
                    // keep submitted newsletter_item_end value
                    break;
                case 'automation':
                default:
                    $data['stop_when_finished'] = 0;
                    // automation_end_mode: 'never' => clear end date, 'date' => keep end date
                    $endMode = $data['automation_end_mode'] ?? 'never';
                    if ($endMode === 'never' || empty($data['newsletter_item_end'])) {
                        $data['newsletter_item_end'] = self::SENTINEL_NEWSLETTER_ITEM_END;
                    }
                    break;
            }
            // Keep 'schedule_type' so it gets persisted into its dedicated DB column
        }

        // Remove UI-only automation_end_mode before DB persist
        unset($data['automation_end_mode']);

        // Remove non-persisted runtime attributes to avoid DB array warnings.
        if (isset($data['followups'])) {
            unset($data['followups']);
        }

        // Normalize date/datetime columns to sentinel values (never persist NULL).
        foreach (['newsletter_item_start', 'newsletter_item_end', 'completed_at'] as $dateCol) {
            if (array_key_exists($dateCol, $data)) {
                $data[$dateCol] = self::normalizeDateColumnForStorage($dateCol, $data[$dateCol]);
            } elseif ($isCreate) {
                $sentinel = self::sentinelValueForDateColumn($dateCol);
                if ($sentinel !== null) {
                    $data[$dateCol] = $sentinel;
                }
            }
        }

        // Ensure `progress` JSON column is persisted as valid JSON, never as an empty string
        if (array_key_exists('progress', $data)) {
            $pv = $data['progress'];
            try {
                if ($pv === null) {
                    // store an empty object by default
                    $data['progress'] = '{}';
                } elseif (is_array($pv)) {
                    // encode arrays/objects to JSON
                    $data['progress'] = json_encode($pv, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                } elseif (is_string($pv)) {
                    $trim = trim($pv);
                    if ($trim === '') {
                        $data['progress'] = '{}';
                    } else {
                        $decoded = json_decode($trim, true);
                        if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
                            // invalid JSON string -> fallback to empty object
                            $data['progress'] = '{}';
                        } else {
                            // re-encode to compact JSON (accept null/array/object)
                            $data['progress'] = json_encode($decoded ?? new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                        }
                    }
                } else {
                    // scalar values -> wrap/encode
                    $data['progress'] = json_encode($pv, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                }
            } catch (\Throwable $e) {
                // last resort: write empty object JSON
                $data['progress'] = '{}';
            }
        }

        $this->flowStatusTouchedInSave = array_key_exists('flow_status', $data);

        // Normalize toggle semantics for legacy storage expectations
        // BeezUI posts toggles as 'on' or '' (missing). Legacy expects 'True'/'False' strings.
        // On create, default to 'False' when status is not provided.
/*
        if (array_key_exists('status', $data)) {
            $raw = is_string($data['status']) ? strtolower(trim((string)$data['status'])) : $data['status'];
            $truthy = ($raw === 'on' || $raw === '1' || $raw === 'true' || $raw === 'yes' || $raw === 1 || $raw === true);
            $data['status'] = $truthy ? 'True' : 'False';
        } else
  */
        if ($isCreate) {
            // Ensure a deterministic default on insert
            $data['status'] = 'False';
        }

        // Normalize flow_status semantics (flow active/paused) — relevant for parent items
        // BeezUI posts toggles as 'on' or ''. Persist as 'True'/'False'.
        if (array_key_exists('flow_status', $data)) {
            $data['flow_status'] = static::normalizeFlowStatusValue($data['flow_status']);
        } elseif ($isCreate) {
            // New items default to flow active
            $data['flow_status'] = 'True';
        }

        // Delegate JSON column normalization to BaseModel (ensures '{}' instead of '').
        $data = parent::filterAttributesForSave($data);
        return $data;
    }

    /**
     * Augment stored values with computed UI-only fields before the form is rendered.
     * Pre-selects start_mode and schedule_type based on existing DB data so that legacy
     * items with dates already set show the correct radiocard selection.
     */
    public function augmentFormValues(array $values): array
    {
        // 1) Normalize stored date/time values to 'Y-m-d' strings for date inputs
        try {
            if (array_key_exists('newsletter_item_start', $values)) {
                $values['newsletter_item_start'] = self::normalizeDateColumnForUi($values['newsletter_item_start'], 'newsletter_item_start');
            }
            if (array_key_exists('newsletter_item_end', $values)) {
                $values['newsletter_item_end'] = self::normalizeDateColumnForUi($values['newsletter_item_end'], 'newsletter_item_end');
            }
        } catch (\Throwable $e) { /* best-effort only */ }

        // 2) Resolve schedule_type from stored value (DB/meta) when not provided
        if (!isset($values['schedule_type']) || $values['schedule_type'] === '') {
            try {
                $stored = $this->getStoredScheduleType();
            } catch (\Throwable $e) { $stored = null; }
            if (is_string($stored) && $stored !== '') {
                $values['schedule_type'] = $stored;
            }
        }

        // 3) Compute start_mode from presence of start date
        if (!isset($values['start_mode']) || $values['start_mode'] === '') {
            $s = (string)($values['newsletter_item_start'] ?? '');
            $values['start_mode'] = ($s !== '') ? 'date' : 'now';
        }

        // 4) For automation schedules, infer automation_end_mode from presence of end date.
        //    This fixes a bug where editing an automation with an existing end date would
        //    default the UI to "never" and on save clear the date.
        $st = (string)($values['schedule_type'] ?? '');
        if ($st === 'automation') {
            if (!isset($values['automation_end_mode']) || $values['automation_end_mode'] === '') {
                $end = (string)($values['newsletter_item_end'] ?? '');
                $values['automation_end_mode'] = ($end !== '') ? 'date' : 'never';
            }
        }

        // 5) schedule_type final fallback for legacy items
        if (!isset($values['schedule_type']) || $values['schedule_type'] === '') {
            $values['schedule_type'] = 'scheduled_window';
        }

        // Note: We intentionally avoid server-side seeding of default dates here.
        // The interactive ScheduleConfig field seeds UX defaults client-side to keep
        // modes consistent (e.g., automation defaults to Now/Never without forcing dates).

        return $values;
    }

    /**
     * Relation: this item belongs to a single campaign (reverse of NewsletterCampaign::items)
     * @return ?NewsletterCampaign
     */
    public function campaign(): ?object
    {
        return $this->belongsTo(NewsletterCampaign::class, 'newsletter_campaign_id');
    }

    /**
     * Relation: this item targets a single audience/list
     * @return ?Audience
     */
    public function audience(): ?object
    {
        return $this->belongsTo(Audience::class, 'newsletter_list_id');
    }

    /**
     * Self-relation: parent item (for follow-ups)
     * @return ?NewsletterItem
     */
    public function parent(): ?object
    {
        return $this->belongsTo(self::class, 'newsletter_followup_id');
    }

    /**
     * Self-relation: children (follow-up items)
     * @return array<NewsletterItem>
     */
    public function children(): array
    {
        return $this->hasMany(self::class, 'newsletter_followup_id', static::pk());
    }

    /**
     * Build and attach the follow-ups tree for this item.
     * Populates $this->followups with an array of NewsletterItem instances, each with its own ->followups, recursively.
     * Returns the built followups array for convenience.
     *
     * Safety: protects against accidental cycles by tracking visited item IDs.
     *
     * @param bool $onlyValid When true, includes only items recognized as follow-ups via isFollowup().
     * @return array<self>
     */
    public function loadFollowupsTree(bool $onlyValid = true): array
    {
        // If legacy DB is unavailable, nothing to load
        if (self::isDev()) {
            $this->followups = [];
            return [];
        }

        $visited = [];
        $build = function (self $item) use (&$build, &$visited, $onlyValid): array {
            $children = [];

            $id = (int)($item->{static::pk()} ?? 0);
            if ($id > 0) {
                if (isset($visited[$id])) {
                    // cycle detected: stop here
                    return [];
                }
                $visited[$id] = true;
            }

            foreach ($item->children() as $child) {
                if (!$child instanceof self) {
                    continue;
                }
                if ($onlyValid && !$child->isFollowup()) {
                    continue;
                }
                $child->followups = $build($child);
                $children[] = $child;
            }

            $item->followups = $children;
            return $children;
        };

        return $build($this);
    }


    /**
     * Build a FlowViz items array representing this NewsletterItem and its followups.
     * Uses FlowBuilder to generate a simple linear flow when single-child chains are present,
     * and a branch block when there are multiple immediate children.
     *
     * @return array<int, array<string,mixed>> FlowViz items
     */
    public function flow(): array
    {
        mh_load_modules_language_files(MH_DIR_MODULE, 'mb_newsletter');


        try {
            // In dev mode with no DB, return an empty flow to avoid errors
            if (self::isDev()) {
                return [];
            }

            // Ensure followups tree exists
            if (!isset($this->followups) || !is_array($this->followups)) {
                try {
                    $this->loadFollowupsTree(true);
                } catch (\Throwable $e) {
                    $this->followups = [];
                }
            }


            $fb = \Components\CampaignFlow\FlowBuilder::make();
            $pageMode = $this->getPageMode();


            // Helper: derive the coupon label shown as Flow step content.
            // Mirrors legacy module behavior (e.g., winback_advanced) via coupon engine when available.
            $couponTypeLabel = function (int $couponId, int $couponExpiryDays): string {
                $mod = static::mbNewsletter();
                return $mod->get_coupon_type_label($couponId, $couponExpiryDays);
            };

            // 1) Initial Audience step (represents the audience for this newsletter)
            // Resolve the current item id early so we can wire the audience card to open the edit modal
            $rootId = (int)($this->{static::pk()} ?? 0);
            $audListId = (int)($this->newsletter_list_id ?? 0);

            $campaignId = $this->campaign()->newsletter_campaign_id ?? -1;

            if ($audListId <= 0) {
                // Fallback: take from campaign if item does not have a list assigned
                try {
                    $c = $this->campaign();
                    $audListId = (int)($c->newsletter_list_id ?? 0);
                } catch (\Throwable $e) { /* ignore */
                }
            }

            $audName = '';
            if ($audListId > 0) {
                try {
                    $aud = Audience::find($audListId);
                    $audName = $aud->getLabel($this);
                } catch (\Throwable $e) {
                    $audName = '';
                }
            }
            if ($audName === '') {
                $audName = (string)\mh_lng('MAILBEEZ_AUDIENCE_UNDEFINED', 'Audience');
            }
            // Build modal attrs to edit this item so users can change newsletter_list_id directly
            // Support targeted field editing similar to FlowBuilder::cfgModal by passing fields=...
            $editUrl = \Components\Router::url('newsletter_item_modal', [
                'partial' => 1,
                'mode' => 'edit',
                'id' => $rootId,
                'page_mode' => $pageMode,
                // limit the form to the audience field for a focused edit experience
                'fields' => 'newsletter_list_id',
                // auto-close the modal on save for snappier UX
                'autoclose' => 'true',
                // Narrow the emitted HX-Trigger to refresh only the expandable flow area
                // instead of the full row (which listens to :updated)
                'event_suffix' => 'expandable:refresh',
            ]);

            // Audience count in cached flow: store a lightweight placeholder instead of a snapshot.
            // The placeholder will be resolved to a dynamic anchor at render time (see StepCard::render).
            $audSubtitle = '';
            if ($audListId > 0) {
                $audSubtitle = Audience::countPlaceholderToken($audListId);
            } else {
                // No audience assigned – keep a neutral label
                $audSubtitle = (string)\mh_lng('MAILBEEZ_AUDIENCE_UNDEFINED', 'Audience');
            }

            $fb->audience($audName, [
                'id' => 'audience-' . max(0, $audListId),
                // Consistent Flow UI: stepType = audience name, content = contact count
                'stepType' => $audName,
                'content' => $audSubtitle,
                // Make the audience clickable to edit the item (newsletter_list_id)
                'attrs' => [
                    'command' => 'show-modal',
                    'commandfor' => 'config-modal',
                    'data-title' => (string)\mh_lng('MAILBEEZ_EDIT_AUDIENCE', 'Edit Audience'),
                    'data-size' => 'xl-sm',
                    'hx-get' => $editUrl,
                    'hx-target' => '#config-modal-body',
                    'hx-swap' => 'innerHTML',
                ],
            ]);

            // 1.5) Visualize schedule as a date_range right after Audience
            // Normalize dates (YYYY-MM-DD) and provide a quick-edit modal for start/end
            [$startDate, $endDate] = $this->scheduleDatesYmdFor($this);

            $editScheduleUrl = static::scheduleEditUrl($rootId, $rootId, true, $pageMode);

            // Only render the DateRange badge when it conveys meaningful information
            // i.e., at least one of start or end is set based on the schedule configuration.
            if ($startDate !== null || $endDate !== null) {
                $fb->dateRange($startDate, $endDate, [
                    'label' => null,
                    'attrs' => static::scheduleModalAttrs($editScheduleUrl),
                    'disabled' => false,
                ]);
            }

            // 2) Root step: the item itself
            $rootTitle = (string)($this->newsletter_item_name ?? ('Item #' . $rootId));
            $isNew = ($this->last_modified === null);

            $fb->mail('flow-item-' . $rootId, $rootTitle, [
                // Consistent Flow UI: stepType = newsletter item name, content = coupon label
                'stepType' => $rootTitle,
                'content' => $couponTypeLabel((int)($this->newsletter_coupon_id ?? 0), ((int)$this->newsletter_coupon_expire_days ?? 0)),
                'description' => (string)($this->newsletter_item_description ?? ''),
                'status' => ($this->status === 'True' ? 'ready' : 'draft'),
//                'icon' => \Components\Base::SVG_MAIL,
                'color' => 'indigo',
                'highlight' => $isNew,
                'highlight_label' => $isNew ? 'MAILBEEZ_NEWSLETTER_ITEM_NEW_HIGHLIGHT_LABEL' : '',
                // coupon indicator (small dot on the icon) similar to winback_advanced
                'icon_indicators' => (function () {
                    try {
                        $couponId = (int)($this->newsletter_coupon_id ?? 0);
                        if ($couponId > 0) {
                            if (!class_exists('\\couponbeez')) {
                                // Attempt to load the coupon engine facade
                                require_once(\mh_cfg('MH_DIR_CONFIG') . 'config_coupon_engine/classes/couponbeez.php');
                            }
                            if (class_exists('\\couponbeez')) {
                                $engine = new \couponbeez();
                                $ii = $engine->get_icon_indicators($couponId, ['pos' => 'tr']);
                                if (is_array($ii) && !empty($ii)) {
                                    return $ii;
                                }
                            }
                        }
                    } catch (\Throwable $e) { /* ignore */ }
                    return '';
                })(),
                // Attach dropdown actions (Editor, Preview, Send test, etc.) using legacy mb_newsletter routes
                // similar to how module flows attach actions in legacy modules.
                'dropdown' => (function () use ($rootId, $campaignId, $pageMode) {
                        $mod = static::mbNewsletter();
                        // Use module-level routes  they open the newsletter editor/preview pages
                        $routes = $mod->getNewsletterAdminActionRoutes($rootId, $rootId, $campaignId, null, null, $pageMode);
                        return $mod->buildFlowStepDropdownFromAdminRoutes($routes);
                })(),
            ]);

            // Add "After Send Actions" step if configured


            $selectedAdd = \mb_newsletter_items::getAfterSendTopicIdsToAssign($rootId);
            $selectedRemove = \mb_newsletter_items::getAfterSendTopicIdsToRemove($rootId);
            if (!empty($selectedAdd) || !empty($selectedRemove)) {
                $fb->step('after-send-' . $rootId, (string)\mh_lng('MAILBEEZ_AFTER_SEND_ACTIONS_STEP_SUBTITLE', 'Topics'), [
                    'subtitle' => (string)\mh_lng('MAILBEEZ_AFTER_SEND_ACTIONS_STEP_LABEL', 'After send'),

                    'icon' => \Components\Base::SVG_GEAR,
                    'color' => 'green',
                    'attrs' => [
                        'command' => 'show-modal',
                        'commandfor' => 'config-modal',
//                        'data-title' => (string)\mh_lng('MAILBEEZ_AFTER_SEND_ACTIONS_DIALOG_TITLE', 'After send actions'),
                        'data-size' => '2xl',
                        'data-url' => \mb_newsletter_items::getAfterSendActionsLink($rootId, $campaignId, $rootId),
                        'hx-target' => '#config-modal-body',
                        'hx-swap' => 'innerHTML',
                    ]
                ]);
            }


            // If there are no children, return single-step flow
            $children = is_array($this->followups ?? null) ? $this->followups : [];
            if (empty($children)) {
                return $fb->toArray();
            }

            // Helper to create a lane from a child, recursively expanding linear chains
            // and inserting nested branches when a node has multiple children.
            // For each follow-up email, we insert a condition and a wait connector before the email step.
            $buildLane = function (self $node, $existingBuilder = null) use (&$buildLane, $rootId, $campaignId, $couponTypeLabel, $pageMode) {
                $b = ($existingBuilder instanceof \Components\CampaignFlow\FlowBuilder)
                    ? $existingBuilder
                    : \Components\CampaignFlow\FlowBuilder::make();

                $current = $node;
                $guard = 0; // protect from infinite loops
                while ($current instanceof self && $guard < 100) {
                    $guard++;
                    $cid = (int)($current->{static::pk()} ?? 0);
                    $title = (string)($current->newsletter_item_name ?? ('Item #' . $cid));
                    
                    
                    // Schedule window for this follow-up item
                    [$cStartDate, $cEndDate] = $this->scheduleDatesYmdFor($current);
                    $childEditUrl = static::scheduleEditUrl($cid, $rootId, false, $pageMode);
                    // Only render the DateRange badge when it conveys meaningful information
                    // i.e., at least one of start or end is set based on the schedule configuration.
                    if ($cStartDate !== null || $cEndDate !== null) {
                        $b->dateRange($cStartDate, $cEndDate, [
                            'label' => null,
                            'attrs' => static::scheduleModalAttrs($childEditUrl),
                            'disabled' => ($cStartDate === null && $cEndDate === null),
                        ]);
                    }
                    // Determine segmentation editor URL (legacy) for this follow-up's audience
                    // so both condition and wait connectors become clickable.
                    $segmentationAttrs = null;
                    try {
                        // Resolve list id: prefer item-specific, then campaign-level
                        $listId = 0;
                        try {
                            $listId = (int)($current->newsletter_list_id ?? 0);
                            if ($listId <= 0) {
                                $campaign = $current->campaign();
                                $listId = (int)($campaign->newsletter_list_id ?? 0);
                            }
                        } catch (\Throwable $e) {
                            $listId = 0;
                        }

                        if ($listId > 0 ) {
                            $mod = static::mbNewsletter();
                            $routes = (array)$mod->getNewsletterSegmentationActionRoutes($listId, $rootId, $pageMode);
                            if (isset($routes['segmentation']['url'])) {
                                $url = (string)$routes['segmentation']['url'];
                                if ($url !== '') {
                                    $segmentationAttrs = [
                                        'command' => 'show-modal',
                                        'commandfor' => 'config-modal',
                                        // open legacy inside iframe wrapper
                                        'data-size' => '4xl',
                                        'data-url' => $url,
                                        'hx-target' => '#config-modal-body',
                                        'hx-swap' => 'innerHTML',
                                    ];
                                }
                            }
                        }
                    } catch (\Throwable $e) { /* ignore; keep non-clickable */ }

                    // Read follow-up segmentation to render condition (event/condition) and wait (timing)
                    // Be defensive in case audience or config is not available
                    try {
                        $followUpSegmentation = (array)($current->audience()->followUpConfiguration());
                    } catch (\Throwable $e) {
                        $followUpSegmentation = [];
                    }

                    // Insert condition and wait before each follow-up email (clickable when segmentation editor is available)
                    // Condition badge: top line = event (uses event-* colors), bottom line = standardized message
                    $condEvent = trim((string)($followUpSegmentation['event'] ?? ''));
                    $condCond = trim((string)($followUpSegmentation['condition'] ?? ''));

                    // normalize event: date_sent -> sent
                    $eventLower = strtolower($condEvent);
                    if ($eventLower === 'date_sent') {
                        $eventLower = 'sent';
                    }

                    $condLower = strtolower($condCond);
                    // Skip badge entirely when event=sent and condition=always (no gating)
                    $skipCondition = ($eventLower === 'sent' && $condLower === 'always');

                    if (!$skipCondition) {
                        $condOptions = $segmentationAttrs ? ['attrs' => $segmentationAttrs] : [];
                        // Enable special rendering mode in ConditionBadge via Connector
                        $condOptions['upper_event_lower_condition'] = true;

                        // Determine lower label ("condition" line). For event=ordered we show no lower label.
                        // Keep previous behavior (use the raw condition text) for all other events.
                        $condLabel = $condLower;
                        if (in_array($eventLower, ['ordered'])) {
                            $condLabel = '';
                        }
                        if ($condLabel == '' || in_array($condLabel, ['always'])) {
                            $condLabel = '';
                        }
                        // Build a combined title/label for hover based on both parts
                        $conditionStepLabel = ($eventLower !== '' ? $eventLower : (string)\mh_lng('MAILBEEZ_FLOW_CONDITION_DEFAULT', 'Condition'))
                            . (($condLabel !== '') ? (' — ' . $condLabel) : '');


                        $eventLabel = $eventLower == 'sent' ? '' : $eventLower;

                        // Upper shows normalized event keyword; lower shows standardized message for common events
                        $b->condition(
                            $conditionStepLabel,
                            $eventLabel,
                            $condLabel,
                            $condOptions
                        );
                    }

                    // Wait label: use timing data if available (days/hours/minutes)
                    $waitLabel = (string)\mh_lng('MAILBEEZ_FLOW_WAIT_GENERIC', 'Wait');
                    $hasTiming = array_key_exists('timing', $followUpSegmentation ?? []);
                    $timing = (array)($followUpSegmentation['timing'] ?? []);
                    $tDays = isset($timing['days']) ? (int)$timing['days'] : 0;
                    $tHours = isset($timing['hours']) ? (int)$timing['hours'] : 0;
                    $tMinutes = isset($timing['minutes']) ? (int)$timing['minutes'] : 0;
                    if ($hasTiming) {
                        // Prefer FlowBuilder's localized day formatter when only days are set (including 0)
                        if ($tHours === 0 && $tMinutes === 0) {
                            $waitLabel = (string)$tDays; // FlowBuilder::wait will localize numeric day labels
                        } else {
                            $parts = [];
                            if ($tDays !== 0) { $parts[] = $tDays . 'd'; }
                            if ($tHours !== 0) { $parts[] = $tHours . 'h'; }
                            if ($tMinutes !== 0) { $parts[] = $tMinutes . 'm'; }
                            if (empty($parts)) { $parts[] = '0m'; }
                            $waitLabel = implode(' ', $parts);
                        }
                    }

                    $b->wait(
                        $waitLabel,
                        $segmentationAttrs ? ['attrs' => $segmentationAttrs] : []
                    );

                    // Then the follow-up email step
                    $b->mail('flow-item-' . $cid, $title, [
                        // Consistent Flow UI: stepType = newsletter item name, content = coupon label
                        'stepType' => $title,
                        'content' => $couponTypeLabel((int)($current->newsletter_coupon_id ?? 0), ((int)$current->newsletter_coupon_expire_days ?? 0)),
                        'description' => (string)($current->newsletter_item_description ?? ''),
                        'status' => ($current->status === 'True' ? 'ready' : 'draft'),
                        'color' => 'indigo',
                        // coupon indicator on the icon, if this follow-up has a coupon selected
                        'icon_indicators' => (function () use ($current) {
                            try {
                                $couponId = (int)($current->newsletter_coupon_id ?? 0);
                                if ($couponId > 0) {
                                    if (!class_exists('\\couponbeez')) {
                                        @require_once(\mh_cfg('MH_DIR_CONFIG') . 'config_coupon_engine/classes/couponbeez.php');
                                    }
                                    if (class_exists('\\couponbeez')) {
                                        $engine = new \couponbeez();
                                        $ii = $engine->get_icon_indicators($couponId, ['pos' => 'tr']);
                                        if (is_array($ii) && !empty($ii)) {
                                            return $ii;
                                        }
                                    }
                                }
                            } catch (\Throwable $e) { /* ignore */ }
                            return '';
                        })(),
                        'dropdown' => (function () use ($cid, $rootId, $campaignId, $pageMode) {
                            $mod = static::mbNewsletter();
                            $routes = $mod->getNewsletterAdminActionRoutes($cid, $rootId, $campaignId, null, null, $pageMode);
                            return $mod->buildFlowStepDropdownFromAdminRoutes($routes);
                        })(),
                    ]);

                    // Add "After Send Actions" step if configured
                    $selectedAdd = \mb_newsletter_items::getAfterSendTopicIdsToAssign($cid);
                    $selectedRemove = \mb_newsletter_items::getAfterSendTopicIdsToRemove($cid);
                    if (!empty($selectedAdd) || !empty($selectedRemove)) {
                        $b->step('after-send-' . $cid, (string)\mh_lng('MAILBEEZ_AFTER_SEND_ACTIONS_STEP_SUBTITLE', 'Topics'), [
                            'subtitle' => (string)\mh_lng('MAILBEEZ_AFTER_SEND_ACTIONS_STEP_LABEL', 'After send'),
                            'icon' => \Components\Base::SVG_GEAR,
                            'color' => 'green',
                            'attrs' => [
                                'command' => 'show-modal',
                                'commandfor' => 'config-modal',
//                                        'data-title' => (string)\mh_lng('MAILBEEZ_AFTER_SEND_ACTIONS_DIALOG_TITLE', 'After send actions'),
                                'data-size' => '2xl',
                                'data-url' => \mb_newsletter_items::getAfterSendActionsLink($cid, $campaignId, $rootId),
                                'hx-target' => '#config-modal-body',
                                'hx-swap' => 'innerHTML',
                            ]
                        ]);
                    }

                    $kids = is_array($current->followups ?? null) ? $current->followups : [];
                    $kidCount = count($kids);
                    if ($kidCount === 1) {
                        // Continue linear chain
                        $current = $kids[0];
                        continue;
                    }

                    if ($kidCount > 1) {
                        // Insert a nested branch right after the current step
                        $closures = [];
                        foreach ($kids as $child) {
                            if (!$child instanceof self) { continue; }
                            $closures[] = function ($laneBuilder) use ($child, &$buildLane) {
                                $buildLane($child, $laneBuilder);
                            };
                        }
                        if (!empty($closures)) {
                            $b->branch($closures, ['gap' => 'md']);
                        }
                    }

                    // Stop after inserting a branch; deeper levels handled inside closures
                    break;
                }

                // Return items array when no builder was provided, otherwise just leave items in builder
                return ($existingBuilder instanceof \Components\CampaignFlow\FlowBuilder)
                    ? $b
                    : $b->toArray();
            };

            if (count($children) === 1) {
                // Linear chain: append lane items directly after root
                $lane = $buildLane($children[0]);
                // Easiest is to merge arrays
                $items = $fb->toArray();
                return array_merge($items, $lane);
            }

            // Multiple children: create a branch with one lane per child (each lane may be a short linear chain)
            // Use FlowBuilder::branch so it can add helpful metadata like top/bottom ordering for 2-lane branches.
            $closures = [];
            foreach ($children as $ch) {
                if (!$ch instanceof self) {
                    continue;
                }
                $closures[] = function ($laneBuilder) use ($ch, &$buildLane) {
                    $buildLane($ch, $laneBuilder);
                };
            }

            if (!empty($closures)) {
                $fb->branch($closures, ['gap' => 'md']);
            }

            return $fb->toArray();
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * @return bool
     */
    public function isFlowCached(): bool
    {
        $lang = $_SESSION['language'] ?? 'default';
        $itemId = (int)($this->{static::pk()} ?? 0);
        if ($itemId <= 0) {
            return false;
        }

        return \Components\Support\FlowHtmlCache::has(
            \Components\Support\FlowHtmlCache::OWNER_NEWSLETTER_ITEM,
            $itemId,
            $lang
        );
    }

    /** @var int Number of uncached flows rendered inline during this request. */
    private static int $inlineFlowBudgetUsed = 0;

    /** @var int Maximum number of uncached flows to render inline per request before switching to lazy loading. */
    private static int $inlineFlowBudget = 10;

    /**
     * Get the rendered flow visualization HTML, utilizing cache if available.
     * When $useCache is true and the flow is not yet cached, up to $inlineFlowBudget
     * flows are rendered inline per request. Additional uncached flows return a
     * lazy-loading placeholder that fetches and caches via HTMX on viewport intersection.
     * @param bool $useCache
     * @param bool $lazy When true (default) and no cache exists (and inline budget exhausted), return a lazy-loading placeholder instead of rendering inline.
     * @return string
     */
    public function renderFlow(bool $useCache = true, bool $lazy = true): string
    {
        $lang = $_SESSION['language'] ?? 'default';
        $itemId = (int)($this->{static::pk()} ?? 0);

        if ($useCache && $itemId > 0) {
            $cached = \Components\Support\FlowHtmlCache::get(
                \Components\Support\FlowHtmlCache::OWNER_NEWSLETTER_ITEM,
                $itemId,
                $lang
            );
            if (is_string($cached) && $cached !== '') {
                // Resolve dynamic placeholders (e.g., audience count) right before output,
                // but do not mutate the cached HTML.
                try {
                    return Audience::renderCountPlaceholders($cached);
                } catch (\Throwable $e) {
                    return $cached;
                }
            }
        }

        // When lazy mode is enabled, no cache exists, and the inline budget is
        // exhausted, return a placeholder that triggers an HTMX request to render
        // and cache the flow on viewport intersection.
        // Special case: if we are in a navigation/highlighting context targeting a flow step,
        // we bypass lazy-loading to ensure the flow is in the DOM for the scroll/pulse effect.
        $highlightId = (string)\mh_get('highlight_step', '', 'string');
        $isTargeted = ($highlightId !== '' && (strpos($highlightId, 'flow-item-') === 0 || strpos($highlightId, 'flow-newsletter-' . $itemId) === 0 || strpos($highlightId, 'flow-custom-automation-' . $itemId) === 0));

        if ($lazy && $useCache && !$isTargeted && self::$inlineFlowBudgetUsed >= self::$inlineFlowBudget) {
            $itemId = (int)($this->{static::pk()} ?? 0);
            $lazyUrl = \Components\Router::url('newsletter_item_flow', ['partial' => 1, 'id' => $itemId]);
            $lazyUrlEsc = htmlspecialchars($lazyUrl, ENT_QUOTES, 'UTF-8');
            return '<div hx-get="' . $lazyUrlEsc . '" hx-trigger="intersect once delay:1s" hx-swap="outerHTML" hx-disinherit="*" data-no-global-loader="1">'
                . \Components\Resources\ItemList::flowLoader()
                . '</div>';
        }

        // Track inline budget usage for uncached flows
        if ($lazy && $useCache) {
            self::$inlineFlowBudgetUsed++;
        }

        // Ensure we have the followups tree ready (flow may rely on it)
        if (!isset($this->followups)) {
            try { $this->loadFollowupsTree(true); } catch (\Throwable $e) { /* ignore */ }
        }

        $flowItems = [];
        try {
            $flowItems = is_array($this->flow()) ? $this->flow() : [];
        } catch (\Throwable $e) {
            $flowItems = [];
        }

        if (empty($flowItems)) {
            return '<div class="p-4 text-xs text-secondary">No flow available.</div>';
        }

        // Render the flow HTML which may contain lightweight placeholders (e.g., [[aud-count:ID]])
        $html = \Components\CampaignFlow\FlowViz::render($flowItems, [
            'orientation' => 'horizontal',
            'size' => 'sm',
            'editable' => false,
            'class' => 'py-2',
            'id' => 'flow-viz-' . $this->{static::pk()},
        ]);

        if ($useCache && $itemId > 0) {
            \Components\Support\FlowHtmlCache::set(
                \Components\Support\FlowHtmlCache::OWNER_NEWSLETTER_ITEM,
                $itemId,
                $lang,
                $html
            );
        }

        // Resolve dynamic placeholders just before sending to the client. Keep cache untouched.
        try {
            return Audience::renderCountPlaceholders($html);
        } catch (\Throwable $e) {
            return $html;
        }
    }

    /**
     * Invalidate the flow cache for this item and its ancestors.
     * @param bool $persist
     */
    public function invalidateFlowCache(bool $persist = true): void
    {
        $itemId = (int)($this->{static::pk()} ?? 0);
        if ($itemId > 0) {
            \Components\Support\FlowHtmlCache::invalidateOwner(
                \Components\Support\FlowHtmlCache::OWNER_NEWSLETTER_ITEM,
                $itemId
            );
        }

        $parent = $this->parent();
        if ($parent instanceof self) {
            $parent->invalidateFlowCache(true);
        }
    }

    /**
     * Invalidate the flow cache for a given item ID.
     * @param int $id
     */
    public static function invalidateFlowCacheById(int $id): void
    {
        if ($id <= 0) {
            return;
        }
        $item = static::find($id);
        if ($item instanceof self) {
            // find uppermost parent (root) to ensure we invalidate the correct flow cache
            $root = $item;
            while ($parent = $root->parent()) {
                $root = $parent;
            }
            $root->invalidateFlowCache();
        }
    }

    public static function updateLastModified(int $id): bool
    {
        if ($id <= 0) return false;
        return (bool)\mh_db_query("UPDATE " . static::table() . " SET last_modified = NOW() WHERE " . static::pk() . " = " . (int)$id);
    }

    public static function updateLastModifiedIfNull(int $id): bool
    {
        if ($id <= 0) return false;
        return (bool)\mh_db_query("UPDATE " . static::table() . " SET last_modified = NOW() WHERE " . static::pk() . " = " . (int)$id . " AND last_modified IS NULL");
    }

    public function updateLastModifiedWithRoot(): void
    {
        $id = (int)($this->attributes[static::pk()] ?? 0);
        if ($id <= 0) return;

        // Update self
        self::updateLastModified($id);

        // Update root if different
        $rootId = $this->resolveFlowRootId();
        if ($rootId > 0 && $rootId !== $id) {
            self::updateLastModified($rootId);
        }
    }

    public function update(): bool
    {
        $this->invalidateFlowCache(false);
        $ok = parent::update();
        if ($ok) {
            $this->updateLastModifiedWithRoot();
            $this->syncFlowStatusAfterSave(false);
        } else {
            $this->flowStatusTouchedInSave = false;
        }
        return $ok;
    }

    protected function insert(): bool
    {
        $ok = parent::insert();
        if ($ok) {
            // If it's a child item, update the root's last_modified
            if ($this->isFollowup()) {
                $rootId = $this->resolveFlowRootId();
                if ($rootId > 0) {
                    self::updateLastModified($rootId);
                }
            }
            $this->syncFlowStatusAfterSave(true);
        } else {
            $this->flowStatusTouchedInSave = false;
        }
        return $ok;
    }

    protected function syncFlowStatusAfterSave(bool $isInsert): void
    {
        try {
            $id = (int)($this->attributes[static::pk()] ?? 0);
            if ($id <= 0) {
                return;
            }

            $followupId = (int)($this->attributes['newsletter_followup_id'] ?? 0);
            $rootId = $this->resolveFlowRootId();
            if ($rootId <= 0) {
                return;
            }

            if ($this->flowStatusTouchedInSave) {
                $flowStatus = static::normalizeFlowStatusValue($this->attributes['flow_status'] ?? 'True');
                $this->persistFlowStatusForRoot($rootId, $flowStatus);
                return;
            }

            if ($isInsert && $followupId > 0) {
                $rootStatus = static::readFlowStatusByItemId($rootId);
                $this->persistFlowStatusForRoot($rootId, $rootStatus);
            }
        } catch (\Throwable $e) {
            // best effort only
        } finally {
            $this->flowStatusTouchedInSave = false;
        }
    }

    public function delete(): bool
    {
        $this->invalidateFlowCache(false);
        // If it's a followup, update the root's last_modified before deleting
        if ($this->isFollowup()) {
            $rootId = $this->resolveFlowRootId();
            if ($rootId > 0) {
                self::updateLastModified($rootId);
            }
        }
        return parent::delete();
    }

    /**
     * Resolve type constants for newsletter items, preferring mb_newsletter when available.
     * @return array{default:int, followup:int}
     */
    protected static function itemTypeDefs(): array
    {

        $default = (int)\mb_newsletter::ITEM_TYPE_DEFAULT;
        $followup = (int)\mb_newsletter::ITEM_TYPE_FOLLOWUP;

        return ['default' => $default, 'followup' => $followup];
    }

    public function isInitial(): bool
    {
        $types = static::itemTypeDefs();
        return ((int)($this->newsletter_followup_id ?? 0)) === 0
            && ((int)($this->newsletter_item_type ?? 0)) === (int)($types['default'] ?? 0);
    }

    public function isFollowup(): bool
    {
        $types = static::itemTypeDefs();
        return ((int)($this->newsletter_followup_id ?? 0)) > 0
            && ((int)($this->newsletter_item_type ?? 0)) === (int)($types['followup'] ?? 1);
    }

    public function isLocked(): bool
    {
        return ((string)($this->status ?? '') === 'True');
    }

    /**
     * Determine the schedule state of this newsletter item.
     *
     * States (in evaluation order):
     *  - draft:     status is not 'True' (disabled)
     *  - completed: completed_at is set (finished via stop_when_finished or manual)
     *  - scheduled: start date is in the future
     *  - ended:     end date is in the past
     *  - active:    currently eligible (within date range or unconstrained)
     *
     * Returns an array with:
     *  - state: one of 'draft'|'completed'|'scheduled'|'ended'|'active'
     *  - label: short, user-friendly wording for the state
     */
    public function scheduleState(): array
    {
        $stopWhenFinished = (int)($this->stop_when_finished ?? 0) === 1;
        $suffix = $stopWhenFinished ? ' · ' . (string)\mh_lng('MAILBEEZ_SCHEDULE_STOPS_WHEN_FINISHED', 'stops when finished') : '';

        // Draft: status is not enabled
        if ((string)($this->status ?? '') !== 'True') {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_DRAFT', 'Draft') . $suffix;
            return ['state' => 'draft', 'label' => $label];
        }

        $aggregatedRootActive = false;
        if ($this->isInitial()) {
            try {
                $aggregatedRootActive = self::isAggregatedRootFlowActive($this);
            } catch (\Throwable $e) { /* keep false */ }
        }

        // Completed: completed_at is set
        $completedAt = $this->completed_at ?? null;
        if ($completedAt !== null && $completedAt !== '' && !self::isSentinelDateValue($completedAt, self::SENTINEL_COMPLETED_AT) && !$aggregatedRootActive) {
            $completedTs = self::parseScheduleBoundaryTimestamp($completedAt);
            $dateStr = $completedTs ? date('j M Y', $completedTs) : '';
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_COMPLETED', 'Completed') . ($dateStr ? ' ' . $dateStr : '');
            return ['state' => 'completed', 'label' => $label ];
        }

        // Flow paused? Applies to parent items (own flow_status) and to children (parent's flow_status)
        $flowPaused = false;
        try {
            $own = strtolower((string)($this->flow_status ?? 'True')) !== 'false' && (string)($this->flow_status ?? 'True') !== ''
                ? ((string)$this->flow_status === 'True')
                : (strtolower((string)$this->flow_status) === 'true');
            // ownActive = true when 'True'; paused when false
            $ownActive = ($this->isInitial()) ? $own : true; // own flag only relevant for parents
            if ($this->isInitial()) {
                $flowPaused = !$ownActive;
            } else {
                // child: respect parent
                $parent = $this->parent();
                if ($parent instanceof self) {
                    $pActive = ((string)($parent->flow_status ?? 'True') === 'True');
                    $flowPaused = !$pActive;
                }
            }
        } catch (\Throwable $e) { /* default: not paused */ }

        $now = time();
        $startTs = self::parseScheduleBoundaryTimestamp($this->newsletter_item_start ?? null, false);
        $endTs = self::parseScheduleBoundaryTimestamp($this->newsletter_item_end ?? null, true);

        // For automation-type schedules, ignore end date constraints entirely.
        // Automation flows are meant to run continuously (unless paused/completed),
        // so a past end date must not downgrade the state to "ended".
        try {
            if ($this->scheduleTypeCode() === 'automation') {
                $endTs = null;
            }
        } catch (\Throwable $e) { /* keep original endTs on failure */ }

        // When flow is paused, reflect that state regardless of scheduling window (but after completed)
        if ($flowPaused) {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_PAUSED', 'Paused');
            return ['state' => 'paused', 'label' => $label];
        }

        if ($aggregatedRootActive) {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_ACTIVE', 'Active') . $suffix;
            return ['state' => 'active', 'label' => $label];
        }

        // Scheduled: start date is in the future
        if ($startTs !== null && $startTs > $now) {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_PENDING', 'Starts on ') . date('j M Y', $startTs) . $suffix;
            return ['state' => 'scheduled', 'label' => $label];
        }

        // Ended: end date is in the past (not applicable to automation due to override above)
        if ($endTs !== null && $endTs < $now) {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_PAST', 'Ended on ') . date('j M Y', $endTs);
            return ['state' => 'ended', 'label' => $label ];
        }

        // Active: within range or unconstrained
        if ($endTs !== null) {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_ACTIVE_UNTIL', 'Active until ') . date('j M Y', $endTs) . $suffix;
        } else {
            $label = (string)\mh_lng('MAILBEEZ_SCHEDULE_ACTIVE', 'Active') . $suffix;
        }
        return ['state' => 'active', 'label' => $label];
    }

    /**
     * Return only the schedule state key: pending|active|past
     */
    public function scheduleStateKey(): string
    {
        $st = $this->scheduleState();
        return isset($st['state']) ? (string)$st['state'] : 'active';
    }

    public function isSchedulePending(): bool
    {
        return in_array($this->scheduleStateKey(), ['pending', 'scheduled'], true);
    }

    public function isScheduleActive(): bool
    {
        return $this->scheduleStateKey() === 'active';
    }

    public function isSchedulePast(): bool
    {
        return in_array($this->scheduleStateKey(), ['past', 'ended', 'completed'], true);
    }

    public function isCompleted(): bool
    {
        return $this->scheduleStateKey() === 'completed';
    }

    public function isDraft(): bool
    {
        return $this->scheduleStateKey() === 'draft';
    }

    /**
     * Hint for UI rendering: determine whether this item's Flow should be pre-rendered
     * on the initial request. We consider items in states where the user is most likely
     * to inspect or edit the flow: draft, active, scheduled.
     * Other states should lazy-load on expand + intersect to keep the DOM light.
     */
    public function shouldPreRenderFlow(): bool
    {
        try {
            $id = (int)($this->newsletter_item_id ?? 0);
            if ($id > 0) {
                // Eager render if this root item is specifically opened
                $openId = (int)mh_get('open_id', 0, 'int');
                if ($openId === $id) {
                    return true;
                }
                // Eager render if any step in this root's flow is targeted for highlighting
                $highlightStep = (string)mh_get('highlight_step', '', 'string');
                if ($highlightStep !== '' && strpos($highlightStep, 'flow-item-' . $id) !== false) {
                    return true;
                }
                // Fallback: check if the highlight step might be a follow-up of this root
                if ($highlightStep !== '' && strpos($highlightStep, 'flow-item-') === 0) {
                    $targetId = (int)substr($highlightStep, strlen('flow-item-'));
                    if ($targetId > 0 && $targetId !== $id) {
                        $ni = self::find($targetId);
                        if ($ni && (int)$ni->resolveFlowRootId() === $id) {
                            return true;
                        }
                    }
                }
            }
            $state = strtolower(trim((string)$this->scheduleStateKey()));
        } catch (\Throwable $e) {
            $state = '';
        }
        return in_array($state, ['draft', 'active', 'scheduled'], true);
    }

    /**
     * Build a concise human-readable schedule summary reflecting the composite
     * scheduling (start_mode + schedule_type + automation_end_mode).
     * Example outputs:
     *  - "Starts immediately. Stops when finished."
     *  - "Starts Jan 5, 2026. Stops Feb 2, 2026."
     *  - "Starts immediately. Runs indefinitely."
     */
    public function buildScheduleSummary(): string
    {
        try {
            // Read stored dates
            $sdRaw = $this->newsletter_item_start ?? null;
            $edRaw = $this->newsletter_item_end ?? null;

            // Normalize to timestamps for formatting
            $sdTs = self::parseScheduleBoundaryTimestamp($sdRaw, false);
            $edTs = self::parseScheduleBoundaryTimestamp($edRaw, true);

            // Derive UI flags from stored values (mirrors augmentFormValues logic)
            $startMode = ($sdTs !== null) ? 'date' : 'now';
            $stopWhenFinished = (int)($this->stop_when_finished ?? 0) === 1;
            $hasEnd = ($edTs !== null);

            // schedule_type: bulk_mail | scheduled_window | automation
            if ($stopWhenFinished) {
                $scheduleType = 'bulk_mail';
            } elseif ($hasEnd) {
                $scheduleType = 'scheduled_window';
            } else {
                $scheduleType = 'automation';
            }

            // automation_end_mode: never | date (only relevant for automation)
            $automationEndMode = ($scheduleType === 'automation' && $hasEnd) ? 'date' : 'never';

            // i18n tokens (reuse the same keys as other summaries)
            $t_starts_now        = (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STARTS_NOW', 'Starts immediately');
            $t_starts_on         = (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STARTS_ON', 'Starts');
            $t_stops_finished    = (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STOPS_FINISHED', 'Stops when finished');
            $t_stops_on          = (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_STOPS_ON', 'Stops');
            $t_runs_indefinitely = (string)\mh_lng('MAILBEEZ_SCHEDULE_SUMMARY_RUNS_INDEFINITELY', 'Runs indefinitely');

            // Locale-aware date formatting (server-side) to mirror client-side date-fns formatting
            // Falls back gracefully to English if intl extension is unavailable
            $fmt = function (?int $ts): string {
                if ($ts === null) return '';
                try {
                    $lc = strtolower((string)\mh_session('language_code', 'en'));
                    // Map short code to a likely ICU locale; allow passing through other locales as-is
                    $localeMap = [
                        'de' => 'de_DE',
                        'en' => 'en_US',
                    ];
                    $icuLocale = $localeMap[$lc] ?? ($lc ?: 'en_US');
                    // Pattern per locale (keep EN legacy shape; DE uses day dot + short month)
                    $pattern = ($lc === 'de') ? 'd. MMM yyyy' : 'MMM d, yyyy';
                    if (class_exists('\\IntlDateFormatter')) {
                        $tz = date_default_timezone_get();
                        $idf = new \IntlDateFormatter($icuLocale, \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, $tz, null, $pattern);
                        $out = $idf->format($ts);
                        if ($out !== false && $out !== '') return (string)$out;
                    }
                    // Fallback without intl: approximate using PHP date() (English month names)
                    return ($lc === 'de') ? date('j. M Y', $ts) : date('M j, Y', $ts);
                } catch (\Throwable $e) {
                    return date('M j, Y', $ts);
                }
            };

            $parts = [];
            if ($startMode === 'date' && $sdTs !== null) {
                $parts[] = $t_starts_on . ' ' . $fmt($sdTs);
            } else {
                $parts[] = $t_starts_now;
            }

            if ($scheduleType === 'bulk_mail') {
                $parts[] = $t_stops_finished;
                if ($hasEnd) {
                    $parts[] = $t_stops_on . ' ' . $fmt($edTs);
                }
            } elseif ($scheduleType === 'scheduled_window' && $hasEnd) {
                $parts[] = $t_stops_on . ' ' . $fmt($edTs);
            } elseif ($scheduleType === 'automation' && $automationEndMode === 'date' && $hasEnd) {
                $parts[] = $t_stops_on . ' ' . $fmt($edTs);
            } elseif ($scheduleType === 'automation') {
                $parts[] = $t_runs_indefinitely;
            }

            return implode('. ', array_filter($parts)) . '.';
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Can the flow_status be toggled for this item?
     * Disabled when the item is draft, ended or completed (per issue requirement).
     */
    public function canToggleFlowStatus(): bool
    {
        $state = $this->scheduleStateKey();
        if (in_array($state, ['draft', 'ended', 'completed'], true)) {
            return false;
        }
        // Followups inherit parent's flow_status; only initial items expose the toggle
        if (!$this->isInitial()) { return false; }
        return true;
    }

    protected static function normalizeFlowStatusValue($raw): string
    {
        if (is_bool($raw)) {
            return $raw ? 'True' : 'False';
        }
        if (is_int($raw) || is_float($raw)) {
            return ((int)$raw === 1) ? 'True' : 'False';
        }

        $v = strtolower(trim((string)$raw));
        if ($v === '') {
            return 'False';
        }
        $truthy = in_array($v, ['on', '1', 'true', 'yes', 'y', 't', 'enabled', 'active', 'checked'], true);
        return $truthy ? 'True' : 'False';
    }

    protected static function readFlowStatusByItemId(int $id): string
    {
        if ($id <= 0 || self::isDev()) {
            return 'True';
        }

        try {
            $q = \mh_db_query(
                "SELECT flow_status FROM " . static::table()
                . " WHERE " . static::pk() . " = " . (int)$id
                . " LIMIT 1"
            );
            $row = \mh_db_fetch_array($q);
            \mh_db_free_result($q);
            if ($row && array_key_exists('flow_status', $row)) {
                if ($row['flow_status'] === null || trim((string)$row['flow_status']) === '') {
                    return 'True';
                }
                return static::normalizeFlowStatusValue($row['flow_status']);
            }
        } catch (\Throwable $e) {
            // best effort only
        }

        return 'True';
    }

    public function resolveFlowRootId(): int
    {
        $id = (int)($this->attributes[static::pk()] ?? 0);
        if ($id <= 0) {
            return 0;
        }

        $followupId = (int)($this->attributes['newsletter_followup_id'] ?? 0);
        if ($followupId <= 0) {
            return $id;
        }

        $rootId = (int)($this->attributes['newsletter_root_id'] ?? 0);
        if ($rootId > 0) {
            return $rootId;
        }

        try {
            $currentId = $id;
            $seen = [];
            $guard = 0;

            while ($currentId > 0 && $guard < 1000) {
                if (isset($seen[$currentId])) {
                    break;
                }
                $seen[$currentId] = true;

                $q = \mh_db_query(
                    "SELECT " . static::pk() . " AS item_id, newsletter_followup_id, IFNULL(newsletter_root_id, 0) AS newsletter_root_id"
                    . " FROM " . static::table()
                    . " WHERE " . static::pk() . " = " . (int)$currentId
                    . " LIMIT 1"
                );
                $row = \mh_db_fetch_array($q);
                \mh_db_free_result($q);

                if (!$row) {
                    break;
                }

                $rowRoot = (int)($row['newsletter_root_id'] ?? 0);
                if ($rowRoot > 0) {
                    return $rowRoot;
                }

                $parentId = (int)($row['newsletter_followup_id'] ?? 0);
                if ($parentId <= 0) {
                    $rowId = (int)($row['item_id'] ?? 0);
                    return $rowId > 0 ? $rowId : $id;
                }

                $currentId = $parentId;
                $guard++;
            }
        } catch (\Throwable $e) {
            // best effort only
        }

        return $id;
    }

    protected static function collectFlowItemIds(int $rootId): array
    {
        if ($rootId <= 0 || self::isDev()) {
            return [];
        }

        $ids = [(int)$rootId => true];

        try {
            $q = \mh_db_query(
                "SELECT " . static::pk() . " AS item_id"
                . " FROM " . static::table()
                . " WHERE newsletter_root_id = " . (int)$rootId
            );
            while ($row = \mh_db_fetch_array($q)) {
                $itemId = (int)($row['item_id'] ?? 0);
                if ($itemId > 0) {
                    $ids[$itemId] = true;
                }
            }
            \mh_db_free_result($q);
        } catch (\Throwable $e) {
            // best effort only
        }

        try {
            $frontier = [(int)$rootId];
            $guard = 0;
            while (!empty($frontier) && $guard < 1000) {
                $next = [];
                foreach (array_chunk($frontier, 200) as $chunk) {
                    $in = implode(',', array_map('intval', $chunk));
                    if ($in === '') {
                        continue;
                    }
                    $q = \mh_db_query(
                        "SELECT " . static::pk() . " AS item_id"
                        . " FROM " . static::table()
                        . " WHERE newsletter_followup_id IN (" . $in . ")"
                    );
                    while ($row = \mh_db_fetch_array($q)) {
                        $childId = (int)($row['item_id'] ?? 0);
                        if ($childId > 0 && !isset($ids[$childId])) {
                            $ids[$childId] = true;
                            $next[] = $childId;
                        }
                    }
                    \mh_db_free_result($q);
                }
                $frontier = $next;
                $guard++;
            }
        } catch (\Throwable $e) {
            // best effort only
        }

        return array_map('intval', array_keys($ids));
    }

    protected function persistFlowStatusForRoot(int $rootId, string $flowStatus): bool
    {
        if ($rootId <= 0 || self::isDev()) {
            return false;
        }

        $ids = static::collectFlowItemIds($rootId);
        if (empty($ids)) {
            $ids = [$rootId];
        }

        try {
            $flowStatus = static::normalizeFlowStatusValue($flowStatus);
            foreach (array_chunk($ids, 500) as $chunk) {
                $in = implode(',', array_map('intval', $chunk));
                if ($in === '') {
                    continue;
                }
                \mh_db_query(
                    "UPDATE " . static::table()
                    . " SET flow_status = '" . \mh_db_input($flowStatus) . "', last_modified = NOW()"
                    . " WHERE " . static::pk() . " IN (" . $in . ")"
                );
            }
            $this->attributes['flow_status'] = $flowStatus;
            $this->invalidateFlowCache(true);
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Persist a new flow_status and invalidate the cached flow.
     */
    public function setFlowStatus(bool $active): bool
    {
        return $this->setFlowStatusForFlow($active);
    }

    public function setFlowStatusForFlow(bool $active): bool
    {
        if (self::isDev()) return false;
        $id = (int)($this->attributes[static::pk()] ?? 0);
        if ($id <= 0) return false;

        $rootId = $this->resolveFlowRootId();
        if ($rootId <= 0) {
            $rootId = $id;
        }

        $flowStatus = $active ? 'True' : 'False';
        return $this->persistFlowStatusForRoot($rootId, $flowStatus);
    }

    /**
     * Static helper: update by id without preloading the full row.
     */
    public static function setFlowStatusById(int $id, bool $active): bool
    {
        if ($id <= 0) return false;
        $it = static::find($id);
        if (!$it instanceof self) { return false; }
        if (!$it->canToggleFlowStatus()) { return false; }
        return $it->setFlowStatus($active);
    }

    /**
     * Re-activate a completed newsletter item.
     * Resets completed_at to the open-state sentinel so it becomes eligible again (when date gates allow).
     * Does NOT change stop_when_finished or scheduling dates.
     *
     * @return bool True if the update was executed
     */
    public function reactivate(): bool
    {
        if (self::isDev()) return false;

        $id = (int)($this->attributes[static::pk()] ?? 0);
        if ($id <= 0) return false;

        try {
            \mh_db_query(
                "UPDATE " . static::table()
                . " SET completed_at = '" . self::SENTINEL_COMPLETED_AT . "', last_modified = NOW()"
                . " WHERE " . static::pk() . " = " . $id
            );
            $this->attributes['completed_at'] = self::SENTINEL_COMPLETED_AT;
            $this->invalidateFlowCache(true);
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Normalize an incoming date value to 'Y-m-d' or null when invalid/empty.
     * Accepts strings like '2025-01-05' or any strtotime()-parsable value or timestamps.
     * @param mixed $v
     */
    protected static function normalizeDateYmd($v): ?string
    {
        if ($v === null || $v === '') {
            return null;
        }
        // timestamp
        if (is_int($v)) {
            if ($v <= 0) {
                return null;
            }
            return date('Y-m-d', $v);
        }
        // string
        if (is_string($v)) {
            $s = trim($v);
            if ($s === '') {
                return null;
            }
            if ($s === '0') {
                return null;
            }
            // If already YYYY-MM-DD assume valid and return as-is
            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
                return $s;
            }
            $ts = @strtotime($s);
            if ($ts === false || $ts <= 0) {
                return null;
            }
            return date('Y-m-d', $ts);
        }
        return null;
    }

    /**
     * Return [startYmd, endYmd] for a given NewsletterItem-like object.
     * @return array{0:?string,1:?string}
     */
    protected function scheduleDatesYmdFor(self $item): array
    {
        $start = self::normalizeDateColumnForUi($item->newsletter_item_start ?? null, 'newsletter_item_start');
        $end = self::normalizeDateColumnForUi($item->newsletter_item_end ?? null, 'newsletter_item_end');
        $start = ($start === '') ? null : $start;
        $end = ($end === '') ? null : $end;
        return [$start, $end];
    }

    /**
     * Build the hx-get URL to edit an item's schedule (start/end) in the modal.
     */
    protected static function scheduleEditUrl(int $itemId, ?int $flow_id = null, $updateStatusBadge = false, $page_mode = null): string
    {
        if ($page_mode === null) {
            $page_mode = \Components\Newsletter\PageConfig::fromRequest()->mode;
        }

        $params = [
            'partial' => 1,
            'mode' => 'edit',
            'id' => $itemId,
            'page_mode' => $page_mode,
            // Open only the new composite schedule editor. The underlying
            // date fields are handled by the controller when this field is posted.
            'fields' => 'schedule_config',
            'autoclose' => 'true',
            'event_suffix' => 'expandable:refresh',
            // Hint the modal to emit an out-of-band status badge update when saving
            // via the schedule date range editor. This keeps list badges in sync
            // without affecting other edit contexts.
            'updateStatusBadge' => $updateStatusBadge ? '1' : '',
        ];
        if (is_int($flow_id) && $flow_id > 0) {
            // Ensure the HX-Trigger targets the parent/root flow id rather than the child id being edited
            $params['ui_event_id'] = (string)$flow_id;
        }
        return \Components\Router::url('newsletter_item_modal', $params);
    }

    /**
     * Modal attribute set for schedule editing via FlowBuilder blocks.
     * Returns the attrs array suitable for FlowBuilder step/connector options.
     * @param string $hxGetUrl URL built by scheduleEditUrl()
     * @param string $titleKey Language key for modal title
     * @param string $titleDefault Fallback modal title
     * @param string $size Preferred modal size
     * @return array<string,string>
     */
    protected static function scheduleModalAttrs(string $hxGetUrl, string $titleKey = 'MAILBEEZ_EDIT_SCHEDULE', string $titleDefault = 'Edit Schedule', string $size = '4xl-sm'): array
    {
        return [
            'command' => 'show-modal',
            'commandfor' => 'config-modal',
            'data-title' => (string)\mh_lng($titleKey, $titleDefault),
            'data-size' => $size,
            'hx-get' => $hxGetUrl,
            'hx-target' => '#config-modal-body',
            'hx-swap' => 'innerHTML',
        ];
    }

    /**
     * Read the user-selected schedule_type persisted in the meta column.
     * Returns null when not set. For legacy items without a stored value, callers
     * may want to treat the default as 'scheduled_window'.
     */
    public function getStoredScheduleType(): ?string
    {
        try {
            // Prefer the dedicated DB column when available
            $col = isset($this->attributes['schedule_type']) ? (string)$this->attributes['schedule_type'] : '';
            if ($col !== '') {
                return $col;
            }
        } catch (\Throwable $e) { /* ignore */ }
        return null;
    }

    /**
     * Determine the schedule type code for this item.
     * Returns one of: 'bulk_mail' | 'scheduled_window' | 'automation' | ''
     *
     * Logic:
     *  - Prefer the stored schedule_type when present (getStoredScheduleType)
     *  - Fallback for legacy rows without stored type:
     *      - stop_when_finished == 1 => 'bulk_mail'
     *      - newsletter_item_end set => 'scheduled_window'
     *      - otherwise => 'automation'
     */
    public function scheduleTypeCode(): string
    {
        try {
            $st = (string)($this->getStoredScheduleType() ?? '');
            if ($st !== '') {
                return $st;
            }

            // Fallback for legacy items
            if ($this->isFollowup()) {
                return 'automation';
            }

            // stop_when_finished == 1 => 'bulk_mail'
            if ((int)($this->stop_when_finished ?? 0) === 1) {
                return 'bulk_mail';
            }

            // newsletter_item_end set => 'scheduled_window'
            $end = (string)($this->newsletter_item_end ?? '');
            if ($end !== '' && $end !== '0000-00-00' && !self::isSentinelDateValue($end, self::SENTINEL_NEWSLETTER_ITEM_END)) {
                return 'scheduled_window';
            }

            return 'newsletter';
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Map the schedule type code to a BeezUI page mode ('newsletter' | 'automation').
     */
    public function getPageMode(): string
    {
        return PageConfig::resolveModeFromScheduleType($this->scheduleTypeCode(), $this->isFollowup());
    }

    /**
     * Localized short label for the schedule type (for list sublines, badges, etc.).
     * Uses mh_lng() with sensible defaults.
     */
    public function scheduleTypeLabel(): string
    {
        $st = $this->scheduleTypeCode();
        switch ($st) {
            case 'bulk_mail':
                return (string)\mh_lng('MAILBEEZ_NEWSLETTER_SCHEDULE_BULK_MAIL', 'Bulk Mail');
            case 'scheduled_window':
                return (string)\mh_lng('MAILBEEZ_NEWSLETTER_SCHEDULE_WINDOW', 'Scheduled Window');
            case 'automation':
            default:
                return (string)\mh_lng('MAILBEEZ_NEWSLETTER_SCHEDULE_AUTOMATION', 'Automation');
        }
    }

    /**
     * Central mapping for schedule_type → UI tokens and icons.
     * Keep this small and single‑sourced for easy maintenance.
     *
     * Keys:
     *  - token: palette token used for soft badges and icon background
     *  - icon:  SVG markup (Components\Base::SVG_*) representing the type
     *  - label_key/label_fallback: i18n info for compact badges (optional)
     *
     * @return array<string, array{token:string, icon:string, label_key?:string, label_fallback?:string}>
     */
    public static function scheduleTypeUiMap(): array
    {
        return [
            'bulk_mail' => [
                'token' => 'orange',
                'icon'  => \Components\Base::SVG_MEGAPHONE,
                'label_key' => 'MAILBEEZ_NEWSLETTER_SCHEDULE_BULK_MAIL',
                'label_fallback' => 'Bulk Mail',
            ],
            'scheduled_window' => [
                'token' => 'green',
                'icon'  => \Components\Base::SVG_CALENDAR_RANGE,
                'label_key' => 'MAILBEEZ_NEWSLETTER_SCHEDULE_WINDOW',
                'label_fallback' => 'Scheduled Window',
            ],
            'automation' => [
                'token' => 'brand',
                'icon'  => \Components\Base::SVG_FLOWER,
                'label_key' => 'MAILBEEZ_NEWSLETTER_SCHEDULE_AUTOMATION',
                'label_fallback' => 'Automation',
            ],
        ];
    }

    /**
     * Render a small type icon for use in various UI contexts (Calendar, List rows, etc.).
     * Centralized to ensure visual consistency across the app.
     *
     * @param string|null $token  The color token (e.g., 'orange', 'green', 'brand')
     * @param string|null $icon   The SVG markup
     * @param array $options      Optional overrides: size (default size-3), rounded (default rounded), icon_size (default size-1.5)
     */
    public static function renderScheduleTypeIconHtml(?string $token, ?string $icon, array $options = []): string
    {
        if (empty($icon)) {
            return '';
        }

        $size     = $options['size'] ?? 'size-3';
        $rounded  = $options['rounded'] ?? 'rounded';
        $iconSize = $options['icon_size'] ?? 'size-1.5';

        $bg = \Components\Support\Palette::dotBg($token ?: 'indigo', 'indigo');
        $bgClass = trim($bg . ' text-white');

        return '<span class="inline-flex shrink-0 items-center justify-center ' . $size . ' ' . $rounded . ' ' . $bgClass . ' [&_svg]:' . $iconSize . '">' . $icon . '</span>';
    }

    /**
     * Dynamic radiocard options for schedule_type field, filtered by page_mode.
     * Also provides grid layout overrides (cards_per_row) based on mode.
     *
     * @return array{options: array, grid: array}
     */
    public static function scheduleTypeOptions(): array
    {
        $all = self::scheduleTypeUiMap();
        $allowed = ['bulk_mail', 'scheduled_window', 'automation'];
        $cols = 3;

        try {
            // Support page_mode context: only filter by applicability if explicitly requested.
            $requestMode = \mh_get('page_mode', '', 'string');
            if ($requestMode === '') {
                $requestMode = \mh_post('page_mode', '', 'string');
            }
            if ($requestMode !== '') {
                $pageConfig = new PageConfig($requestMode);
                $allowed = $pageConfig->allowed_types;

                if ($requestMode === 'newsletter') {
                    $cols = 2;
                } elseif ($requestMode === 'automation') {
                    $cols = 1;
                }
            }
        } catch (\Throwable $e) { /* fallback to all */ }

        $options = [];
        foreach ($allowed as $code) {
            if (!isset($all[$code])) { continue; }
            $cfg = $all[$code];
            $options[$code] = [
                'label'       => (string)\mh_lng($cfg['label_key'] ?? '', $cfg['label_fallback'] ?? ucfirst($code)),
                'description' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_SCHEDULE_' . strtoupper($code) . '_DESC', 'MAILBEEZ_NEWSLETTER_SCHEDULE_' . strtoupper($code) . '_DESC'),
            ];
        }

        return [
            'options' => $options,
            'grid'    => [
                'sm' => $cols,
            ],
        ];
    }

    /**
     * Resolve the UI mapping entry for the current item's schedule_type.
     * Provides sensible fallbacks.
     *
     * @return array{code:string, token:string, icon:string, label:string}
     */
    public function scheduleTypeUi(): array
    {
        $code = $this->scheduleTypeCode() ?: 'automation';
        $map  = self::scheduleTypeUiMap();
        $cfg  = $map[$code] ?? $map['automation'];
        $label = (string)\mh_lng((string)($cfg['label_key'] ?? ''), (string)($cfg['label_fallback'] ?? ucfirst($code)));
        return [
            'code'  => $code,
            'token' => (string)$cfg['token'],
            'icon'  => (string)$cfg['icon'],
            'label' => $label,
        ];
    }

    /**
     * Icon config for ItemList::buildIcon() based on schedule_type mapping.
     * Shape/variant mirror defaults used across BeezUI cards.
     *
     * @return array{svg:string,color:string,variant:string,shape:string}
     */
    public function scheduleTypeIconConfig(): array
    {
        try {
            $ui = $this->scheduleTypeUi();
            // Prefer a strong, readable background; rely on Palette dotBg for token → bg-*
            $bg = \Components\Support\Palette::dotBg($ui['token'] ?: 'indigo', 'indigo');
            $colorClasses = trim($bg . ' text-white');
            return [
                'svg' => (string)$ui['icon'],
                'color' => $colorClasses,
                'variant' => 'solid',
                'shape' => 'square',
            ];
        } catch (\Throwable $e) {
            return [ 'svg' => \Components\Base::SVG_MAIL, 'color' => 'bg-indigo-500 text-white', 'variant' => 'solid', 'shape' => 'square' ];
        }
    }

    /**
     * Build a compact badge config for the newsletter type (not the state badge).
     * Can be fed directly into ItemList's badges[] via Ui\Badge::render adapter.
     *
     * @return array{label:string,class:string,icon:string,icon_class:string}
     */
    public function scheduleTypeBadgeConfig(): array
    {
        try {
            $ui = $this->scheduleTypeUi();
            $pal = \Components\Support\Palette::map();
            $pt  = $pal[$ui['token']] ?? ($pal['gray'] ?? ['bg' => 'bg-gray-100', 'text' => 'text-gray-600']);
            // Soft badge style: bg-*-100 + text-*
            $klass = trim(($pt['bg'] ?? 'bg-gray-100') . ' ' . ($pt['text'] ?? 'text-gray-600') . ' ring-transparent');
            return [
                'label' => (string)$ui['label'],
                'class' => $klass,
//                'icon'  => (string)$ui['icon'],
//                'icon_class' => 'size-3',
            ];
        } catch (\Throwable $e) {
            return [ 'label' => '—', 'class' => 'bg-gray-100 text-gray-600 ring-transparent', 'icon' => '', 'icon_class' => '' ];
        }
    }

}
