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

local p = {}

--------------------------------------------------------------------------------
-- CONFIGURATION
--------------------------------------------------------------------------------

local SCHEMA_TYPES = {
    person = 'https://schema.org/Person',
    organization = 'https://schema.org/Organization',
    nft = 'https://schema.org/CreativeWork',
    concept = 'https://schema.org/DefinedTerm',
    event = 'https://schema.org/Event',
    exhibition = 'https://schema.org/ExhibitionEvent',
    artwork = 'https://schema.org/VisualArtwork',
    website = 'https://schema.org/WebSite',
}

-- Multi-chain block explorer support
local CHAINS = {
    ethereum = {
        explorer = 'https://etherscan.io/address/%s',
        name = 'Etherscan',
    },
    base = {
        explorer = 'https://basescan.org/address/%s',
        name = 'Basescan',
    },
    arbitrum = {
        explorer = 'https://arbiscan.io/address/%s',
        name = 'Arbiscan',
    },
    optimism = {
        explorer = 'https://optimistic.etherscan.io/address/%s',
        name = 'OP Etherscan',
    },
    polygon = {
        explorer = 'https://polygonscan.com/address/%s',
        name = 'Polygonscan',
    },
    solana = {
        explorer = 'https://solscan.io/account/%s',
        name = 'Solscan',
    },
    zora = {
        explorer = 'https://explorer.zora.energy/address/%s',
        name = 'Zora Explorer',
    },
}

-- Marketplace configurations for compact display
-- Order matters: first in list = first displayed
local MARKETPLACES = {
    {
        id = 'opensea',
        name = 'OpenSea',
        abbr = 'OS',
    },
    {
        id = 'blur',
        name = 'Blur',
        abbr = 'Blur',
    },
    {
        id = 'magiceden',
        name = 'Magic Eden',
        abbr = 'ME',
        altParam = 'magic_eden',  -- backward compat
    },
    {
        id = 'scatter',
        name = 'Scatter',
        abbr = 'Scat',
    },
}

--------------------------------------------------------------------------------
-- UTILITY FUNCTIONS
--------------------------------------------------------------------------------

local function trim(s)
    if not s then return nil end
    s = tostring(s)
    return s:match("^%s*(.-)%s*$")
end

local function isEmpty(s)
    if s == nil then return true end
    if type(s) == 'string' then
        return trim(s) == ''
    end
    return false
end

local function hasValue(s)
    return not isEmpty(s)
end

local function getArg(args, name, default)
    local val = args[name]
    if isEmpty(val) then
        return default
    end
    return trim(val)
end

local function getArgWithFallback(args, names, default)
    for _, name in ipairs(names) do
        local val = getArg(args, name)
        if hasValue(val) then
            return val
        end
    end
    return default
end

-- Format blockchain address with appropriate explorer link
local function formatAddress(address, chain, display)
    if isEmpty(address) then return nil end
    address = trim(address)
    chain = chain and trim(chain):lower() or 'ethereum'
    
    local chainConfig = CHAINS[chain]
    if not chainConfig then
        -- Fallback to ethereum if unknown chain
        chainConfig = CHAINS['ethereum']
    end
    
    -- Check if it looks like a valid address
    local isEthStyle = address:match('^0x[a-fA-F0-9]+$')
    local isSolana = chain == 'solana' and address:match('^[1-9A-HJ-NP-Za-km-z]+$')
    
    if isEthStyle or isSolana then
        local short = display or (isEthStyle and (address:sub(1, 6) .. '...' .. address:sub(-4)) or (address:sub(1, 4) .. '...' .. address:sub(-4)))
        local url = string.format(chainConfig.explorer, address)
        return mw.html.create('span')
            :addClass('infobox-contract')
            :wikitext('[' .. url .. ' ' .. short .. ']')
    end
    
    -- Return as-is if not a recognized address format
    return mw.html.create('span')
        :addClass('infobox-contract')
        :wikitext(address)
end

-- Legacy wrapper for backward compatibility
local function formatEthAddress(address, display)
    return formatAddress(address, 'ethereum', display)
