/**
 * ViewportTracker — dual IntersectionObserver for page visibility detection.
 *
 * - visibleSet: pages currently on screen (rootMargin "0px")
 * - bufferSet:  pages within ~2 viewport heights above/below (rootMargin "200% 0px")
 *
 * Calls onVisibilityChange(bufferSet, visibleSet) whenever either set changes.
 */
export class ViewportTracker {
  /**
   * @param {HTMLElement} root           - scroll container (#canvas-container)
   * @param {HTMLElement[]} pageWrappers - array of .page-wrapper elements
   * @param {Function} onVisibilityChange - (bufferSet: Set<number>, visibleSet: Set<number>) => void
   */
  constructor(root, pageWrappers, onVisibilityChange) {
    this._onVisibilityChange = onVisibilityChange;
    this._visibleSet = new Set();
    this._bufferSet  = new Set();
    this._visibleObserver = null;
    this._bufferObserver  = null;

    this._observe(root, pageWrappers);
  }

  _observe(root, pageWrappers) {
    // Both observers update their respective sets and then schedule a single
    // notification via rAF.  This prevents a stale notify when the two
    // observers fire in separate microtasks for the same layout change —
    // e.g. bufferObserver removes a page from bufferSet before visibleObserver
    // has had a chance to also remove it from visibleSet, which would let
    // reconcile() incorrectly tear down a still-visible canvas.
    this._rafPending = null;
    const scheduleNotify = () => {
      if (this._rafPending !== null) return;
      this._rafPending = requestAnimationFrame(() => {
        this._rafPending = null;
        // Hard invariant: every visible page must also be in the buffer.
        for (const p of this._visibleSet) this._bufferSet.add(p);
        this._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet));
      });
    };

    this._visibleObserver = new IntersectionObserver(
      entries => {
        for (const e of entries) {
          const page = parseInt(e.target.dataset.page, 10);
          if (e.isIntersecting) this._visibleSet.add(page);
          else this._visibleSet.delete(page);
        }
        scheduleNotify();
      },
      { root, rootMargin: "0px", threshold: 0 }
    );

    this._bufferObserver = new IntersectionObserver(
      entries => {
        for (const e of entries) {
          const page = parseInt(e.target.dataset.page, 10);
          if (e.isIntersecting) this._bufferSet.add(page);
          else this._bufferSet.delete(page);
        }
        scheduleNotify();
      },
      { root, rootMargin: "200% 0px", threshold: 0 }
    );

    for (const wrap of pageWrappers) {
      this._visibleObserver.observe(wrap);
      this._bufferObserver.observe(wrap);
    }
  }

  /** Observe a newly-added page wrapper. */
  observe(wrap) {
    this._visibleObserver?.observe(wrap);
    this._bufferObserver?.observe(wrap);
  }

  disconnect() {
    this._visibleObserver?.disconnect();
    this._bufferObserver?.disconnect();
    this._visibleSet.clear();
    this._bufferSet.clear();
  }
}
