/**
 * JustifiedGallery
 *
 * A responsive justified image gallery with smooth transitions, modal preview,
 * custom item styles, and dynamic hover effects.
 *
 * Features:
 * - Responsive columns & max row height (with breakpoints)
 * - Optional modal (zoomable, draggable, navigable)
 * - Configurable hover effects (Lens, Ripple, Spotlight, etc.)
 * - Custom per-item inline styles via `itemStyle`
 * - Smooth animated layout transitions when column count changes (FLIP via overlay)
 *
 * Author: Aranéite (Antoine)
 * Version: 2.4
 */

export default class JustifiedGallery {
    /* =========================================================
     * Constructor & public state
     * ======================================================= */

    /**
     * @param {HTMLElement} container - The gallery container element.
     * @param {Object} options        - Configuration options.
     */
    constructor(container, options = {}) {
        if (!container) return;

        this.container   = container;
        this.images      = [];
        this.imagesClone = [];
        this.currentIndex = 0;
        this.isOpenModal  = false;
        this.currentCols  = null; // track current column count for breakpoint animation

        this.options = {
            margin: options.margin ?? 5,
            columns: options.columns ?? 5,
            breakpoints: options.breakpoints ?? { 1248: 3, 768: 2, 480: 1 },
            hasModal: options.hasModal ?? false,
            linkAttribute: options.linkAttribute || 'src',
            justifyLastRow: options.justifyLastRow ?? false,
            hoverEffect: options.hoverEffect || null,
            itemStyle: options.itemStyle || {},

            maxRowHeight: options.maxRowHeight ?? 350,
            maxRowHeightBreakpoints: options.maxRowHeightBreakpoints ?? {
                1248: 300,
                768: 250,
                480: 200,
            },
        };

        // Initialisation
        this.init();

        // Handle responsive relayout on resize
        this._onResize = () => {
            if (!this.container) return;

            const newCols = this._getColumns();
            const shouldAnimateWrap =
                this.currentCols !== null && newCols !== this.currentCols;

            this.currentCols = newCols;
            this.layoutGallery({ animateWrap: shouldAnimateWrap });
        };

        window.addEventListener('resize', this._onResize);
    }

    /* =========================================================
     * Initialization
     * ======================================================= */

    /**
     * Initialize gallery:
     * - wrap images in anchors
     * - wait for load
     * - initial layout
     * - modal / hover / custom styles
     */
    async init() {
        // Start hidden → fade-in after first layout
        this.container.style.opacity = '0';

        // Initial raw images
        this.images      = this._getImages();
        this.imagesClone = this._getImagesClones();

        // Wrap each image in a link (<a>) and attach hover class
        this.images.forEach(img => this._wrapImage(img));

        // Refresh internal image list (now wrapped by <a>)
        this.images = this._getImages();

        // Wait for all images to be loaded (or failed)
        const promises = this.images.map(img => this._waitForImageLoad(img));
        await Promise.all(promises);

        // First layout (no animation on initial render)
        this.currentCols = this._getColumns();
        this.layoutGallery({ animateWrap: false });

        // Modal, hover effects, and custom item styles
        this.setupModal();
        this.initHoverEffects();
        this.applyItemStyles();

        // Smooth fade-in
        requestAnimationFrame(() => {
            this.container.style.transition = 'opacity 0.6s ease';
            this.container.style.opacity = '1';
        });
    }

    /**
     * Apply custom styles defined in options.itemStyle
     * to each gallery item (link wrapper).
     */
    applyItemStyles() {
        this.images.forEach(img => {
            const wrapper = img.parentNode;
            for (const key in this.options.itemStyle) {
                wrapper.style[key] = this.options.itemStyle[key];
            }
        });
    }

    /**
     * Initialize hover effects (spotlight, ripple, lens...)
     * once DOM is ready and images are wrapped.
     */
    initHoverEffects() {
        const effect = this.options.hoverEffect;

        // Spotlight effect (dynamic import)
        if (effect === 'hover-spotlight') {
            import('./effects/spotlight.js')
                .then(({ enableSpotlightEffect }) => {
                    enableSpotlightEffect(this.container);
                })
        }

        // Ripple effect: radial animation on mouse enter
        if (effect === 'hover-ripple-animate') {
            import('./effects/ripple-animate.js')
                .then(({ enableRippleEffect }) => {
                    enableRippleEffect(this.container);
                })
        }

        // Lens effect: zoomed circular region following the cursor
        if (effect === 'hover-lens') {
            import('./effects/lens.js')
                .then(({ enableLensEffect }) => {
                    enableLensEffect(this.container);
                })
        }
    }

