Загрузка данных


(async function () {
  'use strict';

  // ============ КОНФИГ ============
  const DELAY_BETWEEN_ROWS   = 1500;   // пауза между заявками (мс)
  const PANEL_LOAD_TIMEOUT   = 20000;  // максимум ждём загрузки панели
  const COMMENTS_TIMEOUT     = 15000;  // максимум ждём догрузки комментариев
  const SCROLL_STEP          = 300;    // шаг скролла при сборе списка
  const SCROLL_WAIT          = 600;    // ожидание после скролла
  const MAX_IDLE_SCROLLS     = 3;      // шагов без новых строк — стоп
  const MAX_SCROLL_ITER      = 500;    // защита от бесконечного цикла
  const RETRY_FAILED         = true;   // пересобирать заявки, где недобрали комментарии

  // ============ УТИЛИТЫ ============
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  function log(...args) {
    console.log('%c[TFS-Report]', 'color:#0a0;font-weight:bold', ...args);
  }
  function warn(...args) {
    console.warn('%c[TFS-Report]', 'color:#c80;font-weight:bold', ...args);
  }

  function waitFor(selector, { root = document, timeout = 10000 } = {}) {
    return new Promise((resolve, reject) => {
      const found = root.querySelector(selector);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el = root.querySelector(selector);
        if (el) { obs.disconnect(); clearTimeout(timer); resolve(el); }
      });
      obs.observe(root, { childList: true, subtree: true });
      const timer = setTimeout(() => {
        obs.disconnect();
        reject(new Error('timeout waiting for: ' + selector));
      }, timeout);
    });
  }

  function waitForCondition(checkFn, { timeout = 10000, label = '', pollMs = 300 } = {}) {
    return new Promise((resolve, reject) => {
      let done = false;
      let lastDiag = '';

      const check = () => {
        try {
          const v = checkFn();
          if (v === true || (v && v.ok)) return { ok: true, value: v };
          if (v && typeof v === 'object' && v.diag) lastDiag = v.diag;
        } catch (e) {
          lastDiag = 'exception: ' + e.message;
        }
        return { ok: false };
      };

      const tryResolve = () => {
        if (done) return;
        const r = check();
        if (r.ok) { done = true; cleanup(); resolve(r.value); }
      };

      const obs = new MutationObserver(tryResolve);
      obs.observe(document.body, {
        childList: true, subtree: true, characterData: true, attributes: true
      });

      const poll = setInterval(tryResolve, pollMs);

      function cleanup() {
        obs.disconnect();
        clearInterval(poll);
        clearTimeout(timer);
      }

      const timer = setTimeout(() => {
        if (done) return;
        done = true;
        cleanup();
        reject(new Error(`timeout: ${label}${lastDiag ? ` (lastDiag="${lastDiag}")` : ''}`));
      }, timeout);

      tryResolve();
    });
  }

  // ============ 1. СКРОЛЛ-КОНТЕЙНЕР СПИСКА ============
  function ancestors(el, depth = 8) {
    const arr = [];
    let p = el;
    for (let i = 0; i < depth && p; i++) { p = p.parentElement; if (p) arr.push(p); }
    return arr;
  }

  function getScroller() {
    const spacer = document.querySelector('.grid-content-spacer');
    if (!spacer) {
      warn('grid-content-spacer не найден');
      return document.scrollingElement;
    }

    for (const el of [spacer, spacer.parentElement, ...ancestors(spacer, 8)]) {
      if (!el || el === document.body || el === document.documentElement) continue;
      const st = getComputedStyle(el);
      if ((st.overflowY === 'auto' || st.overflowY === 'scroll') &&
          el.scrollHeight > el.clientHeight + 5) return el;
    }

    let el = spacer.parentElement;
    while (el && el !== document.body) {
      if (el.scrollHeight > el.clientHeight + 5) return el;
      el = el.parentElement;
    }
    return spacer.parentElement || document.scrollingElement;
  }

  async function scrollToTop(scroller) {
    if (!scroller) return;
    scroller.scrollTop = 0;
    scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
    await sleep(SCROLL_WAIT);
  }

  // ============ 2. СБОР СПИСКА ЗАЯВОК ============
  function readVisibleRows() {
    const result = [];
    document.querySelectorAll('.grid-row').forEach(row => {
      const a = row.querySelector('a.work-item-title-link');
      if (!a) return;
      const m = (a.getAttribute('href') || '').match(/\/edit\/(\d+)/);
      if (!m) return;
      result.push({
        id: m[1],
        title: a.textContent.trim(),
        href: a.getAttribute('href')
      });
    });
    return result;
  }

  async function collectAllRows(scroller) {
    log('Сбор списка заявок...');
    const map = new Map();
    let idle = 0;
    let iter = 0;

    await scrollToTop(scroller);

    while (iter++ < MAX_SCROLL_ITER) {
      const before = map.size;
      readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });
      if (map.size > before) idle = 0;

      const atBottom = !scroller ||
        scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10;

      if (atBottom) {
        idle++;
        if (idle >= MAX_IDLE_SCROLLS) break;
        scroller.scrollTop = scroller.scrollTop + 5;
        scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
        await sleep(SCROLL_WAIT);
        continue;
      }

      scroller.scrollTop = Math.min(scroller.scrollTop + SCROLL_STEP, scroller.scrollHeight);
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await sleep(SCROLL_WAIT);
    }

    readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });
    log(`Найдено заявок: ${map.size}`);
    return Array.from(map.values());
  }

  // ============ 3. ЧТЕНИЕ ПОЛЕЙ ПАНЕЛИ ============
  const findInput = (panel, aria) => panel.querySelector(`input[aria-label="${aria}"]`);
  const getPanel = () => document.querySelector('.rightPane');

  function readInputOrDisplay(panel, aria, displaySelector) {
    const v = findInput(panel, aria)?.value?.trim();
    if (v) return v;
    return panel.querySelector(displaySelector)?.textContent?.trim() || '';
  }

  function getTitle(panel) {
    const v = findInput(panel, 'Поле заголовка')?.value?.trim()
           || panel.querySelector('#witc_221_txt')?.value?.trim();
    if (v) return v;
    return panel.querySelector('.workitem-info-bar .caption')?.textContent?.trim() || '';
  }

  const getState = (panel) => readInputOrDisplay(panel, 'Поле состояния', '.workitem-state-value');

  const getAssignedTo = (panel) =>
    panel.querySelector('.work-item-form-assignedTo .identity-picker-resolved-name')?.textContent?.trim() || '';

  function getArea(panel) {
    const inp = findInput(panel, 'Путь области') || panel.querySelector('#witc_225_txt');
    return inp?.value?.trim() || '';
  }

  function getIteration(panel) {
    const inp = findInput(panel, 'Путь итерации') || panel.querySelector('#witc_229_txt');
    return inp?.value?.trim() || '';
  }

  function getChangedBy(panel) {
    for (const el of panel.querySelectorAll('.freshness-indicator')) {
      const t = el.textContent.trim();
      if (t && el.offsetParent !== null) return t;
    }
    return '';
  }

  // ============ 4. КОММЕНТАРИИ ============
  function getExpectedCommentCount(panel) {
    const adorn = panel.querySelector('.discussion-adornment-control');
    if (adorn) {
      const s = (adorn.getAttribute('aria-label') || '') + ' ' + (adorn.textContent || '');
      const m = s.match(/(\d+)/);
      if (m) return parseInt(m[1], 10);
    }
    const badge = panel.querySelector('.discussion-adornment-container .text');
    if (badge) {
      const m = badge.textContent.match(/(\d+)/);
      if (m) return parseInt(m[1], 10);
    }
    const tabCount = panel.querySelector('.work-item-form-tab .link-count, .work-item-form-tab .attachment-count');
    if (tabCount) {
      const m = tabCount.textContent.match(/(\d+)/);
      if (m) return parseInt(m[1], 10);
    }
    return null;
  }

  async function ensureDiscussionExpanded(panel) {
    const header = Array.from(panel.querySelectorAll('.tfs-collapsible-header'))
      .find(h => /Обсуждени/i.test(h.textContent));
    if (!header) return;

    const content = header.parentElement?.querySelector('.tfs-collapsible-content');
    if (content && content.style.display === 'none') {
      (header.querySelector('.headerlabel') || header).click();
      await sleep(500);
    }

    const discTab = Array.from(panel.querySelectorAll('.work-item-form-tab'))
      .find(t => /обсужд|discussion/i.test(t.getAttribute('aria-labelledby') || t.textContent || ''));
    if (discTab && !discTab.classList.contains('selected-tab')) {
      const discVisible = content && content.offsetParent !== null;
      if (!discVisible) {
        discTab.click();
        await sleep(500);
      }
    }
  }

  function parseRelativeTime(s) {
    if (!s) return Infinity;
    const m = s.toLowerCase().match(/(\d+)\s*(сек|мин|ч|дн|час|день|недел)/);
    if (!m) return Infinity;
    const n = parseInt(m[1], 10);
    const u = m[2];
    if (/сек/.test(u)) return n;
    if (/мин/.test(u)) return n * 60;
    if (/ч|час/.test(u)) return n * 3600;
    if (/дн|день/.test(u)) return n * 86400;
    if (/недел/.test(u)) return n * 604800;
    return Infinity;
  }

  function extractCommentBody(ci) {
    const content = ci.querySelector('.comment-content');
    if (content) {
      const inner = content.querySelector('.rooster-editor, .lean-rooster');
      const txt = (inner ? inner.innerText : content.innerText || '').trim();
      if (txt) return txt;
    }
    const rooster = ci.querySelector('.rooster-editor, .lean-rooster');
    if (rooster) {
      const txt = (rooster.innerText || '').trim();
      if (txt) return txt;
    }
    const clone = ci.cloneNode(true);
    clone.querySelectorAll('.comment-header, .comment-toolbar, .comment-timestamp, .user-display-name')
      .forEach(n => n.remove());
    return (clone.innerText || '').trim();
  }

  function extractCommentData(ci) {
    const author = ci.querySelector('.user-display-name')?.textContent.trim() || '';
    const tsRaw  = ci.querySelector('.comment-timestamp')?.textContent.trim() || '';
    const body   = extractCommentBody(ci);
    const idAttr = ci.querySelector('[data-comment-id]')?.getAttribute('data-comment-id');
    const id     = idAttr ? parseInt(idAttr, 10) : null;
    return { id, author, tsRaw, ts: parseRelativeTime(tsRaw), body };
  }

  function pickLatest(comments) {
    if (!comments.length) return null;
    const withId = comments.filter(c => c.id != null);
    if (withId.length) return withId.reduce((a, b) => (b.id > a.id ? b : a));
    return comments.reduce((a, b) => (b.ts < a.ts ? b : a));
  }

  function findDiscussionScroller(panel) {
    const roots = [
      panel.querySelector('.discussion-messages'),
      panel.querySelector('.discussion-messages-container'),
      panel.querySelector('.discussion-control'),
      panel.querySelector('.witform-layout-scrollable-frame')
    ].filter(Boolean);

    for (const root of roots) {
      let el = root;
      while (el && el !== document.body) {
        const st = getComputedStyle(el);
        if ((st.overflowY === 'auto' || st.overflowY === 'scroll') &&
            el.scrollHeight > el.clientHeight + 2) {
          return el;
        }
        el = el.parentElement;
      }
    }
    return null;
  }

  async function scrollDiscussionToBottom(scroller, { step = 200, pause = 120 } = {}) {
    if (!scroller) return;
    let stable = 0;
    for (let i = 0; i < 50; i++) {
      const before = scroller.scrollHeight;
      scroller.scrollTop = Math.min(scroller.scrollTop + step, scroller.scrollHeight);
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await sleep(pause);

      if (scroller.scrollHeight === before) stable++; else stable = 0;

      const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 5;
      if (atBottom && stable >= 2) break;
    }
    scroller.scrollTop = scroller.scrollHeight;
    scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
    await sleep(300);
  }

  async function loadAllComments(panel, { timeout = COMMENTS_TIMEOUT, previousCommentIds = [] } = {}) {
    await ensureDiscussionExpanded(panel);

    let expected = getExpectedCommentCount(panel);
    let lastExpected = expected;
    let expectedStable = 0;
    for (let i = 0; i < 10; i++) {
      await sleep(200);
      const cur = getExpectedCommentCount(panel);
      if (cur === lastExpected) expectedStable++;
      else { expectedStable = 0; lastExpected = cur; }
      expected = cur;
      if (expectedStable >= 2) break;
    }

    const prevSet = new Set(previousCommentIds.map(String));

    if (expected === 0) {
      for (let t = 0; t < 15; t++) {
        const items = panel.querySelectorAll('.comment-item');
        const hasStale = Array.from(panel.querySelectorAll('.comment-item [data-comment-id]'))
          .some(el => prevSet.has(el.getAttribute('data-comment-id')));
        if (items.length === 0 && !hasStale) break;
        await sleep(200);
      }
      expected = getExpectedCommentCount(panel);
      if (expected === 0) return [];
    }

    const messagesRoot =
      panel.querySelector('.discussion-messages') ||
      panel.querySelector('.discussion-messages-container') ||
      panel.querySelector('.discussion-control');
    if (!messagesRoot) return [];

    const scroller = findDiscussionScroller(panel) || messagesRoot;

    const started = performance.now();
    const maxTicks = Math.ceil(timeout / 300);
    let lastCount = -1;
    let stableTicks = 0;

    for (let tick = 0; tick < maxTicks; tick++) {
      const items = Array.from(panel.querySelectorAll('.comment-item'));
      const count = items.length;
      const bodies = items.map(extractCommentBody);
      const emptyBodies = bodies.filter(b => !b).length;

      const hasStale = Array.from(panel.querySelectorAll('.comment-item [data-comment-id]'))
        .some(el => prevSet.has(el.getAttribute('data-comment-id')));

      const curExpected = getExpectedCommentCount(panel);
      if (curExpected != null) expected = curExpected;

      const countOk = expected != null
        ? count >= expected
        : (count > 0 && stableTicks >= 3);

      if (countOk && emptyBodies === 0 && !hasStale) break;

      if (tick % 3 === 0) {
        log(`   [tick ${tick}] expected=${expected}, count=${count}, empty=${emptyBodies}, stale=${hasStale}`);
      }

      await scrollDiscussionToBottom(scroller, { step: 200, pause: 120 });

      if (count === lastCount) stableTicks++; else stableTicks = 0;
      lastCount = count;

      if (performance.now() - started > timeout) break;
    }

    let all = Array.from(panel.querySelectorAll('.comment-item')).map(extractCommentData);

    if (all.length === 0) {
      warn(`   Комментарии не собрались, жму "Обновить" панели...`);
      const btn = document.querySelector('[command="refresh-work-item"]');
      if (btn) {
        btn.click();
        await sleep(1500);
        await ensureDiscussionExpanded(panel);
        await scrollDiscussionToBottom(scroller, { step: 200, pause: 120 });
        all = Array.from(panel.querySelectorAll('.comment-item')).map(extractCommentData);
      }
    }

    if (expected != null && all.length < expected) {
      warn(`   ⚠ Собрано ${all.length} из ${expected} комментариев`);
    }
    const empty = all.filter(c => !c.body).length;
    if (empty) warn(`   ⚠ ${empty} комментариев с пустым текстом`);

    return all;
  }

  async function waitForComments(panel, opts = {}) {
    const all = await loadAllComments(panel, opts);
    return { all, latest: pickLatest(all) };
  }

  // ============ 5. ОЖИДАНИЕ ЗАГРУЗКИ ПАНЕЛИ ============
  async function waitForPanelLoaded(expectedId, previousCommentIds = [], { timeout = PANEL_LOAD_TIMEOUT } = {}) {
    await waitFor('.rightPane', { timeout });
    const started = performance.now();

    await waitForCondition(() => {
      const panel = document.querySelector('.rightPane');
      if (!panel) return { ok: false, diag: 'no panel' };

      const caption = panel.querySelector('.workitem-info-bar .caption');
      if (!caption) return { ok: false, diag: 'no caption' };
      const m = caption.textContent.match(/(\d{4,})/);
      if (!m) return { ok: false, diag: 'caption without id' };
      if (expectedId && m[1] !== String(expectedId)) {
        return { ok: false, diag: `caption id ${m[1]} != ${expectedId}` };
      }

      const stateText =
        (findInput(panel, 'Поле состояния')?.value || '').trim() ||
        (panel.querySelector('#witc_223_txt')?.value || '').trim() ||
        (panel.querySelector('.workitem-state-value')?.textContent || '').trim();
      if (!stateText) return { ok: false, diag: 'state empty' };

      if (panel.querySelectorAll('.work-item-form-tab').length < 3) {
        return { ok: false, diag: 'tabs < 3' };
      }
      if (!panel.querySelector('.work-item-form-tab.selected-tab')) {
        return { ok: false, diag: 'no active tab' };
      }
      if (!panel.querySelector('.discussion-control, .discussion-messages-container')) {
        return { ok: false, diag: 'no discussion' };
      }

      if (previousCommentIds.length) {
        const prevSet = new Set(previousCommentIds.map(String));
        const currentIds = Array.from(panel.querySelectorAll('.comment-item [data-comment-id]'))
          .map(el => el.getAttribute('data-comment-id'));
        const stale = currentIds.filter(id => prevSet.has(id));
        if (stale.length) {
          return { ok: false, diag: `stale comments from prev work item (${stale.length})` };
        }
      }

      return { ok: true };
    }, { timeout, label: 'panel fields ready' });

    log(`Панель загружена за ${Math.round(performance.now() - started)} мс (id=${expectedId})`);
  }

  // ============ 6. FULL-SCREEN ЗАЩИТА ============
  async function ensurePanelNotFullScreen() {
    const panel = document.querySelector('.rightPane');
    if (!panel) return;

    const isFull =
      panel.classList.contains('full-screen') ||
      document.body.classList.contains('full-screen') ||
      document.querySelector('.work-item-form.full-screen') ||
      document.querySelector('.work-item-form-main[style*="position: fixed"]');

    if (!isFull) return;

    warn('   Панель в полноэкранном режиме — сворачиваю');
    const btn = document.querySelector('[command="toggle-full-screen"]');
    if (btn) {
      btn.click();
      await sleep(600);
    }
  }

  // ============ 7. КЛИК ПО СТРОКЕ ============
  async function findRowLink(itemId, scroller) {
    const sel = `a.work-item-title-link[href*="/edit/${itemId}"]`;
    let link = document.querySelector(sel);
    if (link) return link;

    if (scroller) {
      scroller.scrollTop = 0;
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await sleep(300);
      for (let pos = 0; pos <= scroller.scrollHeight; pos += SCROLL_STEP) {
        scroller.scrollTop = pos;
        scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
        await sleep(250);
        link = document.querySelector(sel);
        if (link) return link;
      }
    }
    return null;
  }

  async function clickRowSmart(link) {
    const r = link.getBoundingClientRect();
    const cx = r.left + r.width / 2;
    const cy = r.top + r.height / 2;

    const base = {
      bubbles: true,
      cancelable: true,
      view: window,
      button: 0,
      buttons: 1,
      clientX: cx,
      clientY: cy
    };

    try {
      link.dispatchEvent(new PointerEvent('pointerdown', { ...base, pointerType: 'mouse', isPrimary: true, pointerId: 1 }));
      link.dispatchEvent(new MouseEvent('mousedown', base));
      link.dispatchEvent(new PointerEvent('pointerup', { ...base, pointerType: 'mouse', isPrimary: true, pointerId: 1, buttons: 0 }));
      link.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
      link.dispatchEvent(new MouseEvent('click', { ...base, buttons: 0 }));
    } catch (_) {
      link.dispatchEvent(new MouseEvent('mousedown', base));
      link.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
      link.dispatchEvent(new MouseEvent('click', { ...base, buttons: 0 }));
    }
  }

  async function clickRefreshInPanel() {
    const btn = document.querySelector('[command="refresh-work-item"]');
    if (!btn) return false;
    btn.click();
    return true;
  }

  async function openWorkItem(item, scroller, previousCommentIds = []) {
    await ensurePanelNotFullScreen();

    const link = await findRowLink(item.id, scroller);
    if (!link) throw new Error(`Не найдена строка для заявки ${item.id}`);

    link.scrollIntoView({ block: 'center' });
    await sleep(200);

    await clickRowSmart(link);

    await waitForCondition(() => {
      const cur = document.querySelector('.grid-row.grid-row-current');
      if (!cur) return { ok: false, diag: 'no grid-row-current' };
      const a = cur.querySelector('a.work-item-title-link');
      const m = (a?.getAttribute('href') || '').match(/\/edit\/(\d+)/);
      if (!m) return { ok: false, diag: 'current row without id' };
      if (m[1] !== String(item.id)) return { ok: false, diag: `current ${m[1]} != ${item.id}` };
      return { ok: true };
    }, { timeout: 5000, label: `row ${item.id} becomes current` });

    try {
      await waitForPanelLoaded(item.id, previousCommentIds, { timeout: 6000 });
    } catch (e1) {
      warn(`   Панель не обновилась за 6с (${e1.message}), жму "Обновить"...`);
      const ok = await clickRefreshInPanel();
      if (!ok) throw e1;
      await sleep(500);
      await waitForPanelLoaded(item.id, previousCommentIds, { timeout: PANEL_LOAD_TIMEOUT });
    }

    await ensurePanelNotFullScreen();
  }

  // ============ 8. ОДНА ЗАЯВКА ============
  async function processWorkItem(item, scroller, previousCommentIds) {
    await openWorkItem(item, scroller, previousCommentIds);
    const panel = getPanel();

    const { all: comments, latest } = await waitForComments(panel, { previousCommentIds });
    const expectedCount = getExpectedCommentCount(panel);
    const missing = expectedCount != null ? Math.max(0, expectedCount - comments.length) : 0;

    return {
      id: item.id,
      title: getTitle(panel),
      state: getState(panel),
      assignedTo: getAssignedTo(panel),
      area: getArea(panel),
      iteration: getIteration(panel),
      changedBy: getChangedBy(panel),
      commentsCount: comments.length,
      expectedComments: expectedCount,
      missingComments: missing,
      latestComment: latest ? latest.body : '',
      latestAuthor: latest ? latest.author : '',
      latestTime: latest ? latest.tsRaw : '',
      comments
    };
  }

  // ============ 9. ГРУППИРОВКА ============
  function groupByComment(report) {
    const grouped = {};
    const noComment = [];
    const failed = [];

    for (const r of report) {
      if (r.error) {
        failed.push({ id: r.id, got: 0, expected: r.expectedComments ?? null, error: r.error });
        continue;
      }

      const text = (r.latestComment || '').trim();
      const missing = r.missingComments || 0;

      if (missing > 0) {
        failed.push({
          id: r.id,
          got: r.commentsCount,
          expected: r.expectedComments,
          error: null
        });
        if (text) {
          if (!grouped[text]) grouped[text] = [];
          grouped[text].push(r.id);
        }
        continue;
      }

      if (!text) {
        noComment.push(r.id);
        continue;
      }

      if (!grouped[text]) grouped[text] = [];
      grouped[text].push(r.id);
    }

    for (const k of Object.keys(grouped)) {
      grouped[k].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
    }
    noComment.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));

    return { grouped, noComment, failed };
  }

  // ============ 10. ФОРМИРОВАНИЕ TXT ============
  function buildTxtReport({ grouped, noComment, failed }) {
    const lines = [];
    const stamp = new Date().toLocaleString('ru-RU');

    lines.push(`Отчёт по заявкам TFS`);
    lines.push(`Сформирован: ${stamp}`);
    lines.push('');

    lines.push('=== По комментариям ===');
    const entries = Object.entries(grouped);
    if (entries.length) {
      for (const [text, ids] of entries) {
        lines.push(`${ids.join(', ')} - ${text}`);
      }
    } else {
      lines.push('(нет)');
    }

    if (noComment.length) {
      lines.push('');
      lines.push(`=== Без комментариев (${noComment.length}) ===`);
      lines.push(noComment.join(', '));
    }

    if (failed.length) {
      lines.push('');
      lines.push(`=== Не удалось собрать полностью (${failed.length}) ===`);
      for (const f of failed) {
        lines.push(`${f.id} — собрано ${f.got}${f.expected != null ? ` из ${f.expected}` : ''}${f.error ? ` (${f.error})` : ''}`);
      }
    }

    return lines.join('\r\n');
  }

  // ============ 11. RETRY ПРОВАЛЕННЫХ ============
  async function retryFailed(report, itemsById, scroller) {
    const failedNow = report.filter(r => !r.error && r.missingComments > 0);
    if (!failedNow.length) return report;

    warn(`Пересобираю ${failedNow.length} заявок, где недобрали комментарии...`);
    const updated = new Map(report.map(r => [r.id, r]));

    for (const r of failedNow) {
      const it = itemsById[r.id];
      if (!it) continue;

      log(`   retry ${it.id} (было ${r.commentsCount}/${r.expectedComments})`);
      try {
        const fresh = await processWorkItem(it, scroller, r.comments.map(c => c.id).filter(Boolean).map(String));
        updated.set(it.id, fresh);
      } catch (e) {
        warn(`   retry ${it.id} не удался: ${e.message}`);
      }
      await sleep(DELAY_BETWEEN_ROWS);
    }

    return Array.from(updated.values());
  }

  // ============ 12. ГЛАВНЫЙ ЦИКЛ ============
  async function main() {
    const scroller = getScroller() || document.scrollingElement;
    log('Скролл-контейнер:', scroller);

    await scrollToTop(scroller);

    const items = await collectAllRows(scroller);
    if (!items.length) { warn('Заявок не найдено'); return; }

    const itemsById = Object.fromEntries(items.map(it => [it.id, it]));

    let report = [];
    let previousCommentIds = [];

    for (let i = 0; i < items.length; i++) {
      const it = items[i];
      log(`(${i + 1}/${items.length}) Открываю ${it.id} — ${it.title}`);

      try {
        const data = await processWorkItem(it, scroller, previousCommentIds);
        previousCommentIds = data.comments.map(c => c.id).filter(Boolean).map(String);
        report.push(data);

        const miss = data.missingComments > 0
          ? ` ⚠ недобор ${data.commentsCount}/${data.expectedComments}`
          : '';
        log(`   ✓ ${data.state || '?'} | ${data.assignedTo || '?'} | комм.: ${data.commentsCount}${miss}, свежий: "${(data.latestComment || '').slice(0, 60)}"`);
      } catch (e) {
        warn(`   ✗ Ошибка ${it.id}: ${e.message}`);
        report.push({
          id: it.id,
          title: it.title,
          error: e.message,
          comments: [],
          commentsCount: 0,
          expectedComments: null,
          missingComments: 0
        });
      }

      await sleep(DELAY_BETWEEN_ROWS);
    }

    // Retry проваленных
    if (RETRY_FAILED) {
      report = await retryFailed(report, itemsById, scroller);
    }

    // Группировка
    const groupedData = groupByComment(report);
    const { grouped, noComment, failed } = groupedData;

    // Консоль
    console.log('%c=== grouped (комментарий → [id]) ===', 'color:#0a0;font-weight:bold');
    console.dir(grouped);

    console.log('%c=== Без комментариев ===', 'color:#888;font-weight:bold');
    console.log(noComment);

    if (failed.length) {
      console.log('%c=== Не собрано полностью ===', 'color:#c00;font-weight:bold');
      console.table(failed);
    }

    console.log('%c=== Сгруппированные строки ===', 'color:#0a0;font-weight:bold');
    for (const [text, ids] of Object.entries(grouped)) {
      console.log(`${ids.join(', ')} - ${text}`);
    }

    // Глобальные переменные
    window.__tfsReport    = report;
    window.__tfsGrouped   = grouped;
    window.__tfsNoComment = noComment;
    window.__tfsFailed    = failed;
    window.__tfsTxt       = buildTxtReport(groupedData);

    // Скачиваем ОДИН файл — TXT
    const txt = window.__tfsTxt;
    const blob = new Blob(['\ufeff' + txt], { type: 'text/plain;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `tfs_report_${Date.now()}.txt`;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);

    log('Готово. TXT скачан. window.__tfsReport / __tfsGrouped / __tfsTxt');
  }

  main().catch(e => console.error('[TFS-Report] FATAL:', e));
})();