/**
 * GetCourse -> Google Sheets / XLSX exporter
 *
 * Установка:
 * 1) Вставить этот код в глобальную шапку GetCourse.
 * 2) Заполнить CONFIG.appsScriptUrl и CONFIG.exportKey.
 * 3) Разрешённые ID сотрудников задаются здесь: CONFIG.allowedAccountUserIds.
 *
 * ВАЖНО: скрипт завершает работу ДО любой инициализации, если URL страницы
 * не равен /pl/user/user/index (query-параметры выборки разрешены).
 */
(function () {
  'use strict';

  const CONFIG = Object.freeze({
    host: 'uvnschool.ru',
    userListPath: '/pl/user/user/index',

    // Скрипт полностью отключается для сотрудников, которых нет в этом списке.
    // Берётся window.accountUserId со страницы GetCourse.
    allowedAccountUserIds: [470771500, 468872379, 472971354,512679944,478677765],

    // Web App Apps Script после Deploy -> Web app, URL должен оканчиваться /exec.
    appsScriptUrl: 'https://script.google.com/macros/s/AKfycbx8DNoh-j2i9ijJWIHtKbfGxlCcNTBFDc9mM6b-fWkdkiC2qJM96xKZ8TJUdc5H3kL-/exec',

    // Должен совпадать с EXPORT_KEY в Apps Script.
    // Это защита от случайных запросов, а не полноценный секрет: код виден в браузере.
    exportKey: 'test4486',


    parseConcurrency: 4,
    uploadChunkSize: 50,
    listPageSize: 30,
    requestTimeoutMs: 30000,
    requestRetries: 2,
    retryBaseDelayMs: 900,
    statusTimeoutMs: 45000,
    statusPollMs: 1200,
    localRetentionMs: 24 * 60 * 60 * 1000,

    dbName: 'uvn_getcourse_export_v2_contacts',
    dbVersion: 1,

    // XLSX загружается только по нажатию «Выгрузить XLSX».
    xlsxLibraries: [
      'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js',
      'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js'
    ]
  });

  // ---------------------------------------------------------------------------
  // ЖЁСТКИЙ GUARD: на остальных страницах GetCourse код дальше не выполняется.
  // ---------------------------------------------------------------------------
  if (location.hostname !== CONFIG.host) return;
  const normalizedPath = location.pathname.replace(/\/+$/, '') || '/';
  if (normalizedPath !== CONFIG.userListPath) return;

  const HEADERS = Object.freeze(['ID пользователя', 'Имя', 'Фамилия', 'Почта', 'Номер телефона']);

  const state = {
    accountUserId: null,
    db: null,
    panel: null,
    activeJobId: null,
    paused: false,
    cancelled: false,
    currentAction: null,
    observer: null,
    xlsxLoading: null
  };

  onReady(init);

  async function init() {
    const accountUserId = await waitForAccountUserId(10000);
    if (!Number.isFinite(accountUserId)) return;

    // Локальный guard по сотруднику. Для неразрешённого accountUserId
    // дальше не создаются стили, IndexedDB, кнопки и обработчики.
    if (!CONFIG.allowedAccountUserIds.includes(accountUserId)) return;

    state.accountUserId = accountUserId;

    try {
      state.db = await openDb();
      await purgeOldLocalJobs();
    } catch (error) {
      console.error('[UVN Export] IndexedDB недоступна:', error);
      return;
    }

    injectStyles();
    await waitForUserList(10000);
    mountTopControls();
    mountRowButtons();
    observeListChanges();
    await offerResumeIfNeeded();
  }

  // ---------------------------------------------------------------------------
  // UI
  // ---------------------------------------------------------------------------

  function injectStyles() {
    if (document.getElementById('uvn-gc-export-style')) return;
    const style = document.createElement('style');
    style.id = 'uvn-gc-export-style';
    style.textContent = `
      .uvn-export-toolbar{display:inline-flex;gap:7px;align-items:center;margin-left:8px;vertical-align:middle}
      .uvn-export-toolbar .btn{display:inline-flex;align-items:center;gap:6px}
      .uvn-export-row-cell{position:sticky!important;right:0!important;z-index:4;background:#fff!important;min-width:84px;width:84px;padding:5px 7px!important;box-shadow:-7px 0 11px -11px rgba(0,0,0,.8);text-align:center!important}
      tr:hover>.uvn-export-row-cell{background:#f7f7f7!important}
      .uvn-export-row-btn{padding:4px 8px!important;font-size:12px!important;line-height:1.35!important;white-space:nowrap}
      .uvn-export-row-btn.is-loading{opacity:.65;pointer-events:none}
      .uvn-export-panel{position:fixed;right:18px;bottom:18px;z-index:2147483000;width:min(390px,calc(100vw - 28px));background:#fff;border:1px solid #d9e2dc;border-radius:14px;box-shadow:0 18px 50px rgba(0,0,0,.2);font-family:Arial,sans-serif;color:#25352d;overflow:hidden}
      .uvn-export-panel[hidden]{display:none!important}
      .uvn-export-panel__head{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 15px 10px;border-bottom:1px solid #edf1ee}
      .uvn-export-panel__title{font-size:15px;font-weight:700;color:#205a3e}
      .uvn-export-panel__close{border:0;background:transparent;font-size:21px;line-height:1;color:#7b8981;cursor:pointer;padding:0 3px}
      .uvn-export-panel__body{padding:14px 15px 15px}
      .uvn-export-panel__stage{font-size:13px;font-weight:600;margin-bottom:7px}
      .uvn-export-panel__meta{font-size:12px;line-height:1.45;color:#6d7972;white-space:pre-line}
      .uvn-export-progress{height:8px;background:#edf2ef;border-radius:99px;overflow:hidden;margin:11px 0 9px}
      .uvn-export-progress>span{display:block;width:0;height:100%;background:#2f6e50;transition:width .18s ease}
      .uvn-export-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}
      .uvn-export-actions .btn{font-size:12px;padding:5px 9px}
      .uvn-export-error-list{max-height:120px;overflow:auto;margin-top:10px;padding:8px 10px;border-radius:9px;background:#fff4f2;color:#8a332c;font-size:11px;line-height:1.4;white-space:pre-wrap}
      .uvn-export-badge{display:inline-block;padding:2px 7px;border-radius:99px;background:#eaf4ee;color:#286245;font-size:11px;font-weight:700;margin-left:5px}
      .uvn-export-inline-note{display:inline-block;margin-left:8px;font-size:12px;color:#6d7972}
      @media(max-width:700px){.uvn-export-panel{right:10px;bottom:10px;width:calc(100vw - 20px)}}
    `;
    document.head.appendChild(style);
  }

  function mountTopControls() {
    if (document.getElementById('uvn-export-toolbar')) return;

    // Основное место: штатный блок кнопок GetCourse.
    // Вставляем сразу справа от группы «Действия».
    const actionButtons = document.querySelector('.clearfix.pull-left.action-buttons');
    let host = actionButtons;
    let insertAfter = null;

    if (actionButtons) {
      const groups = Array.from(actionButtons.querySelectorAll(':scope > .btn-group'));
      insertAfter = groups.find(group => {
        const button = group.querySelector('button');
        return button && /Действия/i.test(button.textContent || '');
      }) || groups.find(group => group.querySelector('.btn-success.dropdown-toggle')) || null;
    }

    // Fallback на случай, если GetCourse изменит структуру панели.
    if (!host) {
      host = document.querySelector('.page-actions') ||
        document.querySelector('.page-header') ||
        document.querySelector('.main-page-container');
    }
    if (!host) return;

    const wrap = document.createElement('div');
    wrap.id = 'uvn-export-toolbar';
    wrap.className = 'uvn-export-toolbar';
    wrap.innerHTML = `
      <button type="button" class="btn btn-success" data-uvn-action="sheet">
        <span class="glyphicon glyphicon-cloud-upload" aria-hidden="true"></span>
        Выгрузить в таблицу
      </button>
      <button type="button" class="btn btn-default" data-uvn-action="xlsx">
        <span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>
        Выгрузить XLSX
      </button>
    `;

    if (insertAfter && insertAfter.parentNode === host) {
      insertAfter.insertAdjacentElement('afterend', wrap);
    } else {
      host.insertBefore(wrap, host.firstChild);
    }

    wrap.querySelector('[data-uvn-action="sheet"]').addEventListener('click', () => startBulkExport('sheet'));
    wrap.querySelector('[data-uvn-action="xlsx"]').addEventListener('click', () => startBulkExport('xlsx'));
  }

  function mountRowButtons(root) {
    const scope = root || document;
    scope.querySelectorAll('  tr.gc-user-link[data-user-id], .grid-view tbody tr[data-key], .grid-view tbody tr[data-key]').forEach(row => {
      if (row.querySelector('.uvn-export-row-cell')) return;
      const userId = String(row.dataset.userId || row.dataset.key || '').trim();
      if (!/^\d+$/.test(userId)) return;

      const td = document.createElement('td');
      td.className = 'uvn-export-row-cell';
      td.innerHTML = `<button type="button" class="btn btn-xs btn-default uvn-export-row-btn">Export</button>`;
      row.appendChild(td);

      const btn = td.querySelector('button');
      btn.addEventListener('click', event => {
        event.preventDefault();
        event.stopPropagation();
        exportSingleUser(userId, btn);
      });
    });
  }

  function observeListChanges() {
    if (state.observer) return;
    state.observer = new MutationObserver(mutations => {
        if (!document.getElementById('uvn-export-toolbar')) {
    mountTopControls();
  }
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (!(node instanceof Element)) continue;
          if (node.matches && node.matches('  tr.gc-user-link[data-user-id], .grid-view tbody tr[data-key], .grid-view tbody tr[data-key]')) mountRowButtons(node.parentElement || node);
          else if (node.querySelector) mountRowButtons(node);
        }
      }
    });
    state.observer.observe(document.body, { childList: true, subtree: true });
  }

  function getPanel() {
    if (state.panel && document.body.contains(state.panel.root)) return state.panel;

    const root = document.createElement('section');
    root.className = 'uvn-export-panel';
    root.hidden = true;
    root.innerHTML = `
      <div class="uvn-export-panel__head">
        <div class="uvn-export-panel__title">Экспорт пользователей</div>
        <button type="button" class="uvn-export-panel__close" title="Скрыть">×</button>
      </div>
      <div class="uvn-export-panel__body">
        <div class="uvn-export-panel__stage"></div>
        <div class="uvn-export-progress"><span></span></div>
        <div class="uvn-export-panel__meta"></div>
        <div class="uvn-export-error-list" hidden></div>
        <div class="uvn-export-actions"></div>
      </div>
    `;
    document.body.appendChild(root);

    const panel = {
      root,
      title: root.querySelector('.uvn-export-panel__title'),
      stage: root.querySelector('.uvn-export-panel__stage'),
      progress: root.querySelector('.uvn-export-progress > span'),
      meta: root.querySelector('.uvn-export-panel__meta'),
      errors: root.querySelector('.uvn-export-error-list'),
      actions: root.querySelector('.uvn-export-actions')
    };
    root.querySelector('.uvn-export-panel__close').addEventListener('click', () => { root.hidden = true; });
    state.panel = panel;
    return panel;
  }

  function showPanel(opts) {
    const panel = getPanel();
    panel.root.hidden = false;
    if (opts && opts.title != null) panel.title.textContent = opts.title;
    if (opts && opts.stage != null) panel.stage.textContent = opts.stage;
    if (opts && opts.progress != null) panel.progress.style.width = clamp(opts.progress, 0, 100) + '%';
    if (opts && opts.meta != null) panel.meta.textContent = opts.meta;
    if (opts && opts.errors != null) {
      if (opts.errors) {
        panel.errors.hidden = false;
        panel.errors.textContent = opts.errors;
      } else {
        panel.errors.hidden = true;
        panel.errors.textContent = '';
      }
    }
    if (opts && Array.isArray(opts.actions)) setPanelActions(opts.actions);
    return panel;
  }

  function setPanelActions(actions) {
    const panel = getPanel();
    panel.actions.innerHTML = '';
    actions.forEach(action => {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'btn ' + (action.primary ? 'btn-success' : (action.danger ? 'btn-danger' : 'btn-default'));
      btn.textContent = action.label;
      if (action.disabled) btn.disabled = true;
      btn.addEventListener('click', action.onClick);
      panel.actions.appendChild(btn);
    });
  }

  function showFatal(message, error) {
    console.error('[UVN Export]', message, error || '');
    showPanel({
      stage: 'Ошибка',
      progress: 0,
      meta: message,
      errors: error ? errorMessage(error) : '',
      actions: []
    });
  }

  // ---------------------------------------------------------------------------
  // Bulk flow
  // ---------------------------------------------------------------------------

  async function startBulkExport(action) {
    if (state.activeJobId) {
      showPanel({ meta: 'Сейчас уже выполняется другая операция.' });
      return;
    }

    state.cancelled = false;
    state.paused = false;
    state.currentAction = action;

    try {
      const reusable = await findLatestReusableJob();
      if (reusable && reusable.status === 'ready') {
        const useExisting = confirm(
          'Для этой выборки уже есть собранные локально данные (' + reusable.doneCount + ' пользователей).\n\n' +
          'Использовать их без повторного парсинга?'
        );
        if (useExisting) {
          state.activeJobId = reusable.id;
          await performFinalAction(reusable.id, action);
          return;
        }
      }

      const job = await createJob(action, false);
      state.activeJobId = job.id;
      await collectSelection(job.id);
      if (state.cancelled) return finishCancelled(job.id);
      await parseJobUsers(job.id);
      if (state.cancelled) return finishCancelled(job.id);
      await performFinalAction(job.id, action);
    } catch (error) {
      const jobId = state.activeJobId;
      if (jobId) await patchJob(jobId, { status: 'error', lastError: errorMessage(error), updatedAt: Date.now() }).catch(() => {});
      showFatal('Операция остановлена. Собранные данные остаются в браузере.', error);
    } finally {
      state.activeJobId = null;
      state.currentAction = null;
      state.paused = false;
      state.cancelled = false;
    }
  }

  async function createJob(action, single) {
    const id = uuid();
    const job = {
      id,
      accountUserId: state.accountUserId,
      signature: currentSelectionSignature(),
      sourceUrl: location.href,
      single: !!single,
      action,
      status: 'collecting',
      userIds: [],
      total: 0,
      doneCount: 0,
      errorCount: 0,
      createdAt: Date.now(),
      updatedAt: Date.now(),
      upload: null,
      lastError: ''
    };
    await idbPut('jobs', job);
    return job;
  }

  async function collectSelection(jobId) {
    showPanel({
      stage: 'Собираю пользователей выборки…',
      progress: 0,
      meta: 'Получаю список ID со всех страниц.',
      errors: '',
      actions: processingActions(jobId)
    });

    const allIds = [];
    const seen = new Set();
    let page = 1;
    let safety = 0;

    while (!state.cancelled) {
      await waitWhilePaused();
      safety += 1;
      if (safety > 10000) throw new Error('Превышен защитный лимит страниц выборки.');

      const pageUrl = buildListPageUrl(page);
      const html = await fetchText(pageUrl, { method: 'GET' });
      const doc = parseHtml(html);
      const rows = Array.from(doc.querySelectorAll('  tr.gc-user-link[data-user-id], .grid-view tbody tr[data-key], .grid-view tbody tr[data-key]'));
      if (!rows.length) {
        if (page === 1) throw new Error('В текущей выборке не найдено пользователей.');
        break;
      }

      for (const row of rows) {
        const id = String(row.dataset.userId || row.dataset.key || '').trim();
        if (/^\d+$/.test(id) && !seen.has(id)) {
          seen.add(id);
          allIds.push(id);
        }
      }

      showPanel({
        stage: 'Собираю пользователей выборки…',
        progress: 0,
        meta: `Страница ${page} · найдено ${allIds.length}`,
        actions: processingActions(jobId)
      });

      const next = doc.querySelector('.pagination li.next:not(.disabled) a');
      if (!next || rows.length < CONFIG.listPageSize) break;
      page += 1;
    }

    if (state.cancelled) return;

    const now = Date.now();
    await patchJob(jobId, {
      status: 'parsing',
      userIds: allIds,
      total: allIds.length,
      doneCount: 0,
      errorCount: 0,
      updatedAt: now
    });

    const records = allIds.map(id => ({
      key: userRecordKey(jobId, id),
      jobId,
      userId: id,
      status: 'pending',
      data: null,
      error: '',
      updatedAt: now
    }));
    await idbPutMany('users', records);
  }

  async function parseJobUsers(jobId) {
    let job = await idbGet('jobs', jobId);
    if (!job) throw new Error('Локальная задача не найдена.');

    // После перезагрузки processing считаем незавершённым.
    const records = await getJobUserRecords(jobId);
    for (const rec of records) {
      if (rec.status === 'processing') {
        rec.status = 'pending';
        rec.error = '';
        await idbPut('users', rec);
      }
    }

    const queue = (await getJobUserRecords(jobId))
      .filter(rec => rec.status === 'pending')
      .map(rec => rec.userId);

    let completed = (await getJobUserRecords(jobId)).filter(rec => rec.status === 'done').length;
    let errors = (await getJobUserRecords(jobId)).filter(rec => rec.status === 'error').length;
    const total = job.total || job.userIds.length;
    let cursor = 0;

    updateParseProgress(completed, errors, total, 'Запускаю обработку…', jobId);

    const worker = async () => {
      while (!state.cancelled) {
        await waitWhilePaused();
        const index = cursor++;
        if (index >= queue.length) return;
        const userId = queue[index];
        const key = userRecordKey(jobId, userId);
        const rec = await idbGet('users', key);
        if (!rec || rec.status === 'done') continue;

        rec.status = 'processing';
        rec.updatedAt = Date.now();
        await idbPut('users', rec);
        updateParseProgress(completed, errors, total, `ID ${userId}`, jobId);

        try {
          const data = await parseUser(userId);
          rec.status = 'done';
          rec.data = data;
          rec.error = '';
          rec.updatedAt = Date.now();
          await idbPut('users', rec);
          completed += 1;
        } catch (error) {
          rec.status = 'error';
          rec.data = null;
          rec.error = errorMessage(error);
          rec.updatedAt = Date.now();
          await idbPut('users', rec);
          errors += 1;
        }

        await patchJob(jobId, {
          status: 'parsing',
          doneCount: completed,
          errorCount: errors,
          updatedAt: Date.now()
        });
        updateParseProgress(completed, errors, total, `ID ${userId}`, jobId);
      }
    };

    const workers = [];
    const count = Math.max(1, Math.min(CONFIG.parseConcurrency, queue.length || 1));
    for (let i = 0; i < count; i += 1) workers.push(worker());
    await Promise.all(workers);

    if (state.cancelled) return;

    job = await idbGet('jobs', jobId);
    await patchJob(jobId, {
      status: 'ready',
      doneCount: completed,
      errorCount: errors,
      updatedAt: Date.now()
    });

    const errorText = await formatJobErrors(jobId);
    showPanel({
      stage: 'Данные собраны',
      progress: 100,
      meta: `Успешно: ${completed}\nОшибок: ${errors}`,
      errors: errorText,
      actions: readyActions(jobId)
    });
  }

  function updateParseProgress(done, errors, total, current, jobId) {
    const processed = done + errors;
    const pct = total ? processed / total * 100 : 0;
    showPanel({
      stage: 'Получаю данные пользователей…',
      progress: pct,
      meta: `${processed} / ${total}\nУспешно: ${done} · ошибок: ${errors}\nСейчас: ${current}`,
      errors: '',
      actions: processingActions(jobId)
    });
  }

  function processingActions(jobId) {
    return [
      {
        label: state.paused ? 'Продолжить' : 'Пауза',
        onClick: async () => {
          state.paused = !state.paused;
          const job = await idbGet('jobs', jobId).catch(() => null);
          const done = job ? job.doneCount : 0;
          const errors = job ? job.errorCount : 0;
          showPanel({
            meta: state.paused
              ? `Приостановлено. Готово: ${done}, ошибок: ${errors}.`
              : `Продолжаю. Готово: ${done}, ошибок: ${errors}.`,
            actions: processingActions(jobId)
          });
        }
      },
      {
        label: 'Отмена',
        danger: true,
        onClick: () => { state.cancelled = true; state.paused = false; }
      }
    ];
  }

  function readyActions(jobId) {
    return [
      { label: 'В Google Sheets', primary: true, onClick: () => runPanelAction(() => uploadJob(jobId)) },
      { label: 'Скачать XLSX', onClick: () => runPanelAction(() => downloadJobXlsx(jobId)) },
      { label: 'Повторить ошибки', onClick: () => runPanelAction(() => retryJobErrors(jobId)) },
      { label: 'Очистить локально', danger: true, onClick: () => runPanelAction(() => deleteJob(jobId, true)) }
    ];
  }

  async function performFinalAction(jobId, action) {
    if (action === 'sheet') await uploadJob(jobId);
    else if (action === 'xlsx') await downloadJobXlsx(jobId);
  }

  async function retryJobErrors(jobId) {
    const records = await getJobUserRecords(jobId);
    const failed = records.filter(r => r.status === 'error');
    if (!failed.length) {
      showPanel({ meta: 'Ошибок для повторной обработки нет.', actions: readyActions(jobId) });
      return;
    }

    for (const rec of failed) {
      rec.status = 'pending';
      rec.error = '';
      rec.updatedAt = Date.now();
      await idbPut('users', rec);
    }
    const doneCount = records.filter(r => r.status === 'done').length;
    await patchJob(jobId, { status: 'parsing', errorCount: 0, doneCount, updatedAt: Date.now() });

    const previousActive = state.activeJobId;
    state.activeJobId = jobId;
    try {
      await parseJobUsers(jobId);
    } finally {
      state.activeJobId = previousActive;
    }
  }

  async function finishCancelled(jobId) {
    await patchJob(jobId, { status: 'paused', updatedAt: Date.now() });
    const job = await idbGet('jobs', jobId);
    showPanel({
      stage: 'Операция остановлена',
      progress: job && job.total ? ((job.doneCount + job.errorCount) / job.total * 100) : 0,
      meta: 'Прогресс сохранён в браузере. Его можно продолжить позже.',
      actions: [
        { label: 'Продолжить', primary: true, onClick: () => resumeJob(jobId) },
        { label: 'Удалить прогресс', danger: true, onClick: () => runPanelAction(() => deleteJob(jobId, true)) }
      ]
    });
  }

  // ---------------------------------------------------------------------------
  // Single user
  // ---------------------------------------------------------------------------

  async function exportSingleUser(userId, button) {
    if (button.classList.contains('is-loading')) return;
    button.classList.add('is-loading');
    const oldText = button.textContent;
    button.textContent = '…';

    try {
      const data = await parseUser(userId);
      const jobId = 'single-' + uuid();
      showPanel({
        stage: 'Выгружаю пользователя…',
        progress: 55,
        meta: `ID ${userId}`,
        errors: '',
        actions: []
      });
      const result = await uploadUsersToAppsScript(jobId, [data]);
      button.textContent = '✓';
      showPanel({
        stage: 'Пользователь выгружен',
        progress: 100,
        meta: `ID ${userId}\nДобавлено: ${result.inserted || 0} · обновлено: ${result.updated || 0}`,
        errors: '',
        actions: []
      });
      setTimeout(() => { button.textContent = oldText; }, 1800);
    } catch (error) {
      button.textContent = '!';
      showFatal(`Не удалось выгрузить пользователя ID ${userId}.`, error);
      setTimeout(() => { button.textContent = oldText; }, 2500);
    } finally {
      button.classList.remove('is-loading');
    }
  }

  // ---------------------------------------------------------------------------
  // Parsing GetCourse
  // ---------------------------------------------------------------------------

  async function parseUser(userId) {
    const profileUrl = `/user/control/user/update/id/${encodeURIComponent(userId)}`;
    const html = await fetchText(profileUrl, { method: 'GET' });
    const doc = parseHtml(html);

    if (!doc.querySelector('#userForm') && !doc.querySelector('#userFormProfile')) {
      throw new Error('Карточка пользователя не распознана. Возможно, нет доступа или изменилась верстка GetCourse.');
    }

    const profile = doc.querySelector('#userFormProfile');
    return {
      id: String(userId),
      firstName: inputValue(doc, '#User_first_name'),
      lastName: inputValue(doc, '#User_last_name'),
      email: getLabeledRowValue(profile, 'Эл. почта', { preferTitle: true }),
      phone: inputValue(doc, '#User_phone')
    };
  }

  async function fetchAndParseDeals(userId) {
    const url = `/pl/user/user/get-user-deal-data?id=${encodeURIComponent(userId)}`;
    let html;
    try {
      html = await fetchText(url, {
        method: 'POST',
        body: new URLSearchParams(),
        headers: { 'X-Requested-With': 'XMLHttpRequest' }
      });
    } catch (postError) {
      // Fallback на GET — пригодится, если GetCourse изменит обработчик.
      html = await fetchText(url, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
    }

    const doc = parseHtml(`<div id="uvn-deals-root">${html}</div>`);
    const rows = Array.from(doc.querySelectorAll('.deal-row'));
    if (!rows.length) return '';

    const formatted = [];
    for (const row of rows) {
      const link = row.querySelector('a[href*="/sales/control/deal/update/id/"]');
      const href = link ? (link.getAttribute('href') || '') : '';
      const idMatch = href.match(/\/id\/(\d+)/);
      const numberMatch = link ? cleanText(link.textContent).match(/#\s*(\d+)/) : null;
      const status = cleanText((row.querySelector('.deal-status') || {}).textContent || '');

      const directDivs = Array.from(row.children).filter(el => el.tagName === 'DIV');
      const dateBlock = directDivs[1] || null;
      const nameBlock = directDivs[2] || null;

      const dateText = dateBlock ? extractLeadingGcDate(cleanText(dateBlock.textContent)) : '';
      const date = normalizeGcDate(dateText, false);
      const name = nameBlock ? directText(nameBlock) : '';

      formatted.push([
        `id: ${idMatch ? idMatch[1] : ''}`,
        `n: ${numberMatch ? numberMatch[1] : ''}`,
        `date: ${date}`,
        `status: ${status}`,
        `name: ${name}`
      ].join('\n'));
    }
    return formatted.join('\n\n');
  }

  function getLabeledRowValue(panel, label, options) {
    if (!panel) return '';
    const target = normalizeLabel(label);
    for (const row of panel.querySelectorAll('tr')) {
      const cells = row.querySelectorAll(':scope > td');
      if (cells.length < 2) continue;
      const key = normalizeLabel(cells[0].textContent);
      if (key !== target) continue;
      const valueCell = cells[1];
      if (options && options.preferTitle) {
        const titled = valueCell.querySelector('[title]');
        if (titled && titled.getAttribute('title')) return cleanText(titled.getAttribute('title'));
      }
      const selected = valueCell.querySelector('select option:checked, select option[selected]');
      if (selected) return cleanText(selected.textContent);
      const input = valueCell.querySelector('input[value], textarea');
      if (input && String(input.value || '').trim()) return String(input.value || '').trim();
      return cleanText(valueCell.textContent);
    }
    return '';
  }

  function getKeyValueMap(panel) {
    const result = Object.create(null);
    if (!panel) return result;
    for (const row of panel.querySelectorAll('tr')) {
      const cells = row.querySelectorAll(':scope > td');
      if (cells.length < 2) continue;
      const key = cleanText(cells[0].textContent).toLowerCase();
      if (!key) continue;
      result[key] = cleanText(cells[1].textContent);
    }
    return result;
  }

  function inputValue(doc, selector) {
    const el = doc.querySelector(selector);
    if (!el) return '';
    return cleanText(el.value != null ? el.value : el.textContent);
  }

  function extractVkId(doc) {
    const panel = doc.querySelector('#userFormSocialLinkWidget_vk') || doc;
    const links = Array.from(panel.querySelectorAll('a[href*="vk.com/"]'));
    for (const link of links) {
      const href = String(link.getAttribute('href') || '');
      const match = href.match(/vk\.com\/(?:id)?(\d+)/i);
      if (match) return match[1];
    }

    const authProfile = panel.querySelector('.auth-profile');
    if (authProfile) {
      const text = cleanText(authProfile.textContent);
      const match = text.match(/(?:^|\s)id:\s*(\d+)/i);
      if (match) return match[1];
    }
    return '';
  }

  // ---------------------------------------------------------------------------
  // Dates
  // ---------------------------------------------------------------------------

  const RU_MONTHS = Object.freeze({
    'янв': 0, 'январ': 0,
    'фев': 1, 'феврал': 1,
    'мар': 2, 'март': 2,
    'апр': 3, 'апрел': 3,
    'май': 4, 'мая': 4,
    'июн': 5, 'июня': 5,
    'июл': 6, 'июля': 6,
    'авг': 7, 'август': 7,
    'сен': 8, 'сент': 8, 'сентябр': 8,
    'окт': 9, 'октябр': 9,
    'ноя': 10, 'ноябр': 10,
    'дек': 11, 'декабр': 11
  });

  function normalizeGcDate(value, includeTime) {
    const raw = cleanText(value);
    if (!raw) return '';

    // Уже ISO-подобное значение.
    let m = raw.match(/(\d{4})[-.](\d{1,2})[-.](\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?/);
    if (m) {
      const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0));
      return formatDate(d, includeTime);
    }

    // DD.MM.YYYY [HH:MM[:SS]]
    m = raw.match(/(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/);
    if (m) {
      const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0));
      return formatDate(d, includeTime);
    }

    const lower = raw.toLowerCase().replace(/[,.]/g, ' ');
    const tokens = lower.split(/\s+/).filter(Boolean);
    let day = null, month = null, year = null, hour = 0, minute = 0, second = 0;

    for (let i = 0; i < tokens.length; i += 1) {
      const token = tokens[i];
      if (/^\d{1,2}:\d{2}(?::\d{2})?$/.test(token)) {
        const parts = token.split(':').map(Number);
        hour = parts[0] || 0; minute = parts[1] || 0; second = parts[2] || 0;
        continue;
      }
      if (/^\d{4}$/.test(token)) {
        year = Number(token);
        continue;
      }
      const stem = token.replace(/[^а-яё]/gi, '');
      for (const key of Object.keys(RU_MONTHS)) {
        if (stem.startsWith(key)) { month = RU_MONTHS[key]; break; }
      }
      if (month !== null && day === null && i > 0) {
        const prev = tokens[i - 1].replace(/\D/g, '');
        if (/^\d{1,2}$/.test(prev)) day = Number(prev);
      }
    }

    if (day === null || month === null) return raw;
    const now = new Date();
    if (year === null) {
      year = now.getFullYear();
      const tentative = new Date(year, month, day, hour, minute, second);
      // Для дат без года около смены года не допускаем дату сильно в будущем.
      if (tentative.getTime() > now.getTime() + 36 * 60 * 60 * 1000) year -= 1;
    }

    return formatDate(new Date(year, month, day, hour, minute, second), includeTime);
  }

  function extractLeadingGcDate(text) {
    const raw = cleanText(text);
    const m = raw.match(/(?:Пн|Вт|Ср|Чт|Пт|Сб|Вс)?\s*\d{1,2}\s+[А-Яа-яЁё]+(?:\s+\d{4})?(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?/i);
    return m ? cleanText(m[0]) : raw;
  }

  function normalizeBirthday(raw) {
    const value = cleanText(raw);
    if (!value) return '';
    const m = value.match(/(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{4})/);
    if (m) return `${m[3]}-${pad2(m[2])}-${pad2(m[1])}`;
    const iso = value.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
    if (iso) return `${iso[1]}-${pad2(iso[2])}-${pad2(iso[3])}`;
    return value;
  }

  function calculateAge(raw) {
    const normalized = normalizeBirthday(raw);
    const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/);
    if (!m) return '';
    const birth = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
    if (isNaN(birth.getTime())) return '';
    const now = new Date();
    let age = now.getFullYear() - birth.getFullYear();
    const beforeBirthday = (now.getMonth() < birth.getMonth()) ||
      (now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate());
    if (beforeBirthday) age -= 1;
    return age >= 0 && age < 130 ? String(age) : '';
  }

  function formatDate(date, includeTime) {
    if (!(date instanceof Date) || isNaN(date.getTime())) return '';
    const y = date.getFullYear();
    const m = pad2(date.getMonth() + 1);
    const d = pad2(date.getDate());
    if (!includeTime) return `${y}-${m}-${d}`;
    return `${y}-${m}-${d} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
  }

  // ---------------------------------------------------------------------------
  // Upload to Apps Script: no-cors POST + JSONP confirmation
  // ---------------------------------------------------------------------------

  async function uploadJob(jobId) {
    const records = await getJobUserRecords(jobId);
    const users = records.filter(r => r.status === 'done' && r.data).map(r => r.data);
    if (!users.length) throw new Error('Нет успешно собранных пользователей для выгрузки.');

    await patchJob(jobId, { status: 'uploading', updatedAt: Date.now() });
    const result = await uploadUsersToAppsScript(jobId, users, progress => {
      showPanel({
        stage: 'Передаю в Google Sheets…',
        progress: progress.percent,
        meta: `Пачка ${progress.chunkNumber} из ${progress.totalChunks}\nПодтверждено Google: ${progress.confirmed} / ${progress.total}`,
        errors: '',
        actions: []
      });
    });

    await patchJob(jobId, { status: 'ready', upload: result, updatedAt: Date.now() });
    showPanel({
      stage: 'Выгрузка завершена',
      progress: 100,
      meta: `Обработано: ${result.processed || users.length}\nДобавлено: ${result.inserted || 0}\nОбновлено: ${result.updated || 0}\nОшибок: ${result.errors || 0}`,
      errors: result.errorMessages && result.errorMessages.length ? result.errorMessages.join('\n') : '',
      actions: readyActions(jobId)
    });
    return result;
  }

  async function uploadUsersToAppsScript(jobId, users, onProgress) {
    validateAppsScriptConfig();
    const chunks = chunkArray(users, Math.max(1, CONFIG.uploadChunkSize));
    const totals = { processed: 0, inserted: 0, updated: 0, errors: 0, errorMessages: [] };

    for (let i = 0; i < chunks.length; i += 1) {
      const chunk = chunks[i];
      const chunkId = `${jobId}:${i + 1}:${uuid()}`;
      const params = new URLSearchParams();
      params.set('action', 'importUsers');
      params.set('key', CONFIG.exportKey);
      params.set('accountUserId', String(state.accountUserId));
      params.set('jobId', jobId);
      params.set('chunkId', chunkId);
      params.set('chunkIndex', String(i + 1));
      params.set('totalChunks', String(chunks.length));
      params.set('payload', JSON.stringify(chunk));

      await fetch(CONFIG.appsScriptUrl, {
        method: 'POST',
        mode: 'no-cors',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: params.toString(),
        cache: 'no-store',
        redirect: 'follow'
      });

      const status = await waitForChunkConfirmation(jobId, chunkId);
      if (!status || status.state !== 'done') {
        throw new Error(status && status.message ? status.message : `Google не подтвердил пачку ${i + 1}.`);
      }

      const chunkStats = status.chunkStats || {};
      totals.processed += Number(chunkStats.processed || chunk.length);
      totals.inserted += Number(chunkStats.inserted || 0);
      totals.updated += Number(chunkStats.updated || 0);
      totals.errors += Number(chunkStats.errors || 0);
      if (Array.isArray(chunkStats.errorMessages)) totals.errorMessages.push(...chunkStats.errorMessages);

      if (typeof onProgress === 'function') {
        onProgress({
          chunkNumber: i + 1,
          totalChunks: chunks.length,
          confirmed: Math.min(users.length, (i + 1) * CONFIG.uploadChunkSize),
          total: users.length,
          percent: (i + 1) / chunks.length * 100
        });
      }
    }
    return totals;
  }

  async function waitForChunkConfirmation(jobId, chunkId) {
    const started = Date.now();
    let last = null;
    while (Date.now() - started < CONFIG.statusTimeoutMs) {
      last = await jsonpStatus(jobId, chunkId);
      if (last && last.state === 'error' && last.chunkId === chunkId) {
        throw new Error(last.message || 'Apps Script сообщил об ошибке.');
      }
      if (last && last.state === 'done' && last.chunkId === chunkId) return last;
      await sleep(CONFIG.statusPollMs);
    }
    throw new Error('Истекло время ожидания подтверждения от Google Sheets. Данные остаются локально; отправку можно повторить.');
  }

  function jsonpStatus(jobId, chunkId) {
    validateAppsScriptConfig();
    return new Promise((resolve, reject) => {
      const callbackName = '__uvnExportCb_' + uuid().replace(/-/g, '');
      const script = document.createElement('script');
      const timer = setTimeout(() => cleanup(new Error('Таймаут проверки статуса Apps Script.')), 12000);

      function cleanup(error, value) {
        clearTimeout(timer);
        try { delete window[callbackName]; } catch (_) { window[callbackName] = undefined; }
        if (script.parentNode) script.parentNode.removeChild(script);
        if (error) reject(error); else resolve(value);
      }

      window[callbackName] = data => cleanup(null, data || null);
      script.onerror = () => cleanup(new Error('Не удалось получить статус Apps Script.'));

      const url = new URL(CONFIG.appsScriptUrl);
      url.searchParams.set('action', 'status');
      url.searchParams.set('key', CONFIG.exportKey);
      url.searchParams.set('accountUserId', String(state.accountUserId));
      url.searchParams.set('jobId', jobId);
      url.searchParams.set('chunkId', chunkId);
      url.searchParams.set('callback', callbackName);
      url.searchParams.set('_', String(Date.now()));
      script.src = url.toString();
      document.head.appendChild(script);
    });
  }

  function validateAppsScriptConfig() {
    if (!/^https:\/\/script\.google\.com\/macros\/s\/.+\/exec/i.test(CONFIG.appsScriptUrl)) {
      throw new Error('В CONFIG.appsScriptUrl нужно вставить URL опубликованного Apps Script Web App (/exec).');
    }
    if (!CONFIG.exportKey || CONFIG.exportKey === 'CHANGE_ME_TO_A_LONG_RANDOM_STRING') {
      throw new Error('В CONFIG.exportKey нужно указать тот же ключ, что и в Apps Script.');
    }
  }

  // ---------------------------------------------------------------------------
  // XLSX
  // ---------------------------------------------------------------------------

  async function downloadJobXlsx(jobId) {
    const records = await getJobUserRecords(jobId);
    const users = records.filter(r => r.status === 'done' && r.data).map(r => r.data);
    if (!users.length) throw new Error('Нет успешно собранных пользователей для XLSX.');

    showPanel({
      stage: 'Формирую XLSX…',
      progress: 80,
      meta: `${users.length} пользователей`,
      errors: '',
      actions: []
    });

    await ensureXlsxLibrary();
    const aoa = [HEADERS, ...users.map(userToRow)];
    const ws = window.XLSX.utils.aoa_to_sheet(aoa);
    users.forEach((user, index) => {
      const cell = ws[window.XLSX.utils.encode_cell({ r: index + 1, c: 0 })];
      if (cell && user.id) {
        cell.l = {
          Target: `https://uvnschool.ru/user/control/user/update/id/${encodeURIComponent(user.id)}`,
          Tooltip: 'Открыть карточку пользователя'
        };
      }
    });
    ws['!cols'] = [18, 22, 24, 36, 22].map(wch => ({ wch }));
    const wb = window.XLSX.utils.book_new();
    window.XLSX.utils.book_append_sheet(wb, ws, 'Пользователи');
    const stamp = formatFileStamp(new Date());
    window.XLSX.writeFile(wb, `getcourse-users-${stamp}.xlsx`, { compression: true });

    showPanel({
      stage: 'XLSX готов',
      progress: 100,
      meta: `В файл выгружено: ${users.length}`,
      errors: await formatJobErrors(jobId),
      actions: readyActions(jobId)
    });
  }

  async function ensureXlsxLibrary() {
    if (window.XLSX && window.XLSX.utils) return;
    if (state.xlsxLoading) return state.xlsxLoading;
    state.xlsxLoading = (async () => {
      let lastError;
      for (const src of CONFIG.xlsxLibraries) {
        try {
          await loadScript(src, 20000);
          if (window.XLSX && window.XLSX.utils) return;
        } catch (error) {
          lastError = error;
        }
      }
      throw lastError || new Error('Не удалось загрузить библиотеку XLSX.');
    })();
    try {
      await state.xlsxLoading;
    } finally {
      state.xlsxLoading = null;
    }
  }

  function loadScript(src, timeout) {
    return new Promise((resolve, reject) => {
      const s = document.createElement('script');
      const timer = setTimeout(() => {
        if (s.parentNode) s.parentNode.removeChild(s);
        reject(new Error('Таймаут загрузки ' + src));
      }, timeout || 15000);
      s.onload = () => { clearTimeout(timer); resolve(); };
      s.onerror = () => { clearTimeout(timer); reject(new Error('Не удалось загрузить ' + src)); };
      s.src = src;
      document.head.appendChild(s);
    });
  }

  // ---------------------------------------------------------------------------
  // Resume / IndexedDB
  // ---------------------------------------------------------------------------

  async function offerResumeIfNeeded() {
    const job = await findLatestIncompleteJob();
    if (!job) return;
    const records = await getJobUserRecords(job.id);
    const done = records.filter(r => r.status === 'done').length;
    const errors = records.filter(r => r.status === 'error').length;

    showPanel({
      stage: 'Найдена незавершённая выгрузка',
      progress: job.total ? (done + errors) / job.total * 100 : 0,
      meta: `Обработано: ${done + errors} / ${job.total || job.userIds.length}\nУспешно: ${done} · ошибок: ${errors}`,
      errors: '',
      actions: [
        { label: 'Продолжить', primary: true, onClick: () => resumeJob(job.id) },
        { label: 'Начать заново', danger: true, onClick: () => runPanelAction(async () => { await deleteJob(job.id, false); getPanel().root.hidden = true; }) }
      ]
    });
  }

  async function resumeJob(jobId) {
    if (state.activeJobId) return;
    const job = await idbGet('jobs', jobId);
    if (!job) return;
    state.activeJobId = jobId;
    state.cancelled = false;
    state.paused = false;
    state.currentAction = job.action || 'sheet';

    try {
      if (!job.userIds || !job.userIds.length) await collectSelection(jobId);
      await parseJobUsers(jobId);
      if (!state.cancelled) await performFinalAction(jobId, state.currentAction);
    } catch (error) {
      await patchJob(jobId, { status: 'error', lastError: errorMessage(error), updatedAt: Date.now() }).catch(() => {});
      showFatal('Не удалось продолжить выгрузку.', error);
    } finally {
      state.activeJobId = null;
      state.currentAction = null;
    }
  }

  async function findLatestIncompleteJob() {
    const jobs = await idbGetAll('jobs');
    return jobs
      .filter(j => j.accountUserId === state.accountUserId && j.signature === currentSelectionSignature())
      .filter(j => ['collecting', 'parsing', 'uploading', 'paused', 'error'].includes(j.status))
      .sort((a, b) => b.updatedAt - a.updatedAt)[0] || null;
  }

  async function findLatestReusableJob() {
    const jobs = await idbGetAll('jobs');
    const cutoff = Date.now() - CONFIG.localRetentionMs;
    return jobs
      .filter(j => j.accountUserId === state.accountUserId && j.signature === currentSelectionSignature())
      .filter(j => j.status === 'ready' && j.updatedAt >= cutoff)
      .sort((a, b) => b.updatedAt - a.updatedAt)[0] || null;
  }

  async function deleteJob(jobId, hidePanel) {
    const records = await getJobUserRecords(jobId);
    await idbDeleteMany('users', records.map(r => r.key));
    await idbDelete('jobs', jobId);
    if (hidePanel) getPanel().root.hidden = true;
  }

  async function purgeOldLocalJobs() {
    const cutoff = Date.now() - CONFIG.localRetentionMs;
    const jobs = await idbGetAll('jobs');
    for (const job of jobs) {
      if ((job.updatedAt || job.createdAt || 0) < cutoff) await deleteJob(job.id, false);
    }
  }

  function openDb() {
    return new Promise((resolve, reject) => {
      const req = indexedDB.open(CONFIG.dbName, CONFIG.dbVersion);
      req.onupgradeneeded = () => {
        const db = req.result;
        if (!db.objectStoreNames.contains('jobs')) {
          const jobs = db.createObjectStore('jobs', { keyPath: 'id' });
          jobs.createIndex('updatedAt', 'updatedAt', { unique: false });
          jobs.createIndex('signature', 'signature', { unique: false });
        }
        if (!db.objectStoreNames.contains('users')) {
          const users = db.createObjectStore('users', { keyPath: 'key' });
          users.createIndex('jobId', 'jobId', { unique: false });
          users.createIndex('status', 'status', { unique: false });
        }
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error || new Error('IndexedDB open failed'));
    });
  }

  function idbStore(name, mode) {
    return state.db.transaction(name, mode || 'readonly').objectStore(name);
  }

  function idbGet(store, key) {
    return idbRequest(idbStore(store).get(key));
  }

  function idbGetAll(store) {
    return idbRequest(idbStore(store).getAll());
  }

  function idbPut(store, value) {
    return idbRequest(idbStore(store, 'readwrite').put(value));
  }

  function idbPutMany(store, values) {
    return new Promise((resolve, reject) => {
      const tx = state.db.transaction(store, 'readwrite');
      const os = tx.objectStore(store);
      for (const value of values) os.put(value);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
      tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
    });
  }

  function idbDelete(store, key) {
    return idbRequest(idbStore(store, 'readwrite').delete(key));
  }

  function idbDeleteMany(store, keys) {
    return new Promise((resolve, reject) => {
      const tx = state.db.transaction(store, 'readwrite');
      const os = tx.objectStore(store);
      for (const key of keys) os.delete(key);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error || new Error('IndexedDB delete failed'));
      tx.onabort = () => reject(tx.error || new Error('IndexedDB delete aborted'));
    });
  }

  function idbRequest(req) {
    return new Promise((resolve, reject) => {
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error || new Error('IndexedDB request failed'));
    });
  }

  async function getJobUserRecords(jobId) {
    return new Promise((resolve, reject) => {
      const tx = state.db.transaction('users', 'readonly');
      const index = tx.objectStore('users').index('jobId');
      const req = index.getAll(IDBKeyRange.only(jobId));
      req.onsuccess = () => resolve(req.result || []);
      req.onerror = () => reject(req.error || new Error('IndexedDB index read failed'));
    });
  }

  async function patchJob(jobId, patch) {
    const job = await idbGet('jobs', jobId);
    if (!job) throw new Error('Задача ' + jobId + ' не найдена.');
    Object.assign(job, patch || {});
    await idbPut('jobs', job);
    return job;
  }

  // ---------------------------------------------------------------------------
  // Network helpers
  // ---------------------------------------------------------------------------

  async function fetchText(url, options) {
    let lastError;
    for (let attempt = 0; attempt <= CONFIG.requestRetries; attempt += 1) {
      try {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
        const opts = Object.assign({
          credentials: 'same-origin',
          cache: 'no-store',
          redirect: 'follow',
          signal: controller.signal
        }, options || {});
        opts.headers = Object.assign({ 'Accept': 'text/html,application/xhtml+xml' }, (options && options.headers) || {});
        const response = await fetch(url, opts);
        clearTimeout(timer);

        if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
        const text = await response.text();
        if (looksLikeLoginPage(response.url, text)) {
          throw new Error('Сессия GetCourse завершилась. Обновите страницу и войдите в аккаунт заново.');
        }
        return text;
      } catch (error) {
        lastError = error;
        if (attempt >= CONFIG.requestRetries || /сессия GetCourse/i.test(errorMessage(error))) break;
        await sleep(CONFIG.retryBaseDelayMs * Math.pow(2, attempt));
      }
    }
    throw lastError || new Error('Не удалось получить страницу GetCourse.');
  }

  function looksLikeLoginPage(finalUrl, html) {
    if (/\/cms\/system\/login/i.test(finalUrl || '')) return true;
    return /name=["']email["'][^>]*.*name=["']password["']/is.test(html || '') && /login|войти/i.test(html || '');
  }

  function parseHtml(html) {
    return new DOMParser().parseFromString(String(html || ''), 'text/html');
  }

  // ---------------------------------------------------------------------------
  // Selection / page helpers
  // ---------------------------------------------------------------------------

  function buildListPageUrl(page) {
    const url = new URL(location.href);
    url.pathname = CONFIG.userListPath;
    url.searchParams.set('page', String(page));
    url.searchParams.set('per-page', String(CONFIG.listPageSize));
    return url.toString();
  }

  function currentSelectionSignature() {
    const url = new URL(location.href);
    const pairs = [];
    for (const [key, value] of url.searchParams.entries()) {
      if (key === 'page' || key === 'per-page' || /clarity_uid/i.test(key)) continue;
      pairs.push([key, value]);
    }
    pairs.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
    return CONFIG.userListPath + '?' + pairs.map(pair => encodeURIComponent(pair[0]) + '=' + encodeURIComponent(pair[1])).join('&');
  }

  function waitForUserList(timeoutMs) {
    if (document.querySelector('  tr.gc-user-link[data-user-id], .grid-view tbody tr[data-key], .grid-view tbody tr[data-key]')) return Promise.resolve(true);
    return new Promise(resolve => {
      const started = Date.now();
      const timer = setInterval(() => {
        if (document.querySelector('  tr.gc-user-link[data-user-id], .grid-view tbody tr[data-key], .grid-view tbody tr[data-key]') || Date.now() - started >= timeoutMs) {
          clearInterval(timer);
          resolve(true);
        }
      }, 150);
    });
  }

  function waitForAccountUserId(timeoutMs) {
    return new Promise(resolve => {
      const started = Date.now();
      const timer = setInterval(() => {
        const id = Number(window.accountUserId);
        if (Number.isFinite(id) && id > 0) {
          clearInterval(timer);
          resolve(id);
          return;
        }
        if (Date.now() - started >= timeoutMs) {
          clearInterval(timer);
          resolve(NaN);
        }
      }, 100);
    });
  }

  // ---------------------------------------------------------------------------
  // Formatting / utilities
  // ---------------------------------------------------------------------------

  function userToRow(user) {
    return [user.id || '', user.firstName || '', user.lastName || '', user.email || '', user.phone || ''];
  }

  function normalizeLabel(value) {
    return cleanText(value).toLowerCase().replace(/\s+/g, ' ');
  }

  function cleanText(value) {
    return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
  }

  function directText(element) {
    return Array.from(element.childNodes)
      .filter(node => node.nodeType === Node.TEXT_NODE)
      .map(node => cleanText(node.textContent))
      .filter(Boolean)
      .join(' ')
      .trim();
  }

  function formatFileStamp(date) {
    return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2(date.getHours())}-${pad2(date.getMinutes())}`;
  }

  function pad2(value) {
    return String(value).padStart(2, '0');
  }

  function uuid() {
    if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
      const r = Math.random() * 16 | 0;
      const v = c === 'x' ? r : (r & 0x3 | 0x8);
      return v.toString(16);
    });
  }

  function userRecordKey(jobId, userId) {
    return `${jobId}:${userId}`;
  }

  function chunkArray(array, size) {
    const out = [];
    for (let i = 0; i < array.length; i += size) out.push(array.slice(i, i + size));
    return out;
  }

  function clamp(value, min, max) {
    return Math.min(max, Math.max(min, Number(value) || 0));
  }

  function errorMessage(error) {
    return error && error.message ? error.message : String(error || 'Неизвестная ошибка');
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  async function waitWhilePaused() {
    while (state.paused && !state.cancelled) await sleep(250);
  }

  async function formatJobErrors(jobId) {
    const failed = (await getJobUserRecords(jobId)).filter(r => r.status === 'error');
    if (!failed.length) return '';
    return failed.slice(0, 30).map(r => `ID ${r.userId}: ${r.error}`).join('\n') + (failed.length > 30 ? `\n…и ещё ${failed.length - 30}` : '');
  }

  async function runPanelAction(fn) {
    try {
      await fn();
    } catch (error) {
      showFatal('Операция не выполнена.', error);
    }
  }

  function onReady(fn) {
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
    else fn();
  }
})();
