<?php

namespace Components\Models;

use mb_newsletter;
use Components\Newsletter\PageConfig;

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


class Audience extends BaseModel
{
    protected static string $table = 'TABLE_MAILBEEZ_NEWSLETTER_LISTS';
    protected static string $primaryKey = 'newsletter_list_id';

    protected static string $blueprint = 'audience.yaml';
    // JSON cache column used to store per-record cache/meta (e.g., result_count cache)
    protected static ?string $cacheColumn = 'newsletter_list_meta';

    /**
     * Cache key used to store the computed audience result count in the model cache.
     */
    public const CACHE_KEY_RESULT_COUNT = 'result_count';

    /**
     * Unified TTL for audience result-count cache reads used by Flow rendering.
     */
    public const RESULT_COUNT_CACHE_TTL = 900;

    /**
     * Reverse relation: all NewsletterItems that target this Audience/list.
     * @return array<NewsletterItem>
     */
    public function newsletterItems(): array
    {
        // newsletter_items.newsletter_list_id -> audiences.newsletter_list_id
        return $this->hasMany(NewsletterItem::class, 'newsletter_list_id', static::pk());
    }

    /**
     * Efficient count of related NewsletterItems using SQL COUNT(*).
     * Returns 0 in dev mode or when the Audience has no primary key.
     */
    public function newsletterItemsCount(): int
    {
        if (self::isDev()) return 0;

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

        // Build a direct COUNT(*) query against the newsletter items table
        // Now that BaseModel::table() is public, we can use NewsletterItem::table() directly.
        $sql = "SELECT COUNT(*) AS c FROM " . NewsletterItem::table() . " WHERE newsletter_list_id = {$id}";

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

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

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

    /**
     * Returns true when this audience list is marked as favorite.
     */
    public function isFavorite(): bool
    {
        try {
            if (array_key_exists('favorite', $this->attributes)) {
                $v = $this->attributes['favorite'];
                return ($v === 1) || ($v === '1') || ($v === true) || ($v === 'Y') || ($v === 'y');
            }
            $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`.
     */
    public function setFavorite(bool $favorite, bool $persist = true): bool
    {
        try {
            $val = $favorite ? 1 : 0;
            $this->attributes['favorite'] = $val;

            if (!$persist || static::isDev()) {
                return true;
            }

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

            \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.
     */
    public static function favoriteOobId($itemOrId): string
    {
        $id = is_object($itemOrId) ? (int)($itemOrId->newsletter_list_id ?? 0) : (int)$itemOrId;
        return 'audience-fav-' . $id;
    }

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

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

            $hxUrl = \Components\Router::url('audience_favorite_toggle', ['partial' => 1, 'id' => $id, 'id_suffix' => $idSuffix]);
            $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 ml-1"
                  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 '';
        }
    }

    /**
     * Build the reusable, self-refreshing count anchor for a given Audience id.
     * Options:
     * - force (bool): when true, append refresh=1 so backend recomputes and updates cache.
     * - intersect (bool): when true, include "intersect once" so it loads on first view.
     * - label (string|null): inner text/HTML for the anchor (e.g., cached value or placeholder). Defaults to '0'.
     * - events (string): hx-trigger event(s) used for future refreshes. Defaults to
     *   "audience_lists:counts:refresh from:body".
     */
    public static function countAnchorHtml(int $id, array $opts = []): string
    {
        $lazy = (bool)($opts['lazy'] ?? false);
        $force = (bool)($opts['force'] ?? false);
        $intersect = (bool)($opts['intersect'] ?? false);
        $label = array_key_exists('label', $opts) ? (string)$opts['label'] : '0';
        // Build a default, ID-filtered event listener so only the matching anchor refreshes
        // when a counts:refresh event is emitted with detail.id.
        $eventPrefix = (string)($opts['event_prefix'] ?? 'audience_lists');
        $events = (string)($opts['events'] ?? ($eventPrefix . ":counts:refresh[detail.id == '" . (string)$id . "'] from:body"));
        // Optional suffix label (e.g., " contacts") that becomes part of the animated UI during refresh
        $suffixLabel = (string)($opts['suffix_label'] ?? ' ' . (string)mh_lng('MAILBEEZ_CONTACTS', 'contacts'));

        try {
            $params = ['partial' => 1, 'id' => $id];
            if ($force) {
                $params['refresh'] = 1;
            }
            $url = \Components\Router::url('audience_list_count', $params);
        } catch (\Throwable $e) {
            $url = '#';
        }

        // Compose hx-trigger
        $triggers = [];
        if ($intersect && !$lazy) {
            $triggers[] = 'intersect once';
        }
        if ($events !== '') {
            $triggers[] = $events;
        }
        $triggerStr = implode(', ', $triggers);

        // Managed lazy loading attribute
        $lazyAttr = ($intersect && $lazy) ? ' data-lazy-refresh="1"' : '';

        // Always include hx-disinherit so swapped content preserves future triggers
        $urlEsc = \Components\Base::h($url);
        $trigEsc = \Components\Base::h($triggerStr);
        $labelEsc = (string)$label; // label may be raw number or ellipsis; do not escape to allow HTML passed intentionally
        $suffixEsc = $suffixLabel !== '' ? \Components\Base::h($suffixLabel) : '';

        // Build HTML using heredoc to comply with inline HTML guidelines (<<<HTML ... HTML)
        $triggerAttr = ($triggerStr !== '') ? (' hx-trigger="' . $trigEsc . '"') : '';
        // Build a compact inline spinner + label. Spinner becomes visible while a refresh is in-flight
        // and the label (including optional suffix) pulses to signal activity.
        $html = <<<HTML
<span class="inline-flex items-center gap-1 leading-none" data-audience-count="1"{$lazyAttr}
   hx-get="{$urlEsc}"{$triggerAttr}
   hx-swap="outerHTML swap:1s"
   hx-target="this"
   hx-disinherit="*"
   data-no-global-loader="1"
   data-no-local-loader="1"
   hx-on::load="this.classList.add('hb-ping-once'); setTimeout(() => this.classList.remove('hb-ping-once'), 700);"
   hx-on::before-request="(function(el){var s=el.querySelector('.hb-spinner'); if(s){s.classList.remove('hidden');}})(this)"
   hx-on::after-request="(function(el){var s=el.querySelector('.hb-spinner'); if(s){s.classList.add('hidden');}})(this)"
>
<svg class="hb-spinner hidden size-2.5 text-secondary/70 animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="2"  stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>

    <span class="hb-anim"><span class="hb-num">{$labelEsc}</span>{$suffixEsc}</span>
</span>
HTML;
        return $html;
    }

    /**
     * Build a lightweight placeholder token for embedding in cached Flow HTML.
     * The token will be resolved to a dynamic anchor when the Flow is rendered.
     * Example output: [[aud-count:123]]
     */
    public static function countPlaceholderToken(int $id): string
    {
        $id = max(0, (int)$id);
        if ($id <= 0) {
            return '';
        }
        return '[[aud-count:' . (string)$id . ']]';
    }

    /**
     * Resolve one or more audience count placeholders within a text string into
     * self-refreshing anchors. When a fresh cached value exists, seed it as the
     * initial label; otherwise attach lazy loading via intersect.
     *
     * Supported placeholder syntax: [[aud-count:<id>]]
     */
    public static function renderCountPlaceholders(string $text): string
    {
        if ($text === '' || strpos($text, '[[aud-count:') === false) {
            return $text;
        }

        $cb = function (array $m): string {
            $id = (int)($m[1] ?? 0);
            if ($id <= 0) {
                return $m[0];
            }

            $label = null;
            $opts = [];
            $cachedVal = null;
            $isValid = false;
            try {
                $aud = self::find($id);
                if ($aud instanceof self) {
                    $rc = (array)$aud->getResultCountCache(self::RESULT_COUNT_CACHE_TTL);
                    $cachedVal = $rc['value'] ?? null;
                    $isValid = (bool)($rc['is_valid'] ?? false);
                    if ($cachedVal !== null) {
                        try {
                            $label = (string)\Components\Support\Format::humanNumber((int)$cachedVal, [
                                'decimals' => 1,
                                'trim_zeros' => true,
                            ]);
                        } catch (\Throwable $e) {
                            $label = (string)(int)$cachedVal;
                        }
                    }
                }
            } catch (\Throwable $e) { /* ignore cache/lookup issues */
            }

            if ($label !== null) {
                $opts['label'] = $label;
            }

            if (!$isValid) {
                // stale or missing cache – lazy-load once and recompute on the backend.
                $opts['intersect'] = true;
                $opts['force'] = true;
                $opts['lazy'] = true;
            }

            try {
                return self::countAnchorHtml($id, $opts);
            } catch (\Throwable $e) {
                // Fallback to a neutral text in case anchor building fails
                return (string)mh_lng('MAILBEEZ_NEWSLETTER_AUDIENCE', 'Audience') . ' #' . (string)$id;
            }
        };

        return (string)preg_replace_callback('/\[\[aud-count:(\d+)\]\]/', $cb, $text);
    }

    // ---------------- Cache helpers are now inherited from BaseModel ----------------

    /**
     * Get cached result count from cache; respects TTL when provided.
     * When stale or forced, recompute via legacy mb_newsletter_lists::result_cnt() and update cache.
     */
    public function getResultCount(bool $forceRefresh = false, int $ttlSeconds = self::RESULT_COUNT_CACHE_TTL): int
    {
        // 1) Ask the lightweight reader first; return immediately if still fresh and not forced
        $rc = (array)$this->getResultCountCache($ttlSeconds);
        $cachedVal = $rc['value'] ?? null;
        $isValid = isset($rc['is_valid']) ? (bool)$rc['is_valid'] : false;

        if (!$forceRefresh && $cachedVal !== null && $isValid) {
            return (int)$cachedVal;
        }

        // --- Locking Mechanism ---
        $now = time();
        $cache = (array)$this->getCache();
        $lockKey = 'recomputing_since';

        // Check if a recomputation is already in progress (e.g., started in last 5 minutes)
        if (isset($cache[$lockKey]) && ($now - (int)$cache[$lockKey] < 300)) {
            // Return last known value immediately to avoid concurrent expensive queries
            return (int)($cachedVal ?? 0);
        }

        // Set lock and persist
        try {
            $cache[$lockKey] = $now;
            $this->setCache($cache);
            $this->save();
        } catch (\Throwable $e) { /* ignore lock persist errors */ }
        // -------------------------

        // 2) Prepare identifiers for legacy computation
        $id = 0;
        $source = '';
        try {
            $id = (int)($this->attributes[static::pk()] ?? $this->newsletter_list_id ?? 0);
            $source = (string)($this->newsletter_list_source ?? '');
        } catch (\Throwable $e) { /* no-op */
        }

        // 3) Compute via legacy when possible; otherwise keep last known cached value (or 0)
        $count = (int)($cachedVal ?? 0);
        try {
            if ($id > 0 && $source !== '' && class_exists('\\mb_newsletter_lists')) {
                // Some legacy layers may emit HTML comments during computation; suppress any incidental output
                ob_start();
                try {
                    $count = (int)\mb_newsletter_lists::result_cnt($id, $source);
                } finally {
                    ob_end_clean();
                }
            }
        } catch (\Throwable $e) {
            // If legacy fails, keep previous cached value
        }

        // 4) Update value and clear lock
        try {
            $cache = (array)$this->getCache();
            unset($cache[$lockKey]);
            $cache[static::CACHE_KEY_RESULT_COUNT] = ['value' => (int)$count, 'ts' => time()];
            $this->setCache($cache);
            $this->save();
        } catch (\Throwable $e) { /* ignore persist errors */
        }

        return (int)$count;
    }

    /**
     * Read the cached result_count entry and evaluate its TTL validity.
     * Returns an array: ['value' => int|null, 'ts' => int, 'is_valid' => bool]
     */
    public function getResultCountCache(int $ttlSeconds = self::RESULT_COUNT_CACHE_TTL): array
    {
        try {
            $cache = (array)$this->getCache();
        } catch (\Throwable $e) {
            $cache = [];
        }
        $entry = isset($cache[static::CACHE_KEY_RESULT_COUNT]) && is_array($cache[static::CACHE_KEY_RESULT_COUNT])
            ? $cache[static::CACHE_KEY_RESULT_COUNT]
            : [];
        $cachedVal = isset($entry['value']) ? (int)$entry['value'] : null;
        $ts = isset($entry['ts']) ? (int)$entry['ts'] : 0;
        $now = time();
        $isValid = ($cachedVal !== null) && ($ts > 0) && (($now - $ts) <= max(1, (int)$ttlSeconds));
        return [
            'value' => $cachedVal,
            'ts' => $ts,
            'is_valid' => $isValid,
        ];
    }

    /**
     * Check if a configuration value is "effective" (actually applies filtering).
     *
     * @param mixed $val
     * @return bool
     */
    private static function isEffectiveRuleValue($val): bool
    {
        if ($val === null || $val === '' || $val === false) {
            return false;
        }

        if (is_array($val)) {
            foreach ($val as $v) {
                if (self::isEffectiveRuleValue($v)) {
                    return true;
                }
            }
            return false;
        }

        return true;
    }

    /**
     * Count the number of active segmentation rules for this audience.
     * Rules are stored in the module configuration table.
     *
     * @param int $listId
     * @return int
     */
    public function countSegmentationRules(int $listId): int
    {
        // 1) Attempt to resolve the legacy sourcebeez object (e.g. shop_customers_all)
        // and delegate the counting to its internal logic if possible.
        try {
            $aud = self::find($listId);
            if ($aud) {
                $src = (string)($aud->newsletter_list_source ?? '');
                $match = self::resolveSourcebeezMatch($src, false);
                $obj = $match['object'] ?? null;
                if ($obj && method_exists($obj, 'count_active_segmentation_rules')) {
                    return (int)$obj->count_active_segmentation_rules($listId);
                }
            }
        } catch (\Throwable $e) { /* ignore */
        }

        // 2) Improved fallback logic
        $prefix = 'newsletter_listseg-' . $listId;
        $sql = "select cfg_value from " . TABLE_MAILBEEZ_MODULE_CONFIGURATION . " 
                where cfg_key REGEXP '^" . $prefix . "([^0-9]|$)'";
        $q = \mh_db_query($sql);
        $count = 0;

        while ($res = \mh_db_fetch_array($q)) {
            $val = @unserialize($res['cfg_value']);
            if (self::isEffectiveRuleValue($val)) {
                $count++;
            }
        }
        return $count;
    }

    /**
     * Invalidate the cached result_count entry for this Audience record.
     */
    public function invalidateResultCountCache(bool $persist = true, bool $emit = false): bool
    {
        $res = $this->invalidateCache(static::CACHE_KEY_RESULT_COUNT, $persist);
        if ($emit) {
            $id = (int)($this->attributes[static::pk()] ?? 0);
            if ($id > 0) {
                if (class_exists('\Components\Support\Htmx')) {
                    \Components\Support\Htmx::trigger('audience_lists:counts:refresh', ['id' => (string)$id]);
                }
            }
        }
        return $res;
    }

    /**
     * Lifecycle hook invoked by CrudController after a successful save.
     * Ensures the cached result_count is invalidated so subsequent reads
     * will recompute and persist a fresh value.
     *
     * @param array $submitted Original submitted form payload (unused)
     * @return void
     */
    public function afterSave(array $submitted = []): void
    {
        try {
            $this->invalidateResultCountCache(true, true);
        } catch (\Throwable $e) {
            // best-effort only; ignore cache invalidation failures
        }
    }

    /**
     * Return all audience lists, excluding smartlists by default.
     * To include smartlists as well, use allIncludingSmartlists().
     *
     * @return array<static>
     */
    /**
     * Resolve the base segmentation type for a given audience list id.
     * Returns 'customers'|'prospects' or '' when undetermined.
     */
    public static function resolveAudienceType(int $listId): string
    {
        if ($listId <= 0) {
            return '';
        }
        try {
            $aud = self::find($listId);
            if (!$aud) {
                return '';
            }
            $src = (string)($aud->newsletter_list_source ?? '');
            if ($src === '') {
                return '';
            }
            $match = self::resolveSourcebeezMatch($src, false);
            $obj = $match['object'] ?? null;
            if ($obj) {
                $tp = strtolower((string)($obj->list_segmentation_type ?? ''));
                return in_array($tp, ['customers', 'prospects'], true) ? $tp : '';
            }
        } catch (\Throwable $e) { /* ignore */
        }
        return '';
    }

    /**
     * Dynamic select options for newsletter_list_id (Audience)
     * Returns rich option data including identity for CollectionIdentity rendering.
     *
     * @return array<int|string, array{label: string, identity: array}>
     */
    public static function listOptions(): array
    {
        $options = [];

        // Determine base source type filter ('customers'|'prospects') based on the
        // current item's assigned audience (or its campaign's audience as fallback).
        // Only apply this restriction when editing an existing item (id present).
        $typeFilter = null; // one of 'customers'|'prospects' or null for no filtering
        $applicability = '';

        try {
            // Support page_mode context: only filter by applicability if explicitly requested.
            // This prevents over-filtering on general pages (like Audience) 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 */ }

        try {
            // Attempt to resolve context from the request (standard BeezUI pattern)
            $itemId = (int)\mh_get('id', -1);
            if ($itemId > 0) {
                $item = NewsletterItem::find($itemId);
                if ($item instanceof NewsletterItem) {
                    $listId = (int)($item->newsletter_list_id ?? 0);
                    if ($listId <= 0) {
                        try {
                            $camp = $item->campaign();
                            $listId = (int)($camp->newsletter_list_id ?? 0);
                        } catch (\Throwable $e) {
                            $listId = 0;
                        }
                    }
                    if ($listId > 0) {
                        $tp = self::resolveAudienceType($listId);
                        if ($tp !== '') {
                            $typeFilter = $tp;
                        }
                    }
                }
            }
        } catch (\Throwable $e) {
            // best-effort only
        }

        foreach (static::all($applicability) as $a) {
            $id = (int)($a->newsletter_list_id ?? 0);
            if ($id <= 0) {
                continue;
            }

            if ($typeFilter !== null) {
                $tp = self::resolveAudienceType($id);
                if ($tp !== $typeFilter) {
                    continue;
                }
            }

            $options[$id] = [
                'label'    => $a->getLabel(),
                'identity' => $a->icon(),
            ];
        }

        return $options;
    }

    public static function all(string $applicability = ''): array
    {
        if (self::isDev()) return [];

        $table = static::table();
        $pk = static::pk();

        $whereApplicability = '1=1';
        $app = strtolower(trim($applicability));
        if (in_array($app, ['newsletter', 'automation'], true)) {
            $whereApplicability = "(newsletter_list_applicability = 'both' OR newsletter_list_applicability = '" . \mh_db_input($app) . "')";
        }

        $sql = "SELECT * FROM {$table} WHERE newsletter_list_source NOT IN ('shop_smartlist','prospects_smartlist') AND {$whereApplicability} ORDER BY {$pk} ASC";

        $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 all audience lists including smartlists
     * @return array<static>
     */
    public static function allIncludingSmartlists(): array
    {
        if (self::isDev()) return [];
        $table = static::table();
        $pk = static::pk();
        $sql = "SELECT * FROM {$table} ORDER BY {$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;
    }

    /**
     * Retrieve audience lists filtered by logical kind (customers|prospects) and optionally applicability.
     * Smartlists are excluded by default to mirror Audience::all().
     *
     * @param string $kind 'customers' or 'prospects' (case-insensitive)
     * @param bool $includeSmartlists When true, include *_smartlist sources as well
     * @param string $applicability Optional: 'newsletter', 'automation', or 'both'.
     *                              When provided, only returns audiences matching that or 'both'.
     * @return array<static>
     */
    public static function allByKind(string $kind, bool $includeSmartlists = false, string $applicability = ''): array
    {
        if (self::isDev()) return [];

        $k = strtolower(trim($kind));
        $table = static::table();
        $pk = static::pk();

        // Base WHERE for smartlists
        $whereSmart = $includeSmartlists
            ? '1=1'
            : "newsletter_list_source NOT IN ('shop_smartlist','prospects_smartlist')";

        // Kind-specific prefix conditions (case-insensitive handled by DB collation in most shops)
        if ($k === 'prospects') {
            $whereKind = "(newsletter_list_source LIKE 'prospect\_%' OR newsletter_list_source LIKE 'prospects\_%' OR newsletter_list_source LIKE 'proxpect\_%')";
        } elseif ($k === 'favorite') {
            $whereKind = "favorite = 1";
        } else {
            // default: customers
            $whereKind = "newsletter_list_source LIKE 'shop\_%'";
        }

        $whereApplicability = '1=1';
        $app = strtolower(trim($applicability));
        if (in_array($app, ['newsletter', 'automation'], true)) {
            $whereApplicability = "(newsletter_list_applicability = 'both' OR newsletter_list_applicability = '" . \mh_db_input($app) . "')";
        }

        $sql = "SELECT * FROM {$table} WHERE {$whereSmart} AND {$whereKind} AND {$whereApplicability} ORDER BY {$pk} ASC";
        $q = \mh_db_query($sql);
        $items = [];
        while ($row = \mh_db_fetch_array($q)) {
            $items[] = new static($row);
        }
        \mh_db_free_result($q);
        return $items;
    }

    /**
     * Alias: return all customer lists (excludes smartlists).
     * @return array<static>
     */
    public static function typeCustomers(string $applicability = ''): array
    {
        return static::allByKind('customers', false, $applicability);
    }

    /**
     * Singular alias for convenience.
     * @return array<static>
     */
    public static function typeCustomer(string $applicability = ''): array
    {
        return static::typeCustomers($applicability);
    }

    /**
     * Alias: return all prospect lists (excludes smartlists).
     * @return array<static>
     */
    public static function typeProspects(string $applicability = ''): array
    {
        return static::allByKind('prospects', false, $applicability);
    }

    /**
     * Singular alias for convenience.
     * @return array<static>
     */
    public static function typeProspect(string $applicability = ''): array
    {
        return static::typeProspects($applicability);
    }

    /**
     * Generic alias for external callers.
     * @param string $kind
     * @return array<static>
     */
    public static function byKind(string $kind, string $applicability = ''): array
    {
        return static::allByKind($kind, false, $applicability);
    }

    protected static ?array $sourcebeezObjects = null;
    protected static array $sourcebeezMatchCache = [];
    protected static array $sourceMetaCache = [];

    /**
     * Unified accessor for legacy sourcebeez objects.
     * This resolves and returns the instances that mb_newsletter::get_sourcebeez_info()
     * prepares in $GLOBALS, keyed by their legacy module class string.
     *
     * @return array<string, object> [moduleClass => sourcebeez object]
     */
    public static function getSourcebeezObjects(): array
    {
        if (self::$sourcebeezObjects !== null) {
            return self::$sourcebeezObjects;
        }

        // In dev runtime or when legacy class is not present, nothing to return
        if (self::isDev() || !class_exists('\\mb_newsletter')) {
            return self::$sourcebeezObjects = [];
        }

        try {
            $newsletter = NewsletterItem::mbNewsletter();
            $sourcebeezArray = $newsletter->get_sourcebeez_info();
            if (!is_array($sourcebeezArray)) {
                return self::$sourcebeezObjects = [];
            }
            $out = [];
            foreach ($sourcebeezArray as $k => $moduleClass) {
                if (isset($GLOBALS[$moduleClass]) && is_object($GLOBALS[$moduleClass])) {
                    $out[(string)$moduleClass] = $GLOBALS[$moduleClass];
                }
            }
            return self::$sourcebeezObjects = $out;
        } catch (\Throwable $e) {
            return self::$sourcebeezObjects = [];
        }
    }

    /**
     * Resolve a single sourcebeez object by identifier.
     * Identifier may be the module code (preferred) or the legacy module class name.
     *
     * @param ?string $identifier Code or class to match; when null/empty no direct match is attempted.
     * @param bool $fallbackToFirst When true, falls back to the first available object if no match found.
     * @return array{object:object|null, class:string} Matched object (or null) and its module class string.
     */
    public static function resolveSourcebeezMatch(?string $identifier, bool $fallbackToFirst = false): array
    {
        $id = (string)($identifier ?? '');
        $cacheKey = $id . '|' . ($fallbackToFirst ? '1' : '0');
        if (array_key_exists($cacheKey, self::$sourcebeezMatchCache)) {
            return self::$sourcebeezMatchCache[$cacheKey];
        }

        try {
            $sourceObjects = self::getSourcebeezObjects();
            if (empty($sourceObjects)) {
                return self::$sourcebeezMatchCache[$cacheKey] = ['object' => null, 'class' => ''];
            }

            if ($id !== '') {
                foreach ($sourceObjects as $moduleClass => $obj) {
                    $code = (string)($obj->code ?? '');
                    if ($code !== '' && $code === $id) {
                        return self::$sourcebeezMatchCache[$cacheKey] = ['object' => $obj, 'class' => (string)$moduleClass];
                    }
                    // also allow matching by class name as a convenience
                    if ((string)$moduleClass === $id) {
                        return self::$sourcebeezMatchCache[$cacheKey] = ['object' => $obj, 'class' => (string)$moduleClass];
                    }
                }
            }

            if ($fallbackToFirst) {
                foreach ($sourceObjects as $moduleClass => $obj) {
                    return self::$sourcebeezMatchCache[$cacheKey] = ['object' => $obj, 'class' => (string)$moduleClass];
                }
            }

            return self::$sourcebeezMatchCache[$cacheKey] = ['object' => null, 'class' => ''];
        } catch (\Throwable $e) {
            return self::$sourcebeezMatchCache[$cacheKey] = ['object' => null, 'class' => ''];
        }
    }

    /**
     * Normalize/augment attributes before saving.
     * - Update last_modified to now on every save
     * - Ensure date_added is set on insert
     */
    protected function filterAttributesForSave(array $data): array
    {
        // Always set last_modified to current timestamp (Y-m-d H:i:s)
        try {
            $now = (new \DateTime('now'));
            $data['last_modified'] = $now->format('Y-m-d H:i:s');
        } catch (\Throwable $e) {
            // best effort
        }
        // If date_added is missing/empty, set to now
        if (!isset($data['date_added']) || trim((string)$data['date_added']) === '' || $data['date_added'] === '1000-01-01 00:00:00') {
            try {
                $now = (new \DateTime('now'));
                $data['date_added'] = $now->format('Y-m-d H:i:s');
            } catch (\Throwable $e) { /* ignore */
            }
        }
        return $data;
    }

    public function icon()
    {
        $src = (string)($this->attributes['newsletter_list_source'] ?? '');
        $ico = self::resolveIconToken($src);
        // Backward-compat: keep 'color' alias next to 'token'
        return [
            'svg'     => $ico['svg'],
            'color'   => $ico['token'],
            'token'   => $ico['token'],
            'size'    => $ico['size'],
            'variant' => $ico['variant'] ?? 'tint',
        ];
    }

    /**
     * Determine the logical kind of this audience based on its newsletter_list_source.
     * Returns one of: 'customers', 'prospects', 'unknown'.
     *
     * Heuristics (prefix checks, case-insensitive):
     *  - "shop_"        => customers
     *  - "prospect_"    => prospects
     *  - "prospects_"   => prospects
     *  - "proxpect_"    => prospects (tolerate common typo)
     *
     * @param string|null $source Optional explicit source string (for testing/utility use)
     * @return string
     */
    public function getSourceKind(?string $source = null): string
    {
        try {
            $src = strtolower((string)($source ?? ($this->attributes['newsletter_list_source'] ?? '')));
        } catch (\Throwable $e) {
            $src = '';
        }
        $src = trim($src);
        if ($src === '') {
            return 'unknown';
        }

        if (strpos($src, 'shop_') === 0) {
            return 'customers';
        }
        // Accept prospect_, prospects_, and tolerate proxpect_ (typo) prefixes
        if (strpos($src, 'prospect_') === 0 || strpos($src, 'prospects_') === 0 || strpos($src, 'proxpect_') === 0) {
            return 'prospects';
        }
        return 'unknown';
    }

    /**
     * Compute icon/color for a given legacy source code without needing a model instance.
     * Mirrors the logic of self::icon() but operates on the provided $source string.
     *
     * @param string $source Legacy module code or class identifier
     * @return array{svg:string,color:string,size:string}
     */
    public static function iconForSource(string $source): array
    {
        $ico = self::resolveIconToken($source);
        return [
            'svg'     => $ico['svg'],
            'color'   => $ico['token'],
            'token'   => $ico['token'],
            'size'    => $ico['size'],
            'variant' => $ico['variant'] ?? 'tint',
        ];
    }

    /**
     * Unify and simplify: resolve icon SVG + palette token for a source code.
     * Returns canonical keys: ['svg' => string, 'token' => string, 'size' => 'sm']
     */
    private static function resolveIconToken(?string $source): array
    {
        $src = trim((string)($source ?? ''));
        if ($src === '') {
            return ['svg' => \Components\Base::SVG_OUTLINE_USER_GROUP, 'token' => 'green', 'size' => 'sm', 'variant' => 'solid'];
        }

        // 1) Prefer explicit abandoned cart flag from sourcebeez when available
        try {
            $match = self::resolveSourcebeezMatch($src, false);
            $obj = $match['object'] ?? null;
            if ($obj) {
                $flag = null;
                try {
                    $flag = $obj->source_abandoned_cart ?? null;
                } catch (\Throwable $e) { /* ignore */
                }
                $isAbandoned = false;
                if (is_bool($flag)) {
                    $isAbandoned = $flag;
                } elseif (is_int($flag)) {
                    $isAbandoned = ($flag === 1);
                } elseif (is_string($flag)) {
                    $isAbandoned = (strtolower($flag) === 'true' || $flag === '1');
                }
                if (!$isAbandoned && method_exists($obj, 'is_abandoned_cart')) {
                    try {
                        $isAbandoned = (bool)$obj->is_abandoned_cart();
                    } catch (\Throwable $e) { /* ignore */
                    }
                }
                if ($isAbandoned) {
                    return ['svg' => \Components\Base::SVG_CART, 'token' => 'pink', 'size' => 'sm', 'variant' => 'solid'];
                }
            }
        } catch (\Throwable $e) { /* ignore and fallback to prefix-based */
        }

        // 2) Prefix-based defaults
        $lower = strtolower($src);
        if (strpos($lower, 'shop_') === 0) {
            return ['svg' => \Components\Base::SVG_ORDER, 'token' => 'brand', 'size' => 'sm', 'variant' => 'solid'];
        }
        if (strpos($lower, 'prospect_') === 0 || strpos($lower, 'prospects_') === 0 || strpos($lower, 'proxpect_') === 0) {
            return ['svg' => \Components\Base::SVG_OUTLINE_USER_GROUP, 'token' => 'green', 'size' => 'sm', 'variant' => 'solid'];
        }

        // Fallback: neutral prospects-like icon
        return ['svg' => \Components\Base::SVG_OUTLINE_USER_GROUP, 'token' => 'green', 'size' => 'sm', 'variant' => 'solid'];
    }

    public function getSourceMeta(): ?object
    {
        $src = (string)($this->attributes['newsletter_list_source'] ?? '');
        if (!array_key_exists($src, self::$sourceMetaCache)) {
            $match = self::resolveSourcebeezMatch($src, true);
            self::$sourceMetaCache[$src] = $match['object'] ?? null;
        }
        return self::$sourceMetaCache[$src];
    }

    /**
     * Resolve and return the human-readable title of the source module backing this audience list.
     * Mirrors how Audience::audienceSources() pulls `$obj->title` from legacy sourcebeez objects.
     *
     * Fallbacks (when legacy stack not available):
     *  - Return a prettified variant of newsletter_list_source (underscores → spaces, title-cased)
     *
     * @return string
     */
    public function getSourceTitle(): string
    {
        $src = (string)($this->attributes['newsletter_list_source'] ?? '');
        $src = trim($src);
        if ($src === '') {
            return '';
        }

        try {
            $obj = $this->getSourceMeta();
            if ($obj) {
                // Prefer explicit title property; otherwise try common accessors
                $title = (string)($obj->title ?? '');
                if ($title === '' && method_exists($obj, 'get_title')) {
                    try {
                        $title = (string)$obj->get_title();
                    } catch (\Throwable $e) { /* ignore */
                    }
                }
                if ($title !== '') {
                    return $title;
                }
            }
        } catch (\Throwable $e) { /* ignore and use fallback */
        }

        // Friendly fallback: turn code-like strings into human form (e.g., shop_customers_all → Shop customers all)
        $pretty = str_replace(['_', '-'], ' ', $src);
        // Keep acronyms lowercase except first letter; this is a simple fallback only
        $pretty = trim(preg_replace('/\s+/', ' ', $pretty));
        $pretty = $pretty !== '' ? ucfirst($pretty) : $src;
        return $pretty;
    }

    /**
     * Return a human-readable label for this audience.
     * If the source is a smartlist, try to resolve the "Follow-up for [Parent Name]" label.
     *
     * @param ?NewsletterItem $item The NewsletterItem currently using this audience
     */
    public function getLabel(?NewsletterItem $item = null): string
    {
        $id = (int)($this->{static::pk()} ?? 0);
        $name = (string)($this->newsletter_list_name ?? '');
        if ($name === '' && $id > 0) {
            $name = 'List #' . $id;
        }

        $source = (string)($this->newsletter_list_source ?? '');

        // If the source is a smartlist, we expect this list to be a follow-up list
        // and we try to resolve the parent newsletter item's name to build a better label.
        if (strpos($source, '_smartlist') !== false) {
            // Try to resolve the newsletter item using this list if not provided
            if (!$item) {
                try {
                    $items = $this->newsletterItems();
                    if (count($items) === 1) {
                        $item = $items[0];
                    }
                } catch (\Throwable $e) { /* ignore */
                }
            }

            if ($item && $item->isFollowup()) {
                try {
                    $parent = $item->parent();
                    if ($parent) {
                        $parentName = (string)($parent->newsletter_item_name ?? '');
                        if ($parentName !== '') {
                            return sprintf(mh_lng('MAILBEEZ_AUDIENCE_FOLLOWUP_LABEL', 'Follow-up for %s'), $parentName);
                        }
                    }
                } catch (\Throwable $e) { /* fallback to list name */
                }
            }
        }

        return $name;
    }

    /**
     * Provide unified default options for Components\Resources\ItemList::render when listing Audience (newsletter lists).
     * Pages can pass overrides to customize title, items, outer_attrs, etc.
     *
     * @param array $overrides
     * @return array
     */
    /**
     * Return unified action items for creating a new newsletter item from an audience.
     * Used in ItemList dropdowns, CRUD menu items, and Flow diagrams.
     *
     * @param int|string $listId The numeric ID or a placeholder like '{id}'
     * @param string $applicability The applicability filter ('both', 'newsletter', 'automation')
     * @return array<string, array> Keyed action configurations
     */
    public static function getNewNewsletterItemActions($listId, string $applicability = 'both'): array
    {
        $idPlaceholder = '{id}';
        $isPlaceholder = ($listId === $idPlaceholder);

        // Support page_mode context: if applicability is 'both', try to narrow it down
        // based on the current request context.
        if ($applicability === 'both') {
            try {
                // To avoid over-filtering on general pages (like Audience), we only apply
                // the context if page_mode is explicitly present in the request.
                $requestMode = \mh_get('page_mode', '', 'string');
                if ($requestMode === '') {
                    $requestMode = \mh_post('page_mode', '', 'string');
                }
                if ($requestMode === 'newsletter' || $requestMode === 'automation') {
                    $applicability = $requestMode;
                }
            } catch (\Throwable $e) { /* ignore */ }
        }

        $actions = [
            'new_bulkmailing' => [
                'label' => (string)\mh_lng('MAILBEEZ_AUDIENCE_ACTION_NEW_BULKMAILING', 'new bulkmailing with this audience'),
                'icon' => \Components\Base::SVG_ADD,
                'schedule' => 'bulk',
            ],
            'new_automation' => [
                'label' => (string)\mh_lng('MAILBEEZ_AUDIENCE_ACTION_NEW_AUTOMATION', 'new automation with this audience'),
                'icon' => \Components\Base::SVG_ADD,
                'schedule' => 'automation',
            ],
            'export' => [
                'divider' => true,
            ],
            'export_audience' => [
                'label' => (string)\mh_lng('UI_TEXT_EXPORT', 'Export'),
                'icon' => \Components\Base::SVG_ARROW_DOWN_SQUARE,
                'url' => \Components\Router::url('audience_export_modal', ['partial' => 1, 'id' => $listId]),
                'attrs' => [
                    'command' => 'show-modal',
                    'commandfor' => 'config-modal',
                    'data-title' => (string)\mh_lng('UI_TEXT_EXPORT_AUDIENCE', 'Export Audience'),
                    'data-size' => '3xl',
                    'hx-target' => '#config-modal-body',
                    'hx-swap' => 'innerHTML',
                ],
            ],
        ];

        if ($applicability === 'newsletter') {
            unset($actions['new_automation']);
        } elseif ($applicability === 'automation') {
            unset($actions['new_bulkmailing']);
        }

        foreach ($actions as $key => &$action) {
            if (isset($action['divider'])) {
                continue;
            }
            if ($key === 'export_audience') {
                if (!$isPlaceholder) {
                    $action['attrs']['hx-get'] = $action['url'];
                }
                continue;
            }
            $urlParams = [
                'partial' => 1,
                'mode' => 'new',
                'schedule' => $action['schedule'],
                'newsletter_list_id' => $listId,
                'skip_audience' => 1
            ];

            // Carry through the page_mode to ensure the modal respects the current context.
            // When explicitly creating an automation, ensure it opens in automation mode.
            if ($action['schedule'] === 'automation') {
                $urlParams['page_mode'] = 'automation';
            } elseif ($applicability === 'newsletter' || $action['schedule'] === 'bulk') {
                $urlParams['page_mode'] = 'newsletter';
            }

            $url = \Components\Router::url('newsletter_template_selection_modal', $urlParams);
            unset($action['schedule']);

            $action['url'] = $url;
            $action['attrs'] = [
                'command' => 'show-modal',
                'commandfor' => 'config-modal',
                'data-title' => (string)\mh_lng('MAILBEEZ_NEWSLETTER_ITEM_MODAL_NEW_TITLE', 'New Newsletter Item'),
                'data-size' => '5xl',
                'data-dismissible' => 'false',
                'hx-target' => '#config-modal-body',
                'hx-swap' => 'innerHTML',
            ];

            if (!$isPlaceholder) {
                $action['attrs']['hx-get'] = $url;
            }
        }

        return $actions;
    }

    public static function itemListOptions(array $overrides = []): array
    {
        $viewMode = \Components\ConfigStore::getViewMode('audience');
        // Preserve current selection context (kind, list_id, container_id) for row refreshes
        $filterParams = ['partial' => 1];
        try {
            $kind = (string)\mh_get('kind', '', 'string');
            if ($kind !== '') { $filterParams['kind'] = $kind; }

            $listId = (string)\mh_get('list_id', '', 'string');
            if ($listId !== '') { $filterParams['list_id'] = $listId; }

            $containerId = (string)\mh_get('container_id', '', 'string');
            if ($containerId !== '') { $filterParams['container_id'] = $containerId; }
        } catch (\Throwable $e) { /* ignore */ }

        $defaults = [
            'view_mode' => $viewMode,
            'title' => 'Audience Lists',
            'items' => [],
            'id_key' => 'id',
            'name_key' => 'name',
            'status_key' => 'status',
            'outer_attrs' => 'id="audience-lists"',
            'empty_text' => 'No lists found or database not available.',
            'panel_type' => 'drawer',
            'expandable' => true,
            'expand_refresh_button' => true,
            'refresh_type' => 'audience',
            'perf_context' => \Components\Ui\PerformanceDashboard::CONTEXT_AUDIENCE,
            'open_key_prefix' => 'audience-list',
            // When a list is updated, re-fetch the expandable flow content
            // When refreshing the expandable, indicate refresh=1 so nested loaders can bypass caches
            'expand_refresh_url' => \Components\Router::url('audience_list_flow', ['partial' => 1, 'id' => '{id}', 'refresh' => 1]),
            // Event-driven refresh (Pattern A):
            // - Each row listens for `{prefix}:updated` with matching detail.id and self-refreshes
            // - The surrounding list can listen for `{prefix}:created|deleted` to refetch
            'event_prefix' => 'audience_lists',
            // Row self-refresh: re-fetch the list partial and select just this row
            'row_refresh_url' => \Components\Router::url('audience_lists', $filterParams),
            // Default actions
            'row_title_id_prefix' => 'audience-item-title-',
//            'new_action' => [
//                'label' => 'New',
//                'url' => \Components\Router::url('audience_list_modal', ['partial' => 1, 'mode' => 'new']),
//                'attrs' => [
//                    'data-title' => 'New List',
//                    'data-size' => 'xl',
//                ],
//                'variant' => 'primary',
//            ],
            'config_action' => [
                'url' => \Components\Router::url('audience_list_modal', ['partial' => 1, 'mode' => 'edit']) . '&id={id}&drawer=1',
                'attrs' => [
                    'data-title' => 'Configure List',
                    'data-size' => 'xl',
                    'command' => 'show-modal',
                    'commandfor' => 'config-drawer',
                    'hx-target' => '#config-drawer-body',
                    'hx-swap' => 'innerHTML',
                ],
            ],
            'row_actions' => self::getNewNewsletterItemActions('{id}'),
        ];

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

    /**
     * Return available newsletter_list_source items as id/text pairs.
     * Mirrors legacy mb_newsletter::get_sourcebeez_select() without rendering HTML.
     *
     * @return array<int, array{id:string, text:string}>
     */
    public static function audienceSources($sourceSet = null, $filter_type = null): array
    {
        try {
            $sourcebeezObjects = self::getSourcebeezObjects();
            if (empty($sourcebeezObjects)) {
                return [];
            }
            $items = [];

            // Normalize filter: allow only valid types (customers|prospects)
            $filterTypes = null;
            $validTypes = ['customers', 'prospects'];
            if (is_string($filter_type) && $filter_type !== '') {
                $t = strtolower(trim($filter_type));
                if (in_array($t, $validTypes, true)) {
                    $filterTypes = [$t];
                }
            } elseif (is_array($filter_type)) {
                $filtered = [];
                foreach ($filter_type as $v) {
                    $t = strtolower(trim((string)$v));
                    if (in_array($t, $validTypes, true)) {
                        $filtered[$t] = true; // use keys to de-dupe
                    }
                }
                if (!empty($filtered)) {
                    $filterTypes = array_keys($filtered);
                }
            }

            // If no explicit filter was provided, infer type from current selection ($sourceSet)
            // The $sourceSet can be either the module code (obj->code) or the module class name from legacy modules
            if ($filterTypes === null && is_string($sourceSet) && $sourceSet !== '') {
                try {
                    $match = self::resolveSourcebeezMatch((string)$sourceSet, false);
                    if ($match['object']) {
                        $obj0 = $match['object'];
                        $t0 = strtolower((string)($obj0->list_segmentation_type ?? ''));
                        if (in_array($t0, $validTypes, true)) {
                            $filterTypes = [$t0];
                        }
                    }
                } catch (\Throwable $e) {
                    // ignore and keep unfiltered
                }
            }

            foreach ($sourcebeezObjects as $moduleClass => $obj) {

                // Skip smartlists as in legacy select builder
                if (($obj->code ?? null) === 'shop_smartlist' || ($obj->code ?? null) === 'prospects_smartlist') {
                    continue;
                }

                $type = strtolower((string)($obj->list_segmentation_type ?? ''));
                $sortOrder = (int)($obj->sort_order ?? PHP_INT_MAX);

                // Apply optional filter by segmentation type
                if (is_array($filterTypes) && !in_array($type, $filterTypes, true)) {
                    continue;
                }

                $items[] = [
                    'id' => (string)($obj->code ?? ''),
                    'text' => (string)($obj->title ?? (is_string($moduleClass) ? $moduleClass : '')),
                    '_type' => $type,
                    '_order' => $sortOrder,
                ];
            }

            // Sort by type, then by sort_order ascending
            usort($items, function ($a, $b) {
                // Define explicit order to keep known types grouped: customers, prospects, then others
                $rank = function ($t) {
                    if ($t === 'customers') return 0;
                    if ($t === 'prospects') return 1;
                    return 2; // unknown types last
                };
                $ra = $rank($a['_type'] ?? '');
                $rb = $rank($b['_type'] ?? '');
                if ($ra !== $rb) {
                    return $ra <=> $rb;
                }
                // Within same type, sort by numeric sort_order ascending
                $oa = $a['_order'] ?? PHP_INT_MAX;
                $ob = $b['_order'] ?? PHP_INT_MAX;
                return $oa <=> $ob;
            });

            // Return id/text pairs in the new order
            return array_map(function ($it) {
                return [
                    'id' => $it['id'],
                    'text' => $it['text'],
                ];
            }, $items);
        } catch (\Throwable $e) {
            dd($e);
            return [];
        }
    }

    /**
     * Provide a mapping of newsletter_list_source codes to human-readable labels (titles).
     * Similar to \beez::groupLabels(), this returns an associative array that can be used
     * for filter UI labels. Smartlists are excluded to match Audience::all().
     *
     * @param array|string|null $filter_type Optional 'customers'|'prospects' to limit sources
     * @return array<string,string> [ code => label ]
     */
    public static function listSourceLabels($filter_type = null): array
    {
        try {
            // Reuse audienceSources() which resolves titles and sorting using legacy sourcebeez
            $pairs = self::audienceSources(null, $filter_type);
            if (!is_array($pairs) || empty($pairs)) {
                return [];
            }
            $map = [];
            foreach ($pairs as $p) {
                $id = isset($p['id']) ? (string)$p['id'] : '';
                $text = isset($p['text']) ? (string)$p['text'] : '';
                if ($id === '') {
                    continue;
                }
                if ($text === '') {
                    // Friendly fallback: transform code-like strings into human-ish labels
                    $t = str_replace(['_', '-'], ' ', $id);
                    $t = trim(preg_replace('/\s+/', ' ', $t));
                    $text = $t !== '' ? ucfirst($t) : $id;
                }
                $map[$id] = $text;
            }
            return $map;
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * Grouped source provider for newsletter_list_source as RadioCards.
     * Returns two groups: Customers and Prospects, each with an explainer and sorted items.
     * Shape expected by Engine::applyDynamicFieldSources for radiocards grouped options:
     *   [
     *     ['label' => 'Customers', 'help' => '...', 'items' => [ ['id'=>'code','text'=>'Label','description'=>''], ... ]],
     *     ['label' => 'Prospects', 'help' => '...', 'items' => [ ... ]],
     *   ]
     */
    public static function audienceSourcesGrouped($current = null): array
    {
        try {
            $requestMode = '';
            try {
                // To avoid over-filtering on general pages (like Audience), we only apply
                // the context if page_mode is explicitly present in the request.
                $rm = \mh_get('page_mode', '', 'string');
                if ($rm === '') {
                    $rm = \mh_post('page_mode', '', 'string');
                }
                if ($rm !== '') {
                    $pageConfig = new PageConfig($rm);
                    $requestMode = $pageConfig->mode;
                }
            } catch (\Throwable $e) { /* best-effort only */ }

            $sourcebeezObjects = self::getSourcebeezObjects();
            if (empty($sourcebeezObjects)) {
                return [];
            }

            $validTypes = ['customers', 'prospects'];
            // We always return both groups. The renderer will decide whether the
            // user can switch between them (create mode) or not (edit mode).

            $tmp = [
                'customers' => [],
                'prospects' => [],
            ];

            foreach ($sourcebeezObjects as $moduleClass => $obj) {
                // Skip smartlists (follow-up lists managed elsewhere)
                $code = (string)($obj->code ?? '');
                if ($code === 'shop_smartlist' || $code === 'prospects_smartlist') {
                    continue;
                }

                // Filter by applicability if a specific page_mode is requested
                $applicability = $obj->applicability ?? ['newsletter', 'automation', 'both'];
                if (!is_array($applicability)) {
                    $applicability = [(string)$applicability];
                }

                if ($requestMode === \Components\Newsletter\PageConfig::MODE_NEWSLETTER) {
                    if (in_array('newsletter', $applicability, true)) {
                        // explicitly allowed
                    } elseif (in_array('both', $applicability, true) && !in_array('automation', $applicability, true)) {
                        // allowed via 'both' only if not claimed by automation
                    } else {
                        continue;
                    }
                } elseif ($requestMode === \Components\Newsletter\PageConfig::MODE_AUTOMATION) {
                    if (in_array('automation', $applicability, true)) {
                        // explicitly allowed
                    } elseif (in_array('both', $applicability, true) && !in_array('newsletter', $applicability, true)) {
                        // allowed via 'both' only if not claimed by newsletter
                    } else {
                        continue;
                    }
                }

                $type = strtolower((string)($obj->list_segmentation_type ?? 'customers'));
                if (!in_array($type, $validTypes, true)) {
                    $type = 'customers';
                }

                $sortOrder = (int)($obj->sort_order ?? PHP_INT_MAX);
                $title = (string)($obj->title ?? (is_string($moduleClass) ? $moduleClass : ''));

                // Prefer the module's own description if provided; fall back gracefully
                $desc = '';
                if (isset($obj->description) && $obj->description !== '') {
                    $desc = (string)$obj->description;
                } elseif (isset($obj->short_description) && $obj->short_description !== '') {
                    $desc = (string)$obj->short_description;
                } elseif (isset($obj->desc) && $obj->desc !== '') {
                    $desc = (string)$obj->desc;
                }

                // Derive per-source icon + color for RadioCards using unified resolver
                $ico = self::iconForSource($code);

                $tmp[$type][] = [
                    'id' => $code,
                    'text' => $title,
                    'description' => $desc,
                    'icon' => $ico['svg'] ?? null,
                    // Prefer canonical 'token'; include 'color' for backward-compat with older renderers
                    'token' => $ico['token'] ?? ($ico['color'] ?? null),
                    'color' => $ico['token'] ?? ($ico['color'] ?? null),
                    'applicability' => $applicability,
                    '_order' => $sortOrder,
                ];
            }

            // Sort each group by sort_order
            foreach ($tmp as &$arr) {
                usort($arr, function ($a, $b) {
                    $oa = $a['_order'] ?? PHP_INT_MAX;
                    $ob = $b['_order'] ?? PHP_INT_MAX;
                    return $oa <=> $ob;
                });
                foreach ($arr as &$it) {
                    unset($it['_order']);
                }
            }

            $groups = [];
            $groups[] = [
                'key' => 'customers',
                'label' => (string)\mh_lng('MAILBEEZ_AUDIENCE_GROUP_CUSTOMERS', 'Customers'),
                'help' => (string)\mh_lng('MAILBEEZ_AUDIENCE_GROUP_CUSTOMERS_DESC', 'From your shop database (buyers, subscribers).'),
                // Provide icon to the UI (tabs) – keep icons defined in audience context for reusability
//                'icon' => \Components\Base::SVG_CART,
                'items' => $tmp['customers'],
            ];
            $groups[] = [
                'key' => 'prospects',
                'label' => (string)\mh_lng('MAILBEEZ_AUDIENCE_GROUP_PROSPECTS', 'Prospects'),
                'help' => (string)\mh_lng('MAILBEEZ_AUDIENCE_GROUP_PROSPECTS_DESC', 'Leads and sign-ups before first purchase.'),
//                'icon' => \Components\Base::SVG_OUTLINE_USER,
                'items' => $tmp['prospects'],
            ];

            // If caller passed a preferred type (e.g., via modal link), mark that group as active
            // without changing the visual order of tabs (Customers first, Prospects second).
            $pref = '';
            try {
                $pref = (string)\mh_get('filter_type', '', 'key');
            } catch (\Throwable $e) {
                $pref = '';
            }
            if ($pref === '') {
                try {
                    $pref = (string)\mh_get('kind', '', 'key');
                } catch (\Throwable $e) {
                    $pref = '';
                }
            }
            $pref = strtolower(trim($pref));
            if (in_array($pref, ['customers', 'prospects'], true)) {
                foreach ($groups as &$g) {
                    if ((string)($g['key'] ?? '') === $pref) {
                        $g['active'] = true;
                    }
                }
                unset($g);
            }

            return $groups;
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * Retrieve the follow-up configuration for a given audience/list.
     * When the legacy stack is available and we are not in dev mode, this method returns
     * the data retrieved by \mb_newsletter_items::getFollowUpConfiguration($listId).
     *
     * @param ?int $listId Optional explicit list id. Defaults to this model's newsletter_list_id.
     * @return array
     */
    public function followUpConfiguration(?int $listId = null): array
    {
        // In dev mode, legacy DB and classes are not available
        if (self::isDev()) {
            return [];
        }

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

        try {
            if (class_exists('\\mb_newsletter_items') && method_exists('\\mb_newsletter_items', 'getFollowUpConfiguration')) {
                $data = \mb_newsletter_items::getFollowUpConfiguration($id);
                return is_array($data) ? $data : [];
            }
        } catch (\Throwable $e) {
            // Swallow and return empty to keep UI stable if legacy call fails
        }

        return [];
    }

    /**
     * Static convenience wrapper to fetch follow-up configuration for a list id.
     * Mirrors \mb_newsletter_items::getFollowUpConfiguration($listId) but guards for dev/runtime.
     *
     * @param int $listId
     * @return array
     */
    public static function getFollowUpConfiguration(int $listId): array
    {
        if (self::isDev()) {
            return [];
        }
        if ($listId <= 0) {
            return [];
        }
        try {
            if (class_exists('\\mb_newsletter_items') && method_exists('\\mb_newsletter_items', 'getFollowUpConfiguration')) {
                $data = \mb_newsletter_items::getFollowUpConfiguration($listId);
                return is_array($data) ? $data : [];
            }
        } catch (\Throwable $e) {
            // ignore
        }
        return [];
    }

    /**
     * Fetch all audience lists and attach their follow-up configuration on each instance.
     * Uses the legacy \mb_newsletter_items::getFollowUpConfiguration($listId) via
     * Audience::getFollowUpConfiguration(). Smartlists are excluded (same as Audience::all()).
     *
     * The configuration is exposed on each returned model as dynamic property:
     *   $audience->followup_configuration (array)
     *
     * @return array<static> Audience models with ->followup_configuration attached
     */
    public static function allWithFollowUpConfiguration(): array
    {
        if (self::isDev()) {
            return [];
        }

        $items = static::all(); // excludes smartlists
        foreach ($items as $it) {
            if ($it instanceof self) {
                $id = (int)($it->newsletter_list_id ?? 0);
                $cfg = $id > 0 ? static::getFollowUpConfiguration($id) : [];
                try {
                    $it->followup_configuration = $cfg;
                } catch (\Throwable $e) { /* ignore */
                }
            }
        }
        return $items;
    }

    /**
     * Find a single audience list by id and include its follow-up configuration.
     * Returns the model instance with ->followup_configuration attached, or null if not found
     * or in dev mode.
     *
     * Note: method name follows the issue specification (lower-case 'c' in configuration).
     *
     * @param int $id
     * @return static|null
     */
    public static function findWithFollowUpconfiguration(int $id)
    {
        if (self::isDev()) {
            return null;
        }
        if ($id <= 0) {
            return null;
        }

        $model = static::find($id);
        if (!$model) {
            return null;
        }

        $cfg = static::getFollowUpConfiguration($id);
        try {
            $model->followup_configuration = $cfg;
        } catch (\Throwable $e) { /* ignore */
        }
        return $model;
    }

    /**
     * Build CRUD action menu items for the Audience (newsletter list) modal.
     * Pulls legacy admin action routes from the corresponding sourcebeez instance
     * via sourcebeez::build_admin_action_routes() and maps them to Dropdown items
     * that open the legacy pages inside the standard iframe modal.
     *
     * Expected context keys (as provided by CrudController):
     * - id: int (newsletter_list_id)
     * - values: array (current record attributes, should include newsletter_list_source)
     * - mode/selfRoute: unused here
     *
     * @param array $context
     * @return array<int, array<string, mixed>> Dropdown::render-compatible items
     */
    public static function getCrudMenuItems(array $context): array
    {
        // Guard: dev runtime has no legacy stack nor DB
        if (self::isDev()) {
            return [];
        }

        // Legacy prerequisites
        if (!class_exists('\\mb_newsletter')) {
            return [];
        }

        $id = (int)($context['id'] ?? 0);
        $values = is_array($context['values'] ?? null) ? $context['values'] : [];
        $listSource = (string)($values['newsletter_list_source'] ?? '');

        try {
            $match = self::resolveSourcebeezMatch($listSource, true);
            $matchObj = $match['object'];
            $matchClass = $match['class'];
            if (!$matchObj) {
                return [];
            }

            // Prime context expected by build_admin_action_routes()
            // It reads mh_get('list_id'|'module'|'list_source'|'newsletter_id') from the request.
            // Provide sensible defaults so URLs are composed correctly.
            try {
//                $_GET['list_id'] = $id > 0 ? $id : ($_GET['list_id'] ?? null);
                // Prefer the module code when available; fallback to class name
                $moduleCode = (string)($matchObj->code ?? '');
                if ($moduleCode === '' && $matchClass !== '') {
                    $moduleCode = $matchClass;
                }
                if ($moduleCode !== '') {
                    $_GET['module'] = $moduleCode;
                }
                if ($listSource !== '') {
                    $_GET['list_source'] = $listSource;
                }
            } catch (\Throwable $e) { /* ignore */
            }

            // Also set the list id on the object if supported
            try {
                if (method_exists($matchObj, 'set_list_id')) {
                    $matchObj->set_list_id($id);
                }
            } catch (\Throwable $e) { /* ignore */
            }

            // Build legacy routes and map to Dropdown items opening in iframe modal
            $routes = [];
            try {
                if (method_exists($matchObj, 'build_admin_action_routes')) {
                    $routes = (array)$matchObj->build_admin_action_routes();
                }
            } catch (\Throwable $e) {
                $routes = [];
            }
            if (empty($routes)) {
                return [];
            }

            $items = [];
            foreach ($routes as $rid => $route) {
                $url = (string)($route['url'] ?? '');
                $label = (string)($route['label'] ?? (is_string($rid) ? $rid : 'Action'));
                if ($url === '') {
                    continue;
                }
                $items[] = [
                    'label' => $label,
                    'attrs' => [
                        'command' => 'show-modal',
                        'commandfor' => 'config-modal',
                        'data-size' => 'full-margin',
                        'data-url' => '../external_modal.php?url=' . urlencode($url),
                        'hx-target' => '#config-modal-body',
                        'hx-swap' => 'innerHTML',
                    ],
                ];
            }

            // New BeezUI actions
            $items[] = ['divider' => true];
            foreach (self::getNewNewsletterItemActions((int)$id) as $action) {
                $items[] = $action;
            }

            return $items;
        } catch (\Throwable $e) {
            return [];
        }

        // end of getCrudMenuItems()
    }

    /**
     * Build a visual flow for this Audience (newsletter list).
     * The structure is: Source -> filter -> segment
     *
     * @return array List of FlowViz items (FlowBuilder::toArray())
     */
    public function flow(): array
    {
        try {
            $fb = \Components\CampaignFlow\FlowBuilder::make();

            // Read basic attributes
            $listSource = (string)($this->attributes['newsletter_list_source'] ?? '');
            $listId = 0;
            try {
                // IMPORTANT: Prefer the numeric legacy PK 'newsletter_list_id'.
                // The generic model PK (static::pk()) may be a slug like 'audience-customers'
                // which must NOT be used for legacy routes expecting a numeric list_id.
                $nlIdRaw = $this->attributes['newsletter_list_id'] ?? null;
                if (is_numeric($nlIdRaw)) {
                    $listId = (int)$nlIdRaw;
                } else {
                    $pkRaw = $this->attributes[static::pk()] ?? null;
                    $listId = is_numeric($pkRaw) ? (int)$pkRaw : 0;
                }
            } catch (\Throwable $e) {
                $listId = 0;
            }

            $sourceTitle = $listSource !== '' ? strtoupper($listSource) : 'Source';
            $segType = '';
            $segmentationAttrs = null; // clickable modal attrs for segmentation (filled below)

            // Try to resolve legacy sourcebeez object to get human labels
            try {
                $match = self::resolveSourcebeezMatch($listSource, true);
                if ($match['object']) {
                    $obj = $match['object'];
                    $sourceTitle = (string)($obj->title ?? $sourceTitle);
                    $segType = (string)($obj->list_segmentation_type ?? '');

                    // Prime request context expected by sourcebeez::build_admin_action_routes()
                    try {
                        $moduleCode = (string)($obj->code ?? '');
                        $moduleCode = $moduleCode !== '' ? $moduleCode : (string)$match['class'];
                        if ($moduleCode !== '') {
                            $_GET['module'] = $_GET['module'] ?? $moduleCode;
                        }
                        if ($listSource !== '') {
                            $_GET['list_source'] = $_GET['list_source'] ?? $listSource;
                        }
                        // Ensure legacy expects a numeric list_id in the request context
                        if ($listId > 0) {
                            $_GET['list_id'] = (string)$listId;
                        }
                        // try to set list_id on the object if supported
                        if (method_exists($obj, 'set_list_id')) {
                            $obj->set_list_id($listId);
                        }
                    } catch (\Throwable $e) { /* ignore */
                    }

                    // Build routes and extract list_segmentation_edit to open in modal
                    try {
                        if (method_exists($obj, 'build_admin_action_routes')) {
                            $routes = (array)$obj->build_admin_action_routes();
                            if (isset($routes['list_segmentation_edit']['url'])) {
                                $url = (string)$routes['list_segmentation_edit']['url'];
                                if ($url !== '') {
                                    $segmentationAttrs = [
                                        'command' => 'show-modal',
                                        'commandfor' => 'config-modal',
                                        'data-size' => 'full-margin',
                                        'data-dismissible' => 'false',
                                        'data-url' => ($url),
                                        'hx-target' => '#config-modal-body',
                                        'hx-swap' => 'innerHTML',
                                    ];
                                }
                            }
                        }
                    } catch (\Throwable $e) { /* ignore */
                    }
                }
            } catch (\Throwable $e) {
                // ignore, keep fallbacks
            }

            // 1) Source – clickable to configure newsletter_list_source
            $sourceAttrs = null;
            if ($listId > 0) {
                try {
                    $sourceModalUrl = \Components\Router::url('audience_list_modal', [
                        'partial' => 1,
                        'mode' => 'edit',
                        'id' => $listId,
                        'fields' => 'newsletter_list_source',
                    ]);
                    $sourceAttrs = [
                        'command' => 'show-modal',
                        'commandfor' => 'config-modal',
                        'data-title' => \mh_lng('MAILBEEZ_AUDIENCE_SOURCE', 'Source'),
                        'data-size' => '3xl',
                        'hx-get' => $sourceModalUrl,
                        'hx-target' => '#config-modal-body',
                        'hx-swap' => 'innerHTML',
                    ];
                } catch (\Throwable $e) { /* ignore */
                }
            }

            $fb->audienceSource($sourceTitle, [
                'id' => 'source',
                'subtitle' => \mh_lng('MAILBEEZ_AUDIENCE_SOURCE', 'Source'),
                'icon' => \Components\Base::SVG_OUTLINE_DB,
                'color' => 'green',
                'attrs' => $sourceAttrs,
            ]);

            // 2) Segmentation step
            $segRuleCount = $this->countSegmentationRules($listId);
            if ($segRuleCount === 0) {
                $segContent = (string)\mh_lng('MAILBEEZ_AUDIENCE_SEGMENTATION_NO_FILTERS_APPLIED', 'No Filters applied');
            } else {
                $rulesLabel = $segRuleCount === 1
                    ? \mh_lng('MAILBEEZ_AUDIENCE_SEGMENTATION_RULE_APPLIED', 'rule applied')
                    : \mh_lng('MAILBEEZ_AUDIENCE_SEGMENTATION_RULES_APPLIED', 'rules applied');
                $segContent = $segRuleCount . ' ' . $rulesLabel;
            }

            $segOptions = [
                'stepType' => (string)\mh_lng('MAILBEEZ_AUDIENCE_SEGMENTATION', 'Segmentation'),
                'content' => $segContent,
                'id' => 'segmentation',
                // Make segmentation clickable when legacy route is available
                'attrs' => $segmentationAttrs ?? null,
            ];

            if ($segRuleCount === 0) {
                $segOptions['color'] = 'gray';
                $segOptions['ring'] = 'ring-divider';
            }

            $fb->audienceSegmentation('', $segOptions);

            // 3) Result set step — persist a lightweight placeholder in Flow HTML.
            // The placeholder is resolved to a dynamic anchor at render time so
            // cached flow markup stays reusable while counts refresh independently.
            $subtitleHtml = self::countPlaceholderToken($listId);

            $applicability = (string)($this->attributes['newsletter_list_applicability'] ?? 'both');

            $fb->audienceResult((string)\mh_lng('MAILBEEZ_AUDIENCE_RESULT_SET', 'Result set'), [
                'id' => 'result',
                'subtitle' => $subtitleHtml, // Base::h allows <a> so hx-* attrs are preserved
                'dropdown' => [
                    'items' => array_values(self::getNewNewsletterItemActions($listId, $applicability)),
                ],
            ]);

            // 4) Branch into Automations and Newsletters
            $automationCount = NewsletterItem::countAutomations($listId);
            $newsletterCount = NewsletterItem::countNewsletters($listId);

            $branches = [];

            if ($applicability === 'both' || $applicability === 'automation') {
                $branches[] = function (\Components\CampaignFlow\FlowBuilder $b) use ($automationCount, $listId) {
                    $b->action((string)$automationCount, [
                        'subtitle' => (string)\mh_lng('MAILBEEZ_AUDIENCE_FLOW_AUTOMATIONS', 'Automations'),
                        'icon' => \Components\Base::SVG_FLOWER,
                        'color' => 'brand',
                        'hover_icon' => 'view',
                        'attrs' => [
                            'command' => 'show-modal',
                            'commandfor' => 'config-modal',
                            'data-title' => (string)\mh_lng('MAILBEEZ_AUDIENCE_FLOW_AUTOMATIONS', 'Automations'),
                            'data-size' => 'full-margin',
                            'hx-get' => \Components\Router::url('newsletter_items_list', ['partial' => 1, 'group' => 'smart-active', 'list_id' => $listId, 'schedule_type' => 'automation']),
                            'hx-target' => ' #config-modal-body',
                            'hx-swap' => 'innerHTML',
                        ],
                    ]);
                };
            }

            if ($applicability === 'both' || $applicability === 'newsletter') {
                $branches[] = function (\Components\CampaignFlow\FlowBuilder $b) use ($newsletterCount, $listId) {
                    $b->action((string)$newsletterCount, [
                        'subtitle' => (string)\mh_lng('MAILBEEZ_AUDIENCE_FLOW_NEWSLETTERS', 'Newsletters'),
                        'icon' => \Components\Base::SVG_MEGAPHONE,
                        'color' => 'blue',
                        'hover_icon' => 'view',
                        'attrs' => [
                            'command' => 'show-modal',
                            'commandfor' => 'config-modal',
                            'data-title' => (string)\mh_lng('MAILBEEZ_AUDIENCE_FLOW_NEWSLETTERS', 'Newsletters'),
                            'data-size' => 'full-margin',
                            'hx-get' => \Components\Router::url('newsletter_items_list', ['partial' => 1, 'group' => 'smart-active', 'list_id' => $listId, 'schedule_type' => 'newsletter']),
                            'hx-target' => ' #config-modal-body',
                            'hx-swap' => 'innerHTML',
                        ],
                    ]);
                };
            }

            if (count($branches) > 1) {
                $fb->branch($branches);
            } elseif (count($branches) === 1) {
                $branches[0]($fb);
            }

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

    /**
     * Check if the flow HTML is already cached.
     * @return bool
     */
    public function isFlowCached(): bool
    {
        $lang = $_SESSION['language'] ?? 'default';
        $listId = (int)($this->{static::pk()} ?? 0);
        if ($listId <= 0) {
            return false;
        }

        return \Components\Support\FlowHtmlCache::has(
            \Components\Support\FlowHtmlCache::OWNER_AUDIENCE,
            $listId,
            $lang
        );
    }

    /**
     * Return the lazy-load URL for this audience's flow visualization.
     * Used by ItemList when eager rendering is skipped.
     */
    public function lazyFlowUrl(int $id = 0): string
    {
        $resolvedId = $id > 0 ? $id : (int)($this->{static::pk()} ?? 0);
        try {
            return \Components\Router::url('audience_list_flow', ['partial' => 1, 'id' => $resolvedId]);
        } catch (\Throwable $e) {
            return '';
        }
    }

    public function renderFlow(bool $useCache = true): string
    {
        $lang = $_SESSION['language'] ?? 'default';
        $listId = (int)($this->{static::pk()} ?? 0);

        if ($useCache && $listId > 0) {
            $cached = \Components\Support\FlowHtmlCache::get(
                \Components\Support\FlowHtmlCache::OWNER_AUDIENCE,
                $listId,
                $lang
            );
            if (is_string($cached) && $cached !== '') {
                try {
                    // Resolve dynamic placeholders at response time without mutating cache.
                    return self::renderCountPlaceholders($cached);
                } catch (\Throwable $e) {
                    return $cached;
                }
            }
        }

        $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>';
        }

        $html = \Components\CampaignFlow\FlowViz::render($flowItems, [
            'orientation' => 'horizontal',
            'size' => 'sm',
            'editable' => false,
            'class' => 'py-2',
            'id' => 'flow-viz-' . $this->{static::pk()},
        ]);

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

        try {
            return self::renderCountPlaceholders($html);
        } catch (\Throwable $e) {
            return $html;
        }
    }

    /**
     * Invalidate the flow cache for this audience.
     * @param bool $persist
     */
    public function invalidateFlowCache(bool $persist = true): void
    {
        $listId = (int)($this->{static::pk()} ?? 0);
        if ($listId <= 0) {
            return;
        }

        \Components\Support\FlowHtmlCache::invalidateOwner(
            \Components\Support\FlowHtmlCache::OWNER_AUDIENCE,
            $listId
        );
    }

    public static function getNewDefaults(): array
    {
        return [
            'newsletter_list_applicability' => 'both',
        ];
    }

    public function update(): bool
    {
        $this->invalidateFlowCache(false);
        return parent::update();
    }

    public function delete(): bool
    {
        return parent::delete();
    }

    /**
     * Return a badge representing the applicability of this audience.
     * Used in list views to indicate if the audience is for newsletters, automations, or both.
     *
     * @return string HTML for the badge
     */
    public function getApplicabilityBadgeHtml(): string
    {
        return self::getBadgeHtml((string)($this->attributes['newsletter_list_applicability'] ?? 'both'));
    }

    public static function getBadgeHtml(string $applicability): string
    {
        if ($applicability === 'both' || $applicability === 'all') {
            return '';
        }

        $label = '';
        $type = 'gray';
        if ($applicability === 'newsletter') {
            $label = \mh_lng('MAILBEEZ_AUDIENCE_APPLICABILITY_NEWSLETTER', 'Newsletter');
            $type = 'info';
        } elseif ($applicability === 'automation' || $applicability === 'automations') {
            $label = \mh_lng('MAILBEEZ_AUDIENCE_APPLICABILITY_AUTOMATION', 'Automation');
            $type = 'brand';
        }

        if ($label === '') {
            return '';
        }

        return \Components\Ui\Badge::render($type, $label, [
            'variant' => 'soft',
            'inner_class' => 'px-1.5 py-0.5 text-[10px] uppercase font-bold leading-none'
        ]);
    }

    /**
     * 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
    {
        return true;
    }

    /**
     * Augment the form values with dynamic content.
     * When editing with fields=segmentation, we inject the legacy segmentation editor iframe.
     *
     * @param array $values Current form values
     * @return array Augmented values
     */
    public function augmentFormValues(array $values): array
    {
        if (!isset($values['page_mode'])) {
            $values['page_mode'] = \mh_get('page_mode', '', 'string');
        }
        return $values;
    }
}
