// recorder.js - content script for recording user interactions into steps

(function () {
  if (window.__RR_RECORDER_INSTALLED__) return;
  window.__RR_RECORDER_INSTALLED__ = true;

  // ================================================================
  // 1) SHARED CONFIG + HELPERS
  // ================================================================
  const shared = window.__RR_RECORDER_SHARED__;
  if (!shared) {
    console.error('Record & Replay recorder shared helpers are missing');
    return;
  }
  const { CONFIG, FRAME_EVENT, SelectorEngine } = shared;
  const RECORDER_EVENT_PROTOCOL_VERSION = 1;
  const SEND_RETRY_MAX = 2;
  const SEND_RETRY_BASE_MS = 80;

  // ================================================================
  // 2) UI CLASS (injected via constructor)
  // ================================================================
  class UI {
    constructor(recorder) {
      this.recorder = recorder;
      this._box = null;
      // Timeline elements state
      this._timeline = null;
      this._count = 0;
      this._timelineBox = null;
      this._collapsed = false;
    }
    ensure() {
      const rec = this.recorder;
      if (window !== window.top) return;
      let root = document.getElementById('__rr_rec_overlay');
      if (root) return;
      root = document.createElement('div');
      root.id = '__rr_rec_overlay';
      Object.assign(root.style, {
        position: 'fixed',
        top: '10px',
        right: '10px',
        zIndex: 2147483646,
        fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
      });
      root.innerHTML = `
        <div id="__rr_rec_panel" style="background: rgba(220,38,38,0.95); color: #fff; padding:8px 10px; border-radius:8px; display:flex; align-items:center; gap:8px; box-shadow:0 4px 16px rgba(0,0,0,0.2);">
          <span id="__rr_badge" style="font-weight:600;">Recording</span>
          <label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
            <input id="__rr_hide_values" type="checkbox" style="vertical-align:middle;" />Hide input value
          </label>
          <label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
            <input id="__rr_enable_highlight" type="checkbox" style="vertical-align:middle;" />Highlight
          </label>
          <button id="__rr_toggle_timeline" style="background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.5); border-radius:6px; padding:2px 6px; cursor:pointer; font-size:12px;">Collapse</button>
          <button id="__rr_pause" style="background:#fff; color:#111; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">Pause</button>
          <button id="__rr_stop" style="background:#111; color:#fff; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">Stop</button>
        </div>`;
      document.documentElement.appendChild(root);
      // Build timeline container just below the panel
      const timeline = document.createElement('div');
      timeline.id = '__rr_rec_timeline';
      Object.assign(timeline.style, {
        marginTop: '8px',
        width: '360px',
        maxHeight: '220px',
        overflow: 'auto',
        background: 'rgba(17,24,39,0.85)',
        color: '#F9FAFB',
        border: '1px solid rgba(255,255,255,0.2)',
        borderRadius: '8px',
        boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
        padding: '8px 10px',
        fontSize: '12px',
        lineHeight: '1.4',
      });
      const header = document.createElement('div');
      header.textContent = 'Recorded steps';
      header.style.opacity = '0.8';
      header.style.marginBottom = '4px';
      const list = document.createElement('ol');
      list.id = '__rr_rec_timeline_list';
      list.style.listStyle = 'none';
      list.style.margin = '0';
      list.style.padding = '0';
      list.style.display = 'flex';
      list.style.flexDirection = 'column';
      list.style.gap = '4px';
      timeline.appendChild(header);
      timeline.appendChild(list);
      root.appendChild(timeline);
      this._timeline = list;
      this._timelineBox = timeline;
      const btnPause = root.querySelector('#__rr_pause');
      const btnStop = root.querySelector('#__rr_stop');
      const hideChk = root.querySelector('#__rr_hide_values');
      const highlightChk = root.querySelector('#__rr_enable_highlight');
      const btnToggle = root.querySelector('#__rr_toggle_timeline');
      hideChk.checked = !!rec.hideInputValues;
      hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked));
      highlightChk.checked = !!rec.highlightEnabled;
      highlightChk.addEventListener('change', () => {
        rec.highlightEnabled = !!highlightChk.checked;
        rec._updateHoverListener();
      });
      if (btnToggle) {
        btnToggle.addEventListener('click', () => {
          this._collapsed = !this._collapsed;
          if (this._timelineBox)
            this._timelineBox.style.display = this._collapsed ? 'none' : 'block';
          btnToggle.textContent = this._collapsed ? 'Expand' : 'Collapse';
        });
      }
      btnPause.addEventListener('click', () => {
        if (!rec.isPaused) rec.pause();
        else rec.resume();
      });
      btnStop.addEventListener('click', () => {
        chrome.runtime.sendMessage({ type: 'rr_stop_recording' });
      });
      this._box = document.createElement('div');
      Object.assign(this._box.style, {
        position: 'fixed',
        border: '2px solid rgba(59,130,246,0.9)',
        borderRadius: '4px',
        background: 'rgba(59,130,246,0.15)',
        pointerEvents: 'none',
        zIndex: 2147483645,
      });
      document.documentElement.appendChild(this._box);
      if (rec.highlightEnabled)
        document.addEventListener('mousemove', rec._onMouseMove, { capture: true, passive: true });
      this.updateStatus();
    }
    remove() {
      if (window === window.top) {
        const root = document.getElementById('__rr_rec_overlay');
        if (root) root.remove();
        if (this._box) this._box.remove();
        this._timeline = null;
        this._timelineBox = null;
      }
    }
    updateStatus() {
      const badge = document.getElementById('__rr_badge');
      const pauseBtn = document.getElementById('__rr_pause');
      if (badge) badge.textContent = this.recorder.isPaused ? 'Paused': 'Recording';
      if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? 'Continue' : 'Pause';
    }

    // Reset the timeline list content
    resetTimeline() {
      this._count = 0;
      const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
      if (list) list.innerHTML = '';
    }

    // Append a new recorded step into the timeline UI
    appendStep(step) {
      const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
      if (!list) return;
      this._count += 1;
      const item = document.createElement('li');
      const text = this._formatStepText(step, this._count);
      item.setAttribute('data-step-id', step.id || '');
      item.style.display = 'flex';
      item.style.alignItems = 'flex-start';
      item.style.gap = '6px';
      item.innerHTML = `
        <span style="min-width:20px; text-align:right; opacity:0.8;">${this._count}.</span>
        <span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;">${text}</span>
      `;
      list.appendChild(item);
      while (list.children.length > CONFIG.UI_MAX_STEPS) {
        list.removeChild(list.firstChild);
      }
      const container = list.parentElement;
      if (container) container.scrollTop = container.scrollHeight;
    }

    /**
     * Apply a full timeline update from background.
     * Steps can be upserted in place (same id, updated fields) during fill debouncing.
     * Uses smart diffing to minimize DOM operations while ensuring fill values are accurate.
     */
    applyTimelineUpdate(steps) {
      try {
        if (window !== window.top) return;
        const list = Array.isArray(steps) ? steps : [];
        const total = list.length;
        // Ensure UI exists
        if (!this._timeline) this.ensure();
        if (!this._timeline) return;
        if (total === 0) {
          this.resetTimeline();
          return;
        }

        // Calculate the window of steps to display (last N steps)
        const windowStart = Math.max(0, total - CONFIG.UI_MAX_STEPS);
        const windowSteps = list.slice(windowStart);

        // Get current displayed step IDs
        const currentItems = this._timeline.children;
        const currentIds = [];
        for (let i = 0; i < currentItems.length; i++) {
          currentIds.push(currentItems[i].getAttribute('data-step-id') || '');
        }

        // Check if we need a full rebuild or can do incremental update
        const newIds = windowSteps.map((s) => s.id || '');
        const needsRebuild =
          currentIds.length !== newIds.length || currentIds.some((id, i) => id !== newIds[i]);

        if (needsRebuild) {
          // Full rebuild: either structure changed or it's simpler to rebuild
          this.resetTimeline();
          for (let i = 0; i < windowSteps.length; i++) {
            this._appendStepWithIndex(windowSteps[i], windowStart + i + 1);
          }
        } else {
          // Incremental update: same steps, just update values
          for (let i = 0; i < windowSteps.length; i++) {
            const step = windowSteps[i];
            const item = currentItems[i];
            if (item) {
              // Update the text content for this step
              const textSpan = item.querySelector('span:last-child');
              if (textSpan) {
                const newText = this._formatStepText(step, windowStart + i + 1);
                if (textSpan.textContent !== newText) {
                  textSpan.textContent = newText;
                }
              }
            }
          }
        }
        this._count = total;
      } catch {}
    }

    /**
     * Internal method to append a step with a specific display index.
     * Used by applyTimelineUpdate for proper numbering.
     */
    _appendStepWithIndex(step, displayIndex) {
      const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
      if (!list) return;
      const item = document.createElement('li');
      const text = this._formatStepText(step, displayIndex);
      item.setAttribute('data-step-id', step.id || '');
      item.style.display = 'flex';
      item.style.alignItems = 'flex-start';
      item.style.gap = '6px';
      item.innerHTML = `
        <span style="min-width:20px; text-align:right; opacity:0.8;">${displayIndex}.</span>
        <span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;">${text}</span>
      `;
      list.appendChild(item);
      const container = list.parentElement;
      if (container) container.scrollTop = container.scrollHeight;
    }

    // Create a short, human-readable text for a recorded step
    _formatStepText(step, _idx) {
      try {
        if (!step || typeof step !== 'object') return 'Unknown step';
        const t = step.type;
        const sel = step.target && step.target.selector ? step.target.selector : '';
        if (t === 'click' || t === 'dblclick') {
          return `${t === 'dblclick' ? 'Double click' : 'Click'}: ${sel || '(document)'}`;
        }
        if (t === 'fill') {
          const val = step.value;
          const shown = typeof val === 'string' && val.length > 0 ? val : String(val);
          return `Input: ${sel} = ${shown}`;
        }
        if (t === 'scroll') {
          const mode = step.mode === 'container' ? 'container' : 'page';
          const off = step.offset || {};
          return `scroll(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`;
        }
        if (t === 'openTab') return `Open tab: ${step.url || ''}`;
        if (t === 'switchTab') return `Switch tabs: Contains ${step.urlContains || ''}`;
        if (t === 'switchFrame')
          return `Switch Frame: Contains ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`;
        if (t === 'waitFor' || t === 'wait') {
          const condition = step.condition || {};
          if (condition.selector) return `Wait: selector hidden ${condition.selector}`;
          if (condition.text) return `Wait: text ${condition.text}`;
          if (condition.navigation) return `Wait: navigation`;
          if (condition.networkIdle) return `Wait: network idle`;
          return `Wait: ${sel || step.until || ''}`;
        }
        return `${t}`;
      } catch (_) {
        return 'steps';
      }
    }
  }

  // ================================================================
  // 3) MAIN CLASS: ContentRecorder (stateful)
  // ================================================================
  class ContentRecorder {
    constructor() {
      // State
      this.isRecording = false;
      this.isPaused = false;
      this.hideInputValues = false;
      this.highlightEnabled = true;
      this.hoverRAF = 0;
      this.frameSwitchPushed = false;
      this.batch = [];
      this.batchTimer = null;
      this.scrollTimer = null;
      // Protocol/session metadata for background ingest validation
      this.sessionId = '';
      this._sendSeq = 0;
      this._sendNonce = 0;

      // Local, content-side buffer for batching/merging steps during recording.
      // Not the authoritative Flow (background holds the real one).
      this.sessionBuffer = this._createSessionBuffer();
      // lastFill tracks the most recent fill step for debounce/merge
      // el: DOM element reference for reading final value on finalize
      this.lastFill = { step: null, ts: 0, el: null };
      // Input activity tracking for flush gate (separate from merge state)
      // Updated by both local input and iframe upsert messages
      this._lastInputActivityTs = 0;
      // Flush gate: tracks when a typing burst started to enforce MAX_TYPING_HOLD_MS
      this._typingBurstStartTs = 0;
      // Force flush timer: ensures MAX_TYPING_HOLD_MS is a hard upper bound
      // This timer is NOT reset on each input, only cleared on actual flush
      this._forceFlushTimer = null;
      // Recording-time element identity map (not persisted)
      this.el2ref = new WeakMap();
      this.refCounter = 0;

      // Bind handlers
      this._onClick = this._onClick.bind(this);
      this._onInput = this._onInput.bind(this);
      this._onDocInput = this._onDocInput.bind(this);
      this._onChange = this._onChange.bind(this);
      this._onMouseMove = this._onMouseMove.bind(this);
      this._onScroll = this._onScroll.bind(this);
      this._onFocusIn = this._onFocusIn.bind(this);
      this._onFocusOut = this._onFocusOut.bind(this);
      this._onKeyDown = this._onKeyDown.bind(this);
      this._onKeyUp = this._onKeyUp.bind(this);
      this._onWindowMessage = this._onWindowMessage.bind(this);
      this._onMutations = this._onMutations.bind(this);
      // Page lifecycle handlers for best-effort flush on navigation/close
      this._onPageHide = this._onPageHide.bind(this);
      this._onVisibilityChange = this._onVisibilityChange.bind(this);
      this.ui = new UI(this);
      this._scrollPending = null;

      // Focus tracking for per-element input listening
      this._focusedEl = null;
      // Keyboard state for combo recording
      this._pressed = new Set();
      this._lastKeyTs = 0;
      // Map to avoid duplicate switchFrame per iframe source (keyed by frame selector)
      this._frameSwitchMap = new Set();
      // Track last committed values per element ref for no-op fill filtering
      this._lastRecordedFieldValue = new Map();
      // Timestamp for pending single-click denoising
      this._pendingClickAt = 0;
      this._mutationObserver = null;
      this._waitDetector = {
        armedAt: 0,
        loadingSeen: false,
        loadingSelector: '',
        committed: false,
        baselineResourceCount: 0,
        networkProbeTimer: null,
      };
    }

    // Lifecycle
    start(flowMeta) {
      // Idempotent start: if already recording (and not paused), just ensure UI and listeners
      if (this.isRecording && !this.isPaused) {
        this.ui.ensure();
        this._updateHoverListener();
        return;
      }
      // If paused, treat start as resume to avoid resetting local buffer/UI timeline
      if (this.isPaused) {
        this.resume();
        return;
      }
      this._reset(flowMeta || {});
      this.isRecording = true;
      this.isPaused = false;
      this._attach();
      this.ui.ensure();
      this.ui.resetTimeline();
    }

    /**
     * Stop recording and flush all pending data.
     * This is the reliable stop that ensures no data is lost.
     * Waits for background to acknowledge receipt of all data before returning.
     * @returns {Promise<{ack: boolean, steps: number, variables: number}>}
     */
    async stop() {
      if (!this.isRecording) {
        return { ack: true, steps: 0, variables: 0 };
      }

      this.isRecording = false;
      // Stop should clear paused state so detach fully cleans up (and barrier works consistently)
      this.isPaused = false;

      // Step 1: Finalize pending click (dblclick detector)
      this._finalizePendingClick();

      // Step 2: Finalize any pending input (draft mode)
      this._finalizePendingInput();

      // Step 3: Finalize any pending scroll
      this._finalizePendingScroll();

      // Step 4: In iframes, ensure the top-frame aggregator has processed our final postMessages
      // before we ACK the background stop (prevents missing iframe steps)
      let topSyncOk = true;
      if (window !== window.top) {
        topSyncOk = await this._syncStopBarrierToTop();
      }

      // Step 5: Clear timers BEFORE flush (prevent race conditions)
      if (this.batchTimer) clearTimeout(this.batchTimer);
      this.batchTimer = null;
      if (this.scrollTimer) clearTimeout(this.scrollTimer);
      this.scrollTimer = null;
      if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF);
      this.hoverRAF = 0;

      // Step 6: Flush any remaining batched steps and WAIT for ack
      const stepsCount = this.batch.length;
      let stepsAck = true;
      if (stepsCount > 0) {
        stepsAck = await this._flush();
      }

      // Step 7: Send all collected variables and WAIT for ack
      const variablesCount = this.sessionBuffer.variables?.length || 0;
      let variablesAck = true;
      if (variablesCount > 0) {
        variablesAck = await this._sendVariables();
      }

      // Step 8: Detach listeners and clean up UI
      this._detach();
      this.ui.remove();

      // Step 9: Reset state
      this.lastFill = { step: null, ts: 0, el: null };
      this._lastInputActivityTs = 0;
      this._typingBurstStartTs = 0;
      if (this._forceFlushTimer) {
        clearTimeout(this._forceFlushTimer);
        this._forceFlushTimer = null;
      }
      this.batch.length = 0;
      this.sessionBuffer.steps = [];

      // Return acknowledgment with stats
      // ack is true only if all sends were acknowledged
      return {
        ack: stepsAck && variablesAck && topSyncOk,
        steps: stepsCount,
        variables: variablesCount,
      };
    }

    /**
     * Finalize a pending click that hasn't been emitted yet.
     * The dblclick detector holds single clicks temporarily to detect double-clicks.
     * This ensures stop/pause flush includes the last single click.
     */
    _finalizePendingClick() {
      try {
        if (this._pendingClickTimer) clearTimeout(this._pendingClickTimer);
      } catch {}
      this._pendingClickTimer = null;

      try {
        if (this._pendingClick) this._pushStep(this._pendingClick);
      } catch {}
      this._pendingClick = null;
      this._pendingClickAt = 0;
    }

    /**
     * Finalize any pending input that hasn't been flushed yet.
     * This ensures the last input value is captured before stop/pause/navigation.
     * Uses lastFill.el (DOM reference) to read the current value.
     */
    _finalizePendingInput() {
      const last = this.lastFill;
      if (!last || !last.step) return;

      // Commit the latest value from the DOM element
      try {
        const el = last.el;
        if (el) {
          const freshValue = this._getElementValue(el, last.step.value);
          if (freshValue !== last.step.value) {
            last.step.value = freshValue;
            this.sessionBuffer.meta.updatedAt = new Date().toISOString();
          }
        }
      } catch {
        // Element may no longer exist, that's OK - we keep the last known value
      }

      // Enqueue for upsert to ensure background gets the final value
      try {
        this._rememberRecordedValue(last.step._recordingRef, last.step.value);
        this._enqueueForUpsert(last.step);
      } catch {}

      // Reset state
      this.lastFill = { step: null, ts: 0, el: null };
      this._typingBurstStartTs = 0;
    }

    /**
     * Get the current value from an element, handling sensitive fields and contenteditable.
     * @param {Element} el - The element to read from
     * @param {string} existingValue - The existing recorded value (may be a variable placeholder)
     * @returns {string} The value to record
     */
    _getElementValue(el, existingValue) {
      if (!el) return existingValue || '';

      const isContentEditable =
        el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true;

      // If existing value is already a variable placeholder, preserve it
      // Use strict pattern to avoid false positives for user input like "{abc}"
      const existing = typeof existingValue === 'string' ? existingValue : '';
      const varPlaceholderPattern =
        /^\{(?:var_[a-z0-9]{4}|file_[a-z0-9]{4}|[a-zA-Z_][a-zA-Z0-9_]*)\}$/;
      if (varPlaceholderPattern.test(existing)) {
        return existing;
      }

      // Check if this is a sensitive field
      const isSensitive =
        this.hideInputValues ||
        (!isContentEditable &&
          CONFIG.SENSITIVE_INPUT_TYPES.has(
            ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(),
          ));

      if (isSensitive) {
        // Return existing variable or create new one (should already exist from initial capture)
        return existing;
      }

      // Read fresh value from DOM
      try {
        if (isContentEditable) {
          return /** @type {HTMLElement} */ (el).innerText || '';
        }
        if (
          el instanceof HTMLInputElement ||
          el instanceof HTMLTextAreaElement ||
          el instanceof HTMLSelectElement
        ) {
          return el.value || '';
        }
      } catch {}

      return existing || '';
    }

    /**
     * Finalize any pending scroll that hasn't been committed yet.
     * Converts the pending scroll data into a proper scroll step.
     */
    _finalizePendingScroll() {
      if (!this._scrollPending) return;

      const pending = this._scrollPending;
      this._scrollPending = null;

      const { isDoc, target, top, left } = pending;

      // Try merge with last step (same logic as _onScroll timer callback)
      const steps = this.sessionBuffer.steps;
      const last = steps.length ? steps[steps.length - 1] : null;
      if (last && last.type === 'scroll') {
        const sameDoc = isDoc && !last.target && last.mode === 'offset';
        const sameEl =
          !isDoc &&
          last.target &&
          last.target.selector &&
          target &&
          last.target.selector === target.selector &&
          last.mode === 'container';
        if (sameDoc || sameEl) {
          last.offset = { y: top, x: left };
          this.sessionBuffer.meta.updatedAt = new Date().toISOString();
          return;
        }
      }

      // Create new scroll step
      if (isDoc) {
        this._pushStep({
          type: 'scroll',
          mode: 'offset',
          offset: { y: top, x: left },
          screenshotOnFail: false,
        });
      } else {
        this._pushStep({
          type: 'scroll',
          mode: 'container',
          target: target,
          offset: { y: top, x: left },
          screenshotOnFail: false,
        });
      }
    }

    /**
     * Send all collected variables to background.
     * @returns {Promise<boolean>} - Resolves when background acknowledges receipt
     */
    async _sendVariables() {
      if (!this.sessionBuffer.variables || this.sessionBuffer.variables.length === 0) {
        return true;
      }
      return this._send({ kind: 'variables', variables: this.sessionBuffer.variables });
    }

    /**
     * Pause recording. Flushes pending data before pausing.
     */
    pause() {
      if (!this.isRecording || this.isPaused) return;

      // Finalize pending data before pausing
      this._finalizePendingClick();
      this._finalizePendingInput();
      this._finalizePendingScroll();

      // Flush batched steps
      if (this.batch.length > 0) {
        this._flush();
      }

      // Clear timers
      if (this.batchTimer) clearTimeout(this.batchTimer);
      this.batchTimer = null;
      if (this.scrollTimer) clearTimeout(this.scrollTimer);
      this.scrollTimer = null;

      this.isPaused = true;
      this._detach();
      this.ui.updateStatus();
    }

    /**
     * Resume recording after pause.
     */
    resume() {
      if (!this.isPaused) return;

      this.isRecording = true;
      this.isPaused = false;
      this._attach();
      this.ui.ensure();
      this.ui.updateStatus();
    }

    // DOM listeners
    _attach() {
      document.addEventListener('click', this._onClick, true);
      // Use focusin/out to attach input listener only to focused element
      document.addEventListener('focusin', this._onFocusIn, true);
      document.addEventListener('focusout', this._onFocusOut, true);
      // Document-level input capture to support Shadow DOM (custom elements)
      // Use capture phase + composedPath to find inner editable control
      document.addEventListener('input', this._onDocInput, true);
      document.addEventListener('change', this._onChange, true);
      // capture-phase scroll to catch non-bubbling events on any container (passive to avoid jank)
      document.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
      // Keyboard: record Enter and modifier combos
      document.addEventListener('keydown', this._onKeyDown, true);
      document.addEventListener('keyup', this._onKeyUp, true);
      // Page lifecycle: best-effort flush on navigation/close
      window.addEventListener('pagehide', this._onPageHide, true);
      document.addEventListener('visibilitychange', this._onVisibilityChange, true);
      // Cross-frame: top window aggregates iframe-recorded steps
      if (window === window.top) window.addEventListener('message', this._onWindowMessage, true);
      this._updateHoverListener();
      this._ensureMutationObserver();
    }

    _detach() {
      document.removeEventListener('click', this._onClick, true);
      document.removeEventListener('focusin', this._onFocusIn, true);
      document.removeEventListener('focusout', this._onFocusOut, true);
      document.removeEventListener('input', this._onDocInput, true);
      document.removeEventListener('change', this._onChange, true);
      document.removeEventListener('scroll', this._onScroll, { capture: true });
      document.removeEventListener('keydown', this._onKeyDown, true);
      document.removeEventListener('keyup', this._onKeyUp, true);
      window.removeEventListener('pagehide', this._onPageHide, true);
      document.removeEventListener('visibilitychange', this._onVisibilityChange, true);
      document.removeEventListener('mousemove', this._onMouseMove, { capture: true });
      // Keep top-frame aggregator alive during pause; stop() clears isPaused and will remove it
      if (window === window.top && !this.isPaused)
        window.removeEventListener('message', this._onWindowMessage, true);
      // Detach per-element input listener if any
      if (this._focusedEl) this._focusedEl.removeEventListener('input', this._onInput, true);
      this._focusedEl = null;
      this._disconnectMutationObserver();
      // Best-effort cleanup for timers/raf when detaching
      if (this.batchTimer) clearTimeout(this.batchTimer);
      this.batchTimer = null;
      if (this.scrollTimer) clearTimeout(this.scrollTimer);
      this.scrollTimer = null;
      if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF);
      this.hoverRAF = 0;
      // Clear pending click state (stop/pause flush it before detach)
      if (this._pendingClickTimer) {
        clearTimeout(this._pendingClickTimer);
      }
      this._pendingClickTimer = null;
      this._pendingClick = null;
      this._pendingClickAt = 0;
    }

    _updateHoverListener() {
      if (window !== window.top) return;
      document.removeEventListener('mousemove', this._onMouseMove, { capture: true });
      if (this.isRecording && !this.isPaused && this.highlightEnabled) {
        document.addEventListener('mousemove', this._onMouseMove, { capture: true, passive: true });
      }
    }

    _ensureMutationObserver() {
      if (this._mutationObserver || typeof MutationObserver === 'undefined') return;
      try {
        this._mutationObserver = new MutationObserver(this._onMutations);
        this._mutationObserver.observe(document.documentElement || document.body, {
          childList: true,
          subtree: true,
          attributes: true,
          attributeFilter: ['class', 'style', 'hidden', 'aria-busy'],
        });
      } catch {
        this._mutationObserver = null;
      }
    }

    _disconnectMutationObserver() {
      if (this._mutationObserver) {
        try {
          this._mutationObserver.disconnect();
        } catch {}
      }
      this._mutationObserver = null;
      this._clearWaitProbeTimer();
      this._waitDetector.armedAt = 0;
      this._waitDetector.loadingSeen = false;
      this._waitDetector.loadingSelector = '';
      this._waitDetector.committed = false;
      this._waitDetector.baselineResourceCount = 0;
    }

    _clearWaitProbeTimer() {
      try {
        if (this._waitDetector.networkProbeTimer) {
          clearTimeout(this._waitDetector.networkProbeTimer);
          this._waitDetector.networkProbeTimer = null;
        }
      } catch {}
    }

    _getResourceCount() {
      try {
        if (!window.performance || typeof window.performance.getEntriesByType !== 'function') {
          return 0;
        }
        return window.performance.getEntriesByType('resource').length;
      } catch {
        return 0;
      }
    }

    _armWaitDetector(step) {
      if (!step || (step.type !== 'click' && step.type !== 'dblclick')) return;
      this._clearWaitProbeTimer();
      this._waitDetector.armedAt = Date.now();
      this._waitDetector.loadingSeen = false;
      this._waitDetector.loadingSelector = '';
      this._waitDetector.committed = false;
      this._waitDetector.baselineResourceCount = this._getResourceCount();
      this._waitDetector.networkProbeTimer = setTimeout(() => {
        const detector = this._waitDetector;
        detector.networkProbeTimer = null;
        if (!this.isRecording || this.isPaused || detector.committed || !detector.armedAt) return;
        if (detector.loadingSeen) return;
        const hasNetworkActivity = this._getResourceCount() > detector.baselineResourceCount;
        if (!hasNetworkActivity) return;
        this._pushStep({
          type: 'wait',
          condition: { networkIdle: true },
          timeoutMs: 15000,
          screenshotOnFail: false,
        });
        detector.committed = true;
        detector.armedAt = 0;
      }, 900);
    }

    _isLoadingLikeElement(el) {
      if (!(el instanceof Element)) return false;
      const attrBusy = (el.getAttribute && el.getAttribute('aria-busy')) || '';
      if (String(attrBusy).toLowerCase() === 'true') return true;
      const cls = `${el.className || ''} ${el.id || ''}`.toLowerCase();
      if (!cls) return false;
      return (
        cls.includes('loading') ||
        cls.includes('spinner') ||
        cls.includes('skeleton') ||
        cls.includes('progress') ||
        cls.includes('pending')
      );
    }

    _onMutations(records) {
      if (!this.isRecording || this.isPaused) return;
      const detector = this._waitDetector;
      if (!detector.armedAt || detector.committed) return;
      if (Date.now() - detector.armedAt > 4500) {
        this._clearWaitProbeTimer();
        detector.armedAt = 0;
        return;
      }

      for (const rec of records || []) {
        if (!detector.loadingSeen && rec.type === 'childList') {
          const added = Array.from(rec.addedNodes || []);
          for (const node of added) {
            if (!(node instanceof Element)) continue;
            const candidate =
              this._isLoadingLikeElement(node)
                ? node
                : node.querySelector &&
                    node.querySelector(
                      '[aria-busy="true"], .loading, .spinner, .skeleton, [class*="loading"], [class*="spinner"], [class*="skeleton"]',
                    );
            if (!candidate || !(candidate instanceof Element)) continue;
            const target = SelectorEngine.buildTarget(candidate);
            const selector = target && target.selector ? String(target.selector) : '';
            if (!selector) continue;
            detector.loadingSelector = selector;
            detector.loadingSeen = true;
            break;
          }
        }

        if (detector.loadingSeen && detector.loadingSelector && !detector.committed) {
          if (rec.type === 'childList') {
            const removed = Array.from(rec.removedNodes || []);
            const gone = removed.some((node) => {
              if (!(node instanceof Element)) return false;
              try {
                if (node.matches(detector.loadingSelector)) return true;
              } catch {}
              try {
                return !!(node.querySelector && node.querySelector(detector.loadingSelector));
              } catch {
                return false;
              }
            });
            if (gone) {
              this._pushStep({
                type: 'wait',
                condition: { selector: detector.loadingSelector, visible: false },
                timeoutMs: 15000,
                screenshotOnFail: false,
              });
              this._clearWaitProbeTimer();
              detector.committed = true;
              detector.armedAt = 0;
              return;
            }
          }

          if (rec.type === 'attributes' && rec.target instanceof Element) {
            let stillThere = false;
            try {
              stillThere = rec.target.matches(detector.loadingSelector);
            } catch {}
            if (stillThere) {
              const hidden =
                rec.target.hidden === true ||
                rec.target.getAttribute('aria-busy') === 'false' ||
                rec.target.getAttribute('aria-hidden') === 'true';
              if (hidden) {
                this._pushStep({
                  type: 'wait',
                  condition: { selector: detector.loadingSelector, visible: false },
                  timeoutMs: 15000,
                  screenshotOnFail: false,
                });
                this._clearWaitProbeTimer();
                detector.committed = true;
                detector.armedAt = 0;
                return;
              }
            }
          }
        }
      }
    }

    // Flow helpers (content-side buffer only)
    _createSessionBuffer() {
      const nowIso = new Date().toISOString();
      return {
        id: `flow_${Date.now()}`,
        name: 'Unnamed recording',
        version: 1,
        steps: [],
        variables: [],
        meta: { createdAt: nowIso, updatedAt: nowIso },
      };
    }

    _reset(meta) {
      this.sessionBuffer = this._createSessionBuffer();
      this.batch.length = 0;
      if (this.batchTimer) clearTimeout(this.batchTimer);
      this.batchTimer = null;
      if (this.scrollTimer) clearTimeout(this.scrollTimer);
      this.scrollTimer = null;
      try {
        if (meta && typeof meta === 'object') {
          if (meta.id) this.sessionBuffer.id = String(meta.id);
          if (meta.name) this.sessionBuffer.name = String(meta.name);
          if (meta.description) this.sessionBuffer.description = String(meta.description);
          this.sessionId = meta.sessionId ? String(meta.sessionId) : '';
        } else {
          this.sessionId = '';
        }
      } catch {}
      this._sendSeq = 0;
      this._sendNonce = 0;
      this.lastFill = { step: null, ts: 0, el: null };
      this._lastInputActivityTs = 0;
      this._typingBurstStartTs = 0;
      if (this._forceFlushTimer) {
        clearTimeout(this._forceFlushTimer);
        this._forceFlushTimer = null;
      }
      this.frameSwitchPushed = false;
      this._pendingClickAt = 0;
      this._clearWaitProbeTimer();
      this._waitDetector.armedAt = 0;
      this._waitDetector.loadingSeen = false;
      this._waitDetector.loadingSelector = '';
      this._waitDetector.committed = false;
      this._waitDetector.baselineResourceCount = 0;
      try {
        this._lastRecordedFieldValue.clear();
      } catch {}
    }

    /**
     * Update input activity timestamp (used for flush gate).
     * Called on local input and iframe upsert messages.
     */
    _updateInputActivity() {
      const now = Date.now();
      const prevActivityTs = this._lastInputActivityTs || 0;
      this._lastInputActivityTs = now;
      // Start a new burst if previous one expired (or this is first input)
      if (!this._typingBurstStartTs || now - prevActivityTs > CONFIG.INPUT_DEBOUNCE_MS) {
        this._typingBurstStartTs = now;
        // Start force flush timer (hard upper bound for MAX_TYPING_HOLD_MS)
        this._startForceFlushTimer();
      }
    }

    /**
     * Start the force flush timer.
     * This timer ensures MAX_TYPING_HOLD_MS is a hard upper bound.
     * Unlike batchTimer, this timer is NOT reset on each input.
     */
    _startForceFlushTimer() {
      // Don't restart if already running
      if (this._forceFlushTimer) return;
      this._forceFlushTimer = setTimeout(() => {
        this._forceFlushTimer = null;
        // Force flush regardless of current input state
        if (this.batch.length > 0) {
          this._flush();
        }
      }, CONFIG.MAX_TYPING_HOLD_MS);
    }

    /**
     * Clear the force flush timer (called on actual flush).
     */
    _clearForceFlushTimer() {
      if (this._forceFlushTimer) {
        clearTimeout(this._forceFlushTimer);
        this._forceFlushTimer = null;
      }
      this._typingBurstStartTs = 0;
    }

    /**
     * Unified commit and flush logic.
     * Called at commit points: focusout, Enter key, pagehide, visibilitychange.
     * @param {Object} options
     * @param {boolean} [options.bestEffort=false] - If true, don't await (for unload events)
     */
    _commitAndFlush(options = {}) {
      if (!this.isRecording || this.isPaused) return;

      try {
        this._finalizePendingInput();
        this._finalizePendingScroll();
      } catch {}

      // Reset flush gate to allow immediate flush
      this._lastInputActivityTs = 0;
      this._typingBurstStartTs = 0;
      this._clearForceFlushTimer();

      // Flush (best-effort for unload events)
      try {
        if (this.batch.length > 0) this._flush();
      } catch {}
      try {
        const variablesCount = this.sessionBuffer.variables?.length || 0;
        if (variablesCount > 0) this._sendVariables();
      } catch {}

      // If in iframe, ask top to flush too
      this._requestTopFlush();
    }

    _pushStep(step) {
      step.id = step.id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
      // In iframes, forward to top for aggregation (compute frame selector there)
      if (window !== window.top) {
        try {
          const payload = {
            kind: 'iframeStep',
            href: String(location && location.href ? location.href : ''),
            step,
          };
          window.top.postMessage({ type: FRAME_EVENT, payload }, '*');
          return; // Do not push locally in subframe
        } catch {}
      }
      // Top window: optionally insert a switchFrame if this step originated from an iframe message
      this.sessionBuffer.steps.push(step);
      this.sessionBuffer.meta.updatedAt = new Date().toISOString();
      this.batch.push(step);

      // Track input activity for fill steps (to enforce flush gate)
      if (step && step.type === 'fill') {
        this._updateInputActivity();
      }
      if (step && (step.type === 'click' || step.type === 'dblclick')) {
        this._armWaitDetector(step);
      }

      this._scheduleFlush();
    }

    /**
     * Calculate the appropriate flush delay based on typing activity.
     * During active typing, delay flush to avoid sending incomplete values.
     * Note: MAX_TYPING_HOLD_MS is enforced by _forceFlushTimer, not here.
     * @returns {number} Delay in milliseconds before next flush
     */
    _getFlushDelayMs() {
      const now = Date.now();
      const lastInputTs = this._lastInputActivityTs || 0;

      // If no recent input activity, use default batch delay
      if (!lastInputTs || now - lastInputTs >= CONFIG.INPUT_DEBOUNCE_MS) {
        return CONFIG.BATCH_SEND_MS;
      }

      // Wait for input debounce to complete
      const notBefore = lastInputTs + CONFIG.INPUT_DEBOUNCE_MS;
      const delay = Math.max(CONFIG.BATCH_SEND_MS, notBefore - now);

      return delay;
    }

    /**
     * Schedule a batch flush with appropriate delay.
     * Respects typing gate to avoid flushing incomplete fill values.
     */
    _scheduleFlush() {
      if (this.batchTimer) {
        clearTimeout(this.batchTimer);
      }
      const delay = this._getFlushDelayMs();
      this.batchTimer = setTimeout(() => {
        this.batchTimer = null;
        this._flush();
      }, delay);
    }

    /**
     * Request top frame to immediately flush its aggregated buffer.
     * Used by iframes on commit points (focusout, navigation) to ensure
     * their updates are sent to background promptly.
     */
    _requestTopFlush() {
      if (window === window.top) return;
      try {
        const payload = {
          kind: 'iframeFlush',
          href: String(location && location.href ? location.href : ''),
        };
        window.top.postMessage({ type: FRAME_EVENT, payload }, '*');
      } catch {}
    }

    /**
     * Iframe -> top stop barrier sync.
     * Ensures the top frame has processed all prior iframe postMessages (steps/upserts)
     * before this iframe responds to background STOP.
     * @returns {Promise<boolean>}
     */
    _syncStopBarrierToTop() {
      if (window === window.top) return Promise.resolve(true);
      const id = `sb_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
      const href = String(location && location.href ? location.href : '');
      const timeoutMs = 400;

      return new Promise((resolve) => {
        let done = false;
        const cleanup = (ok) => {
          if (done) return;
          done = true;
          try {
            window.removeEventListener('message', onMessage, true);
          } catch {}
          try {
            clearTimeout(t);
          } catch {}
          resolve(!!ok);
        };

        const onMessage = (ev) => {
          try {
            if (ev.source !== window.top) return;
            const d = ev && ev.data;
            if (!d || d.type !== FRAME_EVENT || !d.payload) return;
            const p = d.payload || {};
            if (p.kind !== 'iframeStopBarrierAck' || p.id !== id) return;
            cleanup(true);
          } catch {}
        };

        const t = setTimeout(() => cleanup(false), timeoutMs);
        try {
          window.addEventListener('message', onMessage, true);
          window.top.postMessage(
            { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrier', id, href } },
            '*',
          );
        } catch {
          cleanup(false);
        }
      });
    }

    /**
     * Best-effort drain and flush on page navigation/close.
     * Called by pagehide/visibilitychange handlers.
     * Does not await - unload events are time-constrained.
     */
    _bestEffortDrainAndFlush() {
      if (!this.isRecording || this.isPaused) return;

      // Flush pending single click (dblclick detector) before we may be unloaded
      this._finalizePendingClick();

      // Cancel timers (unload may not wait for them)
      try {
        if (this.batchTimer) clearTimeout(this.batchTimer);
        this.batchTimer = null;
        if (this.scrollTimer) clearTimeout(this.scrollTimer);
        this.scrollTimer = null;
      } catch {}

      // Use unified commit and flush
      this._commitAndFlush({ bestEffort: true });
    }

    /**
     * Handle pagehide event - best-effort flush before navigation/close.
     */
    _onPageHide() {
      this._bestEffortDrainAndFlush();
    }

    /**
     * Handle visibilitychange event - flush when page becomes hidden.
     * This catches some cases that pagehide misses (e.g., tab switch before navigation).
     */
    _onVisibilityChange() {
      try {
        if (document.visibilityState === 'hidden') {
          this._bestEffortDrainAndFlush();
        }
      } catch {}
    }

    /**
     * Flush batched steps to background.
     * @returns {Promise<boolean>} - Resolves when background acknowledges receipt
     */
    async _flush() {
      if (!this.batch.length) return true;

      // Clear force flush timer since we're flushing now
      this._clearForceFlushTimer();

      const rawSteps = this.batch.splice(0, this.batch.length);
      const steps = rawSteps.map((s) => {
        // sanitize internal fields before sending to background
        const { _recordingRef, ...rest } = s || {};
        return rest;
      });
      const ok = await this._send({ kind: 'steps', steps });

      // On transport failure, restore batch for retry to avoid step loss.
      if (!ok) {
        this.batch.unshift(...rawSteps);
        if (this.isRecording && !this.isPaused) {
          this._scheduleFlush();
        }
      }

      return ok;
    }

    /**
     * Send payload to background and wait for acknowledgment.
     * @param {Object} payload - The payload to send
     * @returns {Promise<boolean>} - Resolves true if background acknowledged, false otherwise
     */
    _nextRecorderMeta() {
      this._sendSeq += 1;
      this._sendNonce += 1;
      return {
        version: RECORDER_EVENT_PROTOCOL_VERSION,
        sessionId: this.sessionId || '',
        eventId: `evt_${Date.now()}_${this._sendNonce.toString(36)}`,
        seq: this._sendSeq,
        sentAt: Date.now(),
        source: {
          href: String(location && location.href ? location.href : ''),
          isTop: window === window.top,
        },
      };
    }

    _send(payload) {
      const meta = this._nextRecorderMeta();
      const envelope = { type: 'rr_recorder_event', payload, meta };
      const maxAttempts = Math.max(0, SEND_RETRY_MAX);

      const sendAttempt = (attempt) =>
        new Promise((resolve) => {
          const finish = (ok) => {
            if (ok || attempt >= maxAttempts) {
              resolve(ok);
              return;
            }
            const delay = SEND_RETRY_BASE_MS * (attempt + 1);
            setTimeout(() => {
              sendAttempt(attempt + 1).then(resolve);
            }, delay);
          };

          try {
            chrome.runtime.sendMessage(envelope, (response) => {
              if (chrome.runtime.lastError) {
                console.warn('Recorder: send failed', chrome.runtime.lastError.message);
                finish(false);
                return;
              }

              const ack = response && response.ack ? response.ack : null;
              const ackSeq = ack && Number.isFinite(ack.seq) ? Number(ack.seq) : undefined;
              const seqMismatch = ackSeq !== undefined && ackSeq !== meta.seq;
              if (seqMismatch) {
                console.warn(
                  `Recorder: ack seq mismatch (sent=${meta.seq}, received=${ackSeq})`,
                );
                finish(false);
                return;
              }

              finish(!!(response && response.ok));
            });
          } catch (e) {
            console.warn('Recorder: send exception', e);
            finish(false);
          }
        });

      return sendAttempt(0);
    }

    _addVariable(key, sensitive, defVal) {
      if (!this.sessionBuffer.variables) this.sessionBuffer.variables = [];
      if (this.sessionBuffer.variables.find((v) => v.key === key)) return;
      this.sessionBuffer.variables.push({ key, sensitive: !!sensitive, default: defVal || '' });
    }

    // Handlers
    // Pending click state for dblclick detection
    _pendingClick = null;
    _pendingClickTimer = null;
    _DBLCLICK_THRESHOLD_MS = 300;

    _onClick(e) {
      if (!this.isRecording || this.isPaused) return;
      const el = e.target instanceof Element ? e.target : null;
      if (!el) return;
      try {
        if (el instanceof HTMLInputElement) {
        const t = (el.getAttribute && el.getAttribute('type')) || '';
        const tt = String(t).toLowerCase();
        if (tt === 'checkbox' || tt === 'radio') return; // avoid duplicate with change
      }
      const overlay = document.getElementById('__rr_rec_overlay');
      if (overlay && (el === overlay || (el.closest && el.closest('#__rr_rec_overlay')))) return;
      const a = el.closest && el.closest('a[href]');
        const href = a && a.getAttribute && a.getAttribute('href');
        const tgt = a && a.getAttribute && a.getAttribute('target');
        if (a && href && tgt && tgt.toLowerCase() === '_blank') {
          try {
            const abs = new URL(href, location.href).href;
            this._pushStep({ type: 'openTab', url: abs });
            this._pushStep({ type: 'switchTab', urlContains: abs });
            return;
          } catch (_) {
            this._pushStep({ type: 'openTab', url: href });
            this._pushStep({ type: 'switchTab', urlContains: href });
            return;
          }
        }
      } catch {}

      const target = SelectorEngine.buildTarget(el);
      try {
        const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);
        if (gref) target.ref = gref;
      } catch {}

      // Double-click detection: if e.detail >= 2 means this is the second click of a dblclick
      if (e.detail >= 2) {
        // Cancel pending single click and record dblclick instead
        if (this._pendingClickTimer) {
          clearTimeout(this._pendingClickTimer);
          this._pendingClickTimer = null;
        }
        this._pendingClick = null;
        this._pendingClickAt = 0;
        this._pushStep({
          type: 'dblclick',
          target,
          screenshotOnFail: true,
        });
        return;
      }

      // Single click: wait briefly to see if it becomes a dblclick
      // Cancel any previous pending click first
      if (this._pendingClickTimer) {
        clearTimeout(this._pendingClickTimer);
        const now = Date.now();
        const prevSelector =
          this._pendingClick &&
          this._pendingClick.target &&
          this._pendingClick.target.selector
            ? String(this._pendingClick.target.selector)
            : '';
        const nextSelector =
          target && target.selector ? String(target.selector) : '';
        const isFastDuplicate =
          !!prevSelector &&
          !!nextSelector &&
          prevSelector === nextSelector &&
          now - this._pendingClickAt <= 200;
        // Keep only the latest click for same-target rapid taps.
        if (!isFastDuplicate && this._pendingClick) {
          this._pushStep(this._pendingClick);
        }
      }

      this._pendingClick = {
        type: 'click',
        target,
        screenshotOnFail: true,
      };
      this._pendingClickAt = Date.now();

      this._pendingClickTimer = setTimeout(() => {
        if (this._pendingClick) {
          this._pushStep(this._pendingClick);
          this._pendingClick = null;
        }
        this._pendingClickAt = 0;
        this._pendingClickTimer = null;
      }, this._DBLCLICK_THRESHOLD_MS);
    }

    // Per-element input handler (attached on focusin for native inputs/textarea/contenteditable)
    _onInput(e) {
      if (!this.isRecording || this.isPaused) return;
      // Avoid mid-composition spam (IME): handle final committed value
      try {
        if (e && typeof e.isComposing === 'boolean' && e.isComposing) return;
      } catch {}
      const target = e.target;
      // Support input/textarea and contenteditable elements
      const el =
        target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
          ? target
          : target &&
              target.nodeType === 1 &&
              /** @type {HTMLElement} */ (target).isContentEditable === true
            ? /** @type {HTMLElement} */ (target)
            : null;
      if (!el) return;
      this._handleInputForElement(el);
    }

    // Document-level input handler: supports composed events from Shadow DOM (custom elements)
    _onDocInput(e) {
      if (!this.isRecording || this.isPaused) return;
      try {
        if (e && typeof e.isComposing === 'boolean' && e.isComposing) return;
      } catch {}
      // Avoid double handling when per-element listener already attached to same element
      if (this._focusedEl && e.target === this._focusedEl) return;
      // Find the innermost editable element from composedPath
      const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
      let el = null;
      for (let i = 0; i < path.length; i++) {
        const n = path[i];
        if (n instanceof HTMLInputElement || n instanceof HTMLTextAreaElement) {
          el = n;
          break;
        }
        // Also check for contenteditable
        if (n && n.nodeType === 1 && /** @type {HTMLElement} */ (n).isContentEditable === true) {
          el = /** @type {HTMLElement} */ (n);
          break;
        }
      }
      // As a fallback, walk down activeElement chain (deep active element via shadow roots)
      if (!el) {
        try {
          let ae = document.activeElement;
          let guard = 0;
          while (ae && guard++ < 10) {
            if (ae instanceof HTMLInputElement || ae instanceof HTMLTextAreaElement) {
              el = ae;
              break;
            }
            // Check contenteditable in shadow DOM traversal
            if (
              ae &&
              ae.nodeType === 1 &&
              /** @type {HTMLElement} */ (ae).isContentEditable === true
            ) {
              el = /** @type {HTMLElement} */ (ae);
              break;
            }
            const anyAe = ae;
            if (anyAe && anyAe.shadowRoot && anyAe.shadowRoot.activeElement) {
              ae = anyAe.shadowRoot.activeElement;
              continue;
            }
            break;
          }
        } catch {}
      }
      if (!el) return;
      this._handleInputForElement(el);
    }

    // Shared input processing logic (debounce/merge/sensitivity)
    // Uses Draft/Upsert model: updates are re-enqueued to ensure background gets final value
    _handleInputForElement(el) {
      try {
        const t = (el.getAttribute && el.getAttribute('type')) || '';
        const tt = String(t).toLowerCase();
        if (tt === 'checkbox' || tt === 'radio' || tt === 'file') return;
      } catch {}
      const elRef = this._getElRef(el);
      const target = SelectorEngine.buildTarget(el);

      // Check if element is contenteditable
      const isContentEditable =
        el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true;

      const isSensitive =
        this.hideInputValues ||
        (!isContentEditable &&
          CONFIG.SENSITIVE_INPUT_TYPES.has(
            ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(),
          ));

      // Get value: use .value for input/textarea, .innerText for contenteditable
      let value = isContentEditable
        ? /** @type {HTMLElement} */ (el).innerText || ''
        : el.value || '';
      if (isSensitive) {
        const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`;
        this._addVariable(varKey, true, '');
        value = `{${varKey}}`;
      }
      const nowTs = Date.now();
      const last = this.lastFill.step;
      const sameRef = !!(last && last._recordingRef === elRef);
      const sameSelector = !!(
        last &&
        last.target &&
        last.target.selector &&
        target &&
        target.selector &&
        last.target.selector === target.selector
      );
      const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS;
      if ((sameRef || sameSelector) && within) {
        // No-op update (value unchanged) should not create churn.
        if (this._sameRecordedValue(this.lastFill.step.value, value)) {
          this.lastFill.ts = nowTs;
          this.lastFill.el = el;
          this._updateInputActivity();
          return;
        }
        // Update existing step's value
        this.lastFill.step.value = value;
        this.sessionBuffer.meta.updatedAt = new Date().toISOString();
        this.lastFill.ts = nowTs;
        this.lastFill.el = el; // Keep DOM reference updated for finalize
        this._rememberRecordedValue(elRef, value);
        // Keep flush gate aligned to the latest keystroke
        this._updateInputActivity();
        // Re-enqueue the updated step for upsert (ensures background gets final value)
        this._enqueueForUpsert(this.lastFill.step);
        return;
      }
      if (this._hasRecordedValue(elRef, value)) {
        return;
      }
      const newStep = { type: 'fill', target, value, screenshotOnFail: true };
      newStep._recordingRef = elRef;
      this._rememberRecordedValue(elRef, value);
      this._pushStep(newStep);
      this.lastFill = { step: newStep, ts: nowTs, el: el };
    }

    /**
     * Enqueue a step for upsert - if step with same id exists in batch, update it.
     * This ensures the background receives the final value for fill steps.
     * In iframes, forwards to top window to maintain selector composition consistency.
     */
    _enqueueForUpsert(step) {
      if (!step || !step.id) return;

      // In iframes, forward upsert updates to top so we don't lose composed selectors.
      // The top window aggregates iframe steps and computes "frame |> inner" selectors.
      // If iframe sends directly to background, it would overwrite the composed selector.
      if (window !== window.top) {
        try {
          const payload = {
            kind: 'iframeStepUpsert',
            href: String(location && location.href ? location.href : ''),
            step,
          };
          window.top.postMessage({ type: FRAME_EVENT, payload }, '*');
        } catch {}
        return;
      }

      // Check if step already in batch
      const existingIdx = this.batch.findIndex((s) => s.id === step.id);
      if (existingIdx >= 0) {
        // Update existing entry in batch
        this.batch[existingIdx] = step;
      } else {
        // Add to batch (step was already flushed, so we need to send update)
        this.batch.push(step);
      }

      // Schedule flush with appropriate delay (respects typing gate)
      this._scheduleFlush();
    }

    _onChange(e) {
      if (!this.isRecording || this.isPaused) return;
      const el = e.target;
      if (el instanceof HTMLSelectElement) {
        const val = el.value;
        const nowTs = Date.now();
        const elRef = this._getElRef(el);
        const sameRef = !!(this.lastFill.step && this.lastFill.step._recordingRef === elRef);
        const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS;
        if (sameRef && within) {
          if (this._sameRecordedValue(this.lastFill.step.value, val)) {
            this.lastFill.ts = nowTs;
            this.lastFill.el = el;
            return;
          }
          this.lastFill.step.value = val;
          this.sessionBuffer.meta.updatedAt = new Date().toISOString();
          this.lastFill.ts = nowTs;
          this.lastFill.el = el; // Keep DOM reference updated
          this._rememberRecordedValue(elRef, val);
          // Re-enqueue for upsert
          this._enqueueForUpsert(this.lastFill.step);
          return;
        }
        if (this._hasRecordedValue(elRef, val)) return;
        const target = SelectorEngine.buildTarget(el);
        try {
          const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);
          if (gref) target.ref = gref;
        } catch {}
        const st = { type: 'fill', target, value: val, screenshotOnFail: true };
        st._recordingRef = elRef;
        this._rememberRecordedValue(elRef, val);
        this._pushStep(st);
        this.lastFill = { step: st, ts: nowTs, el: el };
        return;
      }
      if (el instanceof HTMLInputElement) {
        const t = (el.getAttribute && el.getAttribute('type')) || '';
        const tt = String(t).toLowerCase();
        const target = SelectorEngine.buildTarget(el);
        try {
          const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);
          if (gref) target.ref = gref;
        } catch {}
        const elRef = this._getElRef(el);
        if (tt === 'checkbox') {
          const val = !!el.checked;
          if (this._hasRecordedValue(elRef, val)) return;
          const st = { type: 'fill', target, value: !!el.checked, screenshotOnFail: true };
          st._recordingRef = elRef;
          this._rememberRecordedValue(elRef, val);
          this._pushStep(st);
          return;
        }
        if (tt === 'radio') {
          const val = true;
          if (this._hasRecordedValue(elRef, val)) return;
          const st = { type: 'fill', target, value: true, screenshotOnFail: true };
          st._recordingRef = elRef;
          this._rememberRecordedValue(elRef, val);
          this._pushStep(st);
          return;
        }
        if (tt === 'file') {
          const varKey = el.name ? el.name : `file_${Math.random().toString(36).slice(2, 6)}`;
          this._addVariable(varKey, false, '');
          const value = `{${varKey}}`;
          if (this._hasRecordedValue(elRef, value)) return;
          this._rememberRecordedValue(elRef, value);
          this._pushStep({ type: 'fill', target, value, screenshotOnFail: true });
          return;
        }
      }
    }

    _getElRef(el) {
      try {
        let ref = this.el2ref.get(el);
        if (ref) return ref;
        ref = `ref_${++this.refCounter}`;
        this.el2ref.set(el, ref);
        return ref;
      } catch {
        // Fallback to timestamp-based ref if WeakMap fails (should not happen)
        return `ref_${Date.now()}`;
      }
    }

    _sameRecordedValue(a, b) {
      if (typeof a === 'boolean' || typeof b === 'boolean') {
        return Boolean(a) === Boolean(b);
      }
      return String(a == null ? '' : a) === String(b == null ? '' : b);
    }

    _hasRecordedValue(elRef, value) {
      if (!elRef) return false;
      if (!this._lastRecordedFieldValue.has(elRef)) return false;
      return this._sameRecordedValue(this._lastRecordedFieldValue.get(elRef), value);
    }

    _rememberRecordedValue(elRef, value) {
      if (!elRef) return;
      this._lastRecordedFieldValue.set(elRef, value);
    }

    // UI handled by injected UI class

    _onFocusIn(e) {
      if (!this.isRecording || this.isPaused) return;
      const el = e.target;
      const isEditable =
        el instanceof HTMLInputElement ||
        el instanceof HTMLTextAreaElement ||
        (el && el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true);
      if (!isEditable) return;
      if (this._focusedEl && this._focusedEl !== el)
        this._focusedEl.removeEventListener('input', this._onInput, true);
      el.addEventListener('input', this._onInput, true);
      this._focusedEl = el;
    }

    _onFocusOut(e) {
      const el = e.target;
      if (!el) return;
      if (this._focusedEl === el) {
        // Commit point: leaving an input field - finalize and flush pending input
        // This ensures we don't lose values when user tabs away or clicks elsewhere
        this._commitAndFlush();
        el.removeEventListener('input', this._onInput, true);
        this._focusedEl = null;
      }
    }

    _onMouseMove(e) {
      if (!this.highlightEnabled || !this.ui._box || !this.isRecording || this.isPaused) return;
      if (this.hoverRAF) return;
      const el = e.target instanceof Element ? e.target : null;
      if (!el) return;
      this.hoverRAF = requestAnimationFrame(() => {
        try {
          const r = el.getBoundingClientRect();
          Object.assign(this.ui._box.style, {
            left: `${Math.round(r.left)}px`,
            top: `${Math.round(r.top)}px`,
            width: `${Math.round(Math.max(0, r.width))}px`,
            height: `${Math.round(Math.max(0, r.height))}px`,
            display: r.width > 0 && r.height > 0 ? 'block' : 'none',
          });
        } catch {}
        this.hoverRAF = 0;
      });
    }

    _onScroll(e) {
      if (!this.isRecording || this.isPaused) return;
      try {
        const overlay = document.getElementById('__rr_rec_overlay');
        if (overlay) {
          // Use composedPath for shadow DOM compatibility, fallback to target
          const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
          for (const element of path) {
            // If the event path contains our overlay, ignore this scroll event
            if (element === overlay) {
              return;
            }
          }
        }
      } catch {
        // ignore
      }
      // Determine scroll source and positions
      const isDoc = e.target === document;
      const el = isDoc ? document.documentElement : e.target instanceof Element ? e.target : null;
      if (!el) return;
      let top = 0,
        left = 0;
      try {
        if (isDoc) {
          top =
            typeof window.scrollY === 'number'
              ? window.scrollY
              : document.documentElement.scrollTop || 0;
          left =
            typeof window.scrollX === 'number'
              ? window.scrollX
              : document.documentElement.scrollLeft || 0;
        } else {
          top = el.scrollTop || 0;
          left = el.scrollLeft || 0;
        }
      } catch {}
      const target = isDoc ? null : SelectorEngine.buildTarget(el);
      // Debounce/coalesce
      this._scrollPending = { isDoc, target, top, left };
      if (this.scrollTimer) {
        clearTimeout(this.scrollTimer);
      }
      this.scrollTimer = setTimeout(() => {
        this.scrollTimer = null;
        const pending = this._scrollPending;
        this._scrollPending = null;
        if (!pending) return;
        const { isDoc: pDoc, target: pTarget, top: pTop, left: pLeft } = pending;
        // Try merge with last step
        const steps = this.sessionBuffer.steps;
        const last = steps.length ? steps[steps.length - 1] : null;
        if (last && last.type === 'scroll') {
          const sameDoc = pDoc && !last.target && last.mode === 'offset';
          const sameEl =
            !pDoc &&
            last.target &&
            last.target.selector &&
            pTarget &&
            last.target.selector === pTarget.selector &&
            last.mode === 'container';
          if (sameDoc || sameEl) {
            last.offset = { y: pTop, x: pLeft };
            this.sessionBuffer.meta.updatedAt = new Date().toISOString();
            return;
          }
        }
        // New scroll step
        if (pDoc) {
          this._pushStep({
            type: 'scroll',
            mode: 'offset',
            offset: { y: pTop, x: pLeft },
            screenshotOnFail: false,
          });
        } else {
          this._pushStep({
            type: 'scroll',
            mode: 'container',
            target: pTarget,
            offset: { y: pTop, x: pLeft },
            screenshotOnFail: false,
          });
        }
      }, CONFIG.SCROLL_DEBOUNCE_MS);
    }

    // Minimal key recorder: record Enter and modifier combos; avoid plain typing
    _onKeyDown(e) {
      if (!this.isRecording || this.isPaused) return;
      try {
        // Ignore autorepeat to prevent spam
        if (e.repeat) return;
        const key = String(e.key || '').toLowerCase();
        const isModifier = key === 'shift' || key === 'control' || key === 'meta' || key === 'alt';
        const isEditable =
          e.target instanceof HTMLInputElement ||
          e.target instanceof HTMLTextAreaElement ||
          (e.target &&
            e.target.nodeType === 1 &&
            /** @type {HTMLElement} */ (e.target).isContentEditable === true);
        const enterKey = key === 'enter';

        // Track pressed modifiers
        if (isModifier) this._pressed.add(key);

        // Handle Enter in editable contexts (including contenteditable)
        if (isEditable && enterKey) {
          // Commit point: Enter may trigger form submission/navigation
          // Record explicit key action with target first
          const target = SelectorEngine.buildTarget(/** @type {Element} */ (e.target));
          const combo = this._formatKeysCombo(e, 'Enter');
          this._pushStep({ type: 'key', keys: combo, target, screenshotOnFail: false });

          // Then commit and flush (form submit may navigate away)
          this._commitAndFlush();

          this._lastKeyTs = Date.now();
          return;
        }

        // For non-text fields: record modifier combos and special keys
        const special = enterKey || key === 'escape' || key === 'tab';
        if (special || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
          const comboName = this._formatKeysCombo(e, e.key);
          this._pushStep({ type: 'key', keys: comboName, screenshotOnFail: false });
          this._lastKeyTs = Date.now();
        }
      } catch {}
    }

    _onKeyUp(e) {
      const key = String(e.key || '').toLowerCase();
      if (key === 'shift' || key === 'control' || key === 'meta' || key === 'alt')
        this._pressed.delete(key);
    }

    _formatKeysCombo(e, mainKey) {
      const parts = [];
      if (e.ctrlKey) parts.push('Ctrl');
      if (e.altKey) parts.push('Alt');
      if (e.shiftKey) parts.push('Shift');
      if (e.metaKey) parts.push('Meta');
      const mk = String(mainKey || '').trim();
      // Normalize common names to match keyboard-helper parsing
      const norm = (s) => {
        const k = s.toLowerCase();
        if (k === 'escape') return 'Esc';
        if (k === ' ') return 'Space';
        if (k.length === 1) return k.toUpperCase();
        return s;
      };
      parts.push(norm(mk));
      return parts.join('+');
    }

    // Top-level aggregator: receives iframe events and merges into session
    _onWindowMessage(ev) {
      try {
        const d = ev && ev.data;
        if (!d || d.type !== FRAME_EVENT || !d.payload) return;

        // Security: validate message source is from a known iframe in our page
        // ev.source must match contentWindow of an iframe element we control
        let frameEl = null;
        try {
          const frames = document.querySelectorAll('iframe,frame');
          for (let i = 0; i < frames.length; i++) {
            const f = frames[i];
            if (f && f.contentWindow === ev.source) {
              frameEl = f;
              break;
            }
          }
        } catch {}

        // Reject messages not from a recognized iframe in our document
        if (!frameEl) {
          // Message source is not from a child iframe we control - ignore
          return;
        }

        // Additional origin check: only accept from same origin or about:blank iframes
        // (cross-origin iframes legitimately send from their origin)
        try {
          const selfOrigin = window.location.origin;
          const msgOrigin = ev.origin;
          // Allow same-origin, null (for sandboxed iframes), or if iframe src is same-origin
          const frameSrc = frameEl.getAttribute('src') || '';
          let iframeSameOrigin = false;
          try {
            if (!frameSrc || frameSrc === 'about:blank') {
              iframeSameOrigin = true;
            } else {
              const frameUrl = new URL(frameSrc, selfOrigin);
              iframeSameOrigin = frameUrl.origin === selfOrigin;
            }
          } catch {
            // Invalid URL - assume cross-origin
          }
          // If iframe is same-origin, message origin should match
          if (iframeSameOrigin && msgOrigin !== selfOrigin && msgOrigin !== 'null') {
            return; // Origin mismatch for same-origin iframe - suspicious
          }
        } catch {}

        const payload = d.payload || {};
        const kind = payload.kind;

        // Stop barrier sync: ACK back to the iframe so it can finish stop only after
        // its final postMessages have been processed by the top aggregator
        if (kind === 'iframeStopBarrier') {
          try {
            const id = payload.id;
            if (id && ev.source && typeof ev.source.postMessage === 'function') {
              ev.source.postMessage(
                { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrierAck', id } },
                '*',
              );
            }
          } catch {}
          return;
        }

        // Handle iframe flush request: immediately flush top's aggregated buffer
        if (kind === 'iframeFlush') {
          this._lastInputActivityTs = 0;
          this._typingBurstStartTs = 0;
          this._clearForceFlushTimer();
          if (this.batchTimer) clearTimeout(this.batchTimer);
          this.batchTimer = null;
          if (this.batch.length > 0) this._flush();
          return;
        }

        const { step, href } = payload;
        if (!step || typeof step !== 'object') return;

        // Compose frame selector for iframe steps
        const frameTarget = SelectorEngine.buildTarget(frameEl);
        const frameSel = frameTarget?.selector || '';

        // For upsert: find existing step in session and update it
        if (kind === 'iframeStepUpsert') {
          // Update input activity for iframe fills (enables flush gate for iframe input)
          if (step.type === 'fill') {
            this._updateInputActivity();
          }

          // Find step by id in session buffer and update its value
          const existingIdx = this.sessionBuffer.steps.findIndex((s) => s.id === step.id);
          if (existingIdx >= 0) {
            // Update value but preserve the composed selector
            this.sessionBuffer.steps[existingIdx].value = step.value;
            this.sessionBuffer.meta.updatedAt = new Date().toISOString();
            // Also update in batch if present
            const batchIdx = this.batch.findIndex((s) => s.id === step.id);
            if (batchIdx >= 0) {
              this.batch[batchIdx].value = step.value;
            } else {
              // Step was already flushed, add updated version to batch
              const updatedStep = { ...this.sessionBuffer.steps[existingIdx] };
              this.batch.push(updatedStep);
            }
            this._scheduleFlush();
          }
          return;
        }

        // Regular iframe step: compose composite selector and push
        if (step.target) {
          const inner = String(step.target.selector || '').trim();
          if (frameSel && inner) {
            const composite = `${frameSel} |> ${inner}`;
            step.target.selector = composite;
            if (Array.isArray(step.target.candidates)) {
              step.target.candidates.unshift({ type: 'css', value: composite });
            }
          }
          step.target.frameContext = {
            ...(step.target.frameContext || {}),
            kind: 'iframe',
            url: String(href || step.target.frameContext?.url || ''),
            frameSelector: frameSel || step.target.frameContext?.frameSelector || '',
          };
        }
        this._pushStep(step);
      } catch {}
    }
  }

  // ================================================================
  // 3) SINGLETON + MESSAGE HANDLERS
  // ================================================================
  let recorderInstance = null;
  function getRecorder() {
    if (!recorderInstance) recorderInstance = new ContentRecorder();
    return recorderInstance;
  }

  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
    try {
      if (!request || !request.action) return false;
      if (request.action === 'rr_timeline_update') {
        const rec = getRecorder();
        // Only respond to timeline updates when recording is active
        if (!rec.isRecording) {
          sendResponse({ ok: true, ignored: true });
          return true;
        }
        // Replace entire timeline to avoid divergence across tabs
        const steps = Array.isArray(request.steps) ? request.steps : [];
        rec.ui.applyTimelineUpdate(steps);
        sendResponse({ ok: true });
        return true;
      }
      if (request.action === 'rr_recorder_control') {
        const rec = getRecorder();
        const cmd = request.cmd;
        if (cmd === 'start') {
          rec.start(request.meta || {});
          sendResponse({ success: true });
          return true;
        }
        if (cmd === 'pause') {
          rec.pause();
          sendResponse({ success: true });
          return true;
        }
        if (cmd === 'resume') {
          rec.resume();
          sendResponse({ success: true });
          return true;
        }
        if (cmd === 'stop') {
          const requestedSessionId = request?.sessionId || request?.meta?.sessionId;
          if (
            requestedSessionId &&
            rec.sessionId &&
            String(requestedSessionId) !== String(rec.sessionId)
          ) {
            sendResponse({
              success: true,
              ack: true,
              ignored: true,
              reason: 'session_mismatch',
              stats: { ack: true, steps: 0, variables: 0 },
            });
            return true;
          }
          // Stop is now async - flush all data and wait for ack before responding
          rec
            .stop()
            .then((result) => {
              sendResponse({ success: true, ack: result.ack, stats: result });
            })
            .catch((err) => {
              sendResponse({ success: false, ack: false, error: String(err) });
            });
          return true; // Keep channel open for async response
        }
        sendResponse({ success: false, error: 'Unknown command' });
        return true;
      }
      // Handle direct stop message with ack (sent by recorder-manager)
      if (request.action === 'stop' && request.requireAck) {
        const rec = getRecorder();
        const requestedSessionId = request?.sessionId;
        if (
          requestedSessionId &&
          rec.sessionId &&
          String(requestedSessionId) !== String(rec.sessionId)
        ) {
          sendResponse({
            ack: true,
            ignored: true,
            reason: 'session_mismatch',
            stats: { ack: true, steps: 0, variables: 0 },
          });
          return true;
        }
        rec
          .stop()
          .then((result) => {
            sendResponse({ ack: result.ack, stats: result });
          })
          .catch(() => {
            sendResponse({ ack: false });
          });
        return true;
      }
      if (request.action === 'rr_recorder_ping') {
        sendResponse({ status: 'pong' });
        return false;
      }
    } catch (e) {
      sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
      return true;
    }
    return false;
  });

  console.log('Record & Replay recorder.js loaded');
})();
