LSFMBaleStackVisuals = {}

LSFMBaleStackVisuals.MOD_NAME = g_currentModName or ""
LSFMBaleStackVisuals.SPEC_NAME = string.format("%s.lsfmBaleStackVisuals", LSFMBaleStackVisuals.MOD_NAME)
LSFMBaleStackVisuals.SPEC = string.format("spec_%s", LSFMBaleStackVisuals.SPEC_NAME)
LSFMBaleStackVisuals.ERROR_STACK_TYPE_MISMATCH = 6
LSFMBaleStackVisuals.WARNING_STACK_TYPE_MISMATCH_L10N = "warning_lsfmBaleStorageStackTypeMismatch"

local LSFMBaleStackVisuals_mt = Class(LSFMBaleStackVisuals)
local specEntryName = LSFMBaleStackVisuals.SPEC

local BALE_SIZE_EPSILON = 0.08

LSFMBaleStackVisuals.DEFAULT_STACK_TYPES = {
    { name = "squarebale120", linkNodes = "0", isRoundbale = false, width = 0.45, height = 0.35, length = 1.2, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "smallSquareBale102", layout = "squareGrid", rows = 2, layers = 7, stackLength = 12.72, isRoundbale = false, width = 0.53, height = 0.35, length = 1.02, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "squarebale180", linkNodes = "1", isRoundbale = false, width = 1.2, height = 0.9, length = 1.8, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "squarebale220", linkNodes = "2", isRoundbale = false, width = 1.2, height = 0.9, length = 2.2, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "squarebale240", linkNodes = "3", isRoundbale = false, width = 1.2, height = 0.9, length = 2.4, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "smallRoundBale085", layout = "roundPyramid", rows = 3, layers = 5, stackLength = 12.75, spacingDiameter = 0.79, isRoundbale = true, width = 0.74, diameter = 0.85, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "roundbale125", linkNodes = "4", isRoundbale = true, width = 1.2, diameter = 1.25, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "roundbale150", linkNodes = "5", isRoundbale = true, width = 1.2, diameter = 1.5, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "roundbale180", linkNodes = "6", isRoundbale = true, width = 1.2, diameter = 1.8, excludedFillTypes = "COTTON WOODCHIPS" },
    { name = "biomassBale125", linkNodes = "4", isRoundbale = true, width = 1.2, diameter = 1.25, fillTypes = "WOODCHIPS" }
}

if PlaceableObjectStorageErrorEvent ~= nil and not PlaceableObjectStorageErrorEvent.lsfmBaleStackVisualsInstalled then
    PlaceableObjectStorageErrorEvent.ERROR_LSFM_BALE_STACK_TYPE_MISMATCH = LSFMBaleStackVisuals.ERROR_STACK_TYPE_MISMATCH

    local superRunPlaceableObjectStorageErrorEvent = PlaceableObjectStorageErrorEvent.run

    function PlaceableObjectStorageErrorEvent:run(connection)
        if self.placeable ~= nil and self.placeable:getIsSynchronized() and g_currentMission:getFarmId() == self.placeable:getOwnerFarmId() then
            local x1, _, z1 = g_currentMission:getClientPosition()
            local x2, _, z2 = getWorldTranslation(self.placeable.rootNode)

            if MathUtil.vector2Length(x1 - x2, z1 - z2) < PlaceableObjectStorageErrorEvent.SHOW_WARNING_DISTANCE then
                if self.errorId == PlaceableObjectStorageErrorEvent.ERROR_LSFM_BALE_STACK_TYPE_MISMATCH then
                    local text = "Nicht passende Ballengroesse zu den eingelagerten Ballen"

                    if g_i18n:hasText(LSFMBaleStackVisuals.WARNING_STACK_TYPE_MISMATCH_L10N, LSFMBaleStackVisuals.MOD_NAME) then
                        text = g_i18n:getText(LSFMBaleStackVisuals.WARNING_STACK_TYPE_MISMATCH_L10N, LSFMBaleStackVisuals.MOD_NAME)
                    elseif g_i18n:hasText(LSFMBaleStackVisuals.WARNING_STACK_TYPE_MISMATCH_L10N) then
                        text = g_i18n:getText(LSFMBaleStackVisuals.WARNING_STACK_TYPE_MISMATCH_L10N)
                    end

                    g_currentMission:showBlinkingWarning(text, 3500)

                    return
                end
            end
        end

        superRunPlaceableObjectStorageErrorEvent(self, connection)
    end

    PlaceableObjectStorageErrorEvent.lsfmBaleStackVisualsInstalled = true
end

local function getDimensionMatches(valueA, valueB)
    if valueA == nil or valueB == nil then
        return false
    end

    return math.abs(MathUtil.round(valueA, 2) - MathUtil.round(valueB, 2)) <= BALE_SIZE_EPSILON
end

local function getDimensionDifference(valueA, valueB)
    if valueA == nil or valueB == nil then
        return math.huge
    end

    return math.abs(MathUtil.round(valueA, 2) - MathUtil.round(valueB, 2))
end

local function copyShaderParameterRecursive(targetNode, sourceNode, parameterName)
    if getHasClassId(targetNode, ClassIds.SHAPE) and getHasClassId(sourceNode, ClassIds.SHAPE) and getHasShaderParameter(targetNode, parameterName) and getHasShaderParameter(sourceNode, parameterName) then
        local x, y, z, w = getShaderParameter(sourceNode, parameterName)
        setShaderParameter(targetNode, parameterName, x, y, z, w, false)
    end

    for i = 0, math.min(getNumOfChildren(targetNode), getNumOfChildren(sourceNode)) - 1 do
        copyShaderParameterRecursive(getChildAt(targetNode, i), getChildAt(sourceNode, i), parameterName)
    end
end

local function enableStaticCollision(node, playerOnly)
    if node == nil or node == 0 then
        return
    end

    if not getHasClassId(node, ClassIds.SHAPE) then
        return
    end

    setRigidBodyType(node, RigidBodyType.KINEMATIC)

    if playerOnly and CollisionFlag ~= nil and CollisionFlag.DYNAMIC_OBJECT ~= nil and CollisionFlag.PLAYER ~= nil then
        setCollisionFilterGroup(node, CollisionFlag.DYNAMIC_OBJECT)
        setCollisionFilterMask(node, CollisionFlag.PLAYER)
    elseif CollisionPreset ~= nil and CollisionPreset.BALE ~= nil then
        setCollisionFilterGroup(node, CollisionPreset.BALE.group)
        setCollisionFilterMask(node, CollisionPreset.BALE.mask)
    elseif CollisionFlag ~= nil and CollisionFlag.DYNAMIC_OBJECT ~= nil then
        setCollisionFilterGroup(node, CollisionFlag.DYNAMIC_OBJECT)
        setCollisionFilterMask(node, CollisionFlag.PLAYER or 0)
    end
end

local function getBaleDataFromAbstractObject(abstractObject)
    if abstractObject == nil then
        return nil
    end

    if abstractObject.baleObject ~= nil then
        local bale = abstractObject.baleObject

        return {
            xmlFilename = bale.xmlFilename,
            fillTypeIndex = bale:getFillType(),
            wrappingState = bale.wrappingState or 0,
            wrappingColor = bale.wrappingColor,
            variationIndex = bale.variationIndex or 1
        }
    end

    local attributes = abstractObject.baleAttributes

    if attributes ~= nil then
        return {
            xmlFilename = attributes.xmlFilename,
            fillTypeIndex = attributes.fillType,
            wrappingState = attributes.wrappingState or 0,
            wrappingColor = attributes.wrappingColor,
            variationIndex = attributes.variationIndex or 1
        }
    end

    return nil
end

local function sanitizeBaleAttributes(attributes)
    if attributes == nil then
        return
    end

    attributes.wrappingState = attributes.wrappingState or 0
    attributes.wrappingColor = attributes.wrappingColor or { 1, 1, 1 }
    attributes.variationIndex = math.max(math.floor(attributes.variationIndex or 1), 1)
end

local function sanitizeAbstractBaleObject(abstractObject)
    if abstractObject ~= nil and abstractObject.baleAttributes ~= nil then
        sanitizeBaleAttributes(abstractObject.baleAttributes)
    end
end

local function sanitizeStoredBaleAttributes(self)
    local objectStorageSpec = self.spec_objectStorage

    if objectStorageSpec == nil or objectStorageSpec.storedObjects == nil then
        return
    end

    for _, storedObject in ipairs(objectStorageSpec.storedObjects) do
        sanitizeAbstractBaleObject(storedObject)
    end
