NHInteractive = {}

function NHInteractive.prerequisitesPresent(specializations)
    return SpecializationUtil.hasSpecialization(Motorized, specializations)
end

function NHInteractive.initSpecialization()
    local vehicleSchema = Vehicle.xmlSchema
    vehicleSchema:setXMLSpecializationType("NHInteractive")
    EffectManager.registerEffectXMLPaths(vehicleSchema, "vehicle.nhInteractive.dustBurstEffects")
    EffectManager.registerEffectXMLPaths(vehicleSchema, "vehicle.nhInteractive.dustTrailEffects")
    vehicleSchema:setXMLSpecializationType()

    local schema = Vehicle.xmlSchemaSavegame
    local savegamePath = "vehicles.vehicle(?).FS25_NewHollandPack.nhInteractive"
    schema:register(XMLValueType.BOOL, savegamePath .. "#roadMode", "Road mode active", false)
    schema:register(XMLValueType.BOOL, savegamePath .. "#clogged", "Threshing system clogged", false)
    schema:register(XMLValueType.FLOAT, savegamePath .. "#feederWorkAlpha", "Saved feeder working position", 1)

    -- Also register the specialization-local path used by some FS25 savegame
    -- serializers. Both paths are read/written for compatibility with old saves.
    local localSavegamePath = "vehicles.vehicle(?).nhInteractive"
    schema:register(XMLValueType.BOOL, localSavegamePath .. "#roadMode", "Road mode active", false)
    schema:register(XMLValueType.BOOL, localSavegamePath .. "#clogged", "Threshing system clogged", false)
    schema:register(XMLValueType.FLOAT, localSavegamePath .. "#feederWorkAlpha", "Saved feeder working position", 1)
end

function NHInteractive.registerOverwrittenFunctions(vehicleType)
    SpecializationUtil.registerOverwrittenFunction(vehicleType, "setFoldState", NHInteractive.setFoldState)
    SpecializationUtil.registerOverwrittenFunction(vehicleType, "setIsTurnedOn", NHInteractive.setIsTurnedOn)
    SpecializationUtil.registerOverwrittenFunction(vehicleType, "getIsTurnedOnAllowed", NHInteractive.getIsTurnedOnAllowed)
    SpecializationUtil.registerOverwrittenFunction(vehicleType, "getTurnedOnNotAllowedWarning", NHInteractive.getTurnedOnNotAllowedWarning)
    SpecializationUtil.registerOverwrittenFunction(vehicleType, "setJointMoveDown", NHInteractive.setJointMoveDown)
end

function NHInteractive.registerEventListeners(vehicleType)
    SpecializationUtil.registerEventListener(vehicleType, "onLoad", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "onPostLoad", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "onUpdate", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "onUpdateTick", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "onDraw", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "onDelete", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "saveToXMLFile", NHInteractive)
    SpecializationUtil.registerEventListener(vehicleType, "onRegisterActionEvents", NHInteractive)
end

function NHInteractive:onLoad(savegame)
    local spec = {}
    self.spec_nhInteractive = spec
    spec.fieldSpeed = 20 / 3.6
    spec.roadSpeed = 42 / 3.6
    spec.roadMode = false
    spec.lastAppliedMode = nil
    spec.lastIconMode = nil

    -- Manual feeder-house height control. The first attacher joint is the cutter
    -- joint. Its physical lower limit is retained separately while the chosen
    -- working position is used as the automatic lowering target.
    spec.feederJointIndex = 1
    spec.feederInput = 0
    spec.feederRaisePressed = false
    spec.feederLowerPressed = false
    spec.feederMoveTime = 2.5
    spec.feederPhysicalLowerAlpha = nil
    spec.feederPhysicalUpperAlpha = nil
    spec.feederWorkAlpha = nil
    spec.feederManualActive = false
    spec.feederHoldActive = false
    spec.feederAutoActive = false
    spec.feederAutoTargetAlpha = nil
    spec.feederHadHeaderDuringInput = false
    spec.feederVisualNode = nil
    spec.feederJointRotationNode = nil
    spec.feederHydraulicNode = nil
    spec.feederVisualRotationY = 0
    spec.feederVisualRotationZ = 0
    spec.actionEvents = {}
    spec.run6ActivatedForCurrentEngineStart = false
    spec.offActivatedForCurrentEngineStop = false

    -- Simple threshing blockage simulation
    spec.clogged = false
    spec.clogLoadThreshold = 0.95
    spec.clogLoadTimer = 0
    spec.clogTargetTime = nil
    spec.clogLoadTier = 0
    spec.clogClearTime = 2500
    spec.clogClearTimer = 0
    spec.cloggedSpeed = 1 / 3.6
    spec.lastAppliedClogged = nil
    spec.clogWarningShown = false
    spec.lastStrawAnimationPlaying = false

    -- Grain tank warning: keep the dashboard decal visible for as long as
    -- fill unit 1 is at or above 85 percent. Hide it immediately again
    -- as soon as the grain tank drops below the threshold.
    spec.grainTankWarningThreshold = 0.85
    spec.grainTankFullThreshold = 0.95
    spec.grainTankFastFullDecal = nil
    spec.grainTankFullDecal = nil

    -- The attached cutter may only be switched on after the threshing system
    -- is running and the engine has reached at least 1300 rpm.
    spec.cutterMinimumRpm = 1300
    spec.cutterTurnStates = {}
    spec.cutterWarningCooldown = 0

    -- Two-stage dust cloud emitted when the blockage is cleared:
    -- 300 ms dense pressure burst followed by 1100 ms lighter dust trail.
    spec.clogDustBurstEffects = nil
    spec.clogDustTrailEffects = nil
    spec.clogDustTimer = 0
    spec.clogDustBurstDuration = 300
    spec.clogDustTrailDuration = 1100
    spec.clogDustPhase = 0
    spec.clogDustActive = false

    if self.isClient then
        spec.clogDustBurstEffects = g_effectManager:loadEffect(
            self.xmlFile,
            "vehicle.nhInteractive.dustBurstEffects",
            self.components,
            self,
            self.i3dMappings
        )
        spec.clogDustTrailEffects = g_effectManager:loadEffect(
            self.xmlFile,
            "vehicle.nhInteractive.dustTrailEffects",
            self.components,
            self,
            self.i3dMappings
        )
    end

    -- Grain tank warning decal.
    if self.getI3DMapping ~= nil then
        local ok, node = pcall(self.getI3DMapping, self, "korntankfastvoll")
        if ok and node ~= nil and node ~= 0 then
            spec.grainTankFastFullDecal = node
        end
    end

    if spec.grainTankFastFullDecal == nil and I3DUtil ~= nil and I3DUtil.indexToObject ~= nil then
        local ok, node = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|7")
        if ok and node ~= nil and node ~= 0 then
            spec.grainTankFastFullDecal = node
        end
    end

    if spec.grainTankFastFullDecal ~= nil then
        setVisibility(spec.grainTankFastFullDecal, false)
    else
        Logging.warning("[NH GRAIN TANK] i3dMapping 'korntankfastvoll' could not be resolved")
    end

    -- Full grain-tank decal: visible continuously from 95% fill level.
    if self.getI3DMapping ~= nil then
        local ok, node = pcall(self.getI3DMapping, self, "korntankvoll")
        if ok and node ~= nil and node ~= 0 then
            spec.grainTankFullDecal = node
        end
    end

    if spec.grainTankFullDecal == nil and I3DUtil ~= nil and I3DUtil.indexToObject ~= nil then
        local ok, node = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|8")
        if ok and node ~= nil and node ~= 0 then
            spec.grainTankFullDecal = node
        end
    end

    if spec.grainTankFullDecal ~= nil then
        setVisibility(spec.grainTankFullDecal, false)
    else
        Logging.warning("[NH GRAIN TANK] i3dMapping 'korntankvoll' could not be resolved")
    end

    -- Dashboard elements hidden while the 85% grain-tank warning is visible.
    spec.grainTankDrivingSpeed = nil
    spec.grainTankThreshingNumbers = nil
    spec.grainTankGpsIcon = nil
    spec.grainTankCruiseThree = nil
    spec.grainTankCruiseThreeNumber = nil
    if I3DUtil ~= nil and I3DUtil.indexToObject ~= nil then
        local okSpeed, speedNode = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|3|0")
        if okSpeed and speedNode ~= nil and speedNode ~= 0 then
            spec.grainTankDrivingSpeed = speedNode
        end

        local okThreshing, threshingNode = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|0|0|0")
        if okThreshing and threshingNode ~= nil and threshingNode ~= 0 then
            spec.grainTankThreshingNumbers = threshingNode
        end

        local okGps, gpsNode = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|3|15")
        if okGps and gpsNode ~= nil and gpsNode ~= 0 then
            spec.grainTankGpsIcon = gpsNode
        end

        local okCruise, cruiseNode = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|3|20")
        if okCruise and cruiseNode ~= nil and cruiseNode ~= 0 then
            spec.grainTankCruiseThree = cruiseNode
        end

        local okCruiseNumber, cruiseNumberNode = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|3|21")
        if okCruiseNumber and cruiseNumberNode ~= nil and cruiseNumberNode ~= 0 then
            spec.grainTankCruiseThreeNumber = cruiseNumberNode
        end
    end

    -- Warning decal: hidden while the combine is clear and blinking while clogged.
    -- getI3DMapping is not available on every vehicle setup, therefore use the
    -- mapped node path as a reliable fallback.
    spec.verstopftDecal = nil

    if self.getI3DMapping ~= nil then
        local ok, node = pcall(self.getI3DMapping, self, "verstopftdecal")
        if ok and node ~= nil and node ~= 0 then
            spec.verstopftDecal = node
        end
    end

    if spec.verstopftDecal == nil and I3DUtil ~= nil and I3DUtil.indexToObject ~= nil then
        local ok, node = pcall(I3DUtil.indexToObject, self.components, "0>0|10|5|22|6")
        if ok and node ~= nil and node ~= 0 then
            spec.verstopftDecal = node
        end
    end

    spec.verstopftDecalBlinkTimer = 0
    spec.verstopftDecalVisible = false

    -- Blockage warning tone. It is triggered by the same state change that
    -- switches the warning decal on, keeping sound and decal fully synchronized.
    spec.clogWarningSample = nil
    spec.clogBlinkInterval = 350
    spec.clogBlinkWasActive = false

    if self.isClient then
        local soundFile = Utils.getFilename("NewHollandCH/sounds/vehTerraVariant435ReverseBeepPlay_01.ogg", self.baseDirectory)
        local sample = createSample("nhClogWarning")
        if sample ~= nil and loadSample(sample, soundFile, false) then
            spec.clogWarningSample = sample
        else
            if sample ~= nil then
                delete(sample)
            end
            Logging.warning("[NH CLOG] Warning sound could not be loaded: %s", soundFile)
        end
    end

    if spec.verstopftDecal ~= nil then
        setVisibility(spec.verstopftDecal, false)
    else
        Logging.warning("[NH CLOG] i3dMapping 'verstopftdecal' could not be resolved")
    end

    -- Resolve after the I3D mappings and Cylindered moving tools are available.
    NHInteractive.resolveFeederNodes(self)

    if savegame ~= nil and not savegame.resetVehicles then
        local modSaveKey = savegame.key .. ".FS25_NewHollandPack.nhInteractive"
        local localSaveKey = savegame.key .. ".nhInteractive"
        spec.roadMode = savegame.xmlFile:getValue(modSaveKey .. "#roadMode",
            savegame.xmlFile:getValue(localSaveKey .. "#roadMode", false))
        spec.clogged = savegame.xmlFile:getValue(modSaveKey .. "#clogged",
            savegame.xmlFile:getValue(localSaveKey .. "#clogged", false))
        spec.feederWorkAlpha = savegame.xmlFile:getValue(modSaveKey .. "#feederWorkAlpha",
            savegame.xmlFile:getValue(localSaveKey .. "#feederWorkAlpha", nil))
        if spec.feederWorkAlpha ~= nil then
            spec.feederWorkAlpha = math.clamp(spec.feederWorkAlpha, 0, 1)
            Logging.info("[NH FEEDER] loaded work position alpha=%.3f", spec.feederWorkAlpha)
        end
    end
