Jump to content

Module:MasonryLayout

Documentation for this module may be created at Module:MasonryLayout/doc

--[[
 * MasonryLayout.lua
 * Intelligent masonry layout system for content distribution
 * 
 * This module provides smart card distribution across columns for optimal visual balance.
 * Integrates with the Blueprint architecture and follows ICANNWiki performance patterns.
 *
 * Key Features:
 * - Content-aware size estimation
 * - Intelligent card distribution algorithm
 * - Responsive column management
 * - Aggressive caching for performance
 * - Blueprint integration
 * - Error handling integration
 *
 * Integration with other modules:
 * - ErrorHandling: All operations are protected with centralized error handling
 * - TemplateHelpers: Uses caching mechanisms and utility functions
 * - TemplateStructure: Integrates with block-based rendering
]]

local p = {}

-- ========== Constants as Upvalues ==========
local EMPTY_STRING = ''
local DEFAULT_COLUMNS = 3
local MOBILE_BREAKPOINT = 656  -- 41rem to match CSS breakpoint

-- Size estimation constants (based on typical MediaWiki table rendering)
local SIZE_ESTIMATES = {
    BASE_CARD_HEIGHT = 120,      -- Base table overhead (header + borders + padding)
    HEADER_HEIGHT = 40,          -- Table header height
    ROW_HEIGHT = 25,             -- Average height per table row
    TITLE_CHAR_FACTOR = 1.2,     -- Additional height for long titles
    CONTENT_OVERFLOW_FACTOR = 15, -- Extra height for content overflow
    MIN_CARD_HEIGHT = 80,        -- Minimum card height
    MAX_CARD_HEIGHT = 800        -- Maximum card height (safety limit)
}

-- Column distribution weights for different screen sizes
local RESPONSIVE_CONFIG = {
    desktop = { columns = 3, minWidth = MOBILE_BREAKPOINT + 1 },
    mobile = { columns = 1, maxWidth = MOBILE_BREAKPOINT }
}

-- ========== Required modules ==========
local ErrorHandling = require('Module:ErrorHandling')
local TemplateHelpers = require('Module:TemplateHelpers')
local NormalizationText = require('Module:NormalizationText')

-- ========== Module-level Caches ==========
local sizeCache = {}
local distributionCache = {}

-- ========== Utility Functions ==========

-- Generate a signature for a set of cards to use in cache keys
-- @param cards table Array of card objects
-- @return string A signature representing the card set
local function generateCardSignature(cards)
    if not cards or #cards == 0 then
        return 'empty'
    end
    
    local parts = {}
    for i, card in ipairs(cards) do
        parts[i] = string.format('%s:%d:%s', 
            card.id or 'unknown',
            card.estimatedSize or 0,
            card.contentType or 'default'
        )
    end
    
    return table.concat(parts, '|')
end

-- Calculate balance score for column heights (lower is better)
-- @param columnHeights table Array of column heights
-- @return number Balance score (0 = perfect balance)
local function calculateBalance(columnHeights)
    if not columnHeights or #columnHeights < 2 then
        return 0
    end
    
    local total = 0
    local count = #columnHeights
    
    -- Calculate average height
    for _, height in ipairs(columnHeights) do
        total = total + height
    end
    local average = total / count
    
    -- Calculate variance from average
    local variance = 0
    for _, height in ipairs(columnHeights) do
        local diff = height - average
        variance = variance + (diff * diff)
    end
    
    return variance / count
end

-- ========== Core Size Estimation ==========

