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

local p = {}

local MAX_LAYERS = 32
local DEFAULT_SIZE = 200
local MAX_SIZE = 4096
local FILE_NAMESPACE = 6

local ALLOWED_NAMED_ARGS = {
    alt = true,
    class = true,
    link = true,
    link_layer = true,
    link_mode = true,
    preview = true,
    size = true,
}

local function trim(value)
    if value == nil then
        return ''
    end
    return mw.text.trim(tostring(value))
end

local function errorMarkup(message)
    return tostring(
        mw.html.create('strong')
            :addClass('error')
            :wikitext('Layered image: ' .. mw.text.nowiki(message))
    )
end

local function parseLayerKey(key)
    local number = tonumber(key)
    if number and number >= 1 and number == math.floor(number) then
        return number
    end
    return nil
end

local function collectLayers(args)
    local layers = {}
    local highest = 0

    for key, value in pairs(args) do
        local index = parseLayerKey(key)
        if index then
            local image = trim(value)
            if image ~= '' then
                if index > MAX_LAYERS then
                    return nil, string.format(
                        'layer %d exceeds the supported maximum of %d',
                        index,
                        MAX_LAYERS
                    )
                end
                layers[index] = image
                highest = math.max(highest, index)
            end
        elseif not ALLOWED_NAMED_ARGS[tostring(key)] then
            return nil, string.format("unknown parameter '%s'", tostring(key))
        end
    end

    if highest == 0 then
        return nil, 'at least one numbered image layer is required'
    end
    for index = 1, highest do
        if not layers[index] then
            return nil, string.format('layer %d is missing', index)
        end
    end
    return layers
end

local function normalizeFileName(value, index)
    local title = mw.title.new(value, FILE_NAMESPACE)
    if not title or title.namespace ~= FILE_NAMESPACE or title.interwiki ~= '' or title.fragment ~= '' then
        return nil, string.format("layer %d is not a valid local file title: '%s'", index, value)
    end
    return title.text
end

local function normalizeSize(value)
    local raw = trim(value)
    if raw == '' then
        return DEFAULT_SIZE .. 'px'
    end
    local number = tonumber(raw:match('^(%d+)px$') or raw:match('^(%d+)$'))
    if not number or number < 1 or number > MAX_SIZE then
        return nil, string.format('size must be between 1px and %dpx', MAX_SIZE)
    end
    return number .. 'px'
end

local function normalizeClasses(value)
    local classes = {}
    for class in trim(value):gmatch('%S+') do
        if not class:match('^[%w_-]+$') then
            return nil, string.format("invalid CSS class '%s'", class)
        end
        classes[#classes + 1] = class
    end
    return classes
end

local function normalizeLink(args, layerCount)
    local link = trim(args.link)
    local mode = trim(args.link_mode):lower()
    if link ~= '' then
        if mode ~= '' and mode ~= 'page' then
            return nil, nil, nil, 'link_mode must be page when link is supplied'
        end
        local title = mw.title.new(link)
        if not title or title.interwiki ~= '' then
            return nil, nil, nil, string.format("link is not a valid local page title: '%s'", link)
        end
        mode = 'page'
        link = title.fullText
    elseif mode == '' or mode == 'none' then
        mode = 'none'
        link = nil
    elseif mode == 'file' then
        link = nil
    elseif mode == 'page' then
        return nil, nil, nil, 'link_mode=page requires link'
    else
        return nil, nil, nil, "link_mode must be 'none', 'file', or 'page'"
    end

    local rawLayer = trim(args.link_layer)
    local linkLayer = rawLayer == '' and layerCount or tonumber(rawLayer)
    if not linkLayer or linkLayer ~= math.floor(linkLayer) or linkLayer < 1 or linkLayer > layerCount then
        return nil, nil, nil, string.format('link_layer must be between 1 and %d', layerCount)
    end
    return mode, link, linkLayer
end

local function previewEnabled(value)
    local normalized = trim(value):lower()
    if normalized == '' or normalized == '0' or normalized == 'false' or normalized == 'no' then
        return false
    end
    if normalized == '1' or normalized == 'true' or normalized == 'yes' then
        return true
    end
    return nil
end

function p.render(frame)
    local parent = frame:getParent()
    local args = parent and parent.args or frame.args
    local layers, layerError = collectLayers(args)
    if not layers then
        return errorMarkup(layerError)
    end

    for index, value in ipairs(layers) do
        local fileName, fileError = normalizeFileName(value, index)
        if not fileName then
            return errorMarkup(fileError)
        end
        layers[index] = fileName
    end

    local size, sizeError = normalizeSize(args.size)
    if not size then
        return errorMarkup(sizeError)
    end
    local classes, classError = normalizeClasses(args.class)
    if not classes then
        return errorMarkup(classError)
    end
    local linkMode, link, linkLayer, linkError = normalizeLink(args, #layers)
    if not linkMode then
        return errorMarkup(linkError)
    end

    local alt = trim(args.alt)
    local wantsPreview = previewEnabled(args.preview)
    if wantsPreview == nil then
        return errorMarkup("preview must be '1', 'true', 'yes', '0', 'false', or 'no'")
    end
    return frame:callParserFunction{
        name = '#layeredimage',
        args = {
            mw.text.jsonEncode{
                layers = layers,
                width = tonumber(size:match('^(%d+)px$')),
                alt = alt,
                linkMode = linkMode,
                link = link,
                linkLayer = linkLayer,
                classes = classes,
                preview = wantsPreview,
            },
        },
    }
end

return p