end

function NHInteractive:onPostLoad(savegame)
    local spec = self.spec_nhInteractive
    NHInteractive.resolveFeederNodes(self)
    local feederJoint = NHInteractive.getFeederJoint(self)
    if feederJoint ~= nil and spec.feederWorkAlpha ~= nil then
        feederJoint.lowerAlpha = math.clamp(spec.feederWorkAlpha, 0, 1)
    end
    if self.setAnimationTime ~= nil then
        self:setAnimationTime("feldStrasseSpeedMode", spec.roadMode and 1 or 0, true)
    end

    NHInteractive.applyConfiguredBodyColor(self)
    NHInteractive.applySpeed(self, true)
    NHInteractive.updateSpeedModeIcons(self, true)
end

-- The CH body uses the legacy colorMat shader parameters instead of the
-- colorScale parameter used by FS25 vehicle color configurations. Therefore
-- the selected shop color has to be forwarded to the yellow mask channels.
function NHInteractive.applyConfiguredBodyColor(self)
    if not self.isClient then
        return
    end

    local material = nil
    if VehicleConfigurationItemColor ~= nil and VehicleConfigurationItemColor.getMaterialByColorConfiguration ~= nil then
        material = VehicleConfigurationItemColor.getMaterialByColorConfiguration(self, "baseColor")
    end

    local color = material ~= nil and material.colorScale or nil
    if color == nil then
        Logging.warning("[NH COLOR] Could not resolve selected base color")
        return
    end

    -- The material-slot lookup of BaseMaterialConfiguration does not work on
    -- this converted CH model although the names are present in the I3D. Apply
    -- the selected color directly to the known body meshes instead. This keeps
    -- the normal GIANTS shop configuration while avoiding invalid slot lookup.
    local colorMat0Nodes = {
        chSeries_vis2 = true,
        chSeries_vis2_lod = true,
        grainTankRotorPart = true,
        chopper01 = true,
        strawDropRubber = true,
        steeringBar = true
    }
    local colorMat2Nodes = {
        chSeries_vis = true,
        chSeries_vis_lod = true
    }

    local applied = 0

    local function applyToNode(node)
        if node == nil or node == 0 then
            return
        end

        if getHasClassId(node, ClassIds.SHAPE) then
            local nodeName = getName(node)
            local parameterName = nil

            if colorMat0Nodes[nodeName] then
                parameterName = "colorMat0"
            elseif colorMat2Nodes[nodeName] then
                parameterName = "colorMat2"
            end

            if parameterName ~= nil then
                local numMaterials = getNumOfMaterials(node)
                for materialIndex = 0, numMaterials - 1 do
                    setShaderParameter(node, parameterName, color[1], color[2], color[3], 0, false, materialIndex)
                    applied = applied + 1
                end
            end
        end

        for childIndex = 0, getNumOfChildren(node) - 1 do
            applyToNode(getChildAt(node, childIndex))
        end
    end

    for _, component in ipairs(self.components or {}) do
        applyToNode(component.node)
    end

    Logging.info("[NH COLOR] Applied configured body color to %d material(s)", applied)
end

