
var GCDUP = (function () {
  var CFG = {
    LIST_URL: '/pl/user/user/index',   // список пользователей в админке (постраничный)
    SORTS: ['email', '-email', 'name', '-name'], // порядки, в которых читается список (GetCourse отдаёт ~77 страниц в одном порядке)
    PER_PAGE: 100,                     // записей на странице (GetCourse разрешает от 5 до 100)
    MAX_PAGES: 1000,                   // защита от бесконечного перебора
    ONLY_ACTIVE: false,                // значение по умолчанию; на странице переключается галочкой
    USE_EMAIL: false,                   // false = искать дубли только по телефону
    DELAY_MS: 500,                     // пауза между страницами, чтобы не нагружать школу
    RETRY_DELAYS: [2000, 6000, 15000], // паузы перед повторами при сетевой ошибке
    EMPTY_RETRY_DELAYS: [5000, 15000, 30000], // паузы, если сервер вернул пустую страницу, а пользователей должно быть больше
    MIN_COMMON: 6,                     // символов подряд в email (до @)
    MAX_SHINGLE_USERS: 15,             // фрагмент у большего числа людей считаем шумом
    LS_DATA: 'gcdup_data_v2',
    LS_LOCK: 'gcdup_lock_v2'
  };
  var EMAIL_RE = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/;
  var PHONE_RE = /\+?\d[\d\s\-()]{8,}\d/g;

  function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } }
  function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
  function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }

  function normPhone(p) {
    var d = String(p || '').replace(/\D/g, '');
    return d.length < 10 ? '' : d.slice(-10);
  }
  var NON_ALNUM = new RegExp('[^a-z0-9' + String.fromCharCode(0x400) + '-' + String.fromCharCode(0x4FF) + ']', 'g');
  function emailLocal(e) {
    var s = String(e || '').toLowerCase(), at = s.indexOf('@');
    return (at >= 0 ? s.slice(0, at) : s).replace(/\+.*$/, '').replace(NON_ALNUM, '');
  }
  function findPhone(text) {
    var t = String(text || '')
      .replace(EMAIL_RE, ' ')
      .replace(/\d{4}-\d{2}-\d{2}/g, ' ').replace(/\d{1,2}\.\d{1,2}\.\d{2,4}/g, ' ')
      .replace(/\d{1,2}:\d{2}(:\d{2})?/g, ' ');
    var m = t.match(PHONE_RE) || [];
    for (var i = 0; i < m.length; i++) {
      var digits = m[i].replace(/\D/g, '').length;
      if (digits >= 10 && digits <= 15) return m[i].trim();
    }
    return '';
  }

  // Разбор одной страницы списка пользователей
  var activeOnly = false;
  var STATUS_WORDS = ['Активен', 'Деактивирован', 'Забанен', 'Приглашен', 'Приглашён', 'В базе'];
  function parseList(html) {
    var doc = new DOMParser().parseFromString(html, 'text/html');
    var seen = {}, out = [];
    out.skipped = [];
    doc.querySelectorAll('tr').forEach(function (tr) {
      var id = null, name = '';
      tr.querySelectorAll('a[href]').forEach(function (a) {
        if (id) return;
        var m = (a.getAttribute('href') || '').match(
          /\/user\/control\/user\/update\/id\/(\d+)|\/user\/control\/user\/update\?(?:[^#]*&)?id=(\d+)/);
        if (m) { id = m[1] || m[2]; }
      });
      if (id) {
        tr.querySelectorAll('a[href]').forEach(function (a) {
          if (!name && (a.getAttribute('href') || '').indexOf('/update/id/' + id) >= 0) name = (a.textContent || '').trim();
        });
      }
      if (!id || seen[id]) return;
      seen[id] = true;
      if (activeOnly) {   // страховка: если фильтр сервером не применился, чужие статусы отбрасываем здесь
        var sts = [];
        tr.querySelectorAll('span.label').forEach(function (s) {
          var t = (s.textContent || '').trim();
          if (STATUS_WORDS.indexOf(t) >= 0) sts.push(t);
        });
        if (sts.length && sts.indexOf('Активен') < 0) { out.skipped.push(id); return; }
      }
      var rowText = (tr.textContent || '').replace(/\s+/g, ' ');
      var em = rowText.match(EMAIL_RE);
      var phone = '';
      tr.querySelectorAll('a[href^="tel:"]').forEach(function (a) { if (!phone) phone = a.textContent.trim(); });
      if (!phone) {
        tr.querySelectorAll('td').forEach(function (td) { if (!phone) phone = findPhone(td.textContent); });
      }
      out.push({ id: id, name: name, email: em ? em[0] : '', phone: phone });
    });
    return out;
  }

  function findDuplicates(users) {
    var parent = {}, byId = {};
    function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
    function union(a, b) { var ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; }
    users.forEach(function (u) { parent[u.id] = u.id; byId[u.id] = u; u.reasons = []; });
    function addReason(u, t) { if (u.reasons.length < 4 && u.reasons.indexOf(t) < 0) u.reasons.push(t); }

    var byPhone = {};
    users.forEach(function (u) {
      var ph = normPhone(u.phone);
      if (ph) (byPhone[ph] = byPhone[ph] || []).push(u.id);
    });
    Object.keys(byPhone).forEach(function (ph) {
      var ids = byPhone[ph];
      if (ids.length < 2) return;
      ids.forEach(function (id) { addReason(byId[id], 'Совпадает телефон'); union(ids[0], id); });
    });

    var byShingle = {};
    users.forEach(function (u) {
      if (!CFG.USE_EMAIL) return;
      var loc = emailLocal(u.email);
      if (loc.length < CFG.MIN_COMMON) return;
      var seen = {};
      for (var i = 0; i + CFG.MIN_COMMON <= loc.length; i++) {
        var s = loc.substr(i, CFG.MIN_COMMON);
        if (seen[s]) continue;
        seen[s] = true;
        (byShingle[s] = byShingle[s] || []).push(u.id);
      }
    });
    Object.keys(byShingle).forEach(function (s) {
      var ids = byShingle[s];
      if (ids.length < 2 || ids.length > CFG.MAX_SHINGLE_USERS) return;
      ids.forEach(function (id) { addReason(byId[id], 'Email: общий фрагмент «' + s + '»'); union(ids[0], id); });
    });

    var map = {};
    users.forEach(function (u) { var r = find(u.id); (map[r] = map[r] || []).push(u); });
    var groups = [], count = 0;
    Object.keys(map).forEach(function (r) {
      var m = map[r];
      if (m.length < 2) return;
      count += m.length;
      groups.push({ size: m.length, members: m });
    });
    groups.sort(function (a, b) { return b.size - a.size; });
    return { count: count, groups: groups };
  }

  var ownsLock = false;
  window.addEventListener('pagehide', function () { if (ownsLock) lsSet(CFG.LS_LOCK, 0); });

  function load() { return lsGet(CFG.LS_DATA); }
  // Данные свежие, если не старше maxAgeMs и посчитаны с текущими настройками (смена USE_EMAIL = пересчёт)
  function isFresh(maxAgeMs) {
    var d = lsGet(CFG.LS_DATA);
    var max = d && (d.stop === 'error' || d.partial) ? Math.min(maxAgeMs, 5 * 60 * 1000) : maxAgeMs;
    return !!(d && d.ok && d.updatedAt && Date.now() - new Date(d.updatedAt).getTime() < max &&
      d.useEmail === !!CFG.USE_EMAIL);
  }
  function emit(d) {
    try { window.dispatchEvent(new CustomEvent('gcdup:update', { detail: d })); } catch (e) {}
  }

  // Сколько всего пользователей пишет сам GetCourse на странице списка (если нашли; может быть неточно)
  function findClaimedTotal(html) {
    var t = String(html).replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
      .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
    var m = t.match(/(?:Всего|Найдено|Итого)[^0-9]{0,25}(\d[\d ]{0,9}\d|\d)/i);
    if (!m) m = t.match(/из\s+(\d[\d ]{0,9}\d|\d)\s+запис/i);
    return m ? parseInt(m[1].replace(/\D/g, ''), 10) : 0;
  }

  function pageSummary(html) {
    var m = String(html).match(/class="summary"[^>]*>([\s\S]*?)<\/div>/i);
    return m ? m[1].replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 90) : '';
  }
  function pageTitle(html) {
    var m = String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
    return m ? m[1].replace(/\s+/g, ' ').trim().slice(0, 70) : '';
  }

  // Загрузка одной страницы с повторами при сетевых сбоях
  async function fetchPage(url, page) {
    var delays = CFG.RETRY_DELAYS, lastErr = null;
    for (var i = 0; i <= delays.length; i++) {
      try {
        var resp = await fetch(url, { credentials: 'same-origin' });
        if (resp.status === 429 || resp.status >= 500) throw new Error('HTTP ' + resp.status);
        if (!resp.ok) { var fe = new Error('Страница списка ' + page + ' вернула ' + resp.status); fe.fatal = true; throw fe; }
        return await resp.text();
      } catch (e) {
        if (e.fatal) throw e;
        lastErr = e;
        if (i < delays.length) await sleep(delays[i]);
      }
    }
    var ne = new Error('Сетевая ошибка (' + (lastErr && lastErr.message) + ') на странице ' + page + ' списка, адрес ' + url);
    ne.network = true;
    throw ne;
  }

  // Один проход по списку в заданном порядке сортировки: дописывает новых пользователей в общий набор st.
  async function runPass(sort, label, st, onProgress) {
    var info = { sort: sort || '', pages: 0, added: 0, stop: 'limit' }, emptyTry = 0, noNew = 0;
    for (var page = 1; page <= CFG.MAX_PAGES; page++) {
      var url = CFG.LIST_URL + (CFG.LIST_URL.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page + '&per-page=' + CFG.PER_PAGE +
        (sort ? '&sort=' + encodeURIComponent(sort) : '') + (activeOnly ? '&uc%5Bstatus%5D=active' : '');
      var html;
      try {
        html = await fetchPage(url, page);
      } catch (e) {
        // сбой не на самой первой странице: сохраняем прочитанное и помечаем обход как неполный
        if (e.network && (page > 1 || st.list.length)) { info.stop = 'error'; st.errMsg = e.message; st.errPage = page; return info; }
        throw e;
      }
      var rows = parseList(html);
      rows.skipped.forEach(function (k) { st.skippedIds[k] = 1; });
      st.diag.summary = pageSummary(html); st.diag.rows = rows.length; st.diag.len = html.length;
      st.diag.title = pageTitle(html); st.diag.lastPage = page; st.diag.sort = sort || '';
      if (page === 1 && !st.perPage) {
        if (!rows.length) throw new Error('NO_ROWS');
        st.perPage = rows.length;
        st.claimed = findClaimedTotal(html);
      }
      if (!rows.length) {
        // пустая страница, хотя пользователей должно быть больше: возможно, сервер временно отдал заглушку - ждём и повторяем
        if (st.claimed && (st.list.length + Object.keys(st.skippedIds).length) < st.claimed * 0.97 && emptyTry < CFG.EMPTY_RETRY_DELAYS.length && st.emptyTotal < 2 * CFG.EMPTY_RETRY_DELAYS.length) {
          await sleep(CFG.EMPTY_RETRY_DELAYS[emptyTry]); emptyTry++; st.emptyTotal++; st.diag.emptyRetries++; page--; continue;
        }
        info.stop = 'empty'; return info;
      }
      emptyTry = 0;
      var added = 0;
      rows.forEach(function (u) { if (!st.users[u.id]) { st.users[u.id] = 1; st.list.push(u); added++; } });
      if (added) { noNew = 0; info.added += added; info.pages = page; }
      else if (++noNew >= 3) { info.stop = 'repeat'; return info; }   // 3 страницы подряд без новых: глубже сервер не отдаёт
      if (onProgress) onProgress(page, st.list.length, label);
      lsSet(CFG.LS_LOCK, Date.now());
      await sleep(CFG.DELAY_MS);
    }
    return info;
  }

  function publish(st, passes, planned, partial) {
    var res = findDuplicates(st.list);
    var last = passes[passes.length - 1] || { stop: 'limit' };
    var data = {
      ok: true, updatedAt: new Date().toISOString(), total: st.list.length,
      withEmail: st.list.filter(function (u) { return u.email; }).length,
      withPhone: st.list.filter(function (u) { return normPhone(u.phone); }).length,
      pages: passes.reduce(function (n, p) { return n + p.pages; }, 0), perPage: st.perPage, stop: last.stop,
      claimed: st.claimed, useEmail: !!CFG.USE_EMAIL, onlyActive: activeOnly, skipped: Object.keys(st.skippedIds).length, error: st.errMsg, errorPage: st.errPage, diag: st.diag,
      passes: passes.slice(), passesPlanned: planned, partial: !!partial,
      count: res.count, groups: res.groups
    };
    lsSet(CFG.LS_DATA, data);
    emit(data);
    return data;
  }

  // Полный обход списка пользователей. Один обход одновременно (блокировка в localStorage).
  // GetCourse не отдаёт список глубже ~77 страниц, поэтому, если собрано меньше заявленного, читаем его
  // в других порядках сортировки и объединяем результат.
  async function scan(onProgress, opts) {
    var lock = lsGet(CFG.LS_LOCK);
    if (lock && Date.now() - lock < 90 * 1000) return { busy: true };
    lsSet(CFG.LS_LOCK, Date.now());
    ownsLock = true;
    activeOnly = opts && opts.onlyActive != null ? !!opts.onlyActive : !!CFG.ONLY_ACTIVE;
    try {
      var st = { users: {}, list: [], perPage: 0, claimed: 0, errMsg: '', errPage: 0, emptyTotal: 0,
        skippedIds: {}, diag: { summary: '', rows: 0, len: 0, title: '', emptyRetries: 0, lastPage: 0, sort: '' } };
      var passes = [], sorts = CFG.SORTS.length ? CFG.SORTS : [''], data = null;
      for (var i = 0; i < sorts.length; i++) {
        var label = 'проход ' + (i + 1) + ' из ' + sorts.length + (sorts[i] ? ' (' + sorts[i] + ')' : '');
        var info = await runPass(sorts[i], label, st, onProgress);
        passes.push(info);
        var enough = !st.claimed || (st.list.length + Object.keys(st.skippedIds).length) >= st.claimed * 0.97;   // не знаем общего числа -> одного прохода достаточно
        var more = i < sorts.length - 1 && !enough && info.stop !== 'error';
        data = publish(st, passes, sorts.length, more);
        if (!more) break;
      }
      return data;
    } finally {
      ownsLock = false;
      lsSet(CFG.LS_LOCK, 0);
    }
  }

  return { CFG: CFG, load: load, isFresh: isFresh, scan: scan, findClaimedTotal: findClaimedTotal, parseList: parseList, findDuplicates: findDuplicates, lsGet: lsGet };
})();

(function () {
  var PAGE_URL = '/dubli';                  // адрес страницы «Дубли» (Сайт -> Страницы)
  // Бейдж берёт число из последнего поиска. Сам поиск запускается только кнопкой на странице «Дубли».

  function setBadge(n) {
    document.querySelectorAll('.gcdup-badge').forEach(function (b) {
      b.textContent = n > 99 ? '99+' : String(n);
      b.style.display = n > 0 ? 'inline-flex' : 'none';
    });
  }
  function curCount() { var d = GCDUP.load(); return d && d.ok ? d.count : 0; }

  function inject() {
    var list = document.querySelector('.gc-account-user-submenu');
    if (!list || list.querySelector('.menu-item-dups')) return;
    var glue = list.querySelector('.menu-item-gluing');
    if (!glue) return;
    var li = document.createElement('li');
    li.className = 'menu-item-dups';
    li.innerHTML = '<a class="subitem-link" target="_self" href="' + PAGE_URL + '">Дубли' +
      '<span class="gcdup-badge" style="display:none;align-items:center;justify-content:center;' +
      'min-width:18px;height:18px;padding:0 5px;margin-left:6px;border-radius:9px;background:#e53935;' +
      'color:#fff;font:600 11px/18px Arial,sans-serif;box-sizing:border-box;vertical-align:middle;"></span></a>';
    glue.after(li);
    if (location.pathname.replace(/\/$/, '') === PAGE_URL) li.classList.add('active');
    setBadge(curCount());
  }

  window.addEventListener('gcdup:update', function (e) { setBadge((e.detail && e.detail.count) || 0); });
  window.addEventListener('storage', function (e) { if (e.key === GCDUP.CFG.LS_DATA) setBadge(curCount()); });

  function start() {
    inject();
    var t = null;
    new MutationObserver(function () { clearTimeout(t); t = setTimeout(inject, 200); })
      .observe(document.body, { childList: true, subtree: true });
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
})();
