services/analytics/AnalyticsSDK.jsjavascript
// Vendored from ~/Development/js/messe-tracking/analytics.js (messe-tracking repo).
// Keep this file a 1:1 mirror of the source SDK; project-specific adjustments
// (config sourcing, graceful degradation) live in analytics.js instead.
export class AnalyticsSDK {
  constructor(config = {}) {
    if (!config.exponatId) {
      throw new Error("[Analytics] Mandatory configuration missing: 'exponatId' must be provided.");
    }

    if (!config.apiKey) {
      throw new Error(
        "[Analytics] Mandatory configuration missing: 'apiKey' (x-api-key) must be provided."
      );
    }

    this.exponatId = config.exponatId;
    this.apiKey = config.apiKey;
    this.endpoint = config.endpoint || "/events";
    this.batchIntervalMs = config.batchIntervalMs || 5000;
    this.maxBatchSize = config.maxBatchSize || 20;

    this.deviceId = config.deviceId || this._getOrCreateDeviceId();
    this.queue = [];
    this.db = null;

    this._initIndexedDB().then(() => {
      this._flushStoredQueue();
    });

    this._setupGlobalEventListeners();
    this._setupHistoryTracking();
    this._startBatchTimer();
  }

  // ----------------------------------------------------------
  // Identity Management
  // ----------------------------------------------------------
  _getOrCreateDeviceId() {
    let id = localStorage.getItem("analytics_device_id");
    if (!id) {
      id = "dev_" + crypto.randomUUID();
      localStorage.setItem("analytics_device_id", id);
    }
    return id;
  }

  // ----------------------------------------------------------
  // DOM Selector Path Resolver (Normal & Shadow DOM)
  // ----------------------------------------------------------

  _getDomPath(element) {
    const path = [];
    let currentEl = element;

    while (currentEl) {
      if (currentEl instanceof ShadowRoot) {
        path.unshift(`[ShadowRoot]`);
        currentEl = currentEl.host;
        continue;
      }

      if (currentEl.nodeType !== Node.ELEMENT_NODE) {
        currentEl = currentEl.parentNode;
        continue;
      }

      let selector = currentEl.nodeName.toLowerCase();
      if (currentEl.id) {
        selector += `#${currentEl.id}`;
        path.unshift(selector);
        break;
      } else {
        if (
          currentEl.className &&
          typeof currentEl.className === "string" &&
          currentEl.className.trim()
        ) {
          selector += `.${currentEl.className.trim().replace(/\s+/g, ".")}`;
        }
        let siblingIndex = 1;
        let sib = currentEl.previousElementSibling;
        while (sib) {
          if (sib.nodeName.toLowerCase() === currentEl.nodeName.toLowerCase()) {
            siblingIndex++;
          }
          sib = sib.previousElementSibling;
        }
        selector += `:nth-of-type(${siblingIndex})`;
      }

      path.unshift(selector);
      currentEl = currentEl.parentNode || currentEl.host;
    }

    return path.join(" > ");
  }

  // ----------------------------------------------------------
  // Implicit Global Event Tracking
  // ----------------------------------------------------------

  _setupGlobalEventListeners() {
    const eventsToTrack = ["click", "touchstart", "keyup"];

    eventsToTrack.forEach((eventType) => {
      window.addEventListener(
        eventType,
        (event) => {
          const path = event.composedPath ? event.composedPath() : [];
          const target = path[0] || event.target;

          if (!target || !(target instanceof HTMLElement || target instanceof SVGElement)) return;

          const elementText = target.innerText
            ? target.innerText.substring(0, 50).trim()
            : target.tagName || null;

          // SVGElement.className is an SVGAnimatedString, not a plain string
          // (unlike HTMLElement.className) - IndexedDB's structured clone
          // can't store it as-is, which throws DataCloneError on every click
          // inside an inline <svg> (e.g. ValueInfographic's Venn diagram).
          const className =
            typeof target.className === "string"
              ? target.className
              : target.className?.baseVal || null;

          this.trackEvent("click", {
            element: elementText,
            domPath: this._getDomPath(target),
            payload: {
              interactionType: event.type,
              id: target.id || null,
              className: className,
            },
          });
        },
        true
      );
    });
  }

  // ----------------------------------------------------------
  // Framework agnostic History API Router Tracking
  // ----------------------------------------------------------

