let selectedChat = null;
let filterJobId = null; // null | 'spambot' | 'telegram' | 'premium' | 'warmup' | 'bots' | number
let chatDialogSide = 'incoming'; // incoming | outgoing
let inboxJobNames = { spambot: 'SpamBot проверки', telegram: 'Telegram', premium: 'Premium Bot', warmup: 'Прогрев', bots: 'Боты' };
let allAccounts = [];
let allChats = [];
let leadSaveTimer = null;
let toastTimer = null;
let lastPulse = { event_id: 0, unread_total: 0 };
let inboxLoadReqId = 0;
let appSettings = { notify_sound: '1', notify_desktop: '1' };
let notifyReady = false;
const avatarCache = new Map(); // key -> { state: 'ok'|'fail'|'loading', url? }
const avatarLoadPromises = new Map();
const AVATAR_LOAD_PARALLEL = 3;
const AVATAR_CACHE_MAX = 300;

function enforceAvatarCacheLimit() {
  // Вытесняем самые старые записи (Map сохраняет порядок вставки), освобождая
  // blob-URL — иначе за долгую сессию накапливаются сотни живых createObjectURL.
  // Промах не страшен: аватар пере-загрузится в renderAvatar при следующем показе.
  if (avatarCache.size <= AVATAR_CACHE_MAX) return;
  let overflow = avatarCache.size - AVATAR_CACHE_MAX;
  for (const [k, ent] of avatarCache) {
    if (overflow <= 0) break;
    if (ent?.url) { try { URL.revokeObjectURL(ent.url); } catch (_) {} }
    avatarCache.delete(k);
    avatarLoadPromises.delete(k);
    overflow--;
  }
}
let _avatarLoadSlots = 0;
const _avatarLoadWait = [];
let inboxListFp = '';
let lastChatSearch = '';
let profileTemplates = [];
let profileParserBases = [];
let profileParserAccounts = [];
let profileParserImportBusy = false;
let profileActiveMode = 'local';
let profileTemplatesCollapsed = false;
let profileAssetSets = [];
const VALID_TABS = ['inbox', 'clients', 'templates', 'training', 'broadcast', 'bases', 'parsing', 'warmup', 'profiles', 'accounts', 'proxies', 'converter', 'settings'];

let selectedChatKeys = new Set();

const WARMUP_TASK_ID = -1;
const ownAccountUsernames = new Set();

function refreshOwnAccountUsernames() {
  ownAccountUsernames.clear();
  for (const a of allAccounts || []) {
    const u = (a.username || '').trim().replace(/^@/, '').toLowerCase();
    if (u) ownAccountUsernames.add(u);
  }
}

function isWarmupChat(c) {
  if (!c) return false;
  if (Number(c.broadcast_job_id) === WARMUP_TASK_ID) return true;
  const u = (c.peer_username || '').trim().replace(/^@/, '').toLowerCase();
  return !!(u && ownAccountUsernames.has(u));
}

const INBOX_MISC_BOT_USERNAMES = new Set(['wallet']);

function isBotChat(c) {
  if (!c || isWarmupChat(c) || isSpambotChat(c) || isTelegramChat(c) || isPremiumBotChat(c)) return false;
  const u = (c.peer_username || '').trim().replace(/^@/, '').toLowerCase();
  if (!u) return false;
  if (INBOX_MISC_BOT_USERNAMES.has(u)) return true;
  return u.endsWith('bot');
}

function chatKey(phone, peer) {
  return `${phone}\x00${Number(peer)}`;
}

function selectedChatKey() {
  return selectedChat ? chatKey(selectedChat.account_phone, selectedChat.peer_id) : '';
}

function getTabFromHash() {
  const h = (location.hash || '').replace(/^#/, '');
  return VALID_TABS.includes(h) ? h : 'inbox';
}

function activateTab(name, { saveHash = true } = {}) {
  const tab = VALID_TABS.includes(name) ? name : 'inbox';
  document.querySelectorAll('#sidebarNav button[data-tab]').forEach(b => {
    b.classList.toggle('active', b.dataset.tab === tab);
  });
  document.querySelectorAll('.nav-item-group').forEach(g => {
    g.classList.toggle('nav-group-active', g.querySelector('button[data-tab]')?.dataset.tab === tab);
  });
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  document.getElementById('tab-' + tab)?.classList.add('active');
  if (tab !== 'accounts' && typeof _qrStop === 'function') _qrStop();  // не оставлять QR-поллинг в фоне
  if (saveHash && location.hash !== '#' + tab) {
    history.replaceState(null, '', '#' + tab);
  }
  if (tab === 'broadcast') showBcList();
  if (tab === 'bases') { if (typeof loadBasesTab === 'function') loadBasesTab(); }
  if (tab === 'parsing') {
    (async () => {
      if (window.__ugParserReady) await window.__ugParserReady;
      if (typeof loadParserJobs === 'function') loadParserJobs();
      if (typeof startParserJobsRefresh === 'function') startParserJobsRefresh();
    })();
  }
  if (tab === 'accounts') {
    refreshMetaSelects();
    const accKey = accountsTabCacheKey();
    if (_cacheFresh(tabDataCache.accounts, TAB_DATA_TTL_MS) && tabDataCache.accounts.key === accKey && allAccounts.length) {
      renderAccountsTableBody();
      updateBulkBar();
      loadAccountsPoolUi().catch(() => loadAccountsPoolUi());
    } else {
      loadAccountsTable().then(() => loadAccountsPoolUi()).catch(() => loadAccountsPoolUi());
    }
  }
  if (tab === 'proxies') loadProxyGroups();
  if (tab === 'converter') { if (typeof initConverterTab === 'function') initConverterTab(); }
  if (tab === 'settings') loadSettings().catch(() => {});
  if (tab === 'inbox') {
    loadLeadStatuses().then(() => refreshInbox()).catch(() => refreshInbox());
  }
  if (tab === 'clients') {
    loadLeadStatuses().then(() => {
      loadClientJobFilter().then(() => {
        const ck = clientsTabCacheKey();
        if (_cacheFresh(tabDataCache.clients, TAB_DATA_TTL_MS) && tabDataCache.clients.key === ck && clientsListRows.length) {
          renderClientsGrid(clientsListRows, document.getElementById('clientStatusFilter')?.value || '');
          return;
        }
        loadClients();
      }).catch(() => loadClients());
    }).catch(() => loadClients());
  }
  if (tab === 'profiles') setProfileMode(profileActiveMode);
  if (tab === 'templates') {
    if (!_cacheFresh(tabDataCache.templates, TAB_DATA_TTL_MS)) {
      loadTemplatesTab().catch(() => {});
    }
  }
  if (tab === 'warmup') loadWarmupTab().catch(() => {});
  if (tab === 'training') loadTrainingTab().catch(() => {});
}

function accountByPhone(phone) {
  return (allAccounts || []).find(x => x.phone === phone) || null;
}

function inboxAccountTitle(phone) {
  const a = accountByPhone(phone);
  if (!a) return String(phone || '').replace('+', '');
  const name = (a.display_name || '').trim();
  if (name) return name;
  if (a.username) return '@' + a.username;
  return String(a.phone || phone || '').replace('+', '');
}

function inboxChatSortTs(c) {
  const d = (isWarmupChat(c) || isBotChat(c) || isSpambotChat(c) || isTelegramChat(c) || isPremiumBotChat(c))
    ? (c.last_incoming_date || c.last_date)
    : c.last_date;
  return d ? new Date(d).getTime() : 0;
}

function sortInboxChats(chats) {
  if (filterJobId === 'spambot' || filterJobId === 'telegram' || filterJobId === 'premium' || filterJobId === 'warmup' || filterJobId === 'bots') {
    return [...chats].sort((a, b) => inboxChatSortTs(b) - inboxChatSortTs(a));
  }
  return chats;
}

function getInboxVisibleChats() {
  const q = (document.getElementById('chatSearch')?.value || '').trim().toLowerCase();
  const hideWarmup = filterJobId !== 'warmup';
  const hideBots = filterJobId !== 'bots';
  const filtered = allChats.filter(c => {
    if (hideWarmup && isWarmupChat(c)) return false;
    if (hideBots && isBotChat(c)) return false;
    if (!q) return true;
    const hay = [
      c.peer_username, c.peer_name, c.last_incoming_text, c.last_text,
      accLabel(c.account_phone), inboxAccountTitle(c.account_phone),
    ].join(' ').toLowerCase();
    return hay.includes(q);
  });
  return sortInboxChats(filtered);
}

function chatsFromSelectedKeys() {
  return [...selectedChatKeys].map(k => {
    const i = k.indexOf('\x00');
    return { account_phone: k.slice(0, i), peer_id: Number(k.slice(i + 1)) };
  });
}

function updateChatSelUi() {
  const n = selectedChatKeys.size;
  const visible = getInboxVisibleChats();
  const visN = visible.length;
  const allSelected = visN > 0 && visible.every(c =>
    selectedChatKeys.has(chatKey(c.account_phone, c.peer_id))
  );
  ['chatSelCount', 'chatSelDelCount'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.textContent = n;
  });
  const blockCnt = document.getElementById('chatSelBlockCount');
  if (blockCnt) blockCnt.textContent = n;
  const visCnt = document.getElementById('chatVisCount');
  if (visCnt) visCnt.textContent = visN;
  const delBtn = document.getElementById('btnDeleteChats');
  if (delBtn) delBtn.disabled = n === 0;
  const readBtn = document.getElementById('btnReadChats');
  if (readBtn) readBtn.disabled = n === 0;
  const blockBtn = document.getElementById('btnBlockChats');
  if (blockBtn) blockBtn.disabled = n === 0;
  const selAllBtn = document.getElementById('btnSelectAllChats');
  if (selAllBtn) {
    selAllBtn.disabled = visN === 0;
    const label = allSelected ? 'Снять' : 'Все';
    selAllBtn.innerHTML = `${label} <span id="chatVisCount">${visN}</span>`;
  }
}

function toggleSelectAllVisibleChats() {
  const list = getInboxVisibleChats();
  if (!list.length) return;
  const allSelected = list.every(c =>
    selectedChatKeys.has(chatKey(c.account_phone, c.peer_id))
  );
  for (const c of list) {
    const k = chatKey(c.account_phone, c.peer_id);
    if (allSelected) selectedChatKeys.delete(k);
    else selectedChatKeys.add(k);
  }
  syncChatCheckboxes();
  updateChatSelUi();
}

function syncChatCheckboxes() {
  document.querySelectorAll('.chat-sel-chk').forEach(chk => {
    const k = chatKey(chk.dataset.account, chk.dataset.peer);
    chk.checked = selectedChatKeys.has(k);
  });
}

function toggleChatSelection(phone, peer, checked) {
  const k = chatKey(phone, peer);
  if (checked) selectedChatKeys.add(k);
  else selectedChatKeys.delete(k);
  updateChatSelUi();
}

const SPAMBOT_PEER_ID = 178220800;
const TELEGRAM_PEER_ID = 777000;
const PREMIUM_BOT_PEER_ID = 5317618042;

function isSpambotChat(c) {
  const u = String(c?.peer_username || '').toLowerCase();
  return u === 'spambot' || Number(c?.peer_id) === SPAMBOT_PEER_ID;
}

function isTelegramChat(c) {
  const u = String(c?.peer_username || '').toLowerCase();
  return u === 'telegram' || Number(c?.peer_id) === TELEGRAM_PEER_ID;
}

function isPremiumBotChat(c) {
  const u = String(c?.peer_username || '').toLowerCase();
  return u === 'premiumbot' || Number(c?.peer_id) === PREMIUM_BOT_PEER_ID;
}

function inboxChatPrimaryName(c) {
  if (isWarmupChat(c)) {
    const peer = (c.peer_username || c.peer_name || '').trim();
    const peerLabel = peer ? (peer.startsWith('@') ? peer : `@${peer}`) : `id${c.peer_id}`;
    return `${peerLabel} · прогрев`;
  }
  if (isSpambotChat(c) || isTelegramChat(c) || isPremiumBotChat(c)) {
    return inboxAccountTitle(c.account_phone);
  }
  const name = c.peer_name || c.peer_username || ('id' + c.peer_id);
  return c.peer_username ? '@' + c.peer_username : name;
}

function inboxSpamStatusTagHtml(phone) {
  const st = accountByPhone(phone)?.spam_status || '';
  if (st === 'limited') return '<span class="status-tag tag-warn">спамблок</span>';
  if (st === 'blocked') return '<span class="status-tag tag-bad">заблок</span>';
  if (st === 'ok') return '<span class="status-tag tag-ok">чисто</span>';
  return '';
}

function inboxChatPreview(c) {
  if (isWarmupChat(c)) {
    const prefix = c.last_is_outgoing ? 'Мы: ' : 'Они: ';
    return prefix + (c.last_text || c.last_incoming_text || '—');
  }
  if (isBotChat(c) || isSpambotChat(c) || isPremiumBotChat(c)) {
    return c.last_incoming_text || c.last_text || '—';
  }
  const prefix = c.last_is_outgoing ? 'Вы: ' : '';
  return prefix + (c.last_text || c.last_incoming_text || '—');
}

function inboxChatTime(c) {
  if (isWarmupChat(c) || isBotChat(c) || isSpambotChat(c) || isPremiumBotChat(c)) {
    return fmtTime(c.last_incoming_date || c.last_date);
  }
  return fmtTime(c.last_date);
}

function inboxFromLineHtml(c) {
  if (isWarmupChat(c)) {
    const warmTag = '<span class="status-tag bot" style="background:rgba(61,184,232,.16);color:#7ec8f0">Прогрев</span>';
    const peer = (c.peer_username || '').trim().replace(/^@/, '');
    const peerLabel = peer ? `@${peer}` : `id${c.peer_id}`;
    return `${warmTag} · ${esc(accLabel(c.account_phone))} ↔ ${esc(peerLabel)}${chatSideTagHtml(c)}`;
  }
  if (isSpambotChat(c)) {
    const spamTag = inboxSpamStatusTagHtml(c.account_phone);
    const botTag = '<span class="status-tag bot" style="background:rgba(46,213,115,.18);color:#6fdc8c">SpamBot</span>';
    return `${botTag}${spamTag ? ' · ' + spamTag : ''}${chatSideTagHtml(c)}`;
  }
  if (isTelegramChat(c)) {
    const botTag = '<span class="status-tag bot" style="background:rgba(52,152,219,.18);color:#7ec8f0">Telegram</span>';
    return `${botTag} · через ${esc(accLabel(c.account_phone))}${chatSideTagHtml(c)}`;
  }
  if (isPremiumBotChat(c)) {
    const premTag = '<span class="status-tag bot" style="background:rgba(255,193,7,.18);color:#ffd86b">Premium Bot</span>';
    const premAcc = accountByPhone(c.account_phone);
    const star = premAcc?.is_premium
      ? ' <span class="acc-premium-star" title="Аккаунт с Premium">★</span>'
      : '';
    return `${premTag}${star} · ${esc(accLabel(c.account_phone))}${chatSideTagHtml(c)}`;
  }
  if (isBotChat(c)) {
    const botTag = '<span class="status-tag bot" style="background:rgba(155,89,182,.18);color:#c39bd3">Бот</span>';
    return `${botTag} · через ${esc(accLabel(c.account_phone))}${chatSideTagHtml(c)}`;
  }
  const tag = statusTagHtml(c.lead_status || 'new');
  return `${tag} · через ${esc(accLabel(c.account_phone))}${esc(inboxJobLabel(c.broadcast_job_id, c.peer_username))}${chatSideTagHtml(c)}`;
}

function inboxJobLabel(jobId, peerUsername) {
  const un = (peerUsername || '').toLowerCase();
  if (un === 'spambot') return ' · SpamBot';
  if (un === 'telegram') return ' · Telegram';
  if (un === 'premiumbot') return ' · Premium Bot';
  if (Number(jobId) === WARMUP_TASK_ID) return ' · Прогрев';
  if (un.endsWith('bot') && !['spambot', 'telegram', 'premiumbot'].includes(un)) return ' · Бот';
  if (un === 'wallet') return ' · Бот';
  if (jobId === null || jobId === undefined || jobId === '') return '';
  const name = inboxJobNames[jobId];
  return name ? ` · ${name}` : ` · #${jobId}`;
}

function applyInboxJobFilter(val) {
  if (!val) filterJobId = null;
  else if (val === 'spambot') filterJobId = 'spambot';
  else if (val === 'telegram') filterJobId = 'telegram';
  else if (val === 'premium') filterJobId = 'premium';
  else if (val === 'warmup') filterJobId = 'warmup';
  else if (val === 'bots') filterJobId = 'bots';
  else filterJobId = Number(val);
}

function chatSideTagHtml(c) {
  if (chatDialogSide !== 'outgoing') return '';
  const had = Number(c.incoming_count) > 0;
  return had
    ? '<span class="chat-side-tag wait">ждёт ответа</span>'
    : '<span class="chat-side-tag cold">без ответа</span>';
}

function setChatDialogSide(side) {
  chatDialogSide = side === 'outgoing' ? 'outgoing' : 'incoming';
  localStorage.setItem('inboxDialogSide', chatDialogSide);
  document.getElementById('chatSideIncoming')?.classList.toggle('active', chatDialogSide === 'incoming');
  document.getElementById('chatSideOutgoing')?.classList.toggle('active', chatDialogSide === 'outgoing');
}

function initChatDialogSide() {
  const saved = localStorage.getItem('inboxDialogSide');
  if (saved === 'outgoing') setChatDialogSide('outgoing');
  else if (saved === 'incoming') setChatDialogSide('incoming');
  else {
    // миграция со старой галочки «только ответы»
    const legacy = localStorage.getItem('repliesOnly');
    setChatDialogSide(legacy === '0' ? 'outgoing' : 'incoming');
  }
}

function normalizeJobsList(data) {
  if (Array.isArray(data)) return data;
  if (data && Array.isArray(data.tasks)) return data.tasks;
  if (data && Array.isArray(data.jobs)) return data.jobs;
  return [];
}

async function loadInboxJobFilter() {
  const sel = document.getElementById('chatJobFilter');
  if (!sel) return;
  let jobs = [];
  try {
    jobs = normalizeJobsList(await j('/api/inbox/tasks'));
  } catch (_) {
    try {
      jobs = normalizeJobsList(await j('/api/broadcast/jobs'));
    } catch (e2) {
      jobs = [];
    }
  }
  inboxJobNames = { spambot: 'SpamBot проверки', telegram: 'Telegram', premium: 'Premium Bot', warmup: 'Прогрев', bots: 'Боты', 0: 'SpamBot проверки' };
  const saved = localStorage.getItem('inboxJobFilter') || sel.value || '';
  const visibleJobs = jobs.filter(j =>
    Number(j.chat_count) > 0 || String(j.id) === saved
  );
  sel.innerHTML = '<option value="">Все рассылки</option>'
    + '<option value="spambot">SpamBot проверки</option>'
    + '<option value="telegram">Telegram</option>'
    + '<option value="premium">Premium Bot</option>'
    + '<option value="warmup">Прогрев</option>'
    + '<option value="bots">Боты</option>'
    + visibleJobs.map(j => {
      const name = j.name || ('Задача #' + j.id);
      inboxJobNames[j.id] = name;
      const prefix = j.status === 'running' ? '▶ ' : '';
      const cnt = Number(j.chat_count) > 0 ? ` · ${j.chat_count}` : '';
      return `<option value="${j.id}">${esc(prefix + name + cnt)}</option>`;
    }).join('');
  if (saved && [...sel.options].some(o => o.value === saved)) sel.value = saved;
  applyInboxJobFilter(sel.value);
}

async function markSelectedChatsRead() {
  if (!selectedChatKeys.size) return;
  const chats = chatsFromSelectedKeys();
  try {
    const r = await j('/api/chats/bulk-read', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ chats }),
    });
    selectedChatKeys.clear();
    updateChatSelUi();
    syncChatCheckboxes();
    inboxListFp = '';
    await loadInboxChats(true);
    await refreshStats();
    showToast(`Прочитано: ${r.read || chats.length}`);
  } catch (e) {
    showToast(e.message || 'Не удалось пометить прочитанным', 'err');
  }
}

async function blockSelectedChats() {
  if (!selectedChatKeys.size) return;
  const chats = chatsFromSelectedKeys();
  const n = chats.length;
  const word = n === 1 ? 'собеседника' : 'собеседников';
  if (!confirm(`Заблокировать ${n} ${word} в Telegram? Нужные аккаунты поднимутся автоматически.`)) return;
  const btn = document.getElementById('btnBlockChats');
  if (btn) { btn.disabled = true; btn.textContent = 'Блокирую…'; }
  try {
    const r = await j('/api/chats/bulk-block', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ chats }),
    });
    selectedChatKeys.clear();
    updateChatSelUi();
    syncChatCheckboxes();
    inboxListFp = '';
    await loadInboxChats(true);
    let msg = `Заблокировано: ${r.blocked || 0}`;
    if (r.failed) msg += ` · не удалось: ${r.failed}`;
    showToast(msg, r.failed && !r.blocked ? 'err' : 'ok');
    if (r.failed && r.error) showToast(r.error, 'err');
  } catch (e) {
    showToast(e.message || 'Не удалось заблокировать', 'err');
  } finally {
    if (btn) btn.innerHTML = 'Блок <span id="chatSelBlockCount">0</span>';
    updateChatSelUi();
  }
}

let _deleteChatsResolve = null;

function inboxActiveJobContext() {
  if (typeof filterJobId === 'number' && filterJobId > 0) {
    return { jobId: filterJobId, jobName: inboxJobNames[filterJobId] || `Задача #${filterJobId}` };
  }
  return null;
}

function showDeleteChatsConfirm({ count, jobId, jobName, defaultDeleteJob = false }) {
  return new Promise(resolve => {
    const n = Number(count) || 0;
    const word = n === 1 ? 'диалог' : n < 5 ? 'диалога' : 'диалогов';
    document.getElementById('modalDeleteChatsText').textContent =
      `Удалить ${n} ${word} из CRM?`;
    const jobRow = document.getElementById('modalDeleteJobRow');
    const jobHint = document.getElementById('modalDeleteJobHint');
    const jobChk = document.getElementById('modalDeleteJobToo');
    if (jobId && jobName) {
      jobRow.hidden = false;
      jobHint.hidden = false;
      document.getElementById('modalDeleteJobLabel').textContent =
        `Также удалить задачу «${jobName}» из рассылок`;
      jobChk.checked = !!defaultDeleteJob;
    } else {
      jobRow.hidden = true;
      jobHint.hidden = true;
      jobChk.checked = false;
    }
    openModal('modalDeleteChats');
    _deleteChatsResolve = resolve;
  });
}

function finishDeleteChatsConfirm(result) {
  const resolve = _deleteChatsResolve;
  _deleteChatsResolve = null;
  const ov = document.getElementById('modalOverlay');
  if (ov) ov.hidden = true;
  document.querySelectorAll('#modalOverlay .modal').forEach(m => { m.hidden = true; });
  document.body.classList.remove('modal-open');
  if (resolve) resolve(result);
}

document.getElementById('modalDeleteChatsConfirm')?.addEventListener('click', () => {
  const jobChk = document.getElementById('modalDeleteJobToo');
  const jobRow = document.getElementById('modalDeleteJobRow');
  finishDeleteChatsConfirm({
    confirmed: true,
    deleteJob: !!(jobRow && !jobRow.hidden && jobChk?.checked),
  });
});

async function performChatDeletion(chats, { deleteJobId = null } = {}) {
  const body = { chats };
  if (deleteJobId) body.delete_job_id = deleteJobId;
  const r = await j('/api/chats/bulk-delete', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const curKey = selectedChat ? chatKey(selectedChat.account_phone, selectedChat.peer_id) : '';
  if (curKey && chats.some(c =>
    c.account_phone === selectedChat.account_phone && Number(c.peer_id) === Number(selectedChat.peer_id)
  )) clearOpenChat();
  selectedChatKeys.clear();
  updateChatSelUi();
  inboxListFp = '';
  if (deleteJobId && r.job_deleted) {
    filterJobId = null;
    localStorage.removeItem('inboxJobFilter');
    await loadInboxJobFilter();
    if (typeof loadBroadcastJobs === 'function') loadBroadcastJobs();
  }
  await loadInboxChats(true);
  let msg = `Удалено диалогов: ${r.deleted || 0}`;
  if (r.job_deleted) msg += ' · задача удалена';
  showToast(msg);
  return r;
}

async function deleteSelectedChats() {
  if (!selectedChatKeys.size) return;
  const chats = chatsFromSelectedKeys();
  const jobCtx = inboxActiveJobContext();
  const visible = getInboxVisibleChats();
  const deletingAllVisible = visible.length > 0 && visible.every(c =>
    selectedChatKeys.has(chatKey(c.account_phone, c.peer_id))
  );
  const opts = await showDeleteChatsConfirm({
    count: chats.length,
    jobId: jobCtx?.jobId,
    jobName: jobCtx?.jobName,
    defaultDeleteJob: !!(jobCtx && deletingAllVisible),
  });
  if (!opts?.confirmed) return;
  try {
    await performChatDeletion(chats, {
      deleteJobId: opts.deleteJob && jobCtx ? jobCtx.jobId : null,
    });
  } catch (e) {
    showToast(e.message || 'Не удалось удалить', 'err');
  }
}

// === Вкладка «Базы получателей» ===
let _basesAll = [];

function baseSourceLabel(src) {
  const s = (src || 'manual').toLowerCase();
  return {
    parser: 'Парсинг', contacts: 'Контакты', phones: 'Номера',
    manual: 'Ручная', base: 'База', username: 'Username',
  }[s] || s;
}

async function loadBasesTab() {
  const list = document.getElementById('basesList');
  if (!list) return;
  list.innerHTML = '<div class="bc-jobs-loading"><span class="status-spinner"></span>Загрузка баз...</div>';
  try {
    const data = await j('/api/broadcast/bases');
    _basesAll = data.bases || [];
    renderBasesFiltered();
  } catch (e) {
    list.innerHTML = `<div class="hint" style="color:var(--red)">${esc(e.message || 'Ошибка загрузки')}</div>`;
  }
}

function renderBasesFiltered() {
  const q = (document.getElementById('basesSearch')?.value || '').trim().toLowerCase();
  const src = (document.getElementById('basesSourceFilter')?.value || '').toLowerCase();
  let rows = _basesAll;
  if (src) rows = rows.filter(b => (b.source || 'manual').toLowerCase() === src);
  if (q) rows = rows.filter(b => String(b.name || '').toLowerCase().includes(q));
  renderBasesList(rows);
}

function renderBasesList(bases) {
  const list = document.getElementById('basesList');
  if (!list) return;
  if (!bases.length) {
    const hasAny = _basesAll.length > 0;
    list.innerHTML = `<div class="bases-empty">${hasAny
      ? 'Ничего не найдено по фильтру.'
      : 'Баз пока нет. Создай базу по номерам/username кнопкой «+ Создать базу» или собери из контактов аккаунта (вкладка «Рассылка» → «Из контактов»).'}</div>`;
    return;
  }
  list.innerHTML = bases.map(b => {
    const id = b.id;
    const name = esc(b.name || ('База #' + id));
    const cnt = Number(b.item_count || 0);
    const src = baseSourceLabel(b.source);
    const srcRaw = esc((b.source || 'manual'));
    const when = String(b.created_at || '').slice(0, 16).replace('T', ' ');
    return `<div class="base-card" data-base-id="${id}">
      <div class="base-card-main">
        <div class="base-card-name">${name}</div>
        <div class="base-card-meta"><span class="base-badge base-badge--${srcRaw}">${esc(src)}</span> · ${cnt} получателей${when ? ' · ' + esc(when) : ''}</div>
      </div>
      <div class="base-card-actions">
        <button type="button" class="bc-tb-btn" onclick="useBaseInBroadcast(${id})" title="Создать рассылку по этой базе">В рассылку</button>
        ${srcRaw === 'parser'
          ? `<button type="button" class="bc-tb-btn" onclick="viewBaseClients(${id})" title="Спарсенные с AI-анализом (без аватарок)">Клиенты (AI)</button>
        <span class="base-export-wrap">
          <button type="button" class="bc-tb-btn" onclick="toggleBaseExportMenu(event, ${id})" title="Выгрузить клиентов">Экспорт ▾</button>
          <span class="base-export-menu" hidden>
            <button type="button" class="base-export-opt" onclick="exportBaseClients(${id},'txt')">Теги .txt</button>
            <button type="button" class="base-export-opt" onclick="exportBaseClients(${id},'csv')">Таблица .csv (Excel)</button>
          </span>
        </span>`
          : `<button type="button" class="bc-tb-btn" onclick="viewBaseItems(${id})">Состав</button>
        <span class="base-export-wrap">
          <button type="button" class="bc-tb-btn" onclick="toggleBaseExportMenu(event, ${id})" title="Выгрузить получателей">Экспорт ▾</button>
          <span class="base-export-menu" hidden>
            <button type="button" class="base-export-opt" onclick="exportBaseItems(${id},'txt')">Список .txt</button>
            <button type="button" class="base-export-opt" onclick="exportBaseItems(${id},'csv')">Таблица .csv (Excel)</button>
          </span>
        </span>`}
        <button type="button" class="bc-tb-btn" onclick="renameBase(${id})">Переименовать</button>
        <button type="button" class="bc-tb-btn base-del" onclick="deleteBase(${id})">Удалить</button>
      </div>
    </div>`;
  }).join('');
}

function useBaseInBroadcast(id) {
  try { sessionStorage.setItem('bcPreselectBaseId', String(id)); } catch (_) {}
  activateTab('broadcast');
  if (typeof showCreateBroadcastModal === 'function') {
    setTimeout(() => {
      try {
        showCreateBroadcastModal();
        setTimeout(() => {
          if (typeof setBroadcastSource === 'function') setBroadcastSource('parsed');
          const sel = document.getElementById('bcParsedBase');
          if (sel) sel.value = String(id);
        }, 400);
      } catch (_) {}
    }, 150);
  }
}

function onBaseCreateSourceChange() {
  const src = (document.getElementById('baseCreateSource')?.value) || 'phones';
  const ta = document.getElementById('baseCreateTargets');
  if (!ta) return;
  if (src === 'manual') {
    ta.placeholder = 'По одному получателю на строку:\n@username\nhttps://t.me/username';
  } else {
    ta.placeholder = 'По одному получателю на строку:\n+79161234567\n+79031234567';
  }
}

function toggleBaseCreateForm() {
  const f = document.getElementById('baseCreateForm');
  if (!f) return;
  const opening = !f.classList.contains('open');
  f.classList.toggle('open', opening);
  if (opening) {
    document.getElementById('baseCreateName').value = '';
    document.getElementById('baseCreateTargets').value = '';
    document.getElementById('baseCreateInfo').textContent = '';
    resetBaseMergePicker();
    onBaseCreateSourceChange();
    document.getElementById('baseCreateName').focus();
  }
}

function resetBaseMergePicker() {
  const picker = document.getElementById('baseMergePicker');
  const label = document.getElementById('baseMergeToggleLabel');
  const cnt = document.getElementById('baseMergeCount');
  const info = document.getElementById('baseMergeInfo');
  if (picker) { picker.hidden = true; picker.innerHTML = ''; }
  if (label) label.textContent = '➕ Объединить существующие базы';
  if (cnt) { cnt.hidden = true; cnt.textContent = '0'; }
  if (info) { info.hidden = true; info.textContent = ''; }
}

function toggleBaseMergePicker() {
  const picker = document.getElementById('baseMergePicker');
  if (!picker) return;
  if (picker.hidden) {
    renderBaseMergePicker();
    picker.hidden = false;
  } else {
    picker.hidden = true;
  }
}

function renderBaseMergePicker() {
  const picker = document.getElementById('baseMergePicker');
  if (!picker) return;
  if (!_basesAll.length) {
    picker.innerHTML = '<div class="hint" style="padding:8px;">Баз пока нет.</div>';
    return;
  }
  picker.innerHTML = _basesAll.map(b => {
    const id = b.id;
    const name = esc(b.name || ('База #' + id));
    const src = esc(baseSourceLabel(b.source));
    const cnt = Number(b.item_count || 0);
    return `<label class="base-merge-item">
      <input type="checkbox" class="base-merge-chk" value="${id}" onchange="updateBaseMergeCount()">
      <span class="base-merge-name">${name}</span>
      <span class="base-merge-meta">${src} · ${cnt}</span>
    </label>`;
  }).join('');
}

let _baseMergePreviewTimer = null;
function updateBaseMergeCount() {
  const ids = _selectedMergeBaseIds();
  const cnt = document.getElementById('baseMergeCount');
  const info = document.getElementById('baseMergeInfo');
  if (cnt) { cnt.hidden = ids.length === 0; cnt.textContent = String(ids.length); }
  if (!info) return;
  if (!ids.length) { info.hidden = true; info.textContent = ''; return; }
  info.hidden = false;
  info.style.color = 'var(--text-secondary)';
  info.textContent = 'Считаю получателей…';
  clearTimeout(_baseMergePreviewTimer);
  _baseMergePreviewTimer = setTimeout(async () => {
    try {
      const d = await j('/api/broadcast/bases/preview-count', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ base_ids: ids }),
      });
      info.textContent = `Объединить баз: ${d.bases} → получателей: ${d.recipients} (без дублей)`;
    } catch (_) {
      info.textContent = `Выбрано баз: ${ids.length}`;
    }
  }, 350);
}

function _selectedMergeBaseIds() {
  return Array.from(document.querySelectorAll('#baseMergePicker .base-merge-chk:checked'))
    .map(c => parseInt(c.value)).filter(Boolean);
}

async function submitCreateBase() {
  const name = (document.getElementById('baseCreateName').value || '').trim();
  const source = document.getElementById('baseCreateSource').value || 'phones';
  const targets = (document.getElementById('baseCreateTargets').value || '').trim();
  const baseIds = _selectedMergeBaseIds();
  const info = document.getElementById('baseCreateInfo');
  if (!name) { info.style.color = 'var(--red)'; info.textContent = 'Укажи название базы'; return; }
  if (!targets && !baseIds.length) {
    info.style.color = 'var(--red)';
    info.textContent = 'Добавь получателей или выбери базы для объединения';
    return;
  }
  try {
    const body = { name, targets, source };
    if (baseIds.length) body.base_ids = baseIds;
    const r = await j('/api/broadcast/bases', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const extra = r.merged_from ? ` (объединено баз: ${r.merged_from})` : '';
    showToast(`База создана: ${r.count} получателей${extra}`);
    toggleBaseCreateForm();
    loadBasesTab();
  } catch (e) {
    info.style.color = 'var(--red)';
    info.textContent = e.message || 'Ошибка';
  }
}

async function renameBase(id) {
  const cur = document.querySelector(`.base-card[data-base-id="${id}"] .base-card-name`)?.textContent || '';
  const name = prompt('Новое название базы:', cur);
  if (name === null) return;
  const nm = name.trim();
  if (!nm) return;
  try {
    await j(`/api/broadcast/bases/${id}/rename`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: nm }),
    });
    loadBasesTab();
  } catch (e) { showToast(e.message || 'Ошибка', 'err'); }
}

async function deleteBase(id) {
  if (!confirm('Удалить базу? На уже созданные задачи рассылки это не повлияет.')) return;
  try {
    await j(`/api/broadcast/bases/${id}`, { method: 'DELETE' });
    showToast('База удалена');
    loadBasesTab();
  } catch (e) { showToast(e.message || 'Ошибка', 'err'); }
}

async function viewBaseItems(id) {
  const modal = document.getElementById('baseItemsModal');
  const title = document.getElementById('baseItemsTitle');
  const meta = document.getElementById('baseItemsMeta');
  const listEl = document.getElementById('baseItemsList');
  if (!modal) return;
  title.textContent = 'База';
  meta.textContent = 'Загрузка…';
  listEl.innerHTML = '';
  modal.hidden = false;
  try {
    const data = await j(`/api/broadcast/bases/${id}/items?limit=300`);
    title.textContent = (data.base && data.base.name) || ('База #' + id);
    const shown = (data.items || []).length;
    meta.textContent = `Всего ${data.total} · @username: ${data.by_username} · по номеру: ${data.by_phone}`
      + (shown < data.total ? ` (показаны первые ${shown})` : '');
    listEl.innerHTML = (data.items || []).map(i =>
      `<div class="base-item base-item--${esc(i.kind)}">${esc(i.label)}</div>`
    ).join('') || '<div class="hint">Пусто</div>';
  } catch (e) {
    meta.textContent = e.message || 'Ошибка';
  }
}

function closeBaseItems() {
  const modal = document.getElementById('baseItemsModal');
  if (modal) modal.hidden = true;
}

let _baseClientsData = [];
let _baseClientsBaseId = null;

function _baseClientKeyQuote(reason) {
  const m = String(reason || '').match(/«([^»]+)»/g);
  if (!m || !m.length) return '';
  return m[m.length - 1].replace(/[«»]/g, '').trim();
}

function _normMsg(s) {
  return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
}

async function viewBaseClients(id) {
  const modal = document.getElementById('baseClientsModal');
  const title = document.getElementById('baseClientsTitle');
  const meta = document.getElementById('baseClientsMeta');
  const listEl = document.getElementById('baseClientsList');
  if (!modal) return;
  _baseClientsBaseId = id;
  title.textContent = 'Клиенты базы';
  meta.textContent = 'Загрузка…';
  listEl.innerHTML = '';
  modal.hidden = false;
  try {
    const data = await j(`/api/broadcast/bases/${id}/clients?limit=500`);
    _baseClientsData = data.clients || [];
    title.textContent = (data.base && data.base.name) || ('База #' + id);
    const grp = data.group_title || data.group_link || '';
    meta.innerHTML = `Найдено <b>${data.total}</b> · с AI-анализом: <b>${data.with_ai}</b>`
      + (grp ? ` · группа: ${esc(grp)}` : '');
    if (!_baseClientsData.length) {
      listEl.innerHTML = '<div class="hint" style="padding:18px;text-align:center;">Нет спарсенных клиентов с данными по этой базе.</div>';
      return;
    }
    listEl.innerHTML = _baseClientsData.map((c, idx) => {
      const fio = [c.first_name, c.last_name].filter(Boolean).join(' ').trim();
      const nameDisplay = fio || (c.username ? '@' + c.username : '—');
      const avName = fio || c.username || '?';
      const tag = c.username ? '@' + esc(c.username) : '—';
      const hasAi = !!(c.ai_reason || (c.ai_messages && c.ai_messages.length));
      return `<div class="bcl-row" data-idx="${idx}">
        <div class="bcl-av">${esc(initials(avName))}</div>
        <div class="bcl-main">
          <div class="bcl-name">${esc(nameDisplay)}</div>
          <div class="bcl-tag">${tag}</div>
        </div>
        <div class="bcl-reason-preview${c.ai_reason ? '' : ' hint'}" title="${esc(c.ai_reason || '')}">${esc(c.ai_reason || '—')}</div>
        <div class="bcl-cnt" title="Подходящих сообщений">${Number(c.msg_count || 0)}</div>
        <button type="button" class="bc-tb-btn bcl-card-btn" data-idx="${idx}"${hasAi ? '' : ' disabled'}>Карточка</button>
      </div>`;
    }).join('');
    listEl.querySelectorAll('.bcl-card-btn').forEach(btn => {
      btn.onclick = (e) => { e.stopPropagation(); openBaseClientCard(Number(btn.dataset.idx)); };
    });
    listEl.querySelectorAll('.bcl-row').forEach(row => {
      row.onclick = () => openBaseClientCard(Number(row.dataset.idx));
    });
  } catch (e) {
    meta.textContent = e.message || 'Ошибка';
  }
}

function _baseDownload(url, fallbackName) {
  fetch(url).then(async (r) => {
    if (!r.ok) throw new Error('сервер вернул ' + r.status + ' (перезапусти start.bat?)');
    const blob = await r.blob();
    let fname = fallbackName;
    const cd = r.headers.get('content-disposition') || '';
    const m = cd.match(/filename="?([^"]+)"?/);
    if (m) fname = decodeURIComponent(m[1]);
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = fname;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  }).catch((e) => {
    if (typeof showToast === 'function') showToast('Не удалось скачать: ' + (e.message || 'ошибка'), 'err');
  });
}

function exportBaseClients(id, fmt) {
  const baseId = id || _baseClientsBaseId;
  if (!baseId) return;
  closeBaseExportMenus();
  _baseDownload(
    `/api/broadcast/bases/${baseId}/clients/export?fmt=${encodeURIComponent(fmt)}`,
    fmt === 'txt' ? 'tags.txt' : 'clients.csv'
  );
}

function exportBaseItems(id, fmt) {
  if (!id) return;
  closeBaseExportMenus();
  _baseDownload(
    `/api/broadcast/bases/${id}/items/export?fmt=${encodeURIComponent(fmt)}`,
    fmt === 'txt' ? 'recipients.txt' : 'recipients.csv'
  );
}

function closeBaseExportMenus() {
  document.querySelectorAll('.base-export-menu').forEach(m => { m.hidden = true; });
}

function toggleBaseExportMenu(e, id) {
  if (e) e.stopPropagation();
  const wrap = e && e.currentTarget ? e.currentTarget.closest('.base-export-wrap') : null;
  const menu = wrap ? wrap.querySelector('.base-export-menu') : null;
  const wasHidden = menu ? menu.hidden : true;
  closeBaseExportMenus();
  if (menu) menu.hidden = !wasHidden;
}

function closeBaseClients() {
  const modal = document.getElementById('baseClientsModal');
  if (modal) modal.hidden = true;
}

function openBaseClientCard(idx) {
  const c = _baseClientsData[idx];
  if (!c) return;
  const modal = document.getElementById('baseClientCardModal');
  const titleEl = document.getElementById('baseClientCardTitle');
  const subEl = document.getElementById('baseClientCardSub');
  const bodyEl = document.getElementById('baseClientCardBody');
  if (!modal) return;
  const fio = [c.first_name, c.last_name].filter(Boolean).join(' ').trim();
  const avName = fio || c.username || '?';
  const tag = c.username ? '@' + c.username : '';
  titleEl.innerHTML = `<div class="bcl-av bcl-av--lg">${esc(initials(avName))}</div>
    <div class="bcl-card-id">
      <div class="bcl-card-name">${esc(fio || (tag ? tag : '—'))}</div>
      ${tag ? `<div class="bcl-card-tag" title="Скопировать">${esc(tag)}</div>` : ''}
    </div>`;
  if (tag) {
    const tagEl = titleEl.querySelector('.bcl-card-tag');
    if (tagEl) tagEl.onclick = () => copyTextToClipboard(tag, tagEl);
  }
  const grp = c.group_title || c.group_link || '';
  subEl.textContent = (grp ? 'Группа: ' + grp : '')
    + (c.msg_count ? `${grp ? ' · ' : ''}подходящих сообщений: ${c.msg_count}` : '');

  // Ключевое сообщение — цитата из ai_reason, из-за которой клиент попал в выборку
  const quote = _normMsg(_baseClientKeyQuote(c.ai_reason));
  const fmtDate = (d) => {
    const m = String(d || '').match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
    return m ? `${m[3]}.${m[2]} ${m[4]}:${m[5]}` : '';
  };
  const msgs = Array.isArray(c.ai_messages) ? c.ai_messages : [];
  let keyMarked = false;
  const msgsHtml = msgs.length
    ? msgs.slice(0, 12).map(m => {
        const raw = String(m.text || '');
        const t = esc(raw.slice(0, 280));
        const d = fmtDate(m.date);
        const nt = _normMsg(raw);
        const isKey = !keyMarked && quote && (nt === quote || nt.includes(quote) || quote.includes(nt));
        if (isKey) keyMarked = true;
        return `<div class="bcl-msg${isKey ? ' bcl-msg--key' : ''}">${isKey ? '<span class="bcl-msg-key-badge">ключевое</span>' : ''}${d ? `<span class="bcl-msg-date">🕒 ${esc(d)}</span>` : ''}<span class="bcl-msg-text">«${t}»</span></div>`;
      }).join('')
    : '<div class="hint">Сообщения не сохранены.</div>';

  bodyEl.innerHTML = `
    <div class="bcl-card-block">
      <div class="bcl-card-label">Почему подходит (AI)</div>
      <div class="bcl-card-reason">${esc(c.ai_reason || '—')}</div>
    </div>
    <div class="bcl-card-block">
      <div class="bcl-card-label">Сообщения пользователя</div>
      ${msgsHtml}
    </div>`;
  modal.hidden = false;
}

function closeBaseClientCard() {
  const modal = document.getElementById('baseClientCardModal');
  if (modal) modal.hidden = true;
}

// Закрытие модалок баз: клик по фону + ESC
document.addEventListener('click', (e) => {
  if (!(e.target.closest && e.target.closest('.base-export-wrap'))) closeBaseExportMenus();
  const overlay = e.target.closest && e.target.closest('.bases-modal');
  if (overlay && e.target === overlay && !overlay.hidden) overlay.hidden = true;
});
document.addEventListener('keydown', (e) => {
  if (e.key !== 'Escape') return;
  closeBaseExportMenus();
  const open = Array.from(document.querySelectorAll('.bases-modal')).filter(m => !m.hidden);
  if (!open.length) return;
  // Закрываем верхнюю (последнюю в DOM среди открытых)
  open[open.length - 1].hidden = true;
});

function clearOpenChat() {
  selectedChat = null;
  clearReplyKeyboardBar();
  document.getElementById('messages').innerHTML = '<div class="empty-state">Выбери диалог слева</div>';
  updateThreadHeader(null);
  if (typeof peerStatusAbort !== 'undefined' && peerStatusAbort) {
    try { peerStatusAbort.abort(); } catch (_) {}
    peerStatusAbort = null;
  }
  renderPeerStatusLine(null);
  const delBtn = document.getElementById('btnDeleteCurrentChat');
  if (delBtn) delBtn.hidden = true;
  const threadFilter = document.getElementById('threadFilterLabel');
  if (threadFilter) threadFilter.hidden = true;
  const ta = document.getElementById('replyText');
  ta.disabled = true;
  ta.value = '';
  autoResizeReply();
  document.getElementById('sendBtn').disabled = true;
  const ab = document.getElementById('attachBtn');
  if (ab) ab.disabled = true;
  const eb = document.getElementById('emojiBtn');
  if (eb) eb.disabled = true;
  const qb = document.getElementById('quickReplyBtn');
  if (qb) qb.disabled = true;
  closeComposerPanels();
  clearAttach();
  stopThreadReadPoll();
  lastReadOutboxMax = 0;
  loadLeadPanel();
}

function isChatInInboxList(chat, list) {
  if (!chat || !list?.length) return false;
  return list.some(c =>
    c.account_phone === chat.account_phone && Number(c.peer_id) === Number(chat.peer_id)
  );
}

function updateOpenChatListHint(list) {
  const subEl = document.getElementById('threadSubtitle');
  if (!subEl || !selectedChat) return;
  const base = 'Ответить с ' + accLabel(selectedChat.account_phone);
  if (!isChatInInboxList(selectedChat, list)) {
    const sideHint = chatDialogSide === 'incoming'
      ? 'сейчас в «Исходящих»'
      : 'сейчас во «Входящих»';
    subEl.textContent = `${base} · ${sideHint}`;
  } else {
    subEl.textContent = base;
  }
}

function updateThreadHeader(chat) {
  const nameEl = document.getElementById('threadTitle');
  const subEl = document.getElementById('threadSubtitle');
  const avEl = document.getElementById('threadAvatar');
  if (!chat) {
    if (nameEl) nameEl.textContent = 'Выбери диалог';
    if (subEl) subEl.textContent = 'Выбери диалог слева';
    if (avEl) avEl.innerHTML = '?';
    return;
  }
  const display = chat.peer_username ? '@' + chat.peer_username : (chat.peer_name || chat.peer_id);
  if (nameEl) nameEl.textContent = display;
  if (subEl) subEl.textContent = 'Ответить с ' + accLabel(chat.account_phone);
  if (avEl) {
    avEl.innerHTML = initials(chat.peer_name || chat.peer_username || '?');
    bindAvatar(avEl, chat.account_phone, chat.peer_id, chat.peer_name || chat.peer_username || '?', 0, chat.peer_username || '');
  }
  updateOpenChatListHint(getInboxVisibleChats());
}

function closeComposerPanels() {
  const menu = document.getElementById('attachMenu');
  const emoji = document.getElementById('emojiPanel');
  if (menu) menu.hidden = true;
  if (emoji) emoji.hidden = true;
  const qr = document.getElementById('quickReplyPanel');
  if (qr) qr.hidden = true;
}

function autoResizeReply() {
  const ta = document.getElementById('replyText');
  if (!ta) return;
  ta.style.height = 'auto';
  ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
}

async function deleteCurrentChat() {
  if (!selectedChat) return;
  const jobCtx = inboxActiveJobContext()
    || (selectedChat.broadcast_job_id
      ? { jobId: Number(selectedChat.broadcast_job_id), jobName: inboxJobNames[selectedChat.broadcast_job_id] || `Задача #${selectedChat.broadcast_job_id}` }
      : null);
  const visible = getInboxVisibleChats();
  const onlyOneLeft = visible.length === 1
    && visible[0].account_phone === selectedChat.account_phone
    && Number(visible[0].peer_id) === Number(selectedChat.peer_id);
  const opts = await showDeleteChatsConfirm({
    count: 1,
    jobId: jobCtx?.jobId,
    jobName: jobCtx?.jobName,
    defaultDeleteJob: !!(jobCtx && onlyOneLeft && typeof filterJobId === 'number'),
  });
  if (!opts?.confirmed) return;
  try {
    await performChatDeletion(
      [{ account_phone: selectedChat.account_phone, peer_id: Number(selectedChat.peer_id) }],
      { deleteJobId: opts.deleteJob && jobCtx ? jobCtx.jobId : null },
    );
  } catch (e) {
    showToast(e.message || 'Не удалось удалить', 'err');
  }
}

// ── Полный функционал диалога: статус, блок, контакты, удаление в TG ──

function formatPeerStatus(status, lastIso) {
  const s = String(status || '').toLowerCase();
  if (s === 'online') return { text: 'в сети', cls: 'online' };
  if (s === 'recently') return { text: 'был(а) недавно', cls: 'off' };
  if (s === 'last_week') return { text: 'был(а) на этой неделе', cls: 'off' };
  if (s === 'last_month') return { text: 'был(а) в этом месяце', cls: 'off' };
  if (s === 'long_ago') return { text: 'был(а) давно', cls: 'off' };
  if (s === 'offline') {
    if (lastIso) {
      const d = new Date(lastIso);
      if (!isNaN(d)) {
        const now = new Date();
        const sameDay = d.toDateString() === now.toDateString();
        const time = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
        const when = sameDay ? time : d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) + ' ' + time;
        return { text: 'был(а) в сети ' + when, cls: 'off' };
      }
    }
    return { text: 'не в сети', cls: 'off' };
  }
  return null;
}

function renderPeerStatusLine(info) {
  const headEl = document.getElementById('threadPeerStatus');
  const leadEl = document.getElementById('leadPeerStatus');
  if (!info) {
    if (headEl) { headEl.hidden = true; headEl.textContent = ''; headEl.className = 'chat-header-status'; }
    if (leadEl) { leadEl.hidden = true; leadEl.innerHTML = ''; }
    return;
  }
  const st = formatPeerStatus(info.status, info.last_online_date);
  const parts = [];
  if (info.is_bot) parts.push('<span class="peer-tag bot">бот</span>');
  if (info.is_deleted) parts.push('<span class="peer-tag">удалён</span>');
  if (headEl) {
    if (st) {
      headEl.textContent = st.text;
      headEl.className = 'chat-header-status ' + (st.cls === 'online' ? 'is-online' : '');
      headEl.hidden = false;
    } else {
      headEl.hidden = true;
    }
  }
  if (leadEl) {
    const statusHtml = st
      ? `<span class="peer-dot ${st.cls === 'online' ? 'on' : ''}"></span>${esc(st.text)}`
      : '';
    leadEl.innerHTML = statusHtml + (parts.length ? ' ' + parts.join(' ') : '');
    leadEl.hidden = !(statusHtml || parts.length);
  }
}

let peerStatusAbort = null;
async function loadPeerStatus(chat) {
  // Отменяем предыдущий запрос статуса — он не должен держать слот соединения
  // браузера (лимит 6 на хост), иначе /api/chats и /api/thread встают в очередь.
  if (peerStatusAbort) { try { peerStatusAbort.abort(); } catch (_) {} peerStatusAbort = null; }
  if (!chat) { renderPeerStatusLine(null); return; }
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  const ctrl = new AbortController();
  peerStatusAbort = ctrl;
  try {
    const info = await j(
      `/api/chats/peer-status?account=${encodeURIComponent(chat.account_phone)}&peer_id=${chat.peer_id}`,
      { signal: ctrl.signal, timeoutMs: 8000 }
    );
    if (selectedChatKey() !== reqKey) return;
    renderPeerStatusLine(info);
    // Реальное состояние контакта из Telegram имеет приоритет над локальным флагом
    if (typeof info.is_contact === 'boolean') {
      selectedChat.is_contact = info.is_contact ? 1 : 0;
      updateLeadActionButtons();
    }
  } catch (_) {
    if (selectedChatKey() === reqKey) renderPeerStatusLine(null);
  } finally {
    if (peerStatusAbort === ctrl) peerStatusAbort = null;
  }
}

function updateLeadActionButtons() {
  const blocked = !!(selectedChat && Number(selectedChat.is_blocked));
  const contact = !!(selectedChat && Number(selectedChat.is_contact));
  const blockLbl = document.getElementById('btnLeadBlockLabel');
  const blockBtn = document.getElementById('btnLeadBlock');
  const contactLbl = document.getElementById('btnLeadContactLabel');
  const contactBtn = document.getElementById('btnLeadContact');
  if (blockLbl) blockLbl.textContent = blocked ? 'Разблок' : 'Блок';
  if (blockBtn) blockBtn.classList.toggle('is-active', blocked);
  if (contactLbl) contactLbl.textContent = contact ? 'Из контактов' : 'В контакты';
  if (contactBtn) contactBtn.classList.toggle('is-active', contact);
}

function setLeadActionsBusy(busy) {
  ['btnLeadBlock', 'btnLeadContact', 'btnLeadClearHistory', 'btnLeadDeleteTg'].forEach(id => {
    const b = document.getElementById(id);
    if (b) b.disabled = !!busy;
  });
}

async function toggleLeadBlock() {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const blocked = !!Number(selectedChat.is_blocked);
  if (!blocked && !confirm(`Заблокировать ${chat.peer_username ? '@' + chat.peer_username : 'этого собеседника'} в Telegram? Он не сможет вам писать.`)) return;
  setLeadActionsBusy(true);
  try {
    const path = blocked ? '/api/chats/unblock' : '/api/chats/block';
    await j(path, { method: 'POST', body: JSON.stringify({ account_phone: chat.account_phone, peer_id: Number(chat.peer_id) }) });
    if (selectedChat && selectedChatKey() === chatKey(chat.account_phone, chat.peer_id)) {
      selectedChat.is_blocked = blocked ? 0 : 1;
      updateLeadActionButtons();
    }
    showToast(blocked ? 'Разблокирован' : 'Заблокирован');
  } catch (e) {
    showToast(toastText(e), 'err');
  } finally {
    setLeadActionsBusy(false);
  }
}

async function toggleLeadContact() {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const contact = !!Number(selectedChat.is_contact);
  setLeadActionsBusy(true);
  try {
    if (contact) {
      await j('/api/chats/delete-contact', { method: 'POST', body: JSON.stringify({ account_phone: chat.account_phone, peer_id: Number(chat.peer_id) }) });
    } else {
      await j('/api/chats/add-contact', { method: 'POST', body: JSON.stringify({ account_phone: chat.account_phone, peer_id: Number(chat.peer_id), first_name: chat.peer_name || '' }) });
    }
    if (selectedChat && selectedChatKey() === chatKey(chat.account_phone, chat.peer_id)) {
      selectedChat.is_contact = contact ? 0 : 1;
      updateLeadActionButtons();
    }
    showToast(contact ? 'Удалён из контактов' : 'Добавлен в контакты');
  } catch (e) {
    showToast(toastText(e), 'err');
  } finally {
    setLeadActionsBusy(false);
  }
}

async function deleteDialogInTg() {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const who = chat.peer_username ? '@' + chat.peer_username : (chat.peer_name || 'диалог');
  if (!confirm(`Удалить переписку с ${who} у ОБОИХ — и у вас, и у собеседника в Telegram, и здесь в CRM. Отменить нельзя.`)) return;
  setLeadActionsBusy(true);
  try {
    await j('/api/chats/delete-tg', {
      method: 'POST',
      body: JSON.stringify({ account_phone: chat.account_phone, peer_id: Number(chat.peer_id), revoke: true, delete_local: true }),
    });
    if (selectedChat && selectedChatKey() === chatKey(chat.account_phone, chat.peer_id)) clearOpenChat();
    inboxListFp = '';
    await loadInboxChats(true);
    showToast('Диалог удалён у обоих в Telegram');
  } catch (e) {
    showToast(toastText(e), 'err');
  } finally {
    setLeadActionsBusy(false);
  }
}

async function clearDialogHistory() {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const who = chat.peer_username ? '@' + chat.peer_username : (chat.peer_name || 'этот диалог');
  if (!confirm(`Очистить историю переписки с ${who} в Telegram? Сообщения исчезнут, сам диалог и карточка останутся. Отменить нельзя.`)) return;
  setLeadActionsBusy(true);
  try {
    await j('/api/chats/clear-history', {
      method: 'POST',
      body: JSON.stringify({ account_phone: chat.account_phone, peer_id: Number(chat.peer_id) }),
    });
    if (selectedChat && selectedChatKey() === chatKey(chat.account_phone, chat.peer_id)) clearOpenChat();
    inboxListFp = '';
    await loadInboxChats(true);
    showToast('История очищена');
  } catch (e) {
    showToast(toastText(e), 'err');
  } finally {
    setLeadActionsBusy(false);
  }
}

// ── Черновики на диалог (сохраняются между переключениями) ──
function inboxDraftKey(chat) {
  if (!chat) return '';
  return 'inbox_draft_' + chatKey(chat.account_phone, chat.peer_id);
}
function saveInboxDraft() {
  if (!selectedChat) return;
  const ta = document.getElementById('replyText');
  if (!ta) return;
  const key = inboxDraftKey(selectedChat);
  try {
    const v = ta.value;
    if (v && v.trim()) localStorage.setItem(key, v);
    else localStorage.removeItem(key);
  } catch (_) {}
}
function restoreInboxDraft(chat) {
  const ta = document.getElementById('replyText');
  if (!ta || !chat) return;
  try {
    const d = localStorage.getItem(inboxDraftKey(chat)) || '';
    if (d) ta.value = d;
  } catch (_) {}
}
function clearInboxDraft(chat) {
  try { localStorage.removeItem(inboxDraftKey(chat)); } catch (_) {}
}

// ── Кнопка «вниз к последним» в переписке ──
function updateScrollBottomBtn() {
  const el = document.getElementById('messages');
  const btn = document.getElementById('btnScrollBottom');
  if (!el || !btn) return;
  const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
  btn.hidden = !(gap > 240 && selectedChat);
}
function scrollMessagesToBottom() {
  const el = document.getElementById('messages');
  if (el) el.scrollTop = el.scrollHeight;
}
document.getElementById('messages')?.addEventListener('scroll', updateScrollBottomBtn, { passive: true });
document.getElementById('btnScrollBottom')?.addEventListener('click', scrollMessagesToBottom);

// ── Навигация по диалогам с клавиатуры (↑/↓) ──
document.addEventListener('keydown', e => {
  if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
  const inbox = document.getElementById('tab-inbox');
  if (!inbox || !inbox.classList.contains('active')) return;
  const t = e.target;
  const tag = (t && t.tagName) ? t.tagName.toLowerCase() : '';
  if (tag === 'input' || tag === 'textarea' || tag === 'select' || (t && t.isContentEditable)) return;
  const list = getInboxVisibleChats();
  if (!list.length) return;
  let idx = -1;
  if (selectedChat) {
    const curKey = chatKey(selectedChat.account_phone, selectedChat.peer_id);
    idx = list.findIndex(c => chatKey(c.account_phone, c.peer_id) === curKey);
  }
  let next;
  if (idx < 0) next = e.key === 'ArrowDown' ? 0 : list.length - 1;
  else next = e.key === 'ArrowDown' ? Math.min(idx + 1, list.length - 1) : Math.max(idx - 1, 0);
  if (next === idx) return;
  const c = list[next];
  if (!c) return;
  e.preventDefault();
  openChat(c.account_phone, c.peer_id, c.peer_name, c.peer_username, c.lead_status);
});

// ── Клик-копирование (тег / номер / имя) ──
function copyTextToClipboard(text, el) {
  const t = String(text == null ? '' : text).trim();
  if (!t) return;
  const done = () => { if (el) showCopiedFeedback(el); else showToast('Скопировано'); };
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(t).then(done).catch(() => _fallbackCopy(t, done));
  } else {
    _fallbackCopy(t, done);
  }
}
function _fallbackCopy(t, done) {
  try {
    const ta = document.createElement('textarea');
    ta.value = t;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    ta.remove();
    if (done) done();
  } catch (_) {}
}
function showCopiedFeedback(el) {
  if (!el) { showToast('Скопировано'); return; }
  el.classList.remove('copied-flash');
  void el.offsetWidth; // restart animation
  el.classList.add('copied-flash');
  setTimeout(() => el.classList.remove('copied-flash'), 600);
  const rect = el.getBoundingClientRect();
  const tip = document.createElement('div');
  tip.className = 'copy-tip';
  tip.textContent = '✓ Скопировано';
  document.body.appendChild(tip);
  tip.style.left = (rect.left + rect.width / 2) + 'px';
  tip.style.top = rect.top + 'px';
  requestAnimationFrame(() => tip.classList.add('show'));
  setTimeout(() => {
    tip.classList.remove('show');
    setTimeout(() => tip.remove(), 220);
  }, 850);
}
document.addEventListener('click', e => {
  const el = e.target.closest('[data-copy]');
  if (!el) return;
  copyTextToClipboard(el.getAttribute('data-copy'), el);
});

// ── Модалка «Наш аккаунт» (компактная карточка с аватаром и инфой) ──
async function openOurAccountModal(phone, accId) {
  openModal('modalOurAccount');
  const body = document.getElementById('ourAccBody');
  const sub = document.getElementById('ourAccSub');
  if (sub) sub.textContent = accLabel(phone);
  if (body) body.innerHTML = '<div class="client-card-loading">Загрузка…</div>';
  try {
    const id = accId || allAccounts.find(a => a.phone === phone)?.id;
    if (!id) throw new Error('Аккаунт не найден');
    const acc = await j(`/api/accounts/card/${id}`);
    renderOurAccountModal(acc);
  } catch (e) {
    if (body) body.innerHTML = `<div class="client-card-loading">Ошибка: ${esc(toastText(e))}</div>`;
  }
}

function renderOurAccountModal(acc) {
  const body = document.getElementById('ourAccBody');
  const sub = document.getElementById('ourAccSub');
  if (!body) return;
  const name = (acc.display_name || '').trim() || (acc.username ? '@' + acc.username : acc.phone || '—');
  const handle = acc.username ? '@' + String(acc.username).replace(/^@/, '') : '—';
  if (sub) sub.textContent = handle;
  const avUrl = acc.avatar_url || (acc.avatar_path ? accountAvatarUrl(acc.id, acc.updated_at || '') : '');
  const avHtml = avUrl ? `<img src="${esc(avUrl)}" alt="">` : esc(initials(name));
  const poolLabel = acc.pool_status || (accountProfileIsOnline(acc) ? 'online' : 'offline');
  const spam = acc.spam_label || (acc.spam_status && acc.spam_status !== 'ok' ? acc.spam_status : 'нет');
  const proj = acc.project_name || acc.project || '—';
  const premium = acc.is_premium ? '★ Premium' : '—';
  const limit = acc.daily_limit_effective ?? acc.daily_limit ?? '—';
  // Health-поля
  let twofa = '—';
  if (acc.session_2fa_saved) twofa = '🔑 сохранён';
  else if (acc.session_has_2fa) twofa = '⚠ есть (не сохранён)';
  else twofa = 'нет';
  const authTotal = acc.session_auth_total || 0;
  const others = acc.session_other_count || 0;
  const sessInfo = authTotal ? `${authTotal} сессий${others ? ` (чужих: ${others})` : ''}` : '—';
  const checkedAt = acc.spam_checked_at
    ? new Date(acc.spam_checked_at).toLocaleString('ru-RU')
    : 'не проверялся';
  body.innerHTML = `
    <div class="our-acc-hero">
      <div class="our-acc-av">${avHtml}</div>
      <div class="our-acc-id">
        <div class="our-acc-name copyable" data-copy="${esc(name)}" title="Скопировать имя">${esc(name)}</div>
        <div class="our-acc-handle copyable" data-copy="${esc(handle)}" title="Скопировать тег">${esc(handle)}</div>
        <div class="our-acc-state"><span class="dot ${poolDot(poolLabel)}"></span>${esc(poolLabel)}</div>
      </div>
    </div>
    <div class="our-acc-grid">
      <div><b>Тег</b><span class="copyable" data-copy="${esc(handle)}" title="Скопировать">${esc(handle)}</span></div>
      <div><b>Телефон</b><span class="copyable" data-copy="${esc(acc.phone || '')}" title="Скопировать">${esc(acc.phone || '—')}</span></div>
      <div><b>Проект</b><span>${esc(proj)}</span></div>
      <div><b>Premium</b><span>${esc(premium)}</span></div>
      <div><b>Спам-статус</b><span>${esc(spam)}</span></div>
      <div><b>2FA</b><span>${esc(twofa)}</span></div>
      <div><b>Сессии TG</b><span>${esc(sessInfo)}</span></div>
      <div><b>Проверен</b><span>${esc(checkedAt)}</span></div>
      <div><b>Лимит / день</b><span>${esc(String(limit))}</span></div>
      <div><b>Отправлено сегодня</b><span>${esc(String(acc.daily_sent ?? 0))}</span></div>
    </div>`;
}

function formatApiErrorDetail(detail) {
  if (detail == null || detail === '') return '';
  if (typeof detail === 'string') return detail;
  if (Array.isArray(detail)) {
    return detail.map(d => {
      if (!d || typeof d !== 'object') return String(d);
      const loc = Array.isArray(d.loc) ? d.loc.filter(x => x !== 'body').join('.') : '';
      const msg = d.msg || d.message || '';
      return loc ? `${loc}: ${msg}` : msg;
    }).filter(Boolean).join('; ') || 'Ошибка запроса';
  }
  if (typeof detail === 'object') {
    return detail.msg || detail.message || detail.error || JSON.stringify(detail);
  }
  return String(detail);
}

function humanizeTelegramError(msg) {
  let m = String(msg || '');
  const jsonDetail = m.match(/^\s*\{[\s\S]*"detail"\s*:\s*"((?:\\.|[^"\\])*)"/);
  if (jsonDetail) {
    try {
      m = JSON.parse(`"${jsonDetail[1]}"`);
    } catch (_) {
      m = jsonDetail[1].replace(/\\"/g, '"');
    }
  }
  if (/AUTH_KEY_DUPLICATED|auth_key_duplicated|auth key duplicated/i.test(m)) {
    return 'Сессия мёртва — .session одновременно в двух местах. Нужен переимпорт.';
  }
  if (/AUTH_KEY_UNREGISTERED|auth_key_unregistered|session.?revoked|session.?expired/i.test(m)) {
    return 'Сессия мёртва — ключ недействителен. Нужен переимпорт.';
  }
  if (/PHONE_NUMBER_INVALID|phone_number_invalid/i.test(m)) {
    return 'Неверный номер для входа — подключи аккаунт online, софт подтянет номер из Telegram.';
  }
  const fw = m.match(/wait of (\d+) seconds/i);
  if (fw) {
    const sec = Number(fw[1]) || 0;
    const min = Math.max(1, Math.ceil(sec / 60));
    if (/CheckPassword|2FA|пароль/i.test(m)) {
      return `Telegram заблокировал ввод 2FA на ~${min} мин (${sec} с). Подожди, кнопки не жми.`;
    }
    return `Telegram: подожди ~${min} мин (${sec} с) перед повтором.`;
  }
  const phonePrefix = m.match(/^\+?\d{10,15}:\s*(.+)$/);
  if (phonePrefix) m = phonePrefix[1];
  return m;
}

function toastText(msg) {
  if (msg == null) return '';
  if (msg instanceof Error) msg = msg.message || String(msg);
  if (typeof msg === 'object') return humanizeTelegramError(formatApiErrorDetail(msg) || JSON.stringify(msg));
  let s = String(msg);
  const t = s.trim();
  if (t.startsWith('{') || t.startsWith('[')) {
    try {
      const parsed = JSON.parse(t);
      const extracted = formatApiErrorDetail(parsed.detail ?? parsed);
      if (extracted) s = extracted;
    } catch (_) { /* keep original */ }
  }
  return humanizeTelegramError(s);
}

async function j(url, opts = {}) {
  const timeoutMs = Number(opts.timeoutMs) || 0;
  const fetchOpts = { ...opts };
  delete fetchOpts.timeoutMs;
  const headers = { ...(fetchOpts.headers || {}) };
  const apiTok = (localStorage.getItem('spamimpakter_api_token') || '').trim();
  if (apiTok && !headers['X-API-Token'] && !headers['x-api-token']) {
    headers['X-API-Token'] = apiTok;
  }
  const isFormData = typeof FormData !== 'undefined' && fetchOpts.body instanceof FormData;
  if (fetchOpts.body && !isFormData && !headers['Content-Type'] && !headers['content-type']) {
    headers['Content-Type'] = 'application/json';
  }
  const userSignal = fetchOpts.signal;
  delete fetchOpts.signal;
  const ctrl = (timeoutMs > 0 || userSignal) ? new AbortController() : null;
  if (ctrl && userSignal) {
    if (userSignal.aborted) ctrl.abort();
    else userSignal.addEventListener('abort', () => ctrl.abort(), { once: true });
  }
  const timer = ctrl && timeoutMs > 0 ? setTimeout(() => ctrl.abort(), timeoutMs) : null;
  let r;
  try {
    r = await fetch(url, {
      cache: 'no-store',
      ...fetchOpts,
      headers,
      signal: ctrl ? ctrl.signal : undefined,
    });
  } catch (e) {
    if (e && e.name === 'AbortError') {
      if (userSignal?.aborted && !timer) {
        throw new Error('Запрос отменён');
      }
      if (userSignal?.aborted && accCheckAbort) {
        throw new Error('Проверка отменена');
      }
      throw new Error('Таймаут запроса — проверь прокси и попробуй снова');
    }
    throw e;
  } finally {
    if (timer) clearTimeout(timer);
  }
  if (!r.ok) {
    const raw = await r.text();
    let data;
    try {
      data = JSON.parse(raw);
    } catch {
      throw new Error(raw || `HTTP ${r.status}`);
    }
    const detail = data.detail;
    const payload = (detail && typeof detail === 'object' && !Array.isArray(detail))
      ? detail
      : data;
    const msg = (typeof payload.message === 'string' ? payload.message : '')
      || formatApiErrorDetail(payload)
      || formatApiErrorDetail(detail)
      || data.message
      || data.error
      || raw;
    const err = new Error(humanizeTelegramError(msg || `HTTP ${r.status}`));
    if (payload?.account) err.data = { account: payload.account };
    if (payload?.status) err.data = { ...(err.data || {}), status: payload.status };
    throw err;
  }
  return r.json();
}

function fmtTime(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  const now = new Date();
  if (d.toDateString() === now.toDateString()) {
    return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
  }
  return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}

function fmtTimeShort(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}

function fmtDateSep(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  const now = new Date();
  const yesterday = new Date(now);
  yesterday.setDate(yesterday.getDate() - 1);
  if (d.toDateString() === now.toDateString()) return 'Сегодня';
  if (d.toDateString() === yesterday.toDateString()) return 'Вчера';
  const opts = { day: 'numeric', month: 'long' };
  if (d.getFullYear() !== now.getFullYear()) opts.year = 'numeric';
  return d.toLocaleDateString('ru-RU', opts);
}

const TG_CHECK_ONE = '<svg viewBox="0 0 11 11" xmlns="http://www.w3.org/2000/svg"><path d="M1 6L4.5 9.5 10 2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const TG_CHECK_READ = '<svg viewBox="0 0 18 12" xmlns="http://www.w3.org/2000/svg"><path d="M1 7.5L4.5 11 9 3.5" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 7.5L10 11 16.5 3" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>';

function inboxChatChecksHtml(c) {
  if (!c?.last_is_outgoing) return '';
  const mid = Number(c.last_tg_message_id) || 0;
  if (mid <= 0) return '';
  const readMax = Number(c.read_outbox_max_id) || 0;
  const read = mid <= readMax;
  const icon = read ? TG_CHECK_READ : TG_CHECK_ONE;
  return `<span class="tg-checks chat-list-checks ${read ? 'read' : 'sent'}" title="${read ? 'Прочитано' : 'Доставлено'}">${icon}</span>`;
}

function inboxChatTimeLineHtml(c) {
  const unread = Number(c.unread_count) || 0;
  const badge = unread ? `<span class="chat-badge">${unread > 99 ? '99+' : unread}</span>` : '';
  return `${inboxChatTime(c)}${inboxChatChecksHtml(c)}${badge}`;
}

function applyReadOutboxToInboxList(accountPhone, peerId, readOutboxMaxId) {
  const pid = Number(peerId);
  const maxId = Number(readOutboxMaxId) || 0;
  const row = allChats.find(x => x.account_phone === accountPhone && Number(x.peer_id) === pid);
  if (row) row.read_outbox_max_id = maxId;
  const list = getInboxVisibleChats();
  if (!list.some(x => x.account_phone === accountPhone && Number(x.peer_id) === pid)) return;
  if (patchChatListItems(list)) return;
  const item = document.querySelector(
    `.chat-item[data-account="${CSS.escape(accountPhone)}"][data-peer="${pid}"]`
  );
  const timeEl = item?.querySelector('.chat-time');
  if (timeEl && row) timeEl.innerHTML = inboxChatTimeLineHtml(row);
}

function computeReadOutboxMax(rows) {
  let max = 0;
  for (const m of rows || []) {
    if (!m?.is_outgoing || !m?.is_read) continue;
    const mid = Number(m.tg_message_id) || 0;
    if (mid > max) max = mid;
  }
  return max;
}

function msgMetaHtml(m) {
  const time = fmtTimeShort(m.date_iso);
  let checks = '';
  if (m.is_outgoing) {
    const mid = Number(m.tg_message_id) || 0;
    if (mid > 0) {
      const read = m.is_read === true || m.is_read === 1;
      const icon = read ? TG_CHECK_READ : TG_CHECK_ONE;
      checks = `<span class="tg-checks ${read ? 'read' : 'sent'}" title="${read ? 'Прочитано' : 'Отправлено'}">${icon}</span>`;
    }
  }
  return `<span class="tg-meta"><span class="tg-time">${time}</span>${checks}</span>`;
}

function tgBubbleWrap(dir, inner, extraCls = '', layoutCls = 'tg-media-layout') {
  return `<div class="tg-msg ${dir}"><div class="tg-bubble ${extraCls}"><div class="tg-bubble-inner ${layoutCls}">${inner}</div></div></div>`;
}

function tgBubbleWithKeyboard(m, inner, extraCls = '', layoutCls = 'tg-media-layout') {
  const dir = m.is_outgoing ? 'out' : 'in';
  const kb = !m.is_outgoing ? botButtonsHtml(m) : '';
  if (!kb) return tgBubbleWrap(dir, inner, extraCls, layoutCls);
  const bubbleCls = [extraCls, 'tg-bubble-above-kb'].filter(Boolean).join(' ');
  return (
    `<div class="tg-msg-stack ${dir}">` +
    `<div class="tg-msg ${dir}"><div class="tg-bubble ${bubbleCls}">` +
    `<div class="tg-bubble-inner ${layoutCls}">${inner}</div></div></div>` +
    kb +
    `</div>`
  );
}

function botButtonsHtml(m) {
  const mk = m.reply_markup;
  if (!mk || mk.type !== 'inline' || !m.tg_message_id) return '';
  const rows = mk.rows || [];
  if (!rows.length) return '';
  const cols = Math.max(...rows.map(r => r.length), 1);
  return '<div class="tg-inline-keyboard" style="--tg-kb-cols:' + cols + '">' + rows.map((rowBtns, ri) => {
    const count = rowBtns.length;
    return '<div class="tg-bot-row" style="--tg-kb-row-cols:' + count + '">' + rowBtns.map((btn, ci) => {
      const label = esc(btn.text || '···');
      if (btn.url) {
        return `<a class="tg-bot-btn tg-bot-btn-link" href="${esc(btn.url)}" target="_blank" rel="noopener">${label}</a>`;
      }
      return `<button type="button" class="tg-bot-btn" onclick="clickBotButton(${m.tg_message_id},${ri},${ci},this)">${label}</button>`;
    }).join('') + '</div>';
  }).join('') + '</div>';
}

function updateReplyKeyboardBar(rows) {
  const bar = document.getElementById('replyKeyboardBar');
  if (!bar) return;
  let markup = null;
  for (let i = rows.length - 1; i >= 0; i--) {
    const mk = rows[i].reply_markup;
    if (mk && mk.type === 'reply' && !rows[i].is_outgoing) {
      markup = mk;
      break;
    }
  }
  if (!markup || !markup.rows?.length) {
    bar.hidden = true;
    bar.innerHTML = '';
    return;
  }
  bar.hidden = false;
  const flat = markup.rows.flat();
  const cols = Math.min(3, Math.max(1, flat.length <= 4 ? 2 : 3));
  bar.style.setProperty('--tg-rk-cols', String(cols));
  bar.innerHTML = '<div class="tg-reply-kb-grid">' + flat.map(b => {
    const t = esc(b.text || '');
    const payload = JSON.stringify(b.text || '');
    return `<button type="button" class="tg-reply-kb-btn" onclick="sendReplyKeyboardText(${payload})">${t}</button>`;
  }).join('') + '</div>';
}

function clearReplyKeyboardBar() {
  const bar = document.getElementById('replyKeyboardBar');
  if (!bar) return;
  bar.hidden = true;
  bar.innerHTML = '';
}

async function clickBotButton(msgId, row, col, btnEl) {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  if (btnEl) btnEl.disabled = true;
  try {
    const res = await j('/api/bot/click', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        account_phone: chat.account_phone,
        peer_id: chat.peer_id,
        tg_message_id: msgId,
        row,
        col,
      }),
    });
    if (selectedChatKey() !== reqKey) return;
    if (res.action === 'url' && res.url) {
      window.open(res.url, '_blank', 'noopener');
      showToast('Ссылка открыта');
    } else if (res.alert) {
      const hint = res.alert.length > 80 ? res.alert.slice(0, 120) + '…' : res.alert;
      showToast(
        res.saved_to_thread ? 'Ответ бота добавлен в чат' : hint,
        res.popup ? 'info' : 'ok'
      );
    }
    try {
      await j('/api/bot/sync-recent', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          account_phone: chat.account_phone,
          peer_id: chat.peer_id,
          limit: 35,
        }),
      });
    } catch (_) {}
    if (selectedChatKey() !== reqKey) return;
    await loadThread({ silent: true });
    await loadInboxChats();
  } catch (e) {
    alert('Не удалось нажать кнопку:\n\n' + (e.message || String(e)));
  } finally {
    if (btnEl) btnEl.disabled = false;
  }
}

async function sendReplyKeyboardText(text) {
  if (!selectedChat || !text) return;
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  try {
    await j('/api/bot/click', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        account_phone: chat.account_phone,
        peer_id: chat.peer_id,
        tg_message_id: 0,
        reply_text: text,
      }),
    });
    if (selectedChatKey() !== reqKey) return;
    showToast('Отправлено');
    await loadThread();
    await loadInboxChats();
  } catch (e) {
    alert('Ошибка: ' + (e.message || String(e)));
  }
}

window.clickBotButton = clickBotButton;
window.sendReplyKeyboardText = sendReplyKeyboardText;

function tgTextBubble(m, bodyHtml) {
  const body = typeof bodyHtml === 'string' && bodyHtml.includes('<')
    ? bodyHtml
    : formatMessageText(bodyHtml);
  return tgBubbleWithKeyboard(m, `<div class="tg-text">${body}${msgMetaHtml(m)}</div>`, '', 'tg-text-layout');
}

function esc(s) {
  return (s || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function accountStatusTag(a) {
  const st = a.status || 'active';
  if (a.spam_blocked) return `<span class="tag tag-spam">${esc(a.spam_label)}</span>`;
  if (st === 'active') return `<span class="tag tag-ok">active</span>`;
  if (st === 'frozen') return `<span class="tag tag-warn">заморозка</span>`;
  if (['banned', 'dead', 'session_revoked', 'deactivated'].includes(st)) {
    return `<span class="tag tag-bad">${esc(st)}</span>`;
  }
  return `<span class="tag">${esc(st)}</span>`;
}

function proxySelectOptions(proxies, selectedId) {
  let h = `<option value="">— нет —</option>`;
  for (const p of proxies) {
    const sel = Number(selectedId) === p.id ? ' selected' : '';
    h += `<option value="${p.id}"${sel}>#${p.id} ${esc(p.host)}:${p.port}</option>`;
  }
  return h;
}

function poolDot(s) {
  if (s === 'online') return 'online';
  if (s === 'error') return 'error';
  if (s === 'resting') return 'resting';
  if (s === 'importing' || s === 'connecting' || s === 'checking') return 'pending';
  return 'offline';
}

function poolLabel(s) {
  const m = {
    importing: 'загрузка',
    connecting: 'подключение…',
    checking: 'проверка…',
    online: 'online',
    offline: 'offline',
    error: 'ошибка',
    resting: 'отдых',
  };
  return m[s] || s || '—';
}

function poolActionAccounts(ids = null) {
  if (!ids?.length) return allAccounts || [];
  return ids.map(id => getAccountById(id)).filter(Boolean);
}

function hasPoolConnectPending(list) {
  return (list || []).some(a => ['importing', 'connecting'].includes(a.pool_status));
}

function hasPendingImports(list) {
  return (list || []).some(a => ['importing', 'connecting', 'checking'].includes(a.pool_status));
}

function hasPoolBusyAccounts(ids = null) {
  return hasPoolConnectPending(poolActionAccounts(ids));
}

function isPoolServerConnecting(st) {
  return !!(st?.pool_connecting);
}

function isPoolConnectActive(st) {
  return isPoolServerConnecting(st) || accPoolBusy;
}

let accCheckingIds = new Set();
let accCheckRunning = false;
let accCheckAbort = false;
let accCheckRequestCtrl = null;

let statsInflight = null;
let statsCache = { at: 0, data: null };
const STATS_CLIENT_CACHE_MS = 10000;
const STATS_FULL_EVERY_MS = 45000;
let statsLastFullAt = 0;
let statsPollTimer = null;
const STATS_POLL_MS = 15000;
const STATS_POLL_BUSY_MS = 5000;
const TAB_DATA_TTL_MS = 45000;
const META_SELECT_TTL_MS = 60000;
const LEAD_STATUS_TTL_MS = 120000;
let accountsPickerAt = 0;
const tabDataCache = {
  accounts: { at: 0, key: '' },
  clients: { at: 0, key: '' },
  templates: { at: 0 },
  meta: { at: 0 },
  leadStatuses: { at: 0 },
};

function _cacheFresh(entry, ttlMs) {
  return !!(entry?.at && (Date.now() - entry.at) < ttlMs);
}

function accountsTabCacheKey() {
  const pf = document.getElementById('accProjectFilter')?.value ?? '';
  const st = document.getElementById('accStatusFilter')?.value ?? '';
  const sf = document.getElementById('accStateFilter')?.value ?? '';
  const jf = document.getElementById('accJobFilter')?.value ?? '';
  return `${pf}|${st}|${sf}|${jf}`;
}

function clientsTabCacheKey() {
  const status = document.getElementById('clientStatusFilter')?.value || '';
  const job = document.getElementById('clientJobFilter')?.value || '';
  const q = (document.getElementById('clientSearch')?.value || '').trim();
  return `${status}|${job}|${q}`;
}

function invalidateTabCache(scope) {
  if (scope === 'accounts' || scope === 'all') {
    tabDataCache.accounts = { at: 0, key: '' };
    accountsPickerAt = 0;
  }
  if (scope === 'clients' || scope === 'all') {
    tabDataCache.clients = { at: 0, key: '' };
  }
  if (scope === 'templates' || scope === 'all') {
    tabDataCache.templates = { at: 0 };
  }
  if (scope === 'meta' || scope === 'all') {
    tabDataCache.meta = { at: 0 };
  }
  if (scope === 'leadStatuses' || scope === 'all') {
    tabDataCache.leadStatuses = { at: 0 };
  }
}

async function ensureAccountsForLabels(force = false) {
  if (!force && accountsPickerAt && (Date.now() - accountsPickerAt) < TAB_DATA_TTL_MS && allAccounts.length) {
    return;
  }
  const picker = await j('/api/accounts?minimal=1', { timeoutMs: 30000 });
  accountsPickerAt = Date.now();
  if (!allAccounts.length) {
    allAccounts = picker || [];
    refreshOwnAccountUsernames();
    return;
  }
  const byPhone = new Map((picker || []).map(a => [a.phone, a]));
  const known = new Set(allAccounts.map(a => a.phone));
  for (const a of allAccounts) {
    const p = byPhone.get(a.phone);
    if (!p) continue;
    if (p.display_name) a.display_name = p.display_name;
    if (p.username) a.username = p.username;
  }
  for (const p of picker || []) {
    if (!known.has(p.phone)) allAccounts.push(p);
  }
  refreshOwnAccountUsernames();
}

async function fetchStats(opts = {}) {
  const force = !!opts.force;
  const now = Date.now();
  const wantFull = force || (opts.full === true)
    || (now - statsLastFullAt >= STATS_FULL_EVERY_MS);
  if (!force && statsCache.data && now - statsCache.at < STATS_CLIENT_CACHE_MS) {
    return statsCache.data;
  }
  if (statsInflight) return statsInflight;
  const reqOpts = { ...opts };
  delete reqOpts.force;
  delete reqOpts.full;
  const url = wantFull ? '/api/stats' : '/api/stats?light=1';
  statsInflight = j(url, { timeoutMs: 20000, ...reqOpts }).then(data => {
    statsCache = { at: Date.now(), data };
    window.__lastStats = data;
    if (wantFull) statsLastFullAt = Date.now();
    return data;
  }).finally(() => {
    statsInflight = null;
  });
  return statsInflight;
}

function applyPoolUiFromStats(st) {
  if (!st) return;
  syncAccCheckFromStats(st);
  accPoolUi = {
    online: Number(st.online ?? st.pool_connect?.online ?? 0),
    total: Number(st.pool_connect?.total || st.accounts || 0),
    connecting: isPoolConnectActive(st),
  };
}

function syncAccCheckFromStats(st) {
  if (st?.pool_checks_running) {
    if (!accCheckRunning) {
      accCheckRunning = true;
      setAccCheckBanner(true, 'Проверка аккаунтов…');
      setAccCheckButtonsDisabled(true);
    }
    return;
  }
  if (accCheckRunning && !accCheckRequestCtrl) {
    accCheckRunning = false;
    accCheckAbort = false;
    accCheckingIds.clear();
    setAccCheckBanner(false);
    setAccCheckButtonsDisabled(false);
  }
}

function poolConnectPendingCount(list) {
  return (list || []).filter(a => ['importing', 'connecting'].includes(a.pool_status)).length;
}

function poolConnectBannerLabel(online, total, pendingCount = 0) {
  const o = Number(online) || 0;
  const t = Number(total) || 0;
  const p = Number(pendingCount) || 0;
  if (p > 0) return `Подключение… ${o} online, ждём ${p}`;
  if (t > 0 && t >= o) return `Подключение… ${o}/${t}`;
  if (t > 0) return `Подключение… ${o} online (очередь ${t})`;
  return `Подключение… ${o} online`;
}

let accPoolPollTimer = null;
let accPoolBusy = false;
let accPoolBusySince = 0;
let accPoolStartCtrl = null;
let accPoolUi = { online: 0, total: 0, connecting: false };

function stopAccPoolPoll() {
  if (accPoolPollTimer) {
    clearInterval(accPoolPollTimer);
    accPoolPollTimer = null;
  }
}

function releasePoolBusyUi(reason = '') {
  const hadBusy = !!(accPoolBusy || accPoolUi.connecting);
  accPoolBusy = false;
  accPoolBusySince = 0;
  accPoolUi.connecting = false;
  setPoolStartButtonsDisabled(false);
  setAccPoolBanner(false);
  stopAccPoolPoll();
  renderAccPoolToggle();
  if (reason && hadBusy) showToast(reason, 'ok', { duration: 4500 });
}

function maybeReleaseStalePoolUi(st = null) {
  const stats = st || window.__lastStats;
  const serverConnecting = isPoolServerConnecting(stats);
  const pending = poolConnectPendingCount(allAccounts);
  if (!serverConnecting && pending <= 0 && accPoolBusy) {
    releasePoolBusyUi();
    return true;
  }
  if (
    !serverConnecting
    && pending <= 0
    && accPoolBusySince > 0
    && (Date.now() - accPoolBusySince) > 25000
  ) {
    releasePoolBusyUi('Подключение завершено');
    return true;
  }
  return false;
}

let accPoolPollLastOnline = -1;
let accTableLoadSeq = 0;
let accTableDebounceTimer = null;
let accTableLoading = false;
let accTableLoadInFlight = false;
let accTableLastOkAt = 0;
let accTableLoadErrorAt = 0;
const ACC_TABLE_RELOAD_MIN_MS = 8000;
const ACC_TABLE_RELOAD_BUSY_MS = 14000;
let accWarmupListPollAt = 0;

function accountsReloadThrottled() {
  const busy = isPoolConnectActive(window.__lastStats);
  const minMs = busy ? ACC_TABLE_RELOAD_BUSY_MS : ACC_TABLE_RELOAD_MIN_MS;
  return accTableLastOkAt > 0 && (Date.now() - accTableLastOkAt) < minMs;
}

function setAccountsTableBusy(on) {
  document.querySelector('.acc-table-wrap')?.classList.toggle('is-loading', !!on);
}

function accountsNeedAvatarRefresh(list) {
  return (list || []).some(a => accountProfileIsOnline(a) && !(a.avatar_url || a.avatar_path));
}

function scheduleLoadAccountsTable(force = false) {
  clearTimeout(accTableDebounceTimer);
  const sameFilter = tabDataCache.accounts.key === accountsTabCacheKey();
  if (!force && sameFilter && accountsReloadThrottled()) return;
  accTableDebounceTimer = setTimeout(() => loadAccountsTable({ force }), force ? 0 : 400);
}

function startAccPoolPoll() {
  stopAccPoolPoll();
  accPoolPollLastOnline = -1;
  accPoolPollTimer = setInterval(async () => {
    if (document.querySelector('.tab.active')?.id !== 'tab-accounts') return;
    try {
      const st = await fetchStats();
      applyPoolUiFromStats(st);
      const online = accPoolUi.online;
      const serverConnecting = isPoolServerConnecting(st);
      if (serverConnecting && !accPoolBusySince) accPoolBusySince = Date.now();
      accPoolUi.connecting = serverConnecting || accPoolBusy;
      if (maybeReleaseStalePoolUi(st)) return;
      if (serverConnecting && !accCheckRunning) {
        const pending = poolConnectPendingCount(allAccounts);
        setAccPoolBanner(true, poolConnectBannerLabel(online, accPoolUi.total, pending));
      } else if (!serverConnecting) {
        setAccPoolBanner(false);
      }
      renderAccPoolToggle();
      if (online !== accPoolPollLastOnline) {
        accPoolPollLastOnline = online;
        if (!accountsReloadThrottled()) scheduleLoadAccountsTable();
      }
      if (!serverConnecting) {
        stopAccPoolPoll();
        setAccPoolBanner(false);
        accPoolBusy = false;
        accPoolBusySince = 0;
        setPoolStartButtonsDisabled(false);
        accPoolPollLastOnline = -1;
        if (!accCheckRunning && accCheckingIds.size) accCheckingIds.clear();
        scheduleLoadAccountsTable(true);
      }
    } catch (_) {
      maybeReleaseStalePoolUi(window.__lastStats);
    }
  }, 6000);
}

function setAccPoolBanner(show, text = 'Подключение аккаунтов…') {
  const el = document.getElementById('accPoolBanner');
  const tx = document.getElementById('accPoolBannerText');
  if (tx) tx.textContent = text;
  if (el) el.hidden = !show;
  setPoolCancelVisible(!!show);
}

function setPoolCancelVisible(_visible) {
  renderAccPoolToggle();
}

function setPoolStartButtonsDisabled(_disabled) {
  /* accPoolToggle всегда кликабельна — в режиме ⏹ нужен cancel */
}

function poolToggleTargetIds() {
  const ids = getSelectedAccountIds();
  return ids.length ? ids : null;
}

function countOnlineInIds(ids) {
  if (!ids?.length) return 0;
  return ids.filter(id => {
    const a = getAccountById(id);
    if (!a) return false;
    return (a.pool_status || '').toLowerCase() === 'online';
  }).length;
}

function renderAccPoolToggle() {
  const btn = document.getElementById('accPoolToggle');
  if (!btn) return;
  const targetIds = poolToggleTargetIds();
  const selected = !!targetIds?.length;
  const selN = targetIds?.length || 0;
  const { online: globalOnline, total, connecting } = accPoolUi;
  const staleBusy = hasPoolConnectPending(poolActionAccounts(selected ? targetIds : null));
  const busy = connecting || accPoolBusy || staleBusy;
  const onlineCount = selected ? countOnlineInIds(targetIds) : globalOnline;

  btn.classList.remove('acc-cmd-pool-toggle--start', 'acc-cmd-pool-toggle--stop', 'acc-cmd-pool-toggle--busy');
  btn.disabled = false;

  if (busy) {
    btn.classList.add('acc-cmd-pool-toggle--busy');
    btn.dataset.mode = 'cancel';
    if (selected) {
      btn.textContent = `⏹ ${selN} выбр.`;
      btn.title = staleBusy && !connecting && !accPoolBusy
        ? `Сбросить зависшее подключение (${selN} выбранных)`
        : `Прервать подключение (${selN} выбранных в очереди)`;
    } else {
      btn.textContent = `⏹ ${globalOnline}${total && total > globalOnline ? `/${total}` : ''}`;
      btn.title = staleBusy && !connecting && !accPoolBusy
        ? 'Сбросить зависшее подключение в таблице'
        : 'Прервать очередь подключения (уже online останутся)';
    }
  } else if (onlineCount > 0) {
    btn.classList.add('acc-cmd-pool-toggle--stop');
    btn.dataset.mode = 'stop';
    if (selected) {
      btn.textContent = `■ ${onlineCount} выбр.`;
      btn.title = `Остановить ${onlineCount} из ${selN} выбранных`;
    } else {
      btn.textContent = `■ ${onlineCount}`;
      btn.title = `Остановить все ${onlineCount} online`;
    }
  } else {
    btn.classList.add('acc-cmd-pool-toggle--start');
    btn.dataset.mode = 'start';
    if (selected) {
      btn.textContent = `▶ ${selN} выбр.`;
      btn.title = `Запустить ${selN} выбранных (уже online пропустятся)`;
    } else {
      btn.textContent = '▶ Все';
      btn.title = 'Запустить offline (уже online не трогаем)';
    }
  }
}

async function updateAccPoolStatusUi() {
  try {
    const st = await fetchStats();
    applyPoolUiFromStats(st);
    if (accPoolUi.connecting && !accCheckRunning) {
      const pending = poolConnectPendingCount(allAccounts);
      setAccPoolBanner(true, poolConnectBannerLabel(
        accPoolUi.online, accPoolUi.total, pending,
      ));
    } else if (!accPoolUi.connecting) {
      setAccPoolBanner(false);
    }
    renderAccPoolToggle();
  } catch (_) {
    accPoolUi = { online: 0, total: 0, connecting: accPoolBusy };
    renderAccPoolToggle();
  }
}

function poolAutostartFromValue(v) {
  if (v === true || v === 1) return true;
  if (v === false || v === 0) return false;
  const s = String(v ?? '0').trim().toLowerCase();
  return s === '1' || s === 'true' || s === 'yes' || s === 'on';
}

function renderPoolAutostartState(enabled) {
  const btn = document.getElementById('accPoolAutostartBtn');
  const chk = document.getElementById('accPoolAutostart');
  if (chk) chk.checked = enabled;
  if (!btn) return;
  btn.classList.toggle('acc-cmd-toggle--on', enabled);
  btn.title = enabled
    ? 'Автостарт вкл — при start.bat аккаунты подключаются сами'
    : 'Автостарт выкл — подключение только вручную';
}

async function loadAccountsPoolUi() {
  const chk = document.getElementById('accPoolAutostart');
  try {
    const r = await j('/api/pool/autostart');
    const on = !!r.enabled;
    if (chk) chk.checked = on;
    renderPoolAutostartState(on);
  } catch (_) {
    if (chk) chk.checked = false;
    renderPoolAutostartState(false);
  }
  await updateAccPoolStatusUi();
}

async function savePoolAutostart(checked) {
  const chk = document.getElementById('accPoolAutostart');
  const want = checked ? '1' : '0';
  let stopNow = false;
  if (!checked) {
    const st = await fetchStats().catch(() => ({}));
    if ((st.online || 0) > 0) {
      stopNow = confirm(
        'Отключить автозапуск и остановить все аккаунты сейчас?\n\n' +
        'OK — выкл + стоп сейчас (экономия прокси).\nОтмена — только выкл автозапуска, текущие online останутся.'
      );
    }
  }
  const autostartBtn = document.getElementById('accPoolAutostartBtn');
  if (autostartBtn) autostartBtn.disabled = true;
  try {
    const r = await j('/api/pool/autostart', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ enabled: !!checked, stop_now: stopNow }),
    });
    if (String(r.saved_value) !== want) {
      throw new Error('Настройка не записалась в базу — перезапусти start.bat и попробуй снова');
    }
    const on = !!r.enabled;
    if (chk) chk.checked = on;
    renderPoolAutostartState(on);
    if (checked) {
      showToast('Автостарт вкл — при start.bat аккаунты подключатся сами');
    } else if (r.stopped) {
      showToast(`Автостарт выкл, остановлено ${r.stopped} акк.`);
      await loadAccountsTable();
      await updateAccPoolStatusUi();
      await refreshStats();
    } else {
      showToast('Автостарт выкл');
    }
  } catch (e) {
    if (chk) chk.checked = !checked;
    renderPoolAutostartState(!checked);
    showToast(e.message || 'Не удалось сохранить', 'err');
    await loadAccountsPoolUi();
  } finally {
    if (autostartBtn) autostartBtn.disabled = false;
  }
}

async function runPoolStart(accountIds = null) {
  if (accCheckRunning) {
    showToast('Идёт проверка — дождись «Отмена» в синей полоске', 'err');
    return;
  }
  if (accPoolBusy) {
    showToast('Подключение уже идёт — жми ⏹ на кнопке пула', 'err');
    return;
  }
  if (accPoolStartCtrl) {
    try { accPoolStartCtrl.abort(); } catch (_) { /* ignore */ }
  }
  accPoolStartCtrl = new AbortController();
  accPoolBusy = true;
  accPoolBusySince = Date.now();
  accPoolUi.connecting = true;
  renderAccPoolToggle();
  setPoolStartButtonsDisabled(true);
  setAccPoolBanner(true, accountIds?.length ? `Запуск ${accountIds.length} акк…` : 'Запуск всех аккаунтов…');
  startAccPoolPoll();
  try {
    const body = accountIds?.length ? { account_ids: accountIds } : {};
    const r = await j('/api/pool/start', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      timeoutMs: 12000,
      signal: accPoolStartCtrl.signal,
    });
    if (r.background) {
      showToast(accountIds?.length
        ? `Подключение ${accountIds.length} акк. в фоне…`
        : 'Подключение в фоне…', 'info', { duration: 4000 });
      return;
    }
    if (r.busy) {
      showToast('Подключение уже идёт', 'err');
      return;
    }
    let msg;
    if (r.started) {
      msg = `Подключено ${r.started}${r.failed ? `, ошибок ${r.failed}` : ''}${r.skipped ? `, уже online ${r.skipped}` : ''}`;
    } else if (r.failed) {
      msg = `Не подключено: ошибок ${r.failed}${r.skipped ? `, уже online ${r.skipped}` : ''}. Смотри «Ошибка» в таблице.`;
    } else if (r.skipped) {
      msg = `Уже online: ${r.skipped}`;
    } else {
      msg = 'Нечего подключать — нет включённых аккаунтов для подключения';
    }
    if (r.was_cancelled || r.cancelled) {
      msg = `Прервано — подключено ${r.started || 0}, пропущено ${r.cancelled || 0}`;
    }
    showToast(msg);
    if (r.was_cancelled || r.cancelled) {
      releasePoolBusyUi();
    }
    await loadAccountsTable();
    await updateAccPoolStatusUi();
  } catch (e) {
    const aborted = accPoolStartCtrl?.signal?.aborted
      || /отмен/i.test(e?.message || '');
    if (aborted) return;
    const msg = e.message || 'Не удалось запустить';
    if (/проверк/i.test(msg)) {
      accCheckRunning = true;
      setAccCheckBanner(true, 'Идёт проверка — дождитесь окончания');
      setAccCheckButtonsDisabled(true);
    }
    showToast(msg, 'err');
    releasePoolBusyUi();
  } finally {
    accPoolStartCtrl = null;
  }
}

async function resetStalePoolBusy() {
  try {
    const r = await j('/api/pool/reset-stale-busy', { method: 'POST' });
    return r.reset || 0;
  } catch (_) {
    return 0;
  }
}

async function forceReleasePoolUi(msg) {
  abortAccCheckRequest();
  accCheckAbort = false;
  accCheckRunning = false;
  accCheckingIds.clear();
  setAccCheckBanner(false);
  setAccCheckButtonsDisabled(false);
  if (accPoolStartCtrl) {
    try { accPoolStartCtrl.abort(); } catch (_) { /* ignore */ }
    accPoolStartCtrl = null;
  }
  accPoolBusy = false;
  accPoolBusySince = 0;
  accPoolUi.connecting = false;
  setPoolStartButtonsDisabled(false);
  setAccPoolBanner(false);
  stopAccPoolPoll();
  if (msg) showToast(msg, 'warning', { duration: 6000 });
  await loadAccountsTable();
  await updateAccPoolStatusUi();
  await refreshStats();
}

async function runPoolCancelStart(stopConnected = false) {
  const btn = document.getElementById('accPoolToggle');
  if (btn) btn.disabled = true;
  setAccPoolBanner(true, 'Отмена подключения…');
  if (accPoolStartCtrl) {
    try { accPoolStartCtrl.abort(); } catch (_) { /* ignore */ }
  }
  abortAccCheckRequest();
  accCheckAbort = true;
  const pendingChecks = [...accCheckingIds];
  try {
    const r = await j('/api/pool/cancel-start', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ stop_connected: !!stopConnected }),
      timeoutMs: 8000,
    });
    const reset = await resetStalePoolBusy();
    const checkReset = pendingChecks.length
      ? await cancelAccountChecks(pendingChecks)
      : await cancelAccountChecks();
    let msg = r.was_connecting
      ? 'Очередь подключения прервана'
      : 'Подключение остановлено';
    if (stopConnected && r.stopped) msg += `, отключено ${r.stopped}`;
    else if (!stopConnected && r.was_connecting) {
      msg += ` — online остались (${r.online ?? 0})`;
    }
    const extra = (reset || 0) + (checkReset || 0) + Number(r.reset_busy || 0);
    if (extra > 0) msg += `, сброшено зависших: ${extra}`;
    await forceReleasePoolUi(msg);
  } catch (e) {
    const reset = await resetStalePoolBusy();
    const checkReset = await cancelAccountChecks(pendingChecks.length ? pendingChecks : null);
    if (reset > 0 || checkReset > 0) {
      await forceReleasePoolUi(
        `Зависшее состояние сброшено (${reset + checkReset})`,
      );
    } else {
      await forceReleasePoolUi(e.message || 'Принудительно остановлено');
    }
  } finally {
    if (btn) btn.disabled = false;
    renderAccPoolToggle();
  }
}

async function runPoolStop(accountIds = null, { confirm: needConfirm = true } = {}) {
  const n = accountIds?.length;
  const label = n ? `${n} выбранных` : 'все';
  const connecting = accPoolBusy || accPoolUi.connecting;
  const staleBusy = hasPoolBusyAccounts(n ? accountIds : null);
  const extra = (connecting || staleBusy) && !n
    ? '\n\nСейчас идёт или зависло подключение — очередь тоже будет прервана.'
    : '';
  if (needConfirm && !accountIds?.length && accCheckRunning) {
    if (!confirm(
      'Идёт SpamBot/проверка сессий.\n\n'
      + '⏹ без выбора остановит ВСЕ online-аккаунты (все страны).\n'
      + 'Лучше нажми «Отмена» в синей полоске проверки.\n\n'
      + 'Всё равно остановить весь пул?'
    )) return;
  }
  if (needConfirm && !confirm(`Отключить ${label} аккаунт(ов) от Telegram?${extra}\n\nСессии (.session) не удаляются.`)) return;
  if ((connecting || staleBusy) && !n) {
    await runPoolCancelStart(false);
  }
  try {
    const body = accountIds?.length ? { account_ids: accountIds } : {};
    const r = await j('/api/pool/stop', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      timeoutMs: 120000,
    });
    showToast(`Остановлено: ${r.stopped || 0}, online осталось: ${r.remaining_online ?? 0}`);
    accPoolBusy = false;
    setPoolStartButtonsDisabled(false);
    setAccPoolBanner(false);
    stopAccPoolPoll();
    await loadAccountsTable();
    await updateAccPoolStatusUi();
    await refreshStats();
  } catch (e) {
    showToast(e.message || 'Не удалось остановить', 'err');
  }
}

let accImportPollTimer = null;

function stopAccImportPoll() {
  if (accImportPollTimer) {
    clearInterval(accImportPollTimer);
    accImportPollTimer = null;
  }
}

function startAccImportPoll() {
  stopAccImportPoll();
  accImportPollTimer = setInterval(async () => {
    if (document.querySelector('.tab.active')?.id !== 'tab-accounts') return;
    try {
      if (!accountsReloadThrottled()) scheduleLoadAccountsTable();
      if (!hasPendingImports(allAccounts)) {
        stopAccImportPoll();
        showToast('Импорт завершён');
      }
    } catch (_) { /* ignore */ }
  }, 12000);
}

const MAX_STATUS_FAVORITES = 5;
const CLIENT_STARRED_FILTER = 'starred';
const CLIENT_STATUS_PINS_KEY = 'clientStatusQuickPins';

const STATUS_COLOR_PRESETS = [
  '#9aa4ff', '#ff9f43', '#2ed573', '#9ca3af', '#b8a8ff',
  '#ff6b6b', '#48dbfb', '#feca57', '#1dd1a1', '#5f27cd',
];

let leadStatusOptions = [
  { value: 'new', label: 'Новый', description: '', color: '#9aa4ff', builtin: true },
  { value: 'work', label: 'В работе', description: '', color: '#ff9f43', builtin: true },
  { value: 'sale', label: 'Продажа', description: '', color: '#2ed573', builtin: true },
  { value: 'reject', label: 'Отказ', description: '', color: '#9ca3af', builtin: true },
];
let _tplStatusDraft = [];

function _statusTagStyle(color) {
  if (!color) return '';
  return ` style="background:${color}33;color:${color}"`;
}

function _statusPreviewStyle(color) {
  const c = color || '#b8a8ff';
  return `background:${c}33;color:${c}`;
}

function statusLabel(s) {
  if (!s) return '— не менять —';
  return leadStatusOptions.find(o => o.value === s)?.label || s;
}

function fillLeadStatusSelect(sel, { includeEmpty = false, emptyLabel = '— не менять —', selected = '', options = null } = {}) {
  if (!sel) return;
  const source = Array.isArray(options) && options.length ? options : leadStatusOptions;
  const opts = includeEmpty
    ? [{ value: '', label: emptyLabel }, ...source]
    : [...source];
  sel.innerHTML = opts.map(o => {
    const hint = o.description ? ` title="${esc(o.description)}"` : '';
    return `<option value="${esc(o.value)}"${hint}>${esc(o.label)}</option>`;
  }).join('');
  const want = selected || (includeEmpty ? '' : (source[0]?.value || 'new'));
  sel.value = opts.some(o => o.value === want) ? want : (opts[0]?.value || '');
}

function fillClientStatusFilter(sel, { selected = '' } = {}) {
  if (!sel) return;
  const prev = selected || sel.value || '';
  sel.innerHTML = '<option value="">Все</option>' +
    `<option value="${CLIENT_STARRED_FILTER}">★ Избранные</option>` +
    leadStatusOptions.map(o => {
      const hint = o.description ? ` title="${esc(o.description)}"` : '';
      return `<option value="${esc(o.value)}"${hint}>${esc(o.label)}</option>`;
    }).join('');
  sel.value = prev && [...sel.options].some(o => o.value === prev) ? prev : '';
}

function fillChatStatusFilter(sel, { selected = '' } = {}) {
  if (!sel) return;
  const prev = selected || sel.value || '';
  sel.innerHTML = '<option value="">Все статусы</option>' +
    `<option value="${CLIENT_STARRED_FILTER}">★ Избранные</option>` +
    leadStatusOptions.map(o => {
      const hint = o.description ? ` title="${esc(o.description)}"` : '';
      return `<option value="${esc(o.value)}"${hint}>${esc(o.label)}</option>`;
    }).join('');
  sel.value = prev && [...sel.options].some(o => o.value === prev) ? prev : '';
}

function isLeadStarred(v) {
  return Number(v) === 1 || v === true;
}

function leadStarInlineHtml(starred) {
  return isLeadStarred(starred) ? '<span class="chat-star-inline" title="Избранный клиент">★</span>' : '';
}

function syncLeadStarBtn(starred) {
  const btn = document.getElementById('leadStarBtn');
  if (!btn) return;
  const on = isLeadStarred(starred);
  btn.classList.toggle('active', on);
  btn.title = on ? 'Убрать из избранного' : 'В избранное клиентов';
}

async function loadLeadStatuses({ force = false } = {}) {
  if (!force && _cacheFresh(tabDataCache.leadStatuses, LEAD_STATUS_TTL_MS)) {
    refreshAllStatusSelects();
    return;
  }
  try {
    const r = await j('/api/lead-statuses');
    const rows = r.statuses || [];
    if (rows.length) {
      leadStatusOptions = rows.map(s => ({
        value: s.id,
        label: s.label,
        description: s.description || '',
        color: s.color || '#b8a8ff',
        favorite: !!s.favorite,
        builtin: !!s.builtin,
      }));
    }
  } catch (_) {}
  tabDataCache.leadStatuses = { at: Date.now() };
  refreshAllStatusSelects();
}

function refreshAllStatusSelects() {
  const leadSel = document.getElementById('leadStatus');
  const leadVal = leadSel?.value || '';
  fillLeadStatusSelect(leadSel, { selected: leadVal || 'new' });
  fillClientStatusFilter(document.getElementById('clientStatusFilter'));
  fillChatStatusFilter(document.getElementById('chatStatusFilter'));
  const trig = document.getElementById('tplAutoTriggerStatus');
  const after = document.getElementById('tplAutoAfterStatus');
  if (trig) fillLeadStatusSelect(trig, { selected: trig.value || 'new' });
  if (after) fillLeadStatusSelect(after, { includeEmpty: true, selected: after.value || '' });
}

function _loadClientStatusPins() {
  try {
    const raw = JSON.parse(localStorage.getItem(CLIENT_STATUS_PINS_KEY) || '[]');
    if (!Array.isArray(raw)) return [];
    return raw.map(s => String(s));
  } catch (_) {
    return [];
  }
}

function _saveClientStatusPins(pins) {
  try {
    localStorage.setItem(CLIENT_STATUS_PINS_KEY, JSON.stringify(pins));
  } catch (_) { /* ignore */ }
}

function _clientQuickPinCatalog() {
  return [
    { value: '', label: 'Все', description: 'Все клиенты', color: 'var(--muted)' },
    { value: CLIENT_STARRED_FILTER, label: '★ Избранные', description: 'Все избранные клиенты', color: '#feca57' },
    ...leadStatusOptions.map(o => ({
      value: o.value,
      label: o.label,
      description: o.description || '',
      color: o.color || '#b8a8ff',
    })),
  ];
}

function _clientQuickPinCount(st, stats) {
  if (st === '') return stats?.total || 0;
  if (st === CLIENT_STARRED_FILTER) return stats?.[CLIENT_STARRED_FILTER] || 0;
  return stats?.[st] || 0;
}

function _clientQuickPinButton(st, meta, stats, activeStatus) {
  const cnt = _clientQuickPinCount(st, stats);
  const active = activeStatus === st ? ' active' : '';
  const color = meta.color || '#b8a8ff';
  const title = meta.description ? `${meta.label} — ${meta.description}` : meta.label;
  const starredCls = st === CLIENT_STARRED_FILTER ? ' client-st-starred-quick' : '';
  const style = st === '' ? '' : ` style="border-color:${color};color:${color}"`;
  return `<button type="button" class="client-st-quick${starredCls}${active}" data-st="${esc(st)}"${style} title="${esc(title)}">${esc(meta.label)} ${cnt}</button>`;
}

let _clientPinMenuCloser = null;

function _closeClientPinMenu() {
  const menu = document.getElementById('clientStatusPinMenu');
  if (menu) {
    menu.hidden = true;
    menu.dataset.anchorMode = '';
  }
  if (_clientPinMenuCloser) {
    document.removeEventListener('click', _clientPinMenuCloser, true);
    _clientPinMenuCloser = null;
  }
}

function _openClientPinMenu(mode, anchorEl, stats) {
  _closeClientPinMenu();
  const menu = document.getElementById('clientStatusPinMenu');
  if (!menu) return;
  const pins = _loadClientStatusPins();
  const catalog = _clientQuickPinCatalog();
  const catalogMap = new Map(catalog.map(o => [o.value, o]));
  const items = mode === 'add'
    ? catalog.filter(o => !pins.includes(o.value))
    : pins.map(st => catalogMap.get(st)).filter(Boolean);

  if (!items.length) {
    menu.innerHTML = `<div class="client-st-pin-menu-empty">${mode === 'add' ? 'Все статусы уже закреплены' : 'Нет закреплённых статусов'}</div>`;
  } else {
    menu.innerHTML = items.map(o => {
      const cnt = _clientQuickPinCount(o.value, stats);
      const prefix = mode === 'remove' ? '− ' : '';
      return `<button type="button" class="client-st-pin-menu-item" data-st="${esc(o.value)}" data-mode="${mode}">${prefix}${esc(o.label)} <span class="hint">${cnt}</span></button>`;
    }).join('');
  }

  menu.hidden = false;
  menu.dataset.anchorMode = mode;
  const rect = anchorEl.getBoundingClientRect();
  menu.style.left = `${Math.max(8, rect.left)}px`;
  menu.style.top = `${rect.bottom + 4}px`;

  menu.querySelectorAll('.client-st-pin-menu-item').forEach(btn => {
    btn.onclick = (e) => {
      e.stopPropagation();
      const st = btn.dataset.st ?? '';
      const pinsNow = _loadClientStatusPins();
      if (mode === 'add') {
        if (!pinsNow.includes(st)) _saveClientStatusPins([...pinsNow, st]);
      } else {
        _saveClientStatusPins(pinsNow.filter(p => p !== st));
        const sel = document.getElementById('clientStatusFilter');
        if (sel && sel.value === st) {
          sel.value = '';
          loadClients();
          return;
        }
      }
      _closeClientPinMenu();
      renderClientStatusQuick(stats, document.getElementById('clientStatusFilter')?.value || '');
    };
  });

  _clientPinMenuCloser = (e) => {
    if (!menu.contains(e.target) && e.target !== anchorEl && !anchorEl.contains(e.target)) {
      _closeClientPinMenu();
    }
  };
  setTimeout(() => document.addEventListener('click', _clientPinMenuCloser, true), 0);
}

function renderClientStatusQuick(stats, activeStatus = '') {
  const el = document.getElementById('clientStatusQuick');
  if (!el) return;
  const pins = _loadClientStatusPins();
  const catalogMap = new Map(_clientQuickPinCatalog().map(o => [o.value, o]));

  el.hidden = false;
  const pinButtons = pins.map(st => {
    const meta = catalogMap.get(st);
    if (!meta) return '';
    return _clientQuickPinButton(st, meta, stats, activeStatus);
  }).join('');

  el.innerHTML = pinButtons +
    '<button type="button" class="client-st-pin-btn" data-pin-mode="add" title="Закрепить статус">+</button>' +
    '<button type="button" class="client-st-pin-btn" data-pin-mode="remove" title="Открепить статус">−</button>';

  el.querySelectorAll('.client-st-quick').forEach(btn => {
    btn.onclick = () => {
      const sel = document.getElementById('clientStatusFilter');
      const st = btn.dataset.st || '';
      if (sel) sel.value = st;
      loadClients();
    };
  });
  el.querySelectorAll('.client-st-pin-btn').forEach(btn => {
    btn.onclick = (e) => {
      e.stopPropagation();
      const menu = document.getElementById('clientStatusPinMenu');
      if (menu && !menu.hidden && menu.dataset.anchorMode === btn.dataset.pinMode) {
        _closeClientPinMenu();
        return;
      }
      _openClientPinMenu(btn.dataset.pinMode, btn, stats);
    };
  });
}

function _slugStatusPreview(label) {
  const map = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
  const lat = 'abvgdeezzijklmnoprstufhzcss_y_eu';
  let s = String(label || '').toLowerCase().trim();
  let out = '';
  for (const ch of s) {
    const i = map.indexOf(ch);
    if (i >= 0) out += lat[i] || '_';
    else if (/[a-z0-9]/.test(ch)) out += ch;
    else if (ch === ' ' || ch === '-' || ch === '_') out += '_';
  }
  out = out.replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 28);
  if (!out) out = 'status';
  if (!/^[a-z][a-z0-9_]{0,31}$/.test(out)) {
    out = ('s_' + out).replace(/[^a-z0-9_]+/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 31);
  }
  if (!/^[a-z][a-z0-9_]{0,31}$/.test(out)) out = 'status';
  return out;
}

function _ensureStatusSlug(id, label, existingIds) {
  let sid = String(id || '').trim().toLowerCase();
  if (!/^[a-z][a-z0-9_]{0,31}$/.test(sid)) sid = _slugStatusPreview(label);
  let cand = sid;
  let n = 2;
  while (existingIds.has(cand)) {
    cand = `${sid}_${n}`;
    n += 1;
  }
  existingIds.add(cand);
  return cand;
}

function _isJobRuleEnabled(r) {
  if (!r) return false;
  const v = r.enabled;
  if (v === false || v === 0 || v === '0') return false;
  return v === true || v === 1 || v === '1' || v === 'true';
}

function _hasJobRuleBinding(rules, status, tplId) {
  const st = _normStatusId(status);
  const tid = String(tplId);
  return (rules || []).some(rule =>
    _normStatusId(rule.trigger_status) === st
    && String(rule.template_id) === tid
    && _isJobRuleEnabled(rule)
  );
}

function _mergeJobRulesCache(rules) {
  if (!Array.isArray(rules)) return;
  tplJobRulesCache = rules;
  const idx = tplJobsCache.findIndex(j => String(j.id) === String(tplJobId));
  if (idx >= 0) tplJobsCache[idx].autoreply_rules = rules;
}

function _mergeTplJobData(job) {
  if (!job || !job.id) return;
  const idx = tplJobsCache.findIndex(j => String(j.id) === String(job.id));
  if (idx >= 0) tplJobsCache[idx] = { ...tplJobsCache[idx], ...job };
  else tplJobsCache.unshift(job);
  if (String(job.id) === String(tplJobId) && Array.isArray(job.autoreply_rules)) {
    tplJobRulesCache = job.autoreply_rules;
  }
}

function _tplStatusDraftFromRows(rows) {
  return rows.map(s => ({
    id: s.id,
    label: s.label,
    description: s.description || '',
    color: s.color || '#b8a8ff',
    favorite: !!s.favorite,
    builtin: !!s.builtin,
    clients: s.clients || 0,
    scope: s.scope || 'global',
    job_id: s.job_id || null,
    used_by_selected_job: !!s.used_by_selected_job,
  }));
}

function _syncLeadStatusOptions(rows) {
  if (!rows.length) return;
  leadStatusOptions = rows.map(s => ({
    value: s.id,
    label: s.label,
    description: s.description || '',
    color: s.color || '#b8a8ff',
    favorite: !!s.favorite,
    builtin: !!s.builtin,
  }));
}

function _tplStatusFavoriteCount() {
  return _tplStatusDraft.filter(s => s.favorite).length;
}

function _updateTplStatusPreview(card, s) {
  const preview = card?.querySelector('.tpl-st-preview');
  if (!preview) return;
  const label = (s.label || '').trim() || 'Название';
  preview.textContent = label;
  preview.style.cssText = _statusPreviewStyle(s.color);
}

function renderTplStatusEditor() {
  const rowsEl = document.getElementById('tplStatusRows');
  if (!rowsEl) return;
  rowsEl.innerHTML = _tplStatusDraft.map((s, idx) => {
    const label = (s.label || '').trim() || 'Название';
    const clients = Number(s.clients) || 0;
    const clientsLabel = clients === 1 ? '1 клиент' : `${clients} клиентов`;
    const colorDots = STATUS_COLOR_PRESETS.map(c =>
      `<button type="button" class="tpl-st-color-dot${c === s.color ? ' active' : ''}" data-idx="${idx}" data-color="${c}" style="background:${c}" title="${c}"></button>`
    ).join('');
    const delBtn = s.builtin
      ? ''
      : `<button type="button" class="ghost tpl-st-del tpl-status-del" data-idx="${idx}">Удалить</button>`;
    const baseBadge = s.builtin ? '<span class="tpl-st-badge-base">Базовый</span>' : '';
    const scopeBadge = s.scope === 'job' && s.job_id
      ? `<span class="tpl-st-badge-job">задача: #${s.job_id}</span>`
      : (s.used_by_selected_job
        ? '<span class="tpl-st-badge-global">глобальный / используется</span>'
        : '<span class="tpl-st-badge-global">глобальный</span>');
    const favCls = s.favorite ? ' active' : '';
    return `<article class="tpl-st-card" data-idx="${idx}">
      <div class="tpl-st-card-top">
        <button type="button" class="tpl-st-fav${favCls}" data-idx="${idx}" title="Быстрый фильтр в «Клиенты» (до ${MAX_STATUS_FAVORITES})">★</button>
        <span class="tpl-st-preview" style="${_statusPreviewStyle(s.color)}">${esc(label)}</span>
        <div class="tpl-st-meta">
          ${baseBadge}${scopeBadge}
          <span class="tpl-st-clients">${esc(clientsLabel)}</span>
          ${delBtn}
        </div>
      </div>
      <label class="tpl-st-field">
        <span>Название</span>
        <input type="text" class="tpl-status-label" data-idx="${idx}" value="${esc(s.label)}" placeholder="Например: Холодный лид" />
      </label>
      <label class="tpl-st-field">
        <span>Описание</span>
        <textarea class="tpl-status-desc" data-idx="${idx}" rows="2" maxlength="200" placeholder="Когда ставить этот статус">${esc(s.description)}</textarea>
      </label>
      <div class="tpl-st-colors">
        <span>Цвет</span>
        ${colorDots}
      </div>
    </article>`;
  }).join('');

  rowsEl.querySelectorAll('.tpl-status-label').forEach(inp => {
    inp.oninput = () => {
      const i = Number(inp.dataset.idx);
      if (!_tplStatusDraft[i]) return;
      _tplStatusDraft[i].label = inp.value;
      _updateTplStatusPreview(inp.closest('.tpl-st-card'), _tplStatusDraft[i]);
    };
  });
  rowsEl.querySelectorAll('.tpl-status-desc').forEach(inp => {
    inp.oninput = () => {
      const i = Number(inp.dataset.idx);
      if (_tplStatusDraft[i]) _tplStatusDraft[i].description = inp.value;
    };
  });
  rowsEl.querySelectorAll('.tpl-st-fav').forEach(btn => {
    btn.onclick = () => {
      const i = Number(btn.dataset.idx);
      if (!_tplStatusDraft[i]) return;
      const next = !_tplStatusDraft[i].favorite;
      if (next && _tplStatusFavoriteCount() >= MAX_STATUS_FAVORITES) {
        showToast(`Можно отметить ★ не больше ${MAX_STATUS_FAVORITES} статусов`, 'err');
        return;
      }
      _tplStatusDraft[i].favorite = next;
      btn.classList.toggle('active', next);
    };
  });
  rowsEl.querySelectorAll('.tpl-st-color-dot').forEach(btn => {
    btn.onclick = () => {
      const i = Number(btn.dataset.idx);
      const color = btn.dataset.color || '#b8a8ff';
      if (!_tplStatusDraft[i]) return;
      _tplStatusDraft[i].color = color;
      const card = btn.closest('.tpl-st-card');
      card?.querySelectorAll('.tpl-st-color-dot').forEach(dot => {
        dot.classList.toggle('active', dot.dataset.color === color);
      });
      _updateTplStatusPreview(card, _tplStatusDraft[i]);
    };
  });
  rowsEl.querySelectorAll('.tpl-status-del').forEach(btn => {
    btn.onclick = () => {
      const i = Number(btn.dataset.idx);
      _tplStatusDraft.splice(i, 1);
      renderTplStatusEditor();
    };
  });
}

function _tplScopeState() {
  const scope = (document.getElementById('tplScopeSelect')?.value || '').trim().toLowerCase();
  const rawJob = document.getElementById('tplScopeJobSelect')?.value || '';
  const selectedJobId = rawJob ? Number(rawJob) : null;
  return { scope: scope || 'all', selectedJobId };
}

function _tplLeadStatusesUrl() {
  const { scope, selectedJobId } = _tplScopeState();
  const params = new URLSearchParams();
  if (scope === 'global') {
    params.set('scope', 'global');
  } else if (scope === 'job') {
    if (!selectedJobId) return null;
    params.set('scope', 'job');
    params.set('job_id', String(selectedJobId));
  } else {
    params.set('scope', 'all');
  }
  return `/api/lead-statuses?${params.toString()}`;
}

async function loadTplStatusEditorData() {
  const url = _tplLeadStatusesUrl();
  if (!url) {
    _tplStatusDraft = [];
    renderTplStatusEditor();
    return;
  }
  const r = await j(url);
  const rows = r.statuses || [];
  _tplStatusDraft = _tplStatusDraftFromRows(rows);
  renderTplStatusEditor();
}

function showTplAgentMode(on) {
  const layout = document.getElementById('tplMainLayout');
  const panel = document.getElementById('tplAgentPanel');
  const root = document.getElementById('templatesRoot');
  if (layout) layout.dataset.mode = on ? 'agent' : 'templates';
  if (panel) panel.hidden = !on;
  if (root) root.classList.remove('sales-learning-mode');
  if (on) {
    const pb = document.getElementById('tplPlaybookPanel'); if (pb) pb.hidden = true;
    const st = document.getElementById('tplStatusPanel'); if (st) st.hidden = true;
  }
  document.querySelectorAll('.tpl-category-tab').forEach(btn => {
    if (btn.id === 'btnTplAgentTab') btn.classList.toggle('active', on);
    else if (on) btn.classList.remove('active');
  });
  if (on) loadAgentTab().catch(e => showToast(e.message || 'Ошибка загрузки', 'err'));
}

function showTplStatusesMode(on) {
  const layout = document.getElementById('tplMainLayout');
  const panel = document.getElementById('tplStatusPanel');
  const root = document.getElementById('templatesRoot');
  if (layout) layout.dataset.mode = on ? 'statuses' : 'templates';
  if (panel) panel.hidden = !on;
  if (root) root.classList.remove('sales-learning-mode');
  if (on) {
    const pb = document.getElementById('tplPlaybookPanel');
    if (pb) pb.hidden = true;
  }
  document.querySelectorAll('.tpl-category-tab').forEach(btn => {
    if (btn.id === 'btnTplStatusesTab') btn.classList.toggle('active', on);
    else if (on) btn.classList.remove('active');
  });
  if (on) {
    loadTplStatusEditorData().catch(e => showToast(e.message || 'Ошибка загрузки', 'err'));
  }
}

function setTemplatesPlaybookMode(enabled) {
  const on = !!enabled;
  const layout = document.getElementById('tplMainLayout');
  const panel = document.getElementById('tplPlaybookPanel');
  const root = document.getElementById('templatesRoot');
  if (layout) layout.dataset.mode = on ? 'playbook' : 'templates';
  if (panel) panel.hidden = !on;
  if (root) root.classList.toggle('sales-learning-mode', !!on);
  if (on) {
    const st = document.getElementById('tplStatusPanel');
    if (st) st.hidden = true;
    const empty = document.getElementById('tplEditorEmpty');
    const form = document.getElementById('tplEditorForm');
    const board = document.getElementById('tplFunnelBoard');
    if (empty) empty.hidden = true;
    if (form) form.hidden = true;
    if (board) board.hidden = true;
  } else {
    const st = document.getElementById('tplStatusPanel');
    if (st) st.hidden = true;
    if (layout && layout.dataset.mode === 'playbook') layout.dataset.mode = 'templates';
  }
  document.querySelectorAll('.tpl-category-tab').forEach(btn => {
    if (btn.id === 'btnTplPlaybookTab') btn.classList.toggle('active', on);
    else if (on) btn.classList.remove('active');
  });
  if (on) {
    loadPlaybookItems().catch(e => showToast(e.message || 'Ошибка загрузки', 'err'));
  }
}

function showTplPlaybookMode(on) {
  setTemplatesPlaybookMode(on);
}

// ── База техник продаж ──
let _pbItemsCache = [];
let _pbSection = 'base';
let _pbJobs = [];
let _pbSelectedId = null;
const SALES_LEARNING_ANALYSIS_DISABLED = false;

const _REC_TYPE_LABELS_RU = {
  classifier_marker: 'Маркер классификатора',
  playbook_item: 'Правило обработки',
  template_improvement: 'Улучшение шаблона',
  transition_improvement: 'Улучшение перехода',
  first_message_improvement: 'Улучшение первого сообщения',
  good_response_pattern: 'Хороший паттерн',
  bad_response_pattern: 'Плохой паттерн',
};

const _ACTION_LABELS_RU = {
  soft_exit: 'Мягко закрыть',
  answer_objection: 'Ответить на возражение',
  ask_clarifying_question: 'Уточнить',
  human_handoff: 'Передать человеку',
  continue: 'Продолжить',
  offer_trial: 'Предложить тест/демо',
};

function _pbSwitchSection(sec) {
  _pbSection = sec || 'base';
  document.querySelectorAll('.pb-tab').forEach(b => b.classList.toggle('active', b.dataset.pbTab === _pbSection));
  const m = {
    base: 'pbPaneBase',
    analysis: 'pbPaneAnalysis',
    recs: 'pbPaneRecs',
    markers: 'pbPaneMarkers',
  };
  Object.values(m).forEach(id => {
    const el = document.getElementById(id);
    if (el) el.hidden = id !== m[_pbSection];
  });
  if (_pbSection === 'recs') _loadSalesLearningRecs().catch(() => {});
  if (_pbSection === 'markers') _loadSalesLearningMarkers().catch(() => {});
}

function _scopeLabelRu(s) {
  const x = (s || '').toLowerCase();
  if (x === 'global') return 'Глобально';
  if (x === 'category') return 'Категория';
  if (x === 'job') return 'Для задачи';
  return x || '—';
}

async function loadPlaybookItems() {
  const q = document.getElementById('pbSearch')?.value?.trim() || '';
  const objection = document.getElementById('pbObjectionFilter')?.value || '';
  const scope = document.getElementById('pbScopeFilter')?.value || '';
  const params = new URLSearchParams();
  if (q) params.set('q', q);
  if (objection) params.set('objection_type', objection);
  if (scope) params.set('scope', scope);
  const r = await j(`/api/sales-playbook?${params}`);
  _pbItemsCache = r.items || [];
  _renderPlaybookList();
  _syncPlaybookFilters();
  await _loadBroadcastJobsSimple().catch(() => {});
  _loadSalesLearningRecs().catch(() => {});
  _loadSalesLearningMarkers().catch(() => {});
}

async function _loadBroadcastJobsSimple() {
  const sel = document.getElementById('slJobSelect');
  if (!sel) return;
  const r = await j('/api/broadcast/jobs/simple');
  _pbJobs = r.jobs || [];
  const _jobLabel = (x) => {
    const t = String(x?.title || x?.name || x?.label || '').trim();
    return t ? `${t} — #${x.id}` : `Задача #${x.id}`;
  };
  sel.innerHTML = '<option value="">Все задачи</option>' +
    _pbJobs.map(x => `<option value="${x.id}">${esc(_jobLabel(x))}</option>`).join('');
}

function _syncPlaybookFilters() {
  const objSel = document.getElementById('pbObjectionFilter');
  const catSel = document.getElementById('pbCategoryFilter');
  if (objSel && objSel.options.length <= 1) {
    const types = [...new Set(_pbItemsCache.map(i => i.objection_type).filter(Boolean))].sort();
    objSel.innerHTML = '<option value="">— все возражения —</option>' +
      types.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
  }
  if (catSel && catSel.options.length <= 1) {
    const categories = [...new Set(_pbItemsCache.map(i => i.category).filter(Boolean))].sort();
    catSel.innerHTML = '<option value="">— все категории —</option>' +
      categories.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
  }
}

function _getFilteredPlaybookItems() {
  const activeFilter = document.getElementById('pbActiveFilter')?.value || '';
  const categoryFilter = document.getElementById('pbCategoryFilter')?.value || '';
  return _pbItemsCache.filter(it => {
    if (activeFilter === 'active' && !it.is_active) return false;
    if (activeFilter === 'inactive' && it.is_active) return false;
    if (categoryFilter && (it.category || '') !== categoryFilter) return false;
    return true;
  });
}

function _renderPlaybookDetail(item) {
  const detail = document.getElementById('pbDetail');
  if (!detail) return;
  if (!item) {
    detail.innerHTML = '<div class="hint">Выбери технику слева или создай новую.</div>';
    return;
  }
  const statusLabel = item.is_active ? 'Активна' : 'Неактивна';
  const actionLabel = item.is_active ? 'Деактивировать' : 'Активировать';
  detail.innerHTML = `
    <h3 class="pb-subtitle">${esc(item.title || 'Без названия')}</h3>
    <div class="pb-toolbar">
      <span class="pb-badge pb-cat">${esc(item.category || 'global')}</span>
      <span class="pb-badge pb-obj">${esc(item.objection_type || 'other')}</span>
      <span class="pb-badge">${esc(item.funnel_stage || '—')}</span>
      <span class="pb-badge ${item.is_active ? 'pb-ok' : 'pb-pending'}">${statusLabel}</span>
    </div>
    <p class="pb-situation"><b>Описание:</b> ${esc(item.situation || '—')}</p>
    <p class="pb-situation"><b>Стратегия:</b> ${esc(item.recommended_strategy || '—')}</p>
    <p class="pb-situation"><b>Формула:</b> ${esc(item.response_formula || '—')}</p>
    <p class="pb-situation"><b>Что говорить:</b> ${esc((item.do_say || []).join(' · ') || item.response_template || '—')}</p>
    <p class="pb-situation"><b>Что не говорить:</b> ${esc((item.dont_say || []).join(' · ') || '—')}</p>
    <p class="pb-situation"><b>Границы:</b> ${esc((item.ethical_boundaries || []).join(' · ') || '—')}</p>
    <div class="pb-toolbar">
      <button type="button" class="primary" disabled title="Редактирование не поддерживается текущим API">Сохранить</button>
      <button type="button" class="ghost" disabled title="Создание не поддерживается текущим API">Создать</button>
      <button type="button" class="ghost" disabled title="Изменение активности не поддерживается текущим API">${actionLabel}</button>
    </div>
    <div class="hint">Сейчас этот экран работает в режиме просмотра: API пока не поддерживает ручное создание/редактирование техник.</div>
  `;
}

function _renderPlaybookList() {
  const box = document.getElementById('pbList');
  const stats = document.getElementById('pbStats');
  if (!box) return;
  const rows = _getFilteredPlaybookItems();
  if (stats) {
    const active = rows.filter(i => i.is_active).length;
    const approved = rows.filter(i => i.review_status === 'approved').length;
    stats.textContent = rows.length
      ? `Показано: ${rows.length} · активных: ${active} · approved: ${approved} · всего в базе: ${_pbItemsCache.length}`
      : 'База пуста — нажми «Загрузить стартовую базу техник»';
  }
  if (_pbSelectedId && !rows.some(x => Number(x.id) === Number(_pbSelectedId))) _pbSelectedId = null;
  if (!_pbSelectedId && rows.length) _pbSelectedId = rows[0].id;
  box.innerHTML = rows.map(it => `
    <div class="pb-card pb-card-selectable${it.is_active ? '' : ' pb-inactive'}${Number(it.id) === Number(_pbSelectedId) ? ' pb-card-selected' : ''}" data-pb-id="${it.id}">
      <div class="pb-card-head">
        <strong>${esc(it.title)}</strong>
        <span class="pb-badges">
          ${it.category ? `<span class="pb-badge pb-cat">${esc(it.category)}</span>` : '<span class="pb-badge">global</span>'}
          ${it.objection_type ? `<span class="pb-badge pb-obj">${esc(it.objection_type)}</span>` : ''}
          ${it.funnel_stage ? `<span class="pb-badge">${esc(it.funnel_stage)}</span>` : ''}
          <span class="pb-badge ${it.review_status === 'approved' ? 'pb-ok' : 'pb-pending'}">${esc(it.review_status)}</span>
        </span>
      </div>
      <p class="pb-situation">${esc(it.situation)}</p>
      <p class="pb-situation">Категория: ${esc(it.category || 'global')} · Этап: ${esc(it.funnel_stage || '—')}</p>
    </div>
  `).join('') || '<div class="hint">Нет техник под текущие фильтры.</div>';
  box.querySelectorAll('[data-pb-id]').forEach(card => {
    card.onclick = () => {
      _pbSelectedId = Number(card.dataset.pbId);
      _renderPlaybookList();
    };
  });
  const selected = rows.find(x => Number(x.id) === Number(_pbSelectedId)) || null;
  _renderPlaybookDetail(selected);
}

async function _pbSeedDefaults() {
  const btn = document.getElementById('btnPbSeed');
  if (btn) { btn.disabled = true; btn.textContent = 'Загрузка...'; }
  try {
    const r = await j('/api/sales-playbook/seed', { method: 'POST' });
    showToast(r.created
      ? `Загружено техник: ${r.created} (всего: ${r.total})`
      : `База уже загружена (всего: ${r.total}), дубли не созданы`);
    await loadPlaybookItems();
  } catch (e) {
    showToast(e.message || 'Ошибка загрузки базы', 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Загрузить стартовую базу техник'; }
  }
}

async function _runSalesDialogueAnalysis() {
  const scope = document.getElementById('slScope')?.value || 'global';
  const analysis_mode = document.getElementById('slAnalysisMode')?.value || 'fast';
  let scopeHintEl = document.getElementById('slScopeHint');
  if (!scopeHintEl) {
    const toolbar = document.querySelector('#pbPaneAnalysis .pb-toolbar');
    if (toolbar && toolbar.parentElement) {
      scopeHintEl = document.createElement('div');
      scopeHintEl.id = 'slScopeHint';
      scopeHintEl.className = 'hint';
      toolbar.parentElement.insertBefore(scopeHintEl, toolbar.nextSibling);
    }
  }
  if (scopeHintEl) {
    if (scope === 'global') scopeHintEl.textContent = 'Глобально: ищет общие паттерны по всей базе, может включать шум из разных задач.';
    else if (scope === 'job') scopeHintEl.textContent = 'Для задачи: лучше для анализа конкретной рассылки/воронки.';
    else scopeHintEl.textContent = 'Категория: полезно, когда накопилось много задач одной ниши.';
  }
  const category = (document.getElementById('slCategory')?.value || '').trim();
  const jobIdRaw = document.getElementById('slJobSelect')?.value || '';
  const limit = Number(document.getElementById('slLimit')?.value || 100);
  const all = !limit;
  const job_id = jobIdRaw ? Number(jobIdRaw) : null;
  const payload = { scope, category: category || null, job_id, limit, all, analysis_mode };
  if (SALES_LEARNING_ANALYSIS_DISABLED) {
    showToast('Анализ временно отключён до диагностики', 'err');
    const statusElOff = document.getElementById('slAnalyzeStatus');
    const boxOff = document.getElementById('slAnalyzeResult');
    if (statusElOff) statusElOff.textContent = 'Анализ временно отключён до диагностики.';
    if (boxOff) {
      boxOff.hidden = false;
      boxOff.classList.add('err');
      boxOff.textContent = 'Анализ временно отключён до диагностики';
    }
    return;
  }
  const btn = document.getElementById('btnSalesAnalyze');
  const statusEl = document.getElementById('slAnalyzeStatus');
  const box = document.getElementById('slAnalyzeResult');
  const cards = document.getElementById('slAnalyzeCards');
  if (scope === 'job' && !job_id) {
    showToast('Для уровня «Для задачи» выберите задачу', 'err');
    return;
  }
  if (all && !confirm('Будут использованы AI-вызовы и анализ до safety cap. Продолжить?')) return;
  if (btn) { btn.disabled = true; btn.textContent = 'Анализирую...'; }
  if (statusEl) statusEl.textContent = analysis_mode === 'stats_only'
    ? 'Собираю локальную статистику без AI...'
    : 'Собираю диалоги и запускаю AI-анализ. Подождите.';
  if (box) { box.hidden = true; box.textContent = ''; box.classList.remove('err'); }
  if (cards) { cards.hidden = true; cards.innerHTML = ''; }
  try {
    const r = await j('/api/sales-learning/analyze-dialogues', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!r?.ok) {
      const err = r?.error || 'Неизвестная ошибка';
      if (box) {
        box.hidden = false;
        box.classList.add('err');
        box.textContent = `Анализ не запустился\n${err}`;
      }
      if (statusEl) statusEl.textContent = 'Ошибка анализа';
      return;
    }
    const result = r.result || {};
    const outcomes = result.outcomes_summary || {};
    const dbg = r.ai_debug || {};
    const noiseCount = Number((r.local_stats || {}).noise_dialogues_count || 0);
    const scopeNoiseWarning = (scope === 'global' && noiseCount >= 5)
      ? 'В базе много нерелевантного входящего шума. Для точного анализа выберите конкретную задачу или используйте фильтр по категории.'
      : '';
    const dbgText =
      `AI-анализ\n` +
      `Использовался ИИ: ${dbg.ai_used ? 'да' : 'нет'}\n` +
      `Вызов был: ${dbg.ai_call_attempted ? 'да' : 'нет'}\n` +
      `Успешно: ${dbg.ai_call_success ? 'да' : 'нет'}\n` +
      `Батчей: ${dbg.batches_success ?? 0}/${dbg.batches_total ?? 0}\n` +
      `Ошибок батчей: ${dbg.batches_failed ?? 0}\n` +
      `Partial result: ${dbg.partial_result ? 'да' : 'нет'}\n` +
      `Провайдер: ${dbg.ai_provider || '-'}\n` +
      `Модель: ${dbg.ai_model || '-'}\n` +
      `Purpose: ${dbg.ai_purpose || '-'}\n` +
      `Fallback: ${dbg.fallback_used ? 'да' : 'нет'}\n` +
      `Причина fallback: ${dbg.fallback_reason || '-'}\n` +
      `Диалогов отправлено: ${dbg.dialogues_sent_to_ai ?? '-'}\n` +
      `Сообщений отправлено: ${dbg.messages_sent_to_ai ?? '-'}\n` +
      `Символов отправлено: ${dbg.chars_sent_to_ai ?? '-'}\n` +
      `Snippets sent to AI: ${dbg.snippets_sent_to_ai ?? '-'}\n` +
      `Snippets chars: ${dbg.snippets_chars ?? '-'}\n` +
      (dbg.usage ? `Токены: prompt=${dbg.usage.prompt_tokens ?? '-'}, completion=${dbg.usage.completion_tokens ?? '-'}, total=${dbg.usage.total_tokens ?? '-'}` : 'Токены: недоступно');
    if (statusEl) statusEl.textContent = 'Анализ завершён';
    if (cards) {
      const arr = [
        ['Диалогов', `${r.dialogues_count}/${r.total_available}`],
        ['Сообщений', String(r.messages_count || 0)],
        ['Snippets sent', String(r.snippets_sent_to_ai ?? 0)],
        ['AI batches', `${dbg.batches_success ?? 0}/${dbg.batches_total ?? 0}`],
        ['Время', `${Number(r.duration_seconds || 0).toFixed(2)} c`],
        ['Создано рекомендаций', String(r.recommendations_created || 0)],
        ['Noise count', String(noiseCount)],
      ];
      cards.hidden = false;
      cards.innerHTML = arr.map(([k, v]) => `<div class="pb-kpi"><span class="hint">${esc(k)}</span><b>${esc(v)}</b></div>`).join('');
    }
    if (box) {
      box.hidden = false;
      box.classList.remove('err');
      box.textContent =
        `Анализ завершён\n` +
        `Режим: ${analysis_mode}\n` +
        `Длительность: ${Number(r.duration_seconds || 0).toFixed(2)} сек\n` +
        `Диалогов: ${r.dialogues_count}/${r.total_available}\n` +
        `Сообщений: ${r.messages_count}\n` +
        `Snippets selected: ${r.snippets_selected ?? 0}\n` +
        `Snippets sent to AI: ${r.snippets_sent_to_ai ?? 0}\n` +
        `Уровень: ${_scopeLabelRu(scope)}\n` +
        `Создано рекомендаций: ${r.recommendations_created}\n` +
        `${Number(r.auto_applied_learning_count || 0) > 0 ? `Автоприменено: ${r.auto_applied_learning_count} (проверено ${r.auto_apply_safe?.checked ?? 0}, пропущено ${r.auto_apply_safe?.skipped ?? 0})\n` : (Number(r.recommendations_created || 0) > 0 ? `Автоприменено: 0 — проверь «Автообучение» в задаче или нажми «Автоприменить pending»\n` : '')}` +
        `${r.auto_apply_safe?.funnel_bootstrap ? `Воронка: +${r.auto_apply_safe.funnel_bootstrap.applied_transitions || 0} переходов, +${r.auto_apply_safe.funnel_bootstrap.applied_templates || 0} сценариев в шаблонах\n` : ''}` +
        `${scopeNoiseWarning ? `Предупреждение scope: ${scopeNoiseWarning}\n` : ''}` +
        `${r.warning ? `Предупреждение: ${r.warning}\n` : ''}` +
        `Summary: ${result.summary || r.summary || '—'}\n` +
        `Data quality: ${result.data_quality_note || '—'}\n` +
        `Outcomes: ${JSON.stringify(outcomes)}\n` +
        `Что работает: ${(result.what_works || []).length}\n` +
        `Что ломает: ${(result.what_breaks_dialogue || []).length}\n` +
        `Паттернов клиента: ${(result.found_client_patterns || []).length}\n` +
        `Local stats: ${JSON.stringify(r.local_stats || {})}\n\n` +
        `${dbgText}\n` +
        `${dbg.partial_result ? '\nАнализ выполнен частично: часть батчей упала по timeout.\n' : ''}` +
        `${dbg.fallback_used ? '\nВнимание: результат получен без полноценного AI-анализа. Использован локальный fallback.' : ''}`;
    }
    _pbSwitchSection('recs');
    const slStatus = document.getElementById('slRecStatus');
    if (slStatus) slStatus.value = 'pending';
    const appliedCnt = Number(r.auto_applied_learning_count || r.auto_apply_safe?.applied || 0);
    const createdCnt = Number(r.recommendations_created || 0);
    if (createdCnt > 0) {
      if (!r.auto_apply_enabled && scope === 'job') {
        showToast(
          `Анализ готов (${createdCnt} рек.), но Автообучение ВЫКЛ на задаче — нажми «Автоприменить pending» или включи в воронке`,
          'warn',
        );
      } else {
        showToast(
          appliedCnt > 0
            ? `Анализ готов: ${createdCnt} рекомендаций, автоприменено ${appliedCnt}`
            : `Анализ готов: ${createdCnt} в очереди — нажми «Автоприменить pending»`,
          appliedCnt > 0 ? 'ok' : 'warn',
        );
      }
    }
    await _loadSalesLearningRecs();
  } catch (e) {
    console.error('sales analyze error', e);
    if (statusEl) statusEl.textContent = 'Анализ не запустился';
    if (box) {
      box.hidden = false;
      box.classList.add('err');
      box.textContent = `Анализ не запустился\n${e.message || e}`;
    }
    showToast('AI-анализ сейчас недоступен, попробуйте позже / проверьте настройки модели', 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Запустить анализ'; }
  }
}

function _buildIngestPayload(rawInput, { dryRun = false } = {}) {
  const scope = document.getElementById('slScope')?.value || 'job';
  const analysis_mode_raw = document.getElementById('slAnalysisMode')?.value || 'deep';
  const analysis_mode = analysis_mode_raw === 'stats_only' ? 'deep' : analysis_mode_raw;
  const category = (document.getElementById('slCategory')?.value || '').trim();
  const jobIdRaw = document.getElementById('slJobSelect')?.value || '';
  const job_id = jobIdRaw ? Number(jobIdRaw) : null;
  if (scope === 'job' && !job_id) throw new Error('Для уровня «Для задачи» выберите задачу');
  const raw = String(rawInput || '').trim();
  if (!raw) throw new Error('Добавь текст кейса или JSON');
  const payload = {
    scope,
    category: category || null,
    job_id,
    analysis_mode,
    auto_apply: !dryRun,
    dry_run: !!dryRun,
  };
  let source = 'text';
  try {
    const parsed = JSON.parse(raw);
    if (Array.isArray(parsed)) {
      payload.recommendations = parsed;
      source = 'recommendations[]';
    } else if (parsed && typeof parsed === 'object') {
      if (Array.isArray(parsed.recommendations)) {
        payload.recommendations = parsed.recommendations;
        if (parsed.analysis && typeof parsed.analysis === 'object') payload.analysis = parsed.analysis;
        source = 'wrapped';
      } else if (
        Array.isArray(parsed.template_improvements)
        || Array.isArray(parsed.transition_improvements)
        || Array.isArray(parsed.new_classifier_markers)
        || Array.isArray(parsed.bad_response_patterns)
        || Array.isArray(parsed.good_response_patterns)
      ) {
        payload.analysis = parsed;
        source = 'analysis';
      } else {
        payload.analysis = parsed;
        source = 'json';
      }
    } else {
      payload.case_text = String(raw);
    }
  } catch (_) {
    payload.case_text = String(raw);
  }
  return { payload, source };
}

function _renderIngestResult(r, source, { dryRun = false } = {}) {
  const statusEl = document.getElementById('slAnalyzeStatus');
  const box = document.getElementById('slAnalyzeResult');
  const created = Number(r.recommendations_created || 0);
  const applied = Number(r.auto_applied_learning_count || r.auto_apply_safe?.applied || 0);
  const details = Array.isArray(r.auto_apply_safe?.details) ? r.auto_apply_safe.details : [];
  const detailLines = details.slice(0, 12).map((d, idx) => {
    const mark = d.applied ? '✅' : '—';
    const why = d.detail || d.reason || '';
    return `${mark} #${idx + 1}: ${why}`;
  }).join('\n');
  const preview = Array.isArray(r.recommendations_preview) ? r.recommendations_preview : [];
  const previewLines = preview.slice(0, 12).map((x, i) => {
    const t = x.type || 'unknown';
    const title = x.payload?.title || x.payload?.summary || x.payload?.phrase || '';
    return `• ${i + 1}. ${t}: ${title}`;
  }).join('\n');
  if (box) {
    box.hidden = false;
    box.classList.remove('err');
    box.textContent =
      `${dryRun ? 'Предпросмотр ingest' : 'Внешний кейс применён'}\n` +
      `Источник: ${source}\n` +
      `Создано рекомендаций: ${created}\n` +
      `${dryRun ? 'Режим: dry-run (без записи в БД и без применения)\n' : `Автоприменено: ${applied} (проверено ${r.auto_apply_safe?.checked ?? 0}, пропущено ${r.auto_apply_safe?.skipped ?? 0})\n`}` +
      `${r.auto_apply_safe?.funnel_bootstrap ? `Воронка: +${r.auto_apply_safe.funnel_bootstrap.applied_transitions || 0} переходов, +${r.auto_apply_safe.funnel_bootstrap.applied_templates || 0} сценариев\n` : ''}` +
      `Summary: ${r.summary || '—'}\n` +
      `${previewLines ? `\nПредпросмотр рекомендаций:\n${previewLines}\n` : ''}` +
      `${detailLines ? `\nПрименённые действия:\n${detailLines}` : ''}`;
  }
  if (statusEl) statusEl.textContent = dryRun ? 'Предпросмотр ingest готов' : 'Внешний кейс применён';
}

async function _runSalesLearningIngest({ dryRun = false } = {}) {
  const btn = document.getElementById(dryRun ? 'btnSalesIngestPreview' : 'btnSalesIngestApply');
  const statusEl = document.getElementById('slAnalyzeStatus');
  const textArea = document.getElementById('slIngestText');
  const raw = textArea?.value || '';
  const old = btn?.textContent || (dryRun ? 'Предпросмотр ingest' : 'Применить ingest');
  if (btn) { btn.disabled = true; btn.textContent = dryRun ? 'Проверяю...' : 'Применяю...'; }
  if (statusEl) statusEl.textContent = dryRun ? 'Собираю предпросмотр ingest...' : 'Применяю внешний кейс...';
  try {
    const { payload, source } = _buildIngestPayload(raw, { dryRun });
    const r = await j('/api/sales-learning/ingest', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!r?.ok) throw new Error(r?.error || 'ingest failed');
    _renderIngestResult(r, source, { dryRun });
    if (dryRun) {
      showToast(`Предпросмотр: ${Number(r.recommendations_created || 0)} рекомендаций`, 'ok');
      return;
    }
    const applied = Number(r.auto_applied_learning_count || r.auto_apply_safe?.applied || 0);
    showToast(`Кейс принят: ${Number(r.recommendations_created || 0)} рек., автоприменено ${applied}`, applied > 0 ? 'ok' : 'warn');
    _pbSwitchSection('recs');
    const slStatus = document.getElementById('slRecStatus');
    if (slStatus) slStatus.value = 'pending';
    await _loadSalesLearningRecs();
    await _loadSalesLearningMarkers();
    await loadPlaybookItems();
  } catch (e) {
    const box = document.getElementById('slAnalyzeResult');
    if (statusEl) statusEl.textContent = 'Ошибка ingest';
    if (box) {
      box.hidden = false;
      box.classList.add('err');
      box.textContent = `Не удалось выполнить ingest\n${e.message || e}`;
    }
    showToast(e.message || 'Ошибка ingest', 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = old; }
  }
}

async function _handleSalesIngestFileSelect() {
  const fileInput = document.getElementById('slIngestFile');
  const textArea = document.getElementById('slIngestText');
  const f = fileInput?.files?.[0];
  if (!f || !textArea) return;
  const txt = await f.text();
  textArea.value = txt;
  showToast(`Файл загружен: ${f.name} (${txt.length} chars)`);
}

async function _salesLearningAutoApplyPending() {
  const btn = document.getElementById('btnSalesAutoApplyPending');
  const jobPick = document.getElementById('slJobSelect');
  const jobId = jobPick?.value ? Number(jobPick.value) : null;
  const old = btn?.textContent || '';
  if (btn) { btn.disabled = true; btn.textContent = 'Применяю...'; }
  try {
    const r = await j('/api/sales-learning/auto-apply-pending', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ job_id: jobId || null, limit: 300, default_confidence: 0.75 }),
    });
    const applied = Number(r.applied || 0);
    const skipped = Number(r.skipped || 0);
    const checked = Number(r.checked || 0);
    showToast(`Автоприменено ${applied} из ${checked} (пропущено ${skipped})`, applied ? 'ok' : 'warn');
    await _loadSalesLearningRecs();
    await _loadSalesLearningMarkers();
    await loadPlaybookItems();
  } catch (e) {
    showToast(e.message || 'Автоприменение не удалось', 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = old || 'Автоприменить pending'; }
  }
}

async function _loadSalesLearningRecs() {
  const st = document.getElementById('slRecStatus')?.value ?? 'pending';
  const typeFilter = document.getElementById('slRecTypeFilter')?.value || '';
  const sortMode = document.getElementById('slRecSort')?.value || 'priority';
  const r = await j(`/api/sales-learning/recommendations?status=${encodeURIComponent(st)}&limit=100`);
  const el = document.getElementById('slRecsList');
  const hintEl = document.getElementById('slRecsHint');
  if (!el) return;
  const rows = (r.items || []).filter(x => !typeFilter || x.type === typeFilter).slice().sort((a, b) => {
    if (sortMode === 'newest') return Number(b.id || 0) - Number(a.id || 0);
    const stA = (a.status || '') === 'pending' ? 0 : 1;
    const stB = (b.status || '') === 'pending' ? 0 : 1;
    if (stA !== stB) return stA - stB;
    const pA = Number(a?.payload_json?.priority ?? 99);
    const pB = Number(b?.payload_json?.priority ?? 99);
    if (pA !== pB) return pA - pB;
    const order = { classifier_marker: 0, playbook_item: 1, template_improvement: 2, first_message_improvement: 2, transition_improvement: 3, bad_response_pattern: 4, good_response_pattern: 5, client_pattern: 6 };
    const tA = order[a.type] ?? 99;
    const tB = order[b.type] ?? 99;
    if (tA !== tB) return tA - tB;
    return Number(b.id || 0) - Number(a.id || 0);
  });
  const recTypeRu = {
    classifier_marker: 'Маркер',
    playbook_item: 'Правило',
    template_improvement: 'Шаблон',
    first_message_improvement: 'Первое сообщение',
    transition_improvement: 'Переход',
    bad_response_pattern: 'Плохой паттерн',
    good_response_pattern: 'Хороший паттерн',
    client_pattern: 'Паттерн клиента',
  };
  const shortEvidence = (payload) => {
    const ev = payload?.evidence;
    if (!ev) return '';
    if (typeof ev === 'string') return ev.slice(0, 120);
    if (Array.isArray(ev)) return ev.map(x => String(x || '')).join(' · ').slice(0, 120);
    return String(ev).slice(0, 120);
  };
  if (hintEl) {
    if (st === 'pending') {
      hintEl.textContent = rows.length
        ? `На проверке: ${rows.length}. Одобрять вручную не нужно, если включено Автообучение.`
        : 'Очередь пуста — всё уже применено. Живые правила: вкладки «Маркеры» и «База техник».';
    } else if (!st) {
      hintEl.textContent = `История применённых рекомендаций (${rows.length} в списке). Это журнал, не очередь на действие.`;
    } else {
      hintEl.textContent = `Фильтр: ${st}. Применённые правила смотри во вкладках «Маркеры» и «База техник».`;
    }
  }
  el.innerHTML = rows.map(x => {
    const isPending = (x.status || '') === 'pending';
    const statusRu = (x.status || '').replace('pending', 'На проверке').replace('approved', 'Применено').replace('rejected', 'Отклонено');
    const actionsHtml = isPending
      ? `<div class="pb-toolbar">
        <button type="button" class="ghost" data-sl-approve="${x.id}">Одобрить</button>
        <button type="button" class="ghost" data-sl-reject="${x.id}">Отклонить</button>
        <button type="button" class="ghost" data-sl-more="${x.id}">Подробнее</button>
      </div>`
      : `<div class="pb-toolbar">
        <span class="hint">${(x.status || '') === 'approved' ? 'Уже в системе (playbook / маркеры / шаблоны)' : 'Отклонено'}</span>
        <button type="button" class="ghost" data-sl-more="${x.id}">Подробнее</button>
      </div>`;
    return `
    <div class="pb-card${isPending ? '' : ' pb-card-done'}">
      <div class="pb-card-head">
        <strong>${esc((x.payload_json?.title || x.payload_json?.summary || _REC_TYPE_LABELS_RU[x.type] || x.type))}</strong>
        <span class="pb-badges"><span class="pb-badge">${esc(_scopeLabelRu(x.scope || ''))}</span><span class="pb-badge">${esc(statusRu)}</span></span>
      </div>
      <p class="pb-situation">${esc((x.payload_json?.summary || x.payload_json?.problem || x.payload_json?.phrase || x.payload_json?.pattern || '').slice(0, 220))}</p>
      <p class="pb-situation">Тип: ${esc(recTypeRu[x.type] || x.type)} · Priority: ${esc(String(x.payload_json?.priority ?? '-'))} · Confidence: ${esc(String(x.payload_json?.confidence ?? '-'))} · Mode: ${esc(String(x.payload_json?.source_mode || '-'))}</p>
      <p class="pb-situation"><b>Действие:</b> ${esc(String(x.payload_json?.suggested_action || (isPending ? 'Проверить рекомендацию вручную перед применением.' : 'Применено автоматически.')))}</p>
      ${shortEvidence(x.payload_json) ? `<p class="pb-situation"><b>Evidence:</b> ${esc(shortEvidence(x.payload_json))}</p>` : ''}
      ${actionsHtml}
      <div class="hint" id="slRecMore${x.id}" hidden>${esc(JSON.stringify(x.payload_json || {}, null, 2))}</div>
    </div>`;
  }).join('') || (st === 'pending'
    ? '<div class="hint">Нет рекомендаций на проверке — всё уже применено. Смотри вкладки «Маркеры» и «База техник».</div>'
    : '<div class="hint">Нет рекомендаций по выбранному фильтру</div>');
  el.querySelectorAll('[data-sl-approve]').forEach(b => {
    b.onclick = async () => {
      await j(`/api/sales-learning/recommendations/${b.dataset.slApprove}/approve`, { method: 'POST' });
      await _loadSalesLearningRecs();
      await _loadSalesLearningMarkers();
      await loadPlaybookItems();
      showToast('Рекомендация одобрена');
    };
  });
  el.querySelectorAll('[data-sl-reject]').forEach(b => {
    b.onclick = async () => {
      await j(`/api/sales-learning/recommendations/${b.dataset.slReject}/reject`, { method: 'POST' });
      await _loadSalesLearningRecs();
      showToast('Рекомендация отклонена');
    };
  });
  el.querySelectorAll('[data-sl-more]').forEach(b => {
    b.onclick = () => {
      const box = document.getElementById(`slRecMore${b.dataset.slMore}`);
      if (box) box.hidden = !box.hidden;
    };
  });
}

async function _loadSalesLearningMarkers() {
  const r = await j('/api/sales-learning/classifier-markers?active_only=0');
  const el = document.getElementById('slMarkersList');
  if (!el) return;
  const rows = r.items || [];
  const q = (document.getElementById('slMarkerSearch')?.value || '').trim().toLowerCase();
  const activeFilter = document.getElementById('slMarkerActiveFilter')?.value || '';
  const filtered = rows.filter(m => {
    if (activeFilter === 'active' && !Number(m.is_active)) return false;
    if (activeFilter === 'inactive' && Number(m.is_active)) return false;
    if (q && !String(m.phrase || '').toLowerCase().includes(q)) return false;
    return true;
  });
  el.innerHTML = filtered.slice(0, 80).map(m => `
    <div class="pb-card${Number(m.is_active) ? '' : ' pb-inactive'}">
      <div class="pb-card-head">
        <strong>${esc(m.phrase || '')}</strong>
        <span class="pb-badges"><span class="pb-badge">${esc(m.objection_type || '')}</span><span class="pb-badge">${esc(_ACTION_LABELS_RU[m.recommended_action] || m.recommended_action || '')}</span></span>
      </div>
      <p class="pb-situation">Уровень: ${esc(_scopeLabelRu(m.scope || ''))} ${m.category ? `· Категория: ${esc(m.category)}` : ''} ${m.job_id ? `· Задача: ${m.job_id}` : ''} · ${Number(m.is_active) ? 'Активен' : 'Отключён'}</p>
      <div class="pb-toolbar">
        <button type="button" class="ghost" data-sl-marker-toggle="${m.id}" data-on="${Number(m.is_active) ? 1 : 0}">${Number(m.is_active) ? 'Отключить' : 'Включить'}</button>
        <button type="button" class="ghost" data-sl-marker-del="${m.id}">Деактивировать</button>
      </div>
    </div>
  `).join('') || '<div class="hint">Нет маркеров под текущие фильтры.</div>';
  el.querySelectorAll('[data-sl-marker-toggle]').forEach(b => {
    b.onclick = async () => {
      const id = Number(b.dataset.slMarkerToggle);
      const on = Number(b.dataset.on) === 1;
      const marker = rows.find(x => Number(x.id) === id);
      if (!marker) return;
      await j(`/api/sales-learning/classifier-markers/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          phrase: marker.phrase || '',
          objection_type: marker.objection_type || 'other',
          intent: marker.intent || 'other',
          recommended_action: marker.recommended_action || 'continue',
          scope: marker.scope || 'global',
          category: marker.category || null,
          job_id: marker.job_id || null,
          confidence_score: marker.confidence_score || 0.8,
          is_active: !on,
        }),
      });
      await _loadSalesLearningMarkers();
      showToast(!on ? 'Маркер включён' : 'Маркер отключён');
    };
  });
  el.querySelectorAll('[data-sl-marker-del]').forEach(b => {
    b.onclick = async () => {
      await j(`/api/sales-learning/classifier-markers/${Number(b.dataset.slMarkerDel)}`, { method: 'DELETE' });
      await _loadSalesLearningMarkers();
      showToast('Маркер деактивирован');
    };
  });
}

async function _addSalesLearningMarker() {
  const phrase = (document.getElementById('slMarkerPhrase')?.value || '').trim();
  const objection_type = (document.getElementById('slMarkerType')?.value || '').trim();
  let recommended_action = (document.getElementById('slMarkerAction')?.value || '').trim();
  const scope = (document.getElementById('slMarkerScope')?.value || 'global').trim();
  const category = (document.getElementById('slCategory')?.value || '').trim() || null;
  const job_id = (document.getElementById('slJobSelect')?.value || '').trim() || null;
  if (!phrase) return showToast('Нужна фраза маркера', 'err');
  if (!objection_type) return showToast('Нужен тип возражения', 'err');
  if (!recommended_action) {
    if (['aggression', 'not_interested'].includes(objection_type)) recommended_action = 'soft_exit';
    else if (['price', 'trust', 'comparison', 'technical', 'already_has_solution', 'setup', 'security', 'quality', 'guarantee'].includes(objection_type)) recommended_action = 'answer_objection';
    else if (objection_type === 'ready_to_try') recommended_action = 'offer_trial';
    else if (objection_type === 'ready_to_buy') recommended_action = 'continue';
    else recommended_action = 'ask_clarifying_question';
  }
  await j('/api/sales-learning/classifier-markers', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      phrase,
      objection_type,
      intent: 'other',
      recommended_action,
      scope,
      category: scope === 'category' ? category : null,
      job_id: scope === 'job' ? (job_id ? Number(job_id) : null) : null,
      is_active: true,
    }),
  });
  document.getElementById('slMarkerPhrase').value = '';
  document.getElementById('slMarkerAction').value = '';
  await _loadSalesLearningMarkers();
  showToast('Маркер добавлен');
}

document.getElementById('btnPbSeed')?.addEventListener('click', _pbSeedDefaults);
document.getElementById('btnSalesAnalyze')?.addEventListener('click', () => _runSalesDialogueAnalysis().catch(e => showToast(e.message || 'Ошибка анализа', 'err')));
document.getElementById('btnSalesIngest')?.addEventListener('click', () => {
  const textArea = document.getElementById('slIngestText');
  if (textArea) textArea.focus();
  showToast('Вставь JSON/кейс ниже и нажми «Предпросмотр ingest»');
});
document.getElementById('btnSalesIngestPreview')?.addEventListener('click', () => _runSalesLearningIngest({ dryRun: true }).catch(e => showToast(e.message || 'Ошибка ingest', 'err')));
document.getElementById('btnSalesIngestApply')?.addEventListener('click', () => _runSalesLearningIngest({ dryRun: false }).catch(e => showToast(e.message || 'Ошибка ingest', 'err')));
document.getElementById('slIngestFile')?.addEventListener('change', () => _handleSalesIngestFileSelect().catch(e => showToast(e.message || 'Ошибка чтения файла', 'err')));
document.getElementById('btnSalesLoadRecs')?.addEventListener('click', () => _loadSalesLearningRecs().catch(e => showToast(e.message || 'Ошибка загрузки recs', 'err')));
document.getElementById('btnSalesAddMarker')?.addEventListener('click', () => _addSalesLearningMarker().catch(e => showToast(e.message || 'Ошибка добавления маркера', 'err')));
document.getElementById('btnPbGoRecs')?.addEventListener('click', () => _pbSwitchSection('recs'));
document.getElementById('btnSalesAutoApplyPending')?.addEventListener('click', () => _salesLearningAutoApplyPending().catch(() => {}));
document.getElementById('slRecStatus')?.addEventListener('change', () => _loadSalesLearningRecs().catch(() => {}));
document.getElementById('slRecTypeFilter')?.addEventListener('change', () => _loadSalesLearningRecs().catch(() => {}));
document.getElementById('slRecSort')?.addEventListener('change', () => _loadSalesLearningRecs().catch(() => {}));
document.querySelectorAll('.pb-tab').forEach(b => b.addEventListener('click', () => _pbSwitchSection(b.dataset.pbTab)));
document.getElementById('pbSearch')?.addEventListener('input', () => {
  clearTimeout(window._pbSearchTimer);
  window._pbSearchTimer = setTimeout(() => loadPlaybookItems().catch(() => {}), 300);
});
['pbObjectionFilter', 'pbScopeFilter'].forEach(id => {
  document.getElementById(id)?.addEventListener('change', () => loadPlaybookItems().catch(() => {}));
});
['pbActiveFilter', 'pbCategoryFilter'].forEach(id => {
  document.getElementById(id)?.addEventListener('change', () => _renderPlaybookList());
});
document.getElementById('slMarkerSearch')?.addEventListener('input', () => _loadSalesLearningMarkers().catch(() => {}));
document.getElementById('slMarkerActiveFilter')?.addEventListener('change', () => _loadSalesLearningMarkers().catch(() => {}));

async function saveTplStatuses() {
  const payload = _tplStatusDraft
    .map(s => ({
      id: s.id || '',
      label: (s.label || '').trim(),
      description: (s.description || '').trim().slice(0, 200),
      color: s.color || '#b8a8ff',
      favorite: !!s.favorite,
      builtin: !!s.builtin,
    }))
    .filter(s => s.label);
  if (!payload.length) {
    showToast('Добавь хотя бы один статус', 'err');
    return;
  }
  try {
    const r = await j('/api/lead-statuses', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ statuses: payload }),
    });
    const rows = r.statuses || [];
    _syncLeadStatusOptions(rows);
    refreshAllStatusSelects();
    _tplStatusDraft = _tplStatusDraftFromRows(rows);
    renderTplStatusEditor();
    showToast('Статусы сохранены');
    if (document.getElementById('tab-clients')?.classList.contains('active')) {
      loadClients().catch(() => {});
    }
  } catch (e) {
    showToast(e.message || 'Ошибка', 'err');
  }
}

function initials(name) {
  const p = (name || '?').trim();
  return (p[0] || '?').toUpperCase();
}

function avKey(account, peerId) {
  return `${account}|${peerId}`;
}

function avatarSrc(account, peerId) {
  return `/api/avatar?account=${encodeURIComponent(account)}&peer_id=${peerId}`;
}

function _avatarAcquireSlot() {
  if (_avatarLoadSlots < AVATAR_LOAD_PARALLEL) {
    _avatarLoadSlots++;
    return Promise.resolve();
  }
  return new Promise(resolve => _avatarLoadWait.push(resolve));
}

function _avatarReleaseSlot() {
  _avatarLoadSlots = Math.max(0, _avatarLoadSlots - 1);
  const next = _avatarLoadWait.shift();
  if (next) {
    _avatarLoadSlots++;
    next();
  }
}

/** Сброс blob-кэша peer-аватара (перед принудительным обновлением). */
function invalidatePeerAvatar(account, peerId) {
  const key = avKey(account, peerId);
  const ent = avatarCache.get(key);
  if (ent?.url) {
    try { URL.revokeObjectURL(ent.url); } catch (_) {}
  }
  avatarCache.delete(key);
  avatarLoadPromises.delete(key);
}

function _applyAvatarToNodes(key, url, letter) {
  document.querySelectorAll('[data-av-key]').forEach(node => {
    if (node.dataset.avKey !== key) return;
    if (url) {
      node.innerHTML = '';
      const img = document.createElement('img');
      img.dataset.avKey = key;
      img.alt = '';
      img.loading = 'lazy';
      img.src = url;
      node.appendChild(img);
    } else if (!node.querySelector('.av-fallback')) {
      node.innerHTML = `<span class="av-fallback">${letter}</span>`;
    }
  });
}

/** Один fetch на peer за сессию; повтор — только после invalidatePeerAvatar. */
function loadAvatarUrl(account, peerId) {
  const key = avKey(account, peerId);
  const ent = avatarCache.get(key);
  if (ent?.state === 'ok' && ent.url) return Promise.resolve(ent.url);
  if (ent?.state === 'fail') return Promise.resolve(null);
  if (avatarLoadPromises.has(key)) return avatarLoadPromises.get(key);

  const p = _avatarAcquireSlot().then(() => {
    // Таймаут: если сервер завис на загрузке аватара (медленный прокси/Telegram),
    // fetch без таймаута держал бы соединение браузера вечно (лимит 6 на хост) —
    // тогда /api/chats и /api/thread не могут уйти, и панель виснет. AbortController
    // гарантированно освобождает слот и соединение через 8с.
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 8000);
    return fetch(avatarSrc(account, peerId), { signal: ctrl.signal })
      .then(r => (r.ok && r.status !== 204 ? r.blob() : null))
      .then(blob => {
        if (!blob || !blob.size) {
          avatarCache.set(key, { state: 'fail' });
          return null;
        }
        const url = URL.createObjectURL(blob);
        avatarCache.set(key, { state: 'ok', url });
        enforceAvatarCacheLimit();
        return url;
      })
      .catch(() => {
        avatarCache.set(key, { state: 'fail' });
        return null;
      })
      .finally(() => {
        clearTimeout(timer);
        _avatarReleaseSlot();
      });
  });
  avatarLoadPromises.set(key, p);
  p.finally(() => avatarLoadPromises.delete(key));
  return p;
}

/** Обновить аватар peer: сброс кэша + одна загрузка (открытие диалога, новое сообщение). */
function refreshPeerAvatar(account, peerId, name = '', username = '') {
  if (!isAvatarFetchAllowed(peerId, username)) return;
  invalidatePeerAvatar(account, peerId);
  const key = avKey(account, peerId);
  const letter = initials(name || username || '?');
  loadAvatarUrl(account, peerId).then(url => _applyAvatarToNodes(key, url, letter));
}

function isBotUsername(username) {
  const u = String(username || '').toLowerCase().replace(/^@/, '');
  return !!u && (u.endsWith('bot') || u === 'botfather' || u === 'telegram');
}

function isSystemInboxPeer(peerId, username) {
  const u = String(username || '').toLowerCase();
  const id = Number(peerId);
  return u === 'spambot' || u === 'telegram' || u === 'premiumbot'
    || id === SPAMBOT_PEER_ID || id === TELEGRAM_PEER_ID || id === PREMIUM_BOT_PEER_ID;
}

function isAvatarFetchAllowed(peerId, username) {
  const id = Number(peerId);
  if (!Number.isFinite(id) || id <= 0) return false;
  if (isSystemInboxPeer(id, username)) return false;
  if (isBotUsername(username)) return false;
  return true;
}

function bindAvatar(wrap, account, peerId, name, attempt = 0, username = '') {
  if (!wrap) return;
  if (!isAvatarFetchAllowed(peerId, username)) {
    avatarCache.set(avKey(account, peerId), { state: 'fail' });
    wrap.innerHTML = `<span class="av-fallback">${initials(name)}</span>`;
    return;
  }
  const key = avKey(account, peerId);
  wrap.dataset.avKey = key;
  wrap.dataset.avName = name || '';
  wrap.dataset.avUser = username || '';
  const letter = initials(name);

  const ent = avatarCache.get(key);
  if (ent?.state === 'ok' && ent.url) {
    if (wrap.querySelector(`img[data-av-key="${key}"]`)) return;
    wrap.innerHTML = '';
    const img = document.createElement('img');
    img.dataset.avKey = key;
    img.alt = '';
    img.loading = 'lazy';
    img.src = ent.url;
    wrap.appendChild(img);
    return;
  }
  if (ent?.state === 'fail') {
    if (!wrap.querySelector('.av-fallback')) {
      wrap.innerHTML = `<span class="av-fallback">${letter}</span>`;
    }
    return;
  }

  if (!wrap.querySelector('.av-fallback') && !wrap.querySelector('img')) {
    wrap.innerHTML = `<span class="av-fallback">${letter}</span>`;
  }

  loadAvatarUrl(account, peerId).then(url => {
    if (!wrap.isConnected || wrap.dataset.avKey !== key) return;
    _applyAvatarToNodes(key, url, letter);
  });
}

function statusTagHtml(status) {
  const s = status || 'new';
  const o = leadStatusOptions.find(x => x.value === s);
  const style = _statusTagStyle(o?.color);
  return `<span class="status-tag ${esc(s)}"${style}>${esc(statusLabel(s))}</span>`;
}

function showToast(msg, kind = 'ok', opts = {}) {
  const el = document.getElementById('toast');
  if (!el) return;
  clearTimeout(toastTimer);
  const action = opts.action && typeof opts.action.fn === 'function' ? opts.action : null;
  if (action) {
    el.textContent = '';
    const span = document.createElement('span');
    span.className = 'toast-msg';
    span.textContent = toastText(msg);
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'toast-action';
    btn.textContent = action.label || 'Вернуть';
    btn.addEventListener('click', () => {
      el.hidden = true;
      try { action.fn(); } catch (_) {}
    });
    el.append(span, btn);
  } else {
    el.textContent = toastText(msg);
  }
  el.className = `toast ${kind}`;
  el.hidden = false;
  const ms = Number(opts.duration) || (kind === 'err' ? 7000 : 2600);
  toastTimer = setTimeout(() => { el.hidden = true; }, ms);
}

function isSessionFatalError(msg) {
  const s = String(msg || '').toLowerCase();
  return /auth_key|session_revoked|сессия сброшена|сессия мёртва|duplicated|unregistered/.test(s);
}

function profileInitials(p) {
  return initials([p.first_name, p.last_name].filter(Boolean).join(' ') || '?');
}

function profileCardHtml(p) {
  const name = [p.first_name, p.last_name].filter(Boolean).join(' ');
  const avatar = p.avatar_url
    ? `<img loading="lazy" src="${p.avatar_url}?v=${encodeURIComponent(p.updated_at || p.created_at || '')}" alt="">`
    : `<span>${profileInitials(p)}</span>`;
  const setLabel = accountProfileTemplateKind(p) === 'local'
    ? accountProfileTemplateProjectLabel(accountProfileTemplateProjectSlug(p))
    : '';
  const metaParts = [
    setLabel && setLabel !== 'Без набора' ? setLabel : '',
    p.source_label || p.source || 'manual',
    `использован: ${Number(p.used_count || 0)}`,
  ].filter(Boolean);
  return `
    <div class="profile-row" data-profile-id="${p.id}">
      <div class="profile-avatar">${avatar}</div>
      <div class="profile-row-main">
        <div class="profile-row-name">${esc(name)}</div>
        <div class="profile-row-status">${esc(p.status_text || 'Без статуса')}</div>
        <div class="profile-row-meta">${esc(metaParts.join(' · '))}</div>
      </div>
      <div class="profile-row-actions">
        <button type="button" class="ghost profile-avatar-btn" data-profile-avatar="${p.id}">Аватар</button>
        <button type="button" class="ghost profile-del-btn" data-profile-delete="${p.id}">Удалить</button>
      </div>
    </div>
  `;
}

function visibleProfileTemplates() {
  const f = document.getElementById('profileGalleryFilter')?.value || 'all';
  if (f === 'all') return profileTemplates;
  return profileTemplates.filter(p => accountProfileTemplateKind(p) === f);
}

async function loadProfileTemplates() {
  const q = document.getElementById('profileSearch')?.value || '';
  await ensureAccountProfileAssetSets().catch(() => {});
  const data = await j('/api/profile-templates?limit=1000&q=' + encodeURIComponent(q));
  profileTemplates = data.items || [];
  const visible = visibleProfileTemplates();
  const list = document.getElementById('profileList');
  const count = document.getElementById('profileCount');
  if (count) count.textContent = String(visible.length);
  if (!list) return;
  list.innerHTML = visible.length
    ? visible.map(profileCardHtml).join('')
    : `<div class="empty-state">Профилей пока нет. Сгенерируй, импортируй из базы или добавь по @тегу.</div>`;
}

function selectedProfileAssetSet() {
  return document.getElementById('profileAssetSetSelect')?.value || 'default';
}

function profileAssetSetLabel(item) {
  return item.label || item.slug || 'default';
}

function profileAssetSetItem(slug = '') {
  const target = slug || selectedProfileAssetSet();
  return profileAssetSets.find(item => item.slug === target) || null;
}

function openProfileDrawer(id) {
  const drawer = document.getElementById(id);
  if (!drawer) return;
  drawer.hidden = false;
  document.body.classList.add('modal-open');
  if (id === 'profileAddDrawer') {
    setTimeout(() => document.getElementById('profileFirstName')?.focus(), 0);
  }
  if (id === 'profileAssetSetDrawer') {
    setTimeout(() => document.getElementById('profileAssetSetName')?.focus(), 0);
  }
}

function closeProfileDrawer(id) {
  const drawer = document.getElementById(id);
  if (!drawer) return;
  drawer.hidden = true;
  if (!document.querySelector('.profile-drawer-overlay:not([hidden])')) {
    document.body.classList.remove('modal-open');
  }
}

async function loadProfileAssetSets(active = '') {
  const select = document.getElementById('profileAssetSetSelect');
  if (!select) return;
  const data = await j('/api/profile-templates/assets/sets');
  profileAssetSets = data.sets || [];
  const prev = active || select.value || data.active || 'default';
  select.innerHTML = profileAssetSets.length
    ? profileAssetSets.map(item => `<option value="${esc(item.slug)}">${esc(profileAssetSetLabel(item))}</option>`).join('')
    : '<option value="default">Default</option>';
  select.value = profileAssetSets.some(item => item.slug === prev) ? prev : 'default';
  return select.value;
}

async function createProfileAssetSet() {
  const input = document.getElementById('profileAssetSetName');
  const name = input?.value || '';
  if (!name || !name.trim()) return;
  const r = await j('/api/profile-templates/assets/sets', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: name.trim() }),
  });
  const slug = r.item?.slug || 'default';
  await loadProfileAssetSets(slug);
  await loadProfileAssetInfo();
  if (input) input.value = '';
  closeProfileDrawer('profileAssetSetDrawer');
  showToast(`Набор создан: ${r.item?.label || slug}`);
}

async function renameProfileAssetSet() {
  const current = selectedProfileAssetSet();
  const item = profileAssetSetItem(current);
  if (!item) return;
  const name = prompt('Новое название набора', item.label || item.slug || '');
  if (!name || !name.trim()) return;
  const r = await j(`/api/profile-templates/assets/sets/${encodeURIComponent(current)}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: name.trim() }),
  });
  const next = r.item?.slug || current;
  await loadProfileAssetSets(next);
  await loadProfileAssetInfo();
  showToast(`Переименовано: ${r.item?.label || next}`);
}

async function deleteProfileAssetSet() {
  const current = selectedProfileAssetSet();
  if (!current || current === 'default') {
    showToast('Базовый набор удалить нельзя', 'err');
    return;
  }
  const item = profileAssetSetItem(current);
  const name = item?.label || current;
  if (!confirm(`Удалить набор "${name}"?`)) return;
  await j(`/api/profile-templates/assets/sets/${encodeURIComponent(current)}`, { method: 'DELETE' });
  await loadProfileAssetSets('default');
  await loadProfileAssetInfo();
  showToast(`Набор удалён: ${name}`);
}

function setProfileGuideOpen(open) {
  setProfileMode(open ? 'guide' : 'local');
}

function toggleProfileTemplatesPanel() {
  profileTemplatesCollapsed = !profileTemplatesCollapsed;
  document.querySelector('.profiles-shell')?.classList.toggle('templates-collapsed', profileTemplatesCollapsed);
  const btn = document.getElementById('btnToggleProfileTemplates');
  if (btn) {
    btn.setAttribute('aria-expanded', profileTemplatesCollapsed ? 'false' : 'true');
    btn.title = profileTemplatesCollapsed ? 'Развернуть панель шаблонов' : 'Свернуть панель шаблонов';
  }
}

function setProfileMode(mode) {
  const valid = ['local', 'csv', 'base', 'tag', 'guide'];
  const uiMode = valid.includes(mode) ? mode : 'local';
  if (uiMode !== 'guide') profileActiveMode = uiMode;
  document.querySelectorAll('[data-profile-mode-tab]').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.profileModeTab === uiMode);
  });
  document.querySelectorAll('[data-profile-mode-panel]').forEach(panel => {
    const show = panel.dataset.profileModePanel === uiMode;
    panel.hidden = !show;
    if (panel.id === 'profileGuideDrawer') {
      panel.setAttribute('aria-hidden', show ? 'false' : 'true');
    }
  });
  loadProfileTemplates().catch(() => {});
  if (uiMode === 'base') {
    loadProfileParserBases().catch(e => showToast(e.message || 'Не удалось загрузить базы', 'err'));
  } else if (uiMode === 'tag') {
    loadProfileParserAccounts().catch(e => showToast(e.message || 'Не удалось загрузить аккаунты', 'err'));
    setProfileParserSingleError('');
  } else if (uiMode === 'local') {
    loadProfileAssetSets()
      .then(slug => loadProfileAssetInfo(slug || selectedProfileAssetSet()))
      .catch(() => {});
  }
}

function renderProfileAssetStats(lines) {
  const el = document.getElementById('profileAssetsInfo');
  if (!el) return;
  el.innerHTML = lines.map(line => `<div class="profile-asset-stat-line">${line}</div>`).join('');
}

let profileAssetInfoRequest = 0;

async function loadProfileAssetInfo(setSlug = '') {
  const set = (setSlug || selectedProfileAssetSet() || 'default').trim();
  const reqId = ++profileAssetInfoRequest;
  renderProfileAssetStats(['<span class="muted">Загрузка данных набора…</span>']);
  try {
    const info = await j('/api/profile-templates/assets?set=' + encodeURIComponent(set));
    if (reqId !== profileAssetInfoRequest) return;
    const root = info.root || `data/profile_assets/${set}`;
    renderProfileAssetStats([
      `<strong>Набор:</strong> ${esc(root)}`,
      `<strong>Аватарки муж.:</strong> ${Number(info.male || 0)}`,
      `<strong>Аватарки жен.:</strong> ${Number(info.female || 0)}`,
      `<strong>Аватарки общ.:</strong> ${Number(info.neutral || 0)}`,
      `<strong>Bio общие:</strong> ${Number(info.common_statuses || 0)}`,
      `<strong>Bio мужские:</strong> ${Number(info.male_statuses || 0)}`,
      `<strong>Bio женские:</strong> ${Number(info.female_statuses || 0)}`,
      `<strong>Имена муж.:</strong> ${Number(info.male_names || 0)}`,
      `<strong>Имена жен.:</strong> ${Number(info.female_names || 0)}`,
    ]);
    const removed = Number(info.removed_statuses_over_limit || 0);
    if (removed > 0) {
      showToast(`Удалено bio: ${removed} шт. Длина выше 70 символов.`, 'err');
    }
  } catch (e) {
    if (reqId !== profileAssetInfoRequest) return;
    renderProfileAssetStats([
      '<strong>Набор:</strong> data/profile_assets/default',
      '<strong>Аватарки муж.:</strong> 0',
      '<strong>Аватарки жен.:</strong> 0',
      '<strong>Аватарки общ.:</strong> 0',
      '<strong>Bio общие:</strong> 0',
      '<strong>Bio мужские:</strong> 0',
      '<strong>Bio женские:</strong> 0',
      '<strong>Имена муж.:</strong> 0',
      '<strong>Имена жен.:</strong> 0',
    ]);
    showToast(e.message || 'Не удалось загрузить данные набора', 'err');
  }
}

function profileAssetAccept(kind) {
  return kind.endsWith('_avatars') ? 'image/*' : '.txt,.csv,text/plain,text/csv';
}

async function uploadProfileAsset(kind) {
  try {
    const set = selectedProfileAssetSet();
    const r = await j(`/api/profile-templates/assets/open-folder?set=${encodeURIComponent(set)}&kind=${encodeURIComponent(kind)}`, {
        method: 'POST',
      });
    const fileHint = r.expected_file ? `, файл: ${r.expected_file}` : '';
    showToast(`Открыта папка набора${fileHint}`);
    } catch (e) {
    showToast(e.message || 'Не удалось открыть папку', 'err');
    }
}

function profileParserBaseLabel(b) {
  const title = b.group_title || b.name || b.group_link || `База #${b.base_id}`;
  const count = Number(b.named_count || b.item_count || 0);
  const proxy = b.use_proxy ? 'прокси' : 'без прокси';
  const acc = b.account_phone ? ` · ${b.account_phone}` : '';
  return `${title} · ${count} проф. · ${proxy}${acc}`;
}

function updateProfileParserImportSummary() {
  const select = document.getElementById('profileParserBaseSelect');
  const summary = document.getElementById('profileParserImportSummary');
  const downloadAvatars = document.getElementById('profileParserDownloadAvatars');
  const requireAvatar = document.getElementById('profileParserRequireAvatar');
  if (requireAvatar && downloadAvatars) {
    requireAvatar.disabled = !downloadAvatars.checked;
    if (!downloadAvatars.checked) requireAvatar.checked = false;
  }
  if (!summary || !select) return;
  const base = profileParserBases.find(b => String(b.base_id) === String(select.value));
  if (!base) {
    summary.textContent = 'Выбери базу';
    return;
  }
  summary.textContent = base.use_proxy
    ? `Аватарки: через прокси задачи (${base.account_phone || 'аккаунт задачи'})`
    : `Аватарки: напрямую без прокси (${base.account_phone || 'аккаунт задачи'})`;
}

function setProfileParserSingleError(message = '') {
  const el = document.getElementById('profileParserSingleError');
  if (!el) return;
  const msg = String(message || '').trim();
  if (!msg) {
    el.hidden = true;
    el.textContent = '';
    return;
  }
  el.hidden = false;
  el.textContent = msg;
}

function updateProfileParserSingleSummary() {
  const summary = document.getElementById('profileParserSingleSummary');
  const sel = document.getElementById('profileParserSingleAccountSelect');
  const target = (document.getElementById('profileParserSingleTarget')?.value || '').trim();
  if (!summary) return;
  const acc = profileParserAccounts.find(a => String(a.id) === String(sel?.value || ''));
  if (!acc) {
    summary.textContent = 'Выбери аккаунт и введи @тег';
    return;
  }
  summary.textContent = target
    ? `Импорт: ${target} через ${acc.phone || acc.display_name || ('ID ' + acc.id)}`
    : `Аккаунт: ${acc.phone || acc.display_name || ('ID ' + acc.id)}`;
}

async function loadProfileParserAccounts() {
  const select = document.getElementById('profileParserSingleAccountSelect');
  if (!select) return;
  try {
    const data = await j('/api/accounts');
    profileParserAccounts = Array.isArray(data) ? data : [];
    const prev = String(select.value || '');
    // Показываем ВСЕ аккаунты: онлайн в пуле теперь авто-останавливается на время
    // операции и возвращается онлайн после. Поэтому больше не прячем их.
    const available = profileParserAccounts;
    select.innerHTML = available.length
      ? available.map(a => {
          const online = String(a.pool_status || '').toLowerCase() === 'online';
          const label = `${a.phone || ('ID ' + a.id)}${a.username ? ' · @' + a.username : ''}${a.display_name ? ' · ' + a.display_name : ''}${online ? ' · онлайн' : ''}`;
          return `<option value="${a.id}">${esc(label)}</option>`;
        }).join('')
      : '<option value="">Нет доступных аккаунтов</option>';
    if (prev && available.some(a => String(a.id) === prev)) select.value = prev;
  } catch {
    select.innerHTML = '<option value="">Не удалось загрузить аккаунты</option>';
  }
  updateProfileParserSingleSummary();
}

async function loadProfileParserBases() {
  const select = document.getElementById('profileParserBaseSelect');
  if (!select) return;
  try {
    const data = await j('/api/profile-templates/parser-bases');
    profileParserBases = data.bases || [];
    const prev = select.value;
    select.innerHTML = profileParserBases.length
      ? profileParserBases.map(b => `<option value="${b.base_id}">${esc(profileParserBaseLabel(b))}</option>`).join('')
      : '<option value="">Нет баз парсера</option>';
    if (prev && profileParserBases.some(b => String(b.base_id) === String(prev))) {
      select.value = prev;
    }
    updateProfileParserImportSummary();
  } catch (e) {
    select.innerHTML = '<option value="">Не удалось загрузить базы</option>';
  }
}

async function addProfileTemplate() {
  const first = document.getElementById('profileFirstName')?.value.trim() || '';
  const last = document.getElementById('profileLastName')?.value.trim() || '';
  const status = document.getElementById('profileStatusText')?.value.trim() || '';
  if (!first) {
    showToast('Укажи имя', 'err');
    return;
  }
  await j('/api/profile-templates', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ first_name: first, last_name: last, status_text: status, source: 'manual' }),
  });
  ['profileFirstName', 'profileLastName', 'profileStatusText'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.value = '';
  });
  await loadProfileTemplates();
  closeProfileDrawer('profileAddDrawer');
  showToast('Профиль добавлен');
}

async function importProfileTemplates() {
  const text = document.getElementById('profileImportText')?.value || '';
  if (!text.trim()) {
    showToast('Вставь CSV/TXT для импорта', 'err');
    return;
  }
  const r = await j('/api/profile-templates/import-text', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text, source: 'import' }),
  });
  await loadProfileTemplates();
  const skipped = Number(r.skipped || 0);
  clearProfileImportPreview();
  showToast(`Импортировано: ${r.created || 0}${skipped ? `, пропущено: ${skipped}` : ''}`);
}

async function importProfileTemplatesFromParserBase() {
  if (profileParserImportBusy) {
    showToast('Импорт уже идёт — подожди завершения', 'err');
    return;
  }
  const baseId = Number(document.getElementById('profileParserBaseSelect')?.value || 0);
  if (!baseId) {
    showToast('Выбери базу парсера', 'err');
    return;
  }
  const downloadAvatars = !!document.getElementById('profileParserDownloadAvatars')?.checked;
  const requireAvatar = downloadAvatars && !!document.getElementById('profileParserRequireAvatar')?.checked;
  const useProxy = !!document.getElementById('profileParserUseProxy')?.checked;
  const btn = document.getElementById('btnProfileImportParser');
  const summary = document.getElementById('profileParserImportSummary');
  profileParserImportBusy = true;
  if (btn) {
    btn.disabled = true;
    btn.innerHTML = '<span class="status-spinner"></span> Импорт…';
  }
  if (summary) summary.textContent = downloadAvatars ? 'Импорт… ~1.5 с между аватарками' : 'Импорт…';
  let progressTimer = null;
  const pollProgress = async () => {
    try {
      const pr = await j('/api/profile-templates/import-progress', { timeoutMs: 8000 });
      const p = pr?.progress || {};
      if (p.active && summary) {
        const total = Number(p.total || 0);
        const done = Number(p.done || 0);
        const cur = p.current ? ` · ${p.current}` : '';
        summary.textContent = `Импорт ${done}/${total} · создано ${Number(p.created || 0)}, аватарок ${Number(p.avatars_saved || 0)}${cur}`;
      }
    } catch (_) {}
  };
  progressTimer = setInterval(pollProgress, 1200);
  pollProgress();
  try {
    const r = await j('/api/profile-templates/import-parser-base', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        base_id: baseId,
        download_avatars: downloadAvatars,
        require_avatar: requireAvatar,
        limit: 500,
        avatar_delay: 1.5,
        use_proxy: useProxy,
      }),
      timeoutMs: 900000,
    });
    await loadProfileTemplates();
    const created = Number(r.created || 0);
    const avatarsSaved = Number(r.avatars_saved || 0);
    const noAvatar = Number(r.no_avatar || 0);
    if (downloadAvatars && requireAvatar && created === 0 && noAvatar > 0) {
      showToast(`Не импортировано: у ${noAvatar} профилей не нашлось аватарки. Сними галку "Брать только с аватаркой".`, 'err');
      return;
    }
    const avatarText = downloadAvatars ? `, аватарок: ${avatarsSaved}` : '';
    const noAvatarText = noAvatar ? `, без аватарки: ${noAvatar}` : '';
    showToast(`Импортировано: ${created}${avatarText}${noAvatarText}`);
  } catch (e) {
    showToast((e && e.message) ? e.message : 'Ошибка импорта', 'err');
  } finally {
    if (progressTimer) clearInterval(progressTimer);
    profileParserImportBusy = false;
    if (btn) {
      btn.disabled = false;
      btn.textContent = 'Импорт из базы';
    }
    if (summary) summary.textContent = 'Выбери базу';
  }
}

async function importSingleProfileFromParserBase() {
  if (profileParserImportBusy) {
    setProfileParserSingleError('Импорт уже идёт — подожди завершения.');
    return;
  }
  setProfileParserSingleError('');
  const accountId = Number(document.getElementById('profileParserSingleAccountSelect')?.value || 0);
  const target = (document.getElementById('profileParserSingleTarget')?.value || '').trim();
  const useProxy = !!document.getElementById('profileParserSingleUseProxy')?.checked;
  const downloadAvatar = true;
  const requireAvatar = false;

  if (!accountId) {
    setProfileParserSingleError('Выбери аккаунт для загрузки профиля.');
    return;
  }
  if (!target) {
    setProfileParserSingleError('Введи @тег или числовой ID пользователя.');
    return;
  }
  const btn = document.getElementById('btnProfileImportParserSingle');
  const singleSummary = document.getElementById('profileParserSingleSummary');
  profileParserImportBusy = true;
  if (btn) {
    btn.disabled = true;
    btn.innerHTML = '<span class="status-spinner"></span> Импорт…';
  }
  if (singleSummary) singleSummary.textContent = 'Загружаю профиль из Telegram…';
  try {
    const r = await j('/api/profile-templates/import-by-tag', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        account_id: accountId,
        target,
        download_avatar: downloadAvatar,
        require_avatar: requireAvatar,
        use_proxy: useProxy,
      }),
      timeoutMs: 120000,
    });
    await loadProfileTemplates();
    const created = Number(r.created || 0);
    const updated = Number(r.updated || 0);
    const avatarsSaved = Number(r.avatars_saved || 0);
    showToast(
      created
        ? `Профиль ${r.target || ''} добавлен (аватарок ${avatarsSaved})`
        : `Профиль ${r.target || ''} уже был — обновлено ${updated}`
    );
    const tgt = document.getElementById('profileParserSingleTarget');
    if (tgt) tgt.value = '';
    updateProfileParserSingleSummary();
  } catch (e) {
    const msg = String(e?.message || e || 'Ошибка импорта профиля');
    if (/flood|флуд/i.test(msg)) {
      setProfileParserSingleError(
        `${msg}\n\nРекомендация: выбери другой аккаунт в этом блоке и повтори импорт.`,
      );
      return;
    }
    setProfileParserSingleError(msg);
  } finally {
    profileParserImportBusy = false;
    if (btn) {
      btn.disabled = false;
      btn.textContent = 'Импорт 1 профиля';
    }
    updateProfileParserSingleSummary();
  }
}

function updateProfileImportSummary(text, fileName = '') {
  const lines = String(text || '').split(/\r?\n/).filter(x => x.trim()).length;
  const el = document.getElementById('profileImportSummary');
  if (!el) return;
  if (!lines) {
    el.textContent = 'Файл не выбран';
    return;
  }
  el.textContent = `${fileName || 'Вставлено вручную'} · ${lines} строк`;
}

function clearProfileImportPreview() {
  const textArea = document.getElementById('profileImportText');
  const fileName = document.getElementById('profileImportFileName');
  const fileInput = document.getElementById('profileImportFile');
  if (textArea) textArea.value = '';
  if (fileName) fileName.textContent = '';
  if (fileInput) fileInput.value = '';
  updateProfileImportSummary('');
}

function pickProfileImportFile() {
  document.getElementById('profileImportFile')?.click();
}

function readProfileImportFile(file) {
  if (!file) return;
  const reader = new FileReader();
  reader.onload = () => {
    const text = String(reader.result || '');
    const textArea = document.getElementById('profileImportText');
    const nameEl = document.getElementById('profileImportFileName');
    if (textArea) textArea.value = text;
    if (nameEl) nameEl.textContent = file.name;
    updateProfileImportSummary(text, file.name);
    showToast('Файл загружен в поле импорта');
  };
  reader.onerror = () => showToast('Не удалось прочитать файл', 'err');
  reader.readAsText(file, 'utf-8');
}

async function generateProfileTemplates() {
  const count = Number(document.getElementById('profileGenCount')?.value || 10);
  const statusMode = document.getElementById('profileGenStatusMode')?.value || 'random';
  const customStatus = document.getElementById('profileGenCustomStatus')?.value.trim() || '';
  if (statusMode === 'custom' && !customStatus) {
    showToast('Впиши свой статус или выбери Рандомный/Общий', 'err');
    return;
  }
  const r = await j('/api/profile-templates/generate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      count,
      gender: document.getElementById('profileGenGender')?.value || 'any',
      status_mode: statusMode,
      custom_status: customStatus,
      use_avatars: !!document.getElementById('profileGenUseAvatars')?.checked,
      asset_set: selectedProfileAssetSet(),
    }),
  });
  setProfileGuideOpen(false);
  await loadProfileTemplates();
  await loadProfileAssetInfo();
  const warn = Array.isArray(r.warnings) ? r.warnings.filter(Boolean).join(' ') : '';
  showToast(
    warn ? `Сгенерировано: ${r.created || 0}. ${warn}` : `Сгенерировано: ${r.created || 0}`,
    warn ? 'err' : 'ok',
  );
}

async function showRandomProfileTemplate() {
  try {
    const r = await j('/api/profile-templates/random', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ mark_used: true }),
    });
    const p = r.item;
    const name = [p.first_name, p.last_name].filter(Boolean).join(' ');
    showToast(`${name}${p.status_text ? ' · ' + p.status_text : ''}`);
    await loadProfileTemplates();
  } catch (e) {
    showToast(e.message || 'Нет профилей', 'err');
  }
}

function uploadProfileAvatar(profileId) {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = 'image/*';
  input.onchange = async () => {
    const file = input.files?.[0];
    if (!file) return;
    const fd = new FormData();
    fd.append('file', file);
    try {
      await j(`/api/profile-templates/${profileId}/avatar`, { method: 'POST', body: fd });
      await loadProfileTemplates();
      showToast('Аватарка сохранена');
    } catch (e) {
      showToast(e.message || 'Не удалось загрузить аватарку', 'err');
    }
  };
  input.click();
}

async function deleteProfileTemplate(profileId) {
  if (!confirm('Удалить профиль из базы?')) return;
  await j(`/api/profile-templates/${profileId}`, { method: 'DELETE' });
  await loadProfileTemplates();
  showToast('Профиль удалён');
}

async function bulkDeleteProfiles(mode) {
  const count = Number(document.getElementById('profileDeleteLastCount')?.value || 10);
  const scope = document.getElementById('profileGalleryFilter')?.value || 'all';
  const SCOPE_LABELS = { all: 'все источники', local: 'локальные', parser: 'парсерные', tag: 'по тегу' };
  const scopeLabel = SCOPE_LABELS[scope] || 'профили';
  const visibleCount = visibleProfileTemplates().length;
  const hasSearch = !!(document.getElementById('profileSearch')?.value || '').trim();
  const text = mode === 'all'
    ? `Удалить ВСЕ профили категории «${scopeLabel}» (${visibleCount})? Действие необратимо.`
    : `Удалить последние ${count} профилей категории «${scopeLabel}»?`;
  if (!confirm(text)) return;
  if (mode === 'all' && hasSearch) {
    showToast('Очисти поиск перед удалением всех профилей', 'err');
    return;
  }
  const r = await j('/api/profile-templates/bulk-delete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ mode, count, scope, confirm: mode === 'all' }),
  });
  await loadProfileTemplates();
  showToast(`Удалено профилей: ${r.deleted || 0}`);
}

document.getElementById('btnProfileAdd')?.addEventListener('click', () => addProfileTemplate().catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfileImport')?.addEventListener('click', () => importProfileTemplates().catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfileImportParser')?.addEventListener('click', () => importProfileTemplatesFromParserBase().catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfileImportParserSingle')?.addEventListener('click', () => importSingleProfileFromParserBase().catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfilePickFile')?.addEventListener('click', pickProfileImportFile);
document.getElementById('profileImportFile')?.addEventListener('change', e => readProfileImportFile(e.target.files?.[0]));
document.getElementById('profileImportText')?.addEventListener('input', e => updateProfileImportSummary(e.target.value));
document.getElementById('profileParserBaseSelect')?.addEventListener('change', updateProfileParserImportSummary);
document.getElementById('profileParserDownloadAvatars')?.addEventListener('change', updateProfileParserImportSummary);
document.getElementById('profileParserRequireAvatar')?.addEventListener('change', updateProfileParserImportSummary);
document.getElementById('profileParserSingleAccountSelect')?.addEventListener('change', updateProfileParserSingleSummary);
document.getElementById('profileParserSingleTarget')?.addEventListener('input', updateProfileParserSingleSummary);
document.getElementById('btnProfileGenerate')?.addEventListener('click', () => generateProfileTemplates().catch(e => showToast(e.message || e, 'err')));
document.getElementById('profileSearch')?.addEventListener('input', () => loadProfileTemplates().catch(() => {}));
document.getElementById('profileGalleryFilter')?.addEventListener('change', () => loadProfileTemplates().catch(() => {}));
document.querySelectorAll('[data-profile-mode-tab]').forEach(btn => {
  btn.addEventListener('click', () => setProfileMode(btn.dataset.profileModeTab));
});
document.getElementById('btnCloseProfileGuide')?.addEventListener('click', () => setProfileGuideOpen(false));

// ───────────────────────────── Тренинг агентов ─────────────────────────────
let trainState = { session: '', thread: [], status: '', tplId: 0, tplTitle: '', jobId: 0, mode: 'sandbox', agentMode: 'funnel', dialogs: [], firstMessage: '', lastTurn: null, loading: false };

const TRAIN_JOB_KEY = 'trainingJobId';

async function loadTrainingTab() {
  const jobSel = document.getElementById('trainJobSelect');
  if (jobSel && !jobSel.dataset.loaded) {
    try {
      const r = await j('/api/broadcast/jobs/simple');
      const jobs = (r && r.jobs) || [];
      jobSel.innerHTML = jobs.length
        ? jobs.map(jb => `<option value="${jb.id}">${esc(jb.name || ('Задача #' + jb.id))}</option>`).join('')
        : '<option value="">Нет задач — создай рассылку</option>';
      jobSel.dataset.loaded = '1';
      let saved = '';
      try { saved = sessionStorage.getItem(TRAIN_JOB_KEY) || ''; } catch (_) {}
      if (saved && jobs.some(x => String(x.id) === String(saved))) jobSel.value = saved;
    } catch (e) {
      jobSel.innerHTML = '<option value="">Ошибка загрузки задач</option>';
    }
  }
  await trainSyncAgentMode();
  await trainLoadFunnel();
  if (!trainState.session) trainResetDialog();
}

// Статусы воронки выбранной задачи (статус -> шаблон) в селектор.
async function trainLoadFunnel() {
  const jobSel = document.getElementById('trainJobSelect');
  const stSel = document.getElementById('trainStatusSelect');
  if (!stSel) return;
  const jobId = Number(jobSel?.value || 0);
  trainState.jobId = jobId;
  if (!jobId) { stSel.innerHTML = '<option value="">— выбери задачу —</option>'; return; }
  try { sessionStorage.setItem(TRAIN_JOB_KEY, String(jobId)); } catch (_) {}
  stSel.innerHTML = '<option value="">Загрузка…</option>';
  try {
    const [rulesR, statusesR] = await Promise.all([
      j(`/api/broadcast/jobs/${jobId}/autoreply-rules`),
      j(`/api/broadcast/jobs/${jobId}/statuses`).catch(() => ({ statuses: [] })),
    ]);
    const titleByKey = {};
    (statusesR.statuses || []).forEach(s => {
      const k = String(s.status_key || s.id || '').toLowerCase();
      if (k) titleByKey[k] = s.title || s.label || s.status_key || k;
    });
    const rules = (rulesR.rules || []).filter(r => {
      const v = r.enabled;
      return v === true || v === 1 || v === '1' || v === 'true' || v === undefined;
    });
    const seen = new Set();
    const opts = [];
    rules.forEach(r => {
      const st = String(r.trigger_status || '').toLowerCase();
      if (!st || !r.template_id || seen.has(st)) return;
      seen.add(st);
      const label = titleByKey[st] || st;
      opts.push(`<option value="${esc(st)}" data-tpl="${r.template_id}">${esc(label)}</option>`);
    });
    stSel.innerHTML = opts.length ? opts.join('') : '<option value="">Нет AI-шаблонов в задаче</option>';
    const startOpt = [...stSel.options].find(o => o.value === 'new');
    if (startOpt) stSel.value = 'new';
    await trainFetchFirstMessage();
  } catch (e) {
    stSel.innerHTML = '<option value="">Ошибка загрузки воронки</option>';
  }
}

// Первое сообщение задачи (рассылка) — случайный вариант spintax, как в бою.
async function trainFetchFirstMessage() {
  trainState.firstMessage = '';
  const jobId = Number(document.getElementById('trainJobSelect')?.value || 0);
  if (!jobId) return;
  try {
    const r = await j(`/api/broadcast/jobs/${jobId}/first-message`);
    trainState.firstMessage = (r && r.message) || '';
  } catch (_) { trainState.firstMessage = ''; }
}

function trainResetDialog() {
  trainState.session = 'tr_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  trainState.lastTurn = null;
  trainState.status = document.getElementById('trainStatusSelect')?.value || '';
  const chat = document.getElementById('trainChat');
  const first = (trainState.firstMessage || '').trim();
  if (first) {
    // диалог начинается с нашей рассылки (исходящее), агент отвечает как продолжение
    trainState.thread = [{ text: first, is_outgoing: 1 }];
    if (chat) {
      chat.innerHTML = '';
      const div = document.createElement('div');
      div.className = 'train-msg train-msg-agent';
      div.innerHTML = `<div class="train-bubble">${esc(first)}</div><div class="train-meta"><span class="train-badge train-badge-status">рассылка (первое сообщение)</span></div>`;
      chat.appendChild(div);
      const hint = document.createElement('div');
      hint.className = 'training-empty';
      hint.textContent = 'Это наш опенер из рассылки. Ответь как клиент — агент продолжит диалог.';
      chat.appendChild(hint);
    }
  } else {
    trainState.thread = [];
    if (chat) chat.innerHTML = '<div class="training-empty">Новый диалог. Напиши первое сообщение от лица клиента.</div>';
  }
}

function trainAppendClient(text) {
  const chat = document.getElementById('trainChat');
  if (!chat) return;
  chat.querySelector('.training-empty')?.remove();
  const div = document.createElement('div');
  div.className = 'train-msg train-msg-client';
  div.innerHTML = `<div class="train-bubble">${esc(text)}</div>`;
  chat.appendChild(div);
  chat.scrollTop = chat.scrollHeight;
}

function trainAppendAgent(reply, meta, turnCtx) {
  const chat = document.getElementById('trainChat');
  if (!chat) return;
  const cls = meta.classification || {};
  const dbg = meta.ai_debug || {};
  const strategy = [
    (cls.technique_hint || ''),
    (cls.funnel_stage || ''),
  ].filter(Boolean).join(' · ');
  const statusAfter = meta.next_status || dbg.status_after_guard || trainState.status || '';
  const div = document.createElement('div');
  div.className = 'train-msg train-msg-agent';
  const dbgJson = esc(JSON.stringify(dbg, null, 2));
  div.innerHTML =
    `<div class="train-bubble">${esc(reply || '(пусто)')}</div>` +
    `<div class="train-meta">` +
      (statusAfter ? `<span class="train-badge train-badge-status">статус → ${esc(statusAfter)}</span>` : '') +
      (strategy ? `<span class="train-badge train-badge-tech">${esc(strategy)}</span>` : '') +
      `<button type="button" class="train-rate" data-rate="good">👍</button>` +
      `<button type="button" class="train-rate" data-rate="bad">👎</button>` +
      `<button type="button" class="train-dbg-toggle ghost">debug</button>` +
    `</div>` +
    `<div class="train-note-row" hidden><input type="text" class="train-note" placeholder="заметка: что хорошо/плохо…" /><button type="button" class="ghost train-note-save">Сохранить</button></div>` +
    `<pre class="train-debug" hidden>${dbgJson}</pre>`;
  chat.appendChild(div);
  chat.scrollTop = chat.scrollHeight;

  const saveRating = async (rating) => {
    const note = div.querySelector('.train-note')?.value || '';
    try {
      await j('/api/agent-training/save', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          session_id: trainState.session,
          template_id: turnCtx.tplId || null,
          template_title: turnCtx.tplTitle || '',
          status_before: turnCtx.statusBefore || '',
          status_after: statusAfter,
          technique: strategy,
          client_message: turnCtx.clientMsg || '',
          agent_reply: reply || '',
          rating,
          note,
          job_id: trainState.jobId || null,
        }),
      });
      showToast(rating === 'good' ? 'Отмечено 👍 — в базу' : 'Отмечено 👎 — в базу');
    } catch (e) {
      showToast(e.message || 'Не удалось сохранить', 'err');
    }
  };
  div.querySelectorAll('.train-rate').forEach(b => {
    b.addEventListener('click', () => {
      div.querySelectorAll('.train-rate').forEach(x => x.classList.remove('active'));
      b.classList.add('active');
      div.querySelector('.train-note-row').hidden = false;
      saveRating(b.dataset.rate);
    });
  });
  div.querySelector('.train-note-save')?.addEventListener('click', () => {
    const active = div.querySelector('.train-rate.active');
    saveRating(active ? active.dataset.rate : 'note');
  });
  div.querySelector('.train-dbg-toggle')?.addEventListener('click', () => {
    const pre = div.querySelector('.train-debug');
    if (pre) pre.hidden = !pre.hidden;
  });
}

async function trainSend() {
  if (trainState.loading) return;
  const input = document.getElementById('trainInput');
  const msg = (input?.value || '').trim();
  if (!msg) return;
  const jobSel = document.getElementById('trainJobSelect');
  const stSel = document.getElementById('trainStatusSelect');
  const jobId = Number(jobSel?.value || 0);
  if (!jobId) { showToast('Выбери задачу', 'err'); return; }
  const stOpt = stSel?.options[stSel.selectedIndex];
  const tplId = Number(stOpt?.dataset.tpl || 0);
  if (!tplId) { showToast('У этого статуса нет AI-шаблона', 'err'); return; }
  trainState.jobId = jobId;
  trainState.tplId = tplId;
  trainState.tplTitle = stOpt?.textContent || '';
  // первый ход берёт статус из выбора, иначе ведём накопленный
  const hasClientTurn = trainState.thread.some(m => !m.is_outgoing);
  if (!hasClientTurn) {
    trainState.status = stSel?.value || '';
  }
  const statusBefore = trainState.status || '';
  const btn = document.getElementById('btnTrainSend');
  trainState.loading = true;
  if (btn) { btn.disabled = true; btn.innerHTML = '<span class="status-spinner"></span>'; }
  trainAppendClient(msg);
  if (input) input.value = '';
  try {
    const r = await j(`/api/templates/${tplId}/test-reply`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_message: msg,
        status: statusBefore,
        agent_mode: trainState.agentMode || 'funnel',
        use_sales_playbook: !!document.getElementById('trainPlaybook')?.checked,
        sender_persona: document.getElementById('trainPersona')?.value || 'auto',
        parser_context: (document.getElementById('trainParserCtx')?.value || '').trim() || null,
        history: trainState.thread,
        job_id: jobId,
      }),
      timeoutMs: 120000,
    });
    const reply = r.generated_reply || '';
    const statusAfter = r.next_status || (r.ai_debug && r.ai_debug.status_after_guard) || '';
    // контекст хода — для «Перегенерировать» (тот же клиент-месседж и история)
    trainState.lastTurn = { clientMsg: msg, statusBefore, history: trainState.thread.slice(), jobId, tplId };
    trainState.thread.push({ text: msg, is_outgoing: 0 });
    trainState.thread.push({ text: reply, is_outgoing: 1 });
    if (statusAfter) trainState.status = statusAfter;
    trainAppendAgent(reply, r, { tplId, tplTitle: trainState.tplTitle, statusBefore, clientMsg: msg });
    if (!r.ok && r.error) showToast(r.error, 'warn');
  } catch (e) {
    showToast(e.message || 'Ошибка', 'err');
  } finally {
    trainState.loading = false;
    if (btn) { btn.disabled = false; btn.textContent = 'Отправить'; }
  }
}

// Перегенерировать последний ответ агента на то же сообщение клиента (после правок/оценки).
async function trainRegenerate() {
  if (trainState.loading) return;
  const lt = trainState.lastTurn;
  const t = trainState.thread;
  if (!lt || !t.length || t[t.length - 1].is_outgoing !== 1) {
    showToast('Сначала получи ответ агента', 'err');
    return;
  }
  const lastReplyText = t[t.length - 1].text;
  const agents = document.querySelectorAll('#trainChat .train-msg-agent');
  const lastAgentDiv = agents.length ? agents[agents.length - 1] : null;
  // авто-сохранение заметки/оценки последнего ответа, чтобы перегенерация их учла
  if (lastAgentDiv) {
    const note = (lastAgentDiv.querySelector('.train-note')?.value || '').trim();
    const activeRate = lastAgentDiv.querySelector('.train-rate.active')?.dataset.rate || (note ? 'bad' : '');
    if (note || activeRate) {
      try {
        await j('/api/agent-training/save', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            session_id: trainState.session,
            template_id: lt.tplId || null,
            template_title: trainState.tplTitle || '',
            status_before: lt.statusBefore || '',
            status_after: '',
            technique: '',
            client_message: lt.clientMsg || '',
            agent_reply: lastReplyText || '',
            rating: activeRate || 'note',
            note,
            job_id: lt.jobId || null,
          }),
        });
        if (note) showToast('Заметка учтена, перегенерирую…');
      } catch (_) {}
    }
  }
  t.pop(); // убрать прошлый ответ агента из треда
  if (lastAgentDiv) lastAgentDiv.remove(); // убрать его из DOM
  const btn = document.getElementById('btnTrainRegen');
  trainState.loading = true;
  if (btn) { btn.disabled = true; btn.textContent = '⟳ …'; }
  try {
    const r = await j(`/api/templates/${lt.tplId}/test-reply`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_message: lt.clientMsg,
        status: lt.statusBefore,
        agent_mode: trainState.agentMode || 'funnel',
        use_sales_playbook: !!document.getElementById('trainPlaybook')?.checked,
        sender_persona: document.getElementById('trainPersona')?.value || 'auto',
        parser_context: (document.getElementById('trainParserCtx')?.value || '').trim() || null,
        history: lt.history,
        job_id: lt.jobId,
      }),
      timeoutMs: 120000,
    });
    const reply = r.generated_reply || '';
    const statusAfter = r.next_status || (r.ai_debug && r.ai_debug.status_after_guard) || '';
    trainState.thread.push({ text: reply, is_outgoing: 1 });
    if (statusAfter) trainState.status = statusAfter;
    trainAppendAgent(reply, r, { tplId: lt.tplId, tplTitle: trainState.tplTitle, statusBefore: lt.statusBefore, clientMsg: lt.clientMsg });
    if (!r.ok && r.error) showToast(r.error, 'warn');
  } catch (e) {
    showToast(e.message || 'Ошибка', 'err');
  } finally {
    trainState.loading = false;
    if (btn) { btn.disabled = false; btn.textContent = '⟳ Перегенерировать'; }
  }
}

async function trainLoadDialogs() {
  const box = document.getElementById('trainScenarioList');
  if (box) box.innerHTML = '<div class="hint">Загрузка…</div>';
  try {
    const jobId = Number(document.getElementById('trainJobSelect')?.value || 0);
    const qs = jobId ? `?limit=20&job_id=${jobId}` : '?limit=20';
    const r = await j(`/api/agent-training/sample-dialogs${qs}`);
    const dialogs = (r && r.dialogs) || [];
    trainState.dialogs = dialogs;
    if (!dialogs.length) { if (box) box.innerHTML = '<div class="hint">Нет диалогов по этой задаче.</div>'; return; }
    if (trainState.mode === 'review') {
      // Список клиентов — клик открывает полный диалог для разбора.
      box.innerHTML = dialogs.map((d, i) =>
        `<button type="button" class="train-dialog-pick" data-idx="${i}">${esc(d.title || ('Диалог ' + (i + 1)))}` +
        `<span class="train-dialog-count">${(d.messages || []).length}</span></button>`
      ).join('');
      box.querySelectorAll('.train-dialog-pick').forEach(b => {
        b.addEventListener('click', () => {
          box.querySelectorAll('.train-dialog-pick').forEach(x => x.classList.remove('active'));
          b.classList.add('active');
          trainOpenReviewDialog(Number(b.dataset.idx));
        });
      });
    } else {
      // Песочница — клик по реплике вставляет её в поле ввода.
      box.innerHTML = dialogs.map((d, i) =>
        `<div class="train-scenario"><div class="train-scenario-title">${esc(d.title || ('Диалог ' + (i + 1)))}</div>` +
        d.incoming.map(t => `<button type="button" class="train-scenario-line" data-line="${esc(t)}">${esc(t.slice(0, 90))}</button>`).join('') +
        `</div>`
      ).join('');
      box.querySelectorAll('.train-scenario-line').forEach(b => {
        b.addEventListener('click', () => {
          const input = document.getElementById('trainInput');
          if (input) { input.value = b.dataset.line; input.focus(); }
        });
      });
    }
  } catch (e) {
    if (box) box.innerHTML = `<div class="hint err">${esc(e.message || 'Ошибка')}</div>`;
  }
}

// Режим «Разбор диалогов»: рендер готового реального диалога с оценкой ответов агента.
function trainOpenReviewDialog(idx) {
  const d = (trainState.dialogs || [])[idx];
  const chat = document.getElementById('trainChat');
  if (!d || !chat) return;
  const msgs = d.messages || [];
  chat.innerHTML = `<div class="train-review-head">Разбор диалога: ${esc(d.title || '')} — отметь ответы агента 👍/👎</div>`;
  let lastClient = '';
  msgs.forEach(m => {
    const txt = (m.text || '').trim();
    if (!txt) return;
    if (!m.is_outgoing) {
      lastClient = txt;
      const div = document.createElement('div');
      div.className = 'train-msg train-msg-client';
      div.innerHTML = `<div class="train-bubble">${esc(txt)}</div>`;
      chat.appendChild(div);
    } else {
      const clientForThis = lastClient;
      const div = document.createElement('div');
      div.className = 'train-msg train-msg-agent';
      div.innerHTML =
        `<div class="train-bubble">${esc(txt)}</div>` +
        `<div class="train-meta">` +
          `<button type="button" class="train-rate" data-rate="good">👍</button>` +
          `<button type="button" class="train-rate" data-rate="bad">👎</button>` +
        `</div>` +
        `<div class="train-note-row" hidden><input type="text" class="train-note" placeholder="заметка: что хорошо/плохо…" /><button type="button" class="ghost train-note-save">Сохранить</button></div>`;
      chat.appendChild(div);
      const saveReview = async (rating) => {
        const note = div.querySelector('.train-note')?.value || '';
        try {
          await j('/api/agent-training/save', {
            method: 'POST', headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              session_id: 'review_' + (d.peer_username || d.title || idx),
              template_id: null,
              template_title: 'разбор: ' + (d.title || ''),
              status_before: '', status_after: '', technique: '',
              client_message: clientForThis,
              agent_reply: txt,
              rating, note,
              job_id: trainState.jobId || null,
            }),
          });
          showToast(rating === 'good' ? 'Отмечено 👍 — в базу' : 'Отмечено 👎 — в базу');
        } catch (e) {
          showToast(e.message || 'Не удалось сохранить', 'err');
        }
      };
      div.querySelectorAll('.train-rate').forEach(b => {
        b.addEventListener('click', () => {
          div.querySelectorAll('.train-rate').forEach(x => x.classList.remove('active'));
          b.classList.add('active');
          div.querySelector('.train-note-row').hidden = false;
          saveReview(b.dataset.rate);
        });
      });
      div.querySelector('.train-note-save')?.addEventListener('click', () => {
        const active = div.querySelector('.train-rate.active');
        saveReview(active ? active.dataset.rate : 'note');
      });
    }
  });
  chat.scrollTop = 0;
}

function trainSetMode(mode) {
  trainState.mode = mode === 'review' ? 'review' : 'sandbox';
  document.querySelectorAll('[data-train-mode]').forEach(b => {
    b.classList.toggle('active', b.dataset.trainMode === trainState.mode);
  });
  const compose = document.querySelector('.training-compose');
  if (compose) compose.style.display = trainState.mode === 'review' ? 'none' : '';
  const chat = document.getElementById('trainChat');
  if (chat) {
    chat.innerHTML = trainState.mode === 'review'
      ? '<div class="training-empty">Загрузи диалоги и выбери клиента слева — разберём готовый диалог.</div>'
      : '<div class="training-empty">Новый диалог. Напиши первое сообщение от лица клиента.</div>';
  }
  // перерисовать список под режим
  if ((trainState.dialogs || []).length) trainLoadDialogs();
}

document.getElementById('btnTrainSend')?.addEventListener('click', () => trainSend());
document.getElementById('trainInput')?.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); trainSend(); }
});
document.getElementById('btnTrainReset')?.addEventListener('click', () => {
  trainFetchFirstMessage().then(() => trainResetDialog()).catch(() => trainResetDialog());
});
document.getElementById('btnTrainRestart')?.addEventListener('click', () => {
  trainState.lastTurn = null;
  trainFetchFirstMessage().then(() => trainResetDialog()).catch(() => trainResetDialog());
});
document.getElementById('btnTrainRegen')?.addEventListener('click', () => trainRegenerate());
document.getElementById('btnTrainLoadDialogs')?.addEventListener('click', () => trainLoadDialogs());
document.querySelectorAll('[data-train-mode]').forEach(b => {
  b.addEventListener('click', () => trainSetMode(b.dataset.trainMode));
});
document.getElementById('trainJobSelect')?.addEventListener('change', () => {
  trainSyncAgentMode().finally(() => {
    trainLoadFunnel().then(() => trainResetDialog()).catch(() => {});
  });
});

// Подхватить сохранённый режим агента задачи (воронка/простой) в дропдаун тренажёра,
// чтобы тестировать ровно то, что настроено в задаче.
async function trainSyncAgentMode() {
  const jobId = Number(document.getElementById('trainJobSelect')?.value || 0);
  const modeSel = document.getElementById('trainAgentMode');
  if (!jobId || !modeSel) return;
  try {
    const r = await j(`/api/broadcast/jobs/${jobId}/agent-config`);
    const mode = r.agent_mode === 'simple' ? 'simple' : 'funnel';
    modeSel.value = mode;
    trainState.agentMode = mode;
    const stWrap = document.getElementById('trainStatusSelect')?.closest('.lead-field');
    if (stWrap) stWrap.style.opacity = mode === 'simple' ? '0.5' : '';
  } catch (_) {}
}
document.getElementById('trainStatusSelect')?.addEventListener('change', () => { if (!trainState.thread.length) trainState.status = document.getElementById('trainStatusSelect').value; });
document.getElementById('trainAgentMode')?.addEventListener('change', (e) => {
  trainState.agentMode = e.target.value === 'simple' ? 'simple' : 'funnel';
  const stSel = document.getElementById('trainStatusSelect')?.closest('.lead-field');
  if (stSel) stSel.style.opacity = trainState.agentMode === 'simple' ? '0.5' : '';
  trainResetDialog();
  showToast(trainState.agentMode === 'simple'
    ? 'Простой агент: один промпт + справка, без переключения статусов'
    : 'Режим воронки: статусы и шаблоны', 'info', { duration: 4000 });
});
document.getElementById('btnToggleProfileTemplates')?.addEventListener('click', toggleProfileTemplatesPanel);
document.getElementById('btnProfileDeleteLast')?.addEventListener('click', () => bulkDeleteProfiles('last').catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfileDeleteAll')?.addEventListener('click', () => bulkDeleteProfiles('all').catch(e => showToast(e.message || e, 'err')));
document.getElementById('profileAssetSetSelect')?.addEventListener('change', e => {
  const slug = e.target?.value || selectedProfileAssetSet();
  loadProfileAssetInfo(slug).catch(err => showToast(err.message || err, 'err'));
});
document.getElementById('btnOpenProfileAddDrawer')?.addEventListener('click', () => openProfileDrawer('profileAddDrawer'));
document.getElementById('btnOpenProfileAssetSetDrawer')?.addEventListener('click', () => openProfileDrawer('profileAssetSetDrawer'));
document.getElementById('btnProfileAssetSetRename')?.addEventListener('click', () => renameProfileAssetSet().catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfileAssetSetDelete')?.addEventListener('click', () => deleteProfileAssetSet().catch(e => showToast(e.message || e, 'err')));
document.getElementById('btnProfileAssetSetCreate')?.addEventListener('click', () => createProfileAssetSet().catch(e => showToast(e.message || e, 'err')));
document.querySelectorAll('[data-profile-drawer-close]').forEach(btn => {
  btn.addEventListener('click', () => closeProfileDrawer(btn.dataset.profileDrawerClose));
});
document.querySelectorAll('.profile-drawer-overlay').forEach(overlay => {
  overlay.addEventListener('click', e => {
    if (e.target === overlay) closeProfileDrawer(overlay.id);
  });
});
document.getElementById('profileGenStatusMode')?.addEventListener('change', () => {
  const el = document.getElementById('profileGenCustomStatus');
  if (el) el.hidden = document.getElementById('profileGenStatusMode')?.value !== 'custom';
});
document.querySelectorAll('[data-profile-asset-upload]').forEach(btn => {
  btn.addEventListener('click', () => uploadProfileAsset(btn.dataset.profileAssetUpload));
});
document.getElementById('profileList')?.addEventListener('click', e => {
  const avatarBtn = e.target.closest('[data-profile-avatar]');
  if (avatarBtn) {
    uploadProfileAvatar(avatarBtn.dataset.profileAvatar);
    return;
  }
  const delBtn = e.target.closest('[data-profile-delete]');
  if (delBtn) {
    deleteProfileTemplate(delBtn.dataset.profileDelete).catch(err => showToast(err.message || err, 'err'));
  }
});

function fmtSavedAt(iso) {
  if (!iso) return '';
  return 'Обновлено ' + fmtTime(iso);
}

function isMediaPreview(text) {
  if (!text) return false;
  return /^[🎤📷🎬📹📎🙂🎵👤📍📊🎲📩🎁]/.test(text) || text.startsWith('GIF');
}

function formatMessageText(text) {
  if (!text) return '';
  let html = esc(text);
  html = html.replace(/\n/g, '<br>');
  html = html.replace(/(https?:\/\/[^\s<]+[^\s<.,;:!?])/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
  return html;
}

function mediaCaptionHtml(m) {
  const t = (m.text || '').trim();
  if (!t || isMediaPreview(t)) return '';
  return `<div class="tg-caption">${formatMessageText(t)}</div>`;
}

function isBroadcastOutgoing(m) {
  return !!(m.is_outgoing && m.broadcast_job_id);
}

function filterThreadMessages(rows) {
  // Всегда показываем полный диалог (тумблер «все сообщения» убран).
  return rows;
}

function mediaUrl(m) {
  return `/api/media?account=${encodeURIComponent(m.account_phone)}&peer_id=${m.peer_id}&msg_id=${m.tg_message_id}`;
}

function resolveMediaType(m) {
  if (m.media_type) return m.media_type;
  const t = m.text || '';
  if (/^🎤\s*Голосовое/.test(t)) return 'voice';
  if (/^🎵/.test(t)) return 'audio';
  if (/^📷/.test(t)) return 'photo';
  if (/^📹\s*Кружок/.test(t)) return 'video_note';
  if (/^🎬/.test(t)) return 'video';
  if (/^📎/.test(t)) return 'document';
  if (t.startsWith('GIF')) return 'animation';
  return '';
}

function bubbleHtml(m) {
  const mt = resolveMediaType(m);
  if (mt === 'voice' || mt === 'audio') {
    const src = mediaUrl(m);
    const icon = mt === 'voice'
      ? '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 14a3 3 0 003-3V5a3 3 0 10-6 0v6a3 3 0 003 3zm5-3a5 5 0 01-10 0H5a7 7 0 0014 0h-2zm-5 9a7 7 0 007-7h-2a5 5 0 01-10 0H5a7 7 0 007 7z"/></svg>'
      : '🎵';
    return tgBubbleWithKeyboard(m, `
      <div class="tg-voice-row">
        <div class="tg-voice-icon">${icon}</div>
        <audio controls preload="metadata" src="${src}"></audio>
      </div>
      ${msgMetaHtml(m)}`, `media-${mt}`);
  }
  if (mt === 'photo') {
    const src = mediaUrl(m);
    const cap = mediaCaptionHtml(m);
    const meta = cap ? '' : msgMetaHtml(m);
    const capMeta = cap ? `<div class="tg-caption-meta">${msgMetaHtml(m)}</div>` : '';
    return tgBubbleWithKeyboard(m, `
      <a href="${src}" target="_blank" rel="noopener"><img class="msg-photo" src="${src}" alt="фото" loading="lazy" /></a>
      ${cap}${capMeta}${meta}`, cap ? 'media-photo has-caption' : 'media-photo');
  }
  if (mt === 'video' || mt === 'video_note') {
    const src = mediaUrl(m);
    const cap = mediaCaptionHtml(m);
    const meta = cap ? '' : msgMetaHtml(m);
    const capMeta = cap ? `<div class="tg-caption-meta">${msgMetaHtml(m)}</div>` : '';
    return tgBubbleWithKeyboard(m, `
      <video controls preload="none" src="${src}"></video>
      ${cap}${capMeta}${meta}`, cap ? 'media-video has-caption' : 'media-video');
  }
  if (mt === 'document') {
    const src = mediaUrl(m);
    const name = esc((m.text || 'Документ').replace(/^📎\s*/, ''));
    return tgBubbleWithKeyboard(m, `
      <div class="tg-doc-row">
        <div class="tg-doc-icon">📄</div>
        <div class="tg-doc-name">${name}</div>
      </div>
      <a class="tg-doc-link" href="${src}" target="_blank" rel="noopener" download>Скачать</a>
      ${msgMetaHtml(m)}`, 'media-doc');
  }
  return tgTextBubble(m, m.text || '');
}

function renderThreadHtml(rows) {
  let html = '';
  let lastDay = '';
  for (const m of rows) {
    const day = m.date_iso ? new Date(m.date_iso).toDateString() : '';
    if (day && day !== lastDay) {
      html += `<div class="tg-date-sep">${esc(fmtDateSep(m.date_iso))}</div>`;
      lastDay = day;
    }
    html += bubbleHtml(m);
  }
  return html;
}

let threadReadPoll = null;
let threadReadPollBusy = false;
let lastReadOutboxMax = 0;

function stopThreadReadPoll() {
  if (threadReadPoll) {
    clearInterval(threadReadPoll);
    threadReadPoll = null;
  }
}

function startThreadReadPoll() {
  stopThreadReadPoll();
  threadReadPoll = setInterval(async () => {
    if (!selectedChat || threadReadPollBusy) return;
    threadReadPollBusy = true;
    const chat = { ...selectedChat };
    const reqKey = chatKey(chat.account_phone, chat.peer_id);
    try {
      const r = await j(`/api/thread/read-state?account=${encodeURIComponent(chat.account_phone)}&peer_id=${chat.peer_id}`);
      if (selectedChatKey() === reqKey) {
        const maxId = Number(r.read_outbox_max_id) || 0;
        if (maxId !== lastReadOutboxMax) {
          lastReadOutboxMax = maxId;
          applyReadOutboxToInboxList(chat.account_phone, chat.peer_id, maxId);
          await loadThread({ silent: true });
        }
      }
    } catch (_) {} finally {
      threadReadPollBusy = false;
    }
  }, 5000);
}

function accLabel(phone) {
  const a = allAccounts.find(x => x.phone === phone);
  if (!a) return phone;
  return a.username ? '@' + a.username : a.display_name || phone.replace('+', '');
}

let _notifyAudioCtx = null;
function playNotifySound(pulse) {
  if (appSettings.notify_sound !== '1' || !isPulseNotifyEligible(pulse)) return;
  try {
    // Один общий AudioContext на всё приложение: создание нового на каждое
    // уведомление упирается в лимит браузера (~6 живых) → звук молча ломается.
    if (!_notifyAudioCtx) {
      _notifyAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
    }
    const ctx = _notifyAudioCtx;
    if (ctx.state === 'suspended') { ctx.resume().catch(() => {}); }
    const o = ctx.createOscillator();
    const g = ctx.createGain();
    o.type = 'sine';
    o.frequency.value = 880;
    o.connect(g);
    g.connect(ctx.destination);
    g.gain.setValueAtTime(0.12, ctx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
    o.start();
    o.stop(ctx.currentTime + 0.35);
  } catch (_) {}
}

function isPulseNotifyEligible(pulse) {
  if (!pulse?.event_id) return false;
  const jobId = Number(pulse.broadcast_job_id);
  if (!Number.isFinite(jobId) || jobId <= 0) return false;
  const chat = (allChats || []).find(
    c => c.account_phone === pulse.account_phone && Number(c.peer_id) === Number(pulse.peer_id),
  );
  if (chat && (isWarmupChat(chat) || isSpambotChat(chat) || isTelegramChat(chat) || isBotChat(chat))) {
    return false;
  }
  return true;
}

function showDesktopNotify(pulse) {
  if (appSettings.notify_desktop !== '1' || !notifyReady || !isPulseNotifyEligible(pulse)) return;
  const name = pulse.peer_username ? '@' + pulse.peer_username : (pulse.peer_name || 'Клиент');
  const body = (pulse.text || 'Новое сообщение').slice(0, 140);
  try {
    const n = new Notification('SPAMIMPAKTER · ' + name, { body, silent: true });
    n.onclick = () => {
      window.focus();
      openChat(pulse.account_phone, pulse.peer_id, pulse.peer_name, pulse.peer_username);
      n.close();
    };
  } catch (_) {}
}

function updateDocumentTitle(unread) {
  const base = 'SPAMIMPAKTER';
  document.title = unread > 0 ? `(${unread}) ${base}` : base;
}

function openChat(accountPhone, peerId, peerName, peerUsername, leadStatus) {
  const peerNum = Number(peerId);
  const existing = (allChats || []).find(
    c => c.account_phone === accountPhone && Number(c.peer_id) === peerNum
  );
  selectedChat = {
    account_phone: accountPhone,
    peer_id: peerNum,
    peer_name: peerName || existing?.peer_name || '',
    peer_username: peerUsername || existing?.peer_username || '',
    lead_status: leadStatus || existing?.lead_status || 'new',
    ai_context: existing?.ai_context || '',
    ai_context_source: existing?.ai_context_source || '',
    lead_starred: existing?.lead_starred || 0,
    broadcast_job_id: existing?.broadcast_job_id,
  };
  refreshPeerAvatar(
    accountPhone,
    peerNum,
    selectedChat.peer_name,
    selectedChat.peer_username,
  );
  updateThreadHeader(selectedChat);
  const msgEl = document.getElementById('messages');
  if (msgEl) msgEl.innerHTML = '<div class="empty-state">Загрузка…</div>';
  const delBtn = document.getElementById('btnDeleteCurrentChat');
  if (delBtn) delBtn.hidden = false;
  const threadFilter = document.getElementById('threadFilterLabel');
  if (threadFilter) threadFilter.hidden = false;
  const alreadyInbox = document.getElementById('tab-inbox')?.classList.contains('active');
  if (!alreadyInbox) activateTab('inbox');
  loadThread().catch(e => {
    showToast(toastText(e), 'err');
    if (selectedChatKey() === chatKey(accountPhone, peerNum) && msgEl) {
      msgEl.innerHTML = `<div class="empty-state">Не удалось загрузить диалог</div>`;
    }
  });
  loadLeadPanel();
  loadInboxChats();
}

window.openChat = openChat;

async function pollInboxPulse() {
  try {
    const p = await j('/api/inbox/pulse');
    updateDocumentTitle(p.unread_total || 0);
    if (p.event_id && lastPulse.event_id && p.event_id > lastPulse.event_id && isPulseNotifyEligible(p)) {
      playNotifySound(p);
      showDesktopNotify(p);
      showToast('Новое: ' + (p.peer_username ? '@' + p.peer_username : (p.peer_name || 'клиент')));
      refreshPeerAvatar(p.account_phone, p.peer_id, p.peer_name, p.peer_username);
      const active = document.querySelector('.tab.active');
      if (active?.id === 'tab-inbox') {
        await loadInboxChats();
        if (
          selectedChat &&
          selectedChat.account_phone === p.account_phone &&
          selectedChat.peer_id === p.peer_id
        ) {
          await loadThread();
        }
      }
      if (active?.id === 'tab-clients') maybeRefreshClientsTab();
    }
    lastPulse = p;
  } catch (_) {}
}

document.querySelectorAll('#sidebarNav button[data-tab]').forEach(btn => {
  btn.onclick = () => activateTab(btn.dataset.tab);
});

window.addEventListener('hashchange', () => activateTab(getTabFromHash(), { saveHash: false }));

document.getElementById('btnSelectAllChats')?.addEventListener('click', toggleSelectAllVisibleChats);
document.getElementById('btnReadChats')?.addEventListener('click', markSelectedChatsRead);
document.getElementById('btnBlockChats')?.addEventListener('click', blockSelectedChats);
document.getElementById('btnDeleteChats')?.addEventListener('click', deleteSelectedChats);
document.getElementById('btnDeleteCurrentChat')?.addEventListener('click', deleteCurrentChat);
document.getElementById('btnLeadBlock')?.addEventListener('click', toggleLeadBlock);
document.getElementById('btnLeadContact')?.addEventListener('click', toggleLeadContact);
document.getElementById('btnLeadClearHistory')?.addEventListener('click', clearDialogHistory);
document.getElementById('btnLeadDeleteTg')?.addEventListener('click', deleteDialogInTg);

// ── Выдвижная карточка клиента: скрыть/показать, переписка расширяется ──
function applyLeadPanelCollapsed(collapsed) {
  const tab = document.getElementById('tab-inbox');
  const btn = document.getElementById('leadEdgeToggle');
  if (tab) tab.classList.toggle('lead-collapsed', collapsed);
  if (btn) btn.title = collapsed ? 'Показать карточку клиента' : 'Скрыть карточку клиента';
}
function toggleLeadPanel() {
  const tab = document.getElementById('tab-inbox');
  const next = !(tab && tab.classList.contains('lead-collapsed'));
  applyLeadPanelCollapsed(next);
  try { localStorage.setItem('inbox_lead_collapsed', next ? '1' : '0'); } catch (_) {}
}
document.getElementById('leadEdgeToggle')?.addEventListener('click', toggleLeadPanel);
try { applyLeadPanelCollapsed(localStorage.getItem('inbox_lead_collapsed') === '1'); } catch (_) {}

function syncInboxUnreadBadges(s) {
  const inc = Number(s?.unread_incoming) || 0;
  const out = Number(s?.unread_outgoing) || 0;
  const incBtn = document.getElementById('chatSideIncoming');
  const outBtn = document.getElementById('chatSideOutgoing');
  if (incBtn) {
    incBtn.textContent = inc ? `Входящие ${inc}` : 'Входящие';
    incBtn.title = inc
      ? `${inc} непрочитанных — клиент писал последним`
      : 'Клиент написал последним';
  }
  if (outBtn) {
    outBtn.textContent = out ? `Исходящие ${out}` : 'Исходящие';
    outBtn.title = out
      ? `${out} непрочитанных — вы ответили, но сообщения не прочитаны`
      : 'Мы написали последними — ждём ответа';
  }
}

function renderSidebarStats(s) {
  const el = document.getElementById('stats');
  if (!el) return;
  const rows = [];
  const push = (label, value, tone = '') => {
    if (value == null || value === '') return;
    rows.push({ label, value: String(value), tone });
  };
  push('Онлайн', `${s.online}/${s.accounts}`, s.online ? 'ok' : 'muted');
  if (s.pool_connecting) {
    const tot = s.pool_connect?.total || 0;
    const pending = poolConnectPendingCount(allAccounts);
    push('Пул', poolConnectBannerLabel(s.online, tot, pending).replace('… ', ' '), 'accent');
  } else if (!s.pool_ready && s.accounts) {
    push('Пул', 'не готов', 'warn');
  }
  if (s.accounts_no_proxy) push('Без прокси', s.accounts_no_proxy, 'warn');
  if (s.accounts_spam_blocked) push('Спамблок', s.accounts_spam_blocked, 'warn');
  const unreadInc = Number(s.unread_incoming ?? s.unread_total) || 0;
  if (unreadInc) push('Непрочит.', unreadInc, 'accent');
  else if (s.incoming_total) push('Входящих', s.incoming_total);
  if (s.clients?.total) {
    push(
      'Клиентов',
      s.clients.new ? `${s.clients.total} · +${s.clients.new}` : String(s.clients.total),
      'ok',
    );
  }
  if (s.broadcast_running) push('Рассылка', `#${s.broadcast_job_id}`, 'accent');
  else if (s.broadcast_waiting) push('Ожидание', `#${s.broadcast_queued_job_id}`, 'warn');
  if (!rows.length) {
    el.textContent = '...';
    return;
  }
  el.innerHTML = rows.map(r => (
    `<div class="stat-row">` +
    `<span class="stat-row-label">${esc(r.label)}</span>` +
    `<span class="stat-row-value${r.tone ? ` ${esc(r.tone)}` : ''}">${esc(r.value)}</span>` +
    `</div>`
  )).join('');
}

async function refreshStats() {
  if (statsPollBusy || document.hidden) return;
  statsPollBusy = true;
  try {
    const s = await fetchStats({ full: Date.now() - statsLastFullAt >= STATS_FULL_EVERY_MS });
    applyPoolUiFromStats(s);
    syncAccCheckFromStats(s);
    syncInboxUnreadBadges(s);
    renderSidebarStats(s);
    window.__lastStats = s;
    if (document.getElementById('tab-warmup')?.classList.contains('active')) {
      renderWarmupFocusBtn(s);
      patchWarmupPoolBannerFromStats();
    }
  } finally {
    statsPollBusy = false;
  }
}

function chatListFingerprint(list) {
  return list.map(c =>
    [
      c.account_phone,
      c.peer_id,
      c.last_date,
      c.last_incoming_date,
      c.last_text,
      c.lead_status,
      Number(c.lead_starred || 0),
      c.peer_name,
      c.peer_username,
      Number(c.unread_count || 0),
      Number(c.last_is_outgoing || 0),
      Number(c.last_tg_message_id || 0),
      Number(c.read_outbox_max_id || 0),
    ].join('\x1f')
  ).join('\x1e');
}

function patchChatListItems(list) {
  const el = document.getElementById('inboxChats');
  if (!el) return false;
  const items = [...el.querySelectorAll('.chat-item')];
  if (items.length !== list.length) return false;
  const itemByKey = new Map(items.map(node => [
    `${node.dataset.account}\x00${node.dataset.peer}`,
    node,
  ]));

  const frag = document.createDocumentFragment();
  for (const c of list) {
    const item = itemByKey.get(chatKey(c.account_phone, c.peer_id));
    if (!item) return false;
    const name = inboxChatPrimaryName(c);
    const unread = Number(c.unread_count) || 0;
    const preview = inboxChatPreview(c);
    const st = c.lead_status || 'new';
    const starred = isLeadStarred(c.lead_starred);
    const active = Number(selectedChat?.peer_id) === Number(c.peer_id)
      && selectedChat?.account_phone === c.account_phone;
    const botAv = (isSpambotChat(c) || isBotChat(c)) ? ' chat-av-bot' : '';

    item.className = `chat-item ${active ? 'active' : ''} ${unread ? 'has-unread' : ''}${starred ? ' is-starred' : ''}`;
    item.dataset.status = st;
    item.dataset.starred = starred ? '1' : '0';
    item.dataset.account = c.account_phone;
    item.dataset.peer = String(c.peer_id);
    const nameEl = item.querySelector('.chat-name');
    const timeEl = item.querySelector('.chat-time');
    const prevEl = item.querySelector('.chat-preview');
    const fromEl = item.querySelector('.chat-from');
    if (!nameEl || !timeEl || !prevEl || !fromEl) return false;
    nameEl.innerHTML = `${leadStarInlineHtml(c.lead_starred)}${esc(name)}`;
    timeEl.innerHTML = inboxChatTimeLineHtml(c);
    prevEl.textContent = preview;
    fromEl.innerHTML = inboxFromLineHtml(c);
    frag.appendChild(item);
  }
  el.appendChild(frag);
  updateChatSelUi();
  return true;
}

async function loadInboxChats(forceRebuild = false) {
  const reqId = ++inboxLoadReqId;
  let url = '/api/chats';
  const params = [];
  if (filterJobId === 'spambot') params.push('job_id=spambot');
  else if (filterJobId === 'telegram') params.push('job_id=telegram');
  else if (filterJobId === 'premium') params.push('job_id=premium');
  else if (filterJobId === 'warmup') params.push('job_id=warmup');
  else if (filterJobId === 'bots') params.push('job_id=bots');
  else if (filterJobId) params.push(`job_id=${filterJobId}`);
  params.push(`dialog_side=${chatDialogSide}`);
  const stFilter = document.getElementById('chatStatusFilter')?.value;
  if (stFilter) params.push(`lead_status=${encodeURIComponent(stFilter)}`);
  if (params.length) url += '?' + params.join('&');
  const nextChats = await j(url, { timeoutMs: 45000 });
  if (reqId !== inboxLoadReqId) return;
  allChats = nextChats;

  const list = getInboxVisibleChats();
  const q = (document.getElementById('chatSearch')?.value || '').trim().toLowerCase();

  const el = document.getElementById('inboxChats');
  if (!list.length) {
    inboxListFp = '';
    const jobSel = document.getElementById('chatJobFilter')?.value;
    let emptyMsg = chatDialogSide === 'outgoing'
      ? 'Нет исходящих — все, кому писали, уже ответили'
      : 'Нет входящих — клиенты ещё не писали';
    if (jobSel === 'spambot') emptyMsg = 'Нет диалогов SpamBot — запусти проверку на вкладке «Аккаунты»';
    else if (jobSel === 'telegram') emptyMsg = 'Нет сообщений от Telegram (коды, уведомления)';
    else if (jobSel === 'premium') emptyMsg = 'Нет диалогов Premium Bot — подключи аккаунты или дождись синка';
    else if (jobSel === 'warmup') emptyMsg = chatDialogSide === 'outgoing'
      ? 'Нет исходящих прогревов — запусти прогрев на вкладке «Прогрев»'
      : 'Нет входящих прогревов — диалоги между своими аккаунтами появятся после прогрева';
    else if (jobSel === 'bots') emptyMsg = chatDialogSide === 'outgoing'
      ? 'Нет исходящих диалогов с ботами'
      : 'Нет входящих от ботов — @wallet, @combot и подобные появятся здесь';
    else if (jobSel) emptyMsg = chatDialogSide === 'outgoing'
      ? 'По этой рассылке нет диалогов, где мы ждём ответа'
      : 'По этой рассылке нет входящих от клиентов';
    if (selectedChat) {
      emptyMsg = 'Открытый диалог не в этом фильтре — переключи Вх/Исх или продолжай писать справа';
    }
    el.innerHTML = `<div class="empty">${emptyMsg}</div>`;
    updateOpenChatListHint(list);
    updateChatSelUi();
    return;
  }

  const fp = chatListFingerprint(list) + '\x00' + q;
  if (!forceRebuild && fp === inboxListFp && el.querySelector('.chat-item')) {
    if (patchChatListItems(list)) {
      updateOpenChatListHint(list);
      return;
    }
  }
  inboxListFp = fp;
  lastChatSearch = q;

  el.innerHTML = list.map(c => {
    const name = inboxChatPrimaryName(c);
    const active = Number(selectedChat?.peer_id) === Number(c.peer_id)
      && selectedChat?.account_phone === c.account_phone;
    const unread = Number(c.unread_count) || 0;
    const preview = inboxChatPreview(c);
    const st = c.lead_status || 'new';
    const starred = isLeadStarred(c.lead_starred);
    const k = chatKey(c.account_phone, c.peer_id);
    const checked = selectedChatKeys.has(k) ? ' checked' : '';
    const botAv = (isSpambotChat(c) || isBotChat(c)) ? ' chat-av-bot' : '';
    return `
    <div class="chat-item ${active ? 'active' : ''} ${unread ? 'has-unread' : ''}${starred ? ' is-starred' : ''}"
         data-account="${c.account_phone}" data-peer="${c.peer_id}" data-status="${st}"
         data-starred="${starred ? '1' : '0'}"
         data-name="${esc(name)}" data-user="${esc(c.peer_username || '')}">
      <label class="chat-item-chk" onclick="event.stopPropagation()">
        <input type="checkbox" class="chat-sel-chk" data-account="${c.account_phone}" data-peer="${c.peer_id}"${checked} />
      </label>
      <div class="chat-av${botAv}" data-av-account="${c.account_phone}" data-av-peer="${c.peer_id}" data-av-name="${esc(name)}">${initials(name)}</div>
      <div class="chat-body">
        <div class="chat-top">
          <span class="chat-name">${leadStarInlineHtml(c.lead_starred)}${esc(name)}</span>
          <span class="chat-time">${inboxChatTimeLineHtml(c)}</span>
        </div>
        <div class="chat-preview">${esc(preview)}</div>
        <div class="chat-from">${inboxFromLineHtml(c)}</div>
      </div>
    </div>`;
  }).join('');

  el.querySelectorAll('.chat-av[data-av-account]').forEach(node => {
    bindAvatar(node, node.dataset.avAccount, Number(node.dataset.avPeer), node.dataset.avName, 0, node.closest('.chat-item')?.dataset.user || '');
  });

  el.querySelectorAll('.chat-sel-chk').forEach(chk => {
    chk.onchange = e => {
      e.stopPropagation();
      toggleChatSelection(chk.dataset.account, chk.dataset.peer, chk.checked);
    };
  });

  el.querySelectorAll('.chat-item').forEach((n, idx) => {
    n.style.setProperty('--i', String(idx));
    n.classList.add('chat-item-motion');
    n.onclick = e => {
      if (e.target.closest('.chat-item-chk')) return;
      openChat(n.dataset.account, n.dataset.peer, n.dataset.name, n.dataset.user, n.dataset.status);
    };
  });

  updateOpenChatListHint(list);
  updateChatSelUi();
}

async function loadLeadPanel() {
  const empty = document.getElementById('leadEmpty');
  const body = document.getElementById('leadBody');
  const saveWrap = document.getElementById('leadSaveWrap');
  if (!selectedChat) {
    empty.hidden = false;
    body.hidden = true;
    saveWrap.hidden = true;
    renderLeadAiContext(null);
    return;
  }
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  // Сразу гасим онлайн-статус предыдущего собеседника, иначе при переключении
  // диалога до загрузки нового показывается статус прежнего пользователя.
  renderPeerStatusLine(null);
  const isCurrent = () => selectedChatKey() === reqKey;
  empty.hidden = true;
  body.hidden = false;
  saveWrap.hidden = false;

  const name = chat.peer_username
    ? '@' + chat.peer_username
    : (chat.peer_name || chat.peer_id);
  const leadNameEl = document.getElementById('leadName');
  leadNameEl.textContent = chat.peer_name || chat.peer_username || 'Клиент';
  leadNameEl.classList.add('copyable');
  leadNameEl.title = 'Скопировать имя';
  leadNameEl.dataset.copy = chat.peer_name || chat.peer_username || '';
  const leadHandleEl = document.getElementById('leadHandle');
  leadHandleEl.textContent = chat.peer_username ? '@' + chat.peer_username : 'id ' + chat.peer_id;
  leadHandleEl.classList.add('copyable');
  leadHandleEl.title = 'Скопировать';
  leadHandleEl.dataset.copy = chat.peer_username ? '@' + chat.peer_username : String(chat.peer_id);
  bindAvatar(
    document.getElementById('leadAvatarWrap'),
    chat.account_phone,
    chat.peer_id,
    chat.peer_name || chat.peer_username || '?',
    0,
    chat.peer_username || ''
  );

  const acc = allAccounts.find(a => a.phone === chat.account_phone);
  const accChipName = accLabel(chat.account_phone);
  const accInitials = initials(acc?.display_name || accChipName);
  const accAvHtml = (acc && acc.id)
    ? `<img src="${accountAvatarUrl(acc.id, acc.updated_at || '')}" alt="" loading="lazy" onerror="this.parentNode.textContent='${accInitials.replace(/['\\<>]/g, '')}'">`
    : esc(accInitials);
  document.getElementById('leadMeta').innerHTML = `
    <button type="button" class="lead-acc-chip" id="leadAccChip" title="Открыть карточку нашего аккаунта">
      <span class="lead-acc-chip-av">${accAvHtml}</span>
      <span class="lead-acc-chip-text">
        <span class="lead-acc-chip-cap">Наш аккаунт</span>
        <span class="lead-acc-chip-name">${esc(accChipName)}</span>
      </span>
      <svg class="lead-acc-chip-arr" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
    </button>`;
  const accChipBtn = document.getElementById('leadAccChip');
  if (accChipBtn) accChipBtn.onclick = () => openOurAccountModal(chat.account_phone, acc?.id);

  const saveBtn = document.getElementById('saveLead');
  saveBtn.textContent = 'Сохранить';
  saveBtn.classList.remove('saved', 'saving');
  renderLeadAiContext(chat);

  try {
    const data = await j(`/api/lead?account=${encodeURIComponent(chat.account_phone)}&peer_id=${chat.peer_id}`);
    if (!isCurrent()) return;
    const lead = data.lead || {};
    document.getElementById('leadStatus').value = lead.status || 'new';
    syncLeadStarBtn(lead.lead_starred);
    document.getElementById('leadNote').value = lead.note || '';
    renderLeadAiContext(lead);
    const arMode = document.getElementById('leadAutoreplyMode');
    if (arMode) {
      arMode.value = lead.autoreply_enabled == null || lead.autoreply_enabled === ''
        ? '' : String(lead.autoreply_enabled);
    }
    const arTpl = document.getElementById('leadAutoreplyTpl');
    if (arTpl) arTpl.value = lead.autoreply_template_id ? String(lead.autoreply_template_id) : '';
    const hint = document.getElementById('leadAutoreplyHint');
    const preview = data.autoreply_preview || {};
    if (hint) {
      if (preview.enabled) {
        const trig = statusLabel(preview.trigger_status || 'new');
        const after = preview.after_status ? statusLabel(preview.after_status) : 'не менять';
        hint.textContent =
          `Сработает при «${trig}» (${preview.source}): ${(preview.text || '').slice(0, 56)}… · после → ${after}`;
        if (lead.last_autoreply_error) {
          hint.textContent += ` · последняя ошибка: ${lead.last_autoreply_error}`;
        }
      } else {
        hint.textContent = preview.source === 'lead_off'
          ? 'Автоответ выключен для этого диалога'
          : preview.source === 'no_template'
          ? 'Автоответ не настроен: шаблон не выбран'
          : String(preview.source || '').includes('_no_rule')
          ? 'Для текущего статуса в этой задаче нет назначенного AI-шаблона'
          : 'Автоответ ждёт входящее сообщение при подходящем статусе (или выкл глобально)';
        if (lead.last_autoreply_error) {
          hint.textContent += ` · последняя ошибка: ${lead.last_autoreply_error}`;
        }
      }
    }
    document.getElementById('leadSavedAt').textContent = fmtSavedAt(lead.updated_at);
    selectedChat.lead_status = lead.status || 'new';
    selectedChat.lead_starred = lead.lead_starred || 0;
    selectedChat.is_blocked = lead.is_blocked || 0;
    selectedChat.is_contact = lead.is_contact || 0;
    updateLeadActionButtons();
    syncLeadAutoreplyFieldsState();
    loadPeerStatus(selectedChat);
  } catch (_) {
    if (!isCurrent()) return;
    document.getElementById('leadSavedAt').textContent = '';
    syncLeadStarBtn(selectedChat?.lead_starred);
    updateLeadActionButtons();
    renderLeadAiContext(selectedChat);
  }
}

async function toggleLeadStar() {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  const btn = document.getElementById('leadStarBtn');
  const next = !btn?.classList.contains('active');
  try {
    const res = await j('/api/leads/star', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        account_phone: chat.account_phone,
        peer_id: Number(chat.peer_id),
        starred: next,
      }),
    });
    if (selectedChatKey() !== reqKey) return;
    const lead = res.lead || {};
    selectedChat.lead_starred = lead.lead_starred || (next ? 1 : 0);
    syncLeadStarBtn(selectedChat.lead_starred);
    inboxListFp = '';
    await loadInboxChats(true);
    if (document.getElementById('tab-clients')?.classList.contains('active')) {
      loadClients().catch(() => {});
    }
    showToast(next ? 'Клиент в избранном' : 'Убрано из избранного');
  } catch (e) {
    showToast(e.message || 'Ошибка', 'err');
  }
}

function syncLeadAutoreplyFieldsState() {
  const mode = document.getElementById('leadAutoreplyMode')?.value;
  const tplField = document.getElementById('leadAutoreplyTplField');
  // Шаблон автоответа нужен только когда автоответ для диалога включают вручную.
  // По умолчанию/выключено — не показываем, чтобы не плодить лишние дропдауны.
  if (tplField) tplField.hidden = mode !== '1';
}

function splitAiContext(raw) {
  const s = (raw || '').trim();
  const DELIM = '〔ИЗ ЧАТА〕';
  const di = s.indexOf(DELIM);
  if (di < 0) return { comment: s, detail: '' };
  return { comment: s.slice(0, di).trim(), detail: s.slice(di + DELIM.length).trim() };
}

function renderLeadAiContext(lead) {
  const wrap = document.getElementById('leadAiContext');
  const sourceEl = document.getElementById('leadAiSource');
  const textEl = document.getElementById('leadAiText');
  const msgsEl = document.getElementById('leadAiMsgs');
  if (!wrap || !sourceEl || !textEl) return;
  const raw = (lead?.ai_context || '').trim();
  if (!raw) {
    wrap.hidden = true;
    sourceEl.textContent = '';
    textEl.textContent = '';
    if (msgsEl) { msgsEl.hidden = true; msgsEl.textContent = ''; }
    return;
  }
  const { comment, detail } = splitAiContext(raw);
  wrap.hidden = false;
  sourceEl.textContent = (lead.ai_context_source || 'AI-парсинг').trim();
  textEl.textContent = comment;
  if (msgsEl) {
    if (detail) {
      msgsEl.hidden = false;
      msgsEl.textContent = detail;
    } else {
      msgsEl.hidden = true;
      msgsEl.textContent = '';
    }
  }
}

async function saveLead({ silent = false } = {}) {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  const btn = document.getElementById('saveLead');
  const status = document.getElementById('leadStatus').value;
  const note = document.getElementById('leadNote').value;
  const arModeRaw = document.getElementById('leadAutoreplyMode')?.value;
  const arTplRaw = document.getElementById('leadAutoreplyTpl')?.value;
  const payload = {
    account_phone: chat.account_phone,
    peer_id: chat.peer_id,
    status,
    note,
  };
  if (arModeRaw === '1') payload.autoreply_enabled = 1;
  else if (arModeRaw === '0') payload.autoreply_enabled = 0;
  else payload.autoreply_enabled = null;
  payload.autoreply_template_id = arTplRaw ? Number(arTplRaw) : null;
  btn.classList.add('saving');
  btn.textContent = 'Сохраняю...';
  try {
    const res = await j('/api/leads/status', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (selectedChatKey() !== reqKey) return;
    selectedChat.lead_status = res.lead?.status || status;
    document.getElementById('leadSavedAt').textContent = fmtSavedAt(res.lead?.updated_at);
    btn.classList.remove('saving');
    btn.classList.add('saved');
    btn.textContent = 'Сохранено ✓';
    if (!silent) showToast('Карточка клиента сохранена');
    loadInboxChats();
    setTimeout(() => {
      if (selectedChatKey() !== reqKey) return;
      btn.classList.remove('saved');
      btn.textContent = 'Сохранить';
    }, 1800);
  } catch (e) {
    if (selectedChatKey() !== reqKey) return;
    btn.classList.remove('saving');
    btn.textContent = 'Сохранить';
    showToast('Ошибка сохранения', 'err');
    throw e;
  }
}

function scheduleLeadSave() {
  clearTimeout(leadSaveTimer);
  leadSaveTimer = setTimeout(() => saveLead({ silent: true }), 600);
}

const TPL_LAST_ID_KEY = 'spamimpakter_tpl_last_id';
const TPL_CATEGORY_KEY = 'spamimpakter_tpl_category';
const TPL_JOB_KEY = 'spamimpakter_tpl_job_id';
const TPL_FLOW_MODE_KEY = 'spamimpakter_tpl_flow_mode';
const TPL_FLOW_PANEL_KEY = 'spamimpakter_tpl_flow_panel_hidden';
let tplCache = [];
let tplEditId = null;
let tplCategory = 'job';
let _tplJobFilter = '';
let _tplPresetFilterJobId = null;
let _tplPresetMode = 'all';
let _tplPresetScope = 'all';
let _tplPresetSort = 'new';
let _tplPresetJobId = null;
let tplJobId = null;
let tplJobsCache = [];
let tplJobRulesCache = [];
let tplJobTransitionsCache = [];
let tplJobStatusOptions = [];
let _pendingFunnelAssignStatus = '';
const FUNNEL_START_STATUS = 'new';

function _jobFunnelStartStatus() {
  const j = _tplJobForSelected(tplJobId);
  const st = _normStatusId(j?.funnel_start_status || FUNNEL_START_STATUS);
  return st || FUNNEL_START_STATUS;
}

async function setJobFunnelStartStatus(statusId) {
  if (!tplJobId) throw new Error('Сначала выбери задачу');
  const st = _normStatusId(statusId);
  if (!st) throw new Error('Не выбран статус');
  pushFlowHistory(_loadTplFlowCanvasState());
  const r = await j(`/api/broadcast/jobs/${tplJobId}/funnel-start`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ status: st }),
  });
  if (r.job) _mergeTplJobData(r.job);
  _showStatusOnFlowCanvas(st);
  renderTplJobHeader();
  renderTplFunnelBoard();
  showToast(`Старт воронки: «${_tplStatusLabel(st)}»`);
}

function _showStatusOnFlowCanvas(statusId) {
  const st = _normStatusId(statusId);
  if (!st) return;
  const canvas = _loadTplFlowCanvasState();
  canvas.hiddenStatusIds = (canvas.hiddenStatusIds || []).filter(x => x !== st);
  if (!canvas.nodeLayouts[st]) {
    const layouts = canvas.nodeLayouts || {};
    const xs = Object.values(layouts).map(p => Number(p.x) || 0);
    const x = xs.length ? Math.max(...xs) + 180 : 60;
    canvas.nodeLayouts[st] = { x, y: 60, w: TPL_FLOW_NODE_DEF_W, h: TPL_FLOW_NODE_DEF_H };
  }
  _saveTplFlowCanvasState(canvas);
}
let _tplFlowSelection = null;
let _tplFlowStickyStatusPanel = false;
let _tplFlowClickTimer = null;
let tplSettings = {};
let _tplAiVariants = [];
let _tplSelectedVariantKeys = new Set();
let _tplAiSpintax = '';
let _tplAiStatusHtml = '';
let _tplAiGenerating = false;
let _tplOriginalDraft = '';
let _tplEditorCreating = false;

function fillTplSelectElement(sel, list, { valueKey = 'id', emptyLabel = '—' } = {}) {
  if (!sel) return;
  sel.innerHTML = `<option value="">${esc(emptyLabel)}</option>` +
    list.map(t => {
      const val = valueKey === 'text' ? (t.text || '') : String(t.id);
      return `<option value="${esc(val)}">${esc(t.title || 'Без названия')}</option>`;
    }).join('');
}

function fillTemplateSelects(list) {
  const privateList = (list || []).filter(t => _tplScopeOf(t) === 'none');
  fillTplSelectElement(document.getElementById('tplPick'), privateList, { valueKey: 'id', emptyLabel: 'Выбрать...' });
  fillTplSelectElement(document.getElementById('leadAutoreplyTpl'), list, { valueKey: 'id', emptyLabel: '— глобальный —' });
  const pick = document.getElementById('tplPick');
  const applyBtn = document.getElementById('btnApplyTpl');
  if (applyBtn) applyBtn.disabled = !(pick && pick.value);
  syncLeadAutoreplyFieldsState();
}

function _tplNormText(text) {
  return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
}

function _tplVariantKey(text) {
  return _tplNormText(text).slice(0, 500);
}

function _tplTooSimilar(a, b) {
  const wa = new Set(_tplNormText(a).split(' ').filter(w => w.length > 2));
  const wb = new Set(_tplNormText(b).split(' ').filter(w => w.length > 2));
  if (!wa.size || !wb.size) return _tplNormText(a) === _tplNormText(b);
  const small = Math.min(wa.size, wb.size);
  if (small < 5) return _tplNormText(a) === _tplNormText(b);
  let common = 0;
  wa.forEach(w => { if (wb.has(w)) common += 1; });
  return (common / small) >= 0.72;
}

function _tplDedupeVariants(variants) {
  const out = [];
  for (const v of (variants || [])) {
    const text = String(v || '').trim();
    if (!text) continue;
    if (out.some(x => _tplTooSimilar(text, x))) continue;
    out.push(text);
  }
  return out;
}

function _tplVariantsToSpintax(variants) {
  const safe = (variants || [])
    .map(v => String(v || '').replace(/[{}]/g, '').replace(/\|/g, '/').trim())
    .filter(Boolean);
  if (safe.length <= 1) return safe[0] || '';
  return `{${safe.join('|')}}`;
}

function _tplParseVariantsFromText(text) {
  const t = String(text || '').trim();
  const m = t.match(/^\{([^{}]+)\}$/);
  if (!m) return { plain: t, variants: t ? [t] : [] };
  const parts = m[1].split('|').map(s => s.trim()).filter(Boolean);
  return { plain: parts[0] || '', variants: parts.length ? parts : (t ? [t] : []) };
}

function _tplVariantsResultEl() {
  return document.getElementById('tplVariantsResult');
}

function _selectedTplVariants() {
  return _tplAiVariants.filter(v => _tplSelectedVariantKeys.has(_tplVariantKey(v)));
}

function _syncTplSelectedSpintax() {
  const selected = _selectedTplVariants();
  _tplAiSpintax = selected.length ? _tplVariantsToSpintax(selected) : '';
  return selected;
}

function _tplDraftValue() {
  return document.getElementById('tplDraftText')?.value.trim() || '';
}

function _tplTextForSave() {
  const raw = _tplDraftValue();
  // В режиме ИИ-агента справка — только то, что в поле (не закреплённые варианты).
  if (_tplIsJobLike() && _tplArMode() === 'ai') return raw;
  _syncTplSelectedSpintax();
  if (_tplAiSpintax) return _tplAiSpintax.trim();
  if (raw.includes('{')) return raw;
  return raw;
}

function _tplSpintaxPlainSource() {
  const v = _tplDraftValue();
  if (!v) return '';
  if (!v.includes('{')) return v;
  return v.replace(/\{([^{}]+)\}/g, (_, inner) => inner.split('|')[0].trim())
    .replace(/\s+/g, ' ').trim();
}

function _tplCapturePlainSource() {
  const v = (document.getElementById('tplDraftText')?.value || '').trim();
  if (v) _tplOriginalDraft = v;
  return v || (_tplOriginalDraft && !_tplOriginalDraft.includes('{') ? _tplOriginalDraft.trim() : '');
}

function _tplShowAiStatus(html) {
  _tplAiStatusHtml = html;
  if (_tplAiVariants.length) {
    _renderTplAiPreview(_tplAiVariants, '');
    return;
  }
  const el = _tplVariantsResultEl();
  if (el) el.innerHTML = `<div class="tpl-ai-status">${html}</div>`;
}

function _renderTplAiPreview(variants, meta) {
  const el = _tplVariantsResultEl();
  if (!el) return;
  _syncTplSelectedSpintax();
  const selected = _selectedTplVariants();
  const selectedKeys = new Set(selected.map(v => _tplVariantKey(v)));
  const candidates = (variants || []).filter(v => !selectedKeys.has(_tplVariantKey(v)));
  const status = _tplAiStatusHtml || '';
  const head =
    (status ? `<div style="margin-bottom:6px;font-size:11px;">${status}</div>` : '') +
    (meta ? `<div style="margin-bottom:6px;color:var(--green);font-size:11px;">${esc(meta)} · закреплено: ${selected.length} · новых: ${candidates.length}</div>` : '');
  const renderRow = (v, selectedRow) => {
    const idx = _tplAiVariants.findIndex(x => _tplVariantKey(x) === _tplVariantKey(v));
    if (idx < 0) return '';
    const mark = selectedRow ? '✓' : '+';
    const cls = selectedRow ? 'tpl-ai-variant-row selected' : 'tpl-ai-variant-row';
    return `<div class="${cls}" data-idx="${idx}">` +
      `<button type="button" class="tpl-var-pick" title="Выбрать/снять">${mark}</button>` +
      `<div class="tpl-var-body"><span style="color:var(--muted);margin-right:6px;">${idx + 1}.</span>${esc(v)}</div>` +
      `<button type="button" class="tpl-var-del" title="Удалить">✕</button></div>`;
  };
  const selectedBlock = selected.length
    ? `<div style="margin:6px 0 4px;color:var(--green);font-size:11px;font-weight:600;">Закреплены для шаблона</div>` +
      selected.map(v => renderRow(v, true)).join('')
    : '';
  const emptyHint = 'Введи текст и жми «AI: варианты» или «Spintax варианты».';
  const candidateBlock = candidates.length
    ? `<div style="margin:8px 0 4px;color:var(--muted);font-size:11px;font-weight:600;">Новые варианты (отметь ✓)</div>` +
      candidates.map(v => renderRow(v, false)).join('')
    : (variants.length ? '' : `<div style="color:var(--muted);font-size:11px;">${emptyHint}</div>`);
  const saveHint = selected.length > 1
    ? `Сохранится случайный из ${selected.length} выбранных.`
    : (selected.length === 1 ? 'Сохранится один выбранный текст.' : '');
  el.innerHTML = head + selectedBlock + candidateBlock +
    `<div style="font-size:10px;color:var(--muted);margin-top:6px;line-height:1.4;">✓ закреплены в шаблон. Новая генерация заменит только невыбранные.${saveHint ? ` ${saveHint}` : ''}</div>`;
  el.querySelectorAll('.tpl-ai-variant-row').forEach(row => {
    const idx = Number(row.dataset.idx);
    if (idx < 0 || !_tplAiVariants[idx]) return;
    row.querySelector('.tpl-var-pick')?.addEventListener('click', e => {
      e.stopPropagation();
      applyTplAiVariant(idx);
    });
    row.querySelector('.tpl-var-body')?.addEventListener('click', e => {
      e.stopPropagation();
      applyTplAiVariant(idx);
    });
    row.querySelector('.tpl-var-del')?.addEventListener('click', e => {
      e.stopPropagation();
      deleteTplAiVariant(idx);
    });
  });
}

function applyTplAiVariant(idx) {
  if (!_tplAiVariants[idx]) return;
  _tplAiStatusHtml = '';
  const key = _tplVariantKey(_tplAiVariants[idx]);
  if (_tplSelectedVariantKeys.has(key)) _tplSelectedVariantKeys.delete(key);
  else _tplSelectedVariantKeys.add(key);
  _syncTplSelectedSpintax();
  _renderTplAiPreview(_tplAiVariants, 'Варианты автоответа');
}

function deleteTplAiVariant(idx) {
  if (!_tplAiVariants[idx]) return;
  _tplAiStatusHtml = '';
  _tplSelectedVariantKeys.delete(_tplVariantKey(_tplAiVariants[idx]));
  _tplAiVariants.splice(idx, 1);
  _syncTplSelectedSpintax();
  _renderTplAiPreview(_tplAiVariants, 'Варианты автоответа');
}

function _replaceUnselectedTplVariants(newVariants) {
  const selected = _selectedTplVariants();
  const merged = [...selected];
  for (const v of _tplDedupeVariants(newVariants || [])) {
    const text = String(v || '').trim();
    if (!text) continue;
    if (merged.some(x => _tplTooSimilar(text, x))) continue;
    merged.push(text);
  }
  _tplAiVariants = merged;
  _tplSelectedVariantKeys = new Set(selected.map(v => _tplVariantKey(v)));
  _syncTplSelectedSpintax();
}

function clearTplAiVariants(keepDraft = true) {
  const keepText = keepDraft ? _tplTextForSave() : '';
  _tplAiVariants = [];
  _tplSelectedVariantKeys = new Set();
  _tplAiSpintax = '';
  _tplAiStatusHtml = '';
  const draftEl = document.getElementById('tplDraftText');
  if (draftEl) draftEl.value = keepDraft ? (keepText || draftEl.value) : '';
  _renderTplAiPreview([], '');
}

function _tplVariantsFromEditor(text) {
  const parsed = _tplParseVariantsFromText(text || '');
  _tplAiVariants = parsed.variants;
  _tplSelectedVariantKeys = new Set();
  parsed.variants.forEach(v => _tplSelectedVariantKeys.add(_tplVariantKey(v)));
  _tplOriginalDraft = parsed.plain || '';
  const draftEl = document.getElementById('tplDraftText');
  if (draftEl) draftEl.value = parsed.plain || text || '';
  _tplAiStatusHtml = '';
  _syncTplSelectedSpintax();
  _renderTplAiPreview(_tplAiVariants, '');
}

async function loadTemplates({ scope = 'all', jobId = null } = {}) {
  const params = new URLSearchParams();
  const scopeNorm = (scope || 'all').trim().toLowerCase() || 'all';
  if (scopeNorm !== 'all') params.set('scope', scopeNorm);
  if (scopeNorm === 'job') {
    if (!jobId) {
      tplCache = [];
      fillTemplateSelects(tplCache);
      return tplCache;
    }
    params.set('job_id', String(Number(jobId)));
  } else if (jobId) {
    params.set('job_id', String(Number(jobId)));
  }
  const q = params.toString();
  tplCache = await j(q ? `/api/templates?${q}` : '/api/templates');
  fillTemplateSelects(tplCache);
  return tplCache;
}

function _tplTemplateScopeRequest() {
  if (tplCategory !== 'presets') return { scope: 'all', jobId: null };
  const { scope, selectedJobId } = _tplScopeState();
  if (scope === 'global') return { scope: 'global', jobId: null };
  if (scope === 'job') return { scope: 'job', jobId: selectedJobId };
  return { scope: 'all', jobId: null };
}

function applyPickedTemplate() {
  const v = document.getElementById('tplPick')?.value;
  if (!v) return;
  const t = tplCache.find(x => String(x.id) === String(v));
  const text = t?.text || '';
  if (!text) {
    showToast('Шаблон не найден', 'err');
    return;
  }
  const ta = document.getElementById('replyText');
  if (!ta || ta.disabled) {
    showToast('Сначала открой диалог', 'err');
    return;
  }
  ta.value = text;
  autoResizeReply();
  ta.focus();
  showToast('Шаблон вставлен в сообщение');
}

function _tplScopeOf(t) {
  const s = (t?.ar_scope || '').trim().toLowerCase();
  if (s === 'global' || s === 'job') return s;
  if (Number(t?.ar_enabled) === 1) return 'global';
  return 'none';
}

function _tplArScopeForApi(t) {
  const raw = (t?.ar_scope || '').trim().toLowerCase();
  if (raw === 'global' || raw === 'job') return raw;
  if (Number(t?.ar_enabled) === 1) return 'global';
  return 'none';
}

function _tplCategoryScope(cat) {
  if (cat === 'private') return 'none';
  if (cat === 'presets') return 'job';
  return cat || 'global';
}

function _tplIsGlobalActive(t) {
  return _tplScopeOf(t) === 'global' && Number(t.ar_enabled) === 1;
}

function _tplJobForSelected(job) {
  if (!job) return null;
  return tplJobsCache.find(j => String(j.id) === String(job)) || null;
}

function _tplJobAutoreplyOn(j) {
  return (tplJobRulesCache || []).some(_isJobRuleEnabled)
    || (j && (j.autoreply_enabled === true || j.autoreply_enabled === 1 || j.autoreply_enabled === '1'));
}

function _tplBoundTemplateId(job) {
  const trig = (document.getElementById('tplAutoTriggerStatus')?.value || '').trim().toLowerCase();
  const rule = (tplJobRulesCache || []).find(r =>
    _isJobRuleEnabled(r) && (!trig || String(r.trigger_status || '').trim().toLowerCase() === trig)
  ) || (tplJobRulesCache || []).find(_isJobRuleEnabled);
  if (rule?.template_id) return Number(rule.template_id) || null;
  const j = _tplJobForSelected(job);
  if (!j || !j.autoreply_template_id) return null;
  return Number(j.autoreply_template_id) || null;
}

function _tplRulesForTemplate(tplId) {
  return (tplJobRulesCache || []).filter(r =>
    _isJobRuleEnabled(r) && String(r.template_id) === String(tplId)
  );
}

function _tplIsJobActive(t, job) {
  if (!t) return false;
  return _tplRulesForTemplate(t.id).length > 0;
}

function _tplEffectivePlaybookForTemplate(tplId) {
  const j = _tplJobForSelected(tplJobId);
  if (j?.use_sales_playbook !== null && j?.use_sales_playbook !== undefined) {
    return Number(j.use_sales_playbook || 0) === 1;
  }
  const rule = _tplRulesForTemplate(tplId).find(r => (r.ar_mode || '').toLowerCase() === 'ai');
  if (rule) return Number(rule.use_sales_playbook || 0) === 1;
  const t = _tplById(tplId);
  return Number(t?.use_sales_playbook || 0) === 1;
}

function _tplStatusLabel(id) {
  const st = _tplFlowStatusCatalog().find(s => s.value === id)
    || (leadStatusOptions || []).find(s => s.value === id);
  return st?.label || id || '—';
}

let _tplJobAgentMode = 'funnel';
let _tplJobAgentModeJob = 0;
async function loadTplJobAgentMode(jobId) {
  _tplJobAgentModeJob = jobId;
  try {
    const r = await j(`/api/broadcast/jobs/${jobId}/agent-config`);
    _tplJobAgentMode = r.agent_mode === 'simple' ? 'simple' : 'funnel';
  } catch (_) { _tplJobAgentMode = 'funnel'; }
  renderTplJobHeader();
}
async function setTplJobAgentMode(mode) {
  if (!tplJobId) { showToast('Сначала выбери задачу', 'err'); return; }
  mode = mode === 'simple' ? 'simple' : 'funnel';
  try {
    const r = await j(`/api/broadcast/jobs/${tplJobId}/agent-config`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ agent_mode: mode }),
    });
    _tplJobAgentMode = r.agent_mode === 'simple' ? 'simple' : 'funnel';
    _tplJobAgentModeJob = tplJobId;
    renderTplJobHeader();
    showToast(_tplJobAgentMode === 'simple'
      ? 'Режим: Простой агент — отвечает по анкете и справке, воронка не используется'
      : 'Режим: Воронка — отвечает по статусам и шаблонам с холста', 'info', { duration: 4500 });
  } catch (e) { showToast(e.message || e, 'err'); }
}

function renderTplJobHeader() {
  if (tplCategory !== 'job') return;
  if (tplJobId && _tplJobAgentModeJob !== tplJobId) { loadTplJobAgentMode(tplJobId); }
  const line = document.getElementById('tplJobStatusLine');
  const autoBtn = document.getElementById('btnTplJobAutoApplySafe');
  const j = _tplJobForSelected(tplJobId);
  const activeRules = (tplJobRulesCache || []).filter(_isJobRuleEnabled);
  const activeAiRules = activeRules.filter(r => (r.ar_mode || '').toLowerCase() === 'ai');
  const playbookOnCnt = activeAiRules.filter(r => Number(r.use_sales_playbook || 0) === 1).length;
  const jobPbRaw = j?.use_sales_playbook;
  const jobPbSet = jobPbRaw !== null && jobPbRaw !== undefined;
  const jobPbOn = jobPbSet ? Number(jobPbRaw || 0) === 1 : playbookOnCnt > 0;
  const schemaSum = _jobSchemaSummary();
  const schemaChip = !schemaSum.hasSchema
    ? '<span class="tpl-job-off">JSON: не настроен</span>'
    : (schemaSum.source === 'manual'
      ? `<span class="tpl-job-on">JSON: холст · ${schemaSum.templateCount} шабл.</span>`
      : `<span class="tpl-job-on">JSON: ${schemaSum.statusCount} стат.</span>`);
  if (line) {
    if (!j) {
      line.innerHTML = '<span class="tpl-job-off">Нет задач рассылки</span>';
    } else if (!activeRules.length) {
      line.innerHTML = `${schemaChip} · <span class="${jobPbOn ? 'tpl-job-on' : 'tpl-job-off'}">Обучение продаж: ${jobPbOn ? 'ВКЛ' : 'ВЫКЛ'}</span> · <span>AI-шаблонов: 0</span> · <span class="hint">Старт: «${_tplStatusLabel(_jobFunnelStartStatus())}» · назначь AI-шаблоны на карточках</span>`;
    } else {
      const startRule = activeRules.find(r => _normStatusId(r.trigger_status) === _jobFunnelStartStatus());
      const startTpl = startRule ? (startRule.template_title || _tplById(startRule.template_id)?.title || `#${startRule.template_id}`) : '—';
      line.innerHTML = `${schemaChip} · <span class="${jobPbOn ? 'tpl-job-on' : 'tpl-job-off'}">Обучение: ${jobPbOn ? 'ВКЛ' : 'ВЫКЛ'}</span> · <span>AI: ${activeRules.length}</span> · <span class="hint">Старт «${_tplStatusLabel(_jobFunnelStartStatus())}» → ${esc(startTpl)}</span>${jobPbSet ? '' : ' · <span class="hint">legacy по шаблонам</span>'}`;
    }
  }
  if (autoBtn) {
    const autoOn = Number(j?.sales_learning_auto_apply_safe || 0) === 1;
    autoBtn.textContent = `Автообучение: ${autoOn ? 'вкл' : 'выкл'}`;
    autoBtn.classList.toggle('primary', autoOn);
    autoBtn.disabled = !j;
  }
  renderTplJobRulesList();
  renderTplFunnelBoard();
}

function renderTplJobRulesList() {
  const el = document.getElementById('tplJobRulesList');
  if (!el || tplCategory !== 'job') return;
  const rules = (tplJobRulesCache || []).filter(_isJobRuleEnabled);
  const aiRules = rules.filter(r => (r.ar_mode || '').toLowerCase() === 'ai');
  const pbOn = aiRules.filter(r => Number(r.use_sales_playbook || 0) === 1).length;
  const j = _tplJobForSelected(tplJobId);
  const jobPbRaw = j?.use_sales_playbook;
  const jobPbSet = jobPbRaw !== null && jobPbRaw !== undefined;
  const jobPbOn = jobPbSet ? Number(jobPbRaw || 0) === 1 : pbOn > 0;
  const label = rules.length
    ? `Обучение продаж для задачи: ${jobPbOn ? 'ВКЛ' : 'ВЫКЛ'}. AI-шаблонов с обучением: ${pbOn}/${aiRules.length}${jobPbSet ? '' : ' (legacy по шаблонам)'}`
    : 'AI-шаблоны не назначены. Назначь шаблон в карточке CRM-статуса справа';
  el.innerHTML = `<div class="hint">${esc(label)}</div>`;
}

async function setTplJobSalesPlaybookForAll(enabled) {
  if (!tplJobId) throw new Error('Сначала выбери задачу');
  const r = await j(`/api/broadcast/jobs/${tplJobId}/sales-playbook`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ enabled: !!enabled }),
  });
  _mergeTplJobData(r.job);
  if (!Array.isArray(r.job?.autoreply_rules)) await _reloadTplJobRulesOnly();
  renderTplJobHeader();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  const onOff = enabled ? 'включено' : 'выключено';
  showToast(`Обучение продаж ${onOff} на уровне задачи`);
}

async function setTplJobSalesLearningAutoApplySafe(enabled) {
  if (!tplJobId) throw new Error('Сначала выбери задачу');
  const r = await j(`/api/broadcast/jobs/${tplJobId}/sales-learning-auto-apply`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ enabled: !!enabled }),
  });
  _mergeTplJobData(r.job);
  renderTplJobHeader();
  showToast(`Автообучение ${enabled ? 'включено' : 'выключено'} — после анализа рекомендации применяются сами`);
}

async function openJobProductFacts() {
  if (!tplJobId) throw new Error('Сначала выбери задачу');
  const sub = document.getElementById('jobProductFactsSub');
  const ta = document.getElementById('jobProductFactsText');
  const jobName = _tplJobForSelected(tplJobId)?.name || `#${tplJobId}`;
  if (sub) sub.textContent = `Задача: ${jobName}`;
  if (ta) ta.value = 'Загрузка…';
  openModal('modalJobProductFacts');
  try {
    const r = await j(`/api/broadcast/jobs/${tplJobId}/product-facts`);
    if (ta) ta.value = r.product_facts || '';
  } catch (e) {
    if (ta) ta.value = '';
    showToast('Не удалось загрузить базу знаний: ' + (e.message || e), 'err');
  }
}

async function saveJobProductFacts() {
  if (!tplJobId) { showToast('Сначала выбери задачу', 'err'); return; }
  const ta = document.getElementById('jobProductFactsText');
  try {
    await j(`/api/broadcast/jobs/${tplJobId}/product-facts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ product_facts: ta ? ta.value : '' }),
    });
    closeModals();
    showToast('База знаний задачи сохранена');
  } catch (e) {
    showToast('Ошибка сохранения: ' + (e.message || e), 'err');
  }
}

document.getElementById('btnSaveJobProductFacts')?.addEventListener('click', () => {
  saveJobProductFacts();
});

// ── Анкета ниши + режим агента (простой агент / воронка) для задачи ──
const ANKETA_FIELD_IDS = ['product', 'audience', 'goal', 'role', 'allowed', 'forbidden', 'offer'];
let agentConfigJobId = 0;

async function openAgentConfig(jobId) {
  jobId = Number(jobId || 0);
  if (!jobId) throw new Error('Сначала выбери задачу');
  agentConfigJobId = jobId;
  const sub = document.getElementById('agentConfigSub');
  const jobName = (typeof _tplJobForSelected === 'function' ? _tplJobForSelected(jobId)?.name : '') || `#${jobId}`;
  if (sub) sub.textContent = `Задача: ${jobName}`;
  // сброс полей перед загрузкой
  const modeSel = document.getElementById('agentConfigMode');
  if (modeSel) modeSel.value = 'simple';
  ANKETA_FIELD_IDS.forEach(k => { const el = document.getElementById('anketa_' + k); if (el) el.value = ''; });
  openModal('modalAgentConfig');
  try {
    const r = await j(`/api/broadcast/jobs/${jobId}/agent-config`);
    if (modeSel) modeSel.value = (r.agent_mode === 'simple' ? 'simple' : (r.agent_mode === 'funnel' ? 'funnel' : 'simple'));
    const a = r.anketa || {};
    ANKETA_FIELD_IDS.forEach(k => { const el = document.getElementById('anketa_' + k); if (el) el.value = a[k] || ''; });
  } catch (e) {
    showToast('Не удалось загрузить анкету: ' + (e.message || e), 'err');
  }
}

async function saveAgentConfig() {
  if (!agentConfigJobId) { showToast('Задача не выбрана', 'err'); return; }
  const modeSel = document.getElementById('agentConfigMode');
  const anketa = {};
  ANKETA_FIELD_IDS.forEach(k => {
    const el = document.getElementById('anketa_' + k);
    const v = (el?.value || '').trim();
    if (v) anketa[k] = v;
  });
  try {
    const r = await j(`/api/broadcast/jobs/${agentConfigJobId}/agent-config`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ agent_mode: modeSel?.value === 'funnel' ? 'funnel' : 'simple', anketa }),
    });
    closeModals();
    showToast(r.agent_mode === 'simple'
      ? 'Сохранено. Простой агент: ведёт диалог по анкете + справке'
      : 'Сохранено. Режим воронки: статусы и шаблоны');
    // если тренажёр открыт на этой задаче — синхронизируем дропдаун режима
    if (typeof trainState === 'object' && trainState && Number(trainState.jobId) === Number(agentConfigJobId)) {
      const tMode = document.getElementById('trainAgentMode');
      if (tMode && r.agent_mode) { tMode.value = r.agent_mode; trainState.agentMode = r.agent_mode; }
    }
  } catch (e) {
    showToast('Ошибка сохранения: ' + (e.message || e), 'err');
  }
}

document.getElementById('btnSaveAgentConfig')?.addEventListener('click', () => { saveAgentConfig(); });
document.getElementById('btnTrainAnketa')?.addEventListener('click', () => {
  const jobSel = document.getElementById('trainJobSelect');
  const jobId = Number(jobSel?.value || trainState.jobId || 0);
  if (!jobId) { showToast('Сначала выбери задачу', 'err'); return; }
  openAgentConfig(jobId).catch(e => showToast(e.message || e, 'err'));
});

// ───────────────────────────── Простой агент (вкладка) ─────────────────────────────
const AGENT_TAB_KEY = 'agentTabJobId';
const AGENT_ANKETA_FIELDS = ['product', 'audience', 'goal', 'role', 'allowed', 'forbidden', 'offer'];
let agentTabState = { jobId: 0, mode: 'simple' };
let agentJobsMode = {}; // id -> 'funnel'|'simple'

function _agentJobOptionLabel(jb) {
  const mode = (jb.agent_mode === 'funnel') ? 'воронка' : 'простой';
  return `${jb.name || ('Задача #' + jb.id)} — ${mode}`;
}

async function loadAgentTab() {
  const sel = document.getElementById('agentJobSelect');
  if (!sel) return;
  try {
    const r = await j('/api/broadcast/jobs/simple');
    const jobs = (r && r.jobs) || [];
    agentJobsMode = {};
    jobs.forEach(jb => { agentJobsMode[String(jb.id)] = (jb.agent_mode === 'funnel') ? 'funnel' : 'simple'; });
    let saved = sel.value || '';
    if (!saved) { try { saved = sessionStorage.getItem(AGENT_TAB_KEY) || ''; } catch (_) {} }
    sel.innerHTML = jobs.length
      ? jobs.map(jb => `<option value="${jb.id}">${esc(_agentJobOptionLabel(jb))}</option>`).join('')
      : '<option value="">Нет задач — создай рассылку</option>';
    sel.dataset.loaded = '1';
    if (saved && jobs.some(x => String(x.id) === String(saved))) sel.value = saved;
  } catch (e) {
    sel.innerHTML = '<option value="">Ошибка загрузки задач</option>';
  }
  const jobId = Number(sel.value || 0);
  if (jobId) await agentSelectJob(jobId);
  else agentRenderModeStatus('simple');
}

function agentRenderModeStatus(mode) {
  agentTabState.mode = mode === 'funnel' ? 'funnel' : 'simple';
  const st = document.getElementById('agentModeStatus');
  if (st) {
    st.textContent = agentTabState.mode === 'simple' ? 'Простой агент' : 'Воронка';
    st.className = 'agent-mode-status ' + (agentTabState.mode === 'simple' ? 'is-simple' : 'is-funnel');
  }
  const isFunnel = agentTabState.mode === 'funnel';
  const notice = document.getElementById('agentFunnelNotice');
  const grid = document.getElementById('agentGrid');
  if (notice) notice.hidden = !isFunnel;
  if (grid) grid.style.display = isFunnel ? 'none' : '';
}

function agentFillAnketa(anketa) {
  const a = anketa || {};
  AGENT_ANKETA_FIELDS.forEach(k => {
    const el = document.getElementById('agf_' + k);
    if (el) el.value = a[k] || '';
  });
  agentRenderFieldCards();
}

async function agentSelectJob(jobId) {
  jobId = Number(jobId || 0);
  agentTabState.jobId = jobId;
  try { sessionStorage.setItem(AGENT_TAB_KEY, String(jobId)); } catch (_) {}
  if (!jobId) { agentRenderModeStatus('simple'); agentFillAnketa({}); return; }
  try {
    const [cfg, facts] = await Promise.all([
      j(`/api/broadcast/jobs/${jobId}/agent-config`).catch(() => ({})),
      j(`/api/broadcast/jobs/${jobId}/product-facts`).catch(() => ({})),
    ]);
    const mode = cfg.agent_mode === 'funnel' ? 'funnel' : 'simple';
    agentJobsMode[String(jobId)] = mode;
    agentRenderModeStatus(mode);
    agentFillAnketa(cfg.anketa || {});
    const ft = document.getElementById('agentFactsText');
    if (ft) ft.value = facts.product_facts || '';
    agentRenderFieldCards();
  } catch (e) {
    showToast('Не удалось загрузить настройки задачи: ' + (e.message || e), 'err');
  }
}

async function agentSaveAnketa() {
  if (!agentTabState.jobId) { showToast('Сначала выбери задачу', 'err'); return; }
  const anketa = {};
  AGENT_ANKETA_FIELDS.forEach(k => {
    const v = (document.getElementById('agf_' + k)?.value || '').trim();
    if (v) anketa[k] = v;
  });
  try {
    await j(`/api/broadcast/jobs/${agentTabState.jobId}/agent-config`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ agent_mode: agentTabState.mode, anketa }),
    });
    showToast('Анкета сохранена');
  } catch (e) {
    showToast('Ошибка сохранения анкеты: ' + (e.message || e), 'err');
  }
}

async function agentSaveFacts() {
  if (!agentTabState.jobId) { showToast('Сначала выбери задачу', 'err'); return; }
  const ta = document.getElementById('agentFactsText');
  try {
    await j(`/api/broadcast/jobs/${agentTabState.jobId}/product-facts`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ product_facts: ta ? ta.value : '' }),
    });
    showToast('База знаний сохранена');
  } catch (e) {
    showToast('Ошибка сохранения базы знаний: ' + (e.message || e), 'err');
  }
}

async function agentMakeSimple() {
  if (!agentTabState.jobId) { showToast('Сначала выбери задачу', 'err'); return; }
  try {
    await j(`/api/broadcast/jobs/${agentTabState.jobId}/agent-config`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ agent_mode: 'simple' }),
    });
    agentJobsMode[String(agentTabState.jobId)] = 'simple';
    const sel = document.getElementById('agentJobSelect');
    const opt = sel?.querySelector(`option[value="${agentTabState.jobId}"]`);
    if (opt) opt.textContent = opt.textContent.replace(/—\s*воронка\s*$/, '— простой');
    await agentSelectJob(agentTabState.jobId);
    showToast('Задача переведена в режим «Простой агент»');
  } catch (e) {
    showToast('Ошибка смены режима: ' + (e.message || e), 'err');
  }
}

document.getElementById('agentJobSelect')?.addEventListener('change', (e) => {
  agentSelectJob(Number(e.target.value || 0)).catch(() => {});
});
document.getElementById('btnAgentMakeSimple')?.addEventListener('click', () => agentMakeSimple());
document.getElementById('btnAgentSaveAnketa')?.addEventListener('click', () => agentSaveAnketa());
document.getElementById('btnAgentSaveFacts')?.addEventListener('click', () => agentSaveFacts());

// ── Карточки полей анкеты/базы знаний + модалка редактирования ──
const AGENT_FIELD_META = {
  product:   { label: 'Продукт', hint: 'что продаём, суть, кому полезно', store: 'agf_product', ph: 'напр.: VPN под игру X — стабильный пинг, свои серверы, работает где другие лагают' },
  audience:  { label: 'Целевая аудитория', hint: 'кто клиент, его ситуация и боль', store: 'agf_audience', ph: 'напр.: игроки из РФ, у кого игра не заходит без VPN; бесит лаг и обрывы' },
  goal:      { label: 'Цель диалога', hint: 'к какому действию ведём клиента', store: 'agf_goal', ph: 'напр.: довести до пробника, затем до оплаты подписки' },
  role:      { label: 'Роль и тон', hint: 'от чьего лица пишем и как себя ведём', store: 'agf_role', ph: 'напр.: свой парень, сам играю и пользуюсь этим; на «ты», без официоза' },
  allowed:   { label: 'Что можно и нужно', hint: 'разрешённые действия, акценты', store: 'agf_allowed', ph: 'напр.: давать пробник и ссылку на бота, спрашивать про текущий впн, вести к оплате' },
  forbidden: { label: 'Что нельзя', hint: 'запреты', store: 'agf_forbidden', ph: 'напр.: не обещать лишнего, не спамить ссылками, не давить, не палить рассылку' },
  offer:     { label: 'Оффер / следующий шаг', hint: 'что предлагаем, ссылка/бот если есть', store: 'agf_offer', ph: 'напр.: бесплатный тест 3 дня → @bot; подписка 199₽/мес' },
  facts:     { label: 'Факты продукта', hint: 'тарифы, цены, что входит, серверы/устройства, пробник, оплата, ссылка/бот', store: 'agentFactsText', ph: 'Факты о продукте:\n- что это и для чего\n- тарифы и цены\n- что входит, серверы/устройства\n- пробник, оплата, поддержка\n- ссылка/бот' },
};

function _agentFieldCardHtml(key) {
  const m = AGENT_FIELD_META[key];
  if (!m) return '';
  const val = (document.getElementById(m.store)?.value || '').trim();
  const filled = !!val;
  const preview = filled
    ? esc(val.replace(/\s+/g, ' ').slice(0, 160))
    : 'Нажми, чтобы заполнить';
  return `<button type="button" class="agent-field-card${filled ? ' is-filled' : ''}${key === 'facts' ? ' agent-field-card-wide' : ''}" data-agent-field="${key}">
      <span class="agent-field-card-top">
        <span class="agent-field-card-label">${esc(m.label)}</span>
        <span class="agent-field-card-badge${filled ? ' ok' : ''}">${filled ? '✓ заполнено' : 'пусто'}</span>
      </span>
      <span class="agent-field-card-hint">${esc(m.hint)}</span>
      <span class="agent-field-card-preview${filled ? '' : ' empty'}">${preview}</span>
    </button>`;
}

function agentRenderFieldCards() {
  const a = document.getElementById('agentAnketaCards');
  const f = document.getElementById('agentFactsCards');
  if (a) a.innerHTML = AGENT_ANKETA_FIELDS.map(_agentFieldCardHtml).join('');
  if (f) f.innerHTML = _agentFieldCardHtml('facts');
}

let _agentModalKey = null;
function openAgentFieldModal(key) {
  const m = AGENT_FIELD_META[key];
  if (!m) return;
  _agentModalKey = key;
  document.getElementById('agentFieldModalTitle').textContent = m.label;
  document.getElementById('agentFieldModalHint').textContent = m.hint;
  const ta = document.getElementById('agentFieldModalText');
  ta.value = document.getElementById(m.store)?.value || '';
  ta.placeholder = m.ph || '';
  const ov = document.getElementById('agentFieldModal');
  ov.hidden = false;
  document.body.classList.add('modal-open');
  setTimeout(() => ta.focus(), 30);
}
function closeAgentFieldModal() {
  const ov = document.getElementById('agentFieldModal');
  if (ov) ov.hidden = true;
  document.body.classList.remove('modal-open');
  _agentModalKey = null;
}
async function saveAgentFieldModal() {
  if (!_agentModalKey) { closeAgentFieldModal(); return; }
  const m = AGENT_FIELD_META[_agentModalKey];
  const store = document.getElementById(m.store);
  if (store) store.value = document.getElementById('agentFieldModalText').value;
  agentRenderFieldCards();
  if (_agentModalKey === 'facts') await agentSaveFacts();
  else await agentSaveAnketa();
  closeAgentFieldModal();
}

document.getElementById('agentAnketaCards')?.addEventListener('click', (e) => {
  const c = e.target.closest('[data-agent-field]');
  if (c) openAgentFieldModal(c.dataset.agentField);
});
document.getElementById('agentFactsCards')?.addEventListener('click', (e) => {
  const c = e.target.closest('[data-agent-field]');
  if (c) openAgentFieldModal(c.dataset.agentField);
});
document.getElementById('agentFieldModalClose')?.addEventListener('click', closeAgentFieldModal);
document.getElementById('agentFieldModalCancel')?.addEventListener('click', closeAgentFieldModal);
document.getElementById('agentFieldModalSave')?.addEventListener('click', () => saveAgentFieldModal().catch(e => showToast(e.message || e, 'err')));
document.getElementById('agentFieldModal')?.addEventListener('click', (e) => {
  if (e.target.id === 'agentFieldModal') closeAgentFieldModal();
});
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !document.getElementById('agentFieldModal')?.hidden) closeAgentFieldModal();
});


function _statusRowsForFunnelDraft() {
  return (_tplFlowStatusCatalog() || []).map(s => ({
    id: s.value,
    label: s.label || s.value,
    description: s.description || '',
    color: s.color || '#b8a8ff',
    favorite: !!s.favorite,
    builtin: !!s.builtin,
  }));
}

function _funnelStatusPayload(rows) {
  const seen = new Set();
  return rows.map(s => {
    const label = (s.label || '').trim();
    if (!label) return null;
    const id = _ensureStatusSlug(s.id, label, seen);
    return {
      id,
      label,
      description: (s.description || '').trim().slice(0, 200),
      color: s.color || '#b8a8ff',
      favorite: !!s.favorite,
      builtin: !!s.builtin,
    };
  }).filter(Boolean);
}

function _ensureFunnelStartInCatalog(options) {
  const out = [...(options || [])];
  if (!out.some(s => _normStatusId(s.value) === FUNNEL_START_STATUS)) {
    const g = (leadStatusOptions || []).find(s => s.value === FUNNEL_START_STATUS);
    out.unshift({
      value: FUNNEL_START_STATUS,
      label: g?.label || 'Новый',
      description: g?.description || '',
      color: g?.color || '#7eb6ff',
      favorite: false,
      builtin: true,
    });
  }
  return out;
}

function _ensureFunnelStartRow(rows) {
  if (tplCategory !== 'job' || !tplJobId) return rows;
  const out = [...rows];
  if (!out.some(s => _normStatusId(s.id) === FUNNEL_START_STATUS)) {
    const g = (leadStatusOptions || []).find(s => s.value === FUNNEL_START_STATUS);
    out.unshift({
      id: FUNNEL_START_STATUS,
      label: g?.label || 'Новый',
      description: g?.description || '',
      color: g?.color || '#7eb6ff',
      favorite: false,
      builtin: true,
    });
  }
  return out;
}

async function _saveFunnelStatuses(rows, focusStatus = '') {
  const payload = _funnelStatusPayload(_ensureFunnelStartRow(rows));
  if (!payload.length) {
    showToast('Нужен хотя бы один статус', 'err');
    return;
  }
  try {
    const isJob = tplCategory === 'job' && tplJobId;
    const url = isJob
      ? `/api/broadcast/jobs/${tplJobId}/statuses`
      : '/api/lead-statuses';
    const r = await j(url, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ statuses: payload }),
    });
    const saved = r.statuses || [];
    if (isJob) {
      tplJobStatusOptions = saved.map(s => ({
        value: _normStatusId(s.status_key || s.id || ''),
        label: s.title || s.label || s.status_key || s.id || '',
        description: s.description || '',
        color: s.color || '#b8a8ff',
        favorite: false,
        builtin: false,
      })).filter(s => s.value);
      syncTemplateAutoreplyFields();
    } else {
      _syncLeadStatusOptions(saved);
      refreshAllStatusSelects();
    }
    const inserted = focusStatus ? rows.find(x => x.id === focusStatus) : null;
    const focusId = inserted
      ? (_normStatusId(
          saved.find(s => (s.title || s.label) === inserted.label)?.status_key
          || saved.find(s => (s.title || s.label) === inserted.label)?.id
        ) || focusStatus)
      : focusStatus;
    if (focusId) {
      const trig = document.getElementById('tplAutoTriggerStatus');
      if (trig && (_tplFlowStatusCatalog() || []).some(x => x.value === focusId)) trig.value = focusId;
    }
    renderTplJobHeader();
    syncTplJobAttachBar();
    showToast('Статус сохранён');
  } catch (err) {
    showToast(err.message || 'Ошибка сохранения статусов', 'err');
    throw err;
  }
}

async function _insertFunnelStatus(afterStatus) {
  const rows = _statusRowsForFunnelDraft();
  const existing = new Set(rows.map(s => s.id));
  let n = rows.length + 1;
  let id = '';
  let label = '';
  do {
    label = `Статус ${n}`;
    id = _ensureStatusSlug('', label, existing);
    n += 1;
  } while (existing.has(id));
  const pos = rows.findIndex(s => s.id === afterStatus);
  const item = {
    id,
    label,
    description: '',
    color: '#9f8bff',
    favorite: false,
    builtin: false,
  };
  if (pos >= 0) rows.splice(pos + 1, 0, item);
  else rows.push(item);
  await _saveFunnelStatuses(rows, id);
  _showStatusOnFlowCanvas(id);
  renderTplFunnelBoard();
  _openTplFlowStatusSettings(id);
  showToast('Статус добавлен — переименуй и настрой на карточке');
}

async function _renameFunnelStatus(statusId) {
  const rows = _statusRowsForFunnelDraft();
  const idx = rows.findIndex(s => s.id === statusId);
  if (idx < 0) return;
  const current = rows[idx];
  const nextLabel = (prompt('Новое имя статуса:', current.label || statusId) || '').trim();
  if (!nextLabel || nextLabel === current.label) return;
  rows[idx].label = nextLabel;
  await _saveFunnelStatuses(rows, statusId);
}

async function _deleteFunnelStatus(statusId) {
  const rows = _statusRowsForFunnelDraft();
  const idx = rows.findIndex(s => s.id === statusId);
  if (idx < 0) return;
  if (rows[idx].builtin) {
    showToast('Базовый статус удалить нельзя', 'err');
    return;
  }
  if (rows.length <= 1) {
    showToast('Нужен хотя бы один статус', 'err');
    return;
  }
  if (!confirm(`Удалить статус «${rows[idx].label || statusId}»?`)) return;
  rows.splice(idx, 1);
  await _saveFunnelStatuses(rows);
}

function _jobPresetsList() {
  const jid = Number(tplJobId) || 0;
  return (tplCache || []).filter(t => {
    if (_tplScopeOf(t) !== 'job') return false;
    if (!jid) return true;
    const ownerJob = Number(t.job_id || 0);
    if (ownerJob === jid) return true;
    return Array.isArray(t.assigned_job_ids) && t.assigned_job_ids.some(x => Number(x || 0) === jid);
  });
}

function _tplFlowStatusCatalog() {
  if (tplCategory === 'job' && tplJobId && Array.isArray(tplJobStatusOptions) && tplJobStatusOptions.length) {
    return _ensureFunnelStartInCatalog(tplJobStatusOptions);
  }
  return leadStatusOptions || [];
}

function _normStatusId(status) {
  return String(status || '').trim().toLowerCase();
}

function _transitionCondition(t) {
  return String(t?.condition_text ?? t?.condition ?? t?.rule ?? t?.trigger_text ?? '').trim();
}

function _normTransitionText(text) {
  return String(text ?? '').replace(/\r\n/g, '\n').trim();
}

function _transitionTextsMatch(a, b) {
  return _normTransitionText(a) === _normTransitionText(b);
}

function _normalizeJobTransitions(rows) {
  return (Array.isArray(rows) ? rows : []).map(t => ({
    ...t,
    from_status: _normStatusId(t.from_status ?? t.from_status_id),
    to_status: _normStatusId(t.to_status ?? t.to_status_id),
    condition_text: _transitionCondition(t),
  })).filter(t => t.from_status && t.to_status);
}

function _mergeTransitionInCache(row) {
  const norm = _normalizeJobTransitions([row])[0];
  if (!norm) return;
  const rest = (tplJobTransitionsCache || []).filter(t => Number(t.id) !== Number(norm.id));
  tplJobTransitionsCache = [...rest, norm].sort(
    (a, b) => (Number(a.sort_order) || 0) - (Number(b.sort_order) || 0) || Number(a.id) - Number(b.id)
  );
}

function _transitionModalFields() {
  const modal = document.getElementById('modalJobTransition');
  return {
    condition: modal?.querySelector('#transitionCondition') || document.getElementById('transitionCondition'),
    toStatus: modal?.querySelector('#transitionToStatus') || document.getElementById('transitionToStatus'),
    newName: modal?.querySelector('#transitionNewStatusName') || document.getElementById('transitionNewStatusName'),
  };
}

function _readTransitionConditionInput() {
  const modal = document.getElementById('modalJobTransition');
  const el = (modal && !modal.hidden)
    ? modal.querySelector('#transitionCondition')
    : (_transitionModalFields().condition || document.getElementById('transitionCondition'));
  return _normTransitionText(el?.value);
}

function _tplDebug(label, data) {
  try {
    if (localStorage.getItem('spamimpakter_tpl_debug') === '1') {
      console.debug(`[templates] ${label}`, data);
    }
  } catch (_) {}
}

async function _assignPresetToStatus(tplId, status) {
  const st = _normStatusId(status);
  if (!st) throw new Error('Не выбран статус');
  const trig = document.getElementById('tplAutoTriggerStatus');
  if (trig && (_tplFlowStatusCatalog() || []).some(x => x.value === st)) trig.value = st;
  await setJobTplActive(Number(tplId), true, st);
}

function _funnelChainStatuses() {
  const start = _jobFunnelStartStatus();
  const transitions = tplJobTransitionsCache || [];
  const order = [start];
  const seen = new Set([start]);
  const queue = [start];
  while (queue.length) {
    const cur = queue.shift();
    transitions
      .filter(t => _normStatusId(t.from_status) === cur)
      .forEach(t => {
        const toStatus = _normStatusId(t.to_status);
        if (toStatus && !seen.has(toStatus)) {
          seen.add(toStatus);
          order.push(toStatus);
          queue.push(toStatus);
        }
      });
  }
  return order;
}

function _tplFlowMode() {
  try {
    const saved = localStorage.getItem(TPL_FLOW_MODE_KEY);
    if (saved === 'list' || saved === 'builder') return saved;
  } catch (_) {}
  return 'builder';
}

function _tplFlowPositionsKey(jobId = tplJobId) {
  return `ai_flow_positions:${jobId || 'none'}`;
}

function _tplFlowCanvasKey(jobId = tplJobId) {
  return `ai_flow_canvas_state:${jobId || 'none'}`;
}

function _normTplFlowCanvasState(raw) {
  const st = raw && typeof raw === 'object' ? raw : {};
  return {
    hiddenStatusIds: Array.isArray(st.hiddenStatusIds) ? st.hiddenStatusIds.map(String) : [],
    hiddenTransitionIds: Array.isArray(st.hiddenTransitionIds) ? st.hiddenTransitionIds.map(Number) : [],
    nodeLayouts: st.nodeLayouts && typeof st.nodeLayouts === 'object' ? st.nodeLayouts : {},
    anchorSides: st.anchorSides && typeof st.anchorSides === 'object' ? st.anchorSides : {},
    lineAnchors: st.lineAnchors && typeof st.lineAnchors === 'object' ? st.lineAnchors : {},
    lineStyles: st.lineStyles && typeof st.lineStyles === 'object' ? st.lineStyles : {},
  };
}

function _clamp01(v) {
  const n = Number(v);
  if (!Number.isFinite(n)) return 0.5;
  return Math.min(1, Math.max(0, n));
}

// Точка пересечения луча "центр A → центр B" с границей карточки A (в ratio 0..1).
function _tplFlowClosestRatio(rect, otherRect) {
  const ax = rect.left + rect.width / 2;
  const ay = rect.top + rect.height / 2;
  const bx = otherRect.left + otherRect.width / 2;
  const by = otherRect.top + otherRect.height / 2;
  const dx = bx - ax;
  const dy = by - ay;
  const sx = dx !== 0 ? (rect.width / 2) / Math.abs(dx) : Infinity;
  const sy = dy !== 0 ? (rect.height / 2) / Math.abs(dy) : Infinity;
  const s = Math.min(sx, sy, 1e6);
  if (!Number.isFinite(s)) return { xRatio: 1, yRatio: 0.5 };
  const px = ax + dx * s;
  const py = ay + dy * s;
  return {
    xRatio: _clamp01((px - rect.left) / Math.max(1, rect.width)),
    yRatio: _clamp01((py - rect.top) / Math.max(1, rect.height)),
  };
}

function _tplFlowAnchorPoint(rect, wr, ratio) {
  // getBoundingClientRect возвращает РАЗМЕРЫ С УЧЁТОМ zoom (workspace масштабируется
  // через transform: scale). SVG-координаты — немасштабированные, поэтому делим
  // обратно на zoom, иначе при зуме линии отрываются от карточек.
  const z = _tplFlowZoom || 1;
  return {
    x: (rect.left - wr.left + _clamp01(ratio?.xRatio) * rect.width) / z,
    y: (rect.top - wr.top + _clamp01(ratio?.yRatio) * rect.height) / z,
  };
}

function _tplFlowCenterAnchor(statusId) {
  return { statusId: _normStatusId(statusId), xRatio: 0.5, yRatio: 0.5 };
}

// Миграция со старых anchorSides (стороны карточки) на ratio-точки.
function _tplFlowSideToRatio(side) {
  if (side === 'right') return { xRatio: 1, yRatio: 0.5 };
  if (side === 'left') return { xRatio: 0, yRatio: 0.5 };
  if (side === 'bottom') return { xRatio: 0.5, yRatio: 1 };
  if (side === 'top') return { xRatio: 0.5, yRatio: 0 };
  return null;
}

function _loadTplFlowCanvasState(jobId = tplJobId) {
  try {
    const raw = JSON.parse(localStorage.getItem(_tplFlowCanvasKey(jobId)) || 'null');
    if (raw) return _normTplFlowCanvasState(raw);
  } catch (_) {}
  // Миграция со старого ключа ai_flow_positions:{job_id} (только позиции).
  let legacy = {};
  try {
    legacy = JSON.parse(localStorage.getItem(_tplFlowPositionsKey(jobId)) || '{}') || {};
  } catch (_) {}
  return _normTplFlowCanvasState({ nodeLayouts: legacy });
}

function _saveTplFlowCanvasState(st, jobId = tplJobId) {
  try {
    localStorage.setItem(_tplFlowCanvasKey(jobId), JSON.stringify(_normTplFlowCanvasState(st)));
  } catch (_) {}
}

function _loadTplFlowPositions(jobId = tplJobId) {
  return _loadTplFlowCanvasState(jobId).nodeLayouts;
}

function _saveTplFlowPositions(pos, jobId = tplJobId) {
  const st = _loadTplFlowCanvasState(jobId);
  st.nodeLayouts = pos || {};
  _saveTplFlowCanvasState(st, jobId);
}

// ── Undo/Redo: история визуального состояния canvas (localStorage snapshots) ──
const TPL_FLOW_HISTORY_MAX = 60;
let _tplFlowHistory = { jobId: null, past: [], future: [] };
let _tplFlowRestoring = false;

function _syncTplFlowUndoButtons() {
  const board = document.getElementById('tplFunnelBoard');
  if (!board) return;
  const sameJob = _tplFlowHistory.jobId === tplJobId;
  const u = board.querySelector('[data-flow-undo]');
  const r = board.querySelector('[data-flow-redo]');
  if (u) u.disabled = !(sameJob && _tplFlowHistory.past.length);
  if (r) r.disabled = !(sameJob && _tplFlowHistory.future.length);
}

// Снимок правил (привязки AI-шаблонов к статусам) для undo данных.
function _snapshotRulesJson() {
  const seen = {};
  (tplJobRulesCache || []).forEach(r => {
    const k = _normStatusId(r.trigger_status);
    if (!k) return;
    seen[k] = { trigger_status: k, template_id: Number(r.template_id), enabled: _isJobRuleEnabled(r) };
  });
  return JSON.stringify(
    Object.values(seen).sort((a, b) => a.trigger_status.localeCompare(b.trigger_status))
  );
}

// Полный снимок: холст + правила + стартовый статус воронки.
function _snapshotFlowState() {
  return {
    canvas: JSON.stringify(_normTplFlowCanvasState(_loadTplFlowCanvasState())),
    rules: _snapshotRulesJson(),
    start: String(_jobFunnelStartStatus() || ''),
  };
}

function _pushTplFlowHistoryRaw(beforeJson) {
  if (_tplFlowRestoring) return;
  if (_tplFlowHistory.jobId !== tplJobId) _tplFlowHistory = { jobId: tplJobId, past: [], future: [] };
  _tplFlowHistory.past.push({
    canvas: beforeJson,
    rules: _snapshotRulesJson(),
    start: String(_jobFunnelStartStatus() || ''),
  });
  if (_tplFlowHistory.past.length > TPL_FLOW_HISTORY_MAX) _tplFlowHistory.past.shift();
  _tplFlowHistory.future = [];
  _syncTplFlowUndoButtons();
}

function pushFlowHistory(beforeState) {
  _pushTplFlowHistoryRaw(JSON.stringify(_normTplFlowCanvasState(beforeState)));
}

// Применить снимок: холст локально, правила/старт — через API при отличии.
async function _applyFlowSnapshot(entry) {
  if (!entry) return;
  _tplFlowRestoring = true;
  try {
    if (entry.canvas) _saveTplFlowCanvasState(JSON.parse(entry.canvas));
    if (entry.rules != null && entry.rules !== _snapshotRulesJson()) {
      await saveTplJobRules(JSON.parse(entry.rules));
      await _reloadTplJobRulesOnly();
    }
    const curStart = String(_jobFunnelStartStatus() || '');
    if (entry.start && entry.start !== curStart) {
      await setJobFunnelStartStatus(entry.start);
    }
  } catch (e) {
    showToast(toastText(e), 'err');
  } finally {
    _tplFlowRestoring = false;
  }
  renderTplFunnelBoard();
}

async function _undoTplFlow() {
  if (_tplFlowHistory.jobId !== tplJobId || !_tplFlowHistory.past.length) {
    showToast('Нечего отменять');
    return;
  }
  _tplFlowHistory.future.push(_snapshotFlowState());
  const entry = _tplFlowHistory.past.pop();
  _syncTplFlowUndoButtons();
  await _applyFlowSnapshot(entry);
}

async function _redoTplFlow() {
  if (_tplFlowHistory.jobId !== tplJobId || !_tplFlowHistory.future.length) {
    showToast('Нечего повторять');
    return;
  }
  _tplFlowHistory.past.push(_snapshotFlowState());
  const entry = _tplFlowHistory.future.pop();
  _syncTplFlowUndoButtons();
  await _applyFlowSnapshot(entry);
}

// ── Скрытие/возврат объектов: меняет только canvas, данные не трогает ──
function _tplFlowHideStatus(statusId) {
  const st = _loadTplFlowCanvasState();
  pushFlowHistory(st);
  const id = _normStatusId(statusId);
  if (!st.hiddenStatusIds.includes(id)) st.hiddenStatusIds.push(id);
  _saveTplFlowCanvasState(st);
  _tplFlowSelection = null;
  renderTplFunnelBoard();
  showToast(
    `Статус «${_tplStatusLabel(id)}» убран с холста`,
    'ok',
    {
      duration: 9000,
      action: { label: 'Вернуть', fn: () => _tplFlowRestoreStatus(id) },
    },
  );
}

function _tplFlowRestoreStatus(statusId) {
  const st = _loadTplFlowCanvasState();
  pushFlowHistory(st);
  const id = _normStatusId(statusId);
  st.hiddenStatusIds = st.hiddenStatusIds.filter(x => x !== id);
  _saveTplFlowCanvasState(st);
  renderTplFunnelBoard();
}

function _tplFlowHideTransition(transitionId) {
  const st = _loadTplFlowCanvasState();
  pushFlowHistory(st);
  const id = Number(transitionId);
  if (!st.hiddenTransitionIds.includes(id)) st.hiddenTransitionIds.push(id);
  _saveTplFlowCanvasState(st);
  _tplFlowSelection = null;
  renderTplFunnelBoard();
  showToast('Переход убран с холста. Можно вернуть из библиотеки.');
}

// Esc — отмена режима нити; Delete на выбранной линии — безопасное «убрать с холста».
if (!window._tplFlowKeysBound) {
  window._tplFlowKeysBound = true;
  document.addEventListener('keyup', e => {
    if (e.key === 'Shift' && _tplFlowDrawing?.startedByShift && !_tplFlowPendingAnchors) {
      _tplFlowCancelDrawing();
      showToast('Режим нити выключен');
    }
  });
  document.addEventListener('keydown', e => {
    if (e.key === 'Shift' && _tplFlowMode() === 'builder' && !_tplFlowDrawing) {
      const board = document.getElementById('tplFunnelBoard');
      if (!board || board.hidden) return;
      const selId = _tplFlowSelection?.type === 'status' ? _normStatusId(_tplFlowSelection.id) : '';
      if (!selId) return;
      const node = board.querySelector(`[data-flow-status="${_cssEscape(selId)}"]`);
      if (node) {
        _tplFlowLineEdit = null;
        _tplFlowStickyStatusPanel = false;
        _tplFlowBeginFromNode(node, board, null, null, { startedByShift: true });
      }
      return;
    }
    if (e.key === 'Shift' && _tplFlowDrawing?.from) return;
    if (e.key === 'Escape' && _tplFlowDrawing) {
      _tplFlowCancelDrawing();
      showToast('Нить отменена');
      return;
    }
    if (e.key !== 'Delete') return;
    const tag = (e.target?.tagName || '').toLowerCase();
    if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target?.isContentEditable) return;
    const board = document.getElementById('tplFunnelBoard');
    if (!board || board.hidden || _tplFlowMode() !== 'builder') return;
    if (_tplFlowSelection?.type === 'transition') {
      e.preventDefault();
      _tplFlowHideTransition(Number(_tplFlowSelection.id));
    }
    if (_tplFlowSelection?.type === 'status') {
      e.preventDefault();
      _tplFlowHideStatus(String(_tplFlowSelection.id));
    }
  });
}

function _tplFlowRestoreTransition(transitionId) {
  const st = _loadTplFlowCanvasState();
  pushFlowHistory(st);
  const id = Number(transitionId);
  st.hiddenTransitionIds = st.hiddenTransitionIds.filter(x => x !== id);
  // Если переход вернули, но anchors потерялись — создадим на ближайшем рендере.
  _saveTplFlowCanvasState(st);
  renderTplFunnelBoard();
}

function _tplFlowRestoreAllHiddenStatuses() {
  const st = _loadTplFlowCanvasState();
  if (!st.hiddenStatusIds.length) return;
  pushFlowHistory(st);
  st.hiddenStatusIds = [];
  _saveTplFlowCanvasState(st);
  renderTplFunnelBoard();
}

function _tplFlowRestoreAllHiddenTransitions() {
  const st = _loadTplFlowCanvasState();
  if (!st.hiddenTransitionIds.length) return;
  pushFlowHistory(st);
  st.hiddenTransitionIds = [];
  _saveTplFlowCanvasState(st);
  renderTplFunnelBoard();
}

function _openTplFlowStatusSettings(statusId) {
  if (_tplFlowClickTimer) {
    clearTimeout(_tplFlowClickTimer);
    _tplFlowClickTimer = null;
  }
  const status = _normStatusId(statusId);
  const label = _tplStatusLabel(status);
  const byStatus = Object.fromEntries(
    (tplJobRulesCache || []).filter(_isJobRuleEnabled).map(r => [_normStatusId(r.trigger_status), r])
  );
  const rule = byStatus[status];
  const stVisual = getStatusVisualType(label, !!rule);
  const transitions = tplJobTransitionsCache || [];
  const outgoing = transitions.filter(t => _normStatusId(t.from_status) === status);
  const incoming = transitions.filter(t => _normStatusId(t.to_status) === status);

  const titleEl = document.getElementById('modalFlowStatusTitle');
  const hintEl = document.getElementById('modalFlowStatusHint');
  const bodyEl = document.getElementById('modalFlowStatusBody');
  if (!titleEl || !bodyEl) return;

  titleEl.textContent = label;
  if (hintEl) hintEl.innerHTML = `<span class="tpl-flow-type-badge" style="--badge-color:${stVisual.color}">${esc(stVisual.type)}${stVisual.empty ? ' \u00b7 \u0431\u0435\u0437 AI-\u0448\u0430\u0431\u043b\u043e\u043d\u0430' : ''}</span>`;

  const tplSection = rule
    ? `<div class="flow-modal-section">
        <div class="flow-modal-section-title">AI-\u0448\u0430\u0431\u043b\u043e\u043d</div>
        <p><strong>${esc(rule.template_title || _tplById(rule.template_id)?.title || `#${rule.template_id}`)}</strong></p>
        <div class="flow-modal-actions">
          <button type="button" class="ub-btn-primary" data-modal-open-tpl="${rule.template_id}">\u041e\u0442\u043a\u0440\u044b\u0442\u044c AI-\u0448\u0430\u0431\u043b\u043e\u043d</button>
          <button type="button" class="ub-btn-secondary" data-modal-remove-tpl="${esc(status)}">\u0421\u043d\u044f\u0442\u044c AI-\u0448\u0430\u0431\u043b\u043e\u043d</button>
        </div>
      </div>`
    : `<div class="flow-modal-section">
        <div class="flow-modal-section-title">AI-\u0448\u0430\u0431\u043b\u043e\u043d</div>
        <p class="hint">AI-\u0448\u0430\u0431\u043b\u043e\u043d \u043d\u0435 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d</p>
        <label class="flow-modal-pick-label">\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0438\u0437 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438
          <select data-modal-pick-select="${esc(status)}">
            <option value="">\u2014 AI-\u0448\u0430\u0431\u043b\u043e\u043d \u2014</option>
            ${_jobPresetsList().map(p => `<option value="${p.id}">${esc(p.title || `AI-шаблон #${p.id}`)}</option>`).join('')}
          </select>
        </label>
        <div class="flow-modal-actions">
          <button type="button" class="ub-btn-secondary" data-modal-assign-tpl="${esc(status)}">\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c</button>
          <button type="button" class="ub-btn-primary" data-modal-create-tpl="${esc(status)}">\u0421\u043e\u0437\u0434\u0430\u0442\u044c AI-\u0448\u0430\u0431\u043b\u043e\u043d</button>
        </div>
      </div>`;

  const outSection = `<div class="flow-modal-section">
    <div class="flow-modal-section-title">\u0418\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u044b (${outgoing.length})</div>
    ${outgoing.length
      ? outgoing.map(t => `<div class="flow-modal-transition-row">
          <span>\u2192 ${esc(_tplStatusLabel(_normStatusId(t.to_status)))}</span>
          <span class="hint">${esc(_shortTransitionText(t.condition_text) || '\u0443\u0441\u043b\u043e\u0432\u0438\u0435 \u043d\u0435 \u0437\u0430\u0434\u0430\u043d\u043e')}</span>
          <button type="button" class="ub-btn-sm" data-modal-edit-transition="${t.id}">\u2710</button>
          <button type="button" class="ub-btn-sm danger" data-modal-del-transition="${t.id}">\u00d7</button>
        </div>`).join('')
      : '<p class="hint">\u041d\u0435\u0442 \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u043e\u0432 \u0438\u0437 \u044d\u0442\u043e\u0433\u043e \u0441\u0442\u0430\u0442\u0443\u0441\u0430</p>'}
    <button type="button" class="ub-btn-secondary" data-modal-add-transition="${esc(status)}">+ \u041f\u0435\u0440\u0435\u0445\u043e\u0434</button>
  </div>`;

  const inSection = incoming.length ? `<div class="flow-modal-section">
    <div class="flow-modal-section-title">\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u044b (${incoming.length})</div>
    ${incoming.map(t => `<div class="flow-modal-transition-row">
      <span>\u2190 ${esc(_tplStatusLabel(_normStatusId(t.from_status)))}</span>
      <span class="hint">${esc(_shortTransitionText(t.condition_text) || '\u0443\u0441\u043b\u043e\u0432\u0438\u0435 \u043d\u0435 \u0437\u0430\u0434\u0430\u043d\u043e')}</span>
    </div>`).join('')}
  </div>` : '';

  const actionsSection = `<div class="flow-modal-section">
    <button type="button" class="ub-btn-secondary" data-modal-set-start="${esc(status)}" ${status === _jobFunnelStartStatus() ? 'disabled' : ''}>${status === _jobFunnelStartStatus() ? 'Это старт воронки' : 'Сделать стартом воронки'}</button>
    <button type="button" class="ub-btn-secondary" data-modal-hide-status="${esc(status)}">\u0423\u0431\u0440\u0430\u0442\u044c \u0441 \u0445\u043e\u043b\u0441\u0442\u0430</button>
  </div>`;

  bodyEl.innerHTML = tplSection + outSection + inSection + actionsSection;

  // Bind actions inside modal
  bodyEl.querySelectorAll('[data-modal-open-tpl]').forEach(btn => {
    btn.addEventListener('click', () => {
      closeModals();
      selectTemplateEditor(Number(btn.dataset.modalOpenTpl));
    });
  });
  bodyEl.querySelectorAll('[data-modal-remove-tpl]').forEach(btn => {
    btn.addEventListener('click', async () => {
      try { await removeJobRule(String(btn.dataset.modalRemoveTpl)); } catch (e) { showToast(e.message, 'err'); }
      closeModals();
    });
  });
  bodyEl.querySelectorAll('[data-modal-assign-tpl]').forEach(btn => {
    btn.addEventListener('click', async () => {
      const st = String(btn.dataset.modalAssignTpl || '');
      const sel = bodyEl.querySelector(`[data-modal-pick-select="${_cssEscape(st)}"]`);
      const val = sel?.value || '';
      if (!val) {
        showToast('Выбери AI-шаблон из списка', 'err');
        return;
      }
      try {
        await _assignPresetToStatus(val, st);
        closeModals();
        renderTplJobHeader();
        showToast('AI-шаблон назначен на статус');
      } catch (e) {
        showToast(e.message || 'Ошибка назначения', 'err');
      }
    });
  });
  bodyEl.querySelectorAll('[data-modal-create-tpl]').forEach(btn => {
    btn.addEventListener('click', () => {
      closeModals();
      _pendingFunnelAssignStatus = btn.dataset.modalCreateTpl;
      newTemplateEditor();
    });
  });
  bodyEl.querySelectorAll('[data-modal-add-transition]').forEach(btn => {
    btn.addEventListener('click', () => {
      closeModals();
      openTransitionModal(btn.dataset.modalAddTransition);
    });
  });
  bodyEl.querySelectorAll('[data-modal-edit-transition]').forEach(btn => {
    btn.addEventListener('click', () => {
      closeModals();
      openTransitionModal('', Number(btn.dataset.modalEditTransition));
    });
  });
  bodyEl.querySelectorAll('[data-modal-del-transition]').forEach(btn => {
    btn.addEventListener('click', async () => {
      await _deleteTransition(Number(btn.dataset.modalDelTransition));
      closeModals();
    });
  });
  bodyEl.querySelectorAll('[data-modal-set-start]').forEach(btn => {
    btn.addEventListener('click', async () => {
      try {
        await setJobFunnelStartStatus(String(btn.dataset.modalSetStart || ''));
        closeModals();
      } catch (e) {
        showToast(e.message || 'Ошибка', 'err');
      }
    });
  });
  bodyEl.querySelectorAll('[data-modal-hide-status]').forEach(btn => {
    btn.addEventListener('click', () => {
      closeModals();
      _tplFlowHideStatus(btn.dataset.modalHideStatus);
    });
  });

  _tplFlowSelection = { type: 'status', id: status };
  openModal('modalFlowStatus');
}

// ══════════════════════════════════════════════════
// ChatGPT Wizard — генерация промпта + импорт JSON
// ══════════════════════════════════════════════════

const _GPT_WIZARD_PROMPT = `Ты помогаешь собрать сценарий автоответчика для визуального конструктора.

JSON создаётся для task-local import — система сама создаст изолированные копии статусов и шаблонов для конкретной задачи. Не нужно избегать названий, которые уже есть в системе.

Сначала задай мне вопросы, чтобы понять задачу. Не выдавай JSON, пока не поймёшь:

1. Что я продаю или какую задачу решаю?
2. Кто пишет клиенту и в каком стиле?
3. Какой первый статус клиента?
4. Какие основные реакции клиента бывают?
5. Какие финальные исходы нужны?
6. Где нужен живой человек?
7. Где нужно остановить диалог?
8. Какие предложения, цены, ссылки, условия, промокоды или инструкции нужно знать агенту?
9. Какие обещания нельзя давать?
10. Какие статусы и этапы нужны?

После моих ответов собери JSON-схему строго в таком формате:

\`\`\`json
{
  "version": 1,
  "name": "Название сценария",
  "description": "Краткое описание",
  "import_options": {
    "scope": "job",
    "reuse_global_statuses": false,
    "reuse_global_templates": false
  },
  "sales_learning": {
    "enabled": true,
    "mode": "approved_only",
    "auto_apply_safe": false
  },
  "statuses": [
    {
      "key": "new",
      "title": "Новый",
      "type": "start",
      "ai_template": {
        "title": "Название AI-шаблона",
        "prompt": "Промпт агента для этого статуса",
        "knowledge": "Справка/контекст для агента"
      },
      "position": { "x": 100, "y": 100, "w": 260, "h": 150 }
    }
  ],
  "transitions": [
    {
      "from": "new",
      "to": "offer",
      "type": "interest",
      "condition": "Условие перехода простым языком",
      "anchors": {
        "from": { "xRatio": 0.9, "yRatio": 0.5 },
        "to": { "xRatio": 0.1, "yRatio": 0.5 }
      }
    }
  ]
}
\`\`\`

Правила:

* statuses[].key должен быть коротким латинским id без пробелов.
* statuses[].title — человекочитаемое название статуса.
* type может быть: start, offer, objection, human, reject, sale, work, neutral.
* ai_template можно поставить null, если в этом статусе автоответ не нужен.
* prompt должен быть коротким и пригодным для вставки в AI-шаблон.
* knowledge должен содержать факты о продукте/услуге, цены, ссылки, правила и ограничения.
* transitions[].condition должен описывать, когда клиент переходит в следующий статус.
* Не делай переход "сам в себя", если он не нужен.
* Для холодной рассылки обязательны статусы: new (роутер первой реакции), source_context, pain_qualification, early_objection, technical_interest, offer, objection, human, reject, sale.
* new — это роутер первой реакции после рассылки. Прямой вопрос клиента выше сценария.
* Не пиши "спарсили / нейропарсер / база" — клиент не должен знать об источнике контакта.
* Добавь ветки отказа и живого человека, если они нужны.
* Для финальных статусов можно не делать исходящие переходы.
* import_options.scope="job" — импорт создаст изолированные копии для задачи.
* sales_learning.enabled=true включает обучение продаж на уровне всей задачи, без галок в каждом шаблоне.
* sales_learning.auto_apply_safe=true — после анализа автоматически применяются рекомендации с достаточным confidence (маркеры, playbook, дописки в шаблоны, переходы). Слабые и пустые пропускаются.
* Не добавляй runtime-архитектуру и код.
* Выдай только JSON в одном code block.`;

let _gptWizardParsed = null;
let _gptWizardJobId = null;

function _tplJobDisplayName(jobId = tplJobId) {
  const j = _tplJobForSelected(jobId);
  return (j?.name || j?.title || '').trim() || (jobId ? `Задача #${jobId}` : '—');
}

function _jobSchemaUsedStatusCount(sameJob) {
  if (!sameJob) return 0;
  const keys = new Set();
  (tplJobRulesCache || []).forEach(r => {
    if (_isJobRuleEnabled(r) && r.trigger_status) keys.add(_normStatusId(r.trigger_status));
  });
  (tplJobTransitionsCache || []).forEach(t => {
    if (t.from_status) keys.add(_normStatusId(t.from_status));
    if (t.to_status) keys.add(_normStatusId(t.to_status));
  });
  return keys.size;
}

function _jobRuleSalesPlaybook(rule) {
  const j = _tplJobForSelected(tplJobId);
  if (j?.use_sales_playbook !== null && j?.use_sales_playbook !== undefined) {
    return Number(j.use_sales_playbook || 0) === 1;
  }
  if (rule && Number(rule.use_sales_playbook || 0) === 1) return true;
  const tpl = rule?.template_id ? _tplById(rule.template_id) : null;
  return Number(tpl?.use_sales_playbook || 0) === 1;
}

function _buildJobSchemaFromCanvas(jobId = tplJobId) {
  const jid = Number(jobId) || 0;
  if (!jid || Number(tplJobId) !== jid) return null;
  const j = _tplJobForSelected(jid);
  if (!j) return null;

  const jobLocalMap = Object.fromEntries(
    (tplJobStatusOptions || []).map(s => [s.value, s]).filter(([k]) => k)
  );
  const ruleByStatus = Object.fromEntries(
    (tplJobRulesCache || [])
      .filter(_isJobRuleEnabled)
      .map(r => [_normStatusId(r.trigger_status), r])
      .filter(([k]) => k)
  );

  const orderedKeys = [];
  const seen = new Set();
  (tplJobStatusOptions || []).forEach(s => {
    const k = s.value;
    if (k && !seen.has(k)) { seen.add(k); orderedKeys.push(k); }
  });
  (tplJobRulesCache || []).forEach(r => {
    if (!_isJobRuleEnabled(r)) return;
    const k = _normStatusId(r.trigger_status);
    if (k && !seen.has(k)) { seen.add(k); orderedKeys.push(k); }
  });
  (tplJobTransitionsCache || []).forEach(t => {
    [_normStatusId(t.from_status), _normStatusId(t.to_status)].forEach(k => {
      if (k && !seen.has(k)) { seen.add(k); orderedKeys.push(k); }
    });
  });

  const statuses = orderedKeys.map(key => {
    const js = jobLocalMap[key];
    const rule = ruleByStatus[key];
    const label = js?.label || _tplStatusLabel(key);
    const visual = getStatusVisualType(label, !!rule);
    const entry = {
      key,
      title: label,
      type: js?.type || visual.type || '',
      color: js?.color || visual.color || '#b8a8ff',
      description: js?.description || '',
    };
    if (rule?.template_id) {
      const tpl = _tplById(rule.template_id);
      entry.ai_template = {
        title: rule.template_title || tpl?.title || `Шаблон #${rule.template_id}`,
        prompt: (tpl?.ai_prompt || '').trim(),
        knowledge: '',
        use_sales_playbook: _jobRuleSalesPlaybook(rule),
      };
    }
    return entry;
  });

  const transitions = (tplJobTransitionsCache || []).map(t => ({
    from: t.from_status,
    to: t.to_status,
    condition: t.condition_text || '',
  }));

  const jobPbRaw = j.use_sales_playbook;
  return {
    version: 1,
    name: j.name || '',
    description: '',
    import_options: {
      scope: 'job',
      reuse_global_statuses: false,
      reuse_global_templates: false,
    },
    sales_learning: {
      enabled: jobPbRaw != null && Number(jobPbRaw || 0) === 1,
      mode: 'approved_only',
      auto_apply_safe: !!Number(j.sales_learning_auto_apply_safe || 0),
    },
    statuses,
    transitions,
  };
}

async function _fetchJobExportSchema(jobId) {
  const jid = Number(jobId) || 0;
  if (!jid) return { schema: null, fromCanvas: false };
  try {
    const schema = await j(`/api/broadcast/jobs/${jid}/export-schema`);
    if (schema?.statuses?.length) return { schema, fromCanvas: false };
  } catch (_) { /* fallback below */ }
  if (Number(tplJobId) === jid) {
    const local = _buildJobSchemaFromCanvas(jid);
    if (local?.statuses?.length) return { schema: local, fromCanvas: true };
  }
  return { schema: null, fromCanvas: false };
}

function _jobSchemaSummary(jobId = tplJobId) {
  const jid = Number(jobId) || 0;
  if (!jid) {
    return { jobId: 0, jobName: '', statusCount: 0, templateCount: 0, transitionCount: 0, hasSchema: false, source: 'empty' };
  }
  const sameJob = Number(tplJobId) === jid;
  const jobLocalCount = sameJob ? (tplJobStatusOptions || []).length : 0;
  const usedStatusCount = _jobSchemaUsedStatusCount(sameJob);
  const statusCount = Math.max(jobLocalCount, usedStatusCount);
  const transitionCount = sameJob ? (tplJobTransitionsCache || []).length : 0;
  const templateCount = sameJob
    ? (tplJobRulesCache || []).filter(r => _isJobRuleEnabled(r) && r.template_id).length
    : 0;
  const hasSchema = sameJob && (templateCount > 0 || transitionCount > 0 || jobLocalCount > 0 || usedStatusCount > 0);
  const source = !hasSchema ? 'empty' : (jobLocalCount > 0 ? 'imported' : 'manual');
  return {
    jobId: jid,
    jobName: _tplJobDisplayName(jid),
    statusCount,
    templateCount,
    transitionCount,
    hasSchema,
    source,
  };
}

function _jobSchemaSummaryLabel(sum = _jobSchemaSummary()) {
  if (!sum.jobId) return 'JSON: выбери задачу';
  if (!sum.hasSchema) return 'JSON: не настроен';
  if (sum.source === 'manual') {
    return `JSON: ${sum.templateCount} шабл. · ${sum.transitionCount} пер. · ${sum.statusCount} стат.`;
  }
  return `JSON: ${sum.statusCount} стат. · ${sum.templateCount} шабл. · ${sum.transitionCount} пер.`;
}

function _resetGptWizardForm() {
  _gptWizardParsed = null;
  const jsonEl = document.getElementById('gptWizardJson');
  if (jsonEl) jsonEl.value = '';
  const errEl = document.getElementById('gptWizardErrors');
  if (errEl) { errEl.hidden = true; errEl.textContent = ''; }
  const prevEl = document.getElementById('gptWizardPreview');
  if (prevEl) { prevEl.hidden = true; prevEl.innerHTML = ''; }
  const replaceEl = document.getElementById('gptImportReplace');
  if (replaceEl) replaceEl.checked = false;
}

function _syncGptWizardScopeUi(sum = _jobSchemaSummary(_gptWizardJobId || tplJobId)) {
  const scopeEl = document.getElementById('gptWizardScopeLine');
  if (scopeEl) {
    if (!sum.jobId) {
      scopeEl.textContent = 'Задача: не выбрана';
      scopeEl.className = 'gpt-wizard-scope-line empty';
    } else if (!sum.hasSchema) {
      scopeEl.textContent = `Задача «${sum.jobName}» (#${sum.jobId}) · схема не настроена`;
      scopeEl.className = 'gpt-wizard-scope-line empty';
    } else if (sum.source === 'manual') {
      scopeEl.textContent = `Задача «${sum.jobName}» (#${sum.jobId}) · настроено на холсте: ${sum.templateCount} шабл., ${sum.transitionCount} пер., ${sum.statusCount} стат.`;
      scopeEl.className = 'gpt-wizard-scope-line';
    } else {
      scopeEl.textContent = `Задача «${sum.jobName}» (#${sum.jobId}) · ${sum.statusCount} статусов, ${sum.templateCount} шаблонов, ${sum.transitionCount} переходов`;
      scopeEl.className = 'gpt-wizard-scope-line';
    }
  }
  const hintEl = document.getElementById('gptWizardImportHint');
  if (hintEl) {
    hintEl.textContent = !sum.hasSchema
      ? 'Схема пустая — собери на холсте или вставь JSON от ChatGPT/Gemini.'
      : (sum.source === 'manual'
        ? 'Ниже — текущая конфигурация задачи с холста (шаблоны, переходы). Можно экспортировать в Gemini и импортировать обратно.'
        : 'Ниже — JSON-схема этой задачи (импорт). Можно править и импортировать обратно.');
  }
  const jsonEl = document.getElementById('gptWizardJson');
  if (jsonEl) {
    jsonEl.placeholder = !sum.hasSchema
      ? `JSON для «${sum.jobName}» — пусто`
      : (sum.source === 'manual'
        ? `Конфиг «${sum.jobName}» с холста — «Экспорт текущей схемы»`
        : `JSON задачи «${sum.jobName}» — «Экспорт текущей схемы»`);
  }
}

async function _gptWizardLoadJobSchema() {
  const jid = Number(_gptWizardJobId || tplJobId) || 0;
  if (!jid) return;
  const sum = _jobSchemaSummary(jid);
  _syncGptWizardScopeUi(sum);
  const jsonEl = document.getElementById('gptWizardJson');
  if (!sum.hasSchema) {
    if (jsonEl) jsonEl.value = '';
    _gptWizardParsed = null;
    return;
  }
  if ((jsonEl?.value || '').trim()) return;
  try {
    const { schema } = await _fetchJobExportSchema(jid);
    if (Number(_gptWizardJobId || tplJobId) !== jid) return;
    if (!schema?.statuses?.length) {
      if (jsonEl) jsonEl.value = '';
      return;
    }
    if (jsonEl) jsonEl.value = JSON.stringify(schema, null, 2);
    _gptWizardParsed = null;
  } catch (_) { /* export optional on open */ }
}

async function _onTplJobScopeChange() {
  _gptWizardJobId = tplJobId;
  _resetGptWizardForm();
  _syncGptWizardScopeUi();
  const modal = document.getElementById('modalChatGptWizard');
  if (modal && !modal.hidden && tplJobId) {
    await _gptWizardLoadJobSchema();
  }
}

function _openGptWizard() {
  if (!tplJobId) { showToast('Сначала выбери задачу', 'err'); return; }
  _gptWizardJobId = tplJobId;
  _resetGptWizardForm();
  const promptEl = document.getElementById('gptWizardPrompt');
  if (promptEl) promptEl.value = _GPT_WIZARD_PROMPT;
  _syncGptWizardScopeUi();
  _gptWizardSwitchTab('prompt');
  openModal('modalChatGptWizard');
  _gptWizardBindButtons();
  _gptWizardLoadJobSchema().catch(() => {});
}

async function _copyTextToClipboard(text, okMessage = 'Скопировано в буфер обмена') {
  const s = String(text || '');
  if (!s) return false;
  if (navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(s);
      showToast(okMessage);
      return true;
    } catch (_) { /* fallback below */ }
  }
  const ta = document.createElement('textarea');
  ta.value = s;
  ta.setAttribute('readonly', '');
  ta.style.position = 'fixed';
  ta.style.left = '-9999px';
  document.body.appendChild(ta);
  ta.select();
  let ok = false;
  try { ok = document.execCommand('copy'); } catch (_) {}
  document.body.removeChild(ta);
  if (ok) showToast(okMessage);
  else showToast('Не удалось скопировать — выдели текст в поле и Ctrl+C', 'warn');
  return ok;
}

function _gptWizardBindButtons() {
  const modal = document.getElementById('modalChatGptWizard');
  if (!modal) return;
  modal.querySelectorAll('[data-gpt-tab]').forEach(btn => {
    btn.onclick = () => _gptWizardSwitchTab(btn.dataset.gptTab);
  });
  const copyBtn = document.getElementById('gptCopyPrompt');
  if (copyBtn) copyBtn.onclick = () => {
    const el = document.getElementById('gptWizardPrompt');
    if (el) _copyTextToClipboard(el.value, 'Промпт скопирован в буфер обмена');
  };
  const valBtn = document.getElementById('gptValidateJson');
  if (valBtn) valBtn.onclick = () => {
    try { _gptWizardValidate(); } catch (e) { _gptWizardErr('Ошибка валидации: ' + (e.message || e)); }
  };
  const prevBtn = document.getElementById('gptPreviewJson');
  if (prevBtn) prevBtn.onclick = () => {
    try {
      if (!_gptWizardParsed) _gptWizardValidate();
      if (_gptWizardParsed) _gptWizardShowPreview();
    } catch (e) { _gptWizardErr('Ошибка предпросмотра: ' + (e.message || e)); }
  };
  const impBtn = document.getElementById('gptImportJson');
  if (impBtn) impBtn.onclick = () => {
    try {
      if (!_gptWizardParsed) _gptWizardValidate();
      if (!_gptWizardParsed) return;
      _gptWizardImport().catch(e => _gptWizardErr('Ошибка импорта: ' + (e.message || e)));
    } catch (e) { _gptWizardErr('Ошибка: ' + (e.message || e)); }
  };
  const expBtn = document.getElementById('gptExportJson');
  if (expBtn) expBtn.onclick = () => {
    _gptWizardExport().catch(e => showToast(e.message || 'Ошибка экспорта', 'err'));
  };
}

function _gptWizardSwitchTab(tab) {
  const modal = document.getElementById('modalChatGptWizard');
  if (!modal) return;
  modal.querySelectorAll('.gpt-wizard-tab').forEach(b => b.classList.toggle('active', b.dataset.gptTab === tab));
  modal.querySelectorAll('.gpt-wizard-pane').forEach(p => p.hidden = p.dataset.gptPane !== tab);
  if (tab === 'import') {
    if (Number(_gptWizardJobId) !== Number(tplJobId)) {
      _gptWizardJobId = tplJobId;
      _resetGptWizardForm();
    }
    _syncGptWizardScopeUi();
    const raw = (document.getElementById('gptWizardJson')?.value || '').trim();
    if (!raw) _gptWizardLoadJobSchema().catch(() => {});
  }
}

function _gptWizardValidate() {
  const errEl = document.getElementById('gptWizardErrors');
  const prevEl = document.getElementById('gptWizardPreview');
  if (errEl) { errEl.hidden = true; errEl.textContent = ''; }
  if (prevEl) { prevEl.hidden = true; prevEl.innerHTML = ''; }
  _gptWizardParsed = null;

  let raw = (document.getElementById('gptWizardJson')?.value || '').trim();
  // ChatGPT часто оборачивает в ```json ... ``` или выдаёт текст до/после JSON
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fenced) raw = fenced[1].trim();
  if (!raw) {
    // Может пользователь вставил не в то поле — попробуем textarea
    const alt = document.querySelector('#modalChatGptWizard textarea:not([readonly])');
    raw = (alt?.value || '').trim();
    const fenced2 = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
    if (fenced2) raw = fenced2[1].trim();
  }
  if (!raw) { _gptWizardErr('Вставь JSON-схему от ChatGPT'); return; }

  let data;
  try { data = JSON.parse(raw); } catch (e) { _gptWizardErr(`Невалидный JSON: ${e.message}`); return; }

  const errs = [];
  if (typeof data !== 'object' || data === null) { _gptWizardErr('JSON должен быть объектом {...}'); return; }
  if (data.version !== 1) errs.push('version должен быть 1');
  if (!Array.isArray(data.statuses) || !data.statuses.length) { _gptWizardErr('statuses должен быть непустым массивом'); return; }
  if (!Array.isArray(data.transitions)) errs.push('transitions должен быть массивом (может быть пустым)');

  const statusKeys = new Set();
  data.statuses.forEach((s, i) => {
    if (!s.key || typeof s.key !== 'string') errs.push(`statuses[${i}]: отсутствует key`);
    else if (statusKeys.has(s.key)) errs.push(`statuses[${i}]: дублирующийся key "${s.key}"`);
    else statusKeys.add(s.key);
    if (!s.title || typeof s.title !== 'string') errs.push(`statuses[${i}]: отсутствует title`);
    if (!s.type || typeof s.type !== 'string') errs.push(`statuses[${i}]: отсутствует type`);
    if (s.ai_template && typeof s.ai_template === 'object') {
      if (!s.ai_template.title) errs.push(`statuses[${i}].ai_template: отсутствует title`);
      if (!s.ai_template.prompt) errs.push(`statuses[${i}].ai_template: отсутствует prompt`);
    }
  });

  const transitions = Array.isArray(data.transitions) ? data.transitions : [];
  const trSeen = new Set();
  transitions.forEach((tr, i) => {
    if (!tr.from || typeof tr.from !== 'string') errs.push(`transitions[${i}]: отсутствует from`);
    else if (!statusKeys.has(tr.from)) errs.push(`transitions[${i}]: from "${tr.from}" не найден в statuses`);
    if (!tr.to || typeof tr.to !== 'string') errs.push(`transitions[${i}]: отсутствует to`);
    else if (!statusKeys.has(tr.to)) errs.push(`transitions[${i}]: to "${tr.to}" не найден в statuses`);
    if (tr.from && tr.to && tr.from === tr.to) errs.push(`transitions[${i}]: переход сам в себя (${tr.from})`);
    const sig = `${tr.from}>${tr.to}>${(tr.condition || '').trim().slice(0, 50)}`;
    if (trSeen.has(sig)) errs.push(`transitions[${i}]: возможный дубль (${tr.from} → ${tr.to})`);
    else trSeen.add(sig);
  });

  if (errs.length) { _gptWizardErr(errs.join('\n')); return; }

  _gptWizardParsed = data;
  showToast('JSON валиден');
  _gptWizardShowPreview();
}

function _gptWizardErr(msg) {
  const el = document.getElementById('gptWizardErrors');
  if (el) { el.textContent = msg; el.hidden = false; }
}

function _gptWizardShowPreview() {
  const data = _gptWizardParsed;
  if (!data) return;
  const prevEl = document.getElementById('gptWizardPreview');
  if (!prevEl) return;

  const existingStatuses = new Set((leadStatusOptions || []).map(s => s.value?.toLowerCase()));
  const existingTpls = new Set((tplCache || []).map(p => (p.title || '').toLowerCase()));

  let html = `<h4>Сценарий: ${esc(data.name || '—')}</h4>`;
  if (data.description) html += `<p>${esc(data.description)}</p>`;

  const opts = data.import_options || {};
  html += `<div class="gpt-preview-scope"><strong>Scope:</strong> ${opts.scope === 'global' ? 'глобальный' : 'job-local (по умолчанию)'}</div>`;

  html += `<h4>Статусы (${data.statuses.length})</h4><ul>`;
  let globalReused = 0;
  data.statuses.forEach(s => {
    const keyExists = existingStatuses.has((s.key || '').toLowerCase());
    const note = keyExists
      ? '<span class="warn">— key есть глобально, будет создана job-local копия</span>'
      : '<span class="ok">— будет создан (job-local + global key)</span>';
    if (keyExists) globalReused++;
    html += `<li><strong>${esc(s.title)}</strong> <code>${esc(s.key)}</code> [${esc(s.type || '')}] ${note}</li>`;
  });
  html += '</ul>';

  const withTpl = data.statuses.filter(s => s.ai_template);
  html += `<h4>AI-шаблоны (${withTpl.length}) — все job-local</h4><ul>`;
  withTpl.forEach(s => {
    const t = s.ai_template;
    const dup = existingTpls.has((t.title || '').toLowerCase());
    const note = dup
      ? '<span class="warn">— название есть глобально, job-local копия будет создана</span>'
      : '<span class="ok">— будет создан (job-local)</span>';
    html += `<li><strong>${esc(t.title)}</strong> → ${esc(s.title)} ${note}</li>`;
  });
  html += '</ul>';

  const transitions = data.transitions || [];
  html += `<h4>Переходы (${transitions.length}) — все job-local</h4><ul>`;
  transitions.forEach(tr => {
    const fromTitle = data.statuses.find(s => s.key === tr.from)?.title || tr.from;
    const toTitle = data.statuses.find(s => s.key === tr.to)?.title || tr.to;
    html += `<li>${esc(fromTitle)} → ${esc(toTitle)} <span class="hint">${esc(tr.condition || '—')}</span></li>`;
  });
  html += '</ul>';

  const warnings = [];
  const statusKeys = new Set(data.statuses.map(s => s.key));
  const hasNew = statusKeys.has('new');
  if (hasNew && !statusKeys.has('source_context')) warnings.push('Нет статуса source_context (полезен для холодной рассылки)');
  if (hasNew && !statusKeys.has('early_objection')) warnings.push('Нет статуса early_objection (рекомендуется для холодной рассылки)');
  if (!statusKeys.has('reject')) warnings.push('Нет ветки отказа (reject)');
  if (!statusKeys.has('human')) warnings.push('Нет ветки живого человека (human)');

  const nonFinal = data.statuses.filter(s => !['reject', 'human', 'sale', 'bot', 'not_from_broadcast'].includes(s.key) && s.type !== 'final');
  const hasOutgoing = new Set(transitions.map(t => t.from));
  nonFinal.forEach(s => {
    if (!hasOutgoing.has(s.key)) warnings.push(`Статус «${s.title}» (${s.key}) не имеет исходящих переходов`);
  });

  if (warnings.length) {
    html += `<h4>⚠ Предупреждения (${warnings.length})</h4><ul class="gpt-preview-warnings">`;
    warnings.forEach(w => html += `<li>${esc(w)}</li>`);
    html += '</ul>';
  }

  const sl = data.sales_learning;
  if (sl) {
    html += `<h4>Обучение продаж</h4>`;
    html += `<p>enabled: ${sl.enabled ? 'да' : 'нет'}, auto_apply_safe: ${sl.auto_apply_safe ? 'да' : 'нет'}</p>`;
  }

  html += `<div class="gpt-preview-summary">`;
  html += `<strong>Итого:</strong> ${data.statuses.length} job-local статусов, ${withTpl.length} job-local шаблонов, ${transitions.length} переходов`;
  html += `</div>`;

  prevEl.innerHTML = html;
  prevEl.hidden = false;
}

async function _gptWizardImport() {
  const data = _gptWizardParsed;
  if (!data || !tplJobId) return;
  if (Number(_gptWizardJobId) !== Number(tplJobId)) {
    _gptWizardErr('Задача изменилась — закрой окно и открой ChatGPT wizard заново');
    return;
  }

  const replaceMode = document.getElementById('gptImportReplace')?.checked || false;
  const confirmMsg = replaceMode
    ? 'Заменить схему текущей задачи? Старые job-local статусы, шаблоны и переходы будут удалены.'
    : 'Импортировать сценарий в задачу? Будут созданы job-local статусы, шаблоны и переходы.';
  if (!confirm(confirmMsg)) return;

  const importBtn = document.getElementById('gptImportJson');
  if (importBtn) { importBtn.disabled = true; importBtn.textContent = 'Импорт...'; }

  try {
    if (replaceMode) {
      tplJobRulesCache = [];
      tplJobTransitionsCache = [];
      tplJobStatusOptions = [];
      _tplFlowHistory = { jobId: tplJobId, past: [], future: [] };
      _tplFlowSelection = null;
      _tplFlowReport = null;
      _saveTplFlowCanvasState(_normTplFlowCanvasState({}), tplJobId);
    }
    const payload = {
      ...data,
      replace: replaceMode,
    };
    const r = await j(`/api/broadcast/jobs/${tplJobId}/import-schema`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });

    if (r?.summary) {
      const positions = replaceMode ? {} : _loadTplFlowPositions();
      (data.statuses || []).forEach(s => {
        const key = (s.key || '').toLowerCase();
        if (!key) return;
        const pos = s.position || {};
        positions[key] = _normTplFlowNode({
          x: Number(pos.x) || 40, y: Number(pos.y) || 40,
          w: Number(pos.w) || TPL_FLOW_NODE_DEF_W, h: Number(pos.h) || 200,
        });
      });
      _saveTplFlowPositions(positions);
    }

    await _reloadTplJobRulesOnly();
    await _reloadTplJobTransitions();
    await _reloadTplJobStatuses();
    await loadTemplates({ scope: 'job', jobId: tplJobId });
    await loadLeadStatuses();

    closeModals();
    renderTplJobHeader();
    const sum = r?.summary || {};
    showToast(`Импорт в задачу #${tplJobId}: ${sum.statuses_created || 0} статусов, ${sum.templates_created || 0} шаблонов, ${sum.transitions_created || 0} переходов`);
    _gptWizardJobId = null;
  } catch (err) {
    showToast(err.message || 'Ошибка импорта', 'err');
  } finally {
    if (importBtn) { importBtn.disabled = false; importBtn.textContent = 'Импортировать в задачу'; }
  }
}

async function _gptWizardExport() {
  if (!tplJobId) { showToast('Сначала выбери задачу', 'err'); return; }
  _gptWizardJobId = tplJobId;
  try {
    const { schema, fromCanvas } = await _fetchJobExportSchema(tplJobId);
    const jsonEl = document.getElementById('gptWizardJson');
    if (!schema?.statuses?.length) {
      if (jsonEl) jsonEl.value = '';
      _gptWizardParsed = null;
      _syncGptWizardScopeUi();
      _gptWizardSwitchTab('import');
      showToast('Схема пуста — назначь шаблоны на холсте или импортируй JSON', 'warn');
      return;
    }
    const jsonStr = JSON.stringify(schema, null, 2);
    if (jsonEl) {
      jsonEl.value = jsonStr;
      jsonEl.focus();
      jsonEl.select();
    }
    _gptWizardParsed = null;
    _syncGptWizardScopeUi();
    _gptWizardSwitchTab('import');
    const trN = (schema.transitions || []).length;
    const src = fromCanvas ? ' (с холста)' : '';
    await _copyTextToClipboard(
      jsonStr,
      `JSON скопирован в буфер: ${schema.statuses.length} стат., ${trN} пер.${src}`
    );
  } catch (err) {
    showToast(err.message || 'Ошибка экспорта', 'err');
  }
}

// ══════════════════════════════════════════════════

function _cssEscape(value) {
  if (window.CSS?.escape) return CSS.escape(value);
  return String(value).replace(/["\\]/g, '\\$&');
}

const TPL_FLOW_CANVAS_MIGRATE_KEY = 'ai_flow_canvas_migrated:v265';

function _tplFlowMigrateCanvasClutter() {
  if (!tplJobId) return;
  const flagKey = `${TPL_FLOW_CANVAS_MIGRATE_KEY}:${tplJobId}`;
  try {
    if (localStorage.getItem(flagKey) === '1') return;
  } catch (_) {
    return;
  }
  const canvas = _loadTplFlowCanvasState();
  const core = new Set();
  const addCore = id => {
    const st = _normStatusId(id);
    if (st) core.add(st);
  };
  addCore(_jobFunnelStartStatus());
  (tplJobTransitionsCache || []).forEach(t => {
    addCore(t.from_status);
    addCore(t.to_status);
  });
  (tplJobRulesCache || []).filter(_isJobRuleEnabled).forEach(r => addCore(r.trigger_status));
  const hidden = new Set((canvas.hiddenStatusIds || []).map(_normStatusId));
  let changed = false;
  for (const id of Object.keys(canvas.nodeLayouts || {})) {
    const st = _normStatusId(id);
    if (!st || core.has(st) || hidden.has(st)) continue;
    hidden.add(st);
    changed = true;
  }
  if (changed) {
    canvas.hiddenStatusIds = [...hidden];
    _saveTplFlowCanvasState(canvas);
  }
  try {
    localStorage.setItem(flagKey, '1');
  } catch (_) {}
}

/** Статусы/переходы с активными правилами не должны висеть в «скрытых» после правок схемы в БД. */
function _tplFlowSyncHiddenWithSchema() {
  if (!tplJobId) return;
  const canvas = _loadTplFlowCanvasState();
  const mustShow = new Set();
  const add = id => {
    const st = _normStatusId(id);
    if (st) mustShow.add(st);
  };
  add(_jobFunnelStartStatus());
  (tplJobRulesCache || []).filter(_isJobRuleEnabled).forEach(r => add(r.trigger_status));
  (tplJobTransitionsCache || []).forEach(t => {
    add(t.from_status);
    add(t.to_status);
  });
  const prevHidden = (canvas.hiddenStatusIds || []).map(_normStatusId);
  const nextHidden = prevHidden.filter(id => !mustShow.has(id));
  let changed = nextHidden.length !== prevHidden.length;
  if (changed) canvas.hiddenStatusIds = nextHidden;

  const hiddenTr = new Set((canvas.hiddenTransitionIds || []).map(Number));
  const prevTrN = hiddenTr.size;
  (tplJobTransitionsCache || []).forEach(t => {
    const fr = _normStatusId(t.from_status);
    const to = _normStatusId(t.to_status);
    if (mustShow.has(fr) || mustShow.has(to)) hiddenTr.delete(Number(t.id));
  });
  if (hiddenTr.size !== prevTrN) {
    canvas.hiddenTransitionIds = [...hiddenTr];
    changed = true;
  }
  if (changed) _saveTplFlowCanvasState(canvas);
}

function _tplFlowStatuses() {
  const ids = new Set();
  const ordered = [];
  const add = id => {
    const st = _normStatusId(id);
    if (!st || ids.has(st)) return;
    ids.add(st);
    ordered.push(st);
  };
  add(_jobFunnelStartStatus());
  (tplJobTransitionsCache || []).forEach(t => {
    add(t.from_status);
    add(t.to_status);
  });
  (tplJobRulesCache || []).filter(_isJobRuleEnabled).forEach(r => add(r.trigger_status));
  Object.keys(_loadTplFlowCanvasState().nodeLayouts || {}).forEach(add);
  return ordered;
}

function _tplFlowCatalogStatusIds() {
  return (_tplFlowStatusCatalog() || [])
    .map(s => _normStatusId(s.value))
    .filter(Boolean);
}

function _tplFlowDagreLayout(statusIds, transitions) {
  // Слоёная раскладка через dagre (как в Mermaid/Node-RED): ранги по направлению
  // потока, минимизация пересечений, аккуратные отступы. Возвращает top-left x/y
  // или null, если dagre недоступен → тогда падаем на свой tidy-tree.
  try {
    if (typeof dagre === 'undefined' || !dagre.graphlib || !dagre.layout) return null;
    const ids = (statusIds || []).slice();
    if (!ids.length) return null;
    const idSet = new Set(ids);
    const saved = _loadTplFlowPositions();
    const g = new dagre.graphlib.Graph({ multigraph: true });
    g.setGraph({ rankdir: 'TB', nodesep: 55, ranksep: 95, edgesep: 16, marginx: 40, marginy: 40 });
    g.setDefaultEdgeLabel(() => ({}));
    ids.forEach(id => {
      const p = saved[id] || {};
      const w = Math.max(TPL_FLOW_NODE_MIN_W, Number(p.w) || TPL_FLOW_NODE_DEF_W);
      const h = Math.max(TPL_FLOW_NODE_MIN_H, Number(p.h) || TPL_FLOW_NODE_DEF_H);
      g.setNode(id, { width: w, height: h });
    });
    let ei = 0;
    (transitions || []).forEach(t => {
      const f = _normStatusId(t.from_status), to = _normStatusId(t.to_status);
      if (!idSet.has(f) || !idSet.has(to) || f === to) return;
      g.setEdge(f, to, {}, 'e' + (ei++));
    });
    dagre.layout(g);
    const out = {};
    ids.forEach(id => {
      const n = g.node(id);
      if (!n || !Number.isFinite(n.x) || !Number.isFinite(n.y)) return;
      out[id] = { x: Math.round(n.x - n.width / 2), y: Math.round(n.y - n.height / 2) };
    });
    return Object.keys(out).length ? out : null;
  } catch (e) {
    return null;
  }
}

function _tplFlowAutoPositions(statusIds, transitions) {
  const out = {};
  const ids = (statusIds || []).slice();
  if (!ids.length) return out;

  // Сначала — dagre (слоёная раскладка как в готовых редакторах). Если его нет —
  // свой tidy-tree ниже как фолбэк.
  const dag = _tplFlowDagreLayout(ids, transitions);
  if (dag) return dag;

  const start = _jobFunnelStartStatus();
  const idSet = new Set(ids);
  const adj = new Map();
  const indeg = new Map(ids.map(id => [id, 0]));
  (transitions || []).forEach(t => {
    const f = _normStatusId(t.from_status), to = _normStatusId(t.to_status);
    if (!f || !to || f === to || !idSet.has(f) || !idSet.has(to)) return;
    if (!adj.has(f)) adj.set(f, []);
    if (!adj.get(f).includes(to)) adj.get(f).push(to);
    indeg.set(to, (indeg.get(to) || 0) + 1);
  });

  // Корни: стартовый статус + узлы без входящих переходов.
  const roots = [];
  const seen = new Set();
  if (start && idSet.has(start)) { roots.push(start); seen.add(start); }
  ids.forEach(id => { if (!seen.has(id) && (indeg.get(id) || 0) === 0) { roots.push(id); seen.add(id); } });
  if (!roots.length) roots.push(ids[0]);

  // Остовное дерево (BFS): у каждого узла ровно один родитель → дерево, а не каша.
  // Лишние переходы (cross-edges) остаются нитями, но не влияют на раскладку.
  const children = new Map(ids.map(id => [id, []]));
  const depth = new Map();
  const visited = new Set();
  const q = [];
  roots.forEach(r => { if (!visited.has(r)) { visited.add(r); depth.set(r, 0); q.push(r); } });
  let guard = 0;
  const lim = ids.length * ids.length + ids.length + 16;
  while (q.length && guard++ < lim) {
    const cur = q.shift();
    (adj.get(cur) || []).forEach(to => {
      if (!visited.has(to)) {
        visited.add(to);
        depth.set(to, (depth.get(cur) || 0) + 1);
        children.get(cur).push(to);
        q.push(to);
      }
    });
  }
  const orphans = ids.filter(id => !visited.has(id));
  let maxDepth = 0;
  depth.forEach(v => { if (v > maxDepth) maxDepth = v; });

  // Tidy-tree: листья занимают последовательные слоты, родитель центрируется
  // над своими детьми → корень сверху по центру, ветки расходятся вниз пирамидой.
  const slot = new Map();
  let nextLeaf = 0;
  const place = (node) => {
    const kids = children.get(node) || [];
    if (!kids.length) { slot.set(node, nextLeaf); nextLeaf += 1; return; }
    kids.forEach(place);
    const f = slot.get(kids[0]);
    const l = slot.get(kids[kids.length - 1]);
    slot.set(node, (f + l) / 2);
  };
  roots.forEach((r, i) => { place(r); if (i < roots.length - 1) nextLeaf += 0.6; });

  // Несвязанные/недостижимые — отдельный нижний ярус.
  const orphanDepth = orphans.length ? maxDepth + 1 : maxDepth;
  orphans.forEach(id => { slot.set(id, nextLeaf); depth.set(id, orphanDepth); nextLeaf += 1; });
  const totalDepth = orphans.length ? maxDepth + 1 : maxDepth;

  const slots = [...slot.values()];
  const minS = Math.min(...slots);
  const maxS = Math.max(...slots);
  const spanS = Math.max(0.0001, maxS - minS);

  // Размеры подобраны так, чтобы дерево помещалось в фикс-холст 1320×760
  // (fit не сжимает → нет наложений).
  const FW = 1320, FH = 760, pad = 40;
  const nodeW = TPL_FLOW_NODE_DEF_W;
  const saved = _loadTplFlowPositions();
  const rowH = Math.max(TPL_FLOW_NODE_DEF_H, ...ids.map(id => Number(saved[id]?.h) || 0));
  const availW = FW - pad * 2;
  const availH = FH - pad * 2;
  const colStep = Math.min(nodeW + 70, (availW - nodeW) / spanS);
  const rowStep = (totalDepth > 0) ? Math.min(rowH + 120, (availH - rowH) / totalDepth) : 0;
  const contentW = spanS * colStep;
  const offX = pad + Math.max(0, (availW - contentW - nodeW) / 2) - minS * colStep;

  ids.forEach(id => {
    const s = slot.get(id);
    if (s === undefined) return;
    const d = depth.get(id) || 0;
    out[id] = { x: Math.round(offX + s * colStep), y: Math.round(pad + d * rowStep) };
  });
  return out;
}

function _ensureTplFlowPositions(statusIds, transitions, force = false) {
  const savedAll = _loadTplFlowPositions();
  // Не выкидываем layouts скрытых статусов: правим только переданные (видимые) id.
  const next = { ...savedAll };
  const auto = _tplFlowAutoPositions(statusIds, transitions);
  statusIds.forEach(id => {
    const cur = force ? null : savedAll[id];
    const has = cur && Number.isFinite(Number(cur.x)) && Number.isFinite(Number(cur.y));
    // При автосортировке (force) координаты сбрасываются, размеры карточек сохраняются.
    next[id] = _normTplFlowNode(has ? cur : { ...(auto[id] || { x: 40, y: 40 }), w: savedAll[id]?.w, h: savedAll[id]?.h });
  });
  _saveTplFlowPositions(next);
  return next;
}

// Ленивая загрузка ELK (Eclipse Layout Kernel) — только при первом «Автосортировка».
let _elkLoadPromise = null;
function _ensureElkLoaded() {
  if (typeof ELK !== 'undefined') return Promise.resolve();
  if (_elkLoadPromise) return _elkLoadPromise;
  _elkLoadPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = '/static/vendor/elk.bundled.js?v=1';
    s.async = true;
    s.onload = () => resolve();
    s.onerror = () => { _elkLoadPromise = null; reject(new Error('ELK load failed')); };
    document.head.appendChild(s);
  });
  return _elkLoadPromise;
}

// Раскладка холста через ELK (layered, сверху-вниз): минимизация пересечений,
// корректная работа с циклами, аккуратные ярусы — как в pro flow-редакторах.
// Возвращает {statusId: {x, y}}. Дубли-рёбра схлопываются в одну пару для раскладки.
async function _tplFlowElkLayout(statusIds, transitions) {
  await _ensureElkLoaded();
  if (typeof ELK === 'undefined') throw new Error('ELK unavailable');
  const elk = new ELK();
  const saved = _loadTplFlowPositions();
  const idSet = new Set(statusIds);
  const children = statusIds.map(id => {
    const p = saved[id] || {};
    return {
      id,
      width: Math.round(Number(p.w) || TPL_FLOW_NODE_DEF_W),
      height: Math.round(Number(p.h) || TPL_FLOW_NODE_DEF_H),
    };
  });
  const seen = new Set();
  const edges = [];
  // Скелет (остовное дерево) задаёт ФОРМУ дерева: широкая пирамида, а не длинная
  // вертикальная цепочка, которую ELK строит из плотного графа со всеми 55 связями.
  // Перекрёстные связи всё равно рисуются как фоновые нити поверх раскладки.
  const treeEdges = _tplFlowTreeEdgeSet(transitions);
  treeEdges.forEach(key => {
    const [f, to] = key.split('>');
    if (!idSet.has(f) || !idSet.has(to) || seen.has(key)) return;
    seen.add(key);
    edges.push({ id: 'e' + edges.length, sources: [f], targets: [to] });
  });
  // Если скелет почему-то пуст — фолбэк на уникальные пары.
  if (!edges.length) {
    (transitions || []).forEach(t => {
      const f = _normStatusId(t.from_status), to = _normStatusId(t.to_status);
      if (f === to || !idSet.has(f) || !idSet.has(to)) return;
      const key = f + '>' + to;
      if (seen.has(key)) return;
      seen.add(key);
      edges.push({ id: 'e' + edges.length, sources: [f], targets: [to] });
    });
  }
  const graph = {
    id: 'root',
    layoutOptions: {
      'elk.algorithm': 'layered',
      'elk.direction': 'DOWN',
      'elk.layered.spacing.nodeNodeBetweenLayers': '70',
      'elk.spacing.nodeNode': '45',
      'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
      'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST',
      'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
    },
    children,
    edges,
  };
  const res = await elk.layout(graph);
  const out = {};
  const pad = 44;
  (res.children || []).forEach(c => {
    out[c.id] = { x: Math.round((c.x || 0) + pad), y: Math.round((c.y || 0) + pad) };
  });
  return out;
}

function _fitTplFlowIntoFixedCanvas(positions, statusIds, canvasW, canvasH) {
  const pad = 26;
  const ids = (statusIds || []).filter(id => positions[id]);
  if (!ids.length) return { positions, changed: false };
  const rects = ids.map(id => {
    const p = _normTplFlowNode(positions[id]);
    const h = Number(p.h || 0) || 200;
    return { id, x: Number(p.x || 0), y: Number(p.y || 0), w: Number(p.w || TPL_FLOW_NODE_DEF_W), h };
  });
  const minX = Math.min(...rects.map(r => r.x));
  const minY = Math.min(...rects.map(r => r.y));
  const maxX = Math.max(...rects.map(r => r.x + r.w));
  const maxY = Math.max(...rects.map(r => r.y + r.h));
  const spanW = Math.max(1, maxX - minX);
  const spanH = Math.max(1, maxY - minY);
  const availW = Math.max(320, canvasW - pad * 2);
  const availH = Math.max(260, canvasH - pad * 2);
  const scale = Math.min(1, availW / spanW, availH / spanH);
  const next = { ...positions };
  let changed = false;
  rects.forEach(r => {
    const cur = next[r.id] || {};
    const x = pad + (r.x - minX) * scale;
    const y = pad + (r.y - minY) * scale;
    const w = Math.min(TPL_FLOW_NODE_MAX_W, Math.max(TPL_FLOW_NODE_MIN_W, r.w * scale));
    const h = Math.min(TPL_FLOW_NODE_MAX_H, Math.max(TPL_FLOW_NODE_MIN_H, r.h * scale));
    const nx = Math.round(x);
    const ny = Math.round(y);
    const nw = Math.round(w);
    const nh = Math.round(h);
    if (Math.abs((Number(cur.x) || 0) - nx) > 2 || Math.abs((Number(cur.y) || 0) - ny) > 2
      || Math.abs((Number(cur.w) || TPL_FLOW_NODE_DEF_W) - nw) > 2 || Math.abs((Number(cur.h) || 200) - nh) > 2) {
      changed = true;
    }
    next[r.id] = { ...cur, x: nx, y: ny, w: nw, h: nh };
  });
  return { positions: next, changed };
}

function _placeTplFlowStatusNear(fromStatus, toStatus) {
  const from = _normStatusId(fromStatus);
  const to = _normStatusId(toStatus);
  if (!from || !to) return;
  const pos = _loadTplFlowPositions();
  if (pos[to]) return;
  const base = pos[from] || { x: 40, y: 40 };
  pos[to] = { x: Number(base.x || 40) + 280, y: Number(base.y || 40) + 110 };
  _saveTplFlowPositions(pos);
}

function _shortTransitionText(text) {
  const s = _transitionCondition({ condition_text: text });
  if (!s) return 'условие не задано';
  return s.length > 30 ? `${s.slice(0, 30)}…` : s;
}

const TPL_FLOW_TYPE_PRESETS = {
  interest: { type: 'interest', label: 'интерес', color: '#7ee787' },
  objection: { type: 'objection', label: 'сомнение', color: '#ffc857' },
  human: { type: 'human', label: 'человек', color: '#57b7ff' },
  reject: { type: 'reject', label: 'отказ', color: '#ff5b6b' },
  sale: { type: 'sale', label: 'продажа', color: '#35e58f' },
  neutral: { type: 'neutral', label: 'переход', color: '#b8a8ff' },
};

function getTransitionVisualType(conditionText, toStatusName = '') {
  const to = String(toStatusName || '').toLowerCase();
  const cond = String(conditionText || '').toLowerCase();
  const inTo = (...needles) => needles.some(x => to.includes(x));
  const inCond = (...needles) => needles.some(x => cond.includes(x));
  // Приоритет 1: целевой статус, приоритет 2: текст условия.
  if (inTo('продаж', 'sale')) return TPL_FLOW_TYPE_PRESETS.sale;
  if (inTo('отказ', 'reject')) return TPL_FLOW_TYPE_PRESETS.reject;
  if (inTo('человек', 'human', 'оператор')) return TPL_FLOW_TYPE_PRESETS.human;
  if (inTo('возраж', 'objection')) return TPL_FLOW_TYPE_PRESETS.objection;
  if (inTo('предлож', 'interest')) return TPL_FLOW_TYPE_PRESETS.interest;
  if (inCond('купил', 'оплат', 'продаж')) return TPL_FLOW_TYPE_PRESETS.sale;
  if (inCond('отказ', 'не интересно', 'неинтересно')) return TPL_FLOW_TYPE_PRESETS.reject;
  if (inCond('человек', 'оператор', 'менеджер')) return TPL_FLOW_TYPE_PRESETS.human;
  if (inCond('возраж', 'сомнен', 'дорого')) return TPL_FLOW_TYPE_PRESETS.objection;
  if (inCond('интерес', 'хочу', 'скинь', 'давай')) return TPL_FLOW_TYPE_PRESETS.interest;
  return TPL_FLOW_TYPE_PRESETS.neutral;
}

function _tplFlowVisualForTransition(t, canvasState = null) {
  // Ручной тип из localStorage важнее автоопределения.
  const st = canvasState || _loadTplFlowCanvasState();
  const manual = st.lineStyles?.[t.id]?.type;
  if (manual && TPL_FLOW_TYPE_PRESETS[manual]) return TPL_FLOW_TYPE_PRESETS[manual];
  return getTransitionVisualType(t.condition_text, _tplStatusLabel(_normStatusId(t.to_status)));
}

function getStatusVisualType(statusName, hasAiTemplate = true) {
  const name = String(statusName || '').toLowerCase();
  const has = (...needles) => needles.some(x => name.includes(x));
  let base;
  if (has('нов', 'new', 'start', 'старт')) base = { type: 'start', color: '#9f8bff' };
  else if (has('предлож', 'offer')) base = { type: 'offer', color: '#7ee787' };
  else if (has('возраж', 'objection', 'сомнен')) base = { type: 'objection', color: '#ffc857' };
  else if (has('человек', 'human', 'оператор')) base = { type: 'human', color: '#57b7ff' };
  else if (has('отказ', 'reject')) base = { type: 'reject', color: '#ff5b6b' };
  else if (has('продаж', 'sale')) base = { type: 'sale', color: '#35e58f' };
  else if (has('работ', 'work')) base = { type: 'work', color: '#7c9bff' };
  else base = { type: 'neutral', color: '#b8a8ff' };
  return { ...base, empty: !hasAiTemplate };
}

const TPL_FLOW_NODE_MIN_W = 120;
const TPL_FLOW_NODE_MIN_H = 56;
const TPL_FLOW_NODE_MAX_W = 190;
const TPL_FLOW_NODE_MAX_H = 200;
const TPL_FLOW_NODE_DEF_W = 146;
const TPL_FLOW_NODE_DEF_H = 90;

function _normTplFlowNode(p) {
  const out = {
    x: Math.max(8, Number(p?.x) || 8),
    y: Math.max(8, Number(p?.y) || 8),
    w: Number(p?.w) || TPL_FLOW_NODE_DEF_W,
    h: Number(p?.h) || TPL_FLOW_NODE_DEF_H,
  };
  out.w = Math.min(TPL_FLOW_NODE_MAX_W, Math.max(TPL_FLOW_NODE_MIN_W, out.w));
  out.h = Math.min(TPL_FLOW_NODE_MAX_H, Math.max(TPL_FLOW_NODE_MIN_H, out.h));
  return out;
}

function _transitionDuplicateWarnings(transitions = tplJobTransitionsCache || []) {
  const norm = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
  const seen = new Map();
  const ids = new Set();
  (transitions || []).forEach(t => {
    const key = `${_normStatusId(t.from_status)}>${_normStatusId(t.to_status)}>${norm(_transitionCondition(t))}`;
    if (seen.has(key)) {
      ids.add(Number(t.id));
      ids.add(Number(seen.get(key)));
    } else {
      seen.set(key, Number(t.id));
    }
  });
  return ids;
}

function _tplFlowCurve(p1, p2, offset = 0, sameNode = false, canonNormal = null) {
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;
  const dist = Math.max(1, Math.hypot(dx, dy));
  const cd = Math.max(60, Math.min(220, dist * 0.35));
  let c1x = p1.x;
  let c1y = p1.y;
  let c2x = p2.x;
  let c2y = p2.y;
  if (sameNode) {
    c1x = p1.x + 70; c1y = p1.y - 80;
    c2x = p2.x - 70; c2y = p2.y - 80;
  } else if (Math.abs(dx) > Math.abs(dy)) {
    const s = Math.sign(dx) || 1;
    c1x += s * cd;
    c2x -= s * cd;
  } else {
    const s = Math.sign(dy) || 1;
    c1y += s * cd;
    c2y -= s * cd;
  }
  const nx = canonNormal ? canonNormal.x : (-dy / dist);
  const ny = canonNormal ? canonNormal.y : (dx / dist);
  c1x += nx * offset; c1y += ny * offset;
  c2x += nx * offset; c2y += ny * offset;
  const lx = (p1.x + p2.x) / 8 + (c1x + c2x) * 0.375;
  const ly = (p1.y + p2.y) / 8 + (c1y + c2y) * 0.375;
  return { d: `M ${p1.x} ${p1.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`, lx, ly };
}

function _tplFlowEdgeMeta(transitions) {
  const sameDir = new Map();
  const undirected = new Map();
  (transitions || []).forEach(t => {
    const from = _normStatusId(t.from_status);
    const to = _normStatusId(t.to_status);
    const dir = `${from}>${to}`;
    const pair = [from, to].sort().join('<>');
    if (!sameDir.has(dir)) sameDir.set(dir, []);
    if (!undirected.has(pair)) undirected.set(pair, new Set());
    sameDir.get(dir).push(Number(t.id));
    undirected.get(pair).add(dir);
  });
  return { sameDir, undirected };
}

function _tplFlowIsFinalStatus(statusId) {
  const name = `${statusId} ${_tplStatusLabel(statusId)}`.toLowerCase();
  return ['отказ', 'reject', 'продаж', 'sale', 'человек', 'human', 'оператор'].some(x => name.includes(x));
}

let _tplFlowReport = null;

function _checkTplFlowSchema() {
  const transitions = tplJobTransitionsCache || [];
  const dupes = _transitionDuplicateWarnings(transitions);
  const self = transitions.filter(t => _normStatusId(t.from_status) === _normStatusId(t.to_status));
  const byStatus = new Set(
    (tplJobRulesCache || []).filter(_isJobRuleEnabled).map(r => _normStatusId(r.trigger_status))
  );
  const used = new Set();
  transitions.forEach(t => {
    used.add(_normStatusId(t.from_status));
    used.add(_normStatusId(t.to_status));
  });
  const start = _jobFunnelStartStatus();
  const chainStatuses = [...used];
  if (!chainStatuses.includes(start)) chainStatuses.push(start);
  const reachable = new Set(_funnelChainStatuses());
  const noTemplate = chainStatuses.filter(st => !byStatus.has(st));
  const unreachable = chainStatuses.filter(st => !reachable.has(st));
  const noOutgoing = chainStatuses.filter(st =>
    !transitions.some(t => _normStatusId(t.from_status) === st)
  );
  const errors = [];
  const warnings = [];
  if (dupes.size) errors.push(`Возможные дубли переходов: ${dupes.size}`);
  if (self.length) errors.push(`Переходы в тот же статус: ${self.map(t => _tplStatusLabel(_normStatusId(t.from_status))).join(', ')}`);
  if (unreachable.length) errors.push(`Недостижимы из старта: ${unreachable.map(_tplStatusLabel).join(', ')}`);
  // Финальные статусы (отказ/продажа/человек) без AI-шаблона и без исходящих — норма, мягкое info.
  noTemplate.forEach(st => {
    (_tplFlowIsFinalStatus(st) ? warnings : errors).push(`«${_tplStatusLabel(st)}» — нет AI-шаблона`);
  });
  noOutgoing.forEach(st => {
    if (_tplFlowIsFinalStatus(st)) warnings.push(`«${_tplStatusLabel(st)}» — финальный статус без исходящих переходов (обычно это норма)`);
    else errors.push(`«${_tplStatusLabel(st)}» — нет исходящих переходов`);
  });
  // Частый кейс после случайного удаления: у Возражения пропала ветка в Отказ.
  const labelOf = id => _tplStatusLabel(id).toLowerCase();
  const objectionId = chainStatuses.find(st => labelOf(st).includes('возраж') || st.includes('objection') || st.includes('vozrazhen'));
  const rejectId = chainStatuses.find(st => labelOf(st).includes('отказ') || st.includes('reject') || st.includes('otkaz'));
  if (objectionId && rejectId && !transitions.some(t =>
    _normStatusId(t.from_status) === objectionId && _normStatusId(t.to_status) === rejectId
  )) {
    warnings.push(`У «${_tplStatusLabel(objectionId)}» нет ветки в «${_tplStatusLabel(rejectId)}»`);
  }
  _tplFlowReport = { errors, warnings };
  _tplFlowSelection = { type: 'report' };
  renderTplFunnelBoard();
}

let _tplFlowLineEdit = null;       // id перехода в режиме «Изменить нить»
let _tplFlowAnchorDraft = null;    // { id, which: 'from'|'to', xRatio, yRatio } во время drag ручки

function _tplFlowLineContextMenu(x, y, transitionId) {
  const old = document.querySelector('.tpl-flow-line-menu');
  if (old) old.remove();
  const root = document.getElementById('tplFunnelBoard') || document.body;
  const menu = document.createElement('div');
  menu.className = 'tpl-flow-line-menu';
  menu.style.left = `${x}px`;
  menu.style.top = `${y}px`;
  menu.innerHTML = `
    <button type="button" data-line-menu-action="edit">Изменить нить</button>
    <button type="button" data-line-menu-action="hide">Убрать с холста</button>
  `;
  const close = () => {
    if (menu.parentNode) menu.remove();
    document.removeEventListener('pointerdown', outside, true);
  };
  const outside = ev => {
    if (!menu.contains(ev.target)) close();
  };
  menu.querySelector('[data-line-menu-action="edit"]')?.addEventListener('click', () => {
    _tplFlowSelection = { type: 'transition', id: Number(transitionId) };
    _tplFlowLineEdit = Number(transitionId);
    close();
    renderTplFunnelBoard();
  });
  menu.querySelector('[data-line-menu-action="hide"]')?.addEventListener('click', () => {
    _tplFlowHideTransition(Number(transitionId));
    close();
  });
  root.appendChild(menu);
  document.addEventListener('pointerdown', outside, true);
}

function _tplFlowGeoAnchors(fromId, toId, ar, br) {
  // Якоря по сторонам узлов (как в flow-редакторах: react-flow/n8n) — ребро
  // выходит из стороны источника, обращённой к цели, и входит в обращённую
  // сторону цели. Это убирает «кашу» из линий через центры карточек.
  const fid = _normStatusId(fromId);
  const tid = _normStatusId(toId);
  if (fid === tid) {
    return {
      from: { statusId: fid, xRatio: 1, yRatio: 0.35 },
      to: { statusId: tid, xRatio: 0.7, yRatio: 0 },
    };
  }
  const acx = ar.left + ar.width / 2, acy = ar.top + ar.height / 2;
  const bcx = br.left + br.width / 2, bcy = br.top + br.height / 2;
  const dx = bcx - acx, dy = bcy - acy;
  let from, to;
  if (Math.abs(dy) >= Math.abs(dx)) {
    if (dy >= 0) { from = { xRatio: 0.5, yRatio: 1 }; to = { xRatio: 0.5, yRatio: 0 }; }
    else { from = { xRatio: 0.5, yRatio: 0 }; to = { xRatio: 0.5, yRatio: 1 }; }
  } else {
    if (dx >= 0) { from = { xRatio: 1, yRatio: 0.5 }; to = { xRatio: 0, yRatio: 0.5 }; }
    else { from = { xRatio: 0, yRatio: 0.5 }; to = { xRatio: 1, yRatio: 0.5 }; }
  }
  return {
    from: { statusId: fid, xRatio: from.xRatio, yRatio: from.yRatio },
    to: { statusId: tid, xRatio: to.xRatio, yRatio: to.yRatio },
  };
}

function _tplFlowEnsureLineAnchors(t, ar, br, canvasState) {
  // Ручная привязка (перетащена пользователем) приоритетна и сохраняется.
  const cur = canvasState.lineAnchors[t.id];
  if (cur?.from && cur?.to) return { anchors: cur, changed: false };
  // Иначе — геометрические якоря по сторонам, считаются на лету (не персистятся),
  // поэтому сами пересчитываются при перемещении/автосортировке узлов.
  return { anchors: _tplFlowGeoAnchors(t.from_status, t.to_status, ar, br), changed: false };
}

function _tplFlowTreeEdgeSet(transitions) {
  // Остовное дерево (BFS от старта): множество ключей "from>to", которые
  // образуют скелет воронки. Эти связи рисуем ярко, остальные (перекрёстные) —
  // еле видно, чтобы в покое холст выглядел чистым деревом, а не «кашей».
  const start = _jobFunnelStartStatus();
  const ids = new Set();
  const adj = new Map();
  const indeg = new Map();
  (transitions || []).forEach(t => {
    const f = _normStatusId(t.from_status), to = _normStatusId(t.to_status);
    ids.add(f); ids.add(to);
    if (f === to) return;
    if (!adj.has(f)) adj.set(f, []);
    adj.get(f).push(to);
    indeg.set(to, (indeg.get(to) || 0) + 1);
  });
  const roots = []; const seen = new Set();
  if (start && ids.has(start)) { roots.push(start); seen.add(start); }
  [...ids].forEach(id => { if (!seen.has(id) && (indeg.get(id) || 0) === 0) { roots.push(id); seen.add(id); } });
  if (!roots.length && ids.size) roots.push([...ids][0]);
  const tree = new Set(); const visited = new Set(); const q = [];
  roots.forEach(r => { if (!visited.has(r)) { visited.add(r); q.push(r); } });
  let g = 0;
  while (q.length && g++ < 5000) {
    const cur = q.shift();
    (adj.get(cur) || []).forEach(to => {
      if (!visited.has(to)) { visited.add(to); tree.add(cur + '>' + to); q.push(to); }
    });
  }
  return tree;
}

function _renderTplFlowLines(box) {
  const workspace = box.querySelector('[data-flow-workspace]');
  const svg = box.querySelector('[data-flow-lines]');
  if (!workspace || !svg) return;
  const wr = workspace.getBoundingClientRect();
  const canvasState = _loadTplFlowCanvasState();
  const hiddenTr = new Set(canvasState.hiddenTransitionIds);
  const transitions = (tplJobTransitionsCache || []).filter(t => !hiddenTr.has(Number(t.id)));
  const treeEdges = _tplFlowTreeEdgeSet(transitions);
  const { sameDir, undirected } = _tplFlowEdgeMeta(transitions);
  const duplicateIds = _transitionDuplicateWarnings(tplJobTransitionsCache || []);
  const selectedId = _tplFlowSelection?.type === 'transition' ? Number(_tplFlowSelection.id) : null;
  let anchorsChanged = false;
  const pieces = [];
  const defs = `<defs>
    ${['interest', 'objection', 'human', 'reject', 'sale', 'neutral'].map(type =>
      `<marker id="tpl-flow-arrow-${type}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">
        <path class="tpl-flow-arrow tpl-flow-arrow-${type}" d="M 0.5 1 L 9 5 L 0.5 9 z"></path>
      </marker>`
    ).join('')}
  </defs>`;
  pieces.push(defs);
  const handles = [];
  transitions.forEach(t => {
    const from = _normStatusId(t.from_status);
    const to = _normStatusId(t.to_status);
    const a = workspace.querySelector(`[data-flow-status="${_cssEscape(from)}"]`);
    const b = workspace.querySelector(`[data-flow-status="${_cssEscape(to)}"]`);
    if (!a || !b) return;
    const ar = a.getBoundingClientRect();
    const br = b.getBoundingClientRect();
    const ensured = _tplFlowEnsureLineAnchors(t, ar, br, canvasState);
    if (ensured.changed) anchorsChanged = true;
    let fa = ensured.anchors.from;
    let ta = ensured.anchors.to;
    // Во время drag ручки рисуем черновую точку, не трогая сохранённое состояние.
    if (_tplFlowAnchorDraft && Number(_tplFlowAnchorDraft.id) === Number(t.id)) {
      if (_tplFlowAnchorDraft.which === 'from') fa = { ...fa, ..._tplFlowAnchorDraft };
      else ta = { ...ta, ..._tplFlowAnchorDraft };
    }
    let p1 = _tplFlowAnchorPoint(ar, wr, fa);
    let p2 = _tplFlowAnchorPoint(br, wr, ta);
    const sameNode = from === to;
    const pair = [from, to].sort().join('<>');
    // Сквозной индекс среди ВСЕХ нитей этой пары (прямые + обратные)
    const pairDirs = undirected.get(pair) || new Set();
    const pairIds = [];
    pairDirs.forEach(d => (sameDir.get(d) || []).forEach(id => pairIds.push(id)));
    pairIds.sort((a, b) => a - b);
    const pairIdx = Math.max(0, pairIds.indexOf(Number(t.id)));
    const pairCount = pairIds.length;
    // Веер параллельных связей между одной парой ограничен по ширине: при многих
    // дублях (напр. 17 переходов new→pain) они схлопываются почти в одну линию,
    // а не раздуваются в широкий пучок «каши».
    const spreadStep = pairCount > 1 ? Math.min(26, 48 / (pairCount - 1)) : 0;
    const offset = pairCount > 1 ? (pairIdx - (pairCount - 1) / 2) * spreadStep : 0;
    // Каноническая нормаль: всегда от «меньшего» ID к «большему» — единая для пары
    let canonNormal = null;
    if (offset && !sameNode) {
      const canonFrom = from < to ? from : to;
      const canonTo = from < to ? to : from;
      const cA = workspace.querySelector(`[data-flow-status="${_cssEscape(canonFrom)}"]`);
      const cB = workspace.querySelector(`[data-flow-status="${_cssEscape(canonTo)}"]`);
      if (cA && cB) {
        const cAr = cA.getBoundingClientRect();
        const cBr = cB.getBoundingClientRect();
        const cdx = (cBr.left + cBr.width / 2) - (cAr.left + cAr.width / 2);
        const cdy = (cBr.top + cBr.height / 2) - (cAr.top + cAr.height / 2);
        const cdist = Math.max(1, Math.hypot(cdx, cdy));
        canonNormal = { x: -cdy / cdist, y: cdx / cdist };
      }
      const endShift = offset * 0.5;
      const nx = canonNormal ? canonNormal.x : 0;
      const ny = canonNormal ? canonNormal.y : 0;
      p1 = { x: p1.x + nx * endShift, y: p1.y + ny * endShift };
      p2 = { x: p2.x + nx * endShift, y: p2.y + ny * endShift };
    }
    const { d, lx, ly } = _tplFlowCurve(p1, p2, offset, sameNode, canonNormal);
    const visual = _tplFlowVisualForTransition(t, canvasState);
    const selected = selectedId === Number(t.id);
    const muted = selectedId && !selected ? ' muted' : '';
    const duplicate = duplicateIds.has(Number(t.id));
    const crossCls = treeEdges.has(from + '>' + to) ? '' : ' tpl-flow-line--cross';
    const label = esc(visual.label || 'переход');
    const labelW = Math.max(52, Math.min(120, label.length * 7 + (duplicate ? 22 : 16)));
    pieces.push(`<path class="tpl-flow-line-hit" d="${d}" data-transition-line="${t.id}" />`);
    pieces.push(`<path class="tpl-flow-line tpl-flow-line-${visual.type}${selected ? ' selected' : ''}${muted}${duplicate ? ' duplicate' : ''}${crossCls}" style="--flow-color:${visual.color}" marker-end="url(#tpl-flow-arrow-${visual.type})" d="${d}" data-transition-line="${t.id}" />`);
    pieces.push(`<g class="tpl-flow-line-badge${selected ? ' selected' : ''}${muted}" style="--flow-color:${visual.color}" data-transition-line="${t.id}">
      <rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="22" rx="10"></rect>
      <text x="${lx}" y="${ly + 4}">${duplicate ? '⚠ ' : ''}${label}</text>
      <title>${duplicate ? 'Возможный дубль перехода. ' : ''}${esc(_transitionCondition(t))}</title>
    </g>`);
    if (_tplFlowLineEdit != null && Number(_tplFlowLineEdit) === Number(t.id)) {
      handles.push(`<circle class="tpl-flow-anchor-handle" cx="${p1.x}" cy="${p1.y}" r="7" style="--flow-color:${visual.color}" data-anchor-handle="from" data-anchor-for="${t.id}"><title>Перетащи: начало нити</title></circle>`);
      handles.push(`<circle class="tpl-flow-anchor-handle" cx="${p2.x}" cy="${p2.y}" r="7" style="--flow-color:${visual.color}" data-anchor-handle="to" data-anchor-for="${t.id}"><title>Перетащи: конец нити</title></circle>`);
    }
  });
  pieces.push(...handles);
  if (anchorsChanged) _saveTplFlowCanvasState(canvasState);
  svg.innerHTML = pieces.join('');
  svg.querySelectorAll('[data-transition-line]').forEach(el => {
    el.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowSelection = { type: 'transition', id: Number(el.dataset.transitionLine) };
      renderTplFunnelBoard();
    });
    el.addEventListener('contextmenu', e => {
      e.preventDefault();
      e.stopPropagation();
      _tplFlowLineContextMenu(e.clientX, e.clientY, Number(el.dataset.transitionLine));
    });
    el.addEventListener('dblclick', e => {
      e.preventDefault();
      e.stopPropagation();
      const id = Number(el.dataset.transitionLine);
      _tplFlowSelection = { type: 'transition', id };
      _tplFlowLineEdit = null;
      openTransitionModal('', id);
    });
    el.addEventListener('pointerenter', () => {
      const id = el.dataset.transitionLine;
      svg.querySelectorAll(`[data-transition-line="${id}"]`).forEach(x => x.classList.add('hover'));
    });
    el.addEventListener('pointerleave', () => {
      const id = el.dataset.transitionLine;
      svg.querySelectorAll(`[data-transition-line="${id}"]`).forEach(x => x.classList.remove('hover'));
    });
  });
  _bindTplFlowAnchorHandles(box, svg, workspace);

  // Фокус по наведению на карточку: подсветить только её нити, остальные погасить.
  workspace.querySelectorAll('[data-flow-status]').forEach(node => {
    if (node.dataset.hoverFocusBound) return;
    node.dataset.hoverFocusBound = '1';
    const sid = _normStatusId(node.dataset.flowStatus);
    node.addEventListener('pointerenter', () => {
      if (workspace.classList.contains('drawing')) return;
      if (box.querySelector('.tpl-flow-canvas-shell')?.classList.contains('panning')) return;
      const related = new Set();
      (tplJobTransitionsCache || []).forEach(t => {
        if (_normStatusId(t.from_status) === sid || _normStatusId(t.to_status) === sid) related.add(Number(t.id));
      });
      workspace.classList.add('tpl-flow-focus');
      svg.querySelectorAll('[data-transition-line]').forEach(el => {
        el.classList.toggle('lit', related.has(Number(el.dataset.transitionLine)));
      });
    });
    node.addEventListener('pointerleave', () => {
      workspace.classList.remove('tpl-flow-focus');
      svg.querySelectorAll('.lit').forEach(el => el.classList.remove('lit'));
    });
  });
}

function _bindTplFlowAnchorHandles(box, svg, workspace) {
  svg.querySelectorAll('[data-anchor-handle]').forEach(handle => {
    handle.addEventListener('pointerdown', e => {
      if (e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();
      const id = Number(handle.dataset.anchorFor);
      const which = handle.dataset.anchorHandle === 'from' ? 'from' : 'to';
      const state = _loadTplFlowCanvasState();
      const anchors = state.lineAnchors[id];
      if (!anchors) return;
      const statusId = _normStatusId(anchors[which]?.statusId);
      const node = workspace.querySelector(`[data-flow-status="${_cssEscape(statusId)}"]`);
      if (!node) return;
      const beforeJson = JSON.stringify(state);
      handle.setPointerCapture(e.pointerId);
      const move = ev => {
        // Ручка скользит только по своей карточке: ratio зажимается в 0..1.
        const rect = node.getBoundingClientRect();
        _tplFlowAnchorDraft = {
          id,
          which,
          xRatio: _clamp01((ev.clientX - rect.left) / Math.max(1, rect.width)),
          yRatio: _clamp01((ev.clientY - rect.top) / Math.max(1, rect.height)),
        };
        _renderTplFlowLines(box);
      };
      const up = ev => {
        handle.removeEventListener('pointermove', move);
        handle.removeEventListener('pointerup', up);
        const draft = _tplFlowAnchorDraft;
        _tplFlowAnchorDraft = null;
        if (draft && Number(draft.id) === id) {
          const st = _loadTplFlowCanvasState();
          const la = st.lineAnchors[id];
          if (la?.[which]) {
            la[which] = { statusId, xRatio: draft.xRatio, yRatio: draft.yRatio };
            _saveTplFlowCanvasState(st);
            _pushTplFlowHistoryRaw(beforeJson);
          }
        }
        _renderTplFlowLines(box);
      };
      handle.addEventListener('pointermove', move);
      handle.addEventListener('pointerup', up);
    });
  });
}

// Панорамирование холста: тащим пустой фон → скроллим shell вместе с нодами.
let _tplFlowZoom = 1;
const TPL_FLOW_ZOOM_MIN = 0.4;
const TPL_FLOW_ZOOM_MAX = 1.6;

function _applyTplFlowZoom(workspace) {
  if (!workspace) return;
  const z = _tplFlowZoom;
  const baseW = parseFloat(workspace.style.width) || 1320;
  const baseH = parseFloat(workspace.style.height) || 760;
  workspace.style.transformOrigin = '0 0';
  workspace.style.transform = z === 1 ? '' : `scale(${z})`;
  // margin компенсирует визуальный overflow от scale, чтобы shell мог доскроллить
  workspace.style.marginRight = `${baseW * (z - 1)}px`;
  workspace.style.marginBottom = `${baseH * (z - 1)}px`;
}

function _bindTplFlowPan(box) {
  const shell = box.querySelector('.tpl-flow-canvas-shell');
  const workspace = box.querySelector('[data-flow-workspace]');
  if (!shell || !workspace) return;
  _applyTplFlowZoom(workspace);

  // Ctrl/⌘ + колесо — зум к курсору; обычное колесо — нативный скролл shell.
  shell.addEventListener('wheel', e => {
    if (!(e.ctrlKey || e.metaKey)) return;
    e.preventDefault();
    const prevZ = _tplFlowZoom;
    const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
    let z = Math.min(TPL_FLOW_ZOOM_MAX, Math.max(TPL_FLOW_ZOOM_MIN, prevZ * factor));
    if (z === prevZ) return;
    const rect = workspace.getBoundingClientRect();
    const cx = e.clientX - rect.left;
    const cy = e.clientY - rect.top;
    const baseX = cx / prevZ;
    const baseY = cy / prevZ;
    _tplFlowZoom = z;
    _applyTplFlowZoom(workspace);
    shell.scrollLeft += baseX * z - cx;
    shell.scrollTop += baseY * z - cy;
    _renderTplFlowLines(box);
  }, { passive: false });

  workspace.addEventListener('pointerdown', e => {
    if (e.button !== 0) return;
    if (e.target !== workspace) return;
    if (_tplFlowDrawing) return;
    const startX = e.clientX;
    const startY = e.clientY;
    const startL = shell.scrollLeft;
    const startT = shell.scrollTop;
    let moved = false;
    shell.classList.add('panning');
    const move = ev => {
      const dx = ev.clientX - startX;
      const dy = ev.clientY - startY;
      if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return;
      moved = true;
      shell.scrollLeft = startL - dx;
      shell.scrollTop = startT - dy;
    };
    const up = () => {
      shell.classList.remove('panning');
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  });
}

function _bindTplFlowDrag(box) {
  const workspace = box.querySelector('[data-flow-workspace]');
  if (!workspace) return;
  workspace.querySelectorAll('[data-flow-status]').forEach(node => {
    const status = _normStatusId(node.dataset.flowStatus);

    node.querySelectorAll('[data-flow-resize]').forEach(handle => {
      handle.addEventListener('pointerdown', e => {
        if (e.button !== 0) return;
        e.preventDefault();
        e.stopPropagation();
        handle.setPointerCapture(e.pointerId);
        const beforeJson = JSON.stringify(_loadTplFlowCanvasState());
        const positions = _loadTplFlowPositions();
        const rect = node.getBoundingClientRect();
        const z0 = _tplFlowZoom || 1;
        const start = _normTplFlowNode({
          ...(positions[status] || {}),
          x: parseFloat(node.style.left) || 8,
          y: parseFloat(node.style.top) || 8,
          w: rect.width / z0,
          h: rect.height / z0,
        });
        const corner = String(handle.dataset.flowResize || 'se');
        const sx = corner.includes('w') ? -1 : 1;
        const sy = corner.includes('n') ? -1 : 1;
        const startX = e.clientX;
        const startY = e.clientY;
        const move = ev => {
          const z = _tplFlowZoom || 1;
          const dx = (ev.clientX - startX) / z;
          const dy = (ev.clientY - startY) / z;
          const rawW = start.w + dx * sx;
          const rawH = start.h + dy * sy;
          const next = _normTplFlowNode({
            ...start,
            w: rawW,
            h: rawH,
            x: corner.includes('w') ? start.x + (start.w - Math.min(TPL_FLOW_NODE_MAX_W, Math.max(TPL_FLOW_NODE_MIN_W, rawW))) : start.x,
            y: corner.includes('n') ? start.y + (start.h - Math.min(TPL_FLOW_NODE_MAX_H, Math.max(TPL_FLOW_NODE_MIN_H, rawH))) : start.y,
          });
          node.style.left = `${next.x}px`;
          node.style.top = `${next.y}px`;
          node.style.width = `${next.w}px`;
          node.style.height = `${next.h}px`;
          positions[status] = next;
          _renderTplFlowLines(box);
        };
        const up = ev => {
          handle.releasePointerCapture(ev.pointerId);
          handle.removeEventListener('pointermove', move);
          handle.removeEventListener('pointerup', up);
          _saveTplFlowPositions(positions);
          if (JSON.stringify(_loadTplFlowCanvasState()) !== beforeJson) _pushTplFlowHistoryRaw(beforeJson);
        };
        handle.addEventListener('pointermove', move);
        handle.addEventListener('pointerup', up);
      });
    });

    // Двойной клик по карточке — открыть её настройку (правая панель по статусу).
    node.addEventListener('dblclick', e => {
      if (_tplFlowDrawing) return;
      if (e.target.closest('button, select, input, textarea, [data-flow-resize]')) return;
      e.preventDefault();
      _openTplFlowStatusSettings(status);
    });

    // Drag самой карточки (с порогом 5px, чтобы не мешать dblclick).
    node.addEventListener('pointerdown', e => {
      if (_tplFlowDrawing) return;
      if (e.button !== 0 || e.target.closest('button, select, input, textarea, [data-flow-resize]')) return;
      node.setPointerCapture(e.pointerId);
      const beforeJson = JSON.stringify(_loadTplFlowCanvasState());
      const positions = _loadTplFlowPositions();
      const start = positions[status] || { x: parseFloat(node.style.left) || 0, y: parseFloat(node.style.top) || 0 };
      const startX = e.clientX;
      const startY = e.clientY;
      let dragging = false;
      const move = ev => {
        const z = _tplFlowZoom || 1;
        const dx = (ev.clientX - startX) / z;
        const dy = (ev.clientY - startY) / z;
        if (!dragging) {
          if (Math.abs(ev.clientX - startX) < 5 && Math.abs(ev.clientY - startY) < 5) return;
          dragging = true;
        }
        const next = {
          ...start,
          x: Math.max(8, start.x + dx),
          y: Math.max(8, start.y + dy),
        };
        node.style.left = `${next.x}px`;
        node.style.top = `${next.y}px`;
        positions[status] = next;
        _renderTplFlowLines(box);
      };
      const up = ev => {
        node.releasePointerCapture(ev.pointerId);
        node.removeEventListener('pointermove', move);
        node.removeEventListener('pointerup', up);
        if (dragging) {
          _saveTplFlowPositions(positions);
          if (JSON.stringify(_loadTplFlowCanvasState()) !== beforeJson) _pushTplFlowHistoryRaw(beforeJson);
        }
      };
      node.addEventListener('pointermove', move);
      node.addEventListener('pointerup', up);
    });
  });
}

// ── Режим «+ Нить»: клик по любой точке карточки-источника, затем по карточке-цели ──
let _tplFlowDrawing = null;        // { from: {statusId,xRatio,yRatio}|null, x1, y1, moveHandler }
let _tplFlowPendingAnchors = null; // anchors для перехода, который сейчас создаётся в модалке

function _tplFlowCancelDrawing() {
  const workspace = document.querySelector('[data-flow-workspace]');
  if (workspace) {
    workspace.classList.remove('drawing');
    if (_tplFlowDrawing?.moveHandler) workspace.removeEventListener('pointermove', _tplFlowDrawing.moveHandler);
  }
  document.querySelector('[data-flow-temp-line]')?.remove();
  _tplFlowPendingAnchors = null;
  _tplFlowDrawing = null;
}

function _tplFlowBeginFromNode(node, box, clientX = null, clientY = null, { startedByShift = false } = {}) {
  const workspace = box.querySelector('[data-flow-workspace]');
  const svg = box.querySelector('[data-flow-lines]');
  if (!workspace || !svg || !node) return;
  const rect = node.getBoundingClientRect();
  const wr = workspace.getBoundingClientRect();
  const cx = clientX != null ? clientX : rect.left + rect.width * 0.5;
  const cy = clientY != null ? clientY : rect.top + rect.height * 0.5;
  const ratio = {
    xRatio: _clamp01((cx - rect.left) / Math.max(1, rect.width)),
    yRatio: _clamp01((cy - rect.top) / Math.max(1, rect.height)),
  };
  _tplFlowDrawing = {
    from: { statusId: _normStatusId(node.dataset.flowStatus), ...ratio },
    x1: cx - wr.left,
    y1: cy - wr.top,
    startedByShift,
    moveHandler: null,
  };
  workspace.classList.add('drawing');
  let temp = svg.querySelector('[data-flow-temp-line]');
  if (!temp) {
    temp = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    temp.setAttribute('data-flow-temp-line', '1');
    temp.setAttribute('class', 'tpl-flow-temp-line');
    svg.appendChild(temp);
  }
  const moveHandler = ev => {
    if (!_tplFlowDrawing?.from) return;
    const { x1, y1 } = _tplFlowDrawing;
    const x2 = ev.clientX - wr.left;
    const y2 = ev.clientY - wr.top;
    const pull = Math.max(40, Math.hypot(x2 - x1, y2 - y1) * 0.35);
    const sx = Math.sign(x2 - x1) || 1;
    temp.setAttribute('d', `M ${x1} ${y1} C ${x1 + sx * pull} ${y1}, ${x2 - sx * pull} ${y2}, ${x2} ${y2}`);
  };
  _tplFlowDrawing.moveHandler = moveHandler;
  workspace.addEventListener('pointermove', moveHandler);
  showToast('Источник выбран (центр карточки) — кликни по карточке-цели');
}

async function _tplFlowCreateTransitionDirect(fromStatusId, toStatusId, anchors) {
  if (!tplJobId) { showToast('Сначала выбери задачу', 'err'); return; }
  try {
    const r = await j(`/api/broadcast/jobs/${tplJobId}/transitions`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ from_status: fromStatusId, to_status: toStatusId, condition_text: '' }),
    });
    let row = r?.transition || null;
    if (!row) {
      await _reloadTplJobTransitions();
      row = (tplJobTransitionsCache || []).find(t =>
        _normStatusId(t.from_status) === _normStatusId(fromStatusId) &&
        _normStatusId(t.to_status) === _normStatusId(toStatusId)
      ) || null;
    }
    if (!row) { showToast('Не удалось создать переход', 'err'); return; }
    _placeTplFlowStatusNear(row.from_status, row.to_status);
    _mergeTransitionInCache(row);
    if (anchors && row.id != null) {
      const st = _loadTplFlowCanvasState();
      st.lineAnchors[row.id] = anchors;
      _saveTplFlowCanvasState(st);
    }
    await _reloadTplJobTransitions();
    _tplFlowSelection = { type: 'transition', id: Number(row.id) };
    renderTplJobHeader();
    showToast('Переход создан — двойной клик по нити для настройки');
  } catch (err) {
    showToast(err.message || 'Ошибка создания перехода', 'err');
  }
}

function _bindTplFlowDrawing(box) {
  const workspace = box.querySelector('[data-flow-workspace]');
  const svg = box.querySelector('[data-flow-lines]');
  if (!workspace || !svg) return;
  workspace.addEventListener('click', e => {
    if (!_tplFlowDrawing) return;
    e.preventDefault();
    e.stopPropagation();
    const node = e.target.closest('[data-flow-status]');
    if (!node) {
      _tplFlowCancelDrawing();
      showToast('Нить отменена');
      return;
    }
    const statusId = _normStatusId(node.dataset.flowStatus);
    if (!_tplFlowDrawing.from) {
      _tplFlowBeginFromNode(node, box);
      return;
    }
    if (statusId === _tplFlowDrawing.from.statusId) {
      showToast('Переход ведёт в этот же статус — выбери другую карточку', 'err');
      return;
    }
    const from = _tplFlowCenterAnchor(_tplFlowDrawing.from.statusId);
    const to = _tplFlowCenterAnchor(statusId);
    _tplFlowCancelDrawing();
    _tplFlowCreateTransitionDirect(from.statusId, statusId, { from, to });
  }, true);
}

function _renderTplFlowSidePanel(selection, byStatus, transitions) {
  const type = selection?.type;
  const canvasState = _loadTplFlowCanvasState();

  if (type === 'report' && _tplFlowReport) {
    const { errors, warnings } = _tplFlowReport;
    return `<aside class="tpl-flow-panel">
      <div class="tpl-flow-panel-title">Проверка схемы</div>
      ${!errors.length && !warnings.length ? '<div class="tpl-flow-ok">Схема в порядке: проблем не найдено</div>' : ''}
      ${errors.length ? `<div class="tpl-flow-panel-subtitle">Проблемы</div>${errors.map(x => `<div class="tpl-flow-warning err">${esc(x)}</div>`).join('')}` : ''}
      ${warnings.length ? `<div class="tpl-flow-panel-subtitle">Предупреждения</div>${warnings.map(x => `<div class="tpl-flow-warning soft">${esc(x)}</div>`).join('')}` : ''}
      <button type="button" data-flow-deselect>Закрыть отчёт</button>
    </aside>`;
  }

  if (type === 'transition') {
    const tr = (transitions || []).find(t => Number(t.id) === Number(selection.id));
    if (tr) {
      const visual = _tplFlowVisualForTransition(tr, canvasState);
      const manualType = canvasState.lineStyles?.[tr.id]?.type || '';
      const duplicate = _transitionDuplicateWarnings(transitions).has(Number(tr.id));
      const typeOpts = ['', ...Object.keys(TPL_FLOW_TYPE_PRESETS)].map(k =>
        `<option value="${k}" ${k === manualType ? 'selected' : ''}>${k ? `${TPL_FLOW_TYPE_PRESETS[k].label} (${k})` : 'авто (по статусу)'}</option>`
      ).join('');
      return `<aside class="tpl-flow-panel">
        <div class="tpl-flow-panel-title">Переход</div>
        <label>Из статуса<input disabled value="${esc(_tplStatusLabel(_normStatusId(tr.from_status)))}"></label>
        <label>В статус<input disabled value="${esc(_tplStatusLabel(_normStatusId(tr.to_status)))}"></label>
        <div class="tpl-flow-type-badge" style="--badge-color:${visual.color}">${esc(visual.label)}</div>
        <label>Цвет/тип линии<select data-flow-line-type="${tr.id}">${typeOpts}</select></label>
        ${duplicate ? '<div class="tpl-flow-warning">Возможный дубль перехода</div>' : ''}
        ${_normStatusId(tr.from_status) === _normStatusId(tr.to_status) ? '<div class="tpl-flow-warning">Переход ведёт в этот же статус</div>' : ''}
        <label>Условие перехода<textarea disabled rows="6">${esc(_transitionCondition(tr))}</textarea></label>
        <button type="button" class="primary" data-transition-edit="${tr.id}">Редактировать переход</button>
        <button type="button" data-flow-edit-line="${tr.id}">${_tplFlowLineEdit != null && Number(_tplFlowLineEdit) === Number(tr.id) ? 'Готово (нить)' : 'Изменить нить'}</button>
        <button type="button" data-flow-rebuild-line="${tr.id}">Перестроить линию</button>
        <button type="button" data-flow-hide-transition="${tr.id}">Убрать с холста</button>
        <button type="button" class="danger" data-transition-del="${tr.id}">Удалить навсегда</button>
      </aside>`;
    }
  }

  // Статус — настройки через модалку (dblclick), правая панель показывает библиотеку.

  // Библиотека задачи (видимое + скрытое, всё возвращается на холст).
  const hiddenSt = new Set(canvasState.hiddenStatusIds);
  const hiddenTr = new Set(canvasState.hiddenTransitionIds);
  const allStatuses = _tplFlowStatuses();
  const visibleStatuses = allStatuses.filter(id => !hiddenSt.has(id));
  const hiddenStatuses = allStatuses.filter(id => hiddenSt.has(id));
  const onCanvas = new Set(visibleStatuses);
  const libraryStatuses = _tplFlowCatalogStatusIds().filter(id => !onCanvas.has(id) && !hiddenSt.has(id));
  const visibleTrans = (transitions || []).filter(t => !hiddenTr.has(Number(t.id)));
  const hiddenTrans = (transitions || []).filter(t => hiddenTr.has(Number(t.id)));
  const presetsList = _jobPresetsList();
  const assignedBy = Object.fromEntries(
    (tplJobRulesCache || []).filter(_isJobRuleEnabled).map(r => [String(r.template_id), _normStatusId(r.trigger_status)])
  );
  const trRow = t => {
    const v = _tplFlowVisualForTransition(t, canvasState);
    return `${esc(_tplStatusLabel(_normStatusId(t.from_status)))} → ${esc(_tplStatusLabel(_normStatusId(t.to_status)))} <span class="tpl-flow-lib-type" style="--badge-color:${v.color}">${esc(v.label)}</span>`;
  };
  return `<aside class="tpl-flow-panel">
    <div class="tpl-flow-panel-title">Библиотека задачи</div>
    <div class="tpl-flow-panel-subtitle">На холсте (${visibleStatuses.length})</div>
    ${visibleStatuses.map(id => {
      const isStart = id === _jobFunnelStartStatus();
      return `<button type="button" class="tpl-flow-panel-transition${isStart ? ' is-start' : ''}" draggable="true" data-flow-lib-status="${esc(id)}" data-flow-select-status="${esc(id)}" title="Перетащи на холст">${esc(_tplStatusLabel(id))}${isStart ? ' · старт' : ''}${byStatus[id] ? '' : ' · без AI'}</button>`;
    }).join('') || '<div class="hint">Пусто — жми «+ Добавить статус»</div>'}
    ${libraryStatuses.length ? `<div class="tpl-flow-panel-subtitle">Справочник (${libraryStatuses.length}) — перетащи на холст</div>
      ${libraryStatuses.map(id => `<button type="button" class="tpl-flow-panel-transition ghost" draggable="true" data-flow-lib-status="${esc(id)}" title="Добавить на холст">${esc(_tplStatusLabel(id))}</button>`).join('')}` : ''}
    ${hiddenStatuses.length ? `<div class="tpl-flow-panel-subtitle">Скрытые статусы (${hiddenStatuses.length})</div>
      <button type="button" class="ghost" data-flow-restore-status-all>Вернуть все статусы</button>
      ${hiddenStatuses.map(id => `<div class="tpl-flow-lib-row" draggable="true" data-flow-lib-status="${esc(id)}" title="Перетащи на холст или нажми «Вернуть»"><span>${esc(_tplStatusLabel(id))}${byStatus[id] ? ` · ${esc(byStatus[id].template_title || `AI #${byStatus[id].template_id}`)}` : ''}</span>
        <button type="button" data-flow-restore-status="${esc(id)}">Вернуть</button></div>`).join('')}` : ''}
    <div class="tpl-flow-panel-subtitle">Переходы на холсте (${visibleTrans.length})</div>
    ${visibleTrans.map(t => `<button type="button" class="tpl-flow-panel-transition" data-flow-select-transition="${t.id}">${trRow(t)}</button>`).join('') || '<div class="hint">Пусто</div>'}
    ${hiddenTrans.length ? `<div class="tpl-flow-panel-subtitle">Скрытые переходы (${hiddenTrans.length})</div>
      <button type="button" class="ghost" data-flow-restore-transition-all>Вернуть все переходы</button>
      ${hiddenTrans.map(t => `<div class="tpl-flow-lib-row" draggable="true" data-flow-lib-transition="${t.id}" title="Перетащи на холст, чтобы вернуть"><span>${trRow(t)}</span>
        <button type="button" data-flow-restore-transition="${t.id}">Вернуть</button></div>`).join('')}` : ''}
    <div class="tpl-flow-panel-subtitle">AI-шаблоны (${presetsList.length})</div>
    ${presetsList.map(p => `<button type="button" class="tpl-flow-panel-transition" draggable="true" data-flow-lib-tpl="${p.id}" data-funnel-open="${p.id}" title="Перетащи на карточку статуса, чтобы назначить">${esc(p.title || `AI-шаблон #${p.id}`)}${assignedBy[String(p.id)] ? ` · ${esc(_tplStatusLabel(assignedBy[String(p.id)]))}` : ''}</button>`).join('') || '<div class="hint">Нет AI-шаблонов</div>'}
  </aside>`;
}

let _tplFlowMiniMapOpen = false;

function _tplFlowMiniMap(statusIds, transitions) {
  // Свёрнута по умолчанию, открывается кнопкой, не занимает canvas.
  const rows = (transitions || []).map(t =>
    `<span>${esc(_tplStatusLabel(_normStatusId(t.from_status)))} → ${esc(_tplStatusLabel(_normStatusId(t.to_status)))}</span>`
  ).join('');
  return `<div class="tpl-flow-minimap-wrap${_tplFlowMiniMapOpen ? ' open' : ''}">
    <button type="button" class="tpl-flow-minimap-toggle" data-flow-minimap-toggle>${_tplFlowMiniMapOpen ? '× Скрыть схему' : 'Мини-схема'}</button>
    <div class="tpl-flow-minimap" ${_tplFlowMiniMapOpen ? '' : 'hidden'}>
      ${rows || '<span>Переходов пока нет</span>'}
    </div>
  </div>`;
}

function renderTplFunnelBoard() {
  const box = document.getElementById('tplFunnelBoard');
  if (!box) return;
  const show = tplCategory === 'job';
  box.hidden = !show;
  if (!show) return;
  if (tplJobId) _tplFlowMigrateCanvasClutter();
  if (tplJobId) _tplFlowSyncHiddenWithSchema();
  // Перерисовка пересоздаёт workspace — режимы рисования/редактирования нити сбрасываются аккуратно.
  if (_tplFlowDrawing) _tplFlowCancelDrawing();
  if (_tplFlowLineEdit != null && !(_tplFlowSelection?.type === 'transition' && Number(_tplFlowSelection.id) === Number(_tplFlowLineEdit))) {
    _tplFlowLineEdit = null;
  }
  const job = _tplJobForSelected(tplJobId);
  const byStatus = Object.fromEntries(
    (tplJobRulesCache || [])
      .filter(_isJobRuleEnabled)
      .map(r => [_normStatusId(r.trigger_status), r])
  );
  const activeCount = Object.keys(byStatus).length;
  const activeAiRules = (tplJobRulesCache || [])
    .filter(r => _isJobRuleEnabled(r) && (r.ar_mode || '').toLowerCase() === 'ai');
  const playbookOnCnt = activeAiRules.filter(r => Number(r.use_sales_playbook || 0) === 1).length;
  const jobPbRaw = job?.use_sales_playbook;
  const jobPbSet = jobPbRaw !== null && jobPbRaw !== undefined;
  const jobPbOn = jobPbSet ? Number(jobPbRaw || 0) === 1 : playbookOnCnt > 0;
  const autoSafeOn = Number(job?.sales_learning_auto_apply_safe || 0) === 1;
  const schemaSum = _jobSchemaSummary();
  const schemaLabel = _jobSchemaSummaryLabel(schemaSum);
  const presets = _jobPresetsList();
  const jobOpts = (tplJobsCache || []).map(jb =>
    `<option value="${jb.id}" ${String(jb.id) === String(tplJobId) ? 'selected' : ''}>${esc(jb.name || `Задача #${jb.id}`)}</option>`
  ).join('') || '<option value="">— нет задач —</option>';
  const mode = _tplFlowMode();
  const statusIds = tplJobId ? _tplFlowStatuses() : [];
  const transitions = tplJobTransitionsCache || [];
  const flowStatusCatalog = _tplFlowStatusCatalog();
  _tplDebug('render funnel', {
    job_id: tplJobId,
    mode,
    status_ids: statusIds,
    bindings: tplJobRulesCache,
    binding_status_ids: Object.keys(byStatus),
    transitions,
  });

  const statusCardHtml = (statusId, i, { compact = false, pos = null } = {}) => {
    const st = flowStatusCatalog.find(s => s.value === statusId);
    const stLabel = st?.label || statusId;
    const normStatusId = _normStatusId(statusId);
    const funnelStart = _jobFunnelStartStatus();
    const isStart = normStatusId === funnelStart;
    const r = byStatus[normStatusId];
    const tpl = r ? _tplById(r.template_id) : null;
    const title = r ? (r.template_title || tpl?.title || `AI-шаблон #${r.template_id}`) : '';
    const jobPbRaw = job?.use_sales_playbook;
    const pbEffective = (jobPbRaw !== null && jobPbRaw !== undefined)
      ? Number(jobPbRaw || 0) === 1
      : (r ? Number(r.use_sales_playbook || 0) === 1 : false);
    const visual = getStatusVisualType(stLabel, !!r);
    const presetOpts = presets.map(p =>
      `<option value="${p.id}">${esc(p.title || `AI-шаблон #${p.id}`)}</option>`
    ).join('');
    const agentInner = r
      ? `<div class="tpl-funnel-agent-kind">AI-шаблон</div>
         <strong class="tpl-flow-tpl-title" title="${esc(title)}">${esc(title)}</strong>
         <div class="tpl-flow-pb-badge ${pbEffective ? 'on' : 'off'}">обучение: ${pbEffective ? 'on' : 'off'}</div>
         <div class="tpl-funnel-agent-actions">
           <button type="button" class="primary" data-funnel-open="${r.template_id}">Открыть</button>
           <button type="button" data-funnel-remove="${esc(normStatusId)}">Снять</button>
         </div>`
      : `<div class="tpl-funnel-agent-kind">AI-шаблон</div>
         <strong class="tpl-flow-tpl-title empty">AI-шаблон не назначен</strong>
         <div class="tpl-funnel-agent-actions">
           <button type="button" class="primary" data-funnel-open-settings="${esc(normStatusId)}">Настроить…</button>
         </div>`;
    let style = '';
    if (pos) {
      const node = _normTplFlowNode(pos);
      // высоту не фиксируем — карточка по контенту (auto), иначе пустое место
      style = ` style="left:${node.x}px;top:${node.y}px;width:${node.w}px;--node-color:${visual.color}"`;
    }
    if (compact) {
      const selected = _tplFlowSelection?.type === 'status' && _tplFlowSelection.id === normStatusId;
      const agentCompact = r
        ? `<div class="tpl-funnel-agent-kind">AI-шаблон</div>
           <strong class="tpl-flow-tpl-title" title="${esc(title)}">${esc(title)}</strong>
           <div class="tpl-flow-pb-badge ${pbEffective ? 'on' : 'off'}">обучение: ${pbEffective ? 'on' : 'off'}</div>`
        : `<div class="tpl-funnel-agent-kind">AI-шаблон</div>
           <strong class="tpl-flow-tpl-title empty">не назначен</strong>`;
      return `<div class="tpl-flow-node tpl-flow-node-${visual.type}${visual.empty ? ' empty' : ''}${selected ? ' selected' : ''}${isStart ? ' is-funnel-start' : ''}" data-flow-status="${esc(normStatusId)}"${style} title="Двойной клик — настройка статуса">
        <div class="tpl-flow-node-card">
          <div class="tpl-funnel-status-top">
            <strong class="tpl-flow-node-title" title="${esc(stLabel)}">${esc(stLabel)}</strong>
            ${isStart ? '<span class="tpl-flow-start-flag" title="Стартовый статус воронки">старт</span>' : ''}
          </div>
          <div class="tpl-funnel-agent ${r ? 'active' : ''}" data-funnel-status="${esc(normStatusId)}">
            ${agentCompact}
          </div>
        </div>
        <span class="tpl-flow-resize tpl-flow-resize-nw" data-flow-resize="nw" title="Изменить размер"></span>
        <span class="tpl-flow-resize tpl-flow-resize-ne" data-flow-resize="ne" title="Изменить размер"></span>
        <span class="tpl-flow-resize tpl-flow-resize-sw" data-flow-resize="sw" title="Изменить размер"></span>
        <span class="tpl-flow-resize tpl-flow-resize-se" data-flow-resize="se" title="Изменить размер"></span>
      </div>`;
    }
    return `<div class="tpl-funnel-status-card">
      <div class="tpl-funnel-status-top">
        <strong>${esc(stLabel)}</strong>
        <label class="tpl-funnel-start-toggle">
          <input type="radio" name="funnelStartPickList" data-funnel-set-start="${esc(normStatusId)}" ${isStart ? 'checked' : ''}>
          <span>старт</span>
        </label>
      </div>
      <div class="tpl-funnel-agent ${r ? 'active' : ''}" data-funnel-status="${esc(normStatusId)}">
        ${agentInner}
      </div>
      <div class="tpl-funnel-add-transition-row">
        <button type="button" class="ghost tpl-funnel-add-transition" data-transition-add="${esc(normStatusId)}">+ Переход из этого статуса</button>
      </div>
    </div>`;
  };

  const listNodesHtml = _funnelChainStatuses().map((statusId, i) => {
    const normStatusId = _normStatusId(statusId);
    const outgoing = transitions.filter(t => _normStatusId(t.from_status) === normStatusId);
    const transitionsHtml = outgoing.length
      ? outgoing.map(t => {
          const toStatus = _normStatusId(t.to_status);
          const toSt = flowStatusCatalog.find(s => s.value === toStatus);
          const toLabel = toSt?.label || toStatus;
          const cond = _transitionCondition(t) || '— условие не задано —';
          return `<div class="tpl-funnel-transition" data-transition-id="${t.id}">
            <div class="tpl-funnel-transition-head">
              <div class="tpl-funnel-transition-to">→ <strong>${esc(toLabel)}</strong></div>
              <div class="tpl-funnel-transition-acts">
                <button type="button" data-transition-edit="${t.id}" title="Изменить переход">✎</button>
                <button type="button" data-transition-del="${t.id}" title="Удалить переход">🗑</button>
              </div>
            </div>
            <div class="tpl-funnel-transition-cond" title="${esc(cond)}">${esc(cond)}</div>
          </div>`;
        }).join('')
      : '<div class="tpl-funnel-transition-empty">Нет переходов из этого статуса</div>';

    return `
      <div class="tpl-funnel-node">
        ${statusCardHtml(statusId, i)}
        <div class="tpl-funnel-transitions">
          <div class="tpl-funnel-transitions-title">Переходы из статуса</div>
          ${transitionsHtml}
        </div>
      </div>
    `;
  }).join('');

  const canvasState = tplJobId ? _loadTplFlowCanvasState() : _normTplFlowCanvasState(null);
  const hiddenStatusSet = new Set(canvasState.hiddenStatusIds);
  const visibleStatusIds = statusIds.filter(id => !hiddenStatusSet.has(id));
  const FIXED_FLOW_W = 1320;
  const FIXED_FLOW_H = 760;
  let positions = tplJobId ? _ensureTplFlowPositions(visibleStatusIds, transitions) : {};
  // Холст растёт под раскладку (min 1320×760) и скроллится — без сжатия в фикс-
  // размер, которое раньше накладывало карточки друг на друга при многих узлах.
  let flowW = FIXED_FLOW_W;
  let flowH = FIXED_FLOW_H;
  if (tplJobId && visibleStatusIds.length) {
    const rects = visibleStatusIds.map(id => positions[id]).filter(Boolean).map(p => ({
      x: Number(p.x) || 0, y: Number(p.y) || 0,
      w: Number(p.w) || TPL_FLOW_NODE_DEF_W, h: Number(p.h) || TPL_FLOW_NODE_DEF_H,
    }));
    if (rects.length) {
      flowW = Math.max(FIXED_FLOW_W, Math.ceil(Math.max(...rects.map(r => r.x + r.w)) + 60));
      flowH = Math.max(FIXED_FLOW_H, Math.ceil(Math.max(...rects.map(r => r.y + r.h)) + 60));
    }
  }
  const flowNodesHtml = visibleStatusIds.map((statusId, i) =>
    statusCardHtml(statusId, i, { compact: true, pos: positions[statusId] || { x: 40, y: 40 } })
  ).join('');
  const panelHidden = (() => {
    try { return localStorage.getItem(TPL_FLOW_PANEL_KEY) === '1'; } catch (_) { return false; }
  })();
  const panelHtml = panelHidden ? '' : _renderTplFlowSidePanel(_tplFlowSelection, byStatus, transitions);
  const canUndo = _tplFlowHistory.jobId === tplJobId && _tplFlowHistory.past.length > 0;
  const canRedo = _tplFlowHistory.jobId === tplJobId && _tplFlowHistory.future.length > 0;

  box.innerHTML =
    `<div class="tpl-funnel-bar">
      <div class="tpl-flow-mode-switch">
        <button type="button" class="${mode === 'builder' ? 'active' : ''}" data-flow-mode="builder">Конструктор</button>
        <button type="button" class="${mode === 'list' ? 'active' : ''}" data-flow-mode="list">Список</button>
      </div>
      <label class="tpl-funnel-job-field">Задача рассылки
        <select id="tplFunnelJobPick">${jobOpts}</select>
      </label>
      <span class="tpl-funnel-schema-badge ${schemaSum.hasSchema ? '' : 'empty'}" title="JSON-схема только для выбранной задачи">${esc(schemaLabel)}</span>
      <span class="tpl-funnel-count ${activeCount ? '' : 'off'}">${activeCount ? `Назначено AI-шаблонов: ${activeCount}` : 'AI-шаблоны не назначены'}</span>
      <button type="button" class="${jobPbOn ? 'primary' : 'ghost'} tpl-funnel-learning-btn" data-job-playbook-on title="Включить обучение продаж для всей задачи">Обучение: ${jobPbOn ? 'ВКЛ' : 'вкл'}</button>
      <button type="button" class="ghost tpl-funnel-learning-btn" data-job-playbook-off title="Выключить обучение продаж для всей задачи">ВЫКЛ</button>
      <button type="button" class="${autoSafeOn ? 'primary' : 'ghost'} tpl-funnel-learning-btn" data-job-auto-safe title="После анализа диалогов автоматически применять рекомендации (маркеры, playbook, шаблоны, переходы) с порогами confidence">Автообучение: ${autoSafeOn ? 'ВКЛ' : 'выкл'}</button>
      ${_tplJobAgentMode === 'simple'
        ? `<span class="tpl-mode-note" title="Режим задачи выбирается при создании">Эта задача — Простой агент</span>
      <button type="button" class="ghost tpl-funnel-learning-btn" data-make-funnel title="Перевести задачу в режим воронки (конструктор статусов)">Сделать воронкой</button>
      <button type="button" class="ghost tpl-funnel-learning-btn" data-open-agent-tab title="Открыть простого агента для этой задачи">🧠 Открыть простого агента →</button>`
        : `<button type="button" class="ghost tpl-funnel-learning-btn" data-open-agent-tab title="Простой агент: анкета + база знаний настраиваются в отдельной вкладке «Простой агент»">🧠 Простой агент →</button>`}
      <span class="tpl-funnel-bar-spacer"></span>
      <button type="button" class="ghost tpl-flow-undo" data-flow-undo title="Отменить" ${canUndo ? '' : 'disabled'}>↶</button>
      <button type="button" class="ghost tpl-flow-undo" data-flow-redo title="Повторить" ${canRedo ? '' : 'disabled'}>↷</button>
      <button type="button" class="ghost" data-flow-draw title="Кликни по карточке-источнику, потом по карточке-цели">+ Нить</button>
      <button type="button" class="ghost" data-flow-add-status>+ Добавить статус</button>
      <button type="button" class="ghost" data-flow-autosort>Автосортировка</button>
      <button type="button" class="ghost" data-flow-center title="Сдвинуть схему к началу холста">Центрировать</button>
      <button type="button" class="ghost" data-flow-reset-anchors title="Сбросить ручные точки крепления всех нитей">Сбросить нити</button>
      <button type="button" class="ghost" data-flow-check>Проверить схему</button>
      <button type="button" class="ghost" data-flow-panel-toggle>${panelHidden ? 'Показать панель' : 'Скрыть панель'}</button>
      <button type="button" class="ghost" data-funnel-library>Библиотека AI-шаблонов</button>
      <button type="button" class="primary" data-funnel-new-preset>+ AI-шаблон</button>
      <button type="button" class="ghost" data-flow-gpt-wizard title="Собрать сценарий через ChatGPT">🤖 ChatGPT</button>
    </div>` +
    (!tplJobId
      ? `<div class="tpl-funnel-empty">Выбери задачу рассылки, чтобы настроить цепочку автоответов.</div>`
      : mode === 'list'
        ? `<div class="tpl-funnel-chain">${listNodesHtml}</div>`
        : `<div class="tpl-flow-layout${panelHidden ? ' no-panel' : ''}">
            <div class="tpl-flow-canvas-shell">
              <div class="tpl-flow-workspace" data-flow-workspace style="width:${flowW}px;height:${flowH}px">
                <svg class="tpl-flow-lines" data-flow-lines width="${flowW}" height="${flowH}"></svg>
                ${flowNodesHtml}
              </div>
              ${_tplFlowMiniMap(visibleStatusIds, transitions)}
            </div>
            ${panelHtml}
          </div>`);

  const jobSel = box.querySelector('#tplFunnelJobPick');
  if (jobSel) {
    jobSel.onchange = async () => {
      tplJobId = Number(jobSel.value) || null;
      _tplFlowHistory = { jobId: tplJobId, past: [], future: [] };
      _tplFlowSelection = null;
      const mirror = document.getElementById('tplJobPick');
      if (mirror) mirror.value = String(tplJobId || '');
      try { sessionStorage.setItem(TPL_JOB_KEY, String(tplJobId || '')); } catch (_) {}
      await loadTplJobRules();
      await _onTplJobScopeChange();
      renderTplJobHeader();
    };
  }
  box.querySelectorAll('[data-flow-mode]').forEach(btn => {
    btn.addEventListener('click', () => {
      try { localStorage.setItem(TPL_FLOW_MODE_KEY, btn.dataset.flowMode || 'builder'); } catch (_) {}
      renderTplFunnelBoard();
    });
  });
  box.querySelector('[data-job-playbook-on]')?.addEventListener('click', () => {
    setTplJobSalesPlaybookForAll(true).catch(e => showToast(e.message || e, 'err'));
  });
  box.querySelector('[data-job-playbook-off]')?.addEventListener('click', () => {
    setTplJobSalesPlaybookForAll(false).catch(e => showToast(e.message || e, 'err'));
  });
  box.querySelector('[data-job-auto-safe]')?.addEventListener('click', () => {
    const j = _tplJobForSelected(tplJobId);
    const next = !(Number(j?.sales_learning_auto_apply_safe || 0) === 1);
    setTplJobSalesLearningAutoApplySafe(next).catch(e => showToast(e.message || e, 'err'));
  });
  box.querySelector('[data-job-product-facts]')?.addEventListener('click', () => {
    openJobProductFacts().catch(e => showToast(e.message || e, 'err'));
  });
  box.querySelector('[data-job-agent-config]')?.addEventListener('click', () => {
    if (!tplJobId) { showToast('Сначала выбери задачу', 'err'); return; }
    openAgentConfig(tplJobId).catch(e => showToast(e.message || e, 'err'));
  });
  box.querySelector('[data-open-agent-tab]')?.addEventListener('click', () => {
    if (tplJobId) { try { sessionStorage.setItem(AGENT_TAB_KEY, String(tplJobId)); } catch (_) {} }
    switchTplCategory('agent').catch(e => showToast(e.message || e, 'err'));
  });
  box.querySelector('[data-make-funnel]')?.addEventListener('click', () => {
    setTplJobAgentMode('funnel');
  });
  box.querySelectorAll('[data-job-mode]').forEach(btn => {
    btn.addEventListener('click', () => setTplJobAgentMode(btn.dataset.jobMode));
  });
  box.querySelector('[data-flow-add-status]')?.addEventListener('click', () => {
    _insertFunnelStatus('').catch(err => showToast(err.message || 'Ошибка', 'err'));
  });
  box.querySelector('[data-flow-autosort]')?.addEventListener('click', async () => {
    // Раскладка через ELK (layered, сверху-вниз): минимизация пересечений + циклы.
    // Якоря нитей сбрасываем → пересчёт геометрически под новую раскладку.
    pushFlowHistory(_loadTplFlowCanvasState());
    const st = _loadTplFlowCanvasState();
    st.lineAnchors = {};
    st.anchorSides = {};
    _saveTplFlowCanvasState(st);
    const btn = box.querySelector('[data-flow-autosort]');
    let elkPositions = null;
    try {
      if (btn) { btn.disabled = true; btn.textContent = 'Раскладка…'; }
      elkPositions = await _tplFlowElkLayout(visibleStatusIds, transitions);
    } catch (e) {
      console.warn('ELK layout failed, fallback to tidy-tree:', e);
    } finally {
      if (btn) { btn.disabled = false; btn.textContent = 'Автосортировка'; }
    }
    if (elkPositions && Object.keys(elkPositions).length) {
      const saved = _loadTplFlowPositions();
      const next = { ...saved };
      visibleStatusIds.forEach(id => {
        if (elkPositions[id]) {
          next[id] = _normTplFlowNode({ ...elkPositions[id], w: saved[id]?.w, h: saved[id]?.h });
        }
      });
      _saveTplFlowPositions(next);
    } else {
      _ensureTplFlowPositions(visibleStatusIds, transitions, true);
    }
    renderTplFunnelBoard();
  });
  box.querySelector('[data-flow-draw]')?.addEventListener('click', () => {
    if (_tplFlowDrawing) {
      _tplFlowCancelDrawing();
      showToast('Режим нити выключен');
      return;
    }
    const ws = box.querySelector('[data-flow-workspace]');
    if (!ws) return;
    _tplFlowLineEdit = null;
    _tplFlowStickyStatusPanel = false;
    _tplFlowDrawing = { from: null };
    ws.classList.add('drawing');
    showToast('Режим нити: кликни по карточке-источнику, потом по карточке-цели. Shift+⋯ на карточке — быстрый старт');
  });
  box.querySelector('[data-flow-center]')?.addEventListener('click', () => {
    const st = _loadTplFlowCanvasState();
    const vis = visibleStatusIds.map(id => st.nodeLayouts[id]).filter(Boolean);
    if (!vis.length) return;
    pushFlowHistory(st);
    const dx = 40 - Math.min(...vis.map(p => Number(p.x) || 0));
    const dy = 40 - Math.min(...vis.map(p => Number(p.y) || 0));
    visibleStatusIds.forEach(id => {
      const p = st.nodeLayouts[id];
      if (p) {
        p.x = (Number(p.x) || 0) + dx;
        p.y = (Number(p.y) || 0) + dy;
      }
    });
    _saveTplFlowCanvasState(st);
    renderTplFunnelBoard();
  });
  box.querySelector('[data-flow-reset-anchors]')?.addEventListener('click', () => {
    if (!confirm('Это сбросит ручные точки крепления всех нитей. Продолжить?')) return;
    const st = _loadTplFlowCanvasState();
    pushFlowHistory(st);
    st.lineAnchors = {};
    st.anchorSides = {};
    _saveTplFlowCanvasState(st);
    renderTplFunnelBoard();
  });
  box.querySelector('[data-flow-undo]')?.addEventListener('click', () => _undoTplFlow().catch(e => showToast(toastText(e), 'err')));
  box.querySelector('[data-flow-redo]')?.addEventListener('click', () => _redoTplFlow().catch(e => showToast(toastText(e), 'err')));
  box.querySelector('[data-flow-panel-toggle]')?.addEventListener('click', () => {
    try {
      const cur = localStorage.getItem(TPL_FLOW_PANEL_KEY) === '1';
      localStorage.setItem(TPL_FLOW_PANEL_KEY, cur ? '0' : '1');
    } catch (_) {}
    renderTplFunnelBoard();
  });
  box.querySelector('[data-flow-check]')?.addEventListener('click', () => _checkTplFlowSchema());
  box.querySelector('[data-flow-minimap-toggle]')?.addEventListener('click', () => {
    _tplFlowMiniMapOpen = !_tplFlowMiniMapOpen;
    renderTplFunnelBoard();
  });
  box.querySelector('[data-flow-workspace]')?.addEventListener('click', e => {
    // Клик по пустому холсту сбрасывает выбор: панель показывает библиотеку.
    if (e.target === e.currentTarget && _tplFlowSelection) {
      if (!_tplFlowStickyStatusPanel) {
        _tplFlowSelection = null;
        renderTplFunnelBoard();
      }
    }
  });
  box.querySelector('[data-flow-deselect]')?.addEventListener('click', () => {
    _tplFlowStickyStatusPanel = false;
    _tplFlowSelection = null;
    renderTplFunnelBoard();
  });
  box.querySelectorAll('[data-flow-hide-status]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowHideStatus(String(btn.dataset.flowHideStatus || ''));
    });
  });
  box.querySelectorAll('[data-flow-restore-status]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowRestoreStatus(String(btn.dataset.flowRestoreStatus || ''));
    });
  });
  box.querySelector('[data-flow-restore-status-all]')?.addEventListener('click', e => {
    e.stopPropagation();
    _tplFlowRestoreAllHiddenStatuses();
  });
  box.querySelectorAll('[data-flow-hide-transition]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowHideTransition(Number(btn.dataset.flowHideTransition));
    });
  });
  box.querySelectorAll('[data-flow-restore-transition]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowRestoreTransition(Number(btn.dataset.flowRestoreTransition));
    });
  });
  box.querySelector('[data-flow-restore-transition-all]')?.addEventListener('click', e => {
    e.stopPropagation();
    _tplFlowRestoreAllHiddenTransitions();
  });
  box.querySelectorAll('[data-flow-select-status]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowSelection = { type: 'status', id: _normStatusId(btn.dataset.flowSelectStatus) };
      renderTplFunnelBoard();
    });
  });
  box.querySelectorAll('[data-flow-select-transition]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _tplFlowSelection = { type: 'transition', id: Number(btn.dataset.flowSelectTransition) };
      renderTplFunnelBoard();
    });
  });
  box.querySelectorAll('[data-flow-rebuild-line]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      const st = _loadTplFlowCanvasState();
      pushFlowHistory(st);
      delete st.lineAnchors[btn.dataset.flowRebuildLine];
      delete st.anchorSides[btn.dataset.flowRebuildLine];
      _saveTplFlowCanvasState(st);
      renderTplFunnelBoard();
    });
  });
  box.querySelectorAll('[data-flow-edit-line]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      const id = Number(btn.dataset.flowEditLine);
      _tplFlowCancelDrawing();
      _tplFlowLineEdit = _tplFlowLineEdit != null && Number(_tplFlowLineEdit) === id ? null : id;
      renderTplFunnelBoard();
    });
  });
  box.querySelectorAll('[data-flow-line-type]').forEach(sel => {
    sel.addEventListener('change', () => {
      const st = _loadTplFlowCanvasState();
      pushFlowHistory(st);
      const id = sel.dataset.flowLineType;
      if (sel.value && TPL_FLOW_TYPE_PRESETS[sel.value]) st.lineStyles[id] = { type: sel.value };
      else delete st.lineStyles[id];
      _saveTplFlowCanvasState(st);
      renderTplFunnelBoard();
    });
  });
  box.querySelector('[data-funnel-library]')?.addEventListener('click', () => switchTplCategory('presets'));
  box.querySelector('[data-funnel-new-preset]')?.addEventListener('click', () => {
    _pendingFunnelAssignStatus = '';
    newTemplateEditor();
  });
  box.querySelector('[data-flow-gpt-wizard]')?.addEventListener('click', () => _openGptWizard());
  box.querySelectorAll('[data-flow-status]').forEach(node => {
    node.addEventListener('click', e => {
      if (_tplFlowDrawing) return;
      if (e.target.closest('button, select, [data-flow-resize]')) return;
      const id = _normStatusId(node.dataset.flowStatus);
      if (e.shiftKey) {
        _tplFlowCancelDrawing();
        _tplFlowLineEdit = null;
        _tplFlowStickyStatusPanel = false;
        _tplFlowBeginFromNode(node, box);
        return;
      }
      if (e.detail >= 2) {
        _openTplFlowStatusSettings(id);
        return;
      }
      if (_tplFlowClickTimer) clearTimeout(_tplFlowClickTimer);
      _tplFlowClickTimer = setTimeout(() => {
        _tplFlowStickyStatusPanel = false;
        _tplFlowSelection = { type: 'status', id };
        _tplFlowClickTimer = null;
        renderTplFunnelBoard();
      }, 180);
    });
  });
  box.querySelectorAll('[data-funnel-set-start]').forEach(radio => {
    radio.addEventListener('click', e => {
      e.stopPropagation();
      if (radio.checked) {
        setJobFunnelStartStatus(String(radio.dataset.funnelSetStart || ''))
          .catch(err => showToast(err.message || 'Ошибка', 'err'));
      }
    });
  });
  box.querySelectorAll('[data-funnel-open]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      selectTemplateEditor(Number(btn.dataset.funnelOpen));
    });
  });
  box.querySelectorAll('[data-funnel-remove]').forEach(btn => {
    btn.addEventListener('click', async e => {
      e.stopPropagation();
      try { await removeJobRule(String(btn.dataset.funnelRemove || '')); }
      catch (err) { showToast(err.message || 'Ошибка', 'err'); }
    });
  });
  box.querySelectorAll('[data-funnel-open-settings]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      _openTplFlowStatusSettings(String(btn.dataset.funnelOpenSettings || ''));
    });
  });
  box.querySelectorAll('[data-funnel-pick]').forEach(sel => {
    sel.addEventListener('change', async () => {
      const st = String(sel.dataset.funnelPick || '');
      const val = sel.value;
      if (!val) return;
      try {
        await _assignPresetToStatus(val, st);
      } catch (err) { showToast(err.message || 'Ошибка', 'err'); }
    });
  });
  box.querySelectorAll('[data-transition-add]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      openTransitionModal(String(btn.dataset.transitionAdd || ''));
    });
  });
  box.querySelectorAll('[data-transition-edit]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      openTransitionModal('', Number(btn.dataset.transitionEdit));
    });
  });
  box.querySelectorAll('[data-transition-del]').forEach(btn => {
    btn.addEventListener('click', async e => {
      e.stopPropagation();
      try { await _deleteTransition(Number(btn.dataset.transitionDel)); }
      catch (err) { showToast(err.message || 'Ошибка', 'err'); }
    });
  });
  _bindTplFlowDrag(box);
  _bindTplFlowDrawing(box);
  _bindTplFlowLibraryDnd(box);
  _bindTplFlowPan(box);
  requestAnimationFrame(() => _renderTplFlowLines(box));
}

// ── Drag-and-drop из библиотеки: статусы на холст, AI-шаблоны на карточки ──
function _bindTplFlowLibraryDnd(box) {
  const workspace = box.querySelector('[data-flow-workspace]');
  if (!workspace) return;
  box.querySelectorAll('[data-flow-lib-status]').forEach(el => {
    el.addEventListener('dragstart', e => {
      e.dataTransfer.setData('text/flow-status', String(el.dataset.flowLibStatus || ''));
      e.dataTransfer.effectAllowed = 'move';
    });
  });
  box.querySelectorAll('[data-flow-lib-tpl]').forEach(el => {
    el.addEventListener('dragstart', e => {
      e.dataTransfer.setData('text/flow-tpl', String(el.dataset.flowLibTpl || ''));
      e.dataTransfer.effectAllowed = 'copy';
    });
  });
  box.querySelectorAll('[data-flow-lib-transition]').forEach(el => {
    el.addEventListener('dragstart', e => {
      e.dataTransfer.setData('text/flow-transition', String(el.dataset.flowLibTransition || ''));
      e.dataTransfer.effectAllowed = 'move';
    });
  });
  workspace.addEventListener('dragover', e => {
    const types = [...(e.dataTransfer?.types || [])];
    if (types.includes('text/flow-status') || types.includes('text/flow-tpl') || types.includes('text/flow-transition')) e.preventDefault();
  });
  workspace.addEventListener('drop', async e => {
    const statusId = _normStatusId(e.dataTransfer.getData('text/flow-status') || '');
    const tplId = e.dataTransfer.getData('text/flow-tpl') || '';
    if (statusId) {
      e.preventDefault();
      const wr = workspace.getBoundingClientRect();
      const z = _tplFlowZoom || 1;
      const st = _loadTplFlowCanvasState();
      pushFlowHistory(st);
      st.hiddenStatusIds = st.hiddenStatusIds.filter(x => x !== statusId);
      const cur = st.nodeLayouts[statusId] || {};
      st.nodeLayouts[statusId] = _normTplFlowNode({
        ...cur,
        x: (e.clientX - wr.left) / z - 84,
        y: (e.clientY - wr.top) / z - 30,
      });
      _saveTplFlowCanvasState(st);
      renderTplFunnelBoard();
      return;
    }
    const trId = Number(e.dataTransfer.getData('text/flow-transition') || 0);
    if (trId) {
      e.preventDefault();
      const st = _loadTplFlowCanvasState();
      pushFlowHistory(st);
      st.hiddenTransitionIds = st.hiddenTransitionIds.filter(x => Number(x) !== trId);
      _saveTplFlowCanvasState(st);
      renderTplFunnelBoard();
      return;
    }
    if (tplId) {
      e.preventDefault();
      const node = e.target.closest('[data-flow-status]');
      if (!node) {
        showToast('Брось AI-шаблон на карточку статуса', 'err');
        return;
      }
      try {
        await _assignPresetToStatus(tplId, _normStatusId(node.dataset.flowStatus));
      } catch (err) {
        showToast(err.message || 'Ошибка', 'err');
      }
    }
  });
}

let _transitionEditId = null;
let _transitionFromStatus = '';

function openTransitionModal(fromStatus, editId = null, presetToStatus = '') {
  if (!tplJobId) {
    showToast('Сначала выбери задачу', 'err');
    return;
  }
  _transitionEditId = editId;
  const modal = document.getElementById('modalJobTransition');
  if (modal) modal.dataset.transitionEditId = editId != null && !Number.isNaN(Number(editId)) ? String(editId) : '';
  const existing = editId
    ? (tplJobTransitionsCache || []).find(t => Number(t.id) === Number(editId))
    : null;
  if (editId && !existing) {
    showToast('Переход не найден — обновляю цепочку', 'err');
    _reloadTplJobTransitions().then(renderTplJobHeader).catch(() => {});
    return;
  }
  _transitionFromStatus = existing ? _normStatusId(existing.from_status) : _normStatusId(fromStatus);
  const title = document.getElementById('modalTransitionTitle');
  const fromLine = document.getElementById('modalTransitionFrom');
  const { condition: condEl, toStatus: toEl, newName } = _transitionModalFields();
  const newWrap = document.getElementById('transitionNewStatusWrap');
  const statusCatalog = _tplFlowStatusCatalog();
  const fromLabel = statusCatalog.find(s => s.value === _transitionFromStatus)?.label || _transitionFromStatus;

  if (title) title.textContent = existing ? 'Изменить переход' : 'Новый переход';
  if (fromLine) fromLine.textContent = `Из статуса: ${fromLabel}`;
  if (condEl) condEl.value = existing ? _transitionCondition(existing) : '';
  if (newWrap) newWrap.hidden = true;
  if (newName) newName.value = '';

  if (toEl) {
    toEl.innerHTML = statusCatalog
      .map(s => `<option value="${esc(s.value)}">${esc(s.label || s.value)}</option>`)
      .join('') + '<option value="__new">+ создать новый статус</option>';
    const preset = _normStatusId(presetToStatus);
    if (existing) toEl.value = _normStatusId(existing.to_status);
    else if (preset && statusCatalog.some(s => _normStatusId(s.value) === preset)) toEl.value = preset;
    else if (statusCatalog.length) toEl.value = statusCatalog[0].value;
    const syncNewStatusField = () => {
      if (newWrap) newWrap.hidden = toEl.value !== '__new';
    };
    syncNewStatusField();
    toEl.onchange = syncNewStatusField;
  }
  openModal('modalJobTransition');
}

async function _deleteTransition(transitionId) {
  if (!tplJobId || !transitionId) return;
  if (!confirm('Это удалит переход из задачи и runtime больше не будет его учитывать. Удалить навсегда?')) return;
  try {
    await j(`/api/broadcast/jobs/${tplJobId}/transitions/${transitionId}`, { method: 'DELETE' });
  } catch (e) {
    // backend теперь возвращает 400 если задача активна — показываем понятно
    showToast(e.message || 'Не удалось удалить переход', 'err', { duration: 8000 });
    return;
  }
  await _reloadTplJobTransitions();
  if (_tplFlowSelection?.type === 'transition' && Number(_tplFlowSelection.id) === Number(transitionId)) {
    _tplFlowSelection = null;
  }
  renderTplJobHeader();
  showToast('Переход удалён навсегда');
}

async function saveTransitionModal() {
  if (!tplJobId) {
    showToast('Сначала выбери задачу', 'err');
    return;
  }
  const jobId = tplJobId;
  const modalEl = document.getElementById('modalJobTransition');
  const editIdRaw = _transitionEditId ?? modalEl?.dataset?.transitionEditId ?? null;
  const editId = editIdRaw != null && editIdRaw !== '' && !Number.isNaN(Number(editIdRaw)) ? Number(editIdRaw) : null;
  const { toStatus: toEl, newName } = _transitionModalFields();
  const condition = _readTransitionConditionInput();
  let toStatus = _normStatusId(toEl?.value || '');

  if (toStatus === '__new') {
    const label = (newName?.value || '').trim();
    if (!label) {
      showToast('Введи название нового статуса', 'err');
      return;
    }
    const rows = _statusRowsForFunnelDraft();
    const existing = new Set(rows.map(s => s.id));
    const newId = _ensureStatusSlug('', label, existing);
    rows.push({ id: newId, label, description: '', color: '#9f8bff', favorite: false, builtin: false });
    await _saveFunnelStatuses(rows, newId);
    toStatus = leadStatusOptions.some(s => s.value === newId)
      ? newId
      : (leadStatusOptions.find(s => s.label === label)?.value || newId);
  }
  if (!toStatus || toStatus === '__new') {
    showToast('Выбери следующий статус', 'err');
    return;
  }
  if (_normStatusId(_transitionFromStatus) && _normStatusId(_transitionFromStatus) === toStatus) {
    const ok = confirm('Переход ведёт в этот же статус. Обычно это ошибка. Всё равно сохранить?');
    if (!ok) return;
  }

  const pendingAnchorsBeforeSave = _tplFlowPendingAnchors ? JSON.parse(JSON.stringify(_tplFlowPendingAnchors)) : null;
  try {
    let r;
    if (editId) {
      _tplDebug('update transition payload', {
        job_id: jobId,
        transition_id: editId,
        to_status: toStatus,
        condition_text: condition,
      });
      r = await j(`/api/broadcast/jobs/${jobId}/transitions/${editId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ condition_text: condition, to_status: toStatus }),
      });
    } else {
      _tplDebug('create transition payload', {
        job_id: jobId,
        from_status: _transitionFromStatus,
        to_status: toStatus,
        condition_text: condition,
      });
      r = await j(`/api/broadcast/jobs/${jobId}/transitions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ from_status: _transitionFromStatus, to_status: toStatus, condition_text: condition }),
      });
    }
    _tplDebug('transition save response', r);
    let row = r?.transition || null;
    if (!row && r?.ok !== false) {
      if (Number(tplJobId) === Number(jobId)) await _reloadTplJobTransitions();
      if (editId) {
        row = (tplJobTransitionsCache || []).find(t => Number(t.id) === Number(editId)) || null;
      } else {
        row = (tplJobTransitionsCache || []).find(t =>
          _normStatusId(t.from_status) === _normStatusId(_transitionFromStatus) &&
          _normStatusId(t.to_status) === _normStatusId(toStatus)
        ) || null;
      }
    }
    if (!row) {
      throw new Error('Сервер не вернул сохранённый переход — обнови страницу и попробуй снова');
    }
    if (!_transitionTextsMatch(_transitionCondition(row), condition)) {
      throw new Error('Условие перехода не сохранилось — попробуй ещё раз');
    }
    _placeTplFlowStatusNear(row.from_status, row.to_status);
    _mergeTransitionInCache(row);
    // Нить, нарисованная через «+ Нить»: фиксируем точки крепления, выбранные кликами.
    if (!editId && pendingAnchorsBeforeSave && row.id != null
      && _normStatusId(pendingAnchorsBeforeSave.from?.statusId) === _normStatusId(row.from_status)
      && _normStatusId(pendingAnchorsBeforeSave.to?.statusId) === _normStatusId(row.to_status)) {
      const st = _loadTplFlowCanvasState(jobId);
      st.lineAnchors[row.id] = pendingAnchorsBeforeSave;
      _saveTplFlowCanvasState(st, jobId);
    }
    _tplFlowPendingAnchors = null;
    _transitionEditId = null;
    renderTplJobHeader();
    closeModals();
    showToast(editId ? 'Переход сохранён' : 'Переход создан');
    if (Number(tplJobId) === Number(jobId)) {
      await _reloadTplJobTransitions();
      renderTplJobHeader();
    }
  } catch (err) {
    _tplFlowPendingAnchors = null;
    showToast(err.message || 'Ошибка сохранения перехода', 'err');
  }
}

function _tplMoveJobRuleFields() {
  const row = document.getElementById('tplJobRulesRow');
  const mid = document.getElementById('tplToolbarMid');
  const trig = document.getElementById('tplTrigWrap');
  const after = document.getElementById('tplAfterWrap');
  const modeWrap = document.getElementById('tplArModeWrap');
  if (!row || !mid || !trig || !after) return;
  if (_tplIsJobLike()) {
    mid.appendChild(trig);
    mid.appendChild(after);
    trig.hidden = true;
    after.hidden = true;
    if (modeWrap) modeWrap.hidden = true;
  } else {
    mid.appendChild(trig);
    mid.appendChild(after);
    trig.hidden = false;
    after.hidden = false;
    if (modeWrap) modeWrap.hidden = tplCategory !== 'job';
  }
}

function syncTplModeButtons() {
  const wrap = document.getElementById('tplModeSwitch');
  if (!wrap) return;
  const mode = _tplArMode();
  wrap.querySelectorAll('.tpl-mode-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.tplMode === mode);
  });
}

function syncTplJobAttachBar() {
  const bar = document.getElementById('tplJobAttachBar');
  const attachBtn = document.getElementById('btnTplAttachJob');
  const detachBtn = document.getElementById('btnTplDetachJob');
  const hint = document.getElementById('tplJobAttachHint');
  if (!bar) return;
  // Назначение AI-шаблонов делается в карточках CRM-статусов, панель не нужна.
  bar.hidden = true;
  if (attachBtn) attachBtn.hidden = true;
  if (detachBtn) detachBtn.hidden = true;
  if (hint) hint.textContent = '';
}

function _syncTplCategoryUi() {
  if (!_tplUseModalEditor()) {
    const tplModal = document.getElementById('modalTplEditor');
    if (tplModal && !tplModal.hidden) closeModals();
    _restoreTplEditorToDock();
  }
  const agentPanel = document.getElementById('tplAgentPanel');
  if (agentPanel && tplCategory !== 'agent') agentPanel.hidden = true;
  const layout = document.getElementById('tplMainLayout');
  if (layout) {
    layout.dataset.mode = tplCategory === 'job' ? 'job'
      : tplCategory === 'presets' ? 'presets' : 'templates';
  }
  document.querySelectorAll('.tpl-category-tab').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.tplCategory === tplCategory);
  });
  const globalWrap = document.getElementById('tplGlobalArWrap');
  const jobHeader = document.getElementById('tplJobHeader');
  const jobEditor = document.getElementById('tplJobEditorBlock');
  const newBtn = document.getElementById('btnTplNewList');
  if (globalWrap) globalWrap.hidden = tplCategory !== 'global';
  if (jobHeader) jobHeader.hidden = true;
  if (jobEditor) jobEditor.hidden = true;
  if (newBtn) newBtn.textContent = (tplCategory === 'job' || tplCategory === 'presets') ? '+ AI-шаблон' : '+ Создать';
  const emptyHint = document.getElementById('tplEmptyHint');
  const emptyBtn = document.getElementById('btnTplNewCenter');
  const emptyWrap = document.getElementById('tplEditorEmpty');
  if (emptyHint) {
    emptyHint.textContent = tplCategory === 'job'
      ? 'Создай AI-шаблон и назначь его на CRM-статус в задаче'
      : 'Выбери в списке или создай новый';
  }
  if (emptyBtn) emptyBtn.textContent = (tplCategory === 'job' || tplCategory === 'presets') ? '+ AI-шаблон' : '+ Создать';
  if (emptyWrap && tplCategory === 'job') {
    emptyWrap.hidden = true;
  } else if (emptyWrap && !tplEditId) {
    emptyWrap.hidden = false;
  }
  if (tplCategory === 'job') {
    renderTplJobHeader();
    syncTplModeButtons();
    syncTplJobAttachBar();
    renderTplFunnelBoard();
  } else {
    const fb = document.getElementById('tplFunnelBoard');
    if (fb) fb.hidden = true;
  }
  _tplMoveJobRuleFields();
  syncTplCategoryEditorFields();
  const scopeFilterEl = document.getElementById('tplScopeFilter');
  if (scopeFilterEl) {
    // Фильтр «Все/Глобальные/По задаче» убран как лишний — глобальные шаблоны скрыты.
    scopeFilterEl.hidden = true;
  }
  const presetFiltersEl = document.getElementById('tplPresetFilters');
  if (presetFiltersEl) {
    presetFiltersEl.hidden = tplCategory !== 'presets';
    if (tplCategory === 'presets') _fillPresetJobSelect();
  }
  const scopeSelect = document.getElementById('tplScopeSelect');
  const scopeJobSelect = document.getElementById('tplScopeJobSelect');
  if (scopeSelect && (tplCategory === 'presets' || tplCategory === 'statuses')) {
    if (!_tplJobFilter) _tplJobFilter = (scopeSelect.value || '').trim().toLowerCase();
    scopeSelect.value = _tplJobFilter || '';
  }
  if (scopeJobSelect && (tplCategory === 'presets' || tplCategory === 'statuses')) {
    _fillScopeJobSelect(scopeJobSelect);
    scopeJobSelect.hidden = (_tplJobFilter || '') !== 'job';
    _tplPresetFilterJobId = scopeJobSelect.value ? Number(scopeJobSelect.value) : null;
  }
}

function syncTplCategoryEditorFields() {
  const showRules = tplCategory === 'global' || tplCategory === 'job';
  const trigWrap = document.getElementById('tplTrigWrap');
  const afterWrap = document.getElementById('tplAfterWrap');
  const modeWrap = document.getElementById('tplArModeWrap');
  const pbRow = document.getElementById('tplUseSalesPlaybookRow');
  if (trigWrap) trigWrap.hidden = !showRules;
  if (afterWrap) afterWrap.hidden = !showRules;
  if (modeWrap) modeWrap.hidden = true;
  if (pbRow) pbRow.hidden = !_tplIsJobLike();
  _syncTplArModeUi();
  syncTplModeButtons();
  syncTplJobAttachBar();
}

function _tplIsJobLike() {
  return tplCategory === 'job' || tplCategory === 'presets';
}

function _tplActiveJobIdForSave() {
  if (tplCategory === 'job') return tplJobId ? Number(tplJobId) : null;
  if (tplCategory === 'presets') {
    const { scope, selectedJobId } = _tplScopeState();
    if (scope === 'job' && selectedJobId) return Number(selectedJobId);
  }
  return tplJobId ? Number(tplJobId) : null;
}

function _tplArMode() {
  if (_tplIsJobLike()) return 'ai';
  const el = document.getElementById('tplArMode');
  return (el?.value || 'text').trim().toLowerCase() === 'ai' ? 'ai' : 'text';
}

function _syncTplArModeUi() {
  const isJob = _tplIsJobLike();
  const isAi = isJob && _tplArMode() === 'ai';
  // closest() вместо :has() — старый Chromium в QtWebEngine не понимает :has(#id)
  // и валит код с querySelector InvalidSelectorError. closest работает везде.
  const textLbl = document.getElementById('tplDraftText')?.closest('label.lead-field.tpl-field-compact');
  if (textLbl) {
    const cap = textLbl.firstChild;
    if (cap) cap.textContent = isAi ? 'Справка ' : 'Текст / справка ';
  }
  const promptLbl = document.getElementById('tplAiPrompt')?.closest('label.lead-field.tpl-field-compact');
  if (promptLbl) {
    const cap = promptLbl.firstChild;
    if (cap) cap.textContent = isAi ? 'Промпт ' : 'Промпт (опц.) ';
  }
  const modelEl = document.getElementById('tplAiModel');
  const varEl = document.getElementById('tplAiVariantCount');
  const aiBtn = document.getElementById('btnTplAiGen');
  const spintaxBtn = document.getElementById('btnTplSpintaxGen');
  const clearBtn = document.getElementById('btnTplClearVariants');
  const pbRow = document.getElementById('tplUseSalesPlaybookRow');
  const variantsPane = document.getElementById('tplVariantsResult');
  const genHidden = isJob && isAi;
  const toolbarMid = document.getElementById('tplToolbarMid');
  if (toolbarMid) toolbarMid.hidden = genHidden;
  if (modelEl?.parentElement) modelEl.parentElement.hidden = genHidden;
  if (varEl?.parentElement) varEl.parentElement.hidden = genHidden;
  if (aiBtn) aiBtn.hidden = genHidden;
  if (spintaxBtn) spintaxBtn.hidden = genHidden;
  if (clearBtn) clearBtn.hidden = genHidden;
  if (pbRow) pbRow.hidden = !(isJob && isAi);
  if (variantsPane) variantsPane.hidden = genHidden;
  const draft = document.getElementById('tplDraftText');
  if (draft) {
    draft.placeholder = isAi
      ? 'Факты/цены/условия, которые ИИ может использовать в ответах'
      : 'Готовый фиксированный текст ответа';
  }
}

function renderTemplatesList(filter = '') {
  const el = document.getElementById('tplList');
  if (!el) return;
  const q = filter.trim().toLowerCase();
  const wantScope = _tplCategoryScope(tplCategory);
  const jobFilter = _tplJobFilter;
  let rows = tplCache.filter(t => {
    if (_tplScopeOf(t) !== wantScope) return false;
    if (tplCategory === 'presets') {
      if (_tplPresetMode === 'ai' && (t.ar_mode || 'text') !== 'ai') return false;
      if (_tplPresetMode === 'text' && (t.ar_mode || 'text') === 'ai') return false;
      if (_tplPresetJobId) {
        const owned = Number(t.job_id || 0) === _tplPresetJobId;
        const assigned = !!t.assigned_to_selected_job
          || (Array.isArray(t.assigned_job_ids) && t.assigned_job_ids.includes(_tplPresetJobId));
        if (!owned && !assigned) return false;
      } else {
        if (_tplPresetScope === 'global' && t.job_id) return false;
        if (_tplPresetScope === 'job' && !t.job_id) return false;
      }
    }
    return !q || (t.title || '').toLowerCase().includes(q) || (t.text || '').toLowerCase().includes(q);
  });
  if (tplCategory === 'presets') {
    rows.sort((a, b) => {
      if (_tplPresetSort === 'az') return (a.title || '').localeCompare(b.title || '', 'ru');
      if (_tplPresetSort === 'old') return Number(a.id) - Number(b.id);
      return Number(b.id) - Number(a.id);
    });
  }
  const countEl = document.getElementById('tplPresetCount');
  if (countEl) countEl.textContent = tplCategory === 'presets' ? `${rows.length} шт.` : '';
  if (!rows.length) {
    const emptyByCat = {
      global: 'Нет глобальных — жми «+ Создать»',
      job: 'Нет AI-шаблонов — создай новый',
      presets: 'Библиотека AI-шаблонов пуста — жми «+ AI-шаблон»',
      private: 'Нет приватных шаблонов',
    };
    el.innerHTML = `<div class="empty">${emptyByCat[tplCategory] || 'Пусто'}</div>`;
    return;
  }
  const showEnable = tplCategory === 'global' || tplCategory === 'job';
  el.innerHTML = rows.map(t => {
    const active = tplCategory === 'global'
      ? _tplIsGlobalActive(t)
      : _tplIsJobActive(t, tplJobId);
    const isAi = (t.ar_mode || 'text') === 'ai';
    const modeBadge = tplCategory === 'job'
      ? `<span class="tpl-preset-badge ${isAi ? 'tpl-preset-badge-ai' : 'tpl-preset-badge-text'}">${isAi ? 'ИИ' : 'текст'}</span>`
      : '';
    const isAssignedLegacy = !t.job_id && !!t.assigned_to_selected_job;
    const scopeBadge = t.job_id
      ? `<span class="tpl-preset-badge tpl-preset-badge-job">задача</span>`
      : (isAssignedLegacy
        ? `<span class="tpl-preset-badge tpl-preset-badge-job">назначен в задаче</span>`
        : (tplCategory === 'presets' ? `<span class="tpl-preset-badge tpl-preset-badge-global">глобальный</span>` : ''));
    const pbEffective = _tplEffectivePlaybookForTemplate(t.id);
    const playbookBadge = (tplCategory === 'job' && isAi)
      ? `<span class="tpl-preset-badge ${pbEffective ? 'tpl-preset-badge-on' : ''}">обучение: ${pbEffective ? 'on' : 'off'}</span>`
      : '';
    const onBadge = active && tplCategory === 'job'
      ? `<span class="tpl-preset-badge tpl-preset-badge-on">активен</span>${playbookBadge}`
      : playbookBadge;
    const meta = tplCategory === 'job'
      ? `<span class="tpl-preset-meta">при «${_tplStatusLabel(t.ar_trigger_status || 'new')}»` +
        `${t.ar_after_status ? ` → «${_tplStatusLabel(t.ar_after_status)}»` : ''}</span>`
      : (tplCategory === 'presets' && Array.isArray(t.selected_job_trigger_statuses) && t.selected_job_trigger_statuses.length
        ? `<span class="tpl-preset-meta">задача: ${esc(t.selected_job_name || ('#' + (_tplPresetFilterJobId || '')))} · статусы: ${esc(t.selected_job_trigger_statuses.join(', '))}</span>`
        : '');
    const preview = isAi
      ? (t.ai_prompt || t.text || '').replace(/\s+/g, ' ').slice(0, 72)
      : (t.text || '').replace(/\s+/g, ' ').slice(0, 72);
    const enableHtml = showEnable
      ? `<label class="tpl-list-enable" title="${tplCategory === 'job'
        ? (active ? 'Назначен в этой задаче' : 'Назначить на выбранный статус')
        : (active ? 'Выключить' : 'Включить')}">
           <input type="radio" name="tplActivePick" data-enable-id="${t.id}" ${active ? 'checked' : ''} />
         </label>`
      : '';
    return `<div class="tpl-list-item-wrap${active && tplCategory === 'job' ? ' active-job' : ''}">
      ${enableHtml}
      <button type="button" class="tpl-list-item ${tplEditId === t.id ? 'active' : ''}" data-id="${t.id}">
        <strong>${esc(t.title || 'Без названия')}${modeBadge}${scopeBadge}${onBadge}</strong>
        <span>${esc(preview)}</span>
        ${meta}
      </button>
    </div>`;
  }).join('');
  el.querySelectorAll('.tpl-list-item').forEach(btn => {
    btn.onclick = () => selectTemplateEditor(Number(btn.dataset.id));
  });
  el.querySelectorAll('.tpl-list-enable input[data-enable-id]').forEach(inp => {
    inp.addEventListener('click', e => e.stopPropagation());
    inp.addEventListener('click', async (e) => {
      const id = Number(inp.dataset.enableId);
      const t = _tplById(id);
      if (!t) return;
      const isActive = tplCategory === 'global'
        ? _tplIsGlobalActive(t)
        : _tplIsJobActive(t, tplJobId);
      if (!isActive) return;
      e.preventDefault();
      inp.checked = false;
      try {
        if (tplCategory === 'global') await disableGlobalTplActive();
        else await disableJobTplActive();
      } catch (err) {
        inp.checked = true;
        showToast(err.message || 'Ошибка', 'err');
      }
    }, true);
    inp.onchange = async () => {
      if (!inp.checked) return;
      const id = Number(inp.dataset.enableId);
      try {
        if (tplCategory === 'global') await setGlobalTplActive(id);
        else if (tplCategory === 'job') await setJobTplActive(id, true);
      } catch (err) {
        showToast(err.message || 'Ошибка', 'err');
        renderTemplatesList(document.getElementById('tplSearch')?.value || '');
      }
    };
  });
}

function syncTplGlobalAutoreplyToggle() {
  const el = document.getElementById('tplGlobalAutoreply');
  if (el) el.checked = tplSettings.autoresponder_enabled === '1';
}

function syncTemplateAutoreplyFields() {
  const trigEl = document.getElementById('tplAutoTriggerStatus');
  const afterEl = document.getElementById('tplAutoAfterStatus');
  const testEl = document.getElementById('tplTestReplyStatus');
  const tpl = tplEditId ? _tplById(tplEditId) : null;
  const statusOptions = _tplFlowStatusCatalog();
  fillLeadStatusSelect(trigEl, { selected: (tpl?.ar_trigger_status || 'new'), options: statusOptions });
  fillLeadStatusSelect(afterEl, { includeEmpty: true, selected: tpl?.ar_after_status || '', options: statusOptions });
  fillLeadStatusSelect(testEl, { selected: (tpl?.ar_trigger_status || 'work'), options: statusOptions });
  const testPbEl = document.getElementById('tplTestUsePlaybook');
  if (testPbEl) {
    const j = _tplJobForSelected(tplJobId);
    const jobPbSet = _tplIsJobLike() && j && j.use_sales_playbook !== null && j.use_sales_playbook !== undefined;
    testPbEl.checked = jobPbSet
      ? Number(j.use_sales_playbook || 0) === 1
      : Number(tpl?.use_sales_playbook || 0) === 1;
    testPbEl.disabled = !!jobPbSet;
    testPbEl.title = jobPbSet ? 'В тесте используется job-level настройка обучения продаж' : '';
  }
  syncTplCategoryEditorFields();
  if (typeof syncTplJobPlaybookHint === 'function') syncTplJobPlaybookHint();
}

function _tplUseModalEditor() {
  return tplCategory === 'job' || tplCategory === 'presets';
}

function _mountTplEditorHeadBar() {
  const headMount = document.getElementById('tplEditorModalHeadMount');
  const toolbarMain = document.querySelector('#tplEditorForm .tpl-toolbar-main');
  if (!headMount || !toolbarMain) return;
  if (toolbarMain.parentElement !== headMount) headMount.appendChild(toolbarMain);
}

function _restoreTplEditorHeadBar() {
  const headMount = document.getElementById('tplEditorModalHeadMount');
  const toolbar = document.querySelector('#tplEditorForm .tpl-toolbar');
  const toolbarMain = headMount?.querySelector('.tpl-toolbar-main')
    || document.querySelector('#tplEditorForm .tpl-toolbar-main');
  if (!toolbar || !toolbarMain || toolbarMain.parentElement === toolbar) return;
  toolbar.insertBefore(toolbarMain, toolbar.firstChild);
}

function _mountTplEditorToModal() {
  const formEl = document.getElementById('tplEditorForm');
  const mountEl = document.getElementById('tplEditorModalMount');
  if (!formEl || !mountEl) return;
  if (formEl.parentElement !== mountEl) mountEl.appendChild(formEl);
  formEl.hidden = false;
  _mountTplEditorHeadBar();
}

function _restoreTplEditorToDock() {
  _restoreTplEditorHeadBar();
  const formEl = document.getElementById('tplEditorForm');
  const dockEl = document.getElementById('tplEditorDock');
  const mountEl = document.getElementById('tplEditorModalMount');
  if (!formEl || !dockEl || !mountEl) return;
  if (formEl.parentElement === mountEl) dockEl.appendChild(formEl);
  const toolbar = document.querySelector('#tplEditorForm .tpl-toolbar');
  if (toolbar) toolbar.hidden = false;
  if (_tplUseModalEditor()) formEl.hidden = true;
}

function _openTplEditorModal() {
  if (!_tplUseModalEditor()) return;
  openModal('modalTplEditor');
  _mountTplEditorToModal();
}

function _setTplEditorMode(mode) {
  const empty = mode !== 'edit';
  const jobModal = _tplUseModalEditor();
  const panel = document.getElementById('tplEditorPanel');
  if (panel) panel.dataset.editorMode = jobModal ? 'job' : (empty ? 'empty' : 'edit');
  const emptyEl = document.getElementById('tplEditorEmpty');
  const formEl = document.getElementById('tplEditorForm');
  if (emptyEl) emptyEl.hidden = jobModal ? true : !empty;
  if (formEl) formEl.hidden = jobModal ? true : empty;
  if (!jobModal) _restoreTplEditorToDock();
}

function _tplById(id) {
  return tplCache.find(x => String(x.id) === String(id));
}

function showTemplateEmptyState() {
  tplEditId = null;
  _tplEditorCreating = false;
  try { sessionStorage.removeItem(TPL_LAST_ID_KEY); } catch (_) {}
  _setTplEditorMode('empty');
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
}

function _showTemplateEditorForm() {
  _setTplEditorMode('edit');
  if (_tplUseModalEditor()) _openTplEditorModal();
}

function syncTplJobPlaybookHint() {
  const hint = document.getElementById('tplJobPlaybookEffectiveHint');
  const pbEl = document.getElementById('tplUseSalesPlaybook');
  const label = document.getElementById('tplUseSalesPlaybookLabel');
  const j = _tplJobForSelected(tplJobId);
  const isJobAi = _tplIsJobLike() && _tplArMode() === 'ai';
  const jobPbSet = j && j.use_sales_playbook !== null && j.use_sales_playbook !== undefined;
  const tpl = tplEditId ? _tplById(tplEditId) : null;
  if (hint) {
    if (isJobAi && jobPbSet) {
      const on = Number(j.use_sales_playbook || 0) === 1;
      hint.hidden = false;
      hint.textContent = `Обучение продаж ${on ? 'включено' : 'выключено'} на уровне задачи. Эта настройка важнее старой галки шаблона.`;
    } else {
      hint.hidden = true;
      hint.textContent = '';
    }
  }
  if (pbEl) {
    if (isJobAi && jobPbSet) {
      const on = Number(j.use_sales_playbook || 0) === 1;
      pbEl.checked = on;
      pbEl.disabled = true;
      pbEl.title = 'Управляется переключателем обучения продаж в конструкторе задачи';
    } else {
      pbEl.checked = Number(tpl?.use_sales_playbook || 0) === 1;
      pbEl.disabled = false;
      pbEl.title = '';
    }
  }
  if (label) {
    if (isJobAi && jobPbSet) {
      label.textContent = Number(j.use_sales_playbook || 0) === 1
        ? 'Обучение продаж включено задачей'
        : 'Обучение продаж выключено задачей';
    } else {
      label.textContent = 'Использовать обучение продаж';
    }
  }
}

function selectTemplateEditor(id) {
  const t = _tplById(id);
  if (!t) return;
  const text = t.text || '';
  tplEditId = Number(id);
  _tplEditorCreating = false;
  try { sessionStorage.setItem(TPL_LAST_ID_KEY, String(tplEditId)); } catch (_) {}
  _showTemplateEditorForm();
  document.getElementById('tplEditTitle').value = t.title || '';
  document.getElementById('btnTplDelete').hidden = false;
  _tplVariantsFromEditor(text);
  const promptEl = document.getElementById('tplAiPrompt');
  if (promptEl) promptEl.value = String(t.ai_prompt ?? '').trim();
  const modeEl = document.getElementById('tplArMode');
  if (modeEl) modeEl.value = (t.ar_mode || 'text') === 'ai' ? 'ai' : 'text';
  const pbEl = document.getElementById('tplUseSalesPlaybook');
  if (pbEl) pbEl.checked = Number(t.use_sales_playbook || 0) === 1;
  syncTemplateAutoreplyFields();
  syncTplJobPlaybookHint();
  syncTplModeButtons();
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
}

function newTemplateEditor() {
  tplEditId = null;
  _tplEditorCreating = true;
  try { sessionStorage.removeItem(TPL_LAST_ID_KEY); } catch (_) {}
  _tplOriginalDraft = '';
  _showTemplateEditorForm();
  document.getElementById('tplEditTitle').value = '';
  document.getElementById('btnTplDelete').hidden = true;
  clearTplAiVariants(false);
  const promptEl = document.getElementById('tplAiPrompt');
  if (promptEl) promptEl.value = '';
  const modeEl = document.getElementById('tplArMode');
  if (modeEl) modeEl.value = _tplIsJobLike() ? 'ai' : 'text';
  const pbEl = document.getElementById('tplUseSalesPlaybook');
  if (pbEl) pbEl.checked = false;
  if (_pendingFunnelAssignStatus) {
    const stLabel = (_statusById(_pendingFunnelAssignStatus)?.label || _pendingFunnelAssignStatus || '').toLowerCase();
    if (pbEl && stLabel.includes('возраж')) pbEl.checked = true;
    const trig = document.getElementById('tplAutoTriggerStatus');
    if (trig && (_tplFlowStatusCatalog() || []).some(x => x.value === _pendingFunnelAssignStatus)) {
      trig.value = _pendingFunnelAssignStatus;
    }
  }
  syncTemplateAutoreplyFields();
  syncTplJobPlaybookHint();
  syncTplModeButtons();
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  document.getElementById('tplEditTitle').focus();
}

function _setTplSaveBusy(busy) {
  const label = busy ? '⏳ Сохранение…' : '💾 Сохранить';
  for (const id of ['btnTplSaveTop']) {
    const b = document.getElementById(id);
    if (!b) continue;
    b.disabled = busy;
    b.textContent = label;
  }
}

async function saveTemplateEditor() {
  const title = document.getElementById('tplEditTitle').value.trim();
  const text = _tplTextForSave().trim();
  const arMode = _tplArMode();
  if (!title) {
    showToast('Нужно название шаблона', 'err');
    return;
  }
  if (_tplIsJobLike() && arMode === 'ai') {
    const aiPromptDraft = (document.getElementById('tplAiPrompt')?.value || '').trim();
    if (!aiPromptDraft) {
      showToast('Для ИИ-агента нужен промпт', 'err');
      return;
    }
  } else if (!text) {
    showToast('Нужен текст (отметь ✓ варианты или введи в поле)', 'err');
    return;
  }
  let aiPrompt = document.getElementById('tplAiPrompt')?.value ?? '';
  if (!aiPrompt.trim() && tplEditId) {
    const prev = _tplById(tplEditId);
    if (prev?.ai_prompt?.trim()) aiPrompt = prev.ai_prompt;
  }
  const arTrigger = (_pendingFunnelAssignStatus
    || document.getElementById('tplAutoTriggerStatus')?.value
    || 'new');
  const arAfter = document.getElementById('tplAutoAfterStatus')?.value || '';
  const arScope = _tplCategoryScope(tplCategory);
  const prev = tplEditId ? _tplById(tplEditId) : null;
  const tplJob = _tplJobForSelected(tplJobId);
  const jobPbSet = _tplIsJobLike() && tplJob && tplJob.use_sales_playbook !== null && tplJob.use_sales_playbook !== undefined;
  const usePlaybook = jobPbSet
    ? Number(prev?.use_sales_playbook || 0) === 1
    : !!document.getElementById('tplUseSalesPlaybook')?.checked;
  let arEnabled = false;
  if (arScope === 'global') {
    arEnabled = prev ? Number(prev.ar_enabled) === 1 : false;
  }
  _setTplSaveBusy(true);
  try {
    const tplPayload = {
      title, text, ai_prompt: aiPrompt,
      ar_trigger_status: arTrigger,
      ar_after_status: arAfter,
      ar_enabled: arEnabled,
      ar_scope: arScope,
      ar_mode: _tplIsJobLike() ? arMode : 'text',
      use_sales_playbook: _tplIsJobLike() && arMode === 'ai' ? usePlaybook : false,
    };
    const activeJobId = _tplActiveJobIdForSave();
    if (_tplIsJobLike() && activeJobId) tplPayload.job_id = activeJobId;
    if (tplEditId) {
      await j(`/api/templates/${tplEditId}`, {
        method: 'PUT', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(tplPayload),
      });
    } else {
      const r = await j('/api/templates', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(tplPayload),
      });
      tplEditId = r.id;
    }
    const aiModel = document.getElementById('tplAiModel')?.value ?? '';
    if (aiModel !== (tplSettings.autoreply_ai_model || '')) {
      try {
        await j('/api/settings', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ autoreply_ai_model: aiModel }),
        });
        tplSettings = await j('/api/settings');
      } catch (_) {}
    }
    _tplEditorCreating = false;
    await loadTemplates();
    const pendingStatus = _pendingFunnelAssignStatus;
    _pendingFunnelAssignStatus = '';
    if (pendingStatus && tplJobId && _tplScopeOf(_tplById(tplEditId)) === 'job') {
      try {
        await _assignPresetToStatus(tplEditId, pendingStatus);
      } catch (err) {
        showToast(err.message || 'Не удалось назначить на статус', 'err');
      }
      closeModals();
    } else {
      selectTemplateEditor(tplEditId);
      if (tplCategory === 'job') renderTplJobHeader();
    }
    const catNote = tplCategory === 'global' ? ' · глобальный'
      : (tplCategory === 'job' || tplCategory === 'presets') ? ' · для рассылки' : ' · приватный';
    showToast(`Сохранено${catNote}`);
  } finally {
    _setTplSaveBusy(false);
  }
}

async function deleteTemplateEditor() {
  if (!tplEditId) return;
  const deletedId = tplEditId;
  if (!confirm('Удалить этот шаблон?')) return;
  const wasActiveGlobal = tplCategory === 'global' && _tplIsGlobalActive(_tplById(deletedId));
  try {
    await j(`/api/templates/${deletedId}`, { method: 'DELETE' });
  } catch (e) {
    // backend возвращает 400 если шаблон используется в активной рассылке
    showToast(e.message || 'Не удалось удалить шаблон', 'err', { duration: 8000 });
    return;
  }
  tplEditId = null;
  await loadTemplates();
  if (tplJobId) await _reloadTplJobRulesOnly();
  showTemplateEmptyState();
  renderTplJobHeader();
  renderTplFunnelBoard();
  if (wasActiveGlobal && tplSettings.autoresponder_enabled === '1') {
    const anyGlobal = tplCache.some(t => _tplIsGlobalActive(t));
    if (!anyGlobal) {
      showToast('Шаблон удалён · глобальный авто больше не к чему привязать', 'err');
      return;
    }
  }
  showToast('Шаблон удалён');
}

async function loadTplAiModels() {
  const sel = document.getElementById('tplAiModel');
  if (!sel) return;
  const saved = tplSettings.autoreply_ai_model || tplSettings.freemodel_model_openai || '';
  try {
    const r = await j('/api/ai/models?provider=openai');
    const models = r.models || [];
    sel.innerHTML = '<option value="">По умолчанию (gpt-5.4)</option>' +
      models.map(m => `<option value="${esc(m)}">${esc(m)}</option>`).join('');
    if (saved && [...sel.options].some(o => o.value === saved)) sel.value = saved;
  } catch (_) {
    sel.innerHTML = '<option value="">По умолчанию (gpt-5.4)</option>';
    if (saved) sel.innerHTML += `<option value="${esc(saved)}">${esc(saved)}</option>`;
    sel.value = saved;
  }
}

function _tplResolveSpintax(text) {
  let result = String(text || '');
  let iter = 0;
  while (result.includes('{') && iter < 20) {
    result = result.replace(/\{([^{}]+)\}/g, (_, p) => {
      const opts = p.split('|').map(s => s.trim()).filter(Boolean);
      return opts[Math.floor(Math.random() * opts.length)] || '';
    });
    iter++;
  }
  return result.replace(/\s+/g, ' ').trim();
}

async function generateTemplateSpintax() {
  const plain = _tplSpintaxPlainSource();
  if (!plain) {
    showToast('Введи текст в поле «Текст сообщения»', 'err');
    return;
  }
  const variantCount = Math.min(15, Math.max(3, parseInt(document.getElementById('tplAiVariantCount')?.value || '5', 10) || 5));
  _tplAiStatusHtml = '';
  const btn = document.getElementById('btnTplSpintaxGen');
  const prev = btn ? btn.textContent : '';
  if (btn) { btn.disabled = true; btn.textContent = '⏳ Spintax…'; }
  try {
    let enriched = plain;
    try {
      enriched = (await j('/api/spintax/enrich', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: plain, max_blocks: 25 }),
      })).text || plain;
    } catch (_) {}
    const pool = [];
    const seen = new Set();
    const push = (v) => {
      const t = String(v || '').trim();
      if (!t) return;
      const k = _tplVariantKey(t);
      if (seen.has(k)) return;
      seen.add(k);
      pool.push(t);
    };
    for (let i = 0; i < variantCount * 6 && pool.length < variantCount; i++) {
      push(_tplResolveSpintax(enriched));
    }
    if (pool.length < variantCount) {
      const pv = await j('/api/spintax/preview', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: enriched, count: variantCount, auto: true }),
      });
      for (const v of (pv.variants || [])) push(v);
    }
    if (!pool.length) throw new Error('Не удалось собрать варианты');
    const pinned = _selectedTplVariants().length;
    _replaceUnselectedTplVariants(pool.slice(0, variantCount));
    _renderTplAiPreview(_tplAiVariants, `Spintax · закреплено: ${pinned} · новых: ${Math.max(0, pool.length - pinned)}`);
    showToast(`Spintax: ${pool.length} вариантов — отметь ✓`);
  } catch (e) {
    _tplShowAiStatus(`<span style="color:#fc8181;">❌ ${esc(e.message || 'Spintax не сработал')}</span>`);
    showToast(e.message || 'Spintax не сработал', 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = prev || 'Spintax варианты'; }
  }
}

async function generateTemplateWithAi() {
  if (_tplAiGenerating) return;
  const plain = _tplCapturePlainSource();
  const title = document.getElementById('tplEditTitle').value.trim();
  const draft = plain || title || 'Привет! Клиент написал первым — нужен короткий автоответ.';

  const btn = document.getElementById('btnTplAiGen');
  const prev = btn ? btn.textContent : '';
  _tplAiGenerating = true;
  if (btn) { btn.disabled = true; btn.textContent = '⏳ Генерация…'; }

  let prompt = (document.getElementById('tplAiPrompt')?.value || '').trim();
  const model = document.getElementById('tplAiModel')?.value || null;
  const variantCount = Math.min(15, Math.max(1, parseInt(document.getElementById('tplAiVariantCount')?.value || '5', 10) || 5));
  const pinned = _selectedTplVariants();
  if (pinned.length) {
    prompt += `\n\nУже закреплённые варианты, НЕ повторяй их и не делай похожие:\n` +
      pinned.map((v, i) => `${i + 1}. ${v}`).join('\n');
  }

  _tplShowAiStatus(`<span style="color:#b794f6;">⏳ Запрос к ${esc(model || 'gpt-5.4')}: ${variantCount} вариантов… до 90 сек</span>`);

  try {
    const r = await j('/api/spintax/ai-generate', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: draft,
        user_prompt: prompt,
        model,
        style: 'business',
        count: variantCount,
      }),
    });
    const rawVariants = Array.isArray(r.variants) ? r.variants : [];
    if (!rawVariants.length && r.spintax) rawVariants.push(r.spintax);
    if (!rawVariants.length) throw new Error('Пустой ответ AI');
    const selectedBefore = _selectedTplVariants().length;
    _replaceUnselectedTplVariants(rawVariants);
    const usage = r.usage || {};
    const uline = usage.total_tokens ? ` · ${usage.total_tokens} токенов` : '';
    const newPool = Math.max(0, _tplAiVariants.length - selectedBefore);
    const meta = `AI ${r.model || model || 'gpt-5.4'}${uline} · закреплено: ${selectedBefore} · новых: ${newPool}`;
    _tplAiStatusHtml = '';
    _renderTplAiPreview(_tplAiVariants, meta);
    showToast('Варианты готовы — отметь ✓ и жми «Сохранить шаблон»');
  } catch (e) {
    _tplShowAiStatus(`<span style="color:#fc8181;font-weight:600;">❌ ${esc(e.message || 'AI не ответил')}</span>`);
    showToast(e.message || 'AI не ответил', 'err');
  } finally {
    _tplAiGenerating = false;
    if (btn) { btn.disabled = false; btn.textContent = prev || '🧠 AI: варианты'; }
  }
}

async function loadTplJobs() {
  try {
    const r = await j('/api/broadcast/jobs');
    tplJobsCache = r.jobs || r || [];
  } catch (_) {
    tplJobsCache = [];
  }
  const sel = document.getElementById('tplJobPick');
  if (!sel) return;
  if (!tplJobsCache.length) {
    sel.innerHTML = '<option value="">— нет задач —</option>';
    tplJobId = null;
    return;
  }
  sel.innerHTML = tplJobsCache.map(j =>
    `<option value="${j.id}">${esc(j.name || `Задача #${j.id}`)}</option>`
  ).join('');
  let keep = tplJobId;
  if (!keep) {
    try {
      const saved = sessionStorage.getItem(TPL_JOB_KEY);
      if (saved) keep = Number(saved);
    } catch (_) {}
  }
  if (keep && tplJobsCache.some(j => String(j.id) === String(keep))) {
    sel.value = String(keep);
    tplJobId = Number(keep);
  } else {
    tplJobId = Number(tplJobsCache[0].id);
    sel.value = String(tplJobId);
  }
  try { sessionStorage.setItem(TPL_JOB_KEY, String(tplJobId || '')); } catch (_) {}
  await loadTplJobRules();
  if (tplCategory === 'job') renderTplJobHeader();
}

async function loadTplJobRules() {
  if (!tplJobId) {
    tplJobRulesCache = [];
    tplJobTransitionsCache = [];
    tplJobStatusOptions = [];
    renderTplJobHeader();
    return;
  }
  await _reloadTplJobStatuses();
  await _reloadTplJobRulesOnly();
  const prevTr = Array.isArray(tplJobTransitionsCache) ? tplJobTransitionsCache : [];
  try {
    const tr = await j(`/api/broadcast/jobs/${tplJobId}/transitions`);
    tplJobTransitionsCache = _normalizeJobTransitions(tr.transitions || []);
    _tplDebug('load transitions', { job_id: tplJobId, transitions: tplJobTransitionsCache });
  } catch (_) {
    tplJobTransitionsCache = prevTr;
  }
  renderTplJobHeader();
}

async function _reloadTplJobRulesOnly() {
  if (!tplJobId) {
    tplJobRulesCache = [];
    return;
  }
  const prev = Array.isArray(tplJobRulesCache) ? tplJobRulesCache : [];
  try {
    const r = await j(`/api/broadcast/jobs/${tplJobId}/autoreply-rules`);
    _mergeJobRulesCache(Array.isArray(r.rules) ? r.rules : prev);
  } catch (_) {
    const job = _tplJobForSelected(tplJobId);
    const fromJob = Array.isArray(job?.autoreply_rules) ? job.autoreply_rules : [];
    _mergeJobRulesCache(fromJob.length >= prev.length ? fromJob : prev);
  }
}

async function _reloadTplJobTransitions() {
  if (!tplJobId) {
    tplJobTransitionsCache = [];
    return;
  }
  const prev = Array.isArray(tplJobTransitionsCache) ? tplJobTransitionsCache : [];
  try {
    const tr = await j(`/api/broadcast/jobs/${tplJobId}/transitions`);
    tplJobTransitionsCache = _normalizeJobTransitions(tr.transitions || []);
    _tplDebug('reload transitions', { job_id: tplJobId, transitions: tplJobTransitionsCache });
  } catch (_) {
    tplJobTransitionsCache = prev;
  }
}

async function _reloadTplJobStatuses() {
  if (!tplJobId) {
    tplJobStatusOptions = [];
    return;
  }
  const prev = Array.isArray(tplJobStatusOptions) ? tplJobStatusOptions : [];
  try {
    const r = await j(`/api/broadcast/jobs/${tplJobId}/statuses`);
    const rows = Array.isArray(r.statuses) ? r.statuses : [];
    tplJobStatusOptions = rows
      .map(s => ({
        value: _normStatusId(s.status_key || s.id || ''),
        label: s.title || s.label || s.status_key || s.id || '',
        description: s.description || '',
        color: s.color || '#b8a8ff',
        favorite: false,
        builtin: false,
      }))
      .filter(s => s.value);
  } catch (_) {
    tplJobStatusOptions = prev;
  }
}

async function saveTplJobRules(rules) {
  if (!tplJobId) throw new Error('Сначала выбери задачу');
  _tplDebug('save binding payload', { job_id: tplJobId, rules });
  const r = await j(`/api/broadcast/jobs/${tplJobId}/autoreply-rules`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ rules }),
  });
  _tplDebug('save binding response', r);
  if (Array.isArray(r.rules)) _mergeJobRulesCache(r.rules);
  else await _reloadTplJobRulesOnly();
  if (r.job) {
    const idx = tplJobsCache.findIndex(j => String(j.id) === String(tplJobId));
    if (idx >= 0) tplJobsCache[idx] = r.job;
    else tplJobsCache.unshift(r.job);
  }
  renderTplJobHeader();
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  return r;
}

function openJobAutoreplySetup(jobId) {
  const jid = Number(jobId) || null;
  if (jid) {
    tplJobId = jid;
    try { sessionStorage.setItem(TPL_JOB_KEY, String(jid)); } catch (_) {}
  }
  if (typeof switchTab === 'function') switchTab('templates');
  switchTplCategory('job');
  loadTplJobs().then(async () => {
    if (jid) {
      const sel = document.getElementById('tplJobPick');
      if (sel) sel.value = String(jid);
      tplJobId = jid;
    }
    await loadTplJobRules();
    renderTplJobHeader();
    renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  }).catch(() => {});
}
window.openJobAutoreplySetup = openJobAutoreplySetup;

async function switchTplCategory(cat) {
  if (cat === 'agent') {
    tplCategory = 'agent';
    try { sessionStorage.setItem(TPL_CATEGORY_KEY, tplCategory); } catch (_) {}
    setTemplatesPlaybookMode(false);
    showTplStatusesMode(false);
    showTplAgentMode(true);
    return;
  }
  if (cat === 'statuses') {
    tplCategory = 'statuses';
    try { sessionStorage.setItem(TPL_CATEGORY_KEY, tplCategory); } catch (_) {}
    showTplAgentMode(false);
    setTemplatesPlaybookMode(false);
    showTplStatusesMode(true);
    return;
  }
  if (cat === 'playbook') {
    tplCategory = 'playbook';
    try { sessionStorage.setItem(TPL_CATEGORY_KEY, tplCategory); } catch (_) {}
    showTplAgentMode(false);
    showTplStatusesMode(false);
    setTemplatesPlaybookMode(true);
    return;
  }
  showTplAgentMode(false);
  showTplStatusesMode(false);
  setTemplatesPlaybookMode(false);
  tplCategory = cat || 'job';
  try { sessionStorage.setItem(TPL_CATEGORY_KEY, tplCategory); } catch (_) {}
  if (tplCategory === 'job') await loadTplJobRules();
  if (tplCategory === 'presets') {
    const req = _tplTemplateScopeRequest();
    await loadTemplates({ scope: req.scope, jobId: req.jobId });
  }
  _syncTplCategoryUi();
  const cur = tplEditId ? _tplById(tplEditId) : null;
  if (!cur || _tplScopeOf(cur) !== _tplCategoryScope(tplCategory)) {
    showTemplateEmptyState();
  }
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
}

async function _tplPayloadFromRow(t) {
  const arScope = _tplArScopeForApi(t);
  const payload = {
    title: t.title || '',
    text: t.text || '',
    ai_prompt: t.ai_prompt || '',
    ar_trigger_status: t.ar_trigger_status || 'new',
    ar_after_status: t.ar_after_status || '',
    ar_enabled: Number(t.ar_enabled) === 1,
    ar_scope: arScope,
    ar_mode: (t.ar_mode || 'text').trim().toLowerCase(),
    use_sales_playbook: Number(t.use_sales_playbook || 0) === 1,
  };
  const jid = Number(t.job_id || 0) || _tplActiveJobIdForSave();
  if (arScope === 'job' && jid) payload.job_id = jid;
  return payload;
}

async function disableGlobalTplActive() {
  const active = tplCache.find(t => _tplIsGlobalActive(t));
  if (active) {
    const body = await _tplPayloadFromRow(active);
    body.ar_enabled = false;
    body.ar_scope = 'global';
    await j(`/api/templates/${active.id}`, {
      method: 'PUT', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
  }
  if (tplSettings.autoresponder_enabled === '1') {
    await j('/api/settings', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ autoresponder_enabled: '0' }),
    });
    tplSettings.autoresponder_enabled = '0';
    syncTplGlobalAutoreplyToggle();
  }
  await loadTemplates();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  showToast('Глобальный автоответ выключен');
}

async function disableJobTplActive() {
  if (!tplJobId) {
    showToast('Сначала выбери задачу', 'err');
    return;
  }
  await saveTplJobRules([]);
  showToast('Автоответ в задаче выключен');
}

async function setGlobalTplActive(tplId) {
  const target = _tplById(tplId);
  if (!target || _tplScopeOf(target) !== 'global') return;
  const body = await _tplPayloadFromRow(target);
  body.ar_enabled = true;
  body.ar_scope = 'global';
  await j(`/api/templates/${tplId}`, {
    method: 'PUT', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (tplSettings.autoresponder_enabled !== '1') {
    await j('/api/settings', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ autoresponder_enabled: '1' }),
    });
    tplSettings.autoresponder_enabled = '1';
    syncTplGlobalAutoreplyToggle();
  }
  await loadTemplates();
  if (tplEditId === tplId) selectTemplateEditor(tplId);
  else renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  showToast('Глобальный автоответ включён');
}

async function setJobTplActive(tplId, turnOn = true, statusOverride = '') {
  if (!tplJobId) {
    showToast('Сначала выбери задачу', 'err');
    return;
  }
  const target = _tplById(tplId);
  if (!target) {
    showToast('AI-шаблон не найден — обнови страницу', 'err');
    return;
  }
  if (_tplScopeOf(target) !== 'job') {
    showToast('AI-шаблон должен быть из библиотеки «AI-шаблоны»', 'err');
    return;
  }
  const status = (statusOverride != null && String(statusOverride).trim() !== '')
    ? _normStatusId(statusOverride)
    : (_normStatusId(document.getElementById('tplAutoTriggerStatus')?.value || target.ar_trigger_status || 'new') || 'new');
  const catalogIds = new Set([
    ..._tplFlowStatuses(),
    ...(_tplFlowStatusCatalog() || []).map(s => _normStatusId(s.value)),
  ]);
  if (!catalogIds.has(status)) {
    throw new Error(`Статус не найден: ${status}`);
  }
  _tplDebug('single binding payload', { job_id: tplJobId, status_id: status, template_id: Number(tplId) });
  pushFlowHistory(_loadTplFlowCanvasState());
  const r = await j(`/api/broadcast/jobs/${tplJobId}/autoreply-rules/${encodeURIComponent(status)}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ template_id: Number(tplId), enabled: !!turnOn }),
  });
  _tplDebug('single binding response', r);
  if (Array.isArray(r.rules)) _mergeJobRulesCache(r.rules);
  if (r.job) {
    const idx = tplJobsCache.findIndex(j => String(j.id) === String(tplJobId));
    if (idx >= 0) tplJobsCache[idx] = r.job;
    else tplJobsCache.unshift(r.job);
    if (Array.isArray(r.job.autoreply_rules)) _mergeJobRulesCache(r.job.autoreply_rules);
  }
  let hasSavedRule = _hasJobRuleBinding(tplJobRulesCache, status, tplId);
  if (turnOn && !hasSavedRule) {
    await _reloadTplJobRulesOnly();
    hasSavedRule = _hasJobRuleBinding(tplJobRulesCache, status, tplId);
  }
  if (turnOn && !hasSavedRule) {
    if (r?.ok && _normStatusId(r.status) === status) {
      _mergeJobRulesCache([
        ...(tplJobRulesCache || []).filter(rule => _normStatusId(rule.trigger_status) !== status),
        {
          job_id: tplJobId,
          template_id: Number(tplId),
          trigger_status: status,
          enabled: 1,
          template_title: target.title || `AI-шаблон #${tplId}`,
        },
      ]);
    } else {
      throw new Error('Не удалось сохранить привязку AI-шаблона — обнови страницу и попробуй снова');
    }
  }
  renderTplJobHeader();
  renderTplFunnelBoard();
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  showToast(turnOn ? `AI-шаблон назначен на «${_tplStatusLabel(status)}»` : 'AI-шаблон снят со статуса');
}

async function removeJobRule(status) {
  const st = _normStatusId(status);
  if (!st) return;
  // Запомним текущий шаблон, чтобы «Снять» можно было откатить.
  const prevRule = (tplJobRulesCache || []).find(
    r => _normStatusId(r.trigger_status) === st
  );
  const prevTplId = prevRule ? Number(prevRule.template_id) : 0;
  const label = _tplStatusLabel(st);
  if (!confirm(
    `Снять AI-шаблон со статуса «${label}»?\n\n`
    + 'Сам статус останется на холсте — удалится только привязка AI-шаблона. '
    + 'Это не то же самое, что удаление статуса (Del).'
  )) return;
  pushFlowHistory(_loadTplFlowCanvasState());
  const r = await j(`/api/broadcast/jobs/${tplJobId}/autoreply-rules/${encodeURIComponent(st)}`, {
    method: 'DELETE',
  });
  if (Array.isArray(r.rules)) _mergeJobRulesCache(r.rules);
  else await _reloadTplJobRulesOnly();
  if (r.job) {
    const idx = tplJobsCache.findIndex(j => String(j.id) === String(tplJobId));
    if (idx >= 0) tplJobsCache[idx] = r.job;
    else tplJobsCache.unshift(r.job);
  }
  renderTplJobHeader();
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  // Откат: если знаем прежний шаблон — предложим вернуть одной кнопкой.
  if (prevTplId && _tplById(prevTplId)) {
    showToast(
      `AI-шаблон снят со статуса «${label}»`,
      'ok',
      {
        duration: 9000,
        action: {
          label: 'Вернуть',
          fn: () => setJobTplActive(prevTplId, true, st)
            .catch(err => showToast(err.message || 'Ошибка', 'err')),
        },
      },
    );
  } else {
    showToast(`AI-шаблон снят со статуса «${label}»`);
  }
}

async function loadTemplatesTab() {
  await loadLeadStatuses();
  try {
    const savedCat = sessionStorage.getItem(TPL_CATEGORY_KEY);
    // global/private скрыты как неиспользуемые — не восстанавливаем их, дефолт «Конструктор».
    if (savedCat && ['job', 'agent', 'presets', 'statuses', 'playbook'].includes(savedCat)) tplCategory = savedCat;
    else tplCategory = 'job';
  } catch (_) {}
  await loadTplJobs();
  if (tplCategory === 'agent') {
    switchTplCategory('agent');
    tabDataCache.templates = { at: Date.now() };
    return;
  }
  if (tplCategory === 'statuses') {
    switchTplCategory('statuses');
    tabDataCache.templates = { at: Date.now() };
    return;
  }
  if (tplCategory === 'playbook') {
    switchTplCategory('playbook');
    tabDataCache.templates = { at: Date.now() };
    return;
  }
  const settingsPromise = j('/api/settings');
  const modelsPromise = loadTplAiModels();
  if (tplCategory === 'job' && tplJobId) await loadTplJobRules();
  tplSettings = await settingsPromise;
  syncTplGlobalAutoreplyToggle();
  const statusOptions = _tplFlowStatusCatalog();
  fillLeadStatusSelect(document.getElementById('tplAutoTriggerStatus'), { options: statusOptions });
  fillLeadStatusSelect(document.getElementById('tplAutoAfterStatus'), { includeEmpty: true, options: statusOptions });
  await modelsPromise;
  const creating = _tplEditorCreating;
  await loadTemplates();
  _syncTplCategoryUi();
  if (creating) {
    _showTemplateEditorForm();
    const promptEl = document.getElementById('tplAiPrompt');
    if (promptEl) promptEl.value = '';
    renderTemplatesList(document.getElementById('tplSearch')?.value || '');
  } else {
    showTemplateEmptyState();
  }
  tabDataCache.templates = { at: Date.now() };
}

async function loadThread(opts = {}) {
  if (!selectedChat) return;
  const silent = opts.silent === true;
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  const isCurrent = () => selectedChatKey() === reqKey;
  const el = document.getElementById('messages');
  const wasAtBottom = silent && el && (el.scrollHeight - el.scrollTop - el.clientHeight < 80);
  try {
    let rows = await j(
      `/api/thread?account=${encodeURIComponent(chat.account_phone)}&peer_id=${chat.peer_id}`,
      { timeoutMs: 45000 },
    );
    if (!isCurrent()) return;
    const peerU = (chat.peer_username || '').toLowerCase();
    if (peerU.endsWith('bot') && (!rows.some(r => r.reply_markup) || rows.length < 3)) {
      try {
        await j('/api/bot/sync-recent', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            account_phone: chat.account_phone,
            peer_id: chat.peer_id,
            limit: 20,
          }),
        });
        if (!isCurrent()) return;
        rows = await j(`/api/thread?account=${encodeURIComponent(chat.account_phone)}&peer_id=${chat.peer_id}`);
        if (!isCurrent()) return;
      } catch (_) {}
    }
    if (!isCurrent()) return;
    if (!silent) {
      if (!isCurrent()) return;
      await j('/api/chats/read', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ account_phone: chat.account_phone, peer_id: chat.peer_id }),
      });
      if (!isCurrent()) return;
    }
    if (rows[0]) {
      if (rows[0].peer_username) selectedChat.peer_username = rows[0].peer_username;
      if (rows[0].peer_name) selectedChat.peer_name = rows[0].peer_name;
    }
    updateThreadHeader(selectedChat);

    const visible = filterThreadMessages(rows);
    lastReadOutboxMax = Math.max(
      lastReadOutboxMax,
      ...visible.filter(m => m.is_outgoing && m.is_read).map(m => Number(m.tg_message_id) || 0),
      0
    );
    if (!visible.length) {
      el.innerHTML = '<div class="empty-state">Пусто</div>';
    } else {
      el.innerHTML = renderThreadHtml(visible);
    }
    updateReplyKeyboardBar(visible);
    if (!silent || wasAtBottom) el.scrollTop = el.scrollHeight;
    const ta = document.getElementById('replyText');
    ta.disabled = false;
    if (!silent) {
      ta.value = '';
      restoreInboxDraft(selectedChat);
    }
    autoResizeReply();
    updateScrollBottomBtn();
    document.getElementById('sendBtn').disabled = false;
    const ab = document.getElementById('attachBtn');
    if (ab) ab.disabled = false;
    const eb = document.getElementById('emojiBtn');
    if (eb) eb.disabled = false;
    const qb = document.getElementById('quickReplyBtn');
    if (qb) qb.disabled = false;
    if (!silent) startThreadReadPoll();
    applyReadOutboxToInboxList(chat.account_phone, chat.peer_id, computeReadOutboxMax(visible));
    if (!silent) {
      loadInboxChats();
      refreshStats();
    }
  } catch (e) {
    if (!isCurrent()) return;
    if (el) el.innerHTML = `<div class="empty-state">Ошибка загрузки: ${esc(toastText(e))}</div>`;
    throw e;
  }
}

document.getElementById('saveLead').onclick = () => saveLead();
document.getElementById('leadStarBtn')?.addEventListener('click', () => {
  toggleLeadStar().catch(e => showToast(e.message, 'err'));
});
document.getElementById('leadStatus').onchange = () => saveLead({ silent: true });
document.getElementById('leadAutoreplyMode')?.addEventListener('change', () => {
  syncLeadAutoreplyFieldsState();
  scheduleLeadSave();
});
document.getElementById('leadAutoreplyTpl')?.addEventListener('change', () => scheduleLeadSave());
document.getElementById('leadNote').addEventListener('blur', () => scheduleLeadSave());

document.getElementById('tplPick')?.addEventListener('change', () => {
  const btn = document.getElementById('btnApplyTpl');
  const v = document.getElementById('tplPick')?.value;
  if (btn) btn.disabled = !v;
});

document.getElementById('btnApplyTpl')?.addEventListener('click', applyPickedTemplate);

const _bindTplNew = () => newTemplateEditor();
document.getElementById('btnTplNewList')?.addEventListener('click', _bindTplNew);
document.getElementById('btnTplNewCenter')?.addEventListener('click', _bindTplNew);
const _bindTplSave = () => saveTemplateEditor().catch(e => showToast(e.message, 'err'));
document.getElementById('btnTplSaveTop')?.addEventListener('click', _bindTplSave);
document.getElementById('btnTplDelete')?.addEventListener('click', () => deleteTemplateEditor().catch(e => showToast(e.message, 'err')));
document.getElementById('tplSearch')?.addEventListener('input', () => renderTemplatesList(document.getElementById('tplSearch').value));
document.getElementById('tplPresetFilters')?.addEventListener('click', (e) => {
  const chip = e.target.closest('.tpl-pf-chip');
  if (!chip) return;
  const group = chip.closest('.tpl-filter-group');
  const kind = group?.dataset.filter;
  const val = chip.dataset.val;
  if (kind === 'mode') _tplPresetMode = val;
  else if (kind === 'scope') _tplPresetScope = val;
  group.querySelectorAll('.tpl-pf-chip').forEach(c => c.classList.toggle('active', c === chip));
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
});
document.getElementById('tplPresetSortSel')?.addEventListener('change', (e) => {
  _tplPresetSort = e.target.value || 'new';
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
});
document.getElementById('tplPresetJobSel')?.addEventListener('change', (e) => {
  _tplPresetJobId = e.target.value ? Number(e.target.value) : null;
  // при выборе конкретной задачи scope-чипы не нужны — подсветим «Все»
  const scopeGroup = document.querySelector('#tplPresetFilters .tpl-filter-group[data-filter="scope"]');
  if (scopeGroup) {
    scopeGroup.classList.toggle('tpl-filter-group-muted', !!_tplPresetJobId);
    if (_tplPresetJobId) {
      _tplPresetScope = 'all';
      scopeGroup.querySelectorAll('.tpl-pf-chip').forEach(c => c.classList.toggle('active', c.dataset.val === 'all'));
    }
  }
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
});
document.getElementById('tplCategoryTabs')?.addEventListener('click', e => {
  const tab = e.target.closest('[data-tpl-category]');
  if (!tab) return;
  switchTplCategory(tab.dataset.tplCategory).catch(err => showToast(err.message || 'Ошибка', 'err'));
});
document.getElementById('tplScopeSelect')?.addEventListener('change', (e) => {
  _tplJobFilter = e.target.value;
  const jobSel = document.getElementById('tplScopeJobSelect');
  if (jobSel) {
    jobSel.hidden = _tplJobFilter !== 'job';
    if (_tplJobFilter === 'job' && !jobSel.children.length) {
      _fillScopeJobSelect(jobSel);
    }
  }
  if (tplCategory === 'statuses') {
    _filterStatusEditor();
  } else {
    const req = _tplTemplateScopeRequest();
    loadTemplates({ scope: req.scope, jobId: req.jobId })
      .then(() => renderTemplatesList(document.getElementById('tplSearch')?.value || ''))
      .catch(err => showToast(err.message || 'Ошибка загрузки', 'err'));
  }
});
document.getElementById('tplScopeJobSelect')?.addEventListener('change', (e) => {
  _tplPresetFilterJobId = e.target.value ? Number(e.target.value) : null;
  if (tplCategory === 'statuses') {
    _filterStatusEditor();
  } else {
    const req = _tplTemplateScopeRequest();
    loadTemplates({ scope: req.scope, jobId: req.jobId })
      .then(() => renderTemplatesList(document.getElementById('tplSearch')?.value || ''))
      .catch(err => showToast(err.message || 'Ошибка загрузки', 'err'));
  }
});
function _fillScopeJobSelect(sel) {
  const prev = sel.value || '';
  sel.innerHTML = '<option value="">Выбери задачу</option>';
  (tplJobsCache || []).forEach(jb => {
    sel.innerHTML += `<option value="${jb.id}">${esc(jb.name || `#${jb.id}`)}</option>`;
  });
  const preferred = prev || String(_tplPresetFilterJobId || tplJobId || '');
  if (preferred) sel.value = preferred;
}
function _fillPresetJobSelect() {
  const sel = document.getElementById('tplPresetJobSel');
  if (!sel) return;
  sel.innerHTML = '<option value="">Все задачи</option>';
  (tplJobsCache || []).forEach(jb => {
    sel.innerHTML += `<option value="${jb.id}">${esc(jb.name || `#${jb.id}`)}</option>`;
  });
  sel.value = _tplPresetJobId ? String(_tplPresetJobId) : '';
}
function _filterStatusEditor() {
  loadTplStatusEditorData().catch(err => showToast(err.message || 'Ошибка загрузки', 'err'));
}
document.getElementById('tplArMode')?.addEventListener('change', () => _syncTplArModeUi());
document.getElementById('btnTplStatusAdd')?.addEventListener('click', () => {
  if (_tplScopeState().scope === 'job') {
    showToast('Добавление доступно только в режиме «Все/Глобальные»', 'err');
    return;
  }
  _tplStatusDraft.push({
    id: '',
    label: '',
    description: '',
    color: '#b8a8ff',
    favorite: false,
    builtin: false,
    clients: 0,
  });
  renderTplStatusEditor();
  const rowsEl = document.getElementById('tplStatusRows');
  rowsEl?.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
document.getElementById('btnTplStatusSave')?.addEventListener('click', () => {
  if (_tplScopeState().scope === 'job') {
    showToast('Сохранение отключено в фильтре «По задаче»', 'err');
    return;
  }
  saveTplStatuses().catch(err => showToast(err.message, 'err'));
});
document.getElementById('tplJobPick')?.addEventListener('change', async e => {
  tplJobId = Number(e.target.value) || null;
  try { sessionStorage.setItem(TPL_JOB_KEY, String(tplJobId || '')); } catch (_) {}
  await loadTplJobRules();
  await _onTplJobScopeChange();
  renderTplJobHeader();
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
});
document.getElementById('tplModeSwitch')?.addEventListener('click', e => {
  const btn = e.target.closest('.tpl-mode-btn');
  if (!btn || tplCategory !== 'job') return;
  const modeEl = document.getElementById('tplArMode');
  if (!modeEl) return;
  modeEl.value = btn.dataset.tplMode === 'ai' ? 'ai' : 'text';
  syncTplCategoryEditorFields();
});
document.getElementById('tplAutoTriggerStatus')?.addEventListener('change', () => {
  syncTplJobAttachBar();
  renderTemplatesList(document.getElementById('tplSearch')?.value || '');
});
document.getElementById('btnTplAttachJob')?.addEventListener('click', async () => {
  if (!tplEditId) return showToast('Сначала сохрани AI-шаблон', 'err');
  if (!tplJobId) return showToast('Сначала выбери задачу', 'err');
  try {
    await setJobTplActive(tplEditId, true);
  } catch (err) {
    showToast(err.message || 'Ошибка', 'err');
  }
});
document.getElementById('btnTplDetachJob')?.addEventListener('click', async () => {
  try {
    const status = document.getElementById('tplAutoTriggerStatus')?.value || 'new';
    await removeJobRule(status);
  } catch (err) {
    showToast(err.message || 'Ошибка', 'err');
  }
});
document.getElementById('tplGlobalAutoreply')?.addEventListener('change', async e => {
  const on = e.target.checked;
  try {
    await j('/api/settings', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ autoresponder_enabled: on ? '1' : '0' }),
    });
    tplSettings.autoresponder_enabled = on ? '1' : '0';
    showToast(on ? 'Автоответ включён' : 'Автоответ выключен');
  } catch (err) {
    e.target.checked = !on;
    showToast(err.message || 'Ошибка', 'err');
  }
});
document.getElementById('btnTplAiGen')?.addEventListener('click', () => generateTemplateWithAi().catch(e => showToast(e.message, 'err')));
document.getElementById('btnTplSpintaxGen')?.addEventListener('click', () => generateTemplateSpintax().catch(e => showToast(e.message, 'err')));
document.getElementById('btnTplClearVariants')?.addEventListener('click', clearTplAiVariants);
document.getElementById('btnTplPlaybookDebug')?.addEventListener('click', async () => {
  try {
    const msg = (document.getElementById('tplPlaybookDebugMsg')?.value || '').trim();
    const status = document.getElementById('tplAutoTriggerStatus')?.value || 'new';
    const templateTitle = (document.getElementById('tplEditTitle')?.value || '').trim();
    const enabled = document.getElementById('tplUseSalesPlaybook')?.checked ? 1 : 0;
    const smart = document.getElementById('tplPlaybookDebugSmart')?.checked ? 1 : 0;
    const qs = new URLSearchParams({
      message: msg,
      status,
      template_title: templateTitle,
      enabled: String(enabled),
      smart: String(smart),
    });
    const r = await j(`/api/sales-playbook/context/debug?${qs.toString()}`);
    const out = document.getElementById('tplPlaybookDebugResult');
    if (!out) return;
    out.hidden = false;
    const types = Array.isArray(r.objection_types) ? r.objection_types.join(', ') : (r.objection_type || 'other');
    const fallbackTypes = Array.isArray(r.fallback_objection_types) ? r.fallback_objection_types.join(', ') : '';
    const finalTypes = Array.isArray(r.final_objection_types) ? r.final_objection_types.join(', ') : types;
    const picks = Array.isArray(r.selected_items)
      ? r.selected_items.map(it => `[${it.scope || 'global'}] ${it.objection_type || 'stage'} / ${it.funnel_stage || '-'}: ${it.title}`).join('\n')
      : '';
    const llm = r.llm_classification || null;
    const customMarkers = Array.isArray(r.custom_marker_matches) ? r.custom_marker_matches : [];
    const customPart = customMarkers.length
      ? `Сработали пользовательские маркеры:\n${customMarkers.map(m => `- "${m.phrase}" → ${m.objection_type} / ${m.recommended_action} / ${m.scope}`).join('\n')}`
      : 'Пользовательские маркеры не сработали';
    const llmPart = llm
      ? `llm.intent=${llm.intent}, sentiment=${llm.sentiment}, action=${llm.recommended_action}\nllm.types=[${(llm.objection_types || []).join(', ')}]\nreason=${llm.reason_short || '-'}`
      : 'llm=none';
    out.textContent = r.has_context
      ? `stage=${r.funnel_stage}\nfallback=[${fallbackTypes}]\nИтоговые типы возражений=[${finalTypes}]\n${customPart}\n${llmPart}\nselected:\n${picks}\n\n${r.context_block}`
      : `stage=${r.funnel_stage}\nfallback=[${fallbackTypes}]\nИтоговые типы возражений=[${finalTypes}]\n${customPart}\n${llmPart}\nКонтекст не найден`;
  } catch (e) {
    showToast(e.message || 'Ошибка debug-контекста', 'err');
  }
});
document.getElementById('tplTestReplySamples')?.addEventListener('click', (e) => {
  const b = e.target.closest('button[data-sample]');
  if (!b) return;
  const msg = document.getElementById('tplTestReplyMsg');
  if (msg) msg.value = b.dataset.sample || '';
});
document.getElementById('btnTplJobPlaybookEnableAll')?.addEventListener('click', () => {
  setTplJobSalesPlaybookForAll(true).catch(e => showToast(e.message || e, 'err'));
});
document.getElementById('btnTplJobPlaybookDisableAll')?.addEventListener('click', () => {
  setTplJobSalesPlaybookForAll(false).catch(e => showToast(e.message || e, 'err'));
});
document.getElementById('btnTplJobAutoApplySafe')?.addEventListener('click', () => {
  const j = _tplJobForSelected(tplJobId);
  const next = !(Number(j?.sales_learning_auto_apply_safe || 0) === 1);
  setTplJobSalesLearningAutoApplySafe(next).catch(e => showToast(e.message || e, 'err'));
});
document.getElementById('btnTplTestReply')?.addEventListener('click', async () => {
  if (!tplEditId) return showToast('Сначала выбери шаблон', 'err');
  const btn = document.getElementById('btnTplTestReply');
  const oldLabel = btn?.textContent || '';
  const msg = (document.getElementById('tplTestReplyMsg')?.value || '').trim();
  const status = document.getElementById('tplTestReplyStatus')?.value || 'work';
  const usePlaybook = !!document.getElementById('tplTestUsePlaybook')?.checked;
  const parserContext = (document.getElementById('tplTestReplyParserContext')?.value || '').trim();
  if (!msg) return showToast('Введите сообщение клиента для теста', 'err');
  const out = document.getElementById('tplTestReplyResult');
  if (out) { out.hidden = false; out.textContent = 'Генерирую тестовый ответ...'; out.classList.remove('err'); }
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Генерирую...';
  }
  try {
    const r = await j(`/api/templates/${Number(tplEditId)}/test-reply`, {
      method: 'POST',
      timeoutMs: 45000,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_message: msg,
        status,
        use_sales_playbook: usePlaybook,
        parser_context: parserContext,
      }),
    });
    const cls = r.classification || {};
    const pb = r.playbook || {};
    const dbg = r.ai_debug || {};
    const picks = Array.isArray(pb.selected_items) ? pb.selected_items.map(it => `[${it.scope || 'global'}] ${it.objection_type || '-'} / ${it.funnel_stage || '-'}: ${it.title || ''}`).join('\n') : '';
    if (out) {
      out.hidden = false;
      out.classList.toggle('err', !r.ok);
      out.textContent =
        `Классификация\n` +
        `fallback_objection_types: ${(cls.fallback_objection_types || []).join(', ') || '-'}\n` +
        `custom_marker_matches: ${JSON.stringify(cls.custom_marker_matches || [])}\n` +
        `final_objection_types: ${(cls.final_objection_types || []).join(', ') || '-'}\n` +
        `funnel_stage: ${cls.funnel_stage || '-'}\n` +
        `recommended_action: ${cls.recommended_action || '-'}\n\n` +
        `Использованный playbook\n` +
        `enabled: ${pb.enabled ? 'yes' : 'no'}\n` +
        `has_context: ${pb.has_context ? 'yes' : 'no'}\n` +
        `${picks ? `selected_items:\n${picks}\n` : 'selected_items: -\n'}` +
        `context_block:\n${pb.context_block || '-'}\n\n` +
        `Ответ AI\n${r.generated_reply || '-'}\n\n` +
        `Технически\n` +
        `provider: ${dbg.provider || '-'}\n` +
        `model: ${dbg.model || '-'}\n` +
        `purpose: ${dbg.purpose || '-'}\n` +
        `fallback_used: ${dbg.fallback_used ? 'yes' : 'no'}\n` +
        `parser_context_used: ${dbg.parser_context_used ? 'yes' : 'no'}\n` +
        `source_question_guard_used: ${dbg.source_question_guard_used ? 'yes' : 'no'}\n` +
        `retry_used: ${dbg.retry_used ? 'yes' : 'no'}\n` +
        `deterministic_fallback_used: ${dbg.deterministic_fallback_used ? 'yes' : 'no'}\n` +
        `error: ${dbg.error || r.error || '-'}`;
    }
  } catch (e) {
    const message = e?.message || String(e || 'Ошибка теста AI-шаблона');
    const isTimeout = /таймаут|timeout|abort/i.test(message);
    if (out) {
      out.hidden = false;
      out.classList.add('err');
      out.textContent = isTimeout
        ? 'Тест не выполнен\nLLM-провайдер не ответил за 45 секунд. Кнопка работает, но внешний AI сейчас не отвечает. Попробуй ещё раз или смени ключ/провайдера.'
        : `Тест не выполнен\n${message}`;
    }
    showToast(isTimeout ? 'AI не ответил за 45 секунд' : message, 'err');
  } finally {
    if (btn) {
      btn.disabled = false;
      btn.textContent = oldLabel || 'Сгенерировать тестовый ответ';
    }
  }
});
document.getElementById('tplDraftText')?.addEventListener('input', () => {
  const v = document.getElementById('tplDraftText')?.value.trim() || '';
  _tplOriginalDraft = v;
  if (_tplIsJobLike() && _tplArMode() === 'ai') {
    _tplAiVariants = v ? [v] : [];
    _tplSelectedVariantKeys = v ? new Set([_tplVariantKey(v)]) : new Set();
    _tplAiSpintax = '';
  } else if (!v) {
    _tplAiVariants = [];
    _tplSelectedVariantKeys = new Set();
    _tplAiSpintax = '';
  }
});

function onChatDialogSideChange(side) {
  setChatDialogSide(side);
  selectedChatKeys.clear();
  updateChatSelUi();
  inboxListFp = '';
  loadInboxChats(true);
}
document.getElementById('chatSideIncoming')?.addEventListener('click', () => {
  if (chatDialogSide !== 'incoming') onChatDialogSideChange('incoming');
});
document.getElementById('chatSideOutgoing')?.addEventListener('click', () => {
  if (chatDialogSide !== 'outgoing') onChatDialogSideChange('outgoing');
});
document.getElementById('threadAllMessages')?.addEventListener('change', () => {
  localStorage.setItem('threadAllMessages', document.getElementById('threadAllMessages').checked ? '1' : '0');
  if (selectedChat) loadThread();
});
document.getElementById('chatJobFilter')?.addEventListener('change', () => {
  const sel = document.getElementById('chatJobFilter');
  localStorage.setItem('inboxJobFilter', sel?.value || '');
  applyInboxJobFilter(sel?.value || '');
  selectedChatKeys.clear();
  updateChatSelUi();
  inboxListFp = '';
  loadInboxChats(true);
});
document.getElementById('chatStatusFilter')?.addEventListener('change', () => {
  selectedChatKeys.clear();
  updateChatSelUi();
  inboxListFp = '';
  loadInboxChats(true);
});
document.getElementById('chatSearch')?.addEventListener('input', () => {
  selectedChatKeys.clear();
  updateChatSelUi();
  inboxListFp = '';
  loadInboxChats(true);
});

let clientsCache = new Map();
let clientsListRows = [];
let selectedClientKeys = new Set();

function clientKey(phone, peer) {
  return `${phone}\x00${peer}`;
}

function isClientBulkFilterActive(status) {
  const st = status ?? (document.getElementById('clientStatusFilter')?.value || '');
  return !!st && st !== CLIENT_STARRED_FILTER;
}

function clientsFromSelectedKeys() {
  return [...selectedClientKeys].map(k => {
    const i = k.indexOf('\x00');
    return { account_phone: k.slice(0, i), peer_id: Number(k.slice(i + 1)) };
  });
}

function updateClientBulkUi() {
  const n = selectedClientKeys.size;
  const visN = clientsListRows.length;
  const allSelected = visN > 0 && clientsListRows.every(c =>
    selectedClientKeys.has(clientKey(c.account_phone, c.peer_id))
  );
  const countEl = document.getElementById('clientSelCount');
  const visEl = document.getElementById('clientVisCount');
  if (countEl) countEl.textContent = String(n);
  if (visEl) visEl.textContent = String(visN);
  const selAll = document.getElementById('clientSelAll');
  if (selAll) {
    selAll.disabled = visN === 0;
    selAll.checked = allSelected && visN > 0;
    selAll.indeterminate = n > 0 && !allSelected;
  }
  ['btnClientBulkApply', 'btnClientBulkClear', 'btnClientBulkDelete'].forEach(id => {
    const btn = document.getElementById(id);
    if (btn) btn.disabled = n === 0;
  });
  document.querySelectorAll('.client-sel-chk').forEach(chk => {
    const k = clientKey(chk.dataset.acc, chk.dataset.peer);
    chk.checked = selectedClientKeys.has(k);
  });
}

function syncClientBulkMode(status) {
  const bulk = isClientBulkFilterActive(status);
  document.getElementById('clientsGrid')?.classList.toggle('clients-grid-bulk-mode', bulk);
  const headSel = document.getElementById('clientsGridHeadSel');
  if (headSel) headSel.hidden = !bulk;
  const bar = document.getElementById('clientBulkBar');
  if (bar) bar.hidden = !bulk;
  if (!bulk) selectedClientKeys.clear();
  if (bulk) {
    const sel = document.getElementById('clientBulkStatus');
    fillLeadStatusSelect(sel, { selected: 'new' });
    const cur = status || document.getElementById('clientStatusFilter')?.value || '';
    const clearBtn = document.getElementById('btnClientBulkClear');
    if (clearBtn) clearBtn.hidden = cur === 'new';
  }
  updateClientBulkUi();
}

function toggleClientSelection(phone, peer, checked) {
  const k = clientKey(phone, peer);
  if (checked) selectedClientKeys.add(k);
  else selectedClientKeys.delete(k);
  updateClientBulkUi();
}

function toggleSelectAllVisibleClients() {
  if (!clientsListRows.length) return;
  const allSelected = clientsListRows.every(c =>
    selectedClientKeys.has(clientKey(c.account_phone, c.peer_id))
  );
  for (const c of clientsListRows) {
    const k = clientKey(c.account_phone, c.peer_id);
    if (allSelected) selectedClientKeys.delete(k);
    else selectedClientKeys.add(k);
  }
  updateClientBulkUi();
}

async function applyBulkClientStatus(status) {
  if (!selectedClientKeys.size) return;
  const clients = clientsFromSelectedKeys();
  const st = (status || 'new').trim() || 'new';
  try {
    let updated = 0;
    try {
      const r = await j('/api/clients/bulk-status', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ clients, status: st }),
      });
      updated = Number(r.updated) || clients.length;
    } catch (e) {
      const msg = String(e?.message || e || '');
      if (!/404|not found/i.test(msg)) throw e;
      for (const c of clients) {
        await j('/api/leads/status', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            account_phone: c.account_phone,
            peer_id: c.peer_id,
            status: st,
          }),
        });
        updated += 1;
      }
    }
    selectedClientKeys.clear();
    updateClientBulkUi();
    invalidateTabCache('clients');
    await loadClients();
    showToast(`Статус изменён: ${updated} · ${statusLabel(st)}`);
  } catch (e) {
    showToast(e.message || 'Не удалось сменить статус', 'err');
  }
}

async function deleteSelectedClients() {
  if (!selectedClientKeys.size) return;
  const clients = clientsFromSelectedKeys();
  const opts = await showDeleteChatsConfirm({ count: clients.length });
  if (!opts?.confirmed) return;
  try {
    await performChatDeletion(clients);
    selectedClientKeys.clear();
    updateClientBulkUi();
    invalidateTabCache('clients');
    await loadClients();
  } catch (e) {
    showToast(e.message || 'Не удалось удалить', 'err');
  }
}

let _clientAvObserver = null;

function observeClientAvatars(body) {
  if (!body) return;
  const root = body.closest('.clients-grid-wrap') || null;
  if (!_clientAvObserver) {
    _clientAvObserver = new IntersectionObserver(entries => {
      for (const ent of entries) {
        if (!ent.isIntersecting) continue;
        const node = ent.target;
        _clientAvObserver.unobserve(node);
        bindAvatar(
          node,
          node.dataset.avAccount,
          Number(node.dataset.avPeer),
          node.dataset.avName,
          0,
          node.dataset.avUser || '',
        );
      }
    }, { root, rootMargin: '160px' });
  }
  body.querySelectorAll('.clients-row-av[data-av-account]').forEach(node => {
    _clientAvObserver.observe(node);
  });
}

function renderClientsGrid(rows, status) {
  const body = document.getElementById('clientsGridBody');
  if (!body) return;
  if (!rows.length) {
    body.innerHTML = '<div class="empty-row">Нет клиентов — появятся после первых диалогов</div>';
    return;
  }
  body.innerHTML = rows.map(c => {
    const first = (c.peer_first_name || splitPeerName(c.peer_name).first || '').trim();
    const last = (c.peer_last_name || splitPeerName(c.peer_name).last || '').trim();
    const userStem = (c.peer_username || '').trim().replace(/^@/, '');
    const unread = Number(c.unread_count) || 0;
    const firstDisplay = first || userStem || '—';
    const lastDisplay = last || '—';
    const avName = [first, last].filter(Boolean).join(' ') || c.peer_name || userStem || '?';
    const ck = clientKey(c.account_phone, c.peer_id);
    const checked = selectedClientKeys.has(ck) ? ' checked' : '';
    const bulk = isClientBulkFilterActive(status);
    const selCol = bulk
      ? `<div class="cg-sel"><input type="checkbox" class="client-sel-chk" data-acc="${esc(c.account_phone)}" data-peer="${c.peer_id}"${checked}></div>`
      : '';
    return `<div class="clients-grid-row${unread ? ' has-unread' : ''}">
      ${selCol}
      <div class="cg-first">
        <div class="clients-row-av" data-av-key="${esc(avKey(c.account_phone, c.peer_id))}" data-av-account="${esc(c.account_phone)}" data-av-peer="${c.peer_id}" data-av-name="${esc(avName)}" data-av-user="${esc(c.peer_username || '')}">${esc(initials(avName))}</div>
        <span class="cg-first-text">${leadStarInlineHtml(c.lead_starred)}${esc(firstDisplay)}${unread ? ` <span class="chat-badge">${unread}</span>` : ''}</span>
      </div>
      <div class="cg-last">${esc(lastDisplay)}</div>
      <div class="cg-status">${statusTagHtml(c.lead_status)}</div>
      <div class="cg-tag">${clientPeerTagHtml(c)}</div>
      <div class="cg-preview" title="${esc(c.last_text || '')}">${esc(c.last_text || '—')}<span class="hint">${fmtTime(c.last_date)}</span></div>
      <div class="cg-note" title="${esc(c.lead_note || '')}">${esc(c.lead_note || '—')}</div>
      <div class="cg-actions">
        <button type="button" class="card-btn" data-acc="${esc(c.account_phone)}" data-peer="${c.peer_id}">Карточка</button>
        <button type="button" class="open-btn" data-acc="${esc(c.account_phone)}" data-peer="${c.peer_id}" data-name="${esc(c.peer_name || '')}" data-user="${esc(c.peer_username || '')}" data-st="${c.lead_status || 'new'}">Диалог</button>
      </div>
    </div>`;
  }).join('');
  body.querySelectorAll('.open-btn').forEach(btn => {
    btn.onclick = () => openChat(btn.dataset.acc, btn.dataset.peer, btn.dataset.name, btn.dataset.user, btn.dataset.st);
  });
  body.querySelectorAll('.card-btn').forEach(btn => {
    btn.onclick = () => openClientCard(btn.dataset.acc, Number(btn.dataset.peer));
  });
  observeClientAvatars(body);
  updateClientBulkUi();
}

async function loadClientJobFilter() {
  const sel = document.getElementById('clientJobFilter');
  if (!sel) return;
  let jobs = [];
  try {
    jobs = normalizeJobsList(await j('/api/inbox/tasks'));
  } catch (_) {
    try {
      jobs = normalizeJobsList(await j('/api/broadcast/jobs'));
    } catch (_) {
      jobs = [];
    }
  }
  const saved = localStorage.getItem('clientJobFilter') || sel.value || '';
  sel.innerHTML = '<option value="">Все задачи</option>'
    + jobs.map(j => {
      const name = j.name || (`Задача #${j.id}`);
      inboxJobNames[j.id] = name;
      const prefix = j.status === 'running' ? '▶ ' : '';
      const cnt = Number(j.chat_count) > 0 ? ` · ${j.chat_count}` : '';
      return `<option value="${j.id}">${esc(prefix + name + cnt)}</option>`;
    }).join('');
  if (saved && [...sel.options].some(o => o.value === saved)) sel.value = saved;
}

function isClientRowVisible(c) {
  return c && !isWarmupChat(c) && !isBotChat(c);
}

async function loadClients() {
  const status = document.getElementById('clientStatusFilter')?.value || '';
  const job = document.getElementById('clientJobFilter')?.value || '';
  const q = (document.getElementById('clientSearch')?.value || '').trim();
  let url = '/api/clients';
  const params = [];
  if (status) params.push(`status=${encodeURIComponent(status)}`);
  if (job) params.push(`job_id=${encodeURIComponent(job)}`);
  if (q) params.push(`q=${encodeURIComponent(q)}`);
  if (params.length) url += '?' + params.join('&');
  const statsUrl = job ? `/api/clients/stats?job_id=${encodeURIComponent(job)}` : '/api/clients/stats';
  const [, rawRows, stats] = await Promise.all([
    ensureAccountsForLabels(),
    j(url, { timeoutMs: 45000 }),
    j(statsUrl, { timeoutMs: 20000 }),
  ]);
  const rows = (rawRows || []).filter(isClientRowVisible);
  renderClientStatusQuick(stats, status);
  syncClientBulkMode(status);
  clientsListRows = rows;
  clientsCache = new Map(rows.map(c => [`${c.account_phone}|${c.peer_id}`, c]));
  renderClientsGrid(rows, status);
  tabDataCache.clients = { at: Date.now(), key: clientsTabCacheKey() };
}

function maybeRefreshClientsTab() {
  const ck = clientsTabCacheKey();
  if (_cacheFresh(tabDataCache.clients, TAB_DATA_TTL_MS) && tabDataCache.clients.key === ck) return;
  loadClients();
}

document.getElementById('clientSelAll')?.addEventListener('change', () => {
  toggleSelectAllVisibleClients();
});

document.getElementById('clientsGridBody')?.addEventListener('change', e => {
  const chk = e.target.closest('.client-sel-chk');
  if (!chk) return;
  toggleClientSelection(chk.dataset.acc, Number(chk.dataset.peer), chk.checked);
});

document.getElementById('btnClientBulkApply')?.addEventListener('click', () => {
  const st = document.getElementById('clientBulkStatus')?.value || 'new';
  applyBulkClientStatus(st).catch(() => {});
});

document.getElementById('btnClientBulkClear')?.addEventListener('click', () => {
  applyBulkClientStatus('new').catch(() => {});
});

document.getElementById('btnClientBulkDelete')?.addEventListener('click', () => {
  deleteSelectedClients().catch(() => {});
});

function clientPeerTagLabel(c) {
  const user = (c?.peer_username || '').trim().replace(/^@/, '');
  return user ? '@' + user : '';
}

function clientPeerTagHtml(c) {
  const tag = clientPeerTagLabel(c);
  if (!tag) return '<span class="client-tag-empty">—</span>';
  return `<span class="client-user-tag" title="${esc(tag)}">${esc(tag)}</span>`;
}

function clientAiTagLabel(c) {
  const src = (c?.ai_context_source || '').trim();
  if (src) return src;
  const ctx = (c?.ai_context || '').trim();
  if (!ctx) return '';
  return ctx.length > 48 ? ctx.slice(0, 48) + '…' : ctx;
}

function splitPeerName(name) {
  const text = (name || '').trim();
  if (!text) return { first: '', last: '' };
  const idx = text.indexOf(' ');
  if (idx < 0) return { first: text, last: '' };
  return { first: text.slice(0, idx), last: text.slice(idx + 1).trim() };
}

async function fetchClientCardData(accountPhone, peerId) {
  const cached = clientsCache.get(`${accountPhone}|${peerId}`) || {};
  try {
    return await j(`/api/clients/card?account=${encodeURIComponent(accountPhone)}&peer_id=${peerId}`);
  } catch (e) {
    const msg = String(e?.message || e || '');
    if (!/404|not found/i.test(msg)) throw e;
    const leadData = await j(`/api/lead?account=${encodeURIComponent(accountPhone)}&peer_id=${peerId}`);
    const lead = leadData.lead || {};
    const acc = leadData.account || {};
    const names = splitPeerName(cached.peer_name || '');
    return {
      client: {
        ...cached,
        account_phone: accountPhone,
        peer_id: peerId,
        peer_first_name: names.first,
        peer_last_name: names.last,
        peer_phone: '',
        lead_status: lead.status || cached.lead_status || 'new',
        lead_note: lead.note || cached.lead_note || '',
        ai_context: lead.ai_context || cached.ai_context || '',
        ai_context_source: lead.ai_context_source || cached.ai_context_source || '',
      },
      lead,
      account: acc,
      account_first_name: splitPeerName(acc.display_name || '').first,
      account_last_name: splitPeerName(acc.display_name || '').last,
    };
  }
}

let clientCardCtx = null;

async function openClientCard(accountPhone, peerId) {
  const body = document.getElementById('clientCardBody');
  const subtitle = document.getElementById('clientCardSubtitle');
  const openBtn = document.getElementById('clientCardOpenChat');
  if (!body) return;
  body.innerHTML = '<div class="client-card-loading">Загрузка…</div>';
  if (subtitle) subtitle.textContent = '—';
  if (openBtn) openBtn.hidden = true;
  clientCardCtx = null;
  openModal('modalClientCard');
  try {
    if (!allAccounts.length) allAccounts = await j('/api/accounts');
    const data = await fetchClientCardData(accountPhone, peerId);
    const client = data.client || {};
    const lead = data.lead || {};
    const acc = data.account || allAccounts.find(a => a.phone === accountPhone) || {};
    const peerFirst = (client.peer_first_name || splitPeerName(client.peer_name).first || '').trim();
    const peerLast = (client.peer_last_name || splitPeerName(client.peer_name).last || '').trim();
    const accFirst = data.account_first_name || splitPeerName(acc.display_name).first;
    const accLast = data.account_last_name || splitPeerName(acc.display_name).last;
    const peerTag = clientPeerTagLabel(client);
    const peerHandle = peerTag || ('id ' + (client.peer_id || peerId));
    const accUserTag = (acc.username || '').trim() ? '@' + String(acc.username).replace(/^@/, '') : '—';
    const accTag = acc.project_name || acc.project || acc.country || '—';
    const aiTag = clientAiTagLabel({
      ai_context_source: lead.ai_context_source || client.ai_context_source,
      ai_context: lead.ai_context || client.ai_context,
    });
    const fullPeerName = [peerFirst, peerLast].filter(Boolean).join(' ') || peerTag || peerHandle;
    if (subtitle) subtitle.textContent = fullPeerName;

    body.innerHTML = `
      <div class="client-card-grid">
        <section class="client-card-section">
          <h3>Клиент</h3>
          <div class="client-card-hero">
            <div class="client-card-av" id="clientCardPeerAv">${esc(initials(fullPeerName))}</div>
            <div>
              <div class="client-card-title copyable" data-copy="${esc(fullPeerName)}" title="Скопировать имя">${esc([peerFirst, peerLast].filter(Boolean).join(' ') || '—')}</div>
              <p class="client-card-handle copyable" data-copy="${esc(peerHandle)}" title="Скопировать тег">${esc(peerHandle)}</p>
            </div>
          </div>
          <div class="client-card-meta">
            <div><b>Статус</b><span>${statusTagHtml(lead.status || client.lead_status || 'new')}</span></div>
            <div><b>Тег</b><span class="copyable" data-copy="${esc(peerHandle)}" title="Скопировать">${clientPeerTagHtml(client)}</span></div>
            <div><b>Телефон</b><span class="copyable" data-copy="${esc(client.peer_phone || '')}" title="Скопировать">${esc(client.peer_phone || '—')}</span></div>
            <div><b>ID</b><span class="copyable" data-copy="${esc(String(client.peer_id || peerId))}" title="Скопировать">${esc(String(client.peer_id || peerId))}</span></div>
          </div>
        </section>
        <section class="client-card-section">
          <h3>Наш аккаунт</h3>
          <div class="client-card-hero">
            <div class="client-card-av" id="clientCardAccAv">${esc(initials([accFirst, accLast].filter(Boolean).join(' ') || accUserTag))}</div>
            <div>
              <div class="client-card-title copyable" data-copy="${esc([accFirst, accLast].filter(Boolean).join(' ') || acc.display_name || accUserTag)}" title="Скопировать имя">${esc([accFirst, accLast].filter(Boolean).join(' ') || acc.display_name || accUserTag)}</div>
              <p class="client-card-handle copyable" data-copy="${esc(accUserTag)}" title="Скопировать тег">${esc(accUserTag)}</p>
            </div>
          </div>
          <div class="client-card-meta">
            <div><b>Проект</b><span>${esc(accTag)}</span></div>
            <div><b>Телефон</b><span class="copyable" data-copy="${esc(acc.phone || accountPhone || '')}" title="Скопировать">${esc(acc.phone || accountPhone)}</span></div>
            <div><b>Статус</b><span>${acc.pool_status ? `<span class="dot ${poolDot(acc.pool_status)}"></span>${esc(acc.pool_status)}` : '—'}</span></div>
          </div>
        </section>
        <section class="client-card-section wide">
          <h3>AI-карточка</h3>
          ${aiTag ? `<div class="hint" style="margin-bottom:8px">${esc(aiTag)}</div>` : ''}
          ${(() => {
            const { comment, detail } = splitAiContext(lead.ai_context || client.ai_context || '');
            const body = comment || 'Нет данных — появится после парсинга или рассылки с контекстом';
            const msgs = detail ? `<div class="client-card-ai-msgs">${esc(detail)}</div>` : '';
            return `<div class="client-card-ai">${esc(body)}</div>${msgs}`;
          })()}
        </section>
        <section class="client-card-section">
          <h3>Последнее сообщение</h3>
          <div class="client-card-msg">${esc(client.last_text || '—')}</div>
          <div class="hint" style="margin-top:6px">${fmtTime(client.last_date)}</div>
        </section>
        <section class="client-card-section">
          <h3>Заметка</h3>
          <div class="client-card-ai">${esc((lead.note || client.lead_note || '').trim() || '—')}</div>
        </section>
      </div>`;

    bindAvatar(
      document.getElementById('clientCardPeerAv'),
      accountPhone,
      peerId,
      fullPeerName,
      0,
      client.peer_username || ''
    );
    renderClientCardAccountAvatar(acc, accFirst, accLast, accUserTag);

    clientCardCtx = {
      account_phone: accountPhone,
      peer_id: peerId,
      peer_name: client.peer_name || '',
      peer_username: client.peer_username || '',
      lead_status: lead.status || client.lead_status || 'new',
    };
    if (openBtn) {
      openBtn.hidden = false;
      openBtn.onclick = () => {
        closeModals();
        openChat(
          clientCardCtx.account_phone,
          clientCardCtx.peer_id,
          clientCardCtx.peer_name,
          clientCardCtx.peer_username,
          clientCardCtx.lead_status
        );
      };
    }
  } catch (e) {
    body.innerHTML = `<div class="empty">${esc(e.message || String(e))}</div>`;
  }
}

function renderClientCardAccountAvatar(acc, first, last, fallback) {
  const wrap = document.getElementById('clientCardAccAv');
  if (!wrap || !acc?.id) return;
  const name = [first, last].filter(Boolean).join(' ') || fallback;
  const letter = initials(name);
  const url = acc.avatar_path ? accountAvatarUrl(acc.id, acc.updated_at || '') : '';
  if (!url) {
    wrap.innerHTML = esc(letter);
    return;
  }
  const src = url.includes('?') ? url : `${url}?v=${encodeURIComponent(acc.updated_at || '')}`;
  wrap.innerHTML = `<img src="${esc(src)}" alt="">`;
  const img = wrap.querySelector('img');
  if (img) img.onerror = () => { wrap.innerHTML = esc(letter); };
}

document.getElementById('clientStatusFilter')?.addEventListener('change', () => loadClients());
document.getElementById('clientJobFilter')?.addEventListener('change', () => {
  localStorage.setItem('clientJobFilter', document.getElementById('clientJobFilter')?.value || '');
  invalidateTabCache('clients');
  loadClients();
});
document.getElementById('clientSearch')?.addEventListener('input', () => {
  clearTimeout(window._clientSearchT);
  window._clientSearchT = setTimeout(loadClients, 300);
});

document.getElementById('sendBtn').onclick = async () => {
  if (!selectedChat) return;
  const chat = { ...selectedChat };
  const reqKey = chatKey(chat.account_phone, chat.peer_id);
  const text = document.getElementById('replyText').value.trim();
  if (!text && !pendingAttach) return;
  const btn = document.getElementById('sendBtn');
  btn.disabled = true;
  try {
    if (pendingAttach) {
      const fd = new FormData();
      fd.append('account_phone', chat.account_phone);
      fd.append('peer_id', String(chat.peer_id));
      fd.append('caption', text);
      fd.append('file', pendingAttach);
      const r = await fetch('/api/send-media', { method: 'POST', body: fd });
      if (!r.ok) {
        const raw = await r.text();
        try { throw new Error(JSON.parse(raw).detail || raw); } catch (e) {
          if (e instanceof Error && e.message !== raw) throw e;
          throw new Error(raw || `HTTP ${r.status}`);
        }
      }
      if (selectedChatKey() !== reqKey) return;
      document.getElementById('replyText').value = '';
      autoResizeReply();
      clearAttach();
      clearInboxDraft(chat);
      showToast('Медиа отправлено');
      refreshPeerAvatar(
        chat.account_phone,
        chat.peer_id,
        chat.peer_name,
        chat.peer_username,
      );
    } else {
      await j('/api/send', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ account_phone: chat.account_phone, peer_id: chat.peer_id, text }),
      });
      if (selectedChatKey() !== reqKey) return;
      document.getElementById('replyText').value = '';
      autoResizeReply();
      clearInboxDraft(chat);
      showToast('Отправлено');
    }
    refreshPeerAvatar(
      chat.account_phone,
      chat.peer_id,
      chat.peer_name,
      chat.peer_username,
    );
    await loadThread();
    await loadInboxChats();
  } catch (e) {
    alert('Не удалось отправить:\n\n' + (e.message || String(e)));
  } finally {
    btn.disabled = false;
  }
};

let pendingAttach = null;
let attachThumbUrl = null;

function formatFileSize(bytes) {
  if (bytes > 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' МБ';
  return Math.max(1, Math.round(bytes / 1024)) + ' КБ';
}

function clearAttach() {
  pendingAttach = null;
  if (attachThumbUrl) {
    URL.revokeObjectURL(attachThumbUrl);
    attachThumbUrl = null;
  }
  ['replyAttachMedia', 'replyAttachFile', 'replyAttachAudio'].forEach(id => {
    const inp = document.getElementById(id);
    if (inp) inp.value = '';
  });
  const prev = document.getElementById('attachPreview');
  if (prev) { prev.hidden = true; prev.innerHTML = ''; }
}

function showAttachPreview(f) {
  const prev = document.getElementById('attachPreview');
  if (!prev) return;
  prev.hidden = false;
  const isImg = f.type.startsWith('image/');
  let thumbHtml = '';
  if (isImg) {
    if (attachThumbUrl) URL.revokeObjectURL(attachThumbUrl);
    attachThumbUrl = URL.createObjectURL(f);
    thumbHtml = `<img class="attach-preview-thumb" src="${attachThumbUrl}" alt="" />`;
  } else {
    const icon = f.type.startsWith('audio/') ? '🎵' : '📄';
    thumbHtml = `<div class="attach-preview-thumb" style="display:flex;align-items:center;justify-content:center;font-size:22px">${icon}</div>`;
  }
  prev.innerHTML = `${thumbHtml}
    <div class="attach-preview-info">
      <div class="attach-preview-name">${esc(f.name)}</div>
      <div class="attach-preview-size">${formatFileSize(f.size)}</div>
    </div>
    <button type="button" class="attach-clear" id="attachClear" title="Убрать">✕</button>`;
  document.getElementById('attachClear')?.addEventListener('click', e => {
    e.preventDefault();
    clearAttach();
  });
}

function handleAttachFile(f) {
  if (!f) { clearAttach(); return; }
  if (f.size > 50 * 1024 * 1024) {
    alert('Файл больше 50 МБ');
    clearAttach();
    return;
  }
  pendingAttach = f;
  showAttachPreview(f);
  closeComposerPanels();
  document.getElementById('replyText')?.focus();
}

document.getElementById('attachBtn')?.addEventListener('click', e => {
  e.stopPropagation();
  if (document.getElementById('attachBtn').disabled) return;
  const menu = document.getElementById('attachMenu');
  const emoji = document.getElementById('emojiPanel');
  if (emoji) emoji.hidden = true;
  if (menu) menu.hidden = !menu.hidden;
});

document.getElementById('attachMenu')?.addEventListener('click', e => {
  const btn = e.target.closest('[data-attach]');
  if (!btn) return;
  const kind = btn.dataset.attach;
  document.getElementById('attachMenu').hidden = true;
  const map = { media: 'replyAttachMedia', file: 'replyAttachFile', audio: 'replyAttachAudio' };
  document.getElementById(map[kind])?.click();
});

['replyAttachMedia', 'replyAttachFile', 'replyAttachAudio'].forEach(id => {
  document.getElementById(id)?.addEventListener('change', e => handleAttachFile(e.target.files?.[0]));
});

const BASIC_EMOJIS = [
  '😀','😁','😂','🤣','😊','😍','🥰','😘','😎','🤔','😅','😢','😭','😡','👍','👎','🙏','👋',
  '🔥','❤️','💯','✅','❌','⭐','🎉','💪','🤝','📎','📷','🎵','💬','📞','✉️','🚀','💰','📦',
  '🙂','😉','😇','🥳','😬','🤷','👀','💡','⚡','🎯','📌','🏆','⏰','📅','✨','🫡','👏','🤗',
];

function initEmojiPanel() {
  const panel = document.getElementById('emojiPanel');
  if (!panel || panel.dataset.ready) return;
  panel.dataset.ready = '1';
  panel.innerHTML = `<div class="emoji-grid">${BASIC_EMOJIS.map(e =>
    `<button type="button" data-emoji="${e}">${e}</button>`
  ).join('')}</div>`;
  panel.addEventListener('click', ev => {
    const btn = ev.target.closest('[data-emoji]');
    if (!btn) return;
    const ta = document.getElementById('replyText');
    if (!ta || ta.disabled) return;
    const em = btn.dataset.emoji;
    const start = ta.selectionStart ?? ta.value.length;
    const end = ta.selectionEnd ?? ta.value.length;
    ta.value = ta.value.slice(0, start) + em + ta.value.slice(end);
    const pos = start + em.length;
    ta.focus();
    ta.setSelectionRange(pos, pos);
    autoResizeReply();
  });
}

document.getElementById('emojiBtn')?.addEventListener('click', e => {
  e.stopPropagation();
  if (document.getElementById('emojiBtn').disabled) return;
  initEmojiPanel();
  const menu = document.getElementById('attachMenu');
  const panel = document.getElementById('emojiPanel');
  if (menu) menu.hidden = true;
  if (panel) panel.hidden = !panel.hidden;
});

document.addEventListener('click', e => {
  if (!e.target.closest('#attachBtn, #attachMenu, #emojiBtn, #emojiPanel, #quickReplyBtn, #quickReplyPanel')) {
    closeComposerPanels();
  }
});

document.getElementById('replyText')?.addEventListener('input', autoResizeReply);

// Вставка картинки из буфера обмена (Ctrl+V скриншота) — кладём как вложение
document.getElementById('replyText')?.addEventListener('paste', e => {
  const ta = e.currentTarget;
  if (ta.disabled) return;
  const items = e.clipboardData?.items;
  if (!items) return;
  for (let i = 0; i < items.length; i++) {
    const it = items[i];
    if (it.kind === 'file' && it.type.startsWith('image/')) {
      const blob = it.getAsFile();
      if (!blob) continue;
      e.preventDefault();
      const ext = (blob.type.split('/')[1] || 'png').split('+')[0].replace('jpeg', 'jpg');
      const name = (blob.name && !/^image\.\w+$/i.test(blob.name))
        ? blob.name
        : `screenshot-${Date.now()}.${ext}`;
      const file = new File([blob], name, { type: blob.type || 'image/png' });
      handleAttachFile(file);
      return;
    }
  }
});

// ── Быстрые ответы (шаблоны фраз для ручного inbox) ──
let quickRepliesCache = null;
let quickReplyEditMode = false;

function insertQuickReply(text) {
  const ta = document.getElementById('replyText');
  if (!ta || ta.disabled || !text) return;
  const start = ta.selectionStart ?? ta.value.length;
  const end = ta.selectionEnd ?? ta.value.length;
  ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
  const pos = start + text.length;
  ta.focus();
  ta.setSelectionRange(pos, pos);
  autoResizeReply();
  closeComposerPanels();
}

function renderQuickReplyPanel() {
  const panel = document.getElementById('quickReplyPanel');
  if (!panel) return;
  const items = quickRepliesCache || [];
  if (quickReplyEditMode) {
    panel.innerHTML = `
      <div class="qr-head"><span>Шаблоны быстрых ответов</span>
        <button type="button" class="qr-link" data-qr-act="cancel">Готово</button></div>
      <textarea class="qr-edit" id="qrEditArea" placeholder="Один шаблон на строку..."></textarea>
      <div class="qr-edit-foot">
        <span class="qr-hint">По строке на шаблон · до 50 шт</span>
        <button type="button" class="qr-save" data-qr-act="save">Сохранить</button>
      </div>`;
    const ta = panel.querySelector('#qrEditArea');
    if (ta) { ta.value = items.join('\n'); setTimeout(() => ta.focus(), 0); }
    return;
  }
  const list = items.length
    ? items.map((t, i) =>
        `<button type="button" class="qr-item" data-qr-idx="${i}" title="${esc(t)}">${esc(t)}</button>`
      ).join('')
    : `<div class="qr-empty">Пока нет шаблонов. Добавь частые фразы — они вставятся в ответ одним кликом.</div>`;
  panel.innerHTML = `
    <div class="qr-head"><span>Быстрые ответы</span>
      <button type="button" class="qr-link" data-qr-act="edit">✎ Изменить</button></div>
    <div class="qr-list">${list}</div>`;
}

async function loadQuickReplies(force) {
  if (quickRepliesCache && !force) return quickRepliesCache;
  try {
    const r = await fetch('/api/inbox/quick-replies');
    const d = await r.json();
    quickRepliesCache = Array.isArray(d.items) ? d.items : [];
  } catch (e) {
    quickRepliesCache = quickRepliesCache || [];
  }
  return quickRepliesCache;
}

async function saveQuickReplies() {
  const ta = document.getElementById('qrEditArea');
  if (!ta) return;
  const items = ta.value.split('\n').map(s => s.trim()).filter(Boolean).slice(0, 50);
  try {
    const r = await fetch('/api/inbox/quick-replies', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ items }),
    });
    const d = await r.json();
    if (!r.ok) throw new Error(d.detail || 'Ошибка сохранения');
    quickRepliesCache = Array.isArray(d.items) ? d.items : items;
    quickReplyEditMode = false;
    renderQuickReplyPanel();
    showToast('Шаблоны сохранены');
  } catch (e) {
    showToast(toastText(e), 'err');
  }
}

document.getElementById('quickReplyBtn')?.addEventListener('click', async e => {
  e.stopPropagation();
  if (document.getElementById('quickReplyBtn').disabled) return;
  const panel = document.getElementById('quickReplyPanel');
  const menu = document.getElementById('attachMenu');
  const emoji = document.getElementById('emojiPanel');
  if (menu) menu.hidden = true;
  if (emoji) emoji.hidden = true;
  if (!panel) return;
  const willOpen = panel.hidden;
  if (willOpen) {
    quickReplyEditMode = false;
    await loadQuickReplies();
    renderQuickReplyPanel();
  }
  panel.hidden = !willOpen;
});

document.getElementById('quickReplyPanel')?.addEventListener('click', e => {
  const act = e.target.closest('[data-qr-act]')?.dataset.qrAct;
  if (act === 'edit') { quickReplyEditMode = true; renderQuickReplyPanel(); return; }
  if (act === 'cancel') { quickReplyEditMode = false; renderQuickReplyPanel(); return; }
  if (act === 'save') { saveQuickReplies(); return; }
  const itemBtn = e.target.closest('[data-qr-idx]');
  if (itemBtn) {
    const idx = Number(itemBtn.dataset.qrIdx);
    insertQuickReply((quickRepliesCache || [])[idx] || '');
  }
});
document.getElementById('replyText')?.addEventListener('input', saveInboxDraft);

document.getElementById('replyText')?.addEventListener('keydown', e => {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    document.getElementById('sendBtn')?.click();
  }
});

async function refreshInbox() {
  await loadLeadStatuses();
  await refreshStats();
  await ensureAccountsForLabels();
  await loadInboxJobFilter();
  await loadInboxChats();
  if (selectedChat) {
    await loadThread().catch(() => {});
    await loadLeadPanel();
  }
}

// ── Broadcast (undergroundbrand UI in static/ug/) ──
function showBcList() {
  if (typeof loadBroadcastJobs === 'function') loadBroadcastJobs();
}

let cachedProxies = [];
let cachedProjects = [];
let cachedProxyGroups = [];
let selectedAccountIds = new Set();
const accSelectedAccountsCache = new Map();
const ACC_CHECK_PARALLEL = 3;

function poolConnectInProgress() {
  return !!(accPoolBusy || accPoolUi.connecting);
}

async function targetIdsNeedingConnect(targetIds) {
  const st = await fetchStats({ timeoutMs: 8000 });
  const onlineSet = new Set((st.online_list || []).map(p => String(p)));
  if (!allAccounts.length) await loadAccountsTable();
  return targetIds.filter(id => {
    const a = getAccountById(id);
    if (!a || accountIsSessionDead(a)) return false;
    const phone = String(a.phone || '');
    if (onlineSet.has(phone)) return false;
    if (accountProfileIsOnline(a)) return false;
    return true;
  });
}

async function waitForPoolIdle(timeoutMs = 900000) {
  const t0 = Date.now();
  while (Date.now() - t0 < timeoutMs) {
    if (accCheckAbort) return false;
    try {
      const st = await fetchStats({ timeoutMs: 8000 });
      const serverConnecting = isPoolServerConnecting(st);
      accPoolUi = {
        online: Number(st.online ?? st.pool_connect?.online ?? 0),
        total: Number(st.pool_connect?.total || st.accounts || accPoolUi.total || 0),
        connecting: serverConnecting || accPoolBusy,
      };
      renderAccPoolToggle();
      if (!serverConnecting) {
        accPoolBusy = false;
        setAccPoolBanner(false);
        return true;
      }
      if (!accCheckRunning) {
        const pending = poolConnectPendingCount(allAccounts);
        setAccPoolBanner(
          true,
          poolConnectBannerLabel(accPoolUi.online, accPoolUi.total, pending),
        );
      }
    } catch (_) { /* retry */ }
    await sleepMs(2000);
  }
  return false;
}

async function runPoolStartAndWait(accountIds) {
  const body = accountIds?.length ? { account_ids: accountIds } : {};
  await j('/api/pool/start', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    timeoutMs: 12000,
  });
  accPoolBusy = true;
  accPoolUi.connecting = true;
  startAccPoolPoll();
  return waitForPoolIdle();
}

async function prepareForAccountChecks(targetIds) {
  if (poolConnectInProgress()) {
    showToast('Идёт подключение — дождись ⏹ или отмени, потом проверка', 'err');
    return false;
  }
  const offlineIds = await targetIdsNeedingConnect(targetIds);
  if (!offlineIds.length) return true;
  showToast(
    `Сначала подключаем ${offlineIds.length} offline (потом проверка по ${ACC_CHECK_PARALLEL})…`,
    'info',
    { duration: 6000 },
  );
  const ok = await runPoolStartAndWait(offlineIds);
  if (!ok) {
    showToast('Подключение не завершилось — проверка отменена', 'err');
    return false;
  }
  await loadAccountsTable();
  return true;
}

function bulkCheckTimeoutMs(count, checkSpam) {
  const per = checkSpam ? 140000 : 110000;
  return Math.min(900000, Math.ceil(Math.max(1, count) / ACC_CHECK_PARALLEL) * per + 15000);
}
let lastAccClickId = null;
let bulkProxyAssignIds = [];
let bulkProxyActiveTab = 'group';
let modalAccFilesList = [];
let modalNewGroupMode = 'proxy-group'; // or 'project'
let accountProfileEditingId = 0;
let accountProfileLoaded = null;
let accountProfileImmediateApplied = false;
let accountProfileImportedTemplate = null;
let accountProfileBioForceClear = false;
let accountProfileBioTouched = false;
let accountProfilePickerSelectedId = 0;
let accountProfileAssetSets = [];

function closeModals() {
  _restoreTplEditorToDock();
  stopSessionRotatePoll();
  if (!document.getElementById('modalWarmupCreate')?.hidden) {
    warmupCreatePending = null;
    warmupCreateModalHint = null;
  }
  const hadSessionSec = !!sessionSecAccountId
    && !document.getElementById('modalSessionSecurity')?.hidden;
  const deleteModal = document.getElementById('modalDeleteChats');
  if (_deleteChatsResolve && deleteModal && !deleteModal.hidden) {
    finishDeleteChatsConfirm(null);
  }
  const ov = document.getElementById('modalOverlay');
  if (ov) ov.hidden = true;
  document.querySelectorAll('#modalOverlay .modal').forEach(m => { m.hidden = true; });
  document.body.classList.remove('modal-open');
  if (hadSessionSec) {
    sessionSecAccountId = null;
    j('/api/pool/reset-stale-busy', { method: 'POST' }).catch(() => {});
    loadAccountsTable().catch(() => {});
  }
}

function openModal(id) {
  if (typeof closeBroadcastDetails === 'function') closeBroadcastDetails();
  if (typeof closeBroadcastModal === 'function') {
    const bcm = document.getElementById('createBroadcastModal');
    if (bcm?.classList.contains('show')) closeBroadcastModal();
  }
  closeModals();
  const ov = document.getElementById('modalOverlay');
  const box = document.getElementById(id);
  if (!ov || !box) return;
  ov.hidden = false;
  box.hidden = false;
  document.body.classList.add('modal-open');
}

/** Потоки, паузы с акка, PeerFlood — из вкладки «Рассылка» */
window.openBroadcastEngineSettings = async () => {
  await loadSettings();
  await refreshMetaSelects();
  openModal('modalSettingsBroadcast');
};

document.getElementById('modalOverlay')?.addEventListener('click', e => {
  if (e.target.id === 'modalOverlay') closeModals();
});
document.querySelectorAll('[data-close]').forEach(btn => {
  btn.onclick = closeModals;
});
document.addEventListener('keydown', e => {
  if (e.key !== 'Escape') return;
  if (document.getElementById('createBroadcastModal')?.classList.contains('show')) {
    if (typeof closeBroadcastModal === 'function') closeBroadcastModal();
    return;
  }
  if (document.getElementById('broadcastDetailsModal')?.classList.contains('show')) {
    if (typeof closeBroadcastDetails === 'function') closeBroadcastDetails();
    return;
  }
  closeModals();
});

function proxyGroupLabel(g) {
  const total = g.proxy_total || 0;
  const free = g.proxy_free ?? 0;
  return `${esc(g.name)} (#${g.id}) — ${free} своб. (${total})`;
}

function apiList(data, key) {
  if (Array.isArray(data)) return data;
  if (data && Array.isArray(data[key])) return data[key];
  if (data && Array.isArray(data.items)) return data.items;
  if (data && Array.isArray(data.results)) return data.results;
  return [];
}

async function refreshMetaSelects({ force = false } = {}) {
  if (!force && _cacheFresh(tabDataCache.meta, META_SELECT_TTL_MS) && cachedProjects.length) {
    return;
  }
  const [projects, groups] = await Promise.all([
    j('/api/projects'),
    j('/api/proxy-groups'),
  ]);
  cachedProjects = apiList(projects, 'projects');
  cachedProxyGroups = apiList(groups, 'groups');
  tabDataCache.meta = { at: Date.now() };
  const pf = document.getElementById('accProjectFilter');
  if (pf) {
    const cur = pf.value;
    pf.innerHTML = '<option value="">Все проекты</option><option value="0">Без проекта</option>'
      + cachedProjects.map(p => `<option value="${p.id}">${esc(p.name)} (${p.account_count || 0})</option>`).join('');
    pf.value = cur;
  }
  const map = document.getElementById('modalAccProject');
  if (map) {
    const cur = map.value;
    map.innerHTML =
      '<option value="">— Без проекта —</option>'
      + '<option value="__new__">+ Создать новый проект…</option>'
      + cachedProjects.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
    if (cur && [...map.options].some(o => o.value === cur)) map.value = cur;
    else map.value = '';
    toggleAccNewProjectBox();
  }
  const pg = document.getElementById('modalAccProxyGroup');
  if (pg) {
    const cur = pg.value;
    const defGid = appSettings.default_proxy_group_id || '';
    pg.innerHTML = '<option value="">— по умолчанию из настроек —</option>'
      + cachedProxyGroups.map(g =>
        `<option value="${g.id}">${proxyGroupLabel(g)}</option>`).join('');
    if (cur && [...pg.options].some(o => o.value === cur)) pg.value = cur;
    else if (defGid && [...pg.options].some(o => o.value === defGid)) pg.value = defGid;
    else pg.value = '';
  }
  const dpg = document.getElementById('setDefaultProxyGroup');
  if (dpg) {
    const cur = dpg.value || appSettings.default_proxy_group_id || '';
    dpg.innerHTML = '<option value="">— не задана —</option>'
      + cachedProxyGroups.map(g =>
        `<option value="${g.id}">${proxyGroupLabel(g)}</option>`).join('');
    if (cur && [...dpg.options].some(o => o.value === cur)) dpg.value = cur;
    else dpg.value = '';
  }
  const bpg = document.getElementById('bulkProxyGroupSel');
  if (bpg) {
    bpg.innerHTML = cachedProxyGroups.map(g =>
      `<option value="${g.id}">${proxyGroupLabel(g)}</option>`).join('');
  }
  const mg = document.getElementById('modalPxGroup');
  if (mg) {
    const cur = mg.value;
    mg.innerHTML =
      '<option value="">— выберите группу —</option>'
      + '<option value="__new__">+ Создать новую группу…</option>'
      + cachedProxyGroups.map(g =>
        `<option value="${g.id}">${proxyGroupLabel(g)}</option>`).join('');
    if (cur && [...mg.options].some(o => o.value === cur)) mg.value = cur;
    else mg.value = '';
    togglePxNewGroupBox();
  }
}

function togglePxNewGroupBox() {
  const sel = document.getElementById('modalPxGroup');
  const box = document.getElementById('modalPxNewGroupBox');
  if (!sel || !box) return;
  box.hidden = sel.value !== '__new__';
}

function toggleAccNewProjectBox() {
  const sel = document.getElementById('modalAccProject');
  const box = document.getElementById('modalAccNewProjectBox');
  if (!sel || !box) return;
  box.hidden = sel.value !== '__new__';
}

async function resolveAccProjectId() {
  const sel = document.getElementById('modalAccProject');
  let pid = sel?.value || '';
  if (pid !== '__new__') return pid;
  const name = document.getElementById('modalAccNewProjectName')?.value.trim();
  const country = document.getElementById('modalAccNewProjectCountry')?.value.trim() || null;
  if (!name) {
    alert('Укажи название проекта');
    throw new Error('project name required');
  }
  const created = await j('/api/projects', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, country }),
  });
  await refreshMetaSelects();
  if (sel) sel.value = String(created.id);
  toggleAccNewProjectBox();
  return String(created.id);
}

function getSelectedAccountIds() {
  return [...selectedAccountIds];
}

function cacheSelectedAccount(account) {
  const id = Number(account?.id);
  if (id) accSelectedAccountsCache.set(id, account);
}

function getAccountById(id) {
  const nid = Number(id);
  if (!nid) return null;
  return accSelectedAccountsCache.get(nid)
    || (allAccounts || []).find(a => Number(a.id) === nid)
    || null;
}

function rememberSelectedAccountsFrom(list) {
  for (const a of (list || [])) {
    const id = Number(a.id);
    if (id && selectedAccountIds.has(id)) cacheSelectedAccount(a);
  }
}

function getSelectedAccounts() {
  return getSelectedAccountIds()
    .map(id => getAccountById(id))
    .filter(Boolean);
}

function setAccSelected(id, selected) {
  const nid = Number(id);
  if (!nid) return;
  if (selected) {
    selectedAccountIds.add(nid);
    const acc = (allAccounts || []).find(a => Number(a.id) === nid);
    if (acc) cacheSelectedAccount(acc);
  } else {
    selectedAccountIds.delete(nid);
    accSelectedAccountsCache.delete(nid);
  }
}

function clearAccSelection() {
  selectedAccountIds.clear();
  accSelectedAccountsCache.clear();
  lastAccClickId = null;
  syncAccRowSelection();
  updateBulkBar();
}

function getSingleSelectedAccountId() {
  const ids = getSelectedAccountIds();
  return ids.length === 1 ? ids[0] : 0;
}

function accountAvatarUrl(accountId, updatedAt = '') {
  return `/api/accounts/${accountId}/profile/avatar${updatedAt ? `?v=${encodeURIComponent(updatedAt)}` : ''}`;
}

function renderAccountProfileAvatar(profile, account) {
  const wrap = document.getElementById('accountProfileAvatar');
  if (!wrap) return;
  const name = [profile?.first_name, profile?.last_name].filter(Boolean).join(' ') || account?.display_name || account?.phone || '?';
  const letter = initials(name);
  const hasExplicitProfile = !!profile;
  const avatarUrl = hasExplicitProfile
    ? (profile?.avatar_url || '')
    : (account?.avatar_path ? accountAvatarUrl(account.id, account?.updated_at || '') : '');
  const updatedAt = profile?.updated_at || account?.updated_at || '';
  if (avatarUrl) {
    const src = avatarUrl.includes('?')
      ? `${avatarUrl}&v=${encodeURIComponent(updatedAt)}`
      : `${avatarUrl}?v=${encodeURIComponent(updatedAt)}`;
    wrap.innerHTML = `<img src="${esc(src)}" alt="">`;
    const img = wrap.querySelector('img');
    if (img) {
      img.onerror = () => { wrap.innerHTML = `<span>${esc(letter)}</span>`; };
    }
    return;
  }
  wrap.innerHTML = `<span>${esc(letter)}</span>`;
}

function accountProfileTemplateLabel(p) {
  const name = [p.first_name, p.last_name].filter(Boolean).join(' ') || `Профиль #${p.id}`;
  const bio = p.status_text ? ` · ${p.status_text}` : '';
  const avatar = p.avatar_url ? ' · аватар' : '';
  return `${name}${bio}${avatar}`;
}

function accountProfileTemplateNameKey(firstName, lastName) {
  const fn = String(firstName || '').trim().toLowerCase();
  const ln = String(lastName || '').trim().toLowerCase();
  if (!fn) return '';
  return `${fn}\u0000${ln}`;
}

function accountProfileNameKeysInUse() {
  const keys = new Set();
  (allAccounts || []).forEach(a => {
    const { first, last } = splitPeerName(a.display_name || '');
    const key = accountProfileTemplateNameKey(first, last);
    if (key) keys.add(key);
  });
  return keys;
}

function isAccountProfileTemplateInUse(p) {
  const key = accountProfileTemplateNameKey(p?.first_name, p?.last_name);
  if (!key) return false;
  return accountProfileNameKeysInUse().has(key);
}

async function ensureAccountProfileTemplates() {
  if (profileTemplates.length) return profileTemplates;
  const data = await j('/api/profile-templates?limit=1000');
  profileTemplates = data.items || [];
  return profileTemplates;
}

async function ensureAccountProfileAssetSets() {
  if (accountProfileAssetSets.length) return accountProfileAssetSets;
  try {
    const data = await j('/api/profile-templates/assets/sets');
    accountProfileAssetSets = data.sets || [];
  } catch (_) {
    accountProfileAssetSets = [];
  }
  return accountProfileAssetSets;
}

function accountProfileTemplateKind(p) {
  const source = String(p?.source || '');
  if (source.startsWith('parser:')) return 'parser';
  if (source.startsWith('tg:')) return 'tag';
  return 'local';
}

function accountProfileTemplateProjectSlug(p) {
  if (accountProfileTemplateKind(p) !== 'local') return '';
  const source = String(p?.source || '');
  if (source.startsWith('generated:')) return source.slice(10) || 'default';
  if (source === 'generated') return 'default';
  return '__none__';
}

function accountProfileTemplateProjectLabel(slug) {
  if (!slug || slug === '__none__') return 'Без набора';
  const item = accountProfileAssetSets.find(x => x.slug === slug);
  return item?.label || slug;
}

function localTemplateGenderFromNotes(p) {
  const notes = String(p?.notes || '');
  const match = notes.match(/gender=(male|female|neutral|any)/);
  return match ? match[1] : '';
}

function inferLocalTemplateGender(p) {
  const stored = localTemplateGenderFromNotes(p);
  if (stored && stored !== 'any') return stored;
  const first = String(p?.first_name || '').trim().toLowerCase();
  if (!first) return 'unknown';
  const maleExceptions = new Set(['никита', 'илья', 'кузьма', 'фома', 'лука', 'данила']);
  const femaleExceptions = new Set(['любовь', 'марьям', 'асель']);
  if (maleExceptions.has(first)) return 'male';
  if (femaleExceptions.has(first)) return 'female';
  if (/[ая]$/.test(first)) return 'female';
  return 'male';
}

function accountProfileTemplateParserGroup(p) {
  return String(p?.source_group || p?.source_label || '').trim() || 'Парсер';
}

function syncAccountProfilePickerFilters() {
  const kind = document.getElementById('accountProfilePickerKind')?.value || 'all';
  const showLocal = kind === 'all' || kind === 'local';
  const showParser = kind === 'all' || kind === 'parser';
  document.querySelectorAll('[data-picker-filter="local"]').forEach(el => {
    el.hidden = !showLocal;
    el.disabled = !showLocal;
  });
  document.querySelectorAll('[data-picker-filter="parser"]').forEach(el => {
    el.hidden = !showParser;
    el.disabled = !showParser;
  });
  const hint = document.getElementById('accountProfilePickerFilterHint');
  if (hint) {
    if (kind === 'local') {
      hint.textContent = 'Локальные: пол (включая «Общие») и набор файлов — Базовый, Россия и свои.';
    } else if (kind === 'parser') {
      hint.textContent = 'Парсерные: фильтр по базе (чат/группа). Наборы файлов к ним не относятся.';
    } else if (kind === 'tag') {
      hint.textContent = 'По тегу: профили, добавленные напрямую по @username/ID из Telegram.';
    } else {
      hint.textContent = 'Локальные — пол и набор. Парсерные — база парсера. По тегу — добавленные по @username.';
    }
  }
}

function accountProfilePickerCardHtml(p, active = false) {
  const name = [p.first_name, p.last_name].filter(Boolean).join(' ') || `Профиль #${p.id}`;
  const initialsText = initials(name);
  const avatar = p.avatar_url
    ? `<img src="${esc(p.avatar_url)}?v=${encodeURIComponent(p.updated_at || p.created_at || '')}" alt="">`
    : `<span>${esc(initialsText)}</span>`;
  const kind = accountProfileTemplateKind(p);
  const kindLabel = kind === 'parser' ? 'Парсерный' : kind === 'tag' ? 'По тегу' : 'Локальный';
  let metaParts;
  if (kind === 'parser') {
    metaParts = [kindLabel, accountProfileTemplateParserGroup(p)];
  } else if (kind === 'tag') {
    metaParts = [kindLabel, p.source_label || (p.source || '').replace(/^tg:/, '@') || ''].filter(Boolean);
  } else {
    metaParts = [
      kindLabel,
      accountProfileTemplateProjectLabel(accountProfileTemplateProjectSlug(p)),
      p.source_label || p.source || 'manual',
    ].filter(part => part && part !== 'Без набора');
  }
  return `
    <article class="account-profile-picker-card${active ? ' active' : ''}" data-picker-template-id="${p.id}">
      <div class="account-profile-picker-top">
        <div class="account-profile-picker-avatar">${avatar}</div>
        <div>
          <div class="account-profile-picker-name">${esc(name)}</div>
          <div class="account-profile-picker-meta">${esc(metaParts.join(' · '))}</div>
        </div>
      </div>
      <div class="account-profile-picker-status">${esc(p.status_text || 'Без bio')}</div>
    </article>
  `;
}

function applyAccountProfilePickerFilters(list) {
  const kind = document.getElementById('accountProfilePickerKind')?.value || 'all';
  const gender = document.getElementById('accountProfilePickerGender')?.value || 'all';
  const project = document.getElementById('accountProfilePickerProject')?.value || 'all';
  const parserGroup = document.getElementById('accountProfilePickerParser')?.value || 'all';
  const q = (document.getElementById('accountProfilePickerSearch')?.value || '').trim().toLowerCase();
  return list.filter(p => {
    if (isAccountProfileTemplateInUse(p)) return false;
    const pKind = accountProfileTemplateKind(p);
    if (kind !== 'all' && pKind !== kind) return false;
    if (gender !== 'all' && pKind === 'local') {
      const storedGender = localTemplateGenderFromNotes(p);
      if (gender === 'neutral') {
        if (storedGender !== 'neutral') return false;
      } else if (storedGender && storedGender !== 'any') {
        if (storedGender !== gender) return false;
      } else if (inferLocalTemplateGender(p) !== gender) {
        return false;
      }
    }
    if (project !== 'all' && pKind === 'local') {
      if (accountProfileTemplateProjectSlug(p) !== project) return false;
    }
    if (parserGroup !== 'all' && pKind === 'parser') {
      if (accountProfileTemplateParserGroup(p) !== parserGroup) return false;
    }
    if (!q) return true;
    const hay = [
      p.first_name || '',
      p.last_name || '',
      p.status_text || '',
      p.source || '',
      p.source_group || '',
      p.source_label || '',
      accountProfileTemplateProjectLabel(accountProfileTemplateProjectSlug(p)),
    ].join(' ').toLowerCase();
    return hay.includes(q);
  });
}

function renderAccountProfilePicker() {
  const grid = document.getElementById('accountProfilePickerGrid');
  const stats = document.getElementById('accountProfilePickerStats');
  if (!grid || !stats) return;
  const filtered = applyAccountProfilePickerFilters(profileTemplates);
  stats.textContent = `Профилей: ${filtered.length}`;
  grid.innerHTML = filtered.length
    ? filtered.map(p => accountProfilePickerCardHtml(p, Number(p.id) === Number(accountProfilePickerSelectedId))).join('')
    : '<div class="empty-state">Ничего не найдено по фильтрам.</div>';
}

function fillAccountProfilePickerProjects() {
  const sel = document.getElementById('accountProfilePickerProject');
  if (!sel) return;
  const prev = sel.value || 'all';
  const slugs = new Set();
  accountProfileAssetSets.forEach(item => {
    if (item?.slug) slugs.add(item.slug);
  });
  profileTemplates.forEach(p => {
    if (accountProfileTemplateKind(p) === 'local') slugs.add(accountProfileTemplateProjectSlug(p));
  });
  const ordered = [...slugs].sort((a, b) =>
    accountProfileTemplateProjectLabel(a).localeCompare(accountProfileTemplateProjectLabel(b), 'ru'));
  const options = ['<option value="all">Все наборы</option>'];
  ordered.forEach(slug => {
    if (!slug || slug === '__none__') return;
    options.push(`<option value="${esc(slug)}">${esc(accountProfileTemplateProjectLabel(slug))}</option>`);
  });
  if (profileTemplates.some(p => accountProfileTemplateProjectSlug(p) === '__none__')) {
    options.push('<option value="__none__">Без набора</option>');
  }
  sel.innerHTML = options.join('');
  sel.value = [...sel.options].some(o => o.value === prev) ? prev : 'all';
}

function fillAccountProfilePickerParserGroups() {
  const sel = document.getElementById('accountProfilePickerParser');
  if (!sel) return;
  const prev = sel.value || 'all';
  const groups = new Set();
  profileTemplates.forEach(p => {
    if (accountProfileTemplateKind(p) === 'parser') groups.add(accountProfileTemplateParserGroup(p));
  });
  const ordered = [...groups].sort((a, b) => a.localeCompare(b, 'ru'));
  const options = ['<option value="all">Все базы парсера</option>'];
  ordered.forEach(group => {
    options.push(`<option value="${esc(group)}">${esc(group)}</option>`);
  });
  sel.innerHTML = options.join('');
  sel.value = [...sel.options].some(o => o.value === prev) ? prev : 'all';
}

async function openAccountProfilePicker() {
  if (!allAccounts.length) {
    try {
      allAccounts = await j('/api/accounts');
    } catch (_) {}
  }
  await ensureAccountProfileTemplates();
  await ensureAccountProfileAssetSets();
  fillAccountProfilePickerProjects();
  fillAccountProfilePickerParserGroups();
  syncAccountProfilePickerFilters();
  renderAccountProfilePicker();
  const ov = document.getElementById('accountProfilePickerOverlay');
  if (ov) ov.hidden = false;
}

function closeAccountProfilePicker() {
  const ov = document.getElementById('accountProfilePickerOverlay');
  if (ov) ov.hidden = true;
}

function updateAccountProfileSelectedTemplateLabel(p) {
  const el = document.getElementById('accountProfileSelectedTemplate');
  if (!el) return;
  if (!p) {
    el.textContent = 'Профиль не выбран';
    return;
  }
  const name = [p.first_name, p.last_name].filter(Boolean).join(' ') || `Профиль #${p.id}`;
  const project = accountProfileTemplateProjectLabel(accountProfileTemplateProjectSlug(p));
  el.textContent = `${name} · ${project}`;
}

function setAccountProfileImportHint(text) {
  const hint = document.getElementById('accountProfileImportHint');
  if (hint) hint.textContent = text;
}

function applyAccountProfileTemplate(p) {
  if (!p) return;
  accountProfileImportedTemplate = p;
  accountProfilePickerSelectedId = Number(p.id || 0);
  document.getElementById('accountProfileFirstName').value = p.first_name || '';
  document.getElementById('accountProfileLastName').value = p.last_name || '';
  document.getElementById('accountProfileBio').value = String(p.status_text || '').slice(0, 70);
  accountProfileBioTouched = true;
  accountProfileBioForceClear = false;
  const fileInput = document.getElementById('accountProfileAvatarFile');
  if (fileInput) fileInput.value = '';
  const currentUsername = document.getElementById('accountProfileUsername')?.value || accountProfileLoaded?.username || '';
  renderAccountProfileAvatar({
    first_name: p.first_name || '',
    last_name: p.last_name || '',
    username: currentUsername,
    avatar_url: p.avatar_url || '',
    updated_at: p.updated_at || p.created_at || '',
  }, null);
  setAccountProfileImportHint(
    p.avatar_url
      ? 'Профиль вставлен. Его аватарка будет поставлена при сохранении.'
      : 'Профиль вставлен. У него нет аватарки, сохранятся только имя, фамилия и bio.'
  );
  updateAccountProfileSelectedTemplateLabel(p);
}

function setAccountProfileFormEnabled(enabled) {
  [
    'accountProfileFirstName',
    'accountProfileLastName',
    'accountProfileUsername',
    'accountProfileBio',
    'accountProfileAvatarFile',
    'accountProfileClearUsername',
    'accountProfileClearBio',
    'accountProfileDeleteAvatar',
    'accountProfileDeleteAllAvatars',
    'accountProfileOpenPicker',
    'accountProfileImportRandom',
    'accountProfileRefresh',
    'accountProfileSave',
  ].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.disabled = !enabled;
  });
}

function showAccountProfileOffline(account) {
  const box = document.getElementById('accountProfileOfflineBox');
  if (box) box.hidden = false;
  const busyBox = document.getElementById('accountProfileBusyBox');
  if (busyBox) busyBox.hidden = true;
  setAccountProfileFormEnabled(false);
  renderAccountProfileAvatar(null, account);
  document.getElementById('accountProfileHint').textContent = 'Аккаунт offline. Запусти его, потом можно менять оформление.';
}

function showAccountProfileBusy(account) {
  const offlineBox = document.getElementById('accountProfileOfflineBox');
  if (offlineBox) offlineBox.hidden = true;
  const busyBox = document.getElementById('accountProfileBusyBox');
  if (busyBox) busyBox.hidden = false;
  const text = document.getElementById('accountProfileBusyText');
  const reason = account?.profile_busy_reason || 'Аккаунт занят активной задачей';
  if (text) {
    text.textContent = `${reason}. Останови задачу или дождись завершения, затем меняй оформление.`;
  }
  setAccountProfileFormEnabled(false);
  renderAccountProfileAvatar(null, account);
  document.getElementById('accountProfileHint').textContent = 'Оформление заблокировано, чтобы не рисковать session.';
}

function showAccountProfileOnline() {
  const box = document.getElementById('accountProfileOfflineBox');
  if (box) box.hidden = true;
  const busyBox = document.getElementById('accountProfileBusyBox');
  if (busyBox) busyBox.hidden = true;
  setAccountProfileFormEnabled(true);
}

function accountProfileIsOnline(account) {
  return !!account?.profile_online || (account?.pool_status || '') === 'online';
}

function accountProfileLocked() {
  return !document.getElementById('accountProfileOfflineBox')?.hidden
    || !document.getElementById('accountProfileBusyBox')?.hidden;
}

async function handleAccountProfileStateError(e, fallbackAccount = null) {
  const msg = e?.message || String(e || '');
  const accountId = accountProfileEditingId || getSingleSelectedAccountId();
  let account = fallbackAccount;
  try {
    await loadAccountsTable();
    account = allAccounts.find(a => Number(a.id) === Number(accountId)) || account;
  } catch (_) {
    account = account || fallbackAccount;
  }
  if (msg.includes('Аккаунт offline') || (msg.includes('offline') && !msg.includes('занят'))) {
    showAccountProfileOffline(account || {});
    return true;
  }
  if (msg.includes('занят') || msg.includes('Таймаут') || msg.includes('timeout')) {
    const reason = msg.includes('Таймаут')
      ? 'Таймаут чтения профиля — аккаунт занят рассылкой/прогревом/inbox. Подожди 10–20 с.'
      : msg;
    showAccountProfileBusy({ ...(account || {}), profile_busy_reason: reason });
    return true;
  }
  return false;
}

function setAccountProfileTipsOpen(open) {
  const form = document.getElementById('accountProfileFormPane');
  const tips = document.getElementById('accountProfileTipsPane');
  const btn = document.getElementById('accountProfileTipsToggle');
  if (form) form.hidden = !!open;
  if (tips) tips.hidden = !open;
  if (btn) {
    btn.textContent = open ? '← К настройкам' : '💡 Советы по безопасности';
    btn.classList.toggle('is-active', !!open);
  }
}

function accountCachedProfile(account) {
  if (!account) return null;
  const display = (account.display_name || '').trim();
  const parts = display ? display.split(/\s+/) : [];
  return {
    first_name: parts[0] || '',
    last_name: parts.slice(1).join(' ') || '',
    username: (account.username || '').replace(/^@/, ''),
    bio: '',
    avatar_url: account.avatar_url || '',
    updated_at: account.updated_at || '',
  };
}

function fillAccountProfileForm(profile, account) {
  const p = profile || {};
  document.getElementById('accountProfileFirstName').value = p.first_name || '';
  document.getElementById('accountProfileLastName').value = p.last_name || '';
  document.getElementById('accountProfileUsername').value = p.username || '';
  document.getElementById('accountProfileBio').value = p.bio || '';
  renderAccountProfileAvatar(p, account || null);
}

const ACCOUNT_PROFILE_FETCH_TIMEOUT_MS = 35000;

async function openAccountProfileModal() {
  const accountId = getSingleSelectedAccountId();
  if (!accountId) {
    showToast('Выбери ровно один аккаунт', 'err');
    return;
  }
  setAccountProfileTipsOpen(false);
  const account = allAccounts.find(a => Number(a.id) === Number(accountId));
  if (!account) {
    showToast('Аккаунт не найден', 'err');
    return;
  }
  accountProfileEditingId = accountId;
  accountProfileLoaded = null;
  accountProfileImmediateApplied = false;
  accountProfileImportedTemplate = null;
  accountProfilePickerSelectedId = 0;
  accountProfileBioForceClear = false;
  accountProfileBioTouched = false;
  document.getElementById('accountProfileAvatarFile').value = '';
  document.getElementById('modalAccountProfileTitle').textContent = `Оформление · ${account.phone}`;
  document.getElementById('accountProfilePhone').textContent = account.phone || '—';
  document.getElementById('accountProfileHint').textContent = 'Загрузка профиля Telegram…';
  fillAccountProfileForm(accountCachedProfile(account), account);
  setAccountProfileFormEnabled(false);
  const err = document.getElementById('accountProfileError');
  if (err) {
    err.hidden = true;
    err.textContent = '';
  }
  updateAccountProfileSelectedTemplateLabel(null);
  openModal('modalAccountProfile');
  ensureAccountProfileTemplates().catch(() => {});
  if (account.profile_busy || account.in_warmup) {
    showAccountProfileBusy(
      account.in_warmup && !account.profile_busy_reason
        ? {
          ...account,
          profile_busy_reason: `Аккаунт в прогреве (задача #${account.warmup_job_id || '?'})`,
        }
        : account,
    );
    document.getElementById('accountProfileHint').textContent = 'Оформление недоступно, пока аккаунт занят задачей.';
    return;
  }
  if (!accountProfileIsOnline(account)) {
    showAccountProfileOffline(account);
    return;
  }
  showAccountProfileOnline();
  setAccountProfileFormEnabled(false);
  try {
    const r = await j(`/api/accounts/${accountId}/profile`, { timeoutMs: ACCOUNT_PROFILE_FETCH_TIMEOUT_MS });
    accountProfileLoaded = r.profile || null;
    fillAccountProfileForm(r.profile, account);
    document.getElementById('accountProfileHint').textContent = 'Меняет реальный профиль Telegram у выбранного аккаунта.';
    setAccountProfileImportHint('Можно выбрать профиль из базы и применить его после «Сохранить».');
    showAccountProfileOnline();
  } catch (e) {
    if (await handleAccountProfileStateError(e, account)) return;
    document.getElementById('accountProfileHint').textContent = 'Не удалось прочитать профиль Telegram. Показан кэш из таблицы — нажми «Проверить текущий».';
    showAccountProfileOnline();
    if (err) {
      err.hidden = false;
      err.textContent = e.message || String(e);
    }
  } finally {
    if (!accountProfileLocked()) setAccountProfileFormEnabled(true);
  }
}

async function refreshAccountProfileModal({ silent = false } = {}) {
  const accountId = accountProfileEditingId || getSingleSelectedAccountId();
  if (!accountId) {
    if (!silent) showToast('Выбери ровно один аккаунт', 'err');
    return false;
  }
  const account = allAccounts.find(a => Number(a.id) === Number(accountId)) || null;
  const err = document.getElementById('accountProfileError');
  const hint = document.getElementById('accountProfileHint');
  const refreshBtn = document.getElementById('accountProfileRefresh');
  const cur = accountProfileLoaded || {};
  const dirty = (
    (document.getElementById('accountProfileFirstName')?.value.trim() || '') !== String(cur.first_name || '')
    || (document.getElementById('accountProfileLastName')?.value.trim() || '') !== String(cur.last_name || '')
    || (document.getElementById('accountProfileUsername')?.value.trim() || '') !== String(cur.username || '')
    || (document.getElementById('accountProfileBio')?.value.trim() || '') !== String(cur.bio || '')
    || !!document.getElementById('accountProfileAvatarFile')?.files?.[0]
  );
  if (dirty && !silent) {
    if (!confirm('Есть несохранённые изменения. Обновить текущий профиль и потерять их?')) {
      return false;
    }
  }
  if (err) {
    err.hidden = true;
    err.textContent = '';
  }
  if (refreshBtn) {
    refreshBtn.disabled = true;
    refreshBtn.textContent = 'Обновляю…';
  }
  if (hint) hint.textContent = 'Читаю текущий профиль из Telegram…';
  setAccountProfileFormEnabled(false);
  try {
    const r = await j(`/api/accounts/${accountId}/profile`, { timeoutMs: ACCOUNT_PROFILE_FETCH_TIMEOUT_MS });
    accountProfileLoaded = r.profile || null;
    accountProfileBioForceClear = false;
    accountProfileBioTouched = false;
    fillAccountProfileForm(r.profile, account || {});
    showAccountProfileOnline();
    if (hint) hint.textContent = 'Актуальные данные профиля загружены из Telegram.';
    if (!silent) showToast('Профиль обновлён');
    return true;
  } catch (e) {
    if (await handleAccountProfileStateError(e, account)) return false;
    if (hint) hint.textContent = 'Не удалось прочитать профиль Telegram.';
    if (err) {
      err.hidden = false;
      err.textContent = e.message || String(e);
    } else if (!silent) {
      showToast(e.message || 'Не удалось обновить профиль', 'err');
    }
    return false;
  } finally {
    if (refreshBtn && !accountProfileLocked()) {
      refreshBtn.disabled = false;
      refreshBtn.textContent = 'Проверить текущий';
    }
  }
}

async function startAccountFromProfileModal() {
  const accountId = accountProfileEditingId || getSingleSelectedAccountId();
  if (!accountId) return showToast('Выбери один аккаунт', 'err');
  const account = allAccounts.find(a => Number(a.id) === Number(accountId));
  if (account?.profile_busy) {
    showAccountProfileBusy(account);
    return;
  }
  const btn = document.getElementById('accountProfileStartAccount');
  const hint = document.getElementById('accountProfileHint');
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Запускаю…';
  }
  if (hint) hint.textContent = 'Подключаю аккаунт к пулу…';
  try {
    const r = await j('/api/pool/start', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ account_ids: [accountId] }),
    });
    if (r.busy) {
      showToast('Подключение уже идёт, дождись завершения', 'err');
      startAccPoolPoll();
      return;
    }
    await loadAccountsTable();
    await updateAccPoolStatusUi();
    const fresh = allAccounts.find(a => Number(a.id) === Number(accountId));
    if (fresh?.profile_busy) {
      showAccountProfileBusy(fresh);
    } else if (accountProfileIsOnline(fresh)) {
      showToast('Аккаунт запущен');
      await openAccountProfileModal();
    } else {
      showToast('Аккаунт не запустился, проверь статус в таблице', 'err');
      showAccountProfileOffline(fresh || {});
    }
  } catch (e) {
    if (await handleAccountProfileStateError(e, account)) return;
    showToast(e.message || 'Не удалось запустить аккаунт', 'err');
    if (hint) hint.textContent = 'Не удалось запустить аккаунт. Проверь прокси/session и попробуй ещё раз.';
  } finally {
    if (btn) {
      btn.disabled = false;
      btn.textContent = '▶ Запустить аккаунт';
    }
  }
}

function _buildAccountProfileSavePayload(accountId) {
    const current = accountProfileLoaded || {};
    const firstName = document.getElementById('accountProfileFirstName')?.value.trim() || '';
    const lastName = document.getElementById('accountProfileLastName')?.value.trim() || '';
    const username = document.getElementById('accountProfileUsername')?.value.trim() || '';
    const bio = document.getElementById('accountProfileBio')?.value.trim() || '';
    const importedTemplateId = accountProfileImportedTemplate?.id
      ? Number(accountProfileImportedTemplate.id)
      : 0;
    const templateAvatarId = accountProfileImportedTemplate?.avatar_url
      ? Number(accountProfileImportedTemplate.id)
      : 0;
    const body = {};
    if (firstName !== String(current.first_name || '')) body.first_name = firstName;
    if (lastName !== String(current.last_name || '')) body.last_name = lastName;
    if (username !== String(current.username || '')) body.username = username;
    if (bio !== String(current.bio || '')) body.bio = bio;
    if (accountProfileBioForceClear) body.bio = '';
    if (accountProfileBioTouched && !Object.prototype.hasOwnProperty.call(body, 'bio')) {
      body.bio = bio;
  }
  const avatarFile = document.getElementById('accountProfileAvatarFile')?.files?.[0] || null;
  return { accountId, body, importedTemplateId, templateAvatarId, avatarFile };
    }

async function runAccountProfileSaveInBackground(payload) {
  const { accountId, body, importedTemplateId, templateAvatarId, avatarFile } = payload;
  try {
    if (Object.keys(body).length) {
      await j(`/api/accounts/${accountId}/profile`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
    }
    if (avatarFile) {
      const fd = new FormData();
      fd.append('file', avatarFile);
      await j(`/api/accounts/${accountId}/profile/avatar`, {
        method: 'POST',
        body: fd,
      });
      if (importedTemplateId) {
        await j(`/api/profile-templates/${importedTemplateId}/mark-used`, { method: 'POST' });
      }
    } else if (templateAvatarId) {
      await j(`/api/accounts/${accountId}/profile/avatar-from-template/${templateAvatarId}`, {
        method: 'POST',
      });
    } else if (importedTemplateId && Object.keys(body).length) {
      await j(`/api/profile-templates/${importedTemplateId}/mark-used`, { method: 'POST' });
    }
    await loadAccountsTable();
  } catch (e) {
    showToast(e.message || 'Ошибка сохранения оформления', 'err');
  }
}

async function saveAccountProfileModal() {
  const accountId = accountProfileEditingId || getSingleSelectedAccountId();
  if (!accountId) {
    showToast('Выбери ровно один аккаунт', 'err');
    return;
  }
  const payload = _buildAccountProfileSavePayload(accountId);
  const hasChanges = !!(
    payload.avatarFile
    || payload.templateAvatarId
    || Object.keys(payload.body).length
  );
  if (!hasChanges) {
      if (accountProfileImmediateApplied) {
        accountProfileImmediateApplied = false;
        closeModals();
      accountProfileEditingId = 0;
      showToast('Сохранено');
      loadAccountsTable();
      } else {
        showToast('Нет изменений');
      }
      return;
    }
  closeModals();
  accountProfileEditingId = 0;
    accountProfileImmediateApplied = false;
    accountProfileImportedTemplate = null;
    accountProfileBioTouched = false;
    accountProfileBioForceClear = false;
  accountProfileLoaded = null;
  showToast('Сохранено');
  runAccountProfileSaveInBackground(payload);
}

const ACC_BULK_ACTION_IDS = [
  'accBulkProfile', 'accBulkSecurity', 'accBulkProject',
  'accBulkProxy', 'accBulkLimit', 'accBulkWrite', 'accBulkExport', 'accBulkDelete',
];

let sessionSecAccountId = null;
const SESS_SEC_HIDE_KEY = 'sessSecHideSensitive';

function initSessSecHideCheckbox() {
  const cb = document.getElementById('sessSecHideSensitive');
  if (!cb) return;
  try {
    cb.checked = localStorage.getItem(SESS_SEC_HIDE_KEY) === '1';
  } catch (_) { /* ignore */ }
  applySessSecSensitiveVisibility();
}

function applySessSecSensitiveVisibility() {
  const hide = !!document.getElementById('sessSecHideSensitive')?.checked;
  document.querySelectorAll('.sess-sec-sensitive').forEach(el => {
    el.classList.toggle('sess-sec-masked', hide);
  });
}

function session2faLoginEmailDisplay(account, audit) {
  const pwd = audit?.password || {};
  return (pwd.login_email_pattern || '').trim();
}

function session2faRecoveryEmailDisplay(account, audit) {
  const pwd = audit?.password || {};
  const loginPat = session2faLoginEmailDisplay(account, audit);
  const fromAccount = (account?.session_2fa_recovery_email || '').trim();
  const fromAudit = (pwd.recovery_email || '').trim();
  const storedOk = fromAccount && fromAccount !== loginPat;
  return (storedOk ? fromAccount : '') || fromAudit || '';
}

function fillSession2faEmails(account, audit) {
  const loginInp = document.getElementById('sessSec2faLoginEmail');
  const recoveryInp = document.getElementById('sessSec2faEmail');
  const login = session2faLoginEmailDisplay(account, audit);
  const recovery = session2faRecoveryEmailDisplay(account, audit);
  if (loginInp) loginInp.value = login;
  if (recoveryInp) {
    const cur = (recoveryInp.value || '').trim();
    if (recovery || !cur) recoveryInp.value = recovery;
  }
}

async function persistSession2faRecoveryDraft() {
  if (!sessionSecAccountId) return null;
  const email = (document.getElementById('sessSec2faEmail')?.value || '').trim();
  if (!email) return null;
  const body = { recovery_email: email };
  const vaultPwd = (document.getElementById('sessSec2faVaultInput')?.value || '').trim();
  if (vaultPwd) body.password = vaultPwd;
  const r = await j(`/api/accounts/${sessionSecAccountId}/2fa-password`, {
    method: 'PUT',
    body: JSON.stringify(body),
  });
  return r.account;
}

let sessionRotatePollTimer = null;
let sessionRotatePollBusy = false;
let sessionRotatePollAttempts = 0;
let rotateUiHint = '';

function stopSessionRotatePoll() {
  if (sessionRotatePollTimer) {
    clearInterval(sessionRotatePollTimer);
    sessionRotatePollTimer = null;
  }
  sessionRotatePollBusy = false;
  sessionRotatePollAttempts = 0;
}

function setRotateUiHint(text) {
  rotateUiHint = String(text || '').trim();
  const stateEl = document.getElementById('sessSecRotateState');
  if (stateEl && rotateUiHint) stateEl.textContent = rotateUiHint;
}

function applyRotateSuggestedCode(code) {
  const inp = document.getElementById('sessSecRotateCode');
  if (inp && code) inp.value = String(code);
}

function setRotateButtonsBusy(busy) {
  ['sessSecRotateStart', 'sessSecRotatePoll', 'sessSecRotateConfirm', 'sessSecRotateFinish', 'sessSecRotateCancel'].forEach(id => {
    const btn = document.getElementById(id);
    if (btn) btn.disabled = !!busy;
  });
}

async function sessionRotatePollOnce() {
  if (!sessionSecAccountId || sessionRotatePollBusy) return false;
  sessionRotatePollBusy = true;
  sessionRotatePollAttempts += 1;
  setRotateUiHint(`Подтягиваем код из Telegram… (попытка ${sessionRotatePollAttempts})`);
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/session-rotate/poll-code`, { method: 'POST' });
    if (r.throttled) return false;
    if (r.code) {
      const inp = document.getElementById('sessSecRotateCode');
      const prev = (inp?.value || '').trim();
      applyRotateSuggestedCode(r.code);
      setRotateUiHint(`Актуальный код: ${r.code} — нажми «2. Подтвердить код»`);
      if (!prev) {
        showToast(`Код ${r.code} — нажми «Подтвердить»`);
      } else if (prev !== String(r.code)) {
        showToast(`Код обновлён: ${r.code} (был ${prev})`);
      }
      return true;
    }
    if (sessionRotatePollAttempts >= 15) {
      setRotateUiHint('Код не пришёл — введи вручную из Telegram или нажми «Подтянуть код»');
      stopSessionRotatePoll();
    }
    return false;
  } catch (e) {
    if (sessionRotatePollAttempts >= 2) {
      stopSessionRotatePoll();
      setRotateUiHint(`Ошибка подтягивания: ${toastText(e)}`);
      try {
        const r = await j(`/api/accounts/${sessionSecAccountId}/sessions`);
        if (r.account) {
          renderSessionSecurityRotate(r.account, null);
          await applySessionSecurityAccount(r.account);
        }
      } catch (_) { /* ignore */ }
    }
    return false;
  } finally {
    sessionRotatePollBusy = false;
  }
}

function startSessionRotatePoll() {
  stopSessionRotatePoll();
  sessionRotatePollAttempts = 0;
  const tick = () => sessionRotatePollOnce();
  tick();
  sessionRotatePollTimer = setInterval(tick, 6500);
}

function updateBulkBar() {
  const n = selectedAccountIds.size;
  const picked = getSelectedAccounts();
  const countEl = document.getElementById('accBulkCount');
  if (countEl) {
    countEl.textContent = n ? String(n) : '0';
    countEl.title = n
      ? (picked.length !== n
        ? `Выбрано ${n} акк. (${picked.length} в памяти) — можно из разных проектов`
        : `Выбрано ${n} акк. — можно из разных проектов`)
      : 'Ничего не выбрано';
  }
  const clearBtn = document.getElementById('accBulkClear');
  if (clearBtn) clearBtn.hidden = n === 0;
  renderAccPoolToggle();
  ACC_BULK_ACTION_IDS.forEach(id => {
    const btn = document.getElementById(id);
    if (!btn) return;
    const singleOnly = id === 'accBulkProfile' || id === 'accBulkSecurity' || id === 'accBulkWrite';
    btn.disabled = singleOnly ? n !== 1 : n === 0;
  });
  const warmupBtn = document.getElementById('accBulkWarmup');
  if (warmupBtn) {
    warmupBtn.disabled = picked.length < 2
      || picked.length % 2 !== 0
      || picked.length !== n;
  }
}

function getFilteredAccountIds() {
  return (allAccounts || [])
    .filter(a => accountMatchesTableFilters(a, accSearchQuery))
    .map(a => Number(a.id));
}

function syncAccRowSelection() {
  document.querySelectorAll('#accountsTable tbody tr.acc-row').forEach(tr => {
    const id = Number(tr.dataset.id);
    const sel = selectedAccountIds.has(id);
    tr.classList.toggle('acc-row-selected', sel);
    const chk = tr.querySelector('.acc-chk');
    if (chk) chk.checked = sel;
  });
  const filteredIds = getFilteredAccountIds();
  const sa = document.getElementById('accSelectAll');
  if (!sa) return;
  if (!filteredIds.length) {
    sa.checked = false;
    sa.indeterminate = false;
    return;
  }
  const selectedVisible = filteredIds.filter(id => selectedAccountIds.has(id)).length;
  sa.checked = selectedVisible === filteredIds.length;
  sa.indeterminate = selectedVisible > 0 && selectedVisible < filteredIds.length;
}

function handleAccRowClick(id, shift) {
  const ids = getFilteredAccountIds();

  if (shift && lastAccClickId !== null) {
    const i0 = ids.indexOf(lastAccClickId);
    const i1 = ids.indexOf(id);
    if (i0 >= 0 && i1 >= 0) {
      const [a, b] = i0 < i1 ? [i0, i1] : [i1, i0];
      for (let i = a; i <= b; i++) setAccSelected(ids[i], true);
      lastAccClickId = id;
      syncAccRowSelection();
      updateBulkBar();
      return;
    }
  }
  setAccSelected(id, !selectedAccountIds.has(id));
  lastAccClickId = id;
  syncAccRowSelection();
  updateBulkBar();
}

function setAccCheckBanner(show, text) {
  const el = document.getElementById('accCheckBanner');
  if (!el) return;
  el.hidden = !show;
  const t = el.querySelector('.acc-check-text');
  if (t) t.textContent = text || 'Проверяется…';
}

function sleepMs(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function sessionAuditJitterMs() {
  return 2000 + Math.floor(Math.random() * 2500);
}

function setAccCheckButtonsDisabled(disabled) {
  [
    'accCheckSpam', 'accSessionSecurity', 'accBulkSessionsKick', 'accBulkSecurity',
  ].forEach(id => {
    const b = document.getElementById(id);
    if (b) b.disabled = disabled;
  });
  const cancel = document.getElementById('accCheckCancel');
  if (cancel) cancel.disabled = !disabled;
}

function beginAccCheckRequest() {
  if (accCheckRequestCtrl) {
    try { accCheckRequestCtrl.abort(); } catch (_) { /* ignore */ }
  }
  accCheckRequestCtrl = new AbortController();
  return accCheckRequestCtrl.signal;
}

function abortAccCheckRequest() {
  if (!accCheckRequestCtrl) return;
  try { accCheckRequestCtrl.abort(); } catch (_) { /* ignore */ }
  accCheckRequestCtrl = null;
}

async function cancelAccountChecks(accountIds = null) {
  try {
    const body = accountIds?.length ? { account_ids: accountIds } : {};
    const r = await j('/api/accounts/cancel-checks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      timeoutMs: 10000,
    });
    return Number(r.reset || 0);
  } catch (_) {
    return 0;
  }
}

async function requestAccCheckCancel() {
  if (!accCheckRunning) return;
  accCheckAbort = true;
  setAccCheckBanner(true, 'Отмена проверки…');
  const cancelBtn = document.getElementById('accCheckCancel');
  if (cancelBtn) cancelBtn.disabled = true;
  abortAccCheckRequest();
  const pending = [...accCheckingIds];
  const reset = await cancelAccountChecks(pending.length ? pending : null);
  showToast(
    reset > 0
      ? `Отмена: сброшено ${reset} зависших статусов`
      : 'Останавливаем текущую проверку…',
    'warning',
    { duration: 5000 },
  );
}

function fmtSessDate(iso) {
  if (!iso) return '—';
  try {
    return new Date(iso).toLocaleString('ru-RU');
  } catch (_) {
    return iso;
  }
}

function renderSessionSecurityBody(audit, account, opts = {}) {
  const auths = audit?.authorizations || {};
  const pwd = audit?.password || {};
  const sessions = auths.sessions || [];
  const cached = !!opts.cached;
  const other = Number(auths.other_count || account?.session_other_count || 0);
  const total = Number(auths.total || sessions.length || 0);
  const sumEl = document.getElementById('sessSecSummary');
  const listEl = document.getElementById('sessSecSessions');
  if (!sumEl || !listEl) return;

  const chips = [
    `<span class="sess-sec-chip">Всего входов: <b>${total}</b></span>`,
    other > 0
      ? `<span class="sess-sec-chip warn">Чужих: <b>${other}</b></span>`
      : `<span class="sess-sec-chip ok">Чужих нет</span>`,
    pwd.has_2fa
      ? `<span class="sess-sec-chip warn">2FA включена${pwd.hint ? ` · подсказка: ${esc(pwd.hint)}` : ''}</span>`
      : `<span class="sess-sec-chip ok">2FA выключена — можно перелогиниться по коду</span>`,
  ];
  if (auths.authorization_ttl_days) {
    chips.push(`<span class="sess-sec-chip">TTL сессий: ${auths.authorization_ttl_days} дн.</span>`);
  }
  if (cached) {
    const at = account?.session_audit_at || audit?.audited_at || '';
    chips.push(`<span class="sess-sec-chip">Кэш${at ? ` · ${fmtSessDate(at)}` : ''} — «Обновить» для свежих данных</span>`);
  }
  sumEl.innerHTML = chips.join('');
  renderSession2faSetup(account, audit);
  renderSession2faVault(account, audit);
  syncSessionSecTtlSelect(auths.authorization_ttl_days);

  if (!sessions.length) {
    listEl.innerHTML = '<div class="empty">Нет данных — нажми «Обновить»</div>';
  } else {
  listEl.innerHTML = sessions.map(s => {
    const title = [s.device_model, s.platform, s.app_name].filter(Boolean).join(' · ') || 'Устройство';
    const loc = [s.country, s.region, s.ip].filter(Boolean).join(' · ');
    const when = `создана ${fmtSessDate(s.date_created)} · активна ${fmtSessDate(s.date_active)}`;
    const cur = s.current ? '<span class="tag tag-ok">текущая (SPAMIMPAKTER)</span>' : '<span class="tag tag-warn">чужая</span>';
    const kickBtn = s.current || !s.hash
      ? ''
      : `<button type="button" class="ghost sess-sec-kick-one" data-hash="${s.hash}" data-title="${esc(title)}">Выкинуть</button>`;
    return `
      <div class="sess-sec-item${s.current ? ' current' : ''}">
        <div class="sess-sec-item-head"><span>${esc(title)}</span><span class="sess-sec-item-tags">${cur}${kickBtn}</span></div>
        <div class="sess-sec-item-meta">${esc(s.system_version || '—')}${loc ? ` · ${esc(loc)}` : ''}</div>
        <div class="sess-sec-item-meta">${esc(when)}</div>
      </div>`;
  }).join('');
  }
  renderSessionSecurityRotate(account, audit);
}

function applyStored2faPassword(account) {
  const pwd = (account?.session_2fa_password || '').trim();
  const vaultInp = document.getElementById('sessSec2faVaultInput');
  const rotateInp = document.getElementById('sessSecRotate2fa');
  const newInp = document.getElementById('sessSec2faNewPwd');
  const curInp = document.getElementById('sessSec2faCurPwd');
  if (vaultInp && pwd) vaultInp.value = pwd;
  if (rotateInp && pwd) rotateInp.value = pwd;
  if (curInp && pwd) curInp.value = pwd;
  if (newInp && pwd && !newInp.value) newInp.value = pwd;
}

function syncSessionSecTtlSelect(days) {
  const sel = document.getElementById('sessSecTtlSelect');
  if (!sel || !days) return;
  const v = String(days);
  if ([...sel.options].some(o => o.value === v)) sel.value = v;
}

function renderSession2faSetup(account, audit) {
  const pwd = audit?.password || {};
  const has2fa = !!(account?.session_has_2fa || pwd.has_2fa);
  const hint = (account?.session_2fa_hint || pwd.hint || '').trim();
  const tag = document.getElementById('sessSec2faModeTag');
  const curWrap = document.getElementById('sessSec2faCurWrap');
  const btnEnable = document.getElementById('sessSec2faEnable');
  const btnChange = document.getElementById('sessSec2faChange');
  const btnDisable = document.getElementById('sessSec2faDisable');
  const recovery = document.getElementById('sessSec2faRecovery');
  const hintInp = document.getElementById('sessSec2faHint');
  if (tag) {
    tag.textContent = has2fa ? 'включена' : 'выключена';
    tag.className = `tag ${has2fa ? 'tag-warn' : 'tag-ok'}`;
  }
  if (curWrap) curWrap.hidden = !has2fa;
  if (btnEnable) btnEnable.hidden = has2fa;
  if (btnChange) btnChange.hidden = !has2fa;
  if (btnDisable) btnDisable.hidden = !has2fa;
  if (recovery) recovery.hidden = !has2fa || !pwd.has_recovery;
  if (hintInp && hint && !hintInp.value) hintInp.value = hint;
  const emailPending = !!(pwd.email_pending || pwd.email_unconfirmed_pattern);
  showSession2faEmailBox(emailPending);
  if (emailPending && btnEnable) btnEnable.disabled = true;
  else if (btnEnable) btnEnable.disabled = false;
  if (tag && emailPending) {
    tag.textContent = 'ждёт код email';
    tag.className = 'tag tag-warn';
  }
  fillSession2faEmails(account, audit);
  applyStored2faPassword(account);
  applySessSecSensitiveVisibility();
}

function renderSession2faVault(account, audit) {
  const box = document.getElementById('sessSec2faVault');
  if (!box) return;
  const pwd = audit?.password || {};
  const has2fa = !!(account?.session_has_2fa || pwd.has_2fa);
  const saved = !!(account?.session_2fa_saved || (account?.session_2fa_password || '').trim());
  box.hidden = !(has2fa || saved);
  const hintEl = document.getElementById('sessSec2faVaultHint');
  const tagEl = document.getElementById('sessSec2faSavedTag');
  const inp = document.getElementById('sessSec2faVaultInput');
  const hint = (account?.session_2fa_hint || pwd.hint || '').trim();
  if (hintEl) {
    let text = 'Хранится локально в базе — подставится при ротации сессии.';
    if (hint) text += ` Подсказка Telegram: «${hint}».`;
    hintEl.textContent = text;
  }
  if (tagEl) tagEl.hidden = !saved;
  if (inp && (account?.session_2fa_password || '').trim()) {
    inp.value = account.session_2fa_password;
  }
  applyStored2faPassword(account);
  applySessSecSensitiveVisibility();
}

async function saveSession2faPassword() {
  if (!sessionSecAccountId) return;
  const inp = document.getElementById('sessSec2faVaultInput');
  const password = (inp?.value || '').trim();
  if (!password) return showToast('Введи пароль 2FA', 'err');
  const recovery_email = (document.getElementById('sessSec2faEmail')?.value || '').trim() || null;
  const btn = document.getElementById('sessSec2faSave');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa-password`, {
      method: 'PUT',
      body: JSON.stringify({ password, recovery_email }),
    });
    showToast('Пароль 2FA сохранён');
    applyStored2faPassword(r.account);
    renderSession2faVault(r.account, null);
    fillSession2faEmails(r.account, null);
    await applySessionSecurityAccount(r.account);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

function applySessionSecurityResult(r) {
  if (r?.audit && r?.account) {
    renderSessionSecurityBody(r.audit, r.account);
  }
  if (r?.account) applySessionSecurityAccount(r.account);
}

function showSession2faEmailBox(show) {
  const box = document.getElementById('sessSec2faEmailBox');
  if (box) box.hidden = !show;
}

async function loadSessionPrivacy() {
  if (!sessionSecAccountId) return;
  const r = await j(`/api/accounts/${sessionSecAccountId}/privacy`);
  const p = r.privacy || {};
  const map = { phone: 'sessSecPrivPhone', last_seen: 'sessSecPrivLastSeen', photo: 'sessSecPrivPhoto' };
  Object.entries(map).forEach(([k, id]) => {
    const el = document.getElementById(id);
    if (el && p[k]) el.value = p[k];
  });
}

async function loadWebSessions() {
  if (!sessionSecAccountId) return;
  const list = document.getElementById('sessSecWebList');
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/web-sessions`);
    const sessions = r.web?.sessions || [];
    if (!list) return;
    if (!sessions.length) {
      list.hidden = true;
      list.innerHTML = '';
      return;
    }
    list.hidden = false;
    list.innerHTML = sessions.map(s => {
      const title = [s.domain, s.browser, s.platform].filter(Boolean).join(' · ') || 'Web';
      return `<div class="sess-sec-web-item">${esc(title)} · ${esc(fmtSessDate(s.date_active))}</div>`;
    }).join('');
  } catch (_) {
    if (list) { list.hidden = true; list.innerHTML = ''; }
  }
}

async function applySessionTtl() {
  if (!sessionSecAccountId) return;
  const sel = document.getElementById('sessSecTtlSelect');
  const days = Number(sel?.value || 0);
  if (!days) return showToast('Выбери TTL', 'err');
  if (!confirm(`Установить TTL сессий: ${days} дн.?`)) return;
  const btn = document.getElementById('sessSecTtlApply');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/sessions/ttl`, {
      method: 'POST',
      body: JSON.stringify({ days }),
    });
    showToast('TTL обновлён');
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function resetWebSessions() {
  if (!sessionSecAccountId) return;
  if (!confirm('Выйти из всех web.telegram.org и виджетов на этом аккаунте?')) return;
  const btn = document.getElementById('sessSecWebReset');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/web-sessions/reset`, { method: 'POST' });
    showToast('Web-сессии завершены');
    applySessionSecurityResult(r);
    await loadWebSessions();
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function applySessionPrivacy() {
  if (!sessionSecAccountId) return;
  const settings = {};
  const phone = document.getElementById('sessSecPrivPhone')?.value;
  const lastSeen = document.getElementById('sessSecPrivLastSeen')?.value;
  const photo = document.getElementById('sessSecPrivPhoto')?.value;
  if (phone) settings.phone = phone;
  if (lastSeen) settings.last_seen = lastSeen;
  if (photo) settings.photo = photo;
  if (!Object.keys(settings).length) return showToast('Выбери настройки', 'err');
  const btn = document.getElementById('sessSecPrivacyApply');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/privacy`, {
      method: 'PUT',
      body: JSON.stringify({ settings }),
    });
    showToast('Приватность обновлена');
    applySessionSecurityResult(r);
    if (r.privacy) {
      const map = { phone: 'sessSecPrivPhone', last_seen: 'sessSecPrivLastSeen', photo: 'sessSecPrivPhoto' };
      Object.entries(map).forEach(([k, id]) => {
        const el = document.getElementById(id);
        if (el && r.privacy[k]) el.value = r.privacy[k];
      });
    } else {
      await loadSessionPrivacy();
    }
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function enableSession2fa() {
  if (!sessionSecAccountId) return;
  const password = (document.getElementById('sessSec2faNewPwd')?.value || '').trim();
  const hint = (document.getElementById('sessSec2faHint')?.value || '').trim();
  const email = (document.getElementById('sessSec2faEmail')?.value || '').trim() || null;
  const save_to_vault = !!document.getElementById('sessSec2faSaveVault')?.checked;
  if (!password) return showToast('Введи пароль 2FA', 'err');
  if (!confirm('Включить облачный пароль на этом аккаунте в Telegram?')) return;
  const btn = document.getElementById('sessSec2faEnable');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/enable`, {
      method: 'POST',
      body: JSON.stringify({ password, hint, email, save_to_vault }),
    });
    if (r.step === 'email_code') {
      showSession2faEmailBox(true);
      showToast(r.message || 'Введи код с письма');
    } else {
      showSession2faEmailBox(false);
      showToast('2FA включена');
    }
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function changeSession2fa() {
  if (!sessionSecAccountId) return;
  const current_password = (document.getElementById('sessSec2faCurPwd')?.value || '').trim();
  const new_password = (document.getElementById('sessSec2faNewPwd')?.value || '').trim();
  const hint = (document.getElementById('sessSec2faHint')?.value || '').trim();
  const email = (document.getElementById('sessSec2faEmail')?.value || '').trim() || null;
  const save_to_vault = !!document.getElementById('sessSec2faSaveVault')?.checked;
  if (!current_password || !new_password) return showToast('Введи текущий и новый пароль', 'err');
  const btn = document.getElementById('sessSec2faChange');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/change`, {
      method: 'POST',
      body: JSON.stringify({ current_password, new_password, hint, email, save_to_vault }),
    });
    if (r.step === 'email_code') {
      showSession2faEmailBox(true);
      showToast(r.message || 'Подтверди новый email');
    } else {
      showSession2faEmailBox(false);
      showToast('Пароль 2FA изменён');
    }
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function confirmSession2faEmail() {
  if (!sessionSecAccountId) return;
  const code = (document.getElementById('sessSec2faEmailCode')?.value || '').trim();
  if (!code) return showToast('Введи код с письма', 'err');
  try {
    const recovery_email = (document.getElementById('sessSec2faEmail')?.value || '').trim() || null;
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/confirm-email`, {
      method: 'POST',
      body: JSON.stringify({ code, recovery_email }),
    });
    showSession2faEmailBox(false);
    showToast('Recovery email подтверждён');
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  }
}

async function resendSession2faEmail() {
  if (!sessionSecAccountId) return;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/resend-email`, { method: 'POST' });
    showToast('Код отправлен снова');
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  }
}

async function cancelSession2faEmail() {
  if (!sessionSecAccountId) return;
  if (!confirm('Отменить подтверждение recovery email?')) return;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/cancel-email`, { method: 'POST' });
    showSession2faEmailBox(false);
    showToast('Подтверждение email отменено');
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  }
}

async function disableSession2fa() {
  if (!sessionSecAccountId) return;
  const password = (document.getElementById('sessSec2faCurPwd')?.value || '').trim()
    || (document.getElementById('sessSec2faVaultInput')?.value || '').trim();
  if (!password) return showToast('Введи текущий пароль 2FA', 'err');
  if (!confirm('Отключить 2FA на аккаунте? Это снизит защиту.')) return;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/disable`, {
      method: 'POST',
      body: JSON.stringify({ password }),
    });
    showSession2faEmailBox(false);
    showToast('2FA отключена');
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  }
}

async function sendSession2faRecovery() {
  if (!sessionSecAccountId) return;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/recovery/send`, { method: 'POST' });
    showToast(r.message || `Код отправлен${r.email_pattern ? ' на ' + r.email_pattern : ''}`);
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  }
}

async function confirmSession2faRecovery() {
  if (!sessionSecAccountId) return;
  const code = (document.getElementById('sessSec2faRecoveryCode')?.value || '').trim();
  if (!code) return showToast('Введи код восстановления', 'err');
  if (!confirm('Сбросить 2FA по коду? Пароль будет удалён в Telegram.')) return;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa/recovery/confirm`, {
      method: 'POST',
      body: JSON.stringify({ code }),
    });
    showToast(r.message || '2FA сброшена');
    applySessionSecurityResult(r);
  } catch (e) {
    showToast(e, 'err');
  }
}

async function clearSession2faPassword() {
  if (!sessionSecAccountId) return;
  if (!confirm('Удалить сохранённый пароль 2FA для этого аккаунта?')) return;
  const btn = document.getElementById('sessSec2faClear');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/2fa-password`, {
      method: 'PUT',
      body: JSON.stringify({ password: '' }),
    });
    const inp = document.getElementById('sessSec2faVaultInput');
    const rotateInp = document.getElementById('sessSecRotate2fa');
    if (inp) inp.value = '';
    if (rotateInp) rotateInp.value = '';
    showToast('Пароль 2FA удалён');
    renderSession2faVault(r.account, null);
    await applySessionSecurityAccount(r.account);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

function fmtRotateWait(seconds) {
  const sec = Number(seconds) || 0;
  if (sec <= 0) return 'можно завершать';
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  return `осталось ~${h} ч ${m} мин`;
}

function fmtRotateWaitShort(seconds) {
  const sec = Number(seconds) || 0;
  if (sec <= 0) return '0м';
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  if (h > 0) return m > 0 ? `${h}ч${m}м` : `${h}ч`;
  return `${Math.max(1, m)}м`;
}

function renderSessionSecurityRotate(account, audit) {
  const box = document.getElementById('sessSecRotate');
  if (!box) return;
  const pwd = audit?.password || {};
  const has2fa = !!(account?.session_has_2fa || pwd.has_2fa);
  box.hidden = false;
  const status = (account?.session_rotate_status || '').trim() || 'idle';
  const left = Number(account?.session_rotate_seconds_left || 0);
  const ready = !!account?.session_rotate_ready;
  const codeExpired = !!account?.session_rotate_code_expired;
  const awaiting2fa = !!account?.session_rotate_awaiting_2fa;
  const flood2fa = Number(account?.session_rotate_flood_2fa_left || 0);
  const stateEl = document.getElementById('sessSecRotateState');
  const codeBox = document.getElementById('sessSecRotateCodeBox');
  const btnStart = document.getElementById('sessSecRotateStart');
  const btnConfirm = document.getElementById('sessSecRotateConfirm');
  const btnFinish = document.getElementById('sessSecRotateFinish');
  const btnCancel = document.getElementById('sessSecRotateCancel');

  const accRow = (allAccounts || []).find(a => Number(a.id) === Number(sessionSecAccountId));
  const poolConnecting = !!(accPoolUi.connecting || accPoolBusy);
  const pxHint = accRow?.proxy_host
    ? ` Прокси: ${accRow.proxy_host}${accRow.proxy_port ? ':' + accRow.proxy_port : ''}.`
    : '';
  let stateText = has2fa
    ? `Шаг 1: запроси код. На шаге 2 — код и пароль 2FA.${pxHint}`
    : `Шаг 1: запроси код — он придёт в чат Telegram на этом аккаунте.${pxHint}`;
  if (flood2fa > 0) {
    const min = Math.max(1, Math.ceil(flood2fa / 60));
    stateText = `Telegram заблокировал ввод 2FA на ~${min} мин. Ничего не нажимай — таймер на стороне Telegram.`;
  } else if (status === 'awaiting_code') {
    stateText = awaiting2fa
      ? 'Код принят. Введи пароль 2FA и нажми «Подтвердить» — новый код не нужен.'
      : codeExpired
      ? 'Код истёк (~5 мин). Нажми «1. Запросить код» — отправим новый.'
      : (rotateUiHint || 'Код в диалоге Telegram — подтягиваем самый свежий…');
  } else if (status === 'waiting_24h') {
    rotateUiHint = '';
    stateText = ready
      ? '24 ч прошли — можно завершить: старая .session уйдёт в archive/.'
      : `Новая сессия ждёт: ${fmtRotateWait(left)}. Старую пока не трогай.`;
  }
  if (poolConnecting) {
    const prog = accPoolUi.total
      ? ` (${accPoolUi.online}/${accPoolUi.total})`
      : '';
    stateText = `Идёт массовое подключение${prog} — ротацию запусти после ⏹ или когда все online.`;
  }
  if (stateEl) stateEl.textContent = stateText;

  const btnPoll = document.getElementById('sessSecRotatePoll');
  const twofaBox = document.getElementById('sessSecRotate2faBox');
  if (codeBox) codeBox.hidden = status !== 'awaiting_code';
  if (twofaBox) twofaBox.hidden = !(has2fa && status === 'awaiting_code');
  if (btnStart) {
    btnStart.hidden = status === 'waiting_24h';
    btnStart.textContent = codeExpired
      ? '1. Запросить новый код'
      : '1. Запросить код';
  }
  if (btnPoll) btnPoll.hidden = status !== 'awaiting_code';
  if (btnConfirm) {
    btnConfirm.hidden = status !== 'awaiting_code';
    btnConfirm.disabled = poolConnecting
      || (status === 'awaiting_code' && codeExpired && !awaiting2fa)
      || flood2fa > 0;
  }
  if (btnStart) {
    if (flood2fa > 0) btnStart.disabled = true;
    else if (poolConnecting) btnStart.disabled = true;
  }
  if (btnFinish) {
    const waiting = status === 'waiting_24h';
    const canFinish = waiting && ready && !poolConnecting;
    btnFinish.hidden = !waiting;
    btnFinish.disabled = !canFinish;
    btnFinish.classList.toggle('ub-btn-primary', canFinish);
    btnFinish.classList.toggle('ub-btn-secondary', waiting && !canFinish);
    btnFinish.classList.toggle('rotate-finish-locked', waiting && !canFinish);
    btnFinish.textContent = canFinish
      ? '3. Завершить ротацию'
      : `3. Завершить (ещё ${fmtRotateWaitShort(left)})`;
    btnFinish.title = canFinish
      ? 'Старая .session уйдёт в archive/, чужие сессии сбросятся'
      : (waiting ? `Недоступно — ${fmtRotateWait(left)}` : '');
  }
  if (btnCancel) btnCancel.hidden = !status || status === 'idle';
}

async function applySessionSecurityAccount(account) {
  if (!account?.id) return;
  const safe = { ...account };
  delete safe.session_2fa_password;
  const i = (allAccounts || []).findIndex(a => Number(a.id) === Number(account.id));
  if (i >= 0) allAccounts[i] = { ...allAccounts[i], ...safe };
  renderAccountsTableBody();
}

async function openSessionSecurityModal(accountId, opts = {}) {
  const id = Number(accountId);
  if (!id) return;
  const refresh = !!opts.refresh;
  sessionSecAccountId = id;
  const acc = (allAccounts || []).find(a => Number(a.id) === id);
  const sub = document.getElementById('sessSecSubtitle');
  if (sub) sub.textContent = acc ? `${acc.phone}${acc.username ? ' · @' + acc.username : ''}` : `Аккаунт #${id}`;
  openModal('modalSessionSecurity');
  initSessSecHideCheckbox();
  document.getElementById('sessSecSummary').innerHTML = '<div class="empty">Загрузка…</div>';
  document.getElementById('sessSecSessions').innerHTML = '';
  try {
    const accOnline = acc && accountProfileIsOnline(acc);
    const needLive = refresh || accOnline;
    const q = needLive ? '?refresh=true&keep_online=true' : '';
    const url = `/api/accounts/${id}/sessions${q}`;
    const r = await j(url);
    const merged = r.account || acc;
    renderSessionSecurityBody(r.audit, merged, { cached: !!r.cached });
    await applySessionSecurityAccount(merged);
    if ((merged?.session_rotate_status || '').trim() === 'awaiting_code') {
      const inp = document.getElementById('sessSecRotateCode');
      if (!inp?.value?.trim()) {
        setRotateUiHint('Ротация ждёт код — нажми «Подтянуть код» или введи вручную');
      }
    }
  } catch (e) {
    const msg = toastText(e);
    document.getElementById('sessSecSummary').innerHTML = `<div class="err">${esc(msg)}</div>`;
    const fatal = isSessionFatalError(msg);
    showToast(msg, 'err', { duration: fatal ? 12000 : 7000 });
    await loadAccountsTable();
  }
}

async function refreshSessionSecurityModal() {
  if (!sessionSecAccountId) return;
  const btn = document.getElementById('sessSecRefresh');
  if (btn) btn.disabled = true;
  try {
    const draft = (document.getElementById('sessSec2faEmail')?.value || '').trim();
    if (draft) {
      const saved = await persistSession2faRecoveryDraft();
      if (saved) await applySessionSecurityAccount(saved);
    }
    await openSessionSecurityModal(sessionSecAccountId, { refresh: true });
    loadSessionPrivacy().catch(() => {});
    loadWebSessions().catch(() => {});
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function kickOneSession(sessionHash, title) {
  if (!sessionSecAccountId) return;
  const label = (title || '').trim() || 'эту сессию';
  if (!confirm(`Выкинуть сессию?\n\n${label}\n\nТекущая сессия SPAMIMPAKTER не затронута.`)) return;
  const listEl = document.getElementById('sessSecSessions');
  const btns = listEl ? listEl.querySelectorAll('.sess-sec-kick-one') : [];
  btns.forEach(b => { b.disabled = true; });
  try {
    const hashStr = String(sessionHash ?? '').trim();
    if (!hashStr || hashStr === '0') {
      showToast('Не удалось определить сессию — нажми «Обновить»', 'err');
      return;
    }
    const r = await j(`/api/accounts/${sessionSecAccountId}/sessions/reset-one`, {
      method: 'POST',
      body: JSON.stringify({ session_hash: hashStr }),
    });
    const reset = r.reset || {};
    if (reset.fresh_wait) {
      showToast(reset.error || 'Сессия моложе 24 ч — подожди', 'err');
    } else if (reset.ok) {
      showToast('Сессия завершена');
    } else {
      showToast(reset.error || 'Не удалось выкинуть', 'err');
    }
    renderSessionSecurityBody(r.audit, r.account);
    await applySessionSecurityAccount(r.account);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    btns.forEach(b => { b.disabled = false; });
  }
}

async function kickOtherSessionsForModal() {
  if (!sessionSecAccountId) return;
  if (!confirm('Выкинуть все чужие Telegram-сессии на этом аккаунте?\n\nОстанется только текущая сессия софта.')) return;
  const btn = document.getElementById('sessSecKickOthers');
  if (btn) btn.disabled = true;
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/sessions/reset-others`, { method: 'POST' });
    const reset = r.reset || {};
    if (reset.fresh_wait) {
      showToast(reset.error || 'Сессия моложе 24 ч — подожди', 'err');
    } else if (reset.ok) {
      showToast('Чужие сессии завершены');
    } else {
      showToast(reset.error || 'Не удалось выкинуть', 'err');
    }
    renderSessionSecurityBody(r.audit, r.account);
    await applySessionSecurityAccount(r.account);
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
}

async function sessionRotateStart() {
  if (!sessionSecAccountId) return showToast('Нет выбранного аккаунта', 'err');
  if (accPoolUi.connecting || accPoolBusy) {
    return showToast('Идёт массовое подключение — дождись окончания или нажми ⏹', 'err');
  }
  if (!confirm(
    'Запросить код для новой сессии?\n\n'
    + 'Старая сессия останется онлайн — код придёт в Telegram и подтянется сам.'
  )) return;
  const btn = document.getElementById('sessSecRotateStart');
  const btnLabel = btn?.textContent || '';
  const inp = document.getElementById('sessSecRotateCode');
  if (inp) inp.value = '';
  stopSessionRotatePoll();
  rotateUiHint = '';
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Отправляем…';
  }
  setRotateButtonsBusy(true);
  setRotateUiHint('Подключаемся и запрашиваем код у Telegram…');
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/session-rotate/start`, {
      method: 'POST',
      timeoutMs: 90000,
    });
    const acc = r.account;
    renderSessionSecurityRotate(acc, null);
    await applySessionSecurityAccount(acc);
    if (r.already_sent) {
      if (r.suggested_code) applyRotateSuggestedCode(r.suggested_code);
      setRotateUiHint('Действующий код уже в Telegram — подтверди, новый не шлём');
      showToast(r.message || 'Подтверди текущий код');
    } else {
      if (r.suggested_code) {
        applyRotateSuggestedCode(r.suggested_code);
        setRotateUiHint(`Код ${r.suggested_code} — подтверди в течение ~5 мин`);
        showToast(r.message || 'Код подтянут — нажми «Подтвердить код»');
      } else {
        setRotateUiHint('Код отправлен. Подтягиваем из чата Telegram…');
        showToast(r.message || 'Код отправлен — подтягиваем…');
      }
      startSessionRotatePoll();
    }
  } catch (e) {
    setRotateUiHint('');
    stopSessionRotatePoll();
    showToast(e, 'err');
    try {
      const r = await j(`/api/accounts/${sessionSecAccountId}/sessions`);
      if (r.account) {
        renderSessionSecurityRotate(r.account, null);
        await applySessionSecurityAccount(r.account);
      }
    } catch (_) { /* ignore */ }
  } finally {
    setRotateButtonsBusy(false);
    if (btn) {
      btn.disabled = false;
      btn.textContent = btnLabel || '1. Запросить код';
    }
  }
}

async function sessionRotatePoll() {
  if (!sessionSecAccountId) return showToast('Нет выбранного аккаунта', 'err');
  if (sessionRotatePollBusy) return showToast('Уже ищем код…', 'err');
  stopSessionRotatePoll();
  const btn = document.getElementById('sessSecRotatePoll');
  const btnLabel = btn?.textContent || '';
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Ищем…';
  }
  try {
    const ok = await sessionRotatePollOnce();
    if (ok) return;
    showToast('Синхронизируем Telegram и ищем код…');
    startSessionRotatePoll();
  } catch (e) {
    showToast(e, 'err');
  } finally {
    if (btn) {
      btn.disabled = false;
      btn.textContent = btnLabel || 'Подтянуть код';
    }
  }
}

let sessionRotateConfirmBusy = false;
let sessionRotateFinishBusy = false;

async function sessionRotateConfirm() {
  if (!sessionSecAccountId) return showToast('Нет выбранного аккаунта', 'err');
  if (sessionRotateConfirmBusy) return;
  if (accPoolUi.connecting || accPoolBusy) {
    return showToast('Идёт массовое подключение — дождись окончания или нажми ⏹', 'err');
  }
  const acc = (allAccounts || []).find(a => Number(a.id) === Number(sessionSecAccountId));
  const flood2fa = Number(acc?.session_rotate_flood_2fa_left || 0);
  if (flood2fa > 0) {
    return showToast(`Подожди ~${Math.ceil(flood2fa / 60)} мин — Telegram блокирует 2FA`, 'err');
  }
  stopSessionRotatePoll();
  rotateUiHint = '';
  const code = (document.getElementById('sessSecRotateCode')?.value || '').trim();
  const awaiting2fa = !!acc?.session_rotate_awaiting_2fa;
  if (!code && !awaiting2fa) return showToast('Введи код из Telegram', 'err');
  const password = (document.getElementById('sessSecRotate2fa')?.value || '').trim() || null;
  const btn = document.getElementById('sessSecRotateConfirm');
  const cancelBtn = document.getElementById('sessSecRotateCancel');
  const btnLabel = btn?.textContent || '';
  const cancelWasDisabled = !!cancelBtn?.disabled;
  sessionRotateConfirmBusy = true;
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Подключаем…';
  }
  if (cancelBtn) cancelBtn.disabled = true;
  try {
    const body = { code };
    if (password) body.password = password;
    const r = await j(`/api/accounts/${sessionSecAccountId}/session-rotate/confirm`, {
      method: 'POST',
      body: JSON.stringify(body),
      timeoutMs: 120000,
    });
    if (r.used_code) applyRotateSuggestedCode(r.used_code);
    showToast(r.message || 'Новая сессия создана — жди 24 ч');
    renderSessionSecurityRotate(r.account, null);
    await applySessionSecurityAccount(r.account);
  } catch (e) {
    if (e?.data?.account) {
      await applySessionSecurityAccount(e.data.account);
      renderSessionSecurityRotate(e.data.account, null);
    }
    const msg = toastText(e);
    if (/2FA|пароль|FLOOD|заблокировал/i.test(msg)) {
      const pwdInp = document.getElementById('sessSecRotate2fa');
      if (pwdInp) {
        pwdInp.value = '';
        pwdInp.focus();
      }
      setRotateUiHint('Код принят. Введи правильный пароль 2FA вручную → Подтвердить');
      showToast(msg, 'err');
    } else {
      showToast(e, 'err');
    }
  } finally {
    sessionRotateConfirmBusy = false;
    if (btn) {
      btn.disabled = false;
      btn.textContent = btnLabel || '2. Подтвердить код';
    }
    if (cancelBtn) cancelBtn.disabled = cancelWasDisabled;
  }
}

async function sessionRotateFinish() {
  if (!sessionSecAccountId) return;
  if (sessionRotateFinishBusy) return;
  if (accPoolUi.connecting || accPoolBusy) {
    return showToast('Идёт массовое подключение — дождись окончания или нажми ⏹', 'err');
  }
  const acc = (allAccounts || []).find(a => Number(a.id) === Number(sessionSecAccountId));
  const left = Number(acc?.session_rotate_seconds_left || 0);
  const ready = !!acc?.session_rotate_ready;
  if ((acc?.session_rotate_status || '').trim() === 'waiting_24h' && !ready) {
    return showToast(`Рано — подожди ещё ${fmtRotateWait(left)}`, 'err');
  }
  if (!confirm('Завершить ротацию?\n\nСтарая .session уйдёт в archive/, все старые входы будут сброшены.')) return;
  const btn = document.getElementById('sessSecRotateFinish');
  const btnLabel = btn?.textContent || '';
  sessionRotateFinishBusy = true;
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Завершаем…';
  }
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/session-rotate/finish`, {
      method: 'POST',
      timeoutMs: 150000,
    });
    showToast(r.message || 'Ротация завершена');
    await openSessionSecurityModal(sessionSecAccountId, { refresh: true });
  } catch (e) {
    showToast(e, 'err');
  } finally {
    sessionRotateFinishBusy = false;
    if (btn) {
      btn.disabled = false;
      btn.textContent = btnLabel || '3. Завершить ротацию';
    }
  }
}

async function sessionRotateCancel() {
  if (!sessionSecAccountId) return;
  stopSessionRotatePoll();
  rotateUiHint = '';
  if (!confirm('Отменить ротацию? Новая сессия в rotate/ будет удалена.')) return;
  const codeInp = document.getElementById('sessSecRotateCode');
  const pwdInp = document.getElementById('sessSecRotate2fa');
  if (codeInp) codeInp.value = '';
  if (pwdInp) pwdInp.value = '';
  try {
    const r = await j(`/api/accounts/${sessionSecAccountId}/session-rotate/cancel`, { method: 'POST' });
    showToast(r.message || 'Отменено');
    renderSessionSecurityRotate(r.account, null);
    await applySessionSecurityAccount(r.account);
  } catch (e) {
    showToast(e, 'err');
  }
}

function formatSessionBulkSummary(summary, action, total) {
  const s = summary || {};
  if (action === 'reset_others') {
    const parts = [];
    if (s.reset_ok) parts.push(`выкинули ${s.reset_ok}`);
    if (s.fresh_wait) parts.push(`ждут 24ч: ${s.fresh_wait}`);
    if (s.with_others) parts.push(`ещё чужих: ${s.with_others}`);
    if (s.errors) parts.push(`ошибок ${s.errors}`);
    return parts.length ? parts.join(' · ') : `обработано ${total}`;
  }
  const parts = [`проверено ${s.audited || total}`];
  if (s.with_others) parts.push(`с чужими: ${s.with_others}`);
  if (s.with_2fa) parts.push(`2FA: ${s.with_2fa}`);
  if (s.errors) parts.push(`ошибок ${s.errors}`);
  return parts.join(' · ');
}

const ACC_DEAD_STATUSES = new Set([
  'dead', 'session_revoked', 'banned', 'deactivated', 'frozen',
]);

function accountIsSessionDead(a) {
  if (!a) return false;
  const st = String(a.status || '').toLowerCase();
  if (ACC_DEAD_STATUSES.has(st)) return true;
  return (a.filter_tags || []).includes('dead');
}

async function runBulkSessionSecurity(action) {
  if (accCheckRunning) {
    showToast('Проверка уже идёт — дождись завершения', 'err');
    return;
  }
  let targetIds = getSelectedAccountIds();
  if (!targetIds.length) {
    if (!allAccounts.length) await loadAccountsTable();
    targetIds = getFilteredAccountIds();
  }
  if (!targetIds.length) return showToast('Нет аккаунтов в фильтре', 'err');

  const isKick = action === 'reset_others';
  const label = isKick ? 'Выкинуть чужих' : 'Проверка';
  const n = targetIds.length;
  if (isKick && !confirm(
    `${label} — ${n} акк.\n\nОстанется только текущая .session софта на каждом.\n`
    + 'Underground Brand и остальные — убедись, что выбрал нужный проект (например Колумбия).'
  )) return;
  if (!isKick && n > 4 && !confirm(
    `Проверка ${n} акк. — сессия, 2FA, чужие устройства.\n\n`
    + `По ${ACC_CHECK_PARALLEL} параллельно (сначала подключим offline).\n`
    + `Ориентир: ~${Math.max(1, Math.ceil(n / ACC_CHECK_PARALLEL))} мин.\n\nПродолжить?`
  )) return;

  if (!(await prepareForAccountChecks(targetIds))) return;

  accCheckRunning = true;
  accCheckAbort = false;
  accCheckingIds = new Set();
  setAccCheckBanner(true, `${label}: 0/${targetIds.length}…`);
  setAccCheckButtonsDisabled(true);
  await loadAccountsTable();

  let done = 0;
  const failSamples = [];
  const summary = { audited: 0, with_others: 0, with_2fa: 0, reset_ok: 0, fresh_wait: 0, errors: 0 };

  async function auditOne(id) {
    const cached = (allAccounts || []).find(a => Number(a.id) === Number(id));
    if (!isKick && accountIsSessionDead(cached)) {
      summary.errors++;
      failSamples.push(
        `${cached?.phone || '#' + id}: сессия мёртва — переимпорт, проверка не нужна`,
      );
      return;
    }
    const sessUrl = action === 'reset_others'
      ? `/api/accounts/${id}/sessions/reset-others`
      : `/api/accounts/${id}/sessions?refresh=true&keep_online=true`;
    const row = action === 'reset_others'
      ? await j(sessUrl, { method: 'POST', timeoutMs: 50000 })
      : await j(sessUrl, { timeoutMs: 50000 });
    const acc = row.account;
    if (acc?.id) await applySessionSecurityAccount(acc);
    const audit = row.audit || {};
    const pwd = audit.password || {};
    if (action === 'audit') {
      summary.audited++;
      if (Number(acc?.session_other_count || audit.authorizations?.other_count || 0) > 0) {
        summary.with_others++;
      }
      if (acc?.session_has_2fa || pwd.has_2fa) summary.with_2fa++;
    } else {
      const reset = row.reset || {};
      if (reset.fresh_wait) {
        summary.fresh_wait++;
        failSamples.push(`${acc?.phone || id}: ждать 24ч`);
      } else if (reset.ok) {
        summary.reset_ok++;
      } else {
        summary.errors++;
        failSamples.push(`${acc?.phone || id}: ${reset.error || 'сброс не удался'}`);
      }
      if (Number(acc?.session_other_count || 0) > 0) summary.with_others++;
    }
  }

  try {
    for (let i = 0; i < targetIds.length; i += ACC_CHECK_PARALLEL) {
      if (accCheckAbort) break;
      const chunk = targetIds.slice(i, i + ACC_CHECK_PARALLEL);
      chunk.forEach(id => accCheckingIds.add(id));
      setAccCheckBanner(true, `${label}: ${done}/${targetIds.length}…`);
      renderAccountsTableBody();
      const outcomes = await Promise.allSettled(chunk.map(id => auditOne(id)));
      outcomes.forEach((out, idx) => {
        const id = chunk[idx];
        if (out.status === 'rejected') {
          if (accCheckAbort) return;
          summary.errors++;
          const acc = (allAccounts || []).find(a => Number(a.id) === Number(id));
          failSamples.push(`${acc?.phone || '#' + id}: ${toastText(out.reason)}`);
        }
        accCheckingIds.delete(id);
        done++;
      });
      renderAccountsTableBody();
      if (accCheckAbort) break;
      if (!isKick && i + ACC_CHECK_PARALLEL < targetIds.length) {
        await sleepMs(800);
      }
    }
    if (accCheckAbort) {
      showToast(`Проверка отменена (${done}/${targetIds.length})`, 'warning', { duration: 8000 });
    } else {
      let toast = formatSessionBulkSummary(summary, action, targetIds.length);
      if (failSamples.length) toast += ` · ${failSamples.slice(0, 2).join('; ')}`;
      const isErr = summary.errors > 0 || (action === 'reset_others' && summary.fresh_wait > 0 && !summary.reset_ok);
      showToast(toast, isErr ? 'err' : 'ok', { duration: isErr ? 10000 : 2600 });
    }
  } catch (e) {
    if (!accCheckAbort) showToast(e, 'err', { duration: 10000 });
  } finally {
    abortAccCheckRequest();
    accCheckingIds.clear();
    accCheckRunning = false;
    accCheckAbort = false;
    setAccCheckBanner(false);
    await loadAccountsTable();
    setAccCheckButtonsDisabled(false);
    await loadAccountsTable();
    await updateAccPoolStatusUi();
  }
}

function formatCheckSummary(stats, checkSpam, total) {
  if (!checkSpam) return `Проверено сессий: ${total}`;
  const parts = [];
  if (stats.clean) parts.push(`чистых ${stats.clean}`);
  if (stats.spamblock) parts.push(`спамблок ${stats.spamblock}`);
  if (stats.blocked) parts.push(`заблок. ${stats.blocked}`);
  if (stats.unknown.length) {
    parts.push(`не распознано ${stats.unknown.length} (${stats.unknown.join(', ')})`);
  }
  if (stats.errors.length) parts.push(`ошибок ${stats.errors.length}`);
  return parts.length ? `SpamBot: ${parts.join(' · ')}` : `SpamBot: проверено ${total}`;
}

async function runAccountChecks(checkSpam) {
  if (accCheckRunning) {
    showToast('Проверка уже идёт — жми «Отмена» в синей полоске', 'warning');
    return;
  }
  let targetIds = getSelectedAccountIds();
  if (!targetIds.length) {
    if (!allAccounts.length) await loadAccountsTable();
    targetIds = getFilteredAccountIds();
  }
  if (!targetIds.length) return;
  if (checkSpam && !confirm(
    `SpamBot — ${targetIds.length} акк., по ${ACC_CHECK_PARALLEL} параллельно.\n`
    + 'Уже online — сразу проверка, без переподключения. OK?',
  )) return;

  if (!(await prepareForAccountChecks(targetIds))) return;

  accCheckAbort = false;
  accCheckRunning = true;
  accCheckingIds = new Set();
  const label = checkSpam ? 'SpamBot' : 'Сессии';
  setAccCheckBanner(true, `${label}: 0/${targetIds.length} (×${ACC_CHECK_PARALLEL})…`);
  setAccCheckButtonsDisabled(true);
  setAccPoolBanner(false);
  await loadAccountsTable();

  const stats = { clean: 0, spamblock: 0, blocked: 0, unknown: [], errors: [] };
  let done = 0;

  try {
    for (let i = 0; i < targetIds.length; i += ACC_CHECK_PARALLEL) {
      if (accCheckAbort) break;
      const chunk = targetIds.slice(i, i + ACC_CHECK_PARALLEL);
      chunk.forEach(id => accCheckingIds.add(id));
      setAccCheckBanner(true, `${label}: ${done}/${targetIds.length} (×${ACC_CHECK_PARALLEL})…`);
      renderAccountsTableBody();
      const reqSignal = beginAccCheckRequest();
      const r = await j('/api/accounts/bulk-check', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ account_ids: chunk, check_spam: checkSpam }),
        timeoutMs: bulkCheckTimeoutMs(chunk.length, checkSpam),
        signal: reqSignal,
      });
      for (const item of (r.results || [])) {
        const acc = item.account || {};
        const res = item.result || {};
        const phone = acc.phone || `#${item.account_id || '?'}`;
        if (item.error && !res.error) {
          stats.errors.push(phone);
          continue;
        }
        if (checkSpam) {
          if (res.spam_parse_unknown) stats.unknown.push(phone);
          else if (res.spam_error || res.error || item.error) stats.errors.push(phone);
          else if (acc.spam_blocked) {
            if (acc.spam_status === 'limited') stats.spamblock++;
            else stats.blocked++;
          } else stats.clean++;
        }
      }
      chunk.forEach(id => accCheckingIds.delete(id));
      done += chunk.length;
      await loadAccountsTable();
    }
    if (!accCheckAbort) {
      const toast = checkSpam
        ? formatCheckSummary(stats, true, targetIds.length)
        : `Проверено сессий: ${targetIds.length}`;
      showToast(toast, 'ok', { duration: 10000 });
    }
  } catch (e) {
    if (!accCheckAbort) showToast(toastText(e), 'err', { duration: 10000 });
  }

  if (accCheckAbort) {
    showToast(`Проверка отменена`, 'warning', { duration: 8000 });
  }

  abortAccCheckRequest();
  accCheckingIds.clear();
  accCheckRunning = false;
  accCheckAbort = false;
  setAccCheckBanner(false);
  setAccCheckButtonsDisabled(false);
  await loadAccountsTable();
}

document.getElementById('accCheckCancel')?.addEventListener('click', () => {
  requestAccCheckCancel().catch(() => {});
});

function statusTagClass(tags) {
  if (!tags || !tags.length) return 'tag-muted';
  if (tags.includes('rotating')) return 'tag-rotate';
  if (tags.includes('dead')) return 'tag-dead';
  if (tags.includes('frozen') || tags.includes('blocked')) return 'tag-bad';
  if (tags.includes('spamblock')) return 'tag-warn';
  if (tags.includes('resting')) return 'tag-rest';
  if (tags.includes('error')) return 'tag-bad';
  if (tags.includes('active')) return 'tag-ok';
  if (tags.includes('offline')) return 'tag-muted';
  return 'tag-muted';
}

function accountStatusDot(a) {
  return accountProfileIsOnline(a) ? 'online' : 'offline';
}

function accountStatusTooltip(a) {
  const lines = [];
  const err = (a.last_error || '').trim();
  const pool = a.pool_status || 'offline';
  const tags = a.filter_tags || [];
  if (err && (tags.includes('error') || pool === 'error')) lines.push(err);
  if (['importing', 'connecting', 'checking'].includes(pool)) {
    lines.push(`Подключение: ${poolLabel(pool)}`);
  }
  if (a.resting_active && a.resting_until) {
    lines.push(`Отдых до ${new Date(a.resting_until).toLocaleString('ru-RU')}`);
  }
  const rot = (a.session_rotate_status || '').trim();
  if (rot === 'waiting_24h') {
    lines.push(`Ротация сессии: ${fmtRotateWait(Number(a.session_rotate_seconds_left || 0))}`);
  } else if (rot === 'awaiting_code') {
    lines.push('Ротация сессии: ждём код');
  }
  if (a.session_has_2fa) {
    const hint = (a.session_2fa_hint || '').trim();
    lines.push(hint ? `2FA · ${hint}` : '2FA включена');
  }
  const other = Number(a.session_other_count || 0);
  if (other > 0) lines.push(`Чужих сессий: ${other}`);
  if (a.is_premium) lines.push('Telegram Premium');
  if (a.in_warmup && a.warmup_job_id) {
    lines.push(`Сейчас в прогреве #${a.warmup_job_id} (активная пара)`);
  }
  return lines.join(' · ');
}

function renderAccStatusCell(a) {
  const tags = a.filter_tags || [];
  const isChecking = accCheckingIds.has(a.id);
  if (isChecking) {
    return `<span class="tag tag-checking"><span class="status-spinner"></span> Проверяется…</span>`;
  }
  const tip = [a.status_label || '—', accountStatusTooltip(a)].filter(Boolean).join('\n');
  const dot = accountStatusDot(a);
  return `<span class="acc-status-cell" title="${esc(tip)}"><span class="dot ${dot}"></span><span class="tag ${statusTagClass(tags)}">${esc(a.status_label || '—')}</span></span>`;
}

function formatCheckToast(r, spam) {
  const acc = r?.account;
  const res = r?.result || {};
  if (!acc) return res.error || 'Ошибка проверки';
  if (spam) {
    if (res.spam_parse_unknown) {
      const hint = res.spam_error || res.spambot_text || '';
      return `${acc.phone}: не распознан ответ SpamBot${hint ? ' — ' + hint.slice(0, 80) : ''}`;
    }
    return `${acc.phone}: ${acc.status_label || acc.spam_label || '—'}`;
  }
  if (res.is_valid === false) return `${acc.phone}: ${acc.status_label || res.error || 'сессия мёртва'}`;
  return `${acc.phone}: ${acc.status_label || 'OK'}`;
}

function formatBulkSpamSummary(r) {
  const s = r.summary || {};
  const parts = [];
  if (s.clean) parts.push(`чистых ${s.clean}`);
  if (s.spamblock) parts.push(`спамблок ${s.spamblock}`);
  if (s.blocked) parts.push(`заблок. ${s.blocked}`);
  if (s.unknown) parts.push(`не распознано ${s.unknown}`);
  return parts.length ? parts.join(' · ') : `проверено ${r.count}`;
}

const ACC_TAG_SEARCH_LABELS = {
  active: 'активен активные чист готов',
  online: 'онлайн online подключен',
  spamblock: 'спамблок',
  blocked: 'заблокирован',
  frozen: 'заморозка',
  resting: 'отдых',
  dead: 'сессия мёртва мертва',
  offline: 'оффлайн offline',
  error: 'ошибка',
  no_proxy: 'без прокси нет прокси',
  premium: 'премиум premium telegram premium',
};

let accSearchQuery = '';

const SIDEBAR_WIDTH_KEY = 'sidebar_width_v1';
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed_v1';
const NAV_ORDER_KEY = 'sidebar_nav_order_v1';

function initSidebarNavPolish() {
  const nav = document.getElementById('sidebarNav');
  if (!nav || nav.dataset.polishReady) return;
  nav.dataset.polishReady = '1';
  let itemIndex = 0;
  nav.querySelectorAll('button[data-tab]').forEach(btn => {
    btn.style.setProperty('--i', String(itemIndex++));
    btn.classList.add('nav-item-motion');
  });
  nav.querySelectorAll('.nav-section').forEach((sec, idx) => {
    sec.style.setProperty('--i', String(idx));
    sec.classList.add('nav-section-motion');
  });
}

function initSidebarResize() {
  const sidebar = document.getElementById('appSidebar');
  const handle = document.getElementById('sidebarResizeHandle');
  if (!sidebar || !handle || handle.dataset.ready) return;
  handle.dataset.ready = '1';
  initSidebarNavPolish();
  document.documentElement.style.setProperty('--sidebar-width', '220px');
  try { localStorage.setItem(SIDEBAR_WIDTH_KEY, '220'); } catch (_) {}
  handle.style.display = 'none';
  // Сайдбар всегда в режиме рейки — кнопка сворачивания убрана.
  sidebar.classList.add('is-collapsed');
  try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '1'); } catch (_) {}
  applySavedNavOrder();
  initSidebarReorder();
}

// ── Перетаскивание пунктов меню (drag вверх/вниз) с сохранением порядка ──
function _navOrderToggle(nav) {
  return nav.querySelector('.sidebar-collapse-toggle');
}
function saveNavOrder() {
  const nav = document.getElementById('sidebarNav');
  if (!nav) return;
  const order = [...nav.querySelectorAll('button[data-tab]')].map(b => b.dataset.tab);
  try { localStorage.setItem(NAV_ORDER_KEY, JSON.stringify(order)); } catch (_) {}
}
function applySavedNavOrder() {
  const nav = document.getElementById('sidebarNav');
  if (!nav) return;
  let order = null;
  try { order = JSON.parse(localStorage.getItem(NAV_ORDER_KEY) || 'null'); } catch (_) { order = null; }
  if (!Array.isArray(order) || !order.length) return;
  const toggle = _navOrderToggle(nav);
  const byTab = {};
  nav.querySelectorAll('button[data-tab]').forEach(b => { byTab[b.dataset.tab] = b; });
  order.forEach(tab => {
    const b = byTab[tab];
    if (b) nav.insertBefore(b, toggle);
  });
}
function initSidebarReorder() {
  const nav = document.getElementById('sidebarNav');
  if (!nav || nav.dataset.reorderReady) return;
  if (typeof Sortable === 'undefined') return;
  nav.dataset.reorderReady = '1';
  Sortable.create(nav, {
    animation: 160,
    easing: 'cubic-bezier(.16,1,.3,1)',
    draggable: 'button[data-tab]',
    filter: '.sidebar-collapse-toggle, .nav-section',
    forceFallback: true,
    fallbackOnBody: false,
    // Перестановка только при осознанном удержании, иначе обычный клик по вкладке
    // иногда трактовался как начало drag и «не прокликивался» с первого раза.
    delay: 220,
    delayOnTouchOnly: false,
    fallbackTolerance: 6,
    touchStartThreshold: 6,
    ghostClass: 'nav-ghost',
    chosenClass: 'nav-chosen',
    dragClass: 'nav-drag',
    onEnd: () => {
      const toggle = _navOrderToggle(nav);
      if (toggle) nav.appendChild(toggle); // кнопка сворачивания всегда внизу
      saveNavOrder();
    },
  });
}

function accountMatchesSearch(a, rawQ) {
  const q = (rawQ || '').trim().toLowerCase();
  if (!q) return true;
  const digits = q.replace(/\D/g, '');
  const tags = a.filter_tags || [];
  const tagHay = tags.map(t => `${t} ${ACC_TAG_SEARCH_LABELS[t] || ''}`).join(' ');
  const hay = [
    a.display_name,
    a.username,
    a.phone,
    a.project_name,
    a.status_label,
    a.spam_label,
    a.spam_detail_label,
    a.is_premium ? 'premium премиум' : '',
    tagHay,
  ].filter(Boolean).join(' ').toLowerCase();
  if (hay.includes(q)) return true;
  if (a.username && (`@${a.username}`.toLowerCase().includes(q) || a.username.toLowerCase().includes(q))) return true;
  if (digits && (a.phone || '').replace(/\D/g, '').includes(digits)) return true;
  return false;
}

function accountMatchesProjectFilter(a) {
  const raw = document.getElementById('accProjectFilter')?.value;
  if (raw === '' || raw === undefined) return true;
  if (raw === '0') return !a.project_id;
  return String(a.project_id || '') === String(raw);
}

function accountMatchesJobFilter(a) {
  const raw = document.getElementById('accJobFilter')?.value;
  if (raw === '' || raw === undefined || raw === null) return true;
  if (raw === '0') return !a.job_id;
  return String(a.job_id || '') === String(raw);
}

function populateAccJobFilter() {
  const sel = document.getElementById('accJobFilter');
  if (!sel) return;
  const prev = sel.value;
  const jobs = new Map();
  let hasNoJob = false;
  for (const a of (allAccounts || [])) {
    if (a.job_id) {
      jobs.set(String(a.job_id), (a.job_name || `Задача #${a.job_id}`).trim());
    } else {
      hasNoJob = true;
    }
  }
  const opts = ['<option value="">Все задачи</option>'];
  if (hasNoJob) opts.push('<option value="0">Без задачи</option>');
  for (const [id, name] of [...jobs.entries()].sort((x, y) => x[1].localeCompare(y[1], 'ru'))) {
    opts.push(`<option value="${id}">${esc(name)}</option>`);
  }
  sel.innerHTML = opts.join('');
  // сохраняем выбор, если такая задача ещё есть среди аккаунтов
  if ([...sel.options].some(o => o.value === prev)) sel.value = prev;
}

function accountMatchesStatusFilter(a) {
  const st = (document.getElementById('accStatusFilter')?.value || '').trim();
  if (!st) return true;
  return (a.filter_tags || []).includes(st);
}

function accountMatchesStateFilter(a) {
  const st = (document.getElementById('accStateFilter')?.value || '').trim();
  if (!st) return true;
  const pool = String(a.pool_status || '').toLowerCase();
  const spam = String(a.spam_status || '').toLowerCase();
  if (st === 'online') return pool === 'online' || !!a.profile_online;
  if (st === 'offline') return pool === 'offline';
  if (st === 'connecting') return ['connecting', 'importing', 'checking'].includes(pool);
  if (st === 'error') return pool === 'error';
  if (st === 'resting') return pool === 'resting' || !!a.resting_active;
  if (st === 'spam_blocked') return !!a.spam_blocked || spam === 'blocked';
  if (st === 'spam_limited') return spam === 'limited' && !a.spam_blocked;
  if (st === 'premium') return !!a.is_premium;
  if (st === 'no_proxy') return !a.proxy_id;
  if (st === 'in_warmup') return !!a.in_warmup;
  return true;
}

function accountMatchesTableFilters(a, rawQ) {
  return accountMatchesSearch(a, rawQ)
    && accountMatchesStatusFilter(a)
    && accountMatchesStateFilter(a)
    && accountMatchesProjectFilter(a)
    && accountMatchesJobFilter(a);
}

function renderAccTableAvatar(a) {
  const title = (a.display_name || '').trim() || (a.username ? `@${a.username}` : a.phone || '?');
  const letter = initials(title);
  const baseUrl = a.avatar_url || (a.avatar_path ? accountAvatarUrl(a.id, a.updated_at || '') : '');
  if (!baseUrl) {
    return `<div class="acc-row-avatar acc-row-avatar-ph" title="${esc(title)}">${esc(letter)}</div>`;
  }
  const src = baseUrl.includes('?')
    ? `${baseUrl}&v=${encodeURIComponent(a.updated_at || '')}`
    : `${baseUrl}?v=${encodeURIComponent(a.updated_at || '')}`;
  return `<div class="acc-row-avatar" title="${esc(title)}"><img src="${esc(src)}" alt="" loading="lazy" data-fallback="${esc(letter)}" /></div>`;
}

function renderAccWarmupFire(a) {
  if (!a?.in_warmup) return '';
  const jid = a.warmup_job_id ? ` #${a.warmup_job_id}` : '';
  return `<span class="acc-warmup-fire" title="Сейчас в прогреве${esc(jid)}" aria-label="В прогреве">🔥</span>`;
}

function renderAccInlineBadges(a) {
  const parts = [];
  const rot = (a.session_rotate_status || '').trim();
  if (rot === 'waiting_24h') {
    const left = fmtRotateWaitShort(Number(a.session_rotate_seconds_left || 0));
    parts.push(`<span class="acc-sess-rotate" title="Ротация сессии — жди finish">⏳ ${esc(left)}</span>`);
  } else if (rot === 'awaiting_code') {
    parts.push('<span class="acc-sess-rotate busy" title="Идёт ротация — не для рассылки/парсинга">ротация</span>');
  }
  const other = Number(a.session_other_count || 0);
  if (other > 0) {
    parts.push(`<span class="acc-sess-warn" title="Чужие Telegram-сессии: ${other}">+${other} чуж.</span>`);
  }
  if (a.session_has_2fa) {
    const hint = a.session_2fa_hint ? ` · ${esc(a.session_2fa_hint)}` : '';
    parts.push(`<span class="acc-sess-2fa" title="Cloud 2FA${hint}">2FA</span>`);
  }
  if (!parts.length) return '';
  return `<span class="acc-sess-inline">${parts.join('')}</span>`;
}

function renderAccTableIdentity(a) {
  const name = (a.display_name || '').trim();
  const userLine = a.username ? `@${a.username}` : '';
  const phone = a.phone || '';
  const badges = renderAccInlineBadges(a);
  const warmupFire = renderAccWarmupFire(a);
  const premiumStar = a.is_premium
    ? '<span class="acc-premium-star" title="Telegram Premium">★</span>'
    : '';
  const title = [name, phone, userLine].filter(Boolean).join(' · ') || '—';
  const nameBlock = name || badges || premiumStar || warmupFire
    ? `<div class="acc-row-name-line">${warmupFire}${name || premiumStar ? `<span class="acc-row-name">${premiumStar}${name ? esc(name) : ''}</span>` : ''}${badges}</div>`
    : '';
  return `
    <div class="acc-cell-inner" title="${esc(title)}">
      ${renderAccTableAvatar(a)}
      <div class="acc-row-meta">
        ${nameBlock}
        ${phone ? `<div class="acc-row-phone sub">${esc(phone)}</div>` : ''}
        ${userLine ? `<div class="acc-row-user sub">${esc(userLine)}</div>` : ''}
        ${!name && !phone && !userLine && !badges ? '<div class="acc-row-name">—</div>' : ''}
      </div>
    </div>`;
}

function renderAccSpamCell(a) {
  const detail = (a.spam_detail_label || a.spam_label || '—').trim();
  const full = (a.spam_label || detail).trim();
  const tip = full;
  const blocked = a.broadcast_blocked || a.spam_blocked;
  const cls = blocked ? 'err spam-detail' : 'ok-text spam-detail';
  return `<span class="${cls}" title="${esc(tip)}">${esc(detail)}</span>`;
}

function accProxyCountryLabel(a) {
  const country = (a.proxy_country || '').trim();
  if (country) return country;
  const group = (a.proxy_group_name || '').trim();
  if (group) return group;
  return '';
}

function jobLimitCell(a) {
  if (!a.job_id) return '<span class="muted">—</span>';
  const name = (a.job_name || (`#${a.job_id}`)).trim();
  const shortName = name.length > 28 ? `${name.slice(0, 26)}…` : name;
  const lim = a.job_limit > 0 ? a.job_limit : '∞';
  const sent = a.job_sent || 0;
  const tip = `${name} · ${sent}/${lim}`;
  return `<span class="acc-job-name" title="${esc(tip)}">${esc(shortName)}</span>`;
}

function bindAccTableRows() {
  document.querySelectorAll('.acc-row-avatar img').forEach(img => {
    img.onerror = () => {
      const letter = img.dataset.fallback || '?';
      const box = img.closest('.acc-row-avatar');
      if (box) {
        box.classList.add('acc-row-avatar-ph');
        box.textContent = letter;
      }
    };
  });
  document.querySelectorAll('.acc-chk').forEach(chk => {
    chk.onchange = e => {
      e.stopPropagation();
      const id = Number(chk.dataset.id);
      setAccSelected(id, chk.checked);
      lastAccClickId = id;
      syncAccRowSelection();
      updateBulkBar();
    };
  });
  document.querySelectorAll('#accountsTable tbody tr.acc-row').forEach(tr => {
    tr.onclick = e => {
      if (e.target.closest('button') || e.target.closest('input')) return;
      handleAccRowClick(Number(tr.dataset.id), e.shiftKey);
    };
  });
  syncAccRowSelection();
  updateBulkBar();
}

function renderAccountsTableBody() {
  const tbody = document.querySelector('#accountsTable tbody');
  if (!tbody) return;
  if (accTableLoading && !(allAccounts || []).length) {
    tbody.innerHTML = '<tr><td colspan="9" class="empty">Загрузка списка аккаунтов…</td></tr>';
    return;
  }
  const filtered = (allAccounts || [])
    .filter(a => accountMatchesTableFilters(a, accSearchQuery))
    .sort((a, b) => Number(accountProfileIsOnline(b)) - Number(accountProfileIsOnline(a)));
  let html;
  try {
    html = filtered.map(a => {
    const rowCls = (!a.proxy_host ? 'row-warn ' : '')
      + (['importing', 'connecting', 'checking'].includes(a.pool_status) ? ' row-pending' : '');
    const sel = selectedAccountIds.has(a.id) ? ' acc-row-selected' : '';
    const checked = selectedAccountIds.has(a.id) ? ' checked' : '';
    const spamChecked = a.spam_checked_at
      ? ` title="SpamBot: ${esc(new Date(a.spam_checked_at).toLocaleString('ru-RU'))}"`
      : '';
    return `
    <tr class="acc-row ${rowCls.trim()}${sel}" data-id="${a.id}">
      <td class="col-chk"><input type="checkbox" class="acc-chk" data-id="${a.id}"${checked} /></td>
      <td class="acc-cell">${renderAccTableIdentity(a)}</td>
      <td class="project-col" title="${esc(a.project_name || '')}">${esc(a.project_name || '—')}</td>
      <td class="status-col">${renderAccStatusCell(a)}</td>
      <td class="spam-col"${spamChecked}>${renderAccSpamCell(a)}</td>
      <td class="device-col">${a.device_label ? `<span class="device-chip" title="${esc(a.device_label)}" aria-label="${esc(a.device_label)}">📱</span>` : '<span class="device-empty" title="Устройство не задано">—</span>'}</td>
      <td class="proxy-col" title="${esc(a.proxy_host ? `${accProxyCountryLabel(a) || '—'} · ${a.proxy_host}:${a.proxy_port}` : 'нет прокси')}">${a.proxy_host ? esc(accProxyCountryLabel(a) || '—') : '<span class="err">нет</span>'}</td>
      <td class="today-col" title="Холодных сообщений сегодня / дневной лимит">${(a.daily_sent || 0) + '/' + ((a.daily_limit_effective ?? a.daily_limit) || 40)}</td>
      <td class="job-col">${jobLimitCell(a)}</td>
    </tr>`;
    }).join('') || (
      accSearchQuery.trim()
        ? '<tr><td colspan="9" class="empty">Ничего не найдено</td></tr>'
        : '<tr><td colspan="9" class="empty">Нет аккаунтов</td></tr>'
    );
  } catch (e) {
    console.error('renderAccountsTableBody', e);
    if (!(allAccounts || []).length) {
      html = '<tr><td colspan="9" class="empty err">Ошибка отрисовки таблицы</td></tr>';
    } else {
      return;
    }
  }
  tbody.innerHTML = html;
  bindAccTableRows();
}

async function loadAccountsTable(opts = {}) {
  const force = !!opts?.force;
  const cacheKey = accountsTabCacheKey();
  if (!force && accTableLoadInFlight) return;
  if (!force && tabDataCache.accounts.key === cacheKey && accountsReloadThrottled()) return;
  const pf = document.getElementById('accProjectFilter');
  const sf = document.getElementById('accStatusFilter');
  const stf = document.getElementById('accStateFilter');
  const pid = pf?.value;
  const st = sf?.value;
  const seq = ++accTableLoadSeq;
  const qs = new URLSearchParams();
  if (pid !== '' && pid !== undefined) qs.set('project_id', pid);
  if (st) qs.set('status', st);
  const url = '/api/accounts' + (qs.toString() ? `?${qs}` : '');
  accTableLoadInFlight = true;
  accTableLoading = true;
  setAccountsTableBusy(true);
  if (!(allAccounts || []).length) renderAccountsTableBody();
  try {
    const list = await j(url, { timeoutMs: 45000 });
    if (seq !== accTableLoadSeq) return;
    allAccounts = list || [];
    accTableLoading = false;  // сброс ДО рендера: иначе пустой список ([]) застревает на «Загрузка…»
    rememberSelectedAccountsFrom(allAccounts);
    populateAccJobFilter();
    tabDataCache.accounts = { at: Date.now(), key: accountsTabCacheKey() };
    accTableLastOkAt = Date.now();
    const sq = document.getElementById('accSearch');
    if (sq && sq.value !== accSearchQuery) sq.value = accSearchQuery;
    renderAccountsTableBody();
    updateBulkBar();
  } catch (e) {
    if (seq !== accTableLoadSeq) return;
    const msg = (e && e.message) ? String(e.message) : 'ошибка загрузки';
    if (Date.now() - accTableLoadErrorAt > 30000) {
      accTableLoadErrorAt = Date.now();
      const hint = (allAccounts || []).length
        ? ' — показаны сохранённые данные'
        : '';
      showToast(`Список аккаунтов: ${msg}${hint}`, 'warn', { duration: 12000 });
    }
    if ((allAccounts || []).length) renderAccountsTableBody();
  } finally {
    accTableLoadInFlight = false;
    if (seq === accTableLoadSeq) {
      accTableLoading = false;
      setAccountsTableBusy(false);
    }
  }
}

document.getElementById('accSelectAll')?.addEventListener('change', e => {
  const ids = getFilteredAccountIds();
  if (e.target.checked) ids.forEach(id => setAccSelected(id, true));
  else ids.forEach(id => setAccSelected(id, false));
  syncAccRowSelection();
  updateBulkBar();
});

function onAccountsFilterChange() {
  invalidateTabCache('accounts');
  renderAccountsTableBody();
  syncAccRowSelection();
  updateBulkBar();
  scheduleLoadAccountsTable(true);
}

document.getElementById('accProjectFilter')?.addEventListener('change', onAccountsFilterChange);
document.getElementById('accStatusFilter')?.addEventListener('change', onAccountsFilterChange);
document.getElementById('accStateFilter')?.addEventListener('change', onAccountsFilterChange);
document.getElementById('accJobFilter')?.addEventListener('change', onAccountsFilterChange);

document.getElementById('accSearch')?.addEventListener('input', e => {
  accSearchQuery = e.target.value || '';
  renderAccountsTableBody();
  syncAccRowSelection();
  updateBulkBar();
});

document.getElementById('accBulkClear')?.addEventListener('click', () => clearAccSelection());

window.deleteAcc = async id => {
  if (!confirm('Удалить аккаунт из сервиса и .session?')) return;
  try {
    await j(`/api/accounts/${id}?delete_session=true`, { method: 'DELETE' });
    setAccSelected(id, false);
    showToast('Аккаунт удалён');
  } catch (e) {
    showToast('Ошибка удаления: ' + (e.message || e), 'err');
  }
  // force: обойти throttle loadAccountsTable — иначе строка висит до F5,
  // если поллинг недавно дёргал таблицу.
  loadAccountsTable({ force: true });
};

window.assignProxyAcc = async (id, pid) => {
  try {
    await j(`/api/accounts/${id}/proxy`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ proxy_id: pid ? Number(pid) : null }),
    });
    showToast('Прокси обновлён');
  } catch (e) {
    showToast('Ошибка прокси: ' + (e.message || e), 'err');
  }
  loadAccountsTable({ force: true });
};
window.checkAcc = async (id, spam) => {
  showToast(spam ? 'SpamBot…' : 'Сессия…');
  try {
    const r = await j(`/api/accounts/${id}/check?check_spam=${spam}`, { method: 'POST' });
    showToast(formatCheckToast(r, spam));
  } catch (e) { alert(e.message); }
  loadAccountsTable({ force: true });
};
window.reconnAcc = async id => {
  try {
    await j(`/api/accounts/${id}/reconnect`, { method: 'POST' });
    showToast('Reconnect');
  } catch (e) {
    showToast('Reconnect: ' + (e.message || e), 'err');
  }
  loadAccountsTable({ force: true });
};

document.getElementById('accPoolAutostartBtn')?.addEventListener('click', () => {
  const on = !!document.getElementById('accPoolAutostart')?.checked;
  savePoolAutostart(!on).catch(err => showToast(err.message, 'err'));
});
document.getElementById('accPoolToggle')?.addEventListener('click', () => {
  const mode = document.getElementById('accPoolToggle')?.dataset.mode || 'start';
  const ids = poolToggleTargetIds();
  if (mode === 'cancel') {
    runPoolCancelStart(false).catch(e => showToast(e.message, 'err'));
    return;
  }
  if (mode === 'stop') {
    runPoolStop(ids, { confirm: false }).catch(e => showToast(e.message, 'err'));
    return;
  }
  runPoolStart(ids).catch(e => showToast(e.message, 'err'));
});

document.getElementById('accCheckSpam')?.addEventListener('click', () => runAccountChecks(true));
document.getElementById('accSessionSecurity')?.addEventListener('click', () => {
  runBulkSessionSecurity('audit').catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
});
document.getElementById('accBulkSessionsKick')?.addEventListener('click', () => {
  runBulkSessionSecurity('reset_others').catch(e => showToast(e.message || String(e), 'err'));
});
document.getElementById('sessSecHideSensitive')?.addEventListener('change', e => {
  try { localStorage.setItem(SESS_SEC_HIDE_KEY, e.target.checked ? '1' : '0'); } catch (_) { /* ignore */ }
  applySessSecSensitiveVisibility();
});
document.getElementById('sessSecRefresh')?.addEventListener('click', () => {
  refreshSessionSecurityModal().catch(e => showToast(e.message || String(e), 'err'));
});
document.getElementById('sessSecKickOthers')?.addEventListener('click', () => {
  kickOtherSessionsForModal().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faSave')?.addEventListener('click', () => {
  saveSession2faPassword().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faClear')?.addEventListener('click', () => {
  clearSession2faPassword().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecTtlApply')?.addEventListener('click', () => {
  applySessionTtl().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecWebReset')?.addEventListener('click', () => {
  resetWebSessions().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecPrivacyApply')?.addEventListener('click', () => {
  applySessionPrivacy().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecPrivacyRefresh')?.addEventListener('click', () => {
  loadSessionPrivacy().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faEnable')?.addEventListener('click', () => {
  enableSession2fa().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faChange')?.addEventListener('click', () => {
  changeSession2fa().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faDisable')?.addEventListener('click', () => {
  disableSession2fa().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faEmailConfirm')?.addEventListener('click', () => {
  confirmSession2faEmail().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faEmailResend')?.addEventListener('click', () => {
  resendSession2faEmail().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faEmailCancel')?.addEventListener('click', () => {
  cancelSession2faEmail().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faRecoverySend')?.addEventListener('click', () => {
  sendSession2faRecovery().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSec2faRecoveryConfirm')?.addEventListener('click', () => {
  confirmSession2faRecovery().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecSessions')?.addEventListener('click', e => {
  const btn = e.target.closest('.sess-sec-kick-one');
  if (!btn || btn.disabled) return;
  const hash = (btn.dataset.hash || '').trim();
  const title = btn.dataset.title || '';
  if (!hash || hash === '0') {
    showToast('Не удалось определить сессию — нажми «Обновить»', 'err');
    return;
  }
  kickOneSession(hash, title).catch(err => showToast(err, 'err'));
});
document.getElementById('sessSecRotateStart')?.addEventListener('click', () => {
  sessionRotateStart().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecRotatePoll')?.addEventListener('click', () => {
  sessionRotatePoll().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecRotateConfirm')?.addEventListener('click', () => {
  sessionRotateConfirm().catch(e => showToast(e, 'err'));
});
document.getElementById('sessSecRotateCode')?.addEventListener('keydown', e => {
  if (e.key === 'Enter') {
    e.preventDefault();
    sessionRotateConfirm().catch(err => showToast(err, 'err'));
  }
});
document.getElementById('sessSecRotateFinish')?.addEventListener('click', (e) => {
  const btn = e.currentTarget;
  if (btn?.disabled || btn?.classList.contains('rotate-finish-locked')) return;
  sessionRotateFinish().catch(err => showToast(err, 'err'));
});
document.getElementById('sessSecRotateCancel')?.addEventListener('click', () => {
  sessionRotateCancel().catch(e => showToast(e, 'err'));
});
document.getElementById('accBulkProfile')?.addEventListener('click', () => {
  if (selectedAccountIds.size !== 1) {
    showToast('Выбери ровно один аккаунт', 'err');
    return;
  }
  openAccountProfileModal().catch(e => showToast(e.message || String(e), 'err'));
});
document.getElementById('accBulkSecurity')?.addEventListener('click', () => {
  if (selectedAccountIds.size !== 1) {
    showToast('Выбери ровно один аккаунт', 'err');
    return;
  }
  const id = [...selectedAccountIds][0];
  openSessionSecurityModal(id).catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
});

function openAccWriteModal() {
  if (selectedAccountIds.size !== 1) {
    showToast('Выбери ровно один аккаунт', 'err');
    return;
  }
  const accountId = [...selectedAccountIds][0];
  const account = allAccounts.find(a => Number(a.id) === Number(accountId));
  if (!account) {
    showToast('Аккаунт не найден — обнови таблицу', 'err');
    return;
  }
  const sub = document.getElementById('modalAccWriteSub');
  const name = account.display_name || account.phone || 'аккаунт';
  if (sub) sub.textContent = `С аккаунта ${name} · статус «Не из рассылки»`;
  const unameInp = document.getElementById('modalAccWriteUsername');
  const textInp = document.getElementById('modalAccWriteText');
  if (unameInp) {
    unameInp.value = '';
    unameInp.dataset.accountPhone = account.phone || '';
  }
  if (textInp) textInp.value = '';
  openModal('modalAccWrite');
  setTimeout(() => unameInp?.focus(), 50);
}

async function submitAccWriteModal() {
  const unameInp = document.getElementById('modalAccWriteUsername');
  const textInp = document.getElementById('modalAccWriteText');
  const accountPhone = (unameInp?.dataset.accountPhone || '').trim();
  const username = (unameInp?.value || '').trim();
  const text = (textInp?.value || '').trim();
  if (!accountPhone) {
    showToast('Аккаунт не выбран', 'err');
    return;
  }
  if (!username) {
    showToast('Укажи @username', 'err');
    unameInp?.focus();
    return;
  }
  const btn = document.getElementById('modalAccWriteGo');
  if (btn) btn.disabled = true;
  try {
    const res = await j('/api/chats/open-by-username', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ account_phone: accountPhone, username, text: text || null }),
    });
    closeModals();
    if (chatDialogSide !== 'outgoing') onChatDialogSideChange('outgoing');
    openChat(
      res.account_phone,
      res.peer_id,
      res.peer_name,
      res.peer_username,
      res.lead_status,
    );
    showToast(res.sent_first ? 'Сообщение отправлено' : 'Чат открыт');
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
  } finally {
    if (btn) btn.disabled = false;
  }
}

document.getElementById('accBulkWrite')?.addEventListener('click', () => openAccWriteModal());
document.getElementById('accBulkWarmup')?.addEventListener('click', () => {
  openWarmupFromAccounts().catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
});
document.getElementById('modalAccWriteGo')?.addEventListener('click', () => {
  submitAccWriteModal().catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
});
document.getElementById('modalAccWriteUsername')?.addEventListener('keydown', e => {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    submitAccWriteModal().catch(err => showToast(toastText(err), 'err', { duration: 10000 }));
  }
});

document.getElementById('accountProfileTipsToggle')?.addEventListener('click', () => {
  const tips = document.getElementById('accountProfileTipsPane');
  setAccountProfileTipsOpen(!!tips?.hidden);
});
document.getElementById('accountProfileSave')?.addEventListener('click', () => {
  saveAccountProfileModal().catch(e => showToast(e.message || String(e), 'err'));
});
document.getElementById('accountProfileStartAccount')?.addEventListener('click', () => {
  startAccountFromProfileModal().catch(e => showToast(e.message || String(e), 'err'));
});
document.getElementById('accountProfileOpenPicker')?.addEventListener('click', async () => {
  try {
    await openAccountProfilePicker();
  } catch (e) {
    showToast(e.message || 'Не удалось открыть каталог профилей', 'err');
  }
});
document.getElementById('accountProfileImportRandom')?.addEventListener('click', async () => {
  try {
    if (!allAccounts.length) {
      try {
        allAccounts = await j('/api/accounts');
      } catch (_) {}
    }
    const r = await j('/api/profile-templates/random', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ mark_used: false, exclude_used_on_accounts: true }),
    });
    const p = r.item;
    if (!p) return showToast('Нет свободных профилей — все уже на аккаунтах', 'err');
    await ensureAccountProfileAssetSets();
    applyAccountProfileTemplate(p);
  } catch (e) {
    showToast(e.message || 'Нет свободных профилей', 'err');
  }
});
document.getElementById('accountProfilePickerClose')?.addEventListener('click', closeAccountProfilePicker);
document.getElementById('accountProfilePickerCancel')?.addEventListener('click', closeAccountProfilePicker);
document.getElementById('accountProfilePickerKind')?.addEventListener('change', () => {
  syncAccountProfilePickerFilters();
  renderAccountProfilePicker();
});
document.getElementById('accountProfilePickerGender')?.addEventListener('change', renderAccountProfilePicker);
document.getElementById('accountProfilePickerProject')?.addEventListener('change', renderAccountProfilePicker);
document.getElementById('accountProfilePickerSearch')?.addEventListener('input', renderAccountProfilePicker);
document.getElementById('accountProfilePickerOverlay')?.addEventListener('click', e => {
  if (e.target?.id === 'accountProfilePickerOverlay') closeAccountProfilePicker();
});
document.getElementById('accountProfilePickerGrid')?.addEventListener('click', e => {
  const card = e.target.closest('[data-picker-template-id]');
  if (!card) return;
  accountProfilePickerSelectedId = Number(card.dataset.pickerTemplateId || 0);
  renderAccountProfilePicker();
});
document.getElementById('accountProfilePickerApply')?.addEventListener('click', async () => {
  try {
    const list = await ensureAccountProfileTemplates();
    const p = list.find(x => Number(x.id) === Number(accountProfilePickerSelectedId));
    if (!p) {
      showToast('Выбери профиль из списка', 'err');
      return;
    }
    applyAccountProfileTemplate(p);
    closeAccountProfilePicker();
  } catch (e) {
    showToast(e.message || 'Не удалось применить профиль', 'err');
  }
});
document.getElementById('accountProfileClearUsername')?.addEventListener('click', () => {
  const el = document.getElementById('accountProfileUsername');
  if (el) el.value = '';
});
document.getElementById('accountProfileClearBio')?.addEventListener('click', () => {
  const el = document.getElementById('accountProfileBio');
  if (el) {
    el.value = '';
    accountProfileBioTouched = true;
    accountProfileBioForceClear = true;
  }
});
document.getElementById('accountProfileBio')?.addEventListener('input', () => {
  accountProfileBioTouched = true;
  const bio = document.getElementById('accountProfileBio')?.value.trim() || '';
  if (bio) accountProfileBioForceClear = false;
});
document.getElementById('accountProfileRefresh')?.addEventListener('click', async () => {
  await refreshAccountProfileModal();
});
document.getElementById('accountProfileAvatarFile')?.addEventListener('change', () => {
  if (document.getElementById('accountProfileAvatarFile')?.files?.length) {
    accountProfileImportedTemplate = accountProfileImportedTemplate
      ? { ...accountProfileImportedTemplate, avatar_url: '' }
      : null;
    setAccountProfileImportHint('Выбран файл аватарки вручную. Он заменит аватарку из профиля.');
  }
});
async function deleteAccountProfileAvatar({ all = false } = {}) {
  const accountId = accountProfileEditingId || getSingleSelectedAccountId();
  if (!accountId) {
    showToast('Выбери один аккаунт', 'err');
    return;
  }
  const question = all
    ? 'Удалить ВСЕ аватарки из профиля этого Telegram-аккаунта?'
    : 'Удалить текущую аватарку у этого Telegram-аккаунта?';
  if (!confirm(question)) return;
  const err = document.getElementById('accountProfileError');
  if (err) {
    err.hidden = true;
    err.textContent = '';
  }
  try {
    const r = await j(`/api/accounts/${accountId}/profile/avatar${all ? '?all=1' : ''}`, { method: 'DELETE' });
    accountProfileLoaded = r.profile || accountProfileLoaded;
    const account = allAccounts.find(a => Number(a.id) === Number(accountId));
    renderAccountProfileAvatar(accountProfileLoaded || {}, account);
    const fileInput = document.getElementById('accountProfileAvatarFile');
    if (fileInput) fileInput.value = '';
    accountProfileImmediateApplied = true;
    const hint = document.getElementById('accountProfileHint');
    if (hint) {
      hint.textContent = all
        ? `Все аватарки удалены. Удалено: ${r.deleted || 0}.`
        : 'Текущая аватарка удалена. Если в Telegram была старая аватарка, она уже подтянута.';
    }
    showToast(all ? `Удалено аватарок: ${r.deleted || 0}` : 'Аватарка удалена');
    await loadAccountsTable();
  } catch (e) {
    if (await handleAccountProfileStateError(e)) return;
    if (err) {
      err.hidden = false;
      err.textContent = e.message || String(e);
    } else {
      showToast(e.message || String(e), 'err');
    }
  }
}
document.getElementById('accountProfileDeleteAvatar')?.addEventListener('click', () => {
  deleteAccountProfileAvatar({ all: false });
});
document.getElementById('accountProfileDeleteAllAvatars')?.addEventListener('click', () => {
  deleteAccountProfileAvatar({ all: true });
});

document.getElementById('accBulkDelete')?.addEventListener('click', async () => {
  const ids = getSelectedAccountIds();
  if (!ids.length || !confirm(`Удалить ${ids.length} акков?`)) return;
  try {
    await j('/api/accounts/bulk-delete', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ account_ids: ids, delete_sessions: true }),
    });
    selectedAccountIds.clear();
    accSelectedAccountsCache.clear();
    showToast(`Удалено: ${ids.length}`);
  } catch (e) {
    showToast('Ошибка удаления: ' + (e.message || e), 'err', { duration: 8000 });
  } finally {
    loadAccountsTable({ force: true });
  }
});

document.getElementById('accBulkProxy')?.addEventListener('click', async () => {
  const ids = getSelectedAccountIds();
  if (!ids.length) return;
  bulkProxyAssignIds = ids;
  bulkProxyActiveTab = 'group';
  await refreshMetaSelects();
  document.getElementById('modalBulkProxyTitle').textContent = `Изменить прокси · ${ids.length} акк.`;
  document.querySelectorAll('#modalBulkProxyAssign .ub-tab').forEach(t => {
    t.classList.toggle('active', t.dataset.tab === 'group');
  });
  document.getElementById('bulkProxyTabGroup').hidden = false;
  document.getElementById('bulkProxyTabNone').hidden = true;
  openModal('modalBulkProxyAssign');
});

document.querySelectorAll('#modalBulkProxyAssign .ub-tab').forEach(tab => {
  tab.onclick = () => {
    bulkProxyActiveTab = tab.dataset.tab;
    document.querySelectorAll('#modalBulkProxyAssign .ub-tab').forEach(t => {
      t.classList.toggle('active', t.dataset.tab === bulkProxyActiveTab);
    });
    document.getElementById('bulkProxyTabGroup').hidden = bulkProxyActiveTab !== 'group';
    document.getElementById('bulkProxyTabNone').hidden = bulkProxyActiveTab !== 'none';
  };
});

document.getElementById('modalBulkProxyApply')?.addEventListener('click', async () => {
  const btn = document.getElementById('modalBulkProxyApply');
  const ids = [...bulkProxyAssignIds];
  if (!ids.length) {
    showToast('Не выбраны аккаунты — закрой окно и отметь строки снова', 'err');
    return;
  }
  if (btn?.disabled) return;
  const prevLabel = btn?.textContent || 'Применить';
  if (btn) {
    btn.disabled = true;
    btn.textContent = 'Применяю…';
  }
  try {
    if (bulkProxyActiveTab === 'none') {
      for (const id of ids) {
        await j(`/api/accounts/${id}/proxy`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ proxy_id: null }),
          timeoutMs: 10000,
        });
      }
      closeModals();
      bulkProxyAssignIds = [];
      showToast(`Прокси снят с ${ids.length} акк.`, 'ok', { duration: 8000 });
    } else {
      const gid = Number(document.getElementById('bulkProxyGroupSel')?.value);
      if (!gid) {
        alert('Выбери группу прокси');
        return;
      }
      const data = await j('/api/accounts/bulk-assign-proxy', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ account_ids: ids, group_id: gid, force_new: true }),
        timeoutMs: 12000,
      });
      closeModals();
      bulkProxyAssignIds = [];
      const assigned = Number(data.assigned || 0);
      const reused = Number(data.reused || 0);
      const failed = Number(data.failed || 0);
      if (failed && !assigned) {
        showToast(
          `Прокси не назначены (${failed} акк.) — в группе нет свободных IP`,
          'err',
          { duration: 12000 },
        );
      } else if (!assigned && reused) {
        showToast(
          `Прокси не сменились (${reused} акк.) — все уже из этой группы`,
          'warning',
          { duration: 12000 },
        );
      } else {
        const tail = [
          assigned ? `сменено: ${assigned}` : '',
          reused ? `без смены: ${reused}` : '',
          failed ? `ошибок: ${failed}` : '',
        ].filter(Boolean).join(', ');
        showToast(
          `Прокси обновлены (${tail}). Переподключение в фоне…`,
          'ok',
          { duration: 12000 },
        );
      }
    }
    scheduleLoadAccountsTable();
    loadProxyGroups();
    setTimeout(() => scheduleLoadAccountsTable(), 6000);
    setTimeout(() => scheduleLoadAccountsTable(), 20000);
  } catch (e) {
    const msg = (e && e.message) ? String(e.message) : String(e);
    if (/таймаут|timeout|aborted/i.test(msg)) {
      showToast(
        'Таймаут — сервер не ответил. Перезапусти start.bat и нажми Ctrl+F5 в браузере.',
        'err',
        { duration: 15000 },
      );
    } else {
      showToast(msg, 'err', { duration: 12000 });
    }
  } finally {
    if (btn) {
      btn.disabled = false;
      btn.textContent = prevLabel;
    }
  }
});

document.getElementById('accBulkProject')?.addEventListener('click', async () => {
  const ids = getSelectedAccountIds();
  if (!ids.length) return;
  await refreshMetaSelects();
  document.getElementById('modalBulkTitle').textContent = `Проект для ${ids.length} акк.`;
  document.getElementById('modalBulkBody').innerHTML = `
    <label class="ub-label">Проект<select id="bulkProjectSel"><option value="">— снять проект —</option>${cachedProjects.map(p =>
      `<option value="${p.id}">${esc(p.name)}</option>`).join('')}</select></label>`;
  document.getElementById('modalBulkApply').onclick = async () => {
    const v = document.getElementById('bulkProjectSel').value;
    await j('/api/accounts/bulk-project', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ account_ids: ids, project_id: v ? Number(v) : null }),
    });
    closeModals();
    showToast('Проект обновлён');
    refreshMetaSelects();
    loadAccountsTable();
  };
  openModal('modalBulkAssign');
});

function bulkDailyLimitPrefill(ids) {
  const accs = ids.map(id => getAccountById(id)).filter(Boolean);
  if (!accs.length) return 40;
  const limits = [
    ...new Set(
      accs.map(a => Number(a.daily_limit_effective ?? a.daily_limit) || 0).filter(n => n > 0)
    ),
  ];
  if (limits.length === 1) return limits[0];
  if (accs.length === 1) return Number(accs[0].daily_limit_effective ?? accs[0].daily_limit) || 40;
  return 40;
}

document.getElementById('accBulkLimit')?.addEventListener('click', async () => {
  const ids = getSelectedAccountIds();
  if (!ids.length) return;
  const pref = bulkDailyLimitPrefill(ids);
  document.getElementById('modalBulkTitle').textContent = `Дневной лимит для ${ids.length} акк.`;
  document.getElementById('modalBulkBody').innerHTML = `
    <label class="ub-label">Лимит холодных сообщений / сутки
      <input id="bulkDailyLimit" type="number" min="1" max="1000" value="${pref}" />
    </label>
    <p class="hint">Лимит применяется к каждому выбранному аккаунту отдельно. Счётчик “сегодня” не сбрасывается.</p>`;
  document.getElementById('modalBulkApply').onclick = async () => {
    const limit = Number(document.getElementById('bulkDailyLimit').value || 0);
    if (!limit || limit < 1) { alert('Укажи лимит от 1'); return; }
    await j('/api/accounts/bulk-limit', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ account_ids: ids, daily_limit: limit }),
    });
    closeModals();
    showToast(`Лимит ${limit}/день применён`);
    loadAccountsTable();
  };
  openModal('modalBulkAssign');
});

// ── Add accounts modal ──
function renderAccQueue() {
  const el = document.getElementById('modalAccQueue');
  const cnt = document.getElementById('modalAccQueueCount');
  if (cnt) cnt.textContent = String(modalAccFilesList.length);
  if (!el) return;
  if (!modalAccFilesList.length) {
    el.innerHTML = '<div class="ub-queue-empty">Нет файлов — перетащите session / tdata / json / ZIP или нажмите «Выбрать файлы»</div>';
    return;
  }
  el.innerHTML = modalAccFilesList.map((f, i) => {
    const phone = f.name.replace(/\.(session|zip|json|txt)$/i, '');
    const kb = (f.size / 1024).toFixed(1);
    return `
    <div class="ub-queue-item">
      <div class="ub-queue-icon">📄</div>
      <div class="ub-queue-main">
        <div class="ub-queue-name">${esc(phone)}</div>
        <div class="ub-queue-meta">${kb} KB · <span class="ub-status-wait">Ожидает</span></div>
      </div>
      <button type="button" class="ub-queue-rm" data-rm="${i}" title="Убрать">×</button>
    </div>`;
  }).join('');
  el.querySelectorAll('[data-rm]').forEach(btn => {
    btn.onclick = () => {
      modalAccFilesList.splice(Number(btn.dataset.rm), 1);
      renderAccQueue();
    };
  });
}

document.getElementById('modalAccClearAll')?.addEventListener('click', () => {
  modalAccFilesList = [];
  renderAccQueue();
});

const ACC_ADD_DRAWER_ANIM_MS = 360;
let accAddDrawerClosing = false;
let accAddActiveTab = 'session';
let phoneAuthId = null;
let phoneAuthStep = 'idle'; // idle | code | 2fa
let phoneAuthCodeSent = false;

function _qrShow(tab) {
  const isQr = tab === 'qr';
  const isAk = tab === 'authkey';
  const el = (id) => document.getElementById(id);
  if (el('btnQrStart')) el('btnQrStart').hidden = !isQr;
  if (el('btnQr2faConfirm')) el('btnQr2faConfirm').hidden = true;
  if (el('btnAuthkeyImport')) el('btnAuthkeyImport').hidden = !isAk;
  if (el('modalAccUpload')) el('modalAccUpload').hidden = (tab !== 'session');
  if (el('btnAccPreview')) el('btnAccPreview').hidden = (tab !== 'session');
  if (el('modalAccPhoneSend')) el('modalAccPhoneSend').hidden = tab !== 'phone';
  if (el('modalAccPhoneConfirm')) el('modalAccPhoneConfirm').hidden = true;
  if (!isQr && typeof _qrStop === 'function') _qrStop();
}

function setAccAddTab(tab) {
  accAddActiveTab = tab;
  document.querySelectorAll('.acc-add-tab[data-acc-add-tab]').forEach(btn => {
    btn.classList.toggle('active', btn.getAttribute('data-acc-add-tab') === tab);
  });
  // Показать только свою секцию (левая колонка + очередь)
  document.querySelectorAll('[data-acc-add-pane]').forEach(el => {
    el.hidden = el.getAttribute('data-acc-add-pane') !== tab;
  });
  // Правая колонка: показывать/скрывать per-tab поля
  const twofaWrap = document.getElementById('accAddTwofaWrap');
  const fastWrap = document.getElementById('accAddFastUploadWrap');
  const deviceWrap = document.getElementById('modalAccRandomDevice')?.closest('.acc-add-check');
  const checksBlock = document.getElementById('accAddChecks');
  // Авто: всё видно. Телефон: проект+прокси+device. QR/AuthKey: только проект+прокси.
  if (twofaWrap) twofaWrap.hidden = (tab !== 'session');
  if (fastWrap) fastWrap.hidden = (tab !== 'session');
  if (deviceWrap) deviceWrap.hidden = (tab === 'qr' || tab === 'authkey');
  if (checksBlock) checksBlock.hidden = (tab === 'qr' || tab === 'authkey');
  // Footer кнопки
  const uploadBtn = document.getElementById('modalAccUpload');
  const sendBtn = document.getElementById('modalAccPhoneSend');
  const confirmBtn = document.getElementById('modalAccPhoneConfirm');
  if (uploadBtn) uploadBtn.hidden = tab !== 'session';
  const previewBtn = document.getElementById('btnAccPreview');
  if (previewBtn) previewBtn.hidden = tab !== 'session';
  if (sendBtn) {
    sendBtn.hidden = tab !== 'phone';
    sendBtn.textContent = phoneAuthCodeSent ? 'Отправить код снова' : 'Отправить код';
  }
  if (confirmBtn) confirmBtn.hidden = tab !== 'phone' || !phoneAuthCodeSent;
  _qrShow(tab);
}

function setPhoneAuthStep(step) {
  phoneAuthStep = step === '2fa' ? '2fa' : (step === 'code' ? 'code' : 'idle');
  const codeWrap = document.getElementById('modalAccPhoneCodeWrap');
  const faWrap = document.getElementById('modalAccPhone2faWrap');
  const codeEl = document.getElementById('modalAccPhoneCode');
  const faEl = document.getElementById('modalAccPhone2fa');
  if (phoneAuthStep === '2fa') {
    codeWrap?.setAttribute('hidden', '');
    faWrap?.removeAttribute('hidden');
    if (codeEl) codeEl.value = '';
    faEl?.focus();
  } else if (phoneAuthStep === 'code') {
    codeWrap?.removeAttribute('hidden');
    faWrap?.setAttribute('hidden', '');
    if (faEl) faEl.value = '';
    codeEl?.focus();
  } else {
    codeWrap?.setAttribute('hidden', '');
    faWrap?.setAttribute('hidden', '');
    if (codeEl) codeEl.value = '';
    if (faEl) faEl.value = '';
  }
  setAccAddTab(accAddActiveTab);
}

function setPhoneAuthStatus(text, kind = '') {
  const el = document.getElementById('modalAccPhoneStatus');
  if (!el) return;
  const msg = (text || '').trim();
  el.hidden = !msg;
  el.textContent = msg;
  el.classList.remove('is-ok', 'is-warn', 'is-err');
  if (kind) el.classList.add(kind);
}

function resetPhoneAuthUi() {
  phoneAuthId = null;
  phoneAuthCodeSent = false;
  setPhoneAuthStep('idle');
  const phoneEl = document.getElementById('modalAccPhone');
  if (phoneEl) phoneEl.value = '';
  setPhoneAuthStatus('');
  setAccAddTab(accAddActiveTab);
}

async function cancelPhoneAuthIfAny() {
  if (!phoneAuthId) return;
  const id = phoneAuthId;
  phoneAuthId = null;
  try {
    await j('/api/accounts/phone-auth/cancel', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ auth_id: id }),
    });
  } catch { /* ignore */ }
}

document.querySelectorAll('.acc-add-tab[data-acc-add-tab]').forEach(btn => {
  btn.addEventListener('click', async () => {
    if (btn.disabled) return;
    const tab = btn.getAttribute('data-acc-add-tab');
    if (!tab || tab === accAddActiveTab) return;
    if (tab !== 'phone' && phoneAuthId) await cancelPhoneAuthIfAny();
    if (tab !== 'phone') resetPhoneAuthUi();
    setAccAddTab(tab);
  });
});

async function resolveAccProxyGroupId() {
  const gid = document.getElementById('modalAccProxyGroup')?.value || '';
  if (gid) return gid;
  try {
    const settings = await j('/api/settings');
    return settings.default_proxy_group_id || '';
  } catch {
    return '';
  }
}

async function ensureAccProxyGroupOrAlert() {
  const gid = await resolveAccProxyGroupId();
  try {
    const settings = await j('/api/settings');
    if (settings.require_proxy !== '0' && !gid) {
      alert(
        'Задай группу прокси: выбери в списке «Группа прокси» или укажи ' +
        '«Группа прокси по умолчанию» в Настройках.\n\n' +
        'Проект «Россия» не подставляет KZ автоматически — это разные вещи.'
      );
      return null;
    }
  } catch { /* ignore */ }
  return gid;
}

async function modalAccPhoneSendCode() {
  const phone = document.getElementById('modalAccPhone')?.value.trim();
  if (!phone) { alert('Укажи номер телефона'); return; }
  const gid = await ensureAccProxyGroupOrAlert();
  if (gid === null) return;
  let pid;
  try {
    pid = await resolveAccProjectId();
  } catch {
    return;
  }
  const btn = document.getElementById('modalAccPhoneSend');
  if (btn) { btn.disabled = true; btn.textContent = 'Отправка…'; }
  setPhoneAuthStatus('Подключение и запрос кода…');
  try {
    if (phoneAuthId) await cancelPhoneAuthIfAny();
    const body = {
      phone,
      randomize_device: !!document.getElementById('modalAccRandomDevice')?.checked,
      connect_after: true,
      replace_existing: !!document.getElementById('modalAccReplaceExisting')?.checked,
    };
    if (pid) body.project_id = Number(pid);
    if (gid) body.proxy_group_id = Number(gid);
    const data = await j('/api/accounts/phone-auth/start', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      timeoutMs: 120000,
    });
    phoneAuthId = data.auth_id;
    phoneAuthCodeSent = true;
    setPhoneAuthStep('code');
    setPhoneAuthStatus(
      (data.message || 'Код отправлен') +
      ' — введи его в поле «Код из Telegram» (это не пароль 2FA).',
      'is-ok'
    );
    setAccAddTab('phone');
  } catch (e) {
    setPhoneAuthStatus(String(e.message || e), 'is-err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Отправить код'; }
  }
}

async function modalAccPhoneConfirmLogin() {
  if (!phoneAuthId) { alert('Сначала отправь код'); return; }
  const codeEl = document.getElementById('modalAccPhoneCode');
  const faEl = document.getElementById('modalAccPhone2fa');
  const code = codeEl?.value.trim() || '';
  const password = faEl?.value || '';
  if (phoneAuthStep === '2fa') {
    if (!password) { alert('Введи пароль облака Telegram (2FA), не SMS-код'); return; }
  } else if (!code) {
    alert('Введи код из Telegram (5–6 цифр) в поле «Код из Telegram»');
    return;
  }
  const btn = document.getElementById('modalAccPhoneConfirm');
  if (btn) { btn.disabled = true; btn.textContent = 'Проверка…'; }
  setPhoneAuthStatus('Подключение к Telegram… обычно 10–40 с через прокси');
  let addedPhone = null;
  try {
    const body = { auth_id: phoneAuthId };
    if (phoneAuthStep === '2fa') {
      body.password = password;
    } else {
      body.code = code;
    }
    const data = await j('/api/accounts/phone-auth/confirm', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      timeoutMs: 120000,
    });
    if (data.awaiting_2fa && !data.done) {
      setPhoneAuthStep('2fa');
      setPhoneAuthStatus(
        data.message || 'Код принят. Введи пароль облака Telegram (не SMS-код).',
        'is-warn'
      );
      return;
    }
    if (data.done) {
      addedPhone = data.phone;
      phoneAuthId = null;
      resetPhoneAuthUi();
      closeAccAddDrawer();
      showToast(data.message || `Аккаунт ${data.phone} добавлен`, 'ok', { duration: 6000 });
    }
  } catch (e) {
    const msg = String(e.message || e);
    const st = e.data?.status;
    if (st?.awaiting_2fa) {
      setPhoneAuthStep('2fa');
      setPhoneAuthStatus(msg, 'is-warn');
    } else if (phoneAuthStep === '2fa') {
      setPhoneAuthStatus(msg, 'is-err');
    } else {
      setPhoneAuthStep('code');
      if (codeEl) codeEl.value = '';
      codeEl?.focus();
      const hint = /неверный код|истёк|таймаут|занят/i.test(msg)
        ? ' Если не помогает — «Отправить код снова».'
        : '';
      setPhoneAuthStatus(msg + hint, 'is-err');
    }
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Подтвердить'; }
  }
  if (addedPhone) {
    refreshMetaSelects();
    loadAccountsTable().catch(() => {});
  }
}

document.getElementById('modalAccPhoneSend')?.addEventListener('click', modalAccPhoneSendCode);
document.getElementById('modalAccPhoneConfirm')?.addEventListener('click', modalAccPhoneConfirmLogin);

function isAccAddDrawerOpen() {
  const drawer = document.getElementById('accAddDrawer');
  return !!drawer && drawer.classList.contains('is-open');
}

function closeAccAddDrawer() {
  const drawer = document.getElementById('accAddDrawer');
  const panel = document.querySelector('.accounts-panel');
  const btn = document.getElementById('btnOpenAddAccounts');
  if (!drawer || drawer.hidden || accAddDrawerClosing) return;
  accAddDrawerClosing = true;
  cancelPhoneAuthIfAny();
  if (typeof _qrStop === 'function') _qrStop();  // остановить QR-поллинг и серверную сессию
  drawer.classList.remove('is-open');
  panel?.classList.remove('acc-add-open');
  btn?.classList.remove('active');
  const finish = () => {
    drawer.hidden = true;
    accAddDrawerClosing = false;
    resetPhoneAuthUi();
    setAccAddTab('session');
    drawer.removeEventListener('transitionend', onEnd);
  };
  const onEnd = (e) => {
    if (e.target !== drawer || e.propertyName !== 'transform') return;
    finish();
  };
  drawer.addEventListener('transitionend', onEnd);
  setTimeout(finish, ACC_ADD_DRAWER_ANIM_MS + 80);
}

async function openAccAddDrawer() {
  modalAccFilesList = [];
  renderAccQueue();
  resetPhoneAuthUi();
  setAccAddTab('session');
  const nameEl = document.getElementById('modalAccNewProjectName');
  const countryEl = document.getElementById('modalAccNewProjectCountry');
  if (nameEl) nameEl.value = '';
  if (countryEl) countryEl.value = '';
  const twofaEl = document.getElementById('modalAccTwofa');
  if (twofaEl) twofaEl.value = '';
  const resEl = document.getElementById('accImportResult');
  if (resEl) { resEl.hidden = true; resEl.innerHTML = ''; }
  const pasteEl0 = document.getElementById('modalAccPasteText');
  if (pasteEl0) pasteEl0.value = '';
  await refreshMetaSelects();
  const drawer = document.getElementById('accAddDrawer');
  const panel = document.querySelector('.accounts-panel');
  const btn = document.getElementById('btnOpenAddAccounts');
  if (!drawer) return;
  accAddDrawerClosing = false;
  drawer.hidden = false;
  drawer.classList.remove('is-open');
  panel?.classList.remove('acc-add-open');
  btn?.classList.remove('active');
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      drawer.classList.add('is-open');
      panel?.classList.add('acc-add-open');
      btn?.classList.add('active');
    });
  });
}

document.getElementById('btnOpenAddAccounts')?.addEventListener('click', async () => {
  if (!document.getElementById('tab-accounts')?.classList.contains('active')) {
    activateTab('accounts');
  }
  if (isAccAddDrawerOpen()) {
    closeAccAddDrawer();
    return;
  }
  await openAccAddDrawer();
});
document.getElementById('accAddDrawerCollapse')?.addEventListener('click', closeAccAddDrawer);
document.getElementById('accAddDrawerCancel')?.addEventListener('click', closeAccAddDrawer);

document.getElementById('modalAccPick')?.addEventListener('click', () => document.getElementById('modalAccFiles').click());
document.getElementById('modalAccFiles')?.addEventListener('change', e => {
  modalAccFilesList.push(...[...e.target.files]);
  renderAccQueue();
  e.target.value = '';
});

const dropZone = document.getElementById('accDropZone');
dropZone?.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone?.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone?.addEventListener('drop', async e => {
  e.preventDefault();
  dropZone.classList.remove('drag');
  const items = e.dataTransfer?.items;
  // Поддержка drag-drop ПАПКИ целиком (webkitGetAsEntry)
  if (items && items.length && typeof items[0].webkitGetAsEntry === 'function') {
    const entries = [];
    for (const it of items) {
      const en = it.webkitGetAsEntry?.();
      if (en) entries.push(en);
    }
    if (entries.some(en => en && en.isDirectory)) {
      const files = [];
      for (const en of entries) await _readDropEntry(en, files);
      // tdata-папка прилетает как россыпь файлов без расширений — её не собрать без zip.
      const usable = files.filter(f => /\.(session|zip|json|txt)$/i.test(f.name));
      const hasTdataLoose = files.some(f => /^key_data/i.test(f.name));
      if (usable.length) { modalAccFilesList.push(...usable); renderAccQueue(); }
      if (hasTdataLoose && !usable.length) {
        showToast('Папку tdata заархивируй в .zip и перетащи архив — россыпь файлов не собрать', 'warn', { duration: 9000 });
      } else if (!usable.length) {
        showToast('В папке не найдено session/zip/json/txt', 'warn');
      }
      return;
    }
  }
  modalAccFilesList.push(...[...e.dataTransfer.files]);
  renderAccQueue();
});

// Рекурсивный обход dropnutой папки → собрать все файлы
async function _readDropEntry(entry, out, depth = 0) {
  if (!entry || depth > 8) return;
  if (entry.isFile) {
    await new Promise(res => {
      entry.file(f => {
        // путь как имя для tdata-папок: чтобы видеть структуру
        if (entry.fullPath && entry.fullPath !== '/' + f.name) {
          try { Object.defineProperty(f, '_relpath', { value: entry.fullPath }); } catch (_) {}
        }
        out.push(f); res();
      }, () => res());
    });
  } else if (entry.isDirectory) {
    const reader = entry.createReader();
    await new Promise(res => {
      const readBatch = () => {
        reader.readEntries(async batch => {
          if (!batch.length) { res(); return; }
          for (const sub of batch) await _readDropEntry(sub, out, depth + 1);
          readBatch();
        }, () => res());
      };
      readBatch();
    });
  }
}

document.getElementById('modalAccProject')?.addEventListener('change', toggleAccNewProjectBox);

document.getElementById('modalAccUpload')?.addEventListener('click', async () => {
  const btn = document.getElementById('modalAccUpload');
  if (btn?.disabled) return;
  if (btn) { btn.disabled = true; btn.textContent = 'Обработка…'; }  // анти-двойной клик сразу
  const pasteRaw = (document.getElementById('modalAccPasteText')?.value || '').trim();
  const pasteLines = pasteRaw ? pasteRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean) : [];
  if (!modalAccFilesList.length && !pasteLines.length) {
    if (btn) { btn.disabled = false; btn.textContent = 'Загрузить аккаунты'; }
    alert('Добавь файлы или вставь строки (session / tdata / json / string или ZIP)');
    return;
  }
  let pid;
  try {
    pid = await resolveAccProjectId();
  } catch {
    if (btn) { btn.disabled = false; btn.textContent = 'Загрузить аккаунты'; }
    return;
  }
  const fd = new FormData();
  modalAccFilesList.forEach(f => fd.append('files', f));
  // Вставленные строки → синтетические .txt файлы (backend сам определит формат)
  pasteLines.forEach((line, i) => {
    const blob = new Blob([line], { type: 'text/plain' });
    fd.append('files', blob, `pasted_${i + 1}.txt`);
  });
  const gid = document.getElementById('modalAccProxyGroup').value;
  if (pid) fd.append('project_id', pid);
  if (gid) fd.append('proxy_group_id', gid);
  fd.append('randomize_device', document.getElementById('modalAccRandomDevice').checked ? 'true' : 'false');
  const fastUpload = !!document.getElementById('modalAccFastUpload')?.checked;
  const wantSpam = !!document.getElementById('modalAccCheckSpam')?.checked;
  const wantsCheck = !fastUpload || wantSpam;  // хочет проверку/подключение Telegram
  let effGid = gid;
  if (!effGid) {
    try {
      const settings = await j('/api/settings');
      effGid = settings.default_proxy_group_id || '';
    } catch (_) {}
  }
  let connectAfter = false, doCheckSession = false, doCheckSpam = false;
  if (effGid) {
    // Прокси есть — подключаем/проверяем как обычно
    connectAfter = true;
    doCheckSession = !fastUpload;
    doCheckSpam = wantSpam;
  } else if (wantsCheck) {
    // Прокси НЕТ, но просят проверку → предупреждаем (пойдёт с реального IP)
    const okNoProxy = confirm(
      '⚠ Прокси не выбран.\n\nПроверка/подключение аккаунтов пойдёт С ТВОЕГО IP (без прокси) — ' +
      'аккаунты могут спалиться, особенно пачкой.\n\nПродолжить без прокси?'
    );
    if (!okNoProxy) {
      if (btn) { btn.disabled = false; btn.textContent = 'Загрузить аккаунты'; }
      showToast('Импорт отменён — выбери прокси или сними проверки', 'warn');
      return;
    }
    connectAfter = true;
    doCheckSession = !fastUpload;
    doCheckSpam = wantSpam;
  }
  // else: без прокси и без проверок — просто заводим в базе, НЕ запускаем
  fd.append('connect_after', connectAfter ? 'true' : 'false');
  fd.append('check_session', doCheckSession ? 'true' : 'false');
  if (doCheckSpam) fd.append('check_spam', 'true');
  fd.append('background', 'true');
  const twofa = document.getElementById('modalAccTwofa')?.value.trim();
  if (twofa) fd.append('twofa_password', twofa);
  if (btn) { btn.disabled = true; btn.textContent = 'Обработка файлов…'; }
  try {
    const r = await fetch('/api/accounts/import-universal', { method: 'POST', body: fd });
    if (!r.ok) { alert(await r.text()); return; }
    const data = await r.json();
    modalAccFilesList = [];
    renderAccQueue();
    const pasteEl = document.getElementById('modalAccPasteText');
    if (pasteEl) pasteEl.value = '';
    const nNew = data.imported?.length || 0;
    const nSkip = data.skipped?.length || 0;
    const nErr = data.errors?.length || 0;
    _renderImportResult({ nNew, nSkip, nErr, errors: data.errors || [],
      skipped: data.skipped || [], imported: data.imported || [],
      background: data.background });
    if (nNew) {
      showToast(`Импорт: +${nNew} новых${nSkip ? ', ' + nSkip + ' уже были' : ''}${nErr ? ', ' + nErr + ' ошибок' : ''}`,
        nErr ? 'warn' : 'ok', { duration: 6000 });
      if (data.background) startAccImportPoll();
    } else if (nSkip && !nErr) {
      showToast(nSkip === 1 ? 'Аккаунт уже в базе' : `Все ${nSkip} акк. уже в базе`, 'warning', { duration: 7000 });
    } else {
      showToast(nErr ? 'Ни один не импортирован — см. ошибки' : 'Ничего не импортировано', 'err', { duration: 8000 });
    }
    refreshMetaSelects();
    await loadAccountsTable();
  } catch (e) {
    showToast('Ошибка импорта: ' + (e.message || e), 'err', { duration: 8000 });
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Загрузить аккаунты'; }
  }
});

function _renderImportResult(r) {
  const el = document.getElementById('accImportResult');
  if (!el) return;
  const rows = [];
  rows.push(`<div class="air-stat air-ok"><b>✓ ${r.nNew}</b> добавлено</div>`);
  if (r.nSkip) rows.push(`<div class="air-stat air-warn"><b>⚠ ${r.nSkip}</b> уже были</div>`);
  if (r.nErr) rows.push(`<div class="air-stat air-err"><b>✕ ${r.nErr}</b> ошибок</div>`);
  let details = '';
  if (r.nErr) {
    const items = r.errors.slice(0, 50).map(e => `<li>${esc(e)}</li>`).join('');
    const more = r.errors.length > 50 ? `<li>…ещё ${r.errors.length - 50}</li>` : '';
    details += `<details class="air-details" open><summary>Ошибки (${r.nErr})</summary><ul>${items}${more}</ul></details>`;
  }
  if (r.nSkip) {
    const items = r.skipped.slice(0, 50).map(p => `<li>${esc(p)}</li>`).join('');
    details += `<details class="air-details"><summary>Пропущены — уже в базе (${r.nSkip})</summary><ul>${items}</ul></details>`;
  }
  el.hidden = false;
  el.innerHTML = `<div class="air-head">Результат импорта${r.background ? ' · проверка в фоне' : ''}</div>
    <div class="air-stats">${rows.join('')}</div>${details}`;
}

// ── Proxies / groups (Underground accordion) ──
const expandedProxyGroups = new Set();
const proxyGroupLoadCache = new Map();

function countryFlag(country) {
  if (!country) return '🌐';
  const map = {
    estonia: '🇪🇪', эстония: '🇪🇪',
    colombia: '🇨🇴', колумбия: '🇨🇴',
    moldova: '🇲🇩', молдова: '🇲🇩',
    kazakhstan: '🇰🇿', казахстан: '🇰🇿',
    russia: '🇷🇺', россия: '🇷🇺', рф: '🇷🇺',
    germany: '🇩🇪', германия: '🇩🇪',
    usa: '🇺🇸', 'united states': '🇺🇸',
  };
  const k = country.toLowerCase().trim();
  return map[k] || '🌐';
}

function groupSubtitle(g) {
  const parts = [];
  if (g.country) parts.push(g.country);
  if (g.description) parts.push(g.description);
  else if (g.proxy_total) parts.push(`${g.proxy_total} прокси`);
  return parts.join(' · ') || '—';
}

function renderProxyTableRows(proxies, country) {
  if (!proxies.length) {
    return `<tr><td colspan="6" class="px-empty-inner">В группе нет прокси — «+» или массовая загрузка</td></tr>`;
  }
  const flag = countryFlag(country);
  return proxies.map(p => {
    const active = p.is_active !== 0;
    const type = (p.proxy_type || 'socks5').toUpperCase();
    const accN = Number(p.account_count) || 0;
    const fails = Number(p.fail_count) || 0;
    const statusTitle = active
      ? (fails ? `активен · fail ${fails}` : 'активен')
      : `выкл · fail ${fails}${p.last_error ? ': ' + p.last_error : ''}`;
    return `
    <tr>
      <td><div class="px-country-cell"><span>${flag}</span><span>${esc(p.country || country || '—')}</span></div></td>
      <td><code>${esc(p.host)}:${p.port}</code></td>
      <td><span class="px-type-badge">${esc(type)}</span></td>
      <td><span class="px-status-dot${active ? '' : ' off'}" title="${esc(statusTitle)}">${active ? '✓' : '×'}</span>${fails ? ` <span class="hint">${fails}f</span>` : ''}</td>
      <td>${accN}</td>
      <td>
        <div class="px-row-actions">
          <button type="button" class="px-icon-btn danger" data-del-proxy="${p.id}" title="Удалить">🗑</button>
        </div>
      </td>
    </tr>`;
  }).join('');
}

async function loadGroupProxyTable(gid) {
  const body = document.getElementById(`pxBody${gid}`);
  if (!body) return;
  body.innerHTML = '<div class="px-loading">Загрузка прокси…</div>';
  try {
    const px = await j(`/api/proxies?group_id=${gid}`);
    const g = cachedProxyGroups.find(x => x.id === gid);
    const country = g?.country || '';
    proxyGroupLoadCache.set(gid, px);
    body.innerHTML = `
      <div class="px-table-wrap">
        <table class="px-table">
          <thead><tr>
            <th>Страна</th><th>Прокси</th><th>Тип</th><th>Статус</th><th>Акк.</th><th>Действия</th>
          </tr></thead>
          <tbody>${renderProxyTableRows(px, country)}</tbody>
        </table>
      </div>`;
    body.querySelectorAll('[data-del-proxy]').forEach(btn => {
      btn.onclick = e => {
        e.stopPropagation();
        deleteProxy(Number(btn.dataset.delProxy));
      };
    });
  } catch (e) {
    body.innerHTML = `<div class="px-empty-inner err">${esc(String(e.message || e))}</div>`;
  }
}

function syncProxyGroupOpen(gid) {
  const card = document.querySelector(`.px-group[data-gid="${gid}"]`);
  if (!card) return;
  const open = expandedProxyGroups.has(gid);
  card.classList.toggle('px-group-open', open);
  const body = card.querySelector('.px-group-body');
  if (body) body.hidden = !open;
}

async function toggleProxyGroup(gid) {
  if (expandedProxyGroups.has(gid)) {
    expandedProxyGroups.delete(gid);
    syncProxyGroupOpen(gid);
    return;
  }
  expandedProxyGroups.add(gid);
  syncProxyGroupOpen(gid);
  if (!proxyGroupLoadCache.has(gid)) await loadGroupProxyTable(gid);
}

async function loadProxyGroups() {
  const el = document.getElementById('proxyGroupList');
  if (!el) return;
  el.innerHTML = '<div class="px-loading">Загрузка групп прокси…</div>';
  let groups = [];
  try {
    await refreshMetaSelects();
    groups = cachedProxyGroups || [];
  } catch (e) {
    el.innerHTML = `<div class="err">Не удалось загрузить прокси: ${esc(e.message || e)}<br><button type="button" class="ub-btn-outline" onclick="loadProxyGroups()" style="margin-top:10px;">Повторить</button></div>`;
    return;
  }
  if (!groups.length) {
    el.innerHTML = '<div class="empty">Прокси-групп пока нет. Нажми «Новая группа» или «Массовая загрузка».</div>';
    return;
  }
  el.innerHTML = groups.map(g => {
    const open = expandedProxyGroups.has(g.id);
    const total = g.proxy_total || 0;
    const assigned = g.accounts_assigned || 0;
    const free = g.proxy_free ?? Math.max(0, total - assigned);
    return `
    <div class="px-group${open ? ' px-group-open' : ''}" data-gid="${g.id}">
      <div class="px-group-head" data-toggle-gid="${g.id}">
        <span class="px-chevron">▶</span>
        <div class="px-group-info">
          <div class="px-group-title-row">
            <span class="px-flag">${countryFlag(g.country)}</span>
            <span class="px-group-title">${esc(g.name)}</span>
          </div>
          <div class="px-group-sub">${esc(groupSubtitle(g))}</div>
        </div>
        <div class="px-group-badges">
          <span class="px-badge ok">✓ ${total} рабочих</span>
          <span class="px-badge">🌍 ${total}</span>
          <span class="px-badge">👤 ${assigned}</span>
          <span class="px-badge">${free} своб.</span>
        </div>
        <div class="px-group-btns">
          <button type="button" class="px-icon-btn add" data-add-gid="${g.id}" title="Добавить прокси">+</button>
          <button type="button" class="px-icon-btn" data-assign-gid="${g.id}" title="Акки без прокси">↪</button>
          <button type="button" class="px-icon-btn danger" data-del-gid="${g.id}" data-gname="${esc(g.name)}" title="Удалить группу">🗑</button>
        </div>
      </div>
      <div class="px-group-body" id="pxBody${g.id}" hidden></div>
    </div>`;
  }).join('');

  el.querySelectorAll('[data-toggle-gid]').forEach(head => {
    head.onclick = e => {
      if (e.target.closest('.px-group-btns')) return;
      toggleProxyGroup(Number(head.dataset.toggleGid));
    };
  });
  el.querySelectorAll('[data-add-gid]').forEach(btn => {
    btn.onclick = e => { e.stopPropagation(); openBulkForGroup(Number(btn.dataset.addGid)); };
  });
  el.querySelectorAll('[data-assign-gid]').forEach(btn => {
    btn.onclick = e => { e.stopPropagation(); assignGroupToNeedy(Number(btn.dataset.assignGid)); };
  });
  el.querySelectorAll('[data-del-gid]').forEach(btn => {
    btn.onclick = e => {
      e.stopPropagation();
      deleteProxyGroup(Number(btn.dataset.delGid), btn.dataset.gname || '');
    };
  });

  for (const gid of expandedProxyGroups) {
    syncProxyGroupOpen(gid);
    if (proxyGroupLoadCache.has(gid)) {
      const px = proxyGroupLoadCache.get(gid);
      const g = groups.find(x => x.id === gid);
      const body = document.getElementById(`pxBody${gid}`);
      if (body) {
        body.hidden = false;
        body.innerHTML = `
          <div class="px-table-wrap">
            <table class="px-table">
              <thead><tr>
                <th>Страна</th><th>Прокси</th><th>Тип</th><th>Статус</th><th>Акк.</th><th>Действия</th>
              </tr></thead>
              <tbody>${renderProxyTableRows(px, g?.country || '')}</tbody>
            </table>
          </div>`;
        body.querySelectorAll('[data-del-proxy]').forEach(b => {
          b.onclick = ev => { ev.stopPropagation(); deleteProxy(Number(b.dataset.delProxy)); };
        });
      }
    } else {
      loadGroupProxyTable(gid);
    }
  }
}

window.openBulkForGroup = async gid => {
  await refreshMetaSelects();
  document.getElementById('modalPxText').value = '';
  document.getElementById('modalPxFile').value = '';
  document.getElementById('modalPxFileName').textContent = 'файл не выбран';
  const mg = document.getElementById('modalPxGroup');
  if (mg) { mg.value = String(gid); togglePxNewGroupBox(); }
  openModal('modalBulkProxy');
};

window.deleteProxy = async id => {
  if (!confirm('Удалить прокси?')) return;
  try {
    await j(`/api/proxies/${id}`, { method: 'DELETE' });
  } catch (e) { alert(e.message); return; }
  proxyGroupLoadCache.clear();
  loadProxyGroups();
  loadAccountsTable();
};

window.deleteProxyGroup = async (id, name) => {
  if (!confirm(`Удалить группу «${name}»? (прокси останутся без группы)`)) return;
  try {
    await j(`/api/proxy-groups/${id}`, { method: 'DELETE' });
    expandedProxyGroups.delete(id);
    proxyGroupLoadCache.delete(id);
    loadProxyGroups();
  } catch (e) { showToast('Ошибка: ' + (e.message || e)); }
};

window.assignGroupToNeedy = async gid => {
  try {
    const r = await j(`/api/proxies/auto-assign?group_id=${gid}`, { method: 'POST' });
    showToast(`Назначено: ${r.assigned || 0}`);
    loadAccountsTable();
    loadProxyGroups();
  } catch (e) { showToast('Ошибка: ' + (e.message || e)); }
};

document.getElementById('btnAutoAssignProxy')?.addEventListener('click', async () => {
  try {
    const r = await j('/api/proxies/auto-assign', { method: 'POST' });
    showToast(`Авто-назначение: ${r.assigned || 0} акк.`);
    loadAccountsTable();
    loadProxyGroups();
  } catch (e) { showToast('Ошибка: ' + (e.message || e)); }
});

document.getElementById('modalPxGroup')?.addEventListener('change', togglePxNewGroupBox);

document.getElementById('btnOpenBulkProxy')?.addEventListener('click', async () => {
  await refreshMetaSelects();
  document.getElementById('modalPxText').value = '';
  document.getElementById('modalPxFile').value = '';
  document.getElementById('modalPxFileName').textContent = 'файл не выбран';
  document.getElementById('modalPxCountry').value = '';
  document.getElementById('modalPxNewGroupName').value = '';
  document.getElementById('modalPxNewGroupCountry').value = '';
  const mg = document.getElementById('modalPxGroup');
  if (mg) mg.value = '';
  togglePxNewGroupBox();
  openModal('modalBulkProxy');
});

document.getElementById('modalPxPickFile')?.addEventListener('click', () => document.getElementById('modalPxFile').click());
document.getElementById('modalPxFile')?.addEventListener('change', e => {
  const f = e.target.files[0];
  document.getElementById('modalPxFileName').textContent = f ? f.name : 'файл не выбран';
});

document.getElementById('modalPxUpload')?.addEventListener('click', async () => {
  let gid = document.getElementById('modalPxGroup').value;
  if (gid === '__new__') {
    const name = document.getElementById('modalPxNewGroupName').value.trim();
    const gcountry = document.getElementById('modalPxNewGroupCountry').value.trim() || null;
    if (!name) { alert('Укажи название новой группы'); return; }
    const created = await j('/api/proxy-groups', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, country: gcountry }),
    });
    gid = String(created.id);
  }
  if (!gid) { alert('Выбери группу или создай новую'); return; }
  let text = document.getElementById('modalPxText').value.trim();
  const file = document.getElementById('modalPxFile').files[0];
  if (file && !text) text = await file.text();
  if (!text) { alert('Нет прокси в списке'); return; }
  const r = await j('/api/proxies/bulk', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text,
      group_id: Number(gid),
      country: document.getElementById('modalPxCountry').value.trim() || null,
      proxy_type: document.getElementById('modalPxType').value,
    }),
  });
  closeModals();
  const grp = r.group_name ? `«${r.group_name}»` : 'группу';
  const total = r.group_total != null ? ` Всего в группе: ${r.group_total}.` : '';
  let msg;
  let kind = 'ok';
  if (!r.added && !r.updated && r.skipped) {
    msg = `Импорт в ${grp}: ${r.skipped} строк не распознано. Формат: host:port@user:pass`;
    kind = 'err';
  } else if (!r.added && r.updated && !r.skipped) {
    msg = `Импорт в ${grp}: все ${r.updated} прокси уже были в группе.${total}`;
  } else if (!r.added && !r.updated && !r.skipped) {
    msg = `Импорт в ${grp}: список пуст или без валидных строк`;
    kind = 'err';
  } else {
    const parts = [];
    if (r.added) parts.push(`+${r.added} новых`);
    if (r.updated) parts.push(`${r.updated} уже были`);
    if (r.skipped) parts.push(`${r.skipped} пропуск`);
    msg = `Импорт в ${grp}: ${parts.join(', ')}.${total}`;
  }
  showToast(msg, kind, { duration: kind === 'err' ? 8000 : 5500 });
  proxyGroupLoadCache.clear();
  loadProxyGroups();
});

document.getElementById('btnNewProxyGroup')?.addEventListener('click', () => {
  modalNewGroupMode = 'proxy-group';
  document.getElementById('modalNewGroupTitle').textContent = 'Новая группа прокси';
  document.getElementById('modalGroupName').value = '';
  document.getElementById('modalGroupCountry').value = '';
  openModal('modalNewGroup');
});

document.getElementById('btnNewProject')?.addEventListener('click', () => {
  modalNewGroupMode = 'project';
  document.getElementById('modalNewGroupTitle').textContent = 'Новый проект (страна)';
  document.getElementById('modalGroupName').value = '';
  document.getElementById('modalGroupCountry').value = '';
  openModal('modalNewGroup');
});

document.getElementById('btnDeleteProject')?.addEventListener('click', async () => {
  const sel = document.getElementById('accProjectFilter');
  const val = sel?.value || '';
  if (val === '' || val === '0') {
    showToast('Выбери конкретный проект в фильтре сверху, потом жми «− Проект»', 'warn');
    return;
  }
  const pid = Number(val);
  let proj = (cachedProjects || []).find(p => p.id === pid);
  if (!proj) {
    await refreshMetaSelects({ force: true });
    proj = (cachedProjects || []).find(p => p.id === pid);
  }
  const name = proj?.name || ('#' + pid);
  const count = proj?.account_count || 0;
  const info = document.getElementById('delProjInfo');
  const foot = document.getElementById('delProjFoot');
  if (count > 0) {
    info.innerHTML = `В проекте <b>${esc(name)}</b> — <b>${count}</b> акк. Что сделать?`;
    foot.innerHTML =
      '<button type="button" class="ub-btn-secondary" data-close>Отмена</button>' +
      '<button type="button" class="ub-btn-outline" id="delProjMove">Перенести в «Без проекта»</button>' +
      `<button type="button" class="ub-btn-danger" id="delProjWith">Удалить с аккаунтами (${count})</button>`;
  } else {
    info.innerHTML = `Проект <b>${esc(name)}</b> пустой. Удалить?`;
    foot.innerHTML =
      '<button type="button" class="ub-btn-secondary" data-close>Отмена</button>' +
      '<button type="button" class="ub-btn-primary" id="delProjMove">Удалить</button>';
  }
  openModal('modalDeleteProject');

  const doDelete = async (mode) => {
    try {
      const r = await fetch(`/api/projects/${pid}?mode=${mode}`, { method: 'DELETE' });
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json().catch(() => ({}));
      closeModals();
      if (sel) sel.value = '';
      showToast(mode === 'with_accounts'
        ? `Проект «${name}» удалён вместе с ${data.deleted_accounts || 0} акк.`
        : `Проект «${name}» удалён, аккаунты перенесены в «Без проекта»`, 'ok');
      await refreshMetaSelects({ force: true });
      await loadAccountsTable();
    } catch (e) {
      showToast('Ошибка удаления проекта: ' + (e.message || e), 'err');
    }
  };
  foot.querySelector('#delProjMove')?.addEventListener('click', () => doDelete('move'));
  foot.querySelector('#delProjWith')?.addEventListener('click', () => doDelete('with_accounts'));
  foot.querySelectorAll('[data-close]').forEach(b => { b.onclick = closeModals; });
});

document.getElementById('modalTransitionSave')?.addEventListener('click', () => {
  saveTransitionModal().catch(err => showToast(err.message || 'Ошибка', 'err'));
});

document.getElementById('modalGroupSave')?.addEventListener('click', async () => {
  const name = document.getElementById('modalGroupName').value.trim();
  const country = document.getElementById('modalGroupCountry').value.trim() || null;
  if (!name) { alert('Название обязательно'); return; }
  const url = modalNewGroupMode === 'project' ? '/api/projects' : '/api/proxy-groups';
  await j(url, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, country }),
  });
  closeModals();
  showToast('Создано');
  refreshMetaSelects();
  loadProxyGroups();
});

document.getElementById('pxPurgeInvalid')?.addEventListener('click', async () => {
  if (!confirm('Удалить нерабочие прокси? Операция необратима.')) return;
  const r = await j('/api/proxies/purge-invalid', { method: 'POST' });
  showToast(`Удалено битых: ${r.purged}`);
  proxyGroupLoadCache.clear();
  loadProxyGroups();
});

async function loadProxies() { loadProxyGroups(); }
window.loadProxyGroups = loadProxyGroups;

async function loadSettings() {
  try {
    const s = await j('/api/settings');
    applySettingsToForm(s);
  } catch (e) {
    const el = document.getElementById('settingsPauseSummary');
    if (el) el.innerHTML = `<span class="err">Не удалось загрузить настройки: ${esc(e.message || e)}</span>`;
    showToast('Ошибка загрузки настроек');
    throw e;
  }
}

function _aiKeyProviderLabel(key) {
  const k = (key || '').trim().toLowerCase();
  if (!k) return '';
  if (k.startsWith('sk-or-')) return 'OpenRouter';
  if (k.startsWith('sk-')) return 'DeepSeek';
  return 'FreeModel';
}

let _aiKeyBadgesBound = false;
function _bindAiKeyProviderBadges() {
  const ids = ['setFreemodelKey', 'setFreemodelKeyInbox', 'setFreemodelKeyParser', 'setFreemodelKeySpare', 'setFreemodelKeySpare2'];
  ids.forEach(id => {
    const inp = document.getElementById(id);
    const badge = document.getElementById('prov_' + id);
    if (!inp || !badge) return;
    const upd = () => {
      const lbl = _aiKeyProviderLabel(inp.value);
      badge.textContent = lbl ? '→ ' + lbl : '';
      badge.classList.toggle('is-set', !!lbl);
    };
    if (!_aiKeyBadgesBound) inp.addEventListener('input', upd);
    upd();
  });
  _aiKeyBadgesBound = true;
}

async function loadAiModelsIntoSettings(selected) {
  // Динамически тянем модели ИЗ КЛЮЧА (провайдер определяется по ключу на бэке):
  // на нашем дефолтном ключе — модели FreeModel; на ключе клиента (DeepSeek/
  // OpenRouter/Claude) — его модели. Клиент выбирает сам.
  const sel = document.getElementById('setFreemodelModelOpenai');
  if (!sel) return;
  const want = selected || sel.value || 'gpt-5.4';
  try {
    const r = await j('/api/ai/models?provider=openai');
    const models = (r && r.models) || [];
    if (!models.length) {
      sel.innerHTML = `<option value="${esc(want)}">${esc(want)}</option>`;
      return;
    }
    const opts = models.map(m => `<option value="${esc(m)}"${m === want ? ' selected' : ''}>${esc(m)}</option>`);
    if (want && !models.includes(want)) {
      opts.unshift(`<option value="${esc(want)}" selected>${esc(want)} (текущая)</option>`);
    }
    sel.innerHTML = opts.join('');
    sel.value = want;
  } catch (e) {
    sel.innerHTML = `<option value="${esc(want)}">${esc(want)}</option>`;
  }
}

function applySettingsToForm(s) {
  document.getElementById('setAccountDelayMin').value = s.account_delay_min || '8';
  document.getElementById('setAccountDelayMax').value = s.account_delay_max || '12';
  document.getElementById('setDelayMin').value = s.delay_min || '3';
  document.getElementById('setDelayMax').value = s.delay_max || '6';
  document.getElementById('setDailyLimit').value = s.daily_limit_default || '40';
  document.getElementById('setParallel').value = s.parallel_workers || '10';
  const gde = document.getElementById('setGlobalDelay');
  if (gde) gde.checked = (s.global_delay_enabled ?? '0') === '1';
  document.getElementById('setConnectBatch').value = s.pool_connect_batch || '3';
  const cdm = document.getElementById('setConnectDelayMin');
  if (cdm) cdm.value = s.pool_connect_delay_min || '4';
  const cdx = document.getElementById('setConnectDelayMax');
  if (cdx) cdx.value = s.pool_connect_delay_max || '8';
  const cjm = document.getElementById('setConnectJitterMin');
  if (cjm) cjm.value = s.pool_connect_jitter_min || '1';
  const cjx = document.getElementById('setConnectJitterMax');
  if (cjx) cjx.value = s.pool_connect_jitter_max || '3';
  document.getElementById('apiMasked').textContent = s.api_configured
    ? `API OK (id ${s.api_id_masked})`
    : 'API не задан';
  appSettings.notify_sound = s.notify_sound ?? '1';
  appSettings.notify_desktop = s.notify_desktop ?? '1';
  document.getElementById('setNotifySound').checked = appSettings.notify_sound === '1';
  document.getElementById('setNotifyDesktop').checked = appSettings.notify_desktop === '1';
  const setAutoText = document.getElementById('setAutoText');
  if (setAutoText) setAutoText.value = s.autoresponder_text || '';
  const setAutoDelay = document.getElementById('setAutoDelay');
  if (setAutoDelay) setAutoDelay.value = s.autoresponder_delay || '3';
  const rp = document.getElementById('setRequireProxy');
  if (rp) rp.checked = (s.require_proxy ?? '1') === '1';
  const aps = document.getElementById('setAutoProxySwitch');
  if (aps) aps.checked = (s.auto_proxy_switch ?? '1') === '1';
  const ct = document.getElementById('setConnectTimeout');
  if (ct) ct.value = s.pool_connect_timeout || '35';
  const mpr = document.getElementById('setMaxProxyRetry');
  if (mpr) mpr.value = s.max_proxy_retry || '3';
  appSettings.default_proxy_group_id = s.default_proxy_group_id || '';
  const fmStatus = document.getElementById('freemodelKeyStatus');
  if (fmStatus) {
    if (!s.freemodel_configured) {
      fmStatus.textContent = 'AI не настроен — вставьте свой ключ ниже';
      fmStatus.classList.remove('is-ok');
    } else {
      fmStatus.textContent = 'AI настроен ✓ — готов к работе';
      fmStatus.classList.add('is-ok');
    }
  }
  const fmp = document.getElementById('setFreemodelProvider');
  if (fmp) fmp.value = (s.freemodel_provider === 'anthropic') ? 'anthropic' : 'openai';
  const fms = document.getElementById('setFreemodelStyle');
  if (fms && s.freemodel_style) fms.value = s.freemodel_style;
  const fmo = document.getElementById('setFreemodelModelOpenai');
  if (fmo) loadAiModelsIntoSettings(s.freemodel_model_openai || 'gpt-5.4');
  const fmParser = document.getElementById('setFreemodelModelParser');
  if (fmParser) fmParser.value = s.freemodel_model_parser || 'gpt-5.4';
  const fma = document.getElementById('setFreemodelModelAnthropic');
  if (fma) fma.value = s.freemodel_model_anthropic || 'claude-t0';
  const fmk = document.getElementById('setFreemodelKey');
  if (fmk) fmk.value = '';
  ['setFreemodelKeyInbox', 'setFreemodelKeyParser', 'setFreemodelKeySpare', 'setFreemodelKeySpare2'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.value = '';
  });
  _bindAiKeyProviderBadges();
  updateSettingsSummary(s);
}

function updateSettingsSummary(s) {
  const el = document.getElementById('settingsPauseSummary');
  if (!el || !s) return;
  const accMin = s.account_delay_min || '8';
  const accMax = s.account_delay_max || '12';
  const gMin = s.delay_min || '3';
  const gMax = s.delay_max || '6';
  const gOn = (s.global_delay_enabled ?? '0') === '1';
  el.innerHTML =
    `<strong>С акка:</strong> ${accMin}–${accMax} сек · ` +
    `<strong>Общая очередь:</strong> ${gOn ? `${gMin}–${gMax} сек` : 'выкл (быстрый режим)'}`;
}

async function applyPreset(name) {
  await j('/api/settings/preset', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ preset: name }),
  });
  await loadSettings();
  showToast(name === 'safe' ? 'Пресет: безопасный' : 'Пресет: сбалансированный');
}

document.getElementById('btnPresetBalanced')?.addEventListener('click', () => applyPreset('balanced'));
document.getElementById('btnPresetSafe')?.addEventListener('click', () => applyPreset('safe'));

async function saveAllSettings() {
  try {
    await j('/api/settings', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
      account_delay_min: document.getElementById('setAccountDelayMin').value,
      account_delay_max: document.getElementById('setAccountDelayMax').value,
      delay_min: document.getElementById('setDelayMin').value,
      delay_max: document.getElementById('setDelayMax').value,
      daily_limit_default: document.getElementById('setDailyLimit').value,
      parallel_workers: document.getElementById('setParallel').value,
      pool_connect_batch: document.getElementById('setConnectBatch').value,
      pool_connect_delay_min: document.getElementById('setConnectDelayMin')?.value,
      pool_connect_delay_max: document.getElementById('setConnectDelayMax')?.value,
      pool_connect_jitter_min: document.getElementById('setConnectJitterMin')?.value,
      pool_connect_jitter_max: document.getElementById('setConnectJitterMax')?.value,
      require_proxy: document.getElementById('setRequireProxy')?.checked ? '1' : '0',
      auto_proxy_switch: document.getElementById('setAutoProxySwitch')?.checked ? '1' : '0',
      pool_connect_timeout: document.getElementById('setConnectTimeout')?.value,
      max_proxy_retry: document.getElementById('setMaxProxyRetry')?.value,
      default_proxy_group_id: document.getElementById('setDefaultProxyGroup')?.value || '',
      notify_sound: document.getElementById('setNotifySound').checked ? '1' : '0',
      notify_desktop: document.getElementById('setNotifyDesktop').checked ? '1' : '0',
      autoresponder_text: document.getElementById('setAutoText')?.value,
      autoresponder_delay: document.getElementById('setAutoDelay')?.value,
      global_delay_enabled: document.getElementById('setGlobalDelay')?.checked ? '1' : '0',
      freemodel_api_key: document.getElementById('setFreemodelKey')?.value || undefined,
      freemodel_api_key_inbox: document.getElementById('setFreemodelKeyInbox')?.value || undefined,
      freemodel_api_key_parser: document.getElementById('setFreemodelKeyParser')?.value || undefined,
      freemodel_api_key_spare: document.getElementById('setFreemodelKeySpare')?.value || undefined,
      freemodel_api_key_spare2: document.getElementById('setFreemodelKeySpare2')?.value || undefined,
      freemodel_provider: document.getElementById('setFreemodelProvider')?.value,
      freemodel_model_openai: document.getElementById('setFreemodelModelOpenai')?.value || undefined,
      freemodel_model_parser: document.getElementById('setFreemodelModelParser')?.value,
      freemodel_model_anthropic: document.getElementById('setFreemodelModelAnthropic')?.value,
      freemodel_style: document.getElementById('setFreemodelStyle')?.value,
    }),
  });
    closeModals();
    await loadSettings();
    showToast('Настройки сохранены');
  } catch (e) {
    showToast('Ошибка сохранения: ' + (e.message || e));
  }
}

document.querySelectorAll('.settings-tile').forEach(btn => {
  btn.addEventListener('click', async () => {
    if (btn.dataset.tab) {
      activateTab(btn.dataset.tab);
      return;
    }
    try {
      await loadSettings();
      await refreshMetaSelects();
      openModal('modalSettings' + btn.dataset.settings);
    } catch (e) {
      showToast('Не удалось открыть настройки: ' + (e.message || e));
    }
  });
});
document.querySelectorAll('.save-settings-btn').forEach(btn => {
  btn.addEventListener('click', saveAllSettings);
});

// ── Warmup (inter-account dialogues) ──
let selectedWarmupDialogueId = 0;
let warmupActiveJobId = 0;
let warmupUiSelectedJobId = 0;
let warmupLaunchBusy = false;
let warmupFocusActive = false;
const warmupPairCtrlBusy = new Set();
let warmupPollTimer = null;
let warmupTickTimer = null;
let warmupJobsPollBusy = false;
let statsPollBusy = false;
const warmupSelectedIds = new Set();
const warmupCheckedJobIds = new Set();
let warmupAccountsCache = [];
const warmupExpandedPairLogs = new Set();
window.__warmupLogCache = {};
const WARMUP_SETTINGS_KEY = 'warmupDefaultSettings';
const WARMUP_DIALOGUE_KEY = 'warmupDefaultDialogueId';
const WARMUP_LIST_TAB_KEY = 'warmupListTab';
let warmupListTab = 'jobs';
let warmupControlsInited = false;
let warmupCreatePending = null;
let warmupCreateModalHint = null;

function formatWarmupSpan(sec) {
  const s = Number(sec) || 0;
  if (s < 60) return `${s} сек`;
  if (s < 3600) return `${Math.round(s / 60)} мин`;
  if (s < 86400) return `${(s / 3600).toFixed(1)} ч`;
  return `${Math.round(s / 86400)} дн`;
}

function formatDialogueSpan(sec) {
  const s = Number(sec) || 0;
  if (s < 86400 * 2) return formatWarmupSpan(s);
  if (s < 86400 * 45) return `${Math.round(s / 86400)} дн`;
  if (s < 86400 * 365 * 2) return `${(s / (86400 * 30)).toFixed(1)} мес`;
  return `${(s / (86400 * 365)).toFixed(1)} г`;
}

function formatWarmupJobName(name) {
  let raw = String(name || '').trim();
  if (!raw) return 'Задача';
  raw = raw.replace(
    /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/,
    (_, y, mo, d, h, mi) => `${d}.${mo} ${h}:${mi}`
  );
  raw = raw.replace(/^Прогрев\s+Прогрев\s+/i, 'Прогрев ');
  return raw;
}

function formatDialogueTitle(d) {
  const t = (d.title || '').trim();
  if (!t || /^(exported data|export data|диалог)$/i.test(t)) {
    return `${d.author_a || '?'} ↔ ${d.author_b || '?'}`;
  }
  return t;
}

function defaultWarmupJobName() {
  const now = new Date();
  const dd = String(now.getDate()).padStart(2, '0');
  const mm = String(now.getMonth() + 1).padStart(2, '0');
  const hh = String(now.getHours()).padStart(2, '0');
  const mi = String(now.getMinutes()).padStart(2, '0');
  return `Прогрев ${dd}.${mm} ${hh}:${mi}`;
}

function suggestWarmupJobName(accounts) {
  const projects = [...new Set(
    (accounts || []).map(a => String(a?.project_name || '').trim()).filter(Boolean)
  )];
  const defaultName = defaultWarmupJobName();
  if (projects.length === 1) {
    return { defaultName, singleProject: projects[0] };
  }
  return { defaultName, singleProject: '' };
}

function warmupJobNameForEdit(rawName) {
  const raw = String(rawName || '').trim();
  return raw || 'Задача';
}

function warmupDialogueAuthorLabel(dlg, authorKey) {
  const k = String(authorKey || '').trim().toLowerCase();
  if (k === 'a' || k === 'author_a') return dlg.author_a || 'A';
  if (k === 'b' || k === 'author_b') return dlg.author_b || 'B';
  return authorKey || '?';
}

function formatLogTime(iso) {
  if (!iso) return '—';
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '—';
  return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

function warmupAccountByPhone(phone) {
  const p = String(phone || '').trim();
  return warmupAccountsCache.find(x => String(x.phone || '').trim() === p)
    || (allAccounts || []).find(x => String(x.phone || '').trim() === p)
    || null;
}

function warmupPhoneFull(phone) {
  const a = warmupAccountByPhone(phone);
  const raw = String((a && a.phone) || phone || '').trim();
  if (!raw) return '?';
  return raw.startsWith('+') ? raw : `+${raw.replace(/^\+/, '')}`;
}

function warmupShortLabel(phone) {
  const a = warmupAccountByPhone(phone);
  if (!a) {
    const raw = String(phone || '').trim();
    return raw.length > 8 ? `…${raw.slice(-4)}` : (raw || '?');
  }
  const uname = (a.username || '').trim().replace(/^@/, '');
  if (uname) return `@${uname}`;
  const name = (
    a.display_name
    || [a.first_name, a.last_name].filter(Boolean).join(' ')
    || ''
  ).trim();
  if (name) return name.length > 18 ? `${name.slice(0, 16)}…` : name;
  const ph = String(a.phone || '').trim();
  return ph.length > 8 ? `…${ph.slice(-4)}` : ph;
}

function warmupPairLabel(phoneA, phoneB) {
  return `${warmupPhoneFull(phoneA)} ↔ ${warmupPhoneFull(phoneB)}`;
}

let warmupLogPollAt = 0;
const WARMUP_LOG_POLL_MS = 12000;

function warmupPollIntervalMs() {
  const jobs = window.__warmupJobsCache?.jobs || [];
  return jobs.some(j => String(j.status || '') === 'running') ? 15000 : 10000;
}

function scheduleWarmupPoll() {
  if (warmupPollTimer) clearInterval(warmupPollTimer);
  warmupPollTimer = setInterval(() => {
    if (!document.getElementById('tab-warmup')?.classList.contains('active')) return;
    loadWarmupJobs({ fullRender: false }).catch(() => {});
  }, warmupPollIntervalMs());
}

function warmupPairLogKey(jobId, pairIndex) {
  return `${Number(jobId)}:${Number(pairIndex)}`;
}

function warmupFocusModeOn() {
  return !!(warmupFocusActive || window.__lastStats?.warmup_focus_mode);
}

function warmupPairStatusClass(st, p = null, j = null) {
  const s = String(st || 'pending');
  if (j && String(j.status || '') === 'paused' && ['running', 'paused', 'pending'].includes(s)) {
    return 'paused';
  }
  if (j && p && warmupPairIsActive(j, p)) {
    return 'running';
  }
  const ru = warmupPairStatusRu(st, p, j);
  if (ru === 'в очереди' || ru === 'ждёт слот') return 'queue';
  if (ru === 'ждёт online' || ru === 'ждёт акк') return 'wait-online';
  return s;
}

function warmupPairStatusRu(st, p = null, j = null) {
  const s = String(st || 'pending');
  if (j && String(j.status || '') === 'paused' && ['running', 'paused', 'pending'].includes(s)) {
    return 'пауза';
  }
  if (j && p && warmupPairIsActive(j, p)) {
    return 'идёт';
  }
  if (s === 'pending' && j && String(j.status || '') === 'running') {
    if (p && warmupPairIsWaitingOnline(p)) return 'ждёт online';
    if (warmupFocusModeOn()) return 'ждёт акк';
    const sent = Number(p?.sent_ok ?? p?.sent_count) || 0;
    if (sent > 0) return 'ждёт слот';
    return 'в очереди';
  }
  const m = {
    running: 'идёт',
    pending: 'ожид',
    paused: 'пауза',
    completed: 'готово',
    stopped: 'стоп',
    error: 'ошибка',
    hold: 'выкл',
  };
  return m[s] || s || '—';
}

function warmupPairStatusTitle(st, p = null, j = null) {
  if (!j || !p) return '';
  if (warmupPairIsActive(j, p)) {
    return 'Пара сейчас отправляет сообщения';
  }
  if (String(j.status || '') !== 'running') return '';
  const par = Number(j.parallel_pairs) || 1;
  const active = Array.isArray(j.active_pair_indices) ? j.active_pair_indices.length : 0;
  if (warmupPairIsWaitingOnline(p)) {
    return 'Ждёт подключения аккаунта — задача connect сама';
  }
  if (String(p.status || '') !== 'pending') return '';
  if (warmupFocusModeOn()) {
    const sent = Number(p?.sent_ok ?? p?.sent_count) || 0;
    if (sent > 0) {
      return `Фокус: уже ${sent} отправок — ждёт connect аккаунта пары`;
    }
    return 'Фокус: без лимита parallel — ждёт online аккаунтов задачи (connect идёт сам)';
  }
  const sent = Number(p?.sent_ok ?? p?.sent_count) || 0;
  if (sent > 0) {
    return `Уже ${sent} отправок — ждёт слот parallel (идут ${active} из ${par})`;
  }
  return `Лимит parallel=${par} (сейчас ${active}). Для всех пар сразу — «Фокус: все пары»`;
}

function humanizeWarmupError(msg) {
  let s = humanizeTelegramError(String(msg || '').trim());
  if (!s) return 'Неизвестная ошибка';
  const low = s.toLowerCase();
  if (low.includes('офлайн') || low.includes('offline')) {
    return 'Аккаунт offline — задача подключает его сама, подожди…';
  }
  if (low.includes('подключ') && low.includes('акк')) {
    return s;
  }
  if (low.includes('proxy') || low.includes('прокси') || low.includes('socks')) {
    return 'Прокси недоступен — проверь баланс и настройки прокси у аккаунта';
  }
  if (low.includes('спамблок') || low.includes('spamblock') || low.includes('peer_flood') || low.includes('flood')) {
    return 'Ограничение Telegram (flood) — подожди или смени аккаунт';
  }
  if (low.includes('отдых') || low.includes('resting')) {
    return 'Аккаунт на отдыхе после лимита отправки';
  }
  if (low.includes('занят') || low.includes('warming') || low.includes('broadcast')) {
    return 'Аккаунт занят другой задачей';
  }
  if (low.includes('перезапуск сервера')) {
    return 'Остановлено при перезапуске — жми «Продолжить»';
  }
  if (low.includes('limited') || low.includes('не для прогрева') || low.includes('нельзя писать первым')) {
    return 'Спамблок: этот аккаунт не может писать первым — ждёт ответа от партнёра';
  }
  if (low.includes('монолог')) {
    return 'Аккаунт с ограничением — нельзя вести диалог в одну сторону';
  }
  if (low.includes('_stop_pair') || low.includes('keyword-only arguments')) {
    return 'Сбой движка — перезапусти start.bat и жми «Обнулить» у задачи';
  }
  if (/^not found$/i.test(s.trim()) || low === 'not found') {
    return 'Сервер без нового API — перезапусти start.bat';
  }
  if (low.includes('статус hold') || low.includes('выключена')) {
    return 'Пара выключена вручную';
  }
  if (low.includes('user is blocked') || low.includes('user_not_mutual') || low.includes('peer_id_invalid')) {
    return 'Нет доступа к собеседнику между аккаунтами';
  }
  if (low.includes('auth_key') || low.includes('session')) {
    return humanizeTelegramError(s);
  }
  return s.length > 180 ? `${s.slice(0, 177)}…` : s;
}

function warmupListJobNumber(jobs, jobId) {
  const list = Array.isArray(jobs) ? jobs : [];
  const idx = list.findIndex(j => Number(j.id) === Number(jobId));
  return idx >= 0 ? idx + 1 : 0;
}

function warmupJobDisplayNum(jobId, jobs) {
  const n = warmupListJobNumber(jobs || window.__warmupJobsCache?.jobs || [], jobId);
  return n > 0 ? n : Number(jobId) || '?';
}

function warmupPairIsWaitingOnline(p) {
  if (p && typeof p.wait_offline === 'boolean') return p.wait_offline;
  const err = String(p?.last_error || '').toLowerCase();
  return err.includes('офлайн') || err.includes('offline');
}

function syncWarmupConnectLoadingBanner(job) {
  if (!job) return;
  const root = document.querySelector(`[data-job-detail="${Number(job.id)}"]`);
  if (!root) return;
  root.querySelectorAll(':scope > .warmup-connect-loading').forEach(el => el.remove());
  const html = warmupPoolConnectBannerHtml(job);
  if (html) root.insertAdjacentHTML('afterbegin', html);
}

function patchWarmupPoolBannerFromStats() {
  syncWarmupConnectLoadingBanner(warmupSelectedJob());
}

function warmupJobInfoNote(raw) {
  const t = String(raw || '').trim();
  if (!t) return '';
  if (/^(Идёт:|Подключение \d+)/.test(t)) return '';
  if (t.includes(' · фокус') && /\bonline\b/i.test(t)) return '';
  if (/; ждут offline/.test(t)) return '';
  return t;
}

function warmupTaskRowMeta(j) {
  const st = j?.status || 'draft';
  const sent = Number(j?.sent_total) || 0;
  if (st === 'running' && sent) return `${sent} отправлено`;
  const pairsN = (j?.pairs || []).length || Math.max(1, Math.floor((j?.account_phones || []).length / 2));
  return `${pairsN} ${pairsN === 1 ? 'пара' : 'пар'}`;
}

function warmupPoolConnectBannerHtml(job) {
  const st = String(job?.status || '');
  if (st !== 'running') return '';
  const waitPairs = (job.pairs || []).filter(p => warmupPairIsWaitingOnline(p)).length;
  if (waitPairs <= 0) return '';
  return '<div class="warmup-connect-loading">Загрузка…</div>';
}

async function warmupConnectJobAccounts(jobId) {
  const jid = Number(jobId);
  if (!jid) return;
  try {
    const res = await j(`/api/warmup/jobs/${jid}/connect`, { method: 'POST' });
    const total = Number(res?.connect_total) || 0;
    const started = Number(res?.connect_started) || 0;
    const skipped = Number(res?.connect_skipped) || 0;
    if (res?.connect_busy) {
      showToast('Подключение уже идёт…', 'info');
    } else if (total === 0) {
      const rev = Number(res?.reconciled_pairs) || 0;
      if (rev > 0) {
        showToast(`Аккаунты уже online — возобновляем ${rev} пар`, 'ok', { duration: 8000 });
      } else {
        showToast('Все аккаунты задачи online', 'ok');
      }
    } else {
      showToast(`Connect задачи: ${started + skipped}/${total}`, 'ok', { duration: 8000 });
    }
    await loadWarmupJobs({ fullRender: false });
  } catch (e) {
    showToast(toastText(e), 'err');
  }
}

function warmupPairIsQueued(j, p) {
  if (warmupFocusModeOn()) return false;
  if (!j || !p) return false;
  if (String(j.status || '') !== 'running') return false;
  if (String(p.status || '') !== 'pending') return false;
  return !warmupPairIsWaitingOnline(p);
}

function warmupPairIsActive(j, p) {
  if (!j || !p) return false;
  if (String(j.status || '') === 'paused') return false;
  const pi = Number(p.pair_index);
  if (String(p.status || '') === 'running') return true;
  return Array.isArray(j.active_pair_indices)
    && j.active_pair_indices.some(x => Number(x) === pi);
}

function renderWarmupLogLines(log, pairIndex = null, pairMeta = null) {
  let rows = Array.isArray(log) ? log : [];
  if (pairIndex !== null && pairIndex !== undefined) {
    rows = rows.filter(row => Number(row.pair_index) === Number(pairIndex));
  }
  if (!rows.length) {
    const err = pairMeta?.last_error;
    if (err) {
      return `<div class="warmup-log-err">⚠ ${esc(humanizeWarmupError(err))}</div>`;
    }
    if (String(pairMeta?.status || '') === 'error') {
      return '<div class="warmup-log-err">⚠ Ошибка пары — детали появятся после попытки отправки</div>';
    }
    return '<div class="warmup-log-empty">Пока нет отправок</div>';
  }
  return rows.map(row => {
    const mark = row.ok ? '✓' : '✗';
    const from = warmupPhoneFull(row.phone_from);
    const delay = row.delay_used ? ` · ${Math.round(row.delay_used)}с` : '';
    const err = row.error ? ` — ${esc(humanizeWarmupError(row.error))}` : '';
    const text = (row.text_preview || '').trim();
    const seq = row.seq ? `#${row.seq} ` : '';
    return `<div class="warmup-log-line${row.ok ? '' : ' err'}">
      <span class="warmup-log-time">${formatLogTime(row.created_at)}</span>
      <span class="warmup-log-text">${mark} ${seq}<span class="warmup-log-dir">${esc(from)}→</span> ${esc(text)}${esc(delay)}${err}</span>
    </div>`;
  }).join('');
}

function warmupJobStatsText(j) {
  const parts = [];
  const sent = Number(j.sent_total) || 0;
  if (sent) parts.push(`${sent} отпр.`);
  const elapsed = ['running', 'paused'].includes(j.status || '') ? warmupElapsedLabel(j.started_at) : '';
  if (elapsed) parts.push(elapsed);
  return parts.join(' · ');
}

function captureWarmupScroll() {
  const jobsScroll = document.getElementById('warmupJobsList');
  const logs = {};
  document.querySelectorAll('.warmup-log-inline[data-pair-log]').forEach(el => {
    const key = el.dataset.pairLog;
    if (!key) return;
    const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
    logs[key] = { top: el.scrollTop, stickBottom: gap < 40 };
  });
  return { jobsTop: jobsScroll?.scrollTop || 0, logs };
}

function restoreWarmupScroll(saved) {
  if (!saved) return;
  const jobsScroll = document.getElementById('warmupJobsList');
  if (jobsScroll) jobsScroll.scrollTop = saved.jobsTop;
  document.querySelectorAll('.warmup-log-inline[data-pair-log]').forEach(el => {
    const key = el.dataset.pairLog;
    const s = key ? saved.logs[key] : null;
    if (!s) return;
    if (s.stickBottom) el.scrollTop = el.scrollHeight;
    else el.scrollTop = s.top;
  });
}

function patchWarmupJobCard(j) {
  const jid = Number(j.id);
  let ok = false;
  const row = document.querySelector(`[data-warmup-pick="${jid}"]`);
  if (row) {
    const meta = row.querySelector('.warmup-task-meta');
    if (meta) meta.textContent = warmupTaskRowMeta(j);
    const stEl = row.querySelector('.warmup-job-st');
    const st = j.status || 'draft';
    if (stEl) {
      stEl.textContent = warmupJobStatusRu(st);
      stEl.className = `warmup-job-st warmup-st-${st}`;
    }
    row.classList.toggle('is-running', st === 'running');
    ok = true;
  }
  if (Number(warmupUiSelectedJobId) === jid) {
    const root = document.querySelector(`[data-job-detail="${jid}"]`);
    if (root) {
      const maxPer = Number(j.max_messages_per_account) || 8;
      const maxPairMsgs = maxPer * 2;
      const statsEl = root.querySelector('.warmup-job-stats');
      if (statsEl) statsEl.textContent = warmupJobStatsText(j);
      (j.pairs || []).forEach((p) => {
        const pi = Number(p.pair_index);
        const card = root.querySelector(`[data-pair-card="${jid}:${pi}"]`);
        if (!card) return;
        const sentOk = Number(p.sent_ok ?? p.sent_count) || 0;
        const sentA = Number(p.sent_per_a) || 0;
        const sentB = Number(p.sent_per_b) || 0;
        const pct = Math.min(100, Math.round((sentOk / Math.max(1, maxPairMsgs)) * 100));
        const bar = card.querySelector('.warmup-pair-bar > span');
        if (bar) bar.style.width = `${pct}%`;
        const prog = card.querySelector('.warmup-pair-prog');
        if (prog) {
          const balance = (sentA || sentB) ? ` (${sentA}+${sentB})` : '';
          prog.textContent = `${sentOk}/${maxPairMsgs}${balance}`;
        }
        const pst = String(p.status || 'pending');
        const live = warmupPairIsActive(j, p);
        const queued = warmupPairIsQueued(j, p);
        const stEl2 = card.querySelector('.warmup-pair-st');
        if (stEl2) {
          const ru = warmupPairStatusRu(pst, p, j);
          stEl2.textContent = ru;
          stEl2.className = `warmup-pair-st warmup-pair-st-${warmupPairStatusClass(pst, p, j)}`;
          stEl2.title = warmupPairStatusTitle(pst, p, j);
        }
        card.classList.toggle('is-active', live);
        card.classList.toggle('is-queued', queued);
        card.classList.toggle('is-error', pst === 'error');
        card.classList.toggle('is-hold', pst === 'hold');
      });
      const stEl = root.querySelector('.warmup-job-st');
      const st = j.status || 'draft';
      if (stEl) {
        stEl.textContent = warmupJobStatusRu(st);
        stEl.className = `warmup-job-st warmup-st-${st}`;
      }
      root.classList.toggle('is-running', st === 'running');
      syncWarmupConnectLoadingBanner(j);
      syncWarmupTaskToolbar(j);
      ok = true;
    }
  }
  return ok;
}

function tickWarmupElapsed() {
  const jobs = window.__warmupJobsCache?.jobs;
  if (!jobs?.length) return;
  jobs.forEach(j => {
    if (!['running', 'paused'].includes(j.status)) return;
    patchWarmupJobCard(j);
  });
}

async function patchWarmupExpandedLogs({ force = false } = {}) {
  if (!warmupExpandedPairLogs.size) return;
  const jobs = window.__warmupJobsCache?.jobs || [];
  const hasLive = jobs.some(j => ['running', 'paused'].includes(String(j.status || '')));
  if (!hasLive && !force) return;
  const now = Date.now();
  if (!force && now - warmupLogPollAt < WARMUP_LOG_POLL_MS) return;
  warmupLogPollAt = now;
  // Перфоманс: поллим только видимые в DOM логи и не больше лимита одновременно —
  // иначе при 50+ раскрытых парах вкладка захлёбывается (51×500 строк каждые 12с).
  const MAX_POLLED_LOGS = 6;
  const POLL_LOG_LIMIT = 80;  // для live-обновления хватает последних строк
  let polled = 0;
  for (const key of warmupExpandedPairLogs) {
    if (polled >= MAX_POLLED_LOGS) break;
    const [jidStr, piStr] = String(key).split(':');
    const jid = Number(jidStr);
    const pairIndex = Number(piStr);
    if (!jid || Number.isNaN(pairIndex)) continue;
    // Пропускаем логи которых нет в DOM (свёрнуты/не на экране)
    const logEl = document.querySelector(`[data-pair-log="${key}"]`);
    if (!logEl) continue;
    // Пропускаем если элемент не виден (offsetParent === null)
    if (logEl.offsetParent === null) continue;
    polled += 1;
    try {
      const job = await j(`/api/warmup/jobs/${jid}?log_limit=${POLL_LOG_LIMIT}&pair_index=${pairIndex}`);
      const newLog = job.log || [];
      const oldLog = window.__warmupLogCache[key] || [];
      window.__warmupLogCache[key] = newLog;
      if (newLog.length === oldLog.length) continue;
      const gap = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight;
      const stickBottom = gap < 40;
      const prevTop = logEl.scrollTop;
      logEl.innerHTML = renderWarmupLogLines(newLog, pairIndex, pairMetaFromCache(jid, pairIndex));
      logEl.scrollTop = stickBottom ? logEl.scrollHeight : prevTop;
    } catch (_) { /* ignore */ }
  }
}

function warmupElapsedLabel(iso) {
  if (!iso) return '';
  const t0 = Date.parse(String(iso));
  if (!Number.isFinite(t0)) return '';
  const sec = Math.max(0, Math.floor((Date.now() - t0) / 1000));
  return formatWarmupSpan(sec);
}

function warmupPhoneLabel(phone) {
  return warmupPhoneFull(phone);
}

function warmupJobStatusRu(st) {
  const m = { running: 'идёт', paused: 'пауза', completed: 'готово', stopped: 'стоп', error: 'ошибка', draft: 'черновик', pending: 'ожидание' };
  return m[st] || st || '—';
}

function pairMetaFromCache(jobId, pairIndex) {
  const job = window.__warmupJobsCache?.jobs?.find(j => Number(j.id) === Number(jobId));
  return (job?.pairs || []).find(p => Number(p.pair_index) === Number(pairIndex)) || null;
}

function warmupPairCanStart(p) {
  const st = String(p?.status || '');
  return ['hold', 'stopped', 'paused', 'error', 'pending'].includes(st);
}

function warmupPairCanHold(p) {
  const st = String(p?.status || '');
  return !['completed', 'hold'].includes(st);
}

function showWarmupLaunchBanner(text) {
  const el = document.getElementById('warmupLaunchBanner');
  if (!el) return;
  if (!text) {
    el.hidden = true;
    el.textContent = '';
    return;
  }
  el.hidden = false;
  el.textContent = text;
}

function setWarmupLaunchUi(busy, label) {
  const btn = document.getElementById('accBulkWarmup');
  if (btn) {
    btn.disabled = busy;
    if (busy) {
      if (!btn.dataset.prevLabel) btn.dataset.prevLabel = btn.textContent;
      btn.textContent = label || '⏳ Запуск…';
      btn.classList.add('is-busy');
    } else {
      if (btn.dataset.prevLabel) {
        btn.textContent = btn.dataset.prevLabel;
        delete btn.dataset.prevLabel;
      }
      btn.classList.remove('is-busy');
    }
  }
  showWarmupLaunchBanner(busy ? (label || '⏳ Создание и запуск задачи прогрева…') : '');
}

async function warmupPairCtrl(jobId, pairIndex, action) {
  const jid = Number(jobId);
  const pi = Number(pairIndex);
  if (!jid || Number.isNaN(pi)) return;
  const busyKey = `${jid}:${pi}:${action}`;
  if (warmupPairCtrlBusy.has(busyKey)) return;
  warmupPairCtrlBusy.add(busyKey);
  const path = action === 'start' ? 'start' : 'stop';
  const btnSel = action === 'start'
    ? `[data-warmup-pair-start="${jid}:${pi}"]`
    : `[data-warmup-pair-stop="${jid}:${pi}"]`;
  const btn = document.querySelector(btnSel);
  const prevTitle = btn?.title;
  if (btn) {
    btn.disabled = true;
    btn.classList.add('is-busy');
    btn.title = action === 'start' ? 'Запуск…' : 'Выключение…';
  }
  try {
    await j(`/api/warmup/jobs/${jid}/pairs/${pi}/${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ exclusive: false }),
    });
    const label = action === 'start' ? 'включена' : 'выключена';
    showToast(`Пара ${pi + 1} ${label}`, 'ok', { duration: 4000 });
    await loadWarmupJobs();
  } catch (e) {
    const msg = toastText(e);
    showToast(msg, 'err', { duration: 12000 });
  } finally {
    warmupPairCtrlBusy.delete(busyKey);
    if (btn) {
      btn.classList.remove('is-busy');
      if (prevTitle) btn.title = prevTitle;
    }
    await loadWarmupJobs();
  }
}

function readWarmupSettingsFromForm() {
  const targetMin = Number(document.getElementById('warmupTargetMin')?.value || 0);
  const useTarget = targetMin > 0;
  return {
    // even — авто-подстройка пауз под выбранную длительность («Время»).
    timing_mode: useTarget ? 'even' : 'delay_range',
    delay_min: Number(document.getElementById('warmupDelayMin')?.value || 35),
    delay_max: Number(document.getElementById('warmupDelayMax')?.value || 180),
    target_duration_sec: useTarget ? targetMin * 60 : 0,
    max_messages_per_account: Number(document.getElementById('warmupMaxMsgs')?.value || 8),
    parallel_pairs: Number(document.getElementById('warmupParallelPairs')?.value || 4),
  };
}

function applyWarmupSettingsToForm(settings) {
  const s = settings || {};
  const modeEl = document.getElementById('warmupTimingMode');
  if (modeEl) modeEl.value = 'delay_range';
  const dmin = document.getElementById('warmupDelayMin');
  if (dmin && s.delay_min) dmin.value = String(s.delay_min);
  const dmax = document.getElementById('warmupDelayMax');
  if (dmax && s.delay_max) dmax.value = String(s.delay_max);
  const tmin = document.getElementById('warmupTargetMin');
  if (tmin && s.target_duration_sec) tmin.value = String(Math.max(5, Math.round(s.target_duration_sec / 60)));
  const maxMsgs = document.getElementById('warmupMaxMsgs');
  if (maxMsgs && s.max_messages_per_account) maxMsgs.value = String(s.max_messages_per_account);
  const parallel = document.getElementById('warmupParallelPairs');
  if (parallel && s.parallel_pairs) parallel.value = String(s.parallel_pairs);
  syncWarmupControlsFromForm();
}

function saveWarmupSettingsFromForm() {
  try {
    localStorage.setItem(WARMUP_SETTINGS_KEY, JSON.stringify(readWarmupSettingsFromForm()));
  } catch (_) { /* ignore */ }
}

function getWarmupSettings() {
  try {
    const raw = localStorage.getItem(WARMUP_SETTINGS_KEY);
    if (raw) return { ...readWarmupSettingsFromForm(), ...JSON.parse(raw) };
  } catch (_) { /* ignore */ }
  return readWarmupSettingsFromForm();
}

function saveWarmupDialogueId(id) {
  selectedWarmupDialogueId = Number(id) || 0;
  try {
    if (selectedWarmupDialogueId) localStorage.setItem(WARMUP_DIALOGUE_KEY, String(selectedWarmupDialogueId));
    else localStorage.removeItem(WARMUP_DIALOGUE_KEY);
  } catch (_) { /* ignore */ }
  syncWarmupSettingsUi();
}

function loadWarmupDialogueIdFromStorage() {
  const stored = Number(localStorage.getItem(WARMUP_DIALOGUE_KEY) || 0);
  if (stored) selectedWarmupDialogueId = stored;
}

async function resolveWarmupDialogueId() {
  const list = await j('/api/warmup/dialogues');
  const dlgs = Array.isArray(list) ? list : [];
  if (!dlgs.length) return 0;
  const stored = Number(localStorage.getItem(WARMUP_DIALOGUE_KEY) || selectedWarmupDialogueId) || 0;
  if (stored && dlgs.some(d => Number(d.id) === stored)) return stored;
  const first = Number(dlgs[0].id) || 0;
  saveWarmupDialogueId(first);
  return first;
}

function syncWarmupAccountsCache() {
  warmupAccountsCache = Array.isArray(allAccounts) ? allAccounts : [];
}

function pickWarmupUiJobId(jobs, activeId) {
  const list = Array.isArray(jobs) ? jobs : [];
  if (warmupUiSelectedJobId && list.some(j => Number(j.id) === Number(warmupUiSelectedJobId))) {
    return Number(warmupUiSelectedJobId);
  }
  const aid = Number(activeId) || 0;
  if (aid && list.some(j => Number(j.id) === aid)) return aid;
  const live = list.find(j => ['running', 'paused'].includes(j.status || ''));
  if (live) return Number(live.id);
  return list[0] ? Number(list[0].id) : 0;
}

function selectWarmupJob(jobId) {
  warmupUiSelectedJobId = Number(jobId) || 0;
  const jobs = window.__warmupJobsCache?.jobs || [];
  const job = jobs.find(j => Number(j.id) === warmupUiSelectedJobId) || null;
  renderWarmupJobDetail(job);
  syncWarmupTaskToolbar(job);
  document.querySelectorAll('[data-warmup-pick]').forEach(el => {
    el.classList.toggle('active', Number(el.dataset.warmupPick) === warmupUiSelectedJobId);
  });
}

function warmupAccUsable(a) {
  if (!a) return false;
  if (a.warmup_usable === false) return false;
  if (a.warmup_issue) return false;
  if (a.busy_in === 'warming' || a.in_warmup) return false;
  return true;
}

function warmupAccLabel(a) {
  const star = a.is_premium ? '★ ' : '';
  const name = (
    a.display_name
    || [a.first_name, a.last_name].filter(Boolean).join(' ')
    || a.phone
    || '?'
  ).trim();
  const uname = (a.username || '').trim();
  const base = uname ? `${name} @${uname.replace(/^@/, '')}` : name;
  return `${star}${base}`;
}

function syncWarmupAccSelCount() {
  const el = document.getElementById('warmupAccSelCount');
  if (!el) return;
  const n = warmupSelectedIds.size;
  const pairs = Math.floor(n / 2);
  el.textContent = n ? `${n} выбрано · ${pairs} пар` : '0 выбрано';
  syncWarmupPairsPreview();
}

function getWarmupSelectedAccountsOrdered() {
  return [...warmupSelectedIds]
    .map(id => warmupAccountsCache.find(a => Number(a.id) === Number(id)))
    .filter(Boolean);
}

function syncWarmupPairsPreview() {
  const wrap = document.getElementById('warmupPairsPreview');
  const list = document.getElementById('warmupPairsPreviewList');
  if (!wrap || !list) return;
  const picked = getWarmupSelectedAccountsOrdered();
  if (picked.length < 2) {
    wrap.hidden = true;
    list.innerHTML = '';
    return;
  }
  wrap.hidden = false;
  const chunks = [];
  for (let i = 0; i < picked.length - 1; i += 2) {
    if (i + 1 >= picked.length) break;
    chunks.push([picked[i], picked[i + 1]]);
  }
  if (picked.length % 2 === 1) {
    chunks.push([picked[picked.length - 1], null]);
  }
  list.innerHTML = chunks.map((pair, idx) => {
    const a = warmupAccLabel(pair[0]);
    const b = pair[1] ? warmupAccLabel(pair[1]) : '— без пары';
    return `<div class="warmup-pair-preview">
      <span class="warmup-pair-preview-num">${idx + 1}</span>
      <span class="warmup-pair-preview-names">${esc(a)} <span class="warmup-pair-preview-arrow">↔</span> ${esc(b)}</span>
    </div>`;
  }).join('');
}

function renderWarmupAccounts(list) {
  const el = document.getElementById('warmupAccountsList');
  if (!el) return;
  warmupAccountsCache = Array.isArray(list) ? list : [];
  if (!warmupAccountsCache.length) {
    el.innerHTML = '<div class="empty" style="padding:10px">Нет аккаунтов с сессией</div>';
    syncWarmupAccSelCount();
    return;
  }
  el.innerHTML = warmupAccountsCache.map(a => {
    const id = Number(a.id);
    const checked = warmupSelectedIds.has(id);
    const usable = warmupAccUsable(a);
    const online = a.online || (a.pool_status || '') === 'online';
    const meta = [online ? 'online' : 'offline', a.status_label || a.status || ''].filter(Boolean).join(' · ');
    const issue = (!usable && (a.warmup_issue || a.busy_in)) ? (a.warmup_issue || a.busy_in) : '';
    let pairBadge = '';
    let pairCls = '';
    if (checked) {
      const order = getWarmupSelectedAccountsOrdered();
      const pos = order.findIndex(x => Number(x.id) === id);
      if (pos >= 0) {
        const pairNum = Math.floor(pos / 2) + 1;
        pairBadge = `<span class="warmup-acc-pair-badge">#${pairNum}</span>`;
        pairCls = pos % 2 === 0 ? ' in-pair' : ' in-pair-alt';
      }
    }
    return `<label class="warmup-acc-row${usable ? '' : ' off'}${pairCls}">
      <input type="checkbox" data-warmup-acc="${id}" ${checked ? 'checked' : ''} />
      ${pairBadge}
      <span class="warmup-acc-name">${esc(warmupAccLabel(a))}</span>
      <span class="warmup-acc-meta">${esc(meta)}</span>
      ${issue ? `<span class="warmup-acc-issue" title="${esc(issue)}">${esc(issue)}</span>` : ''}
    </label>`;
  }).join('');
  el.querySelectorAll('[data-warmup-acc]').forEach(cb => {
    cb.addEventListener('change', () => {
      const id = Number(cb.dataset.warmupAcc);
      if (cb.checked) warmupSelectedIds.add(id);
      else warmupSelectedIds.delete(id);
      syncWarmupAccSelCount();
    });
  });
  syncWarmupAccSelCount();
}

async function loadWarmupAccounts() {
  try {
    const data = await j('/api/accounts/for-warmup');
    renderWarmupAccounts(data.accounts || []);
  } catch (e) {
    const el = document.getElementById('warmupAccountsList');
    if (el) el.innerHTML = `<div class="empty" style="padding:10px;color:#f88">${esc(toastText(e))}</div>`;
  }
}

function warmupSelectAccounts(mode) {
  warmupSelectedIds.clear();
  for (const a of warmupAccountsCache) {
    const id = Number(a.id);
    if (!id) continue;
    if (mode === 'all') warmupSelectedIds.add(id);
    else if (mode === 'usable' && warmupAccUsable(a)) warmupSelectedIds.add(id);
  }
  renderWarmupAccounts(warmupAccountsCache);
}

async function warmupConnectSelected(all = false) {
  const ids = all
    ? null
    : [...warmupSelectedIds].map(Number).filter(Boolean);
  if (!all && (!ids || !ids.length)) {
    showToast('Выбери аккаунты или жми «Все аккаунты»', 'warn');
    return;
  }
  showToast(all ? 'Подключение всех аккаунтов…' : `Подключение ${ids.length} акк…`, 'info', { duration: 5000 });
  await runPoolStart(ids);
  await loadWarmupAccounts();
  if (document.getElementById('tab-accounts')?.classList.contains('active')) {
    await loadAccountsTable();
  }
}

async function warmupCreateAndStartFromTab() {
  const ids = [...warmupSelectedIds].map(Number).filter(Boolean);
  if (ids.length < 2) {
    showToast('Выбери минимум 2 аккаунта', 'warn');
    return;
  }
  if (warmupLaunchBusy) {
    showToast('Запуск прогрева уже идёт…', 'info', { duration: 3000 });
    return;
  }
  const dialogueId = Number(selectedWarmupDialogueId) || await resolveWarmupDialogueId();
  if (!dialogueId) {
    showToast('Сначала импортируйте HTML-диалог', 'warn', { duration: 10000 });
    return;
  }
  const picked = ids
    .map(id => warmupAccountsCache.find(a => Number(a.id) === id))
    .filter(Boolean);
  const phones = picked.map(a => a.phone).filter(Boolean);
  saveWarmupSettingsFromForm();
  openWarmupCreateModal(picked, phones, dialogueId);
}

async function createAndStartWarmupJob(accountPhones, dialogueId, name, opts = {}) {
  const largeBatchAck = !!opts.largeBatchAck;
  const settings = getWarmupSettings();
  const body = {
    dialogue_id: dialogueId,
    account_phones: accountPhones,
    ...settings,
    start: true,
    large_batch_ack: largeBatchAck,
  };
  const label = String(name || '').trim();
  if (label) body.name = label;
  const res = await j('/api/warmup/jobs', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  return res;
}

function openWarmupCreateModal(picked, phones, dialogueId) {
  warmupCreatePending = { picked, phones, dialogueId, clearSelection: true };
  const hint = suggestWarmupJobName(picked);
  warmupCreateModalHint = hint;
  const pairs = Math.floor((phones || []).length / 2);
  const sub = document.getElementById('modalWarmupCreateSub');
  const inp = document.getElementById('modalWarmupCreateName');
  const wrap = document.getElementById('modalWarmupCreateProjectWrap');
  const useProj = document.getElementById('modalWarmupCreateUseProject');
  const projHint = document.getElementById('modalWarmupCreateProjectHint');
  if (sub) {
    const orphan = (phones || []).length % 2;
    sub.textContent = `${picked.length} акк. · ${pairs} ${pairs === 1 ? 'пара' : 'пар'}`
      + (orphan ? ` · ${orphan} без пары (пропустится)` : '');
  }
  const syncName = () => {
    if (!inp) return;
    if (useProj?.checked && hint.singleProject) inp.value = hint.singleProject;
    else inp.value = hint.defaultName.replace(/^Прогрев\s+/i, '').trim() || hint.defaultName;
  };
  if (wrap && hint.singleProject) {
    wrap.hidden = false;
    if (projHint) projHint.textContent = `Использовать название проекта: «${hint.singleProject}»`;
    if (useProj) {
      useProj.checked = true;
      useProj.onchange = syncName;
    }
  } else if (wrap) {
    wrap.hidden = true;
    if (useProj) useProj.onchange = null;
  }
  syncName();
  openModal('modalWarmupCreate');
  window.setTimeout(() => {
    inp?.focus();
    inp?.select();
  }, 30);
}

async function confirmWarmupCreateFromModal() {
  const pending = warmupCreatePending;
  if (!pending) return;
  const name = document.getElementById('modalWarmupCreateName')?.value.trim();
  if (!name) {
    showToast('Введите название задачи', 'warn');
    return;
  }
  closeModals();
  warmupCreatePending = null;
  warmupCreateModalHint = null;
  activateTab('warmup');
  await executeWarmupJobLaunch(pending.phones, pending.dialogueId, name, {
    clearSelection: !!pending.clearSelection,
  });
}

async function executeWarmupJobLaunch(phones, dialogueId, name, { clearSelection = false } = {}) {
  if (warmupLaunchBusy) {
    showToast('Запуск прогрева уже идёт…', 'info', { duration: 3000 });
    return;
  }
  warmupLaunchBusy = true;
  setWarmupLaunchUi(true, '⏳ Создание задачи…');
  showToast('Создание задачи прогрева…', 'info', { duration: 8000 });
  try {
    const isLargeBatch = (phones || []).length >= 50;
    let largeBatchAck = false;
    if (isLargeBatch) {
      const msg = warmupFocusModeOn()
        ? 'Запуск 50+ аккаунтов. Подтвердить массовый прогрев?'
        : 'Запуск 50+ аккаунтов: рекомендуется режим «Только прогрев» (акки не будут отвлекаться на другие функции). Продолжить?';
      if (!window.confirm(msg)) {
        showToast('Массовый запуск отменён', 'warn');
        return;
      }
      largeBatchAck = true;
    }
    saveWarmupSettingsFromForm();
    const res = await createAndStartWarmupJob(phones, dialogueId, name, { largeBatchAck });
    setWarmupLaunchUi(true, '⏳ Запуск движка…');
    warmupActiveJobId = Number(res.job_id) || 0;
    warmupUiSelectedJobId = warmupActiveJobId;
    warmupExpandedPairLogs.clear();
    if (clearSelection) clearAccSelection();
    const pairs = Number(res.pairs_count) || Math.floor((res.job?.account_phones?.length || phones.length) / 2);
    const skipped = Array.isArray(res.skipped) ? res.skipped : [];
    await loadWarmupJobs();
    await loadWarmupAccounts();
    if (document.getElementById('tab-accounts')?.classList.contains('active')) {
      await loadAccountsTable();
    }
    const jobTitle = formatWarmupJobName(res.job?.name || name);
    let msg = `${jobTitle}: ${pairs} ${pairs === 1 ? 'пара' : 'пар'}`;
    if (skipped.length) msg += ` · пропущено ${skipped.length}`;
    const reused = !!res.reused_existing;
    const startQueued = !!res.start_queued;
    if (reused) {
      const reusedMsg = res.message || `Задача уже была создана — открыт существующий прогрев #${res.job_id}`;
      showToast(reusedMsg, 'warn', { duration: 10000 });
      if (startQueued) {
        showToast('Запуск задачи поставлен в очередь, пары стартуют по мере готовности аккаунтов', 'info', { duration: 7000 });
      }
    } else {
      showToast(msg, skipped.length ? 'warn' : 'ok', { duration: skipped.length ? 12000 : 6000 });
      if (skipped.length) showToast(skipped.slice(0, 3).join('; '), 'warn', { duration: 14000 });
      if (startQueued) {
        showToast('Задача создана, запуск идёт в фоне — список пар может появиться с небольшой задержкой', 'info', { duration: 7000 });
      }
    }
    if (isLargeBatch && !warmupFocusModeOn() && warmupActiveJobId) {
      try {
        const fr = await j('/api/warmup/focus-mode', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ enabled: true, job_id: warmupActiveJobId }),
        });
        warmupFocusActive = !!fr?.enabled;
        showToast('Для массового запуска включён режим «Только прогрев»', 'warn', { duration: 9000 });
      } catch (focusErr) {
        showToast(
          `Не удалось автоматически включить «Только прогрев»: ${toastText(focusErr)}`,
          'warn',
          { duration: 14000 },
        );
      }
    }
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 12000 });
    await loadWarmupJobs();
  } finally {
    warmupLaunchBusy = false;
    setWarmupLaunchUi(false);
  }
}

async function openWarmupFromAccounts() {
  const ids = getSelectedAccountIds().map(Number).filter(Boolean);
  if (ids.length < 2) {
    showToast('Для прогрева выберите минимум 2 аккаунта', 'warn');
    return;
  }
  if (warmupLaunchBusy) {
    showToast('Запуск прогрева уже идёт…', 'info', { duration: 3000 });
    activateTab('warmup');
    return;
  }
  try {
    const picked = getSelectedAccounts();
    if (picked.length !== ids.length) {
      showToast(
        'Не все выбранные аккаунты в памяти — переключите проекты или снимите лишние галочки (×)',
        'warn',
        { duration: 12000 },
      );
      return;
    }
    if (picked.length < 2 || picked.length % 2 !== 0) {
      showToast('Для прогрева нужно чётное число аккаунтов (2, 4, 6…)', 'warn');
      return;
    }
    if (!picked.length) {
      showToast('Не найдены выбранные аккаунты — обновите таблицу', 'err');
      return;
    }
    const dialogueId = await resolveWarmupDialogueId();
    if (!dialogueId) {
      showToast('Сначала импортируйте HTML-диалог на вкладке «Прогрев»', 'warn', { duration: 10000 });
      activateTab('warmup');
      return;
    }
    const phones = picked.map(a => a.phone).filter(Boolean);
    const inWarmupSel = picked.filter(a => a.in_warmup);
    if (inWarmupSel.length && inWarmupSel.length < picked.length) {
      showToast(
        `В выборе ${inWarmupSel.length} акк. уже в прогреве, остальные свободны — проверь галочки`,
        'warn',
        { duration: 10000 }
      );
    }
    const jobsData = await j('/api/warmup/jobs').catch(() => null);
    const allJobs = jobsData?.jobs || [];
    const activeJobs = allJobs.filter(j => ['running', 'paused'].includes(String(j.status || '')));
    if (activeJobs.length) {
      const busySet = warmupActiveBusyPhones(activeJobs);
      const overlap = phones.filter(ph => busySet.has(String(ph)));
      const busyNames = activeJobs
        .slice(0, 3)
        .map(j => formatWarmupJobName(j.name || j.dialogue_title || `#${j.id}`))
        .join(', ');
      const moreJobs = activeJobs.length > 3 ? ` +${activeJobs.length - 3}` : '';
      if (overlap.length === phones.length && overlap.length >= 2) {
        showToast(
          `Все выбранные аккаунты уже в активных задачах (${busyNames}${moreJobs}). Открой «Прогрев» или останови задачу.`,
          'warn',
          { duration: 14000 }
        );
        activateTab('warmup');
        warmupUiSelectedJobId = Number(activeJobs[0].id);
        warmupActiveJobId = Number(jobsData?.active_job_id) || Number(activeJobs[0].id);
        await loadWarmupJobs();
        return;
      }
      if (overlap.length > 0 && overlap.length < phones.length) {
        showToast(
          `${overlap.length} из ${phones.length} уже в активных задачах (${busyNames}${moreJobs}) — снимите их из выбора`,
          'warn',
          { duration: 14000 }
        );
        return;
      }
    }
    openWarmupCreateModal(picked, phones, dialogueId);
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 12000 });
  }
}

async function renameWarmupJob(jobId, currentName) {
  const jid = Number(jobId);
  if (!jid) return;
  const next = prompt('Название задачи:', warmupJobNameForEdit(currentName));
  if (next === null) return;
  const trimmed = next.trim();
  if (!trimmed) {
    showToast('Пустое название', 'warn');
    return;
  }
  await j(`/api/warmup/jobs/${jid}/rename`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: trimmed }),
  });
  await loadWarmupJobs();
  showToast('Название задачи обновлено', 'ok');
}

async function renameWarmupDialogue(dialogueId, currentTitle, dlgMeta) {
  const id = Number(dialogueId);
  if (!id) return;
  const shown = dlgMeta ? formatDialogueTitle(dlgMeta) : String(currentTitle || '').trim();
  const next = prompt('Название диалога:', shown);
  if (next === null) return;
  const trimmed = next.trim();
  if (!trimmed) {
    showToast('Пустое название', 'warn');
    return;
  }
  await j(`/api/warmup/dialogues/${id}/rename`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: trimmed }),
  });
  await loadWarmupDialogues();
  showToast('Название диалога обновлено', 'ok');
}

async function openWarmupDialoguePreview(dialogueId) {
  const id = Number(dialogueId);
  if (!id) return;
  const titleEl = document.getElementById('modalWarmupDlgPreviewTitle');
  const metaEl = document.getElementById('modalWarmupDlgPreviewMeta');
  const listEl = document.getElementById('modalWarmupDlgPreviewList');
  if (listEl) listEl.innerHTML = '<div class="warmup-dlg-preview-empty">Загрузка…</div>';
  openModal('modalWarmupDialoguePreview');
  try {
    const dlg = await j(`/api/warmup/dialogues/${id}?preview=100`);
    const title = formatDialogueTitle(dlg);
    if (titleEl) titleEl.textContent = title;
    if (metaEl) {
      const parts = [`${dlg.msg_count || (dlg.messages || []).length} сообщ.`];
      if (dlg.source_filename) parts.push(dlg.source_filename);
      if (dlg.span_seconds) parts.push(formatDialogueSpan(dlg.span_seconds));
      metaEl.textContent = parts.join(' · ');
    }
    const msgs = dlg.messages || dlg.preview || [];
    if (!listEl) return;
    if (!msgs.length) {
      listEl.innerHTML = '<div class="warmup-dlg-preview-empty">Нет сообщений</div>';
      return;
    }
    listEl.innerHTML = msgs.map(m => {
      const author = esc(warmupDialogueAuthorLabel(dlg, m.author_key));
      const text = esc(String(m.text || '').trim() || '—');
      return `<div class="warmup-dlg-preview-msg"><div class="warmup-dlg-preview-author">${author}</div><div class="warmup-dlg-preview-text">${text}</div></div>`;
    }).join('');
  } catch (e) {
    if (listEl) listEl.innerHTML = `<div class="warmup-dlg-preview-empty">${esc(toastText(e))}</div>`;
  }
}

function bindWarmupRenameActions(root) {
  if (!root) return;
  root.querySelectorAll('[data-rename-job]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.preventDefault();
      e.stopPropagation();
      const jid = Number(btn.dataset.renameJob);
      const job = (window.__warmupJobsCache?.jobs || []).find(j => Number(j.id) === jid);
      renameWarmupJob(jid, job?.name || '').catch(err => showToast(toastText(err), 'err'));
    });
  });
  root.querySelectorAll('[data-rename-dialogue]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.preventDefault();
      e.stopPropagation();
      const id = Number(btn.dataset.renameDialogue);
      const meta = (window.__warmupDialoguesCache || []).find(d => Number(d.id) === id) || null;
      renameWarmupDialogue(id, meta?.title || '', meta).catch(err => showToast(toastText(err), 'err'));
    });
  });
  root.querySelectorAll('[data-preview-dialogue]').forEach(btn => {
    btn.addEventListener('click', e => {
      e.preventDefault();
      e.stopPropagation();
      openWarmupDialoguePreview(btn.dataset.previewDialogue).catch(err => showToast(toastText(err), 'err'));
    });
  });
}

function syncWarmupRangeUi() {
  const minEl = document.getElementById('warmupDelayMin');
  const maxEl = document.getElementById('warmupDelayMax');
  const minRange = document.getElementById('warmupDelayMinRange');
  const maxRange = document.getElementById('warmupDelayMaxRange');
  const fill = document.getElementById('warmupRangeFill');
  const label = document.getElementById('warmupDelayLabel');
  if (!minEl || !maxEl || !minRange || !maxRange) return;
  let min = Number(minEl.value) || 30;
  let max = Number(maxEl.value) || 180;
  if (min > max) [min, max] = [max, min];
  min = Math.max(30, Math.min(300, min));
  max = Math.max(30, Math.min(300, max));
  minEl.value = String(min);
  maxEl.value = String(max);
  minRange.value = String(min);
  maxRange.value = String(max);
  const span = 300 - 30;
  if (fill) {
    fill.style.left = `${((min - 30) / span) * 100}%`;
    fill.style.width = `${((max - min) / span) * 100}%`;
  }
  if (label) label.textContent = `${min}–${max} сек`;
}

function syncWarmupChipGroup(containerId, attr, value, hiddenEl) {
  const box = document.getElementById(containerId);
  if (!box) return;
  box.querySelectorAll('.warmup-chip').forEach(btn => {
    btn.classList.toggle('active', String(btn.dataset[attr]) === String(value));
  });
  if (hiddenEl) hiddenEl.value = String(value);
}

function syncWarmupControlsFromForm() {
  const s = readWarmupSettingsFromForm();
  const modeEl = document.getElementById('warmupTimingMode');
  if (modeEl) modeEl.value = 'delay_range';
  syncWarmupRangeUi();
  syncWarmupChipGroup('warmupMaxMsgsChips', 'msgs', s.max_messages_per_account, document.getElementById('warmupMaxMsgs'));
  syncWarmupChipGroup('warmupParallelChips', 'parallel', s.parallel_pairs, document.getElementById('warmupParallelPairs'));
  const targetMin = Math.max(5, Math.round((Number(s.target_duration_sec) || 1200) / 60));
  const targetEl = document.getElementById('warmupTargetMin');
  if (targetEl) targetEl.value = String(targetMin);
  syncWarmupChipGroup('warmupTargetChips', 'target', targetMin, null);
  syncWarmupSettingsUi();
}

function setWarmupListTab(tab) {
  warmupListTab = tab === 'dialogues' ? 'dialogues' : 'jobs';
  try { localStorage.setItem(WARMUP_LIST_TAB_KEY, warmupListTab); } catch (_) {}
  document.getElementById('warmupListTabJobs')?.classList.toggle('active', warmupListTab === 'jobs');
  document.getElementById('warmupListTabDialogues')?.classList.toggle('active', warmupListTab === 'dialogues');
  const jobsEl = document.getElementById('warmupJobsList');
  const dlgEl = document.getElementById('warmupDialogueList');
  const importBtn = document.querySelector('.warmup-import-btn--toolbar');
  if (jobsEl) jobsEl.hidden = warmupListTab !== 'jobs';
  if (dlgEl) dlgEl.hidden = warmupListTab !== 'dialogues';
  if (importBtn) importBtn.hidden = warmupListTab !== 'dialogues';
}

function initWarmupListTab() {
  let saved = 'jobs';
  try { saved = localStorage.getItem(WARMUP_LIST_TAB_KEY) || 'jobs'; } catch (_) {}
  setWarmupListTab(saved === 'dialogues' ? 'dialogues' : 'jobs');
}

async function warmupBatchSecondaryAction(kind) {
  const ids = warmupControlJobIds();
  if (!ids.length) {
    showToast('Сначала выбери задачу в списке или отметь галочками', 'warn');
    return;
  }
  if (ids.length === 1) {
    const jid = ids[0];
    if (kind === 'relaunch') return relaunchWarmupJob(jid);
    if (kind === 'clear') return resetWarmupJob(jid, { start: false });
    if (kind === 'delete') return deleteWarmupJob(jid);
    return;
  }
  const jobs = warmupJobsByIds(ids);
  if (kind === 'relaunch') {
    const targets = jobs.filter(j => String(j.status || 'draft') !== 'draft');
    if (!targets.length) {
      showToast('Нет задач для перезапуска', 'warn');
      return;
    }
    for (const job of targets) {
      await relaunchWarmupJob(Number(job.id));
    }
    return;
  }
  if (kind === 'clear') {
    const targets = jobs.filter(j => !['running', 'paused'].includes(String(j.status || '')));
    if (!targets.length) {
      showToast('Нет остановленных задач для сброса', 'warn');
      return;
    }
    if (!confirm(`Сбросить данные у ${targets.length} задач?\n\nСчётчики, лог и прогресс пар обнулятся.`)) return;
    for (const job of targets) {
      await resetWarmupJob(Number(job.id), { start: false, skipConfirm: true, skipReload: true });
    }
    await loadWarmupJobs();
    showToast(`Сброшено задач: ${targets.length}`, 'ok');
    return;
  }
  if (kind === 'delete') {
    const targets = jobs.filter(j => String(j.status || '') !== 'running');
    if (!targets.length) {
      showToast('Running-задачи нельзя удалить — сначала ■ Стоп', 'warn');
      return;
    }
    if (!confirm(`Удалить ${targets.length} задач?\n\nСчётчики, лог и пары будут удалены безвозвратно.`)) return;
    let ok = 0;
    let fail = 0;
    for (const job of targets) {
      try {
        await deleteWarmupJob(Number(job.id), { skipConfirm: true, skipToast: true, skipReload: true });
        ok += 1;
      } catch (_) {
        fail += 1;
      }
    }
    await loadWarmupJobs();
    if (ok) {
      showToast(
        `Удалено задач: ${ok}${fail ? `, ошибок: ${fail}` : ''}`,
        fail ? 'warn' : 'ok',
      );
    } else {
      showToast('Не удалось удалить задачи', 'err');
    }
  }
}

function initWarmupControls() {
  if (warmupControlsInited) return;
  warmupControlsInited = true;

  const onRange = () => {
    const minRange = document.getElementById('warmupDelayMinRange');
    const maxRange = document.getElementById('warmupDelayMaxRange');
    const minEl = document.getElementById('warmupDelayMin');
    const maxEl = document.getElementById('warmupDelayMax');
    if (!minRange || !maxRange || !minEl || !maxEl) return;
    let min = Number(minRange.value) || 30;
    let max = Number(maxRange.value) || 180;
    if (min > max) {
      if (document.activeElement === minRange) max = min;
      else min = max;
    }
    minEl.value = String(min);
    maxEl.value = String(max);
    syncWarmupRangeUi();
    saveWarmupSettingsFromForm();
    syncWarmupApplyParallelBtn(window.__warmupJobsCache?.jobs || []);
  };
  document.getElementById('warmupDelayMinRange')?.addEventListener('input', onRange);
  document.getElementById('warmupDelayMaxRange')?.addEventListener('input', onRange);

  document.getElementById('warmupMaxMsgsChips')?.addEventListener('click', e => {
    const btn = e.target.closest('[data-msgs]');
    if (!btn) return;
    const val = Number(btn.dataset.msgs) || 8;
    const sel = document.getElementById('warmupMaxMsgs');
    if (sel) sel.value = String(val);
    syncWarmupChipGroup('warmupMaxMsgsChips', 'msgs', val, sel);
    saveWarmupSettingsFromForm();
  });
  document.getElementById('warmupParallelChips')?.addEventListener('click', e => {
    const btn = e.target.closest('[data-parallel]');
    if (!btn) return;
    const val = Number(btn.dataset.parallel) || 4;
    const sel = document.getElementById('warmupParallelPairs');
    if (sel) sel.value = String(val);
    syncWarmupChipGroup('warmupParallelChips', 'parallel', val, sel);
    saveWarmupSettingsFromForm();
    syncWarmupApplyParallelBtn(window.__warmupJobsCache?.jobs || []);
  });
  document.getElementById('warmupTargetChips')?.addEventListener('click', e => {
    const btn = e.target.closest('[data-target]');
    if (!btn) return;
    const val = Number(btn.dataset.target) || 20;
    const inp = document.getElementById('warmupTargetMin');
    if (inp) inp.value = String(val);
    syncWarmupChipGroup('warmupTargetChips', 'target', val, null);
    saveWarmupSettingsFromForm();
  });

  document.getElementById('warmupBtnStart')?.addEventListener('click', () => {
    const btn = warmupToolbarBtn('warmupBtnStart');
    warmupCtrlSelectedJob(btn?.dataset.action || 'start');
  });
  document.getElementById('warmupBtnPause')?.addEventListener('click', () => warmupCtrlSelectedJob('pause'));
  document.getElementById('warmupBtnStop')?.addEventListener('click', () => warmupCtrlSelectedJob('stop'));
  document.getElementById('warmupBtnRelaunch')?.addEventListener('click', () => {
    warmupBatchSecondaryAction('relaunch').catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
  });
  document.getElementById('warmupBtnClear')?.addEventListener('click', () => {
    warmupBatchSecondaryAction('clear').catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
  });
  document.getElementById('warmupBtnDelete')?.addEventListener('click', () => {
    warmupBatchSecondaryAction('delete').catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
  });
  document.getElementById('warmupBtnFocus')?.addEventListener('click', () => warmupSetFocusMode(true));
  document.getElementById('warmupBtnNormal')?.addEventListener('click', () => warmupSetFocusMode(false));
  document.getElementById('warmupBtnFocusApply')?.addEventListener('click', () => warmupReapplyFocus());
  document.getElementById('warmupListTabJobs')?.addEventListener('click', () => setWarmupListTab('jobs'));
  document.getElementById('warmupListTabDialogues')?.addEventListener('click', () => {
    setWarmupListTab('dialogues');
    loadWarmupDialogues().catch(() => {});
  });
  initWarmupListTab();
}

function syncWarmupSettingsUi() {
  const delayRow = document.getElementById('warmupDelayRow');
  if (delayRow) delayRow.hidden = false;
  const hint = document.getElementById('warmupDefaultDialogueHint');
  if (hint) hint.textContent = '';
}

function renderWarmupDialogues(list) {
  const el = document.getElementById('warmupDialogueList');
  if (!el) return;
  if (!list.length) {
    el.innerHTML = '<div class="warmup-empty">Импортируй HTML из Telegram Desktop</div>';
    return;
  }
  el.innerHTML = list.map(d => {
    const active = Number(d.id) === Number(selectedWarmupDialogueId);
    const title = formatDialogueTitle(d);
    return `<label class="warmup-dialogue-item${active ? ' active' : ''}">
      <input type="radio" name="warmupDialogue" value="${d.id}"${active ? ' checked' : ''} />
      <div class="warmup-dialogue-body">
        <div class="warmup-dialogue-title" title="${esc(title)}">${esc(title)}</div>
        <div class="warmup-dialogue-meta">${d.msg_count} сообщ.${d.span_seconds ? ` · ${formatDialogueSpan(d.span_seconds)}` : ''}</div>
      </div>
      <div class="warmup-row-actions">
        <button type="button" class="warmup-icon-btn" data-preview-dialogue="${d.id}" title="Просмотр текста">👁</button>
        <button type="button" class="warmup-icon-btn" data-rename-dialogue="${d.id}" title="Переименовать">✎</button>
        <button type="button" class="warmup-icon-btn warmup-icon-btn--danger" data-del-dialogue="${d.id}" title="Удалить">×</button>
      </div>
    </label>`;
  }).join('');
  el.querySelectorAll('input[name="warmupDialogue"]').forEach(r => {
    r.addEventListener('change', () => {
      saveWarmupDialogueId(Number(r.value) || 0);
      el.querySelectorAll('.warmup-dialogue-item').forEach(l => l.classList.remove('active'));
      r.closest('.warmup-dialogue-item')?.classList.add('active');
    });
  });
  el.querySelectorAll('[data-del-dialogue]').forEach(btn => {
    btn.addEventListener('click', async e => {
      e.preventDefault();
      e.stopPropagation();
      const id = Number(btn.dataset.delDialogue);
      if (!confirm('Удалить диалог из базы?')) return;
      await j(`/api/warmup/dialogues/${id}`, { method: 'DELETE' });
      if (selectedWarmupDialogueId === id) saveWarmupDialogueId(0);
      await loadWarmupDialogues();
    });
  });
  bindWarmupRenameActions(el);
}

async function loadWarmupDialogues() {
  loadWarmupDialogueIdFromStorage();
  const list = await j('/api/warmup/dialogues');
  const dlgs = Array.isArray(list) ? list : [];
  window.__warmupDialoguesCache = dlgs;
  if (!selectedWarmupDialogueId && dlgs.length) saveWarmupDialogueId(dlgs[0].id);
  renderWarmupDialogues(dlgs);
}

function renderWarmupJobPairCard(j, p, maxPairMsgs) {
  const jid = Number(j.id);
  const pi = Number(p.pair_index);
  const key = warmupPairLogKey(jid, pi);
  const sentOk = Number(p.sent_ok ?? p.sent_count) || 0;
  const sentA = Number(p.sent_per_a) || 0;
  const sentB = Number(p.sent_per_b) || 0;
  const pct = Math.min(100, Math.round((sentOk / Math.max(1, maxPairMsgs)) * 100));
  const pst = String(p.status || 'pending');
  const logOpen = warmupExpandedPairLogs.has(key);
  const logCache = window.__warmupLogCache[key] || [];
  const balanceHint = (sentA || sentB) ? ` <span class="warmup-pair-balance" title="отправок A / B">${sentA}+${sentB}</span>` : '';
  const logHtml = logOpen
    ? `<div class="warmup-log-inline" data-pair-log="${esc(key)}">${renderWarmupLogLines(logCache, pi, p)}</div>`
    : '';
  const la = esc(warmupPhoneFull(p.phone_a));
  const lb = esc(warmupPhoneFull(p.phone_b));
  const canStart = warmupPairCanStart(p);
  const canHold = warmupPairCanHold(p);
  const pairWaiting = pst === 'pending' && warmupPairIsWaitingOnline(p);
  const pairErr = p.last_error && (pst === 'error' || pairWaiting)
    ? `<div class="warmup-pair-err${pairWaiting ? ' warmup-pair-err--wait' : ''}">${esc(humanizeWarmupError(p.last_error))}</div>`
    : '';
  const live = warmupPairIsActive(j, p);
  const queued = warmupPairIsQueued(j, p);
  return `<div class="warmup-pair-card${live ? ' is-active' : ''}${queued ? ' is-queued' : ''}${pairWaiting ? ' is-wait-online' : ''}${pst === 'error' ? ' is-error' : ''}${pst === 'hold' ? ' is-hold' : ''}" data-pair-card="${jid}:${pi}" style="--pair-i:${pi}">
    <div class="warmup-pair-head">
      <span class="warmup-pair-num">${pi + 1}</span>
      <span class="warmup-pair-names"><span class="warmup-pair-phone">${la}</span><span class="warmup-pair-arrow">↔</span><span class="warmup-pair-phone">${lb}</span></span>
      <div class="warmup-pair-bar"><span style="width:${pct}%"></span></div>
      <span class="warmup-pair-prog">${sentOk}/${maxPairMsgs}${balanceHint}</span>
      <span class="warmup-pair-st warmup-pair-st-${warmupPairStatusClass(pst, p, j)}" title="${esc(warmupPairStatusTitle(pst, p, j))}">${esc(warmupPairStatusRu(pst, p, j))}</span>
      <div class="warmup-pair-ctrl">
        <button type="button" class="warmup-pair-ctrl-btn" data-warmup-pair-start="${jid}:${pi}" title="Включить пару (остальные не трогаются)" ${canStart ? '' : 'disabled'}>▶</button>
        <button type="button" class="warmup-pair-ctrl-btn is-stop" data-warmup-pair-stop="${jid}:${pi}" title="Выключить пару (остальные не трогаются)" ${canHold ? '' : 'disabled'}>■</button>
        <button type="button" class="warmup-pair-log-btn${logOpen ? ' open' : ''}" data-warmup-pair-log="${esc(key)}" title="Лог отправок пары">${logOpen ? '▾ Лог' : '▸ Лог'}</button>
      </div>
    </div>
    ${pairErr}
    ${logHtml}
  </div>`;
}

function renderWarmupJobDetailHtml(j, listNum = 0) {
  if (!j) return '';
  const st = j.status || 'draft';
  const jid = Number(j.id);
  const pairs = Array.isArray(j.pairs) ? j.pairs : [];
  const maxPer = Number(j.max_messages_per_account) || 8;
  const maxPairMsgs = maxPer * 2;
  const pairsHtml = pairs.length
    ? `<div class="warmup-job-pairs">${pairs.map(p => renderWarmupJobPairCard(j, p, maxPairMsgs)).join('')}</div>`
    : '<div class="empty" style="padding:10px">Нет пар в задаче</div>';
  const errLine = warmupJobInfoNote(j.last_error)
    ? `<div class="warmup-job-err">${esc(humanizeWarmupError(warmupJobInfoNote(j.last_error)))}</div>`
    : '';
  const formPar = Number(readWarmupSettingsFromForm().parallel_pairs) || 1;
  const jobPar = Number(j.parallel_pairs) || 1;
  const parWarn = (['running', 'paused'].includes(st) && formPar !== jobPar)
    ? `<div class="warmup-job-err">В задаче паралл.${jobPar}, в настройках ${formPar} — «↻ parallel»</div>`
    : '';
  const stats = warmupJobStatsText(j);
  const runningStrip = st === 'running' ? '<div class="warmup-job-running-strip"></div>' : '';
  const poolBanner = warmupPoolConnectBannerHtml(j);
  return `<div class="warmup-job-item warmup-job-detail${st === 'running' ? ' is-running' : ''}" data-job-detail="${jid}">
    ${poolBanner}
    ${runningStrip}
    <div class="warmup-job-row1">
      <span class="warmup-job-title">${esc(formatWarmupJobName(j.name || j.dialogue_title || ''))}</span>
      <button type="button" class="warmup-icon-btn" data-rename-job="${jid}" title="Переименовать">✎</button>
      <span class="warmup-job-st warmup-st-${st}">${esc(warmupJobStatusRu(st))}</span>
      <span class="warmup-job-stats">${esc(stats)}</span>
    </div>
    ${pairsHtml}
    ${errLine}
    ${parWarn}
  </div>`;
}

function bindWarmupJobDetailActions(root) {
  if (!root) return;
  bindWarmupRenameActions(root);
  root.querySelectorAll('[data-warmup-job-connect]').forEach(b => {
    b.onclick = (e) => {
      e.stopPropagation();
      warmupConnectJobAccounts(b.dataset.warmupJobConnect);
    };
  });
  root.querySelectorAll('[data-warmup-pair-log]').forEach(b => b.onclick = () => toggleWarmupPairLog(b.dataset.warmupPairLog));
  root.querySelectorAll('[data-warmup-pair-start]').forEach(b => {
    b.onclick = (e) => {
      e.stopPropagation();
      const [jid, pi] = String(b.dataset.warmupPairStart || '').split(':');
      warmupPairCtrl(jid, pi, 'start');
    };
  });
  root.querySelectorAll('[data-warmup-pair-stop]').forEach(b => {
    b.onclick = (e) => {
      e.stopPropagation();
      const [jid, pi] = String(b.dataset.warmupPairStop || '').split(':');
      warmupPairCtrl(jid, pi, 'stop');
    };
  });
}

function warmupToolbarBtn(id) {
  return document.getElementById(id);
}

function syncWarmupTaskToolbar(job) {
  const startBtn = warmupToolbarBtn('warmupBtnStart');
  const pauseBtn = warmupToolbarBtn('warmupBtnPause');
  const stopBtn = warmupToolbarBtn('warmupBtnStop');
  const relaunchBtn = warmupToolbarBtn('warmupBtnRelaunch');
  const clearBtn = warmupToolbarBtn('warmupBtnClear');
  const deleteBtn = warmupToolbarBtn('warmupBtnDelete');
  if (!startBtn) return;

  const controlJobs = warmupJobsByIds(warmupControlJobIds());
  const hasJob = controlJobs.length > 0;
  const multi = controlJobs.length > 1;

  let canStart = false;
  let canTopup = false;
  let canPause = false;
  let canStop = false;
  let canRelaunch = false;
  let canClear = false;
  let canDeleteAny = false;
  let hasRunning = false;

  for (const j of controlJobs) {
    const st = String(j.status || 'draft');
    if (['draft', 'stopped', 'error', 'paused'].includes(st)) canStart = true;
    if (st === 'running') {
      canTopup = true;
      canPause = true;
      hasRunning = true;
    }
    if (['running', 'paused'].includes(st)) canStop = true;
    if (st !== 'draft') canRelaunch = true;
    if (!['running', 'paused'].includes(st)) canClear = true;
    if (st !== 'running') canDeleteAny = true;
  }

  startBtn.disabled = !(canStart || canTopup);
  if (!hasJob) {
    startBtn.textContent = '▶ Запуск';
    startBtn.dataset.action = 'start';
    startBtn.title = 'Запуск/продолжение задачи. Снимает hold со всех пар';
    startBtn.dataset.jobId = '';
  } else if (multi) {
    startBtn.textContent = `▶ Запуск (${controlJobs.length})`;
    startBtn.dataset.action = 'start';
    startBtn.title = 'Запуск, продолжение или дозапуск отмеченных задач';
    startBtn.dataset.jobId = '';
  } else {
    const one = controlJobs[0];
    const st = String(one.status || 'draft');
    const jid = Number(one.id);
    if (st === 'running') {
      startBtn.textContent = '▶ Дозапуск';
      startBtn.dataset.action = 'topup';
      startBtn.title = 'Дозапустить свободные пары до parallel (текущие не трогает, «выкл» остаются)';
    } else if (st === 'paused') {
      startBtn.textContent = '▶ Продолжить';
      startBtn.dataset.action = 'resume';
      startBtn.title = 'Продолжить задачу и дозапустить пары';
    } else {
      startBtn.textContent = '▶ Запуск';
      startBtn.dataset.action = 'start';
      startBtn.title = 'Запуск/продолжение задачи. Снимает hold со всех пар';
    }
    startBtn.dataset.jobId = jid || '';
  }

  if (pauseBtn) {
    pauseBtn.disabled = !canPause;
    pauseBtn.dataset.jobId = multi ? '' : String(controlJobs[0]?.id || '');
  }
  if (stopBtn) {
    stopBtn.disabled = !canStop;
    stopBtn.dataset.jobId = multi ? '' : String(controlJobs[0]?.id || '');
  }
  if (relaunchBtn) {
    relaunchBtn.disabled = !canRelaunch;
    relaunchBtn.dataset.jobId = multi ? '' : String(controlJobs[0]?.id || '');
  }
  if (clearBtn) {
    clearBtn.disabled = !canClear;
    clearBtn.dataset.jobId = multi ? '' : String(controlJobs[0]?.id || '');
  }
  if (deleteBtn) {
    deleteBtn.disabled = !canDeleteAny;
    deleteBtn.dataset.jobId = multi ? '' : String(controlJobs[0]?.id || '');
    deleteBtn.title = hasRunning
      ? (multi ? 'Running-задачи пропустятся — сначала ■ Стоп' : 'Сначала остановите задачу (■ Стоп)')
      : (multi ? 'Удалить отмеченные задачи' : 'Удалить задачу из списка');
  }
  renderWarmupFocusBtn(window.__lastStats || {});
}

function warmupJobPairsCount(job) {
  if (!job) return 0;
  return (job.pairs || []).length
    || Math.max(1, Math.floor((job.account_phones || []).length / 2));
}

function getWarmupEngineRunningCount() {
  const ids = window.__warmupJobsCache?.runningJobIds;
  if (Array.isArray(ids)) return ids.length;
  return (window.__warmupJobsCache?.jobs || []).filter(j =>
    ['running', 'paused'].includes(String(j.status || ''))
  ).length;
}

function renderWarmupTopbar(jobs, stats) {
  const list = jobs || window.__warmupJobsCache?.jobs || [];
  const st = stats || window.__lastStats || {};
  const badge = document.getElementById('warmupEngineBadge');
  const strip = document.getElementById('warmupStatsStrip');
  const detailLbl = document.getElementById('warmupDetailJobLabel');
  const running = list.filter(j => String(j.status || '') === 'running').length;
  const paused = list.filter(j => String(j.status || '') === 'paused').length;
  const total = list.length;
  let pairs = 0;
  list.forEach(j => { pairs += warmupJobPairsCount(j); });
  const focus = !!(st.warmup_focus_mode);

  if (badge) {
    if (focus && running > 0) {
      badge.textContent = `Фокус · ${running} актив.`;
      badge.className = 'warmup-engine-badge warmup-engine-badge--focus';
    } else if (running > 0) {
      badge.textContent = `${running} ${running === 1 ? 'задача' : 'задач'} греется`;
      badge.className = 'warmup-engine-badge warmup-engine-badge--run';
    } else if (paused > 0) {
      badge.textContent = `${paused} на паузе`;
      badge.className = 'warmup-engine-badge warmup-engine-badge--pause';
    } else if (total > 0) {
      badge.textContent = `${total} ${total === 1 ? 'задача' : 'задач'} · idle`;
      badge.className = 'warmup-engine-badge warmup-engine-badge--idle';
    } else {
      badge.textContent = 'Нет задач';
      badge.className = 'warmup-engine-badge warmup-engine-badge--idle';
    }
  }

  if (strip) {
    strip.innerHTML = [
      `<div class="warmup-stat"><span class="warmup-stat-val">${total}</span><span class="warmup-stat-lbl">задач</span></div>`,
      `<div class="warmup-stat warmup-stat--run"><span class="warmup-stat-val">${running}</span><span class="warmup-stat-lbl">активно</span></div>`,
      `<div class="warmup-stat warmup-stat--pause"><span class="warmup-stat-val">${paused}</span><span class="warmup-stat-lbl">пауза</span></div>`,
      `<div class="warmup-stat warmup-stat--pairs"><span class="warmup-stat-val">${pairs}</span><span class="warmup-stat-lbl">пар всего</span></div>`,
    ].join('');
  }

  if (detailLbl) {
    const job = warmupSelectedJob();
    if (!job) {
      detailLbl.textContent = '';
    } else {
      const name = String(job.name || job.title || 'Без названия').trim();
      detailLbl.textContent = `#${warmupJobDisplayNum(job.id)} · ${name}`;
    }
  }
}

function renderWarmupFocusBtn(stats) {
  const focusBtn = document.getElementById('warmupBtnFocus');
  const normalBtn = document.getElementById('warmupBtnNormal');
  if (!focusBtn || !normalBtn) return;
  warmupFocusActive = !!(stats?.warmup_focus_mode);
  const job = warmupSelectedJob();
  const runningCount = getWarmupEngineRunningCount();
  focusBtn.classList.toggle('is-active', warmupFocusActive);
  normalBtn.classList.toggle('is-active', !warmupFocusActive);
  // «Только прогрев» — доступен, если фокус ещё не включён, есть задача и не >1 активной.
  focusBtn.disabled = warmupLaunchBusy || warmupFocusActive || (!job || runningCount > 1);
  // «Обычный» — доступен только когда фокус включён (чтобы было что выключать).
  normalBtn.disabled = warmupLaunchBusy || !warmupFocusActive;
  focusBtn.title = runningCount > 1
    ? 'Фокус недоступен при нескольких активных задачах — остановите лишние'
    : 'Только прогрев: пауза рассылки и фона, все пары задачи параллельно';
  renderWarmupTopbar(window.__warmupJobsCache?.jobs || [], stats);
}

async function warmupRebalancePairs() {
  const jid = Number(warmupUiSelectedJobId || warmupActiveJobId);
  if (!jid) {
    showToast('Сначала выбери задачу прогрева', 'warn');
    return;
  }
  const btn = document.getElementById('warmupBtnRebalance');
  const prev = btn?.textContent;
  try {
    if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
    const res = await j(`/api/warmup/jobs/${jid}/rebalance`, { method: 'POST' });
    const formed = Number(res?.formed_pairs) || 0;
    const left = Number(res?.orphans_left) || 0;
    if (formed) {
      showToast(`Пересобрано пар: ${formed}${left ? `, без пары: ${left} акк.` : ''}`, 'ok', { duration: 9000 });
    } else {
      showToast(left ? `Нечего собрать — ${left} акк. без пары` : 'Нет пар для пересборки (нужны error/hold со спамблоком)', 'warn', { duration: 8000 });
    }
    await loadWarmupJobs();
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
  } finally {
    if (btn) {
      btn.disabled = false;
      if (prev) btn.textContent = prev;
    }
  }
}

async function warmupReapplyFocus() {
  const jid = Number(warmupUiSelectedJobId || warmupActiveJobId);
  if (!jid) {
    showToast('Сначала выбери задачу прогрева', 'warn');
    return;
  }
  const btn = document.getElementById('warmupBtnFocusApply');
  const prev = btn?.textContent;
  try {
    if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
    const res = await j('/api/warmup/focus-mode', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ enabled: true, job_id: jid }),
    });
    const par = Number(res?.parallel_pairs) || 0;
    const workers = Number(res?.workers) || par;
    showToast(`Фокус: ${par} пар параллельно${workers ? ` (${workers} воркеров)` : ''}`, 'ok', { duration: 8000 });
    await loadWarmupJobs();
    await refreshStats();
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
  } finally {
    if (btn) {
      btn.disabled = false;
      if (prev) btn.textContent = prev;
    }
    renderWarmupFocusBtn(window.__lastStats || {});
  }
}

async function warmupSetFocusMode(enable) {
  if (warmupLaunchBusy) return;
  if (enable && warmupFocusActive) return;
  if (!enable && !warmupFocusActive) return;
  const focusBtn = document.getElementById('warmupBtnFocus');
  const normalBtn = document.getElementById('warmupBtnNormal');
  const targetBtn = enable ? focusBtn : normalBtn;
  const prevText = targetBtn?.textContent || '';

  if (enable) {
    const jid = Number(warmupUiSelectedJobId || warmupActiveJobId);
    if (!jid) {
      showToast('Сначала выбери задачу прогрева', 'warn');
      return;
    }
    const runningCount = getWarmupEngineRunningCount();
    if (runningCount > 1) {
      showToast('Останови лишние активные задачи — фокус только для одной', 'warn', { duration: 7000 });
      return;
    }
  }

  // визуальный busy + оптимистичное переключение: кнопка перекрашивается сразу,
  // не дожидаясь loadWarmupJobs/refreshStats (иначе виден лаг ~10с).
  warmupFocusActive = enable;
  if (focusBtn) { focusBtn.disabled = true; focusBtn.classList.toggle('is-active', enable); }
  if (normalBtn) { normalBtn.disabled = true; normalBtn.classList.toggle('is-active', !enable); }
  if (targetBtn) targetBtn.textContent = '⏳…';

  try {
    if (enable) {
      const jid = Number(warmupUiSelectedJobId || warmupActiveJobId);
      const res = await j('/api/warmup/focus-mode', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ enabled: true, job_id: jid }),
      });
      const par = Number(res?.parallel_pairs) || 0;
      showToast(`Только прогрев: ${par || '—'} пар`, 'ok', { duration: 7000 });
      warmupActiveJobId = jid;
      warmupUiSelectedJobId = jid;
    } else {
      await j('/api/warmup/focus-mode', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ enabled: false }),
      });
      showToast('Обычный режим: рассылка и фон восстановлены', 'ok', { duration: 6000 });
    }
    await Promise.all([loadWarmupJobs(), refreshStats()]);
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
  } finally {
    if (targetBtn && prevText) targetBtn.textContent = prevText;
    renderWarmupFocusBtn(window.__lastStats || {});
  }
}

function warmupSelectedJob() {
  const jobs = window.__warmupJobsCache?.jobs || [];
  const jid = Number(warmupUiSelectedJobId);
  return jobs.find(j => Number(j.id) === jid) || null;
}

function warmupJobsByIds(ids) {
  const jobs = window.__warmupJobsCache?.jobs || [];
  return (ids || []).map(id => jobs.find(j => Number(j.id) === Number(id))).filter(Boolean);
}

function warmupControlJobIds() {
  if (warmupCheckedJobIds.size) {
    return [...warmupCheckedJobIds].map(Number).filter(Boolean);
  }
  const jid = Number(warmupUiSelectedJobId);
  return jid ? [jid] : [];
}

function warmupActiveBusyPhones(jobs) {
  const busySet = new Set();
  for (const job of (jobs || [])) {
    if (!['running', 'paused'].includes(String(job.status || ''))) continue;
    for (const p of (job.pairs || [])) {
      const pst = String(p.status || '');
      if (['hold', 'completed', 'error'].includes(pst)) continue;
      if (p.phone_a) busySet.add(String(p.phone_a));
      if (p.phone_b) busySet.add(String(p.phone_b));
    }
  }
  return busySet;
}

function warmupActionForJob(job, action) {
  const st = String(job?.status || 'draft');
  if (action === 'pause' || action === 'stop') return action;
  if (st === 'running') return 'topup';
  if (st === 'paused') return 'resume';
  return 'start';
}

function warmupJobCtrlLabel(jid, action) {
  const jobs = window.__warmupJobsCache?.jobs || [];
  const job = jobs.find(j => Number(j.id) === Number(jid));
  const name = formatWarmupJobName(job?.name || '');
  return name || `#${jid}`;
}

async function warmupCtrlSelectedJob(action) {
  const ids = warmupControlJobIds();
  if (!ids.length) {
    showToast('Сначала выбери задачу в списке или отметь галочками', 'warn');
    return;
  }
  const jobs = warmupJobsByIds(ids);
  const targets = [];
  for (const job of jobs) {
    const jid = Number(job.id);
    const st = String(job.status || 'draft');
    if (action === 'pause') {
      if (st === 'running') targets.push({ jid, act: 'pause' });
    } else if (action === 'stop') {
      if (['running', 'paused'].includes(st)) targets.push({ jid, act: 'stop' });
    } else {
      targets.push({ jid, act: warmupActionForJob(job, action) });
    }
  }
  if (!targets.length) {
    showToast('Нет задач для этого действия', 'warn');
    return;
  }
  if (targets.length === 1) {
    await warmupJobCtrl(targets[0].jid, targets[0].act);
    return;
  }
  const btnMap = {
    start: 'warmupBtnStart', topup: 'warmupBtnStart', resume: 'warmupBtnStart',
    pause: 'warmupBtnPause', stop: 'warmupBtnStop',
  };
  const btnKey = ['pause', 'stop'].includes(action) ? action : targets[0].act;
  const btn = warmupToolbarBtn(btnMap[btnKey] || 'warmupBtnStart');
  const prevText = btn?.textContent;
  let ok = 0;
  let fail = 0;
  try {
    if (btn) {
      btn.disabled = true;
      btn.textContent = '⏳…';
    }
    for (const { jid, act } of targets) {
      try {
        await warmupJobCtrl(jid, act, { quiet: true, skipReload: true });
        ok += 1;
      } catch (_) {
        fail += 1;
      }
    }
    await loadWarmupJobs();
    if (ok) {
      showToast(
        `Готово: ${ok} ${ok === 1 ? 'задача' : 'задач'}${fail ? `, ошибок: ${fail}` : ''}`,
        fail ? 'warn' : 'ok',
      );
    } else {
      showToast('Не удалось выполнить действие', 'err');
    }
  } finally {
    if (btn) {
      btn.disabled = false;
      if (prevText) btn.textContent = prevText;
      syncWarmupTaskToolbar(warmupSelectedJob());
    }
  }
}

function renderWarmupJobList(jobs, selectedId) {
  const el = document.getElementById('warmupJobsList');
  if (!el) return;
  if (!jobs.length) {
    el.innerHTML = '<div class="warmup-empty">Нет задач — выбери аккаунты и нажми «Прогрев»</div>';
    return;
  }
  el.innerHTML = jobs.map((j) => {
    const jid = Number(j.id);
    const st = j.status || 'draft';
    const active = jid === Number(selectedId);
    const checked = warmupCheckedJobIds.has(jid);
    const title = formatWarmupJobName(j.name || j.dialogue_title || '');
    return `<div class="warmup-task-row${active ? ' active' : ''}${checked ? ' is-checked' : ''}${st === 'running' ? ' is-running' : ''}" data-warmup-pick="${jid}" data-job="${jid}">
      <label class="warmup-task-check-wrap" title="Управление кнопками">
        <input type="checkbox" class="warmup-task-check" data-warmup-check="${jid}"${checked ? ' checked' : ''}>
      </label>
      <div class="warmup-task-body">
        <div class="warmup-task-title" title="${esc(title)}">${esc(title)}</div>
        <div class="warmup-task-meta-row">
          <span class="warmup-task-meta">${esc(warmupTaskRowMeta(j))}</span>
          <span class="warmup-job-st warmup-st-${st}">${esc(warmupJobStatusRu(st))}</span>
        </div>
      </div>
      <button type="button" class="warmup-icon-btn" data-rename-job="${jid}" title="Переименовать">✎</button>
    </div>`;
  }).join('');
  el.querySelectorAll('[data-warmup-pick]').forEach(row => {
    row.onclick = () => selectWarmupJob(Number(row.dataset.warmupPick));
  });
  el.querySelectorAll('.warmup-task-check').forEach(cb => {
    cb.addEventListener('click', e => e.stopPropagation());
    cb.addEventListener('change', e => {
      e.stopPropagation();
      const id = Number(cb.dataset.warmupCheck);
      if (cb.checked) warmupCheckedJobIds.add(id);
      else warmupCheckedJobIds.delete(id);
      cb.closest('.warmup-task-row')?.classList.toggle('is-checked', cb.checked);
      syncWarmupTaskToolbar(warmupSelectedJob());
    });
  });
  bindWarmupRenameActions(el);
}

function renderWarmupJobDetail(job) {
  const el = document.getElementById('warmupJobDetail');
  if (!el) return;
  if (!job) {
    el.innerHTML = '<div class="warmup-empty warmup-empty--hero"><div class="warmup-empty-icon" aria-hidden="true">🔥</div><p class="warmup-empty-title">Задача не выбрана</p><p class="warmup-empty-hint">Выбери задачу слева или создай новую во вкладке «Аккаунты»</p></div>';
    syncWarmupTaskToolbar(null);
    renderWarmupTopbar(window.__warmupJobsCache?.jobs || [], window.__lastStats || {});
    return;
  }
  el.innerHTML = renderWarmupJobDetailHtml(job, warmupListJobNumber(window.__warmupJobsCache?.jobs || [], job.id));
  bindWarmupJobDetailActions(el);
  syncWarmupTaskToolbar(job);
}

function renderWarmupJobs(jobs, selectedId, { preserveScroll = false } = {}) {
  const saved = preserveScroll ? captureWarmupScroll() : null;
  warmupUiSelectedJobId = pickWarmupUiJobId(jobs, selectedId);
  renderWarmupJobList(jobs, warmupUiSelectedJobId);
  const job = jobs.find(j => Number(j.id) === Number(warmupUiSelectedJobId)) || null;
  renderWarmupJobDetail(job);
  renderWarmupTopbar(jobs, window.__lastStats || {});
  restoreWarmupScroll(saved);
}

async function toggleWarmupPairLog(key) {
  const raw = String(key || '');
  if (!raw.includes(':')) return;
  const [jidStr, piStr] = raw.split(':');
  const jid = Number(jidStr);
  const pairIndex = Number(piStr);
  if (!jid || Number.isNaN(pairIndex)) return;
  if (warmupExpandedPairLogs.has(raw)) {
    warmupExpandedPairLogs.delete(raw);
    const cache = window.__warmupJobsCache;
    if (cache?.jobs) {
      renderWarmupJobDetail(cache.jobs.find(j => Number(j.id) === warmupUiSelectedJobId) || null);
    }
    return;
  }
  try {
    const job = await j(`/api/warmup/jobs/${jid}?log_limit=500&pair_index=${pairIndex}`);
    window.__warmupLogCache[raw] = job.log || [];
    warmupExpandedPairLogs.add(raw);
    warmupUiSelectedJobId = jid;
    warmupActiveJobId = jid;
    const cache = window.__warmupJobsCache;
    if (cache?.jobs) {
      renderWarmupJobList(cache.jobs, warmupUiSelectedJobId);
      renderWarmupJobDetail(cache.jobs.find(j => Number(j.id) === jid) || null);
    }
  } catch (e) {
    showToast(toastText(e), 'err');
  }
}

async function relaunchWarmupJob(jobId) {
  const jid = Number(jobId);
  if (!jid) return;
  const btn = warmupToolbarBtn('warmupBtnRelaunch');
  const prevText = btn?.textContent;
  try {
    if (btn) {
      btn.disabled = true;
      btn.textContent = '⏳…';
    }
    saveWarmupSettingsFromForm();
    const settings = readWarmupSettingsFromForm();
    await j(`/api/warmup/jobs/${jid}/relaunch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ parallel_pairs: settings.parallel_pairs }),
    });
    warmupActiveJobId = jid;
    warmupUiSelectedJobId = jid;
    showToast(`Прогрев #${jid}: перезапущен`, 'ok');
    await loadWarmupJobs();
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
    await loadWarmupJobs();
  } finally {
    if (btn) {
      btn.disabled = false;
      if (prevText) btn.textContent = prevText;
      syncWarmupTaskToolbar(warmupSelectedJob());
    }
  }
}

async function resetWarmupJob(jobId, { start = true, skipConfirm = false, skipReload = false } = {}) {
  const msg = start
    ? 'Полный сброс и запуск: обнулятся счётчики, лог и прогресс пар, диалог начнётся с новой точки. Продолжить?'
    : 'Обнулить все данные отправки: счётчики, лог и позиции в диалоге. Задача не запустится автоматически. Продолжить?';
  if (!skipConfirm && !confirm(msg)) return;
  const settings = readWarmupSettingsFromForm();
  saveWarmupSettingsFromForm();
  try {
    await j(`/api/warmup/jobs/${jobId}/reset`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        start: Boolean(start),
        parallel_pairs: settings.parallel_pairs,
        max_messages_per_account: settings.max_messages_per_account,
      }),
    });
    warmupActiveJobId = start ? Number(jobId) : 0;
    warmupExpandedPairLogs.clear();
    for (const k of Object.keys(window.__warmupLogCache)) {
      if (k === String(jobId) || String(k).startsWith(`${jobId}:`)) delete window.__warmupLogCache[k];
    }
    showToast(start ? `Прогрев #${jobId}: сброс и запуск` : `Прогрев #${jobId}: данные обнулены`, 'ok');
    if (!skipReload) {
      await loadWarmupJobs();
      await patchWarmupExpandedLogs();
    }
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
    if (!skipReload) await loadWarmupJobs();
  }
}

async function deleteWarmupJob(jobId, { skipConfirm = false, skipToast = false, skipReload = false } = {}) {
  const jobs = window.__warmupJobsCache?.jobs || [];
  const listNum = warmupListJobNumber(jobs, jobId);
  const job = jobs.find(j => Number(j.id) === Number(jobId));
  const title = formatWarmupJobName(job?.name || job?.dialogue_title || '');
  const label = title ? `#${listNum} «${title}»` : `#${listNum}`;
  if (!skipConfirm && !confirm(`Удалить задачу ${label}?\n\nСчётчики, лог и пары будут удалены безвозвратно.`)) return;
  try {
    await j(`/api/warmup/jobs/${jobId}`, { method: 'DELETE' });
    warmupCheckedJobIds.delete(Number(jobId));
    for (const key of [...warmupExpandedPairLogs]) {
      if (String(key).startsWith(`${jobId}:`)) warmupExpandedPairLogs.delete(key);
    }
    for (const k of Object.keys(window.__warmupLogCache)) {
      if (k === String(jobId) || String(k).startsWith(`${jobId}:`)) delete window.__warmupLogCache[k];
    }
    if (warmupActiveJobId === jobId) warmupActiveJobId = 0;
    if (warmupUiSelectedJobId === jobId) warmupUiSelectedJobId = 0;
    if (!skipToast) showToast('Задача удалена', 'ok');
    if (!skipReload) await loadWarmupJobs();
  } catch (e) {
    if (!skipToast) {
      showToast(toastText(e), 'err', { duration: 10000 });
      if (!skipReload) await loadWarmupJobs();
    } else if (!skipReload) {
      await loadWarmupJobs();
    }
    throw e;
  }
}

function warmupPrimaryActiveJob(jobs) {
  const list = jobs || window.__warmupJobsCache?.jobs || [];
  const controlJobs = warmupJobsByIds(warmupControlJobIds());
  const fromControl = controlJobs.find(j => ['running', 'paused'].includes(String(j.status || '')));
  if (fromControl) return fromControl;
  const selected = warmupSelectedJob();
  if (selected && ['running', 'paused'].includes(String(selected.status || ''))) return selected;
  return list.find(j => ['running', 'paused'].includes(String(j.status || ''))) || null;
}

function syncWarmupApplyParallelBtn(jobs) {
  const btn = document.getElementById('warmupApplyParallel');
  if (!btn) return;
  const active = warmupPrimaryActiveJob(jobs);
  if (!active) {
    btn.style.display = 'none';
    return;
  }
  const formPar = Number(readWarmupSettingsFromForm().parallel_pairs) || 1;
  const jobPar = Number(active.parallel_pairs) || 1;
  btn.style.display = formPar !== jobPar ? 'inline-block' : 'none';
}

async function applyWarmupParallel() {
  const jobs = window.__warmupJobsCache?.jobs || [];
  const active = warmupPrimaryActiveJob(jobs);
  if (!active) {
    showToast('Нет активной задачи прогрева', 'warn');
    return;
  }
  const parallel = Number(readWarmupSettingsFromForm().parallel_pairs) || 1;
  const jid = Number(active.id);
  try {
    saveWarmupSettingsFromForm();
    await j(`/api/warmup/jobs/${jid}/relaunch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ parallel_pairs: parallel }),
    });
    warmupActiveJobId = jid;
    showToast(`Прогрев #${warmupJobDisplayNum(jid)}: parallel=${parallel}`, 'ok');
    await loadWarmupJobs();
    if ([...warmupExpandedPairLogs].some(k => String(k).startsWith(`${jid}:`))) await patchWarmupExpandedLogs();
  } catch (e) {
    showToast(toastText(e), 'err', { duration: 10000 });
    await loadWarmupJobs();
  }
}

async function loadWarmupJobs({ fullRender = true } = {}) {
  if (warmupJobsPollBusy) return;
  warmupJobsPollBusy = true;
  try {
    const data = await j('/api/warmup/jobs');
    warmupActiveJobId = Number(data.active_job_id) || warmupActiveJobId;
    const jobs = data.jobs || [];
    for (const id of [...warmupCheckedJobIds]) {
      if (!jobs.some(j => Number(j.id) === id)) warmupCheckedJobIds.delete(id);
    }
    const prevJobs = window.__warmupJobsCache?.jobs || [];
    const statusChanged = jobs.some(j => {
      const prev = prevJobs.find(p => Number(p.id) === Number(j.id));
      return !prev || (prev.status || 'draft') !== (j.status || 'draft');
    });
    const countChanged = jobs.length !== prevJobs.length;
    window.__warmupJobsCache = {
      jobs,
      activeId: warmupActiveJobId,
      runningJobIds: (data.running_job_ids || []).map(Number).filter(Boolean),
    };

    const canPatch = !fullRender && !statusChanged && !countChanged && jobs.every(j => patchWarmupJobCard(j));
    if (canPatch) {
      tickWarmupElapsed();
      await patchWarmupExpandedLogs();
      syncWarmupApplyParallelBtn(jobs);
      scheduleWarmupPoll();
      return;
    }

    renderWarmupJobs(jobs, warmupActiveJobId, { preserveScroll: true });
    await patchWarmupExpandedLogs();
    syncWarmupApplyParallelBtn(jobs);
    scheduleWarmupPoll();
  } finally {
    warmupJobsPollBusy = false;
  }
}

async function warmupJobCtrl(jobId, action, { quiet = false, skipReload = false } = {}) {
  const jid = Number(jobId);
  const btnIdByAction = {
    start: 'warmupBtnStart',
    topup: 'warmupBtnStart',
    resume: 'warmupBtnStart',
    pause: 'warmupBtnPause',
    stop: 'warmupBtnStop',
  };
  const btn = quiet ? null : warmupToolbarBtn(btnIdByAction[action]);
  const prevText = btn?.textContent;
  try {
    if (btn) {
      btn.disabled = true;
      if (action === 'start') btn.textContent = '⏳ Запуск…';
      else if (action === 'topup') btn.textContent = '⏳ Дозапуск…';
      else if (action === 'resume') btn.textContent = '⏳…';
    }
    const opts = { method: 'POST' };
    if (action === 'start' || action === 'topup') {
      opts.headers = { 'Content-Type': 'application/json' };
      opts.body = JSON.stringify({
        parallel_pairs: readWarmupSettingsFromForm().parallel_pairs,
        release_holds: action === 'start',
      });
    } else if (action === 'resume') {
      opts.headers = { 'Content-Type': 'application/json' };
      opts.body = JSON.stringify({ release_holds: true });
    }
    const apiAction = action === 'topup' ? 'start' : action;
    const res = await j(`/api/warmup/jobs/${jid}/${apiAction}`, opts);
    if (!quiet) {
      if (action === 'start' || action === 'resume' || action === 'topup') {
        warmupActiveJobId = jid;
        warmupUiSelectedJobId = jid;
        const run = Number(res?.spawned_pairs ?? res?.runnable_pairs) || 0;
        const wait = Number(res?.skipped_pairs) || 0;
        const on = Number(res?.online_accounts) || 0;
        const conn = Number(res?.connect_pending) || 0;
        const tail = wait ? `, ждут ${wait} пар` : '';
        const connNote = conn ? `, connect ${conn} акк.` : '';
        const disp = warmupJobDisplayNum(jid);
        const verb = action === 'resume' ? 'продолжен' : (action === 'topup' ? 'дозапущен' : 'запущен');
        showToast(
          `Прогрев #${disp} ${verb}: ${run} пар, online ${on}${connNote}${tail}`,
          'ok',
          { duration: 8000 },
        );
      } else if (action === 'pause') {
        showToast(`Прогрев #${warmupJobDisplayNum(jid)} на паузе`, 'ok');
      } else if (action === 'stop') {
        showToast(`Прогрев #${warmupJobDisplayNum(jid)} остановлен`, 'ok');
      }
    } else if (action === 'start' || action === 'resume' || action === 'topup') {
      warmupActiveJobId = jid;
    }
    if (action === 'stop') {
      for (const key of [...warmupExpandedPairLogs]) {
        if (String(key).startsWith(`${jid}:`)) warmupExpandedPairLogs.delete(key);
      }
    }
    if (!skipReload) {
      await loadWarmupJobs();
      if ([...warmupExpandedPairLogs].some(k => String(k).startsWith(`${jid}:`))) await patchWarmupExpandedLogs();
    }
  } catch (e) {
    if (!quiet) showToast(toastText(e), 'err', { duration: 10000 });
    if (!skipReload) await loadWarmupJobs();
    throw e;
  } finally {
    if (!quiet && btn) {
      btn.disabled = false;
      if (prevText) btn.textContent = prevText;
      syncWarmupTaskToolbar(warmupSelectedJob());
    }
  }
}

async function importWarmupHtml(file) {
  const fd = new FormData();
  fd.append('html_file', file);
  const res = await fetch('/api/warmup/dialogues/import', { method: 'POST', body: fd });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.detail || 'Ошибка импорта');
  selectedWarmupDialogueId = Number(data.dialogue_id) || 0;
  saveWarmupDialogueId(selectedWarmupDialogueId);
  showToast(`Импорт: ${data.msg_count} сообщ.`);
  if (data.warnings?.length) showToast(data.warnings.join('; '), 'warn', { duration: 8000 });
  await loadWarmupDialogues();
}

async function loadWarmupTab() {
  initWarmupControls();
  applyWarmupSettingsToForm(getWarmupSettings());
  if (!allAccounts.length) {
    try {
      allAccounts = await j('/api/accounts');
    } catch (_) { /* keep cache empty */ }
  }
  syncWarmupAccountsCache();
  await loadWarmupDialogues();
  await loadWarmupJobs();
  syncWarmupSettingsUi();
  scheduleWarmupPoll();
  if (warmupTickTimer) clearInterval(warmupTickTimer);
  warmupTickTimer = setInterval(() => {
    if (!document.getElementById('tab-warmup')?.classList.contains('active')) return;
    tickWarmupElapsed();
  }, 1000);
}

document.getElementById('modalWarmupCreateGo')?.addEventListener('click', () => {
  confirmWarmupCreateFromModal().catch(e => showToast(toastText(e), 'err', { duration: 10000 }));
});
document.getElementById('modalWarmupCreateName')?.addEventListener('keydown', e => {
  if (e.key !== 'Enter') return;
  e.preventDefault();
  confirmWarmupCreateFromModal().catch(err => showToast(toastText(err), 'err', { duration: 10000 }));
});
document.getElementById('warmupHtmlFile')?.addEventListener('change', async e => {
  const f = e.target.files?.[0];
  e.target.value = '';
  if (!f) return;
  try {
    await importWarmupHtml(f);
  } catch (err) {
    showToast(toastText(err), 'err');
  }
});
document.getElementById('warmupTimingMode')?.addEventListener('change', () => {
  syncWarmupSettingsUi();
  saveWarmupSettingsFromForm();
});
['warmupDelayMin', 'warmupDelayMax', 'warmupTargetMin', 'warmupMaxMsgs', 'warmupParallelPairs'].forEach(id => {
  document.getElementById(id)?.addEventListener('change', () => {
    saveWarmupSettingsFromForm();
    syncWarmupApplyParallelBtn(window.__warmupJobsCache?.jobs || []);
  });
});
document.getElementById('warmupApplyParallel')?.addEventListener('click', () => {
  applyWarmupParallel().catch(() => {});
});
document.getElementById('warmupRefreshJobs')?.addEventListener('click', () => {
  loadWarmupJobs().catch(() => {});
});

async function initApp() {
  try {
    await loadSettings();
    await loadLeadStatuses();
  } catch (_) {
    /* defaults — см. applySettingsToForm при успешной загрузке */
  }
  const threadAll = localStorage.getItem('threadAllMessages') === '1';
  const threadAllEl = document.getElementById('threadAllMessages');
  if (threadAllEl) threadAllEl.checked = threadAll;
  initChatDialogSide();
  initSidebarResize();
  activateTab(getTabFromHash(), { saveHash: false });
  updateBulkBar();
  if ('Notification' in window && Notification.permission === 'default') {
    Notification.requestPermission().catch(() => {});
  }
  notifyReady = 'Notification' in window && Notification.permission === 'granted';
  const pulseTask = j('/api/inbox/pulse', { timeoutMs: 15000 })
    .then(pulse => {
      lastPulse = pulse;
      updateDocumentTitle(pulse.unread_total || 0);
    })
    .catch(() => {});
  const templatesTask = loadTemplates().catch(() => {});
  const metaTask = refreshMetaSelects().catch(() => {});
  await Promise.allSettled([pulseTask, templatesTask, metaTask]);
}

initApp().catch(e => {
  console.error('initApp failed', e);
  showToast('Частичная загрузка UI: ' + (e.message || e));
});

async function runBackgroundPoll() {
  if (document.hidden) return;
  await refreshStats();
  const active = document.querySelector('.tab.active');
  if (active?.id === 'tab-inbox') await pollInboxPulse();
  if (active?.id === 'tab-broadcast' && typeof loadBroadcastJobs === 'function') loadBroadcastJobs();
  if (active?.id === 'tab-parsing' && typeof loadParserJobs === 'function') loadParserJobs();
  if (active?.id === 'tab-clients') maybeRefreshClientsTab();
  if (active?.id === 'tab-accounts') {
    try {
      const st = window.__lastStats;
      if (!st) return;
      applyPoolUiFromStats(st);
      if (isPoolServerConnecting(st) && !accPoolBusySince) accPoolBusySince = Date.now();
      maybeReleaseStalePoolUi(st);
      if (accPoolUi.connecting && !accCheckRunning) {
        const pending = poolConnectPendingCount(allAccounts);
        setAccPoolBanner(true, poolConnectBannerLabel(accPoolUi.online, accPoolUi.total, pending));
        renderAccPoolToggle();
      } else {
        setAccPoolBanner(false);
        renderAccPoolToggle();
        if (hasPoolConnectPending(allAccounts)) {
          if (!accountsReloadThrottled()) scheduleLoadAccountsTable();
          if (!accImportPollTimer) startAccImportPoll();
        }
      }
      if ((st.pool_connecting || hasPoolConnectPending(allAccounts)) && !accPoolPollTimer) {
        startAccPoolPoll();
      } else if (accountsNeedAvatarRefresh(allAccounts)) {
        if (!accountsReloadThrottled()) scheduleLoadAccountsTable();
      } else if (
        (st.warmup_focus_mode || (allAccounts || []).some(a => a.in_warmup))
        && Date.now() - accWarmupListPollAt > 12000
        && !accountsReloadThrottled()
      ) {
        accWarmupListPollAt = Date.now();
        scheduleLoadAccountsTable();
      }
    } catch (_) { /* ignore */ }
  }
}

function scheduleBackgroundPoll(delayMs = STATS_POLL_MS) {
  clearTimeout(statsPollTimer);
  if (document.hidden) return;
  statsPollTimer = setTimeout(async () => {
    try {
      await runBackgroundPoll();
    } catch (_) { /* ignore */ }
    scheduleBackgroundPoll(
      (accPoolBusy || accPoolUi.connecting || accCheckRunning)
        ? STATS_POLL_BUSY_MS
        : STATS_POLL_MS,
    );
  }, delayMs);
}

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    clearTimeout(statsPollTimer);
    statsPollTimer = null;
    stopAccPoolPoll();
    stopThreadReadPoll();
    return;
  }
  scheduleBackgroundPoll(800);
  if (selectedChat) startThreadReadPoll();
});

scheduleBackgroundPoll(3000);


/* ===================== Конвертер сессий ===================== */
let _convReady = false;
let _convSource = 'tdata';
let _convTarget = 'session_pyrogram';
let _convFile = null;
let _convBatchFiles = [];

const _CONV_TARGET_LABELS = {
  session_pyrogram: 'Pyrogram .session',
  session_telethon: 'Telethon .session',
  session_json: 'Session+JSON (zip)',
  json: 'JSON',
  tdata: 'tdata (zip)',
  pyrogram_string: 'Pyrogram string',
  telethon_string: 'Telethon string',
};
const _CONV_BATCH_SOURCES = ['session', 'json', 'pyrogram_string', 'telethon_string'];

function _convSetStatus(html, kind) {
  const el = document.getElementById('convStatus');
  if (!el) return;
  if (!html) { el.hidden = true; el.innerHTML = ''; return; }
  el.hidden = false;
  el.className = 'conv-status' + (kind ? ' conv-status-' + kind : '');
  el.innerHTML = html;
}

function _convUpdateGoBtn() {
  const btn = document.getElementById('convGoBtn');
  if (btn) btn.disabled = !_convFile;
  const bb = document.getElementById('convBatchBtn');
  if (bb) bb.disabled = !_convBatchFiles.length;
}

function _convDownloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename || 'converted';
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 4000);
}

function _convFilenameFromHeaders(resp, fallback) {
  const cd = resp.headers.get('Content-Disposition') || '';
  const m = /filename="?([^"]+)"?/.exec(cd);
  return m ? m[1] : fallback;
}

function _convImportModeOn() {
  return !!document.getElementById('convImportMode')?.checked;
}

function _convPopulateProxyGroups() {
  const sel = document.getElementById('convImportProxyGroup');
  if (sel) {
    const groups = (typeof cachedProxyGroups !== 'undefined' && cachedProxyGroups) ? cachedProxyGroups : [];
    const cur = sel.value;
    sel.innerHTML = '<option value="">Группа прокси по умолчанию</option>' +
      groups.map(g => `<option value="${g.id}">${esc(g.name)} (${g.proxy_total || 0})</option>`).join('');
    if (cur) sel.value = cur;
  }
  const psel = document.getElementById('convImportProject');
  if (psel) {
    const projects = (typeof cachedProjects !== 'undefined' && cachedProjects) ? cachedProjects : [];
    const cur = psel.value;
    psel.innerHTML = '<option value="">— Без проекта —</option>' +
      projects.map(p => `<option value="${p.id}">${esc(p.name)} (${p.account_count || 0})</option>`).join('');
    if (cur) psel.value = cur;
  }
}

function _convRefreshMeta() {
  if (typeof refreshMetaSelects === 'function') {
    refreshMetaSelects().then(_convPopulateProxyGroups).catch(_convPopulateProxyGroups);
  } else {
    _convPopulateProxyGroups();
  }
}

async function _convDoConvert() {
  if (!_convFile) return;
  const btn = document.getElementById('convGoBtn');
  if (btn) { btn.disabled = true; btn.textContent = 'Конвертирую…'; }
  _convSetStatus('Обработка…', 'wait');
  try {
    const fd = new FormData();
    fd.append('file', _convFile);
    fd.append('source_format', _convSource);
    if (_convImportModeOn()) {
      const grp = document.getElementById('convImportProxyGroup')?.value || '';
      if (grp) fd.append('proxy_group_id', grp);
      const proj = document.getElementById('convImportProject')?.value || '';
      if (proj) fd.append('project_id', proj);
      if (document.getElementById('convConnectAfter')?.checked) fd.append('connect_after', 'true');
      const resp = await fetch('/api/converter/import', { method: 'POST', body: fd });
      const data = await resp.json().catch(() => ({}));
      if (data && data.skipped) {
        _convSetStatus('⚠ ' + esc(data.error || 'Аккаунт уже есть в базе'), 'warn');
        showToast(data.error || 'Аккаунт уже есть в базе', 'warn');
        return;
      }
      if (!resp.ok || data.ok === false) {
        throw new Error(data.detail || data.error || ('HTTP ' + resp.status));
      }
      _convSetStatus(
        `✓ Импортирован новый аккаунт <b>${esc(data.phone || '')}</b>` +
        ` · DC${data.dc_id ?? '?'} · user_id ${data.user_id ?? '?'}` +
        (data.connected ? ' · подключён' : '') +
        (data.proxy_failed ? ' · ⚠ прокси не назначен' : ''),
        data.proxy_failed ? 'warn' : 'ok'
      );
      showToast('Аккаунт импортирован: ' + (data.phone || ''), data.proxy_failed ? 'warn' : 'ok');
    } else {
      fd.append('target_format', _convTarget);
      const resp = await fetch('/api/converter/convert', { method: 'POST', body: fd });
      if (!resp.ok) {
        let detail = 'HTTP ' + resp.status;
        try { const j = await resp.json(); detail = j.detail || detail; } catch (_) {}
        throw new Error(detail);
      }
      const blob = await resp.blob();
      const fname = _convFilenameFromHeaders(resp, 'converted');
      _convDownloadBlob(blob, fname);
      _convSetStatus(`✓ Готово: <b>${esc(fname)}</b> (${_CONV_TARGET_LABELS[_convTarget] || _convTarget}) — файл скачан`, 'ok');
      showToast('Сконвертировано: ' + fname, 'ok');
    }
  } catch (e) {
    _convSetStatus('✕ Ошибка: ' + esc(e.message || e), 'err');
    showToast('Ошибка конвертации: ' + (e.message || e), 'err');
  } finally {
    if (btn) { btn.textContent = 'Конвертировать'; btn.disabled = !_convFile; }
  }
}

async function _convDoBatch() {
  if (!_convBatchFiles.length) return;
  if (!_CONV_BATCH_SOURCES.includes(_convSource)) {
    showToast('Пачка только для .session, JSON и string источников', 'warn');
    return;
  }
  const btn = document.getElementById('convBatchBtn');
  if (btn) { btn.disabled = true; btn.textContent = 'Конвертирую…'; }
  _convSetStatus(`Обработка ${_convBatchFiles.length} файлов…`, 'wait');
  try {
    const fd = new FormData();
    _convBatchFiles.forEach(f => fd.append('files', f));
    fd.append('source_format', _convSource);
    fd.append('target_format', _convTarget);
    const resp = await fetch('/api/converter/convert-batch', { method: 'POST', body: fd });
    if (!resp.ok) {
      let detail = 'HTTP ' + resp.status;
      try { const j = await resp.json(); detail = j.detail || detail; } catch (_) {}
      throw new Error(detail);
    }
    const okCount = resp.headers.get('X-Converted-Count') || '?';
    const errCount = resp.headers.get('X-Error-Count') || '0';
    const blob = await resp.blob();
    const fname = _convFilenameFromHeaders(resp, 'converted.zip');
    _convDownloadBlob(blob, fname);
    _convSetStatus(`✓ Пачка: ${okCount} ок, ${errCount} ошибок — архив скачан`, errCount !== '0' ? 'warn' : 'ok');
  } catch (e) {
    _convSetStatus('✕ Ошибка пачки: ' + esc(e.message || e), 'err');
  } finally {
    if (btn) { btn.textContent = 'Конвертировать пачку'; btn.disabled = !_convBatchFiles.length; }
  }
}

function initConverterTab() {
  _convRefreshMeta();
  if (_convReady) return;
  _convReady = true;

  document.querySelectorAll('#convSourceFmt .conv-fmt').forEach(b => {
    b.addEventListener('click', () => {
      document.querySelectorAll('#convSourceFmt .conv-fmt').forEach(x => x.classList.remove('active'));
      b.classList.add('active');
      _convSource = b.dataset.fmt;
    });
  });
  document.querySelectorAll('#convTargetFmt .conv-fmt').forEach(b => {
    b.addEventListener('click', () => {
      if (_convImportModeOn()) return;
      document.querySelectorAll('#convTargetFmt .conv-fmt').forEach(x => x.classList.remove('active'));
      b.classList.add('active');
      _convTarget = b.dataset.fmt;
    });
  });

  const fileInput = document.getElementById('convFile');
  const drop = document.getElementById('convDrop');
  const dropText = document.getElementById('convDropText');
  if (fileInput) {
    fileInput.addEventListener('change', () => {
      _convFile = fileInput.files[0] || null;
      if (dropText) dropText.textContent = _convFile ? _convFile.name : 'Перетащите файл сюда или нажмите для выбора';
      _convUpdateGoBtn();
    });
  }
  if (drop) {
    ['dragover', 'dragenter'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('conv-drop-over'); }));
    ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('conv-drop-over'); }));
    drop.addEventListener('drop', e => {
      const f = e.dataTransfer?.files?.[0];
      if (f) { _convFile = f; if (fileInput) fileInput.files = e.dataTransfer.files; if (dropText) dropText.textContent = f.name; _convUpdateGoBtn(); }
    });
  }

  const importChk = document.getElementById('convImportMode');
  const importOpts = document.getElementById('convImportOpts');
  const targetGroup = document.getElementById('convTargetFmt');
  if (importChk) {
    importChk.addEventListener('change', () => {
      const on = importChk.checked;
      if (importOpts) importOpts.hidden = !on;
      if (targetGroup) targetGroup.classList.toggle('conv-disabled', on);
      if (on) _convRefreshMeta();
    });
  }

  document.getElementById('convGoBtn')?.addEventListener('click', _convDoConvert);

  const batchInput = document.getElementById('convBatchFiles');
  const batchText = document.getElementById('convBatchText');
  if (batchInput) {
    batchInput.addEventListener('change', () => {
      _convBatchFiles = Array.from(batchInput.files || []);
      if (batchText) batchText.textContent = _convBatchFiles.length ? `${_convBatchFiles.length} файлов выбрано` : 'Выберите несколько файлов';
      _convUpdateGoBtn();
    });
  }
  document.getElementById('convBatchBtn')?.addEventListener('click', _convDoBatch);
  _convUpdateGoBtn();
}


/* ===================== QR-код авторизация ===================== */
let _qrAuthId = null;
let _qrPollTimer = null;

function _qrStop() {
  if (_qrPollTimer) { clearInterval(_qrPollTimer); _qrPollTimer = null; }
  if (typeof _qrStopTimer === 'function') _qrStopTimer();
  if (_qrAuthId) {
    fetch('/api/accounts/qr-auth/cancel', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ auth_id: _qrAuthId }),
    }).catch(() => {});
    _qrAuthId = null;
  }
}

function _qrStatus(msg, kind) {
  const el = document.getElementById('qrAuthStatus');
  if (!el) return;
  if (!msg) { el.hidden = true; return; }
  el.hidden = false;
  el.className = 'acc-add-phone-status' + (kind ? ' status-' + kind : '');
  el.textContent = msg;
}

function _qrRenderCanvas(qrUrl) {
  const canvas = document.getElementById('qrAuthCanvas');
  if (!canvas) return;
  // Simple QR via external lib or inline SVG; fallback: clickable link
  canvas.innerHTML = `
    <div style="text-align:center;padding:20px">
      <img id="qrAuthImg" style="max-width:220px;border-radius:12px;background:#fff;padding:12px" />
      <p style="margin-top:10px;font-size:12px;color:var(--muted)">Откройте Telegram → Настройки → Устройства → Подключить устройство</p>
    </div>`;
  // Generate QR using qrcode.js (if available) or fallback to API
  const img = document.getElementById('qrAuthImg');
  if (img) {
    img.src = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`;
    img.alt = 'QR-код для входа';
  }
}

document.getElementById('btnQrStart')?.addEventListener('click', async () => {
  const btn = document.getElementById('btnQrStart');
  // Сначала разрешаем проект (создаём «новый», если выбран «__new__»),
  // иначе при QR проект терялся: Number("__new__") = NaN → project_id:null.
  let pid;
  try {
    pid = await resolveAccProjectId();
  } catch {
    return; // resolveAccProjectId уже показал alert «Укажи название проекта»
  }
  if (btn) { btn.disabled = true; btn.textContent = 'Подключение…'; }
  _qrStatus('Подключение к Telegram…', 'wait');
  try {
    const gid = document.getElementById('modalAccProxyGroup')?.value || '';
    const body = { randomize_device: true };
    if (gid) body.proxy_group_id = Number(gid);
    if (pid) body.project_id = Number(pid);
    const r = await fetch('/api/accounts/qr-auth/start', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data = await r.json();
    if (!r.ok || !data.ok) throw new Error(data.detail || data.error || 'Ошибка');
    _qrAuthId = data.auth_id;
    _qrRenderCanvas(data.qr_url);
    _qrStatus('Ожидание сканирования…', 'wait');
    if (data.expires) _qrStartTimer(data.expires);
    if (btn) btn.textContent = 'Обновить QR';
    _qrStartPoll();
  } catch (e) {
    _qrStatus('Ошибка: ' + (e.message || e), 'err');
  } finally {
    if (btn) btn.disabled = false;
  }
});

function _qrStartPoll() {
  if (_qrPollTimer) clearInterval(_qrPollTimer);
  _qrPollTimer = setInterval(async () => {
    if (!_qrAuthId) return;
    try {
      const r = await fetch('/api/accounts/qr-auth/poll', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ auth_id: _qrAuthId }),
      });
      const data = await r.json();
      if (data.status === 'done') {
        _qrStop();
        _qrStatus(`✓ Аккаунт ${data.phone || ''} добавлен!`, 'ok');
        showToast('QR-логин: аккаунт добавлен — ' + (data.phone || ''), 'ok');
        refreshMetaSelects();
        loadAccountsTable();
        // Сброс QR-панели через 2с
        setTimeout(() => {
          _qrStatus('');
          const canvas = document.getElementById('qrAuthCanvas');
          if (canvas) canvas.innerHTML = '<p class="acc-add-hint" style="padding:40px 0;text-align:center">Нажмите «Показать QR-код» ниже</p>';
          const btn = document.getElementById('btnQrStart');
          if (btn) btn.textContent = 'Показать QR-код';
        }, 2500);
      } else if (data.status === 'skipped') {
        _qrStop();
        _qrStatus('⚠ ' + (data.error || 'Аккаунт уже в базе'), 'warn');
        showToast(data.error || 'Аккаунт уже в базе', 'warn');
      } else if (data.status === 'awaiting_2fa') {
        clearInterval(_qrPollTimer); _qrPollTimer = null;
        if (typeof _qrStopTimer === 'function') _qrStopTimer();  // погасить «обновляю QR…»
        _qrStatus('✓ Отсканировано! Введи облачный пароль 2FA и нажми «Подтвердить 2FA».', 'wait');
        const canvas = document.getElementById('qrAuthCanvas');
        if (canvas) canvas.innerHTML = '<div style="padding:28px 0;text-align:center;font-size:42px;color:var(--accent);line-height:1">✓</div><p class="acc-add-hint" style="text-align:center">QR отсканирован</p>';
        const w = document.getElementById('qrAuth2faWrap'); if (w) w.hidden = false;
        const b = document.getElementById('btnQr2faConfirm'); if (b) b.hidden = false;
        const s = document.getElementById('btnQrStart'); if (s) s.hidden = true;
        document.getElementById('qrAuth2faInput')?.focus();
      } else if (data.status === 'expired' || data.status === 'error') {
        _qrStop();
        _qrStatus(data.error || 'Сессия истекла', 'err');
      } else if (data.token) {
        _qrRenderCanvas(data.qr_url || `tg://login?token=${data.token}`);
        if (data.expires) _qrStartTimer(data.expires);
      }
    } catch (_) {}
  }, 3000);
}

document.getElementById('btnQr2faConfirm')?.addEventListener('click', async () => {
  const pw = document.getElementById('qrAuth2faInput')?.value || '';
  if (!pw) { _qrStatus('Введите пароль 2FA', 'err'); return; }
  const btn = document.getElementById('btnQr2faConfirm');
  const inp = document.getElementById('qrAuth2faInput');
  if (btn) { btn.disabled = true; btn.textContent = 'Проверка…'; }
  try {
    const r = await fetch('/api/accounts/qr-auth/confirm-2fa', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ auth_id: _qrAuthId, password: pw }),
    });
    const data = await r.json();
    if (!data.ok) throw new Error(data.error || 'Неверный пароль');
    _qrAuthId = null;
    _qrStatus(`✓ Аккаунт ${data.phone || ''} добавлен!`, 'ok');
    showToast('QR+2FA: аккаунт добавлен', 'ok');
    document.getElementById('qrAuth2faWrap').hidden = true;
    if (btn) btn.hidden = true;
    refreshMetaSelects();
    loadAccountsTable();
    // Сброс
    setTimeout(() => {
      _qrStatus('');
      if (inp) inp.value = '';
      const canvas = document.getElementById('qrAuthCanvas');
      if (canvas) canvas.innerHTML = '<p class="acc-add-hint" style="padding:40px 0;text-align:center">Нажмите «Показать QR-код» ниже</p>';
      const startBtn = document.getElementById('btnQrStart');
      if (startBtn) { startBtn.hidden = false; startBtn.textContent = 'Показать QR-код'; }
    }, 2500);
  } catch (e) {
    const msg = e.message || String(e);
    _qrStatus('Ошибка: ' + msg, 'err');
    // Очищаем поле — чтобы пользователь ввёл пароль заново (не оставлял неверный)
    if (inp) { inp.value = ''; inp.focus(); }
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Подтвердить 2FA'; }
  }
});

/* ===================== AuthKey-импорт ===================== */
function _akStatus(msg, kind) {
  const el = document.getElementById('authkeyStatus');
  if (!el) return;
  if (!msg) { el.hidden = true; return; }
  el.hidden = false;
  el.className = 'acc-add-phone-status' + (kind ? ' status-' + kind : '');
  el.textContent = msg;
}

document.getElementById('btnAuthkeyImport')?.addEventListener('click', async () => {
  const key = document.getElementById('authkeyInput')?.value.trim() || '';
  if (!key) { _akStatus('Вставь auth_key', 'err'); return; }
  const dc = document.getElementById('authkeyDcId')?.value || '2';
  const twofa = document.getElementById('authkey2fa')?.value.trim() || '';
  const gid = document.getElementById('modalAccProxyGroup')?.value || '';
  // Разрешаем проект (создаём «новый», если выбран), иначе project_id терялся.
  let pid;
  try {
    pid = await resolveAccProjectId();
  } catch {
    return; // resolveAccProjectId уже показал alert «Укажи название проекта»
  }
  const btn = document.getElementById('btnAuthkeyImport');
  if (btn) { btn.disabled = true; btn.textContent = 'Проверка ключа…'; }
  _akStatus('Подключение к Telegram для проверки…', 'wait');
  try {
    const body = { auth_key: key, dc_id: Number(dc), randomize_device: true };
    if (gid) body.proxy_group_id = Number(gid);
    if (pid) body.project_id = Number(pid);
    if (twofa) body.twofa_password = twofa;
    const r = await fetch('/api/accounts/import-authkey', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data = await r.json();
    if (!r.ok) throw new Error(data.detail || data.error || 'HTTP ' + r.status);
    if (data.skipped) {
      _akStatus('⚠ ' + (data.error || 'Аккаунт уже в базе'), 'warn');
      showToast(data.error || 'Аккаунт уже в базе', 'warn');
    } else {
      _akStatus(`✓ Аккаунт ${data.phone || ''} добавлен (DC${data.dc_id})`, 'ok');
      showToast('AuthKey: аккаунт добавлен — ' + (data.phone || ''), 'ok');
      document.getElementById('authkeyInput').value = '';
    }
    refreshMetaSelects();
    loadAccountsTable();
  } catch (e) {
    _akStatus('Ошибка: ' + (e.message || e), 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Импортировать по ключу'; }
  }
});

/* ── Tab switch hook for QR/AuthKey buttons ── */
// _qrShow уже вызывается из setAccAddTab — дополнительный хук не нужен


/* ===================== Экспорт аккаунтов ===================== */
document.getElementById('accBulkExport')?.addEventListener('click', () => {
  const ids = getSelectedAccountIds();
  if (!ids.length) { showToast('Выбери аккаунты для экспорта', 'warn'); return; }
  const info = document.getElementById('exportInfo');
  if (info) info.textContent = `Будет экспортировано аккаунтов: ${ids.length}. Выбери формат:`;
  const st = document.getElementById('exportStatus');
  if (st) st.hidden = true;
  openModal('modalExportAccounts');
});

document.getElementById('exportConfirmBtn')?.addEventListener('click', async () => {
  const ids = getSelectedAccountIds();
  if (!ids.length) return;
  const fmt = document.getElementById('exportFormat')?.value || 'session_pyrogram';
  const btn = document.getElementById('exportConfirmBtn');
  const st = document.getElementById('exportStatus');
  if (btn) { btn.disabled = true; btn.textContent = 'Собираю архив…'; }
  if (st) { st.hidden = false; st.textContent = 'Обработка…'; }
  try {
    const r = await fetch('/api/accounts/export', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ account_ids: ids, target_format: fmt }),
    });
    if (!r.ok) {
      let detail = 'HTTP ' + r.status;
      try { const j = await r.json(); detail = j.detail || detail; } catch (_) {}
      throw new Error(detail);
    }
    const okCount = r.headers.get('X-Exported-Count') || '?';
    const errCount = r.headers.get('X-Error-Count') || '0';
    const blob = await r.blob();
    const cd = r.headers.get('Content-Disposition') || '';
    const m = /filename="?([^"]+)"?/.exec(cd);
    const fname = m ? m[1] : 'export.zip';
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = fname;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 4000);
    showToast(`Экспорт: ${okCount} ок${errCount !== '0' ? ', ' + errCount + ' ошибок' : ''}`, errCount !== '0' ? 'warn' : 'ok');
    closeModals();
  } catch (e) {
    if (st) { st.hidden = false; st.textContent = 'Ошибка: ' + (e.message || e); }
    showToast('Ошибка экспорта: ' + (e.message || e), 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Скачать архив'; }
  }
});


/* ===================== Превью импорта (#9) ===================== */
document.getElementById('btnAccPreview')?.addEventListener('click', async () => {
  if (!modalAccFilesList.length) { showToast('Добавь файлы для проверки', 'warn'); return; }
  const btn = document.getElementById('btnAccPreview');
  const el = document.getElementById('accImportResult');
  if (btn) { btn.disabled = true; btn.textContent = 'Проверяю…'; }
  try {
    const fd = new FormData();
    modalAccFilesList.forEach(f => fd.append('files', f));
    const r = await fetch('/api/accounts/import-preview', { method: 'POST', body: fd });
    if (!r.ok) { throw new Error(await r.text()); }
    const data = await r.json();
    const fmts = Object.entries(data.by_format || {})
      .map(([k, v]) => `<span class="air-chip">${esc(k)}: ${v}</span>`).join('');
    let details = '';
    if (data.error_count) {
      const items = (data.errors || []).slice(0, 50).map(e => `<li>${esc(e)}</li>`).join('');
      details = `<details class="air-details" open><summary>Битые/непонятные (${data.error_count})</summary><ul>${items}</ul></details>`;
    }
    if (el) {
      el.hidden = false;
      el.innerHTML = `<div class="air-head">Превью (без заведения)</div>
        <div class="air-stats"><div class="air-stat air-ok"><b>${data.total}</b> найдено</div>
        ${data.error_count ? `<div class="air-stat air-err"><b>${data.error_count}</b> битых</div>` : ''}</div>
        <div class="air-formats">${fmts}</div>${details}`;
    }
  } catch (e) {
    showToast('Ошибка превью: ' + (e.message || e), 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Проверить'; }
  }
});

/* ===================== QR-таймер (#7) ===================== */
let _qrExpiresAt = 0;
let _qrTimerInt = null;

function _qrStartTimer(expiresEpoch) {
  if (expiresEpoch) _qrExpiresAt = expiresEpoch * 1000;
  if (_qrTimerInt) clearInterval(_qrTimerInt);
  _qrTimerInt = setInterval(() => {
    const left = Math.max(0, Math.round((_qrExpiresAt - Date.now()) / 1000));
    const el = document.getElementById('qrAuthTimer');
    if (el) {
      if (left > 0) { el.textContent = `QR обновится через ${left}с`; el.hidden = false; }
      else { el.textContent = 'обновляю QR…'; }
    }
    if (left <= 0 && _qrTimerInt) { clearInterval(_qrTimerInt); _qrTimerInt = null; }
  }, 1000);
}

function _qrStopTimer() {
  if (_qrTimerInt) { clearInterval(_qrTimerInt); _qrTimerInt = null; }
  const el = document.getElementById('qrAuthTimer');
  if (el) el.hidden = true;
}