end

-- Format marketplace links (manual only, compact display)
-- Returns mw.html node for compact marketplace badges
local function formatMarketplaces(args)
    local links = {}
    
    for _, mp in ipairs(MARKETPLACES) do
        -- Check for parameter (support both magiceden and magic_eden style)
        local url = args[mp.id]
        if isEmpty(url) and mp.altParam then
            url = args[mp.altParam]
        end
        
        if hasValue(url) then
            url = trim(url)
            local link = mw.html.create('span')
                :addClass('infobox-mp')
                :addClass('infobox-mp-' .. mp.id)
                :attr('title', mp.name)
                :wikitext('[' .. url .. ' ' .. mp.abbr .. ']')
            table.insert(links, tostring(link))
        end
    end
    
    if #links == 0 then
        return nil
    end
    
    return mw.html.create('span')
        :addClass('infobox-marketplaces')
        :wikitext(table.concat(links, ' '))
end

-- Format social media handle with link
local function formatSocial(platform, handle)
    if isEmpty(handle) then return nil end
    handle = trim(handle)
    
    if platform == 'twitter' or platform == 'x' then
        handle = handle:gsub('^@', '')
        return '[https://x.com/' .. handle .. ' @' .. handle .. ']'
    elseif platform == 'farcaster' then
        handle = handle:gsub('^@', '')
        return '[https://warpcast.com/' .. handle .. ' @' .. handle .. ']'
    elseif platform == 'discord' then
        -- If it's an invite link, use it directly
        if handle:match('discord%.gg') or handle:match('discord%.com') then
            return '[' .. handle .. ' Discord]'
        end
        return handle
    end
    return handle
end

--------------------------------------------------------------------------------
-- TRACKING CATEGORIES (disabled)
-- Removed to avoid creating indexed category pages that signal "under construction"
-- to search engines and LLMs. Use manual audits instead.
--------------------------------------------------------------------------------

local function getTrackingCategories(spec)
    return ''
end

--------------------------------------------------------------------------------
-- ROW GENERATORS
--------------------------------------------------------------------------------

function p.row(label, data, options)
    options = options or {}
    if isEmpty(data) then return nil end
    
    local tr = mw.html.create('tr')
    
    tr:tag('th')
        :addClass('infobox-label')
        :attr('scope', 'row')
        :wikitext(label)
    
    local td = tr:tag('td')
        :addClass('infobox-data')
    if options.dataClass then
        td:addClass(options.dataClass)
    end
    if options.itemprop then
        td:attr('itemprop', options.itemprop)
    end
    -- Handle both string data and mw.html nodes
    if type(data) == 'table' and data.allDone then
        td:node(data)
    else
        td:wikitext(tostring(data))
    end
    
    return tr
end

function p.dataRow(data, options)
    options = options or {}
    if isEmpty(data) then return nil end
    
    local tr = mw.html.create('tr')
    local td = tr:tag('td')
        :attr('colspan', '2')
        :addClass('infobox-data-full')
    if options.class then
        td:addClass(options.class)
    end
    -- Handle both string data and mw.html nodes
    if type(data) == 'table' and data.allDone then
        td:node(data)
    else
        td:wikitext(tostring(data))
    end
    
    return tr
end

function p.header(text, options)
    options = options or {}
    if isEmpty(text) then return nil end
    
    local tr = mw.html.create('tr')
    tr:tag('th')
        :attr('colspan', '2')
        :attr('scope', 'colgroup')
        :addClass('infobox-header')
        :wikitext(text)
    
    return tr
end

function p.subheader(text, options)
    options = options or {}
    if isEmpty(text) then return nil end
    
    local tr = mw.html.create('tr')
    tr:tag('th')
        :attr('colspan', '2')
        :attr('scope', 'colgroup')
        :addClass('infobox-subheader')
        :wikitext(text)
    
    return tr
end