    /**
     * Clean up listeners and DOM references.
     */
    destroy() {
        window.removeEventListener('resize', this._onResize);

        if (this.modal && document.body.contains(this.modal)) {
            this.modal.remove();
        }

        // Remove any listeners on images by cloning them
        if (this.images && this.images.length) {
            this.images.forEach(img => {
                const clone = img.cloneNode(true);
                img.replaceWith(clone);
            });
        }

        this.container  = null;
        this.images     = null;
        this.modal      = null;
        this.modalImg   = null;
        this.closeBtn   = null;
        this.prevBtn    = null;
        this.nextBtn    = null;
        this.isOpenModal = false;
    }

    /* =========================================================
     * Layout computation & application
     * ======================================================= */

    /**
     * Compute layout data for all rows without mutating the DOM.
     *
     * @returns {Array<{ item: HTMLElement, img: HTMLImageElement,
     *                   width: number, height: number,
     *                   marginRight: number, marginBottom: number }>}
     */
    _computeLayout() {
        const containerWidth = this.container.clientWidth;
        const margin         = this.options.margin;
        const cols           = this._getColumns();
        const maxRowHeight   = this._getMaxRowHeight();
        const justifyLastRow = this.options.justifyLastRow;

        const layout = [];
        let rowImages = [];
        let totalRatio = 0;

        this.images.forEach((img, index) => {
            const ratio = img.naturalWidth / img.naturalHeight || 1;
            const item  = img.parentNode;

            rowImages.push({ item, img, ratio });
            totalRatio += ratio;

            const isLastRow = index === this.images.length - 1;

            if (rowImages.length === cols || isLastRow) {
                let rowHeight;

                if (isLastRow && rowImages.length < cols && !justifyLastRow) {
                    // Do not stretch last row: use average ratio and target less height
                    const avgRatio = totalRatio / rowImages.length;
                    rowHeight = Math.floor((containerWidth / cols) / avgRatio);
                    rowHeight = Math.min(rowHeight, maxRowHeight);
                } else {
                    // Normal justified row: fill container width
                    rowHeight = Math.floor(
                        (containerWidth - margin * (rowImages.length - 1)) / totalRatio
                    );
                    rowHeight = Math.min(rowHeight, maxRowHeight);

                    // Stretch to perfectly fill the row horizontally
                    const totalWidth =
                        rowImages.reduce((sum, r) => sum + rowHeight * r.ratio, 0) +
                        margin * (rowImages.length - 1);

                    if (totalWidth > 0) {
                        const stretchFactor = containerWidth / totalWidth;
                        rowHeight *= stretchFactor;
                    }
                }

                rowImages.forEach((r, idx) => {
                    const width = rowHeight * r.ratio;
                    layout.push({
                        item: r.item,
                        img: r.img,
                        width,
                        height: rowHeight,
                        marginRight: idx < rowImages.length - 1 ? margin : 0,
                        marginBottom: margin,
                    });
                });

                rowImages  = [];
                totalRatio = 0;
            }
        });

        // Keep in sync with actual columns used
        this.currentCols = this._getColumns();

        return layout;
    }

    /**
     * Apply computed layout to DOM elements (without animation).
     * @param {Array} layout - Layout entries from _computeLayout().
     */
    _applyLayout(layout) {
        layout.forEach(entry => {
            const { item, img, width, height, marginRight, marginBottom } = entry;

            // Anchor <a> wrapper sized using computed layout
            item.style.width  = `${width}px`;
            item.style.height = `${height}px`;
            item.style.display = 'inline-block';
            item.style.marginRight  = `${marginRight}px`;
            item.style.marginBottom = `${marginBottom}px`;
            item.style.transition   = 'none';

            // Image fills its wrapper
            img.style.width  = '100%';
            img.style.height = '100%';
            img.style.cursor = 'pointer';
            img.style.transition = 'none';
        });
    }