-- Estimate the rendered height of a card based on its content
-- @param cardData table Card information: {title, rowCount, contentType, hasLongNames, isEmpty, rawDataCount}
-- @param options table Options: {mobileMode, customFactors}
-- @return number Estimated height in pixels
function p.estimateCardSize(cardData, options)
    options = options or {}
    
    -- Handle empty cards
    if cardData.isEmpty then
        return 0
    end
    
    -- Use raw data count if available for most accurate estimation
    local effectiveRowCount = cardData.rawDataCount or cardData.rowCount or 0
    
    -- Generate cache key
    local cacheKey = TemplateHelpers.generateCacheKey(
        'masonryCardSize',
        cardData.title or '',
        effectiveRowCount,
        cardData.contentType or 'default',
        cardData.hasLongNames and 'longNames' or 'shortNames',
        options.mobileMode and 'mobile' or 'desktop'
    )
    
    return TemplateHelpers.withCache(cacheKey, function()
        -- Card padding constant (15px top + 15px bottom from CSS)
        local CARD_PADDING = 30
        
        -- Special handling for intro cards (text content, not tables)
        if cardData.contentType == 'intro' then
            -- Intro is typically 2-3 lines of welcome text + card padding
            return 120 + CARD_PADDING -- Fixed height for consistency
        end
        
        -- Special handling for infoBox
        if cardData.contentType == 'infoBox' then
            -- InfoBox has fixed structure: header + 4-5 rows + card padding
            return 200 + CARD_PADDING -- Fixed height based on actual structure
        end
        
        -- Precise calculation for data tables
        -- Based on actual MediaWiki table rendering measurements:
        local TABLE_HEADER = 45      -- Table header with title
        local TABLE_PADDING = 20     -- Top/bottom padding
        local ROW_HEIGHT = 28        -- Actual height per row in pixels
        local BORDER_SPACING = 2     -- Border between rows
        
        -- Calculate exact height based on row count
        local contentHeight = TABLE_HEADER + TABLE_PADDING + CARD_PADDING
        
        if effectiveRowCount > 0 then
            -- Each row takes ROW_HEIGHT plus border spacing
            contentHeight = contentHeight + (effectiveRowCount * (ROW_HEIGHT + BORDER_SPACING))
            
            -- Add extra height for long content names (wrapping)
            if cardData.hasLongNames then
                -- Long names typically wrap to 2 lines
                local wrapBonus = math.floor(effectiveRowCount * 0.3) * ROW_HEIGHT
                contentHeight = contentHeight + wrapBonus
            end
        end
        
        -- Add small buffer for margin/padding variations
        contentHeight = contentHeight + 15
        
        -- Mobile adjustments (slightly more spacing)
        if options.mobileMode then
            contentHeight = contentHeight * 1.1
        end
        
        -- Apply reasonable bounds
        contentHeight = math.max(80, contentHeight)  -- Minimum height
        contentHeight = math.min(2000, contentHeight) -- Maximum height (very large tables)
        
        return math.floor(contentHeight)
    end)
end

-- Analyze SMW query results to estimate card content
-- @param queryResults table Results from SMW query
-- @param cardType string Type of card ('organizations', 'people', etc.)
-- @return table Card data for size estimation
function p.analyzeQueryResults(queryResults, cardType)
    if not queryResults or #queryResults == 0 then
        return {
            isEmpty = true,
            rowCount = 0,
            contentType = cardType
        }
    end
    
    local rowCount = #queryResults
    local hasLongNames = false
    local totalNameLength = 0
    
    -- Analyze content for long names
    for _, result in ipairs(queryResults) do
        local name = result.result or result[1] or ''
        totalNameLength = totalNameLength + #name
        if #name > 30 then
            hasLongNames = true
        end
    end
    
    local averageNameLength = totalNameLength / rowCount
    
    return {
        isEmpty = false,
        rowCount = rowCount,
        contentType = cardType,
        hasLongNames = hasLongNames or averageNameLength > 25,
        averageNameLength = averageNameLength
    }
end

-- ========== Smart Distribution Algorithm ==========

-- Distribute cards across columns for optimal balance
-- @param cards table Array of card objects with size estimates
-- @param columnCount number Target number of columns (default: 3)
-- @return table Distribution result: {columns, heights, balance}
function p.distributeCards(cards, columnCount)
    columnCount = columnCount or DEFAULT_COLUMNS
    
    -- Handle edge cases
    if not cards or #cards == 0 then
        local emptyColumns = {}
        local emptyHeights = {}
        for i = 1, columnCount do
            emptyColumns[i] = {}
            emptyHeights[i] = 0
        end
        return {
            columns = emptyColumns,
            heights = emptyHeights,
            balance = 0
        }
    end
    
    -- Generate cache key
    local cardSignature = generateCardSignature(cards)
    local cacheKey = TemplateHelpers.generateCacheKey('masonryDistribution', cardSignature, columnCount)
    
    return TemplateHelpers.withCache(cacheKey, function()
        return p.distributeCardsIntelligent(cards, columnCount)
    end)