function NHInteractive:onUpdate(dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected)
    local spec = self.spec_nhInteractive

    -- Run6 is the default monitor animation whenever the engine is started.
    -- Trigger it once per engine start so Interactive Control can still change
    -- the monitor afterwards without this update loop forcing Run6 continuously.
    local motorStarted = false
    if self.getIsMotorStarted ~= nil then
        motorStarted = self:getIsMotorStarted()
    elseif self.spec_motorized ~= nil then
        motorStarted = self.spec_motorized.isMotorStarted == true
    end

    if motorStarted then
        -- Engine running: activate Run6 once. Do not run "off" backwards here,
        -- because both animations control the same display nodes and can override
        -- each other when they are played at the same time.
        if not spec.run6ActivatedForCurrentEngineStart then
            if self.playAnimation ~= nil then
                self:playAnimation("Run6", 1, nil, true)
            elseif self.setAnimationTime ~= nil then
                self:setAnimationTime("Run6", 1, true)
            end
            spec.run6ActivatedForCurrentEngineStart = true
            spec.offActivatedForCurrentEngineStop = false
        end
    else
        -- Engine stopped: activate only the OFF animation once and leave it active
        -- until the next engine start.
        if not spec.offActivatedForCurrentEngineStop then
            if self.playAnimation ~= nil then
                self:playAnimation("off", 1, nil, true)
            elseif self.setAnimationTime ~= nil then
                self:setAnimationTime("off", 1, true)
            end
            spec.offActivatedForCurrentEngineStop = true
            spec.run6ActivatedForCurrentEngineStart = false
        end
    end

    if self.getAnimationTime ~= nil then
        local animationTime = self:getAnimationTime("feldStrasseSpeedMode")
        if animationTime ~= nil then
            spec.roadMode = animationTime > 0.5
        end
    end

    NHInteractive.updateFeederControl(self, dt, isActiveForInputIgnoreSelection)
    NHInteractive.updateCutterStartInterlock(self, dt)
    NHInteractive.updateClogging(self, dt)
    NHInteractive.updateGrainTankWarning(self, dt)
    NHInteractive.updateCloggedDecal(self, dt)
    NHInteractive.updateClogDust(self, dt)
    NHInteractive.applySpeed(self, false)
    NHInteractive.updateSpeedModeIcons(self, false)
end

-- Prevent the attached header from being started too early. The check is
-- performed on the rising edge of the cutter state, so a running cutter is
-- not repeatedly toggled. Both requirements must be met: threshing system on
-- and engine speed at or above 1300 rpm.
function NHInteractive.updateCutterStartInterlock(self, dt)
    local spec = self.spec_nhInteractive
    if spec == nil then
        return
    end

    spec.cutterWarningCooldown = math.max((spec.cutterWarningCooldown or 0) - dt, 0)

    local threshingOn = false
    if self.getIsTurnedOn ~= nil then
        threshingOn = self:getIsTurnedOn() == true
    elseif self.spec_turnOnVehicle ~= nil then
        threshingOn = self.spec_turnOnVehicle.isTurnedOn == true
    end

    local motorRpm = 0
    local motorized = self.spec_motorized
    if motorized ~= nil and motorized.motor ~= nil then
        if motorized.motor.getLastMotorRpm ~= nil then
            motorRpm = motorized.motor:getLastMotorRpm() or 0
        elseif motorized.motor.lastMotorRpm ~= nil then
            motorRpm = motorized.motor.lastMotorRpm
        end
    end

    local attachedImplements = self.spec_attacherJoints ~= nil and self.spec_attacherJoints.attachedImplements or nil
    if attachedImplements == nil then
        spec.cutterTurnStates = {}
        return
    end

    local stillAttached = {}
    for _, implement in pairs(attachedImplements) do
        local cutter = implement.object
        if cutter ~= nil and cutter.spec_cutter ~= nil then
            stillAttached[cutter] = true

            local cutterOn = false
            if cutter.getIsTurnedOn ~= nil then
                cutterOn = cutter:getIsTurnedOn() == true
            elseif cutter.spec_turnOnVehicle ~= nil then
                cutterOn = cutter.spec_turnOnVehicle.isTurnedOn == true
            end

            local wasOn = spec.cutterTurnStates[cutter] == true
            if cutterOn and not wasOn and (not threshingOn or motorRpm < spec.cutterMinimumRpm) then
                if cutter.setIsTurnedOn ~= nil then
                    cutter:setIsTurnedOn(false)
                elseif cutter.spec_turnOnVehicle ~= nil then
                    cutter.spec_turnOnVehicle.isTurnedOn = false
                end
                cutterOn = false

                if self.isClient and spec.cutterWarningCooldown <= 0 and g_currentMission ~= nil then
                    g_currentMission:showBlinkingWarning("Drehzahl zu niedrig", 3000)
                    spec.cutterWarningCooldown = 3000
                end
            end

            spec.cutterTurnStates[cutter] = cutterOn
        end
    end

    for cutter, _ in pairs(spec.cutterTurnStates) do
        if not stillAttached[cutter] then
            spec.cutterTurnStates[cutter] = nil
        end
    end
end

function NHInteractive:onDelete()
    local spec = self.spec_nhInteractive
    if spec ~= nil and spec.clogDustBurstEffects ~= nil then
        g_effectManager:stopEffects(spec.clogDustBurstEffects)
        g_effectManager:deleteEffects(spec.clogDustBurstEffects)
        spec.clogDustBurstEffects = nil
    end
    if spec ~= nil and spec.clogDustTrailEffects ~= nil then
        g_effectManager:stopEffects(spec.clogDustTrailEffects)
        g_effectManager:deleteEffects(spec.clogDustTrailEffects)
        spec.clogDustTrailEffects = nil
    end
    if spec ~= nil and spec.clogWarningSample ~= nil then
        stopSample(spec.clogWarningSample, 0, 0)
        delete(spec.clogWarningSample)
        spec.clogWarningSample = nil
    end
end


function NHInteractive.startClogDust(self)
    local spec = self.spec_nhInteractive
    if spec == nil or not self.isClient then
        return
    end

    -- Restart both stages cleanly if the clear action is triggered again.
    if spec.clogDustBurstEffects ~= nil then
        g_effectManager:stopEffects(spec.clogDustBurstEffects)
    end
    if spec.clogDustTrailEffects ~= nil then
        g_effectManager:stopEffects(spec.clogDustTrailEffects)
    end

    if FillType ~= nil and FillType.STRAW ~= nil then
        if spec.clogDustBurstEffects ~= nil then
            g_effectManager:setEffectTypeInfo(spec.clogDustBurstEffects, FillType.STRAW)
        end
        if spec.clogDustTrailEffects ~= nil then
            g_effectManager:setEffectTypeInfo(spec.clogDustTrailEffects, FillType.STRAW)
        end
    end

    -- Phase 1: large, dense pressure burst.
    if spec.clogDustBurstEffects ~= nil then
        g_effectManager:startEffects(spec.clogDustBurstEffects)
    end
    spec.clogDustTimer = spec.clogDustBurstDuration
    spec.clogDustPhase = 1
    spec.clogDustActive = true
end

function NHInteractive.updateClogDust(self, dt)
    local spec = self.spec_nhInteractive
    if spec == nil or not spec.clogDustActive then
        return
    end

    spec.clogDustTimer = spec.clogDustTimer - dt
    if spec.clogDustTimer > 0 then
        return
    end

    if spec.clogDustPhase == 1 then
        -- Phase 2: stop the pressure burst and let finer dust trail behind.
        if spec.clogDustBurstEffects ~= nil then
            g_effectManager:stopEffects(spec.clogDustBurstEffects)
        end
        if spec.clogDustTrailEffects ~= nil then
            g_effectManager:startEffects(spec.clogDustTrailEffects)
        end
        spec.clogDustPhase = 2
        spec.clogDustTimer = spec.clogDustTrailDuration
    else
        if spec.clogDustTrailEffects ~= nil then
            g_effectManager:stopEffects(spec.clogDustTrailEffects)
        end
        spec.clogDustTimer = 0
        spec.clogDustPhase = 0
        spec.clogDustActive = false
    end