function p.image(image, options)
    options = options or {}
    if isEmpty(image) then return nil end
    
    local size = options.size or '250px'
    local caption = options.caption
    local alt = options.alt or ''
    
    local fileLink = '[[File:' .. image .. '|' .. size
    if hasValue(alt) then
        fileLink = fileLink .. '|alt=' .. alt
    end
    fileLink = fileLink .. ']]'
    
    local tr = mw.html.create('tr')
    tr:tag('td')
        :attr('colspan', '2')
        :addClass('infobox-image')
        :wikitext(fileLink)
    
    local result = { tr }
    
    if hasValue(caption) then
        local captionTr = mw.html.create('tr')
        captionTr:tag('td')
            :attr('colspan', '2')
            :addClass('infobox-caption')
            :wikitext(caption)
        table.insert(result, captionTr)
    end
    
    return result
end

function p.title(text, options)
    options = options or {}
    if isEmpty(text) then return nil end
    
    local tr = mw.html.create('tr')
    local th = tr:tag('th')
        :attr('colspan', '2')
        :addClass('infobox-above')
    if options.itemprop then
        th:attr('itemprop', options.itemprop)
    end
    th:wikitext(text)
    
    return tr
end

function p.below(text, options)
    options = options or {}
    if isEmpty(text) then return nil end
    
    local tr = mw.html.create('tr')
    tr:tag('td')
        :attr('colspan', '2')
        :addClass('infobox-below')
        :wikitext(text)
    
    return tr
end

--------------------------------------------------------------------------------
-- MAIN BUILD FUNCTION
--------------------------------------------------------------------------------

function p.build(spec)
    if not spec then return '' end
    
    local rows = {}
    
    -- Helper to add row(s) to the list
    local function addRow(row)
        if row then
            if type(row) == 'table' and row[1] then
                -- Multiple rows returned (e.g., image + caption)
                for _, r in ipairs(row) do
                    table.insert(rows, r)
                end
            else
                table.insert(rows, row)
            end
        end
    end
    
    addRow(p.title(spec.title, { 
        itemprop = spec.schemaType and 'name' or nil
    }))
    
    addRow(p.image(spec.image, {
        size = spec.imageSize or '250px',
        caption = spec.imageCaption,
        alt = spec.imageAlt,
    }))
    
    if spec.rows then
        for _, rowSpec in ipairs(spec.rows) do
            if rowSpec.header then
                addRow(p.header(rowSpec.header))
            elseif rowSpec.subheader then
                addRow(p.subheader(rowSpec.subheader))
            elseif rowSpec.label and rowSpec.data then
                addRow(p.row(rowSpec.label, rowSpec.data, {
                    dataClass = rowSpec.dataClass,
                    itemprop = rowSpec.itemprop
                }))
            elseif rowSpec.data then
                addRow(p.dataRow(rowSpec.data, { class = rowSpec.class }))
            end
        end
    end
    
    addRow(p.below(spec.below))
    
    -- Build the main table
    local tbl = mw.html.create('table')
        :addClass('infobox')
    if hasValue(spec.boxClass) then
        tbl:addClass(spec.boxClass)
    end
    
    if spec.schemaType and SCHEMA_TYPES[spec.schemaType] then
        tbl:attr('itemscope', '')
        tbl:attr('itemtype', SCHEMA_TYPES[spec.schemaType])
    end
    
    for _, row in ipairs(rows) do
        tbl:node(row)
    end
    
    -- Wrap in noexcerpt div
    local wrapper = mw.html.create('div')
        :addClass('noexcerpt')
        :node(tbl)
    
    return tostring(wrapper) .. getTrackingCategories(spec)
end

--------------------------------------------------------------------------------
-- HELPERS
--------------------------------------------------------------------------------

function p.getPageTitle()
    return mw.title.getCurrentTitle().text
end

local function getFrameArgs(frame)
    local args = {}
    local parentArgs = frame:getParent().args
    local directArgs = frame.args
    for k, v in pairs(directArgs) do args[k] = v end
    for k, v in pairs(parentArgs) do args[k] = v end
    return args
end

--------------------------------------------------------------------------------
-- GENERIC INFOBOX
--------------------------------------------------------------------------------