  _setupHistoryTracking() {
    let lastUrl = window.location.href;

    const notifyUrlChange = () => {
      const currentUrl = window.location.href;
      if (currentUrl !== lastUrl) {
        const previousUrl = lastUrl;
        lastUrl = currentUrl;
        this.trackPageView({ previousUrl, currentUrl });
      }
    };

    ["pushState", "replaceState"].forEach((method) => {
      const original = history[method];
      history[method] = (...args) => {
        const result = original.apply(history, args);
        notifyUrlChange();
        return result;
      };
    });

    window.addEventListener("popstate", notifyUrlChange);

    this.trackPageView({ previousUrl: null, currentUrl: lastUrl });
  }

  // ----------------------------------------------------------
  // Explicit Tracking API
  // ----------------------------------------------------------

  trackPageView(customData = {}) {
    this.recordPayload({
      eventType: "page_view",
      url: customData.currentUrl || window.location.href,
      payload: {
        title: document.title,
        previousUrl: customData.previousUrl || null,
      },
    });
  }

  trackEvent(eventType, options = {}) {
    this.recordPayload({
      eventType: eventType,
      element: options.element || null,
      domPath: options.domPath || null,
      payload: options.payload || null,
    });
  }

  trackSlideOrStory(storyId, slideIndex, extraPayload = {}) {
    this.recordPayload({
      eventType: "story_view",
      element: `Story ${storyId} - Slide ${slideIndex}`,
      payload: {
        storyId,
        slideIndex,
        ...extraPayload,
      },
    });
  }

  trackHotspot(hotspotId, extraPayload = {}) {
    this.recordPayload({
      eventType: "click",
      element: `Hotspot ${hotspotId}`,
      domPath: "Hotspot Overlay Component",
      payload: {
        hotspotId,
        ...extraPayload,
      },
    });
  }

  recordPayload({ eventType, url, element, domPath, payload }) {
    const event = {
      _localId: crypto.randomUUID(),
      exponatId: this.exponatId,
      deviceId: this.deviceId,
      eventType: eventType,
      url: url || window.location.href,
      timestamp: new Date().toISOString(),
    };

    if (element) event.element = element;
    if (domPath) event.domPath = domPath;
    if (payload && Object.keys(payload).length > 0) event.payload = payload;

    this._enqueue(event);
  }

  // ----------------------------------------------------------
  // Persistence & Caching
  // ----------------------------------------------------------

  async _initIndexedDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open("AnalyticsCacheDB", 1);
      request.onupgradeneeded = (e) => {
        const db = e.target.result;
        if (!db.objectStoreNames.contains("events")) {
          db.createObjectStore("events", { keyPath: "_localId" });
        }
      };
      request.onsuccess = (e) => {
        this.db = e.target.result;
        resolve();
      };
      request.onerror = (e) => reject(e);
    });
  }

  async _enqueue(event) {
    this.queue.push(event);
    if (this.db) {
      const tx = this.db.transaction("events", "readwrite");
      tx.objectStore("events").put(event);
    }

    if (this.queue.length >= this.maxBatchSize) {
      this.flush();
    }
  }

  _startBatchTimer() {
    setInterval(() => this.flush(), this.batchIntervalMs);
  }

  async flush() {
    if (this.queue.length === 0) return;

    const batchToSend = [...this.queue];

    // Remove internal _localId property before sending to API
    const cleanedBatch = batchToSend.map((item) => {
      const eventData = { ...item };
      delete eventData._localId;
      return eventData;
    });

    try {
      const response = await fetch(this.endpoint, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-api-key": this.apiKey,
        },
        body: JSON.stringify(cleanedBatch),
        keepalive: true,
      });

      if (response.ok) {
        // Clear successfully sent items from memory queue and IndexedDB
        this.queue = this.queue.filter(
          (item) => !batchToSend.some((sent) => sent._localId === item._localId)
        );

        if (this.db) {
          const tx = this.db.transaction("events", "readwrite");
          const store = tx.objectStore("events");
          batchToSend.forEach((item) => store.delete(item._localId));
        }
      } else {
        // eslint-disable-next-line no-console
        console.warn(`[Analytics] Backend rejected batch with HTTP ${response.status}`);
      }
    } catch (err) {
      // eslint-disable-next-line no-console
      console.warn("[Analytics] Network error or offline mode. Retrying on next tick.", err);
    }
  }

  async _flushStoredQueue() {
    if (!this.db) return;
    const tx = this.db.transaction("events", "readonly");
    const store = tx.objectStore("events");
    const request = store.getAll();

    request.onsuccess = () => {
      if (request.result && request.result.length > 0) {
        this.queue = [...request.result, ...this.queue];
        this.flush();
      }
    };
  }
}