// =====================================================
// DASHBOARD BUILDER PRO - SHARED ENGINE (code_shared)
// Namespace: window.DBPEngine
// Todos os módulos reutilizáveis do plugin
// =====================================================

(function () {
    'use strict';

    // Versão atual do engine. Mudar aqui força reload em páginas com cache do Bubble Shared.
    var CURRENT_VERSION = '3.6.1';

    // Evitar re-inicialização SE já carregou a MESMA versão.
    // O Bubble cacheia a aba Shared agressivamente entre páginas — usar comparação
    // de versão (não só existência) garante que fixes de bug sejam aplicados.
    if (window.DBPEngine && window.DBPEngine._version === CURRENT_VERSION) return;

    // =====================================================
    // MÓDULO: UTILS
    // Funções utilitárias puras (sem dependência de instance)
    // =====================================================

    var Utils = {};

    // Escape HTML para prevenir XSS
    Utils.escapeHtml = function (str) {
        str = (str === null || str === undefined) ? '' : String(str);
        return str
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    };

    // Resolver label customizável (ui_labels)
    // keys = array de chaves para tentar, fallback = valor padrão
    Utils.resolveLabel = function (uiLabels, keys, fallback) {
        try {
            for (var i = 0; i < keys.length; i++) {
                var k = keys[i];
                if (uiLabels && Object.prototype.hasOwnProperty.call(uiLabels, k)) {
                    var v = uiLabels[k];
                    if (v !== null && v !== undefined && String(v).trim() !== '') {
                        return String(v);
                    }
                }
            }
        } catch (e) { }
        return fallback;
    };

    // Resolver label amigável de campo (field_labels)
    Utils.getFieldLabel = function (fieldLabels, fieldName) {
        if (fieldLabels && fieldLabels[fieldName]) {
            return fieldLabels[fieldName];
        }
        return fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
    };

    // Formatar número com locale e formato
    Utils.formatNumber = function (value, format, locale, currency) {
        if (value === null || value === undefined || isNaN(value)) return '-';

        if (format === 'currency' || format === 'currency_brl') {
            return new Intl.NumberFormat(locale, { style: 'currency', currency: currency }).format(value);
        }
        if (format === 'percent' || format === 'percentage') {
            return new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value / 100);
        }
        if (format === 'integer') {
            return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
        }
        return new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(value);
    };

    // Calcular variação entre período atual e anterior
    Utils.formatVariation = function (current, previous) {
        if (!previous || previous === 0) return { value: 0, text: '-', class: 'neutral' };
        var variation = ((current - previous) / Math.abs(previous)) * 100;
        var sign = variation >= 0 ? '+' : '';
        var cssClass = variation > 0 ? 'positive' : (variation < 0 ? 'negative' : 'neutral');
        var arrow = variation > 0 ? '↑' : (variation < 0 ? '↓' : '→');
        return { value: variation, text: arrow + ' ' + sign + variation.toFixed(1) + '%', class: cssClass };
    };

    // Agregar valores de um array de objetos
    Utils.aggregate = function (data, field, method) {
        if (!data || data.length === 0) return 0;
        var values = data.map(function (item) {
            var val = parseFloat(item[field]);
            return isNaN(val) ? 0 : val;
        });

        switch (method) {
            case 'sum': return values.reduce(function (a, b) { return a + b; }, 0);
            case 'avg': case 'average': return values.reduce(function (a, b) { return a + b; }, 0) / values.length;
            case 'count': return data.length;
            case 'min': return Math.min.apply(null, values);
            case 'max': return Math.max.apply(null, values);
            default: return values.reduce(function (a, b) { return a + b; }, 0);
        }
    };

    // Filtrar dados por período de datas
    Utils.filterByPeriod = function (data, startDate, endDate, dateFieldName) {
        if (!startDate || !endDate) return data;
        if (!data || data.length === 0) return data;

        var actualDateField = dateFieldName;
        if (!data[0].hasOwnProperty(dateFieldName)) {
            if (data[0].hasOwnProperty('date')) {
                actualDateField = 'date';
            } else if (data[0].hasOwnProperty('data')) {
                actualDateField = 'data';
            }
        }

        var start = new Date(startDate);
        var end = new Date(endDate);
        end.setHours(23, 59, 59, 999);

        return data.filter(function (item) {
            var dateValue = item[actualDateField];
            if (!dateValue) return false;
            var itemDate = new Date(dateValue);
            return itemDate >= start && itemDate <= end;
        });
    };

    // Agrupar por campo categórico
    Utils.groupBy = function (data, field, valueField, aggregation) {
        var groups = {};
        data.forEach(function (item) {
            var key = item[field] || 'Outros';
            if (!groups[key]) groups[key] = [];
            groups[key].push(item);
        });

        var result = [];
        for (var key in groups) {
            result.push({
                label: key,
                value: Utils.aggregate(groups[key], valueField, aggregation),
                count: groups[key].length
            });
        }
        return result;
    };

    // Agrupar por data (dia, semana, mês, ano)
    Utils.groupByDate = function (data, dateFieldName, valueField, aggregation, groupFormat) {
        var groups = {};

        data.forEach(function (item) {
            var date = new Date(item[dateFieldName]);
            var key;

            switch (groupFormat) {
                case 'day':
                    key = date.toISOString().split('T')[0];
                    break;
                case 'week':
                    var weekStart = new Date(date);
                    weekStart.setDate(date.getDate() - date.getDay());
                    key = weekStart.toISOString().split('T')[0];
                    break;
                case 'month':
                    key = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
                    break;
                case 'year':
                    key = String(date.getFullYear());
                    break;
                default:
                    key = date.toISOString().split('T')[0];
            }

            if (!groups[key]) groups[key] = [];
            groups[key].push(item);
        });

        var result = [];
        var sortedKeys = Object.keys(groups).sort();

        sortedKeys.forEach(function (key) {
            var label = key;
            if (groupFormat === 'month') {
                var parts = key.split('-');
                var monthNames = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
                label = monthNames[parseInt(parts[1]) - 1] + '/' + parts[0].substr(2);
            }
            result.push({
                key: key,
                label: label,
                value: Utils.aggregate(groups[key], valueField, aggregation),
                count: groups[key].length
            });
        });

        return result;
    };

    // Valores únicos de um campo
    Utils.getUniqueValues = function (data, field) {
        var values = {};
        data.forEach(function (item) {
            var val = item[field];
            if (val !== undefined && val !== null && val !== '') {
                values[val] = true;
            }
        });
        return Object.keys(values).sort();
    };

    // Aplicar filtros combinados (período + campos)
    Utils.applyFilters = function (data, filters, dateFieldName) {
        var filtered = data;
        if (filters.startDate && filters.endDate) {
            filtered = Utils.filterByPeriod(filtered, filters.startDate, filters.endDate, dateFieldName);
        }
        Object.keys(filters).forEach(function (key) {
            if (key !== 'startDate' && key !== 'endDate' && key !== 'comparison' && filters[key] && filters[key] !== 'all') {
                filtered = filtered.filter(function (item) {
                    return item[key] == filters[key];
                });
            }
        });
        return filtered;
    };

    // Formatar data para input[type=date]
    Utils.formatDateInput = function (date) {
        if (!date) return '';
        if (typeof date === 'number') {
            date = new Date(date);
        }
        try {
            var d = new Date(date);
            if (isNaN(d.getTime())) return '';
            return d.toISOString().split('T')[0];
        } catch (e) {
            return '';
        }
    };

    // Converter string de data para Date object (para Bubble)
    // Usa T12:00:00 para evitar problemas de timezone/DST
    Utils.toDateObject = function (dateStr) {
        if (!dateStr) return null;
        try {
            var d;
            if (typeof dateStr === 'string' && dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) {
                d = new Date(dateStr + 'T12:00:00');
            } else {
                d = new Date(dateStr);
            }
            if (isNaN(d.getTime())) return null;
            return d;
        } catch (e) {
            return null;
        }
    };

    // Calcular tickAmount ideal baseado na altura do container
    Utils.calculateTickAmount = function (containerHeight) {
        var availableHeight = containerHeight - 60;
        var optimalTicks = Math.floor(availableHeight / 40);
        return Math.max(3, Math.min(optimalTicks, 10));
    };

    // Parsear JSON com fallback seguro
    Utils.safeParseJSON = function (str, fallback) {
        if (!str || (typeof str === 'string' && str.trim() === '')) return fallback;
        if (typeof str === 'object') return str;
        try {
            var parsed = JSON.parse(str);
            if (parsed === null || parsed === undefined) return fallback;
            if (Array.isArray(fallback) && !Array.isArray(parsed)) return fallback;
            if (!Array.isArray(fallback) && typeof fallback === 'object' && (Array.isArray(parsed) || typeof parsed !== 'object')) return fallback;
            return parsed;
        } catch (e) {
            return fallback;
        }
    };

    // Normalizar filtros (remover vazios)
    Utils.normalizeFilters = function (filters) {
        var clean = {};
        if (!filters) return clean;
        Object.keys(filters).forEach(function (k) {
            var v = filters[k];
            if (v === undefined || v === null || v === '' || v === 'all') return;
            if (k === 'startDate' || k === 'endDate' || k === 'comparison') return;
            clean[k] = v;
        });
        if (filters.startDate) clean.startDate = filters.startDate;
        if (filters.endDate) clean.endDate = filters.endDate;
        if (filters.comparison !== undefined) clean.comparison = filters.comparison;
        return clean;
    };

    // Stringify estável para comparação de filtros (dirty-check)
    Utils.stableStringify = function (filters) {
        var normalized = Utils.normalizeFilters(filters);
        return JSON.stringify(normalized, Object.keys(normalized).sort());
    };

    // Normalização ultra-agressiva para matching de campos Bubble
    Utils.ultraNormalize = function (str) {
        return str
            .toLowerCase()
            .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
            .replace(/[^a-z0-9]/g, "");
    };

    // Extrair raiz do slug do Bubble
    Utils.extractSlugRoot = function (slug) {
        var lower = slug.toLowerCase();
        var cutPatterns = ['_option_', '_custom_', '_text', '_number', '_date', '_boolean', '_list_', '_os_', '_file', '_image'];
        for (var i = 0; i < cutPatterns.length; i++) {
            var idx = lower.indexOf(cutPatterns[i]);
            if (idx > 0) {
                return slug.substring(0, idx);
            }
        }
        return slug;
    };

    // Chart family mapping
    // v3.3.1: cartesian expandido pra incluir stacked e multiseries
    // (são variantes de bar/line — fazem sentido como opção de troca)
    Utils.chartFamilies = {
        cartesian: ['line', 'area', 'bar', 'stacked', 'multiseries'],
        proportion: ['pie', 'donut'],
        funnel: ['funnel'],
        radial: ['gauge'],
        kpi: ['kpi', 'progress'],
        data: ['table']
    };

    // Obter família de um tipo de gráfico
    Utils.getChartFamily = function (chartType) {
        for (var family in Utils.chartFamilies) {
            if (Utils.chartFamilies[family].indexOf(chartType) !== -1) {
                return family;
            }
        }
        return 'cartesian';
    };

    // Tipos permitidos para um card (baseado na família)
    Utils.getAllowedTypes = function (card) {
        if (card.allowed_chart_types && card.allowed_chart_types.length > 0) {
            return card.allowed_chart_types;
        }
        var currentType = card.type || card.chartType || 'bar';
        var family = Utils.getChartFamily(currentType);
        return Utils.chartFamilies[family] || [currentType];
    };

    // =====================================================
    // MÓDULO: STORAGE (v3.2.0)
    // Local-first cache via localStorage.
    // Reaproveita o padrão do SDS — armazena cards_config_full por dashboard,
    // com timestamp pra sync inteligente com servidor (Xano via Bubble).
    // =====================================================

    var Storage = {};

    // Prefixo padrão do localStorage key (versionado pra futuras migrations)
    Storage.PREFIX = 'dbp_v1_';

    // Constrói chave única por dashboard.
    // Importante: usar dashboardId fornecido pelo user (NÃO uniqueId, que
    // muda a cada session). Sem dashboardId, usa "default" (só serve para 1 dashboard).
    Storage.getKey = function (dashboardId) {
        return Storage.PREFIX + (dashboardId || 'default');
    };

    // Lê do localStorage. Retorna { timestamp, cards_config_full, hidden_cards_json } ou null.
    Storage.read = function (dashboardId) {
        try {
            if (typeof localStorage === 'undefined') return null;
            var raw = localStorage.getItem(Storage.getKey(dashboardId));
            if (!raw) return null;
            var parsed = JSON.parse(raw);
            if (!parsed || typeof parsed !== 'object') return null;
            return parsed;
        } catch (e) {
            return null;
        }
    };

    // Escreve no localStorage. Adiciona timestamp automaticamente.
    // payload = { cards_config_full: '<json string>' }
    Storage.write = function (dashboardId, payload) {
        try {
            if (typeof localStorage === 'undefined') return false;
            var data = {
                timestamp: Date.now(),
                version: '24col',
                cards_config_full: payload.cards_config_full || null
            };
            localStorage.setItem(Storage.getKey(dashboardId), JSON.stringify(data));
            return true;
        } catch (e) {
            // QuotaExceededError ou localStorage bloqueado (modo anônimo Safari)
            return false;
        }
    };

    // Remove cache do dashboard.
    Storage.clear = function (dashboardId) {
        try {
            if (typeof localStorage === 'undefined') return;
            localStorage.removeItem(Storage.getKey(dashboardId));
        } catch (e) { }
    };

    // Extrai timestamp de um JSON string de cards_config (do servidor).
    // Retorna 0 se não tiver timestamp (compat com saves antigos).
    Storage.extractTimestamp = function (jsonStr) {
        if (!jsonStr || typeof jsonStr !== 'string') return 0;
        try {
            var parsed = JSON.parse(jsonStr);
            return (parsed && parsed.timestamp) ? parsed.timestamp : 0;
        } catch (e) {
            return 0;
        }
    };

    // Decide se deve usar versão local ou do servidor baseado em timestamps.
    // Returns: 'local' | 'server' | 'none' (nenhum disponível)
    Storage.resolve = function (dashboardId, serverJsonStr) {
        var local = Storage.read(dashboardId);
        var serverTs = Storage.extractTimestamp(serverJsonStr);
        var localTs = (local && local.timestamp) ? local.timestamp : 0;
        var hasServer = !!(serverJsonStr && serverJsonStr.trim() !== '' && serverJsonStr !== '{}' && serverJsonStr !== '[]');
        var hasLocal = !!(local && local.cards_config_full);

        if (!hasLocal && !hasServer) return { source: 'none', data: null };
        if (!hasLocal) return { source: 'server', data: serverJsonStr, timestamp: serverTs };
        if (!hasServer) return { source: 'local', data: local.cards_config_full, timestamp: localTs };
        // Ambos existem — usa o mais recente
        if (localTs > serverTs) return { source: 'local', data: local.cards_config_full, timestamp: localTs };
        return { source: 'server', data: serverJsonStr, timestamp: serverTs };
    };

    // =====================================================
    // MÓDULO: CSS
    // Gera todo o CSS do dashboard como string
    // =====================================================

    var CSS = {};

    // Gera o bloco <style> completo do dashboard
    // cfg = { uniqueId, primaryColor, secondaryColor, successColor, dangerColor,
    //         cardBackground, cardRadius, fontFamily, gap }
    CSS.generate = function (cfg) {
        var id = '#' + cfg.uniqueId;
        var r = cfg.cardRadius || 12;
        var g = (cfg.gap !== undefined && cfg.gap !== null) ? cfg.gap : 16;
        var halfG = g / 2;
        var cols = cfg.columns || 12;
        var maxRows = 50;

        // v3.1.20: gerar CSS de grid dinamicamente baseado em cfg.columns.
        // GridStack v10 padrão só vem com CSS para 12 colunas (e gridstack-extra
        // cobre 2-11). Para columns=24 (nosso novo default), sem essas regras
        // os cards ficam com width/left indefinidos → invisíveis.
        var gridCss = '';
        // Width baseado em gs-w
        for (var iW = 1; iW <= cols; iW++) {
            gridCss += id + ' .grid-stack > .grid-stack-item[gs-w="' + iW + '"] { width: calc(100% / ' + cols + ' * ' + iW + '); }';
        }
        // Left baseado em gs-x (0 é sempre left:0 já garantido pelo position absolute)
        for (var iX = 1; iX < cols; iX++) {
            gridCss += id + ' .grid-stack > .grid-stack-item[gs-x="' + iX + '"] { left: calc(100% / ' + cols + ' * ' + iX + '); }';
        }
        // Top baseado em gs-y (usa --gs-cell-height que GridStack v10 seta inline no .grid-stack)
        gridCss += id + ' .grid-stack > .grid-stack-item[gs-y="0"] { top: 0; }';
        for (var iY = 1; iY < maxRows; iY++) {
            gridCss += id + ' .grid-stack > .grid-stack-item[gs-y="' + iY + '"] { top: calc(var(--gs-cell-height, 40px) * ' + iY + '); }';
        }
        // Height baseado em gs-h
        for (var iH = 1; iH <= maxRows; iH++) {
            gridCss += id + ' .grid-stack > .grid-stack-item[gs-h="' + iH + '"] { height: calc(var(--gs-cell-height, 40px) * ' + iH + '); }';
        }

        return '<style>' +
            gridCss +

            // --- Variáveis base ---
            id + ' {' +
            '--dbp-primary: ' + cfg.primaryColor + ';' +
            '--dbp-secondary: ' + cfg.secondaryColor + ';' +
            '--dbp-success: ' + cfg.successColor + ';' +
            '--dbp-danger: ' + cfg.dangerColor + ';' +
            '--dbp-radius: ' + r + 'px;' +
            '--dbp-gap: ' + g + 'px;' +
            '--dbp-half-gap: ' + halfG + 'px;' +
            'font-family: ' + cfg.fontFamily + ';' +
            '}' +

            // --- Tema Light ---
            id + '.dbp-theme-light {' +
            '--dbp-bg: #f5f7fa;' +
            '--dbp-card-bg: #ffffff;' +
            '--dbp-text: #2c3e50;' +
            '--dbp-text-secondary: #7f8c8d;' +
            '--dbp-border: #e0e6ed;' +
            '--dbp-grid-color: #ecf0f1;' +
            '}' +
            id + '.dbp-theme-light .dbp-card { background: #ffffff !important; color: #2c3e50; box-shadow: 0 1px 2px rgba(16,24,40,0.04), 0 2px 6px rgba(16,24,40,0.06), 0 8px 20px rgba(16,24,40,0.05); }' +
            id + '.dbp-theme-light .dbp-card-title { color: #2c3e50; }' +
            id + '.dbp-theme-light .dbp-kpi-value { color: #2c3e50; }' +
            id + '.dbp-theme-light .dbp-filters { background: #ffffff; border-bottom: 1px solid #e0e6ed; }' +
            id + '.dbp-theme-light .grid-stack-item-content { background: transparent; border: none; box-shadow: none; }' +
            id + '.dbp-theme-light .dbp-card { border-radius: ' + r + 'px; overflow: hidden; border: none; }' +

            // --- Tema Dark ---
            id + '.dbp-theme-dark {' +
            '--dbp-bg: #1a1a2e;' +
            '--dbp-card-bg: #16213e;' +
            '--dbp-text: #eaeaea;' +
            '--dbp-text-secondary: #c0c0c0;' +
            '--dbp-border: #3d4758;' +
            '--dbp-grid-color: #3d4758;' +
            '}' +
            id + '.dbp-theme-dark .dbp-card { background: #16213e !important; color: #eaeaea !important; box-shadow: 0 1px 2px rgba(0,0,0,0.2), 0 2px 8px rgba(0,0,0,0.28), 0 8px 24px rgba(0,0,0,0.22); }' +

            // v3.6.1: hover elevado (com #id pra vencer specificity) + transição
            id + ' .dbp-card { transition: box-shadow 0.22s ease, transform 0.22s ease; }' +
            id + '.dbp-theme-light .dbp-card:hover { box-shadow: 0 2px 4px rgba(16,24,40,0.06), 0 8px 16px rgba(16,24,40,0.1), 0 16px 32px rgba(16,24,40,0.08); transform: translateY(-2px); }' +
            id + '.dbp-theme-dark .dbp-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,0.3), 0 10px 24px rgba(0,0,0,0.35), 0 18px 40px rgba(0,0,0,0.3); transform: translateY(-2px); }' +

            // v3.6.1: animação de entrada dos cards (fade-in + slide-up)
            '@keyframes dbp-card-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }' +
            id + ' .grid-stack-item-content > .dbp-card { animation: dbp-card-in 0.35s ease both; }' +

            // v3.6.1: empty state de card individual (sem dados) com ícone
            id + ' .dbp-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 8px; color: var(--dbp-text-secondary, #95a5a6); font-size: 13px; text-align: center; padding: 16px; }' +
            id + ' .dbp-empty-ico { font-size: 28px; opacity: 0.45; }' +

            id + '.dbp-theme-dark .dbp-card-title { color: #eaeaea !important; }' +
            id + '.dbp-theme-dark .dbp-kpi-value { color: #eaeaea !important; }' +
            id + '.dbp-theme-dark .dbp-kpi-label { color: #c0c0c0; }' +
            id + '.dbp-theme-dark .dbp-kpi-comparison { color: inherit; }' +
            id + '.dbp-theme-dark .dbp-filters { background: #16213e; border-bottom: 1px solid #3d4758; }' +
            id + '.dbp-theme-dark .dbp-progress-labels { color: #c0c0c0; }' +
            id + '.dbp-theme-dark .dbp-progress-header { color: #eaeaea; }' +
            id + '.dbp-theme-dark .dbp-table th { color: #c0c0c0; background: rgba(255,255,255,0.08); }' +
            id + '.dbp-theme-dark .dbp-table td { color: #eaeaea; }' +
            id + '.dbp-theme-dark .dbp-empty { color: #c0c0c0; }' +
            id + '.dbp-theme-dark .grid-stack-item-content { background: transparent; border: none; box-shadow: none; }' +
            id + '.dbp-theme-dark .dbp-card { border-radius: ' + r + 'px; overflow: hidden; border: none; }' +
            id + '.dbp-theme-dark .dbp-filter-input { background: #1a1a2e; color: #eaeaea; border-color: #3d4758; }' +
            id + '.dbp-theme-dark .dbp-filter-label { color: #d0d0d0; }' +
            id + '.dbp-theme-dark .dbp-progress-bar { background: #3a3a5a; }' +

            // Override cardBackground
            (cfg.cardBackground ? id + ' .dbp-card { background: ' + cfg.cardBackground + ' !important; }' : '') +

            // --- v3.6.0: Botões uniformes da toolbar (linha 1) com sombra 3D ---
            // button.dbp-toolbar-btn (tag+classe) vence os estilos antigos (.dbp-btn-add-chart etc).
            id + ' button.dbp-toolbar-btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border: none; border-radius: 8px; background: var(--dbp-primary); color: #ffffff; font-size: 13px; font-weight: 600; cursor: pointer; line-height: 1; box-shadow: 0 2px 4px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08); transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; }' +
            id + ' button.dbp-toolbar-btn:hover { filter: brightness(1.08); box-shadow: 0 4px 10px rgba(0,0,0,0.18), 0 2px 4px rgba(0,0,0,0.1); transform: translateY(-1px); }' +
            id + ' button.dbp-toolbar-btn:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(0,0,0,0.15); }' +
            id + ' button.dbp-toolbar-btn .dbp-tb-ico { width: 16px; height: 16px; flex-shrink: 0; }' +
            // Filter ativo: leve destaque (anel)
            id + ' button.dbp-toolbar-btn.active { box-shadow: 0 0 0 2px rgba(255,255,255,0.5) inset, 0 2px 4px rgba(0,0,0,0.12); filter: brightness(0.94); }' +

            // --- Filtros e Inputs ---
            id + ' .dbp-filter-input { padding: 8px 12px; border: 1px solid var(--dbp-border); border-radius: 6px; background: var(--dbp-card-bg); color: var(--dbp-text); font-size: 13px; }' +
            id + ' .dbp-filter-btn { padding: 8px 16px; border: none; border-radius: 6px; background: var(--dbp-primary); color: white; font-size: 13px; font-weight: 500; cursor: pointer; }' +
            id + ' .dbp-filter-btn-secondary { background: var(--dbp-border); color: var(--dbp-text); }' +
            id + ' .dbp-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; }' +
            id + ' .dbp-toggle-switch { position: relative; width: 40px; height: 22px; background: var(--dbp-border); border-radius: 11px; transition: background 0.2s; }' +
            id + ' .dbp-toggle-switch.active { background: var(--dbp-primary); }' +
            id + ' .dbp-toggle-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; background: white; border-radius: 50%; transition: transform 0.2s; }' +
            id + ' .dbp-toggle-switch.active::after { transform: translateX(18px); }' +
            id + ' .dbp-records-count { font-size: 12px; color: var(--dbp-text-secondary); padding: 6px 12px; background: var(--dbp-bg); border-radius: 4px; }' +

            // --- KPI ---
            id + ' .dbp-card { position: relative; }' +
            id + ' .dbp-kpi { text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; }' +
            id + ' .dbp-kpi-value { font-weight: 700; line-height: 1.2; transition: font-size 0.2s ease; }' +
            id + ' .dbp-kpi-comparison { font-weight: 600; padding: 4px 8px; border-radius: 4px; display: inline-block; margin-top: 8px; transition: font-size 0.2s ease; }' +
            id + ' .dbp-kpi-comparison.positive { color: #27ae60; background: rgba(39,174,96,0.1); }' +
            id + ' .dbp-kpi-comparison.negative { color: #e74c3c; background: rgba(231,76,60,0.1); }' +
            id + ' .dbp-kpi-comparison.neutral { color: #7f8c8d; background: rgba(127,140,141,0.1); }' +
            id + ' .dbp-kpi-label { color: var(--dbp-text-secondary); margin-top: 4px; transition: font-size 0.2s ease; }' +

            // --- Card header / title ---
            id + ' .dbp-card-title { margin: 0; font-weight: 600; transition: font-size 0.2s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 1; }' +
            id + ' .dbp-card-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-shrink: 0; }' +
            id + ' .dbp-card-actions { display: flex; gap: 4px; opacity: 0; max-width: 0; overflow: hidden; pointer-events: none; transition: opacity 0.2s ease, max-width 0.2s ease; }' +
            id + ' .dbp-card:hover .dbp-card-actions { opacity: 1; max-width: 200px; pointer-events: auto; }' +

            // --- Botões de ação do card ---
            id + ' .dbp-btn-screenshot { background: transparent; border: none; cursor: pointer; padding: 4px 6px; border-radius: 4px; font-size: 1rem; opacity: 0.6; transition: all 0.2s ease; }' +
            id + ' .dbp-btn-screenshot:hover { opacity: 1; background: var(--dbp-border, rgba(0,0,0,0.05)); transform: scale(1.1); }' +
            id + ' .dbp-card-settings { background: transparent; border: none; cursor: pointer; padding: 4px 6px; border-radius: 4px; font-size: 1rem; opacity: 0.6; transition: all 0.2s ease; }' +
            id + ' .dbp-card-settings:hover { opacity: 1; background: var(--dbp-border, rgba(0,0,0,0.05)); }' +

            // --- Progress bar ---
            id + ' .dbp-progress-container { display: flex; flex-direction: column; justify-content: center; height: 100%; padding: 10px 0; }' +
            id + ' .dbp-progress-header { display: flex; justify-content: space-between; font-weight: 700; transition: font-size 0.2s ease; }' +
            id + ' .dbp-progress-labels { display: flex; justify-content: space-between; transition: font-size 0.2s ease; }' +
            id + ' .dbp-progress-bar { height: 16px; transition: height 0.2s ease; border-radius: 6px; background: #e0e0e0; overflow: hidden; margin: 10px 0; }' +
            id + ' .dbp-progress-fill { height: 100%; border-radius: 6px; transition: width 0.3s ease; min-width: 2px; }' +

            // --- Tabela ---
            id + ' .dbp-table { width: 100%; border-collapse: collapse; transition: font-size 0.2s ease; }' +
            id + ' .dbp-table th, ' + id + ' .dbp-table td { text-align: left; border-bottom: 1px solid var(--dbp-border, #e0e0e0); transition: padding 0.2s ease; }' +

            // --- Card body e gráfico ---
            id + ' .dbp-card-body { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 12px; }' +
            id + ' .dbp-card-body, ' + id + ' .dbp-card-content { flex: 1; overflow: hidden; min-height: 0; }' +
            id + ' .dbp-chart { flex: 1; min-height: 150px; width: 100%; height: 100%; }' +
            id + ' .dbp-chart .apexcharts-canvas { width: 100% !important; height: 100% !important; }' +
            id + ' .dbp-chart .apexcharts-canvas svg { width: 100% !important; height: 100% !important; }' +
            id + ' .dbp-chart .apexcharts-radialbar { transform-origin: center center; }' +

            // --- Empty state do dashboard (v3.5.0) ---
            id + ' .dbp-empty-dashboard { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 48px 24px; min-height: 240px; color: var(--dbp-text-secondary, #7f8c8d); }' +
            id + ' .dbp-empty-dashboard-icon { font-size: 48px; opacity: 0.5; margin-bottom: 16px; }' +
            id + ' .dbp-empty-dashboard-title { font-size: 18px; font-weight: 600; color: var(--dbp-text, #2c3e50); margin-bottom: 8px; }' +
            id + ' .dbp-empty-dashboard-text { font-size: 14px; max-width: 360px; line-height: 1.5; }' +

            // --- Tooltip do ApexCharts (v3.1.13) ---
            // Sobrescreve estilo default que estava com baixa legibilidade.
            // Light theme: fundo branco + texto escuro. Dark: fundo escuro + texto claro.
            id + ' .apexcharts-tooltip { background: #ffffff !important; color: #2c3e50 !important; border: 1px solid #e0e6ed !important; box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; border-radius: 6px !important; overflow: hidden; font-size: 12px; }' +
            id + ' .apexcharts-tooltip-title { background: #f5f7fa !important; color: #2c3e50 !important; border-bottom: 1px solid #e0e6ed !important; font-weight: 600 !important; padding: 6px 10px !important; margin: 0 !important; }' +
            id + ' .apexcharts-tooltip-series-group { padding: 6px 10px !important; background: transparent !important; }' +
            id + ' .apexcharts-tooltip-text-y-value, ' + id + ' .apexcharts-tooltip-text-y-label, ' + id + ' .apexcharts-tooltip-text-goals-value, ' + id + ' .apexcharts-tooltip-text-goals-label { color: #2c3e50 !important; font-weight: 500 !important; }' +
            id + ' .apexcharts-tooltip-marker { width: 10px !important; height: 10px !important; margin-right: 8px !important; }' +
            id + ' .apexcharts-xaxistooltip, ' + id + ' .apexcharts-yaxistooltip { background: #ffffff !important; color: #2c3e50 !important; border: 1px solid #e0e6ed !important; }' +
            id + '.dbp-theme-dark .apexcharts-tooltip { background: #16213e !important; color: #eaeaea !important; border-color: #3d4758 !important; }' +
            id + '.dbp-theme-dark .apexcharts-tooltip-title { background: #1a1a2e !important; color: #eaeaea !important; border-bottom-color: #3d4758 !important; }' +
            id + '.dbp-theme-dark .apexcharts-tooltip-text-y-value, ' + id + '.dbp-theme-dark .apexcharts-tooltip-text-y-label, ' + id + '.dbp-theme-dark .apexcharts-tooltip-text-goals-value, ' + id + '.dbp-theme-dark .apexcharts-tooltip-text-goals-label { color: #eaeaea !important; }' +
            id + '.dbp-theme-dark .apexcharts-xaxistooltip, ' + id + '.dbp-theme-dark .apexcharts-yaxistooltip { background: #16213e !important; color: #eaeaea !important; border-color: #3d4758 !important; }' +

            // v3.1.15: forçar fill branco nas labels SVG dos charts em tema escuro.
            // ApexCharts tem CSS interno que pode sobrescrever a opção `colors` que
            // passamos — esse seletor garante o contraste máximo.
            id + '.dbp-theme-dark .apexcharts-text, ' +
            id + '.dbp-theme-dark .apexcharts-xaxis-label, ' +
            id + '.dbp-theme-dark .apexcharts-yaxis-label, ' +
            id + '.dbp-theme-dark .apexcharts-legend-text, ' +
            id + '.dbp-theme-dark .apexcharts-datalabel-label, ' +
            id + '.dbp-theme-dark .apexcharts-datalabel-value, ' +
            id + '.dbp-theme-dark .apexcharts-pie-label, ' +
            id + '.dbp-theme-dark .apexcharts-radialbar-label { fill: #ffffff !important; color: #ffffff !important; }' +

            // --- Badge de data source ---
            id + ' .dbp-data-source-badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; background: rgba(52,152,219,0.1); color: #3498db; margin-left: 8px; }' +

            // --- Toast ---
            id + ' .dbp-toast { position: fixed; bottom: 20px; right: 20px; background: #333; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; z-index: 10000; opacity: 0; transform: translateY(20px); transition: all 0.3s ease; }' +
            id + ' .dbp-toast.show { opacity: 1; transform: translateY(0); }' +
            id + ' .dbp-toast.success { background: #27ae60; }' +
            id + ' .dbp-toast.error { background: #e74c3c; }' +

            // --- GridStack overrides ---
            // GAP STRATEGY: o gap entre cards é controlado via MARGIN no .dbp-card
            // (usando --dbp-half-gap), NÃO via o mecanismo de margin do GridStack.
            // Por quê?
            //   - GridStack v10 cria gap via top/right/bottom/left no .grid-stack-item-content
            //   - Mas se quisermos manter height: 100% no content (pra cadeia de
            //     sizing dos charts funcionar), o bottom é ignorado → gap vertical some
            //   - Controlar o gap no card (que é filho do content) preserva a cadeia
            //     de altura E garante gap consistente em todas as direções
            // GridStack deve ser inicializado com margin: 0 (ver code_update).
            id + ' .grid-stack-item-content { background: transparent !important; border: none !important; box-shadow: none !important; outline: none !important; border-radius: ' + r + 'px !important; height: 100% !important; }' +
            id + ' .grid-stack-item { background: transparent !important; border: none !important; box-shadow: none !important; }' +
            id + ' .grid-stack-item-content > .dbp-card { ' +
                'height: calc(100% - var(--dbp-gap)) !important; ' +
                'width: calc(100% - var(--dbp-gap)) !important; ' +
                'margin: var(--dbp-half-gap) !important; ' +
                'border-radius: ' + r + 'px !important; ' +
                'border: none !important; ' +
                'overflow: hidden; ' +
                'display: flex; ' +
                'flex-direction: column; ' +
            '}' +
            id + ' .grid-stack-item > .grid-stack-item-content { background: transparent !important; }' +
            id + ' .grid-stack-item::before, ' + id + ' .grid-stack-item::after { display: none !important; }' +
            id + ' .grid-stack-item-content::before, ' + id + ' .grid-stack-item-content::after { display: none !important; }' +
            id + ' .grid-stack .ui-resizable-handle { z-index: 100; }' +
            id + ' .grid-stack .ui-resizable-se { bottom: 0 !important; right: 0 !important; }' +

            // --- Anti-flickering ---
            id + '.dbp-loading { opacity: 0; transition: opacity 0.3s ease; }' +
            id + '.dbp-ready { opacity: 1; }' +

            // --- Modal genérico ---
            id + ' .dbp-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; }' +
            id + ' .dbp-modal-overlay.active { opacity: 1; pointer-events: auto; }' +
            // v3.4.3: modal com altura máxima + flex column pro body rolar internamente
            // (modal cresceu com os campos de customização e estourava a tela).
            id + ' .dbp-modal { background: var(--dbp-card-bg, #fff); color: var(--dbp-text, #2c3e50); border-radius: 12px; padding: 24px; min-width: 320px; max-width: 400px; max-height: 88vh; display: flex; flex-direction: column; box-shadow: 0 10px 40px rgba(0,0,0,0.2); transform: scale(0.9); transition: transform 0.2s ease; }' +
            id + ' .dbp-modal-overlay.active .dbp-modal { transform: scale(1); }' +
            id + ' .dbp-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }' +
            id + ' .dbp-modal-title { font-size: 18px; font-weight: 600; margin: 0; color: var(--dbp-text, #333); }' +
            id + ' .dbp-modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--dbp-text-secondary, #666); padding: 0; line-height: 1; }' +
            id + ' .dbp-modal-close:hover { color: var(--dbp-text, #333); }' +
            // v3.4.3: body rola internamente (flex:1 + overflow-y), header e footer fixos.
            id + ' .dbp-modal-body { display: flex; flex-direction: column; gap: 16px; flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-right: 4px; margin-right: -4px; }' +
            id + ' .dbp-modal-header { flex-shrink: 0; }' +
            id + ' .dbp-modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--dbp-border, #ddd); flex-shrink: 0; }' +
            // Scrollbar discreta no body do modal
            id + ' .dbp-modal-body::-webkit-scrollbar { width: 8px; }' +
            id + ' .dbp-modal-body::-webkit-scrollbar-thumb { background: var(--dbp-border, #ccc); border-radius: 4px; }' +
            id + ' .dbp-modal-body::-webkit-scrollbar-track { background: transparent; }' +

            // --- Formulários ---
            id + ' .dbp-form-group { display: flex; flex-direction: column; gap: 6px; }' +
            id + ' .dbp-form-label { font-size: 13px; font-weight: 500; color: var(--dbp-text-secondary, #666); }' +
            id + ' .dbp-form-input { padding: 10px 12px; border: 1px solid var(--dbp-border, #ddd); border-radius: 8px; font-size: 14px; background: var(--dbp-bg, #f5f5f5); color: var(--dbp-text, #333); }' +
            id + ' .dbp-form-input:focus { outline: none; border-color: var(--dbp-primary); }' +
            id + ' .dbp-form-select { padding: 10px 12px; border: 1px solid var(--dbp-border, #ddd); border-radius: 8px; font-size: 14px; background: var(--dbp-bg, #f5f5f5); color: var(--dbp-text, #333); cursor: pointer; }' +
            id + ' .dbp-form-toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; }' +
            id + ' .dbp-form-toggle input { display: none; }' +
            id + ' .dbp-form-toggle .toggle-track { width: 44px; height: 24px; background: var(--dbp-border, #ddd); border-radius: 12px; position: relative; transition: background 0.2s; }' +
            id + ' .dbp-form-toggle input:checked + .toggle-track { background: var(--dbp-primary); }' +
            id + ' .dbp-form-toggle .toggle-thumb { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: white; border-radius: 50%; transition: transform 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }' +
            id + ' .dbp-form-toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(20px); }' +

            // --- Botões genéricos ---
            id + ' .dbp-btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }' +
            id + ' .dbp-btn-primary { background: var(--dbp-primary); color: white; }' +
            id + ' .dbp-btn-primary:hover { filter: brightness(1.1); }' +
            id + ' .dbp-btn-secondary { background: var(--dbp-border, #ddd); color: var(--dbp-text, #333); }' +
            id + ' .dbp-btn-secondary:hover { background: #ccc; }' +

            // --- Floating action bar ---
            id + ' .dbp-action-bar { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(100px); background: var(--dbp-card-bg, #fff); border-radius: 12px; padding: 12px 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); display: flex; gap: 12px; align-items: center; z-index: 9999; opacity: 0; pointer-events: none; transition: all 0.3s ease; }' +
            id + ' .dbp-action-bar.visible { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }' +
            id + ' .dbp-action-bar-text { font-size: 14px; color: var(--dbp-text-secondary, #666); margin-right: 8px; }' +
            id + ' .dbp-action-bar .dbp-btn { padding: 8px 16px; }' +

            // --- Add/Remove cards ---
            id + ' .dbp-btn-remove-card { background: none; border: none; font-size: 16px; cursor: pointer; color: var(--dbp-text-secondary, #999); padding: 2px 6px; opacity: 0.6; transition: all 0.2s; }' +
            id + ' .dbp-btn-remove-card:hover { opacity: 1; color: var(--dbp-danger, #e74c3c); }' +
            id + ' .dbp-btn-hide-card { background: none; border: none; font-size: 14px; cursor: pointer; color: var(--dbp-text-secondary, #999); padding: 2px 6px; opacity: 0.6; transition: all 0.2s; }' +
            id + ' .dbp-btn-hide-card:hover { opacity: 1; color: var(--dbp-warning, #f39c12); }' +
            id + ' .dbp-btn-delete-card { background: none; border: none; font-size: 14px; cursor: pointer; color: var(--dbp-text-secondary, #999); padding: 2px 6px; opacity: 0.6; transition: all 0.2s; }' +
            id + ' .dbp-btn-delete-card:hover { opacity: 1; color: var(--dbp-danger, #e74c3c); }' +
            id + ' .dbp-btn-add-card { background: var(--dbp-primary); color: white; border: none; border-radius: 6px; padding: 8px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.2s; }' +
            id + ' .dbp-btn-add-card:hover { filter: brightness(1.1); }' +
            id + ' .dbp-btn-add-chart { background: var(--dbp-success, #27ae60); color: white; border: none; border-radius: 6px; padding: 8px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.2s; font-weight: 500; }' +
            id + ' .dbp-btn-add-chart:hover { filter: brightness(1.1); }' +

            // --- Cards list (modal add card) ---
            id + ' .dbp-cards-list { max-height: 300px; overflow-y: auto; }' +
            id + ' .dbp-card-option { display: flex; align-items: center; padding: 12px 16px; cursor: pointer; border-bottom: 1px solid var(--dbp-border, #eee); transition: background 0.15s; }' +
            id + ' .dbp-card-option:hover { background: var(--dbp-bg, #f5f5f5); }' +
            id + ' .dbp-card-option:last-child { border-bottom: none; }' +
            id + ' .dbp-card-option-icon { font-size: 20px; margin-right: 12px; }' +
            id + ' .dbp-card-option-info { flex: 1; }' +
            id + ' .dbp-card-option-title { font-weight: 500; color: var(--dbp-text, #333); }' +
            id + ' .dbp-card-option-type { font-size: 12px; color: var(--dbp-text-secondary, #666); }' +
            id + ' .dbp-empty-hidden { padding: 24px; text-align: center; color: var(--dbp-text-secondary, #666); }' +

            // --- Dashboard settings button ---
            id + ' .dbp-btn-dashboard-settings { background: var(--dbp-card-bg, #f8f9fa); color: var(--dbp-text, #333); border: 1px solid var(--dbp-border, #ddd); border-radius: 6px; padding: 8px 12px; font-size: 16px; cursor: pointer; transition: all 0.2s; }' +
            id + ' .dbp-btn-dashboard-settings:hover { background: var(--dbp-primary); color: white; border-color: var(--dbp-primary); }' +

            // --- Create Chart modal ---
            id + ' .dbp-chart-types-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; margin-bottom: 20px; }' +
            id + ' .dbp-chart-type-option { display: flex; flex-direction: column; align-items: center; padding: 15px 10px; border: 2px solid var(--dbp-border, #ddd); border-radius: 8px; cursor: pointer; transition: all 0.2s; background: var(--dbp-card-bg, #fff); }' +
            id + ' .dbp-chart-type-option:hover { border-color: var(--dbp-primary); background: rgba(52,152,219,0.05); }' +
            id + ' .dbp-chart-type-option.selected { border-color: var(--dbp-primary); background: rgba(52,152,219,0.1); }' +
            id + ' .dbp-chart-type-icon { font-size: 28px; margin-bottom: 8px; }' +
            id + ' .dbp-chart-type-label { font-size: 12px; color: var(--dbp-text, #333); text-align: center; }' +
            id + ' .dbp-create-chart-fields { display: flex; flex-direction: column; gap: 12px; }' +
            id + ' .dbp-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }' +
            id + ' .dbp-field-row.full-width { grid-template-columns: 1fr; }' +

            '</style>';
    };

    // =====================================================
    // MÓDULO: DATA CONVERTER
    // Conversão de Bubble List para JSON
    // =====================================================

    var DataConverter = {};

    // Converter lista Bubble para array de objetos JSON
    DataConverter.convertListToData = async function (lista, camposStr) {
        try {
            if (!lista) return { data: [], count: 0, error: 'Lista vazia' };
            if (!camposStr) return { data: [], count: 0, error: 'Campos vazios' };

            var length = lista.length();
            if (length && typeof length.then === 'function') length = await length;
            if (length === 0) return { data: [], count: 0, error: '' };

            var bubbleThings = lista.get(0, length);
            if (bubbleThings && typeof bubbleThings.then === 'function') bubbleThings = await bubbleThings;
            if (!bubbleThings || bubbleThings.length === 0) return { data: [], count: 0, error: '' };

            var camposSolicitados = camposStr.split(',').map(function (c) { return c.trim(); }).filter(function (c) { return c; });
            var firstItem = bubbleThings[0];
            var availableProps = (typeof firstItem.listProperties === 'function') ? firstItem.listProperties() : [];

            // Matching com múltiplas estratégias
            var fieldMap = {};
            camposSolicitados.forEach(function (solicitado) {
                var normSolicitado = Utils.ultraNormalize(solicitado);
                var bestMatch = null;
                var bestScore = 0;

                for (var i = 0; i < availableProps.length; i++) {
                    var propReal = availableProps[i];
                    var raiz = Utils.extractSlugRoot(propReal);
                    var normRaiz = Utils.ultraNormalize(raiz);
                    var score = 0;

                    if (normRaiz === normSolicitado) {
                        score = 100;
                    } else if (normRaiz.startsWith(normSolicitado)) {
                        score = 80;
                    } else if (normSolicitado.startsWith(normRaiz) && normRaiz.length >= 2) {
                        score = 70;
                    } else {
                        var len = Math.min(normRaiz.length, normSolicitado.length);
                        if (len >= 2) {
                            var slugSemUnderscore = Utils.ultraNormalize(propReal);
                            if (slugSemUnderscore.substring(0, 3) === normSolicitado.substring(0, 3)) {
                                score = 60;
                            } else if (slugSemUnderscore.substring(0, 2) === normSolicitado.substring(0, 2)) {
                                score = 40;
                            }
                        }
                    }
                    if (score === 0) {
                        if (normRaiz.indexOf(normSolicitado) !== -1 || normSolicitado.indexOf(normRaiz) !== -1) {
                            score = 30;
                        }
                    }

                    if (score > bestScore) {
                        bestScore = score;
                        bestMatch = propReal;
                    }
                }
                fieldMap[solicitado] = bestMatch || solicitado;
            });

            // Processar itens
            var result = [];
            for (var i = 0; i < bubbleThings.length; i++) {
                var item = bubbleThings[i];
                var obj = {};

                for (var k = 0; k < camposSolicitados.length; k++) {
                    var campoUser = camposSolicitados[k];
                    var campoReal = fieldMap[campoUser];
                    var val = null;

                    try {
                        val = item.get(campoReal);
                        if (val && typeof val.then === 'function') val = await val;

                        if (val && val.get && typeof val.get === 'function') {
                            var disp = val.get('display');
                            if (disp && typeof disp.then === 'function') disp = await disp;
                            if (disp !== null && disp !== undefined) {
                                val = disp;
                            } else {
                                var id = val.get('_id');
                                if (id && typeof id.then === 'function') id = await id;
                                if (id) val = id;
                            }
                        }
                    } catch (e) { val = null; }

                    if (val instanceof Date) val = val.toISOString();
                    obj[campoUser] = (val === undefined) ? null : val;
                }
                result.push(obj);
            }

            return { data: result, count: result.length, error: '' };

        } catch (err) {
            return { data: [], count: 0, error: err.message };
        }
    };

    // Carregar data source com callback (wrapper para o convertListToData)
    DataConverter.loadDataSource = function (instance, dataSource, dataFields, callback) {
        instance.publishState('data_source_status', 'loading');
        instance.publishState('is_loading', true);

        DataConverter.convertListToData(dataSource, dataFields)
            .then(function (result) {
                if (result.error) {
                    instance.publishState('data_source_status', 'error');
                    instance.publishState('error_message', result.error);
                    callback([], result.error);
                } else {
                    instance.data.convertedData = result.data;
                    instance.data.dataSourceLoaded = true;
                    instance.publishState('data_source_status', 'ready');
                    instance.publishState('filtered_records_count', result.count);
                    callback(result.data, null);
                }
            })
            .catch(function (err) {
                instance.publishState('data_source_status', 'error');
                instance.publishState('error_message', err.message);
                callback([], err.message);
            });
    };

    // =====================================================
    // MÓDULO: COLOR
    // Gerenciamento de paleta de cores
    // =====================================================

    var Color = {};

    // Paleta padrão
    Color.defaultPalette = function (primary, secondary, success) {
        return [
            primary, secondary, success,
            '#1E40AF', '#2563EB', '#38BDF8', '#0EA5E9', '#14B8A6',
            '#22C55E', '#84CC16', '#F59E0B', '#F97316', '#E11D48',
            '#6B7280', '#9CA3AF', '#8B5CF6', '#A855F7', '#EC4899',
            '#06B6D4', '#10B981', '#FBBF24', '#EF4444', '#6366F1'
        ];
    };

    // Construir paleta (custom ou default, com fallback)
    Color.buildPalette = function (customColorsInput, primary, secondary, success) {
        var defaults = Color.defaultPalette(primary, secondary, success);
        if (!customColorsInput || customColorsInput.trim() === '') return defaults;

        var palette = customColorsInput.split(',').map(function (c) { return c.trim(); }).filter(function (c) { return c.length > 0; });
        if (palette.length < 20) {
            var additional = defaults.filter(function (c) { return palette.indexOf(c) === -1; });
            palette = palette.concat(additional.slice(0, 20 - palette.length));
        }
        return palette;
    };

    // Converter paleta para versão com opacidade (rgba)
    Color.withOpacity = function (palette, opacity) {
        if (isNaN(opacity) || opacity < 0) opacity = 0;
        if (opacity > 1) opacity = 1;

        return palette.map(function (color) {
            if (!color || typeof color !== 'string') return 'rgba(52,152,219,' + opacity + ')';
            if (color.indexOf('rgba') === 0 || color.indexOf('rgb') === 0) return color;

            var hex = color.charAt(0) === '#' ? color.substring(1) : color;
            if (hex.length !== 6 && hex.length !== 3) return 'rgba(52,152,219,' + opacity + ')';
            if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];

            var r = parseInt(hex.substring(0, 2), 16);
            var g = parseInt(hex.substring(2, 4), 16);
            var b = parseInt(hex.substring(4, 6), 16);
            if (isNaN(r) || isNaN(g) || isNaN(b)) return 'rgba(52,152,219,' + opacity + ')';

            return 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')';
        });
    };

    // =====================================================
    // MÓDULO: TEMPLATES
    // Geração de HTML para toolbar, modais, cards, etc.
    // Todas as funções recebem ctx (contexto) e retornam string HTML
    // =====================================================
    //
    // ctx = {
    //   uniqueId, uiLabels, fieldLabels, theme,
    //   toolbarMode, showPeriodFilter, showComparisonToggle,
    //   filterFieldsArray, filterLabels,
    //   allowAddCard, allowAddChart, allowCardSettings,
    //   enableComparison, useDataSource,
    //   currentStart, currentEnd, gap, rowHeight, cardRadius,
    //   columns, dataFields, activeFilters, hiddenCards,
    //   parsedData (para getUniqueValues)
    // }

    var Templates = {};

    // Atalhos internos para label e escape
    function _lbl(ctx, keys, fallback) {
        return Utils.resolveLabel(ctx.uiLabels, keys, fallback);
    }
    function _esc(str) {
        return Utils.escapeHtml(str);
    }

    // v3.6.0: ícones SVG (feather-style, stroke currentColor) pros botões da toolbar.
    var _svgWrap = function (inner) {
        return '<svg class="dbp-tb-ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
    };
    var ICONS = {
        settings: _svgWrap('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>'),
        chart: _svgWrap('<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>'),
        filter: _svgWrap('<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>'),
        reset: _svgWrap('<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>'),
        plus: _svgWrap('<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>')
    };

    // --- TOOLBAR ---
    Templates.toolbar = function (ctx) {
        var uid = ctx.uniqueId;
        var toolbarMode = ctx.toolbarMode;
        if (toolbarMode === 'hidden') return '';

        var showPeriodInToolbar = (toolbarMode === 'period_only' || toolbarMode === 'compact') ? true :
            (toolbarMode === 'full' && ctx.showPeriodFilter);
        var showFieldFilters = (toolbarMode === 'filters_only') ? true :
            (toolbarMode === 'full' && ctx.filterFieldsArray.length > 0);
        var showCompToggle = toolbarMode === 'full' && ctx.showComparisonToggle;
        var showButtons = toolbarMode === 'full' || toolbarMode === 'filters_only';

        var compActive = ctx.activeFilters.hasOwnProperty('comparison') ? ctx.activeFilters.comparison : ctx.enableComparison;

        // v3.1.21: toolbar v2.
        // Linha 1 (sempre visível): Settings, Add Chart, Add Card, Filter (toggle)
        // Linha 2 (collapsible): Período, Compare toggle, filtros de campo, Apply, Clear
        var hasFiltersRow = showPeriodInToolbar ||
            (showFieldFilters && ctx.filterFieldsArray.length > 0) ||
            showCompToggle || showButtons;

        var html = '<div class="dbp-filters">';

        // ====== LINHA 1: ACTIONS ======
        html += '<div class="dbp-toolbar-row dbp-toolbar-actions">';

        // v3.6.0: botões uniformes (.dbp-toolbar-btn) com ícone SVG + texto + sombra 3D.
        // Classes específicas mantidas pros handlers (delegation/getElementById).

        // Botão Dashboard Settings + Add Chart
        if (ctx.allowAddChart) {
            html += '<button class="dbp-toolbar-btn dbp-btn-dashboard-settings" id="' + uid + '-btn-dashboard-settings" title="' + _esc(_lbl(ctx, ["dashboard_settings"], "Configurações do Dashboard")) + '">' + ICONS.settings + '<span>' + _esc(_lbl(ctx, ["settings"], "Configurações")) + '</span></button>';
            html += '<button class="dbp-toolbar-btn dbp-btn-add-chart" id="' + uid + '-btn-add-chart" title="' + _esc(_lbl(ctx, ["create_chart", "new_chart"], "Criar novo gráfico")) + '">' + ICONS.chart + '<span>' + _esc(_lbl(ctx, ["add_chart", "new_chart"], "Add Chart")) + '</span></button>';
            // Botão Reset — só visível quando há customização
            var resetHiddenCls = ctx.isCustomized ? '' : ' dbp-btn-reset-hidden';
            html += '<button class="dbp-toolbar-btn dbp-btn-reset' + resetHiddenCls + '" id="' + uid + '-btn-reset" title="' + _esc(_lbl(ctx, ["reset_layout", "reset"], "Restaurar layout padrão (descartar customizações)")) + '">' + ICONS.reset + '<span>' + _esc(_lbl(ctx, ["reset"], "Restaurar")) + '</span></button>';
        }

        // Botão Add Card (restaurar ocultos)
        if (ctx.allowAddCard) {
            var addBtnStyle = ctx.hiddenCards.length > 0 ? '' : ' style="display:none"';
            html += '<button class="dbp-toolbar-btn dbp-btn-add-card" id="' + uid + '-btn-add-card" title="Adicionar card"' + addBtnStyle + '>' + ICONS.plus + '<span>' + _esc(_lbl(ctx, ["add_card", "add"], "Adicionar")) + '</span></button>';
        }

        // Botão Filter — toggla a linha 2
        if (hasFiltersRow) {
            var filterBtnActive = ctx.filtersExpanded ? ' active' : '';
            html += '<button class="dbp-toolbar-btn dbp-btn-filter-toggle' + filterBtnActive + '" id="' + uid + '-btn-filter-toggle" title="' + _esc(_lbl(ctx, ["filter", "filters"], "Mostrar/Ocultar filtros")) + '">' + ICONS.filter + '<span>' + _esc(_lbl(ctx, ["filter", "filters"], "Filtrar")) + '</span></button>';
        }

        // Records count — opt-in via property show_records_count
        if (ctx.showRecordsCount === true) {
            html += '<span class="dbp-records-count" id="' + uid + '-records-count"></span>';
        }

        html += '</div>'; // fim actions row

        // ====== LINHA 2: FILTROS (collapsed por default, persiste estado entre renders) ======
        if (hasFiltersRow) {
            var collapsedCls = ctx.filtersExpanded ? '' : ' dbp-toolbar-filters-collapsed';
            html += '<div class="dbp-toolbar-row dbp-toolbar-filters-row' + collapsedCls + '" id="' + uid + '-filters-row">';

            // Período
            if (showPeriodInToolbar) {
                var startVal = ctx.activeFilters.startDate || Utils.formatDateInput(ctx.currentStart) || '';
                var endVal = ctx.activeFilters.endDate || Utils.formatDateInput(ctx.currentEnd) || '';
                html += '<div class="dbp-filter-group"><span class="dbp-filter-label">' + _esc(_lbl(ctx, ["period", "period_label"], "Período:")) + '</span>';
                html += '<input type="date" class="dbp-filter-input" id="' + uid + '-filter-start" value="' + startVal + '">';
                html += '<span class="dbp-filter-label">' + _esc(_lbl(ctx, ["until", "to"], "até")) + '</span>';
                html += '<input type="date" class="dbp-filter-input" id="' + uid + '-filter-end" value="' + endVal + '"></div>';
            }

            // Toggle comparação (junto com período)
            if (showCompToggle) {
                html += '<div class="dbp-filter-group"><div class="dbp-toggle" id="' + uid + '-toggle-comparison">';
                html += '<div class="dbp-toggle-switch' + (compActive ? ' active' : '') + '"></div>';
                html += '<span class="dbp-filter-label">' + _esc(_lbl(ctx, ["compare_periods", "compare", "comparison"], "Comparar períodos")) + '</span></div></div>';
            }

            // Filtros de campo
            if (showFieldFilters && ctx.filterFieldsArray.length > 0) {
                ctx.filterFieldsArray.forEach(function (field) {
                    var label = (ctx.filterLabels && ctx.filterLabels[field]) || field.charAt(0).toUpperCase() + field.slice(1);
                    var options = Utils.getUniqueValues(ctx.parsedData, field);
                    var currentValue = ctx.activeFilters[field] || 'all';
                    html += '<div class="dbp-filter-group"><span class="dbp-filter-label">' + label + ':</span>';
                    html += '<select class="dbp-filter-input" id="' + uid + '-filter-' + field + '">';
                    html += '<option value="all">Todos</option>';
                    options.forEach(function (opt) {
                        html += '<option value="' + opt + '"' + (currentValue === opt ? ' selected' : '') + '>' + opt + '</option>';
                    });
                    html += '</select></div>';
                });
            }

            // Botões Aplicar/Limpar — fim da linha (referem aos filtros)
            if (showButtons) {
                html += '<div class="dbp-filter-group">';
                html += '<button class="dbp-filter-btn" id="' + uid + '-btn-apply">' + _esc(_lbl(ctx, ["apply"], "Aplicar")) + '</button>';
                html += '<button class="dbp-filter-btn dbp-filter-btn-secondary" id="' + uid + '-btn-clear">' + _esc(_lbl(ctx, ["clear", "reset"], "Limpar")) + '</button></div>';
            }

            html += '</div>'; // fim filters row
        }

        html += '</div>';
        return html;
    };

    // --- CARD HTML (um card individual no grid) ---
    // cardContent (opcional): HTML pré-renderizado para o body do card.
    // Quando fornecido, é inserido diretamente — evita render em 2 passos e flash visual.
    Templates.card = function (ctx, card, index, cardSettings, cardContent) {
        var uid = ctx.uniqueId;
        var cardId = card.id || 'card_' + index;

        // Pular cards ocultos
        if (ctx.hiddenCards.indexOf(cardId) !== -1) return '';

        var x = card.x !== undefined ? card.x : (index % ctx.columns);
        var y = card.y !== undefined ? card.y : Math.floor(index / ctx.columns);
        var w = card.w || card.width || 3;
        var h = card.h || card.height || 2;

        var savedSettings = cardSettings[cardId] || {};
        var displayTitle = savedSettings.title || card.title || 'Card';
        var bodyHtml = (cardContent !== undefined && cardContent !== null) ? cardContent : '';

        // v3.4.1/2: data-attrs de font size E cor customizada (lidos por adjustCardFonts).
        // 0/vazio = automático.
        var cardCfg = card.config || {};
        var titleFs = parseInt(cardCfg.titleFontSize) || 0;
        var contentFs = parseInt(cardCfg.labelFontSize) || 0;
        var fsAttrs = ' data-title-fs="' + titleFs + '" data-content-fs="' + contentFs + '"';
        if (cardCfg.titleColor) fsAttrs += ' data-title-color="' + Utils.escapeHtml(cardCfg.titleColor) + '"';
        if (cardCfg.labelColor) fsAttrs += ' data-content-color="' + Utils.escapeHtml(cardCfg.labelColor) + '"';

        var html = '<div class="grid-stack-item" gs-id="' + cardId + '" gs-x="' + x + '" gs-y="' + y + '" gs-w="' + w + '" gs-h="' + h + '">';
        html += '<div class="grid-stack-item-content"><div class="dbp-card" data-card-id="' + cardId + '"' + fsAttrs + '>';
        html += '<div class="dbp-card-header"><h3 class="dbp-card-title">' + Utils.escapeHtml(displayTitle) + '</h3>';
        html += '<div class="dbp-card-actions">';
        html += '<button class="dbp-btn-screenshot" data-card-id="' + cardId + '" title="Copiar imagem">📷</button>';
        if (ctx.allowCardSettings) html += '<button class="dbp-card-settings" data-card-id="' + cardId + '" title="Configurações">⚙️</button>';
        if (ctx.allowAddCard && card.locked !== true) {
            html += '<button class="dbp-btn-hide-card" data-card-id="' + cardId + '" title="Ocultar card">👁️</button>';
            html += '<button class="dbp-btn-delete-card" data-card-id="' + cardId + '" title="Excluir card">🗑️</button>';
        }
        html += '</div></div>';
        html += '<div class="dbp-card-body">' + bodyHtml + '</div>';
        html += '</div></div></div>';
        return html;
    };

    // --- MODAL: Card Settings ---
    // v3.3.0: Modal expandido — permite editar config de dados do card
    // (Value Field, Aggregation, Group By, Format, Orientation, Target, Data Labels)
    Templates.cardSettingsModal = function (ctx) {
        var uid = ctx.uniqueId;
        var availableFields = ctx.dataFields ? ctx.dataFields.split(',').map(function (f) { return f.trim(); }).filter(function (f) { return f; }) : [];

        var html = '<div class="dbp-modal-overlay" id="' + uid + '-settings-modal">';
        html += '<div class="dbp-modal" style="max-width: 520px; width: 90%;">';
        html += '<div class="dbp-modal-header">';
        html += '<h4 class="dbp-modal-title">' + _esc(_lbl(ctx, ["card_settings"], "Configurações do Card")) + '</h4>';
        html += '<button class="dbp-modal-close" id="' + uid + '-modal-close">&times;</button></div>';
        html += '<div class="dbp-modal-body">';

        // Título
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["title"], "Título")) + '</label>';
        html += '<input type="text" class="dbp-form-input" id="' + uid + '-setting-title" placeholder="' + _esc(_lbl(ctx, ["title_placeholder"], "Título personalizado...")) + '"></div>';

        // Tipo de Gráfico
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["chart_type"], "Tipo de Gráfico")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-charttype"></select></div>';

        // Value Field + Aggregation (linha dupla)
        html += '<div class="dbp-field-row">';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["value_field"], "Campo de Valor")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-field">';
        html += '<option value="">' + _esc(_lbl(ctx, ["select_field"], "Selecione...")) + '</option>';
        availableFields.forEach(function (f) {
            html += '<option value="' + f + '">' + _esc(Utils.getFieldLabel(ctx.fieldLabels, f)) + '</option>';
        });
        html += '</select></div>';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["aggregation"], "Agregação")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-aggregation">';
        html += '<option value="sum">' + _esc(_lbl(ctx, ["sum"], "Soma")) + '</option>';
        html += '<option value="count">' + _esc(_lbl(ctx, ["count"], "Contagem")) + '</option>';
        html += '<option value="avg">' + _esc(_lbl(ctx, ["average"], "Média")) + '</option>';
        html += '<option value="min">' + _esc(_lbl(ctx, ["minimum"], "Mínimo")) + '</option>';
        html += '<option value="max">' + _esc(_lbl(ctx, ["maximum"], "Máximo")) + '</option>';
        html += '</select></div></div>';

        // Group By (condicional)
        html += '<div class="dbp-form-group" id="' + uid + '-setting-groupby-container">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["group_by"], "Agrupar Por")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-groupby">';
        html += '<option value="">' + _esc(_lbl(ctx, ["none"], "Nenhum")) + '</option>';
        availableFields.forEach(function (f) {
            html += '<option value="' + f + '">' + _esc(Utils.getFieldLabel(ctx.fieldLabels, f)) + '</option>';
        });
        html += '</select></div>';

        // v3.3.2: Stack By (só pra stacked)
        html += '<div class="dbp-form-group" id="' + uid + '-setting-stackby-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["stack_by"], "Empilhar Por")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-stackby">';
        html += '<option value="">' + _esc(_lbl(ctx, ["none"], "Nenhum")) + '</option>';
        availableFields.forEach(function (f) {
            html += '<option value="' + f + '">' + _esc(Utils.getFieldLabel(ctx.fieldLabels, f)) + '</option>';
        });
        html += '</select></div>';

        // v3.3.2: Series UI dinâmica (só pra multiseries)
        html += '<div class="dbp-form-group" id="' + uid + '-setting-series-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["sub_chart_type"], "Tipo da Série")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-subcharttype" style="margin-bottom:10px;">';
        html += '<option value="bar">Bar</option>';
        html += '<option value="line">Line</option>';
        html += '<option value="area">Area</option>';
        html += '</select>';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["series"], "Séries")) + '</label>';
        html += '<div id="' + uid + '-setting-series-list" style="display:flex;flex-direction:column;gap:6px;"></div>';
        html += '<button type="button" class="dbp-btn dbp-btn-secondary" id="' + uid + '-setting-series-add" style="margin-top:8px;align-self:flex-start;">+ ' + _esc(_lbl(ctx, ["add_series"], "Adicionar série")) + '</button>';
        html += '</div>';

        // Format + Orientation (linha dupla)
        html += '<div class="dbp-field-row">';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["format"], "Formato")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-format">';
        html += '<option value="number">' + _esc(_lbl(ctx, ["number"], "Número")) + '</option>';
        html += '<option value="currency">' + _esc(_lbl(ctx, ["currency_format"], "Moeda")) + '</option>';
        html += '<option value="percent">' + _esc(_lbl(ctx, ["percent"], "Porcentagem")) + '</option>';
        html += '</select></div>';
        html += '<div class="dbp-form-group" id="' + uid + '-setting-orientation-container">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["orientation"], "Orientação")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-setting-orientation">';
        html += '<option value="vertical">' + _esc(_lbl(ctx, ["vertical"], "Vertical")) + '</option>';
        html += '<option value="horizontal">' + _esc(_lbl(ctx, ["horizontal"], "Horizontal")) + '</option>';
        html += '</select></div></div>';

        // Target (condicional pra progress/gauge)
        html += '<div class="dbp-form-group" id="' + uid + '-setting-target-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["target", "goal"], "Meta")) + '</label>';
        html += '<input type="number" class="dbp-form-input" id="' + uid + '-setting-target" placeholder="10000"></div>';

        // v3.4.0: Cores customizadas (lista dinâmica de color pickers).
        // Vazio = herda paleta global do dashboard.
        html += '<div class="dbp-form-group" id="' + uid + '-setting-colors-container">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["chart_colors", "colors"], "Cores do Gráfico")) + ' <span style="font-weight:400;opacity:0.7;">(' + _esc(_lbl(ctx, ["colors_hint"], "vazio = paleta padrão")) + ')</span></label>';
        html += '<div id="' + uid + '-setting-colors-list" style="display:flex;flex-wrap:wrap;gap:8px;"></div>';
        html += '<button type="button" class="dbp-btn dbp-btn-secondary" id="' + uid + '-setting-colors-add" style="margin-top:8px;align-self:flex-start;">+ ' + _esc(_lbl(ctx, ["add_color"], "Adicionar cor")) + '</button>';
        html += '</div>';

        // v3.4.1: tamanho de fontes (vazio = automático/responsivo)
        html += '<div class="dbp-field-row">';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["title_font_size"], "Fonte do Título (px)")) + '</label>';
        html += '<input type="number" min="8" max="48" class="dbp-form-input" id="' + uid + '-setting-title-fs" placeholder="' + _esc(_lbl(ctx, ["auto"], "auto")) + '"></div>';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["content_font_size"], "Fonte dos Dados (px)")) + '</label>';
        html += '<input type="number" min="8" max="72" class="dbp-form-input" id="' + uid + '-setting-content-fs" placeholder="' + _esc(_lbl(ctx, ["auto"], "auto")) + '"></div>';
        html += '</div>';

        // v3.4.2: cor do texto (toggle revela pickers; off = automático/tema)
        html += '<div class="dbp-form-group"><label class="dbp-form-toggle"><span>' + _esc(_lbl(ctx, ["custom_text_color"], "Personalizar cor do texto")) + '</span>';
        html += '<input type="checkbox" id="' + uid + '-setting-customtext"><span class="toggle-track"><span class="toggle-thumb"></span></span></label></div>';
        html += '<div class="dbp-field-row" id="' + uid + '-setting-textcolors-container" style="display:none;">';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["title_color"], "Cor do Título")) + '</label>';
        html += '<input type="color" class="dbp-form-input" id="' + uid + '-setting-title-color" value="#2c3e50" style="height:38px;padding:2px;cursor:pointer;"></div>';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["content_color"], "Cor dos Dados")) + '</label>';
        html += '<input type="color" class="dbp-form-input" id="' + uid + '-setting-content-color" value="#666666" style="height:38px;padding:2px;cursor:pointer;"></div>';
        html += '</div>';

        // Toggles (Show Legend + Data Labels)
        html += '<div class="dbp-form-group"><label class="dbp-form-toggle"><span>' + _esc(_lbl(ctx, ["show_legend"], "Exibir Legenda")) + '</span>';
        html += '<input type="checkbox" id="' + uid + '-setting-legend"><span class="toggle-track"><span class="toggle-thumb"></span></span></label></div>';
        html += '<div class="dbp-form-group"><label class="dbp-form-toggle"><span>' + _esc(_lbl(ctx, ["show_data_labels"], "Exibir Rótulos de Dados")) + '</span>';
        html += '<input type="checkbox" id="' + uid + '-setting-datalabels"><span class="toggle-track"><span class="toggle-thumb"></span></span></label></div>';

        html += '</div><div class="dbp-modal-footer">';
        html += '<button class="dbp-btn dbp-btn-secondary" id="' + uid + '-modal-cancel">' + _esc(_lbl(ctx, ["cancel"], "Cancelar")) + '</button>';
        html += '<button class="dbp-btn dbp-btn-primary" id="' + uid + '-modal-save">' + _esc(_lbl(ctx, ["save"], "Salvar")) + '</button>';
        html += '</div></div></div>';
        return html;
    };

    // --- MODAL: Add Card (restaurar ocultos) ---
    Templates.addCardModal = function (ctx) {
        var uid = ctx.uniqueId;
        var html = '<div class="dbp-modal-overlay" id="' + uid + '-add-card-modal">';
        html += '<div class="dbp-modal"><div class="dbp-modal-header">';
        html += '<h3>Adicionar Card</h3>';
        html += '<button class="dbp-modal-close" id="' + uid + '-add-modal-close">×</button></div>';
        html += '<div class="dbp-modal-body"><div class="dbp-cards-list" id="' + uid + '-hidden-cards-list">';
        html += '<div class="dbp-empty-hidden">Nenhum card oculto</div></div></div></div></div>';
        return html;
    };

    // --- MODAL: Create Chart ---
    Templates.createChartModal = function (ctx) {
        if (!ctx.allowAddChart) return '';

        var uid = ctx.uniqueId;
        var availableFields = ctx.dataFields ? ctx.dataFields.split(',').map(function (f) { return f.trim(); }).filter(function (f) { return f; }) : [];

        var html = '<div class="dbp-modal-overlay" id="' + uid + '-create-chart-modal">';
        html += '<div class="dbp-modal" style="max-width: 500px; width: 90%;"><div class="dbp-modal-header">';
        html += '<h3 class="dbp-modal-title">' + _esc(_lbl(ctx, ["create_chart", "new_chart"], "Criar Novo Gráfico")) + '</h3>';
        html += '<button class="dbp-modal-close" id="' + uid + '-create-chart-close">×</button></div>';
        html += '<div class="dbp-modal-body">';

        // Título
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["title"], "Título")) + '</label>';
        html += '<input type="text" class="dbp-form-input" id="' + uid + '-new-chart-title" placeholder="' + _esc(_lbl(ctx, ["chart_title_placeholder"], "Ex: Vendas por Mês")) + '"></div>';

        // Tipo de gráfico
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["chart_type"], "Tipo de Gráfico")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-chart-type-select">';
        // v3.3.1: incluídos stacked, multiseries e funnel (estavam no renderApexChart mas faltavam aqui)
        var chartTypes = [
            { type: 'kpi', icon: '📊', label: _lbl(ctx, ["chart_kpi"], "KPI - Valor único") },
            { type: 'progress', icon: '📈', label: _lbl(ctx, ["chart_progress"], "Progress - Barra de progresso") },
            { type: 'bar', icon: '📊', label: _lbl(ctx, ["chart_bar"], "Bar - Gráfico de barras") },
            { type: 'line', icon: '📉', label: _lbl(ctx, ["chart_line"], "Line - Gráfico de linhas") },
            { type: 'area', icon: '📈', label: _lbl(ctx, ["chart_area"], "Area - Gráfico de área") },
            { type: 'pie', icon: '🥧', label: _lbl(ctx, ["chart_pie"], "Pie - Gráfico de pizza") },
            { type: 'donut', icon: '🍩', label: _lbl(ctx, ["chart_donut"], "Donut - Gráfico de rosca") },
            { type: 'gauge', icon: '⏱️', label: _lbl(ctx, ["chart_gauge"], "Gauge - Velocímetro") },
            { type: 'funnel', icon: '🔻', label: _lbl(ctx, ["chart_funnel"], "Funnel - Gráfico de funil") },
            { type: 'stacked', icon: '📚', label: _lbl(ctx, ["chart_stacked"], "Stacked - Barras empilhadas") },
            { type: 'multiseries', icon: '📊', label: _lbl(ctx, ["chart_multiseries"], "Multi-series - Múltiplas séries") },
            { type: 'table', icon: '📋', label: _lbl(ctx, ["chart_table"], "Table - Tabela de dados") }
        ];
        chartTypes.forEach(function (ct) {
            html += '<option value="' + ct.type + '">' + ct.icon + ' ' + ct.label + '</option>';
        });
        html += '</select></div>';

        // Campos do form
        html += '<div class="dbp-create-chart-fields" id="' + uid + '-create-chart-fields">';

        // Value field + Aggregation
        html += '<div class="dbp-field-row"><div class="dbp-form-group">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["value_field"], "Campo de Valor")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-field">';
        html += '<option value="">' + _esc(_lbl(ctx, ["select_field"], "Selecione...")) + '</option>';
        availableFields.forEach(function (f) {
            html += '<option value="' + f + '">' + _esc(Utils.getFieldLabel(ctx.fieldLabels, f)) + '</option>';
        });
        html += '</select></div>';
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["aggregation"], "Agregação")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-aggregation">';
        html += '<option value="sum">' + _esc(_lbl(ctx, ["sum"], "Soma")) + '</option>';
        html += '<option value="count">' + _esc(_lbl(ctx, ["count"], "Contagem")) + '</option>';
        html += '<option value="avg">' + _esc(_lbl(ctx, ["average"], "Média")) + '</option>';
        html += '<option value="min">' + _esc(_lbl(ctx, ["minimum"], "Mínimo")) + '</option>';
        html += '<option value="max">' + _esc(_lbl(ctx, ["maximum"], "Máximo")) + '</option>';
        html += '</select></div></div>';

        // Group By
        html += '<div class="dbp-form-group" id="' + uid + '-new-chart-groupby-container">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["group_by"], "Agrupar Por")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-groupby">';
        html += '<option value="">' + _esc(_lbl(ctx, ["none"], "Nenhum")) + '</option>';
        availableFields.forEach(function (f) {
            html += '<option value="' + f + '">' + _esc(Utils.getFieldLabel(ctx.fieldLabels, f)) + '</option>';
        });
        html += '</select></div>';

        // v3.3.2: Stack By (só pra stacked)
        html += '<div class="dbp-form-group" id="' + uid + '-new-chart-stackby-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["stack_by"], "Empilhar Por")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-stackby">';
        html += '<option value="">' + _esc(_lbl(ctx, ["none"], "Nenhum")) + '</option>';
        availableFields.forEach(function (f) {
            html += '<option value="' + f + '">' + _esc(Utils.getFieldLabel(ctx.fieldLabels, f)) + '</option>';
        });
        html += '</select></div>';

        // v3.3.2: Series UI dinâmica (só pra multiseries)
        html += '<div class="dbp-form-group" id="' + uid + '-new-chart-series-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["sub_chart_type"], "Tipo da Série")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-subcharttype" style="margin-bottom:10px;">';
        html += '<option value="bar">Bar</option>';
        html += '<option value="line">Line</option>';
        html += '<option value="area">Area</option>';
        html += '</select>';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["series"], "Séries")) + '</label>';
        html += '<div id="' + uid + '-new-chart-series-list" style="display:flex;flex-direction:column;gap:6px;"></div>';
        html += '<button type="button" class="dbp-btn dbp-btn-secondary" id="' + uid + '-new-chart-series-add" style="margin-top:8px;align-self:flex-start;">+ ' + _esc(_lbl(ctx, ["add_series"], "Adicionar série")) + '</button>';
        html += '</div>';

        // Format + Target + Orientation
        html += '<div class="dbp-field-row"><div class="dbp-form-group">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["format"], "Formato")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-format">';
        html += '<option value="number">' + _esc(_lbl(ctx, ["number"], "Número")) + '</option>';
        html += '<option value="currency">' + _esc(_lbl(ctx, ["currency_format"], "Moeda")) + '</option>';
        html += '<option value="percent">' + _esc(_lbl(ctx, ["percent"], "Porcentagem")) + '</option>';
        html += '</select></div>';
        html += '<div class="dbp-form-group" id="' + uid + '-new-chart-target-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["target", "goal"], "Meta")) + '</label>';
        html += '<input type="number" class="dbp-form-input" id="' + uid + '-new-chart-target" placeholder="10000"></div>';
        html += '<div class="dbp-form-group" id="' + uid + '-new-chart-orientation-container" style="display:none;">';
        html += '<label class="dbp-form-label">' + _esc(_lbl(ctx, ["orientation"], "Orientação")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-new-chart-orientation">';
        html += '<option value="vertical">' + _esc(_lbl(ctx, ["vertical"], "Vertical")) + '</option>';
        html += '<option value="horizontal">' + _esc(_lbl(ctx, ["horizontal"], "Horizontal")) + '</option>';
        html += '</select></div></div></div>';

        html += '</div><div class="dbp-modal-footer">';
        html += '<button class="dbp-btn dbp-btn-secondary" id="' + uid + '-create-chart-cancel">' + _esc(_lbl(ctx, ["cancel"], "Cancelar")) + '</button>';
        html += '<button class="dbp-btn dbp-btn-primary" id="' + uid + '-create-chart-save">' + _esc(_lbl(ctx, ["create"], "Criar")) + '</button>';
        html += '</div></div></div>';
        return html;
    };

    // --- MODAL: Dashboard Settings ---
    Templates.dashboardSettingsModal = function (ctx) {
        if (!ctx.allowAddChart) return '';

        var uid = ctx.uniqueId;
        var html = '<div class="dbp-modal-overlay" id="' + uid + '-dashboard-settings-modal">';
        html += '<div class="dbp-modal" style="max-width: 400px;"><div class="dbp-modal-header">';
        html += '<h3 class="dbp-modal-title">' + _esc(_lbl(ctx, ["dashboard_settings"], "Configurações do Dashboard")) + '</h3>';
        html += '<button class="dbp-modal-close" id="' + uid + '-dashboard-settings-close">×</button></div>';
        html += '<div class="dbp-modal-body">';

        // Theme
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["theme"], "Tema")) + '</label>';
        html += '<select class="dbp-form-select" id="' + uid + '-dashboard-theme">';
        html += '<option value="light"' + (ctx.theme === 'light' ? ' selected' : '') + '>' + _esc(_lbl(ctx, ["theme_light"], "Claro")) + '</option>';
        html += '<option value="dark"' + (ctx.theme === 'dark' ? ' selected' : '') + '>' + _esc(_lbl(ctx, ["theme_dark"], "Escuro")) + '</option>';
        html += '</select></div>';

        // Gap
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["gap", "spacing"], "Espaçamento entre cards (px)")) + '</label>';
        html += '<input type="number" class="dbp-form-input" id="' + uid + '-dashboard-gap" value="' + ctx.gap + '" min="0" max="50"></div>';

        // Row Height
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["row_height"], "Altura da linha (px)")) + '</label>';
        html += '<input type="number" class="dbp-form-input" id="' + uid + '-dashboard-row-height" value="' + ctx.rowHeight + '" min="50" max="200"></div>';

        // Card Radius
        html += '<div class="dbp-form-group"><label class="dbp-form-label">' + _esc(_lbl(ctx, ["card_radius"], "Borda arredondada (px)")) + '</label>';
        html += '<input type="number" class="dbp-form-input" id="' + uid + '-dashboard-card-radius" value="' + ctx.cardRadius + '" min="0" max="30"></div>';

        html += '</div><div class="dbp-modal-footer">';
        html += '<button class="dbp-btn dbp-btn-secondary" id="' + uid + '-dashboard-settings-cancel">' + _esc(_lbl(ctx, ["cancel"], "Cancelar")) + '</button>';
        html += '<button class="dbp-btn dbp-btn-primary" id="' + uid + '-dashboard-settings-apply">' + _esc(_lbl(ctx, ["apply"], "Aplicar")) + '</button>';
        html += '</div></div></div>';
        return html;
    };

    // --- FLOATING ACTION BAR ---
    Templates.actionBar = function (ctx) {
        var uid = ctx.uniqueId;
        var html = '<div class="dbp-action-bar" id="' + uid + '-action-bar">';
        html += '<span class="dbp-action-bar-text">' + _esc(_lbl(ctx, ["pending_changes", "pending"], "Alterações pendentes")) + '</span>';
        html += '<button class="dbp-btn dbp-btn-secondary" id="' + uid + '-action-cancel">' + _esc(_lbl(ctx, ["cancel"], "Cancelar")) + '</button>';
        html += '<button class="dbp-btn dbp-btn-primary" id="' + uid + '-action-save">' + _esc(_lbl(ctx, ["save"], "Salvar")) + '</button>';
        html += '</div>';
        return html;
    };

    // --- MONTAR DASHBOARD HTML COMPLETO ---
    // Retorna a string HTML completa (CSS + container + toolbar + grid + modais + action bar)
    // cardContentMap: { cardId: '<html>...' } com conteúdo pré-renderizado de cada card.
    // Inserir o conteúdo aqui evita render em 2 passos e o flash do placeholder anterior.
    Templates.buildDashboard = function (ctx, cardsConfig, cardSettings, cardContentMap) {
        var uid = ctx.uniqueId;
        var themeClass = 'dbp-theme-' + ctx.theme;
        var structureChanged = ctx.structureChanged;
        var loadingClass = structureChanged ? ' dbp-loading' : '';
        var contentMap = cardContentMap || {};

        var cssBlock = CSS.generate({
            uniqueId: uid,
            primaryColor: ctx.primaryColor,
            secondaryColor: ctx.secondaryColor,
            successColor: ctx.successColor,
            dangerColor: ctx.dangerColor,
            cardBackground: ctx.cardBackground,
            cardRadius: ctx.cardRadius,
            fontFamily: ctx.fontFamily,
            gap: ctx.gap,
            columns: ctx.columns
        });

        var html = cssBlock + '<div id="' + uid + '" class="dbp-container ' + themeClass + loadingClass + '">';

        // Toolbar
        html += Templates.toolbar(ctx);

        // Grid de cards
        html += '<div class="dbp-grid"><div class="grid-stack" id="' + uid + '-grid">';

        // v3.5.0: contar cards visíveis (não-ocultos) pra decidir empty state
        var visibleCount = 0;
        if (cardsConfig.cards && cardsConfig.cards.length > 0) {
            cardsConfig.cards.forEach(function (card, index) {
                var cardId = card.id || 'card_' + index;
                if (ctx.hiddenCards.indexOf(cardId) === -1) visibleCount++;
                html += Templates.card(ctx, card, index, cardSettings, contentMap[cardId]);
            });
        }

        html += '</div>';

        // v3.5.0: empty state SEMPRE no DOM (oculto quando há cards). Assim ele
        // reaparece dinamicamente ao deletar/ocultar todos os cards (refreshEmptyState).
        var emptyStyle = visibleCount === 0 ? '' : ' style="display:none"';
        html += '<div class="dbp-empty-dashboard" id="' + uid + '-empty-dashboard"' + emptyStyle + '>';
        html += '<div class="dbp-empty-dashboard-icon">📊</div>';
        html += '<div class="dbp-empty-dashboard-title">' + _esc(_lbl(ctx, ["empty_dashboard_title"], "Nenhum gráfico ainda")) + '</div>';
        if (ctx.allowAddChart) {
            html += '<div class="dbp-empty-dashboard-text">' + _esc(_lbl(ctx, ["empty_dashboard_text"], 'Clique em "+ Add Chart" para começar a montar seu dashboard.')) + '</div>';
        }
        html += '</div>';

        html += '</div>';

        // Modais
        html += Templates.cardSettingsModal(ctx);
        html += Templates.addCardModal(ctx);
        html += Templates.createChartModal(ctx);
        html += Templates.dashboardSettingsModal(ctx);

        // Action bar
        html += Templates.actionBar(ctx);

        // Fechar container
        html += '</div>';

        return html;
    };


    // =====================================================
    // MÓDULO: RENDERERS
    // Geração de conteúdo HTML para cada tipo de card
    // Funções puras que retornam HTML ou configuram charts
    // =====================================================

    var Renderers = {};

    // --- KPI ---
    // Retorna HTML do KPI com comparação opcional
    Renderers.kpi = function (ctx, card, config, currentData, allData, comparisonEnabled) {
        var field = config.field || config.valueField || 'valor';
        var aggregation = config.aggregation || 'sum';
        var format = config.format || 'number';
        var showComp = config.showComparison !== false && comparisonEnabled;

        var currentValue = Utils.aggregate(currentData, field, aggregation);
        var previousValue = null;
        var variation = null;
        var variationHtml = '';

        if (showComp) {
            var activeStart = ctx.activeFilters.startDate;
            var activeEnd = ctx.activeFilters.endDate;
            var prevStart = null;
            var prevEnd = null;

            if (ctx.previousStart && ctx.previousEnd) {
                prevStart = Utils.formatDateInput(ctx.previousStart);
                prevEnd = Utils.formatDateInput(ctx.previousEnd);
            } else if (activeStart && activeEnd) {
                var startDate = new Date(activeStart + 'T12:00:00');
                var endDate = new Date(activeEnd + 'T12:00:00');
                var duration = endDate.getTime() - startDate.getTime();
                var newPrevEnd = new Date(startDate.getTime() - 86400000);
                var newPrevStart = new Date(newPrevEnd.getTime() - duration);
                prevStart = newPrevStart.toISOString().split('T')[0];
                prevEnd = newPrevEnd.toISOString().split('T')[0];
            }

            if (prevStart && prevEnd) {
                var previousData = Utils.filterByPeriod(allData, prevStart, prevEnd, ctx.dateField);
                previousValue = Utils.aggregate(previousData, field, aggregation);
                variation = Utils.formatVariation(currentValue, previousValue);

                var prevStartFormatted = new Date(prevStart).toLocaleDateString(ctx.locale);
                var prevEndFormatted = new Date(prevEnd).toLocaleDateString(ctx.locale);
                var periodLabel = 'vs ' + prevStartFormatted + ' - ' + prevEndFormatted;

                variationHtml = '<div class="dbp-kpi-comparison ' + variation.class + '">' + variation.text + '</div>' +
                    '<div class="dbp-kpi-label">' + Utils.escapeHtml(periodLabel) + '</div>';
            }
        }

        return {
            html: '<div class="dbp-kpi"><div class="dbp-kpi-value">' + Utils.formatNumber(currentValue, format, ctx.locale, ctx.currency) + '</div>' + variationHtml + '</div>',
            currentValue: currentValue,
            previousValue: previousValue,
            variation: variation
        };
    };

    // --- PROGRESS BAR ---
    Renderers.progress = function (ctx, card, config, data) {
        var valueField = config.valueField || config.field || 'valor';
        var targetField = config.targetField || 'meta';
        var aggregation = config.aggregation || 'sum';
        var format = config.format || 'number';

        var currentValue = Utils.aggregate(data, valueField, aggregation);

        // Hierarquia de meta
        var finalTarget = 0;
        if (config.target && config.target > 0) {
            finalTarget = parseFloat(config.target);
        } else if (ctx.targetValue && ctx.targetValue > 0) {
            finalTarget = parseFloat(ctx.targetValue);
        } else {
            finalTarget = Utils.aggregate(data, targetField, aggregation);
        }
        if (finalTarget === 0) finalTarget = 100;

        var percentageReal = (currentValue / finalTarget) * 100;
        // v3.4.0: cor customizada do card (config.colors[0]) tem prioridade
        var customProgressColor = (config.colors && config.colors.length > 0) ? config.colors[0] : null;
        var progressColor = customProgressColor || (percentageReal >= 100 ? ctx.successColor : ctx.primaryColor);
        var cssPercentage = Math.max(0, Math.min(100, parseFloat(percentageReal) || 0));

        return {
            html: '<div class="dbp-progress-container" style="padding: 0 16px;">' +
                '<div class="dbp-progress-header" style="display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: nowrap;"><span style="flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;">' + Utils.formatNumber(currentValue, format, ctx.locale, ctx.currency) + '</span><span style="white-space: nowrap; flex-shrink: 0; color: ' + progressColor + ';">' + percentageReal.toFixed(1) + '%</span></div>' +
                '<div style="width: 100%; height: 16px; background-color: #d0d0d0; border-radius: 8px; overflow: hidden; margin: 10px 0; position: relative;">' +
                '<div style="width: ' + cssPercentage + '%; height: 100%; background-color: ' + progressColor + '; border-radius: 8px; transition: width 0.5s ease;"></div></div>' +
                '<div class="dbp-progress-labels" style="display: flex; justify-content: space-between;"><span>Sales Made</span><span>Target: ' + Utils.formatNumber(finalTarget, format, ctx.locale, ctx.currency) + '</span></div></div>',
            currentValue: currentValue,
            target: finalTarget,
            percentage: percentageReal
        };
    };

    // --- TABLE ---
    Renderers.table = function (ctx, card, config, data) {
        var cols = config.columns || [];
        var groupByField = config.groupBy;
        var valueField = config.valueField || 'valor';
        var aggregation = config.aggregation || 'sum';
        var sortBy = config.sortBy || valueField;
        var sortOrder = config.sortOrder || 'desc';
        var limit = config.limit || 10;
        var format = config.format || 'number';

        var tableData = [];
        if (groupByField) {
            tableData = Utils.groupBy(data, groupByField, valueField, aggregation);
        } else {
            tableData = data.slice(0, limit).map(function (item) {
                return { label: item[cols[0]] || '-', value: parseFloat(item[valueField]) || 0 };
            });
        }

        tableData.sort(function (a, b) {
            return sortOrder === 'desc' ? b.value - a.value : a.value - b.value;
        });
        tableData = tableData.slice(0, limit);

        var html = '<div class="dbp-table-wrapper" style="overflow-x: auto; max-height: 100%; font-weight: 500;"><table class="dbp-table">';
        html += '<thead><tr><th style="font-weight: 600;">#</th><th style="font-weight: 600;">' + (groupByField || cols[0] || 'Item') + '</th><th style="text-align: right; font-weight: 600;">' + (valueField || 'Valor') + '</th></tr></thead>';
        html += '<tbody>';
        tableData.forEach(function (item, index) {
            html += '<tr><td style="font-weight: 500;">' + (index + 1) + '</td><td style="font-weight: 500;">' + item.label + '</td><td style="text-align: right; font-weight: 600;">' + Utils.formatNumber(item.value, format, ctx.locale, ctx.currency) + '</td></tr>';
        });
        html += '</tbody></table></div>';
        return { html: html };
    };

    // --- CHART (prepara config para ApexCharts, retorna div placeholder) ---
    // Não renderiza o chart, apenas retorna o HTML e adiciona à fila chartsToRender
    Renderers.chart = function (ctx, card, config, data, chartType, chartsToRender) {
        var chartId = ctx.uniqueId + '_chart_' + card.id;
        var valueField = config.valueField || config.field || 'valor';
        var groupByField = config.groupBy;
        var aggregation = config.aggregation || 'sum';
        var groupFormat = config.groupByFormat || 'day';
        var orientation = config.orientation || 'vertical';

        var savedSettings = ctx.cardSettings[card.id] || {};
        var showLegend = savedSettings.showLegend !== undefined ? savedSettings.showLegend : (config.showLegend !== false);
        var showDataLabels = config.showDataLabels || false;
        var format = config.format || 'number';

        var chartData = [];
        var rawData = data;

        if (chartType === 'stacked' || chartType === 'multiseries') {
            if (groupByField) {
                chartData = Utils.groupBy(data, groupByField, valueField, aggregation);
            } else {
                var autoDateField = ctx.dateField;
                if (data.length > 0 && !data[0].hasOwnProperty(ctx.dateField) && data[0].hasOwnProperty('date')) {
                    autoDateField = 'date';
                }
                chartData = Utils.groupByDate(data, autoDateField, valueField, aggregation, groupFormat);
            }
        } else {
            var isDateGrouping = config.groupByDate ||
                groupByField === ctx.dateField ||
                groupByField === 'date' || groupByField === 'data' ||
                (groupByField && groupByField.toLowerCase().indexOf('date') >= 0);

            if (isDateGrouping) {
                var actualDateField = (groupByField && groupByField !== ctx.dateField) ? groupByField : ctx.dateField;
                if (data.length > 0 && !data[0].hasOwnProperty(actualDateField) && data[0].hasOwnProperty('date')) {
                    actualDateField = 'date';
                }
                chartData = Utils.groupByDate(data, actualDateField, valueField, aggregation, groupFormat);
            } else if (groupByField) {
                chartData = Utils.groupBy(data, groupByField, valueField, aggregation);
            } else {
                var autoDateField2 = ctx.dateField;
                if (data.length > 0 && !data[0].hasOwnProperty(ctx.dateField) && data[0].hasOwnProperty('date')) {
                    autoDateField2 = 'date';
                }
                chartData = Utils.groupByDate(data, autoDateField2, valueField, aggregation, groupFormat);
            }
        }

        chartsToRender.push({
            id: chartId,
            type: chartType,
            data: chartData,
            rawData: rawData,
            config: config,
            orientation: orientation,
            showLegend: showLegend,
            showDataLabels: showDataLabels,
            format: format,
            cardId: card.id
        });

        return { html: '<div class="dbp-chart" id="' + chartId + '"></div>' };
    };

    // --- RENDER CARD CONTENT (router) ---
    // Direciona para o renderer correto baseado no tipo do card
    Renderers.cardContent = function (ctx, card, data, allData, chartsToRender) {
        var cardId = card.id || 'card_unknown';
        var savedSettings = ctx.cardSettings[cardId] || {};
        var type = (savedSettings.chartType || card.type || 'kpi').toLowerCase();
        var config = card.config || {};
        var comparisonEnabled = ctx.activeFilters.hasOwnProperty('comparison') ? ctx.activeFilters.comparison : ctx.enableComparison;

        // Status filter
        var cardData = data;
        var statusFilter = config.statusFilter;
        var statusField = config.statusField || 'status';

        if (statusFilter && type !== 'funnel') {
            var allowedStatus = Array.isArray(statusFilter) ? statusFilter : [statusFilter];
            cardData = data.filter(function (item) {
                var itemStatus = item[statusField];
                if (!itemStatus) return false;
                var itemStatusLower = String(itemStatus).toLowerCase().trim();
                return allowedStatus.some(function (s) {
                    return String(s).toLowerCase().trim() === itemStatusLower;
                });
            });
        }

        try {
            switch (type) {
                case 'kpi':
                    return Renderers.kpi(ctx, card, config, cardData, allData, comparisonEnabled).html;
                case 'progress':
                    return Renderers.progress(ctx, card, config, cardData).html;
                case 'table':
                    return Renderers.table(ctx, card, config, cardData).html;
                case 'bar': case 'line': case 'area': case 'donut': case 'pie':
                case 'gauge': case 'funnel': case 'stacked': case 'multiseries':
                    var chartData = type === 'funnel' ? data : cardData;
                    return Renderers.chart(ctx, card, config, chartData, type, chartsToRender).html;
                default:
                    return '<div class="dbp-empty">Tipo "' + type + '" não suportado</div>';
            }
        } catch (err) {
            return '<div class="dbp-empty">Erro: ' + err.message + '</div>';
        }
    };

    // --- AJUSTE RESPONSIVO DE FONTES ---
    // v3.1.15: scaleFactor min 0.4 + pisos ainda menores pra cards minúsculos.
    // Em cards muito pequenos, escala vai DE FATO encolher as fontes.
    Renderers.adjustCardFonts = function (cardElement) {
        var width = cardElement.offsetWidth;
        var height = cardElement.offsetHeight;
        var widthFactor = Math.max(0.4, Math.min(1.8, width / 300));
        var heightFactor = Math.max(0.4, Math.min(1.8, height / 200));
        var scaleFactor = Math.min(widthFactor, heightFactor);

        // v3.4.1/2: overrides manuais de tamanho (0 = auto) e cor ('' = auto) de fonte
        var titleFs = parseInt(cardElement.getAttribute('data-title-fs')) || 0;
        var contentFs = parseInt(cardElement.getAttribute('data-content-fs')) || 0;
        var titleColor = cardElement.getAttribute('data-title-color') || '';
        var contentColor = cardElement.getAttribute('data-content-color') || '';

        // Título do card
        var cardTitle = cardElement.querySelector('.dbp-card-title');
        if (cardTitle) {
            cardTitle.style.fontSize = (titleFs > 0 ? titleFs : Math.max(9, Math.min(20, Math.round(15 * scaleFactor)))) + 'px';
            if (titleColor) cardTitle.style.color = titleColor;
        }

        // KPI — valor usa contentFs/contentColor (override) quando definido
        var kpiValue = cardElement.querySelector('.dbp-kpi-value');
        if (kpiValue) {
            kpiValue.style.fontSize = (contentFs > 0 ? contentFs : Math.max(10, Math.min(48, Math.round(32 * scaleFactor)))) + 'px';
            if (contentColor) kpiValue.style.color = contentColor;
        }

        var kpiComp = cardElement.querySelector('.dbp-kpi-comparison');
        if (kpiComp) kpiComp.style.fontSize = Math.max(8, Math.min(28, Math.round(16 * scaleFactor))) + 'px';

        var kpiLabel = cardElement.querySelector('.dbp-kpi-label');
        if (kpiLabel) kpiLabel.style.fontSize = Math.max(8, Math.min(16, Math.round(12 * scaleFactor))) + 'px';

        // Progress
        var progressHeader = cardElement.querySelector('.dbp-progress-header');
        if (progressHeader) progressHeader.style.fontSize = Math.max(10, Math.min(28, Math.round(22 * scaleFactor))) + 'px';

        var progressLabels = cardElement.querySelector('.dbp-progress-labels');
        if (progressLabels) progressLabels.style.fontSize = Math.max(8, Math.min(14, Math.round(12 * scaleFactor))) + 'px';

        var progressBar = cardElement.querySelector('.dbp-progress-bar');
        if (progressBar) progressBar.style.height = Math.max(6, Math.min(28, Math.round(14 * scaleFactor))) + 'px';

        // Table
        var table = cardElement.querySelector('.dbp-table');
        if (table) {
            table.style.fontSize = Math.max(10, Math.min(18, Math.round(13 * scaleFactor))) + 'px';
            var cells = cardElement.querySelectorAll('.dbp-table th, .dbp-table td');
            var cellPadding = Math.max(4, Math.round(8 * scaleFactor)) + 'px ' + Math.max(6, Math.round(12 * scaleFactor)) + 'px';
            for (var i = 0; i < cells.length; i++) {
                cells[i].style.padding = cellPadding;
            }
        }
    };

    // --- TOAST ---
    Renderers.showToast = function (container, message, type) {
        var existing = container.querySelector('.dbp-toast');
        if (existing) existing.remove();

        var toast = document.createElement('div');
        toast.className = 'dbp-toast' + (type ? ' ' + type : '');
        toast.textContent = message;
        container.appendChild(toast);

        setTimeout(function () { toast.classList.add('show'); }, 10);
        setTimeout(function () {
            toast.classList.remove('show');
            setTimeout(function () { toast.remove(); }, 300);
        }, 2500);
    };


    // =====================================================
    // MÓDULO: CHART ENGINE
    // Renderização ApexCharts + Resize
    // ctx precisa ter: theme, fontFamily, colorPalette, colorPaletteWithOpacity,
    //   primaryColor, successColor, targetValue, locale, currency
    // =====================================================

    var ChartEngine = {};

    // v3.3.6: gera função formatter do eixo Y (abrevia k/M + moeda + percent).
    // Reutilizável: usado tanto no render INICIAL quanto no resizeChart.
    // CRÍTICO: o resize precisa re-passar este formatter, senão o ApexCharts
    // reseta a label do eixo pro default cru (ex: 400000.00000000000).
    ChartEngine._makeYAxisFormatter = function (fmt, locale, currency) {
        var currencySymbol = '';
        if (fmt === 'currency' || fmt === 'currency_brl') {
            try {
                var parts = new Intl.NumberFormat(locale, { style: 'currency', currency: currency }).formatToParts(0);
                for (var p = 0; p < parts.length; p++) {
                    if (parts[p].type === 'currency') { currencySymbol = parts[p].value + ' '; break; }
                }
            } catch (e) { currencySymbol = ''; }
        }
        var isPercent = fmt === 'percent' || fmt === 'percentage';
        return function (val) {
            if (val === undefined || val === null || isNaN(val)) return '';
            var num = Math.round(Number(val));
            var suffix = isPercent ? '%' : '';
            if (Math.abs(num) >= 1000000) return currencySymbol + (num / 1000000).toFixed(1) + 'M' + suffix;
            if (Math.abs(num) >= 1000) return currencySymbol + (num / 1000).toFixed(0) + 'k' + suffix;
            return currencySymbol + num.toLocaleString(locale) + suffix;
        };
    };

    // v3.1.13: helper de tipografia responsiva.
    // Calcula tamanhos de fonte baseado no menor lado do container do chart.
    // Aplicado em xaxis/yaxis labels, legend, dataLabels, tooltip.
    // v3.4.1: aceita overridePx — se > 0, todas as fontes viram esse valor fixo
    // (exceto tooltip, que mantém legível). Usado pra customização manual.
    ChartEngine._calcFontSizes = function (width, height, overridePx) {
        var ov = parseInt(overridePx) || 0;
        if (ov > 0) {
            var ovPx = ov + 'px';
            return {
                axisLabel: ovPx, legendLabel: ovPx, dataLabel: ovPx,
                tooltipLabel: ovPx, funnelLabel: ovPx
            };
        }
        var w = (typeof width === 'number' && width > 0) ? width : 300;
        var h = (typeof height === 'number' && height > 0) ? height : 200;
        var minDim = Math.min(w, h);
        // Fator de escala entre 0.75 e 1.5 baseado em menor dimensão
        var factor = Math.max(0.75, Math.min(1.5, minDim / 220));
        function clamp(base, lo, hi) {
            return Math.max(lo, Math.min(hi, Math.round(base * factor))) + 'px';
        }
        return {
            axisLabel: clamp(11, 9, 16),
            legendLabel: clamp(12, 10, 16),
            dataLabel: clamp(11, 9, 14),
            tooltipLabel: clamp(12, 10, 15),
            funnelLabel: clamp(12, 10, 15)
        };
    };

    // Renderizar um gráfico ApexCharts a partir do chartConfig
    // ctx = contexto, chartConfig = item da fila chartsToRender, el = DOM element
    // Retorna a instância do chart (ou null em erro)
    ChartEngine.renderApexChart = function (ctx, chartConfig, el, forceHeight) {
        // v3.1.9 fix: validar que el está no DOM e tem parent renderizado.
        if (!el || !document.body.contains(el)) return null;

        // v3.1.11 fix: detectar parent sem dimensões válidas ANTES de criar chart.
        // Sem isso, ApexCharts internamente faz cálculos com offsetHeight/Width=0
        // e gera NaN no transform/height do SVG. Retorna marker { needsRetry: true }
        // pro chamador agendar retry via rAF (mantendo controle do instance.data.charts).
        var parent = el.parentElement;
        var parentH = parent ? parent.offsetHeight : 0;
        var parentW = parent ? parent.offsetWidth : 0;
        if (!parent || parentH < 50 || parentW < 50) {
            return { needsRetry: true };
        }

        var labels = chartConfig.data.map(function (d) { return d.label; });
        var values = chartConfig.data.map(function (d) { return d.value; });

        if (!labels.length || !values.length) {
            el.innerHTML = '<div class="dbp-empty"><span class="dbp-empty-ico">📭</span><span>' + Utils.escapeHtml(Utils.resolveLabel(ctx.uiLabels, ["no_data", "empty_chart"], "Sem dados para exibir")) + '</span></div>';
            return null;
        }

        var containerHeight = forceHeight || (el.parentElement ? el.parentElement.offsetHeight : 0);
        if (!containerHeight || isNaN(containerHeight) || containerHeight < 50) containerHeight = 250;
        var chartHeight = Math.max(150, containerHeight - 10);
        var tickAmount = Utils.calculateTickAmount(chartHeight);
        if (!tickAmount || isNaN(tickAmount) || tickAmount < 2) tickAmount = 5;

        var isDark = ctx.theme === 'dark';
        // v3.1.15: branco puro no dark pra contraste máximo
        var textColor = isDark ? '#ffffff' : '#666666';
        var gridColor = isDark ? '#4a5577' : '#e0e0e0';
        // v3.4.2: cor customizada dos rótulos/eixos/legenda (config.labelColor)
        if (chartConfig.config && chartConfig.config.labelColor) {
            textColor = chartConfig.config.labelColor;
        }

        // v3.4.0: cores customizadas POR CARD (config.colors). Se ausente, herda
        // a paleta global do dashboard (ctx.colorPalette / colorPaletteWithOpacity).
        var customColors = (chartConfig.config && Array.isArray(chartConfig.config.colors) && chartConfig.config.colors.length > 0)
            ? chartConfig.config.colors.filter(function (c) { return c && typeof c === 'string'; })
            : null;
        var paletteSolid = customColors || ctx.colorPalette;
        var paletteOpacity = customColors || ctx.colorPaletteWithOpacity;

        // v3.1.13/3.4.1: tipografia responsiva + override manual (config.labelFontSize).
        var labelFsOverride = (chartConfig.config && parseInt(chartConfig.config.labelFontSize)) || 0;
        var fontSizes = ChartEngine._calcFontSizes(parentW, parentH, labelFsOverride);

        var isDonut = chartConfig.type === 'donut';
        var isPie = chartConfig.type === 'pie';
        var isLine = chartConfig.type === 'line';
        var isArea = chartConfig.type === 'area';
        var isBar = chartConfig.type === 'bar';
        var isGauge = chartConfig.type === 'gauge';
        var isFunnel = chartConfig.type === 'funnel';
        var isStacked = chartConfig.type === 'stacked';
        var isMultiseries = chartConfig.type === 'multiseries';
        var isHorizontal = chartConfig.orientation === 'horizontal';

        values = values.map(function (v) { var num = parseFloat(v); return isNaN(num) ? 0 : num; });
        labels = labels.map(function (l) { return l ? String(l) : 'N/A'; });

        var fmt = chartConfig.format;
        function fmtNum(val) { return Utils.formatNumber(val, fmt, ctx.locale, ctx.currency); }

        // v3.3.6: usa helper compartilhado (mesmo formatter no render e no resize)
        var yAxisFormatter = ChartEngine._makeYAxisFormatter(fmt, ctx.locale, ctx.currency);

        // ===== GAUGE =====
        if (isGauge) {
            var gaugeValue = values.reduce(function (sum, val) { return sum + (val || 0); }, 0);
            // Hierarquia: config.max > config.target > ctx.targetValue (global) > 100 (default)
            var gaugeMax = 100;
            if (chartConfig.config) {
                if (chartConfig.config.max !== undefined && chartConfig.config.max !== null && parseFloat(chartConfig.config.max) > 0) {
                    gaugeMax = parseFloat(chartConfig.config.max);
                } else if (chartConfig.config.target !== undefined && chartConfig.config.target !== null && parseFloat(chartConfig.config.target) > 0) {
                    gaugeMax = parseFloat(chartConfig.config.target);
                } else if (ctx.targetValue > 0) {
                    gaugeMax = ctx.targetValue;
                }
            } else if (ctx.targetValue > 0) {
                gaugeMax = ctx.targetValue;
            }
            var gaugePercentReal = (gaugeValue / gaugeMax) * 100;
            var gaugePercentVisual = Math.min(gaugePercentReal, 100);

            var containerWidth = el.offsetWidth || 200;
            var minDimension = Math.min(containerWidth, chartHeight);
            var scale = Math.max(0.5, Math.min(1.2, minDimension / 180));
            var percentFontSize = Math.round(24 * scale) + 'px';
            var valueFontSize = Math.round(14 * scale) + 'px';

            var options = {
                chart: { type: 'radialBar', height: chartHeight, redrawOnParentResize: false, redrawOnWindowResize: false, sparkline: { enabled: true }, toolbar: { show: false }, background: 'transparent', fontFamily: ctx.fontFamily, animations: { enabled: true, dynamicAnimation: { enabled: true, speed: 150 } } },
                series: [gaugePercentVisual],
                colors: [customColors ? customColors[0] : (gaugePercentReal >= 100 ? ctx.successColor : ctx.primaryColor)],
                plotOptions: { radialBar: { startAngle: -135, endAngle: 135, hollow: { size: '55%', background: 'transparent' }, track: { background: isDark ? '#3a3a5a' : '#e0e0e0', strokeWidth: '100%' }, dataLabels: { name: { show: true, fontSize: valueFontSize, fontWeight: 600, color: isDark ? '#b0b0b0' : '#555555', offsetY: 30, formatter: function () { return fmtNum(gaugeValue); } }, value: { show: true, fontSize: percentFontSize, fontWeight: 700, color: isDark ? '#eaeaea' : '#2c3e50', offsetY: -10, formatter: function () { return gaugePercentReal.toFixed(1) + '%'; } } } } },
                labels: [fmtNum(gaugeValue)],
                stroke: { lineCap: 'round' }
            };

            try {
                var chart = new ApexCharts(el, options);
                chart.render();
                return { chart: chart, gaugeValue: gaugeValue, gaugeMax: gaugeMax };
            } catch (err) {
                el.innerHTML = '<div class="dbp-empty">Erro ao renderizar gauge</div>';
                return null;
            }
        }

        // ===== FUNNEL =====
        if (isFunnel) {
            var funnelOptions = {
                chart: { type: 'bar', height: chartHeight, toolbar: { show: false }, background: 'transparent', fontFamily: ctx.fontFamily, redrawOnParentResize: false, redrawOnWindowResize: false },
                series: [{ name: 'Valor', data: values }],
                colors: paletteOpacity,
                plotOptions: { bar: { horizontal: true, borderRadius: 0, barHeight: '70%', distributed: true, isFunnel: true, dataLabels: { position: 'center' } } },
                dataLabels: { enabled: true, formatter: function (val, opt) { return labels[opt.dataPointIndex] + ': ' + fmtNum(val); }, style: { fontSize: fontSizes.funnelLabel, colors: ['#fff'], fontWeight: 600 }, dropShadow: { enabled: true, top: 1, left: 1, blur: 2, opacity: 0.5 } },
                xaxis: { categories: labels, labels: { show: false } },
                yaxis: { labels: { show: true, style: { colors: textColor, fontSize: fontSizes.axisLabel, fontWeight: 500 }, maxWidth: 150 } },
                grid: { padding: { left: 10, right: 20 } },
                legend: { show: chartConfig.showLegend !== false, position: 'bottom', fontSize: fontSizes.legendLabel, labels: { colors: textColor } },
                tooltip: { theme: isDark ? 'dark' : 'light', style: { fontSize: fontSizes.tooltipLabel }, y: { formatter: fmtNum } }
            };
            try {
                var chart = new ApexCharts(el, funnelOptions);
                chart.render();
                return { chart: chart };
            } catch (err) {
                el.innerHTML = '<div class="dbp-empty">Erro ao renderizar funnel</div>';
                return null;
            }
        }

        // ===== STACKED =====
        if (isStacked) {
            var stackByField = chartConfig.config.stackBy || chartConfig.config.seriesField;
            var groupByField = chartConfig.config.groupBy;
            var valueField = chartConfig.config.valueField || chartConfig.config.field || 'value';
            var stackedAggregation = chartConfig.config.aggregation || 'sum';
            var stackedData = {}, stackedCounts = {}, categories = [], seriesNames = [];
            var sourceData = chartConfig.rawData || chartConfig.data;

            if (!stackByField) {
                var stackedSeries = [{ name: 'Total', data: values }];
                categories = labels;
            } else {
                sourceData.forEach(function (item) {
                    var category = item[groupByField] || 'Outros';
                    var stackKey = item[stackByField] || 'Outros';
                    var val = parseFloat(item[valueField]) || 0;
                    if (categories.indexOf(category) === -1) categories.push(category);
                    if (seriesNames.indexOf(stackKey) === -1) seriesNames.push(stackKey);
                    if (!stackedData[stackKey]) { stackedData[stackKey] = {}; stackedCounts[stackKey] = {}; }
                    if (!stackedData[stackKey][category]) { stackedData[stackKey][category] = 0; stackedCounts[stackKey][category] = 0; }
                    stackedData[stackKey][category] += val;
                    stackedCounts[stackKey][category] += 1;
                });
                var stackedSeries = seriesNames.map(function (name) {
                    return {
                        name: name,
                        data: categories.map(function (cat) {
                            if (stackedAggregation === 'count') return stackedCounts[name][cat] || 0;
                            if (stackedAggregation === 'avg' && stackedCounts[name][cat] > 0) return (stackedData[name][cat] || 0) / stackedCounts[name][cat];
                            return stackedData[name][cat] || 0;
                        })
                    };
                });
            }

            var stackedOptions = {
                chart: { type: 'bar', height: chartHeight, stacked: true, toolbar: { show: false }, background: 'transparent', fontFamily: ctx.fontFamily, redrawOnParentResize: false, redrawOnWindowResize: false },
                series: stackedSeries, colors: paletteOpacity,
                plotOptions: { bar: { horizontal: isHorizontal, borderRadius: 4, borderRadiusApplication: 'end', borderRadiusWhenStacked: 'last', columnWidth: '55%' } },
                xaxis: { categories: categories, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, rotate: -45 } },
                yaxis: { forceNiceScale: true, decimalsInFloat: 0, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, formatter: yAxisFormatter } },
                legend: { show: true, position: 'top', fontSize: fontSizes.legendLabel, labels: { colors: textColor } },
                dataLabels: { enabled: false },
                tooltip: { theme: isDark ? 'dark' : 'light', style: { fontSize: fontSizes.tooltipLabel }, y: { formatter: fmtNum } }
            };
            try {
                var chart = new ApexCharts(el, stackedOptions);
                chart.render();
                return { chart: chart };
            } catch (err) {
                el.innerHTML = '<div class="dbp-empty">Erro ao renderizar stacked</div>';
                return null;
            }
        }

        // ===== MULTISERIES =====
        if (isMultiseries) {
            var seriesConfig = chartConfig.config.series || [];
            var multiChartType = chartConfig.config.chartType || 'line';
            var groupByField2 = chartConfig.config.groupBy;
            var multiSeries = [];
            var multiLabels = labels;
            var sourceData2 = chartConfig.rawData || [];

            if (seriesConfig.length > 0 && sourceData2.length > 0) {
                var groupedData = {};
                sourceData2.forEach(function (item) {
                    var key = item[groupByField2] || 'Outros';
                    if (!groupedData[key]) {
                        groupedData[key] = {};
                        seriesConfig.forEach(function (s) { groupedData[key][s.field] = 0; });
                    }
                    seriesConfig.forEach(function (s) { groupedData[key][s.field] += parseFloat(item[s.field]) || 0; });
                });
                multiLabels = Object.keys(groupedData);
                seriesConfig.forEach(function (s) {
                    multiSeries.push({ name: s.name || s.field, data: multiLabels.map(function (key) { return groupedData[key][s.field] || 0; }) });
                });
            } else if (seriesConfig.length > 0) {
                seriesConfig.forEach(function (s) {
                    multiSeries.push({ name: s.name || s.field, data: chartConfig.data.map(function (item) { return item[s.field] || item.value || 0; }) });
                });
            } else {
                multiSeries = [{ name: 'Valor', data: values }];
            }

            var multiOptions = {
                chart: { type: multiChartType, height: chartHeight, toolbar: { show: false }, background: 'transparent', fontFamily: ctx.fontFamily, redrawOnParentResize: false, redrawOnWindowResize: false },
                series: multiSeries, colors: paletteOpacity,
                plotOptions: { bar: { borderRadius: 4, columnWidth: '60%' } },
                stroke: multiChartType === 'line' ? { curve: 'smooth', width: 2 } : { width: 0 },
                xaxis: { categories: multiLabels, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, rotate: -45 } },
                yaxis: { forceNiceScale: true, decimalsInFloat: 0, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, formatter: yAxisFormatter } },
                legend: { show: true, position: 'top', fontSize: fontSizes.legendLabel, labels: { colors: textColor } },
                dataLabels: { enabled: false },
                tooltip: { theme: isDark ? 'dark' : 'light', style: { fontSize: fontSizes.tooltipLabel }, y: { formatter: fmtNum } },
                grid: { borderColor: gridColor, strokeDashArray: 3 }
            };
            try {
                var chart = new ApexCharts(el, multiOptions);
                chart.render();
                return { chart: chart };
            } catch (err) {
                el.innerHTML = '<div class="dbp-empty">Erro ao renderizar multiseries</div>';
                return null;
            }
        }

        // ===== STANDARD (bar, line, area, donut, pie) =====
        // v3.1.12: redrawOnParentResize/WindowResize: FALSE — sem isso o ApexCharts
        // instala SEU PRÓPRIO ResizeObserver em paralelo ao nosso, criando race
        // condition que gera NaN no transform/height do SVG (a "barata" 🪳).
        // Nosso ResizeObserver custom já cuida de redimensionamento.
        var options = {
            chart: { type: isDonut ? 'donut' : (isArea ? 'area' : (isLine ? 'line' : 'bar')), height: chartHeight, toolbar: { show: false }, background: 'transparent', animations: { enabled: true, easing: 'easeinout', speed: 500 }, fontFamily: ctx.fontFamily, redrawOnParentResize: false, redrawOnWindowResize: false },
            theme: { mode: isDark ? 'dark' : 'light' },
            colors: paletteOpacity,
            dataLabels: { enabled: chartConfig.showDataLabels || false, style: { fontSize: fontSizes.dataLabel } },
            tooltip: { theme: isDark ? 'dark' : 'light', style: { fontSize: fontSizes.tooltipLabel }, y: { formatter: fmtNum } },
            plotOptions: { bar: { horizontal: isHorizontal, borderRadius: 4, columnWidth: '60%', barHeight: '60%' } }
        };

        if (isDonut || isPie) {
            options.series = values;
            options.labels = labels;
            options.chart.type = isDonut ? 'donut' : 'pie';
            options.colors = paletteSolid;
            options.legend = { show: chartConfig.showLegend !== false, position: 'bottom', fontSize: fontSizes.legendLabel, labels: { colors: textColor } };
            options.plotOptions = { pie: { donut: { size: '65%' } } };
            options.stroke = { width: 2, colors: [isDark ? '#1e1e2e' : '#ffffff'] };
        } else {
            options.series = [{ name: 'Valor', data: values }];

            if (isBar && isHorizontal) {
                // v3.3.7: formatter numérico no X (valores). Y mantém categorias (nomes) sem formatter.
                options.xaxis = { categories: labels, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, formatter: yAxisFormatter }, axisBorder: { color: gridColor }, axisTicks: { color: gridColor } };
                options.yaxis = { labels: { show: true, style: { colors: textColor, fontSize: fontSizes.axisLabel }, maxWidth: 180 } };
            } else {
                options.xaxis = { categories: labels, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, rotate: -45, rotateAlways: labels.length > 5, maxHeight: 60, trim: true }, axisBorder: { color: gridColor }, axisTicks: { color: gridColor } };
                options.yaxis = { tickAmount: tickAmount, labels: { style: { colors: textColor, fontSize: fontSizes.axisLabel }, formatter: yAxisFormatter, minWidth: 50, maxWidth: 90 }, forceNiceScale: true, decimalsInFloat: 0 };
            }

            options.grid = { borderColor: gridColor, strokeDashArray: 3, padding: { left: 12, right: 12, top: 5, bottom: 8 } };
            options.legend = { show: false };

            if (isBar) {
                options.plotOptions = { bar: { horizontal: isHorizontal, borderRadius: 4, columnWidth: '60%' } };
                options.stroke = { width: 0 };
            }
            if (isLine || isArea) options.stroke = { curve: 'smooth', width: 2 };
            if (isArea) options.fill = { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.5, opacityTo: 0.1 } };
        }

        try {
            var chart = new ApexCharts(el, options);
            chart.render();
            return { chart: chart };
        } catch (e) {
            el.innerHTML = '<div class="dbp-empty">Erro ao renderizar gráfico</div>';
            return null;
        }
    };

    // Redimensionar chart existente com nova altura
    ChartEngine.resizeChart = function (ctx, chart, chartConfig, newHeight) {
        if (!newHeight || isNaN(newHeight) || newHeight < 50) return;
        if (!chart || typeof chart.updateOptions !== 'function') return;
        // v3.1.9 fix: garantir que o elemento ainda existe no DOM.
        // Sem isso, ApexCharts faz getComputedStyle(this.el)=null → cascata de NaN
        // quando timeout dispara após o chart ser destruído (dual render race).
        if (chartConfig && chartConfig.id) {
            var el = document.getElementById(chartConfig.id);
            if (!el || !document.body.contains(el)) return;
        }

        var isGauge = chartConfig.type === 'gauge';
        var isDonut = chartConfig.type === 'donut';
        var isPie = chartConfig.type === 'pie';
        var isFunnel = chartConfig.type === 'funnel';
        var isStacked = chartConfig.type === 'stacked';
        var isMultiseries = chartConfig.type === 'multiseries';
        var isHorizontal = chartConfig.orientation === 'horizontal';
        // v3.4.2: cor dos labels re-passada no resize (senão ApexCharts pode resetar)
        var labelColorR = (chartConfig.config && chartConfig.config.labelColor) || (ctx.theme === 'dark' ? '#ffffff' : '#666666');

        try {
            if (isGauge) {
                var el = document.getElementById(chartConfig.id);
                if (el) {
                    var containerWidth = el.offsetWidth || 200;
                    var minDimension = Math.min(containerWidth, newHeight);
                    var scale = Math.max(0.5, Math.min(1.2, minDimension / 180));
                    chart.updateOptions({
                        chart: { height: newHeight },
                        plotOptions: { radialBar: { dataLabels: { name: { fontSize: Math.round(14 * scale) + 'px', offsetY: Math.round(30 * scale) }, value: { fontSize: Math.round(24 * scale) + 'px', offsetY: Math.round(-10 * scale) } } } }
                    }, false, true);
                }
            } else if (isDonut || isPie || isFunnel) {
                // v3.1.13: atualizar fontes proporcionalmente ao tamanho
                var elDP = document.getElementById(chartConfig.id);
                var newFontSizesDP = ChartEngine._calcFontSizes(elDP ? elDP.offsetWidth : newHeight, newHeight, (chartConfig.config && parseInt(chartConfig.config.labelFontSize)) || 0);
                chart.updateOptions({
                    chart: { height: newHeight },
                    legend: { fontSize: newFontSizesDP.legendLabel },
                    dataLabels: { style: { fontSize: isFunnel ? newFontSizesDP.funnelLabel : newFontSizesDP.dataLabel } },
                    tooltip: { style: { fontSize: newFontSizesDP.tooltipLabel } }
                }, false, true);
            } else if (isHorizontal || isStacked || isMultiseries) {
                var elHSM = document.getElementById(chartConfig.id);
                var newFontSizesHSM = ChartEngine._calcFontSizes(elHSM ? elHSM.offsetWidth : newHeight, newHeight, (chartConfig.config && parseInt(chartConfig.config.labelFontSize)) || 0);
                var fmtHSM = ChartEngine._makeYAxisFormatter(chartConfig.format, ctx.locale, ctx.currency);
                var updateOptsHSM = {
                    chart: { height: newHeight },
                    legend: { fontSize: newFontSizesHSM.legendLabel },
                    tooltip: { style: { fontSize: newFontSizesHSM.tooltipLabel } }
                };
                // v3.3.7 fix: em HORIZONTAL, os valores estão no eixo X e as categorias
                // (ex: nomes de vendedores) no eixo Y. O formatter numérico vai no X.
                // Aplicá-lo no Y apagava os nomes (isNaN → '').
                if (isHorizontal) {
                    updateOptsHSM.xaxis = { labels: { style: { fontSize: newFontSizesHSM.axisLabel, colors: labelColorR }, formatter: fmtHSM } };
                    updateOptsHSM.yaxis = { labels: { style: { fontSize: newFontSizesHSM.axisLabel, colors: labelColorR } } };
                } else {
                    updateOptsHSM.xaxis = { labels: { style: { fontSize: newFontSizesHSM.axisLabel, colors: labelColorR } } };
                    updateOptsHSM.yaxis = { forceNiceScale: true, decimalsInFloat: 0, labels: { style: { fontSize: newFontSizesHSM.axisLabel, colors: labelColorR }, formatter: fmtHSM } };
                }
                chart.updateOptions(updateOptsHSM, false, true);
            } else {
                var newTickAmount = Utils.calculateTickAmount(newHeight);
                if (!newTickAmount || isNaN(newTickAmount)) newTickAmount = 5;
                var fmt = chartConfig.format;
                var elStd = document.getElementById(chartConfig.id);
                var newFontSizesStd = ChartEngine._calcFontSizes(elStd ? elStd.offsetWidth : newHeight, newHeight, (chartConfig.config && parseInt(chartConfig.config.labelFontSize)) || 0);
                chart.updateOptions({
                    chart: { height: newHeight },
                    xaxis: { labels: { style: { fontSize: newFontSizesStd.axisLabel, colors: labelColorR } } },
                    tooltip: { style: { fontSize: newFontSizesStd.tooltipLabel } },
                    yaxis: {
                        tickAmount: newTickAmount, decimalsInFloat: 0,
                        labels: {
                            style: { fontSize: newFontSizesStd.axisLabel, colors: labelColorR },
                            formatter: function (val) {
                                if (val === undefined || val === null || isNaN(val)) return '';
                                var num = Math.round(Number(val));
                                var isCurr = fmt === 'currency' || fmt === 'currency_brl';
                                var prefix = isCurr ? 'R$ ' : '';
                                if (Math.abs(num) >= 1000000) return prefix + (num / 1000000).toFixed(1) + 'M';
                                if (Math.abs(num) >= 1000) return prefix + (num / 1000).toFixed(0) + 'k';
                                return prefix + num.toLocaleString('pt-BR');
                            }
                        }
                    }
                }, false, true);
            }
        } catch (e) {
            console.warn('Dashboard: Erro ao redimensionar gráfico:', chartConfig.id, e);
        }
    };


    // =====================================================
    // EXPORTAR NAMESPACE PÚBLICO
    // =====================================================

    window.DBPEngine = {
        _version: CURRENT_VERSION,
        Utils: Utils,
        Storage: Storage,
        CSS: CSS,
        DataConverter: DataConverter,
        Color: Color,
        Templates: Templates,
        Renderers: Renderers,
        ChartEngine: ChartEngine
    };

})();