    /**
     * Main layout function.
     * - If animateWrap = true, uses overlay + FLIP-like animation
     *   when the number of columns changes (breakpoint hit).
     * - Otherwise only recomputes justification without wrap animation.
     *
     * @param {Object} options
     * @param {boolean} options.animateWrap
     */
    layoutGallery({ animateWrap = false } = {}) {
        if (!this.container || !this.images || !this.images.length) return;

        const layout = this._computeLayout();

        // Simple re-justification when column count did not change
        if (!animateWrap) {
            this._applyLayout(layout);
            return;
        }

        /* ---------------------------------------------
         * Animated wrap change using fixed overlay + clones
         * ------------------------------------------- */

        // 1) Capture current positions BEFORE applying new layout
        const prevRects     = this._getWrapperRects();
        const containerRect = this.container.getBoundingClientRect();

        // 2) Create overlay (fixed) containing clones at old positions
        const overlay = document.createElement('div');
        overlay.style.position      = 'fixed';
        overlay.style.left          = `${containerRect.left}px`;
        overlay.style.top           = `${containerRect.top}px`;
        overlay.style.width         = `${containerRect.width}px`;
        overlay.style.height        = `${containerRect.height}px`;
        overlay.style.pointerEvents = 'none';
        overlay.style.zIndex        = '9999';

        const wrappers = this.images.map(img => img.parentNode);
        const clones   = [];

        wrappers.forEach((wrapper, index) => {
            const r = prevRects[index];
            if (!r) return;

            const clone = wrapper.cloneNode(true);

            // Position & size BEFORE layout change
            clone.style.position   = 'absolute';
            clone.style.left       = `${r.left - containerRect.left}px`;
            clone.style.top        = `${r.top - containerRect.top}px`;
            clone.style.width      = `${r.width}px`;
            clone.style.height     = `${r.height}px`;
            clone.style.margin     = '0';
            clone.style.boxSizing  = 'border-box';
            clone.style.transition = 'none';

            clones.push(clone);
            overlay.appendChild(clone);
        });

        document.body.appendChild(overlay);

        // Hide real items during animation to avoid flicker/misalignment
        wrappers.forEach(w => {
            w.style.opacity = '0';
        });

        // 3) Apply NEW layout (on the real gallery, but hidden)
        this._applyLayout(layout);

        // 4) Measure final positions AFTER layout
        const newRects = wrappers.map(w => w.getBoundingClientRect());

        // 5) Animate clones from old to new rects
        requestAnimationFrame(() => {
            clones.forEach((clone, index) => {
                const newRect = newRects[index];
                const final   = layout[index]; // final width/height from layout
                if (!newRect || !final) return;

                clone.style.transition = 'all 0.6s cubic-bezier(0.25, 1, 0.5, 1)';

                // Final position
                clone.style.left = `${newRect.left - containerRect.left}px`;
                clone.style.top  = `${newRect.top  - containerRect.top }px`;

                // Final size (should match real item => avoids jump)
                clone.style.width  = `${newRect.width }px`;
                clone.style.height = `${newRect.height}px`;
            });
        });

        // 6) Cleanup: remove overlay & reveal real gallery
        setTimeout(() => {
            overlay.remove();
            wrappers.forEach(w => {
                w.style.opacity = '1';
            });
        }, 650);
    }

    /* =========================================================
     * Modal viewer
     * ======================================================= */

    /**
     * Setup modal viewer if enabled.
     */
    setupModal() {
        if (!this.options.hasModal) return;

        this.modal = document.createElement('div');
        this.modal.classList.add('jg-modal');
        this.modal.innerHTML = `
            <span class="jg-close">&times;</span>
            <img class="jg-modal-img" src="">
            <div class="jg-controls">
                <span class="jg-prev">&#10094;</span>
                <span class="jg-next">&#10095;</span>
            </div>
        `;
        document.body.appendChild(this.modal);

        this.modalImg = this.modal.querySelector('.jg-modal-img');
        this.closeBtn = this.modal.querySelector('.jg-close');
        this.prevBtn  = this.modal.querySelector('.jg-prev');
        this.nextBtn  = this.modal.querySelector('.jg-next');

        // Open modal on image click
        this.images.forEach((img, idx) => {
            img.addEventListener('click', e => {
                e.preventDefault();
                this.openModal(idx);
            });
        });

        // Basic modal controls
        this.closeBtn.addEventListener('click', () => this.closeModal());
        this.prevBtn.addEventListener('click',  () => this.prevImage());
        this.nextBtn.addEventListener('click',  () => this.nextImage());

        // Close when clicking outside image
        this.modal.addEventListener('click', e => {
            if (e.target === this.modal) this.closeModal();
        });

        // Keyboard navigation inside modal
        document.addEventListener('keydown', e => {
            if (this.modal?.style.display === 'flex') {
                if (e.key === 'ArrowLeft')  this.prevImage();
                if (e.key === 'ArrowRight') this.nextImage();
                if (e.key === 'Escape')     this.closeModal();
            }
        });

        this.initModalInteractions();
    }

