/**
 * SifencyDistortion — Universal Visual Distortion Engine
 * Version: 2.0.0 — Performance Rewrite
 *
 * WHAT CHANGED FROM v1:
 *  - No per-pixel CPU loops in the RAF (was killing scroll on liquid + rgb)
 *  - RAF only runs when something actually changed (dirty flag pattern)
 *  - RAF pauses automatically when widget is off-screen (IntersectionObserver)
 *  - Liquid effect replaced with CSS filter + SVG feTurbulence (GPU, zero JS cost per frame)
 *  - RGB effect replaced with CSS multi-layer composite trick (GPU)
 *  - Pixel effect uses downscale→upscale canvas trick (no getImageData loop)
 *  - Canvas only resized when size actually changes (debounced)
 *  - OffscreenCanvas allocations removed from hot path
 *  - Slice updates batched and only fire when progress delta > threshold
 *
 * Effects:   pixel | rgb | liquid | slice
 * Triggers:  scroll (GSAP ScrollTrigger scrub) | hover | both
 * Modes:     image | background | object
 *
 * @package  Sifency Addons
 * @version  2.0.0
 */

(function () {
    'use strict';

    // =====================================================================
    // Unique SVG filter ID counter (multiple widgets on one page)
    // =====================================================================
    let _filterIdCounter = 0;

    // =====================================================================
    // SifencyDistortion Class
    // =====================================================================

    class SifencyDistortion {

        /**
         * @param {HTMLElement} el  Root .sifency-distortion element
         */
        constructor(el) {
            this.root = el;
            this.wrap = el.querySelector('.sifency-distortion-wrap');
            this.canvas = el.querySelector('.sifency-distortion-canvas');
            this.config = this._parseConfig();

            // Unique ID for SVG filters
            this._uid = ++_filterIdCounter;

            // State
            this._progress = 0;    // 0→1 scroll progress
            this._hoverProgress = 0;    // 0→1 hover additive
            this._lastCombined = -1;   // last rendered combined value (dirty check)
            this._rafId = null;
            this._rafScheduled = false;
            this._imageEl = null;
            this._imageLoaded = false;
            this._sliceEls = [];
            this._lastSliceProg = -1;   // dirty check for slices
            this._hoverTween = null;
            this._resizeTimer = null;
            this._visible = false;
            this._ctx = null;

            // Offscreen scratch canvas for pixel effect (allocated once)
            this._scratch = null;
            this._scratchCtx = null;

            this._init();
        }

        // -----------------------------------------------------------------
        // Config
        // -----------------------------------------------------------------

        _parseConfig() {
            try {
                return JSON.parse(this.root.dataset.distortion || '{}');
            } catch (e) {
                return {};
            }
        }

        // -----------------------------------------------------------------
        // Init
        // -----------------------------------------------------------------

        _init() {
            const { effect, trigger } = this.config;

            if (effect === 'slice') {
                this._initSlice();
            } else if (effect === 'liquid') {
                // Liquid uses SVG + CSS — no canvas needed
                this.canvas.style.display = 'none';
                this._initLiquidFilter();
            } else if (effect === 'rgb') {
                // RGB uses CSS layers — no canvas pixel loops
                this.canvas.style.display = 'none';
                this._initRGBLayers();
            } else {
                // pixel — uses canvas but with the fast downscale trick
                this._ctx = this.canvas.getContext('2d', { willReadFrequently: false });
                this._initCanvas();
            }

            // Triggers
            if (trigger === 'scroll' || trigger === 'both') {
                this._initScroll();
            }
            if (trigger === 'hover' || trigger === 'both') {
                this._initHover();
            }

            // Pause RAF when off-screen
            this._initVisibility();

            // Resize
            this._initResize();
        }

        // -----------------------------------------------------------------
        // Canvas init (pixel effect only)
        // -----------------------------------------------------------------

        _initCanvas() {
            const { mode } = this.config;

            if (mode === 'image') {
                this._imageEl = this.wrap.querySelector('.sifency-distortion-image');
                if (this._imageEl) {
                    if (this._imageEl.complete && this._imageEl.naturalWidth) {
                        this._onImageReady();
                    } else {
                        this._imageEl.addEventListener('load', () => this._onImageReady());
                    }
                }
            } else if (mode === 'background') {
                const bgEl = this.wrap.querySelector('.sifency-distortion-bg');
                const bgUrl = bgEl ? bgEl.dataset.bgUrl : '';
                if (bgUrl) {
                    const img = new Image();
                    img.crossOrigin = 'anonymous';
                    img.src = bgUrl;
                    this._imageEl = img;
                    img.onload = () => this._onImageReady();
                }
            } else {
                // Object mode — no image source needed for pixel effect
                this._imageLoaded = true;
                this._fitCanvas();
            }
        }

        _onImageReady() {
            this._imageLoaded = true;
            this._fitCanvas();
            // Allocate scratch canvas once at image size
            const w = this.canvas.width;
            const h = this.canvas.height;
            this._scratch = document.createElement('canvas');
            this._scratch.width = w;
            this._scratch.height = h;
            this._scratchCtx = this._scratch.getContext('2d', { willReadFrequently: false });
            // Pre-draw image onto scratch with cover fit — static, never redrawn unless resize
            if (this._imageEl) {
                this._drawImageCover(this._scratchCtx, this._imageEl, w, h);
            }
            this._scheduleDraw();
        }

        _fitCanvas() {
            const w = this.wrap.offsetWidth || 800;
            const h = this.wrap.offsetHeight || 600;
            if (this.canvas.width !== w || this.canvas.height !== h) {
                this.canvas.width = w;
                this.canvas.height = h;
                // Re-draw scratch on resize with cover fit
                if (this._scratch && this._imageEl) {
                    this._scratch.width = w;
                    this._scratch.height = h;
                    this._drawImageCover(this._scratchCtx, this._imageEl, w, h);
                }
            }
        }

        // -----------------------------------------------------------------
        // Cover-fit helper
        // Returns the { sx, sy, sw, sh } source crop rectangle that
        // replicates CSS object-fit:cover / background-size:cover for
        // drawing an image onto the canvas at (0,0,canvasW,canvasH).
        // -----------------------------------------------------------------

        _coverRect(imgW, imgH, canvasW, canvasH) {
            const imgRatio = imgW / imgH;
            const canvasRatio = canvasW / canvasH;

            let sw, sh, sx, sy;

            if (imgRatio > canvasRatio) {
                // Image is wider than canvas (relative) → crop sides
                sh = imgH;
                sw = imgH * canvasRatio;
                sx = (imgW - sw) / 2;
                sy = 0;
            } else {
                // Image is taller than canvas (relative) → crop top/bottom
                sw = imgW;
                sh = imgW / canvasRatio;
                sx = 0;
                sy = (imgH - sh) / 2;
            }

            return { sx, sy, sw, sh };
        }

        // Wrapper: drawImage with cover behaviour
        _drawImageCover(ctx, img, canvasW, canvasH) {
            const iw = img.naturalWidth || img.videoWidth || img.width;
            const ih = img.naturalHeight || img.videoHeight || img.height;

            if (!iw || !ih) {
                // Dimensions unknown — fall back to stretch
                ctx.drawImage(img, 0, 0, canvasW, canvasH);
                return;
            }

            const { sx, sy, sw, sh } = this._coverRect(iw, ih, canvasW, canvasH);
            ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvasW, canvasH);
        }

        // -----------------------------------------------------------------
        // DIRTY-FLAG RAF — only runs when something changed
        // -----------------------------------------------------------------

        _scheduleDraw() {
            if (this._rafScheduled || !this._visible) {
                return;
            }
            this._rafScheduled = true;
            this._rafId = requestAnimationFrame(() => {
                this._rafScheduled = false;
                this._draw();
            });
        }

        _stopRaf() {
            if (this._rafId) {
                cancelAnimationFrame(this._rafId);
                this._rafId = null;
                this._rafScheduled = false;
            }
        }

        // -----------------------------------------------------------------
        // Master Draw — only fires when actually scheduled
        // -----------------------------------------------------------------

        _draw() {
            const combined = Math.min(1, Math.max(0, this._progress + this._hoverProgress));

            // Dirty check — skip if nothing changed
            if (Math.abs(combined - this._lastCombined) < 0.002) {
                return;
            }
            this._lastCombined = combined;

            const { effect } = this.config;

            if (effect === 'pixel' && this._imageLoaded && this._ctx) {
                this._drawPixel(combined);
            }
            // liquid and rgb are CSS/SVG driven — no draw call needed
        }

        // -----------------------------------------------------------------
        // EFFECT 1 — Pixel Block Distortion (fast downscale trick)
        //
        // Instead of looping every pixel:
        //  1. Draw image at tiny resolution (blockSize × blockSize)
        //  2. Scale back up with imageSmoothingEnabled = false
        //  = instant pixelation, zero pixel loops, pure GPU composite
        // -----------------------------------------------------------------

        _drawPixel(progress) {
            const { intensity, blockSize } = this.config;
            const w = this.canvas.width;
            const h = this.canvas.height;
            const ctx = this._ctx;
            const img = this._imageEl || this._scratch;

            if (!img) {
                return;
            }

            ctx.clearRect(0, 0, w, h);

            if (progress <= 0.01) {
                this._drawImageCover(ctx, img, w, h);
                return;
            }

            // Phase: 0→0.5 pixelate in, 0.5→1 pixelate out
            const phase = progress <= 0.5 ? progress * 2 : (1 - progress) * 2;
            const maxShift = (intensity / 100) * 60;
            const shift = maxShift * phase;

            // Current block size (minimum 1)
            const curBlock = Math.max(1, Math.round(blockSize * phase));

            // Step 1: draw at tiny size
            const tinyW = Math.max(1, Math.ceil(w / curBlock));
            const tinyH = Math.max(1, Math.ceil(h / curBlock));

            // Use scratch for tiny draw
            const s = this._scratchCtx;
            if (!s) {
                this._drawImageCover(ctx, img, w, h);
                return;
            }

            // Resize scratch if needed
            if (this._scratch.width !== tinyW || this._scratch.height !== tinyH) {
                // Use a temp canvas instead of modifying scratch (which holds our pre-drawn image)
                if (!this._tiny) {
                    this._tiny = document.createElement('canvas');
                    this._tinyCtx = this._tiny.getContext('2d', { willReadFrequently: false });
                }
                this._tiny.width = tinyW;
                this._tiny.height = tinyH;
                this._tinyCtx.clearRect(0, 0, tinyW, tinyH);
                // Draw with cover crop at tiny size
                this._drawImageCover(this._tinyCtx, img, tinyW, tinyH);

                // Step 2: scale back up with pixelation (no smoothing = blocky pixels)
                ctx.imageSmoothingEnabled = false;
                ctx.save();
                if (shift > 0.5) {
                    ctx.translate(
                        Math.sin(progress * Math.PI * 4) * shift * 0.3,
                        Math.cos(progress * Math.PI * 3) * shift * 0.2
                    );
                }
                ctx.drawImage(this._tiny, 0, 0, w, h);
                ctx.restore();
                ctx.imageSmoothingEnabled = true;

            } else {
                // block matches canvas size — draw with cover at full size
                this._drawImageCover(ctx, img, w, h);
            }
        }

        // -----------------------------------------------------------------
        // EFFECT 2 — RGB / Chromatic Aberration (CSS-only, zero JS per frame)
        //
        // Creates three absolutely-positioned copies of the image
        // with CSS color filters (red/green/blue channel isolation)
        // and drives their translateX via CSS custom property.
        // GPU composited, no canvas, no pixel reads.
        // -----------------------------------------------------------------

        _initRGBLayers() {
            const { mode } = this.config;
            const imgSrc = this._getImageSrc();

            // Inject SVG filter for channel isolation
            const svgId = `sifency-rgb-filter-${this._uid}`;
            if (!document.getElementById(svgId)) {
                const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                svg.setAttribute('id', svgId);
                svg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden');
                svg.innerHTML = `
					<defs>
						<filter id="sifency-red-${this._uid}" color-interpolation-filters="sRGB">
							<feColorMatrix type="matrix" values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
						</filter>
						<filter id="sifency-green-${this._uid}" color-interpolation-filters="sRGB">
							<feColorMatrix type="matrix" values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
						</filter>
						<filter id="sifency-blue-${this._uid}" color-interpolation-filters="sRGB">
							<feColorMatrix type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0"/>
						</filter>
					</defs>
				`;
                document.body.appendChild(svg);
            }

            // Container
            const container = document.createElement('div');
            container.className = 'sifency-distortion-rgb-wrap';
            container.style.cssText = 'position:absolute;inset:0;overflow:hidden;';

            const channels = [
                { cls: 'r', filterId: `sifency-red-${this._uid}`, blendMode: 'screen', varName: '--sd-rgb-r' },
                { cls: 'g', filterId: `sifency-green-${this._uid}`, blendMode: 'screen', varName: '--sd-rgb-g' },
                { cls: 'b', filterId: `sifency-blue-${this._uid}`, blendMode: 'screen', varName: '--sd-rgb-b' },
            ];

            this._rgbLayers = [];

            channels.forEach((ch, idx) => {
                const layer = document.createElement('div');
                layer.className = `sifency-distortion-rgb-layer sifency-distortion-rgb-${ch.cls}`;
                layer.style.cssText = [
                    'position:absolute;inset:0;',
                    imgSrc ? `background-image:url('${imgSrc}');` : '',
                    'background-size:cover;background-position:center;',
                    `filter:url(#${ch.filterId});`,
                    idx > 0 ? `mix-blend-mode:${ch.blendMode};` : '',
                    'will-change:transform;',
                    'transition:transform 0s linear;', // GSAP drives this
                ].join('');

                container.appendChild(layer);
                this._rgbLayers.push(layer);
            });

            // Hide original image visually (layers replace it)
            const origImg = this.wrap.querySelector('.sifency-distortion-image, .sifency-distortion-bg');
            if (origImg) {
                origImg.style.opacity = '0';
            }

            this.wrap.appendChild(container);
        }

        _updateRGB(progress) {
            if (!this._rgbLayers) {
                return;
            }
            const { intensity, rgbOffset } = this.config;
            const maxOff = (intensity / 100) * (rgbOffset || 20);
            const currOff = maxOff * progress;

            // R shifts left, G stays, B shifts right
            this._rgbLayers[0].style.transform = `translateX(${-currOff}px)`;
            this._rgbLayers[1].style.transform = `translateX(0px)`;
            this._rgbLayers[2].style.transform = `translateX(${currOff}px)`;
        }

        // -----------------------------------------------------------------
        // EFFECT 3 — Liquid Wave Warp (SVG feTurbulence — GPU, zero JS per frame)
        //
        // SVG feTurbulence + feDisplacementMap on the image element.
        // The turbulence baseFrequency and scale are driven by CSS custom props.
        // Animation: GSAP tweens a JS value → we update the SVG filter attribute.
        // Only updates when progress actually changes (dirty flag).
        // -----------------------------------------------------------------

        _initLiquidFilter() {
            const imgSrc = this._getImageSrc();
            const uid = this._uid;

            // Build SVG filter inline in the DOM
            const svgNS = 'http://www.w3.org/2000/svg';
            const svg = document.createElementNS(svgNS, 'svg');
            svg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;');
            svg.setAttribute('aria-hidden', 'true');

            const defs = document.createElementNS(svgNS, 'defs');
            const filter = document.createElementNS(svgNS, 'filter');
            filter.setAttribute('id', `sifency-liquid-${uid}`);
            filter.setAttribute('x', '-20%');
            filter.setAttribute('y', '-20%');
            filter.setAttribute('width', '140%');
            filter.setAttribute('height', '140%');
            filter.setAttribute('color-interpolation-filters', 'sRGB');

            const turb = document.createElementNS(svgNS, 'feTurbulence');
            turb.setAttribute('type', 'fractalNoise');
            turb.setAttribute('baseFrequency', '0.015 0.015');
            turb.setAttribute('numOctaves', '3');
            turb.setAttribute('seed', '2');
            turb.setAttribute('result', 'noise');

            const disp = document.createElementNS(svgNS, 'feDisplacementMap');
            disp.setAttribute('in', 'SourceGraphic');
            disp.setAttribute('in2', 'noise');
            disp.setAttribute('scale', '0');
            disp.setAttribute('xChannelSelector', 'R');
            disp.setAttribute('yChannelSelector', 'G');

            filter.appendChild(turb);
            filter.appendChild(disp);
            defs.appendChild(filter);
            svg.appendChild(defs);
            document.body.appendChild(svg);

            // Store references for progress updates
            this._liquidDisp = disp;
            this._liquidTurb = turb;
            this._liquidSvg = svg;

            // Apply filter to the element
            const target = this.wrap.querySelector(
                '.sifency-distortion-image, .sifency-distortion-bg, .sifency-distortion-object'
            );
            if (target) {
                target.style.filter = `url(#sifency-liquid-${uid})`;
                this._liquidTarget = target;
            }

            // Animate turbulence seed for movement — lightweight GSAP tween
            // (updates only the SVG attribute, not pixels)
            this._liquidAnimating = false;
        }

        _updateLiquid(progress) {
            if (!this._liquidDisp) {
                return;
            }
            const { intensity, liquidScale } = this.config;
            const maxScale = (intensity / 100) * 80 * (liquidScale || 3);
            const scale = maxScale * progress;

            this._liquidDisp.setAttribute('scale', String(scale));

            // Animate turbulence baseFrequency for ripple when active
            if (progress > 0.02 && !this._liquidAnimating) {
                this._liquidAnimating = true;
                this._animateTurbulence();
            } else if (progress <= 0.02 && this._liquidAnimating) {
                this._liquidAnimating = false;
                if (this._liquidTween) {
                    this._liquidTween.kill();
                }
            }
        }

        _animateTurbulence() {
            if (!this._liquidTurb) {
                return;
            }
            let phase = 0;
            const tick = () => {
                if (!this._liquidAnimating) {
                    return;
                }
                phase += 0.003;
                const freq = 0.012 + Math.sin(phase) * 0.006;
                this._liquidTurb.setAttribute('baseFrequency', `${freq.toFixed(5)} ${(freq * 0.8).toFixed(5)}`);
                this._rafId = requestAnimationFrame(tick);
            };
            this._rafId = requestAnimationFrame(tick);
        }

        // -----------------------------------------------------------------
        // EFFECT 4 — Slice Strip Reveal (DOM transforms, GPU composited)
        // -----------------------------------------------------------------

        _initSlice() {
            const { sliceCount, sliceDirection } = this.config;
            const imgSrc = this._getImageSrc();

            // Hide canvas — slice is DOM-only
            this.canvas.style.display = 'none';

            const container = document.createElement('div');
            container.className = 'sifency-distortion-slices';
            this.wrap.appendChild(container);

            const isH = sliceDirection !== 'vertical';

            // Delay slice build until we know the wrap dimensions.
            // If wrap isn't painted yet, defer to next frame.
            const build = () => {
                const wrapW = this.wrap.offsetWidth;
                const wrapH = this.wrap.offsetHeight;

                // Remove any previously built slices (e.g. on resize rebuild)
                container.innerHTML = '';
                this._sliceEls = [];

                for (let i = 0; i < sliceCount; i++) {
                    const slice = document.createElement('div');
                    slice.className = 'sifency-distortion-slice';

                    const pct = (i / sliceCount) * 100;       // % offset of this slice in the wrap
                    const size = (1 / sliceCount) * 100;       // % size of each slice

                    // -------------------------------------------------------
                    // background-size: cover  replicates object-fit:cover.
                    //
                    // background-position:
                    //   With background-size:cover the bg image is already
                    //   cover-fitted to the SLICE element, not the whole wrap.
                    //   We need to counteract that and make it look like a
                    //   window cut out of one large cover-fitted image.
                    //
                    //   The trick: set background-position as a negative offset
                    //   in px so the visible portion through each slice aligns
                    //   with where that slice sits inside the full wrap.
                    //
                    //   For a horizontal slice at top = T% of wrapH:
                    //     bgOffsetY = -(T / 100 * wrapH)  px
                    //     bgOffsetX = 0
                    //   But the bg is cover-fitted to the slice, not the wrap.
                    //   So we must express the offset relative to the bg size.
                    //
                    //   Simpler equivalent: use calc() with known pixel values.
                    // -------------------------------------------------------

                    if (isH) {
                        // Each horizontal slice is full-width, (1/n) of height.
                        // The bg covers the SLICE, so it covers 100% wide × (wrapH/sliceH) tall
                        // of the original image. We want the i-th vertical strip.
                        // Offset: shift bg up by (i * sliceH) to show the right band.
                        const sliceH = wrapH / sliceCount;
                        const bgOffY = -(i * sliceH); // px, negative = shift up

                        slice.style.cssText = `
							position:absolute;left:0;right:0;
							top:${pct}%;height:${size}%;
							background-image:url('${imgSrc}');
							background-size:cover;
							background-position:center ${bgOffY}px;
							will-change:transform;
							transform:translateX(0);
						`;
                    } else {
                        // Each vertical slice is full-height, (1/n) of width.
                        const sliceW = wrapW / sliceCount;
                        const bgOffX = -(i * sliceW); // px, negative = shift left

                        slice.style.cssText = `
							position:absolute;top:0;bottom:0;
							left:${pct}%;width:${size}%;
							background-image:url('${imgSrc}');
							background-size:cover;
							background-position:${bgOffX}px center;
							will-change:transform;
							transform:translateY(0);
						`;
                    }

                    container.appendChild(slice);
                    this._sliceEls.push(slice);
                }
            };

            // Build immediately if wrap is already laid out, else defer one frame
            if (this.wrap.offsetWidth > 0) {
                build();
            } else {
                requestAnimationFrame(build);
            }

            this._sliceIsH = isH;
            this._sliceBuild = build; // keep ref for resize rebuild

            // Hide original image (slices cover it)
            const origEl = this.wrap.querySelector('.sifency-distortion-image, .sifency-distortion-bg');
            if (origEl) {
                origEl.style.opacity = '0';
            }
        }

        _updateSlices(progress) {
            // Dirty check — only update when progress changed meaningfully
            if (Math.abs(progress - this._lastSliceProg) < 0.003) {
                return;
            }
            this._lastSliceProg = progress;

            const { intensity } = this.config;
            const maxOffset = (intensity / 100) * 120;
            const phase = 1 - progress; // 1=scattered, 0=assembled

            // Batch DOM writes (no interleaved reads)
            const transforms = this._sliceEls.map((_, i) => {
                const dir = i % 2 === 0 ? 1 : -1;
                return dir * maxOffset * phase;
            });

            this._sliceEls.forEach((slice, i) => {
                const offset = transforms[i];
                const opacity = phase < 0.95 ? 1 : 1 - (phase - 0.95) * 20;
                slice.style.transform = this._sliceIsH
                    ? `translateX(${offset}px)`
                    : `translateY(${offset}px)`;
                slice.style.opacity = String(Math.max(0, opacity));
            });
        }

        // -----------------------------------------------------------------
        // TRIGGERS — Scroll
        // -----------------------------------------------------------------

        _initScroll() {
            if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
                console.warn('SifencyDistortion: GSAP / ScrollTrigger not found.');
                return;
            }

            const { scrollStart, scrollEnd, effect } = this.config;

            ScrollTrigger.create({
                trigger: this.root,
                start: scrollStart || 'top 85%',
                end: scrollEnd || 'bottom 20%',
                scrub: true,
                onUpdate: (self) => {
                    this._progress = self.progress;
                    this._onProgressChange();
                },
            });
        }

        // -----------------------------------------------------------------
        // TRIGGERS — Hover
        // -----------------------------------------------------------------

        _initHover() {
            const { hoverEase, hoverDuration, reverseOnLeave } = this.config;

            this.wrap.addEventListener('mouseenter', () => {
                if (this._hoverTween) {
                    this._hoverTween.kill();
                }
                this._hoverTween = gsap.to(this, {
                    _hoverProgress: 0.5,
                    duration: hoverDuration || 0.6,
                    ease: hoverEase || 'power2.out',
                    onUpdate: () => this._onProgressChange(),
                });
            });

            this.wrap.addEventListener('mouseleave', () => {
                if (!reverseOnLeave) {
                    return;
                }
                if (this._hoverTween) {
                    this._hoverTween.kill();
                }
                this._hoverTween = gsap.to(this, {
                    _hoverProgress: 0,
                    duration: hoverDuration || 0.6,
                    ease: hoverEase || 'power2.out',
                    onUpdate: () => this._onProgressChange(),
                });
            });
        }

        // -----------------------------------------------------------------
        // Central progress dispatcher — called by scroll + hover
        // -----------------------------------------------------------------

        _onProgressChange() {
            const combined = Math.min(1, Math.max(0, this._progress + this._hoverProgress));
            const { effect } = this.config;

            switch (effect) {
                case 'pixel':
                    this._scheduleDraw();
                    break;
                case 'rgb':
                    this._updateRGB(combined);
                    break;
                case 'liquid':
                    this._updateLiquid(combined);
                    break;
                case 'slice':
                    this._updateSlices(combined);
                    break;
            }
        }

        // -----------------------------------------------------------------
        // Visibility — pause RAF when off screen
        // -----------------------------------------------------------------

        _initVisibility() {
            this._visible = false;
            if (!('IntersectionObserver' in window)) {
                this._visible = true;
                return;
            }
            const io = new IntersectionObserver(
                (entries) => {
                    this._visible = entries[0].isIntersecting;
                    if (!this._visible) {
                        this._stopRaf();
                    }
                },
                { threshold: 0 }
            );
            io.observe(this.root);
            this._io = io;
        }

        // -----------------------------------------------------------------
        // Resize — debounced, no per-frame cost
        // -----------------------------------------------------------------

        _initResize() {
            const ro = new ResizeObserver(() => {
                clearTimeout(this._resizeTimer);
                this._resizeTimer = setTimeout(() => {
                    // Pixel canvas resize
                    this._fitCanvas();
                    this._lastCombined = -1;
                    this._scheduleDraw();

                    // Slice mode: rebuild background-position offsets
                    if (this.config.effect === 'slice' && this._sliceBuild) {
                        this._lastSliceProg = -1;
                        this._sliceBuild();
                    }
                }, 150);
            });
            ro.observe(this.wrap);
            this._ro = ro;
        }

        // -----------------------------------------------------------------
        // Helpers
        // -----------------------------------------------------------------

        _getImageSrc() {
            const { mode } = this.config;
            if (mode === 'image') {
                return this.wrap.querySelector('.sifency-distortion-image')?.src || '';
            }
            if (mode === 'background') {
                return this.wrap.querySelector('.sifency-distortion-bg')?.dataset.bgUrl || '';
            }
            return '';
        }

        // -----------------------------------------------------------------
        // Destroy
        // -----------------------------------------------------------------

        destroy() {
            this._stopRaf();
            clearTimeout(this._resizeTimer);

            if (this._ro) {
                this._ro.disconnect();
            }
            if (this._io) {
                this._io.disconnect();
            }
            if (this._hoverTween) {
                this._hoverTween.kill();
            }
            if (this._liquidTween) {
                this._liquidTween.kill();
            }
            if (this._liquidSvg && this._liquidSvg.parentNode) {
                this._liquidSvg.parentNode.removeChild(this._liquidSvg);
            }
            // Clean up channel SVG filters
            const svgId = `sifency-rgb-filter-${this._uid}`;
            const oldSvg = document.getElementById(svgId);
            if (oldSvg && oldSvg.parentNode) {
                oldSvg.parentNode.removeChild(oldSvg);
            }
        }
    }

    // =====================================================================
    // Boot
    // =====================================================================

    function bootDistortion(scope) {
        const els = (scope || document).querySelectorAll('.sifency-distortion');
        els.forEach((el) => {
            if (el._sifencyDistortion) {
                return;
            }
            el._sifencyDistortion = new SifencyDistortion(el);
        });
    }

    document.addEventListener('DOMContentLoaded', () => bootDistortion(document));

    if (window.elementorFrontend && window.elementorFrontend.hooks && window.elementorFrontend.hooks.addAction) {
        window.elementorFrontend.hooks.addAction(
            'frontend/element_ready/sifency-distortion.default',
            ($scope) => bootDistortion($scope[0])
        );
    }

    window.SifencyDistortion = SifencyDistortion;

})();