<?php
namespace Components\Models;

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

    /**
     * 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
    {
        $force = (bool)($opts['force'] ?? false);
        $intersect = (bool)($opts['intersect'] ?? false);
        $label = array_key_exists('label', $opts) ? (string)$opts['label'] : '0';
        $events = (string)($opts['events'] ?? 'audience_lists:counts:refresh from:body');

        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) { $triggers[] = 'intersect once'; }
        if ($events !== '') { $triggers[] = $events; }
        $triggerStr = implode(', ', $triggers);

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

        // Build HTML using heredoc to comply with inline HTML guidelines (<<<HTML ... HTML)
        $triggerAttr = ($triggerStr !== '') ? (' hx-trigger="' . $trigEsc . '"') : '';
        $html = <<<HTML
<a class="fade-me-out" hx-get="{$urlEsc}"{$triggerAttr} hx-swap="outerHTML swap:1s" hx-target="this" hx-disinherit="*" data-no-global-loader="1" data-no-local-loader="1" @click.stop
   hx-on="htmx:load: this.classList.add('hb-ping-once'); setTimeout(() => this.classList.remove('hb-ping-once'), 700);">{$labelEsc}</a>
HTML;
        return $html;
    }

    // ---------------- 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 = 900): 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;
        }

        // 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 = is_int($cachedVal) ? (int)$cachedVal : (int)($cachedVal ?? 0);
        try {
            if ($id > 0 && $source !== '' && class_exists('\\mb_newsletter_lists')) {
                $count = (int) \mb_newsletter_lists::result_cnt($id, $source);
            }
        } catch (\Throwable $e) {
            // If legacy fails, keep previous cached value
        }

        // 4) Update cache entry with new value and timestamp, then persist (best-effort)
        $now = time();
        try {
            $cache = (array) $this->getCache();
            $cache[static::CACHE_KEY_RESULT_COUNT] = ['value' => (int)$count, 'ts' => $now];
            $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 = 900): 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,
        ];
    }

    /**
     * Invalidate the cached result_count entry for this Audience record.
     */
    public function invalidateResultCountCache(bool $persist = true): bool
    {
        return $this->invalidateCache(static::CACHE_KEY_RESULT_COUNT, $persist);
    }

    /**
     * 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);
        } 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>
     */
    public static function all(): array
    {
        if (self::isDev()) return [];

        $table = static::table();
        $pk = static::pk();
        $sql = "SELECT * FROM {$table} WHERE newsletter_list_source NOT IN ('shop_smartlist','prospects_smartlist') 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;
    }

    /**
     * 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
    {
        // In dev runtime or when legacy class is not present, nothing to return
        if (self::isDev() || !class_exists('\\mb_newsletter')) {
            return [];
        }

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

    /**
     * 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
    {
        try {
            $sourceObjects = self::getSourcebeezObjects();
            if (empty($sourceObjects)) {
                return ['object' => null, 'class' => ''];
            }

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

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

            return ['object' => null, 'class' => ''];
        } catch (\Throwable $e) {
            return ['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() {
        return ['svg' => \Components\Base::SVG_OUTLINE_USER_GROUP, 'color' => 'green', 'size' => 'sm'];
                /*
        switch ($this->attributes['newsletter_list_source']) {
            case 'F':
                return ['svg' => \Components\Base::SVG_OUTLINE_MONEY, 'color' => 'green', 'size' => 'sm'];
            case 'P':
                return ['svg' => \Components\Base::SVG_VOUCHER, 'color' => 'orange', 'size' => 'sm'];
            case 'S':
            default:
                return ['svg' => \Components\Base::SVG_OUTLINE_TRUCK, 'color' => 'blue', 'size' => 'sm'];
        }
                */
    }

    /**
     * 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
     */
    public static function itemListOptions(array $overrides = []): array
    {
        $defaults = [
            '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,
            'open_key_prefix' => 'audience-list',
            // Lazy-load expandable flow content similar to NewsletterItems
            'expand_lazy_url' => \Components\Router::url('audience_list_flow', ['partial' => 1, 'id' => '{id}']),
            'expand_lazy_attrs' => [
                'hx-trigger' => 'intersect once',
            ],
            // 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', ['partial' => 1]),
            // 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' => [
                // Intentionally empty; pages may override if they want extra actions
            ],
        ];

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

    /**
     * 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',
                    ],
                ];
            }

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

        // end of getCrudMenuItems()
        }

        /**
         * Build a visual flow for this Audience (newsletter list).
         * The structure is: Source -> Segmentation -> Result set
         *
         * @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 {
                    // Prefer explicit primary key if present
                    $listId = (int)($this->attributes[static::pk()] ?? $this->attributes['newsletter_list_id'] ?? 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; }
                            // 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-url' => ($url),
                                            'hx-target' => '#config-modal-body',
                                            'hx-swap' => 'innerHTML',
                                        ];
                                    }
                                }
                            }
                        } catch (\Throwable $e) { /* ignore */ }
                    }
                } catch (\Throwable $e) {
                    // ignore, keep fallbacks
                }

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

                // 2) Segmentation step
                $segLabel = $segType !== '' ? ucfirst((string)$segType) : (string)\mh_lng('MAILBEEZ_AUDIENCE_SEGMENTATION_NONE', 'None');
                $fb->audienceSegmentation((string)\mh_lng('MAILBEEZ_AUDIENCE_SEGMENTATION', 'Segmentation'), [
                    'id' => 'segmentation',
                    'subtitle' => $segLabel,
                    // Make segmentation clickable when legacy route is available
                    'attrs' => $segmentationAttrs ?? null,
                ]);


                // 3) Result set step — use unified helpers: read cache via getResultCountCache();
                // show cached value immediately (formatted) and refresh in background if stale.
                $ttlSeconds = 900; // 15 minutes TTL
                $rc = (array) $this->getResultCountCache($ttlSeconds);
                $cachedVal = isset($rc['value']) ? $rc['value'] : null;
                $isValid = isset($rc['is_valid']) ? (bool)$rc['is_valid'] : false;

                // Determine label: cached value formatted when present; otherwise an ellipsis
                $label = '…';
                if ($cachedVal !== null) {
                    try {
                        $label = \Components\Support\Format::humanNumber((int)$cachedVal, [
                            'decimals' => 1,
                            'trim_zeros' => true,
                        ]);
                    } catch (\Throwable $e) {
                        $label = (string)(int)$cachedVal;
                    }
                }

                // Render anchor: if cache is invalid, force background refresh on intersection
                $subtitleHtml = self::countAnchorHtml($listId, [
                    'force' => !$isValid,
                    'intersect' => !$isValid,
                    'label' => (string)$label,
                ]);

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

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