    /**
     * Initialize modal drag & zoom interactions.
     */
    initModalInteractions() {
        this.modalImg.currentScale = 1;

        let isDragging = false;
        let startX = 0;
        let startY = 0;
        let translateX = 0;
        let translateY = 0;

        // Mouse drag (panning)
        this.modalImg.addEventListener('mousedown', e => {
            if (e.button !== 0) return;
            isDragging = true;
            startX = e.clientX;
            startY = e.clientY;
            this.modalImg.style.cursor = 'grabbing';
            e.preventDefault();
        });

        this.modal.addEventListener('mousemove', e => {
            if (!isDragging) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            startX = e.clientX;
            startY = e.clientY;
            translateX += dx;
            translateY += dy;
            this.modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${this.modalImg.currentScale})`;
        });

        // Stop dragging on mouse up / leave
        ['mouseup', 'mouseleave'].forEach(event => {
            this.modal.addEventListener(event, () => {
                isDragging = false;
                this.modalImg.style.cursor = 'grab';
            });
        });

        // Scroll to zoom
        this.modalImg.addEventListener('wheel', e => {
            e.preventDefault();
            const delta = e.deltaY > 0 ? -0.1 : 0.1;
            this.modalImg.currentScale = Math.min(
                Math.max(this.modalImg.currentScale + delta, 0.5),
                3
            );
            this.modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${this.modalImg.currentScale})`;
        });
    }

    openModal(index) {
        this.isOpenModal  = true;
        this.currentIndex = index;
        this.modal.style.display = 'flex';
        this.modalImg.src = this.images[this.currentIndex].src;
        document.body.style.overflow = 'hidden';
    }

    closeModal() {
        this.isOpenModal = false;
        this.modal.style.display = 'none';
        document.body.style.overflow = '';
        this.modalImg.currentScale = 1;
        this.modalImg.style.transform = 'translate(0px, 0px) scale(1)';
    }

    prevImage() {
        this.currentIndex =
            (this.currentIndex - 1 + this.images.length) % this.images.length;
        this.modalImg.src = this.images[this.currentIndex].src;
    }

    nextImage() {
        this.currentIndex =
            (this.currentIndex + 1) % this.images.length;
        this.modalImg.src = this.images[this.currentIndex].src;
    }

    /* =========================================================
     * Private helpers
     * ======================================================= */

    /**
     * @returns {HTMLImageElement[]} - All images inside the container.
     * @private
     */
    _getImages() {
        return Array.from(this.container.querySelectorAll('img'));
    }

    /**
     * @returns {HTMLElement[]} - All elements marked as `.jg-clone` (if any).
     * @private
     */
    _getImagesClones() {
        return Array.from(this.container.querySelectorAll('.jg-clone'));
    }

    /**
     * Wrap an image inside an anchor and attach hover class.
     * @private
     */
    _wrapImage(img) {
        const href = img.getAttribute(this.options.linkAttribute) || img.src;
        const a    = document.createElement('a');

        a.href        = href;
        a.style.display = 'inline-block';

        // Preserve caption if present
        a.dataset.caption = img.dataset.caption || '';
        img.dataset.caption = '';

        // Attach hover class if defined
        this._setHover(a);

        img.parentNode.insertBefore(a, img);
        a.appendChild(img);
    }

    /**
     * Attach a hover class on the wrapper if hoverEffect is configured.
     * @private
     */
    _setHover(el) {
        if (this.options.hoverEffect) {
            el.classList.add(this.options.hoverEffect);
        }
    }

    /**
     * Return the current columns count according to container width & breakpoints.
     * @private
     */
    _getColumns() {
        const width = this.container.clientWidth;
        let cols    = this.options.columns;

        const breakpoints = Object
            .keys(this.options.breakpoints)
            .map(Number)
            .sort((a, b) => a - b);

        for (const bp of breakpoints) {
            if (width <= bp) {
                cols = this.options.breakpoints[bp];
                break;
            }
        }

        return cols;
    }

    /**
     * Return the max row height according to container width & maxRowHeightBreakpoints.
     * @private
     */
    _getMaxRowHeight() {
        const width      = this.container.clientWidth;
        let maxHeight    = this.options.maxRowHeight;
        const breakpoints = this.options.maxRowHeightBreakpoints;

        if (!breakpoints) return maxHeight;

        const sorted = Object
            .keys(breakpoints)
            .map(Number)
            .sort((a, b) => a - b);

        for (const bp of sorted) {
            if (width <= bp) {
                maxHeight = breakpoints[bp];
                break;
            }
        }

        return maxHeight;
    }

    /**
     * Wait until an image is loaded (or fails) before resolving.
     * @private
     */
    _waitForImageLoad(img) {
        return new Promise(resolve => {
            if (img.complete) {
                resolve();
            } else {
                img.onload  = () => resolve();
                img.onerror = () => resolve();
            }
        });
    }

    /**
     * Get bounding rects of wrappers (anchors) before layout.
     * @returns {DOMRect[]} rects ordered by image index
     * @private
     */
    _getWrapperRects() {
        return this.images.map(img => img.parentNode.getBoundingClientRect());
    }
}
