PageSourceSearch

https://catellix.com/static/js/detector.js?v=68&_cb=1789769345

js catellix.com collected 2026-09-24 12:57:02 UTC 657,930 bytes, 15,674 lines download raw bytes

1/**
2 * Catellix Detector — catálogo (/api/detector/catalog) + resumo (/api/detector/summary) + modal com histórico.
3 */
4(function () {
5    /** Wi‑Fi MAP (e outras páginas leves): só inicia o pack de anúncios, sem o resto do Detector. */
6    var isMapaAdSlotsPage =
7        document.documentElement && document.documentElement.getAttribute('data-catellix-mapa-ad-slots') === '1';
8
9    var state = {
10        view: 'list',
11        filter: 'all',
12        /** Filtro DNS: all | v4 | dual | v6 */
13        ipStackFilter: 'all',
14        rankSort: 'live_problems',
15        catalog: null,
16        summary: null,
17        chart: null,
18        /** Janela do gráfico no modal (horas); alinhada com GET .../latency?hours= */
19        chartRangeHours: 168,
20        modalSvcId: null,
21        modalDetailData: null,
22        probeIntervalSec: 60,
23        communityWindowDays: 7,
24        /** Janela do ranking comunidade: 1/7/15/30 dias civis desde 0h (Brasília); padrão 1 dia. */
25        complaintsRankingDays: 1,
26        communityDisclaimerPt: '',
27        crSvcId: null,
28        /** ID do comentário público a que se responde (um nível); null = relato normal. */
29        crParentReportId: null,
30        crVerifyToken: null,
31        communityFeedFilter: 'all',
32        communityFeedReplyParentId: null,
33        communityFeedVerifyToken: null,
34        communityFeedAnonToken: null,
35        rankExpanded: {
36            latFast: false,
37            latSlow: false,
38            incidents: false,
39            trends: false,
40            games: false,
41            community: false,
42        },
43        /** Lista completa do ranking comunidade (após último GET). undefined = ainda não carregado; null = erro. */
44        communityRankingRows: undefined,
45        /** Posição anterior por service_id (1-based) para setas ↑↓ no ranking comunidade. */
46        communityRankPrev: {},
47        /** Snapshot antes do último `communityRankPrev` (mobile + sidebar usam nas setas). */
48        _complaintsRankTrendPrev: {},
49        /** Motivos de relato (/api/detector/report-reasons). */
50        reportReasons: [],
51        /** Google OAuth client id (relatos com verificação Google). */
52        googleClientId: '',
53        /** Rankings: 'bad' = incidentes / latência alta / tendências;
53 'good' = latência baixa + jogos (ordenados OK primeiro). */
54        rankMood: 'bad',
55        /** eco de cat.probe_profile (ICMP paralelo, POPs, etc.) */
56        probeProfile: {},
57        /** Limites e tiers (eco de /api/detector/catalog latency_class_config) */
58        latencyClassConfig: {},
59        /** Filtro de texto no catálogo (nome, id, descrição, domínio) */
60        catalogSearch: '',
61        /** true enquanto loadAll() está em curso (contagem de auto-refresh em pausa). */
62        detectorLoadInProgress: false,
63        /** Atualização automática da página (ms); padrão 1 min */
64        refreshIntervalMs: 60000,
65        /** Última janela (dias) para a qual `communityRankingRows` foi obtida do servidor; evita GET duplicado após prefetch paralelo. */
66        _fetchedCommunityRankingDays: null,
67        /** Limite de linhas pedido na última resposta de community-ranking (alinhado a RANK_FULL_MAX). */
68        _fetchedCommunityRankingLimit: null,
69        /**
70         * Mapa canónico logo_domain → URL (GET /api/detector/logo-manifest).
71         * Prioriza o mesmo ícone correcto no PDF e no cartão social.
72         */
73        logoManifestByDomain: null,
74        _logoManifestPromise: null,
75        /** Eco de cat.ad_slots { total, available } para UI (faixa + modal). */
76        detectorAdSlots: null,
77        /** Tamanho do conjunto de anúncios em destaque (API local-ad/next). */
78        localAdPoolSize: 0,
79        /** Slots da faixa com criativo fixo (catálogo → local_ads_carousel). */
80        detectorCarouselSlotAds: [],
81        _adZoneDismissedL: false,
82        _adZoneDismissedR: false,
83        _adZoneSupportUiL: false,
84        _adZoneSupportUiR: false,
85        _topBarDismissed: false,
86    };
87
88    /** Tradução UI (PT padrão / EN) — chaves em `detector-i18n.js`. */
89    function __(k) {
90        if (typeof window.detectorT === 'function') return window.detectorT(k);
91        return k;
92    }
93
94    /** Carrega a base canónica de logos (espelho de LOGO_URL_BY_DOMAIN no servidor). */
95    function fetchDetectorLogoManifest() {
96        if (state._logoManifestPromise) return state._logoManifestPromise;
97        var origin = '';
98        try {
99            origin = window.location.origin || '';
100        } catch (eO) {}
101        var manifestUrl = origin + '/api/detector/logo-manifest';
102        var manifestFetch = fetch(manifestUrl, { credentials: 'omit' });
103        var manifestDeadline = new Promise(function (_, reject) {
104            setTimeout(function () {
105                reject(new Error('manifest-timeout'));
106            }, DET_T10_SOCIAL_MANIFEST_MS);
107        });
108        state._logoManifestPromise = Promise.race([manifestFetch, manifestDeadline])
109            .then(function (r) {
110                if (!r || !r.ok) throw new Error('manifest-http');
111                return r.json();
112            })
113            .then(function (j) {
114                if (j && j.success && j.by_domain && typeof j.by_domain === 'object') {
115                    state.logoManifestByDomain = j.by_domain;
116                } else {
117                    state.logoManifestByDomain = {};
118                }
119                return state.logoManifestByDomain;
120            })
121            .catch(function () {
122                state.logoManifestByDomain = {};
123                return state.logoManifestByDomain;
124            });
125        return state._logoManifestPromise;
126    }
127
128    function catLabel(cat) {
129        var id = cat || 'core';
130        var k = 'cat_' + id;
131        var s = __(k);
132        return s !== k ? s : id;
133    }
134
135    function categoryBlurb(cat) {
136        var id = cat || 'core';
137        var k = 'blurb_' + id;
138        var s = __(k);
139        return s !== k ? s : '';
140    }
141
142    function detectorCommunityAnonToken() {
143        if (state.communityFeedAnonToken) return state.communityFeedAnonToken;
144        var key = 'detector_public_anon_token_v1';
145        try {
146            var cur = localStorage.getItem(key);
147            if (cur && cur.length >= 12) {
148                state.communityFeedAnonToken = cur;
149                return cur;
150            }
151        } catch (e0) {}
152        var made = 'anon_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 12);
153        state.communityFeedAnonToken = made;
154        try {
155            localStorage.setItem(key, made);
156        } catch (e1) {}
157        return made;
158    }
159
160    function i18nSortLocale() {
161        return typeof window.detectorGetLocale === 'function' && window.detectorGetLocale() === 'en'
162            ? 'en'
163            : 'pt';
164    }
165
166    var DETECTOR_REFRESH_MS_KEY = 'detector_refresh_interval_ms';
167    var COMPLAINTS_RANKING_DAYS_KEY = 'detector_complaints_ranking_days';
168    var DETECTOR_THEME_KEY = 'detector_theme';
169    /** Stale-while-revalidate: mostrar último catálogo da sessão enquanto /catalog refetch (TTL de exibição). */
170    var DETECTOR_CATALOG_SWR_KEY = 'detector_catalog_swr_v2';
171    var CATALOG_SWR_MAX_DISPLAY_AGE_MS = 2 * 60 * 60 * 1000;
172    /** Ranking comunidade (localStorage): mostrar último snapshot à entrada; GET atualiza sem skeleton. */
173    var COMMUNITY_RANKING_SWR_KEY = 'detector_community_ranking_swr_v1';
174    var COMMUNITY_RANKING_SWR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
175    var detReportPickerEscBound = false;
176    var detComplaintsHourlyEscBound = false;
177    var detComplaintsHourlyFetchGen = 0;
178    /** Último relatório aberto no modal (top10 | top20 | top30) — partilha/PDF coerentes. */
179    var detReportModalScope = 'top30';
180    /** Logo Catellix para o cartão PNG do Top 10 (carregado uma vez). */
181    var detTop10SocialBrandLogoImg = null;
182    var detTop10SocialBrandLogoPromise = null;
183    /** Timeouts do cartão social Top 10 — evita modal preso em rede lenta ou fetch pendente. */
184    /** Manifest: rede lenta não deve abortar antes da resposta (antes: 8s cortava e esvaziava o mapa de logos). */
185    var DET_T10_SOCIAL_MANIFEST_MS = 22000;
186    /** Cada URL de ícone (via proxy); várias tentativas em cadeia precisam caber em ROW_LOGO_MS. */
187    var DET_T10_SOCIAL_ICON_FETCH_MS = 4000;
188    /** Uma linha do Top 10: DOM + várias URLs; 8s esgotava antes do proxy tentar Wikimedia/favicons. */
189    var DET_T10_SOCIAL_ROW_LOGO_MS = 52000;
190    var DET_T10_SOCIAL_BRAND_IMG_MS = 8000;
191
192    /* ── Logo Blob Cache (IndexedDB) ─────────────────────────────────────
193       Persiste blobs de logos entre sessões para que o cartão social abra
194       instantaneamente na segunda vez. TTL de 48h por entrada.           */
195    var _logoCacheDB = null;
196    var _logoCacheDBReady = false;
197    var _LOGO_CACHE_DB = 'det_logo_cache_v1';
198    var _LOGO_CACHE_STORE = 'blobs';
199    var _LOGO_CACHE_TTL = 48 * 60 * 60 * 1000;
200
201    function _openLogoCacheDB() {
202        if (_logoCacheDB) return Promise.resolve(_logoCacheDB);
203        if (typeof indexedDB === 'undefined') return Promise.reject();
204        return new Promise(function (resolve, reject) {
205            try {
206                var req = indexedDB.open(_LOGO_CACHE_DB, 1);
207                req.onupgradeneeded = function (e) {
208                    var db = e.target.result;
209                    if (!db.objectStoreNames.contains(_LOGO_CACHE_STORE)) {
210                        db.createObjectStore(_LOGO_CACHE_STORE, { keyPath: 'url' });
211                    }
212                };
213                req.onsuccess = function (e) {
214                    _logoCacheDB = e.target.result;
215                    _logoCacheDBReady = true;
216                    resolve(_logoCacheDB);
217                };
218                req.onerror = reject;
219            } catch (ex) { reject(ex); }
220        });
221    }
222
223    function logoCacheGet(url) {
224        return _openLogoCacheDB().then(function (db) {
225            return new Promise(function (resolve, reject) {
226                try {
227                    var tx = db.transaction(_LOGO_CACHE_STORE, 'readonly');
228                    var req = tx.objectStore(_LOGO_CACHE_STORE).get(url);
229                    req.onsuccess = function () {
230                        var row = req.result;
231                        if (!row || !row.blob) return resolve(null);
232                        if (Date.now() - (row.ts || 0) > _LOGO_CACHE_TTL) return resolve(null);
233                        resolve(row.blob);
234                    };
235                    req.onerror = function () { resolve(null); };
236                } catch (ex) { resolve(null); }
237            });
238        }).catch(function () { return null; });
239    }
240
241    function logoCachePut(url, blob) {
242        _openLogoCacheDB().then(function (db) {
243            try {
244                var tx = db.transaction(_LOGO_CACHE_STORE, 'readwrite');
245                tx.objectStore(_LOGO_CACHE_STORE).put({ url: url, blob: blob, ts: Date.now() });
246            } catch (ex) {}
247        }).catch(function () {});
248    }
249
250    function blobToImage(blob) {
251        return new Promise(function (resolve, reject) {
252            var ou = URL.createObjectURL(blob);
253            var im = new Image();
254            im.onload = function () { URL.revokeObjectURL(ou);
254 im.naturalWidth > 0 ? resolve(im) : reject(); };
255            im.onerror = function () { URL.revokeObjectURL(ou); reject(); };
256            im.src = ou;
257        });
258    }
259
260    function getDetTheme() {
261        var a = document.documentElement.getAttribute('data-det-theme');
262        return a === 'dark' ? 'dark' : 'light';
263    }
264
265    function setDetTheme(theme) {
266        if (theme !== 'dark' && theme !== 'light') theme = 'light';
267        document.documentElement.setAttribute('data-det-theme', theme);
268        try {
269            localStorage.setItem(DETECTOR_THEME_KEY, theme);
270        } catch (e) {
271            /* ignore */
272        }
273        updateDetThemeToggleUi();
274        refreshDetectorChartsAfterThemeChange();
275    }
276
277    /** Recria o gráfico do modal de serviço para aplicar cores do tema (Chart.js). */
278    function refreshDetectorChartsAfterThemeChange() {
279        try {
280            if (state.chart && state.modalSvcId) {
281                reloadModalChartFromControls();
282            }
283        } catch (e) {
284            /* ignore */
285        }
286    }
287
288    function updateDetThemeToggleUi() {
289        var btn = document.getElementById('detThemeToggle');
290        if (!btn) return;
291        var dark = getDetTheme() === 'dark';
292        btn.setAttribute('aria-pressed', dark ? 'true' : 'false');
293        btn.textContent = dark ? '☀' : '🌙';
294        var goDark = __('theme_goto_dark');
295        var goLight = __('theme_goto_light');
296        btn.setAttribute('title', dark ? goLight : goDark);
297        btn.setAttribute('aria-label', dark ? goLight : goDark);
298    }
299
300    function wireDetThemeToggle() {
301        var btn = document.getElementById('detThemeToggle');
302        if (!btn || btn.dataset.detectorThemeWired === '1') return;
303        btn.dataset.detectorThemeWired = '1';
304        btn.addEventListener('click', function () {
305            setDetTheme(getDetTheme() === 'dark' ? 'light' : 'dark');
306        });
307        updateDetThemeToggleUi();
308    }
309
310    /** Em viewports estreitas: Top 10 comunidade no hero, tendências inline, quatro rankings (top10Boards) antes do catálogo; painel «Rankings rápidos» do details fica só no desktop. */
311    var DET_COMPLAINTS_VIEWPORT_MQ = window.matchMedia('(max-width: 1023px)');
312    var _detTop10CardsViewportNarrow = DET_COMPLAINTS_VIEWPORT_MQ.matches;
313
314    /* ── Lazy-load por IntersectionObserver ──
315     * Seções pesadas só renderizam quando perto do viewport.
316     * Se os dados chegam mas a seção não está visível, marcamos "dirty".
317     * Quando o observer dispara, renderiza se dirty.
318     */
319    var _lazyRegistry = {};
320    var _hasIO = typeof IntersectionObserver === 'function';
321    function registerLazySection(hostId, renderFn) {
322        if (_lazyRegistry[hostId]) return;
323        var entry = { dirty: false, rendered: false, fn: renderFn, observer: null };
324        _lazyRegistry[hostId] = entry;
325        if (!_hasIO) return;
326        var el = document.getElementById(hostId);
327        if (!el) return;
328        entry.observer = new IntersectionObserver(function (entries) {
329            for (var i = 0; i < entries.length; i++) {
330                if (entries[i].isIntersecting && entry.dirty) {
331                    entry.dirty = false;
332                    entry.rendered = true;
333                    try { entry.fn(); } catch (e) { console.warn('[det-lazy]', hostId, e); }
334                }
335            }
336        }, { rootMargin: '400px 0px' });
337        entry.observer.observe(el);
338    }
339    function markLazySectionDirty(hostId) {
340        var entry = _lazyRegistry[hostId];
341        if (!entry) return false;
342        entry.dirty = true;
343        if (!_hasIO) { try { entry.fn(); } catch (e) {} return true; }
344        var el = document.getElementById(hostId);
345        if (el) {
346            var rect = el.getBoundingClientRect();
347            var near = rect.top < (window.innerHeight + 400);
348            if (near) {
349                entry.dirty = false;
350                entry.rendered = true;
351                try { entry.fn(); } catch (e) { console.warn('[det-lazy]', hostId, e); }
352            }
353        }
354        return true;
355    }
356
357    /** Antigo: movia a secção inteira para dentro de Rankings. Agora o Top 10 mobile está só no hero. */
358    function relocateDetComplaintsSectionForViewport() {}
359
360    function relocateComplaintsToolbarForViewport() {
361        var tb = document.getElementById('detSidebarComplaintsToolbar');
362        var host = document.getElementById('detMobileComplaintsToolbarHost');
363        var sec = document.getElementById('detSidebarComplaintsSection');
364        if (!tb || !host || !sec) return;
365        try {
366            if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
367                if (tb.parentElement !== host) {
368                    host.appendChild(tb);
369                    host.removeAttribute('hidden');
370                }
371            } else {
372                var head = sec.querySelector('.det-sidebar-section-head');
373                if (tb.parentElement === host && head) {
374                    if (head.nextSibling) {
375                        sec.insertBefore(tb, head.nextSibling);
376                    } else {
377                        sec.appendChild(tb);
378                    }
379                }
380                host.setAttribute('hidden', '');
381            }
382        } catch (e) {
383            /* ignore */
384        }
385    }
386
387    function relocateRankPanelsForViewport() {
388        var wrap = document.getElementById('rankSectionWrap');
389        var mount = document.getElementById('detMobileRankPanelsMount');
390        var detailsBody = document.querySelector('#detDashRank .det-dash-body');
391        if (!wrap || !detailsBody) return;
392        try {
393            if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
394                if (mount && wrap.parentElement === mount) {
395                    detailsBody.insertBefore(wrap, detailsBody.firstChild);
396                }
397                return;
398            }
399            if (wrap.parentElement !== detailsBody) {
400                detailsBody.insertBefore(wrap, detailsBody.firstChild);
401            }
402        } catch (e2) {
403            /* ignore */
404        }
405    }
406
407    function relocateCatalogSearchForViewport() {
408        var bar = document.getElementById('catalogSearchBar');
409        var head = document.querySelector('.block-head--catalog');
410        var shelfSlot = document.getElementById('catalogSearchShelfSlot');
411        if (!bar || !head) return;
412        try {
413            if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
414                if (bar.parentElement !== head) head.appendChild(bar);
415            } else if (shelfSlot) {
416                if (bar.parentElement !== shelfSlot) shelfSlot.appendChild(bar);
417            } else if (bar.parentElement !== head) {
418                head.appendChild(bar);
419            }
420        } catch (e3) {
421            /* ignore */
422        }
423    }
424
425    function relocateTop10BoardsForViewport() {
426        var boards = document.getElementById('top10Boards');
427        var slot = document.getElementById('detMobileTopFiveSlot');
428        var shelves = document.getElementById('secShelves');
429        var cards = document.getElementById('top10Cards');
430        if (!boards || !shelves || !cards) return;
431        try {
432            if (DET_COMPLAINTS_VIEWPORT_MQ.matches && slot) {
433                if (boards.parentElement !== slot) {
434                    slot.appendChild(boards);
435                }
436            } else {
437                if (boards.parentElement !== shelves) {
438                    shelves.insertBefore(boards, cards);
439                } else if (boards.nextElementSibling !== cards) {
440                    shelves.insertBefore(boards, cards);
441                }
442            }
443        } catch (eB) {
444            /* ignore */
445        }
446    }
447
448    function syncDetectorMobileFlowLayout() {
449        relocateComplaintsToolbarForViewport();
450        relocateRankPanelsForViewport();
451        relocateCatalogSearchForViewport();
452        relocateTop10BoardsForViewport();
453        placeRefreshToolbarForViewport();
454        try {
455            initDetectorTopBarLocalSpin();
456        } catch (eSpinVp) {
457            /* ignore */
458        }
459        try {
460            mountMobileGamesBoardClone();
461        } catch (eMg) {
462            /* ignore */
463        }
464    }
465
466    function wireDetComplaintsViewportRelocate() {
467        if (!document.getElementById('detSidebarComplaintsSection')) return;
468        syncDetectorMobileFlowLayout();
469        if (typeof DET_COMPLAINTS_VIEWPORT_MQ.addEventListener === 'function') {
470            DET_COMPLAINTS_VIEWPORT_MQ.addEventListener('change', syncDetectorMobileFlowLayout);
471        } else if (typeof DET_COMPLAINTS_VIEWPORT_MQ.addListener === 'function') {
472            DET_COMPLAINTS_VIEWPORT_MQ.addListener(syncDetectorMobileFlowLayout);
473        }
474        window.addEventListener('resize', syncDetectorMobileFlowLayout, { passive: true });
475    }
476
477    /** Em ≤1023px, coloca intervalo + ↻ acima da barra de carregamento e do Top 10 comunidade. */
478    function placeRefreshToolbarForViewport() {
479        var slot = document.getElementById('detRefreshToolbarSlot');
480        var tb = document.getElementById('toolbarCompact');
481        var hero = document.getElementById('detMobileCommunityHero');
482        var loading = document.getElementById('detectorLoading');
483        if (!slot || !tb || !hero) return;
484        try {
485            if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
486                var anchor =
487                    loading && loading.parentNode === hero.parentNode ? loading : hero;
488                if (slot.nextElementSibling !== anchor) {
489                    anchor.parentNode.insertBefore(slot, anchor);
490                }
491            } else if (slot.parentNode !== tb || slot !== tb.lastElementChild) {
492                tb.appendChild(slot);
493            }
494        } catch (e) {
495            /* ignore */
496        }
497    }
498
499    function wireDetRefreshToolbarPlacement() {
500        if (!document.getElementById('detRefreshToolbarSlot')) return;
501        placeRefreshToolbarForViewport();
502        if (typeof DET_COMPLAINTS_VIEWPORT_MQ.addEventListener === 'function') {
503            DET_COMPLAINTS_VIEWPORT_MQ.addEventListener('change', placeRefreshToolbarForViewport);
504        } else if (typeof DET_COMPLAINTS_VIEWPORT_MQ.addListener === 'function') {
505            DET_COMPLAINTS_VIEWPORT_MQ.addListener(placeRefreshToolbarForViewport);
506        }
507        window.addEventListener('resize', placeRefreshToolbarForViewport, { passive: true });
508    }
509
510    window.detectorRefreshThemeToggleI18n = updateDetThemeToggleUi;
511
512    var VALID_RANKING_DAYS = [1, 7, 15, 30];
513    /** Linhas pedidas ao GET community-ranking (Top 10/30 embed + painel até «ranking completo»). */
514    var COMMUNITY_RANKING_API_LIMIT = 60;
515    function communityRankingApiQuery(days) {
516        var d = VALID_RANKING_DAYS.indexOf(days) !== -1 ? days : 1;
517        return (
518            'days=' +
519            encodeURIComponent(String(d)) +
520            '&limit=' +
521            encodeURIComponent(String(COMMUNITY_RANKING_API_LIMIT))
522        );
523    }
524
525    function readCommunityRankingSwrCache(days, limit) {
526        try {
527            var raw = localStorage.getItem(COMMUNITY_RANKING_SWR_KEY);
528            if (!raw) return null;
529            var o = JSON.parse(raw);
530            if (!o || o.v !== 1 || typeof o.t !== 'number' || !o.payload) return null;
531            if (Date.now() - o.t > COMMUNITY_RANKING_SWR_MAX_AGE_MS) return null;
532            var p = o.payload;
533            if (!p || p.success !== true || !Array.isArray(p.ranking)) return null;
534            if (p.days !== days || p.limit !== limit) return null;
535            return p;
536        } catch (e) {
537            return null;
538        }
539    }
540
541    function writeCommunityRankingSwrCache(data) {
542        if (!data || data.success !== true || !Array.isArray(data.ranking)) return;
543        var d = data.days;
544        var lim = data.limit;
545        if (typeof d !== 'number' || typeof lim !== 'number') return;
546        try {
547            localStorage.setItem(
548                COMMUNITY_RANKING_SWR_KEY,
549                JSON.stringify({
550                    v: 1,
551                    t: Date.now(),
552                    payload: {
553                        success: true,
554                        days: d,
555                        limit: lim,
556                        ranking: data.ranking,
557                        disclaimer_pt: data.disclaimer_pt != null ? String(data.disclaimer_pt) : '',
558                        next_reset_utc: data.next_reset_utc != null ? String(data.next_reset_utc) : '',
559                        window_timezone: data.window_timezone,
560                        hours_window: data.hours_window,
561                    },
562                })
563            );
564        } catch (e) {
565            /* quota */
566        }
567    }
568
569    /** Aplica último ranking da storage para pintura imediata (antes do catálogo). Retorna true se havia dados. */
570    function hydrateCommunityRankingFromSwrCache(days) {
571        var d = VALID_RANKING_DAYS.indexOf(days) !== -1 ? days : 1;
572        var disk = readCommunityRankingSwrCache(d, COMMUNITY_RANKING_API_LIMIT);
573        if (!disk) return false;
574        state.communityRankingRows = disk.ranking;
575        state._fetchedCommunityRankingDays = d;
576        state._fetchedCommunityRankingLimit = COMMUNITY_RANKING_API_LIMIT;
577        if (disk.disclaimer_pt) state.communityDisclaimerPt = disk.disclaimer_pt;
578        if (disk.next_reset_utc) scheduleCommunityRankingMidnightRefresh(disk.next_reset_utc);
579        var discEl = document.getElementById('rankCommunityDisclaimer');
580        if (discEl && disk.disclaimer_pt) discEl.textContent = disk.disclaimer_pt;
581        return true;
582    }
583
584    function readComplaintsRankingDays() {
585        try {
586            var x = parseInt(localStorage.getItem(COMPLAINTS_RANKING_DAYS_KEY), 10);
587            if (VALID_RANKING_DAYS.indexOf(x) !== -1) return x;
588        } catch (e) {
589            /* ignore */
590        }
591        return 1;
592    }
593
594    function persistComplaintsRankingDays(d) {
595        try {
596            localStorage.setItem(COMPLAINTS_RANKING_DAYS_KEY, String(d));
597        } catch (e) {
598            /* ignore */
599        }
600    }
601
602    function updateHeroTagline() {
603        var el = document.getElementById('heroTagline');
604        if (!el) return;
605        var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
606        var periodMap = {
607            1: __('js_comm_period_24h'),
608            7: __('js_comm_period_7d'),
609            15: __('js_comm_period_15d'),
610            30: __('js_comm_period_30d'),
611        };
612        var p = periodMap[days] || '';
613        el.textContent = __('hero_tagline_tpl').replace(/\{p\}/g, p);
614    }
615
616    function applyCommunityRankingToCatalog(rows) {
617        if (!state.catalog || !state.catalog.length || !Array.isArray(rows)) return;
618        var map = {};
619        for (var i = 0; i < rows.length; i++) {
620            var r = rows[i];
621            if (r && r.service_id) map[String(r.service_id)] = r;
622        }
623        for (var j = 0; j < state.catalog.length; j++) {
624            var s = state.catalog[j];
625            var row = map[String(s.id)];
626            if (!row) continue;
627            var vc = Number(row.vote_count) || 0;
628            s.community_vote_count = vc;
629            var vuRaw = row.vote_count_verified;
630            var vsRaw = row.vote_count_signals;
631            var vu = vuRaw != null && vuRaw !== '' && !isNaN(Number(vuRaw)) ? Number(vuRaw) : vc;
632            var vs = vsRaw != null && vsRaw !== '' && !isNaN(Number(vsRaw)) ? Number(vsRaw) : 0;
633            s.community_vote_count_verified = vu;
634            s.community_vote_count_signals = vs;
635            var vah = row.vote_avg_per_hour;
636            if (vah != null && vah !== '' && !isNaN(Number(vah))) {
637                s.community_vote_avg_per_hour = Number(vah);
638            } else {
639                s.community_vote_avg_per_hour = null;
640            }
641            var vl = row.volume_last_30m;
642            var vp = row.volume_prev_30m;
643            s.community_volume_last_30m =
644                vl != null && vl !== '' && !isNaN(Number(vl)) ? Number(vl) : null;
645            s.community_volume_prev_30m =
646                vp != null && vp !== '' && !isNaN(Number(vp)) ? Number(vp) : null;
647        }
648    }
649
650    function refreshCommunityRankingSilently() {
651        if (!document.getElementById('detComplaintsTop10')) return;
652        if (state.detectorLoadInProgress) return;
653        var origin = window.location.origin || '';
654        var d = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
655        fetch(origin + '/api/detector/community-ranking?' + communityRankingApiQuery(d), { credentials: 'omit' })
656            .then(function (r) {
657                return r.json().then(function (data) {
658                    return { ok: r.ok, data: data };
659                });
660            })
661            .then(function (pack) {
662                var data = pack.data || {};
663                if (!pack.ok || !data.success) return;
664                writeCommunityRankingSwrCache(data);
665                state.communityRankingRows = data.ranking || [];
666                scheduleCommunityRankingMidnightRefresh(data.next_reset_utc);
667                if (data.disclaimer_pt) state.communityDisclaimerPt = data.disclaimer_pt;
668                applyCommunityRankingToCatalog(state.communityRankingRows);
669                var disc = document.getElementById('rankCommunityDisclaimer');
670                if (disc && data.disclaimer_pt) disc.textContent = data.disclaimer_pt;
671                renderCommunityRankingOl();
672                syncComplaintsTop10BoardsAndCards();
673                refreshComplaintsTrendSparklines(document);
674                try { prefetchTop10Logos(); } catch (ePf) {}
675                try {
676                    renderTop30List();
677                } catch (eT30s) {}
678            })
679            .catch(function () {});
680    }
681
682    function startCommunityRankingPoll() {
683        if (communityRankingPollTimer != null) clearInterval(communityRankingPollTimer);
684        communityRankingPollTimer = setInterval(function () {
685            refreshCommunityRankingSilently();
686        }, COMMUNITY_RANKING_POLL_MS);
687    }
688
689    function syncSidebarComplaintsToolbarLabels() {
690        var sel = document.getElementById('selSidebarComplaintsRankingDays');
691        if (sel) {
692            var labelMap = { '1': __('js_window_1d'), '7': __('js_window_7d'), '15': __('js_window_15d'), '30': __('js_window_30d') };
693            for (var oi = 0; oi < sel.options.length; oi++) {
694                var lbl = labelMap[sel.options[oi].value];
695                if (lbl) sel.options[oi].textContent = lbl;
696            }
697        }
698        var live = document.getElementById('detComplaintsSyncHint');
699        if (live) {
700            live.textContent = __('sidebar_rank_live');
701            live.setAttribute('title', __('sidebar_rank_live_title'));
702        }
703    }
704
705    function wireSidebarComplaintsRanking() {
706        var sel = document.getElementById('selSidebarComplaintsRankingDays');
707        if (!sel || sel.dataset.detWired === '1') return;
708        sel.dataset.detWired = '1';
709        syncSidebarComplaintsToolbarLabels();
710        sel.value = String(VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1);
711        sel.addEventListener('change', function () {
712            var v = parseInt(sel.value, 10);
713            if (VALID_RANKING_DAYS.indexOf(v) === -1) v = 1;
714            state.complaintsRankingDays = v;
715            state.communityRankPrev = {};
716            state._complaintsRankTrendPrev = {};
717            updateHeroTagline();
718            persistComplaintsRankingDays(v);
719            mountCommunityRanking();
720        });
721    }
722    var detectorLoadAllGen = 0;
723    var AUTO_REFRESH_ALLOWED_MS = [30000, 45000, 60000, 180000, 300000];
724    /** Timestamp (ms) em que deve correr a próxima tentativa de auto-refresh. */
725    var refreshDeadlineAt = 0;
726    var countdownTimerId = null;
727    /** Atualização leve só do ranking comunidade (lateral + contagens no catálogo), sem recarregar todo o catálogo. Alinhado ao ciclo ~20 min do feed. */
728    var COMMUNITY_RANKING_POLL_MS = 1200000;
729    var communityRankingPollTimer = null;
730    var communityRankingMidnightTimer = null;
731
732    /** Após a API devolver next_reset_utc, agenda um refresh na meia-noite Brasília (novo dia civil = ranking do «1 dia» recomeça). */
733    function scheduleCommunityRankingMidnightRefresh(nextResetIso) {
734        if (communityRankingMidnightTimer != null) {
735            clearTimeout(communityRankingMidnightTimer);
736            communityRankingMidnightTimer = null;
737        }
738        if (!nextResetIso || typeof nextResetIso !== 'string') return;
739        var t = Date.parse(nextResetIso);
740        if (isNaN(t)) return;
741        var delay = t - Date.now() + 1200;
742        if (delay < 2000) delay = 2000;
743        if (delay > 172800000) delay = 86400000;
744        communityRankingMidnightTimer = setTimeout(function () {
745            communityRankingMidnightTimer = null;
746            if (!document.getElementById('detComplaintsTop10')) return;
747            refreshCommunityRankingSilently();
748        }, delay);
749    }
750    /** Evita aplicar resposta antiga se outro fetch do mesmo serviço foi disparado depois. */
751    var complaintsTrendFetchGen = {};
752
753    function communityTop10ServiceIds() {
754        var rows = state.communityRankingRows;
755        if (!rows || !rows.length) return [];
756        var out = [];
757        for (var i = 0; i < rows.length && out.length < 10; i++) {
758            if ((Number(rows[i].vote_count) || 0) < 1) continue;
759            if (rows[i].service_id) out.push(rows[i].service_id);
760        }
761        return out;
762    }
763
764    function refreshComplaintsTrendSparklines(root) {
765        var scope = root && root.querySelectorAll ? root : document;
766        var top = communityTop10ServiceIds();
767        var topSet = {};
768        for (var ti = 0; ti < top.length; ti++) topSet[top[ti]] = true;
769        var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
770        var origin = window.location.origin || '';
771        var bySid = {};
772        var nodes = scope.querySelectorAll('.tile-complaints-trend-wrap[data-complaints-trend]');
773        for (var ni = 0; ni < nodes.length; ni++) {
774            var el = nodes[ni];
775            var sid = el.getAttribute('data-complaints-trend');
776            if (!sid) continue;
777            if (!topSet[sid]) {
778                el.innerHTML = '';
779                el.setAttribute('hidden', '');
780                el.setAttribute('aria-hidden', 'true');
781                continue;
782            }
783            if (!bySid[sid]) bySid[sid] = [];
784            bySid[sid].push(el);
785        }
786        Object.keys(bySid).forEach(function (sid) {
787            var els = bySid[sid];
788            var gen = (complaintsTrendFetchGen[sid] || 0) + 1;
789            complaintsTrendFetchGen[sid] = gen;
790            var url =
791                origin +
792                '/api/detector/service/' +
793                encodeURIComponent(sid) +
794                '/community-hourly?days=' +
795                encodeURIComponent(String(days)) +
796                '&hours=72&require_top10=true';
797            fetch(url, { credentials: 'omit' })
798                .then(function (r) {
799                    return r.json().then(function (data) {
800                        return { ok: r.ok, data: data };
801                    });
802                })
803                .then(function (pack) {
804                    if (complaintsTrendFetchGen[sid] !== gen) return;
805                    var data = pack.data || {};
806                    var html = '';
807                    if (
808                        pack.ok &&
809                        data.success &&
810                        data.in_top10 &&
811                        communityTop10ServiceIds().indexOf(sid) >= 0
812                    ) {
813                        var pts = (data.points || []).map(function (p) {
814                            return Number(p.n);
815                        });
816                        html =
817                            '<div class="tile-complaints-trend-inner" title="' +
818                            esc(__('js_tile_complaints_trend_title')) +
819                            '">' +
820                            '<span class="tile-complaints-trend-label">' +
821                            esc(__('js_tile_complaints_trend_label')) +
822                            '</span>' +
823                            complaintsSparklineSvg(pts, null) +
824                            '</div>';
825                    }
826                    for (var ei = 0; ei < els.length; ei++) {
827                        var node = els[ei];
828                        if (!node || !node.parentNode) continue;
829                        if (!html) {
830                            node.innerHTML = '';
831                            node.setAttribute('hidden', '');
832                            node.setAttribute('aria-hidden', 'true');
833                        } else {
834                            node.innerHTML = html;
835                            node.removeAttribute('hidden');
836                            node.setAttribute('aria-hidden', 'false');
837                        }
838                    }
839                })
840                .catch(function () {
841                    if (complaintsTrendFetchGen[sid] !== gen) return;
842                    for (var ej = 0; ej < els.length; ej++) {
843                        var node2 = els[ej];
844                        if (node2 && node2.parentNode) {
845                            node2.innerHTML = '';
846                            node2.setAttribute('hidden', '');
847                            node2.setAttribute('aria-hidden', 'true');
848                        }
849                    }
850                });
851        });
852    }
853
854    function readStoredRefreshMs() {
855        try {
856            var raw = localStorage.getItem(DETECTOR_REFRESH_MS_KEY);
857            var v = parseInt(raw, 10);
858            if (AUTO_REFRESH_ALLOWED_MS.indexOf(v) >= 0) return v;
859        } catch (e) { /* ignore */ }
860        return 60000;
861    }
862
863    function persistRefreshMs(ms) {
864        try {
865            localStorage.setItem(DETECTOR_REFRESH_MS_KEY, String(ms));
866        } catch (e) { /* ignore */ }
867    }
868
869    function shouldSkipAutoRefresh() {
870        if (document.visibilityState !== 'visible') return true;
871        var m1 = document.getElementById('svcModal');
872        var m2 = document.getElementById('communityReportModal');
873        var m3 = document.getElementById('starRatingModal');
874        var m4 = document.getElementById('det-contact-overlay');
875        var m5 = document.getElementById('communityStatsModal');
876        if (m1 && m1.classList.contains('open')) return true;
877        if (m2 && m2.classList.contains('open')) return true;
878        if (m3 && m3.classList.contains('open')) return true;
879        if (m4 && m4.classList.contains('visible')) return true;
880        if (m5 && m5.classList.contains('open')) return true;
881        return false;
882    }
883
884    function tickAutoRefresh() {
885        if (shouldSkipAutoRefresh()) return;
886        loadAll();
887    }
888
889    function formatRefreshCountdownClock(sec) {
890        sec = Math.max(0, Math.floor(sec));
891        var m = Math.floor(sec / 60);
892        var s = sec % 60;
893        return m + ':' + (s < 10 ? '0' : '') + s;
894    }
895
896    function scheduleNextRefreshDeadline() {
897        refreshDeadlineAt = Date.now() + (state.refreshIntervalMs || 60000);
898    }
899
900    function updateRefreshCountdownUi() {
901        var sel = document.getElementById('selRefreshInterval');
902        var cd = document.getElementById('detRefreshCountdown');
903        if (!sel) return;
904        var loadingLab =
905            typeof window.detectorT === 'function'
906                ? window.detectorT('refresh_countdown_loading')
907                : '…';
908        if (state.detectorLoadInProgress) {
909            if (cd) cd.textContent = loadingLab;
910            sel.setAttribute('title', loadingLab);
911            return;
912        }
913        var secLeft = Math.max(0, Math.ceil((refreshDeadlineAt - Date.now()) / 1000));
914        var clock = formatRefreshCountdownClock(secLeft);
915        var line = clock;
916        if (typeof window.detectorT === 'function') {
917            var tpl = window.detectorT('refresh_countdown_title');
918            if (tpl && tpl.indexOf('{t}') >= 0) {
919                line = tpl.replace(/\{t\}/g, clock);
920            }
921        }
922        if (cd) cd.textContent = line;
923        sel.setAttribute('title', line);
924    }
925
926    function onRefreshCountdownTick() {
927        if (state.detectorLoadInProgress) {
928            updateRefreshCountdownUi();
929            return;
930        }
931        updateRefreshCountdownUi();
932        if (Date.now() < refreshDeadlineAt) return;
933        if (shouldSkipAutoRefresh()) {
934            scheduleNextRefreshDeadline();
935            updateRefreshCountdownUi();
936            return;
937        }
938        tickAutoRefresh();
939    }
940
941    function setAutoRefreshInterval(ms) {
942        if (countdownTimerId != null) {
943            clearInterval(countdownTimerId);
944            countdownTimerId = null;
945        }
946        if (AUTO_REFRESH_ALLOWED_MS.indexOf(ms) < 0) ms = 60000;
947        state.refreshIntervalMs = ms;
948        persistRefreshMs(ms);
949        var sel = document.getElementById('selRefreshInterval');
950        if (sel && String(sel.value) !== String(ms)) sel.value = String(ms);
951        scheduleNextRefreshDeadline();
952        countdownTimerId = setInterval(onRefreshCountdownTick, 1000);
953        updateRefreshCountdownUi();
954    }
955
956    function wireRefreshIntervalSelect() {
957        var sel = document.getElementById('selRefreshInterval');
958        if (!sel) return;
959        var initial = readStoredRefreshMs();
960        state.refreshIntervalMs = initial;
961        sel.value = String(initial);
962        sel.addEventListener('change', function () {
963            var n = parseInt(sel.value, 10);
964            if (AUTO_REFRESH_ALLOWED_MS.indexOf(n) < 0) n = 60000;
965            setAutoRefreshInterval(n);
966            if (typeof requestAnimationFrame === 'function') {
967                requestAnimationFrame(function () {
968                    try {
969                        sel.blur();
970                    } catch (eBl) {
971                        /* ignore */
972                    }
973                    updateRefreshCountdownUi();
974                });
975            } else {
976                updateRefreshCountdownUi();
977            }
978        });
979        setAutoRefreshInterval(initial);
980    }
981
982    /**
983     * «Menor latência»: basear-se na sonda principal (HTTPS/TLS). Só excluir por ICMP quando isso
984     * acompanha falha da sonda principal — CDN sem ping continua elegível se last_ok.
985     */
986    function passesIcmpForFastestRank(s) {
987        if (!s || typeof s !== 'object') return true;
988        if (s.icmp_responds === false && s.last_ok === true) return true;
989        if (s.icmp_responds === false) return false;
990        return true;
991    }
992
993    /** Latência TCP na porta (catálogo tcp_ports), só com handshake OK. */
994    function tcpPortOpenLatencyMs(rows, port) {
995        if (!Array.isArray(rows)) return null;
996        var p = Number(port);
997        if (!isFinite(p)) return null;
998        for (var i = 0; i < rows.length; i++) {
999            var r = rows[i];
1000            if (Number(r.port) !== p) continue;
1001            if (r.ok === true && r.latency_ms != null) {
1002                var v = Number(r.latency_ms);
1003                if (!isNaN(v) && v >= 0) return v;
1004            }
1005        }
1006        return null;
1007    }
1008
1009    /**
1010     * Média HTTP+HTTPS quando ambas têm amostra; se só uma estiver aberta, usa essa.
1011     * Alinha ranking com a experiência web real quando ICMP está degradado (ex.: AliExpress).
1012     */
1013    function tcpHttpHttpsAvgLatencyMs(svc) {
1014        if (!svc || !Array.isArray(svc.tcp_ports)) return null;
1015        var a = tcpPortOpenLatencyMs(svc.tcp_ports, 80);
1016        var b = tcpPortOpenLatencyMs(svc.tcp_ports, 443);
1017        var parts = [];
1018        if (a != null) parts.push(a);
1019        if (b != null) parts.push(b);
1020        if (parts.length === 0) return null;
1021        var sum = 0;
1022        for (var j = 0; j < parts.length; j++) sum += parts[j];
1023        return sum / parts.length;
1024    }
1025
1026    function icmpCatalogLatencyMs(svc) {
1027        if (!svc || svc.icmp_responds !== true || svc.icmp_latency_ms == null) return null;
1028        var v = Number(svc.icmp_latency_ms);
1029        return !isNaN(v) ? v : null;
1030    }
1031
1032    function isVeroInternetRankingService(svc) {
1033        if (!svc || typeof svc !== 'object') return false;
1034        var id = String(svc.id || '').toLowerCase();
1035        if (id === 'ext_verointernet_com_br') return true;
1036        var dom = String(svc.logo_domain || '')
1037            .toLowerCase()
1038            .trim();
1039        return dom === 'verointernet.com.br';
1040    }
1041
1042    /**
1043     * Vero Internet: ranking sempre pelo menor ms entre cartão, destinos ao vivo, TCP, ICMP
1044     * e mínimo recente em `latency_metrics` (alinha ao cálculo do servidor).
1045     */
1046    function veroRankingLatencyMinMs(svc) {
1047        var cands = [];
1048        var base = svc.last_latency_ms != null ? Number(svc.last_latency_ms) : null;
1049        if (base != null && !isNaN(base)) cands.push(base);
1050        if (Array.isArray(svc.targets_live)) {
1051            for (var i = 0; i < svc.targets_live.length; i++) {
1052                var t = svc.targets_live[i];
1053                if (t && t.last_ok === true && t.last_latency_ms != null) {
1054                    var tv = Number(t.last_latency_ms);
1055                    if (!isNaN(tv) && tv >= 0) cands.push(tv);
1056                }
1057            }
1058        }
1059        var icmpL = icmpCatalogLatencyMs(svc);
1060        if (icmpL != null) cands.push(icmpL);
1061        if (Array.isArray(svc.tcp_ports)) {
1062            for (var j = 0; j < svc.tcp_ports.length; j++) {
1063                var p = svc.tcp_ports[j];
1064                if (p && p.ok === true && p.latency_ms != null) {
1065                    var pv = Number(p.latency_ms);
1066                    if (!isNaN(pv) && pv >= 0) cands.push(pv);
1067                }
1068            }
1069        }
1070        var met = svc.latency_metrics || {};
1071        var mm = met.min_ms != null ? Number(met.min_ms) : null;
1072        if (mm != null && !isNaN(mm) && mm >= 0) cands.push(mm);
1073        if (cands.length === 0) return null;
1074        return Math.min.apply(null, cands);
1075    }
1076
1077    /**
1078     * Latência para rankings / ordenação / médias no resumo:
1079     * se a média HTTP+HTTPS (TCP) for claramente melhor que ICMP ou que last_latency_ms,
1080     * usa essa média; caso contrário mantém a sonda principal do catálogo.
1081     * Vero Internet: sempre o mínimo entre todas as sondas relevantes (ver `veroRankingLatencyMinMs`).
1082     */
1083    function rankingLatencyMs(svc) {
1084        if (!svc || svc.last_ok !== true) return null;
1085        if (isVeroInternetRankingService(svc)) {
1086            return veroRankingLatencyMinMs(svc);
1087        }
1088        var base = svc.last_latency_ms != null ? Number(svc.last_latency_ms) : null;
1089        if (base != null && isNaN(base)) base = null;
1090
1091        var tcpAvg = tcpHttpHttpsAvgLatencyMs(svc);
1092        if (tcpAvg == null || isNaN(tcpAvg)) return base;
1093
1094        var icmpL = icmpCatalogLatencyMs(svc);
1095        var delta = 25;
1096        if (icmpL != null && tcpAvg < icmpL - delta) return tcpAvg;
1097        if (base != null && tcpAvg < base - delta) return tcpAvg;
1098        if (base == null) return tcpAvg;
1099        return base;
1100    }
1101
1102    function hasRankingLatencySample(svc) {
1103        return svc && svc.last_ok === true && rankingLatencyMs(svc) != null;
1104    }
1105
1106    /**
1107     * Desempate nos tops de latência: em empate (ms arredondados iguais), priorizar marcas com maior
1108     * relevância global (alinhado às «mega-marcas» do detector + sinais do catálogo).
1109     */
1110    var MARKET_PROM_DOMAIN_WEIGHT = (function () {
1111        var o = {};
1112        function add(list, w) {
1113            var parts = String(list).split(',');
1114            for (var i = 0; i < parts.length; i++) {
1115                var k = parts[i].trim().toLowerCase();
1116                if (k) o[k] = w;
1117            }
1118        }
1119        add(
1120            'google.com,youtube.com,gmail.com,android.com,chrome.com,chromium.org,' +
1121                'facebook.com,instagram.com,whatsapp.com,messenger.com,meta.com,' +
1122                'microsoft.com,office.com,outlook.com,live.com,azure.com,skype.com,xbox.com,' +
1123                'apple.com,icloud.com,apps.apple.com,music.apple.com,' +
1124                'amazon.com,amazonaws.com,aws.amazon.com,primevideo.com,' +
1125                'netflix.com,spotify.com,tiktok.com,linkedin.com,twitter.com,x.com,' +
1126                'discord.com,reddit.com,snapchat.com,pinterest.com,telegram.org,' +
1127                'paypal.com,visa.com,mastercard.com,stripe.com,shopify.com,squareup.com,' +
1128                'openai.com,chatgpt.com,anthropic.com,claude.ai,' +
1129                'deepseek.com,grok.com,x.ai,mistral.ai,perplexity.ai,' +
1130                'midjourney.com,character.ai,meta.ai,copilot.microsoft.com,' +
1131                'huggingface.co,elevenlabs.io,runwayml.com,suno.com,poe.com,' +
1132                'cohere.com,stability.ai,cursor.com,' +
1133                'baidu.com,qq.com,wechat.com,weixin.qq.com,tencent.com,alibaba.com,aliexpress.com,' +
1134                'bytedance.com,cloud.google.com,adobe.com,salesforce.com,oracle.com,ibm.com,' +
1135                'uber.com,airbnb.com,booking.com,expedia.com,doordash.com,' +
1136                'walmart.com,target.com,ebay.com,bestbuy.com,costco.com,' +
1137                'samsung.com,sony.com,nvidia.com,intel.com,' +
1138                'github.com,gitlab.com,bitbucket.org,stackoverflow.com,' +
1139                'zoom.us,slack.com,dropbox.com,notion.so,atlassian.com,' +
1140                'bbc.com,cnn.com,nytimes.com,reuters.com,bloomberg.com,' +
1141                'nubank.com.br,bb.com.br,itau.com.br,bradesco.com.br,santander.com.br,caixa.gov.br,' +
1142                'mercadolivre.com.br,magazineluiza.com.br,americanas.com.br,shopee.com.br,ifood.com.br,' +
1143                'gov.br,correios.com.br,esocial.gov.br,nfe.fazenda.gov.br,bcb.gov.br,dados.gov.br,' +
1144                'drogasil.com.br,drogaraia.com.br,paguemenos.com.br,unimed.coop.br,hapvida.com.br,' +
1145                'fleury.com.br,serasa.com.br,reclameaqui.com.br,' +
1146                'usp.br,unicamp.br,ufrj.br,ufmg.br,' +
1147                'latamairlines.com,voegol.com.br,voeazul.com.br,decolar.com,cvc.com.br,' +
1148                'carrefour.com.br,casasbahia.com.br,quintoandar.com.br,' +
1149                'portoseguro.com.br,hotmart.com,gupy.io,globoplay.globo.com,' +
1150                'g1.globo.com,folha.uol.com.br,valor.globo.com,infomoney.com.br,' +
1151                'dazn.com,espn.com.br,sofascore.com,flashscore.com.br,' +
1152                'cbf.com.br,premierleague.com,uefa.com,conmebol.com,fifa.com,' +
1153                'flamengo.com.br,corinthians.com.br,palmeiras.com.br,' +
1154                'ufc.com.br,nba.com,formula1.com,lance.com.br',
1155            1000,
1156        );
1157        add(
1158            'epicgames.com,steampowered.com,steamcommunity.com,riotgames.com,roblox.com,' +
1159                'playstation.com,ubisoft.com,ea.com,blizzard.com,battle.net,activision.com,' +
1160                'hoyoverse.com,mihoyo.com,nintendo.com,twitch.tv,' +
1161                'fortnite.com,callofduty.com,rockstargames.com,rocketleague.com,' +
1162                'ff.garena.com,supercell.com,tibia.com,pokemongolive.com,' +
1163                'huawei.com,zte.com,cisco.com,juniper.net,cloudflare.com,fastly.com,akamai.com',
1164            720,
1165        );
1166        add('fiberhome.com,fiberhome.com.cn', 420);
1167        return o;
1168    })();
1169
1170    function domainMarketProminenceWeight(host) {
1171        if (!host) return 0;
1172        var d = String(host).toLowerCase().trim();
1173        if (!d) return 0;
1174        for (;;) {
1175            var w = MARKET_PROM_DOMAIN_WEIGHT[d];
1176            if (w) return w;
1177            var ix = d.indexOf('.');
1178            if (ix < 0) break;
1179            d = d.slice(ix + 1);
1180        }
1181        return 0;
1182    }
1183
1184    function marketProminenceForLatencyRank(svc) {
1185        if (!svc || typeof svc !== 'object') return 0;
1186        var w = domainMarketProminenceWeight(svc.logo_domain);
1187        var ts = Number(svc.trust_score);
1188        if (!isNaN(ts) && ts > 0) w += Math.min(ts, 100) * 0.15;
1189        var vc = Number(svc.star_rating_count) || 0;
1190        w += Math.min(vc, 150) * 0.05;
1191        var comm = Number(svc.community_vote_count) || 0;
1192        w += Math.min(comm, 80) * 0.12;
1193        return w;
1194    }
1195
1196    function ratingBoardCompositeParts(s) {
1197        var votes = Number(s.star_rating_count) || 0;
1198        var stars = votes > 0 ? Number(s.avg_star_rating) || 5 : 0;
1199        var sc = Number(s.trust_score) || 0;
1200        if (isNaN(sc)) sc = 0;
1201
1202        var met = s.latency_metrics || {};
1203        var avgMs = met.avg_ms != null ? Number(met.avg_ms) : null;
1204        var jitter = met.jitter_ms != null ? Number(met.jitter_ms) : null;
1205        var pLoss = met.packet_loss_pct != null ? Number(met.packet_loss_pct) : null;
1206        var isUp = s.last_ok === true;
1207        var reports = Number(s.community_vote_count) || 0;
1208
1209        var latBonus = 0;
1210        if (avgMs != null && !isNaN(avgMs) && avgMs > 0) {
1211            latBonus = avgMs < 100 ? 15 : avgMs < 300 ? 10 : avgMs < 600 ? 5 : avgMs < 1000 ? 2 : 0;
1212        }
1213
1214        var lossBonus = 0;
1215        if (pLoss != null && !isNaN(pLoss)) {
1216            lossBonus = pLoss <= 0 ? 10 : pLoss < 1 ? 7 : pLoss < 5 ? 3 : 0;
1217        }
1218
1219        var jitterBonus = 0;
1220        if (jitter != null && !isNaN(jitter)) {
1221            jitterBonus = jitter < 10 ? 5 : jitter < 50 ? 3 : jitter < 100 ? 1 : 0;
1222        }
1223
1224        var uptimeBonus = isUp ? 5 : 0;
1225
1226        var reportPenalty = reports <= 0 ? 5 : reports <= 5 ? 3 : reports <= 20 ? 1 : 0;
1227
1228        return {
1229            composite: sc + stars * 4 + Math.min(votes, 50) * 0.5
1230                       + latBonus + lossBonus + jitterBonus + uptimeBonus + reportPenalty,
1231            votes: votes,
1232        };
1233    }
1234
1235    /**
1236     * Top «pior / melhor avaliação»: ordena pela métrica composta
1237     * (nota + estrelas + votos + latência + perda + jitter + uptime + relatos).
1238     * Empates: peso de mercado pelo domínio; empate final aleatório.
1239     */
1240    function sortCatalogForRatingBoard(items, worst) {
1241        var decorated = items.map(function (s) {
1242            var p = ratingBoardCompositeParts(s);
1243            return {
1244                s: s,
1245                c: p.composite,
1246                w: domainMarketProminenceWeight(s.logo_domain),
1247                r: Math.random(),
1248            };
1249        });
1250        decorated.sort(function (a, b) {
1251            if (a.c !== b.c) return worst ? a.c - b.c : b.c - a.c;
1252            if (a.w !== b.w) return worst ? a.w - b.w : b.w - a.w;
1253            return a.r - b.r;
1254        });
1255        return decorated.map(function (x) {
1256            return x.s;
1257        });
1258    }
1259
1260    function cmpRankingLatencyWorst(a, b) {
1261        var la = latencyMs(a);
1262        var lb = latencyMs(b);
1263        if (la == null && lb == null) {
1264            return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
1265        }
1266        if (la == null) return 1;
1267        if (lb == null) return -1;
1268        var ra = Math.round(la);
1269        var rb = Math.round(lb);
1270        if (ra !== rb) return rb - ra;
1271        var pa = marketProminenceForLatencyRank(a);
1272        var pb = marketProminenceForLatencyRank(b);
1273        if (pb !== pa) return pb - pa;
1274        if (lb !== la) return lb - la;
1275        return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
1276    }
1277
1278    function cmpRankingLatencyBest(a, b) {
1279        var la = latencyMs(a);
1280        var lb = latencyMs(b);
1281        if (la == null && lb == null) {
1282            return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
1283        }
1284        if (la == null) return 1;
1285        if (lb == null) return -1;
1286        var ra = Math.round(la);
1287        var rb = Math.round(lb);
1288        if (ra !== rb) return ra - rb;
1289        var pa = marketProminenceForLatencyRank(a);
1290        var pb = marketProminenceForLatencyRank(b);
1291        if (pb !== pa) return pb - pa;
1292        if (la !== lb) return la - lb;
1293        return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
1294    }
1295
1296    /** Itens por coluna nos painéis de ranking (página mais curta). */
1297    var RANK_TOP = 5;
1298    /** Máximo ao expandir «ranking completo». */
1299    var RANK_FULL_MAX = 60;
1300    /** Top N no catálogo de problemas (global e por categoria). */
1301    var PROB_TOP = 10;
1302    /** Alinhar resumo «jogos» a entradas ext_* do catálogo pelo domínio do logo. */
1303    var GAME_SLUG_LOGO = {
1304        steam: 'steampowered.com',
1305        epicgames: 'epicgames.com',
1306        playstation: 'playstation.com',
1307        xbox: 'xbox.com',
1308        roblox: 'roblox.com',
1309        riotgames: 'riotgames.com',
1310        discord: 'discord.com',
1311        twitch: 'twitch.tv',
1312    };
1313
1314    /** Statuspage «sp_*» alinhados ao resumo Jogos & live do Detector. */
1315    var GAME_SP_STATUS_IDS = {
1316        steam: 1,
1317        epicgames: 1,
1318        playstation: 1,
1319        xbox: 1,
1320        roblox: 1,
1321        riotgames: 1,
1322        discord: 1,
1323        twitch: 1,
1324    };
1325
1326    var detTop10SocialSession = { kind: 'community', rows: null };
1327
1328    function gameCatalogDomainRoots() {
1329        var roots = [];
1330        var seen = {};
1331        function add(d) {
1332            var x = String(d || '')
1333                .trim()
1334                .toLowerCase();
1335            if (!x || seen[x]) return;
1336            seen[x] = 1;
1337            roots.push(x);
1338        }
1339        var k;
1340        for (k in GAME_SLUG_LOGO) {
1341            if (Object.prototype.hasOwnProperty.call(GAME_SLUG_LOGO, k)) add(GAME_SLUG_LOGO[k]);
1342        }
1343        var extras = [
1344            'steamcommunity.com',
1345            'battle.net',
1346            'blizzard.com',
1347            'ea.com',
1348            'electronicarts.com',
1349            'origin.com',
1350            'ubisoft.com',
1351            'nintendo.com',
1352            'minecraft.net',
1353            'facepunch.com',
1354            'pubg.com',
1355            'mihoyo.com',
1356            'hoyoverse.com',
1357            'wargaming.net',
1358            'deadbydaylight.com',
1359            'bhvr.com',
1360            'garena.com',
1361            'krafton.com',
1362            'leagueoflegends.com',
1363            'playvalorant.com',
1364            'rockstargames.com',
1365            'activision.com',
1366            'callofduty.com',
1367            'bethesda.net',
1368            'zenimax.com',
1369            'cdprojektred.com',
1370            'gog.com',
1371            'nexusmods.com',
1372            'curseforge.com',
1373        ];
1374        for (var ei = 0; ei < extras.length; ei++) add(extras[ei]);
1375        return roots;
1376    }
1377
1378    var _gameCatalogDomainRootsCache = null;
1379    function domainMatchesGameCatalog(dom) {
1380        var d = String(dom || '')
1381            .trim()
1382            .toLowerCase();
1383        if (!d) return false;
1384        if (!_gameCatalogDomainRootsCache) _gameCatalogDomainRootsCache = gameCatalogDomainRoots();
1385        var roots = _gameCatalogDomainRootsCache;
1386        var ri;
1387        for (ri = 0; ri < roots.length; ri++) {
1388            var r = roots[ri];
1389            if (d === r || d.endsWith('.' + r)) return true;
1390        }
1391        return false;
1392    }
1393
1394    function isDetectorGameCatalogService(svc) {
1395        if (!svc) return false;
1396        var id = String(svc.id || '').toLowerCase();
1397        if (id.indexOf('sp_') === 0) {
1398            var rest = id.slice(3);
1399            if (GAME_SP_STATUS_IDS[rest]) return true;
1400        }
1401        if (String(svc.category || '').toLowerCase() === 'gaming') return true;
1402        if (domainMatchesGameCatalog(svc.logo_domain)) return true;
1403
1404        var bag = (
1405            String(svc.id || '') +
1406            ' ' +
1407            String(svc.name || '') +
1408            ' ' +
1409            String(svc.logo_domain || '')
1410        ).toLowerCase();
1411        var hints = [
1412            'game',
1413            'gaming',
1414            'jogo',
1415            'jogos',
1416            'steam',
1417            'epic',
1418            'riot',
1419            'valorant',
1420            'league of legends',
1421            'playstation',
1422            'psn',
1423            'xbox',
1424            'nintendo',
1425            'roblox',
1426            'minecraft',
1427            'twitch',
1428            'discord',
1429            'battle.net',
1430            'blizzard',
1431        ];
1432        for (var hi = 0; hi < hints.length; hi++) {
1433            if (bag.indexOf(hints[hi]) !== -1) return true;
1434        }
1435        return false;
1436    }
1437
1438    function top10GamesCatalogServices(limit) {
1439        if (!state.catalog || !state.catalog.length) return [];
1440        var pool = state.catalog.filter(function (s) {
1441            return isDetectorGameCatalogService(s) && gameRankingLatencyMs(s) != null;
1442        });
1443        pool.sort(cmpGamesRankingLatencyWorst);
1444        var lim = Number(limit);
1445        if (!isNaN(lim) && lim > 0) return pool.slice(0, lim);
1446        return pool.slice(0, 10);
1447    }
1448
1449    function gamesLatencyQuantileMs(svc, q) {
1450        var src = svc && Array.isArray(svc.latency_sparkline_ms) ? svc.latency_sparkline_ms : [];
1451        var vals = [];
1452        var i;
1453        for (i = 0; i < src.length; i++) {
1454            var n = Number(src[i]);
1455            if (!isNaN(n) && n >= 0) vals.push(n);
1456        }
1457        if (!vals.length) return null;
1458        vals.sort(function (a, b) {
1459            return a - b;
1460        });
1461        var qq = Number(q);
1462        if (isNaN(qq)) qq = 0.5;
1463        if (qq < 0) qq = 0;
1464        if (qq > 1) qq = 1;
1465        var pos = Math.round((vals.length - 1) * qq);
1466        return vals[pos];
1467    }
1468
1469    /**
1470     * Candidatos de latência para o Top 10 jogos: mesma ideia do ranking «Vero» — várias sondas
1471     * (HTTPS/TCP, sparkline, métricas, ICMP, destinos ao vivo) e escolha robusta (min vs mediana).
1472     */
1473    function gameRankingLatencyCandidateMs(svc) {
1474        if (!svc || svc.last_ok !== true) return [];
1475        var out = [];
1476        var b = rankingLatencyMs(svc);
1477        if (b != null && !isNaN(b) && b >= 0) out.push(b);
1478        var tcp = tcpHttpHttpsAvgLatencyMs(svc);
1479        if (tcp != null && !isNaN(tcp) && tcp >= 0) out.push(tcp);
1480        var p50 = gamesLatencyQuantileMs(svc, 0.5);
1481        if (p50 != null && !isNaN(p50) && p50 >= 0) out.push(p50);
1482        var met = svc.latency_metrics || {};
1483        var mn = met.min_ms != null ? Number(met.min_ms) : null;
1484        if (mn != null && !isNaN(mn) && mn >= 0) out.push(mn);
1485        var ic = icmpCatalogLatencyMs(svc);
1486        if (ic != null && !isNaN(ic) && ic >= 0) out.push(ic);
1487        if (Array.isArray(svc.targets_live)) {
1488            var ti;
1489            for (ti = 0; ti < svc.targets_live.length; ti++) {
1490                var tl = svc.targets_live[ti];
1491                if (tl && tl.last_ok === true && tl.last_latency_ms != null) {
1492                    var tv = Number(tl.last_latency_ms);
1493                    if (!isNaN(tv) && tv >= 0) out.push(tv);
1494                }
1495            }
1496        }
1497        return out;
1498    }
1499
1500    function gameRankingLatencyMs(svc) {
1501        var cands = gameRankingLatencyCandidateMs(svc);
1502        if (!cands.length) return null;
1503        cands.sort(function (a, b) {
1504            return a - b;
1505        });
1506        var lo = cands[0];
1507        var hi = cands[cands.length - 1];
1508        if (cands.length === 1) return lo;
1509        if (cands.length === 2) return Math.min(cands[0], cands[1]);
1510        if (hi - lo > 500) {
1511            return cands[Math.floor(cands.length / 2)];
1512        }
1513        return lo;
1514    }
1515
1516    function cmpGamesRankingLatencyWorst(a, b) {
1517        var la = gameRankingLatencyMs(a);
1518        var lb = gameRankingLatencyMs(b);
1519        if (la == null && lb == null) {
1520            return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
1521        }
1522        if (la == null) return 1;
1523        if (lb == null) return -1;
1524        var ra = Math.round(la);
1525        var rb = Math.round(lb);
1526        if (ra !== rb) return rb - ra;
1527        var pa = marketProminenceForLatencyRank(a);
1528        var pb = marketProminenceForLatencyRank(b);
1529        if (pb !== pa) return pb - pa;
1530        if (lb !== la) return lb - la;
1531        return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
1532    }
1533
1534    /**
1535     * Mesma ordenação que o painel «Top 10 games» à direita: relatos de games primeiro,
1536     * depois lugares até 10 por latência (catálogo, piores primeiro). Serviço único para UI e cartão/copy de partilha.
1537     */
1538    function sidebarTop10GamesHybridParts() {
1539        var commRows = state.communityRankingRows;
1540        var gameCommRows = [];
1541        if (commRows && commRows.length) {
1542            for (var ri = 0; ri < commRows.length; ri++) {
1543                var rw = commRows[ri];
1544                if (!rw || (Number(rw.vote_count) || 0) < 1) continue;
1545                var svcCheck = findSvc(rw.service_id);
1546                if (svcCheck && isDetectorGameCatalogService(svcCheck)) {
1547                    gameCommRows.push(rw);
1548                }
1549            }
1550        }
1551        gameCommRows = gameCommRows.slice(0, 10);
1552        var latFillSvcs = [];
1553        if (gameCommRows.length < 10) {
1554            var commIds = {};
1555            for (var ci = 0; ci < gameCommRows.length; ci++) {
1556                commIds[gameCommRows[ci].service_id] = true;
1557            }
1558            var allLatSvcs = top10GamesCatalogServices(200);
1559            for (var li2 = 0; li2 < allLatSvcs.length && latFillSvcs.length < (10 - gameCommRows.length); li2++) {
1560                var ls = allLatSvcs[li2];
1561                if (ls && !commIds[ls.id]) latFillSvcs.push(ls);
1562            }
1563        }
1564        return { communityRankingRows: commRows, gameCommRows: gameCommRows, latFillSvcs: latFillSvcs };
1565    }
1566
1567    /** Linhas no formato esperado pelo modal social / canvas (iguais ao painel direito). */
1568    function rankingRowsForTop10GamesSocialShare() {
1569        var hy = sidebarTop10GamesHybridParts();
1570        var gameCommRows = hy.gameCommRows;
1571        var latFillSvcs = hy.latFillSvcs;
1572        var merged = [];
1573        var gi;
1574        for (gi = 0; gi < gameCommRows.length; gi++) {
1575            merged.push(gameCommRows[gi]);
1576        }
1577        var fj;
1578        for (fj = 0; fj < latFillSvcs.length; fj++) {
1579            var s = latFillSvcs[fj];
1580            if (!s || !s.id) continue;
1581            merged.push({
1582                service_id: s.id,
1583                name: s.name || s.id,
1584                vote_count: 0,
1585                logo_domain: s.logo_domain,
1586                logo_url: s.logo_url,
1587                logo_icons: s.logo_icons,
1588            });
1589        }
1590        return merged;
1591    }
1592
1593    function getTop10SocialShareRowsForModal() {
1594        if (detTop10SocialSession && detTop10SocialSession.rows && detTop10SocialSession.rows.length) {
1595            return detTop10SocialSession.rows.slice(0, 10);
1596        }
1597        return communityRowsWithVotesForReport().slice(0, 10);
1598    }
1599
1600    function top10SocialCardPngBasename() {
1601        return detTop10SocialSession && detTop10SocialSession.kind === 'games'
1602            ? 'catellix-top10-jogos.png'
1603            : 'catellix-top10-comunidade.png';
1604    }
1605
1606    function rankLimit(panelKey) {
1607        return state.rankExpanded[panelKey] ? RANK_FULL_MAX : RANK_TOP;
1608    }
1609
1610    function updateRankMoreBtn(btnId, panelKey, totalAvailable) {
1611        var btn = document.getElementById(btnId);
1612        if (!btn) return;
1613        var n = Number(totalAvailable) || 0;
1614        if (n <= RANK_TOP) {
1615            btn.hidden = true;
1616            return;
1617        }
1618        btn.hidden = false;
1619        btn.textContent = state.rankExpanded[panelKey] ? __('js_rank_more_top5') : __('js_rank_more_full');
1620        btn.setAttribute('aria-expanded', state.rankExpanded[panelKey] ? 'true' : 'false');
1621    }
1622
1623    var RANK_OPTION_KEYS = [
1624        ['live_problems', 'rank_sort_live_problems'],
1625        ['latency_desc', 'rank_sort_latency_desc'],
1626        ['latency_asc', 'rank_sort_latency_asc'],
1627        ['rank_global', 'rank_sort_rank_global'],
1628        ['fail_first', 'rank_sort_fail_first'],
1629        ['incident_first', 'rank_sort_incident_first'],
1630        ['name', 'rank_sort_name'],
1631        ['name_desc', 'rank_sort_name_desc'],
1632    ];
1633
1634    function enrichDescription(svc) {
1635        function clip(t, max) {
1636            var s = (t || '').trim();
1637            if (s.length <= max) return s;
1638            return s.slice(0, max - 1) + '\u2026';
1639        }
1640        var base = (svc && svc.description) ? String(svc.description).trim() : '';
1641        if (base.length >= 100) return clip(base, 190);
1642        var extra = categoryBlurb(svc && svc.category);
1643        if (!extra) return clip(base || __('desc_fallback'), 190);
1644        if (!base) return clip(extra, 190);
1645        return clip(base + ' — ' + extra, 190);
1646    }
1647
1648    function esc(s) {
1649        if (s == null) return '';
1650        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
1651    }
1652
1653    function faviconForDomain(dom) {
1654        if (!dom) return 'https://www.google.com/s2/favicons?domain=catellix.com&sz=128';
1655        return 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(dom) + '&sz=128';
1656    }
1657
1658    function trendFaviconDomain(x) {
1659        if (!x) return 'catellix.com';
1660        var d = x.hint_domain != null ? String(x.hint_domain).trim() : '';
1661        if (d) return d;
1662        var s = x.slug != null ? String(x.slug).trim() : '';
1663        if (s.indexOf('.') >= 0) return s;
1664        return s ? s + '.com' : 'catellix.com';
1665    }
1666
1667    function trendHref(x) {
1668        var u = x && x.url != null ? String(x.url).trim() : '';
1669        return u || '/detector';
1670    }
1671
1672    function trendIsExternal(href) {
1673        return /^https?:\/\//i.test(href) && href.indexOf('catellix.com') < 0;
1674    }
1675
1676    function trendLinkTargetAttrs(href) {
1677        if (trendIsExternal(href)) return ' target="_blank" rel="noopener noreferrer"';
1678        return '';
1679    }
1680
1681    function faviconDdg(dom) {
1682        if (!dom) return '';
1683        return 'https://icons.duckduckgo.com/ip3/' + encodeURIComponent(dom) + '.ico';
1684    }
1685
1686    /**
1687     * Fallbacks em `<img>`: **não** usar unavatar como 1º passo (devolve «cara» genérica, não logo de marca).
1688     * Ordem: proxy DDG → proxy Google favicon do domínio «pai» (.com.br → .com).
1689     * Requer `detectorIconProxyWrapUrl` (definido mais abaixo; declaração hoisted no IIFE).
1690     */
1691    function imgLogoOnErrorAttr(logoDomain) {
1692        var dom = (logoDomain || '').trim();
1693        if (!dom) return '';
1694        var ddg = faviconDdg(dom);
1695        var u1 = ddg ? detectorIconProxyWrapUrl(ddg) : '';
1696        var u2 = '';
1697        if (/\.com\.(br|ar|mx)$/i.test(dom)) {
1698            u2 = detectorIconProxyWrapUrl(faviconForDomain(dom.replace(/\.com\.(br|ar|mx)$/i, '.com')));
1699        }
1700        if (!u1 && !u2) return '';
1701        if (!u1) {
1702            return (
1703                ' onerror="if(this.dataset.lx===\'1\'){this.onerror=null;return;}this.dataset.lx=\'1\';this.src=\'' +
1704                esc(u2) +
1705                '\'"'
1706            );
1707        }
1708        if (!u2) {
1709            return (
1710                ' onerror="if(this.dataset.lx===\'1\'){this.onerror=null;return;}this.dataset.lx=\'1\';this.src=\'' +
1711                esc(u1) +
1712                '\'"'
1713            );
1714        }
1715        return (
1716            ' onerror="(function(el){var s=parseInt(el.dataset.lx,10)||0;var u1=\'' +
1717            esc(u1) +
1718            '\';var u2=\'' +
1719            esc(u2) +
1720            '\';if(s===0){el.dataset.lx=\'1\';el.src=u1;return;}if(s===1){el.dataset.lx=\'2\';el.src=u2;return;}el.onerror=null;})(this)"'
1721        );
1722    }
1723
1724    /**
1725     * `<img>` com cadeia vinda do backend (`logo_icons`): mesmo primário que `logo_url`, depois Logo.dev,
1726     * Clearbit, Simple Icons, `/static/detector-logos/…`, favicon Google, unavatar, DDG.
1727     */
1728    function detLogoImgHtml(svc, extraClass, dimAttrs) {
1729        var dom = (svc.logo_domain || '').trim();
1730        var chain = Array.isArray(svc.logo_icons) && svc.logo_icons.length ? svc.logo_icons : null;
1731        var src = chain ? chain[0] : svc.logo_url || faviconForDomain(dom);
1732        var baseCls = (extraClass || '').trim();
1733        var cls = (baseCls + (chain ? ' det-logo-fallback' : '')).trim();
1734        var dim = dimAttrs || '';
1735        if (chain) {
1736            var enc = encodeURIComponent(JSON.stringify(chain));
1737            return (
1738                '<img class="' +
1739                esc(cls) +
1740                '"' +
1741                dim +
1742                ' src="' +
1743                esc(src) +
1744                '" alt="" loading="lazy" decoding="async" data-logo-chain="' +
1745                esc(enc) +
1746                '" data-logo-i="0" data-logo-domain="' +
1747                esc(dom) +
1748                '">'
1749            );
1750        }
1751        return (
1752            '<img class="' +
1753            esc(cls) +
1754            '"' +
1755            dim +
1756            ' src="' +
1757            esc(src) +
1758            '" alt="" loading="lazy" decoding="async" data-logo-domain="' +
1759            esc(dom) +
1760            '"' +
1761            imgLogoOnErrorAttr(dom) +
1762            '>'
1763        );
1764    }
1765
1766    function attachLogoChainOnError(img) {
1767        if (!img) return;
1768        var raw = img.getAttribute('data-logo-chain');
1769        if (!raw) return;
1770        var chain;
1771        try {
1772            chain = JSON.parse(decodeURIComponent(raw));
1773        } catch (e) {
1774            return;
1775        }
1776        if (!Array.isArray(chain) || chain.length < 1) return;
1777        img.onerror = function () {
1778            var i = parseInt(img.getAttribute('data-logo-i'), 10) || 0;
1779            var n = i + 1;
1780            if (n >= chain.length) {
1781                img.onerror = null;
1782                return;
1783            }
1784            img.setAttribute('data-logo-i', String(n));
1785            img.src = chain[n];
1786        };
1787    }
1788
1789    function bindDetLogoFallbacks(root) {
1790        if (!root || !root.querySelectorAll) return;
1791        root.querySelectorAll('img.det-logo-fallback[data-logo-chain]').forEach(attachLogoChainOnError);
1792    }
1793
1794    function updateOneShelfHint(wrap) {
1795        if (!wrap) return;
1796        var row = wrap.querySelector('.row-scroll');
1797        var hint = wrap.querySelector('.shelf-scroll-hint');
1798        if (!row || !hint) return;
1799        var cs = window.getComputedStyle(row);
1800        if (cs.display === 'none' || cs.visibility === 'hidden') {
1801            hint.classList.remove('visible');
1802            return;
1803        }
1804        var max = row.scrollWidth - row.clientWidth;
1805        if (max <= 6) {
1806            hint.classList.remove('visible');
1807            return;
1808        }
1809        var atEnd = row.scrollLeft >= max - 6;
1810        hint.classList.toggle('visible', !atEnd);
1811    }
1812
1813    /**
1814     * Roda do rato vertical → scroll horizontal no carrossel; arrastar com o rato (desktop).
1815     * Snap CSS é desligado durante o arrasto para o movimento ficar contínuo.
1816     */
1817    function bindShelfRowSmoothUx(row) {
1818        if (!row || row.dataset.detShelfUx === '1') return;
1819        row.dataset.detShelfUx = '1';
1820
1821        row.addEventListener(
1822            'wheel',
1823            function (e) {
1824                var max = row.scrollWidth - row.clientWidth;
1825                if (max <= 1) return;
1826                var absY = Math.abs(e.deltaY);
1827                var absX = Math.abs(e.deltaX);
1828                if (absX >= absY) return;
1829                if (absY < 0.5) return;
1830                var delta = e.deltaY;
1831                var next = row.scrollLeft + delta;
1832                var atStart = row.scrollLeft <= 0;
1833                var atEnd = row.scrollLeft >= max - 1;
1834                if (delta < 0 && atStart) return;
1835                if (delta > 0 && atEnd) return;
1836                row.scrollLeft = Math.max(0, Math.min(max, next));
1837                e.preventDefault();
1838            },
1839            { passive: false },
1840        );
1841
1842        var drag = {
1843            active: false,
1844            ptrId: null,
1845            startX: 0,
1846            startScroll: 0,
1847            moved: false,
1848        };
1849
1850        function isInteractiveTarget(t) {
1851            if (!t || !t.closest) return false;
1852            return !!t.closest(
1853                'button, a[href], input, select, textarea, [role="button"], label[for]',
1854            );
1855        }
1856
1857        function endDrag(e) {
1858            if (!drag.active) return;
1859            if (e && e.pointerId != null && drag.ptrId != null && e.pointerId !== drag.ptrId) return;
1860            var didMove = drag.moved;
1861            drag.active = false;
1862            drag.ptrId = null;
1863            row.classList.remove('is-shelf-dragging');
1864            try {
1865                if (e && e.pointerId != null) row.releasePointerCapture(e.pointerId);
1866            } catch (err) {
1867                /* ignore */
1868            }
1869            if (didMove) {
1870                row.addEventListener(
1871                    'click',
1872                    function suppressClick(ev) {
1873                        ev.preventDefault();
1874                        ev.stopPropagation();
1875                        row.removeEventListener('click', suppressClick, true);
1876                    },
1877                    true,
1878                );
1879            }
1880            drag.moved = false;
1881        }
1882
1883        row.addEventListener('pointerdown', function (e) {
1884            if (e.pointerType !== 'mouse' || e.button !== 0) return;
1885            if (isInteractiveTarget(e.target)) return;
1886            drag.active = true;
1887            drag.ptrId = e.pointerId;
1888            drag.startX = e.clientX;
1889            drag.startScroll = row.scrollLeft;
1890            drag.moved = false;
1891            try {
1892                row.setPointerCapture(e.pointerId);
1893            } catch (err2) {
1894                /* ignore */
1895            }
1896        });
1897        row.addEventListener('pointermove', function (e) {
1898            if (!drag.active || e.pointerId !== drag.ptrId) return;
1899            var dx = e.clientX - drag.startX;
1900            if (Math.abs(dx) > 4) {
1901                drag.moved = true;
1902                row.classList.add('is-shelf-dragging');
1903            }
1904            row.scrollLeft = drag.startScroll - dx;
1905        });
1906        row.addEventListener('pointerup', endDrag);
1907        row.addEventListener('pointercancel', endDrag);
1908        row.addEventListener('lostpointercapture', function (e) {
1909            if (drag.active && drag.ptrId === e.pointerId) endDrag(e);
1910        });
1911    }
1912
1913    function bindShelfScrollHint(wrap) {
1914        if (!wrap) return;
1915        var row = wrap.querySelector('.row-scroll');
1916        if (!row) return;
1917        bindShelfRowSmoothUx(row);
1918        function update() {
1919            updateOneShelfHint(wrap);
1920        }
1921        row.addEventListener('scroll', update, { passive: true });
1922        window.addEventListener('resize', update, { passive: true });
1923        if (typeof ResizeObserver !== 'undefined') {
1924            var ro = new ResizeObserver(update);
1925            ro.observe(row);
1926        }
1927        update();
1928    }
1929
1930    function refreshShelfHints() {
1931        document.querySelectorAll('.shelf-row-wrap').forEach(updateOneShelfHint);
1932    }
1933
1934    var ddRowHintBound = false;
1935
1936    function badgeForService(svc) {
1937        if (svc.last_ok === true) return { cls: 'ok', label: __('js_status_ok') };
1938        if (svc.last_ok === false) return { cls: 'bad', label: __('js_status_fail') };
1939        return { cls: 'unknown', label: __('js_status_no_data') };
1940    }
1941
1942    /**
1943     * Classificação global de latência (servidor: `latency_class` + métricas; cliente: DetectorLatencyClassify).
1944     */
1945    function latencyTierMeta(svc) {
1946        if (svc.last_ok === false) {
1947            return {
1948                cls: 'lat-tier-fail',
1949                pill: __('js_status_fail'),
1950                hint: __('js_lat_tier_fail_hint'),
1951            };
1952        }
1953        if (svc.last_ok !== true || svc.last_latency_ms == null) {
1954            return {
1955                cls: 'lat-tier-unk',
1956                pill: __('js_pill_no_data'),
1957                hint: __('js_lat_tier_unk_hint'),
1958            };
1959        }
1960        var lc = svc.latency_class;
1961        if (lc && lc.ui_class) {
1962            var hint = (lc.description_pt || '').trim();
1963            var rules = lc.rules_applied || [];
1964            if (rules.length) {
1965                hint += (hint ? ' · ' : '') + __('js_lat_rules_label') + ' ' + rules.join(', ');
1966            }
1967            return {
1968                cls: lc.ui_class,
1969                pill: (lc.short_pt || lc.label_pt || '—').trim(),
1970                hint: hint || (lc.label_pt || '').trim() || __('js_lat_tier_fallback_hint'),
1971            };
1972        }
1973        if (typeof DetectorLatencyClassify !== 'undefined' && DetectorLatencyClassify.classifyFromSvc) {
1974            var fb = DetectorLatencyClassify.classifyFromSvc(svc);
1975            var h2 = (fb.description_pt || '').trim();
1976            if (fb.rules_applied && fb.rules_applied.length) {
1977                h2 += (h2 ? ' · ' : '') + __('js_lat_rules_label') + ' ' + fb.rules_applied.join(', ');
1978            }
1979            return {
1980                cls: fb.ui_class,
1981                pill: (fb.short_pt || '—').trim(),
1982                hint: h2 || __('js_lat_tier_client_hint'),
1983            };
1984        }
1985        return { cls: 'lat-tier-unk', pill: __('js_pill_no_data'), hint: __('js_lat_no_class') };
1986    }
1987
1988    /** Tags IPv4 (azul) / IPv6 (dourado) conforme registos DNS A / AAAA do hostname sondado. */
1989    function ipStackTagsHtml(svc) {
1990        var h4 = svc.has_ipv4 === true;
1991        var h6 = svc.has_ipv6 === true;
1992        if (!h4 && !h6) return '';
1993        var parts = [];
1994        if (h4) {
1995            parts.push(
1996                '<span class="ip-tag ip-tag--v4" title="' +
1997                    esc(__('js_ip_tag_v4_title')) +
1998                    '">IPv4</span>',
1999            );
2000        }
2001        if (h6) {
2002            parts.push(
2003                '<span class="ip-tag ip-tag--v6" title="' +
2004                    esc(__('js_ip_tag_v6_title')) +
2005                    '">IPv6</span>',
2006            );
2007        }
2008        return (
2009            '<div class="tile-ip-tags" role="group" aria-label="' +
2010            esc(__('js_ip_stack_aria')) +
2011            '">' +
2012            parts.join('') +
2013            '</div>'
2014        );
2015    }
2016
2017    function ipStackTagsHtmlCompact(svc) {
2018        var h4 = svc.has_ipv4 === true;
2019        var h6 = svc.has_ipv6 === true;
2020        if (!h4 && !h6) return '';
2021        var p = [];
2022        if (h4) p.push('<span class="ip-tag ip-tag--v4 ip-tag--compact">IPv4</span>');
2023        if (h6) p.push('<span class="ip-tag ip-tag--v6 ip-tag--compact">IPv6</span>');
2024        return '<span class="rank-ip-tags">' + p.join('') + '</span>';
2025    }
2026
2027    /** Ícone «i» (Wikimedia Commons Info.svg), via https — CSP permite img https:. */
2028    var INFO_ICON_HTML =
2029        '<img class="tile-info-ico" src="https://upload.wikimedia.org/wikipedia/commons/e/e1/Info.svg" width="17" height="17" alt="" decoding="async" loading="lazy" referrerpolicy="no-referrer" />';
2030
2031    function sparklineSvg(pts, chartW) {
2032        var W = chartW != null ? chartW : 112;
2033        var H = 34;
2034        var pad = 2;
2035        var arr = Array.isArray(pts)
2036            ? pts
2037                  .map(function (x) {
2038                      return Number(x);
2039                  })
2040                  .filter(function (x) {
2041                      return !isNaN(x) && isFinite(x);
2042                  })
2043            : [];
2044        if (arr.length === 0) {
2045            return (
2046                '<svg class="tile-spark-svg tile-spark-svg--latency" viewB
2046ox="0 0 ' +
2047                W +
2048                ' ' +
2049                H +
2050                '" preserveAspectRatio="none" aria-hidden="true"><line class="tile-spark-empty" x1="' +
2051                pad +
2052                '" y1="' +
2053                H / 2 +
2054                '" x2="' +
2055                (W - pad) +
2056                '" y2="' +
2057                H / 2 +
2058                '" stroke-width="1.75" stroke-linecap="round" stroke-dasharray="4 3"/></svg>'
2059            );
2060        }
2061        if (arr.length === 1) {
2062            var cx = W / 2;
2063            var cy = H / 2;
2064            return (
2065                '<svg class="tile-spark-svg tile-spark-svg--latency" viewBox="0 0 ' +
2066                W +
2067                ' ' +
2068                H +
2069                '" preserveAspectRatio="none" aria-hidden="true"><circle class="tile-spark-dot" cx="' +
2070                cx +
2071                '" cy="' +
2072                cy +
2073                '" r="4"/></svg>'
2074            );
2075        }
2076        var mn = Math.min.apply(null, arr);
2077        var mx = Math.max.apply(null, arr);
2078        if (mx - mn < 1e-6) {
2079            mn -= 1;
2080            mx += 1;
2081        }
2082        var n = arr.length;
2083        var yB = H - pad;
2084        var xy = [];
2085        var i;
2086        for (i = 0; i < n; i++) {
2087            var x = pad + ((W - 2 * pad) * i) / (n - 1);
2088            var y = pad + (H - 2 * pad) * (1 - (arr[i] - mn) / (mx - mn));
2089            xy.push({ x: x, y: y });
2090        }
2091        var dLine = '';
2092        for (i = 0; i < n; i++) {
2093            dLine += (i === 0 ? 'M' : 'L') + xy[i].x.toFixed(2) + ',' + xy[i].y.toFixed(2) + ' ';
2094        }
2095        dLine = dLine.trim();
2096        var dArea =
2097            'M ' +
2098            xy[0].x.toFixed(2) +
2099            ',' +
2100            yB.toFixed(2) +
2101            ' L ' +
2102            xy[0].x.toFixed(2) +
2103            ',' +
2104            xy[0].y.toFixed(2) +
2105            ' ';
2106        for (i = 1; i < n; i++) {
2107            dArea += 'L ' + xy[i].x.toFixed(2) + ',' + xy[i].y.toFixed(2) + ' ';
2108        }
2109        dArea += 'L ' + xy[n - 1].x.toFixed(2) + ',' + yB.toFixed(2) + ' Z';
2110        return (
2111            '<svg class="tile-spark-svg tile-spark-svg--latency" viewBox="0 0 ' +
2112            W +
2113            ' ' +
2114            H +
2115            '" preserveAspectRatio="none" aria-hidden="true">' +
2116            '<path class="tile-spark-area" d="' +
2117            dArea +
2118            '"/>' +
2119            '<path class="tile-spark-glow" fill="none" d="' +
2120            dLine +
2121            '"/>' +
2122            '<path class="tile-spark-line" fill="none" d="' +
2123            dLine +
2124            '"/>' +
2125            '</svg>'
2126        );
2127    }
2128
2129    /** Sparkline de relatos (comunidade) — cor quente, distinta da latência. */
2130    function complaintsSparklineSvg(pts, chartW) {
2131        var W = chartW != null ? chartW : 112;
2132        var H = 34;
2133        var pad = 2;
2134        var arr = Array.isArray(pts)
2135            ? pts
2136                  .map(function (x) {
2137                      return Number(x);
2138                  })
2139                  .filter(function (x) {
2140                      return !isNaN(x) && isFinite(x) && x >= 0;
2141                  })
2142            : [];
2143        if (arr.length === 0) {
2144            return (
2145                '<svg class="tile-spark-svg tile-spark-svg--complaints" viewBox="0 0 ' +
2146                W +
2147                ' ' +
2148                H +
2149                '" preserveAspectRatio="none" aria-hidden="true"><line class="tile-spark-empty" x1="' +
2150                pad +
2151                '" y1="' +
2152                H / 2 +
2153                '" x2="' +
2154                (W - pad) +
2155                '" y2="' +
2156                H / 2 +
2157                '" stroke-width="1.75" stroke-linecap="round" stroke-dasharray="4 3"/></svg>'
2158            );
2159        }
2160        if (arr.length === 1) {
2161            var cx = W / 2;
2162            var cy = H / 2;
2163            return (
2164                '<svg class="tile-spark-svg tile-spark-svg--complaints" viewBox="0 0 ' +
2165                W +
2166                ' ' +
2167                H +
2168                '" preserveAspectRatio="none" aria-hidden="true"><circle class="tile-spark-dot" cx="' +
2169                cx +
2170                '" cy="' +
2171                cy +
2172                '" r="4"/></svg>'
2173            );
2174        }
2175        var mn = Math.min.apply(null, arr);
2176        var mx = Math.max.apply(null, arr);
2177        if (mx - mn < 1e-6) {
2178            mn -= 1;
2179            mx += 1;
2180        }
2181        var n = arr.length;
2182        var yB = H - pad;
2183        var xy = [];
2184        var i;
2185        for (i = 0; i < n; i++) {
2186            var x = pad + ((W - 2 * pad) * i) / (n - 1);
2187            var y = pad + (H - 2 * pad) * (1 - (arr[i] - mn) / (mx - mn));
2188            xy.push({ x: x, y: y });
2189        }
2190        var dLine = '';
2191        for (i = 0; i < n; i++) {
2192            dLine += (i === 0 ? 'M' : 'L') + xy[i].x.toFixed(2) + ',' + xy[i].y.toFixed(2) + ' ';
2193        }
2194        dLine = dLine.trim();
2195        var dArea =
2196            'M ' +
2197            xy[0].x.toFixed(2) +
2198            ',' +
2199            yB.toFixed(2) +
2200            ' L ' +
2201            xy[0].x.toFixed(2) +
2202            ',' +
2203            xy[0].y.toFixed(2) +
2204            ' ';
2205        for (i = 1; i < n; i++) {
2206            dArea += 'L ' + xy[i].x.toFixed(2) + ',' + xy[i].y.toFixed(2) + ' ';
2207        }
2208        dArea += 'L ' + xy[n - 1].x.toFixed(2) + ',' + yB.toFixed(2) + ' Z';
2209        return (
2210            '<svg class="tile-spark-svg tile-spark-svg--complaints" viewBox="0 0 ' +
2211            W +
2212            ' ' +
2213            H +
2214            '" preserveAspectRatio="none" aria-hidden="true">' +
2215            '<path class="tile-spark-area" d="' +
2216            dArea +
2217            '"/>' +
2218            '<path class="tile-spark-glow" fill="none" d="' +
2219            dLine +
2220            '"/>' +
2221            '<path class="tile-spark-line" fill="none" d="' +
2222            dLine +
2223            '"/>' +
2224            '</svg>'
2225        );
2226    }
2227
2228    var TCP_PORT_SVC_LABEL = {
2229        20: 'FTP-D',
2230        21: 'FTP',
2231        22: 'SSH',
2232        23: 'Telnet',
2233        25: 'SMTP',
2234        53: 'DNS',
2235        80: 'HTTP',
2236        443: 'HTTPS',
2237        3306: 'MySQL',
2238        3389: 'RDP',
2239        8080: 'HTTP·alt',
2240        8443: 'HTTPS·alt',
2241    };
2242
2243    function tcpPortServiceLabel(port) {
2244        var p = Number(port);
2245        if (!isFinite(p)) return 'TCP';
2246        return TCP_PORT_SVC_LABEL[p] || 'TCP';
2247    }
2248
2249    function tcpPortsRowHtml(svc) {
2250        var pp = state.probeProfile || {};
2251        if (pp.tcp_port_status_enabled === false) return '';
2252        var rows = svc.tcp_ports;
2253        if (!rows || !rows.length) return '';
2254        var parts = rows
2255            .map(function (r) {
2256                var p = Number(r.port);
2257                if (!isFinite(p)) return '';
2258                var ok = r.ok === true;
2259                var cls = ok
2260                    ? 'tcp-port-pill tcp-port-pill--open'
2261                    : r.ok === false
2262                      ? 'tcp-port-pill tcp-port-pill--closed'
2263                      : 'tcp-port-pill tcp-port-pill--unk';
2264                var svcLb = tcpPortServiceLabel(p);
2265                var title = ok
2266                    ? __('js_tcp_pill_open_tpl').replace(/\{p\}/g, String(p)).replace(/\{svc\}/g, svcLb)
2267                    : r.ok === false
2268                      ? __('js_tcp_pill_closed_tpl').replace(/\{p\}/g, String(p)).replace(/\{svc\}/g, svcLb)
2269                      : __('js_tcp_pill_wait_tpl').replace(/\{p\}/g, String(p)).replace(/\{svc\}/g, svcLb);
2270                var ms = r.latency_ms != null ? Math.round(Number(r.latency_ms)) + ' ms' : '';
2271                return (
2272                    '<span class="' +
2273                    cls +
2274                    '" title="' +
2275                    esc(title) +
2276                    '"><span class="tcp-port-num">' +
2277                    esc(String(p)) +
2278                    '</span>' +
2279                    (ms ? '<span class="tcp-port-ms">' + esc(ms) + '</span>' : '') +
2280                    '</span>'
2281                );
2282            })
2283            .join('');
2284        if (!parts.trim()) return '';
2285        return (
2286            '<div class="tile-tcp-ports" role="group" aria-label="' +
2287            esc(__('js_tcp_group_aria')) +
2288            '">' +
2289            parts +
2290            '</div>'
2291        );
2292    }
2293
2294    function renderProbeLayersBlock(svc, detail) {
2295        var el = document.getElementById('svcProbeLayersHost');
2296        if (!el) return;
2297        var prof = (detail && detail.probe_profile) || state.probeProfile || {};
2298        var pp = detail && detail.primary_probe;
2299        var icmpD = detail && detail.icmp_echo;
2300        var notePt =
2301            (detail && detail.availability_note_pt) || __('js_prob_hint_tls');
2302
2303        var primaryOk = pp && pp.ok !== undefined && pp.ok !== null ? pp.ok : svc.last_ok;
2304        var primaryMs =
2305            pp && pp.latency_ms != null
2306                ? Number(pp.latency_ms)
2307                : svc.last_latency_ms != null
2308                  ? Number(svc.last_latency_ms)
2309                  : null;
2310        var primaryLabel = (pp && pp.mode_label_pt) || prof.mode_label_pt || prof.mode || 'Sonda principal';
2311        var primaryEp = (pp && pp.probe_endpoint) || svc.card_probe_url || svc.probe_url || '';
2312        var targetKey = (pp && pp.target_key) || svc.card_target_key || 'primary';
2313
2314        var primaryCardCls = 'probe-layer-card';
2315        if (primaryOk === true) primaryCardCls += ' probe-layer-card--ok';
2316        else if (primaryOk === false) primaryCardCls += ' probe-layer-card--bad';
2317        else primaryCardCls += ' probe-layer-card--unk';
2318
2319        var primaryStateTxt;
2320        if (primaryOk === true) {
2321            primaryStateTxt =
2322                primaryMs != null && !isNaN(primaryMs)
2323                    ? __('js_probe_operational_ms').replace(
2324                          /\{ms\}/g,
2325                          String(Math.round(primaryMs)),
2326                      )
2327                    : __('js_probe_operational');
2328        } else if (primaryOk === false) {
2329            primaryStateTxt = __('js_probe_main_timeout');
2330        } else {
2331            primaryStateTxt = __('js_primary_no_measure');
2332        }
2333
2334        var icmpOn = prof.icmp_status_enabled !== false;
2335        var icmpCardCls = 'probe-layer-card';
2336        var icmpBody = '';
2337        if (!icmpOn) {
2338            icmpCardCls += ' probe-layer-card--off';
2339            icmpBody = __('js_probe_disabled');
2340        } else if (icmpD && icmpD.enabled === false) {
2341            icmpCardCls += ' probe-layer-card--off';
2342            icmpBody = __('js_probe_disabled');
2343        } else if (icmpD && icmpD.ok === true) {
2344            icmpCardCls += ' probe-layer-card--ok';
2345            var il = icmpD.latency_ms != null ? Math.round(Number(icmpD.latency_ms)) + ' ms' : '';
2346            var pl =
2347                icmpD.packet_loss_pct != null
2348                    ? __('js_probe_icmp_loss').replace(
2349                          /\{pct\}/g,
2350                          Number(icmpD.packet_loss_pct).toFixed(1),
2351                      )
2352                    : '';
2353            icmpBody = __('js_icmp_ok') + (il ? ' · ' + il : '') + pl;
2354        } else if (icmpD && icmpD.ok === false) {
2355            icmpCardCls += ' probe-layer-card--bad';
2356            icmpBody = __('js_icmp_no');
2357        } else if (icmpD) {
2358            icmpCardCls += ' probe-layer-card--unk';
2359            icmpBody = __('js_icmp_wait');
2360        } else if (svc.icmp_responds === true) {
2361            icmpCardCls += ' probe-layer-card--ok';
2362            var il0 = svc.icmp_latency_ms != null ? Math.round(Number(svc.icmp_latency_ms)) + ' ms' : '';
2363            icmpBody = __('js_icmp_ok') + (il0 ? ' · ' + il0 : '');
2364        } else if (svc.icmp_responds === false) {
2365            icmpCardCls += ' probe-layer-card--bad';
2366            icmpBody = __('js_icmp_no');
2367        } else {
2368            icmpCardCls += ' probe-layer-card--unk';
2369            icmpBody = __('js_icmp_wait');
2370        }
2371
2372        var httpHint = '';
2373        if (detail && Array.isArray(detail.tcp_ports)) {
2374            var p80 = detail.tcp_ports.filter(function (x) { return Number(x.port) === 80; })[0];
2375            var p443 = detail.tcp_ports.filter(function (x) { return Number(x.port) === 443; })[0];
2376                if (p80 || p443) {
2377                var bits = [];
2378                var st80 =
2379                    p80 && (p80.ok === true
2380                        ? __('js_tcp_state_open')
2381                        : p80.ok === false
2382                          ? __('js_tcp_state_closed')
2383                          : __('js_tcp_state_wait'));
2384                var st443 =
2385                    p443 && (p443.ok === true
2386                        ? __('js_tcp_state_open')
2387                        : p443.ok === false
2388                          ? __('js_tcp_state_closed')
2389                          : __('js_tcp_state_wait'));
2390                if (p80) bits.push(__('js_probe_line_80').replace(/\{state\}/g, st80));
2391                if (p443) bits.push(__('js_probe_line_443').replace(/\{state\}/g, st443));
2392                httpHint =
2393                    '<div class="probe-layer-sub" style="margin-top:10px"><strong>' +
2394                    esc(__('js_probe_tcp_vs')) +
2395                    '</strong> ' +
2396                    esc(bits.join(' · ')) +
2397                    '</div>';
2398            }
2399        }
2400
2401        el.style.display = 'block';
2402        el.innerHTML =
2403            '<h4 class="svc-probe-layers-title">' +
2404            esc(__('js_probe_layers_title')) +
2405            '</h4>' +
2406            '<div class="probe-layer-grid">' +
2407            '<div class="' +
2408            esc(primaryCardCls) +
2409            '">' +
2410            '<div class="probe-layer-k">' +
2411            esc(__('js_probe_main_key').replace(/\{key\}/g, String(targetKey))) +
2412            '</div>' +
2413            '<div class="probe-layer-v">' +
2414            esc(primaryStateTxt) +
2415            '</div>' +
2416            '<div class="probe-layer-sub">' +
2417            esc(primaryLabel) +
2418            '</div>' +
2419            (primaryEp
2420                ? '<div class="probe-layer-sub probe-layer-sub--ep">' +
2421                  esc(
2422                      String(primaryEp).length > 72
2423                          ? String(primaryEp).slice(0, 69) + '\u2026'
2424                          : String(primaryEp),
2425                  ) +
2426                  '</div>'
2427                : '') +
2428            '</div>' +
2429            '<div class="' +
2430            esc(icmpCardCls) +
2431            '">' +
2432            '<div class="probe-layer-k">' +
2433            esc(__('js_probe_icmp')) +
2434            '</div>' +
2435            '<div class="probe-layer-v">' +
2436            esc(icmpBody) +
2437            '</div>' +
2438            '<div class="probe-layer-sub">' +
2439            esc(__('js_probe_icmp_sub')) +
2440            '</div>' +
2441            '</div>' +
2442            '</div>' +
2443            httpHint +
2444            (notePt && String(notePt).trim()
2445                ? '<p class="probe-avail-note" role="note">' +
2446                  esc(
2447                      String(notePt).length > 100
2448                          ? String(notePt).trim().slice(0, 97) + '\u2026'
2449                          : String(notePt).trim(),
2450                  ) +
2451                  '</p>'
2452                : '');
2453    }
2454
2455    function renderSvcTcpPortsBlock(rows, prof) {
2456        var el = document.getElementById('svcTcpPortsHost');
2457        if (!el) return;
2458        var pp = prof && typeof prof === 'object' ? prof : state.probeProfile || {};
2459        if (pp.tcp_port_status_enabled === false) {
2460            el.innerHTML = '';
2461            el.style.display = 'none';
2462            return;
2463        }
2464        el.style.display = 'block';
2465        if (!rows || !rows.length) {
2466            el.innerHTML =
2467                '<div class="svc-tcp-ports-wrap-inner"><h4 class="svc-tcp-ports-title">' +
2468                esc(__('js_tcp_title')) +
2469                '</h4><p class="svc-tcp-ports-note">' +
2470                esc(__('js_tcp_disabled_note')) +
2471                '</p></div>';
2472            return;
2473        }
2474        var cells = rows
2475            .map(function (r) {
2476                var p = Number(r.port);
2477                if (!isFinite(p)) return '';
2478                var ok = r.ok === true;
2479                var cellCls = ok
2480                    ? 'tcp-port-cell tcp-port-cell--open'
2481                    : r.ok === false
2482                      ? 'tcp-port-cell tcp-port-cell--closed'
2483                      : 'tcp-port-cell tcp-port-cell--unk';
2484                var ms = r.latency_ms != null ? Math.round(Number(r.latency_ms)) + ' ms' : '—';
2485                var svcLb = tcpPortServiceLabel(p);
2486                var stLab = ok
2487                    ? __('js_tcp_cell_open')
2488                    : r.ok === false
2489                      ? __('js_tcp_cell_closed')
2490                      : __('js_tcp_cell_wait');
2491                return (
2492                    '<div class="' +
2493                    cellCls +
2494                    '" title="' +
2495                    esc(
2496                        __('js_tcp_port_title_tpl')
2497                            .replace(/\{p\}/g, String(p))
2498                            .replace(/\{svc\}/g, svcLb)
2499                            .replace(/\{st\}/g, stLab),
2500                    ) +
2501                    '">' +
2502                    '<div class="tcp-port-cell-num">' +
2503                    esc(String(p)) +
2504                    '</div>' +
2505                    '<div class="tcp-port-cell-svc">' +
2506                    esc(svcLb) +
2507                    '</div>' +
2508                    '<div class="tcp-port-cell-st">' +
2509                    esc(stLab) +
2510                    '</div>' +
2511                    '<div class="tcp-port-cell-ms">' +
2512                    esc(ms) +
2513                    '</div></div>'
2514                );
2515            })
2516            .join('');
2517        el.innerHTML =
2518            '<div class="svc-tcp-ports-wrap-inner"><h4 class="svc-tcp-ports-title">' +
2519            esc(__('js_tcp_title')) +
2520            '</h4>' +
2521            '<div class="tcp-port-grid">' +
2522            cells +
2523            '</div></div>';
2524    }
2525
2526    function tileHtml(svc) {
2527        var bd = badgeForService(svc);
2528        var rankHtml = svc.latency_rank ? '<span class="poster-rank">#' + esc(svc.latency_rank) + '</span>' : '';
2529        var tsNum = Number(svc.trust_score);
2530        var trustHtml = '';
2531        if (!isNaN(tsNum) && tsNum > 0) {
2532            var tCls = tsNum >= 90 ? '' : tsNum >= 80 ? ' warn' : ' bad';
2533            trustHtml =
2534                '<span class="tile-trust-score' +
2535                tCls +
2536                '" title="' +
2537                esc(__('js_tile_trust_title')) +
2538                '">' +
2539                esc(tsNum.toFixed(1)) +
2540                '</span>';
2541        }
2542        var latCol;
2543        var tileLatMs = rankingLatencyMs(svc);
2544        if (svc.last_ok === true && tileLatMs != null && !isNaN(tileLatMs)) {
2545            latCol =
2546                '<div class="poster-lat"><span class="lat-val">' +
2547                esc(Math.round(tileLatMs)) +
2548                '</span><span class="lat-unit">ms</span></div>';
2549        } else if (svc.last_ok === false) {
2550            latCol =
2551                '<div class="poster-lat bad"><span class="lat-val">—</span><span class="lat-unit">' +
2552                esc(__('js_tile_lat_error')) +
2553                '</span></div>';
2554        } else {
2555            latCol =
2556                '<div class="poster-lat unk"><span class="lat-val">…</span><span class="lat-unit">ms</span></div>';
2557        }
2558        var stCls = svc.last_ok === true ? 'state-ok' : (svc.last_ok === false ? 'state-bad' : 'state-unknown');
2559        var lt = latencyTierMeta(svc);
2560        var cv = Number(svc.community_vote_count) || 0;
2561        var cvSig = Number(svc.community_vote_count_signals) || 0;
2562        var cvVer = Number(svc.community_vote_count_verified);
2563        if (isNaN(cvVer)) cvVer = Math.max(0, cv - cvSig);
2564        var vAvgField = svc.community_vote_avg_per_hour;
2565        var useAvgDisp =
2566            vAvgField != null && vAvgField !== '' && !isNaN(Number(vAvgField));
2567        var cvDispStr = useAvgDisp ? Number(vAvgField).toFixed(1) : String(cv);
2568        var cvW = cv === 1 ? __('js_relato_word') : __('js_relato_plural');
2569        var cvTitleBase = useAvgDisp
2570            ? __('js_tile_comm_avg_hint_tpl')
2571                  .replace(/\{avg\}/g, cvDispStr)
2572                  .replace(/\{total\}/g, String(cv))
2573            : __('js_tile_comm_title_tpl').replace(/\{n\}/g, String(cv)).replace(/\{w\}/g, cvW);
2574        var cvTitle = cvTitleBase;
2575        var showCvBadge = cv > 0 || useAvgDisp;
2576        var cvBadge =
2577            showCvBadge
2578                ? '<button type="button" class="tile-community-cnt" data-cs-sid="' +
2579                  esc(svc.id) +
2580                  '" data-cs-name="' +
2581                  esc(svc.name || svc.id) +
2582                  '" title="' +
2583                  esc(cvTitle) +
2584                  '">' +
2585                  esc(cvDispStr) +
2586                  '</button>'
2587                : '';
2588        var avgRating = Number(svc.avg_star_rating) || 0;
2589        var starBadge =
2590            '<button type="button" class="tile-star-btn" data-star-sid="' +
2591            esc(svc.id) +
2592            '" data-star-name="' +
2593            esc(svc.name || svc.id) +
2594            '" title="' +
2595            esc(__('js_tile_rate_title')) +
2596            '">' +
2597            '<span class="star-ic">&#9733;</span>' +
2598            '<span class="star-avg">' +
2599            (avgRating > 0 ? avgRating.toFixed(1) : esc(__('js_tile_rate_cta'))) +
2600            '</span></button>';
2601        var a11yName = esc(svc.name || svc.id || __('js_tile_service_fallback'));
2602        return (
2603            '<div tabindex="0" role="group" class="tile tile-3d ' +
2604            stCls +
2605            ' ' +
2606            lt.cls +
2607            '" data-svc-id="' +
2608            esc(svc.id) +
2609            '" aria-label="' +
2610            a11yName +
2611            esc(__('js_tile_aria_hub')) +
2612            '">' +
2613            '<button type="button" class="tile-report-tag" title="' +
2614            esc(__('js_tile_report_title')) +
2615            '">' +
2616            esc(__('js_tile_report')) +
2617            '</button>' +
2618            cvBadge +
2619            '<div class="poster">' +
2620            starBadge +
2621            trustHtml +
2622            rankHtml +
2623            '<div class="poster-logo-wrap">' +
2624            detLogoImgHtml(svc, '', ' width="96" height="96"') +
2625            '</div>' +
2626            latCol +
2627            '</div>' +
2628            '<div class="meta">' +
2629            ipStackTagsHtml(svc) +
2630            '<span class="tile-lat-pill ' +
2631            lt.cls +
2632            '" title="' +
2633            esc(lt.hint) +
2634            '">' +
2635            esc(lt.pill) +
2636            '</span><div class="name">' +
2637            esc(svc.name) +
2638            '</div><span class="badge ' +
2639            bd.cls +
2640            '">' +
2641            esc(bd.label) +
2642            '</span></div>' +
2643            tcpPortsRowHtml(svc) +
2644            '<div class="tile-sparkline-wrap" title="Tendência recente da latência (destino do cartão)">' +
2645            sparklineSvg(svc.latency_sparkline_ms, null) +
2646            '</div>' +
2647            '<div class="tile-complaints-trend-wrap" data-complaints-trend="' +
2648            esc(svc.id) +
2649            '" hidden aria-hidden="true"></div>' +
2650            '<button type="button" class="tile-more-btn" data-more-sid="' +
2651            esc(svc.id) +
2652            '" aria-label="+ info: mais detalhes, hub e gráfico"><span class="tile-info-row">' +
2653            INFO_ICON_HTML +
2654            '<span class="tile-info-txt">+ info</span></span></button></div>'
2655        );
2656    }
2657
2658    function bindCommunityReportButtons(root) {
2659        if (!root) return;
2660        root.querySelectorAll('.tile-report-tag').forEach(function (btn) {
2661            btn.addEventListener('click', function (e) {
2662                e.preventDefault();
2663                e.stopPropagation();
2664                var tile = btn.closest('.tile');
2665                var id = tile && tile.getAttribute('data-svc-id');
2666                if (id) openCommunityReportModal(id);
2667            });
2668        });
2669        root.querySelectorAll('.list-report-btn').forEach(function (btn) {
2670            btn.addEventListener('click', function (e) {
2671                e.preventDefault();
2672                e.stopPropagation();
2673                var id = btn.getAttribute('data-report-sid');
2674                if (id) openCommunityReportModal(id);
2675            });
2676        });
2677    }
2678
2679    /* ── Community Stats Modal (score click) ── */
2680    var _csChart = null;
2681
2682    function bindCommunityStatsButtons(root) {
2683        if (!root) return;
2684        root.querySelectorAll('.tile-community-cnt[data-cs-sid]').forEach(function (btn) {
2685            btn.addEventListener('click', function (e) {
2686                e.preventDefault();
2687                e.stopPropagation();
2688                var sid = btn.getAttribute('data-cs-sid');
2689                var sname = btn.getAttribute('data-cs-name') || sid;
2690                if (sid) openCommunityStatsModal(sid, sname);
2691            });
2692        });
2693    }
2694
2695    function bindStarRatingButtons(root) {
2696        if (!root) return;
2697        root.querySelectorAll('.tile-star-btn[data-star-sid]').forEach(function (btn) {
2698            btn.addEventListener('click', function (e) {
2699                e.preventDefault();
2700                e.stopPropagation();
2701                var sid = btn.getAttribute('data-star-sid');
2702                var sname = btn.getAttribute('data-star-name') || sid;
2703                if (sid) openStarRatingModal(sid, sname);
2704            });
2705        });
2706    }
2707
2708    /** Só suprime o clique se o dedo deslizou claramente em horizontal (carrossel). touchcancel não marca como arrasto. */
2709    function bindTouchAwareOpen(el, openFn) {
2710        var ptr = { x0: 0, y0: 0, horizontalScroll: false };
2711        el.addEventListener(
2712            'touchstart',
2713            function (e) {
2714                if (e.touches.length !== 1) return;
2715                ptr.x0 = e.touches[0].clientX;
2716                ptr.y0 = e.touches[0].clientY;
2717                ptr.horizontalScroll = false;
2718            },
2719            { passive: true },
2720        );
2721        el.addEventListener(
2722            'touchmove',
2723            function (e) {
2724                if (!e.touches.length) return;
2725                var dx = e.touches[0].clientX - ptr.x0;
2726                var dy = e.touches[0].clientY - ptr.y0;
2727                if (Math.abs(dx) > 26 && Math.abs(dx) > Math.abs(dy) * 1.2) ptr.horizontalScroll = true;
2728            },
2729            { passive: true },
2730        );
2731        el.addEventListener('touchcancel', function () {
2732            ptr.horizontalScroll = false;
2733        }, { passive: true });
2734        el.addEventListener('click', function (e) {
2735            if (ptr.horizontalScroll) {
2736                e.preventDefault();
2737                e.stopPropagation();
2738                ptr.horizontalScroll = false;
2739                return;
2740            }
2741            if (
2742                e.target.closest(
2743                    'button.tile-report-tag, button.tile-more-btn, button.list-report-btn, button.list-more-btn',
2744                )
2745            ) {
2746                return;
2747            }
2748            openFn(el);
2749        });
2750    }
2751
2752    /** Liga clique/toque nos `.tile` e `.list-row` dentro de um contentor (carrosséis Top 10, ICMP, prateleiras). */
2753    function attachTileInteractions(container, openCallback) {
2754        if (!container || typeof openCallback !== 'function') return;
2755        container.querySelectorAll('.tile[data-svc-id], .list-row[data-svc-id]').forEach(function (el) {
2756            function doOpen() {
2757                openCallback(el);
2758            }
2759            bindTouchAwareOpen(el, doOpen);
2760            el.addEventListener('keydown', function (e) {
2761                if (e.key === 'Enter' || e.key === ' ') {
2762                    e.preventDefault();
2763                    doOpen();
2764                }
2765            });
2766        });
2767    }
2768
2769    /* ── Star Rating Modal ── */
2770    var _starState = { svcId: null, svcName: '', stars: 0, verifyToken: null };
2771    var STAR_LABELS = ['', 'Péssimo', 'Ruim', 'Regular', 'Bom', 'Excelente'];
2772
2773    function starRatingParseDetail(detail) {
2774        if (detail == null || detail === '') return '';
2775        if (typeof detail === 'string') return detail;
2776        if (Array.isArray(detail)) {
2777            return detail
2778                .map(function (item) {
2779                    if (typeof item === 'string') return item;
2780                    if (item && item.msg) return String(item.msg);
2781                    return '';
2782                })
2783                .filter(Boolean)
2784                .join(' ');
2785        }
2786        return String(detail);
2787    }
2788
2789    function starRatingUserMessage(status, data) {
2790        var d = data || {};
2791        if (d.message_pt) return d.message_pt;
2792        var det = starRatingParseDetail(d.detail);
2793        if (det) return det;
2794        if (status === 429 && d.retry_after_sec != null) {
2795            var sec = Math.max(0, Number(d.retry_after_sec) || 0);
2796            var h = Math.floor(sec / 3600);
2797            var m = Math.floor((sec % 3600) / 60);
2798            if (h > 0) {
2799                return (
2800                    'Você já votou neste serviço nas últimas 24 horas. Aguarde mais ' +
2801                    h +
2802                    (h === 1 ? ' hora' : ' horas') +
2803                    (m > 0 ? ' e ' + m + ' min.' : '.')
2804                );
2805            }
2806            if (m > 0) return 'Aguarde mais ' + m + ' min. para votar de novo.';
2807        }
2808        if (status >= 500) return 'Erro no servidor. Tente mais tarde.';
2809        if (status === 400 || status === 404) return 'Pedido inválido. Atualize a página e tente de novo.';
2810        return 'Erro ao processar o pedido (código ' + status + ').';
2811    }
2812
2813    function setStarMsgNeutral(text) {
2814        var msg = document.getElementById('starMsg');
2815        if (!msg) return;
2816        msg.className = 'star-msg';
2817        msg.textContent = text || '';
2818    }
2819
2820    function setStarMsgOk(text) {
2821        var msg = document.getElementById('starMsg');
2822        if (!msg) return;
2823        msg.className = 'star-msg ok';
2824        msg.innerHTML =
2825            '<span class="star-ok-tick" aria-hidden="true">✓</span><span class="star-ok-text">' +
2826            esc(text || 'Concluído.') +
2827            '</span>';
2828    }
2829
2830    function setStarMsgErr(text) {
2831        var msg = document.getElementById('starMsg');
2832        if (!msg) return;
2833        msg.className = 'star-msg err';
2834        msg.textContent = text || 'Erro.';
2835    }
2836
2837    function starRatingPostJson(url, body) {
2838        return fetch(url, {
2839            method: 'POST',
2840            headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2841            /* same-origin: envia cookie CSRF; csrf.js acrescenta X-CSRF-Token (double-submit) */
2842            credentials: 'same-origin',
2843            body: JSON.stringify(body),
2844        }).then(function (r) {
2845            return r.text().then(function (t) {
2846                var data = {};
2847                if (t && String(t).trim()) {
2848                    try {
2849                        data = JSON.parse(t);
2850                    } catch (e) {
2851                        data = { message_pt: 'Resposta inválida do servidor.' };
2852                    }
2853                }
2854                return { status: r.status, okHttp: r.ok, data: data };
2855            });
2856        });
2857    }
2858
2859    function openStarRatingModal(svcId, svcName, presetStars) {
2860        _starState = { svcId: svcId, svcName: svcName, stars: 0, verifyToken: null };
2861        var modal = document.getElementById('starRatingModal');
2862        if (!modal) return;
2863        document.getElementById('starSvcName').textContent = svcName || svcId;
2864        document.getElementById('starLabel').textContent = 'Toque numa estrela';
2865        document.getElementById('starAuthSection').style.display = 'none';
2866        document.getElementById('starCodeRow').style.display = 'none';
2867        var msg = document.getElementById('starMsg');
2868        if (msg) {
2869            msg.textContent = '';
2870            msg.innerHTML = '';
2871            msg.className = 'star-msg';
2872        }
2873        var emailIn = document.getElementById('starEmail');
2874        if (emailIn) emailIn.value = '';
2875        var codeIn = document.getElementById('starCode');
2876        if (codeIn) codeIn.value = '';
2877        document.querySelectorAll('#starPicker button').forEach(function (b) {
2878            b.classList.remove('active');
2879        });
2880        var ps = Number(presetStars);
2881        if (!isNaN(ps) && ps >= 1 && ps <= 5) {
2882            _starState.stars = ps;
2883            document.querySelectorAll('#starPicker button').forEach(function (b) {
2884                b.classList.toggle('active', Number(b.getAttribute('data-star')) <= ps);
2885            });
2886            document.getElementById('starLabel').textContent = STAR_LABELS[ps] || '';
2887            document.getElementById('starAuthSection').style.display = '';
2888        }
2889        modal.classList.add('open');
2890        document.body.style.overflow = 'hidden';
2891        _initStarGoogleButton();
2892    }
2893
2894    function closeStarRatingModal() {
2895        var modal = document.getElementById('starRatingModal');
2896        if (modal) modal.classList.remove('open');
2897        document.body.style.overflow = '';
2898        _starState = { svcId: null, svcName: '', stars: 0, verifyToken: null };
2899    }
2900
2901    function _initStarGoogleButton() {
2902        var host = document.getElementById('starGoogleHost');
2903        if (!host) return;
2904        var cid = state.googleClientId || '';
2905        if (!cid) { host.innerHTML = '<p style="color:var(--muted);font-size:0.72rem;">Google não configurado.</p>'; return; }
2906        if (typeof google === 'undefined' || !google.accounts || !google.accounts.id) {
2907            setTimeout(_initStarGoogleButton, 250);
2908            return;
2909        }
2910        host.innerHTML = '';
2911        try {
2912            google.accounts.id.initialize({
2913                client_id: cid,
2914                callback: _onStarGoogleCredential,
2915                auto_select: false,
2916            });
2917            google.accounts.id.renderButton(host, {
2918                type: 'standard', theme: 'filled_blue', size: 'large', text: 'continue_with', width: 280,
2919            });
2920        } catch (e) {
2921            host.innerHTML = '<p style="color:var(--bad);font-size:0.72rem;">Erro ao carregar Google.</p>';
2922        }
2923    }
2924
2925    function _onStarGoogleCredential(response) {
2926        var cred = response && response.credential;
2927        if (!cred || !_starState.svcId || !_starState.stars) {
2928            setStarMsgErr('Selecione estrelas primeiro.');
2929            return;
2930        }
2931        setStarMsgNeutral('A registar avaliação…');
2932        var origin = window.location.origin || '';
2933        starRatingPostJson(origin + '/api/detector/star-rating/start', {
2934            service_id: _starState.svcId,
2935            stars: _starState.stars,
2936            channel: 'google',
2937            google_id_token: cred,
2938        })
2939            .then(function (pack) {
2940                var d = pack.data || {};
2941                if (pack.okHttp && d.ok) {
2942                    setStarMsgOk(d.message_pt || 'Avaliação registada com sucesso!');
2943                    setTimeout(closeStarRatingModal, 2200);
2944                } else {
2945                    setStarMsgErr(starRatingUserMessage(pack.status, d));
2946                }
2947            })
2948            .catch(function () {
2949                setStarMsgErr('Erro de rede.');
2950            });
2951    }
2952
2953    (function _starModalInit() {
2954        var modal = document.getElementById('starRatingModal');
2955        if (!modal) return;
2956        modal.addEventListener('click', function (e) { if (e.target === modal) closeStarRatingModal(); });
2957        document.getElementById('starClose').addEventListener('click', closeStarRatingModal);
2958        document.addEventListener('keydown', function (e) {
2959            if (e.key === 'Escape' && modal.classList.contains('open')) closeStarRatingModal();
2960        });
2961        document.querySelectorAll('#starPicker button').forEach(function (btn) {
2962            btn.addEventListener('click', function () {
2963                var val = Number(btn.getAttribute('data-star'));
2964                _starState.stars = val;
2965                document.querySelectorAll('#starPicker button').forEach(function (b) {
2966                    b.classList.toggle('active', Number(b.getAttribute('data-star')) <= val);
2967                });
2968                document.getElementById('starLabel').textContent = STAR_LABELS[val] || '';
2969                document.getElementById('starAuthSection').style.display = '';
2970            });
2971        });
2972
2973        document.getElementById('starSendEmail').addEventListener('click', function () {
2974            var emailEl = document.getElementById('starEmail');
2975            var email = emailEl ? emailEl.value.trim() : '';
2976            if (!email || email.indexOf('@') < 1) {
2977                setStarMsgErr('Informe um e-mail válido.');
2978                return;
2979            }
2980            if (!_starState.svcId || !_starState.stars) return;
2981            setStarMsgNeutral('A enviar código…');
2982            var origin = window.location.origin || '';
2983            starRatingPostJson(origin + '/api/detector/star-rating/start', {
2984                service_id: _starState.svcId,
2985                stars: _starState.stars,
2986                channel: 'email',
2987                email: email,
2988            })
2989                .then(function (pack) {
2990                    var d = pack.data || {};
2991                    if (pack.okHttp && d.ok && d.verify_token) {
2992                        _starState.verifyToken = d.verify_token;
2993                        document.getElementById('starCodeRow').style.display = '';
2994                        setStarMsgOk(
2995                            d.message_pt ||
2996                                'Código enviado com sucesso! Verifique o seu e-mail e introduza os 4 dígitos.',
2997                        );
2998                        var cin = document.getElementById('starCode');
2999                        if (cin) setTimeout(function () { cin.focus(); }, 80);
3000                    } else {
3001                        setStarMsgErr(starRatingUserMessage(pack.status, d));
3002                    }
3003                })
3004                .catch(function () {
3005                    setStarMsgErr('Erro de rede.');
3006                });
3007        });
3008
3009        document.getElementById('starConfirmCode').addEventListener('click', function () {
3010            var codeEl = document.getElementById('starCode');
3011            var code = codeEl ? codeEl.value.replace(/\D/g, '').slice(0, 4) : '';
3012            if (code.length !== 4) {
3013                setStarMsgErr('Introduza os 4 dígitos.');
3014                return;
3015            }
3016            if (!_starState.verifyToken) return;
3017            setStarMsgNeutral('A confirmar…');
3018            var origin = window.location.origin || '';
3019            starRatingPostJson(origin + '/api/detector/star-rating/confirm', {
3020                verify_token: _starState.verifyToken,
3021                code: code,
3022            })
3023                .then(function (pack) {
3024                    var d = pack.data || {};
3025                    if (pack.okHttp && d.ok) {
3026                        setStarMsgOk(d.message_pt || 'Avaliação registada com sucesso!');
3027                        setTimeout(closeStarRatingModal, 2200);
3028                    } else {
3029                        setStarMsgErr(starRatingUserMessage(pack.status, d));
3030                    }
3031                })
3032                .catch(function () {
3033                    setStarMsgErr('Erro de rede.');
3034                });
3035        });
3036    })();
3037
3038    function openCommunityStatsModal(serviceId, serviceName) {
3039        var modal = document.getElementById('communityStatsModal');
3040        if (!modal) return;
3041        modal.classList.add('open');
3042        document.body.style.overflow = 'hidden';
3043
3044        var title = document.getElementById('csTitle');
3045        if (title) title.textContent = (serviceName || serviceId) + ' — Reclamações';
3046
3047        document.getElementById('csTotal').textContent = '…';
3048        document.getElementById('csAnon').textContent = '…';
3049        document.getElementById('csReg').textContent = '…';
3050        var pill = document.getElementById('csScorePill');
3051        if (pill) { pill.textContent = '…'; pill.className = 'cs-score-pill cs-score-pill--good'; }
3052        var fbList = document.getElementById('csFeedbackList');
3053        if (fbList) fbList.innerHTML = '<div class="cs-empty">Carregando…</div>';
3054
3055        if (_csChart) { _csChart.destroy(); _csChart = null; }
3056
3057        fetch('/api/detector/service/' + encodeURIComponent(serviceId) + '/community-stats?days=30')
3058            .then(function (r) { return r.json(); })
3059            .then(function (data) {
3060                if (!data || !data.success) {
3061                    if (fbList) fbList.innerHTML = '<div class="cs-empty">Erro ao carregar dados.</div>';
3062                    return;
3063                }
3064                document.getElementById('csTotal').textContent = String(data.total_reports || 0);
3065                document.getElementById('csAnon').textContent = String(data.anonymous_count || 0);
3066                document.getElementById('csReg').textContent = String(data.registered_count || 0);
3067
3068                var ts = Number(data.trust_score);
3069                if (pill && !isNaN(ts)) {
3070                    pill.textContent = ts.toFixed(1) + ' / 100';
3071                    pill.className = 'cs-score-pill ' + (ts >= 90 ? 'cs-score-pill--good' : ts >= 80 ? 'cs-score-pill--warn' : 'cs-score-pill--bad');
3072                }
3073
3074                renderCsChart(data.daily_counts || []);
3075                renderCsFeedback(data.public_feedback || [], serviceId);
3076            })
3077            .catch(function () {
3078                if (fbList) fbList.innerHTML = '<div class="cs-empty">Erro de rede.</div>';
3079            });
3080    }
3081
3082    function renderCsChart(daily) {
3083        var canvas = document.getElementById('csChart');
3084        if (!canvas) return;
3085        var ctx = canvas.getContext('2d');
3086
3087        var today = new Date();
3088        var labels = [];
3089        var counts = {};
3090        daily.forEach(function (d) { counts[d.date] = d.count; });
3091        var data = [];
3092        for (var i = 29; i >= 0; i--) {
3093            var dt = new Date(today);
3094            dt.setDate(dt.getDate() - i);
3095            var key = dt.toISOString().slice(0, 10);
3096            labels.push(key.slice(5));
3097            data.push(counts[key] || 0);
3098        }
3099
3100        var gradient = ctx.createLinearGradient(0, 0, 0, canvas.parentElement.clientHeight || 160);
3101        gradient.addColorStop(0, 'rgba(239,68,68,0.5)');
3102        gradient.addColorStop(1, 'rgba(239,68,68,0.02)');
3103
3104        _csChart = new Chart(ctx, {
3105            type: 'bar',
3106            data: {
3107                labels: labels,
3108                datasets: [{
3109                    label: 'Reclamações',
3110                    data: data,
3111                    backgroundColor: gradient,
3112                    borderColor: 'rgba(239,68,68,0.8)',
3113                    borderWidth: 1,
3114                    borderRadius: 3,
3115                    hoverBackgroundColor: 'rgba(239,68,68,0.7)',
3116                }]
3117            },
3118            options: {
3119                responsive: true,
3120                maintainAspectRatio: false,
3121                animation: { duration: 400 },
3122                plugins: {
3123                    legend: { display: false },
3124                    tooltip: {
3125                        backgroundColor: 'rgba(255,255,255,0.97)',
3126                        titleColor: '#0369a1',
3127                        bodyColor: '#0f172a',
3128                        borderColor: 'rgba(239,68,68,0.4)',
3129                        borderWidth: 1,
3130                        cornerRadius: 8,
3131                        padding: 10,
3132                        callbacks: {
3133                            title: function (items) { return items[0] ? items[0].label : ''; },
3134                            label: function (item) { return item.raw + ' reclamação' + (item.raw !== 1 ? 'ões' : ''); }
3135                        }
3136                    }
3137                },
3138                scales: {
3139                    x: {
3140                        grid: { display: false },
3141                        ticks: { color: 'rgba(71,85,105,0.85)', font: { size: 9 }, maxRotation: 45 }
3142                    },
3143                    y: {
3144                        beginAtZero: true,
3145                        grace: '10%',
3146                        ticks: { color: 'rgba(71,85,105,0.85)', font: { size: 10 }, precision: 0 },
3147                        grid: { color: 'rgba(148,163,184,0.2)' }
3148                    }
3149                }
3150            }
3151        });
3152    }
3153
3154    function renderCsFeedback(fb, serviceId) {
3155        var el = document.getElementById('csFeedbackList');
3156        if (!el) return;
3157        var titleEl = document.getElementById('csFeedbackTitle');
3158        if (!fb || !fb.length) {
3159            el.innerHTML =
3160                '<div class="svc-feedback-empty">' +
3161                '<span class="svc-feedback-empty__ico">💬</span>' +
3162                '<span class="svc-feedback-empty__msg">Nenhum comentário público ainda para este serviço.</span>' +
3163                '</div>';
3164            if (titleEl) titleEl.innerHTML = '<span class="svc-feed-ico">💬</span> Comentários públicos';
3165            return;
3166        }
3167        var sid = serviceId || '';
3168        if (titleEl) titleEl.innerHTML = '<span class="svc-feed-ico">💬</span> Comentários públicos <span style="font-weight:400;font-size:.82em;color:var(--muted)">(' + fb.length + ')</span>';
3169        var html = fb.map(function (it) {
3170            var when = it.created_at ? esc(it.created_at.slice(0, 16).replace('T', ' ')) : '—';
3171            var auth = esc(it.author_label || 'Anónimo');
3172            var reason = esc(it.reason_label_pt || '');
3173            var com = it.comment ? '<div class="svc-feed-comment">' + esc(it.comment) + '</div>' : '';
3174            var reasonTag = reason ? '<span style="display:inline-block;padding:2px 8px;border-radius:999px;background:rgba(251,191,36,.12);color:#b45309;font-size:.76rem;font-weight:600;margin-top:4px">' + reason + '</span>' : '';
3175            var replyBtn = '';
3176            if (it.id && sid) {
3177                replyBtn =
3178                    '<div class="svc-fb-actions"><button type="button" class="det-fb-reply-btn svc-feed-open-report" data-det-reply-svc="' +
3179                    esc(String(sid)) +
3180                    '" data-det-reply-parent="' +
3181                    esc(String(it.id)) +
3182                    '" aria-label="' +
3183                    esc(__('js_feedback_reply_aria')) +
3184                    '">↩ ' +
3185                    esc(__('js_feedback_reply')) +
3186                    '</button></div>';
3187            }
3188            return (
3189                '<article class="svc-feedback-item svc-feed-card">' +
3190                '<div class="svc-feed-head"><div class="svc-feed-avatar">' +
3191                esc((auth || 'A').slice(0, 1).toUpperCase()) +
3192                '</div><div class="svc-feed-head__txt"><div class="svc-feed-meta"><strong>' +
3193                auth +
3194                '</strong> · ' +
3195                when +
3196                '</div>
3196' + reasonTag + '</div></div>' +
3197                com +
3198                renderReactionButtons(it) +
3199                renderFeedRepliesHtml(it.replies) +
3200                replyBtn +
3201                '</article>'
3202            );
3203        }).join('');
3204        el.innerHTML = '<div class="svc-feed-list">' + html + '</div>';
3205    }
3206
3207    function closeCommunityStatsModal() {
3208        var modal = document.getElementById('communityStatsModal');
3209        if (!modal) return;
3210        modal.classList.remove('open');
3211        document.body.style.overflow = '';
3212        if (_csChart) { _csChart.destroy(); _csChart = null; }
3213    }
3214
3215    (function _csModalInit() {
3216        var modal = document.getElementById('communityStatsModal');
3217        if (!modal) return;
3218        var closeBtn = document.getElementById('csClose');
3219        if (closeBtn) closeBtn.addEventListener('click', closeCommunityStatsModal);
3220        modal.addEventListener('click', function (e) {
3221            if (e.target === modal) closeCommunityStatsModal();
3222        });
3223        document.addEventListener('keydown', function (e) {
3224            if (e.key === 'Escape' && modal.classList.contains('open')) closeCommunityStatsModal();
3225        });
3226    })();
3227
3228    function bindTileMoreButtons(root) {
3229        if (!root) return;
3230        root.querySelectorAll('.tile-more-btn').forEach(function (btn) {
3231            btn.addEventListener('click', function (e) {
3232                e.preventDefault();
3233                e.stopPropagation();
3234                var id =
3235                    btn.getAttribute('data-more-sid') ||
3236                    (function () {
3237                        var tile = btn.closest('.tile');
3238                        return tile && tile.getAttribute('data-svc-id');
3239                    })();
3240                if (id) openModal(id);
3241            });
3242        });
3243        root.querySelectorAll('.list-more-btn').forEach(function (btn) {
3244            btn.addEventListener('click', function (e) {
3245                e.preventDefault();
3246                e.stopPropagation();
3247                var id = btn.getAttribute('data-more-sid');
3248                if (id) openModal(id);
3249            });
3250        });
3251    }
3252
3253    function getCrMetaPayload() {
3254        var checked = document.querySelector('#crReasonList input[name="crReason"]:checked');
3255        var rc = checked ? String(checked.value || 'other') : 'other';
3256        var ta = document.getElementById('crComment');
3257        var cmt = ta ? String(ta.value || '').trim() : '';
3258        var pubEl = document.getElementById('crPublic');
3259        var anonEl = document.getElementById('crAnonymous');
3260        var dnEl = document.getElementById('crDisplayName');
3261        var bcastEl = document.getElementById('crBroadcast');
3262        var bcastIdEl = document.getElementById('crBroadcastIdentify');
3263        var pub = !!(pubEl && pubEl.checked);
3264        var anon = anonEl ? !!anonEl.checked : true;
3265        var dn = dnEl ? String(dnEl.value || '').trim() : '';
3266        var bcast = bcastEl ? !!bcastEl.checked : true;
3267        var bcastId = bcastIdEl ? !!bcastIdEl.checked : false;
3268        var out = {
3269            reason_code: rc,
3270            comment: cmt || null,
3271            is_public: pub,
3272            is_anonymous: anon,
3273            display_name: dn || null,
3274            broadcast_notify: bcast,
3275            broadcast_identify: bcastId,
3276            parent_report_id: state.crParentReportId || null,
3277        };
3278        if (state.crParentReportId) {
3279            out.reason_code = 'other';
3280            out.is_public = true;
3281            out.broadcast_notify = false;
3282        }
3283        return out;
3284    }
3285
3286    var _crModTimer = null;
3287    var _crModSeq = 0;
3288
3289    function crModerationPayload() {
3290        var p = getCrMetaPayload();
3291        var dn = null;
3292        if (!p.is_anonymous || p.broadcast_identify) {
3293            dn = p.display_name || null;
3294        }
3295        return {
3296            comment: p.comment || null,
3297            display_name: dn,
3298            is_reply: !!p.parent_report_id,
3299        };
3300    }
3301
3302    function setCrModerationHint(text, extraClass) {
3303        var el = document.getElementById('crModerationHint');
3304        if (!el) return;
3305        if (!text) {
3306            el.textContent = '';
3307            el.className = 'cr-mod-hint';
3308            el.hidden = true;
3309            return;
3310        }
3311        el.textContent = text;
3312        el.className = 'cr-mod-hint' + (extraClass ? ' ' + extraClass : '');
3313        el.hidden = false;
3314    }
3315
3316    function scheduleCommunityCommentModeration() {
3317        var sm = document.getElementById('crStepMeta');
3318        if (!sm || sm.style.display === 'none') return;
3319        if (_crModTimer) clearTimeout(_crModTimer);
3320        setCrModerationHint(__('js_cr_mod_analyzing'), '');
3321        var seq = ++_crModSeq;
3322        _crModTimer = setTimeout(function () {
3323            _crModTimer = null;
3324            var origin = '';
3325            try {
3326                origin = window.location.origin || '';
3327            } catch (eO) {}
3328            fetch(origin + '/api/detector/community-comment/moderate', {
3329                method: 'POST',
3330                headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
3331                body: JSON.stringify(crModerationPayload()),
3332                credentials: 'same-origin',
3333            })
3334                .then(function (r) {
3335                    return r.json();
3336                })
3337                .then(function (data) {
3338                    if (seq !== _crModSeq) return;
3339                    if (!data || !data.ok || !data.moderation) {
3340                        setCrModerationHint('', '');
3341                        return;
3342                    }
3343                    var loc = typeof window.detectorGetLocale === 'function' ? window.detectorGetLocale() : 'pt';
3344                    var m = data.moderation;
3345                    var txt = loc === 'en' && m.summary_en ? m.summary_en : m.summary_pt || '';
3346                    setCrModerationHint(txt, m.allowed ? 'cr-mod-hint--ok' : 'cr-mod-hint--bad');
3347                })
3348                .catch(function () {
3349                    if (seq !== _crModSeq) return;
3350                    setCrModerationHint('', '');
3351                });
3352        }, 520);
3353    }
3354
3355    function runCommunityCommentModerationSync() {
3356        return new Promise(function (resolve) {
3357            var origin = '';
3358            try {
3359                origin = window.location.origin || '';
3360            } catch (e1) {}
3361            fetch(origin + '/api/detector/community-comment/moderate', {
3362                method: 'POST',
3363                headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
3364                body: JSON.stringify(crModerationPayload()),
3365                credentials: 'same-origin',
3366            })
3367                .then(function (r) {
3368                    return r.json();
3369                })
3370                .then(function (data) {
3371                    if (!data || !data.ok) {
3372                        resolve({ allowed: true, moderation: null });
3373                        return;
3374                    }
3375                    resolve({ allowed: !!data.allowed, moderation: data.moderation || null });
3376                })
3377                .catch(function () {
3378                    resolve({ allowed: true, moderation: null });
3379                });
3380        });
3381    }
3382
3383    function renderCrReasonList() {
3384        var host = document.getElementById('crReasonList');
3385        if (!host) return;
3386        if (state.crParentReportId) {
3387            host.innerHTML =
3388                '<input type="radio" name="crReason" value="other" checked tabindex="-1" aria-hidden="true" style="position:absolute;opacity:0;width:0;height:0;pointer-events:none;" />';
3389            return;
3390        }
3391        var list = state.reportReasons || [];
3392        if (!list.length) {
3393            host.innerHTML =
3394                '<label class="cr-reason-item"><input type="radio" name="crReason" value="other" checked />' +
3395                '<span>Outro motivo<small>Descreva no comentário, se quiser.</small></span></label>';
3396            return;
3397        }
3398        host.innerHTML = list
3399            .map(function (r, i) {
3400                return (
3401                    '<label class="cr-reason-item"><input type="radio" name="crReason" value="' +
3402                    esc(r.id) +
3403                    '" ' +
3404                    (i === 0 ? 'checked' : '') +
3405                    ' /><span>' +
3406                    esc(r.label_pt || r.id) +
3407                    '<small>' +
3408                    esc(r.hint_pt || '') +
3409                    '</small></span></label>'
3410                );
3411            })
3412            .join('');
3413    }
3414
3415    function initCrGoogleButton() {
3416        var host = document.getElementById('crGoogleBtnHost');
3417        if (!host || !state.googleClientId) {
3418            if (host)
3419                host.innerHTML =
3420                    '<p style="color:var(--muted);font-size:0.75rem;">Google não configurado no servidor.</p>';
3421            return;
3422        }
3423        if (typeof google === 'undefined' || !google.accounts || !google.accounts.id) {
3424            setTimeout(initCrGoogleButton, 250);
3425            return;
3426        }
3427        host.innerHTML = '';
3428        try {
3429            google.accounts.id.initialize({
3430                client_id: state.googleClientId,
3431                callback: onCrGoogleCredential,
3432                auto_select: false,
3433            });
3434            google.accounts.id.renderButton(host, {
3435                type: 'standard',
3436                theme: 'filled_blue',
3437                size: 'large',
3438                text: 'continue_with',
3439                width: 320,
3440            });
3441        } catch (e) {
3442            host.innerHTML = '<p style="color:var(--bad);font-size:0.75rem;">Não foi possível carregar o botão Google.</p>';
3443        }
3444    }
3445
3446    function onCrGoogleCredential(response) {
3447        var msg = document.getElementById('crMsg');
3448        var sid = state.crSvcId;
3449        var cred = response && response.credential;
3450        if (!sid || !cred) return;
3451        var origin = window.location.origin || '';
3452        var body = Object.assign(
3453            {
3454                service_id: sid,
3455                channel: 'google',
3456                google_id_token: cred,
3457            },
3458            getCrMetaPayload(),
3459        );
3460        if (msg) {
3461            msg.textContent = 'A enviar…';
3462            msg.className = 'cr-msg';
3463        }
3464        fetch(origin + '/api/detector/community-report/start', {
3465            method: 'POST',
3466            headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
3467            body: JSON.stringify(body),
3468            credentials: 'same-origin',
3469        })
3470            .then(function (r) {
3471                return r.json().then(function (data) {
3472                    return { r: r, data: data };
3473                });
3474            })
3475            .then(function (pack) {
3476                var d = pack.data || {};
3477                if (msg) {
3478                    msg.className = 'cr-msg ' + (pack.r.ok && d.ok ? 'ok' : 'err');
3479                    var locG =
3480                        typeof window.detectorGetLocale === 'function' ? window.detectorGetLocale() : 'pt';
3481                    var apiG =
3482                        locG === 'en' && d.message_en
3483                            ? d.message_en
3484                            : d.message_pt || (pack.r.ok ? 'OK.' : 'Falhou.');
3485                    msg.textContent = apiG;
3486                }
3487                if (pack.r.ok && d.ok) {
3488                    if (!state.crParentReportId && !d.moderation_pending) {
3489                        bumpCommunityVoteLocal(sid);
3490                        mountShelves();
3491                        mountProblemsCatalog();
3492                        mountCommunityRanking(true);
3493                    }
3494                    setTimeout(closeCommunityReportModal, 2000);
3495                }
3496            })
3497            .catch(function () {
3498                if (msg) {
3499                    msg.className = 'cr-msg err';
3500                    msg.textContent = 'Erro de rede.';
3501                }
3502            });
3503    }
3504
3505    function reactionLabel(reaction) {
3506        var map = { like: 'Curtir', love: 'Amei', wow: 'Uau', fire: 'Top' };
3507        return map[reaction] || reaction;
3508    }
3509
3510    function renderReactionButtons(item) {
3511        var reactions = ['like', 'love', 'wow', 'fire'];
3512        var counts = item && item.reactions ? item.reactions : {};
3513        var active = item && item.viewer_reaction ? String(item.viewer_reaction) : '';
3514        return (
3515            '<div class="svc-feed-reactions">' +
3516            reactions
3517                .map(function (key) {
3518                    var n = Number(counts[key]) || 0;
3519                    return (
3520                        '<button type="button" class="svc-feed-react-btn' +
3521                        (active === key ? ' is-active' : '') +
3522                        '" data-det-react-report="' +
3523                        esc(String(item.id || '')) +
3524                        '" data-det-react-kind="' +
3525                        esc(key) +
3526                        '" aria-label="' +
3527                        esc(reactionLabel(key)) +
3528                        '">' +
3529                        '<span class="svc-feed-react-ico" aria-hidden="true">' +
3530                        (key === 'like' ? '👍' : key === 'love' ? '❤️' : key === 'wow' ? '😮' : '🔥') +
3531                        '</span>' +
3532                        '<span class="svc-feed-react-lbl">' +
3533                        esc(reactionLabel(key)) +
3534                        '</span>' +
3535                        '<span class="svc-feed-react-n">' +
3536                        esc(String(n)) +
3537                        '</span>' +
3538                        '</button>'
3539                    );
3540                })
3541                .join('') +
3542            '</div>'
3543        );
3544    }
3545
3546    function renderFeedRepliesHtml(replies) {
3547        var list = Array.isArray(replies) ? replies : [];
3548        if (!list.length) return '';
3549        return (
3550            '<div class="det-fb-replies svc-feed-replies">' +
3551            list
3552                .map(function (rp) {
3553                    var w2 = rp.created_at ? esc(rp.created_at.slice(0, 16).replace('T', ' ')) : '';
3554                    var au = esc(rp.author_label || 'Anónimo');
3555                    var cm = rp.comment ? '<div class="svc-feed-comment">' + esc(rp.comment) + '</div>' : '';
3556                    return (
3557                        '<div class="det-fb-reply svc-feed-reply">' +
3558                        '<div class="svc-feed-meta">' +
3559                        au +
3560                        (w2 ? ' · ' + w2 : '') +
3561                        '</div>' +
3562                        cm +
3563                        renderReactionButtons(rp) +
3564                        '</div>'
3565                    );
3566                })
3567                .join('') +
3568            '</div>'
3569        );
3570    }
3571
3572    function filteredCommunityFeedRows(fb) {
3573        var rows = Array.isArray(fb) ? fb.slice() : [];
3574        if (state.communityFeedFilter === 'with_comments') {
3575            rows = rows.filter(function (it) {
3576                return !!((it && it.comment) || ((it.replies || []).length > 0));
3577            });
3578        }
3579        return rows;
3580    }
3581
3582    function bindPublicFeedbackComposer(root, sid) {
3583        if (!root || !sid) return;
3584        var ta = root.querySelector('#svcFeedComposerInput');
3585        var sendBtn = root.querySelector('#svcFeedComposerSend');
3586        var hint = root.querySelector('#svcFeedComposerHint');
3587        var chips = root.querySelectorAll('.svc-feed-filter-btn');
3588        chips.forEach(function (btn) {
3589            btn.addEventListener('click', function () {
3590                state.communityFeedFilter = btn.getAttribute('data-feed-filter') || 'all';
3591                renderPublicFeedbackBlock(state.modalDetailData, sid);
3592            });
3593        });
3594        root.querySelectorAll('.svc-feed-open-report,.det-fb-reply-btn').forEach(function (btn) {
3595            btn.addEventListener('click', function () {
3596                var parentId = btn.getAttribute('data-det-reply-parent');
3597                openCommunityReportModal(
3598                    sid,
3599                    parentId ? parseInt(parentId, 10) : null,
3600                );
3601            });
3602        });
3603        root.querySelectorAll('.svc-feed-react-btn').forEach(function (btn) {
3604            btn.addEventListener('click', function () {
3605                var reportId = btn.getAttribute('data-det-react-report');
3606                var kind = btn.getAttribute('data-det-react-kind');
3607                if (!reportId || !kind) return;
3608                fetch((window.location.origin || '') + '/api/detector/community-reaction', {
3609                    method: 'POST',
3610                    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
3611                    credentials: 'same-origin',
3612                    body: JSON.stringify({
3613                        report_id: parseInt(reportId, 10),
3614                        reaction: kind,
3615                        anon_token: detectorCommunityAnonToken(),
3616                    }),
3617                })
3618                    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d || {} }; }); })
3619                    .then(function (pack) {
3620                        if (!pack.ok || !state.modalDetailData || !Array.isArray(state.modalDetailData.public_feedback)) return;
3621                        state.modalDetailData.public_feedback.forEach(function (it) {
3622                            function applyOne(row) {
3623                                if (!row || String(row.id) !== String(reportId)) return;
3624                                row.reactions = pack.data.reactions || {};
3625                                row.reaction_total = pack.data.reaction_total || 0;
3626                                row.viewer_reaction = pack.data.viewer_reaction || null;
3627                            }
3628                            applyOne(it);
3629                            (it.replies || []).forEach(applyOne);
3630                        });
3631                        renderPublicFeedbackBlock(state.modalDetailData, sid);
3632                    })
3633                    .catch(function () {});
3634            });
3635        });
3636        function triggerSend() {
3637            var text = ta ? String(ta.value || '').trim() : '';
3638            if (!text) return;
3639            state.crParentReportId = null;
3640            openCommunityReportModal(sid, null);
3641            setTimeout(function () {
3642                var c = document.getElementById('crComment');
3643                var pub = document.getElementById('crPublic');
3644                if (c) c.value = text;
3645                if (pub) pub.checked = true;
3646                if (hint) hint.textContent = 'Para publicar, confirme seu e-mail ou Google.';
3647            }, 30);
3648        }
3649        if (sendBtn) sendBtn.addEventListener('click', triggerSend);
3650        if (ta) {
3651            ta.addEventListener('keydown', function (e) {
3652                if (e.key === 'Enter' && !e.shiftKey) {
3653                    e.preventDefault();
3654                    triggerSend();
3655                }
3656            });
3657            ta.addEventListener('input', function () {
3658                ta.style.height = 'auto';
3659                ta.style.height = Math.min(ta.scrollHeight, 120) + 'px';
3660            });
3661        }
3662    }
3663
3664    function renderPublicFeedbackBlock(data, modalSvcId) {
3665        var el = document.getElementById('svcPublicFeedback');
3666        if (!el) return;
3667        if (!data || !data.success) {
3668            el.style.display = 'none';
3669            el.innerHTML = '';
3670            return;
3671        }
3672        state.modalDetailData = data;
3673        var fb = filteredCommunityFeedRows(data && data.public_feedback);
3674        var ts = data && data.trust_score;
3675        var sid = modalSvcId || state.modalSvcId || '';
3676        var tsStr =
3677            ts != null && !isNaN(Number(ts))
3678                ? '<p class="svc-feedback-meta">Nota comunidade: <strong>' + esc(String(Number(ts).toFixed(1))) + '</strong> / 100 · relatos verificados com pesos por tipo</p>'
3679                : '';
3680        var fbCount = fb ? fb.length : 0;
3681        var headBlock =
3682            '<div class="svc-feed-head-block">' +
3683            '<div class="svc-feed-composer__top">' +
3684            '<div><h4 class="svc-feed-section-title"><span class="svc-feed-ico">💬</span> ' +
3685            esc(__('js_comments_app_title')) +
3686            (fbCount > 0
3687                ? ' <span style="font-weight:400;font-size:.82em;color:var(--muted)">(' + fbCount + ')</span>'
3688                : '') +
3689            '</h4>' +
3690            tsStr +
3691            '</div>' +
3692            (fbCount > 0
3693                ? '<div class="svc-feed-filters">' +
3694                  '<button type="button" class="svc-feed-filter-btn' +
3695                  (state.communityFeedFilter === 'all' ? ' is-active' : '') +
3696                  '" data-feed-filter="all">📋 Tudo</button>' +
3697                  '<button type="button" class="svc-feed-filter-btn' +
3698                  (state.communityFeedFilter === 'with_comments' ? ' is-active' : '') +
3699                  '" data-feed-filter="with_comments">💬 Comentários</button>' +
3700                  '</div>'
3701                : '') +
3702            '</div></div>';
3703        var composerFoot =
3704            '<div class="svc-feed-composer">' +
3705            '<div class="svc-feed-composer__box">' +
3706            '<textarea id="svcFeedComposerInput" class="svc-feed-composer__input" rows="1" maxlength="2000" placeholder="Escreva um comentário..."></textarea>' +
3707            '<button type="button" id="svcFeedComposerSend" class="svc-feed-composer__send svc-feed-composer__send--pulse" aria-label="Enviar">➤</button>' +
3708            '</div>' +
3709            '<span id="svcFeedComposerHint" class="svc-feed-composer__hint">🔒 Reaja sem login · Para comentar, pedimos verificação rápida por e-mail ou Google</span>' +
3710            '</div>';
3711        if (!fb || !fb.length) {
3712            el.style.display = 'block';
3713            el.innerHTML =
3714                headBlock +
3715                '<div class="svc-feedback-empty">' +
3716                '<span class="svc-feedback-empty__ico">💬</span>' +
3717                '<span class="svc-feedback-empty__msg">Nenhum comentário ainda. Seja o primeiro a relatar!</span>' +
3718                '<span class="svc-feedback-empty__cta">👇 Use a caixa abaixo para partilhar a sua experiência</span>' +
3719                '</div>' +
3720                composerFoot;
3721            bindPublicFeedbackComposer(el, sid);
3722            return;
3723        }
3724        var rows = fb
3725            .map(function (it) {
3726                var when = it.created_at ? esc(it.created_at.slice(0, 16).replace('T', ' ')) : '—';
3727                var auth = esc(it.author_label || 'Anónimo');
3728                var reason = esc(it.reason_label_pt || '');
3729                var com = it.comment ? '<div class="svc-feed-comment">' + esc(it.comment) + '</div>' : '';
3730                var reasonTag = reason ? '<span style="display:inline-block;padding:2px 8px;border-radius:999px;background:rgba(251,191,36,.12);color:#b45309;font-size:.76rem;font-weight:600;margin-top:4px">' + reason + '</span>' : '';
3731                var replyBtn = '';
3732                if (it.id && sid) {
3733                    replyBtn =
3734                        '<div class="svc-fb-actions">' +
3735                        '<button type="button" class="det-fb-reply-btn svc-feed-open-report" data-det-reply-svc="' +
3736                        esc(String(sid)) +
3737                        '" data-det-reply-parent="' +
3738                        esc(String(it.id)) +
3739                        '" aria-label="' +
3740                        esc(__('js_feedback_reply_aria')) +
3741                        '">↩ ' +
3742                        esc(__('js_feedback_reply')) +
3743                        '</button></div>';
3744                }
3745                return (
3746                    '<article class="svc-feedback-item svc-feed-card">' +
3747                    '<div class="svc-feed-head"><div class="svc-feed-avatar">' +
3748                    esc((auth || 'A').slice(0, 1).toUpperCase()) +
3749                    '</div><div class="svc-feed-head__txt"><div class="svc-feed-meta"><strong>' +
3750                    auth +
3751                    '</strong> · ' +
3752                    when +
3753                    '</div>
3753' + reasonTag + '</div></div>' +
3754                    com +
3755                    renderReactionButtons(it) +
3756                    renderFeedRepliesHtml(it.replies) +
3757                    replyBtn +
3758                    '</article>'
3759                );
3760            })
3761            .join('');
3762        el.style.display = 'block';
3763        el.innerHTML = headBlock + '<div class="svc-feed-list">' + rows + '</div>' + composerFoot;
3764        bindPublicFeedbackComposer(el, sid);
3765    }
3766
3767    function applyCrExplainerLinkIntoParagraph(pEl) {
3768        if (!pEl) return;
3769        pEl.appendChild(document.createTextNode(' '));
3770        var a = document.createElement('a');
3771        a.href = '#';
3772        a.className = 'cr-open-det-explainer';
3773        a.textContent = __('cr_explainer_fab_link');
3774        pEl.appendChild(a);
3775    }
3776
3777    function applyCrQuestionLead(isReply) {
3778        var q = document.getElementById('crQuestion');
3779        if (!q) return;
3780        if (isReply) {
3781            q.textContent = __('cr_reply_step_question');
3782            return;
3783        }
3784        q.textContent = '';
3785        var span = document.createElement('span');
3786        span.textContent = __('cr_question_main');
3787        q.appendChild(span);
3788        applyCrExplainerLinkIntoParagraph(q);
3789    }
3790
3791    function applyCrVerifyLeadText() {
3792        var p = document.getElementById('crVerifyLead');
3793        if (!p) return;
3794        p.textContent = __('cr_verify_lead_main');
3795    }
3796
3797    function resetCommunityReportModalUi() {
3798        var s0 = document.getElementById('crStepConfirm');
3799        var sm = document.getElementById('crStepMeta');
3800        var s2 = document.getElementById('crStepVerify');
3801        var msg = document.getElementById('crMsg');
3802        var ph = document.getElementById('crPhone');
3803        var em = document.getElementById('crEmail');
3804        var cd = document.getElementById('crCode');
3805        var panel = document.getElementById('crCodePanel');
3806        var ch = document.getElementById('crChannel');
3807        var pw = document.getElementById('crPhoneWrap');
3808        var ew = document.getElementById('crEmailWrap');
3809        var gw = document.getElementById('crGoogleWrap');
3810        var sendBtn = document.getElementById('crSendCode');
3811        var cmt = document.getElementById('crComment');
3812        var pub = document.getElementById('crPublic');
3813        var anon = document.getElementById('crAnonymous');
3814        var dn = document.getElementById('crDisplayName');
3815        var dnw = document.getElementById('crDisplayNameWrap');
3816        var gHost = document.getElementById('crGoogleBtnHost');
3817        state.crVerifyToken = null;
3818        if (s0) s0.style.display = '';
3819        if (sm) sm.style.display = 'none';
3820        if (s2) s2.style.display = 'none';
3821        if (msg) {
3822            msg.textContent = '';
3823            msg.className = 'cr-msg';
3824        }
3825        if (ph) ph.value = '';
3826        if (em) em.value = '';
3827        if (cd) cd.value = '';
3828        if (cmt) cmt.value = '';
3829        if (pub) pub.checked = false;
3830        if (anon) anon.checked = true;
3831        if (dn) dn.value = '';
3832        if (dnw) dnw.style.display = 'none';
3833        if (panel) panel.style.display = 'none';
3834        if (ch) ch.value = 'google';
3835        if (gHost) gHost.innerHTML = '';
3836        syncCrChannelUi();
3837        var crInline = document.getElementById('crMetaInlineErr');
3838        if (crInline) {
3839            crInline.textContent = '';
3840            crInline.style.display = 'none';
3841        }
3842        if (_crModTimer) {
3843            clearTimeout(_crModTimer);
3844            _crModTimer = null;
3845        }
3846        _crModSeq += 1;
3847        setCrModerationHint('', '');
3848        var bcast = document.getElementById('crBroadcast');
3849        var bcastId = document.getElementById('crBroadcastIdentify');
3850        var bcastWrap = document.getElementById('crBroadcastNameWrap');
3851        if (bcast) bcast.checked = true;
3852        if (bcastId) bcastId.checked = false;
3853        if (bcastWrap) bcastWrap.style.display = 'none';
3854        if (pub) pub.disabled = false;
3855        var bcastRow = _crBroadcastRow();
3856        if (bcastRow) bcastRow.style.display = '';
3857        var lead = document.getElementById('crMetaReasonLead');
3858        var list = document.getElementById('crReasonList');
3859        if (lead) lead.style.display = '';
3860        if (list) list.style.display = '';
3861        ensureCrUiDefaults();
3862        applyCrQuestionLead(false);
3863        applyCrVerifyLeadText();
3864        var mcl = document.getElementById('crMetaCommentLabel');
3865        if (mcl) mcl.textContent = _crUiDefaults.commentLbl;
3866        state.crParentReportId = null;
3867    }
3868
3869    function syncCrChannelUi() {
3870        var sel = document.getElementById('crChannel');
3871        var pw = document.getElementById('crPhoneWrap');
3872        var ew = document.getElementById('crEmailWrap');
3873        var gw = document.getElementById('crGoogleWrap');
3874        var sendBtn = document.getElementById('crSendCode');
3875        if (!sel || !ew) return;
3876        var v = sel.value || 'google';
3877        if (pw) pw.style.display = 'none';
3878        ew.style.display = v === 'email' ? '' : 'none';
3879        if (gw) gw.style.display = v === 'google' ? '' : 'none';
3880        if (sendBtn) sendBtn.style.display = v === 'google' ? 'none' : '';
3881        if (v !== 'google') {
3882            var cp = document.getElementById('crCodePanel');
3883            if (cp) cp.style.display = 'none';
3884        } else {
3885            initCrGoogleButton();
3886        }
3887    }
3888
3889    function resetCommunityReportModal() {
3890        resetCommunityReportModalUi();
3891        state.crSvcId = null;
3892        state.crParentReportId = null;
3893    }
3894
3895    function closeCommunityReportModal() {
3896        var m = document.getElementById('communityReportModal');
3897        if (m) m.classList.remove('open');
3898        resetCommunityReportModal();
3899    }
3900
3901    var _crUiDefaults = { commentLbl: '' };
3902    function ensureCrUiDefaults() {
3903        if (_crUiDefaults.commentLbl) return;
3904        var mcl = document.getElementById('crMetaCommentLabel');
3905        _crUiDefaults.commentLbl = mcl ? mcl.textContent : '';
3906    }
3907    function _crBroadcastRow() {
3908        var b = document.getElementById('crBroadcast');
3909        return b && b.closest ? b.closest('.cr-check-row') : null;
3910    }
3911    function applyCrReplyMode(isReply) {
3912        ensureCrUiDefaults();
3913        var lead = document.getElementById('crMetaReasonLead');
3914        var list = document.getElementById('crReasonList');
3915        var pub = document.getElementById('crPublic');
3916        var bcastRow = _crBroadcastRow();
3917        var mcl = document.getElementById('crMetaCommentLabel');
3918        if (lead) lead.style.display = isReply ? 'none' : '';
3919        if (list) list.style.display = isReply ? 'none' : '';
3920        if (pub) {
3921            pub.disabled = !!isReply;
3922            if (isReply) pub.checked = true;
3923        }
3924        if (bcastRow) bcastRow.style.display = isReply ? 'none' : '';
3925        var bcastIdRow = document.getElementById('crBroadcastNameWrap');
3926        if (bcastIdRow) bcastIdRow.style.display = 'none';
3927        applyCrQuestionLead(!!isReply);
3928        if (mcl) mcl.textContent = isReply ? __('cr_reply_comment_label') : _crUiDefaults.commentLbl;
3929    }
3930
3931    function openCommunityReportModal(svcId, parentReportId) {
3932        var svc = findSvc(svcId);
3933        if (!svc) return;
3934        resetCommunityReportModalUi();
3935        state.crSvcId = svcId;
3936        var pr =
3937            parentReportId != null && parentReportId !== undefined && parentReportId !== ''
3938                ? parseInt(parentReportId, 10)
3939                : NaN;
3940        state.crParentReportId = isFinite(pr) && pr > 0 ? pr : null;
3941        applyCrReplyMode(!!state.crParentReportId);
3942        var m = document.getElementById('communityReportModal');
3943        var t = document.getElementById('crTitle');
3944        var leg = document.getElementById('crLegal');
3945        if (t) {
3946            t.textContent = state.crParentReportId
3947                ? __('js_feedback_reply') + ': ' + (svc.name || svcId)
3948                : 'Relato: ' + (svc.name || svcId);
3949        }
3950        if (leg) leg.textContent = state.communityDisclaimerPt || '';
3951        if (m) m.classList.add('open');
3952    }
3953
3954    function openReportPickerModal() {
3955        var m = document.getElementById('detReportPickerModal');
3956        if (!m) return;
3957        m.classList.add('open');
3958        var inp = document.getElementById('detReportPickerSearch');
3959        if (inp) inp.value = '';
3960        renderReportPickerList('');
3961        if (inp) {
3962            setTimeout(function () {
3963                inp.focus();
3964            }, 80);
3965        }
3966    }
3967
3968    function closeReportPickerModal() {
3969        var m = document.getElementById('detReportPickerModal');
3970        if (m) m.classList.remove('open');
3971    }
3972
3973    function reportPickerMatchesQuery(svc, q) {
3974        if (!q) return true;
3975        q = String(q).trim().toLowerCase();
3976        if (!q) return true;
3977        var name = (svc.name && String(svc.name).toLowerCase()) || '';
3978        var id = String(svc.id || '').toLowerCase();
3979        var dom = (svc.logo_domain && String(svc.logo_domain).toLowerCase()) || '';
3980        return name.indexOf(q) >= 0 || id.indexOf(q) >= 0 || dom.indexOf(q) >= 0;
3981    }
3982
3983    function renderReportPickerList(filterText) {
3984        var listEl = document.getElementById('detReportPickerList');
3985        var emptyEl = document.getElementById('detReportPickerEmpty');
3986        if (!listEl) return;
3987        var cat = state.catalog;
3988        if (!Array.isArray(cat) || !cat.length) {
3989            listEl.innerHTML =
3990                '<div class="det-report-picker-row" role="presentation"><span class="det-rp-name" style="flex:1;">A carregar catálogo…</span></div>';
3991            if (emptyEl) emptyEl.hidden = true;
3992            return;
3993        }
3994        var q = filterText != null ? String(filterText) : '';
3995        var rows = [];
3996        for (var i = 0; i < cat.length; i++) {
3997            if (reportPickerMatchesQuery(cat[i], q)) rows.push(cat[i]);
3998        }
3999        rows.sort(function (a, b) {
4000            var an = (a.name || a.id || '').toLowerCase();
4001            var bn = (b.name || b.id || '').toLowerCase();
4002            if (an < bn) return -1;
4003            if (an > bn) return 1;
4004            return 0;
4005        });
4006        var lim = Math.min(rows.length, 200);
4007        var h = '';
4008        for (var j = 0; j < lim; j++) {
4009            var s = rows[j];
4010            h +=
4011                '<div class="det-report-picker-row" role="listitem">' +
4012                detLogoImgHtml(s, '', ' width="36" height="36"') +
4013                '<span class="det-rp-name">' +
4014                esc(s.name || s.id) +
4015                '</span>' +
4016                '<button type="button" class="det-rp-report" data-det-rp-sid="' +
4017                esc(String(s.id)) +
4018                '">Reportar</button></div>';
4019        }
4020        if (rows.length > lim) {
4021            h +=
4022                '<p class="det-report-picker-lead" style="text-align:center;margin:8px 0 0;">Mostrando ' +
4023                lim +
4024                ' de ' +
4025                rows.length +
4026                '. Refine a pesquisa para encontrar mais.</p>';
4027        }
4028        listEl.innerHTML = h;
4029        bindDetLogoFallbacks(listEl);
4030        if (emptyEl) emptyEl.hidden = rows.length > 0;
4031        listEl.querySelectorAll('.det-rp-report').forEach(function (btn) {
4032            btn.addEventListener('click', function (ev) {
4033                ev.preventDefault();
4034                var sid = btn.getAttribute('data-det-rp-sid');
4035                closeReportPickerModal();
4036                if (sid) openCommunityReportModal(sid);
4037            });
4038        });
4039    }
4040
4041    function wireReportPickerModal() {
4042        var btn = document.getElementById('detBarReportBtn');
4043        var m = document.getElementById('detReportPickerModal');
4044        var closeBtn = document.getElementById('detReportPickerClose');
4045        var search = document.getElementById('detReportPickerSearch');
4046        if (btn && btn.dataset.detRpWired !== '1') {
4047            btn.dataset.detRpWired = '1';
4048            btn.addEventListener('click', function () {
4049                openReportPickerModal();
4050            });
4051        }
4052        if (closeBtn && closeBtn.dataset.detRpWired !== '1') {
4053            closeBtn.dataset.detRpWired = '1';
4054            closeBtn.addEventListener('click', function () {
4055                closeReportPickerModal();
4056            });
4057        }
4058        if (m && m.dataset.detRpBackdrop !== '1') {
4059            m.dataset.detRpBackdrop = '1';
4060            m.addEventListener('click', function (e) {
4061                if (e.target === m) closeReportPickerModal();
4062            });
4063        }
4064        if (search && search.dataset.detRpWired !== '1') {
4065            search.dataset.detRpWired = '1';
4066            search.addEventListener('input', function () {
4067                renderReportPickerList(search.value);
4068            });
4069        }
4070        if (!detReportPickerEscBound) {
4071            detReportPickerEscBound = true;
4072            document.addEventListener('keydown', function (e) {
4073                if (e.key !== 'Escape') return;
4074                var mod = document.getElementById('detReportPickerModal');
4075                if (mod && mod.classList.contains('open')) closeReportPickerModal();
4076            });
4077        }
4078    }
4079
4080    function initDetectorBarMascoteVideo() {
4081        var btn = document.getElementById('aurix-carnaval-mask');
4082        var v = btn && btn.querySelector('video');
4083        if (v) v.play().catch(function () {});
4084    }
4085
4086    /** Meta do ranking comunidade (HTML seguro: texto escapado + seta de tendência de volume). */
4087    function communityRankingMetaLineHtml(row) {
4088        if (!row) return '';
4089        var vc = Number(row.vote_count) || 0;
4090        var parts = [
4091            esc(__('js_sidebar_meta_simple').replace(/\{n\}/g, String(vc))) + volumeTrendMarkHtml(row),
4092        ];
4093        if (row.probe_down) parts.push(esc(__('js_sidebar_meta_probe_down')));
4094        var vel = Number(row.velocity_30m) || 0;
4095        if (vel >= 2) parts.push(esc(__('js_sidebar_meta_accel')));
4096        var pz = Number(row.poisson_z) || 0;
4097        if (pz >= 3) parts.push(esc(__('js_sidebar_meta_anomaly')));
4098        return parts.join(' · ');
4099    }
4100
4101    /** Ícone de constância (ondas azuis): sempre visível quando não há subida/descida clara. */
4102    function trendConstancyMarkHtml(titleText) {
4103        var t = esc(titleText);
4104        return (
4105            '<span class="det-trend-stable" title="' +
4106            t +
4107            '" role="img" aria-label="' +
4108            t +
4109            '"><svg class="det-trend-stable-svg" viewBox="0 0 20 12" width="18" height="11" focusable="false" aria-hidden="true">' +
4110            '<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M1 6.5c2.2-1.8 4.1 1.8 6.3 0s4.1-1.8 6.3 0 3.2 2.2 4.7 0"/>' +
4111            '<path fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" opacity="0.55" d="M1 9c2.2-1.8 4.1 1.8 6.3 0s4.1-1.8 6.3 0 3.2 2.2 4.7 0"/>' +
4112            '</svg></span>'
4113        );
4114    }
4115
4116    /**
4117     * Seta fina verde ↑ / vermelha ↓: volume nos últimos 30 min vs 30 min anteriores (humanos + sinais).
4118     * Igual espírito ao indicador de uso no Catellix Kids (subiu/desceu vs período anterior).
4119     */
4120    function volumeTrendMarkHtml(row) {
4121        // Só faz sentido com pontuação agregada (+N): relatos comunidade ou sinais coletados.
4122        // +0 (sem rede, sem local, sem votos) — não mostrar tendência de volume.
4123        var agg =
4124            row && row.vote_count != null && row.vote_count !== ''
4125                ? Number(row.vote_count)
4126                : 0;
4127        if (isNaN(agg) || agg < 1) return '';
4128        var aRaw = row && row.volume_last_30m;
4129        var bRaw = row && row.volume_prev_30m;
4130        var a = aRaw != null && aRaw !== '' && !isNaN(Number(aRaw)) ? Number(aRaw) : NaN;
4131        var b = bRaw != null && bRaw !== '' && !isNaN(Number(bRaw)) ? Number(bRaw) : NaN;
4132        if (isNaN(a) || isNaN(b)) return '';
4133        if (a === b) {
4134            return trendConstancyMarkHtml(__('js_vol_trend_stable_title').replace(/\{cur\}/g, String(a)).replace(/\{prev\}/g, String(b)));
4135        }
4136        var up = a > b;
4137        var cls = 'det-vol-trend ' + (up ? 'det-vol-trend--up' : 'det-vol-trend--down');
4138        var sym = up ? '↑' : '↓';
4139        var titleKey = up ? 'js_vol_trend_up_title' : 'js_vol_trend_down_title';
4140        var title = __(titleKey).replace(/\{cur\}/g, String(a)).replace(/\{prev\}/g, String(b));
4141        return (
4142            '<span class="' +
4143            cls +
4144            '" title="' +
4145            esc(title) +
4146            '" role="img" aria-label="' +
4147            esc(title) +
4148            '">' +
4149            sym +
4150            '</span>'
4151        );
4152    }
4153
4154    /** Seta ↑ (subiu) / ↓ (desceu) vs última atualização do ranking; ondas se estável ou 1.ª leitura na sessão. */
4155    function rankingTrendMarkHtml(svcId, rank, prevMap) {
4156        if (!svcId) return '';
4157        var map = prevMap || {};
4158        var prev = map[svcId];
4159        if (prev == null || prev === undefined || isNaN(Number(prev))) {
4160            return trendConstancyMarkHtml(__('js_rank_trend_baseline_title'));
4161        }
4162        if (Number(prev) === Number(rank)) {
4163            return trendConstancyMarkHtml(__('js_rank_trend_stable_title'));
4164        }
4165        var up = Number(rank) < Number(prev);
4166        var cls = 'det-rank-trend ' + (up ? 'det-rank-trend--up' : 'det-rank-trend--down');
4167        var sym = up ? '↑' : '↓';
4168        var title = up ? __('js_sidebar_rank_up') : __('js_sidebar_rank_down');
4169        return (
4170            '<span class="' +
4171            cls +
4172            '" title="' +
4173            esc(title) +
4174            '" role="img" aria-label="' +
4175            esc(title) +
4176            '">' +
4177            sym +
4178            '</span>'
4179        );
4180    }
4181
4182    function snapshotCommunityRankPositions(rows) {
4183        var m = {};
4184        if (!rows || !rows.length) return m;
4185        for (var i = 0; i < rows.length; i++) {
4186            if (rows[i].service_id) m[rows[i].service_id] = i + 1;
4187        }
4188        return m;
4189    }
4190
4191    /** Mescla uma linha do ranking comunidade com o serviço do catálogo (votos atualizados). */
4192    function communityRankingRowToSvc(row) {
4193        if (!row || !row.service_id) return null;
4194        var vc = Number(row.vote_count) || 0;
4195        var vuRaw = row.vote_count_verified;
4196        var vsRaw = row.vote_count_signals;
4197        var vu = vuRaw != null && vuRaw !== '' && !isNaN(Number(vuRaw)) ? Number(vuRaw) : vc;
4198        var vs = vsRaw != null && vsRaw !== '' && !isNaN(Number(vsRaw)) ? Number(vsRaw) : 0;
4199        var vah = row.vote_avg_per_hour;
4200        var avgPerH =
4201            vah != null && vah !== '' && !isNaN(Number(vah)) ? Number(vah) : null;
4202        var patch = {
4203            community_vote_count: vc,
4204            community_vote_count_verified: vu,
4205            community_vote_count_signals: vs,
4206            community_vote_avg_per_hour: avgPerH,
4207        };
4208        var base = findSvc(row.service_id);
4209        if (base) {
4210            var o = {};
4211            for (var k in base) {
4212                if (Object.prototype.hasOwnProperty.call(base, k)) o[k] = base[k];
4213            }
4214            var rowIcons = row.logo_icons;
4215            if (
4216                (!Array.isArray(o.logo_icons) || !o.logo_icons.length) &&
4217                Array.isArray(rowIcons) &&
4218                rowIcons.length
4219            ) {
4220                o.logo_icons = rowIcons;
4221            }
4222            if (!(o.logo_domain || '').trim() && row.logo_domain) {
4223                o.logo_domain = String(row.logo_domain).trim();
4224            }
4225            if ((o.logo_url == null || o.logo_url === '') && row.logo_url) {
4226                o.logo_url = row.logo_url;
4227            }
4228            o.community_vote_count = patch.community_vote_count;
4229            o.community_vote_count_verified = patch.community_vote_count_verified;
4230            o.community_vote_count_signals = patch.community_vote_count_signals;
4231            o.community_vote_avg_per_hour = patch.community_vote_avg_per_hour;
4232            return o;
4233        }
4234        var ld0 = row.logo_domain != null ? String(row.logo_domain).trim() : '';
4235        var li0 = row.logo_icons;
4236        return {
4237            id: row.service_id,
4238            name: row.name || row.service_id,
4239            logo_domain: ld0,
4240            logo_url: row.logo_url != null && row.logo_url !== '' ? row.logo_url : null,
4241            logo_icons: Array.isArray(li0) && li0.length ? li0 : null,
4242            last_ok: null,
4243            last_latency_ms: null,
4244            category: 'core',
4245            community_vote_count: vc,
4246            community_vote_count_verified: vu,
4247            community_vote_count_signals: vs,
4248            community_vote_avg_per_hour: avgPerH,
4249        };
4250    }
4251
4252    var MOBILE_COMMUNITY_TOP_N = 10;
4253
4254    function mobileCommunityTop10HeadActionsHtml() {
4255        return (
4256            '<div class="det-m-t10-head-actions" role="group" aria-label="' +
4257            esc(__('js_t10_actions_aria')) +
4258            '"><button type="button" class="det-m-t10-report-btn">' +
4259            esc(__('js_rank_share_btn_short')) +
4260            '</button><button type="button" class="det-m-t10-social-btn"><span class="det-m-t10-social-ico" aria-hidden="true">✦</span>' +
4261            esc(__('js_t10_social_btn_short')) +
4262            '</button></div>'
4263        );
4264    }
4265
4266    function mountMobileCommunityTop10Board() {
4267        var host = document.getElementById('detMobileCommunityBoardHost');
4268        if (!host) return;
4269
4270        if (!state.catalog || !state.catalog.length) {
4271            host.innerHTML =
4272                '<div class="top10-board top10-board--bad">' +
4273                '<div class="top10-board-head">' +
4274                '<span class="t10-icon">📣</span>' +
4275                '<div class="t10-title-col"><h4>' +
4276                esc(__('js_m_top10_comm_title')) +
4277                '</h4><small>' +
4278                esc(__('js_m_top10_comm_sub')) +
4279                '</small></div>' +
4280                mobileCommunityTop10HeadActionsHtml() +
4281                '</div>' +
4282                '<div class="top10-empty">' +
4283                esc(__('js_m_top10_comm_loading')) +
4284                '</div></div>';
4285            var rep0 = host.querySelector('.det-m-t10-report-btn');
4286            var soc0 = host.querySelector('.det-m-t10-social-btn');
4287            if (rep0)
4288                rep0.addEventListener('click', function (e) {
4289                    e.preventDefault();
4290                    openDetectorRankingReportModal('top10');
4291                });
4292            if (soc0)
4293                soc0.addEventListener('click', function (e) {
4294                    e.preventDefault();
4295                    openTop10SocialShareModal();
4296                });
4297            return;
4298        }
4299
4300        var rankRows = state.communityRankingRows;
4301        var rowsWithVotes = [];
4302        if (rankRows !== undefined && rankRows !== null && rankRows.length) {
4303            for (var wi = 0; wi < rankRows.length; wi++) {
4304                var rw0 = rankRows[wi];
4305                if (rw0 && (Number(rw0.vote_count) || 0) > 0) rowsWithVotes.push(rw0);
4306            }
4307        }
4308        var globalRankById = {};
4309        for (var gi = 0; gi < rowsWithVotes.length; gi++) {
4310            var gr = rowsWithVotes[gi];
4311            if (gr && gr.service_id) globalRankById[gr.service_id] = gi + 1;
4312        }
4313        var trendPrevMap = state._complaintsRankTrendPrev || {};
4314
4315        var items = [];
4316        if (rankRows !== undefined && rankRows !== null && rankRows.length) {
4317            for (var ri = 0; ri < rankRows.length && items.length < MOBILE_COMMUNITY_TOP_N; ri++) {
4318                var row = rankRows[ri];
4319                if (!row || (Number(row.vote_count) || 0) < 1) continue;
4320                var s0 = communityRankingRowToSvc(row);
4321                if (!s0 || !passesFilterSansSearch(s0)) continue;
4322                items.push({ svc: s0, row: row });
4323            }
4324        }
4325
4326        var title = __('js_m_top10_comm_title');
4327        var sub;
4328        if (items.length && rankRows !== undefined && rankRows !== null) {
4329            var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
4330            var dayLblMap = {
4331                1: __('js_window_1d'),
4332                7: __('js_window_7d'),
4333                15: __('js_window_15d'),
4334                30: __('js_window_30d'),
4335            };
4336            var dayLbl = dayLblMap[days] || __('js_window_1d');
4337            sub = __('js_t10_complaints_window_top_tpl')
4338                .replace(/\{w\}/g, dayLbl)
4339                .replace(/\{top\}/g, String(MOBILE_COMMUNITY_TOP_N))
4340                .replace(/\{n\}/g, String(items.length));
4341        } else {
4342            sub = __('js_m_top10_comm_sub');
4343        }
4344
4345        var body = '';
4346        if (!items.length) {
4347            var emptyMsg;
4348            if (rankRows === undefined) {
4349                emptyMsg = __('js_m_top10_comm_loading');
4350            } else if (rankRows === null) {
4351                emptyMsg = __('js_comm_rank_err_li');
4352            } else if (
4353                rankRows.some(function (r) {
4354                    return r && (Number(r.vote_count) || 0) > 0;
4355                })
4356            ) {
4357                emptyMsg = __('js_cards_empty_filter');
4358            } else {
4359                emptyMsg = __('js_complaints_empty_generic').replace(/\{d\}/g, String(state.complaintsRankingDays));
4360            }
4361            body = '<div class="top10-empty">' + esc(emptyMsg) + '</div>';
4362        } else {
4363            var listParts = [];
4364            for (var ii = 0; ii < items.length; ii++) {
4365                var it = items[ii];
4366                var row = it.row;
4367                var pillDisp = complaintsRankingPillDisplay(row);
4368                var pillTitle = complaintsRankingPillTitle(row, pillDisp);
4369                var globalRnk = globalRankById[row.service_id] || ii + 1;
4370                var idx0 = globalRnk - 1;
4371                var rankCls =
4372                    idx0 === 0
4373                        ? 'det-sb-rank det-sb-rank--gold'
4374                        : idx0 === 1
4375                          ? 'det-sb-rank det-sb-rank--silver'
4376                          : idx0 === 2
4377                            ? 'det-sb-rank det-sb-rank--bronze'
4378                            : 'det-sb-rank';
4379                var trendHtml = rankingTrendMarkHtml(row.service_id, globalRnk, trendPrevMap);
4380                var svc = findSvc(row.service_id);
4381                var dom = svc && svc.logo_domain ? String(svc.logo_domain) : (row.logo_domain ? String(row.logo_domain) : '');
4382                var logo = detComplaintsRankLogoSrc(svc, row);
4383                var rowA11y =
4384                    esc(__('js_open_service_aria')) + ' ' + esc(row.name || row.service_id || '');
4385                listParts.push(
4386                    '<li class="det-sidebar-complaints-item"><button type="button" class="det-sidebar-line det-sidebar-line--rank det-sidebar-line--rank-main" data-svc-id="' +
4387                        esc(row.service_id) +
4388                        '" aria-label="' +
4389                        rowA11y +
4390                        '"><span class="det-sb-rank-wrap"><span class="' +
4391                        esc(rankCls) +
4392                        '">' +
4393                        esc(String(globalRnk)) +
4394                        '</span>' +
4395                        trendHtml +
4396                        '</span><img class="det-sb-logo" src="' +
4397                        esc(logo) +
4398                        '" alt="" width="36" height="36" loading="lazy" decoding="async"' +
4399                        imgLogoOnErrorAttr(dom) +
4400                        '><span class="det-sb-body"><span class="det-sb-name">' +
4401                        esc(row.name) +
4402                        '</span><span class="det-sb-meta">' +
4403                        communityRankingMetaLineHtml(row) +
4404                        '</span></span></button>' +
4405                        communityTop10ComplaintsRailHtml(row, pillDisp, pillTitle) +
4406                        '</li>'
4407                );
4408            }
4409            body =
4410                '<div class="det-t10-m-comm-stack">' +
4411                '<div class="det-t10-comm-col-head">' +
4412                esc(__('js_t10_report_now_line')) +
4413                '</div>' +
4414                '<ol class="det-complaints-ol det-complaints-ol--sidebar-rank det-m-community-rank-ol" aria-label="' +
4415                esc(__('sidebar_complaints_aria')) +
4416                '">' +
4417                listParts.join('') +
4418                '</ol></div>';
4419        }
4420
4421        host.innerHTML =
4422            '<div class="top10-board top10-board--bad">' +
4423            '<div class="top10-board-head">' +
4424            '<span class="t10-icon">📣</span>' +
4425            '<div class="t10-title-col"><h4>' +
4426            esc(title) +
4427            '</h4><small>' +
4428            esc(sub) +
4429            '</small></div>' +
4430            mobileCommunityTop10HeadActionsHtml() +
4431            '</div>' +
4432            body +
4433            '</div>';
4434
4435        bindDetLogoFallbacks(host);
4436        wireComplaintsTop10Clicks(host);
4437        var repB = host.querySelector('.det-m-t10-report-btn');
4438        var socB = host.querySelector('.det-m-t10-social-btn');
4439        if (repB)
4440            repB.addEventListener('click', function (e) {
4441                e.preventDefault();
4442                openDetectorRankingReportModal('top10');
4443            });
4444        if (socB)
4445            socB.addEventListener('click', function (e) {
4446                e.preventDefault();
4447                openTop10SocialShareModal();
4448            });
4449    }
4450
4451    function mountMobileGamesBoardClone() {
4452        var mobileMount = document.getElementById('detMobileGamesBoardMount');
4453        if (!mobileMount) return;
4454        if (!DET_COMPLAINTS_VIEWPORT_MQ.matches) {
4455            mobileMount.innerHTML = '';
4456            return;
4457        }
4458        var desktopBoard = document.querySelector('.ads-right .det-right-games-board');
4459        if (!desktopBoard) return;
4460        var clone = desktopBoard.cloneNode(true);
4461        clone.removeAttribute('id');
4462        var ids = clone.querySelectorAll('[id]');
4463        for (var i = 0; i < ids.length; i++) {
4464            ids[i].removeAttribute('id');
4465        }
4466        mobileMount.innerHTML = '';
4467        mobileMount.appendChild(clone);
4468        bindDetLogoFallbacks(clone);
4469        clone.querySelectorAll('.det-sidebar-line--rank-main[data-svc-id]').forEach(function (btn) {
4470            btn.addEventListener('click', function () {
4471                var id = btn.getAttribute('data-svc-id');
4472                if (id) openModal(id);
4473            });
4474        });
4475        wireComplaintsTop10Clicks(clone);
4476    }
4477
4478    function syncComplaintsTop10BoardsAndCards() {
4479        mountMobileCommunityTop10Board();
4480        if (state.catalog && state.catalog.length) {
4481            if (!markLazySectionDirty('top10Boards')) mountTop10Boards();
4482            mountTop10Cards();
4483            try { mountRightTop10GamesBoard(); } catch (eGb) { console.warn('[det] games board err', eGb); }
4484            try { mountMobileGamesBoardClone(); } catch (eMg) { console.warn('[det] mobile games clone err', eMg); }
4485            updateCatalogFilterBanner();
4486        }
4487    }
4488
4489    /**
4490     * Top 10 Games — mesma lógica do Top 10 comunidade, mas filtrada só a serviços de games.
4491     *
4492     * Fonte primária: communityRankingRows (ranking_sort_score + relatos + sinais — igual ao
4493     * painel esquerdo), filtrado por isDetectorGameCatalogService.
4494     *
4495     * Fallback: quando não há linhas de comunidade com votos para games (dados ainda não
4496     * carregaram ou janela zerada), cai para ordenação por latência (gameRankingLatencyMs)
4497     * nos serviços do catálogo com categoria game.
4498     */
4499    function mountRightTop10GamesBoard() {
4500        var host = document.getElementById('detRightGamesTop10');
4501        var sub  = document.getElementById('detRightGamesTop10Sub');
4502        var headEl = document.getElementById('detRightGamesTop10Head');
4503        var shareBtn = document.getElementById('btnRightTop10GamesSocial');
4504        if (!host) return;
4505
4506        var hybrid = sidebarTop10GamesHybridParts();
4507        var commRows = hybrid.communityRankingRows;
4508        var gameCommRows = hybrid.gameCommRows;
4509        var latFillSvcs = hybrid.latFillSvcs;
4510        var prevMap  = state._gamesRankTrendPrev || {};
4511
4512        var totalItems = gameCommRows.length + latFillSvcs.length;
4513
4514        /* ── 3. Estado de carregamento ── */
4515        if (commRows === undefined && totalItems === 0) {
4516            if (sub) sub.textContent = __('js_comm_rank_loading');
4517            if (headEl) headEl.hidden = true;
4518            var sk = '';
4519            for (var si2 = 0; si2 < 5; si2++) {
4520                sk += '<li class="det-sidebar-skel" aria-hidden="true"><span class="det-sb-skel-rank"></span><span class="det-sb-skel-logo"></span><span class="det-sb-skel-text"><span class="det-sb-skel-line"></span><span class="det-sb-skel-line det-sb-skel-line--short"></span></span></li>';
4521            }
4522            host.innerHTML = sk;
4523            if (shareBtn && shareBtn.dataset.detWired !== '1') {
4524                shareBtn.dataset.detWired = '1';
4525                shareBtn.addEventListener('click', function (e) { e.preventDefault(); openTop10GamesSocialShareModal(); });
4526            }
4527            return;
4528        }
4529
4530        if (totalItems === 0) {
4531            if (sub) sub.textContent = __('js_t10_games_social_no_data');
4532            if (headEl) headEl.hidden = true;
4533            host.innerHTML = '<li class="det-sidebar-empty" role="status">' + esc(__('js_t10_games_social_no_data')) + '</li>';
4534            if (shareBtn && shareBtn.dataset.detWired !== '1') {
4535                shareBtn.dataset.detWired = '1';
4536                shareBtn.addEventListener('click', function (e) { e.preventDefault(); openTop10GamesSocialShareModal(); });
4537            }
4538            return;
4539        }
4540
4541        /* ── 4. Renderização ── */
4542        if (sub) sub.textContent = __('js_t10_games_sub');
4543        if (headEl) {
4544            headEl.hidden = (gameCommRows.length === 0);
4545            headEl.textContent = __('js_t10_report_now_line');
4546        }
4547
4548        /* Helper: renderiza linha de latência (fill) */
4549        function renderGameLatRow(s, rnk, trendHtml) {
4550            var ms      = gameRankingLatencyMs(s);
4551            var msRound = ms != null && !isNaN(ms) ? Math.round(ms) : null;
4552            var rankCls = rnk === 1 ? 'det-sb-rank det-sb-rank--gold'
4553                        : rnk === 2 ? 'det-sb-rank det-sb-rank--silver'
4554                        : rnk === 3 ? 'det-sb-rank det-sb-rank--bronze'
4555                        :             'det-sb-rank';
4556            var logo    = detComplaintsRankLogoSrc(s, null);
4557            var dom     = s.logo_domain || '';
4558            var rowA11y = esc(__('js_open_service_aria')) + ' ' + esc(s.name || s.id || '');
4559            return (
4560                '<li class="det-sidebar-complaints-item det-right-games-board__item det-right-games-board__item--lat">' +
4561                '<button type="button" class="det-sidebar-line det-sidebar-line--rank det-sidebar-line--rank-main" data-svc-id="' +
4562                esc(s.id) + '" aria-label="' + rowA11y + '">' +
4563                '<span class="det-sb-rank-wrap"><span class="' + esc(rankCls) + '">' +
4564                esc(String(rnk)) + '</span>' + trendHtml + '</span>' +
4565                '<img class="det-sb-logo" src="' + esc(logo) +
4566                '" alt="" width="36" height="36" loading="lazy" decoding="async"' +
4567                imgLogoOnErrorAttr(dom) + '>' +
4568                '<span class="det-sb-body"><span class="det-sb-name">' + esc(s.name || s.id) +
4569                '</span><span class="det-sb-meta det-sb-meta--lat">' +
4570                (msRound != null ? 'HTTPS ' + String(msRound) + ' ms · sem relatos ainda' : __('js_games_rank_meta')) +
4571                '</span></span></button>' +
4572                gamesTop10LatencyRailHtml(s, msRound) +
4573                '</li>'
4574            );
4575        }
4576
4577        var htmlParts = [];
4578
4579        /* Parte A — linhas com relatos (comunidade) */
4580        for (var ci2 = 0; ci2 < gameCommRows.length; ci2++) {
4581            try {
4582                var row     = gameCommRows[ci2];
4583                if (!row || !row.service_id) continue;
4584                var rnk     = ci2 + 1;
4585                var rankCls = rnk === 1 ? 'det-sb-rank det-sb-rank--gold'
4586                            : rnk === 2 ? 'det-sb-rank det-sb-rank--silver'
4587                            : rnk === 3 ? 'det-sb-rank det-sb-rank--bronze'
4588                            :             'det-sb-rank';
4589                var trendHtml   = rankingTrendMarkHtml(row.service_id, rnk, prevMap);
4590                var svc         = findSvc(row.service_id);
4591                var logo        = detComplaintsRankLogoSrc(svc, row);
4592                var dom         = svc && svc.logo_domain ? String(svc.logo_domain) : (row.logo_domain || '');
4593                var pillDisp    = complaintsRankingPillDisplay(row);
4594                var pillTitle   = complaintsRankingPillTitle(row, pillDisp);
4595                var rowA11y     = esc(__('js_open_service_aria')) + ' ' + esc(row.name || row.service_id || '');
4596                var metaLine    = communityRankingMetaLineHtml(row);
4597                htmlParts.push(
4598                    '<li class="det-sidebar-complaints-item det-right-games-board__item">' +
4599                    '<button type="button" class="det-sidebar-line det-sidebar-line--rank det-sidebar-line--rank-main" data-svc-id="' +
4600                    esc(row.service_id) + '" aria-label="' + rowA11y + '">' +
4601                    '<span class="det-sb-rank-wrap"><span class="' + esc(rankCls) + '">' +
4602                    esc(String(rnk)) + '</span>' + trendHtml + '</span>' +
4603                    '<img class="det-sb-logo" src="' + esc(logo) +
4604                    '" alt="" width="36" height="36" loading="lazy" decoding="async"' +
4605                    imgLogoOnErrorAttr(dom) + '>' +
4606                    '<span class="det-sb-body"><span class="det-sb-name">' + esc(row.name) +
4607                    '</span><span class="det-sb-meta">' + metaLine + '</span></span></button>' +
4608                    communityTop10ComplaintsRailHtml(row, pillDisp, pillTitle) +
4609                    '</li>'
4610                );
4611            } catch (eComm) { /* skip broken row */ }
4612        }
4613
4614        /* Parte B — fill com latência */
4615        for (var fi = 0; fi < latFillSvcs.length; fi++) {
4616            try {
4617                if (!latFillSvcs[fi] || !latFillSvcs[fi].id) continue;
4618                var fillRnk = gameCommRows.length + fi + 1;
4619                var fillTrend = rankingTrendMarkHtml(latFillSvcs[fi].id, fillRnk, prevMap);
4620                htmlParts.push(renderGameLatRow(latFillSvcs[fi], fillRnk, fillTrend));
4621            } catch (eFill) { /* skip broken row */ }
4622        }
4623
4624        var html = htmlParts.join('');
4625        host.innerHTML = html;
4626
4627        /* Snapshot de posições para a próxima chamada */
4628        var snapGames = {};
4629        gameCommRows.forEach(function (row, i) { snapGames[row.service_id] = i + 1; });
4630        latFillSvcs.forEach(function (s, i) { snapGames[s.id] = gameCommRows.length + i + 1; });
4631        state._gamesRankTrendPrev = snapGames;
4632
4633        bindDetLogoFallbacks(host);
4634        wireComplaintsTop10Clicks(host);
4635
4636        if (shareBtn && shareBtn.dataset.detWired !== '1') {
4637            shareBtn.dataset.detWired = '1';
4638            shareBtn.addEventListener('click', function (e) {
4639                e.preventDefault();
4640                openTop10GamesSocialShareModal();
4641            });
4642        }
4643    }
4644
4645    function wireComplaintsTop10Clicks(container) {
4646        if (!container) return;
4647        container.querySelectorAll('.det-sidebar-line--rank-main[data-svc-id]').forEach(function (btn) {
4648            btn.addEventListener('click', function () {
4649                var id = btn.getAttribute('data-svc-id');
4650                if (id) openModal(id);
4651            });
4652        });
4653        container.querySelectorAll('.det-sb-hourly-btn[data-hourly-sid]').forEach(function (btn) {
4654            btn.addEventListener('click', function (e) {
4655                e.preventDefault();
4656                e.stopPropagation();
4657                var sid = btn.getAttribute('data-hourly-sid');
4658                var nm = btn.getAttribute('data-hourly-name') || sid;
4659                if (sid) openComplaintsHourlyMicroModal(sid, nm);
4660            });
4661        });
4662    }
4663
4664    function formatRankingAvgPerHour(row) {
4665        if (!row || row.vote_avg_per_hour == null || row.vote_avg_per_hour === '') return '0.0';
4666        var n = Number(row.vote_avg_per_hour);
4667        return isNaN(n) ? '0.0' : n.toFixed(1);
4668    }
4669
4670    /**
4671     * Escala máxima do Pulso normalizado: o #1 do ranking recebe até este valor.
4672     * Escala 0–100 (metáfora batimento cardíaco).
4673     */
4674    var PULSE_SCALE_MAX = 100;
4675
4676    function _communityRankingMaxScore() {
4677        var rows = state.communityRankingRows;
4678        if (!rows || !rows.length) return 1;
4679        var mx = 0;
4680        for (var i = 0; i < rows.length; i++) {
4681            var s = Number(rows[i].ranking_sort_score);
4682            if (!isNaN(s) && s > mx) mx = s;
4683        }
4684        return mx || 1;
4685    }
4686
4687    function complaintsRankingPillDisplay(row) {
4688        var avg = row && row.vote_avg_per_hour != null ? Number(row.vote_avg_per_hour) : NaN;
4689        var ultra = row && row.vote_per_hour_ultra != null ? Number(row.vote_per_hour_ultra) : 
4689NaN;
4690        var vel = row ? Number(row.velocity_30m) || 0 : 0;
4691        var pz = row ? Number(row.poisson_z) || 0 : 0;
4692        var down = row && row.probe_down;
4693
4694        if (down) {
4695            return { value: __('js_sidebar_pill_down'), isSpike: true, isDown: true };
4696        }
4697        var isSpike = false;
4698        if (!isNaN(ultra) && !isNaN(avg) && ultra >= avg * 1.22 && ultra >= 0.35) isSpike = true;
4699        if (vel >= 3 || pz >= 4) isSpike = true;
4700
4701        var sortScore = row && row.ranking_sort_score != null ? Number(row.ranking_sort_score) : NaN;
4702        var displayVal;
4703        if (!isNaN(sortScore) && sortScore > 0) {
4704            var maxScore = _communityRankingMaxScore();
4705            var pulse = (sortScore / maxScore) * PULSE_SCALE_MAX;
4706            displayVal = pulse.toFixed(1);
4707        } else {
4708            displayVal = formatRankingAvgPerHour(row);
4709        }
4710        return { value: displayVal, isSpike: isSpike, isDown: false };
4711    }
4712
4713    function complaintsRankingPillTitle(row, pillDisp) {
4714        var avgStr = formatRankingAvgPerHour(row);
4715        var vc = row && (Number(row.vote_count) || 0) > 0 ? Number(row.vote_count) : 0;
4716        var uh = row && row.ultra_window_hours != null ? Number(row.ultra_window_hours) : 2;
4717        var vel = row ? Number(row.velocity_30m) || 0 : 0;
4718        var pz = row ? Number(row.poisson_z) || 0 : 0;
4719        if (pillDisp && pillDisp.isDown) {
4720            return __('js_sidebar_avg_pill_title_down');
4721        }
4722        if (pillDisp && pillDisp.isSpike) {
4723            var parts = [];
4724            if (row && row.vote_per_hour_ultra != null) {
4725                parts.push(__('js_sidebar_avg_pill_title_spike_tpl')
4726                    .replace(/\{uh\}/g, String(!isNaN(uh) && uh > 0 ? uh : 2))
4727                    .replace(/\{short\}/g, pillDisp.value)
4728                    .replace(/\{avg\}/g, avgStr)
4729                    .replace(/\{total\}/g, String(vc)));
4730            }
4731            if (vel >= 3) parts.push(__('js_sidebar_meta_accel'));
4732            if (pz >= 4) parts.push(__('js_sidebar_meta_anomaly_detail').replace(/\{z\}/g, pz.toFixed(1)));
4733            return parts.join(' — ') || avgStr;
4734        }
4735        return __('js_sidebar_avg_pill_title_tpl').replace(/\{avg\}/g, avgStr).replace(/\{total\}/g, String(vc));
4736    }
4737
4738    /**
4739     * Um único botão por linha no Top 10 comunidade: abre o gráfico por hora; o relato fica no modal.
4740     */
4741    function communityTop10ComplaintsRailHtml(row, pillDisp, pillTitle) {
4742        if (!row || !row.service_id) return '';
4743        var disp = pillDisp || { value: '—', isSpike: false, isDown: false };
4744        var sid = row.service_id;
4745        var nm = row.name || sid;
4746        var btnCls =
4747            'det-t10-unified-btn det-sb-hourly-btn' +
4748            (disp.isDown ? ' det-t10-unified--down' : disp.isSpike ? ' det-t10-unified--spike' : '');
4749        var reportLbl = esc(__('js_t10_btn_report') || 'Reportar');
4750        var pulseLbl = esc(__('js_rank_pill_label') || 'Pulso');
4751        return (
4752            '<div class="det-t10-comm-rail">' +
4753            '<button type="button" class="' +
4754            esc(btnCls) +
4755            '" data-hourly-sid="' +
4756            esc(sid) +
4757            '" data-hourly-name="' +
4758            esc(nm) +
4759            '" title="' +
4760            esc(pillTitle) +
4761            '" aria-label="' +
4762            esc(pillTitle) +
4763            '">' +
4764            '<span class="det-t10-unified-shine" aria-hidden="true"></span>' +
4765            '<span class="det-t10-unified-inner">' +
4766            '<span class="det-t10-report-tag">' +
4767            reportLbl +
4768            '</span>' +
4769            '<span class="det-t10-report-sep" aria-hidden="true"></span>' +
4770            '<span class="det-t10-pulse-mini">' +
4771            '<span class="det-t10-pulse-mini-lbl">' +
4772            pulseLbl +
4773            '</span>' +
4774            '<span class="det-t10-pulse-mini-val">' +
4775            esc(disp.value) +
4776            '</span>' +
4777            '</span></span></button></div>'
4778        );
4779    }
4780
4781    /**
4782     * Rail do Top 10 jogos (direita): botão Reportar + latência como info secundária.
4783     */
4784    function gamesTop10LatencyRailHtml(svc, ms) {
4785        if (!svc || !svc.id) return '';
4786        var sid = svc.id;
4787        var nm = svc.name || sid;
4788        var dispVal = ms != null && !isNaN(ms) ? String(Math.round(ms)) + ' ms' : '—';
4789        var isDown = ms == null || isNaN(ms);
4790        var isSpike = !isDown && ms > 500;
4791        var btnCls =
4792            'det-t10-unified-btn det-sb-hourly-btn' +
4793            (isDown ? ' det-t10-unified--down' : isSpike ? ' det-t10-unified--spike' : '');
4794        var reportLbl = esc(__('js_t10_btn_report') || 'Reportar');
4795        var httpsLbl = esc(__('js_games_lat_rail_https'));
4796        var aria = reportLbl + ' · ' + esc(nm);
4797        return (
4798            '<div class="det-t10-comm-rail">' +
4799            '<button type="button" class="' +
4800            esc(btnCls) +
4801            '" data-hourly-sid="' +
4802            esc(sid) +
4803            '" data-hourly-name="' +
4804            esc(nm) +
4805            '" title="' +
4806            esc(aria) +
4807            '" aria-label="' +
4808            esc(aria) +
4809            '">' +
4810            '<span class="det-t10-unified-shine" aria-hidden="true"></span>' +
4811            '<span class="det-t10-unified-inner">' +
4812            '<span class="det-t10-report-tag">' +
4813            reportLbl +
4814            '</span>' +
4815            '<span class="det-t10-report-sep" aria-hidden="true"></span>' +
4816            '<span class="det-t10-pulse-mini">' +
4817            '<span class="det-t10-pulse-mini-lbl">' +
4818            httpsLbl +
4819            '</span>' +
4820            '<span class="det-t10-pulse-mini-val">' +
4821            esc(dispVal) +
4822            '</span>' +
4823            '</span></span></button></div>'
4824        );
4825    }
4826
4827    function syncDetComplaintsTop10ColumnHead(visible) {
4828        var h = document.getElementById('detComplaintsTop10Head');
4829        if (!h) return;
4830        h.hidden = !visible;
4831        if (visible) h.textContent = __('js_t10_report_now_line');
4832    }
4833
4834    function closeComplaintsHourlyMicroModal() {
4835        var m = document.getElementById('detComplaintsHourlyModal');
4836        var repRow = document.getElementById('detComplaintsHourlyReportRow');
4837        if (m) {
4838            m.classList.remove('open');
4839            m.setAttribute('aria-hidden', 'true');
4840            try {
4841                delete m.dataset.hourlySvcId;
4842            } catch (eD) {
4843                m.removeAttribute('data-hourly-svc-id');
4844            }
4845        }
4846        if (repRow) repRow.hidden = true;
4847    }
4848
4849    /**
4850     * GET JSON do detector com abort após timeoutMs (evita modal preso em «A carregar gráfico…»).
4851     */
4852    function fetchDetectorApiJson(url, timeoutMs) {
4853        var ms = timeoutMs != null ? timeoutMs : 28000;
4854        var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
4855        var tid = null;
4856        if (ctrl && ms > 0) {
4857            tid = setTimeout(function () {
4858                try {
4859                    ctrl.abort();
4860                } catch (eAbort) {
4861                    /* ignore */
4862                }
4863            }, ms);
4864        }
4865        var opts = { credentials: 'omit' };
4866        if (ctrl) opts.signal = ctrl.signal;
4867        return fetch(url, opts)
4868            .then(function (r) {
4869                return r.text().then(function (text) {
4870                    var data = {};
4871                    if (text != null && String(text).trim()) {
4872                        try {
4873                            data = JSON.parse(text);
4874                        } catch (eParse) {
4875                            var err = new Error('bad_json');
4876                            err._httpStatus = r.status;
4877                            throw err;
4878                        }
4879                    }
4880                    return { ok: r.ok, status: r.status, data: data };
4881                });
4882            })
4883            .finally(function () {
4884                if (tid) clearTimeout(tid);
4885            });
4886    }
4887
4888    function openComplaintsHourlyMicroModal(sid, svcName) {
4889        var m = document.getElementById('detComplaintsHourlyModal');
4890        var title = document.getElementById('detComplaintsHourlyTitle');
4891        var host = document.getElementById('detComplaintsHourlyChartHost');
4892        var foot = document.getElementById('detComplaintsHourlyFoot');
4893        var repRow = document.getElementById('detComplaintsHourlyReportRow');
4894        var repBtn = document.getElementById('detComplaintsHourlyReportBtn');
4895        if (!m || !host) return;
4896        m.dataset.hourlySvcId = sid;
4897        if (repBtn) {
4898            repBtn.textContent = __('js_t10_report_now_line');
4899            var nm = svcName || sid;
4900            repBtn.setAttribute('aria-label', __('js_t10_report_flow_aria_tpl').replace(/\{name\}/g, nm));
4901            repBtn.setAttribute('title', __('js_t10_report_flow_aria_tpl').replace(/\{name\}/g, nm));
4902        }
4903        if (repRow) repRow.hidden = false;
4904        var reqGen = (detComplaintsHourlyFetchGen += 1);
4905        var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
4906        var hours = Math.min(720, Math.max(24, days * 24));
4907        var origin = window.location.origin || '';
4908        if (title) {
4909            title.textContent =
4910                (svcName || sid) + ' — ' + __('js_sidebar_hourly_chart_lead');
4911        }
4912        var lead = document.getElementById('detComplaintsHourlyLead');
4913        if (lead) {
4914            lead.textContent = __('js_sidebar_hourly_modal_lead') ||
4915                'Distribuição horária na janela selecionada (relatos verificados + sinais agregados).';
4916        }
4917        host.innerHTML =
4918            '<p class="det-hourly-loading">' + esc(__('js_sidebar_hourly_loading')) + '</p>';
4919        if (foot) foot.textContent = '';
4920        m.classList.add('open');
4921        m.setAttribute('aria-hidden', 'false');
4922
4923        var resolved = false;
4924        var safetyTid = setTimeout(function () {
4925            if (!resolved && reqGen === detComplaintsHourlyFetchGen) {
4926                host.innerHTML =
4927                    '<p class="det-hourly-err">' +
4928                    esc(__('js_sidebar_hourly_err_timeout') || 'Tempo esgotado ao carregar gráfico.') +
4929                    '</p>';
4930            }
4931        }, 24000);
4932
4933        var url =
4934            origin +
4935            '/api/detector/service/' +
4936            encodeURIComponent(sid) +
4937            '/community-hourly?days=' +
4938            encodeURIComponent(String(days)) +
4939            '&hours=' +
4940            encodeURIComponent(String(hours)) +
4941            '&require_top10=false';
4942        fetchDetectorApiJson(url, 20000)
4943            .then(function (pack) {
4944                resolved = true;
4945                clearTimeout(safetyTid);
4946                if (reqGen !== detComplaintsHourlyFetchGen) return;
4947                var data = pack.data || {};
4948                if (!pack.ok || !data.success) {
4949                    var emsg =
4950                        pack.status === 429
4951                            ? __('js_sidebar_hourly_err_429') || __('js_chart_429')
4952                            : __('js_sidebar_hourly_err');
4953                    if (pack.status !== 429 && data.detail) {
4954                        emsg = String(data.detail);
4955                    }
4956                    host.innerHTML = '<p class="det-hourly-err">' + esc(emsg) + '</p>';
4957                    return;
4958                }
4959                try {
4960                    var pts = (data.points || []).map(function (p) {
4961                        return typeof p === 'number' ? p : Number(p.n);
4962                    });
4963                    if (!pts.length) {
4964                        host.innerHTML =
4965                            '<p class="det-hourly-err" style="color:var(--muted)">' +
4966                            esc(__('js_sidebar_hourly_no_data') || 'Sem dados de relatos nesta janela.') +
4967                            '</p>';
4968                        return;
4969                    }
4970                    host.innerHTML =
4971                        '<div class="det-hourly-spark-wrap" title="' +
4972                        esc(__('js_sidebar_hourly_chart_hint')) +
4973                        '">' +
4974                        complaintsSparklineSvg(pts, 300) +
4975                        '</div>';
4976                    var sum = 0;
4977                    for (var i = 0; i < pts.length; i++) sum += pts[i];
4978                    if (foot) {
4979                        foot.textContent = __('js_sidebar_hourly_foot_tpl').replace(/\{n\}/g, String(sum));
4980                    }
4981                } catch (eRender) {
4982                    host.innerHTML =
4983                        '<p class="det-hourly-err">' + esc(__('js_sidebar_hourly_err')) + '</p>';
4984                }
4985            })
4986            .catch(function (err) {
4987                resolved = true;
4988                clearTimeout(safetyTid);
4989                if (reqGen !== detComplaintsHourlyFetchGen) return;
4990                var msg = __('js_sidebar_hourly_err');
4991                if (err && err.name === 'AbortError') {
4992                    msg =
4993                        __('js_sidebar_hourly_err_timeout') ||
4994                        'Tempo esgotado ao carregar gráfico. Tente de novo dentro de instantes.';
4995                }
4996                host.innerHTML = '<p class="det-hourly-err">' + esc(msg) + '</p>';
4997            });
4998    }
4999
5000    function wireComplaintsHourlyMicroModal() {
5001        var m = document.getElementById('detComplaintsHourlyModal');
5002        var closeBtn = document.getElementById('detComplaintsHourlyClose');
5003        if (!m || m.dataset.detHourlyWired === '1') return;
5004        m.dataset.detHourlyWired = '1';
5005        if (closeBtn) {
5006            closeBtn.addEventListener('click', function () {
5007                closeComplaintsHourlyMicroModal();
5008            });
5009        }
5010        var repBtn = document.getElementById('detComplaintsHourlyReportBtn');
5011        if (repBtn && repBtn.dataset.detHourlyReportWired !== '1') {
5012            repBtn.dataset.detHourlyReportWired = '1';
5013            repBtn.addEventListener('click', function (e) {
5014                e.preventDefault();
5015                var mod = document.getElementById('detComplaintsHourlyModal');
5016                var id = mod && mod.dataset ? mod.dataset.hourlySvcId : '';
5017                if (!id) return;
5018                closeComplaintsHourlyMicroModal();
5019                openCommunityReportModal(id);
5020            });
5021        }
5022        m.addEventListener('click', function (e) {
5023            if (e.target === m) closeComplaintsHourlyMicroModal();
5024        });
5025        if (!detComplaintsHourlyEscBound) {
5026            detComplaintsHourlyEscBound = true;
5027            document.addEventListener('keydown', function (e) {
5028                if (e.key !== 'Escape') return;
5029                var mod = document.getElementById('detComplaintsHourlyModal');
5030                if (mod && mod.classList.contains('open')) closeComplaintsHourlyMicroModal();
5031            });
5032        }
5033    }
5034
5035    /* ── Top 30 tendências (GET /api/detector/top-services) ── */
5036    var TOP30_CACHE_MS = 30000;
5037    var TOP30_DEBOUNCE_MS = 280;
5038    var TOP30_POLL_MS = 30000;
5039    var TOP30_LIMIT = 30;
5040    var top30FetchGen = 0;
5041    var top30State = {
5042        cacheAt: 0,
5043        cachePayload: null,
5044        debounceT: null,
5045        pollT: null,
5046        open: false,
5047    };
5048
5049    /** Tendência de atividade (30 min vs 30 min anteriores), alinhada ao motor do servidor. */
5050    function communityRowActivityTrend(row) {
5051        if (!row) return 'stable';
5052        var a =
5053            row.volume_last_30m != null && row.volume_last_30m !== '' && !isNaN(Number(row.volume_last_30m))
5054                ? Number(row.volume_last_30m)
5055                : NaN;
5056        var b =
5057            row.volume_prev_30m != null && row.volume_prev_30m !== '' && !isNaN(Number(row.volume_prev_30m))
5058                ? Number(row.volume_prev_30m)
5059                : NaN;
5060        if (isNaN(a) || isNaN(b)) return 'stable';
5061        var prev = Math.max(b, 1.0);
5062        var ratio = a / prev;
5063        if (ratio > 1.2) return 'up';
5064        if (ratio < 0.8) return 'down';
5065        return 'stable';
5066    }
5067
5068    function communityRowToTop30Item(row, position) {
5069        var st = 'ok';
5070        if (row && row.probe_down) st = 'unstable';
5071        return {
5072            position: position,
5073            name: (row && (row.name || row.service_id)) || '',
5074            service_id: row && row.service_id ? String(row.service_id) : '',
5075            norm_key: '',
5076            status: st,
5077            trend: communityRowActivityTrend(row),
5078            sources: Math.max(0, Number(row && row.vote_count_signals) || 0),
5079        };
5080    }
5081
5082    /**
5083     * Top 30 na UI: prioriza ranking da comunidade na janela JANELA (até 30 linhas);
5084     * se não houver dados, usa agregador externo (GET top-services) em cache.
5085     */
5086    function getTop30ItemsForUi(externalOverride) {
5087        var filtered = communityRowsWithVotesForReport();
5088        if (filtered.length) {
5089            var out = [];
5090            for (var i = 0; i < filtered.length && out.length < TOP30_LIMIT; i++) {
5091                out.push(communityRowToTop30Item(filtered[i], out.length + 1));
5092            }
5093            return out;
5094        }
5095        if (externalOverride && externalOverride.length) return externalOverride;
5096        var ext = top30State.cachePayload && top30State.cachePayload.items;
5097        return ext && ext.length ? ext : [];
5098    }
5099
5100    /**
5101     * Relatório PDF/modal: até 30 linhas — comunidade primeiro, depois top-services em cache,
5102     * depois catálogo (por votos agregados), sem duplicar serviço.
5103     */
5104    function mergeTop30ItemsForReport() {
5105        var seen = {};
5106        var out = [];
5107        function pushItem(raw) {
5108            if (!raw) return;
5109            var sid = raw.service_id != null ? String(raw.service_id).trim() : '';
5110            var nk = raw.norm_key != null ? String(raw.norm_key).trim() : '';
5111            var nm = raw.name != null ? String(raw.name).trim() : '';
5112            var key = sid || nk || (nm ? 'n:' + nm : '');
5113            if (!key) return;
5114            if (seen[key]) return;
5115            seen[key] = true;
5116            out.push(raw);
5117        }
5118        var primary = getTop30ItemsForUi();
5119        var i;
5120        for (i = 0; i < primary.length; i++) pushItem(primary[i]);
5121        var apiItems = (top30State.cachePayload && top30State.cachePayload.items) || [];
5122        for (i = 0; i < apiItems.length && out.length < TOP30_LIMIT; i++) pushItem(apiItems[i]);
5123        if (out.length < TOP30_LIMIT) {
5124            var fb = buildTop30FallbackFromCatalog();
5125            for (i = 0; i < fb.length && out.length < TOP30_LIMIT; i++) pushItem(fb[i]);
5126        }
5127        var merged = [];
5128        for (i = 0; i < out.length; i++) {
5129            var it = out[i];
5130            merged.push({
5131                position: i + 1,
5132                name: it.name,
5133                service_id: it.service_id != null ? String(it.service_id) : '',
5134                norm_key: it.norm_key != null ? String(it.norm_key) : '',
5135                status: it.status || 'ok',
5136                trend: it.trend || 'stable',
5137                sources: Math.max(0, Number(it.sources) || 0),
5138            });
5139        }
5140        return merged;
5141    }
5142
5143    function top30PrevRanksKey() {
5144        var d = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
5145        return 'det_top30_prev_v4_d' + d;
5146    }
5147
5148    function readTop30PrevRanks() {
5149        try {
5150            var raw = sessionStorage.getItem(top30PrevRanksKey());
5151            if (!raw) return {};
5152            var o = JSON.parse(raw);
5153            return o && typeof o === 'object' ? o : {};
5154        } catch (e) {
5155            return {};
5156        }
5157    }
5158
5159    function writeTop30PrevRanks(map) {
5160        try {
5161            sessionStorage.setItem(top30PrevRanksKey(), JSON.stringify(map));
5162        } catch (e) {}
5163    }
5164
5165    function updateTop30OpenLabels() {
5166        var b1 = document.getElementById('btnTop30Sidebar');
5167        var b2 = document.getElementById('btnTop30RankCol');
5168        var b3 = document.getElementById('btnTop30MobileHero');
5169        var t = __('js_top30_btn');
5170        if (b1) b1.textContent = t;
5171        if (b2) b2.textContent = t;
5172        if (b3) {
5173            b3.textContent = __('js_top30_mobile_cta');
5174            b3.setAttribute('aria-label', __('js_top30_mobile_cta_aria'));
5175        }
5176        var title = document.getElementById('detTop30Title');
5177        var lead = document.getElementById('detTop30Lead');
5178        if (title) title.textContent = __('js_top30_modal_title');
5179        if (lead) lead.textContent = __('js_top30_modal_lead');
5180        var s10 = document.getElementById('btnSidebarTop10Share');
5181        if (s10) {
5182            var s10lbl = s10.querySelector('.det-sd-action__lbl');
5183            if (s10lbl) s10lbl.textContent = __('js_rank_share_btn_short');
5184            s10.setAttribute('title', __('js_rank_share_btn_top10'));
5185            s10.setAttribute('aria-label', __('js_rank_share_aria_top10'));
5186        }
5187        var s10s = document.getElementById('btnSidebarTop10Social');
5188        if (s10s) {
5189            var s10sl = s10s.querySelector('.det-sd-action__lbl');
5190            if (s10sl) s10sl.textContent = __('js_t10_social_btn_short');
5191            else s10s.textContent = __('js_t10_social_btn_short');
5192            s10s.setAttribute('title', __('js_t10_social_btn'));
5193            s10s.setAttribute('aria-label', __('js_t10_social_aria'));
5194        }
5195        var t30s = document.getElementById('detTop30Share');
5196        if (t30s) {
5197            t30s.textContent = __('js_rank_share_btn_short');
5198            t30s.setAttribute('title', __('js_rank_share_btn'));
5199            t30s.setAttribute('aria-label', __('js_rank_share_aria_top30'));
5200        }
5201        var t20s = document.getElementById('detTop20Share');
5202        if (t20s) {
5203            t20s.textContent = __('js_rank_share_btn');
5204            t20s.setAttribute('aria-label', __('js_rank_share_aria_top20'));
5205        }
5206        var rh = document.getElementById('detReportShareTitle');
5207        if (rh) rh.textContent = __('js_report_modal_title');
5208        function setDetReportBtnLabel(id, text) {
5209            var b = document.getElementById(id);
5210            if (!b) return;
5211            var lbl = b.querySelector('.det-report-act-lbl');
5212            if (lbl) lbl.textContent = text;
5213            else b.textContent = text;
5214        }
5215        setDetReportBtnLabel('detReportBtnPrint', __('js_report_btn_print'));
5216        setDetReportBtnLabel('detReportBtnWhatsapp', __('js_report_btn_wa'));
5217        setDetReportBtnLabel('detReportBtnEmail', __('js_report_btn_email'));
5218        setDetReportBtnLabel('detReportBtnNative', __('js_report_btn_native'));
5219        var tsm = document.getElementById('detTop10SocialTitle');
5220        if (tsm) tsm.textContent = __('js_t10_social_modal_title');
5221        setDetReportBtnLabel('detTop10SocialCopyCap', __('js_t10_social_act_copy'));
5222        var waIcoBtn = document.getElementById('detTop10SocialWa');
5223        if (waIcoBtn) { waIcoBtn.title = __('js_t10_social_act_wa'); waIcoBtn.setAttribute('aria-label', __('js_t10_social_act_wa')); }
5224        var igIcoBtn = document.getElementById('detTop10SocialIg');
5225        if (igIcoBtn) { igIcoBtn.title = 'Instagram'; igIcoBtn.setAttribute('aria-label', 'Instagram'); }
5226        var fbIcoBtn = document.getElementById('detTop10SocialFb');
5227        if (fbIcoBtn) { fbIcoBtn.title = 'Facebook'; fbIcoBtn.setAttribute('aria-label', 'Facebook'); }
5228        setDetReportBtnLabel('detTop10SocialSave', __('js_t10_social_act_save'));
5229        setDetReportBtnLabel('detTop10SocialNative', __('js_t10_social_act_native'));
5230        var t10Ld = document.getElementById('detTop10SocialLoading');
5231        if (t10Ld && !t10Ld.hidden) t10Ld.textContent = __('js_t10_social_preparing');
5232    }
5233
5234    function top30StatusLabel(st) {
5235        if (st === 'down') return __('js_top30_status_down');
5236        if (st === 'unstable') return __('js_top30_status_unstable');
5237        return __('js_top30_status_ok');
5238    }
5239
5240    function top30StatusEmoji(st) {
5241        if (st === 'down') return '🔴';
5242        if (st === 'unstable') return '🟡';
5243        return '🟢';
5244    }
5245
5246    function top30TrendSub(t) {
5247        if (t === 'up') return __('js_top30_trend_up');
5248        if (t === 'down') return __('js_top30_trend_down');
5249        return __('js_top30_trend_stable');
5250    }
5251
5252    function buildTop30FallbackFromCatalog() {
5253        if (!state.catalog || !state.catalog.length) return [];
5254        var arr = state.catalog.slice();
5255        arr.sort(function (a, b) {
5256            return (Number(b.community_vote_count) || 0) - (Number(a.community_vote_count) || 0);
5257        });
5258        var out = [];
5259        for (var i = 0; i < arr.length && out.length < TOP30_LIMIT; i++) {
5260            var s = arr[i];
5261            if (!s || s.id == null || s.id === '') continue;
5262            out.push({
5263                position: out.length + 1,
5264                name: s.display_name || s.name || String(s.id),
5265                service_id: String(s.id),
5266                norm_key: String(s.norm_key || ''),
5267                status: 'ok',
5268                trend: 'stable',
5269                sources: 0,
5270            });
5271        }
5272        return out;
5273    }
5274
5275    function buildTop30SkeletonInto(skEl, nRows) {
5276        if (!skEl) return;
5277        var n = nRows != null ? nRows : 8;
5278        skEl.innerHTML = '';
5279        skEl.hidden = false;
5280        for (var i = 0; i < n; i++) {
5281            var d = document.createElement('div');
5282            d.className = 'det-top30-sk-row';
5283            skEl.appendChild(d);
5284        }
5285    }
5286
5287    function buildTop30SkeletonRows() {
5288        buildTop30SkeletonInto(document.getElementById('detTop30Skeleton'), 8);
5289    }
5290
5291    function renderTop30ListInto(list, empty, err, sk, items, skipWritePrev) {
5292        if (sk) sk.hidden = true;
5293        if (err) {
5294            err.hidden = true;
5295            err.textContent = '';
5296        }
5297        if (!list || !empty) return;
5298
5299        var fromApi = items && items.length;
5300        var effective = fromApi ? items : buildTop30FallbackFromCatalog();
5301        var usedFallback = !fromApi && effective && effective.length;
5302
5303        if (!effective || !effective.length) {
5304            list.hidden = true;
5305            list.innerHTML = '';
5306            empty.hidden = false;
5307            empty.textContent = __('js_top30_empty');
5308            return;
5309        }
5310        empty.hidden = true;
5311        list.hidden = false;
5312
5313        var prev = readTop30PrevRanks();
5314        var nextMap = {};
5315        var frag = document.createDocumentFragment();
5316        for (var i = 0; i < effective.length; i++) {
5317            var it = effective[i];
5318            var pos = it.position != null ? Number(it.position) : i + 1;
5319            var sid = (it.service_id && String(it.service_id).trim()) || '';
5320            var mapKey = sid || String(it.norm_key || it.name || 'k' + i);
5321            nextMap[mapKey] = pos;
5322            var movedUp = prev[mapKey] != null && pos < prev[mapKey];
5323            var movedDown = prev[mapKey] != null && pos > prev[mapKey];
5324            var rankExtra = pos === 1 ? ' det-top30-item--rank1' : pos === 2 ? ' det-top30-item--rank2' : pos === 3 ? ' det-top30-item--rank3' : '';
5325            var upExtra = movedUp ? ' det-top30-item--moved-up' : '';
5326            var downExtra = movedDown ? ' det-top30-item--moved-down' : '';
5327            var row = document.createElement(sid ? 'button' : 'div');
5328            row.className = 'det-top30-item' + rankExtra + upExtra + downExtra;
5329            if (sid) {
5330                row.setAttribute('type', 'button');
5331                row.setAttribute('data-top30-sid', sid);
5332                row.setAttribute(
5333                    'aria-label',
5334                    __('js_open_service_aria') + ' ' + (it.name || sid),
5335                );
5336            }
5337            var posWrap = document.createElement('span');
5338            posWrap.className = 'det-top30-pos-wrap';
5339            posWrap.innerHTML =
5340                '<span class="det-top30-pos">' +
5341                String(pos) +
5342                '</span>' +
5343                rankingTrendMarkHtml(mapKey, pos, prev);
5344            var main = document.createElement('div');
5345            main.className = 'det-top30-main';
5346            var nm = document.createElement('div');
5347            nm.className = 'det-top30-name';
5348            nm.textContent = it.name || '';
5349            var meta = document.createElement('div');
5350            meta.className = 'det-top30-meta';
5351            var st = it.status || 'ok';
5352            var tr = it.trend || 'stable';
5353            var stSpan = document.createElement('span');
5354            stSpan.className = 'det-top30-status';
5355            stSpan.textContent = top30StatusEmoji(st) + ' ' + top30StatusLabel(st);
5356            var trSpan = document.createElement('span');
5357            trSpan.className = 'det-top30-trend';
5358            var trSub = top30TrendSub(tr);
5359            trSpan.title = trSub;
5360            if (tr === 'up' || tr === 'down') {
5361                trSpan.textContent = (tr === 'up' ? '↑ ' : '↓ ') + trSub;
5362            } else {
5363                trSpan.textContent = '— ' + trSub;
5364            }
5365            var srcSpan = document.createElement('span');
5366            srcSpan.textContent = __('js_top30_sources_tpl').replace(/\{n\}/g, String(Number(it.sources) || 0));
5367            meta.appendChild(stSpan);
5368            meta.appendChild(trSpan);
5369            meta.appendChild(srcSpan);
5370            main.appendChild(nm);
5371            main.appendChild(meta);
5372            row.appendChild(posWrap);
5373            row.appendChild(main);
5374            frag.appendChild(row);
5375        }
5376        list.innerHTML = '';
5377        list.appendChild(frag);
5378        if (!skipWritePrev) {
5379            writeTop30PrevRanks(nextMap);
5380        }
5381
5382        list.querySelectorAll('button.det-top30-item[data-top30-sid]').forEach(function (el) {
5383            el.addEventListener('click', function () {
5384                var id = el.getAttribute('data-top30-sid');
5385                if (!id) return;
5386                closeTop30Modal();
5387                openModal(id);
5388            });
5389        });
5390    }
5391
5392    function renderTop30List(itemsFromFetch) {
5393        var eff = getTop30ItemsForUi(itemsFromFetch);
5394        renderTop30ListInto(
5395            document.getElementById('detTop30List'),
5396            document.getElementById('detTop30Empty'),
5397            document.getElementById('detTop30Error'),
5398            document.getElementById('detTop30Skeleton'),
5399            eff,
5400            false,
5401        );
5402    }
5403
5404    function clearTop30ErrorState() {
5405        var err = document.getElementById('detTop30Error');
5406        if (err) {
5407            err.hidden = true;
5408            err.textContent = '';
5409        }
5410    }
5411
5412    function showTop30ErrorState(msg) {
5413        var err = document.getElementById('detTop30Error');
5414        var t = msg || __('js_top30_err');
5415        if (err) {
5416            err.hidden = false;
5417            err.textContent = t;
5418        }
5419    }
5420
5421    function fetchTop30Services(force) {
5422        var origin = window.location.origin || '';
5423        var now = Date.now();
5424        if (!force && top30State.cachePayload && now - top30State.cacheAt < TOP30_CACHE_MS) {
5425            return Promise.resolve(top30State.cachePayload);
5426        }
5427        var gen = ++top30FetchGen;
5428        var url =
5429            origin +
5430            '/api/detector/top-services?limit=' +
5431            encodeURIComponent(String(TOP30_LIMIT));
5432        return fetch(url, { credentials: 'omit' })
5433            .then(function (r) {
5434                return r.text().then(function (text) {
5435                    var data = {};
5436                    try {
5437                        data = text ? JSON.parse(text) : {};
5438                    } catch (eJson) {
5439                        data = {};
5440                    }
5441                    return { ok: r.ok, data: data };
5442                });
5443            })
5444            .then(function (pack) {
5445                if (gen !== top30FetchGen) {
5446                    return Promise.reject({ code: 'stale' });
5447                }
5448                var data = pack.data || {};
5449                if (!pack.ok || !data.success) {
5450                    throw new Error('fail');
5451                }
5452                top30State.cacheAt = Date.now();
5453                top30State.cachePayload = data;
5454                return data;
5455            });
5456    }
5457
5458    function openTop30ModalLoad() {
5459        var sk = document.getElementById('detTop30Skeleton');
5460        var empty = document.getElementById('detTop30Empty');
5461        clearTop30ErrorState();
5462        if (empty) empty.hidden = true;
5463
5464        var hasComm = communityRowsWithVotesForReport().length > 0;
5465        if (hasComm) {
5466            if (sk) {
5467                sk.hidden = true;
5468                sk.innerHTML = '';
5469            }
5470            renderTop30List();
5471            return;
5472        }
5473
5474        var now = Date.now();
5475        if (top30State.cachePayload && now - top30State.cacheAt < TOP30_CACHE_MS) {
5476            if (sk) sk.hidden = true;
5477            renderTop30List(top30State.cachePayload.items || []);
5478        } else {
5479            buildTop30SkeletonRows();
5480        }
5481
5482        fetchTop30Services(false)
5483            .then(function (data) {
5484                if (!top30State.open) return;
5485                var items =
5486                    (data && data.items) ||
5487                    (top30State.cachePayload && top30State.cachePayload.items) ||
5488                    [];
5489                renderTop30List(items);
5490            })
5491            .catch(function (e) {
5492                if (e && e.code === 'stale') return;
5493                if (!top30State.open) return;
5494                if (sk) sk.hidden = true;
5495                var list = document.getElementById('detTop30List');
5496                var hasList = list && list.children && list.children.length > 0;
5497                if (top30State.cachePayload && top30State.cachePayload.items && top30State.cachePayload.items.length) {
5498                    renderTop30List(top30State.cachePayload.items);
5499                    return;
5500                }
5501                if (hasList) return;
5502                showTop30ErrorState();
5503            });
5504    }
5505
5506    function openTop30Modal() {
5507        var modal = document.getElementById('detTop30Modal');
5508        if (!modal) return;
5509        top30State.open = true;
5510        modal.classList.add('open');
5511        modal.setAttribute('aria-hidden', 'false');
5512        requestAnimationFrame(function () {
5513            modal.classList.add('det-top30-modal--anim');
5514        });
5515        try {
5516            document.body.style.overflow = 'hidden';
5517        } catch (e) {}
5518        updateTop30OpenLabels();
5519        openTop30ModalLoad();
5520
5521        if (top30State.pollT) clearInterval(top30State.pollT);
5522        top30State.pollT = setInterval(function () {
5523            if (!top30State.open) return;
5524            if (communityRowsWithVotesForReport().length > 0) {
5525                renderTop30List();
5526                return;
5527            }
5528            fetchTop30Services(true)
5529                .then(function (data) {
5530                    if (!data || !top30State.open) return;
5531                    renderTop30List(data.items || []);
5532                })
5533                .catch(function () {});
5534        }, TOP30_POLL_MS);
5535    }
5536
5537    function closeTop30Modal() {
5538        var modal = document.getElementById('detTop30Modal');
5539        if (!modal) return;
5540        top30State.open = false;
5541        modal.classList.remove('open', 'det-top30-modal--anim');
5542        modal.setAttribute('aria-hidden', 'true');
5543        try {
5544            document.body.style.overflow = '';
5545        } catch (e) {}
5546        if (top30State.pollT) {
5547            clearInterval(top30State.pollT);
5548            top30State.pollT = null;
5549        }
5550        var sk = document.getElementById('detTop30Skeleton');
5551        if (sk) {
5552            sk.hidden = true;
5553            sk.innerHTML = '';
5554        }
5555    }
5556
5557    function stripDetectorHtmlToText(html) {
5558        var d = document.createElement('div');
5559        d.innerHTML = String(html || '');
5560        return String(d.textContent || d.innerText || '')
5561            .replace(/\s+/g, ' ')
5562            .trim();
5563    }
5564
5565    function communityRowsWithVotesForReport() {
5566        var rows = state.communityRankingRows;
5567        if (!Array.isArray(rows)) return [];
5568        return rows.filter(function (row) {
5569            return row && (Number(row.vote_count) || 0) > 0;
5570        });
5571    }
5572
5573    function catalogCategoryMixForReport() {
5574        var cat = state.catalog || [];
5575        var map = {};
5576        for (var i = 0; i < cat.length; i++) {
5577            var c = (cat[i] && cat[i].category) || 'core';
5578            map[c] = (map[c] || 0) + 1;
5579        }
5580        return Object.keys(map)
5581            .map(function (k) {
5582                return { id: k, n: map[k], label: catLabel(k) };
5583            })
5584            .sort(function (a, b) {
5585                return b.n - a.n;
5586            });
5587    }
5588
5589    function catalogIpStackMixForReport() {
5590        var cat = state.catalog || [];
5591        var map = { v4: 0, dual: 0, v6: 0, unknown: 0 };
5592        for (var i = 0; i < cat.length; i++) {
5593            var st = (cat[i] && cat[i].ip_stack) || 'unknown';
5594            if (map[st] == null) st = 'unknown';
5595            map[st]++;
5596        }
5597        return map;
5598    }
5599
5600    /**
5601     * Preferir URLs same-origin (cache HTTP + CORS previsível) ao carregar ícones para canvas / partilha.
5602     */
5603    function prioritizeSameOriginDetectorUrls(urls) {
5604        if (!urls || !urls.length) return urls || [];
5605        var pageOrigin = '';
5606        try {
5607            pageOrigin = new URL(window.location.href || 'http://localhost').origin;
5608        } catch (eO) {}
5609        if (!pageOrigin) return urls.slice();
5610        var same = [];
5611        var other = [];
5612        var i;
5613        for (i = 0; i < urls.length; i++) {
5614            var u = urls[i];
5615            if (!u) continue;
5616            try {
5617                var o = new URL(u, pageOrigin).origin;
5618                if (o === pageOrigin) same.push(u);
5619                else other.push(u);
5620            } catch (eU) {
5621                other.push(u);
5622            }
5623        }
5624        return same.concat(other);
5625    }
5626
5627    /** Célula «Serviço» no relatório: logo do catálogo + nome. */
5628    function reportRankingServiceCellHtml(displayName, serviceId, rankingRow) {
5629        var sid = serviceId != null ? String(serviceId).trim() : '';
5630        var svc = sid ? findSvc(sid) : null;
5631        if (
5632            !svc &&
5633            rankingRow &&
5634            sid &&
5635            String(rankingRow.service_id != null ? rankingRow.service_id : '').trim() === sid
5636        ) {
5637            var ldR = rankingRow.logo_domain != null ? String(rankingRow.logo_domain).trim() : '';
5638            var liR = rankingRow.logo_icons;
5639            svc = {
5640                id: sid,
5641                name: rankingRow.name || sid,
5642                logo_domain: ldR,
5643                logo_url:
5644                    rankingRow.logo_url != null && rankingRow.logo_url !== ''
5645                        ? rankingRow.logo_url
5646                        : null,
5647                logo_icons: Array.isArray(liR) && liR.length ? liR : null,
5648            };
5649        }
5650        var stub = svc || { logo_domain: '', logo_url: null, logo_icons: null };
5651        var logoHtml = detLogoImgHtml(stub, 'det-rpt-svc-logo', ' width="32" height="32"');
5652        return (
5653            '<div class="det-rpt-svc-cell">' +
5654            logoHtml +
5655            '<span class="det-rpt-svc-name">' +
5656            esc(displayName != null ? String(displayName) : '') +
5657            '</span></div>'
5658        );
5659    }
5660
5661    function buildDetectorRankingReportDocumentHtml(scope) {
5662        var rows = communityRowsWithVotesForReport();
5663        var lim = scope === 'top10' ? 10 : scope === 'top20' ? 20 : 30;
5664        var slice = rows.slice(0, lim);
5665        var top30items = mergeTop30ItemsForReport();
5666        var title =
5667            scope === 'top10'
5668                ? __('js_report_title_top10')
5669                : scope === 'top20'
5670                  ? __('js_report_title_top20')
5671                  : __('js_report_title_top30');
5672        var sub = __('js_report_subtitle_enterprise');
5673        var when = new Date().toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
5674        var url = typeof window !== 'undefined' && window.location ? String(window.location.href) : '';
5675        var reportLogoSrc = '/static/assets/logo-catellix%202.png';
5676        try {
5677            if (typeof window !== 'undefined' && window.location && window.location.origin) {
5678                reportLogoSrc = new URL(reportLogoSrc, window.location.origin).href;
5679            }
5680        } catch (eLogo) {}
5681        var catMix = catalogCategoryMixForReport();
5682        var ipMix = catalogIpStackMixForReport();
5683        var nCat = (state.catalog || []).length;
5684        var tableRows = '';
5685        var i;
5686        var rptTop30 = {
5687            h: '#',
5688            svc: __('js_report_th_service'),
5689            st: __('js_report_th_status'),
5690            tr: __('js_report_th_trend'),
5691            src: __('js_report_th_sources'),
5692        };
5693        var rptComm = {
5694            h: '#',
5695            svc: __('js_report_th_service'),
5696            sig: __('js_report_th_signal'),
5697            ctx: __('js_report_th_context'),
5698        };
5699        if (scope === 'top30') {
5700            for (i = 0; i < top30items.length; i++) {
5701                var it = top30items[i];
5702                var pos = it.position != null ? Number(it.position) : i + 1;
5703                tableRows +=
5704                    '<tr><td data-det-rpt-lbl="' +
5705                    esc(rptTop30.h) +
5706                    '">' +
5707                    esc(String(pos)) +
5708                    '</td><td data-det-rpt-lbl="' +
5709                    esc(rptTop30.svc) +
5710                    '">' +
5711                    reportRankingServiceCellHtml(it.name || '', it.service_id) +
5712                    '</td><td data-det-rpt-lbl="' +
5713                    esc(rptTop30.st) +
5714                    '">' +
5715                    esc(top30StatusLabel(it.status || 'ok')) +
5716                    '</td><td data-det-rpt-lbl="' +
5717                    esc(rptTop30.tr) +
5718                    '">' +
5719                    esc(top30TrendSub(it.trend || 'stable')) +
5720                    '</td><td data-det-rpt-lbl="' +
5721                    esc(rptTop30.src) +
5722                    '">' +
5723                    esc(String(Number(it.sources) || 0)) +
5724                    '</td></tr>';
5725            }
5726        } else {
5727            for (i = 0; i < slice.length; i++) {
5728                var rw = slice[i];
5729                var pill = complaintsRankingPillDisplay(rw);
5730                tableRows +=
5731                    '<tr><td data-det-rpt-lbl="' +
5732                    esc(rptComm.h) +
5733                    '">' +
5734                    esc(String(i + 1)) +
5735                    '</td><td data-det-rpt-lbl="' +
5736                    esc(rptComm.svc) +
5737                    '">' +
5738                    reportRankingServiceCellHtml(rw.name || rw.service_id || '', rw.service_id, rw) +
5739                    '</td><td data-det-rpt-lbl="' +
5740                    esc(rptComm.sig) +
5741                    '">' +
5742                    esc(String(pill.value)) +
5743                    '</td><td data-det-rpt-lbl="' +
5744                    esc(rptComm.ctx) +
5745                    '">' +
5746                    esc(stripDetectorHtmlToText(communityRankingMetaLineHtml(rw))) +
5747                    '</td></tr>';
5748            }
5749        }
5750        var catBars = '';
5751        for (i = 0; i < catMix.length && i < 12; i++) {
5752            var pct = nCat > 0 ? Math.round((catMix[i].n / nCat) * 1000) / 10 : 0;
5753            catBars +=
5754                '<div class="det-rpt-bar-row"><span class="det-rpt-bar-lbl">' +
5755                esc(catMix[i].label) +
5756                '</span><div class="det-rpt-bar-track"><span class="det-rpt-bar-fill" style="width:' +
5757                esc(String(Math.min(100, pct))) +
5758                '%"></span></div><span class="det-rpt-bar-pct">' +
5759                esc(String(pct)) +
5760                '%</span></div>';
5761        }
5762        var ipTotal = ipMix.v4 + ipMix.dual + ipMix.v6 + ipMix.unknown;
5763        function ipPct(x) {
5764            return ipTotal > 0 ? Math.round((x / ipTotal) * 1000) / 10 : 0;
5765        }
5766        var thead =
5767            scope === 'top30'
5768                ? '<thead><tr><th>
5768#</th><th>' +
5769                  esc(__('js_report_th_service')) +
5770                  '</th><th>' +
5771                  esc(__('js_report_th_status')) +
5772                  '</th><th>' +
5773                  esc(__('js_report_th_trend')) +
5774                  '</th><th>' +
5775                  esc(__('js_report_th_sources')) +
5776                  '</th></tr></thead>'
5777                : '<thead><tr><th>#</th><th>' +
5778                  esc(__('js_report_th_service')) +
5779                  '</th><th>' +
5780                  esc(__('js_report_th_signal')) +
5781                  '</th><th>' +
5782                  esc(__('js_report_th_context')) +
5783                  '</th></tr></thead>';
5784        return (
5785            '<div class="det-rpt-doc">' +
5786            '<header class="det-rpt-doc-head"><div class="det-rpt-brand"><img class="det-rpt-logo" src="' +
5787            esc(reportLogoSrc) +
5788            '" alt="' +
5789            esc('Catellix') +
5790            '" width="180" height="52" loading="eager" decoding="async" /></div><h1>' +
5791            esc(title) +
5792            '</h1><p class="det-rpt-lead">' +
5793            esc(sub) +
5794            '</p><p class="det-rpt-meta">' +
5795            esc(__('js_report_generated')) +
5796            ' ' +
5797            esc(when) +
5798            '</p></header>' +
5799            '<section class="det-rpt-sec"><h2>' +
5800            esc(__('js_report_sec_ranking')) +
5801            '</h2><table class="det-rpt-table">' +
5802            thead +
5803            '<tbody>' +
5804            tableRows +
5805            '</tbody></table></section>' +
5806            '<section class="det-rpt-sec"><h2>' +
5807            esc(__('js_report_sec_mix_cat')) +
5808            '</h2><p class="det-rpt-note">' +
5809            esc(__('js_report_mix_cat_note')) +
5810            '</p>' +
5811            catBars +
5812            '</section>' +
5813            '<section class="det-rpt-sec"><h2>' +
5814            esc(__('js_report_sec_mix_ip')) +
5815            '</h2><p class="det-rpt-note">' +
5816            esc(__('js_report_mix_ip_note')) +
5817            '</p><ul class="det-rpt-ip-list"><li>IPv4: ' +
5818            esc(String(ipPct(ipMix.v4))) +
5819            '%</li><li>IPv4+IPv6: ' +
5820            esc(String(ipPct(ipMix.dual))) +
5821            '%</li><li>IPv6: ' +
5822            esc(String(ipPct(ipMix.v6))) +
5823            '%</li><li>' +
5824            esc(__('js_report_ip_other')) +
5825            ': ' +
5826            esc(String(ipPct(ipMix.unknown))) +
5827            '%</li></ul></section>' +
5828            '<footer class="det-rpt-foot"><p>' +
5829            esc(__('js_report_footer')) +
5830            '</p><p class="det-rpt-foot-site"><a href="https://catellix.com" class="det-rpt-foot-link">catellix.com</a> · ' +
5831            esc(__('js_report_site_tagline')) +
5832            '</p><p class="det-rpt-url">' +
5833            esc(url) +
5834            '</p></footer></div>'
5835        );
5836    }
5837
5838    function openDetectorRankingReportModal(scope) {
5839        detReportModalScope =
5840            scope === 'top10' || scope === 'top20' || scope === 'top30' ? scope : 'top30';
5841        var modal = document.getElementById('detReportShareModal');
5842        var inner = document.getElementById('detReportPreviewInner');
5843        if (!modal || !inner) return;
5844        inner.innerHTML = buildDetectorRankingReportDocumentHtml(detReportModalScope);
5845        bindDetLogoFallbacks(inner);
5846        modal.classList.add('open');
5847        modal.setAttribute('aria-hidden', 'false');
5848        try {
5849            document.body.style.overflow = 'hidden';
5850        } catch (e) {}
5851    }
5852
5853    function closeDetectorRankingReportModal() {
5854        var modal = document.getElementById('detReportShareModal');
5855        if (!modal) return;
5856        modal.classList.remove('open');
5857        modal.setAttribute('aria-hidden', 'true');
5858        try {
5859            document.body.style.overflow = '';
5860        } catch (e2) {}
5861    }
5862
5863    /** Mesmo fluxo do botão «Relatório» do modal Top 30: atualiza cache e abre o relatório. */
5864    function openTop30RankingReportAfterPrefetch() {
5865        fetchTop30Services(false)
5866            .then(function () {
5867                openDetectorRankingReportModal('top30');
5868            })
5869            .catch(function () {
5870                openDetectorRankingReportModal('top30');
5871            });
5872    }
5873
5874    function detectorReportShareTitle() {
5875        var s = detReportModalScope || 'top30';
5876        if (s === 'top10') return __('js_report_title_top10');
5877        if (s === 'top20') return __('js_report_title_top20');
5878        return __('js_report_title_top30');
5879    }
5880
5881    /**
5882     * Texto para WhatsApp, e-mail e partilha nativa: lista conforme o relatório aberto (10/20/30),
5883     * site oficial e link da página (uma vez).
5884     */
5885    function detectorReportShareBodyText() {
5886        var s = detReportModalScope || 'top30';
5887        var lim = s === 'top10' ? 10 : s === 'top20' ? 20 : 30;
5888        var lines = [];
5889        var i;
5890        if (s === 'top30') {
5891            var items = mergeTop30ItemsForReport();
5892            for (i = 0; i < items.length && i < lim; i++) {
5893                lines.push(String(i + 1) + '. ' + String(items[i].name || items[i].service_id || ''));
5894            }
5895        } else {
5896            var rows = communityRowsWithVotesForReport();
5897            var slice = rows.slice(0, lim);
5898            for (i = 0; i < slice.length; i++) {
5899                lines.push(String(i + 1) + '. ' + String(slice[i].name || slice[i].service_id || ''));
5900            }
5901        }
5902        var head = detectorReportShareTitle() + ' · ' + new Date().toISOString().slice(0, 10);
5903        var siteLine = __('js_report_share_site_line');
5904        var page =
5905            typeof window !== 'undefined' && window.location && window.location.href
5906                ? window.location.href
5907                : '';
5908        var body =
5909            head +
5910            '\n\n' +
5911            (lines.length ? lines.join('\n') : __('js_report_share_no_rank_lines')) +
5912            '\n\n' +
5913            siteLine +
5914            '\n' +
5915            page;
5916        return body;
5917    }
5918
5919    function buildTop10SocialCaption(rows) {
5920        var siteLine = __('js_report_share_site_line');
5921        var page =
5922            typeof window !== 'undefined' && window.location && window.location.href
5923                ? window.location.href
5924                : '';
5925        var intro =
5926            detTop10SocialSession && detTop10SocialSession.kind === 'games'
5927                ? __('js_t10_social_caption_intro_games')
5928                : __('js_t10_social_caption_intro');
5929        var lines = [];
5930        for (var i = 0; i < rows.length; i++) {
5931            var base = String(i + 1) + '. ' + String(rows[i].name || rows[i].service_id || '');
5932            if (detTop10SocialSession && detTop10SocialSession.kind === 'games') {
5933                var svcG = communityRankingRowToSvc(rows[i]);
5934                var msG = svcG ? gameRankingLatencyMs(svcG) : null;
5935                if (msG != null && !isNaN(msG)) base += ' — ' + Math.round(msG) + ' ms';
5936            }
5937            lines.push(base);
5938        }
5939        return intro + '\n\n' + lines.join('\n') + '\n\n' + siteLine + '\n' + page;
5940    }
5941
5942    function catellixDetectorBrandLogoSrc() {
5943        var src = '/static/assets/logo-catellix%202.png';
5944        try {
5945            if (typeof window !== 'undefined' && window.location && window.location.origin) {
5946                src = new URL(src, window.location.origin).href;
5947            }
5948        } catch (eSrc) {}
5949        return src;
5950    }
5951
5952    function loadTop10SocialBrandLogo(callback) {
5953        if (detTop10SocialBrandLogoImg && detTop10SocialBrandLogoImg.complete && detTop10SocialBrandLogoImg.naturalHeight > 0) {
5954            callback(detTop10SocialBrandLogoImg);
5955            return;
5956        }
5957        if (!detTop10SocialBrandLogoPromise) {
5958            detTop10SocialBrandLogoPromise = new Promise(function (resolve) {
5959                var settled = false;
5960                var tid = setTimeout(function () {
5961                    if (settled) return;
5962                    settled = true;
5963                    resolve(null);
5964                }, DET_T10_SOCIAL_BRAND_IMG_MS);
5965                var img = new Image();
5966                img.onload = function () {
5967                    if (settled) return;
5968                    settled = true;
5969                    try {
5970                        clearTimeout(tid);
5971                    } catch (eT) {}
5972                    resolve(img.naturalWidth > 0 ? img : null);
5973                };
5974                img.onerror = function () {
5975                    if (settled) return;
5976                    settled = true;
5977                    try {
5978                        clearTimeout(tid);
5979                    } catch (eE) {}
5980                    resolve(null);
5981                };
5982                img.src = catellixDetectorBrandLogoSrc();
5983            });
5984        }
5985        detTop10SocialBrandLogoPromise.then(function (img) {
5986            detTop10SocialBrandLogoImg = img;
5987            callback(img);
5988        });
5989    }
5990
5991    function detSocialCardRoundClip(ctx, width, height, radius) {
5992        var rr = Math.min(radius, width / 2, height / 2);
5993        ctx.beginPath();
5994        if (typeof ctx.roundRect === 'function') {
5995            ctx.roundRect(0, 0, width, height, rr);
5996        } else {
5997            var x = 0;
5998            var y = 0;
5999            var w0 = width;
6000            var h0 = height;
6001            ctx.moveTo(x + rr, y);
6002            ctx.lineTo(x + w0 - rr, y);
6003            ctx.quadraticCurveTo(x + w0, y, x + w0, y + rr);
6004            ctx.lineTo(x + w0, y + h0 - rr);
6005            ctx.quadraticCurveTo(x + w0, y + h0, x + w0 - rr, y + h0);
6006            ctx.lineTo(x + rr, y + h0);
6007            ctx.quadraticCurveTo(x, y + h0, x, y + h0 - rr);
6008            ctx.lineTo(x, y + rr);
6009            ctx.quadraticCurveTo(x, y, x + rr, y);
6010            ctx.closePath();
6011        }
6012    }
6013
6014    function detSocialCardRoundRectPath(ctx, x, y, rw, rh, rad) {
6015        var r0 = Math.min(rad, rw / 2, rh / 2);
6016        ctx.beginPath();
6017        if (typeof ctx.roundRect === 'function') {
6018            ctx.roundRect(x, y, rw, rh, r0);
6019        } else {
6020            ctx.rect(x, y, rw, rh);
6021        }
6022    }
6023
6024    function detAbsDetectorAssetUrl(u) {
6025        if (u == null || u === '') return '';
6026        var s = String(u).trim();
6027        if (!s) return '';
6028        if (/^https?:\/\//i.test(s) || /^data:/i.test(s)) return s;
6029        try {
6030            if (typeof window !== 'undefined' && window.location && window.location.origin) {
6031                return new URL(s, window.location.origin).href;
6032            }
6033        } catch (eAbs) {}
6034        return s;
6035    }
6036
6037    /** URLs para tentar no cartão social (fetch+CORS → blob, sem «taint» no canvas). */
6038    function detLogoUrlCandidatesForTop10SocialCard(svc) {
6039        if (!svc) return [];
6040        var dom = (svc.logo_domain || '').trim();
6041        var domLc = dom.toLowerCase();
6042        var seen = {};
6043        var out = [];
6044        function addUrl(u) {
6045            var abs = detAbsDetectorAssetUrl(u);
6046            if (!abs) return;
6047            var key = abs.split('#')[0];
6048            if (seen[key]) return;
6049            seen[key] = 1;
6050            out.push(abs);
6051        }
6052        var man = state.logoManifestByDomain;
6053        if (man && domLc) {
6054            if (man[domLc]) addUrl(man[domLc]);
6055            else if (/\.com\.(br|ar|mx)$/i.test(domLc)) {
6056                var manPar = domLc.replace(/\.com\.(br|ar|mx)$/i, '.com');
6057                if (man[manPar]) addUrl(man[manPar]);
6058            }
6059        }
6060        var prim = (svc.logo_url || '').trim();
6061        if (prim) addUrl(prim);
6062        var chain = Array.isArray(svc.logo_icons) && svc.logo_icons.length ? svc.logo_icons : null;
6063        var c;
6064        if (chain) {
6065            for (c = 0; c < chain.length; c++) {
6066                addUrl(chain[c]);
6067            }
6068        }
6069        if (dom) {
6070            addUrl('/static/detector-logos/' + dom.replace(/\./g, '_') + '.png');
6071            addUrl(faviconForDomain(dom));
6072            addUrl('https://icons.duckduckgo.com/ip3/' + encodeURIComponent(dom) + '.ico');
6073            addUrl('https://www.google.com/s2/favicons?domain=' + encodeURIComponent(dom) + '&sz=128');
6074        }
6075        return prioritizeSameOriginDetectorUrls(out);
6076    }
6077
6078    /** `drawImage` + `toDataURL` só funcionam com mesma origem, data: ou blob: (evita canvas «tainted»). */
6079    function detImgSafeForCanvasExport(img) {
6080        if (!img || !img.complete || !(img.naturalWidth > 0)) return false;
6081        var src = (img.currentSrc || img.getAttribute('src') || '').trim();
6082        if (!src) return false;
6083        if (/^data:/i.test(src) || /^blob:/i.test(src)) return true;
6084        try {
6085            var page = window.location.href || 'http://localhost/';
6086            return new URL(src, page).origin === new URL(page).origin;
6087        } catch (eSafe) {
6088            return false;
6089        }
6090    }
6091
6092    function loadTop10SocialRowLogoImageOne(row) {
6093        var svc = communityRankingRowToSvc(row);
6094        var domImgSrcs = [];
6095        var fetchOne = function () {
6096            if (typeof fetch !== 'function') return Promise.resolve(null);
6097            var metaUrls = detLogoUrlCandidatesForTop10SocialCard(svc);
6098            var merged = domImgSrcs.concat(metaUrls);
6099            var seen = {};
6100            var unique = [];
6101            for (var k = 0; k < merged.length; k++) {
6102                var u = merged[k];
6103                if (!u) continue;
6104                var nk = u.split('#')[0];
6105                if (seen[nk]) continue;
6106                seen[nk] = 1;
6107                unique.push(u);
6108            }
6109            return loadFirstTop10SocialLogoFromUrls(prioritizeSameOriginDetectorUrls(unique));
6110        };
6111        var sid = row && row.service_id;
6112        if (sid == null || sid === '') return fetchOne();
6113        var idSel =
6114            typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
6115                ? CSS.escape(String(sid))
6116                : String(sid).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
6117        var domSelectors = [
6118            '#top10Cards .tile[data-svc-id="' + idSel + '"] .poster-logo-wrap img',
6119            '#top10Cards .top10-cards-shelf--games .tile[data-svc-id="' + idSel + '"] .poster-logo-wrap img',
6120            '#top10Boards .top10-item[data-svc-id="' + idSel + '"] img.top10-logo',
6121            '#detMobileCommunityBoardHost .det-sidebar-line--rank-main[data-svc-id="' + idSel + '"] img.det-sb-logo',
6122            '.det-sidebar-complaints-item .det-sidebar-line--rank-main[data-svc-id="' + idSel + '"] img.det-sb-logo',
6123            '[data-svc-id="' + idSel + '"] img.det-sb-logo',
6124            '[data-svc-id="' + idSel + '"] img[src]',
6125        ];
6126
6127        function tryDomAt(index) {
6128            if (index >= domSelectors.length) return fetchOne();
6129            var el = document.querySelector(domSelectors[index]);
6130            if (!el) return tryDomAt(index + 1);
6131            var elSrc = (el.currentSrc || el.getAttribute('src') || '').trim();
6132            if (elSrc && !/^data:/i.test(elSrc)) domImgSrcs.push(elSrc);
6133            if (detImgSafeForCanvasExport(el)) return Promise.resolve(el);
6134            /* URL externa nunca fica «safe» para canvas após load — não esperar 2,2s por selector. */
6135            var canBecomeSafe = false;
6136            try {
6137                var pageH = window.location.href || 'http://localhost/';
6138                if (elSrc && (/^data:/i.test(elSrc) || /^blob:/i.test(elSrc))) canBecomeSafe = true;
6139                else if (elSrc) canBecomeSafe = new URL(elSrc, pageH).origin === new URL(pageH).origin;
6140            } catch (eOrig) {}
6141            if (!canBecomeSafe) return tryDomAt(index + 1);
6142            if (!el.complete) {
6143                return new Promise(function (resolve) {
6144                    var settled = false;
6145                    function goNext() {
6146                        if (settled) return;
6147                        settled = true;
6148                        try {
6149                            clearTimeout(tid);
6150                        } catch (eT) {}
6151                        tryDomAt(index + 1).then(resolve);
6152                    }
6153                    var tid = setTimeout(goNext, 2200);
6154                    el.addEventListener(
6155                        'load',
6156                        function () {
6157                            if (settled) return;
6158                            settled = true;
6159                            try {
6160                                clearTimeout(tid);
6161                            } catch (eL) {}
6162                            if (detImgSafeForCanvasExport(el)) resolve(el);
6163                            else tryDomAt(index + 1).then(resolve);
6164                        },
6165                        { once: true },
6166                    );
6167                    el.addEventListener('error', goNext, { once: true });
6168                });
6169            }
6170            return tryDomAt(index + 1);
6171        }
6172
6173        return tryDomAt(0);
6174    }
6175
6176    /**
6177     * URLs de ícones externas passam pelo proxy same-origin (/api/detector/fetch-icon),
6178     * para fetch→blob→canvas no cartão social (CDNs raramente enviam CORS).
6179     */
6180    function detectorIconProxyWrapUrl(url) {
6181        if (url == null || url === '') return url;
6182        var s = String(url).trim();
6183        if (!s || /^data:/i.test(s) || /^blob:/i.test(s)) return s;
6184        var pageHref = '';
6185        try {
6186            pageHref = window.location.href || '';
6187        } catch (e0) {
6188            return s;
6189        }
6190        var abs = s;
6191        try {
6192            abs = new URL(s, pageHref).href;
6193        } catch (e1) {
6194            return s;
6195        }
6196        if (abs.indexOf('/api/detector/fetch-icon?') !== -1) return abs;
6197        var pageOrigin = '';
6198        try {
6199            pageOrigin = new URL(pageHref || 'http://localhost').origin;
6200        } catch (e2) {
6201            return s;
6202        }
6203        try {
6204            if (new URL(abs).origin === pageOrigin) return abs;
6205        } catch (e3) {
6206            return s;
6207        }
6208        try {
6209            return pageOrigin + '/api/detector/fetch-icon?u=' + encodeURIComponent(abs);
6210        } catch (e4) {
6211            return s;
6212        }
6213    }
6214
6215    /**
6216     * Ranking comunidade (mobile + sidebar): `src` via `/api/detector/fetch-icon` para o mesmo PNG que o servidor valida
6217     * (evita bloqueio de favicon Google no mobile e 404 antigos no catálogo sem «cara» do unavatar).
6218     */
6219    function detComplaintsRankLogoSrc(svc, row) {
6220        var dom =
6221            (svc && svc.logo_domain ? String(svc.logo_domain) : '').trim() ||
6222            (row && row.logo_domain ? String(row.logo_domain) : '').trim();
6223        var raw = '';
6224        if (svc && svc.logo_url != null && String(svc.logo_url).trim() !== '') {
6225            raw = String(svc.logo_url).trim();
6226        } else if (row && row.logo_url != null && String(row.logo_url).trim() !== '') {
6227            raw = String(row.logo_url).trim();
6228        }
6229        if (!raw) {
6230            raw = faviconForDomain(dom);
6231        }
6232        var abs = detAbsDetectorAssetUrl(raw);
6233        if (!abs) {
6234            abs = faviconForDomain(dom);
6235        }
6236        return detectorIconProxyWrapUrl(abs);
6237    }
6238
6239    /** Resolve com `fallback` se `promise` não terminar em `ms` (compatível com Promise sem .finally). */
6240    function detPromiseOrFallback(promise, ms, fallback) {
6241        return new Promise(function (resolve) {
6242            var done = false;
6243            var tid = setTimeout(function () {
6244                if (done) return;
6245                done = true;
6246                resolve(fallback);
6247            }, ms);
6248            promise.then(
6249                function (v) {
6250                    if (done) return;
6251                    done = true;
6252                    try {
6253                        clearTimeout(tid);
6254                    } catch (eC) {}
6255                    resolve(v);
6256                },
6257                function () {
6258                    if (done) return;
6259                    done = true;
6260                    try {
6261                        clearTimeout(tid);
6262                    } catch (eC2) {}
6263                    resolve(fallback);
6264                },
6265            );
6266        });
6267    }
6268
6269    function fetchImageForCanvasViaBlob(url) {
6270        var cacheKey = url;
6271        var fromCache = logoCacheGet(cacheKey).then(function (cachedBlob) {
6272            if (!cachedBlob) throw new Error('miss');
6273            return blobToImage(cachedBlob);
6274        });
6275        var pipeline = fromCache.catch(function () {
6276            var fetchUrl = detectorIconProxyWrapUrl(url);
6277            return fetch(fetchUrl, { mode: 'cors', credentials: 'omit' })
6278                .then(function (r) {
6279                    if (!r.ok) throw new Error('http');
6280                    return r.blob();
6281                })
6282                .then(function (blob) {
6283                    logoCachePut(cacheKey, blob);
6284                    return blobToImage(blob);
6285                });
6286        });
6287        var timeoutP = new Promise(function (_, reject) {
6288            setTimeout(function () {
6289                reject(new Error('timeout'));
6290            }, DET_T10_SOCIAL_ICON_FETCH_MS);
6291        });
6292        return Promise.race([pipeline, timeoutP]);
6293    }
6294
6295    function loadImageCrossOriginDirect(url) {
6296        return new Promise(function (resolve, reject) {
6297            var im = new Image();
6298            im.crossOrigin = 'anonymous';
6299            var tid = setTimeout(function () { im.src = ''; reject(); }, 4000);
6300            im.onload = function () {
6301                clearTimeout(tid);
6302                if (im.naturalWidth > 0) resolve(im);
6303                else reject();
6304            };
6305            im.onerror = function () { clearTimeout(tid); reject(); };
6306            var sep = url.indexOf('?') === -1 ? '?' : '&';
6307            im.src = url + sep + '_c=1';
6308        });
6309    }
6310
6311    function loadFirstTop10SocialLogoFromUrls(urls) {
6312        var i = 0;
6313        function nextAttempt() {
6314            if (i >= urls.length) return crossOriginPass();
6315            var u = urls[i++];
6316            if (!u) return nextAttempt();
6317            return fetchImageForCanvasViaBlob(u).catch(nextAttempt);
6318        }
6319        var j = 0;
6320        function crossOriginPass() {
6321            if (j >= urls.length) return Promise.resolve(null);
6322            var u = urls[j++];
6323            if (!u || /^data:/i.test(u) || /^blob:/i.test(u)) return crossOriginPass();
6324            return loadImageCrossOriginDirect(u).catch(crossOriginPass);
6325        }
6326        return nextAttempt();
6327    }
6328
6329    /**
6330     * @param {Array} rows — até 10 linhas do ranking comunidade
6331     * @param {function(Array<HTMLImageElement|null>)} doneCb
6332     */
6333    function loadTop10SocialRowLogoImages(rows, doneCb) {
6334        var lim = Math.min(rows.length, 10);
6335        if (!lim) {
6336            doneCb([]);
6337            return;
6338        }
6339        var promises = [];
6340        var ri;
6341        for (ri = 0; ri < lim; ri++) {
6342            promises.push(
6343                detPromiseOrFallback(loadTop10SocialRowLogoImageOne(rows[ri]), DET_T10_SOCIAL_ROW_LOGO_MS, null),
6344            );
6345        }
6346        Promise.all(promises)
6347            .then(function (imgs) {
6348                doneCb(imgs || []);
6349            })
6350            .catch(function () {
6351                doneCb([]);
6352            });
6353    }
6354
6355    var _prefetchLogosScheduled = false;
6356    function prefetchTop10Logos() {
6357        if (_prefetchLogosScheduled) return;
6358        _prefetchLogosScheduled = true;
6359        setTimeout(function () {
6360            try {
6361                var rows = communityRowsWithVotesForReport().slice(0, 10);
6362                if (!rows.length) { _prefetchLogosScheduled = false; return; }
6363                fetchDetectorLogoManifest().then(function () {
6364                    var idx = 0;
6365                    function nextLogo() {
6366                        if (idx >= rows.length) return;
6367                        var svc = communityRankingRowToSvc(rows[idx]);
6368                        idx++;
6369                        if (!svc) { nextLogo(); return; }
6370                        var urls = detLogoUrlCandidatesForTop10SocialCard(svc);
6371                        if (!urls.length) { nextLogo(); return; }
6372                        var firstUrl = urls[0];
6373                        logoCacheGet(firstUrl).then(function (cached) {
6374                            if (cached) { nextLogo(); return; }
6375                            var fetchUrl = detectorIconProxyWrapUrl(firstUrl);
6376                            fetch(fetchUrl, { mode: 'cors', credentials: 'omit' })
6377                                .then(function (r) { return r.ok ? r.blob() : null; })
6378                                .then(function (blob) { if (blob) logoCachePut(firstUrl, blob); })
6379                                .catch(function () {})
6380                                .then(nextLogo);
6381                        }).catch(nextLogo);
6382                    }
6383                    nextLogo();
6384                }).catch(function () { _prefetchLogosScheduled = false; });
6385            } catch (ex) { _prefetchLogosScheduled = false; }
6386        }, 3000);
6387    }
6388
6389    /** Estrelas Catellix (meias por clip), alinhadas a `rightX`, para o cartão social Top 10. */
6390    function drawTop10SocialCardStarsRight(ctx, rightX, yMid, starValue, starSize) {
6391        var v = Math.max(0, Math.min(5, Number(starValue) || 0));
6392        ctx.save();
6393        ctx.font = 'bold ' + starSize + 'px system-ui, "Segoe UI", Roboto, sans-serif';
6394        ctx.textBaseline = 'middle';
6395        ctx.textAlign = 'left';
6396        var starW = ctx.measureText('★').width || starSize * 0.92;
6397        var gap = Math.max(0.5, Math.round(starSize * 0.06));
6398        var totalW = 5 * starW + 4 * gap;
6399        var x0 = rightX - totalW;
6400        var i;
6401        for (i = 0; i < 5; i++) {
6402            var sx = x0 + i * (starW + gap);
6403            var frac = Math.max(0, Math.min(1, v - i));
6404            ctx.shadowBlur = 0;
6405            ctx.fillStyle = 'rgba(100, 116, 139, 0.5)';
6406            ctx.fillText('★', sx, yMid);
6407            if (frac > 0.001) {
6408                ctx.save();
6409                ctx.beginPath();
6410                ctx.rect(sx, yMid - starSize * 0.55, starW * frac + 0.35, starSize * 1.12);
6411                ctx.clip();
6412                ctx.fillStyle = '#f59e0b';
6413                ctx.shadowColor = 'rgba(251, 191, 36, 0.4)';
6414                ctx.shadowBlur = 3;
6415                ctx.fillText('★', sx, yMid);
6416                ctx.restore();
6417            }
6418        }
6419        ctx.restore();
6420    }
6421
6422    /**
6423     * Cartão vertical para stories/posts: gradiente marca, lista em “pills”, logo Catellix + catellix.com.
6424     * @param {HTMLCanvasElement} canvas
6425     * @param {Array} rows
6426     * @param {HTMLImageElement|null} brandImg — de loadTop10SocialBrandLogo
6427     * @param {Array<HTMLImageElement|null>=} rowLogos — ícones por linha (opcional)
6428     * @param {{ maxDpr?: number }=} drawOpts — ex.: `{ maxDpr: 1 }` para pré-visualização leve no telemóvel
6429     */
6430    function drawTop10SocialCard(canvas, rows, brandImg, rowLogos, drawOpts) {
6431        var w = 720;
6432        var h = 940;
6433        var opt = drawOpts && typeof drawOpts === 'object' ? drawOpts : {};
6434        var shareKind =
6435            (opt && opt.shareKind) ||
6436            (detTop10SocialSession && detTop10SocialSession.kind) ||
6437            'community';
6438        var isGamesCard = shareKind === 'games';
6439        var capDpr =
6440            typeof opt.maxDpr === 'number' && opt.maxDpr > 0
6441                ? opt.maxDpr
6442                : typeof window !== 'undefined' && window.devicePixelRatio
6443                  ? Math.min(2, window.devicePixelRatio)
6444                  : 1;
6445        var dpr = capDpr;
6446        canvas.width = Math.round(w * dpr);
6447        canvas.height = Math.round(h * dpr);
6448        canvas.style.width = w + 'px';
6449        canvas.style.height = h + 'px';
6450        var ctx = canvas.getContext('2d');
6451        if (!ctx) return;
6452        ctx.scale(dpr, dpr);
6453        ctx.textBaseline = 'alphabetic';
6454
6455        ctx.save();
6456        detSocialCardRoundClip(ctx, w, h, 28);
6457        ctx.clip();
6458
6459        var g = ctx.createLinearGradient(0, 0, w, h * 1.08);
6460        g.addColorStop(0, '#070f1a');
6461        g.addColorStop(0.28, '#0f2744');
6462        g.addColorStop(0.55, '#164e63');
6463        g.addColorStop(0.82, '#0e7490');
6464        g.addColorStop(1, '#155e75');
6465        ctx.fillStyle = g;
6466        ctx.fillRect(0, 0, w, h);
6467
6468        var rg = ctx.createRadialGradient(w * 0.12, 0, 0, w * 0.35, h * 0.22, w * 0.95);
6469        rg.addColorStop(0, 'rgba(56, 189, 248, 0.28)');
6470        rg.addColorStop(0.45, 'rgba(14, 165, 233, 0.1)');
6471        rg.addColorStop(1, 'rgba(7, 15, 26, 0)');
6472        ctx.fillStyle = rg;
6473        ctx.fillRect(0, 0, w, h);
6474
6475        ctx.fillStyle = 'rgba(255, 255, 255, 0.045)';
6476        var dg;
6477        for (dg = 20; dg < w + h; dg += 44) {
6478            ctx.beginPath();
6479            ctx.moveTo(dg, 0);
6480            ctx.lineTo(dg + h, h);
6481            ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
6482            ctx.lineWidth = 1;
6483            ctx.stroke();
6484        }
6485
6486        ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
6487        var gx;
6488        var gy;
6489        for (gx = 22; gx < w; gx += 40) {
6490            for (gy = 22; gy < h * 0.72; gy += 40) {
6491                ctx.beginPath();
6492                ctx.arc(gx, gy, 1.25, 0, Math.PI * 2);
6493                ctx.fill();
6494            }
6495        }
6496
6497        ctx.fillStyle = 'rgba(6, 182, 212, 0.95)';
6498        detSocialCardRoundRectPath(ctx, 28, 40, 56, 6, 3);
6499        ctx.fill();
6500
6501        ctx.strokeStyle = 'rgba(56, 189, 248, 0.55)';
6502        ctx.lineWidth = 3;
6503        ctx.beginPath();
6504        ctx.moveTo(28, 54);
6505        ctx.lineTo(200, 54);
6506        ctx.stroke();
6507
6508        var tk = isGamesCard ? __('js_t10_games_social_card_kicker') : __('js_t10_social_card_kicker');
6509        var titleFontPx = 27;
6510        var titleRightReserve = brandImg && brandImg.naturalWidth > 0 ? 200 : 40;
6511        var titleMaxW = w - 28 - titleRightReserve;
6512        ctx.fillStyle = '#f8fafc';
6513        for (; titleFontPx >= 17; titleFontPx--) {
6514            ctx.font = 'bold ' + titleFontPx + 'px system-ui, "Segoe UI", Roboto, sans-serif';
6515            if (ctx.measureText(tk).width <= titleMaxW) break;
6516        }
6517        ctx.fillText(tk, 28, 88);
6518
6519        ctx.font = '600 16px system-ui, "Segoe UI", Roboto, sans-serif';
6520        ctx.fillStyle = 'rgba(226, 232, 240, 0.88)';
6521        var subY = titleFontPx <= 20 ? 120 : 118;
6522        if (isGamesCard) {
6523            ctx.fillText(__('js_t10_games_social_card_sub') + ' · ' + new Date().toISOString().slice(0, 10), 28, subY);
6524        } else {
6525            var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
6526            var dayLblMap = {
6527                1: __('js_window_1d'),
6528                7: __('js_window_7d'),
6529                15: __('js_window_15d'),
6530                30: __('js_window_30d'),
6531            };
6532            var winLbl = dayLblMap[days] || __('js_window_1d');
6533            ctx.fillText(winLbl + ' · ' + new Date().toISOString().slice(0, 10), 28, subY);
6534        }
6535
6536        if (brandImg && brandImg.naturalWidth > 0) {
6537            var headLh = 40;
6538            var headLw = (brandImg.naturalWidth / brandImg.naturalHeight) * headLh;
6539            var headMaxW = 168;
6540            if (headLw > headMaxW) {
6541                headLw = headMaxW;
6542                headLh = (brandImg.naturalHeight / brandImg.naturalWidth) * headLw;
6543            }
6544            ctx.save();
6545            ctx.globalAlpha = 0.92;
6546            ctx.drawImage(brandImg, w - headLw - 28, 44, headLw, headLh);
6547            ctx.restore();
6548        }
6549
6550        var rowH = 54;
6551        var pillInnerH = 48;
6552        var rowTop0 = subY + 20;
6553        var pillW = w - 56;
6554        var rankColors = ['#eab308', '#94a3b8', '#d97706', '#0ea5e9'];
6555        var logos = rowLogos || [];
6556        var iconS = 30;
6557        var iconPadL = 72;
6558        var yRankMid = function (y0) {
6559            return y0 + pillInnerH / 2;
6560        };
6561        if (!isGamesCard) {
6562            ctx.fillStyle = 'rgba(148, 163, 184, 0.62)';
6563            ctx.font = '600 9px system-ui, "Segoe UI", Roboto, sans-serif';
6564            ctx.textAlign = 'right';
6565            ctx.textBaseline = 'alphabetic';
6566            var entHint = __('js_t10_social_enterprise_hint');
6567            if (entHint) ctx.fillText(entHint, 28 + pillW - 12, rowTop0 - 6);
6568        }
6569        var r;
6570        for (r = 0; r < rows.length && r < 10; r++) {
6571            var y0 = rowTop0 + r * rowH;
6572            detSocialCardRoundRectPath(ctx, 28, y0, pillW, pillInnerH, 12);
6573            ctx.fillStyle = 'rgba(15, 23, 42, 0.42)';
6574            ctx.fill();
6575            var pillGrad = ctx.createLinearGradient(28, y0, 28 + pillW * 0.55, y0);
6576            pillGrad.addColorStop(0, 'rgba(56, 189, 248, 0.07)');
6577            pillGrad.addColorStop(1, 'rgba(15, 23, 42, 0)');
6578            ctx.fillStyle = pillGrad;
6579            detSocialCardRoundRectPath(ctx, 28, y0, pillW, pillInnerH, 12);
6580            ctx.fill();
6581            detSocialCardRoundRectPath(ctx, 28, y0, pillW, pillInnerH, 12);
6582            ctx.strokeStyle = 'rgba(56, 189, 248, 0.28)';
6583            ctx.lineWidth = 1;
6584            ctx.stroke();
6585
6586            var ym = yRankMid(y0);
6587            var rankFill = rankColors[r < 3 ? r : 3];
6588            ctx.beginPath();
6589            ctx.arc(54, ym, 15, 0, Math.PI * 2);
6590            ctx.fillStyle = rankFill;
6591            ctx.fill();
6592            ctx.strokeStyle = 'rgba(255,255,255,0.28)';
6593            ctx.lineWidth = 1;
6594            ctx.stroke();
6595
6596            ctx.fillStyle = r < 3 ? '#0f172a' : '#f8fafc';
6597            ctx.font = 'bold 14px system-ui, "Segoe UI", Roboto, sans-serif';
6598            ctx.textAlign = 'center';
6599            ctx.textBaseline = 'middle';
6600            ctx.fillText(String(r + 1), 54, ym);
6601            ctx.textAlign = 'left';
6602            ctx.textBaseline = 'alphabetic';
6603
6604            var rowLogo = logos[r];
6605            var hasLogo = rowLogo && rowLogo.naturalWidth > 0;
6606            var iconY = y0 + (pillInnerH - iconS) / 2;
6607            if (hasLogo) {
6608                ctx.save();
6609                detSocialCardRoundRectPath(ctx, iconPadL, iconY, iconS, iconS, 8);
6610                ctx.clip();
6611                var iw = rowLogo.naturalWidth;
6612                var ih = rowLogo.naturalHeight;
6613                var sc = Math.min(iconS / iw, iconS / ih);
6614                var dw = iw * sc;
6615                var dh = ih * sc;
6616                var dx = iconPadL + (iconS - dw) / 2;
6617                var dy = iconY + (iconS - dh) / 2;
6618                ctx.drawImage(rowLogo, dx, dy, dw, dh);
6619                ctx.restore();
6620            } else {
6621                var fullNm0 = String(rows[r].name || rows[r].service_id || '');
6622                ctx.save();
6623                detSocialCardRoundRectPath(ctx, iconPadL, iconY, iconS, iconS, 8);
6624                ctx.fillStyle = 'rgba(56, 189, 248, 0.18)';
6625                ctx.fill();
6626                ctx.strokeStyle = 'rgba(56, 189, 248, 0.35)';
6627                ctx.lineWidth = 1;
6628                ctx.stroke();
6629                ctx.fillStyle = 'rgba(248, 250, 252, 0.92)';
6630                ctx.font = 'bold 15px system-ui, "Segoe UI", Roboto, sans-serif';
6631                ctx.textAlign = 'center';
6632                ctx.textBaseline = 'middle';
6633                ctx.fillText((fullNm0.charAt(0) || '?').toUpperCase(), iconPadL + iconS / 2, iconY + iconS / 2);
6634                ctx.restore();
6635            }
6636
6637            var nameX = iconPadL + iconS + 12;
6638            var fullNm = String(rows[r].name || rows[r].service_id || '');
6639            var svc = communityRankingRowToSvc(rows[r]);
6640            var metricsRight = 28 + pillW - 12;
6641            var maxTextW;
6642            if (isGamesCard) {
6643                var msR = svc ? gameRankingLatencyMs(svc) : null;
6644                var latStr =
6645                    msR != null && !isNaN(msR) ? String(Math.round(msR)) + ' ms' : '—';
6646                ctx.textAlign = 'right';
6647                ctx.textBaseline = 'middle';
6648                ctx.font = 'bold 13px system-ui, "Segoe UI", Roboto, sans-serif';
6649                var latCol =
6650                    msR != null && !isNaN(msR) && msR > 500
6651                        ? '#f87171'
6652                        : msR != null && !isNaN(msR) && msR > 200
6653                          ? '#fbbf24'
6654                          : 'rgba(186, 230, 253, 0.95)';
6655                ctx.fillStyle = latCol;
6656                ctx.fillText(latStr, metricsRight, ym);
6657                ctx.textAlign = 'left';
6658                var latW = ctx.measureText(latStr).width;
6659                maxTextW = Math.max(48, metricsRight - latW - 14 - nameX);
6660            } else {
6661                var sd = computeModalStarDisplay(svc);
6662                var ts = Number(svc && svc.trust_score);
6663                if (isNaN(ts)) ts = 100;
6664                var scoreStr = ts.toFixed(1).replace('.', ',') + '/100';
6665                ctx.font = 'bold 11px system-ui, "Segoe UI", Roboto, sans-serif';
6666                ctx.textBaseline = 'middle';
6667                var scoreW = ctx.measureText(scoreStr).width;
6668                var starSize = 11;
6669                ctx.font = 'bold ' + starSize + 'px system-ui, "Segoe UI", Roboto, sans-serif';
6670                var starW = ctx.measureText('★').width || starSize * 0.92;
6671                var sGap = Math.max(0.5, Math.round(starSize * 0.06));
6672                var starsBlockW = 5 * starW + 4 * sGap;
6673                var scoreX = metricsRight - starsBlockW - 8 - scoreW;
6674                ctx.textAlign = 'left';
6675                ctx.fillStyle = 'rgba(186, 230, 253, 0.95)';
6676                ctx.font = 'bold 11px system-ui, "Segoe UI", Roboto, sans-serif';
6677                ctx.fillText(scoreStr, scoreX, ym);
6678                drawTop10SocialCardStarsRight(ctx, metricsRight, ym, sd.value, starSize);
6679                maxTextW = Math.max(48, scoreX - 10 - nameX);
6680            }
6681            ctx.fillStyle = '#f1f5f9';
6682            ctx.font = '600 17px system-ui, "Segoe UI", Roboto, sans-serif';
6683            ctx.textBaseline = 'alphabetic';
6684            var nm = fullNm;
6685            while (nm.length > 1 && ctx.measureText(nm + '…').width > maxTextW) {
6686                nm = nm.slice(0, -1);
6687            }
6688            if (nm.length < fullNm.length) nm += '…';
6689            ctx.textBaseline = 'middle';
6690            ctx.fillText(nm, nameX, ym);
6691        }
6692
6693        var footBand = ctx.createLinearGradient(0, h - 120, 0, h);
6694        footBand.addColorStop(0, 'rgba(7, 15, 26, 0)');
6695        footBand.addColorStop(0.35, 'rgba(7, 15, 26, 0.55)');
6696        footBand.addColorStop(1, 'rgba(7, 15, 26, 0.82)');
6697        ctx.fillStyle = footBand;
6698        ctx.fillRect(0, h - 120, w, 120);
6699
6700        var footTop = h - 96;
6701        var logoH = 44;
6702        if (brandImg && brandImg.naturalWidth > 0) {
6703            var lw = (brandImg.naturalWidth / brandImg.naturalHeight) * logoH;
6704            var logoMaxW = 200;
6705            if (lw > logoMaxW) {
6706                lw = logoMaxW;
6707                logoH = (brandImg.naturalHeight / brandImg.naturalWidth) * lw;
6708            }
6709            ctx.drawImage(brandImg, 28, footTop, lw, logoH);
6710            ctx.fillStyle = '#f0f9ff';
6711            ctx.font = 'bold 23px system-ui, "Segoe UI", Roboto, sans-serif';
6712            ctx.textBaseline = 'middle';
6713            ctx.fillText('catellix.com', 28 + lw + 14, footTop + logoH * 0.52);
6714        } else {
6715            ctx.fillStyle = '#f0f9ff';
6716            ctx.font = 'bold 23px system-ui, "Segoe UI", Roboto, sans-serif';
6717            ctx.textBaseline = 'middle';
6718            ctx.fillText('catellix.com', 28, footTop + 22);
6719        }
6720
6721        ctx.fillStyle = 'rgba(226, 232, 240, 0.78)';
6722        ctx.font = '13px system-ui, "Segoe UI", Roboto, sans-serif';
6723        ctx.textBaseline = 'alphabetic';
6724        ctx.fillText(__('js_t10_social_card_footer'), 28, h - 22);
6725
6726        ctx.restore();
6727
6728        ctx.save();
6729        detSocialCardRoundClip(ctx, w, h, 28);
6730        ctx.strokeStyle = 'rgba(56, 189, 248, 0.4)';
6731        ctx.lineWidth = 2;
6732        ctx.stroke();
6733        ctx.restore();
6734    }
6735
6736    function setTop10SocialModalGenerating(isGenerating, errorMsg, opts) {
6737        var o = opts && typeof opts === 'object' ? opts : {};
6738        var keepPreview = !!o.keepPreviewVisible;
6739        var loadEl = document.getElementById('detTop10SocialLoading');
6740        var img = document.getElementById('detTop10SocialPreview');
6741        var act = document.getElementById('detTop10SocialActions');
6742        if (loadEl) {
6743            if (isGenerating) {
6744                loadEl.hidden = false;
6745                loadEl.textContent = o.loadingText != null ? String(o.loadingText) : __('js_t10_social_preparing');
6746                loadEl.setAttribute('data-busy', '1');
6747            } else if (errorMsg) {
6748                loadEl.hidden = false;
6749                loadEl.textContent = errorMsg;
6750                loadEl.removeAttribute('data-busy');
6751            } else {
6752                loadEl.hidden = true;
6753                loadEl.textContent = '';
6754                loadEl.removeAttribute('data-busy');
6755            }
6756        }
6757        if (img) {
6758            if (errorMsg) {
6759                img.hidden = true;
6760            } else if (isGenerating && !keepPreview) {
6761                img.hidden = true;
6762            }
6763        }
6764        if (act) {
6765            act.classList.remove('det-t10-social-actions--disabled');
6766        }
6767    }
6768
6769    function openTop10GamesSocialShareModal() {
6770        var gRows = rankingRowsForTop10GamesSocialShare();
6771        if (!gRows.length) {
6772            try {
6773                alert(__('js_t10_games_social_no_data'));
6774            } catch (eA) {}
6775            return;
6776        }
6777        detTop10SocialSession = { kind: 'games', rows: gRows };
6778        openTop10SocialShareModalBody();
6779    }
6780
6781    function openTop10SocialShareModal() {
6782        detTop10SocialSession = { kind: 'community', rows: null };
6783        openTop10SocialShareModalBody();
6784    }
6785
6786    function openTop10SocialShareModalBody() {
6787        var rows = getTop10SocialShareRowsForModal();
6788        if (!rows.length) {
6789            try {
6790                alert(__('js_t10_social_no_data'));
6791            } catch (eA) {}
6792            detTop10SocialSession = { kind: 'community', rows: null };
6793            return;
6794        }
6795        var modal = document.getElementById('detTop10SocialModal');
6796        var img = document.getElementById('detTop10SocialPreview');
6797        var cv = document.getElementById('detTop10SocialCanvas');
6798        if (!modal || !img || !cv) return;
6799        try {
6800            cv.dataset.detExportReady = '0';
6801        } catch (eDs) {}
6802        try {
6803            img.removeAttribute('src');
6804            img.alt = '';
6805            img.hidden = true;
6806        } catch (eClr) {}
6807        var tsm = document.getElementById('detTop10SocialTitle');
6808        if (tsm) {
6809            tsm.textContent =
6810                detTop10SocialSession && detTop10SocialSession.kind === 'games'
6811                    ? __('js_t10_games_social_modal_title')
6812                    : __('js_t10_social_modal_title');
6813        }
6814        var lead = document.getElementById('detTop10SocialLead');
6815        if (lead) {
6816            lead.textContent =
6817                detTop10SocialSession && detTop10SocialSession.kind === 'games'
6818                    ? __('js_t10_games_social_lead')
6819                    : __('js_t10_social_lead');
6820        }
6821        modal.classList.add('open');
6822        modal.setAttribute('aria-hidden', 'false');
6823        try {
6824            document.body.style.overflow = 'hidden';
6825        } catch (eB) {}
6826
6827        function applyPreviewJpegFromCanvas(c) {
6828            try {
6829                img.src = c.toDataURL('image/jpeg', 0.82);
6830                img.alt = __('js_t10_social_preview_alt');
6831                img.hidden = false;
6832            } catch (eJ) {
6833                try {
6834                    img.src = c.toDataURL('image/png');
6835                    img.alt = __('js_t10_social_preview_alt');
6836                    img.hidden = false;
6837                } catch (eP) {}
6838            }
6839        }
6840
6841        var brandCached =
6842            detTop10SocialBrandLogoImg &&
6843            detTop10SocialBrandLogoImg.complete &&
6844            detTop10SocialBrandLogoImg.naturalHeight > 0
6845                ? detTop10SocialBrandLogoImg
6846                : null;
6847        var preCv = document.createElement('canvas');
6848        var skShare =
6849            detTop10SocialSession && detTop10SocialSession.kind === 'games' ? 'games' : 'community';
6850        try {
6851            drawTop10SocialCard(preCv, rows, brandCached, [], { maxDpr: 1, shareKind: skShare });
6852            applyPreviewJpegFromCanvas(preCv);
6853        } catch (eQuick) {}
6854
6855        setTop10SocialModalGenerating(true, null, {
6856            keepPreviewVisible: true,
6857            loadingText: __('js_t10_social_icons_loading'),
6858        });
6859
6860        try {
6861            loadTop10SocialBrandLogo(function () {});
6862        } catch (ePreload) {}
6863
6864        function finishCardOk() {
6865            function applyPngDeferred() {
6866                try {
6867                    try {
6868                        img.src = cv.toDataURL('image/png');
6869                    } catch (ePng) {
6870                        applyPreviewJpegFromCanvas(cv);
6871                    }
6872                    img.alt = __('js_t10_social_preview_alt');
6873                    img.hidden = false;
6874                    try {
6875                        cv.dataset.detExportReady = '1';
6876                    } catch (eD2) {}
6877                    setTop10SocialModalGenerating(false);
6878                } catch (eImg) {
6879                    try {
6880                        img.removeAttribute('src');
6881                        img.alt = '';
6882                        img.hidden = true;
6883                    } catch (eImg2) {}
6884                    setTop10SocialModalGenerating(false, __('js_t10_social_card_failed'));
6885                }
6886            }
6887            try {
6888                if (typeof requestAnimationFrame === 'function') {
6889                    requestAnimationFrame(function () {
6890                        setTimeout(applyPngDeferred, 0);
6891                    });
6892                } else {
6893                    setTimeout(applyPngDeferred, 0);
6894                }
6895            } catch (eRaf) {
6896                applyPngDeferred();
6897            }
6898        }
6899        function drawSocialCardAfterManifest() {
6900            loadTop10SocialBrandLogo(function (brandImg) {
6901                loadTop10SocialRowLogoImages(rows, function (rowLogos) {
6902                    try {
6903                        drawTop10SocialCard(cv, rows, brandImg, rowLogos, { shareKind: skShare });
6904                        finishCardOk();
6905                    } catch (eDraw) {
6906                        setTop10SocialModalGenerating(false, __('js_t10_social_card_failed'));
6907                    }
6908                });
6909            });
6910        }
6911        fetchDetectorLogoManifest()
6912            .then(drawSocialCardAfterManifest)
6913            .catch(drawSocialCardAfterManifest);
6914    }
6915
6916    function closeTop10SocialShareModal() {
6917        var modal = document.getElementById('detTop10SocialModal');
6918        if (!modal) return;
6919        modal.classList.remove('open');
6920        modal.setAttribute('aria-hidden', 'true');
6921        try {
6922            document.body.style.overflow = '';
6923        } catch (e) {}
6924        detTop10SocialSession = { kind: 'community', rows: null };
6925    }
6926
6927    function fillTop20StudioDom() {
6928        var host = document.getElementById('detTop20ListHost');
6929        if (!host) return;
6930        var rows = communityRowsWithVotesForReport().slice(0, 20);
6931        if (!rows.length) {
6932            host.innerHTML =
6933                '<p class="det-top20-empty">' + esc(__('js_top20_empty')) + '</p>';
6934            return;
6935        }
6936        var frag = '';
6937        for (var i = 0; i < rows.length; i++) {
6938            var rw = rows[i];
6939            var rnk = i + 1;
6940            var tier =
6941                rnk === 1 ? 'gold' : rnk === 2 ? 'silver' : rnk === 3 ? 'bronze' : 'std';
6942            var pill = complaintsRankingPillDisplay(rw);
6943            var svc = findSvc(rw.service_id);
6944            var dom = svc && svc.logo_domain ? String(svc.logo_domain) : '';
6945            var logo = svc && svc.logo_url ? svc.logo_url : faviconForDomain(dom);
6946            frag +=
6947                '<article class="det-top20-card det-top20-card--' +
6948                esc(tier) +
6949                '"><div class="det-top20-rank">' +
6950                esc(String(rnk)) +
6951                '</div><img class="det-top20-logo" src="' +
6952                esc(logo) +
6953                '" alt="" width="48" height="48" loading="lazy"' +
6954                imgLogoOnErrorAttr(dom) +
6955                '><div class="det-top20-card-main"><h3 class="det-top20-name">' +
6956                esc(rw.name || rw.service_id) +
6957                '</h3><p class="det-top20-meta">' +
6958                communityRankingMetaLineHtml(rw) +
6959                '</p></div><div class="det-top20-score" title="' +
6960                esc(complaintsRankingPillTitle(rw, pill)) +
6961                '">' +
6962                esc(pill.value) +
6963                '</div></article>';
6964        }
6965        host.innerHTML = frag;
6966        host.querySelectorAll('.det-top20-card').forEach(function (card, idx) {
6967            card.style.animationDelay = idx * 0.04 + 's';
6968        });
6969    }
6970
6971    function openTop20StudioModal() {
6972        var modal = document.getElementById('detTop20Modal');
6973        if (!modal) return;
6974        fillTop20StudioDom();
6975        var t = document.getElementById('detTop20Title');
6976        if (t) t.textContent = __('js_top20_modal_title');
6977        var lead = document.getElementById('detTop20Lead');
6978        if (lead) lead.textContent = __('js_top20_modal_lead');
6979        modal.classList.add('open', 'det-top20-modal--anim');
6980        modal.setAttribute('aria-hidden', 'false');
6981        try {
6982            document.body.style.overflow = 'hidden';
6983        } catch (e) {}
6984    }
6985
6986    function closeTop20StudioModal() {
6987        var modal = document.getElementById('detTop20Modal');
6988        if (!modal) return;
6989        modal.classList.remove('open', 'det-top20-modal--anim');
6990        modal.setAttribute('aria-hidden', 'true');
6991        try {
6992            document.body.style.overflow = '';
6993        } catch (e2) {}
6994    }
6995
6996    function wireDetectorRankingStudioAndReport() {
6997        if (document.documentElement.dataset.detRankingShareWired === '1') return;
6998        document.documentElement.dataset.detRankingShareWired = '1';
6999
7000        var btnTop10 = document.getElementById('btnSidebarTop10Share');
7001        if (btnTop10) {
7002            btnTop10.addEventListener('click', function () {
7003                openDetectorRankingReportModal('top10');
7004            });
7005        }
7006        var btnTop10Soc = document.getElementById('btnSidebarTop10Social');
7007        if (btnTop10Soc) {
7008            btnTop10Soc.addEventListener('click', function () {
7009                openTop10SocialShareModal();
7010            });
7011        }
7012        var btnTop20Share = document.getElementById('detTop20Share');
7013        if (btnTop20Share) {
7014            btnTop20Share.addEventListener('click', function () {
7015                openDetectorRankingReportModal('top20');
7016            });
7017        }
7018        var btnTop30Share = document.getElementById('detTop30Share');
7019        if (btnTop30Share) {
7020            btnTop30Share.addEventListener('click', function () {
7021                openTop30RankingReportAfterPrefetch();
7022            });
7023        }
7024        var repClose = document.getElementById('detReportShareClose');
7025        var repBd = document.getElementById('detReportShareBackdrop');
7026        if (repClose) repClose.addEventListener('click', closeDetectorRankingReportModal);
7027        if (repBd) repBd.addEventListener('click', closeDetectorRankingReportModal);
7028
7029        var btnPrint = document.getElementById('detReportBtnPrint');
7030        if (btnPrint) {
7031            btnPrint.addEventListener('click', function () {
7032                window.print();
7033            });
7034        }
7035        var btnWa = document.getElementById('detReportBtnWhatsapp');
7036        if (btnWa) {
7037            btnWa.addEventListener('click', function () {
7038                var text = encodeURIComponent(detectorReportShareBodyText());
7039                window.open('https://wa.me/?text=' + text, '_blank', 'noopener,noreferrer');
7040            });
7041        }
7042        var btnMail = document.getElementById('detReportBtnEmail');
7043        if (btnMail) {
7044            btnMail.addEventListener('click', function () {
7045                var subj = encodeURIComponent(detectorReportShareTitle() + ' — Catellix');
7046                var body = encodeURIComponent(detectorReportShareBodyText());
7047                window.location.href = 'mailto:?subject=' + subj + '&body=' + body;
7048            });
7049        }
7050        var btnNative = document.getElementById('detReportBtnNative');
7051        if (btnNative && navigator.share) {
7052            btnNative.addEventListener('click', function () {
7053                navigator
7054                    .share({
7055                        title: detectorReportShareTitle(),
7056                        text: detectorReportShareBodyText(),
7057                    })
7058                    .catch(function () {});
7059            });
7060        } else if (btnNative) {
7061            btnNative.hidden = true;
7062        }
7063
7064        var t20Close = document.getElementById('detTop20Close');
7065        var t20Bd = document.getElementById('detTop20Backdrop');
7066        if (t20Close) t20Close.addEventListener('click', closeTop20StudioModal);
7067        if (t20Bd) t20Bd.addEventListener('click', closeTop20StudioModal);
7068
7069        document.addEventListener('keydown', function (e) {
7070            if (e.key !== 'Escape') return;
7071            var r = document.getElementById('detReportShareModal');
7072            if (r && r.classList.contains('open')) closeDetectorRankingReportModal();
7073            var t = document.getElementById('detTop20Modal');
7074            if (t && t.classList.contains('open')) closeTop20StudioModal();
7075            var soc = document.getElementById('detTop10SocialModal');
7076            if (soc && soc.classList.contains('open')) closeTop10SocialShareModal();
7077        });
7078    }
7079
7080    /** Regista partilha do cartão Top 10 no servidor (contador; e-mail interno a cada 1000). */
7081    function reportDetectorSocialCardShare(channel) {
7082        try {
7083            var origin = '';
7084            try {
7085                origin = window.location.origin || '';
7086            } catch (eO) {}
7087            var ch = typeof channel === 'string' ? channel.trim().slice(0, 32) : '';
7088            fetch(origin + '/api/detector/social-card-share', {
7089                method: 'POST',
7090                headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
7091                credentials: 'same-origin',
7092                body: JSON.stringify(ch ? { channel: ch } : {}),
7093            }).catch(function () {});
7094        } catch (eR) {}
7095    }
7096
7097    function waitTop10SocialExportReady(done, timeoutMs) {
7098        var cv = document.getElementById('detTop10SocialCanvas');
7099        var limit = typeof timeoutMs === 'number' ? timeoutMs : 60000;
7100        var deadline = Date.now() + limit;
7101        function tick() {
7102            try {
7103                if (cv && cv.dataset && cv.dataset.detExportReady === '1') {
7104                    done(true);
7105                    return;
7106                }
7107            } catch (e0) {}
7108            if (Date.now() > deadline) {
7109                done(false);
7110                return;
7111            }
7112            setTimeout(tick, 120);
7113        }
7114        tick();
7115    }
7116
7117    function top10SocialCanvasBlob(callback) {
7118        var cv = document.getElementById('detTop10SocialCanvas');
7119        if (!cv || typeof cv.toBlob !== 'function') {
7120            callback(null);
7121            return;
7122        }
7123        function runBlob() {
7124            cv.toBlob(function (blob) {
7125                callback(blob || null);
7126            }, 'image/png');
7127        }
7128        try {
7129            if (cv.dataset && cv.dataset.detExportReady === '1') {
7130                runBlob();
7131                return;
7132            }
7133        } catch (e1) {}
7134        waitTop10SocialExportReady(function (ok) {
7135            if (!ok) {
7136                callback(null);
7137                return;
7138            }
7139            runBlob();
7140        }, 60000);
7141    }
7142
7143    function top10SocialSharePayloadFromBlob(blob, cap) {
7144        var title =
7145            detTop10SocialSession && detTop10SocialSession.kind === 'games'
7146                ? __('js_t10_games_social_share_title')
7147                : __('js_t10_social_share_title');
7148        if (!blob) {
7149            return { title: title, text: cap, files: null };
7150        }
7151        var file = new File([blob], top10SocialCardPngBasename(), { type: 'image/png' });
7152        return { title: title, text: cap, files: [file] };
7153    }
7154
7155    function top10SocialTryShareNavigator(payload, onFallback) {
7156        if (!navigator.share) {
7157            onFallback();
7158            return;
7159        }
7160        if (payload.files && payload.files.length && navigator.canShare) {
7161            if (!navigator.canShare(payload)) {
7162                onFallback();
7163                return;
7164            }
7165        }
7166        navigator.share(payload).catch(function (err) {
7167            if (err && err.name === 'AbortError') return;
7168            onFallback();
7169        });
7170    }
7171
7172    function top10SocialDownloadBlob(blob) {
7173        if (!blob) return;
7174        var u = URL.createObjectURL(blob);
7175        var a = document.createElement('a');
7176        a.href = u;
7177        a.download = top10SocialCardPngBasename();
7178        a.click();
7179        setTimeout(function () {
7180            try {
7181                URL.revokeObjectURL(u);
7182            } catch (eR) {}
7183        }, 2500);
7184    }
7185
7186    function top10SocialOpenFacebookLinkShare(cap) {
7187        var urlPg = encodeURIComponent('https://catellix.com/detector');
7188        window.open(
7189            'https://www.facebook.com/sharer/sharer.php?u=' + urlPg + '&quote=' + encodeURIComponent(cap),
7190            '_blank',
7191            'noopener,noreferrer'
7192        );
7193    }
7194
7195    function wireTop10SocialShareModal() {
7196        if (document.documentElement.dataset.detTop10SocialWired === '1') return;
7197        document.documentElement.dataset.detTop10SocialWired = '1';
7198        var closeBtn = document.getElementById('detTop10SocialClose');
7199        var bd = document.getElementById('detTop10SocialBackdrop');
7200        if (closeBtn) closeBtn.addEventListener('click', closeTop10SocialShareModal);
7201        if (bd) bd.addEventListener('click', closeTop10SocialShareModal);
7202        var copyBtn = document.getElementById('detTop10SocialCopyCap');
7203        if (copyBtn) {
7204            copyBtn.addEventListener('click', function () {
7205                var rows = getTop10SocialShareRowsForModal();
7206                if (!rows.length) return;
7207                reportDetectorSocialCardShare('copy_caption');
7208                var cap = buildTop10SocialCaption(rows);
7209                if (navigator.clipboard && navigator.clipboard.writeText) {
7210                    navigator.clipboard.writeText(cap).catch(function () {
7211                        try {
7212                            alert(cap);
7213                        } catch (e1) {}
7214                    });
7215                } else {
7216                    try {
7217                        alert(cap);
7218                    } catch (e2) {}
7219                }
7220            });
7221        }
7222        var waBtn = document.getElementById('detTop10SocialWa');
7223        if (waBtn) {
7224            waBtn.addEventListener('click', function () {
7225                var rows = getTop10SocialShareRowsForModal();
7226                if (!rows.length) return;
7227                var cap = buildTop10SocialCaption(rows);
7228                try {
7229                    window.open('https://wa.me/?text=' + encodeURIComponent(cap), '_blank', 'noopener,noreferrer');
7230                } catch (eWa) {}
7231                reportDetectorSocialCardShare('whatsapp');
7232            });
7233        }
7234        var igBtn = document.getElementById('detTop10SocialIg');
7235        if (igBtn) {
7236            igBtn.addEventListener('click', function () {
7237                var rows = getTop10SocialShareRowsForModal();
7238                if (!rows.length) return;
7239                reportDetectorSocialCardShare('instagram');
7240                try {
7241                    window.open('https://www.instagram.com/create/select/', '_blank', 'noopener,noreferrer');
7242                } catch (eIg) {}
7243            });
7244        }
7245        var fbBtn = document.getElementById('detTop10SocialFb');
7246        if (fbBtn) {
7247            fbBtn.addEventListener('click', function () {
7248                var rows = getTop10SocialShareRowsForModal();
7249                if (!rows.length) return;
7250                var cap = buildTop10SocialCaption(rows);
7251                reportDetectorSocialCardShare('facebook');
7252                try {
7253                    top10SocialOpenFacebookLinkShare(cap);
7254                } catch (eFb) {}
7255            });
7256        }
7257        var saveBtn = document.getElementById('detTop10SocialSave');
7258        if (saveBtn) {
7259            saveBtn.addEventListener('click', function () {
7260                var cv = document.getElementById('detTop10SocialCanvas');
7261                if (!cv) return;
7262                reportDetectorSocialCardShare('save_png');
7263                function downloadUrl(u) {
7264                    var a = document.createElement('a');
7265                    a.href = u;
7266                    a.download = top10SocialCardPngBasename();
7267                    a.click();
7268                }
7269                function doSave() {
7270                    if (typeof cv.toBlob === 'function') {
7271                        cv.toBlob(function (blob) {
7272                            if (!blob) {
7273                                try {
7274                                    downloadUrl(cv.toDataURL('image/png'));
7275                                } catch (e0) {}
7276                                return;
7277                            }
7278                            var u = URL.createObjectURL(blob);
7279                            downloadUrl(u);
7280                            setTimeout(function () {
7281                                try {
7282                                    URL.revokeObjectURL(u);
7283                                } catch (eR) {}
7284                            }, 2500);
7285                        }, 'image/png');
7286                    } else {
7287                        try {
7288                            downloadUrl(cv.toDataURL('image/png'));
7289                        } catch (e3) {}
7290                    }
7291                }
7292                try {
7293                    if (cv.dataset && cv.dataset.detExportReady === '1') {
7294                        doSave();
7295                        return;
7296                    }
7297                } catch (eReady) {}
7298                var prevTxt = saveBtn.textContent;
7299                saveBtn.disabled = true;
7300                saveBtn.textContent = __('js_t10_social_preparing');
7301                waitTop10SocialExportReady(function (ok) {
7302                    saveBtn.disabled = false;
7303                    saveBtn.textContent = prevTxt;
7304                    if (ok) doSave();
7305                    else {
7306                        try {
7307                            alert(__('js_t10_social_card_failed'));
7308                        } catch (eA) {}
7309                    }
7310                }, 60000);
7311            });
7312        }
7313        var nat = document.getElementById('detTop10SocialNative');
7314        if (nat && navigator.share) {
7315            nat.addEventListener('click', function () {
7316                var rows = getTop10SocialShareRowsForModal();
7317                if (!rows.length) return;
7318                reportDetectorSocialCardShare('native_share');
7319                var cap = buildTop10SocialCaption(rows);
7320                var cv0 = document.getElementById('detTop10SocialCanvas');
7321                var shareTitle =
7322                    detTop10SocialSession && detTop10SocialSession.kind === 'games'
7323                        ? __('js_t10_games_social_share_title')
7324                        : __('js_t10_social_share_title');
7325                var runText = function () {
7326                    navigator.share({ title: shareTitle, text: cap }).catch(function () {});
7327                };
7328                function tryShareWithFile() {
7329                    top10SocialCanvasBlob(function (blob) {
7330                        var built = top10SocialSharePayloadFromBlob(blob, cap);
7331                        if (!built.files) {
7332                            runText();
7333                            return;
7334                        }
7335                        var payload = { title: built.title, text: built.text, files: built.files };
7336                        top10SocialTryShareNavigator(payload, runText);
7337                    });
7338                }
7339                try {
7340                    if (cv0 && cv0.dataset && cv0.dataset.detExportReady === '1') {
7341                        tryShareWithFile();
7342                        return;
7343                    }
7344                } catch (eNat0) {}
7345                var prevNat = nat.textContent;
7346                nat.disabled = true;
7347                nat.textContent = __('js_t10_social_preparing');
7348                waitTop10SocialExportReady(function (ok) {
7349                    nat.disabled = false;
7350                    nat.textContent = prevNat;
7351                    if (ok) tryShareWithFile();
7352                    else runText();
7353                }, 60000);
7354            });
7355        } else if (nat) {
7356            nat.hidden = true;
7357        }
7358    }
7359
7360    function wireTop30OutageModal() {
7361        if (document.documentElement.dataset.detTop30Wired === '1') return;
7362        document.documentElement.dataset.detTop30Wired = '1';
7363
7364        document.addEventListener(
7365            'click',
7366            function (e) {
7367                var t = e.target;
7368                var btn = t && t.closest ? t.closest('.det-js-top30-open') : null;
7369                if (!btn) return;
7370                e.preventDefault();
7371                if (top30State.debounceT) clearTimeout(top30State.debounceT);
7372                top30State.debounceT = setTimeout(function () {
7373                    top30State.debounceT = null;
7374                    openTop30RankingReportAfterPrefetch();
7375                }, TOP30_DEBOUNCE_MS);
7376            },
7377            false
7378        );
7379
7380        var closeBtn = document.getElementById('detTop30Close');
7381        var backdrop = document.getElementById('detTop30Backdrop');
7382        var modal = document.getElementById('detTop30Modal');
7383        if (closeBtn) {
7384            closeBtn.addEventListener('click', function () {
7385                closeTop30Modal();
7386            });
7387        }
7388        if (backdrop) {
7389            backdrop.addEventListener('click', function () {
7390                closeTop30Modal();
7391            });
7392        }
7393        if (modal) {
7394            modal.addEventListener('click', function (e) {
7395                if (e.target === backdrop) closeTop30Modal();
7396            });
7397        }
7398        document.addEventListener('keydown', function (e) {
7399            if (e.key !== 'Escape') return;
7400            var m = document.getElementById('detTop30Modal');
7401            if (m && m.classList.contains('open')) closeTop30Modal();
7402        });
7403    }
7404
7405    function refreshSidebarComplaintsTop10() {
7406        var sol = document.getElementById('detComplaintsTop10');
7407        var sub = document.getElementById('detComplaintsTop10Sub');
7408        if (!sol) return;
7409        wireSidebarComplaintsRanking();
7410        syncSidebarComplaintsToolbarLabels();
7411        var selSide = document.getElementById('selSidebarComplaintsRankingDays');
7412        if (selSide && selSide.dataset.detWired === '1') {
7413            selSide.value = String(VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1);
7414        }
7415        var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
7416        var rows = state.communityRankingRows;
7417        var dayLblMap = { 1: __('js_window_1d'), 7: __('js_window_7d'), 15: __('js_window_15d'), 30: __('js_window_30d') };
7418        var dayLbl = dayLblMap[days] || __('js_window_1d');
7419        var periodMap = { 1: __('js_comm_period_24h'), 7: __('js_comm_period_7d'), 15: __('js_comm_period_15d'), 30: __('js_comm_period_30d') };
7420        var periodLbl = periodMap[days] || (days + ' ' + __('js_relato_plural'));
7421        var subTxt;
7422        if (rows === undefined) {
7423            subTxt = __('js_comm_sub_loading_tpl').replace(/\{d\}/g, dayLbl);
7424        } else if (rows === null) {
7425            subTxt = __('js_comm_sub_err_tpl').replace(/\{d\}/g, dayLbl);
7426        } else {
7427            subTxt = __('js_comm_sub_ready_tpl').replace(/\{p\}/g, periodLbl);
7428        }
7429        if (sub) sub.textContent = subTxt;
7430
7431        if (rows === undefined) {
7432            syncDetComplaintsTop10ColumnHead(false);
7433            var sk = '';
7434            for (var si = 0; si < 5; si++) {
7435                sk +=
7436                    '<li class="det-sidebar-skel" aria-hidden="true"><span class="det-sb-skel-rank"></span><span class="det-sb-skel-logo"></span><span class="det-sb-skel-text"><span class="det-sb-skel-line"></span><span class="det-sb-skel-line det-sb-skel-line--short"></span></span></li>';
7437            }
7438            sol.innerHTML = sk;
7439            return;
7440        }
7441        if (rows === null) {
7442            syncDetComplaintsTop10ColumnHead(false);
7443            state.communityRankPrev = {};
7444            state._complaintsRankTrendPrev = {};
7445            sol.innerHTML =
7446                '<li class="det-sidebar-empty" role="status">' + esc(__('js_comm_rank_err_li')) + '</li>';
7447            syncComplaintsTop10BoardsAndCards();
7448            return;
7449        }
7450        var rowsWithVotes = rows.filter(function (row) {
7451            return row && (Number(row.vote_count) || 0) > 0;
7452        });
7453        if (!rowsWithVotes.length) {
7454            syncDetComplaintsTop10ColumnHead(false);
7455            state.communityRankPrev = {};
7456            state._complaintsRankTrendPrev = {};
7457            sol.innerHTML =
7458                '<li class="det-sidebar-empty" role="status">' + esc(__('js_sidebar_empty_comm')) + '</li>';
7459            syncComplaintsTop10BoardsAndCards();
7460            return;
7461        }
7462        var prevMap = state.communityRankPrev || {};
7463        var top = rowsWithVotes.slice(0, 10);
7464        var listHtml = top
7465            .map(function (row, i) {
7466                var pillDisp = complaintsRankingPillDisplay(row);
7467                var rnk = i + 1;
7468                var rankCls =
7469                    i === 0 ? 'det-sb-rank det-sb-rank--gold' : i === 1 ? 'det-sb-rank det-sb-rank--silver' : i === 2 ? 'det-sb-rank det-sb-rank--bronze' : 'det-sb-rank';
7470                var trendHtml = rankingTrendMarkHtml(row.service_id, rnk, prevMap);
7471                var svc = findSvc(row.service_id);
7472                var dom = svc && svc.logo_domain ? String(svc.logo_domain) : (row.logo_domain ? String(row.logo_domain) : '');
7473                var logo = detComplaintsRankLogoSrc(svc, row);
7474                var pillTitle = complaintsRankingPillTitle(row, pillDisp);
7475                var rowA11y =
7476                    esc(__('js_open_service_aria')) + ' ' + esc(row.name || row.service_id || '');
7477                return (
7478                    '<li class="det-sidebar-complaints-item"><button type="button" class="det-sidebar-line det-sidebar-line--rank det-sidebar-line--rank-main" data-svc-id="' +
7479                    esc(row.service_id) +
7480                    '" aria-label="' +
7481                    rowA11y +
7482                    '"><span class="det-sb-rank-wrap"><span class="' +
7483                    esc(rankCls) +
7484                    '">' +
7485                    esc(String(rnk)) +
7486                    '</span>' +
7487                    trendHtml +
7488                    '</span><img class="det-sb-logo" src="' +
7489                    esc(logo) +
7490                    '" alt="" width="36" height="36" loading="lazy" decoding="async"' +
7491                    imgLogoOnErrorAttr(dom) +
7492                    '><span class="det-sb-body"><span class="det-sb-name">' +
7493                    esc(row.name) +
7494                    '</span><span class="det-sb-meta">' +
7495                    communityRankingMetaLineHtml(row) +
7496                    '</span></span></button>' +
7497                    communityTop10ComplaintsRailHtml(row, pillDisp, pillTitle) +
7498                    '</li>'
7499                );
7500            })
7501            .join('');
7502        sol.innerHTML = listHtml;
7503        syncDetComplaintsTop10ColumnHead(true);
7504        state._complaintsRankTrendPrev = prevMap;
7505        state.communityRankPrev = snapshotCommunityRankPositions(rowsWithVotes);
7506        wireComplaintsTop10Clicks(sol);
7507        syncComplaintsTop10BoardsAndCards();
7508    }
7509
7510    /** Espaços na faixa inferior esquerda (sidebar); eco do catálogo ad_slots. */
7511    var AD_SLOTS_COUNT = 25;
7512    /** Lugares livres (UI); atualizado com /api/detector/catalog → ad_slots. */
7513    var AD_SLOTS_AVAILABLE = 22;
7514    /** Faixa fixa no canto superior direito (desktop): só slots 1–15. */
7515    var AD_SLOTS_RAIL_COUNT = 15;
7516    var AD_SLOTS_RAIL_AVAILABLE = 12;
7517    /**
7518     * Criativos fixos nos slots 1–3 quando o catálogo não traz local_ads_carousel
7519     * (cache SWR antigo, API sem deploy ou ficheiro JSON em falta no servidor).
7520     */
7521    var DETECTOR_DEFAULT_CAROUSEL_ADS = [
7522        {
7523            slot: 1,
7524            id: 'catellix_monitoring_sidebar',
7525            image_url: '/static/assets/logo-catellix%202.png',
7526            link_url: 'https://www.catellix.com/welcome',
7527            days_remaining: 30,
7528        },
7529        {
7530            slot: 2,
7531            id: 'catellix_kids_sidebar',
7532            image_url: '/static/assets/LogoCatellixCKids.png',
7533            link_url: 'https://www.catellix.com/planos-mobile',
7534            days_remaining: 30,
7535        },
7536        {
7537            slot: 3,
7538            id: 'hojemais_partner',
7539            image_url: '/static/detector-logos/hojemais_com_br.png',
7540            link_url: 'https://www.hojemais.com.br/',
7541            days_remaining: 30,
7542        },
7543    ];
7544
7545    /** Modal de contribuição: 6 posições premium (1 Catellix + 5 disponíveis). */
7546    var AD_PACK_MODAL_SLOTS_TOTAL = 6;
7547    var AD_PACK_MODAL_SLOTS_AVAILABLE = 5;
7548
7549    /**
7550     * Junta criativos da API com os defaults por número de slot.
7551     * Se a API só trouxer, por exemplo, os dois Catellix, o slot 3 (Hoje+) continua a ser preenchido pelo default —
7552     * o spotlight e o carrossel percorrem todos os preenchidos, não só os que vieram na rede.
7553     */
7554    function mergeDetectorCarouselSlotDefaults() {
7555        var fixedSlots = {};
7556        for (var dfi = 0; dfi < DETECTOR_DEFAULT_CAROUSEL_ADS.length; dfi++) {
7557            var d = DETECTOR_DEFAULT_CAROUSEL_ADS[dfi];
7558            if (!d) continue;
7559            var sl = parseInt(d.slot, 10);
7560            if (isNaN(sl) || sl < 1 || sl > AD_SLOTS_COUNT) continue;
7561            d.days_remaining = 30;
7562            fixedSlots[sl] = d;
7563        }
7564        var bySlot = {};
7565        var arr = state.detectorCarouselSlotAds || [];
7566        for (var ai = 0; ai < arr.length; ai++) {
7567            var a = arr[ai];
7568            if (!a) continue;
7569            var sn = parseInt(a.slot, 10);
7570            if (isNaN(sn) || sn < 1 || sn > AD_SLOTS_COUNT) continue;
7571            if (fixedSlots[sn]) continue;
7572            bySlot[sn] = a;
7573        }
7574        for (var fk in fixedSlots) {
7575            if (fixedSlots.hasOwnProperty(fk)) bySlot[fk] = fixedSlots[fk];
7576        }
7577        var merged = [];
7578        for (var s = 1; s <= AD_SLOTS_COUNT; s++) {
7579            if (bySlot[s]) merged.push(bySlot[s]);
7580        }
7581        state.detectorCarouselSlotAds = merged;
7582    }
7583
7584    var _detLocalAdTakeoverTimer = null;
7585    var _detLocalAdTakeoverWired = false;
7586    var _detLocalAdLoopTimer = null;
7587    var _detLocalAdCountdownTimer = null;
7588    var _detLocalAdAutoCloseAtMs = 0;
7589    var _detTopBarSpinTimer = null;
7590    var _detTopBarSpinWired = false;
7591    var TOP_BAR_LOCAL_SPIN_MS = 6000;
7592    var DET_LOCAL_AD_SHOW_MS = 6000;
7593    var DET_LOCAL_AD_GAP_MS = 6000;
7594    /** Parceiros na barra / faixa lateral só depois da intro do Top 10 (evita propaganda por cima). */
7595    var _detectorTop10IntroDone = false;
7596    var _detectorPromoScheduleToken = 0;
7597
7598    function shuffleDetectorAdArray(arr) {
7599        var a = arr.slice();
7600        for (var i = a.length - 1; i > 0; i--) {
7601            var j = Math.floor(Math.random() * (i + 1));
7602            var t = a[i];
7603            a[i] = a[j];
7604            a[j] = t;
7605        }
7606        return a;
7607    }
7608
7609    function teardownDetectorTopBarLocalSpin() {
7610        if (_detTopBarSpinTimer) {
7611            try {
7612                clearInterval(_detTopBarSpinTimer);
7613            } catch (eT) {
7614                /* ignore */
7615            }
7616            _detTopBarSpinTimer = null;
7617        }
7618    }
7619
7620    function initDetectorTopBarLocalSpin() {
7621        if (state._topBarDismissed) return;
7622        var wrap = document.getElementById('detTopBarLocalSpin');
7623        var track = document.getElementById('detTopBarCarouselTrack');
7624        if (!wrap || !track) return;
7625        if (detIsNarrowDetectorViewport()) {
7626            teardownDetectorTopBarLocalSpin();
7627            wrap.setAttribute('hidden', '');
7628            wrap.hidden = true;
7629            return;
7630        }
7631        teardownDetectorTopBarLocalSpin();
7632        var pool = [];
7633        var src = state.detectorCarouselSlotAds && state.detectorCarouselSlotAds.length
7634            ? state.detectorCarouselSlotAds
7635            : DETECTOR_DEFAULT_CAROUSEL_ADS;
7636        for (var pi = 0; pi < src.length; pi++) {
7637            var p = src[pi];
7638            if (!p) continue;
7639            var sn = parseInt(p.slot, 10);
7640            if (!isNaN(sn) && sn > AD_SLOTS_RAIL_COUNT) continue;
7641            var iu = String(p.image_url || '').trim();
7642            var lnk = String(p.link_url || '').trim();
7643            var pid = String(p.id || '').trim();
7644            if (!iu || !pid) continue;
7645            if (lnk.indexOf('http://') !== 0 && lnk.indexOf('https://') !== 0) continue;
7646            pool.push({ id: pid, image_url: iu, link_url: lnk });
7647        }
7648        if (!pool.length) {
7649            wrap.setAttribute('hidden', '');
7650            wrap.hidden = true;
7651            return;
7652        }
7653
7654        var cursor = pool.length > 1 ? Math.floor(Math.random() * pool.length) : 0;
7655        function atIdx(i) { return pool[((i % pool.length) + pool.length) % pool.length]; }
7656
7657        function makeItem(ad, pos) {
7658            var a = document.createElement('a');
7659            a.href = ad.link_url;
7660            a.target = '_blank';
7661            a.rel = 'noopener noreferrer sponsored';
7662            a.className = 'det-topbar-carousel__item' +
7663                (pos === 'center' ? ' det-topbar-carousel__item--center' : ' det-topbar-carousel__item--side');
7664            a.setAttribute('data-ad-id', ad.id);
7665            var frame = document.createElement('span');
7666            frame.className = 'det-topbar-carousel__frame';
7667            var img = document.createElement('img');
7668            img.className = 'det-topbar-carousel__img';
7669            img.src = ad.image_url;
7670            img.alt = '';
7671            img.width = pos === 'center' ? 42 : 32;
7672            img.height = pos === 'center' ? 42 : 32;
7673            img.decoding = 'async';
7674            frame.appendChild(img);
7675            a.appendChild(frame);
7676            a.addEventListener('click', function () { postLocalAdEvent(ad.id, 'click', 'right'); });
7677            return a;
7678        }
7679
7680        function renderState() {
7681            var left = atIdx(cursor - 1);
7682            var center = atIdx(cursor);
7683            var right = atIdx(cursor + 1);
7684            track.innerHTML = '';
7685            track.appendChild(makeItem(left, 'side'));
7686            track.appendChild(makeItem(center, 'center'));
7687            track.appendChild(makeItem(right, 'side'));
7688            setTimeout(function () { postLocalAdEvent(center.id, 'impression', 'right'); }, 120);
7689        }
7690
7691        renderState();
7692        wrap.removeAttribute('hidden');
7693        wrap.hidden = false;
7694
7695        if (detIsNarrowDetectorViewport() && !wrap.querySelector('.det-topbar-carousel__dismiss')) {
7696            wrap.style.position = 'relative';
7697            var dismissAds = document.createElement('button');
7698            dismissAds.type = 'button';
7699            dismissAds.className = 'det-topbar-carousel__dismiss det-ad-close-btn--pulse';
7700            dismissAds.setAttribute('aria-label', __('det_topbar_dismiss_aria'));
7701            dismissAds.innerHTML =
7702                '<span class="det-ad-close-btn__glyph" aria-hidden="true">\u00d7</span>';
7703            dismissAds.addEventListener('click', function (e) {
7704                e.preventDefault();
7705                e.stopPropagation();
7706                state._topBarDismissed = true;
7707                teardownDetectorTopBarLocalSpin();
7708                wrap.setAttribute('hidden', '');
7709                wrap.hidden = true;
7710            });
7711            wrap.appendChild(dismissAds);
7712        }
7713
7714        _detTopBarSpinTimer = setInterval(function () {
7715            cursor++;
7716            renderState();
7717        }, TOP_BAR_LOCAL_SPIN_MS);
7718    }
7719
7720    /**
7721     * Percorre suavemente os cartões da 1.ª fila do Top 10 (comunidade) antes de libertar publicidade local.
7722     * @param {number} [scheduleToken] — se definido, cancela passos quando `_detectorPromoScheduleToken` muda (SWR + rede).
7723     */
7724    function runDetectorTop10CommunityIntroThen(cb, scheduleToken) {
7725        var done = typeof cb === 'function' ? cb : function () {};
7726        function stale() {
7727            return scheduleToken != null && scheduleToken !== _detectorPromoScheduleToken;
7728        }
7729        function later(fn, ms) {
7730            window.setTimeout(function () {
7731                if (stale()) return;
7732                fn();
7733            }, ms);
7734        }
7735        if (
7736            typeof window.matchMedia === 'function' &&
7737            window.matchMedia('(prefers-reduced-motion: reduce)').matches
7738        ) {
7739            later(done, 120);
7740            return;
7741        }
7742        if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
7743            later(done, 280);
7744            return;
7745        }
7746        var host = document.getElementById('top10Cards');
7747        if (!host || host.hasAttribute('hidden')) {
7748            later(done, 320);
7749            return;
7750        }
7751        var row = host.querySelector('.top10-cards-row');
7752        if (!row) {
7753            later(done, 320);
7754            return;
7755        }
7756        function measureAndRun() {
7757            if (stale()) return;
7758            var max = row.scrollWidth - row.clientWidth;
7759            if (max < 12) {
7760                later(done, 1400);
7761                return;
7762            }
7763            var tiles = row.querySelectorAll('.tile[data-svc-id]');
7764            if (!tiles.length) {
7765                later(done, 900);
7766                return;
7767            }
7768            var n = Math.min(10, tiles.length);
7769            var i = 0;
7770            function step() {
7771                if (stale()) return;
7772                if (i >= n) {
7773                    try {
7774                        row.scrollTo({ left: 0, behavior: 'smooth' });
7775                    } catch (eSc) {
7776                        row.scrollLeft = 0;
7777                    }
7778                    later(done, 950);
7779                    return;
7780                }
7781                var t = tiles[i];
7782                try {
7783                    t.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
7784                } catch (e2) {
7785                    row.scrollLeft = Math.min(max, i * 168);
7786                }
7787                i++;
7788                later(step, 500);
7789            }
7790            try {
7791                row.scrollLeft = 0;
7792            } catch (e0) {
7793                /* ignore */
7794            }
7795            later(step, 420);
7796        }
7797        requestAnimationFrame(function () {
7798            if (stale()) return;
7799            requestAnimationFrame(measureAndRun);
7800        });
7801    }
7802
7803    function scheduleDetectorPromotionalUiAfterTop10() {
7804        function enable() {
7805            try {
7806                initDetectorAdSlotsCarousel();
7807            } catch (eCar) {
7808                /* ignore */
7809            }
7810            try {
7811                initDetectorAdSlotsCarouselRight();
7812            } catch (eCarR) {
7813                /* ignore */
7814            }
7815            try {
7816                initDetectorTopBarLocalSpin();
7817            } catch (eTb) {
7818                /* ignore */
7819            }
7820        }
7821        if (_detectorTop10IntroDone) {
7822            enable();
7823            return;
7824        }
7825        var token = ++_detectorPromoScheduleToken;
7826        runDetectorTop10CommunityIntroThen(function () {
7827            if (token !== _detectorPromoScheduleToken) return;
7828            _detectorTop10IntroDone = true;
7829            enable();
7830        }, token);
7831    }
7832
7833    function syncDetectorAdSlotsCountUi() {
7834        var row = document.getElementById('detAdSlotsCountRow');
7835        var capEl = document.getElementById('detAdSlotsCountCaption');
7836        var availEl = document.getElementById('detAdSlotsCountAvail');
7837        var totalEl = document.getElementById('detAdSlotsCountTotal');
7838        var hintEl = document.getElementById('detAdSlotsCountHint');
7839        var srEl = document.getElementById('detAdSlotsCountSr');
7840        var micro = document.getElementById('detAdSlotsMicroAdd');
7841        if (!row || !availEl || !totalEl) return;
7842        if (isMapaAdSlotsPage) {
7843            if (capEl) capEl.textContent = __('sidebar_ad_slots_count_caption');
7844            availEl.textContent = '01';
7845            totalEl.textContent = '25';
7846            if (hintEl) {
7847                hintEl.textContent = __('sidebar_ad_slots_count_hint_tpl')
7848                    .replace(/\{avail\}/g, '01')
7849                    .replace(/\{total\}/g, '25');
7850            }
7851            if (srEl) {
7852                srEl.textContent = __('sidebar_ad_slots_count_sr_tpl')
7853                    .replace(/\{avail\}/g, '1')
7854                    .replace(/\{total\}/g, '25');
7855            }
7856            if (micro) micro.setAttribute('aria-label', __('sidebar_ad_slots_micro_add_aria'));
7857            return;
7858        }
7859        if (capEl) capEl.textContent = __('sidebar_ad_slots_count_caption');
7860        var t = AD_SLOTS_COUNT;
7861        var a = AD_SLOTS_AVAILABLE;
7862        if (state.detectorAdSlots && typeof state.detectorAdSlots === 'object') {
7863            if (state.detectorAdSlots.total != null && !isNaN(Number(state.detectorAdSlots.total))) {
7864                t = Number(state.detectorAdSlots.total);
7865            }
7866            if (state.detectorAdSlots.available != null && !isNaN(Number(state.detectorAdSlots.available))) {
7867                a = Number(state.detectorAdSlots.available);
7868            }
7869        }
7870        if (t < 1) t = AD_SLOTS_COUNT;
7871        if (a < 0) a = 0;
7872        if (a > t) a = t;
7873        availEl.textContent = String(a);
7874        totalEl.textContent = String(t);
7875        if (hintEl) {
7876            hintEl.textContent = __('sidebar_ad_slots_count_hint_tpl')
7877                .replace(/\{avail\}/g, String(a))
7878                .replace(/\{total\}/g, String(t));
7879        }
7880        if (srEl) {
7881            srEl.textContent = __('sidebar_ad_slots_count_sr_tpl')
7882                .replace(/\{avail\}/g, String(a))
7883                .replace(/\{total\}/g, String(t));
7884        }
7885        if (micro) micro.setAttribute('aria-label', __('sidebar_ad_slots_micro_add_aria'));
7886        syncDetectorAdSlotsCountUiRight();
7887    }
7888
7889    function syncDetectorAdSlotsCountUiRight() {
7890        var row = document.getElementById('detAdSlotsCountRowR');
7891        var capEl = document.getElementById('detAdSlotsCountCaptionR');
7892        var availEl = document.getElementById('detAdSlotsCountAvailR');
7893        var totalEl = document.getElementById('detAdSlotsCountTotalR');
7894        var hintEl = document.getElementById('detAdSlotsCountHintR');
7895        var srEl = document.getElementById('detAdSlotsCountSrR');
7896        var micro = document.getElementById('detAdSlotsMicroAddR');
7897        if (!row || !availEl || !totalEl) return;
7898        var t = AD_SLOTS_RAIL_COUNT;
7899        var a = AD_SLOTS_RAIL_AVAILABLE;
7900        if (capEl) capEl.textContent = __('sidebar_ad_slots_count_caption');
7901        availEl.textContent = String(a);
7902        totalEl.textContent = String(t);
7903        if (hintEl) {
7904            hintEl.textContent = __('sidebar_ad_slots_count_hint_tpl')
7905                .replace(/\{avail\}/g, String(a))
7906                .replace(/\{total\}/g, String(t));
7907        }
7908        if (srEl) {
7909            srEl.textContent = __('sidebar_ad_slots_count_sr_tpl')
7910                .replace(/\{avail\}/g, String(a))
7911                .replace(/\{total\}/g, String(t));
7912        }
7913        if (micro) micro.setAttribute('aria-label', __('sidebar_ad_slots_micro_add_aria'));
7914    }
7915
7916    function applyDetectorAdSlotsFromCatalog(cat) {
7917        var as = cat && cat.ad_slots;
7918        if (as && typeof as === 'object') {
7919            var tt = parseInt(as.total, 10);
7920            var aa = parseInt(as.available, 10);
7921            var tOk = !isNaN(tt) && tt > 0;
7922            var aOk = !isNaN(aa) && aa >= 0;
7923            state.detectorAdSlots = {
7924                total: tOk ? Math.min(100, tt) : AD_SLOTS_COUNT,
7925                available: aOk ? aa : AD_SLOTS_AVAILABLE,
7926            };
7927            if (state.detectorAdSlots.available > state.detectorAdSlots.total) {
7928                state.detectorAdSlots.available = state.detectorAdSlots.total;
7929            }
7930            AD_SLOTS_AVAILABLE = state.detectorAdSlots.available;
7931        }
7932        var lap = cat && cat.local_ads_pool_size;
7933        var lapN = lap != null ? parseInt(lap, 10) : NaN;
7934        if (!isNaN(lapN) && lapN > 0) {
7935            state.localAdPoolSize = Math.min(100, lapN);
7936        }
7937        state.detectorCarouselSlotAds = [];
7938        var lac = cat && cat.local_ads_carousel;
7939        if (lac && Array.isArray(lac)) {
7940            for (var li = 0; li < lac.length; li++) {
7941                var it = lac[li];
7942                if (!it || typeof it !== 'object') continue;
7943                var sl = parseInt(it.slot, 10);
7944                var iu = String(it.image_url || '').trim();
7945                var lnk = String(it.link_url || '').trim();
7946                var iid = String(it.id || '').trim();
7947                if (isNaN(sl) || sl < 1 || !iu || !iid) continue;
7948                if (lnk.indexOf('http://') !== 0 && lnk.indexOf('https://') !== 0) continue;
7949                var dr = parseInt(it.days_remaining != null ? it.days_remaining : it.campaign_days_left, 10);
7950                if (isNaN(dr) || dr < 0) dr = 30;
7951                if (dr > 3660) dr = 3660;
7952                state.detectorCarouselSlotAds.push({
7953                    slot: sl,
7954                    id: iid,
7955                    image_url: iu,
7956                    link_url: lnk,
7957                    days_remaining: dr,
7958                });
7959            }
7960        }
7961        mergeDetectorCarouselSlotDefaults();
7962        syncDetectorAdSlotsCountUi();
7963    }
7964
7965    function postLocalAdEvent(adId, kind, strip) {
7966        if (!adId || !kind) return;
7967        var origin = window.location.origin || '';
7968        var body = { ad_id: adId, kind: kind };
7969        if (strip === 'left' || strip === 'right') body.strip = strip;
7970        /* same-origin: envia cookie CSRF;
7970 csrf.js define X-CSRF-Token (omit quebrava 403 e métricas ficavam a 0) */
7971        fetch(origin + '/api/detector/local-ad/event', {
7972            method: 'POST',
7973            headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
7974            credentials: 'same-origin',
7975            body: JSON.stringify(body),
7976            keepalive: true,
7977        }).catch(function () {});
7978    }
7979
7980    var DET_LOC_AD_SEEN_KEY = 'det_loc_ad_seen_ids_v2';
7981
7982    function localAdGetSeenIds() {
7983        try {
7984            var j = sessionStorage.getItem(DET_LOC_AD_SEEN_KEY);
7985            var a = j ? JSON.parse(j) : [];
7986            return Array.isArray(a) ? a : [];
7987        } catch (e) {
7988            return [];
7989        }
7990    }
7991
7992    function localAdSetSeenIds(arr) {
7993        try {
7994            sessionStorage.setItem(DET_LOC_AD_SEEN_KEY, JSON.stringify(arr || []));
7995        } catch (e) {}
7996    }
7997
7998    function localAdPrepareExcludeForFetch() {
7999        var ids = localAdGetSeenIds();
8000        var ps = state.localAdPoolSize || 0;
8001        if (ps > 0 && ids.length >= ps) {
8002            localAdSetSeenIds([]);
8003            return [];
8004        }
8005        return ids;
8006    }
8007
8008    function stopDetectorLocalAdTakeoverLoop() {
8009        if (_detLocalAdLoopTimer) {
8010            clearTimeout(_detLocalAdLoopTimer);
8011            _detLocalAdLoopTimer = null;
8012        }
8013    }
8014
8015    function scheduleDetectorLocalAdTakeoverLoop(delayMs) {
8016        stopDetectorLocalAdTakeoverLoop();
8017        var d = Math.max(0, Math.round(Number(delayMs) || 0));
8018        _detLocalAdLoopTimer = setTimeout(function () {
8019            _detLocalAdLoopTimer = null;
8020            fetchNextLocalAdAndMaybeShow();
8021        }, d);
8022    }
8023
8024    function startDetectorLocalAdTakeoverLoop() {
8025        scheduleDetectorLocalAdTakeoverLoop(280);
8026    }
8027
8028    function fetchNextLocalAdAndMaybeShow() {
8029        wireDetectorLocalAdTakeoverOnce();
8030        var ids = localAdPrepareExcludeForFetch();
8031        var q = ids.length ? '?exclude=' + encodeURIComponent(ids.join(',')) : '';
8032        var origin = window.location.origin || '';
8033        fetch(origin + '/api/detector/local-ad/next' + q, { credentials: 'omit' })
8034            .then(function (r) {
8035                return r.json().catch(function () {
8036                    return {};
8037                });
8038            })
8039            .then(function (data) {
8040                if (!data || !data.success) return;
8041                if (data.pool_size != null) state.localAdPoolSize = Number(data.pool_size) || 0;
8042                if (!data.ad || !data.ad.image_url) {
8043                    scheduleDetectorLocalAdTakeoverLoop(DET_LOCAL_AD_GAP_MS);
8044                    return;
8045                }
8046                var linkU = String(data.ad.link_url || '').trim();
8047                if (linkU.indexOf('http://') !== 0 && linkU.indexOf('https://') !== 0) {
8048                    scheduleDetectorLocalAdTakeoverLoop(DET_LOCAL_AD_GAP_MS);
8049                    return;
8050                }
8051                var dur = Number(data.duration_sec);
8052                if (isNaN(dur) || dur < 1) dur = DET_LOCAL_AD_SHOW_MS / 1000;
8053                dur = Math.max(1, Math.min(30, dur));
8054                dur = Math.max(dur, DET_LOCAL_AD_SHOW_MS / 1000);
8055                openDetectorLocalAdTakeover(data.ad, dur);
8056            })
8057            .catch(function () {
8058                scheduleDetectorLocalAdTakeoverLoop(DET_LOCAL_AD_GAP_MS);
8059            });
8060    }
8061
8062    function closeDetectorLocalAdTakeover() {
8063        if (_detLocalAdTakeoverTimer) {
8064            clearTimeout(_detLocalAdTakeoverTimer);
8065            _detLocalAdTakeoverTimer = null;
8066        }
8067        if (_detLocalAdCountdownTimer) {
8068            clearInterval(_detLocalAdCountdownTimer);
8069            _detLocalAdCountdownTimer = null;
8070        }
8071        var root = document.getElementById('detLocalAdTakeover');
8072        if (root && root._detLocalAdEsc) {
8073            document.removeEventListener('keydown', root._detLocalAdEsc);
8074            root._detLocalAdEsc = null;
8075        }
8076        if (!root) return;
8077        if (root.dataset) root.dataset.adId = '';
8078        root.classList.remove('det-local-ad-takeover--open');
8079        root.setAttribute('hidden', '');
8080        root.hidden = true;
8081        root.setAttribute('aria-hidden', 'true');
8082        var cd = document.getElementById('detLocalAdTakeoverCountdown');
8083        if (cd) cd.textContent = '';
8084        try {
8085            document.body.style.overflow = '';
8086        } catch (e) {}
8087        scheduleDetectorLocalAdTakeoverLoop(DET_LOCAL_AD_GAP_MS);
8088    }
8089
8090    function wireDetectorLocalAdTakeoverOnce() {
8091        if (_detLocalAdTakeoverWired) return;
8092        _detLocalAdTakeoverWired = true;
8093        var cls = document.getElementById('detLocalAdTakeoverClose');
8094        if (cls) cls.addEventListener('click', closeDetectorLocalAdTakeover);
8095        var cta = document.getElementById('detLocalAdTakeoverCta');
8096        if (cta) {
8097            cta.addEventListener('click', function () {
8098                var rid = document.getElementById('detLocalAdTakeover');
8099                var aid = rid && rid.dataset ? rid.dataset.adId : '';
8100                if (aid) postLocalAdEvent(aid, 'click');
8101                setTimeout(closeDetectorLocalAdTakeover, 80);
8102            });
8103        }
8104    }
8105
8106    function openDetectorLocalAdTakeover(ad, durationSec) {
8107        if (!ad || !ad.id) return;
8108        var imgU = String(ad.image_url || '').trim();
8109        var linkU = String(ad.link_url || '').trim();
8110        if (!imgU || (linkU.indexOf('http://') !== 0 && linkU.indexOf('https://') !== 0)) return;
8111        var root = document.getElementById('detLocalAdTakeover');
8112        var imgEl = document.getElementById('detLocalAdTakeoverImg');
8113        var cta = document.getElementById('detLocalAdTakeoverCta');
8114        var cls = document.getElementById('detLocalAdTakeoverClose');
8115        if (!root || !imgEl || !cta) return;
8116        var seen = localAdGetSeenIds();
8117        if (seen.indexOf(ad.id) === -1) {
8118            seen.push(ad.id);
8119            localAdSetSeenIds(seen);
8120        }
8121        root.dataset.adId = ad.id;
8122        imgEl.src = imgU;
8123        imgEl.alt = __('det_local_ad_img_alt');
8124        cta.href = linkU;
8125        cta.textContent = __('det_local_ad_cta');
8126        if (cls) cls.setAttribute('aria-label', __('det_local_ad_close_aria'));
8127        root.removeAttribute('hidden');
8128        root.hidden = false;
8129        root.classList.add('det-local-ad-takeover--open');
8130        root.setAttribute('aria-hidden', 'false');
8131        try {
8132            document.body.style.overflow = 'hidden';
8133        } catch (e1) {}
8134        function onEsc(ev) {
8135            if (ev.key === 'Escape') closeDetectorLocalAdTakeover();
8136        }
8137        root._detLocalAdEsc = onEsc;
8138        document.addEventListener('keydown', onEsc);
8139        setTimeout(function () {
8140            postLocalAdEvent(ad.id, 'impression');
8141        }, 160);
8142        var cd = document.getElementById('detLocalAdTakeoverCountdown');
8143        var ms = Math.round(Number(durationSec) * 1000);
8144        if (!ms || ms < DET_LOCAL_AD_SHOW_MS) ms = DET_LOCAL_AD_SHOW_MS;
8145        _detLocalAdAutoCloseAtMs = Date.now() + ms;
8146        function renderCountdown() {
8147            if (!cd) return;
8148            var remain = Math.max(0, _detLocalAdAutoCloseAtMs - Date.now());
8149            var sec = Math.max(0, Math.ceil(remain / 1000));
8150            cd.textContent = __('det_local_ad_countdown_tpl').replace(/\{s\}/g, String(sec));
8151        }
8152        if (_detLocalAdCountdownTimer) {
8153            clearInterval(_detLocalAdCountdownTimer);
8154            _detLocalAdCountdownTimer = null;
8155        }
8156        renderCountdown();
8157        _detLocalAdCountdownTimer = setInterval(renderCountdown, 250);
8158        _detLocalAdTakeoverTimer = setTimeout(closeDetectorLocalAdTakeover, ms);
8159    }
8160    var AD_PACK_MAX_DAYS = 30;
8161    /** R$ por dia — faixa direita (premium). */
8162    var AD_PACK_PRICE_RIGHT_PER_DAY = 20;
8163    /** R$ por dia — faixa esquerda. */
8164    var AD_PACK_PRICE_LEFT_PER_DAY = 10;
8165    /** Preço activo no modal; muda conforme a origem do clique. */
8166    var _adPackActivePrice = AD_PACK_PRICE_RIGHT_PER_DAY;
8167    var AD_PACK_PROMO_DISCOUNT_PCT = 20.0;
8168    var AD_PACK_TABLE_DAYS = [1, 5, 10, 15, 30];
8169    var _detAdPixPollTimer = null;
8170    var _detAdPixPaymentId = null;
8171    var _detAdPixEmail = null;
8172    var _detAdPackCheckoutDays = 30;
8173    var _detAdDemoReportClientPid = null;
8174    var _detAdPackPreviewObjUrl = null;
8175
8176    function normalizeDetAdPackTargetUrl(raw) {
8177        var s = String(raw || '').trim();
8178        if (!s) return '';
8179        var uStr = s;
8180        if (!/^https?:\/\//i.test(uStr)) uStr = 'https://' + uStr;
8181        try {
8182            var u = new URL(uStr);
8183            if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
8184            return u.href;
8185        } catch (eNorm) {
8186            return '';
8187        }
8188    }
8189
8190    function clearDetAdPackCreativePreview() {
8191        if (_detAdPackPreviewObjUrl) {
8192            try {
8193                URL.revokeObjectURL(_detAdPackPreviewObjUrl);
8194            } catch (eRev) {
8195                /* ignore */
8196            }
8197            _detAdPackPreviewObjUrl = null;
8198        }
8199        var prevImg = document.getElementById('detAdPackPreviewImg');
8200        var ph = document.getElementById('detAdPackPreviewPlaceholder');
8201        var slot = document.getElementById('detAdPackPreviewSlot');
8202        if (prevImg) {
8203            prevImg.removeAttribute('src');
8204            prevImg.src = '';
8205            prevImg.setAttribute('hidden', '');
8206            prevImg.hidden = true;
8207        }
8208        if (slot) slot.classList.remove('det-ad-pack-preview-slot--has-img');
8209        if (ph) {
8210            ph.removeAttribute('hidden');
8211            ph.hidden = false;
8212        }
8213    }
8214
8215    function updateDetAdPackPreviewFromFile(fileInp) {
8216        var prevImg = document.getElementById('detAdPackPreviewImg');
8217        var ph = document.getElementById('detAdPackPreviewPlaceholder');
8218        var slot = document.getElementById('detAdPackPreviewSlot');
8219        if (_detAdPackPreviewObjUrl) {
8220            try {
8221                URL.revokeObjectURL(_detAdPackPreviewObjUrl);
8222            } catch (eR) {
8223                /* ignore */
8224            }
8225            _detAdPackPreviewObjUrl = null;
8226        }
8227        if (!fileInp || !fileInp.files || !fileInp.files[0]) {
8228            if (prevImg) {
8229                prevImg.removeAttribute('src');
8230                prevImg.src = '';
8231                prevImg.setAttribute('hidden', '');
8232                prevImg.hidden = true;
8233            }
8234            if (slot) slot.classList.remove('det-ad-pack-preview-slot--has-img');
8235            if (ph) {
8236                ph.removeAttribute('hidden');
8237                ph.hidden = false;
8238            }
8239            return;
8240        }
8241        _detAdPackPreviewObjUrl = URL.createObjectURL(fileInp.files[0]);
8242        if (prevImg) {
8243            prevImg.src = _detAdPackPreviewObjUrl;
8244            prevImg.removeAttribute('hidden');
8245            prevImg.hidden = false;
8246            prevImg.setAttribute('alt', __('ad_pack_creative_label'));
8247        }
8248        if (ph) ph.hidden = true;
8249        if (slot) slot.classList.add('det-ad-pack-preview-slot--has-img');
8250    }
8251
8252    function stopDetAdPixPoll() {
8253        if (_detAdPixPollTimer) {
8254            clearInterval(_detAdPixPollTimer);
8255            _detAdPixPollTimer = null;
8256        }
8257    }
8258
8259    function resetDetAdPixPanel() {
8260        stopDetAdPixPoll();
8261        _detAdPixPaymentId = null;
8262        _detAdPixEmail = null;
8263        var footCta = document.getElementById('detAdPackFootCta');
8264        var postPay = document.getElementById('detAdPackPostPay');
8265        if (footCta && (!postPay || postPay.hidden)) {
8266            footCta.removeAttribute('hidden');
8267            footCta.hidden = false;
8268        }
8269        var panel = document.getElementById('detAdPackPixPanel');
8270        if (panel) {
8271            panel.setAttribute('hidden', '');
8272            panel.hidden = true;
8273        }
8274        var code = document.getElementById('detAdPackPixCode');
8275        if (code) code.value = '';
8276        var qr = document.getElementById('detAdPackPixQrImg');
8277        if (qr) {
8278            qr.src = '';
8279            qr.removeAttribute('alt');
8280        }
8281        var wrap = document.getElementById('detAdPackPixQrWrap');
8282        if (wrap) {
8283            wrap.setAttribute('hidden', '');
8284            wrap.hidden = true;
8285        }
8286        var tk = document.getElementById('detAdPackPixTicket');
8287        if (tk) {
8288            tk.setAttribute('hidden', '');
8289            tk.hidden = true;
8290            tk.href = '#';
8291        }
8292        var st = document.getElementById('detAdPackPixStatus');
8293        if (st) st.textContent = '';
8294    }
8295
8296    function resetAdPackPurchaseUI(fullReset) {
8297        var post = document.getElementById('detAdPackPostPay');
8298        var payRow = document.getElementById('detAdPackPayRow');
8299        var footCta = document.getElementById('detAdPackFootCta');
8300        var est = document.getElementById('detAdPackEstimate');
8301        var chkFields = document.getElementById('detAdPackCheckoutFields');
8302        var f = document.getElementById('detAdPackCreativeFile');
8303        var cmsg = document.getElementById('detAdPackCreativeMsg');
8304        var csub = document.getElementById('detAdPackCreativeSubmit');
8305        if (post) {
8306            post.setAttribute('hidden', '');
8307            post.hidden = true;
8308        }
8309        if (payRow) {
8310            payRow.removeAttribute('hidden');
8311            payRow.hidden = false;
8312        }
8313        if (footCta) {
8314            footCta.removeAttribute('hidden');
8315            footCta.hidden = false;
8316        }
8317        if (est) {
8318            est.removeAttribute('hidden');
8319            est.hidden = false;
8320        }
8321        if (chkFields) {
8322            chkFields.removeAttribute('hidden');
8323            chkFields.hidden = false;
8324        }
8325        clearDetAdPackCreativePreview();
8326        if (f) f.value = '';
8327        var urlF = document.getElementById('detAdPackCreativeUrl');
8328        if (urlF) {
8329            urlF.value = '';
8330            urlF.disabled = false;
8331        }
8332        var pickB = document.getElementById('detAdPackPickImageBtn');
8333        if (pickB) pickB.disabled = false;
8334        if (cmsg) {
8335            cmsg.setAttribute('hidden', '');
8336            cmsg.textContent = '';
8337            cmsg.style.color = '';
8338        }
8339        if (csub) csub.disabled = false;
8340        if (fullReset) {
8341            _detAdDemoReportClientPid = null;
8342        }
8343    }
8344
8345    function enterAdPackPurchasedMode() {
8346        var post = document.getElementById('detAdPackPostPay');
8347        var payRow = document.getElementById('detAdPackPayRow');
8348        var footCta = document.getElementById('detAdPackFootCta');
8349        var est = document.getElementById('detAdPackEstimate');
8350        var chkFields = document.getElementById('detAdPackCheckoutFields');
8351        var pix = document.getElementById('detAdPackPixPanel');
8352        if (payRow) {
8353            payRow.setAttribute('hidden', '');
8354            payRow.hidden = true;
8355        }
8356        if (footCta) {
8357            footCta.setAttribute('hidden', '');
8358            footCta.hidden = true;
8359        }
8360        if (est) {
8361            est.setAttribute('hidden', '');
8362            est.hidden = true;
8363        }
8364        if (chkFields) {
8365            chkFields.setAttribute('hidden', '');
8366            chkFields.hidden = true;
8367        }
8368        if (pix) {
8369            pix.setAttribute('hidden', '');
8370            pix.hidden = true;
8371        }
8372        if (post) {
8373            post.removeAttribute('hidden');
8374            post.hidden = false;
8375        }
8376        var f = document.getElementById('detAdPackCreativeFile');
8377        var csub = document.getElementById('detAdPackCreativeSubmit');
8378        var donePid = _detAdPixPaymentId || '';
8379        var creativeDone = false;
8380        if (donePid) {
8381            try {
8382                creativeDone = !!sessionStorage.getItem('det_ad_creative_done_' + donePid);
8383            } catch (eSt) {
8384                creativeDone = false;
8385            }
8386        }
8387        if (f) f.disabled = creativeDone;
8388        var urlF = document.getElementById('detAdPackCreativeUrl');
8389        if (urlF) urlF.disabled = creativeDone;
8390        var pickB2 = document.getElementById('detAdPackPickImageBtn');
8391        if (pickB2) pickB2.disabled = creativeDone;
8392        if (csub) csub.disabled = creativeDone;
8393    }
8394
8395    function persistAdPackUploadPending() {
8396        if (!_detAdPixPaymentId || !_detAdPixEmail) return;
8397        try {
8398            sessionStorage.setItem(
8399                'det_ad_upload_pending',
8400                JSON.stringify({
8401                    pid: _detAdPixPaymentId,
8402                    email: _detAdPixEmail,
8403                    days: _detAdPackCheckoutDays,
8404                })
8405            );
8406        } catch (e1) {
8407            /* ignore */
8408        }
8409    }
8410
8411    function tryRestorePendingAdPackUpload() {
8412        try {
8413            var raw = sessionStorage.getItem('det_ad_upload_pending');
8414            if (!raw) return;
8415            var o = JSON.parse(raw);
8416            if (!o || !o.pid || !o.email) return;
8417            if (sessionStorage.getItem('det_ad_creative_done_' + o.pid)) return;
8418            _detAdPixPaymentId = o.pid;
8419            _detAdPixEmail = o.email;
8420            _detAdPackCheckoutDays = Math.max(1, Math.min(AD_PACK_MAX_DAYS, Math.round(Number(o.days)) || 30));
8421            enterAdPackPurchasedMode();
8422        } catch (e2) {
8423            /* ignore */
8424        }
8425    }
8426
8427    function sendDetectorAdDemoReportIfNeeded(pid, email, days) {
8428        if (!pid || !email) return;
8429        if (_detAdDemoReportClientPid === pid) return;
8430        _detAdDemoReportClientPid = pid;
8431        var d = Math.max(1, Math.min(AD_PACK_MAX_DAYS, Math.round(Number(days)) || 30));
8432        adPackPostJson('/api/detector/ad-send-demo-report', {
8433            payment_id: pid,
8434            email: email,
8435            days: d,
8436        }).then(function (res) {
8437            var d0 = res.data || {};
8438            if (!res.okHttp || !d0.success) {
8439                var m = document.getElementById('detAdPackFormMsg');
8440                if (m) {
8441                    m.removeAttribute('hidden');
8442                    m.textContent = __('ad_pack_demo_report_err');
8443                    m.style.color = '#b45309';
8444                }
8445            }
8446        });
8447    }
8448
8449    function adPackPostJson(path, body) {
8450        var origin = window.location.origin || '';
8451        return fetch(origin + path, {
8452            method: 'POST',
8453            headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
8454            credentials: 'same-origin',
8455            body: JSON.stringify(body),
8456        }).then(function (r) {
8457            return r.text().then(function (t) {
8458                var data = {};
8459                if (t && String(t).trim()) {
8460                    try {
8461                        data = JSON.parse(t);
8462                    } catch (eJson) {
8463                        data = {};
8464                    }
8465                }
8466                return { status: r.status, okHttp: r.ok, data: data };
8467            });
8468        });
8469    }
8470
8471    function pollDetAdPixOnce() {
8472        if (!_detAdPixPaymentId || !_detAdPixEmail) return;
8473        adPackPostJson('/api/detector/ad-pix-status', {
8474            payment_id: _detAdPixPaymentId,
8475            email: _detAdPixEmail,
8476        }).then(function (res) {
8477            var d = res.data || {};
8478            var stEl = document.getElementById('detAdPackPixStatus');
8479            if (res.okHttp && d.success && d.approved) {
8480                stopDetAdPixPoll();
8481                if (stEl) stEl.textContent = __('ad_pack_pix_approved');
8482                var msg2 = document.getElementById('detAdPackFormMsg');
8483                if (msg2) {
8484                    msg2.removeAttribute('hidden');
8485                    msg2.textContent = __('ad_pack_pix_approved');
8486                    msg2.style.color = '';
8487                }
8488                var daysInp2 = document.getElementById('detAdPackInputDays');
8489                var dCh =
8490                    Math.max(
8491                        1,
8492                        Math.min(
8493                            AD_PACK_MAX_DAYS,
8494                            Math.round(Number(daysInp2 && daysInp2.value)) || _detAdPackCheckoutDays
8495                        )
8496                    );
8497                sendDetectorAdDemoReportIfNeeded(_detAdPixPaymentId, _detAdPixEmail, dCh);
8498                persistAdPackUploadPending();
8499                enterAdPackPurchasedMode();
8500            }
8501        });
8502    }
8503
8504    function detectorAdPackPriceForDays(days) {
8505        var d = Math.max(1, Math.min(AD_PACK_MAX_DAYS, Math.round(Number(days)) || 0));
8506        if (!d) d = 1;
8507        var ppd = _adPackActivePrice || AD_PACK_PRICE_RIGHT_PER_DAY;
8508        var base = Math.round(ppd * d * 100) / 100;
8509        var pct = d <= 1 ? 0 : Math.round((d - 1) / (AD_PACK_MAX_DAYS - 1) * AD_PACK_PROMO_DISCOUNT_PCT * 10) / 10;
8510        pct = Math.min(pct, AD_PACK_PROMO_DISCOUNT_PCT);
8511        var promo = pct > 0;
8512        var finalVal = promo ? Math.round(base * (1 - pct / 100) * 100) / 100 : base;
8513        return {
8514            days: d,
8515            base: base,
8516            final: finalVal,
8517            promo: promo,
8518            discountPct: pct,
8519        };
8520    }
8521
8522    function detectorFormatBrl(amount) {
8523        var loc =
8524            typeof i18nSortLocale === 'function' && i18nSortLocale() === 'en' ? 'en-US' : 'pt-BR';
8525        return Number(amount).toLocaleString(loc, { style: 'currency', currency: 'BRL' });
8526    }
8527
8528    function syncDetectorAdPackEstimate() {
8529        var daysInp = document.getElementById('detAdPackInputDays');
8530        var est = document.getElementById('detAdPackEstimate');
8531        if (!daysInp || !est) return;
8532        var raw = Number(daysInp.value);
8533        if (daysInp.value === '' || daysInp.value === '-' || isNaN(raw)) {
8534            est.innerHTML = '';
8535            return;
8536        }
8537        var d = Math.max(1, Math.min(AD_PACK_MAX_DAYS, Math.round(raw)));
8538        var priceObj = detectorAdPackPriceForDays(d);
8539        var priceTxt = esc(detectorFormatBrl(priceObj.final));
8540        if (priceObj.promo) {
8541            var pN = priceObj.discountPct;
8542            var pctStr = '-' + (pN % 1 === 0 ? pN.toFixed(0) : pN.toFixed(1)) + '%';
8543            var tierCls = pN >= 15 ? ' det-ad-pack-price-badge--hot' : pN >= 8 ? ' det-ad-pack-price-badge--warm' : '';
8544            var fire = pN >= 15 ? ' 🔥' : '';
8545            priceTxt +=
8546                ' <span class="det-ad-pack-price--old" style="font-size:0.85em;">' +
8547                esc(detectorFormatBrl(priceObj.base)) +
8548                '</span> ' +
8549                '<span class="det-ad-pack-price-badge' + tierCls + '" style="vertical-align:middle;">' +
8550                esc(pctStr) + fire +
8551                '</span>';
8552        }
8553        est.innerHTML = esc(__('ad_pack_estimate_tpl'))
8554            .replace(/\{days\}/g, esc(String(d)))
8555            .replace(/\{price\}/g, priceTxt);
8556    }
8557
8558    function refreshDetectorAdPackModalContent() {
8559        var baseEl = document.getElementById('detAdPackBaseNote');
8560        var tb = document.getElementById('detAdPackTableBody');
8561        var daysInp = document.getElementById('detAdPackInputDays');
8562        if (daysInp) {
8563            daysInp.setAttribute('max', String(AD_PACK_MAX_DAYS));
8564            daysInp.setAttribute('min', '1');
8565        }
8566        if (baseEl) {
8567            var ppd = _adPackActivePrice || AD_PACK_PRICE_RIGHT_PER_DAY;
8568            var p30 = detectorAdPackPriceForDays(AD_PACK_MAX_DAYS);
8569            baseEl.textContent = __('ad_pack_base_note_tpl')
8570                .replace(/\{dayrate\}/g, detectorFormatBrl(ppd))
8571                .replace(/\{days\}/g, String(AD_PACK_MAX_DAYS))
8572                .replace(/\{full30\}/g, detectorFormatBrl(p30.base))
8573                .replace(/\{final30\}/g, detectorFormatBrl(p30.final));
8574        }
8575        var stripN = document.getElementById('detAdPackLeftStripNotice');
8576        if (stripN) {
8577            var isLeft = _adPackActivePrice === AD_PACK_PRICE_LEFT_PER_DAY;
8578            if (isLeft) {
8579                stripN.textContent = __('ad_pack_right_strip_notice');
8580            } else {
8581                stripN.textContent = __('ad_pack_left_strip_notice');
8582            }
8583        }
8584        var isLeftCtx = _adPackActivePrice === AD_PACK_PRICE_LEFT_PER_DAY;
8585        var slotsPill = document.getElementById('detAdPackSlotsPill');
8586        if (slotsPill) {
8587            if (isMapaAdSlotsPage && !isLeftCtx) {
8588                slotsPill.textContent = __('ad_pack_slots_tpl')
8589                    .replace(/\{avail\}/g, '01')
8590                    .replace(/\{total\}/g, '25');
8591            } else if (isLeftCtx) {
8592                slotsPill.textContent = __('ad_pack_slots_tpl')
8593                    .replace(/\{avail\}/g, String(AD_SLOTS_AVAILABLE))
8594                    .replace(/\{total\}/g, String(AD_SLOTS_COUNT));
8595            } else {
8596                slotsPill.textContent = __('ad_pack_slots_tpl')
8597                    .replace(/\{avail\}/g, String(AD_SLOTS_RAIL_AVAILABLE))
8598                    .replace(/\{total\}/g, String(AD_SLOTS_RAIL_COUNT));
8599            }
8600        }
8601        if (tb) {
8602            tb.innerHTML = AD_PACK_TABLE_DAYS.map(function (d) {
8603                var p = detectorAdPackPriceForDays(d);
8604                var priceHtml = '';
8605                if (p.promo) {
8606                    var pctNum = p.discountPct;
8607                    var badgeTxt = '-' + (pctNum % 1 === 0 ? pctNum.toFixed(0) : pctNum.toFixed(1)) + '%';
8608                    var tierCls = pctNum >= 15 ? ' det-ad-pack-price-badge--hot'
8609                        : pctNum >= 8 ? ' det-ad-pack-price-badge--warm' : '';
8610                    var fireIcon = pctNum >= 15 ? ' 🔥' : '';
8611                    priceHtml =
8612                        '<span class="det-ad-pack-price">' +
8613                        '<span class="det-ad-pack-price--old">' +
8614                        esc(detectorFormatBrl(p.base)) +
8615                        '</span>' +
8616                        '<span class="det-ad-pack-price--new">' +
8617                        esc(detectorFormatBrl(p.final)) +
8618                        '</span>' +
8619                        '<span class="det-ad-pack-price-badge' + tierCls + '">' +
8620                        esc(badgeTxt) + fireIcon +
8621                        '</span>' +
8622                        '</span>';
8623                } else {
8624                    priceHtml =
8625                        '<span class="det-ad-pack-price det-ad-pack-price--new">' +
8626                        esc(detectorFormatBrl(p.final)) +
8627                        '</span>';
8628                }
8629                return (
8630                    '<tr><td>' + esc(String(d)) + '</td><td>' + priceHtml + '</td></tr>'
8631                );
8632            }).join('');
8633        }
8634        var mobileBadge = document.getElementById('detAdRightMobileBadge');
8635        var mobileSeal = document.getElementById('detAdPackMobilePremiumSeal');
8636        var isRightCtx = _adPackActivePrice !== AD_PACK_PRICE_LEFT_PER_DAY;
8637        if (mobileBadge) {
8638            mobileBadge.hidden = !isRightCtx;
8639        }
8640        if (mobileSeal) {
8641            mobileSeal.hidden = !isRightCtx;
8642            var sealText = document.getElementById('detAdPackMobileSealText');
8643            if (sealText) sealText.textContent = __('ad_pack_mobile_premium_seal');
8644        }
8645        var rpTitle = document.getElementById('detAdPackReportTitle');
8646        if (rpTitle) rpTitle.textContent = __('ad_pack_report_title');
8647        var rpThM = document.getElementById('detAdPackReportThMetric');
8648        if (rpThM) rpThM.textContent = __('ad_pack_report_th_metric');
8649        var rpThV = document.getElementById('detAdPackReportThValue');
8650        if (rpThV) rpThV.textContent = __('ad_pack_report_th_value');
8651        var rpFoot = document.getElementById('detAdPackReportFooter');
8652        if (rpFoot) rpFoot.textContent = __('ad_pack_report_footer');
8653        var rpTbody = document.getElementById('detAdPackReportTbody');
8654        if (rpTbody) {
8655            var isLeftRp = _adPackActivePrice === AD_PACK_PRICE_LEFT_PER_DAY;
8656            var exImpr = isLeftRp ? '4.230' : '8.710';
8657            var exClk = isLeftRp ? '187' : '412';
8658            var exCtr = isLeftRp ? '4,4%' : '4,7%';
8659            var exDay = '12 / 30';
8660            var exRemain = '18';
8661            var exStrip = isLeftRp ? __('ad_pack_report_strip_left') : __('ad_pack_report_strip_right');
8662            rpTbody.innerHTML =
8663                '<tr><td>' + esc(__('ad_pack_report_row_impressions')) + '</td><td class="det-rp-val">' + exImpr + '</td></tr>' +
8664                '<tr><td>' + esc(__('ad_pack_report_row_clicks')) + '</td><td class="det-rp-val">' + exClk + '</td></tr>' +
8665                '<tr><td>' + esc(__('ad_pack_report_row_ctr')) + '</td><td class="det-rp-val">' + exCtr + '</td></tr>' +
8666                '<tr><td>' + esc(__('ad_pack_report_row_day')) + '</td><td class="det-rp-val">' + exDay + '</td></tr>' +
8667                '<tr><td>' + esc(__('ad_pack_report_row_remaining')) + '</td><td class="det-rp-val">' + exRemain + '</td></tr>' +
8668                '<tr><td>' + esc(__('ad_pack_report_row_strip')) + '</td><td class="det-rp-val">' + esc(exStrip) + '</td></tr>';
8669        }
8670        syncDetectorAdPackEstimate();
8671        var pickAria = document.getElementById('detAdPackPickImageBtn');
8672        if (pickAria) pickAria.setAttribute('aria-label', __('ad_pack_pick_image'));
8673    }
8674
8675    function detectorAdPackSourceForCarouselSfx(sfx) {
8676        if (isMapaAdSlotsPage) return 'right';
8677        return sfx === 'R' ? 'right' : 'left';
8678    }
8679
8680    function openDetectorAdPackModal(source) {
8681        if (source === 'left') {
8682            _adPackActivePrice = AD_PACK_PRICE_LEFT_PER_DAY;
8683        } else if (source === 'right') {
8684            _adPackActivePrice = AD_PACK_PRICE_RIGHT_PER_DAY;
8685        }
8686        var modal = document.getElementById('detAdPackModal');
8687        if (!modal) return;
8688        resetDetAdPixPanel();
8689        tryRestorePendingAdPackUpload();
8690        var post = document.getElementById('detAdPackPostPay');
8691        if (!post || post.hidden) {
8692            resetAdPackPurchaseUI(true);
8693        }
8694        refreshDetectorAdPackModalContent();
8695        try {
8696            resetDetectorAdPackEnterprise3dTilt();
8697        } catch (eEnt3d) {
8698            /* ignore */
8699        }
8700        var msg = document.getElementById('detAdPackFormMsg');
8701        if (msg) {
8702            msg.setAttribute('hidden', '');
8703            msg.textContent = '';
8704            msg.style.color = '';
8705        }
8706        modal.classList.add('open');
8707        modal.removeAttribute('hidden');
8708        modal.setAttribute('aria-hidden', 'false');
8709    }
8710
8711    function closeDetectorAdPackModal() {
8712        stopDetAdPixPoll();
8713        var modal = document.getElementById('detAdPackModal');
8714        if (!modal) return;
8715        modal.classList.remove('open');
8716        modal.setAttribute('hidden', '');
8717        modal.setAttribute('aria-hidden', 'true');
8718    }
8719
8720    function wireDetectorAdPackEnterprise3dOnce() {
8721        var root = document.getElementById('detAdPackEnterpriseCard');
8722        var rotate = document.getElementById('detAdPackEnterpriseRotate');
8723        if (!root || !rotate || root.dataset.detEnterprise3dWired === '1') return;
8724        root.dataset.detEnterprise3dWired = '1';
8725
8726        var reduceMotion =
8727            typeof window.matchMedia === 'function' &&
8728            window.matchMedia('(prefers-reduced-motion: reduce)').matches;
8729        if (reduceMotion) {
8730            root.classList.add('det-ad-pack-enterprise--no-3d');
8731            return;
8732        }
8733
8734        root.classList.add('det-ad-pack-enterprise--3d');
8735
8736        var tilt = { rx: 0, ry: 0 };
8737        rotate._detEnterpriseTilt = tilt;
8738        var dragging = false;
8739        var startX = 0;
8740        var startY = 0;
8741        var startRx = 0;
8742        var startRy = 0;
8743        var maxRx = 26;
8744        var maxRy = 34;
8745
8746        function applyTransform() {
8747            rotate.style.transform =
8748                'rotateX(' + tilt.rx.toFixed(2) + 'deg) rotateY(' + tilt.ry.toFixed(2) + 'deg)';
8749        }
8750
8751        function isInteractiveTarget(t) {
8752            if (!t || !t.closest) return false;
8753            return !!t.closest('button, a, input, label, textarea, select');
8754        }
8755
8756        root.addEventListener('pointerdown', function (e) {
8757            if (e.pointerType === 'mouse' && e.button !== 0) return;
8758            if (isInteractiveTarget(e.target)) return;
8759            dragging = true;
8760            root.classList.add('det-ad-pack-enterprise--dragging');
8761            rotate.style.transition = 'none';
8762            startX = e.clientX;
8763            startY = e.clientY;
8764            startRx = tilt.rx;
8765            startRy = tilt.ry;
8766            try {
8767                root.setPointerCapture(e.pointerId);
8768            } catch (eCap) {
8769                /* ignore */
8770            }
8771            e.preventDefault();
8772        });
8773
8774        root.addEventListener('pointermove', function (e) {
8775            if (!dragging) return;
8776            var dx = e.clientX - startX;
8777            var dy = e.clientY - startY;
8778            tilt.ry = startRy + dx * 0.32;
8779            tilt.rx = startRx - dy * 0.28;
8780            tilt.ry = Math.max(-maxRy, Math.min(maxRy, tilt.ry));
8781            tilt.rx = Math.max(-maxRx, Math.min(maxRx, tilt.rx));
8782            applyTransform();
8783        });
8784
8785        function endDrag(e) {
8786            if (!dragging) return;
8787            dragging = false;
8788            root.classList.remove('det-ad-pack-enterprise--dragging');
8789            try {
8790                if (e && e.pointerId != null) root.releasePointerCapture(e.pointerId);
8791            } catch (eRel) {
8792                /* ignore */
8793            }
8794            rotate.style.transition = 'transform 0.65s cubic-bezier(0.22, 1, 0.36, 1)';
8795            tilt.rx = 0;
8796            tilt.ry = 0;
8797            applyTransform();
8798            window.setTimeout(function () {
8799                rotate.style.transition = '';
8800            }, 700);
8801        }
8802
8803        root.addEventListener('pointerup', endDrag);
8804        root.addEventListener('pointercancel', endDrag);
8805        root.addEventListener('lostpointercapture', function () {
8806            if (dragging) {
8807                dragging = false;
8808                root.classList.remove('det-ad-pack-enterprise--dragging');
8809            }
8810        });
8811
8812        root.addEventListener('mouseenter', function () {
8813            if (dragging) return;
8814            if (tilt.rx === 0 && tilt.ry === 0) return;
8815            rotate.style.transition = 'transform 0.45s cubic-bezier(0.22, 1, 0.36, 1)';
8816            tilt.rx = 0;
8817            tilt.ry = 0;
8818            applyTransform();
8819            window.setTimeout(function () {
8820                if (!dragging) rotate.style.transition = '';
8821            }, 460);
8822        });
8823
8824        applyTransform();
8825    }
8826
8827    function resetDetectorAdPackEnterprise3dTilt() {
8828        var rotate = document.getElementById('detAdPackEnterpriseRotate');
8829        if (!rotate) return;
8830        var tilt = rotate._detEnterpriseTilt;
8831        if (tilt) {
8832            tilt.rx = 0;
8833            tilt.ry = 0;
8834        }
8835        rotate.style.transition = 'transform 0.45s cubic-bezier(0.22, 1, 0.36, 1)';
8836        rotate.style.transform = 'rotateX(0deg) rotateY(0deg)';
8837        window.setTimeout(function () {
8838            rotate.style.transition = '';
8839        }, 480);
8840    }
8841
8842    function wireDetectorAdZoneDismissButtons() {
8843        var pairs = [
8844            { btnId: 'detAdZoneDismissL', sfx: '', strip: 'left' },
8845            { btnId: 'detAdZoneDismissR', sfx: 'R', strip: 'right' }
8846        ];
8847        for (var pi = 0; pi < pairs.length; pi++) {
8848            (function (pair) {
8849                var btn = document.getElementById(pair.btnId);
8850                if (!btn || btn._detDismissWired) return;
8851                btn._detDismissWired = true;
8852                btn.addEventListener('click', function (e) {
8853                    e.preventDefault();
8854                    e.stopPropagation();
8855                    var zone = btn.closest('.det-ad-slots-zone');
8856                    var dismissKey = pair.sfx === 'R' ? '_adZoneDismissedR' : '_adZoneDismissedL';
8857                    var supportKey = pair.sfx === 'R' ? '_adZoneSupportUiR' : '_adZoneSupportUiL';
8858                    if (detIsNarrowDetectorViewport()) {
8859                        state[dismissKey] = true;
8860                        teardownDetectorAdSlotsCarousel(pair.sfx);
8861                        if (zone) zone.classList.add('det-ad-slots-zone--dismissed');
8862                        return;
8863                    }
8864                    state[supportKey] = true;
8865                    teardownDetectorAdSlotsCarousel(pair.sfx);
8866                    var vp = document.getElementById('detAdSlotsViewport' + pair.sfx);
8867                    var trk = document.getElementById('detAdSlotsTrack' + pair.sfx);
8868                    if (trk) {
8869                        trk.style.visibility = 'hidden';
8870                        trk.style.pointerEvents = 'none';
8871                    }
8872                    var carousel = document.getElementById('detAdSlotsCarousel' + pair.sfx);
8873                    if (carousel) carousel._detAdSupportUiActive = true;
8874                    mountDetectorSupport3dInViewport(vp, pair.sfx);
8875                    btn.style.display = 'none';
8876                    var hintEl = document.getElementById('detAdSlotsHoverHint' + pair.sfx);
8877                    if (hintEl) hintEl.textContent = '';
8878                });
8879            })(pairs[pi]);
8880        }
8881    }
8882
8883    function wireDetectorAdPackModalOnce() {
8884        var modal = document.getElementById('detAdPackModal');
8885        if (!window._detAdZoneDismissWiredOnce) {
8886            window._detAdZoneDismissWiredOnce = true;
8887            try {
8888                wireDetectorAdZoneDismissButtons();
8889            } catch (eZ) {
8890                /* ignore */
8891            }
8892        }
8893        if (!modal) {
8894            var microL0 = document.getElementById('detAdSlotsMicroAdd');
8895            if (microL0 && !microL0._detAdMicroWired) {
8896                microL0._detAdMicroWired = true;
8897                microL0.addEventListener('click', function () {
8898                    openDetectorAdPackModal(isMapaAdSlotsPage ? 'right' : 'left');
8899                });
8900            }
8901            return;
8902        }
8903        if (modal.dataset.detPackWired === '1') return;
8904        modal.dataset.detPackWired = '1';
8905
8906        var addBtn = document.getElementById('detAdSlotsCtaAdd');
8907        if (addBtn && !addBtn._detAdCtaWired) {
8908            addBtn._detAdCtaWired = true;
8909            addBtn.addEventListener('click', function () {
8910                openDetectorAdPackModal('left');
8911            });
8912        }
8913        var microAdd = document.getElementById('detAdSlotsMicroAdd');
8914        if (microAdd && !microAdd._detAdMicroWired) {
8915            microAdd._detAdMicroWired = true;
8916            microAdd.addEventListener('click', function () {
8917                openDetectorAdPackModal(isMapaAdSlotsPage ? 'right' : 'left');
8918            });
8919        }
8920        modal.setAttribute('aria-hidden', 'true');
8921
8922        var bc = document.getElementById('detAdPackModalClose');
8923        var bx = document.getElementById('detAdPackBtnCancel');
8924        if (bc) bc.addEventListener('click', closeDetectorAdPackModal);
8925        if (bx) bx.addEventListener('click', closeDetectorAdPackModal);
8926        modal.addEventListener('click', function (e) {
8927            if (e.target === modal) closeDetectorAdPackModal();
8928        });
8929        document.addEventListener('keydown', function (e) {
8930            if (e.key !== 'Escape') return;
8931            if (!modal.classList.contains('open')) return;
8932            closeDetectorAdPackModal();
8933        });
8934
8935        var form = document.getElementById('detAdPackForm');
8936        var daysInp = document.getElementById('detAdPackInputDays');
8937        var emailInp = document.getElementById('detAdPackInputEmail');
8938        if (daysInp) {
8939            daysInp.addEventListener('input', syncDetectorAdPackEstimate);
8940            daysInp.addEventListener('change', syncDetectorAdPackEstimate);
8941        }
8942        if (form) {
8943            form.addEventListener('submit', function (e) {
8944                e.preventDefault();
8945            });
8946        }
8947
8948        function validateAdPackForm(msgEl) {
8949            if (!daysInp || !emailInp) return null;
8950            var d = Math.round(Number(daysInp.value));
8951            var em = String(emailInp.value || '').trim();
8952            if (isNaN(d) || d < 1 || d > AD_PACK_MAX_DAYS) {
8953                if (msgEl) {
8954                    msgEl.removeAttribute('hidden');
8955                    msgEl.textContent = __('ad_pack_err_days_tpl').replace(/\{max\}/g, String(AD_PACK_MAX_DAYS));
8956                    msgEl.style.color = '#b91c1c';
8957                }
8958                return null;
8959            }
8960            if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)) {
8961                if (msgEl) {
8962                    msgEl.removeAttribute('hidden');
8963                    msgEl.textContent = __('ad_pack_err_email');
8964                    msgEl.style.color = '#b91c1c';
8965                }
8966                return null;
8967            }
8968            var fileInp0 = document.getElementById('detAdPackCreativeFile');
8969            var urlInp0 = document.getElementById('detAdPackCreativeUrl');
8970            if (!fileInp0 || !fileInp0.files || !fileInp0.files[0] || !normalizeDetAdPackTargetUrl(urlInp0 && urlInp0.value)) {
8971                if (msgEl) {
8972                    msgEl.removeAttribute('hidden');
8973                    msgEl.textContent = __('ad_pack_err_creative_before_pay');
8974                    msgEl.style.color = '#b91c1c';
8975                }
8976                return null;
8977            }
8978            if (msgEl) {
8979                msgEl.setAttribute('hidden', '');
8980                msgEl.textContent = '';
8981                msgEl.style.color = '';
8982            }
8983            return { days: d, email: em };
8984        }
8985
8986        var btnMp = document.getElementById('detAdPackBtnMp');
8987        var btnPix = document.getElementById('detAdPackBtnPix');
8988        if (btnMp) {
8989            btnMp.addEventListener('click', function () {
8990                var msg = document.getElementById('detAdPackFormMsg');
8991                var v = validateAdPackForm(msg);
8992                if (!v) return;
8993                btnMp.disabled = true;
8994                if (btnPix) btnPix.disabled = true;
8995                if (msg) {
8996                    msg.removeAttribute('hidden');
8997                    msg.textContent = __('ad_pack_redirecting');
8998                    msg.style.color = '';
8999                }
9000                adPackPostJson('/api/detector/ad-checkout', {
9001                    days: v.days,
9002                    email: v.email.toLowerCase(),
9003                    method: 'checkout_pro',
9004                    strip: _adPackActivePrice === AD_PACK_PRICE_LEFT_PER_DAY ? 'left' : 'right',
9005                })
9006                    .then(function (res) {
9007                        btnMp.disabled = false;
9008                        if (btnPix) btnPix.disabled = false;
9009                        var d = res.data || {};
9010                        if (res.okHttp && d.success && d.checkout_url) {
9011                            window.location.href = d.checkout_url;
9012                            return;
9013                        }
9014                        if (msg) {
9015                            msg.removeAttribute('hidden');
9016                            msg.textContent = (d && d.detail) || __('ad_pack_err_checkout');
9017                            msg.style.color = '#b91c1c';
9018                        }
9019                    })
9020                    .catch(function () {
9021                        btnMp.disabled = false;
9022                        if (btnPix) btnPix.disabled = false;
9023                        if (msg) {
9024                            msg.removeAttribute('hidden');
9025                            msg.textContent = __('ad_pack_err_network');
9026                            msg.style.color = '#b91c1c';
9027                        }
9028                    });
9029            });
9030        }
9031        if (btnPix) {
9032            btnPix.addEventListener('click', function () {
9033                var msg = document.getElementById('detAdPackFormMsg');
9034                var v = validateAdPackForm(msg);
9035                if (!v) return;
9036                resetDetAdPixPanel();
9037                btnPix.disabled = true;
9038                if (btnMp) btnMp.disabled = true;
9039                adPackPostJson('/api/detector/ad-checkout', {
9040                    days: v.days,
9041                    email: v.email.toLowerCase(),
9042                    method: 'pix',
9043                    strip: _adPackActivePrice === AD_PACK_PRICE_LEFT_PER_DAY ? 'left' : 'right',
9044                })
9045                    .then(function (res) {
9046                        btnPix.disabled = false;
9047                        if (btnMp) btnMp.disabled = false;
9048                        var d = res.data || {};
9049                        if (!res.okHttp || !d.success || !d.payment_id) {
9050                            if (msg) {
9051                                msg.removeAttribute('hidden');
9052                                msg.textContent = (d && d.detail) || __('ad_pack_err_checkout');
9053                                msg.style.color = '#b91c1c';
9054                            }
9055                            return;
9056                        }
9057                        var panel = document.getElementById('detAdPackPixPanel');
9058                        var codeIn = document.getElementById('detAdPackPixCode');
9059                        var qrImg = document.getElementById('detAdPackPixQrImg');
9060                        var qrWrap = document.getElementById('detAdPackPixQrWrap');
9061                        var ticket = document.getElementById('detAdPackPixTicket');
9062                        var stEl = document.getElementById('detAdPackPixStatus');
9063                        var footCta = document.getElementById('detAdPackFootCta');
9064                        if (footCta) {
9065                            footCta.setAttribute('hidden', '');
9066                            footCta.hidden = true;
9067                        }
9068                        if (panel) {
9069                            panel.removeAttribute('hidden');
9070                            panel.hidden = false;
9071                        }
9072                        if (codeIn) codeIn.value = d.qr_code || '';
9073                        if (d.qr_code_base64 && qrImg && qrWrap) {
9074                            qrImg.src = 'data:image/png;base64,' + d.qr_code_base64;
9075                            qrImg.setAttribute('alt', 'QR Code PIX');
9076                            qrWrap.removeAttribute('hidden');
9077                            qrWrap.hidden = false;
9078                        }
9079                        if (ticket && d.ticket_url) {
9080                            ticket.href = d.ticket_url;
9081                            ticket.removeAttribute('hidden');
9082                            ticket.hidden = false;
9083                        }
9084                        if (stEl) stEl.textContent = __('ad_pack_pix_pending');
9085                        if (msg) msg.setAttribute('hidden', '');
9086                        _detAdPackCheckoutDays = v.days;
9087                        _detAdPixPaymentId = d.payment_id;
9088                        _detAdPixEmail = v.email.toLowerCase();
9089                        stopDetAdPixPoll();
9090                        _detAdPixPollTimer = setInterval(pollDetAdPixOnce, 9000);
9091                    })
9092                    .catch(function () {
9093                        btnPix.disabled = false;
9094                        if (btnMp) btnMp.disabled = false;
9095                        if (msg) {
9096                            msg.removeAttribute('hidden');
9097                            msg.textContent = __('ad_pack_err_network');
9098                            msg.style.color = '#b91c1c';
9099                        }
9100                    });
9101            });
9102        }
9103        var paidBtn = document.getElementById('detAdPackPixPaidBtn');
9104        if (paidBtn) paidBtn.addEventListener('click', pollDetAdPixOnce);
9105        var copyBtn = document.getElementById('detAdPackPixCopyBtn');
9106        var pixCode = document.getElementById('detAdPackPixCode');
9107        if (copyBtn && pixCode) {
9108            copyBtn.addEventListener('click', function () {
9109                pixCode.select();
9110                try {
9111                    navigator.clipboard.writeText(pixCode.value);
9112                } catch (eCopy) {
9113                    /* ignore */
9114                }
9115            });
9116        }
9117
9118        var supBtn = document.getElementById('detAdPackOpenSupport');
9119        if (supBtn) {
9120            supBtn.addEventListener('click', function () {
9121                closeDetectorAdPackModal();
9122                var o = document.getElementById('det-btn-open-contact');
9123                if (o) o.click();
9124            });
9125        }
9126
9127        var ticketTop = document.getElementById('detAdPackTicketTop');
9128        if (ticketTop) {
9129            ticketTop.addEventListener('click', function () {
9130                closeDetectorAdPackModal();
9131                var o2 = document.getElementById('det-btn-open-contact');
9132                if (o2) o2.click();
9133            });
9134        }
9135
9136        var pickImgBtn = document.getElementById('detAdPackPickImageBtn');
9137        var creativeFileInp = document.getElementById('detAdPackCreativeFile');
9138        if (pickImgBtn && creativeFileInp) {
9139            pickImgBtn.addEventListener('click', function () {
9140                if (pickImgBtn.disabled) return;
9141                creativeFileInp.click();
9142            });
9143        }
9144        if (creativeFileInp) {
9145            creativeFileInp.addEventListener('change', function () {
9146                updateDetAdPackPreviewFromFile(creativeFileInp);
9147            });
9148        }
9149
9150        var creativeBtn = document.getElementById('detAdPackCreativeSubmit');
9151        if (creativeBtn) {
9152            creativeBtn.addEventListener('click', function () {
9153                var fileInp = document.getElementById('detAdPackCreativeFile');
9154                var cmsg = document.getElementById('detAdPackCreativeMsg');
9155                var pid = _detAdPixPaymentId;
9156                var em = _detAdPixEmail;
9157                if (!pid || !em) {
9158                    if (cmsg) {
9159                        cmsg.removeAttribute('hidden');
9160                        cmsg.textContent = __('ad_pack_err_checkout');
9161                        cmsg.style.color = '#b91c1c';
9162                    }
9163                    return;
9164                }
9165                if (!fileInp || !fileInp.files || !fileInp.files[0]) {
9166                    if (cmsg) {
9167                        cmsg.removeAttribute('hidden');
9168                        cmsg.textContent = __('ad_pack_creative_need_file');
9169                        cmsg.style.color = '#b91c1c';
9170                    }
9171                    return;
9172                }
9173                var urlInp = document.getElementById('detAdPackCreativeUrl');
9174                var urlNorm = normalizeDetAdPackTargetUrl(urlInp && urlInp.value);
9175                if (!urlNorm) {
9176                    if (cmsg) {
9177                        cmsg.removeAttribute('hidden');
9178                        cmsg.textContent = __('ad_pack_creative_err_url');
9179                        cmsg.style.color = '#b91c1c';
9180                    }
9181                    return;
9182                }
9183                creativeBtn.disabled = true;
9184                if (cmsg) cmsg.setAttribute('hidden', '');
9185                var origin0 = window.location.origin || '';
9186                var fd = new FormData();
9187                fd.append('payment_id', pid);
9188                fd.append('email', em);
9189                fd.append('target_url', urlNorm);
9190                fd.append('creative', fileInp.files[0]);
9191                fetch(origin0 + '/api/detector/ad-creative', {
9192                    method: 'POST',
9193                    body: fd,
9194                    credentials: 'same-origin',
9195                })
9196                    .then(function (r) {
9197                        return r.text().then(function (t) {
9198                            var j = {};
9199                            if (t && String(t).trim()) {
9200                                try {
9201                                    j = JSON.parse(t);
9202                                } catch (eJ) {
9203                                    j = {};
9204                                }
9205                            }
9206                            return { okHttp: r.ok, j: j };
9207                        });
9208                    })
9209                    .then(function (pack) {
9210                        if (cmsg) cmsg.removeAttribute('hidden');
9211                        if (pack.okHttp && pack.j && pack.j.success) {
9212                            try {
9213                                sessionStorage.setItem('det_ad_creative_done_' + pid, '1');
9214                                sessionStorage.removeItem('det_ad_upload_pending');
9215                            } catch (eSs) {
9216                                /* ignore */
9217                            }
9218                            if (cmsg) {
9219                                cmsg.textContent = __('ad_pack_creative_ok');
9220                                cmsg.style.color = '#047857';
9221                            }
9222                            if (fileInp) fileInp.disabled = true;
9223                            if (urlInp) urlInp.disabled = true;
9224                            var pickDone = document.getElementById('detAdPackPickImageBtn');
9225                            if (pickDone) pickDone.disabled = true;
9226                        } else {
9227                            creativeBtn.disabled = false;
9228                            if (cmsg) {
9229                                cmsg.textContent = (pack.j && pack.j.detail) || __('ad_pack_creative_err');
9230                                cmsg.style.color = '#b91c1c';
9231                            }
9232                        }
9233                    })
9234                    .catch(function () {
9235                        creativeBtn.disabled = false;
9236                        if (cmsg) {
9237                            cmsg.removeAttribute('hidden');
9238                            cmsg.textContent = __('ad_pack_err_network');
9239                            cmsg.style.color = '#b91c1c';
9240                        }
9241                    });
9242            });
9243        }
9244
9245        try {
9246            wireDetectorAdPackEnterprise3dOnce();
9247        } catch (eWire3d) {
9248            /* ignore */
9249        }
9250    }
9251
9252    /**
9253     * Confetes discretos só na zona «Espaços publicitários»; dispara só com o destaque (evita spam).
9254     */
9255    function teardownDetectorAdSlotsConfetti(sfx) {
9256        var confLayer = document.getElementById('detAdSlotsConfetti' + (sfx || ''));
9257        if (confLayer) confLayer.innerHTML = '';
9258    }
9259
9260    function fireDetectorAdSlotsConfettiBurst(sfx) {
9261        var layer = document.getElementById('detAdSlotsConfetti' + (sfx || ''));
9262        if (!layer) return;
9263        if (
9264            typeof window.matchMedia === 'function' &&
9265            window.matchMedia('(prefers-reduced-motion: reduce)').matches
9266        ) {
9267            return;
9268        }
9269        if (document.hidden) return;
9270        var carnival =
9271            isMapaAdSlotsPage === true ||
9272            (typeof window.CatellixCarnivalConfetti === 'boolean' && window.CatellixCarnivalConfetti);
9273        var colors = carnival
9274            ? ['#fbbf24', '#f59e0b', '#fde047', '#f472b6', '#ec4899', '#a855f7', '#22c55e', '#34d399', '#38bdf8', '#ef4444']
9275            : ['#38bdf8', '#22d3ee', '#fbbf24', '#fcd34d', '#a78bfa', '#34d399', '#f472b6'];
9276        var n = carnival ? 16 : 4;
9277        for (var i = 0; i < n; i++) {
9278            var p = document.createElement('span');
9279            p.className = 'det-ad-slots-confetti-piece';
9280            p.style.left = 8 + Math.random() * 84 + '%';
9281            var dur = 4.8 + Math.random() * 2.4;
9282            p.style.animationDuration = dur + 's';
9283            p.style.animationDelay = Math.random() * 0.5 + 's';
9284            p.style.setProperty('--det-cf-drift', (Math.random() * 32 - 16).toFixed(1) + 'px');
9285            p.style.background = colors[Math.floor(Math.random() * colors.length)];
9286            var w = 3.2 + Math.random() * 2.8;
9287            p.style.width = w.toFixed(1) + 'px';
9288            p.style.height = (w * 1.2 + 1.2).toFixed(1) + 'px';
9289            layer.appendChild(p);
9290            (function (el, ms) {
9291                window.setTimeout(function () {
9292                    try {
9293                        if (el && el.parentNode) el.parentNode.removeChild(el);
9294                    } catch (eRm) {
9295                        /* ignore */
9296                    }
9297                }, ms);
9298            })(p, Math.ceil((dur + 0.85) * 1000));
9299        }
9300    }
9301
9302    function detIsNarrowDetectorViewport() {
9303        return (
9304            typeof window.matchMedia === 'function' &&
9305            window.matchMedia('(max-width: 1023px)').matches
9306        );
9307    }
9308
9309    function mountDetectorSupport3dInViewport(viewport, sfx) {
9310        sfx = sfx || '';
9311        if (!viewport) return;
9312        var old = viewport.querySelector('.det-ad-support-3d-wrap');
9313        if (old && old.parentNode) {
9314            try {
9315                old.parentNode.removeChild(old);
9316            } catch (eO) {
9317                /* ignore */
9318            }
9319        }
9320        var strip = detectorAdPackSourceForCarouselSfx(sfx);
9321        var wrap = document.createElement('div');
9322        wrap.className = 'det-ad-support-3d-wrap';
9323        wrap.setAttribute('role', 'region');
9324        wrap.setAttribute('aria-label', __('det_support_3d_title'));
9325
9326        var scene = document.createElement('div');
9327        scene.className = 'det-ad-support-3d-scene';
9328
9329        var rot = document.createElement('div');
9330        rot.className = 'det-ad-support-3d-rot';
9331        var plate = document.createElement('div');
9332        plate.className = 'det-ad-support-3d-plate';
9333        plate.setAttribute('aria-hidden', 'true');
9334        rot.appendChild(plate);
9335        scene.appendChild(rot);
9336
9337        var card = document.createElement('div');
9338        card.className = 'det-ad-support-3d-card';
9339
9340        var title = document.createElement('p');
9341        title.className = 'det-ad-support-3d-title';
9342        title.textContent = __('det_support_3d_title');
9343
9344        var sub = document.createElement('p');
9345        sub.className = 'det-ad-support-3d-sub';
9346        sub.textContent = __('det_support_3d_sub');
9347
9348        card.appendChild(title);
9349        card.appendChild(sub);
9350        scene.appendChild(card);
9351        wrap.appendChild(scene);
9352
9353        var cta = document.createElement('button');
9354        cta.type = 'button';
9355        cta.className = 'det-ad-support-3d-cta';
9356        cta.textContent = __('det_support_3d_cta');
9357        cta.addEventListener('click', function () {
9358            openDetectorAdPackModal(strip);
9359        });
9360        wrap.appendChild(cta);
9361
9362        viewport.appendChild(wrap);
9363    }
9364
9365    function dismissDetectorAdSlotZoneBySfx(sfx) {
9366        sfx = sfx || '';
9367        teardownDetectorAdSlotsCarousel(sfx);
9368        var carEl = document.getElementById('detAdSlotsCarousel' + sfx);
9369        var zone = carEl && carEl.closest ? carEl.closest('.det-ad-slots-zone') : null;
9370        if (zone) zone.classList.add('det-ad-slots-zone--dismissed');
9371    }
9372
9373    /**
9374     * Carrossel de slots (faixa fixa): ciclo 6 s a rolar + 6 s em destaque (X + contagem) dentro do viewport.
9375     */
9376    function teardownDetectorAdSlotsCarousel(sfx) {
9377        sfx = sfx || '';
9378        var carouselPre = document.getElementById('detAdSlotsCarousel' + sfx);
9379        if (carouselPre) {
9380            if (carouselPre._detSpotlightAutoHideTimer != null) {
9381                try { window.clearTimeout(carouselPre._detSpotlightAutoHideTimer); } catch (eSa) {}
9382                carouselPre._detSpotlightAutoHideTimer = null;
9383            }
9384            if (carouselPre._detSpotlightCountdownStartTimer != null) {
9385                try { window.clearTimeout(carouselPre._detSpotlightCountdownStartTimer); } catch (eScs) {}
9386                carouselPre._detSpotlightCountdownStartTimer = null;
9387            }
9388            if (carouselPre._detSpotlightCountdownInterval != null) {
9389                try { window.clearInterval(carouselPre._detSpotlightCountdownInterval); } catch (eSci) {}
9390                carouselPre._detSpotlightCountdownInterval = null;
9391            }
9392            if (carouselPre._detAdSlotsSpotlightTimer != null) {
9393                try { window.clearTimeout(carouselPre._detAdSlotsSpotlightTimer); } catch (eSt) {}
9394                carouselPre._detAdSlotsSpotlightTimer = null;
9395            }
9396            if (carouselPre._detAdSlotPhaseTimer != null) {
9397                try { window.clearTimeout(carouselPre._detAdSlotPhaseTimer); } catch (ePh) {}
9398                carouselPre._detAdSlotPhaseTimer = null;
9399            }
9400            if (carouselPre._detSpotlightFadeOutTimer != null) {
9401                try { window.clearTimeout(carouselPre._detSpotlightFadeOutTimer); } catch (eFo) {}
9402                carouselPre._detSpotlightFadeOutTimer = null;
9403            }
9404        }
9405        teardownDetectorAdSlotsConfetti(sfx);
9406        var carousel = document.getElementById('detAdSlotsCarousel' + sfx);
9407        if (!carousel || !carousel._detAdSlotsInit) return;
9408        if (carousel._detAdSlotsRaf) {
9409            try { window.cancelAnimationFrame(carousel._detAdSlotsRaf); } catch (eRf) {}
9410            carousel._detAdSlotsRaf = 0;
9411        }
9412        carousel._detAdSlotsInit = false;
9413        if (carousel._detAdSlotsVisAbo) {
9414            try { carousel._detAdSlotsVisAbo.abort(); } catch (eAb0) {}
9415            carousel._detAdSlotsVisAbo = null;
9416        }
9417        if (carousel._detAdSlotsAbo) {
9418            try { carousel._detAdSlotsAbo.abort(); } catch (eAb) {}
9419            carousel._detAdSlotsAbo = null;
9420        }
9421        var track = document.getElementById('detAdSlotsTrack' + sfx);
9422        if (track) {
9423            track.innerHTML = '';
9424            track.classList.remove('det-ad-slots-track--static');
9425            track.style.transform = '';
9426            track.style.visibility = '';
9427            track.style.pointerEvents = '';
9428        }
9429        var viewportTe = document.getElementById('detAdSlotsViewport' + sfx);
9430        if (viewportTe) {
9431            var supRm = viewportTe.querySelector('.det-ad-support-3d-wrap');
9432            if (supRm && supRm.parentNode) {
9433                try {
9434                    supRm.parentNode.removeChild(supRm);
9435                } catch (eSp) {
9436                    /* ignore */
9437                }
9438            }
9439        }
9440        var carouselEl = document.getElementById('detAdSlotsCarousel' + sfx);
9441        if (carouselEl) carouselEl._detAdSupportUiActive = false;
9442        try {
9443            var zone = document.getElementById('detAdSlotsCarousel' + sfx);
9444            var parent = zone || document;
9445            var mounts = parent.querySelectorAll('.det-ad-slots-spotlight-mount');
9446            for (var mi = 0; mi < mounts.length; mi++) {
9447                var m = mounts[mi];
9448                if (m.parentNode) m.parentNode.removeChild(m);
9449            }
9450        } catch (eMt) {}
9451    }
9452
9453    function computeCarouselSlotHash() {
9454        var arr = state.detectorCarouselSlotAds || [];
9455        var parts = [];
9456        for (var hi = 0; hi < arr.length; hi++) {
9457            var a = arr[hi];
9458            if (!a) continue;
9459            parts.push(String(a.slot || '') + ':' + String(a.id || '') + ':' + String(a.image_url || '') + ':' + String(a.link_url || ''));
9460        }
9461        return parts.join('|');
9462    }
9463
9464    function invalidateCarouselSlotHash(sfx) {
9465        sfx = sfx || '';
9466        var c = document.getElementById('detAdSlotsCarousel' + sfx);
9467        if (c) c._detSlotHash = null;
9468    }
9469
9470    function initDetectorAdSlotsCarousel(sfx, slotMax) {
9471        sfx = sfx || '';
9472        var dismissKey = sfx === 'R' ? '_adZoneDismissedR' : '_adZoneDismissedL';
9473        var supportKey = sfx === 'R' ? '_adZoneSupportUiR' : '_adZoneSupportUiL';
9474        if (state[dismissKey] || state[supportKey]) return;
9475        var smax = slotMax != null && !isNaN(Number(slotMax)) ? Number(slotMax) : AD_SLOTS_COUNT;
9476        if (smax < 1) smax = AD_SLOTS_COUNT;
9477        wireDetectorAdZoneDismissButtons();
9478        wireDetectorAdPackModalOnce();
9479        var carousel = document.getElementById('detAdSlotsCarousel' + sfx);
9480        if (!carousel) return;
9481
9482        var newHash = computeCarouselSlotHash();
9483        if (
9484            carousel._detAdSlotsInit &&
9485            carousel._detSlotHash === newHash &&
9486            carousel._detSlotMax === smax
9487        ) {
9488            return;
9489        }
9490
9491        var isVirginCarousel = !carousel._detSpotlightCycleIdx && !carousel._detScrollQueueHead && !carousel._detCarouselGen;
9492        var prevCycleIdx = carousel._detSpotlightCycleIdx || 0;
9493        var prevQueueHead = carousel._detScrollQueueHead || 0;
9494        var initGen = (carousel._detCarouselGen || 0) + 1;
9495
9496        teardownDetectorAdSlotsCarousel(sfx);
9497        var viewport = document.getElementById('detAdSlotsViewport' + sfx);
9498        var track = document.getElementById('detAdSlotsTrack' + sfx);
9499        var hintEl = document.getElementById('detAdSlotsHoverHint' + sfx);
9500        if (!viewport || !track) return;
9501        carousel._detAdSlotsInit = true;
9502        carousel._detCarouselGen = initGen;
9503        carousel._detSlotHash = newHash;
9504        carousel._detSlotMax = smax;
9505
9506        function buildSlotsHtml() {
9507            var bySlot = {};
9508            var fad = state.detectorCarouselSlotAds || [];
9509            for (var fj = 0; fj < fad.length; fj++) {
9510                var fa = fad[fj];
9511                if (!fa) continue;
9512                var sn = parseInt(fa.slot, 10);
9513                if (!isNaN(sn) && sn >= 1 && sn <= smax) {
9514                    bySlot[sn] = fa;
9515                }
9516            }
9517            var html = '';
9518            for (var i = 1; i <= smax; i++) {
9519                var aria = __('ad_pack_slot_aria_tpl').replace(/\{n\}/g, String(i));
9520                var ad = bySlot[i];
9521                if (ad && ad.image_url && ad.link_url) {
9522                    var imgU = String(ad.image_url).trim();
9523                    var linkU = String(ad.link_url).trim();
9524                    var adId = esc(String(ad.id || ''));
9525                    var dRem = 30;
9526                    if (ad.days_remaining != null) {
9527                        var dp = parseInt(ad.days_remaining, 10);
9528                        if (!isNaN(dp)) dRem = Math.max(0, Math.min(3660, dp));
9529                    }
9530                    html +=
9531                        '<a href="' +
9532                        esc(linkU) +
9533                        '" class="det-ad-slot det-ad-slot--filled" data-slot="' +
9534                        i +
9535                        '" data-ad-id="' +
9536                        adId +
9537                        '" data-campaign-days="' +
9538                        esc(String(dRem)) +
9539                        '" target="_blank" rel="noopener noreferrer" aria-label="' +
9540                        esc(aria) +
9541                        '"><img class="det-ad-slot-img" src="' +
9542                        esc(imgU) +
9543                        '" alt="" width="80" height="80" loading="lazy" decoding="async"></a>';
9544                } else {
9545                    html +=
9546                        '<button type="button" class="det-ad-slot" data-slot="' +
9547                        i +
9548                        '" aria-label="' +
9549                        esc(aria) +
9550                        '"><span class="det-ad-slot-plus" aria-hidden="true">+</span></button>';
9551                }
9552            }
9553            return html;
9554        }
9555
9556        var reduceMotion =
9557            typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
9558        if (reduceMotion) {
9559            track.innerHTML = buildSlotsHtml();
9560            track.classList.add('det-ad-slots-track--static');
9561            if (hintEl) hintEl.textContent = __('sidebar_ad_slots_reduced_hint');
9562            var rmFilled = track.querySelectorAll('.det-ad-slot--filled');
9563            for (var rmi = 0; rmi < rmFilled.length; rmi++) {
9564                var rmAid = rmFilled[rmi].getAttribute('data-ad-id');
9565                if (rmAid) postLocalAdEvent(rmAid, 'impression', sfx === 'R' ? 'right' : 'left');
9566            }
9567            track.addEventListener('click', function (e) {
9568                var btn = e.target && e.target.closest ? e.target.closest('.det-ad-slot') : null;
9569                if (!btn || !track.contains(btn)) return;
9570                if (btn.classList && btn.classList.contains('det-ad-slot--filled')) {
9571                    var aid = btn.getAttribute('data-ad-id');
9572                    if (aid) postLocalAdEvent(aid, 'click', sfx === 'R' ? 'right' : 'left');
9573                    return;
9574                }
9575                e.preventDefault();
9576                openDetectorAdPackModal(detectorAdPackSourceForCarouselSfx(sfx));
9577            });
9578            return;
9579        }
9580
9581        track.innerHTML = buildSlotsHtml() + buildSlotsHtml();
9582
9583        var paused = false;
9584        var tabHidden = false;
9585        var pos = 0;
9586        var speed = 0.42;
9587        var scrollQueueHead = prevQueueHead;
9588
9589        if (isVirginCarousel) {
9590            var filledCount = track.querySelectorAll('.det-ad-slot--filled').length / 2;
9591            if (filledCount > 1) {
9592                var rndStart = Math.floor(Math.random() * filledCount);
9593                scrollQueueHead = rndStart;
9594                prevCycleIdx = rndStart;
9595            }
9596        }
9597
9598        var _sidebarImprSent = {};
9599        (function fireInitialSidebarImpressions() {
9600            var uniqSlots = {};
9601            var nodes = track.querySelectorAll('.det-ad-slot--filled');
9602            for (var si = 0; si < nodes.length; si++) {
9603                var sAid = nodes[si].getAttribute('data-ad-id');
9604                if (sAid && !uniqSlots[sAid]) {
9605                    uniqSlots[sAid] = true;
9606                    _sidebarImprSent[sAid] = true;
9607                    postLocalAdEvent(sAid, 'impression', sfx === 'R' ? 'right' : 'left');
9608                }
9609            }
9610        })();
9611
9612        var spotlightEl = null;
9613        var spotlightCarouselFrozen = false;
9614        var spotlightFrozenBest = null;
9615        var SPOTLIGHT_HOLD_SEC = 6;
9616        var SPOTLIGHT_FADE_IN_MS = 420;
9617        var SPOTLIGHT_FADE_OUT_MS = 520;
9618        var CAROUSEL_SCROLL_PHASE_MS = 6000;
9619        var spotlightCycleIndex = prevCycleIdx;
9620        /** scroll | spotlight — prazos em performance.now(); avançam com o relógio só quando não está em pausa (hover ou separador). */
9621        var carouselPhase = 'scroll';
9622        var scrollPhaseUntil = 0;
9623        var spotlightPhaseUntil = 0;
9624        var spotlightCountdownStartAt = 0;
9625        var lastCarouselTickT = performance.now();
9626
9627        function pickUniqueFilledAdSlotEls() {
9628            var nodes = track.querySelectorAll('.det-ad-slot--filled');
9629            var seen = {};
9630            var uniq = [];
9631            for (var pi = 0; pi < nodes.length; pi++) {
9632                var n = nodes[pi];
9633                var ds = n.getAttribute('data-slot');
9634                if (!ds || seen[ds]) continue;
9635                seen[ds] = true;
9636                uniq.push(n);
9637            }
9638            return uniq;
9639        }
9640
9641        function getFilledSlotsSorted() {
9642            var uniq = pickUniqueFilledAdSlotEls();
9643            uniq.sort(function (a, b) {
9644                return (
9645                    parseInt(a.getAttribute('data-slot'), 10) - parseInt(b.getAttribute('data-slot'), 10)
9646                );
9647            });
9648            return uniq;
9649        }
9650
9651        function centerScrollQueueHeadEntry() {
9652            var filled = getFilledSlotsSorted();
9653            if (filled.length) {
9654                var idx = scrollQueueHead % filled.length;
9655                snapSlotToViewportCenter(filled[idx]);
9656            } else {
9657                var halfW = track.scrollWidth / 2;
9658                var vr = viewport.getBoundingClientRect();
9659                if (halfW > 0 && vr.width > 0) {
9660                    var slot1CenterInTrack = 64;
9661                    var initPos = halfW + slot1CenterInTrack - vr.width / 2;
9662                    pos = Math.max(0, Math.min(halfW - 1, initPos));
9663                    track.style.transform = 'translate3d(' + (-pos).toFixed(2) + 'px,0,0)';
9664                }
9665            }
9666            try {
9667                updateScales();
9668            } catch (eSc) {
9669                /* ignore */
9670            }
9671        }
9672
9673        function clearAdSlotPhaseTimer() {
9674            if (carousel._detAdSlotPhaseTimer != null) {
9675                try {
9676                    window.clearTimeout(carousel._detAdSlotPhaseTimer);
9677                } catch (eCt) {
9678                    /* ignore */
9679                }
9680                carousel._detAdSlotPhaseTimer = null;
9681            }
9682        }
9683
9684        /**
9685         * Após fechar destaque: 6 s de carrossel a rolar, depois próximo destaque.
9686         * @param {boolean} [isFirstBoot] — se true, não avança a fila indiana (primeira entrada).
9687         */
9688        function runScrollPhaseThenSpotlight(isFirstBoot) {
9689            clearAdSlotPhaseTimer();
9690            if (!carousel._detAdSlotsInit) return;
9691            finishSpotlightHide();
9692            spotlightCarouselFrozen = false;
9693            spotlightFrozenBest = null;
9694            carouselPhase = 'scroll';
9695            var filledRot = getFilledSlotsSorted();
9696            if (isFirstBoot === true) {
9697                if (filledRot.length) {
9698                    scrollQueueHead = spotlightCycleIndex % filledRot.length;
9699                }
9700            } else {
9701                if (filledRot.length) {
9702                    scrollQueueHead = (scrollQueueHead + 1) % filledRot.length;
9703                }
9704            }
9705            syncCycleStateToCarousel();
9706            centerScrollQueueHeadEntry();
9707            scrollPhaseUntil = performance.now() + CAROUSEL_SCROLL_PHASE_MS;
9708        }
9709
9710        function syncCycleStateToCarousel() {
9711            if (carousel._detCarouselGen !== initGen) return;
9712            carousel._detSpotlightCycleIdx = spotlightCycleIndex;
9713            carousel._detScrollQueueHead = scrollQueueHead;
9714        }
9715
9716        function openNextSpotlight() {
9717            if (!carousel._detAdSlotsInit) return;
9718            var uniq = pickUniqueFilledAdSlotEls();
9719            uniq.sort(function (a, b) {
9720                return (
9721                    parseInt(a.getAttribute('data-slot'), 10) - parseInt(b.getAttribute('data-slot'), 10)
9722                );
9723            });
9724            if (!uniq.length) {
9725                scrollPhaseUntil = performance.now() + CAROUSEL_SCROLL_PHASE_MS;
9726                return;
9727            }
9728            var pick = uniq[spotlightCycleIndex % uniq.length];
9729            spotlightCycleIndex++;
9730            syncCycleStateToCarousel();
9731            snapSlotToViewportCenter(pick);
9732            updateScales();
9733            if (!showAdSlotSpotlight(pick)) {
9734                /* Não bloquear o ciclo 6s+6s: tenta o próximo slot quase de seguida. */
9735                scrollPhaseUntil = performance.now() + 120;
9736                return;
9737            }
9738            spotlightCarouselFrozen = true;
9739            spotlightFrozenBest = pick;
9740            carouselPhase = 'spotlight';
9741            var spAdId = pick.getAttribute('data-ad-id');
9742            if (spAdId) postLocalAdEvent(spAdId, 'impression', sfx === 'R' ? 'right' : 'left');
9743            var nowSpot = performance.now();
9744            spotlightCountdownStartAt = nowSpot + SPOTLIGHT_FADE_IN_MS;
9745            spotlightPhaseUntil = spotlightCountdownStartAt + SPOTLIGHT_HOLD_SEC * 1000;
9746            clearSpotlightHoldTimers();
9747            try {
9748                fireCenterBurst();
9749            } catch (eCf0) {
9750                /* ignore */
9751            }
9752            try {
9753                fireDetectorAdSlotsConfettiBurst(sfx);
9754            } catch (eCf) {
9755                /* ignore */
9756            }
9757        }
9758
9759        function snapSlotToViewportCenter(slotEl) {
9760            var half = loopHalfWidth();
9761            if (half <= 0 || !slotEl) return;
9762            var vr = viewport.getBoundingClientRect();
9763            var er = slotEl.getBoundingClientRect();
9764            var vMid = vr.left + vr.width / 2;
9765            var eMid = er.left + er.width / 2;
9766            var delta = eMid - vMid;
9767            pos += delta;
9768            while (pos >= half) pos -= half;
9769            while (pos < 0) pos += half;
9770            track.style.transform = 'translate3d(' + (-pos).toFixed(2) + 'px,0,0)';
9771        }
9772
9773        function clearSpotlightHoldTimers() {
9774            if (carousel._detSpotlightAutoHideTimer != null) {
9775                try {
9776                    window.clearTimeout(carousel._detSpotlightAutoHideTimer);
9777                } catch (eCl) {
9778                    /* ignore */
9779                }
9780                carousel._detSpotlightAutoHideTimer = null;
9781            }
9782            if (carousel._detSpotlightCountdownStartTimer != null) {
9783                try {
9784                    window.clearTimeout(carousel._detSpotlightCountdownStartTimer);
9785                } catch (eCs) {
9786                    /* ignore */
9787                }
9788                carousel._detSpotlightCountdownStartTimer = null;
9789            }
9790            if (carousel._detSpotlightCountdownInterval != null) {
9791                try {
9792                    window.clearInterval(carousel._detSpotlightCountdownInterval);
9793                } catch (eCi) {
9794                    /* ignore */
9795                }
9796                carousel._detSpotlightCountdownInterval = null;
9797            }
9798        }
9799
9800        function finishSpotlightHide() {
9801            clearSpotlightHoldTimers();
9802            if (spotlightEl && spotlightEl.parentNode) {
9803                try {
9804                    spotlightEl.parentNode.removeChild(spotlightEl);
9805                } catch (eRm) {
9806                    /* ignore */
9807                }
9808            }
9809            spotlightEl = null;
9810            spotlightCarouselFrozen = false;
9811            spotlightFrozenBest = null;
9812        }
9813
9814        function beginSpotlightHide() {
9815            if (!spotlightEl || !spotlightEl.parentNode) {
9816                finishSpotlightHide();
9817                if (carousel._detAdSlotsInit) runScrollPhaseThenSpotlight();
9818                return;
9819            }
9820            if (spotlightEl.classList.contains('det-ad-slots-spotlight-mount--closing')) return;
9821            clearSpotlightHoldTimers();
9822            spotlightEl.classList.remove('det-ad-slots-spotlight-mount--open');
9823            spotlightEl.classList.add('det-ad-slots-spotlight-mount--closing');
9824            var animEl =
9825                spotlightEl.querySelector('.det-ad-slots-spotlight-frame') || spotlightEl;
9826            var done = false;
9827            function onEnd(e) {
9828                if (done) return;
9829                if (e && e.propertyName && e.propertyName !== 'opacity' && e.propertyName !== 'transform') {
9830                    return;
9831                }
9832                done = true;
9833                try {
9834                    animEl.removeEventListener('transitionend', onEnd);
9835                } catch (eL) {
9836                    /* ignore */
9837                }
9838                finishSpotlightHide();
9839                if (carousel._detAdSlotsInit) runScrollPhaseThenSpotlight();
9840            }
9841            animEl.addEventListener('transitionend', onEnd);
9842            carousel._detSpotlightFadeOutTimer = window.setTimeout(function () {
9843                carousel._detSpotlightFadeOutTimer = null;
9844                onEnd({});
9845            }, SPOTLIGHT_FADE_OUT_MS + 140);
9846        }
9847
9848        function hideAdSlotSpotlight() {
9849            beginSpotlightHide();
9850        }
9851
9852        function showAdSlotSpotlight(slotEl) {
9853            finishSpotlightHide();
9854            var img = slotEl.querySelector('img');
9855            if (!img) return false;
9856            var srcRaw = (
9857                img.getAttribute('src') ||
9858                img.getAttribute('data-src') ||
9859                (img.currentSrc ? img.currentSrc : '') ||
9860                (img.src && String(img.src).indexOf('data:') !== 0 ? img.src : '')
9861            );
9862            srcRaw = String(srcRaw || '').trim();
9863            if (!srcRaw) return false;
9864            var href = slotEl.getAttribute('href') || '#';
9865            var adId = slotEl.getAttribute('data-ad-id') || '';
9866            var vr = viewport.getBoundingClientRect();
9867            var maxSide = Math.min(vr.width, vr.height) * 0.82;
9868            var side = Math.max(88, Math.min(240, maxSide));
9869
9870            var wrap = document.createElement('div');
9871            wrap.className = 'det-ad-slots-spotlight-mount det-ad-slots-spotlight-mount--inline';
9872            wrap.setAttribute('role', 'presentation');
9873
9874            var frame = document.createElement('div');
9875            frame.className = 'det-ad-slots-spotlight-frame';
9876
9877            var glowRing = document.createElement('div');
9878            glowRing.className = 'det-ad-slots-spotlight-timer-glow';
9879            if (
9880                typeof window.matchMedia === 'function' &&
9881                window.matchMedia('(prefers-reduced-motion: reduce)').matches
9882            ) {
9883                glowRing.classList.add('det-ad-slots-spotlight-timer-glow--static');
9884            }
9885            frame.appendChild(glowRing);
9886
9887            var a = document.createElement('a');
9888            a.className = 'det-ad-slots-spotlight-card';
9889            a.href = href;
9890            a.target = '_blank';
9891            a.rel = 'noopener noreferrer';
9892            if (adId) a.setAttribute('data-ad-id', adId);
9893            a.addEventListener('click', function () {
9894                if (adId) postLocalAdEvent(adId, 'click', sfx === 'R' ? 'right' : 'left');
9895            });
9896
9897            var im = document.createElement('img');
9898            im.className = 'det-ad-slots-spotlight-img';
9899            im.alt = '';
9900            im.decoding = 'async';
9901            im.src = img.currentSrc || srcRaw;
9902
9903            a.appendChild(im);
9904            frame.appendChild(a);
9905
9906            var campDays = parseInt(slotEl.getAttribute('data-campaign-days') || '', 10);
9907            if (isNaN(campDays) || campDays < 0) campDays = 30;
9908            if (campDays > 3660) campDays = 3660;
9909            var campBadge = document.createElement('div');
9910            campBadge.className = 'det-ad-slots-spotlight-camp-badge';
9911            campBadge.setAttribute('aria-hidden', 'true');
9912            var bolt = document.createElement('span');
9913            bolt.className = 'det-ad-slots-spotlight-camp-badge__bolt';
9914            bolt.textContent = '\u26A1';
9915            var daysTxt = document.createElement('span');
9916            daysTxt.className = 'det-ad-slots-spotlight-camp-badge__days';
9917            daysTxt.textContent = __('sidebar_spotlight_campaign_badge_tpl').replace(/\{n\}/g, String(campDays));
9918            campBadge.appendChild(bolt);
9919            campBadge.appendChild(daysTxt);
9920            frame.appendChild(campBadge);
9921
9922            var cdSpan = document.createElement('span');
9923            cdSpan.className = 'det-ad-slots-spotlight-countdown';
9924            cdSpan.setAttribute('role', 'status');
9925            cdSpan.setAttribute('aria-live', 'polite');
9926            cdSpan.setAttribute('aria-atomic', 'true');
9927            frame.appendChild(cdSpan);
9928            var closeBtn = document.createElement('button');
9929            closeBtn.type = 'button';
9930            closeBtn.className = 'det-ad-slots-spotlight-close det-ad-close-btn--pulse';
9931            closeBtn.setAttribute('aria-label', __('ad_pack_close_aria'));
9932            closeBtn.innerHTML =
9933                '<span class="det-ad-close-btn__glyph" aria-hidden="true">\u00d7</span>';
9934            closeBtn.addEventListener('click', function (e) {
9935                e.preventDefault();
9936                e.stopPropagation();
9937                beginSpotlightHide();
9938            });
9939            frame.appendChild(closeBtn);
9940            frame.style.width = side + 'px';
9941            frame.style.height = side + 'px';
9942            wrap.appendChild(frame);
9943            viewport.appendChild(wrap);
9944            spotlightEl = wrap;
9945            try {
9946                void wrap.offsetHeight;
9947                wrap.classList.add('det-ad-slots-spotlight-mount--open');
9948            } catch (eOp) {
9949                /* ignore */
9950            }
9951            return true;
9952        }
9953        var visAbo = typeof AbortController !== 'undefined' ? new AbortController() : null;
9954        var visSignal = visAbo ? visAbo.signal : undefined;
9955        if (visSignal) {
9956            carousel._detAdSlotsVisAbo = visAbo;
9957            document.addEventListener(
9958                'visibilitychange',
9959                function onDetAdVis() {
9960                    tabHidden = document.hidden;
9961                },
9962                { signal: visSignal }
9963            );
9964        } else {
9965            document.addEventListener('visibilitychange', function onDetAdVis() {
9966                tabHidden = document.hidden;
9967            });
9968        }
9969
9970        function loopHalfWidth() {
9971            var w = track.scrollWidth;
9972            return w > 0 ? w / 2 : 0;
9973        }
9974
9975        function fireCenterBurst() {
9976            var layer = document.getElementById('detAdSlotsConfetti' + sfx);
9977            if (!layer) return;
9978            if (typeof window.matchMedia === 'function' &&
9979                window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
9980            if (document.hidden) return;
9981            var colors = ['#fbbf24', '#fcd34d', '#f59e0b', '#38bdf8', '#a78bfa', '#34d399', '#f472b6', '#ff6b6b', '#4ade80'];
9982            var n = 18;
9983            for (var bi = 0; bi < n; bi++) {
9984                var p = document.createElement('span');
9985                p.className = 'det-ad-slots-confetti-piece';
9986                /* Espalha ao redor do centro */
9987                p.style.left = 15 + Math.random() * 70 + '%';
9988                var dur = 2.6 + Math.random() * 1.8;
9989                p.style.animationDuration = dur + 's';
9990                p.style.animationDelay = (Math.random() * 0.22) + 's';
9991                p.style.setProperty('--det-cf-drift', (Math.random() * 52 - 26).toFixed(1) + 'px');
9992                p.style.background = colors[Math.floor(Math.random() * colors.length)];
9993                var w = 4 + Math.random() * 4;
9994                p.style.width = w.toFixed(1) + 'px';
9995                p.style.height = (w * 1.3 + 1).toFixed(1) + 'px';
9996                p.style.borderRadius = Math.random() > 0.5 ? '50%' : '2px';
9997                layer.appendChild(p);
9998                (function (el, ms) {
9999                    window.setTimeout(function () {
10000                        try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch (e) {}
10001                    }, ms);
10002                })(p, Math.ceil((dur + 0.5) * 1000));
10003            }
10004        }
10005
10006        function updateScales() {
10007            var vr = viewport.getBoundingClientRect();
10008            var mid = vr.left + vr.width / 2;
10009            var slots = track.querySelectorAll('.det-ad-slot');
10010            var best = null;
10011            var bestD = 1e9;
10012            for (var i = 0; i < slots.length; i++) {
10013                var slot = slots[i];
10014                var r = slot.getBoundingClientRect();
10015                var c = r.left + r.width / 2;
10016                var d = Math.abs(c - mid);
10017                if (d < bestD) {
10018                    bestD = d;
10019                    best = slot;
10020                }
10021                var norm = Math.min(1, d / (vr.width * 0.42));
10022                /* Centro da faixa: escala extra (efeito «expandir» como no destaque). */
10023                var sc = 0.74 + (1 - norm) * 0.48;
10024                var op = 0.5 + (1 - norm) * 0.5;
10025                slot.style.transform = 'scale(' + sc.toFixed(3) + ')';
10026                slot.style.opacity = op.toFixed(3);
10027                slot.classList.remove('det-ad-slot--hot');
10028            }
10029            if (best) {
10030                best.classList.add('det-ad-slot--hot');
10031            }
10032            return best;
10033        }
10034
10035        function tick() {
10036            if (carousel._detAdSupportUiActive) return;
10037            var now = performance.now();
10038            /* Com hover ou separador em segundo plano, o tempo lógico do ciclo 6s+6s para — não salta para o próximo destaque. */
10039            if (paused || tabHidden) {
10040                scrollPhaseUntil += now - lastCarouselTickT;
10041                spotlightPhaseUntil += now - lastCarouselTickT;
10042                spotlightCountdownStartAt += now - lastCarouselTickT;
10043            }
10044            lastCarouselTickT = now;
10045
10046            var frozen = spotlightCarouselFrozen;
10047            var half = loopHalfWidth();
10048
10049            if (carouselPhase === 'spotlight' && spotlightEl && !paused && !tabHidden) {
10050                if (now >= spotlightPhaseUntil) {
10051                    beginSpotlightHide();
10052                } else if (now >= spotlightCountdownStartAt) {
10053                    var cdEl0 = spotlightEl.querySelector('.det-ad-slots-spotlight-countdown');
10054                    if (cdEl0) {
10055                        var secLeft = Math.max(0, Math.ceil((spotlightPhaseUntil - now) / 1000));
10056                        if (cdEl0.textContent !== String(secLeft)) cdEl0.textContent = String(secLeft);
10057                    }
10058                    var glowEl = spotlightEl.querySelector('.det-ad-slots-spotlight-timer-glow');
10059                    if (
10060                        glowEl &&
10061                        !glowEl.classList.contains('det-ad-slots-spotlight-timer-glow--static') &&
10062                        typeof window.matchMedia === 'function' &&
10063                        !window.matchMedia('(prefers-reduced-motion: reduce)').matches
10064                    ) {
10065                        var totalSpotMs = SPOTLIGHT_HOLD_SEC * 1000;
10066                        var leftMs = Math.max(0, spotlightPhaseUntil - now);
10067                        var rem = totalSpotMs > 0 ? leftMs / totalSpotMs : 0;
10068                        var pulseDur = 0.45 + 1.85 * rem;
10069                        if (glowEl._detPulseDur !== pulseDur) {
10070                            glowEl._detPulseDur = pulseDur;
10071                            glowEl.style.animationDuration = pulseDur.toFixed(2) + 's';
10072                        }
10073                        var br = 0.88 + 0.28 * (1 - rem);
10074                        if (glowEl._detGlowBr !== br) {
10075                            glowEl._detGlowBr = br;
10076                            glowEl.style.filter = 'brightness(' + br.toFixed(3) + ')';
10077                        }
10078                    }
10079                }
10080            }
10081
10082            if (carouselPhase === 'scroll' && !frozen && !paused && !tabHidden && now >= scrollPhaseUntil) {
10083                if (half > 0) {
10084                    openNextSpotlight();
10085                } else {
10086                    scrollPhaseUntil = now + 400;
10087                }
10088            }
10089
10090            if (!paused && !tabHidden && half > 0 && !frozen) {
10091                pos += speed;
10092                if (pos >= half) pos -= half;
10093                track.style.transform = 'translate3d(' + (-pos).toFixed(2) + 'px,0,0)';
10094            }
10095
10096            if (frozen && spotlightFrozenBest) {
10097                /* Durante o destaque 6s a escala da faixa fica estável (como antes). */
10098            } else {
10099                updateScales();
10100            }
10101
10102            if (hintEl && !paused && !tabHidden && !frozen) {
10103                hintEl.textContent = __('sidebar_ad_slots_scroll_hint');
10104            }
10105            carousel._detAdSlotsRaf = window.requestAnimationFrame(tick);
10106        }
10107
10108        var evAbo = typeof AbortController !== 'undefined' ? new AbortController() : null;
10109        var evSig = evAbo ? evAbo.signal : undefined;
10110        carousel._detAdSlotsAbo = evAbo;
10111        var evOpts = evSig ? { signal: evSig } : false;
10112
10113        function addVpEv(name, fn, opts) {
10114            if (evOpts) viewport.addEventListener(name, fn, Object.assign({}, opts || {}, evOpts));
10115            else viewport.addEventListener(name, fn, opts || false);
10116        }
10117
10118        addVpEv(
10119            'click',
10120            function (e) {
10121                var btn = e.target && e.target.closest ? e.target.closest('.det-ad-slot') : null;
10122                if (!btn || !viewport.contains(btn)) return;
10123                if (btn.classList && btn.classList.contains('det-ad-slot--filled')) {
10124                    var aid2 = btn.getAttribute('data-ad-id');
10125                    if (aid2) postLocalAdEvent(aid2, 'click', sfx === 'R' ? 'right' : 'left');
10126                    return;
10127                }
10128                e.preventDefault();
10129                openDetectorAdPackModal(detectorAdPackSourceForCarouselSfx(sfx));
10130            },
10131            false
10132        );
10133
10134        addVpEv(
10135            'mouseenter',
10136            function () {
10137                paused = true;
10138            },
10139            false
10140        );
10141        addVpEv('mouseleave', function () {
10142            paused = false;
10143            if (hintEl) hintEl.textContent = __('sidebar_ad_slots_scroll_hint');
10144        });
10145        addVpEv(
10146            'touchstart',
10147            function () {
10148                paused = true;
10149            },
10150            { passive: true }
10151        );
10152        addVpEv(
10153            'touchend',
10154            function () {
10155                paused = false;
10156                if (hintEl) hintEl.textContent = __('sidebar_ad_slots_scroll_hint');
10157            },
10158            { passive: true }
10159        );
10160
10161        carousel._detAdSlotsRaf = window.requestAnimationFrame(tick);
10162        runScrollPhaseThenSpotlight(true);
10163    }
10164
10165    function initDetectorAdSlotsCarouselRight() {
10166        if (isMapaAdSlotsPage) return;
10167        initDetectorAdSlotsCarousel('R', AD_SLOTS_RAIL_COUNT);
10168        var microR = document.getElementById('detAdSlotsMicroAddR');
10169        if (microR && !microR._detWired) {
10170            microR._detWired = true;
10171            microR.addEventListener('click', function () { openDetectorAdPackModal('right'); });
10172        }
10173    }
10174
10175    function renderCommunityRankingOl() {
10176        var ol = document.getElementById('rankCommunity');
10177        if (!ol) return;
10178        var rows = state.communityRankingRows;
10179        var rowsVotes = (rows || []).filter(function (row) {
10180            return row && (Number(row.vote_count) || 0) > 0;
10181        });
10182        if (!rowsVotes.length) {
10183            ol.innerHTML =
10184                '<li class="det-sidebar-empty" role="status">' + esc(__('js_sidebar_empty_comm')) + '</li>';
10185            updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10186            refreshSidebarComplaintsTop10();
10187            try {
10188                renderTop30List();
10189            } catch (eT30e) {}
10190            return;
10191        }
10192        var lim = rankLimit('community');
10193        var items = rowsVotes.slice(0, lim);
10194        var prevMapPanel = state.communityRankPrev || {};
10195        ol.innerHTML = items
10196            .map(function (row, i) {
10197                var rnk = i + 1;
10198                var tr = rankingTrendMarkHtml(row.service_id, rnk, prevMapPanel);
10199                var pillDisp = complaintsRankingPillDisplay(row);
10200                var rvalCls = 'rval' + (pillDisp.isDown ? ' rval--down' : pillDisp.isSpike ? ' rval--spike' : '');
10201                return (
10202                    '<li><button type="button" class="rank-line" data-svc-id="' +
10203                    esc(row.service_id) +
10204                    '"><span class="rn-wrap"><span class="rn">' +
10205                    esc(String(rnk)) +
10206                    '</span>' +
10207                    tr +
10208                    '</span><span class="rbody"><span class="rname">' +
10209                    esc(row.name) +
10210                    '</span><span class="rmeta">' +
10211                    communityRankingMetaLineHtml(row) +
10212                    '</span></span><span class="' +
10213                    esc(rvalCls) +
10214                    '" title="' +
10215                    esc(complaintsRankingPillTitle(row, pillDisp)) +
10216                    '"><span class="rval-label">' + esc(__('js_rank_pill_label') || 'Pulso') + '</span>' +
10217                    esc(pillDisp.value) +
10218                    '</span></button></li>'
10219                );
10220            })
10221            .join('');
10222        ol.querySelectorAll('.rank-line[data-svc-id]').forEach(function (btn) {
10223            btn.addEventListener('click', function () {
10224                var id = btn.getAttribute('data-svc-id');
10225                if (id) openModal(id);
10226            });
10227        });
10228        updateRankMoreBtn('btnMoreCommunity', 'community', rowsVotes.length);
10229        refreshSidebarComplaintsTop10();
10230        try {
10231            renderTop30List();
10232        } catch (eT30r) {}
10233    }
10234
10235    function mountCommunityRanking(forceRefresh) {
10236        var ol = document.getElementById('rankCommunity');
10237        var disc = document.getElementById('rankCommunityDisclaimer');
10238        if (disc && state.communityDisclaimerPt) disc.textContent = state.communityDisclaimerPt;
10239        if (!ol) return;
10240        var d = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
10241        var origin = window.location.origin || '';
10242
10243        if (forceRefresh) {
10244            state.communityRankingRows = undefined;
10245            state._fetchedCommunityRankingDays = null;
10246            state._fetchedCommunityRankingLimit = null;
10247            refreshSidebarComplaintsTop10();
10248            fetch(origin + '/api/detector/community-ranking?' + communityRankingApiQuery(d), { credentials: 'omit' })
10249                .then(function (r) {
10250                    return r.json().then(function (data) {
10251                        return { ok: r.ok, data: data };
10252                    });
10253                })
10254                .then(function (pack) {
10255                    var data = pack.data || {};
10256                    if (!pack.ok || !data.success) {
10257                        state.communityRankingRows = null;
10258                        state._fetchedCommunityRankingDays = null;
10259                        state._fetchedCommunityRankingLimit = null;
10260                        ol.innerHTML =
10261                            '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
10262                            esc(__('js_comm_ol_unavail')) +
10263                            '</li>';
10264                        updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10265                        refreshSidebarComplaintsTop10();
10266                        try {
10267                            renderTop30List();
10268                        } catch (eT30f) {}
10269                        return;
10270                    }
10271                    writeCommunityRankingSwrCache(data);
10272                    scheduleCommunityRankingMidnightRefresh(data.next_reset_utc);
10273                    if (data.disclaimer_pt && disc) disc.textContent = data.disclaimer_pt;
10274                    state.communityRankingRows = data.ranking || [];
10275                    state._fetchedCommunityRankingDays = d;
10276                    state._fetchedCommunityRankingLimit = COMMUNITY_RANKING_API_LIMIT;
10277                    if (data.disclaimer_pt) state.communityDisclaimerPt = data.disclaimer_pt;
10278                    if (!state.communityRankingRows.length) {
10279                        ol.innerHTML =
10280                            '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
10281                            esc(__('js_comm_ol_empty')) +
10282                            '</li>';
10283                        updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10284                        refreshSidebarComplaintsTop10();
10285                        try {
10286                            renderTop30List();
10287                        } catch (eT30g) {}
10288                        return;
10289                    }
10290                    applyCommunityRankingToCatalog(state.communityRankingRows);
10291                    renderCommunityRankingOl();
10292                })
10293                .catch(function () {
10294                    state.communityRankingRows = null;
10295                    state._fetchedCommunityRankingDays = null;
10296                    state._fetchedCommunityRankingLimit = null;
10297                    ol.innerHTML =
10298                        '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
10299                        esc(__('js_comm_ol_unavail')) +
10300                        '</li>';
10301                    updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10302                    refreshSidebarComplaintsTop10();
10303                    try {
10304                        renderTop30List();
10305                    } catch (eT30h) {}
10306                });
10307            return;
10308        }
10309
10310        if (
10311            state._fetchedCommunityRankingDays === d &&
10312            state._fetchedCommunityRankingLimit === COMMUNITY_RANKING_API_LIMIT &&
10313            Array.isArray(state.communityRankingRows)
10314        ) {
10315            applyCommunityRankingToCatalog(state.communityRankingRows);
10316            renderCommunityRankingOl();
10317            refreshSidebarComplaintsTop10();
10318            syncComplaintsTop10BoardsAndCards();
10319            if (state.detectorLoadInProgress) return;
10320        } else {
10321            var disk = readCommunityRankingSwrCache(d, COMMUNITY_RANKING_API_LIMIT);
10322            if (disk) {
10323                state.communityRankingRows = disk.ranking;
10324                state._fetchedCommunityRankingDays = d;
10325                state._fetchedCommunityRankingLimit = COMMUNITY_RANKING_API_LIMIT;
10326                if (disk.disclaimer_pt) {
10327                    state.communityDisclaimerPt = disk.disclaimer_pt;
10328                    if (disc) disc.textContent = disk.disclaimer_pt;
10329                }
10330                if (disk.next_reset_utc) scheduleCommunityRankingMidnightRefresh(disk.next_reset_utc);
10331                applyCommunityRankingToCatalog(state.communityRankingRows);
10332                renderCommunityRankingOl();
10333                refreshSidebarComplaintsTop10();
10334                syncComplaintsTop10BoardsAndCards();
10335                if (state.detectorLoadInProgress) return;
10336            } else {
10337                state.communityRankingRows = undefined;
10338                refreshSidebarComplaintsTop10();
10339                if (state.detectorLoadInProgress) return;
10340            }
10341        }
10342
10343        fetch(origin + '/api/detector/community-ranking?' + communityRankingApiQuery(d), { credentials: 'omit' })
10344            .then(function (r) {
10345                return r.json().then(function (data) {
10346                    return { ok: r.ok, data: data };
10347                });
10348            })
10349            .then(function (pack) {
10350                var data = pack.data || {};
10351                if (!pack.ok || !data.success) {
10352                    if (state._fetchedCommunityRankingDays === d && Array.isArray(state.communityRankingRows)) {
10353                        try {
10354                            renderTop30List();
10355                        } catch (eT30f2) {}
10356                        return;
10357                    }
10358                    state.communityRankingRows = null;
10359                    state._fetchedCommunityRankingDays = null;
10360                    state._fetchedCommunityRankingLimit = null;
10361                    ol.innerHTML =
10362                        '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
10363                        esc(__('js_comm_ol_unavail')) +
10364                        '</li>';
10365                    updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10366                    refreshSidebarComplaintsTop10();
10367                    try {
10368                        renderTop30List();
10369                    } catch (eT30f) {}
10370                    return;
10371                }
10372                writeCommunityRankingSwrCache(data);
10373                scheduleCommunityRankingMidnightRefresh(data.next_reset_utc);
10374                if (data.disclaimer_pt && disc) disc.textContent = data.disclaimer_pt;
10375                state.communityRankingRows = data.ranking || [];
10376                state._fetchedCommunityRankingDays = d;
10377                state._fetchedCommunityRankingLimit = COMMUNITY_RANKING_API_LIMIT;
10378                if (data.disclaimer_pt) state.communityDisclaimerPt = data.disclaimer_pt;
10379                if (!state.communityRankingRows.length) {
10380                    ol.innerHTML =
10381                        '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
10382                        esc(__('js_comm_ol_empty')) +
10383                        '</li>';
10384                    updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10385                    refreshSidebarComplaintsTop10();
10386                    try {
10387                        renderTop30List();
10388                    } catch (eT30g) {}
10389                    return;
10390                }
10391                applyCommunityRankingToCatalog(state.communityRankingRows);
10392                renderCommunityRankingOl();
10393            })
10394            .catch(function () {
10395                if (state._fetchedCommunityRankingDays === d && Array.isArray(state.communityRankingRows)) {
10396                    try {
10397                        renderTop30List();
10398                    } catch (eT30h2) {}
10399                    return;
10400                }
10401                state.communityRankingRows = null;
10402                state._fetchedCommunityRankingDays = null;
10403                state._fetchedCommunityRankingLimit = null;
10404                ol.innerHTML =
10405                    '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
10406                    esc(__('js_comm_ol_unavail')) +
10407                    '</li>';
10408                updateRankMoreBtn('btnMoreCommunity', 'community', 0);
10409                refreshSidebarComplaintsTop10();
10410                try {
10411                    renderTop30List();
10412                } catch (eT30h) {}
10413            });
10414    }
10415
10416    function bumpCommunityVoteLocal(svcId) {
10417        if (!state.catalog || !svcId) return;
10418        for (var i = 0; i < state.catalog.length; i++) {
10419            if (state.catalog[i].id === svcId) {
10420                var c = Number(state.catalog[i].community_vote_count) || 0;
10421                state.catalog[i].community_vote_count = c + 1;
10422                break;
10423            }
10424        }
10425    }
10426
10427    function passesIpStackFilter(svc) {
10428        var f = state.ipStackFilter || 'all';
10429        if (f === 'all') return true;
10430        var st = svc.ip_stack || 'unknown';
10431        if (f === 'v4') return st === 'v4';
10432        if (f === 'dual') return st === 'dual';
10433        if (f === 'v6') return st === 'v6';
10434        return true;
10435    }
10436
10437    /** Minúsculas + remove diacríticos (pt), para bater «comercio» com «comércio». */
10438    function normalizeCatalogSearchString(s) {
10439        var t = String(s || '').toLowerCase();
10440        try {
10441            t = t.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
10442        } catch (e) {
10443            /* IE / ambientes sem normalize */
10444        }
10445        return t;
10446    }
10447
10448    /** Só letras e dígitos — «Chat GPT» → «chatgpt», útil para «gpt» bater no nome composto. */
10449    function catalogSearchCompact(s) {
10450        return normalizeCatalogSearchString(s).replace(/[^a-z0-9]+/g, '');
10451    }
10452
10453    /**
10454     * Texto agregado para busca: nome, id, descrição, domínio, categoria, URLs e alvos de sonda.
10455     * Inclui campos que antes não entravam (ex.: official_url), para termos como marcas/paths.
10456     */
10457    function catalogSearchHaystackParts(svc) {
10458        var parts = [];
10459        function push(x) {
10460            if (x != null && x !== '') parts.push(String(x));
10461        }
10462        push(svc.name);
10463        push(svc.id);
10464        push(svc.description);
10465        push(svc.logo_domain);
10466        push(svc.category);
10467        push(catLabel(svc.category));
10468        push(svc.official_url);
10469        push(svc.status_url);
10470        push(svc.validation_url);
10471        push(svc.probe_url);
10472        push(svc.card_probe_url);
10473        var tl = svc.targets_live;
10474        if (Array.isArray(tl)) {
10475            for (var i = 0; i < tl.length; i++) {
10476                var x = tl[i];
10477                if (!x) continue;
10478                push(x.url);
10479                push(x.label);
10480                push(x.key);
10481            }
10482        }
10483        var targets = svc.targets;
10484        if (Array.isArray(targets)) {
10485            for (var j = 0; j < targets.length; j++) {
10486                var y = targets[j];
10487                if (!y) continue;
10488                push(y.url);
10489                push(y.label);
10490                push(y.key);
10491            }
10492        }
10493        return parts;
10494    }
10495
10496    /** Mesma lógica do filtro do catálogo, com texto de pesquisa explícito (ex.: modal Enter). */
10497    function passesCatalogSearchQuery(svc, raw) {
10498        raw = String(raw || '').trim();
10499        if (!raw) return true;
10500        var normQ = normalizeCatalogSearchString(raw);
10501        var tokens = normQ.split(/\s+/).filter(function (w) {
10502            return w.length > 0;
10503        });
10504        if (!tokens.length) return true;
10505        var parts = catalogSearchHaystackParts(svc);
10506        var hayNormal = normalizeCatalogSearchString(parts.join(' \n '));
10507        var hayCompact = catalogSearchCompact(parts.join(' '));
10508        for (var t = 0; t < tokens.length; t++) {
10509            var tok = tokens[t];
10510            if (hayNormal.indexOf(tok) >= 0) continue;
10511            var tokC = catalogSearchCompact(tok);
10512            if (tokC.length >= 2 && hayCompact.indexOf(tokC) >= 0) continue;
10513            return false;
10514        }
10515        return true;
10516    }
10517
10518    function passesCatalogSearch(svc) {
10519        return passesCatalogSearchQuery(svc, state.catalogSearch || '');
10520    }
10521
10522    var CATALOG_SEARCH_MODAL_MAX = 120;
10523
10524    function catalogSearchModalSortKey(svc, raw) {
10525        var normQ = normalizeCatalogSearchString(String(raw || '').trim());
10526        var cq = catalogSearchCompact(raw);
10527        var name = normalizeCatalogSearchString(svc.name || '');
10528        var id = normalizeCatalogSearchString(String(svc.id || ''));
10529        var cname = catalogSearchCompact(svc.name || '');
10530        var cid = catalogSearchCompact(String(svc.id || ''));
10531        var tier = 40;
10532        if (normQ && (id === normQ || cid === cq)) tier = 0;
10533        else if (normQ && name.indexOf(normQ) === 0) tier = 1;
10534        else if (cq.length >= 2 && cname.indexOf(cq) === 0) tier = 2;
10535        else if (normQ && name.indexOf(normQ) >= 0) tier = 3;
10536        else tier = 4;
10537        return { tier: tier, name: String(svc.name || '').toLowerCase() };
10538    }
10539
10540    function catalogSearchModalMatches(raw) {
10541        var q = String(raw || '').trim();
10542        var cat = state.catalog;
10543        if (!cat || !cat.length) return [];
10544        var out = [];
10545        for (var i = 0; i < cat.length; i++) {
10546            var s = cat[i];
10547            if (!passesFilterSansSearch(s)) continue;
10548            if (!passesCatalogSearchQuery(s, q)) continue;
10549            out.push(s);
10550        }
10551        out.sort(function (a, b) {
10552            var ka = catalogSearchModalSortKey(a, q);
10553            var kb = catalogSearchModalSortKey(b, q);
10554            if (ka.tier !== kb.tier) return ka.tier - kb.tier;
10555            return ka.name.localeCompare(kb.name, i18nSortLocale());
10556        });
10557        return out;
10558    }
10559
10560    function catalogSearchModalCardHtml(svc) {
10561        var cat = catLabel(svc.category) || svc.category || '';
10562        var dom = svc.logo_domain ? String(svc.logo_domain) : '';
10563        var lat = rankingLatencyMs(svc);
10564        var latTxt =
10565            svc.last_ok === true && lat != null && !isNaN(lat)
10566                ? String(Math.round(lat)) + ' ms'
10567                : svc.last_ok === false
10568                  ? 'erro'
10569                  : '—';
10570        var st = svc.last_ok === true ? 'ok' : svc.last_ok === false ? 'bad' : 'unk';
10571        return (
10572            '<button type="button" class="csm-card" data-svc-id="' +
10573            esc(svc.id) +
10574            '" role="listitem">' +
10575            detLogoImgHtml(svc, 'csm-card__logo', '') +
10576            '<span class="csm-card__body">' +
10577            '<span class="csm-card__name">' +
10578            esc(svc.name || svc.id) +
10579            '</span>' +
10580            '<span class="csm-card__meta">' +
10581            esc(cat) +
10582            (dom ? '<span class="csm-card__dot"> · </span><span class="csm-card__dom">' + esc(dom) + '</span>' : '') +
10583            '</span>' +
10584            '</span>' +
10585            '<span class="csm-card__lat csm-card__lat--' +
10586            st +
10587            '" aria-label="Latência">' +
10588            esc(latTxt) +
10589            '</span>' +
10590            '</button>'
10591        );
10592    }
10593
10594    function renderCatalogSearchModal(raw) {
10595        var modal = document.getElementById('catalogSearchModal');
10596        var qEl = document.getElementById('catalogSearchModalQuery');
10597        var hintEl = document.getElementById('catalogSearchModalHint');
10598        var grid = document.getElementById('catalogSearchModalGrid');
10599        var foot = document.getElementById('catalogSearchModalFoot');
10600        if (!modal || !grid) return;
10601        var q = String(raw || '').trim();
10602        if (qEl) {
10603            qEl.textContent = q ? '«' + q + '»' : '';
10604            qEl.style.display = q ? '' : 'none';
10605        }
10606        if (!q) {
10607            if (hintEl) {
10608                hintEl.hidden = false;
10609                hintEl.textContent =
10610                    'Escreva um termo na caixa de pesquisa (nome, domínio ou ID) e prima Enter para ver correspondências em todo o catálogo.';
10611            }
10612            grid.innerHTML =
10613                '<div class="csm-empty csm-empty--soft" role="status">Nenhuma pesquisa ativa.</div>';
10614            if (foot) {
10615                foot.hidden = true;
10616                foot.textContent = '';
10617            }
10618            return;
10619        }
10620        if (hintEl) hintEl.hidden = true;
10621        var all = catalogSearchModalMatches(q);
10622        var total = all.length;
10623        var slice = all.slice(0, CATALOG_SEARCH_MODAL_MAX);
10624        if (!total) {
10625            grid.innerHTML =
10626                '<div class="csm-empty" role="status">Nenhum serviço corresponde a esta pesquisa no catálogo completo.</div>';
10627        } else {
10628            grid.innerHTML = slice.map(catalogSearchModalCardHtml).join('');
10629            bindDetLogoFallbacks(grid);
10630        }
10631        if (foot) {
10632            if (total === 0) {
10633                foot.hidden = true;
10634                foot.textContent = '';
10635            } else if (total > CATALOG_SEARCH_MODAL_MAX) {
10636                foot.hidden = false;
10637                foot.textContent =
10638                    'A mostrar ' +
10639                    String(CATALOG_SEARCH_MODAL_MAX) +
10640                    ' de ' +
10641                    String(total) +
10642                    ' resultados. Refine a pesquisa para listar menos entradas.';
10643            } else {
10644                foot.hidden = false;
10645                foot.textContent =
10646                    total === 1 ? '1 resultado no catálogo completo.' : String(total) + ' resultados no catálogo completo.';
10647            }
10648        }
10649    }
10650
10651    function openCatalogSearchResultsModal() {
10652        var modal = document.getElementById('catalogSearchModal');
10653        if (!modal) return;
10654        var inp = document.getElementById('catalogSearchInput');
10655        if (inp) {
10656            if (_catalogSearchDebounce) {
10657                clearTimeout(_catalogSearchDebounce);
10658                _catalogSearchDebounce = null;
10659            }
10660            state.catalogSearch = inp.value || '';
10661        }
10662        renderCatalogSearchModal(state.catalogSearch || '');
10663        modal.classList.add('open');
10664        modal.setAttribute('aria-hidden', 'false');
10665        document.body.style.overflow = 'hidden';
10666        var closeBtn = document.getElementById('catalogSearchModalClose');
10667        if (closeBtn) closeBtn.focus();
10668    }
10669
10670    function closeCatalogSearchResultsModal() {
10671        var modal = document.getElementById('catalogSearchModal');
10672        if (!modal) return;
10673        modal.classList.remove('open');
10674        modal.setAttribute('aria-hidden', 'true');
10675        document.body.style.overflow = '';
10676        var inp = document.getElementById('catalogSearchInput');
10677        if (inp) inp.focus();
10678    }
10679
10680    function wireCatalogSearchModal() {
10681        var modal = document.getElementById('catalogSearchModal');
10682        if (!modal || modal.dataset.detectorCsmWired === '1') return;
10683        modal.dataset.detectorCsmWired = '1';
10684        var closeBtn = document.getElementById('catalogSearchModalClose');
10685        if (closeBtn) {
10686            closeBtn.addEventListener('click', function () {
10687                closeCatalogSearchResultsModal();
10688            });
10689        }
10690        modal.addEventListener('click', function (e) {
10691            if (e.target === modal) closeCatalogSearchResultsModal();
10692        });
10693        document.addEventListener('keydown', function (e) {
10694            if (e.key === 'Escape' && modal.classList.contains('open')) {
10695                closeCatalogSearchResultsModal();
10696            }
10697        });
10698        var grid = document.getElementById('catalogSearchModalGrid');
10699        if (grid) {
10700            grid.addEventListener('click', function (e) {
10701                var btn = e.target && e.target.closest ? e.target.closest('.csm-card[data-svc-id]') : null;
10702                if (!btn) return;
10703                var id = btn.getAttribute('data-svc-id');
10704                if (!id) return;
10705                closeCatalogSearchResultsModal();
10706                openModal(id);
10707            });
10708        }
10709    }
10710
10711    function passesFilter(svc) {
10712        if (!passesIpStackFilter(svc)) return false;
10713        if (state.filter !== 'all' && (svc.category || '') !== state.filter) return false;
10714        if (!passesCatalogSearch(svc)) return false;
10715        return true;
10716    }
10717
10718    /** Categoria + stack IP (sem texto da caixa de busca) — rankings Top 5 e problemas globais. */
10719    function passesFilterSansSearch(svc) {
10720        if (!passesIpStackFilter(svc)) return false;
10721        if (state.filter !== 'all' && (svc.category || '') !== state.filter) return false;
10722        return true;
10723    }
10724
10725    function filteredCatalog() {
10726        if (!state.catalog) return [];
10727        return state.catalog.filter(passesFilter);
10728    }
10729
10730    function filteredCatalogSansSearch() {
10731        if (!state.catalog) return [];
10732        return state.catalog.filter(passesFilterSansSearch);
10733    }
10734
10735    function nonSearchFiltersActive() {
10736        if (state.filter && state.filter !== 'all') return true;
10737        var ipf = state.ipStackFilter || 'all';
10738        if (ipf !== 'all') return true;
10739        return false;
10740    }
10741
10742    /** Pesquisa, categoria ou stack IP ≠ «todos» — tops e banner seguem o subconjunto filtrado. */
10743    function catalogFiltersActive() {
10744        var q = (state.catalogSearch || '').trim();
10745        if (q) return true;
10746        if (state.filter && state.filter !== 'all') return true;
10747        var ipf = state.ipStackFilter || 'all';
10748        if (ipf !== 'all') return true;
10749        return false;
10750    }
10751
10752    function updateCatalogFilterBanner() {
10753        var sec = document.getElementById('secShelves');
10754        var ban = document.getElementById('catalogFilterBanner');
10755        if (!sec || !ban) return;
10756        if (!catalogFiltersActive()) {
10757            sec.classList.remove('is-filtered');
10758            ban.innerHTML = '';
10759            ban.setAttribute('hidden', '');
10760            return;
10761        }
10762        var n = filteredCatalog().length;
10763        var chips = [];
10764        var q = (state.catalogSearch || '').trim();
10765        if (q) {
10766            chips.push(
10767                '<span class="cfb-chip cfb-chip--search" title="Pesquisa no catálogo">Busca: <strong>' +
10768                    esc(q) +
10769                    '</strong></span>',
10770            );
10771        }
10772        if (state.filter && state.filter !== 'all') {
10773            var cl = catLabel(state.filter) || state.filter;
10774            chips.push('<span class="cfb-chip cfb-chip--cat" title="Categoria">Cat.: ' + esc(cl) + '</span>');
10775        }
10776        var ipf = state.ipStackFilter || 'all';
10777        if (ipf !== 'all') {
10778            var ipLab =
10779                ipf === 'v4' ? 'Só IPv4' : ipf === 'dual' ? 'IPv4 + IPv6' : ipf === 'v6' ? 'Só IPv6' : ipf;
10780            chips.push('<span class="cfb-chip cfb-chip--ip" title="Stack IP">' + esc(ipLab) + '</span>');
10781        }
10782        ban.innerHTML =
10783            '<div class="cfb-inner">' +
10784            '<span class="cfb-label">' +
10785            esc(__('js_filter_banner_label')) +
10786            '</span>' +
10787            '<div class="cfb-chips">' +
10788            chips.join('') +
10789            '</div>' +
10790            '<span class="cfb-count">' +
10791            esc(String(n)) +
10792            ' serviço' +
10793            (n !== 1 ? 's' : '') +
10794            '</span>' +
10795            '<button type="button" class="cfb-clear" id="btnCatalogFilterClear">Limpar filtro</button>' +
10796            '</div>';
10797        ban.removeAttribute('hidden');
10798        sec.classList.add('is-filtered');
10799        var clr = document.getElementById('btnCatalogFilterClear');
10800        if (clr) {
10801            clr.onclick = function () {
10802                resetCatalogHomeView();
10803            };
10804        }
10805    }
10806
10807    function problemsIndex() {
10808        var d = state.summary;
10809        var ix = {};
10810        if (!d || !d.success) return ix;
10811        var raw = d.saas_problems || [];
10812        for (var i = 0; i < raw.length; i++) {
10813            var p = raw[i];
10814            var sl = String(p.slug || '')
10815                .trim()
10816                .toLowerCase();
10817            if (sl) ix[sl] = p;
10818        }
10819        return ix;
10820    }
10821
10822    function svcStatuspageSlug(svc) {
10823        var id = svc.id || '';
10824        if (id.indexOf('sp_') === 0) return id.slice(3).toLowerCase();
10825        return '';
10826    }
10827
10828    function latencyMs(svc) {
10829        var v = rankingLatencyMs(svc);
10830        return v != null && !isNaN(v) ? v : null;
10831    }
10832
10833    /**
10834     * Ordenação estável entre estados da sonda principal: OK → sem amostra → erro.
10835     * Mantém cartões com falha na sondagem HTTPS/TLS no fim de ca
10835da prateleira (melhor UX).
10836     */
10837    function svcProbeOkSortGroup(svc) {
10838        if (svc.last_ok === false) return 2;
10839        if (svc.last_ok === true) return 0;
10840        return 1;
10841    }
10842
10843    function sortItems(items) {
10844        var mode = state.rankSort || 'live_problems';
10845        var prob = problemsIndex();
10846        var arr = items.slice();
10847        var saasBySlug = null;
10848        var gamesBySlug = null;
10849        if (mode === 'live_problems') {
10850            saasBySlug = buildSaasSlugMap();
10851            gamesBySlug = buildGamesSlugMap();
10852        }
10853        function nm(s) {
10854            return String(s.name || '').toLowerCase();
10855        }
10856        function liveScore(svc) {
10857            return problemEntryForService(svc, saasBySlug, gamesBySlug).score;
10858        }
10859        arr.sort(function (a, b) {
10860            var ga = svcProbeOkSortGroup(a);
10861            var gb = svcProbeOkSortGroup(b);
10862            if (ga !== gb) return ga - gb;
10863            if (mode === 'name') return nm(a).localeCompare(nm(b), i18nSortLocale());
10864            if (mode === 'name_desc') return nm(b).localeCompare(nm(a), i18nSortLocale());
10865            if (mode === 'live_problems') {
10866                var sb = liveScore(b);
10867                var sa = liveScore(a);
10868                if (sb !== sa) return sb - sa;
10869                return cmpRankingLatencyWorst(a, b);
10870            }
10871            if (mode === 'rank_global') {
10872                var ra = a.latency_rank != null ? a.latency_rank : 999999;
10873                var rb = b.latency_rank != null ? b.latency_rank : 999999;
10874                if (ra !== rb) return ra - rb;
10875                return nm(a).localeCompare(nm(b), i18nSortLocale());
10876            }
10877            if (mode === 'fail_first') {
10878                var fa = a.last_ok === false ? 0 : a.last_ok === true ? 1 : 2;
10879                var fb = b.last_ok === false ? 0 : b.last_ok === true ? 1 : 2;
10880                if (fa !== fb) return fa - fb;
10881                var la = latencyMs(a);
10882                var lb = latencyMs(b);
10883                if (la != null && lb != null && la !== lb) return la - lb;
10884                return nm(a).localeCompare(nm(b), i18nSortLocale());
10885            }
10886            if (mode === 'incident_first') {
10887                var sa = svcStatuspageSlug(a);
10888                var sb = svcStatuspageSlug(b);
10889                var pa = sa ? prob[sa] : null;
10890                var pb = sb ? prob[sb] : null;
10891                var ia = pa ? (Number(pa.severity) || 0) * 1000 + (Number(pa.incidents_count) || 0) : 0;
10892                var ib = pb ? (Number(pb.severity) || 0) * 1000 + (Number(pb.incidents_count) || 0) : 0;
10893                if (ib !== ia) return ib - ia;
10894                var l1 = latencyMs(a);
10895                var l2 = latencyMs(b);
10896                if (l1 != null && l2 != null && l1 !== l2) return l1 - l2;
10897                return nm(a).localeCompare(nm(b), i18nSortLocale());
10898            }
10899            if (mode === 'latency_desc') {
10900                return cmpRankingLatencyWorst(a, b);
10901            }
10902            var x = latencyMs(a);
10903            var y = latencyMs(b);
10904            if (x == null && y == null) return nm(a).localeCompare(nm(b), i18nSortLocale());
10905            if (x == null) return 1;
10906            if (y == null) return -1;
10907            var rx = Math.round(x);
10908            var ry = Math.round(y);
10909            if (rx !== ry) return rx - ry;
10910            var pxa = marketProminenceForLatencyRank(a);
10911            var pxb = marketProminenceForLatencyRank(b);
10912            if (pxb !== pxa) return pxb - pxa;
10913            if (x !== y) return x - y;
10914            return nm(a).localeCompare(nm(b), i18nSortLocale());
10915        });
10916        return arr;
10917    }
10918
10919    function rebuildCategorySelect() {
10920        var sel = document.getElementById('selCategory');
10921        if (!sel || !state.catalog) return;
10922        var cur = state.filter;
10923        sel.innerHTML = '';
10924        var o0 = document.createElement('option');
10925        o0.value = 'all';
10926        o0.textContent = __('cat_filter_all');
10927        sel.appendChild(o0);
10928        var seen = {};
10929        for (var i = 0; i < state.catalog.length; i++) {
10930            var c = state.catalog[i].category || 'core';
10931            seen[c] = true;
10932        }
10933        var keys = Object.keys(seen).sort(function (a, b) {
10934            return catLabel(a).localeCompare(catLabel(b), i18nSortLocale());
10935        });
10936        for (var k = 0; k < keys.length; k++) {
10937            var key = keys[k];
10938            var o = document.createElement('option');
10939            o.value = key;
10940            o.textContent = catLabel(key);
10941            sel.appendChild(o);
10942        }
10943        var oTodos = document.createElement('option');
10944        oTodos.value = '__all__';
10945        oTodos.textContent = __('cat_filter_todos');
10946        sel.appendChild(oTodos);
10947        if (cur === 'all') sel.value = '__all__';
10948        else if (seen[cur]) sel.value = cur;
10949        else {
10950            sel.value = '__all__';
10951            state.filter = 'all';
10952        }
10953    }
10954
10955    function fillRankSelectOptions() {
10956        var sel = document.getElementById('selRank');
10957        if (!sel) return;
10958        var cur = sel.value || state.rankSort;
10959        sel.innerHTML = '';
10960        for (var i = 0; i < RANK_OPTION_KEYS.length; i++) {
10961            var o = document.createElement('option');
10962            o.value = RANK_OPTION_KEYS[i][0];
10963            o.textContent = __(RANK_OPTION_KEYS[i][1]);
10964            sel.appendChild(o);
10965        }
10966        var okVal = false;
10967        for (var j = 0; j < RANK_OPTION_KEYS.length; j++) {
10968            if (RANK_OPTION_KEYS[j][0] === cur) {
10969                okVal = true;
10970                break;
10971            }
10972        }
10973        sel.value = okVal ? cur : state.rankSort;
10974        if (sel.value !== cur && okVal === false) state.rankSort = sel.value || 'live_problems';
10975    }
10976
10977    function initRankSelect() {
10978        var sel = document.getElementById('selRank');
10979        if (!sel || sel.dataset.detectorInited === '1') return;
10980        sel.dataset.detectorInited = '1';
10981        fillRankSelectOptions();
10982        sel.addEventListener('change', function () {
10983            state.rankSort = sel.value || 'live_problems';
10984            mountShelves();
10985            mountRankPanels();
10986        });
10987    }
10988
10989    function wireCategorySelect() {
10990        var sel = document.getElementById('selCategory');
10991        if (!sel || sel.dataset.detectorWired === '1') return;
10992        sel.dataset.detectorWired = '1';
10993        sel.addEventListener('change', function () {
10994            var v = sel.value || 'all';
10995            state.filter = v === '__all__' || v === 'all' ? 'all' : v;
10996            state.rankExpanded.latFast = false;
10997            state.rankExpanded.latSlow = false;
10998            mountShelves();
10999            mountRankPanels();
11000            mountProblemsCatalog();
11001            updateMetricsBar();
11002            applyFilterSections();
11003            refreshShelfHints();
11004        });
11005    }
11006
11007    function wireIpStackSelect() {
11008        var sel = document.getElementById('selIpStack');
11009        if (!sel || sel.dataset.detectorIpWired === '1') return;
11010        sel.dataset.detectorIpWired = '1';
11011        sel.addEventListener('change', function () {
11012            state.ipStackFilter = sel.value || 'all';
11013            state.rankExpanded.latFast = false;
11014            state.rankExpanded.latSlow = false;
11015            mountShelves();
11016            mountRankPanels();
11017            mountProblemsCatalog();
11018            updateMetricsBar();
11019        });
11020    }
11021
11022    var _catalogSearchDebounce = null;
11023    function wireCatalogSearchInput() {
11024        var inp = document.getElementById('catalogSearchInput');
11025        if (!inp || inp.dataset.detectorSearchWired === '1') return;
11026        inp.dataset.detectorSearchWired = '1';
11027        inp.addEventListener('input', function () {
11028            state.catalogSearch = inp.value || '';
11029            mountTop10Cards();
11030            updateCatalogFilterBanner();
11031            if (_catalogSearchDebounce) clearTimeout(_catalogSearchDebounce);
11032            _catalogSearchDebounce = setTimeout(function () {
11033                _catalogSearchDebounce = null;
11034                var refocusSearch = document.activeElement === inp;
11035                var selA = inp.selectionStart;
11036                var selB = inp.selectionEnd;
11037                state.rankExpanded.latFast = false;
11038                state.rankExpanded.latSlow = false;
11039                mountCatalogGridAndIcmpSection();
11040                updateMetricsBar();
11041                applyFilterSections();
11042                if (refocusSearch) {
11043                    var inp2 = document.getElementById('catalogSearchInput');
11044                    if (inp2) {
11045                        inp2.focus();
11046                        if (typeof inp2.setSelectionRange === 'function') {
11047                            var len = String(inp2.value || '').length;
11048                            var a = typeof selA === 'number' && !isNaN(selA) ? selA : len;
11049                            var b = typeof selB === 'number' && !isNaN(selB) ? selB : len;
11050                            if (a > len) a = len;
11051                            if (b > len) b = len;
11052                            try {
11053                                inp2.setSelectionRange(a, b);
11054                            } catch (eSel) {
11055                                /* IME / type=search edge cases */
11056                            }
11057                        }
11058                    }
11059                }
11060            }, 0);
11061        });
11062        inp.addEventListener('keydown', function (e) {
11063            if (e.key !== 'Enter') return;
11064            e.preventDefault();
11065            if (_catalogSearchDebounce) {
11066                clearTimeout(_catalogSearchDebounce);
11067                _catalogSearchDebounce = null;
11068            }
11069            state.catalogSearch = inp.value || '';
11070            state.rankExpanded.latFast = false;
11071            state.rankExpanded.latSlow = false;
11072            mountShelves();
11073            updateMetricsBar();
11074            applyFilterSections();
11075            openCatalogSearchResultsModal();
11076        });
11077    }
11078
11079    function updateProbePopBadge(pp) {
11080        var el = document.getElementById('probePopBadge');
11081        var lbl = document.getElementById('probePopBadgeLabel');
11082        if (!el) return;
11083        var pub = pp && pp.public_hint_pt ? String(pp.public_hint_pt).trim() : '';
11084        if (!pub) {
11085            el.style.display = 'none';
11086            el.title = '';
11087            el.classList.remove('probe-pop-badge--ix');
11088            return;
11089        }
11090        el.style.display = 'inline-flex';
11091        el.title = pub;
11092        var ix = !!(pp && pp.delegate_active);
11093        el.classList.toggle('probe-pop-badge--ix', ix);
11094        if (lbl) lbl.textContent = ix ? 'IX·BR' : 'Rede Catellix';
11095    }
11096
11097    function renderObservationHub(profile) {
11098        var wrap = document.getElementById('detObservationHub');
11099        var list = document.getElementById('detObservationHubList');
11100        var note = document.getElementById('detObservationHubNote');
11101        var dashObs = document.getElementById('detDashObs');
11102        if (!wrap || !list || !note) return;
11103        profile = profile || {};
11104        var pts = profile.observation_points || [];
11105        if (!pts.length) {
11106            wrap.style.display = 'none';
11107            if (dashObs) dashObs.hidden = true;
11108            return;
11109        }
11110        if (dashObs) dashObs.hidden = false;
11111        wrap.style.display = 'block';
11112        list.innerHTML = pts
11113            .map(function (p) {
11114                var active = p.active !== false;
11115                var lab = esc(p.label_pt || p.label || '');
11116                return (
11117                    '<li class="' +
11118                    (active ? '' : 'det-obs-inactive') +
11119                    '">' +
11120                    lab +
11121                    (active ? '' : ' · inativo') +
11122                    '</li>'
11123                );
11124            })
11125            .join('');
11126        note.textContent = '';
11127        note.style.display = 'none';
11128    }
11129
11130    function buildSaasSlugMap() {
11131        var map = {};
11132        var probs = (state.summary && state.summary.saas_problems) || [];
11133        for (var i = 0; i < probs.length; i++) {
11134            var p = probs[i];
11135            var sl = String(p.slug || '')
11136                .trim()
11137                .toLowerCase();
11138            if (!sl) continue;
11139            if (!map[sl] || (p.severity || 0) > (map[sl].severity || 0)) map[sl] = p;
11140        }
11141        return map;
11142    }
11143
11144    function buildGamesSlugMap() {
11145        var map = {};
11146        var g = (state.summary && state.summary.games) || [];
11147        for (var j = 0; j < g.length; j++) {
11148            var x = g[j];
11149            var sl = String(x.slug || '')
11150                .trim()
11151                .toLowerCase();
11152            if (sl) map[sl] = x;
11153        }
11154        return map;
11155    }
11156
11157    function catalogServiceForGameSlug(slug) {
11158        if (!state.catalog) return null;
11159        var s0 = String(slug || '')
11160            .trim()
11161            .toLowerCase();
11162        if (!s0) return null;
11163        var dom = GAME_SLUG_LOGO[s0];
11164        for (var i = 0; i < state.catalog.length; i++) {
11165            var s = state.catalog[i];
11166            var id = String(s.id || '').toLowerCase();
11167            if (id === 'sp_' + s0) return s;
11168            if (dom && String(s.logo_domain || '').toLowerCase() === dom) return s;
11169        }
11170        return null;
11171    }
11172
11173    function gameIssuesForService(svc, gamesBySlug) {
11174        var extra = 0;
11175        var rs = [];
11176        for (var gslug in gamesBySlug) {
11177            if (!Object.prototype.hasOwnProperty.call(gamesBySlug, gslug)) continue;
11178            var hit = catalogServiceForGameSlug(gslug);
11179            if (!hit || hit.id !== svc.id) continue;
11180            var gg = gamesBySlug[gslug];
11181            if (!gg) continue;
11182            if (gg.state === 'bad') {
11183                extra += 600;
11184                rs.push('Plataforma (jogos): incidente / alerta');
11185            } else if (gg.state === 'warn') {
11186                extra += 320;
11187                rs.push('Plataforma (jogos): atenção');
11188            }
11189        }
11190        return { extra: extra, reasons: rs };
11191    }
11192
11193    function problemEntryForService(svc, saasBySlug, gamesBySlug) {
11194        var score = 0;
11195        var reasons = [];
11196        if (svc.last_ok === false) {
11197            score += 10000;
11198            reasons.push('Sondagem HTTPS falhou');
11199        }
11200        var slugFromId = '';
11201        if ((svc.id || '').indexOf('sp_') === 0) {
11202            slugFromId = svc.id.slice(3).toLowerCase();
11203        }
11204        var sp = slugFromId ? saasBySlug[slugFromId] : null;
11205        if (sp && (sp.severity || 0) >= 1) {
11206            score += 800 + (sp.severity || 0) * 200 + (sp.incidents_count || 0) * 30;
11207            var ind = String(sp.indicator || '').trim();
11208            reasons.push(
11209                'Statuspage' +
11210                    (ind ? ' (' + ind + ')' : '') +
11211                    (sp.incidents_count ? ' · ' + sp.incidents_count + ' ativos' : ''),
11212            );
11213        }
11214        var gi = gameIssuesForService(svc, gamesBySlug);
11215        score += gi.extra;
11216        reasons = reasons.concat(gi.reasons);
11217        var votes = Number(svc.community_vote_count) || 0;
11218        if (votes >= 1) {
11219            score += Math.min(votes, 25) * 40;
11220            reasons.push('Comunidade: ' + votes + ' relato(s) (' + state.communityWindowDays + 'd)');
11221        }
11222        return { svc: svc, score: score, reasons: reasons };
11223    }
11224
11225    function sortProblemEntries(a, b) {
11226        if (b.score !== a.score) return b.score - a.score;
11227        return cmpRankingLatencyWorst(a.svc, b.svc);
11228    }
11229
11230    function topWorstLatencyEntries(svcs, n) {
11231        return svcs
11232            .filter(function (s) {
11233                return hasRankingLatencySample(s);
11234            })
11235            .sort(function (a, b) {
11236                return cmpRankingLatencyWorst(a, b);
11237            })
11238            .slice(0, n)
11239            .map(function (svc) {
11240                return { svc: svc, score: 0, reasons: [], latencyFallback: true };
11241            });
11242    }
11243
11244    function problemRowHtml(ent, idx, modeLatency) {
11245        var svc = ent.svc;
11246        var meta =
11247            modeLatency || ent.latencyFallback
11248                ? 'Pior latência entre sondagens OK neste grupo.'
11249                : esc(ent.reasons.join(' · '));
11250        var rlm = rankingLatencyMs(svc);
11251        var ms =
11252            rlm != null && !isNaN(rlm) ? esc(String(Math.round(rlm))) + ' ms' : '—';
11253        return (
11254            '<li><button type="button" class="rank-line" data-svc-id="' +
11255            esc(svc.id) +
11256            '"><span class="rn">' +
11257            esc(String(idx + 1)) +
11258            '</span>' +
11259            detLogoImgHtml(svc, 'rfav', '') +
11260            '<span class="rbody"><span class="rname">' +
11261            esc(svc.name) +
11262            '</span><span class="rmeta">' +
11263            esc(catLabel(svc.category) || svc.category || '') +
11264            ipStackTagsHtmlCompact(svc) +
11265            ' · ' +
11266            meta +
11267            '</span></span><span class="rval">' +
11268            ms +
11269            '</span></button></li>'
11270        );
11271    }
11272
11273    function mountProblemsCatalog() {
11274        var gOl = document.getElementById('probCatalogGlobal');
11275        var byHost = document.getElementById('probCatalogByCat');
11276        var gTitle = document.getElementById('probCatalogGlobalTitle');
11277        var gSub = document.getElementById('probCatalogGlobalSub');
11278        if (!gOl || !byHost) return;
11279        if (!state.catalog || !state.catalog.length) {
11280            gOl.innerHTML = '';
11281            byHost.innerHTML = '';
11282            return;
11283        }
11284        var cat = filteredCatalogSansSearch();
11285        if (!cat.length) {
11286            gOl.innerHTML =
11287                '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">Nenhum serviço neste filtro.</li>';
11288            byHost.innerHTML = '';
11289            return;
11290        }
11291        var saasBySlug = buildSaasSlugMap();
11292        var gamesBySlug = buildGamesSlugMap();
11293        var allScored = cat.map(function (s) {
11294            return problemEntryForService(s, saasBySlug, gamesBySlug);
11295        });
11296        var probGlobal = allScored
11297            .filter(function (e) {
11298                return e.score > 0;
11299            })
11300            .sort(sortProblemEntries)
11301            .slice(0, PROB_TOP);
11302        var useLatGlobal = probGlobal.length === 0;
11303        var globalList = useLatGlobal ? topWorstLatencyEntries(cat, PROB_TOP) : probGlobal;
11304        var probScopeLab = nonSearchFiltersActive() ? 'filtro atual' : 'geral';
11305        if (gTitle) {
11306            gTitle.textContent = useLatGlobal
11307                ? 'Top 10 · pior latência (' + probScopeLab + ')'
11308                : 'Top 10 · problemas (' + probScopeLab + ')';
11309        }
11310        if (gSub) {
11311            var gSubBase = useLatGlobal
11312                ? __('js_prob_sort_latency')
11313                : 'Falhas de sondagem, Statuspage, jogos e comunidade.';
11314            if (state.filter !== 'all') {
11315                gSubBase =
11316                    'Categoria: ' +
11317                    (catLabel(state.filter) || state.filter) +
11318                    '. ' +
11319                    gSubBase;
11320            } else if (nonSearchFiltersActive()) {
11321                gSubBase = 'Filtro categoria/IP. ' + gSubBase;
11322            }
11323            gSub.textContent = gSubBase;
11324        }
11325        gOl.innerHTML = globalList.length
11326            ? globalList.map(function (e, i) {
11327                  return problemRowHtml(e, i, useLatGlobal);
11328              }).join('')
11329            : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">Sem dados para listar.</li>';
11330
11331        var catKeys = {};
11332        for (var c = 0; c < cat.length; c++) {
11333            var ck = cat[c].category || 'core';
11334            catKeys[ck] = true;
11335        }
11336        var order = Object.keys(catKeys).sort(function (a, b) {
11337            return catLabel(a).localeCompare(catLabel(b), i18nSortLocale());
11338        });
11339        var blocks = '';
11340        for (var o = 0; o < order.length; o++) {
11341            var key = order[o];
11342            var inCat = cat.filter(function (s) {
11343                return (s.category || 'core') === key;
11344            });
11345            var scored = inCat.map(function (s) {
11346                return problemEntryForService(s, saasBySlug, gamesBySlug);
11347            });
11348            var probCat = scored
11349                .filter(function (e) {
11350                    return e.score > 0;
11351                })
11352                .sort(sortProblemEntries)
11353                .slice(0, PROB_TOP);
11354            var useLatCat = probCat.length === 0;
11355            var list = useLatCat ? topWorstLatencyEntries(inCat, PROB_TOP) : probCat;
11356            var label = catLabel(key);
11357            var modeHint = useLatCat ? 'latência' : 'alertas';
11358            blocks +=
11359                '<details class="det-prob-cat-details" data-prob-cat="' +
11360                esc(key) +
11361                '">' +
11362                '<summary class="det-prob-cat-sum">' +
11363                '<span class="det-dash-chev det-dash-chev--sm" aria-hidden="true">▼</span>' +
11364                '<span class="det-prob-cat-name">' +
11365                esc(label) +
11366                '</span>' +
11367                '<span class="det-prob-cat-pill">' +
11368                esc(String(inCat.length)) +
11369                ' · ' +
11370                esc(modeHint) +
11371                '</span>' +
11372                '</summary>' +
11373                '<div class="det-prob-cat-body"><ol class="prob-catalog-ol">' +
11374                (list.length
11375                    ? list
11376                          .map(function (e, i) {
11377                              return problemRowHtml(e, i, useLatCat);
11378                          })
11379                          .join('')
11380                    : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">Sem dados.</li>') +
11381                '</ol></div></details>';
11382        }
11383        byHost.innerHTML = blocks;
11384        bindDetLogoFallbacks(gOl);
11385        bindDetLogoFallbacks(byHost);
11386    }
11387
11388    function updateMetricsBar() {
11389        var cat = state.catalog;
11390        var sum = state.summary;
11391        if (!cat || !cat.length) return;
11392        var ok = 0;
11393        var fail = 0;
11394        var unk = 0;
11395        var targets = 0;
11396        var sp = 0;
11397        var latSum = 0;
11398        var latN = 0;
11399        for (var i = 0; i < cat.length; i++) {
11400            var s = cat[i];
11401            if (s.last_ok === true) ok++;
11402            else if (s.last_ok === false) fail++;
11403            else unk++;
11404            var tc = parseInt(String(s.target_count != null ? s.target_count : 1), 10);
11405            if (isNaN(tc) || tc < 1) tc = 1;
11406            targets += tc;
11407            if ((s.id || '').indexOf('sp_') === 0) sp++;
11408            if (hasRankingLatencySample(s)) {
11409                latSum += Number(rankingLatencyMs(s));
11410                latN++;
11411            }
11412        }
11413        var saasN = 0;
11414        if (sum && sum.success && sum.saas_problems) {
11415            for (var j = 0; j < sum.saas_problems.length; j++) {
11416                if ((sum.saas_problems[j].severity || 0) >= 1) saasN++;
11417            }
11418        }
11419        var gamesAlert = sum && sum.success && sum.games_with_issue != null ? sum.games_with_issue : 0;
11420        var ddLen = sum && sum.trending_issues ? sum.trending_issues.length : 0;
11421        var set = function (id, v) {
11422            var el = document.getElementById(id);
11423            if (el) el.textContent = String(v);
11424        };
11425        set('stTotal', cat.length);
11426        set('stReach', ok);
11427        set('stFail', fail);
11428        set('stUnknown', unk);
11429        set('stTargets', targets);
11430        set('stSp', sp);
11431        set('stSaas', saasN);
11432        set('stGames', gamesAlert);
11433        set('stDd', ddLen);
11434        set('stProbeSec', state.probeIntervalSec != null ? state.probeIntervalSec : '—');
11435        if (latN > 0) set('stLatAvg', Math.round(latSum / latN));
11436        else set('stLatAvg', '—');
11437
11438        var vis = filteredCatalog().length;
11439        var mt = document.getElementById('stTotal');
11440        if (mt && state.filter !== 'all') {
11441            mt.title = __('js_metrics_catalog_full_tpl')
11442                .replace(/\{n\}/g, String(cat.length))
11443                .replace(/\{v\}/g, String(vis));
11444        } else if (mt) {
11445            mt.title = __('js_metrics_catalog_entries');
11446        }
11447    }
11448
11449    function mountRankPanels() {
11450        var list = filteredCatalogSansSearch();
11451        var fastOl = document.getElementById('rankLatFast');
11452        var slowOl = document.getElementById('rankLatSlow');
11453        var incOl = document.getElementById('rankIncidents');
11454        var trOl = document.getElementById('rankTrends');
11455        var measurable = list.filter(function (s) {
11456            return hasRankingLatencySample(s);
11457        });
11458        var byLat = measurable.slice().sort(cmpRankingLatencyBest);
11459        var byLatFastPool = byLat.filter(passesIcmpForFastestRank);
11460        var fast = byLatFastPool.slice(0, rankLimit('latFast'));
11461        var slow = measurable.slice().sort(cmpRankingLatencyWorst);
11462        slow = slow.slice(0, rankLimit('latSlow'));
11463
11464        function rowLatency(svc, idx, ms) {
11465            return (
11466                '<li><button type="button" class="rank-line" data-svc-id="' +
11467                esc(svc.id) +
11468                '"><span class="rn">' +
11469                esc(String(idx + 1)) +
11470                '</span>' +
11471                detLogoImgHtml(svc, 'rfav', '') +
11472                '<span class="rbody"><span class="rname">' +
11473                esc(svc.name) +
11474                '</span><span class="rmeta">' +
11475                esc(catLabel(svc.category) || svc.category || '') +
11476                ipStackTagsHtmlCompact(svc) +
11477                '</span></span><span class="rval">' +
11478                (ms != null && !isNaN(ms) ? esc(String(Math.round(ms))) + ' ms' : '—') +
11479                '</span></button></li>'
11480            );
11481        }
11482        if (fastOl) {
11483            fastOl.innerHTML = fast.length
11484                ? fast.map(function (s, i) {
11485                      return rowLatency(s, i, rankingLatencyMs(s));
11486                  }).join('')
11487                : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
11488                  esc(__('js_rank_no_ok')) +
11489                  '</li>';
11490        }
11491        if (slowOl) {
11492            slowOl.innerHTML = slow.length
11493                ? slow.map(function (s, i) {
11494                      return rowLatency(s, i, rankingLatencyMs(s));
11495                  }).join('')
11496                : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
11497                  esc(__('js_rank_no_data')) +
11498                  '</li>';
11499        }
11500
11501        var probs = (state.summary && state.summary.saas_problems) || [];
11502        var rankedAll = probs
11503            .filter(function (p) {
11504                return (p.severity || 0) >= 1;
11505            })
11506            .slice()
11507            .sort(function (a, b) {
11508                var sb = (b.severity || 0) - (a.severity || 0);
11509                if (sb !== 0) return sb;
11510                return (b.incidents_count || 0) - (a.incidents_count || 0);
11511            });
11512        var ranked = rankedAll.slice(0, rankLimit('incidents'));
11513        if (incOl) {
11514            incOl.innerHTML = ranked.length
11515                ? ranked
11516                      .map(function (p, i) {
11517                          var url = esc(p.status_url || '#');
11518                          return (
11519                              '<li><a class="rank-line rank-line--ext" href="' +
11520                              url +
11521                              '" target="_blank" rel="noopener noreferrer"><span class="rn">' +
11522                              esc(String(i + 1)) +
11523                              '</span><span class="rbody"><span class="rname">' +
11524                              esc(p.service || p.slug) +
11525                              '</span><span class="rmeta">' +
11526                              esc(String(p.indicator || '')) +
11527                              ' · ' +
11528                              esc(String(p.incidents_count || 0)) +
11529                              ' ' +
11530                              esc(__('js_rank_active_count')) +
11531                              '</span></span><span class="rval">→</span></a></li>'
11532                          );
11533                      })
11534                      .join('')
11535                : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
11536                  esc(__('js_rank_incidents_none')) +
11537                  '</li>';
11538        }
11539        updateRankMoreBtn('btnMoreIncidents', 'incidents', rankedAll.length);
11540
11541        var ddFull = (state.summary && state.summary.trending_issues) || [];
11542        var dd = ddFull.slice(0, rankLimit('trends'));
11543        if (trOl) {
11544            trOl.innerHTML = dd.length
11545                ? dd.map(function (x, i) {
11546                      var raw = trendHref(x);
11547                      var url = esc(raw);
11548                      var tgt = trendLinkTargetAttrs(raw);
11549                      return (
11550                          '<li><a class="rank-line rank-line--ext" href="' +
11551                          url +
11552                          '"' +
11553                          tgt +
11554                          '><span class="rn">' +
11555                          esc(String(i + 1)) +
11556                          '</span><span class="rbody"><span class="rname">' +
11557                          esc(x.name || x.slug) +
11558                          '</span><span class="rmeta">' +
11559                          esc(__('js_rank_trend_badge')) +
11560                          '</span></span><span class="rval">↗</span></a></li>'
11561                      );
11562                  }).join('')
11563                : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
11564                  esc(__('js_unavailable')) +
11565                  '</li>';
11566        }
11567        updateRankMoreBtn('btnMoreTrends', 'trends', ddFull.length);
11568
11569        var gOl = document.getElementById('rankGames');
11570        var games = (state.summary && state.summary.games) || [];
11571        var gSortedFull = games.slice().sort(function (a, b) {
11572            var orderBad = { bad: 0, warn: 1, unknown: 2, ok: 3 };
11573            var orderGood = { ok: 0, unknown: 1, warn: 2, bad: 3 };
11574            var order = state.rankMood === 'good' ? orderGood : orderBad;
11575            var oa = order[a.state] != null ? order[a.state] : 2;
11576            var ob = order[b.state] != null ? order[b.state] : 2;
11577            if (oa !== ob) return oa - ob;
11578            return String(a.name || '').localeCompare(String(b.name || ''), i18nSortLocale());
11579        });
11580        var gSorted = gSortedFull.slice(0, rankLimit('games'));
11581        if (gOl) {
11582            gOl.innerHTML = gSorted.length
11583                ? gSorted.map(function (g, i) {
11584                      var url = esc(g.status_url || '#');
11585                      var st = g.state || 'unknown';
11586                      var lab = g.status_label
11587                          ? String(g.status_label)
11588                          : st === 'ok'
11589                            ? __('js_game_state_ok')
11590                            : st === 'bad'
11591                              ? __('js_game_state_bad')
11592                              : st === 'warn'
11593                                ? __('js_game_state_warn')
11594                                : '—';
11595                      var cls = st === 'ok' ? 'var(--ok)' : st === 'bad' ? 'var(--bad)' : 'var(--warn)';
11596                      return (
11597                          '<li><a class="rank-line rank-line--ext" href="' +
11598                          url +
11599                          '" target="_blank" rel="noopener noreferrer"><span class="rn">' +
11600                          esc(String(i + 1)) +
11601                          '</span><span class="rbody"><span class="rname">' +
11602                          esc(g.name || g.slug) +
11603                          '</span><span class="rmeta">' +
11604                          esc(lab) +
11605                          '</span></span><span class="rval" style="color:' +
11606                          cls +
11607                          ';">●</span></a></li>'
11608                      );
11609                  }).join('')
11610                : '<li class="rank-empty" style="color:var(--muted);padding:8px 0;">' +
11611                  esc(__('js_rank_no_data')) +
11612                  '</li>';
11613        }
11614        updateRankMoreBtn('btnMoreGames', 'games', gSortedFull.length);
11615
11616        updateRankMoreBtn('btnMoreLatFast', 'latFast', byLatFastPool.length);
11617        updateRankMoreBtn('btnMoreLatSlow', 'latSlow', byLat.length);
11618
11619        if (fastOl) {
11620            fastOl.querySelectorAll('.rank-line[data-svc-id]').forEach(function (btn) {
11621                btn.addEventListener('click', function () {
11622                    var id = btn.getAttribute('data-svc-id');
11623                    if (id) openModal(id);
11624                });
11625            });
11626        }
11627        if (slowOl) {
11628            slowOl.querySelectorAll('.rank-line[data-svc-id]').forEach(function (btn) {
11629                btn.addEventListener('click', function () {
11630                    var id = btn.getAttribute('data-svc-id');
11631                    if (id) openModal(id);
11632                });
11633            });
11634        }
11635    }
11636
11637    var RANK_MOOD_STORAGE_KEY = 'detector_rank_mood';
11638
11639    function loadRankMoodFromStorage() {
11640        try {
11641            var v = localStorage.getItem(RANK_MOOD_STORAGE_KEY);
11642            if (v === 'good' || v === 'bad') state.rankMood = v;
11643        } catch (e) {
11644            /* ignore */
11645        }
11646    }
11647
11648    function applyRankMoodUI() {
11649        var outer = document.getElementById('rankPanelsOuter');
11650        var btn = document.getElementById('btnRankMoodToggle');
11651        var hint = document.getElementById('rankMoodHint');
11652        if (outer) {
11653            outer.classList.remove('rank-mood--good', 'rank-mood--bad');
11654            outer.classList.add(state.rankMood === 'good' ? 'rank-mood--good' : 'rank-mood--bad');
11655        }
11656        if (btn) {
11657            btn.setAttribute('aria-pressed', state.rankMood === 'good' ? 'true' : 'false');
11658        }
11659        if (hint) {
11660            hint.textContent =
11661                state.rankMood === 'good' ? __('js_rank_mood_hint_good') : __('js_rank_mood_hint_bad');
11662        }
11663        var gameSub = document.querySelector('#secRankPanels .rank-col--both .rank-sub');
11664        if (gameSub) {
11665            gameSub.textContent =
11666                state.rankMood === 'good'
11667                    ? __('js_rank_mood_game_sub_good')
11668                    : __('js_rank_mood_game_sub_bad');
11669        }
11670    }
11671
11672    function wireRankMoodToggle() {
11673        loadRankMoodFromStorage();
11674        var btn = document.getElementById('btnRankMoodToggle');
11675        if (!btn || btn.dataset.detectorMoodWired === '1') return;
11676        btn.dataset.detectorMoodWired = '1';
11677        btn.addEventListener('click', function () {
11678            state.rankMood = state.rankMood === 'good' ? 'bad' : 'good';
11679            try {
11680                localStorage.setItem(RANK_MOOD_STORAGE_KEY, state.rankMood);
11681            } catch (e) {
11682                /* ignore */
11683            }
11684            var outer = document.getElementById('rankPanelsOuter');
11685            if (outer) {
11686                outer.classList.remove('rank-mood-flash-good', 'rank-mood-flash-bad', 'is-rank-mood-anim');
11687                void outer.offsetWidth;
11688                outer.classList.add(state.rankMood === 'good' ? 'rank-mood-flash-good' : 'rank-mood-flash-bad');
11689                outer.classList.add('is-rank-mood-anim');
11690                setTimeout(function () {
11691                    outer.classList.remove('is-rank-mood-anim');
11692                }, 320);
11693                setTimeout(function () {
11694                    outer.classList.remove('rank-mood-flash-good', 'rank-mood-flash-bad');
11695                }, 680);
11696            }
11697            applyRankMoodUI();
11698            mountRankPanels();
11699        });
11700        applyRankMoodUI();
11701    }
11702
11703    /** /detector#incidentes ou #incidents — vista «ruins», painel de rankings aberto, scroll até Top 5 · incidentes. */
11704    function applyDeepLinkFromHash() {
11705        var raw = (window.location.hash || '').replace(/^#/, '').toLowerCase();
11706        if (raw !== 'incidentes' && raw !== 'incidents') return;
11707        state.rankMood = 'bad';
11708        applyRankMoodUI();
11709        mountRankPanels();
11710        var det = document.getElementById('detDashRank');
11711        if (det) {
11712            try {
11713                det.open = true;
11714            } catch (eOpen) {
11715                var sum = det.querySelector('summary');
11716                if (sum) sum.click();
11717            }
11718        }
11719        var target = document.getElementById('rankColIncidents');
11720        if (target && target.scrollIntoView) {
11721            setTimeout(function () {
11722                target.scrollIntoView({ behavior: 'smooth', block: 'center' });
11723            }, 120);
11724        }
11725    }
11726
11727    /* ── Top 5 Ranking Boards ── */
11728    var TOP_N = 5;
11729
11730    function mountTop10Boards() {
11731        var host = document.getElementById('top10Boards');
11732        if (!host || !state.catalog || !state.catalog.length) return;
11733
11734        /* Latência: catálogo completo. Pior/melhor avaliação: nota + estrelas + latência + perda + jitter + uptime + relatos · telecom. */
11735        var allGlobal = state.catalog;
11736
11737        function poolTelecomForRatings(all) {
11738            var tel = all.filter(function (s) {
11739                return String(s.category || '') === 'telecom';
11740            });
11741            return tel.length ? tel : all;
11742        }
11743
11744        function passesRatingBoard(s) {
11745            var t = Number(s.trust_score);
11746            if (!isNaN(t) && t > 0) return true;
11747            return (Number(s.star_rating_count) || 0) >= 1;
11748        }
11749
11750        function valRatingBoardCell(s, worst) {
11751            var t = Number(s.trust_score);
11752            if (isNaN(t)) t = 0;
11753            var cls;
11754            if (worst) {
11755                cls = t < 70 ? 'top10-val--bad' : t < 90 ? 'top10-val--warn' : 'top10-val--blue';
11756            } else {
11757                cls = t >= 95 ? 'top10-val--good' : t >= 90 ? 'top10-val--blue' : 'top10-val--warn';
11758            }
11759            var votes = Number(s.star_rating_count) || 0;
11760            var avg = votes > 0 ? Number(s.avg_star_rating) || 5 : 5;
11761            var voteLbl =
11762                votes === 1 ? __('js_star_badge_votes') : __('js_star_badge_votes_plural');
11763            var starTitle =
11764                avg.toFixed(1) +
11765                ' ' +
11766                __('js_star_badge_title') +
11767                ' (' +
11768                votes +
11769                ' ' +
11770                voteLbl +
11771                ')';
11772            var starBadge =
11773                '<span class="t10-star-badge" title="' +
11774                esc(starTitle) +
11775                '">★' +
11776                avg.toFixed(1) +
11777                '</span>';
11778            return { text: t.toFixed(1) + ' ' + starBadge, cls: cls, html: true };
11779        }
11780
11781        var ratingPool = poolTelecomForRatings(allGlobal);
11782
11783        var byWorstLat = allGlobal
11784            .filter(function (s) { return hasRankingLatencySample(s); })
11785            .sort(cmpRankingLatencyWorst)
11786            .slice(0, TOP_N);
11787
11788        var ratingEligible = ratingPool.filter(passesRatingBoard);
11789        var sortedWorst = sortCatalogForRatingBoard(ratingEligible, true);
11790        var sortedBest  = sortCatalogForRatingBoard(ratingEligible, false);
11791
11792        var byWorstScore = sortedWorst.slice(0, TOP_N);
11793        var worstIds = {};
11794        byWorstScore.forEach(function (s) { worstIds[s.id] = true; });
11795
11796        var byBestScore = [];
11797        for (var bi = 0; bi < sortedBest.length && byBestScore.length < TOP_N; bi++) {
11798            if (!worstIds[sortedBest[bi].id]) byBestScore.push(sortedBest[bi]);
11799        }
11800
11801        var byBestLat = allGlobal
11802            .filter(function (s) {
11803                var rl = rankingLatencyMs(s);
11804                return (
11805                    rl != null &&
11806                    !isNaN(rl) &&
11807                    rl > 0 &&
11808                    passesIcmpForFastestRank(s)
11809                );
11810            })
11811            .sort(cmpRankingLatencyBest)
11812            .slice(0, TOP_N);
11813
11814        function boardHtml(cfg) {
11815            var rows = '';
11816            if (!cfg.items.length) {
11817                rows =
11818                    '<div class="top10-empty">' +
11819                    esc(cfg.emptyMessage != null ? cfg.emptyMessage : __('js_top10_empty_shelf')) +
11820                    '</div>';
11821            } else {
11822                rows = '<ol class="top10-list">';
11823                for (var i = 0; i < cfg.items.length; i++) {
11824                    var s = cfg.items[i];
11825                    var val;
11826                    try {
11827                        val = cfg.valFn(s);
11828                    } catch (eRow) {
11829                        val = { text: '—', cls: 'top10-val--warn' };
11830                    }
11831                    if (!val || val.text == null) val = { text: '—', cls: 'top10-val--warn' };
11832                    var valContent = val.html ? String(val.text) : esc(String(val.text));
11833                    var valCls = val.cls != null ? String(val.cls) : '';
11834                    rows +=
11835                        '<li class="top10-item" data-svc-id="' + esc(s.id) + '" tabindex="0" title="' + esc(s.name) + '">' +
11836                        '<span class="top10-pos">' + (i + 1) + '</span>' +
11837                        detLogoImgHtml(s, 'top10-logo', '') +
11838                        '<span class="top10-name">' + esc(s.name) + '</span>' +
11839                        '<span class="top10-val ' + valCls + '">' + valContent + '</span>' +
11840                        '</li>';
11841                }
11842                rows += '</ol>';
11843            }
11844            var rawSub = cfg.sub != null ? String(cfg.sub).trim() : '';
11845            var subEl = rawSub ? '<small>' + esc(rawSub) + '</small>' : '';
11846            var infoKind = cfg.showInfo ? String(cfg.infoKind || 'rating') : '';
11847            var infoBtn = cfg.showInfo
11848                ? '<button type="button" class="t10-info-btn" aria-label="' +
11849                  esc(__('js_t10_info_btn_aria')) +
11850                  '" data-t10-info-kind="' +
11851                  esc(infoKind) +
11852                  '">i</button>'
11853                : '';
11854            return (
11855                '<div class="top10-board ' + cfg.boardCls + '">' +
11856                '<div class="top10-board-head">' +
11857                '<span class="t10-icon">' + cfg.icon + '</span>' +
11858                '<div class="t10-title-col"><h4>' + esc(cfg.title) + '</h4>' + subEl + '</div>' +
11859                (cfg.headExtra || '') +
11860                infoBtn +
11861                '</div>' +
11862                rows + '</div>'
11863            );
11864        }
11865
11866        host.innerHTML =
11867            boardHtml({
11868                title: __('js_more_latency'),
11869                icon: '🐢',
11870                boardCls: 'top10-board--bad',
11871                items: byWorstLat,
11872                showInfo: true,
11873                infoKind: 'latency_worst',
11874                valFn: function (s) {
11875                    var ms = Math.round(rankingLatencyMs(s));
11876                    var cls = ms > 500 ? 'top10-val--bad' : ms > 200 ? 'top10-val--warn' : 'top10-val--blue';
11877                    return { text: ms + ' ms', cls: cls };
11878                }
11879            }) +
11880            boardHtml({
11881                title: __('js_worst_rating'),
11882                icon: '📉',
11883                boardCls: 'top10-board--bad',
11884                items: byWorstScore,
11885                showInfo: true,
11886                infoKind: 'rating',
11887                valFn: function (s) {
11888                    return valRatingBoardCell(s, true);
11889                }
11890            }) +
11891            boardHtml({
11892                title: __('js_less_latency'),
11893                icon: '⚡',
11894                boardCls: 'top10-board--good',
11895                items: byBestLat,
11896                showInfo: true,
11897                infoKind: 'latency_best',
11898                valFn: function (s) {
11899                    var ms = Math.round(rankingLatencyMs(s));
11900                    var cls = ms < 50 ? 'top10-val--good' : ms < 150 ? 'top10-val--blue' : 'top10-val--warn';
11901                    return { text: ms + ' ms', cls: cls };
11902                }
11903            }) +
11904            boardHtml({
11905                title: __('js_best_rating'),
11906                icon: '⭐',
11907                boardCls: 'top10-board--good',
11908                items: byBestScore,
11909                showInfo: true,
11910                infoKind: 'rating',
11911                valFn: function (s) {
11912                    return valRatingBoardCell(s, false);
11913                }
11914            });
11915
11916        bindDetLogoFallbacks(host);
11917        host.querySelectorAll('.top10-item[data-svc-id]').forEach(function (el) {
11918            function doOpen() {
11919                var id = el.getAttribute('data-svc-id');
11920                if (id) openModal(id);
11921            }
11922            el.addEventListener('click', doOpen);
11923            el.addEventListener('keydown', function (e) {
11924                if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); doOpen(); }
11925            });
11926        });
11927
11928        host.querySelectorAll('.t10-info-btn').forEach(function (btn) {
11929            btn.addEventListener('click', function (e) {
11930                e.stopPropagation();
11931                var kind = btn.getAttribute('data-t10-info-kind') || 'rating';
11932                var infoTitle;
11933                var infoBody;
11934                if (kind === 'latency_worst') {
11935                    infoTitle = __('js_latency_rank_info_title');
11936                    infoBody = __('js_latency_rank_worst_body');
11937                } else if (kind === 'latency_best') {
11938                    infoTitle = __('js_latency_rank_info_title');
11939                    infoBody = __('js_latency_rank_best_body');
11940                } else {
11941                    infoTitle = __('js_rating_board_info_title');
11942                    infoBody = __('js_rating_board_info_body');
11943                }
11944                var ov = document.createElement('div');
11945                ov.className = 't10-info-overlay is-open';
11946                ov.innerHTML =
11947                    '<div class="t10-info-card">' +
11948                    '<button type="button" class="t10-info-close" aria-label="' +
11949                    esc(__('modal_close')) +
11950                    '">&times;</button>' +
11951                    '<h5>' + esc(infoTitle) + '</h5>' +
11952                    '<p>' + esc(infoBody) + '</p>' +
11953                    '</div>';
11954                document.body.appendChild(ov);
11955                function closeOv() { if (ov.parentNode) ov.parentNode.removeChild(ov); }
11956                ov.querySelector('.t10-info-close').addEventListener('click', closeOv);
11957                ov.addEventListener('click', function (ev) { if (ev.target === ov) closeOv(); });
11958                document.addEventListener('keydown', function onEsc(ev) {
11959                    if (ev.key === 'Escape') { closeOv(); document.removeEventListener('keydown', onEsc); }
11960                });
11961            });
11962        });
11963    }
11964
11965    function mountTop10Cards() {
11966        var host = document.getElementById('top10Cards');
11967        if (!host || !state.catalog || !state.catalog.length) return;
11968        if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
11969            host.innerHTML = '';
11970            host.setAttribute('hidden', '');
11971            return;
11972        }
11973        host.removeAttribute('hidden');
11974
11975        var allGlobal = state.catalog;
11976        var CARD_N = 10;
11977        var topLbl = __('js_label_top');
11978
11979        function shelfHtml(cfg) {
11980            var n = cfg.items.length;
11981            var sub =
11982                cfg.shelfSub != null
11983                    ? cfg.shelfSub
11984                    : 'Top ' + n;
11985            var head =
11986                '<div class="top10-cards-shelf-head">' +
11987                '<span class="t10c-icon">' +
11988                cfg.icon +
11989                '</span>' +
11990                '<h4>' +
11991                cfg.title +
11992                '</h4><small>' +
11993                esc(sub) +
11994                '</small></div>';
11995            if (!n) {
11996                if (cfg.placeholder) {
11997                    return (
11998                        '<div class="top10-cards-shelf ' +
11999                        cfg.shelfCls +
12000                        '">' +
12001                        head +
12002                        '<div class="top10-cards-placeholder" role="status">' +
12003                        cfg.placeholder +
12004                        '</div></div>'
12005                    );
12006                }
12007                return '';
12008            }
12009            var cards = cfg.items.map(function (s) { return tileHtml(s); }).join('');
12010            return (
12011                '<div class="top10-cards-shelf ' + cfg.shelfCls + '">' +
12012                head +
12013                '<div class="top10-cards-row shelf-row-wrap">' +
12014                cards +
12015                '</div></div>'
12016            );
12017        }
12018
12019        function shelfSubFullTop(n) {
12020            return __('js_t10_shelf_ctx_top_tpl')
12021                .replace(/\{ctx\}/g, __('js_catalog_full_short'))
12022                .replace(/\{top\}/g, topLbl)
12023                .replace(/\{n\}/g, String(n));
12024        }
12025
12026        var qNav = String(state.catalogSearch || '').trim();
12027        var blockFirst;
12028        var rankRows = state.communityRankingRows;
12029        if (qNav) {
12030            var found = catalogSearchModalMatches(qNav).slice(0, CARD_N);
12031            var subSrch = __('js_t10_search_results_sub_tpl')
12032                .replace(/\{n\}/g, String(found.length))
12033                .replace(/\{q\}/g, qNav);
12034            blockFirst = shelfHtml({
12035                title: __('js_t10_search_results_title'),
12036                icon: '🔍',
12037                shelfCls: 'top10-cards-shelf--neutral',
12038                shelfSub: subSrch,
12039                items: found,
12040                placeholder: found.length ? undefined : esc(__('js_t10_search_no_results')),
12041            });
12042        } else {
12043            var complaintsItems = [];
12044            var phFirst;
12045            if (rankRows === undefined) {
12046                phFirst = esc(__('js_cards_loading_rank')) + '…';
12047            } else if (rankRows === null) {
12048                phFirst = esc(__('js_comm_rank_err_li'));
12049            } else if (!rankRows.length) {
12050                phFirst = esc(
12051                    __('js_complaints_empty_generic').replace(/\{d\}/g, String(state.complaintsRankingDays)),
12052                );
12053            } else if (
12054                !rankRows.some(function (r) {
12055                    return r && (Number(r.vote_count) || 0) > 0;
12056                })
12057            ) {
12058                phFirst = esc(
12059                    __('js_complaints_empty_generic').replace(/\{d\}/g, String(state.complaintsRankingDays)),
12060                );
12061            } else {
12062                for (var ri = 0; ri < rankRows.length && complaintsItems.length < CARD_N; ri++) {
12063                    if ((Number(rankRows[ri].vote_count) || 0) < 1) continue;
12064                    var s0 = communityRankingRowToSvc(rankRows[ri]);
12065                    if (s0 && passesFilterSansSearch(s0)) complaintsItems.push(s0);
12066                }
12067                if (!complaintsItems.length) {
12068                    phFirst = esc(__('js_cards_empty_filter'));
12069                }
12070            }
12071            var days = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
12072            var dayLblMapC = { 1: __('js_window_1d'), 7: __('js_window_7d'), 15: __('js_window_15d'), 30: __('js_window_30d') };
12073            var dayLbl = dayLblMapC[days] || __('js_window_1d');
12074            var winTopSub =
12075                rankRows !== undefined && rankRows !== null && rankRows.length && complaintsItems.length
12076                    ? __('js_t10_complaints_window_top_tpl')
12077                          .replace(/\{w\}/g, dayLbl)
12078                          .replace(/\{top\}/g, topLbl)
12079                          .replace(/\{n\}/g, String(complaintsItems.length))
12080                    : null;
12081            blockFirst = shelfHtml({
12082                title: __('js_t10_complaints_comm_title'),
12083                icon: '📣',
12084                shelfCls: 'top10-cards-shelf--neutral top10-cards-shelf--dup-mobile-hide',
12085                shelfSub: winTopSub,
12086                items: complaintsItems,
12087                placeholder: phFirst,
12088            });
12089        }
12090
12091        var byWorstLat = allGlobal
12092            .filter(function (s) { return hasRankingLatencySample(s); })
12093            .sort(cmpRankingLatencyWorst)
12094            .slice(0, CARD_N);
12095
12096        var byBestScore = sortCatalogForRatingBoard(
12097            allGlobal.filter(function (s) {
12098                var t = Number(s.trust_score);
12099                return !isNaN(t) && t > 0;
12100            }),
12101            false,
12102        ).slice(0, CARD_N);
12103
12104        var byBestLat = allGlobal
12105            .filter(function (s) {
12106                var rl = rankingLatencyMs(s);
12107                return (
12108                    rl != null &&
12109                    !isNaN(rl) &&
12110                    rl > 0 &&
12111                    passesIcmpForFastestRank(s)
12112                );
12113            })
12114            .sort(cmpRankingLatencyBest)
12115            .slice(0, CARD_N);
12116
12117        var blockWorst = shelfHtml({
12118            title: __('js_more_latency'),
12119            icon: '🐢',
12120            shelfCls: 'top10-cards-shelf--bad',
12121            shelfSub: shelfSubFullTop(byWorstLat.length),
12122            items: byWorstLat,
12123        });
12124        var blockScore = shelfHtml({
12125            title: __('js_best_rating'),
12126            icon: '⭐',
12127            shelfCls: 'top10-cards-shelf--good',
12128            shelfSub: shelfSubFullTop(byBestScore.length),
12129            items: byBestScore,
12130        });
12131        var blockBest = shelfHtml({
12132            title: __('js_less_latency'),
12133            icon: '⚡',
12134            shelfCls: 'top10-cards-shelf--good',
12135            shelfSub: shelfSubFullTop(byBestLat.length),
12136            items: byBestLat,
12137        });
12138
12139        var blockGames = '';
12140        var gamesSvcs = top10GamesCatalogServices();
12141        if (gamesSvcs.length) {
12142            var gamesShareSvg =
12143                '<svg class="det-sd-action__ico" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12144                '<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />' +
12145                '<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />' +
12146                '</svg>';
12147            var gamesHead =
12148                '<div class="top10-cards-shelf-head top10-cards-shelf-head--games-share">' +
12149                '<span class="t10c-icon">🎮</span>' +
12150                '<div class="t10c-title-stack"><h4>' +
12151                esc(__('js_t10_games_title')) +
12152                '</h4><small>' +
12153                esc(__('js_t10_games_sub')) +
12154                '</small></div>' +
12155                '<button type="button" class="det-sd-action det-sd-action--social" id="bt
12155nTop10GamesSocialShare" title="' +
12156                esc(__('js_t10_social_aria')) +
12157                '" aria-label="' +
12158                esc(__('js_t10_social_aria')) +
12159                '">' +
12160                gamesShareSvg +
12161                '<span class="det-sd-action__lbl">' +
12162                esc(__('js_t10_social_btn_short')) +
12163                '</span></button></div>';
12164            var gamesCards = gamesSvcs.map(function (s) {
12165                return tileHtml(s);
12166            }).join('');
12167            blockGames =
12168                '<div class="top10-cards-shelf top10-cards-shelf--bad top10-cards-shelf--games top10-cards-shelf--dup-mobile-hide">' +
12169                gamesHead +
12170                '<div class="top10-cards-row shelf-row-wrap">' +
12171                gamesCards +
12172                '</div></div>';
12173        }
12174
12175        var assembled = (blockFirst || '') + blockGames + blockWorst + blockScore + blockBest;
12176        if (!assembled.trim()) {
12177            host.innerHTML =
12178                '<div class="top10-cards-filter-empty" role="status">' +
12179                '<strong>' +
12180                esc(__('js_t10_filter_empty_title')) +
12181                '</strong> ' +
12182                esc(__('js_t10_filter_empty_p')) +
12183                '</div>';
12184            return;
12185        }
12186        host.innerHTML = assembled;
12187
12188        bindDetLogoFallbacks(host);
12189        var btnGamesSoc = document.getElementById('btnTop10GamesSocialShare');
12190        if (btnGamesSoc) {
12191            btnGamesSoc.addEventListener('click', function (e) {
12192                e.preventDefault();
12193                e.stopPropagation();
12194                openTop10GamesSocialShareModal();
12195            });
12196        }
12197        attachTileInteractions(host, function (el) {
12198            var id = el.getAttribute('data-svc-id');
12199            if (id) openModal(id);
12200        });
12201        bindCommunityReportButtons(host);
12202        bindCommunityStatsButtons(host);
12203        bindStarRatingButtons(host);
12204        bindTileMoreButtons(host);
12205        host.querySelectorAll('.shelf-row-wrap').forEach(bindShelfScrollHint);
12206        refreshComplaintsTrendSparklines(document);
12207    }
12208
12209    function mountIcmpNoReplySection() {
12210        var host = document.getElementById('icmpNoReplyHost');
12211        if (!host || !state.catalog || !state.catalog.length) return;
12212        var pp = state.probeProfile || {};
12213        var icmpOn = pp.icmp_status_enabled !== false;
12214        if (!icmpOn) {
12215            host.innerHTML = '';
12216            host.setAttribute('hidden', '');
12217            return;
12218        }
12219        host.removeAttribute('hidden');
12220        var all = state.catalog.filter(function (s) { return passesFilter(s); });
12221        var hasAnyIcmp = all.some(function (s) {
12222            return s.icmp_responds === true || s.icmp_responds === false;
12223        });
12224        if (!hasAnyIcmp) {
12225            host.innerHTML =
12226                '<div class="icmp-section icmp-section--empty" role="status">' +
12227                '<p>Ainda não há resultado de <strong>ping</strong> neste painel. Volte daqui a cerca de um minuto.</p>' +
12228                '</div>';
12229            return;
12230        }
12231        var noIcmp = all
12232            .filter(function (s) {
12233                return s.icmp_responds === false && s.last_ok === true;
12234            })
12235            .sort(function (a, b) { return (a.name || '').localeCompare(b.name || '', i18nSortLocale()); });
12236        if (!noIcmp.length) {
12237            host.innerHTML =
12238                '<div class="icmp-section icmp-section--empty" role="status">' +
12239                '<p>Com o filtro atual não há aqui serviços em que o <strong>site responde bem</strong> mas o <strong>ping não</strong>. ' +
12240                'Se um serviço estiver <strong>fora do ar</strong>, aparece nos cartões normais como erro — não nesta lista.</p>' +
12241                '</div>';
12242            return;
12243        }
12244        var cards = noIcmp.map(function (s) { return tileHtml(s); }).join('');
12245        host.innerHTML =
12246            '<div class="icmp-section" role="region" aria-label="Site ok, sem ping ICMP">' +
12247            '<div class="icmp-section-head">' +
12248            '<h3>Site ok · sem ping (ICMP)</h3>' +
12249            '<p>Estes serviços <strong>não respondem a ping</strong> (ICMP). Isso é <strong>normal</strong> em muitos sites e apps na internet. ' +
12250            'Mesmo assim <strong>respondem a outros testes</strong>: em geral <strong>HTTPS</strong> (ligação web segura, porta 443) e, em alguns cartões, <strong>
12250HTTP ou outras portas TCP</strong> (por exemplo 80 ou 443).</p>' +
12251            '<p>O <strong>tempo em ms</strong> em destaque em cada cartão vem sobretudo da verificação <strong>HTTPS/TLS</strong>. ' +
12252            'Quando vê números ao lado das portas no cartão, é a <strong>latência medida por TCP</strong> nessa porta. ' +
12253            'Serviços <strong>indisponíveis</strong> aparecem no catálogo geral em erro, não aqui.</p>' +
12254            '</div>' +
12255            '<div class="icmp-cards-row shelf-row-wrap">' + cards + '</div>' +
12256            '</div>';
12257        bindDetLogoFallbacks(host);
12258        attachTileInteractions(host, function (el) {
12259            var id = el.getAttribute('data-svc-id');
12260            if (id) openModal(id);
12261        });
12262        bindCommunityReportButtons(host);
12263        bindCommunityStatsButtons(host);
12264        bindStarRatingButtons(host);
12265        bindTileMoreButtons(host);
12266        host.querySelectorAll('.shelf-row-wrap').forEach(bindShelfScrollHint);
12267        refreshComplaintsTrendSparklines(document);
12268    }
12269
12270    /** Grelha por categoria + secção ICMP (responde a `passesFilter`, inclui texto de busca). */
12271    function mountCatalogGridAndIcmpSection() {
12272        var host = document.getElementById('shelvesHost');
12273        if (!host || !state.catalog || !state.catalog.length) return;
12274        var searchBar = document.getElementById('catalogSearchBar');
12275        var catalogHead = document.querySelector('.block-head--catalog');
12276        if (searchBar && catalogHead) {
12277            try {
12278                catalogHead.appendChild(searchBar);
12279            } catch (ePreserveSearch) {
12280                /* ignore */
12281            }
12282        }
12283        var byCat = {};
12284        state.catalog.forEach(function (svc) {
12285            if (!passesFilter(svc)) return;
12286            var c = svc.category || 'core';
12287            if (!byCat[c]) byCat[c] = [];
12288            byCat[c].push(svc);
12289        });
12290        var multiKeys = [];
12291        var orphanItems = [];
12292        Object.keys(byCat).forEach(function (c) {
12293            var bucket = byCat[c];
12294            if (bucket.length >= 2) {
12295                multiKeys.push(c);
12296            } else if (bucket.length === 1) {
12297                orphanItems.push(bucket[0]);
12298            }
12299        });
12300        multiKeys.sort(function (a, b) {
12301            return catLabel(a).localeCompare(catLabel(b), i18nSortLocale());
12302        });
12303        orphanItems = sortItems(orphanItems);
12304
12305        var html = '';
12306        function shelfBlockHtml(cat, items, opts) {
12307            opts = opts || {};
12308            var noTitle = !!opts.noTitle;
12309            var titleLabel = opts.titleLabel != null ? opts.titleLabel : catLabel(cat);
12310            var rowId = opts.rowId || 'row_' + String(cat).replace(/[^a-z0-9]+/gi, '_');
12311            var shelfSearchSlot = !!opts.catalogSearchSlot;
12312            var h = '';
12313            h += '<div class="shelf-block' + (noTitle ? ' shelf-block--no-title' : '') + '" data-cat="' + esc(cat) + '">';
12314            if (!noTitle) {
12315                h += '<div class="shelf-head-row">';
12316                h +=
12317                    '<div class="shelf-title">▸ <span>' +
12318                    esc(titleLabel) +
12319                    '</span> <small style="color:var(--muted);font-weight:500;">(' +
12320                    items.length +
12321                    ')</small></div>';
12322                if (shelfSearchSlot) {
12323                    h += '<div id="catalogSearchShelfSlot" class="shelf-catalog-search-slot"></div>';
12324                }
12325                h += '</div>';
12326            }
12327            h += '<div class="shelf-row-wrap">';
12328            h += '<div id="' + rowId + '" class="row-scroll"></div>';
12329            h +=
12330                '<div class="shelf-scroll-hint" aria-hidden="true" title="Deslize para o lado — há mais serviços">›</div>';
12331            h += '</div>';
12332            h += '<div id="' + rowId + '_grid" class="grid-view" style="display:none;"></div>';
12333            h += '<div id="' + rowId + '_list" class="list-view" style="display:none;"></div>';
12334            h += '</div>';
12335            return h;
12336        }
12337        for (var mk = 0; mk < multiKeys.length; mk++) {
12338            html += shelfBlockHtml(multiKeys[mk], byCat[multiKeys[mk]], { catalogSearchSlot: mk === 0 });
12339        }
12340        if (orphanItems.length === 1) {
12341            html += shelfBlockHtml(orphanItems[0].category || 'core', orphanItems, {
12342                noTitle: true,
12343                rowId: 'row__orphan_single',
12344            });
12345        } else if (orphanItems.length > 1) {
12346            html += shelfBlockHtml('__mixed__', orphanItems, {
12347                titleLabel: __('shelf_orphans_title'),
12348                rowId: 'row__orphans_mixed',
12349                catalogSearchSlot: multiKeys.length === 0,
12350            });
12351        }
12352        host.innerHTML = html || '<div class="empty">Nenhum serviço neste filtro.</div>';
12353
12354        function fillShelfRow(rowId, items) {
12355            var row = document.getElementById(rowId);
12356            var grid = document.getElementById(rowId + '_grid');
12357            var list = document.getElementById(rowId + '_list');
12358            if (!row) return;
12359            var hr = '';
12360            var hg = '';
12361            var hl = '';
12362            for (var i = 0; i < items.length; i++) {
12363                var t = tileHtml(items[i]);
12364                hr += t;
12365                hg += t;
12366                var it = items[i];
12367                var ltList = latencyTierMeta(it);
12368                var lr = it.latency_rank ? '<span class="list-rank">#' + esc(it.latency_rank) + '</span>' : '';
12369                var ltxt;
12370                var listLatMs = rankingLatencyMs(it);
12371                if (it.last_ok === true && listLatMs != null && !isNaN(listLatMs)) {
12372                    ltxt = '<span class="list-lat">' + esc(Math.round(listLatMs)) + ' ms</span>';
12373                } else if (it.last_ok === false) {
12374                    ltxt = '<span class="list-lat bad">erro</span>';
12375                } else {
12376                    ltxt = '<span class="list-lat" style="color:var(--muted)">—</span>';
12377                }
12378                var listReportLbl = esc(__('js_list_report') || 'Reportar');
12379                var listReportAria = esc(__('js_list_report_aria') || 'Reportar problema');
12380                var listMoreInner =
12381                    '<span class="tile-info-row">' +
12382                    INFO_ICON_HTML +
12383                    '<span class="tile-info-txt">+ info</span></span>';
12384                hl +=
12385                    '<div class="list-row ' +
12386                    (it.last_ok === true ? 'state-ok' : it.last_ok === false ? 'state-bad' : 'state-unknown') +
12387                    ' ' +
12388                    ltList.cls +
12389                    '" role="button" tabindex="0" data-svc-id="' +
12390                    esc(it.id) +
12391                    '">' +
12392                    lr +
12393                    '<div class="limg">' +
12394                    detLogoImgHtml(it, '', '') +
12395                    '</div>' +
12396                    '<div class="lbody">' +
12397                    '<div class="lbody-top">' +
12398                    '<div class="t">' +
12399                    esc(it.name) +
12400                    ipStackTagsHtmlCompact(it) +
12401                    '</div>' +
12402                    '<button type="button" class="list-more-btn list-more-btn--mob-only" data-more-sid="' +
12403                    esc(it.id) +
12404                    '" aria-label="+ info: mais detalhes, hub e gráfico">' +
12405                    listMoreInner +
12406                    '</button>' +
12407                    '</div>' +
12408                    '<div class="lbody-sub">' +
12409                    '<div class="d">' +
12410                    esc((it.description || '').slice(0, 160)) +
12411                    '</div>' +
12412                    '<span class="list-lat--mob-only">' +
12413                    ltxt +
12414                    '</span>' +
12415                    '</div></div>' +
12416                    '<div class="list-row-extras">' +
12417                    '<div class="list-row-metrics">' +
12418                    '<div class="list-lat-slot">' +
12419                    ltxt +
12420                    '</div>' +
12421                    '<span class="list-lat-pill ' +
12422                    ltList.cls +
12423                    '" title="' +
12424                    esc(ltList.hint) +
12425                    '">' +
12426                    esc(ltList.pill) +
12427                    '</span>' +
12428                    '<div class="list-sparkline-wrap" title="Tendência recente da latência">' +
12429                    sparklineSvg(it.latency_sparkline_ms, 88) +
12430                    '</div>' +
12431                    '</div>' +
12432                    '<div class="list-row-cta">' +
12433                    '<button type="button" class="list-more-btn list-more-btn--desk-only" data-more-sid="' +
12434                    esc(it.id) +
12435                    '" aria-label="+ info: mais detalhes, hub e gráfico">' +
12436                    listMoreInner +
12437                    '</button>' +
12438                    '<button type="button" class="list-report-btn" data-report-sid="' +
12439                    esc(it.id) +
12440                    '" aria-label="' +
12441                    listReportAria +
12442                    '"><span class="list-report-shine" aria-hidden="true"></span><span class="list-report-label">' +
12443                    listReportLbl +
12444                    '</span></button>' +
12445                    '</div></div>' +
12446                    '<span class="chev">→</span></div>';
12447            }
12448            row.innerHTML = hr;
12449            if (grid) grid.innerHTML = hg;
12450            if (list) list.innerHTML = hl;
12451        }
12452
12453        for (var ok = 0; ok < multiKeys.length; ok++) {
12454            var catK = multiKeys[ok];
12455            fillShelfRow('row_' + catK.replace(/[^a-z0-9]+/gi, '_'), sortItems(byCat[catK]));
12456        }
12457        if (orphanItems.length === 1) {
12458            fillShelfRow('row__orphan_single', orphanItems);
12459        } else if (orphanItems.length > 1) {
12460            fillShelfRow('row__orphans_mixed', orphanItems);
12461        }
12462
12463        bindDetLogoFallbacks(host);
12464        host.querySelectorAll('.tile[data-svc-id], .list-row[data-svc-id]').forEach(function (el) {
12465            function doOpen() {
12466                var id = el.getAttribute('data-svc-id');
12467                if (id) openModal(id);
12468            }
12469            bindTouchAwareOpen(el, doOpen);
12470            el.addEventListener('keydown', function (e) {
12471                if (e.key === 'Enter' || e.key === ' ') {
12472                    e.preventDefault();
12473                    doOpen();
12474                }
12475            });
12476        });
12477        host.querySelectorAll('.shelf-row-wrap').forEach(bindShelfScrollHint);
12478        bindCommunityReportButtons(host);
12479        bindCommunityStatsButtons(host);
12480        bindStarRatingButtons(host);
12481        bindTileMoreButtons(host);
12482        applyViewToShelves();
12483        relocateCatalogSearchForViewport();
12484        mountIcmpNoReplySection();
12485        refreshComplaintsTrendSparklines(document);
12486    }
12487
12488    function mountShelves() {
12489        if (!markLazySectionDirty('top10Boards')) mountTop10Boards();
12490        mountTop10Cards();
12491        updateCatalogFilterBanner();
12492        if (!markLazySectionDirty('shelvesHost')) mountCatalogGridAndIcmpSection();
12493    }
12494
12495    /** Repõe filtros do catálogo e volta a mostrar os Top 10 em cartões (vista global). */
12496    function resetCatalogHomeView() {
12497        state.filter = 'all';
12498        state.ipStackFilter = 'all';
12499        state.rankSort = 'live_problems';
12500        state.catalogSearch = '';
12501        var inp = document.getElementById('catalogSearchInput');
12502        if (inp) inp.value = '';
12503        rebuildCategorySelect();
12504        var ip = document.getElementById('selIpStack');
12505        if (ip) ip.value = 'all';
12506        var sr = document.getElementById('selRank');
12507        if (sr) sr.value = 'live_problems';
12508        state.view = 'list';
12509        syncSegViewButtonsFromState();
12510        state.rankExpanded = {
12511            latFast: false,
12512            latSlow: false,
12513            incidents: false,
12514            trends: false,
12515            games: false,
12516            community: false,
12517        };
12518        mountShelves();
12519        updateCatalogViewToggleBtn();
12520        mountRankPanels();
12521        mountProblemsCatalog();
12522        updateMetricsBar();
12523        applyFilterSections();
12524        refreshShelfHints();
12525        var scrollEl = document.getElementById('top10Cards');
12526        if (DET_COMPLAINTS_VIEWPORT_MQ.matches) {
12527            scrollEl = document.getElementById('secShelves') || document.getElementById('catalogSearchBar') || scrollEl;
12528        }
12529        if (scrollEl && scrollEl.scrollIntoView) {
12530            try {
12531                scrollEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
12532            } catch (e) {
12533                scrollEl.scrollIntoView(true);
12534            }
12535        }
12536    }
12537
12538    function effectiveDetectorView() {
12539        return state.view || 'list';
12540    }
12541
12542    function syncSegViewButtonsFromState() {
12543        document.querySelectorAll('#toolbarCompact .seg-view button[data-view]').forEach(function (x) {
12544            x.classList.toggle('active', (x.getAttribute('data-view') || '') === state.view);
12545        });
12546    }
12547
12548    function updateCatalogViewToggleBtn() {
12549        var btn = document.getElementById('btnCatalogViewToggle');
12550        if (!btn) return;
12551        var txt = btn.querySelector('.catalog-view-toggle-txt');
12552        var ic = btn.querySelector('.catalog-view-toggle-ic');
12553        if (state.view === 'grid') {
12554            if (txt) txt.textContent = __('catalog_view_toggle_list_txt');
12555            if (ic) ic.textContent = '☰';
12556            btn.setAttribute('title', __('catalog_view_toggle_list_title'));
12557            btn.setAttribute('aria-label', __('catalog_view_toggle_list_aria'));
12558            btn.setAttribute('data-mode-to', 'list');
12559        } else {
12560            if (txt) txt.textContent = __('catalog_view_toggle_cards_txt');
12561            if (ic) ic.textContent = '▦';
12562            btn.setAttribute('title', __('catalog_view_toggle_cards_title'));
12563            btn.setAttribute('aria-label', __('catalog_view_toggle_cards_aria'));
12564            btn.setAttribute('data-mode-to', 'grid');
12565        }
12566    }
12567
12568    window.detectorUpdateCatalogViewToggleI18n = updateCatalogViewToggleBtn;
12569
12570    function applyViewToShelves() {
12571        var v = effectiveDetectorView();
12572        document.querySelectorAll('.shelf-block').forEach(function (block) {
12573            var row = block.querySelector('.row-scroll');
12574            var grid = block.querySelector('.grid-view');
12575            var list = block.querySelector('.list-view');
12576            if (row) row.style.display = v === 'row' ? 'flex' : 'none';
12577            if (grid) grid.style.display = v === 'grid' ? 'grid' : 'none';
12578            if (list) list.style.display = v === 'list' ? 'flex' : 'none';
12579        });
12580        refreshShelfHints();
12581    }
12582
12583    function mountDd() {
12584        var dd = (state.summary && state.summary.trending_issues) || [];
12585        var ddRow = document.getElementById('ddRow');
12586        if (!ddRow) return;
12587        var h = '';
12588        for (var j = 0; j < dd.length; j++) {
12589            var x = dd[j];
12590            var rawH = trendHref(x);
12591            var logo = faviconForDomain(trendFaviconDomain(x));
12592            h +=
12593                '<a class="tile state-warn" href="' +
12594                esc(rawH) +
12595                '"' +
12596                trendLinkTargetAttrs(rawH) +
12597                '>' +
12598                '<div class="poster"><img src="' +
12599                esc(logo) +
12600                '" alt="" loading="lazy"' +
12601                imgLogoOnErrorAttr(trendFaviconDomain(x)) +
12602                '></div>' +
12603                '<div class="meta"><div class="name">' +
12604                esc(x.name || x.slug) +
12605                '</div><span class="badge warn">' +
12606                esc(__('js_rank_trend_badge')) +
12607                '</span></div></a>';
12608        }
12609        ddRow.innerHTML = h || '<div class="empty">' + esc(__('js_dd_empty')) + '</div>';
12610    }
12611
12612    function findSvc(id) {
12613        if (!state.catalog || id == null || id === '') return null;
12614        var sid = String(id);
12615        for (var i = 0; i < state.catalog.length; i++) {
12616            if (String(state.catalog[i].id) === sid) return state.catalog[i];
12617        }
12618        return null;
12619    }
12620
12621    function destroyChart() {
12622        if (state.chart) {
12623            state.chart.destroy();
12624            state.chart = null;
12625        }
12626        var rzBtn = document.getElementById('svcChartResetZoom');
12627        if (rzBtn) rzBtn.hidden = true;
12628    }
12629
12630    function getChartRangeHours() {
12631        var rangeSel = document.getElementById('svcChartRange');
12632        if (rangeSel && rangeSel.value) {
12633            var n = parseInt(rangeSel.value, 10);
12634            if (!isNaN(n) && n > 0) return n;
12635        }
12636        return state.chartRangeHours || 168;
12637    }
12638
12639    /** Chart.js: carregamento preguiçoso; após o catálogo, pré-carrega em idle para o modal abrir mais rápido. */
12640    var chartJsLoadPromise = null;
12641    function ensureChartJsLoaded() {
12642        if (typeof Chart !== 'undefined') {
12643            return Promise.resolve();
12644        }
12645        if (chartJsLoadPromise) {
12646            return chartJsLoadPromise;
12647        }
12648        function loadScript(src) {
12649            return new Promise(function (resolve, reject) {
12650                var s = document.createElement('script');
12651                s.src = src;
12652                s.async = true;
12653                s.onload = resolve;
12654                s.onerror = function () {
12655                    reject(new Error('script'));
12656                };
12657                document.head.appendChild(s);
12658            });
12659        }
12660        chartJsLoadPromise = loadScript(
12661            'https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js',
12662        )
12663            .then(function () {
12664                return loadScript(
12665                    'https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-adapter-date-fns.bundle.min.js',
12666                );
12667            })
12668            .then(function () {
12669                return loadScript(
12670                    'https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js',
12671                );
12672            })
12673            .then(function () {
12674                return loadScript(
12675                    'https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js',
12676                );
12677            })
12678            .catch(function () {
12679                chartJsLoadPromise = null;
12680                throw new Error('chart_js');
12681            });
12682        return chartJsLoadPromise;
12683    }
12684
12685    function renderCatellixProductsPing(payload) {
12686        var monE = document.getElementById('catProdMonitoringStatus');
12687        var kidE = document.getElementById('catProdKidsStatus');
12688        var monC = document.getElementById('catProdCardMonitoring');
12689        var kidC = document.getElementById('catProdCardKids');
12690        function line(block) {
12691            if (!block) return '—';
12692            var ok = !!block.ok;
12693            var ms = block.ms != null ? String(Math.round(Number(block.ms))) : '—';
12694            var st =
12695                '<span class="' +
12696                (ok ? 'cat-ok' : 'cat-bad') +
12697                '">' +
12698                (ok ? 'OK' : 'Falha') +
12699                '</span> · ' +
12700                esc(ms) +
12701                ' ms';
12702            return st;
12703        }
12704        if (payload && payload.success && payload.monitoring && payload.kids) {
12705            if (monE) monE.innerHTML = line(payload.monitoring);
12706            if (kidE) kidE.innerHTML = line(payload.kids);
12707            var tip =
12708                (payload.monitoring && payload.monitoring.measure_kind_pt) ||
12709                (payload.measure_note_pt || '').slice(0, 220);
12710            if (monC) {
12711                monC.classList.toggle('cat-prod--ok', !!payload.monitoring.ok);
12712                monC.classList.toggle('cat-prod--bad', !payload.monitoring.ok);
12713                if (tip) monC.title = tip;
12714            }
12715            if (kidC) {
12716                kidC.classList.toggle('cat-prod--ok', !!payload.kids.ok);
12717                kidC.classList.toggle('cat-prod--bad', !payload.kids.ok);
12718                if (tip) kidC.title = tip;
12719            }
12720        } else {
12721            if (monE) monE.textContent = 'Indisponível';
12722            if (kidE) kidE.textContent = 'Indisponível';
12723            if (monC) {
12724                monC.classList.remove('cat-prod--ok');
12725                monC.classList.add('cat-prod--bad');
12726                monC.removeAttribute('title');
12727            }
12728            if (kidC) {
12729                kidC.classList.remove('cat-prod--ok');
12730                kidC.classList.add('cat-prod--bad');
12731                kidC.removeAttribute('title');
12732            }
12733        }
12734    }
12735
12736    function chartRangeLabelPt(hrs) {
12737        if (hrs < 24) return 'últ. ' + hrs + ' h';
12738        if (hrs === 24) return 'últ. 1 dia';
12739        var d = hrs / 24;
12740        if (d === Math.floor(d)) return 'últ. ' + Math.floor(d) + ' dias';
12741        return 'últ. ' + hrs + ' h';
12742    }
12743
12744    function z2(n) {
12745        return (n < 10 ? '0' : '') + n;
12746    }
12747
12748    function localDayKey(d) {
12749        return d.getFullYear() + '-' + z2(d.getMonth() + 1) + '-' + z2(d.getDate());
12750    }
12751
12752    function getLast7LocalDays() {
12753        var out = [];
12754        for (var i = 6; i >= 0; i--) {
12755            var d = new Date();
12756            d.setHours(12, 0, 0, 0);
12757            d.setDate(d.getDate() - i);
12758            out.push({ key: localDayKey(d), date: d });
12759        }
12760        return out;
12761    }
12762
12763    /** Estrelas do modal: com votos → média 1–5 (meias estrelas); sem votos → mapa justo a partir do trust_score (100→5, piso 58→1), alinhado ao algoritmo de confiança. */
12764    function computeModalStarDisplay(svc) {
12765        var trust = Number(svc && svc.trust_score);
12766        if (isNaN(trust)) trust = 100;
12767        var cnt = parseInt(String((svc && svc.star_rating_count) || 0), 10) || 0;
12768        var avg = Number(svc && svc.avg_star_rating);
12769        if (cnt >= 1 && !isNaN(avg) && avg > 0) {
12770            var v = Math.max(0, Math.min(5, avg));
12771            var av = v.toFixed(1).replace('.', ',');
12772            return {
12773                value: v,
12774                source: 'votes',
12775                hint:
12776                    cnt === 1
12777                        ? __('js_star_vote_one_tpl').replace(/\{avg\}/g, av)
12778                        : __('js_star_votes_hint_tpl')
12779                              .replace(/\{n\}/g, String(cnt))
12780                              .replace(/\{avg\}/g, av),
12781            };
12782        }
12783        var lo = 58;
12784        var hi = 100;
12785        var t = Math.max(lo, Math.min(hi, trust));
12786        var mapped = 1 + ((t - lo) / (hi - lo)) * 4;
12787        mapped = Math.max(0.5, Math.min(5, mapped));
12788        return {
12789            value: mapped,
12790            source: 'trust',
12791            hint: __('js_star_vote_hint'),
12792        };
12793    }
12794
12795    function modalTrustPillClass(score) {
12796        if (score >= 85) return 'svc-modal-trust-pill svc-modal-trust-pill--good';
12797        if (score >= 72) return 'svc-modal-trust-pill svc-modal-trust-pill--warn';
12798        return 'svc-modal-trust-pill svc-modal-trust-pill--bad';
12799    }
12800
12801    function renderStarsDisplayHtml(starValue, ariaLabel) {
12802        var v = Number(starValue);
12803        if (isNaN(v)) v = 0;
12804        v = Math.max(0, Math.min(5, v));
12805        var html =
12806            '<span class="svc-stars-display" role="img" aria-label="' +
12807            esc(ariaLabel) +
12808            '">';
12809        for (var i = 1; i <= 5; i++) {
12810            var fill = Math.max(0, Math.min(1, v - (i - 1)));
12811            var pct = Math.round(fill * 100);
12812            html +=
12813                '<span class="svc-star-cell" aria-hidden="true">' +
12814                '<span class="svc-star-bg">★</span>' +
12815                '<span class="svc-star-fill" style="width:' +
12816                pct +
12817                '%">★</span>' +
12818                '</span>';
12819        }
12820        html += '</span>';
12821        return html;
12822    }
12823
12824    /** Estrelas no cabeçalho do modal de detalhe: cada uma abre o fluxo de voto com N estrelas. */
12825    function renderModalStarsInteractiveHtml(svc, sd) {
12826        var v = Number(sd.value);
12827        if (isNaN(v)) v = 0;
12828        v = Math.max(0, Math.min(5, v));
12829        var sid = svc && svc.id != null ? String(svc.id) : '';
12830        var sname = svc && svc.name != null ? String(svc.name) : sid;
12831        var ariaLabel =
12832            sd.value.toFixed(1).replace('.', ',') +
12833            ' de 5 estrelas. ' +
12834            (sd.source === 'votes' ? __('js_modal_stars_aria_votes') : __('js_modal_stars_aria_trust'));
12835        var html =
12836            '<span class="svc-stars-display svc-stars-display--interactive" role="img" aria-label="' +
12837            esc(ariaLabel) +
12838            '">';
12839        var i;
12840        for (i = 1; i <= 5; i++) {
12841            var fill = Math.max(0, Math.min(1, v - (i - 1)));
12842            var pct = Math.round(fill * 100);
12843            var voteAria = __('js_modal_star_vote_n').replace(/\{n\}/g, String(i));
12844            html +=
12845                '<button type="button" class="svc-star-vote" data-svc-id="' +
12846                esc(sid) +
12847                '" data-svc-name="' +
12848                esc(sname) +
12849                '" data-star-vote="' +
12850                i +
12851                '" aria-label="' +
12852                esc(voteAria) +
12853                '">' +
12854                '<span class="svc-star-cell" aria-hidden="true">' +
12855                '<span class="svc-star-bg">★</span>' +
12856                '<span class="svc-star-fill" style="width:' +
12857                pct +
12858                '%">★</span>' +
12859                '</span></button>';
12860        }
12861        html += '</span>';
12862        return html;
12863    }
12864
12865    function updateSvcModalHeaderScores(svc) {
12866        var pill = document.getElementById('svcModalScorePill');
12867        var host = document.getElementById('svcModalStarsHost');
12868        if (!pill || !host) return;
12869        var ts = Number(svc && svc.trust_score);
12870        if (isNaN(ts)) ts = 100;
12871        pill.hidden = false;
12872        pill.className = modalTrustPillClass(ts) + ' svc-modal-trust-pill--clickable';
12873        pill.textContent = Math.round(ts) + '/100';
12874        pill.setAttribute('role', 'button');
12875        pill.setAttribute('tabindex', '0');
12876        pill.setAttribute(
12877            'title',
12878            __('trust_score_help_title'),
12879        );
12880        var sd = computeModalStarDisplay(svc);
12881        var hintSpan =
12882            sd.hint && String(sd.hint).trim()
12883                ? '<span class="svc-modal-star-hint">' + esc(sd.hint) + '</span>'
12884                : '';
12885        hintSpan +=
12886            '<span class="svc-modal-star-hint svc-modal-star-hint--tap">' +
12887            esc(__('js_modal_stars_group_hint')) +
12888            '</span>';
12889        host.innerHTML = renderModalStarsInteractiveHtml(svc, sd) + hintSpan;
12890    }
12891
12892    function clearSvcModalHeaderScores() {
12893        var pill = document.getElementById('svcModalScorePill');
12894        var host = document.getElementById('svcModalStarsHost');
12895        if (pill) {
12896            pill.hidden = true;
12897            pill.textContent = '';
12898            pill.className = 'svc-modal-trust-pill svc-modal-trust-pill--good';
12899            pill.removeAttribute('role');
12900            pill.removeAttribute('tabindex');
12901            pill.removeAttribute('title');
12902        }
12903        if (host) host.innerHTML = '';
12904    }
12905
12906    function openTrustScoreExplainModal() {
12907        var modal = document.getElementById('trustScoreExplainModal');
12908        var body = document.getElementById('trustScoreExplainBody');
12909        var titleEl = document.getElementById('trustScoreExplainTitle');
12910        if (!modal || !body) return;
12911        if (titleEl) titleEl.textContent = __('trust_score_help_title');
12912        body.innerHTML =
12913            '<p class="trust-score-help-lead">' +
12914            esc(__('trust_score_help_p1')) +
12915            '</p>' +
12916            '<ul class="trust-score-help-ul">' +
12917            '<li>' +
12918            esc(__('trust_score_help_p2')) +
12919            '</li>' +
12920            '<li>' +
12921            esc(__('trust_score_help_p3')) +
12922            '</li>' +
12923            '<li>' +
12924            esc(__('trust_score_help_p4')) +
12925            '</li>' +
12926            '</ul>' +
12927            '<p class="trust-score-help-foot">' +
12928            esc(__('trust_score_help_p5')) +
12929            '</p>';
12930        modal.classList.add('open');
12931        modal.setAttribute('aria-hidden', 'false');
12932        document.body.style.overflow = 'hidden';
12933    }
12934
12935    function closeTrustScoreExplainModal() {
12936        var modal = document.getElementById('trustScoreExplainModal');
12937        if (modal) {
12938            modal.classList.remove('open');
12939            modal.setAttribute('aria-hidden', 'true');
12940        }
12941        var svc = document.getElementById('svcModal');
12942        var star = document.getElementById('starRatingModal');
12943        if ((svc && svc.classList.contains('open')) || (star && star.classList.contains('open'))) {
12944            document.body.style.overflow = 'hidden';
12945        } else {
12946            document.body.style.overflow = '';
12947        }
12948    }
12949
12950    function renderSvcAvailabilityPlan(pts, targetKey) {
12951        var el = document.getElementById('svcAvailabilityPlan');
12952        if (!el) return;
12953        var days = getLast7LocalDays();
12954        var byDay = {};
12955        for (var i = 0; i < (pts || []).length; i++) {
12956            var p = pts[i];
12957            if (!p || !p.t) continue;
12958            var dt = new Date(p.t);
12959            if (isNaN(dt.getTime())) continue;
12960            var k = localDayKey(dt);
12961            if (!byDay[k]) byDay[k] = [];
12962            byDay[k].push(p);
12963        }
12964        var cleanCount = 0;
12965        var rows = '';
12966        for (var d = 0; d < days.length; d++) {
12967            var dk = days[d].key;
12968            var dd = days[d].date;
12969            var arr = byDay[dk] || [];
12970            var status = 'unk';
12971            if (arr.length > 0) {
12972                var anyFail = false;
12973                for (var j = 0; j < arr.length; j++) {
12974                    if (arr[j].ok === false) {
12975                        anyFail = true;
12976                        break;
12977                    }
12978                }
12979                status = anyFail ? 'bad' : 'ok';
12980            }
12981            if (status === 'ok') cleanCount++;
12982            var lab = dd.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: '2-digit' });
12983            var fillCls =
12984                status === 'ok' ? 'svc-ap-bar-fill--ok' : status === 'bad' ? 'svc-ap-bar-fill--bad' : 'svc-ap-bar-fill--unk';
12985            var tag =
12986                status === 'ok' ? 'OK' : status === 'bad' ? 'Falha' : 'Sem dados';
12987            var tagCls =
12988                status === 'ok' ? 'svc-ap-tag svc-ap-tag--ok' : status === 'bad' ? 'svc-ap-tag svc-ap-tag--bad' : 'svc-ap-tag';
12989            rows +=
12990                '<div class="svc-ap-row">' +
12991                '<span class="svc-ap-label">' +
12992                esc(lab) +
12993                '</span>' +
12994                '<div class="svc-ap-bar-track"><div class="svc-ap-bar-fill ' +
12995                fillCls +
12996                '"></div></div>' +
12997                '<span class="' +
12998                tagCls +
12999                '">' +
13000                esc(tag) +
13001                '</span>' +
13002                '</div>';
13003        }
13004        el.innerHTML =
13005            '<div class="svc-ap-title">' +
13006            esc(__('js_svc_ap_title')) +
13007            '</div>' +
13008            '<p class="svc-ap-sub">' +
13009            esc(__('js_svc_ap_sub')) +
13010            '</p>' +
13011            '<div class="svc-ap-scoreline">' +
13012            esc(__('js_svc_ap_score')) +
13013            ': <strong>+' +
13014            esc(String(cleanCount)) +
13015            '</strong> <span style="color:var(--muted);font-weight:600;">/ 7</span></div>' +
13016            rows;
13017        el.hidden = false;
13018    }
13019
13020    function clearSvcAvailabilityPlan() {
13021        var el = document.getElementById('svcAvailabilityPlan');
13022        if (!el) return;
13023        el.innerHTML = '';
13024        el.hidden = true;
13025    }
13026
13027    /** Cores de grelha, eixos e tooltip do Chart.js conforme tema claro / escuro. */
13028    function getDetectorChartPalette() {
13029        var dark = getDetTheme() === 'dark';
13030        if (dark) {
13031            return {
13032                xGrid: 'rgba(255, 255, 255, 0.05)',
13033                yGrid: 'rgba(130, 138, 152, 0.2)',
13034                axisLine: 'rgba(255, 255, 255, 0.11)',
13035                tick: 'rgba(212, 216, 222, 0.9)',
13036                title: 'rgba(232, 234, 237, 0.92)',
13037                legend: 'rgba(220, 224, 230, 0.9)',
13038                tooltipBg: 'rgba(48, 54, 64, 0.96)',
13039                tooltipTitle: '#9ca8b8',
13040                tooltipBody: '#e8eaed',
13041                tooltipBorder: 'rgba(255, 255, 255, 0.12)',
13042            };
13043        }
13044        return {
13045            xGrid: 'rgba(2, 132, 199, 0.07)',
13046            yGrid: 'rgba(100, 116, 139, 0.13)',
13047            axisLine: 'rgba(2, 132, 199, 0.24)',
13048            tick: 'rgba(30, 41, 59, 0.86)',
13049            title: 'rgba(30, 41, 59, 0.88)',
13050            legend: 'rgba(30, 41, 59, 0.9)',
13051            tooltipBg: 'rgba(255, 255, 255, 0.98)',
13052            tooltipTitle: '#0369a1',
13053            tooltipBody: '#0f172a',
13054            tooltipBorder: 'rgba(2, 132, 199, 0.38)',
13055        };
13056    }
13057
13058    /** Gradiente vertical sob a linha do gráfico de latência (adaptado ao tema). */
13059    function detectorLatencyAreaGradient(ctx, chartArea) {
13060        if (!chartArea || chartArea.bottom <= chartArea.top) {
13061            return getDetTheme() === 'dark' ? 'rgba(148, 163, 184, 0.12)' : 'rgba(14, 165, 233, 0.16)';
13062        }
13063        var dark = getDetTheme() === 'dark';
13064        var g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
13065        if (dark) {
13066            g.addColorStop(0, 'rgba(148, 163, 184, 0.28)');
13067            g.addColorStop(0.38, 'rgba(113, 113, 122, 0.1)');
13068            g.addColorStop(0.78, 'rgba(72, 78, 88, 0.06)');
13069            g.addColorStop(1, 'rgba(38, 42, 50, 0.02)');
13070        } else {
13071            g.addColorStop(0, 'rgba(0, 163, 255, 0.34)');
13072            g.addColorStop(0.32, 'rgba(2, 132, 199, 0.18)');
13073            g.addColorStop(0.68, 'rgba(56, 189, 248, 0.09)');
13074            g.addColorStop(1, 'rgba(248, 250, 252, 0.02)');
13075        }
13076        return g;
13077    }
13078
13079    /** Cores semi-transparentes por série (modo comparativo). */
13080    var CHART_MULTI_LINE_RGB = [
13081        '56, 189, 248',
13082        '167, 139, 250',
13083        '52, 211, 153',
13084        '251, 191, 36',
13085        '248, 113, 113',
13086        '244, 114, 182',
13087        '94, 234, 212',
13088        '147, 197, 253',
13089        '249, 168, 212',
13090        '192, 132, 252',
13091    ];
13092
13093    function setSvcChartMultiUi(active) {
13094        var wrap = document.getElementById('svcLatencyChartWrap');
13095        var legHint = document.getElementById('svcChartMultiHint');
13096        var sel = document.getElementById('svcChartTarget');
13097        if (wrap) wrap.classList.toggle('chart-wrap--multi', !!active);
13098        if (legHint) {
13099            legHint.textContent = '';
13100            legHint.hidden = true;
13101        }
13102        if (sel) sel.disabled = !!active;
13103    }
13104
13105    /** Overlay dentro da caixa do gráfico (evita caixa “vazia” enquanto a API responde). */
13106    function setSvcLatencyChartLoading(show, mainText) {
13107        var el = document.getElementById('svcLatencyChartLoading');
13108        var lab = document.getElementById('svcLatencyChartLoadingLabel');
13109        var sub = document.getElementById('svcLatencyChartLoadingSub');
13110        var wrap = document.getElementById('svcLatencyChartWrap');
13111        if (!el) return;
13112        if (show) {
13113            if (lab && mainText) lab.textContent = mainText;
13114            if (sub) sub.textContent = __('js_chart_loading_overlay_sub');
13115            el.hidden = false;
13116            if (wrap) wrap.setAttribute('aria-busy', 'true');
13117        } else {
13118            el.hidden = true;
13119            if (wrap) wrap.removeAttribute('aria-busy');
13120        }
13121    }
13122
13123    function isSvcChartMultiMode() {
13124        var c = document.getElementById('svcChartMulti');
13125        return !!(c && c.checked);
13126    }
13127
13128    function prettyChartTargetLabel(key) {
13129        var k = (key || 'primary').toString();
13130        if (k === 'primary') return 'Principal (HTTPS)';
13131        if (k === 'icmp_echo') return 'ICMP (ping)';
13132        if (k === 'status') return 'Página de status';
13133        if (k === 'community') return 'Comunidade';
13134        if (/^tcp_\d+$/.test(k)) return 'TCP :' + k.slice(4);
13135        return k;
13136    }
13137
13138    function chartTargetKeySortOrder(k) {
13139        var order = { primary: 0, status: 1, community: 2, icmp_echo: 10 };
13140        if (order[k] !== undefined) return order[k];
13141        var m = /^tcp_(\d+)$/.exec(k);
13142        if (m) return 20 + parseInt(m[1], 10) / 65535;
13143        return 5;
13144    }
13145
13146    function detectorLatencyFillGradientTinted(ctx, chartArea, rgbStr) {
13147        if (!chartArea || chartArea.bottom <= chartArea.top) {
13148            return 'rgba(' + rgbStr + ', ' + (getDetTheme() === 'dark' ? '0.16' : '0.12') + ')';
13149        }
13150        var dark = getDetTheme() === 'dark';
13151        var g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
13152        g.addColorStop(0, 'rgba(' + rgbStr + ', ' + (dark ? '0.4' : '0.34') + ')');
13153        g.addColorStop(0.42, 'rgba(' + rgbStr + ', ' + (dark ? '0.16' : '0.14') + ')');
13154        g.addColorStop(1, 'rgba(' + rgbStr + ', ' + (dark ? '0.03' : '0.02') + ')');
13155        return g;
13156    }
13157
13158    function chartTooltipLabelLine(ctx) {
13159        var raw = ctx.raw;
13160        var y = ctx.parsed && ctx.parsed.y;
13161        var name = (ctx.dataset && ctx.dataset.label) || '';
13162        if (raw && typeof raw === 'object' && raw._ok === false) {
13163            return ' ' + name + ': falha (sem latência)';
13164        }
13165        if (y == null || isNaN(Number(y))) {
13166            return ' ' + name + ': sem medição neste instante';
13167        }
13168        var tag =
13169            raw && raw._ok === true ? 'OK' : raw && raw._ok === false ? 'falhou' : '—';
13170        return ' ' + name + ': ' + Math.round(Number(y)) + ' ms · ' + tag;
13171    }
13172
13173    function reloadModalChartFromControls() {
13174        var sid = state.modalSvcId;
13175        if (!sid) return;
13176        if (isSvcChartMultiMode()) {
13177            loadModalChartAllTargets(sid);
13178        } else {
13179            var tsel = document.getElementById('svcChartTarget');
13180            loadModalChart(sid, tsel && tsel.value ? tsel.value : 'primary');
13181        }
13182    }
13183
13184    /**
13185     * Um gráfico, N linhas: todos os target_key (HTTPS, ICMP, tcp_*) no mesmo eixo temporal.
13186     * Legenda Chart.js: clique para ocultar/mostrar série.
13187     */
13188    function loadModalChartAllTargets(svcId) {
13189        setSvcChartMultiUi(true);
13190        destroyChart();
13191        setSvcLatencyChartLoading(true, __('js_chart_comp_loading'));
13192        var hint = document.getElementById('svcChartProbeHint');
13193        if (hint) hint.textContent = '';
13194        clearSvcAvailabilityPlan();
13195        var hrs = getChartRangeHours();
13196        state.chartRangeHours = hrs;
13197        var origin = window.location.origin || '';
13198        var api =
13199            origin +
13200            '/api/detector/service/' +
13201            encodeURIComponent(svcId) +
13202            '/latency?hours=' +
13203            encodeURIComponent(String(hrs)) +
13204            '&target_key=all';
13205        var fetchP = fetch(api, { credentials: 'omit' }).then(function (r) {
13206            return r
13207                .json()
13208                .catch(function () {
13209                    return {};
13210                })
13211                .then(function (data) {
13212                    if (!r.ok) {
13213                        var det = (data && (data.detail || data.message)) || '';
13214                        var err = new Error(det || 'HTTP ' + r.status);
13215                        err._httpStatus = r.status;
13216                        throw err;
13217                    }
13218                    return data;
13219                });
13220        });
13221        Promise.all([ensureChartJsLoaded(), fetchP])
13222            .then(function (pair) {
13223                var data = pair[1];
13224                var pts = (data && data.points) || [];
13225                if (hint) hint.textContent = '';
13226                var byKey = {};
13227                for (var i = 0; i < pts.length; i++) {
13228                    var p = pts[i];
13229                    var kk = (p.target_key || 'primary').toString();
13230                    if (!byKey[kk]) byKey[kk] = [];
13231                    byKey[kk].push(p);
13232                }
13233                var keys = Object.keys(byKey).sort(function (a, b) {
13234                    var d = chartTargetKeySortOrder(a) - chartTargetKeySortOrder(b);
13235                    return d !== 0 ? d : a.localeCompare(b);
13236                });
13237                var canvas = document.getElementById('svcLatencyChart');
13238                if (!canvas) {
13239                    setSvcLatencyChartLoading(false);
13240                    if (hint) hint.textContent = 'Área do gráfico indisponível na página.';
13241                    return;
13242                }
13243                if (typeof Chart === 'undefined') {
13244                    setSvcLatencyChartLoading(false);
13245                    if (hint) {
13246                        hint.textContent =
13247                            'Chart.js não ficou carregado. Verifique rede ou bloqueios a cdn.jsdelivr.net.';
13248                    }
13249                    return;
13250                }
13251                if (keys.length === 0 || pts.length === 0) {
13252                    setSvcLatencyChartLoading(false);
13253                    if (hint) {
13254                        hint.textContent =
13255                            'Sem pontos na base para este serviço neste período. Aguarde o worker de sondas.';
13256                    }
13257                    return;
13258                }
13259                var datasets = [];
13260                var totalXY = 0;
13261                for (var di = 0; di < keys.length; di++) {
13262                    var kk = keys[di];
13263                    var raw = byKey[kk];
13264                    var rgb = CHART_MULTI_LINE_RGB[di % CHART_MULTI_LINE_RGB.length];
13265                    var xy = raw
13266                        .map(function (p) {
13267                            var xd = p.t ? new Date(p.t) : null;
13268                            if (xd && isNaN(xd.getTime())) xd = null;
13269                            return {
13270                                x: xd,
13271                                y: p.ok && p.ms != null ? Number(p.ms) : null,
13272                                _ok: p.ok,
13273                            };
13274                        })
13275                        .filter(function (pt) {
13276                            return pt.x != null;
13277                        })
13278                        .sort(function (a, b) {
13279                            return a.x.getTime() - b.x.getTime();
13280                        });
13281                    totalXY += xy.length;
13282                    datasets.push({
13283                        label: prettyChartTargetLabel(kk),
13284                        data: xy,
13285                        parsing: false,
13286                        borderColor: 'rgba(' + rgb + ', 0.94)',
13287                        backgroundColor: function (ctx) {
13288                            return detectorLatencyFillGradientTinted(
13289                                ctx.chart.ctx,
13290                                ctx.chart.chartArea,
13291                                rgb,
13292                            );
13293                        },
13294                        spanGaps: true,
13295                        tension: 0.36,
13296                        fill: true,
13297                        pointRadius: 0,
13298                        pointHoverRadius: 6,
13299                        pointHoverBorderWidth: 2,
13300                        pointHoverBackgroundColor: 'rgba(255,255,255,0.9)',
13301                        pointHoverBorderColor: 'rgba(' + rgb + ', 1)',
13302                        hitRadius: 16,
13303                        borderWidth: 2.45,
13304                        borderJoinStyle: 'round',
13305                        borderCapStyle: 'round',
13306                    });
13307                }
13308                var useDecimate = totalXY > 280;
13309                var cp = getDetectorChartPalette();
13310                try {
13311                    state.chart = new Chart(canvas, {
13312                        type: 'line',
13313                        data: { datasets: datasets },
13314                        options: {
13315                            responsive: true,
13316                            maintainAspectRatio: false,
13317                            resizeDelay: 16,
13318                            interaction: { mode: 'index', intersect: false, axis: 'x' },
13319                            hover: { mode: 'index', intersect: false },
13320                            animation: {
13321                                duration:
13322                                    (typeof window.matchMedia === 'function' &&
13323                                        window.matchMedia('(max-width: 768px)').matches) ||
13324                                    totalXY > 500
13325                                        ? 0
13326                                        : 720,
13327                                easing: 'easeOutQuart',
13328                            },
13329                            elements: {
13330                                line: { borderJoinStyle: 'round', borderCapStyle: 'round' },
13331                                point: { hoverRadius: 6 },
13332                            },
13333                            scales: {
13334                                x: {
13335                                    type: 'time',
13336                                    min: new Date(Date.now() - hrs * 3600000),
13337                                    max: new Date(),
13338                                    time: {
13339                                        tooltipFormat: 'dd/MM/yyyy HH:mm',
13340                                        displayFormats: {
13341                                            millisecond: 'HH:mm:ss.SSS',
13342                                            second: 'HH:mm:ss',
13343                                            minute: 'HH:mm',
13344                                            hour: 'HH:mm',
13345                                            day: 'dd/MM',
13346                                            week: 'dd/MM',
13347                                            month: 'MMM yyyy',
13348                                            quarter: 'MMM yyyy',
13349                                            year: 'yyyy',
13350                                        },
13351                                    },
13352                                    grid: {
13353                                        color: cp.xGrid,
13354                                        lineWidth: 1,
13355                                        borderDash: [4, 6],
13356                                    },
13357                                    border: {
13358                                        display: true,
13359                                        color: cp.axisLine,
13360                                        dash: [2, 2],
13361                                    },
13362                                    ticks: {
13363                                        color: cp.tick,
13364                                        maxRotation: 0,
13365                                        maxTicksLimit: hrs <= 24 ? 12 : 8,
13366                                        font: { size: 10, family: "system-ui, 'Segoe UI', sans-serif" },
13367                                    },
13368                                },
13369                                y: {
13370                                    grace: '12%',
13371                                    title: {
13372                                        display: true,
13373                                        text: 'Latência (ms)',
13374                                        color: cp.title,
13375                                        font: { size: 11, weight: '600' },
13376                                    },
13377                                    grid: {
13378                                        color: cp.yGrid,
13379                                        lineWidth: 1,
13380                                        borderDash: [3, 5],
13381                                    },
13382                                    border: {
13383                                        display: true,
13384                                        color: cp.axisLine,
13385                                        dash: [2, 2],
13386                                    },
13387                                    ticks: {
13388                                        color: cp.tick,
13389                                        font: { size: 10, family: "system-ui, 'Segoe UI', sans-serif" },
13390                                        callback: function (v) {
13391                                            var n = Number(v);
13392                                            return isNaN(n) ? v : Math.round(n);
13393                                        },
13394                                    },
13395                                },
13396                            },
13397                            plugins: {
13398                                decimation: useDecimate
13399                                    ? {
13400                                          enabled: true,
13401                                          algorithm: 'lttb',
13402                                          samples: Math.min(200, Math.max(60, Math.floor(totalXY / keys.length))),
13403                                      }
13404                                    : { enabled: false },
13405                                legend: {
13406                                    display: true,
13407                                    position: 'bottom',
13408                                    align: 'start',
13409                                    labels: {
13410                                        color: cp.legend,
13411                                        boxWidth: 20,
13412                                        boxHeight: 3,
13413                                        padding: 10,
13414                                        font: { size: 10, weight: '600' },
13415                                        usePointStyle: true,
13416                                        pointStyle: 'line',
13417                                    },
13418                                },
13419                                tooltip: {
13420                                    enabled: true,
13421                                    backgroundColor: cp.tooltipBg,
13422                                    titleColor: cp.tooltipTitle,
13423                                    bodyColor: cp.tooltipBody,
13424                                    borderColor: cp.tooltipBorder,
13425                                    borderWidth: 1,
13426                                    padding: 12,
13427                                    cornerRadius: 12,
13428                                    displayColors: true,
13429                                    boxPadding: 4,
13430                                    titleFont: { size: 12, weight: '600' },
13431                                    bodyFont: { size: 12 },
13432                                    callbacks: {
13433                                        label: chartTooltipLabelLine,
13434                                    },
13435                                },
13436                                zoom: {
13437                                    pan: {
13438                                        enabled: true,
13439                                        mode: 'x',
13440                                        modifierKey: 'ctrl',
13441                                        onPanComplete: function () {
13442                                            var btn = document.getElementById('svcChartResetZoom');
13443                                            if (btn) btn.hidden = false;
13444                                        },
13445                                    },
13446                                    limits: { x: { min: 'original', max: 'original' } },
13447                                    zoom: {
13448                                        pinch: { enabled: true },
13449                                        drag: {
13450                                            enabled: true,
13451                                            borderColor: 'rgba(2,132,199,0.35)',
13452                                            borderWidth: 1,
13453                                            backgroundColor: 'rgba(2,132,199,0.08)',
13454                                        },
13455                                        mode: 'x',
13456                                        onZoomComplete: function () {
13457                                            var btn = document.getElementById('svcChartResetZoom');
13458                                            if (btn) btn.hidden = false;
13459                                        },
13460                                    },
13461                                },
13462                            },
13463                        },
13464                    });
13465                    setSvcLatencyChartLoading(false);
13466                } catch (e2) {
13467                    setSvcLatencyChartLoading(false);
13468                    if (typeof console !== 'undefined' && console.warn) console.warn('[detector] Chart multi', e2);
13469                    if (hint) {
13470                        hint.textContent =
13471                            'Não foi possível desenhar o gráfico comparativo. ' +
13472                            (e2 && e2.message ? e2.message : '');
13473                    }
13474                }
13475            })
13476            .catch(function (err) {
13477                setSvcLatencyChartLoading(false);
13478                if (typeof console !== 'undefined' && console.warn) {
13479                    console.warn('[detector] loadModalChartAllTargets', err);
13480                }
13481                if (hint) {
13482                    if (err && err.message === 'chart_js') {
13483                        hint.textContent =
13484                            'Não foi possível carregar Chart.js (rede ou bloqueador a jsDelivr).';
13485                    } else if (err && err._httpStatus === 429) {
13486                        hint.textContent = 'Servidor limitou pedidos (429). Aguarde e tente de novo.';
13487                    } else if (err && err.message) {
13488                        hint.textContent = 'Gráfico comparativo indisponível: ' + err.message;
13489                    } else {
13490                        hint.textContent = 'Gráfico comparativo indisponível.';
13491                    }
13492                }
13493            });
13494    }
13495
13496    function loadModalChart(svcId, targetKey) {
13497        if (isSvcChartMultiMode()) {
13498            loadModalChartAllTargets(svcId);
13499            return;
13500        }
13501        setSvcChartMultiUi(false);
13502        destroyChart();
13503        setSvcLatencyChartLoading(true, __('js_chart_loading'));
13504        var hint = document.getElementById('svcChartProbeHint');
13505        if (hint) hint.textContent = '';
13506        clearSvcAvailabilityPlan();
13507        var planEl = document.getElementById('svcAvailabilityPlan');
13508        if (planEl) {
13509            planEl.hidden = false;
13510            planEl.innerHTML =
13511                '<p class="chart-probe-hint" style="margin-top:12px;">A carregar plano de 7 dias…</p>';
13512        }
13513        var hrs = getChartRangeHours();
13514        state.chartRangeHours = hrs;
13515        var origin = window.location.origin || '';
13516        var tk = targetKey || 'primary';
13517        var api =
13518            origin +
13519            '/api/detector/service/' +
13520            encodeURIComponent(svcId) +
13521            '/latency?hours=' +
13522            encodeURIComponent(String(hrs)) +
13523            '&target_key=' +
13524            encodeURIComponent(tk) +
13525            '&include_availability_plan=true';
13526        var fetchP = fetch(api, { credentials: 'omit' }).then(function (r) {
13527            return r
13528                .json()
13529                .catch(function () {
13530                    return {};
13531                })
13532                .then(function (data) {
13533                    if (!r.ok) {
13534                        var det = (data && (data.detail || data.message)) || '';
13535                        var err = new Error(det || 'HTTP ' + r.status);
13536                        err._httpStatus = r.status;
13537                        throw err;
13538                    }
13539                    return data;
13540                });
13541        });
13542        Promise.all([ensureChartJsLoaded(), fetchP])
13543            .then(function (pair) {
13544                var data = pair[1];
13545                var pts = (data && data.points) || [];
13546                var probe = '';
13547                for (var i = pts.length - 1; i >= 0; i--) {
13548                    if (pts[i].probe_endpoint) {
13549                        probe = pts[i].probe_endpoint;
13550                        break;
13551                    }
13552                }
13553                if (hint) {
13554                    if (probe) {
13555                        var pu = String(probe);
13556                        hint.textContent = pu.length > 64 ? pu.slice(0, 61) + '\u2026' : pu;
13557                    } else {
13558                        hint.textContent = '';
13559                    }
13560                }
13561                var xy = pts
13562                    .map(function (p) {
13563                        var xd = p.t ? new Date(p.t) : null;
13564                        if (xd && isNaN(xd.getTime())) xd = null;
13565                        return {
13566                            x: xd,
13567                            y: p.ok && p.ms != null ? Number(p.ms) : null,
13568                            _ok: p.ok,
13569                        };
13570                    })
13571                    .filter(function (pt) {
13572                        return pt.x != null;
13573                    })
13574                    .sort(function (a, b) {
13575                        return a.x.getTime() - b.x.getTime();
13576                    });
13577                if (data.plan_points && data.plan_points.length) {
13578                    renderSvcAvailabilityPlan(data.plan_points, tk);
13579                } else {
13580                    renderSvcAvailabilityPlan([], tk);
13581                }
13582                var canvas = document.getElementById('svcLatencyChart');
13583                if (!canvas) {
13584                    setSvcLatencyChartLoading(false);
13585                    if (hint) hint.textContent = 'Área do gráfico indisponível na página.';
13586                    return;
13587                }
13588                if (typeof Chart === 'undefined') {
13589                    setSvcLatencyChartLoading(false);
13590                    if (hint) {
13591                        hint.textContent =
13592                            'Chart.js não ficou carregado. Verifique rede ou bloqueios a cdn.jsdelivr.net.';
13593                    }
13594                    return;
13595                }
13596                if (xy.length === 0) {
13597                    setSvcLatencyChartLoading(false);
13598                    if (hint) {
13599                        hint.textContent =
13600                            'Sem pontos de latência na base para este destino neste período. ' +
13601                            'O worker do Catellix regista o histórico ao longo do tempo; tente outro intervalo ou volte mais tarde. ' +
13602                            '(Statuspage: o gráfico reflecte sondagens HTTPS ao URL de estado.)';
13603                    }
13604                    return;
13605                }
13606                var nPts = xy.length;
13607                var useDecimate = nPts > 160;
13608                var chartLabel = 'Latência (ms) · ' + tk + ' · ' + chartRangeLabelPt(hrs);
13609                var cpSingle = getDetectorChartPalette();
13610                try {
13611                    state.chart = new Chart(canvas, {
13612                    type: 'line',
13613                    data: {
13614                        datasets: [
13615                            {
13616                                label: chartLabel,
13617                                data: xy,
13618                                parsing: false,
13619                                borderColor:
13620                                    getDetTheme() === 'dark'
13621                                        ? 'rgba(148, 163, 184, 0.95)'
13622                                        : 'rgba(14, 165, 233, 0.98)',
13623                                backgroundColor: function (ctx) {
13624                                    return detectorLatencyAreaGradient(
13625                                        ctx.chart.ctx,
13626                                        ctx.chart.chartArea,
13627                                    );
13628                                },
13629                                spanGaps: true,
13630                                tension: 0.38,
13631                                fill: true,
13632                                pointRadius: 0,
13633                                pointHoverRadius: 7,
13634                                pointHoverBorderWidth: 2,
13635                                pointHoverBackgroundColor:
13636                                    getDetTheme() === 'dark'
13637                                        ? 'rgba(48, 54, 64, 0.96)'
13638                                        : 'rgba(255, 255, 255, 0.98)',
13639                                pointHoverBorderColor:
13640                                    getDetTheme() === 'dark'
13641                                        ? 'rgba(168, 180, 200, 1)'
13642                                        : 'rgba(14, 165, 233, 1)',
13643                                hitRadius: 22,
13644                                borderWidth: 2.65,
13645                                borderJoinStyle: 'round',
13646                                borderCapStyle: 'round',
13647                            },
13648                        ],
13649                    },
13650                    options: {
13651                        responsive: true,
13652                        maintainAspectRatio: false,
13653                        resizeDelay: 16,
13654                        interaction: { mode: 'index', intersect: false, axis: 'x' },
13655                        hover: { mode: 'index', intersect: false },
13656                        animation: {
13657                            duration:
13658                                (typeof window.matchMedia === 'function' &&
13659                                    window.matchMedia('(max-width: 768px)').matches) ||
13660                                nPts > 400
13661                                    ? 0
13662                                    : 780,
13663                            easing: 'easeOutQuart',
13664                        },
13665                        elements: {
13666                            line: {
13667                                borderJoinStyle: 'round',
13668                                borderCapStyle: 'round',
13669                            },
13670                            point: {
13671                                hoverRadius: 7,
13672                            },
13673                        },
13674                        scales: {
13675                            x: {
13676                                type: 'time',
13677                                min: new Date(Date.now() - hrs * 3600000),
13678                                max: new Date(),
13679                                time: {
13680                                    tooltipFormat: 'dd/MM/yyyy HH:mm',
13681                                    displayFormats: {
13682                                        millisecond: 'HH:mm:ss.SSS',
13683                                        second: 'HH:mm:ss',
13684                                        minute: 'HH:mm',
13685                                        hour: 'HH:mm',
13686                                        day: 'dd/MM',
13687                                        week: 'dd/MM',
13688                                        month: 'MMM yyyy',
13689                                        quarter: 'MMM yyyy',
13690                                        year: 'yyyy',
13691                                    },
13692                                },
13693                                grid: {
13694                                    color: cpSingle.xGrid,
13695                                    lineWidth: 1,
13696                                    borderDash: [4, 6],
13697                                    drawTicks: true,
13698                                },
13699                                border: {
13700                                    display: true,
13701                                    color: cpSingle.axisLine,
13702                                    dash: [2, 2],
13703                                },
13704                                ticks: {
13705                                    color: cpSingle.tick,
13706                                    maxRotation: 0,
13707                                    autoSkipPadding: 10,
13708                                    maxTicksLimit: hrs <= 24 ? 12 : 9,
13709                                    font: { size: 11, family: "system-ui, 'Segoe UI', sans-serif" },
13710                                },
13711                            },
13712                            y: {
13713                                grace: '14%',
13714                                title: {
13715                                    display: true,
13716                                    text: 'Latência (ms)',
13717                                    color: cpSingle.title,
13718                                    font: { size: 11, weight: '600' },
13719                                },
13720                                grid: {
13721                                    color: cpSingle.yGrid,
13722                                    lineWidth: 1,
13723                                    borderDash: [3, 5],
13724                                },
13725                                border: {
13726                                    display: true,
13727                                    color: cpSingle.axisLine,
13728                                    dash: [2, 2],
13729                                },
13730                                ticks: {
13731                                    color: cpSingle.tick,
13732                                    font: { size: 11, family: "system-ui, 'Segoe UI', sans-serif" },
13733                                    callback: function (v) {
13734                                        var n = Number(v);
13735                                        return isNaN(n) ? v : Math.round(n);
13736                                    },
13737                                },
13738                            },
13739                        },
13740                        plugins: {
13741                            decimation: useDecimate
13742                                ? {
13743                                      enabled: true,
13744                                      algorithm: 'lttb',
13745                                      samples: Math.min(220, Math.max(80, Math.floor(nPts / 3))),
13746                                  }
13747                                : { enabled: false },
13748                            legend: {
13749                                display: true,
13750                                position: 'top',
13751                                align: 'start',
13752                                labels: {
13753                                    color: cpSingle.legend,
13754                                    boxWidth: 16,
13755                                    boxHeight: 6,
13756                                    padding: 14,
13757                                    font: { size: 11, weight: '600' },
13758                                },
13759                            },
13760                            tooltip: {
13761                                enabled: true,
13762                                backgroundColor: cpSingle.tooltipBg,
13763                                titleColor: cpSingle.tooltipTitle,
13764                                bodyColor: cpSingle.tooltipBody,
13765                                borderColor: cpSingle.tooltipBorder,
13766                                borderWidth: 1,
13767                                padding: 12,
13768                                cornerRadius: 12,
13769                                displayColors: true,
13770                                boxPadding: 6,
13771                                titleFont: { size: 12, weight: '600' },
13772                                bodyFont: { size: 13 },
13773                                callbacks: {
13774                                    label: chartTooltipLabelLine,
13775                                },
13776                            },
13777                            zoom: {
13778                                pan: {
13779                                    enabled: true,
13780                                    mode: 'x',
13781                                    modifierKey: 'ctrl',
13782                                    onPanComplete: function () {
13783                                        var btn = document.getElementById('svcChartResetZoom');
13784                                        if (btn) btn.hidden = false;
13785                                    },
13786                                },
13787                                limits: { x: { min: 'original', max: 'original' } },
13788                                zoom: {
13789                                    pinch: { enabled: true },
13790                                    drag: {
13791                                        enabled: true,
13792                                        borderColor: 'rgba(2,132,199,0.35)',
13793                                        borderWidth: 1,
13794                                        backgroundColor: 'rgba(2,132,199,0.08)',
13795                                    },
13796                                    mode: 'x',
13797                                    onZoomComplete: function () {
13798                                        var btn = document.getElementById('svcChartResetZoom');
13799                                        if (btn) btn.hidden = false;
13800                                    },
13801                                },
13802                            },
13803                        },
13804                    },
13805                });
13806                    setSvcLatencyChartLoading(false);
13807                } catch (chartErr) {
13808                    setSvcLatencyChartLoading(false);
13809                    if (typeof console !== 'undefined' && console.warn) {
13810                        console.warn('[detector] Chart', chartErr);
13811                    }
13812                    if (hint) {
13813                        hint.textContent =
13814                            'Não foi possível desenhar o gráfico. ' +
13815                            (chartErr && chartErr.message ? chartErr.message : 'Dados ou escala inválidos.');
13816                    }
13817                }
13818            })
13819            .catch(function (err) {
13820                setSvcLatencyChartLoading(false);
13821                if (typeof console !== 'undefined' && console.warn) {
13822                    console.warn('[detector] loadModalChart', err);
13823                }
13824                if (hint) {
13825                    if (err && err.message === 'chart_js') {
13826                        hint.textContent =
13827                            'Não foi possível carregar Chart.js (rede, firewall ou bloqueador a jsDelivr). Desative bloqueios ou tente outra rede.';
13828                    } else if (err && err._httpStatus === 429) {
13829                        hint.textContent =
13830                            'Servidor limitou pedidos (429). Aguarde um minuto e abra o gráfico de novo.';
13831                    } else if (err && err._httpStatus === 400) {
13832                        hint.textContent =
13833                            (err.message || 'Pedido inválido.') +
13834                            ' Verifique o período do gráfico (valores permitidos na lista).';
13835                    } else if (err && err._httpStatus === 404) {
13836                        hint.textContent = 'Serviço não encontrado no catálogo — atualize a página.';
13837                    } else if (err && err.message) {
13838                        hint.textContent = 'Gráfico indisponível: ' + err.message;
13839                    } else {
13840                        hint.textContent = 'Gráfico indisponível para este destino (rede ou servidor).';
13841                    }
13842                }
13843                clearSvcAvailabilityPlan();
13844            });
13845    }
13846
13847    function statuspageIndicatorClass(ind) {
13848        var k = String(ind || '')
13849            .trim()
13850            .toLowerCase()
13851            .replace(/[^a-z0-9]+/g, '');
13852        if (k === 'none' || k === '') return 'svc-sp-ind--none';
13853        if (k === 'minor') return 'svc-sp-ind--minor';
13854        if (k === 'major') return 'svc-sp-ind--major';
13855        if (k === 'critical') return 'svc-sp-ind--critical';
13856        if (k.indexOf('maintenance') >= 0) return 'svc-sp-ind--maintenance';
13857        return 'svc-sp-ind--unk';
13858    }
13859
13860    function renderDetailEnrichment(detailData) {
13861        var strip = document.getElementById('svcDetailEnrich');
13862        if (!strip) return;
13863        if (!detailData || !detailData.success) {
13864            strip.innerHTML = '';
13865            strip.style.display = 'none';
13866            return;
13867        }
13868        function clipNote(t, max) {
13869            var s = (t || '').trim();
13870            if (s.length <= max) return s;
13871            return s.slice(0, max - 1) + '\u2026';
13872        }
13873        var parts = [];
13874        var pitch = clipNote(detailData.monitoring_positioning_pt, 140);
13875        if (pitch) {
13876            parts.push('<p class="svc-enrich-pitch">' + esc(pitch) + '</p>');
13877        }
13878        var sp = detailData.statuspage_public;
13879        if (sp && sp.ok) {
13880            var indCls = statuspageIndicatorClass(sp.indicator);
13881            var desc = clipNote(sp.status_description || '—', 12
138810);
13882            var pu = (sp.page_url || '').trim();
13883            var link =
13884                pu.indexOf('http') === 0
13885                    ? '<a href="' +
13886                      esc(pu) +
13887                      '" target="_blank" rel="noopener noreferrer">' +
13888                      esc(__('js_enrich_open_status')) +
13889                      '</a>'
13890                    : '';
13891            var comp = '';
13892            if (sp.components_total > 0) {
13893                comp =
13894                    '<div class="svc-sp-comp">' +
13895                    esc(
13896                        __('js_enrich_components_tpl')
13897                            .replace(/\{n\}/g, String(sp.components_non_operational))
13898                            .replace(/\{t\}/g, String(sp.components_total)),
13899                    ) +
13900                    '</div>';
13901            }
13902            var upd = (sp.updated_at || '').trim();
13903            var meta = (link ? link : '') + (upd ? (link ? ' · ' : '') + esc(upd) : '');
13904            parts.push(
13905                '<div class="svc-sp-card ' +
13906                    indCls +
13907                    '"><h5>' +
13908                    esc(__('js_enrich_statuspage_title')) +
13909                    '</h5><div class="svc-sp-status">' +
13910                    esc(desc) +
13911                    '</div>' +
13912                    (meta ? '<div class="svc-sp-meta">' + meta + '</div>' : '') +
13913                    comp +
13914                    '</div>',
13915            );
13916        }
13917        var foot = clipNote(detailData.detector_enrichment_note_pt, 110);
13918        if (foot) {
13919            parts.push('<p class="svc-enrich-foot">' + esc(foot) + '</p>');
13920        }
13921        strip.innerHTML = parts.join('');
13922        strip.style.display = parts.length ? 'block' : 'none';
13923    }
13924
13925    function renderLatencyHub(detailData) {
13926        var el = document.getElementById('svcLatencyHub');
13927        if (!el) return;
13928        if (!detailData || !detailData.success) {
13929            el.innerHTML = '';
13930            el.style.display = 'none';
13931            return;
13932        }
13933        var sources = detailData.latency_sources || [];
13934        var best = detailData.best_latency_ms;
13935        var note = detailData.hub_note_pt || '';
13936        var apiNote = (detailData.note || '').trim();
13937        var lq = detailData.latency_quality || {};
13938        var lc = detailData.latency_class || {};
13939        var tierKey = lc.key ? String(lc.key) : lq.key ? String(lq.key) : 'unknown';
13940        if (tierKey === 'great') tierKey = 'excellent';
13941        if (tierKey === 'fair') tierKey = 'medium';
13942        if (tierKey === 'slow') tierKey = 'critical';
13943        var mergedBest = null;
13944        for (var bi = 0; bi < sources.length; bi++) {
13945            var bm = sources[bi].ms;
13946            if (bm != null && !isNaN(Number(bm))) {
13947                var bn = Number(bm);
13948                mergedBest = mergedBest == null ? bn : Math.min(mergedBest, bn);
13949            }
13950        }
13951        if (mergedBest == null && best != null && !isNaN(Number(best))) mergedBest = Number(best);
13952        var msRounded = mergedBest != null ? Math.round(mergedBest) : null;
13953        var tierLabel = lc.short_pt ? String(lc.short_pt) : lq.short_pt ? String(lq.short_pt) : '—';
13954        function hubClip(t, max) {
13955            var s = (t || '').trim();
13956            if (s.length <= max) return s;
13957            return s.slice(0, max - 1) + '\u2026';
13958        }
13959        var html =
13960            '<div class="latency-hub-card latency-hub-card--compact"><h4 class="latency-hub-title">' +
13961            esc(__('js_hub_card_title')) +
13962            '</h4>';
13963        html += '<div class="latency-hub-hero lh-tier-' + esc(tierKey) + '">';
13964        html += '<div class="lh-hero-icon" aria-hidden="true">📡</div><div class="lh-hero-body">';
13965        html += '<div class="lh-hero-kicker">' + esc(__('js_hub_hero_kicker')) + '</div>';
13966        if (msRounded != null) {
13967            html +=
13968                '<div class="lh-ms-pill"><span class="lh-ms-num">' +
13969                esc(String(msRounded)) +
13970                '</span> <span class="lh-ms-unit">ms</span></div>';
13971            html +=
13972                '<div class="lh-hero-tier">' +
13973                esc(__('js_hub_tier_lbl')) +
13974                ': <span>' +
13975                esc(tierLabel) +
13976                '</span></div>';
13977            if (lc.color_hex) {
13978                html +=
13979                    '<span class="lh-class-badge" style="border-color:' +
13980                    esc(String(lc.color_hex)) +
13981                    ';color:' +
13982                    esc(String(lc.color_hex)) +
13983                    '">' +
13984                    esc(tierLabel) +
13985                    '</span>';
13986            }
13987        } else {
13988            html += '<div class="lh-ms-pill">' + esc(__('js_no_recent')) + '</div>';
13989            html += '<div class="lh-hero-tier">' + esc(__('js_hub_empty_hint')) + '</div>';
13990        }
13991        html += '</div></div>';
13992        var met = detailData.latency_metrics || {};
13993        var hasMet =
13994            met.current_ms != null ||
13995            met.avg_ms != null ||
13996            met.max_ms != null ||
13997            met.jitter_ms != null ||
13998            met.packet_loss_pct != null ||
13999            msRounded != null;
14000        if (hasMet) {
14001            function metricCell(label, val, unit) {
14002                if (val == null || val === '') return '';
14003                var u = unit != null ? unit : '';
14004                return (
14005                    '<div class="lh-metric-cell"><b>' +
14006                    esc(label) +
14007                    '</b><span class="lh-metric-val">' +
14008                    esc(String(val)) +
14009                    esc(u) +
14010                    '</span></div>'
14011                );
14012            }
14013            html +=
14014                '<div class="lh-metrics-grid" aria-label="' + esc(__('js_hub_metrics_aria')) + '">';
14015            html += metricCell(
14016                __('js_hub_metric_current'),
14017                met.current_ms != null ? Math.round(Number(met.current_ms)) : msRounded,
14018                ' ms',
14019            );
14020            html += metricCell(
14021                __('js_hub_metric_avg'),
14022                met.avg_ms != null ? Number(met.avg_ms).toFixed(1) : null,
14023                ' ms',
14024            );
14025            html += metricCell(
14026                __('js_hub_metric_max'),
14027                met.max_ms != null ? Number(met.max_ms).toFixed(1) : null,
14028                ' ms',
14029            );
14030            html += metricCell(
14031                __('js_hub_metric_jitter'),
14032                met.jitter_ms != null ? Number(met.jitter_ms).toFixed(1) : null,
14033                ' ms',
14034            );
14035            html += metricCell(
14036                __('js_hub_metric_loss'),
14037                met.packet_loss_pct != null ? Number(met.packet_loss_pct).toFixed(1) : null,
14038                ' %',
14039            );
14040            html += '</div>';
14041        }
14042        html += '<ul class="latency-hub-list">';
14043        for (var i = 0; i < sources.length; i++) {
14044            var s = sources[i];
14045            var ic = s.ms != null ? '🛰' : '🔗';
14046            if (s.ms != null) {
14047                html +=
14048                    '<li class="lh-src"><span class="lh-src-ic" aria-hidden="true">' +
14049                    ic +
14050                    '</span><span><span class="lh-label">' +
14051                    esc(s.label_pt) +
14052                    '</span> <strong class="lh-ms">' +
14053                    esc(String(s.ms)) +
14054                    ' ms</strong></span></li>';
14055            } else if (s.note_pt) {
14056                var npt = hubClip(s.note_pt, 96);
14057                html +=
14058                    '<li class="lh-src lh-note"><span class="lh-src-ic" aria-hidden="true">' +
14059                    ic +
14060                    '</span><span>' +
14061                    (s.label_pt
14062                        ? '<span class="lh-label" style="display:block;margin-bottom:4px;">' +
14063                          esc(s.label_pt) +
14064                          '</span>'
14065                        : '') +
14066                    esc(npt) +
14067                    '</span></li>';
14068            }
14069        }
14070        html += '</ul>';
14071        if (mergedBest != null) {
14072            html +=
14073                '<p class="lh-best">' +
14074                esc(__('js_hub_best_tpl').replace(/\{ms\}/g, String(Math.round(mergedBest)))) +
14075                '</p>';
14076        }
14077        var noteC = note ? hubClip(note, 100) : '';
14078        var apiC = apiNote ? hubClip(apiNote, 100) : '';
14079        if (noteC) html += '<p class="lh-foot">' + esc(noteC) + '</p>';
14080        if (apiC) html += '<p class="lh-foot">' + esc(apiC) + '</p>';
14081        html += '</div>';
14082        el.innerHTML = html;
14083        el.style.display = 'block';
14084    }
14085
14086    function openModal(svcId) {
14087        var svc = findSvc(svcId);
14088        if (!svc) return;
14089        state.modalSvcId = svcId;
14090        state.communityFeedReplyParentId = null;
14091        state.communityFeedVerifyToken = null;
14092        var modal = document.getElementById('svcModal');
14093        var title = document.getElementById('svcModalName');
14094        var desc = document.getElementById('svcModalDesc');
14095        var links = document.getElementById('svcModalLinks');
14096        var logoEl = document.getElementById('svcModalLogo');
14097        var sel = document.getElementById('svcChartTarget');
14098        var wrap = document.getElementById('svcTargetsTableWrap');
14099        var tb = document.getElementById('svcTargetsTbody');
14100        var hubEl = document.getElementById('svcLatencyHub');
14101        if (!modal || !title || !desc || !links) return;
14102        renderSvcTcpPortsBlock(svc.tcp_ports, state.probeProfile);
14103        renderProbeLayersBlock(svc, null);
14104        title.textContent = svc.name || svcId;
14105        updateSvcModalHeaderScores(svc);
14106        desc.textContent = enrichDescription(svc);
14107        var enrichEl = document.getElementById('svcDetailEnrich');
14108        if (enrichEl) {
14109            enrichEl.innerHTML = '';
14110            enrichEl.style.display = 'none';
14111        }
14112        if (hubEl) {
14113            hubEl.style.display = 'block';
14114            hubEl.innerHTML =
14115                '<p class="latency-hub-loading" style="color:var(--muted);font-size:0.82rem;">' +
14116                esc(__('js_hub_loading')) +
14117                '</p>';
14118        }
14119        if (logoEl) {
14120            logoEl.onerror = null;
14121            logoEl.removeAttribute('data-fb');
14122            logoEl.removeAttribute('data-lx');
14123            logoEl.removeAttribute('data-lxf');
14124            logoEl.removeAttribute('data-logo-chain');
14125            logoEl.removeAttribute('data-logo-i');
14126            var domM = (svc.logo_domain || '').trim();
14127            logoEl.setAttribute('data-logo-domain', domM);
14128            var chainM = Array.isArray(svc.logo_icons) && svc.logo_icons.length ? svc.logo_icons : null;
14129            if (chainM) {
14130                logoEl.classList.add('det-logo-fallback');
14131                logoEl.setAttribute('data-logo-chain', encodeURIComponent(JSON.stringify(chainM)));
14132                logoEl.setAttribute('data-logo-i', '0');
14133                logoEl.src = chainM[0];
14134                attachLogoChainOnError(logoEl);
14135            } else {
14136                logoEl.classList.remove('det-logo-fallback');
14137                logoEl.src = svc.logo_url || faviconForDomain(domM);
14138                logoEl.onerror = function () {
14139                    var d = (svc.logo_domain || '').trim();
14140                    var step = parseInt(logoEl.getAttribute('data-lxf'), 10) || 0;
14141                    if (step === 0 && d) {
14142                        logoEl.setAttribute('data-lxf', '1');
14143                        logoEl.src = 'https://unavatar.io/' + encodeURIComponent(d);
14144                        return;
14145                    }
14146                    if (step === 1 && d) {
14147                        logoEl.setAttribute('data-lxf', '2');
14148                        logoEl.src = faviconDdg(d);
14149                        return;
14150                    }
14151                    logoEl.onerror = null;
14152                };
14153            }
14154        }
14155        function modalLinkChip(icon, labelKey, url) {
14156            var u = (url || '').trim();
14157            if (!u || u === '#') return '';
14158            var hosty = u.replace(/^https?:\/\//i, '');
14159            if (hosty.length > 52) hosty = hosty.slice(0, 49) + '\u2026';
14160            return (
14161                '<a class="modal-link-chip" href="' +
14162                esc(u) +
14163                '" target="_blank" rel="noopener noreferrer">' +
14164                '<span class="mlc-ico" aria-hidden="true">' +
14165                icon +
14166                '</span><span><span class="mlc-lbl">' +
14167                esc(__(labelKey)) +
14168                '</span><span class="mlc-url">' +
14169                esc(hosty) +
14170                '</span></span></a>'
14171            );
14172        }
14173        links.innerHTML =
14174            '<div class="modal-links-compact">' +
14175            modalLinkChip('🌐', 'js_modal_link_site', svc.official_url) +
14176            modalLinkChip('📋', 'js_modal_link_status', svc.status_url) +
14177            modalLinkChip('✅', 'js_modal_link_validate', svc.validation_url || svc.probe_url) +
14178            '</div>';
14179        var reportBtn = document.getElementById('svcModalReportBtn');
14180        if (reportBtn) {
14181            reportBtn.onclick = function (e) {
14182                e.preventDefault();
14183                openCommunityReportModal(svcId);
14184            };
14185        }
14186
14187        modal.classList.add('open');
14188        destroyChart();
14189        var hint0 = document.getElementById('svcChartProbeHint');
14190        if (hint0) hint0.textContent = '';
14191        var rangeSel = document.getElementById('svcChartRange');
14192        if (rangeSel) {
14193            rangeSel.value = String(state.chartRangeHours || 168);
14194            rangeSel.onchange = function () {
14195                var h = parseInt(rangeSel.value, 10);
14196                state.chartRangeHours = !isNaN(h) && h > 0 ? h : 168;
14197                reloadModalChartFromControls();
14198            };
14199        }
14200        var multiCb = document.getElementById('svcChartMulti');
14201        if (multiCb) {
14202            multiCb.checked = true;
14203            multiCb.onchange = function () {
14204                reloadModalChartFromControls();
14205            };
14206        }
14207        var rzBtn = document.getElementById('svcChartResetZoom');
14208        if (rzBtn) {
14209            rzBtn.hidden = true;
14210            rzBtn.onclick = function () {
14211                if (state.chart && typeof state.chart.resetZoom === 'function') {
14212                    state.chart.resetZoom();
14213                }
14214                rzBtn.hidden = true;
14215            };
14216        }
14217        setSvcChartMultiUi(true);
14218        if (sel) {
14219            sel.innerHTML = '';
14220            sel.onchange = null;
14221        }
14222        if (tb) tb.innerHTML = '';
14223        if (wrap) wrap.style.display = 'none';
14224
14225        loadModalChartAllTargets(svcId);
14226
14227        var origin = window.location.origin || '';
14228        var detailUrl =
14229            origin +
14230            '/api/detector/service/' +
14231            encodeURIComponent(svcId) +
14232            '/detail?anon_token=' +
14233            encodeURIComponent(detectorCommunityAnonToken());
14234        fetch(detailUrl, { credentials: 'omit' })
14235            .then(function (r) {
14236                return r.json().then(function (data) {
14237                    if (!r.ok) throw new Error(data.detail || 'HTTP ' + r.status);
14238                    return data;
14239                });
14240            })
14241            .then(function (data) {
14242                if (data && data.service) {
14243                    var apiDesc = (data.service.description || '').trim();
14244                    var cur = (svc.description || '').trim();
14245                    if (apiDesc.length >= cur.length && apiDesc.length > 0) {
14246                        desc.textContent = enrichDescription(
14247                            Object.assign({}, svc, { description: data.service.description }),
14248                        );
14249                    }
14250                }
14251                renderDetailEnrichment(data);
14252                renderLatencyHub(data);
14253                renderPublicFeedbackBlock(data, svcId);
14254                renderSvcTcpPortsBlock(data.tcp_ports, data.probe_profile || state.probeProfile);
14255                renderProbeLayersBlock(svc, data);
14256                var targets = (data && data.targets) || [];
14257                if (wrap) wrap.style.display = targets.length ? 'block' : 'none';
14258                if (tb && targets.length) {
14259                    var rows = '';
14260                    for (var j = 0; j < targets.length; j++) {
14261                        var t = targets[j];
14262                        var lat =
14263                            t.last_latency_ms != null ? esc(String(Math.round(t.last_latency_ms))) + ' ms' : '—';
14264                        var st = t.last_ok === false ? ' (falhou)' : '';
14265                        var ipsHtml = '—';
14266                        var det = t.resolved_ips_detail;
14267                        if (det && det.length) {
14268                            ipsHtml = det
14269                                .map(function (d) {
14270                                    var ip = d.ip || '';
14271                                    var fam = d.family;
14272                                    var cls =
14273                                        fam === 6
14274                                            ? 'ip-tag ip-tag--v6 ip-tag--dns'
14275                                            : 'ip-tag ip-tag--v4 ip-tag--dns';
14276                                    return (
14277                                        '<span class="' +
14278                                        cls +
14279                                        '" title="' +
14280                                        esc(fam === 6 ? 'IPv6 (AAAA)' : 'IPv4 (A)') +
14281                                        '">' +
14282                                        esc(ip) +
14283                                        '</span>'
14284                                    );
14285                                })
14286                                .join('');
14287                        } else if ((t.resolved_ips || []).length) {
14288                            ipsHtml = (t.resolved_ips || [])
14289                                .map(function (ip) {
14290                                    var is6 = String(ip).indexOf(':') >= 0;
14291                                    var cls = is6
14292                                        ? 'ip-tag ip-tag--v6 ip-tag--dns'
14293                                        : 'ip-tag ip-tag--v4 ip-tag--dns';
14294                                    return (
14295                                        '<span class="' +
14296                                        cls +
14297                                        '">' +
14298                                        esc(String(ip)) +
14299                                        '</span>'
14300                                    );
14301                                })
14302                                .join('');
14303                        }
14304                        rows +=
14305                            '<tr><td><strong>' +
14306                            esc(t.label || t.key) +
14307                            '</strong><br><small style="color:var(--muted);word-break:break-all;">' +
14308                            esc(t.url || '') +
14309                            '</small></td><td>' +
14310                            lat +
14311                            esc(st) +
14312                            '</td><td class="ip-dns-cell"><small>' +
14313                            ipsHtml +
14314                            '</small></td></tr>';
14315                    }
14316                    tb.innerHTML = rows;
14317                }
14318                if (sel && targets.length) {
14319                    for (var k = 0; k < targets.length; k++) {
14320                        var t2 = targets[k];
14321                        var opt = document.createElement('option');
14322                        opt.value = t2.key || 'primary';
14323                        opt.textContent = t2.label || t2.key;
14324                        sel.appendChild(opt);
14325                    }
14326                    sel.onchange = function () {
14327                        reloadModalChartFromControls();
14328                    };
14329                    if (!isSvcChartMultiMode()) {
14330                        reloadModalChartFromControls();
14331                    }
14332                } else if (!isSvcChartMultiMode()) {
14333                    reloadModalChartFromControls();
14334                }
14335            })
14336            .catch(function () {
14337                renderDetailEnrichment(null);
14338                renderLatencyHub(null);
14339                renderPublicFeedbackBlock(null, svcId);
14340                renderSvcTcpPortsBlock(svc.tcp_ports, state.probeProfile);
14341                renderProbeLayersBlock(svc, null);
14342                if (wrap) wrap.style.display = 'none';
14343                if (!isSvcChartMultiMode()) {
14344                    reloadModalChartFromControls();
14345                }
14346            });
14347    }
14348
14349    function closeModal() {
14350        var pfb = document.getElementById('svcPublicFeedback');
14351        if (pfb) {
14352            pfb.style.display = 'none';
14353            pfb.innerHTML = '';
14354        }
14355        state.modalDetailData = null;
14356        state.communityFeedReplyParentId = null;
14357        state.communityFeedVerifyToken = null;
14358        clearSvcModalHeaderScores();
14359        clearSvcAvailabilityPlan();
14360        var modal = document.getElementById('svcModal');
14361        if (modal) modal.classList.remove('open');
14362        destroyChart();
14363        setSvcLatencyChartLoading(false);
14364        state.modalSvcId = null;
14365        var sel = document.getElementById('svcChartTarget');
14366        if (sel) sel.onchange = null;
14367        var rangeSel = document.getElementById('svcChartRange');
14368        if (rangeSel) rangeSel.onchange = null;
14369        var multiCb = document.getElementById('svcChartMulti');
14370        if (multiCb) {
14371            multiCb.onchange = null;
14372            multiCb.checked = false;
14373        }
14374        setSvcChartMultiUi(false);
14375        var tb = document.getElementById('svcTargetsTbody');
14376        if (tb) tb.innerHTML = '';
14377        var wrap = document.getElementById('svcTargetsTableWrap');
14378        if (wrap) wrap.style.display = 'none';
14379        var hint = document.getElementById('svcChartProbeHint');
14380        if (hint) hint.textContent = '';
14381        var hub = document.getElementById('svcLatencyHub');
14382        if (hub) {
14383            hub.innerHTML = '';
14384            hub.style.display = 'none';
14385        }
14386        var enrich = document.getElementById('svcDetailEnrich');
14387        if (enrich) {
14388            enrich.innerHTML = '';
14389            enrich.style.display = 'none';
14390        }
14391        var tcpEl = document.getElementById('svcTcpPortsHost');
14392        if (tcpEl) {
14393            tcpEl.innerHTML = '';
14394            tcpEl.style.display = 'none';
14395        }
14396        var plEl = document.getElementById('svcProbeLayersHost');
14397        if (plEl) {
14398            plEl.innerHTML = '';
14399            plEl.style.display = 'none';
14400        }
14401    }
14402
14403    function readDetectorCatalogSwrCache() {
14404        try {
14405            var raw = localStorage.getItem(DETECTOR_CATALOG_SWR_KEY);
14406            if (!raw) {
14407                raw = sessionStorage.getItem(DETECTOR_CATALOG_SWR_KEY);
14408            }
14409            if (!raw) return null;
14410            var o = JSON.parse(raw);
14411            if (!o || typeof o.t !== 'number' || !o.payload || o.payload.success !== true) return null;
14412            if (Date.now() - o.t > CATALOG_SWR_MAX_DISPLAY_AGE_MS) return null;
14413            return o;
14414        } catch (e) {
14415            return null;
14416        }
14417    }
14418
14419    function writeDetectorCatalogSwrCache(payload) {
14420        if (!payload || payload.success !== true) return;
14421        var blob = JSON.stringify({ t: Date.now(), payload: payload });
14422        try {
14423            localStorage.setItem(DETECTOR_CATALOG_SWR_KEY, blob);
14424        } catch (e) {
14425            try {
14426                sessionStorage.setItem(DETECTOR_CATALOG_SWR_KEY, blob);
14427            } catch (e2) { /* quota */ }
14428        }
14429    }
14430
14431    function setCatalogSyncing(on) {
14432        var el = document.getElementById('detCatalogSyncHint');
14433        if (!el) return;
14434        if (on) {
14435            el.hidden = false;
14436            el.textContent = __('catalog_sync_hint');
14437        } else {
14438            el.hidden = true;
14439            el.textContent = '';
14440        }
14441    }
14442
14443    function setLoading(on) {
14444        var el = document.getElementById('detectorLoading');
14445        if (!el) return;
14446        if (on) el.classList.add('is-visible');
14447        else {
14448            el.classList.remove('is-visible');
14449            var sk = document.getElementById('detSkeletonPlaceholder');
14450            if (sk) sk.remove();
14451        }
14452    }
14453
14454    /**
14455     * Aplica resposta de /api/detector/catalog ao estado e à UI.
14456     * @param {Object} ctx - { err, finishLoad, btn }
14457     */
14458    function applyDetectorCatalogPayload(cat, ctx) {
14459        var err = ctx.err;
14460        var finishLoad = ctx.finishLoad;
14461        var btn = ctx.btn;
14462        if (!cat.success) throw new Error(cat.error || __('js_err_detector_load'));
14463        applyDetectorAdSlotsFromCatalog(cat);
14464        state.catalog = cat.services || [];
14465        state.communityWindowDays =
14466            cat.community_vote_window_days != null ? Number(cat.community_vote_window_days) : 7;
14467        if (isNaN(state.communityWindowDays)) state.communityWindowDays = 7;
14468        state.complaintsRankingDays = readComplaintsRankingDays();
14469        var dCat = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
14470        state.complaintsRankingDays = dCat;
14471        updateHeroTagline();
14472        state.communityDisclaimerPt = cat.community_disclaimer_pt || '';
14473        state.probeIntervalSec =
14474            cat.probe_interval_sec != null ? Number(cat.probe_interval_sec) : 60;
14475        if (isNaN(state.probeIntervalSec)) state.probeIntervalSec = 60;
14476        state.probeProfile = cat.probe_profile && typeof cat.probe_profile === 'object' ? cat.probe_profile : {};
14477        state.latencyClassConfig =
14478            cat.latency_class_config && typeof cat.latency_class_config === 'object'
14479                ? cat.latency_class_config
14480                : {};
14481        if (
14482            typeof DetectorLatencyClassify !== 'undefined' &&
14483            DetectorLatencyClassify.setThresholdsFromApi
14484        ) {
14485            DetectorLatencyClassify.setThresholdsFromApi(state.latencyClassConfig);
14486        }
14487        var _ric = typeof requestIdleCallback === 'function'
14488            ? requestIdleCallback
14489            : function (cb) { setTimeout(cb, 40); };
14490        try {
14491            rebuildCategorySelect();
14492            initRankSelect();
14493            wireCategorySelect();
14494            wireIpStackSelect();
14495            var ipstackSel = document.getElementById('selIpStack');
14496            if (ipstackSel && ['all', 'v4', 'dual', 'v6'].indexOf(state.ipStackFilter || '') >= 0) {
14497                ipstackSel.value = state.ipStackFilter;
14498            }
14499            var sr = document.getElementById('selRank');
14500            if (sr) sr.value = state.rankSort;
14501            updateProbePopBadge(cat.probe_profile || {});
14502            mountCommunityRanking();
14503            if (!markLazySectionDirty('top10Boards')) mountTop10Boards();
14504            mountTop10Cards();
14505            try {
14506                scheduleDetectorPromotionalUiAfterTop10();
14507            } catch (ePromo) {
14508                /* ignore */
14509            }
14510            updateCatalogFilterBanner();
14511            applyFilterSections();
14512
14513            /* Fase 1 — leve: UI que não envolve a grid do catálogo */
14514            _ric(function () {
14515                try {
14516                    renderObservationHub(cat.probe_profile || {});
14517                    refreshComplaintsTrendSparklines(document);
14518                    refreshDetectorSummaryDependentUi();
14519                    syncDetectorMobileFlowLayout();
14520                    updateCatalogViewToggleBtn();
14521                    renderTop30List();
14522                } catch (eP1) {
14523                    if (typeof console !== 'undefined' && console.error) console.error('[detector] deferred-p1', eP1);
14524                }
14525            });
14526            /* Fase 2 — pesada: grid do catálogo (shelves) — só quando perto do viewport */
14527            setTimeout(function () {
14528                try {
14529                    markLazySectionDirty('shelvesHost');
14530                    applyViewToShelves();
14531                    mountIcmpNoReplySection();
14532                    if (!ddRowHintBound) {
14533                        ddRowHintBound = true;
14534                        bindShelfScrollHint(document.getElementById('ddRowWrap'));
14535                    }
14536                    refreshShelfHints();
14537                    var rpm = document.getElementById('detReportPickerModal');
14538                    if (rpm && rpm.classList.contains('open')) {
14539                        var si = document.getElementById('detReportPickerSearch');
14540                        renderReportPickerList(si ? si.value : '');
14541                    }
14542                    if (finishLoad) {
14543                        setTimeout(function () {
14544                            try { applyDeepLinkFromHash(); } catch (eH) {}
14545                        }, 200);
14546                    }
14547                } catch (eP2) {
14548                    if (typeof console !== 'undefined' && console.error) console.error('[detector] deferred-p2', eP2);
14549                }
14550            }, 120);
14551        } catch (uiErr) {
14552            if (typeof console !== 'undefined' && console.error) {
14553                console.error('[detector] erro ao montar UI após catálogo', uiErr);
14554            }
14555            try {
14556                mountShelves();
14557                syncDetectorMobileFlowLayout();
14558                updateCatalogViewToggleBtn();
14559                renderTop30List();
14560            } catch (e2) { /* ignore */ }
14561            try {
14562                mountDd();
14563            } catch (e3) { /* ignore */ }
14564            try {
14565                scheduleDetectorPromotionalUiAfterTop10();
14566            } catch (ePromo2) {
14567                /* ignore */
14568            }
14569            if (err) {
14570                err.style.display = 'block';
14571                err.textContent =
14572                    __('js_ui_partial_err') +
14573                    (uiErr && uiErr.message ? ' (' + uiErr.message + ')' : '');
14574            }
14575        }
14576        if (finishLoad) {
14577            state.detectorLoadInProgress = false;
14578            scheduleNextRefreshDeadline();
14579            updateRefreshCountdownUi();
14580            if (btn) btn.disabled = false;
14581            setLoading(false);
14582            setCatalogSyncing(false);
14583            _ric(function () {
14584                if (typeof Chart === 'undefined') {
14585                    ensureChartJsLoaded().catch(function () {});
14586                }
14587            });
14588        }
14589    }
14590
14591    /** Atualiza blocos que dependem de /api/detector/summary (SaaS, jogos, tendências DD). */
14592    function refreshDetectorSummaryDependentUi() {
14593        if (!state.catalog || !state.catalog.length) return;
14594        mountProblemsCatalog();
14595        mountRankPanels();
14596        updateMetricsBar();
14597        mountDd();
14598    }
14599
14600    function normalizeDetectorSummaryPayload(data) {
14601        if (data && data.success !== false) return data;
14602        return {
14603            success: false,
14604            saas_problems: [],
14605            saas_total_issues: 0,
14606            games: [],
14607            games_with_issue: 0,
14608            games_total: 0,
14609            trending_issues: [],
14610        };
14611    }
14612
14613    function applyDetectorAuxiliaryClientData(pingPack, reasons, gcfg) {
14614        state.reportReasons = (reasons && reasons.success && reasons.reasons) || [];
14615        state.googleClientId = (gcfg && gcfg.client_id) || '';
14616        renderCatellixProductsPing(pingPack && pingPack.ok ? pingPack.data : null);
14617        var rpm = document.getElementById('detReportPickerModal');
14618        if (rpm && rpm.classList.contains('open')) {
14619            var si = document.getElementById('detReportPickerSearch');
14620            renderReportPickerList(si ? si.value : '');
14621        }
14622    }
14623
14624    function loadAll() {
14625        var btn = document.getElementById('btnRefresh');
14626        var err = document.getElementById('errBox');
14627        state.detectorLoadInProgress = true;
14628        detectorLoadAllGen++;
14629        var loadGen = detectorLoadAllGen;
14630        try {
14631            fetchDetectorLogoManifest();
14632        } catch (eLm) {
14633            /* ignore */
14634        }
14635        updateRefreshCountdownUi();
14636        if (btn) btn.disabled = true;
14637        state.complaintsRankingDays = readComplaintsRankingDays();
14638        var dParallel = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
14639        state.complaintsRankingDays = dParallel;
14640        updateHeroTagline();
14641        if (hydrateCommunityRankingFromSwrCache(dParallel)) {
14642            wireSidebarComplaintsRanking();
14643            refreshSidebarComplaintsTop10();
14644            try {
14645                syncComplaintsTop10BoardsAndCards();
14646            } catch (eHy) {}
14647        }
14648        var hadSwrPrime = false;
14649        var swrCached = readDetectorCatalogSwrCache();
14650        if (swrCached && swrCached.payload) {
14651            try {
14652                applyDetectorCatalogPayload(swrCached.payload, {
14653                    err: err,
14654                    finishLoad: false,
14655                    btn: btn,
14656                });
14657                if (state.communityRankingRows && state.communityRankingRows.length) {
14658                    applyCommunityRankingToCatalog(state.communityRankingRows);
14659                    renderCommunityRankingOl();
14660                    syncComplaintsTop10BoardsAndCards();
14661                }
14662                setLoading(false);
14663                setCatalogSyncing(true);
14664                hadSwrPrime = true;
14665                setTimeout(function () {
14666                    try { refreshComplaintsTrendSparklines(document); } catch (e) {}
14667                    try { renderTop30List(); } catch (e) {}
14668                }, 200);
14669            } catch (ePrime) {
14670                hadSwrPrime = false;
14671            }
14672        }
14673        if (!hadSwrPrime) {
14674            setLoading(true);
14675        }
14676        if (err) {
14677            err.style.display = 'none';
14678            err.textContent = '';
14679        }
14680        var origin = window.location.origin || '';
14681        fetch(origin + '/api/detector/community-ranking?' + communityRankingApiQuery(dParallel), {
14682            credentials: 'omit',
14683        })
14684            .then(function (r) {
14685                return r
14686                    .json()
14687                    .then(function (data) {
14688                        return { ok: r.ok, data: data };
14689                    })
14690                    .catch(function () {
14691                        return { ok: false, data: {} };
14692                    });
14693            })
14694            .then(function (pack) {
14695                if (loadGen !== detectorLoadAllGen) return;
14696                var data = pack.data || {};
14697                if (!pack.ok || !data.success) return;
14698                var dNow = VALID_RANKING_DAYS.indexOf(state.complaintsRankingDays) !== -1 ? state.complaintsRankingDays : 1;
14699                if (dNow !== dParallel) return;
14700                writeCommunityRankingSwrCache(data);
14701                state.communityRankingRows = data.ranking || [];
14702                state._fetchedCommunityRankingDays = dParallel;
14703                state._fetchedCommunityRankingLimit = COMMUNITY_RANKING_API_LIMIT;
14704                scheduleCommunityRankingMidnightRefresh(data.next_reset_utc);
14705                if (data.disclaimer_pt) {
14706                    state.communityDisclaimerPt = data.disclaimer_pt;
14707                    var discP = document.getElementById('rankCommunityDisclaimer');
14708                    if (discP) discP.textContent = data.disclaimer_pt;
14709                }
14710                refreshSidebarComplaintsTop10();
14711                if (state.catalog && state.catalog.length) {
14712                    applyCommunityRankingToCatalog(state.communityRankingRows);
14713                    renderCommunityRankingOl();
14714                    syncComplaintsTop10BoardsAndCards();
14715                    try {
14716                        refreshComplaintsTrendSparklines(document);
14717                    } catch (eSk) {
14718                        /* ignore */
14719                    }
14720                }
14721                try {
14722                    renderTop30List();
14723                } catch (eT30) {}
14724            })
14725            .catch(function () {});
14726
14727        var p1 = fetch(origin + '/api/detector/catalog', { credentials: 'omit' }).then(function (r) {
14728            return r.json().then(function (data) {
14729                if (!r.ok) throw new Error((data && data.detail) || 'HTTP ' + r.status);
14730                return data;
14731            });
14732        });
14733
14734        var p2 = fetch(origin + '/api/detector/summary', { credentials: 'omit' })
14735            .then(function (r) {
14736                return r.json().then(function (data) {
14737                    if (!r.ok) {
14738                        return normalizeDetectorSummaryPayload({
14739                            success: false,
14740                            detail: (data && data.detail) || 'HTTP ' + r.status,
14741                        });
14742                    }
14743                    return normalizeDetectorSummaryPayload(data);
14744                });
14745            })
14746            .catch(function () {
14747                return normalizeDetectorSummaryPayload(null);
14748            });
14749
14750        p2.then(function (sum) {
14751            state.summary = sum;
14752            refreshDetectorSummaryDependentUi();
14753        });
14754
14755        function loadDeferredApis() {
14756            var p3 = fetch(origin + '/api/public/catellix-products-ping', { credentials: 'omit' })
14757                .then(function (r) {
14758                    return r.json().then(function (data) {
14759                        return { ok: r.ok, data: data };
14760                    });
14761                })
14762                .catch(function () {
14763                    return { ok: false, data: null };
14764                });
14765            var p4 = fetch(origin + '/api/detector/report-reasons', { credentials: 'omit' })
14766                .then(function (r) {
14767                    return r.json().catch(function () {
14768                        return { success: false };
14769                    });
14770                })
14771                .catch(function () {
14772                    return { success: false };
14773                });
14774            var p5 = fetch(origin + '/api/config/google-client-id', { credentials: 'omit' })
14775                .then(function (r) {
14776                    return r.json().catch(function () {
14777                        return {};
14778                    });
14779                })
14780                .catch(function () {
14781                    return {};
14782                });
14783            Promise.all([p3, p4, p5])
14784                .then(function (triple) {
14785                    applyDetectorAuxiliaryClientData(triple[0], triple[1], triple[2] || {});
14786                })
14787                .catch(function () {
14788                    applyDetectorAuxiliaryClientData({ ok: false, data: null }, { success: false }, {});
14789                });
14790
14791            try {
14792                mergeDetectorCarouselSlotDefaults();
14793            } catch (eFill) {
14794                /* ignore */
14795            }
14796            try {
14797                syncDetectorAdSlotsCountUi();
14798                wireDetectorLocalAdTakeoverOnce();
14799            } catch (eSlotsUi) {
14800                /* ignore */
14801            }
14802        }
14803        setTimeout(loadDeferredApis, 80);
14804
14805        p1.then(function (cat) {
14806            if (loadGen !== detectorLoadAllGen) return;
14807            try {
14808                applyDetectorCatalogPayload(cat, {
14809                    err: err,
14810                    finishLoad: true,
14811                    btn: btn,
14812                });
14813                writeDetectorCatalogSwrCache(cat);
14814            } catch (eApply) {
14815                if (err) {
14816                    err.style.display = 'block';
14817                    err.textContent =
14818                        __('js_err_detector_load') + ' ' + (eApply.message || __('js_try_again'));
14819                }
14820                state.detectorLoadInProgress = false;
14821                scheduleNextRefreshDeadline();
14822                updateRefreshCountdownUi();
14823                if (btn) btn.disabled = false;
14824                setLoading(false);
14825                setCatalogSyncing(false);
14826            }
14827        }).catch(function (e) {
14828            if (err) {
14829                err.style.display = 'block';
14830                err.textContent =
14831                    __('js_err_detector_load') + ' ' + (e.message || __('js_try_again'));
14832            }
14833            state.detectorLoadInProgress = false;
14834            scheduleNextRefreshDeadline();
14835            updateRefreshCountdownUi();
14836            if (btn) btn.disabled = false;
14837            setLoading(false);
14838            setCatalogSyncing(false);
14839        });
14840    }
14841
14842    function applyFilterSections() {
14843        var d = document.getElementById('secDd');
14844        if (d) d.classList.toggle('filter-hide', state.filter !== 'all');
14845    }
14846
14847    document.querySelectorAll('#toolbarCompact .seg-view button').forEach(function (b) {
14848        b.addEventListener('click', function () {
14849            document.querySelectorAll('#toolbarCompact .seg-view button').forEach(function (x) {
14850                x.classList.remove('active');
14851            });
14852            b.classList.add('active');
14853            state.view = b.getAttribute('data-view') || 'list';
14854            applyViewToShelves();
14855            updateCatalogViewToggleBtn();
14856        });
14857    });
14858
14859    var btnCatalogViewToggle = document.getElementById('btnCatalogViewToggle');
14860    if (btnCatalogViewToggle && btnCatalogViewToggle.dataset.detWired !== '1') {
14861        btnCatalogViewToggle.dataset.detWired = '1';
14862        btnCatalogViewToggle.addEventListener('click', function () {
14863            var to = btnCatalogViewToggle.getAttribute('data-mode-to') || 'grid';
14864            state.view = to === 'grid' ? 'grid' : 'list';
14865            syncSegViewButtonsFromState();
14866            applyViewToShelves();
14867            updateCatalogViewToggleBtn();
14868        });
14869    }
14870
14871    var btnRefresh = document.getElementById('btnRefresh');
14872    if (btnRefresh) btnRefresh.addEventListener('click', loadAll);
14873
14874    var secRankPanels = document.getElementById('secRankPanels');
14875    if (secRankPanels && secRankPanels.dataset.detectorRankMoreWired !== '1') {
14876        secRankPanels.dataset.detectorRankMoreWired = '1';
14877        secRankPanels.addEventListener('click', function (e) {
14878            var t = e.target && e.target.closest ? e.target.closest('.rank-more-btn') : null;
14879            if (!t || t.disabled) return;
14880            var panel = t.getAttribute('data-panel');
14881            if (!panel || state.rankExpanded[panel] === undefined) return;
14882            e.preventDefault();
14883            state.rankExpanded[panel] = !state.rankExpanded[panel];
14884            mountRankPanels();
14885            if (panel === 'community') renderCommunityRankingOl();
14886        });
14887    }
14888
14889    var mc = document.getElementById('svcModalClose');
14890    if (mc) mc.addEventListener('click', closeModal);
14891    var mb = document.getElementById('svcModal');
14892    if (mb) {
14893        mb.addEventListener('click', function (e) {
14894            if (e.target === mb) closeModal();
14895        });
14896        if (mb.dataset.detSvcHeaderWired !== '1') {
14897            mb.dataset.detSvcHeaderWired = '1';
14898            mb.addEventListener('click', function (e) {
14899                var pill = e.target && e.target.closest ? e.target.closest('#svcModalScorePill') : null;
14900                if (!pill || pill.hidden) return;
14901                e.preventDefault();
14902                e.stopPropagation();
14903                openTrustScoreExplainModal();
14904            });
14905            mb.addEventListener('keydown', function (e) {
14906                if (e.key !== 'Enter' && e.key !== ' ') return;
14907                var pill = e.target && e.target.id === 'svcModalScorePill' ? e.target : null;
14908                if (!pill || pill.hidden) return;
14909                e.preventDefault();
14910                openTrustScoreExplainModal();
14911            });
14912            mb.addEventListener('click', function (e) {
14913                var btn = e.target && e.target.closest ? e.target.closest('.svc-star-vote') : null;
14914                if (!btn || !mb.contains(btn)) return;
14915                e.preventDefault();
14916                e.stopPropagation();
14917                var sid = btn.getAttribute('data-svc-id');
14918                var sname = btn.getAttribute('data-svc-name') || sid;
14919                var n = Number(btn.getAttribute('data-star-vote'));
14920                if (!sid || isNaN(n)) return;
14921                openStarRatingModal(sid, sname, n);
14922            });
14923        }
14924    }
14925
14926    (function wireTrustScoreExplainModal() {
14927        var modal = document.getElementById('trustScoreExplainModal');
14928        var closeBtn = document.getElementById('trustScoreExplainClose');
14929        if (!modal || modal.dataset.detWired === '1') return;
14930        modal.dataset.detWired = '1';
14931        modal.setAttribute('aria-hidden', 'true');
14932        if (closeBtn) closeBtn.addEventListener('click', closeTrustScoreExplainModal);
14933        modal.addEventListener('click', function (e) {
14934            if (e.target === modal) closeTrustScoreExplainModal();
14935        });
14936        document.addEventListener('keydown', function (e) {
14937            if (e.key !== 'Escape') return;
14938            if (!modal.classList.contains('open')) return;
14939            closeTrustScoreExplainModal();
14940        });
14941    })();
14942
14943    var crm = document.getElementById('communityReportModal');
14944    if (crm)
14945        crm.addEventListener('click', function (e) {
14946            if (e.target === crm) closeCommunityReportModal();
14947        });
14948    var crClose = document.getElementById('crClose');
14949    if (crClose) crClose.addEventListener('click', closeCommunityReportModal);
14950    var crNo = document.getElementById('crNo');
14951    if (crNo) crNo.addEventListener('click', closeCommunityReportModal);
14952    var crYes = document.getElementById('crYes');
14953    if (crYes)
14954        crYes.addEventListener('click', function () {
14955            var s0 = document.getElementById('crStepConfirm');
14956            var sm = document.getElementById('crStepMeta');
14957            if (s0) s0.style.display = 'none';
14958            if (sm) sm.style.display = 'block';
14959            renderCrReasonList();
14960            scheduleCommunityCommentModeration();
14961        });
14962    var crToVerify = document.getElementById('crToVerify');
14963    if (crToVerify)
14964        crToVerify.addEventListener('click', function () {
14965            var sm = document.getElementById('crStepMeta');
14966            var s2 = document.getElementById('crStepVerify');
14967            var msg = document.getElementById('crMsg');
14968            var inlineErr = document.getElementById('crMetaInlineErr');
14969            if (inlineErr) {
14970                inlineErr.textContent = '';
14971                inlineErr.style.display = 'none';
14972            }
14973            if (state.crParentReportId) {
14974                var ta0 = document.getElementById('crComment');
14975                var tx0 = ta0 ? String(ta0.value || '').trim() : '';
14976                if (tx0.length < 1) {
14977                    if (inlineErr) {
14978                        inlineErr.textContent = __('js_cr_reply_need_text');
14979                        inlineErr.style.display = '';
14980                    }
14981                    return;
14982                }
14983            } else {
14984                var chk = document.querySelector('#crReasonList input[name="crReason"]:checked');
14985                if (!chk) {
14986                    if (inlineErr) {
14987                        inlineErr.textContent = 'Escolha um motivo.';
14988                        inlineErr.style.display = '';
14989                    }
14990                    return;
14991                }
14992            }
14993            crToVerify.disabled = true;
14994            setCrModerationHint(__('js_cr_mod_analyzing'), '');
14995            runCommunityCommentModerationSync().then(function (mr) {
14996                crToVerify.disabled = false;
14997                if (!mr.allowed && mr.moderation) {
14998                    var loc0 =
14999                        typeof window.detectorGetLocale === 'function' ? window.detectorGetLocale() : 'pt';
15000                    var t0 =
15001                        loc0 === 'en' && mr.moderation.summary_en
15002                            ? mr.moderation.summary_en
15003                            : mr.moderation.summary_pt || '';
15004                    if (inlineErr) {
15005                        inlineErr.textContent = t0;
15006                        inlineErr.style.display = '';
15007                    }
15008                    setCrModerationHint(t0, 'cr-mod-hint--bad');
15009                    return;
15010                }
15011                if (sm) sm.style.display = 'none';
15012                if (s2) s2.style.display = 'block';
15013                if (msg) {
15014                    msg.textContent = '';
15015                    msg.className = 'cr-msg';
15016                }
15017                syncCrChannelUi();
15018                var sel = document.getElementById('crChannel');
15019                var v = sel && sel.value === 'google' ? null : 'crEmail';
15020                var inp = v ? document.getElementById(v) : null;
15021                if (inp) setTimeout(function () { inp.focus(); }, 80);
15022            });
15023        });
15024    var crAnon = document.getElementById('crAnonymous');
15025    if (crAnon)
15026        crAnon.addEventListener('change', function () {
15027            var w = document.getElementById('crDisplayNameWrap');
15028            var on = !crAnon.checked;
15029            if (w) w.style.display = on ? 'block' : 'none';
15030        });
15031    var crBcast = document.getElementById('crBroadcast');
15032    if (crBcast)
15033        crBcast.addEventListener('change', function () {
15034            var w = document.getElementById('crBroadcastNameWrap');
15035            if (w) w.style.display = crBcast.checked ? '' : 'none';
15036        });
15037    var crBcastId = document.getElementById('crBroadcastIdentify');
15038    if (crBcastId)
15039        crBcastId.addEventListener('change', function () {
15040            var w = document.getElementById('crDisplayNameWrap');
15041            if (w) w.style.display = crBcastId.checked ? 'block' : 'none';
15042        });
15043    var crChannel = document.getElementById('crChannel');
15044    if (crChannel) crChannel.addEventListener('change', syncCrChannelUi);
15045
15046    var crCmtInp = document.getElementById('crComment');
15047    if (crCmtInp) crCmtInp.addEventListener('input', scheduleCommunityCommentModeration);
15048    var crDnInp = document.getElementById('crDisplayName');
15049    if (crDnInp) crDnInp.addEventListener('input', scheduleCommunityCommentModeration);
15050    var crReasonListHost = document.getElementById('crReasonList');
15051    if (crReasonListHost) crReasonListHost.addEventListener('change', scheduleCommunityCommentModeration);
15052    var crAnonMod = document.getElementById('crAnonymous');
15053    if (crAnonMod) crAnonMod.addEventListener('change', scheduleCommunityCommentModeration);
15054    var crBcastIdMod = document.getElementById('crBroadcastIdentify');
15055    if (crBcastIdMod) crBcastIdMod.addEventListener('change', scheduleCommunityCommentModeration);
15056
15057    var crSendCode = document.getElementById('crSendCode');
15058    if (crSendCode)
15059        crSendCode.addEventListener('click', function () {
15060            var sid = state.crSvcId;
15061            var msg = document.getElementById('crMsg');
15062            var sel = document.getElementById('crChannel');
15063            var channel = sel ? String(sel.value || 'google') : 'google';
15064            var phone = '';
15065            var email = '';
15066            if (channel === 'email') {
15067                var emEl = document.getElementById('crEmail');
15068                email = emEl ? String(emEl.value || '').trim() : '';
15069                if (!email || email.indexOf('@') < 1 || email.indexOf('.') < 0) {
15070                    if (msg) {
15071                        msg.className = 'cr-msg err';
15072                        msg.textContent = __('js_cr_invalid_email') || 'Informe um e-mail válido.';
15073                    }
15074                    return;
15075                }
15076            }
15077            if (!sid) return;
15078            if (channel === 'google') {
15079                if (msg) {
15080                    msg.className = 'cr-msg err';
15081                    msg.textContent = __('js_use_google_above');
15082                }
15083                return;
15084            }
15085            crSendCode.disabled = true;
15086            if (msg) {
15087                msg.textContent = '';
15088                msg.className = 'cr-msg';
15089            }
15090            var origin = window.location.origin || '';
15091            var payload = Object.assign(
15092                { service_id: sid, channel: channel, phone: phone || null, email: email || null },
15093                getCrMetaPayload(),
15094            );
15095            fetch(origin + '/api/detector/community-report/start', {
15096                method: 'POST',
15097                headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
15098                body: JSON.stringify(payload),
15099                credentials: 'same-origin',
15100            })
15101                .then(function (r) {
15102                    return r.json().then(function (data) {
15103                        return { r: r, data: data };
15104                    });
15105                })
15106                .then(function (pack) {
15107                    var d = pack.data || {};
15108                    if (msg) {
15109                        msg.className = 'cr-msg ' + (pack.r.ok && d.ok ? 'ok' : 'err');
15110                        var loc = typeof window.detectorGetLocale === 'function' ? window.detectorGetLocale() : 'pt';
15111                        var apiMsg =
15112                            loc === 'en' && d.message_en
15113                                ? d.message_en
15114                                : d.message_pt ||
15115                                  (pack.r.ok ? __('js_cr_code_sent') : __('js_cr_send_fail'));
15116                        var txt = apiMsg;
15117                        if (d.error === 'require_google') {
15118                            txt += __('js_cr_require_google_suffix');
15119                        }
15120                        if (d.debug_code) txt += ' [dev: ' + d.debug_code + ']';
15121                        msg.textContent = txt;
15122                    }
15123                    if (pack.r.ok && d.ok && d.verify_token) {
15124                        state.crVerifyToken = d.verify_token;
15125                        var panel = document.getElementById('crCo
15125dePanel');
15126                        if (panel) panel.style.display = 'block';
15127                        var cIn = document.getElementById('crCode');
15128                        if (cIn) setTimeout(function () { cIn.focus(); }, 80);
15129                    }
15130                })
15131                .catch(function () {
15132                    if (msg) {
15133                        msg.className = 'cr-msg err';
15134                        msg.textContent = __('js_err_net_retry');
15135                    }
15136                })
15137                .finally(function () {
15138                    crSendCode.disabled = false;
15139                });
15140        });
15141
15142    var crConfirmCode = document.getElementById('crConfirmCode');
15143    if (crConfirmCode)
15144        crConfirmCode.addEventListener('click', function () {
15145            var sid = state.crSvcId;
15146            var tok = state.crVerifyToken;
15147            var cEl = document.getElementById('crCode');
15148            var msg = document.getElementById('crMsg');
15149            var code = cEl ? String(cEl.value || '').replace(/\D/g, '').slice(0, 4) : '';
15150            if (!sid || !tok) {
15151                if (msg) {
15152                    msg.className = 'cr-msg err';
15153                    msg.textContent = __('js_send_code_first');
15154                }
15155                return;
15156            }
15157            if (code.length !== 4) {
15158                if (msg) {
15159                    msg.className = 'cr-msg err';
15160                    msg.textContent = __('js_enter_4_digits');
15161                }
15162                return;
15163            }
15164            crConfirmCode.disabled = true;
15165            var origin = window.location.origin || '';
15166            fetch(origin + '/api/detector/community-report/confirm', {
15167                method: 'POST',
15168                headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
15169                body: JSON.stringify({
15170                    verify_token: tok,
15171                    code: code,
15172                    broadcast_notify: getCrMetaPayload().broadcast_notify,
15173                    broadcast_who: getCrMetaPayload().broadcast_identify ? (getCrMetaPayload().display_name || null) : null,
15174                    service_name: (findSvc(sid) || {}).name || sid,
15175                }),
15176                credentials: 'same-origin',
15177            })
15178                .then(function (r) {
15179                    return r.json().then(function (data) {
15180                        return { r: r, data: data };
15181                    });
15182                })
15183                .then(function (pack) {
15184                    var d = pack.data || {};
15185                    if (msg) {
15186                        msg.className = 'cr-msg ' + (pack.r.ok && d.ok ? 'ok' : 'err');
15187                        var loc2 =
15188                            typeof window.detectorGetLocale === 'function' ? window.detectorGetLocale() : 'pt';
15189                        var apiMsg2 =
15190                            loc2 === 'en' && d.message_en
15191                                ? d.message_en
15192                                : d.message_pt ||
15193                                  (pack.r.ok ? __('js_cr_confirmed') : __('js_cr_confirm_fail'));
15194                        msg.textContent = apiMsg2;
15195                    }
15196                    if (pack.r.ok && d.ok) {
15197                        if (!d.parent_report_id && !d.moderation_pending) {
15198                            bumpCommunityVoteLocal(sid);
15199                            mountShelves();
15200                            mountProblemsCatalog();
15201                            mountCommunityRanking(true);
15202                        }
15203                        setTimeout(closeCommunityReportModal, 2200);
15204                    }
15205                })
15206                .catch(function () {
15207                    if (msg) {
15208                        msg.className = 'cr-msg err';
15209                        msg.textContent = __('js_err_net_retry');
15210                    }
15211                })
15212                .finally(function () {
15213                    crConfirmCode.disabled = false;
15214                });
15215        });
15216
15217    function initDetectorContactForm() {
15218        var overlay = document.getElementById('det-contact-overlay');
15219        var form = document.getElementById('det-contact-form');
15220        var btnOpen = document.getElementById('det-btn-open-contact');
15221        var btnCancel = document.getElementById('det-contact-cancel');
15222        var btnSubmit = document.getElementById('det-contact-submit');
15223        var successEl = document.getElementById('det-contact-success');
15224        var errorEl = document.getElementById('det-contact-error');
15225        if (!overlay || !form || !btnOpen) return;
15226
15227        function openM() {
15228            overlay.classList.add('visible');
15229            overlay.setAttribute('aria-hidden', 'false');
15230            if (successEl) {
15231                successEl.classList.remove('visible');
15232                successEl.textContent = '';
15233            }
15234            if (errorEl) {
15235                errorEl.classList.remove('visible');
15236                errorEl.textContent = '';
15237            }
15238            form.reset();
15239            form.querySelectorAll('input, textarea, select').forEach(function (el) {
15240                el.disabled = false;
15241            });
15242            if (btnSubmit) {
15243                btnSubmit.style.display = '';
15244                btnSubmit.disabled = false;
15245            }
15246            var em = document.getElementById('det-contact-email');
15247            if (em) em.focus();
15248        }
15249
15250        function closeM() {
15251            overlay.classList.remove('visible');
15252            overlay.setAttribute('aria-hidden', 'true');
15253        }
15254
15255        btnOpen.addEventListener('click', openM);
15256        if (btnCancel) btnCancel.addEventListener('click', closeM);
15257        overlay.addEventListener('click', function (e) {
15258            if (e.target === overlay) closeM();
15259        });
15260        document.addEventListener('keydown', function (e) {
15261            if (e.key === 'Escape' && overlay.classList.contains('visible')) closeM();
15262        });
15263
15264        form.addEventListener('submit', function (e) {
15265            e.preventDefault();
15266            var email = (document.getElementById('det-contact-email').value || '').trim();
15267            var mensagemRaw = (document.getElementById('det-contact-mensagem').value || '').trim();
15268            var nome = (document.getElementById('det-contact-nome').value || '').trim();
15269            var tipoEl = document.getElementById('det-contact-tipo');
15270            var tipo = tipoEl ? (tipoEl.value || '').trim() : '';
15271            var empresa = (document.getElementById('det-contact-empresa').value || '').trim();
15272            var assunto = (document.getElementById('det-contact-assunto').value || '').trim();
15273            if (!email || mensagemRaw.length < 5 || !tipo) {
15274                if (errorEl) {
15275                    errorEl.textContent =
15276                        'Preencha o e-mail, o motivo da mensagem e o texto (mínimo 5 caracteres).';
15277                    errorEl.classList.add('visible');
15278                }
15279                return;
15280            }
15281            var tipoLabel =
15282                tipo === 'parceria'
15283                    ? 'Parceria / melhorar índices de monitorização'
15284                    : tipo === 'problema'
15285                      ? 'Problema ou dúvida sobre o Detector'
15286                      : 'Outro assunto';
15287            var bodyLines = [
15288                '— Mensagem à Catellix · origem: Catellix Detector (formulário público) —',
15289                'Página: https://catellix.com/detector',
15290                '',
15291                'Motivo: ' + tipoLabel,
15292            ];
15293            if (empresa) bodyLines.push('Empresa / projeto: ' + empresa);
15294            if (assunto) bodyLines.push('Assunto: ' + assunto);
15295            bodyLines.push('', mensagemRaw);
15296            var fullMsg = bodyLines.join('\n');
15297            if (fullMsg.length > 4000) {
15298                fullMsg = fullMsg.slice(0, 4000);
15299            }
15300            if (btnSubmit) btnSubmit.disabled = true;
15301            if (errorEl) errorEl.classList.remove('visible');
15302            var origin = window.location.origin || '';
15303            fetch(origin + '/api/public/chat-contact', {
15304                method: 'POST',
15305                headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
15306                credentials: 'omit',
15307                body: JSON.stringify({
15308                    departamento: 'suporte',
15309                    email: email,
15310                    mensagem: fullMsg,
15311                    origem: 'detector',
15312                    nome: nome || undefined,
15313                    assunto: assunto || undefined,
15314                }),
15315            })
15316                .then(function (r) {
15317                    return r.json().then(function (d) {
15318                        return { ok: r.ok, data: d };
15319                    });
15320                })
15321                .then(function (pack) {
15322                    if (btnSubmit) btnSubmit.disabled = false;
15323                    if (pack.ok && pack.data.success && pack.data.protocolo) {
15324                        if (successEl) {
15325                            successEl.innerHTML =
15326                                '<strong>Mensagem enviada à Catellix.</strong> Referência: <strong>' +
15327                                esc(String(pack.data.protocolo)) +
15328                                '</strong>. ' +
15329                                esc(pack.data.message || 'Confirme a sua caixa de e-mail.');
15330                            successEl.classList.add('visible');
15331                        }
15332                        form.querySelectorAll('input, textarea, select').forEach(function (el) {
15333                            el.disabled = true;
15334                        });
15335                        if (btnSubmit) btnSubmit.style.display = 'none';
15336                    } else {
15337                        if (errorEl) {
15338                            errorEl.textContent =
15339                                (pack.data && pack.data.detail) ||
15340                                (pack.data && pack.data.message) ||
15341                                'Erro ao enviar. Tente novamente ou escreva para [email protected].';
15342                            errorEl.classList.add('visible');
15343                        }
15344                    }
15345                })
15346                .catch(function () {
15347                    if (btnSubmit) btnSubmit.disabled = false;
15348                    if (errorEl) {
15349                        errorEl.textContent =
15350                            'Erro de rede. Tente novamente ou contacte [email protected].';
15351                        errorEl.classList.add('visible');
15352                    }
15353                });
15354        });
15355    }
15356
15357    function refreshDetectorDynamicI18n() {
15358        fillRankSelectOptions();
15359        rebuildCategorySelect();
15360        updateMetricsBar();
15361        applyRankMoodUI();
15362        syncSidebarComplaintsToolbarLabels();
15363        refreshSidebarComplaintsTop10();
15364        updateCatalogFilterBanner();
15365        refreshShelfHints();
15366        applyFilterSections();
15367        if (state.catalog && state.catalog.length) {
15368            mountShelves();
15369            mountRankPanels();
15370            mountProblemsCatalog();
15371            mountMobileCommunityTop10Board();
15372            if (!markLazySectionDirty('top10Boards')) mountTop10Boards();
15373            mountTop10Cards();
15374            mountDd();
15375            mountIcmpNoReplySection();
15376            syncComplaintsTop10BoardsAndCards();
15377        }
15378        updateRefreshCountdownUi();
15379        updateTop30OpenLabels();
15380        updateHeroTagline();
15381        try {
15382            syncDetectorAdSlotsCountUi();
15383        } catch (eSlots) {
15384            /* ignore */
15385        }
15386        try {
15387            renderTop30List((top30State.cachePayload && top30State.cachePayload.items) || []);
15388        } catch (eT30) {
15389            /* ignore */
15390        }
15391        var crModal = document.getElementById('communityReportModal');
15392        if (crModal && crModal.classList.contains('open') && state.crSvcId) {
15393            applyCrQuestionLead(!!state.crParentReportId);
15394            applyCrVerifyLeadText();
15395        }
15396    }
15397
15398    window.detectorOnLocaleChange = function () {
15399        updateDetThemeToggleUi();
15400        refreshDetectorDynamicI18n();
15401        try {
15402            syncDetectorAdSlotsCountUi();
15403        } catch (eSlotsI18n) {
15404            /* ignore */
15405        }
15406        try {
15407            mergeDetectorCarouselSlotDefaults();
15408            invalidateCarouselSlotHash();
15409            invalidateCarouselSlotHash('R');
15410            initDetectorAdSlotsCarousel();
15411            if (!isMapaAdSlotsPage) {
15412                initDetectorAdSlotsCarouselRight();
15413            }
15414            initDetectorTopBarLocalSpin();
15415        } catch (eLocTools) {
15416            /* ignore */
15417        }
15418        try {
15419            var adPack = document.getElementById('detAdPackModal');
15420            if (adPack && !adPack.hidden && adPack.getAttribute('aria-hidden') !== 'true') {
15421                refreshDetectorAdPackModalContent();
15422            }
15423        } catch (eAdPackLoc) {
15424            /* ignore */
15425        }
15426    };
15427
15428    if (!isMapaAdSlotsPage) {
15429    var secProbCatalog = document.getElementById('secProbCatalog');
15430    if (secProbCatalog && secProbCatalog.dataset.detectorProbClick !== '1') {
15431        secProbCatalog.dataset.detectorProbClick = '1';
15432        secProbCatalog.addEventListener('click', function (e) {
15433            var btn = e.target && e.target.closest ? e.target.closest('button.rank-line[data-svc-id]') : null;
15434            if (!btn) return;
15435            var id = btn.getAttribute('data-svc-id');
15436            if (id) openModal(id);
15437        });
15438    }
15439
15440    initDetectorContactForm();
15441    (function initDetectorFeedbackReplyButtons() {
15442        document.addEventListener('click', function (e) {
15443            var t = e.target;
15444            var btn = t && t.closest ? t.closest('.det-fb-reply-btn') : null;
15445            if (!btn) return;
15446            var sid = btn.getAttribute('data-det-reply-svc');
15447            var pid = btn.getAttribute('data-det-reply-parent');
15448            if (!sid || !pid) return;
15449            e.preventDefault();
15450            var n = parseInt(pid, 10);
15451            if (!isFinite(n) || n < 1) return;
15452            openCommunityReportModal(sid, n);
15453        });
15454    })();
15455    document.addEventListener('click', function (e) {
15456        var a = e.target && e.target.closest && e.target.closest('a.cr-open-det-explainer');
15457        if (!a) return;
15458        e.preventDefault();
15459        if (typeof window.detOpenPlatformExplainer === 'function') {
15460            window.detOpenPlatformExplainer();
15461        }
15462    });
15463    wireReportPickerModal();
15464    wireComplaintsHourlyMicroModal();
15465    wireTop30OutageModal();
15466    wireDetectorRankingStudioAndReport();
15467    wireTop10SocialShareModal();
15468    updateTop30OpenLabels();
15469    initDetectorBarMascoteVideo();
15470    initRankSelect();
15471    wireCategorySelect();
15472    wireIpStackSelect();
15473    wireCatalogSearchInput();
15474    wireCatalogSearchModal();
15475    wireRankMoodToggle();
15476    wireRefreshIntervalSelect();
15477    wireSidebarComplaintsRanking();
15478    startCommunityRankingPoll();
15479    var btnCatalogHome = document.getElementById('btnCatalogHome');
15480    if (btnCatalogHome && btnCatalogHome.dataset.detectorWired !== '1') {
15481        btnCatalogHome.dataset.detectorWired = '1';
15482        btnCatalogHome.addEventListener('click', function () {
15483            resetCatalogHomeView();
15484        });
15485    }
15486    wireDetThemeToggle();
15487    wireDetComplaintsViewportRelocate();
15488    (function wireMobileGamesTop10ShareDelegate() {
15489        var mount = document.getElementById('detMobileGamesBoardMount');
15490        if (!mount || mount.dataset.detGamesShareDel === '1') return;
15491        mount.dataset.detGamesShareDel = '1';
15492        mount.addEventListener(
15493            'click',
15494            function (e) {
15495                var t = e.target;
15496                var btn = t && t.closest ? t.closest('.det-right-games-board__share') : null;
15497                if (!btn || !mount.contains(btn)) return;
15498                e.preventDefault();
15499                e.stopPropagation();
15500                openTop10GamesSocialShareModal();
15501            },
15502            false
15503        );
15504    })();
15505    wireDetRefreshToolbarPlacement();
15506    if (typeof window.detectorWireLocaleToggle === 'function') {
15507        window.detectorWireLocaleToggle();
15508    }
15509    if (typeof window.detectorApplyDomI18n === 'function') {
15510        window.detectorApplyDomI18n(document);
15511    }
15512    applyCrQuestionLead(false);
15513    applyCrVerifyLeadText();
15514    updateCatalogViewToggleBtn();
15515    updateHeroTagline();
15516    syncSidebarComplaintsToolbarLabels();
15517    requestAnimationFrame(function () {
15518        if (communityRowsWithVotesForReport().length > 0) {
15519            renderTop30List();
15520            clearTop30ErrorState();
15521        } else {
15522            fetchTop30Services(false)
15523                .then(function (data) {
15524                    renderTop30List((data && data.items) || []);
15525                    clearTop30ErrorState();
15526                })
15527                .catch(function () {
15528                    renderTop30List();
15529                    var tList = document.getElementById('detTop30List');
15530                    var hasRows = tList && tList.children && tList.children.length > 0;
15531                    if (!hasRows) {
15532                        showTop30ErrorState();
15533                    }
15534                });
15535        }
15536    });
15537    registerLazySection('top10Boards', mountTop10Boards);
15538    registerLazySection('shelvesHost', mountCatalogGridAndIcmpSection);
15539    loadAll();
15540
15541    window.addEventListener(
15542        'resize',
15543        function () {
15544            applyViewToShelves();
15545            var narrowNow = DET_COMPLAINTS_VIEWPORT_MQ.matches;
15546            if (narrowNow !== _detTop10CardsViewportNarrow) {
15547                _detTop10CardsViewportNarrow = narrowNow;
15548                mountTop10Cards();
15549            }
15550        },
15551        { passive: true },
15552    );
15553
15554    /* ── Live Report Toasts (SSE) ── */
15555    (function initLiveToasts() {
15556        var rail = document.getElementById('liveToastRail');
15557        if (!rail) return;
15558        var MAX_TOASTS = 4;
15559        var TOAST_MS = 3500;
15560
15561        function showToast(data) {
15562            if (!data || data.type !== 'report') return;
15563            var svcName = data.service_name || data.service_id || '?';
15564            var who = data.who
15565                ? '<strong>' + esc(data.who) + '</strong>'
15566                : esc(__('js_anonymous'));
15567            var el = document.createElement('div');
15568            el.className = 'live-toast';
15569            el.innerHTML =
15570                '<div class="live-toast-icon">&#9888;</div>' +
15571                '<div class="live-toast-body">' +
15572                '<div class="live-toast-title">' +
15573                esc(__('toast_title')) +
15574                '</div>' +
15575                '<div class="live-toast-svc">' + esc(svcName) + '</div>' +
15576                '<div class="live-toast-who">' +
15577                esc(__('toast_by')) +
15578                ' ' +
15579                who +
15580                '</div>' +
15581                '<div class="live-toast-bar"></div>' +
15582                '</div>';
15583            rail.appendChild(el);
15584            while (rail.children.length > MAX_TOASTS) {
15585                rail.removeChild(rail.firstChild);
15586            }
15587            setTimeout(function () {
15588                el.classList.add('out');
15589                setTimeout(function () {
15590                    if (el.parentNode) el.parentNode.removeChild(el);
15591                }, 350);
15592            }, TOAST_MS);
15593        }
15594
15595        function connect() {
15596            var origin = window.location.origin || '';
15597            var es;
15598            try {
15599                es = new EventSource(origin + '/api/detector/live-reports');
15600            } catch (e) { return; }
15601            es.onmessage = function (ev) {
15602                try {
15603                    var d = JSON.parse(ev.data);
15604                    if (d.type === 'report' && document.visibilityState === 'visible') {
15605                        showToast(d);
15606                    }
15607                } catch (e) {}
15608            };
15609            es.onerror = function () {
15610                es.close();
15611                setTimeout(connect, 15000);
15612            };
15613        }
15614        connect();
15615    })();
15616    } /* fim: init completo (não usado no Wi‑Fi MAP) */
15617
15618    /** Wi‑Fi MAP: mesma faixa premium do Detector (R$ 20/dia — `openDetectorAdPackModal('right')`). */
15619    window.CatellixOpenDetectorAdPackForMapa = function () {
15620        openDetectorAdPackModal('right');
15621    };
15622
15623    window.CatellixBootstrapMapaAdSlots = function () {
15624        if (!isMapaAdSlotsPage) {
15625            return Promise.resolve();
15626        }
15627        var origin = window.location.origin || '';
15628        return fetch(origin + '/api/detector/catalog', { credentials: 'omit' })
15629            .then(function (r) {
15630                return r.json().catch(function () {
15631                    return {};
15632                });
15633            })
15634            .then(function (cat) {
15635                if (cat && cat.success) {
15636                    try {
15637                        applyDetectorAdSlotsFromCatalog(cat);
15638                    } catch (e1) {
15639                        try {
15640                            mergeDetectorCarouselSlotDefaults();
15641                            syncDetectorAdSlotsCountUi();
15642                        } catch (e1b) {
15643                            /* ignore */
15644                        }
15645                    }
15646                } else {
15647                    try {
15648                        mergeDetectorCarouselSlotDefaults();
15649                        syncDetectorAdSlotsCountUi();
15650                    } catch (e2) {
15651                        /* ignore */
15652                    }
15653                }
15654                try {
15655                    initDetectorAdSlotsCarousel();
15656                } catch (e3) {
15657                    /* ignore */
15658                }
15659            })
15660            .catch(function () {
15661                try {
15662                    mergeDetectorCarouselSlotDefaults();
15663                    syncDetectorAdSlotsCountUi();
15664                } catch (e5) {
15665                    /* ignore */
15666                }
15667                try {
15668                    initDetectorAdSlotsCarousel();
15669                } catch (e6) {
15670                    /* ignore */
15671                }
15672            });
15673    };
15674})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.