end

function NHInteractive.updateGrainTankWarning(self, dt)
    local spec = self.spec_nhInteractive
    if spec == nil then
        return
    end

    local fillLevel = 0
    local capacity = 0

    if self.getFillUnitFillLevel ~= nil then
        fillLevel = self:getFillUnitFillLevel(1) or 0
    end
    if self.getFillUnitCapacity ~= nil then
        capacity = self:getFillUnitCapacity(1) or 0
    end

    local fillRatio = capacity > 0 and fillLevel / capacity or 0

    -- Keep the 85% grain-tank warning visible continuously while the tank is
    -- at or above 85%, but hide it whenever the threshing system is clogged.
    -- It becomes visible again after the clog is cleared (if still >= 85%) or
    -- remains hidden once the fill level has dropped below 85%.
    local showGrainTankWarning = fillRatio >= spec.grainTankWarningThreshold and not spec.clogged

    if spec.grainTankFastFullDecal ~= nil then
        setVisibility(spec.grainTankFastFullDecal, showGrainTankWarning)
    end

    -- Additional full-tank icon: stays illuminated from 95% upwards,
    -- but is hidden while the threshing system is clogged.
    local showGrainTankFull = fillRatio >= spec.grainTankFullThreshold and not spec.clogged
    if spec.grainTankFullDecal ~= nil then
        setVisibility(spec.grainTankFullDecal, showGrainTankFull)
    end

    -- Hide speed and threshing-number readouts for as long as the 85% warning
    -- itself is visible. They return immediately when that warning goes out.
    local showNormalDashboard = not showGrainTankWarning
    if spec.grainTankDrivingSpeed ~= nil then
        setVisibility(spec.grainTankDrivingSpeed, showNormalDashboard)
    end
    if spec.grainTankThreshingNumbers ~= nil then
        setVisibility(spec.grainTankThreshingNumbers, showNormalDashboard)
    end
    if spec.grainTankGpsIcon ~= nil then
        setVisibility(spec.grainTankGpsIcon, showNormalDashboard)
    end
    if spec.grainTankCruiseThree ~= nil then
        setVisibility(spec.grainTankCruiseThree, showNormalDashboard)
    end
    if spec.grainTankCruiseThreeNumber ~= nil then
        setVisibility(spec.grainTankCruiseThreeNumber, showNormalDashboard)
    end
end

function NHInteractive.updateCloggedDecal(self, dt)
    local spec = self.spec_nhInteractive
    if spec == nil then
        return
    end

    if spec.clogged then
        -- Start every blockage with the decal visible and play the beep once.
        if not spec.clogBlinkWasActive then
            spec.clogBlinkWasActive = true
            spec.verstopftDecalBlinkTimer = 0
            spec.verstopftDecalVisible = true

            if spec.verstopftDecal ~= nil then
                setVisibility(spec.verstopftDecal, true)
            end

            if spec.clogWarningSample ~= nil then
                stopSample(spec.clogWarningSample, 0, 0)
                playSample(spec.clogWarningSample, 1, 0.85, 0, 0, 0)
            end
        else
            spec.verstopftDecalBlinkTimer = spec.verstopftDecalBlinkTimer + dt

            -- Use one timer for both functions. The beep is played exactly when
            -- the decal changes from invisible to visible.
            while spec.verstopftDecalBlinkTimer >= spec.clogBlinkInterval do
                spec.verstopftDecalBlinkTimer = spec.verstopftDecalBlinkTimer - spec.clogBlinkInterval
                spec.verstopftDecalVisible = not spec.verstopftDecalVisible

                if spec.verstopftDecal ~= nil then
                    setVisibility(spec.verstopftDecal, spec.verstopftDecalVisible)
                end

                if spec.verstopftDecalVisible and spec.clogWarningSample ~= nil then
                    stopSample(spec.clogWarningSample, 0, 0)
                    playSample(spec.clogWarningSample, 1, 0.85, 0, 0, 0)
                end
            end
        end
    else
        if spec.clogBlinkWasActive and spec.clogWarningSample ~= nil then
            stopSample(spec.clogWarningSample, 0, 0)
        end

        spec.clogBlinkWasActive = false
        spec.verstopftDecalBlinkTimer = 0
        spec.verstopftDecalVisible = false

        if spec.verstopftDecal ~= nil then
            setVisibility(spec.verstopftDecal, false)
        end
    end
end

function NHInteractive.applySpeed(self, force)
    local spec = self.spec_nhInteractive
    if not force and spec.lastAppliedMode == spec.roadMode and spec.lastAppliedClogged == spec.clogged then
        return
    end

    local motorized = self.spec_motorized
    if motorized ~= nil and motorized.motor ~= nil then
        local targetSpeed = spec.roadMode and spec.roadSpeed or spec.fieldSpeed
        if spec.clogged then
            targetSpeed = math.min(targetSpeed, spec.cloggedSpeed)
        end

        motorized.motor.maxForwardSpeed = targetSpeed
        spec.lastAppliedMode = spec.roadMode
        spec.lastAppliedClogged = spec.clogged
    end
end



local function nhGetCombineXPLoad(self)
    -- Combine XPerience exposes the HUD load on both names in current versions.
    -- Prefer the normal alias and fall back to the fully qualified specialization.
    local xpSpec = self.spec_xpCombine
    if xpSpec ~= nil and xpSpec.mrCombineLimiter ~= nil then
        return xpSpec.mrCombineLimiter.engineLoad or 0
    end

    xpSpec = self.spec_FS25_CombineXP_xpCombine
    if xpSpec ~= nil and xpSpec.mrCombineLimiter ~= nil then
        return xpSpec.mrCombineLimiter.engineLoad or 0
    end

    return 0
end

local function nhGetClogTier(load)
    if load > 1.20 then
        return 3
    elseif load > 1.10 then
        return 2
    elseif load >= 0.95 then
        return 1
    end
    return 0
end

local function nhGetRandomClogTime(tier)
    if tier == 3 then
        return math.random(1000, 2000)
    elseif tier == 2 then
        return math.random(3000, 6000)
    elseif tier == 1 then
        return math.random(8000, 15000)
    end
    return nil
end

local function nhIsStrawSwathActive(self)
    if self.getIsSwathActive ~= nil then
        local ok, value = pcall(self.getIsSwathActive, self)
        if ok then
            return value == true
        end
    end

    local combineSpec = self.spec_combine
    if combineSpec ~= nil then
        if combineSpec.isSwathActive ~= nil then
            return combineSpec.isSwathActive == true
        end
        if combineSpec.swathActive ~= nil then
            return combineSpec.swathActive == true
        end
    end

    return false
end

local function nhIsCutterOff(self)
    local attacherSpec = self.spec_attacherJoints
    if attacherSpec == nil or attacherSpec.attachedImplements == nil then
        return true
    end

    local cutterFound = false
    for _, implement in pairs(attacherSpec.attachedImplements) do
        local object = implement.object
        if object ~= nil and object.spec_cutter ~= nil then
            cutterFound = true
            local turnedOn = false
            if object.getIsTurnedOn ~= nil then
                local ok, value = pcall(object.getIsTurnedOn, object)
                turnedOn = ok and value == true
            elseif object.spec_turnOnVehicle ~= nil then
                turnedOn = object.spec_turnOnVehicle.isTurnedOn == true
            end

            if turnedOn then
                return false
            end
        end
    end

    return not cutterFound or true