end

local function getBaleFillTypeIndex(object)
    if object ~= nil and object.getFillType ~= nil then
        return object:getFillType()
    end

    return nil
end

local function getIsWrappedGrassBale(fillTypeIndex, wrappingState)
    if FillType == nil or (wrappingState or 0) < 1 then
        return false
    end

    return fillTypeIndex == FillType.GRASS or fillTypeIndex == FillType.GRASS_WINDROW
end

local function getPurchaseWrappingState(fillTypeIndex)
    if FillType == nil then
        return 0
    end

    if fillTypeIndex == FillType.SILAGE or fillTypeIndex == FillType.GRASS or fillTypeIndex == FillType.GRASS_WINDROW then
        return 1
    end

    return 0
end

local function getIsFillTypeExcluded(spec, fillTypeIndex, wrappingState)
    if spec == nil or spec.excludedFillTypes == nil or spec.excludedFillTypes[fillTypeIndex] ~= true then
        return false
    end

    return not getIsWrappedGrassBale(fillTypeIndex, wrappingState)
end

local function getFillTypeLookup(fillTypeNames)
    if fillTypeNames == nil then
        return nil
    end

    local fillTypes = {}

    for _, fillTypeName in ipairs(string.split(fillTypeNames, " ")) do
        local fillTypeIndex = g_fillTypeManager:getFillTypeIndexByName(fillTypeName)

        if fillTypeIndex ~= nil then
            fillTypes[fillTypeIndex] = true
        end
    end

    return fillTypes
end

