/**
 * SITS Sticky Controls – Frontend Handler
 *
 * Applies sticky positioning to Elementor elements that have been
 * configured via the Sticky Controls section in the Advanced tab.
 *
 * Supports:
 *  - Top / Bottom sticky direction
 *  - Per-device activation (desktop / tablet / mobile)
 *  - Configurable offset
 *  - Z-index override
 *  - Fade / Slide entrance animations
 *  - Hide on scroll-down
 *  - Custom sticky background colour
 *  - Custom CSS classes while sticky
 *
 * @package Softech_Addons_for_Elementor
 */

(function ($) {
  "use strict";

  /* ── Breakpoints (mirrors Elementor defaults) ── */
  var BREAKPOINTS = {
    mobile: 768,    // < 768   → mobile
    tablet: 1025,   // 768–1024 → tablet
    desktop: Infinity,
  };

  /**
   * Detect the current device type string.
   *
   * @returns {string}  'mobile' | 'tablet' | 'desktop'
   */
  function currentDevice() {
    var w = window.innerWidth || document.documentElement.clientWidth;
    if (w < BREAKPOINTS.mobile) return "mobile";
    if (w < BREAKPOINTS.tablet) return "tablet";
    return "desktop";
  }

  /**
   * Parse a comma-separated device list string into an array.
   *
   * @param  {string} raw
   * @returns {string[]}
   */
  function parseDevices(raw) {
    if (!raw) return ["desktop", "tablet", "mobile"];
    return raw.split(",").map(function (d) { return d.trim(); });
  }

  /**
   * Parse an optional integer while preserving 0 as a valid value.
   *
   * @param {*} value
   * @returns {number|null}
   */
  function parseOptionalInt(value) {
    if (value === undefined || value === null || value === "") return null;
    var n = parseInt(value, 10);
    return isNaN(n) ? null : n;
  }

  /* ── Unique ID counter so each instance gets its own event namespace ── */
  var _uid = 0;

  /**
   * Single sticky element controller.
   *
   * @param {HTMLElement} el
   */
  function StickyElement(el) {
    this.$el    = $(el);
    this.el     = el;
    this.isSticky = false;
    this.originalStyles = {};

    /* ── Per-instance event namespace (fixes destroy() wiping sibling instances) ── */
    this._ns = ".sitsSticky_" + (++_uid);

    /* ── Guard: don't react to scroll until threshold is computed correctly ── */
    this._ready = false;

    // Read configuration from data attributes.
    this.direction   = this.$el.data("sits-sticky-direction") || "top";
    this.offsets     = {
      desktop: parseOptionalInt(this.$el.data("sits-sticky-offset")),
      tablet: parseOptionalInt(this.$el.data("sits-sticky-offset-tablet")),
      mobile: parseOptionalInt(this.$el.data("sits-sticky-offset-mobile")),
    };
    this.offset      = this._resolveOffset(currentDevice());
    this.devices     = parseDevices(this.$el.data("sits-sticky-on"));
    this.zIndex      = parseInt(this.$el.data("sits-sticky-z-index"), 10) || 999;
    this.animation   = this.$el.data("sits-sticky-animation") || "none";
    this.hideDown    =
      this.$el.data("sits-sticky-hide-down") === true ||
      this.$el.data("sits-sticky-hide-down") === "true";
    this.extraClasses = (this.$el.data("sits-sticky-classes") || "").trim();

    // lastScrollY is synchronised to the real pageYOffset inside _lateInit(),
    // AFTER the browser has restored the scroll position.  Initialising it to
    // 0 here would cause the first _onScroll() call to treat the restored
    // position as a "scroll down" and corrupt hide-on-scroll-down state.
    this.lastScrollY = 0;

    // Threshold – the element's original distance from the relevant edge.
    this.threshold   = 0;
    this._placeholder = null;

    this._init();
  }

  StickyElement.prototype = {

    /**
     * Attach scroll/resize listeners and defer threshold calculation until after
     * the browser has finished restoring the page's scroll position.
     *
     * WHY DEFER?
     * Browsers restore scroll position asynchronously relative to script
     * execution.  If we call _calculateThreshold() immediately, pageYOffset
     * may still be 0 while getBoundingClientRect() already reflects the
     * restored position (or vice-versa), producing a threshold that equals the
     * mid-page scroll offset instead of the element's natural document top.
     * The result: sticky activates only when scrollY > restore-position, which
     * makes the header appear on scroll-down and disappear on scroll-up —
     * exactly the wrong behaviour.
     *
     * Using the "load" event + a double requestAnimationFrame guarantees that
     * both the layout and the scroll position are fully settled before we
     * snapshot the threshold.
     */
    _init: function () {
      var self = this;

      // Cache original inline styles so we can restore them on _release().
      this.originalStyles = {
        position:   this.el.style.position   || "",
        top:        this.el.style.top        || "",
        bottom:     this.el.style.bottom     || "",
        zIndex:     this.el.style.zIndex     || "",
        width:      this.el.style.width      || "",
        left:       this.el.style.left       || "",
        transition: this.el.style.transition || "",
      };

      // ── Scroll listener (guarded until _ready) ──
      $(window).on("scroll" + this._ns, function () {
        if (self._ready) self._onScroll();
      });

      // ── Resize: release, recalculate, re-evaluate ──
      $(window).on(
        "resize" + this._ns,
        this._debounce(function () {
          self._release();
          // After resize, re-snapshot the threshold from the element's natural
          // (unstuck) position, then re-sync lastScrollY.
          self._calculateThreshold();
          self.lastScrollY = window.pageYOffset;
          self._onScroll();
        }, 200)
      );

      // ── Deferred initialisation ──
      if (document.readyState === "complete") {
        // load already fired (e.g. Elementor editor or late widget init)
        requestAnimationFrame(function () {
          requestAnimationFrame(function () { self._lateInit(); });
        });
      } else {
        $(window).on("load" + this._ns, function () {
          // Extra rAF so the browser's scroll-restore micro-task finishes first.
          requestAnimationFrame(function () {
            requestAnimationFrame(function () { self._lateInit(); });
          });
        });
      }
    },

    /**
     * Runs once after the load event and two animation frames, at which point
     * pageYOffset reliably reflects the browser's restored scroll position.
     */
    _lateInit: function () {
      // Sync scroll baseline BEFORE calculating the threshold so the very
      // first _onScroll() call sees scrollDown = false (no jump).
      this.lastScrollY = window.pageYOffset;
      this._calculateThreshold();
      this._ready = true;
      this._onScroll();
    },

    /**
     * Record how far the element's relevant edge sits from the document edge.
     *
     * Always use the placeholder's position when available so that a subsequent
     * resize-triggered recalculation doesn't measure the fixed element instead
     * of its original in-flow location. The offset is a scroll threshold, not a
     * fixed inset on the sticky element itself.
     */
    _calculateThreshold: function () {
      this.offset = this._resolveOffset(currentDevice());

      if (this.direction === "bottom") {
        var docHeight =
          Math.max(
            document.body.scrollHeight,
            document.documentElement.scrollHeight,
            document.body.offsetHeight,
            document.documentElement.offsetHeight,
            document.documentElement.clientHeight
          ) || 0;
        var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;

        // Bottom sticky activates when the viewport is within `offset` pixels
        // of the bottom of the page.
        this.threshold = Math.max(0, docHeight - viewportHeight - this.offset);
      } else {
        // Top sticky activates after the page has scrolled `offset` pixels.
        this.threshold = Math.max(0, this.offset);
      }
    },

    /**
     * Main scroll handler.
     */
    _onScroll: function () {
      var scrollY    = window.pageYOffset;
      var scrollDown = scrollY > this.lastScrollY;

      // IMPORTANT: update lastScrollY before ANY early return so it stays
      // accurate even when the device is not in the enabled list.
      this.lastScrollY = scrollY;

      if (!this._deviceEnabled()) {
        if (this.isSticky) this._release();
        return;
      }

      // Hide-on-scroll-down: hide when scrolling down while sticky.
      if (this.isSticky && this.hideDown) {
        if (scrollDown) {
          this.$el.addClass("sits-sticky-hidden");
        } else {
          this.$el.removeClass("sits-sticky-hidden");
        }
      }

      // Determine whether the threshold has been crossed.
      var shouldStick = scrollY >= this.threshold;

      if (shouldStick && !this.isSticky) {
        this._stick();
      } else if (!shouldStick && this.isSticky) {
        this._release();
      }
    },

    /**
     * Apply sticky state.
     */
    _stick: function () {
      var self   = this;
      var width  = this.el.offsetWidth;
      var height = this.el.offsetHeight;

      // Insert a placeholder to prevent layout jump.
      if (!this._placeholder) {
        var ph = document.createElement("div");
        ph.className  = "sits-sticky-placeholder";
        ph.style.cssText =
          "height:" + height + "px;" +
          "width:"  + width  + "px;" +
          "display:block;visibility:hidden;";
        this.el.parentNode.insertBefore(ph, this.el);
        this._placeholder = ph;
      }

      this.el.style.position = "fixed";
      this.el.style.width    = width + "px";
      this.el.style.zIndex   = this.zIndex;

      if (this.direction === "bottom") {
        this.el.style.bottom = "0";
        this.el.style.top    = "";
      } else {
        this.el.style.top    = "0";
        this.el.style.bottom = "";
      }

      // Align to the placeholder's horizontal position.
      var phRect = this._placeholder.getBoundingClientRect();
      this.el.style.left   = phRect.left + "px";
      this.el.style.zIndex = this.zIndex; // re-apply after left assignment

      // Animation.
      if (this.animation === "fade") {
        this.el.style.opacity    = "0";
        this.el.style.transition = "opacity 0.3s ease";
        void this.el.offsetWidth; // trigger reflow
        this.el.style.opacity = "1";
      } else if (this.animation === "slide") {
        var translateProp =
          this.direction === "bottom" ? "translateY(100%)" : "translateY(-100%)";
        this.el.style.transform  = translateProp;
        this.el.style.transition = "transform 0.3s ease";
        void this.el.offsetWidth; // trigger reflow
        this.el.style.transform  = "translateY(0)";
        // Clear transform after animation so it doesn't create a containing
        // block that breaks fixed-position children (e.g. mega menus).
        var animEl = this.el;
        setTimeout(function () {
          animEl.style.transform  = "";
          animEl.style.transition = "";
        }, 320);
      }

      this.$el.addClass("sits-is-sticky sits-sticky-" + this.direction);
      if (this.animation !== "none") {
        this.$el.addClass("sits-sticky-anim-" + this.animation);
      }
      if (this.extraClasses) {
        this.$el.addClass(this.extraClasses);
      }
      this.isSticky = true;
    },

    /**
     * Remove sticky state and restore original styles.
     */
    _release: function () {
      if (this._placeholder && this._placeholder.parentNode) {
        this._placeholder.parentNode.removeChild(this._placeholder);
        this._placeholder = null;
      }

      var s      = this.el.style;
      s.position = this.originalStyles.position;
      s.top      = this.originalStyles.top;
      s.bottom   = this.originalStyles.bottom;
      s.zIndex   = this.originalStyles.zIndex;
      s.width    = this.originalStyles.width;
      s.left     = this.originalStyles.left;
      s.transition = this.originalStyles.transition;
      s.opacity  = "";
      s.transform = "";

      this.$el
        .removeClass(
          "sits-is-sticky sits-sticky-top sits-sticky-bottom sits-sticky-hidden"
        )
        .removeClass("sits-sticky-anim-fade sits-sticky-anim-slide");

      if (this.extraClasses) {
        this.$el.removeClass(this.extraClasses);
      }

      this.isSticky = false;
    },

    /**
     * Returns true when the current device is in the enabled-devices list.
     *
     * @returns {boolean}
     */
    _deviceEnabled: function () {
      return this.devices.indexOf(currentDevice()) !== -1;
    },

    /**
     * Resolve the responsive offset for the active device.
     *
     * @param {string} device
     * @returns {number}
     */
    _resolveOffset: function (device) {
      var desktop = this.offsets.desktop;
      var tablet  = this.offsets.tablet;
      var mobile  = this.offsets.mobile;

      if (device === "mobile") {
        if (mobile !== null) return mobile;
        if (tablet !== null) return tablet;
      } else if (device === "tablet") {
        if (tablet !== null) return tablet;
      }

      if (desktop !== null) return desktop;
      return 0;
    },

    /**
     * Simple debounce helper.
     *
     * @param  {Function} fn
     * @param  {number}   delay
     * @returns {Function}
     */
    _debounce: function (fn, delay) {
      var timer;
      return function () {
        clearTimeout(timer);
        timer = setTimeout(fn, delay);
      };
    },

    /**
     * Tear down all event listeners and release sticky state.
     * Uses the per-instance namespace so other sticky elements are unaffected.
     */
    destroy: function () {
      $(window).off(this._ns);
      this._release();
    },
  };

  /* ── Initialisation ── */

  /**
   * Find all sticky-enabled elements and wire them up.
   */
  function initStickyElements() {
    $('[data-sits-sticky="true"]').each(function () {
      // Avoid double-init.
      if ($(this).data("sits-sticky-instance")) return;
      var instance = new StickyElement(this);
      $(this).data("sits-sticky-instance", instance);
    });
  }

  // Run after DOM is ready.
  $(document).ready(function () {
    initStickyElements();
  });

  // Re-run after Elementor frontend finishes rendering (editor preview & dynamic content).
  $(window).on("elementor/frontend/init", function () {
    if (window.elementorFrontend && elementorFrontend.hooks) {
      elementorFrontend.hooks.addAction(
        "frontend/element_ready/global",
        function () { initStickyElements(); }
      );
    }
  });

})(jQuery);
