/* ══════════════════════════════════════════════════════════════════
   TelePush — Main Application JavaScript
   ══════════════════════════════════════════════════════════════════ */

// ── Portal-based tooltips: рендерятся в body, обходят overflow:hidden ───
// CSS-тултип клиппится родителем с overflow:hidden, поэтому рендерим
// floating-div'ом в body с position:fixed — вне любого контейнера.
(function() {
    var _floatTip = null;
    function _removeTip() {
        if (_floatTip) { try { _floatTip.remove(); } catch (_) {} _floatTip = null; }
    }
    function _showTip(el, text) {
        _removeTip();
        _floatTip = document.createElement('div');
        _floatTip.className = 'tt-floating';
        _floatTip.textContent = text;
        // Цвета берём из темы, а не свои: подсказка была на фиолетовом фоне с
        // ярко-сиреневой рамкой и выбивалась из всего остального интерфейса.
        _floatTip.style.cssText =
            'position:fixed;background:var(--bg-card,#161624);color:var(--text-primary,#c8ccd4);' +
            'padding:8px 11px;border-radius:5px;font-size:0.74rem;line-height:1.5;max-width:300px;' +
            'border:1px solid var(--border,#2a2b3a);' +
            'z-index:99999;pointer-events:none;opacity:0;transition:opacity 0.12s;' +
            'white-space:normal;text-align:left;font-weight:normal;';
        document.body.appendChild(_floatTip);
        try {
            var r = el.getBoundingClientRect();
            var tipRect = _floatTip.getBoundingClientRect();
            var vw = window.innerWidth, vh = window.innerHeight;
            // Сначала пробуем сверху
            var top = r.top - tipRect.height - 8;
            if (top < 8) top = r.bottom + 8; // если сверху не помещается — снизу
            // Центрируем по горизонтали + clamp в viewport
            var left = r.left + r.width / 2 - tipRect.width / 2;
            left = Math.max(8, Math.min(vw - tipRect.width - 8, left));
            // Если и снизу не помещается — прижимаем к низу
            if (top + tipRect.height > vh - 8) top = vh - tipRect.height - 8;
            _floatTip.style.top = top + 'px';
            _floatTip.style.left = left + 'px';
            _floatTip.style.opacity = '1';
        } catch (_) {}
    }
    document.addEventListener('mouseover', function(e) {
        if (!e.target || !e.target.closest) return;
        var el = e.target.closest('[data-tip]');
        if (!el) return;
        var tip = el.getAttribute('data-tip');
        if (!tip) return;
        _showTip(el, tip);
    }, {capture: true});
    document.addEventListener('mouseout', function(e) {
        if (!e.target || !e.target.closest) return;
        if (e.target.closest('[data-tip]')) _removeTip();
    }, {capture: true});
    // Клик/тап по информационному бейджу тоже показывает тултип и через 3.5с
    // прячет. Кнопки действий (у них onclick) НЕ трогаем — иначе перехватим клик.
    document.addEventListener('click', function(e) {
        if (!e.target || !e.target.closest) return;
        var el = e.target.closest('[data-tip]');
        if (!el) return;
        if (el.closest('button, a, [onclick], [role="button"]')) return;
        var tip = el.getAttribute('data-tip');
        if (!tip) return;
        _showTip(el, tip);
        setTimeout(_removeTip, 3500);
    });
    // При скролле/resize прячем (позиция уже несвежая)
    window.addEventListener('scroll', _removeTip, {capture: true, passive: true});
    window.addEventListener('resize', _removeTip);
    // Помечаем body чтобы CSS pseudo-element тултипы спрятались (избегаем двойного отображения)
    try { document.body.classList.add('tt-js'); } catch (_) {}
})();

// ── Spintax Preview Helper ─────────────────────────────────────
// Кнопка «Предпросмотр спинтакса» под каждым полем ввода с поддержкой {a|b|c}.
// Шлёт текст на /api/spintax/preview, отрисовывает до 5 примеров раскрытия.
// Если спинтакса в тексте нет — показывает один пример («без вариантов»).
function spintaxPreview(textareaId, previewId) {
    var ta = document.getElementById(textareaId);
    var box = document.getElementById(previewId);
    if (!ta || !box) return;
    var text = (ta.value || '').trim();
    if (!text) {
        box.style.display = 'block';
        box.innerHTML = '<div style="color:var(--text-muted);font-size:0.72rem;">Сначала введи текст</div>';
        return;
    }
    box.style.display = 'block';
    box.innerHTML = '<div style="color:var(--text-muted);font-size:0.72rem;">Генерирую варианты…</div>';
    API.post('/api/spintax/preview', { text: text, count: 5 }).then(function(d) {
        if (!d || d.error) {
            box.innerHTML = '<div style="color:var(--danger);font-size:0.72rem;">' + (d && d.error ? d.error : 'Ошибка') + '</div>';
            return;
        }
        var variants = d.variants || [];
        var hint = d.has_spintax
            ? 'Примеры раскрытия (' + variants.length + ' из возможных вариантов)'
            : 'В тексте нет спинтакса {вар1|вар2} — все юзеры получат одно и то же:';
        var html = '<div style="color:var(--text-muted);font-size:0.7rem;margin-bottom:4px;"><i class="bi bi-lightbulb"></i> ' + hint + '</div>';
        html += variants.map(function(v, i) {
            var escaped = (v || '').replace(/[<>&]/g, function(c) {
                return c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;';
            }).replace(/\n/g, '<br>');
            return '<div class="spintax-variant"><span class="spintax-num">' + (i + 1) + '</span>' + escaped + '</div>';
        }).join('');
        box.innerHTML = html;
    }).catch(function() {
        box.innerHTML = '<div style="color:var(--danger);font-size:0.72rem;">Ошибка запроса</div>';
    });
}

// ── AI Spintax Generator ──────────────────────────────────────
// Превращает шаблон в spintax-строку через пользовательский AI-ключ.
function aiSpintaxGenerate(textareaId) {
    var ta = document.getElementById(textareaId);
    if (!ta) return;
    var template = (ta.value || '').trim();
    if (!template) {
        showToast('Сначала напиши шаблон сообщения', 'warning');
        return;
    }
    // Показываем модалку с настройками генерации
    var old = document.getElementById('ai-spintax-overlay');
    if (old) old.remove();
    var overlay = document.createElement('div');
    overlay.id = 'ai-spintax-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.15s ease;';
    var popup = document.createElement('div');
    popup.style.cssText = 'min-width:420px;max-width:600px;max-height:85vh;display:flex;flex-direction:column;padding:22px;background:var(--bg-card);border:1px solid var(--border);border-radius:7px;';
    popup.innerHTML =
        '<div style="margin-bottom:14px;display:flex;align-items:center;gap:10px;">' +
            '<i class="bi bi-magic" style="font-size:1.4rem;color:var(--accent-light);"></i>' +
            '<div>' +
                '<div style="font-size:1rem;font-weight:700;color:var(--text-primary);">AI-генератор spintax</div>' +
                '<div style="font-size:0.72rem;color:var(--text-muted);">Превратит шаблон в строку с альтернативами {вариант1|вариант2}</div>' +
            '</div>' +
        '</div>' +
        '<div style="background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:10px;margin-bottom:12px;max-height:120px;overflow-y:auto;font-size:0.78rem;color:var(--text-muted);white-space:pre-wrap;">' + escHtml(template) + '</div>' +
        '<label class="form-label" style="font-size:0.78rem;">Тон (опционально)</label>' +
        '<input type="text" id="ai-sx-tone" class="form-control mb-2" placeholder="дружелюбный / формальный / короткий" style="font-size:0.82rem;">' +
        '<label class="form-label" style="font-size:0.78rem;">Целевая «плотность» вариаций (5-50)</label>' +
        '<input type="number" id="ai-sx-count" class="form-control mb-3" value="20" min="5" max="50" style="width:120px;font-size:0.82rem;">' +
        '<div id="ai-sx-result-wrap" style="display:none;margin-bottom:12px;">' +
            '<label class="form-label" style="font-size:0.78rem;">Результат (можешь править руками):</label>' +
            '<textarea id="ai-sx-result" rows="6" class="form-control" style="font-family:monospace;font-size:0.78rem;"></textarea>' +
            '<div style="font-size:0.7rem;color:var(--text-muted);margin-top:4px;" id="ai-sx-stats"></div>' +
        '</div>' +
        '<div id="ai-sx-status" style="display:none;font-size:0.8rem;margin-bottom:10px;"></div>' +
        '<div style="display:flex;gap:8px;justify-content:flex-end;">' +
            '<button class="btn btn-outline" id="ai-sx-close" style="padding:7px 18px;font-size:0.82rem;">Закрыть</button>' +
            '<button class="btn btn-accent" id="ai-sx-gen" style="padding:7px 18px;font-size:0.82rem;"><i class="bi bi-magic"></i> Сгенерировать</button>' +
            '<button class="btn btn-pink" id="ai-sx-apply" style="padding:7px 18px;font-size:0.82rem;display:none;"><i class="bi bi-check2"></i> Вставить в шаблон</button>' +
        '</div>';
    overlay.appendChild(popup);
    document.body.appendChild(overlay);
    document.getElementById('ai-sx-close').onclick = function() { overlay.remove(); };
    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };

    var genBtn = document.getElementById('ai-sx-gen');
    var applyBtn = document.getElementById('ai-sx-apply');
    var statusEl = document.getElementById('ai-sx-status');
    var resultWrap = document.getElementById('ai-sx-result-wrap');
    var resultTa = document.getElementById('ai-sx-result');
    var statsEl = document.getElementById('ai-sx-stats');

    function updateStats() {
        var s = resultTa.value || '';
        // Считаем кол-во {...|...} групп и оценочное декартово число
        var matches = s.match(/\{[^{}]+\}/g) || [];
        var combos = 1;
        matches.forEach(function(g) {
            var parts = g.slice(1, -1).split('|');
            combos *= parts.length || 1;
            if (combos > 1e9) combos = 1e9;
        });
        statsEl.innerHTML = '<i class="bi bi-info-circle"></i> Групп альтернатив: <b>' + matches.length + '</b> · уникальных комбинаций: <b>' + (combos >= 1e9 ? '>1 млрд' : combos.toLocaleString('ru-RU')) + '</b>';
    }
    resultTa && resultTa.addEventListener('input', updateStats);

    genBtn.onclick = function() {
        var tone = (document.getElementById('ai-sx-tone').value || '').trim();
        var count = parseInt(document.getElementById('ai-sx-count').value || '20', 10);
        if (isNaN(count) || count < 5) count = 20;
        genBtn.disabled = true;
        genBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Генерирую…';
        statusEl.style.display = 'block';
        statusEl.style.color = 'var(--text-muted)';
        statusEl.innerHTML = '<i class="bi bi-hourglass-split"></i> AI обрабатывает шаблон (5-30 сек)…';
        API.post('/api/ai/spintax', { template: template, count: count, tone: tone }).then(function(d) {
            genBtn.disabled = false;
            genBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Ещё раз';
            statusEl.style.display = 'none';
            if (!d || !d.ok || !d.spintax) {
                statusEl.style.display = 'block';
                statusEl.style.color = 'var(--danger)';
                statusEl.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + escHtml((d && d.error) || 'Не удалось получить ответ');
                return;
            }
            resultWrap.style.display = 'block';
            resultTa.value = d.spintax;
            updateStats();
            applyBtn.style.display = '';
        }).catch(function(e) {
            genBtn.disabled = false;
            genBtn.innerHTML = '<i class="bi bi-magic"></i> Сгенерировать';
            statusEl.style.display = 'block';
            statusEl.style.color = 'var(--danger)';
            var msg = (e && e.message) ? e.message : 'Ошибка запроса';
            statusEl.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + escHtml(msg);
        });
    };
    applyBtn.onclick = function() {
        var newTxt = resultTa.value || '';
        if (!newTxt.trim()) return;
        ta.value = newTxt;
        try { ta.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
        overlay.remove();
        showToast('Шаблон обновлён — теперь с вариантами в скобках', 'success');
    };
}

// ── Flag Helper ────────────────────────────────────────────────
// WebView2 на Windows НЕ умеет рендерить regional indicator emoji (🇻🇳) как флаг
// — показывает «VN» буквами. Возвращаемся к SVG через flagcdn.com: браузер
// кеширует один раз и переиспользует для всех табов. Это десятки запросов
// при первом открытии, но они кешируются на месяцы — на повторных открытиях
// 304 Not Modified, мгновенно.
function flagEmoji(cc) {
    // Оставлено для обратной совместимости — где-то могло вызываться.
    if (!cc || cc.length !== 2 || cc === 'xx' || cc === 'XX') return '';
    try {
        var c = cc.toUpperCase();
        return String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65, 0x1F1E6 + c.charCodeAt(1) - 65);
    } catch (e) { return ''; }
}
// Стабильный градиент для аватарок без фото — на основе hash от телефона
function _avatarBubbleGradient(phone) {
    var p = String(phone || '');
    var sum = 0;
    for (var i = 0; i < p.length; i++) sum = (sum * 31 + p.charCodeAt(i)) >>> 0;
    var palettes = [
        ['#8b5cf6', '#6366f1'],
        ['#ec4899', '#8b5cf6'],
        ['#f59e0b', '#ef4444'],
        ['#10b981', '#3b82f6'],
        ['#06b6d4', '#8b5cf6'],
        ['#ef4444', '#f59e0b'],
        ['#a855f7', '#ec4899'],
        ['#14b8a6', '#06b6d4'],
    ];
    var c = palettes[sum % palettes.length];
    return 'linear-gradient(135deg,' + c[0] + ',' + c[1] + ')';
}

function flagImg(cc) {
    if (!cc || cc === 'xx' || cc.length !== 2) return '';
    var lc = cc.toLowerCase();
    var title = cc.toUpperCase();
    // SVG-картинка через flagcdn — WebView2 НЕ рендерит regional-indicator emoji
    // как настоящий флаг (показывает буквами «VN»). Браузер кеширует один раз
    // на cc → дальше 304 Not Modified, мгновенно.
    return '<img class="cc-flag" src="https://flagcdn.com/h20/' + lc + '.png" alt="' + title +
        '" title="' + title + '" loading="lazy" ' +
        'style="height:14px;width:auto;vertical-align:middle;border-radius:2px;display:inline-block;" ' +
        'onerror="this.outerHTML=&quot;<span class=\\&quot;cc-text\\&quot; style=\\&quot;font-size:0.65rem;color:var(--text-muted);\\&quot;>' + title + '</span>&quot;">';
}

// ── Avatar cache bust (set once on load, reset on profile update) ──
var _avatarCacheBust = Date.now();

// ── Кастомные модалки в стиле app (заменяют native confirm/alert) ──

function appAlert(msg, opts) {
    opts = opts || {};
    return new Promise(function(resolve) {
        _showAppModal({
            title: opts.title || 'Сообщение',
            body: String(msg || ''),
            icon: opts.icon || 'bi-info-circle',
            danger: false,
            okLabel: opts.okLabel || 'OK',
            cancelLabel: null,
            html: !!opts.html,
            onOk: function() { resolve(); },
            onCancel: function() { resolve(); },
        });
    });
}

function appConfirm(msg, opts) {
    opts = opts || {};
    return new Promise(function(resolve) {
        _showAppModal({
            title: opts.title || 'Подтверди действие',
            body: String(msg || ''),
            icon: opts.icon || (opts.danger ? 'bi-exclamation-triangle' : 'bi-question-circle'),
            danger: !!opts.danger,
            okLabel: opts.okLabel || 'OK',
            cancelLabel: opts.cancelLabel || 'Отмена',
            onOk: function() { resolve(true); },
            onCancel: function() { resolve(false); },
        });
    });
}

function _showAppModal(cfg) {
    var prev = document.getElementById('app-modal-bd');
    if (prev) prev.remove();

    var bd = document.createElement('div');
    bd.className = 'app-modal-backdrop';
    bd.id = 'app-modal-bd';

    var box = document.createElement('div');
    box.className = 'app-modal-box';

    var title = document.createElement('div');
    title.className = 'app-modal-title' + (cfg.danger ? ' danger' : '');
    title.innerHTML = '<i class="bi ' + cfg.icon + '"></i><span></span>';
    title.querySelector('span').textContent = cfg.title;
    box.appendChild(title);

    var body = document.createElement('div');
    body.className = 'app-modal-body';
    // opt-in HTML-режим: html:true → innerHTML, иначе textContent.
    // Динамику вызывающий обязан экранировать (escHtml).
    if (cfg.html) { body.innerHTML = cfg.body; }
    else { body.textContent = cfg.body; }
    box.appendChild(body);

    var actions = document.createElement('div');
    actions.className = 'app-modal-actions';
    if (cfg.cancelLabel) {
        var cancel = document.createElement('button');
        cancel.className = 'app-modal-btn';
        cancel.textContent = cfg.cancelLabel;
        cancel.onclick = function() { bd.remove(); cfg.onCancel(); };
        actions.appendChild(cancel);
    }
    var ok = document.createElement('button');
    ok.className = 'app-modal-btn ' + (cfg.danger ? 'danger' : 'primary');
    ok.textContent = cfg.okLabel;
    ok.onclick = function() { bd.remove(); cfg.onOk(); };
    actions.appendChild(ok);
    box.appendChild(actions);

    bd.appendChild(box);
    document.body.appendChild(bd);

    // Esc = Cancel, Enter = OK
    var onKey = function(e) {
        if (e.key === 'Escape' && cfg.cancelLabel) {
            document.removeEventListener('keydown', onKey);
            bd.remove(); cfg.onCancel();
        } else if (e.key === 'Enter') {
            document.removeEventListener('keydown', onKey);
            bd.remove(); cfg.onOk();
        }
    };
    document.addEventListener('keydown', onKey);
    // Click outside = Cancel
    bd.onclick = function(e) {
        if (e.target === bd && cfg.cancelLabel) {
            document.removeEventListener('keydown', onKey);
            bd.remove(); cfg.onCancel();
        }
    };
    setTimeout(function() { ok.focus(); }, 50);
}

// ── API Helper ──────────────────────────────────────────────────

// При 401 на любом endpoint — auto-redirect на /login (слетела cookie session).
// /login через cached license проходит автологин и возвращает в /panel.
function _maybeRedirect401(status) {
    if (status !== 401) return false;
    try {
        if (window.location.pathname !== '/login') {
            window.location.href = '/login';
            return true;
        }
    } catch (_) {}
    return false;
}

// Сеть отвалилась на уровне самого запроса — fetch отдаёт «Failed to fetch»,
// и это английское сообщение браузера доезжало до клиента как есть. Своей
// программы на том конце нет только в двух случаях: она перезапускается или
// её закрыли, а страница осталась открытой. Так и говорим.
function _netError(e) {
    var err = new Error('Программа не ответила — возможно, она перезапускается '
                        + 'или была закрыта. Обнови страницу.');
    err.network = true;
    err.cause = String((e && e.message) || e || '');
    return err;
}

// Текст ошибки для показа человеку. Склейка «'Ошибка: ' + e» звала toString
// объекта Error и давала английскую приставку «Error:». Поля .error у брошенного
// объекта нет никогда — обращение к нему молча схлопывало причину в пустоту.
function _errText(e) {
    if (!e) return '';
    return String((e && e.message) || e);
}

var API = {
    post: function(url, data) {
        return fetch(url, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify(data || {})
        }).catch(function(e) { throw _netError(e); }).then(function(r) {
            if (!r.ok) return r.json().catch(function() { return {}; }).then(function(d) {
                if (_maybeRedirect401(r.status)) {
                    // Останавливаем chain — навигация идёт.
                    throw new Error('Сессия входа истекла — открываю страницу входа.');
                }
                // Throw Error с человеческим .message чтобы toString давал
                // понятный текст вместо «[object Object]».
                var msg = (d && (d.error || d.message)) || ('HTTP ' + r.status);
                var err = new Error(msg);
                err.data = d || {};
                err.status = r.status;
                throw err;
            });
            return r.json();
        });
    },
    get: function(url) {
        return fetch(url).catch(function(e) { throw _netError(e); }).then(function(r) {
            if (!r.ok) {
                // Разбираем error из тела как в POST (понятный текст вместо HTTP-кода).
                return r.json().catch(function() { return {}; }).then(function(d) {
                    if (_maybeRedirect401(r.status)) {
                        throw new Error('Сессия входа истекла — открываю страницу входа.');
                    }
                    var msg = (d && (d.error || d.message)) || ('HTTP ' + r.status);
                    var err = new Error(msg);
                    err.data = d || {};
                    err.status = r.status;
                    throw err;
                });
            }
            return r.json();
        });
    }
};

// ── Tab Navigation ──────────────────────────────────────────────

function showTab(name) {
    document.querySelectorAll('.section').forEach(function(s) {
        s.classList.remove('active');
    });
    var sec = document.getElementById('tab-' + name);
    if (sec) sec.classList.add('active');

    // Module lock check
    var moduleMap = {'spam':'spam','dm':'dm','join':'join','multi':'multi','accounts':'accounts','proxy':'proxy','parser':'parser','warmup':'warmup','profiles':'profiles','autoreply':'autoreply','templates':'templates','analytics':'analytics'};
    var module = moduleMap[name];
    if (module && _allowedModules.length && _allowedModules.indexOf(module) === -1) {
        _showModuleBlocked(name);
    }
    document.querySelectorAll('.nav-link').forEach(function(l) {
        l.classList.remove('active');
    });
    var link = document.querySelector('.nav-link[data-tab="' + name + '"]');
    if (link) link.classList.add('active');
    // Монитор аккаунтов: опрос только пока вкладка открыта.
    if (name === 'accmon') {
        if (typeof startAccmon === 'function') startAccmon();
    } else if (typeof stopAccmon === 'function') {
        stopAccmon();
    }
    // Живая лента автоответчика — тоже только пока вкладка открыта: незачем
    // дёргать сервер, когда человек смотрит другой раздел.
    if (name === 'autoreply') {
        if (typeof arFeedStart === 'function') arFeedStart();
    } else if (typeof arFeedStop === 'function') {
        arFeedStop();
    }
    // Auto-load data for tab
    if (name === 'accounts') {
        // Мгновенно отрисовываем из кэша если уже грузили — нет мерцания при
        // переходе между вкладками. Sync/scan уходят в фон.
        if (_allAccounts && _allAccounts.length) {
            try { renderAccountsTable(); updateAccStats(); } catch(e) {}
        }
        // Подгружаем путь к папке Аккаунты — баннер сверху, куда класть сессии.
        try {
            API.get('/api/paths/info').then(function(d) {
                if (!d || !d.accounts_dir) return;
                var banner = document.getElementById('accounts-path-banner');
                var val = document.getElementById('accounts-path-value');
                // Показываем только последние 2 сегмента пути (типа
                // «TelePush\Аккаунты»), не весь C:\Users\...
                var short = d.accounts_dir;
                try {
                    var parts = String(d.accounts_dir).replace(/\\/g, '/').split('/').filter(Boolean);
                    if (parts.length >= 2) short = parts[parts.length - 2] + '\\' + parts[parts.length - 1];
                    else short = parts[parts.length - 1] || d.accounts_dir;
                } catch(_){}
                if (val) {
                    val.textContent = short;
                    val.title = d.accounts_dir;  // полный путь — в title (hover)
                }
                if (banner) banner.style.display = '';
            }).catch(function(){});
        } catch (e) {}
        // Баннер «запущено >1 копии» — показать какая копия где и где аккаунты.
        try { loadMultiCopyBanner(); } catch (e) {}
        API.post('/api/accounts/sync')
            .then(function() { return API.post('/api/accounts/scan_folder'); })
            .then(loadAccounts)
            .catch(loadAccounts);
        // Авто-скан каждые 8 сек убран. Скан запускается только: при заходе на
        // вкладку (выше) + при drop файлов (handleSessionDrop) + по кнопке «Обновить».
        if (window._accountsAutoScanTimer) {
            clearInterval(window._accountsAutoScanTimer);
            window._accountsAutoScanTimer = null;
        }
    }
    if (name === 'analytics') { loadAnalytics(); if (typeof loadAdStats === 'function') loadAdStats(); }
    // handler удалён вместе с вкладкой «Шаблоны»
    if (name === 'proxy') {
        loadProxies();
        loadProxyStats();
        if (window._proxyAutoTimer) clearInterval(window._proxyAutoTimer);
        window._proxyAutoTimer = setInterval(function() {
            if (_currentTab !== 'proxy') {
                clearInterval(window._proxyAutoTimer);
                window._proxyAutoTimer = null;
                return;
            }
            loadProxies();
            loadProxyStats();
        }, 10000);
    }
    if (name === 'multi') { loadMultiAccounts(); refreshMultiTimings(); }
    if (name === 'settings') loadSettings();
    if (name === 'parser') {
        loadParserChats();
        // Восстанавливаем прошлый результат парсера при возврате на вкладку —
        // поднимаем из памяти ИЛИ с сервера.
        try {
            if (_parserLastResult && _parserLastResult.length) {
                document.getElementById('parser-result').style.display = 'block';
                var _isUC2 = _parserLastResult.length > 0 && typeof _parserLastResult[0] === 'object'
                    && _parserLastResult[0] !== null
                    && ('link' in _parserLastResult[0] || 'chat_id' in _parserLastResult[0]);
                _renderParserPreview(_parserLastResult, _isUC2);
                _renderParserResultChips(_parserLastResult, _isUC2);
            } else {
                // Память пустая (страница перезагружалась) — спросим сервер.
                API.get('/api/parser/status').then(function(s) {
                    if (s && s.status === 'finished' && s.count > 0) loadParserResult();
                }).catch(function(){});
            }
        } catch(_) {}
    }
    if (name === 'warmup') loadWarmup();
    if (name === 'profile') loadProfile();
    if (['spam', 'dm', 'join', 'parser', 'profiles', 'autoreply', 'imitation'].indexOf(name) !== -1) renderAccountSelectors();
    if (name === 'profiles') bfInitMaterialsPath();
    if (name === 'autoreply') { try { arLoadLeads(); } catch(_) {} }
    if (name === 'imitation') {
        loadImitationStatus();
        if (window._imitTimer) clearInterval(window._imitTimer);
        window._imitTimer = setInterval(function() {
            // Самоочистка при уходе со вкладки — иначе поллер /api/imitation/status
            // жил вечно после одного захода (зомби +12 req/мин).
            if (_currentTab !== 'imitation') { clearInterval(window._imitTimer); window._imitTimer = null; return; }
            loadImitationStatus();
        }, 5000);
    }
    if (name === 'boost') { onBoostTypeChange(); pollBoostStatus(); }
    if (name === 'checker') { loadCheckerConfig(); pollCheckerStatus(); renderAccountSelectors(); }
    if (name === 'number-checker') { renderAccountSelectors(); if (typeof pollNumberCheck === 'function') pollNumberCheck(); }
    // Один запрос статуса при открытии — он же приносит сохранённые настройки
    // постинга. Без этого поля показывали бы разметку, а не выбор клиента.
    if (name === 'stories') { renderAccountSelectors(); if (typeof pollStoriesStatus === 'function') pollStoriesStatus(); }
    if (name === 'chatter') { renderAccountSelectors(); if (typeof chatterPoll === 'function') chatterPoll(); if (typeof loadChatterConfig === 'function') loadChatterConfig(); }
    if (name === 'reauth') loadReauth();
    // Конвертеры подгружают acc-список такой же как reauth, так как пикер общий
    if (name === 'conv-t2s' || name === 'conv-s2t') {
        if (typeof loadReauth === 'function') loadReauth();
    }
    if (name === 'monitor') { loadMonitor(); renderAccountSelectors(); }
    if (name === 'invite') { loadInviteSettings(); refreshInviteStatus(); }
    // history/dashboard handlers удалены вместе с вкладками
}

// ── Отчёт по прогреву ─────────────────────────────────────────
function loadWarmupReport() {
    API.get('/api/warmup/report').then(function(d) {
        var box = document.getElementById('warmup-report');
        if (box) box.style.display = 'block';
        var s = d.summary || {};
        var sumEl = document.getElementById('warmup-report-summary');
        if (sumEl) {
            var parts = ['Аккаунтов: ' + (s.total || 0)];
            if (s.day) parts.push('День: ' + s.day + '/' + (s.total_days || '?'));
            if (s.total_messages !== undefined) parts.push('Сообщений всего: ' + s.total_messages);
            if (s.status) parts.push('Статус: ' + s.status);
            if (s.dead) parts.push('<span style="color:var(--danger);"><i class="bi bi-x-circle-fill"></i> мёртвых: ' + s.dead + '</span>');
            if (s.frozen) parts.push('<span style="color:#5db7ff;"><i class="bi bi-snow"></i> заморожено: ' + s.frozen + '</span>');
            if (s.floodwait) parts.push('<span style="color:var(--warning);"><i class="bi bi-hourglass-split"></i> флуд: ' + s.floodwait + '</span>');
            if (s.spam_block) parts.push('<span style="color:var(--warning);"><i class="bi bi-slash-circle-fill"></i> спам-блок: ' + s.spam_block + '</span>');
            sumEl.innerHTML = parts.join('  •  ');
        }
        var rows = d.accounts || [];
        var tbl = document.getElementById('warmup-report-table');
        if (!tbl) return;
        if (!rows.length) { tbl.innerHTML = '<div class="text-muted text-sm">Нет аккаунтов в папке «Прогрев».</div>'; return; }
        var html = rows.map(function(r) {
            var status = [];
            if (r.dead) status.push('<span style="color:var(--danger);" title="Аккаунт мёртв — сессия отозвана"><i class="bi bi-x-circle-fill"></i> мёртвый</span>');
            if (r.frozen) status.push('<span style="color:#5db7ff;" title="Заморожен Telegram — отлёжка 24+ часов"><i class="bi bi-snow"></i> заморожен</span>');
            if (r.disabled) status.push('<span style="color:var(--warning);" title="Отключён системой' + (r.disabled_reason?' — '+r.disabled_reason:'') + '"><i class="bi bi-shield-exclamation"></i> отключён' + (r.disabled_reason?' ('+r.disabled_reason+')':'') + '</span>');
            if (r.floodwait_left > 0) status.push('<span style="color:var(--warning);" title="Временный флуд-блок от Telegram"><i class="bi bi-hourglass-split"></i> флуд ' + r.floodwait_left + 'с</span>');
            if (r.spam_blocked) status.push('<span style="color:var(--warning);" title="Спам-блок: ' + (r.spam_block_note||'проверить /start @SpamBot') + '"><i class="bi bi-slash-circle-fill"></i> спам-блок</span>');
            if (!status.length) status.push('<span style="color:var(--success);" title="Аккаунт работает нормально"><i class="bi bi-check-circle-fill"></i> живой</span>');
            if (r.idle) status.push('<span style="color:var(--text-muted-strong);" title="Аккаунт в режиме сна"><i class="bi bi-moon-fill"></i> спит</span>');
            return '<tr style="border-top:1px solid #1e2433;">' +
                '<td style="padding:5px 8px;">+' + r.phone + '</td>' +
                '<td style="padding:5px 8px;color:var(--text-muted);">' + (r.username ? '@'+r.username : '—') + '</td>' +
                '<td style="padding:5px 8px;">' + status.join('&nbsp;') + '</td>' +
            '</tr>';
        }).join('');
        tbl.innerHTML = '<table style="width:100%;border-collapse:collapse;">' +
            '<thead><tr style="color:var(--text-muted);text-align:left;font-size:0.7rem;">' +
            '<th style="padding:5px 8px;">Телефон</th><th style="padding:5px 8px;">Username</th>' +
            '<th style="padding:5px 8px;">Статус</th></tr></thead><tbody>' + html + '</tbody></table>';
    }).catch(function() {});
}

// ── Аналитика прогрева ────────────────────────────────────────────
function loadWarmupAnalytics() {
    API.get('/api/warmup/analytics').then(function(d) {
        var box = document.getElementById('warmup-analytics');
        var body = document.getElementById('warmup-analytics-body');
        if (!box || !body) return;
        box.style.display = 'block';
        if (!d || !d.running) {
            body.innerHTML = '<div class="text-muted text-sm">Прогрев не запущен. Аналитика появится после первого запуска.</div>';
            return;
        }
        var k = d.kpi || {};
        var accs = d.accounts || {};
        var tod = d.today_breakdown || {};
        var pst = d.pair_states || {};
        var top = d.top_accounts || [];
        var hist = d.history || [];

        function kpi(label, value, hint, color) {
            color = color || 'var(--accent-light)';
            return '<div style="background:var(--bg-input);border-radius:5px;padding:10px 12px;min-width:130px;">' +
                '<div style="color:var(--text-muted);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.5px;">' + label + '</div>' +
                '<div style="color:' + color + ';font-weight:700;font-size:1.4rem;line-height:1.2;">' + value + '</div>' +
                (hint ? '<div style="color:var(--text-muted);font-size:0.65rem;margin-top:2px;">' + hint + '</div>' : '') +
                '</div>';
        }

        // KPI row
        var kpiRow = '<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:14px;">' +
            kpi('Сегодня', k.messages_today || 0, 'сообщений за день') +
            kpi('Всего', k.messages_total || 0, 'за весь цикл', 'var(--success)') +
            kpi('Среднее/день', k.avg_per_day || 0, 'по факту', 'var(--info)') +
            kpi('День', (k.current_day || 0) + '/' + (k.total_days || 21), 'этап: ' + (k.stage || '—'), 'var(--warning)') +
            kpi('Аккаунтов', accs.alive + '/' + accs.total, (accs.frozen||0)+' замор. · '+(accs.dead||0)+' мёртв.',
                (accs.frozen + accs.dead > accs.alive*0.2) ? 'var(--warning)' : 'var(--success)') +
            kpi('Сторож', k.watchdog_blocks || 0, 'паттернов заблокировано',
                (k.watchdog_blocks > 0) ? '#a78bfa' : 'var(--text-muted)') +
            '</div>';

        // Today breakdown
        var totalTod = (tod.chat_msgs||0) + (tod.reactions||0) + (tod.subs||0);
        function bar(label, val, color) {
            var pct = totalTod > 0 ? Math.round(val/totalTod*100) : 0;
            return '<div style="margin-bottom:6px;">' +
                '<div style="display:flex;justify-content:space-between;font-size:0.7rem;margin-bottom:2px;">' +
                '<span>' + label + '</span><span style="color:var(--text-muted);">' + val + ' · ' + pct + '%</span></div>' +
                '<div style="background:var(--bg-input);height:6px;border-radius:3px;overflow:hidden;">' +
                '<div style="width:' + pct + '%;height:100%;background:' + color + ';"></div></div></div>';
        }
        var todayHtml = '<div class="info-box" style="margin-bottom:12px;">' +
            '<div style="font-weight:700;color:var(--accent-light);margin-bottom:8px;font-size:0.75rem;">Сегодня по сценариям</div>' +
            (totalTod === 0
                ? '<div class="text-muted text-sm">Сценарные действия ещё не начались — идёт защитная имитация (см. блок ниже)</div>'
                : (bar('<i class="bi bi-chat-dots-fill" style="color:#34d399;"></i> Чат / дневники', tod.chat_msgs||0, '#34d399') +
                   bar('<i class="bi bi-hand-thumbs-up-fill" style="color:#60a5fa;"></i> Реакции', tod.reactions||0, '#60a5fa') +
                   bar('<i class="bi bi-megaphone-fill" style="color:#f59e0b;"></i> Подписки', tod.subs||0, '#f59e0b'))) +
            '</div>';

        // Защита (имитация активности) — что делают аккаунты для антипалева.
        var defStats = d.defense_stats || {};
        var defTotal = (defStats.profiles_viewed||0) + (defStats.photos_viewed||0)
                       + (defStats.messages_read||0) + (defStats.captcha_solved||0);
        var defenseHtml = '<div class="info-box" style="margin-bottom:12px;">' +
            '<div style="font-weight:700;color:var(--accent-light);margin-bottom:8px;font-size:0.75rem;">' +
            '<i class="bi bi-shield-check" style="color:#a78bfa;"></i> Защита аккаунтов (за сессию)</div>';
        if (defTotal === 0) {
            defenseHtml += '<div class="text-muted text-sm">Защитные действия пока не выполнялись. Они появятся после первого цикла имитации (5-15 мин).</div>';
        } else {
            function defRow(icon, label, val, color) {
                return '<div style="display:flex;justify-content:space-between;padding:3px 0;font-size:0.72rem;">' +
                    '<span><i class="bi ' + icon + '" style="color:' + color + ';"></i> ' + label + '</span>' +
                    '<span style="color:' + color + ';font-weight:600;">' + val + '</span></div>';
            }
            defenseHtml += defRow('bi-person-circle',     'Открыто профилей',       defStats.profiles_viewed||0, '#a78bfa');
            defenseHtml += defRow('bi-image',             'Просмотрено фото',       defStats.photos_viewed||0,   '#60a5fa');
            defenseHtml += defRow('bi-envelope-open-fill','Прочитано сообщений',    defStats.messages_read||0,   '#34d399');
            if (defStats.captcha_solved) {
                defenseHtml += defRow('bi-puzzle-fill',   'Решено капч в чатах',    defStats.captcha_solved,     '#fbbf24');
            }
            if (defStats.pre_check_runs) {
                defenseHtml += defRow('bi-shield-check',  'Pre-check @SpamBot',     defStats.pre_check_runs,     '#06b6d4');
            }
        }
        defenseHtml += '</div>';
        todayHtml += defenseHtml;

        // Pair states
        var pairTotal = Object.values(pst).reduce(function(s,n){return s+(n||0);}, 0);
        var stateColors = {chatting:'#34d399', greeting:'#f59e0b', idle:'var(--text-muted)', goodbye:'#60a5fa', done:'var(--success)'};
        var stateLabels = {chatting:'Общаются', greeting:'Знакомятся', idle:'Ждёт', goodbye:'Прощаются', done:'Готово'};
        var pairHtml = '<div class="info-box" style="margin-bottom:12px;">' +
            '<div style="font-weight:700;color:var(--accent-light);margin-bottom:8px;font-size:0.75rem;">Активные пары (' + pairTotal + ')</div>';
        if (pairTotal === 0) {
            pairHtml += '<div class="text-muted text-sm">Пары пересоберутся при следующем дне</div>';
        } else {
            pairHtml += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
            Object.keys(pst).forEach(function(s) {
                pairHtml += '<div style="background:var(--bg-input);border-radius:4px;padding:6px 12px;">' +
                    '<span style="color:' + (stateColors[s]||'var(--text-muted)') + ';font-weight:700;font-size:1.1rem;">' + pst[s] + '</span> ' +
                    '<span style="color:var(--text-muted);font-size:0.7rem;">' + (stateLabels[s] || s) + '</span></div>';
            });
            pairHtml += '</div>';
        }
        pairHtml += '</div>';

        // Top accounts
        var topHtml = '';
        if (top.length) {
            topHtml = '<div class="info-box" style="margin-bottom:12px;">' +
                '<div style="font-weight:700;color:var(--accent-light);margin-bottom:8px;font-size:0.75rem;">Топ-5 активных аккаунтов</div>' +
                '<table style="width:100%;border-collapse:collapse;font-size:0.72rem;">' +
                '<thead><tr style="color:var(--text-muted);text-align:left;"><th style="padding:4px 6px;">Телефон</th><th style="padding:4px 6px;">Каналы</th><th style="padding:4px 6px;">Чаты</th><th style="padding:4px 6px;">Сообщ.</th><th style="padding:4px 6px;">Σ</th></tr></thead><tbody>' +
                top.map(function(a) {
                    return '<tr style="border-top:1px solid #1e2433;">' +
                        '<td style="padding:5px 6px;">+' + a.phone + '</td>' +
                        '<td style="padding:5px 6px;color:var(--info);">' + (a.channels||0) + '</td>' +
                        '<td style="padding:5px 6px;color:var(--warning);">' + (a.chats||0) + '</td>' +
                        '<td style="padding:5px 6px;color:var(--success);">' + (a.chat_msgs_total||0) + '</td>' +
                        '<td style="padding:5px 6px;font-weight:700;">' + a.total + '</td></tr>';
                }).join('') + '</tbody></table></div>';
        }

        // History 7d as text mini-rows
        var histHtml = '';
        if (hist.length) {
            var maxTotal = Math.max.apply(null, hist.map(function(h){return h.total||0;})) || 1;
            histHtml = '<div class="info-box">' +
                '<div style="font-weight:700;color:var(--accent-light);margin-bottom:8px;font-size:0.75rem;">Последние ' + hist.length + ' дней</div>';
            histHtml += hist.map(function(h) {
                var w = Math.round((h.total||0) / maxTotal * 100);
                return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;font-size:0.7rem;">' +
                    '<span style="width:48px;color:var(--text-muted);">День ' + h.day + '</span>' +
                    '<div style="flex:1;background:var(--bg-input);height:14px;border-radius:3px;overflow:hidden;">' +
                    '<div style="width:' + w + '%;height:100%;background:linear-gradient(90deg,#6366f1,#8b5cf6);"></div></div>' +
                    '<span style="width:50px;text-align:right;font-weight:600;">' + (h.total||0) + '</span>' +
                    '<span style="width:60px;text-align:right;color:var(--text-muted);">' + (h.accounts_alive||0) + ' акк.</span>' +
                    '</div>';
            }).join('');
            histHtml += '</div>';
        }

        body.innerHTML = kpiRow + todayHtml + pairHtml + topHtml + histHtml;
    }).catch(function() {
        var body = document.getElementById('warmup-analytics-body');
        if (body) body.innerHTML = '<div class="text-muted text-sm">Не удалось загрузить аналитику.</div>';
    });
}

// ── Монитор (реал-тайм наблюдение за чатами) ────────────────────────
var _monitorPollTimer = null;

function loadMonitor() {
    API.get('/api/monitor/config').then(function(d) {
        var ch = document.getElementById('monitor-chats');
        var kw = document.getElementById('monitor-keywords');
        var iw = document.getElementById('monitor-ignore-words');
        var tg = document.getElementById('monitor-target');
        var adm = document.getElementById('monitor-auto-dm');
        var bcid = document.getElementById('monitor-bot-chat-id');
        var btSaved = document.getElementById('monitor-bot-token-saved');
        var bt = document.getElementById('monitor-bot-token');
        // Подтягиваем сохранённый конфиг ВСЕГДА (раньше было `if (d.chats)` —
        // при пустом ответе textarea оставалась пустой даже если монитор
        // работал с 35 чатами из autoload. Клиент видел пустое поле и думал
        // что окно не работает).
        if (ch && typeof d.chats === 'string') ch.value = d.chats;
        if (kw && typeof d.keywords === 'string') kw.value = d.keywords;
        if (iw && typeof d.ignore_words === 'string') iw.value = d.ignore_words;
        if (tg && d.target) tg.value = d.target;
        var ep = document.getElementById('monitor-extra-phones');
        var mm = document.getElementById('monitor-multi-mode');
        // Подтягиваем сохранённые "доп акки" — это все phones кроме первого
        // (первый — основной из acc-selector). Чтобы UI не показывал
        // его дважды.
        if (ep && Array.isArray(d.phones) && d.phones.length > 1) {
            ep.value = d.phones.slice(1).join('\n');
        }
        if (mm && d.multi_mode) mm.value = d.multi_mode;
        if (adm && typeof d.auto_dm_text === 'string') adm.value = d.auto_dm_text;
        if (bcid && typeof d.bot_chat_id === 'string') bcid.value = d.bot_chat_id;
        var brEl = document.getElementById('monitor-bot-reports');
        if (brEl) brEl.checked = !!d.bot_reports_enabled;
        // Токен не показываем — только флаг «сохранён» + маску в placeholder
        if (bt && d.bot_token_set) {
            bt.placeholder = d.bot_token_masked || '•••• сохранён';
            if (btSaved) btSaved.style.display = '';
        } else if (bt) {
            bt.placeholder = '123456789:AA...';
            if (btSaved) btSaved.style.display = 'none';
        }
        // Fallback-блок раскрыт по умолчанию только если бот НЕ настроен —
        // чтобы клиент сразу видел куда пойдут уведомления если бот пустой.
        // Если бот есть — fallback свёрнут (он не используется).
        var fb = document.getElementById('monitor-fallback-wrap');
        if (fb) fb.open = !d.bot_token_set;
        _updateMonitorUI(d);
        updateContactedCount();
    }).catch(function() {});
}

function toggleBotReports(enabled) {
    // Шлём token+chat_id из полей — иначе включить отчёты нельзя было без
    // предварительного запуска keyword-мониторинга (токен нигде не сохранялся).
    var _tk = ((document.getElementById('monitor-bot-token') || {}).value || '').trim();
    var _ch = ((document.getElementById('monitor-bot-chat-id') || {}).value || '').trim();
    API.post('/api/monitor/bot_reports', { enabled: enabled, token: _tk, chat_id: _ch }).then(function(d) {
        if (d && d.ok) {
            showToast(enabled ? 'Отчёты в бот включены — напиши боту /start в Telegram' : 'Отчёты в бот выключены', enabled ? 'success' : 'info');
        } else {
            showToast((d && d.error) || 'Не удалось переключить', 'error');
            var el = document.getElementById('monitor-bot-reports');
            if (el) el.checked = !enabled;
        }
    }).catch(function() {
        var el = document.getElementById('monitor-bot-reports');
        if (el) el.checked = !enabled;
    });
}

function _updateMonitorUI(d) {
    var running = !!(d && d.running);
    var status = document.getElementById('monitor-status');
    var btnStart = document.getElementById('btn-monitor-start');
    var btnStop = document.getElementById('btn-monitor-stop');
    if (status) status.style.display = running ? 'block' : 'none';
    if (btnStart) btnStart.style.display = running ? 'none' : '';
    if (btnStop) btnStop.style.display = running ? '' : 'none';
    if (running && d.stats) {
        var m = document.getElementById('monitor-matched');
        if (m) m.textContent = 'Алертов: ' + (d.stats.matched || 0);
        var info = document.getElementById('monitor-info');
        if (info) {
            var parts = ['Чатов подписано: ' + (d.stats.resolved || 0)];
            if (d.stats.auto_dm_sent) {
                parts.push('Авто-DM отправлено: ' + d.stats.auto_dm_sent);
            }
            if (d.stats.auto_dm_skipped_dup) {
                parts.push('Дубли пропущено: ' + d.stats.auto_dm_skipped_dup);
            }
            if (d.stats.failed && d.stats.failed.length) {
                parts.push('Не открылись: ' + d.stats.failed.length);
            }
            info.textContent = parts.join('  •  ');
        }
    }
    // Polling работает пока открыта вкладка Мониторинг — даже когда running=false,
    // чтобы подхватить старт из scheduler-autoload (клиент открыл вкладку ДО того
    // как scheduler стартовал монитор, тогда initial load видел running=false,
    // таймер не запускался, UI не обновлялся, клиент видел «Старт» у работающего
    // мониторинга). Таймер гасится только если вкладка не Monitor.
    if (_currentTab === 'monitor' && !_monitorPollTimer) {
        _monitorPollTimer = setInterval(function() {
            if (_currentTab !== 'monitor') {
                clearInterval(_monitorPollTimer); _monitorPollTimer = null;
                return;
            }
            API.get('/api/monitor/config').then(function(dd) {
                _updateMonitorUI(dd);
            }).catch(function() {});
        }, 3000);
    } else if (_currentTab !== 'monitor' && _monitorPollTimer) {
        clearInterval(_monitorPollTimer); _monitorPollTimer = null;
    }
}

function startMonitor() {
    var chats = (document.getElementById('monitor-chats') || {}).value || '';
    var keywords = (document.getElementById('monitor-keywords') || {}).value || '';
    var ignoreWords = (document.getElementById('monitor-ignore-words') || {}).value || '';
    var extraPhonesRaw = (document.getElementById('monitor-extra-phones') || {}).value || '';
    var multiMode = (document.getElementById('monitor-multi-mode') || {}).value || 'redundant';
    var target = (document.getElementById('monitor-target') || {}).value || 'me';
    var autoDm = (document.getElementById('monitor-auto-dm') || {}).value || '';
    var botToken = (document.getElementById('monitor-bot-token') || {}).value || '';
    var botChatId = (document.getElementById('monitor-bot-chat-id') || {}).value || '';
    if (!chats.trim()) { showToast('Добавьте чаты', 'warning'); return; }
    if (!keywords.trim()) { showToast('Добавьте ключевые слова', 'warning'); return; }
    // Приоритет: акк из папки «Мониторинг» → потом глобальный активный.
    // Раньше всегда брался _cachedActive, и софт стартовал не тот акк который
    // лежит в папке Мониторинг/.
    var phone = '';
    var monAcc = (_cachedAccounts || []).find(function(a) {
        return (a.folder || '') === 'Мониторинг' && !a.dead && !a.frozen;
    });
    if (monAcc) phone = monAcc.phone;
    if (!phone) phone = _cachedActive || '';
    // Собираем список телефонов: основной + допы из textarea
    var phones = phone ? [phone] : [];
    extraPhonesRaw.split(/[\n\r,;]+/).forEach(function(p) {
        p = (p || '').trim().replace(/^\+/, '').replace(/\D/g, '');
        if (p && phones.indexOf(p) === -1) phones.push(p);
    });
    var payload = {
        chats: chats, keywords: keywords,
        ignore_words: ignoreWords.trim(),
        target: target,
        phones: phones,
        phone: phone,  // для совместимости со старым бэком
        multi_mode: multiMode,
        auto_dm_text: autoDm.trim(),
        bot_chat_id: botChatId.trim(),
    };
    if (botToken.trim()) payload.bot_token = botToken.trim();
    else payload.bot_token_keep = true;
    payload.ad_config = readAdConfig('monitor');
    API.post('/api/monitor/start', payload).then(function(d) {
        if (d.ok) {
            var msg = 'Мониторинг запущен: ' + d.chats + ' чатов, ' + d.keywords + ' ключей';
            if (autoDm.trim()) msg += ' + авто-DM автору коммента';
            showToast(msg, 'success');
            loadMonitor();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) {
        var msg = (e && e.message) || 'Ошибка';
        showToast(msg, 'error');
        // Если «уже запущен» — это значит UI был stale, подтягиваем свежий статус
        // чтобы показалась кнопка Стоп (клиент жаловался: вижу Старт у работающего
        // мониторинга, жму — говорит «уже запущен»).
        if (msg.indexOf('уже запущен') !== -1) {
            loadMonitor();
        }
    });
}

async function resetContactedList() {
    if (!(await appConfirm('Сбросить список «кому уже писали»? После этого авто-DM начнёт писать тем же юзерам заново.', {danger: true}))) return;
    API.post('/api/monitor/contacted_reset', {}).then(function(d) {
        if (d.ok) {
            showToast('Список сброшен. Следующий матч снова напишет.', 'success');
            updateContactedCount();
        }
    }).catch(function(e) { showToast((e && e.message) || 'Ошибка', 'error'); });
}

function detectChatId() {
    var tokEl = document.getElementById('monitor-bot-token');
    var cidEl = document.getElementById('monitor-bot-chat-id');
    var tokVal = tokEl ? tokEl.value.trim() : '';
    showToast('Запрашиваю getUpdates у бота...', 'info');
    API.post('/api/monitor/detect_chat_id', {token: tokVal}).then(function(d) {
        if (d.ok) {
            if (cidEl) cidEl.value = d.chat_id;
            var msg = 'Chat ID найден: ' + d.chat_id;
            if (d.user_info) msg += ' (' + d.user_info + ')';
            showToast(msg, 'success');
        } else {
            showToast(d.error || 'Не удалось определить', 'warning');
        }
    }).catch(function(e) {
        showToast((e && e.message) || 'Ошибка', 'error');
    });
}

function resumeMonitor() {
    // Восстановить мониторинг из сохранённого конфига (после рестарта софта он
    // не возобновляется сам).
    API.get('/api/monitor/config').then(function(d) {
        if (!d || (!((d.chats || '').trim()) && !((d.keywords || '').trim()))) {
            showToast('Нет сохранённого мониторинга для восстановления', 'info');
            return;
        }
        var set = function(id, v) { var e = document.getElementById(id); if (e) e.value = v || ''; };
        set('monitor-chats', d.chats);
        set('monitor-keywords', d.keywords);
        set('monitor-ignore-words', d.ignore_words);
        set('monitor-target', d.target);
        set('monitor-multi-mode', d.multi_mode);
        set('monitor-auto-dm', d.auto_dm_text);
        set('monitor-bot-chat-id', d.bot_chat_id);
        set('monitor-extra-phones', (d.phones || []).join('\n'));  // доп. акки мульти-мониторинга
        // bot-token поле НЕ трогаем (пусто) → startMonitor пошлёт bot_token_keep=true,
        // бэкенд подхватит сохранённый зашифрованный токен.
        showToast('Восстанавливаю мониторинг из сохранённого конфига…', 'info');
        setTimeout(startMonitor, 120);
    }).catch(function(e) { showToast('Ошибка: ' + ((e && e.message) || e), 'error'); });
}

function testMonitorBot() {
    var tokEl = document.getElementById('monitor-bot-token');
    var cidEl = document.getElementById('monitor-bot-chat-id');
    // Если поле токена пустое — сервер возьмёт сохранённый
    var payload = {
        token: (tokEl ? tokEl.value.trim() : ''),
        chat_id: (cidEl ? cidEl.value.trim() : ''),
    };
    showToast('Отправляю тестовое сообщение...', 'info');
    API.post('/api/monitor/test_bot', payload).then(function(d) {
        if (d.ok) {
            showToast(d.message || 'Тест ушёл', 'success');
        } else {
            showToast(d.error || 'Не получилось', 'error');
        }
    }).catch(function(e) {
        showToast((e && e.message) || 'Ошибка', 'error');
    });
}

function addToIgnoreList() {
    var inp = document.getElementById('monitor-ignore-input');
    if (!inp) return;
    var val = (inp.value || '').trim();
    if (!val) { showToast('Впишите @юзернейм или числовой ID', 'warning'); return; }
    API.post('/api/monitor/contacted_add', {user: val}).then(function(d) {
        if (d.ok) {
            showToast('Добавлен в исключения: ' + d.user_id + ' (всего ' + d.total + ')', 'success');
            inp.value = '';
            updateContactedCount();
        } else {
            showToast(d.error || 'Не удалось', 'error');
        }
    }).catch(function(e) {
        showToast((e && e.message) || 'Ошибка', 'error');
    });
}

function openIgnoreDb() {
    var modal = document.createElement('div');
    modal.id = 'ignore-db-modal';
    modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:600;display:flex;align-items:center;justify-content:center;';
    modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
    modal.innerHTML =
        '<div style="background:var(--bg-card-hover);border:1px solid var(--border);border-radius:7px;padding:18px;max-width:640px;width:92%;max-height:88vh;display:flex;flex-direction:column;">' +
            '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">' +
                '<h3 style="color:#fff;font-size:1.05rem;margin:0;"><i class="bi bi-database"></i> База игнор-листа мониторинга</h3>' +
                '<button class="btn btn-outline btn-sm" style="padding:2px 10px;font-size:0.7rem;" onclick="document.getElementById(\'ignore-db-modal\').remove()">✕</button>' +
            '</div>' +
            '<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;">' +
                '<input type="text" id="ignore-db-search" placeholder="Поиск по id..." oninput="_loadIgnoreDb(0)" style="flex:1;min-width:140px;padding:5px 10px;font-size:0.78rem;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);">' +
                '<button class="btn btn-outline btn-sm" style="padding:4px 10px;font-size:0.7rem;" onclick="_importIgnoreDb()" title="Импорт из текста"><i class="bi bi-cloud-upload"></i> Импорт</button>' +
                '<button class="btn btn-outline btn-sm" style="padding:4px 10px;font-size:0.7rem;" onclick="_exportIgnoreDb()" title="Сохранить .txt"><i class="bi bi-download"></i> Экспорт</button>' +
            '</div>' +
            '<div id="ignore-db-stats" style="font-size:0.72rem;color:var(--text-muted);margin-bottom:8px;">Загрузка…</div>' +
            '<div id="ignore-db-list" style="flex:1;overflow-y:auto;background:var(--bg-input);border:1px solid var(--border);border-radius:5px;padding:6px;font-family:monospace;font-size:0.78rem;min-height:200px;"></div>' +
            '<div style="margin-top:10px;display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap;">' +
                '<button class="btn btn-outline btn-sm" style="padding:4px 12px;font-size:0.72rem;" onclick="_loadIgnoreDb(0)"><i class="bi bi-arrow-clockwise"></i> Обновить</button>' +
                '<button class="btn btn-danger btn-sm" style="padding:4px 12px;font-size:0.72rem;" onclick="resetContactedList(); document.getElementById(\'ignore-db-modal\').remove();"><i class="bi bi-trash"></i> Очистить всё</button>' +
            '</div>' +
        '</div>';
    document.body.appendChild(modal);
    _loadIgnoreDb(0);
}
var _ignoreDbOffset = 0;
var _ignoreDbLimit = 500;
function _loadIgnoreDb(offset) {
    _ignoreDbOffset = offset || 0;
    var q = (document.getElementById('ignore-db-search') || {}).value || '';
    var stats = document.getElementById('ignore-db-stats');
    var list = document.getElementById('ignore-db-list');
    if (!list) return;
    list.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;"><i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;"></i></div>';
    API.get('/api/monitor/contacted_list?offset=' + _ignoreDbOffset + '&limit=' + _ignoreDbLimit + (q ? '&q=' + encodeURIComponent(q) : '')).then(function(d) {
        if (!d.ok) {
            list.innerHTML = '<div style="color:#f87171;padding:20px;">' + escHtml(d.error || 'Ошибка') + '</div>';
            return;
        }
        if (stats) stats.textContent = 'Всего: ' + d.total + (q ? ' (фильтр)' : '') + ' • Показано: ' + (d.ids || []).length + ' (с ' + d.offset + ')';
        if (!d.ids || !d.ids.length) {
            list.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;">База пуста</div>';
            return;
        }
        list.innerHTML = d.ids.map(function(id) {
            return '<div style="display:flex;align-items:center;gap:10px;padding:4px 8px;border-bottom:1px solid #1a1a2a;">' +
                '<span style="flex:1;color:var(--text-primary);">' + id + '</span>' +
                '<button onclick="_removeFromIgnore(' + id + ')" class="btn btn-outline btn-sm" style="padding:1px 8px;font-size:0.65rem;color:var(--danger);" title="Убрать из игнора"><i class="bi bi-x"></i></button>' +
            '</div>';
        }).join('');
        // пагинация
        if (d.total > _ignoreDbOffset + d.ids.length) {
            list.innerHTML += '<div style="text-align:center;padding:8px;"><button class="btn btn-outline btn-sm" style="padding:3px 14px;font-size:0.7rem;" onclick="_loadIgnoreDb(' + (_ignoreDbOffset + _ignoreDbLimit) + ')">Показать ещё</button></div>';
        }
    }).catch(function() {
        list.innerHTML = '<div style="color:#f87171;padding:20px;">Ошибка сети</div>';
    });
}
function _removeFromIgnore(id) {
    API.post('/api/monitor/contacted_remove', {ids: [id]}).then(function(d) {
        if (d.ok) {
            showToast('Удалён id=' + id + ' (осталось ' + d.total + ')', 'success');
            _loadIgnoreDb(_ignoreDbOffset);
            updateContactedCount();
        } else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() { showToast('Ошибка сети', 'error'); });
}
function _importIgnoreDb() {
    var modal = document.createElement('div');
    modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:700;display:flex;align-items:center;justify-content:center;';
    modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
    modal.innerHTML =
        '<div style="background:var(--bg-card-hover);border:1px solid var(--border);border-radius:7px;padding:18px;max-width:480px;width:90%;">' +
            '<h3 style="color:#fff;font-size:1rem;margin-bottom:10px;"><i class="bi bi-cloud-upload"></i> Импорт user_id в игнор-базу</h3>' +
            '<textarea id="_ig-import-text" placeholder="123456789\n987654321\n555111222" rows="8" style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:8px 12px;font-family:monospace;font-size:0.78rem;outline:none;"></textarea>' +
            '<div style="font-size:0.7rem;color:var(--text-muted);margin-top:6px;">По одному id на строке (или через запятую). @username не поддерживается — нужны цифровые id.</div>' +
            '<div style="display:flex;gap:6px;margin-top:10px;align-items:center;">' +
                '<label style="display:flex;align-items:center;gap:6px;font-size:0.75rem;cursor:pointer;"><input type="radio" name="_ig-mode" value="merge" checked> Дополнить</label>' +
                '<label style="display:flex;align-items:center;gap:6px;font-size:0.75rem;cursor:pointer;"><input type="radio" name="_ig-mode" value="replace"> Заменить полностью</label>' +
                '<button class="btn btn-accent btn-sm" style="margin-left:auto;padding:5px 14px;font-size:0.75rem;" onclick="_doImportIgnore(this)">Загрузить</button>' +
                '<button class="btn btn-outline btn-sm" style="padding:5px 12px;font-size:0.75rem;" onclick="this.closest(\'div[style*=fixed]\').remove();">Отмена</button>' +
            '</div>' +
        '</div>';
    document.body.appendChild(modal);
}
function _doImportIgnore(btn) {
    var text = (document.getElementById('_ig-import-text') || {}).value || '';
    var mode = 'merge';
    document.querySelectorAll('input[name="_ig-mode"]').forEach(function(r) { if (r.checked) mode = r.value; });
    if (!text.trim()) { showToast('Текст пустой', 'warning'); return; }
    API.post('/api/monitor/contacted_import', {text: text, mode: mode}).then(function(d) {
        if (d.ok) {
            showToast('Импортировано: +' + d.added + ' (распознано ' + d.parsed + ', всего ' + d.total + ')', 'success');
            btn.closest('div[style*="fixed"]').remove();
            _loadIgnoreDb(0);
            updateContactedCount();
        } else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() { showToast('Ошибка сети', 'error'); });
}
function _exportIgnoreDb() {
    API.get('/api/monitor/contacted_export').then(function(d) {
        if (d.ok) showToast('Сохранено в ' + d.path + ' (' + d.count + ' id)', 'success');
        else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() { showToast('Ошибка сети', 'error'); });
}

function updateContactedCount() {
    var el = document.getElementById('monitor-contacted-count');
    if (!el) return;
    API.get('/api/monitor/contacted_count').then(function(d) {
        if (d && typeof d.count === 'number') {
            el.textContent = 'В базе: ' + d.count + ' юзеров';
            el.style.color = d.count > 0 ? 'var(--accent-light)' : 'var(--text-muted)';
        }
    }).catch(function() {});
}

// Общий хелпер — выгрузка результатов задачи в папку с категориями
function _doExport(url, label) {
    showToast('Раскладываю результаты ' + label + '...', 'info');
    API.get(url).then(function(d) {
        if (d && d.ok) {
            var parts = [];
            var sum = d.summary || {};
            for (var k in sum) parts.push(k + ': ' + sum[k]);
            showToast(parts.join(' • ') || ('Всего: ' + (d.ok_count + d.fail_count)), 'success');
            if (typeof addLog === 'function') addLog(label + ': ' + (d.folder || ''), 'SUCCESS');
        } else {
            showToast((d && d.error) || 'Ошибка', 'error');
        }
    }).catch(function(e) { showToast(_errText(e) || 'Ошибка', 'error'); });
}

function exportJoinResults() { _doExport('/api/join/export_results', 'подписки'); }
function exportSpamResults() { _doExport('/api/spam/export_results', 'рассылки'); }
function exportDmResults()   { _doExport('/api/dm/export_results', 'DM'); }

// Повторить failed — оставить в поле целей только те, где НЕ получилось.
// Для Multi-DM/рассылки фильтруем по МЕТКАМ в самом поле (они в localStorage и
// переживают рестарт), а не по /api/retry_failed — у мульти-задачи job.results
// пуст после завершения. Семантика меток — utils/result_marks.py:
//   ok / notfound / privacy → УБИРАЕМ (отправлено / писать смысла нет)
//   spamblock / blocked / без-метки → ОСТАВЛЯЕМ (другой акк / задача не дошла)
function _retryFromMarks(kind, inputId) {
    var ta = document.getElementById(inputId);
    if (!ta) { showToast('Поле целей не найдено', 'error'); return; }
    var DROP = {ok: 1, notfound: 1, privacy: 1};
    var kept = [], nRetry = 0, nSent = 0, nCant = 0;
    (ta.value || '').split(/\r?\n/).forEach(function(line) {
        var raw = line.replace(/\s+$/, '');
        if (!raw.trim()) return;                        // пустые строки убираем
        var m = _TP_MARK_RE.exec(raw);
        var mark = m ? m[1].toLowerCase() : '';
        if (DROP[mark]) { if (mark === 'ok') nSent++; else nCant++; return; }
        kept.push(raw.replace(_TP_MARK_RE, '').trim()); // снимаем метку (spamblock/blocked) или как есть
        nRetry++;
    });
    ta.value = kept.join('\n');
    try { ta.dispatchEvent(new Event('input', {bubbles: true})); } catch (e) {}
    // Чистим серверный store меток — иначе 4-сек поллинг applyMultiMarks тут же
    // вернёт снятые метки на оставшиеся строки (spamblock/blocked).
    try { API.post('/api/multi/marks/clear', {type: kind}); } catch (e) {}
    if (!nRetry) {
        showToast('Повторять нечего — всё отправлено или писать нельзя (убрано ' + (nSent + nCant) + ')', 'info');
        return;
    }
    var parts = ['Оставлено для повтора: ' + nRetry];
    if (nSent) parts.push('убрано отправленных: ' + nSent);
    if (nCant) parts.push('убрано (писать нельзя): ' + nCant);
    showToast(parts.join(' · ') + '. Выберите аккаунт и нажмите Старт.', 'success');
}
function _retryFailed(kind, inputId) {
    // Multi-DM/рассылка с метками в поле → фильтруем по ним (надёжнее серверного).
    if (kind === 'dm' || kind === 'spam') {
        var ta = document.getElementById(inputId);
        if (ta && (ta.value || '').split(/\r?\n/).some(function(l) { return _TP_MARK_RE.test(l); })) {
            _retryFromMarks(kind, inputId);
            return;
        }
    }
    // Фолбэк (single-acc без меток / подписка): серверный сбор результатов.
    API.post('/api/retry_failed', {kind: kind}).then(function(d) {
        if (!d.ok) { showToast(d.error || 'Ошибка', 'error'); return; }
        if (!d.count) { showToast('Нет неудачных целей — нечего повторять', 'info'); return; }
        var el = document.getElementById(inputId);
        if (!el) return;
        el.value = d.items.join('\n');
        el.dispatchEvent(new Event('input'));
        showToast('Загружено ' + d.count + ' целей для повтора. Выберите аккаунт и нажмите Старт.', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}
function retryFailedJoin() { _retryFailed('join', 'join-links'); }
function retryFailedSpam() { _retryFailed('spam', 'spam-targets-input'); }
function retryFailedDm()   { _retryFailed('dm', 'dm-targets-input'); }

// Копирование в буфер — для текстовых превью (парсер/подписка)
function copyToClipboard(text, label) {
    if (!text) { showToast('Нечего копировать', 'warning'); return; }
    try {
        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(text).then(function() {
                showToast((label || 'Скопировано') + ' (' + text.split('\n').length + ' строк)', 'success');
            }).catch(function() {
                _fallbackCopy(text, label);
            });
        } else {
            _fallbackCopy(text, label);
        }
    } catch (e) { _fallbackCopy(text, label); }
}
function _fallbackCopy(text, label) {
    try {
        var ta = document.createElement('textarea');
        ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
        document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
        showToast((label || 'Скопировано') + ' (' + text.split('\n').length + ' строк)', 'success');
    } catch (e) { showToast('Копирование не сработало', 'error'); }
}

function openMonitorLog() {
    API.post('/api/monitor/open_log', {}).then(function(d) {
        if (!d.ok) showToast(d.error || 'Лог не найден', 'warning');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function stopMonitor() {
    API.post('/api/monitor/stop', {}).then(function(d) {
        if (d.ok) {
            showToast('Мониторинг остановлен. Алертов: ' + (d.matched || 0), 'info');
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
        loadMonitor();
    }).catch(function(e) {
        // Раньше ловили молча — клиент жал «Стоп», ничего не происходило,
        // казалось что кнопка не реагирует. Теперь явно показываем ошибку
        // и в любом случае подтягиваем актуальный статус мониторинга.
        showToast((e && e.message) || 'Не удалось остановить мониторинг', 'error');
        loadMonitor();
    });
}

// ── Toast Notifications ─────────────────────────────────────────

// Окно «Что нового» — модалка с changelog'ом из /api/version.
function showWhatsNew() {
    var old = document.getElementById('whatsnew-overlay');
    if (old) old.remove();

    var overlay = document.createElement('div');
    overlay.id = 'whatsnew-overlay';
    overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.15s ease;';

    var popup = document.createElement('div');
    popup.style.cssText = 'min-width:420px;max-width:560px;max-height:80vh;display:flex;flex-direction:column;padding:0;background:var(--bg-card);border:1px solid var(--border);border-radius:7px;overflow:hidden;';

    popup.innerHTML =
        '<div style="padding:18px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;">' +
            '<i class="bi bi-stars" style="font-size:1.4rem;color:var(--accent-light);"></i>' +
            '<div style="flex:1;">' +
                '<div style="font-size:1rem;font-weight:700;color:var(--text-primary);">Что нового</div>' +
                '<div style="font-size:0.72rem;color:var(--text-muted);" id="whatsnew-version">Загружаю…</div>' +
            '</div>' +
            '<button id="whatsnew-close" style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:1.4rem;padding:0 4px;line-height:1;">×</button>' +
        '</div>' +
        '<div id="whatsnew-body" style="padding:18px 22px;overflow-y:auto;font-size:0.85rem;line-height:1.6;color:var(--text-primary);white-space:pre-wrap;">Загружаю changelog…</div>' +
        '<div style="padding:12px 22px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;">' +
            '<button class="btn btn-accent" id="whatsnew-ok" style="padding:7px 22px;font-size:0.82rem;">Понятно</button>' +
        '</div>';

    overlay.appendChild(popup);
    document.body.appendChild(overlay);

    function close() { overlay.remove(); }
    document.getElementById('whatsnew-close').onclick = close;
    document.getElementById('whatsnew-ok').onclick = close;
    overlay.onclick = function(e) { if (e.target === overlay) close(); };

    // Грузим changelog с сервера
    API.get('/api/version').then(function(d) {
        var v = (d && d.version) || '';
        var txt = (d && d.changelog) || '';
        var verEl = document.getElementById('whatsnew-version');
        if (verEl) verEl.textContent = 'Версия ' + v;
        var body = document.getElementById('whatsnew-body');
        if (body) {
            if (txt.trim()) {
                body.textContent = txt;
            } else {
                body.innerHTML = '<span class="muted">Описание изменений не указано.</span>';
            }
        }
        // Запоминаем что юзер уже увидел эту версию — не показывать тост заново.
        try { localStorage.setItem('tp_whatsnew_seen', v); } catch (e) {}
    }).catch(function(e) {
        var body = document.getElementById('whatsnew-body');
        if (body) body.innerHTML = '<span class="muted">Не удалось загрузить changelog: ' + escHtml(String(e && e.message || e)) + '</span>';
    });
}

function showConfirmDialog(title, text, onConfirm, onCancel) {
    var old = document.getElementById('confirm-dialog-overlay');
    if (old) old.remove();

    var overlay = document.createElement('div');
    overlay.id = 'confirm-dialog-overlay';
    overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.15s ease;';

    var popup = document.createElement('div');
    popup.style.cssText = 'min-width:340px;max-width:420px;text-align:center;padding:28px 24px;background:var(--bg-card);border:1px solid var(--border);border-radius:7px;';
    popup.innerHTML =
        '<div style="margin-bottom:16px;">' +
            '<i class="bi bi-exclamation-triangle" style="font-size:2.2rem;color:var(--warning);"></i>' +
        '</div>' +
        '<div style="font-size:1rem;font-weight:700;color:var(--text-primary);margin-bottom:8px;">' + escHtml(title || 'Подтверждение') + '</div>' +
        '<div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:24px;line-height:1.5;">' + escHtml(text || '') + '</div>' +
        '<div style="display:flex;gap:10px;justify-content:center;">' +
            '<button class="btn btn-accent" id="confirm-dialog-ok" style="padding:8px 28px;font-size:0.85rem;">Да, продолжить</button>' +
            '<button class="btn btn-outline" id="confirm-dialog-cancel" style="padding:8px 28px;font-size:0.85rem;">Отмена</button>' +
        '</div>';

    overlay.appendChild(popup);
    document.body.appendChild(overlay);

    document.getElementById('confirm-dialog-ok').onclick = function() {
        overlay.remove();
        if (onConfirm) onConfirm();
    };
    document.getElementById('confirm-dialog-cancel').onclick = function() {
        overlay.remove();
        if (onCancel) onCancel();
    };
    overlay.onclick = function(e) {
        if (e.target === overlay) {
            overlay.remove();
            if (onCancel) onCancel();
        }
    };
}

// Пред-стартовое подтверждение для мульти-операций: показывает полный список
// акков-отправителей (включая невидимые кросс-папочные из _multiSelected),
// чтобы юзер подтвердил или отменил.
function showMultiPreflightConfirm(jobLabel, phones, onConfirm) {
    var old = document.getElementById('preflight-confirm-overlay');
    if (old) old.remove();
    var overlay = document.createElement('div');
    overlay.id = 'preflight-confirm-overlay';
    overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.15s ease;';
    var popup = document.createElement('div');
    popup.style.cssText = 'min-width:380px;max-width:520px;max-height:80vh;display:flex;flex-direction:column;padding:24px;background:var(--bg-card);border:1px solid var(--border);border-radius:7px;';
    var head =
        '<div style="margin-bottom:14px;display:flex;align-items:center;gap:10px;">' +
            '<i class="bi bi-shield-exclamation" style="font-size:1.6rem;color:var(--warning);"></i>' +
            '<div>' +
                '<div style="font-size:1.05rem;font-weight:700;color:var(--text-primary);">Проверь акки-отправители</div>' +
                '<div style="font-size:0.78rem;color:var(--text-muted);">' + escHtml(jobLabel) + ' — будет запущено с этих ' + phones.length + ' акков</div>' +
            '</div>' +
        '</div>';
    // Пометка акков что сейчас в прогреве — risk высокий, юзер должен видеть.
    var warmingSet = {};
    var freshSet = {};  // акки зарегистрированы < FRESH_HOURS ч назад
    var FRESH_HOURS = 3;
    var nowSec = Date.now() / 1000;
    try {
        (_allAccounts || []).forEach(function(a) {
            if (!a || !a.phone) return;
            if (a.warming_now) warmingSet[String(a.phone)] = (a.warming_elapsed_min || 0) + '/' + (a.warming_total_min || 15);
            var rt = parseInt(a.register_time || 0, 10);
            if (rt > 0) {
                var ageH = (nowSec - rt) / 3600;
                if (ageH < FRESH_HOURS) {
                    freshSet[String(a.phone)] = Math.max(1, Math.round(ageH * 60));  // минут с регистрации
                }
            }
        });
    } catch (_) {}
    var warmingCount = phones.filter(function(p) { return warmingSet[String(p)]; }).length;
    var freshCount = phones.filter(function(p) { return freshSet[String(p)]; }).length;

    var listHtml = '<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:5px;padding:6px;margin-bottom:14px;max-height:42vh;">';
    phones.forEach(function(p) {
        var _badge = '';
        if (warmingSet[String(p)]) _badge += ' <span style="color:#ffaa44;font-size:0.72rem;">🔥 прогрев ' + warmingSet[String(p)] + ' мин</span>';
        if (freshSet[String(p)]) _badge += ' <span style="color:#ff7676;font-size:0.72rem;">⚠ свежий (' + freshSet[String(p)] + ' мин)</span>';
        listHtml += '<div style="padding:6px 10px;font-family:monospace;font-size:0.82rem;border-bottom:1px solid var(--border);">+' + escHtml(p) + _badge + '</div>';
    });
    listHtml += '</div>';
    var hint = '<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:12px;line-height:1.4;">Если в списке есть лишний акк (например из другой папки, выбранный раньше) — нажми «Отмена», вернись к селектору и сними галочку.</div>';
    if (warmingCount > 0) {
        hint += '<div style="font-size:0.74rem;color:#ffaa44;margin-bottom:12px;padding:8px 10px;background:rgba(255,170,68,0.08);border-left:3px solid #ffaa44;border-radius:4px;line-height:1.4;">⚠ <b>' + warmingCount + ' акк' + (warmingCount > 1 ? 'ов' : '') + ' сейчас в прогреве.</b> Если запустишь сейчас — риск что Telegram пометит как бота и отзовёт сессии выше. Рекомендуется дождаться окончания прогрева (видно в таблице аккаунтов).</div>';
    }
    if (freshCount > 0) {
        hint += '<div style="font-size:0.74rem;color:#ff7676;margin-bottom:12px;padding:8px 10px;background:rgba(255,118,118,0.08);border-left:3px solid #ff7676;border-radius:4px;line-height:1.4;">⚠ <b>' + freshCount + ' свеже-зарегистрированных акк' + (freshCount > 1 ? 'ов' : '') + ' (< ' + FRESH_HOURS + ' ч назад).</b> Telegram часто отзывает сессии у таких при массовом DM/спаме с серверного IP. Подожди ещё несколько часов и/или включи прогрев в Настройках, либо сними галки перед стартом.</div>';
    }
    var btns =
        '<div style="display:flex;gap:10px;justify-content:flex-end;">' +
            '<button class="btn btn-outline" id="preflight-cancel" style="padding:8px 22px;font-size:0.85rem;">Отмена</button>' +
            '<button class="btn btn-accent" id="preflight-ok" style="padding:8px 22px;font-size:0.85rem;">Запустить на ' + phones.length + ' акков</button>' +
        '</div>';
    popup.innerHTML = head + listHtml + hint + btns;
    overlay.appendChild(popup);
    document.body.appendChild(overlay);
    document.getElementById('preflight-ok').onclick = function() {
        overlay.remove();
        if (onConfirm) onConfirm();
    };
    document.getElementById('preflight-cancel').onclick = function() { overlay.remove(); };
    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
}

// ── Связь с программой ───────────────────────────────────────────────────────
// Страница — это окно в программу: все данные идут запросами к ней. Программу
// закрыли или она перезапускается — страница остаётся на экране и выглядит
// рабочей, а кнопки «ничего не делают». Владелец 13.08.2026: «если сервер лежит,
// веб не может работать, запросы то все на сервер идут». Значит и говорить об
// этом должна сама страница.
var _srvDown = 0;

function _srvBanner(show) {
    var el = document.getElementById('server-down-banner');
    if (!show) { if (el) el.remove(); return; }
    if (el) return;
    el = document.createElement('div');
    el.id = 'server-down-banner';
    el.style.cssText = 'position:fixed;left:0;right:0;top:0;z-index:99999;' +
        'background:#b91c1c;color:#fff;padding:10px 16px;font-size:0.86rem;' +
        'text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.4);';
    el.innerHTML = 'Программа TelePush закрыта или перезапускается — эта страница ' +
        'сейчас ничего не покажет и не сохранит. Запустите программу; как только ' +
        'она поднимется, страница оживёт сама.';
    document.body.appendChild(el);
}

function _srvWatchTick() {
    fetch('/api/ping', {cache: 'no-store'}).then(function(r) {
        if (!r.ok) throw new Error('bad');
        if (_srvDown >= 2) {           // была потеряна и вернулась — обновляем данные
            _srvDown = 0;
            _srvBanner(false);
            try { if (typeof loadStatus === 'function') loadStatus(); } catch (_) {}
        }
        _srvDown = 0;
    }).catch(function() {
        _srvDown++;
        if (_srvDown >= 2) _srvBanner(true);   // две подряд, чтобы не мигать
    });
}

setInterval(_srvWatchTick, 5000);

function showToast(msg, type, actionLabel, actionFn) {
    type = type || 'info';
    var container = document.getElementById('toast-container');
    if (!container) return;
    var icons = {success: 'bi-check-circle', error: 'bi-x-circle', info: 'bi-info-circle', warning: 'bi-exclamation-triangle'};
    var toast = document.createElement('div');
    toast.className = 'toast ' + type;
    // Иконка безопасна (whitelist), msg приходит от бэкенда — экранируем на случай HTML-символов
    var iconClass = icons[type] || 'bi-info-circle';
    toast.innerHTML = '<i class="bi ' + iconClass + '"></i> ';
    var span = document.createElement('span');
    span.textContent = (typeof msg === 'string') ? msg : String(msg);
    toast.appendChild(span);
    // Если тост error и явное action не задано — автоматически даём «Открыть логи».
    // 201 call-site в коде не трогаем, теперь у всех есть быстрый путь в логи.
    if (type === 'error' && !actionLabel) {
        actionLabel = 'Открыть логи';
        actionFn = function() {
            if (typeof openLogsWindow === 'function') openLogsWindow();
        };
    }
    // Опциональная кнопка действия — например «Открыть логи» при ошибке.
    // Клиент сразу видит что делать вместо абстрактного «Ошибка».
    var ttl = 3000;
    if (actionLabel && typeof actionFn === 'function') {
        var btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'toast-action';
        btn.textContent = actionLabel;
        btn.style.cssText = 'margin-left:10px;padding:3px 10px;border-radius:4px;background:rgba(255,255,255,.12);color:inherit;border:1px solid rgba(255,255,255,.18);cursor:pointer;font-size:0.78rem;';
        btn.onclick = function(e) {
            e.stopPropagation();
            try { actionFn(); } catch (_) {}
            toast.style.opacity = '0';
            setTimeout(function() { toast.remove(); }, 200);
        };
        toast.appendChild(btn);
        ttl = 6000; // даём время дотянуться до кнопки
    }
    container.appendChild(toast);
    setTimeout(function() {
        toast.style.opacity = '0';
        toast.style.transform = 'translateX(20px)';
        setTimeout(function() { toast.remove(); }, 300);
    }, ttl);
    // Log to console
    if (typeof addLog === 'function') {
        var lvl = type === 'error' ? 'ERROR' : type === 'success' ? 'SUCCESS' : type === 'warning' ? 'WARNING' : 'INFO';
        addLog(msg, lvl);
    }
    // Дублируем тост в backend-логгер — окно «Логи работы» показывает все
    // UI-уведомления. Fire-and-forget, без блокировки UI.
    try {
        var apiLvl = type === 'error' ? 'ERROR' : type === 'success' ? 'SUCCESS'
                    : type === 'warning' ? 'WARNING' : 'INFO';
        fetch('/api/logs/client_event', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            credentials: 'same-origin',
            body: JSON.stringify({message: String(msg).slice(0, 500), level: apiLvl, module: 'ui'}),
        }).catch(function() {});
    } catch (_) {}
    // Звуковое уведомление на критические тосты (opt-in в Настройках)
    if (typeof tpBeep === 'function') {
        if (type === 'error') tpBeep('error');
    }
}

// Хелпер: error-тост с кнопкой «Открыть логи» для диагностики.
function showError(msg) {
    showToast(msg || 'Ошибка', 'error', 'Открыть логи', function() {
        if (typeof openLogsWindow === 'function') openLogsWindow();
    });
}

// ── Звуковые уведомления ───────────────────────────────────────
// Опциональные beeps на критические события через Web Audio API
// (без mp3-файлов в bundle). По умолчанию ВЫКЛЮЧЕНЫ — opt-in в Настройках.
function tpSoundEnabled(kind) {
    try {
        return localStorage.getItem('tp_sound_' + kind) === '1';
    } catch (_) { return false; }
}

function tpSoundVolume() {
    try {
        // Дефолт громкости 0.1 (тихо).
        var v = parseFloat(localStorage.getItem('tp_sound_volume') || '0.1');
        return Math.max(0, Math.min(1, v));
    } catch (_) { return 0.1; }
}

function tpBeep(kind) {
    if (!tpSoundEnabled(kind)) return;
    _tpPlayTone(kind);
}

// Воспроизводит короткий beep по типу события. Разная частота даёт
// разное «настроение»: freeze=низкий, error=средний-вниз, done=два восходящих.
function _tpPlayTone(kind) {
    try {
        var AC = window.AudioContext || window.webkitAudioContext;
        if (!AC) return;
        var ctx = window._tpAudioCtx || (window._tpAudioCtx = new AC());
        var vol = tpSoundVolume();
        var now = ctx.currentTime;
        var notes = [];
        if (kind === 'freeze') notes = [{f: 220, t: 0, d: 0.4}];           // глухой низкий
        else if (kind === 'error') notes = [{f: 660, t: 0, d: 0.15}, {f: 440, t: 0.18, d: 0.2}];
        else if (kind === 'done')  notes = [{f: 587, t: 0, d: 0.12}, {f: 880, t: 0.14, d: 0.18}];
        else notes = [{f: 800, t: 0, d: 0.12}];
        notes.forEach(function(n) {
            var osc = ctx.createOscillator();
            var gain = ctx.createGain();
            osc.type = 'sine';
            osc.frequency.value = n.f;
            gain.gain.value = vol * 0.3;
            gain.gain.setValueAtTime(vol * 0.3, now + n.t);
            gain.gain.exponentialRampToValueAtTime(0.0001, now + n.t + n.d);
            osc.connect(gain).connect(ctx.destination);
            osc.start(now + n.t);
            osc.stop(now + n.t + n.d + 0.05);
        });
    } catch (e) {
        console && console.debug && console.debug('[sound] beep failed:', e);
    }
}

// Вызывается из настроек: «Проверить звук» — играем независимо от настройки.
function tpTestSound(kind) {
    _tpPlayTone(kind || 'done');
}

function tpSetSound(kind, enabled) {
    try {
        localStorage.setItem('tp_sound_' + kind, enabled ? '1' : '0');
    } catch (_) {}
}

function tpSetSoundVolume(v) {
    try { localStorage.setItem('tp_sound_volume', String(v)); } catch (_) {}
}

// ── Кастомный prompt в стиле приложения ────────────────────────
// Использовать вместо window.prompt() — родной диалог уродует UI и
// палит host:port в заголовке.
// tpPrompt({title, hint, placeholder, defaultValue, okText, cancelText}) → Promise<string|null>
function tpPrompt(opts) {
    opts = opts || {};
    return new Promise(function(resolve) {
        var backdrop = document.createElement('div');
        backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(7,8,14,.62);z-index:9999;display:flex;align-items:center;justify-content:center;animation:tpPromptFadeIn .15s ease-out;';

        var box = document.createElement('div');
        box.style.cssText = 'background:var(--bg-card,#1a1b27);border:1px solid var(--border,#2a2b3a);border-radius:7px;padding:22px 22px 18px;width:min(440px,92vw);color:var(--text,#e6e7ee);font-family:inherit;animation:tpPromptIn .18s cubic-bezier(.2,.9,.3,1.1);';

        var titleEl = document.createElement('div');
        titleEl.style.cssText = 'font-size:15px;font-weight:600;margin-bottom:6px;display:flex;align-items:center;gap:8px;';
        titleEl.innerHTML = '<i class="bi bi-pencil-square" style="color:var(--accent,#8b5cf6);"></i> <span></span>';
        titleEl.querySelector('span').textContent = opts.title || 'Введите значение';
        box.appendChild(titleEl);

        if (opts.hint) {
            var hint = document.createElement('div');
            hint.style.cssText = 'font-size:12.5px;color:var(--text-muted,#9aa0b4);line-height:1.45;margin-bottom:12px;white-space:pre-line;';
            hint.textContent = opts.hint;
            box.appendChild(hint);
        }

        var input = document.createElement('input');
        input.type = 'text';
        input.value = opts.defaultValue || '';
        input.placeholder = opts.placeholder || '';
        input.style.cssText = 'width:100%;padding:10px 12px;background:var(--bg-input,#0f1018);border:1px solid var(--border,#2a2b3a);border-radius:5px;color:inherit;font-size:14px;outline:none;transition:border-color .12s;box-sizing:border-box;';
        input.onfocus = function() { input.style.borderColor = 'var(--accent,#8b5cf6)'; };
        input.onblur = function() { input.style.borderColor = 'var(--border,#2a2b3a)'; };
        box.appendChild(input);

        var btns = document.createElement('div');
        btns.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;margin-top:16px;';

        var cancel = document.createElement('button');
        cancel.type = 'button';
        cancel.textContent = opts.cancelText || 'Отмена';
        cancel.style.cssText = 'padding:8px 16px;background:transparent;border:1px solid var(--border,#2a2b3a);border-radius:5px;color:var(--text-muted,#9aa0b4);cursor:pointer;font-size:13px;font-weight:500;transition:all .12s;';
        cancel.onmouseover = function() { cancel.style.color = 'var(--text,#e6e7ee)'; cancel.style.borderColor = 'var(--text-muted,#9aa0b4)'; };
        cancel.onmouseout = function() { cancel.style.color = 'var(--text-muted,#9aa0b4)'; cancel.style.borderColor = 'var(--border,#2a2b3a)'; };

        var ok = document.createElement('button');
        ok.type = 'button';
        ok.textContent = opts.okText || 'OK';
        ok.style.cssText = 'padding:8px 18px;background:linear-gradient(135deg,#8b5cf6 0%,#7c3aed 100%);border:none;border-radius:5px;color:#fff;cursor:pointer;font-size:13px;font-weight:600;transition:transform .08s,box-shadow .12s;';
        ok.onmouseover = function() { ok.style.transform = 'translateY(-1px)'; ok.style.boxShadow = '0 6px 18px rgba(139,92,246,.5)'; };
        ok.onmouseout = function() { ok.style.transform = 'none'; ok.style.boxShadow = '0 4px 14px rgba(139,92,246,.35)'; };

        btns.appendChild(cancel);
        btns.appendChild(ok);
        box.appendChild(btns);
        backdrop.appendChild(box);
        document.body.appendChild(backdrop);

        // Анимация (один раз на страницу)
        if (!document.getElementById('tp-prompt-anim')) {
            var s = document.createElement('style');
            s.id = 'tp-prompt-anim';
            s.textContent = '@keyframes tpPromptFadeIn{from{opacity:0}to{opacity:1}}@keyframes tpPromptIn{from{opacity:0;transform:translateY(8px) scale(.97)}to{opacity:1;transform:none}}';
            document.head.appendChild(s);
        }

        function close(val) {
            backdrop.style.opacity = '0';
            backdrop.style.transition = 'opacity .12s';
            setTimeout(function() { backdrop.remove(); }, 130);
            resolve(val);
        }
        ok.onclick = function() { close(input.value.trim() || null); };
        cancel.onclick = function() { close(null); };
        backdrop.onclick = function(e) { if (e.target === backdrop) close(null); };
        input.onkeydown = function(e) {
            if (e.key === 'Enter') { e.preventDefault(); close(input.value.trim() || null); }
            else if (e.key === 'Escape') { e.preventDefault(); close(null); }
        };
        setTimeout(function() { input.focus(); input.select(); }, 30);
    });
}

// ── HTML Escape ─────────────────────────────────────────────────

// Честная подпись к запуску: показываем, сколько аккаунтов реально ушло в работу,
// и отдельно — сколько отсеялось и почему. Раньше в тосте стояло число ВЫБРАННЫХ,
// и человек читал «запущено 20», когда 15 были заняты другой задачей, а в логе
// секундой позже стояло «Подключено 5 из 20».
function _startedMsg(d, what) {
    var started = (d && typeof d.started === 'number') ? d.started : null;
    var selected = (d && typeof d.selected === 'number') ? d.selected : null;
    if (started === null) return what + ' запущен(а)';
    var msg = what + ': ' + started + ' акк.';
    if (selected !== null && selected > started) {
        var why = [];
        try {
            Object.keys(d.skipped || {}).forEach(function(k) { why.push(k + ' — ' + d.skipped[k]); });
        } catch (_) {}
        msg += ' из ' + selected + ' выбранных';
        if (why.length) msg += ' (' + why.join(', ') + ')';
    }
    return msg;
}

function _startedTone(d) {
    return (d && typeof d.selected === 'number' && typeof d.started === 'number'
            && d.started < d.selected) ? 'warning' : 'success';
}

function escHtml(s) {
    var d = document.createElement('div');
    d.textContent = s || '';
    return d.innerHTML;
}

// Цвет строки во встроенных лог-боксах панели. Раньше цвет брался по эмодзи в
// тексте; строки стали единого вида «[Поток N] [номер] Вердикт», и эмодзи в них
// нет — без этого весь бокс стал бы серым.
// Порядок проверок значим: «Без спам-блока» содержит «спам-блок» и без первой
// строки покрасился бы жёлтым, то есть чистый аккаунт выглядел бы как
// заблокированный.
function verdictColor(line) {
    var t = String(line || '').toLowerCase();
    if (t.indexOf('без спам-блока') !== -1) return 'var(--success)';
    if (/заморож|❄/.test(t)) return 'var(--info)';
    if (/вечный спам|✗/.test(t)) return 'var(--danger)';
    if (/спам-блок|флуд|медленный режим|⏳/.test(t)) return 'var(--warning)';
    if (/мёртв|мертв|не подключился|не найден|не проверен|отменён/.test(t)) return 'var(--danger)';
    // «Не заполнен» содержит «заполнен» — отрицания проверяем раньше.
    if (/не заполнен|пропущен|остался на месте|ошибк|сбой|не удалось|не вышло|⚠|↪/.test(t)) return 'var(--accent-light)';
    if (/отправлено|вступил|уже состоит|заявка отправлена|профиль заполнен|перенесён|в telegram|✓/.test(t)) return 'var(--success)';
    return 'var(--text-muted)';
}

// Stage icon: STAGES в core/warmup.py отдаёт Bootstrap-Icons-классы
// (bi-flower1 и т.п.). Legacy emoji (со старого сервера) — отображаем как есть.
function _renderStageIcon(icon) {
    if (!icon) return '';
    var s = String(icon);
    if (s.indexOf('bi-') === 0) {
        return '<i class="bi ' + s + '" style="color:var(--accent-light);"></i>';
    }
    return escHtml(s);
}

// Перевод технических меток источника возраста аккаунта на понятный язык.
function _humanizeAgeSource(src) {
    var s = String(src || '');
    if (s.indexOf('user_id_estimate') >= 0) {
        return 'Возраст рассчитан по Telegram user_id — самый точный способ, Telegram сам выдал ID при регистрации.';
    }
    if (s.indexOf('register_time') >= 0) {
        return 'Возраст взят из метаданных продавца (.json-файла рядом с .session). Продавец мог проставить дату вручную — не всегда точно.';
    }
    if (s.indexOf('session_created') >= 0) {
        return 'Возраст из поля session_created в .json — обычно дата создания сессии у продавца, не дата регистрации.';
    }
    if (s.indexOf('file_date') >= 0) {
        return 'Возраст определён по дате .session-файла на диске. Самый ненадёжный источник — при копировании файла дата меняется.';
    }
    return 'Точный источник возраста неизвестен — используются дефолтные оценки.';
}

// ── Sound Notifications ────────────────────────────────────────

function playSound(type) {
    // Мягкие звуки: triangle-wave + low-pass filter 1200Hz, плавные
    // exponentialRampToValueAtTime, fade-in 60мс (избегаем «клика» в начале).
    try {
        var ctx = new (window.AudioContext || window.webkitAudioContext)();
        var osc = ctx.createOscillator();
        var gain = ctx.createGain();
        var filter = ctx.createBiquadFilter();
        filter.type = 'lowpass';
        filter.frequency.value = 1200;  // приглушаем «звон» высоких гармоник
        filter.Q.value = 0.7;
        osc.connect(filter);
        filter.connect(gain);
        gain.connect(ctx.destination);

        var t0 = ctx.currentTime;
        gain.gain.value = 0;  // fade-in от 0 убирает щелчок

        if (type === 'success') {
            // Тёплый мажорный мотив C5→G5 через плавный glide, не резкие переходы
            osc.type = 'triangle';
            osc.frequency.setValueAtTime(523, t0);
            osc.frequency.exponentialRampToValueAtTime(784, t0 + 0.45);
            osc.start(t0);
            // fade-in 60мс → плавно держим 350мс → fade-out 400мс
            gain.gain.linearRampToValueAtTime(0.07, t0 + 0.06);
            gain.gain.setValueAtTime(0.07, t0 + 0.42);
            gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.85);
            osc.stop(t0 + 0.9);
        } else if (type === 'error') {
            // Минорный нисходящий мотив, БЕЗ square-wave (резко).
            osc.type = 'triangle';
            osc.frequency.setValueAtTime(392, t0);  // G4
            osc.frequency.exponentialRampToValueAtTime(294, t0 + 0.4);  // D4
            osc.start(t0);
            gain.gain.linearRampToValueAtTime(0.05, t0 + 0.06);
            gain.gain.setValueAtTime(0.05, t0 + 0.38);
            gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.7);
            osc.stop(t0 + 0.75);
        } else {
            // Тихий короткий «бип» для уведомлений
            osc.type = 'sine';
            osc.frequency.setValueAtTime(560, t0);
            osc.start(t0);
            gain.gain.linearRampToValueAtTime(0.04, t0 + 0.04);
            gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.3);
            osc.stop(t0 + 0.32);
        }
    } catch(e) {}
}

// ── Status Refresh ──────────────────────────────────────────────

var _refreshTimer = null;
var _hasActiveJobs = false;
var _prevJobStatuses = {};

// Обновление глобальной statusbar внизу окна — что софт делает прямо сейчас.
function _updateStatusBar(d) {
    var dot = document.getElementById('sb-conn-dot');
    if (dot) dot.style.background = 'var(--success)';

    var sbJobs = document.getElementById('sb-jobs');
    if (sbJobs) {
        var jobs = [];
        if (d.spam_job && d.spam_job.status === 'running')
            jobs.push('рассылка ' + (d.spam_job.processed || 0) + '/' + (d.spam_job.total || 0));
        if (d.user_spam_job && d.user_spam_job.status === 'running')
            jobs.push('ЛС ' + (d.user_spam_job.processed || 0) + '/' + (d.user_spam_job.total || 0));
        if (d.join_job && d.join_job.status === 'running')
            jobs.push('подписка ' + (d.join_job.processed || 0) + '/' + (d.join_job.total || 0));
        if (d.warmup_job && d.warmup_job.status === 'running')
            jobs.push('прогрев');
        var multiCount = (d.multi_jobs || []).filter(function(j) { return j.status === 'running'; }).length;
        if (multiCount > 0) jobs.push('мульти ×' + multiCount);
        sbJobs.innerHTML = jobs.length
            ? '<span style="color:var(--success);"><i class="bi bi-play-fill"></i></span> ' + jobs.join(' · ')
            : '<span style="opacity:0.6;"><i class="bi bi-pause-circle"></i> Простой</span>';
    }

    var sbAccs = document.getElementById('sb-accounts');
    if (sbAccs && typeof _allAccounts !== 'undefined' && _allAccounts) {
        var alive = 0, frozen = 0, deadOrSpam = 0;
        _allAccounts.forEach(function(a) {
            if (a.dead) deadOrSpam++;
            else if (a.frozen) frozen++;
            else if (a._isPerma) deadOrSpam++;
            else alive++;
        });
        sbAccs.innerHTML = '<i class="bi bi-people"></i> ' + alive + ' живых'
            + (frozen ? ' · <span style="color:#60a5fa;">' + frozen + ' заморозка</span>' : '')
            + (deadOrSpam ? ' · <span style="color:#f87171;">' + deadOrSpam + ' мёртвых</span>' : '');
    }

    var sbProxies = document.getElementById('sb-proxies');
    if (sbProxies) {
        // proxies-stats не приходит из /api/status — берём из последнего вызова loadProxies()
        // если был. Иначе скрываем ячейку, чтобы не вводить в заблуждение.
        var ps = window._lastProxyStats;
        if (ps && ps.total > 0) {
            var col = ps.alive === 0 ? '#f87171' : (ps.alive < ps.total * 0.5 ? '#fbbf24' : 'var(--success)');
            sbProxies.style.display = '';
            sbProxies.innerHTML = '<i class="bi bi-shield-lock"></i> <span style="color:' + col + ';">' + ps.alive + '</span>/' + ps.total + ' прокси';
        } else {
            sbProxies.style.display = 'none';
            // Скрываем разделитель перед прокси если прокси скрыт
        }
    }

    var sbVer = document.getElementById('sb-version');
    if (sbVer && d.sb_version) sbVer.textContent = 'v' + d.sb_version;

    // Тост «Обновлено до X.Y.Z» если версия изменилась с прошлого запуска.
    if (d.sb_version && !window._verToastShown) {
        try {
            var lastSeen = localStorage.getItem('tp_last_seen_version') || '';
            if (lastSeen && lastSeen !== d.sb_version) {
                window._verToastShown = true;
                showToast('Обновлено: ' + lastSeen + ' → ' + d.sb_version, 'success', 'Что нового', function() {
                    if (typeof showWhatsNew === 'function') showWhatsNew();
                    else if (typeof showTab === 'function') showTab('settings');
                });
            }
            localStorage.setItem('tp_last_seen_version', d.sb_version);
            window._verToastShown = true;
        } catch (_) {}
    }

    var sbHwid = document.getElementById('sb-hwid');
    if (sbHwid && d.sb_hwid_short) sbHwid.textContent = d.sb_hwid_short;
}

// При сетевой ошибке /api/status — красим конект-индикатор красным.
(function() {
    var origGet = (typeof API !== 'undefined' && API && API.get) ? API.get : null;
    if (!origGet) return;
    API.get = function(url) {
        var p = origGet.apply(this, arguments);
        if (url && url.indexOf('/api/status') === 0) {
            p.catch(function() {
                var dot = document.getElementById('sb-conn-dot');
                if (dot) dot.style.background = '#f87171';
                var sbJobs = document.getElementById('sb-jobs');
                if (sbJobs) sbJobs.innerHTML = '<span style="color:#f87171;"><i class="bi bi-wifi-off"></i> Нет связи с сервером</span>';
            });
        }
        return p;
    };
})();

function scheduleRefresh() {
    clearTimeout(_refreshTimer);
    var interval = _hasActiveJobs ? 1000 : 2000;
    _refreshTimer = setTimeout(function() {
        // Не дёргаем /api/status пока окно скрыто/свёрнуто — юзер не смотрит.
        // Цикл продолжается; при возврате на вкладку refreshStatus вызывается
        // сразу через visibilitychange (ниже), без ожидания тика.
        if (!document.hidden) refreshStatus();
        scheduleRefresh();
    }, interval);
}

// Возврат на вкладку → сразу освежаем статус (не ждём 2с тика).
document.addEventListener('visibilitychange', function() {
    if (!document.hidden && typeof refreshStatus === 'function') refreshStatus();
});

var _lastAccountsCount = -1;
function refreshStatus() {
    API.get('/api/status').then(function(d) {
        if (!d.ok) return;

        // Module access control
        if (d.allowed_modules) _allowedModules = d.allowed_modules;
        if (d.allowed_features) _allowedFeatures = d.allowed_features;
        if (d.key_type) _keyType = d.key_type;
        if (d.onboarding_done) window._serverOnboardingDone = true;
        _applyModuleLocks();
        // Подтянуть сохранённые «Защита аккаунта» во все модули
        loadAdSettings();

        // License expired handling
        var expBanner = document.getElementById('expired-banner');
        if (d.license_expired) {
            if (expBanner) expBanner.style.display = 'block';
            _applyExpiredLock(true);
        } else {
            if (expBanner) expBanner.style.display = 'none';
            _applyExpiredLock(false);
        }

        _hasActiveJobs = !!(d.join_job && d.join_job.status === 'running')
            || !!(d.spam_job && d.spam_job.status === 'running')
            || !!(d.user_spam_job && d.user_spam_job.status === 'running')
            || !!(d.warmup_job && d.warmup_job.status === 'running')
            || (d.multi_jobs || []).some(function(j) { return j.status === 'running'; });

        try { _applyBusyLock(d.busy_lock); } catch (_) {}

        // Глобальная statusbar внизу — что софт делает прямо сейчас.
        try { _updateStatusBar(d); } catch (_) {}

        // Звуковые сигналы на переходы (freeze count↑, job finished).
        try {
            // freeze detection — растёт ли количество замороженных
            if (typeof _allAccounts !== 'undefined' && _allAccounts) {
                var frozenNow = _allAccounts.filter(function(a) { return a.frozen && !a.dead; }).length;
                if (window._prevFrozenCount === undefined) window._prevFrozenCount = frozenNow;
                if (frozenNow > window._prevFrozenCount && typeof tpBeep === 'function') {
                    tpBeep('freeze');
                }
                window._prevFrozenCount = frozenNow;
            }
            // job done detection — running → finished/done
            var jobNames = ['spam_job', 'user_spam_job', 'join_job', 'warmup_job'];
            if (!window._prevJobStat) window._prevJobStat = {};
            jobNames.forEach(function(jn) {
                var st = d[jn] && d[jn].status;
                var prev = window._prevJobStat[jn] || '';
                if (prev === 'running' && st && st !== 'running' && typeof tpBeep === 'function') {
                    tpBeep('done');
                }
                window._prevJobStat[jn] = st || '';
            });
        } catch (_) {}

        // Стоп-кнопки доступны ТОЛЬКО когда задача реально запущена.
        function _setBtnDisabled(id, disabled) {
            var b = document.getElementById(id);
            if (b) b.disabled = !!disabled;
        }
        var spamRunning = !!(d.spam_job && d.spam_job.status === 'running');
        var dmRunning = !!(d.user_spam_job && d.user_spam_job.status === 'running');
        var joinRunning = !!(d.join_job && d.join_job.status === 'running');
        // Мульти-задачи (Multi-DM/Spam/Join) тоже учитываем — кнопка «Стоп» в
        // табе должна быть активна когда работает мульти-вариант.
        (d.multi_jobs || []).forEach(function(mj) {
            if (mj.status !== 'running') return;
            if (mj.type === 'spam') spamRunning = true;
            else if (mj.type === 'dm') dmRunning = true;
            else if (mj.type === 'join') joinRunning = true;
        });
        // Фаза пре-флайта: аккаунты ещё подключаются, job'ов нет — но прервать
        // запуск можно и НУЖНО (юзер увидел ошибку в настройках уже после старта).
        // Раньше «Стоп» тут был серым, и оставалось только закрыть софт.
        // Тайминги поправлены при загрузке — сказать один раз и явно.
        if (d.timings_fixed && d.timings_fixed.length && !window._timingsNoticeShown) {
            window._timingsNoticeShown = true;
            showToast('Тайминги теперь реально применяются (раньше настройки не доходили до движка). ' +
                      'Поправлено значений: ' + d.timings_fixed.length +
                      ' — проверь паузы в настройках модулей.', 'warning');
        }
        var _launching = d.multi_launching || [];
        if (_launching.indexOf('spam') >= 0) spamRunning = true;
        if (_launching.indexOf('dm') >= 0) dmRunning = true;
        if (_launching.indexOf('join') >= 0) joinRunning = true;
        _setBtnDisabled('btn-spam-stop', !spamRunning);
        _setBtnDisabled('btn-dm-stop', !dmRunning);
        _setBtnDisabled('btn-join-stop', !joinRunning);

        // Авто-снятие галочки с ОТРАБОТАВШИХ акков в Рассылка по ЛС (запрос юзера):
        // акк, чья DM-задача завершилась и который реально отработал — отправил ≥1
        // ИЛИ словил спам-блок (даже сразу) — снимаем из выбранных live. Не дошедшие
        // до очереди / не подключившиеся / FloodWait (sent=0, без спама) ОСТАЮТСЯ —
        // в конце видно готовый запас. Армится только после старта ЛС (_dmAutoArmed),
        // чтобы stale-jobs после перезагрузки не трогали выбор. «Завершился» = больше
        // не running ЛИБО исчез из списка (сервер дропает finished-jobs >5мин по
        // started_at — длинные акки иначе не словить); решаем по последним известным
        // counts (_dmLastSeen).
        if (window._dmAutoArmed) {
            window._dmAutoDeselected = window._dmAutoDeselected || {};
            window._dmLastSeen = window._dmLastSeen || {};
            var _curDm = {};
            (d.multi_jobs || []).forEach(function(mj) {
                if (!mj || mj.type !== 'dm') return;
                var ph = String(mj.phone || ''); if (!ph) return;
                var c = mj.counts || {};
                _curDm[ph] = {
                    sent: parseInt(c.sent, 10) || 0,
                    spam: (parseInt(c.spamfilter, 10) || 0) > 0,
                    running: (mj.status === 'running' || mj.status === 'paused' || mj.status === 'queued')
                };
                window._dmLastSeen[ph] = _curDm[ph];
            });
            Object.keys(window._dmLastSeen).forEach(function(ph) {
                if (window._dmAutoDeselected[ph]) return;
                if (_multiSelected.indexOf(ph) === -1) return;
                var cur = _curDm[ph];
                var done = !cur || !cur.running;          // исчез ИЛИ больше не running
                if (!done) return;
                var last = window._dmLastSeen[ph];
                if (last.sent < 1 && !last.spam) return;  // не отработал — оставляем
                var i = _multiSelected.indexOf(ph);
                if (i !== -1) _multiSelected.splice(i, 1);
                window._dmAutoDeselected[ph] = 1;
                _saveMultiSelected();
                try { updateDmMultiCount(); } catch (e) {}
                try { _updateMultiButtons(); } catch (e) {}
                var _why = last.spam ? 'спам-блок' : ('отправил ' + last.sent);
                showToast('+' + ph + ' отработал (' + _why + ') — снят', 'info');
            });
        }
        // Кнопка «Старт» глушится пока задача этого модуля идёт — нельзя случайно
        // запустить вторую поверх. Прогрев/мониторинг НЕ трогаем (свой цикл).
        // ВАЖНО: глушим при running ИЛИ при пустом счётчике целей — иначе затираем
        // контент-гейт (btn-spam/dm/join выключены пока 0 целей/ссылок).
        function _countZero(id) {
            var e = document.getElementById(id);
            return !e || (parseInt(e.textContent, 10) || 0) === 0;
        }
        // btn-spam НЕ гейтим по счётчику чатов: рассылка «По чатам» собирает чаты
        // каждому акку автоматически на старте (/api/multi/spam) — кнопка «Найти
        // чаты» больше не обязательна. Глушим только пока задача идёт.
        _setBtnDisabled('btn-spam', spamRunning);
        _setBtnDisabled('btn-dm', dmRunning || _countZero('dm-count'));
        _setBtnDisabled('btn-join', joinRunning || _countZero('join-count'));
        // Инвайт/чекер/парсер: статус приходит в /api/status (если есть) — глушим
        // Старт по running. Их собственный своп Старт↔Стоп остаётся как был.
        var inviteRunning = !!(d.invite_job && d.invite_job.status === 'running');
        var checkerRunning = !!(d.checker_job && (d.checker_job.status === 'running' || d.checker_job.running));
        var parserRunning = !!(d.parser_job && (d.parser_job.status === 'running' || d.parser_job.running));
        _setBtnDisabled('btn-invite-start', inviteRunning);
        _setBtnDisabled('btn-ck-start', checkerRunning);
        _setBtnDisabled('btn-parser', parserRunning);

        renderAllJobs(d);

        // Refresh warmup tab if active and job running
        if (_currentTab === 'warmup' && d.warmup_job && d.warmup_job.status === 'running') {
            loadWarmup();
        }

        if (d.accounts_count !== undefined) {
            checkOnboarding(d.accounts_count);
            var el = document.getElementById('accounts-count');
            if (el) el.textContent = d.accounts_count;
            // Сайдбар-счётчик «Менеджер аккаунтов» убран — показывал len(ctx.accounts)
            // вкл. dead/frozen и расходился с таблицей. Число акков видно во вкладках.
            if (_lastAccountsCount !== -1 && d.accounts_count !== _lastAccountsCount) {
                if (_currentTab === 'accounts') loadAccounts();
                if (_currentTab === 'multi') loadMultiAccounts();
                renderAccountSelectors();
            }
            _lastAccountsCount = d.accounts_count;
        }

        // Live-индикатор открытых ТГ-веб. Набор открытых вебок приходит в
        // /api/status каждый поллинг; при смене рефрешим таблицу — бейдж «TG-веб»
        // появляется/исчезает сразу. _tgwebOpenSet — для подсветки строки.
        if (d.tgweb_open !== undefined) {
            window._tgwebOpenSet = {};
            (d.tgweb_open || []).forEach(function(p){ window._tgwebOpenSet[String(p)] = 1; });
            var _twSig = (d.tgweb_open || []).slice().sort().join(',');
            if (window._tgwebOpenSig === undefined) {
                window._tgwebOpenSig = _twSig;
            } else if (_twSig !== window._tgwebOpenSig) {
                window._tgwebOpenSig = _twSig;
                if (_currentTab === 'accounts') loadAccounts();
            }
        }

        // Показываем баннер обновления (без автоустановки)
        if (d.update_available && !window._updateBannerShown) {
            window._updateBannerShown = true;
            var banner = document.getElementById('update-banner');
            var ver = document.getElementById('update-version');
            var cl = document.getElementById('update-changelog');
            if (banner) banner.style.display = 'flex';
            if (ver) ver.textContent = 'v' + d.update_version;
            if (cl && d.update_changelog) cl.textContent = d.update_changelog;
            if (typeof playUpdateChime === 'function') playUpdateChime();
        } else if (!d.update_available && window._updateBannerShown) {
            // Уже на свежей версии (после рестарта) — прячем, иначе баннер залипал.
            window._updateBannerShown = false;
            var _ub = document.getElementById('update-banner');
            if (_ub) _ub.style.display = 'none';
        }

        // Автообновление сдалось — говорим клиенту, что делать. Без этого он молча
        // оставался на старой версии: софт переставал пытаться, а сообщение уходило
        // только в лог, которого клиент не видит.
        var _mrb = document.getElementById('manual-reinstall-banner');
        if (_mrb) {
            if (d.update_manual_needed) {
                var _det = document.getElementById('manual-reinstall-detail');
                if (_det) {
                    var _n = d.update_manual_attempts || 0;
                    _det.textContent = 'Антивирус заблокировал файлы программы' +
                        (_n ? ' (попыток: ' + _n + ')' : '') +
                        '. Скачай пакет с сайта и распакуй поверх папки TelePush — ' +
                        'аккаунты и настройки останутся на месте.';
                }
                _mrb.style.display = 'flex';
            } else {
                _mrb.style.display = 'none';
            }
        }

        // Критичный оверлей: exe запущен прямо из архива WinRAR/7z без распаковки.
        // Каждый закрытый архив = потеря настроек/аккаунтов/обновлений.
        // Показываем полноэкранный блокирующий оверлей один раз, клиент его увидит
        // ДАЖЕ если не смотрит наверх окна.
        if (d.temp_launch_warning) {
            var tb = document.getElementById('temp-launch-overlay') || document.getElementById('temp-launch-banner');
            if (tb && !window._tempLaunchShown) {
                tb.style.display = (tb.id === 'temp-launch-overlay') ? 'flex' : 'block';
                window._tempLaunchShown = true;
            }
            var tp = document.getElementById('temp-launch-path');
            if (tp) tp.textContent = d.temp_launch_path || '(не определена)';
        }
    }).catch(function() {});
}

// ── Job Rendering ───────────────────────────────────────────────

function renderAllJobs(data) {
    var container = document.getElementById('active-jobs');
    if (!container) return;
    var jobs = [];
    if (data.join_job) jobs.push(Object.assign({}, data.join_job, {type: 'join'}));
    if (data.spam_job) jobs.push(Object.assign({}, data.spam_job, {type: 'spam'}));
    if (data.user_spam_job) jobs.push(Object.assign({}, data.user_spam_job, {type: 'user_spam'}));
    if (data.warmup_job && data.warmup_job.status !== 'stopped' && data.warmup_job.status !== 'finished') {
        jobs.push(Object.assign({}, data.warmup_job, {type: 'warmup', phone: 'warmup'}));
    }
    (data.multi_jobs || []).forEach(function(j) {
        jobs.push(Object.assign({}, j, {is_multi: true}));
    });
    if (data.parser_job && data.parser_job.status === 'running') {
        var pj = data.parser_job;
        jobs.push({
            type: 'parser',
            phone: 'парсинг',
            status: 'running',
            processed: pj.found || 0,
            total: Math.max((pj.found || 0) + 1, (pj.scanned || 0) + 1),
            current_step: pj.status_text || ('Найдено: ' + (pj.found || 0) + ' | Проверено: ' + (pj.scanned || 0) + ' | ' + pj.elapsed + 'с'),
            is_parser: true,
        });
    }

    // Detect job completion — play sound + лог в консоль.
    // Раньше требовалось чтобы prev был 'running' — если между пулами задача
    // успевала проскочить сразу в 'finished', клиент не видел уведомления.
    // Теперь срабатывает один раз на завершение даже если running-снапшот
    // был потерян; _finishedKeys не даёт показать дважды.
    jobs.forEach(function(j) {
        var key = (j.type || 'job') + '_' + (j.phone || '');
        var prev = _prevJobStatuses[key];
        var finishedKey = '__f__' + key;
        var label = j.type === 'join' ? 'Подписка' : j.type === 'user_spam' ? 'DM' : 'Рассылка';
        if (j.status === 'finished' && !_prevJobStatuses[finishedKey]) {
            _prevJobStatuses[finishedKey] = true;
            playSound('success');
            showToast(label + ' завершена! +' + (j.phone || ''), 'success');
            if (typeof addLog === 'function') {
                addLog(label + ' завершена! +' + (j.phone || ''), 'SUCCESS');
            }
        } else if (j.status === 'stopped' && prev === 'running') {
            playSound('error');
        }
        _prevJobStatuses[key] = j.status;
    });

    if (jobs.length === 0) {
        container.innerHTML = '<div style="text-align:center;padding:32px;color:var(--text-muted);"><i class="bi bi-moon-stars" style="font-size:2rem;display:block;margin-bottom:8px;"></i>Нет активных задач</div>';
        return;
    }
    container.innerHTML = jobs.map(function(j) { return renderJobCard(j); }).join('');
}

function renderJobCard(job) {
    var isJoin = job.type === 'join';
    var isDm = job.type === 'user_spam';
    var isWarmup = job.type === 'warmup';
    var isParser = job.type === 'parser';
    var icon = isParser ? 'bi-search' : (isWarmup ? 'bi-fire' : (isJoin ? 'bi-box-arrow-in-right' : (isDm ? 'bi-person-lines-fill' : 'bi-megaphone')));
    var color = isParser ? '#a855f7' : (isWarmup ? '#f97316' : (isJoin ? 'var(--success)' : (isDm ? 'var(--pink)' : 'var(--info)')));
    var label = isParser ? 'Парсинг' : (isWarmup ? ('Прогрев ' + _renderStageIcon(job.stage_icon) + ' ' + (job.stage_label || '')) : (isJoin ? 'Подписка' : (isDm ? 'DM рассылка' : 'Рассылка')));
    var pct = job.total > 0 ? Math.round(job.processed / job.total * 100) : 0;
    var statusMap = {running: 'Работает', paused: 'Пауза', finished: 'Завершено', stopped: 'Стоп'};
    var badgeMap = {running: 'badge-running', paused: 'badge-paused', finished: 'badge-finished', stopped: 'badge-stopped'};

    var controls = '';
    if (job.status === 'running' || job.status === 'paused') {
        controls = '<div class="d-flex gap-2" style="margin-top:8px;">';
        // Парсер не поддерживает pause — только стоп
        if (!isParser) {
            // tp-allow — управление ИДУЩЕЙ задачей обязано пережить блокировку
            // интерфейса, иначе её нечем будет остановить.
            if (job.status === 'running') {
                controls += '<button class="btn btn-outline btn-sm tp-allow" onclick="jobAction(\'pause\',\'' + job.type + '\')"><i class="bi bi-pause-fill"></i> Пауза</button>';
            } else {
                controls += '<button class="btn btn-success btn-sm tp-allow" onclick="jobAction(\'resume\',\'' + job.type + '\')"><i class="bi bi-play-fill"></i> Продолжить</button>';
            }
        }
        var stopArgs;
        if (isParser) stopArgs = "stopParser()";
        else if (job.is_multi) stopArgs = "multiJobStop('" + (job.phone || '') + "','" + job.type + "')";
        else stopArgs = "jobAction('stop','" + job.type + "')";
        controls += '<button class="btn btn-danger btn-sm tp-allow" onclick="' + stopArgs + '"><i class="bi bi-stop-fill"></i> Стоп</button>';
        controls += '</div>';
    }

    var breakdown = '';
    var c = job.counts || {};
    var isDmLike = isDm || job.type === 'dm';
    var hasBreakdown = (isJoin || isDmLike || job.type === 'spam') && (
        (c.sent || 0) + (c.failed || 0) + (c.no_rights || 0) +
        (c.spamfilter || 0) + (c.flood || 0) + (c.slowmode || 0) +
        (c.joined || 0) + (c.already_member || 0) > 0
    );
    if (hasBreakdown) {
        var parts = [];
        var sentKey = isJoin ? (c.joined || 0) : (c.sent || 0);
        if (sentKey) parts.push('<span style="color:var(--success);"><i class="bi bi-check-circle-fill"></i> ' + sentKey + '</span>');
        if (c.already_member) parts.push('<span style="color:var(--text-muted-strong);" title="уже состоит в чате">↩ уже ' + c.already_member + '</span>');
        if (c.no_rights) parts.push('<span style="color:var(--warning);" title="приватность/нет прав"><i class="bi bi-lock-fill"></i> приватность ' + c.no_rights + '</span>');
        if (c.spamfilter) parts.push('<span style="color:var(--warning);" title="спам-фильтр Telegram"><i class="bi bi-slash-circle-fill"></i> спам-блок ' + c.spamfilter + '</span>');
        if (c.flood) parts.push('<span style="color:var(--warning);" title="Временный флуд-блок от Telegram"><i class="bi bi-hourglass-split"></i> флуд ' + c.flood + '</span>');
        if (c.slowmode) parts.push('<span style="color:var(--text-muted-strong);" title="медленный режим"><i class="bi bi-speedometer"></i> медл-режим ' + c.slowmode + '</span>');
        if (c.failed) parts.push('<span style="color:var(--danger);" title="username не найден, приватность и т.п."><i class="bi bi-x-circle-fill"></i> ' + c.failed + '</span>');
        breakdown = '<div class="text-sm" style="margin-top:6px;display:flex;gap:10px;flex-wrap:wrap;">' + parts.join('') + '</div>';
    }

    return '<div class="card mb-3" style="border-left:3px solid ' + color + ';">' +
        '<div class="card-body">' +
            '<div class="d-flex align-center justify-between mb-2">' +
                '<span><i class="bi ' + icon + '" style="color:' + color + ';margin-right:6px;"></i><strong>' + label + '</strong> <span class="text-muted text-sm">+' + escHtml(job.phone || '') + '</span></span>' +
                '<span class="badge ' + (badgeMap[job.status] || '') + '">' + (statusMap[job.status] || job.status) + '</span>' +
            '</div>' +
            '<div class="progress mb-2"><div class="progress-bar" style="width:' + pct + '%;"></div></div>' +
            '<div class="d-flex justify-between text-sm text-muted">' +
                '<span>' + job.processed + '/' + job.total + ' (' + pct + '%)</span>' +
                '<span>' + escHtml(job.current_step || '') + '</span>' +
            '</div>' +
            breakdown +
            controls +
        '</div>' +
    '</div>';
}

function jobAction(action, type) {
    API.post('/api/job/' + action, {type: type}).then(function(d) {
        if (d.ok) showToast(action === 'pause' ? 'Приостановлено' : action === 'resume' ? 'Возобновлено' : 'Остановлено', 'success');
        refreshStatus();
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function multiJobStop(phone, type) {
    showConfirmDialog('Остановить задачу?', 'Аккаунт +' + phone + ' будет остановлен. Уже сделанное сохранится.', function() {
        API.post('/api/multi/stop', {phone: phone, type: type}).then(function(d) {
            if (d.ok) showToast('Остановлено: ' + d.stopped + ' задач', 'success');
            else showToast(d.error || 'Ошибка', 'error');
            refreshStatus();
        }).catch(function() { showToast('Ошибка', 'error'); });
    });
}

function multiStopAll() {
    showConfirmDialog('Остановить все мульти-задачи?', 'Все аккаунты будут остановлены одновременно.', function() {
        API.post('/api/multi/stop', {}).then(function(d) {
            if (d.ok) showToast('Остановлено: ' + d.stopped + ' задач', 'success');
            refreshStatus();
        }).catch(function() { showToast('Ошибка', 'error'); });
    });
}

// ══════════════════════════════════════════════════════════════════
//  SPAM — рассылка по чатам
// ══════════════════════════════════════════════════════════════════

var _spamTargets = [];
var _spamManualActive = false;  // ручной список чатов активен → рассылка шлёт use_manual

function collectTargets() {
    // используем первый из выбранных акков (multi-only mode)
    if (!Array.isArray(_multiSelected) || _multiSelected.length === 0) {
        showToast('Сначала выберите аккаунты', 'warning');
        openSpamMultiPicker();
        return;
    }
    var firstAcc = _multiSelected[0];
    showToast('Собираю чаты с акка ' + firstAcc + '...', 'info');
    API.post('/api/spam/targets/collect', {phone: firstAcc}).then(function(d) {
        if (d.ok) {
            _spamManualActive = false;  // авто-превью «Найти чаты» = НЕ ручной режим
            _spamTargets = [];
            for (var i = 0; i < d.count; i++) _spamTargets.push(i);
            document.getElementById('spam-count').textContent = d.count;
            document.getElementById('btn-spam').disabled = d.count === 0;
            var diag = d.diag || {};
            if (d.count === 0) {
                var msg;
                if (diag.fetch_error) {
                    msg = 'Не удалось дочитать список чатов (сеть/прокси моргнули на середине). Попробуй собрать ещё раз.';
                } else if ((diag.total || 0) === 0) {
                    msg = 'Аккаунт не состоит ни в одной группе или канале. Сначала вступи в чаты (раздел «Вступление»), потом собери снова.';
                } else if ((diag.channels || 0) > 0 && (diag.writable || 0) === 0) {
                    msg = 'У акка ' + diag.total + ' диалогов, из них ' + diag.channels + ' — это КАНАЛЫ. В каналы рассылка невозможна (там пишет только админ). Для рассылки нужны ГРУППЫ/чаты'
                        + (((diag.readonly_groups || 0) > 0) ? (' (ещё ' + diag.readonly_groups + ' групп только-чтение)') : '')
                        + '. Вступай в группы, не в каналы (в «Вступлении» есть галка «Только чаты/группы»).';
                } else if ((diag.readonly_groups || 0) > 0) {
                    msg = 'У акка есть группы (' + diag.readonly_groups + '), но во всех писать нельзя (только-чтение / форум-топики / бан на отправку). Вступи в группы, где разрешена отправка.';
                } else {
                    msg = 'Нет чатов для рассылки — акк не состоит в группах, где можно писать. Вступи в чаты (раздел «Вступление»), потом собери снова.';
                }
                showToast(msg, 'warning');
            } else {
                var extra = ((diag.channels || 0) > 0) ? (' (' + diag.channels + ' каналов пропущено — в них рассылка невозможна)') : '';
                showToast('Найдено ' + d.count + ' чатов для рассылки' + extra, 'success');
            }
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) { showToast((e && e.message) || 'Ошибка сбора чатов', 'error'); });
}

function toggleManualTargets(type) {
    var el = document.getElementById(type + '-manual');
    if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}

// Конфиг автоответчика для запуска ВМЕСТЕ с рассылкой. Поля общие (ar-*, вкладка
// «Автоответчик»), движок один — поэтому и сборщик один. Три копии этого объекта
// разъезжаются: новое поле добавят в одну, забудут в двух.
// Возвращает null, если галка выключена; {error: ...} — если включена, а текст пуст.
function _autoreplyPayload(switchId) {
    var on = !!((document.getElementById(switchId) || {}).checked);
    if (!on) return null;
    var text = ((document.getElementById('ar-text') || {}).value || '').trim();
    if (!text) return {error: 'Автоответчик включён, но текст пустой — заполни его на вкладке «Автоответчик»'};
    return {
        enabled: true,
        text: text,
        delay_min: parseFloat((document.getElementById('ar-delay-min') || {}).value || '3') || 3,
        delay_max: parseFloat((document.getElementById('ar-delay-max') || {}).value || '8') || 8,
        stop_words: (((document.getElementById('ar-stop-words') || {}).value) || '')
            .split(',').map(function (s) { return s.trim(); }).filter(Boolean),
        simulate_typing: !!((document.getElementById('ar-simulate-typing') || {}).checked),
        mark_as_read: !!((document.getElementById('ar-mark-as-read') || {}).checked),
        once_per_user: true
    };
}

// Галка «Чистить дубликаты в списке» — своя на вкладке чатов и на вкладке ЛС.
// Выключена по умолчанию: список уходит как есть, повторы не режем.
function _cleanDupsOn(tab) {
    var el = document.getElementById(tab + '-clean-dups');
    return !!(el && el.checked);
}

function addManualTargets(type) {
    var input = document.getElementById(type + '-targets-input');
    if (!input) return;
    var text = input.value.trim();
    if (!text) return;

    if (type === 'spam') {
        API.post('/api/spam/targets/manual', {text: text, clean_duplicates: _cleanDupsOn('spam')}).then(function(d) {
            if (d.ok) {
                document.getElementById('spam-count').textContent = d.count;
                document.getElementById('btn-spam').disabled = d.count === 0;
                input.value = '';
                _spamManualActive = d.count > 0;  // ручной режим активен
                showToast('Добавлено. Всего: ' + d.count + ' чатов — распределятся между акками', 'success');
                if (d.duplicates) {
                    showToast(d.duplicates_removed
                        ? ('Повторов убрано: ' + d.duplicates_removed)
                        : ('Повторов в списке: ' + d.duplicates + ' — оставлены, галка очистки выключена'),
                        'info');
                }
            }
        }).catch(function() { showToast('Ошибка', 'error'); });
    }
}

function applyPreparedChats() {
    // Подставляет чаты, собранные «Подготовкой аккаунтов» (модуль Проверка).
    API.get('/api/checker/prepared_chats').then(function(d) {
        if (!d || !d.ok) { showToast('Ошибка', 'error'); return; }
        if (!d.count) {
            showToast('Нет собранных чатов. Запусти Проверку с галкой «Подготовить аккаунты».', 'warning');
            return;
        }
        var manual = document.getElementById('spam-manual');
        if (manual) manual.style.display = 'block';
        var ta = document.getElementById('spam-targets-input');
        if (ta) ta.value = d.text || '';
        addManualTargets('spam');  // POST + активация целей
        showToast('Подставлено ' + d.count + ' чат(ов) из ' + d.accounts + ' акк', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function clearTargets(type) {
    if (type === 'spam') {
        API.post('/api/spam/targets/clear').then(function() {
            document.getElementById('spam-count').textContent = '0';
            document.getElementById('btn-spam').disabled = true;
            _spamManualActive = false;  // вернулись в авто-режим
            showToast('Список чатов очищен', 'info');
        });
    } else if (type === 'dm') {
        API.post('/api/dm/targets/clear').then(function() {
            document.getElementById('dm-count').textContent = '0';
            document.getElementById('btn-dm').disabled = true;
            showToast('Список юзеров очищен', 'info');
        });
    }
}


// ══════════════════════════════════════════════════════════════════
//  Anti-detect helper — собирает флаги «Защита аккаунта» с любого модуля
// ══════════════════════════════════════════════════════════════════

function readAdConfig(prefix) {
    function v(id) {
        var el = document.getElementById(prefix + '-' + id);
        return el && el.checked;
    }
    return {
        pre_spam_check: !!v('pre-spam-check'),
        solve_captcha: !!v('solve-captcha'),
        online: !!v('keep-online'),
        read_messages: !!v('read-msgs'),
        mark_read: !!v('mark-read'),
        view_profiles: !!v('view-profile'),
        view_photos: !!v('view-photo'),
        auto_leave_no_write: !!v('auto-leave-no-write'),
    };
}

// Per-module persist чекбоксов «Защита аккаунта» (spam/dm/join/invite/multi/
// warmup/clean): на любой change сохраняем в user_settings.module_ad[<module>],
// при первой загрузке refreshStatus подтягиваем и расставляем.
var _AD_PREFIXES = ['spam','dm','join','inv','multi','warmup','cln','nc'];
var _AD_MODULE_BY_PREFIX = {spam:'spam', dm:'dm', join:'join', inv:'invite',
                            multi:'multi', warmup:'warmup', cln:'clean', nc:'neuro_comments'};
var _AD_FIELDS = [
    ['pre-spam-check', 'pre_spam_check'],
    ['solve-captcha', 'solve_captcha'],
    ['keep-online',   'online'],
    ['read-msgs',     'read_messages'],
    ['mark-read',     'mark_read'],
    ['view-profile',  'view_profiles'],
    ['view-photo',    'view_photos'],
    ['auto-leave-no-write', 'auto_leave_no_write'],
];
var _adLoaded = false;

function _saveAdForPrefix(prefix) {
    var module = _AD_MODULE_BY_PREFIX[prefix];
    if (!module) return;
    var flags = {};
    _AD_FIELDS.forEach(function(pair) {
        var el = document.getElementById(prefix + '-' + pair[0]);
        if (el) flags[pair[1]] = !!el.checked;
    });
    API.post('/api/module_ad/save', { module: module, flags: flags }).catch(function(){});
}

function _applyAdSettings(allFlags) {
    _AD_PREFIXES.forEach(function(prefix) {
        var module = _AD_MODULE_BY_PREFIX[prefix];
        var saved = (allFlags && allFlags[module]) || {};
        _AD_FIELDS.forEach(function(pair) {
            var el = document.getElementById(prefix + '-' + pair[0]);
            if (!el) return;
            if (saved.hasOwnProperty(pair[1])) {
                el.checked = !!saved[pair[1]];
            }
            // На каждый change — авто-save (idempotent attach)
            if (!el._adSaveAttached) {
                el._adSaveAttached = true;
                el.addEventListener('change', function() { _saveAdForPrefix(prefix); });
            }
        });
    });
}

function loadAdSettings() {
    if (_adLoaded) return;
    _adLoaded = true;
    API.get('/api/module_ad/get').then(function(d) {
        _applyAdSettings((d && d.module_ad) || {});
    }).catch(function() { _adLoaded = false; });
}

function saveGlobalAdSettings() {
    var pre = document.getElementById('global-pre-spam-check');
    var payload = { pre_spam_check: !!(pre && pre.checked) };
    API.post('/api/global_ad/save', payload).then(function(d) {
        if (d && d.ok) showToast('Глобальная защита сохранена', 'success');
    }).catch(function() {});
}

// auto-install обновлений
function saveAutoUpdateInstall() {
    var cb = document.getElementById('s-auto-update-install');
    if (!cb) return;
    API.post('/api/settings/auto_update_install', { enabled: !!cb.checked })
        .then(function(d) {
            if (d && d.ok) {
                showToast(cb.checked
                    ? 'Авто-установка обновлений включена'
                    : 'Авто-установка обновлений выключена', 'success');
            }
        }).catch(function() {});
}

function loadAutoUpdateInstall() {
    API.get('/api/settings/auto_update_install').then(function(d) {
        var cb = document.getElementById('s-auto-update-install');
        if (cb && d) cb.checked = !!d.enabled;
    }).catch(function() {});
}

// Тумблера фонового сбора в интерфейсе больше нет — он включён у всех.
// Заглушки: функции зовут из общей загрузки настроек.
function saveBgScanner() {}
function loadBgScanner() {}

// Отдых аккаунта между задачами. UI в СЕКУНДАХ (единая единица во всём софте),
// эндпоинт исторически принимает минуты — переводим на границе.
function saveAccountCooldown() {
    var el = document.getElementById('s-account-cooldown-sec');
    if (!el) return;
    var sec = parseInt(el.value, 10);
    if (isNaN(sec) || sec < 0) sec = 0;
    if (sec > 7200) sec = 7200;          // потолок = 120 мин на бэкенде
    el.value = sec;
    _refreshSecHints();
    API.post('/api/settings/account_cooldown', { minutes: Math.round(sec / 60) })
        .then(function(d) {
            if (d && d.ok) {
                var s = (d.minutes || 0) * 60;
                showToast(s > 0 ? ('Отдых между задачами: ' + s + ' сек ' + _humanSeconds(s))
                                : 'Отдых между задачами выключен', 'success');
            }
        }).catch(function() {});
}
function loadAccountCooldown() {
    API.get('/api/settings/account_cooldown').then(function(d) {
        var el = document.getElementById('s-account-cooldown-sec');
        if (el && d && d.minutes !== undefined) { el.value = (d.minutes || 0) * 60; _refreshSecHints(); }
    }).catch(function() {});
}

// Настройки «работать без прокси» (allow_no_proxy / allow_login_without_proxy)
// удалены: гейты «без прокси» убраны из софта целиком, прокси необязателен нигде.

function onSpamModeChange() {
    var mode = (document.querySelector('input[name="spam-mode"]:checked') || {}).value || 'text';
    document.getElementById('spam-mode-text-block').style.display = mode === 'text' ? '' : 'none';
    document.getElementById('spam-mode-forward-block').style.display = mode === 'forward' ? '' : 'none';
}

function startSpam() {
    // всегда multi-режим (без активного, только выбор акков).
    if (!Array.isArray(_multiSelected) || _multiSelected.length === 0) {
        showToast('Сначала выберите аккаунты', 'warning');
        openSpamMultiPicker();
        return;
    }
    var ad = readAdConfig('spam');
    var mode = (document.querySelector('input[name="spam-mode"]:checked') || {}).value || 'text';

    var payload = { ad_config: ad, phones: _multiSelected.slice() };
    payload.use_manual = _spamManualActive;  // явный ручной режим (flush ниже уточнит)
    var loopEl = document.getElementById('spam-loop');
    if (loopEl && loopEl.checked) {
        // Пауза между кругами — в СЕКУНДАХ (единая единица во всём софте).
        // id намеренно новый (было spam-loop-minutes): сохранённое в localStorage
        // старое значение в минутах иначе прочиталось бы как секунды и круг
        // сократился бы с 30 минут до 30 секунд.
        payload.loop_seconds = parseInt(document.getElementById('spam-loop-seconds').value || '600', 10);
        var _lsMax = document.getElementById('spam-loop-seconds-max');
        if (_lsMax && _lsMax.value) payload.loop_seconds_max = parseInt(_lsMax.value, 10);
        // Сколько кругов: пусто/0 = бесконечно, N = N проходов и стоп.
        var _lrEl = document.getElementById('spam-loop-rounds');
        var _lr = _lrEl ? parseInt(_lrEl.value || '0', 10) : 0;
        payload.loop_rounds = (isNaN(_lr) || _lr < 0) ? 0 : _lr;
    }
    var capEl = document.getElementById('spam-max-per-acc');
    if (capEl) {
        var capVal = parseInt(capEl.value || '0', 10);
        if (!isNaN(capVal) && capVal > 0) payload.max_per_acc = capVal;
    }
    // PF tolerance — сколько спам-блоков подряд пропускать перед @SpamBot чеком
    var pfTolEl = document.getElementById('spam-pf-tolerance');
    if (pfTolEl) {
        var pfTolVal = parseInt(pfTolEl.value || '3', 10);
        if (!isNaN(pfTolVal)) payload.pf_tolerance = Math.max(0, Math.min(20, pfTolVal));
    }
    // «во все чаты» — каждый акк шлёт во ВСЕ свои чаты (без распределения между акками)
    var spamNoDedupEl = document.getElementById('spam-no-dedup');
    if (spamNoDedupEl) payload.no_dedup = spamNoDedupEl.checked;
    // Автоответчик вместе с рассылкой: галка multi-spam-autoreply на этой вкладке,
    // текст/настройки — из вкладки «Автоответчик» (общие ar-* поля, единый движок АО).
    var _ar = _autoreplyPayload('multi-spam-autoreply');
    if (_ar && _ar.error) { showToast(_ar.error, 'warning'); return; }
    if (_ar) payload.autoreply = _ar;
    // потоки — 0=все параллельно, N=лимит
    payload.threads = parseInt((document.getElementById('spam-threads') || {}).value) || 0;
    payload.dry_run = (document.getElementById('spam-dry-run') || {}).checked || false;

    var startUrl = '/api/multi/spam';
    var successMsg = function(d) { return _startedMsg(d, 'Рассылка запущена'); };
    var isMulti = true;  // legacy var for branches below

    if (mode === 'forward') {
        var srcChat = document.getElementById('spam-forward-chat').value.trim();
        var msgId = parseInt(document.getElementById('spam-forward-msg-id').value || '0', 10);
        if (!srcChat || !msgId) { showToast('Укажи источник и ID сообщения', 'warning'); return; }
        payload.forward_from_chat = srcChat;
        payload.forward_msg_id = msgId;
        return API.post('/api/spam/template', {template: '[forward]'}).then(function() {
            return API.post(startUrl, payload);
        }).then(function(d) {
            if (d && d.ok !== false) {
                showToast(isMulti ? 'Пересылка запущена: ' + _multiSelected.length + ' акков'
                                  : ('Пересылка запущена! ' + (d.total || 0) + ' чатов'), 'success');
                document.getElementById('btn-spam').disabled = true;
                refreshStatus();
            } else {
                showToast((d && d.error) || 'Ошибка запуска', 'error');
            }
        }).catch(function(e) { showToast((e && e.message) || 'Ошибка', 'error'); });
    }

    var tpl = document.getElementById('spam-tpl').value.trim();
    if (!tpl) { showToast('Введите текст сообщения', 'warning'); return; }
    var parseMode = document.getElementById('spam-parse-mode').value || '';
    if (parseMode) payload.parse_mode = parseMode;
    // Flush недобавленного ручного списка (юзер вписал в поле, но не нажал «Добавить»).
    // use_manual — ЯВНЫЙ флаг ручного режима (не по факту пула на сервере): иначе
    // устаревший manual_targets молча включил бы ручную раздачу на авто-рассылке.
    var _manualTa = document.getElementById('spam-targets-input');
    var _manualRaw = (_manualTa && _manualTa.value || '').trim();
    var _flushManual = _manualRaw
        ? API.post('/api/spam/targets/manual', {text: _manualRaw}).then(function(d) {
              if (_manualTa) _manualTa.value = '';
              _spamManualActive = !!(d && d.count > 0);  // вписанный текст = ручной режим
              if (d && typeof d.count === 'number') {
                  var _c = document.getElementById('spam-count');
                  if (_c) _c.textContent = d.count;
              }
          }).catch(function() {})
        : Promise.resolve();
    _flushManual.then(function() {
        payload.use_manual = _spamManualActive;
        return API.post('/api/spam/template', {template: tpl});
    }).then(function() {
        return API.post(startUrl, payload);
    }).then(function(d) {
        if (d && d.ok !== false) {
            showToast(successMsg(d), 'success');
            document.getElementById('btn-spam').disabled = true;
            refreshStatus();
        } else {
            showToast((d && d.error) || 'Ошибка запуска', 'error');
        }
    }).catch(function(e) { showToast((e && e.message) || 'Ошибка запуска рассылки', 'error'); });
}

function openSpamMultiPicker() {
    openMultiAccPopup();
    var checkClose = setInterval(function() {
        if (!document.getElementById('acc-popup-overlay')) {
            clearInterval(checkClose);
            updateSpamMultiCount();
        }
    }, 400);
}

function updateSpamMultiCount() {
    var el = document.getElementById('spam-multi-count');
    if (!el) return;
    var n = Array.isArray(_multiSelected) ? _multiSelected.length : 0;
    // Просто число рядом с кнопкой. «не выбрано» / «N выбрано» — лишние слова
    // там, где всё понятно по цифре.
    el.textContent = n;
    el.style.color = n === 0 ? 'var(--text-muted)' : 'var(--success)';
}

function stopSpam() {
    showConfirmDialog('Остановить рассылку?', 'Текущее сообщение допишется, дальше остановится. Автоответчик, поднятый этой рассылкой, тоже выключится. Прогресс сохранится.', function() {
        // дёргаем оба — single и multi spam.
        Promise.allSettled([
            API.post('/api/spam/stop', {}),
            API.post('/api/multi/stop', {type: 'spam'}),
        ]).then(function(results) {
            var any_ok = results.some(function(r) { return r.status === 'fulfilled' && r.value && r.value.ok; });
            if (any_ok) showToast('Запрос на остановку отправлен', 'info');
            else showToast('Не удалось остановить', 'error');
        });
    });
}

// ══════════════════════════════════════════════════════════════════
//  DM — рассылка в личные сообщения
// ══════════════════════════════════════════════════════════════════

// Автозагрузка таргетов при наборе — debounce 600мс после последней клавиши.
var _dmTargetsLoadTimer = null;
function _dmTargetsAutoload(el) {
    if (!el) return;
    if (_dmTargetsLoadTimer) clearTimeout(_dmTargetsLoadTimer);
    var txt = (el.value || '').trim();
    if (!txt) {
        // Очищаем и НА СЕРВЕРЕ. Раньше пустое поле просто не отправлялось, и
        // серверный список оставался прежним: юзер видел пустое поле и «0», а
        // рассылка ушла бы по старым получателям.
        _dmTargetsLoadTimer = setTimeout(function() {
            API.post('/api/dm/targets/clear').catch(function(){});
        }, 600);
        var cnt = document.getElementById('dm-count');
        if (cnt) cnt.textContent = '0';
        var btn = document.getElementById('btn-dm');
        if (btn) btn.disabled = true;
        return;
    }
    _dmTargetsLoadTimer = setTimeout(function() { loadDmTargets(true); }, 600);
}

function loadDmTargets(silent) {
    var input = document.getElementById('dm-targets-input');
    if (!input) return;
    var text = input.value.trim();
    if (!text) { if (!silent) showToast('Введите юзернеймы или ID', 'warning'); return; }

    API.post('/api/dm/targets', {text: text, clean_duplicates: _cleanDupsOn('dm')}).then(function(d) {
        if (d.ok) {
            document.getElementById('dm-count').textContent = d.count;
            document.getElementById('btn-dm').disabled = d.count === 0;
            if (!silent) showToast('Загружено ' + d.count + ' юзеров', 'success');
            if (!silent && d.failed && d.failed.length > 0) {
                showToast('Не распознано: ' + d.failed.join(', '), 'warning');
            }
            // ЗАЩИТА — показываем когда отсеяли свои акки фермы
            if (!silent && d.skipped_own_fleet) {
                var _ex = (d.skipped_examples || []).join(', ');
                showToast('Защита: отсеяно ' + d.skipped_own_fleet + ' твоих акков фермы (' + _ex + ') — DM на свои = бан отправителя', 'warning');
            }
        }
    }).catch(function(e) { if (!silent) showToast((e && e.message) || 'Ошибка загрузки', 'error'); });
}

function loadDmFile(input) {
    if (!input.files || !input.files[0]) return;
    var reader = new FileReader();
    reader.onload = function(e) {
        var text = e.target.result;
        document.getElementById('dm-targets-input').value = text;
        loadDmTargets();
    };
    reader.readAsText(input.files[0]);
}

function startDm() {
    // всегда multi-режим.
    if (!Array.isArray(_multiSelected) || _multiSelected.length === 0) {
        showToast('Сначала выберите аккаунты', 'warning');
        openDmMultiPicker();
        return;
    }
    var tpl = document.getElementById('dm-tpl').value.trim();
    var _fcEl = document.getElementById('dm-forward-chat');
    var _fmEl = document.getElementById('dm-forward-msg-id');
    var _isFwd = !!(_fcEl && _fcEl.value.trim() && _fmEl && parseInt(_fmEl.value || '0', 10));
    if (!tpl && !_isFwd) { showToast('Введите текст или укажи пост для репоста', 'warning'); return; }
    // Пустой текст автоответчика ловим ДО подтверждения запуска, а не после.
    var _arCheck = _autoreplyPayload('dm-autoreply');
    if (_arCheck && _arCheck.error) { showToast(_arCheck.error, 'warning'); return; }
    var isMulti = true;

    // cross-module overlap check
    checkBusyAndConfirm(_multiSelected.slice(), 'Рассылка по ЛС').then(function(decision) {
        if (decision.cancel) return;
        _multiSelected = decision.phones;  // обновлённый список после выбора юзера
        if (!_multiSelected.length) { showToast('Нет свободных аккаунтов', 'warning'); return; }
        _startDmCore(tpl, isMulti);
    });
}

function _startDmCore(tpl, isMulti) {
    API.post('/api/dm/template', {template: tpl}).then(function(d) {
        if (d.trigger_warnings && d.trigger_warnings.length > 0) {
            var warnEl = document.getElementById('dm-trigger-warn');
            var wordsEl = document.getElementById('dm-trigger-words');
            if (warnEl && wordsEl) {
                wordsEl.textContent = 'Осторожно! Слова-триггеры: ' + d.trigger_warnings.join(', ');
                warnEl.style.display = 'block';
            }
        }
        var dmPayload = {ad_config: readAdConfig('dm'), phones: _multiSelected.slice()};
        dmPayload.dry_run = (document.getElementById('dm-dry-run') || {}).checked || false;
        dmPayload.drip_steps = (typeof _collectDripSteps === 'function') ? _collectDripSteps() : [];
        dmPayload.drip_stop_if_replied = (document.getElementById('dm-drip-stop-replied') || {checked:true}).checked;
        var pmEl = document.getElementById('dm-parse-mode');
        if (pmEl && pmEl.value) dmPayload.parse_mode = pmEl.value;
        var capEl = document.getElementById('dm-max-per-acc');
        if (capEl) {
            var capVal = parseInt(capEl.value || '0', 10);
            if (!isNaN(capVal) && capVal > 0) dmPayload.max_per_acc = capVal;
        }
        // PF tolerance из DM таба
        var pfTolEl = document.getElementById('dm-pf-tolerance');
        if (pfTolEl) {
            var pfTolVal = parseInt(pfTolEl.value || '3', 10);
            if (!isNaN(pfTolVal)) dmPayload.pf_tolerance = Math.max(0, Math.min(20, pfTolVal));
        }
        var dmDelEl = document.getElementById('dm-del-contact');
        dmPayload.delete_contact_after = dmDelEl ? dmDelEl.checked : true;
        dmPayload.clean_duplicates = _cleanDupsOn('dm');
        // Автоответчик вместе с рассылкой по ЛС — тот же движок и те же поля,
        // что на вкладке «По чатам». Пустой текст отсечён в startDm() до запуска.
        var _arDm = _autoreplyPayload('dm-autoreply');
        if (_arDm && !_arDm.error) dmPayload.autoreply = _arDm;
        // Репост готового поста по ЛС (форвард / копия / альбом).
        var fwdChatEl = document.getElementById('dm-forward-chat');
        var fwdMsgEl = document.getElementById('dm-forward-msg-id');
        var fwdChat = fwdChatEl ? fwdChatEl.value.trim() : '';
        var fwdMsg = fwdMsgEl ? parseInt(fwdMsgEl.value || '0', 10) : 0;
        if (fwdChat && fwdMsg) {
            dmPayload.forward_from_chat = fwdChat;
            dmPayload.forward_msg_id = fwdMsg;
            var fhEl = document.getElementById('dm-forward-hide');
            var faEl = document.getElementById('dm-forward-album');
            dmPayload.forward_hide_source = fhEl ? fhEl.checked : false;
            dmPayload.forward_album = faEl ? faEl.checked : false;
        }
        return API.post('/api/multi/dm', dmPayload);
    }).then(function(d) {
        if (d && d.ok !== false) {
            showToast(isMulti
                ? 'Рассылка по ЛС запущена: ' + _multiSelected.length + ' акков'
                : ('DM рассылка запущена! ' + (d.total || 0) + ' юзеров'), 'success');
            document.getElementById('btn-dm').disabled = true;
            // Армим авто-снятие галочки с отработавших акков (см. блок в poll).
            // Сброс на КАЖДЫЙ старт — состояние per-run.
            window._dmAutoArmed = true;
            window._dmAutoDeselected = {};
            window._dmLastSeen = {};
            refreshStatus();
        } else {
            showToast((d && d.error) || 'Ошибка запуска', 'error');
        }
    }).catch(function(e) { showToast((e && e.message) || 'Ошибка запуска DM', 'error'); });
}

function openDmMultiPicker() {
    openMultiAccPopup();
    var checkClose = setInterval(function() {
        if (!document.getElementById('acc-popup-overlay')) {
            clearInterval(checkClose);
            updateDmMultiCount();
        }
    }, 400);
}

function updateDmMultiCount() {
    var el = document.getElementById('dm-multi-count');
    if (!el) return;
    var n = Array.isArray(_multiSelected) ? _multiSelected.length : 0;
    // Просто число рядом с кнопкой. «не выбрано» / «N выбрано» — лишние слова
    // там, где всё понятно по цифре.
    el.textContent = n;
    el.style.color = n === 0 ? 'var(--text-muted)' : 'var(--success)';
}

function stopDm() {
    showConfirmDialog('Остановить рассылку в ЛС?', 'Текущее сообщение допишется, дальше остановится. Прогресс сохранится.', function() {
        // дёргаем ОБА эндпоинта — single и multi (мульти-воркеры живут отдельно).
        Promise.allSettled([
            API.post('/api/dm/stop', {}),
            API.post('/api/multi/stop', {type: 'dm'}),
        ]).then(function(results) {
            var any_ok = results.some(function(r) { return r.status === 'fulfilled' && r.value && r.value.ok; });
            if (any_ok) showToast('Запрос на остановку отправлен', 'info');
            else showToast('Не удалось остановить', 'error');
        });
    });
}

// ══════════════════════════════════════════════════════════════════
//  JOIN — вступление в чаты
// ══════════════════════════════════════════════════════════════════

function loadJoinFile(input) {
    if (!input.files || !input.files[0]) return;
    var reader = new FileReader();
    reader.onload = function(e) {
        var el = document.getElementById('join-links');
        el.value = (el.value ? el.value + '\n' : '') + e.target.result;
        el.dispatchEvent(new Event('input'));
    };
    reader.readAsText(input.files[0]);
}

// Предфильтр: ловим явных ботов и одиночных юзеров в списке — предлагаем убрать
// до старта. Логика та же что потом даст ignored:not_a_chat на сервере.
function _sniffJunkLinks(text) {
    var lines = (text || '').split(/[\r\n]+/).map(function(s){return s.trim();}).filter(Boolean);
    var bots = [], users = [];
    lines.forEach(function(l) {
        var m = l.match(/^(?:https?:\/\/)?(?:t|telegram)\.me\/([A-Za-z0-9_]{4,})\/?$/i);
        if (!m) return;
        var uname = m[1].toLowerCase();
        if (uname.endsWith('bot')) bots.push(l);
    });
    return {bots: bots, users: users};
}

function startJoin() {
    // всегда multi-режим.
    if (!Array.isArray(_multiSelected) || _multiSelected.length === 0) {
        showToast('Сначала выберите аккаунты', 'warning');
        openJoinMultiPicker();
        return;
    }
    var taEl = document.getElementById('join-links');
    var links = taEl.value.trim();
    if (!links) { showToast('Введите ссылки на чаты', 'warning'); return; }

    var junk = _sniffJunkLinks(links);
    if (junk.bots.length >= 3) {
        var msg = 'В списке ' + junk.bots.length + ' ссылок на ботов (оканчиваются на _bot). ' +
                  'Подписаться на бота нельзя — они всё равно не пройдут (ignored:not_a_chat).\n\n' +
                  'Убрать их автоматически и продолжить?';
        showConfirmDialog('Убрать ботов из списка?', msg, function() {
            var botSet = {}; junk.bots.forEach(function(b) { botSet[b] = 1; });
            var clean = links.split(/[\r\n]+/).filter(function(l) { return !botSet[l.trim()]; }).join('\n');
            taEl.value = clean;
            taEl.dispatchEvent(new Event('input'));
            _doStartJoin(clean);
        }, function() {
            _doStartJoin(links);
        });
        return;
    }
    _doStartJoin(links);
}

function _doStartJoin(links) {
    API.post('/api/join/load', {text: links}).then(function(d) {
        if (!d.ok) { showToast('Ошибка загрузки ссылок', 'error'); return Promise.reject(); }
        var payload = {ad_config: readAdConfig('join')};
        var capEl = document.getElementById('join-max-per-acc');
        if (capEl) {
            var capVal = parseInt(capEl.value || '0', 10);
            if (!isNaN(capVal) && capVal > 0) payload.max_per_acc = capVal;
        }
        // «Только чаты/группы, не каналы» — broadcast-каналы пропускаются
        var joinOnlyChatsEl = document.getElementById('join-only-chats');
        payload.only_chats = !!(joinOnlyChatsEl && joinOnlyChatsEl.checked);
        // потоки
        payload.threads = parseInt((document.getElementById('join-threads') || {}).value) || 0;
        // «Ждать любой FloodWait и продолжать» — шлём явно, бэк перезаписывает
        // ctx перед стартом джоба.
        var _fwWaitEl = document.getElementById('s-join-fw-wait');
        payload.floodwait_wait_continue = !!(_fwWaitEl && _fwWaitEl.checked);
        // «Автопилот» — раньше снятие галки не уходило на бэк (оставался старый ctx).
        var _apEl = document.getElementById('s-join-autopilot');
        payload.join_autopilot = !!(_apEl && _apEl.checked);
        payload.clean_duplicates = _cleanDupsOn('join');

        // Если в Подписке выбраны доп. акки через openJoinMultiPicker —
        // стартуем мульти-подписку прямо отсюда (отдельная вкладка не нужна).
        if (Array.isArray(_multiSelected) && _multiSelected.length > 0) {
            payload.phones = _multiSelected.slice();
            // backend /api/multi/join ожидает phones[], cap, etc.
            return API.post('/api/multi/join', payload).then(function(r) {
                if (r && r.ok !== false) {
                    showToast(_startedMsg(r, 'Подписка запущена'), _startedTone(r));
                    document.getElementById('btn-join').disabled = true;
                    refreshStatus();
                } else if (r && r.error) {
                    showToast(r.error, 'error');
                }
                return r;
            });
        }
        return API.post('/api/join/start', payload).then(function(r) {
            if (r && r.ok) {
                showToast('Подписка запущена! ' + r.total + ' чатов' + (r.removed > 0 ? ' (убрано ' + r.removed + ' дубликатов)' : ''), 'success');
                document.getElementById('btn-join').disabled = true;
                refreshStatus();
            } else if (r) {
                showToast(r.error || 'Ошибка запуска', 'error');
            }
            return r;
        });
    }).catch(function(e) { showToast((e && (e.message || (e.data && e.data.error))) || 'Ошибка запуска подписки', 'error'); });
}

function openJoinMultiPicker() {
    // Используем существующий popup из Multi-вкладки. После закрытия —
    // обновляем счётчик возле кнопки в Подписке.
    openMultiAccPopup();
    // Перехватим закрытие popup'а чтобы обновить badge
    var checkClose = setInterval(function() {
        if (!document.getElementById('acc-popup-overlay')) {
            clearInterval(checkClose);
            updateJoinMultiCount();
        }
    }, 400);
}

function updateJoinMultiCount() {
    var el = document.getElementById('join-multi-count');
    if (!el) return;
    var n = Array.isArray(_multiSelected) ? _multiSelected.length : 0;
    // Просто число рядом с кнопкой. «не выбрано» / «N выбрано» — лишние слова
    // там, где всё понятно по цифре.
    el.textContent = n;
    el.style.color = n === 0 ? 'var(--text-muted)' : 'var(--success)';
}

function stopJoin() {
    showConfirmDialog('Остановить вступление?', 'Текущая ссылка дообработается, дальше процесс остановится.', function() {
        // дёргаем оба — single и multi join.
        Promise.allSettled([
            API.post('/api/join/stop', {}),
            API.post('/api/multi/stop', {type: 'join'}),
        ]).then(function(results) {
            var any_ok = results.some(function(r) { return r.status === 'fulfilled' && r.value && r.value.ok; });
            if (any_ok) showToast('Запрос на остановку отправлен', 'info');
            else showToast('Не удалось остановить', 'error');
        });
    });
}

// ══════════════════════════════════════════════════════════════════
//  MULTI — несколько аккаунтов
// ══════════════════════════════════════════════════════════════════

// Восстанавливаем из localStorage чтобы выбор пачки переживал перезагрузку
// страницы. Сохраняем через _saveMultiSelected() везде где _multiSelected
// меняется (push/splice/=).
var _multiSelected = (function() {
    try {
        var raw = localStorage.getItem('tp_multi_selected');
        if (!raw) return [];
        var arr = JSON.parse(raw);
        return Array.isArray(arr) ? arr : [];
    } catch (e) {
        return [];
    }
})();

function _saveMultiSelected() {
    try {
        localStorage.setItem('tp_multi_selected', JSON.stringify(_multiSelected));
    } catch (e) { /* quota / private mode */ }
}

function loadMultiAccounts() {
    // Just update counter, popup handles selection
    var countEl = document.getElementById('multi-selected-count');
    if (countEl) countEl.textContent = _multiSelected.length + ' выбрано';
    _updateMultiButtons();
    refreshMultiTimings();
}

function refreshMultiTimings() {
    // Снимаем актуальные значения из исходных input-ов модулей и
    // показываем в превью. Если ползунки ещё не инициализированы (settings
    // не успели загрузиться) — берём из API.
    var get = function(id) {
        var el = document.getElementById(id);
        return el ? el.value : null;
    };
    var setText = function(id, v) {
        var el = document.getElementById(id);
        if (el) el.textContent = (v === null || v === undefined || v === '') ? '—' : v;
    };
    setText('mtj-i', get('s-join-interval'));
    setText('mtj-j', get('s-join-jitter'));
    setText('mtj-m', get('s-join-max'));
    setText('mtj-b', get('s-join-break'));
    setText('mtj-bm', get('s-join-break-max'));
    setText('mts-mn', get('s-spam-min'));
    setText('mts-mx', get('s-spam-max'));
    setText('mts-b', get('s-spam-burst'));
    setText('mts-bp', get('s-spam-burst-pause-min'));
    setText('mts-bpm', get('s-spam-burst-pause-max'));
    setText('mtd-mn', get('s-dm-min'));
    setText('mtd-mx', get('s-dm-max'));
    setText('mtd-b', get('s-dm-burst'));
    setText('mtd-bp', get('s-dm-burst-pause-min'));
    setText('mtd-bpm', get('s-dm-burst-pause-max'));
    // Если значений нет (не загружены) — fallback на /api/settings
    if (!get('s-join-interval')) {
        API.get('/api/settings').then(function(s) {
            if (!s) return;
            var jn = s.join || {}, sp = s.spam || {}, dm = s.dm || {};
            setText('mtj-i', jn.min_interval); setText('mtj-j', jn.jitter); setText('mtj-m', jn.max_per_session);
            setText('mtj-b', jn.session_break_min); setText('mtj-bm', jn.session_break_max);
            setText('mts-mn', sp.min_delay); setText('mts-mx', sp.max_delay); setText('mts-b', sp.burst_size);
            setText('mts-bp', sp.burst_pause_min); setText('mts-bpm', sp.burst_pause_max);
            setText('mtd-mn', dm.min_delay); setText('mtd-mx', dm.max_delay); setText('mtd-b', dm.burst_size);
            setText('mtd-bp', dm.burst_pause_min); setText('mtd-bpm', dm.burst_pause_max);
        }).catch(function() {});
    }
}

// Модалка таймингов для Multi-режима. Не переключает таб, не сбрасывает выбор
// акков. Поля синхронизируются с range-слайдерами одиночного модуля
// (s-join-*, s-spam-*, s-dm-*) — изменения автосохраняются через change-listener.
function openMultiTimingsModal(module) {
    var CONFIG = {
        join: {
            title: 'Тайминги Подписки',
            color: 'success',
            fields: [
                {label: 'Пауза между (сек)', source: 's-join-interval', min: 5, max: 60, tip: 'Базовая пауза между подписками с одного акка'},
                {label: 'Случайность ±сек', source: 's-join-jitter', min: 0, max: 15, tip: 'Случайный разброс к базовой паузе (18+3 = 18-21с)'},
                {label: 'До паузы (шт)', source: 's-join-max', min: 3, max: 20, tip: 'Сколько подписок подряд перед длинной паузой'},
                {label: 'Длинная пауза мин (сек)', source: 's-join-break', min: 60, max: 600, tip: 'Минимальная длинная пауза'},
                {label: 'Длинная пауза макс (сек)', source: 's-join-break-max', min: 120, max: 1200, tip: 'Максимальная длинная пауза'},
                {label: 'Стоп при FloodWait > (сек)', source: 's-join-floodwait-stop', min: 60, max: 900, tip: 'Стопаем задачу только если Telegram попросил подождать > N сек. 300 = норма; меньше — старое жёсткое поведение.'}
            ]
        },
        spam: {
            title: 'Тайминги Рассылки по чатам',
            color: 'info',
            fields: [
                {label: 'Мин. пауза (сек)', source: 's-spam-min', min: 3, max: 30, tip: 'Минимальная пауза между сообщениями'},
                {label: 'Макс. пауза (сек)', source: 's-spam-max', min: 5, max: 60, tip: 'Максимальная пауза. Реальная задержка случайна в [мин, макс]'},
                {label: 'До паузы (шт)', source: 's-spam-burst', min: 1, max: 15, tip: 'Сколько сообщений подряд перед длинной паузой'},
                {label: 'Длинная пауза мин (сек)', source: 's-spam-burst-pause-min', min: 30, max: 600, tip: 'Минимальная длинная пауза'},
                {label: 'Длинная пауза макс (сек)', source: 's-spam-burst-pause-max', min: 60, max: 1200, tip: 'Максимальная длинная пауза'},
                {label: 'Стоп при FloodWait > (сек)', source: 's-spam-floodwait-stop', min: 60, max: 900, tip: 'Стопаем задачу если Telegram попросил подождать > N сек. 300 = норма.'}
            ]
        },
        dm: {
            title: 'Тайминги Рассылки в ЛС',
            color: 'pink',
            fields: [
                {label: 'Мин. пауза (сек)', source: 's-dm-min', min: 10, max: 120, tip: 'Минимальная пауза между ЛС. В ЛС лимиты жёстче — 30+с минимум'},
                {label: 'Макс. пауза (сек)', source: 's-dm-max', min: 20, max: 300, tip: 'Максимальная пауза между ЛС'},
                {label: 'До паузы (шт)', source: 's-dm-burst', min: 1, max: 10, tip: 'Сколько ЛС подряд перед длинной паузой (в ЛС держи 2-5)'},
                {label: 'Длинная пауза мин (сек)', source: 's-dm-burst-pause-min', min: 60, max: 900, tip: 'Минимальная длинная пауза (5+ минут безопаснее)'},
                {label: 'Длинная пауза макс (сек)', source: 's-dm-burst-pause-max', min: 120, max: 1800, tip: 'Максимальная длинная пауза'},
                {label: 'Стоп при FloodWait > (сек)', source: 's-dm-floodwait-stop', min: 60, max: 900, tip: 'В ЛС лимиты жёстче — можешь поставить 600.'}
            ]
        }
    };
    var cfg = CONFIG[module];
    if (!cfg) return;

    var old = document.getElementById('multi-timings-overlay');
    if (old) old.remove();

    // Снимок текущих значений — для Отмена (rollback)
    var snapshot = {};
    cfg.fields.forEach(function(f) {
        var el = document.getElementById(f.source);
        snapshot[f.source] = el ? el.value : '';
    });

    var fieldsHtml = cfg.fields.map(function(f, i) {
        var cur = snapshot[f.source] || '';
        return (
            '<div style="margin-bottom:14px;">' +
                '<label style="display:block;font-size:0.78rem;color:var(--text-primary);margin-bottom:6px;">' +
                    f.label +
                '</label>' +
                '<input type="number" inputmode="numeric" class="form-control" id="mtm-r-' + i + '" min="0" step="1" value="' + cur + '" style="max-width:150px;">' +
                '<div style="font-size:0.65rem;color:var(--text-muted);margin-top:3px;">' + f.tip + '</div>' +
            '</div>'
        );
    }).join('');

    var overlay = document.createElement('div');
    overlay.id = 'multi-timings-overlay';
    overlay.className = 'tp-modal-overlay';
    overlay.innerHTML =
        '<div class="tp-modal" style="max-width:520px;">' +
            '<div class="tp-modal-title">' +
                '<i class="bi bi-clock text-' + cfg.color + '"></i> ' + cfg.title +
            '</div>' +
            '<div class="tp-modal-body">' +
                '<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:14px;">' +
                    'Эти тайминги используются и в Multi-режиме, и в одиночном модуле «' + cfg.title.replace('Тайминги ', '') + '».' +
                '</div>' +
                fieldsHtml +
            '</div>' +
            '<div class="tp-modal-actions">' +
                '<button class="btn btn-outline" id="mtm-cancel">Отмена</button>' +
                '<button class="btn btn-accent" id="mtm-save">Сохранить</button>' +
            '</div>' +
        '</div>';
    document.body.appendChild(overlay);

    // Live-синк: при движении ползунка обновляем источник + триггерим change
    // (чтобы автосохранение settings сработало как обычно).
    cfg.fields.forEach(function(f, i) {
        var slider = document.getElementById('mtm-r-' + i);
        var syncSrc = function() {
            var src = document.getElementById(f.source);
            if (src) {
                src.value = slider.value;
                src.dispatchEvent(new Event('input', {bubbles: true}));
                src.dispatchEvent(new Event('change', {bubbles: true}));
            }
        };
        slider.addEventListener('input', syncSrc);
        slider.addEventListener('change', syncSrc);
    });

    document.getElementById('mtm-save').onclick = function() {
        overlay.remove();
        refreshMultiTimings();
        showToast('Тайминги сохранены', 'success');
    };
    document.getElementById('mtm-cancel').onclick = function() {
        // Rollback к снимку
        cfg.fields.forEach(function(f) {
            var src = document.getElementById(f.source);
            if (src && snapshot[f.source] !== '') {
                src.value = snapshot[f.source];
                var srcLabel = document.getElementById('v-' + f.source.slice(2));
                if (srcLabel) srcLabel.textContent = snapshot[f.source];
                src.dispatchEvent(new Event('input', {bubbles: true}));
                src.dispatchEvent(new Event('change', {bubbles: true}));
            }
        });
        overlay.remove();
        refreshMultiTimings();
    };
    // Esc / клик по фону = отмена
    overlay.addEventListener('click', function(e) {
        if (e.target === overlay) document.getElementById('mtm-cancel').click();
    });
}

function toggleMultiAcc(phone, btn) {
    var idx = _multiSelected.indexOf(phone);
    if (idx === -1) {
        _multiSelected.push(phone);
        btn.className = 'btn btn-sm btn-accent';
    } else {
        _multiSelected.splice(idx, 1);
        btn.className = 'btn btn-sm btn-outline';
    }
    _saveMultiSelected();
    _updateMultiButtons();
}

function _updateMultiButtons() {
    var has = _multiSelected.length > 0;
    var el;
    el = document.getElementById('btn-multi-join'); if (el) el.disabled = !has;
    el = document.getElementById('btn-multi-spam'); if (el) el.disabled = !has;
    el = document.getElementById('btn-multi-dm'); if (el) el.disabled = !has;
}

// «Снять выбор» рядом с «X выбрано».
function clearMultiSelected() {
    if (!_multiSelected.length) return;
    _multiSelected = [];
    _saveMultiSelected();
    // Обновляем счётчики spam/dm/join-multi-count и кнопки (каждый гейтит по своему el).
    try { updateSpamMultiCount(); } catch (e) {}
    try { updateDmMultiCount(); } catch (e) {}
    try { updateJoinMultiCount(); } catch (e) {}
    try { _updateMultiButtons(); } catch (e) {}
    var _msc = document.getElementById('multi-selected-count');
    if (_msc) _msc.textContent = '0 выбрано';
    showToast('Выбор аккаунтов снят', 'info');
}

// Автосчётчик ссылок в Подписке — обновляется при наборе.
function updateJoinCount() {
    var ta = document.getElementById('join-links');
    var cn = document.getElementById('join-count');
    if (!ta || !cn) return;
    var lines = (ta.value || '').split('\n').map(function(s){return s.trim();}).filter(function(s){return s.length>0;});
    cn.textContent = lines.length;
    var btn = document.getElementById('btn-join');
    if (btn) btn.disabled = lines.length === 0;
}

// Единая статус-логика для ВСЕХ выборок акков (одиночная/мульти).
// Заморозка=синий, спам=жёлтый, готов=зелёный, занят=акцент, мёртвый=красный.
function _accStatusInfo(a) {
    var inSpam = !!(a.spam_blocked || a.folder === 'В спам' || a.folder === 'Вечный спам');
    if (a.dead) return {color: 'var(--danger)', label: 'Мёртвый'};
    if (a.frozen) return {color: '#4a8fd4', label: 'Заморозка'};
    if (a.busy) return {color: 'var(--accent)', label: '▶ ' + a.busy};
    if (inSpam) return {color: 'var(--warning)', label: 'Спам'};
    if (a.available) {
        // Отличаем ПРОВЕРЕННОГО-чистого от НЕ проверенного через @SpamBot: раньше
        // оба светились зелёным «Готов», поэтому в пикере не было видно, кого
        // реально проверили (часть акков проверка пропускала — см. очередь
        // spam_check). Непроверенный НЕ блокируется, просто другой цвет/лейбл,
        // и флаг unchecked для фильтра «только проверенные».
        var checkedAt = a.spam_checked_at || a.last_spam_check_at || 0;
        if (checkedAt > 0) return {color: 'var(--success)', label: 'Готов'};
        return {color: 'var(--text-muted)', label: 'Не проверен', unchecked: true};
    }
    return {color: 'var(--text-muted)', label: 'Спит'};
}

// Поиск-матч акка по номеру / @юзернейму / имени (для всех выборок).
// Окно выбора аккаунтов, когда их ещё нет. Открывается точно так же, как обычное,
// и объясняет, что делать: молча гаснущая кнопка выглядит как поломка.
function _showEmptyAccountPicker(opts) {
    opts = opts || {};
    var old = document.getElementById('acc-popup-overlay');
    if (old) old.remove();
    var overlay = document.createElement('div');
    overlay.id = 'acc-popup-overlay';
    overlay.className = 'acc-popup-overlay';
    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
    var popup = document.createElement('div');
    popup.className = 'acc-popup';
    popup.innerHTML =
        '<div class="acc-popup-head">' +
            '<h3><i class="bi ' + (opts.icon || 'bi-people') + '" style="color:' +
            (opts.iconColor || 'var(--accent-light)') + ';margin-right:6px;"></i>' +
            escHtml(opts.title || 'Выбор аккаунтов') + '</h3>' +
            '<button class="acc-popup-close" type="button"><i class="bi bi-x-lg"></i></button>' +
        '</div>' +
        '<div style="padding:18px 4px 6px;text-align:center;color:var(--text-muted);font-size:0.82rem;line-height:1.6;">' +
            '<i class="bi bi-people" style="font-size:1.8rem;opacity:0.35;display:block;margin-bottom:10px;"></i>' +
            'Аккаунтов пока нет.<br>Добавьте их на вкладке «Аккаунты»: по номеру телефона или скопируйте купленные в папку «В работе».' +
        '</div>' +
        '<div style="text-align:center;padding-bottom:4px;">' +
            '<button class="btn btn-accent btn-sm" type="button" id="acc-popup-goto-accounts">Перейти к аккаунтам</button>' +
        '</div>';
    overlay.appendChild(popup);
    document.body.appendChild(overlay);
    popup.querySelector('.acc-popup-close').onclick = function() { overlay.remove(); };
    var go = popup.querySelector('#acc-popup-goto-accounts');
    if (go) go.onclick = function() {
        overlay.remove();
        try { showTab('accounts'); } catch (e) {}
    };
}

function openAccountPicker(opts) {
    // Единый пикер аккаунтов: табы-папки + поиск + выбрать-все-в-папке +
    // статус-дот (_accStatusInfo) + занятость. Все модули зовут его через тонкие
    // адаптеры (openMultiAccPopup / openAccMultiPopup / openAccPopup /
    // openInviteAccPopup ниже). opts:
    //   mode 'multi'|'single', title, icon, iconColor,
    //   includeDead (показать мёртвых выключенными), disableFrozen,
    //   isSelected(phone), toggle(phone,on), onChange(), onDone()  [multi]
    //   onPick(phone)                                              [single]
    opts = opts || {};
    var mode = opts.mode || 'multi';
    var includeDead = !!opts.includeDead;
    var disableFrozen = !!opts.disableFrozen;
    // Переавторизация/конвертер ДОЛЖНЫ давать выбрать мёртвых (реавторизация
    // их и оживляет) — иначе пикер их глушит и фичу не запустить.
    var allowDead = !!opts.allowDead;
    API.get('/api/accounts').then(function(d) {
        // Пустой список — окно всё равно открываем. Раньше кнопка молча показывала
        // всплывашку «Нет аккаунтов», и человек не понимал, нажалась ли она вообще
        // и что делать дальше.
        if (!d.accounts || !d.accounts.length) { _showEmptyAccountPicker(opts); return; }
        var old = document.getElementById('acc-popup-overlay');
        if (old) old.remove();

        var overlay = document.createElement('div');
        overlay.id = 'acc-popup-overlay';
        overlay.className = 'acc-popup-overlay';
        overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };

        var popup = document.createElement('div');
        popup.className = 'acc-popup';
        // Шапка липкая: список бывает на сотни акков, а «Готово» раньше висело в
        // самом низу — до него надо было прокрутить всё. Плюс выбор и так
        // сохраняется на каждый клик (toggle → _saveMultiSelected), так что кнопка
        // это просто «закрыть», и она обязана быть на виду всегда.
        var headBar = document.createElement('div');
        headBar.className = 'acc-popup-head';
        headBar.innerHTML =
            '<h3><i class="bi ' + (opts.icon || 'bi-people') + '" style="color:' + (opts.iconColor || 'var(--accent-light)') + ';margin-right:6px;"></i>' +
            escHtml(opts.title || 'Выбор аккаунтов') + '</h3>' +
            '<button class="acc-popup-close" type="button"><i class="bi bi-x-lg"></i></button>';
        popup.appendChild(headBar);
        headBar.querySelector('.acc-popup-close').onclick = function() { overlay.remove(); };

        var okBtn = null;
        if (mode === 'multi') {
            okBtn = document.createElement('button');
            okBtn.type = 'button';
            // НЕ *-popup-* в имени класса: тематические правила бьют по
            // [class*="popup"] с !important и перекрасили бы акцентную кнопку в фон.
            okBtn.className = 'btn btn-accent btn-sm accpick-done';
            okBtn.onclick = function() {
                try { if (opts.onDone) opts.onDone(); } catch (e) {}
                overlay.remove();
            };
            headBar.appendChild(okBtn);
        }
        // Счётчик прямо на кнопке — видно сколько набрал, не закрывая пикер.
        function _syncDone() {
            if (!okBtn) return;
            var n = 0;
            allAccounts.forEach(function(a) {
                if (opts.isSelected && opts.isSelected(a.phone)) n++;
            });
            okBtn.innerHTML = '<i class="bi bi-check2"></i> Готово' +
                (n ? ' <b>' + n + '</b>' : '');
        }

        // Пул: папку «Мёртвые» прячем всегда; мёртвых-вне-папки показываем
        // выключенными только если includeDead (инвайт), иначе скрываем.
        var allAccounts = d.accounts.filter(function(a) {
            var fn = (a.folder || a.folder_name || '').trim();
            // При allowDead (реавторизация/конвертер) папку «Мёртвые» НЕ прячем —
            // иначе пары, лежащие в Мёртвые/, нельзя выбрать на оживление.
            if ((fn === 'Мёртвые' || fn === 'Dead') && !allowDead) return false;
            if (a.dead && !includeDead) return false;
            return true;
        });

        var folderOrder = ['В работе', 'Группы', 'Личка', 'Прогрев', 'Ферма',
                           'Мониторинг', 'Архив', 'Заморозка', 'В спам', 'Вечный спам'];
        if (allowDead) folderOrder.push('Мёртвые');
        var folderCounts = {};
        allAccounts.forEach(function(a) {
            var fn = (a.folder || a.folder_name || 'В работе').trim() || 'В работе';
            folderCounts[fn] = (folderCounts[fn] || 0) + 1;
        });
        Object.keys(folderCounts).forEach(function(fn) {
            if ((fn === 'Мёртвые' || fn === 'Dead') && !allowDead) return;
            if (folderOrder.indexOf(fn) === -1) folderOrder.push(fn);
        });

        var currentFolder = 'all';
        var searchQuery = '';

        var foldersBar = document.createElement('div');
        foldersBar.className = 'multi-folder-bar';
        // Счётчик «Все» = ровно то, что этот вид покажет. Иначе цифра обещает больше,
        // чем в списке, и человек ищет пропавшие аккаунты.
        var _allShown = allAccounts.filter(function(a) {
            return !_isQuietFolder(a.folder || a.folder_name || '');
        }).length;
        var chips = '<span class="multi-folder-chip is-active" data-folder="all">' +
                    '<i class="bi bi-grid-fill"></i> Все <b>' + _allShown + '</b></span>';
        folderOrder.forEach(function(fn) {
            var c = folderCounts[fn] || 0;
            if (c === 0) return;
            chips += '<span class="multi-folder-chip" data-folder="' + escHtml(fn) + '">' +
                     escHtml(fn) + ' <b>' + c + '</b></span>';
        });
        foldersBar.innerHTML = chips;
        popup.appendChild(foldersBar);

        var searchRow = document.createElement('div');
        searchRow.style.cssText = 'position:relative;margin:6px 0 8px;';
        searchRow.innerHTML =
            '<input type="text" id="accpick-search" placeholder="Поиск: номер, @username или имя" ' +
            'autocomplete="off" spellcheck="false" ' +
            'style="width:100%;padding:7px 30px 7px 30px;font-size:0.78rem;background:var(--bg-input,#1a1a26);' +
            'border:1px solid var(--border,#2a2a3a);border-radius:5px;color:var(--text);outline:none;">' +
            '<i class="bi bi-search" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);' +
            'color:var(--text-muted);font-size:0.8rem;pointer-events:none;"></i>' +
            '<i class="bi bi-x-circle-fill" id="accpick-search-clear" ' +
            'style="position:absolute;right:10px;top:50%;transform:translateY(-50%);color:var(--text-muted);' +
            'font-size:0.85rem;cursor:pointer;display:none;"></i>';
        popup.appendChild(searchRow);

        var selAll = null, selAllCb = null;
        if (mode === 'multi') {
            selAll = document.createElement('div');
            selAll.className = 'multi-select-all-row';
            selAll.innerHTML = '<input type="checkbox" id="accpick-select-all"> ' +
                               '<span class="multi-select-all-text">Выбрать все</span>' +
                               '<button type="button" id="accpick-select-clean" ' +
                               'title="Выбрать только проверенных через @SpamBot и без спам-блока (непроверенных и спам снять)" ' +
                               'style="margin-left:auto;font-size:0.68rem;padding:2px 9px;border-radius:4px;cursor:pointer;' +
                               'background:rgba(46,160,67,.16);color:var(--success);border:1px solid rgba(46,160,67,.35);">' +
                               '<i class="bi bi-patch-check"></i> только проверенные</button>' +
                               // Замена кнопке «Снять» с вкладок: снимает выбор целиком,
                               // а не только в открытой папке (галка «Выбрать все» умеет
                               // лишь текущую).
                               '<button type="button" id="accpick-clear-all" ' +
                               'title="Снять выбор со всех аккаунтов" ' +
                               'style="margin-left:8px;font-size:0.68rem;padding:2px 9px;border-radius:4px;cursor:pointer;' +
                               'background:transparent;color:var(--text-muted);border:1px solid var(--border);">' +
                               '<i class="bi bi-x-circle"></i> снять всё</button>';
            selAllCb = selAll.querySelector('input');
            popup.appendChild(selAll);

            // Выбор диапазоном: «с 10 по 23» или «1-300». Номера — те же, что человек
            // видит слева в списке, то есть считаются по ТЕКУЩЕМУ виду (с учётом папки
            // и поиска). Иначе номер на экране и номер в поле означали бы разное.
            var rangeRow = document.createElement('div');
            rangeRow.className = 'acc-range-row';
            rangeRow.innerHTML =
                '<span>Выбрать с</span>' +
                '<input type="text" inputmode="numeric" id="accpick-range-from" placeholder="10">' +
                '<span>по</span>' +
                '<input type="text" inputmode="numeric" id="accpick-range-to" placeholder="23">' +
                '<button type="button" id="accpick-range-go">Выбрать</button>' +
                '<span class="acc-range-hint">можно и так: 1-300</span>';
            popup.appendChild(rangeRow);

            var _rFrom = rangeRow.querySelector('#accpick-range-from');
            var _rTo = rangeRow.querySelector('#accpick-range-to');

            function _applyRange() {
                var visible = getFiltered();
                if (!visible.length) { showToast('В этом виде нет аккаунтов', 'error'); return; }
                var raw = (_rFrom.value || '').trim();
                var from, to;
                // «1-300» целиком в первом поле — так короче, и владелец писал именно так.
                var dash = raw.match(/^(\d+)\s*[-–—]\s*(\d+)$/);
                if (dash) { from = +dash[1]; to = +dash[2]; }
                else { from = parseInt(raw, 10); to = parseInt((_rTo.value || '').trim(), 10); }
                if (!from && from !== 0) { showToast('Впиши, с какого номера начать', 'error'); return; }
                if (isNaN(to)) to = from;
                if (from > to) { var t = from; from = to; to = t; }
                from = Math.max(1, from);
                to = Math.min(visible.length, to);
                if (from > visible.length) {
                    showToast('В списке всего ' + visible.length + ' — номер ' + from + ' дальше конца', 'error');
                    return;
                }
                var picked = 0, skipped = 0;
                for (var i = from - 1; i <= to - 1; i++) {
                    var a = visible[i];
                    if (!a) continue;
                    if (_isDisabled(a)) { skipped++; continue; }
                    if (opts.isSelected && opts.isSelected(a.phone)) continue;
                    if (opts.toggle) opts.toggle(a.phone, true);
                    picked++;
                }
                try { if (opts.onChange) opts.onChange(); } catch (e) {}
                renderList();
                var msg = 'Выбрано ' + picked + ' — с ' + from + ' по ' + to;
                // Про пропущенных говорим прямо: иначе «выбрал 14 из 14, а взялось 11»
                // выглядит как потеря аккаунтов.
                if (skipped) msg += ', пропущено занятых и нерабочих: ' + skipped;
                showToast(msg, picked ? 'success' : 'error');
            }

            rangeRow.querySelector('#accpick-range-go').onclick = _applyRange;
            [_rFrom, _rTo].forEach(function(inp) {
                inp.onkeydown = function(e) { if (e.key === 'Enter') { e.preventDefault(); _applyRange(); } };
            });
            var _clearAllBtn = selAll.querySelector('#accpick-clear-all');
            if (_clearAllBtn) _clearAllBtn.onclick = function() {
                allAccounts.forEach(function(a) {
                    if (opts.isSelected && opts.isSelected(a.phone) && opts.toggle) opts.toggle(a.phone, false);
                });
                try { if (opts.onChange) opts.onChange(); } catch (e) {}
                renderList();
            };
        }

        var listWrap = document.createElement('div');
        listWrap.className = 'multi-acc-list';
        popup.appendChild(listWrap);

        var searchInput = searchRow.querySelector('#accpick-search');
        var searchClearBtn = searchRow.querySelector('#accpick-search-clear');
        searchInput.oninput = function() {
            searchQuery = (this.value || '').trim().toLowerCase();
            searchClearBtn.style.display = searchQuery ? '' : 'none';
            renderList();
        };
        searchClearBtn.onclick = function() {
            searchInput.value = ''; searchQuery = '';
            searchClearBtn.style.display = 'none';
            renderList(); searchInput.focus();
        };

        function getFiltered() {
            // Вид «Все» не показывает тихие папки (заморозка, мёртвые, вечный спам,
            // после инвайта). Аккаунты никуда не делись — нажми на саму папку.
            var base = (currentFolder === 'all')
                ? allAccounts.filter(function(a) { return !_isQuietFolder(a.folder || a.folder_name || ''); })
                : allAccounts.filter(function(a) {
                    var fn = (a.folder || a.folder_name || 'В работе').trim() || 'В работе';
                    return fn === currentFolder;
                });
            if (!searchQuery) return base;
            var digitsOnly = searchQuery.replace(/\D/g, '');
            return base.filter(function(a) {
                var phone = String(a.phone || '');
                var uname = String(a.username || '').toLowerCase();
                var fname = String(a.first_name || a.name || '').toLowerCase();
                if (digitsOnly && phone.indexOf(digitsOnly) !== -1) return true;
                if (uname && uname.indexOf(searchQuery) !== -1) return true;
                if (fname && fname.indexOf(searchQuery) !== -1) return true;
                return false;
            });
        }

        function _isDisabled(a) {
            if (a.dead && !allowDead) return true;
            if (a.busy) return true;
            if (a.frozen && disableFrozen) return true;
            return false;
        }

        // «Нормальный» акк для кнопки «только проверенные»: доступен, реально
        // проверен через @SpamBot (spam_checked_at>0) и без спам-блока. Отсекает
        // непроверенных («Не проверен») и спам-блокнутых — юзер берёт только тех,
        // кого проверка подтвердила чистыми.
        function _isCleanChecked(a) {
            if (_isDisabled(a)) return false;
            if (a.dead || a.frozen) return false;
            var inSpam = !!(a.spam_blocked || a.folder === 'В спам' || a.folder === 'Вечный спам');
            var checkedAt = a.spam_checked_at || a.last_spam_check_at || 0;
            return !!a.available && checkedAt > 0 && !inSpam;
        }

        function renderList() {
            var accs = getFiltered();
            listWrap.innerHTML = '';
            _syncDone();
            if (mode === 'multi') {
                var availableCount = accs.filter(function(a) { return !_isDisabled(a); }).length;
                selAll.querySelector('.multi-select-all-text').textContent =
                    'Выбрать все в этой папке (' + availableCount + ' доступно)';
                // Чекбокс отражает «все ли доступные выбраны» — иначе он всегда был
                // unchecked, клик всегда = «выбрать все», и СНЯТЬ все было нельзя
                // (юзер: «не могу снять, если выбрал все»). Теперь при всех выбранных
                // чекбокс checked → клик снимает все.
                var _selCount = accs.filter(function(a) { return !_isDisabled(a) && opts.isSelected && opts.isSelected(a.phone); }).length;
                selAllCb.checked = availableCount > 0 && _selCount === availableCount;
            }
            if (!accs.length) {
                listWrap.innerHTML = '<div class="multi-folder-empty">В этой папке нет аккаунтов</div>';
                return;
            }
            accs.forEach(function(a, _idx) {
                var disabled = _isDisabled(a);
                var sel = (mode === 'multi') && !disabled && opts.isSelected && opts.isSelected(a.phone);
                var _si = _accStatusInfo(a);
                var item = document.createElement('div');
                item.className = 'acc-popup-item' + (sel ? ' selected' : '') + (disabled ? ' disabled' : '');
                item.style.cssText += 'gap:8px;' + (disabled ? 'opacity:0.5;' : '');
                if (a.busy) item.title = (a.busy === 'TG-веб')
                    ? 'Аккаунт открыт в TG-веб — закрой веб-окно, через ~8 сек станет «Готов». Рассылка + веб = две сессии на акк → Telegram отзовёт его (AuthKeyDuplicated).'
                    : 'Занят: ' + a.busy + ' — дождись завершения, потом выбери.';
                var cbHtml = (mode === 'multi')
                    ? '<input type="checkbox" class="accpick-cb" data-phone="' + a.phone + '"' + (sel ? ' checked' : '') + (disabled ? ' disabled' : '') + '>'
                    : '';
                var _ptags = (_accountTags[a.phone] || []);
                var _copyIco = function(fn, arg, tip) {
                    return '<i class="bi bi-copy" onclick="' + fn + '(event,\'' + arg + '\')" title="' + tip + '" style="font-size:0.64rem;color:var(--text-muted);cursor:pointer;margin-left:4px;"></i>';
                };
                item.innerHTML = cbHtml +
                    // Номер по текущему виду — им человек и называет диапазон.
                    '<span class="acc-num">' + (_idx + 1) + '</span>' +
                    '<span class="dot" style="background:' + _si.color + ';"></span>' +
                    '<span class="phone">' + flagImg(a.flag) + ' +' + a.phone + _copyIco('copyPhone', a.phone, 'Копировать номер') + '</span>' +
                    (a.username ? '<span style="font-size:0.7rem;color:var(--accent-light);white-space:nowrap;">@' + escHtml(a.username) + _copyIco('copyUsername', a.username, 'Копировать @username') + '</span>' : '') +
                    (a.first_name ? '<span style="font-size:0.7rem;color:var(--text-muted);margin-left:4px;">' + escHtml(a.first_name) + '</span>' : '') +
                    (_ptags.length ? '<span style="display:inline-flex;gap:3px;margin-left:5px;flex-wrap:wrap;">' + _ptags.map(function(t){return '<span style="font-size:0.6rem;background:rgba(109,92,199,.18);color:var(--accent-light);padding:1px 6px;border-radius:4px;">' + escHtml(t) + '</span>';}).join('') + '</span>' : '') +
                    '<span class="status-text" style="color:' + _si.color + ';">' + _si.label + '</span>';
                if (mode === 'single') {
                    if (!disabled) item.onclick = function() {
                        try { if (opts.onPick) opts.onPick(a.phone); } catch (e) {}
                        overlay.remove();
                    };
                } else if (!disabled) {
                    var cb = item.querySelector('.accpick-cb');
                    // _syncDone вместо renderList: полный ре-рендер сотен строк на
                    // каждый клик тормозит, а счётчику хватает обновления кнопки.
                    item.onclick = function(e) {
                        if (e.target.tagName === 'INPUT') return;
                        cb.checked = !cb.checked;
                        item.classList.toggle('selected', cb.checked);
                        if (opts.toggle) opts.toggle(a.phone, cb.checked);
                        if (opts.onChange) opts.onChange();
                        _syncDone();
                    };
                    cb.onchange = function() {
                        item.classList.toggle('selected', this.checked);
                        if (opts.toggle) opts.toggle(a.phone, this.checked);
                        if (opts.onChange) opts.onChange();
                        _syncDone();
                    };
                }
                listWrap.appendChild(item);
            });
        }

        foldersBar.querySelectorAll('.multi-folder-chip').forEach(function(chip) {
            chip.onclick = function() {
                foldersBar.querySelectorAll('.multi-folder-chip').forEach(function(c) { c.classList.remove('is-active'); });
                chip.classList.add('is-active');
                currentFolder = chip.getAttribute('data-folder');
                renderList();
            };
        });

        if (mode === 'multi') {
            selAllCb.onchange = function() {
                getFiltered().forEach(function(a) {
                    if (_isDisabled(a)) return;
                    if (opts.toggle) opts.toggle(a.phone, selAllCb.checked);
                });
                if (opts.onChange) opts.onChange();
                renderList();
            };
            selAll.onclick = function(e) {
                if (e.target === selAllCb) return;
                if (e.target.closest && e.target.closest('#accpick-select-clean')) return;
                selAllCb.checked = !selAllCb.checked;
                selAllCb.onchange();
            };
            var selCleanBtn = selAll.querySelector('#accpick-select-clean');
            if (selCleanBtn) selCleanBtn.onclick = function(e) {
                e.stopPropagation();
                // По текущей папке/поиску: чистых-проверенных берём, остальных
                // (непроверенных/спам) снимаем — «взять только нормальные».
                var _n = 0;
                getFiltered().forEach(function(a) {
                    var pick = _isCleanChecked(a);
                    if (pick) _n++;
                    if (opts.toggle) opts.toggle(a.phone, pick);
                });
                if (opts.onChange) opts.onChange();
                renderList();
                showToast(_n ? ('Выбрано проверенных: ' + _n) : 'В этой папке нет проверенных чистых акков', _n ? 'success' : 'warning');
            };
        }

        renderList();

        overlay.appendChild(popup);
        document.body.appendChild(overlay);
        setTimeout(function() { if (searchInput) searchInput.focus(); }, 50);
    });
}

function openMultiAccPopup() {
    // Адаптер: рассылка / ЛС / подписка — общий глобал _multiSelected.
    openAccountPicker({
        title: 'Выбор аккаунтов', icon: 'bi-people', iconColor: 'var(--warning)', mode: 'multi',
        isSelected: function(p) { return _multiSelected.indexOf(p) !== -1; },
        toggle: function(p, on) {
            var i = _multiSelected.indexOf(p);
            if (on) { if (i === -1) _multiSelected.push(p); }
            else if (i !== -1) _multiSelected.splice(i, 1);
            _saveMultiSelected();
        },
        onChange: _afterMultiPick,
        onDone: _afterMultiPick
    });
}

function _afterMultiPick() {
    try { _updateMultiButtons(); } catch (e) {}
    var c = document.getElementById('multi-selected-count');
    if (c) c.textContent = _multiSelected.length + ' выбрано';
}


function multiShowSection(id) {
    ['multi-join-section', 'multi-spam-section', 'multi-dm-section'].forEach(function(s) {
        var el = document.getElementById(s);
        if (el) el.style.display = s === id && el.style.display !== 'block' ? 'block' : 'none';
    });
}

function multiStop(type) {
    showConfirmDialog('Остановить мульти-' + (type === 'join' ? 'подписку' : type === 'spam' ? 'рассылку' : 'рассылку в ЛС') + '?',
        'Активные задачи остановятся. Уже подписанные/отправленные сохранятся.', function() {
            var payload = type ? { type: type } : {};
            API.post('/api/multi/stop', payload).then(function(d) {
                if (d.ok) showToast('Остановлено: ' + (d.stopped || 0) + ' задач', 'info');
                else showToast(d.error || 'Не получилось', 'error');
            }).catch(function(e) { showToast('Ошибка: ' + (e.message || e), 'error'); });
        });
}

function multiJoin() {
    // Берём ссылки из мульти-секции или из таба вступления
    var links = (document.getElementById('multi-join-links') || {}).value || (document.getElementById('join-links') || {}).value || '';
    links = links.trim();
    if (!links) { showToast('Введите ссылки на чаты', 'warning'); return; }
    if (!_multiSelected.length) { showToast('Выберите аккаунты', 'warning'); return; }

    API.post('/api/join/load', {text: links}).then(function(d) {
        if (!d.ok) { showToast('Ошибка загрузки ссылок', 'error'); return Promise.reject(); }
        return new Promise(function(resolve, reject) {
            showMultiPreflightConfirm('Подписка', _multiSelected.slice(), function() {
                var payload = {phones: _multiSelected, ad_config: readAdConfig('multi')};
                var capEl = document.getElementById('join-max-per-acc');
                if (capEl) {
                    var capVal = parseInt(capEl.value || '0', 10);
                    if (!isNaN(capVal) && capVal > 0) payload.max_per_acc = capVal;
                }
                var skipPre = document.getElementById('multi-join-skip-precheck');
                if (skipPre && skipPre.checked) payload.skip_pre_check = true;
                payload.clean_duplicates = _cleanDupsOn('join');
                API.post('/api/multi/join', payload).then(resolve).catch(reject);
            });
        });
    }).then(function(d) {
        if (d && d.ok) {
            showToast(_startedMsg(d, 'Автоподписка запущена') + ', ' + d.total + ' чатов', _startedTone(d));
            refreshStatus();
        } else if (d) {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) { if (e) showToast('Ошибка: ' + _errText(e), 'error'); });
}

function multiSpam() {
    var text = (document.getElementById('multi-spam-text') || {}).value || '';
    if (!text.trim()) { showToast('Введите текст рассылки', 'warning'); return; }
    if (!_multiSelected.length) { showToast('Выберите аккаунты', 'warning'); return; }
    // «Запустить автоответчик вместе с рассылкой» — настройки берём со вкладки
    // Автоответчик (один на все модули). Валидируем текст ДО preflight-подтверждения:
    // иначе юзер подтверждает запуск, а он падает уже после.
    var _ar = _autoreplyPayload('multi-spam-autoreply');
    if (_ar && _ar.error) { showToast(_ar.error, 'warning'); return; }
    showMultiPreflightConfirm('Рассылка по чатам', _multiSelected.slice(), function() {
        var capEl = document.getElementById('spam-max-per-acc');
        var capVal = capEl ? parseInt(capEl.value || '0', 10) : 0;
        var payload = {phones: _multiSelected, text: text, ad_config: readAdConfig('multi')};
        if (_ar) payload.autoreply = _ar;
        if (!isNaN(capVal) && capVal > 0) payload.max_per_acc = capVal;
        // PF tolerance из spam таба
        var pfTolEl = document.getElementById('spam-pf-tolerance');
        if (pfTolEl) {
            var pfTolVal = parseInt(pfTolEl.value || '3', 10);
            if (!isNaN(pfTolVal)) payload.pf_tolerance = Math.max(0, Math.min(20, pfTolVal));
        }
        var spamNoDedupEl2 = document.getElementById('spam-no-dedup');
        if (spamNoDedupEl2) payload.no_dedup = spamNoDedupEl2.checked;
        API.post('/api/multi/spam', payload).then(function(d) {
            if (d && d.ok) {
                showToast('Рассылка запущена на ' + (d.started || _multiSelected.length) + ' акков', 'success');
                refreshStatus();
            } else if (d) {
                showToast(d.error || 'Ошибка', 'error');
            }
        }).catch(function(e) { showToast('Ошибка: ' + _errText(e), 'error'); });
    });
}

function multiDm() {
    var text = (document.getElementById('multi-dm-text') || {}).value || '';
    var targets = (document.getElementById('multi-dm-targets') || {}).value || '';
    if (!text.trim()) { showToast('Введите текст', 'warning'); return; }
    if (!targets.trim()) { showToast('Введите получателей', 'warning'); return; }
    if (!_multiSelected.length) { showToast('Выберите аккаунты', 'warning'); return; }
    // pre-flight confirm с полным списком отправителей.
    showMultiPreflightConfirm('Рассылка по ЛС рассылка', _multiSelected.slice(), function() {
        // лимит акка и потоки из Multi-DM секции (фолбэк на dm-* из DM таба)
        var capEl = document.getElementById('multi-dm-max-per-acc') || document.getElementById('dm-max-per-acc');
        var capVal = capEl ? parseInt(capEl.value || '0', 10) : 0;
        var thrEl = document.getElementById('multi-dm-threads') || document.getElementById('dm-threads');
        var thrVal = thrEl ? parseInt(thrEl.value || '0', 10) : 0;
        var payload = {phones: _multiSelected, text: text, targets: targets, ad_config: readAdConfig('multi')};
        if (!isNaN(capVal) && capVal > 0) payload.max_per_acc = capVal;
        // PF tolerance — из DM таба
        var pfTolEl = document.getElementById('dm-pf-tolerance');
        if (pfTolEl) {
            var pfTolVal = parseInt(pfTolEl.value || '3', 10);
            if (!isNaN(pfTolVal)) payload.pf_tolerance = Math.max(0, Math.min(20, pfTolVal));
        }
        if (!isNaN(thrVal) && thrVal > 0) payload.threads = thrVal;
        // дедуп истории отправок
        API.post('/api/multi/dm', payload).then(function(d) {
            if (d && d.ok) {
                var t = d.threads || _multiSelected.length;
                showToast('ЛС рассылка: ' + _multiSelected.length + ' акков, ' + t + ' потоков, лимит ' + (d.max_per_acc || 0) + '/акк, очередь ' + (d.total || 0), 'success');
                refreshStatus();
            } else if (d) {
                showToast(d.error || 'Ошибка', 'error');
            }
        }).catch(function(e) { showToast('Ошибка: ' + _errText(e), 'error'); });
    });
}

// ══════════════════════════════════════════════════════════════════
//  INLINE ACCOUNT SELECTOR — выбор аккаунта в каждом модуле
// ══════════════════════════════════════════════════════════════════

var _cachedAccounts = null;
var _cachedActive = null;

// Track which selectors are in "all"/"one"/"multi" mode
var _accSelectorMode = {};
// Multi-mode selection per selector id: {id: [phone1, phone2, ...]}
var _accSelectorPicked = {};

// Сохраняем выбор по аккаунтам в localStorage чтобы при перезапуске софта
// (или перезагрузке вкладки) выбор не терялся — особенно для warmup picker,
// где можно собирать выборку днями.
var _ACC_SEL_STORAGE_KEY = 'telepush_acc_selector_state_v1';
function _saveAccSelectorState() {
    try {
        var payload = { modes: _accSelectorMode, picked: _accSelectorPicked };
        localStorage.setItem(_ACC_SEL_STORAGE_KEY, JSON.stringify(payload));
    } catch (_) {}
}
function _loadAccSelectorState() {
    try {
        var raw = localStorage.getItem(_ACC_SEL_STORAGE_KEY);
        if (!raw) return;
        var data = JSON.parse(raw);
        if (data && typeof data === 'object') {
            _accSelectorMode = data.modes || {};
            _accSelectorPicked = data.picked || {};
        }
    } catch (_) {}
}
_loadAccSelectorState();

// Русское склонение «аккаунт»: 1 аккаунт / 2 аккаунта / 5 аккаунтов.
function plAcc(n) {
    n = Math.abs(Number(n)) || 0;
    var d = n % 10, h = n % 100;
    var w = (d === 1 && h !== 11) ? 'аккаунт'
        : (d >= 2 && d <= 4 && (h < 12 || h > 14)) ? 'аккаунта'
        : 'аккаунтов';
    return n + ' ' + w;
}

function renderAccountSelectors() {
    API.get('/api/accounts').then(function(d) {
        _cachedAccounts = d.accounts || [];
        _cachedActive = d.active;
        // Когда акк уезжает в Мёртвые/Заморозку (или удалён), он пропадает из
        // /api/accounts, но застревал в _multiSelected → фантомное «N выбрано».
        // Сверяем выбор с загруженными акками (только когда список НЕ пуст).
        if (_multiSelected && _multiSelected.length && _cachedAccounts.length) {
            var _norm = function(p) { return String(p || '').replace(/\D/g, ''); };
            var _live = {};
            _cachedAccounts.forEach(function(a) { _live[_norm(a.phone)] = 1; });
            var _kept = _multiSelected.filter(function(p) { return _live[_norm(p)]; });
            if (_kept.length !== _multiSelected.length) {
                _multiSelected = _kept;
                _saveMultiSelected();
                try { updateSpamMultiCount(); } catch (e) {}
                try { updateDmMultiCount(); } catch (e) {}
                try { updateJoinMultiCount(); } catch (e) {}
                try { _updateMultiButtons(); } catch (e) {}
                var _msc = document.getElementById('multi-selected-count');
                if (_msc) _msc.textContent = _multiSelected.length + ' выбрано';
            }
        }
        var ids = ['acc-selector-spam', 'acc-selector-dm', 'acc-selector-join', 'acc-selector-parser', 'acc-selector-profiles', 'acc-selector-autoreply', 'acc-selector-monitor', 'acc-selector-neuro-comments', 'acc-selector-chatter', 'acc-selector-imitation', 'acc-selector-bulk-fill', 'acc-selector-checker', 'acc-selector-number-checker', 'acc-selector-cleanup', 'acc-selector-warmup'];
        ids.forEach(function(id) {
            var el = document.getElementById(id);
            if (!el) return;
            if (!_cachedAccounts.length) {
                // Тот же вид, что и обычно: кнопка открывает окно выбора, а оно
                // само объяснит, что аккаунтов нет и где их добавить. Красная
                // строка вместо кнопки выглядела как поломка модуля.
                el.innerHTML =
                    '<div class="d-flex align-center gap-2 flex-wrap" style="font-size:0.78rem;">' +
                        '<button class="btn btn-accent btn-sm" onclick="openAccMultiPopup(\'' + id + '\')" style="font-size:0.78rem;padding:5px 14px;">' +
                            '<i class="bi bi-people"></i> Выбрать аккаунты' +
                        '</button>' +
                        '<span style="font-weight:700;color:var(--text-muted);">0</span>' +
                    '</div>';
                return;
            }
            // Один и тот же вид во ВСЕХ модулях: кнопка «Выбрать аккаунты» и
            // число рядом — как в рассылке, ЛС и подписке. Раньше тут был другой
            // виджет с переключателями «Один / Выбрать / Все» и выпадающим чипом,
            // из-за чего каждый модуль выглядел по-своему.
            var singleOnly = (id === 'acc-selector-monitor' || id === 'acc-selector-parser');
            var _open, _n;
            if (singleOnly) {
                _open = "openAccPopup('" + id + "')";
                var _cur = _accSelectorPicked[id] && _accSelectorPicked[id][0];
                if (!_cur) {
                    var _a = (id === 'acc-selector-monitor')
                        ? _cachedAccounts.find(function(a) { return (a.folder || '') === 'Мониторинг' && !a.dead && !a.frozen; })
                        : null;
                    _a = _a || _cachedAccounts.find(function(a) { return a.phone === _cachedActive; }) || _cachedAccounts[0];
                    _cur = _a && _a.phone;
                }
                _n = _cur ? ('+' + _cur) : '0';
            } else {
                _open = "openAccMultiPopup('" + id + "')";
                _n = _selectablePickedPhones(id).length;
            }
            var _zero = (_n === 0 || _n === '0');
            el.innerHTML =
                '<div class="d-flex align-center gap-2 flex-wrap" style="font-size:0.78rem;">' +
                    '<button class="btn btn-accent btn-sm" onclick="' + _open + '" style="font-size:0.78rem;padding:5px 14px;">' +
                        '<i class="bi bi-people"></i> Выбрать аккаунты' +
                    '</button>' +
                    '<span style="font-weight:700;color:' + (_zero ? 'var(--text-muted)' : 'var(--success)') + ';">' + _n + '</span>' +
                '</div>';
        });
    });
}

function setAccSelectorMode(id, mode) {
    _accSelectorMode[id] = mode;
    _saveAccSelectorState();
    renderAccountSelectors();
}

function openAccMultiPopup(selectorId) {
    // Адаптер: per-module мульти-селектор (чекер/очистка/имитация/профили/bulk) —
    // хранилище _accSelectorPicked[selectorId]. Открытие = форсим multi-mode.
    if (_accSelectorMode[selectorId] !== 'multi') {
        _accSelectorMode[selectorId] = 'multi';
        try { _saveAccSelectorState(); } catch (_e) {}
    }
    if (!_accSelectorPicked[selectorId]) _accSelectorPicked[selectorId] = [];
    var picked = _accSelectorPicked[selectorId];
    openAccountPicker({
        title: 'Выбор аккаунтов', icon: 'bi-check2-square', mode: 'multi',
        isSelected: function(p) { return picked.indexOf(p) !== -1; },
        toggle: function(p, on) {
            var i = picked.indexOf(p);
            if (on) { if (i === -1) picked.push(p); }
            else if (i !== -1) picked.splice(i, 1);
            try { _saveAccSelectorState(); } catch (_e) {}
        },
        onChange: function() {},
        onDone: function() { renderAccountSelectors(); }
    });
}

function _multiPickToggleAll(selectorId, selectAll) {
    if (!_accSelectorPicked[selectorId]) _accSelectorPicked[selectorId] = [];
    if (selectAll) {
        // Только выбираемые (как в пикере и в счётчике) — иначе «Выбрать все»
        // тащит мёртвых/из «Мёртвые», и счётчик опять расходится с реальностью.
        _accSelectorPicked[selectorId] = (_cachedAccounts || []).filter(function(a) {
            if (a.dead) return false;
            var fn = (a.folder || a.folder_name || '').trim();
            return fn !== 'Мёртвые' && fn !== 'Dead';
        }).map(function(a) { return a.phone; });
    } else {
        _accSelectorPicked[selectorId] = [];
    }
    _saveAccSelectorState();
    openAccMultiPopup(selectorId);
}

// Только реально выбираемые из picked: телефон должен существовать в текущем
// списке акков и быть живым (не dead, не в папке «Мёртвые»). Стейл-пик
// (восстановленный/удалённый/переехавший в Мёртвые акк) раздувал счётчик
// «31 из 284» при 30 выбранных — busy-guard ронял его на запуске, и лог
// показывал 30. Считаем и запускаем по одному и тому же набору.
function _selectablePickedPhones(id) {
    var picked = _accSelectorPicked[id] || [];
    if (!picked.length) return [];
    if (!_cachedAccounts || !_cachedAccounts.length) return picked.slice();
    var byPhone = {};
    _cachedAccounts.forEach(function(a) { byPhone[a.phone] = a; });
    return picked.filter(function(p) {
        var a = byPhone[p];
        if (!a || a.dead) return false;
        var fn = (a.folder || a.folder_name || '').trim();
        if (fn === 'Мёртвые' || fn === 'Dead') return false;
        return true;
    });
}

function getSelectorPhones(id) {
    var mode = _accSelectorMode[id] || 'one';
    if (mode === 'all') {
        return (_cachedAccounts || []).filter(function(a) { return a.available; }).map(function(a) { return a.phone; });
    }
    // Приоритет picked только когда mode='multi' — иначе стейл picks от
    // предыдущей multi-сессии утекали бы в mode='one' UI.
    if (mode === 'multi') {
        return _selectablePickedPhones(id);
    }
    return _cachedActive ? [_cachedActive] : [];
}

// Проверка пересечения выбранных акков с уже запущенными задачами в других
// модулях — не даём запустить дубль на занятом акке.
//
// Использование:
//   checkBusyAndConfirm(phones, currentModuleName).then(function(decision) {
//     if (decision.cancel) return;            // юзер нажал «Отмена»
//     if (decision.proceed) runTask(decision.phones);  // обновлённый список (м.б. меньше)
//   });
//
// decision.phones — отфильтрованный список акков для запуска.
// Если юзер выбрал «снять с предыдущей задачи» — лишние акки остаются в списке
// (а UI шлёт /api/multi/stop / equivalent для их освобождения).
// Обзор «кто чем занят» — агрегат по всем аккам, сгруппированный по задаче.
// Данные из /api/accounts/activity (_get_account_busy на каждый акк).
function showAccountsActivity() {
    showToast('Собираю активность…', 'info');
    API.post('/api/accounts/activity', {}).then(function(d) {
        if (!d || !d.ok) { showToast('Не удалось получить активность', 'error'); return; }
        var head = '<div style="margin-bottom:12px;line-height:1.6;">' +
            '<span style="color:var(--accent);font-weight:600;">Заняты: ' + (d.busy_total || 0) + '</span> · ' +
            '<span style="color:var(--success);">свободны: ' + (d.free_total || 0) + '</span> ' +
            '<span class="text-muted">из ' + (d.total || 0) + '</span></div>';
        var groups = d.groups || [];
        var body;
        if (!groups.length) {
            body = '<div class="text-muted" style="padding:8px 0;">Сейчас все аккаунты свободны — ни одной активной задачи.</div>';
        } else {
            body = groups.map(function(g) {
                var chips = (g.accounts || []).map(function(a) {
                    var nm = a.name ? (' ' + escHtml(a.name)) : '';
                    // Бейдж «АО»: на этом акке крутится автоответчик поверх задачи.
                    var ao = a.ao ? ('<span title="Автоответчик активен" style="display:inline-block;' +
                        'margin-left:5px;padding:0 5px;border-radius:4px;background:rgba(139,124,248,0.18);' +
                        'color:var(--accent);font-size:0.62rem;font-weight:700;vertical-align:middle;">АО</span>') : '';
                    return '<span style="display:inline-block;background:var(--bg-card);border:1px solid var(--border);' +
                        'border-radius:4px;padding:2px 7px;margin:2px;font-size:0.72rem;white-space:nowrap;">' +
                        '<code>' + escHtml(a.phone) + '</code>' + nm + ao + '</span>';
                }).join('');
                return '<div style="margin-bottom:12px;">' +
                    '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">' +
                    '<span class="status-dot status-dot--running" style="margin:0;"></span>' +
                    '<b style="color:var(--accent);">' + escHtml(g.label) + '</b>' +
                    '<span class="text-muted" style="font-size:0.75rem;">— ' + g.count + ' акк.</span></div>' +
                    '<div style="display:flex;flex-wrap:wrap;">' + chips + '</div></div>';
            }).join('');
        }
        var footer = '<div style="margin-top:10px;text-align:right;">' +
            '<button class="btn btn-outline btn-sm" onclick="showAccountsActivity()"><i class="bi bi-arrow-clockwise"></i> Обновить</button></div>';
        if (typeof appAlert === 'function') {
            appAlert(head + body + footer, {title: 'Кто чем занят', html: true, icon: 'bi-activity'});
        }
    }).catch(function(e) {
        var m = (e && e.message) ? e.message : e;
        showToast('Ошибка: ' + m, 'error');
    });
}

function checkBusyAndConfirm(phones, currentModule) {
    return new Promise(function(resolve) {
        if (!phones || !phones.length) { resolve({proceed: true, phones: phones, cancel: false}); return; }
        API.post('/api/accounts/busy_status', {phones: phones}).then(function(d) {
            var busy = (d && d.busy) || {};
            var busyPhones = Object.keys(busy);
            if (!busyPhones.length) { resolve({proceed: true, phones: phones, cancel: false}); return; }
            _showBusyOverlapModal(phones, busy, currentModule, resolve);
        }).catch(function() {
            // Если endpoint не отвечает — не блокируем юзера, идём как есть.
            resolve({proceed: true, phones: phones, cancel: false});
        });
    });
}

function _showBusyOverlapModal(allPhones, busyMap, currentModule, resolve) {
    var old = document.getElementById('busy-overlap-modal');
    if (old) old.remove();
    var busyPhones = Object.keys(busyMap);
    var freePhones = allPhones.filter(function(p) { return !busyMap[p]; });
    // Группируем по модулям для отображения
    var byModule = {};
    busyPhones.forEach(function(p) {
        var m = busyMap[p];
        if (!byModule[m]) byModule[m] = [];
        byModule[m].push(p);
    });
    var groupsHtml = Object.keys(byModule).map(function(mod) {
        var phs = byModule[mod];
        return '<div style="margin-bottom:10px;">' +
            '<div style="font-weight:600;color:var(--accent-light);margin-bottom:4px;">' + escHtml(mod) + ' — ' + phs.length + ' акк' + '</div>' +
            '<div style="font-size:0.72rem;color:var(--text-muted);">' + phs.map(function(p) { return '+' + escHtml(p); }).join(', ') + '</div>' +
        '</div>';
    }).join('');
    var overlay = document.createElement('div');
    overlay.id = 'busy-overlap-modal';
    overlay.className = 'tp-modal-overlay';
    overlay.innerHTML =
        '<div class="tp-modal" style="max-width:520px;">' +
            '<div class="tp-modal-icon"><i class="bi bi-exclamation-triangle-fill" style="color:#f59e0b;"></i></div>' +
            '<div class="tp-modal-title">Эти аккаунты уже в работе</div>' +
            '<div class="tp-modal-body">' +
                '<div style="margin-bottom:12px;font-size:0.82rem;">Из ' + allPhones.length + ' выбранных <b>' + busyPhones.length + '</b> уже заняты в других модулях. Свободных: <b>' + freePhones.length + '</b>.</div>' +
                groupsHtml +
                '<div style="margin-top:14px;font-size:0.78rem;">Что сделать?</div>' +
            '</div>' +
            '<div class="tp-modal-actions" style="flex-direction:column;gap:6px;">' +
                (freePhones.length > 0 ?
                    '<button class="btn btn-accent btn-sm" id="bom-only-free"><i class="bi bi-check2"></i> Запустить только свободные (' + freePhones.length + ')</button>'
                    : '') +
                '<button class="btn btn-outline btn-sm" id="bom-stop-and-start"><i class="bi bi-arrow-repeat"></i> Остановить предыдущие и запустить здесь все ' + allPhones.length + '</button>' +
                '<button class="btn btn-outline btn-sm" id="bom-cancel"><i class="bi bi-x"></i> Отмена</button>' +
            '</div>' +
        '</div>';
    document.body.appendChild(overlay);
    function close(decision) { overlay.remove(); resolve(decision); }
    overlay.addEventListener('click', function(e) { if (e.target === overlay) close({cancel: true}); });
    var btnFree = overlay.querySelector('#bom-only-free');
    if (btnFree) btnFree.onclick = function() { close({proceed: true, phones: freePhones, cancel: false}); };
    overlay.querySelector('#bom-stop-and-start').onclick = function() {
        // Останавливаем чужие задачи, потом запускаем со всеми выбранными.
        // Мульти-стоп бьёт по всем активным multi-jobs владельца — этого хватит
        // для multi-DM/spam/join. Для warmup/checker — отдельные стопы (TODO).
        API.post('/api/multi/stop', {}).catch(function(){}).then(function() {
            close({proceed: true, phones: allPhones, cancel: false});
        });
    };
    overlay.querySelector('#bom-cancel').onclick = function() { close({cancel: true}); };
}

function openAccPopup(selectorId) {
    // Адаптер: одиночный выбор (парсер/профили) — ставит активный акк через
    // pickAccount(/api/accounts/select). selectorId не нужен (актив глобальный).
    openAccountPicker({
        title: 'Выбор аккаунта', icon: 'bi-phone', mode: 'single',
        onPick: function(p) { pickAccount(p); }
    });
}

function pickAccount(phone) {
    API.post('/api/accounts/select', {phone: phone}).then(function(d) {
        if (d.ok) {
            renderAccountSelectors();
            loadAccounts();
            // Для парсера: cache hit → быстрый показ из КЭША без коннекта,
            // cache miss → один scan с подключением и прогрессом.
            if (_currentTab === 'parser') {
                API.get('/api/parser/chats').then(function(r) {
                    if (r && r.chats && r.chats.length > 0) {
                        // Есть кэш — показать
                        loadParserChats();
                    } else {
                        // Кэша нет — авто-скан
                        scanParserChats();
                    }
                }).catch(function() {
                    var sel = document.getElementById('parser-chat');
                    if (sel) sel.innerHTML = '<option value="">Нажмите «Обновить список чатов»</option>';
                });
            }
        }
    });
}

// ══════════════════════════════════════════════════════════════════
//  ACCOUNTS — управление аккаунтами
// ══════════════════════════════════════════════════════════════════

var _allAccounts = [];
var _accFilter = 'all';
// Доп. фильтр по статусу (поверх folder-фильтра): чипы
// «Все / Живой / Заморожен / Спам / Мёртвый» для быстрого поиска проблемных.
var _accStatusFilter = 'all';
try {
    // Чипы статус-фильтра убраны из UI — не восстанавливаем сохранённое значение
    // (нет видимого контрола сброса → юзер залипал в скрытом фильтре). Форсим «Все».
    _accStatusFilter = 'all';
    localStorage.removeItem('telepush_acc_status_filter');
} catch (_) {}

function _matchAccStatus(a, status) {
    // «Все» = только акки которые можно использовать (alive + spam). Заморозка и
    // dead исключены (нерабочие); замороженные — в отдельном табе «Заморозка».
    if (status === 'all') return !a.dead && !a.frozen;
    if (status === 'dead') return !!a.dead;  // зарезервировано на будущее
    if (status === 'frozen') return !a.dead && !!a.frozen;
    if (status === 'spam') return !a.dead && !a.frozen && !!a._isSpam;
    if (status === 'alive') return !a.dead && !a.frozen && !a._isSpam;
    // heartbeat-based фильтры
    if (status === 'active' || status === 'dormant') {
        if (a.dead || a.frozen) return false;
        var _now = Date.now() / 1000;
        var _lu = a.last_used_at || 0;
        if (status === 'active') return _lu && (_now - _lu < 3600);
        // dormant: либо никогда не использовался, либо больше суток назад
        return !_lu || (_now - _lu >= 86400);
    }
    if (status === 'deaf') return !a.dead && !a.frozen && !!a.deaf;
    return !a.dead && !a.frozen;
}

function renderAccStatusChips() {
    // status-chips удалены — no-op.
    return;
    var row = document.getElementById('acc-status-row');
    if (!row) return;
    if (!_allAccounts.length) { row.style.display = 'none'; return; }
    var counts = { all: _allAccounts.length, alive: 0, frozen: 0, spam: 0, dead: 0,
                   active: 0, dormant: 0, deaf: 0 };
    var _now_chip = Date.now() / 1000;
    _allAccounts.forEach(function(a) {
        if (a.dead) { counts.dead++; return; }
        if (a.frozen) { counts.frozen++; return; }
        if (a._isSpam) counts.spam++;
        else counts.alive++;
        if (a.deaf) counts.deaf++;
        var _lu = a.last_used_at || 0;
        if (_lu && _now_chip - _lu < 3600) counts.active++;
        else if (!_lu || _now_chip - _lu >= 86400) counts.dormant++;
    });
    counts.all = counts.alive + counts.spam;
    var chips = [
        { id: 'all',     label: 'Все',        icon: 'bi-grid-fill',          color: 'var(--accent-light)' },
        { id: 'active',  label: 'Активные',   icon: 'bi-circle-fill',        color: 'var(--success)' },
        { id: 'dormant', label: 'Дремлют',    icon: 'bi-pause-circle',       color: 'var(--text-muted)' },
        { id: 'frozen',  label: 'Заморозка',  icon: 'bi-snow',               color: '#60a5fa' },
        { id: 'spam',    label: 'Спам',       icon: 'bi-shield-exclamation', color: 'var(--warning)' },
        { id: 'deaf',    label: 'Глух',       icon: 'bi-ear',                color: '#fbbf24' },
    ];
    row.style.display = 'flex';
    row.innerHTML = chips.map(function(c) {
        var isAct = _accStatusFilter === c.id;
        return '<span class="multi-folder-chip ' + (isAct ? 'is-active' : '') + '" data-status="' + c.id + '" style="--chip-accent:' + c.color + ';">' +
               '<i class="bi ' + c.icon + '" style="color:' + c.color + ';margin-right:4px;"></i>' +
               c.label + ' <b>' + counts[c.id] + '</b></span>';
    }).join('');
}

function setAccStatusFilter(status) {
    _accStatusFilter = status;
    try { localStorage.setItem('telepush_acc_status_filter', status); } catch (_) {}
    renderAccStatusChips();
    renderAccountsTable();
}

document.addEventListener('DOMContentLoaded', function() {
    var row = document.getElementById('acc-status-row');
    if (row) row.addEventListener('click', function(e) {
        var chip = e.target.closest('.multi-folder-chip');
        if (chip && chip.dataset.status) setAccStatusFilter(chip.dataset.status);
    });
    // показать счётчик выбранных multi-акков в Подписке/Рассылке
    if (typeof updateJoinMultiCount === 'function') {
        try { updateJoinMultiCount(); } catch (e) {}
    }
    if (typeof updateSpamMultiCount === 'function') {
        try { updateSpamMultiCount(); } catch (e) {}
    }
    if (typeof updateDmMultiCount === 'function') {
        try { updateDmMultiCount(); } catch (e) {}
    }
});

function loadAccounts() {
    loadAccountTags();
    API.get('/api/accounts').then(function(d) {
        var el = document.getElementById('accounts-list');
        var total = document.getElementById('acc-total');
        if (!d.accounts || !d.accounts.length) {
            var _path = d.accounts_dir || 'папка Аккаунты/';
            var _disk = d.disk_sessions_count || 0;
            var _hint = '';
            if (_disk > 0) {
                // Причину берём из данных, а не гадаем: раньше здесь ВСЕГДА писалось
                // «в именах «+» и пробелы», и клиент шёл переименовывать уже
                // правильные файлы, пока настоящая причина оставалась в логе.
                var _bad = d.bad_session_names || [];
                var _rep = d.autoload || {};
                var _why;
                if (!d.autoload) {
                    // Автозагрузка ещё не отработала ни разу: панель успевает
                    // отрисоваться раньше неё. Раньше в этот момент показывалась
                    // красная пугалка про «плохие имена файлов» на здоровой папке.
                    _why = null;
                    _hint = '<div style="margin-top:8px;color:var(--text-muted);font-size:0.78rem;max-width:440px;margin-left:auto;margin-right:auto;line-height:1.5;">Нашёл ' + _disk + ' файл(ов) аккаунтов — загружаю, это занимает несколько секунд. Список появится сам.</div>';
                    setTimeout(loadAccounts, 3000);
                } else if (_bad.length) {
                    _why = 'у ' + _bad.length + ' из них имя не из одних цифр (' + _bad.slice(0, 3).join(', ') +
                           '). Переименуй в номер телефона голыми цифрами — например 79991234567.session — и нажми «Обновить».';
                } else if (_rep.empty_files && _rep.empty_files.length) {
                    _why = 'в части файлов нет входа в аккаунт — если сейчас копируешь аккаунты, дождись окончания и нажми «Обновить».';
                } else if (_rep.failed) {
                    _why = 'загрузка упала на ' + _rep.failed + ' файл(ах): ' +
                           (_rep.errors || []).slice(0, 2).join('; ') + '. Имена файлов тут ни при чём.';
                } else {
                    _why = 'причина не в именах файлов — открой «Логи» и найди строки [autoload], там написано что произошло.';
                }
                if (_why) {
                    _hint = '<div style="margin-top:8px;color:#f87171;font-size:0.78rem;max-width:440px;margin-left:auto;margin-right:auto;word-break:break-word;line-height:1.5;"><i class="bi bi-exclamation-triangle-fill"></i> В папке лежат ' + _disk + ' аккаунт(ов), но в список не попал ни один — ' + _why + '</div>';
                }
            } else {
                _hint = '<div style="margin-top:8px;color:var(--text-muted);font-size:0.78rem;max-width:440px;margin-left:auto;margin-right:auto;word-break:break-word;line-height:1.5;">Добавьте аккаунт формой выше или скопируйте купленные в эту папку и нажмите «Обновить».</div>';
            }
            // Статичная иконка. Пульсация тут крутилась бесконечно и без повода:
            // список пуст — это не процесс, ждать нечего, а анимация всё время
            // тянет перерисовку у клиентов на слабых машинах.
            var _animSvg = '<svg width="120" height="120" viewBox="0 0 120 120" style="margin:0 auto 12px;display:block;">' +
                '<defs><linearGradient id="emptyGrad" x1="0" y1="0" x2="1" y2="1">' +
                    '<stop offset="0%" stop-color="#a78bfa"/><stop offset="100%" stop-color="#7c3aed"/></linearGradient></defs>' +
                '<circle cx="60" cy="60" r="50" fill="url(#emptyGrad)" opacity="0.08"/>' +
                '<circle cx="60" cy="60" r="38" fill="url(#emptyGrad)" opacity="0.18"/>' +
                '<g transform="translate(60,60)">' +
                    '<rect x="-20" y="-22" width="40" height="44" rx="6" fill="none" stroke="#c4b5fd" stroke-width="2.5"/>' +
                    '<rect x="-14" y="-15" width="28" height="3" rx="1.5" fill="#c4b5fd" opacity="0.7"/>' +
                    '<rect x="-14" y="-7" width="20" height="3" rx="1.5" fill="#c4b5fd" opacity="0.5"/>' +
                    '<rect x="-14" y="1" width="24" height="3" rx="1.5" fill="#c4b5fd" opacity="0.5"/>' +
                    '<circle cx="0" cy="14" r="3" fill="#a78bfa"/>' +
                '</g></svg>';
            el.innerHTML = '<tr><td colspan="10" style="padding:0;white-space:normal;">' +
                '<div class="empty-state">' +
                    _animSvg +
                    '<div class="empty-state__title">Аккаунтов пока нет</div>' +
                    // Путь показываем коротко: полная строка вида
                    // C:\Users\...\Desktop\TelePush\Аккаунты занимает всю ширину и
                    // ничего не добавляет — человеку нужны последние две папки.
                    '<div class="empty-state__text"><i class="bi bi-folder2-open"></i> Папка: <code style="background:var(--bg-card-hover);padding:2px 6px;border-radius:4px;font-size:0.7rem;">' + escHtml(_shortPath(_path)) + '</code></div>' +
                    _hint +
                '</div>' +
            '</td></tr>';
            if (total) total.textContent = '0';
            _allAccounts = [];
            updateAccStats();
            return;
        }
        _allAccounts = d.accounts.map(function(a) {
            a._active = a.phone === d.active;
            // Compute spam status
            var sb = (a.spamblock || '').toLowerCase();
            a._isPerma = sb.indexOf('permanent') !== -1 || sb.indexOf('вечн') !== -1;
            a._isTemp = sb.indexOf('temporary') !== -1 || sb.indexOf('времен') !== -1;
            if (a.spam_checked_at > 0) {
                // Была ЖИВАЯ проверка @SpamBot — spam_blocked авторитетен (персист
                // через account_states.json). Не даём stale spamblock_json (из .json
                // при загрузке) держать «Со спамблоком» ПОСЛЕ снятия блока: раньше
                // снятый со спама акк залипал, т.к. _isSpam OR-ил старый sb → статус
                // «не обновлялся после проверки».
                a._isSpam = !!a.spam_blocked;
                if (!a.spam_blocked) { a._isPerma = false; a._isTemp = false; }
            } else {
                a._isSpam = a.spam_blocked || a._isPerma || a._isTemp || (sb && sb !== 'free' && sb !== 'none' && sb !== 'no' && sb !== '');
            }
            // Отлёжка. Приоритет — дата СОЗДАНИЯ СЕССИИ (session_created, ISO),
            // т.к. register_time у пачек часто заглушка (одинаковая дата 2021 у
            // всех → ложные «4г»). Фолбэк на register_time если session_created нет.
            a._ageText = '';
            a._ageTs = 0;
            a._ageSrc = '';
            if (a.session_created) {
                var _sc = Date.parse(a.session_created);
                if (!isNaN(_sc)) { a._ageTs = Math.floor(_sc / 1000); a._ageSrc = 'сессия от ' + a.session_created.slice(0,10); }
            }
            if (!a._ageTs && a.register_time) {
                a._ageTs = parseInt(a.register_time, 10) || 0;
                if (a._ageTs) a._ageSrc = 'регистрация (session_created нет)';
            }
            if (a._ageTs) {
                var ageDays = Math.max(0, Math.floor((Date.now()/1000 - a._ageTs) / 86400));
                if (ageDays > 365) a._ageText = Math.floor(ageDays/365) + 'г ' + Math.floor((ageDays%365)/30) + 'мес';
                else if (ageDays > 30) a._ageText = Math.floor(ageDays/30) + ' мес';
                else a._ageText = ageDays + ' д';
            }
            return a;
        });
        updateAccStats();
        renderAccStatusChips();
        renderAccountsTable();
    }).catch(function() {});
}

// ── Filters: auto-generated from real folders on disk ──────────────────────
// Pretty names for known folder names (both cyrillic and latin)
var _folderLabels = {
    'В работе': 'Для работы', 'Active': 'Для работы',
    'Мёртвые': 'Мёртвые', 'Dead': 'Мёртвые',
    'Заморозка': 'Заморозка', 'Frozen': 'Заморозка',
    'В спам': 'В спаме', 'Spam': 'В спаме',
    'Вечный спам': 'Вечный спам', 'Permanent_spam': 'Вечный спам',
    'Прогрев': 'Прогрев', 'Warmup': 'Прогрев',
    'Ферма': 'Ферма', 'Farm': 'Ферма',
    'Архив': 'Архив', 'Archive': 'Архив',
};
// Known folder order (first = top). Мёртвые исключены — не имеют смысла в фильтрах
var _folderOrder = ['В работе', 'Active', 'Прогрев', 'Warmup', 'Ферма', 'Farm',
                     'Заморозка', 'Frozen',
                     'В спам', 'Spam', 'Вечный спам', 'Permanent_spam', 'Архив', 'Archive'];
// Кладбищенские папки — где мёртвые/замороженные/спам акки лежат. В рабочих
// папках мёртвых не показываем.
var _GRAVEYARD_FOLDERS = ['Мёртвые', 'Dead', 'Заморозка', 'Frozen', 'В спам', 'Spam',
                          'Вечный спам', 'Permanent_spam', 'Архив', 'Archive'];
function _isGraveyardFolder(f) { return _GRAVEYARD_FOLDERS.indexOf(f) !== -1; }
// Папки, аккаунты которых читаются, но в общем виде не показываются. Аккаунт
// никуда не девается: он загружен, посчитан, им можно управлять — просто не
// мозолит глаза в списке «Все». Чтобы его увидеть, надо нажать на саму папку.
//
// Владелец 13.08.2026 уточнил состав: прятать ТОЛЬКО «Заморозка» и «Мёртвые».
// «Вечный спам» и «После инвайта» — показывать (это живые аккаунты, с ними работают).
//
// Сравниваем ПОЛНОЕ имя папки, а не последний сегмент: аккаунт, уехавший инвайтером
// в «После инвайта/Заморозка», по прямому указанию владельца остаётся видимым.
// Он и так помечен «заморожен», поэтому в общем виде его отсеет статус-фильтр, а в
// списке выбора он будет неактивен — прятать его ещё и папкой незачем.
var _folderQuiet = ['Заморозка', 'Frozen', 'Мёртвые', 'Dead'];
function _isQuietFolder(f) { return _folderQuiet.indexOf((f || '').trim()) !== -1; }

// Папки которые НЕ показываем как chips (операционные/технические)
var _folderHidden = ['После переавторизации', 'После проверки', 'После инвайта', 'После фермы',
                     'Неверный формат', 'Конвертер tdata → session', 'Конвертер session → tdata',
                     'Мёртвые', 'Dead'];
// Папки, которые юзер ЯВНО добавил через «Добавить папку» — показываем чипом ДАЖЕ
// если они в _folderHidden (напр. «Мёртвые»: по умолчанию скрыта, но захотел видеть).
// Переживает перезагрузку через localStorage. Раньше выбор скрытой папки давал
// тост «добавлена», но чип не появлялся никогда (hidden-скип безусловен).
var _forceShownFolders = (function() {
    try { return JSON.parse(localStorage.getItem('tp_force_shown_folders') || '[]') || []; }
    catch (_) { return []; }
})();
function _saveForceShown() {
    try { localStorage.setItem('tp_force_shown_folders', JSON.stringify(_forceShownFolders)); } catch (_) {}
}

var _dynamicFilters = [{ id: 'all', name: 'Все аккаунты', type: 'builtin' }];

function _buildFiltersFromFolders(folders) {
    var filters = [{ id: 'all', name: 'Все аккаунты', type: 'builtin' }];
    var added = {};
    // First add known folders in order
    _folderOrder.forEach(function(fname) {
        if (added[_folderLabels[fname]]) return;
        var found = folders.find(function(f) { return f.name === fname; });
        if (found) {
            var label = _folderLabels[fname] || fname;
            added[label] = true;
            filters.push({ id: 'folder_' + fname, name: label, folder: fname, type: 'builtin' });
        }
    });
    // Then add any remaining folders (custom user folders) кроме скрытых.
    // Скрытую папку показываем, если юзер ЯВНО добавил её (_forceShownFolders).
    folders.forEach(function(f) {
        if (_folderHidden.indexOf(f.name) !== -1 && _forceShownFolders.indexOf(f.name) === -1) return;
        var label = _folderLabels[f.name] || f.name;
        if (added[label]) return;
        added[label] = true;
        filters.push({ id: 'folder_' + f.name, name: label, folder: f.name, type: 'custom' });
    });
    _dynamicFilters = filters;
}

function _loadFiltersFromServer() {
    API.get('/api/folders').then(function(d) {
        if (d.folders && d.folders.length) {
            _buildFiltersFromFolders(d.folders);
            updateAccStats();
        }
    }).catch(function(){});
}
// Run on load
document.addEventListener('DOMContentLoaded', function() { setTimeout(_loadFiltersFromServer, 500); });

function _getAllFilters() {
    // Вкладку «Мёртвые» в менеджере не показываем. Мёртвые акки лежат в
    // директории и из видов «Все»/«Для работы» уже скрыты.
    return _dynamicFilters;
}

// Последний сегмент пути папки. Акк, ушедший через инвайтер во вложенную
// статус-папку (folder_name вида «После инвайта/Заморозка»), матчится chip-
// фильтром по статус-категории (Заморозка), а не по всему пути.
function _folderLeaf(s) {
    s = (s || '').replace(/\\/g, '/');
    var i = s.lastIndexOf('/');
    return i >= 0 ? s.slice(i + 1) : s;
}

function _countForFilter(f) {
    // Бейдж каждого фильтра = ровно то, что покажет его вид. Вид «Все» прячет
    // dead/frozen → бейдж считает только рабочих. Папочные виды показывают ВСЁ
    // в папке → там бейдж = полное число файлов в папке.
    if (f.id === 'all') {
        return _allAccounts.filter(function(a) {
            return !a.dead && !a.frozen && !_isQuietFolder(a.folder || '');
        }).length;
    }
    if (f.status === 'dead') {
        return _allAccounts.filter(function(a) { return a.dead; }).length;
    }
    if (f.folder) {
        // Любая РАБОЧАЯ (не-кладбищенская) папка считает только живых —
        // мёртвые/замороженные не показываются в рабочих вкладках.
        var _isWork = !_isGraveyardFolder(f.folder);
        return _allAccounts.filter(function(a) {
            if (_folderLeaf(a.folder) !== f.folder) return false;
            if (_isWork && (a.dead || a.frozen)) return false;
            return true;
        }).length;
    }
    return 0;
}

function _flaggedInFolder(f) {
    if (!f.folder) return 0;
    return _allAccounts.filter(function(a) {
        return _folderLeaf(a.folder) === f.folder && (a.dead || a.frozen);
    }).length;
}

function updateAccStats() {
    var row = document.getElementById('acc-stats-row');
    if (!row) return;
    var filters = _getAllFilters();
    var html = '';
    filters.forEach(function(f) {
        var count = _countForFilter(f);
        var flagged = _flaggedInFolder(f);
        var isActive = _accFilter === f.id;
        var canDelete = f.type !== 'builtin';
        // Подпись под числом. Раньше «(N раб.)» считалось как count - flagged, но
        // для рабочих папок сам count УЖЕ без мёртвых и замороженных — они
        // вычитались второй раз, и на плитке появлялось отрицательное число
        // («Прогрев 2 (-3 раб.)»). Признак «рабочая» тоже расходился: счётчик
        // считал рабочей любую не-кладбищенскую, а подпись — только «В работе».
        var _isWorkF = f.folder && !_isGraveyardFolder(f.folder);
        var subLabel = '';
        if (flagged) {
            subLabel = _isWorkF
                // count уже только живые — говорим, сколько рядом лежит нерабочих.
                ? ' <span style="opacity:.65;font-size:.85em;">(+' + flagged + ' не в работе)</span>'
                // кладбищенская папка: count = всё содержимое, поясняем живых.
                : ' <span style="opacity:.65;font-size:.85em;">(' + Math.max(0, count - flagged) + ' раб.)</span>';
        }
        html += '<div class="acc-stat-item' + (isActive ? ' active' : '') + '" data-filter="' + f.id + '" style="position:relative;">' +
            '<span class="acc-stat-value">' + count + '</span>' +
            '<span class="acc-stat-label">' + escHtml(f.name) + subLabel + '</span>' +
            (canDelete ? '<button onclick="event.stopPropagation();removeCustomFilter(\'' + f.id + '\')" style="position:absolute;top:4px;right:6px;background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.6rem;padding:2px;opacity:0.6;" title="Удалить фильтр"><i class="bi bi-x-lg"></i></button>' : '') +
        '</div>';
    });
    // Add button
    html += '<div class="acc-stat-item" onclick="showAddFilterPopup()" style="cursor:pointer;display:flex;align-items:center;justify-content:center;border-style:dashed;opacity:0.6;" title="Добавить фильтр">' +
        '<i class="bi bi-plus-lg" style="font-size:1.2rem;color:var(--accent-light);"></i>' +
    '</div>';
    row.innerHTML = html;
    var total = document.getElementById('acc-total');
    // total = только акки которые можно использовать (без dead и frozen) —
    // иначе расхождение с «Показано».
    if (total) {
        // Y = «всего в ТЕКУЩЕМ виде» (иначе на папке таблица 3, а Y глобальный 2 → «3/2»).
        // Вид «Все» = рабочие; папочный вид = все файлы папки. _countForFilter уже так и считает.
        var _activeF = filters.find(function(x) { return x.id === _accFilter; });
        var _viewTotal;
        if (_activeF) {
            _viewTotal = _countForFilter(_activeF);
        } else {
            _viewTotal = _allAccounts.filter(function(a) { return !a.dead && !a.frozen; }).length;
        }
        total.textContent = _viewTotal;
    }
    // Heartbeat сводка: сколько акков использовались за час / сутки / давно.
    // Показываем рядом с «Показано: X/Y» чтобы клиент сразу видел сколько
    // акков «живых» а сколько «дремлет». Скрываем если все акки никогда не использовались.
    try {
        var hbEl = document.getElementById('acc-heartbeat-summary');
        if (hbEl) {
            var nowSec = Date.now() / 1000;
            var live = 0, recent = 0, dormant = 0, never = 0;
            for (var _j = 0; _j < _allAccounts.length; _j++) {
                var _aa = _allAccounts[_j];
                if (_aa.dead || _aa.frozen) continue;
                var _lu = _aa.last_used_at || 0;
                if (!_lu) { never++; continue; }
                var _diff = nowSec - _lu;
                if (_diff < 3600) live++;
                else if (_diff < 86400) recent++;
                else dormant++;
            }
            if (live + recent + dormant === 0) {
                hbEl.style.display = 'none';
            } else {
                var parts = [];
                if (live) parts.push('<span style="color:var(--success);">●</span> ' + live + ' активных');
                if (recent) parts.push('<span style="color:#fbbf24;">●</span> ' + recent + ' за сутки');
                if (dormant) parts.push('<span style="color:var(--text-muted);">●</span> ' + dormant + ' дремлют');
                hbEl.innerHTML = '· ' + parts.join(' · ');
                hbEl.style.display = '';
            }
        }
    } catch (_e) {}
}

function setAccFilter(filter) {
    _accFilter = filter;
    updateAccStats();
    renderAccountsTable();
}

// Init stats row click handlers
document.addEventListener('DOMContentLoaded', function() {
    var row = document.getElementById('acc-stats-row');
    if (row) row.addEventListener('click', function(e) {
        var del = e.target.closest('button');
        if (del) return;
        var item = e.target.closest('.acc-stat-item');
        if (item && item.dataset.filter) setAccFilter(item.dataset.filter);
    });
});

function showAddFilterPopup() {
    var old = document.getElementById('filter-popup-overlay');
    if (old) old.remove();

    var overlay = document.createElement('div');
    overlay.id = 'filter-popup-overlay';
    overlay.className = 'acc-popup-overlay';
    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };

    var popup = document.createElement('div');
    popup.className = 'acc-popup';
    popup.style.minWidth = '320px';
    popup.innerHTML = '<button class="acc-popup-close" onclick="document.getElementById(\'filter-popup-overlay\').remove()"><i class="bi bi-x-lg"></i></button>' +
        '<h3><i class="bi bi-funnel" style="color:var(--accent-light);margin-right:6px;"></i>Добавить папку</h3>' +
        '<label class="form-label">Существующая папка</label>' +
        '<select class="form-control mb-2" id="new-filter-folder"><option value="">Загрузка...</option></select>' +
        '<div class="d-flex gap-2 align-center mb-3">' +
            '<span class="text-xs text-muted">или создать</span>' +
            '<input type="text" class="form-control" id="new-filter-folder-custom" placeholder="Имя новой папки" style="flex:1;">' +
        '</div>' +
        '<button class="btn btn-accent btn-sm" onclick="addCustomFilter()"><i class="bi bi-plus"></i> Добавить</button>';
    overlay.appendChild(popup);
    document.body.appendChild(overlay);

    // Load folders from API
    API.get('/api/folders').then(function(d) {
        var sel = document.getElementById('new-filter-folder');
        if (!sel) return;
        var html = '<option value="">-- выберите папку --</option>';
        (d.folders || []).forEach(function(f) {
            html += '<option value="' + escHtml(f.name) + '">' + escHtml(f.name) + ' (' + f.count + ' акков)</option>';
        });
        sel.innerHTML = html;
    });
}

function addCustomFilter() {
    // Кастомное имя приоритетно: если юзер его ввёл — создаём и показываем ИМЕННО
    // его (иначе при заполненных ОБОИХ полях создавалась бы кастомная папка, а
    // чип/тост ссылались на выбор из дропдауна — рассинхрон).
    var customName = document.getElementById('new-filter-folder-custom').value.trim();
    var folder = customName || document.getElementById('new-filter-folder').value;
    if (!folder) { showToast('Выберите или введите имя папки', 'warning'); return; }

    var finish = function() {
        // Явно добавленную папку помечаем force-shown — иначе скрытая (Мёртвые)
        // не появится чипом, хотя тост скажет «добавлена».
        if (folder && _forceShownFolders.indexOf(folder) === -1) {
            _forceShownFolders.push(folder);
            _saveForceShown();
        }
        document.getElementById('filter-popup-overlay').remove();
        _loadFiltersFromServer();
        showToast('Папка "' + folder + '" добавлена', 'success');
    };

    if (customName) {
        API.post('/api/folders/create', { name: customName }).then(function(d) {
            if (d.ok) finish();
            else showToast(d.error || 'Ошибка', 'error');
        }).catch(function() { showToast('Ошибка создания папки', 'error'); });
    } else {
        finish();
    }
}

function removeCustomFilter(id) {
    // Only remove from UI — folder stays on disk
    if (_accFilter === id) _accFilter = 'all';
    _dynamicFilters = _dynamicFilters.filter(function(f) { return f.id !== id; });
    // Force-shown папку убираем из override, иначе вернётся чипом на перезагрузке.
    var fname = (id || '').indexOf('folder_') === 0 ? id.slice(7) : null;
    if (fname) {
        var _fi = _forceShownFolders.indexOf(fname);
        if (_fi !== -1) { _forceShownFolders.splice(_fi, 1); _saveForceShown(); }
    }
    updateAccStats();
    renderAccountsTable();
}

function filterAccountsTable() {
    renderAccountsTable();
}

function copyPhone(ev, phone) {
    try { ev.stopPropagation(); } catch (_) {}
    var txt = '+' + phone;
    try {
        navigator.clipboard.writeText(txt).then(function() {
            showToast('Скопировано: ' + txt, 'success');
        }).catch(function() {
            // Fallback для старых WebView
            var ta = document.createElement('textarea');
            ta.value = txt; document.body.appendChild(ta); ta.select();
            try { document.execCommand('copy'); showToast('Скопировано: ' + txt, 'success'); }
            catch (_) { showToast('Не удалось скопировать', 'warning'); }
            document.body.removeChild(ta);
        });
    } catch (e) { showToast('Не удалось скопировать', 'warning'); }
}

function copyUsername(ev, username) {
    try { ev.stopPropagation(); } catch (_) {}
    if (!username) { showToast('У аккаунта нет @username', 'warning'); return; }
    var txt = '@' + username;
    try {
        navigator.clipboard.writeText(txt).then(function() {
            showToast('Скопировано: ' + txt, 'success');
        }).catch(function() {
            var ta = document.createElement('textarea');
            ta.value = txt; document.body.appendChild(ta); ta.select();
            try { document.execCommand('copy'); showToast('Скопировано: ' + txt, 'success'); }
            catch (_) { showToast('Не удалось скопировать', 'warning'); }
            document.body.removeChild(ta);
        });
    } catch (e) { showToast('Не удалось скопировать', 'warning'); }
}

// Теги по аккаунтам {phone: ["tag1","tag2"]}
var _accountTags = {};
var _allAccountTags = [];

function loadAccountTags() {
    return API.get('/api/account_tags').then(function(d) {
        if (d && d.ok) {
            _accountTags = d.tags || {};
            _allAccountTags = d.all_tags || [];
            var sel = document.getElementById('acc-tag-filter');
            if (sel) {
                var cur = sel.value;
                sel.innerHTML = '<option value="">Все теги</option>' +
                    _allAccountTags.map(function(t){return '<option value="'+escHtml(t)+'">'+escHtml(t)+'</option>'}).join('');
                sel.value = cur;
            }
        }
    });
}

function renderAccountTagsCell(phone) {
    var tags = _accountTags[phone] || [];
    var palette = ['#6d5cc7','#22c55e','#f59e0b','#3b82f6','#ef4444','#ec4899'];
    var chips = tags.slice(0,3).map(function(t,i) {
        return '<span class="badge" style="background:'+palette[i%palette.length]+'22;color:'+palette[i%palette.length]+';font-size:.62rem;padding:1px 6px;border-radius:3px;">'+escHtml(t)+'</span>';
    }).join(' ');
    if (tags.length > 3) chips += ' <span style="font-size:.62rem;color:var(--text-muted)">+'+(tags.length-3)+'</span>';
    return '<div style="display:flex;gap:3px;flex-wrap:wrap;align-items:center;">' +
        (chips || '<span style="font-size:.6rem;color:var(--text-muted)">—</span>') +
        ' <button onclick="editAccountTags(\''+phone+'\',event)" class="btn btn-outline btn-sm" style="padding:1px 5px;font-size:.6rem;" title="Редактировать теги"><i class="bi bi-tags"></i></button>' +
        '</div>';
}

function editAccountTags(phone, e) {
    if (e) e.stopPropagation();
    var current = (_accountTags[phone] || []).join(', ');
    var suggestions = _allAccountTags.length ? 'Существующие теги: ' + _allAccountTags.join(', ') : 'Через запятую, до 6 шт.';
    tpPrompt({
        title: 'Теги для +' + phone,
        hint: suggestions,
        placeholder: 'work, friends, vip',
        defaultValue: current
    }).then(function(raw) {
        if (raw === null) return;
        var tags = raw.split(',').map(function(s){return s.trim()}).filter(function(s){return s.length});
        API.post('/api/account_tags/set', {phone: phone, tags: tags}).then(function(d) {
            if (d.ok) {
                _accountTags[phone] = d.tags || [];
                loadAccountTags().then(function(){ renderAccountsTable(); });
                toast('Теги сохранены');
            } else {
                toast('Ошибка: ' + (d.error || ''));
            }
        }).catch(function(err){ toast('Ошибка: ' + err.message); });
    });
}

// Пагинация Менеджера аккаунтов страницами по 30/100/500 — рендер всех акков
// сразу лагал при 200+ акках.
var _accPage = 0;
var _accPageSize = parseInt(localStorage.getItem('acc_page_size') || '500', 10);
// Выделенные акки — НАБОР телефонов, живёт поверх страниц (см. _syncSelectionFromDom).
var _selectedAccPhones = {};
// Телефоны всего отфильтрованного списка (все страницы) — заполняет renderAccountsTable.
var _lastFilteredPhones = [];

function accChangePageSize() {
    var sel = document.getElementById('acc-page-size');
    if (!sel) return;
    _accPageSize = parseInt(sel.value, 10) || 0;
    _accPage = 0;
    try { localStorage.setItem('acc_page_size', String(_accPageSize)); } catch (_) {}
    renderAccountsTable();
}

function accPrevPage() {
    if (_accPage > 0) { _accPage -= 1; renderAccountsTable(); }
}

function accNextPage() {
    _accPage += 1;
    renderAccountsTable();
}

// Sortable columns в таблице аккаунтов. Клик на th переключает asc/desc.
var _accSortKey = '';
var _accSortDir = 'asc';
try {
    _accSortKey = localStorage.getItem('telepush_acc_sort_key') || '';
    _accSortDir = localStorage.getItem('telepush_acc_sort_dir') || 'asc';
} catch (_) {}

function setAccSort(key) {
    if (_accSortKey === key) {
        _accSortDir = (_accSortDir === 'asc') ? 'desc' : 'asc';
    } else {
        _accSortKey = key;
        _accSortDir = 'asc';
    }
    try {
        localStorage.setItem('telepush_acc_sort_key', _accSortKey);
        localStorage.setItem('telepush_acc_sort_dir', _accSortDir);
    } catch (_) {}
    renderAccountsTable();
}

function _accSortValue(a, key) {
    switch (key) {
        case 'phone': return a.phone || '';
        case 'geo': return (a.geo || a.flag || '').toLowerCase();
        case 'name': return ((a.first_name || '') + ' ' + (a.username || '')).toLowerCase().trim() || 'я';
        case 'age': return -(a._ageTs || 0); // старше = выше (по дате создания сессии)
        case 'status':
            // 0=dead/perma 1=frozen 2=spam 3=floodwait 4=unverified 5=alive
            if (a.dead) return 0;
            if (a._isPerma) return 0;
            if (a.frozen) return 1;
            if (a._isSpam || a._isTemp) return 2;
            if (a.floodwait) return 3;
            if (!a.verified) return 4;
            return 5;
        default: return 0;
    }
}

function renderAccountsTable() {
    var el = document.getElementById('accounts-list');
    var search = (document.getElementById('acc-search') || {}).value || '';
    search = search.toLowerCase().trim();
    var tagFilter = (document.getElementById('acc-tag-filter') || {}).value || '';
    // Обновляем стрелки в заголовках
    try {
        document.querySelectorAll('.sortable-th').forEach(function(th) {
            th.classList.remove('sorted-asc', 'sorted-desc');
            if (th.getAttribute('data-sort-key') === _accSortKey) {
                th.classList.add(_accSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
            }
        });
    } catch (_) {}

    // Восстановим сохранённый размер страницы в селект
    var _psSel = document.getElementById('acc-page-size');
    if (_psSel && parseInt(_psSel.value, 10) !== _accPageSize) {
        _psSel.value = String(_accPageSize);
    }

    var filtered = _allAccounts.filter(function(a) {
        if (_accFilter === 'all') {
            // Тихие папки в общем виде не показываем — их аккаунты видны на своей
            // папке. Мёртвых и замороженных отсеивает статус-фильтр ниже, а вот
            // «Вечный спам» и «После инвайта» флага не имеют и раньше подмешивались
            // в общий список.
            if (_isQuietFolder(a.folder || '')) return false;
        }
        else {
            // Find filter (builtin or custom) and match by folder
            var allFilters = _getAllFilters();
            var activeFilter = allFilters.find(function(f) { return f.id === _accFilter; });
            if (activeFilter && activeFilter.status === 'dead') {
                // Вкладка «Мёртвые» — статусная: только мёртвые акки (файл может
                // лежать в «В работе» при auto_clean_dead=off).
                if (!a.dead) return false;
            } else if (activeFilter && activeFilter.folder) {
                if (_folderLeaf(a.folder) !== activeFilter.folder) return false;
                // Мёртвый акк не показывается НИ В ОДНОЙ рабочей папке. Исключение —
                // кладбищенские папки (Мёртвые/Заморозка/В спам/Архив). Файл не двигаем
                // (auto_clean_dead=off) — скрытие визуальное, ложно-мёртвый
                // восстановится self-heal'ом.
                if (a.dead && !_isGraveyardFolder(activeFilter.folder)) return false;
                // Замороженный акк — тоже не в рабочих папках (для него отдельный таб
                // «Заморозка»); иначе бейдж/итог считал их лишними («Показано N из N+1»).
                if (a.frozen && !_isGraveyardFolder(activeFilter.folder)) return false;
            }
        }
        // Status filter (поверх folder-фильтра). При активном папочном фильтре
        // (Заморозка / В спам / Архив) status-фильтр пропускаем — он отсеивал бы
        // frozen/dead из «all».
        if (_accFilter === 'all' && !_matchAccStatus(a, _accStatusFilter)) return false;
        // Filter by search. Для номера сравниваем по цифрам (вставленный
        // «+373 67 130 625» находит +37367130625). Для имени/username — как есть.
        if (search) {
            var hay = (a.phone + ' ' + (a.first_name || '') + ' ' + (a.username || '')).toLowerCase();
            var digitsOnly = search.replace(/\D/g, '');
            var phoneMatch = digitsOnly && String(a.phone || '').indexOf(digitsOnly) !== -1;
            if (!phoneMatch && hay.indexOf(search) === -1) return false;
        }
        // Filter by tag
        if (tagFilter) {
            var tags = _accountTags[a.phone] || [];
            if (tags.indexOf(tagFilter) === -1) return false;
        }
        return true;
    });

    // применяем сортировку, если выбрана
    if (_accSortKey) {
        filtered.sort(function(a, b) {
            var va = _accSortValue(a, _accSortKey);
            var vb = _accSortValue(b, _accSortKey);
            if (va < vb) return _accSortDir === 'asc' ? -1 : 1;
            if (va > vb) return _accSortDir === 'asc' ? 1 : -1;
            return 0;
        });
    }

    // Телефоны ВСЕГО отфильтрованного списка (до среза по странице) — по ним
    // работает «Выделить разом…», иначе оно видело бы только текущую страницу.
    _lastFilteredPhones = filtered.map(function(a) { return _normAccPhone(a.phone); })
                                  .filter(function(p) { return !!p; });

    // slice по странице (0 = «Все», без среза)
    var pageSize = _accPageSize > 0 ? _accPageSize : filtered.length || 1;
    var totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
    if (_accPage >= totalPages) _accPage = Math.max(0, totalPages - 1);
    var pageStart = _accPage * pageSize;
    var pageEnd = pageStart + pageSize;
    var pageRows = filtered.slice(pageStart, pageEnd);

    var shown = document.getElementById('acc-shown');
    if (shown) shown.textContent = pageRows.length;

    // Пагинатор показываем только если страниц больше одной
    var pager = document.getElementById('acc-pager');
    var pageInfo = document.getElementById('acc-page-info');
    if (pager) pager.style.display = totalPages > 1 ? 'inline-flex' : 'none';
    if (pageInfo) pageInfo.textContent = (_accPage + 1) + ' / ' + totalPages;

    if (!filtered.length) {
        var _searching = !!(search || tagFilter);
        var _title, _text;
        if (_searching) {
            _title = 'Ничего не найдено';
            _text = 'Попробуйте изменить поиск или сбросить фильтр.';
        } else if (_allAccounts.length > 0) {
            // Акки ЕСТЬ, но текущая вкладка их скрывает — чаще всего все ушли в
            // Мёртвые/Заморозку после проверки. Это НЕ пропажа: показываем где они,
            // чтобы юзер не думал что акки исчезли (частая паника при ложных смертях).
            var _dead = 0, _frozen = 0, _spam = 0;
            _allAccounts.forEach(function(a) {
                if (a.dead) _dead++;
                else if (a.frozen) _frozen++;
                else if (a._isSpam) _spam++;
            });
            var _parts = [];
            if (_dead) _parts.push(_dead + ' в «Мёртвые»');
            if (_frozen) _parts.push(_frozen + ' в «Заморозке»');
            if (_spam) _parts.push(_spam + ' в спаме');
            _title = 'В этой вкладке пусто';
            _text = 'Аккаунты никуда не пропали — их ' + _allAccounts.length +
                ', просто не в этой вкладке' +
                (_parts.length ? ' (' + _parts.join(', ') + '). Переключи вкладку сверху.' : '. Переключи вкладку сверху.') +
                (_dead ? ' Если пометились мёртвыми по ошибке (например из-за второй копии) — поставь галку «Перепроверить мёртвых» и нажми «Проверить спам».' : '');
        } else {
            _title = 'Аккаунтов пока нет';
            _text = 'Загрузите .session-файл аккаунта чтобы начать работу.';
        }
        el.innerHTML = '<tr><td colspan="10" style="padding:0;white-space:normal;">' +
            '<div class="empty-state">' +
                '<i class="bi bi-phone empty-state__icon"></i>' +
                '<div class="empty-state__title">' + _title + '</div>' +
                '<div class="empty-state__text" style="max-width:480px;line-height:1.5;">' + _text + '</div>' +
            '</div>' +
        '</td></tr>';
        return;
    }

    el.innerHTML = pageRows.map(function(a, i) {
        var flag = flagImg(a.flag);
        var cc = a.flag ? a.flag.toUpperCase() : '';

        // Status badge (badge-soft — единый стиль для всех вариантов).
        // «Мёртвый» — только если это подтверждённая Telegram'ом смерть (session_revoked,
        // auth_key_duplicated, user_banned, user_deactivated). Транзиентные ошибки connect
        // или загрузка из state без проверки → «Не проверялся» вместо «Мёртвый», иначе
        // пользователь думает что новый акк сразу умер хотя на самом деле жив.
        // Паритет с backend _CONFIRMED_DEAD_REASONS (models.py).
        // 'unauthorized' и 'from_state' НАМЕРЕННО НЕ красим: первый self-heal'ится
        // планировщиком (ложно-мёртвый), второй — неизвестная причина из state.
        var isReallyDead = (_accBadgeKind(a) === 'dead');

        // hover-tooltip с временем последней проверки спама.
        function _whenAgo(ts) {
            if (!ts) return 'не проверялся';
            var sec = Math.max(0, Math.floor(Date.now() / 1000 - ts));
            if (sec < 60) return sec + ' сек назад';
            if (sec < 3600) return Math.floor(sec / 60) + ' мин назад';
            if (sec < 86400) return Math.floor(sec / 3600) + ' ч назад';
            return Math.floor(sec / 86400) + ' д назад';
        }
        var _checkedAt = a.spam_checked_at || a.last_spam_check_at || 0;
        var _checkedTip = _checkedAt ? ('Последняя проверка: ' + _whenAgo(_checkedAt)) : 'Ещё не проверялся через @SpamBot';

        // human-friendly mapping для причины смерти/заморозки.
        function _humanDeadReason(reason) {
            var r = String(reason || '').toLowerCase();
            if (r.indexOf('session_revoked') !== -1 || r.indexOf('sessionrevoked') !== -1) return 'сессия отозвана';
            if (r.indexOf('auth_key_duplicated') !== -1) return 'тот же аккаунт открыт где-то ещё';
            if (r.indexOf('authkeyunregistered') !== -1 || r.indexOf('auth_key_unregistered') !== -1) return 'сессия мёртва';
            if (r.indexOf('authkeyrevokedsilent') !== -1) return 'сессия слетела (тихий отзыв)';
            if (r.indexOf('user_banned') !== -1 || r.indexOf('userbanned') !== -1) return 'бан Telegram';
            if (r.indexOf('user_deactivated') !== -1 || r.indexOf('userdeactivated') !== -1) return 'акк удалён';
            if (r.indexOf('phone_banned') !== -1 || r.indexOf('phonenumberbanned') !== -1) return 'номер забанен';
            if (r.indexOf('session_expired') !== -1 || r.indexOf('sessionexpired') !== -1) return 'вход больше не принимают';
            if (r.indexOf('session_file_broken') !== -1) return 'файл сессии не прочитался — перепроверь';
            // специфичные Telethon-ошибки frozen-разновидностей.
            if (r.indexOf('frozenmethodinvaliderror') !== -1) return 'заморозка (метод заблокирован)';
            if (r.indexOf('frozenparticipantmissing') !== -1) return 'заморозка (нет участника)';
            if (r.indexOf('restricted_by_telegram') !== -1) return 'ограничение Telegram';
            if (r.indexOf('frozen') !== -1) return 'заморозка Telegram';
            return reason ? String(reason).slice(0, 30) : '';
        }
        var _deadHuman = _humanDeadReason(a.dead_reason);
        var _frozenHuman = _humanDeadReason(a.frozen_reason || 'frozen');
        var _fmtFrz = function (ts) { try { return new Date(ts * 1000).toLocaleString('ru-RU'); } catch (_) { return ''; } };

        var statusBadge = '';
        if (isReallyDead) {
            // короткий бейдж «Мёртвые»; в тултипе — причина + КОГДА слетел.
            var _deadTip = [_deadHuman || a.dead_reason || 'сессия отозвана'];
            if (a.dead_since) _deadTip.push('слетел: ' + _fmtFrz(a.dead_since));
            statusBadge = '<span class="badge-soft badge-soft--danger" title="' + escHtml(_deadTip.join(' · ')) + '">Мёртвые</span>';
        }
        else if (a.frozen) {
            // «❄ Заморозка» + в тултипе ПОЛНАЯ причина: когда заморозили, до когда
            // (или бессрочно), а рядом — кликабельная апелляция в Telegram. Это и есть
            // «знать причину и исправить сразу»: апелляция или дождаться срока.
            var _tipParts = [_frozenHuman ? ('Telegram заморозил акк — ' + _frozenHuman) : 'Telegram заморозил аккаунт'];
            if (a.frozen_since) _tipParts.push('заморожен: ' + _fmtFrz(a.frozen_since));
            if (a.frozen_until) _tipParts.push('до: ' + _fmtFrz(a.frozen_until));
            else if (a.frozen_since) _tipParts.push('срок: бессрочно — снимается по апелляции');
            statusBadge = '<span class="badge-soft badge-soft--info" title="' + escHtml(_tipParts.join(' · ')) + '"><i class="bi bi-snow"></i> Заморозка</span>';
            if (a.frozen_appeal_url) {
                statusBadge += ' <a href="' + escHtml(a.frozen_appeal_url) + '" target="_blank" rel="noopener" title="Подать апелляцию в Telegram"><i class="bi bi-box-arrow-up-right"></i> апелляция</a>';
            }
        }
        // Дальше решает _accBadgeKind — ТОТ ЖЕ вердикт, по которому «Выделить
        // разом…» проставляет галки. Иначе юзер удалял бы не то, что видит.
        else if (_accBadgeKind(a) === 'unchecked')
            statusBadge = '<span class="badge-soft badge-soft--muted" title="Через @SpamBot ещё не проверялся (или ни разу не подключался) — нажми «Проверить спам»">Не проверялся</span>';
        else if (_accBadgeKind(a) === 'perma') statusBadge = '<span class="badge-soft badge-soft--amber" title="Вечный спам-блок · ' + _checkedTip + '">Вечный спам</span>';
        else if (_accBadgeKind(a) === 'spam') statusBadge = '<span class="badge-soft badge-soft--warning" title="Временный спам-блок · ' + _checkedTip + '">Со спамблоком</span>';
        else if (_accBadgeKind(a) === 'floodwait') statusBadge = '<span class="badge-soft badge-soft--warning" title="Флуд-блок от Telegram">Флуд ' + escHtml(a.floodwait) + '</span>';
        else statusBadge = '<span class="badge-soft badge-soft--success" title="' + _checkedTip + '">Без спама</span>';

        // Busy module — пульсирующий индикатор работающей задачи.
        var busyBadge = '';
        if (a.busy) {
            busyBadge = '<span class="badge-soft badge-soft--accent" style="margin-left:4px;"><span class="status-dot status-dot--running" style="margin-right:0;"></span>' + escHtml(a.busy) + '</span>';
            // Если есть busy (TG-веб/Парсер/Спам-рассылка) — скрываем status-бейдж
            // «Без спама»/«Не проверялся». Приоритет: активная задача → статус акка.
            statusBadge = '';
        }

        // «Глух» — Telegram не отдаёт ResolveUsername этому акку. Ставится:
        // join detector'ом (8+ подряд not_found) или health check'ом (score<45).
        // Авто-снимается через 24ч или после успешного join.
        var deafBadge = '';
        if (a.deaf) {
            var deafTip = a.deaf_reason || 'Telegram не резолвит публичные каналы — прогрейте акк';
            deafBadge = '<span class="badge-soft badge-soft--warning" data-tip="' + escHtml(deafTip) + '" style="margin-left:4px;cursor:help;"><i class="bi bi-ear" style="margin-right:2px;"></i>Глух</span>';
        }

        // Бейдж «Без прокси» в строке УБРАН (юзер: «зачем писать без прокси? лишний
        // бейдж»): когда прокси нет у всех акков, он дублировался в каждой строке и
        // зашумлял таблицу. Сигнал не потерян — он есть в колонке «Прокси», в
        // предупреждении лога («Прокси не добавлен...») и в пустом состоянии вкладки
        // «Прокси». Переменную оставляем пустой, чтобы не трогать сборку строки ниже.
        var noProxyBadge = '';

        // Badge «Прогрев N/X мин» — свежезарегистрированный acc сейчас
        // прогревается. Если включён в Multi-DM в это время — риск revoke высокий.
        var warmingBadge = '';
        if (a.warming_now) {
            var _wtip = 'Прогрев свежезарегистрированного акка (' + a.warming_elapsed_min + '/' + a.warming_total_min + ' мин). После завершения acc безопаснее запускать в Multi-DM.';
            warmingBadge = '<span class="badge-soft badge-soft--warning" data-tip="' + escHtml(_wtip) + '" style="margin-left:4px;cursor:help;"><i class="bi bi-fire"></i> Прогрев ' + a.warming_elapsed_min + '/' + a.warming_total_min + ' мин</span>';
        }

        // Spam info short
        var spamShort = '';
        if (a._isPerma) spamShort = '<span style="color:var(--danger);font-size:0.65rem;">Вечный</span>';
        else if (a._isSpam) spamShort = '<span style="color:var(--warning);font-size:0.65rem;">Временный</span>';
        else if (!a.dead) spamShort = '<span style="color:var(--success);font-size:0.65rem;">Чисто</span>';

        // Имя — только first_name (одна строка, обрезается «…» через CSS).
        // @username переехал в «Действия» как @-кнопка (копировать + тултип).
        var nameStr = a.first_name ? escHtml(a.first_name) : '';
        // Пол — рядом с именем: заполнение профилей берёт имя/аватарку из папки
        // «Женские» или «Мужские», и клиенту нужно видеть, что размечено, а что нет.
        // Бейдж рисуем ВСЕГДА, включая «пол не задан»: иначе на 1000 строк не
        // отличить неразмеченный акк от того, у которого просто не видно.
        // data-tip (не нативный title) — единый вид тултипов во всей таблице.
        var _gv = (a.gender === 'female' || a.gender === 'male') ? a.gender : '';
        var _gm = {
            female: {t: 'Ж', c: '#e96eb4', bg: 'rgba(233,110,180,.18)',
                     tip: 'Женский — имя и аватарка возьмутся из папки «Женские»'},
            male:   {t: 'М', c: '#5a96eb', bg: 'rgba(90,150,235,.18)',
                     tip: 'Мужской — имя и аватарка возьмутся из папки «Мужские»'},
            '':     {t: '?', c: 'var(--text-muted)', bg: 'rgba(255,255,255,.06)',
                     tip: 'Пол не задан — заполнение профиля возьмёт папку наугад (берётся из .json пары или определяется по имени)'}
        }[_gv];
        nameStr += '<span data-tip="' + _gm.tip + '"' +
            ' style="margin-left:5px;font-size:0.66rem;padding:0 5px;border-radius:4px;cursor:help;' +
            'background:' + _gm.bg + ';color:' + _gm.c + ';">' + _gm.t + '</span>';

        var rowStyle = a._active ? 'background:var(--bg-card-hover);' : '';

        return '<tr onclick="selectAccount(\'' + a.phone + '\')" style="cursor:pointer;' + rowStyle + '" class="acc-row" data-phone="' + a.phone + '">' +
            '<td style="padding:4px 6px;vertical-align:middle;text-align:center;width:28px;" onclick="event.stopPropagation();var cb=this.querySelector(\'input\');if(event.target!==cb){cb.checked=!cb.checked;cb.dispatchEvent(new Event(\'change\',{bubbles:true}));}"><input type="checkbox" class="acc-check" value="' + a.phone + '" onclick="event.stopPropagation()" style="vertical-align:middle;margin:0;cursor:pointer;"></td>' +
            '<td style="color:var(--text-muted);font-size:0.68rem;padding:4px 6px;text-align:center;">' + (i+1) + '</td>' +
            '<td style="padding:4px 6px;white-space:nowrap;">' +
                '<span style="display:inline-flex;align-items:center;gap:6px;">' +
                    // Аватар: буква-заглушка + поверх реальное фото (onerror убирает img → видна буква)
                    '<span style="position:relative;width:22px;height:22px;border-radius:50%;background:' + _avatarBubbleGradient(a.phone) + ';display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-size:0.62rem;flex-shrink:0;overflow:hidden;">' +
                        (((a.first_name || a.phone || '?').charAt(0) || '?').toUpperCase()) +
                        '<img src="/api/accounts/avatar/' + a.phone + '?v=' + _avatarCacheBust + '" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;" onerror="this.remove()">' +
                    '</span>' +
                    '<span class="acc-phone-copy" onclick="event.stopPropagation();copyPhone(event, \'' + a.phone + '\')" title="Кликни чтобы скопировать" style="font-weight:600;font-size:0.77rem;cursor:copy;user-select:all;">+' + a.phone + '</span>' +
                    (a.is_premium ? '<span style="color:#fbbf24;font-size:0.7rem;" title="Premium"><i class="bi bi-star-fill"></i></span>' : '') +
                    // Пульс-индикатор активности убран.
                '</span>' +
                '</td>' +
            '<td style="padding:4px 6px;">' + (cc ? flag : '<span style="color:var(--text-muted);font-size:0.65rem;">—</span>') + '</td>' +
            '<td style="padding:4px 6px;">' + statusBadge + busyBadge + deafBadge + noProxyBadge + warmingBadge +
                // Цветной folder-pill, чтобы в фильтре «Все» сразу видно откуда акк.
                // «В работе» не показываем — это дефолт, не несёт инфо.
                (function() {
                    var fld = (a.folder || '').trim();
                    if (!fld || fld === 'В работе') return '';
                    // Один актуальный тег, без дубля: для кладбищенских папок
                    // (Мёртвые / Заморозка / В спам / Вечный спам) показываем ТОЛЬКО
                    // статус-бейдж — folder-pill с тем же смыслом не рисуем.
                    if (fld === 'Заморозка' || fld === 'В спам' || fld === 'Вечный спам'
                        || fld === 'Мёртвые' || fld === 'Dead') return '';
                    // Не дублируем «Прогрев» — если уже есть warming/busy-бейдж
                    // «Прогрев», folder-pill «Прогрев» лишний.
                    if (fld === 'Прогрев' && (a.warming_now || a.busy === 'Прогрев')) return '';
                    var palette = {
                        'Прогрев':                 ['#fb923c', 'rgba(251,146,60,0.14)'],
                        'Мониторинг':              ['#c084fc', 'rgba(192,132,252,0.14)'],
                        'Архив':                   ['#94a3b8', 'rgba(148,163,184,0.14)'],
                        'Ферма':                   ['#4ade80', 'rgba(74,222,128,0.14)'],
                        'После инвайта':           ['#a78bfa', 'rgba(167,139,250,0.14)'],
                        'После переавторизации':   ['#60a5fa', 'rgba(96,165,250,0.14)'],
                        'Конвертер session → tdata': ['#94a3b8', 'rgba(148,163,184,0.10)'],
                        'Конвертер tdata → session': ['#94a3b8', 'rgba(148,163,184,0.10)'],
                    };
                    var pal = palette[fld] || ['#a89bc7', 'rgba(125,111,232,0.12)'];
                    return '<span style="display:inline-block;margin-left:4px;padding:1px 8px;border-radius:5px;font-size:0.62rem;color:' + pal[0] + ';background:' + pal[1] + ';border:1px solid ' + pal[0] + '40;font-weight:500;" title="Папка ' + escHtml(fld) + '">' + escHtml(fld) + '</span>';
                })() +
            '</td>' +
            '<td style="padding:4px 6px;white-space:nowrap;">' +
                (a.proxy
                    ? '<span style="font-size:0.67rem;font-family:monospace;color:var(--text-muted);" data-tip="Прокси закреплён за аккаунтом">' + escHtml(a.proxy) + '</span>'
                    : '<span style="color:var(--text-muted);font-size:0.65rem;">—</span>') +
            '</td>' +
            '<td class="col-age" style="padding:4px 6px;"' + (a._ageSrc ? ' title="Отлёжка: ' + escHtml(a._ageSrc) + '"' : '') + '><span style="font-size:0.68rem;color:var(--text-muted);">' + (a._ageText || '—') + '</span></td>' +
            '<td style="font-size:0.72rem;padding:4px 6px;overflow:hidden;">' + (nameStr || '<span style="color:var(--text-muted);">—</span>') + '</td>' +
            '<td style="padding:4px 6px;">' + renderAccountTagsCell(a.phone) + '</td>' +
            (function() {
                // Блокируем action-кнопки если акк сейчас в задаче — дёргать
                // @SpamBot и Telethon параллельно с активной job ломает сессию.
                var _busy = a.busy || '';
                var _disabled = _busy ? 'disabled' : '';
                var _busyTip = _busy ? ('Недоступно — акк сейчас в задаче «' + _busy + '». Сначала остановите задачу.') : '';
                var _bs = _busy ? 'opacity:0.4;cursor:not-allowed;pointer-events:none;' : '';
                var _uname = a.username ? escHtml(a.username) : '';
                return '<td style="text-align:center;padding:4px 6px;">' +
                '<div class="acc-actions">' +
                    '<button onclick="openAccountTimeline(\'' + a.phone + '\');event.stopPropagation();" class="btn btn-outline btn-sm" data-tip="Диаграмма активности и статистика акка за 7 дней"><i class="bi bi-graph-up"></i></button>' +
                    '<button ' + _disabled + ' onclick="' + (_busy ? '' : 'checkAccountHealth(\'' + a.phone + '\',event)') + '" class="btn btn-outline btn-sm" style="' + _bs + '" data-tip="' + (_busyTip || 'Диагностика акка — trust-score') + '"><i class="bi bi-heart-pulse"></i></button>' +
                    '<button ' + _disabled + ' onclick="' + (_busy ? '' : 'checkSpam(\'' + a.phone + '\',event)') + '" class="btn btn-outline btn-sm" style="' + _bs + '" data-tip="' + (_busyTip || 'Проверить спамблок через @SpamBot') + '"><i class="bi bi-shield-check"></i></button>' +
                    (_uname ? '<button onclick="event.stopPropagation();copyUsername(event,\'' + _uname + '\')" class="btn btn-outline btn-sm" data-tip="@' + _uname + ' — кликни чтобы скопировать" style="font-weight:700;">@</button>' : '') +
                    '<button ' + _disabled + ' onclick="' + (_busy ? '' : 'viewAccount(\'' + a.phone + '\',event)') + '" class="btn-tg-open btn-sm" style="' + _bs + '" data-tip="' + (_busyTip || 'Открыть ТГ в вебе') + '"><i class="bi bi-telegram"></i></button>' +
                    '<button ' + _disabled + ' onclick="' + (_busy ? '' : 'open2faModal(\'' + a.phone + '\',event)') + '" class="btn btn-outline btn-sm" style="' + _bs + '" data-tip="' + (_busyTip || 'Поставить / сменить / снять облачный пароль (2FA) этому аккаунту') + '"><i class="bi bi-shield-lock"></i></button>' +
                    (a.proxy ? '<button ' + _disabled + ' onclick="' + (_busy ? '' : 'resetProxyRow(\'' + a.phone + '\',event)') + '" class="btn btn-outline btn-sm" style="' + _bs + '" data-tip="' + (_busyTip || 'Сбросить прокси с акка — вернётся к раздаче по номеру') + '"><i class="bi bi-hdd-network"></i></button>' : '') +
                    '<button ' + _disabled + ' onclick="' + (_busy ? '' : 'deleteAccount(\'' + a.phone + '\',event)') + '" class="btn btn-outline btn-sm btn-acc-del" style="' + _bs + '" data-tip="' + (_busyTip || 'Удалить аккаунт') + '"><i class="bi bi-trash"></i></button>' +
                '</div>' +
                '</td>';
            })() +
        '</tr>';
    }).join('');
    // после перерисовки восстанавливаем checked phones из localStorage
    try { _restoreCheckedAccPhones(); } catch(e) {}
}


function toggleAllAccChecks(master) {
    document.querySelectorAll('.acc-check').forEach(function(c) { c.checked = master.checked; });
    updateBulkActionsBar();
    _saveCheckedAccPhones();
}

// Причины смерти, при которых акк реально показывается как «Мёртвые». Голого
// acc.dead мало: транзиентные (таймаут/сеть) в бейдж не идут и хоронить их нельзя.
var _CONFIRMED_DEAD_REASONS = ['session_revoked','auth_key_duplicated','authkeyrevokedsilent',
    'authkeyunregistered','sessionrevoked','user_banned','user_deactivated',
    'manual_move','session_format_incompatible','phone_banned','session_expired',
    'session_file_broken'];

// ЕДИНЫЙ вердикт о типе аккаунта: dead / frozen / unchecked / perma / spam /
// floodwait / clean. Им пользуется И бейдж в таблице, И «Выделить разом…».
// Держать две копии этой лестницы нельзя: «Выделить разом…» ведёт к массовому
// УДАЛЕНИЮ сессий, и любое расхождение = юзер сносит не то, что видит глазами.
// Порядок веток обязан совпадать с рендером бейджа (dead → frozen → …).
function _accBadgeKind(a) {
    if (!a) return 'clean';
    if (a.dead && a.dead_reason &&
        _CONFIRMED_DEAD_REASONS.indexOf(String(a.dead_reason).toLowerCase()) !== -1) return 'dead';
    if (a.frozen) return 'frozen';
    if (!a.verified && !a._isPerma && !a._isTemp && !a._isSpam && !a.floodwait) return 'unchecked';
    if (a._isPerma) return 'perma';
    if (a._isTemp || a._isSpam) return 'spam';
    if (a.floodwait) return 'floodwait';
    if ((a.spam_checked_at || a.last_spam_check_at || 0) > 0) return 'clean';
    return 'unchecked';
}

// Массовое выделение по типу акка. Боль клиента: «1000 акков, 300 не перекинулось,
// их надо удалить — и вот по одному это нажимать». Авто-раскладка после проверки
// (_autoSortAfterCheck) двигает только акки с ОПРЕДЕЛЁННЫМ вердиктом, а серые
// «Не проверялся» и не подключившиеся остаются на месте — их и приходилось тыкать.
// Делаем ВЫДЕЛЕНИЕ, а не фильтрацию: статус-фильтр в UI уже убирали, потому что
// юзер залипал в скрытом фильтре и не понимал, куда делись акки. Тут таблица
// показывает всё как было, просто проставляются галки → дальше существующие
// «Перенести» / «Удалить выделенные».
function selectAccountsByType(kind) {
    if (!kind) return;
    var byPhone = {};
    (_allAccounts || []).forEach(function(a) {
        var p = String(a.phone || '').replace(/\D/g, '');
        if (p) byPhone[p] = a;
    });
    // Пункт списка → вердикт _accBadgeKind. «Со спам-блоком» берёт и временный,
    // и вечный: для юзера это один тип «в спаме».
    var want = {
        unchecked: ['unchecked'],
        spam:      ['spam', 'perma'],
        dead:      ['dead'],
        frozen:    ['frozen'],
        clean:     ['clean']
    }[kind];
    if (kind === 'none') {
        _clearAccSelection();
        _saveCheckedAccPhones();
        updateBulkActionsBar();
        _syncAccMasterCheckbox();
        showToast('Выделение снято', 'info');
        return;
    }
    // Работаем по ВСЕМУ отфильтрованному списку (все страницы), а не по видимым
    // строкам — иначе на 1000 акках выделялись бы только первые 500.
    var scope = (_lastFilteredPhones || []).slice();
    if (!scope.length) {
        document.querySelectorAll('.acc-check').forEach(function(c) {
            var n0 = _normAccPhone(c.value); if (n0) scope.push(n0);
        });
    }
    // Новый выбор по типу ЗАМЕЩАЕТ прежний, а не докидывается к нему: иначе к
    // «выделено 12 мёртвых» тихо прибавлялись галки, поставленные вчера (они
    // живут в localStorage), и удалялось 15 акков вместо 12.
    var busySkipped = 0;
    if (kind !== 'invert') _clearAccSelection();
    scope.forEach(function(ph) {
        var a = byPhone[ph];
        if (!a) return;
        // Занятый акк не трогаем НИКОГДА, включая инверсию: бейдж у него скрыт
        // под индикатором задачи (юзер его типа не видит), а «Удалить выделенные»
        // снесёт файл сессии прямо посреди работы.
        if (a.busy) { busySkipped++; delete _selectedAccPhones[ph]; return; }
        if (kind === 'invert') {
            if (_selectedAccPhones[ph]) delete _selectedAccPhones[ph];
            else _selectedAccPhones[ph] = true;
            return;
        }
        if (want && want.indexOf(_accBadgeKind(a)) !== -1) _selectedAccPhones[ph] = true;
    });
    // Проставляем галки на видимой странице из набора.
    document.querySelectorAll('.acc-check').forEach(function(c) {
        var n0 = _normAccPhone(c.value);
        if (n0) c.checked = !!_selectedAccPhones[n0];
    });
    try { localStorage.setItem('tp_checked_accs', JSON.stringify(Object.keys(_selectedAccPhones))); } catch (_) {}
    updateBulkActionsBar();
    _syncAccMasterCheckbox();

    var labels = {unchecked: 'непроверенных', spam: 'со спам-блоком', dead: 'подтверждённо мёртвых',
                  frozen: 'в заморозке', clean: 'проверенных чистых', invert: 'после инверсии'};
    // Считаем ИТОГ по набору, а не «сколько добавили»: именно эту цифру потом
    // покажет подтверждение удаления, и врать она не должна.
    var total = Object.keys(_selectedAccPhones).length;
    var tail = busySkipped ? (' · пропущено занятых: ' + busySkipped) : '';
    var offPage = total - document.querySelectorAll('.acc-check:checked').length;
    if (offPage > 0) tail += ' · из них на других страницах: ' + offPage;
    if (total) showToast('Выделено ' + total + ' ' + (labels[kind] || '') + tail +
                         ' — дальше «Перенести» или «Удалить выделенные»', 'success');
    else showToast('Подходящих аккаунтов не нашлось' + tail +
                   '. Учитываются вкладка, поиск и фильтр тегов', 'warning');
}

// Пол выделенным аккаунтам. Нужен «Заполнению профилей»: материалы лежат в
// папках «Женские»/«Мужские», а пол акка раньше выбирался случайно — мужской акк
// мог получить женское имя и женскую аватарку.
function setGenderForSelected(kind) {
    if (!kind) return;
    var phones = _getCheckedAccPhones();
    if (!phones.length) { showToast('Сначала выдели аккаунты', 'warning'); return; }
    var body = { phones: phones };
    if (kind === 'auto') body.auto = true;
    else if (kind === 'clear') body.gender = '';
    else body.gender = kind;
    API.post('/api/accounts/set_gender', body).then(function(d) {
        if (!d || !d.ok) { showToast((d && d.error) || 'Не удалось', 'error'); return; }
        var undList = d.undetected || [];
        var label = {female: 'женский', male: 'мужской', auto: 'по имени', clear: 'сброшен'}[kind] || '';
        var msg = 'Пол (' + label + ') проставлен: ' + d.changed;
        if (undList.length) {
            // Сказать «не определилось у 137» и не дать их найти — тупик.
            // Оставляем выделенными ровно их: клиент сразу жмёт Ж или М.
            msg += ' · не определился у ' + undList.length + ' — они остались выделены, поставь вручную';
        }
        loadAccounts();
        if (undList.length) {
            _selectedAccPhones = {};
            undList.forEach(function(p) {
                var n = _normAccPhone(p); if (n) _selectedAccPhones[n] = true;
            });
            try { localStorage.setItem('tp_checked_accs', JSON.stringify(Object.keys(_selectedAccPhones))); } catch (_) {}
            setTimeout(function() {
                document.querySelectorAll('.acc-check').forEach(function(c) {
                    var n = _normAccPhone(c.value); if (n) c.checked = !!_selectedAccPhones[n];
                });
                updateBulkActionsBar();
                _syncAccMasterCheckbox();
            }, 400);
        }
        showToast(msg, undList.length ? 'warning' : 'success');
        return;
    }).catch(function() { showToast('Ошибка запроса', 'error'); });
}

function _syncAccMasterCheckbox() {
    var m = document.getElementById('acc-check-all');
    if (!m) return;
    var all = document.querySelectorAll('.acc-check').length;
    var on = document.querySelectorAll('.acc-check:checked').length;
    m.checked = all > 0 && on === all;
}

// ── Выделение аккаунтов: источник истины — НАБОР телефонов, а не галки в DOM ──
// Таблица пагинируется (дефолт 500/стр). Пока выделение жило в DOM, переход на
// вторую страницу молча стирал выбор с первой: _saveCheckedAccPhones перезаписывал
// localStorage тем, что сейчас отрисовано. На флоте в 1000 акков (ровно кейс из
// жалобы) юзер выделял 300, а удалялись 150 — и никто не объяснял почему.
// ── Подсказка «сколько это по-человечески» рядом с секундными полями ─────────
// Все паузы в софте задаются в СЕКУНДАХ — одна единица, никакой путаницы
// «мин» = минуты или минимум. Но 600 сек читается хуже, чем «10 мин», а у
// конкурентов круги задаются минутами — клиенту пришлось бы считать в уме.
// Поэтому ввод в секундах, а рядом live-подсказка с переводом.
function _humanSeconds(sec) {
    sec = Math.round(Number(sec) || 0);
    if (sec < 60) return '';
    if (sec < 3600) {
        var m = sec / 60;
        return '= ' + (Math.abs(m - Math.round(m)) < 0.05 ? Math.round(m) : m.toFixed(1)) + ' мин';
    }
    var h = sec / 3600;
    return '= ' + (Math.abs(h - Math.round(h)) < 0.05 ? Math.round(h) : h.toFixed(1)) + ' ч';
}

function _refreshSecHints() {
    document.querySelectorAll('.tp-sec-hint').forEach(function(el) {
        var base = el.getAttribute('data-for');
        if (!base) return;
        var a = document.getElementById(base);
        var b = document.getElementById(base + '-max');
        if (!a) return;
        var lo = parseInt(a.value, 10) || 0;
        var hi = b ? (parseInt(b.value, 10) || 0) : 0;
        // Нижняя граница < 60 сек даёт пустой перевод — без фолбэка выходило
        // «( – 15 мин)» с висящим тире.
        var loTxt = _humanSeconds(lo) || (lo ? (lo + ' сек') : '');
        var hiTxt = _humanSeconds(hi) || (hi ? (hi + ' сек') : '');
        var txt = (hi && hi !== lo && loTxt && hiTxt)
            ? (loTxt + ' – ' + hiTxt.replace('= ', ''))
            : loTxt;
        el.textContent = txt ? ('(' + txt.replace('= ', '') + ')') : '';
        el.style.color = 'var(--text-muted)';
        el.style.fontSize = '0.72rem';
    });
}

// input И change: ползунки/стрелки дают разные события, а программная запись
// value (восстановление из localStorage, ответ /api/settings) не даёт вообще
// никакого — поэтому все загрузчики ниже дёргают _refreshSecHints() явно.
// Без этого подсказка после перезапуска показывала перевод от HTML-дефолта:
// в поле 1800, а рядом «(10 мин)» — врала ровно там, ради чего делалась.
document.addEventListener('input', function(e) {
    if (e.target && e.target.classList && e.target.classList.contains('tp-sec')) _refreshSecHints();
});
document.addEventListener('change', function(e) {
    if (e.target && e.target.classList && e.target.classList.contains('tp-sec')) _refreshSecHints();
});
document.addEventListener('DOMContentLoaded', _refreshSecHints);

function _normAccPhone(v) { return String(v || '').replace(/\D/g, ''); }

function _syncSelectionFromDom() {
    // Видимые строки — авторитет ДЛЯ СЕБЯ. Невидимые (другие страницы) не трогаем,
    // иначе снова потеряем выбор при листании.
    document.querySelectorAll('.acc-check').forEach(function(c) {
        var n = _normAccPhone(c.value);
        if (!n) return;
        if (c.checked) _selectedAccPhones[n] = true;
        else delete _selectedAccPhones[n];
    });
}

function _clearAccSelection() {
    _selectedAccPhones = {};
    document.querySelectorAll('.acc-check').forEach(function(c) { c.checked = false; });
}

function _getCheckedAccPhones() {
    _syncSelectionFromDom();
    return Object.keys(_selectedAccPhones);
}

function updateBulkActionsBar() {
    var phones = _getCheckedAccPhones();
    var n = phones.length;
    // «Проверить спам» — переименовываем когда выделены конкретные акки.
    // min-width на кнопке держит ширину постоянной чтобы тулбар не дёргался.
    var spamBtn = document.getElementById('btn-check-all-spam');
    if (spamBtn && spamBtn.dataset.running !== '1') {
        spamBtn.innerHTML = (n > 0)
            ? '<i class="bi bi-shield-check"></i> Проверить выделенные (' + n + ')'
            : '<i class="bi bi-shield-check"></i> Проверить спам';
    }
}

// При клике по любой строке-чекбоксу — пересчитываем bulk-actions.
// Делегируем чтобы не привязываться к каждой строке отдельно после loadAccounts().
// Сохраняем checked phones в localStorage и восстанавливаем после перерисовки.
function _saveCheckedAccPhones() {
    try {
        _syncSelectionFromDom();
        localStorage.setItem('tp_checked_accs', JSON.stringify(Object.keys(_selectedAccPhones)));
    } catch(e) {}
}
function _restoreCheckedAccPhones() {
    try {
        var raw = localStorage.getItem('tp_checked_accs');
        if (raw) {
            var phones = JSON.parse(raw) || [];
            if (Array.isArray(phones)) {
                phones.forEach(function(p) {
                    var n = _normAccPhone(p);
                    if (n) _selectedAccPhones[n] = true;
                });
            }
        }
        // Чистим протухшее: телефоны удалённых акков копились в localStorage
        // вечно и всплывали в счётчике «Удалить выделенные» как призраки.
        if ((_allAccounts || []).length) {
            var live = {};
            _allAccounts.forEach(function(a) {
                var n = _normAccPhone(a.phone);
                if (n) live[n] = true;
            });
            Object.keys(_selectedAccPhones).forEach(function(p) {
                if (!live[p]) delete _selectedAccPhones[p];
            });
        }
        document.querySelectorAll('.acc-check').forEach(function(c){
            var n = _normAccPhone(c.value);
            if (n && _selectedAccPhones[n]) c.checked = true;
        });
        updateBulkActionsBar();
    } catch(e) {}
}
document.addEventListener('change', function(e) {
    if (e.target && e.target.classList && e.target.classList.contains('acc-check')) {
        updateBulkActionsBar();
        _saveCheckedAccPhones();
    }
});

function selectAccount(phone) {
    API.post('/api/accounts/select', {phone: phone}).then(function(d) {
        if (d.ok) {
            // тихо, без toast
            loadAccounts();
            // Если открыт парсер — сразу перетянуть чаты под новый акк.
            if (_currentTab === 'parser') {
                window._parserAutoScanInFlight = false;
                loadParserChats();
            }
        }
    });
}

// ══════════════════════════════════════════════════════════════════
//  INVITE — массовый инвайт юзеров в чат/канал
// ══════════════════════════════════════════════════════════════════

var _invitePollTimer = null;

var _inviteSelectedPhones = [];

function openInviteAccPopup() {
    // Адаптер: инвайт — глобал _inviteSelectedPhones. Показываем мёртвых/замороженных
    // выключенными (includeDead + disableFrozen), как было в старом пикере инвайта.
    openAccountPicker({
        title: 'Выбор акков-инвайтеров', icon: 'bi-people', mode: 'multi',
        includeDead: true, disableFrozen: true,
        isSelected: function(p) { return _inviteSelectedPhones.indexOf(p) !== -1; },
        toggle: function(p, on) {
            var i = _inviteSelectedPhones.indexOf(p);
            if (on) { if (i === -1) _inviteSelectedPhones.push(p); }
            else if (i !== -1) _inviteSelectedPhones.splice(i, 1);
        },
        onChange: function() { renderInviteAccBadges(); },
        onDone: function() { renderInviteAccBadges(); }
    });
}

function renderInviteAccBadges() {
    var lbl = document.getElementById('invite-acc-count');
    var list = document.getElementById('invite-acc-list');
    if (!lbl || !list) return;
    if (!_inviteSelectedPhones.length) {
        // Пусто = пойдут все живые. Пишем это словами: цифра «0» тут соврала бы.
        lbl.textContent = 'все живые';
        lbl.style.color = 'var(--text-muted)';
        list.style.display = 'none';
        list.innerHTML = '';
        return;
    }
    lbl.textContent = _inviteSelectedPhones.length;
    lbl.style.color = 'var(--success)';
    list.style.display = '';
    list.innerHTML = _inviteSelectedPhones.map(function(p) {
        return '<span class="badge" style="background:var(--surface-info-bg);color:var(--accent-light);padding:4px 8px;border-radius:4px;font-size:0.72rem;display:inline-flex;align-items:center;gap:4px;">' +
            '+' + escHtml(p) +
            '<i class="bi bi-x" style="cursor:pointer;font-size:0.85rem;" onclick="removeInviteAcc(\'' + p + '\')"></i>' +
        '</span>';
    }).join('');
}

function removeInviteAcc(phone) {
    var idx = _inviteSelectedPhones.indexOf(phone);
    if (idx !== -1) {
        _inviteSelectedPhones.splice(idx, 1);
        renderInviteAccBadges();
    }
}

function clearInviteAccs() {
    _inviteSelectedPhones = [];
    renderInviteAccBadges();
}

function inviteShowSubtab(name) {
    ['main', 'userdb', 'reports'].forEach(function(n) {
        var t = document.getElementById('inv-tab-' + n);
        if (t) t.style.display = n === name ? '' : 'none';
        var b = document.getElementById('inv-sub-' + n);
        if (b) {
            b.classList.toggle('btn-accent', n === name);
            b.classList.toggle('btn-outline', n !== name);
        }
    });
    if (name === 'userdb') loadInviteUserdb();
    if (name === 'reports') loadInviteReports();
}
function inviteToggleAdminBlock() {
    var sel = document.getElementById('inv-mode');
    var blk = document.getElementById('inv-admin-block');
    if (sel && blk) blk.style.display = sel.value === 'via_admin' ? '' : 'none';
}
function loadInviteSettings() {
    API.get('/api/invite/settings').then(function(d) {
        if (!d.ok) return;
        var setVal = function(id, v) {
            var el = document.getElementById(id);
            if (el && v !== undefined && v !== null) el.value = v;
        };
        var setChk = function(id, v) {
            var el = document.getElementById(id);
            if (el) el.checked = !!v;
        };
        setVal('invite-pause-min', d.pause_min);
        setVal('invite-pause-max', d.pause_max);
        setVal('invite-max-per-acc', d.max_per_acc);
        // Хранится в минутах (flood_cooldown_min), показываем в секундах —
        // единая единица во всём софте.
        setVal('invite-flood-cooldown-sec', d.flood_cooldown_min ? d.flood_cooldown_min * 60 : 86400);
        setVal('invite-hourly-limit', d.hourly_limit);
        setVal('invite-stop-on-privacy', d.stop_on_privacy_streak);
        setVal('inv-spamblock-skip', d.spamblock_skip_limit);
        setVal('inv-chargeback', d.chargeback_limit);
        setVal('inv-big-fw', d.big_floodwait_limit);
        setVal('inv-delay-join-min', d.delay_after_join_min);
        setVal('inv-delay-join-max', d.delay_after_join_max);
        setVal('inv-delay-promote-min', d.delay_after_promote_min);
        setVal('inv-delay-promote-max', d.delay_after_promote_max);
        setVal('inv-cascade-seed', d.cascade_seed_count);
        setChk('inv-set-spamblock', d.set_spamblock_in_userdb);
        setChk('inv-skip-chatinvalid', d.skip_chat_invalid);
        setChk('inv-leave-worker', d.leave_after_invite_worker);
        setChk('inv-leave-admin', d.leave_after_invite_admin);
        setChk('inv-archive', d.archive_chat);
        setVal('inv-mode', d.mode);
        setVal('inv-main-admin', d.main_admin_phone);
        var r = d.admin_rights || {};
        setChk('inv-r-changeinfo', r.change_info);
        setChk('inv-r-deletemsg', r.delete_messages);
        setChk('inv-r-banusers', r.ban_users);
        setChk('inv-r-managecall', r.manage_call);
        setChk('inv-r-pin', r.pin_messages);
        setChk('inv-r-anon', r.anonymous);
        setVal('inv-folder-spamblock', d.folder_spamblock);
        setVal('inv-folder-fresh', d.folder_fresh_session);
        setVal('inv-folder-search', d.folder_search_limit);
        inviteToggleAdminBlock();
    }).catch(function() {});
    inviteShowSubtab('main');
    updateInviteUserdbCount();
    renderInviteAccBadges();
    // Подсчёт целей при изменении textarea
    var ta = document.getElementById('invite-targets');
    if (ta && !ta._counterBound) {
        ta.addEventListener('input', updateInviteTargetsCount);
        ta._counterBound = true;
    }
    updateInviteTargetsCount();
    try { _refreshSecHints(); } catch (_) {}   // значения пришли программно — событий нет
}

function updateInviteTargetsCount() {
    var ta = document.getElementById('invite-targets');
    var lbl = document.getElementById('invite-targets-count');
    if (!ta || !lbl) return;
    var lines = (ta.value || '').split(/[\s,;\n\r]+/).filter(function(x) { return x.trim(); });
    lbl.textContent = lines.length ? (lines.length + ' цел.') : '';
}

function _collectInviteSettings() {
    var v = function(id) { var el = document.getElementById(id); return el ? el.value : ''; };
    var c = function(id) { var el = document.getElementById(id); return el ? el.checked : false; };
    return {
        pause_min: +v('invite-pause-min') || 30,
        pause_max: +v('invite-pause-max') || 70,
        max_per_acc: +v('invite-max-per-acc') || 40,
        hourly_limit: +v('invite-hourly-limit') || 40,
        // UI в секундах, бэкенд принимает минуты (ключ и его границы не трогаем —
        // он живёт в конфигах отложенных задач у клиентов).
        flood_cooldown_min: Math.max(1, Math.round((+v('invite-flood-cooldown-sec') || 86400) / 60)),
        stop_on_privacy_streak: +v('invite-stop-on-privacy') || 0,
        spamblock_skip_limit: +v('inv-spamblock-skip') || 1,
        chargeback_limit: +v('inv-chargeback') || 999,
        big_floodwait_limit: +v('inv-big-fw') || 9999,
        delay_after_join_min: +v('inv-delay-join-min') || 7,
        delay_after_join_max: +v('inv-delay-join-max') || 15,
        delay_after_promote_min: +v('inv-delay-promote-min') || 5,
        delay_after_promote_max: +v('inv-delay-promote-max') || 12,
        cascade_seed_count: +v('inv-cascade-seed') || 3,
        delete_contact_after: document.getElementById('invite-del-contact') ? c('invite-del-contact') : true,
        set_spamblock_in_userdb: c('inv-set-spamblock'),
        skip_chat_invalid: c('inv-skip-chatinvalid'),
        leave_after_invite_worker: c('inv-leave-worker'),
        leave_after_invite_admin: c('inv-leave-admin'),
        archive_chat: c('inv-archive'),
        mode: v('inv-mode') || 'direct',
        main_admin_phone: (v('inv-main-admin') || '').replace(/\D/g, ''),
        admin_rights: {
            change_info: c('inv-r-changeinfo'),
            delete_messages: c('inv-r-deletemsg'),
            ban_users: c('inv-r-banusers'),
            manage_call: c('inv-r-managecall'),
            pin_messages: c('inv-r-pin'),
            anonymous: c('inv-r-anon'),
        },
        folder_spamblock: v('inv-folder-spamblock'),
        folder_fresh_session: v('inv-folder-fresh'),
        folder_search_limit: v('inv-folder-search'),
    };
}
function saveInviteSettings() {
    API.post('/api/invite/settings', _collectInviteSettings()).then(function(d) {
        if (d.ok) showToast('Настройки инвайтера сохранены', 'success');
        else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() { showToast('Ошибка сети', 'error'); });
}

function loadInviteFromParser() {
    API.get('/api/parser/result').then(function(d) {
        if (!d.usernames || !d.usernames.length) {
            showToast('Парсер пуст — сначала запусти парсинг', 'warning'); return;
        }
        var ta = document.getElementById('invite-targets');
        if (!ta) return;
        var items = d.usernames.map(function(u) {
            if (typeof u === 'string') return u;
            return u.username ? '@' + u.username : (u.id ? String(u.id) : '');
        }).filter(function(x) { return x; });
        ta.value = items.join('\n');
        updateInviteTargetsCount();
        showToast('Подставлено ' + items.length + ' юзеров из парсера', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function onInviteTargetsFile(e) {
    var f = e.target.files && e.target.files[0];
    if (!f) return;
    var reader = new FileReader();
    reader.onload = function(ev) {
        var ta = document.getElementById('invite-targets');
        if (ta) {
            ta.value = (ev.target.result || '').toString();
            updateInviteTargetsCount();
            showToast('Файл загружен', 'success');
        }
    };
    reader.readAsText(f, 'utf-8');
    e.target.value = '';
}

function startInvite() {
    var chat = ((document.getElementById('invite-chat') || {}).value || '').trim();
    var targets = ((document.getElementById('invite-targets') || {}).value || '').trim();
    if (!chat) { showToast('Укажи целевой чат', 'warning'); return; }
    if (!targets) { showToast('Список целей пуст', 'warning'); return; }
    var phones = (_inviteSelectedPhones || []).slice();
    var payload = Object.assign({
        chat: chat,
        targets: targets,
        phones: phones,
    }, _collectInviteSettings());
    payload.ad_config = readAdConfig('inv');
    API.post('/api/invite/start', payload).then(function(d) {
        if (d.ok) {
            showToast('Инвайт запущен: ' + d.targets + ' целей, ' + d.accs + ' акков', 'success');
            document.getElementById('invite-progress').style.display = '';
            document.getElementById('btn-invite-start').style.display = 'none';
            document.getElementById('btn-invite-stop').style.display = '';
            startInvitePolling();
        } else {
            showToast(d.error || 'Ошибка запуска', 'error');
        }
    }).catch(function(e) { showToast((e && e.message) || 'Ошибка', 'error'); });
}

function stopInvite() {
    API.post('/api/invite/stop', {}).then(function(d) {
        if (d.ok) showToast('Стоп: обработано ' + (d.processed || 0), 'info');
        refreshInviteStatus();
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function startInvitePolling() {
    if (_invitePollTimer) clearInterval(_invitePollTimer);
    _invitePollTimer = setInterval(function() {
        if (_currentTab !== 'invite') {
            clearInterval(_invitePollTimer); _invitePollTimer = null;
            return;
        }
        refreshInviteStatus();
    }, 3000);
    refreshInviteStatus();
}

function refreshInviteStatus() {
    API.get('/api/invite/status').then(function(d) {
        var lbl = document.getElementById('invite-status-label');
        var bar = document.getElementById('invite-bar');
        var counts = document.getElementById('invite-counts');
        var elapsed = document.getElementById('invite-elapsed');
        var log = document.getElementById('invite-log');
        var prog = document.getElementById('invite-progress');
        if (!lbl) return;
        var statusMap = {idle: 'Не запущен', running: 'Идёт', stopping: 'Останавливается…', stopped: 'Остановлено', done: 'Готово'};
        lbl.textContent = (statusMap[d.status] || d.status) + ' • ' + (d.processed || 0) + '/' + (d.total || 0) + (d.chat ? ' → ' + d.chat : '');
        if (d.total > 0) bar.style.width = Math.round(d.processed * 100 / d.total) + '%';
        if (bar) {
            if (d.status === 'running' || d.status === 'stopping') bar.classList.add('is-running');
            else bar.classList.remove('is-running');
        }
        if (counts && d.counts) {
            var c = d.counts;
            counts.innerHTML = [
                '<span style="color:var(--success);"><i class="bi bi-check-circle-fill"></i> ' + (c.invited||0) + ' приглашено</span>',
                '<span style="color:var(--text-muted-strong);">↩ ' + (c.already||0) + ' уже там</span>',
                '<span style="color:var(--warning);"><i class="bi bi-lock-fill"></i> ' + (c.privacy||0) + ' приватность</span>',
                '<span style="color:var(--danger);" title="вечный спам-блок"><i class="bi bi-fire"></i> ' + (c.peer_flood||0) + ' вечный спам</span>',
                '<span style="color:var(--warning);" title="временный флуд-блок"><i class="bi bi-hourglass-split"></i> ' + (c.flood_wait||0) + ' флуд</span>',
                '<span style="color:var(--text-muted-strong);"><i class="bi bi-x-circle"></i> ' + (c.failed||0) + ' ошибок</span>',
                '<span style="color:var(--text-muted-strong);"><i class="bi bi-question-circle"></i> ' + (c.not_found||0) + ' не найдено</span>',
                // Эти две причины Telegram отдаёт чаще всего, а в ряду их не было:
                // список, где все цели такие, показывал одни нули при полном прогрессе.
                '<span style="color:var(--text-muted-strong);" title="Telegram требует, чтобы человек был во взаимных контактах"><i class="bi bi-people"></i> ' + (c.not_mutual||0) + ' нужны взаимные</span>',
                '<span style="color:var(--text-muted-strong);" title="у человека предел по числу чатов"><i class="bi bi-slash-circle"></i> ' + (c.too_many_channels||0) + ' предел по чатам</span>',
            ].join('');
        }
        if (elapsed) elapsed.textContent = d.elapsed ? Math.floor(d.elapsed/60) + 'м ' + (d.elapsed%60) + 'с' : '—';
        if (log && d.log && d.log.length) {
            log.innerHTML = d.log.map(function(l) { return escHtml(l); }).join('<br>');
            log.scrollTop = log.scrollHeight;
        }
        if (d.status === 'idle') {
            if (prog) prog.style.display = 'none';
        } else {
            if (prog) prog.style.display = '';
        }
        var bs = document.getElementById('btn-invite-start');
        var bp = document.getElementById('btn-invite-stop');
        var be = document.getElementById('btn-invite-export');
        if (d.status === 'running' || d.status === 'stopping') {
            // stopping: движок ещё отпускает акки — «Старт» держим скрытым, чтобы
            // клиент не запустил вторую задачу на тех же аккаунтах.
            if (bs) bs.style.display = 'none';
            if (bp) { bp.style.display = ''; bp.disabled = (d.status === 'stopping'); }
            if (be) be.style.display = 'none';
        } else {
            if (bs) bs.style.display = '';
            if (bp) bp.style.display = 'none';
            if (be) be.style.display = (d.processed > 0 ? '' : 'none');
        }
    }).catch(function() {});
}

// === Invite User Database (отдельно от monitor contacted) ===
function loadInviteUserdb() {
    var q = (document.getElementById('inv-userdb-search') || {}).value || '';
    var list = document.getElementById('inv-userdb-list');
    if (!list) return;
    list.textContent = 'Загрузка…';
    API.get('/api/invite/userdb_list?q=' + encodeURIComponent(q)).then(function(d) {
        if (!d.ok) { list.innerHTML = '<div style="color:#f87171;">' + escHtml(d.error||'Ошибка') + '</div>'; return; }
        updateInviteUserdbCount(d.total);
        if (!d.items || !d.items.length) {
            list.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;">База пуста</div>';
            return;
        }
        list.innerHTML = d.items.map(function(it) {
            var tags = (it.tags || []).map(function(t) {
                var color = t === 'spamblock' ? '#f87171' : t === 'invited' ? '#34a853' : 'var(--accent-light)';
                return '<span style="background:' + color + '22;color:' + color + ';border-radius:4px;padding:0 6px;font-size:0.65rem;margin-left:4px;">' + escHtml(t) + '</span>';
            }).join('');
            return '<div style="display:flex;gap:8px;padding:4px 6px;border-bottom:1px solid #1a1a2a;">' +
                '<span style="flex:1;">' + escHtml(it.username ? '@' + it.username : String(it.id)) + tags + '</span>' +
                '<button onclick="_invUserdbRemove(' + it.id + ')" class="btn btn-outline btn-sm" style="padding:0 6px;font-size:0.65rem;color:var(--danger);">×</button>' +
            '</div>';
        }).join('');
    }).catch(function() { list.textContent = 'Ошибка'; });
}
function updateInviteUserdbCount(total) {
    var el = document.getElementById('inv-userdb-cnt');
    if (!el) return;
    if (typeof total === 'number') { el.textContent = total; return; }
    API.get('/api/invite/userdb_count').then(function(d) { if (d.ok) el.textContent = d.count; }).catch(function() {});
}
function _invUserdbRemove(id) {
    API.post('/api/invite/userdb_remove', {ids: [id]}).then(function(d) {
        if (d.ok) { showToast('Удалён ' + id, 'success'); loadInviteUserdb(); }
        else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() {});
}
function loadInviteFromUserdb() {
    API.get('/api/invite/userdb_list?limit=10000').then(function(d) {
        if (!d.ok || !d.items || !d.items.length) { showToast('База пуста', 'warning'); return; }
        var ta = document.getElementById('invite-targets');
        if (!ta) return;
        var lines = d.items.filter(function(it) {
            return !(it.tags || []).includes('invited') && !(it.tags || []).includes('spamblock');
        }).map(function(it) {
            return it.username ? '@' + it.username : String(it.id);
        });
        ta.value = lines.join('\n');
        updateInviteTargetsCount();
        showToast('Подставлено ' + lines.length + ' юзеров из базы', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}
function onInviteUserdbImport(e) {
    var f = e.target.files && e.target.files[0];
    if (!f) return;
    var reader = new FileReader();
    reader.onload = function(ev) {
        var text = (ev.target.result || '').toString();
        API.post('/api/invite/userdb_import', {text: text}).then(function(d) {
            if (d.ok) { showToast('Добавлено ' + d.added + ' (всего ' + d.total + ')', 'success'); loadInviteUserdb(); }
            else showToast(d.error || 'Ошибка', 'error');
        });
    };
    reader.readAsText(f, 'utf-8');
    e.target.value = '';
}
function exportInviteUserdb() {
    API.get('/api/invite/userdb_export').then(function(d) {
        if (d.ok) showToast('Сохранено: ' + d.path, 'success');
        else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() { showToast('Ошибка', 'error'); });
}
async function clearInviteUserdb() {
    if (!(await appConfirm('Очистить всю базу пользователей инвайтера?', {danger: true}))) return;
    API.post('/api/invite/userdb_clear', {}).then(function(d) {
        if (d.ok) { showToast('База очищена', 'success'); loadInviteUserdb(); }
    });
}
function loadInviteReports() {
    var box = document.getElementById('inv-reports-list');
    if (!box) return;
    API.get('/api/invite/reports').then(function(d) {
        if (!d.ok || !d.reports || !d.reports.length) {
            box.innerHTML = '<div style="color:var(--text-muted);text-align:center;padding:20px;">Запусков ещё не было</div>';
            return;
        }
        box.innerHTML = d.reports.map(function(r) {
            return '<div style="background:var(--surface-info-bg);border-radius:5px;padding:10px 14px;margin-bottom:8px;">' +
                '<div style="display:flex;justify-content:space-between;font-size:0.8rem;font-weight:500;margin-bottom:4px;">' +
                    '<span>' + escHtml(r.chat) + '</span>' +
                    '<span style="color:var(--text-muted);font-size:0.7rem;">' + escHtml(r.started_at) + '</span>' +
                '</div>' +
                '<div style="font-size:0.74rem;color:var(--text-muted);">' +
                    '<i class="bi bi-check-circle-fill" style="color:var(--success);"></i> ' + (r.invited||0) +
                    ' &nbsp;<i class="bi bi-lock-fill" style="color:#fbbf24;"></i> ' + (r.privacy||0) +
                    ' &nbsp;<i class="bi bi-fire" style="color:#f97316;"></i> ' + (r.peer_flood||0) +
                    ' &nbsp;<i class="bi bi-x-circle-fill" style="color:var(--danger);"></i> ' + (r.failed||0) +
                '</div>' +
            '</div>';
        }).join('');
    }).catch(function() {});
}

function exportInviteResults() {
    API.get('/api/invite/results').then(function(d) {
        if (!d.results || !Object.keys(d.results).length) {
            showToast('Нет результатов', 'info'); return;
        }
        // В файл — человеческий текст: клиент открывает его в блокноте, а не
        // разбирает коды движка. Коды остаются в d.results для подсчётов.
        var human = d.results_human || {};
        var lines = Object.keys(d.results).map(function(k) {
            return k + '\t' + (human[k] || d.results[k]);
        });
        var blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'});
        var url = URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = 'invite_results_' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
        a.click();
        URL.revokeObjectURL(url);
        showToast('Скачано', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function toggleSidebar() {
    var collapsed = document.body.classList.toggle('sidebar-collapsed');
    try { localStorage.setItem('sidebar_collapsed', collapsed ? '1' : '0'); } catch(e) {}
}
// Восстанавливаем состояние сайдбара после загрузки страницы
(function _restoreSidebar() {
    try {
        if (localStorage.getItem('sidebar_collapsed') === '1') {
            document.body.classList.add('sidebar-collapsed');
        }
    } catch(e) {}
})();

function deleteAccount(phone, e) {
    if (e) e.stopPropagation();
    showConfirmDialog(
        'Удалить аккаунт +' + phone + '?',
        'Файлы .session и .json будут стёрты с диска без возможности восстановления. Аккаунт можно будет добавить заново только с нуля (вход по коду).',
        function() {
            API.post('/api/accounts/delete', { phone: phone }).then(function(d) {
                if (d.ok) {
                    if (d.errors && d.errors.length) {
                        // Файлы не удалились (антивирус, Telegram-клиент, OS-lock).
                        // Без явного сообщения клиент удивляется почему акк
                        // вернулся после «Обновить» — scan_folder его подхватывает.
                        showToast('Аккаунт убран из списка, но файлы не удалось удалить (' + d.errors.length + ' шт). Перезапустите программу — после рестарта файлы освободятся.', 'warning');
                    } else if (d.skipped_busy && d.skipped_busy.length) {
                        // Сервер намеренно не трогает занятый акк — сообщаем, иначе
                        // юзер видит «ок», а акк на месте и думает что софт врёт.
                        showToast('Не удалён: аккаунт занят задачей (' +
                                  (d.skipped_busy[0].busy || 'в работе') + '). Останови её и повтори', 'warning');
                    } else {
                        showToast('Удалён +' + phone, 'success');
                    }
                    loadAccounts();
                } else {
                    showToast(d.error || 'Не удалось удалить', 'error');
                }
            }).catch(function() { showToast('Ошибка удаления', 'error'); });
        }
    );
}


var _tgOpenInFlight = {};  // debounce от множественных кликов
function viewAccount(phone, e) {
    if (e) e.stopPropagation();
    // debounce 3с — повторный клик в течение 3с игнорируется (двойной клик
    // открывал 2 окна).
    var _key = String(phone);
    if (_tgOpenInFlight[_key]) {
        return;
    }
    _tgOpenInFlight[_key] = true;
    setTimeout(function(){ delete _tgOpenInFlight[_key]; }, 3000);
    // Пускаем открывать даже frozen-акки. TG-веб откроется, GetDialogs
    // вернёт ошибку — это OK.
    showToast('Открываю Telegram для +' + phone + '…', 'info');
    // В браузере window.open ПОСЛЕ async-ответа блокируется попап-блокером
    // (не жест юзера). Поэтому в браузерном режиме открываем пустое окно
    // СИНХРОННО на клике (это валидный жест) и потом перенаправляем его на URL.
    // В pywebview-приложении окно создаёт сам сервер (webview.create_window) —
    // там пустышку не открываем, иначе будет лишнее окно.
    var _isPywebview = (typeof window.pywebview !== 'undefined');
    var _w = null;
    if (!_isPywebview) {
        try { _w = window.open('', 'tgk_' + phone, 'width=1200,height=820'); } catch (_) {}
    }
    API.post('/api/tg/open_window', {phone: phone}).then(function(d) {
        if (d.ok) {
            if (d.opened) {
                // Сервер открыл pywebview-окно — закрываем браузерную пустышку если была.
                if (_w) { try { _w.close(); } catch (_) {} }
            } else if (d.url) {
                if (_w) { _w.location.href = d.url; }
                else { window.open(d.url, 'tgk_' + phone, 'width=1200,height=820'); }
            }
        } else {
            if (_w) { try { _w.close(); } catch (_) {} }
            showToast(d.error || 'Не удалось открыть окно', 'error');
        }
    }).catch(function(e) {
        if (_w) { try { _w.close(); } catch (_) {} }
        // Показываем РЕАЛЬНУЮ причину: API.post кидает Error с .message = server
        // d.error (напр. «Аккаунт занят: Автоответчик…» на 409). Раньше глотали в
        // безполезное «Ошибка открытия» → юзер не понимал, почему TG-веб не идёт.
        var _m = (e && e.message) || 'Ошибка открытия';
        // 401-страж: идёт переход на страницу входа, тостом не пугаем.
        if (/страницу входа/i.test(_m)) return;
        showToast(_m, 'error');
    });
}

// Старый слайд-панель (мини-просмотр). Оставлен на случай если кто-то
// привык, открывается долгим нажатием кнопки или из консоли.
function viewAccountInline(phone, e) {
    if (e) e.stopPropagation();
    _tgPanelPhone = phone;
    API.post('/api/accounts/select', {phone: phone}).then(function(d) {
        if (d.ok) {
            if (!_tgPanelOpen) toggleTgPanel();
            else loadTgDialogs();
        }
    });
}

// QR-логин

// Авто-чистка ввода номера: убирает пробелы/тире/скобки/буквы, оставляет
// только цифры и одну ведущую «+».





// ── Массовое добавление по номеру (ручная очередь кодов) ─────────────









// ── Account Health (диагностика акка для TG-trust) ─────────────

function checkAccountHealth(phone, e) {
    if (e) e.stopPropagation();
    var loadingId = 'health-loading-' + phone.replace(/\D/g, '');
    _showHealthModal({
        phone: phone,
        loading: true,
        modalId: loadingId,
    });
    API.post('/api/accounts/health/' + phone).then(function(d) {
        if (d.ok) {
            _showHealthModal({ phone: phone, data: d });
        } else if (d.error) {
            _showHealthModal({ phone: phone, error: d.error, status: d.status });
        } else {
            _showHealthModal({ phone: phone, error: 'Неизвестная ошибка' });
        }
    }).catch(function(err) {
        // API.post бросает Error с .message и .data.error — показываем настоящую
        // причину (таймаут/HTTP/etc), а не generic «Ошибка сети».
        var msg = (err && (err.message || (err.data && err.data.error))) || 'Ошибка сети';
        var status = err && err.status ? err.status : null;
        _showHealthModal({ phone: phone, error: msg, status: status });
    });
}

function _showHealthModal(opts) {
    var old = document.getElementById('health-modal-overlay');
    if (old) old.remove();

    var overlay = document.createElement('div');
    overlay.id = 'health-modal-overlay';
    overlay.className = 'acc-popup-overlay';
    overlay.onclick = function(ev) { if (ev.target === overlay) overlay.remove(); };

    var popup = document.createElement('div');
    popup.className = 'acc-popup';
    popup.style.maxWidth = '520px';
    popup.style.width = '92%';

    var body;
    if (opts.loading) {
        body =
            '<h3><i class="bi bi-heart-pulse" style="color:var(--accent-light);margin-right:6px;"></i>Диагностика +' + opts.phone + '</h3>' +
            '<div style="padding:30px;text-align:center;color:var(--text-muted);font-size:0.85rem;">' +
                '<i class="bi bi-arrow-clockwise" style="display:inline-block;animation:spin 1s linear infinite;font-size:1.4rem;margin-bottom:10px;"></i><br>' +
                'Собираю данные с Telegram…<br>' +
                '<span style="font-size:0.7rem;">~10-20 секунд (контакты, диалоги, профиль)</span>' +
            '</div>';
    } else if (opts.error) {
        // HTTP-статус (500/504/etc) не показываем — текст ошибки уже описывает причину.
        body =
            '<h3><i class="bi bi-exclamation-triangle" style="color:var(--danger);margin-right:6px;"></i>Не удалось проверить</h3>' +
            '<div style="padding:14px;background:rgba(201,56,56,0.08);border:1px solid rgba(201,56,56,0.3);border-radius:5px;color:var(--text-primary);font-size:0.82rem;">' +
                escHtml(opts.error) +
            '</div>' +
            '<div style="margin-top:10px;font-size:0.75rem;color:var(--text-muted);">' +
                'Подробности в окне логов (нижний левый угол → «Открыть логи»).' +
            '</div>';
    } else {
        var d = opts.data;
        var det = d.details || {};
        var score = d.score || 0;
        var scoreColor = score >= 70 ? 'var(--success)' : (score >= 45 ? 'var(--warning)' : 'var(--danger)');
        var scoreBg = score >= 70 ? 'rgba(52,168,83,0.12)' : (score >= 45 ? 'rgba(212,147,13,0.12)' : 'rgba(201,56,56,0.12)');

        var detailsHtml =
            '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:0.74rem;margin:14px 0;">' +
                _healthRow('Возраст', (det.age_days || 0) + ' дн.', (det.age_days || 0) >= 30) +
                _healthRow('Username', det.username ? '@' + det.username : '<нет>', !!det.username) +
                _healthRow('Имя', det.first_name || '<нет>', !!det.first_name) +
                _healthRow('Аватарка', det.has_photo ? 'есть' : '<нет>', det.has_photo) +
                _healthRow('Bio', det.bio ? ('есть (' + det.bio.length + ' симв)') : '<нет>', !!det.bio) +
                _healthRow('Контакты', String(det.contacts_count || 0), (det.contacts_count || 0) > 0) +
                _healthRow('Диалоги', String(det.dialogs_count || 0) + '+', (det.dialogs_count || 0) >= 3) +
                _healthRow('2FA', det.has_2fa ? 'включён' : 'нет', det.has_2fa) +
                _healthRow('Premium', det.is_premium ? 'да' : 'нет', det.is_premium, true) +
                _healthRow('Spam-блок', det.spamblock || 'free', !det.spamblock || det.spamblock === 'free') +
            '</div>';

        var bioHtml = '';
        if (det.bio) {
            bioHtml = '<div style="margin-top:10px;padding:8px 10px;background:var(--bg-card-hover);border:1px solid var(--border);border-radius:4px;font-size:0.72rem;color:var(--text-muted);"><b style="color:var(--text-primary);">Bio:</b> ' + escHtml(det.bio) + '</div>';
        }

        var reasonsOkHtml = '';
        if (d.reasons_ok && d.reasons_ok.length) {
            reasonsOkHtml = '<div style="margin-top:10px;"><b style="color:var(--success);font-size:0.72rem;">Плюсы (+):</b><br>' +
                '<span style="font-size:0.7rem;color:var(--text-muted);line-height:1.55;">' +
                d.reasons_ok.map(escHtml).join(' · ') + '</span></div>';
        }
        var reasonsBadHtml = '';
        if (d.reasons_bad && d.reasons_bad.length) {
            reasonsBadHtml = '<div style="margin-top:10px;"><b style="color:var(--danger);font-size:0.72rem;">Слабые места:</b><br>' +
                '<ul style="margin:4px 0 0 18px;padding:0;font-size:0.7rem;color:var(--text-muted);line-height:1.55;">' +
                d.reasons_bad.map(function(r) { return '<li>' + escHtml(r) + '</li>'; }).join('') + '</ul></div>';
        }

        body =
            '<h3><i class="bi bi-heart-pulse" style="color:var(--accent-light);margin-right:6px;"></i>Диагностика +' + opts.phone + '</h3>' +
            '<div style="padding:14px;background:' + scoreBg + ';border:1px solid ' + scoreColor + ';border-radius:5px;text-align:center;margin-bottom:6px;">' +
                '<div style="font-size:2.2rem;font-weight:800;color:' + scoreColor + ';line-height:1;">' + score + '<span style="font-size:0.9rem;color:var(--text-muted);">/100</span></div>' +
                '<div style="font-size:0.78rem;color:var(--text-primary);margin-top:6px;">' + escHtml(d.verdict || '') + '</div>' +
            '</div>' +
            detailsHtml +
            bioHtml +
            reasonsOkHtml +
            reasonsBadHtml +
            '<div style="margin-top:14px;padding:10px;background:rgba(167,139,212,0.06);border-radius:4px;font-size:0.7rem;color:var(--text-muted);line-height:1.55;">' +
                '<b>Что значит score:</b><br>' +
                '<b style="color:var(--success);">70+</b> — Telegram доверяет, ResolveUsername работает нормально.<br>' +
                '<b style="color:var(--warning);">45-69</b> — средний trust, часть публичных каналов может не резолвиться.<br>' +
                '<b style="color:var(--danger);">&lt;45</b> — низкий trust. TG режет API публичных каналов. Прогрейте акк, добавьте аватарку/bio/username, дайте полежать неделю.' +
            '</div>';
    }

    popup.innerHTML =
        '<button class="acc-popup-close" onclick="document.getElementById(\'health-modal-overlay\').remove()"><i class="bi bi-x-lg"></i></button>' +
        body;
    overlay.appendChild(popup);
    document.body.appendChild(overlay);
}

function _healthRow(label, val, isOk, neutral) {
    var color = neutral ? 'var(--text-muted)' : (isOk ? 'var(--success)' : 'var(--danger)');
    var icon = neutral ? '' : (isOk ? '<i class="bi bi-check-circle-fill" style="color:var(--success);margin-right:4px;font-size:0.78rem;"></i>'
                                    : '<i class="bi bi-x-circle-fill" style="color:var(--danger);margin-right:4px;font-size:0.78rem;"></i>');
    return '<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 9px;background:var(--bg-card-hover);border-radius:4px;">' +
        '<span style="color:var(--text-muted);">' + escHtml(label) + '</span>' +
        '<span style="color:' + color + ';font-weight:600;">' + icon + escHtml(val) + '</span>' +
    '</div>';
}

// ── Spam Check ─────────────────────────────────────────────────

function checkSpam(phone, e) {
    if (e) e.stopPropagation();
    showToast('Проверяю ' + phone + '...', 'info');
    API.post('/api/accounts/check_spam/' + phone).then(function(d) {
        if (d.ok) {
            // status может быть: 'clean' / 'spam' / 'frozen' / 'dead'.
            // Раньше выводили только blocked/clean, замороженные показывались
            // как «без ограничений» — это и был тот баг с заморозкой.
            if (d.status === 'frozen') {
                showToast('+' + phone + ': Заморозка Telegram (' + (d.note || '') + ')', 'warning');
            } else if (d.status === 'dead') {
                showToast('+' + phone + ': Аккаунт мёртвый (' + (d.note || '') + ')', 'error');
            } else if (d.status === 'spam' || d.blocked) {
                showToast('+' + phone + ': Спамблок (' + (d.scope || '') + ')', 'warning');
            } else {
                showToast('+' + phone + ': Без ограничений', 'success');
            }
            loadAccounts();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) { showToast('Ошибка: ' + (e.error || e), 'error'); });
}

var _spamCheckTimer = null;

function checkAllSpam() {
    var btn = document.getElementById('btn-check-all-spam');
    // Если уже запущена — превращаем кнопку в стоп
    if (btn && btn.dataset.running === '1') {
        appConfirm('Остановить проверку аккаунтов на спам-блок?', {
            title: 'Остановить проверку',
            icon: 'bi-stop-circle',
            okLabel: 'Остановить',
        }).then(function(yes) {
            if (!yes) return;
            API.post('/api/accounts/check_spam_stop', {}).then(function(d) {
                if (d.ok) showToast('Останавливаю...', 'info');
                else showToast(d.error || 'Не удалось остановить', 'error');
            });
        });
        return;
    }
    // Если есть отмеченные галками — проверяем только их.
    var selected = _getCheckedAccPhones();
    // передаём настройку потоков (по умолчанию 5; 0 = авто по размеру пула прокси).
    var _threads = parseInt((document.getElementById('acc-spam-threads') || {}).value);
    if (isNaN(_threads) || _threads < 0) _threads = 5;
    var payload = selected.length ? { phones: selected, max_parallel: _threads }
                                  : { max_parallel: _threads };
    var label = selected.length ? ('выделенных (' + selected.length + ')') : 'всех аккаунтов';
    showToast('Запускаю проверку ' + label + '...', 'info');
    API.post('/api/accounts/check_spam_all', payload).then(function(d) {
        if (d.ok) {
            showToast('Проверка запущена: ' + d.total + ' акков', 'success');
            if (btn) btn.dataset.running = '1';
            _spamLiveRefreshChecked = -1;  // сброс live-обновления на новый запуск
            // Поллим раз в 500мс — кнопка ревертится почти мгновенно после
            // завершения backend'ом. ОБЯЗАТЕЛЬНО гасим предыдущий интервал: без
            // этого он «утекает» (переменная хранит только новый id), clearInterval
            // в ветке done убивает лишь последний, а утёкшие тикают вечно и каждые
            // 500мс печатают «Проверка завершена» + дёргают loadAccounts().
            if (_spamCheckTimer) { clearInterval(_spamCheckTimer); _spamCheckTimer = null; }
            _spamCheckTimer = setInterval(pollSpamCheck, 500);
        }
    }).catch(function(e) { showToast('Ошибка: ' + (e.error || e), 'error'); });
}

// восстанавливаем настройку потоков из localStorage
(function _restoreSpamThreads(){
    try {
        var v = localStorage.getItem('tp_spam_threads');
        if (v !== null) {
            var inp = document.getElementById('acc-spam-threads');
            if (inp) inp.value = v;
            // Если элемент ещё не в DOM — повторим через 500мс
            else setTimeout(_restoreSpamThreads, 500);
        }
    } catch(e) {}
})();

// watchdog — если backend застрял на одном checked >5 мин, форсуем ревертнуть
// кнопку. Защита от silent-fail в _check_all.
var _spamPollWatch = {checked: -1, since: 0};
// Кол-во акков, отрисованных в таблице во время текущей проверки — чтобы
// обновлять статусы по мере готовности, а не одним пакетом в конце.
var _spamLiveRefreshChecked = -1;

function pollSpamCheck() {
    API.get('/api/accounts/check_spam_status').then(function(d) {
        var btn = document.getElementById('btn-check-all-spam');
        if (d.status === 'running') {
            if (btn) btn.innerHTML = '<i class="bi bi-stop-fill"></i> ' + d.checked + '/' + d.total + ' — Стоп';
            // Как только очередной акк отчитался — обновляем таблицу (бэкенд уже
            // проставил acc.spam_blocked/spam_checked_at per-account). Триггер по
            // росту d.checked = ровно раз на акк.
            if (d.checked !== _spamLiveRefreshChecked && d.checked > 0) {
                _spamLiveRefreshChecked = d.checked;
                // ТРОТТЛ: на 600 акках loadAccounts() (fetch всех + полный ререндер
                // таблицы) на КАЖДЫЙ отчёт акка вешал UI намертво (~600 ререндеров за
                // проверку). Живой рефреш — не чаще раза в 2.5с; счётчик в кнопке
                // (checked/total) обновляется каждые 500мс дёшево, а финальный полный
                // рефреш всё равно будет на 'done' (_autoSortAfterCheck → loadAccounts).
                var _rnow = Date.now();
                if (_rnow - (window._spamLiveRefreshAt || 0) > 2500) {
                    window._spamLiveRefreshAt = _rnow;
                    loadAccounts();
                }
            }
            var now = Date.now();
            // Если все checked но статус ещё running — даём backend'у 3с на
            // завершение, потом форсим клиент-сайд done (защита от залипания
            // когда worker'ы кончили но gather ждёт sync_accounts_folder).
            if (d.checked >= d.total && d.total > 0) {
                if (!_spamPollWatch.allDoneSince) {
                    _spamPollWatch.allDoneSince = now;
                } else if (now - _spamPollWatch.allDoneSince > 3000) {
                    clearInterval(_spamCheckTimer);
                    if (btn) {
                        btn.innerHTML = '<i class="bi bi-shield-check"></i> Проверить все на спам';
                        btn.dataset.running = '';
                    }
                    showToast('Проверка завершена: ' + d.checked + '/' + d.total, 'success');
                    _autoSortAfterCheck(d.results || {});
                    _spamPollWatch = {checked: -1, since: 0};
                    return;
                }
            } else {
                _spamPollWatch.allDoneSince = 0;
            }
            if (_spamPollWatch.checked !== d.checked) {
                _spamPollWatch = {checked: d.checked, since: now};
            } else if (now - _spamPollWatch.since > 300000) {
                clearInterval(_spamCheckTimer);
                if (btn) {
                    btn.innerHTML = '<i class="bi bi-shield-check"></i> Проверить все на спам';
                    btn.dataset.running = '';
                }
                showToast('Проверка зависла на ' + d.checked + '/' + d.total + ' — кнопка возвращена', 'warning');
                _spamPollWatch = {checked: -1, since: 0};
            }
        } else if (d.status === 'done' || d.status === 'stopped') {
            // Гард однократности: сюда попадают и ответы «в полёте», и тики утёкших
            // интервалов — без него итог печатался десятки раз подряд.
            if (!_spamCheckTimer) return;
            _spamPollWatch = {checked: -1, since: 0};
            clearInterval(_spamCheckTimer);
            _spamCheckTimer = null;
            if (btn) {
                btn.innerHTML = '<i class="bi bi-shield-check"></i> Проверить все на спам';
                btn.dataset.running = '';
            }
            // Считаем ВСЕ категории чтобы пользователь видел куда делись акки.
            // Раньше показывали только clean+spam — из 29 аккаунтов могло быть
            // «4 без ограничений, 0 в спаме», а 25 замороженных/мёртвых
            // выглядели как «потерянные».
            var clean = 0, spam = 0, frozen = 0, dead = 0, offline = 0, timeout = 0, other = 0, cancelled = 0;
            for (var k in d.results) {
                var v = String(d.results[k] || '');
                if (v === 'clean') clean++;
                else if (v === 'spam') spam++;
                else if (v === 'frozen') frozen++;
                else if (v.indexOf('dead') === 0) dead++;
                else if (v === 'offline') offline++;
                else if (v === 'timeout') timeout++;
                else if (v === 'cancelled') cancelled++;
                else other++;
            }
            var parts = [];
            if (clean) parts.push(clean + ' чистых');
            if (spam) parts.push(spam + ' в спаме');
            if (frozen) parts.push(frozen + ' заморожено');
            if (dead) parts.push(dead + ' мёртвых');
            if (offline) parts.push(offline + ' оффлайн');
            // «без ответа», а не «таймаут»: слово из сводки попадает в окно
            // логов, где строки со сбоями красные, и удачный прогон с парой
            // недоотвеченных аккаунтов светился тревожным цветом целиком.
            if (timeout) parts.push(timeout + ' без ответа');
            if (other) parts.push(other + ' прочее');
            var summary = parts.length ? parts.join(', ') : 'нет результатов';
            var lvl = (frozen || dead || spam) ? 'warning' : 'success';
            if (d.status === 'stopped') {
                showToast('Остановлено: ' + summary + ' (всего ' + d.total + ')', 'info');
            } else {
                showToast('Проверка завершена: ' + summary, lvl);
            }
            _autoSortAfterCheck(d.results || {});
        }
    });
}

// Авто-раскладка по папкам ПОСЛЕ ручной проверки (юзер: «должны падать в папки»):
// спам→«В спам» (вечный→«Вечный спам»), заморозка→«Заморозка», мёртвый→«Мёртвые»,
// чистый→«В работе» ТОЛЬКО если возвращается из проблемной папки (Прогрев/Ферма/
// Мониторинг/Архив НЕ трогаем — там акки лежат намеренно). offline/timeout/skipped/
// error/cancelled — не двигаем (статус не определён). Реюз /move_to_folder
// (он сам делает disconnect+стоп-монитор+move файлов). Заканчивается loadAccounts().
var _PROBLEM_FOLDERS = ['В спам', 'Вечный спам', 'Spam', 'Permanent_spam',
                        'Заморозка', 'Frozen', 'Мёртвые', 'Dead'];
function _autoSortAfterCheck(results) {
    var info = {};
    (_allAccounts || []).forEach(function(a) {
        var ph = String(a.phone || '').replace(/\D/g, '');
        if (ph) info[ph] = { folder: (a.folder || a.folder_name || '').trim(), perma: !!a._isPerma };
    });
    var groups = {};
    function add(folder, ph) { (groups[folder] = groups[folder] || []).push(ph); }
    for (var k in results) {
        var ph = String(k).replace(/\D/g, '');
        if (!ph) continue;
        var v = String(results[k] || '');
        var cur = (info[ph] && info[ph].folder) || '';
        if (v.indexOf('dead') === 0) {
            if (cur !== 'Мёртвые' && cur !== 'Dead') add('Мёртвые', ph);
        } else if (v === 'frozen') {
            if (cur !== 'Заморозка' && cur !== 'Frozen') add('Заморозка', ph);
        } else if (v === 'spam') {
            var tgt = (info[ph] && info[ph].perma) ? 'Вечный спам' : 'В спам';
            if (cur !== tgt) add(tgt, ph);
        } else if (v === 'clean') {
            if (_PROBLEM_FOLDERS.indexOf(cur) !== -1) add('В работе', ph);
        }
    }
    var targets = Object.keys(groups);
    if (!targets.length) { loadAccounts(); return; }
    var i = 0, movedTotal = 0;
    (function next() {
        if (i >= targets.length) {
            if (movedTotal) showToast('Разложено по папкам: ' + movedTotal + ' акк.', 'success');
            loadAccounts();
            return;
        }
        var folder = targets[i++];
        API.post('/api/accounts/move_to_folder', { phones: groups[folder], folder: folder })
            .then(function(r) { if (r && r.moved) movedTotal += r.moved; })
            .catch(function() {})
            .then(next);
    })();
}

// ── Session upload (mass) ───────────────────────────────────────

function handleSessionDrop(e) {
    e.preventDefault();
    e.currentTarget.classList.remove('dragover');
    var files = e.dataTransfer.files;
    if (!files || files.length === 0) return;
    // если бросили один .zip — bulk-импорт через распаковку на бэке.
    if (files.length === 1 && /\.zip$/i.test(files[0].name)) {
        uploadAccountsZip(files[0]);
        return;
    }
    uploadSessions(files);
}

function uploadAccountsZip(zipFile) {
    if (!zipFile) return;
    var fd = new FormData();
    fd.append('file', zipFile);
    document.getElementById('upload-progress').style.display = 'block';
    document.getElementById('upload-status').textContent = 'Распаковываю ZIP ' + zipFile.name + '...';
    document.getElementById('upload-bar').style.width = '50%';
    fetch('/api/accounts/upload_zip', {method: 'POST', body: fd})
        .then(function(r){ return r.json(); })
        .then(function(d){
            document.getElementById('upload-bar').style.width = '100%';
            if (d.ok) {
                var imported = (d.imported || []).length;
                var skipped = (d.skipped || []).length;
                document.getElementById('upload-status').textContent =
                    'ZIP: импортировано ' + imported + ', пропущено ' + skipped;
                showToast('ZIP: импортировано ' + imported + ' аккаунтов' + (skipped ? ', пропущено ' + skipped : ''),
                    imported > 0 ? 'success' : 'warning');
                if (skipped > 0 && typeof addLog === 'function') {
                    (d.skipped || []).forEach(function(s){
                        addLog('ZIP skip: ' + (s.name || s.phone || '?') + ' — ' + (s.reason || '?'), 'WARN');
                    });
                }
                refreshStatus();
                loadAccounts();
            } else {
                showToast('Ошибка ZIP: ' + (d.error || '?'), 'error');
                document.getElementById('upload-status').textContent = 'Ошибка: ' + (d.error || '?');
            }
        })
        .catch(function(e){
            showToast('Сбой загрузки ZIP', 'error');
            document.getElementById('upload-status').textContent = 'Сбой: ' + e;
        });
}

function uploadSessions(files) {
    if (!files || files.length === 0) return;

    var pairs = {};
    for (var i = 0; i < files.length; i++) {
        var name = files[i].name;
        var base = name.replace(/\.(session|json)$/i, '');
        if (!pairs[base]) pairs[base] = {};
        if (name.endsWith('.session')) pairs[base].session = files[i];
        else if (name.endsWith('.json')) pairs[base].json = files[i];
    }

    var pairList = Object.keys(pairs).filter(function(k) { return pairs[k].session && pairs[k].json; });
    var unpaired = Object.keys(pairs).filter(function(k) { return !pairs[k].session || !pairs[k].json; });

    if (unpaired.length > 0) {
        showToast('Нет пары для: ' + unpaired.join(', ') + '. Нужен .session + .json с одинаковым именем', 'error');
    }

    if (pairList.length === 0) {
        showToast('Нет ни одной пары .session + .json', 'error');
        return;
    }

    var total = pairList.length;
    var done = 0, ok = 0, fail = 0;

    document.getElementById('upload-progress').style.display = 'block';
    document.getElementById('upload-status').textContent = 'Загрузка 0/' + total + ' аккаунтов...';
    document.getElementById('upload-bar').style.width = '0%';

    function uploadPair(idx) {
        if (idx >= pairList.length) {
            document.getElementById('upload-status').textContent = 'Готово: ' + ok + ' загружено, ' + fail + ' ошибок';
            showToast('Загружено ' + ok + '/' + total + ' аккаунтов', ok > 0 ? 'success' : 'error');
            refreshStatus();
            loadAccounts();
            return;
        }

        var base = pairList[idx];
        var pair = pairs[base];
        var fd = new FormData();
        fd.append('file', pair.session);
        fd.append('file', pair.json);

        fetch('/api/accounts/upload_session', {method: 'POST', body: fd})
        .then(function(r) { return r.json(); })
        .then(function(d) {
            if (d.ok) { ok++; } else { fail++; showToast('Ошибка ' + base + ': ' + (d.error || '?'), 'warning'); }
        })
        .catch(function() { fail++; })
        .finally(function() {
            done++;
            var pct = Math.round(done / total * 100);
            document.getElementById('upload-bar').style.width = pct + '%';
            document.getElementById('upload-status').textContent = 'Загрузка ' + done + '/' + total + '...';
            uploadPair(idx + 1);
        });
    }
    uploadPair(0);
}

var _syncInFlight = false;
var _lastSyncTs = 0;
function syncAndReload() {
    // debounce: одно in-flight + 1 секунда между запусками (защита от
    // долбёжки кнопки).
    var now = Date.now();
    if (_syncInFlight) return;
    if (now - _lastSyncTs < 1000) return;
    _syncInFlight = true;
    _lastSyncTs = now;
    var _step = 'sync';  // какой endpoint выполнялся при catch
    API.post('/api/accounts/sync').then(function(d) {
        if (d && d.removed > 0) {
            showToast('Удалено ' + d.removed + ' аккаунтов (файлы не найдены)', 'info');
        }
        _step = 'scan_folder';
        return API.post('/api/accounts/scan_folder');
    }).then(function(d) {
        if (d && d.ok) {
            if (d.added > 0) showToast(d.msg, 'success');
            else showToast('Синхронизировано', 'success');
        }
        loadAccounts();
    }).catch(function(e) {
        var status = (e && (e.status || e.statusCode)) || 0;
        // 401 = сессия лицензии expired → редирект на /login, без error-тоста.
        if (status === 401) {
            try { _maybeRedirect401(401); } catch (_) {}
            return;
        }
        // показываем реальную причину, а не generic «Ошибка синхронизации».
        var detail = '';
        if (e) {
            if (e.error) detail = String(e.error);
            else if (e.message) detail = String(e.message);
            else if (typeof e === 'string') detail = e;
            else { try { detail = JSON.stringify(e).slice(0, 200); } catch (_) {} }
        }
        var msg = 'Ошибка синхронизации (' + _step + ')';
        if (status) msg += ' HTTP ' + status;
        if (detail) msg += ': ' + detail;
        showToast(msg, 'error');
        try { console.error('[syncAndReload]', _step, e); } catch (_) {}
    }).finally(function() {
        _syncInFlight = false;
    });
}

// Дубль `openAccountsFolder` удалён (function hoisting делал эту версию мёртвой).
// Чтобы открыть папку текущего фильтра — используй openCurrentFilterFolder().
function openCurrentFilterFolder() {
    var folderName = '';
    if (_accFilter && _accFilter !== 'all') {
        var f = _dynamicFilters.find(function(x) { return x.id === _accFilter; });
        if (f && f.folder) folderName = f.folder;
    }
    API.post('/api/accounts/open_folder', { folder: folderName }).then(function(d) {
        if (d && d.ok) showToast('Папка открыта: ' + (d.path || ''), 'info');
        else showToast((d && d.error) || 'Не удалось открыть', 'error');
    }).catch(function() {});
}

// Модалка «Откуда грузятся акки?»
function openAccountsSourcesModal() {
    var existing = document.getElementById('sources-modal');
    if (existing) existing.remove();
    var box = document.createElement('div');
    box.id = 'sources-modal';
    box.style.cssText = 'position:fixed;inset:0;background:rgba(7,8,14,.62);z-index:9999;display:flex;align-items:center;justify-content:center;';
    box.innerHTML =
        '<div style="background:var(--bg-card,#1a1b27);border:1px solid var(--border,#2a2b3a);border-radius:7px;padding:0;width:min(720px,92vw);max-height:85vh;overflow:hidden;color:var(--text);font-family:inherit;display:flex;flex-direction:column;">' +
        '<div style="padding:18px 22px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--divider);">' +
            '<div style="font-size:15px;font-weight:600;"><i class="bi bi-folder2-open"></i> Откуда грузятся акки?</div>' +
            '<button onclick="document.getElementById(\'sources-modal\').remove()" style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:22px;padding:4px 8px;">×</button>' +
        '</div>' +
        '<div id="sources-body" style="padding:14px 22px;overflow-y:auto;flex:1;">' +
            '<div style="text-align:center;color:var(--text-muted);padding:40px;"><i class="bi bi-arrow-repeat spin" style="font-size:20px;"></i></div>' +
        '</div>' +
        '<div style="padding:10px 22px;border-top:1px solid var(--divider);font-size:11.5px;color:var(--text-muted);">Софт сканирует все подпапки <code>Аккаунты\\</code>. Если есть лишние — удали кнопкой «Удалить» справа.</div>' +
        '</div>';
    box.onclick = function(e) { if (e.target === box) box.remove(); };
    document.body.appendChild(box);
    loadAccountsSources();
}

function loadAccountsSources() {
    var body = document.getElementById('sources-body');
    if (!body) return;
    fetch('/api/accounts/sources').then(function(r) { return r.json(); }).then(function(d) {
        if (!d || !d.sources) { body.innerHTML = '<div style="color:#f87171;padding:20px;text-align:center;">Не удалось загрузить</div>'; return; }
        if (!d.sources.length) { body.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center;">Аккаунтов нет</div>'; return; }
        // Группируем по папке
        var byFolder = {};
        d.sources.forEach(function(s) {
            if (!byFolder[s.folder]) byFolder[s.folder] = [];
            byFolder[s.folder].push(s);
        });
        // показываем только короткое имя папки-родителя, не полный путь.
        var _rootLabel = '';
        try {
            var _parts = String(d.root || '').replace(/\\/g, '/').split('/').filter(Boolean);
            // Берём последнюю папку (например "Аккаунты") + предпоследнюю
            // (имя копии TelePush). Получится: «TelePush\Аккаунты».
            if (_parts.length >= 2) _rootLabel = _parts[_parts.length - 2] + '\\' + _parts[_parts.length - 1];
            else _rootLabel = _parts[_parts.length - 1] || d.root;
        } catch(_) { _rootLabel = 'Аккаунты'; }
        var html = '<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px;">Всего: <b>' + d.total + '</b> акков, копия: <code>' + escapeHtml(_rootLabel) + '</code></div>';
        Object.keys(byFolder).sort().forEach(function(folder) {
            var list = byFolder[folder];
            html += '<div style="margin-bottom:14px;">';
            html += '<div style="font-size:13px;font-weight:600;color:var(--accent-light,#6ab2f2);margin-bottom:6px;"><i class="bi bi-folder2-open"></i> ' + escapeHtml(folder) + ' (' + list.length + ')</div>';
            list.forEach(function(s) {
                var maskedPhone = s.phone.length > 5 ? s.phone.substr(0,3) + '***' + s.phone.substr(-2) : s.phone;
                html += '<div style="display:flex;align-items:center;gap:10px;padding:7px 10px;background:var(--bg-input,#1a2030);border-radius:5px;margin-bottom:4px;">' +
                    '<span style="flex:1;font-size:13px;font-family:monospace;">+' + escapeHtml(maskedPhone) + '</span>' +
                    '<span style="font-size:11px;color:var(--text-muted);">' + s.size_kb + ' KB' + (s.has_json ? ' · ✓ json' : ' · ⚠ нет json') + '</span>' +
                    '<button onclick="deleteAccountSourceFiles(\'' + s.path.replace(/\\/g, '\\\\').replace(/\'/g, "\\\'") + '\',\'' + escapeHtml(maskedPhone) + '\',this)" ' +
                        'style="background:transparent;border:1px solid #ef4444;color:#ef4444;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;font-family:inherit;" ' +
                        'data-tip="Удалить .session и .json файлы этого акка"><i class="bi bi-trash"></i> Удалить</button>' +
                '</div>';
            });
            html += '</div>';
        });
        body.innerHTML = html;
    }).catch(function(e) {
        body.innerHTML = '<div style="color:#f87171;padding:20px;text-align:center;">Ошибка: ' + e + '</div>';
    });
}

async function deleteAccountSourceFiles(path, phone, btn) {
    // appConfirm вместо native confirm — native confirm в pywebview без стиля.
    var ok = await appConfirm(
        'Удалить файлы акка ' + phone + '? Это действие нельзя отменить.',
        {
            title: 'Удаление акка',
            icon: 'bi-trash',
            okLabel: 'Удалить',
            danger: true,
        }
    );
    if (!ok) return;
    if (btn) { btn.disabled = true; btn.textContent = '...'; }
    fetch('/api/accounts/delete_files', {
        method: 'POST', headers: {'Content-Type':'application/json'},
        body: JSON.stringify({ path: path })
    }).then(function(r) { return r.json(); }).then(function(d) {
        if (d.ok) {
            // отчёт что удалили парой
            var _dlist = d.deleted || [];
            var _has_s = _dlist.some(function(n){return n.endsWith('.session');});
            var _has_j = _dlist.some(function(n){return n.endsWith('.json');});
            var _msg;
            if (_has_s && _has_j) _msg = 'Удалена пара .session + .json акка ' + phone;
            else if (_has_s) _msg = 'Удалён .session акка ' + phone + ' (.json не было)';
            else if (_has_j) _msg = 'Удалён .json акка ' + phone + ' (.session не было)';
            else _msg = 'Файлы не найдены';
            showToast(_msg, 'success');
            loadAccountsSources();  // перезагружаем список
            if (typeof loadAccountsList === 'function') loadAccountsList();  // обновляем таблицу
        } else {
            showToast(d.error || 'Не удалось удалить', 'error');
            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-trash"></i> Удалить'; }
        }
    }).catch(function() {
        showToast('Ошибка сети', 'error');
        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-trash"></i> Удалить'; }
    });
}

function scanFolder() {
    showToast('Сканирую аккаунты...', 'info');
    API.post('/api/accounts/sync').then(function() {
        return API.post('/api/accounts/scan_folder');
    }).then(function(d) {
        if (d.ok) {
            showToast(d.msg, d.added > 0 ? 'success' : 'info');
            if (d.errors && d.errors.length > 0) {
                d.errors.forEach(function(e) {
                    if (typeof addLog === 'function') addLog('Ошибка: ' + _errText(e), 'WARNING');
                });
            }
            loadAccounts();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function() { showToast('Ошибка сканирования', 'error'); });
}

// ══════════════════════════════════════════════════════════════════
//  PROXY — прокси
// ══════════════════════════════════════════════════════════════════

function loadProxies() {
    API.get('/api/proxy/list').then(function(d) {
        var el = document.getElementById('proxy-list');
        var proxies = d.proxies || [];
        // статистика для глобальной statusbar — кеш в window
        // Единый вердикт: если по прокси прогоняли MTProto-проверку — берём ЕЁ
        // (несёт / не несёт Telegram), иначе fallback на TCP-alive. Раньше бейдж и
        // счётчик читали ТОЛЬКО alive (TCP-проба к 1.1.1.1, Telegram не трогает) →
        // сводка «4 не несут» и одновременно 4 зелёных «Живой» в списке. Теперь
        // оба из одного источника.
        var _proxyEff = function(p) {
            if (p.mtproto_ok === true) return true;
            if (p.mtproto_ok === false) return false;
            return p.alive === true ? true : (p.alive === false ? false : null);
        };
        try {
            var _aliveCnt = proxies.filter(function(p) { return _proxyEff(p) === true; }).length;
            window._lastProxyStats = { alive: _aliveCnt, total: proxies.length };
        } catch (_) {}
        if (!el) return;
        if (proxies.length === 0) {
            el.innerHTML = '<div class="empty-state">' +
                '<i class="bi bi-shield-exclamation empty-state__icon" style="color:var(--warning);"></i>' +
                '<div class="empty-state__title">Прокси не настроены</div>' +
                '<div class="empty-state__text">Без прокси аккаунты ходят через ваш IP — Telegram может ассоциировать их между собой и заблокировать пачкой. Добавьте прокси чтобы развести аккаунты по разным IP.</div>' +
            '</div>';
            return;
        }
        var countLabel = document.getElementById('proxy-count-label');
        if (countLabel) {
            var alive = proxies.filter(function(p) { return _proxyEff(p) === true; }).length;
            countLabel.textContent = alive + '/' + proxies.length + ' живых';
        }
        el.innerHTML = proxies.map(function(p, i) {
            var eff = _proxyEff(p);
            var statusColor = eff === true ? 'var(--success)' : eff === false ? 'var(--danger)' : 'var(--text-muted)';
            var statusText = eff === true ? 'Живой' : eff === false ? 'Мёртвый' : 'Не проверен';
            var statusBadge;
            if (p.mtproto_ok === true) {
                statusBadge = '<span class="badge-soft badge-soft--success"><span class="status-dot status-dot--running" style="margin-right:0;"></span>Несёт Telegram</span>';
            } else if (p.mtproto_ok === false) {
                statusBadge = '<span class="badge-soft badge-soft--danger">Не несёт Telegram</span>';
            } else {
                statusBadge = p.alive === true
                    ? '<span class="badge-soft badge-soft--success"><span class="status-dot status-dot--running" style="margin-right:0;"></span>Живой</span>'
                    : p.alive === false
                    ? '<span class="badge-soft badge-soft--danger">Мёртвый</span>'
                    : '<span class="badge-soft badge-soft--muted">Не проверен</span>';
            }
            // Когда TCP жив, а Telegram не отвечает — поясняем (частый источник путаницы).
            var mtLine = (p.mtproto_ok === false && p.mtproto_detail)
                ? '<div style="font-size:0.65rem;color:var(--danger);margin-top:2px;opacity:0.85;">' + escHtml(p.mtproto_detail).substring(0, 120) + '</div>'
                : '';
            var errLine = '';  // p.error бэкендом не отдаётся — фантом убран
            // Гео/страна/exit-IP бэкендом НЕ отдаются → фантомный рендер убран
            // (geo-lookup exit-IP — отдельная фича, вернём при необходимости).
            var geoLine = '';
            // dropdown «тип прокси» (mobile/residential/DC) убран — протокол
            // авто-определяется, ручная классификация засоряла строку.
            return '<div class="hover-row" style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;">' +
                '<div style="flex:1;min-width:0;">' +
                    '<div style="font-size:0.8rem;font-family:monospace;overflow:hidden;text-overflow:ellipsis;">' +
                        escHtml(p.addr) +
                    '</div>' +
                    geoLine +
                    errLine +
                    mtLine +
                '</div>' +
                '<div style="display:flex;align-items:center;gap:6px;flex-shrink:0;">' +
                    statusBadge +
                    '<button onclick="recheckProxy(' + i + ')" class="btn btn-outline btn-sm btn-icon" title="Перепроверить"><i class="bi bi-arrow-clockwise"></i></button>' +
                    '<button onclick="removeProxy(' + i + ')" class="btn btn-outline btn-sm btn-icon" style="color:var(--danger);" title="Удалить"><i class="bi bi-trash"></i></button>' +
                '</div>' +
            '</div>';
        }).join('');
    });
}

// Статистика подключений к прокси: по каждому — статус, пинг и сколько акков на нём.
function loadProxyStats() {
    var el = document.getElementById('proxy-stats');
    if (!el) return;
    API.get('/api/proxy/stats').then(function(d) {
        var rows = (d && d.proxies) || [];
        if (!rows.length) {
            el.innerHTML = '<div style="color:var(--text-muted);font-size:0.75rem;padding:6px 2px;">Прокси не настроены.</div>';
            return;
        }
        var _eff = function(p) {
            if (p.mtproto_ok === true) return true;
            if (p.mtproto_ok === false) return false;
            return p.alive === true ? true : (p.alive === false ? false : null);
        };
        var head = '<tr style="text-align:left;color:var(--text-muted);font-size:0.68rem;">' +
            '<th style="padding:4px 8px;">Прокси</th>' +
            '<th style="padding:4px 8px;">Статус</th>' +
            '<th style="padding:4px 8px;">Пинг</th>' +
            '<th style="padding:4px 8px;text-align:center;">Аккаунтов</th></tr>';
        var body = rows.map(function(p) {
            var eff = _eff(p);
            var badge = eff === true
                ? '<span class="badge-soft badge-soft--success">Живой</span>'
                : eff === false
                ? '<span class="badge-soft badge-soft--danger">Мёртвый</span>'
                : '<span class="badge-soft badge-soft--muted">Не проверен</span>';
            var ping = (p.mtproto_ms != null) ? (p.mtproto_ms + ' мс')
                     : (p.latency_ms != null) ? (p.latency_ms + ' мс') : '—';
            return '<tr style="border-top:1px solid var(--border);">' +
                '<td style="padding:5px 8px;font-family:monospace;font-size:0.72rem;white-space:nowrap;">' + escHtml(p.addr) + '</td>' +
                '<td style="padding:5px 8px;">' + badge + '</td>' +
                '<td style="padding:5px 8px;font-size:0.72rem;color:var(--text-muted);white-space:nowrap;">' + ping + '</td>' +
                '<td style="padding:5px 8px;text-align:center;font-weight:600;font-size:0.75rem;' + (p.accounts ? '' : 'color:var(--text-muted);') + '">' + (p.accounts || 0) + '</td>' +
            '</tr>';
        }).join('');
        el.innerHTML = '<table style="width:100%;border-collapse:collapse;">' + head + body + '</table>';
    }).catch(function() {});
}

// Автосохранение прокси: поле само коммитит при потере фокуса (кнопка «Добавить»
// убрана). Без интрузивного confirm про мёртвые — проверка отдельной кнопкой.
var _proxyAutoSaving = false;
function autoSaveProxies() {
    var input = document.getElementById('proxy-input');
    if (!input) return;
    var text = input.value.trim();
    if (!text || _proxyAutoSaving) return;
    _proxyAutoSaving = true;
    var defaultType = (document.getElementById('proxy-type-default') || {}).value || 'auto';
    API.post('/api/proxy/add', {text: text, default_type: defaultType}).then(function(d) {
        _proxyAutoSaving = false;
        if (d && d.ok) {
            input.value = '';
            if (d.added > 0) { showToast('Добавлено прокси: ' + d.added, 'success'); loadProxies(); }
        } else {
            showToast('Не удалось добавить: ' + ((d && d.error) || 'ошибка'), 'error');
        }
    }).catch(function() { _proxyAutoSaving = false; showToast('Ошибка добавления прокси', 'error'); });
}

// Порядок раздачи прокси (random/sequential) — сохраняется сразу по выбору,
// т.к. селектор в отдельной вкладке «Прокси» (не завязан на кнопку «Сохранить»).
function saveProxyOrder() {
    var el = document.getElementById('proxy-order');
    if (!el) return;
    var val = (el.value === 'random') ? 'random' : 'sequential';
    API.post('/api/settings', {proxy_order: val}).then(function() {
        showToast(val === 'random' ? 'Порядок: случайный' : 'Порядок: по очереди', 'info');
    }).catch(function() {});
}

function saveProxyMaxPerProxy() {
    var el = document.getElementById('proxy-max-per-proxy');
    if (!el) return;
    var val = Math.max(0, parseInt(el.value || 0) || 0);
    el.value = val;
    API.post('/api/settings', {proxy_max_accounts_per_proxy: val}).then(function() {
        showToast(val === 0 ? 'Раздача: авто' : ('Макс ' + val + ' акк/прокси'), 'info');
    }).catch(function() {});
}

function addProxies() {
    var input = document.getElementById('proxy-input');
    if (!input) return;
    var text = input.value.trim();
    if (!text) { showToast('Введите прокси', 'warning'); return; }
    var defaultType = (document.getElementById('proxy-type-default') || {}).value || 'socks5';
    API.post('/api/proxy/add', {text: text, default_type: defaultType}).then(function(d) {
        if (d.ok) {
            input.value = '';
            // авто-тест после добавления — сразу проверяем и показываем сколько живых.
            if (d.added > 0) {
                showToast('Добавлено ' + d.added + ', проверяю...', 'info');
                loadProxies();
                API.post('/api/proxy/check').then(function(c) {
                    if (c && c.ok) {
                        var alive = c.alive || 0, total = c.total || 0;
                        var dead = total - alive;
                        if (dead > 0) {
                            // confirm с кнопкой удаления вместо тоста-вопроса.
                            if (confirm('Живых: ' + alive + '/' + total + '. Удалить ' + dead + ' мёртвых?')) {
                                API.post('/api/proxy/remove_dead', {}).then(function(rr) {
                                    if (rr && rr.ok) {
                                        showToast('Удалено ' + (rr.removed || 0) + ' мёртвых. Осталось ' + (rr.remaining || 0), 'success');
                                        loadProxies();
                                    } else {
                                        showToast('Не удалось удалить', 'error');
                                    }
                                }).catch(function() { showToast('Ошибка удаления', 'error'); });
                            } else {
                                showToast('Живых: ' + alive + '/' + total + ' (оставил всех)', 'warning');
                            }
                        } else {
                            showToast('Все ' + alive + ' прокси живые', 'success');
                        }
                        loadProxies();
                    }
                }).catch(function() {});
            } else {
                showToast('Все прокси уже были в списке', 'info');
                loadProxies();
            }
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// Единая проверка прокси. Сначала быстрый TCP (жив ли, страна, резолв
// протокола — без него HTTP-прокси без схемы парсился как SOCKS5 и реальный
// акк не коннектился), затем авторитетная MTProto-проверка: реальный коннект к
// Telegram через каждый прокси. TCP лжёт на прокси, что проходят TCP, но виснут
// на MTProto-хендшейке — именно из-за них живой акк висит на коннекте >50с и
// софт ложно пишет «сессия отозвана».
function checkAllProxies() {
    showToast('Проверяю прокси: TCP + реальный Telegram…', 'info');
    // TCP-шаг помечает alive/страну/протокол в списке. Не критичен для вердикта —
    // если упал, MTProto ниже всё равно даёт финальный ответ, поэтому глотаем.
    return API.post('/api/proxy/check').catch(function() { return null; }).then(function() {
        loadProxies();
        return mtprotoCheckProxies();
    });
}

// MTProto-часть общей проверки: реальный коннект к DC через каждый прокси.
function mtprotoCheckProxies() {
    return API.post('/api/proxy/mtproto_check', {}).then(function(d) {
        if (d && d.error) { showToast('Ошибка: ' + d.error, 'error'); return; }
        if (!d || !d.ok) { showToast('Не удалось проверить', 'error'); return; }
        if (!d.total) { showToast('Список прокси пуст', 'warning'); return; }
        var rows = (d.results || []).map(function(r) {
            var ok = !!r.mtproto_ok;
            var ms = (r.elapsed_ms != null) ? (r.elapsed_ms + 'мс') : '';
            // Бинарно: несёт Telegram (✓) / не несёт (✗). ms показываем как ФАКТ,
            // без ярлыка «медленный/замени» — он скачет, решение за юзером.
            var ic = ok ? '<i class="bi bi-check-circle-fill"></i>' : '<i class="bi bi-x-circle-fill"></i>';
            return '<div style="display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--border);">' +
                '<span style="flex-shrink:0;color:' + (ok ? 'var(--success)' : 'var(--danger)') + ';">' + ic + '</span>' +
                '<div style="min-width:0;flex:1;"><code style="word-break:break-all;">' + escHtml(r.addr) + '</code>' +
                (ms ? ' <span class="text-sm" style="color:var(--muted,#9a9aae);">' + escHtml(ms) + '</span>' : '') +
                '<div class="text-sm" style="word-break:break-word;color:var(--muted,#9a9aae);">' + escHtml(r.detail || '') + '</div></div>' +
                '</div>';
        }).join('');
        var head = '<div style="margin-bottom:10px;line-height:1.5;">' +
            '<span style="color:var(--success);">Несут Telegram: ' + (d.alive || 0) + '</span> · ' +
            '<span style="color:var(--danger);">Не несут: ' + d.dead + '</span> из ' + d.total + '.<br>' +
            '<span class="text-sm text-muted">Рядом — сколько занял хендшейк сейчас (ms скачет, это просто замер на момент проверки). «Не несут» = проходит TCP, но через него Telegram не отвечает.</span></div>';
        var footer = d.dead > 0
            ? '<div style="margin-top:12px;text-align:right;"><button class="btn btn-outline btn-sm" style="border-color:var(--danger);color:var(--danger);" onclick="removeMtprotoDead()"><i class="bi bi-trash"></i> Удалить не-Telegram (' + d.dead + ')</button></div>'
            : '';
        if (typeof appAlert === 'function') {
            appAlert(head + rows + footer, {title: 'Проверка прокси через Telegram', html: true});
        }
        showToast('Через Telegram: ' + (d.alive || 0) + '/' + d.total + ' несут Telegram' + (d.dead ? ', ' + d.dead + ' не несут' : ''),
                  d.dead === 0 ? 'success' : 'warning');
        loadProxies();
    }).catch(function(e) {
        var m = (e && e.message) ? e.message : e;
        showToast('Ошибка: ' + m, 'error');
    });
}

function removeMtprotoDead() {
    API.post('/api/proxy/remove_mtproto_dead', {}).then(function(d) {
        if (d && d.ok) {
            showToast('Удалено ' + (d.removed || 0) + ' прокси, не несущих Telegram. Осталось ' + (d.remaining || 0), 'success');
            var _bd = document.getElementById('app-modal-bd'); if (_bd) _bd.remove();
            loadProxies();
        } else {
            showToast('Не удалось удалить', 'error');
        }
    }).catch(function() { showToast('Ошибка удаления', 'error'); });
}

function recheckProxy(idx) {
    showToast('Перепроверяю прокси #' + (idx+1) + '...', 'info');
    API.post('/api/proxy/recheck', {index: idx}).then(function(d) {
        if (d.ok) {
            var msg;
            if (d.alive) {
                msg = 'Прокси живой (' + d.proto + ')';
                if (d.country) msg += ' — ' + d.country + (d.exit_ip ? ' / ' + d.exit_ip : '');
            } else {
                msg = 'Прокси мёртвый: ' + (d.error || '').substring(0, 60);
            }
            showToast(msg, d.alive ? 'success' : 'warning');
            loadProxies();
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function removeProxy(idx) {
    API.post('/api/proxy/remove', {index: idx}).then(function(d) {
        if (d.ok) {
            showToast('Прокси удалён', 'success');
            loadProxies();
        }
    });
}

function clearAllProxies() {
    if (!confirm('Удалить ВСЕ прокси из списка? Это действие нельзя отменить.')) return;
    API.post('/api/proxy/clear', {}).then(function(d) {
        if (d && d.ok) {
            var _inp = document.getElementById('proxy-input');
            if (_inp) _inp.value = '';
            showToast('Все прокси удалены', 'success');
            loadProxies();
        } else {
            showToast('Не удалось очистить список', 'error');
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// ══════════════════════════════════════════════════════════════════
//  TEMPLATES — готовые тексты
// ══════════════════════════════════════════════════════════════════



// Inline-edit шаблона прямо в карточке


// Диаграмма активности акка — SVG-sparkline без библиотек
function openAccountTimeline(phone) {
    var overlay = document.createElement('div');
    overlay.id = 'acc-timeline-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9996;display:flex;align-items:center;justify-content:center;';
    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
    var box = document.createElement('div');
    box.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:7px;padding:18px;width:90%;max-width:680px;max-height:80vh;overflow-y:auto;';
    box.innerHTML = '<div style="text-align:center;padding:30px;color:var(--text-muted);"><i class="bi bi-hourglass-split"></i> Загрузка…</div>';
    overlay.appendChild(box);
    document.body.appendChild(overlay);
    API.get('/api/account/' + encodeURIComponent(phone) + '/timeline').then(function(d) {
        var s = d.stats || {};
        var dur = d.durable || {};
        var st = d.status || {};
        var events = d.events || [];
        var ageStr = st.age_days >= 0 ? (st.age_days + ' дней') : 'неизвестен';
        var statusStr = st.dead ? '<span style="color:var(--danger);">мёртв</span>'
                      : st.frozen ? '<span style="color:var(--info);">заморожен</span>'
                      : st.floodwait_until > Date.now() / 1000 ? '<span style="color:var(--warning);">FloodWait</span>'
                      : '<span style="color:var(--success);">живой</span>';
        // Считаем суточные группы из events (24 точки за 7 дней)
        var buckets = {};
        events.forEach(function(ev) {
            var ts = ev.ts || ev.timestamp || 0;
            if (!ts) return;
            var day = new Date(ts * 1000).toISOString().slice(0, 10);
            buckets[day] = (buckets[day] || 0) + 1;
        });
        var days = Object.keys(buckets).sort();
        var maxVal = Math.max(1, Math.max.apply(null, days.map(function(d){ return buckets[d]; })));
        var W = 600, H = 80, padding = 4;
        var spark = '<svg width="' + W + '" height="' + H + '" style="display:block;background:var(--bg);border-radius:4px;">';
        if (days.length) {
            var bw = (W - padding * 2) / days.length;
            days.forEach(function(d, i) {
                var v = buckets[d];
                var bh = Math.max(2, (v / maxVal) * (H - padding * 2));
                spark += '<rect x="' + (padding + i * bw) + '" y="' + (H - padding - bh) + '" width="' + Math.max(1, bw - 2) + '" height="' + bh + '" fill="var(--accent-light)" rx="2"><title>' + d + ': ' + v + ' событий</title></rect>';
            });
        } else {
            spark += '<text x="' + (W/2) + '" y="' + (H/2 + 5) + '" text-anchor="middle" fill="var(--text-muted)" font-size="12">Нет событий за 7 дней</text>';
        }
        spark += '</svg>';

        box.innerHTML =
            '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">'
            + '<h3 style="margin:0;">Аккаунт +' + escHtml(phone) + '</h3>'
            + '<button onclick="document.getElementById(\'acc-timeline-overlay\').remove()" style="background:transparent;border:none;color:var(--text-muted);font-size:1.3rem;cursor:pointer;">×</button>'
            + '</div>'
            + '<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:6px;font-size:0.8rem;">'
              + '<div><b>Статус:</b> ' + statusStr + '</div>'
              + '<div><b>Возраст:</b> ' + ageStr + '</div>'
              + '<div><b>FloodWait\'ов:</b> ' + (s.floodwait_count || 0) + '</div>'
            + '</div>'
            + '<div style="font-size:0.7rem;color:var(--text-muted);margin:10px 0 4px;">Активность за 90 дней (сохраняется между запусками):</div>'
            + '<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;font-size:0.8rem;">'
              + '<div><b>Подписался:</b> ' + (dur.joined || 0) + (dur.join_failed ? ' <span style="color:var(--text-muted);">(неуд. ' + dur.join_failed + ')</span>' : '') + '</div>'
              + '<div><b>Отправил в чаты:</b> ' + (dur.sent_chats || 0) + (dur.chats_failed ? ' <span style="color:var(--text-muted);">(неуд. ' + dur.chats_failed + ')</span>' : '') + '</div>'
              + '<div><b>Отправил в ЛС:</b> ' + (dur.sent_dm || 0) + (dur.dm_failed ? ' <span style="color:var(--text-muted);">(неуд. ' + dur.dm_failed + ')</span>' : '') + '</div>'
              + (dur.parsed ? '<div><b>Спарсил юзеров:</b> ' + dur.parsed + '</div>' : '')
              + (s.spam_spamblock ? '<div><b>Спам-блоков:</b> ' + s.spam_spamblock + '</div>' : '')
            + '</div>'
            + '<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:6px;">Активность за 7 дней:</div>'
            + spark
            + (st.last_fail_reason ? ('<div style="margin-top:10px;padding:8px;background:var(--surface-warning-bg);border-radius:4px;font-size:0.75rem;color:var(--warning);"><b>Последняя проблема:</b> ' + escHtml(st.last_fail_reason) + '</div>') : '');
    }).catch(function(e) {
        box.innerHTML = '<div style="text-align:center;padding:30px;color:var(--danger);">Ошибка: ' + e + '</div>';
    });
}






// ══════════════════════════════════════════════════════════════════
//  PARSER — сбор юзернеймов
// ══════════════════════════════════════════════════════════════════

var _parserChatsData = [];

function scanParserChats(opts) {
    // opts = {limit: N, auto: bool}. auto-scan передаёт limit:600 (как bot_modular),
    // ручной клик «Просканировать» — без лимита (юзер хочет все чаты).
    opts = opts || {};
    var btn = document.getElementById('btn-parser-scan');
    var sel = document.getElementById('parser-chat');
    // таймер в кнопке + selector — без живого счётчика выглядело будто завис.
    var startedAt = Date.now();
    var resetBtn = function() {
        if (btn) {
            btn.disabled = false;
            btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Обновить список чатов';
        }
        if (window._parserScanTimer) { clearInterval(window._parserScanTimer); window._parserScanTimer = null; }
    };
    if (btn) {
        btn.disabled = true;
        btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Сканирую (0с)…';
    }
    if (window._parserScanTimer) clearInterval(window._parserScanTimer);
    window._parserScanTimer = setInterval(function() {
        var elapsed = Math.floor((Date.now() - startedAt) / 1000);
        if (btn && btn.disabled) {
            btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Сканирую (' + elapsed + 'с)…';
        }
        if (sel) sel.innerHTML = '<option value="">Сканирую чаты ' + elapsed + 'с (обычно 5-60с, медленный прокси — до 3 мин)...</option>';
    }, 1000);

    var phone = _cachedActive || '';
    var _payload = {phone: phone};
    if (opts.limit && opts.limit > 0) _payload.limit = opts.limit;
    // Дельта-режим по дефолту OFF — полный скан предсказуемее (offset_date мог
    // зацикливать iter_dialogs). Дельта — только при явном opt-in.
    if (opts.force_full) _payload.force_full = true;
    if (opts.delta === true) _payload.delta = true;
    API.post('/api/parser/scan_chats', _payload).then(function(d) {
        resetBtn();
        if (d && d.ok) {
            var deltaHint = d.delta_mode ? ' (дельта)' : '';
            // Формулировки в success-пути явно позитивные (список из кэша уже доступен).
            if (d.via_cache) {
                showToast('Найдено ' + d.count + ' чатов — можно выбирать и парсить', 'success');
            } else if (d.msg) {
                showToast(d.msg, 'warning');
            } else if (d.count > 0) {
                showToast('Найдено ' + d.count + ' чатов' + deltaHint, 'success');
            } else {
                // короткий warning
                showToast('0 диалогов у акка — подпишись на чаты в TG-вебе и повтори', 'warning');
            }
            loadParserChats();
        } else {
            // «Аккаунт занят» — норма (параллельный запуск). Показываем как
            // WARNING, не ERROR — иначе красная ❌ в логах при нормальной работе.
            var _err = (d && d.error) || 'Не удалось получить список чатов';
            var _kind = /Аккаунт занят|занят:/i.test(_err) ? 'warning' : 'error';
            showToast(_err, _kind);
            // при ошибке возвращаем понятный placeholder, не «Сканирую...»
            if (sel) sel.innerHTML = '<option value="">' + (_kind === 'warning' ? 'Подожди задачу акка' : 'Не удалось — попробуй ещё раз') + '</option>';
        }
    }).catch(function(e) {
        resetBtn();
        // API.post бросает Error на non-2xx с e.data/e.status (прикладная ошибка
        // бэка) и обычный Error на реальном сетевом сбое (нет e.data). Разводим
        // их, чтобы «не успел за 2 мин» не читалось как сетевая катастрофа.
        var appErr = e && e.data && (e.data.error || e.data.message);
        if (appErr) {
            var _kind = /Аккаунт занят|занят:/i.test(appErr) ? 'warning' : 'error';
            showToast(appErr, _kind);
            if (sel) sel.innerHTML = '<option value="">' + (_kind === 'warning' ? 'Подожди задачу акка' : 'Не удалось — попробуй ещё раз') + '</option>';
        } else {
            showToast('Сеть упала: ' + (e && e.message ? e.message : e), 'error');
            if (sel) sel.innerHTML = '<option value="">Ошибка — попробуй ещё раз</option>';
        }
    });
}

function loadParserChats(forceWake) {
    var sel = document.getElementById('parser-chat');
    sel.innerHTML = '<option value="">Загрузка чатов...</option>';
    renderAccountSelectors();
    var descEl = document.getElementById('parser-mode-desc');
    if (descEl && _parserModeDescs[_parserMode]) descEl.innerHTML = _parserModeDescs[_parserMode];
    var url = '/api/parser/chats';
    API.get(url).then(function(d) {
        var chats = d.chats || [];
        _parserChatsData = chats;
        var activeAcc = (_cachedAccounts || []).find(function(a) { return a._active && !a.dead && !a.frozen; });
        var hasActiveAcc = !!activeAcc;
        // НЕ авто-сканить свежедобавленный (verified=false) акк: auto-scan →
        // ensure_connected с direct IP → Telegram revoke сессии (геофингерпринт
        // продавца). Для свежих ждём явного «Просканировать» (после привязки прокси).
        var freshlyAdded = activeAcc && !activeAcc.verified;
        // Запускаем фоновый скан при заходе на парсер, если:
        //   - есть выбранный живой акк И
        //   - кэш отсутствует ИЛИ старше 5 минут И
        //   - на этой вкладке И уже не идёт скан И
        //   - акк уже хоть раз успешно подключался (verified=true)
        // Кэш < 5 мин — не дёргаем TG зря.
        var cacheAgeSec = d.updated_at ? (Date.now() / 1000 - d.updated_at) : 99999;
        var cacheStale = chats.length === 0 || cacheAgeSec > 300;
        if (cacheStale && hasActiveAcc && !freshlyAdded && _currentTab === 'parser' && !window._parserAutoScanInFlight) {
            window._parserAutoScanInFlight = true;
            // если кэш есть — показываем его сразу, скан крутится в фоне
            if (chats.length === 0) {
                sel.innerHTML = '<option value="">Подключаюсь к Telegram, тяну чаты у выбранного акка...</option>';
            }
            // auto-scan с лимитом 600 — массовое iter_dialogs без лимита на
            // свежей сессии = бот-сигнатура → revoke. Больше — ручной «Обновить».
            scanParserChats({limit: 600, auto: true});
            setTimeout(function() { window._parserAutoScanInFlight = false; }, 10000);
            if (chats.length === 0) return;
        }
        if (chats.length === 0) {
            if (freshlyAdded) {
                // явное объяснение почему НЕ автоскан, и что делать
                sel.innerHTML = '<option value="">Новый аккаунт — привяжите прокси и нажмите «Просканировать» (сами не сканируем, чтобы не закрыть вход)</option>';
            } else if (d.needs_connect) {
                sel.innerHTML = '<option value="">' + (d.msg || 'Нажмите «Обновить»') + '</option>';
            } else if (!hasActiveAcc) {
                sel.innerHTML = '<option value="">Выбери активный акк в шапке слева</option>';
            } else {
                sel.innerHTML = '<option value="">Нет чатов — попробуй обновить</option>';
            }
            updateChatPreview();
            return;
        }
        // показываем «кэш от N мин назад» если data из cache (без коннекта).
        var label = 'Выберите чат (' + chats.length + ' доступно)';
        if (d.cached && d.updated_at) {
            var ago = Math.max(0, Math.floor((Date.now() / 1000 - d.updated_at) / 60));
            var agoStr = ago < 1 ? 'только что' : ago < 60 ? (ago + ' мин назад') :
                         ago < 1440 ? (Math.floor(ago / 60) + ' ч назад') :
                         (Math.floor(ago / 1440) + ' д назад');
            label = 'Кэш ' + agoStr + ' (' + chats.length + ' чатов)' +
                    (cacheStale ? ' — обновляю в фоне' : '');
        }
        sel.innerHTML = '<option value="">' + label + '</option>';
        chats.forEach(function(c) {
            var icon = c.type === 'channel' ? '[канал]' : '[группа]';
            var members = c.members > 0 ? ' (' + c.members.toLocaleString() + ')' : '';
            var opt = document.createElement('option');
            opt.value = c.id;
            opt.textContent = icon + ' ' + c.name + members;
            sel.appendChild(opt);
        });
        updateChatPreview();
    }).catch(function() { sel.innerHTML = '<option value="">Ошибка загрузки</option>'; });
}

function filterParserChats() {
    var inp = document.getElementById('parser-chat-search');
    var sel = document.getElementById('parser-chat');
    if (!sel) return;
    var q = (inp ? inp.value : '').trim().toLowerCase();
    var visible = 0, total = 0;
    for (var i = 0; i < sel.options.length; i++) {
        var opt = sel.options[i];
        if (!opt.value) continue;
        total++;
        var match = !q || (opt.textContent || '').toLowerCase().indexOf(q) !== -1;
        opt.hidden = !match;
        opt.disabled = !match;
        if (match) visible++;
    }
    if (sel.options.length > 0 && !sel.options[0].value) {
        sel.options[0].textContent = q
            ? (visible ? 'Найдено: ' + visible + ' из ' + total : 'Ничего не найдено')
            : 'Выберите чат (' + total + ' доступно)';
    }
}

function updateChatPreview() {
    var preview = document.getElementById('parser-chat-preview');
    if (!preview) return;
    var sel = document.getElementById('parser-chat');
    var chatId = sel ? sel.value : '';
    if (!chatId) { preview.style.display = 'none'; return; }
    var chat = _parserChatsData.find(function(c) { return String(c.id) === String(chatId); });
    if (!chat) { preview.style.display = 'none'; return; }
    var icon = chat.type === 'channel' ? 'bi-megaphone' : 'bi-people';
    var typeLabel = chat.type === 'channel' ? 'Канал' : 'Группа';
    preview.style.display = 'block';
    preview.innerHTML = '<div class="chat-info">' +
        '<i class="bi ' + icon + '"></i>' +
        '<span>' + escHtml(chat.name) + '</span>' +
        '<span style="font-size:0.7rem;color:var(--text-muted);background:var(--surface-info-bg);padding:2px 6px;border-radius:4px;">' + typeLabel + '</span>' +
        '<span class="chat-members"><i class="bi bi-people"></i> ' + (chat.members || 0).toLocaleString() + '</span>' +
    '</div>';
}

var _parserPollTimer = null;
var _parserMode = 'members';
// Связка аудитории: 4 источника — мульти-выбор в один проход.
var _AUDIENCE_MODES = ['members', 'writers', 'reactions', 'polls'];
var _parserAudienceSel = ['members'];  // активные источники аудитории (массив, мин. 1)
var _parserSelectedChats = [];  // [{id, name}]
var _parserLinksMode = false;
var _parserAllChats = false;

function toggleParserInputMode() {
    _parserLinksMode = !_parserLinksMode;
    var selMode = document.getElementById('parser-chat-select-mode');
    var linksMode = document.getElementById('parser-links-mode');
    var btn = document.getElementById('btn-parser-input-mode');
    if (_parserLinksMode) {
        if (selMode) selMode.style.display = 'none';
        if (linksMode) linksMode.style.display = 'block';
        if (btn) btn.innerHTML = '<i class="bi bi-list-ul"></i> Из списка';
    } else {
        if (selMode) selMode.style.display = 'block';
        if (linksMode) linksMode.style.display = 'none';
        if (btn) btn.innerHTML = '<i class="bi bi-link-45deg"></i> Ввести ссылки';
    }
}

function toggleParserAllChats() {
    _parserAllChats = !_parserAllChats;
    var banner = document.getElementById('parser-all-chats-banner');
    if (banner) banner.style.display = _parserAllChats ? 'block' : 'none';
}

// Один клик — сканируем все чаты аккаунта (как в старом link_collector боте).
// Для режимов keyword/search/user_chats — нет смысла, форсим режим «Ссылки».
// Для links/members/writers — оставляем текущий режим + включаем all_chats и жмём Старт.
function scanAllChatsOneClick() {
    if (_parserMode === 'keyword' || _parserMode === 'search' || _parserMode === 'user_chats') {
        setParserMode('links');
    }
    _parserAllChats = true;
    var banner = document.getElementById('parser-all-chats-banner');
    if (banner) banner.style.display = 'block';
    var lbl = _parserMode === 'members' ? 'участников' :
              _parserMode === 'writers' ? 'писавших' : 'ссылки';
    showToast('Сканирую все чаты аккаунта → собираю ' + lbl + '...', 'info');
    setTimeout(function() { startParser(); }, 100);
}

function addParserChat() {
    var sel = document.getElementById('parser-chat');
    // Если чат выбран в списке — добавляем его
    if (sel && sel.value) {
        var id = sel.value;
        var name = sel.options[sel.selectedIndex].text;
        if (_parserSelectedChats.some(function(c) { return c.id === id; })) {
            showToast('Этот чат уже добавлен', 'warning');
            return;
        }
        _parserSelectedChats.push({id: id, name: name});
        _renderParserSelectedChats();
        showToast('Чат добавлен: ' + name.substring(0, 40), 'success');
        return;
    }
    // Нет выбранного — просим ссылку или @username вручную
    tpPrompt({
        title: 'Добавить чат вручную',
        hint: 'Вставьте ссылку или @username чата.\nНапример: @telegram или https://t.me/telegram',
        placeholder: '@telegram или https://t.me/...'
    }).then(function(input) {
        if (!input) return;
        var username = input.replace(/^https?:\/\/(t\.me|telegram\.me|telegram\.dog)\//i, '').replace(/^@/, '').split('?')[0].split('/')[0];
        if (!username) { showToast('Не удалось распознать ссылку', 'error'); return; }
        if (_parserSelectedChats.some(function(c) { return c.id === username || c.name === '@' + username; })) {
            showToast('Этот чат уже добавлен', 'warning');
            return;
        }
        _parserSelectedChats.push({id: username, name: '@' + username});
        _renderParserSelectedChats();
        showToast('Чат добавлен: @' + username, 'success');
    });
}

function removeParserChat(id) {
    _parserSelectedChats = _parserSelectedChats.filter(function(c) { return c.id !== id; });
    _renderParserSelectedChats();
}

function _renderParserSelectedChats() {
    var el = document.getElementById('parser-selected-chats');
    if (!el) return;
    if (!_parserSelectedChats.length) { el.innerHTML = ''; return; }
    el.innerHTML = _parserSelectedChats.map(function(c) {
        return '<span style="background:var(--surface-info-bg);border:1px solid var(--border-accent);border-radius:4px;padding:3px 8px;font-size:0.72rem;display:inline-flex;align-items:center;gap:4px;">' +
            escHtml(c.name.substring(0, 30)) +
            '<span onclick="removeParserChat(\'' + c.id + '\')" style="cursor:pointer;color:var(--text-muted);font-size:0.8rem;">&times;</span>' +
        '</span>';
    }).join('');
}

var _parserModeDescs = {
    members: '<b>Участники</b> — @юзернеймы из списка участников чата. Фильтры: активность, пропуск админов, спам-детекция. Расширенный алфавит (лат+кир).',
    writers: '<b>Писавшие</b> — @юзернеймы из сообщений. Для каналов автоматически собирает комментаторов + @mentions из постов.',
    reactions: '<b>Реакции</b> — @юзернеймы тех, кто ставил реакции на сообщения. Работает в <b>группах</b> (в каналах-вещаниях Telegram скрывает кто реагировал). Самые вовлечённые юзеры — выше конверсия в DM. Скан последних ~300 сообщений. Те же фильтры качества: активность, спам, аватар, премиум.',
    polls: '<b>Опросы</b> — @юзернеймы тех, кто ГОЛОСОВАЛ в опросах канала/чата. Вовлечённые юзеры. ⚠️ Только <b>публичные</b> опросы — в анонимных Telegram скрывает кто голосовал (такие пропускаются).',
    links: '<b>Сборщик ссылок</b> — t.me из сообщений (entities, кнопки) + из описания/about самого чата + закреплённая invite-ссылка. Unwrap коротких URL (bit.ly, telegra.ph). Каноникализация и дедупликация.',
    keyword: '<b>По словам</b> — ищет юзернеймы/ссылки по ключевым словам в чатах, <b>куда вступил выбранный акк</b> (твои подписки). Нужен акк. Для поиска без акка — режим «Слово везде».',
    keyword_global: '<b>Слово везде</b> — находит чаты/каналы, где встречается слово или фраза. <b>Без аккаунта</b>, мгновенно.',
    search: '<b>Глоб. поиск</b> — найти публичные чаты/каналы по ключевому слову через Telegram API + TGStat. Транслитерация RU→LAT, расширение запросов.',
    user_chats: '<b>Чаты юзера</b> — ищет чаты/каналы, где состоит указанный @юзернейм.',
};

// фильтр реакций по эмодзи (режим «Реакции»). Пусто = все реакции.
var _reactionFilter = [];
var _REACTION_EMOJIS = ['👍','❤️','🔥','🎉','😁','👏','🥰','🤔','😮','😢','👎','🙏'];
function _buildReactionEmojis() {
    var wrap = document.getElementById('parser-reaction-emojis');
    if (!wrap || wrap._built) return;
    wrap._built = true;
    _REACTION_EMOJIS.forEach(function(emo) {
        var b = document.createElement('button');
        b.type = 'button';
        b.className = 'reaction-emoji-btn' + (_reactionFilter.indexOf(emo) !== -1 ? ' active' : '');
        b.textContent = emo;
        b.onclick = function() {
            var i = _reactionFilter.indexOf(emo);
            if (i === -1) { _reactionFilter.push(emo); b.classList.add('active'); }
            else { _reactionFilter.splice(i, 1); b.classList.remove('active'); }
        };
        wrap.appendChild(b);
    });
}

// Быстрые пресеты активности для members/writers
function applyActivityPreset(kind, hours) {
    var filterEl = document.getElementById('parser-filter');
    var hoursEl = document.getElementById('parser-hours');
    if (kind === 'online') {
        if (filterEl) filterEl.value = 'online';
    } else if (kind === 'all') {
        if (filterEl) filterEl.value = 'all';
    } else if (kind === 'hours') {
        if (filterEl) filterEl.value = 'hours';
        if (hoursEl) hoursEl.value = hours;
    }
    // Подсвечиваем активную кнопку
    var btns = document.querySelectorAll('#parser-activity-presets button');
    btns.forEach(function(b) { b.classList.remove('btn-accent'); b.classList.add('btn-outline'); });
    if (event && event.currentTarget) {
        event.currentTarget.classList.remove('btn-outline');
        event.currentTarget.classList.add('btn-accent');
    }
}

// Селект «Период сканирования» в режиме «Писавшие» — самодостаточен,
// значение читается в startParser() напрямую из #parser-period-preset.
function applyPeriodPreset(days) {
    // noop: оставлен для совместимости со старыми HTML-атрибутами onchange
}

// Селект «Период сканирования» в режиме «Ссылки» — аналогично, читается напрямую.
function applyLinksPeriodPreset(days) {
    // noop
}

// Собирает актуальное значение date_limit_days в зависимости от активного режима парсинга.
// В writers — из #parser-period-preset, в links — из #parser-links-period, иначе 0.
function _currentDateLimitDays(mode) {
    var el = null;
    if (mode === 'writers') el = document.getElementById('parser-period-preset');
    else if (mode === 'links') el = document.getElementById('parser-links-period');
    if (!el) return 0;
    var v = parseInt(el.value);
    return isNaN(v) ? 0 : v;
}

// Дропдаун-режим парсера. Каждая функция — пункт; «Аудитория» разворачивает
// чекбоксы союза; «Досье» — accountless-режим в этом же блоке.
// Fancy select — апгрейд нативного <select.js-fancy-select> в кастомный дропдаун
// в стиль софта (нативный список Chromium темой не стилизуется). Значение и
// onchange остаются на нативном <select> — логика не меняется.
var _FSEL_ICONS = {
    audience: 'people', links: 'link-45deg', keyword_global: 'globe-americas',
    keyword: 'chat-square-text', search: 'search', user_check: 'person-check',
    link_check: 'shield-check'
};
function _fselEsc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function initFancySelect(sel) {
    if (!sel || sel._fselDone) return;
    sel._fselDone = true;
    var wrap = document.createElement('div');
    wrap.className = 'fsel';
    sel.parentNode.insertBefore(wrap, sel);
    wrap.appendChild(sel);
    sel.classList.add('fsel-native');

    var trigger = document.createElement('button');
    trigger.type = 'button';
    trigger.className = 'fsel-trigger';
    trigger.innerHTML = '<i class="bi"></i><span class="fsel-label"></span><i class="bi bi-chevron-down fsel-caret"></i>';
    var panel = document.createElement('div');
    panel.className = 'fsel-panel';

    function buildOpt(o) {
        var ic = _FSEL_ICONS[o.value] || 'circle';
        var parts = (o.textContent || '').split(' — ');
        var title = parts.shift();
        var sub = parts.join(' — ');
        var d = document.createElement('div');
        d.className = 'fsel-opt' + (o.selected ? ' selected' : '');
        d.setAttribute('data-val', o.value);
        d.innerHTML = '<i class="bi bi-' + ic + ' fsel-ico"></i><span class="fsel-otext">' +
            '<span class="fsel-otitle">' + _fselEsc(title) + '</span>' +
            (sub ? '<span class="fsel-osub">' + _fselEsc(sub) + '</span>' : '') + '</span>';
        d.addEventListener('click', function () { pick(o.value); });
        return d;
    }
    Array.prototype.forEach.call(sel.children, function (node) {
        if (node.tagName === 'OPTGROUP') {
            var g = document.createElement('div');
            g.className = 'fsel-group';
            g.textContent = node.label;
            panel.appendChild(g);
            Array.prototype.forEach.call(node.children, function (o) { panel.appendChild(buildOpt(o)); });
        } else if (node.tagName === 'OPTION') {
            panel.appendChild(buildOpt(node));
        }
    });

    function syncTrigger() {
        var o = sel.options[sel.selectedIndex];
        if (!o) return;
        trigger.querySelector('.bi').className = 'bi bi-' + (_FSEL_ICONS[o.value] || 'circle');
        trigger.querySelector('.fsel-label').textContent = (o.textContent || '').split(' — ')[0];
    }
    function pick(val) {
        if (sel.value !== val) {
            sel.value = val;
            sel.dispatchEvent(new Event('change', { bubbles: true }));
        }
        Array.prototype.forEach.call(panel.querySelectorAll('.fsel-opt'), function (e) {
            e.classList.toggle('selected', e.getAttribute('data-val') === val);
        });
        syncTrigger();
        wrap.classList.remove('open');
    }
    trigger.addEventListener('click', function (e) { e.stopPropagation(); wrap.classList.toggle('open'); });
    document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) wrap.classList.remove('open'); });

    wrap.appendChild(trigger);
    wrap.appendChild(panel);
    syncTrigger();
}
document.addEventListener('DOMContentLoaded', function () {
    Array.prototype.forEach.call(document.querySelectorAll('select.js-fancy-select'), initFancySelect);
});

// Подсказка под селектором «Как искать» — что делает выбранный режим.
var _SEARCH_MODE_HINTS = {
    phrase: '<b style="color:var(--text);">Точная фраза</b> — ищет фразу целиком, оставляет только чаты, где есть <b>все</b> слова. «Москва барахолка» → только московские барахолки, без мусора из других городов.',
    all_words: '<b style="color:var(--text);">Все слова</b> — ищет по каждому слову отдельно (шире охват), но оставляет только чаты, где встречаются <b>все</b> слова запроса.',
    any_word: '<b style="color:var(--text);">Любое слово</b> — максимальный охват: подойдёт чат с <b>любым</b> из слов. Результатов больше, но и мусора больше.',
    raw: '<b style="color:var(--text);">Как ввёл</b> — отправляет запрос ровно как есть, без транслита и фильтра по словам. Что вернул Telegram, то и берём.'
};
function updateSearchModeHint() {
    var sel = document.getElementById('parser-search-mode');
    var box = document.getElementById('parser-search-mode-hint');
    if (!sel || !box) return;
    box.innerHTML = _SEARCH_MODE_HINTS[sel.value] || _SEARCH_MODE_HINTS.phrase;
}

function onParserModeSelect(val) {
    _setParserDossierMode(false);
    var isAud = (val === 'audience');
    var combo = document.getElementById('parser-audience-combo');
    var hint = document.getElementById('parser-audience-hint');
    if (combo) combo.style.display = isAud ? '' : 'none';
    if (hint) hint.style.display = isAud ? '' : 'none';
    if (isAud) {
        if (!_parserAudienceSel.length) _parserAudienceSel = ['members'];
        setParserMode(_parserAudienceSel[_parserAudienceSel.length - 1], true);  // skipToggle
    } else {
        setParserMode(val);
    }
}

function _setParserDossierMode(on) {
    var HIDE = ['acc-selector-parser','parser-audience-combo','parser-audience-hint',
        'parser-audience-intersect-row','parser-mode-desc','parser-user-check-row',
        'parser-link-check-row','parser-keyword-global-row','parser-user-row','parser-chat-row',
        'parser-opts-users','parser-opts-links','parser-opts-keyword','parser-opts-search',
        'parser-presets','parser-limit-row','parser-engagement-row','parser-writers-extra',
        'parser-reactions-row','parser-scan-all-wrap','parser-result'];
    var block = document.getElementById('parser-dossier-block');
    var btn = document.getElementById('btn-parser');
    if (on) {
        HIDE.forEach(function(id){ var e=document.getElementById(id); if(e) e.style.display='none'; });
        if (btn) btn.style.display = 'none';
        if (block) block.style.display = 'block';
    } else {
        if (block) block.style.display = 'none';
        if (btn) btn.style.display = '';  // остальные строки вернёт setParserMode
    }
}

function setParserMode(mode, skipToggle) {
    // Связка аудитории: 4 источника (members/writers/reactions/polls) — мульти-выбор
    // (клик тогглит, минимум 1). Остальные режимы — одиночные. _parserMode держим
    // «репрезентативным» (последний выбранный), чтобы display-логика ниже работала
    // по нему без переписывания. mode="audience" подставляется только в payload.
    if (_AUDIENCE_MODES.indexOf(mode) >= 0) {
        if (!skipToggle) {
            var _ai = _parserAudienceSel.indexOf(mode);
            if (_ai >= 0) { if (_parserAudienceSel.length > 1) _parserAudienceSel.splice(_ai, 1); }
            else _parserAudienceSel.push(mode);
        }
        mode = _parserAudienceSel[_parserAudienceSel.length - 1] || mode;
    } else {
        _parserAudienceSel = [];
    }
    _parserMode = mode;
    ['members','writers','reactions','polls','links','keyword','search','user_chats','keyword_global','user_check','link_check'].forEach(function(m) {
        var btn = document.getElementById('pm-' + m);
        if (btn) {
            var _on = (_AUDIENCE_MODES.indexOf(m) >= 0) ? (_parserAudienceSel.indexOf(m) >= 0) : (m === mode);
            btn.className = 'parser-mode-btn' + (_on ? ' active' : '');
        }
    });
    // видимость блока user_check
    var ucRow = document.getElementById('parser-user-check-row');
    if (ucRow) ucRow.style.display = (mode === 'user_check') ? 'block' : 'none';
    var lcRow = document.getElementById('parser-link-check-row');
    if (lcRow) lcRow.style.display = (mode === 'link_check') ? 'block' : 'none';
    var caRow = document.getElementById('parser-check-accs-row');
    if (caRow) caRow.style.display = (mode === 'user_check' || mode === 'link_check') ? 'block' : 'none';
    // Подпись главной кнопки = логическая задача режима.
    var _pBtn = document.getElementById('btn-parser');
    if (_pBtn) {
        var _pLabel = (mode === 'link_check') ? 'Проверить ссылки'
                    : (mode === 'user_check') ? 'Проверить юзов'
                    : 'Начать парсинг';
        _pBtn.innerHTML = '<i class="bi bi-play-fill"></i> ' + _pLabel;
    }
    var _optsUsers0 = document.getElementById('parser-opts-users');
    if (_optsUsers0) _optsUsers0.style.display = (mode === 'members' || mode === 'writers' || mode === 'reactions' || mode === 'polls') ? 'block' : 'none';
    // фильтр реакций по эмодзи — только в режиме «Реакции».
    var reactionsRow = document.getElementById('parser-reactions-row');
    if (reactionsRow) {
        reactionsRow.style.display = (mode === 'reactions') ? 'block' : 'none';
        if (mode === 'reactions') _buildReactionEmojis();
    }
    // Глубина скана + порог — для «Реакции»/«Опросы».
    var engRow = document.getElementById('parser-engagement-row');
    if (engRow) {
        engRow.style.display = (mode === 'reactions' || mode === 'polls') ? 'grid' : 'none';
        var engLabel = document.getElementById('parser-min-engagement-label');
        if (engLabel) engLabel.innerHTML = (mode === 'polls' ? 'Мин. голосов в опросе' : 'Мин. реакций на посте') +
            ' <span style="color:var(--text-muted);font-weight:400;">(0 = все)</span>';
    }
    // min_messages + период сканирования — только для режима «Писавшие».
    // В «Участниках» они не нужны (участники берутся из API-списка, не из сообщений).
    var writersExtra = document.getElementById('parser-writers-extra');
    if (writersExtra) writersExtra.style.display = (mode === 'writers') ? 'grid' : 'none';
    document.getElementById('parser-opts-links').style.display = mode === 'links' ? 'block' : 'none';
    document.getElementById('parser-opts-keyword').style.display = mode === 'keyword' ? 'block' : 'none';
    var searchOpts = document.getElementById('parser-opts-search');
    if (searchOpts) searchOpts.style.display = mode === 'search' ? 'block' : 'none';
    var presets = document.getElementById('parser-presets');
    if (presets) presets.style.display = (mode === 'links') ? 'block' : 'none';
    var chatRow = document.getElementById('parser-chat-row');
    // keyword_global тоже не привязан к конкретным чатам — скрываем chat-row
    if (chatRow) chatRow.style.display = (mode === 'keyword' || mode === 'search' || mode === 'user_chats' || mode === 'keyword_global' || mode === 'user_check' || mode === 'link_check') ? 'none' : 'block';
    // В «Чекер юзов» лимит результатов не нужен — проверяем ровно столько
    // юзеров сколько в списке. Также скрываем для server-режимов.
    var limitRow = document.getElementById('parser-limit-row');
    if (limitRow) limitRow.style.display = (mode === 'user_check' || mode === 'link_check' || mode === 'keyword_global') ? 'none' : '';
    // Чекеры не ходят по чатам и не задают темп: заголовок «Источник чатов»
    // висел над пустотой, а «Пауза между запросами» ни на что не влияла.
    var _checkMode = (mode === 'user_check' || mode === 'link_check');
    var pauseRow = document.getElementById('parser-pause-row');
    if (pauseRow) pauseRow.style.display = (_checkMode || mode === 'keyword_global') ? 'none' : '';
    var srcGroup = document.getElementById('parser-source-group');
    if (srcGroup) srcGroup.style.display = (_checkMode || mode === 'keyword_global') ? 'none' : '';
    var limGroup = document.getElementById('parser-limit-group');
    if (limGroup) limGroup.style.display = (_checkMode || mode === 'keyword_global') ? 'none' : '';
    // «Сканировать все чаты» — осмысленно только в режиме «Ссылки»:
    // там собираются t.me URL и логично пройти по всем группам сразу.
    // В «Участники»/«Писавшие» прогон по всем чатам = миллионы записей и FloodWait, поэтому скрываем.
    var scanAllWrap = document.getElementById('parser-scan-all-wrap');
    if (scanAllWrap) scanAllWrap.style.display = (mode === 'links') ? 'block' : 'none';
    var userRow = document.getElementById('parser-user-row');
    if (userRow) userRow.style.display = (mode === 'user_chats') ? 'block' : 'none';
    var kgRow = document.getElementById('parser-keyword-global-row');
    if (kgRow) kgRow.style.display = (mode === 'keyword_global') ? 'block' : 'none';
    // восстанавливаем сохранённый TGStat-токен при входе в режим
    if (mode === 'keyword_global') {
        var _kgTok = document.getElementById('parser-kg-tgstat-token');
        if (_kgTok && !_kgTok.value) {
            try {
                var _saved = localStorage.getItem('tp_tgstat_token') || '';
                if (_saved) _kgTok.value = _saved;
            } catch (e) {}
        }
        if (_kgTok && !_kgTok._tpSaveBound) {
            _kgTok._tpSaveBound = true;
            _kgTok.addEventListener('input', function() {
                try { localStorage.setItem('tp_tgstat_token', _kgTok.value || ''); } catch (e) {}
            });
        }
        // запоминаем выбор языка и региона
        ['parser-kg-lang', 'parser-kg-region'].forEach(function(id) {
            var _el = document.getElementById(id);
            if (!_el) return;
            try { _el.value = localStorage.getItem('tp_' + id) || ''; } catch (e) {}
            if (!_el._tpSaveBound) {
                _el._tpSaveBound = true;
                _el.addEventListener('change', function() {
                    try { localStorage.setItem('tp_' + id, _el.value || ''); } catch (e) {}
                });
            }
        });
    }
    var descEl = document.getElementById('parser-mode-desc');
    if (descEl) {
        // Для user_chats у нас уже есть подробная инфо-рамка рядом с полем ввода
        // (parser-user-row). Дублировать описание сверху — мусор, скрываем.
        if (mode === 'user_chats') {
            descEl.style.display = 'none';
            descEl.innerHTML = '';
        } else {
            descEl.style.display = '';
            descEl.innerHTML = _parserModeDescs[mode] || '';
        }
    }
    // акк-селектор скрыт для «Слов везде» и «Чаты юзера» — там он не нужен.
    var accSel = document.getElementById('acc-selector-parser');
    if (accSel) {
        // акк не нужен — keyword_global + link_check (чистый HTTP/сервер).
        // user_check — акк ОПЦИОНАЛЬНО (с акком + онлайн-статус, без — валидность по
        // HTTP): прячем пикер только если акков нет, чтоб не пугать «Нет аккаунтов».
        var _hasAccs = !!(_cachedAccounts && _cachedAccounts.length);
        var _noAcc = (mode === 'keyword_global' || mode === 'link_check');
        var _optAcc = (mode === 'user_check');
        accSel.style.display = (_noAcc || (_optAcc && !_hasAccs)) ? 'none' : '';
    }
    // Удаляем подсказку если осталась с прошлой версии
    var oldHint = document.getElementById('parser-no-acc-hint');
    if (oldHint) oldHint.style.display = 'none';
    // Связка аудитории: видимость блоков по НАБОРУ источников (перекрываем
    // одиночную display-логику выше) + тоггл пересечения при ≥2 выбранных.
    if (_parserAudienceSel.length) {
        var _hasR = _parserAudienceSel.indexOf('reactions') >= 0;
        var _hasP = _parserAudienceSel.indexOf('polls') >= 0;
        var _hasW = _parserAudienceSel.indexOf('writers') >= 0;
        var _ou = document.getElementById('parser-opts-users');
        if (_ou) _ou.style.display = 'block';
        var _rr2 = document.getElementById('parser-reactions-row');
        if (_rr2) { _rr2.style.display = _hasR ? 'block' : 'none'; if (_hasR) _buildReactionEmojis(); }
        var _er2 = document.getElementById('parser-engagement-row');
        if (_er2) _er2.style.display = (_hasR || _hasP) ? 'grid' : 'none';
        var _we2 = document.getElementById('parser-writers-extra');
        if (_we2) _we2.style.display = _hasW ? 'grid' : 'none';
    }
    var _audHint = document.getElementById('parser-audience-hint');
    if (_audHint) _audHint.style.display = _parserAudienceSel.length ? 'block' : 'none';
    var _intRow = document.getElementById('parser-audience-intersect-row');
    if (_intRow) _intRow.style.display = (_parserAudienceSel.length >= 2) ? 'block' : 'none';
}

function startParser() {
    // Собираем чаты: из мульти-выбора, из ссылок, или из select
    var chatIds = [];
    var chatLinks = '';
    // Режимы без привязки к конкретному чату — выбор чата не нужен.
    // keyword=по словам в своих чатах, search=глоб.поиск,
    // user_chats=поиск чатов по юзернейму, keyword_global=Слово везде (server DB).
    var _noChatNeeded = (
        _parserMode === 'keyword' || _parserMode === 'search' ||
        _parserMode === 'user_chats' || _parserMode === 'keyword_global' ||
        _parserMode === 'user_check' || _parserMode === 'link_check'
    );
    var allChats = !!_parserAllChats && !_noChatNeeded;
    if (allChats) {
        // Режим «все чаты» — сервер сам развернёт через iter_dialogs
    } else if (_parserLinksMode) {
        chatLinks = (document.getElementById('parser-chat-links') || {}).value || '';
        if (!_noChatNeeded && !chatLinks.trim()) {
            showToast('Вставьте ссылки на чаты', 'warning'); return;
        }
    } else if (_parserSelectedChats.length > 0) {
        chatIds = _parserSelectedChats.map(function(c) { return c.id; });
    } else {
        var chatId = document.getElementById('parser-chat').value;
        if (!_noChatNeeded && !chatId) {
            showToast('Выберите чат', 'warning'); return;
        }
        if (chatId) chatIds = [chatId];
    }

    if (_parserMode === 'keyword') {
        var kw = document.getElementById('parser-keywords').value.trim();
        if (!kw) { showToast('Введите ключевые слова', 'warning'); return; }
    }
    if (_parserMode === 'search') {
        var sq = (document.getElementById('parser-search-query') || {}).value || '';
        if (!sq.trim()) { showToast('Введите поисковый запрос', 'warning'); return; }
    }
    if (_parserMode === 'user_chats') {
        var tu = (document.getElementById('parser-target-user') || {}).value || '';
        if (!tu.trim()) { showToast('Введите @юзернейм или ID', 'warning'); return; }
    }
    if (_parserMode === 'keyword_global') {
        var kg = (document.getElementById('parser-keyword-global') || {}).value || '';
        if (!kg.trim()) { showToast('Введите слово или фразу для поиска', 'warning'); return; }
    }
    if (_parserMode === 'user_check') {
        var uc = (document.getElementById('parser-user-check-list') || {}).value || '';
        if (!uc.trim()) { showToast('Вставь список юзеров для проверки', 'warning'); return; }
    }
    if (_parserMode === 'link_check') {
        var lc = (document.getElementById('parser-link-check-list') || {}).value || '';
        if (!lc.trim()) { showToast('Вставь список ссылок для проверки', 'warning'); return; }
    }

    // Связка аудитории: ≥2 источника → mode="audience" + sources + intersection.
    // Ровно 1 источник → обычный режим (backward-compat, старый путь работает 1:1).
    var _outMode = _parserMode, _audSources = [], _audIntersect = false;
    if (_parserAudienceSel.length >= 2) {
        _outMode = 'audience';
        _audSources = _parserAudienceSel.slice();
        var _ic = document.getElementById('parser-audience-intersect');
        _audIntersect = !!(_ic && _ic.checked);
    }
    var data = {
        chat_id: chatIds.length === 1 ? chatIds[0] : '',
        chat_ids: chatIds,
        chat_links: chatLinks,
        all_chats: allChats,
        mode: _outMode,
        sources: _audSources,
        intersection: _audIntersect,
        filter: document.getElementById('parser-filter').value,
        hours: parseInt(document.getElementById('parser-hours').value) || 24,
        limit: parseInt(document.getElementById('parser-limit').value) || 20000,
        // Пауза парсера — настройка клиента. 0 = темп задаёт Telegram.
        parser_pause: parseFloat((document.getElementById('parser-pause')||{}).value) || 0,
        link_type: document.getElementById('parser-link-type').value,
        link_source: (document.getElementById('parser-link-source') || {}).value || 'both',
        keywords: _parserMode === 'keyword' ? document.getElementById('parser-keywords').value : '',
        kw_scope: document.getElementById('parser-kw-scope').value,
        kw_collect: document.getElementById('parser-kw-collect').value,
        // New params
        skip_admins: (document.getElementById('parser-skip-admins') || {}).checked || false,
        // ON (дефолт) — ботов нет; OFF — собираем и ботов
        skip_bots: (document.getElementById('parser-skip-bots') || {checked: true}).checked,
        strict_activity: (document.getElementById('parser-strict') || {}).checked || false,
        date_limit_days: _currentDateLimitDays(_parserMode),
        min_messages: parseInt((document.getElementById('parser-min-messages') || {}).value) || 1,
        unwrap_urls: (document.getElementById('parser-unwrap') || {}).checked || false,
        search_query: (document.getElementById('parser-search-query') || {}).value || '',
        // Режим «Как искать» задаёт дробление/фильтр/транслит разом (см. resolve_search_mode).
        search_mode: (document.getElementById('parser-search-mode') || {}).value || 'phrase',
        search_all_accs: (document.getElementById('parser-search-all-accs') || {}).checked || false,
        search_deep: (document.getElementById('parser-search-deep') || {}).checked !== false,
        check_all_accs: (document.getElementById('parser-check-all-accs') || {}).checked !== false,
        search_source: (document.getElementById('parser-search-source') || {}).value || 'telegram',
        min_participants: parseInt((document.getElementById('parser-min-members') || {}).value) || 0,
        // user_chats mode
        target_user: (document.getElementById('parser-target-user') || {}).value || '',
        use_all_accs: (document.getElementById('parser-user-all-accs') || {}).checked || false,
        // keyword_global mode — server-DB + TGStat + SearchGlobal
        keyword_global: (document.getElementById('parser-keyword-global') || {}).value || '',
        // список юзеров для режима «Чекер юзов»
        user_check_list: (document.getElementById('parser-user-check-list') || {}).value || '',
        // Фильтр «оставить только активных за период» в Чекере юзов
        user_check_active_period: (document.getElementById('parser-user-check-period') || {}).value || 'all',
        // Список ссылок для режима «Чекер ссылок»
        link_check_list: (document.getElementById('parser-link-check-list') || {}).value || '',
        kg_all_accs: (document.getElementById('parser-kg-all-accs') || {}).checked !== false,
        kg_lang: (document.getElementById('parser-kg-lang') || {}).value || '',
        kg_region: (document.getElementById('parser-kg-region') || {}).value || '',
        // Токен из поля keyword_global ИЛИ из существующего поля search-mode (если юзер ввёл там раньше)
        tgstat_token: (
            (document.getElementById('parser-kg-tgstat-token') || {}).value
            || (document.getElementById('parser-tgstat-token') || {}).value
            || ''
        ),
        // Расширенные фильтры
        save_mode: (document.getElementById('parser-save-mode') || {}).value || 'usernames',
        roles_filter: (document.getElementById('parser-roles') || {}).value || 'users',
        dm_access: (document.getElementById('parser-dm-access') || {}).value || 'all',
        online_min_hours: parseInt((document.getElementById('parser-online-min') || {}).value) || 0,
        online_max_hours: parseInt((document.getElementById('parser-online-max') || {}).value) || 99999,
        only_avatar: (document.getElementById('parser-only-avatar') || {}).checked || false,
        only_premium: (document.getElementById('parser-only-premium') || {}).checked || false,
        include_poll_voters: (document.getElementById('parser-include-poll-voters') || {}).checked || false,
        recent_only: (document.getElementById('parser-recent-only') || {}).checked || false,
        reaction_filter: (_parserAudienceSel.indexOf('reactions') >= 0 || _parserMode === 'reactions') ? _reactionFilter.slice() : [],
        scan_posts: parseInt((document.getElementById('parser-scan-posts') || {}).value) || 300,
        min_engagement: parseInt((document.getElementById('parser-min-engagement') || {}).value) || 0,
    };

    document.getElementById('btn-parser').disabled = true;
    var _stopBtn = document.getElementById('btn-parser-stop');
    if (_stopBtn) { _stopBtn.style.display = ''; _stopBtn.disabled = false; }
    document.getElementById('parser-progress').style.display = 'block';
    document.getElementById('parser-result').style.display = 'none';
    document.getElementById('parser-bar').style.width = '0%';
    document.getElementById('parser-status-text').textContent = 'Запуск парсинга...';

    API.post('/api/parser/start', data).then(function(d) {
        if (d.ok) {
            showToast('Парсинг запущен!', 'success');
            _parserPollTimer = setInterval(pollParserStatus, 1500);
        } else {
            showToast(d.error || 'Ошибка', 'error');
            document.getElementById('btn-parser').disabled = false;
            if (_stopBtn) _stopBtn.style.display = 'none';
        }
        // Сбрасываем флаг all_chats после запуска — следующий парсинг не должен
        // случайно зацепить все чаты если юзер уже выбрал конкретные.
        _parserAllChats = false;
        var banner = document.getElementById('parser-all-chats-banner');
        if (banner) banner.style.display = 'none';
    }).catch(function(e) {
        showToast((e && (e.message || e.error)) || 'Ошибка', 'error');
        document.getElementById('btn-parser').disabled = false;
        if (_stopBtn) _stopBtn.style.display = 'none';
        _parserAllChats = false;
        var banner2 = document.getElementById('parser-all-chats-banner');
        if (banner2) banner2.style.display = 'none';
    });
}

function stopParser() {
    showConfirmDialog('Остановить парсинг?', 'Уже собранные данные сохранятся.', function() {
        var btn = document.getElementById('btn-parser-stop');
        if (btn) {
            btn.disabled = true;
            btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Останавливаю...';
        }
        API.post('/api/parser/stop', {}).then(function(d) {
            if (d.ok) {
                showToast('Запрос на остановку отправлен', 'info');
            } else {
                showToast(d.error || 'Не удалось остановить', 'error');
                if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-stop-fill"></i> Остановить'; }
            }
        }).catch(function(e) {
            showToast('Ошибка: ' + _errText(e), 'error');
            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-stop-fill"></i> Остановить'; }
        });
    });
}

function forceStopParser() {
    showConfirmDialog('Принудительно сбросить парсинг?', 'Это пометит задачу как остановленную и позволит запустить новую. Фоновый запрос всё равно будет доживать в памяти.', function() {
        API.post('/api/parser/stop', {force: true}).then(function(d) {
            if (d.ok) {
                showToast('Парсинг сброшен', 'info');
                clearInterval(_parserPollTimer);
                var btn = document.getElementById('btn-parser'); if (btn) btn.disabled = false;
                var sb = document.getElementById('btn-parser-stop'); if (sb) sb.style.display = 'none';
                var hint = document.getElementById('parser-stuck-hint'); if (hint) hint.remove();
                var status = document.getElementById('parser-status-text');
                if (status) status.textContent = 'Сброшено';
            } else {
                showToast(d.error || 'Ошибка', 'error');
            }
        });
    });
}

function pollParserStatus() {
    API.get('/api/parser/status').then(function(d) {
        var pct = d.count > 0 ? Math.min(100, Math.round(d.count / 50 * 100)) : (d.scanned > 0 ? Math.min(90, Math.round(d.scanned / 100)) : 5);
        var bar = document.getElementById('parser-bar');
        bar.style.width = pct + '%';
        bar.classList.add('is-running');
        var stext = 'Найдено: ' + d.progress + ' | Просканировано: ' + d.scanned + ' | ' + d.elapsed + 'с';
        if (d.status_text) stext = d.status_text + ' — ' + stext;
        document.getElementById('parser-status-text').textContent = stext;
        // Если за 2 минуты нет прогресса — предлагаем force-reset
        if (d.status === 'running' && d.elapsed > 120 && d.progress === 0 && d.scanned === 0) {
            var hint = document.getElementById('parser-stuck-hint');
            if (!hint) {
                hint = document.createElement('div');
                hint.id = 'parser-stuck-hint';
                hint.style.cssText = 'margin-top:8px;padding:8px 12px;background:var(--surface-danger-bg);border:1px solid var(--surface-danger-border);border-radius:4px;color:var(--danger);font-size:0.78rem;';
                hint.innerHTML = 'Парсинг не двигается уже ' + d.elapsed + 'с. <a href="#" onclick="forceStopParser();return false;" style="color:#fff;text-decoration:underline;">Принудительно сбросить</a>';
                var prog = document.getElementById('parser-progress');
                if (prog) prog.appendChild(hint);
            } else {
                hint.innerHTML = 'Парсинг не двигается уже ' + d.elapsed + 'с. <a href="#" onclick="forceStopParser();return false;" style="color:#fff;text-decoration:underline;">Принудительно сбросить</a>';
            }
        }

        if (d.status === 'finished' || d.status === 'error' || d.status === 'stopped') {
            clearInterval(_parserPollTimer);
            document.getElementById('btn-parser').disabled = false;
            var _sb = document.getElementById('btn-parser-stop');
            if (_sb) {
                _sb.style.display = 'none';
                _sb.disabled = false;
                _sb.innerHTML = '<i class="bi bi-stop-fill"></i> Остановить';
            }

            if (d.status === 'finished') {
                var _pb = document.getElementById('parser-bar');
                _pb.style.width = '100%';
                _pb.classList.remove('is-running');
                // Лейбл по mode: keyword_global/user_chats → чатов, links →
                // ссылок, user_check → проверено, остальное → юзернеймов.
                var _resultNoun = _parserResultNoun(_parserMode);
                // Если 0 результатов И есть объясняющая пометка (реакции только в
                // группах / опросы анонимные / дедуп истории 7 дней) — показываем
                // ПРИЧИНУ, а не «Готово! 0» (юзер думал что парсер сломан).
                var _pNote = (d.stats && d.stats.note) ? d.stats.note
                           : (d.count === 0 && d.error) ? d.error : '';
                if (_pNote && d.count === 0) {
                    document.getElementById('parser-status-text').textContent = _pNote;
                    showToast(_pNote, 'warning');
                } else {
                    // user_check: серверный status_text (активных/валидных/проверено).
                    document.getElementById('parser-status-text').textContent =
                        ((_parserMode === 'user_check' || _parserMode === 'link_check') && d.status_text)
                            ? d.status_text
                            : ('Готово! ' + d.count + ' ' + _resultNoun + ' за ' + d.elapsed + 'с');
                    showToast('Парсинг завершён: ' + d.count + ' ' + _resultNoun, 'success');
                    playSound('success');
                }
                loadParserResult();
            } else if (d.status === 'stopped') {
                var _resultNoun = _parserResultNoun(_parserMode);
                document.getElementById('parser-status-text').textContent = 'Остановлено пользователем. Собрано: ' + d.count + ' ' + _resultNoun;
                showToast('Парсинг остановлен, собрано: ' + d.count, 'info');
                if (d.count > 0) loadParserResult();
            } else {
                showToast('Ошибка парсинга: ' + (d.error || ''), 'error');
                playSound('error');
                document.getElementById('parser-status-text').textContent = 'Ошибка: ' + (d.error || '');
            }
        }
    });
}

// Кэшируем последний результат парсера для chip-фильтрации без повторного API.
var _parserLastResult = null;
var _parserCurrentFilter = 'all';

function _classifyParserItem(item, isUserChats) {
    if (isUserChats && typeof item === 'object') {
        return item.type || 'unknown';  // 'channel' | 'group' | 'dialog'
    }
    var s = String(item || '');
    if (s.startsWith('@')) return 'username';
    if (/^https?:\/\/(?:t\.me|telegram\.me|telegram\.dog)\/\+/.test(s)) return 'invite';
    if (/^https?:\/\/(?:t\.me|telegram\.me|telegram\.dog)\/[^+]/.test(s)) return 'link';
    if (/^\d+$/.test(s)) return 'id';
    return 'other';
}

function _renderParserResultChips(result, isUserChats) {
    var bar = document.getElementById('parser-result-chips');
    if (!bar) return;
    if (!result || result.length === 0) {
        bar.style.display = 'none';
        return;
    }
    // Считаем по категориям
    var counts = {};
    result.forEach(function(it) {
        var k = _classifyParserItem(it, isUserChats);
        counts[k] = (counts[k] || 0) + 1;
    });
    // Если только одна категория — фильтр не нужен.
    var categoryKeys = Object.keys(counts);
    if (categoryKeys.length < 2) {
        bar.style.display = 'none';
        return;
    }
    var labels = isUserChats
        ? { channel: 'Каналы', group: 'Группы', dialog: 'Диалоги', unknown: 'Прочее' }
        : { username: 'Юзернеймы', invite: 'Инвайт-ссылки', link: 'Ссылки', id: 'ID', other: 'Прочее' };
    var order = isUserChats
        ? ['channel', 'group', 'dialog', 'unknown']
        : ['username', 'invite', 'link', 'id', 'other'];
    var chipsHtml = '<span class="multi-folder-chip ' + (_parserCurrentFilter === 'all' ? 'is-active' : '') + '" data-cat="all">' +
                    '<i class="bi bi-grid-fill"></i> Все <b>' + result.length + '</b></span>';
    order.forEach(function(k) {
        if (!counts[k]) return;
        chipsHtml += '<span class="multi-folder-chip ' + (_parserCurrentFilter === k ? 'is-active' : '') + '" data-cat="' + k + '">' +
                     escHtml(labels[k] || k) + ' <b>' + counts[k] + '</b></span>';
    });
    bar.innerHTML = chipsHtml;
    bar.style.display = '';
    bar.querySelectorAll('.multi-folder-chip').forEach(function(chip) {
        chip.onclick = function() {
            bar.querySelectorAll('.multi-folder-chip').forEach(function(c) { c.classList.remove('is-active'); });
            chip.classList.add('is-active');
            _parserCurrentFilter = chip.getAttribute('data-cat');
            _renderParserPreview(_parserLastResult, isUserChats);
        };
    });
}

function _renderParserPreview(result, isUserChats) {
    var preview = document.getElementById('parser-preview');
    if (!preview || !result) return;
    var filtered = _parserCurrentFilter === 'all'
        ? result
        : result.filter(function(it) {
            return _classifyParserItem(it, isUserChats) === _parserCurrentFilter;
          });
    if (isUserChats) {
        var lines = filtered.map(function(c) {
            var link = c.link || (c.username ? 'https://t.me/' + c.username : '(приватный, id=' + (c.chat_id || c.id || '?') + ')');
            var typeIcon = c.type === 'channel' ? '📢' : (c.type === 'group' ? '👥' : '💬');
            var srcTag = '';
            if (c.source === 'tgstat') srcTag = ' [TGStat]';
            else if (c.source === 'telegram') srcTag = ' [TG]';
            var hits = c.hits ? ' ×' + c.hits : '';
            var langTag = '';
            if (c.lang) langTag = ' ' + c.lang;
            if (c.region) langTag += '/' + c.region;
            var scoreTag = (c.ai_score !== undefined && c.ai_score !== null) ? ('[' + c.ai_score + '%] ') : '';
            var reasonTag = (c.ai_reason) ? ('  — ' + c.ai_reason) : '';
            return scoreTag + typeIcon + ' ' + (c.title || c.username || c.chat_id) + ' — ' + link + (c.members ? ' (' + c.members + ')' : '') + srcTag + langTag + hits + reasonTag;
        });
        preview.value = lines.slice(0, 300).join('\n') + (lines.length > 300 ? '\n...(ещё ' + (lines.length - 300) + ')' : '');
    } else {
        // Объектные результаты (user_check: {input,valid,online_status}) рендерим
        // читаемо — иначе массив объектов джойнится как «[object Object]».
        var _lines = filtered.slice(0, 200).map(function(it) {
            if (it && typeof it === 'object') {
                // «?» — проверить не удалось (пауза Telegram, обрыв связи). Это не
                // «не найден»: такие строки выбрасывать из базы нельзя.
                var mark = it.valid ? '✓' : (it.status === 'unknown' ? '?' : '✗');
                var name = it.username ? ('@' + it.username) : (it.input || it.chat_id || '');
                var tail = it.valid
                    ? (it.online_status ? (' — ' + it.online_status) : '')
                    : (it.error ? (' — ' + it.error) : '');
                return mark + ' ' + name + tail;
            }
            return it;
        });
        preview.value = _lines.join('\n') + (filtered.length > 200 ? '\n...(ещё ' + (filtered.length - 200) + ')' : '');
    }
}

// ИИ: тема словами → подбор запросов/ключевиков, заполняет целевое поле.
function aiParserSuggest(btn, topicInputId, fillTargetId, which) {
    var ti = document.getElementById(topicInputId);
    var topic = ti ? ti.value.trim() : '';
    if (!topic) { showToast('Напиши тему словами', 'error'); return; }
    var _orig = btn ? btn.innerHTML : '';
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> ИИ думает…'; }
    var _done = function() { if (btn) { btn.disabled = false; btn.innerHTML = _orig; } };
    API.post('/api/parser/ai/keywords', { topic: topic }).then(function(d) {
        _done();
        if (!d || !d.ok) { showToast((d && d.error) || 'ИИ не ответил', 'error'); return; }
        var vals = (which === 'queries') ? (d.queries || []) : (d.keywords || []);
        if (!vals.length) vals = (d.keywords || []).concat(d.queries || []);
        if (!vals.length) { showToast('ИИ ничего не подобрал', 'error'); return; }
        var tgt = document.getElementById(fillTargetId);
        if (tgt) {
            var ex = (tgt.value || '').trim();
            tgt.value = (ex ? ex + '\n' : '') + vals.join('\n');
        }
        showToast('ИИ подобрал: ' + vals.length, 'success');
    }).catch(function() { _done(); showToast('Ошибка ИИ', 'error'); });
}

// ИИ: ранжирование найденных чатов по релевантности теме (переписывает результат).
function aiRankChats(btn) {
    var ti = document.getElementById('parser-ai-rank-topic');
    var topic = ti ? ti.value.trim() : '';
    if (!topic) { showToast('Напиши тему для оценки', 'error'); return; }
    var _orig = btn ? btn.innerHTML : '';
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> ИИ ранжирует…'; }
    var _done = function() { if (btn) { btn.disabled = false; btn.innerHTML = _orig; } };
    API.post('/api/parser/ai/rank_chats', { topic: topic }).then(function(d) {
        _done();
        if (!d || !d.ok) { showToast((d && d.error) || 'ИИ не ответил', 'error'); return; }
        showToast('ИИ оценил чатов: ' + (d.scored || 0), 'success');
        loadParserResult();
    }).catch(function() { _done(); showToast('Ошибка ИИ', 'error'); });
}

// ── Проверка текста рассылки: стоп-слова (локально) + ИИ-риск ──────────────
function spamLoadStopwords() {
    API.get('/api/spam/stopwords').then(function(d) {
        if (d && d.words) {
            var ta = document.getElementById('spam-stopwords');
            if (ta && !ta.value.trim()) ta.value = d.words.join('\n');
        }
    }).catch(function() {});
}

function spamSaveStopwords(btn) {
    var ta = document.getElementById('spam-stopwords');
    API.post('/api/spam/stopwords', { words: ta ? ta.value : '' }).then(function(d) {
        if (d && d.ok) showToast('Список сохранён: ' + (d.count || 0), 'success');
        else showToast('Не удалось сохранить', 'error');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function _spamTextCheckShow(html) {
    var box = document.getElementById('spam-textcheck-result');
    if (box) { box.innerHTML = html; box.style.display = 'block'; }
}

function spamCheckStopwords() {
    var txt = (document.getElementById('spam-tpl') || {}).value || '';
    var listRaw = (document.getElementById('spam-stopwords') || {}).value || '';
    var words = listRaw.split('\n').map(function(w) { return w.trim(); }).filter(Boolean);
    if (!txt.trim()) { showToast('Пустой текст рассылки', 'error'); return; }
    if (!words.length) { showToast('Список стоп-слов пуст', 'error'); return; }
    var low = txt.toLowerCase();
    var found = words.filter(function(w) { return low.indexOf(w.toLowerCase()) !== -1; });
    if (!found.length) {
        _spamTextCheckShow('<span style="color:var(--success);"><i class="bi bi-check-circle"></i> Стоп-слов из списка в тексте нет.</span>');
    } else {
        _spamTextCheckShow('<b style="color:var(--danger);">Нашлись стоп-слова (' + found.length + '):</b> ' +
            found.map(function(w) { return '<span style="color:var(--danger);">' + escHtml(w) + '</span>'; }).join(', '));
    }
}

function spamCheckTextAI(btn) {
    var txt = (document.getElementById('spam-tpl') || {}).value || '';
    if (!txt.trim()) { showToast('Пустой текст рассылки', 'error'); return; }
    var _orig = btn ? btn.innerHTML : '';
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> ИИ проверяет…'; }
    var _done = function() { if (btn) { btn.disabled = false; btn.innerHTML = _orig; } };
    API.post('/api/spam/ai/check_text', { text: txt }).then(function(d) {
        _done();
        if (!d || !d.ok) { showToast((d && d.error) || 'ИИ не ответил', 'error'); return; }
        var risky = d.risky || [];
        if (!risky.length) {
            _spamTextCheckShow('<span style="color:var(--success);"><i class="bi bi-check-circle"></i> ' + escHtml(d.note || 'ИИ рисков не нашёл.') + '</span>');
        } else {
            var rows = risky.map(function(r) {
                return '<div style="margin-top:3px;"><span style="color:var(--danger);font-weight:600;">' + escHtml(r.term) + '</span>' +
                       (r.why ? ' <span style="color:var(--text-muted);">— ' + escHtml(r.why) + '</span>' : '') + '</div>';
            }).join('');
            _spamTextCheckShow('<b style="color:var(--danger);">ИИ отметил рискованное (' + risky.length + '):</b>' + rows +
                (d.note ? '<div style="margin-top:5px;color:var(--text-muted);">' + escHtml(d.note) + '</div>' : ''));
        }
    }).catch(function() { _done(); showToast('Ошибка ИИ', 'error'); });
}

// Лейбл результата по режиму парсера (чаты / ссылки / юзернеймы / проверено).
function _parserResultNoun(mode) {
    if (mode === 'keyword_global' || mode === 'user_chats' || mode === 'search') return 'чатов';
    if (mode === 'links') return 'ссылок';
    if (mode === 'user_check') return 'проверено';
    if (mode === 'link_check') return 'ссылок';
    return 'юзернеймов';  // members, writers, keyword
}

// Читаемый рендер результата «Чекер ссылок» (объекты, не строки — иначе
// общий превью отдаёт [object Object]).
function _renderLinkCheckResult(result) {
    var validN = result.filter(function(r){ return r && r.valid; }).length;
    var cnt = document.getElementById('parser-count');
    if (cnt) cnt.textContent = validN + ' валидных из ' + result.length;
    var cb = document.getElementById('parser-result-chips');
    if (cb) cb.style.display = 'none';
    var lines = result.map(function(r) {
        var mark = r.valid ? '✓' : '✗';
        var t = r.type_human || r.type || '';
        if (r.valid) {
            var title = r.title || r.username || r.input;
            var link = r.username ? ('t.me/' + r.username) : r.input;
            var mem = (r.members && r.members > 0) ? (' · ' + Number(r.members).toLocaleString() + ' подп.') : '';
            var geo = r.geo ? (' · гео: ' + r.geo) : '';
            return mark + ' [' + t + '] ' + title + ' — ' + link + mem + geo;
        }
        return mark + ' [' + t + '] ' + r.input + (r.error ? (' — ' + r.error) : '');
    });
    var pv = document.getElementById('parser-preview');
    if (pv) pv.value = lines.slice(0, 400).join('\n') + (lines.length > 400 ? ('\n...(ещё ' + (lines.length - 400) + ')') : '');
}

function loadParserResult() {
    API.get('/api/parser/result').then(function(d) {
        var result = d.usernames || [];
        _parserLastResult = result;
        _parserCurrentFilter = 'all';
        document.getElementById('parser-result').style.display = 'block';

        // Режим user_chats/keyword_global — результат это список чатов (dicts с link/title)
        var isUserChats = result.length > 0 && typeof result[0] === 'object' && result[0] !== null && ('link' in result[0] || 'chat_id' in result[0]);
        // ИИ-ранжирование доступно только для результата-чатов.
        var _rankRow = document.getElementById('parser-ai-rank-row');
        if (_rankRow) _rankRow.style.display = (isUserChats && result.length) ? 'flex' : 'none';
        // user_check — список объектов с input/valid — отдельный лейбл «проверено»
        // link_check — объекты с type_human (валидность ссылок). Проверяем ДО
        // isUserCheck: у link-объектов тоже есть 'valid'.
        var isLinkCheck = result.length > 0 && typeof result[0] === 'object' && result[0] !== null && ('type_human' in result[0]);
        var isUserCheck = !isLinkCheck && result.length > 0 && typeof result[0] === 'object' && result[0] !== null && ('valid' in result[0] || 'online_status' in result[0]);
        if (isLinkCheck) {
            _renderLinkCheckResult(result);
        } else if (isUserCheck) {
            var _validCount = result.filter(function(r){return r.valid;}).length;
            document.getElementById('parser-count').textContent = _validCount + ' валидных из ' + result.length;
            _renderParserResultChips(result, false);
            _renderParserPreview(result, false);
        } else if (isUserChats) {
            document.getElementById('parser-count').textContent = result.length + ' чатов';
            _renderParserResultChips(result, true);
            _renderParserPreview(result, true);
        } else {
            // Если в списке есть raw ID (строки из цифр без @) — показать разбивку
            var idCount = 0;
            for (var i = 0; i < result.length; i++) {
                var s = result[i];
                if (typeof s === 'string' && s && s[0] !== '@' && /^\d+$/.test(s)) idCount++;
            }
            var unameCount = result.length - idCount;
            var _noun = _parserResultNoun(_parserMode);
            var label;
            if (idCount > 0 && unameCount > 0) label = unameCount + ' ' + _noun + ' + ' + idCount + ' ID';
            else if (idCount > 0 && unameCount === 0) label = idCount + ' ID (без юзернеймов)';
            else label = result.length + ' ' + _noun;
            document.getElementById('parser-count').textContent = label;
            _renderParserResultChips(result, false);
            _renderParserPreview(result, false);
        }

        // Stats
        API.get('/api/parser/status').then(function(s) {
            var stats = s.stats || {};
            // user_check: чип из серверной сводки. После фильтра по периоду в
            // result остаются только активные — тоталы проверено/валидных в uc_summary.
            if (isUserCheck && s.uc_summary) {
                var _u = s.uc_summary;
                document.getElementById('parser-count').textContent = _u.filtered
                    ? (_u.active + ' активных' + (_u.period ? ' (' + _u.period + ')' : '') + ' · ' + _u.checked + ' проверено')
                    : (_u.valid + ' валидных из ' + _u.checked);
            }
            var parts = [];
            if (stats.target_user) parts.push('Юзер: ' + (stats.target_username ? '@' + stats.target_username : stats.target_user) + (stats.target_name ? ' (' + stats.target_name.trim() + ')' : ''));
            if (stats.accounts_used) parts.push('Аккаунтов: ' + stats.accounts_used);
            if (stats.scanned) parts.push('Просканировано: ' + stats.scanned);
            if (stats.scanned_msgs) parts.push('Сообщений: ' + stats.scanned_msgs);
            if (stats.spam_skipped) parts.push('Спам-блок: ' + stats.spam_skipped);
            if (stats.ids_saved) parts.push('Сохранено по ID: ' + stats.ids_saved);
            if (stats.no_username) parts.push('Без юзернейма (пропущено): ' + stats.no_username);
            if (stats.filtered) parts.push('Отфильтровано: ' + stats.filtered);
            // Счётчик приватных/недоступных чатов (не ошибка — нормально для мульти-парсинга)
            if (stats.skipped_private) parts.push('<i class="bi bi-lock-fill"></i> Приватных/недоступных: ' + stats.skipped_private);
            // Keyword режим — диагностика чтоб юзер видел куда делись чаты
            if (stats.dialogs_total !== undefined && stats.keywords !== undefined) {
                parts.push('Диалогов: ' + stats.dialogs_total);
                parts.push('Подходят: ' + (stats.dialogs_after_scope || 0));
                if (stats.chats_searched !== undefined) parts.push('Обработано: ' + stats.chats_searched);
                if (stats.messages_found !== undefined) parts.push('Найдено сообщений: ' + stats.messages_found);
            }
            // keyword_global — итог
            if (stats.keyword && (stats.server_count !== undefined || stats.tgstat_count !== undefined || stats.telegram_count !== undefined)) {
                parts.push('<i class="bi bi-search"></i> «' + stats.keyword + '»');
                if (stats.chats_found !== undefined) parts.push('Найдено: ' + stats.chats_found);
            }
            document.getElementById('parser-stats').textContent = parts.join(' | ');

            // Подсказка если Telegram ограничивает (выводим отдельной строкой под статой)
            var hintEl = document.getElementById('parser-api-hint');
            if (stats.hint) {
                if (!hintEl) {
                    hintEl = document.createElement('div');
                    hintEl.id = 'parser-api-hint';
                    hintEl.style.cssText = 'margin-top:8px;padding:10px;background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.35);border-radius:5px;color:#fbbf24;font-size:13px;line-height:1.45;';
                    var statsEl = document.getElementById('parser-stats');
                    if (statsEl && statsEl.parentNode) statsEl.parentNode.insertBefore(hintEl, statsEl.nextSibling);
                }
                hintEl.textContent = stats.hint;
            } else if (hintEl) {
                hintEl.remove();
            }
        });
    });
}

function exportParser(fmt) {
    fmt = fmt || 'txt';
    showToast('Сохраняю ' + fmt + '...', 'info');
    API.get('/api/parser/export?fmt=' + fmt).then(function(d) {
        if (d && d.ok) {
            var name = (d.path || '').split(/[\\/]/).pop();
            showToast('Сохранено: ' + name + ' (' + d.count + ')', 'success');
            if (typeof addLog === 'function') addLog('Файл сохранён: ' + d.path, 'SUCCESS');
            // Предложим открыть папку
            setTimeout(function(){
                appConfirm('Открыть папку «Экспорт»?\n\n' + (d.folder || ''), {
                    title: 'Файл сохранён',
                    icon: 'bi-folder2-open',
                    okLabel: 'Открыть',
                }).then(function(yes){
                    if (yes) API.post('/api/parser/open_export_folder', {}).catch(function(){});
                });
            }, 300);
        } else {
            showToast((d && d.error) || 'Ошибка экспорта', 'error');
        }
    }).catch(function(e) {
        var msg = (e && (e.error || e.message)) ? (e.error || e.message) : 'Ошибка';
        showToast('Ошибка экспорта: ' + msg, 'error');
    });
}

function openExportFolder() {
    API.post('/api/parser/open_export_folder', {}).then(function(d) {
        if (!d.ok) showToast(d.error || 'Не удалось открыть папку', 'error');
    });
}

function copyParserPreview() {
    // Берём полный результат через /api/parser/result чтобы не ограничивать превью
    API.get('/api/parser/result').then(function(d) {
        var arr = d && (d.usernames || d.result || []);
        if (!arr.length) {
            var preview = (document.getElementById('parser-preview') || {}).value || '';
            copyToClipboard(preview, 'Скопировано из превью');
            return;
        }
        // Уважаем активный чип-фильтр (Все/Каналы/Группы/…): если юзер снял
        // каналы кнопкой «Группы», в буфер должны уйти только группы, а не всё.
        var isUserChats = typeof arr[0] === 'object' && arr[0] !== null
            && ('link' in arr[0] || 'chat_id' in arr[0]);
        var filtered = (_parserCurrentFilter === 'all')
            ? arr
            : arr.filter(function(it) {
                return _classifyParserItem(it, isUserChats) === _parserCurrentFilter;
              });
        var lines = filtered.map(function(x) {
            if (typeof x === 'string') return x;
            if (x.link) return x.link;
            if (x.username) return '@' + x.username;
            return String(x);
        });
        var label = (_parserCurrentFilter === 'all')
            ? 'Результат парсера скопирован'
            : 'Скопировано (фильтр: ' + _parserFilterLabel(_parserCurrentFilter, isUserChats) + ')';
        copyToClipboard(lines.join('\n'), label);
    }).catch(function() {
        var preview = (document.getElementById('parser-preview') || {}).value || '';
        copyToClipboard(preview, 'Скопировано');
    });
}

// Человеческий лейбл активной категории для тоста копирования.
function _parserFilterLabel(cat, isUserChats) {
    var labels = isUserChats
        ? { channel: 'Каналы', group: 'Группы', dialog: 'Диалоги', unknown: 'Прочее' }
        : { username: 'Юзернеймы', invite: 'Инвайт-ссылки', link: 'Ссылки', id: 'ID', other: 'Прочее' };
    return labels[cat] || cat;
}

// Категоризированный экспорт — раскладывает результат по подпапке
// Парсинг_<timestamp>/ с отдельными .txt на каждую категорию.
function exportParserByFolders() {
    showToast('Раскладываю по категориям...', 'info');
    API.get('/api/parser/export_folders').then(function(d) {
        if (d && d.ok) {
            var parts = [];
            var sum = d.summary || {};
            for (var k in sum) parts.push(k + ': ' + sum[k]);
            var msg = 'Готово. ' + (parts.join('  •  ') || ('Всего: ' + d.total));
            showToast(msg, 'success');
            if (typeof addLog === 'function') addLog('Экспорт: ' + (d.folder || '') + '  (' + parts.join(', ') + ')', 'SUCCESS');
        } else {
            showToast((d && d.error) || 'Ошибка', 'error');
        }
    }).catch(function(e) {
        showToast(_errText(e) || 'Ошибка', 'error');
    });
}

function applyParserPreset(preset) {
    if (preset === 'basic') {
        var el;
        el = document.getElementById('parser-links-period'); if (el) el.value = '7';
        el = document.getElementById('parser-link-type'); if (el) el.value = 'all';
        el = document.getElementById('parser-unwrap'); if (el) el.checked = false;
        el = document.getElementById('parser-limit'); if (el) el.value = 20000;
        showToast('Базовый пресет: 7 дней, без URL unwrap', 'info');
    } else if (preset === 'full') {
        var el;
        el = document.getElementById('parser-links-period'); if (el) el.value = '0';
        el = document.getElementById('parser-link-type'); if (el) el.value = 'all';
        el = document.getElementById('parser-unwrap'); if (el) el.checked = true;
        el = document.getElementById('parser-limit'); if (el) el.value = 10000;
        showToast('Полный пресет: все дни, unwrap вкл', 'info');
    } else if (preset === 'invites') {
        var el;
        el = document.getElementById('parser-links-period'); if (el) el.value = '0';
        el = document.getElementById('parser-link-type'); if (el) el.value = 'invites';
        el = document.getElementById('parser-unwrap'); if (el) el.checked = true;
        el = document.getElementById('parser-limit'); if (el) el.value = 5000;
        showToast('Пресет инвайтов: только invite-ссылки', 'info');
    }
}

function useParserForDm() {
    API.post('/api/parser/use_for_dm').then(function(d) {
        if (d.ok) {
            // После добавления — переключаем на вкладку «По ЛС» и заполняем
            // textarea списком таргетов (раньше был только toast).
            var ta = document.getElementById('dm-targets-input');
            if (ta && Array.isArray(d.targets)) {
                ta.value = d.targets.join('\n');
                ta.dispatchEvent(new Event('input'));
            }
            showToast('Добавлено ' + d.added + ' в Рассылку в ЛС → переключаю на вкладку', 'success');
            // ЗАЩИТА — показываем когда отсеяли свои акки фермы из парсера
            if (d.skipped_own_fleet) {
                var _ex = (d.skipped_examples || []).join(', ');
                showToast('Защита: из парсера отсеяно ' + d.skipped_own_fleet + ' твоих акков фермы (' + _ex + ') — DM на свои = бан', 'warning');
            }
            // Переключение на ЛС через 600мс — чтобы юзер увидел toast
            setTimeout(function() {
                if (typeof showTab === 'function') showTab('dm');
            }, 600);
        } else {
            showToast(d.error || 'Не удалось добавить в Рассылку ЛС', 'error');
        }
    });
}

function useParserForJoin() {
    // Уважаем активный чип-фильтр (Все/Каналы/Группы) — как «Копировать».
    API.post('/api/parser/use_for_join', {filter: _parserCurrentFilter}).then(function(d) {
        if (!d || !d.ok) { showToast((d && d.error) || 'Ошибка', 'error'); return; }
        var links = d.links || [];
        if (!links.length) {
            showToast('В этом результате нет ссылок на чаты — похоже, это список людей. Им подойдёт кнопка «В ЛС».', 'warning');
            return;
        }
        var ta = document.getElementById('join-links');
        if (!ta) { showToast('Не нашёл поле ссылок на вкладке «Подписка»', 'error'); return; }
        // Поле — единственный владелец списка. Дописываем к тому, что уже есть,
        // и не плодим дубли: ключ — сам таргет без хвоста « — Название».
        var have = (ta.value || '').split('\n')
            .map(function(s) { return s.trim(); })
            .filter(function(s) { return s.length > 0; });
        var seen = {};
        have.forEach(function(l) { seen[l.split(' — ')[0].trim().toLowerCase()] = 1; });
        var added = 0;
        links.forEach(function(l) {
            var k = String(l).trim().toLowerCase();
            if (!k || seen[k]) return;
            seen[k] = 1;
            have.push(l);
            added++;
        });
        ta.value = have.join('\n');
        ta.dispatchEvent(new Event('input'));
        try { updateJoinCount(); } catch(_) {}
        // Сервер должен видеть ровно тот список, который видит человек.
        API.post('/api/join/load', {text: ta.value});
        showToast(added
            ? ('В подписку добавлено ' + added + ' ссылок (в списке: ' + have.length + ') → открываю вкладку')
            : ('Все ' + links.length + ' ссылок уже в списке подписки → открываю вкладку'),
            added ? 'success' : 'info');
        setTimeout(function() {
            if (typeof showTab === 'function') showTab('join');
        }, 600);
    }).catch(function() { showToast('Не удалось передать ссылки в подписку', 'error'); });
}

// ══════════════════════════════════════════════════════════════════
//  ANALYTICS — аналитика
// ══════════════════════════════════════════════════════════════════

var _chart = null;

function loadAnalytics() {
    API.get('/api/analytics').then(function(d) {
        if (!d.ok) return;
        // Update stats
        var el;
        el = document.getElementById('stat-sent'); if (el) el.textContent = d.totals.spam || 0;
        el = document.getElementById('stat-joined'); if (el) el.textContent = d.totals.joins || 0;
        el = document.getElementById('stat-blocked'); if (el) el.textContent = d.totals.blocked || 0;
        el = document.getElementById('stat-errors'); if (el) el.textContent = d.totals.errors || 0;

        // Chart
        if (typeof Chart === 'undefined') return;
        var canvas = document.getElementById('analytics-chart');
        if (!canvas) return;

        var days = d.days || [];
        var labels = days.map(function(_, i) { return (7 - i) + ' дней назад'; });
        labels[labels.length - 1] = 'Сегодня';
        var spamData = days.map(function(day) { return day.spam; });
        var joinData = days.map(function(day) { return day.joins; });

        if (_chart) _chart.destroy();
        _chart = new Chart(canvas.getContext('2d'), {
            type: 'bar',
            data: {
                labels: labels,
                datasets: [
                    { label: 'Отправлено', data: spamData, backgroundColor: '#3b82f6', borderRadius: 4 },
                    { label: 'Вступлений', data: joinData, backgroundColor: '#22c55e', borderRadius: 4 },
                ],
            },
            options: {
                responsive: true,
                plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } },
                scales: {
                    x: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { color: '#1e2433' } },
                    y: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { color: '#1e2433' }, beginAtZero: true },
                },
            },
        });
    });
}

function exportExcel() {
    window.location.href = '/api/analytics/export';
    showToast('Скачиваю Excel отчёт...', 'info');
}

// ══════════════════════════════════════════════════════════════════
//  SETTINGS — настройки
// ══════════════════════════════════════════════════════════════════

// ── Прогрев: подсказка при выборе режима генерации ─────────────────────
function onWarmupGenModeChange() {
    var sel = document.getElementById('warmup-gen-mode');
    var hint = document.getElementById('warmup-gen-mode-hint');
    if (!sel || !hint) return;
    var mode = sel.value;
    if (mode === 'ai') {
        // Проверяем что AI настроен
        fetch('/api/ai/get').then(function(r){return r.json()}).then(function(d) {
            var enabled = d && d.status && d.status.enabled;
            if (enabled) {
                hint.innerHTML = '<span style="color:var(--success);"><i class="bi bi-check-circle-fill"></i> AI настроен (' + (d.status.model || '') + '). Прогрев будет писать через нейросеть.</span>';
            } else {
                hint.innerHTML = '<span style="color:var(--warning);"><i class="bi bi-exclamation-triangle-fill"></i> AI не настроен. <a href="javascript:showTab(\'settings\')" style="color:var(--accent-light);">Настройки → AI</a> — введи API ключ. Без ключа прогрев упадёт на шаблоны автоматически.</span>';
            }
        });
    } else {
        hint.innerHTML = 'Прогрев пишет из готового набора фраз. AI не используется, $ не тратятся.';
    }
}

// ── Нейрокомменты ──────────────────────────────────────────────────────
var _ncTimer = null;

function ncStart() {
    var channels = (document.getElementById('nc-channels') || {}).value || '';
    if (!channels.trim()) { showToast('Укажи каналы', 'warning'); return; }
    // Собираем выбранных аккаунтов из selector (multi)
    // Собираем выбранных: режим multi → _accSelectorPicked, режим one → _cachedActive,
    // режим all → все живые из _cachedAccounts.
    var phones = [];
    var selId = 'acc-selector-neuro-comments';
    var mode = (window._accSelectorMode || {})[selId] || 'one';
    if (mode === 'multi' && (window._accSelectorPicked || {})[selId]) {
        phones = (window._accSelectorPicked[selId] || []).slice();
    } else if (mode === 'all') {
        phones = (_cachedAccounts || [])
            .filter(function(a){ return !a.dead && !a.frozen; })
            .map(function(a){ return a.phone; });
    } else if (_cachedActive) {
        phones = [_cachedActive];
    }
    if (!phones.length) { showToast('Выбери хотя бы 1 аккаунт', 'warning'); return; }
    var payload = {
        channels: channels,
        phones: phones,
        cta: (document.getElementById('nc-cta') || {}).value || '',
        custom_prompt: (document.getElementById('nc-custom-prompt') || {}).value || '',
        poll_interval_min: +((document.getElementById('nc-poll-interval') || {}).value || 5),
        recency_min: Math.max(1, Math.round(+((document.getElementById('nc-recency-sec') || {}).value || 3600) / 60)),
        delay_between_acc_min: Math.max(1, Math.round(+((document.getElementById('ncomm-delay-sec') || {}).value || 120) / 60)),
        delay_between_acc_max: Math.max(2, Math.round(+((document.getElementById('ncomm-delay-sec-max') || {}).value || 900) / 60)),
        max_comments_per_post: +((document.getElementById('nc-per-post') || {}).value || 1),
        max_per_acc_per_hour: +((document.getElementById('nc-per-hour') || {}).value || 5),
        max_per_acc_per_day: +((document.getElementById('nc-per-day') || {}).value || 30),
        ad_config: readAdConfig('nc'),
    };
    fetch('/api/neuro_comments/start', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload)
    }).then(function(r){return r.json()}).then(function(d) {
        if (d.ok) {
            showToast('Нейрокомменты запущены: ' + d.channels + ' каналов, ' + d.accounts + ' акков', 'success');
            document.getElementById('btn-ncomm-start').style.display = 'none';
            document.getElementById('btn-ncomm-stop').style.display = '';
            document.getElementById('ncomm-counters').style.display = '';
            (document.getElementById('ncomm-log') || {style:{}}).style.display = '';
            if (_ncTimer) clearInterval(_ncTimer);
            _ncTimer = setInterval(ncPoll, 4000);
            ncPoll();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    });
}

function ncStop() {
    fetch('/api/neuro_comments/stop', {method: 'POST'}).then(function(r){return r.json()}).then(function() {
        showToast('Останавливаю...', 'info');
    });
}

function ncPoll() {
    fetch('/api/neuro_comments/status').then(function(r){return r.json()}).then(function(d) {
        var lbl = document.getElementById('ncomm-status-label');
        if (lbl) {
            lbl.textContent = (d.status === 'running') ? '● Работает' :
                              (d.status === 'stopped' ? 'Остановлено' : 'Не запущен');
            lbl.style.color = d.status === 'running' ? 'var(--success)' :
                              (d.status === 'stopped' ? 'var(--warning)' : 'var(--text-muted)');
        }
        if (d.status !== 'running' && _ncTimer) {
            clearInterval(_ncTimer); _ncTimer = null;
            document.getElementById('btn-ncomm-start').style.display = '';
            document.getElementById('btn-ncomm-stop').style.display = 'none';
        }
        var c = d.counts || {};
        var counters = document.getElementById('ncomm-counters');
        if (counters) {
            counters.innerHTML =
                '<span style="color:var(--success);">✓ Прокомментировано: <b>' + (c.commented || 0) + '</b></span> · ' +
                '<span>Пропущено (уже комментили): ' + (c.skipped_already || 0) + '</span> · ' +
                '<span>Без свободных акков: ' + (c.skipped_no_acc || 0) + '</span> · ' +
                '<span style="color:var(--danger);">Ошибки: ' + (c.errors || 0) + '</span>';
        }
        var logEl = document.getElementById('ncomm-log');
        if (logEl && d.log) {
            logEl.innerHTML = d.log.map(function(l) {
                return '<div>' + l.replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</div>';
            }).join('');
            logEl.scrollTop = logEl.scrollHeight;
        }
    });
}

// ── Нейро-чаттер ───────────────────────────────────────────────────────
var _chatterTimer = null;

function _chatterSelectedPhones() {
    var selId = 'acc-selector-chatter';
    var mode = (window._accSelectorMode || {})[selId] || 'one';
    if (mode === 'multi' && (window._accSelectorPicked || {})[selId]) {
        return (window._accSelectorPicked[selId] || []).slice();
    }
    if (mode === 'all') {
        return (_cachedAccounts || []).filter(function(a){ return !a.dead && !a.frozen; })
            .map(function(a){ return a.phone; });
    }
    return _cachedActive ? [_cachedActive] : [];
}

function loadChatterConfig() {
    // Восстанавливаем сохранённый конфиг чаттера (раньше был write-only —
    // сохранялся, но при заходе поля были пустые).
    API.get('/api/chatter/config').then(function(c) {
        if (!c) return;
        function _sv(id, v) { var e = document.getElementById(id); if (e && v !== undefined && v !== null) e.value = v; }
        _sv('chatter-targets', (c.seed_targets || []).join('\n'));
        _sv('chatter-topics', (c.topics || []).join('\n'));
        _sv('chatter-persona', c.persona || '');
        _sv('chatter-prompt', c.custom_prompt || '');
        var se = document.getElementById('chatter-seed-enabled');
        if (se && typeof c.seed_enabled === 'boolean') se.checked = c.seed_enabled;
    }).catch(function(){});
}

function startChatter() {
    var phones = _chatterSelectedPhones();
    if (!phones.length) { showToast('Выбери хотя бы 1 аккаунт', 'warning'); return; }
    var _v = function(id, d) { var e = document.getElementById(id); return e ? e.value : d; };
    var _c = function(id) { var e = document.getElementById(id); return e ? e.checked : false; };
    var payload = {
        phones: phones,
        seed_targets: _v('chatter-targets', ''),
        topics: _v('chatter-topics', ''),
        persona: _v('chatter-persona', ''),
        custom_prompt: _v('chatter-prompt', ''),
        seed_enabled: _c('chatter-seed-enabled'),
        seed_per_acc_per_day: +(_v('chatter-seed-day', 8)),
        // UI в секундах (единая единица), движок чаттера считает в минутах —
        // переводим тут, ключи и границы бэкенда не трогаем.
        seed_interval_min: Math.max(1, Math.round(+(_v('chatter-seed-sec', 1200)) / 60)),
        seed_interval_max: Math.max(2, Math.round(+(_v('chatter-seed-sec-max', 5400)) / 60)),
        max_replies_per_acc_per_day: +(_v('chatter-reply-day', 30)),
        max_replies_per_acc_per_hour: +(_v('chatter-reply-hour', 6)),
        max_msgs_per_dialog_per_day: +(_v('chatter-dialog-day', 4)),
        use_typing: _c('chatter-typing'),
        use_quote_reply: _c('chatter-quote'),
        no_links: _c('chatter-nolinks')
    };
    fetch('/api/chatter/start', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload)
    }).then(function(r){return r.json()}).then(function(d) {
        if (d.ok) {
            var m = 'Чаттер запущен: ' + d.accounts + ' акк';
            if (d.targets) m += ', целей ' + d.targets;
            if (d.busy && d.busy.length) m += ' (занятых пропущено: ' + d.busy.length + ')';
            showToast(m, 'success');
            document.getElementById('btn-chatter-start').style.display = 'none';
            document.getElementById('btn-chatter-stop').style.display = '';
            document.getElementById('chatter-counters').style.display = '';
            (document.getElementById('chatter-log') || {style:{}}).style.display = '';
            if (_chatterTimer) clearInterval(_chatterTimer);
            _chatterTimer = setInterval(chatterPoll, 4000);
            chatterPoll();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(){ showToast('Ошибка сети', 'error'); });
}

function stopChatter() {
    fetch('/api/chatter/stop', {method: 'POST'}).then(function(r){return r.json()}).then(function() {
        showToast('Останавливаю чаттер...', 'info');
    });
}

function chatterPoll() {
    fetch('/api/chatter/status').then(function(r){return r.json()}).then(function(d) {
        var lbl = document.getElementById('chatter-status-label');
        if (lbl) {
            lbl.textContent = d.running ? '● Работает' :
                              (d.status === 'stopped' ? 'Остановлено' : 'Не запущен');
            lbl.style.color = d.running ? 'var(--success)' :
                              (d.status === 'stopped' ? 'var(--warning)' : 'var(--text-muted)');
        }
        if (d.running) {
            // синк UI при открытии вкладки во время работы (перезагрузка/смена таба)
            document.getElementById('btn-chatter-start').style.display = 'none';
            document.getElementById('btn-chatter-stop').style.display = '';
            document.getElementById('chatter-counters').style.display = '';
            (document.getElementById('chatter-log') || {style:{}}).style.display = '';
            if (!_chatterTimer) _chatterTimer = setInterval(chatterPoll, 4000);
        } else if (_chatterTimer) {
            clearInterval(_chatterTimer); _chatterTimer = null;
            document.getElementById('btn-chatter-start').style.display = '';
            document.getElementById('btn-chatter-stop').style.display = 'none';
        }
        var c = d.counts || {};
        var counters = document.getElementById('chatter-counters');
        if (counters) {
            counters.innerHTML =
                '<span style="color:var(--success);">Посеяно диалогов: <b>' + (c.seeded || 0) + '</b></span> · ' +
                '<span style="color:var(--success);">Ответов: <b>' + (c.replies || 0) + '</b></span> · ' +
                '<span>Лимит: ' + (c.skipped_cap || 0) + '</span> · ' +
                '<span>FloodWait: ' + (c.floodwait || 0) + '</span> · ' +
                '<span style="color:var(--danger);">Спам-блок: ' + (c.spamblock || 0) + '</span>';
        }
        var logEl = document.getElementById('chatter-log');
        if (logEl && d.log) {
            logEl.innerHTML = d.log.map(function(l) {
                return '<div>' + l.replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</div>';
            }).join('');
            logEl.scrollTop = logEl.scrollHeight;
        }
    }).catch(function(){});
}

// ── AI settings ─────────────────────────────────────────────────────────
var _aiProviders = {};

function aiLoad() {
    fetch('/api/ai/get').then(function(r){ return r.json(); }).then(function(d) {
        if (!d.ok) return;
        _aiProviders = d.providers || {};
        var s = d.settings || {};
        // Заполняем dropdown провайдеров
        var sel = document.getElementById('ai-provider');
        if (sel) {
            sel.innerHTML = '';
            Object.keys(_aiProviders).forEach(function(k) {
                var opt = document.createElement('option');
                opt.value = k;
                opt.textContent = _aiProviders[k].label;
                if (k === (s.provider || 'openrouter')) opt.selected = true;
                sel.appendChild(opt);
            });
        }
        onAiProviderChange();
        if (s.url) document.getElementById('ai-url').value = s.url;
        if (s.model) {
            var mSel = document.getElementById('ai-model');
            // Если модели нет в списке провайдера — добавим как custom
            var found = false;
            for (var i = 0; i < mSel.options.length; i++) {
                if (mSel.options[i].value === s.model) { mSel.value = s.model; found = true; break; }
            }
            if (!found && s.model) {
                var opt = document.createElement('option');
                opt.value = s.model; opt.textContent = s.model;
                mSel.appendChild(opt); mSel.value = s.model;
            }
        }
        document.getElementById('ai-budget-usd').value = s.daily_limit_usd || 0;
        document.getElementById('ai-budget-req').value = s.max_requests || 0;
        document.getElementById('ai-dialog-topics').value = s.dialog_topics || '';
        document.getElementById('ai-custom-dialog-prompt').value = s.custom_dialog_prompt || '';
        document.getElementById('ai-custom-comment-prompt').value = s.custom_comment_prompt || '';
        document.getElementById('ai-comment-cta').value = s.comment_cta || '';
        document.getElementById('ai-enabled').checked = !!s.enabled;
        var retryEl = document.getElementById('ai-retry-on-error');
        if (retryEl) retryEl.checked = !!s.retry_on_error;
        var hasKey = document.getElementById('ai-has-key-badge');
        if (hasKey) hasKey.style.display = s.has_key ? '' : 'none';
        // Status badge + spent info
        var status = d.status || {};
        var badge = document.getElementById('ai-status-badge');
        if (badge) {
            if (status.enabled) {
                badge.className = 'badge-soft badge-soft--success';
                badge.textContent = 'активно: ' + (status.model || '');
            } else {
                badge.className = 'badge-soft badge-soft--muted';
                badge.textContent = 'не настроено';
            }
        }
        var spent = document.getElementById('ai-spent-info');
        if (spent) {
            if (status.enabled) {
                var todayLine = 'Сегодня: $' + (status.spent_usd_today || 0).toFixed(4) +
                    ' (' + (status.requests_today || 0) + ' запросов)' +
                    (status.daily_limit_usd > 0 ? ' / лимит $' + status.daily_limit_usd.toFixed(2) : '');
                var monthLine = '   |   Месяц: $' + (status.spent_usd_month || 0).toFixed(2) +
                    ' (' + (status.requests_month || 0) + ' запросов)';
                spent.textContent = todayLine + monthLine;
            } else { spent.textContent = ''; }
        }
        // Quota warning + saport redirect
        try { _aiQuotaCheck(status); } catch (e) {}
    });
}

function onAiProviderChange() {
    var pid = (document.getElementById('ai-provider') || {}).value || 'openrouter';
    var prov = _aiProviders[pid] || {};
    var urlRow = document.getElementById('ai-url-row');
    if (urlRow) urlRow.style.display = pid === 'custom' ? '' : 'none';
    if (pid !== 'custom') {
        document.getElementById('ai-url').value = prov.url || '';
    }
    var mSel = document.getElementById('ai-model');
    if (mSel) {
        mSel.innerHTML = '';
        (prov.models || []).forEach(function(m) {
            var opt = document.createElement('option');
            opt.value = m; opt.textContent = m;
            mSel.appendChild(opt);
        });
        if (prov.default_model) mSel.value = prov.default_model;
        // Для custom — поле ввода вместо select
        if (pid === 'custom') {
            mSel.innerHTML = '<option value="">(укажите адрес выше и зайдите снова)</option>';
        }
    }
    // Подсказка
    var hint = document.getElementById('ai-signup-hint');
    if (hint) {
        var lines = [];
        if (prov.signup) {
            lines.push('Получить ключ: <a href="' + prov.signup + '" target="_blank" style="color:var(--accent-light);">' + prov.signup + '</a>');
        }
        if (prov.hint) lines.push(prov.hint);
        if (prov.price_per_1m_in) {
            lines.push('Цена: $' + prov.price_per_1m_in + ' за 1М input-токенов / $' +
                       prov.price_per_1m_out + ' за 1М output-токенов');
        }
        hint.innerHTML = lines.join('<br>');
    }
}

function aiSave() {
    var key = (document.getElementById('ai-key') || {}).value || '';
    var payload = {
        enabled: document.getElementById('ai-enabled').checked,
        provider: document.getElementById('ai-provider').value,
        url: document.getElementById('ai-url').value,
        model: document.getElementById('ai-model').value,
        daily_limit_usd: parseFloat(document.getElementById('ai-budget-usd').value) || 0,
        max_requests: parseInt(document.getElementById('ai-budget-req').value) || 0,
        dialog_topics: document.getElementById('ai-dialog-topics').value,
        custom_dialog_prompt: document.getElementById('ai-custom-dialog-prompt').value,
        custom_comment_prompt: document.getElementById('ai-custom-comment-prompt').value,
        comment_cta: document.getElementById('ai-comment-cta').value,
        retry_on_error: (document.getElementById('ai-retry-on-error') || {}).checked || false,
    };
    if (key) payload.api_key = key;
    fetch('/api/ai/save', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload)
    }).then(function(r){return r.json()}).then(function(d) {
        if (d.ok) {
            showToast('AI-настройки сохранены', 'success');
            document.getElementById('ai-key').value = '';
            aiLoad();
        } else {
            showToast(d.error || 'Ошибка сохранения', 'error');
        }
    });
}

function aiTest() {
    var btn = document.getElementById('btn-ai-test');
    var resEl = document.getElementById('ai-test-result');
    if (resEl) resEl.innerHTML = '<i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;display:inline-block;"></i> Проверяю...';
    if (btn) btn.disabled = true;
    var payload = {
        provider: document.getElementById('ai-provider').value,
        url: document.getElementById('ai-url').value,
        model: document.getElementById('ai-model').value,
        api_key: (document.getElementById('ai-key') || {}).value || '',
    };
    fetch('/api/ai/test', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload)
    }).then(function(r){return r.json()}).then(function(d) {
        if (btn) btn.disabled = false;
        if (d.ok) {
            resEl.innerHTML = '<span style="color:var(--success);">✓ Работает.</span> Модель: <code>' + d.model + '</code>. ' +
                'Токены in/out: ' + d.tokens_in + '/' + d.tokens_out + '. ' +
                'Стоимость теста: $' + (d.cost_usd || 0).toFixed(6) + '. ' +
                '<br><span style="color:var(--text-muted);">Ответ: «' + (d.reply || '') + '»</span>';
        } else {
            resEl.innerHTML = '<span style="color:var(--danger);">✗ ' + (d.error || 'Ошибка') + '</span>';
        }
    });
}

// ── AI Pools (generated chat names / usernames / greetings / diary posts) ──
var _aiPoolPoll = null;
var _cachedSupportUrl = null;

function _aiSupportLink() {
    // Саппорт-ссылка — тянем с /api/support_info и кешируем. Источник истины —
    // config.SUPPORT_TG_URL на сервере (через ENV TELEPUSH_SUPPORT_URL).
    // Если ещё не успели загрузить — дефолт, потом перезапишется.
    if (_cachedSupportUrl) return _cachedSupportUrl;
    fetch('/api/support_info').then(function(r){return r.json()}).then(function(d) {
        if (d && d.url) {
            _cachedSupportUrl = d.url;
            // Обновляем уже отрендеренные ссылки на саппорт
            var links = document.querySelectorAll('a[data-support-link]');
            for (var i = 0; i < links.length; i++) links[i].href = d.url;
        }
    }).catch(function(){});
    return _cachedSupportUrl || 'https://t.me/TP_supports';
}

function loadAiPoolStatus() {
    var supportA = document.getElementById('ai-support-link');
    if (supportA) supportA.href = _aiSupportLink();
    API.get('/api/ai/pool_status').then(function(s) {
        var el = document.getElementById('ai-pool-status');
        var hintEl = document.getElementById('warmup-ai-pool-hint');
        if (s.empty) {
            if (el) el.innerHTML = '<span style="color:var(--text-muted);">Пулы не сгенерированы — используются встроенные шаблоны.</span>';
            if (hintEl) hintEl.textContent = 'AI-пулы пока не сгенерированы.';
        } else {
            var dtxt = '';
            try {
                var d = new Date(s.generated_at * 1000);
                dtxt = d.toLocaleString('ru-RU');
            } catch (e) { dtxt = '—'; }
            var diaryStr = Object.keys(s.diary || {}).map(function(k){return k+':'+s.diary[k]}).join(', ');
            if (el) {
                el.innerHTML = '<span style="color:var(--success);">✓ Пулы готовы.</span> ' +
                    'Имена: ' + s.chat_names + ' · юзернеймы: ' + s.usernames + ' · приветствия: ' + s.greetings +
                    ' · дневники: ' + diaryStr +
                    '<br><span style="color:var(--text-muted);font-size:0.65rem;">Сгенерировано ' + dtxt + ' (модель: ' + (s.model_used || '—') + ')</span>';
            }
            if (hintEl) hintEl.textContent = 'AI-пулы готовы (' + s.usernames + ' юзернеймов, ' + s.chat_names + ' имён, ' + s.greetings + ' приветствий).';
        }
        // Прогресс генерации (если идёт)
        var gen = s.generation || {};
        var pBlock = document.getElementById('ai-pool-progress');
        var pBar = document.getElementById('ai-pool-progress-bar');
        var pText = document.getElementById('ai-pool-progress-text');
        var btn = document.getElementById('btn-ai-regen');
        if (gen.running) {
            if (pBlock) pBlock.style.display = '';
            var pp = gen.progress || {};
            var pct = pp.total > 0 ? Math.round((pp.done / pp.total) * 100) : 0;
            if (pBar) pBar.style.width = pct + '%';
            if (pText) pText.textContent = (pp.label || '...') + ' — ' + pp.done + '/' + pp.total;
            if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;display:inline-block;"></i> Генерирую...'; }
            // Поллим каждые 2 сек пока идёт
            if (!_aiPoolPoll) {
                _aiPoolPoll = setInterval(loadAiPoolStatus, 2000);
            }
        } else {
            if (_aiPoolPoll) { clearInterval(_aiPoolPoll); _aiPoolPoll = null; }
            if (pBlock) pBlock.style.display = 'none';
            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-magic"></i> Сгенерировать AI-пулы'; }
            // Показать результат последнего запуска если был
            var res = gen.result;
            if (res && !res.ok && res.error) {
                showToast('AI-пулы: ' + res.error, 'error');
            }
        }
    }).catch(function() {});
}

async function aiRegeneratePools() {
    if (!(await appConfirm('Запустить AI-генерацию пулов? Займёт 1-3 минуты, потратит ~$0.05-0.15 на твоём ключе.', {}))) return;
    API.post('/api/ai/regenerate_pools', {}).then(function(d) {
        if (d.ok) {
            showToast('Генерация AI-пулов запущена', 'success');
            loadAiPoolStatus();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function() { showToast('Нет связи', 'error'); });
}

async function aiClearPool() {
    if (!(await appConfirm('Сбросить сгенерированные AI-пулы? Прогрев вернётся к встроенным шаблонам.', {danger: true}))) return;
    API.post('/api/ai/clear_pool', {}).then(function(d) {
        if (d.ok) {
            showToast('Пулы сброшены', 'success');
            loadAiPoolStatus();
        }
    });
}

// Quota warning: если бюджет исчерпан — показать блок с redirect на саппорт.
// Хук в aiLoad: после загрузки статуса проверяем и показываем/прячем.
function _aiQuotaCheck(status) {
    var warn = document.getElementById('ai-quota-warning');
    if (!warn) return;
    var limit = status.daily_limit_usd || 0;
    var spent = status.spent_usd_today || 0;
    var maxReq = status.max_requests || 0;
    var req = status.requests_today || 0;
    var depleted = (limit > 0 && spent >= limit) || (maxReq > 0 && req >= maxReq);
    warn.style.display = depleted ? '' : 'none';
}

var _openFolderInFlight = false;
var _lastOpenFolderTs = 0;
function openPathFolder(which) {
    API.post('/api/paths/open', { which: which }).then(function(d) {
        if (d && d.ok) showToast('Открыто: ' + (d.path || which), 'success');
        else showToast((d && d.error) || 'Не удалось открыть', 'error');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// Поиск по карточкам в табе Настройки (~30+ карточек, без поиска легко потеряться).
function filterSettings(query) {
    var q = (query || '').trim().toLowerCase();
    var tab = document.getElementById('tab-settings');
    if (!tab) return;
    var clear = document.getElementById('settings-search-clear');
    var empty = document.getElementById('settings-search-empty');
    if (clear) clear.style.display = q ? 'block' : 'none';
    // Берём только прямые карточки таба, кроме самой карточки поиска.
    var cards = tab.querySelectorAll(':scope > .card');
    var shown = 0;
    cards.forEach(function(card, idx) {
        // Первая карточка — наша search-карточка, не фильтруем
        if (card.querySelector('#settings-search')) return;
        if (!q) {
            card.style.display = '';
            shown++;
            return;
        }
        var text = (card.textContent || '').toLowerCase();
        if (text.indexOf(q) !== -1) {
            card.style.display = '';
            shown++;
        } else {
            card.style.display = 'none';
        }
    });
    if (empty) empty.style.display = (q && shown === 0) ? 'block' : 'none';
}

async function factoryReset() {
    // Кастомная модалка — нативный await appConfirm() выглядит как из 2003-го.
    var old = document.getElementById('factory-reset-modal');
    if (old) old.remove();
    var overlay = document.createElement('div');
    overlay.id = 'factory-reset-modal';
    overlay.className = 'tp-modal-overlay';
    overlay.innerHTML =
        '<div class="tp-modal tp-modal-danger">' +
            '<div class="tp-modal-icon"><i class="bi bi-exclamation-triangle-fill"></i></div>' +
            '<div class="tp-modal-title">Полный сброс TelePush</div>' +
            '<div class="tp-modal-body">' +
                '<div class="tp-modal-warn">Это действие <b>необратимо</b>. После подтверждения софт самозакроется.</div>' +
                '<div class="tp-modal-list-title">Будут удалены:</div>' +
                '<ul class="tp-modal-list">' +
                    '<li><i class="bi bi-folder2"></i> <b>Аккаунты/</b> — все .session и .json файлы</li>' +
                    '<li><i class="bi bi-database"></i> <b>data/</b> — база, настройки, логи</li>' +
                    '<li><i class="bi bi-shield"></i> <b>~/.telepush/</b> — резервные настройки и статусы</li>' +
                '</ul>' +
                '<label class="tp-modal-confirm">' +
                    '<input type="checkbox" id="fr-confirm-cb"> ' +
                    '<span>Я понимаю что все аккаунты и настройки удалятся без возможности восстановления</span>' +
                '</label>' +
            '</div>' +
            '<div class="tp-modal-actions">' +
                '<button class="btn btn-outline btn-sm" id="fr-cancel"><i class="bi bi-x"></i> Отмена</button>' +
                '<button class="btn btn-danger btn-sm" id="fr-confirm-btn" disabled><i class="bi bi-trash"></i> Стереть всё</button>' +
            '</div>' +
        '</div>';
    document.body.appendChild(overlay);
    overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); });
    var cb = overlay.querySelector('#fr-confirm-cb');
    var btn = overlay.querySelector('#fr-confirm-btn');
    cb.addEventListener('change', function() { btn.disabled = !cb.checked; });
    overlay.querySelector('#fr-cancel').onclick = function() { overlay.remove(); };
    btn.onclick = function() {
        btn.disabled = true;
        btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Стираю...';
        API.post('/api/system/factory_reset', { confirm: true }).then(function(d) {
            if (d && d.ok) {
                var info = 'Удалено: ' + ((d.deleted || []).join(', ') || 'ничего не было');
                if (d.errors && d.errors.length) info += '. Ошибки: ' + d.errors.join('; ');
                overlay.querySelector('.tp-modal-body').innerHTML =
                    '<div class="tp-modal-warn" style="background:rgba(34,197,94,0.1);border-color:#22c55e;color:#22c55e;">' +
                    '<i class="bi bi-check-circle-fill"></i> ' + info + '<br>Софт закроется через 1 секунду.' +
                    '</div>';
                overlay.querySelector('.tp-modal-actions').style.display = 'none';
            } else {
                showToast((d && d.error) || 'Ошибка', 'error');
                btn.disabled = false;
                btn.innerHTML = '<i class="bi bi-trash"></i> Стереть всё';
            }
        }).catch(function() {
            showToast('Ошибка запроса', 'error');
            btn.disabled = false;
            btn.innerHTML = '<i class="bi bi-trash"></i> Стереть всё';
        });
    };
}

function loadSettings() {
    aiLoad();
    loadAiPoolStatus();
    // Пути софта — баннер в карточке «Где софт хранит данные»
    try {
        API.get('/api/paths/info').then(function(d) {
            if (!d) return;
            var rows = document.getElementById('paths-info-rows');
            if (!rows) return;
            rows.innerHTML =
                '<div><i class="bi bi-folder2-open"></i> <b>Аккаунты:</b> <code>' + escHtml(d.accounts_dir || '—') + '</code></div>' +
                '<div><i class="bi bi-database"></i> <b>data:</b> <code>' + escHtml(d.data_dir || '—') + '</code></div>' +
                '<div><i class="bi bi-shield"></i> <b>Резерв:</b> <code>' + escHtml(d.backup_dir || '—') + '</code> ' +
                '<span style="color:var(--text-muted);">— тут лежат статусы dead/frozen, сессий нет</span></div>';
        }).catch(function() {});
    } catch (e) {}
    // Глобальные anti-detect настройки
    API.get('/api/global_ad/get').then(function(d) {
        var g = (d && d.global_ad) || {};
        var el = document.getElementById('global-pre-spam-check');
        if (el) el.checked = !!g.pre_spam_check;
    }).catch(function() {});
    // Расписание + TG-бот + авто-обнова
    // loadSchedule() убран — настройка удалена
    if (typeof loadTgBot === 'function') loadTgBot();
    if (typeof loadBotControl === 'function') loadBotControl();
    if (typeof loadAutoUpdateInstall === 'function') loadAutoUpdateInstall();
    if (typeof loadUpdateSound === 'function') loadUpdateSound();
    if (typeof loadBgScanner === 'function') loadBgScanner();
    if (typeof loadAccountCooldown === 'function') loadAccountCooldown();
    if (typeof loadSafetyProfile === 'function') loadSafetyProfile();
    // Ручная правка профильного слайдера → подсветка профиля снимается (стало Custom).
    // Делегированный listener вешаем один раз.
    if (!window._safetyCustomListener) {
        window._safetyCustomListener = true;
        document.addEventListener('input', function(ev) {
            if (ev.target && ev.target.id && typeof _safetyMarkCustomIfControlled === 'function') {
                _safetyMarkCustomIfControlled(ev.target.id);
            }
        });
    }
    API.get('/api/settings').then(function(d) {
        // ВАЖНО: не дёргаем autosave на programmatic изменения value слайдеров,
        // иначе loadSettings() → setValue() → change → save → петля.
        // Отключаем флаг на время, привязку добавим после.
        window._settingsAutosaveBound = window._settingsAutosaveBound;  // сохраняем текущее
        if (d.join) {
            _setSlider('s-join-interval', d.join.interval);
            _setSlider('s-join-jitter', d.join.jitter);
            _setSlider('s-join-max', d.join.max_per_session);
            _setSlider('s-join-break', d.join.break_min);
            _setSlider('s-join-break-max', d.join.break_max);
            if (d.join.floodwait_stop_sec) _setSlider('s-join-floodwait-stop', d.join.floodwait_stop_sec);
            if (d.join.autopilot_max_rest_sec) _setSlider('s-join-ap-maxrest', d.join.autopilot_max_rest_sec);
            // чекбокс «ждать любой FloodWait и продолжать»
            var _waitC = document.getElementById('s-join-fw-wait');
            if (_waitC) _waitC.checked = !!d.join.floodwait_wait_continue;
            // автопилот забивки (дефолт ВКЛ если не задано)
            var _apC = document.getElementById('s-join-autopilot');
            if (_apC) _apC.checked = d.join.autopilot !== false;
        }
        if (d.spam) {
            _setSlider('s-spam-min', d.spam.min_delay);
            _setSlider('s-spam-max', d.spam.max_delay);
            _setSlider('s-spam-burst', d.spam.burst_size);
            _setSlider('s-spam-burst-pause-min', d.spam.burst_pause_min);
            _setSlider('s-spam-burst-pause-max', d.spam.burst_pause_max);
            if (d.spam.floodwait_stop_sec) _setSlider('s-spam-floodwait-stop', d.spam.floodwait_stop_sec);
        }
        if (d.dm) {
            _setSlider('s-dm-min', d.dm.min_delay);
            _setSlider('s-dm-max', d.dm.max_delay);
            _setSlider('s-dm-burst', d.dm.burst_size);
            _setSlider('s-dm-burst-pause-min', d.dm.burst_pause_min);
            _setSlider('s-dm-burst-pause-max', d.dm.burst_pause_max);
            if (d.dm.floodwait_stop_sec) _setSlider('s-dm-floodwait-stop', d.dm.floodwait_stop_sec);
        }
        if (d.api_id) { var el = document.getElementById('s-api-id'); if (el) el.value = d.api_id; }
        if (d.api_hash) { var el = document.getElementById('s-api-hash'); if (el) el.value = d.api_hash; }
        if (d.json_device) { var el = document.getElementById('s-json-device'); if (el) el.value = d.json_device; }
        if (typeof d.tg_lang_ru !== 'undefined') {
            var elLangRu = document.getElementById('s-tg-lang-ru');
            if (elLangRu) elLangRu.checked = !!d.tg_lang_ru;
        }
        if (d.auto_clean_dead) { var el = document.getElementById('s-auto-clean-dead'); if (el) el.value = d.auto_clean_dead; }
        if (d.spam_auto_recheck) { var el = document.getElementById('s-spam-auto-recheck'); if (el) el.value = d.spam_auto_recheck; }
        if (typeof d.monitor_dedupe !== 'undefined') {
            var el = document.getElementById('monitor-dedupe');
            if (el) el.checked = !!d.monitor_dedupe;
        }
        if (typeof d.bl_users !== 'undefined') {
            var el = document.getElementById('bl-users'); if (el) el.value = d.bl_users || '';
        }
        if (typeof d.bl_chats !== 'undefined') {
            var el = document.getElementById('bl-chats'); if (el) el.value = d.bl_chats || '';
        }
        if (typeof d.auto_rotate_proxy_on_floodwait !== 'undefined') {
            var el = document.getElementById('s-auto-rotate-proxy'); if (el) el.checked = !!d.auto_rotate_proxy_on_floodwait;
        }
        if (typeof d.proxy_order !== 'undefined') {
            var elPo = document.getElementById('proxy-order');
            if (elPo) elPo.value = (d.proxy_order === 'random') ? 'random' : 'sequential';
        }
        if (typeof d.proxy_max_accounts_per_proxy !== 'undefined') {
            var elMx = document.getElementById('proxy-max-per-proxy');
            if (elMx) elMx.value = parseInt(d.proxy_max_accounts_per_proxy) || 0;
        }
        // Прогрев свежих аккаунтов: храним в минутах, показываем в секундах.
        // Без этой строки поле после перезагрузки показывало ноль из разметки, и
        // ближайшее сохранение настроек выключало прогрев, который клиент включал.
        if (typeof d.warmup_duration_min !== 'undefined') {
            var elwd = document.getElementById('s-warmup-duration-sec');
            if (elwd) elwd.value = (parseInt(d.warmup_duration_min) || 0) * 60;
        }
        if (typeof d.warmup_hub_channel_id !== 'undefined' && d.warmup_hub_channel_id) {
            var elhc = document.getElementById('s-warmup-hub-channel');
            if (elhc) elhc.value = parseInt(d.warmup_hub_channel_id) || '';
        }
        if (typeof d.warmup_watchdog_enabled !== 'undefined') {
            var elw = document.getElementById('s-warmup-watchdog'); if (elw) elw.checked = !!d.warmup_watchdog_enabled;
        }
        if (typeof d.warmup_watchdog_threshold !== 'undefined' && d.warmup_watchdog_threshold) {
            var elwt = document.getElementById('s-warmup-watchdog-threshold'); if (elwt) elwt.value = parseInt(d.warmup_watchdog_threshold);
        }
        // webhook_secret убран вместе с UI
        // Бюджет первых попыток. Был зашит в коде: клиент ставил таймаут 15,
        // а каждая попытка обрывалась на восьмой секунде.
        if (typeof d.connect_attempt_timeout !== 'undefined') {
            var elcat = document.getElementById('s-connect-attempt-timeout');
            if (elcat) elcat.value = d.connect_attempt_timeout;
        }
        // попытки подключения к прокси. default 3.
        if (typeof d.connect_retries !== 'undefined') {
            var el = document.getElementById('s-connect-retries');
            if (el) el.value = d.connect_retries || 3;
        }
        // пауза между подключениями акков. default 3.
        if (typeof d.connect_delay !== 'undefined') {
            var el = document.getElementById('s-connect-delay');
            // Ноль — валидное значение («все сразу»), не «пусто».
            if (el) el.value = (d.connect_delay === undefined || d.connect_delay === null) ? 3 : d.connect_delay;
        }
        // паузы для защиты аккаунтов (0 = выкл). Поля в СЕКУНДАХ (как весь UI);
        // loop хранится в сек, peerflood/phone-import — в мин на бэкенде → ×60.
        if (typeof d.loop_pause_min_sec !== 'undefined') {
            var _elLp = document.getElementById('s-loop-pause-min');
            if (_elLp) _elLp.value = parseInt(d.loop_pause_min_sec);
        }
        if (typeof d.peerflood_cooldown_min !== 'undefined') {
            var _elPf = document.getElementById('s-peerflood-cooldown');
            if (_elPf) _elPf.value = Math.max(0, parseInt(d.peerflood_cooldown_min) || 0) * 60;
        }
        if (typeof d.phone_import_rest_min !== 'undefined') {
            var _elPir = document.getElementById('s-phone-import-rest');
            if (_elPir) _elPir.value = Math.max(0, parseInt(d.phone_import_rest_min) || 0) * 60;
        }
        if (typeof _refreshSecHints === 'function') { try { _refreshSecHints(); } catch (e) {} }
        // таймаут на коннект к прокси. default 15.
        if (typeof d.proxy_timeout !== 'undefined') {
            var el = document.getElementById('s-proxy-timeout');
            if (el) el.value = d.proxy_timeout || 15;
        }
        // Подключаем автосохранение ПОСЛЕ установки значений, чтобы loadSettings не
        // триггерил сохранение. Сбрасываем таймер если он был поставлен событиями во
        // время _setSlider.
        if (_autosaveTimer) { clearTimeout(_autosaveTimer); _autosaveTimer = null; }
        bindSettingsAutosave();
    });
    try { _refreshSecHints(); } catch (_) {}   // значения пришли программно — событий нет
}

function _setSlider(id, val) {
    var el = document.getElementById(id);
    if (el) {
        el.value = val;
        var valEl = document.getElementById('v-' + id.replace('s-', ''));
        if (valEl) valEl.textContent = val;
    }
}

// Безопасный (рекомендуемый) пресет таймингов: ~2-3x медленнее «быстрых» дефолтов
// → заметно меньше спам-блоков/FloodWait. Значения в пределах слайдеров каждого
// модуля. Опт-ин (кнопка), дефолт молча не меняем.
var _TIMING_SAFE_PRESET = {
    spam: {'s-spam-min':20,'s-spam-max':45,'s-spam-burst-pause-min':300,'s-spam-burst-pause-max':600,'s-spam-floodwait-stop':600},
    dm:   {'s-dm-min':60,'s-dm-max':150,'s-dm-burst-pause-min':600,'s-dm-burst-pause-max':1200,'s-dm-floodwait-stop':600},
    join: {'s-join-interval':40,'s-join-jitter':10,'s-join-break':300,'s-join-break-max':600,'s-join-floodwait-stop':600}
};
function applyTimingPreset(module) {
    var p = _TIMING_SAFE_PRESET[module];
    if (!p) return;
    Object.keys(p).forEach(function(id){ _setSlider(id, p[id]); });
    if (typeof _scheduleAutosaveSettings === 'function') _scheduleAutosaveSettings();
    showToast('Безопасный пресет применён — паузы больше, меньше риск спам-блока', 'success');
}

// ── Профили безопасности: один переключатель задаёт паузы/стоп-пороги ВСЕХ
// модулей (рассылка/ЛС/подписки) разом. Ultra-Safe → Fast: паузы падают,
// скорость растёт, риск спам-блока растёт. Значения в пределах слайдеров.
// «Профиль безопасности» убран из интерфейса: одна кнопка перетирала паузы во
// всех модулях, а клиент настраивает их там же руками и потом не понимает, кто
// сбросил его значения. Заглушки — их зовут из загрузки настроек и из правки
// слайдеров.
function applySafetyProfile() {}
function loadSafetyProfile() {}
function _safetyMarkCustomIfControlled() {}

// ── Чекер номеров ──────────────────────────────────────────────
var _ncPollTimer = null;
function startNumberCheck() {
    var ta = document.getElementById('nc-phones');
    var phones = (ta ? ta.value : '').trim();
    if (!phones) { showToast('Вставь список номеров', 'warning'); return; }
    var accs = (typeof getSelectorPhones === 'function') ? getSelectorPhones('acc-selector-number-checker') : [];
    if (!accs.length) { showToast('Выбери аккаунты (кем проверять)', 'warning'); return; }
    var dmin = parseFloat((document.getElementById('nc-delay-min') || {}).value || 4);
    var dmax = parseFloat((document.getElementById('nc-delay-max') || {}).value || 8);
    API.post('/api/number_checker/start', { phones: phones, account_phones: accs, delay_min: dmin, delay_max: dmax }).then(function (d) {
        if (d.ok) {
            var bs = document.getElementById('btn-nc-start'); if (bs) bs.style.display = 'none';
            var bt = document.getElementById('btn-nc-stop'); if (bt) bt.style.display = 'inline-flex';
            var pr = document.getElementById('nc-progress'); if (pr) pr.style.display = 'block';
            var lg = document.getElementById('nc-log'); if (lg) lg.style.display = 'block';
            if (_ncPollTimer) clearInterval(_ncPollTimer);
            _ncPollTimer = setInterval(pollNumberCheck, 2000);
            showToast('Проверка ' + d.total + ' номеров запущена', 'success');
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function (e) { showToast('Ошибка: ' + _errText(e), 'error'); });
}

function stopNumberCheck() {
    API.post('/api/number_checker/stop', {}).then(function () { showToast('Останавливаю…', 'success'); });
}

function pollNumberCheck() {
    API.get('/api/number_checker/status').then(function (d) {
        if (!d || (d.running === false && !d.status)) return;
        var total = d.total || 0, done = d.done || 0;
        var pct = total ? Math.round(done * 100 / total) : 0;
        var bar = document.getElementById('nc-progress-bar'); if (bar) bar.style.width = pct + '%';
        var pt = document.getElementById('nc-progress-text'); if (pt) pt.textContent = done + ' / ' + total;
        var cn = document.getElementById('nc-counters');
        if (cn) cn.innerHTML =
            '<span class="text-success">В Telegram: ' + (d.registered || 0) + '</span>' +
            '<span class="text-muted">Нет: ' + (d.not_registered || 0) + '</span>' +
            '<span class="text-danger">Ошибок: ' + (d.error || 0) + '</span>' +
            (d.limit ? '<span class="text-warning">Лимит-парковок: ' + d.limit + '</span>' : '');
        var lg = document.getElementById('nc-log');
        if (lg && d.logs) { lg.innerHTML = d.logs.map(function (l) { return escapeHtml(l); }).join('<br>'); lg.scrollTop = lg.scrollHeight; }
        if (d.results && d.results.length) _renderNcResults(d.results);
        var sl = document.getElementById('nc-status-label'); if (sl) sl.textContent = d.status || '';
        if (d.running === false) {
            if (_ncPollTimer) { clearInterval(_ncPollTimer); _ncPollTimer = null; }
            var bs = document.getElementById('btn-nc-start'); if (bs) bs.style.display = 'inline-flex';
            var bt = document.getElementById('btn-nc-stop'); if (bt) bt.style.display = 'none';
        }
    }).catch(function () {});
}

function _renderNcResults(rows) {
    var wrap = document.getElementById('nc-results-wrap'); if (wrap) wrap.style.display = 'block';
    var box = document.getElementById('nc-results'); if (!box) return;
    var html = '<table style="width:100%;border-collapse:collapse;font-size:0.74rem;">' +
        '<tr style="text-align:left;color:var(--text-muted);"><th style="padding:4px 8px;">Номер</th><th>Статус</th><th>@username</th><th>Имя</th></tr>';
    rows.forEach(function (r) {
        var st = r.status === 'registered'
            ? '<span class="text-success">✓ в Telegram</span>'
            : (r.status === 'not_registered' ? '<span class="text-muted">— нет</span>' : '<span class="text-danger">ошибка</span>');
        html += '<tr style="border-top:1px solid var(--border);">' +
            '<td style="padding:4px 8px;">' + escapeHtml(r.phone || '') + '</td>' +
            '<td>' + st + '</td>' +
            '<td>' + (r.username ? '@' + escapeHtml(r.username) : '') + '</td>' +
            '<td>' + escapeHtml(r.name || '') + '</td></tr>';
    });
    html += '</table>';
    box.innerHTML = html;
}

function exportNumberCheck(filter, fmt) {
    API.post('/api/number_checker/export', { filter: filter, fmt: fmt }).then(function (d) {
        if (d.ok) {
            showToast('Экспортировано: ' + d.count + (d.path ? (' → ' + d.path) : ''), 'success');
        } else {
            showToast(d.error || 'Нет данных', 'warning');
        }
    }).catch(function (e) { showToast('Ошибка: ' + _errText(e), 'error'); });
}

// ── Drip / follow-up цепочка (ЛС) ──────────────────────────────
var _dripStepCount = 0;
function addDripStep() {
    var box = document.getElementById('dm-drip-steps');
    if (!box) return;
    if (box.children.length >= 5) { showToast('Максимум 5 догоняшек', 'warning'); return; }
    _dripStepCount++;
    var n = box.children.length + 1;
    var row = document.createElement('div');
    row.className = 'drip-step mb-2';
    row.style.cssText = 'border:1px solid var(--border);border-radius:4px;padding:8px;';
    row.innerHTML =
        '<div class="d-flex align-center gap-2 mb-1" style="justify-content:space-between;">' +
            '<span style="font-size:0.72rem;color:var(--text-muted);">Догоняшка ' + n + '</span>' +
            '<button type="button" class="btn btn-outline btn-sm" style="padding:2px 8px;font-size:0.7rem;" onclick="this.closest(\'.drip-step\').remove();_renumberDripSteps();"><i class="bi bi-x"></i></button>' +
        '</div>' +
        '<textarea class="form-control drip-text mb-1" rows="2" placeholder="Текст догоняшки (можно spintax {Привет|Здравствуйте})" style="font-size:0.76rem;"></textarea>' +
        '<label class="form-label m-0" style="font-size:0.72rem;">Через (часов после предыдущего): ' +
            '<input type="number" class="form-control drip-delay" value="24" min="1" max="720" style="width:90px;display:inline-block;"></label>';
    box.appendChild(row);
}
function _renumberDripSteps() {
    var box = document.getElementById('dm-drip-steps');
    if (!box) return;
    var rows = box.querySelectorAll('.drip-step');
    for (var i = 0; i < rows.length; i++) {
        var lbl = rows[i].querySelector('span');
        if (lbl) lbl.textContent = 'Догоняшка ' + (i + 1);
    }
}
function _collectDripSteps() {
    var box = document.getElementById('dm-drip-steps');
    if (!box) return [];
    var out = [];
    var rows = box.querySelectorAll('.drip-step');
    for (var i = 0; i < rows.length; i++) {
        var txt = (rows[i].querySelector('.drip-text') || {}).value || '';
        var dh = parseFloat((rows[i].querySelector('.drip-delay') || {}).value || 24) || 24;
        txt = txt.trim();
        if (txt) out.push({ text: txt, delay_hours: dh });
    }
    return out;
}

function numberCheckToDm() {
    API.post('/api/number_checker/export', { filter: 'registered', fmt: 'txt' }).then(function (d) {
        if (!d.ok || !d.text) { showToast('Нет зарегистрированных номеров', 'warning'); return; }
        showTab('dm');
        setTimeout(function () {
            var ta = document.getElementById('dm-targets-input');
            if (ta) {
                ta.value = d.text;
                // Синк на сервер ОБЯЗАТЕЛЕН: старт идёт на /api/multi/dm без targets
                // в payload, бэкенд берёт серверный список. Без синка рассылка ушла
                // бы по получателям прошлой кампании (повторный DM → жалобы → PeerFlood).
                loadDmTargets(true);
                showToast('Подставлено в ЛС-рассылку: ' + d.count, 'success');
            }
        }, 200);
    }).catch(function (e) { showToast('Ошибка: ' + _errText(e), 'error'); });
}

function openSettingsFolder() {
    API.post('/api/settings/open_folder', {}).then(function(d) {
        if (d.ok) {
            showToast('Папка открыта: ' + d.path, 'success');
        } else {
            showToast('Не удалось открыть: ' + (d.error || '?') + '\nПуть: ' + (d.path || '?'), 'error');
        }
    }).catch(function(e) {
        showToast('Ошибка: ' + _errText(e), 'error');
    });
}

// Сырые коды статусов операций → человеческий русский. Раньше в «По статусам»
// светились joined:public / request_sent / failed:flood — юзер про такое: «конченные
// ответы, не красивые». Неизвестный код показываем как есть (данные не теряем).
function _humanOpStatus(code) {
    var c = String(code || '').toLowerCase();
    var FULL = {
        'ok': 'отправлено',
        'already_member': 'уже участник',
        'request_sent': 'заявка отправлена (ждёт модерации)',
        'spam_block': 'спам-блок',
        'privacy': 'закрыт настройками приватности',
        'blocked': 'в блоке у получателя',
        'user_banned': 'аккаунт забанен',
        'banned': 'забанен',
        'no_rights': 'нет прав писать',
        'joined:public': 'вступил (публичный)',
        'joined:invite': 'вступил (по ссылке)',
        'joined:left_no_write': 'вступил и вышел (писать нельзя)',
        'ignored:broadcast_no_write': 'канал — писать нельзя',
        'ignored:unsupported_or_unsafe_link': 'ссылка не поддерживается',
        'ignored:folders_full': 'папки переполнены'
    };
    if (FULL[c]) return FULL[c];
    var parts = c.split(':');
    var base = parts[0], rest = parts.slice(1).join(':');
    var BASE = {joined: 'вступил', failed: 'не вышло', ignored: 'пропущен', error: 'ошибка'};
    var REST = {
        flood: 'флуд-пауза', invalid: 'неверная ссылка', private: 'приватный',
        spam: 'спам-блок', expired: 'просрочено', not_found: 'не найдено',
        reconnect_timeout: 'нет связи', session_invalid: 'сессия слетела',
        public: 'публичный', invite: 'по ссылке'
    };
    if (BASE[base]) {
        return rest ? (BASE[base] + ': ' + (REST[rest] || rest)) : BASE[base];
    }
    return c;
}

function showHistoryStats() {
    API.get('/api/history/stats').then(function(d) {
        if (!d.ok) { showToast('Ошибка: ' + (d.error || '?'), 'error'); return; }
        var s = d.stats || {};
        var rows = [];
        var LABEL = {dm: '<i class="bi bi-chat-dots"></i> ЛС-рассылка',
                     spam: '<i class="bi bi-megaphone"></i> Рассылка по чатам',
                     join: '<i class="bi bi-box-arrow-in-right"></i> Подписки',
                     parser: '<i class="bi bi-search"></i> Парсер'};
        ['dm', 'spam', 'join', 'parser'].forEach(function(op) {
            var st = s[op] || {};
            var total = Object.values(st).reduce(function(a, b) { return a + b; }, 0);
            if (total === 0) {
                rows.push('<tr><td style="padding:8px;">' + (LABEL[op] || op) + '</td>' +
                          '<td colspan="2" style="padding:8px;color:var(--text-muted);">пусто</td>' +
                          '<td style="padding:8px;"><button class="btn btn-outline btn-sm" disabled>Сброс</button></td></tr>');
                return;
            }
            var details = Object.keys(st).map(function(k) { return _humanOpStatus(k) + ': ' + st[k]; }).join(', ');
            rows.push('<tr><td style="padding:8px;">' + (LABEL[op] || op) + '</td>' +
                      '<td style="padding:8px;">' + total + '</td>' +
                      '<td style="padding:8px;font-size:0.7rem;color:var(--text-muted);">' + details + '</td>' +
                      '<td style="padding:8px;"><button class="btn btn-outline btn-sm" onclick="clearOpHistory(\'' + op + '\')">Сброс</button></td></tr>');
        });
        var html = '<div style="max-width:560px;">' +
            '<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:12px;line-height:1.5;">' +
                'Софт помнит что уже было сделано (отправки, подписки, парсинг) чтобы при повторном запуске пропускать обработанное. ' +
                'TTL: 90 дней (старые автоудаляются). Парсер: 7 дней (после этого можно парсить снова, состав чата мог измениться).' +
            '</div>' +
            '<table style="width:100%;border-collapse:collapse;font-size:0.85rem;">' +
                '<thead><tr style="border-bottom:1px solid var(--border);">' +
                    '<th style="padding:8px;text-align:left;">Модуль</th>' +
                    '<th style="padding:8px;text-align:left;">Всего</th>' +
                    '<th style="padding:8px;text-align:left;">По статусам</th>' +
                    '<th style="padding:8px;"></th>' +
                '</tr></thead>' +
                '<tbody>' + rows.join('') + '</tbody>' +
            '</table>' +
            '<div style="margin-top:14px;text-align:right;">' +
                '<button class="btn btn-outline btn-sm" onclick="clearOpHistory(null)">Сбросить всю историю</button>' +
            '</div>' +
        '</div>';
        appAlert(html, {html: true});
    }).catch(function(e) { showToast('Ошибка: ' + (e && e.message || e), 'error'); });
}

function clearOpHistory(opType) {
    var label = opType ? opType : 'ВСЮ историю';
    if (!confirm('Сбросить ' + label + ' из памяти софта? Софт забудет что уже делал и при повторном запуске будет обрабатывать всё заново.')) return;
    API.post('/api/history/clear', {op_type: opType}).then(function(d) {
        if (d.ok) {
            showToast('Удалено записей: ' + d.deleted, 'success');
            showHistoryStats();
        } else {
            showToast('Ошибка: ' + (d.error || '?'), 'error');
        }
    }).catch(function(e) { showToast('Ошибка: ' + (e && e.message || e), 'error'); });
}

function showSettingsDebug() {
    API.get('/api/settings/debug').then(function(d) {
        var lines = [];
        lines.push('📁 DATA_DIR: ' + d.data_dir);
        lines.push('📄 Файл настроек: ' + d.settings_path);
        lines.push('   существует: ' + (d.settings_exists ? 'да' : 'нет'));
        if (d.settings_exists) {
            lines.push('   размер: ' + (d.settings_size || 0) + ' байт');
            lines.push('   изменён: ' + (d.settings_mtime || '?'));
            lines.push('   ключей: ' + ((d.settings_keys || []).length));
            lines.push('   spam_min_delay: ' + d.spam_min_delay);
            lines.push('   spam_max_delay: ' + d.spam_max_delay);
            if (d.settings_error) lines.push('   ошибка: ' + d.settings_error);
        }
        lines.push('');
        lines.push('💾 Бэкап: ' + d.backup_path);
        lines.push('   существует: ' + (d.backup_exists ? 'да' : 'нет'));
        if (d.backup_exists) {
            lines.push('   размер: ' + (d.backup_size || 0) + ' байт');
            lines.push('   изменён: ' + (d.backup_mtime || '?'));
            lines.push('   ключей: ' + d.backup_keys);
            lines.push('   spam_min_delay: ' + d.backup_spam_min);
        }
        lines.push('');
        lines.push('🖥 exe: ' + d.sys_executable);
        lines.push('🌍 env TELEPUSH_DATA_DIR: ' + (d.env_data_dir || '(не задан)'));
        lines.push('📂 cwd: ' + d.cwd);
        if (d.settings_readonly) {
            lines.push('');
            lines.push('[!] ФАЙЛ НАСТРОЕК ТОЛЬКО ДЛЯ ЧТЕНИЯ — снимите атрибут read-only!');
        }
        if (typeof d.telepush_processes_count !== 'undefined') {
            lines.push('');
            lines.push('🔎 Запущено процессов TelePush: ' + d.telepush_processes_count);
            lines.push('   (несколько копий в разных папках — норма, у каждой своя data/)');
            if (d.telepush_processes) {
                d.telepush_processes.forEach(function(pr) {
                    lines.push('   PID ' + pr.pid + ': ' + pr.exe);
                });
            }
        }
        appAlert(lines.join('\n'));
    }).catch(function(e) {
        appAlert('Ошибка диагностики: ' + e);
    });
}

async function restoreSettingsFromBackup() {
    if (!(await appConfirm('Восстановить настройки из резервной копии? Текущие значения будут заменены.', {}))) return;
    API.post('/api/settings/restore_from_backup', {}).then(function(d) {
        if (d.ok) {
            showToast('Восстановлено ' + d.keys + ' ключей. Обновите страницу.', 'success');
            setTimeout(function(){ loadSettings(); }, 500);
        } else {
            showToast(d.error || 'Не удалось восстановить', 'error');
        }
    }).catch(function(e) {
        showToast('Ошибка: ' + _errText(e), 'error');
    });
}

function saveSettings() {
    function _v(id) {
        var el = document.getElementById(id);
        return el ? parseInt(el.value) : null;
    }
    var data = {
        join_min_interval: _v('s-join-interval'),
        join_jitter: _v('s-join-jitter'),
        join_max_per_session: _v('s-join-max'),
        join_session_break_min: _v('s-join-break'),
        join_session_break_max: _v('s-join-break-max'),
        spam_min_delay: _v('s-spam-min'),
        spam_max_delay: _v('s-spam-max'),
        spam_burst_size: _v('s-spam-burst'),
        spam_burst_pause_min: _v('s-spam-burst-pause-min'),
        spam_burst_pause_max: _v('s-spam-burst-pause-max'),
        spam_floodwait_stop_sec: _v('s-spam-floodwait-stop'),
        dm_min_delay: _v('s-dm-min'),
        dm_max_delay: _v('s-dm-max'),
        dm_burst_size: _v('s-dm-burst'),
        dm_burst_pause_min: _v('s-dm-burst-pause-min'),
        dm_burst_pause_max: _v('s-dm-burst-pause-max'),
        dm_floodwait_stop_sec: _v('s-dm-floodwait-stop'),
        join_floodwait_stop_sec: _v('s-join-floodwait-stop'),
        join_autopilot_max_rest_sec: _v('s-join-ap-maxrest'),
        join_floodwait_wait_continue: !!(document.getElementById('s-join-fw-wait') && document.getElementById('s-join-fw-wait').checked),
        join_autopilot: (document.getElementById('s-join-autopilot') ? (document.getElementById('s-join-autopilot').checked ? 1 : 0) : 1),
    };
    // Убираем null-ключи (на случай отсутствия slider в DOM — старые шаблоны)
    Object.keys(data).forEach(function(k) { if (data[k] === null) delete data[k]; });
    // Extended settings
    var proxyTimeout = document.getElementById('s-proxy-timeout');
    if (proxyTimeout) data.proxy_timeout = parseInt(proxyTimeout.value);
    var connectRetries = document.getElementById('s-connect-retries');
    if (connectRetries) data.connect_retries = parseInt(connectRetries.value);
    var noProxyWeb = document.getElementById('s-no-proxy-web');
    if (noProxyWeb) data.no_proxy_web = noProxyWeb.checked ? 1 : 0;
    var connectDelay = document.getElementById('s-connect-delay');
    if (connectDelay) data.connect_delay = parseInt(connectDelay.value);
    // паузы для защиты аккаунтов (0 = выкл). UI в секундах; loop уходит в сек,
    // peerflood/phone-import — минутные эндпоинты → округляем сек/60.
    var _lpEl = document.getElementById('s-loop-pause-min');
    if (_lpEl) data.loop_pause_min_sec = Math.max(0, parseInt(_lpEl.value || 0) || 0);
    var _pfEl = document.getElementById('s-peerflood-cooldown');
    if (_pfEl) data.peerflood_cooldown_min = Math.max(0, Math.round((parseInt(_pfEl.value || 0) || 0) / 60));
    var _pirEl = document.getElementById('s-phone-import-rest');
    if (_pirEl) data.phone_import_rest_min = Math.max(0, Math.round((parseInt(_pirEl.value || 0) || 0) / 60));
    // Бюджет первых попыток; 0 = сразу давать полный таймаут.
    var connAttemptTo = document.getElementById('s-connect-attempt-timeout');
    if (connAttemptTo) data.connect_attempt_timeout = parseInt(connAttemptTo.value || 0, 10);
    // прогрев свежезарегистрированных
    var warmupDur = document.getElementById('s-warmup-duration-sec');
    if (warmupDur) data.warmup_duration_min = Math.round((parseInt(warmupDur.value) || 0) / 60);
    var apiId = document.getElementById('s-api-id');
    if (apiId) data.api_id = parseInt(apiId.value);
    var apiHash = document.getElementById('s-api-hash');
    if (apiHash) data.api_hash = apiHash.value.trim();
    var jsonDevice = document.getElementById('s-json-device');
    if (jsonDevice) data.json_device = jsonDevice.value;
    var tgLangRu = document.getElementById('s-tg-lang-ru');
    if (tgLangRu) data.tg_lang_ru = tgLangRu.checked ? 1 : 0;
    var autoCleanDead = document.getElementById('s-auto-clean-dead');
    if (autoCleanDead) data.auto_clean_dead = autoCleanDead.value;
    var spamAutoRecheck = document.getElementById('s-spam-auto-recheck');
    if (spamAutoRecheck) data.spam_auto_recheck = spamAutoRecheck.value;
    var monitorDedupe = document.getElementById('monitor-dedupe');
    if (monitorDedupe) data.monitor_dedupe = monitorDedupe.checked ? 1 : 0;
    var blUsers = document.getElementById('bl-users');
    if (blUsers) data.bl_users = blUsers.value;
    var blChats = document.getElementById('bl-chats');
    if (blChats) data.bl_chats = blChats.value;
    var autoRotate = document.getElementById('s-auto-rotate-proxy');
    if (autoRotate) data.auto_rotate_proxy_on_floodwait = autoRotate.checked ? 1 : 0;
    var wwd = document.getElementById('s-warmup-watchdog');
    if (wwd) data.warmup_watchdog_enabled = wwd.checked ? 1 : 0;
    var wwdT = document.getElementById('s-warmup-watchdog-threshold');
    if (wwdT && wwdT.value) data.warmup_watchdog_threshold = parseInt(wwdT.value) || 3;
    // webhook_secret убран
    // hub-chat настройки (хранятся глобально в user_settings)
    var hubPhone = document.getElementById('scn-hub-phone');
    if (hubPhone) data.warmup_hub_phone = hubPhone.value.trim();
    var hubChan = document.getElementById('scn-hub-channel-id');
    if (hubChan && hubChan.value) data.warmup_hub_channel_id = parseInt(hubChan.value) || 0;
    var searchKw = document.getElementById('scn-search-keywords');
    if (searchKw) data.warmup_search_keywords = searchKw.value;

    // возвращаем Promise чтобы _scheduleAutosaveSettings мог дёрнуть .then()
    // для обновления sync-badges в таймингах.
    return API.post('/api/settings', data).then(function(d) {
        if (d.ok) {
            _flashSettingsSaved(d.keys || '?');
            return d;
        } else {
            showToast('Не сохранилось: ' + (d.error || 'неизвестная ошибка'), 'error');
            throw new Error(d.error || 'save failed');
        }
    }).catch(function(e) {
        var msg = (e && (e.error || e.message)) ? (e.error || e.message) : (typeof e === 'string' ? e : JSON.stringify(e));
        showToast('Ошибка сохранения: ' + msg, 'error');
        throw e;
    });
}

// Короткий флэш «Сохранено» у кнопки «Сохранить настройки» — чтобы клиент
// видел что autosave отработал, но без спама тостов на каждый клик слайдера.
function _flashSettingsSaved(keys) {
    var btn = document.querySelector('#tab-settings button[onclick*="saveSettings"]');
    if (!btn) {
        showToast('Настройки сохранены (' + keys + ' ключей)', 'success');
        return;
    }
    var orig = btn.innerHTML;
    if (btn._flashRestoreTimer) clearTimeout(btn._flashRestoreTimer);
    btn.innerHTML = '<i class="bi bi-check2-circle"></i> Сохранено (' + keys + ')';
    btn.style.background = 'var(--success, #34d399)';
    btn._flashRestoreTimer = setTimeout(function() {
        btn.innerHTML = orig;
        btn.style.background = '';
    }, 1800);
}

// Авто-сохранение настроек: debounced save ко всем контролам на вкладке.
// Клиент двигает слайдер → через 800 мс настройки сами уходят на сервер.
var _autosaveTimer = null;
function _scheduleAutosaveSettings() {
    if (_autosaveTimer) clearTimeout(_autosaveTimer);
    // показываем badge «сохраняю» во всех таймингах модулей
    _setTimingSyncBadges('saving');
    _autosaveTimer = setTimeout(function() {
        saveSettings().then(function() {
            _setTimingSyncBadges('saved');
        }).catch(function() {
            _setTimingSyncBadges('error');
        });
    }, 800);
}

function _setTimingSyncBadges(state) {
    // state: 'saved' | 'saving' | 'error'
    var STYLES = {
        saved:  {text: '✓ сохранено', color: 'var(--success)'},
        saving: {text: '⏳ сохраняю…', color: 'var(--warning)'},
        error:  {text: '⚠ ошибка сохранения', color: 'var(--danger)'}
    };
    var s = STYLES[state] || STYLES.saved;
    var badges = document.querySelectorAll('.ts-sync-badge');
    for (var i = 0; i < badges.length; i++) {
        badges[i].textContent = s.text;
        badges[i].style.color = s.color;
    }
}

function bindSettingsAutosave() {
    if (window._settingsAutosaveBound) return;
    var sec = document.getElementById('tab-settings');
    if (!sec) return;
    var inputs = sec.querySelectorAll('input, select, textarea');
    for (var i = 0; i < inputs.length; i++) {
        var el = inputs[i];
        var tag = (el.tagName || '').toLowerCase();
        var type = (el.type || '').toLowerCase();
        // Для текстовых/числовых — change (когда юзер закончил ввод). Для слайдеров/чекбоксов/селектов — input
        if (type === 'checkbox' || type === 'radio' || tag === 'select' || type === 'range') {
            el.addEventListener('change', _scheduleAutosaveSettings);
            el.addEventListener('input', _scheduleAutosaveSettings);
        } else {
            el.addEventListener('change', _scheduleAutosaveSettings);
        }
    }
    // Слайдеры таймингов (s-join-*, s-spam-*, s-dm-*) живут в табах модулей,
    // НЕ в #tab-settings — привязываем им autosave-listener отдельно.
    // При перемещении ползунка обновляем рядом лежащий v-* текст.
    var timingSliders = document.querySelectorAll(
        '#s-join-interval, #s-join-jitter, #s-join-max, #s-join-break, #s-join-break-max, #s-join-floodwait-stop,' +
        '#s-spam-min, #s-spam-max, #s-spam-burst, #s-spam-burst-pause-min, #s-spam-burst-pause-max, #s-spam-floodwait-stop,' +
        '#s-dm-min, #s-dm-max, #s-dm-burst, #s-dm-burst-pause-min, #s-dm-burst-pause-max, #s-dm-floodwait-stop'
    );
    for (var j = 0; j < timingSliders.length; j++) {
        (function(slider) {
            // text-label рядом со слайдером: id="v-join-interval" → берём суффикс
            var vId = 'v-' + slider.id.replace(/^s-/, '');
            var vEl = document.getElementById(vId);
            var syncAndSave = function() {
                if (vEl) vEl.textContent = slider.value;
                _scheduleAutosaveSettings();
            };
            slider.addEventListener('input', syncAndSave);
            slider.addEventListener('change', syncAndSave);
        })(timingSliders[j]);
    }
    window._settingsAutosaveBound = true;
}


var _devicePresets = {
    desktop_2040: { api_id: 2040, api_hash: 'b18441a1ff607e10a989891a5462e627' },
    android:      { api_id: 4,    api_hash: '014b35b6184100b085b0d0572f9b5103' },
    ios:          { api_id: 8,    api_hash: '7245de8e747a0d6fbe11f7cc14fcc0bb' }
};

function onDeviceChange() {
    var sel = document.getElementById('s-json-device');
    if (!sel) return;
    var preset = _devicePresets[sel.value];
    if (preset) {
        var idEl = document.getElementById('s-api-id');
        var hashEl = document.getElementById('s-api-hash');
        if (idEl) idEl.value = preset.api_id;
        if (hashEl) hashEl.value = preset.api_hash;
    }
}

function toggleSettingsSection(id) {
    var el = document.getElementById(id);
    var chev = document.getElementById('chev-' + id);
    if (!el) return;
    if (el.style.display === 'none') {
        el.style.display = 'block';
        if (chev) { chev.classList.remove('bi-chevron-down'); chev.classList.add('bi-chevron-up'); }
    } else {
        el.style.display = 'none';
        if (chev) { chev.classList.remove('bi-chevron-up'); chev.classList.add('bi-chevron-down'); }
    }
}

function packLogs() {
    showToast('Упаковка логов...', 'info');
    API.post('/api/logs/pack').then(function(d) {
        if (d.ok) {
            var msg = 'Логи зашифрованы: ' + (d.file || 'logs.tplog');
            showToast(msg, 'success');
            if (typeof addLog === 'function') {
                addLog('Зашифрованный архив: ' + (d.path || d.file), 'INFO');
                addLog('Файл .tplog — зашифрован AES-256, прочитать может только разработчик. Безопасно пересылать в поддержку.', 'INFO');
            }
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function() { showToast('Ошибка упаковки', 'error'); });
}

// Переключателя расширенных логов в интерфейсе больше нет — присылка включена
// всегда. Заглушки оставлены: их зовут из общей загрузки настроек, и падение
// там уронило бы всю страницу настроек.
function refreshDebugToServerStatus() {}
function toggleDebugToServer() {}

document.addEventListener('DOMContentLoaded', function(){
    setTimeout(refreshDebugToServerStatus, 800);
});

// ══════════════════════════════════════════════════════════════════
//  TICKET — поддержка
// ══════════════════════════════════════════════════════════════════

function previewTicketImg(input) {
    var preview = document.getElementById('ticket-img-preview');
    var thumb = document.getElementById('ticket-img-thumb');
    if (input.files && input.files[0]) {
        var reader = new FileReader();
        reader.onload = function(e) { thumb.src = e.target.result; preview.style.display = 'block'; };
        reader.readAsDataURL(input.files[0]);
    }
}
function clearTicketImg() {
    document.getElementById('ticket-screenshot').value = '';
    document.getElementById('ticket-img-preview').style.display = 'none';
}

function copyDiagInfo(btn) {
    fetch('/api/diag').then(function(r) { return r.json(); }).then(function(d) {
        // Форматируем в человекочитаемый текст для вставки в саппорт.
        var lines = [];
        lines.push('=== TelePush диагностика ' + (d.ts || '') + ' ===');
        lines.push('Версия: ' + (d.version || '?'));
        lines.push('ОС: ' + (d.platform || '?'));
        lines.push('Python: ' + (d.python || '?'));
        if (d.license_status) lines.push('Лицензия: ' + d.license_status + (d.license_expires ? ' (до ' + d.license_expires + ')' : ''));
        if (d.license_key_mask) lines.push('Ключ: ' + d.license_key_mask);
        if (d.hwid_mask) lines.push('HWID: ' + d.hwid_mask);
        lines.push('');
        lines.push('Аккаунты: ' + (d.accounts_total || 0) + ' (мёртвых: ' + (d.accounts_dead || 0) +
            ', замороженных: ' + (d.accounts_frozen || 0) + ', floodwait: ' + (d.accounts_floodwait || 0) +
            ', занято модулями: ' + (d.accounts_busy || 0) + ')');
        if (d.accounts_dir_size_mb) lines.push('Размер папки Аккаунты: ' + d.accounts_dir_size_mb + ' МБ');
        if (typeof d.proxies_total === 'number') lines.push('Прокси: ' + d.proxies_alive + '/' + d.proxies_total + ' живых');
        if (d.active_jobs && d.active_jobs.length) {
            lines.push('');
            lines.push('Активные задачи:');
            d.active_jobs.forEach(function(j) {
                lines.push('  - ' + j.type + ' [' + j.status + '] ' + (j.processed || 0) + '/' + (j.total || 0));
            });
        }
        if (d.recent_errors && d.recent_errors.length) {
            lines.push('');
            lines.push('Последние ошибки:');
            d.recent_errors.forEach(function(e) { lines.push('  ' + e); });
        }
        var txt = lines.join('\n');
        // Копируем через clipboard API с фоллбэком на textarea-select.
        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(txt).then(function() {
                if (btn) {
                    var orig = btn.innerHTML;
                    btn.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
                    setTimeout(function() { btn.innerHTML = orig; }, 2000);
                }
                showToast('Диагностика скопирована (' + txt.length + ' символов). Вставьте в обращение.', 'success');
            }, function() {
                _diagFallbackCopy(txt);
            });
        } else {
            _diagFallbackCopy(txt);
        }
    }).catch(function() {
        showToast('Не удалось получить диагностику', 'error');
    });
}

function _diagFallbackCopy(txt) {
    var ta = document.createElement('textarea');
    ta.value = txt;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    try {
        document.execCommand('copy');
        showToast('Диагностика скопирована', 'success');
    } catch (e) {
        showToast('Не удалось скопировать. Скопируйте вручную из консоли.', 'error');
        console.log(txt);
    }
    document.body.removeChild(ta);
}

function sendTicket() {
    var type = (document.getElementById('ticket-type') || {}).value || 'bug';
    var module = (document.getElementById('ticket-module') || {}).value || '';
    var subject = (document.getElementById('ticket-subject') || {}).value.trim();
    var body = (document.getElementById('ticket-body') || {}).value.trim();
    var fileInput = document.getElementById('ticket-screenshot');

    // Для бага модуль обязателен (нужен контекст); для идеи/вопроса — нет.
    if (type === 'bug' && !module) { showToast('Выберите модуль', 'warning'); return; }
    if (!subject) { showToast('Введите тему', 'warning'); return; }
    // Для бага описание НЕ обязательно — скриншот обязателен и несёт суть.
    // Для идеи/вопроса описание = единственный контент → оставляем обязательным.
    if (type !== 'bug' && !body) { showToast('Опишите подробнее', 'warning'); return; }

    // Скриншот больше НЕ обязателен: у клиента есть живой чат с поддержкой, где
    // файл можно дослать в любой момент. Отказ принять обращение без картинки
    // просто отсекал тех, кому нечего приложить.
    if (type === 'bug' && !body && (!fileInput || !fileInput.files.length)) {
        showToast('Опишите проблему или приложите скриншот', 'warning');
        return;
    }

    var btn = document.getElementById('btn-send-ticket');
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Отправка...'; }

    // Тег в теме — чтобы саппорт сразу видел тип без изменений на сервере.
    var typeTag = type === 'idea' ? '[Предложение] ' : (type === 'question' ? '[Вопрос] ' : '[Баг] ');

    var fd = new FormData();
    fd.append('type', type);
    fd.append('module', module);
    fd.append('subject', typeTag + subject);
    fd.append('body', body);
    if (fileInput && fileInput.files.length) {
        fd.append('screenshot', fileInput.files[0]);
    }

    fetch('/api/ticket', {method: 'POST', body: fd}).then(function(r) { return r.json(); }).then(function(d) {
        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send"></i> Отправить'; }
        var res = document.getElementById('ticket-result');
        if (d.ok) {
            showToast('Тикет отправлен!', 'success');
            document.getElementById('ticket-subject').value = '';
            document.getElementById('ticket-body').value = '';
            document.getElementById('ticket-module').value = '';
            clearTicketImg();
            if (res) { res.style.display = 'block'; res.style.color = 'var(--success)'; res.textContent = 'Отправлено! Ответим в ближайшее время.'; }
        } else {
            if (res) { res.style.display = 'block'; res.style.color = 'var(--danger)'; res.textContent = d.error || 'Ошибка'; }
        }
    }).catch(function() {
        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send"></i> Отправить'; }
        showToast('Ошибка отправки', 'error');
    });
}

// ── Звук «пришло обновление» ────────────────────────────────────
// Синтезируем короткий «динь-дон» через Web Audio — без аудио-файла в бандле,
// работает офлайн. Играет ОДИН раз при появлении баннера обновления.
// Отключается галкой в Настройках (localStorage, дефолт ВКЛ).
function isUpdateSoundOn() {
    try { return localStorage.getItem('tp_update_sound') !== '0'; }
    catch (e) { return true; }
}

function playUpdateChime(force) {
    try {
        if (!force && !isUpdateSoundOn()) return;  // force=true — превью «Прослушать» играет всегда
        var Ctx = window.AudioContext || window.webkitAudioContext;
        if (!Ctx) return;
        var ctx = window._tpAudioCtx || (window._tpAudioCtx = new Ctx());
        if (ctx.state === 'suspended') { try { ctx.resume(); } catch (e) {} }
        var t = ctx.currentTime;
        // Праздничное восходящее «та-да»: C5→E5→G5→C6 (мажорное арпеджио, как
        // «level-up» в играх) — весело и заметно, сразу читается «вышло новое».
        // triangle даёт яркий игривый тембр, ноты быстрым восходом, финал звенит.
        var notes = [
            [523.25, 0.00, 0.16],
            [659.25, 0.09, 0.16],
            [783.99, 0.18, 0.16],
            [1046.50, 0.30, 0.55]
        ];
        notes.forEach(function(n) {
            var t0 = t + n[1];  // n = [частота, старт, длительность]
            var osc = ctx.createOscillator();
            var g = ctx.createGain();
            osc.type = 'triangle';
            osc.frequency.value = n[0];
            g.gain.setValueAtTime(0.0001, t0);
            g.gain.exponentialRampToValueAtTime(0.2, t0 + 0.015);
            g.gain.exponentialRampToValueAtTime(0.0001, t0 + n[2] + 0.05);
            osc.connect(g); g.connect(ctx.destination);
            osc.start(t0); osc.stop(t0 + n[2] + 0.1);
        });
        // Искорка (E6) поверх финальной ноты — лёгкий «блеск», добавляет «вау».
        var t1 = t + 0.30;
        var s = ctx.createOscillator(), sg = ctx.createGain();
        s.type = 'sine'; s.frequency.value = 1318.51;
        sg.gain.setValueAtTime(0.0001, t1);
        sg.gain.exponentialRampToValueAtTime(0.09, t1 + 0.02);
        sg.gain.exponentialRampToValueAtTime(0.0001, t1 + 0.45);
        s.connect(sg); sg.connect(ctx.destination);
        s.start(t1); s.stop(t1 + 0.5);
    } catch (e) {}
}

function saveUpdateSound(cb) {
    try { localStorage.setItem('tp_update_sound', cb.checked ? '1' : '0'); } catch (e) {}
    if (cb.checked) playUpdateChime();  // мгновенный «предпросмотр» — юзер слышит как звучит
    if (typeof showToast === 'function') {
        showToast(cb.checked ? 'Звук обновления включён' : 'Звук обновления выключен', 'success');
    }
}

function loadUpdateSound() {
    var cb = document.getElementById('s-update-sound');
    if (cb) cb.checked = isUpdateSoundOn();
}

// ── Update checker ──────────────────────────────────────────────

function checkUpdate() {
    API.get('/api/update/check').then(function(d) {
        if (d.available) {
            var _wasShown = window._updateBannerShown;  // звук только на переходе скрыт→показан
            var banner = document.getElementById('update-banner');
            var ver = document.getElementById('update-version');
            var cl = document.getElementById('update-changelog');
            if (banner) banner.style.display = 'flex';
            // Помечаем что баннер показан — иначе /api/status-поллер не сможет
            // его спрятать (его hide-условие: !update_available && _updateBannerShown).
            window._updateBannerShown = true;
            if (ver) ver.textContent = 'v' + d.version;
            if (cl && d.changelog) cl.textContent = d.changelog;
            if (!_wasShown) playUpdateChime();

            if (d.force_download) {
                var btn = document.querySelector('#update-banner .btn');
                if (btn) {
                    btn.onclick = function() { window.open(d.download_url || 'https://telepush.org', '_blank'); };
                    btn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Скачать с сайта';
                }
            }
        } else {
            // Нет обновления (уже свежая версия) — прячем баннер, не залипает.
            window._updateBannerShown = false;
            var _b = document.getElementById('update-banner');
            if (_b) _b.style.display = 'none';
        }
    }).catch(function() {});
}
// Проверяем обновления каждые 2 минуты
setInterval(checkUpdate, 120000);

// ── Broadcast (объявления от админа) ─────────────────────────────────────────
var _BC_LEVELS = {
    info:     { color: '#3b82f6', bg: 'rgba(59,130,246,0.10)', border: 'rgba(59,130,246,0.45)', icon: 'bi-info-circle-fill' },
    success:  { color: '#22c55e', bg: 'rgba(34,197,94,0.10)',  border: 'rgba(34,197,94,0.45)',  icon: 'bi-check-circle-fill' },
    warning:  { color: '#eab308', bg: 'rgba(234,179,8,0.10)',  border: 'rgba(234,179,8,0.45)',  icon: 'bi-exclamation-triangle-fill' },
    critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.12)',  border: 'rgba(239,68,68,0.50)',  icon: 'bi-megaphone-fill' }
};

function pollBroadcast() {
    fetch('/api/broadcast/check').then(function(r) { return r.json(); }).then(function(d) {
        var ann = d && d.announcement;
        var banner = document.getElementById('broadcast-banner');
        if (!banner) return;
        if (!ann) {
            banner.style.display = 'none';
            return;
        }
        // Проверяем что юзер не закрыл именно это объявление
        var dismissedId = null;
        try { dismissedId = localStorage.getItem('broadcast_dismissed_id'); } catch (e) {}
        if (dismissedId && String(dismissedId) === String(ann.id)) {
            banner.style.display = 'none';
            return;
        }
        var lvl = _BC_LEVELS[ann.level] || _BC_LEVELS.info;
        banner.style.background = lvl.bg;
        banner.style.borderColor = lvl.border;
        banner.style.color = lvl.color;
        var ic = document.getElementById('broadcast-icon');
        if (ic) {
            ic.className = 'bi ' + lvl.icon;
            ic.style.color = lvl.color;
        }
        var t = document.getElementById('broadcast-title');
        if (t) {
            t.textContent = ann.title || '';
            t.style.display = ann.title ? 'block' : 'none';
        }
        var txt = document.getElementById('broadcast-text');
        if (txt) txt.textContent = ann.text || '';
        banner.dataset.bcId = String(ann.id);
        banner.style.display = 'block';
    }).catch(function() {});
}

// Launcher banner: показываем ТОЛЬКО если у клиента старый лаунчер.
// Версия лаунчера лежит в data/state/launcher_ver.txt (пишет сам лаунчер).
// Если версия >= 2.1.6 — баннер не показываем.
var MIN_LAUNCHER_VER_FOR_DIALOG = '2.1.6';
function _verLE(a, b) {
    var pa = String(a || '0').split('.').map(function(x){return parseInt(x)||0;});
    var pb = String(b || '0').split('.').map(function(x){return parseInt(x)||0;});
    while (pa.length < 3) pa.push(0);
    while (pb.length < 3) pb.push(0);
    for (var i = 0; i < 3; i++) {
        if (pa[i] < pb[i]) return true;
        if (pa[i] > pb[i]) return false;
    }
    return true;  // равно
}
function showLauncherBannerIfNeeded() {
    fetch('/api/paths/info', {credentials:'same-origin'})
        .then(function(r){ return r.json(); })
        .then(function(d) {
            var lv = (d && d.launcher_version) || '';
            // Дефолт «лаунчер ОК». Показываем баннер ТОЛЬКО когда точно знаем
            // что лаунчер старый (lv задан И < MIN); пустой lv — не показываем.
            if (!lv) return;
            if (!_verLE(lv, MIN_LAUNCHER_VER_FOR_DIALOG)) return;
            if (lv === MIN_LAUNCHER_VER_FOR_DIALOG) return;
            // Если dismiss был — не показываем (теперь forever, не 7 дней).
            try {
                if (localStorage.getItem('launcher_banner_dismissed') === '1') return;
            } catch(_){}
            var banner = document.getElementById('launcher-update-banner');
            if (banner) banner.style.display = 'flex';
        })
        .catch(function(){});
}
function dismissLauncherBanner() {
    // dismiss теперь forever (не 7 дней).
    try { localStorage.setItem('launcher_banner_dismissed', '1'); } catch(_) {}
    var banner = document.getElementById('launcher-update-banner');
    if (banner) banner.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function(){
    setTimeout(showLauncherBannerIfNeeded, 2500);
});

function dismissBroadcast() {
    var banner = document.getElementById('broadcast-banner');
    if (!banner) return;
    var id = banner.dataset.bcId;
    if (id) {
        try { localStorage.setItem('broadcast_dismissed_id', id); } catch (e) {}
    }
    banner.style.display = 'none';
}

// Сразу опрашиваем при загрузке + раз в 5 минут
setTimeout(pollBroadcast, 2000);
setInterval(pollBroadcast, 300000);

function installUpdate() {
    showConfirmDialog(
        'Установить обновление?',
        'Все активные задачи будут остановлены. Софт перезапустится автоматически.',
        function() {
            showToast('Скачиваю обновление... Не закрывайте программу!', 'info');
            _doInstallUpdate();
        }
    );
}

function _doInstallUpdate() {
    var btn = document.querySelector('#update-banner .btn');
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;display:inline-block;"></i> Скачиваю...'; }

    // backend async — старт мгновенный, статус через polling (синхронный POST
    // висел до конца установки и waitress рвал channel за 60с).
    API.post('/api/update/install').then(function(d) {
        if (!d || !d.ok) {
            var msg = (d && d.message) || 'Не удалось запустить установку';
            _renderUpdateError(msg, 'https://telepush.org/api/download?v=latest', btn);
            return;
        }
        // Поллим статус каждые 3с
        var elapsed = 0;
        var poll = setInterval(function() {
            elapsed += 3;
            API.get('/api/update/status').then(function(s) {
                if (!s) return;
                if (s.status === 'error') {
                    clearInterval(poll);
                    _renderUpdateError(s.message || 'Ошибка установки',
                        'https://telepush.org/api/download?v=latest', btn);
                } else if (btn) {
                    btn.innerHTML = '<i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;display:inline-block;"></i> Скачиваю... (' + elapsed + 'с)';
                }
            }).catch(function() {
                // catch — норма. Flask завершился os._exit после успешной установки,
                // лаунчер перезапустит с новой версией.
                clearInterval(poll);
                showToast('Установка завершена! Перезапуск...', 'success');
            });
        }, 3000);
    }).catch(function(err) {
        var msg = 'Не удалось связаться с Flask-сервером (программа зависла?). Перезапусти TelePush или скачай вручную.';
        _renderUpdateError(msg, 'https://telepush.org/api/download?v=latest', btn);
    });
}

function _renderUpdateError(msg, manualUrl, btn) {
    showToast('Ошибка обновления: ' + msg, 'error');
    try {
        if (typeof addLog === 'function') {
            addLog('Ошибка обновления: ' + msg, 'ERROR');
            addLog('Скачать вручную: ' + manualUrl + ' — распакуй поверх папки TelePush, папку data/ не трогать', 'INFO');
        }
    } catch (_) {}
    if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download"></i> Обновить'; }
    // Показываем кнопку «Скачать вручную» рядом с основной
    var banner = document.getElementById('update-banner');
    if (banner && !document.getElementById('update-manual-btn')) {
        var a = document.createElement('a');
        a.id = 'update-manual-btn';
        a.href = manualUrl;
        a.target = '_blank';
        a.rel = 'noopener';
        a.className = 'btn btn-outline btn-sm';
        a.style.cssText = 'margin-left:8px;white-space:nowrap;';
        a.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Скачать вручную';
        if (btn && btn.parentNode) btn.parentNode.insertBefore(a, btn.nextSibling);
    }
}

// ── Telegram Messenger Panel ────────────────────────────────────

var _tgPanelOpen = false;
var _tgCurrentChat = null;
// Пин аккаунта слайд-панели: панель помнит phone (из ответа /api/tg/dialogs) и
// шлёт его явно во все вызовы — иначе две вкладки панели = действие над чужим акком.
var _tgPanelPhone = null;
function _tgInject(payload) {
    payload = payload || {};
    if (_tgPanelPhone) payload.phone = _tgPanelPhone;
    return payload;
}
function _tgQS() {
    return _tgPanelPhone ? ('?phone=' + encodeURIComponent(_tgPanelPhone)) : '';
}

function toggleTgPanel() {
    _tgPanelOpen = !_tgPanelOpen;
    var panel = document.getElementById('tg-panel');
    if (panel) panel.style.right = _tgPanelOpen ? '0' : '-420px';
    if (_tgPanelOpen) loadTgDialogs();
}

function _tgSetHeader(title, sub, showBack) {
    var t = document.getElementById('tg-header-title');
    var s = document.getElementById('tg-header-sub');
    var b = document.getElementById('tg-back-btn');
    var icon = document.getElementById('tg-header-icon');
    var tabs = document.getElementById('tg-tabs');
    var info = document.getElementById('tg-info-btn');
    if (t) t.textContent = title;
    if (s) { s.textContent = sub || ''; s.style.display = sub ? 'block' : 'none'; }
    if (b) b.style.display = showBack ? '' : 'none';
    if (icon) icon.style.display = showBack ? 'none' : '';
    if (tabs) tabs.style.display = showBack ? 'none' : 'flex';
    if (info) info.style.display = showBack ? '' : 'none';
}

function showTgUserInfo() {
    if (!_tgCurrentChat) return;
    var modal = document.createElement('div');
    modal.id = 'tg-user-modal';
    modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:flex;align-items:center;justify-content:center;';
    modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
    modal.innerHTML = '<div style="background:var(--bg-card-hover);border:1px solid var(--border);border-radius:7px;padding:20px;max-width:380px;width:90%;max-height:85vh;overflow-y:auto;"><div style="text-align:center;color:var(--text-muted);padding:24px;"><i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;font-size:1.4rem;"></i></div></div>';
    document.body.appendChild(modal);

    API.get('/api/tg/user_info?chat_id=' + _tgCurrentChat).then(function(d) {
        var box = modal.querySelector('div');
        if (!box) return;
        if (d.error) {
            box.innerHTML = '<div style="color:#f87171;padding:20px;text-align:center;">Ошибка: ' + escHtml(d.error) + '</div>' +
                '<button onclick="document.getElementById(\'tg-user-modal\').remove()" style="width:100%;padding:8px;background:var(--accent);border:none;border-radius:5px;color:#fff;cursor:pointer;">Закрыть</button>';
            return;
        }
        var html = '';
        var avatarHtml = '<div style="text-align:center;margin-bottom:16px;"><img src="/api/tg/avatar?chat_id=' + _tgCurrentChat + '" style="width:90px;height:90px;border-radius:50%;object-fit:cover;border:2px solid #252540;" onerror="this.style.display=\'none\'"></div>';
        if (d.type === 'user') {
            var name = (d.first_name + ' ' + d.last_name).trim() || 'Без имени';
            var badges = '';
            if (d.is_premium) badges += '<span style="display:inline-block;background:#fef3c7;color:#92400e;padding:2px 6px;border-radius:4px;font-size:0.65rem;font-weight:600;margin-right:4px;"><i class="bi bi-star-fill"></i> Premium</span>';
            if (d.is_verified) badges += '<span style="display:inline-block;background:#dbeafe;color:#1e40af;padding:2px 6px;border-radius:4px;font-size:0.65rem;font-weight:600;margin-right:4px;">✓ Verified</span>';
            if (d.is_bot) badges += '<span style="display:inline-block;background:#ede9fe;color:#5b21b6;padding:2px 6px;border-radius:4px;font-size:0.65rem;font-weight:600;margin-right:4px;">Bot</span>';
            if (d.is_scam) badges += '<span style="display:inline-block;background:#fee2e2;color:#991b1b;padding:2px 6px;border-radius:4px;font-size:0.65rem;font-weight:600;margin-right:4px;"><i class="bi bi-exclamation-triangle-fill"></i> Scam</span>';
            if (d.is_fake) badges += '<span style="display:inline-block;background:#fee2e2;color:#991b1b;padding:2px 6px;border-radius:4px;font-size:0.65rem;font-weight:600;margin-right:4px;"><i class="bi bi-exclamation-triangle-fill"></i> Fake</span>';
            html = avatarHtml +
                '<h3 style="text-align:center;color:#fff;font-size:1.1rem;margin-bottom:4px;">' + escHtml(name) + '</h3>' +
                (d.username ? '<div style="text-align:center;color:var(--accent-light);font-size:0.82rem;margin-bottom:10px;">@' + escHtml(d.username) + '</div>' : '') +
                (badges ? '<div style="text-align:center;margin-bottom:14px;">' + badges + '</div>' : '') +
                '<div style="background:var(--bg-input);border-radius:5px;padding:12px;margin-bottom:12px;font-size:0.78rem;">' +
                    '<div style="display:flex;justify-content:space-between;margin-bottom:6px;"><span style="color:var(--text-muted);">ID</span><span style="color:#fff;font-family:monospace;">' + d.id + '</span></div>' +
                    (d.phone ? '<div style="display:flex;justify-content:space-between;margin-bottom:6px;"><span style="color:var(--text-muted);">Телефон</span><span style="color:#fff;">+' + escHtml(d.phone) + '</span></div>' : '') +
                    (d.common_chats ? '<div style="display:flex;justify-content:space-between;"><span style="color:var(--text-muted);">Общих чатов</span><span style="color:#fff;">' + d.common_chats + '</span></div>' : '') +
                '</div>' +
                (d.about ? '<div style="background:var(--bg-input);border-radius:5px;padding:12px;margin-bottom:12px;"><div style="color:var(--text-muted);font-size:0.7rem;margin-bottom:4px;text-transform:uppercase;">О себе</div><div style="color:#fff;font-size:0.82rem;white-space:pre-wrap;">' + escHtml(d.about) + '</div></div>' : '');
        } else {
            html = avatarHtml +
                '<h3 style="text-align:center;color:#fff;font-size:1.1rem;margin-bottom:4px;">' + escHtml(d.title || '') + '</h3>' +
                (d.username ? '<div style="text-align:center;color:var(--accent-light);font-size:0.82rem;margin-bottom:10px;">@' + escHtml(d.username) + '</div>' : '') +
                '<div style="background:var(--bg-input);border-radius:5px;padding:12px;margin-bottom:12px;font-size:0.78rem;">' +
                    '<div style="display:flex;justify-content:space-between;margin-bottom:6px;"><span style="color:var(--text-muted);">ID</span><span style="color:#fff;font-family:monospace;">' + d.id + '</span></div>' +
                    '<div style="display:flex;justify-content:space-between;margin-bottom:6px;"><span style="color:var(--text-muted);">Тип</span><span style="color:#fff;">' + (d.type === 'channel' ? 'Канал' : 'Группа') + '</span></div>' +
                    (d.participants_count ? '<div style="display:flex;justify-content:space-between;"><span style="color:var(--text-muted);">Участников</span><span style="color:#fff;">' + d.participants_count + '</span></div>' : '') +
                '</div>' +
                (d.about ? '<div style="background:var(--bg-input);border-radius:5px;padding:12px;margin-bottom:12px;"><div style="color:var(--text-muted);font-size:0.7rem;margin-bottom:4px;text-transform:uppercase;">Описание</div><div style="color:#fff;font-size:0.82rem;white-space:pre-wrap;">' + escHtml(d.about) + '</div></div>' : '');
        }
        html += '<button onclick="document.getElementById(\'tg-user-modal\').remove()" style="width:100%;padding:10px;background:var(--accent);border:none;border-radius:5px;color:#fff;cursor:pointer;font-weight:600;">Закрыть</button>';
        box.innerHTML = html;
    });
}

function tgShowTab(btn, tab) {
    document.querySelectorAll('.tg-tab').forEach(function(b) {
        b.style.color = '#64748b';
        b.style.borderBottomColor = 'transparent';
    });
    btn.style.color = '#a78bfa';
    btn.style.borderBottomColor = '#7c3aed';
    ['chats','replies','log'].forEach(function(t) {
        var el = document.getElementById('tg-' + t);
        if (el) el.style.display = t === tab ? 'block' : 'none';
    });
    _tgSetHeader('Telegram', '', false);
    _tgCurrentChat = null;
    if (tab === 'chats') loadTgDialogs();
    if (tab === 'replies') loadTgReplies();
}

function loadTgDialogs() {
    _tgCurrentChat = null;
    _tgSetHeader('Telegram', '', false);
    var tabs = document.getElementById('tg-tabs');
    if (tabs) tabs.style.display = 'flex';

    API.get('/api/tg/dialogs' + _tgQS()).then(function(d) {
        var el = document.getElementById('tg-chats');
        if (!el) return;
        // Пиним акк, который реально использовал бэкенд — далее все мутации идут на него.
        if (d && d.phone) _tgPanelPhone = d.phone;
        if (d.error) {
            el.innerHTML = '<div style="text-align:center;color:#f59e0b;padding:32px;font-size:0.8rem;"><i class="bi bi-exclamation-triangle" style="font-size:1.5rem;display:block;margin-bottom:8px;"></i>' + escHtml(d.error) + '</div>';
            return;
        }
        if (d.busy) {
            el.innerHTML = '<div style="text-align:center;color:#f59e0b;padding:32px;font-size:0.8rem;"><i class="bi bi-lock" style="font-size:1.5rem;display:block;margin-bottom:8px;"></i>' + escHtml(d.msg || 'Аккаунт занят задачей') + '</div>';
            return;
        }
        if (!d.dialogs || !d.dialogs.length) {
            el.innerHTML = '<div style="text-align:center;color:#64748b;padding:32px;"><i class="bi bi-chat-square-text" style="font-size:1.5rem;display:block;margin-bottom:8px;"></i>Нет чатов</div>';
            return;
        }
        el.innerHTML = d.dialogs.map(function(c) {
            var avatarBg = c.type === 'user' ? '#3b82f6' : c.type === 'channel' ? '#f59e0b' : '#22c55e';
            var initial = (c.name || '?').charAt(0).toUpperCase();
            var unread = c.unread > 0 ? '<span style="background:var(--accent);color:#fff;border-radius:5px;padding:2px 7px;font-size:0.6rem;font-weight:700;min-width:18px;text-align:center;">' + c.unread + '</span>' : '';
            var lastMsg = c.last_message || '';
            var timeStr = '';
            if (c.date) {
                try { var dt = new Date(c.date); timeStr = dt.getHours() + ':' + (dt.getMinutes()<10?'0':'') + dt.getMinutes(); } catch(e) {}
            }
            // data-атрибуты вместо inline onclick — имена с кавычками/эмодзи/апострофами
            // раньше ломали JS-строку и чат просто не открывался.
            return '<div class="tg-chat-item" data-chat-id="' + c.id + '" data-chat-type="' + escHtml(c.type) + '" data-chat-name="' + escHtml(c.name) + '" style="padding:10px 12px;border-bottom:1px solid #1a1a2e;cursor:pointer;transition:background 0.15s;">' +
                '<div style="display:flex;align-items:center;gap:10px;pointer-events:none;">' +
                    '<div style="width:40px;height:40px;border-radius:50%;background:' + avatarBg + ';display:flex;align-items:center;justify-content:center;flex-shrink:0;font-weight:700;font-size:0.9rem;color:#fff;overflow:hidden;position:relative;">' +
                        '<img src="/api/tg/avatar?chat_id=' + c.id + '" style="width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;" onerror="this.style.display=\'none\'">' +
                        initial +
                    '</div>' +
                    '<div style="flex:1;min-width:0;">' +
                        '<div style="display:flex;justify-content:space-between;align-items:center;">' +
                            '<span style="font-size:0.82rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">' + escHtml(c.name) + '</span>' +
                            '<span style="font-size:0.6rem;color:var(--text-muted);flex-shrink:0;">' + timeStr + '</span>' +
                        '</div>' +
                        '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:2px;">' +
                            '<span style="font-size:0.72rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;">' + escHtml(lastMsg) + '</span>' +
                            unread +
                        '</div>' +
                    '</div>' +
                '</div>' +
            '</div>';
        }).join('');
        // Делегированный handler — один раз на контейнер, берём атрибуты из элемента
        if (!el._tgClickBound) {
            el.addEventListener('click', function(ev) {
                var item = ev.target.closest('.tg-chat-item');
                if (!item) return;
                var cid = parseInt(item.getAttribute('data-chat-id'));
                var cname = item.getAttribute('data-chat-name') || '';
                var ctype = item.getAttribute('data-chat-type') || 'user';
                if (!isNaN(cid)) loadTgMessages(cid, cname, ctype);
            });
            el._tgClickBound = true;
        }
        // Hover-эффект через CSS вместо inline
        el.querySelectorAll('.tg-chat-item').forEach(function(it) {
            it.onmouseenter = function() { this.style.background = '#161028'; };
            it.onmouseleave = function() { this.style.background = 'transparent'; };
        });
    }).catch(function() {});
}

function _tgRenderMedia(m) {
    if (!m.media) return '';
    var chatId = m.chat_id;
    var msgId = m.id;
    if (m.media === 'photo') {
        return '<img src="/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '&thumb=1" style="max-width:100%;border-radius:4px;margin:4px 0;cursor:pointer;max-height:250px;object-fit:cover;" onclick="window.open(\'/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '\',\'_blank\')" loading="lazy">';
    }
    if (m.media === 'video' || m.media === 'gif' || m.media === 'video_note') {
        var isRound = m.media === 'video_note';
        var roundStyle = isRound ? 'border-radius:50%;width:200px;height:200px;object-fit:cover;' : 'max-width:100%;border-radius:4px;max-height:300px;';
        return '<video src="/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '" style="' + roundStyle + 'margin:4px 0;" controls ' + (m.media === 'gif' ? 'autoplay loop muted' : '') + '></video>';
    }
    if (m.media === 'sticker') {
        return '<img src="/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '" style="max-width:120px;max-height:120px;margin:4px 0;" loading="lazy">';
    }
    if (m.media === 'voice') {
        return '<audio src="/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '" controls style="width:100%;max-width:250px;height:32px;margin:4px 0;"></audio>';
    }
    if (m.media === 'audio') {
        return '<div style="margin:4px 0;">' +
            '<div style="font-size:0.7rem;color:var(--text-muted);margin-bottom:2px;">' + escHtml(m.media_name || 'Аудио') + '</div>' +
            '<audio src="/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '" controls style="width:100%;max-width:280px;height:32px;"></audio>' +
        '</div>';
    }
    if (m.media === 'document') {
        return '<div style="background:var(--surface-info-bg);border-radius:4px;padding:8px;margin:4px 0;display:flex;align-items:center;gap:6px;cursor:pointer;" onclick="window.open(\'/api/tg/media?chat_id=' + chatId + '&msg_id=' + msgId + '\',\'_blank\')">' +
            '<i class="bi bi-file-earmark" style="color:var(--warning);font-size:1.1rem;"></i>' +
            '<span style="font-size:0.72rem;color:var(--accent-light);">' + escHtml(m.media_name || 'Файл') + '</span>' +
        '</div>';
    }
    return '<span style="font-size:0.7rem;color:var(--text-muted);font-style:italic;">[' + m.media + ']</span>';
}

function loadTgMessages(chatId, chatName, chatType) {
    _tgCurrentChat = chatId;
    var typeLabel = chatType === 'channel' ? 'канал' : chatType === 'group' ? 'группа' : '';
    _tgSetHeader(chatName || 'Чат', typeLabel, true);

    var el = document.getElementById('tg-chats');
    if (el) el.innerHTML = '<div style="text-align:center;padding:32px;color:var(--text-muted);"><i class="bi bi-arrow-repeat" style="animation:spin 1s linear infinite;display:inline-block;font-size:1.2rem;"></i></div>';

    API.get('/api/tg/messages?chat_id=' + chatId + (_tgPanelPhone ? '&phone=' + encodeURIComponent(_tgPanelPhone) : '')).then(function(d) {
        if (_tgCurrentChat !== chatId) return;
        if (!el) return;
        if (!d.messages || !d.messages.length) {
            el.innerHTML = '<div style="text-align:center;padding:32px;color:var(--text-muted);">Нет сообщений</div>';
            return;
        }
        var msgs = d.messages.reverse();
        // Reply/Edit state
        var replyHtml = '<div id="tg-reply-bar" style="display:none;padding:6px 12px;background:var(--bg-card-hover);border-top:1px solid #252540;font-size:0.72rem;color:var(--accent-light);align-items:center;gap:8px;">' +
            '<i class="bi bi-reply"></i> <span id="tg-reply-text"></span>' +
            '<span style="margin-left:auto;cursor:pointer;color:var(--text-muted);" onclick="cancelTgReply()"><i class="bi bi-x"></i></span>' +
        '</div>';

        // File attach button + input bar
        var inputHtml = replyHtml +
            '<div id="tg-input-bar" style="padding:8px;border-top:1px solid var(--border);display:flex;gap:6px;align-items:flex-end;background:var(--bg-input);">' +
            '<label style="cursor:pointer;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:5px;background:var(--bg-input);border:1px solid var(--border);flex-shrink:0;" title="Прикрепить файл">' +
                '<i class="bi bi-paperclip" style="color:var(--text-muted);font-size:1rem;"></i>' +
                '<input type="file" id="tg-file-input" style="display:none;" onchange="sendTgFile()">' +
            '</label>' +
            '<textarea id="tg-msg-input" rows="1" placeholder="Написать сообщение..." style="flex:1;resize:none;background:var(--bg-input);border:1px solid var(--border);border-radius:5px;padding:8px 12px;color:var(--text-primary);font-size:0.8rem;font-family:inherit;outline:none;max-height:80px;min-height:34px;" onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();sendTgMsg()}"></textarea>' +
            '<button onclick="sendTgMsg()" style="background:var(--accent);border:none;border-radius:5px;width:36px;height:36px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><i class="bi bi-send-fill" style="color:#fff;font-size:0.85rem;"></i></button>' +
        '</div>';

        el.innerHTML = '<div style="padding:8px;flex:1;overflow-y:auto;" id="tg-msgs-scroll">' + msgs.map(function(m) {
            var isOut = m.out;
            var bubbleBg = isOut ? '#1a1040' : '#161623';
            var bubbleAlign = isOut ? 'margin-left:15%;' : 'margin-right:15%;';
            var senderColor = isOut ? 'var(--accent-light)' : '#3b82f6';
            var mediaHtml = _tgRenderMedia(m);
            var textHtml = m.text ? '<div style="font-size:0.78rem;word-break:break-word;line-height:1.45;">' + escHtml(m.text) + '</div>' : '';
            var replyPreview = m.reply_text ? '<div style="border-left:2px solid var(--accent);padding:2px 6px;margin-bottom:4px;font-size:0.65rem;color:var(--text-muted);"><b>' + escHtml(m.reply_sender || '') + '</b> ' + escHtml(m.reply_text).substring(0, 60) + '</div>' : '';
            var avatarUrl = '/api/tg/avatar?chat_id=' + (m.sender_id || m.chat_id);

            // Context menu data
            var dataAttrs = 'data-mid="' + m.id + '" data-cid="' + chatId + '" data-out="' + (isOut?1:0) + '" data-text="' + escHtml(m.text || '').replace(/"/g,'&quot;') + '"';

            return '<div class="tg-bubble" ' + dataAttrs + ' oncontextmenu="tgMsgMenu(event,this)" style="' + bubbleAlign + 'background:' + bubbleBg + ';border-radius:5px;padding:6px 10px;margin-bottom:4px;max-width:85%;position:relative;cursor:default;">' +
                '<div style="display:flex;gap:6px;align-items:flex-start;">' +
                (isOut ? '' : '<img src="' + avatarUrl + '" style="width:28px;height:28px;border-radius:50%;object-fit:cover;flex-shrink:0;margin-top:2px;" onerror="this.style.display=\'none\'">') +
                '<div style="flex:1;min-width:0;">' +
                (isOut ? '' : '<div style="font-size:0.62rem;color:' + senderColor + ';font-weight:600;margin-bottom:2px;">' + escHtml(m.sender || '') + '</div>') +
                replyPreview +
                mediaHtml +
                textHtml +
                '<div style="text-align:right;margin-top:2px;display:flex;justify-content:flex-end;align-items:center;gap:4px;">' +
                    (m.edited ? '<span style="font-size:0.5rem;color:#4a5568;">ред.</span>' : '') +
                    '<span style="font-size:0.55rem;color:#4a5568;">' + (m.time || '') + '</span>' +
                '</div>' +
                '</div></div>' +
            '</div>';
        }).join('') + '</div>' + inputHtml;

        // Mark as read
        API.post('/api/tg/read', _tgInject({chat_id: chatId})).catch(function(){});

        var scroll = document.getElementById('tg-msgs-scroll');
        if (scroll) scroll.scrollTop = scroll.scrollHeight;
    }).catch(function() {
        if (el) el.innerHTML = '<div style="text-align:center;padding:32px;color:var(--danger);">Ошибка загрузки</div>';
    });
}

var _tgReplyTo = null;
var _tgEditId = null;

function tgMsgMenu(e, el) {
    e.preventDefault();
    var old = document.getElementById('tg-ctx-menu');
    if (old) old.remove();
    var mid = el.dataset.mid;
    var cid = el.dataset.cid;
    var isOut = el.dataset.out === '1';
    var text = el.dataset.text;

    var menu = document.createElement('div');
    menu.id = 'tg-ctx-menu';
    menu.style.cssText = 'position:fixed;z-index:9999;background:var(--bg-input);border:1px solid var(--border);border-radius:5px;padding:4px 0;min-width:160px;';
    menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
    menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';

    function item(icon, label, fn) {
        var d = document.createElement('div');
        d.style.cssText = 'padding:8px 14px;font-size:0.78rem;cursor:pointer;display:flex;align-items:center;gap:8px;color:var(--text-secondary);';
        d.innerHTML = '<i class="bi ' + icon + '" style="font-size:0.85rem;width:16px;"></i>' + label;
        d.onmouseover = function() { d.style.background = '#1a1a2e'; };
        d.onmouseout = function() { d.style.background = 'none'; };
        d.onclick = function() { menu.remove(); fn(); };
        menu.appendChild(d);
    }

    item('bi-reply', 'Ответить', function() { setTgReply(mid, text); });
    if (isOut && text) item('bi-pencil', 'Редактировать', function() { setTgEdit(mid, cid, text); });
    item('bi-forward', 'Переслать', function() { tgForwardMsg(mid, cid); });
    if (isOut) item('bi-trash', 'Удалить', function() { tgDeleteMsg(mid, cid); });

    document.body.appendChild(menu);
    setTimeout(function() {
        document.addEventListener('click', function _close() { menu.remove(); document.removeEventListener('click', _close); }, {once: true});
    }, 50);
}

function setTgReply(mid, text) {
    _tgReplyTo = mid;
    _tgEditId = null;
    var bar = document.getElementById('tg-reply-bar');
    var rt = document.getElementById('tg-reply-text');
    if (bar) { bar.style.display = 'flex'; }
    if (rt) rt.textContent = 'Ответ: ' + (text || '').substring(0, 50);
    var input = document.getElementById('tg-msg-input');
    if (input) input.focus();
}

function setTgEdit(mid, cid, text) {
    _tgEditId = mid;
    _tgReplyTo = null;
    var bar = document.getElementById('tg-reply-bar');
    var rt = document.getElementById('tg-reply-text');
    if (bar) { bar.style.display = 'flex'; }
    if (rt) rt.textContent = 'Редактирование';
    var input = document.getElementById('tg-msg-input');
    if (input) { input.value = text.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"'); input.focus(); }
}

function cancelTgReply() {
    _tgReplyTo = null;
    _tgEditId = null;
    var bar = document.getElementById('tg-reply-bar');
    if (bar) bar.style.display = 'none';
    var input = document.getElementById('tg-msg-input');
    if (input) input.value = '';
}

async function tgDeleteMsg(mid, cid) {
    if (!(await appConfirm('Удалить сообщение у обоих?', {danger: true}))) return;
    API.post('/api/tg/delete', _tgInject({chat_id: cid, msg_ids: [mid]})).then(function(d) {
        if (d.ok) loadTgMessages(_tgCurrentChat, '', '');
        else showToast(d.error || 'Ошибка', 'error');
    });
}

function tgForwardMsg(mid, cid) {
    tpPrompt({
        title: 'Переслать сообщение',
        hint: 'Куда переслать: @username или числовой chat_id.',
        placeholder: '@channel или -1001234567890'
    }).then(function(to) {
        if (!to) return;
        API.post('/api/tg/forward', _tgInject({from_chat: cid, to_chat: to, msg_ids: [mid]})).then(function(d) {
            if (d.ok) showToast('Переслано', 'success');
            else showToast(d.error || 'Ошибка', 'error');
        });
    });
}

function sendTgFile() {
    var fileInput = document.getElementById('tg-file-input');
    if (!fileInput || !fileInput.files.length || !_tgCurrentChat) return;
    var fd = new FormData();
    fd.append('file', fileInput.files[0]);
    fd.append('chat_id', _tgCurrentChat);
    fd.append('caption', '');
    if (_tgPanelPhone) fd.append('phone', _tgPanelPhone);
    fetch('/api/tg/send_file', {method: 'POST', body: fd}).then(function(r) { return r.json(); }).then(function(d) {
        if (d.ok) loadTgMessages(_tgCurrentChat, '', '');
        else showToast(d.error || 'Ошибка', 'error');
    }).catch(function() { showToast('Ошибка загрузки', 'error'); });
    fileInput.value = '';
}

function sendTgMsg() {
    var input = document.getElementById('tg-msg-input');
    if (!input || !_tgCurrentChat) return;
    var text = input.value.trim();
    if (!text) return;
    input.disabled = true;

    var promise;
    if (_tgEditId) {
        promise = API.post('/api/tg/edit', _tgInject({chat_id: _tgCurrentChat, msg_id: _tgEditId, text: text}));
    } else {
        var payload = {chat_id: _tgCurrentChat, text: text};
        if (_tgReplyTo) payload.reply_to = _tgReplyTo;
        promise = API.post('/api/tg/send', _tgInject(payload));
    }

    promise.then(function(d) {
        input.disabled = false;
        if (d.ok) {
            input.value = '';
            cancelTgReply();
            loadTgMessages(_tgCurrentChat, '', '');
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) {
        input.disabled = false;
        showToast((e && (e.message || e.error)) || 'Ошибка', 'error');
    });
}

function loadTgReplies() {
    API.get('/api/tg/incoming').then(function(d) {
        var el = document.getElementById('tg-replies');
        if (!el) return;
        if (!d.replies || !d.replies.length) {
            el.innerHTML = '<div style="text-align:center;color:#64748b;padding:32px;"><i class="bi bi-chat-dots" style="font-size:1.5rem;display:block;margin-bottom:8px;"></i>Нет входящих</div>';
            return;
        }
        el.innerHTML = d.replies.map(function(r) {
            var initial = (r.user || '?').charAt(0).toUpperCase();
            return '<div onclick="loadTgMessages(' + r.user_id + ',\'' + escHtml(r.user).replace(/'/g,"\\'") + '\',\'user\')" style="padding:10px 12px;border-bottom:1px solid #1a1a2e;cursor:pointer;transition:background 0.15s;" onmouseover="this.style.background=\'#161028\'" onmouseout="this.style.background=\'none\'">' +
                '<div style="display:flex;align-items:center;gap:10px;">' +
                    '<div style="width:36px;height:36px;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-weight:700;font-size:0.8rem;color:#fff;">' + initial + '</div>' +
                    '<div style="flex:1;min-width:0;">' +
                        '<div style="display:flex;justify-content:space-between;align-items:center;">' +
                            '<span style="font-size:0.8rem;font-weight:600;">' + escHtml(r.user) + '</span>' +
                            '<span style="background:var(--accent);color:#fff;border-radius:5px;padding:2px 7px;font-size:0.6rem;font-weight:700;">' + r.unread + '</span>' +
                        '</div>' +
                        '<div style="font-size:0.72rem;color:#94a3b8;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + escHtml(r.text) + '</div>' +
                    '</div>' +
                '</div>' +
            '</div>';
        }).join('');
    }).catch(function() {});
}

// ── Warnings — proxy & timings ──────────────────────────────────

function checkWarnings() {
    // Помним что уже показывали через localStorage — не повторяем при каждом рестарте.
    function _warnedOnce(key) {
        try { return localStorage.getItem('tp_warn_' + key) === '1'; } catch(e) { return false; }
    }
    function _markWarned(key) {
        try { localStorage.setItem('tp_warn_' + key, '1'); } catch(e) {}
    }
    // Если у клиента 0 акков — это первый запуск, предупреждения про прокси
    // и тайминги бессмысленны, юзер ещё ничего не настраивал.
    API.get('/api/accounts').then(function(accD) {
        if (!accD.accounts || !accD.accounts.length) return;
        API.get('/api/proxy/list').then(function(p) {
            var proxies = p.proxies || [];
            var total = proxies.length;
            var alive = proxies.filter(function(x){ return x.alive === true; }).length;
            var unchecked = proxies.filter(function(x){ return x.alive === null || x.alive === undefined || x.status === 'unchecked'; }).length;
            if (total === 0 && !_warnedOnce('no_proxy')) {
                if (typeof addLog === 'function') addLog('Прокси не добавлен. Без прокси аккаунт может быть заблокирован!', 'WARNING');
                _markWarned('no_proxy');
            } else if (alive === 0 && unchecked === 0 && !_warnedOnce('proxy_all_dead')) {
                if (typeof addLog === 'function') addLog('Все прокси мёртвые! Нажмите «Проверить все» в настройках', 'WARNING');
                _markWarned('proxy_all_dead');
            }
        }).catch(function(){});
        // Проверяем текущие тайминги + состояние файла настроек.
        // Warning только если клиент ТОЧНО ещё не настраивал (все дефолты + файл не пишется).
        API.get('/api/settings').then(function(s) {
            if (!s.spam) return;
            var usingDefaults = (s.spam.min_delay === 6 && s.spam.max_delay === 14 &&
                                  s.spam.burst_size === 7);
            if (!usingDefaults) return;
            API.get('/api/settings/debug').then(function(dbg) {
                if (dbg.settings_exists && (dbg.settings_keys || []).length === 0) {
                    if (!_warnedOnce('settings_empty')) {
                        if (typeof addLog === 'function') addLog('Файл настроек существует но пуст! Тайминги сброшены к 6-14с. Открой Настройки → задай значения → автосохранение. Путь: ' + dbg.settings_path, 'ERROR');
                        _markWarned('settings_empty');
                    }
                } else if (!dbg.settings_exists) {
                    if (!_warnedOnce('settings_missing')) {
                        if (typeof addLog === 'function') addLog('Тайминги рассылки дефолтные 6-14с — открой Настройки → задай свои, сохранятся автоматически.', 'INFO');
                        _markWarned('settings_missing');
                    }
                } else if (!_warnedOnce('settings_defaults')) {
                    if (typeof addLog === 'function') addLog('Тайминги рассылки стандартные (6-14с). Для безопасности рекомендуется увеличить в Настройках', 'WARNING');
                    _markWarned('settings_defaults');
                }
            }).catch(function() {
                if (!_warnedOnce('settings_defaults')) {
                    if (typeof addLog === 'function') addLog('Тайминги рассылки стандартные (6-14с). Для безопасности рекомендуется увеличить в Настройках', 'WARNING');
                    _markWarned('settings_defaults');
                }
            });
        }).catch(function(){});
    }).catch(function(){});
}

// ── Poll logs from server ───────────────────────────────────────

// Лог-стрим переехал в отдельное окно (/logs) — главное окно не поллит
// /api/logs/recent, это делает popup сам. Оставлено для совместимости (no-op).
var _lastLogId = 0;
function pollLogs() {}

// ── Onboarding (первый запуск) ─────────────────────────────────

var _onboardingStep = 0;
var _onboardingSteps = [];

function checkOnboarding(accountsCount) {
    // Показываем только если 0 аккаунтов И клиент ещё ни разу не видел онбординг.
    // Флаг сохраняется на сервере (файл data/onboarding_done.flag) чтобы не зависеть
    // от webview localStorage, который может чиститься private_mode при обновлении.
    if (accountsCount > 0) return;
    if (localStorage.getItem('onboarding_done')) return;
    if (window._serverOnboardingDone) return;
    localStorage.setItem('onboarding_done', '1');
    try { API.post('/api/onboarding/mark_done', {}); } catch (e) {}

    var keyType = _keyType || 'pro';
    var typeLabel = {trial: 'Trial (пробный)', standard: 'Standard', pro: 'Pro', custom: 'Custom'}[keyType] || keyType;

    // Онбординг: SVG/Bootstrap-иконки вместо эмодзи, высокий контраст текста
    // (#f0f0f5/#b8b8c8), акцентные иконки-плашки.
    var _AL = 'var(--accent-light, #9d8cff)';
    var _obHero = function(icon) {
        return '<div style="width:58px;height:58px;margin:0 auto 16px;border-radius:7px;display:flex;align-items:center;justify-content:center;' +
            'background:rgba(109,92,199,0.16);border:1px solid rgba(109,92,199,0.4);">' +
            '<i class="bi bi-' + icon + '" style="font-size:1.7rem;color:' + _AL + ';"></i></div>';
    };
    var _obHead = function(icon, txt) {
        return '<h2 style="color:#f0f0f5;font-size:1.12rem;margin-bottom:14px;display:flex;align-items:center;gap:9px;">' +
            '<i class="bi bi-' + icon + '" style="color:' + _AL + ';font-size:1.18rem;"></i>' + txt + '</h2>';
    };
    var _obCard = function(accent, icon, title, body) {
        return '<div style="background:#1c1c28;border:1px solid #2a2a3c;border-left:3px solid ' + accent + ';border-radius:5px;padding:12px 14px;display:flex;gap:11px;align-items:flex-start;">' +
            '<i class="bi bi-' + icon + '" style="color:' + accent + ';font-size:1.05rem;margin-top:1px;flex-shrink:0;"></i>' +
            '<div><b style="color:' + accent + ';font-size:0.82rem;">' + title + '</b>' +
            '<p style="color:#b8b8c8;font-size:0.76rem;margin-top:4px;line-height:1.45;">' + body + '</p></div></div>';
    };
    var _obMini = function(accent, icon, title, body) {
        return '<div style="background:#1c1c28;border:1px solid #2a2a3c;border-radius:5px;padding:11px;">' +
            '<b style="color:' + accent + ';font-size:0.78rem;display:flex;align-items:center;gap:6px;"><i class="bi bi-' + icon + '"></i>' + title + '</b>' +
            '<p style="color:#a8a8ba;font-size:0.7rem;margin-top:4px;line-height:1.4;">' + body + '</p></div>';
    };

    _onboardingSteps = [
        // Шаг 1: Приветствие
        {
            html: '<div style="text-align:center;padding:6px 0;">' +
                _obHero('rocket-takeoff') +
                '<h2 style="color:#f0f0f5;font-size:1.35rem;margin-bottom:11px;">Добро пожаловать в TelePush!</h2>' +
                '<div style="display:inline-flex;align-items:center;gap:7px;background:rgba(109,92,199,0.14);border:1px solid rgba(109,92,199,0.32);border-radius:7px;padding:4px 14px;margin-bottom:11px;">' +
                    '<span style="color:#9ca3af;font-size:0.76rem;">Тариф</span><b style="color:' + _AL + ';font-size:0.82rem;">' + typeLabel + '</b></div>' +
                '<p style="color:#b8b8c8;font-size:0.83rem;">Настроим софт за пару минут</p>' +
            '</div>'
        },
        // Шаг 2: Добавление аккаунта
        {
            html: '<div>' + _obHead('person-plus', 'Шаг 1: Добавьте аккаунт') +
                '<p style="color:#b8b8c8;font-size:0.8rem;margin-bottom:14px;">Три способа на выбор:</p>' +
                '<div style="display:flex;flex-direction:column;gap:9px;">' +
                    _obCard('#9d8cff', 'file-earmark-zip', 'Файлы .session + .json', 'Перетащите готовые файлы сессии в зону загрузки на вкладке «Аккаунты»') +
                    _obCard('#60a5fa', 'telephone', 'По номеру телефона', 'Введите номер → код придёт в Telegram → введите код. Аккаунт добавится сам') +
                    _obCard('#4ade80', 'folder2-open', 'Из папки', 'Закиньте файлы в «Аккаунты/В работе/» и нажмите «Обновить из папки»') +
                '</div></div>'
        },
        // Шаг 3: Что можно делать
        {
            html: '<div>' + _obHead('grid-3x3-gap-fill', 'Шаг 2: Выберите задачу') +
                '<p style="color:#b8b8c8;font-size:0.8rem;margin-bottom:12px;">После добавления аккаунта доступны:</p>' +
                '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">' +
                    (hasModule('spam') ? _obMini('#9d8cff', 'megaphone', 'Рассылка', 'Сообщения в группы и каналы') : '') +
                    (hasModule('dm') ? _obMini('#818cf8', 'envelope', 'Личные сообщения', 'Рассылка в ЛС по юзернеймам') : '') +
                    (hasModule('join') ? _obMini('#60a5fa', 'link-45deg', 'Подписка', 'Вступление в группы по ссылкам') : '') +
                    (hasModule('parser') ? _obMini('#f472b6', 'search', 'Парсинг', 'Сбор юзернеймов из чатов') : '') +
                    (hasModule('warmup') ? _obMini('#fb923c', 'fire', 'Прогрев', 'Подготовка новых аккаунтов') : '') +
                    (hasModule('multi') ? _obMini('#c084fc', 'people', 'Мульти-аккаунт', 'Несколько аккаунтов параллельно') : '') +
                '</div></div>'
        },
        // Шаг 4: Прокси
        {
            html: '<div>' + _obHead('shield-lock', 'Шаг 3: Прокси (рекомендуется)') +
                '<p style="color:#b8b8c8;font-size:0.8rem;margin-bottom:12px;">Для безопасности аккаунтов добавьте прокси.</p>' +
                '<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.32);border-radius:5px;padding:11px 13px;margin-bottom:13px;display:flex;gap:9px;align-items:flex-start;">' +
                    '<i class="bi bi-exclamation-triangle-fill" style="color:#fbbf24;font-size:1rem;flex-shrink:0;margin-top:1px;"></i>' +
                    '<p style="color:#e3c878;font-size:0.76rem;line-height:1.45;">Без прокси аккаунты (особенно новые) могут быть заблокированы Telegram.</p></div>' +
                '<p style="color:#b8b8c8;font-size:0.78rem;line-height:1.5;">Вкладка <b style="color:#9d8cff;">Прокси</b> слева → добавьте свои. Софт сам определит тип (SOCKS5/HTTP) и распределит между аккаунтами.</p>' +
            '</div>'
        },
        // Шаг 5: Готово
        {
            html: '<div style="text-align:center;padding:6px 0;">' +
                _obHero('check-circle-fill') +
                '<h2 style="color:#f0f0f5;font-size:1.35rem;margin-bottom:9px;">Всё готово!</h2>' +
                '<p style="color:#b8b8c8;font-size:0.83rem;margin-bottom:18px;">Положите аккаунты в папку «В работе» и начинайте работать</p>' +
                '<div style="display:flex;gap:9px;justify-content:center;">' +
                    '<button class="btn btn-accent btn-sm" onclick="closeOnboarding();showTab(\'accounts\')"><i class="bi bi-people"></i> К аккаунтам</button>' +
                    '<button class="btn btn-outline btn-sm" onclick="closeOnboarding();showTab(\'guide\')"><i class="bi bi-book"></i> Инструкция</button>' +
                '</div>' +
            '</div>',
            noNext: true
        },
    ];

    _onboardingStep = 0;
    renderOnboarding();
    document.getElementById('onboarding-overlay').classList.remove('hidden');
}

function renderOnboarding() {
    var step = _onboardingSteps[_onboardingStep];
    var el = document.getElementById('onboarding-content');
    if (!el || !step) return;

    var nav = '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;padding-top:16px;border-top:1px solid #252540;">' +
        '<div style="font-size:0.72rem;color:#4b5060;">' + (_onboardingStep + 1) + ' / ' + _onboardingSteps.length + '</div>' +
        '<div style="display:flex;gap:8px;">';

    if (_onboardingStep > 0) {
        nav += '<button class="btn btn-outline btn-sm" onclick="onboardingPrev()" style="font-size:0.75rem;padding:4px 14px;">Назад</button>';
    }
    nav += '<button class="btn btn-outline btn-sm" onclick="closeOnboarding()" style="font-size:0.75rem;padding:4px 14px;color:var(--text-muted);">Пропустить</button>';
    if (!step.noNext) {
        nav += '<button class="btn btn-accent btn-sm" onclick="onboardingNext()" style="font-size:0.75rem;padding:4px 14px;">Далее</button>';
    }
    nav += '</div></div>';

    el.innerHTML = step.html + nav;
}

function onboardingNext() {
    if (_onboardingStep < _onboardingSteps.length - 1) {
        _onboardingStep++;
        renderOnboarding();
    }
}

function onboardingPrev() {
    if (_onboardingStep > 0) {
        _onboardingStep--;
        renderOnboarding();
    }
}

function closeOnboarding() {
    document.getElementById('onboarding-overlay').classList.add('hidden');
}

// ── Module Access Control ──────────────────────────────────────

var _allowedModules = [];
var _allowedFeatures = {};
var _keyType = 'pro';

function hasModule(name) { return !_allowedModules.length || _allowedModules.indexOf(name) !== -1; }
function hasFeature(name) { return _allowedFeatures[name] !== false; }

function _applyModuleLocks() {
    if (!_allowedModules.length) return;  // no restrictions
    // Если лицензия истекла — _applyExpiredLock уже залочил всё. Не снимать
    // замки тут, иначе получится мешанина с баннером «срок истёк».
    if (_expiredLocked) return;

    var alwaysOpen = ['settings', 'guide', 'profile', 'support'];
    document.querySelectorAll('.nav-link[data-tab]').forEach(function(link) {
        var tab = link.getAttribute('data-tab');
        if (alwaysOpen.indexOf(tab) !== -1) return;

        // Map tab names to module names
        var moduleMap = {
            'spam': 'spam', 'dm': 'dm', 'join': 'join', 'multi': 'multi',
            'accounts': 'accounts', 'proxy': 'proxy', 'parser': 'parser',
            'warmup': 'warmup', 'profiles': 'profiles', 'autoreply': 'autoreply',
            'templates': 'templates', 'analytics': 'analytics', 'reauth': 'reauth',
        };
        var module = moduleMap[tab];
        if (!module) return;

        var allowed = _allowedModules.indexOf(module) !== -1;
        if (!allowed) {
            link.style.opacity = '0.35';
            link.style.pointerEvents = 'none';
            if (!link.querySelector('.lock-icon')) {
                var lock = document.createElement('i');
                lock.className = 'bi bi-lock-fill lock-icon';
                lock.style.cssText = 'color:#f87171;font-size:0.65rem;margin-left:auto;';
                link.appendChild(lock);
            }
        } else {
            link.style.opacity = '';
            link.style.pointerEvents = '';
            var lockIcon = link.querySelector('.lock-icon');
            if (lockIcon) lockIcon.remove();
        }
    });
    _applyFeatureLocks();
}

function _applyFeatureLocks() {
    document.querySelectorAll('[data-feature]').forEach(function(el) {
        var feature = el.getAttribute('data-feature');
        var allowed = hasFeature(feature);
        var existing = el.querySelector(':scope > .feature-blocker');
        if (!allowed) {
            if (existing) return;
            if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
            var ov = document.createElement('div');
            ov.className = 'feature-blocker';
            ov.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(7,7,13,0.78);z-index:50;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:5px;cursor:default;text-align:center;padding:12px;';
            ov.innerHTML =
                '<i class="bi bi-lock-fill" style="font-size:1.4rem;color:#f87171;margin-bottom:6px;"></i>' +
                '<div style="font-size:0.78rem;font-weight:600;color:#fff;margin-bottom:4px;">Функция недоступна</div>' +
                '<div style="font-size:0.68rem;color:#9ca3af;margin-bottom:8px;">Доступно в тарифе Pro</div>' +
                '<button class="btn btn-accent btn-xs" style="font-size:0.7rem;padding:4px 10px;" onclick="requestFeatureAccess(\'' + feature + '\')"><i class="bi bi-key"></i> Запросить</button>';
            ov.addEventListener('click', function(e){ e.stopPropagation(); });
            el.appendChild(ov);
        } else if (existing) {
            existing.remove();
        }
    });
}

function requestFeatureAccess(featureName) {
    API.post('/api/ticket', {
        module: featureName,
        subject: 'Запрос доступа к функции: ' + featureName,
        body: 'Хочу получить доступ к функции ' + featureName
    }).then(function() {
        showToast('Запрос отправлен!', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function _showModuleBlocked(tabName) {
    var sec = document.getElementById('tab-' + tabName);
    if (!sec) return;
    // Check if already has blocker
    if (sec.querySelector('.module-blocker')) return;
    var blocker = document.createElement('div');
    blocker.className = 'module-blocker';
    blocker.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(7,7,13,0.85);z-index:100;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:var(--radius);';
    blocker.innerHTML =
        '<i class="bi bi-lock-fill" style="font-size:3rem;color:#f87171;margin-bottom:16px;"></i>' +
        '<div style="font-size:1.1rem;font-weight:700;color:#fff;margin-bottom:8px;">Модуль недоступен</div>' +
        '<div style="font-size:0.82rem;color:#9ca3af;margin-bottom:20px;text-align:center;max-width:300px;">Этот модуль не входит в ваш тарифный план. Запросите доступ или обновите тариф.</div>' +
        '<button class="btn btn-accent btn-sm" onclick="requestModuleAccess(\'' + tabName + '\')"><i class="bi bi-key"></i> Запросить доступ</button>';
    sec.style.position = 'relative';
    sec.appendChild(blocker);
}

function requestModuleAccess(moduleName) {
    API.post('/api/ticket', {module: moduleName, subject: 'Запрос доступа к модулю: ' + moduleName, body: 'Хочу получить доступ к модулю ' + moduleName}).then(function() {
        showToast('Запрос отправлен!', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// ── Expired Lock ───────────────────────────────────────────────

var _expiredLocked = false;
// Софт занят задачей — всё, что запускает работу или трогает аккаунты, гаснет.
// Только классом на body: снятый disabled пришлось бы возвращать, и любая
// ошибка в возврате оставила бы кнопки мёртвыми до перезапуска программы.
function _applyBusyLock(info) {
    var tasks = (info && info.tasks) || [];
    var locked = !!(info && info.locked);
    document.body.classList.toggle('tp-busy', locked);
    var what = document.getElementById('tp-busy-what');
    if (what && locked) {
        what.textContent = tasks.length === 1 ? tasks[0].toLowerCase()
                                              : tasks.join(' + ').toLowerCase();
    }
}

function _applyExpiredLock(locked) {
    if (_expiredLocked === locked) return;
    _expiredLocked = locked;
    // Lock sidebar nav items (except profile, settings, support)
    var allowedTabs = ['profile', 'settings', 'support'];
    document.querySelectorAll('.nav-link[data-tab]').forEach(function(link) {
        var tab = link.getAttribute('data-tab');
        if (allowedTabs.indexOf(tab) !== -1) return;
        if (locked) {
            link.style.opacity = '0.4';
            link.style.pointerEvents = 'none';
            // Add lock icon if not already
            if (!link.querySelector('.lock-icon')) {
                var lock = document.createElement('i');
                lock.className = 'bi bi-lock-fill lock-icon';
                lock.style.cssText = 'color:var(--danger);font-size:0.7rem;margin-left:4px;';
                link.appendChild(lock);
            }
        } else {
            link.style.opacity = '';
            link.style.pointerEvents = '';
            var lockIcon = link.querySelector('.lock-icon');
            if (lockIcon) lockIcon.remove();
        }
    });
    // Disable all start buttons
    if (locked) {
        document.querySelectorAll('button[onclick*="Start"], button[onclick*="start"], button[onclick*="Spam"], button[onclick*="Join"], button[onclick*="multi"]').forEach(function(btn) {
            btn.disabled = true;
            btn.title = 'Лицензия истекла';
        });
    }
}

// Принудительная пере-проверка лицензии у сервера (ключ продлён на сервере,
// но клиент кэшировал «истёк»).
function recheckLicense() {
    var btn = document.getElementById('btn-recheck');
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Проверяю...'; }
    var res = document.getElementById('renew-result');
    if (res) { res.style.display = 'none'; }
    API.post('/api/license/recheck', {}).then(function(d) {
        if (res) {
            res.style.display = 'block';
            if (d.ok && d.valid) {
                res.style.color = 'var(--success)';
                res.textContent = '✓ ' + (d.msg || 'Ключ активен. Обновляю страницу...');
                setTimeout(function() { location.reload(); }, 1500);
            } else {
                res.style.color = 'var(--danger)';
                res.textContent = (d.error || 'Сервер подтвердил что ключ невалиден') +
                                  '. Если только что продлили — подожди минуту и нажми ещё раз.';
                if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Перепроверить'; }
            }
        }
    }).catch(function(e) {
        if (res) {
            res.style.display = 'block';
            res.style.color = 'var(--danger)';
            res.textContent = 'Сеть не отвечает: ' + ((e && e.message) || 'попробуй позже');
        }
        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Перепроверить'; }
    });
}

function requestRenewal() {
    var tgUser = (document.getElementById('renew-tg-username') || {}).value || '';
    tgUser = tgUser.trim().replace(/^@/, '');
    if (!tgUser) {
        showToast('Введите ваш @username в Telegram', 'warning');
        var inp = document.getElementById('renew-tg-username');
        if (inp) { inp.focus(); inp.style.borderColor = 'var(--danger)'; }
        return;
    }
    var btn = document.getElementById('btn-renew');
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Отправка...'; }
    API.post('/api/renew', {tg_username: tgUser}).then(function(d) {
        var res = document.getElementById('renew-result');
        if (res) {
            res.style.display = 'block';
            if (d.ok) {
                res.style.color = 'var(--success)';
                res.textContent = 'Запрос отправлен! Ожидайте — администратор продлит ключ.';
            } else {
                res.style.color = 'var(--danger)';
                res.textContent = d.error || 'Ошибка';
                if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> Запросить продление'; }
            }
        }
    }).catch(function() {
        var res = document.getElementById('renew-result');
        if (res) { res.style.display = 'block'; res.style.color = 'var(--danger)'; res.textContent = 'Нет связи с сервером'; }
        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> Запросить продление'; }
    });
}

// ── Profile ────────────────────────────────────────────────────

function loadProfile() {
    API.get('/api/profile').then(function(d) {
        var el;
        el = document.getElementById('prf-key'); if (el) el.textContent = d.key || '---';
        el = document.getElementById('prf-expires');
        if (el) {
            if (d.expires) {
                var exp = new Date(d.expires);
                var dd = String(exp.getDate()).padStart(2,'0');
                var mm = String(exp.getMonth()+1).padStart(2,'0');
                var yy = exp.getFullYear();
                var nice = dd + '.' + mm + '.' + yy;
                if (exp < new Date()) { el.style.color = 'var(--danger)'; el.textContent = nice + ' (истёк)'; }
                else {
                    var days = Math.ceil((exp - new Date()) / 86400000);
                    el.style.color = days < 7 ? 'var(--warning)' : 'var(--success)';
                    el.textContent = nice + ' (' + days + ' дн.)';
                }
            } else { el.textContent = 'Бессрочная'; el.style.color = 'var(--success)'; }
        }
        el = document.getElementById('prf-version'); if (el) el.textContent = 'v' + (d.version || '?');
        el = document.getElementById('prf-key-type');
        if (el) {
            var typeLabels = {trial: 'Trial (7 дней)', standard: 'Standard', pro: 'Pro', custom: 'Custom'};
            var kt = _keyType || 'pro';
            el.textContent = typeLabels[kt] || kt;
            el.style.color = kt === 'pro' ? 'var(--success)' : kt === 'trial' ? 'var(--warning)' : 'var(--accent-light)';
        }
        el = document.getElementById('prf-hwid'); if (el) el.textContent = d.hwid || '---';
        el = document.getElementById('prf-pc'); if (el) el.textContent = d.pc_name || '---';
        el = document.getElementById('prf-accs');
        if (el) el.innerHTML = '<span style="color:var(--success);">' + (d.accounts_alive || 0) + ' живых</span>' +
            (d.accounts_busy ? ' / <span style="color:var(--accent);">' + d.accounts_busy + ' в работе</span>' : '') +
            ' / ' + (d.accounts_total || 0) + ' всего';
        el = document.getElementById('prf-site-link');
        if (el) el.href = (d.site_url || 'https://telepush.org') + '/profile';
        el = document.getElementById('prf-modules');
        if (el) {
            var moduleLabels = {
                spam: 'Рассылка', dm: 'Личные', join: 'Подписка', multi: 'Мульти',
                accounts: 'Аккаунты', proxy: 'Прокси', parser: 'Парсинг',
                warmup: 'Прогрев', profiles: 'Профили', autoreply: 'Автоответ',
                templates: 'Шаблоны', analytics: 'Аналитика', tg_messenger: 'TG мессенджер'
            };
            var alwaysOpen = {settings: 'Настройки', profile: 'Профиль', support: 'Поддержка', guide: 'Инструкция'};
            var allowed = (d.modules && d.modules.length) ? d.modules : Object.keys(moduleLabels);
            var html = '';
            Object.keys(moduleLabels).forEach(function(m) {
                var on = allowed.indexOf(m) !== -1;
                var bg = on ? 'background:rgba(34,197,94,0.12);border:1px solid rgba(34,197,94,0.35);color:#86efac;' : 'background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.25);color:#fca5a5;opacity:0.55;';
                var ic = on ? 'bi-check-circle' : 'bi-lock';
                html += '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 9px;border-radius:4px;font-size:0.72rem;font-weight:600;' + bg + '"><i class="bi ' + ic + '" style="font-size:0.7rem;"></i>' + moduleLabels[m] + '</span>';
            });
            Object.keys(alwaysOpen).forEach(function(m) {
                html += '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 9px;border-radius:4px;font-size:0.72rem;font-weight:600;background:rgba(34,197,94,0.12);border:1px solid rgba(34,197,94,0.35);color:#86efac;"><i class="bi bi-check-circle" style="font-size:0.7rem;"></i>' + alwaysOpen[m] + '</span>';
            });
            el.innerHTML = html;
        }
    }).catch(function() {});
}

function changeLicenseKey() {
    var inp = document.getElementById('new-license-key');
    var res = document.getElementById('change-key-result');
    var btn = document.getElementById('btn-change-key');
    var key = ((inp && inp.value) || '').trim();
    function _show(color, text) {
        if (!res) return;
        res.style.display = 'block'; res.style.color = color; res.textContent = text;
    }
    if (!key) { _show('var(--danger)', 'Введите новый ключ'); return; }
    if (btn) btn.disabled = true;
    _show('var(--text-muted)', 'Активирую ключ…');
    API.post('/api/license/change', { key: key }).then(function(d) {
        if (btn) btn.disabled = false;
        if (d && d.ok) {
            _show('var(--success)', 'Ключ сменён ✓' + (d.expires ? (' · действует до ' + d.expires) : ''));
            if (inp) inp.value = '';
            if (typeof loadProfile === 'function') loadProfile();  // обновить карточку лицензии
        }
    }).catch(function(e) {
        if (btn) btn.disabled = false;
        _show('var(--danger)', (e && e.message) || 'Не удалось сменить ключ');
    });
}

async function resetHwid() {
    if (!(await appConfirm('Сбросить привязку к этому ПК? Нужно будет войти заново.', {danger: true}))) return;
    var btn = document.getElementById('btn-reset-hwid');
    if (btn) btn.disabled = true;
    API.post('/api/profile/reset_hwid', {}).then(function(d) {
        var res = document.getElementById('reset-result');
        if (res) {
            res.style.display = 'block';
            if (d.ok) {
                res.style.color = 'var(--success)';
                res.textContent = d.msg || 'Готово! Перенаправление...';
                setTimeout(function() { window.location.href = '/login'; }, 1500);
            } else {
                res.style.color = 'var(--danger)';
                res.textContent = d.error || 'Ошибка';
                if (btn) btn.disabled = false;
            }
        }
    }).catch(function() {
        var res = document.getElementById('reset-result');
        if (res) { res.style.display = 'block'; res.style.color = 'var(--danger)'; res.textContent = 'Нет связи с сервером'; }
        if (btn) btn.disabled = false;
    });
}

// ── Live auto-refresh for active tab ────────────────────────────

var _currentTab = 'spam';
var _origShowTab = showTab;
showTab = function(name) {
    _currentTab = name;
    // помним последнюю активную вкладку в localStorage, чтобы при следующем
    // старте открыться на ней (а не всегда на spam).
    try { localStorage.setItem('telepush_last_tab', String(name||'')); } catch(_){}
    _origShowTab(name);
    // Открыли автоответчик — сверяем кнопки Старт/Стоп с живым состоянием сервера
    // (автоответчик мог остаться запущен с прошлой сессии → показать Стоп).
    if (name === 'autoreply') { try { pollAutoReply(); } catch(_){} }
};

// No live refresh of tabs — prevents scroll reset, avatar flicker, and DOM rebuild
// Data refreshes when user switches tabs via showTab()

// ── Init ────────────────────────────────────────────────────────

// Метки результатов прямо в textarea с целями (не в логах).
// После задачи (Multi-DM/Spam/Join) фронт поллит
// /api/multi/marks и подписывает каждую строку:
//   @user            ← не дошёл / повторим
//   @user — ok       ← отправлено, пропустим в след. запуске
//   @user — spamblock ← акк в блоке, в след. запуске даст другому акку
//   @user — notfound ← нет такого юзера, не пробуем снова
//   @user — privacy  ← закрытые ЛС
//   @user — blocked  ← наш акк заблочен этим юзером
var _TP_MARK_TARGETS = {
    dm:   "dm-targets-input",
    spam: "spam-targets-input",
    join: "join-links"
};
var _TP_MARK_RE = /\s+—\s+(ok|spamblock|notfound|privacy|blocked)\s*$/i;
function _tpNormKey(s) {
    return String(s || "").trim().toLowerCase().replace(/^[@+]+/, "");
}
// Метки «помнятся» после задачи (backend ctx.temp + 4с-поллер). Когда юзер сам
// чистит поле и вставляет свежие юзы — старые метки лезли обратно (юзер: «почему
// софт запоминает? как почистить?»). Любая РУЧНАЯ правка поля = метки этого типа
// больше не валидны: чистим backend-стор и глушим поллер пока чистка не дойдёт.
var _tpMarksDirty = {};
var _tpMarkClearTimer = {};
window._tpApplyingMarks = false;
function _tpBindMarkClearOnEdit() {
    Object.keys(_TP_MARK_TARGETS).forEach(function(type) {
        var ta = document.getElementById(_TP_MARK_TARGETS[type]);
        if (!ta || ta._tpMarkBound) return;
        ta._tpMarkBound = true;
        ta.addEventListener("input", function() {
            if (window._tpApplyingMarks) return;  // синтетический input от поллера — игнор
            _tpMarksDirty[type] = true;
            clearTimeout(_tpMarkClearTimer[type]);
            _tpMarkClearTimer[type] = setTimeout(function() {
                fetch("/api/multi/marks/clear", {
                    method: "POST", credentials: "same-origin",
                    headers: {"Content-Type": "application/json"},
                    body: JSON.stringify({type: type})
                }).then(function() { _tpMarksDirty[type] = false; })
                  .catch(function() { /* clear не дошёл — оставляем dirty=true, чтобы поллер не вернул старые метки */ });
            }, 500);
        });
    });
}
function applyMultiMarks(type) {
    var taId = _TP_MARK_TARGETS[type];
    if (!taId) return;
    if (_tpMarksDirty[type]) return;  // юзер правит поле — не навешиваем старые метки
    var ta = document.getElementById(taId);
    if (!ta) return;
    fetch("/api/multi/marks?type=" + encodeURIComponent(type), {credentials:"same-origin"})
        .then(function(r){ return r.json(); })
        .then(function(d){
            var marks = (d && d.marks) || {};
            if (!Object.keys(marks).length) return;
            var lines = (ta.value || "").split(/\r?\n/);
            var changed = false;
            var newLines = lines.map(function(line){
                var original = line.replace(_TP_MARK_RE, "").trim();
                if (!original) return line;
                var key = _tpNormKey(original);
                var m = marks[key];
                // Также пробуем взять последний токен (на случай "Username @user")
                if (!m) {
                    var parts = original.split(/\s+/);
                    var last = parts[parts.length-1];
                    if (last) m = marks[_tpNormKey(last)];
                }
                var newLine = m ? (original + " — " + m) : original;
                if (newLine !== line) changed = true;
                return newLine;
            });
            if (changed) {
                ta.value = newLines.join("\n");
                window._tpApplyingMarks = true;
                try { ta.dispatchEvent(new Event("input", {bubbles:true})); } catch(_){}
                window._tpApplyingMarks = false;
            }
        })
        .catch(function(){});
}
// Poll каждые 4с — лёгкий запрос. Только когда страница видима.
setInterval(function(){
    _tpBindMarkClearOnEdit();
    if (document.hidden) return;
    applyMultiMarks("dm");
    applyMultiMarks("spam");
    applyMultiMarks("join");
}, 4000);

// Persist UI state модулей в localStorage: поля ниже сохраняются на изменение
// и восстанавливаются при загрузке. localStorage webview2 живёт в %APPDATA% и
// переживает обнову TelePush.exe (launcher не трогает webview profile).
var _TP_PERSIST_FIELDS = [
    // Рассылка
    "spam-multi-count", "spam-parse-mode", "spam-tpl", "spam-forward-chat", "spam-forward-msg-id",
    "spam-loop", "spam-loop-seconds", "spam-loop-seconds-max", "spam-loop-rounds", "spam-targets-input", "spam-max-per-acc",
    "spam-pf-tolerance", "spam-count", "spam-threads",
    "spam-pre-spam-check", "spam-solve-captcha", "spam-keep-online",
    // ЛС (DM)
    "dm-multi-count", "dm-parse-mode", "dm-tpl", "dm-loop", "dm-loop-hours",
    "dm-targets-input", "dm-trigger-warn", "dm-trigger-words", "dm-max-per-acc",
    "dm-pf-tolerance", "dm-count",
    "dm-pre-spam-check", "dm-keep-online", "dm-view-profile", "dm-view-photo",
    // Подписка
    // join-links (вставленные ссылки на чаты) — сохраняем, иначе перезапуск чистит.
    "s-join-fw-wait", "join-links", "join-only-chats",
    // Парсер
    // «Слово везде» (parser-keyword-global) и «Чекер юзов» (parser-user-check-list).
    "parser-keyword-global", "parser-user-check-list",
    "parser-skip-bots", "parser-skip-admins", "parser-strict", "parser-dm-access",
    "parser-only-avatar", "parser-only-premium", "parser-include-poll-voters",
    "parser-recent-only",
    "parser-online-min", "parser-online-max", "parser-filter", "parser-hours",
    "parser-min-messages", "parser-period-preset",
    "parser-target-user", "parser-chat-search", "parser-chat-links",
    "parser-link-type", "parser-links-period", "parser-min-members",
    "parser-search-query", "parser-search-source", "parser-keywords", "parser-kw-scope",
    "parser-search-mode", "parser-unwrap",
    // Монитор — галки «Защита аккаунта» (раньше не персистились, сбрасывались)
    "monitor-solve-captcha", "monitor-keep-online",
    // Прогрев
    "warmup-days", "warmup-threads", "warmup-reaction-share",
    "warmup-tpl-greetings", "warmup-tpl-chatting", "warmup-tpl-goodbye",
    "warmup-pre-spam-check", "warmup-keep-online", "warmup-read-msgs",
    "warmup-mark-read", "warmup-view-profile", "warmup-view-photo",
    "warmup-id-format", "warmup-duplicate-dm",
    "warmup-hours-start", "warmup-hours-end",
    "warmup-delay-min", "warmup-delay-max"
];
function bindPersistFields() {
    var KEY_PREFIX = "tp_field_";
    _TP_PERSIST_FIELDS.forEach(function(id) {
        var el = document.getElementById(id);
        if (!el) return;
        var key = KEY_PREFIX + id;
        // 1) Восстановить сохранённое значение (null = никогда не сохраняли,
        //    HTML-дефолт не затираем)
        try {
            var saved = localStorage.getItem(key);
            if (saved !== null) {
                if (el.type === "checkbox") {
                    el.checked = (saved === "1");
                } else if (el.tagName === "SELECT" || el.tagName === "TEXTAREA"
                           || el.type === "text" || el.type === "number"
                           || el.type === "password") {
                    el.value = saved;
                }
            }
        } catch(_) {}
        // 2) Подписаться на изменение
        var evName = (el.type === "checkbox" || el.tagName === "SELECT") ? "change" : "input";
        var _isMarkField = (id === "dm-targets-input" || id === "spam-targets-input" || id === "join-links");
        function _persistNow() {
            try {
                if (el.type === "checkbox") {
                    localStorage.setItem(key, el.checked ? "1" : "0");
                } else if (_isMarkField) {
                    // Не сохраняем метки (— spamblock/notfound/ok) в localStorage —
                    // иначе после рестарта они «воскресают» в поле целей.
                    var _clean = (el.value || "").split(/\r?\n/).map(function(l) {
                        return l.replace(_TP_MARK_RE, "");
                    }).join("\n");
                    localStorage.setItem(key, _clean);
                } else {
                    localStorage.setItem(key, el.value || "");
                }
            } catch(_) {}
        }
        el.addEventListener(evName, function() {
            // Чекбоксы/селекты — сразу (одно значение, событий мало).
            if (el.type === "checkbox" || el.tagName === "SELECT") { _persistNow(); return; }
            // Текстовые поля — с задержкой. localStorage.setItem СИНХРОННЫЙ, а для
            // полей целей тут ещё и regex по каждой строке. На вставке тысяч ссылок
            // это гонялось на КАЖДОЕ событие ввода и вешало вкладку на секунды.
            if (el.__tpPersistTimer) clearTimeout(el.__tpPersistTimer);
            el.__tpPersistTimer = setTimeout(function() {
                el.__tpPersistTimer = null;
                _persistNow();
            }, 400);
        });
        // Уход со страницы/скрытие вкладки не должны терять последние 400мс ввода.
        el.addEventListener("blur", function() {
            if (el.__tpPersistTimer) { clearTimeout(el.__tpPersistTimer); el.__tpPersistTimer = null; }
            _persistNow();
        });
    });
    try { _refreshSecHints(); } catch (_) {}   // значения пришли программно — событий нет
    try { updateSearchModeHint(); } catch (_) {}  // синхронизируем подсказку «Как искать»
}

document.addEventListener('DOMContentLoaded', function() {
    // Восстанавливаем последнюю активную вкладку; если её нет в DOM — fallback spam.
    var _initialTab = 'spam';
    try {
        var _saved = localStorage.getItem('telepush_last_tab');
        if (_saved && document.getElementById('tab-' + _saved)) {
            _initialTab = _saved;
        }
    } catch(_){}
    showTab(_initialTab);
    // Грузим тайминги (s-spam-*, s-dm-*, s-join-*) сразу на загрузке, а не
    // только при заходе в «Настройки» — иначе слайдеры в Spam/DM/Join табах
    // висят с HTML-дефолтами пока юзер не зайдёт в Настройки.
    try { if (typeof loadSettings === 'function') loadSettings(); } catch(_) {}
    try { bindPersistFields(); } catch(_) {}
    // После восстановления сохранённых полей — пересчитать зависимые кнопки/счётчики.
    // Иначе восстановленные из сохранёнки ссылки в «Подписке» ЕСТЬ, а кнопка
    // «Подписаться» серая: updateJoinCount() висит на oninput, который при программной
    // установке value (restore) не срабатывает → кнопка включалась только после клика/ввода.
    try { updateJoinCount(); } catch(_) {}
    // DM: восстановленные из сохранёнки цели → пересчитать кнопку (silent, без тоста;
    // /api/dm/targets — лёгкий парс текста без коннекта). Иначе btn-dm серая пока не
    // тронешь поле — тот же класс бага, что в «Подписке».
    try {
        var _dmTaRestore = document.getElementById('dm-targets-input');
        if (_dmTaRestore && (_dmTaRestore.value || '').trim()) loadDmTargets(true);
    } catch(_) {}
    refreshStatus();
    scheduleRefresh();
    checkUpdate();
    renderAccountSelectors();
    loadTheme();
    setTimeout(checkWarnings, 3000);
    // install-path label убран из шапки (создавал визуальный шум). Полный путь
    // доступен в Настройках → Пути.
});

// ══════════════════════════════════════════════════════════════════
//  PROFILE FILLER
// ══════════════════════════════════════════════════════════════════

function toggleGenName() {
    var checked = document.getElementById('pf-gen-name').checked;
    var input = document.getElementById('pf-firstname');
    input.disabled = checked;
    if (checked) input.placeholder = 'Автогенерация включена';
    else input.placeholder = 'Имя или {Имя1|Имя2}';
}

function toggleGenSurname() {
    var checked = document.getElementById('pf-gen-surname').checked;
    var input = document.getElementById('pf-lastname');
    input.disabled = checked;
    if (checked) input.placeholder = 'Автогенерация включена';
    else input.placeholder = 'Фамилия или {Фам1|Фам2}';
}

function previewGenName() {
    API.get('/api/profile/generate_name').then(function(d) {
        document.getElementById('pf-gen-preview').textContent = d.first_name + ' ' + d.last_name;
    });
}

function toggleGenUsername() {
    var checked = document.getElementById('pf-gen-username').checked;
    var input = document.getElementById('pf-username');
    input.disabled = checked;
    if (checked) input.placeholder = 'Автогенерация включена';
    else input.placeholder = 'username или оставь пусто';
}

function previewGenUsername() {
    API.get('/api/profile/generate_username').then(function(d) {
        document.getElementById('pf-gen-username-preview').textContent = '@' + d.username;
    });
}

function toggleEmojiAvatar() {
    var checked = document.getElementById('pf-avatar-emoji').checked;
    document.getElementById('pf-emoji-row').style.display = checked ? 'block' : 'none';
    if (checked) {
        document.getElementById('pf-avatar-input').value = '';
        document.getElementById('pf-avatar-name').textContent = 'Эмодзи-аватарка';
        document.getElementById('pf-avatar-preview').style.display = 'none';
        // Отключаем папочный режим — эмодзи и папка взаимоисключающие
        var f = document.getElementById('pf-avatar-folder-enabled');
        if (f && f.checked) { f.checked = false; toggleFolderAvatar(); }
    } else {
        document.getElementById('pf-avatar-name').textContent = 'Не выбрано';
    }
}

function toggleFolderAvatar() {
    var checked = document.getElementById('pf-avatar-folder-enabled').checked;
    document.getElementById('pf-folder-row').style.display = checked ? 'block' : 'none';
    if (checked) {
        document.getElementById('pf-avatar-input').value = '';
        document.getElementById('pf-avatar-name').textContent = 'Папка с фото';
        document.getElementById('pf-avatar-preview').style.display = 'none';
        var e = document.getElementById('pf-avatar-emoji');
        if (e && e.checked) { e.checked = false; toggleEmojiAvatar(); }
    } else {
        document.getElementById('pf-avatar-name').textContent = 'Не выбрано';
    }
}

function scanAvatarFolder() {
    var folder = (document.getElementById('pf-avatar-folder') || {}).value || '';
    if (!folder.trim()) {
        showToast('Введи путь к папке', 'warning');
        return;
    }
    API.post('/api/profile/avatar_folder_scan', {folder: folder}).then(function(d) {
        var info = document.getElementById('pf-folder-info');
        if (d.ok) {
            info.innerHTML = '<span style="color:var(--success);">✓ Найдено файлов: ' + d.count + '</span> (примеры: ' + (d.samples || []).join(', ') + ')';
        } else {
            info.innerHTML = '<span style="color:var(--danger);">✗ ' + (d.error || 'Ошибка') + '</span>';
        }
    }).catch(function(e) {
        document.getElementById('pf-folder-info').innerHTML = '<span style="color:var(--danger);">✗ ' + (e.error || e) + '</span>';
    });
}

function openAvatarFolder() {
    var folder = (document.getElementById('pf-avatar-folder') || {}).value || '';
    if (!folder.trim()) {
        showToast('Введи путь к папке', 'warning');
        return;
    }
    API.post('/api/profile/avatar_folder_open', {folder: folder}).then(function(d) {
        if (!d.ok) showToast(d.error || 'Не удалось открыть', 'error');
    });
}

function previewAvatar(input) {
    var file = input.files[0];
    if (!file) return;
    document.getElementById('pf-avatar-name').textContent = file.name;
    var reader = new FileReader();
    reader.onload = function(e) {
        var img = document.getElementById('pf-avatar-img');
        img.src = e.target.result;
        document.getElementById('pf-avatar-preview').style.display = 'block';
    };
    reader.readAsDataURL(file);
}

function applyProfile() {
    var genName = document.getElementById('pf-gen-name').checked;
    var genSurname = document.getElementById('pf-gen-surname').checked;
    var genUsername = (document.getElementById('pf-gen-username') || {}).checked;
    var emojiAvatar = (document.getElementById('pf-avatar-emoji') || {}).checked;
    var fnEl = document.getElementById('pf-firstname');
    var lnEl = document.getElementById('pf-lastname');
    var unEl = document.getElementById('pf-username');
    var aboutEl = document.getElementById('pf-about');
    var fn = (fnEl ? fnEl.value : '').trim();
    var ln = (lnEl ? lnEl.value : '').trim();
    var un = (unEl ? unEl.value : '').trim().replace(/^@/, '');
    var about = (aboutEl ? aboutEl.value : '').trim();
    // touched-семантика. Пустое поле touched = очистить в TG.
    var fnTouched = (fnEl && fnEl.dataset.touched === '1') || !!fn;
    var lnTouched = (lnEl && lnEl.dataset.touched === '1') || !!ln;
    var unTouched = (unEl && unEl.dataset.touched === '1') || !!un;
    var aboutTouched = (aboutEl && aboutEl.dataset.touched === '1') || !!about;
    // applyProfile (основная кнопка) ТОЖЕ читает чекбоксы «Очистить» —
    // раньше их видел только applyProfileAll, и "" не уходило (touched-семантика
    // требовала клик в поле). Теперь чекбокс работает на обеих кнопках.
    var clearLn = (document.getElementById('pf-clear-lastname') || {}).checked;
    var clearUn = (document.getElementById('pf-clear-username') || {}).checked;
    var clearAbout = (document.getElementById('pf-clear-about') || {}).checked;

    // Папочная аватарка (случайное фото из папки) — тоже валидное основание, иначе
    // режим «Случайное фото из папки» блокировался ложным «Заполните поле».
    var _pf_folderOk = !!((document.getElementById('pf-avatar-folder-enabled') || {}).checked && ((document.getElementById('pf-avatar-folder') || {}).value || '').trim());
    if (!fnTouched && !lnTouched && !unTouched && !aboutTouched && !genName && !genSurname && !genUsername && !emojiAvatar && !clearLn && !clearUn && !clearAbout && !document.getElementById('pf-avatar-input').files.length && !_pf_folderOk) {
        showToast('Заполните хотя бы одно поле или включите генерацию', 'warning');
        return;
    }

    var data = {};
    if (genName) data.gen_name = true;
    else if (fnTouched) data.first_name = fn;
    if (genSurname) data.gen_surname = true;
    else if (clearLn) data.last_name = "";  // явная очистка фамилии
    else if (lnTouched) data.last_name = ln;
    if (genUsername) data.gen_username = true;
    else if (clearUn) data.username = "";  // явная очистка username
    else if (unTouched) data.username = un;
    if (clearAbout) data.about = "";  // явная очистка био
    else if (aboutTouched) data.about = about;

    // При переключении в bulk нужны и поля аватарки тоже. Собираем
    // avatar_folder заранее — чтобы передать в bulk-payload ниже.
    var _pf_emojiAvatar = (document.getElementById('pf-avatar-emoji') || {}).checked;
    var _pf_folderEnabled = (document.getElementById('pf-avatar-folder-enabled') || {}).checked;
    var _pf_folderPath = ((document.getElementById('pf-avatar-folder') || {}).value || '').trim();
    var _pf_avatarFolder = (_pf_folderEnabled && _pf_folderPath) ? _pf_folderPath : '';
    var _pf_inlineFile = (document.getElementById('pf-avatar-input').files || [])[0];

    var resultEl = document.getElementById('pf-result');
    resultEl.style.display = 'block';
    resultEl.innerHTML = '<span class="text-muted">Обновляю профиль...</span>';

    // applyProfile читает выбранные акки из чекбоксов (как applyProfileAll) —
    // иначе шло БЕЗ phone и сервер падал на ctx.active.
    var selectedPhones = (typeof getSelectorPhones === 'function')
        ? getSelectorPhones('acc-selector-profiles') : [];
    if (!selectedPhones.length) {
        resultEl.innerHTML = '<span class="text-danger">✗ Не выбран акк. Отметь чекбоксом в списке выше.</span>';
        return;
    }
    // Если 1 выбран — обновляем явно по phone. Если >1 — переключаемся в bulk-endpoint.
    if (selectedPhones.length > 1) {
        data.phones = selectedPhones;
        if (_pf_avatarFolder) data.avatar_folder = _pf_avatarFolder;
        // inline-файл аватарки. Заливаем как temp + bulk раздаст всем.
        var _bulk_payload_chain = Promise.resolve();
        if (_pf_inlineFile && !_pf_avatarFolder) {
            var _fd = new FormData();
            _fd.append('file', _pf_inlineFile);
            _bulk_payload_chain = fetch('/api/profile/avatar_upload_temp', {method: 'POST', body: _fd})
                .then(function(r){return r.json();})
                .then(function(up){
                    if (up && up.ok && up.path) data.avatar_file = up.path;
                });
        }
        return _bulk_payload_chain.then(function(){
            return API.post('/api/profile/fill_bulk', data);
        }).then(function(d) {
            if (d && d.ok) {
                resultEl.innerHTML = '<span class="text-success">✓ Запущено для ' + selectedPhones.length + ' акков. Смотри логи.</span>';
                _startProfileFillPoll();
                loadAccounts();
            } else {
                resultEl.innerHTML = '<span class="text-danger">✗ ' + ((d && d.error) || 'Ошибка') + '</span>';
            }
        }).then(function() {
            // Эмодзи-аватарка в fill_bulk не поддерживается → раздаём по одному
            // (раньше при 2+ акках эмодзи молча игнорировалась при ложном «Запущено»).
            if (_pf_emojiAvatar && !_pf_avatarFolder && !_pf_inlineFile) {
                var _emo = ((document.getElementById('pf-avatar-emoji-char') || {}).value || '').trim();
                var _bg = ((document.getElementById('pf-avatar-emoji-bg') || {}).value || '#3b82f6');
                var _ec = Promise.resolve();
                selectedPhones.forEach(function(_ph) {
                    _ec = _ec.then(function() { return API.post('/api/profile/avatar_emoji', {emoji: _emo, bg: _bg, phone: _ph}); });
                });
                return _ec.then(function() {
                    resultEl.innerHTML += '<br><span class="text-success">✓ Эмодзи-аватарка разослана на ' + selectedPhones.length + ' акков</span>';
                    _avatarCacheBust = Date.now(); loadAccounts();
                });
            }
        }).catch(function(e) {
            resultEl.innerHTML = '<span class="text-danger">✗ ' + e + '</span>';
        });
    }
    data.phone = selectedPhones[0];

    var chain = Promise.resolve();

    if (fnTouched || lnTouched || unTouched || aboutTouched || genName || genSurname || genUsername) {
        chain = chain.then(function() {
            return API.post('/api/profile/update', data);
        }).then(function(d) {
            if (d.ok) {
                var html = '<span class="text-success">✓ ' + (d.changes || []).join(', ') + '</span>';
                // показываем username_error явно — иначе кажется что username
                // сменился, хотя упало с UsernameOccupied/UsernameInvalid.
                if (d.username_error) {
                    html += '<br><span class="text-warning">⚠ ' + d.username_error + '</span>';
                }
                resultEl.innerHTML = html;
            } else {
                resultEl.innerHTML = '<span class="text-danger">✗ ' + (d.error || 'Ошибка') + '</span>';
            }
        });
    }

    // Then avatar
    var avatarFile = document.getElementById('pf-avatar-input').files[0];
    var folderEnabled = (document.getElementById('pf-avatar-folder-enabled') || {}).checked;
    var folderPath = ((document.getElementById('pf-avatar-folder') || {}).value || '').trim();

    if (emojiAvatar) {
        // Серверная генерация png из эмодзи + цвета.
        var emo = ((document.getElementById('pf-avatar-emoji-char') || {}).value || '').trim();
        var bg = ((document.getElementById('pf-avatar-emoji-bg') || {}).value || '#3b82f6');
        chain = chain.then(function() {
            return API.post('/api/profile/avatar_emoji', {emoji: emo, bg: bg, phone: data.phone});
        }).then(function(d) {
            if (d.ok) {
                resultEl.innerHTML += '<br><span class="text-success">✓ Эмодзи-аватарка установлена (' + (d.used_emoji || '?') + ')</span>';
            } else {
                resultEl.innerHTML += '<br><span class="text-danger">✗ Эмодзи-аватарка: ' + (d.error || 'Ошибка') + '</span>';
            }
        });
    } else if (folderEnabled && folderPath) {
        chain = chain.then(function() {
            return API.post('/api/profile/avatar_from_folder', {folder: folderPath, phone: data.phone});
        }).then(function(d) {
            if (d.ok) {
                resultEl.innerHTML += '<br><span class="text-success">✓ Аватарка из папки: ' + (d.file || '?') + '</span>';
            } else {
                resultEl.innerHTML += '<br><span class="text-danger">✗ Из папки: ' + (d.error || 'Ошибка') + '</span>';
            }
        });
    } else if (avatarFile) {
        chain = chain.then(function() {
            var fd = new FormData();
            fd.append('file', avatarFile);
            fd.append('phone', data.phone);
            return fetch('/api/profile/avatar', {method: 'POST', body: fd}).then(function(r) { return r.json(); });
        }).then(function(d) {
            if (d.ok) {
                resultEl.innerHTML += '<br><span class="text-success">✓ Аватарка обновлена</span>';
            } else {
                resultEl.innerHTML += '<br><span class="text-danger">✗ Аватарка: ' + (d.error || 'Ошибка') + '</span>';
            }
        });
    }

    chain.then(function() {
        _avatarCacheBust = Date.now();
        loadAccounts();
        playSound('success');
    }).catch(function(e) {
        resultEl.innerHTML = '<span class="text-danger">✗ ' + e + '</span>';
    });
}

function applyProfileAll() {
    var genName = document.getElementById('pf-gen-name').checked;
    var genSurname = document.getElementById('pf-gen-surname').checked;
    var genUsername = (document.getElementById('pf-gen-username') || {}).checked;
    var fnEl = document.getElementById('pf-firstname');
    var lnEl = document.getElementById('pf-lastname');
    var unEl = document.getElementById('pf-username');
    var aboutEl = document.getElementById('pf-about');
    var fn = (fnEl ? fnEl.value : '').trim();
    var ln = (lnEl ? lnEl.value : '').trim();
    var un = (unEl ? unEl.value : '').trim().replace(/^@/, '');
    var about = (aboutEl ? aboutEl.value : '').trim();
    // Помним касался ли юзер поля. Если поле touched и НЕ генерация — отправляем
    // как есть (пустая строка = очистить в TG). Если untouched — не отправляем.
    var fnTouched = (fnEl && fnEl.dataset.touched === '1');
    var lnTouched = (lnEl && lnEl.dataset.touched === '1');
    var unTouched = (unEl && unEl.dataset.touched === '1');
    var aboutTouched = (aboutEl && aboutEl.dataset.touched === '1');
    // Совместимость: если поле НЕ touched но имеет значение (юзер вставил из
    // localStorage/autofill) — тоже считаем touched.
    if (fn) fnTouched = true;
    if (ln) lnTouched = true;
    if (un) unTouched = true;
    if (about) aboutTouched = true;

    var folderEnabled = (document.getElementById('pf-avatar-folder-enabled') || {}).checked;
    var folderPath = ((document.getElementById('pf-avatar-folder') || {}).value || '').trim();
    var avatarFolder = (folderEnabled && folderPath) ? folderPath : '';
    // Поддержка inline-файла аватарки в bulk-flow («Ко всем аккам»).
    var inlineAvatarFile = (document.getElementById('pf-avatar-input').files || [])[0];

    var _clearLnEarly = (document.getElementById('pf-clear-lastname') || {}).checked;
    var _clearUnEarly = (document.getElementById('pf-clear-username') || {}).checked;
    var _clearAboutEarly = (document.getElementById('pf-clear-about') || {}).checked;
    // Эмодзи-аватарка — тоже валидное основание (раньше блокировалась «Заполните поле»).
    var _pf_all_emoji = (document.getElementById('pf-avatar-emoji') || {}).checked;
    if (!fnTouched && !lnTouched && !unTouched && !aboutTouched && !genName && !genSurname && !genUsername && !avatarFolder && !inlineAvatarFile && !_clearLnEarly && !_clearUnEarly && !_clearAboutEarly && !_pf_all_emoji) {
        showToast('Заполните хотя бы одно поле, включите генерацию, очистку, папку или эмодзи-аватарку', 'warning');
        return;
    }

    // Явные чекбоксы «Очистить» для фамилии/username/био — отправляют "" =
    // ОЧИСТИТЬ (без чекбокса очистка требовала клик в поле с пустым значением).
    var clearLn = (document.getElementById('pf-clear-lastname') || {}).checked;
    var clearUn = (document.getElementById('pf-clear-username') || {}).checked;
    var clearAbout = (document.getElementById('pf-clear-about') || {}).checked;

    var data = {};
    if (genName) data.gen_name = true;
    else if (fnTouched) data.first_name = fn;  // "" = очистить
    if (genSurname) data.gen_surname = true;
    else if (clearLn) data.last_name = "";  // явная очистка
    else if (lnTouched) data.last_name = ln;
    if (genUsername) data.gen_username = true;
    else if (clearUn) data.username = "";  // явная очистка
    else if (unTouched) data.username = un;
    if (clearAbout) data.about = "";  // явная очистка
    else if (aboutTouched) data.about = about;
    if (avatarFolder) data.avatar_folder = avatarFolder;

    var resultEl = document.getElementById('pf-result');
    resultEl.style.display = 'block';
    resultEl.innerHTML = '<span class="text-muted">Заполнение запущено...</span>';

    var phones = getSelectorPhones('acc-selector-profiles');
    if (!phones.length) {
        showToast('Не выбраны аккаунты. Включите режим «Все» или «Выбрать».', 'warning');
        return;
    }
    // Видимое подтверждение что РЕАЛЬНО взято — юзер сразу видит phones.
    showToast('Заполняю ' + phones.length + ' акков (' + phones.slice(0, 3).map(function(p){return '+' + p;}).join(', ') + (phones.length > 3 ? '…' : '') + ')', 'info');
    data.phones = phones;

    // Если выбран inline-файл — грузим на сервер как временный, сервер раздаёт
    // всем аккам. Если указан folder — он приоритетнее (round-robin per-acc).
    var uploadPromise;
    if (inlineAvatarFile && !avatarFolder) {
        var fd = new FormData();
        fd.append('file', inlineAvatarFile);
        uploadPromise = fetch('/api/profile/avatar_upload_temp', {method: 'POST', body: fd})
            .then(function(r) { return r.json(); })
            .then(function(up) {
                if (up && up.ok && up.path) {
                    data.avatar_file = up.path;  // server'll read this file for each acc
                }
                return null;
            });
    } else {
        uploadPromise = Promise.resolve(null);
    }

    uploadPromise.then(function() {
    return API.post('/api/profile/fill_bulk', data);
    }).then(function(d) {
        // Обрабатываем ошибочный ответ (400/409) — иначе при d.ok=false не было
        // ни тоста, ни visible error и кнопка казалась дохлой.
        if (d && d.ok) {
            var extra = avatarFolder ? ' с аватарками из папки' : (data.avatar_file ? ' с inline-файлом аватарки' : '');
            resultEl.innerHTML = '<span class="text-success">✓ Запущено для ' + d.total + ' акков (каждому — разные данные' + extra + ')</span>';
            showToast('Заполнение запущено для ' + d.total + ' акков', 'success');
            _startProfileFillPoll();
        } else {
            var _err = (d && d.error) || 'Не удалось запустить заполнение';
            resultEl.innerHTML = '<span class="text-danger">✗ ' + escHtml(_err) + '</span>';
            showToast(_err, 'error');
        }
    }).then(function() {
        // Эмодзи-аватарка (fill_bulk её не поддерживает) — раздаём по одному.
        if (_pf_all_emoji && !avatarFolder && !inlineAvatarFile) {
            var _emo = ((document.getElementById('pf-avatar-emoji-char') || {}).value || '').trim();
            var _bg = ((document.getElementById('pf-avatar-emoji-bg') || {}).value || '#3b82f6');
            var _ec = Promise.resolve();
            phones.forEach(function(_ph) {
                _ec = _ec.then(function() { return API.post('/api/profile/avatar_emoji', {emoji: _emo, bg: _bg, phone: _ph}); });
            });
            return _ec.then(function() {
                resultEl.innerHTML += '<br><span class="text-success">✓ Эмодзи-аватарка разослана на ' + phones.length + ' акков</span>';
                _avatarCacheBust = Date.now(); loadAccounts();
            });
        }
    }).catch(function(e) { showToast('Ошибка: ' + _errText(e), 'error'); });
}

// Прогресс ручного «Заполнить всё» + показ кнопки Стоп, пока идёт.
function _startProfileFillPoll() {
    var stopBtn = document.getElementById('pf-stop-btn');
    if (stopBtn) stopBtn.style.display = '';
    if (window._pfFillTimer) clearInterval(window._pfFillTimer);
    window._pfFillTimer = setInterval(function() {
        API.get('/api/profile/fill_status').then(function(s) {
            var resultEl = document.getElementById('pf-result');
            if (s && s.running) {
                if (resultEl) {
                    resultEl.style.display = 'block';
                    resultEl.innerHTML = '<span class="text-muted">Заполняю: <b>' + (s.done || 0) + '/' + (s.total || 0) + '</b>… (детали в логах)</span>';
                }
            } else {
                clearInterval(window._pfFillTimer); window._pfFillTimer = null;
                if (stopBtn) stopBtn.style.display = 'none';
                if (resultEl && s) {
                    var stopped = s.status === 'stopped';
                    resultEl.innerHTML = stopped
                        ? '<span class="text-warning">⏹ Остановлено на ' + (s.done || 0) + '/' + (s.total || 0) + '</span>'
                        : '<span class="text-success">✓ Готово: ' + (s.done || 0) + '/' + (s.total || 0) + '</span>';
                }
                if (typeof loadAccounts === 'function') loadAccounts();
            }
        }).catch(function(){});
    }, 1500);
}

function stopProfileFill() {
    API.post('/api/profile/bulk_stop', {}).then(function() {
        showToast('Останавливаю…', 'info');
    }).catch(function() {});
}

// ══════════════════════════════════════════════════════════════════
//  AUTO-RESPONDER
// ══════════════════════════════════════════════════════════════════

var _arPollTimer = null;

function startAutoReply() {
    var text = document.getElementById('ar-text').value.trim();
    if (!text) { showToast('Введите текст ответа', 'warning'); return; }
    var delayMin = parseFloat(document.getElementById('ar-delay-min').value) || 3;
    var delayMax = parseFloat(document.getElementById('ar-delay-max').value) || 8;

    var phones = getSelectorPhones('acc-selector-autoreply');
    if (!phones.length) {
        showToast('Не выбраны аккаунты. Переключи режим на «Выбрать» и отметь нужные.', 'warning');
        return;
    }
    var backfillEl = document.getElementById('ar-backfill-hours');
    var backfillHours = backfillEl ? parseFloat(backfillEl.value) || 0 : 0;
    var swEl = document.getElementById('ar-stop-words');
    var stopWords = swEl ? swEl.value.trim() : '';
    var typingEl = document.getElementById('ar-simulate-typing');
    var readEl = document.getElementById('ar-mark-as-read');
    API.post('/api/autoresponder/start', {
        phones: phones, text: text, delay_min: delayMin, delay_max: delayMax,
        once_per_user: true, backfill_hours: backfillHours,
        stop_words: stopWords,
        simulate_typing: typingEl && typingEl.checked,
        mark_as_read: readEl && readEl.checked
    }).then(function(d) {
        if (d && d.ok && d.started > 0) {
            showToast('Автоответчик запущен на ' + d.started + ' акков', 'success');
            if (d.errors && d.errors.length) showToast('Не стартовали: ' + d.errors.join('; '), 'warning');
            document.getElementById('btn-ar-start').style.display = 'none';
            document.getElementById('btn-ar-stop').style.display = '';
            if (!_arPollTimer) _arPollTimer = setInterval(pollAutoReply, 3000);  // гвард от дубля (pollAutoReply мог уже поднять таймер)
        } else if (d && d.ok) {
            // started===0 — раньше показывало «успех» и уходило в running с 0 акков.
            showToast('Ни один аккаунт не запустил автоответчик' + ((d.errors && d.errors.length) ? ': ' + d.errors.join('; ') : ''), 'warning');
        } else {
            showToast((d && d.error) || 'Не удалось запустить автоответчик', 'error');
        }
    }).catch(function(e) { showToast('Ошибка: ' + ((e && e.message) || e), 'error'); });
}

function stopAutoReply() {
    API.get('/api/accounts').then(function(d) {
        var phones = (d.accounts || []).map(function(a) { return a.phone; });
        return API.post('/api/autoresponder/stop', {phones: phones});
    }).then(function(d) {
        if (d && d.ok) {
            showToast('Остановлен. Ответил ' + d.replied + ' юзерам', 'success');
            document.getElementById('btn-ar-start').style.display = '';
            document.getElementById('btn-ar-stop').style.display = 'none';
            clearInterval(_arPollTimer); _arPollTimer = null;  // null-им, иначе гвард !_arPollTimer не даст перезапустить
            document.getElementById('ar-status').innerHTML = '';
        }
    });
}

function pollAutoReply() {
    API.get('/api/autoresponder/status').then(function(d) {
        var el = document.getElementById('ar-status');
        var resp = d.responders || {};
        var phones = Object.keys(resp);
        // Сверяем кнопки с РЕАЛЬНЫМ состоянием сервера (источник истины). Раньше
        // btn-ar-stop показывался только после клика «Старт» в этой же сессии —
        // после перезагрузки страницы автоответчик крутился в памяти сервера, а
        // остановить было нечем (акки «заняты», задача «висела»). Теперь при живых
        // автоответчиках Стоп появляется сам, при пустом списке — прячется.
        var _bStart = document.getElementById('btn-ar-start');
        var _bStop = document.getElementById('btn-ar-stop');
        if (phones.length) {
            if (_bStart) _bStart.style.display = 'none';
            if (_bStop) _bStop.style.display = '';
            if (!_arPollTimer) _arPollTimer = setInterval(pollAutoReply, 3000);
        } else {
            if (_bStart) _bStart.style.display = '';
            if (_bStop) _bStop.style.display = 'none';
            if (_arPollTimer) { clearInterval(_arPollTimer); _arPollTimer = null; }
        }
        if (!el) return;
        if (!phones.length) {
            el.innerHTML = '<span class="text-muted text-sm">Нет активных автоответчиков</span>';
            return;
        }
        var html = '<div class="text-sm" style="border:1px solid var(--border);border-radius:5px;padding:10px;">';
        html += '<div class="fw-bold mb-2" style="color:var(--success);"><i class="bi bi-reply"></i> Активно: ' + phones.length + ' акков</div>';
        phones.forEach(function(phone) {
            var r = resp[phone];
            var uptime = r.uptime > 3600 ? Math.floor(r.uptime/3600) + 'ч' : Math.floor(r.uptime/60) + 'мин';
            html += '<div style="padding:3px 0;font-size:0.7rem;color:var(--text-muted);">+' + phone + ' — ответил ' + r.replied + ' юзерам, ' + uptime + '</div>';
        });
        html += '</div>';
        el.innerHTML = html;
    }).catch(function() { /* транзиентный сбой статуса — тихо, без unhandled rejection */ });
}


// ═══ Warmup (Прогрев) ═══════════════════════════════════════════════════════

var _warmupLoaded = false;

function loadWarmup() {
    API.get('/api/warmup/status').then(function(d) {
        _warmupLoaded = true;
        _renderWarmupAccounts(d.accounts || []);
        _renderWarmupStatus(d);
        _renderWarmupPairs(d.pairs || []);
        _renderWarmupLog(d.log || []);
        // Подсказка под селектом «Генерация текстов»: видно ли настроен AI.
        try { onWarmupGenModeChange(); } catch (e) {}
    }).catch(function() {});

    // AI-пул статус для warmup-ai-pool-hint
    try { loadAiPoolStatus(); } catch (e) {}
    // GitHub URL/pool статус
    try { loadGithubStatus(); } catch (e) {}

    loadOwnEntities();

    // Load config
    API.get('/api/warmup/config').then(function(d) {
        var el;
        el = document.getElementById('warmup-gemini-key');
        if (el && d.gemini_api_key) el.value = d.gemini_api_key;
        el = document.getElementById('warmup-gen-mode');
        // Только существующие <option> (бэк мог отдать 'auto', которого нет → пустой селект).
        if (el && (d.warmup_gen_mode === 'templates' || d.warmup_gen_mode === 'ai')) el.value = d.warmup_gen_mode;
        el = document.getElementById('warmup-hours-start');
        if (el) el.value = d.warmup_active_start || 9;
        el = document.getElementById('warmup-hours-end');
        if (el) el.value = d.warmup_active_end || 22;
        el = document.getElementById('warmup-delay-min');
        if (el) el.value = d.warmup_min_delay || 60;
        el = document.getElementById('warmup-delay-max');
        if (el) el.value = d.warmup_max_delay || 300;
        el = document.getElementById('warmup-auto-move');
        if (el) el.checked = d.warmup_auto_move !== false;
        // Quiet hours (мёртвые часы по локальному времени аккаунта)
        el = document.getElementById('warmup-quiet-enabled');
        if (el) el.checked = d.warmup_quiet_hours_enabled !== false;
        el = document.getElementById('warmup-quiet-start');
        if (el) el.value = (typeof d.warmup_quiet_hours_start === 'number') ? d.warmup_quiet_hours_start : 23;
        el = document.getElementById('warmup-quiet-end');
        if (el) el.value = (typeof d.warmup_quiet_hours_end === 'number') ? d.warmup_quiet_hours_end : 7;

        // Свои шаблоны прогрева (3 textarea — greetings/chatting/goodbye)
        var ut = d.warmup_user_templates || {};
        var g = document.getElementById('warmup-tpl-greetings');
        if (g) g.value = (ut.greetings || []).join('\n');
        var c = document.getElementById('warmup-tpl-chatting');
        if (c) c.value = (ut.chatting || []).join('\n');
        var gb = document.getElementById('warmup-tpl-goodbye');
        if (gb) gb.value = (ut.goodbye || []).join('\n');

        // Восстанавливаем сохранённые сценарии («Сохранить сценарии»). Без этого
        // конфиг был write-only: после перезагрузки UI сбрасывался к дефолтам и
        // сохранённые сценарии молча терялись при «Запустить». Зеркалит _getScenariosPayload.
        var scn = d.scenarios || {};
        if (scn && Object.keys(scn).length) {
            var scc = scn.scenarios || {};
            var _sChk = function(id, v) { var e = document.getElementById(id); if (e) e.checked = !!v; };
            var _sVal = function(id, v) { var e = document.getElementById(id); if (e && v !== undefined && v !== null) e.value = v; };
            _sChk('scn-dm', scc.dm !== false);
            _sChk('scn-channels', scc.channels);
            _sChk('scn-reactions', scc.reactions);
            _sChk('scn-own-chat', scc.own_chat);
            _sChk('scn-diaries', scc.diaries);
            _sChk('scn-search', scc.search);
            _sChk('scn-mention', scc.mention);
            _sChk('scn-hub', scc.hub_post);
            _sChk('scn-reply', scn.reply_to_incoming !== false);
            _sVal('scn-channels-list', (scn.channels_list || []).join('\n'));
            _sVal('scn-channels-per-day', scn.channels_per_day);
            _sVal('scn-reactions-per-day', scn.reactions_per_day);
            _sVal('scn-dm-msgs-per-day', scn.dm_msgs_per_day_per_acc);
            _sVal('scn-search-keywords', scn.search_keywords);
            _sVal('scn-mention-per-day', scn.mention_replies_per_day);
        }
    }).catch(function() {});

    // Load blacklist
    API.get('/api/warmup/blacklist').then(function(d) {
        var el = document.getElementById('warmup-blacklist');
        if (el && d.text) el.value = d.text;
    }).catch(function() {});
}

function _splitTemplateLines(text) {
    if (!text) return [];
    return text.split('\n').map(function(s){ return s.trim(); }).filter(function(s){ return s.length > 0; });
}

function saveWarmupTemplates() {
    var status = document.getElementById('warmup-tpl-status');
    var payload = {
        warmup_user_templates: {
            greetings: _splitTemplateLines((document.getElementById('warmup-tpl-greetings') || {}).value || ''),
            chatting:  _splitTemplateLines((document.getElementById('warmup-tpl-chatting')  || {}).value || ''),
            goodbye:   _splitTemplateLines((document.getElementById('warmup-tpl-goodbye')   || {}).value || ''),
        }
    };
    var total = payload.warmup_user_templates.greetings.length +
                payload.warmup_user_templates.chatting.length +
                payload.warmup_user_templates.goodbye.length;
    API.post('/api/settings', payload).then(function(d) {
        if (d && d.ok) {
            if (status) status.textContent = 'Сохранено: ' + total + ' фраз';
            if (status) status.style.color = 'var(--success)';
            showToast('Свои шаблоны прогрева сохранены (' + total + ' фраз)', 'success');
        } else {
            if (status) status.textContent = 'Ошибка: ' + (d && d.error || 'неизвестно');
            if (status) status.style.color = 'var(--danger)';
        }
    }).catch(function(e) {
        if (status) status.textContent = 'Ошибка сети';
        if (status) status.style.color = 'var(--danger)';
    });
}

async function clearWarmupTemplates() {
    if (!(await appConfirm('Сбросить свои шаблоны и вернуть стандартные? Текст из полей сотрётся.', {danger: true}))) return;
    var g = document.getElementById('warmup-tpl-greetings'); if (g) g.value = '';
    var c = document.getElementById('warmup-tpl-chatting');  if (c) c.value = '';
    var gb = document.getElementById('warmup-tpl-goodbye');  if (gb) gb.value = '';
    saveWarmupTemplates();
}

function importWarmupTemplatesFromFile(input) {
    var status = document.getElementById('warmup-tpl-status');
    if (!input || !input.files || !input.files.length) return;
    var file = input.files[0];
    var target = (document.getElementById('warmup-tpl-import-target') || {}).value || 'auto';
    var reader = new FileReader();
    reader.onload = function(ev) {
        var text = String(ev.target.result || '');
        var lines = text.split(/\r?\n/);
        // Парсим теги [greetings] / [chatting] / [goodbye] — позволяет в одном
        // файле задать все три пула. Без тегов всё идёт в выбранный target.
        var buckets = { greetings: [], chatting: [], goodbye: [] };
        var current = (target === 'auto') ? null : target;
        var tagRe = /^\[(greetings|приветствия|chatting|основные|основное|chat|goodbye|прощания|farewell)\]\s*$/i;
        var tagMap = {
            greetings: 'greetings', 'приветствия': 'greetings',
            chatting: 'chatting', 'основные': 'chatting', 'основное': 'chatting', 'chat': 'chatting',
            goodbye: 'goodbye', 'прощания': 'goodbye', 'farewell': 'goodbye',
        };
        var unknown_lines = 0;
        for (var i = 0; i < lines.length; i++) {
            var line = lines[i].trim();
            if (!line || line.startsWith('#') || line.startsWith('//')) continue;
            var m = line.match(tagRe);
            if (m) {
                current = tagMap[m[1].toLowerCase()] || null;
                continue;
            }
            if (!current) {
                unknown_lines++;
                if (unknown_lines === 1) {
                    // В режиме auto без тегов первую строку без тега кидаем в greetings,
                    // но это ошибочный сценарий — лучше юзер выберет target явно.
                    current = 'greetings';
                }
            }
            if (current) buckets[current].push(line);
        }
        // Если режим был не auto и теги внутри файла поменяли направление — оставляем как есть.
        // Если режим target и тегов в файле не было — всё ушло в target (current не менялось).
        var total = buckets.greetings.length + buckets.chatting.length + buckets.goodbye.length;
        if (!total) {
            if (status) {
                status.textContent = 'В файле нет фраз';
                status.style.color = 'var(--warning)';
            }
            input.value = '';
            return;
        }
        // Аппендим к существующим, не заменяя
        function _appendTo(elId, items) {
            if (!items.length) return;
            var el = document.getElementById(elId);
            if (!el) return;
            var existing = (el.value || '').split('\n').map(function(s){return s.trim();}).filter(function(s){return s.length>0;});
            var merged = existing.concat(items);
            // Дедуплицируем case-insensitive
            var seen = {};
            var unique = [];
            for (var j = 0; j < merged.length; j++) {
                var k = merged[j].toLowerCase();
                if (!seen[k]) { seen[k] = true; unique.push(merged[j]); }
            }
            el.value = unique.join('\n');
        }
        _appendTo('warmup-tpl-greetings', buckets.greetings);
        _appendTo('warmup-tpl-chatting',  buckets.chatting);
        _appendTo('warmup-tpl-goodbye',   buckets.goodbye);

        var parts = [];
        if (buckets.greetings.length) parts.push('приветствия: ' + buckets.greetings.length);
        if (buckets.chatting.length)  parts.push('основные: ' + buckets.chatting.length);
        if (buckets.goodbye.length)   parts.push('прощания: ' + buckets.goodbye.length);
        if (status) {
            status.textContent = 'Загружено ' + parts.join(', ') + ' — не забудь Сохранить';
            status.style.color = 'var(--success)';
        }
        showToast('Импортировано ' + total + ' фраз — нажми «Сохранить шаблоны»', 'success');
        input.value = '';  // позволяет повторно выбрать тот же файл
    };
    reader.onerror = function() {
        if (status) { status.textContent = 'Ошибка чтения файла'; status.style.color = 'var(--danger)'; }
        input.value = '';
    };
    reader.readAsText(file, 'UTF-8');
}

function _renderWarmupAccounts(accounts) {
    var el = document.getElementById('warmup-accounts-table');
    if (!el) return;
    if (!accounts.length) {
        el.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:16px;">' +
            '<i class="bi bi-inbox"></i> Переместите аккаунты в папку "Прогрев"</td></tr>';
        return;
    }
    var html = '';
    accounts.forEach(function(a) {
        html += '<tr>' +
            '<td style="font-size:0.75rem;white-space:nowrap;">' + (a.flag ? flagImg(a.flag) + ' ' : '') + '+' + escHtml(a.phone) + '</td>' +
            '<td style="font-size:0.75rem;">' + escHtml(a.first_name || a.username || '') + '</td>' +
            '<td style="font-size:0.75rem;">' + escHtml(a.age_text) +
                ' <span class="tt tt-left" data-tip="' + escHtml(_humanizeAgeSource(a.age_source)) + '">?</span></td>' +
            '<td style="font-size:0.75rem;">' + (a.is_premium ? '<span style="color:#f97316;"><i class="bi bi-star-fill"></i></span>' : '—') + '</td>' +
            '<td style="font-size:0.75rem;">' + (a.has_proxy ? '<span style="color:#34d399;">✓</span>' : '<span style="color:var(--danger);">✗</span>') + '</td>' +
            '<td style="font-size:0.75rem;">' + escHtml(a.folder) + '</td>' +
            '<td style="font-size:0.7rem;text-align:right;">' +
                '<button class="btn btn-outline btn-sm" onclick="resetAccDay(\'' + a.phone + '\')" style="padding:2px 6px;font-size:0.65rem;" title="Сбросить на день 1"><i class="bi bi-arrow-counterclockwise"></i> День 1</button>' +
            '</td>' +
        '</tr>';
    });
    el.innerHTML = html;

    // Обновить dropdown для hub-акка
    var hubSel = document.getElementById('warmup-hub-phone');
    if (hubSel) {
        var currentVal = hubSel.value;
        var opts = '<option value="">Первый в списке</option>';
        accounts.forEach(function(a) {
            opts += '<option value="' + a.phone + '">+' + a.phone + ' ' + escHtml(a.first_name || a.username || '') + '</option>';
        });
        hubSel.innerHTML = opts;
        if (currentVal) hubSel.value = currentVal;
    }
}

async function resetAccDay(phone) {
    if (!(await appConfirm('Сбросить аккаунт +' + phone + ' на день 1?', {danger: true}))) return;
    API.post('/api/warmup/reset_acc_day', { phone: phone }).then(function(d) {
        if (d.ok) showToast('Акк +' + phone + ' сброшен на день 1', 'success');
        else showToast(d.error || 'Ошибка', 'error');
    });
}

// Безопасный пресет — выставляет консервативные дефолты прогрева.
function onWarmupSafePresetChange(enabled) {
    if (!enabled) return; // выкл = ничего не делаем, юзер крутит сам
    var sets = {
        'warmup-mode': 'classic',
        'warmup-gen-mode': 'templates',
        'warmup-hours-start': '9',
        'warmup-hours-end': '22',
        'warmup-quiet-start': '23',
        'warmup-quiet-end': '7',
        'warmup-delay-min': '120',
        'warmup-delay-max': '300',
        'warmup-threads': '1',
    };
    Object.keys(sets).forEach(function(id) {
        var el = document.getElementById(id);
        if (el) el.value = sets[id];
    });
    // тихие часы вкл
    var q = document.getElementById('warmup-quiet-enabled');
    if (q) q.checked = true;
    // только DM-сценарий
    ['scn-dm'].forEach(function(id) {
        var el = document.getElementById(id); if (el) el.checked = true;
    });
    ['scn-channels', 'scn-reactions', 'scn-hub-chat', 'scn-own-channel'].forEach(function(id) {
        var el = document.getElementById(id); if (el) el.checked = false;
    });
    try { onWarmupModeChange(); onWarmupGenModeChange(); } catch (e) {}
    if (typeof showToast === 'function') showToast('Безопасный пресет применён', 'success');
}

function onWarmupModeChange() {
    var mode = (document.getElementById('warmup-mode') || {}).value || 'classic';
    var hubWrap = document.getElementById('warmup-hub-wrap');
    var chatWrap = document.getElementById('warmup-chat-opts-wrap');
    if (hubWrap) hubWrap.style.display = mode === 'hub' ? '' : 'none';
    if (chatWrap) chatWrap.style.display = mode === 'chat_flow' ? '' : 'none';
    var hint = document.getElementById('warmup-mode-hint');
    if (hint) {
        if (mode === 'classic') hint.title = 'Classic: акки разбиваются на пары и общаются в ЛС друг с другом. Самый естественный вариант.';
        else if (mode === 'hub') hint.title = 'Hub: выбранный акк добавляет всех в контакты и пишет им первым. Подходит когда нужен 1 "главный" акк.';
        else if (mode === 'chat_flow') hint.title = 'Chat-flow: все акки общаются в одном общем чате прогрева. Сначала создайте чат в сценарии "Свой чат" ниже.';
    }
}

function _renderWarmupStatus(d) {
    var banner = document.getElementById('warmup-status-banner');
    var schedule = document.getElementById('warmup-schedule');
    var startBtn = document.getElementById('warmup-start-btn');
    var pauseBtn = document.getElementById('warmup-pause-btn');
    var stopBtn = document.getElementById('warmup-stop-btn');
    var daysSelect = document.getElementById('warmup-days');

    if (!d.running) {
        if (banner) banner.style.display = 'none';
        if (schedule) schedule.style.display = 'none';
        if (startBtn) startBtn.style.display = '';
        if (pauseBtn) pauseBtn.style.display = 'none';
        if (stopBtn) stopBtn.style.display = 'none';
        if (daysSelect) daysSelect.disabled = false;
        return;
    }

    if (startBtn) startBtn.style.display = 'none';
    if (pauseBtn) { pauseBtn.style.display = ''; pauseBtn.innerHTML = d.status === 'paused' ? '<i class="bi bi-play-fill"></i> Продолжить' : '<i class="bi bi-pause-fill"></i> Пауза'; }
    if (stopBtn) stopBtn.style.display = '';
    if (daysSelect) daysSelect.disabled = true;

    var s = d.schedule || {};
    if (schedule) {
        schedule.style.display = '';
        var stageText = document.getElementById('warmup-stage-text');
        if (stageText) stageText.innerHTML = _renderStageIcon(s.stage_icon) + ' ' + escHtml(s.stage_label || '');
        var dayText = document.getElementById('warmup-day-text');
        if (dayText) dayText.textContent = 'День ' + s.current_day + '/' + s.total_days;
        var bar = document.getElementById('warmup-progress-bar');
        if (bar) {
            bar.style.width = (s.pct || 0) + '%';
            if (d.status === 'running') bar.classList.add('is-running');
            else bar.classList.remove('is-running');
        }
        var mt = document.getElementById('warmup-msgs-today');
        if (mt) mt.textContent = d.messages_today || 0;
        var ml = document.getElementById('warmup-msgs-limit');
        if (ml) ml.textContent = s.msgs_per_day || 0;
        var total = document.getElementById('warmup-msgs-total');
        if (total) total.textContent = d.total_messages || 0;
    }

    if (banner) {
        var color = d.status === 'paused' ? '#f59e0b' : '#34d399';
        var label = d.status === 'paused' ? 'Пауза' : 'Работает';
        banner.style.display = '';
        banner.innerHTML = '<div style="background:' + color + '20;border:1px solid ' + color + '40;border-radius:5px;padding:8px 12px;font-size:0.8rem;color:' + color + ';">' +
            '<b>' + label + '</b> — ' + _renderStageIcon(s.stage_icon) + ' ' + escHtml(s.stage_label || '') +
            ' | День ' + (s.current_day || 1) + '/' + (s.total_days || 21) +
            ' | ' + (s.max_peers || 5) + ' юзов | ' + (d.total_messages || 0) + ' сообщений</div>';
    }
}

function _renderWarmupPairs(pairs) {
    var section = document.getElementById('warmup-pairs-section');
    var el = document.getElementById('warmup-pairs-list');
    if (!el || !section) return;
    if (!pairs.length) { section.style.display = 'none'; return; }
    section.style.display = '';
    // Карточки с цветовой кодировкой статуса и понятными бейджами.
    var stateMap = {
        'chatting':  {label: 'Общаются', color: '#34d399', bg: 'rgba(52,211,153,0.10)', icon: 'bi-chat-dots-fill'},
        'greeting':  {label: 'Знакомятся', color: '#f59e0b', bg: 'rgba(245,158,11,0.10)', icon: 'bi-hand-thumbs-up'},
        'idle':      {label: 'Ждёт', color: 'var(--text-muted)', bg: 'rgba(255,255,255,0.03)', icon: 'bi-pause-circle'},
        'goodbye':   {label: 'Прощаются', color: '#60a5fa', bg: 'rgba(96,165,250,0.10)', icon: 'bi-hand-thumbs-up'},
        'done':      {label: 'Готово', color: 'var(--success)', bg: 'rgba(52,168,83,0.08)', icon: 'bi-check-circle-fill'},
    };
    var html = '';
    pairs.forEach(function(p) {
        var st = stateMap[p.state] || stateMap.idle;
        var topicTxt = p.topic ? '<span style="color:var(--text-muted);font-size:0.66rem;margin-left:6px;">· ' + escHtml(p.topic) + '</span>' : '';
        html += '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;margin-bottom:5px;background:' + st.bg + ';border-radius:5px;font-size:0.74rem;border:1px solid rgba(255,255,255,0.04);">' +
            '<i class="bi ' + st.icon + '" style="color:' + st.color + ';font-size:0.95rem;flex-shrink:0;"></i>' +
            '<div style="flex:1;min-width:0;">' +
                '<div style="color:var(--text-primary);font-weight:500;">+' + p.phone_a + ' <span style="color:var(--text-muted);">↔</span> +' + p.phone_b + topicTxt + '</div>' +
            '</div>' +
            '<span style="background:' + st.color + '22;color:' + st.color + ';padding:2px 8px;border-radius:5px;font-size:0.62rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;white-space:nowrap;">' + st.label + '</span>' +
            '<span style="color:var(--text-muted);font-size:0.66rem;white-space:nowrap;min-width:55px;text-align:right;">' + p.exchanged + ' обм.</span>' +
        '</div>';
    });
    el.innerHTML = html;
}

function _renderWarmupLog(log) {
    // no-op. Инлайн «Лог» в модуле убран — события идут в единый поток «Логи
    // работы». Функция оставлена чтоб не сломать существующие вызовы.
}

function _getScenariosPayload() {
    var chansText = (document.getElementById('scn-channels-list') || {}).value || '';
    return {
        scenarios: {
            dm: (document.getElementById('scn-dm') || {}).checked !== false,
            channels: !!(document.getElementById('scn-channels') || {}).checked,
            reactions: !!(document.getElementById('scn-reactions') || {}).checked,
            own_chat: !!(document.getElementById('scn-own-chat') || {}).checked,
            diaries: !!(document.getElementById('scn-diaries') || {}).checked,
            // новые сценарии
            search: !!(document.getElementById('scn-search') || {}).checked,
            mention: !!(document.getElementById('scn-mention') || {}).checked,
            hub_post: !!(document.getElementById('scn-hub') || {}).checked,
        },
        reply_to_incoming: (document.getElementById('scn-reply') || {}).checked !== false,
        channels_list: chansText.split('\n').map(function(s){return s.trim()}).filter(Boolean),
        channels_per_day: parseInt((document.getElementById('scn-channels-per-day') || {}).value) || 3,
        reactions_per_day: parseInt((document.getElementById('scn-reactions-per-day') || {}).value) || 5,
        // Сообщений ЛС/день на акк. 0 = авто (по дню/неделе прогрева).
        // Если задан — будет применяться весь цикл независимо от дня.
        dm_msgs_per_day_per_acc: Math.max(0, Math.min(200, parseInt((document.getElementById('scn-dm-msgs-per-day') || {}).value) || 0)),
        // search/mention параметры
        search_keywords: (document.getElementById('scn-search-keywords') || {}).value || '',
        mention_replies_per_day: parseInt((document.getElementById('scn-mention-per-day') || {}).value) || 3,
    };
}

function startWarmup() {
    var days = parseInt(document.getElementById('warmup-days').value) || 21;
    var startDay = parseInt((document.getElementById('warmup-start-day') || {}).value) || 1;
    if (startDay < 1) startDay = 1;
    if (startDay > days) {
        showToast('Стартовый день не может быть больше всего дней (' + days + ')', 'warning');
        return;
    }
    var genMode = (document.getElementById('warmup-gen-mode') || {}).value || 'templates';
    var startH = parseInt((document.getElementById('warmup-hours-start') || {}).value) || 9;
    var endH = parseInt((document.getElementById('warmup-hours-end') || {}).value) || 22;
    var minDelay = parseInt((document.getElementById('warmup-delay-min') || {}).value) || 60;
    var maxDelay = parseInt((document.getElementById('warmup-delay-max') || {}).value) || 300;
    var autoMove = (document.getElementById('warmup-auto-move') || {}).checked !== false;

    var msgsOverride = parseInt((document.getElementById('warmup-msgs-override') || {}).value) || 0;
    if (msgsOverride < 0) msgsOverride = 0;
    if (msgsOverride > 500) msgsOverride = 500;

    var qhStartW = parseInt((document.getElementById('warmup-quiet-start') || {}).value);
    var qhEndW = parseInt((document.getElementById('warmup-quiet-end') || {}).value);

    // Picker: phones (опционально). По умолчанию (mode='one' + ничего не выбрано)
    // getSelectorPhones вернёт текущего активного — это не нужно прогреву (он работает
    // на ВСЕХ из папки). Шлём phones ТОЛЬКО если режим 'multi' или 'all'.
    var warmupPhones = [];
    var warmupMode = _accSelectorMode['acc-selector-warmup'] || 'one';
    if (warmupMode === 'multi' || warmupMode === 'all') {
        warmupPhones = getSelectorPhones('acc-selector-warmup');
    }

    var payload = {
        total_days: days,
        start_day: startDay,
        msgs_per_day_override: msgsOverride,
        auto_move: autoMove,
        gen_mode: genMode,
        phones: warmupPhones,
        active_hours_start: startH,
        active_hours_end: endH,
        min_delay: minDelay,
        max_delay: maxDelay,
        threads: parseInt((document.getElementById('warmup-threads') || {}).value) || 1,
        reaction_share: (function(){
            var v = parseInt((document.getElementById('warmup-reaction-share') || {}).value);
            if (isNaN(v)) return 70;
            return Math.max(0, Math.min(100, v));
        })(),
        work_until_midnight: !!(document.getElementById('warmup-until-midnight') || {}).checked,
        manual_next_day: !!(document.getElementById('warmup-manual-next-day') || {}).checked,
        warmup_mode: (document.getElementById('warmup-mode') || {}).value || 'classic',
        hub_phone: (document.getElementById('warmup-hub-phone') || {}).value || '',
        id_format: !!(document.getElementById('warmup-id-format') || {}).checked,
        duplicate_dm_after_reply: (document.getElementById('warmup-duplicate-dm') || {}).checked !== false,
        quiet_hours_enabled: (document.getElementById('warmup-quiet-enabled') || {}).checked !== false,
        quiet_hours_start: isNaN(qhStartW) ? 23 : qhStartW,
        quiet_hours_end: isNaN(qhEndW) ? 7 : qhEndW,
    };
    var sc = _getScenariosPayload();
    for (var k in sc) payload[k] = sc[k];

    payload.ad_config = readAdConfig('warmup');
    API.post('/api/warmup/start', payload).then(function(d) {
        if (d.ok) {
            showToast('Прогрев запущен: ' + d.pairs + ' пар, ' + d.days + ' дней', 'success');
            loadWarmup();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) {
        // API.post бросает Error с .message = d.error бэкенда (напр. «Нужно
        // минимум 2 аккаунта в папке Прогрев») — показываем реальную причину.
        showToast((e && e.message) || 'Ошибка запуска прогрева', 'error');
    });
}

function stopWarmup() {
    API.post('/api/warmup/stop').then(function(d) {
        if (d.ok) { showToast('Прогрев остановлен', 'info'); loadWarmup(); }
    });
}

function pauseWarmup() {
    API.post('/api/warmup/pause').then(function(d) {
        if (d.ok) {
            showToast(d.status === 'paused' ? 'Прогрев на паузе' : 'Прогрев возобновлён', 'info');
            loadWarmup();
        }
    });
}

function moveSelectedToFolder() {
    var phones = [];
    document.querySelectorAll('.acc-check:checked').forEach(function(cb) {
        phones.push(cb.value);
    });
    if (!phones.length) { showToast('Отметьте аккаунты', 'warning'); return; }

    API.get('/api/accounts/folders').then(function(d) {
        var folders = d.folders || [];
        if (!folders.length) { showToast('Нет доступных папок', 'error'); return; }
        var modal = document.createElement('div');
        modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:flex;align-items:center;justify-content:center;';
        modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
        modal.innerHTML = '<div style="background:var(--bg-card-hover);border:1px solid var(--border);border-radius:7px;padding:20px;max-width:380px;width:90%;">' +
            '<h3 style="color:#fff;font-size:1.05rem;margin-bottom:12px;">Перенести ' + phones.length + ' аккаунтов в папку</h3>' +
            '<select id="_move-folder-sel" class="form-control mb-3" style="font-size:0.85rem;">' +
            folders.map(function(f) { return '<option value="' + escHtml(f) + '">' + escHtml(f) + '</option>'; }).join('') +
            '</select>' +
            '<div class="d-flex gap-2"><button class="btn btn-accent btn-sm" onclick="_moveDoIt()">Перенести</button>' +
            '<button class="btn btn-outline btn-sm" onclick="this.closest(\'div[style*=fixed]\').remove();">Отмена</button></div>' +
        '</div>';
        modal.id = '_move-modal';
        window._movePhones = phones;
        document.body.appendChild(modal);
    });
}

function _moveDoIt() {
    var sel = document.getElementById('_move-folder-sel');
    var folder = sel ? sel.value : '';
    if (!folder) return;
    API.post('/api/accounts/move_to_folder', { phones: window._movePhones || [], folder: folder }).then(function(d) {
        if (d.ok) {
            // Часть акков могла остаться на месте — чаще всего потому, что они
            // сейчас в работе. Молчаливое «Перенесено: 3» из пяти выбранных
            // читалось как полный успех, и человек узнавал об остатке случайно.
            var left = d.errors || [];
            // notes — перенос удался, но рядом осталась вторая копия номера.
            // Это не ошибка, а повод заглянуть в папки.
            var notes = d.notes || [];
            if (left.length) {
                showToast('Перенесено: ' + d.moved + '. Осталось на месте:\n' + left.join('\n'),
                          d.moved ? 'warning' : 'error');
            } else if (notes.length) {
                showToast('Перенесено: ' + d.moved + '.\n' + notes.join('\n'), 'warning');
            } else {
                showToast('Перенесено: ' + d.moved, 'success');
            }
            loadAccounts();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
        var m = document.getElementById('_move-modal');
        if (m) m.remove();
    });
}

async function warmupNextDay() {
    if (!(await appConfirm('Запустить следующий день прогрева?', {}))) return;
    API.post('/api/warmup/next_day').then(function(d) {
        if (d.ok) {
            showToast(d.msg || 'Следующий день запущен', 'success');
            loadWarmup();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    });
}

function saveWarmupConfig(silent) {
    var qhStart = parseInt((document.getElementById('warmup-quiet-start') || {}).value);
    var qhEnd = parseInt((document.getElementById('warmup-quiet-end') || {}).value);
    var data = {
        gemini_api_key: (document.getElementById('warmup-gemini-key') || {}).value || '',
        warmup_gen_mode: (document.getElementById('warmup-gen-mode') || {}).value || 'templates',
        warmup_active_start: parseInt((document.getElementById('warmup-hours-start') || {}).value) || 9,
        warmup_active_end: parseInt((document.getElementById('warmup-hours-end') || {}).value) || 22,
        warmup_min_delay: parseInt((document.getElementById('warmup-delay-min') || {}).value) || 60,
        warmup_max_delay: parseInt((document.getElementById('warmup-delay-max') || {}).value) || 300,
        warmup_auto_move: (document.getElementById('warmup-auto-move') || {}).checked !== false,
        warmup_quiet_hours_enabled: (document.getElementById('warmup-quiet-enabled') || {}).checked !== false,
        warmup_quiet_hours_start: isNaN(qhStart) ? 23 : qhStart,
        warmup_quiet_hours_end: isNaN(qhEnd) ? 7 : qhEnd,
    };
    API.post('/api/warmup/config', data).then(function(d) {
        if (d.ok && !silent) showToast('Настройки сохранены', 'success');
    });
}

// Авто-сохранение настроек прогрева при изменении любого поля.
// Дебаунс 800мс чтобы не долбить сервер на каждый keypress.
var _warmupSaveTimer = null;
function _scheduleWarmupSave() {
    if (_warmupSaveTimer) clearTimeout(_warmupSaveTimer);
    _warmupSaveTimer = setTimeout(function() { saveWarmupConfig(true); }, 800);
}

function _bindWarmupAutoSave() {
    var ids = ['warmup-gemini-key', 'warmup-gen-mode', 'warmup-hours-start',
               'warmup-hours-end', 'warmup-delay-min', 'warmup-delay-max',
               'warmup-auto-move', 'warmup-quiet-enabled', 'warmup-quiet-start',
               'warmup-quiet-end'];
    ids.forEach(function(id) {
        var el = document.getElementById(id);
        if (!el || el.dataset._autosaveBound) return;
        el.dataset._autosaveBound = '1';
        el.addEventListener('change', _scheduleWarmupSave);
        if (el.tagName === 'INPUT' && el.type !== 'checkbox') {
            el.addEventListener('input', _scheduleWarmupSave);
        }
    });
}
if (typeof document !== 'undefined') {
    document.addEventListener('DOMContentLoaded', _bindWarmupAutoSave);
}

function checkGeminiKey() {
    var key = (document.getElementById('warmup-gemini-key') || {}).value || '';
    if (!key) { showToast('Введите ключ', 'warning'); return; }
    showToast('Проверяю...', 'info');
    API.post('/api/warmup/check_key', { api_key: key }).then(function(d) {
        if (d.ok) showToast('Ключ работает!', 'success');
        else showToast('Ошибка: ' + (d.error || 'неизвестно'), 'error');
    }).catch(function() { showToast('Ошибка проверки', 'error'); });
}

function saveWarmupBlacklist() {
    var text = (document.getElementById('warmup-blacklist') || {}).value || '';
    API.post('/api/warmup/blacklist', { text: text }).then(function(d) {
        if (d.ok) showToast('Чёрный список сохранён', 'success');
    });
}

function fetchGithubChannels() {
    var urlEl = document.getElementById('warmup-github-url');
    var url = (urlEl && urlEl.value || '').trim();
    if (!url) { showToast('Укажите URL списка каналов', 'warning'); return; }
    if (!/^https?:\/\//.test(url)) { showToast('URL должен начинаться с http:// или https://', 'warning'); return; }
    showToast('Скачиваю список...', 'info');
    API.post('/api/warmup/fetch_github_channels', { url: url }).then(function(d) {
        if (d.ok) {
            showToast('Загружено ' + d.fetched + ' каналов. В пуле: ' + d.total_in_pool, 'success');
            loadDefaultChannels();
            loadGithubStatus();
        } else {
            showToast(d.error || 'Не удалось скачать', 'error');
            if (d.details && console) console.warn('Fetch errors:', d.details);
        }
    }).catch(function() { showToast('Нет связи', 'error'); });
}

async function clearGithubPool() {
    if (!(await appConfirm('Очистить локальный пул каналов скачанных по ссылке?', {danger: true}))) return;
    API.post('/api/warmup/clear_github_pool', {}).then(function(d) {
        if (d.ok) {
            showToast('Пул очищен', 'success');
            loadGithubStatus();
        }
    });
}

function loadGithubStatus() {
    API.get('/api/warmup/github_status').then(function(d) {
        var urlEl = document.getElementById('warmup-github-url');
        if (urlEl && !urlEl.value && d.url) urlEl.value = d.url;
        var st = document.getElementById('warmup-github-status');
        if (st) {
            if (d.pool_size > 0) {
                st.innerHTML = '<i class="bi bi-check-circle" style="color:var(--success);"></i> В пуле ' + d.pool_size + ' каналов из вашего источника';
            } else {
                st.innerHTML = '';
            }
        }
    }).catch(function() {});
}

function loadDefaultChannels() {
    API.get('/api/warmup/defaults').then(function(d) {
        var el = document.getElementById('scn-channels-list');
        if (el && d.channels) {
            var existing = (el.value || '').split('\n').map(function(s){return s.trim()}).filter(Boolean);
            var merged = existing.concat(d.channels).filter(function(v, i, a){ return a.indexOf(v) === i; });
            el.value = merged.join('\n');
            showToast('Загружено ' + d.channels.length + ' каналов', 'success');
        }
    });
}




// Создать канал хабу + подписать на него warmup-акки


function loadDiariesStatus() {
    var st = document.getElementById('diaries-status');
    if (!st) return;
    API.get('/api/warmup/diaries_status').then(function(d) {
        var total = d.total || 0;
        if (total === 0) {
            st.innerHTML = '<span style="color:var(--text-muted);">Дневники ещё не созданы</span>';
            return;
        }
        var byTheme = {};
        (d.diaries || []).forEach(function(it) {
            byTheme[it.theme] = (byTheme[it.theme] || 0) + 1;
        });
        var labels = { work: 'Работа', learning: 'Учёба', travel: 'Путешествия', sport: 'Спорт', freelance: 'Фриланс' };
        var parts = Object.keys(byTheme).map(function(k){ return (labels[k] || k) + ': ' + byTheme[k]; });
        st.innerHTML = '<i class="bi bi-check-circle" style="color:var(--success);"></i> Создано <b>' + total + '</b> дневников. ' + parts.join(' · ');
    }).catch(function(){
        st.innerHTML = '<span style="color:var(--danger);">Не удалось загрузить статус</span>';
    });
}


function loadOwnEntities() {
    API.get('/api/warmup/own_entities').then(function(d) {
        if (d.own_chat_id) {
            var st = document.getElementById('own-chat-status');
            if (st) st.innerHTML = '<i class="bi bi-check-circle" style="color:var(--success);"></i> ' + escHtml(d.own_chat_title || 'Чат') + ' создан (ID: ' + d.own_chat_id + ')';
        }
    }).catch(function(){});
    loadDiariesStatus();
}

function saveScenarios() {
    // Сохраняем в user_settings как JSON
    var payload = _getScenariosPayload();
    API.post('/api/warmup/config', {
        warmup_scenarios_json: JSON.stringify(payload),
    }).then(function(d) {
        if (d.ok) showToast('Сценарии сохранены. Они применятся при следующем запуске прогрева.', 'success');
    });
}

// ══════════════════════════════════════════════════════════════════════
// REAUTH (Переавторизация)
// ══════════════════════════════════════════════════════════════════════

var _reauthSelected = [];
var _s2tSelected = [];
var _reauthStatusTimer = null;

function loadReauth() {
    // Загружаем custom preset
    API.get('/api/reauth/custom_preset').then(function(d) {
        var c = d.custom || {};
        if (c.api_id) document.getElementById('cp-api-id').value = c.api_id;
        if (c.api_hash) document.getElementById('cp-api-hash').value = c.api_hash;
        if (c.device_model) document.getElementById('cp-device').value = c.device_model;
        if (c.system_version) document.getElementById('cp-sysver').value = c.system_version;
        if (c.app_version) document.getElementById('cp-appver').value = c.app_version;
        if (c.lang_code) document.getElementById('cp-lang').value = c.lang_code;
        if (c.system_lang_code) document.getElementById('cp-syslang').value = c.system_lang_code;
    });
    _startReauthPolling();
}

function _startReauthPolling() {
    if (_reauthStatusTimer) clearInterval(_reauthStatusTimer);
    _reauthStatusTimer = setInterval(_pollReauthStatus, 2000);
    _pollReauthStatus();
}

function _pollReauthStatus() {
    API.get('/api/reauth/status').then(function(d) {
        if (!d || d.error) return;
        var running = d.status === 'running';
        var kind = d.kind || 'reauth';
        var pct = d.total > 0 ? (d.done / d.total) * 100 : 0;
        var statsText = 'OK=' + d.ok + ' FAIL=' + d.fail;
        var progressText = d.done + '/' + d.total;

        // Скрываем все прогресс-блоки
        ['reauth-progress', 't2s-progress', 's2t-progress'].forEach(function(id) {
            var el = document.getElementById(id);
            if (el && kind !== (id === 'reauth-progress' ? 'reauth' : id === 't2s-progress' ? 't2s' : 's2t')) {
                // скрываем если не наш kind И не running
                if (!running && d.done === 0) el.style.display = 'none';
            }
        });

        // Показываем нужный
        var progressId = kind === 't2s' ? 't2s-progress' : kind === 's2t' ? 's2t-progress' : 'reauth-progress';
        var barId = kind === 't2s' ? 't2s-progress-bar' : kind === 's2t' ? 's2t-progress-bar' : 'reauth-progress-bar';
        var txtId = kind === 't2s' ? 't2s-progress-text' : kind === 's2t' ? 's2t-progress-text' : 'reauth-progress-text';
        var statsId = kind === 't2s' ? 't2s-stats' : kind === 's2t' ? 's2t-stats' : 'reauth-stats';

        if (running || d.done > 0) {
            var pBlock = document.getElementById(progressId);
            if (pBlock) pBlock.style.display = '';
            var txt = document.getElementById(txtId);
            var bar = document.getElementById(barId);
            var stats = document.getElementById(statsId);
            if (txt) txt.textContent = progressText;
            if (bar) bar.style.width = pct + '%';
            if (stats) stats.textContent = statsText;
        }

        // Лог задачи — в поле СВОЕЙ вкладки. Раньше он всегда писался в один
        // «reauth-log», а этот элемент из панели удалили: getElementById возвращал
        // null, условие не срабатывало, и лог не показывался НИ У ОДНОЙ из трёх
        // задач. Со стороны это выглядело как «кнопка нажимается, а лог пустой» —
        // хотя задача шла и работала.
        var logId = kind === 't2s' ? 't2s-log' : kind === 's2t' ? 's2t-log' : 'reauth-log';
        var log = document.getElementById(logId);
        if (log && d.log && d.log.length) {
            log.style.display = '';
            var _wasAtBottom = (log.scrollTop + log.clientHeight >= log.scrollHeight - 24);
            log.value = d.log.join('\n');
            // Держим показанным конец: за ним и следят, пока задача идёт. Но если
            // человек отмотал выше — не дёргаем его обратно.
            if (_wasAtBottom) log.scrollTop = log.scrollHeight;
        }

        // Кнопки для reauth
        var btnStart = document.getElementById('btn-reauth-start');
        var btnStop = document.getElementById('btn-reauth-stop');
        if (btnStart && btnStop && kind === 'reauth') {
            btnStart.style.display = running ? 'none' : '';
            btnStop.style.display = running ? '' : 'none';
        }
    });
}

function reauthPresetChanged() {
    // «Свои параметры» — единственный пункт, которому нужен ввод от юзера.
    // Кнопку, открывавшую это окно, когда-то спрятали как дубль пункта списка,
    // и пункт остался тупиком: выбираешь — ничего не происходит.
    var sel = document.getElementById('reauth-preset');
    if (sel && sel.value === 'custom') openReauthCustomModal();
}

function openReauthCustomModal() {
    var m = document.getElementById('reauth-custom-modal');
    if (m) m.style.display = 'flex';
}

function saveReauthCustomPreset() {
    var data = {
        api_id: parseInt(document.getElementById('cp-api-id').value) || 0,
        api_hash: document.getElementById('cp-api-hash').value.trim(),
        device_model: document.getElementById('cp-device').value.trim(),
        system_version: document.getElementById('cp-sysver').value.trim(),
        app_version: document.getElementById('cp-appver').value.trim(),
        lang_code: document.getElementById('cp-lang').value.trim(),
        system_lang_code: document.getElementById('cp-syslang').value.trim(),
    };
    if (!data.api_id || !data.api_hash) {
        showToast('Введите api_id и api_hash', 'warning');
        return;
    }
    API.post('/api/reauth/custom_preset', data).then(function(d) {
        if (d.ok) {
            showToast('Параметры сохранены', 'success');
            document.getElementById('reauth-custom-modal').style.display = 'none';
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    });
}

function openReauthAccPicker(mode) {
    // Пикер переавторизации / session→tdata на едином openAccountPicker —
    // табы-папки + поиск + выбрать-все-в-папке. includeDead:true — мёртвых
    // показываем выбираемыми (реавторизация именно их и оживляет).
    mode = mode || 'reauth';
    var getSel = function() { return mode === 's2t' ? _s2tSelected : _reauthSelected; };
    var setSel = function(arr) { if (mode === 's2t') _s2tSelected = arr; else _reauthSelected = arr; };
    openAccountPicker({
        title: mode === 's2t' ? 'Выбор акков для конвертации' : 'Выбор акков для переавторизации',
        icon: mode === 's2t' ? 'bi-arrow-left-right' : 'bi-shield-lock', mode: 'multi',
        includeDead: true, allowDead: true,
        isSelected: function(p) { return getSel().indexOf(p) !== -1; },
        toggle: function(p, on) {
            var arr = getSel().slice();
            var i = arr.indexOf(p);
            if (on) { if (i === -1) arr.push(p); }
            else if (i !== -1) arr.splice(i, 1);
            setSel(arr);
        },
        onChange: function() { _renderReauthSelection(mode); },
        onDone: function() { _renderReauthSelection(mode); }
    });
}

function _renderReauthSelection(mode) {
    var selected = mode === 's2t' ? _s2tSelected : _reauthSelected;
    var listEl = document.getElementById(mode === 's2t' ? 's2t-acc-list' : 'reauth-acc-list');
    var countEl = document.getElementById(mode === 's2t' ? 's2t-selected-count' : 'reauth-selected-count');
    if (countEl) {
        countEl.textContent = selected.length;
        countEl.style.color = selected.length === 0 ? 'var(--text-muted)' : 'var(--success)';
    }
    if (listEl) listEl.innerHTML = selected.map(function(p) {
        return '<span style="background:var(--surface-info-bg);border:1px solid var(--accent);border-radius:4px;padding:2px 8px;color:var(--accent-light);font-family:monospace;">+' + p + '</span>';
    }).join('');
}

function startReauth() {
    if (!_reauthSelected.length) {
        showToast('Выберите аккаунты', 'warning');
        return;
    }
    var preset = document.getElementById('reauth-preset').value;
    // подхватываем TXT-импорт per-phone параметров.
    var perAccText = '';
    var perAccEl = document.getElementById('reauth-per-acc-text');
    if (perAccEl) perAccText = (perAccEl.value || '').trim();
    var payload = {
        phones: _reauthSelected,
        preset: preset,
        per_acc_text: perAccText,
        new_2fa: document.getElementById('reauth-new-2fa').value,
        close_others: document.getElementById('reauth-close-others').checked,
        delete_dialog: document.getElementById('reauth-delete-dialog').checked,
        continue_on_limit: document.getElementById('reauth-continue-limit').checked,
        keep_original: document.getElementById('reauth-keep-original').checked,
    };
    API.post('/api/reauth/start', payload).then(function(d) {
        if (d.ok) {
            showToast('Запущено: ' + d.total + ' акк.', 'success');
            _pollReauthStatus();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    });
}

function stopReauth() {
    API.post('/api/reauth/stop').then(function(d) {
        if (d.ok) showToast('Остановка...', 'info');
    });
}

function runTdataToSession() {
    // Пустое поле — это НОРМА: и подпись под ним, и сервер обещают папку по
    // умолчанию «Аккаунты/Конвертер tdata → session/». Раньше здесь стоял запрет,
    // то есть интерфейс сам не давал сделать то, что сам же и предлагал.
    var folder = document.getElementById('reauth-t2s-folder').value.trim();
    var payload = {
        folder: folder,
        keep_originals: (document.getElementById('reauth-t2s-keep') || {}).checked === true,
        password_filename: document.getElementById('reauth-t2s-pwd').value,
        depth: parseInt(document.getElementById('reauth-t2s-depth').value) || 4,
        threads: parseInt((document.getElementById('reauth-t2s-threads') || {}).value) || 5,
        // .session получит свой ключ через TG-handshake — tdata останется
        // рабочей. Дефолт ON; снять только если нужен тот же ключ.
        new_session: (document.getElementById('reauth-t2s-new-session') || {}).checked || false,
    };
    API.post('/api/reauth/tdata_to_session', payload).then(function(d) {
        if (d.ok) {
            showToast('Конвертация запущена', 'success');
            _pollReauthStatus();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) {
        // Без этого отказ сервера (400/503) пропадал молча: ни тоста, ни лога —
        // кнопка будто нажата впустую.
        showToast('Не удалось запустить: ' + ((e && e.message) || 'сервер не ответил'), 'error');
    });
}

function runSessionToTdata() {
    if (!_s2tSelected.length) { showToast('Выберите аккаунты', 'warning'); return; }
    var payload = {
        phones: _s2tSelected,
        pack_count: parseInt(document.getElementById('s2t-pack').value) || 1,
        offline: document.getElementById('s2t-offline').checked,
        save_2fa: document.getElementById('s2t-save-2fa').checked,
        copy_original: document.getElementById('s2t-copy-orig').checked,
        threads: parseInt((document.getElementById('s2t-threads') || {}).value) || 5,
        new_session: (document.getElementById('s2t-new-session') || {}).checked || false,
        // после конверта переместить акк в спец-папку. Шлём явно, чтоб UI
        // checkbox рулил (бэк-дефолт True если поле отсутствует).
        move_to_archive: (document.getElementById('s2t-move-to-archive') || {}).checked || false,
    };
    API.post('/api/reauth/session_to_tdata', payload).then(function(d) {
        if (d.ok) {
            showToast('Конвертация запущена', 'success');
            _pollReauthStatus();
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function(e) {
        showToast('Не удалось запустить: ' + ((e && e.message) || 'сервер не ответил'), 'error');
    });
}


// ══════════════════════════════════════════════════════════════════
//  Имитация активности — фоновое чтение / просмотр профилей
// ══════════════════════════════════════════════════════════════════

function startImitation() {
    var phones = getSelectorPhones('acc-selector-imitation');
    if (!phones.length) { showToast('Не выбраны аккаунты', 'warning'); return; }
    var payload = {
        phones: phones,
        interval_min: Math.max(1, Math.round(parseInt(document.getElementById('imit-interval-sec').value || '900', 10) / 60)),
        interval_max: Math.max(2, Math.round(parseInt(document.getElementById('imit-interval-sec-max').value || '2700', 10) / 60)),
        actions_per_round: parseInt(document.getElementById('imit-actions-per-round').value || '3', 10),
        do_read: document.getElementById('imit-do-read').checked,
        do_view_profile: document.getElementById('imit-do-view-profile').checked,
        do_view_photo: document.getElementById('imit-do-view-photo').checked,
    };
    API.post('/api/imitation/start', payload).then(function(d) {
        if (d && d.ok) {
            showToast('Имитация запущена на ' + d.phones + ' акков', 'success');
            document.getElementById('btn-imit-start').style.display = 'none';
            document.getElementById('btn-imit-stop').style.display = '';
            loadImitationStatus();
        } else {
            showToast((d && d.error) || 'Ошибка запуска', 'error');
        }
    }).catch(function(e) { showToast('Ошибка: ' + _errText(e), 'error'); });
}

function stopImitation() {
    API.post('/api/imitation/stop', {}).then(function(d) {
        if (d && d.ok) {
            showToast('Имитация остановлена', 'success');
            document.getElementById('btn-imit-start').style.display = '';
            document.getElementById('btn-imit-stop').style.display = 'none';
            loadImitationStatus();
        }
    }).catch(function() {});
}

function loadImitationStatus() {
    API.get('/api/imitation/status').then(function(d) {
        var lbl = document.getElementById('imit-status-label');
        var counters = document.getElementById('imit-counters');
        var log = document.getElementById('imit-log');
        if (!lbl) return;
        if (d.status === 'running') {
            lbl.innerHTML = '<i class="bi bi-circle-fill" style="color:var(--success);"></i> работает (' + d.phones + ' акков)';
            document.getElementById('btn-imit-start').style.display = 'none';
            document.getElementById('btn-imit-stop').style.display = '';
        } else {
            lbl.innerHTML = '<i class="bi bi-circle" style="color:var(--text-muted-strong);"></i> остановлено';
            document.getElementById('btn-imit-start').style.display = '';
            document.getElementById('btn-imit-stop').style.display = 'none';
        }
        if (d.counts) {
            counters.style.display = '';
            counters.innerHTML =
                '<i class="bi bi-eye"></i> Прочитано: <b>' + (d.counts.reads || 0) + '</b> &nbsp;' +
                '<i class="bi bi-person"></i> Профилей: <b>' + (d.counts.profiles || 0) + '</b> &nbsp;' +
                '<i class="bi bi-image"></i> Фото: <b>' + (d.counts.photos || 0) + '</b> &nbsp;' +
                '<i class="bi bi-arrow-repeat"></i> Раундов: <b>' + (d.counts.rounds || 0) + '</b>';
        }
        if (d.log && d.log.length) {
            log.style.display = '';
            log.innerHTML = d.log.slice().reverse().map(escHtml).join('<br>');
        }
    }).catch(function() {});
}


// ══════════════════════════════════════════════════════════════════
//  Массовая привязка прокси
// ══════════════════════════════════════════════════════════════════



// Сброс прокси с ОДНОГО акка прямо из строки менеджера.
function resetProxyRow(phone, event) {
    if (event) event.stopPropagation();
    API.post('/api/proxy/unbind_one', { phone: phone }).then(function(d) {
        if (d && d.ok) {
            showToast('Прокси сброшен', 'success');
            if (typeof loadAccounts === 'function') loadAccounts();
        } else {
            showToast((d && d.error) || 'Не удалось', 'error');
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// Массовый сброс прокси у отмеченных акков.
async function resetProxySelected() {
    var phones = _getCheckedAccPhones();
    if (!phones.length) { showToast('Отметь аккаунты галками в таблице', 'warning'); return; }
    if (!(await appConfirm('Снять прокси с ' + phones.length + ' отмеченных аккаунтов? Они вернутся к раздаче по номеру.'))) return;
    API.post('/api/proxy/unbind_selected', { phones: phones }).then(function(d) {
        if (d && d.ok) {
            showToast('Прокси сброшен у ' + (d.unbound || 0) + ' из ' + (d.total || phones.length), 'success');
            if (typeof loadAccounts === 'function') loadAccounts();
        } else {
            showToast((d && d.error) || 'Не удалось', 'error');
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// ── Смена 2FA (облачного пароля) у отмеченных акков ──────────────────────────
// phone задан — 2FA для ЭТОГО аккаунта (кнопка в строке таблицы, рядом с
// открытием веб-ТГ). Без phone — по отмеченным галками (кнопка в верхней панели,
// массовая смена: там же живёт «свой случайный пароль каждому»).
function open2faModal(phone, ev) {
    if (ev && ev.stopPropagation) ev.stopPropagation();
    var phones = phone ? [phone] : _getCheckedAccPhones();
    if (!phones.length) { showToast('Отметь аккаунты галками в таблице', 'warning'); return; }
    var _title = phone ? ('2FA для +' + phone) : ('Смена 2FA — отмечено ' + phones.length + ' акк.');
    var prev = document.getElementById('app-modal-bd'); if (prev) prev.remove();
    var bd = document.createElement('div');
    bd.className = 'app-modal-backdrop'; bd.id = 'app-modal-bd';
    bd.innerHTML =
        '<div class="app-modal-box" onclick="event.stopPropagation()" style="max-width:460px;">' +
            '<div class="app-modal-title"><i class="bi bi-shield-lock"></i><span>' + escapeHtml(_title) + '</span></div>' +
            '<div class="app-modal-body">' +
                '<div style="margin-bottom:4px;font-size:0.78rem;">Новый пароль 2FA:</div>' +
                '<input type="text" id="tfa-pass" class="form-control" placeholder="новый облачный пароль" autocomplete="off" style="width:100%;">' +
                '<label class="ad-check" style="margin-top:10px;display:flex;gap:8px;align-items:center;"><input type="checkbox" id="tfa-random" onchange="_tfaSyncModal()"> <span>Свой случайный пароль каждому аккаунту</span></label>' +
                '<label class="ad-check" style="margin-top:6px;display:flex;gap:8px;align-items:center;"><input type="checkbox" id="tfa-remove" onchange="_tfaSyncModal()"> <span>Снять 2FA (не ставить новый)</span></label>' +
                '<div style="font-size:0.67rem;color:var(--text-muted);margin-top:8px;line-height:1.5;">Где 2FA нет — поставится новый. Где есть — сменится (текущий берётся из «twoFA» в .json пары; нет его — акк пропустится, чтобы не залочить). Новый пароль запишется в .json.<br><b>Случайный</b>: каждому акку свой пароль из 16 символов — если утечёт один .json, остальные акки не открыть. Пароли видно в логе ниже и в .json.</div>' +
                '<div id="tfa-progress" style="margin-top:12px;font-size:0.72rem;color:var(--text-muted);"></div>' +
            '</div>' +
            '<div class="app-modal-actions">' +
                '<button class="app-modal-btn" onclick="document.getElementById(\'app-modal-bd\').remove()">Закрыть</button>' +
                '<button class="app-modal-btn primary" id="tfa-apply-btn" onclick="start2faBulk()">Применить</button>' +
            '</div>' +
        '</div>';
    bd.onclick = function(e){ if(e.target===bd) bd.remove(); };
    document.body.appendChild(bd);
    window._tfaPhones = phones;
    setTimeout(function(){ var p=document.getElementById('tfa-pass'); if(p) p.focus(); }, 50);
}

function _tfaSyncModal() {
    // Ручной пароль не нужен ни при «снять», ни при «случайный» — гасим поле,
    // чтобы не выглядело обязательным. Галки взаимоисключающие.
    var rnd = document.getElementById('tfa-random');
    var rem = document.getElementById('tfa-remove');
    var p = document.getElementById('tfa-pass');
    if (rnd && rem) {
        if (rem.checked) rnd.checked = false;
        else if (rnd.checked) rem.checked = false;
    }
    var off = !!((rnd && rnd.checked) || (rem && rem.checked));
    if (p) { p.disabled = off; p.style.opacity = off ? 0.4 : 1; }
}

function start2faBulk() {
    var phones = window._tfaPhones || [];
    if (!phones.length) { showToast('Нет отмеченных', 'warning'); return; }
    var remove = !!(document.getElementById('tfa-remove') || {}).checked;
    var randomize = !!(document.getElementById('tfa-random') || {}).checked;
    var pass = ((document.getElementById('tfa-pass') || {}).value || '').trim();
    if (!remove && !randomize && !pass) {
        showToast('Укажи пароль, либо отметь «случайный» или «снять 2FA»', 'warning'); return;
    }
    var btn = document.getElementById('tfa-apply-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Идёт...'; }
    var prog = document.getElementById('tfa-progress'); if (prog) prog.innerHTML = 'Запуск...';
    API.post('/api/accounts/set_2fa_bulk', { phones: phones, new_password: pass, remove: remove, randomize: randomize }).then(function(d){
        if (!d || !d.ok) { showToast((d && d.error) || 'Не удалось', 'error'); if(btn){btn.disabled=false;btn.textContent='Применить';} return; }
        _poll2fa();
    }).catch(function(){ showToast('Ошибка', 'error'); if(btn){btn.disabled=false;btn.textContent='Применить';} });
}

function _poll2fa() {
    API.get('/api/accounts/set_2fa_status').then(function(s){
        var prog = document.getElementById('tfa-progress');
        if (prog) {
            var logs = (s.logs || []).slice(-12).map(function(x){ return escHtml(x); }).join('<br>');
            prog.innerHTML = '<b>' + (s.done||0) + '/' + (s.total||0) + '</b> · ✓ ' + (s.ok||0) + ' / ✗ ' + (s.fail||0) +
                '<div style="margin-top:6px;font-family:monospace;font-size:0.66rem;max-height:160px;overflow:auto;background:var(--bg-input);padding:6px 8px;border-radius:4px;">' + logs + '</div>';
        }
        if (s.running) { setTimeout(_poll2fa, 1200); return; }
        var btn = document.getElementById('tfa-apply-btn'); if (btn) { btn.disabled = false; btn.textContent = 'Применить'; }
        showToast('2FA: ✓ ' + (s.ok||0) + ' / ✗ ' + (s.fail||0), (s.fail ? 'warning' : 'success'));
        if (typeof loadAccounts === 'function') loadAccounts();
    }).catch(function(){ setTimeout(_poll2fa, 2000); });
}


// loadSchedule/saveSchedule удалены вместе с UI «Расписания работы»


// ══════════════════════════════════════════════════════════════════
//  TG-бот уведомления
// ══════════════════════════════════════════════════════════════════

function loadTgBot() {
    API.get('/api/tg_bot/get').then(function(d) {
        var b = (d && d.tg_bot) || {};
        var tk = document.getElementById('tg-bot-token');
        var ch = document.getElementById('tg-bot-chat-id');
        if (tk) tk.value = b.token || '';
        if (ch) ch.value = b.chat_id || '';
        var ids = ['notify_spamblock', 'notify_task_done', 'notify_error'];
        ids.forEach(function(k) {
            var el = document.getElementById('tg-' + k.replace('_', '-'));
            if (el) el.checked = !!b[k];
        });
        var st = document.getElementById('tg-bot-status');
        if (st) {
            if (b.token && b.chat_id) {
                st.innerHTML = '<i class="bi bi-check-circle-fill" style="color:var(--success);"></i> настроен';
            } else {
                st.innerHTML = '<i class="bi bi-circle" style="color:var(--text-muted-strong);"></i> не настроен';
            }
        }
    }).catch(function() {});
}

function saveTgBot() {
    var payload = {
        token: document.getElementById('tg-bot-token').value.trim(),
        chat_id: document.getElementById('tg-bot-chat-id').value.trim(),
        notify_spamblock: document.getElementById('tg-notify-spamblock').checked,
        notify_task_done: document.getElementById('tg-notify-task-done').checked,
        notify_error: document.getElementById('tg-notify-error').checked,
    };
    API.post('/api/tg_bot/save', payload).then(function(d) {
        if (d && d.ok) {
            showToast('Настройки бота сохранены', 'success');
            loadTgBot();
            // Блок управления смотрит на те же поля — обновляем и его, иначе
            // жёлтое «сначала заполни ключ» висело при зелёном «настроен».
            if (typeof loadBotControl === 'function') loadBotControl();
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

// ── Управление софтом из бота ───────────────────────────────────────────────
// Список модулей приходит с сервера: он один и тот же для бота и для этой
// формы, иначе галочка была бы в панели, а бот про неё не знал.
var _botControl = {enabled: false, modules: {}, catalog: [], bot_ready: false};

function loadBotControl() {
    API.get('/api/bot/control').then(function(d) {
        if (!d) return;
        _botControl = d;
        var en = document.getElementById('bot-control-enabled');
        if (en) en.checked = !!d.enabled;
        renderBotControl();
    }).catch(function() {});
}

function renderBotControl() {
    var box = document.getElementById('bot-control-modules');
    var en = document.getElementById('bot-control-enabled');
    if (!box) return;
    var on = en && en.checked;
    box.style.display = on ? '' : 'none';
    if (!on) { updateBotControlStatus(); return; }
    box.innerHTML = (_botControl.catalog || []).map(function(m) {
        var checked = (_botControl.modules || {})[m.key] ? ' checked' : '';
        return '<label class="ad-check"><input type="checkbox" data-bot-mod="' + m.key + '"' + checked +
               '> <span>' + escHtml(m.label) + ' <span class="text-muted">— ' + escHtml(m.hint) + '</span></span></label>';
    }).join('');
    updateBotControlStatus();
}

function updateBotControlStatus() {
    var st = document.getElementById('bot-control-status');
    if (!st) return;
    var en = document.getElementById('bot-control-enabled');
    if (en && en.checked && !_botControl.bot_ready) {
        // Токен и чат бот берёт из блока уведомлений — без них управлять нечем.
        st.innerHTML = '<i class="bi bi-exclamation-triangle-fill" style="color:var(--warning);"></i> сначала заполни ключ бота и Chat ID выше';
    } else if (en && en.checked) {
        st.innerHTML = '<i class="bi bi-check-circle-fill" style="color:var(--success);"></i> бот управляет отмеченным';
    } else {
        st.innerHTML = '<i class="bi bi-circle" style="color:var(--text-muted-strong);"></i> выключено';
    }
}

function saveBotControl() {
    var mods = {};
    Array.prototype.forEach.call(document.querySelectorAll('[data-bot-mod]'), function(el) {
        mods[el.getAttribute('data-bot-mod')] = el.checked;
    });
    var en = document.getElementById('bot-control-enabled');
    API.post('/api/bot/control', {enabled: !!(en && en.checked), modules: mods}).then(function(d) {
        if (d && d.ok) {
            showToast('Сохранено', 'success');
            loadBotControl();
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function testTgBot() {
    showToast('Отправляю тест...', 'info');
    API.post('/api/tg_bot/test', {}).then(function(d) {
        if (d && d.ok) {
            showToast('Тест отправлен — проверь Telegram!', 'success');
        } else {
            // Причину присылает сервер — она называет, что именно не так.
            showToast((d && d.error) || 'Не получилось отправить тест.', 'error');
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}


// ══════════════════════════════════════════════════════════════════
//  Массовое заполнение профилей из материалов (bulk fill)
// ══════════════════════════════════════════════════════════════════

var _bfPollTimer = null;

// ── Живая лента автоответчика ─────────────────────────────────────────
// Владелец 12.08.2026: «хочу видеть, что тебе на АО пишется — кому, что и какой
// номер». Дописываем строки по мере событий, а не перерисовываем список: если
// человек отмотал наверх читать старое, лента не должна дёргать его вниз.
var _arFeedTimer = null;
var _arFeedLastId = 0;
var _arFeedLabels = {};

function _arFeedTime(ts) {
    try {
        var d = new Date(ts * 1000);
        return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) +
               ':' + ('0' + d.getSeconds()).slice(-2);
    } catch (_) { return ''; }
}

function _arFeedRow(e) {
    var color = { replied: '#34d399', skipped: 'var(--text-muted)',
                  paused: '#fbbf24', error: '#ef4444' }[e.kind] || 'var(--text-muted)';
    var label = (_arFeedLabels && _arFeedLabels[e.kind]) || e.kind;
    var html = '<div style="padding:6px 4px;border-bottom:1px solid var(--border);font-size:0.72rem;line-height:1.55;">' +
        '<span style="color:var(--text-muted);">' + _arFeedTime(e.ts) + '</span> ' +
        '<span style="color:' + color + ';font-weight:600;">' + escHtml(label) + '</span> ' +
        '<span style="color:var(--accent-light);">' + escHtml(e.phone) + '</span>' +
        ' <span style="color:var(--text-muted);">←</span> ' +
        '<b>' + escHtml(e.who) + '</b>';
    if (e.incoming) {
        html += '<div style="color:#b8b8c8;margin-top:2px;">💬 ' + escHtml(e.incoming) + '</div>';
    }
    if (e.reply) {
        html += '<div style="color:#34d399;margin-top:2px;">↳ ' + escHtml(e.reply) + '</div>';
    }
    if (e.note) {
        html += '<div style="color:#fbbf24;margin-top:2px;">' + escHtml(e.note) + '</div>';
    }
    return html + '</div>';
}

function arFeedTick() {
    API.get('/api/autoresponder/feed?since=' + _arFeedLastId).then(function(r) {
        if (!r || !r.ok) return;
        if (r.labels) _arFeedLabels = r.labels;
        var box = document.getElementById('ar-feed');
        var state = document.getElementById('ar-feed-state');
        if (!box) return;
        var events = r.events || [];
        if (events.length) {
            var empty = document.getElementById('ar-feed-empty');
            if (empty) empty.remove();
            // Дописываем сверху: свежее — первым, как в любом живом потоке.
            var chunk = events.slice().reverse().map(_arFeedRow).join('');
            box.insertAdjacentHTML('afterbegin', chunk);
            _arFeedLastId = r.last_id || _arFeedLastId;
            // Не даём ленте расти бесконечно в DOM.
            while (box.children.length > 300) box.removeChild(box.lastChild);
        }
        if (state) {
            state.textContent = r.total
                ? ('Событий в ленте: ' + r.total)
                : 'Слежу за перепиской…';
        }
    }).catch(function() {});
}

function arFeedStart() {
    arFeedTick();
    if (_arFeedTimer) return;
    _arFeedTimer = setInterval(arFeedTick, 3000);
}

function arFeedStop() {
    if (_arFeedTimer) { clearInterval(_arFeedTimer); _arFeedTimer = null; }
}

function arFeedClear() {
    API.post('/api/autoresponder/feed/clear', {}).then(function() {
        var box = document.getElementById('ar-feed');
        if (box) {
            box.innerHTML = '<div class="text-xs text-muted" id="ar-feed-empty">' +
                'Пока тихо. Как только кто-то напишет в личку аккаунту с включённым ' +
                'автоответчиком — строка появится здесь.</div>';
        }
        _arFeedLastId = 0;
        var state = document.getElementById('ar-feed-state');
        if (state) state.textContent = 'Слежу за перепиской…';
    });
}

function arLoadLeads() {
    var cEl = document.getElementById('ar-leads-count');
    var pEl = document.getElementById('ar-leads-preview');
    API.get('/api/autoresponder/leads').then(function(r) {
        if (!r || !r.ok) return;
        if (cEl) cEl.textContent = r.count;
        if (pEl) {
            if (!r.count) { pEl.innerHTML = 'Пока пусто — лиды появятся, когда автоответчик кому-то ответит.'; return; }
            var rows = (r.leads || []).slice(0, 15).map(function(x) {
                var who = x.username ? ('@' + x.username) : (x.name || ('id' + x.sender_id));
                return '<div><i class="bi bi-person"></i> ' + who + (x.name && x.username ? ' <span style="opacity:.6">(' + x.name + ')</span>' : '') + '</div>';
            }).join('');
            pEl.innerHTML = rows + (r.count > 15 ? '<div style="opacity:.6;margin-top:4px">…и ещё ' + (r.count - 15) + '. Выгрузи — там все.</div>' : '');
        }
    }).catch(function(){});
    lcLoad();
}

function arExportLeads() {
    API.post('/api/autoresponder/leads/export', {}).then(function(r) {
        if (r && r.ok) showToast('Выгружено лидов: ' + r.count + ' → Экспорт/', 'success');
        else showToast((r && r.error) || 'Не удалось выгрузить', 'warning');
    }).catch(function(){ showToast('Ошибка выгрузки', 'warning'); });
}

function lcLoad() {
    var cEl = document.getElementById('lc-count');
    API.get('/api/live_chats').then(function(r) {
        if (r && r.ok && cEl) cEl.textContent = r.count;
    }).catch(function(){});
}

function lcOpen() {
    API.post('/api/live_chats/open', {}).then(function(r) {
        if (r && r.ok) showToast('Живых чатов: ' + r.count + ' → Экспорт/Живые_чаты.txt', 'success');
        else showToast((r && r.error) || 'Живых чатов пока нет', 'warning');
    }).catch(function(){ showToast('Ошибка', 'warning'); });
}

function bfInitMaterialsPath() {
    // Подставляем дефолтный путь к папке шаблонов «Заполнение профилей», чтобы
    // клиент не искал куда класть материалы. Не перетираем, если уже что-то ввёл.
    var el = document.getElementById('bf-materials-path');
    if (!el || el.value.trim()) return;
    API.get('/api/profile/templates_path').then(function(r) {
        if (r && r.ok && r.path && el && !el.value.trim()) el.value = r.path;
    }).catch(function(){});
}

function bfScanMaterials() {
    var folder = ((document.getElementById('bf-materials-path') || {}).value || '').trim();
    if (!folder) { showToast('Укажи путь к папке с материалами', 'warning'); return; }
    var rs = document.getElementById('bf-scan-result');
    if (rs) rs.innerHTML = '<i class="bi bi-arrow-clockwise" style="animation:spin 1s linear infinite;"></i> Сканирую...';
    API.post('/api/profile/bulk_scan', { path: folder }).then(function(d) {
        if (d.error) {
            rs.innerHTML = '<span style="color:var(--danger);">' + escHtml(d.error) + '</span>';
            return;
        }
        var r = d.result || {};
        var lines = [];
        ['female', 'male', 'general'].forEach(function(k) {
            if (!r[k]) return;
            var label = k === 'female' ? 'Женские' : k === 'male' ? 'Мужские' : 'Общая';
            var inf = r[k];
            var bits = [];
            if (inf.names) bits.push('Имён: ' + inf.names);
            if (inf.surnames) bits.push('Фамилий: ' + inf.surnames);
            if (inf.usernames) bits.push('Юзернеймов: ' + inf.usernames);
            if (inf.bios) bits.push('Био: ' + inf.bios);
            if (inf.avatars) bits.push('<b style="color:var(--success);">Аватарок: ' + inf.avatars + '</b>');
            if (inf.stories) bits.push('Сторис: ' + inf.stories);
            if (!bits.length) bits.push('<i style="color:var(--text-muted);">пусто</i>');
            lines.push('<div><b>' + label + ':</b> ' + bits.join(' · ') + '</div>');
        });
        rs.innerHTML = lines.length ? lines.join('') : '<span style="color:var(--warning);">Подпапок не найдено. Проверь структуру.</span>';
    }).catch(function(e) {
        // Показываем РЕАЛЬНУЮ ошибку с сервера (e.message = d.error при 4xx),
        // а не generic «Не удалось проверить».
        var msg = (e && e.message) ? e.message : 'Не удалось проверить';
        rs.innerHTML = '<span style="color:var(--danger);">' + escHtml(msg) + '</span>';
    });
}

function bfOpenMaterials() {
    var folder = ((document.getElementById('bf-materials-path') || {}).value || '').trim();
    if (!folder) { showToast('Укажи путь', 'warning'); return; }
    API.post('/api/profile/bulk_open', { path: folder }).catch(function(){});
}

function bfStart() {
    var folder = ((document.getElementById('bf-materials-path') || {}).value || '').trim();
    if (!folder) { showToast('Укажи путь к папке', 'warning'); return; }

    // Через helper getSelectorPhones — режим «Все» в селекторе не помечает чипы
    // .is-selected, прямое чтение дало бы пустой массив.
    var phones = getSelectorPhones('acc-selector-bulk-fill');
    if (!phones.length) { showToast('Выбери аккаунты', 'warning'); return; }

    var payload = {
        path: folder,
        phones: phones,
        do_name: !!(document.getElementById('bf-do-name') || {}).checked,
        do_username: !!(document.getElementById('bf-do-username') || {}).checked,
        do_bio: !!(document.getElementById('bf-do-bio') || {}).checked,
        do_avatar: !!(document.getElementById('bf-do-avatar') || {}).checked,
        do_stories: !!(document.getElementById('bf-do-stories') || {}).checked,
        avatar_once: !!(document.getElementById('bf-avatar-once') || {}).checked,
        delete_old: !!(document.getElementById('bf-delete-old') || {}).checked,
        move_processed: !!(document.getElementById('bf-move-processed') || {}).checked,
        target_folder: ((document.getElementById('bf-target-folder') || {}).value || '').trim(),
        // потоки — сколько акков обрабатывается одновременно
        threads: parseInt((document.getElementById('bf-threads') || {}).value) || 1,
    };

    showToast('Запускаю заполнение...', 'info');
    API.post('/api/profile/bulk_start', payload).then(function(d) {
        if (d.ok) {
            document.getElementById('btn-bf-start').style.display = 'none';
            document.getElementById('btn-bf-stop').style.display = 'inline-flex';
            document.getElementById('bf-progress').style.display = 'block';
            (document.getElementById('bf-log') || {style:{}}).style.display = 'block';
            if (_bfPollTimer) clearInterval(_bfPollTimer);
            _bfPollTimer = setInterval(bfPollStatus, 1500);
        } else {
            showToast(d.error || 'Не удалось запустить', 'error');
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function bfStop() {
    API.post('/api/profile/bulk_stop', {}).then(function() { showToast('Останавливаю...', 'info'); });
}

function bfPollStatus() {
    API.get('/api/profile/bulk_status').then(function(d) {
        if (!d) return;
        var bar = document.getElementById('bf-progress-bar');
        var txt = document.getElementById('bf-progress-text');
        var log = document.getElementById('bf-log');
        var lbl = document.getElementById('bf-status-label');
        var total = d.total || 0, done = d.done || 0;
        if (bar) bar.style.width = (total > 0 ? Math.round(done/total*100) : 0) + '%';
        if (txt) txt.innerHTML = '<b>' + done + '</b> / ' + total
            + '  ·  ✓ <span style="color:var(--success);">' + (d.ok || 0) + '</span>'
            + '  ·  ✗ <span style="color:var(--danger);">' + (d.fail || 0) + '</span>';
        if (log && d.logs) {
            log.innerHTML = (d.logs || []).map(function(l){
                return '<div style="color:'+verdictColor(l)+';">'+escHtml(l)+'</div>';
            }).join('');
            log.scrollTop = log.scrollHeight;
        }
        if (lbl) {
            if (d.running) lbl.textContent = '⏳ работа...';
            else if (d.status === 'finished') lbl.textContent = '✓ завершено';
            else if (d.status === 'stopped') lbl.textContent = 'остановлено';
        }
        if (!d.running) {
            if (_bfPollTimer) { clearInterval(_bfPollTimer); _bfPollTimer = null; }
            document.getElementById('btn-bf-start').style.display = 'inline-flex';
            document.getElementById('btn-bf-stop').style.display = 'none';
        }
    }).catch(function(){});
}


// ══════════════════════════════════════════════════════════════════
//  Чекер аккаунтов
// ══════════════════════════════════════════════════════════════════

var _ckPollTimer = null;
var _ckSpamTexts = [];

function loadCheckerConfig() {
    API.get('/api/checker/config').then(function(d) {
        var c = (d && d.checker) || {};
        var set = function(id, val) { var el = document.getElementById(id); if (el) { if (el.type === 'checkbox') el.checked = !!val; else el.value = val || ''; } };
        set('ck-rucaptcha', c.rucaptcha_key);
        set('ck-deepseek', c.deepseek_key);
        set('ck-freeze-prompt', c.freeze_prompt);
        set('ck-check-spam', c.check_spamblock !== false);
        set('ck-submit-spam-appeal', c.submit_spamblock_appeal);
        set('ck-submit-freeze-appeal', c.submit_freeze_appeal);
        set('ck-use-hacked', c.use_hacked_reason !== false);
        set('ck-use-mistake', c.use_mistake_reason !== false);
        set('ck-sort-folders', c.sort_by_folders !== false);
        set('ck-rename-files', c.rename_files);
        set('ck-delete-dialog', c.delete_spambot_dialog);
        set('ck-prepare', c.prepare_accounts);
        set('ck-prepare-leave', c.prepare_leave_nonwritable !== false);
        set('ck-prepare-bot', c.prepare_send_bot);
        set('ck-prepare-load', c.prepare_on_load);
        set('ck-folder-alive', c.folder_alive || 'Аккаунты/Живые');
        set('ck-folder-temp', c.folder_temp_spam || 'Аккаунты/Спамблок/Временный');
        set('ck-folder-eternal', c.folder_eternal_spam || 'Аккаунты/Спамблок/Вечный');
        set('ck-folder-frozen', c.folder_frozen || 'Аккаунты/Замороженные');
        set('ck-max-parallel', c.max_parallel || 0);
        _ckSpamTexts = c.spamblock_texts || [];
    }).catch(function(){});
}

function saveCheckerConfig() {
    var get = function(id) { var el = document.getElementById(id); if (!el) return ''; return el.type === 'checkbox' ? el.checked : el.value; };
    var payload = {
        rucaptcha_key: get('ck-rucaptcha'),
        deepseek_key: get('ck-deepseek'),
        freeze_prompt: get('ck-freeze-prompt'),
        check_spamblock: get('ck-check-spam'),
        submit_spamblock_appeal: get('ck-submit-spam-appeal'),
        submit_freeze_appeal: get('ck-submit-freeze-appeal'),
        use_hacked_reason: get('ck-use-hacked'),
        use_mistake_reason: get('ck-use-mistake'),
        sort_by_folders: get('ck-sort-folders'),
        rename_files: get('ck-rename-files'),
        delete_spambot_dialog: get('ck-delete-dialog'),
        prepare_accounts: get('ck-prepare'),
        prepare_leave_nonwritable: get('ck-prepare-leave'),
        prepare_send_bot: get('ck-prepare-bot'),
        prepare_on_load: get('ck-prepare-load'),
        folder_alive: get('ck-folder-alive'),
        folder_temp_spam: get('ck-folder-temp'),
        folder_eternal_spam: get('ck-folder-eternal'),
        folder_frozen: get('ck-folder-frozen'),
        // 0 = все сразу, как и написано под полем. Прежний зажим до 10 молча
        // превращал и 0, и 50 в пятёрку: поле показывало одно, софт делал другое.
        max_parallel: Math.max(0, Math.min(200, parseInt(get('ck-max-parallel'), 10) || 0)),
        spamblock_texts: _ckSpamTexts,
    };
    API.post('/api/checker/config', payload).then(function(d) {
        if (d && d.ok) showToast('Настройки сохранены', 'success');
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function ckOpenSpamTexts() {
    var existing = (_ckSpamTexts || []).join('\n');
    var overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px;';
    overlay.innerHTML =
        '<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:7px;padding:20px;width:780px;max-width:96vw;max-height:90vh;display:flex;flex-direction:column;">' +
        '<div style="display:flex;align-items:center;margin-bottom:10px;"><h3 style="margin:0;font-size:1rem;color:var(--text-primary);">Тексты для снятия спамблока</h3>' +
        '<button onclick="this.closest(\'.ck-modal\').remove()" style="margin-left:auto;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.2rem;">×</button></div>' +
        '<div class="info-box" style="margin-bottom:10px;font-size:0.7rem;">Запиши каждый текст с новой строчки. Эти тексты будут отправляться в @SpamBot, чтобы попросить снять спамблок с аккаунта.</div>' +
        '<textarea id="ck-spam-texts-area" style="flex:1;min-height:300px;background:var(--bg-input);border:1px solid var(--border);border-radius:5px;color:var(--text-primary);padding:10px;font-family:monospace;font-size:0.78rem;resize:vertical;">' + escHtml(existing) + '</textarea>' +
        '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px;">' +
        '<span style="font-size:0.72rem;color:var(--text-muted);" id="ck-spam-count">Строк: ' + _ckSpamTexts.length + '</span>' +
        '<button class="btn btn-accent" onclick="ckSaveSpamTexts(this)"><i class="bi bi-save"></i> Сохранить</button>' +
        '</div></div>';
    overlay.className = 'ck-modal';
    overlay.onclick = function(e){ if (e.target === overlay) overlay.remove(); };
    document.body.appendChild(overlay);
    var ta = document.getElementById('ck-spam-texts-area');
    if (ta) {
        ta.addEventListener('input', function() {
            var lines = ta.value.split('\n').filter(function(s){return s.trim();});
            var c = document.getElementById('ck-spam-count');
            if (c) c.textContent = 'Строк: ' + lines.length;
        });
    }
}

function ckSaveSpamTexts(btn) {
    var ta = document.getElementById('ck-spam-texts-area');
    if (!ta) return;
    _ckSpamTexts = ta.value.split('\n').map(function(s){return s.trim();}).filter(Boolean);
    btn.closest('.ck-modal').remove();
    showToast('Сохранено: ' + _ckSpamTexts.length + ' строк', 'success');
}

function startChecker() {
    // Через helper getSelectorPhones — корректно обрабатывает режим «Все»
    // (чипы не помечены .is-selected, прямое чтение дало бы пустой массив).
    var phones = getSelectorPhones('acc-selector-checker');
    if (!phones.length) { showToast('Выбери аккаунты', 'warning'); return; }

    // Сначала сохраняем настройки чтобы runtime их видел
    saveCheckerConfig();
    setTimeout(function() {
        API.post('/api/checker/start', { phones: phones }).then(function(d) {
            if (d.ok) {
                document.getElementById('btn-ck-start').style.display = 'none';
                document.getElementById('btn-ck-stop').style.display = 'inline-flex';
                document.getElementById('ck-progress').style.display = 'block';
                (document.getElementById('ck-log') || {style:{}}).style.display = 'block';
                if (_ckPollTimer) clearInterval(_ckPollTimer);
                _ckPollTimer = setInterval(pollCheckerStatus, 2000);
            } else {
                showToast(d.error || 'Не удалось запустить', 'error');
            }
        }).catch(function() { showToast('Ошибка', 'error'); });
    }, 300);
}

function stopChecker() {
    API.post('/api/checker/stop', {}).then(function() { showToast('Останавливаю...', 'info'); });
}

function pollCheckerStatus() {
    API.get('/api/checker/status').then(function(d) {
        if (!d) return;
        var bar = document.getElementById('ck-progress-bar');
        var txt = document.getElementById('ck-progress-text');
        var cnt = document.getElementById('ck-counters');
        var log = document.getElementById('ck-log');
        var lbl = document.getElementById('ck-status-label');
        var total = d.total || 0, done = d.done || 0;
        if (bar) bar.style.width = (total > 0 ? Math.round(done/total*100) : 0) + '%';
        if (txt) txt.innerHTML = '<b>' + done + '</b> / ' + total;
        if (cnt) cnt.innerHTML =
            '<span><span style="color:var(--success);">✓ Живых:</span> <b>' + (d.alive||0) + '</b></span>' +
            '<span><span style="color:var(--warning);">⏳ Врем.спам:</span> <b>' + (d.temp_spam||0) + '</b></span>' +
            '<span><span style="color:var(--danger);">✗ Вечн.спам:</span> <b>' + (d.eternal_spam||0) + '</b></span>' +
            '<span><span style="color:var(--info);"><i class="bi bi-snow"></i> Замор.:</span> <b>' + (d.frozen||0) + '</b></span>' +
            '<span><span style="color:var(--text-muted);">⚠ Ошибок:</span> <b>' + (d.error||0) + '</b></span>';
        if (log && d.logs) {
            log.innerHTML = (d.logs || []).map(function(l){
                return '<div style="color:'+verdictColor(l)+';">'+escHtml(l)+'</div>';
            }).join('');
            log.scrollTop = log.scrollHeight;
        }
        if (lbl) {
            if (d.running) lbl.textContent = '⏳ работа...';
            else if (d.status === 'finished') lbl.textContent = '✓ завершено';
            else if (d.status === 'stopped') lbl.textContent = 'остановлено';
        }
        if (!d.running) {
            if (_ckPollTimer) { clearInterval(_ckPollTimer); _ckPollTimer = null; }
            document.getElementById('btn-ck-start').style.display = 'inline-flex';
            document.getElementById('btn-ck-stop').style.display = 'none';
        }
    }).catch(function(){});
}


// ══════════════════════════════════════════════════════════════════
//  Очистка ТГ (cleanup)
// ══════════════════════════════════════════════════════════════════

var _cleanupPollTimer = null;

function _cleanupCollectConfig() {
    var phones = getSelectorPhones('acc-selector-cleanup');
    var types = [];
    if (document.getElementById('cln-t-channels').checked) types.push('channels');
    if (document.getElementById('cln-t-groups').checked) types.push('groups');
    if (document.getElementById('cln-t-bots').checked) types.push('bots');
    if (document.getElementById('cln-t-pm').checked) types.push('pm');
    var actionEl = document.querySelector('input[name="cln-action"]:checked');
    return {
        phones: phones,
        types: types,
        action: actionEl ? actionEl.value : 'delete',
        only_no_write: document.getElementById('cln-only-no-write').checked,
        inactive_days: parseInt(document.getElementById('cln-inactive-days').value || '0', 10),
        keywords: document.getElementById('cln-keywords').value || '',
        whitelist_usernames: document.getElementById('cln-whitelist').value || '',
        skip_no_username: document.getElementById('cln-skip-no-username').checked,
        delete_folders: (document.getElementById('cln-delete-folders') || {}).checked || false,
        // потоки
        threads: parseInt((document.getElementById('cln-threads') || {}).value) || 1,
    };
}

function cleanupPreview() {
    var cfg = _cleanupCollectConfig();
    if (!cfg.phones.length) { showToast('Выбери аккаунты', 'warning'); return; }
    if (!cfg.types.length) { showToast('Выбери что чистить', 'warning'); return; }
    cfg.dry_run = true;
    document.getElementById('cleanup-dryrun-result').style.display = 'none';
    document.getElementById('cleanup-progress').style.display = 'block';
    (document.getElementById('cleanup-log') || {style:{}}).innerHTML = '<div style="color:var(--text-muted);">Идёт сухой прогон…</div>';
    API.post('/api/cleanup/start', cfg).then(function(d) {
        if (d.ok) {
            if (_cleanupPollTimer) clearInterval(_cleanupPollTimer);
            _cleanupPollTimer = setInterval(pollCleanupStatus, 1500);
        } else {
            showToast(d.error || 'Ошибка', 'error');
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

async function cleanupStart() {
    var cfg = _cleanupCollectConfig();
    if (!cfg.phones.length) { showToast('Выбери аккаунты', 'warning'); return; }
    if (!cfg.types.length) { showToast('Выбери что чистить', 'warning'); return; }
    cfg.dry_run = false;
    var typesText = cfg.types.join(', ');
    if (!(await appConfirm('Внимание! Будет удалено всё что попадает под фильтры (' + typesText + ') на ' + cfg.phones.length + ' акк. Операция необратима. Продолжить?', {danger: true}))) {
        return;
    }
    document.getElementById('cleanup-dryrun-result').style.display = 'none';
    document.getElementById('cleanup-progress').style.display = 'block';
    (document.getElementById('cleanup-log') || {style:{}}).innerHTML = '';
    document.getElementById('btn-cleanup-stop').style.display = 'inline-flex';
    API.post('/api/cleanup/start', cfg).then(function(d) {
        if (d.ok) {
            if (_cleanupPollTimer) clearInterval(_cleanupPollTimer);
            _cleanupPollTimer = setInterval(pollCleanupStatus, 2000);
        } else {
            showToast(d.error || 'Ошибка', 'error');
            document.getElementById('btn-cleanup-stop').style.display = 'none';
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function cleanupStop() {
    API.post('/api/cleanup/stop', {}).then(function() { showToast('Останавливаю…', 'info'); });
}

async function cleanupLeaveAll() {
    var phones = getSelectorPhones('acc-selector-cleanup');
    if (!phones.length) { showToast('Выбери аккаунты', 'warning'); return; }
    var wl = document.getElementById('cln-whitelist').value || '';
    if (!(await appConfirm('Покинуть ВСЕ каналы и группы на ' + phones.length + ' акк (кроме whitelist)? Личку и ботов не трогаем. Операция необратима.', {danger: true}))) {
        return;
    }
    document.getElementById('cleanup-progress').style.display = 'block';
    (document.getElementById('cleanup-log') || {style:{}}).innerHTML = '';
    document.getElementById('btn-cleanup-stop').style.display = 'inline-flex';
    API.post('/api/cleanup/leave_all', { phones: phones, whitelist_usernames: wl, dry_run: false }).then(function(d) {
        if (d.ok) {
            if (_cleanupPollTimer) clearInterval(_cleanupPollTimer);
            _cleanupPollTimer = setInterval(pollCleanupStatus, 2000);
        } else {
            showToast(d.error || 'Ошибка', 'error');
            document.getElementById('btn-cleanup-stop').style.display = 'none';
        }
    }).catch(function() { showToast('Ошибка', 'error'); });
}

function pollCleanupStatus() {
    API.get('/api/cleanup/status').then(function(d) {
        if (!d) return;
        if (d.processed !== undefined) {
            document.getElementById('cleanup-processed').textContent = d.processed;
            document.getElementById('cleanup-ch').textContent = d.deleted_channels || 0;
            document.getElementById('cleanup-gr').textContent = d.deleted_groups || 0;
            document.getElementById('cleanup-bt').textContent = d.deleted_bots || 0;
            document.getElementById('cleanup-pm').textContent = d.deleted_pm || 0;
            document.getElementById('cleanup-wl').textContent = d.skipped_whitelist || 0;
            document.getElementById('cleanup-err').textContent = d.errors || 0;
        }
        if (d.current_phone || d.current_step) {
            document.getElementById('cleanup-current-step').textContent =
                (d.current_phone || '') + ' — ' + (d.current_step || '');
        }
        var logEl = document.getElementById('cleanup-log');
        if (logEl && d.logs) {
            logEl.innerHTML = d.logs.map(function(l) { return '<div>' + escapeHtml(l) + '</div>'; }).join('');
            logEl.scrollTop = logEl.scrollHeight;
        }
        if (!d.running) {
            if (_cleanupPollTimer) { clearInterval(_cleanupPollTimer); _cleanupPollTimer = null; }
            document.getElementById('btn-cleanup-stop').style.display = 'none';
            if (d.dry_run && d.dry_run_results && d.dry_run_results.length) {
                var box = document.getElementById('cleanup-dryrun-result');
                var lst = document.getElementById('cleanup-dryrun-list');
                document.getElementById('cleanup-dryrun-count').textContent = d.dry_run_results.length;
                lst.innerHTML = d.dry_run_results.map(function(r) {
                    var kindIcon = {channel:'<i class="bi bi-megaphone"></i>', group:'<i class="bi bi-people"></i>', bot:'<i class="bi bi-robot"></i>', pm:'<i class="bi bi-person"></i>'}[r.kind] || '·';
                    var u = r.username ? (' @' + r.username) : '';
                    return '<div style="padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.05);">' +
                        kindIcon + ' <span style="color:var(--text-muted);">' + escapeHtml(r.phone) + '</span> · ' +
                        escapeHtml(r.title || '') + u +
                    '</div>';
                }).join('');
                box.style.display = 'block';
            }
        }
    });
}

// Короткий путь для интерфейса: только две последние папки. Полный путь ничего
// не объясняет, занимает всю ширину и выглядит как техническая строка.
function _shortPath(p) {
    var s = String(p || '').replace(/[\\/]+$/, '');
    if (!s) return '';
    var parts = s.split(/[\\/]/).filter(Boolean);
    return parts.length <= 2 ? s : parts.slice(-2).join('\\');
}

function escapeHtml(s) {
    return String(s || '').replace(/[&<>"']/g, function(c) {
        return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
    });
}

// Баннер «на машине запущено >1 копии TelePush». Данные — самоотчёт живых
// процессов (см. /api/instances), НЕ обход диска. Показывает путь+акки+версию
// каждой копии и кнопку открыть папку той, где аккаунты.
function loadMultiCopyBanner() {
    var banner = document.getElementById('multicopy-banner');
    if (!banner) return;
    API.get('/api/instances').then(function(d) {
        if (!d || (d.count || 0) <= 1) { banner.style.display = 'none'; return; }
        var copies = d.copies || [];
        var cnt = document.getElementById('mc-count');
        if (cnt) cnt.textContent = d.count;
        var rows = copies.map(function(c) {
            var self = c.is_self ? '<span class="mc-self">эта копия</span>' : '';
            // Копия на старой версии не сообщает путь/кол-во (обновится с 1.9.20+).
            if (!c.root) {
                return '<div class="mc-row"><span class="mc-meta">другая копия — старая версия, ' +
                       'не видит путь; обнови её, чтобы показать папку и аккаунты</span> ' + self + '</div>';
            }
            var n = (typeof c.acc_count === 'number') ? c.acc_count : '?';
            var ver = c.version ? ('v' + escapeHtml(c.version)) : '';
            var port = c.port ? (' · порт ' + escapeHtml(String(c.port))) : '';
            return '<div class="mc-row"><code>' + escapeHtml(c.root) + '</code> — <b>' +
                   n + '</b> акк. <span class="mc-meta">' + ver + port + '</span> ' + self + '</div>';
        }).join('');
        // Кнопка «Открыть папку»: приоритет — копия с макс. аккаунтами (>0); если
        // таких нет — эта копия (или любая) с известной папкой, чтобы юзер мог
        // открыть её и докинуть/проверить пары.
        var top = null;
        for (var i = 0; i < copies.length; i++) {
            if (typeof copies[i].acc_count === 'number' && copies[i].acc_count > 0) { top = copies[i]; break; }
        }
        if (!top) { for (var j = 0; j < copies.length; j++) {
            if (copies[j].is_self && copies[j].accounts_dir) { top = copies[j]; break; } } }
        if (!top) { for (var k = 0; k < copies.length; k++) {
            if (copies[k].accounts_dir) { top = copies[k]; break; } } }
        var actions = '';
        if (top && top.accounts_dir) {
            var hasAccs = (typeof top.acc_count === 'number' && top.acc_count > 0);
            var label = hasAccs ? 'Открыть папку с аккаунтами' : 'Открыть папку аккаунтов (проверить пары)';
            var hint = hasAccs ? 'Открывай копию с аккаунтами, вторую закрой.'
                               : 'Пары кладут в подпапку «В работе», затем «Обновить» на вкладке Аккаунты.';
            actions = '<div class="mc-actions">' +
                '<button class="btn btn-accent btn-sm" onclick="openAccountsCopyFolder(this)" data-path="' +
                escapeHtml(top.accounts_dir) + '"><i class="bi bi-folder2-open"></i> ' + label + '</button>' +
                '<span class="mc-hint">' + hint + '</span></div>';
        }
        var list = document.getElementById('mc-list');
        if (list) list.innerHTML = rows + actions;
        banner.style.display = 'flex';
    }).catch(function() { /* тихо — баннер не критичен */ });
}

function openAccountsCopyFolder(btn) {
    var path = btn && btn.dataset ? btn.dataset.path : '';
    if (!path) return;
    API.post('/api/instances/open_folder', { path: path }).catch(function(e) {
        showToast((e && e.message) || 'Не удалось открыть папку', 'error');
    });
}


// ══════════════════════════════════════════════════════════════════
//  Накрутка (boost)
// ══════════════════════════════════════════════════════════════════

var _boostPollTimer = null;

function onBoostTypeChange() {
    var t = (document.getElementById('boost-type') || {}).value || 'subscribers';
    var labelMap = {
        subscribers: 'Ссылка на канал / @username (подписаться сюда)',
        reactions:   'Ссылка на пост (t.me/channel/123)',
        polls:       'Ссылка на пост-опрос',
        buttons:     'Ссылка на сообщение с inline-кнопками',
        bots:        '@username бота для запуска',
        views:       'Ссылка на пост (накрутить просмотры)',
    };
    var lbl = document.getElementById('boost-target-label');
    if (lbl) lbl.textContent = labelMap[t] || 'Цель';

    var fields = {
        'boost-field-emojis': t === 'reactions',
        'boost-field-poll':   t === 'polls',
        'boost-field-button': t === 'buttons',
    };
    Object.keys(fields).forEach(function(id) {
        var el = document.getElementById(id);
        if (el) el.style.display = fields[id] ? 'block' : 'none';
    });
}

function _parseDelayRange(text) {
    var s = (text || '').replace(/\s/g, '');
    var m = s.match(/^([\d.]+)(?:-([\d.]+))?$/);
    if (!m) return [5, 15];
    var lo = parseFloat(m[1]) || 5;
    var hi = m[2] ? parseFloat(m[2]) : lo;
    if (hi < lo) hi = lo;
    return [lo, hi];
}

async function startBoost() {
    var type = (document.getElementById('boost-type') || {}).value || 'subscribers';
    var target = ((document.getElementById('boost-target') || {}).value || '').trim();
    if (!target) { showToast('Укажи цель (ссылку/юзернейм)', 'warning'); return; }

    var delay = _parseDelayRange((document.getElementById('boost-delay') || {}).value || '5-15');
    var emojisStr = ((document.getElementById('boost-emojis') || {}).value || '').trim();
    var emojis = emojisStr ? emojisStr.split(/\s+/).filter(Boolean) : [];

    var pollOptionRaw = (document.getElementById('boost-poll-option') || {}).value;
    var pollOption = (pollOptionRaw === '' || pollOptionRaw == null) ? null : parseInt(pollOptionRaw);

    var payload = {
        type: type,
        target: target,
        count: parseInt((document.getElementById('boost-count') || {}).value) || 50,
        delay_min: delay[0],
        delay_max: delay[1],
        threads: parseInt((document.getElementById('boost-threads') || {}).value) || 1,
        folder: ((document.getElementById('boost-folder') || {}).value || '').trim(),
        emojis: emojis,
        poll_option: pollOption,
        button_text: ((document.getElementById('boost-button') || {}).value || '').trim(),
        allow_restart: !!(document.getElementById('boost-allow-restart') || {}).checked,
        allow_revote: !!(document.getElementById('boost-allow-revote') || {}).checked,
    };

    // Soft-warning для большого числа акков на 1 цель
    if (payload.count > 50) {
        if (!(await appConfirm('Накрутка >50 аккаунтов за один заход на одну цель. Telegram может пометить цель как накрученную и применить санкции (для канала — пометка «массовый набор», скрытие из поиска). Продолжить?', {}))) return;
    }

    showToast('Запускаю накрутку...', 'info');
    API.post('/api/boost/start', payload).then(function(d) {
        if (d.ok) {
            document.getElementById('btn-boost-start').style.display = 'none';
            document.getElementById('btn-boost-stop').style.display = 'inline-flex';
            document.getElementById('boost-progress').style.display = 'block';
            (document.getElementById('boost-log') || {style:{}}).style.display = 'block';
            if (_boostPollTimer) clearInterval(_boostPollTimer);
            _boostPollTimer = setInterval(pollBoostStatus, 1500);
        } else {
            showToast(d.error || 'Не удалось запустить', 'error');
        }
    }).catch(function(e) { showToast((e && e.message) || 'Не удалось запустить накрутку', 'error'); });
}

function stopBoost() {
    API.post('/api/boost/stop', {}).then(function() {
        showToast('Останавливаю...', 'info');
    });
}

function pollBoostStatus() {
    API.get('/api/boost/status').then(function(d) {
        if (!d) return;
        var bar = document.getElementById('boost-progress-bar');
        var txt = document.getElementById('boost-progress-text');
        var log = document.getElementById('boost-log');
        var lbl = document.getElementById('boost-status-label');
        var total = d.total || 0, done = d.done || 0;
        if (bar) bar.style.width = (total > 0 ? Math.round(done/total*100) : 0) + '%';
        if (txt) txt.innerHTML = '<b>' + done + '</b> / ' + total
            + '  ·  ✓ <span style="color:var(--success);">' + (d.ok || 0) + '</span>'
            + '  ·  ✗ <span style="color:var(--danger);">' + (d.fail || 0) + '</span>';
        if (log && d.logs) {
            log.innerHTML = (d.logs || []).map(function(l){
                return '<div style="color:'+verdictColor(l)+';">'+escHtml(l)+'</div>';
            }).join('');
            log.scrollTop = log.scrollHeight;
        }
        if (lbl) {
            if (d.running) lbl.textContent = '⏳ работа...';
            else if (d.status === 'finished') lbl.textContent = '✓ завершено';
            else if (d.status === 'stopped') lbl.textContent = 'остановлено';
        }
        if (!d.running) {
            if (_boostPollTimer) { clearInterval(_boostPollTimer); _boostPollTimer = null; }
            document.getElementById('btn-boost-start').style.display = 'inline-flex';
            document.getElementById('btn-boost-stop').style.display = 'none';
        }
    }).catch(function(){});
}


// ══════════════════════════════════════════════════════════════════
//  Тема оформления
// ══════════════════════════════════════════════════════════════════

function applyTheme(theme) {
    var html = document.documentElement;
    var body = document.body;
    ['theme-calm', 'theme-amoled', 'theme-light'].forEach(function(c) {
        html.classList.remove(c);
        body.classList.remove(c);
    });
    if (theme === 'amoled' || theme === 'light') {
        html.classList.add('theme-' + theme);
        body.classList.add('theme-' + theme);
    }
    // Подсветить активную карточку в picker'е
    document.querySelectorAll('.theme-card').forEach(function(c) {
        c.classList.toggle('is-active', c.getAttribute('data-theme') === theme);
    });
}

function selectTheme(theme) {
    if (['calm', 'amoled', 'light'].indexOf(theme) === -1) return;
    applyTheme(theme);
    try { localStorage.setItem('tp_theme', theme); } catch(e) {}
    API.post('/api/theme/save', { theme: theme }).catch(function() {});
}

function loadTheme() {
    // Сначала из localStorage (мгновенно), потом синк с сервера
    var saved = 'calm';
    try { saved = localStorage.getItem('tp_theme') || 'calm'; } catch(e) {}
    applyTheme(saved);
    API.get('/api/theme/get').then(function(d) {
        var srv = (d && d.theme) || 'calm';
        if (srv !== saved) {
            applyTheme(srv);
            try { localStorage.setItem('tp_theme', srv); } catch(e) {}
        }
    }).catch(function() {});
}


// ══════════════════════════════════════════════════════════════════
//  Anti-detect статистика (на вкладке Аналитика)
// ══════════════════════════════════════════════════════════════════

function loadAdStats() {
    API.get('/api/ad_stats').then(function(d) {
        var c = (d && d.counters) || {};
        function set(id, v) {
            var el = document.getElementById(id);
            if (el) el.textContent = (v || 0).toLocaleString('ru-RU');
        }
        set('ad-stat-precheck-runs', c.pre_check_runs);
        set('ad-stat-precheck-blocked', c.pre_check_blocked);
        set('ad-stat-captcha', c.captcha_solved);
        set('ad-stat-profiles', c.profiles_viewed);
        set('ad-stat-photos', c.photos_viewed);
        set('ad-stat-reads', c.messages_read);
    }).catch(function() {});
}

// ══════════════════════════════════════════════════════════════════
//  TOPBAR — Ctrl+K поиск + последние действия + AI помощник
// ══════════════════════════════════════════════════════════════════

// ── Последние действия в topbar ──
function _formatAgo(ts) {
    var sec = Math.max(0, Math.floor(Date.now() / 1000 - ts));
    if (sec < 60) return sec + 'с';
    if (sec < 3600) return Math.floor(sec / 60) + 'м';
    if (sec < 86400) return Math.floor(sec / 3600) + 'ч';
    return Math.floor(sec / 86400) + 'д';
}

var _recentItems = [];

function pollRecentActivity() {
    if (document.hidden) return;  // окно скрыто — не дёргаем сервер ради косметической строки
    API.get('/api/recent_activity').then(function(d) {
        _recentItems = d.items || [];
        var el = document.getElementById('topbar-recent-text');
        if (!el) return;
        if (!_recentItems.length) {
            el.textContent = 'Пока ничего не происходило';
        } else {
            var top = _recentItems[0];
            el.innerHTML = '<b>' + _formatAgo(top.ts) + '</b> назад — ' + escHtml(top.text);
        }
    }).catch(function() {});
}

function toggleRecentActivity() {
    var existing = document.getElementById('recent-popover');
    if (existing) { existing.remove(); return; }
    if (!_recentItems.length) {
        showToast('История действий пустая', 'info');
        return;
    }
    var pop = document.createElement('div');
    pop.id = 'recent-popover';
    pop.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:10px;z-index:9999;max-width:600px;width:90%;max-height:60vh;overflow-y:auto;';
    var html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"><b style="font-size:0.85rem;">Последние действия</b><button onclick="document.getElementById(\'recent-popover\').remove()" style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:1.1rem;">×</button></div>';
    _recentItems.forEach(function(it) {
        var color = it.level === 'error' ? 'var(--danger)' : it.level === 'warn' ? 'var(--warning)' : 'var(--text-muted)';
        html += '<div style="padding:6px 8px;border-bottom:1px solid var(--border);font-size:0.78rem;">'
              + '<span style="color:' + color + ';">' + _formatAgo(it.ts) + ' назад</span> — '
              + escHtml(it.text) + '</div>';
    });
    pop.innerHTML = html;
    document.body.appendChild(pop);
}

// ── Ctrl+K глобальный поиск ──
function openGlobalSearch() {
    var existing = document.getElementById('global-search');
    if (existing) { existing.remove(); return; }
    var overlay = document.createElement('div');
    overlay.id = 'global-search';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9998;display:flex;align-items:flex-start;justify-content:center;padding-top:120px;';
    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
    var box = document.createElement('div');
    box.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:7px;width:90%;max-width:560px;overflow:hidden;';
    box.innerHTML =
        '<div style="padding:12px;border-bottom:1px solid var(--border);">' +
        '<input id="gs-input" type="text" placeholder="Найти акк, чат, шаблон, команду…" ' +
        'style="width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:0.95rem;" />' +
        '</div>' +
        '<div id="gs-results" style="max-height:50vh;overflow-y:auto;padding:6px;"></div>' +
        '<div style="padding:8px 12px;font-size:0.7rem;color:var(--text-muted);border-top:1px solid var(--border);">' +
        '<kbd style="background:var(--bg);padding:1px 5px;border-radius:3px;border:1px solid var(--border);">↑↓</kbd> навигация · ' +
        '<kbd style="background:var(--bg);padding:1px 5px;border-radius:3px;border:1px solid var(--border);">Enter</kbd> выбрать · ' +
        '<kbd style="background:var(--bg);padding:1px 5px;border-radius:3px;border:1px solid var(--border);">Esc</kbd> закрыть' +
        '</div>';
    overlay.appendChild(box);
    document.body.appendChild(overlay);
    var inp = document.getElementById('gs-input');
    inp.focus();
    _gsSelectedIdx = 0;
    _gsRenderResults('');
    inp.oninput = function() { _gsSelectedIdx = 0; _gsRenderResults(inp.value); };
    inp.onkeydown = function(e) {
        if (e.key === 'Escape') { overlay.remove(); return; }
        if (e.key === 'ArrowDown') { e.preventDefault(); _gsMoveSelection(1); }
        if (e.key === 'ArrowUp') { e.preventDefault(); _gsMoveSelection(-1); }
        if (e.key === 'Enter') { e.preventDefault(); _gsActivateSelected(); }
    };
}

var _gsSelectedIdx = 0;
var _gsLastResults = [];

// Команды интерфейса
var _GS_COMMANDS = [
    {label: 'Аналитика', icon: 'graph-up', action: function(){ showTab('analytics'); }},
    {label: 'Менеджер аккаунтов', icon: 'people', action: function(){ showTab('accounts'); }},
    {label: 'Прокси', icon: 'shield-lock', action: function(){ showTab('proxy'); }},
    {label: 'Рассылка по чатам', icon: 'megaphone', action: function(){ showTab('spam'); }},
    {label: 'Рассылка ЛС / DM', icon: 'person-lines-fill', action: function(){ showTab('dm'); }},
    {label: 'Подписка / Вступление', icon: 'box-arrow-in-right', action: function(){ showTab('join'); }},
    {label: 'Инвайт', icon: 'person-plus', action: function(){ showTab('invite'); }},
    {label: 'Парсинг', icon: 'search', action: function(){ showTab('parser'); }},
    {label: 'Мониторинг', icon: 'broadcast', action: function(){ showTab('monitor'); }},
    {label: 'Прогрев', icon: 'fire', action: function(){ showTab('warmup'); }},
    {label: 'Чекер аккаунтов', icon: 'shield-check', action: function(){ showTab('checker'); }},
    {label: 'Чекер номеров', icon: 'telephone', action: function(){ showTab('number-checker'); }},
    {label: 'Настройки', icon: 'gear', action: function(){ showTab('settings'); }},
    {label: 'Открыть логи', icon: 'list-task', action: function(){ if (typeof openLogsWindow === 'function') openLogsWindow(); }},
];

function _gsBuildIndex(q) {
    var query = (q || '').toLowerCase().trim();
    var results = [];
    // Команды
    _GS_COMMANDS.forEach(function(c) {
        if (!query || c.label.toLowerCase().indexOf(query) !== -1) {
            results.push({type: 'cmd', icon: c.icon, title: c.label, sub: 'Команда', action: c.action});
        }
    });
    // Аккаунты
    (_cachedAccounts || []).forEach(function(a) {
        var hit = !query
            || (a.phone || '').toLowerCase().indexOf(query) !== -1
            || (a.username || '').toLowerCase().indexOf(query) !== -1
            || ((a.first_name || '') + ' ' + (a.last_name || '')).toLowerCase().indexOf(query) !== -1;
        if (hit) {
            results.push({
                type: 'acc',
                icon: a.dead ? 'x-circle' : (a.frozen ? 'snow' : 'person'),
                title: '+' + a.phone + (a.username ? ' (@' + a.username + ')' : ''),
                sub: (a.first_name || 'Аккаунт') + (a.folder ? ' · ' + a.folder : ''),
                action: function() { pickAccount(a.phone); showTab('accounts'); },
            });
        }
    });
    return results.slice(0, 30);
}

function _gsRenderResults(q) {
    var el = document.getElementById('gs-results');
    if (!el) return;
    _gsLastResults = _gsBuildIndex(q);
    if (!_gsLastResults.length) {
        el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.85rem;">Ничего не найдено</div>';
        return;
    }
    var html = '';
    _gsLastResults.forEach(function(r, i) {
        var sel = i === _gsSelectedIdx;
        html += '<div class="gs-item" data-idx="' + i + '" style="padding:8px 12px;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:10px;' + (sel ? 'background:var(--accent-soft);' : '') + '" onclick="_gsActivateByIdx(' + i + ')">'
              + '<i class="bi bi-' + r.icon + '" style="font-size:1rem;color:var(--accent-light);"></i>'
              + '<div style="flex:1;"><div style="font-size:0.85rem;">' + escHtml(r.title) + '</div>'
              + '<div style="font-size:0.7rem;color:var(--text-muted);">' + escHtml(r.sub) + '</div></div>'
              + '</div>';
    });
    el.innerHTML = html;
}

function _gsMoveSelection(delta) {
    _gsSelectedIdx = Math.max(0, Math.min(_gsLastResults.length - 1, _gsSelectedIdx + delta));
    _gsRenderResults(document.getElementById('gs-input').value);
}

function _gsActivateSelected() {
    _gsActivateByIdx(_gsSelectedIdx);
}

function _gsActivateByIdx(i) {
    var r = _gsLastResults[i];
    if (!r) return;
    document.getElementById('global-search').remove();
    try { r.action(); } catch(e) { console.error('gs action', e); }
}

// Ctrl+K hotkey
document.addEventListener('keydown', function(e) {
    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault();
        openGlobalSearch();
    }
});

// Старт polling топбара
setInterval(pollRecentActivity, 3000);
if (document.readyState !== 'loading') {
    setTimeout(pollRecentActivity, 500);
} else {
    document.addEventListener('DOMContentLoaded', function(){ setTimeout(pollRecentActivity, 500); });
}

// ══════════════════════════════════════════════════════════════════
//  AI помощник в углу — настройки провайдера
//  Namespace tphelper- чтобы не конфликтовать с warmup AI чекера
// ══════════════════════════════════════════════════════════════════
var _TPHELPER_AI_PROVIDERS = [];

function loadTphelperAiSettings() {
    API.get('/api/ai/settings').then(function(d) {
        _TPHELPER_AI_PROVIDERS = d.available_providers || [];
        var sel = document.getElementById('tphelper-ai-provider');
        if (sel) {
            sel.value = d.provider || 'deepseek';
            onTphelperAiProviderChange();
        }
        var keyEl = document.getElementById('tphelper-ai-key');
        if (keyEl) {
            keyEl.value = '';
            keyEl.placeholder = d.api_key_masked ? ('Ключ сохранён: ' + d.api_key_masked) : (sel ? _tphelperAiKeyHint(sel.value) : 'sk-...');
        }
        var modelEl = document.getElementById('tphelper-ai-model');
        if (modelEl) modelEl.value = d.model || '';
        var st = document.getElementById('tphelper-ai-key-status');
        if (st) st.textContent = d.api_key_masked ? ('Ключ настроен (' + (d.provider || 'deepseek') + ')') : 'Ключ не задан';
    }).catch(function(){});
}

function _tphelperAiKeyHint(provider) {
    var p = _TPHELPER_AI_PROVIDERS.find(function(x){ return x.id === provider; });
    return p ? p.key_hint : '';
}

function onTphelperAiProviderChange() {
    var sel = document.getElementById('tphelper-ai-provider');
    if (!sel) return;
    var p = _TPHELPER_AI_PROVIDERS.find(function(x){ return x.id === sel.value; });
    var hintEl = document.getElementById('tphelper-ai-key-hint');
    var modelEl = document.getElementById('tphelper-ai-model');
    if (hintEl && p) hintEl.textContent = p.key_hint + ' · модель по умолчанию: ' + p.default_model;
    if (modelEl && p) modelEl.placeholder = 'по умолчанию: ' + p.default_model;
}

function saveTphelperAiSettings() {
    var provider = (document.getElementById('tphelper-ai-provider') || {}).value || 'deepseek';
    var api_key = (document.getElementById('tphelper-ai-key') || {}).value || '';
    var model = (document.getElementById('tphelper-ai-model') || {}).value || '';
    var st = document.getElementById('tphelper-ai-key-status');
    if (st) st.textContent = 'Сохраняю…';
    API.post('/api/ai/settings', {provider: provider, api_key: api_key, model: model}).then(function(d) {
        if (d.ok) {
            if (st) st.textContent = '✓ Сохранено (' + d.provider + ')';
            showToast('AI-настройки сохранены', 'success');
            loadTphelperAiSettings();
        } else {
            if (st) st.textContent = 'Ошибка';
            showToast(d.error || 'Не удалось сохранить', 'error');
        }
    });
}

function testTphelperAiSettings() {
    var st = document.getElementById('tphelper-ai-key-status');
    if (st) st.textContent = 'Проверка…';
    API.post('/api/ai/chat', {message: 'Привет! Ответь одним словом «работаю».', history: []}).then(function(d) {
        if (d && d.reply) {
            if (st) st.textContent = '✓ Работает. Ответ: «' + d.reply.slice(0, 80) + '»';
            showToast('AI отвечает', 'success');
        } else {
            if (st) st.textContent = '✗ ' + (d.error || 'нет ответа');
            showToast(d.error || 'Нет ответа', 'error');
        }
    }).catch(function(e) {
        if (st) st.textContent = 'Ошибка: ' + e;
    });
}

// Авто-загрузка при открытии вкладки настроек
(function(){
    var origShowTab = window.showTab;
    if (typeof origShowTab === 'function') {
        window.showTab = function(name) {
            var r = origShowTab.apply(this, arguments);
            if (name === 'settings') setTimeout(loadTphelperAiSettings, 200);
            return r;
        };
    }
})();

// ══════════════════════════════════════════════════════════════════
//  AI помощник в углу
// ══════════════════════════════════════════════════════════════════
var _aiChatHistory = [];

function toggleAiChat() {
    var existing = document.getElementById('ai-chat');
    if (existing) {
        existing.remove();
        if (typeof _tpRedock === 'function') _tpRedock();
        return;
    }
    var box = document.createElement('div');
    box.id = 'ai-chat';
    box.style.cssText = 'position:fixed;bottom:20px;right:20px;width:380px;max-width:92vw;height:520px;max-height:80vh;background:var(--bg-card);border:1px solid var(--border);border-radius:7px;z-index:9997;display:flex;flex-direction:column;overflow:hidden;';
    box.innerHTML =
        '<div id="ai-chat-head" style="padding:10px 14px;border-bottom:1px solid var(--border);background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;display:flex;align-items:center;gap:8px;">'
        + '<i class="bi bi-stars"></i><b style="font-size:0.9rem;">AI помощник</b>'
        + '<button onclick="document.getElementById(\'ai-chat\').remove()" style="margin-left:auto;background:transparent;border:none;color:#fff;cursor:pointer;font-size:1.1rem;">×</button>'
        + '</div>'
        + '<div id="ai-chat-body" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px;"></div>'
        + '<div style="border-top:1px solid var(--border);padding:8px;display:flex;gap:6px;">'
        + '<input id="ai-chat-input" type="text" placeholder="Спросите про аккаунты, чаты, паузы Telegram…" style="flex:1;background:transparent;border:1px solid var(--border);border-radius:4px;padding:6px 10px;color:var(--text);font-size:0.82rem;" />'
        + '<button onclick="aiChatSend()" style="background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;border:none;border-radius:4px;padding:6px 14px;cursor:pointer;font-size:0.82rem;"><i class="bi bi-send"></i></button>'
        + '</div>';
    document.body.appendChild(box);
    if (typeof _tpRedock === 'function') _tpRedock();
    if (typeof _tpMakeMovable === 'function') {
        _tpMakeMovable(box, box.querySelector('#ai-chat-head'), 'tp_win_ai');
    }
    var inp = document.getElementById('ai-chat-input');
    inp.focus();
    inp.onkeydown = function(e) { if (e.key === 'Enter') { e.preventDefault(); aiChatSend(); } };
    _aiRenderHistory();
    if (!_aiChatHistory.length) {
        _aiAppend('assistant', 'Привет! Я вижу ваши аккаунты и что с ними происходит. Подскажу, какой аккаунт взять под задачу, объясню, почему Telegram просит подождать, и посоветую настройки. Спрашивайте.');
    }
}

function _aiAppend(role, text) {
    if (role !== 'system') _aiChatHistory.push({role: role, content: text});
    var b = document.getElementById('ai-chat-body');
    if (!b) return;
    var div = document.createElement('div');
    var isUser = role === 'user';
    div.style.cssText = 'max-width:85%;padding:8px 10px;border-radius:5px;font-size:0.82rem;line-height:1.4;white-space:pre-wrap;' + (isUser ? 'background:var(--accent-soft);align-self:flex-end;' : 'background:var(--bg);align-self:flex-start;border:1px solid var(--border);');
    div.textContent = text;
    b.appendChild(div);
    b.scrollTop = b.scrollHeight;
}

function _aiRenderHistory() {
    var b = document.getElementById('ai-chat-body');
    if (!b) return;
    b.innerHTML = '';
    _aiChatHistory.forEach(function(m) { _aiAppend(m.role, m.content); });
    // hack — _aiAppend дублирует в историю, отфильтруем дубли
    _aiChatHistory = _aiChatHistory.filter(function(m, i, arr) {
        return arr.findIndex(function(x) { return x.role === m.role && x.content === m.content; }) === i;
    });
}

function aiChatSend() {
    var inp = document.getElementById('ai-chat-input');
    var q = (inp.value || '').trim();
    if (!q) return;
    inp.value = '';
    _aiAppend('user', q);
    _aiAppend('assistant', '…');
    API.post('/api/ai/chat', {message: q, history: _aiChatHistory.slice(-20)}).then(function(d) {
        // Убираем последнее «…»
        var b = document.getElementById('ai-chat-body');
        if (b && b.lastChild) b.removeChild(b.lastChild);
        _aiChatHistory.pop();
        if (d && d.reply) {
            _aiAppend('assistant', d.reply);
        } else {
            _aiAppend('assistant', (d && d.error) ? ('Ошибка: ' + d.error) : 'Пустой ответ от ИИ. Проверьте ключ в настройках.');
        }
    }).catch(function(e) {
        var b = document.getElementById('ai-chat-body');
        if (b && b.lastChild) b.removeChild(b.lastChild);
        _aiChatHistory.pop();
        // Не настроен ключ — это не поломка, а нехватка настройки. Раньше сюда
        // прилетало «Связь упала: Error: ...», и клиент писал в поддержку «не
        // работает помощник», хотя работать было нечем.
        var _st = e && e.status;
        if (_st === 400 || _st === 402 || _st === 403) {
            _aiAppend('assistant', (e && e.message) || 'Помощнику нужен ключ ИИ.');
            _aiOfferSettings();
            return;
        }
        _aiAppend('assistant', 'Не получилось связаться с помощником. Проверьте интернет и попробуйте ещё раз.');
    });
}

// Кнопка прямо в чате: написать «открой настройки» и не дать туда попасть —
// это тупик.
function _aiOfferSettings() {
    var b = document.getElementById('ai-chat-body');
    if (!b || document.getElementById('ai-chat-goto-settings')) return;
    var wrap = document.createElement('div');
    wrap.style.cssText = 'align-self:flex-start;';
    wrap.innerHTML = '<button id="ai-chat-goto-settings" class="btn btn-accent btn-sm" '
        + 'style="font-size:0.78rem;padding:5px 14px;">Открыть настройки ИИ</button>';
    b.appendChild(wrap);
    b.scrollTop = b.scrollHeight;
    document.getElementById('ai-chat-goto-settings').onclick = function() {
        var box = document.getElementById('ai-chat');
        if (box) box.remove();
        if (typeof showTab === 'function') showTab('settings');
        var el = document.getElementById('tphelper-ai-key');
        if (el) {
            el.scrollIntoView({block: 'center'});
            el.focus();
        }
    };
}

// ══════════════════════════════════════════════════════════════════
// Cross-module busy overlap guards — обёртки. Перед стартом задачи проверяем
// что выбранные акки не заняты в другом модуле; если заняты — модалка с вариантами.
// Обёртки для startSpam, startJoin, startChecker, applyProfileAll.
// startWarmup пропускаем (работает на всю папку «Прогрев»).
// ══════════════════════════════════════════════════════════════════

(function() {
    function wrap(name, getPhones, setPhones, label) {
        if (typeof window[name] !== 'function') return;
        var orig = window[name];
        window[name] = function() {
            var args = arguments;
            var phones = (typeof getPhones === 'function') ? (getPhones() || []) : [];
            if (!Array.isArray(phones) || !phones.length) {
                return orig.apply(this, args);
            }
            checkBusyAndConfirm(phones.slice(), label).then(function(decision) {
                if (decision.cancel) return;
                if (typeof setPhones === 'function') setPhones(decision.phones);
                orig.apply(window, args);
            });
        };
    }

    // startSpam — _multiSelected
    wrap('startSpam',
        function() { return Array.isArray(_multiSelected) ? _multiSelected : []; },
        function(p) { _multiSelected = p; },
        'Рассылка');

    // startJoin — _multiSelected (multi-join тоже использует тот же глобал)
    wrap('startJoin',
        function() { return Array.isArray(_multiSelected) ? _multiSelected : []; },
        function(p) { _multiSelected = p; },
        'Подписка');

    // startChecker — getSelectorPhones('acc-selector-checker')
    wrap('startChecker',
        function() { return (typeof getSelectorPhones === 'function') ? getSelectorPhones('acc-selector-checker') : []; },
        function(p) {
            // Перезаписываем picked для селектора чекера
            if (typeof _accSelectorPicked !== 'undefined') {
                _accSelectorPicked['acc-selector-checker'] = p.slice();
            }
        },
        'Чекер');

    // applyProfileAll — getSelectorPhones('acc-selector-profiles')
    wrap('applyProfileAll',
        function() { return (typeof getSelectorPhones === 'function') ? getSelectorPhones('acc-selector-profiles') : []; },
        function(p) {
            if (typeof _accSelectorPicked !== 'undefined') {
                _accSelectorPicked['acc-selector-profiles'] = p.slice();
            }
        },
        'Заполнение профилей');
})();

// ══════════════════════════════════════════════════════════════════
//  ИСТОРИИ (Stories) — постинг историй с акков (прогрев)
// ══════════════════════════════════════════════════════════════════
function openStoriesMultiPicker() {
    openMultiAccPopup();
    var t = setInterval(function() {
        if (!document.getElementById('acc-popup-overlay')) {
            clearInterval(t);
            updateStoriesMultiCount();
        }
    }, 400);
}
function updateStoriesMultiCount() {
    var el = document.getElementById('stories-multi-count');
    if (!el) return;
    var n = Array.isArray(_multiSelected) ? _multiSelected.length : 0;
    // Просто число рядом с кнопкой. «не выбрано» / «N выбрано» — лишние слова
    // там, где всё понятно по цифре.
    el.textContent = n;
    el.style.color = n === 0 ? 'var(--text-muted)' : 'var(--success)';
}
function loadStoriesMedia() {
    API.get('/api/stories/media').then(function(d) {
        if (!d || !d.ok) { _spDropDeadTicket(d && d.error); return; }
        var cnt = document.getElementById('stories-media-count');
        if (cnt) cnt.textContent = 'медиа: ' + d.count;
        var list = document.getElementById('stories-media-list');
        if (!list) return;
        if (!d.count) {
            list.innerHTML = '<span style="color:var(--warning);">папка «Истории» пуста — загрузи фото/видео</span>';
        } else {
            list.innerHTML = (d.items || []).map(function(it) {
                var ic = it.video ? 'bi-camera-video' : 'bi-image';
                return '<div><i class="bi ' + ic + '"></i> ' + escHtml(it.name) + '</div>';
            }).join('');
        }
    });
}
function uploadStoriesMedia(input) {
    if (!input || !input.files || !input.files.length) return;
    var fd = new FormData();
    for (var i = 0; i < input.files.length; i++) fd.append('file', input.files[i]);
    showToast('Загрузка ' + input.files.length + ' файл(ов)...', 'info');
    fetch('/api/stories/upload', { method: 'POST', body: fd, credentials: 'same-origin' })
        .then(function(r) { return r.json(); })
        .then(function(d) {
            input.value = '';
            if (d && d.ok) {
                showToast('Загружено: ' + d.saved + (d.skipped ? (', пропущено ' + d.skipped) : ''), 'success');
                loadStoriesMedia();
            } else { showToast('Ошибка загрузки', 'error'); }
        })
        .catch(function() { showToast('Ошибка загрузки', 'error'); });
}
function openStoriesFolder() { API.post('/api/stories/open_folder', {}); }
function startStories() {
    var phones = Array.isArray(_multiSelected) ? _multiSelected.slice() : [];
    if (!phones.length) { showToast('Выбери аккаунты', 'warning'); return; }
    var privacy = (document.getElementById('stories-privacy') || {}).value || 'everyone';
    var period = parseInt((document.getElementById('stories-period') || {}).value || '86400', 10);
    // Паузу между аккаунтами и лимит на одно фото движок читал из настроек, но
    // задать их было неоткуда: полей не существовало, и работали только
    // умолчания. Отправляем вместе с запуском — маршрут сохранит их в настройки.
    var _sMin = parseInt((document.getElementById('stories-min-delay') || {}).value || '20', 10);
    var _sMax = parseInt((document.getElementById('stories-max-delay') || {}).value || '60', 10);
    var _sThr = parseInt((document.getElementById('stories-watchdog-threshold') || {}).value || '3', 10);
    API.post('/api/stories/start', {
        phones: phones, privacy: privacy, period: period,
        stories_min_delay: _sMin, stories_max_delay: _sMax,
        stories_watchdog_threshold: _sThr,
    }).then(function(d) {
        if (d && d.ok) { showToast('Постинг историй запущен (' + d.total + ' акк)', 'info'); _storiesStartPolling(); }
        else { showToast((d && d.error) || 'Не удалось запустить', 'error'); }
    });
}
function stopStories() {
    API.post('/api/stories/stop', {}).then(function(d) {
        if (d && d.ok) showToast('Запрос на остановку отправлен', 'info');
    });
}
var _storiesPoll = null;
function _storiesStartPolling() {
    if (_storiesPoll) clearInterval(_storiesPoll);
    pollStoriesStatus();
    _storiesPoll = setInterval(pollStoriesStatus, 2500);
}
// Показать в полях то, что клиент задал в прошлый раз. Без этого поля всегда
// показывали бы разметку по умолчанию, а следующий запуск записал бы её поверх
// выбора — тем же путём самовыключался прогрев свежих аккаунтов.
function _storiesApplyConfig(cfg) {
    if (!cfg) return;
    var pairs = [['stories-min-delay', cfg.stories_min_delay],
                 ['stories-max-delay', cfg.stories_max_delay],
                 ['stories-watchdog-threshold', cfg.stories_watchdog_threshold]];
    pairs.forEach(function(p) {
        var el = document.getElementById(p[0]);
        if (el && typeof p[1] !== 'undefined' && p[1] !== null) el.value = p[1];
    });
}

function pollStoriesStatus() {
    API.get('/api/stories/status').then(function(d) {
        var box = document.getElementById('stories-status');
        var startBtn = document.getElementById('stories-start-btn');
        var stopBtn = document.getElementById('stories-stop-btn');
        if (d && d.config) _storiesApplyConfig(d.config);
        if (!d || !d.ok || (!d.running && !d.counts)) {
            if (_storiesPoll) { clearInterval(_storiesPoll); _storiesPoll = null; }
            if (startBtn) startBtn.style.display = '';
            if (stopBtn) stopBtn.style.display = 'none';
            return;
        }
        if (box) box.style.display = '';
        var c = d.counts || {};
        var sum = document.getElementById('stories-status-summary');
        if (sum) sum.innerHTML = 'Обработано ' + (d.processed || 0) + ' / ' + (d.total || 0) +
            ' · <span style="color:var(--success);">запощено ' + (c.posted || 0) + '</span>' +
            ' · watchdog ' + (c.skipped_watchdog || 0) + ' · ошибок ' + (c.failed || 0) +
            (d.current_step ? ('<br><span style="color:var(--text-muted);">' + escHtml(d.current_step) + '</span>') : '');
        var list = document.getElementById('stories-status-list');
        if (list && d.results) {
            list.innerHTML = Object.keys(d.results).map(function(ph) {
                var st = d.results[ph];
                var ok = st === 'запощено';
                var col = ok ? 'var(--success)' : (st && st.indexOf('watchdog') >= 0 ? 'var(--warning)' : 'var(--danger)');
                return '<div>' + escHtml(ph) + ' — <span style="color:' + col + ';">' + escHtml(st) + '</span></div>';
            }).join('');
        }
        if (d.running) {
            if (startBtn) startBtn.style.display = 'none';
            if (stopBtn) stopBtn.style.display = '';
        } else {
            if (_storiesPoll) { clearInterval(_storiesPoll); _storiesPoll = null; }
            if (startBtn) startBtn.style.display = '';
            if (stopBtn) stopBtn.style.display = 'none';
            showToast('Истории: запощено ' + (c.posted || 0), 'success');
        }
    });
}
document.addEventListener('DOMContentLoaded', function() { try { loadStoriesMedia(); } catch (e) {} });

// ══════════════════════════════════════════════════════════════════
//  Досье по @юзу — мгновенная сводка без аккаунтов
// ══════════════════════════════════════════════════════════════════

// ═══ Досье по @юзу — мгновенная сводка без аккаунтов ═══

// ═══ Монитор аккаунтов ══════════════════════════════════════════════════════
// Пассивное наблюдение. Опрашивает /api/accmon/stream и собирает карточки из
// потока событий на стороне браузера — сервер ничего не агрегирует, иначе на
// сотнях аккаунтов повторили бы обвал интерфейса, который уже случался.
// Поллер обязан гаситься при уходе с вкладки: живые setInterval на закрытых
// вкладках в этом проекте уже приводили к зомби-опросу.
(function () {
    'use strict';

    var POLL_MS = 1500;
    var RIB_LEN = 14;      // столбиков в ленте на карточке
    var STEP_LEN = 10;     // столбиков под сценой
    var FEED_LEN = 40;     // сколько событий держим на аккаунт

    var RANK = { ok: 0, run: 1, slow: 2, throttled: 3, blocked: 4, error: 5 };
    var LABEL = { ok: 'в работе', run: 'работает', slow: 'медленно',
                  throttled: 'придержан', blocked: 'встал', error: 'сбой', idle: 'свободен' };
    var VERD = { ok: 'идёт нормально', run: 'работает', slow: 'медленно — шаги идут дольше обычного',
                 throttled: 'Telegram придержал — это не блокировка',
                 blocked: 'встало, нужна реакция', error: 'сбой на аккаунте', idle: 'свободен' };

    var state = { seq: 0, accs: {}, order: [], sel: null, filter: 'all', timer: null,
                  meta: {}, known: 0, run: undefined };

    // Индикатор обязан говорить правду. Раньше в разметке намертво стояло
    // «наблюдение включено» — и надпись бодро горела даже когда до сервера
    // не доходил ни один запрос.
    function live(text, ok) {
        var t = document.getElementById('accmon-live-text');
        if (t) t.textContent = text;
        var box = document.getElementById('accmon-live');
        if (box) box.style.opacity = ok ? '' : '.55';
        var pulse = box ? box.querySelector('.am-pulse') : null;
        if (pulse) pulse.style.background = ok ? '' : '#ef7d7d';
    }

    function esc(s) {
        return String(s == null ? '' : s).replace(/[&<>"]/g, function (m) {
            return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m];
        });
    }
    function worst(a, b) { return (RANK[b] || 0) > (RANK[a] || 0) ? b : a; }
    function plural(n, one, few, many) {
        var a = Math.abs(n) % 100, b = a % 10;
        if (a > 10 && a < 20) return many;
        if (b > 1 && b < 5) return few;
        return b === 1 ? one : many;
    }
    function hhmm(ts) {
        var d = new Date((ts || 0) * 1000);
        return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
    }

    function acc(phone) {
        if (!state.accs[phone]) {
            state.accs[phone] = { phone: phone, verdict: 'idle', module: '', now: '',
                                  feed: [], steps: [], done: 0, total: 0, last: 0 };
            state.order.push(phone);
        }
        return state.accs[phone];
    }

    // Вердикт карточки — ХУДШЕЕ из последних шагов. Завершение задачи его не
    // отменяет: аккаунт, словивший спам-блок, зелёным быть не должен.
    function apply(ev) {
        var a = acc(ev.phone);
        a.last = ev.ts;
        if (ev.module) a.module = ev.module;
        a.feed.push(ev);
        if (a.feed.length > FEED_LEN) a.feed.splice(0, a.feed.length - FEED_LEN);
        a.steps.push(ev);
        if (a.steps.length > RIB_LEN) a.steps.splice(0, a.steps.length - RIB_LEN);

        if (ev.kind === 'task' && ev.status === 'run') {
            // Новая задача — вердикт с чистого листа. Но постоянные беды так не
            // лечатся: спам-блок держится сутками, и аккаунт под ограничением
            // не должен зеленеть от того, что на нём запустили что-то ещё.
            var meta = state.meta[ev.phone] || {};
            a.verdict = (meta.spam_blocked || meta.dead || meta.frozen) ? 'blocked' : 'run';
            a.steps = [ev];
            a.feed = [ev];
            var m = /(\d+)\s/.exec(ev.label || '');
            a.total = m ? parseInt(m[1], 10) : 0;
            a.done = 0;
        } else {
            a.verdict = worst(a.verdict === 'idle' ? 'ok' : a.verdict, ev.status);
        }
        if (ev.kind === 'send' || ev.kind === 'join') a.done += 1;
        a.now = ev.label + (ev.target ? ' · ' + ev.target : '');
    }

    function ribbon(steps) {
        var out = '', pad = RIB_LEN - steps.length, i;
        for (i = 0; i < pad; i++) out += '<b style="height:6%;opacity:.25"></b>';
        var maxMs = 1;
        steps.forEach(function (s) { if (s.ms && s.ms > maxMs) maxMs = s.ms; });
        steps.forEach(function (s) {
            // Высота нормирована на максимум В ЭТОЙ ленте: у одного акка ответы
            // по 200 мс, у другого по 3 с — обе ленты читаются одинаково.
            var h = s.ms ? Math.max(8, Math.round(s.ms / maxMs * 100)) : 30;
            out += '<b class="' + (s.status === 'ok' ? '' : esc(s.status)) + '" style="height:' + h + '%"></b>';
        });
        return out;
    }

    function cardHtml(a) {
        var meta = state.meta[a.phone] || {};
        var v = a.verdict;
        var prog = a.total ? (a.done + ' из ' + a.total) : (a.done ? a.done + ' ' + plural(a.done, 'шаг', 'шага', 'шагов') : '—');
        var perm = meta.dead ? 'слетел' : meta.frozen ? 'заморожен'
                 : meta.spam_blocked ? 'спам-блок' : '';
        return '<div class="am-top"><span class="am-phone">' + esc(a.phone) + '</span>' +
               '<span class="am-badge am-b-' + esc(v) + '">' + esc(LABEL[v] || v) + '</span></div>' +
               '<div class="am-meta">' + esc(a.module || '—') +
               (meta.device ? ' · ' + esc(meta.device) : '') +
               (perm ? ' · <span style="color:#ef7d7d">' + esc(perm) + '</span>' : '') + '</div>' +
               '<div class="am-now">' + esc(a.now || '') + '</div>' +
               '<div class="am-rib">' + ribbon(a.steps) + '</div>' +
               '<div class="am-foot"><span>' + esc(prog) + '</span><span>' +
               esc(meta.proxy || 'без прокси') + '</span></div>';
    }

    function group(v) { return (v === 'blocked' || v === 'error') ? 'bad' : (v === 'idle' ? 'idle' : 'work'); }

    function renderGrid() {
        var grid = document.getElementById('accmon-grid');
        var empty = document.getElementById('accmon-empty');
        if (!grid) return;
        // Сначала те, у кого проблема, потом активные, потом остальные —
        // затык не должен уезжать вниз за сотней спокойных карточек.
        var list = state.order.slice().sort(function (x, y) {
            var dv = (RANK[state.accs[y].verdict] || 0) - (RANK[state.accs[x].verdict] || 0);
            return dv !== 0 ? dv : (state.accs[y].last - state.accs[x].last);
        });
        var shown = 0, counts = { all: list.length, work: 0, bad: 0 };
        list.forEach(function (p) { counts[group(state.accs[p].verdict)] = (counts[group(state.accs[p].verdict)] || 0) + 1; });

        grid.innerHTML = list.map(function (p) {
            var a = state.accs[p], g = group(a.verdict);
            if (state.filter !== 'all' && g !== state.filter) return '';
            shown++;
            return '<button type="button" class="am-card' + (state.sel === p ? ' sel' : '') +
                   '" data-v="' + esc(a.verdict) + '" data-p="' + esc(p) + '">' + cardHtml(a) + '</button>';
        }).join('');

        if (empty) empty.style.display = list.length ? 'none' : '';
        document.querySelectorAll('#accmon-toolbar .am-chip').forEach(function (ch) {
            var c = ch.querySelector('.am-cnt');
            if (c) c.textContent = counts[ch.dataset.f] || 0;
        });
        grid.querySelectorAll('.am-card').forEach(function (el) {
            el.addEventListener('click', function () { state.sel = el.dataset.p; renderGrid(); renderStage(); });
        });
    }

    function bubble(ev) {
        var t = hhmm(ev.ts);
        if (ev.kind === 'send' || ev.kind === 'join') {
            var bad = (ev.status === 'blocked' || ev.status === 'error');
            var name = ev.target || (ev.kind === 'join' ? 'ссылка' : 'чат');
            return '<div class="am-row"><div class="am-bub in"><span class="am-nm">' + esc(name) + '</span>' +
                   esc(ev.label) + '<div class="am-bmeta">' + t + '</div></div></div>' +
                   (bad ? '<div class="am-sys bad">' + esc(ev.label) + '</div>' : '');
        }
        if (ev.kind === 'connect') {
            return '<div class="am-sys' + (ev.status === 'slow' ? '' : '') + '">' + esc(ev.label) +
                   (ev.ms ? ' · ' + ev.ms + ' мс' : '') + '</div>';
        }
        var cls = ev.status === 'blocked' || ev.status === 'error' ? ' bad'
                : ev.status === 'ok' && ev.kind === 'task' ? ' good' : '';
        return '<div class="am-sys' + cls + '">' + esc(ev.label) + '</div>';
    }

    function renderStage() {
        var stage = document.getElementById('accmon-stage');
        if (!stage) return;
        if (!state.sel || !state.accs[state.sel]) { stage.style.display = 'none'; return; }
        stage.style.display = '';
        var a = state.accs[state.sel], meta = state.meta[a.phone] || {};

        document.getElementById('accmon-ava').textContent = (a.module || '?').slice(0, 2);
        document.getElementById('accmon-title').textContent =
            (a.feed.length && a.feed[a.feed.length - 1].target) || a.module || 'аккаунт';
        document.getElementById('accmon-sub').textContent =
            (a.module || '').toLowerCase() + ' · ' + a.phone;
        document.getElementById('accmon-rt').innerHTML =
            a.last ? ('последнее событие<br>' + hhmm(a.last)) : '';

        var chat = document.getElementById('accmon-chat');
        chat.innerHTML = a.feed.slice(-14).map(bubble).join('');
        chat.scrollTop = chat.scrollHeight;

        var steps = a.steps.slice(-STEP_LEN);
        var maxMs = 1;
        steps.forEach(function (s) { if (s.ms && s.ms > maxMs) maxMs = s.ms; });
        document.getElementById('accmon-steps').innerHTML = steps.map(function (s) {
            var h = s.ms ? Math.max(10, Math.round(s.ms / maxMs * 100)) : 34;
            var lbl = s.label + (s.ms ? ' · ' + s.ms + ' мс' : '');
            return '<span class="am-stp" data-v="' + esc(s.status) + '" data-l="' + esc(lbl) +
                   '" style="height:' + h + '%"></span>';
        }).join('');
        document.getElementById('accmon-cap').textContent = 'последние ' + steps.length + ' шагов';

        document.getElementById('accmon-verd').innerHTML =
            '<div class="am-verd ' + esc(a.verdict) + '">' + esc(VERD[a.verdict] || a.verdict) + '</div>';

        var kv = [['Модуль', a.module || '—'], ['Устройство', meta.device || '—'],
                  ['Прокси', meta.proxy || 'без прокси'], ['Папка', meta.folder || '—'],
                  ['Пройдено', a.total ? (a.done + ' из ' + a.total) : String(a.done)]];
        document.getElementById('accmon-kv').innerHTML = kv.map(function (p) {
            return '<dt>' + esc(p[0]) + '</dt><dd>' + esc(p[1]) + '</dd>';
        }).join('');
    }

    // Началась другая работа — всё накопленное относится к прошлой. Без сброса
    // монитор показывал прогресс позапрошлой рассылки на аккаунтах, которых в
    // новой задаче нет, и складывал два прогона в один счётчик («88 из 66»).
    function startedNewRun(runId) {
        state.accs = {};
        state.order = [];
        state.sel = null;
        state.seq = 0;
        state.run = runId;
        state.tickN = 0;      // следующий опрос попросит свежий список аккаунтов
        renderGrid();
        renderStage();
    }

    function tick() {
        if (typeof _currentTab !== 'undefined' && _currentTab !== 'accmon') { stopAccmon(); return; }
        if (document.hidden) return;
        // Раз в ~15 секунд просим заодно свежий список аккаунтов: они
        // подгружаются уже после открытия вкладки, и без этого сетка застыла бы
        // на том, что было в момент открытия.
        state.tickN = (state.tickN || 0) + 1;
        var full = (state.tickN % 10 === 1) ? '&full=1' : '';
        fetch('/api/accmon/stream?since=' + state.seq + full, { credentials: 'same-origin' })
            .then(function (r) {
                // Код ответа запоминаем: «нет связи» без него означало сразу всё —
                // и отказ в доступе, и обрыв, и ошибку сервера, — и один и тот же
                // текст на скриншоте не давал понять, что чинить.
                state.httpCode = r.status;
                return r.ok ? r.json() : null;
            })
            .then(function (d) {
                if (!d || !d.ok) {
                    live('нет связи с софтом' + (state.httpCode ? ' (код ' + state.httpCode + ')' : ''), false);
                    return;
                }
                if (typeof d.run === 'number' && state.run !== undefined && d.run !== state.run) {
                    startedNewRun(d.run);
                    return;
                }
                state.run = d.run;
                if (d.accounts) {
                    // Карточку заводим на КАЖДЫЙ аккаунт сразу, не дожидаясь события.
                    // Иначе пустая сетка означала сразу три разных вещи — «тихо»,
                    // «аккаунты не подгрузились» и «монитор сломан», — и отличить
                    // их было нельзя.
                    var alive = {};
                    d.accounts.forEach(function (m) {
                        alive[m.phone] = 1;
                        state.meta[m.phone] = m;
                        var a = acc(m.phone);
                        if (m.dead || m.frozen || m.spam_blocked) a.verdict = 'blocked';
                    });
                    // Аккаунт удалили или перенесли — карточка должна уйти вместе с
                    // ним. Раньше сетка помнила всё, что видела: у клиента висело
                    // 136 карточек при четырёх живых аккаунтах.
                    state.order = state.order.filter(function (p) {
                        if (alive[p]) return true;
                        delete state.accs[p];
                        delete state.meta[p];
                        if (state.sel === p) state.sel = null;
                        return false;
                    });
                    state.known = d.accounts.length;
                }
                (d.events || []).forEach(apply);
                if (d.max_seq) state.seq = d.max_seq;
                live(state.known
                        ? 'слежу за ' + state.known + ' аккаунтами'
                        : 'аккаунты не загружены — откройте «Аккаунты»', true);
                // Перерисовываем ТОЛЬКО когда есть что менять. Безусловный
                // renderGrid на каждом тике — это перестроение сотен карточек
                // через innerHTML дважды в секунду: тот самый обвал интерфейса,
                // ради которого сервер и не агрегирует карточки.
                if ((d.events || []).length || d.accounts) { renderGrid(); renderStage(); }
            })
            .catch(function () { live('нет связи с софтом', false); });
    }

    window.startAccmon = function () {
        stopAccmon();
        state.seq = 0;
        tick();
        state.timer = setInterval(tick, POLL_MS);
        document.querySelectorAll('#accmon-toolbar .am-chip').forEach(function (ch) {
            ch.onclick = function () {
                document.querySelectorAll('#accmon-toolbar .am-chip').forEach(function (o) {
                    o.setAttribute('aria-pressed', 'false');
                });
                ch.setAttribute('aria-pressed', 'true');
                state.filter = ch.dataset.f;
                renderGrid();
            };
        });
    };

    window.stopAccmon = function () {
        if (state.timer) { clearInterval(state.timer); state.timer = null; }
    };
})();

// ══════════════════════════════════════════════════════════════════
//  Живая поддержка: кружок в углу → чат с оператором
//
//  Отдельно от ИИ-помощника: тот отвечает сам, этот зовёт живого человека.
//  Оба живут в правом нижнем углу, поэтому позицию считает _tpDockSlot():
//  открытые окна встают друг над другом и никогда не перекрываются.
// ══════════════════════════════════════════════════════════════════

var _spTicketId = 0;
var _spSince = 0;
var _spTimer = null;
var _spSupportWasOnline = null;

// Окна в углу не должны наезжать друг на друга: считаем свободный уровень.
function _tpDockSlot(self) {
    var order = ['ai-chat', 'sp-chat'];
    var offset = 0;
    for (var i = 0; i < order.length; i++) {
        if (order[i] === self) break;
        var el = document.getElementById(order[i]);
        if (el) offset += el.offsetWidth + 12;
    }
    return offset;
}

function _tpRedock() {
    var sp = document.getElementById('sp-chat');
    // Окно, которое человек передвинул сам, автоматика больше не двигает —
    // иначе оно прыгало бы обратно при каждом открытии соседнего.
    if (sp && sp.dataset.moved !== '1') sp.style.right = (20 + _tpDockSlot('sp-chat')) + 'px';
    var btn = document.getElementById('sp-launcher');
    if (btn) btn.style.display = sp ? 'none' : 'flex';
}

// Окна в углу можно таскать за шапку и тянуть за правый нижний угол.
// Положение и размер помним: подобрал раз — и больше не подбираешь.
function _tpMakeMovable(el, handle, key) {
    if (!el || !handle) return;
    el.style.resize = 'both';
    el.style.overflow = 'hidden';
    if (!el.style.minWidth) el.style.minWidth = '300px';
    if (!el.style.minHeight) el.style.minHeight = '320px';

    var saved = null;
    try { saved = JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { saved = null; }
    if (saved && saved.w) {
        el.style.width = saved.w + 'px';
        el.style.height = saved.h + 'px';
    }
    if (saved && typeof saved.x === 'number') {
        // Окно могло остаться за границей после смены разрешения — возвращаем.
        var x = Math.min(Math.max(0, saved.x), Math.max(0, window.innerWidth - 120));
        var y = Math.min(Math.max(0, saved.y), Math.max(0, window.innerHeight - 60));
        el.style.left = x + 'px';
        el.style.top = y + 'px';
        el.style.right = 'auto';
        el.style.bottom = 'auto';
        el.dataset.moved = '1';
    }

    var _saveTimer = null;
    function remember() {
        // Растягивание сыплет событиями десятками в секунду, а запись в
        // localStorage синхронная. Пишем один раз, когда человек остановился.
        if (_saveTimer) clearTimeout(_saveTimer);
        _saveTimer = setTimeout(function() {
            _saveTimer = null;
            var r = el.getBoundingClientRect();
            var box = {w: Math.round(r.width), h: Math.round(r.height)};
            if (el.dataset.moved === '1') { box.x = Math.round(r.left); box.y = Math.round(r.top); }
            try { localStorage.setItem(key, JSON.stringify(box)); } catch (e) {}
        }, 300);
    }

    handle.style.cursor = 'move';
    // Указатель, а не мышь: с захватом браузер гарантированно отдаст нам
    // pointerup даже если отпустили за пределами окна. На mouseup такое
    // событие не приходит вовсе, и окно продолжало ездить за курсором.
    handle.addEventListener('pointerdown', function(ev) {
        // Крестик и кнопки в шапке не должны таскать окно.
        if (ev.target.closest('button, input, select, a')) return;
        if (ev.button !== 0) return;
        ev.preventDefault();
        var r = el.getBoundingClientRect();
        var dx = ev.clientX - r.left, dy = ev.clientY - r.top;
        el.style.right = 'auto';
        el.style.bottom = 'auto';
        el.dataset.moved = '1';
        try { handle.setPointerCapture(ev.pointerId); } catch (e) {}

        function move(e) {
            var x = Math.min(Math.max(0, e.clientX - dx), window.innerWidth - 60);
            var y = Math.min(Math.max(0, e.clientY - dy), window.innerHeight - 40);
            el.style.left = x + 'px';
            el.style.top = y + 'px';
        }
        function up(e) {
            handle.removeEventListener('pointermove', move);
            handle.removeEventListener('pointerup', up);
            handle.removeEventListener('pointercancel', up);
            try { handle.releasePointerCapture(e.pointerId); } catch (err) {}
            remember();
        }
        handle.addEventListener('pointermove', move);
        handle.addEventListener('pointerup', up);
        handle.addEventListener('pointercancel', up);
    });

    try {
        var ro = new ResizeObserver(function() { remember(); });
        ro.observe(el);
    } catch (e) {}
}

function _spEsc(t) {
    return String(t == null ? '' : t)
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// Разделы софта. Коды совпадают с теми, по которым поддержка достаёт логи —
// иначе выбор клиента ни на что не влиял бы.
var _SP_MODULES = [
    ['spam', 'Рассылка по чатам'], ['dm', 'Рассылка по ЛС'],
    ['join', 'Вступление в чаты'], ['invite', 'Приглашения'],
    ['parser', 'Парсер'], ['warmup', 'Прогрев'],
    ['monitor', 'Мониторинг'], ['profiles', 'Профили'],
    ['autoreply', 'Автоответчик'], ['checker', 'Проверка'],
    ['proxy', 'Прокси'], ['accounts', 'Аккаунты'],
    ['web', 'Интерфейс']
];

function _spModuleOptions(sel) {
    var out = '';
    for (var i = 0; i < _SP_MODULES.length; i++) {
        out += '<option value="' + _SP_MODULES[i][0] + '"'
            + (_SP_MODULES[i][0] === sel ? ' selected' : '') + '>'
            + _SP_MODULES[i][1] + '</option>';
    }
    return out + '<option value="other"' + (sel === 'other' ? ' selected' : '') + '>Другое</option>';
}

// Раздел, выбранный человеком, важнее угаданного по вкладке: стоять можно
// в парсере, а сломаться может рассылка.
// Имя спрашиваем один раз и помним: звать человека по номеру телефона —
// так себе поддержка.
function spSetName(inp) {
    var v = (inp && inp.value || '').trim();
    API.post('/api/support/name', {name: v}).catch(function() {});
}

function spSetModule(sel) {
    if (!sel || !_spTicketId) return;
    API.post('/api/support/module', {ticket_id: _spTicketId, module: sel.value})
        .catch(function() {});
}

// Куда клиент смотрел, когда решил написать: раздел подставляем сами, чтобы
// не спрашивать «а где это было» перепиской.
function _spCurrentModule() {
    var sec = document.querySelector('.section.active');
    var id = sec ? (sec.id || '') : '';
    return id.replace(/^tab-/, '') || 'other';
}

function _spCurrentModuleTitle() {
    var sec = document.querySelector('.section.active');
    var t = sec ? sec.querySelector('.ch-title') : null;
    return t ? t.textContent.trim() : '';
}

function tpSupportLauncher() {
    if (document.getElementById('sp-launcher')) return;
    var b = document.createElement('button');
    b.id = 'sp-launcher';
    b.type = 'button';
    b.title = 'Написать в поддержку';
    b.className = 'sp-launcher';
    b.innerHTML = '<i class="bi bi-headset"></i><span class="sp-dot" id="sp-unread" style="display:none;"></span>';
    b.onclick = toggleSupportChat;
    document.body.appendChild(b);
}

function toggleSupportChat() {
    var existing = document.getElementById('sp-chat');
    if (existing) {
        existing.remove();
        _tpRedock();
        // Опрос не глушим, а замедляем: иначе ответ поддержки не дойдёт до
        // человека, пока он сам не откроет чат заново.
        if (_spTicketId) _spStartPoll(true); else _spStopPoll();
        return;
    }

    var box = document.createElement('div');
    box.id = 'sp-chat';
    box.className = 'sp-chat';
    box.innerHTML =
        '<div class="sp-head">'
        +   '<i class="bi bi-headset"></i>'
        +   '<div class="sp-head-tx"><b>Поддержка</b><span id="sp-status">проверяю…</span></div>'
        +   '<button type="button" class="sp-close" onclick="toggleSupportChat()" aria-label="Закрыть">'
        +     '<i class="bi bi-x-lg"></i></button>'
        + '</div>'
        + '<div class="sp-body" id="sp-body"></div>'
        + '<div class="sp-mod" id="sp-name-row" style="display:none;">'
        +   '<span>Как вас зовут:</span>'
        +   '<input id="sp-name" type="text" placeholder="например, Андрей" '
        +     'autocomplete="off" onchange="spSetName(this)">'
        + '</div>'
        + '<div class="sp-mod" id="sp-mod-row" style="display:none;">'
        +   '<span>Где проблема:</span>'
        +   '<select id="sp-mod" onchange="spSetModule(this)">'
        +     _spModuleOptions(_spCurrentModule())
        +   '</select>'
        + '</div>'
        + '<div class="sp-foot" id="sp-foot" style="display:none;">'
        +   '<input type="file" id="sp-file" style="display:none;" '
        +     'accept="image/*,.txt,.log,.zip,.json,.csv,.xlsx,.tplog">'
        +   '<button type="button" class="sp-attach" aria-label="Прикрепить файл" '
        +     'onclick="document.getElementById(&quot;sp-file&quot;).click()">'
        +     '<i class="bi bi-paperclip"></i></button>'
        +   '<input id="sp-input" type="text" placeholder="Напишите сообщение…" autocomplete="off">'
        +   '<button type="button" class="sp-send" onclick="spSend()" aria-label="Отправить">'
        +     '<i class="bi bi-send"></i></button>'
        + '</div>';
    document.body.appendChild(box);
    _tpRedock();
    _tpMakeMovable(box, box.querySelector('.sp-head'), 'tp_win_support');

    // Скрепку вешаем сразу на оба состояния: до создания обращения она тоже
    // нажимается, и молчать в ответ нельзя.
    var fpick = document.getElementById('sp-file');
    if (fpick) fpick.onchange = function() { spAttach(this); };

    API.get('/api/support/name').then(function(d) {
        var ni = document.getElementById('sp-name');
        if (ni && d && d.name) ni.value = d.name;
    }).catch(function() {});

    var el = document.getElementById('sp-unread');
    if (el) el.style.display = 'none';

    API.get('/api/support/state').then(function(d) {
        _spTicketId = (d && d.ticket_id) || 0;
        if (_spTicketId) { _spOpenThread(); } else { _spGreet(); }
    }).catch(function() { _spGreet(); });
}

function _spGreet() {
    // Никаких «выберите тему»: человек открыл чат, чтобы написать, а не
    // заполнять форму. Раздел, версию и ключ прикладываем сами.
    var b = document.getElementById('sp-body');
    if (!b) return;
    var st = document.getElementById('sp-status');
    if (st) st.textContent = 'напишите, с чем помочь';
    var mod = _spCurrentModuleTitle();
    b.innerHTML = '<div class="sp-sys">Напишите, что случилось — ответим здесь же.'
        + (mod ? ('<br>Вы в разделе «' + _spEsc(mod) + '», приложим это к обращению.') : '')
        + '</div>';
    document.getElementById('sp-foot').style.display = 'flex';
    var mr = document.getElementById('sp-mod-row');
    if (mr) mr.style.display = 'flex';
    var nr2 = document.getElementById('sp-name-row');
    if (nr2) nr2.style.display = 'flex';
    var nr = document.getElementById('sp-name-row');
    if (nr) nr.style.display = 'flex';
    var inp = document.getElementById('sp-input');
    if (inp) {
        inp.focus();
        inp.onkeydown = function(e) { if (e.key === 'Enter') { e.preventDefault(); spSend(); } };
    }
}

function _spCreateTicket(text, onDone) {
    var _sel = document.getElementById('sp-mod');
    var _nm = document.getElementById('sp-name');
    API.post('/api/ticket', {
        client_name: (_nm && _nm.value || '').trim(),
        module: (_sel && _sel.value) || _spCurrentModule(),
        type: 'chat',
        subject: _spCurrentModuleTitle() || 'Обращение из чата',
        body: text
    }).then(function(d) {
        if (d && d.ticket_id) {
            _spTicketId = d.ticket_id;
            _spOpenThread(true);
            if (onDone) onDone();
        } else {
            _spFail('Не получилось создать обращение. Попробуйте ещё раз.');
        }
    }).catch(function(e) {
        _spFail((e && e.message) || 'Не получилось отправить. Проверьте интернет.');
    });
}

function _spFail(msg) {
    var b = document.getElementById('sp-body');
    if (b) b.innerHTML = '<div class="sp-sys">' + _spEsc(msg) + '</div>';
}

function _spOpenThread(fresh) {
    var b = document.getElementById('sp-body');
    if (b && !fresh) b.innerHTML = '';
    _spSystem('Обращение №' + _spTicketId + ' открыто. Ответим здесь же.');
    document.getElementById('sp-foot').style.display = 'flex';
    var mr = document.getElementById('sp-mod-row');
    if (mr) mr.style.display = 'flex';
    var inp = document.getElementById('sp-input');
    if (inp) {
        inp.focus();
        inp.onkeydown = function(e) { if (e.key === 'Enter') { e.preventDefault(); spSend(); } };
    }
    var f = document.getElementById('sp-file');
    if (f) f.onchange = function() { spAttach(this); };
    _spSince = 0;
    _spPoll();
    _spStartPoll();
}

function _spStartPoll(background) {
    _spStopPoll();
    _spTimer = setInterval(_spPoll, background ? 20000 : 4000);
}

function _spStopPoll() {
    if (_spTimer) { clearInterval(_spTimer); _spTimer = null; }
}

// Обращение потерялось (например создано прошлой версией под другим
// удостоверением): забываем его, иначе человек залипает в мёртвом тикете и
// не может написать заново — каждое сообщение уходит в никуда.
function _spDropDeadTicket(err) {
    if (!/404|не найдено/i.test(String(err || ''))) return false;
    _spTicketId = 0;
    _spSince = 0;
    API.post('/api/support/forget', {}).catch(function() {});
    var b = document.getElementById('sp-body');
    if (b) {
        b.innerHTML = '';
        _spSystem('Прошлое обращение закрыто. Напишите заново — ответим здесь же.');
    }
    return true;
}

function _spPoll(silent) {
    if (!_spTicketId) return;
    API.post('/api/support/poll', {ticket_id: _spTicketId, since: _spSince}).then(function(d) {
        if (!d || !d.ok) return;
        (d.messages || []).forEach(function(m) {
            _spSince = Math.max(_spSince, m.id);
            if (!silent) _spRender(m);
        });
        _spSetStatus(d.support || {});
        if (d.status === 'closed') {
            // Обращение закрыто — иначе панель опрашивала бы его бесконечно,
            // а человек ждал бы ответа, которого уже не будет.
            _spStopPoll();
            _spSystem('Обращение закрыто. Напишите ещё раз, если что-то осталось.');
            _spTicketId = 0;
        }
    }).catch(function() {
        _spSetStatus({offline: true});
    });
}

function _spSetStatus(sup) {
    var el = document.getElementById('sp-status');
    if (!el) return;
    if (sup.offline) { el.textContent = 'нет связи с сервером'; return; }
    if (sup.typing) { el.textContent = 'печатает…'; el.className = 'sp-on'; return; }
    if (sup.online) {
        el.textContent = (sup.name ? (sup.name + ' на связи') : 'на связи');
        el.className = 'sp-on';
    } else {
        el.textContent = 'офлайн — ответим, как только освободимся';
        el.className = '';
    }
    // Сообщаем о смене состояния строкой в переписке — иначе подключение
    // оператора проходит незаметно и клиент продолжает ждать в тишине.
    if (_spSupportWasOnline !== null && sup.online !== _spSupportWasOnline) {
        _spSystem(sup.online ? 'Оператор подключился к обращению' : 'Оператор отключился');
    }
    _spSupportWasOnline = !!sup.online;
}

function _spSystem(text) {
    var b = document.getElementById('sp-body');
    if (!b) return;
    var d = document.createElement('div');
    d.className = 'sp-sys';
    d.textContent = text;
    b.appendChild(d);
    b.scrollTop = b.scrollHeight;
}

function _spRender(m) {
    var b = document.getElementById('sp-body');
    if (!b) {
        // Чат свёрнут — рисовать некуда, но о новом ответе сказать обязаны.
        if (m.sender !== 'client') {
            var dot = document.getElementById('sp-unread');
            if (dot) dot.style.display = 'block';
        }
        return;
    }
    var mine = m.sender === 'client';
    var d = document.createElement('div');
    d.className = 'sp-msg ' + (mine ? 'sp-me' : 'sp-them');
    var who = mine ? 'Вы' : (m.author || 'Поддержка');
    d.innerHTML = '<span class="sp-who">' + _spEsc(who) + '</span>' + _spEsc(m.text);
    b.appendChild(d);
    b.scrollTop = b.scrollHeight;
}

function spAttach(input) {
    var file = input && input.files && input.files[0];
    if (!file) return;
    if (!_spTicketId) {
        // Файл без единого слова непонятен и мне, и клиенту: он жмёт скрепку
        // первым делом, а раньше в ответ не происходило ровно ничего.
        _spSystem('Сначала напишите пару слов о проблеме — потом приложите файл.');
        input.value = '';
        return;
    }
    if (file.size > 10 * 1024 * 1024) {
        _spSystem('Файл больше 10 МБ — такой не отправить');
        input.value = '';
        return;
    }
    _spSystem('Отправляю «' + file.name + '»…');
    var fd = new FormData();
    fd.append('file', file);
    fd.append('ticket_id', _spTicketId);
    fetch('/api/support/attach', {method: 'POST', body: fd})
        .then(function(r) { return r.json(); })
        .then(function(d) {
            if (d && d.ok) { _spPoll(); }
            else { _spSystem((d && d.error) || 'Файл не ушёл — попробуйте ещё раз'); }
        })
        .catch(function() { _spSystem('Файл не ушёл — проверьте интернет'); });
    input.value = '';
}

function spSend() {
    var inp = document.getElementById('sp-input');
    var txt = (inp.value || '').trim();
    if (!txt) return;
    inp.value = '';
    if (!_spTicketId) {
        // Первое сообщение и есть обращение — отдельной формы нет.
        _spCreateTicket(txt);
        return;
    }
    API.post('/api/support/say', {ticket_id: _spTicketId, text: txt}).then(function(d) {
        if (d && d.ok) { _spPoll(); return; }
        if (_spDropDeadTicket(d && d.error)) { _spCreateTicket(txt); return; }
        _spSystem((d && d.error) || 'Сообщение не ушло — попробуйте ещё раз');
    }).catch(function() {
        _spSystem('Сообщение не ушло — проверьте интернет');
    });
}

document.addEventListener('DOMContentLoaded', function() {
    setTimeout(function() {
        tpSupportLauncher();
        // Обращение могло остаться с прошлого запуска: подхватываем его и следим
        // за ответом в фоне, не заставляя человека открывать чат «на всякий случай».
        API.get('/api/support/state').then(function(d) {
            if (d && d.ticket_id) {
                _spTicketId = d.ticket_id;
                _spSince = 0;
                // Первый проход молча догоняет уже прочитанное: иначе значок
                // «новый ответ» загорался бы при каждом запуске софта.
                _spPoll(true);
                _spStartPoll(true);
            }
        }).catch(function() {});
    }, 1200);
});