end

-- Intelligent card distribution with balance optimization
-- @param cards table Array of card objects with size estimates
-- @param columnCount number Target number of columns
-- @return table Distribution result: {columns, heights, balance}
function p.distributeCardsIntelligent(cards, columnCount)
    -- Pre-allocate column arrays
    local columns = {}
    local columnHeights = {}
    
    for i = 1, columnCount do
        columns[i] = {}
        columnHeights[i] = 0
    end
    
    -- Separate special positioning cards from regular cards
    local regularCards = {}
    local infoBoxCard = nil
    local introCard = nil
    
    for _, card in ipairs(cards) do
        if card.id == 'infoBox' then
            infoBoxCard = card
        elseif card.id == 'intro' then
            introCard = card
        else
            table.insert(regularCards, card)
        end
    end
    
    -- Place intro and infoBox first for alignment
    if introCard then
        table.insert(columns[1], introCard)
        columnHeights[1] = introCard.estimatedSize or 0
    end
    
    if infoBoxCard then
        table.insert(columns[columnCount], infoBoxCard)
        columnHeights[columnCount] = infoBoxCard.estimatedSize or 0
    end
    
    -- Calculate total size and identify extreme cases
    local totalRegularSize = 0
    local cardSizes = {}
    
    for _, card in ipairs(regularCards) do
        local size = card.estimatedSize or 0
        totalRegularSize = totalRegularSize + size
        table.insert(cardSizes, {card = card, size = size})
    end
    
    -- Sort by size descending for optimal packing
    table.sort(cardSizes, function(a, b) return a.size > b.size end)
    
    -- Identify extremely large cards (using actual pixel calculations)
    local extremeCards = {}
    local normalCards = {}
    local extremeThreshold = totalRegularSize * 0.4 -- 40% of total content
    
    for _, item in ipairs(cardSizes) do
        if item.size > extremeThreshold and #extremeCards < columnCount then
            table.insert(extremeCards, item.card)
        else
            table.insert(normalCards, item.card)
        end
    end
    
    -- Phase 1: Distribute extreme cards to minimize imbalance
    if #extremeCards > 0 then
        -- Find columns with least content (excluding special cards initially)
        local availableColumns = {}
        for i = 1, columnCount do
            -- Skip columns with special cards for initial extreme card placement
            if not ((i == 1 and introCard) or (i == columnCount and infoBoxCard)) then
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
            end
        end
        
        -- If all columns have special cards, include them
        if #availableColumns == 0 then
            for i = 1, columnCount do
                table.insert(availableColumns, {column = i, height = columnHeights[i]})
            end
        end
        
        -- Sort by height ascending
        table.sort(availableColumns, function(a, b) return a.height < b.height end)
        
        -- Place extreme cards in shortest columns
        for i, extremeCard in ipairs(extremeCards) do
            if availableColumns[i] then
                local targetColumn = availableColumns[i].column
                table.insert(columns[targetColumn], extremeCard)
                columnHeights[targetColumn] = columnHeights[targetColumn] + (extremeCard.estimatedSize or 0)
            end
        end
    end
    
    -- Phase 2: Distribute remaining cards using modified best-fit algorithm
    for _, card in ipairs(normalCards) do
        -- Calculate which column would result in best overall balance
        local bestColumn = 1
        local bestScore = math.huge
        
        for i = 1, columnCount do
            -- Simulate adding this card to column i
            local simulatedHeight = columnHeights[i] + (card.estimatedSize or 0)
            
            -- Calculate variance if we add to this column
            local variance = 0
            local totalHeight = 0
            
            for j = 1, columnCount do
                local h = (j == i) and simulatedHeight or columnHeights[j]
                totalHeight = totalHeight + h
            end
            
            local avgHeight = totalHeight / columnCount
            
            for j = 1, columnCount do
                local h = (j == i) and simulatedHeight or columnHeights[j]
                local diff = h - avgHeight
                variance = variance + (diff * diff)
            end
            
            -- Prefer columns that minimize variance
            if variance < bestScore then
                bestScore = variance
                bestColumn = i
            end
        end
        
        -- Place card in best column
        table.insert(columns[bestColumn], card)
        columnHeights[bestColumn] = columnHeights[bestColumn] + (card.estimatedSize or 0)
    end
    
    -- Phase 3: Post-optimization - try to swap cards between columns to improve balance
    -- This is a simple optimization pass that can significantly improve results
    local improved = true
    local iterations = 0
    local maxIterations = 10
    
    while improved and iterations < maxIterations do
        improved = false
        iterations = iterations + 1
        
        -- Find most and least loaded columns
        local maxCol, minCol = 1, 1
        local maxHeight, minHeight = columnHeights[1], columnHeights[1]
        
        for i = 2, columnCount do
            if columnHeights[i] > maxHeight then
                maxHeight = columnHeights[i]
                maxCol = i
            end
            if columnHeights[i] < minHeight then
                minHeight = columnHeights[i]
                minCol = i
            end
        end
        
        -- Try to move a card from max to min column
        if maxCol ~= minCol and maxHeight - minHeight > 100 then -- Significant imbalance
            -- Find a card in maxCol that would improve balance
            for i = #columns[maxCol], 1, -1 do
                local card = columns[maxCol][i]
                -- Skip special cards
                if card.id ~= 'intro' and card.id ~= 'infoBox' then
                    local cardSize = card.estimatedSize or 0
                    -- Check if moving this card would improve balance
                    local newMaxHeight = maxHeight - cardSize
                    local newMinHeight = minHeight + cardSize
                    
                    if math.abs(newMaxHeight - newMinHeight) < math.abs(maxHeight - minHeight) then
                        -- Move the card
                        table.remove(columns[maxCol], i)
                        table.insert(columns[minCol], card)
                        columnHeights[maxCol] = newMaxHeight
                        columnHeights[minCol] = newMinHeight
                        improved = true
                        break
                    end
                end
            end
        end
    end
    
    return {
        columns = columns,
        heights = columnHeights,
        balance = calculateBalance(columnHeights),
        totalSize = totalRegularSize,
        extremeCards = #extremeCards,
        iterations = iterations
    }