local function getFillTypeNames(fillTypeLookup)
    local names = {}

    if fillTypeLookup ~= nil then
        for fillTypeIndex, isAllowed in pairs(fillTypeLookup) do
            if isAllowed then
                names[#names + 1] = fillTypeIndex
            end
        end
    end

    table.sort(names)

    return names
end

local function getXMLValueWithFallback(xmlFile, primaryPath, fallbackPath, defaultValue, components, i3dMappings)
    if xmlFile:hasProperty(primaryPath) then
        return xmlFile:getValue(primaryPath, defaultValue, components, i3dMappings)
    end

    return xmlFile:getValue(fallbackPath, defaultValue, components, i3dMappings)
end

local function addDefaultStackTypes(spec)
    for _, defaultStackType in ipairs(LSFMBaleStackVisuals.DEFAULT_STACK_TYPES) do
        table.insert(spec.stackTypes, {
            name = defaultStackType.name,
            linkNodes = defaultStackType.linkNodes,
            layout = defaultStackType.layout,
            rows = defaultStackType.rows,
            layers = defaultStackType.layers,
            stackLength = defaultStackType.stackLength,
            spacingDiameter = defaultStackType.spacingDiameter,
            isRoundbale = defaultStackType.isRoundbale,
            width = MathUtil.round(defaultStackType.width or 0, 2),
            height = MathUtil.round(defaultStackType.height or 0, 2),
            length = MathUtil.round(defaultStackType.length or 0, 2),
            diameter = MathUtil.round(defaultStackType.diameter or 0, 2),
            fillTypes = getFillTypeLookup(defaultStackType.fillTypes),
            excludedFillTypes = getFillTypeLookup(defaultStackType.excludedFillTypes)
        })
    end
end

local function getSpec(self)
    return self[specEntryName] or self.spec_lsfmBaleStackVisuals
end

local function getStackTypeByName(spec, name)
    if spec == nil or name == nil then
        return nil
    end

    for _, stackType in ipairs(spec.stackTypes) do
        if stackType.name == name then
            return stackType
        end
    end

    return nil
end

local function indexToObjectWithFallback(rootNode, index)
    if rootNode == nil or rootNode == 0 or index == nil then
        return nil
    end

    local node = I3DUtil.indexToObject(rootNode, index)

    if node == nil and index:find("|", 1, true) == nil then
        node = I3DUtil.indexToObject(rootNode, "0|" .. index)
    end

    if node == nil and index:sub(1, 2) == "0|" then
        node = I3DUtil.indexToObject(rootNode, index:sub(3))
    end

    return node
end

local function getStoredBaleDataList(self)
    local objectStorageSpec = self.spec_objectStorage
    local storedObjects = objectStorageSpec ~= nil and objectStorageSpec.storedObjects or nil
    local baleDataList = {}

    if storedObjects == nil then
        return baleDataList
    end

    for _, storedObject in ipairs(storedObjects) do
        local baleData = getBaleDataFromAbstractObject(storedObject)

        if baleData ~= nil then
            table.insert(baleDataList, baleData)
        end
    end

    return baleDataList
end

function LSFMBaleStackVisuals.prerequisitesPresent(specializations)
    return SpecializationUtil.hasSpecialization(PlaceableObjectStorage, specializations)
end

function LSFMBaleStackVisuals.registerXMLPaths(schema, basePath)
    schema:setXMLSpecializationType("LSFMBaleStackVisuals")

    schema:register(XMLValueType.NODE_INDEX, basePath .. ".lsfmBaleStackVisuals.linkNodes#linkNode", "Node the LSFM bale stack visuals are linked to")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.linkNodes#filename", "I3D with stack position link nodes")
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.rules#singleBaleType", "Only allow one bale type/fill type per storage", false)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.rules#excludedFillTypes", "Fill types that cannot be stored in this bale storage")
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.placementPreview#enabled", "Show a full temporary bale stack while placing this placeable", false)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.placementPreview#stackType", "Stack type used for the placement preview", "roundbale125")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.placementPreview#fillType", "Fill type used for the placement preview bale visuals", "STRAW")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.placementPreview#baleFilename", "Optional explicit bale XML used for the placement preview")
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.placementPreview#variationIndex", "Bale variation used for the placement preview", 1)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.placementPreview#wrappingState", "Bale wrapping state used for the placement preview", 0)
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.placementPreview#showCover", "Show the matching cover during placement preview", true)
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.visualCollisions#enabled", "Enable collisions on visible stored bales", false)
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.visualCollisions#maxBales", "Maximum visible bales with collision. 0 means unlimited", 0)
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.visualCollisions#playerOnly", "Only collide with players so storage loading remains unobstructed", true)
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.visualCollisions#placementPreview", "Enable collisions on placement preview bales", false)
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.placementPreview.frontMarker#enabled", "Show a temporary front marker during placement preview", true)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.placementPreview.frontMarker#filename", "I3D used as front marker during placement preview")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.placementPreview.frontMarker#node", "Front marker node index path inside the marker I3D")
    schema:register(XMLValueType.VECTOR_TRANS, basePath .. ".lsfmBaleStackVisuals.placementPreview.frontMarker#translation", "Front marker translation offset from the preview root", "4.8 0 0")
    schema:register(XMLValueType.VECTOR_ROT, basePath .. ".lsfmBaleStackVisuals.placementPreview.frontMarker#rotation", "Front marker rotation offset from the preview root", "0 0 0")
    schema:register(XMLValueType.VECTOR_SCALE, basePath .. ".lsfmBaleStackVisuals.placementPreview.frontMarker#scale", "Front marker scale", "1 1 1")
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.purchase#enabled", "Allow buying supported bales from the storage dialog", false)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.purchase#fillTypes", "Fill types that can be bought through the storage dialog")
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.purchase#maxQuantity", "Maximum bales bought in one dialog action, 0 uses all free storage slots", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.purchase#priceScale", "Price multiplier for bought bales", 1.25)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.covers#filename", "I3D cover library")
    schema:register(XMLValueType.NODE_INDEX, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#linkNode", "Node the cover visual is linked to")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#filename", "I3D cover template shown when this stack type is full")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#node", "Cover node index path inside the cover I3D")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#nodeId", "Legacy alias for cover node index path inside the cover I3D")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#stackType", "Stack type name that can show this cover")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#excludedFillTypes", "Fill types that prevent this cover from being shown")
    schema:register(XMLValueType.VECTOR_TRANS, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#translation", "Cover translation offset from cover link node", "0 0 0")
    schema:register(XMLValueType.VECTOR_ROT, basePath .. ".lsfmBaleStackVisuals.covers.cover(?)#rotation", "Cover rotation offset from cover link node", "0 0 0")

    schema:register(XMLValueType.NODE_INDEX, basePath .. ".lsfmBaleStackVisuals.cover#linkNode", "Legacy node the cover visual is linked to")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.cover#filename", "Legacy I3D cover template")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.cover#node", "Legacy cover node index path inside the cover I3D")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.cover#nodeId", "Legacy alias for cover node index path inside the cover I3D")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.cover#stackType", "Legacy cover stack type", "roundbale125")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.cover#excludedFillTypes", "Legacy cover fill type exclusions")
    schema:register(XMLValueType.VECTOR_TRANS, basePath .. ".lsfmBaleStackVisuals.cover#translation", "Legacy cover translation", "0 0 0")
    schema:register(XMLValueType.VECTOR_ROT, basePath .. ".lsfmBaleStackVisuals.cover#rotation", "Legacy cover rotation", "0 0 0")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#name", "Stack type name")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#linkNodes", "Link node path in linkNodesFilename")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#layout", "Generated layout type: squareGrid or roundPyramid")
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#rows", "Generated stack depth rows", 1)
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#layers", "Generated stack vertical layers", 1)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#stackLength", "Generated stack usable length", 12.5)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#spacingDiameter", "Visual center spacing for generated round bale stacks; bale diameter is still used for type matching")
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#isRoundbale", "Stack type is for round bales", true)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#width", "Bale width", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#height", "Bale height", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#length", "Bale length", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#diameter", "Bale diameter", 0)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#fillTypes", "Only use this stack type for these fill types")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackTypes.stackType(?)#excludedFillTypes", "Do not use this stack type for these fill types")

    schema:register(XMLValueType.NODE_INDEX, basePath .. ".lsfmBaleStackVisuals#linkNode", "Node the LSFM bale stack visuals are linked to")
    schema:register(XMLValueType.NODE_INDEX, basePath .. ".lsfmBaleStackVisuals#coverLinkNode", "Node the cover visual is linked to")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#linkNodesFilename", "I3D with stack position link nodes")
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals#singleBaleType", "Only allow one bale type/fill type per storage", false)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#excludedFillTypes", "Legacy fill types that cannot be stored in this bale storage")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#coverFilename", "I3D cover template shown when the configured stack is full")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#coverNode", "Cover node index path inside the cover I3D")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#coverNodeId", "Legacy alias for cover node index path inside the cover I3D")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#coverStackType", "Stack type name that can show the cover", "roundbale125")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals#coverExcludedFillTypes", "Fill types that prevent the cover from being shown")
    schema:register(XMLValueType.VECTOR_TRANS, basePath .. ".lsfmBaleStackVisuals#coverTranslation", "Cover translation offset from cover link node", "0 0 0")
    schema:register(XMLValueType.VECTOR_ROT, basePath .. ".lsfmBaleStackVisuals#coverRotation", "Cover rotation offset from cover link node", "0 0 0")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackType(?)#name", "Stack type name")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackType(?)#linkNodes", "Link node path in linkNodesFilename")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackType(?)#layout", "Generated layout type: squareGrid or roundPyramid")
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#rows", "Generated stack depth rows", 1)
    schema:register(XMLValueType.INT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#layers", "Generated stack vertical layers", 1)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#stackLength", "Generated stack usable length", 12.5)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#spacingDiameter", "Visual center spacing for generated round bale stacks; bale diameter is still used for type matching")
    schema:register(XMLValueType.BOOL, basePath .. ".lsfmBaleStackVisuals.stackType(?)#isRoundbale", "Stack type is for round bales", true)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#width", "Bale width", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#height", "Bale height", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#length", "Bale length", 0)
    schema:register(XMLValueType.FLOAT, basePath .. ".lsfmBaleStackVisuals.stackType(?)#diameter", "Bale diameter", 0)
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackType(?)#fillTypes", "Only use this stack type for these fill types")
    schema:register(XMLValueType.STRING, basePath .. ".lsfmBaleStackVisuals.stackType(?)#excludedFillTypes", "Do not use this stack type for these fill types")

    schema:setXMLSpecializationType()
end

function LSFMBaleStackVisuals.registerOverwrittenFunctions(placeableType)
    SpecializationUtil.registerOverwrittenFunction(placeableType, "getObjectStorageCanStoreObject", LSFMBaleStackVisuals.getObjectStorageCanStoreObject)
    SpecializationUtil.registerOverwrittenFunction(placeableType, "removeAbstractObjectFromStorage", LSFMBaleStackVisuals.removeAbstractObjectFromStorage)
    SpecializationUtil.registerOverwrittenFunction(placeableType, "updateObjectStorageObjectInfos", LSFMBaleStackVisuals.updateObjectStorageObjectInfos)
    SpecializationUtil.registerOverwrittenFunction(placeableType, "updateObjectStorageVisualAreas", LSFMBaleStackVisuals.updateObjectStorageVisualAreas)
end

function LSFMBaleStackVisuals.registerFunctions(placeableType)
    SpecializationUtil.registerFunction(placeableType, "getLSFMBalePurchaseCatalog", LSFMBaleStackVisuals.getLSFMBalePurchaseCatalog)
    SpecializationUtil.registerFunction(placeableType, "getLSFMBalePurchaseValidation", LSFMBaleStackVisuals.getLSFMBalePurchaseValidation)
    SpecializationUtil.registerFunction(placeableType, "getLSFMBalePurchasePrice", LSFMBaleStackVisuals.getLSFMBalePurchasePrice)
    SpecializationUtil.registerFunction(placeableType, "buyLSFMBales", LSFMBaleStackVisuals.buyLSFMBales)
end

function LSFMBaleStackVisuals.registerEventListeners(placeableType)
    SpecializationUtil.registerEventListener(placeableType, "onLoad", LSFMBaleStackVisuals)
    SpecializationUtil.registerEventListener(placeableType, "onPreFinalizePlacement", LSFMBaleStackVisuals)
    SpecializationUtil.registerEventListener(placeableType, "onDelete", LSFMBaleStackVisuals)
end

function LSFMBaleStackVisuals:onLoad(savegame)
    local spec = getSpec(self)
    local basePath = "placeable.lsfmBaleStackVisuals"
    local linkNodesFilename = getXMLValueWithFallback(self.xmlFile, basePath .. ".linkNodes#filename", basePath .. "#linkNodesFilename")

    spec.linkNode = getXMLValueWithFallback(self.xmlFile, basePath .. ".linkNodes#linkNode", basePath .. "#linkNode", nil, self.components, self.i3dMappings) or self.rootNode
    spec.linkNodesFilename = linkNodesFilename ~= nil and Utils.getFilename(linkNodesFilename, self.baseDirectory) or nil
    spec.singleBaleType = getXMLValueWithFallback(self.xmlFile, basePath .. ".rules#singleBaleType", basePath .. "#singleBaleType", false)
    spec.excludedFillTypes = getFillTypeLookup(getXMLValueWithFallback(self.xmlFile, basePath .. ".rules#excludedFillTypes", basePath .. "#excludedFillTypes", "GRASS_WINDROW GRASS"))
    spec.covers = {}
    spec.coverSharedLoadRequestIds = {}
    spec.stackTypes = {}
    spec.stackPositions = {}
    spec.visualRoot = nil
    spec.placementPreviewActive = false
    spec.lastWarningText = nil
    spec.purchase = {
        enabled = self.xmlFile:getValue(basePath .. ".purchase#enabled", false),
        fillTypes = getFillTypeLookup(self.xmlFile:getValue(basePath .. ".purchase#fillTypes", "STRAW DRYGRASS_WINDROW SILAGE")),
        maxQuantity = math.max(self.xmlFile:getValue(basePath .. ".purchase#maxQuantity", 0), 0),
        priceScale = math.max(self.xmlFile:getValue(basePath .. ".purchase#priceScale", 1.25), 0)
    }
    spec.visualCollisions = {
        enabled = self.xmlFile:getValue(basePath .. ".visualCollisions#enabled", false),
        maxBales = math.max(self.xmlFile:getValue(basePath .. ".visualCollisions#maxBales", 0), 0),
        playerOnly = self.xmlFile:getValue(basePath .. ".visualCollisions#playerOnly", true),
        placementPreview = self.xmlFile:getValue(basePath .. ".visualCollisions#placementPreview", false)
    }
    spec.placementPreview = {
        enabled = self.xmlFile:getValue(basePath .. ".placementPreview#enabled", false),
        stackType = self.xmlFile:getValue(basePath .. ".placementPreview#stackType", "roundbale125"),
        fillTypeName = self.xmlFile:getValue(basePath .. ".placementPreview#fillType", "STRAW"),
        baleFilename = self.xmlFile:getValue(basePath .. ".placementPreview#baleFilename"),
        variationIndex = self.xmlFile:getValue(basePath .. ".placementPreview#variationIndex", 1),
        wrappingState = self.xmlFile:getValue(basePath .. ".placementPreview#wrappingState", 0),
        showCover = self.xmlFile:getValue(basePath .. ".placementPreview#showCover", true)
    }

    if spec.placementPreview.baleFilename ~= nil then
        spec.placementPreview.baleFilename = Utils.getFilename(spec.placementPreview.baleFilename, self.baseDirectory)
    end

    local markerKey = basePath .. ".placementPreview.frontMarker"
    local markerFilename = self.xmlFile:getValue(markerKey .. "#filename")
    local markerTranslationX, markerTranslationY, markerTranslationZ = self.xmlFile:getValue(markerKey .. "#translation", "4.8 0 0")
    local markerRotationX, markerRotationY, markerRotationZ = self.xmlFile:getValue(markerKey .. "#rotation", "0 0 0")
    local markerScaleX, markerScaleY, markerScaleZ = self.xmlFile:getValue(markerKey .. "#scale", "1 1 1")

    spec.placementPreview.frontMarker = {
        enabled = self.xmlFile:getValue(markerKey .. "#enabled", true),
        filename = markerFilename ~= nil and Utils.getFilename(markerFilename, self.baseDirectory) or nil,
        templateNode = self.xmlFile:getValue(markerKey .. "#node"),
        translation = { markerTranslationX or 0, markerTranslationY or 0, markerTranslationZ or 0 },
        rotation = { markerRotationX or 0, markerRotationY or 0, markerRotationZ or 0 },
        scale = { markerScaleX or 1, markerScaleY or 1, markerScaleZ or 1 }
    }

    local coverLibraryFilename = self.xmlFile:getValue(basePath .. ".covers#filename")

    if coverLibraryFilename ~= nil then
        coverLibraryFilename = Utils.getFilename(coverLibraryFilename, self.baseDirectory)
    end

    local function loadCover(_, key)
        local filename = self.xmlFile:getValue(key .. "#filename")
        local stackType = self.xmlFile:getValue(key .. "#stackType")

        if filename ~= nil then
            filename = Utils.getFilename(filename, self.baseDirectory)
        else
            filename = coverLibraryFilename
        end

        if filename ~= nil and stackType ~= nil then
            local translationX, translationY, translationZ = self.xmlFile:getValue(key .. "#translation", "0 0 0")
            local rotationX, rotationY, rotationZ = self.xmlFile:getValue(key .. "#rotation", "0 0 0")
            local templateNode = self.xmlFile:getValue(key .. "#node")

            if templateNode == nil then
                templateNode = self.xmlFile:getValue(key .. "#nodeId")
            end

            table.insert(spec.covers, {
                linkNode = self.xmlFile:getValue(key .. "#linkNode", self.rootNode, self.components, self.i3dMappings),
                filename = filename,
                templateNode = templateNode,
                stackType = stackType,
                excludedFillTypes = getFillTypeLookup(self.xmlFile:getValue(key .. "#excludedFillTypes")),
                translation = { translationX or 0, translationY or 0, translationZ or 0 },
                rotation = { rotationX or 0, rotationY or 0, rotationZ or 0 },
                node = nil
            })
        end
    end

    self.xmlFile:iterate(basePath .. ".covers.cover", loadCover)

    if #spec.covers == 0 and self.xmlFile:hasProperty(basePath .. ".cover") then
        loadCover(nil, basePath .. ".cover")
    end

    if #spec.covers == 0 then
        local legacyCoverFilename = self.xmlFile:getValue(basePath .. "#coverFilename")

        if legacyCoverFilename ~= nil then
            local translationX, translationY, translationZ = self.xmlFile:getValue(basePath .. "#coverTranslation", "0 0 0")
            local rotationX, rotationY, rotationZ = self.xmlFile:getValue(basePath .. "#coverRotation", "0 0 0")

            table.insert(spec.covers, {
                linkNode = self.xmlFile:getValue(basePath .. "#coverLinkNode", self.rootNode, self.components, self.i3dMappings),
                filename = Utils.getFilename(legacyCoverFilename, self.baseDirectory),
                templateNode = self.xmlFile:getValue(basePath .. "#coverNode") or self.xmlFile:getValue(basePath .. "#coverNodeId"),
                stackType = self.xmlFile:getValue(basePath .. "#coverStackType", "roundbale125"),
                excludedFillTypes = getFillTypeLookup(self.xmlFile:getValue(basePath .. "#coverExcludedFillTypes")),
                translation = { translationX or 0, translationY or 0, translationZ or 0 },
                rotation = { rotationX or 0, rotationY or 0, rotationZ or 0 },
                node = nil
            })
        end
    end

    addDefaultStackTypes(spec)

    local loadedStackTypeCount = 0

    local function loadStackType(_, key)
        local stackType = {
            name = self.xmlFile:getValue(key .. "#name", "unknown"),
            linkNodes = self.xmlFile:getValue(key .. "#linkNodes"),
            layout = self.xmlFile:getValue(key .. "#layout"),
            rows = math.max(self.xmlFile:getValue(key .. "#rows", 1), 1),
            layers = math.max(self.xmlFile:getValue(key .. "#layers", 1), 1),
            stackLength = math.max(self.xmlFile:getValue(key .. "#stackLength", 12.5), 0.1),
            spacingDiameter = self.xmlFile:getValue(key .. "#spacingDiameter"),
            isRoundbale = self.xmlFile:getValue(key .. "#isRoundbale", true),
            width = MathUtil.round(self.xmlFile:getValue(key .. "#width", 0), 2),
            height = MathUtil.round(self.xmlFile:getValue(key .. "#height", 0), 2),
            length = MathUtil.round(self.xmlFile:getValue(key .. "#length", 0), 2),
            diameter = MathUtil.round(self.xmlFile:getValue(key .. "#diameter", 0), 2),
            fillTypes = nil,
            excludedFillTypes = nil
        }

        stackType.fillTypes = getFillTypeLookup(self.xmlFile:getValue(key .. "#fillTypes"))
        stackType.excludedFillTypes = getFillTypeLookup(self.xmlFile:getValue(key .. "#excludedFillTypes"))

        if stackType.linkNodes ~= nil or stackType.layout == "squareGrid" or stackType.layout == "roundPyramid" then
            local replaced = false

            for index, existingStackType in ipairs(spec.stackTypes) do
                if existingStackType.name == stackType.name then
                    spec.stackTypes[index] = stackType
                    replaced = true

                    break
                end
            end

            if not replaced then
                table.insert(spec.stackTypes, stackType)
            end

            loadedStackTypeCount = loadedStackTypeCount + 1
        end
    end

    self.xmlFile:iterate(basePath .. ".stackTypes.stackType", loadStackType)

    if loadedStackTypeCount == 0 then
        self.xmlFile:iterate(basePath .. ".stackType", loadStackType)
    end

    if savegame == nil and not self.isPreplaced and spec.placementPreview.enabled then
        LSFMBaleStackVisuals.spawnPlacementPreview(self)
    end
end

function LSFMBaleStackVisuals:onDelete()
    LSFMBaleStackVisuals.deleteLSFMBaleStackVisuals(self)
end

function LSFMBaleStackVisuals:onPreFinalizePlacement()
    local spec = getSpec(self)

    if spec ~= nil and spec.placementPreviewActive then
        LSFMBaleStackVisuals.deleteLSFMBaleStackVisuals(self)
        spec.placementPreviewActive = false
    end
end

function LSFMBaleStackVisuals:deleteLSFMBaleStackVisuals()
    local spec = getSpec(self)

    if spec ~= nil and spec.visualRoot ~= nil and entityExists(spec.visualRoot) then
        delete(spec.visualRoot)
        spec.visualRoot = nil
    end

    if spec ~= nil and spec.covers ~= nil then
        for _, cover in ipairs(spec.covers) do
            if cover.node ~= nil and entityExists(cover.node) then
                delete(cover.node)
                cover.node = nil
            end
        end
    end

    if spec ~= nil and spec.coverSharedLoadRequestIds ~= nil then
        for _, sharedLoadRequestId in ipairs(spec.coverSharedLoadRequestIds) do
            g_i3DManager:releaseSharedI3DFile(sharedLoadRequestId)
        end

        spec.coverSharedLoadRequestIds = {}
    end
end

function LSFMBaleStackVisuals:getObjectStorageCanStoreObject(superFunc, object, automatically)
    local spec = getSpec(self)

    if spec == nil or object == nil or object.isa == nil or not object:isa(Bale) then
        return superFunc(self, object, automatically)
    end

    local baleData = {
        xmlFilename = object.xmlFilename,
        fillTypeIndex = getBaleFillTypeIndex(object),
        wrappingState = object.wrappingState or 0
    }

    if getIsFillTypeExcluded(spec, baleData.fillTypeIndex, baleData.wrappingState) then
        return false, PlaceableObjectStorageErrorEvent.ERROR_OBJECT_NOT_SUPPORTED
    end

    local stackType = LSFMBaleStackVisuals.getStackTypeForBale(self, baleData.xmlFilename, baleData.fillTypeIndex, baleData.wrappingState)
    local positions = LSFMBaleStackVisuals.getStackPositions(self, stackType)

    if stackType == nil or positions == nil or #positions == 0 then
        return false, PlaceableObjectStorageErrorEvent.ERROR_OBJECT_NOT_SUPPORTED
    end

    local storedBaleDataList = getStoredBaleDataList(self)

    if spec.singleBaleType and storedBaleDataList[1] ~= nil then
        local firstBaleData = storedBaleDataList[1]

        if firstBaleData.xmlFilename ~= baleData.xmlFilename or firstBaleData.fillTypeIndex ~= baleData.fillTypeIndex then
            return false, PlaceableObjectStorageErrorEvent.ERROR_OBJECT_NOT_SUPPORTED
        end
    end

    local numStoredForStackType = 0

    for _, storedBaleData in ipairs(storedBaleDataList) do
        local storedStackType = LSFMBaleStackVisuals.getStackTypeForBale(self, storedBaleData.xmlFilename, storedBaleData.fillTypeIndex, storedBaleData.wrappingState)

        if storedStackType ~= nil then
            if storedStackType.name ~= stackType.name then
                return false, PlaceableObjectStorageErrorEvent.ERROR_LSFM_BALE_STACK_TYPE_MISMATCH
            end

            numStoredForStackType = numStoredForStackType + 1
        end
    end

    if numStoredForStackType >= #positions then
        return false, PlaceableObjectStorageErrorEvent.ERROR_STORAGE_IS_FULL
    end

    -- GIANTS checks its fixed XML capacity before our visual stack capacity.
    -- Keep it aligned with the active stack so the HUD and admission limit agree.
    self.spec_objectStorage.capacity = #positions

    local canStore, errorId = superFunc(self, object, automatically)

    return canStore, errorId
end

function LSFMBaleStackVisuals:removeAbstractObjectFromStorage(superFunc, abstractObject, x, y, z, rx, ry, rz)
    sanitizeAbstractBaleObject(abstractObject)

    return superFunc(self, abstractObject, x, y, z, rx, ry, rz)
end

function LSFMBaleStackVisuals:updateObjectStorageObjectInfos(superFunc)
    sanitizeStoredBaleAttributes(self)

    return superFunc(self)
end

function LSFMBaleStackVisuals:getStackTypeForBale(xmlFilename, fillTypeIndex, wrappingState)
    local spec = getSpec(self)

    if spec == nil or xmlFilename == nil then
        return nil
    end

    if getIsFillTypeExcluded(spec, fillTypeIndex, wrappingState) then
        return nil
    end

    local isRoundbale, width, height, length, diameter = g_baleManager:getBaleInfoByXMLFilename(xmlFilename, false)
    local fallbackStackType = nil
    local fallbackScore = math.huge

    for _, stackType in ipairs(spec.stackTypes) do
        local fillTypeAllowed = stackType.fillTypes == nil or stackType.fillTypes[fillTypeIndex] == true
        local fillTypeExcluded = stackType.excludedFillTypes ~= nil and stackType.excludedFillTypes[fillTypeIndex] == true

        if fillTypeAllowed and not fillTypeExcluded and stackType.isRoundbale == (isRoundbale == true) then
            if isRoundbale then
                if getDimensionMatches(stackType.width, width) and getDimensionMatches(stackType.diameter, diameter) then
                    return stackType
                end

                local score = getDimensionDifference(stackType.width, width) + getDimensionDifference(stackType.diameter, diameter)

                if score < fallbackScore then
                    fallbackScore = score
                    fallbackStackType = stackType
                end
            elseif getDimensionMatches(stackType.width, width) and getDimensionMatches(stackType.height, height) and getDimensionMatches(stackType.length, length) then
                return stackType
            else
                local score = getDimensionDifference(stackType.width, width) + getDimensionDifference(stackType.height, height) + getDimensionDifference(stackType.length, length)

                if score < fallbackScore then
                    fallbackScore = score
                    fallbackStackType = stackType
                end
            end
        end
    end

    return fallbackStackType
end

local function createGeneratedStackPositions(stackType)
    local positions = {}
    local rows = math.max(math.floor(stackType.rows or 1), 1)
    local layers = math.max(math.floor(stackType.layers or 1), 1)
    local stackLength = math.max(stackType.stackLength or 12.5, 0.1)
    local rotationY = math.pi * 0.5

    if stackType.layout == "squareGrid" then
        local baleWidth = stackType.width or 0
        local baleHeight = stackType.height or 0
        local baleLength = stackType.length or 0

        if baleWidth <= 0 or baleHeight <= 0 or baleLength <= 0 then
            return positions
        end

        local balesPerRow = math.max(math.floor(stackLength / baleWidth + 0.0001), 1)

        for layer = 0, layers - 1 do
            for row = 1, rows do
                local x = (row - (rows + 1) * 0.5) * baleLength
                local y = baleHeight * 0.5 + layer * baleHeight

                for index = 0, balesPerRow - 1 do
                    positions[#positions + 1] = {
                        x = x,
                        y = y,
                        z = (balesPerRow - 1) * baleWidth * 0.5 - index * baleWidth,
                        rx = 0,
                        ry = rotationY,
                        rz = 0
                    }
                end
            end
        end
    elseif stackType.layout == "roundPyramid" then
        local baleWidth = stackType.width or 0
        local diameter = stackType.diameter or 0
        local spacingDiameter = stackType.spacingDiameter or diameter

        if baleWidth <= 0 or diameter <= 0 or spacingDiameter <= 0 then
            return positions
        end

        local bottomRowCount = math.max(math.floor(stackLength / spacingDiameter + 0.0001), 1)
        local verticalSpacing = spacingDiameter * math.sqrt(0.75)

        for layer = 0, math.min(layers, bottomRowCount) - 1 do
            local balesPerRow = bottomRowCount - layer
            local y = diameter * 0.5 + layer * verticalSpacing

            for row = 1, rows do
                local x = (row - (rows + 1) * 0.5) * baleWidth

                for index = 0, balesPerRow - 1 do
                    positions[#positions + 1] = {
                        x = x,
                        y = y,
                        z = (balesPerRow - 1) * spacingDiameter * 0.5 - index * spacingDiameter,
                        rx = 0,
                        ry = rotationY,
                        rz = 0
                    }
                end
            end
        end
    end

    return positions
end

function LSFMBaleStackVisuals:getStackPositions(stackType)
    local spec = getSpec(self)

    if spec == nil or stackType == nil then
        return nil
    end

    if spec.stackPositions[stackType.name] ~= nil then
        return spec.stackPositions[stackType.name]
    end

    if stackType.layout ~= nil then
        local positions = createGeneratedStackPositions(stackType)
        spec.stackPositions[stackType.name] = positions

        return positions
    end

    if spec.linkNodesFilename == nil or stackType.linkNodes == nil then
        return nil
    end

    local rootNode, sharedLoadRequestId = g_i3DManager:loadSharedI3DFile(spec.linkNodesFilename, false, false)

    if rootNode == nil or rootNode == 0 then
        return nil
    end

    local linkNodes = indexToObjectWithFallback(rootNode, stackType.linkNodes)

    local positions = {}

    if linkNodes ~= nil then
        for i = 0, getNumOfChildren(linkNodes) - 1 do
            local linkNode = getChildAt(linkNodes, i)
            local x, y, z = getTranslation(linkNode)
            local rx, ry, rz = getRotation(linkNode)

            table.insert(positions, {
                x = x,
                y = y,
                z = z,
                rx = rx,
                ry = ry,
                rz = rz
            })
        end
    end

    delete(rootNode)

    if sharedLoadRequestId ~= nil then
        g_i3DManager:releaseSharedI3DFile(sharedLoadRequestId)
    end

    spec.stackPositions[stackType.name] = positions

    return positions
end

function LSFMBaleStackVisuals:updateStorageCapacity()
    local spec = getSpec(self)
    local objectStorageSpec = self.spec_objectStorage

    if spec == nil or objectStorageSpec == nil then
        return
    end

    local activeStackType = nil
    local storedBaleDataList = getStoredBaleDataList(self)

    if storedBaleDataList[1] ~= nil then
        local baleData = storedBaleDataList[1]
        activeStackType = LSFMBaleStackVisuals.getStackTypeForBale(self, baleData.xmlFilename, baleData.fillTypeIndex, baleData.wrappingState)
    end

    if activeStackType ~= nil then
        local positions = LSFMBaleStackVisuals.getStackPositions(self, activeStackType)

        if positions ~= nil and #positions > 0 then
            objectStorageSpec.capacity = #positions
            return
        end
    end

    local maximumCapacity = 0

    for _, stackType in ipairs(spec.stackTypes) do
        local positions = LSFMBaleStackVisuals.getStackPositions(self, stackType)
        maximumCapacity = math.max(maximumCapacity, positions ~= nil and #positions or 0)
    end

    if maximumCapacity > 0 then
        objectStorageSpec.capacity = maximumCapacity
    end
end

local function getPurchaseErrorCode(name)
    if LSFMBaleStorageBuyEvent ~= nil and LSFMBaleStorageBuyEvent[name] ~= nil then
        return LSFMBaleStorageBuyEvent[name]
    end

    local fallbackCodes = {
        BUY_SUCCESS = 0,
        BUY_ERROR_NO_PERMISSION = 1,
        BUY_ERROR_NOT_ENOUGH_MONEY = 2,
        BUY_ERROR_NOT_ENOUGH_SPACE = 3,
        BUY_ERROR_NOT_SUPPORTED = 4,
        BUY_ERROR_STACK_MISMATCH = 5,
        BUY_ERROR_OBJECT_DOES_NOT_EXIST = 6
    }

    return fallbackCodes[name] or 4
end

local function getBaleDescContainsFillType(baleDesc, fillTypeIndex)
    if baleDesc == nil or baleDesc.fillTypes == nil then
        return false
    end

    for _, fillTypeData in ipairs(baleDesc.fillTypes) do
        if fillTypeData.fillTypeIndex == fillTypeIndex then
            return true
        end
    end

    return false
end

function LSFMBaleStackVisuals:getLSFMBalePurchaseValidation(xmlFilename, fillTypeIndex, quantity)
    local spec = getSpec(self)
    local objectStorageSpec = self.spec_objectStorage

    if spec == nil or objectStorageSpec == nil or spec.purchase == nil or not spec.purchase.enabled then
        return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
    end

    if xmlFilename == nil or fillTypeIndex == nil or spec.purchase.fillTypes == nil or spec.purchase.fillTypes[fillTypeIndex] ~= true then
        return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
    end

    local purchaseWrappingState = getPurchaseWrappingState(fillTypeIndex)

    if getIsFillTypeExcluded(spec, fillTypeIndex, purchaseWrappingState) then
        return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
    end

    local baleIndex = g_baleManager:getBaleTypeIndexByXMLFilename(xmlFilename)
    local baleDesc = baleIndex ~= nil and g_baleManager:getBaleDescByIndex(baleIndex) or nil

    if baleDesc == nil or not baleDesc.isAvailable or not getBaleDescContainsFillType(baleDesc, fillTypeIndex) then
        return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
    end

    local stackType = LSFMBaleStackVisuals.getStackTypeForBale(self, xmlFilename, fillTypeIndex, purchaseWrappingState)
    local positions = LSFMBaleStackVisuals.getStackPositions(self, stackType)

    if stackType == nil or positions == nil or #positions == 0 then
        return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
    end

    local storedBaleDataList = getStoredBaleDataList(self)
    local numStoredForStackType = 0

    for _, storedBaleData in ipairs(storedBaleDataList) do
        if spec.singleBaleType and (storedBaleData.xmlFilename ~= xmlFilename or storedBaleData.fillTypeIndex ~= fillTypeIndex) then
            return getPurchaseErrorCode("BUY_ERROR_STACK_MISMATCH"), 0
        end

        local storedStackType = LSFMBaleStackVisuals.getStackTypeForBale(self, storedBaleData.xmlFilename, storedBaleData.fillTypeIndex, storedBaleData.wrappingState)

        if storedStackType == nil or storedStackType.name ~= stackType.name then
            return getPurchaseErrorCode("BUY_ERROR_STACK_MISMATCH"), 0
        end

        numStoredForStackType = numStoredForStackType + 1
    end

    local freeSlots = math.max(#positions - numStoredForStackType, 0)

    if freeSlots <= 0 then
        return getPurchaseErrorCode("BUY_ERROR_NOT_ENOUGH_SPACE"), 0
    end

    local requestedQuantity = math.max(math.floor(quantity or 1), 1)
    local maxAllowedQuantity = freeSlots
    local configuredMaxQuantity = math.max(spec.purchase.maxQuantity or 0, 0)

    if configuredMaxQuantity > 0 then
        maxAllowedQuantity = math.min(maxAllowedQuantity, configuredMaxQuantity)
    end

    if requestedQuantity > maxAllowedQuantity then
        return getPurchaseErrorCode("BUY_ERROR_NOT_ENOUGH_SPACE"), maxAllowedQuantity
    end

    objectStorageSpec.capacity = #positions

    return getPurchaseErrorCode("BUY_SUCCESS"), maxAllowedQuantity, stackType, baleIndex
end

function LSFMBaleStackVisuals:getLSFMBalePurchasePrice(xmlFilename, fillTypeIndex)
    local spec = getSpec(self)
    local baleIndex = g_baleManager:getBaleTypeIndexByXMLFilename(xmlFilename)
    local capacity = g_baleManager:getBaleCapacityByBaleIndex(baleIndex, fillTypeIndex)
    local priceScale = spec ~= nil and spec.purchase ~= nil and spec.purchase.priceScale or 1.25
    local pricePerLiter = g_currentMission.economyManager:getCostPerLiter(fillTypeIndex)
    local price = math.ceil((capacity or 0) * (pricePerLiter or 0) * priceScale)

    return math.max(price, 1), capacity or 0
end

function LSFMBaleStackVisuals:getLSFMBalePurchaseCatalog()
    local spec = getSpec(self)
    local catalog = {}

    if spec == nil or spec.purchase == nil or not spec.purchase.enabled or spec.purchase.fillTypes == nil then
        return catalog
    end

    local addedKeys = {}
    local allowedFillTypes = getFillTypeNames(spec.purchase.fillTypes)

    for _, fillTypeIndex in ipairs(allowedFillTypes) do
        for baleIndex, baleDesc in ipairs(g_baleManager.bales or {}) do
            if baleDesc.isAvailable and getBaleDescContainsFillType(baleDesc, fillTypeIndex) then
                local xmlFilename = baleDesc.xmlFilename
                local wrappingState = getPurchaseWrappingState(fillTypeIndex)
                local stackType = LSFMBaleStackVisuals.getStackTypeForBale(self, xmlFilename, fillTypeIndex, wrappingState)
                local errorCode, maxQuantity = LSFMBaleStackVisuals.getLSFMBalePurchaseValidation(self, xmlFilename, fillTypeIndex, 1)

                if errorCode == getPurchaseErrorCode("BUY_SUCCESS") and stackType ~= nil and maxQuantity > 0 then
                    local unitPrice, capacity = LSFMBaleStackVisuals.getLSFMBalePurchasePrice(self, xmlFilename, fillTypeIndex)
                    local key = string.format("%s|%s|%s|%s", stackType.name, tostring(fillTypeIndex), tostring(capacity), tostring(baleDesc.customEnvironment or "base"))

                    if addedKeys[key] == nil then
                        addedKeys[key] = true

                        table.insert(catalog, {
                            xmlFilename = xmlFilename,
                            fillTypeIndex = fillTypeIndex,
                            capacity = capacity,
                            unitPrice = unitPrice,
                            maxQuantity = maxQuantity,
                            stackTypeName = stackType.name,
                            wrappingState = wrappingState,
                            baleIndex = baleIndex
                        })
                    end
                end
            end
        end
    end

    table.sort(catalog, function(a, b)
        if a.fillTypeIndex == b.fillTypeIndex then
            if a.stackTypeName == b.stackTypeName then
                return a.capacity < b.capacity
            end

            return a.stackTypeName < b.stackTypeName
        end

        return a.fillTypeIndex < b.fillTypeIndex
    end)

    return catalog
end

function LSFMBaleStackVisuals:buyLSFMBales(farmId, xmlFilename, fillTypeIndex, quantity, purchasedWrappingColor)
    quantity = math.max(math.floor(quantity or 1), 1)

    local errorCode, maxQuantity = LSFMBaleStackVisuals.getLSFMBalePurchaseValidation(self, xmlFilename, fillTypeIndex, quantity)

    if errorCode ~= getPurchaseErrorCode("BUY_SUCCESS") then
        return errorCode, 0
    end

    quantity = math.min(quantity, maxQuantity)

    local unitPrice, capacity = LSFMBaleStackVisuals.getLSFMBalePurchasePrice(self, xmlFilename, fillTypeIndex)
    local totalPrice = unitPrice * quantity

    if g_currentMission:getMoney(farmId) < totalPrice then
        return getPurchaseErrorCode("BUY_ERROR_NOT_ENOUGH_MONEY"), 0
    end

    local abstractBaleClass = PlaceableObjectStorage.ABSTRACT_OBJECTS_BY_CLASS_NAME.Bale

    if abstractBaleClass == nil then
        return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
    end

    local wrappingState = getPurchaseWrappingState(fillTypeIndex)
    local wrappingColor = { 1, 1, 1 }

    if wrappingState > 0 and type(purchasedWrappingColor) == "table" then
        wrappingColor[1] = math.clamp(tonumber(purchasedWrappingColor[1]) or 1, 0, 1)
        wrappingColor[2] = math.clamp(tonumber(purchasedWrappingColor[2]) or 1, 0, 1)
        wrappingColor[3] = math.clamp(tonumber(purchasedWrappingColor[3]) or 1, 0, 1)
    end

    if getIsWrappedGrassBale(fillTypeIndex, wrappingState) then
        local x, y, z = getWorldTranslation(self.rootNode)

        for _ = 1, quantity do
            local bale = Bale.new(self.isServer, self.isClient)

            if not bale:loadFromConfigXML(xmlFilename, x, y, z, 0, 0, 0) then
                bale:delete()

                return getPurchaseErrorCode("BUY_ERROR_NOT_SUPPORTED"), 0
            end

            bale:setFillType(fillTypeIndex, true)
            bale:setOwnerFarmId(farmId, true)
            bale:setVariationIndex(1)
            bale:setWrappingState(wrappingState)
            bale:setColor(wrappingColor[1], wrappingColor[2], wrappingColor[3])
            self:addObjectToObjectStorage(bale)
        end
    else
        for _ = 1, quantity do
            local abstractBale = abstractBaleClass.new()
            abstractBale.baleAttributes = {
                xmlFilename = xmlFilename,
                farmId = farmId,
                fillLevel = capacity,
                fillType = fillTypeIndex,
                wrappingState = wrappingState,
                wrappingColor = { wrappingColor[1], wrappingColor[2], wrappingColor[3] },
                variationIndex = 1
            }

            self:addAbstactObjectToObjectStorage(abstractBale)
        end

        g_farmManager:updateFarmStats(self:getOwnerFarmId(), "storedBales", quantity)
    end

    g_currentMission:addMoney(-totalPrice, farmId, MoneyType.PURCHASE_BALES, true, true)

    LSFMBaleStackVisuals.updateStorageCapacity(self)
    self:updateObjectStorageObjectInfos()
    self:updateObjectStorageVisualAreas()

    if self.raiseDirtyFlags ~= nil and self.spec_objectStorage ~= nil and self.spec_objectStorage.dirtyFlag ~= nil then
        self:raiseDirtyFlags(self.spec_objectStorage.dirtyFlag)
    else
        self:setObjectStorageObjectInfosDirty()
    end

    return getPurchaseErrorCode("BUY_SUCCESS"), quantity
end

function LSFMBaleStackVisuals:spawnBaleStackVisuals(objectInfo, positions, visualRoot, startIndex, enableCollisions, collisionStartIndex)
    local baleData = getBaleDataFromAbstractObject(objectInfo.objects[1])

    if baleData == nil or positions == nil or #positions == 0 then
        return 0
    end

    local dummyBale, sharedLoadRequestId = Bale.createDummyBale(baleData.xmlFilename, baleData.fillTypeIndex, baleData.variationIndex or 1, baleData.wrappingState or 0, baleData.wrappingColor)

    if dummyBale == nil or dummyBale == 0 then
        return 0
    end

    startIndex = startIndex or 1
    collisionStartIndex = collisionStartIndex or 0

    local spec = getSpec(self)
    local maxCollisionBales = spec ~= nil and spec.visualCollisions ~= nil and spec.visualCollisions.maxBales or 0

    local numVisuals = math.min(objectInfo.numObjects, math.max(#positions - startIndex + 1, 0))

    for i = 1, numVisuals do
        local position = positions[startIndex + i - 1]
        local visualBale = clone(dummyBale, false, false, false)

        copyShaderParameterRecursive(visualBale, dummyBale, "wrappingState")
        copyShaderParameterRecursive(visualBale, dummyBale, "colorScale")

        link(visualRoot, visualBale)
        setTranslation(visualBale, position.x, position.y, position.z)
        setRotation(visualBale, position.rx, position.ry, position.rz)

        if enableCollisions and (maxCollisionBales == 0 or collisionStartIndex + i <= maxCollisionBales) then
            enableStaticCollision(visualBale, spec ~= nil and spec.visualCollisions ~= nil and spec.visualCollisions.playerOnly)
        end
    end

    delete(dummyBale)

    if sharedLoadRequestId ~= nil then
        g_i3DManager:releaseSharedI3DFile(sharedLoadRequestId)
    end

    return numVisuals
end

function LSFMBaleStackVisuals:canShowCoverForBaleData(cover, stackType, baleData)
    if cover == nil or cover.filename == nil or stackType == nil or baleData == nil then
        return false
    end

    if stackType.name ~= cover.stackType then
        return false
    end

    if cover.excludedFillTypes ~= nil and cover.excludedFillTypes[baleData.fillTypeIndex] == true then
        return false
    end

    if getIsWrappedGrassBale(baleData.fillTypeIndex, baleData.wrappingState) and cover.excludedFillTypes ~= nil and cover.excludedFillTypes[FillType.SILAGE] == true then
        return false
    end

    return true
end

function LSFMBaleStackVisuals:spawnCoverVisual(cover, enableCollision)
    local spec = getSpec(self)

    if spec == nil or cover == nil or cover.filename == nil or cover.linkNode == nil then
        return 0
    end

    local coverRoot, sharedLoadRequestId = g_i3DManager:loadSharedI3DFile(cover.filename, false, false)

    if coverRoot == nil or coverRoot == 0 then
        return 0
    end

    if sharedLoadRequestId ~= nil then
        table.insert(spec.coverSharedLoadRequestIds, sharedLoadRequestId)
    end

    local coverNode

    if cover.templateNode ~= nil then
        coverNode = indexToObjectWithFallback(coverRoot, cover.templateNode)

        if coverNode == nil then
            Logging.warning("LSFMBaleStackVisuals: Cover node '%s' not found in '%s'", tostring(cover.templateNode), tostring(cover.filename))
        end
    elseif getNumOfChildren(coverRoot) > 0 then
        coverNode = getChildAt(coverRoot, 0)
    else
        coverNode = coverRoot
    end

    if coverNode == nil then
        delete(coverRoot)

        return 0
    end

    removeFromPhysics(coverNode)
    link(cover.linkNode, coverNode)

    if coverNode ~= coverRoot then
        delete(coverRoot)
    end

    cover.node = coverNode

    setTranslation(coverNode, cover.translation[1], cover.translation[2], cover.translation[3])
    setRotation(coverNode, cover.rotation[1], cover.rotation[2], cover.rotation[3])
    setVisibility(coverNode, true)
    if enableCollision ~= false then
        addToPhysics(coverNode)
    end

    return 1
end

function LSFMBaleStackVisuals:spawnPlacementFrontMarker(parentNode)
    local spec = getSpec(self)
    local marker = spec ~= nil and spec.placementPreview ~= nil and spec.placementPreview.frontMarker or nil

    if parentNode == nil or parentNode == 0 or marker == nil or not marker.enabled or marker.filename == nil then
        return 0
    end

    local markerRoot, sharedLoadRequestId = g_i3DManager:loadSharedI3DFile(marker.filename, false, false)

    if markerRoot == nil or markerRoot == 0 then
        return 0
    end

    if sharedLoadRequestId ~= nil then
        table.insert(spec.coverSharedLoadRequestIds, sharedLoadRequestId)
    end

    local markerNode

    if marker.templateNode ~= nil then
        markerNode = indexToObjectWithFallback(markerRoot, marker.templateNode)

        if markerNode == nil then
            Logging.warning("LSFMBaleStackVisuals: Placement front marker node '%s' not found in '%s'", tostring(marker.templateNode), tostring(marker.filename))
        end
    elseif getNumOfChildren(markerRoot) > 0 then
        markerNode = getChildAt(markerRoot, 0)
    else
        markerNode = markerRoot
    end

    if markerNode == nil then
        delete(markerRoot)

        return 0
    end

    link(parentNode, markerNode)

    if markerNode ~= markerRoot then
        delete(markerRoot)
    end

    setTranslation(markerNode, marker.translation[1], marker.translation[2], marker.translation[3])
    setRotation(markerNode, marker.rotation[1], marker.rotation[2], marker.rotation[3])
    setScale(markerNode, marker.scale[1], marker.scale[2], marker.scale[3])
    setVisibility(markerNode, true)

    return 1
end

function LSFMBaleStackVisuals:spawnPlacementPreview()
    local spec = getSpec(self)

    if spec == nil or spec.placementPreview == nil or spec.linkNode == nil or spec.linkNodesFilename == nil then
        return
    end

    local stackType = getStackTypeByName(spec, spec.placementPreview.stackType)
    local positions = LSFMBaleStackVisuals.getStackPositions(self, stackType)

    if stackType == nil or positions == nil or #positions == 0 then
        return
    end

    local fillTypeIndex = g_fillTypeManager:getFillTypeIndexByName(spec.placementPreview.fillTypeName)

    if fillTypeIndex == nil and FillType ~= nil then
        fillTypeIndex = FillType.STRAW
    end

    if fillTypeIndex == nil then
        return
    end

    local xmlFilename = spec.placementPreview.baleFilename

    if xmlFilename == nil then
        local width = stackType.width ~= nil and stackType.width > 0 and stackType.width or nil
        local height = stackType.height ~= nil and stackType.height > 0 and stackType.height or nil
        local length = stackType.length ~= nil and stackType.length > 0 and stackType.length or nil
        local diameter = stackType.diameter ~= nil and stackType.diameter > 0 and stackType.diameter or nil
        xmlFilename = g_baleManager:getBaleXMLFilename(fillTypeIndex, stackType.isRoundbale, width, height, length, diameter, nil)
    end

    if xmlFilename == nil then
        return
    end

    LSFMBaleStackVisuals.deleteLSFMBaleStackVisuals(self)

    spec.visualRoot = createTransformGroup("lsfmBaleStackPlacementPreview")
    link(spec.linkNode, spec.visualRoot)

    local baleData = {
        xmlFilename = xmlFilename,
        fillType = fillTypeIndex,
        fillTypeIndex = fillTypeIndex,
        variationIndex = spec.placementPreview.variationIndex or 1,
        wrappingState = spec.placementPreview.wrappingState or 0,
        wrappingColor = { 1, 1, 1 }
    }

    local previewObjectInfo = {
        numObjects = #positions,
        objects = {
            {
                baleAttributes = baleData
            }
        }
    }

    local spawnedVisuals = LSFMBaleStackVisuals.spawnBaleStackVisuals(self, previewObjectInfo, positions, spec.visualRoot, 1, spec.visualCollisions ~= nil and spec.visualCollisions.placementPreview, 0)
    spawnedVisuals = spawnedVisuals + LSFMBaleStackVisuals.spawnPlacementFrontMarker(self, spec.visualRoot)

    if spec.placementPreview.showCover then
        for _, cover in ipairs(spec.covers) do
            if LSFMBaleStackVisuals.canShowCoverForBaleData(self, cover, stackType, baleData) then
                spawnedVisuals = spawnedVisuals + LSFMBaleStackVisuals.spawnCoverVisual(self, cover, false)
            end
        end
    end

    setVisibility(spec.visualRoot, spawnedVisuals > 0)
    spec.placementPreviewActive = spawnedVisuals > 0
end

function LSFMBaleStackVisuals:updateObjectStorageVisualAreas(superFunc)
    local spec = getSpec(self)
    local objectStorageSpec = self.spec_objectStorage

    if spec == nil or objectStorageSpec == nil or spec.linkNode == nil or spec.linkNodesFilename == nil or #spec.stackTypes == 0 then
        return superFunc(self)
    end

    sanitizeStoredBaleAttributes(self)
    LSFMBaleStackVisuals.updateStorageCapacity(self)
    LSFMBaleStackVisuals.deleteLSFMBaleStackVisuals(self)

    spec.visualRoot = createTransformGroup("lsfmBaleStackVisuals")
    link(spec.linkNode, spec.visualRoot)
    setVisibility(spec.visualRoot, false)

    local spawnedVisuals = 0
    local collisionVisuals = 0
    local usedPositionsByStackType = {}
    local coverStates = {}

    for _, cover in ipairs(spec.covers) do
        coverStates[cover] = { canBeShown = false, hasAllowedBales = false }
    end

    for _, objectInfo in ipairs(objectStorageSpec.objectInfos) do
        if objectInfo.objects ~= nil and objectInfo.objects[1] ~= nil and objectInfo.numObjects > 0 then
            local baleData = getBaleDataFromAbstractObject(objectInfo.objects[1])
            local stackType = baleData ~= nil and LSFMBaleStackVisuals.getStackTypeForBale(self, baleData.xmlFilename, baleData.fillTypeIndex, baleData.wrappingState) or nil
            local positions = LSFMBaleStackVisuals.getStackPositions(self, stackType)
            local stackTypeName = stackType ~= nil and stackType.name or "unknown"
            local usedPositions = usedPositionsByStackType[stackTypeName] or 0
            local numSpawned = LSFMBaleStackVisuals.spawnBaleStackVisuals(self, objectInfo, positions, spec.visualRoot, usedPositions + 1, spec.visualCollisions ~= nil and spec.visualCollisions.enabled, collisionVisuals)

            usedPositionsByStackType[stackTypeName] = usedPositions + numSpawned
            spawnedVisuals = spawnedVisuals + numSpawned
            collisionVisuals = collisionVisuals + numSpawned

            for _, cover in ipairs(spec.covers) do
                if stackType ~= nil and stackType.name == cover.stackType then
                    local coverState = coverStates[cover]

                    if LSFMBaleStackVisuals.canShowCoverForBaleData(self, cover, stackType, baleData) then
                        coverState.hasAllowedBales = true
                    end

                    if positions ~= nil and #positions > 0 and usedPositionsByStackType[stackTypeName] >= #positions then
                        coverState.canBeShown = true
                    end
                end
            end
        end
    end

    for _, cover in ipairs(spec.covers) do
        local coverState = coverStates[cover]

        if coverState.canBeShown and coverState.hasAllowedBales then
            spawnedVisuals = spawnedVisuals + LSFMBaleStackVisuals.spawnCoverVisual(self, cover)
        end
    end

    setVisibility(spec.visualRoot, spawnedVisuals > 0)

    if objectStorageSpec.numStoredObjects > 0 and spawnedVisuals == 0 then
        local warningText = string.format("[LSFM BaleStackVisuals] No visual bale stack spawned for %s, storedObjects=%d, objectInfos=%d", self.configFileName or "unknown", objectStorageSpec.numStoredObjects, #objectStorageSpec.objectInfos)

        if spec.lastWarningText ~= warningText then
            Logging.warning(warningText)
            spec.lastWarningText = warningText
        end
    end
end