end

local function nhGetMappedNode(self, mappingId)
    if self.getI3DMapping == nil then
        return nil
    end

    local ok, node = pcall(self.getI3DMapping, self, mappingId)
    if ok and node ~= nil and node ~= 0 then
        return node
    end

    return nil
end

local function nhDropClogStrawPile(self)
    if not self.isServer then
        return false
    end

    if FillType == nil or FillType.STRAW == nil or DensityMapHeightUtil == nil
        or DensityMapHeightUtil.tipToGroundAroundLine == nil then
        Logging.warning("[NH CLOG] Straw pile could not be created: required FS25 functions are missing")
        return false
    end

    -- Prefer the combine's real straw-swath work area. This places the material
    -- exactly where the normal straw swath would leave the machine.
    local startNode = nhGetMappedNode(self, "workAreaStrawStart")
    local widthNode = nhGetMappedNode(self, "workAreaStrawWidth")

    local sx, sy, sz
    local ex, ey, ez
    local radius = 0.65

    if startNode ~= nil and widthNode ~= nil then
        sx, sy, sz = getWorldTranslation(startNode)
        ex, ey, ez = getWorldTranslation(widthNode)
    else
        -- Fallback for vehicles without mapped straw work-area nodes.
        local node = nhGetMappedNode(self, "strawUnloadEffectNode") or self.rootNode
        if node == nil or node == 0 then
            Logging.warning("[NH CLOG] Straw pile could not be created: no unload node found")
            return false
        end

        sx, sy, sz = localToWorld(node, -0.75, -0.35, -0.9)
        ex, ey, ez = localToWorld(node,  0.75, -0.35, -0.9)
    end

    -- A clearly visible but still small symbolic plug of straw.
    local litersToDrop = 500
    local ok, droppedLiters = pcall(
        DensityMapHeightUtil.tipToGroundAroundLine,
        self,
        litersToDrop,
        FillType.STRAW,
        sx, sy, sz,
        ex, ey, ez,
        radius,
        nil,
        nil,
        false,
        nil,
        false
    )

    if not ok then
        Logging.warning("[NH CLOG] Straw pile creation failed in tipToGroundAroundLine")
        return false
    end

    droppedLiters = droppedLiters or 0
    Logging.info("[NH CLOG] Symbolic straw pile dropped: %.1f liters", droppedLiters)
    return droppedLiters > 0
end

function NHInteractive.getIsStrawClickAnimationPlaying(self)
    -- A real IC click starts the configured toggleChopper animation.
    -- Reading only its playing state avoids false triggers when the player
    -- exits the vehicle and Interactive Control refreshes action states/icons.
    if self.getIsAnimationPlaying ~= nil then
        local ok, isPlaying = pcall(self.getIsAnimationPlaying, self, "entstopfen")
        if ok then
            return isPlaying == true
        end
    end

    local animatedSpec = self.spec_animatedVehicle
    if animatedSpec ~= nil and animatedSpec.animations ~= nil then
        local animation = animatedSpec.animations["entstopfen"]
        if animation ~= nil then
            return animation.currentSpeed ~= nil and math.abs(animation.currentSpeed) > 0.0001
        end
    end

    return false
end

function NHInteractive.updateClogging(self, dt)
    local spec = self.spec_nhInteractive
    if spec == nil then
        return
    end

    local isTurnedOn = false
    if self.getIsTurnedOn ~= nil then
        isTurnedOn = self:getIsTurnedOn() == true
    end
    if not isTurnedOn and self.spec_turnOnVehicle ~= nil then
        isTurnedOn = self.spec_turnOnVehicle.isTurnedOn == true
    end

    local speedKmh = self.getLastSpeed ~= nil and self:getLastSpeed() or 0

    if not spec.clogged then
        local xpLoad = nhGetCombineXPLoad(self)
        local tier = nhGetClogTier(xpLoad)
        local overloadActive = not spec.roadMode and speedKmh > 1 and tier > 0

        if overloadActive then
            if spec.clogTargetTime == nil then
                spec.clogTargetTime = nhGetRandomClogTime(tier)
                spec.clogLoadTier = tier
            elseif tier > spec.clogLoadTier then
                local severeTarget = nhGetRandomClogTime(tier)
                spec.clogTargetTime = math.min(spec.clogTargetTime, spec.clogLoadTimer + severeTarget)
                spec.clogLoadTier = tier
            end

            spec.clogLoadTimer = spec.clogLoadTimer + dt
        else
            spec.clogLoadTimer = 0
            spec.clogTargetTime = nil
            spec.clogLoadTier = 0
        end

        if spec.clogTargetTime ~= nil and spec.clogLoadTimer >= spec.clogTargetTime then
            spec.clogged = true
            spec.clogLoadTimer = 0
            spec.clogTargetTime = nil
            spec.clogLoadTier = 0
            spec.clogClearTimer = 0
            spec.lastAppliedClogged = nil

            -- Keep the threshing system running. Only the travel speed is limited
            -- to 1 km/h until the existing Interactive Control point "STROH" is clicked.
            spec.lastStrawAnimationPlaying = NHInteractive.getIsStrawClickAnimationPlaying(self)
            NHInteractive.showCloggedWarning(self)
        end
    else
        -- The STROH Interactive Control click point is used as a clearing button.
        -- It is enough that its action state changes once; selecting straw swath is
        -- not a prerequisite and the threshing system remains switched on.
        local strawAnimationPlaying = NHInteractive.getIsStrawClickAnimationPlaying(self)

        -- Only the rising edge of the actual IC-controlled animation counts.
        -- Leaving the vehicle may refresh IC states, but it does not start
        -- toggleChopper and therefore cannot clear the blockage anymore.
        if strawAnimationPlaying and not spec.lastStrawAnimationPlaying then
            nhDropClogStrawPile(self)
            NHInteractive.startClogDust(self)
            spec.clogged = false
            spec.clogClearTimer = 0
            spec.lastAppliedClogged = nil
            NHInteractive.showClogClearedMessage(self)
        end

        spec.lastStrawAnimationPlaying = strawAnimationPlaying
    end
end

function NHInteractive.showCloggedWarning(self)
    if g_currentMission ~= nil and self.isClient then
        g_currentMission:showBlinkingWarning(
            "Dreschwerk verstopft - Geschwindigkeit auf 1 km/h begrenzt. IC-Clickpoint ENTSTOPFEN druecken.",
            5000
        )
    end
end

function NHInteractive.showClogClearedMessage(self)
    if g_currentMission ~= nil and self.isClient then
        g_currentMission:showBlinkingWarning(
            "Verstopfung beseitigt.",
            3000
        )
    end
end

-- Interactive Control 1.2 supports showClickIcon per clickPoint. Both clickPoints
-- stay usable, but only the icon matching the currently active driving mode is shown.
function NHInteractive.updateSpeedModeIcons(self, force)
    local spec = self.spec_nhInteractive

    -- Interactive Control restores the visibility of all click icons whenever
    -- its overlay is enabled again. Therefore the icon state must be applied
    -- continuously, even when the driving mode itself has not changed.
    local icSpec = self.spec_interactiveControl
    if icSpec == nil or icSpec.interactiveControllers == nil then
        return
    end

    local foundIcon = false

    for _, controller in pairs(icSpec.interactiveControllers) do
        if controller.interactiveActions ~= nil then
            for _, action in pairs(controller.interactiveActions) do
                local typeName = action.typeName ~= nil and string.upper(action.typeName) or ""
                local showIcon = nil

                if typeName == "STRASSENMODUS" then
                    showIcon = spec.roadMode
                elseif typeName == "FELDMODUS" then
                    showIcon = not spec.roadMode
                end

                if showIcon ~= nil then
                    foundIcon = true
                    action.showClickIcon = showIcon

                    if action.clickIconNode ~= nil then
                        setVisibility(action.clickIconNode, action.activated == true and showIcon)
                    end
                end
            end
        end
    end

    if foundIcon then
        spec.lastIconMode = spec.roadMode
    end
