/**
 * Parst einen Zeitstring vom Server in ein Date-Objekt.
 * Timestamps mit 'Z' oder '+' werden direkt geparst (echtes UTC/Offset).
 * Timestamps ohne Zeitzonen-Info werden als Europe/Berlin interpretiert,
 * da die DB alle Zeiten in Lokalzeit speichert.
 */
function parseUTC(dateStr) {
    if (!dateStr) return null;
    try {
        if (dateStr.endsWith('Z') || dateStr.includes('+')) {
            const d = new Date(dateStr);
            return isNaN(d.getTime()) ? null : d;
        }
        // DB-Timestamps sind Europe/Berlin Lokalzeit.
        // Aktuellen Berlin-UTC-Offset ermitteln und anwenden.
        const normalized = dateStr.replace(' ', 'T');
        const naive = new Date(normalized + 'Z'); // als UTC parsen
        if (isNaN(naive.getTime())) return null;
        // Berlin-Offset fuer diesen Zeitpunkt bestimmen
        const berlinStr = naive.toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' });
        const berlinAsUTC = new Date(berlinStr.replace(' ', 'T') + 'Z');
        const offsetMs = naive.getTime() - berlinAsUTC.getTime();
        const d = new Date(naive.getTime() + offsetMs);
        return isNaN(d.getTime()) ? null : d;
    } catch (e) {
        return null;
    }
}

/**
 * UI-Komponenten für das Dashboard.
 */