function p.main(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'title') or getArg(args, 'name') or p.getPageTitle(),
        boxClass = getArg(args, 'boxclass'),
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '250px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        below = getArg(args, 'below'),
        templateName = 'generic',
        trackMissingImage = false,
    }
    
    for i = 1, 30 do
        local header = getArg(args, 'header' .. i)
        local subheader = getArg(args, 'subheader' .. i)
        local label = getArg(args, 'label' .. i)
        local data = getArg(args, 'data' .. i)
        
        if hasValue(header) then
            table.insert(spec.rows, { header = header })
        elseif hasValue(subheader) then
            table.insert(spec.rows, { subheader = subheader })
        elseif hasValue(data) then
            if hasValue(label) then
                table.insert(spec.rows, { label = label, data = data })
            else
                table.insert(spec.rows, { data = data })
            end
        end
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- PERSON
--------------------------------------------------------------------------------

function p.person(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-person',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '220px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'person',
        templateName = 'person',
        trackMissingImage = true,
    }
    
    -- Pseudonym/alias row (only if different from name)
    local alias = getArgWithFallback(args, {'alias', 'pseudonym', 'handle'})
    local name = getArg(args, 'name') or p.getPageTitle()
    if hasValue(alias) and alias ~= name then
        table.insert(spec.rows, { label = 'Also known as', data = alias })
    end
    
    if hasValue(args.birth_date) then
        table.insert(spec.rows, { label = 'Born', data = args.birth_date, itemprop = 'birthDate' })
    end
    if hasValue(args.nationality) then
        table.insert(spec.rows, { label = 'Nationality', data = args.nationality, itemprop = 'nationality' })
    end
    if hasValue(args.occupation) then
        table.insert(spec.rows, { label = 'Occupation', data = args.occupation, itemprop = 'jobTitle' })
    end
    if hasValue(args.known_for) then
        table.insert(spec.rows, { label = 'Known for', data = args.known_for })
    end
    if hasValue(args.affiliation) then
        table.insert(spec.rows, { label = 'Affiliation', data = args.affiliation, itemprop = 'affiliation' })
    end
    if hasValue(args.years_active) then
        table.insert(spec.rows, { label = 'Years active', data = args.years_active })
    end
    
    -- Social links
    local socials = {}
    if hasValue(args.twitter) or hasValue(args.x) then
        table.insert(socials, formatSocial('twitter', args.twitter or args.x))
    end
    if hasValue(args.farcaster) then
        table.insert(socials, formatSocial('farcaster', args.farcaster))
    end
    if #socials > 0 then
        table.insert(spec.rows, { label = 'Social', data = table.concat(socials, '<br />') })
    end
    
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website, itemprop = 'url' })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- ORGANIZATION
--------------------------------------------------------------------------------

function p.organization(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-organization',
        image = getArg(args, 'logo') or getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '200px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'organization',
        templateName = 'organization',
        trackMissingImage = false,
    }
    
    if hasValue(args.type) then
        table.insert(spec.rows, { label = 'Type', data = args.type })
    end
    if hasValue(args.founded) then
        table.insert(spec.rows, { label = 'Founded', data = args.founded, itemprop = 'foundingDate' })
    end
    if hasValue(args.founder) then
        table.insert(spec.rows, { label = 'Founder', data = args.founder, itemprop = 'founder' })
    end
    if hasValue(args.headquarters) then
        table.insert(spec.rows, { label = 'Headquarters', data = args.headquarters })
    end
    if hasValue(args.key_people) then
        table.insert(spec.rows, { label = 'Key people', data = args.key_people })
    end
    if hasValue(args.products) then
        table.insert(spec.rows, { label = 'Products', data = args.products })
    end
    if hasValue(args.industry) then
        table.insert(spec.rows, { label = 'Industry', data = args.industry })
    end
    
    -- Social links
    local socials = {}
    if hasValue(args.twitter) or hasValue(args.x) then
        table.insert(socials, formatSocial('twitter', args.twitter or args.x))
    end
    if hasValue(args.farcaster) then
        table.insert(socials, formatSocial('farcaster', args.farcaster))
    end
    if hasValue(args.discord) then
        table.insert(socials, formatSocial('discord', args.discord))
    end
    if #socials > 0 then
        table.insert(spec.rows, { label = 'Social', data = table.concat(socials, '<br />') })
    end
    
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website, itemprop = 'url' })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- NFT COLLECTION
--------------------------------------------------------------------------------