end

-- Resolve the exact nodes from chSeries.xml. getI3DMapping is not exposed on
-- every FS25 vehicle instance, so the original XML index paths are the reliable
-- fallback: feeder=0>0|7|1 and attacherJointRot=0>0|7|0.
function NHInteractive.resolveFeederNodes(self)
    local spec = self.spec_nhInteractive
    if spec == nil then return end

    local function mapped(id)
        if self.getI3DMapping ~= nil then
            local ok, node = pcall(self.getI3DMapping, self, id)
            if ok and node ~= nil and node ~= 0 then return node end
        end
        if self.i3dMappings ~= nil then
            local entry = self.i3dMappings[id]
            if type(entry) == "number" and entry ~= 0 then return entry end
            if type(entry) == "table" then
                local node = entry.node or entry.object or entry[1]
                if node ~= nil and node ~= 0 then return node end
            end
        end
        return nil
    end

    spec.feederVisualNode = spec.feederVisualNode or mapped("feeder")
    spec.feederJointRotationNode = spec.feederJointRotationNode or mapped("attacherJointRot")
    spec.feederHydraulicNode = spec.feederHydraulicNode or mapped("feederHydraulic")

    if I3DUtil ~= nil and I3DUtil.indexToObject ~= nil then
        if spec.feederVisualNode == nil then
            local ok, node = pcall(I3DUtil.indexToObject, self.components, "0>0|7|1")
            if ok and node ~= nil and node ~= 0 then spec.feederVisualNode = node end
        end
        if spec.feederJointRotationNode == nil then
            local ok, node = pcall(I3DUtil.indexToObject, self.components, "0>0|7|0")
            if ok and node ~= nil and node ~= 0 then spec.feederJointRotationNode = node end
        end
    end

    -- Cylindered already owns the exact feeder node. Use it as the final
    -- authoritative source if mappings were unavailable during onLoad.
    if spec.feederVisualNode == nil and self.spec_cylindered ~= nil and self.spec_cylindered.movingTools ~= nil then
        for _, movingTool in pairs(self.spec_cylindered.movingTools) do
            if movingTool.xmlIndex == "vehicle.cylindered.movingTools.movingTool(3)"
                or movingTool.name == "feeder" then
                spec.feederVisualNode = movingTool.node
                break
            end
        end
    end

    if spec.feederVisualNode ~= nil and not spec.feederNodeResolvedLogged then
        local _, ry, rz = getRotation(spec.feederVisualNode)
        spec.feederVisualRotationY = ry
        spec.feederVisualRotationZ = rz
        spec.feederNodeResolvedLogged = true
        Logging.info("[NH FEEDER] feeder node resolved")
    end
end

-- Returns the cutter joint and remembers stable mechanical travel limits.
-- Some vehicle configurations do not expose upperAlpha/lowerAlpha immediately,
-- therefore safe normalized defaults are used instead of passing nil to math.clamp.
function NHInteractive.getFeederJoint(self)
    local spec = self.spec_nhInteractive
    local joints = self.spec_attacherJoints ~= nil and self.spec_attacherJoints.attacherJoints or nil
    local joint = joints ~= nil and joints[spec.feederJointIndex] or nil
    if joint == nil then
        return nil
    end

    -- Attacher-joint movement is normalized in GIANTS (0 = raised, 1 = lowered).
    -- Do not derive these limits from temporary runtime values; some cutter
    -- configurations report identical/nil limits until the first lowering action.
    spec.feederPhysicalUpperAlpha = 0
    spec.feederPhysicalLowerAlpha = 1

    if joint.moveAlpha == nil then
        joint.moveAlpha = 0
    end
    joint.moveAlpha = math.clamp(joint.moveAlpha, 0, 1)

    if spec.feederWorkAlpha == nil then
        spec.feederWorkAlpha = joint.moveAlpha
    else
        spec.feederWorkAlpha = math.clamp(spec.feederWorkAlpha, 0, 1)
    end

    return joint
end

function NHInteractive.getFeederHeader(self)
    local attached = self.spec_attacherJoints ~= nil and self.spec_attacherJoints.attachedImplements or nil
    if attached ~= nil then
        -- Prefer the object on the configured cutter joint.
        for _, implement in pairs(attached) do
            if implement ~= nil
                and implement.jointDescIndex == self.spec_nhInteractive.feederJointIndex
                and implement.object ~= nil then
                return implement.object
            end
        end

        -- Some mod headers report a different runtime jointDescIndex. A cutter
        -- specialization is therefore the reliable fallback for memory storage.
        for _, implement in pairs(attached) do
            if implement ~= nil and implement.object ~= nil and implement.object.spec_cutter ~= nil then
                return implement.object
            end
        end
    end
    return nil
end

function NHInteractive:onRegisterActionEvents(isActiveForInput, isActiveForInputIgnoreSelection)
    if not self.isClient then
        return
    end

    local spec = self.spec_nhInteractive
    self:clearActionEventsTable(spec.actionEvents)

    -- Root vehicles are not always reported as selected while a cutter is the
    -- currently selected implement. Ignore-selection input keeps the feeder
    -- controls active in both cases.
    if isActiveForInputIgnoreSelection then
        local raiseAction = InputAction.NH_FEEDER_RAISE
        local lowerAction = InputAction.NH_FEEDER_LOWER
        if raiseAction == nil or lowerAction == nil then
            Logging.error("[NH FEEDER] Input actions missing")
            return
        end

        local _, raiseId = self:addActionEvent(spec.actionEvents, raiseAction, self, NHInteractive.actionEventFeederRaise, true, true, true, true, nil)
        local _, lowerId = self:addActionEvent(spec.actionEvents, lowerAction, self, NHInteractive.actionEventFeederLower, true, true, true, true, nil)

        if raiseId ~= nil then
            g_inputBinding:setActionEventTextPriority(raiseId, GS_PRIO_HIGH)
            g_inputBinding:setActionEventText(raiseId, g_i18n:getText("input_NH_FEEDER_RAISE"))
            g_inputBinding:setActionEventActive(raiseId, true)
        end
        if lowerId ~= nil then
            g_inputBinding:setActionEventTextPriority(lowerId, GS_PRIO_HIGH)
            g_inputBinding:setActionEventText(lowerId, g_i18n:getText("input_NH_FEEDER_LOWER"))
            g_inputBinding:setActionEventActive(lowerId, true)
        end
    end
end

