import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from './uiHelpers.js';

/**
 * Column-based (masonry) virtual scroller.
 *
 * Mirrors the VirtualScroller constructor options and lifecycle, but places
 * items into the shortest column instead of a fixed row grid. Each item keeps
 * its natural aspect ratio (width/height provided by the backend); items
 * without dimensions fall back to the default 896:1152 card ratio.
 *
 * Synchronous layout contract: column placement (_layoutItems) and the spacer
 * height are computed synchronously inside loadInitialBatch/refreshWithData,
 * BEFORE scheduleRender's rAF callback. This guarantees the scroll container's
 * scrollHeight is correct within 2 rAFs so restoreScrollPosition
 * (infiniteScroll.js) can preserve the scroll position after refresh.
 */
export class MasonryScroller {
    constructor(options) {
        // Configuration
        this.gridElement = options.gridElement;
        this.createItemFn = options.createItemFn;
        this.fetchItemsFn = options.fetchItemsFn;
        this.overscan = options.overscan || 5; // Extra items to render above/below viewport
        this.containerElement = options.containerElement || this.gridElement.parentElement;
        this.scrollContainer = options.scrollContainer || this.containerElement;
        this.batchSize = options.batchSize || 50;
        this.pageSize = options.pageSize || 100;
        this.itemAspectRatio = 896 / 1152; // Fallback aspect ratio of cards
        this.rowGap = options.rowGap || 20; // Vertical gap between items (default 20px)
        this.columnGap = options.columnGap || 12; // Horizontal gap between columns

        // Container padding properties
        this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
        this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS

        // Data windowing flag is accepted for API parity but never enabled here
        this.enableDataWindowing = false;

        // State
        this.items = []; // All items metadata
        this.renderedItems = new Map(); // Map of rendered DOM elements by index
        this.totalItems = 0;
        this.isLoading = false;
        this.hasMore = true;
        this.lastScrollTop = 0;
        this.scrollDirection = 'down';
        this.disabled = false;
        this.resizeObserver = null;

        // Masonry layout state
        this.columnsCount = 0;
        this.itemWidth = 0;
        this.positions = []; // Per-index placement: { col, top, left, width, height }
        this.columnHeights = []; // Accumulated height per column
        this.lastRenderRange = new Set(); // Indices rendered in the last pass

        // Loading timeout state
        this.loadingTimeout = null;
        this.loadingTimeoutDuration = options.loadingTimeoutDuration || 15000; // 15 seconds default

        // Initialize
        this.initializeContainer();
        this.setupEventListeners();
        this.calculateLayout();
    }

    initializeContainer() {
        // Add virtual scroll and masonry classes to grid
        this.gridElement.classList.add('virtual-scroll');
        this.gridElement.classList.add('masonry-layout');

        // Set the container to have relative positioning
        if (getComputedStyle(this.containerElement).position === 'static') {
            this.containerElement.style.position = 'relative';
        }

        // Create a spacer element with the total height
        this.spacerElement = document.createElement('div');
        this.spacerElement.className = 'virtual-scroll-spacer';
        this.spacerElement.style.width = '100%';
        this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
        this.spacerElement.style.pointerEvents = 'none';

        // The grid will be used for the actual visible items
        this.gridElement.style.position = 'relative';
        this.gridElement.style.minHeight = '0';

        // Apply padding directly to ensure consistency
        this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
        this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;

        // Place the spacer inside the grid container
        this.gridElement.appendChild(this.spacerElement);
    }

