/**
 * VisitorLens — Replay Page JS
 * Full visual session replay engine.
 * Ported from reference replay.php; adapted to use VSR REST API helpers.
 */
(function () {
  'use strict';

  if (typeof vlensAdmin === 'undefined') return;

  const shell = document.getElementById('vsr-replay-shell');
  if (!shell) return;

  // ── RECORDING-DISABLED NOTICE ──────────────────────────────────
  // One-click "turn on recording" for the banner shown when
  // vlens_recording_enabled is off. Saves via the settings REST endpoint.
  (function initEnableRecording() {
    const notice = document.getElementById('vsrRecNotice');
    const btn    = document.getElementById('vsrEnableRecordingBtn');
    if (!notice || !btn) return;

    const statusEl = document.getElementById('vsrEnableRecordingStatus');
    const label    = btn.querySelector('.vsr-rec-notice-btn-label');

    function setStatus(msg, color) {
      if (!statusEl) return;
      statusEl.textContent = msg;
      statusEl.style.color = color || '';
    }

    btn.addEventListener('click', async function () {
      btn.disabled = true;
      if (label) label.textContent = 'Turning on…';
      setStatus('', '');

      try {
        await VSR.post('settings', { recording_enabled: '1' });

        // Flip the banner to a success state so the user gets clear confirmation.
        notice.classList.add('vsr-rec-done');
        const title = notice.querySelector('.vsr-rec-notice-title');
        const text  = notice.querySelector('.vsr-rec-notice-text');
        const icon  = notice.querySelector('.vsr-rec-notice-icon');
        if (title) title.textContent = 'Session recording is on';
        if (text)  text.innerHTML = 'New visitor sessions will now be recorded and appear here automatically. You’re all set.';
        if (icon)  icon.innerHTML = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';

        const actions = notice.querySelector('.vsr-rec-notice-actions');
        if (actions) actions.remove();
      } catch (e) {
        btn.disabled = false;
        if (label) label.textContent = 'Turn on recording';
        setStatus('Couldn’t save — please try again.', '#b91c1c');
        console.error('VSR: failed to enable recording', e);
      }
    });
  })();

  // ── THEME ──────────────────────────────────────────────────────
  const saved = localStorage.getItem('vlens_replay_theme') || 'light';
  setTheme(saved);

  function setTheme(t) {
    shell.setAttribute('data-theme', t);
    localStorage.setItem('vlens_replay_theme', t);
    const btn = document.getElementById('vsrReplayThemeToggle');
    if (btn) btn.textContent = t === 'dark' ? '☀ Light' : '☾ Dark';
  }

  const themeBtn = document.getElementById('vsrReplayThemeToggle');
  if (themeBtn) {
    themeBtn.addEventListener('click', () => {
      setTheme(shell.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
    });
  }

  // ── SESSION LIST ───────────────────────────────────────────────
  let currentPage = 1;
  let filterTimer;

  const fmt = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');

  // Show/hide custom date row and wire its inputs
  const filterRange   = document.getElementById('filterRange');
  const customDateRow = document.getElementById('customDateRow');
  const customFrom    = document.getElementById('customFrom');
  const customTo      = document.getElementById('customTo');

  if (filterRange) {
    filterRange.addEventListener('change', () => {
      const v = filterRange.value;
      if (customDateRow) customDateRow.style.display = v === 'custom' ? 'flex' : 'none';
      if (v !== 'custom') loadSessions(1);
    });
  }
  if (customFrom) customFrom.addEventListener('change', () => { if (customFrom.value && customTo.value) loadSessions(1); });
  if (customTo)   customTo.addEventListener('change',   () => { if (customFrom.value && customTo.value) loadSessions(1); });

  // Filters panel is hidden by default (see .list-filters in replay.css);
  // this button just toggles it open/closed.
  const listFilterToggle = document.getElementById('listFilterToggle');
  const listFilters      = document.getElementById('listFilters');
  if (listFilterToggle && listFilters) {
    listFilterToggle.addEventListener('click', () => {
      const isOpen = listFilters.classList.toggle('open');
      listFilterToggle.classList.toggle('active', isOpen);
      listFilterToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
    });
  }

  async function loadSessions(page = 1) {
    currentPage = page;
    const list = document.getElementById('sessionList');
    if (list) list.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text3)">Loading sessions\u2026</div>';

    const uri       = (document.getElementById('uriFilter') || {}).value || '';
    const rangeVal  = (document.getElementById('filterRange') || {}).value || '';
    const deviceVal = (document.getElementById('filterDevice') || {}).value || '';

    let path = 'replay/sessions?page=' + page;
    if (uri.trim()) path += '&uri=' + encodeURIComponent(uri.trim());
    if (deviceVal) path += '&device=' + encodeURIComponent(deviceVal);

    // Calculate date range
    if (rangeVal) {
      const today = new Date();
      today.setHours(0, 0, 0, 0);
      let from, to;

      if (rangeVal === 'today') {
        from = today;
        to   = today;
      } else if (rangeVal === 'yesterday') {
        const y = new Date(today);
        y.setDate(y.getDate() - 1);
        from = y;
        to   = y;
      } else if (rangeVal === 'custom') {
        const fv = customFrom ? customFrom.value : '';
        const tv = customTo   ? customTo.value   : '';
        if (!fv || !tv) {
          // wait for both inputs
          if (list) list.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text3)">Pick a date range above…</div>';
          return;
        }
        path += '&date_from=' + encodeURIComponent(fv) + '&date_to=' + encodeURIComponent(tv);
      } else {
        const days = parseInt(rangeVal) || 7;
        from = new Date(today.getTime() - (days - 1) * 86400000);
        to   = today;
      }

      if (rangeVal !== 'custom') {
        path += '&date_from=' + fmt(from) + '&date_to=' + fmt(to);
      }
    }
    try {
      const d = await VSR.get(path);
      renderSessions(d.rows || [], d.total || 0, d.page || 1, d.limit || 30, d.total_ips || 0, d.ip_order || [], d.online_ips || [], d.bot_count || 0);
    } catch (e) {
      console.error('VSR Replay: loadSessions error', e);
      if (list) list.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text3)">\u26a0 Failed to load sessions — check browser console for details.</div>';
      const lc = document.getElementById('listCount');
      if (lc) lc.textContent = 'Error';
    }
  }

  // ── COUNTRY / OS / BROWSER / DEVICE ICON HELPERS ─────────────
  // All icons served from admin/images/{flags,operating-system,browser,device}/

  const _IMG_BASE = (vlensAdmin.pluginUrl || '') + 'admin/images/';

  const _COUNTRY_NAMES = {
    US:'United States',GB:'United Kingdom',DE:'Germany',FR:'France',IT:'Italy',
    ES:'Spain',CA:'Canada',AU:'Australia',JP:'Japan',KR:'South Korea',
    CN:'China',IN:'India',BR:'Brazil',MX:'Mexico',RU:'Russia',
    NL:'Netherlands',SE:'Sweden',NO:'Norway',DK:'Denmark',FI:'Finland',
    PL:'Poland',PT:'Portugal',BE:'Belgium',CH:'Switzerland',AT:'Austria',
    TR:'Turkey',SA:'Saudi Arabia',AE:'United Arab Emirates',ZA:'South Africa',
    NG:'Nigeria',EG:'Egypt',PK:'Pakistan',BD:'Bangladesh',ID:'Indonesia',
    TH:'Thailand',VN:'Vietnam',PH:'Philippines',MY:'Malaysia',SG:'Singapore',
    NZ:'New Zealand',AR:'Argentina',CO:'Colombia',CL:'Chile',PE:'Peru',
    UA:'Ukraine',CZ:'Czech Republic',HU:'Hungary',RO:'Romania',GR:'Greece',
    IL:'Israel',IR:'Iran',MA:'Morocco',TW:'Taiwan',HK:'Hong Kong',
    IE:'Ireland',SK:'Slovakia',HR:'Croatia',RS:'Serbia',BG:'Bulgaria',
    LT:'Lithuania',LV:'Latvia',EE:'Estonia',BY:'Belarus',MD:'Moldova',
  };

  function _countryName(cc) {
    return _COUNTRY_NAMES[(cc || '').toUpperCase()] || (cc || '').toUpperCase() || '';
  }

  function _qe(s) { return (s || '').replace(/"/g, '&quot;'); }

  function _countryFlagImg(cc) {
    if (!cc || cc.length !== 2) return '';
    const name = _countryName(cc);
    const src  = _IMG_BASE + 'flags/' + cc.toLowerCase() + '.svg';
    return '<img src="' + src + '" class="ip-group-flag" title="' + _qe(name) + '" alt="' + _qe(name) + '" width="20" height="14" onerror="this.style.display=\'none\'">';
  }

  // OS platform string → SVG filename in operating-system/
  const _OS_FILE = {
    'windows 10/11':'windows','windows 8.1':'windows','windows 7':'windows',
    'windows':'windows','android':'android','ios':'ios','ipad':'ipados',
    'macos':'mac_os','linux':'linux','chromeos':'chrome_os',
    'ubuntu':'ubuntu','debian':'debian','fedora':'fedora',
    'windows phone':'windows_phone','fire os':'fire_os','webos':'webos',
    'harmony os':'harmony_os','tizen':'tizen','freebsd':'freebsd',
  };

  function _osIcon(platform) {
    const key  = (platform || '').toLowerCase();
    const file = _OS_FILE[key] || 'unknown';
    const t    = _qe(platform || 'Unknown OS');
    const src  = _IMG_BASE + 'operating-system/' + file + '.svg';
    return '<img src="' + src + '" class="vsr-ipg-icon vsr-ipg-icon--os" title="' + t + '" alt="' + t + '" width="15" height="15" onerror="this.style.display=\'none\'">';
  }

  // Browser name → SVG filename in browser/
  const _BR_FILE = {
    'chrome':'chrome','chrome mobile':'chrome_mobile','chromium':'chromium',
    'firefox':'firefox','firefox mobile':'firefox_mobile',
    'safari':'safari','mobile safari':'mobile_safari',
    'edge':'edge','internet explorer':'internet_explorer',
    'opera':'opera','opera mobile':'opera_mobile','opera gx':'opera_gx',
    'brave':'brave','vivaldi':'vivaldi','samsung browser':'samsung_browser',
    'samsung internet':'samsung_internet','yandex browser':'yandex_browser',
    'duckduckgo':'duckduckgo','uc browser':'uc_browser','maxthon':'maxthon',
    'tor browser':'tor_browser','waterfox':'waterfox','midori':'midori',
    'huawei browser':'huawei_browser','miui browser':'miui_browser',
    'mi browser':'mi_browser','wechat':'wechat','instagram':'instagram',
    'facebook':'facebook','twitter':'twitter','line':'line',
  };

  function _browserIcon(browser) {
    const key  = (browser || '').toLowerCase();
    const file = _BR_FILE[key] || 'unknown';
    const t    = _qe(browser || 'Unknown Browser');
    const src  = _IMG_BASE + 'browser/' + file + '.svg';
    return '<img src="' + src + '" class="vsr-ipg-icon vsr-ipg-icon--browser" title="' + t + '" alt="' + t + '" width="15" height="15" onerror="this.style.display=\'none\'">';
  }

  // Device type from viewport width → SVG filename in device/
  function _deviceIcon(vpW) {
    const w = parseInt(vpW) || 1280;
    let file, title;
    if (w < 768)       { file = 'smartphone'; title = 'Mobile';  }
    else if (w < 1024) { file = 'tablet';     title = 'Tablet';  }
    else               { file = 'desktop';    title = 'Desktop'; }
    const src = _IMG_BASE + 'device/' + file + '.svg';
    return '<img src="' + src + '" class="vsr-ipg-icon vsr-ipg-icon--device" title="' + title + '" alt="' + title + '" width="15" height="15" onerror="this.style.display=\'none\'">';
  }

  // ── VISIT GROUPING ─────────────────────────────────────────────
  // A "visit" = consecutive page views from the same IP where the
  // gap between pages is under 30 minutes. Each visit is one row
  // in the ip-group body.
  const VISIT_GAP_MS = 30 * 60 * 1000;

  function groupIntoVisits(sessions) {
    if (!sessions || !sessions.length) return [];
    const sorted = [...sessions].sort(
      (a, b) => new Date(a.started_at || 0) - new Date(b.started_at || 0)
    );
    const visits = [];
    let cur = null;
    let curStartMs = 0;
    for (const s of sorted) {
      const startMs = s.started_at ? new Date(s.started_at).getTime() : 0;
      if (!cur) {
        cur = [s];
        curStartMs = startMs;
      } else {
        // Gap is measured from the visit's FIRST page, not the previous page's end.
        // This bounds each visit to a strict 30-minute window rather than allowing
        // an unbounded chain where every consecutive page is just under 30 min apart.
        if (startMs - curStartMs > VISIT_GAP_MS) {
          visits.push(cur);
          cur = [s];
          curStartMs = startMs;
        } else {
          cur.push(s);
        }
      }
    }
    if (cur) visits.push(cur);
    return visits.reverse(); // most recent first
  }

  function renderVisitRow(pages) {
    const first    = pages[0];
    const last     = pages[pages.length - 1];
    const ids      = pages.map(p => p.id).join(',');
    const firstId  = first.id;
    const entryUri = first.page_uri || '/';
    const exitUri  = last.page_uri  || '/';
    const showExit = exitUri !== entryUri;
    const startMs  = first.started_at ? new Date(first.started_at).getTime() : 0;
    const endMs    = last.ended_at
      ? new Date(last.ended_at).getTime()
      : (last.started_at ? new Date(last.started_at).getTime() + (parseInt(last.duration_ms) || 0) : 0);
    const dur = endMs > startMs ? VSR.fmtDur(endMs - startMs)
              : (first.duration_ms ? VSR.fmtDur(first.duration_ms) : '—');
    const totalClicks = pages.reduce((n, p) => n + (parseInt(p.click_count) || 0), 0);
    const totalRage   = pages.reduce((n, p) => n + (parseInt(p.rage_clicks) || 0), 0);
    const faviconUrl  = vlensAdmin.siteUrl + '/favicon.ico';

    let h = '<div class="visit-row" data-id="' + firstId + '" data-ids="' + esc(ids) + '" onclick="vsrReplay.selectSession(' + firstId + ',this,\'' + esc(ids) + '\')">';
    h += '<div class="vr-top">';
    h += '<img class="sr-favicon" src="' + esc(faviconUrl) + '" onerror="this.style.display=\'none\'" alt="">';
    h += '<div class="vr-route">';
    h += '<span class="vr-entry" title="' + esc(entryUri) + '">' + esc(entryUri) + '</span>';
    if (showExit) {
      h += '<span class="vr-arrow">→</span>';
      h += '<span class="vr-exit" title="' + esc(exitUri) + '">' + esc(exitUri) + '</span>';
    }
    h += '</div>';
    h += '<div class="sr-time">' + VSR.fmtTime(first.started_at) + '</div>';
    h += '</div>';
    h += '<div class="sr-meta">';
    if (pages.length > 1) {
      h += '<span class="sr-badge badge-pages">' + pages.length + ' pages</span>';
    }
    h += '<span class="sr-badge badge-dur">' + dur + '</span>';
    h += '<span class="sr-badge badge-click">' + totalClicks + ' clicks</span>';
    if (totalRage > 0) {
      h += '<span class="sr-badge badge-rage">' + totalRage + ' rage</span>';
    }
    h += '<button class="vr-insights-btn" title="Session insights" onclick="event.stopPropagation();vsrReplay.showVisitInsights(\'' + esc(ids) + '\',this)">';
    h += '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> Insights';
    h += '</button>';
    h += '</div>';
    h += '</div>';
    return h;
  }

  function renderBotBanner(botCount) {
    const banner = document.getElementById('botBanner');
    if (!banner) return;
    if (!botCount) { banner.style.display = 'none'; return; }
    banner.style.display = 'flex';
    banner.innerHTML =
      '<span class="bot-banner-icon">🤖</span>' +
      '<span class="bot-banner-text">' +
        '<strong>' + botCount + ' bot' + (botCount !== 1 ? 's' : '') + '</strong> detected &amp; filtered — no recording' +
      '</span>' +
      '<span class="bot-banner-tip" title="Bots match known crawler/spider user-agents and are tracked for stats but never recorded.">?</span>';
  }

  function renderSessions(rows, total, page, limit, totalIps, ipOrder, onlineIps, botCount) {
    document.getElementById('listCount').textContent = total.toLocaleString() + ' page views';
    renderBotBanner(botCount || 0);
    const list = document.getElementById('sessionList');

    if (!rows.length) {
      list.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text3)">No sessions found</div>';
      updatePagination(0, 1, 1);
      return;
    }

    // Group by IP
    const groups = {};
    rows.forEach(s => {
      const ip = s.ip_raw || 'Unknown';
      if (!groups[ip]) groups[ip] = [];
      groups[ip].push(s);
    });

    window._vsrSessionGroups = groups;

    const orderedIps = ipOrder.length ? ipOrder : Object.keys(groups);
    let html = '';

    orderedIps.forEach(ip => {
      const sessions = groups[ip];
      if (!sessions || !sessions.length) return;

      const visits   = groupIntoVisits(sessions);
      const cnt      = visits.length;
      const totalMs  = sessions.reduce((sum, s) => sum + (parseInt(s.duration_ms) || 0), 0);
      const isOnline = onlineIps.includes(ip);
      const onlineDot = isOnline ? '<span class="online-dot-ip" title="Online now"></span>' : '';
      const lastRec  = getIpLastRec(sessions);

      // Dominant platform, browser, country, viewport across all sessions for this IP
      const _pMap = {}, _bMap = {}, _cMap = {}, _vMap = {};
      sessions.forEach(s => {
        if (s.platform)     _pMap[s.platform]     = (_pMap[s.platform]     || 0) + 1;
        if (s.browser)      _bMap[s.browser]       = (_bMap[s.browser]      || 0) + 1;
        if (s.country_code) _cMap[s.country_code]  = (_cMap[s.country_code] || 0) + 1;
        const vw = parseInt(s.viewport_w) || 1280;
        _vMap[vw] = (_vMap[vw] || 0) + 1;
      });
      const topPlatform = Object.entries(_pMap).sort((a,b)=>b[1]-a[1])[0]?.[0] || '';
      const topBrowser  = Object.entries(_bMap).sort((a,b)=>b[1]-a[1])[0]?.[0]  || '';
      const topCountry  = Object.entries(_cMap).sort((a,b)=>b[1]-a[1])[0]?.[0]  || '';
      const topVpW      = Object.entries(_vMap).sort((a,b)=>b[1]-a[1])[0]?.[0]  || 1280;

      html += '<div class="ip-group" data-ip="' + esc(ip) + '">';
      html += '<div class="ip-group-header" onclick="vsrReplay.toggleIpGroup(this)">';
      html += '<span class="ip-group-arrow">&#9654;</span>';
      const userSession = sessions.find(s => s.wp_user_id && +s.wp_user_id > 0);
      html += '<div class="ip-group-main">';

      // Row 1 — identity: who/what, plus live status and total time on site.
      html += '<div class="ip-group-row1">';
      html += _countryFlagImg(topCountry);
      if (topPlatform) html += _osIcon(topPlatform);
      if (topBrowser)  html += _browserIcon(topBrowser);
      html += _deviceIcon(topVpW);
      html += '<span class="ip-group-ip" title="' + esc(ip) + '">' + esc(maskIp(ip)) + '</span>';
      if (userSession) {
        const editUrl = vlensAdmin.adminUrl + 'user-edit.php?user_id=' + userSession.wp_user_id;
        const displayName = userSession.wp_display_name || 'User #' + userSession.wp_user_id;
        html += '<a class="ip-group-user" href="' + esc(editUrl) + '" target="_blank" data-user-id="' + esc(userSession.wp_user_id) + '" title="' + esc(displayName) + '" onclick="event.stopPropagation()">' + esc(displayName) + '</a>';
      }
      html += '<span class="ip-group-row1-spacer"></span>';
      html += onlineDot;
      html += '<span class="ip-group-time">' + VSR.fmtDur(totalMs) + '</span>';
      html += '</div>';

      // Row 2 — meta + actions: last seen, visit count, and controls.
      html += '<div class="ip-group-row2">';
      html += '<span class="ip-group-last-rec" title="' + esc(lastRec.absolute) + '">';
      html += '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
      html += esc(lastRec.relative) + '</span>';
      html += '<span class="ip-group-row2-spacer"></span>';
      html += '<span class="ip-group-count">' + cnt + ' visit' + (cnt !== 1 ? 's' : '') + '</span>';
      html += '<button class="ip-group-play" title="Play all sessions for this IP" onclick="event.stopPropagation();vsrReplay.playIpGroup(\'' + esc(ip) + '\',this)">';
      html += '<svg width="10" height="10" fill="currentColor" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
      html += '</button>';
      html += '<button class="ip-group-insights" title="View behavioral insights" onclick="event.stopPropagation();vsrReplay.showIpInsights(\'' + esc(ip) + '\',this)">';
      html += '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
      html += '</button>';
      html += '<button class="ip-group-del" title="Delete all sessions for this IP" onclick="event.stopPropagation();vsrReplay.deleteIpGroup(\'' + esc(ip) + '\',this)">';
      html += '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
      html += '</button>';
      html += '</div>';

      html += '</div>'; // .ip-group-main
      html += '</div>'; // .ip-group-header
      html += '<div class="ip-group-body">';

      visits.forEach(pages => { html += renderVisitRow(pages); });

      html += '</div></div>';
    });

    list.innerHTML = html;
    updatePagination(totalIps, page, limit);
  }

  function updatePagination(totalIps, page, limit) {
    const totalPages = Math.max(1, Math.ceil(totalIps / limit));
    const prev = document.getElementById('pgPrev');
    const next = document.getElementById('pgNext');
    const info = document.getElementById('pgInfo');
    if (prev) prev.disabled = page <= 1;
    if (next) next.disabled = page >= totalPages;
    if (info) info.textContent = 'Page ' + page + ' of ' + totalPages;
  }

  // Returns { relative, absolute } for the most recent activity across a
  // group's sessions. `relative` is the compact glance-friendly label shown
  // in the row ("Just now" / "12m ago" / "3h ago" / "31 Jul"); `absolute` is
  // the full timestamp (with seconds) meant for the title="" tooltip.
  function getIpLastRec(sessions) {
    try {
      let latestMs = 0;
      sessions.forEach(s => {
        let endMs = s.ended_at ? new Date(s.ended_at).getTime() : 0;
        if (!endMs && s.started_at) {
          endMs = new Date(s.started_at).getTime() + (parseInt(s.duration_ms) || 0);
        }
        if (endMs > latestMs) latestMs = endMs;
      });
      if (!latestMs) return { relative: '—', absolute: '' };

      const absolute = new Date(latestMs).toLocaleString('en-GB', {
        day: 'numeric', month: 'short', year: 'numeric',
        hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true
      });

      const diffSec = Math.max(0, Math.floor((Date.now() - latestMs) / 1000));
      let relative;
      if (diffSec < 45)          relative = 'Just now';
      else if (diffSec < 3600)   relative = Math.floor(diffSec / 60) + 'm ago';
      else if (diffSec < 86400)  relative = Math.floor(diffSec / 3600) + 'h ago';
      else if (diffSec < 604800) relative = Math.floor(diffSec / 86400) + 'd ago';
      else {
        relative = new Date(latestMs).toLocaleString('en-GB', { day: 'numeric', month: 'short' });
      }
      return { relative, absolute };
    } catch (e) { return { relative: '—', absolute: '' }; }
  }

  document.getElementById('pgPrev').addEventListener('click', () => loadSessions(currentPage - 1));
  document.getElementById('pgNext').addEventListener('click', () => loadSessions(currentPage + 1));
  document.getElementById('uriFilter').addEventListener('input', () => {
    clearTimeout(filterTimer);
    filterTimer = setTimeout(() => loadSessions(1), 400);
  });
  document.getElementById('filterDevice').addEventListener('change', () => loadSessions(1));

  // ── IP GROUP ACTIONS ───────────────────────────────────────────
  function toggleIpGroup(header) {
    const group = header.closest('.ip-group') || header.parentElement;
    const body  = group.querySelector('.ip-group-body');
    const arrow = group.querySelector('.ip-group-arrow');
    const isOpen = group.classList.contains('expanded');
    body.style.display = isOpen ? 'none' : 'block';
    arrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
    group.classList.toggle('expanded', !isOpen);
  }

  async function deleteIpGroup(ip, btn) {
    if (!confirm('Delete all sessions for IP ' + maskIp(ip) + '?')) return;
    btn.disabled = true;
    try {
      const d = await VSR.del('replay/delete-ip?ip=' + encodeURIComponent(ip));
      if (d.ok) loadSessions(currentPage);
    } catch (e) {
      alert('Delete failed');
      btn.disabled = false;
    }
  }

  // ── IP PLAYLIST ────────────────────────────────────────────────
  let ipPlaylist    = [];
  let ipPlaylistIdx = -1;
  let ipPlayBtn     = null;

  async function playIpGroup(ip, btn) {
    if (ipPlayBtn === btn && ipPlaylist.length) { stopIpPlaylist(); return; }
    stopIpPlaylist();
    ipPlayBtn = btn;
    btn.classList.add('playing');
    btn.title = 'Stop playlist';
    btn.innerHTML = '<svg width="10" height="10" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';

    const group = btn.closest('.ip-group');
    let sessionsForIp = (window._vsrSessionGroups && window._vsrSessionGroups[ip]) || [];

    if (!sessionsForIp.length) {
      const rows = Array.from(group.querySelectorAll('.visit-row[data-id]'));
      ipPlaylist = rows.map(r => parseInt(r.dataset.id)).sort((a, b) => a - b);
    } else {
      const sorted = [...sessionsForIp].sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
      const mostRecent = sorted[0];
      const mostRecentDate = mostRecent.started_at ? new Date(mostRecent.started_at).toDateString() : null;
      const dayGroup = mostRecentDate
        ? sorted.filter(s => s.started_at && new Date(s.started_at).toDateString() === mostRecentDate)
        : sorted;
      dayGroup.sort((a, b) => new Date(a.started_at) - new Date(b.started_at));
      ipPlaylist = dayGroup.map(s => parseInt(s.id));
    }

    if (!ipPlaylist.length) { stopIpPlaylist(); return; }

    const header = group.querySelector('.ip-group-header');
    if (!group.classList.contains('expanded')) toggleIpGroup(header);

    ipPlaylistIdx = 0;
    await playIpPlaylistSession();
  }

  async function playIpPlaylistSession() {
    if (ipPlaylistIdx >= ipPlaylist.length) { stopIpPlaylist(); return; }
    const sid = ipPlaylist[ipPlaylistIdx];
    const row = document.querySelector('.visit-row[data-id="' + sid + '"]');
    await window.vsrReplay.selectSession(sid, row);
    play();
    waitForSessionEnd();
  }

  function waitForSessionEnd() {
    const check = setInterval(() => {
      if (!ipPlaylist.length) { clearInterval(check); return; }
      if (!playing && currentT >= getMaxT() - 50) {
        clearInterval(check);
        if (ipPlaylistIdx + 1 < ipPlaylist.length) {
          const nextIdx = ipPlaylistIdx + 1;
          const trans = document.getElementById('sessionTransition');
          document.getElementById('stLabel').textContent = 'Session ' + (nextIdx + 1) + ' of ' + ipPlaylist.length;
          document.getElementById('stSub').textContent = 'Loading next session…';
          trans.classList.add('show');
          setTimeout(async () => {
            trans.classList.remove('show');
            ipPlaylistIdx = nextIdx;
            await playIpPlaylistSession();
          }, 1800);
        } else {
          stopIpPlaylist();
        }
      }
    }, 300);
    window._vsrPlaylistCheck = check;
  }

  function stopIpPlaylist() {
    ipPlaylist = [];
    ipPlaylistIdx = -1;
    if (window._vsrPlaylistCheck) { clearInterval(window._vsrPlaylistCheck); window._vsrPlaylistCheck = null; }
    if (ipPlayBtn) {
      ipPlayBtn.classList.remove('playing');
      ipPlayBtn.title = 'Play all sessions for this IP';
      ipPlayBtn.innerHTML = '<svg width="10" height="10" fill="currentColor" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
      ipPlayBtn = null;
    }
    pause();
  }

  // ── REPLAY ENGINE ──────────────────────────────────────────────
  let events             = [];
  let session            = null;
  let pageSegments       = [];
  let initialPageMeta    = null;
  let sessionAggregate   = null;
  let currentPageDims    = null;
  let playing            = false;
  let speed            = 2;
  let currentT         = 0;
  let lastRealTime     = 0;
  let rafId            = null;
  let logCollapsed     = true;
  let lastClickEventIdx = -1;
  let lastAppliedIdx   = -1;
  let currentSid       = null;
  const cursor      = document.getElementById('cursor');
  const vpFrame     = document.getElementById('vpFrame');
  const vpScaler    = document.getElementById('vpScaler');
  const replayView  = document.getElementById('replayView');
  const scrollInd   = document.getElementById('scrollIndicator');
  const replayPanel = document.getElementById('replayPanel');
  const eventLog    = document.getElementById('eventLog');

  async function selectSession(id, el, idsStr) {
    shell.querySelectorAll('.visit-row').forEach(r => r.classList.remove('active'));
    if (el) el.classList.add('active');
    stopPlayback();
    currentSid = id;
    const newTabBtnEl = document.getElementById('newTabBtn');
    if (newTabBtnEl) newTabBtnEl.style.display = '';

    try {
      const idList = idsStr
        ? String(idsStr).split(',').map(Number).filter(Boolean)
        : [id];

      pageSegments     = [];
      sessionAggregate = null;
      currentPageDims  = null;

      if (idList.length <= 1) {
        const d = await VSR.get('replay/events/' + id);
        session = d.session;
        events  = d.events || [];
      } else {
        const results = await Promise.all(idList.map(sid => VSR.get('replay/events/' + sid)));
        results.sort((a, b) => new Date(a.session.started_at) - new Date(b.session.started_at));

        const firstStartMs = new Date(results[0].session.started_at).getTime();
        const merged = [];

        results.forEach((r, i) => {
          const offset = new Date(r.session.started_at).getTime() - firstStartMs;
          pageSegments.push({ session: r.session, offset });

          if (i > 0) {
            merged.push({
              t:    offset,
              type: 'page_change',
              meta: JSON.stringify({
                uri:        r.session.page_uri,
                viewport_w: r.session.viewport_w,
                viewport_h: r.session.viewport_h,
                page_h:     r.session.page_h,
              }),
            });
          }

          (r.events || []).forEach(e => merged.push(Object.assign({}, e, { t: e.t + offset })));
        });

        merged.sort((a, b) => a.t - b.t);

        const lastSeg = results[results.length - 1];
        session = Object.assign({}, results[0].session, {
          duration_ms: (new Date(lastSeg.session.started_at).getTime() - firstStartMs) +
                       (parseInt(lastSeg.session.duration_ms) || 0),
        });
        events = merged;

        sessionAggregate = {
          viewport_w:   results[0].session.viewport_w,
          viewport_h:   results[0].session.viewport_h,
          click_count:  results.reduce((n, r) => n + (parseInt(r.session.click_count)  || 0), 0),
          scroll_count: results.reduce((n, r) => n + (parseInt(r.session.scroll_count) || 0), 0),
        };
      }

      initialPageMeta = {
        uri:        session.page_uri,
        viewport_w: session.viewport_w,
        viewport_h: session.viewport_h,
        page_h:     session.page_h,
      };

      document.getElementById('replayEmpty').style.display = 'none';
      const ui = document.getElementById('replayUI');
      ui.style.display = 'flex';
      replayPanel.classList.add('vsr-mob-open');

      setupViewport();
      buildEventMarkers();
      renderLogAll();
      updateMeta();
      currentT = 0;
      lastClickEventIdx = -1;
      lastAppliedIdx    = -1;
      updateProgress();
      applyCursorToT(0);
    } catch (e) {
      console.error('VSR Replay: selectSession error', e);
    }
  }

  // Reserved space for the event-log overlay (only while it's actually
  // open — it's hidden by default), plus a fixed margin on every side so
  // the framed device never touches the stage edges (was the cause of the
  // "full-height pen" look on tall mobile recordings: the box was
  // stretched edge-to-edge vertically with no room to breathe, and never
  // centered vertically either). On desktop the log is a right sidebar
  // (reserve width); on the mobile-admin sheet it's still a bottom overlay
  // (reserve height) — matches the .event-log / .vsr-is-mobile .event-log
  // CSS in replay.css.
  const VP_STAGE_PAD      = 28;
  const VP_LOG_RESERVE_W  = 300;
  const VP_LOG_RESERVE_H  = 160 + 32;
  // Keep the device frame at a consistent, phone-like scale regardless of
  // how big or small the stage happens to be — never blow it up to near
  // full-size on a huge window, never shrink it to an illegible sliver on
  // a small one.
  const VP_SCALE_MAX    = 0.85;
  const VP_SCALE_MIN    = 0.35;

  // The device frame is always exactly one viewport (vw x vh) — never the
  // full scrollable page — so it always fits within the stage and can
  // always be centered on both axes.
  function computeViewportGeometry(vw, vh) {
    const logOpen  = eventLog && !eventLog.classList.contains('hidden');
    const isMobile = shell.classList.contains('vsr-is-mobile');
    const logReserveW = (logOpen && !isMobile) ? VP_LOG_RESERVE_W : 0;
    const logReserveH = (logOpen && isMobile)  ? VP_LOG_RESERVE_H : 0;
    const availW = Math.max(100, (replayView.offsetWidth  || 900) - logReserveW - VP_STAGE_PAD * 2);
    const availH = Math.max(100, (replayView.offsetHeight || 600) - logReserveH - VP_STAGE_PAD * 2);
    const fitScale = Math.min(availW / vw, availH / vh, 1);
    const scale  = Math.max(VP_SCALE_MIN, Math.min(VP_SCALE_MAX, fitScale));
    const left   = VP_STAGE_PAD + Math.max(0, (availW - vw * scale) / 2);
    const top    = VP_STAGE_PAD + Math.max(0, (availH - vh * scale) / 2);
    return { scale, left, top };
  }

  // Same-origin only — silently leaves the iframe's native scrollbar in
  // place (still functionally correct, just a minor cosmetic miss) if the
  // recorded page ever turns out to be cross-origin.
  function hideIframeScrollbar(iframeEl) {
    try {
      const doc = iframeEl.contentDocument;
      if (!doc || !doc.head) return;
      const style = doc.createElement('style');
      style.textContent = 'html,body{scrollbar-width:none!important}' +
        'html::-webkit-scrollbar,body::-webkit-scrollbar{display:none!important}';
      doc.head.appendChild(style);
    } catch (_) { /* cross-origin */ }
  }

  function setupViewport() {
    const vw = parseInt(session.viewport_w) || 1280;
    const vh = parseInt(session.viewport_h) || 800;
    const ph = parseInt(session.page_h)     || vh; // used by the scroll-position indicator only

    // The frame is the real device viewport, not the full scrollable page —
    // the recorded page scrolls for real inside it (see applyCursorToT),
    // just like it did for the actual visitor.
    vpFrame.style.width  = vw + 'px';
    vpFrame.style.height = vh + 'px';

    // Remove previous iframe
    const old = document.getElementById('replayIframe');
    if (old) old.remove();

    const iframe = document.createElement('iframe');
    iframe.id = 'replayIframe';
    const uri = session.page_uri || '/';
    const sep = uri.includes('?') ? '&' : '?';
    iframe.src = uri + sep + 'vs_replay=1';
    iframe.style.cssText = 'position:absolute;top:0;left:0;width:' + vw + 'px;height:' + vh + 'px;border:none;pointer-events:none;z-index:0;background:#fff';
    iframe.addEventListener('load', () => hideIframeScrollbar(iframe));

    iframe.addEventListener('error', () => {
      iframe.style.display = 'none';
      const fb = document.createElement('div');
      fb.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#888;font-size:13px;z-index:0;background:#f5f5f7';
      fb.textContent = 'Page cannot be embedded — cursor replay only';
      vpFrame.insertBefore(fb, vpFrame.firstChild);
    });

    vpFrame.insertBefore(iframe, vpFrame.firstChild);

    const { scale, left, top } = computeViewportGeometry(vw, vh);
    vpScaler.style.transform = 'scale(' + scale + ')';
    vpScaler.style.width  = vw + 'px';
    vpScaler.style.height = vh + 'px';
    replayView.style.overflow = 'hidden';
    vpScaler.style.left = left + 'px';
    vpScaler.style.top  = top + 'px';

    cursor.style.display = 'block';
    scrollInd.style.height = Math.max(20, Math.round(vh / ph * 100)) + '%';
    scrollInd.style.top    = '0px';
  }

  function buildEventMarkers() {
    const maxT = getMaxT();
    const wrap = document.getElementById('eventMarkers');
    wrap.innerHTML = '';
    events.forEach((e, i) => {
      if (!['click', 'rage_click', 'scroll', 'input', 'dead_click', 'u_turn', 'iframe_click', 'touch', 'broken_resource', 'mutation'].includes(e.type)) return;
      const pct = maxT > 0 ? (e.t / maxT * 100) : 0;
      const m   = document.createElement('div');
      m.className = 'ev-marker ' + e.type;
      m.style.left = pct + '%';
      if (['click', 'rage_click', 'dead_click', 'u_turn', 'iframe_click', 'touch', 'broken_resource', 'mutation'].includes(e.type)) {
        m.title = e.type.replace('_', ' ') + ' @ ' + fmtMs(e.t);
        m.addEventListener('click', ev => { ev.stopPropagation(); seekToEvent(i); });
      }
      wrap.appendChild(m);
    });
  }

  // Jump to next click event from current time
  function jumpToNextClick() {
    if (!events.length) return;
    for (let i = 0; i < events.length; i++) {
      if ((events[i].type === 'click' || events[i].type === 'rage_click') && events[i].t > currentT + 50) {
        seekToEvent(i);
        return;
      }
    }
    // Wrap around to first click
    for (let i = 0; i < events.length; i++) {
      if (events[i].type === 'click' || events[i].type === 'rage_click') {
        seekToEvent(i);
        return;
      }
    }
  }

  function netStatusClass(e) {
    if (e.type !== 'network') return '';
    try {
      var m = JSON.parse(e.meta || '{}');
      var st = m.status || 0;
      return st >= 200 && st < 300 ? ' net-ok' : (st >= 400 ? ' net-err' : ' net-other');
    } catch (_) { return ''; }
  }

  function renderLogAll() {
    const log = document.getElementById('logBody');
    log.innerHTML = events.map((e, i) => {
      return '<div class="log-row' + netStatusClass(e) + '" id="lr' + i + '" data-i="' + i + '">' +
        '<span class="log-t">' + fmtMs(e.t) + '</span>' +
        '<span class="log-type ' + esc(e.type) + '">' + esc(e.type) + '</span>' +
        '<span class="log-detail">' + esc(eventDetail(e)) + '</span>' +
        '</div>';
    }).join('');
    log.querySelectorAll('.log-row').forEach(row => {
      row.addEventListener('click', () => seekToEvent(+row.dataset.i));
    });
  }

  function eventDetail(e) {
    switch (e.type) {
      case 'click': case 'rage_click':
        return [(e.tag || ''), (e.el_text || ''), e.href ? '→' + e.href : '', '(' + (e.x || 0) + ',' + (e.y || 0) + ')'].join(' ').trim();
      case 'dead_click':
        return '⊘ ' + [(e.tag || ''), (e.el_text || ''), '(' + (e.x || 0) + ',' + (e.y || 0) + ')'].join(' ').trim();
      case 'u_turn':
        try { var m = JSON.parse(e.meta || '{}'); return '↩ ' + (m.from_url || '') + ' → ' + (m.to_url || '') + ' (' + (m.time_on_page_ms || 0) + 'ms)'; } catch (_) { return '↩ u-turn'; }
      case 'scroll':    return 'scrollY: ' + (e.scroll_y || 0) + 'px';
      case 'input':     return (e.tag || '') + ' name="' + (e.input_name || '') + '" type="' + (e.input_type || '') + '"';
      case 'focus': case 'blur': return (e.tag || '') + ' #' + (e.el_id || '') + ' .' + (e.el_class || '');
      case 'page_change': try { return '→ ' + (JSON.parse(e.meta || '{}').uri || ''); } catch (_) { return '→ (page change)'; }
      case 'navigate':  try { return JSON.parse(e.meta || '{}').url || ''; } catch (_) { return ''; }
      case 'error':     try { return JSON.parse(e.meta || '{}').message || ''; } catch (_) { return ''; }
      case 'resize':    return (e.vp_w || '') + '×' + (e.vp_h || '');
      case 'visibility': try { return JSON.parse(e.meta || '{}').hidden ? 'hidden' : 'visible'; } catch (_) { return ''; }
      case 'network':
        try {
          var m = JSON.parse(e.meta || '{}');
          var st = m.status || 0;
          var tag = (st >= 200 && st < 300) ? '[OK]' : (st >= 400 ? '[ERR]' : '[' + st + ']');
          return tag + ' ' + (m.method || 'GET') + ' ' + (m.url || '') + ' (' + (m.duration_ms || 0) + 'ms)';
        } catch (_) { return 'network request'; }
      case 'custom_tagged':
        try {
          var m = JSON.parse(e.meta || '{}');
          var label = m.event_name || 'custom';
          var d = m.data ? Object.keys(m.data).map(function(k) { return k + '=' + m.data[k]; }).join(', ') : '';
          return esc(label) + (d ? ' {' + esc(d) + '}' : '');
        } catch (_) { return 'custom event'; }
      case 'iframe_click':
        try { var m = JSON.parse(e.meta || '{}'); return '⧈ iframe ' + (m.iframe_selector || '') + ' → ' + (m.tag || '') + ' ' + (m.el_text || ''); } catch (_) { return '⧈ iframe click'; }
      case 'iframe_scroll':
        try { var m = JSON.parse(e.meta || '{}'); return '⧈ iframe ' + (m.iframe_selector || '') + ' scrollY:' + (e.scroll_y || 0); } catch (_) { return '⧈ iframe scroll'; }
      case 'iframe_mousemove':
        return '⧈ iframe cursor';
      case 'iframe_input':
        try { var m = JSON.parse(e.meta || '{}'); return '⧈ iframe ' + (m.iframe_selector || '') + ' ' + (m.tag || '') + ' ' + (m.input_name || ''); } catch (_) { return '⧈ iframe input'; }
      case 'mutation':
        try { var m = JSON.parse(e.meta || '{}'); return '△ DOM ' + (m.count || 0) + ' mutation(s)'; } catch (_) { return '△ DOM mutation'; }
      case 'touch':
        try {
          var m = JSON.parse(e.meta || '{}');
          var g = m.gesture || 'touch';
          var dir = m.direction ? ' ' + m.direction : '';
          var fingers = m.fingers && m.fingers > 1 ? ' (' + m.fingers + ' fingers)' : '';
          return '👆 ' + g + dir + fingers;
        } catch (_) { return '👆 touch'; }
      case 'broken_resource':
        try {
          var m = JSON.parse(e.meta || '{}');
          var rt = m.resource_type || 'resource';
          return '⚠ ' + rt + ': ' + (m.url || '').slice(0, 80);
        } catch (_) { return '⚠ broken resource'; }
      case 'form_submit':
        try {
          var m = JSON.parse(e.meta || '{}');
          var src = m.source === 'server' ? ' [server]' : '';
          var fid = e.el_id ? ' #' + e.el_id : '';
          return '⊕ form' + fid + ' ' + (m.method || 'GET') + src +
                 (m.form_name ? ' "' + m.form_name + '"' : '') +
                 (e.href ? ' → ' + (e.href || '').slice(0, 60) : '');
        } catch (_) { return '⊕ form submit'; }
      default: return '';
    }
  }

  // ── PLAYBACK ──────────────────────────────────────────────────
  function play() {
    playing = true;
    lastRealTime = performance.now() - currentT / speed;
    document.getElementById('playIcon').innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
    rafId = requestAnimationFrame(tick);
  }

  function pause() {
    playing = false;
    if (rafId) cancelAnimationFrame(rafId);
    const pi = document.getElementById('playIcon');
    if (pi) pi.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
  }

  function stopPlayback() {
    pause();
    currentT = 0;
    lastClickEventIdx = -1;
    lastAppliedIdx    = -1;
    if (window._vsrPlaylistCheck) { clearInterval(window._vsrPlaylistCheck); window._vsrPlaylistCheck = null; }
    updateProgress();
  }

  function tick(realNow) {
    if (!playing) return;
    currentT = (realNow - lastRealTime) * speed;
    const maxT = getMaxT();
    if (currentT >= maxT) { currentT = maxT; pause(); }
    applyCursorToT(currentT);
    updateProgress();
    if (playing) rafId = requestAnimationFrame(tick);
  }

  // ── CLICK EFFECT ──────────────────────────────────────────────
  function showClickEffect(x, y, type) {
    const wrap = document.createElement('div');
    wrap.style.cssText = 'position:absolute;left:' + x + 'px;top:' + y + 'px;pointer-events:none;z-index:10000;transform:translate(-50%,-50%)';

    if (type === 'dead_click') {
      // Grey dashed ring for dead clicks
      const ring = document.createElement('div');
      ring.className = 'dead-click-ring';
      ring.style.cssText = 'position:absolute;width:10px;height:10px;transform:translate(-50%,-50%)';
      wrap.appendChild(ring);
      const dot = document.createElement('div');
      dot.style.cssText = 'position:absolute;width:6px;height:6px;border-radius:50%;background:var(--text3);transform:translate(-50%,-50%);opacity:.6';
      wrap.appendChild(dot);
    } else if (type === 'touch') {
      // Touch: finger dot with ripple
      const dot = document.createElement('div');
      dot.className = 'touch-dot';
      dot.style.cssText = 'position:absolute;width:14px;height:14px;border-radius:50%;background:var(--purple);transform:translate(-50%,-50%);opacity:.7;animation:vsrRpClickDotFade 0.6s ease-out forwards';
      wrap.appendChild(dot);
      const ring = document.createElement('div');
      ring.style.cssText = 'position:absolute;width:10px;height:10px;border-radius:50%;border:2px solid var(--purple);transform:translate(-50%,-50%);animation:vsrRpClickRingExpand 0.7s ease-out forwards';
      wrap.appendChild(ring);
    } else if (type === 'iframe_click') {
      // iFrame click: blue ring
      const ring = document.createElement('div');
      ring.style.cssText = 'position:absolute;width:10px;height:10px;border-radius:50%;border:2px solid var(--accent);transform:translate(-50%,-50%);animation:vsrRpClickRingExpand 0.65s ease-out forwards';
      wrap.appendChild(ring);
      const dot = document.createElement('div');
      dot.style.cssText = 'position:absolute;width:8px;height:8px;border-radius:50%;background:var(--accent);transform:translate(-50%,-50%);animation:vsrRpClickDotFade 0.5s ease-out forwards';
      wrap.appendChild(dot);
    } else {
      const isRage = type === 'rage_click';
      const ringCount = isRage ? 3 : 1;
      const color = isRage ? 'var(--red)' : 'var(--click-ring)';
      for (let r = 0; r < ringCount; r++) {
        const ring = document.createElement('div');
        ring.style.cssText = 'position:absolute;width:10px;height:10px;border-radius:50%;border:2px solid ' + color + ';transform:translate(-50%,-50%);animation:vsrRpClickRingExpand 0.65s ease-out ' + (r * 0.13) + 's forwards';
        wrap.appendChild(ring);
      }
      const dot = document.createElement('div');
      dot.style.cssText = 'position:absolute;width:8px;height:8px;border-radius:50%;background:' + color + ';transform:translate(-50%,-50%);animation:vsrRpClickDotFade 0.5s ease-out forwards';
      wrap.appendChild(dot);
    }

    vpFrame.appendChild(wrap);
    setTimeout(() => wrap.remove(), 900);
  }

  function applyPageChange(meta) {
    if (!meta || !meta.uri) return;
    const iframe = document.getElementById('replayIframe');
    if (iframe) {
      const sep    = meta.uri.includes('?') ? '&' : '?';
      const newSrc = meta.uri + sep + 'vs_replay=1';
      if (iframe.getAttribute('data-vs-src') !== newSrc) {
        iframe.setAttribute('data-vs-src', newSrc);
        iframe.src = newSrc;
      }
    }
    const vw = parseInt(meta.viewport_w) || parseInt(session.viewport_w) || 1280;
    const vh = parseInt(meta.viewport_h) || parseInt(session.viewport_h) || 800;
    const ph = parseInt(meta.page_h)     || vh;
    currentPageDims = { vw, vh, ph };
    vpFrame.style.width  = vw + 'px';
    vpFrame.style.height = vh + 'px';
    if (iframe) {
      iframe.style.width  = vw + 'px';
      iframe.style.height = vh + 'px';
    }
    scrollInd.style.height = Math.max(20, Math.round(vh / ph * 100)) + '%';
    const { scale, left, top } = computeViewportGeometry(vw, vh);
    vpScaler.style.transform = 'scale(' + scale + ')';
    vpScaler.style.width  = vw + 'px';
    vpScaler.style.height = vh + 'px';
    vpScaler.style.left   = left + 'px';
    vpScaler.style.top    = top + 'px';
  }

  function applyCursorToT(t, forceFullScan) {
    const needFullScan = forceFullScan || t < currentT || lastAppliedIdx < 0;
    // cx/cy are page-absolute (recorded via pageX/pageY); sx/sy is the
    // scroll position they're relative to at that point in the timeline —
    // both are needed to convert to viewport-relative coordinates below,
    // since the frame is now a real scrolling device viewport, not a
    // flattened full-page canvas.
    let cx = null, cy = null, sx = 0, sy = 0, logIdx = -1;

    if (needFullScan) {
      lastAppliedIdx = -1;
      let pendingPageMeta = null;
      for (let i = 0; i < events.length; i++) {
        const e = events[i];
        if (e.t > t) break;
        logIdx = i; lastAppliedIdx = i;
        if (e.type === 'mousemove' || e.type === 'click' || e.type === 'rage_click' || e.type === 'dead_click' || e.type === 'iframe_click' || e.type === 'iframe_mousemove' || (e.type === 'touch' && e.x != null)) {
          if (e.x != null) cx = +e.x;
          if (e.y != null) cy = +e.y;
        }
        if (e.type === 'scroll') {
          if (e.scroll_y != null) sy = +e.scroll_y;
          if (e.scroll_x != null) sx = +e.scroll_x;
        }
        if (e.type === 'page_change') {
          try { pendingPageMeta = JSON.parse(e.meta || '{}'); } catch (_) {}
        }
        if ((e.type === 'click' || e.type === 'rage_click' || e.type === 'dead_click' || e.type === 'iframe_click' || e.type === 'touch') && i > lastClickEventIdx && e.x != null && e.y != null) {
          showClickEffect(+e.x - sx, +e.y - sy, e.type);
          lastClickEventIdx = i;
        }
      }
      applyPageChange(pendingPageMeta || initialPageMeta);
    } else {
      for (let i = 0; i <= lastAppliedIdx && i < events.length; i++) {
        const e = events[i];
        if (e.type === 'mousemove' || e.type === 'click' || e.type === 'rage_click') {
          if (e.x != null) cx = +e.x;
          if (e.y != null) cy = +e.y;
        }
        if (e.type === 'scroll') {
          if (e.scroll_y != null) sy = +e.scroll_y;
          if (e.scroll_x != null) sx = +e.scroll_x;
        }
      }
      logIdx = lastAppliedIdx;
      for (let i = lastAppliedIdx + 1; i < events.length; i++) {
        const e = events[i];
        if (e.t > t) break;
        logIdx = i; lastAppliedIdx = i;
        if (e.type === 'mousemove' || e.type === 'click' || e.type === 'rage_click' || e.type === 'dead_click' || e.type === 'iframe_click' || e.type === 'iframe_mousemove' || (e.type === 'touch' && e.x != null)) {
          if (e.x != null) cx = +e.x;
          if (e.y != null) cy = +e.y;
        }
        if (e.type === 'scroll') {
          if (e.scroll_y != null) sy = +e.scroll_y;
          if (e.scroll_x != null) sx = +e.scroll_x;
        }
        if (e.type === 'page_change') {
          try { applyPageChange(JSON.parse(e.meta || '{}')); } catch (_) {}
        }
        if ((e.type === 'click' || e.type === 'rage_click' || e.type === 'dead_click' || e.type === 'iframe_click' || e.type === 'touch') && i > lastClickEventIdx && e.x != null && e.y != null) {
          showClickEffect(+e.x - sx, +e.y - sy, e.type);
          lastClickEventIdx = i;
        }
      }
    }

    if (cx !== null && cy !== null) {
      cursor.style.left = (cx - sx) + 'px';
      cursor.style.top  = (cy - sy) + 'px';
    }

    const dims = currentPageDims || {};
    const ph   = dims.ph || parseInt(session.page_h) || (dims.vh || parseInt(session.viewport_h) || 800);

    // Scroll the embedded page for real, instead of panning a giant canvas.
    const iframe = document.getElementById('replayIframe');
    if (iframe) {
      try { iframe.contentWindow.scrollTo(sx, sy); } catch (_) { /* cross-origin or not loaded yet */ }
    }
    scrollInd.style.top = Math.round(sy / ph * 100) + '%';

    if (logIdx >= 0) {
      shell.querySelectorAll('.log-row').forEach(r => r.classList.remove('current'));
      const row = document.getElementById('lr' + logIdx);
      if (row) { row.classList.add('current'); row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
    }
  }

  function seekToEvent(i) {
    const newT = events[i] ? events[i].t : 0;
    if (newT < currentT) { lastClickEventIdx = -1; lastAppliedIdx = -1; }
    currentT = newT;
    lastAppliedIdx = -1;
    if (playing) lastRealTime = performance.now() - currentT / speed;
    applyCursorToT(currentT, true);
    updateProgress();
  }

  function getMaxT() {
    if (!events.length) return 1;
    return events[events.length - 1].t || (session && session.duration_ms ? parseInt(session.duration_ms) : 1) || 1;
  }

  function updateProgress() {
    const maxT = getMaxT();
    const pct  = maxT > 0 ? Math.min(100, currentT / maxT * 100) : 0;
    const pf = document.getElementById('progressFill');
    const pt = document.getElementById('progressThumb');
    const td = document.getElementById('timeDisplay');
    if (pf) pf.style.width = pct + '%';
    if (pt) pt.style.left  = pct + '%';
    if (td) {
      // Elapsed / total, matching format on both sides — was previously
      // showing wall-clock time-of-day on the left (e.g. "07:27:30")
      // against elapsed duration on the right (e.g. "3:57"), which read
      // as a broken/nonsensical time. The actual wall-clock moment is
      // still available as a hover tooltip for anyone who wants it.
      td.textContent = fmtMs(currentT) + ' / ' + fmtMs(maxT);
      td.title = fmtMsAsTime(currentT);
    }
  }

  const RM_ICON_VIEWPORT = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
  const RM_ICON_CLICK    = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 9l11 4-4.5 2L14 20z"/></svg>';
  const RM_ICON_SCROLL   = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18M7 8l5-5 5 5M7 16l5 5 5-5"/></svg>';

  function rmChip(icon, label) {
    return '<span class="rm-chip">' + icon + '<span>' + label + '</span></span>';
  }

  function updateMeta() {
    const el = document.getElementById('replayMeta');
    if (!el || !session) return;
    const m = sessionAggregate || session;
    el.innerHTML =
      rmChip(RM_ICON_VIEWPORT, (m.viewport_w || '?') + '×' + (m.viewport_h || '?')) +
      rmChip(RM_ICON_CLICK, (m.click_count || 0) + ' clicks') +
      rmChip(RM_ICON_SCROLL, (m.scroll_count || 0) + ' scrolls');
  }

  // Shared seek helper — clamps to the timeline, resets the forward-scan
  // cache when jumping backward, and keeps playback timing in sync.
  function seekTo(newT) {
    newT = Math.max(0, Math.min(getMaxT(), newT));
    const backward = newT < currentT;
    if (backward) { lastClickEventIdx = -1; lastAppliedIdx = -1; }
    currentT = newT;
    if (playing) lastRealTime = performance.now() - currentT / speed;
    applyCursorToT(currentT, backward);
    updateProgress();
  }

  // Progress bar seek
  document.getElementById('progressWrap').addEventListener('click', e => {
    const rect = e.currentTarget.getBoundingClientRect();
    const pct  = (e.clientX - rect.left) / rect.width;
    seekTo(pct * getMaxT());
  });

  document.getElementById('skipBackBtn').addEventListener('click', () => seekTo(currentT - 10000));
  document.getElementById('skipFwdBtn').addEventListener('click', () => seekTo(currentT + 30000));
  document.getElementById('jumpStartBtn').addEventListener('click', () => seekTo(0));
  document.getElementById('jumpEndBtn').addEventListener('click', () => seekTo(getMaxT()));

  document.getElementById('playBtn').addEventListener('click', () => {
    if (playing) pause();
    else { if (currentT >= getMaxT()) currentT = 0; play(); }
  });

  // Playback speed — custom dropdown (a native <select>'s popup can't be
  // styled, which looked out of place next to the rest of the toolbar).
  const speedBtn      = document.getElementById('speedBtn');
  const speedBtnLabel = document.getElementById('speedBtnLabel');
  const speedMenu     = document.getElementById('speedMenu');
  if (speedBtn && speedMenu) {
    speedBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      speedMenu.classList.toggle('open');
      speedBtn.classList.toggle('active', speedMenu.classList.contains('open'));
    });
    speedMenu.querySelectorAll('.speed-menu-item').forEach(item => {
      item.addEventListener('click', () => {
        speed = +item.dataset.speed;
        if (playing) lastRealTime = performance.now() - currentT / speed;
        speedBtnLabel.textContent = item.textContent;
        speedMenu.querySelectorAll('.speed-menu-item').forEach(i => i.classList.remove('active'));
        item.classList.add('active');
        speedMenu.classList.remove('open');
        speedBtn.classList.remove('active');
      });
    });
    document.addEventListener('click', () => {
      speedMenu.classList.remove('open');
      speedBtn.classList.remove('active');
    });
  }

  document.getElementById('nextClickBtn').addEventListener('click', jumpToNextClick);

  const logOpenBtn = document.getElementById('logOpenBtn');

  function setLogVisible(visible) {
    logCollapsed = !visible;
    eventLog.classList.toggle('hidden', !visible);
    logOpenBtn.classList.toggle('active', visible);
    logOpenBtn.title = visible ? 'Hide event log' : 'Show event log';
    if (session) applyCursorToT(currentT, true);
  }

  document.getElementById('logCloseBtn').addEventListener('click', () => setLogVisible(false));

  logOpenBtn.addEventListener('click', () => {
    setLogVisible(eventLog.classList.contains('hidden'));
  });

  // Keyboard shortcuts
  document.addEventListener('keydown', e => {
    if (!shell) return;
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
    if (e.key === ' ') {
      e.preventDefault();
      document.getElementById('playBtn').click();
    }
    if (e.key === 'ArrowRight') {
      currentT = Math.min(getMaxT(), currentT + 2000);
      if (playing) lastRealTime = performance.now() - currentT / speed;
      applyCursorToT(currentT); updateProgress();
    }
    if (e.key === 'ArrowLeft') {
      currentT = Math.max(0, currentT - 2000);
      lastClickEventIdx = -1; lastAppliedIdx = -1;
      if (playing) lastRealTime = performance.now() - currentT / speed;
      applyCursorToT(currentT, true); updateProgress();
    }
    if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
      e.preventDefault();
      jumpToNextClick();
    }
    if (e.key === 'f' && !e.ctrlKey && !e.metaKey) {
      document.getElementById('fullscreenBtn').click();
    }
  });

  // ── FULLSCREEN ────────────────────────────────────────────────
  const expandSvg   = '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>';
  const collapseSvg = '<path d="M4 14h6v6m10-10h-6V4m0 16h6M4 4h6"/>';

  document.getElementById('fullscreenBtn').addEventListener('click', () => {
    const panel = document.getElementById('replayPanel');
    if (!document.fullscreenElement) {
      panel.requestFullscreen().catch(err => console.warn('Fullscreen failed:', err));
    } else {
      document.exitFullscreen();
    }
  });

  document.addEventListener('fullscreenchange', () => {
    const isFs = !!document.fullscreenElement;
    const fi   = document.getElementById('fullscreenIcon');
    if (fi) fi.innerHTML = isFs ? collapseSvg : expandSvg;
    if (session) setTimeout(setupViewport, 100);
  });

  // ── OPEN IN NEW TAB ─────────────────────────────────────────────
  document.getElementById('newTabBtn').addEventListener('click', () => {
    if (!currentSid) return;
    const url = new URL(window.location.href);
    url.searchParams.set('sid', currentSid);
    window.open(url.toString(), '_blank', 'noopener');
  });

  // ── MOBILE CLOSE BUTTON ────────────────────────────────────────
  const mobCloseBtn = document.getElementById('vsrMobCloseBtn');
  if (mobCloseBtn) {
    mobCloseBtn.addEventListener('click', () => {
      replayPanel.classList.remove('vsr-mob-open');
      stopIpPlaylist();
      pause();
    });
  }

  // ── 19a: EXPORT ────────────────────────────────────────────────
  // Build query string from the current list-panel filters (same params as loadSessions).
  function buildFilterParams() {
    const uri       = (document.getElementById('uriFilter')    || {}).value || '';
    const rangeVal  = (document.getElementById('filterRange')  || {}).value || '';
    const deviceVal = (document.getElementById('filterDevice') || {}).value || '';

    const p = new URLSearchParams();
    if (uri.trim())  p.set('uri', uri.trim());
    if (deviceVal)   p.set('device', deviceVal);

    if (rangeVal) {
      const now = new Date();
      let from;
      if (rangeVal === 'today') {
        from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
      } else {
        const days = parseInt(rangeVal) || 7;
        from = new Date(now.getTime() - days * 86400000);
      }
      const fmt = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
      p.set('date_from', fmt(from));
      p.set('date_to',   fmt(now));
    }
    return p;
  }

  function triggerExport(format) {
    const p = buildFilterParams();
    p.set('format', format);
    p.set('_wpnonce', vlensAdmin.nonce);
    // Construct the full REST URL directly (restBase already ends with '/')
    const url = vlensAdmin.restBase + 'replay/export?' + p.toString();
    const a = document.createElement('a');
    a.href = url;
    a.download = '';
    document.body.appendChild(a);
    a.click();
    a.remove();
  }

  // Export dropdown toggle
  const exportWrap = document.getElementById('exportWrap');
  const exportMenu = document.getElementById('exportMenu');
  if (exportWrap && exportMenu) {
    document.getElementById('exportBtn').addEventListener('click', (e) => {
      e.stopPropagation();
      exportMenu.classList.toggle('open');
    });
    document.getElementById('exportCsvBtn').addEventListener('click', () => {
      exportMenu.classList.remove('open');
      triggerExport('csv');
    });
    document.getElementById('exportJsonBtn').addEventListener('click', () => {
      exportMenu.classList.remove('open');
      triggerExport('json');
    });
    // Close menu when clicking outside
    document.addEventListener('click', () => exportMenu.classList.remove('open'));
  }

  // Gear popup toggle — holds theme/export/bulk-delete/clear, the
  // less-frequently-used actions, tucked behind a single icon button.
  const gearBtn  = document.getElementById('gearBtn');
  const gearMenu = document.getElementById('gearMenu');
  if (gearBtn && gearMenu) {
    gearBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      const isOpen = gearMenu.classList.toggle('open');
      gearBtn.classList.toggle('active', isOpen);
    });
    gearMenu.addEventListener('click', (e) => e.stopPropagation());
    document.addEventListener('click', () => {
      gearMenu.classList.remove('open');
      gearBtn.classList.remove('active');
    });
  }

  // ── 19c: BULK DELETE FILTERED ──────────────────────────────────
  const bulkDelBtn = document.getElementById('bulkDelBtn');
  if (bulkDelBtn) {
    bulkDelBtn.addEventListener('click', async () => {
      const p = buildFilterParams();
      if (!p.toString()) {
        alert('No filters are active. Apply at least one filter (URI, date range, or device) before bulk deleting.');
        return;
      }
      const filterDesc = Array.from(p.entries())
        .map(([k, v]) => k + '=' + v).join(', ');
      if (!confirm('Delete ALL sessions matching the current filters?\n\n' + filterDesc + '\n\nThis cannot be undone.')) return;
      bulkDelBtn.disabled = true;
      bulkDelBtn.textContent = 'Deleting…';
      try {
        const d = await VSR.del('replay/bulk-delete?' + p.toString());
        if (d.ok) {
          alert('Deleted ' + (d.deleted || 0) + ' session(s).');
          loadSessions(1);
        }
      } catch (e) {
        alert('Bulk delete failed. Check browser console for details.');
        console.error(e);
      } finally {
        bulkDelBtn.disabled = false;
        bulkDelBtn.textContent = '🗑 Delete Filtered';
      }
    });
  }

  // ── CLEAR ALL ──────────────────────────────────────────────────
  document.getElementById('clearBtn').addEventListener('click', async () => {
    if (!confirm('Delete ALL recorded sessions? This cannot be undone.')) return;
    const btn = document.getElementById('clearBtn');
    btn.textContent = 'Clearing…'; btn.disabled = true;
    try {
      const d = await VSR.del('replay/clear');
      if (d.ok) {
        // Reset session list UI
        document.getElementById('sessionList').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text3)">No sessions found</div>';
        document.getElementById('listCount').textContent = '0 page views';
        // Reset pagination
        currentPage = 1;
        updatePagination(0, 1, 1);
        // Clear cached session groups
        window._vsrSessionGroups = {};
        // Stop any active playback and reset player state
        stopPlayback();
        session = null;
        events  = [];
        const td = document.getElementById('timeDisplay');
        if (td) td.textContent = '0:00 / 0:00';
        const lb = document.getElementById('logBody');
        if (lb) lb.innerHTML = '';
        const em = document.getElementById('eventMarkers');
        if (em) em.innerHTML = '';
        const rm = document.getElementById('replayMeta');
        if (rm) rm.textContent = '';
        const ntb = document.getElementById('newTabBtn');
        if (ntb) ntb.style.display = 'none';
        const sb = document.getElementById('vsr-share-btn');
        if (sb) sb.style.display = 'none';
        // Show empty state
        document.getElementById('replayEmpty').style.display = '';
        document.getElementById('replayUI').style.display = 'none';
      }
    } catch (e) {
      alert('Clear failed');
    }
    btn.textContent = '🗑 Clear All'; btn.disabled = false;
  });

  // ── UTILS ──────────────────────────────────────────────────────
  function fmtMs(ms) {
    const s = Math.floor(ms / 1000), m = Math.floor(s / 60);
    return m + ':' + (s % 60).toString().padStart(2, '0');
  }

  function fmtMsAsTime(ms) {
    if (session && session.started_at) {
      const wallMs = new Date(session.started_at).getTime() + ms;
      const d = new Date(wallMs);
      return d.getHours().toString().padStart(2, '0') + ':' +
             d.getMinutes().toString().padStart(2, '0') + ':' +
             d.getSeconds().toString().padStart(2, '0');
    }
    return fmtMs(ms);
  }

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

  function deviceType(w) {
    w = parseInt(w) || 0;
    if (w > 0 && w < 768) return 'mobile';
    if (w >= 768 && w < 1024) return 'tablet';
    return 'desktop';
  }

  function deviceIcon(w) {
    const t = deviceType(w);
    if (t === 'mobile') return '<svg class="sr-device-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="7" y="2" width="10" height="20" rx="2"/><line x1="12" y1="18" x2="12" y2="18.01"/></svg>';
    if (t === 'tablet') return '<svg class="sr-device-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="12" y1="18" x2="12" y2="18.01"/></svg>';
    return '<svg class="sr-device-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
  }

  function maskIp(ip) {
    if (!ip || ip === 'Unknown') return 'Unknown';
    if (ip.includes(':')) { const p = ip.split(':'); return '***:' + p.slice(-4).join(':'); }
    return ip;
  }

  // ── PREMIUM GATE ───────────────────────────────────────────────
  function _showPremiumGate(btn, feature) {
    // Prefer the full upgrade modal if available
    if (window.vsrUpgrade && window.vsrUpgrade.show) {
      window.vsrUpgrade.show(feature);
      return;
    }
    // Fallback: small inline popover (shown when vsrUpgrade not yet loaded)
    const existing = document.getElementById('vsrPremiumGate');
    if (existing) { existing.remove(); return; }
    const el = document.createElement('div');
    el.id = 'vsrPremiumGate';
    el.className = 'vsr-premium-gate';
    el.innerHTML =
      '<strong>🔒 ' + feature + ' — Premium</strong>' +
      '<p>Upgrade to VisitorLens Premium to unlock behavioural analysis, intent scoring &amp; date-wise insights for every visitor.</p>';
    document.body.appendChild(el);
    if (btn) {
      const r    = btn.getBoundingClientRect();
      const left = Math.max(8, Math.min(r.left, window.innerWidth - 276));
      el.style.left = left + 'px';
      el.style.top  = (r.bottom + 8) + 'px';
    } else {
      el.style.left      = '50%';
      el.style.top       = '50%';
      el.style.transform = 'translate(-50%,-50%)';
    }
    setTimeout(() => el.remove(), 4500);
    document.addEventListener('click', function once(e) {
      if (!el.contains(e.target)) { el.remove(); document.removeEventListener('click', once); }
    });
  }

  // ── IP-GROUP INSIGHTS MODAL ────────────────────────────────────
  async function showIpInsights(ip, btn) {
    if (window.vsrInsights && window.vsrInsights.showIpModal) {
      await window.vsrInsights.showIpModal(ip, btn);
    } else {
      _showPremiumGate(btn, 'Visitor Insights');
    }
  }

  // ── PER-VISIT INSIGHTS ─────────────────────────────────────────
  // Opens the IP insights modal scoped to only the sessions in this visit.
  async function showVisitInsights(idsStr, btn) {
    if (!window.vsrInsights || !window.vsrInsights.showIpModal) {
      _showPremiumGate(btn, 'Visitor Insights');
      return;
    }
    const ids  = idsStr.split(',').map(Number);
    const all  = Object.values(window._vsrSessionGroups || {}).flat();
    const segs = all.filter(s => ids.includes(parseInt(s.id)));
    if (!segs.length) return;
    const ip = segs[0].ip_raw || 'Unknown';
    await window.vsrInsights.showIpModal(ip, btn, segs, true);
  }

  window.vsrReplay = { selectSession, toggleIpGroup, deleteIpGroup, playIpGroup, showIpInsights, showVisitInsights };

  // ── MOBILE DETECTION ──────────────────────────────────────────
  // WP admin ignores CSS media queries on mobile (renders at desktop
  // viewport width), so we toggle a class via JS instead.
  function vsrCheckMobile() {
    shell.classList.toggle('vsr-is-mobile', window.innerWidth <= 900);
  }
  vsrCheckMobile();
  window.addEventListener('resize', vsrCheckMobile);

  // ── INIT ───────────────────────────────────────────────────────
  (async function init() {
    await loadSessions();

    // Refresh online dots every 30s
    setInterval(async () => {
      try {
        const d = await VSR.get('replay/sessions?page=' + currentPage);
        const onlineIps = d.online_ips || [];
        shell.querySelectorAll('.ip-group').forEach(group => {
          const ip = group.dataset.ip;
          const header = group.querySelector('.ip-group-header');
          const existingDot = header.querySelector('.online-dot-ip');
          const isOnline = onlineIps.includes(ip);
          if (isOnline && !existingDot) {
            const dot = document.createElement('span');
            dot.className = 'online-dot-ip';
            dot.title = 'Online now';
            const playBtn2 = header.querySelector('.ip-group-play');
            if (playBtn2) header.insertBefore(dot, playBtn2);
            else header.appendChild(dot);
          } else if (!isOnline && existingDot) {
            existingDot.remove();
          }
        });
      } catch (e) {}
    }, 30000);

    // Auto-select session from URL ?sid=
    const autoSid = new URLSearchParams(window.location.search).get('sid');
    if (!autoSid) return;
    const sid = parseInt(autoSid, 10);
    if (sid <= 0) return;

    // Visit rows carry data-id of their first page; also check data-ids for mid-visit pages
    let row = document.querySelector('.visit-row[data-id="' + sid + '"]');
    if (!row) {
      row = document.querySelector('.visit-row[data-ids*="' + sid + '"]') || null;
    }
    if (!row) { window.vsrReplay.selectSession(sid, null); return; }

    const ipGroup = row.closest('.ip-group');
    if (ipGroup) {
      const body  = ipGroup.querySelector('.ip-group-body');
      const arrow = ipGroup.querySelector('.ip-group-arrow');
      body.style.display = 'block';
      arrow.style.transform = 'rotate(90deg)';
      ipGroup.classList.add('expanded', 'highlight');
    }

    window.vsrReplay.selectSession(parseInt(row.dataset.id), row, row.dataset.ids);
    setTimeout(() => row.scrollIntoView({ block: 'center', behavior: 'smooth' }), 200);
  })();

})();