function NHInteractive.actionEventFeederRaise(self, actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_nhInteractive
    if spec == nil then return end
    if inputValue ~= nil and math.abs(inputValue) > 0.001 then
        spec.feederRaisePressed = true
        if not spec.feederRaiseLogged then
            Logging.info("[NH FEEDER] Raise input received")
            spec.feederRaiseLogged = true
        end
    else
        spec.feederRaisePressed = false
        spec.feederRaiseLogged = false
    end
end

function NHInteractive.actionEventFeederLower(self, actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_nhInteractive
    if spec == nil then return end
    if inputValue ~= nil and math.abs(inputValue) > 0.001 then
        spec.feederLowerPressed = true
        if not spec.feederLowerLogged then
            Logging.info("[NH FEEDER] Lower input received")
            spec.feederLowerLogged = true
        end
    else
        spec.feederLowerPressed = false
        spec.feederLowerLogged = false
    end
end

function NHInteractive.applyFeederVisual(self, alpha)
    local spec = self.spec_nhInteractive
    if spec == nil then return end
    if spec.feederVisualNode == nil then
        NHInteractive.resolveFeederNodes(self)
    end
    if spec.feederVisualNode == nil then return end

    -- chSeries.xml: upperRotation=-16 degrees, lowerRotation=25 degrees.
    local angleX = math.rad(-16 + 41 * math.clamp(alpha, 0, 1))

    -- The feeder node is also registered as a Cylindered movingTool. Merely
    -- rotating the node is overwritten by Cylindered in the same frame. Keep
    -- the matching movingTool's runtime rotation in sync so the position also
    -- remains visible when no cutter is attached.
    local cylindered = self.spec_cylindered
    if cylindered ~= nil and cylindered.movingTools ~= nil then
        for _, movingTool in pairs(cylindered.movingTools) do
            if movingTool.node == spec.feederVisualNode then
                if movingTool.curRot ~= nil then
                    movingTool.curRot[1] = angleX
                end
                if movingTool.targetRot ~= nil then
                    movingTool.targetRot[1] = angleX
                end
                if movingTool.rot ~= nil then
                    movingTool.rot[1] = angleX
                end
                break
            end
        end
    end

    -- The physical attacher rotation and the rendered feeder are sibling nodes
    -- in chSeries.i3d. Rotate both so the feeder also moves without an attached
    -- cutter. onDraw repeats this after Cylindered has finished its update.
    if spec.feederJointRotationNode ~= nil then
        local _, jy, jz = getRotation(spec.feederJointRotationNode)
        setRotation(spec.feederJointRotationNode, angleX, jy, jz)
    end
    setRotation(spec.feederVisualNode, angleX, spec.feederVisualRotationY or 0, spec.feederVisualRotationZ or 0)
end

-- Refresh Cylindered after the feeder transform was changed manually. This
-- updates dependent moving parts such as feederHydraulic and its translating
-- piston. The guarded fallbacks keep the mod compatible with minor engine API
-- differences without failing vehicle loading.
function NHInteractive.refreshFeederHydraulic(self, dt)
    local spec = self.spec_nhInteractive
    if spec == nil or self.spec_cylindered == nil then return end

    -- FS25 Cylindered does not expose updateMovingParts(). The correct API is
    -- setMovingToolDirty(node, true, dt): with forceUpdate=true GIANTS updates
    -- the moving tool and all XML dependentParts immediately. For the feeder
    -- this includes feederHydraulic and its translating piston.
    if spec.feederVisualNode ~= nil and self.setMovingToolDirty ~= nil then
        local ok = pcall(self.setMovingToolDirty, self, spec.feederVisualNode, true, dt or 0)
        if ok then return end
    end

    -- Fallback for engine variants: explicitly update the hydraulic movingPart.
    if spec.feederHydraulicNode ~= nil and self.updateMovingPartByNode ~= nil then
        pcall(self.updateMovingPartByNode, self, spec.feederHydraulicNode, dt or 0)
    end
end

function NHInteractive.applyFeederAlpha(self, joint, alpha, header)
    local spec = self.spec_nhInteractive
    alpha = math.clamp(alpha, 0, 1)
    joint.moveAlpha = alpha

    if self.updateAttacherJointRotationNodes ~= nil then
        self:updateAttacherJointRotationNodes(joint, alpha)
    end

    if header ~= nil and header.spec_attachable ~= nil and self.updateAttacherJointRotation ~= nil then
        self:updateAttacherJointRotation(joint, header)
        if self.isServer and joint.jointIndex ~= nil and joint.jointIndex ~= 0 and joint.jointTransform ~= nil then
            setJointFrame(joint.jointIndex, 0, joint.jointTransform)
        end
    else
        NHInteractive.applyFeederVisual(self, alpha)
    end
end

function NHInteractive.updateFeederControl(self, dt, isActiveForInput)
    local spec = self.spec_nhInteractive
    if spec == nil then return end

    local direction = 0
    if spec.feederRaisePressed then direction = direction - 1 end
    if spec.feederLowerPressed then direction = direction + 1 end

    local joint = NHInteractive.getFeederJoint(self)
    if joint == nil then
        if direction ~= 0 and not spec.feederMissingJointLogged then
            Logging.warning("[NH FEEDER] Cutter attacher joint 1 was not found")
            spec.feederMissingJointLogged = true
        end
        return
    end

    local header = NHInteractive.getFeederHeader(self)

    -- With a cutter attached, never write moveAlpha or the joint frame directly.
    -- Route the custom feeder keys through the native GIANTS attacher-joint state
    -- machine. This keeps physical movement, lowered/raised HUD state and cutter
    -- work permission synchronized. Direct/manual alpha control is used only
    -- while no cutter is attached.
    if header ~= nil then
        spec.feederManualActive = false
        spec.feederManualAlpha = nil
        spec.feederHoldActive = false
        spec.feederAutoActive = false
        spec.feederAutoTargetAlpha = nil
        spec.feederTargetAlpha = nil

        if direction ~= 0 and direction ~= spec.feederLastHeaderDirection then
            self:setJointMoveDown(spec.feederJointIndex, direction > 0)
            Logging.info("[NH FEEDER] native cutter moveDown=%s", tostring(direction > 0))
        end
        spec.feederLastHeaderDirection = direction
        return
    end
    spec.feederLastHeaderDirection = 0

    if direction ~= 0 then
        -- Manual input always takes priority over an automatic raise/lower run.
        spec.feederAutoActive = false
        spec.feederAutoTargetAlpha = nil

        local rawMoveTime = joint.moveTime or spec.feederMoveTime or 2.5
        local moveTimeMs = rawMoveTime > 100 and rawMoveTime or rawMoveTime * 1000
        moveTimeMs = math.max(moveTimeMs, 100)

        local current = math.clamp(spec.feederManualAlpha or joint.moveAlpha or 0, 0, 1)
        local target = math.clamp(current + direction * dt / moveTimeMs, 0, 1)
        spec.feederManualAlpha = target
        spec.feederManualActive = true
        spec.feederHoldActive = true
        spec.feederTargetAlpha = target

        NHInteractive.applyFeederAlpha(self, joint, target, nil)
        NHInteractive.refreshFeederHydraulic(self, dt)

        spec.feederDebugTimer = (spec.feederDebugTimer or 0) - dt
        if spec.feederDebugTimer <= 0 then
            Logging.info("[NH FEEDER] moving without cutter direction=%d alpha=%.3f", direction, target)
            spec.feederDebugTimer = 500
        end
    elseif spec.feederManualActive then
        if NHInteractive.getFeederHeader(self) ~= nil and spec.feederTargetAlpha ~= nil then
            spec.feederWorkAlpha = math.clamp(spec.feederTargetAlpha, 0, 1)
            Logging.info("[NH FEEDER] work position stored alpha=%.3f", spec.feederWorkAlpha)
        end
        spec.feederManualActive = false
    end

    -- Automatic lift/lower uses the same movement duration as the original
    -- attacher joint, but approaches the remembered target incrementally. This
    -- prevents the header from dropping instantly when the lower command is
    -- pressed after manually changing the work height.
    if direction == 0 and spec.feederAutoActive and spec.feederAutoTargetAlpha ~= nil then
        local rawMoveTime = joint.moveTime or spec.feederMoveTime or 2.5
        local moveTimeMs = rawMoveTime > 100 and rawMoveTime or rawMoveTime * 1000
        moveTimeMs = math.max(moveTimeMs, 100)
        local current = math.clamp(joint.moveAlpha or 0, 0, 1)
        local target = math.clamp(spec.feederAutoTargetAlpha, 0, 1)
        local maxStep = dt / moveTimeMs
        local nextAlpha
        if current < target then
            nextAlpha = math.min(current + maxStep, target)
        else
            nextAlpha = math.max(current - maxStep, target)
        end

        spec.feederTargetAlpha = nextAlpha
        spec.feederHoldActive = true
        NHInteractive.applyFeederAlpha(self, joint, nextAlpha, NHInteractive.getFeederHeader(self))
        NHInteractive.refreshFeederHydraulic(self, dt)

        if math.abs(nextAlpha - target) < 0.0005 then
            spec.feederAutoActive = false
            spec.feederAutoTargetAlpha = nil
            spec.feederTargetAlpha = target
            Logging.info("[NH FEEDER] automatic target reached alpha=%.3f", target)
        end
    end
end

-- The manual position must be retained after releasing the key; otherwise the
-- native attacher-joint state immediately drives it back to its previous end
-- position. A normal lift/lower command clears feederHoldActive in
-- setJointMoveDown below and hands control back to GIANTS.
function NHInteractive:onUpdateTick(dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected)
    local spec = self.spec_nhInteractive
    if spec == nil or not spec.feederHoldActive or spec.feederTargetAlpha == nil then
        return
    end
    if NHInteractive.getFeederHeader(self) ~= nil then
        spec.feederHoldActive = false
        spec.feederAutoActive = false
        spec.feederAutoTargetAlpha = nil
        spec.feederTargetAlpha = nil
        return
    end

    local joint = NHInteractive.getFeederJoint(self)
    if joint ~= nil then
        NHInteractive.applyFeederAlpha(self, joint, spec.feederTargetAlpha, NHInteractive.getFeederHeader(self))
        NHInteractive.refreshFeederHydraulic(self, dt)
    end
end

-- Cylindered can write the feeder transform after onUpdateTick. Rendering the
-- held no-header position once more here guarantees that the visible feeder
-- follows the manual alpha even when no attachable object exists.
function NHInteractive:onDraw(isActiveForInput, isActiveForInputIgnoreSelection, isSelected)
    local spec = self.spec_nhInteractive
    if spec ~= nil and spec.feederHoldActive and spec.feederTargetAlpha ~= nil
        and NHInteractive.getFeederHeader(self) == nil then
        NHInteractive.applyFeederVisual(self, spec.feederTargetAlpha)
        NHInteractive.refreshFeederHydraulic(self, 0)
    end
end

-- A native lift/lower request explicitly releases the manual hold. Raising
-- still goes to the stock upper stop. Lowering with a cutter attached uses the
-- last manually selected working height.
function NHInteractive:setJointMoveDown(superFunc, jointDescIndex, moveDown, noEventSend)
    local spec = self.spec_nhInteractive
    local isFeeder = spec ~= nil and jointDescIndex == spec.feederJointIndex
    if not isFeeder then
        return superFunc(self, jointDescIndex, moveDown, noEventSend)
    end

    local joint = NHInteractive.getFeederJoint(self)
    if joint == nil then
        return superFunc(self, jointDescIndex, moveDown, noEventSend)
    end

    -- A mounted cutter must stay completely native. Do not retain a manual
    -- alpha and do not override lowerAlpha after the stock call.
    if NHInteractive.getFeederHeader(self) ~= nil then
        spec.feederManualActive = false
        spec.feederManualAlpha = nil
        spec.feederHoldActive = false
        spec.feederAutoActive = false
        spec.feederAutoTargetAlpha = nil
        spec.feederTargetAlpha = nil
        return superFunc(self, jointDescIndex, moveDown, noEventSend)
    end

    -- Let the stock function broadcast and update the normal joint state, then
    -- take over only the actual travel. The target is approached smoothly in
    -- updateFeederControl instead of being assigned as the current position.
    local result = superFunc(self, jointDescIndex, moveDown, noEventSend)

    spec.feederManualActive = false
    spec.feederManualAlpha = nil
    spec.feederHoldActive = true
    spec.feederAutoActive = true
    spec.feederAutoTargetAlpha = moveDown
        and math.clamp(spec.feederWorkAlpha or spec.feederPhysicalLowerAlpha or 1, 0, 1)
        or math.clamp(spec.feederPhysicalUpperAlpha or 0, 0, 1)
    spec.feederTargetAlpha = math.clamp(joint.moveAlpha or 0, 0, 1)

    -- Keep the stock lower limit mechanical. The remembered work position is
    -- our automatic target, not a rewritten hard stop; this avoids a gravity-like
    -- snap caused by changing lowerAlpha while the joint is already lowering.
    joint.lowerAlpha = spec.feederPhysicalLowerAlpha or 1

    Logging.info("[NH FEEDER] automatic move target alpha=%.3f", spec.feederAutoTargetAlpha)
    return result
end

function NHInteractive:saveToXMLFile(xmlFile, key, usedModNames)
    local spec = self.spec_nhInteractive
    if spec == nil then
        return
    end

    -- For specialization save callbacks, `key` already points to
    -- vehicles.vehicle(?).FS25_NewHollandPack.nhInteractive.
    -- Appending the mod/specialization path again produced an invalid path such as
    -- ...nhInteractive.nhInteractive#clogged and triggered savegame schema warnings.
    xmlFile:setValue(key .. "#roadMode", spec.roadMode == true)
    xmlFile:setValue(key .. "#clogged", spec.clogged == true)
    if spec.feederWorkAlpha ~= nil then
        xmlFile:setValue(key .. "#feederWorkAlpha", math.clamp(spec.feederWorkAlpha, 0, 1))
        Logging.info("[NH FEEDER] saved work position alpha=%.3f", spec.feederWorkAlpha)
    end
end


-- Prevent field operation while road mode is active. Folding back in and
-- switching the harvester off remain possible at all times.
function NHInteractive:setFoldState(superFunc, direction, moveToMiddle, noEventSend)
    local spec = self.spec_nhInteractive
    local foldable = self.spec_foldable

    if spec ~= nil and spec.roadMode and foldable ~= nil then
        local isUnfoldRequest = direction ~= 0 and direction == foldable.turnOnFoldDirection
        if isUnfoldRequest then
            if self.isClient and (noEventSend == nil or noEventSend == false) then
                NHInteractive.showFieldModeWarning(self)
            end
            return
        end
    end

    return superFunc(self, direction, moveToMiddle, noEventSend)
end

-- Hard interception of the actual turn-on command. The normal permission
-- query is retained below for the HUD/action state, while this method also
-- blocks direct and externally triggered activation calls.
function NHInteractive:setIsTurnedOn(superFunc, isTurnedOn, noEventSend)
    local spec = self.spec_nhInteractive

    if isTurnedOn == true and spec ~= nil and spec.roadMode == true then
        if self.isClient and (noEventSend == nil or noEventSend == false) then
            NHInteractive.showFieldModeWarning(self)
        end
        return
    end

    return superFunc(self, isTurnedOn, noEventSend)
end

function NHInteractive:getIsTurnedOnAllowed(superFunc, isTurnedOn)
    local allowed = superFunc(self, isTurnedOn)
    local spec = self.spec_nhInteractive

    if isTurnedOn and spec ~= nil and spec.roadMode then
        return false
    end

    return allowed
end

function NHInteractive:getTurnedOnNotAllowedWarning(superFunc)
    local spec = self.spec_nhInteractive
    if spec ~= nil and spec.roadMode then
        return "In Straßenmodus nicht möglich. Bitte in den Feldmodus schalten !"
    end

    return superFunc(self)
end

function NHInteractive.showFieldModeWarning(self)
    if g_currentMission ~= nil then
        g_currentMission:showBlinkingWarning(
            "In Straßenmodus nicht möglich. Bitte in den Feldmodus schalten !",
            3000
        )
    end
end