    calculateLayout() {
        const pageState = getCurrentPageState();
        if (pageState.duplicatesMode) {
            return false;
        }

        // Get container width and style information
        const containerWidth = this.containerElement.clientWidth;
        const containerStyle = getComputedStyle(this.containerElement);
        const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
        const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;

        // Calculate available content width (excluding padding)
        const availableContentWidth = containerWidth - paddingLeft - paddingRight;

        // Get display density setting
        const displayDensity = state.global.settings?.display_density || 'default';

        // Base gap between cards
        const baseGap = 12;
        this.columnGap = baseGap;

        // Define minimum card width based on density setting to ensure usability
        // Cards smaller than this become hard to interact with and view
        const minCardWidths = {
            'default': 240,  // Default: comfortable minimum
            'medium': 200,   // Medium: slightly smaller
            'compact': 170   // Compact: smallest usable size
        };
        const minCardWidth = minCardWidths[displayDensity] || 240;

        // Calculate maximum possible columns that fit in available width
        // Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
        const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));

        // Ensure at least 1 column
        const maxColumns = Math.max(1, maxPossibleColumns);

        // Define preferred maximum columns based on display density and screen size
        // These are upper limits to prevent too many columns on ultra-wide screens
        let preferredMaxColumns;
        if (window.innerWidth >= 3000) { // 4K
            if (displayDensity === 'default') {
                preferredMaxColumns = 8;
            } else if (displayDensity === 'medium') {
                preferredMaxColumns = 10;
            } else { // compact
                preferredMaxColumns = 12;
            }
        } else if (window.innerWidth >= 2150) { // 2K/1440p
            if (displayDensity === 'default') {
                preferredMaxColumns = 6;
            } else if (displayDensity === 'medium') {
                preferredMaxColumns = 8;
            } else { // compact
                preferredMaxColumns = 10;
            }
        } else { // 1080p and smaller
            if (displayDensity === 'default') {
                preferredMaxColumns = 5;
            } else if (displayDensity === 'medium') {
                preferredMaxColumns = 6;
            } else { // compact
                preferredMaxColumns = 8;
            }
        }

        // Use the smaller of: max columns that fit, or preferred max
        // This ensures cards are never smaller than minCardWidth
        this.columnsCount = Math.min(maxColumns, preferredMaxColumns);

        // Calculate card width to perfectly fill available space
        // Formula: (availableWidth - totalGap) / columns
        const totalGap = (this.columnsCount - 1) * this.columnGap;
        this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;

        // Edge-to-edge layout: no offset, grid fills container
        const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;

        // Update grid element to fill available width
        this.gridElement.style.maxWidth = `${actualGridWidth}px`;
        this.gridElement.style.width = `${actualGridWidth}px`;

        // Add or remove density classes for style adjustments
        this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
        this.gridElement.classList.add(`${displayDensity}-density`);

        // Recompute placement and spacer synchronously, then re-render
        this._layoutItems();
        this.updateSpacerHeight();
        this.clearRenderedItems();
        this.scheduleRender();

        return true;
    }

    /**
     * Compute the display height of an item at the current column width.
     * Falls back to the default 896:1152 card ratio when dimensions are
     * missing (videos, missing previews, legacy data).
     */
    _computeHeight(item) {
        if (item && item.width && item.height) {
            return this.itemWidth * item.height / item.width;
        }
        return this.itemWidth / this.itemAspectRatio;
    }

    /**
     * Place every loaded item into the shortest column.
     * Produces this.positions (per-index placement) and this.columnHeights.
     */
    _layoutItems() {
        this.positions = [];
        this.columnHeights = [];
        if (this.columnsCount === 0) return;

        // All columns start at the container's top padding
        for (let c = 0; c < this.columnsCount; c++) {
            this.columnHeights.push(this.containerPaddingTop);
        }

        for (let i = 0; i < this.items.length; i++) {
            const item = this.items[i];
            const height = this._computeHeight(item);

            // Find the shortest column (first wins on ties)
            let col = 0;
            for (let c = 1; c < this.columnsCount; c++) {
                if (this.columnHeights[c] < this.columnHeights[col]) {
                    col = c;
                }
            }

            const top = this.columnHeights[col];
            const left = col * (this.itemWidth + this.columnGap);

            this.positions.push({ col, top, left, width: this.itemWidth, height });

            this.columnHeights[col] += height + this.rowGap;
        }
    }

    updateSpacerHeight() {
        if (this.columnsCount === 0) return;

        // Total height = tallest column minus the trailing row gap, plus padding
        const maxColumnHeight = this.columnHeights.length > 0 ? Math.max(...this.columnHeights) : 0;
        const spacerHeight = Math.max(
            0,
            maxColumnHeight - this.rowGap + this.containerPaddingTop + this.containerPaddingBottom
        );

        // Update spacer height to represent all items
        this.spacerElement.style.height = `${spacerHeight}px`;
    }

    setupEventListeners() {
        // Debounced scroll handler
        this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
        this.scrollContainer.addEventListener('scroll', this.scrollHandler);

        // Window resize handler for layout recalculation
        this.resizeHandler = this.debounce(() => {
            this.calculateLayout();
        }, 150);

        window.addEventListener('resize', this.resizeHandler);

        // Use ResizeObserver for more accurate container size detection
        if (typeof ResizeObserver !== 'undefined') {
            this.resizeObserver = new ResizeObserver(this.debounce(() => {
                this.calculateLayout();
            }, 150));

            this.resizeObserver.observe(this.containerElement);
        }
    }

    async initialize() {
        try {
            await this.loadInitialBatch();
            this.scheduleRender();
        } catch (err) {
            console.error('Failed to initialize masonry scroller:', err);
            showToast('toast.virtual.loadFailed', {}, 'error');
        }
    }

    async loadInitialBatch() {
        const pageState = getCurrentPageState();
        if (this.isLoading) return;

        this.isLoading = true;
        this.setLoadingTimeout(); // Add loading timeout safety

        try {
            const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);

            this.items = items || [];
            this.totalItems = totalItems || 0;
            this.hasMore = hasMore;

            // Synchronous layout contract: placement and spacer height must be
            // computed here, before any rAF-driven render, so the scroll
            // container's scrollHeight is correct for restoreScrollPosition.
            this._layoutItems();
            this.updateSpacerHeight();

            // Check if there are no items and show placeholder if needed
            if (this.items.length === 0) {
                this.showNoItemsPlaceholder();
            } else {
                this.removeNoItemsPlaceholder();
            }

            // Reset page state to sync with our scroller
            pageState.currentPage = 2; // Next page to load would be 2
            pageState.hasMore = this.hasMore;
            pageState.isLoading = false;

            return { items, totalItems, hasMore };
        } catch (err) {
            console.error('Failed to load initial batch:', err);
            this.showNoItemsPlaceholder('Failed to load items. Please try refreshing the page.');
            throw err;
        } finally {
            this.isLoading = false;
            this.clearLoadingTimeout(); // Clear the timeout
        }
    }

    async loadMoreItems() {
        const pageState = getCurrentPageState();
        if (this.isLoading || !this.hasMore) return;

        this.isLoading = true;
        pageState.isLoading = true;
        this.setLoadingTimeout(); // Add loading timeout safety

        try {
            console.log('Loading more items, page:', pageState.currentPage);
            const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);

            if (items && items.length > 0) {
                this.items = [...this.items, ...items];
                this.hasMore = hasMore;
                pageState.hasMore = hasMore;

                // Update page for next request
                pageState.currentPage++;

                // Recompute placement and spacer synchronously
                this._layoutItems();
                this.updateSpacerHeight();

                // Render the newly loaded items if they're in view
                this.scheduleRender();

                console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
            } else {
                this.hasMore = false;
                pageState.hasMore = false;
                console.log('No more items to load');
            }

            return items;
        } catch (err) {
            console.error('Failed to load more items:', err);
            showToast('toast.virtual.loadMoreFailed', {}, 'error');
        } finally {
            this.isLoading = false;
            pageState.isLoading = false;
            this.clearLoadingTimeout(); // Clear the timeout
        }
    }

    // Loading timeout safety: never leave isLoading stuck on a hung fetch
    setLoadingTimeout() {
        // Clear any existing timeout first
        this.clearLoadingTimeout();

        // Set a new timeout to prevent loading state from getting stuck
        this.loadingTimeout = setTimeout(() => {
            if (this.isLoading) {
                console.warn('Loading timeout occurred. Resetting loading state.');
                this.isLoading = false;
                const pageState = getCurrentPageState();
                pageState.isLoading = false;
            }
        }, this.loadingTimeoutDuration);
    }

    clearLoadingTimeout() {
        if (this.loadingTimeout) {
            clearTimeout(this.loadingTimeout);
            this.loadingTimeout = null;
        }
    }

    /**
     * Return the Set of currently visible item indices.
     * An item is visible when its vertical span intersects the viewport
     * expanded by overscanH = overscan * itemWidth on both sides.
     */
    getVisibleRange() {
        const scrollTop = this.scrollContainer.scrollTop;
        const viewportHeight = this.scrollContainer.clientHeight;
        const overscanH = this.overscan * this.itemWidth;

        const visible = new Set();
        const minTop = scrollTop - overscanH;
        const maxTop = scrollTop + viewportHeight + overscanH;

        for (let i = 0; i < this.positions.length; i++) {
            const pos = this.positions[i];
            if (pos.top < maxTop && pos.top + pos.height > minTop) {
                visible.add(i);
            }
        }

        return visible;
    }

    scheduleRender() {
        if (this.disabled || this.renderScheduled) return;

        this.renderScheduled = true;
        requestAnimationFrame(() => {
            this.renderItems();
            this.renderScheduled = false;
        });
    }

    renderItems() {
        if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;

        const visible = this.getVisibleRange();

        // Skip re-render when the visible set has not changed
        if (visible.size === this.lastRenderRange.size) {
            let identical = visible.size > 0;
            if (identical) {
                for (const index of visible) {
                    if (!this.lastRenderRange.has(index)) {
                        identical = false;
                        break;
                    }
                }
            }
            if (identical) return;
        }

        this.lastRenderRange = new Set(visible);

        // Remove items that are no longer visible
        for (const [index, element] of this.renderedItems.entries()) {
            if (!visible.has(index)) {
                element.remove();
                this.renderedItems.delete(index);
            }
        }

        // Use DocumentFragment for batch DOM operations
        const fragment = document.createDocumentFragment();

        // Add new visible items to the fragment
        for (const index of visible) {
            if (index >= this.items.length) continue;
            if (!this.renderedItems.has(index)) {
                const item = this.items[index];
                const element = this.createItemElement(item, index);
                fragment.appendChild(element);
                this.renderedItems.set(index, element);
            }
        }

        // Add the fragment to the grid (single DOM operation)
        if (fragment.childNodes.length > 0) {
            this.gridElement.appendChild(fragment);
        }

        // If we're close to the end and have more items to load, fetch them
        let lastVisibleIndex = -1;
        for (const index of visible) {
            if (index > lastVisibleIndex) lastVisibleIndex = index;
        }
        if (lastVisibleIndex >= 0 &&
            lastVisibleIndex >= this.items.length - (this.columnsCount * 2) &&
            this.hasMore && !this.isLoading) {
            this.loadMoreItems();
        }
    }

    clearRenderedItems() {
        this.renderedItems.forEach(element => element.remove());
        this.renderedItems.clear();
        this.lastRenderRange = new Set();
    }

    refreshWithData(items, totalItems, hasMore) {
        this.items = items || [];
        this.totalItems = totalItems || 0;
        this.hasMore = hasMore;

        // Synchronous layout contract: placement and spacer height are
        // computed before the rAF-driven render so scroll restoration works.
        this._layoutItems();
        this.updateSpacerHeight();

        // Check if there are no items and show placeholder if needed
        if (this.items.length === 0) {
            this.showNoItemsPlaceholder();
        } else {
            this.removeNoItemsPlaceholder();
        }

        // Clear all rendered items and redraw
        this.clearRenderedItems();
        this.scheduleRender();
    }

    createItemElement(item, index) {
        // Create the DOM element
        const element = this.createItemFn(item);

        // Add virtual scroll item class
        element.classList.add('virtual-scroll-item');

        // Look up the precomputed masonry placement
        const pos = this.positions[index];

        // Position the element with absolute positioning
        element.style.position = 'absolute';
        element.style.left = `${pos.left}px`;
        element.style.top = `${pos.top}px`;
        element.style.width = `${pos.width}px`;
        element.style.height = `${pos.height}px`;

        // Remove max-width/min-width constraints from the model-card to allow
        // dynamic sizing. The card is either the item element itself (e.g.
        // RecipeCard returns the .model-card root) or a descendant (ModelCard).
        const modelCard = element.classList.contains('model-card')
            ? element
            : element.querySelector('.model-card');
        if (modelCard) {
            modelCard.style.maxWidth = 'none';
            modelCard.style.minWidth = '0';
        }

        // Scroll recycling rebuilds cards from scratch, dropping `.selected`
        // even though the filepath is still selected in state.
        if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
            modelCard.classList.add('selected');
        }

        return element;
    }

    handleScroll() {
        // Determine scroll direction
        const scrollTop = this.scrollContainer.scrollTop;
        this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
        this.lastScrollTop = scrollTop;

        // Render visible items
        this.scheduleRender();

        // If we're near the bottom and have more items, load them
        const { clientHeight, scrollHeight } = this.scrollContainer;
        const scrollBottom = scrollTop + clientHeight;

        // Bottom of the tallest column's last item (columnHeights include the
        // trailing row gap after each item, so subtract it back out)
        const contentBottom = this.columnHeights.length > 0
            ? Math.max(...this.columnHeights) - this.rowGap
            : 0;

        // Trigger when within 20% of the scroll height, or within two item
        // widths of the bottom, whichever threshold is smaller
        const scrollThreshold = Math.min(
            scrollHeight * 0.2,
            this.itemWidth * 2
        );

        const shouldLoadMore = scrollBottom >= contentBottom - scrollThreshold;

        if (shouldLoadMore && this.hasMore && !this.isLoading) {
            this.loadMoreItems();
        }
    }

    reset() {
        // Remove all rendered items
        this.clearRenderedItems();

        // Reset state
        this.items = [];
        this.totalItems = 0;
        this.hasMore = true;
        this.positions = [];
        this.columnHeights = [];

        // Reset spacer height
        this.spacerElement.style.height = '0px';

        // Remove any placeholder
        this.removeNoItemsPlaceholder();

        // Schedule a re-render
        this.scheduleRender();
    }

    dispose() {
        // Remove event listeners
        this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
        window.removeEventListener('resize', this.resizeHandler);

        // Clean up the resize observer if present
        if (this.resizeObserver) {
            this.resizeObserver.disconnect();
        }

        // Remove any active grid loading overlay
        this.hideGridLoading();

        // Remove rendered elements
        this.clearRenderedItems();

        // Remove spacer
        this.spacerElement.remove();

        // Remove virtual scroll and masonry classes
        this.gridElement.classList.remove('virtual-scroll');
        this.gridElement.classList.remove('masonry-layout');

        // Clear any pending timeout
        this.clearLoadingTimeout();
    }

    // Placeholder display helpers
    showNoItemsPlaceholder(message) {
        // Remove any existing placeholder first
        this.removeNoItemsPlaceholder();

        // Create placeholder message
        const placeholder = document.createElement('div');
        placeholder.className = 'placeholder-message';

        // Determine appropriate message based on page type
        let placeholderText = '';

        if (message) {
            placeholderText = message;
        } else {
            const pageType = state.currentPageType;

            if (pageType === 'recipes') {
                if (String(getCurrentPageState().sortBy).startsWith('opened')) {
                    placeholderText = `
                        <p>No recently opened recipes</p>
                        <p>Recipes you open will appear here.</p>
                    `;
                } else {
                    placeholderText = `
                        <p>No recipes found</p>
                        <p>Add recipe images to your recipes folder to see them here.</p>
                    `;
                }
            } else if (pageType === 'loras') {
                placeholderText = `
                    <p>No LoRAs found</p>
                    <p>Add LoRAs to your models folder to see them here.</p>
                `;
            } else if (pageType === 'checkpoints') {
                placeholderText = `
                    <p>No checkpoints found</p>
                    <p>Add checkpoints to your models folder to see them here.</p>
                `;
            } else {
                placeholderText = `
                    <p>No items found</p>
                    <p>Try adjusting your search filters or add more content.</p>
                `;
            }
        }

        placeholder.innerHTML = placeholderText;
        placeholder.id = 'virtualScrollPlaceholder';

        // Append placeholder to the grid
        this.gridElement.appendChild(placeholder);
    }

    removeNoItemsPlaceholder() {
        const placeholder = document.getElementById('virtualScrollPlaceholder');
        if (placeholder) {
            placeholder.remove();
        }
    }

    // Utility method for debouncing
    debounce(func, wait) {
        let timeout;
        return function (...args) {
            const context = this;
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(context, args), wait);
        };
    }

    /**
     * Show a grid-scoped loading indicator (replaces full-page overlay)
     * Only covers the card grid area, leaving header/sidebar unaffected.
     */
    showGridLoading() {
        // Remove any stale overlay from a prior deferred hide (e.g. from final rAF)
        this.hideGridLoading();
        const overlay = document.createElement('div');
        overlay.className = 'grid-loading-overlay';
        const spinner = document.createElement('div');
        spinner.className = 'loading-spinner';
        overlay.appendChild(spinner);
        this.gridElement.appendChild(overlay);
        this.gridLoadingOverlay = overlay;
    }

    /**
     * Hide the grid-scoped loading indicator.
     */
    hideGridLoading() {
        if (this.gridLoadingOverlay) {
            this.gridLoadingOverlay.remove();
            this.gridLoadingOverlay = null;
        }
    }

    // Add disable method to stop rendering and events
    disable() {
        // Detach scroll event listener
        this.scrollContainer.removeEventListener('scroll', this.scrollHandler);

        // Clear all rendered items from the DOM
        this.clearRenderedItems();

        // Hide the spacer element
        if (this.spacerElement) {
            this.spacerElement.style.display = 'none';
        }

        // Flag as disabled
        this.disabled = true;

        console.log('Masonry scroller disabled');
    }

    // Add enable method to resume rendering and events
    enable() {
        if (!this.disabled) return;

        // Reattach scroll event listener
        this.scrollContainer.addEventListener('scroll', this.scrollHandler);

        // Check if spacer element exists in the DOM, if not, recreate it
        // (duplicates mode destroys it via gridElement.innerHTML = '')
        if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
            console.log('Spacer element not found in DOM, recreating it');

            // Create a new spacer element
            this.spacerElement = document.createElement('div');
            this.spacerElement.className = 'virtual-scroll-spacer';
            this.spacerElement.style.width = '100%';
            this.spacerElement.style.height = '0px';
            this.spacerElement.style.pointerEvents = 'none';

            // Append it to the grid
            this.gridElement.appendChild(this.spacerElement);
        } else {
            // Show the spacer element if it exists
            this.spacerElement.style.display = 'block';
        }

        // Masonry needs a full synchronous re-placement on re-enable: column
        // heights and the spacer height must be recomputed before rendering.
        this._layoutItems();
        this.updateSpacerHeight();

        // Flag as enabled
        this.disabled = false;

        // Re-render items
        this.scheduleRender();

        console.log('Masonry scroller enabled');
    }

    // Helper function for deep merging objects - only updates existing keys in target
    deepMerge(target, source) {
        if (!source || !target) return target;

        // Initialize result with a copy of target
        const result = { ...target };

        if (!source) return result;

        // Iterate over all keys in the source object
        Object.keys(source).forEach(key => {
            const targetValue = target[key];
            const sourceValue = source[key];

            // If both values are non-null objects and not arrays, merge recursively
            if (
                targetValue !== null &&
                typeof targetValue === 'object' &&
                !Array.isArray(targetValue) &&
                sourceValue !== null &&
                typeof sourceValue === 'object' &&
                !Array.isArray(sourceValue)
            ) {
                result[key] = this.deepMerge(targetValue || {}, sourceValue);
            } else {
                // Otherwise update with source value (includes primitives, arrays, and new keys)
                result[key] = sourceValue;
            }
        });

        return result;
    }

    updateSingleItem(filePath, updatedItem) {
        if (!filePath || !updatedItem) {
            console.error('Invalid parameters for updateSingleItem');
            return false;
        }

        // Find the index of the item with the matching file_path
        const index = this.items.findIndex(item => item.file_path === filePath);
        if (index === -1) {
            console.warn(`Item with file path ${filePath} not found in masonry scroller data`);
            return false;
        }

        // Update the item data using deep merge
        this.items[index] = this.deepMerge(this.items[index], updatedItem);

        // Full synchronous re-placement: width/height changes (e.g. preview
        // re-fetched) must re-flow the affected column and everything after it.
        this._layoutItems();
        this.updateSpacerHeight();

        // If the item is currently rendered, update its DOM representation
        if (this.renderedItems.has(index)) {
            const element = this.renderedItems.get(index);

            // Remove the old element
            element.remove();
            this.renderedItems.delete(index);

            // Create and render the updated element
            const updatedElement = this.createItemElement(this.items[index], index);

            // Add update indicator visual effects
            updatedElement.classList.add('updated');

            // Add temporary update tag
            const updateIndicator = document.createElement('div');
            updateIndicator.className = 'update-indicator';
            updateIndicator.textContent = 'Updated';
            updatedElement.querySelector('.card-preview').appendChild(updateIndicator);

            // Automatically remove the updated class after animation completes
            setTimeout(() => {
                updatedElement.classList.remove('updated');
            }, 1500);

            // Automatically remove the indicator after animation completes
            setTimeout(() => {
                if (updateIndicator && updateIndicator.parentNode) {
                    updateIndicator.remove();
                }
            }, 2000);

            this.renderedItems.set(index, updatedElement);
            this.gridElement.appendChild(updatedElement);
        }

        return true;
    }

    // Remove an item by file path
    removeItemByFilePath(filePath) {
        if (!filePath || this.disabled || this.items.length === 0) return false;

        // Find the index of the item with the matching file path
        const index = this.items.findIndex(item => item.file_path === filePath);

        if (index === -1) {
            console.warn(`Item with file path ${filePath} not found in masonry scroller data`);
            return false;
        }

        // Remove the item from the data array
        this.items.splice(index, 1);

        // Decrement total count
        this.totalItems = Math.max(0, this.totalItems - 1);

        // Full synchronous re-placement of all remaining items, then spacer
        this._layoutItems();
        this.updateSpacerHeight();

        // Re-render to ensure proper layout
        this.clearRenderedItems();
        this.scheduleRender();

        console.log(`Removed item with file path ${filePath} from masonry scroller data`);
        return true;
    }

    /**
     * Remove multiple items by their file paths.
     * More efficient than calling removeItemByFilePath individually.
     * @param {string[]} filePaths - Array of file paths to remove
     * @returns {boolean} - True if any items were removed
     */
    removeMultipleItemsByFilePath(filePaths) {
        if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;

        // Build a set for fast lookup
        const pathsToRemove = new Set(filePaths);
        const originalLength = this.items.length;

        // Filter out removed items; keep those not in the set
        this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));

        const removedCount = originalLength - this.items.length;
        if (removedCount === 0) return false;

        this.totalItems = Math.max(0, this.totalItems - removedCount);

        // Full synchronous re-placement of all remaining items, then spacer
        this._layoutItems();
        this.updateSpacerHeight();

        // Re-render to fill gaps left by removed items
        this.clearRenderedItems();
        this.scheduleRender();

        console.log(`Removed ${removedCount} items from masonry scroller data`);
        return true;
    }

    // Add keyboard navigation methods
    handlePageUpDown(direction) {
        // Prevent duplicate animations by checking last trigger time
        const now = Date.now();
        if (this.lastPageNavTime && now - this.lastPageNavTime < 300) {
            return; // Ignore rapid repeated triggers
        }
        this.lastPageNavTime = now;

        const scrollContainer = this.scrollContainer;
        const viewportHeight = scrollContainer.clientHeight;

        // Calculate scroll distance (one viewport minus 10% overlap for context)
        const scrollDistance = viewportHeight * 0.9;

        // Determine the new scroll position
        const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);

        // Remove any existing transition indicators
        this.removeExistingTransitionIndicator();

        // Scroll to the new position with smooth animation
        scrollContainer.scrollTo({
            top: newScrollTop,
            behavior: 'smooth'
        });

        // Force render after scrolling
        setTimeout(() => this.renderItems(), 100);
        setTimeout(() => this.renderItems(), 300);
    }

    // Helper to remove existing indicators
    removeExistingTransitionIndicator() {
        const existingIndicator = document.querySelector('.page-transition-indicator');
        if (existingIndicator) {
            existingIndicator.remove();
        }
    }

    scrollToTop() {
        this.removeExistingTransitionIndicator();

        this.scrollContainer.scrollTo({
            top: 0,
            behavior: 'smooth'
        });

        // Force render after scrolling
        setTimeout(() => this.renderItems(), 100);
    }

    scrollToBottom() {
        this.removeExistingTransitionIndicator();

        // Start loading all remaining pages to ensure content is available
        this.loadRemainingPages().then(() => {
            // After loading all content, scroll to the very bottom
            const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
            this.scrollContainer.scrollTo({
                top: maxScroll,
                behavior: 'smooth'
            });
        });
    }

    // Load all remaining pages (used by End key navigation)
    async loadRemainingPages() {
        // If we're already at the end or loading, don't proceed
        if (!this.hasMore || this.isLoading) return;

        console.log('Loading all remaining pages for End key navigation...');

        // Keep loading pages until we reach the end
        while (this.hasMore && !this.isLoading) {
            await this.loadMoreItems();

            // Force render after each page load
            this.renderItems();

            // Small delay to prevent overwhelming the browser
            await new Promise(resolve => setTimeout(resolve, 50));
        }

        console.log('Finished loading all pages');

        // Final render to ensure all content is displayed
        this.renderItems();
    }

    /**
     * Find the index of an item by its file path.
     * @param {string} filePath
     * @returns {number} index of the item or -1 when not found
     */
    findIndexByFilePath(filePath) {
        if (!filePath) return -1;
        return this.items.findIndex(item => item.file_path === filePath);
    }

    /**
     * Return navigation state for the given item.
     * @param {string} filePath
     * @returns {{index: number, hasPrev: boolean, hasNext: boolean, loadedItems: number, totalItems: number}}
     */
    getNavigationState(filePath) {
        const index = this.findIndexByFilePath(filePath);
        const hasPrev = index > 0;
        const hasNext = index !== -1 && (index < this.items.length - 1 || this.hasMore);

        return {
            index,
            hasPrev,
            hasNext,
            loadedItems: this.items.length,
            totalItems: this.totalItems
        };
    }

    /**
     * Get the adjacent item relative to the provided file path.
     * When the target index falls outside the loaded items and more pages
     * are available, this method will request additional pages until the
     * target item is available or no more data exists.
     * @param {string} filePath
     * @param {'prev' | 'next'} direction
     * @returns {Promise<{item: Object, index: number} | null>}
     */
    async getAdjacentItemByFilePath(filePath, direction = 'next') {
        const currentIndex = this.findIndexByFilePath(filePath);
        if (currentIndex === -1) return null;

        const offset = direction === 'prev' ? -1 : 1;
        let targetIndex = currentIndex + offset;

        if (targetIndex < 0) {
            return null;
        }

        // Attempt to load more items if needed to reach the target index
        let safetyCounter = 0;
        while (targetIndex >= this.items.length && this.hasMore && safetyCounter < 10) {
            safetyCounter++;
            const newItems = await this.loadMoreItems();
            if (!newItems || newItems.length === 0) {
                break;
            }
        }

        if (targetIndex < 0 || targetIndex >= this.items.length) {
            return null;
        }

        return {
            item: this.items[targetIndex],
            index: targetIndex
        };
    }

    // Data windowing is accepted for API parity but never enabled here;
    // these stubs keep the public method surface identical to VirtualScroller.
    async fetchDataWindow(targetIndex) {
        if (!this.enableDataWindowing) return;
    }

    async slideDataWindow() {
        if (!this.enableDataWindowing) return;
    }
}
