/**
 * meSEO Site Tag — Heatmap, scroll depth, attention zones
 * --------------------------------------------------------
 * Captures three streams of engagement signal per page:
 *   • Click coordinates + target metadata (batched, flushed every 10s)
 *   • Scroll depth milestones (25/50/75/90/100) + time on page
 *   • Attention time per section via IntersectionObserver (50% threshold)
 *
 * All streams flush on beforeunload via sendBeacon so nothing is lost
 * when the visitor navigates away. Listeners are passive. Depends on
 * meSEOAttribution for visitor/session context.
 */
(function () {
  "use strict";

  var FLUSH_INTERVAL_MS = 10000;
  var MILESTONES = [25, 50, 75, 90, 100];

  function init(cfg) {
    if (!cfg || !cfg.endpoint) return;
    var endpoint = cfg.endpoint;
    var pageUrl = window.location.href;
    var startTime = Date.now();
    // SPA mode: opt-in via cfg.spa or window.meSEOConfig.spa. When enabled,
    // history navigation events (pushState/replaceState/popstate) trigger a
    // flush+reset so attention data is correctly segmented by route instead
    // of bleeding across virtual pages.
    var spaMode = !!(cfg.spa || (window.meSEOConfig && window.meSEOConfig.spa));

    function ctx() { try { return (window.meSEOAttribution && window.meSEOAttribution.getContext()) || {}; } catch (e) { return {}; } }

    function beacon(payload) {
      var c = ctx();
      payload.siteId = cfg.siteId;
      payload.visitorId = c.visitorId || null;
      payload.sessionId = c.sessionId || null;
      payload.pageUrl = pageUrl;
      payload.timestamp = new Date().toISOString();
      var body = JSON.stringify(payload);
      try { if (navigator.sendBeacon) { navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" })); return; } } catch (e) {}
      try { fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: body, keepalive: true, credentials: "omit" }).catch(function () {}); } catch (e) {}
    }

    // ── 1. Click heatmap ───────────────────────────────────────────────
    //
    // Every captured click needs a HUMAN-READABLE label so the
    // analytics UI doesn't collapse to "unknown" rows. The previous
    // describeTarget() only carried the raw tag / id / class / href /
    // text fields; when the click landed on a button with an icon (no
    // text node) or a styled div the row surfaced as "unknown" on the
    // Heatmaps tab.
    //
    // clickLabelFor() walks an 8-step priority chain picking the most
    // descriptive field that's actually populated. Each level is
    // capped at a safe length (≤ 60 chars) so long strings don't
    // dominate the JSON payload.
    var clickBuffer = [];
    function truncate(s, n) { return (s || "").slice(0, n); }

    function clickLabelFor(el) {
      if (!el || !el.tagName) return null;
      try {
        // 1. aria-label — most descriptive when present (screen-
        //    reader-optimised sites carry these aggressively).
        var aria = el.getAttribute && el.getAttribute("aria-label");
        if (aria) { aria = aria.trim(); if (aria) return aria.slice(0, 60); }
        // 2. Visible text — normalised + truncated. Skip when > 100
        //    chars (probably paragraph text the user highlighted, not
        //    a discrete clickable target).
        var txt = (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
        if (txt && txt.length > 0 && txt.length < 100) return txt.slice(0, 60);
        // 3. id attribute — conventionally developer-named.
        if (el.id) return "#" + String(el.id).slice(0, 58);
        // 4. title attribute (icon buttons commonly carry one).
        var title = el.getAttribute && el.getAttribute("title");
        if (title) { title = String(title).trim(); if (title) return title.slice(0, 60); }
        // 5. alt text for images.
        var alt = el.getAttribute && el.getAttribute("alt");
        if (alt) { alt = String(alt).trim(); if (alt) return alt.slice(0, 60); }
        // 6. href for links — path-only is most scannable.
        if (el.tagName === "A" && el.href) {
          var href = String(el.href);
          try { href = href.replace(window.location.origin, ""); } catch (e) {}
          return ("Link: " + href).slice(0, 60);
        }
        // 7. src filename for images.
        if (el.tagName === "IMG" && el.src) {
          var parts = String(el.src).split("/");
          var file = parts[parts.length - 1].split("?")[0];
          return ("Image: " + file).slice(0, 60);
        }
        // 8. value / placeholder for form inputs.
        if (el.value) return String(el.value).slice(0, 60);
        if (el.placeholder) return String(el.placeholder).slice(0, 60);
        // 9. Last resort — tag + first meaningful class (strip
        //    framework prefixes + excessively underscored BEM
        //    variants that don't read well).
        var classes = "";
        if (el.className && typeof el.className === "string") {
          var parts2 = el.className.split(/\s+/).filter(function (c) {
            return c && c.length > 2 && c.indexOf("_") < 0;
          });
          classes = parts2.slice(0, 2).join(".");
        }
        return (el.tagName.toLowerCase() + (classes ? "." + classes : "")).slice(0, 60);
      } catch (err) {
        try { return el.tagName ? el.tagName.toLowerCase() : null; } catch (e) { return null; }
      }
    }

    function describeTarget(el) {
      if (!el || !el.tagName) return null;
      var rect = null;
      try { rect = el.getBoundingClientRect(); } catch (e) {}
      var pageHeight = Math.max(
        document.documentElement.scrollHeight,
        document.body ? document.body.scrollHeight : 0,
      ) || 1;
      var scrollY = (window.scrollY !== undefined ? window.scrollY : window.pageYOffset) || 0;
      var positionX = null;
      var positionY = null;
      var aboveFold = null;
      if (rect) {
        // As a percentage of viewport-width / total-page-height so
        // the position is comparable across different screen sizes.
        try {
          positionX = Math.round(((rect.left + rect.width / 2) / Math.max(window.innerWidth, 1)) * 100);
          positionY = Math.round(((rect.top + rect.height / 2 + scrollY) / pageHeight) * 100);
          aboveFold = rect.top < window.innerHeight;
        } catch (e) {}
      }
      return {
        tag: el.tagName.toLowerCase(),
        id: el.id || null,
        cls: truncate(el.className && typeof el.className === "string" ? el.className : "", 120),
        text: truncate((el.innerText || el.textContent || "").trim(), 80),
        href: el.href ? String(el.href).slice(0, 200) : null,
        // Human-readable identifier — the new required field. The
        // server aggregator uses this as the grouping key in place
        // of the raw selector / text / "unknown" fallback chain.
        label: clickLabelFor(el),
        // Position + fold flag for a future visual heatmap overlay.
        // Percentages are portable across viewport sizes; the raw
        // viewportWidth is kept so downstream consumers can de-
        // normalise if they want device-specific clusters.
        positionX: positionX,
        positionY: positionY,
        aboveFold: aboveFold,
        viewportWidth: window.innerWidth,
      };
    }
    document.addEventListener("click", function (e) {
      var rect = document.documentElement.getBoundingClientRect();
      var desc = describeTarget(e.target);
      clickBuffer.push({
        x: Math.round(e.pageX || (e.clientX - rect.left)),
        y: Math.round(e.pageY || (e.clientY - rect.top)),
        vx: Math.round(e.clientX || 0),
        vy: Math.round(e.clientY || 0),
        t: Date.now() - startTime,
        target: desc,
        // Flattened fields — the server aggregator reads these at
        // the top level of the event instead of digging into target.
        // Keeps the JSON payload small (no duplicated target object)
        // while still letting older server builds read the legacy
        // .target structure.
        label: desc ? desc.label : null,
        tag: desc ? desc.tag : null,
        positionX: desc ? desc.positionX : null,
        positionY: desc ? desc.positionY : null,
        aboveFold: desc ? desc.aboveFold : null,
      });
      if (clickBuffer.length > 500) clickBuffer.splice(0, clickBuffer.length - 500);
    }, { passive: true, capture: true });

    function dims() {
      return {
        pageWidth: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),
        pageHeight: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0),
        viewportWidth: window.innerWidth,
        viewportHeight: window.innerHeight,
      };
    }

    function flushClicks(final) {
      if (!clickBuffer.length) return;
      var batch = clickBuffer.splice(0, clickBuffer.length);
      var d = dims();
      beacon({ eventType: "clicks", events: batch, pageWidth: d.pageWidth, pageHeight: d.pageHeight, viewportWidth: d.viewportWidth, viewportHeight: d.viewportHeight, final: !!final });
    }
    setInterval(function () { flushClicks(false); }, FLUSH_INTERVAL_MS);

    // ── 2. Scroll depth + time on page ─────────────────────────────────
    var maxScroll = 0;
    var milestonesReached = [];
    function updateScroll() {
      var h = Math.max(document.documentElement.scrollHeight - window.innerHeight, 1);
      var pct = Math.min(100, Math.round(((window.scrollY || window.pageYOffset || 0) / h) * 100));
      if (pct > maxScroll) {
        maxScroll = pct;
        for (var i = 0; i < MILESTONES.length; i++) {
          if (pct >= MILESTONES[i] && milestonesReached.indexOf(MILESTONES[i]) === -1) milestonesReached.push(MILESTONES[i]);
        }
      }
    }
    window.addEventListener("scroll", updateScroll, { passive: true });
    updateScroll();

    // ── 3. Attention zones via IntersectionObserver ────────────────────
    var sectionTimes = {}; // selector → cumulative ms FOR THIS PAGEVIEW
    var sectionLabels = {}; // selector → human-readable label
    var visibleSince = {}; // selector → timestamp (when it became visible)
    var sectionIndex = 0;

    function selectorFor(el) {
      if (el.id) return "#" + el.id;
      if (el.dataset && el.dataset.meseoSection) return "[data-meseo-section='" + el.dataset.meseoSection + "']";
      if (!el.__meseoIdx) el.__meseoIdx = ++sectionIndex;
      return (el.tagName || "div").toLowerCase() + "#idx" + el.__meseoIdx;
    }

    /** Turn a slug (kebab/snake/camel case) into a Title-Cased phrase for
     * use as a section label when no heading / aria / paragraph exists. */
    function formatSlug(slug) {
      if (!slug) return "";
      var cleaned = String(slug)
        // kebab-case / snake_case → spaces
        .replace(/[-_]+/g, " ")
        // camelCase → space-separated
        .replace(/([a-z])([A-Z])/g, "$1 $2")
        .replace(/\s+/g, " ")
        .trim();
      if (!cleaned) return "";
      return cleaned.split(" ").map(function (w) {
        return w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w;
      }).join(" ");
    }

    /** Build a human-readable label for a section element. Fallback chain:
     *   1. First heading (h1-h4) text
     *   2. aria-label attribute
     *   3. data-meseo-label attribute
     *   4. First paragraph text (truncated)
     *   5. Element's id, formatted (#contact-form → "Contact Form")
     *   6. First class name, formatted (.hero-banner → "Hero Banner")
     *   7. "Section N" using the IntersectionObserver-assigned index
     *
     * Returns null only if every fallback fails — otherwise the renderer
     * would display the raw CSS selector (e.g. "div#idx4") to users. */
    function labelFor(el) {
      try {
        // 1. First heading
        var heading = el.querySelector ? el.querySelector("h1, h2, h3, h4") : null;
        if (heading) {
          var t = (heading.textContent || "").trim();
          if (t.length > 0) return t.slice(0, 60);
        }
        // 2. aria-label
        var aria = el.getAttribute && el.getAttribute("aria-label");
        if (aria) return String(aria).trim().slice(0, 60);
        // 3. data-meseo-label
        if (el.dataset && el.dataset.meseoLabel) return String(el.dataset.meseoLabel).slice(0, 60);
        // 4. First paragraph
        var p = el.querySelector ? el.querySelector("p") : null;
        if (p) {
          var pt = (p.textContent || "").trim();
          if (pt.length > 0) return pt.slice(0, 60) + (pt.length > 60 ? "\u2026" : "");
        }
        // 5. Formatted id — useful when the developer named the container
        //    (e.g. #contact-form, #hero, #pricing-tiers) even without a
        //    heading inside.
        if (el.id) {
          var niceId = formatSlug(el.id);
          if (niceId) return niceId;
        }
        // 6. First class name — same idea, picks up design-system hints
        //    like .hero-banner, .testimonials, .feature-grid.
        if (el.className && typeof el.className === "string") {
          var firstClass = el.className.split(/\s+/).filter(Boolean)[0];
          if (firstClass) {
            var niceClass = formatSlug(firstClass);
            if (niceClass) return niceClass;
          }
        }
        // 7. Last resort — number by page position. __meseoIdx is assigned
        //    by selectorFor() for elements that have no id/data attr, so
        //    this always lands on elements that would have shown "div#idx4".
        if (el.__meseoIdx) return "Section " + el.__meseoIdx;
      } catch (e) { /* fall through */ }
      return null;
    }

    var io = null;
    try {
      io = new IntersectionObserver(function (entries) {
        var now = Date.now();
        for (var i = 0; i < entries.length; i++) {
          var ent = entries[i];
          var sel = selectorFor(ent.target);
          // Capture the label the first time we see this section
          if (!sectionLabels[sel]) {
            var lbl = labelFor(ent.target);
            if (lbl) sectionLabels[sel] = lbl;
          }
          if (ent.isIntersecting) {
            visibleSince[sel] = now;
          } else if (visibleSince[sel]) {
            sectionTimes[sel] = (sectionTimes[sel] || 0) + (now - visibleSince[sel]);
            delete visibleSince[sel];
          }
        }
      }, { threshold: 0.5 });
    } catch (e) {}

    // Track which elements we've already handed to the IntersectionObserver
    // so observeSections() can be called repeatedly (e.g. from the SPA
    // MutationObserver fallback) without re-observing the same element
    // and without inflating the sectionIndex counter.
    var observedElements = typeof WeakSet === "function" ? new WeakSet() : null;

    function observeSections() {
      if (!io) return 0;
      var newlyObserved = 0;
      try {
        var targets = document.querySelectorAll("section, article, main > div");
        for (var i = 0; i < targets.length && i < 100; i++) {
          var t = targets[i];
          if (observedElements) {
            if (observedElements.has(t)) continue;
            observedElements.add(t);
          }
          io.observe(t);
          newlyObserved++;
        }
      } catch (e) {}
      return newlyObserved;
    }

    /** Re-observe sections after an SPA route change. Runs observeSections()
     * on the next animation frame (fast path) AND sets up a temporary
     * MutationObserver on document.body as a safety net for frameworks
     * (React Suspense, Next.js async layouts, Vue async components) that
     * may not have mounted the new route's DOM within a single frame.
     * The MutationObserver auto-disconnects after 5 seconds OR as soon as
     * observeSections() reports at least one newly observed section —
     * whichever comes first — to avoid indefinite observation. */
    function observeSectionsWithMutationFallback() {
      // Fast path: next animation frame
      if (typeof window.requestAnimationFrame === "function") {
        window.requestAnimationFrame(function () { setTimeout(observeSections, 0); });
      } else {
        setTimeout(observeSections, 50);
      }

      // Safety net: only runs in SPA mode and only for a bounded window
      if (typeof MutationObserver !== "function") return;
      var mo = null;
      var disconnectTimer = null;
      var pending = false;
      function stop() {
        if (mo) { try { mo.disconnect(); } catch (e) {} mo = null; }
        if (disconnectTimer) { clearTimeout(disconnectTimer); disconnectTimer = null; }
      }
      function scheduleObserve() {
        if (pending) return; // debounce/coalesce rapid mutations
        pending = true;
        var run = function () {
          pending = false;
          var newCount = observeSections();
          // Stop as soon as we've observed at least one new section — the
          // MutationObserver has done its job for this route change.
          if (newCount > 0) stop();
        };
        if (typeof window.requestAnimationFrame === "function") {
          window.requestAnimationFrame(run);
        } else {
          setTimeout(run, 0);
        }
      }
      try {
        mo = new MutationObserver(scheduleObserve);
        mo.observe(document.body || document.documentElement, { childList: true, subtree: true });
        // Hard timeout: stop watching after 5 seconds regardless of whether
        // any new sections were found. Prevents indefinite observation.
        disconnectTimer = setTimeout(stop, 5000);
      } catch (e) { /* ignore — rAF fast path already scheduled */ }
    }

    observeSections();

    // ── 4. Flush (on unload OR SPA route change) ───────────────────────
    // If `resetAfter` is true, we flush the current pageview's data and
    // reset all state so subsequent tracking belongs to the next route.
    // Used by the SPA navigation listeners below.
    function finalFlush(resetAfter) {
      // Close any still-visible sections
      var now = Date.now();
      for (var sel in visibleSince) {
        if (Object.prototype.hasOwnProperty.call(visibleSince, sel)) {
          sectionTimes[sel] = (sectionTimes[sel] || 0) + (now - visibleSince[sel]);
        }
      }
      flushClicks(true);
      var d = dims();
      beacon({
        eventType: "scroll",
        maxScrollPercent: maxScroll,
        milestonesReached: milestonesReached,
        timeOnPage: Math.round((Date.now() - startTime) / 1000),
        pageWidth: d.pageWidth, pageHeight: d.pageHeight,
        viewportWidth: d.viewportWidth, viewportHeight: d.viewportHeight,
      });

      // Build attention payload as an array of { selector, label, ms }
      // entries so the API can read labels alongside times. sectionTimes
      // is also sent for backward compatibility with the old aggregator.
      var attentionEntries = [];
      for (var k in sectionTimes) {
        if (Object.prototype.hasOwnProperty.call(sectionTimes, k)) {
          attentionEntries.push({ selector: k, label: sectionLabels[k] || null, ms: sectionTimes[k] });
        }
      }
      beacon({
        eventType: "attention",
        events: attentionEntries,
        sectionTimes: sectionTimes,
        timeOnPage: Math.round((Date.now() - startTime) / 1000),
      });

      if (resetAfter) {
        // Reset all per-pageview state so the next virtual route starts fresh
        sectionTimes = {};
        visibleSince = {};
        sectionLabels = {};
        sectionIndex = 0;
        startTime = Date.now();
        maxScroll = 0;
        milestonesReached = [];
        pageUrl = window.location.href;
        // Reset the observed-elements tracker so new DOM elements mounted
        // by the SPA for this route are eligible to be observed. Old
        // references to detached DOM nodes will be garbage collected.
        if (typeof WeakSet === "function") observedElements = new WeakSet();
        // Re-observe with the MutationObserver safety net for frameworks
        // that mount content asynchronously (React Suspense, Next.js async
        // layouts, Vue async components, etc.)
        observeSectionsWithMutationFallback();
      }
    }

    // Standard flush on page unload (MPA path)
    window.addEventListener("beforeunload", function () { finalFlush(false); }, { passive: true });
    // visibilitychange — some browsers (iOS Safari) don't fire beforeunload reliably
    document.addEventListener("visibilitychange", function () {
      if (document.visibilityState === "hidden") finalFlush(false);
    }, { passive: true });

    // ── 5. SPA route change handling ───────────────────────────────────
    // Gated behind cfg.spa / window.meSEOConfig.spa so it only runs for
    // sites that actually use client-side routing. Listens for popstate
    // (back/forward button) and monkey-patches pushState/replaceState to
    // catch programmatic route changes since neither fires a DOM event
    // natively.
    if (spaMode) {
      function handleRouteChange() {
        // Only flush if the URL actually changed — some frameworks call
        // pushState for state-only updates that don't change the path
        if (window.location.href !== pageUrl) {
          finalFlush(true);
        }
      }
      window.addEventListener("popstate", handleRouteChange, { passive: true });
      try {
        var origPush = history.pushState;
        var origReplace = history.replaceState;
        history.pushState = function () {
          var ret = origPush.apply(this, arguments);
          handleRouteChange();
          return ret;
        };
        history.replaceState = function () {
          var ret = origReplace.apply(this, arguments);
          handleRouteChange();
          return ret;
        };
      } catch (e) { /* history API not patchable — listener alone will catch popstate */ }
    }
  }

  window.meSEOHeatmap = { init: init };
})();