function p.nft(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-nft',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '220px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'nft',
        templateName = 'nft_collection',
        trackMissingImage = true,
    }
    
    -- Basic info
    if hasValue(args.parent_group) then
        table.insert(spec.rows, { label = 'Parent group', data = args.parent_group })
    end
    if hasValue(args.creator) then
        table.insert(spec.rows, { label = 'Creator', data = args.creator, itemprop = 'creator' })
    end
    if hasValue(args.artist) then
        table.insert(spec.rows, { label = 'Artist', data = args.artist, itemprop = 'creator' })
    end
    if hasValue(args.blockchain) then
        table.insert(spec.rows, { label = 'Blockchain', data = args.blockchain })
    end
    if hasValue(args.token_standard) then
        table.insert(spec.rows, { label = 'Token standard', data = args.token_standard })
    end
    if hasValue(args.supply) then
        table.insert(spec.rows, { label = 'Supply', data = args.supply })
    end
    
    -- Dates
    if hasValue(args.launch_date) then
        table.insert(spec.rows, { label = 'Launch date', data = args.launch_date, itemprop = 'datePublished' })
    end
    if hasValue(args.mint_price) then
        table.insert(spec.rows, { label = 'Mint price', data = args.mint_price })
    end
    
    -- Contract address with chain-aware formatting
    if hasValue(args.contract) then
        local chain = args.chain and trim(args.chain):lower() or (args.blockchain and trim(args.blockchain):lower()) or 'ethereum'
        local formatted = formatAddress(args.contract, chain)
        table.insert(spec.rows, { label = 'Contract', data = tostring(formatted) })
    end
    
    -- Marketplace links (compact format)
    local marketplaceHtml = formatMarketplaces(args)
    if marketplaceHtml then
        table.insert(spec.rows, { label = 'Marketplaces', data = tostring(marketplaceHtml) })
    end
    
    -- License
    if hasValue(args.license) then
        table.insert(spec.rows, { label = 'License', data = args.license })
    end
    
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website, itemprop = 'url' })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- CONCEPT
--------------------------------------------------------------------------------

function p.concept(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-concept',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '220px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'concept',
        templateName = 'concept',
        trackMissingImage = false,
    }
    
    if hasValue(args.type) then
        table.insert(spec.rows, { label = 'Type', data = args.type })
    end
    if hasValue(args.coined_by) then
        table.insert(spec.rows, { label = 'Coined by', data = args.coined_by })
    end
    if hasValue(args.coined_date) then
        table.insert(spec.rows, { label = 'Date coined', data = args.coined_date })
    end
    if hasValue(args.origin) then
        table.insert(spec.rows, { label = 'Origin', data = args.origin })
    end
    if hasValue(args.related) then
        table.insert(spec.rows, { label = 'Related concepts', data = args.related })
    end
    if hasValue(args.field) then
        table.insert(spec.rows, { label = 'Field', data = args.field })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- EVENT
--------------------------------------------------------------------------------