end

-- ========== Blueprint Integration ==========

-- Create Blueprint-compatible block functions for masonry layout
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return table Blueprint block configuration
function p.createMasonryBlocks(cardDefinitions, options)
    options = options or {}
    
    return {
        masonryWrapperOpen = {
            feature = 'fullPage',
            render = function(template, args)
                return p.renderMasonryOpen(options)
            end
        },
        
        masonryContent = {
            feature = 'fullPage',
            render = function(template, args)
                return p.renderMasonryContent(template, args, cardDefinitions, options)
            end
        },
        
        masonryWrapperClose = {
            feature = 'fullPage',
            render = function(template, args)
                return p.renderMasonryClose()
            end
        }
    }
end

-- Render the opening masonry container
-- @param options table Layout options
-- @return string HTML for masonry container opening
function p.renderMasonryOpen(options)
    options = options or {}
    local cssClass = options.containerClass or 'country-hub-masonry-container'
    
    return string.format('<div class="%s">', cssClass)
end

-- Render the closing masonry container
-- @return string HTML for masonry container closing
function p.renderMasonryClose()
    return '</div>'
end

-- Render the main masonry content with intelligent distribution
-- @param template table Template object
-- @param args table Template arguments
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return string Complete masonry layout HTML
function p.renderMasonryContent(template, args, cardDefinitions, options)
    if not template._errorContext then
        template._errorContext = ErrorHandling.createContext("MasonryLayout")
    end
    
    return ErrorHandling.protect(
        template._errorContext,
        "renderMasonryContent",
        function()
            return p.renderMasonryContentInternal(template, args, cardDefinitions, options)
        end,
        EMPTY_STRING,
        template, args, cardDefinitions, options
    )