const UI = {
    /**
     * Sidebar-Eintrag für eine Lage rendern.
     */
    renderIncidentItem(incident, isActive) {
        const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id);
        const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived');
        const activeClass = isActive ? 'active' : '';
        const creator = (incident.created_by_username || '').split('@')[0];

        // Determine refresh status for sidebar display
        let refreshClass = '';
        let refreshStatusHtml = '';
        if (isRefreshing) {
            const state = this._progressState[incident.id];
            const step = state ? state.step : 'researching';
            const isQueued = (step === 'queued');

            if (isQueued) {
                refreshClass = ' queued-item';
                const pos = state && state._queuePos ? ' (#' + state._queuePos + ')' : '';
                refreshStatusHtml = '<div class="incident-refresh-status queued-status" id="sidebar-refresh-' + incident.id + '"><span>Warteschlange' + pos + '</span></div>';
            } else {
                refreshClass = ' refreshing-item';
                const label = this._getStepLabel(step);
                refreshStatusHtml = '<div class="incident-refresh-status" id="sidebar-refresh-' + incident.id + '"><span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incident.id + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span></div>';
            }
        }

        // Im Auswahlmodus der Seitenleiste tritt ein Haken an die Stelle des
        // Statuspunktes und ein Klick waehlt aus, statt die Lage zu oeffnen.
        const auswahl = App._bulkMode != null;
        const gewaehlt = auswahl && App._bulkSel.has(incident.id);
        const klick = auswahl
            ? `App.toggleBulkSel(${incident.id}, !App._bulkSel.has(${incident.id}))`
            : `App.selectIncident(${incident.id})`;
        const markierung = auswahl
            ? `<label class="incident-check" title="Auswählen" onclick="event.stopPropagation()"><input type="checkbox" ${gewaehlt ? 'checked' : ''} onchange="App.toggleBulkSel(${incident.id}, this.checked)"></label>`
            : `<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>`;

        return `
            <div class="incident-item ${activeClass}${refreshClass}${gewaehlt ? ' picked' : ''}" data-id="${incident.id}" onclick="${klick}" role="button" tabindex="0">
                ${markierung}
                <div style="flex:1;min-width:0;">
                    <div class="incident-name">${this.escape(incident.title)}</div>
                    <div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}${incident.ai_backend === 'bedrock' ? ' &middot; <span class="sidebar-ai-tag ai-eu">EU</span>' : (incident.ai_backend === 'cli' ? ' &middot; <span class="sidebar-ai-tag ai-anthropic">Anthropic</span>' : '')}</div>
                    ${refreshStatusHtml}
                </div>
                ${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
                ${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">&#x21bb;</span>' : ''}
            </div>
        `;
    },

    /**
     * Faktencheck-Eintrag rendern.
     */
    // Faktencheck-Status-Labels (org-sprach-relativ via T()).
    // Die DE-Fallbacks sind die historische Quelle der Wahrheit; bei
    // englischer Org liefert T() den EN-Text aus i18n/en.json.
    _fcLabelDefaultsDE: {
        confirmed: 'Bestätigt durch mehrere Quellen',
        unconfirmed: 'Nicht unabhängig bestätigt',
        contradicted: 'Quellen widersprechen sich',
        false: 'Widerlegt',
        developing: 'Faktenlage noch im Fluss',
        established: 'Gesicherter Fakt (3+ Quellen)',
        disputed: 'Umstrittener Sachverhalt',
        unverified: 'Nicht unabhängig verifizierbar',
    },
    _fcTooltipDefaultsDE: {
        confirmed: 'Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.',
        established: 'Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.',
        developing: 'Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.',
        unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.',
        unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.',
        disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.',
        contradicted: 'Widersprüchlich: Die Belege widersprechen einander, keine Seite ist entschieden. Unterschiedliche Zahlen verschiedener Erhebungsstellen gelten nicht als Widerspruch.',
        false: 'Widerlegt: Ein Beleg entkräftet diese Aussage nachweislich.',
    },
    _fcChipDefaultsDE: {
        confirmed: 'Bestätigt',
        unconfirmed: 'Unbestätigt',
        contradicted: 'Widersprüchlich',
        false: 'Widerlegt',
        developing: 'Unklar',
        established: 'Gesichert',
        disputed: 'Umstritten',
        unverified: 'Ungeprüft',
    },

    get factCheckLabels() {
        const out = {};
        for (const k of Object.keys(this._fcLabelDefaultsDE)) {
            out[k] = (typeof T === 'function')
                ? T('fc.label.' + k, this._fcLabelDefaultsDE[k])
                : this._fcLabelDefaultsDE[k];
        }
        return out;
    },
    get factCheckTooltips() {
        const out = {};
        for (const k of Object.keys(this._fcTooltipDefaultsDE)) {
            out[k] = (typeof T === 'function')
                ? T('fc.tooltip.' + k, this._fcTooltipDefaultsDE[k])
                : this._fcTooltipDefaultsDE[k];
        }
        return out;
    },
    get factCheckChipLabels() {
        const out = {};
        for (const k of Object.keys(this._fcChipDefaultsDE)) {
            out[k] = (typeof T === 'function')
                ? T('fc.chip.' + k, this._fcChipDefaultsDE[k])
                : this._fcChipDefaultsDE[k];
        }
        return out;
    },

    factCheckIcons: {
        confirmed: '&#10003;',
        unconfirmed: '?',
        contradicted: '&#8644;',
        false: '&#10007;',
        developing: '&#8635;',
        established: '&#10003;',
        disputed: '&#9888;',
        unverified: '?',
    },

    /**
     * Faktencheck-Filterleiste rendern.
     */
    renderFactCheckFilters(factchecks) {
        // Welche Stati kommen tatsächlich vor + Zähler
        const statusCounts = {};
        factchecks.forEach(fc => {
            statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
        });
        const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted', 'false'];
        const usedStatuses = statusOrder.filter(s => statusCounts[s]);
        if (usedStatuses.length <= 1) return '';

        const items = usedStatuses.map(status => {
            const icon = this.factCheckIcons[status] || '?';
            const chipLabel = this.factCheckChipLabels[status] || status;
            const tooltip = this.factCheckTooltips[status] || '';
            const count = statusCounts[status];
            return `<label class="fc-dropdown-item" data-status="${status}" title="${tooltip}">
                <input type="checkbox" checked onchange="App.toggleFactCheckFilter('${status}')">
                <span class="factcheck-icon ${status}">${icon}</span>
                <span class="fc-dropdown-label">${chipLabel}</span>
                <span class="fc-dropdown-count">${count}</span>
            </label>`;
        }).join('');

        return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)" aria-haspopup="true" aria-expanded="false">Filter</button>
            <div class="fc-dropdown-menu" id="fc-dropdown-menu">${items}</div>`;
    },

    renderFactCheck(fc) {
        const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
        const count = urls.length;
        return `
            <div class="factcheck-item" data-fc-status="${fc.status}">
                <div class="factcheck-icon ${fc.status}" title="${this.factCheckTooltips[fc.status] || this.factCheckLabels[fc.status] || fc.status}" aria-hidden="true">${this.factCheckIcons[fc.status] || '?'}</div>
                <span class="sr-only">${this.factCheckLabels[fc.status] || fc.status}</span>
                <div style="flex:1;">
                    <div class="factcheck-claim">${this.escape(fc.claim)}</div>
                    <div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
                        <span class="factcheck-sources">${count} Quelle${count !== 1 ? 'n' : ''}</span>
                    </div>
                    <div class="evidence-block">${this.renderEvidence(fc.evidence || '')}</div>
                </div>
            </div>
        `;
    },

    /**
     * Evidence mit erklärenden Text UND Quellen-Chips rendern.
     */
    renderEvidence(text) {
        if (!text) return '<span class="evidence-empty">Keine Belege</span>';

        const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
        if (urls.length === 0) {
            return `<span class="evidence-text">${this.escape(text)}</span>`;
        }

        // Erklärenden Text extrahieren (URLs entfernen)
        let explanation = text;
        urls.forEach(url => { explanation = explanation.replace(url, '').trim(); });
        // Aufräumen: Klammern, mehrfache Kommas/Leerzeichen
        explanation = explanation.replace(/\(\s*\)/g, '');
        explanation = explanation.replace(/,\s*,/g, ',');
        explanation = explanation.replace(/\s+/g, ' ').trim();
        explanation = explanation.replace(/[,.:;]+$/, '').trim();

        // Chips für jede URL
        const chips = urls.map(url => {
            let label;
            try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; }
            return `<a href="${this.escape(url)}" target="_blank" rel="noopener" class="evidence-chip" title="${this.escape(url)}">${this.escape(label)}</a>`;
        }).join('');

        const explanationHtml = explanation
            ? `<span class="evidence-text">${this.escape(explanation)}</span>`
            : '';

        return `${explanationHtml}<div class="evidence-chips">${chips}</div>`;
    },

    /**
     * Toast-Benachrichtigung anzeigen.
     */
    _toastTimers: new Map(),

    showToast(message, type = 'info', duration = 5000) {
        const container = document.getElementById('toast-container');

        // Duplikat? Bestehenden Toast neu animieren
        const existing = Array.from(container.children).find(
            t => t.dataset.msg === message && t.dataset.type === type
        );
        if (existing) {
            clearTimeout(this._toastTimers.get(existing));
            // Kurz rausschieben, dann neu reingleiten
            existing.style.transition = 'none';
            existing.style.opacity = '0';
            existing.style.transform = 'translateX(100%)';
            void existing.offsetWidth; // Reflow erzwingen
            existing.style.transition = 'all 0.3s ease';
            existing.style.opacity = '1';
            existing.style.transform = 'translateX(0)';
            const timer = setTimeout(() => {
                existing.style.opacity = '0';
                existing.style.transform = 'translateX(100%)';
                setTimeout(() => { existing.remove(); this._toastTimers.delete(existing); }, 300);
            }, duration);
            this._toastTimers.set(existing, timer);
            return;
        }

        const toast = document.createElement('div');
        toast.className = `toast toast-${type}`;
        toast.setAttribute('role', 'status');
        toast.dataset.msg = message;
        toast.dataset.type = type;
        toast.innerHTML = `<span class="toast-text">${this.escape(message)}</span>`;
        container.appendChild(toast);

        const timer = setTimeout(() => {
            toast.style.opacity = '0';
            toast.style.transform = 'translateX(100%)';
            toast.style.transition = 'all 0.3s ease';
            setTimeout(() => { toast.remove(); this._toastTimers.delete(toast); }, 300);
        }, duration);
        this._toastTimers.set(toast, timer);
    },

    _progressStartTime: null,
    _progressTimer: null,

    /**
     * Fortschrittsanzeige einblenden und Status setzen.
     */
    // === Progress State (per-incident) ===
    _progressState: {},  // { incidentId: { step, isFirst, startTime, minimized } }
    _progressTimerInterval: null,

    _getStepOrder() {
        return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
    },

    _getStepLabel(step) {
        const fallback = {
            queued: 'In Warteschlange',
            researching: 'Recherchiert...',
            deep_researching: 'Tiefenrecherche...',
            analyzing: 'Analysiert...',
            factchecking: 'Faktencheck...',
            cancelling: 'Wird abgebrochen...',
        };
        if (!fallback[step]) return step;
        return (typeof T === 'function')
            ? T('progress.status.' + step, fallback[step])
            : fallback[step];
    },

    showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
        if (!incidentId) incidentId = App.currentIncidentId;
        if (!incidentId) return;

        // Init state for this incident
        if (!this._progressState[incidentId]) {
            this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false };
        }
        const state = this._progressState[incidentId];
        state.step = status;
        if (isFirstRefresh) state.isFirst = true;

        // Start timer on first non-queued status
        if (status !== 'queued' && !state.startTime) {
            if (extra.started_at) {
                const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at);
                state.startTime = serverStart ? serverStart.getTime() : Date.now();
            } else {
                state.startTime = Date.now();
            }
        }

        // Start global timer interval if not running
        if (!this._progressTimerInterval) {
            this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000);
        }

        // Store queue position
        if (status === 'queued' && extra.queue_position) {
            state._queuePos = extra.queue_position;
        }

        // Update sidebar status for ALL incidents (not just current)
        this._updateSidebarRefreshStatus(incidentId, status, extra);

        // Only show popup/mini UI for current incident
        if (incidentId !== App.currentIncidentId) return;


        if (false) { // popup always shown initially
            state.minimized = true;
        }

        if (state.minimized) {
            this._showMiniProgress(status, state);
            return;
        }

        this._showPopupProgress(status, extra, state);
    },

    /**
     * Hält den globalen Header bedienbar, solange das blockierende
     * Fortschritts-Overlay sichtbar ist. Die Body-Klasse hebt den Header per
     * z-index über das Overlay: Barrierefreiheit, Theme, Konto-Menü und
     * Abmelden müssen jederzeit erreichbar sein.
     */
    _syncHeaderAccess() {
        const overlay = document.getElementById('progress-overlay');
        const blocking = !!overlay
            && overlay.classList.contains('blocking')
            && overlay.style.display !== 'none';
        document.body.classList.toggle('first-refresh-blocking', blocking);
    },

    _showPopupProgress(status, extra, state) {
        const overlay = document.getElementById('progress-overlay');
        const popup = document.getElementById('progress-popup');
        if (!overlay || !popup) return;

        overlay.style.display = 'flex';
        this._initClickOutside();

        // Blocking (no close) for first refresh
        if (state.isFirst) {
            overlay.classList.add('blocking');
            // Apply blur to incident-view (Header + Tab-Panels gemeinsam).
            const blurTarget = document.getElementById('incident-view');
            if (blurTarget) {
                blurTarget.classList.add('refresh-blurred');
                // Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick
                // (Display-Wechsel, renderSidebar, leere innerHTML) greift
                // CSS filter:blur erst beim naechsten Layout-Pass. Im
                // naechsten Frame nochmal setzen — idempotent.
                requestAnimationFrame(() => {
                    if (state && state.isFirst) blurTarget.classList.add('refresh-blurred');
                });
            }
        } else {
            overlay.classList.remove('blocking');
        }
        this._syncHeaderAccess();

        // Minimize button: only for updates (not first)
        const minBtn = document.getElementById('progress-popup-minimize');
        if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';

        // Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
        const titleEl = document.getElementById('progress-popup-title');
        if (titleEl) {
            const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
            let title;
            if (status === 'queued') {
                const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
                title = _t('progress.title.queued', 'In Warteschlange') + pos;
            } else if (status === 'cancelling') {
                title = _t('progress.title.cancelling', 'Wird abgebrochen\u2026');
            } else if (state.isFirst) {
                title = _t('progress.title.first_refresh', 'Erste Recherche l\u00e4uft');
            } else {
                title = _t('progress.title.refresh', 'Aktualisierung l\u00e4uft');
            }
            titleEl.textContent = title;
        }

        // Multi-pass info
        const passEl = document.getElementById('progress-popup-pass');
        if (passEl) {
            if (extra.research_pass && extra.research_total_passes) {
                passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes;
                passEl.style.display = '';
            } else {
                passEl.style.display = 'none';
            }
        }

        // Update checklist
        const stepOrder = this._getStepOrder();
        const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status);
        const items = document.querySelectorAll('.progress-check-item');
        // Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4
        const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 };

        items.forEach(item => {
            const step = item.dataset.step;
            const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1;
            const icon = item.querySelector('.progress-check-icon');
            const detail = item.querySelector('.progress-check-detail');

            item.classList.remove('active', 'done', 'error');

            if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) {
                item.classList.add('done');
                if (icon) icon.innerHTML = '\u2713';
            } else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) {
                item.classList.add('active');
                if (icon) icon.innerHTML = '<div class="spinner"></div>';
                if (detail && extra.detail) detail.textContent = extra.detail;
                else if (detail) detail.textContent = '';
            } else {
                if (icon) icon.innerHTML = '\u25cb';
                if (detail) detail.textContent = '';
            }
        });

        // Cancel button
        const cancelBtn = document.getElementById('progress-cancel-btn');
        if (cancelBtn) {
            cancelBtn.style.display = '';
            cancelBtn.textContent = 'Abbrechen';
            cancelBtn.disabled = false;
        }

        // Hide complete summary
        const summaryEl = document.getElementById('progress-complete-summary');
        if (summaryEl) summaryEl.style.display = 'none';

        // Hide mini bar
        const mini = document.getElementById('progress-mini');
        if (mini) mini.style.display = 'none';

        // Lock action buttons during first refresh
        this._lockActionsIfFirst(state.isFirst);
    },

    _lockActionsIfFirst(isFirst) {
        const actions = document.querySelector('.incident-header-actions');
        if (!actions) return;
        if (isFirst) {
            actions.classList.add('first-refresh-locked');
        } else {
            actions.classList.remove('first-refresh-locked');
        }
    },

    _showMiniProgress(status, state) {
        const mini = document.getElementById('progress-mini');
        if (!mini) return;
        mini.style.display = 'flex';

        const textEl = document.getElementById('progress-mini-text');
        if (textEl) textEl.textContent = this._getStepLabel(status);

        // Hide popup
        const overlay = document.getElementById('progress-overlay');
        if (overlay) overlay.style.display = 'none';
        this._syncHeaderAccess();
    },

    minimizeProgress(incidentId) {
        if (!incidentId) incidentId = App.currentIncidentId;
        const state = this._progressState[incidentId];
        if (!state) return;
        state.minimized = true;
        state._userOpenedPopup = false;
        this._showMiniProgress(state.step, state);
    },

    openProgressPopup(incidentId) {
        if (!incidentId) incidentId = App.currentIncidentId;
        const state = this._progressState[incidentId];
        if (!state) return;
        state.minimized = false;
        state._userOpenedPopup = true;
        this._showPopupProgress(state.step, {}, state);
    },

    showProgressComplete(data, incidentId) {
        if (!incidentId) incidentId = App.currentIncidentId;
        const state = this._progressState[incidentId];

        // Calculate total time
        let totalTimeStr = '';
        if (state && state.startTime) {
            const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
            const mins = Math.floor(elapsed / 60);
            const secs = elapsed % 60;
            totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
        }

        if (incidentId === App.currentIncidentId) {
            // Remove blur
            const blurTarget = document.getElementById('incident-view');
            if (blurTarget) blurTarget.classList.remove('refresh-blurred');

            const overlay = document.getElementById('progress-overlay');
            if (overlay) {
                overlay.style.display = 'flex';
                overlay.classList.remove('blocking');
            }
            this._syncHeaderAccess();

            // Mark all steps done
            document.querySelectorAll('.progress-check-item').forEach(item => {
                item.classList.remove('active', 'error');
                item.classList.add('done');
                const icon = item.querySelector('.progress-check-icon');
                if (icon) icon.innerHTML = '\u2713';
            });

            // Show summary
            const parts = [];
            if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
            if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
            if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widersprüchlich');
            const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';

            const summaryEl = document.getElementById('progress-complete-summary');
            if (summaryEl) {
                summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
                    + (totalTimeStr ? '<span class="total-time">Gesamtzeit: ' + totalTimeStr + '</span>' : '');
                summaryEl.style.display = 'block';
            }

            // Update title
            const titleEl = document.getElementById('progress-popup-title');
            if (titleEl) titleEl.textContent = 'Abgeschlossen';

            // Hide cancel, show minimize
            const cancelBtn = document.getElementById('progress-cancel-btn');
            if (cancelBtn) cancelBtn.style.display = 'none';
            const minBtn = document.getElementById('progress-popup-minimize');
            if (minBtn) minBtn.style.display = '';

            // Hide mini bar
            const mini = document.getElementById('progress-mini');
            if (mini) mini.style.display = 'none';
        }

        // Remove sidebar refresh status
        this._removeSidebarRefreshStatus(incidentId);

        // Clean up state after delay
        setTimeout(() => {
            this.hideProgress(incidentId);
        }, 5000);
    },

    showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
        if (!incidentId) incidentId = App.currentIncidentId;
        if (incidentId !== App.currentIncidentId) return;

        const overlay = document.getElementById('progress-overlay');
        if (overlay) overlay.style.display = 'flex';

        // Mark current step as error
        const state = this._progressState[incidentId];
        if (state) {
            const items = document.querySelectorAll('.progress-check-item.active');
            items.forEach(item => {
                item.classList.remove('active');
                item.classList.add('error');
                const icon = item.querySelector('.progress-check-icon');
                if (icon) icon.innerHTML = '\u2717';
            });
        }

        const titleEl = document.getElementById('progress-popup-title');
        if (titleEl) {
            titleEl.textContent = willRetry
                ? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...'
                : 'Fehlgeschlagen: ' + errorMsg;
        }

        const cancelBtn = document.getElementById('progress-cancel-btn');
        if (cancelBtn) cancelBtn.style.display = 'none';

        if (!willRetry) {
            this._removeSidebarRefreshStatus(incidentId);
            setTimeout(() => this.hideProgress(incidentId), 6000);
        }
    },

    hideProgress(incidentId) {
        if (!incidentId) incidentId = App.currentIncidentId;

        // Remove blur
        const blurTarget = document.getElementById('incident-view');
        if (blurTarget) blurTarget.classList.remove('refresh-blurred');

        if (incidentId === App.currentIncidentId) {
            const overlay = document.getElementById('progress-overlay');
            if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
            this._syncHeaderAccess();
            const mini = document.getElementById('progress-mini');
            if (mini) mini.style.display = 'none';
        }

        // Unlock action buttons
        this._lockActionsIfFirst(false);

        // Remove sidebar status
        this._removeSidebarRefreshStatus(incidentId);

        // Clean up state
        delete this._progressState[incidentId];

        // Stop timer if no more active refreshes
        if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) {
            clearInterval(this._progressTimerInterval);
            this._progressTimerInterval = null;
        }
    },

    _tickProgressTimers() {
        for (const [id, state] of Object.entries(this._progressState)) {
            if (!state.startTime) continue;
            const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
            const mins = Math.floor(elapsed / 60);
            const secs = elapsed % 60;
            const timeStr = mins + ':' + String(secs).padStart(2, '0');

            if (parseInt(id) === App.currentIncidentId) {
                // Update popup timer
                const timerEl = document.getElementById('progress-popup-timer');
                if (timerEl) timerEl.textContent = timeStr;
                // Update mini timer
                const miniTimer = document.getElementById('progress-mini-timer');
                if (miniTimer) miniTimer.textContent = timeStr;
            }

            // Update sidebar timer for this incident
            const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
            if (sidebarTimer) sidebarTimer.textContent = timeStr;
        }
    },

    // === Sidebar Refresh Status ===
    _updateSidebarRefreshStatus(incidentId, status, extra) {
        const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
        if (!item) return;

        const isQueued = (status === 'queued');

        // Add appropriate class
        item.classList.remove('refreshing-item', 'queued-item');
        item.classList.add(isQueued ? 'queued-item' : 'refreshing-item');

        // Add or update status text below meta
        let statusEl = document.getElementById('sidebar-refresh-' + incidentId);
        if (!statusEl) {
            const textCol = item.querySelector('div[style*="flex:1"]');
            if (!textCol) return;
            statusEl = document.createElement('div');
            statusEl.id = 'sidebar-refresh-' + incidentId;
            textCol.appendChild(statusEl);
        }

        if (isQueued) {
            const pos = (extra && extra.queue_position) ? extra.queue_position : ((this._progressState[incidentId] || {})._queuePos || '');
            // Store queue position in state for renderIncidentItem
            const pState = this._progressState[incidentId];
            if (pState && pos) pState._queuePos = pos;
            statusEl.className = 'incident-refresh-status queued-status';
            statusEl.innerHTML = '<span>Warteschlange' + (pos ? ' (#' + pos + ')' : '') + '</span>';
        } else {
            statusEl.className = 'incident-refresh-status';
            const label = this._getStepLabel(status);
            statusEl.innerHTML = '<span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incidentId + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span>';
        }
    },

    _removeSidebarRefreshStatus(incidentId) {
        const statusEl = document.getElementById('sidebar-refresh-' + incidentId);
        if (statusEl) statusEl.remove();
        const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
        if (item) item.classList.remove('refreshing-item', 'queued-item');
    },

    _reindexQueuePositions() {
        // Collect all queued incidents and renumber sequentially
        const queued = [];
        for (const [id, state] of Object.entries(this._progressState)) {
            if (state && state.step === 'queued') queued.push({ id: Number(id), pos: state._queuePos || 999 });
        }
        queued.sort((a, b) => a.pos - b.pos);
        queued.forEach((item, idx) => {
            const newPos = idx + 1;
            const state = this._progressState[item.id];
            if (state) state._queuePos = newPos;
            const statusEl = document.getElementById('sidebar-refresh-' + item.id);
            if (statusEl) statusEl.innerHTML = '<span>Warteschlange (#' + newPos + ')</span>';
        });
    },


    // === Click-outside to auto-minimize popup ===
    _initClickOutside() {
        if (this._clickOutsideInit) return;
        this._clickOutsideInit = true;
        document.addEventListener('click', (e) => {
            const overlay = document.getElementById('progress-overlay');
            if (!overlay || overlay.style.display === 'none') return;
            const popup = document.getElementById('progress-popup');
            if (!popup) return;
            // Ignore clicks inside the popup itself
            if (popup.contains(e.target)) return;
            // Ignore clicks on the mini bar
            const mini = document.getElementById('progress-mini');
            if (mini && mini.contains(e.target)) return;
            // Don't minimize during first refresh (blocking)
            const currentId = App.currentIncidentId;
            const state = this._progressState[currentId];
            if (state && state.isFirst) return;
            // Auto-minimize
            if (state && !state.minimized) {
                this.minimizeProgress(currentId);
            }
        });
    },

    /**
     * Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
     */
    /**
     * Extrahiert die ZUSAMMENFASSUNG-Sektion aus einem Research-Briefing.
     * Returns: { zusammenfassung: string|null, remaining: string }
     */
    extractZusammenfassung(summary) {
        if (!summary) return { zusammenfassung: null, remaining: summary };
        const pattern = /## (?:ZUSAMMENFASSUNG|ÜBERBLICK)\s*\n(.*?)(?=\n## |$)/s;
        const match = summary.match(pattern);
        if (!match) return { zusammenfassung: null, remaining: summary };
        const zusammenfassung = match[1].trim();
        const remaining = summary.substring(0, match.index) + summary.substring(match.index + match[0].length);
        return { zusammenfassung, remaining: remaining.trim() };
    },

    /**
     * Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER
     * JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet.
     */
    _parseSources(input) {
        if (!input) return [];
        if (Array.isArray(input)) return input;
        try {
            const parsed = JSON.parse(input);
            return Array.isArray(parsed) ? parsed : [];
        } catch (e) {
            return [];
        }
    },

    /**
     * Rendert die Zusammenfassung als HTML (Bullet Points).
     */
    renderZusammenfassung(text, sourcesJson) {
        if (!text) return '<span style="color:var(--text-disabled);">Noch keine Zusammenfassung.</span>';
        const sources = this._parseSources(sourcesJson);
        // Nur Bullet-Point-Zeilen behalten, Fliesstext herausfiltern
        const bulletLines = text.split("\n").filter(line => line.trim().startsWith("- "));
        const bulletText = bulletLines.length > 0 ? bulletLines.join("\n") : text;
        let html = this.escape(bulletText);
        // Bullet points
        html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
        html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul style="margin:4px 0 4px 18px;line-height:1.7;">$&</ul>');
        // Zeilenumbrueche
        html = html.replace(/\n(?!<)/g, '<br>');
        html = html.replace(/(<br>){2,}/g, '<br>');
        // Inline-Zitate als klickbare Links
        if (sources.length > 0) {
            html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
                let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
                if ((!src || !src.url) && /[a-z]$/.test(num)) {
                    const baseNum = num.replace(/[a-z]$/, '');
                    const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
                    if (baseSrc && baseSrc.url) src = baseSrc;
                }
                if (src && src.url) {
                    return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
                }
                return match;
            });
        }
        return html;
    },

    /**
     * Rendert die "Öffentliche Stimmung"-Kachel.
     * Eingabe ist Markdown mit "- "-Bullets (vom AnalyzerAgent.generate_public_mood).
     * Quellen-Pills brauchen wir hier nicht — die Bullet-Texte nennen die Foren-Herkunft
     * explizit ("auf 5ch /seiji/ ...", "Hatena-Kommentare betonen ...").
     */
    renderPublicMood(text) {
        if (!text) return '<span style="color:var(--text-disabled);">Noch kein Stimmungsbild erfasst.</span>';
        const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- "));
        if (bulletLines.length === 0) {
            // Fliesstext-Fallback: HTML-escapen + Zeilenumbrueche
            return this.escape(text).replace(/\n/g, '<br>');
        }
        const items = bulletLines.map(l => {
            const body = l.replace(/^-\s+/, '');
            return `<li>${this.escape(body)}</li>`;
        }).join('');
        return `<ul style="margin:4px 0 4px 18px;line-height:1.7;">${items}</ul>`;
    },

    /**
     * Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
     * Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
     * Legacy: Inline-[N]-Citations werden als Fallback ebenfalls erkannt.
     */
    renderLatestDevelopments(text, sourcesJson) {
        if (!text) return '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst.</span>';
        const sources = this._parseSources(sourcesJson);

        const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l && (l.startsWith("- ") || l.startsWith("[")));
        if (bulletLines.length === 0) {
            return this.renderZusammenfassung(text, sourcesJson);
        }

        const bulletRe = /^(?:-\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$/;
        const citationRe = /\[(\d+[a-z]?)\]/g;
        const trailingNamesRe = /\s*\{([^{}]+)\}\s*\.?\s*$/;

        const lookupByNum = (num) => {
            let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
            if (!src && /[a-z]$/.test(num)) {
                const baseNum = num.replace(/[a-z]$/, '');
                src = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
            }
            return src || null;
        };

        const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
        const lookupByName = (name) => {
            const n = normalize(name);
            if (!n) return null;
            let src = sources.find(s => normalize(s.name) === n);
            if (src) return src;
            src = sources.find(s => {
                const sn = normalize(s.name);
                return sn.includes(n) || n.includes(sn);
            });
            return src || null;
        };

        const buildPill = (src, fallbackName) => {
            const displayName = src ? (src.name || fallbackName) : fallbackName;
            const url = (src && src.url) || '';
            const tgMatch = url.match(/^https?:\/\/t\.me\/([^\/?#]+)/i);
            const label = tgMatch ? displayName + ' (t.me/' + tgMatch[1] + ')' : displayName;
            const esc = this.escape(label);
            const titleEsc = this.escape(displayName);
            if (src && src.url) {
                return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="dev-source-pill" title="${titleEsc}">${esc}</a>`;
            }
            return `<span class="dev-source-pill" title="${titleEsc}">${esc}</span>`;
        };

        const cards = bulletLines.map(line => {
            const m = bulletRe.exec(line);
            if (!m) {
                const body = this.escape(line.replace(/^-\s*/, ''));
                return `<div class="dev-bullet"><div class="dev-body">${body}</div></div>`;
            }
            const day = m[1].padStart(2, '0');
            const month = m[2].padStart(2, '0');
            const date = `${day}.${month}.`;
            const time = m[3];
            let rawBody = m[4];

            let pillsHtml = '';

            // Primär: {Name1|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende
            const trailing = trailingNamesRe.exec(rawBody);
            if (trailing) {
                rawBody = rawBody.replace(trailingNamesRe, '').trim();
                const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean);
                const seen = new Set();
                pillsHtml = items.map(item => {
                    // Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name
                    const pipeIdx = item.indexOf('|');
                    const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim();
                    const itemUrl  = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : '';
                    if (!itemName) return '';
                    const key = normalize(itemName);
                    if (seen.has(key)) return '';
                    seen.add(key);
                    if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return '';
                    // Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich
                    if (itemUrl) {
                        return buildPill({ name: itemName, url: itemUrl }, itemName);
                    }
                    // Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json
                    const src = lookupByName(itemName);
                    return buildPill(src, itemName);
                }).filter(Boolean).join('');
            }

            // Fallback: Inline-[N]-Citations (Legacy-Recherche-Format)
            if (!pillsHtml) {
                const nums = [];
                let cm;
                while ((cm = citationRe.exec(rawBody)) !== null) {
                    if (!nums.includes(cm[1])) nums.push(cm[1]);
                }
                citationRe.lastIndex = 0;
                if (nums.length > 0) {
                    rawBody = rawBody.replace(citationRe, '').replace(/\s+/g, ' ').trim();
                    pillsHtml = nums.map(num => {
                        const src = lookupByNum(num);
                        return src ? buildPill(src, src.name || `Quelle ${num}`) : '';
                    }).filter(Boolean).join('');
                }
            }

            const cleanBody = this.escape(rawBody.trim());
            const sourcesHtml = pillsHtml ? `<span class="dev-sources">${pillsHtml}</span>` : '<span class="dev-sources"></span>';
            const timeHtml = `<span class="dev-time" title="${this.escape(date + ' ' + time)}">${this.escape(time)} \u00b7 ${this.escape(date)}</span>`;

            return `<div class="dev-bullet"><div class="dev-bullet-head">${sourcesHtml}${timeHtml}</div><div class="dev-body">${cleanBody}</div></div>`;
        });

        return `<div class="dev-list">${cards.join('')}</div>`;
    },


    renderSummary(summary, sourcesJson, incidentType) {
        if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';

        const sources = this._parseSources(sourcesJson);

        // Markdown-Rendering
        let html = this.escape(summary);

        // ## Überschriften
        html = html.replace(/^## (.+)$/gm, '<h3 class="briefing-heading">$1</h3>');
        // **Fettdruck**
        html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
        // Listen (- Item)
        html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
        html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul>$&</ul>');
        // Zeilenumbrüche (aber nicht nach Headings/Listen)
        html = html.replace(/\n(?!<)/g, '<br>');
        // Überflüssige <br> nach Block-Elementen entfernen + doppelte <br> zusammenfassen
        html = html.replace(/<\/h3>(<br>)+/g, '</h3>');
        html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
        html = html.replace(/(<br>){2,}/g, '<br>');

        // Markdown-Tabellen rendern
        html = html.replace(/(?:^|<br>)((?:\|.+\|(?:<br>|$))+)/g, function(match, tableBlock) {
            var rows = tableBlock.split('<br>').filter(function(r) { return r.trim().length > 0; });
            if (rows.length < 2) return match;
            var isSep = function(r) { return /^\|[\s\-:|]+\|$/.test(r.trim()); };
            if (!isSep(rows[1])) return match;
            var parseRow = function(r) { return r.split('|').slice(1, -1).map(function(c) { return c.trim(); }); };
            var headerCells = parseRow(rows[0]);
            var thead = '<thead><tr>' + headerCells.map(function(c) { return '<th>' + c + '</th>'; }).join('') + '</tr></thead>';
            var tbody = '<tbody>' + rows.slice(2).map(function(r) {
                if (isSep(r)) return '';
                var cells = parseRow(r);
                return '<tr>' + cells.map(function(c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
            }).join('') + '</tbody>';
            return '<div class="summary-table-wrap"><table class="summary-table">' + thead + tbody + '</table></div>';
        });

        // Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern
        if (sources.length > 0) {
            html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
                // Exakte Suche (auch mit Buchstaben-Suffix)
                let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
                // Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen
                if ((!src || !src.url) && /[a-z]$/.test(num)) {
                    const baseNum = num.replace(/[a-z]$/, '');
                    const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
                    if (baseSrc && baseSrc.url) src = baseSrc;
                }
                if (src && src.url) {
                    return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
                }
                return match;
            });
        }

        return `<div class="briefing-content">${html}</div>`;
    },

    /**
     * Quellenübersicht für eine Lage rendern.
     */
    /**
     * Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage,
     * unabhaengig von Paginierung im Frontend).
     * data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]}
     */
    renderSourceOverviewFromSummary(data) {
        if (!data || !data.sources || data.sources.length === 0) return '';

        const langChips = (data.language_counts || [])
            .map(l => `<span class="source-lang-chip">${(l.language || 'de').toUpperCase()} <strong>${l.cnt}</strong></span>`)
            .join('');

        let html = `<div class="source-overview-header">`;
        html += `<span class="source-overview-stat">${data.total} Artikel aus ${data.sources.length} Quellen</span>`;
        html += `<div class="source-lang-chips">${langChips}</div>`;
        html += `</div>`;

        // Typ-Filter-Chips: immer zeigen, sobald Quellen vorhanden sind. Die Leiste
        // zeigt zugleich auf einen Blick, welche Quellentypen der Fall enthaelt.
        const typeCounts = { web: 0, telegram: 0, x: 0 };
        data.sources.forEach(s => {
            const t = s.source_type || 'web';
            typeCounts[t] = (typeCounts[t] || 0) + 1;
        });
        const typeMeta = [
            { key: '', label: 'Alle', count: data.sources.length },
            { key: 'web', label: 'Web', count: typeCounts.web },
            { key: 'telegram', label: 'Telegram', count: typeCounts.telegram },
            { key: 'x', label: 'X', count: typeCounts.x },
        ];
        const chips = typeMeta
            .filter(t => t.key === '' || t.count > 0)
            .map(t => `<button type="button" class="source-type-filter-chip${t.key === '' ? ' active' : ''}" data-type="${t.key}" onclick="App.filterSourceOverview('${t.key}', this)">${t.label} <strong>${t.count}</strong></button>`)
            .join('');
        html += `<div class="source-type-filter-chips">${chips}</div>`;

        html += '<div class="source-overview-grid">';
        data.sources.forEach(s => {
            const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
            const sourceName = this.escape(s.source || 'Unbekannt');
            const sType = s.source_type || 'web';
            html += `<div class="source-overview-item" data-source="${sourceName}" data-type="${sType}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
                <span class="source-overview-name">${sourceName}</span>
                <span class="source-overview-lang">${langs}</span>
                <span class="source-overview-count">${s.article_count}</span>
            </div>`;
        });
        html += '</div>';

        return html;
    },

    renderSourceOverview(articles) {
        if (!articles || articles.length === 0) return '';

        // Nach Quelle aggregieren
        const sourceMap = {};
        articles.forEach(a => {
            const name = a.source || 'Unbekannt';
            if (!sourceMap[name]) {
                sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
            }
            sourceMap[name].count++;
            sourceMap[name].languages.add(a.language || 'de');
            if (a.source_url) sourceMap[name].urls.push(a.source_url);
        });

        const sources = Object.entries(sourceMap)
            .sort((a, b) => b[1].count - a[1].count);

        // Sprach-Statistik
        const langCount = {};
        articles.forEach(a => {
            const lang = (a.language || 'de').toUpperCase();
            langCount[lang] = (langCount[lang] || 0) + 1;
        });

        const langChips = Object.entries(langCount)
            .sort((a, b) => b[1] - a[1])
            .map(([lang, count]) => `<span class="source-lang-chip">${lang} <strong>${count}</strong></span>`)
            .join('');

        let html = `<div class="source-overview-header">`;
        html += `<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>`;
        html += `<div class="source-lang-chips">${langChips}</div>`;
        html += `</div>`;

        html += '<div class="source-overview-grid">';
        sources.forEach(([name, data]) => {
            const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
            html += `<div class="source-overview-item">
                <span class="source-overview-name">${this.escape(name)}</span>
                <span class="source-overview-lang">${langs}</span>
                <span class="source-overview-count">${data.count}</span>
            </div>`;
        });
        html += '</div>';

        return html;
    },

    /**
     * Kategorie-Labels.
     */
    _categoryLabels: {
        'nachrichtenagentur': 'Agentur',
        'oeffentlich-rechtlich': 'ÖR',
        'qualitaetszeitung': 'Qualität',
        'behoerde': 'Behörde',
        'fachmedien': 'Fach',
        'think-tank': 'Think Tank',
        'international': 'Intl.',
        'regional': 'Regional',
        'boulevard': 'Boulevard',
        'telegram': 'Telegram',
        'sonstige': 'Sonstige',
    },

    _politicalLabels: {
        links_extrem: { short: 'L+', full: 'Links (extrem)' },
        links: { short: 'L', full: 'Links' },
        mitte_links: { short: 'ML', full: 'Mitte-Links' },
        liberal: { short: 'LIB', full: 'Liberal' },
        mitte: { short: 'M', full: 'Mitte' },
        konservativ: { short: 'KON', full: 'Konservativ' },
        mitte_rechts: { short: 'MR', full: 'Mitte-Rechts' },
        rechts: { short: 'R', full: 'Rechts' },
        rechts_extrem: { short: 'R+', full: 'Rechts (extrem)' },
        na: { short: '?', full: 'Nicht eingeordnet' },
    },
    _reliabilityLabels: {
        sehr_hoch: 'Sehr hoch',
        hoch: 'Hoch',
        gemischt: 'Gemischt',
        niedrig: 'Niedrig',
        sehr_niedrig: 'Sehr niedrig',
        na: 'Nicht eingeordnet',
    },
    _mediaTypeLabels: {
        tageszeitung: 'Tageszeitung',
        wochenzeitung: 'Wochenzeitung',
        magazin: 'Magazin',
        tv_sender: 'TV-Sender',
        radio: 'Radio',
        oeffentlich_rechtlich: 'Öffentlich-Rechtlich',
        nachrichtenagentur: 'Nachrichtenagentur',
        online_only: 'Online-only',
        blog: 'Blog',
        telegram_kanal: 'Telegram-Kanal',
        telegram_bot: 'Telegram-Bot',
        podcast: 'Podcast',
        social_media: 'Social Media',
        imageboard: 'Imageboard',
        think_tank: 'Think Tank',
        ngo: 'NGO',
        behoerde: 'Behörde',
        staatsmedium: 'Staatsmedium',
        fachmedium: 'Fachmedium',
        sonstige: 'Sonstige',
    },
    _alignmentLabels: {
        prorussisch: 'prorussisch',
        proiranisch: 'proiranisch',
        prowestlich: 'prowestlich',
        proukrainisch: 'proukrainisch',
        prochinesisch: 'prochinesisch',
        projapanisch: 'projapanisch',
        proisraelisch: 'proisraelisch',
        propalaestinensisch: 'propalästinensisch',
        protuerkisch: 'protürkisch',
        panarabisch: 'panarabisch',
        neutral: 'neutral',
        sonstige: 'sonstige',
    },

    _renderClassificationBadges(feed) {
        const parts = [];
        const pol = feed.political_orientation;
        if (pol && pol !== 'na') {
            const label = this._politicalLabels[pol] || { short: pol, full: pol };
            parts.push(`<span class="source-political-badge pol-${this.escape(pol)}" title="${this.escape(label.full)}">${this.escape(label.short)}</span>`);
        }
        const rel = feed.reliability;
        if (rel && rel !== 'na') {
            const relLabel = this._reliabilityLabels[rel] || rel;
            const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)'
                : (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)`
                : '(LLM-Schätzung)');
            const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`;
            parts.push(`<span class="source-reliability-dot rel-${this.escape(rel)}" title="${this.escape(relTitle)}" aria-label="${this.escape(relTitle)}"></span>`);
        }
        if (feed.ifcn_signatory) {
            parts.push(`<span class="source-ifcn-badge" title="IFCN-zertifizierter Faktenchecker" aria-label="IFCN-Faktenchecker">✓ IFCN</span>`);
        }
        if (feed.eu_disinfo_listed) {
            const cnt = feed.eu_disinfo_case_count || 0;
            const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`;
            parts.push(`<span class="source-eu-disinfo-badge" title="${this.escape(title)}" aria-label="${this.escape(title)}">⚠ EU-Desinfo (${cnt})</span>`);
        }
        if (feed.state_affiliated) {
            parts.push(`<span class="source-state-badge" title="Staatsnah/-kontrolliert" aria-label="Staatsnah">⚑</span>`);
        }
        const aligns = Array.isArray(feed.alignments) ? feed.alignments : [];
        aligns.forEach(a => {
            const label = this._alignmentLabels[a] || a;
            parts.push(`<span class="source-alignment-chip-badge align-${this.escape(a)}">${this.escape(label)}</span>`);
        });
        return parts.join('');
    },

    /**
     * Domain-Gruppe rendern (aufklappbar mit Feeds).
     */
    _sourceTypeLabel(type) {
        return ({ rss_feed: 'RSS', web_source: 'Web', telegram_channel: 'Telegram', x_account: 'X', podcast_feed: 'Podcast', excluded: 'Ausgeschlossen' })[type] || 'Web';
    },

    renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
        const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
        const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
        const hasMultiple = feedCount > 1;
        const displayName = (domain && !domain.startsWith('_single_')) ? domain : (feeds[0]?.name || 'Unbekannt');
        const escapedDomain = this.escape(domain);

        if (isExcluded) {
            // Ausgeschlossene Domain
            const notesHtml = excludedNotes ? ` <span class="source-group-notes">${this.escape(excludedNotes)}</span>` : '';
            return `<div class="source-group">
                <div class="source-group-header excluded">
                    <div class="source-group-info">
                        <span class="source-group-name">${this.escape(displayName)}</span>${notesHtml}
                    </div>
                    <span class="source-excluded-badge">${(typeof T === 'function' ? T('sources_modal.excluded_badge', 'Ausgeschlossen') : 'Ausgeschlossen')}</span>
                    <div class="source-group-actions">
                        <button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
                    </div>
                </div>
            </div>`;
        }

        // Aktive Domain-Gruppe
        const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
        const toggleIcon = hasMultiple ? '<span class="source-group-toggle" aria-hidden="true">&#9654;</span>' : '<span class="source-group-toggle-placeholder"></span>';

        let feedRows = '';
        if (hasMultiple) {
            const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
            feedRows = `<div class="source-group-feeds" data-domain="${escapedDomain}">`;
            realFeeds.forEach((feed, i) => {
                const isLast = i === realFeeds.length - 1;
                const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
                const typeLabel = this._sourceTypeLabel(feed.source_type);
                const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
                feedRows += `<div class="source-feed-row">
                    <span class="source-feed-connector">${connector}</span>
                    <span class="source-feed-name">${this.escape(feed.name)}</span>
                    <span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
                    <span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
                    ${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
                    <button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>` : '<span class="source-global-badge">Grundquelle</span>'}
                </div>`;
            });
            feedRows += '</div>';
        }

        const feedCountBadge = feedCount > 0
            ? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</span>`
            : '';

        // Info-Button mit Tooltip (Typ, Sprache, Ausrichtung, Klassifikation)
        let infoButtonHtml = '';
        const firstFeed = feeds[0] || {};
        const hasInfo = firstFeed.language || firstFeed.bias
            || (firstFeed.political_orientation && firstFeed.political_orientation !== 'na')
            || (firstFeed.media_type && firstFeed.media_type !== 'sonstige')
            || (firstFeed.reliability && firstFeed.reliability !== 'na')
            || firstFeed.state_affiliated
            || firstFeed.country_code
            || (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
        if (hasInfo) {
            const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', x_account: 'X (Twitter)', podcast_feed: 'Podcast' };
            const lines = [];
            lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
            if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
            if (firstFeed.country_code) lines.push('Land: ' + firstFeed.country_code);
            if (firstFeed.media_type && firstFeed.media_type !== 'sonstige') {
                lines.push('Medientyp: ' + (this._mediaTypeLabels[firstFeed.media_type] || firstFeed.media_type));
            }
            if (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') {
                const pl = this._politicalLabels[firstFeed.political_orientation];
                lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation));
            }
            if (firstFeed.reliability && firstFeed.reliability !== 'na') {
                const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability;
                const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)'
                    : (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)`
                    : ' (LLM-Schätzung)');
                lines.push('Glaubwürdigkeit: ' + relLabel + relSrc);
            }
            if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja');
            if (firstFeed.eu_disinfo_listed) {
                lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : ''));
            }
            if (firstFeed.state_affiliated) lines.push('Staatsnah: ja');
            if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) {
                const labels = firstFeed.alignments.map(a => this._alignmentLabels[a] || a);
                lines.push('Geopolitische Nähe: ' + labels.join(', '));
            }
            if (firstFeed.bias) lines.push('Notiz: ' + firstFeed.bias);
            const tooltipText = this.escape(lines.join('\n'));
            infoButtonHtml = ` <span class="info-icon tooltip-below" data-tooltip="${tooltipText}"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span>`;
        }

        const classificationBadges = this._renderClassificationBadges(firstFeed);

        return `<div class="source-group">
            <div class="source-group-header" ${toggleAttr}>
                ${toggleIcon}
                <div class="source-group-info">
                    <span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
                </div>
                ${!hasMultiple ? `<span class="source-type-badge type-${feeds[0]?.source_type || ''}">${this._sourceTypeLabel(feeds[0]?.source_type)}</span>` : ''}
                <span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
                ${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
                ${feedCountBadge}
                <div class="source-group-actions" onclick="event.stopPropagation()">
                    ${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
                    <button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
                    ${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>` : ''}
                </div>
            </div>
            ${feedRows}
        </div>`;
    },

    /**
     * URL kürzen für die Anzeige in Feed-Zeilen.
     */
    _shortenUrl(url) {
        try {
            const u = new URL(url);
            let path = u.pathname;
            if (path.length > 40) path = path.substring(0, 37) + '...';
            return u.hostname + path;
        } catch {
            return url.length > 50 ? url.substring(0, 47) + '...' : url;
        }
    },
    /**
     * Leaflet-Karte mit Locations rendern.
     */
    _map: null,
    _mapCluster: null,
    _mapCategoryLayers: {},
    _mapLegendControl: null,

    _pendingLocations: null,

    // Farbige Marker-Icons nach Kategorie (inline SVG, keine externen Ressourcen)
    _markerIcons: null,
    _createSvgIcon(fillColor, strokeColor) {
        const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="42" viewBox="0 0 28 42">` +
            `<path d="M14 0C6.27 0 0 6.27 0 14c0 10.5 14 28 14 28s14-17.5 14-28C28 6.27 21.73 0 14 0z" fill="${fillColor}" stroke="${strokeColor}" stroke-width="1.5"/>` +
            `<circle cx="14" cy="14" r="7" fill="#fff" opacity="0.9"/>` +
            `<circle cx="14" cy="14" r="4" fill="${fillColor}"/>` +
            `</svg>`;
        return L.divIcon({
            html: svg,
            className: 'map-marker-svg',
            iconSize: [28, 42],
            iconAnchor: [14, 42],
            popupAnchor: [0, -36],
        });
    },
    _initMarkerIcons() {
        if (this._markerIcons || typeof L === 'undefined') return;
        this._markerIcons = {
            primary: this._createSvgIcon('#dc3545', '#a71d2a'),
            secondary: this._createSvgIcon('#f39c12', '#c47d0a'),
            tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'),
            mentioned: this._createSvgIcon('#7b7b7b', '#555555'),
        };
    },

    _defaultCategoryLabels: {
        primary: 'Hauptgeschehen',
        secondary: 'Reaktionen',
        tertiary: 'Beteiligte',
        mentioned: 'Erwaehnt',
    },
    _categoryColors: {
        primary: '#cb2b3e',
        secondary: '#f39c12',
        tertiary: '#2a81cb',
        mentioned: '#7b7b7b',
    },

    _activeCategoryLabels: null,

    renderMap(locations, categoryLabels) {
        const container = document.getElementById('map-container');
        const emptyEl = document.getElementById('map-empty');
        const statsEl = document.getElementById('map-stats');
        if (!container) return;

        // Leaflet noch nicht geladen? Locations merken und spaeter rendern
        if (typeof L === 'undefined') {
            this._pendingLocations = locations;
            // Statistik trotzdem anzeigen
            if (locations && locations.length > 0) {
                const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
                if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
                if (emptyEl) emptyEl.style.display = 'none';
            }
            return;
        }

        if (!locations || locations.length === 0) {
            if (emptyEl) emptyEl.style.display = 'flex';
            if (statsEl) statsEl.textContent = '';
            if (this._map) {
                this._map.remove();
                this._map = null;
                this._mapCluster = null;
            }
            return;
        }

        if (emptyEl) emptyEl.style.display = 'none';

        // Statistik
        const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
        if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;

        // Container-Hoehe sicherstellen (Leaflet braucht px-Hoehe)
        const gsItem = container.closest('.grid-stack-item');
        if (gsItem) {
            const headerEl = container.closest('.map-card')?.querySelector('.card-header');
            const headerH = headerEl ? headerEl.offsetHeight : 40;
            const available = gsItem.offsetHeight - headerH - 4;
            container.style.height = Math.max(available, 200) + 'px';
        } else if (container.offsetHeight < 50) {
            container.style.height = '300px';
        }

        // Karte initialisieren oder updaten
        if (!this._map) {
            this._map = L.map(container, {
                zoomControl: true,
                attributionControl: true,
                minZoom: 2,
                maxBounds: [[-85, -180], [85, 180]],
                maxBoundsViscosity: 1.0,
            }).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum

            this._applyMapTiles();
            this._mapCluster = L.markerClusterGroup({
                maxClusterRadius: 40,
                iconCreateFunction: function(cluster) {
                    const count = cluster.getChildCount();
                    let size = 'small';
                    if (count >= 10) size = 'medium';
                    if (count >= 50) size = 'large';
                    return L.divIcon({
                        html: '<div><span>' + count + '</span></div>',
                        className: 'map-cluster map-cluster-' + size,
                        iconSize: L.point(40, 40),
                    });
                },
            });
            this._map.addLayer(this._mapCluster);
        } else {
            this._mapCluster.clearLayers();
            this._mapCategoryLayers = {};
        }

        // Marker hinzufuegen
        const bounds = [];
        this._initMarkerIcons();
        // Dynamische Labels verwenden (API > Default)
        const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels;
        this._activeCategoryLabels = catLabels;
        const usedCategories = new Set();

        locations.forEach(loc => {
            const cat = loc.category || 'mentioned';
            usedCategories.add(cat);
            const icon = (this._markerIcons && this._markerIcons[cat]) ? this._markerIcons[cat] : undefined;
            const markerOpts = icon ? { icon } : {};
            const marker = L.marker([loc.lat, loc.lon], markerOpts);

            // Popup-Inhalt
            const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat;
            const catColor = this._categoryColors[cat] || '#7b7b7b';
            let popupHtml = `<div class="map-popup">`;
            popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`;
            if (loc.country_code) popupHtml += ` <span class="map-popup-cc">${this.escape(loc.country_code)}</span>`;
            popupHtml += `</div>`;
            popupHtml += `<div class="map-popup-category" style="font-size:11px;margin-bottom:4px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${catColor};margin-right:4px;vertical-align:middle;"></span>${catLabel}</div>`;
            popupHtml += `<div class="map-popup-count">${loc.article_count} Artikel</div>`;
            popupHtml += `<div class="map-popup-articles">`;
            const maxShow = 5;
            loc.articles.slice(0, maxShow).forEach(art => {
                const headline = this.escape(art.headline || 'Ohne Titel');
                const source = this.escape(art.source || '');
                if (art.source_url) {
                    popupHtml += `<a href="${this.escape(art.source_url)}" target="_blank" rel="noopener" class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></a>`;
                } else {
                    popupHtml += `<div class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></div>`;
                }
            });
            if (loc.articles.length > maxShow) {
                popupHtml += `<div class="map-popup-more">+${loc.articles.length - maxShow} weitere</div>`;
            }
            popupHtml += `</div></div>`;

            marker.bindPopup(popupHtml, { maxWidth: 300, className: 'map-popup-container' });
            if (!this._mapCategoryLayers[cat]) this._mapCategoryLayers[cat] = L.featureGroup();
            this._mapCategoryLayers[cat].addLayer(marker);
            this._mapCluster.addLayer(marker);
            bounds.push([loc.lat, loc.lon]);
        });

        // Ansicht auf Marker zentrieren
        if (bounds.length > 0) {
            if (bounds.length === 1) {
                this._map.setView(bounds[0], 8);
            } else {
                this._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
            }
        }

        // Legende mit Checkbox-Filter
        if (this._map) {
            const existingLegend = document.querySelector('.map-legend-ctrl');
            if (existingLegend) existingLegend.remove();
            if (this._mapLegendControl) {
                try { this._map.removeControl(this._mapLegendControl); } catch(e) {}
            }

            const legend = L.control({ position: 'bottomright' });
            const self2 = this;
            const legendLabels = catLabels;
            legend.onAdd = function() {
                const div = L.DomUtil.create('div', 'map-legend-ctrl');
                L.DomEvent.disableClickPropagation(div);
                let html = '<strong style="display:block;margin-bottom:6px;">Filter</strong>';
                ['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
                    if (usedCategories.has(cat) && legendLabels[cat]) {
                        html += '<label class="map-legend-item" style="display:flex;align-items:center;gap:6px;margin:3px 0;cursor:pointer;">'
                            + '<input type="checkbox" checked data-map-cat="' + cat + '" style="accent-color:' + self2._categoryColors[cat] + ';margin:0;cursor:pointer;">'
                            + '<span style="width:10px;height:10px;border-radius:50%;background:' + self2._categoryColors[cat] + ';flex-shrink:0;"></span>'
                            + '<span>' + legendLabels[cat] + '</span></label>';
                    }
                });
                div.innerHTML = html;
                div.addEventListener('change', function(e) {
                    const cb = e.target;
                    if (!cb.dataset.mapCat) return;
                    self2._toggleMapCategory(cb.dataset.mapCat, cb.checked);
                });
                return div;
            };
            legend.addTo(this._map);
            this._mapLegendControl = legend;
        }

        // Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
        const self = this;
        [100, 300, 800].forEach(delay => {
            setTimeout(() => {
                if (!self._map) return;
                self._map.invalidateSize();
                if (bounds.length === 1) {
                    self._map.setView(bounds[0], 8);
                } else if (bounds.length > 1) {
                    self._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
                }
            }, delay);
        });
    },

    _applyMapTiles() {
        if (!this._map) return;
        // Alte Tile-Layer entfernen
        this._map.eachLayer(layer => {
            if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
        });

        // Deutsche OSM-Kacheln: deutsche Ortsnamen, einheitlich fuer beide Themes
        const tileUrl = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png';
        const attribution = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';

        L.tileLayer(tileUrl, { attribution, maxZoom: 18, noWrap: true }).addTo(this._map);
    },

    updateMapTheme() {
        this._applyMapTiles();
    },

    invalidateMap() {
        if (this._map) this._map.invalidateSize();
    },

    retryPendingMap() {
        if (this._pendingLocations && typeof L !== 'undefined') {
            const locs = this._pendingLocations;
            this._pendingLocations = null;
            this.renderMap(locs, this._activeCategoryLabels);
        }
    },

    _mapFullscreen: false,
    _mapOriginalParent: null,

    toggleMapFullscreen() {
        const overlay = document.getElementById('map-fullscreen-overlay');
        const fsContainer = document.getElementById('map-fullscreen-container');
        const mapContainer = document.getElementById('map-container');
        const statsEl = document.getElementById('map-stats');
        const fsStatsEl = document.getElementById('map-fullscreen-stats');

        if (!this._mapFullscreen) {
            // Save original parent and height
            this._mapOriginalParent = mapContainer.parentElement;
            this._savedMapHeight = mapContainer.style.height || mapContainer.offsetHeight + 'px';

            // Move entire map-container into fullscreen overlay
            fsContainer.appendChild(mapContainer);
            mapContainer.style.height = '100%';

            if (statsEl && fsStatsEl) {
                fsStatsEl.textContent = statsEl.textContent;
            }
            overlay.classList.add('active');
            this._mapFullscreen = true;

            // Escape key to close
            this._mapFsKeyHandler = (e) => { if (e.key === 'Escape') this.toggleMapFullscreen(); };
            document.addEventListener('keydown', this._mapFsKeyHandler);

            setTimeout(() => { if (this._map) this._map.invalidateSize(); }, 100);
        } else {
            // Exit fullscreen: move map-container back to original parent
            overlay.classList.remove('active');
            if (this._mapOriginalParent) {
                this._mapOriginalParent.appendChild(mapContainer);
            }
            // Restore saved height
            mapContainer.style.height = this._savedMapHeight || '';

            this._mapFullscreen = false;
            if (this._mapFsKeyHandler) {
                document.removeEventListener('keydown', this._mapFsKeyHandler);
                this._mapFsKeyHandler = null;
            }

            const self = this;
            [100, 300, 600].forEach(delay => {
                setTimeout(() => { if (self._map) self._map.invalidateSize(); }, delay);
            });
        }
    },

    _mapFsKeyHandler: null,

    _toggleMapCategory(cat, visible) {
        const layers = this._mapCategoryLayers[cat];
        if (!layers || !this._mapCluster) return;
        layers.eachLayer(marker => {
            if (visible) {
                this._mapCluster.addLayer(marker);
            } else {
                this._mapCluster.removeLayer(marker);
            }
        });
    },

    /**
     * HTML escapen.
     */
    escape(str) {
        if (!str) return '';
        const div = document.createElement('div');
        div.textContent = str;
        return div.innerHTML;
    },
};