function p.event(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-event',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '220px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'event',
        templateName = 'event',
        trackMissingImage = true,
    }
    
    if hasValue(args.type) then
        table.insert(spec.rows, { label = 'Type', data = args.type })
    end
    
    -- Handle date/time flexibility
    if hasValue(args.date) then
        table.insert(spec.rows, { label = 'Date', data = args.date, itemprop = 'startDate' })
    else
        if hasValue(args.start_date) then
            table.insert(spec.rows, { label = 'Start', data = args.start_date, itemprop = 'startDate' })
        end
        if hasValue(args.end_date) then
            table.insert(spec.rows, { label = 'End', data = args.end_date, itemprop = 'endDate' })
        end
    end
    
    if hasValue(args.location) then
        table.insert(spec.rows, { label = 'Location', data = args.location, itemprop = 'location' })
    end
    if hasValue(args.venue) then
        table.insert(spec.rows, { label = 'Venue', data = args.venue })
    end
    if hasValue(args.coordinates) then
        table.insert(spec.rows, { label = 'Coordinates', data = args.coordinates })
    end
    if hasValue(args.organizer) then
        table.insert(spec.rows, { label = 'Organizer', data = args.organizer, itemprop = 'organizer' })
    end
    if hasValue(args.host) then
        table.insert(spec.rows, { label = 'Host', data = args.host })
    end
    if hasValue(args.participants) then
        table.insert(spec.rows, { label = 'Participants', data = args.participants })
    end
    if hasValue(args.attendance) then
        table.insert(spec.rows, { label = 'Attendance', data = args.attendance })
    end
    if hasValue(args.outcome) then
        table.insert(spec.rows, { label = 'Outcome', data = args.outcome })
    end
    if hasValue(args.recording) then
        table.insert(spec.rows, { label = 'Recording', data = args.recording })
    end
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website, itemprop = 'url' })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- EXHIBITION
--------------------------------------------------------------------------------

function p.exhibition(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-exhibition',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '220px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'exhibition',
        templateName = 'exhibition',
        trackMissingImage = true,
    }
    
    if hasValue(args.type) then
        table.insert(spec.rows, { label = 'Type', data = args.type })
    end
    
    if hasValue(args.date) then
        table.insert(spec.rows, { label = 'Date', data = args.date })
    else
        if hasValue(args.opening) then
            table.insert(spec.rows, { label = 'Opening', data = args.opening, itemprop = 'startDate' })
        end
        if hasValue(args.closing) then
            table.insert(spec.rows, { label = 'Closing', data = args.closing, itemprop = 'endDate' })
        end
    end
    
    if hasValue(args.venue) then
        table.insert(spec.rows, { label = 'Venue', data = args.venue })
    end
    if hasValue(args.location) then
        table.insert(spec.rows, { label = 'Location', data = args.location, itemprop = 'location' })
    end
    if hasValue(args.curator) then
        table.insert(spec.rows, { label = 'Curator', data = args.curator })
    end
    if hasValue(args.organizer) then
        table.insert(spec.rows, { label = 'Organizer', data = args.organizer, itemprop = 'organizer' })
    end
    if hasValue(args.artists) then
        table.insert(spec.rows, { label = 'Artists', data = args.artists })
    end
    if hasValue(args.works) then
        table.insert(spec.rows, { label = 'Featured works', data = args.works })
    end
    if hasValue(args.attendance) then
        table.insert(spec.rows, { label = 'Attendance', data = args.attendance })
    end
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website, itemprop = 'url' })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- ARTWORK
--------------------------------------------------------------------------------

function p.artwork(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'title') or p.getPageTitle(),
        boxClass = 'infobox-artwork',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '220px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'artwork',
        templateName = 'artwork',
        trackMissingImage = true,
    }
    
    if hasValue(args.artist) then
        table.insert(spec.rows, { label = 'Artist', data = args.artist, itemprop = 'creator' })
    end
    if hasValue(args.year) then
        table.insert(spec.rows, { label = 'Year', data = args.year, itemprop = 'dateCreated' })
    end
    if hasValue(args.medium) then
        table.insert(spec.rows, { label = 'Medium', data = args.medium, itemprop = 'artMedium' })
    end
    if hasValue(args.dimensions) then
        table.insert(spec.rows, { label = 'Dimensions', data = args.dimensions })
    end
    if hasValue(args.token_id) then
        table.insert(spec.rows, { label = 'Token ID', data = args.token_id })
    end
    if hasValue(args.contract) then
        local chain = args.chain and trim(args.chain):lower() or 'ethereum'
        local formatted = formatAddress(args.contract, chain)
        table.insert(spec.rows, { label = 'Contract', data = tostring(formatted) })
    end
    if hasValue(args.sale_price) then
        table.insert(spec.rows, { label = 'Sale price', data = args.sale_price })
    end
    if hasValue(args.collection) then
        table.insert(spec.rows, { label = 'Collection', data = args.collection })
    end
    if hasValue(args.location) then
        table.insert(spec.rows, { label = 'Location', data = args.location })
    end
    if hasValue(args.exhibition) then
        table.insert(spec.rows, { label = 'Exhibition', data = args.exhibition })
    end
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website, itemprop = 'url' })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- WEBSITE
--------------------------------------------------------------------------------