end

-- Internal masonry content rendering (protected by error handling)
-- @param template table Template object
-- @param args table Template arguments
-- @param cardDefinitions table Array of card definitions
-- @param options table Layout options
-- @return string Complete masonry layout HTML
function p.renderMasonryContentInternal(template, args, cardDefinitions, options)
    options = options or {}
    local columnCount = options.columns or DEFAULT_COLUMNS
    
    -- Build cards with content analysis
    local cards = {}
    local cardIndex = 1
    
    for _, cardDef in ipairs(cardDefinitions) do
        -- Check if this card's feature is enabled
        if template.features[cardDef.feature] then
            -- Get the block renderer for this card
            local renderer = template._blocks and template._blocks[cardDef.blockId]
            if renderer and renderer.render then
                -- Render the card content
                local cardContent = renderer.render(template, args)
                
                if cardContent and cardContent ~= EMPTY_STRING then
                    -- Get raw data count if available
                    local rawDataCount = template._rawDataCounts and template._rawDataCounts[cardDef.blockId] or nil
                    
                    -- Analyze the content for size estimation
                    local cardData = p.analyzeCardContent(cardContent, cardDef, rawDataCount)
                    cardData.id = cardDef.blockId
                    cardData.title = cardDef.title or cardDef.blockId
                    cardData.content = cardContent
                    cardData.estimatedSize = p.estimateCardSize(cardData, options)
                    
                    cards[cardIndex] = cardData
                    cardIndex = cardIndex + 1
                end
            end
        end
    end
    
    -- Distribute cards across columns
    local distribution = p.distributeCards(cards, columnCount)
    
    -- Render the distributed layout
    return p.renderDistributedLayout(distribution, options)
end