function p.website(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = 'infobox-website',
        image = getArg(args, 'logo') or getArg(args, 'screenshot'),
        imageSize = getArg(args, 'image_size') or '200px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        schemaType = 'website',
        templateName = 'website',
        trackMissingImage = false,
    }
    
    if hasValue(args.logo) and hasValue(args.screenshot) then
        table.insert(spec.rows, { data = '[[File:' .. args.screenshot .. '|250px]]' })
    end
    
    if hasValue(args.type) then
        table.insert(spec.rows, { label = 'Type', data = args.type })
    end
    if hasValue(args.owner) then
        table.insert(spec.rows, { label = 'Owner', data = args.owner })
    end
    if hasValue(args.author) then
        table.insert(spec.rows, { label = 'Author', data = args.author, itemprop = 'author' })
    end
    if hasValue(args.url) then
        table.insert(spec.rows, { label = 'URL', data = args.url, itemprop = 'url' })
    end
    if hasValue(args.launch_date) then
        table.insert(spec.rows, { label = 'Launched', data = args.launch_date, itemprop = 'dateCreated' })
    end
    if hasValue(args.current_status) then
        table.insert(spec.rows, { label = 'Status', data = args.current_status })
    end
    if hasValue(args.language) then
        table.insert(spec.rows, { label = 'Language', data = args.language })
    end
    if hasValue(args.registration) then
        table.insert(spec.rows, { label = 'Registration', data = args.registration })
    end
    if hasValue(args.content_license) then
        table.insert(spec.rows, { label = 'License', data = args.content_license })
    end
    if hasValue(args.archive) then
        table.insert(spec.rows, { label = 'Archive', data = args.archive })
    end
    if hasValue(args.predecessor) then
        table.insert(spec.rows, { label = 'Predecessor', data = args.predecessor })
    end
    if hasValue(args.successor) then
        table.insert(spec.rows, { label = 'Successor', data = args.successor })
    end
    
    return p.build(spec)
end

--------------------------------------------------------------------------------
-- SUBJECT (generic fallback)
--------------------------------------------------------------------------------

function p.subject(frame)
    local args = getFrameArgs(frame)
    
    local spec = {
        title = getArg(args, 'name') or p.getPageTitle(),
        boxClass = '',
        image = getArg(args, 'image'),
        imageSize = getArg(args, 'image_size') or '250px',
        imageCaption = getArg(args, 'caption'),
        imageAlt = getArg(args, 'alt'),
        rows = {},
        templateName = 'subject',
        trackMissingImage = false,
    }
    
    if hasValue(args.type) then
        table.insert(spec.rows, { label = 'Type', data = args.type })
    end
    if hasValue(args.date) then
        table.insert(spec.rows, { label = 'Date', data = args.date })
    end
    if hasValue(args.location) then
        table.insert(spec.rows, { label = 'Location', data = args.location })
    end
    if hasValue(args.status) then
        table.insert(spec.rows, { label = 'Status', data = args.status })
    end
    if hasValue(args.related) then
        table.insert(spec.rows, { label = 'Related', data = args.related })
    end
    
    for i = 1, 10 do
        local label = getArg(args, 'label' .. i)
        local data = getArg(args, 'data' .. i)
        if hasValue(label) and hasValue(data) then
            table.insert(spec.rows, { label = label, data = data })
        end
    end
    
    if hasValue(args.website) then
        table.insert(spec.rows, { label = 'Website', data = args.website })
    end
    
    return p.build(spec)
end

-- Aliases for backward compatibility
p.nft_collection = p.nft

return p