-- Analyze rendered card content to extract size information
-- @param cardContent string Rendered HTML content
-- @param cardDef table Card definition
-- @param rawDataCount number Optional: actual count of data entries from SMW query
-- @return table Card data for size estimation
function p.analyzeCardContent(cardContent, cardDef, rawDataCount)
    if not cardContent or cardContent == EMPTY_STRING then
        return {
            isEmpty = true,
            rowCount = 0,
            contentType = cardDef.blockId,
            rawDataCount = 0
        }
    end
    
    -- Use raw data count if provided (most accurate)
    local rowCount = rawDataCount or 0
    
    -- Fallback: Count table rows in the content (look for <tr> tags or table row patterns)
    if rowCount == 0 then
        local _, trCount = cardContent:gsub('<tr[^>]*>', '')
        local _, wikiRowCount = cardContent:gsub('|-', '')
        rowCount = math.max(trCount, wikiRowCount)
    end
    
    -- Final fallback: estimate based on content length
    if rowCount == 0 and #cardContent > 100 then
        -- Rough estimation: every ~200 characters might be a row
        rowCount = math.ceil(#cardContent / 200)
    end
    
    -- Check for long content (rough heuristic)
    local hasLongNames = #cardContent > 1000 or cardContent:find('[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+[%w%s%-%.]+')
    
    return {
        isEmpty = false,
        rowCount = math.max(1, rowCount), -- At least 1 row if content exists
        contentType = cardDef.blockId,
        hasLongNames = hasLongNames,
        contentLength = #cardContent,
        rawDataCount = rawDataCount or rowCount -- Store the actual count for debugging
    }
end

-- Render the final distributed layout
-- @param distribution table Distribution result from distributeCards
-- @param options table Layout options
-- @return string Complete HTML for the distributed layout
function p.renderDistributedLayout(distribution, options)
    options = options or {}
    local columnClass = options.columnClass or 'country-hub-masonry-column'
    local cardClass = options.cardClass or 'country-hub-masonry-card'
    
    local columns = distribution.columns
    local columnCount = #columns
    
    if columnCount == 0 then
        return EMPTY_STRING
    end
    
    -- Build HTML for each column
    local columnHtml = {}
    
    for i = 1, columnCount do
        local columnCards = columns[i]
        local cardHtml = {}
        
        -- Render cards in this column
        for j, card in ipairs(columnCards) do
            -- ALL cards should be wrapped in the card class for consistent styling
            cardHtml[j] = string.format(
                '<div class="%s" data-card-id="%s">%s</div>',
                cardClass,
                card.id or 'unknown',
                card.content or EMPTY_STRING
            )
        end
        
        -- Wrap column
        columnHtml[i] = string.format(
            '<div class="%s" data-column="%d">%s</div>',
            columnClass,
            i,
            table.concat(cardHtml, '\n')
        )
    end
    
    return table.concat(columnHtml, '\n')
end

-- ========== Responsive Utilities ==========

-- Get responsive column count based on screen size
-- @param screenWidth number Screen width in pixels
-- @return number Appropriate column count
function p.getResponsiveColumns(screenWidth)
    if screenWidth <= MOBILE_BREAKPOINT then
        return 1
    else
        return 3
    end
end

-- Generate responsive CSS classes
-- @param options table Layout options
-- @return string CSS classes for responsive behavior
function p.generateResponsiveClasses(options)
    options = options or {}
    local baseClass = options.baseClass or 'country-hub-masonry'
    
    local classes = {baseClass}
    
    if options.mobileColumns then
        table.insert(classes, baseClass .. '-mobile-' .. options.mobileColumns)
    end
    
    if options.tabletColumns then
        table.insert(classes, baseClass .. '-tablet-' .. options.tabletColumns)
    end
    
    if options.desktopColumns then
        table.insert(classes, baseClass .. '-desktop-' .. options.desktopColumns)
    end
    
    return table.concat(classes, ' ')
end

-- ========== Intelligent Layout Rendering ==========

-- Main render function for intelligent masonry layout
-- This is the core function that coordinates content rendering, analysis, and distribution
-- @param template table Template object with features and configuration
-- @param args table Template arguments
-- @param config table Configuration with cardDefinitions, options, and blockRenderers
-- @return string Complete masonry layout HTML
function p.renderIntelligentLayout(template, args, config)
    -- Create render-time error context (Blueprint pattern)
    local errorContext = ErrorHandling.createContext("MasonryLayout")
    
    return ErrorHandling.protect(
        errorContext,
        "renderIntelligentLayout",
        function()
            return p.renderIntelligentLayoutInternal(template, args, config, errorContext)
        end,
        EMPTY_STRING,
        template, args, config
    )
end

-- Internal implementation of intelligent layout rendering
-- @param template table Template object
-- @param args table Template arguments  
-- @param config table Configuration object
-- @param errorContext table Error context for protected operations
-- @return string Complete masonry layout HTML
function p.renderIntelligentLayoutInternal(template, args, config, errorContext)
    local cardDefinitions = config.cardDefinitions or {}
    local options = config.options or {}
    local blockRenderers = config.blockRenderers or {}
    
    -- Determine render mode (mobile vs desktop)
    -- Default to desktop mode, but can be overridden by options
    local isMobileMode = options.mobileMode or false
    local columnCount = isMobileMode and 1 or (options.columns or DEFAULT_COLUMNS)
    
    -- Build cards with render-time content generation
    local cards = {}
    local cardIndex = 1
    
    for _, cardDef in ipairs(cardDefinitions) do
        -- Check if this card's feature is enabled
        if template.features[cardDef.feature] then
            -- Get the block renderer for this card
            local renderer = blockRenderers[cardDef.blockId]
            if renderer and renderer.render then
                -- Render the card content at render-time (not configuration-time)
                local cardContent = ErrorHandling.protect(
                    errorContext,
                    "renderCard_" .. cardDef.blockId,
                    function()
                        return renderer.render(template, args)
                    end,
                    EMPTY_STRING,
                    template, args
                )
                
                if cardContent and cardContent ~= EMPTY_STRING then
                    -- Analyze the content for size estimation
                    local cardData = p.analyzeCardContent(cardContent, cardDef)
                    cardData.id = cardDef.blockId
                    cardData.title = cardDef.title or cardDef.blockId
                    cardData.content = cardContent
                    cardData.estimatedSize = p.estimateCardSize(cardData, options)
                    
                    cards[cardIndex] = cardData
                    cardIndex = cardIndex + 1
                end
            end
        end
    end
    
    -- Branch based on render mode
    if isMobileMode or columnCount == 1 then
        -- MOBILE MODE: Single column with explicit ordering
        local orderedCards = {}
        local introCard = nil
        local infoBoxCard = nil
        local otherCards = {}
        
        -- Separate special cards from regular cards
        for _, card in ipairs(cards) do
            if card.id == 'intro' then
                introCard = card
            elseif card.id == 'infoBox' then
                infoBoxCard = card
            else
                table.insert(otherCards, card)
            end
        end
        
        -- Build final ordered list: intro → infoBox → others
        if introCard then
            table.insert(orderedCards, introCard)
        end
        if infoBoxCard then
            table.insert(orderedCards, infoBoxCard)
        end
        for _, card in ipairs(otherCards) do
            table.insert(orderedCards, card)
        end
        
        -- Render as single column layout
        local containerClass = options.containerClass or 'country-hub-masonry-container'
        local cardClass = options.cardClass or 'country-hub-masonry-card'
        
        local masonryHtml = string.format('<div class="%s country-hub-mobile-mode">', containerClass)
        
        -- Add debug information
        masonryHtml = masonryHtml .. string.format(
            '<!-- Masonry Debug: Total cards: %d, Mobile single column layout -->',
            #orderedCards
        )
        
        -- Render each card in order
        for _, card in ipairs(orderedCards) do
            masonryHtml = masonryHtml .. string.format(
                '\n<div class="%s" data-card-id="%s">%s</div>',
                cardClass,
                card.id or 'unknown',
                card.content or EMPTY_STRING
            )
        end
        
        masonryHtml = masonryHtml .. '\n</div>'
        
        return masonryHtml
    else
        -- DESKTOP MODE: Multi-column with intelligent distribution
        local distribution = p.distributeCards(cards, columnCount)
        
        -- Render the complete masonry layout
        local containerClass = options.containerClass or 'country-hub-masonry-container'
        local masonryHtml = string.format('<div class="%s">', containerClass)
        
        -- Add debug information as HTML comments
        if distribution then
            masonryHtml = masonryHtml .. string.format(
                '<!-- Masonry Debug: Total cards: %d, Columns: %d, Heights: [%s], Balance: %.2f, Extreme cards: %d -->',
                #cards,
                #(distribution.columns or {}),
                table.concat(distribution.heights or {}, ', '),
                distribution.balance or 0,
                distribution.extremeCards or 0
            )
            
            -- Add per-card debug info
            for i, column in ipairs(distribution.columns or {}) do
                masonryHtml = masonryHtml .. string.format('\n<!-- Column %d: ', i)
                for _, card in ipairs(column) do
                    masonryHtml = masonryHtml .. string.format('%s(%dpx) ', card.id or 'unknown', card.estimatedSize or 0)
                end
                masonryHtml = masonryHtml .. '-->'
            end
        end
        
        masonryHtml = masonryHtml .. '\n' .. p.renderDistributedLayout(distribution, options)
        masonryHtml = masonryHtml .. '</div>'
        
        return masonryHtml
    end
end

-- ========== Debug and Utilities ==========

-- Get distribution statistics for debugging
-- @param distribution table Distribution result
-- @return table Statistics about the distribution
function p.getDistributionStats(distribution)
    if not distribution or not distribution.columns then
        return {
            columnCount = 0,
            totalCards = 0,
            balance = 0,
            heights = {}
        }
    end
    
    local totalCards = 0
    for _, column in ipairs(distribution.columns) do
        totalCards = totalCards + #column
    end
    
    return {
        columnCount = #distribution.columns,
        totalCards = totalCards,
        balance = distribution.balance or 0,
        heights = distribution.heights or {},
        averageHeight = distribution.heights and (#distribution.heights > 0) and 
                       (table.concat(distribution.heights, '+') / #distribution.heights) or 0
    }
end

-- Clear caches (for debugging/testing)
function p.clearCaches()
    sizeCache = {}
    distributionCache = {}
    -- Also clear TemplateHelpers cache if needed
end

return p