-- Sprayer wiring for the RCR consumable products (RCR_FUNGICIDE / RCR_NEMATICIDE): registers their sprayType, ground paint, and disease-protection/AI data.
RealisticCropRotationSprayerProducts = {}

RealisticCropRotationSprayerProducts.PRODUCT_NAMES = { "RCR_FUNGICIDE", "RCR_NEMATICIDE" }

-- Treatment family per product; selects which per-cell protection map a sprayed strip marks.
RealisticCropRotationSprayerProducts.PRODUCT_TREATMENTS = {
    RCR_FUNGICIDE = "FUNGICIDE",
    RCR_NEMATICIDE = "NEMATICIDE",
}

-- Engine consumption at usageScale 1: l/ha = ENGINE_LHA_PER_LPS * litersPerSecond (Sprayer:getSprayerUsage).
local ENGINE_LHA_PER_LPS = 36000

-- Per-product spray rate (l/s): fungicide 200 l/ha foliar, nematicide 400 l/ha soil.
RealisticCropRotationSprayerProducts.PRODUCT_LITERS_PER_SECOND = {
    RCR_FUNGICIDE = 200 / ENGINE_LHA_PER_LPS,
    RCR_NEMATICIDE = 400 / ENGINE_LHA_PER_LPS,
}

-- Fallback spray rate (l/s) when a product has no explicit rate.
RealisticCropRotationSprayerProducts.litersPerSecond = 0.0081

-- fillTypeIndex -> true; refreshed each mission load since fillType indices may shift between savegames.
local productFillTypeSet = {}
-- fillTypeIndex -> "FUNGICIDE" | "NEMATICIDE"; same lifecycle as productFillTypeSet.
local productTreatmentByFillType = {}
-- fillTypeIndex -> spray rate (l/s); same lifecycle as productFillTypeSet.
local productLitersPerSecondByFillType = {}
local hookInstalled = false

-- Minimum ground speed (km/h) to spray.
local MIN_WORK_SPEED = 0.5

local PF_MOD_NAME = "FS25_precisionFarming"
local PF_PATCH_REFRESH_INTERVAL = 1000
local pfExtendedSprayer = nil
local pfExtendedSprayerEffects = nil
local pfPatchedFunctionSet = {}
local pfPatchRecords = {}
local pfPatchTimer = 0
local pfUpdateRegistered = false
local pfTreatmentValueMap = nil

-- Ground-paint value only (SPRAY_TYPE channel); the engine clears it on the crop's next growth-state transition.
local treatmentSprayType = nil

local function getPrecisionFarmingEnvironment()
    local globalEnvironment = _G
    local globalMetaTable = getmetatable(_G)
    if globalMetaTable ~= nil and type(globalMetaTable.__index) == "table" then
        globalEnvironment = globalMetaTable.__index
    end

    if g_thGlobalEnv ~= nil then
        local environment = g_thGlobalEnv[PF_MOD_NAME]
        if environment ~= nil then
            return environment._G or environment
        end
    end

    if globalEnvironment ~= nil then
        local environment = globalEnvironment[PF_MOD_NAME]
        if environment ~= nil then
            return environment._G or environment
        end
    end

    if g_modManager ~= nil then
        local modData = g_modManager.getModByName ~= nil and g_modManager:getModByName(PF_MOD_NAME) or nil
        if modData == nil and g_modManager.nameToMod ~= nil then
            modData = g_modManager.nameToMod[PF_MOD_NAME]
                or g_modManager.nameToMod[string.upper(PF_MOD_NAME)]
                or g_modManager.nameToMod[string.lower(PF_MOD_NAME)]
        end
        if modData ~= nil then
            return modData.environment or modData._G
        end
    end

    return nil
end

local function getSprayerFillSource(vehicle)
    if vehicle == nil or type(vehicle.getSprayerFillUnitIndex) ~= "function" then return nil, nil end

    if pfExtendedSprayer ~= nil and pfExtendedSprayer.getFillTypeSourceVehicle ~= nil
        and vehicle.specializations ~= nil
        and SpecializationUtil.hasSpecialization(pfExtendedSprayer, vehicle.specializations) then
        return pfExtendedSprayer.getFillTypeSourceVehicle(vehicle)
    end

    local fillUnitIndex = vehicle:getSprayerFillUnitIndex()
    if vehicle:getFillUnitFillLevel(fillUnitIndex) <= 0 then
        local spec = vehicle.spec_sprayer
        if spec ~= nil then
            for _, fillType in ipairs(spec.supportedSprayTypes or {}) do
                for _, source in ipairs((spec.fillTypeSources or {})[fillType] or {}) do
                    if source.vehicle:getFillUnitFillLevel(source.fillUnitIndex) > 0
                        and source.vehicle:getFillUnitFillType(source.fillUnitIndex) == fillType then
                        return source.vehicle, source.fillUnitIndex
                    end
                end
            end
        end
    end

    return vehicle, fillUnitIndex
end

function RealisticCropRotationSprayerProducts.getVehicleProductTreatment(vehicle)
    local sourceVehicle, fillUnitIndex = getSprayerFillSource(vehicle)
    if sourceVehicle == nil or fillUnitIndex == nil
        or type(sourceVehicle.getFillUnitFillLevel) ~= "function"
        or sourceVehicle:getFillUnitFillLevel(fillUnitIndex) <= 0 then return nil end

    local fillType = sourceVehicle:getFillUnitFillType(fillUnitIndex)
    if fillType == FillType.UNKNOWN then
        fillType = sourceVehicle:getFillUnitLastValidFillType(fillUnitIndex)
    end

    return RealisticCropRotationSprayerProducts.getProductTreatment(fillType)
end

function RealisticCropRotationSprayerProducts.getControlledProductTreatment()
    local controlledVehicle = g_localPlayer ~= nil and g_localPlayer:getCurrentVehicle() or nil
    if controlledVehicle == nil then return nil end

    local rootVehicle = type(controlledVehicle.getRootVehicle) == "function"
        and controlledVehicle:getRootVehicle() or controlledVehicle
    local vehicles = rootVehicle.childVehicles or { rootVehicle }
    for _, vehicle in ipairs(vehicles) do
        local treatment = RealisticCropRotationSprayerProducts.getVehicleProductTreatment(vehicle)
        if treatment ~= nil then return treatment, vehicle end
    end
    return nil
end

function RealisticCropRotationSprayerProducts.getIsPrecisionFarmingMinimapActive()
    return pfTreatmentValueMap ~= nil
end

local function registerPrecisionFarmingTreatmentMap(pfEnvironment)
    local precisionFarming = pfEnvironment ~= nil and pfEnvironment.g_precisionFarming or nil
    local valueMapClass = pfEnvironment ~= nil and pfEnvironment.ValueMap or nil
    if precisionFarming == nil or valueMapClass == nil or valueMapClass.new == nil
        or precisionFarming.registerValueMap == nil then return end

    local valueMap = valueMapClass.new(precisionFarming)
    valueMap.name = "rcrTreatmentMap"
    valueMap.id = "RCR_TREATMENT"

    function valueMap:getShowInMenu()
        return false
    end

    function valueMap:getValueFilter()
        return { true }
    end

    function valueMap:getMinimapZoomFactor()
        return 3
    end

    function valueMap:buildOverlay(overlay)
        local diseaseMap = RealisticCropRotationDiseaseMap
        if diseaseMap ~= nil then
            diseaseMap:configureTreatmentOverlay(overlay, self.rcrTreatmentFamily)
        end
    end

    precisionFarming:registerValueMap(valueMap)
    pfTreatmentValueMap = valueMap
end

local function updatePrecisionFarmingTreatmentMap()
    if pfTreatmentValueMap == nil then return end

    local treatment, vehicle = RealisticCropRotationSprayerProducts.getControlledProductTreatment()
    local isOnField = false
    if treatment ~= nil and vehicle ~= nil and type(vehicle.getPFStatisticInfo) == "function" then
        local _, _, _, vehicleIsOnField = vehicle:getPFStatisticInfo()
        isOnField = vehicleIsOnField == true
    end

    local familyChanged = pfTreatmentValueMap.rcrTreatmentFamily ~= treatment
    if familyChanged and treatment ~= nil and vehicle ~= nil and pfExtendedSprayer ~= nil then
        local spec = vehicle[pfExtendedSprayer.SPEC_TABLE_NAME]
        if spec ~= nil then
            spec.pHMap:setRequireMinimapDisplay(false, vehicle)
            spec.nitrogenMap:setRequireMinimapDisplay(false, vehicle)
        end
    end

    local previousVehicle = pfTreatmentValueMap.minimapSourceObject
    if previousVehicle ~= nil and previousVehicle ~= vehicle then
        pfTreatmentValueMap:setRequireMinimapDisplay(false, previousVehicle)
    end

    local grid = RealisticCropRotation ~= nil and RealisticCropRotation.grid or nil
    local protectionRevision = grid ~= nil and grid.protectionRevision or 0
    local coverageChanged = pfTreatmentValueMap.rcrProtectionRevision ~= protectionRevision
    pfTreatmentValueMap.rcrTreatmentFamily = treatment
    pfTreatmentValueMap.rcrProtectionRevision = protectionRevision
    pfTreatmentValueMap:setRequireMinimapDisplay(
        treatment ~= nil and isOnField, vehicle,
        vehicle ~= nil and type(vehicle.getIsSelected) == "function" and vehicle:getIsSelected() or false)
    if familyChanged or coverageChanged then
        pfTreatmentValueMap:setMinimapRequiresUpdate(true)
    end
end

local function runPrecisionFarmingVanillaEffectState(originalFunction, vehicle, force, ...)
    local isRcrProduct = RealisticCropRotationSprayerProducts.getVehicleProductTreatment(vehicle) ~= nil
    local effectsSpec = pfExtendedSprayerEffects ~= nil
        and vehicle[pfExtendedSprayerEffects.SPEC_TABLE_NAME] or nil

    if effectsSpec ~= nil then
        effectsSpec.rcrUseVanillaSprayerEffects = isRcrProduct
    end

    if not isRcrProduct or effectsSpec == nil or effectsSpec.hasCustomEffects ~= true then
        return originalFunction(vehicle, force, ...)
    end

    effectsSpec.hasCustomEffects = false
    originalFunction(vehicle, force, ...)
    effectsSpec.hasCustomEffects = true
end

local function createPatchedUpdateSprayerEffectState(originalFunction)
    return function(vehicle, force, ...)
        return runPrecisionFarmingVanillaEffectState(originalFunction, vehicle, force, ...)
    end
end

local function useVanillaSprayerEffects(vehicle)
    local effectsSpec = pfExtendedSprayerEffects ~= nil
        and vehicle[pfExtendedSprayerEffects.SPEC_TABLE_NAME] or nil
    return effectsSpec ~= nil and effectsSpec.rcrUseVanillaSprayerEffects == true
end

local function createPatchedNozzleEffectState(originalFunction)
    return function(vehicle, ...)
        if useVanillaSprayerEffects(vehicle) then
            return false, 1
        end
        return originalFunction(vehicle, ...)
    end
end

local function patchPrecisionFarmingTarget(functionName, target, createPatchedFunction)
    if target == nil or target[functionName] == nil then
        return
    end

    local originalFunction = target[functionName]
    if pfPatchedFunctionSet[originalFunction] == true then
        return
    end

    local patchedFunction = createPatchedFunction(originalFunction)
    local functionRecords = pfPatchRecords[functionName]
    if functionRecords == nil then
        functionRecords = {}
        pfPatchRecords[functionName] = functionRecords
    end

    functionRecords[target] = { original = originalFunction, patched = patchedFunction }
    pfPatchedFunctionSet[patchedFunction] = true
    target[functionName] = patchedFunction
end

local function patchPrecisionFarmingFunctionCopies(functionName, createPatchedFunction)
    patchPrecisionFarmingTarget(functionName, pfExtendedSprayer, createPatchedFunction)

    if g_vehicleTypeManager ~= nil and g_vehicleTypeManager.types ~= nil then
        for _, vehicleType in pairs(g_vehicleTypeManager.types) do
            if vehicleType.functions ~= nil
                and vehicleType.specializations ~= nil
                and SpecializationUtil ~= nil
                and SpecializationUtil.hasSpecialization(pfExtendedSprayer, vehicleType.specializations) then
                patchPrecisionFarmingTarget(functionName, vehicleType.functions, createPatchedFunction)
            end
        end
    end

    local vehicles = g_currentMission ~= nil and g_currentMission.vehicleSystem ~= nil
        and g_currentMission.vehicleSystem.vehicles or nil
    if vehicles ~= nil then
        for _, vehicle in pairs(vehicles) do
            if vehicle.specializations ~= nil
                and SpecializationUtil ~= nil
                and SpecializationUtil.hasSpecialization(pfExtendedSprayer, vehicle.specializations) then
                patchPrecisionFarmingTarget(functionName, vehicle, createPatchedFunction)
            end
        end
    end
end

local function patchPrecisionFarmingEffects()
    if pfExtendedSprayer == nil or pfExtendedSprayerEffects == nil then
        return
    end

    patchPrecisionFarmingTarget(
        "updateSprayerEffectState", pfExtendedSprayer, createPatchedUpdateSprayerEffectState)
    patchPrecisionFarmingFunctionCopies(
        "updateExtendedSprayerNozzleEffectState",
        createPatchedNozzleEffectState)
end

local function restorePrecisionFarmingEffects()
    for functionName, functionRecords in pairs(pfPatchRecords) do
        for target, record in pairs(functionRecords) do
            if target[functionName] == record.patched then
                target[functionName] = record.original
            end
        end
    end

    pfExtendedSprayer = nil
    pfExtendedSprayerEffects = nil
    pfPatchedFunctionSet = {}
    pfPatchRecords = {}
    pfPatchTimer = 0
end

function RealisticCropRotationSprayerProducts:update(dt)
    updatePrecisionFarmingTreatmentMap()
    pfPatchTimer = pfPatchTimer + dt
    if pfPatchTimer >= PF_PATCH_REFRESH_INTERVAL then
        pfPatchTimer = pfPatchTimer - PF_PATCH_REFRESH_INTERVAL
        patchPrecisionFarmingEffects()
    end
end

function RealisticCropRotationSprayerProducts.registerMaterialHolder(modDirectory)
    if g_dedicatedServerInfo == nil and g_materialManager ~= nil then
        local materialHolderPath = "effects/rcrSprayer_materialHolder.i3d"
        local assetConfigFilename = Utils.getFilename("xml/aaMixed6Assets.xml", modDirectory)
        local assetConfig = loadXMLFile("AAMixed6Assets", assetConfigFilename)
        if assetConfig ~= nil and assetConfig ~= 0 then
            materialHolderPath = getXMLString(
                assetConfig,
                "aaMixed6Assets.sprayerMaterialHolder#filename"
            ) or materialHolderPath
            delete(assetConfig)
        end

        g_materialManager:addModMaterialHolder(Utils.getFilename(materialHolderPath, modDirectory))
    end
end

---Returns true when the given fillType index is one of the RCR sprayer products.
-- @param integer fillTypeIndex fillType index (may be nil)
-- @return boolean isProduct true for RCR_FUNGICIDE / RCR_NEMATICIDE
function RealisticCropRotationSprayerProducts.isProductFillType(fillTypeIndex)
    return fillTypeIndex ~= nil and productFillTypeSet[fillTypeIndex] == true
end

---Curative treatment family of an RCR product fillType, or nil for a non-RCR fillType.
-- @param integer fillTypeIndex fillType index (may be nil)
-- @return string|nil treatment "FUNGICIDE" | "NEMATICIDE" | nil
function RealisticCropRotationSprayerProducts.getProductTreatment(fillTypeIndex)
    return fillTypeIndex ~= nil and productTreatmentByFillType[fillTypeIndex] or nil
end

local function ensureSprayTypes()
    if g_fillTypeManager == nil or g_sprayTypeManager == nil then
        Logging.warning("[RealisticCropRotation] fillType/sprayType managers unavailable; RCR sprayer products were not wired")
        return
    end

    local herbicideSprayType = g_sprayTypeManager:getSprayTypeByName("HERBICIDE")
    local litersPerSecond = herbicideSprayType ~= nil and herbicideSprayType.litersPerSecond or 0.0081
    RealisticCropRotationSprayerProducts.litersPerSecond = litersPerSecond
    local sprayGroundType = herbicideSprayType ~= nil and herbicideSprayType.sprayGroundType or nil

    -- Reuses FERTILIZER's ground value for the paint channel (SPRAY_TYPE only, never SPRAY_LEVEL, so no fertilisation/nitrogen/yield).
    local fertiliserSprayType = g_sprayTypeManager:getSprayTypeByName("FERTILIZER")
    treatmentSprayType = fertiliserSprayType ~= nil and fertiliserSprayType.sprayGroundType or nil
    if treatmentSprayType == nil then
        Logging.warning("[RealisticCropRotation] FERTILIZER sprayGroundType unavailable; the treated-ground visual is disabled")
    end

    for _, name in ipairs(RealisticCropRotationSprayerProducts.PRODUCT_NAMES) do
        local fillTypeIndex = g_fillTypeManager:getFillTypeIndexByName(name)
        if fillTypeIndex == nil then
            Logging.warning("[RealisticCropRotation] Missing fillType '%s'; its sprayType was not registered", name)
        else
            local productLps = RealisticCropRotationSprayerProducts.PRODUCT_LITERS_PER_SECOND[name] or litersPerSecond
            productFillTypeSet[fillTypeIndex] = true
            productTreatmentByFillType[fillTypeIndex] = RealisticCropRotationSprayerProducts.PRODUCT_TREATMENTS[name]
            productLitersPerSecondByFillType[fillTypeIndex] = productLps

            if g_sprayTypeManager:getSprayTypeByFillTypeIndex(fillTypeIndex) == nil then
                -- HERBICIDE is the only engine sprayType accepting a custom fillType; its ground effect is neutralized below.
                g_sprayTypeManager:addSprayType(name, productLps, "HERBICIDE", sprayGroundType, false)
            end
        end
    end

end

---Returns the farmland selected by the sprayed strip's three work-area nodes.
-- @param table workArea Work area whose start/width/height nodes give the sprayed strip
-- @return integer|nil farmlandId
local function getWorkAreaFarmlandId(workArea)
    if workArea == nil or workArea.start == nil or workArea.width == nil or workArea.height == nil then
        return nil
    end
    if getWorldTranslation == nil or g_farmlandManager == nil
        or type(g_farmlandManager.getFarmlandIdAtWorldPosition) ~= "function" then
        return nil
    end

    local sx, _, sz = getWorldTranslation(workArea.start)
    local wx, _, wz = getWorldTranslation(workArea.width)
    local hx, _, hz = getWorldTranslation(workArea.height)
    if sx == nil or wx == nil or hx == nil then return nil end

    local farmlandId = tonumber(g_farmlandManager:getFarmlandIdAtWorldPosition(
        (sx + wx + hx) / 3, (sz + wz + hz) / 3))
    return farmlandId ~= nil and farmlandId > 0 and farmlandId or nil
end

---Server: paints disease protection under the sprayed strip and returns changed/total pixels for native sprayer statistics.
-- @param table workArea work area whose start/width/height nodes give the sprayed strip
-- @param integer fillType RCR product fillType
-- @param integer farmlandId Sole farmland allowed to receive the treatment
-- @return number changedPixels
-- @return number totalPixels
local function paintDiseaseProtection(workArea, fillType, farmlandId)
    if workArea == nil or workArea.start == nil or workArea.width == nil or workArea.height == nil then
        return 0, 0
    end
    if getWorldTranslation == nil then return 0, 0 end

    local treatmentType = RealisticCropRotationSprayerProducts.getProductTreatment(fillType)
    if treatmentType == nil then return 0, 0 end

    local grid = RealisticCropRotation ~= nil and RealisticCropRotation.grid or nil
    if grid == nil then return 0, 0 end

    local sx, _, sz = getWorldTranslation(workArea.start)
    local wx, _, wz = getWorldTranslation(workArea.width)
    local hx, _, hz = getWorldTranslation(workArea.height)
    if sx == nil or wx == nil or hx == nil then return 0, 0 end

    return grid:paintProtection(treatmentType, farmlandId, sx, sz, wx, wz, hx, hz)
end

---AI job-completion rule: prohibits re-working an already-protected cell.
-- @param table self Sprayer vehicle
-- @param function superFunc Original function
-- @param integer fillType
local function setSprayerAITerrainDetailProhibitedRange(self, superFunc, fillType)
    if not RealisticCropRotationSprayerProducts.isProductFillType(fillType) then
        return superFunc(self, fillType)
    end

    if not self:getUseSprayerAIRequirements() then return end
    if self.addAITerrainDetailProhibitedRange == nil then return end

    self:clearAITerrainDetailRequiredRange()
    self:clearAITerrainDetailProhibitedRange()
    self:clearAIFruitRequirements()
    self:clearAIFruitProhibitions()

    local treatmentType = RealisticCropRotationSprayerProducts.getProductTreatment(fillType)
    local grid = RealisticCropRotation ~= nil and RealisticCropRotation.grid or nil
    if treatmentType == nil or grid == nil then return end

    local protectionMapId = treatmentType == "FUNGICIDE" and grid.fungicideProtectionMapId
        or treatmentType == "NEMATICIDE" and grid.nematicideProtectionMapId or nil
    if protectionMapId == nil or RealisticCropRotationDiseaseGrid == nil then return end

    self:addAIGroundTypeRequirements(Sprayer.AI_REQUIRED_GROUND_TYPES)

    -- Already-protected (value 1) cells are excluded from future AI passes over this fillType.
    self:addAIFruitProhibitions(0, 1, 1, protectionMapId, 0, RealisticCropRotationDiseaseGrid.PROTECTION_NUM_CHANNELS)

    -- Don't work a field whose crop is already past its harvest-ready growth state.
    if g_fruitTypeManager ~= nil then
        for _, fruitType in pairs(g_fruitTypeManager:getFruitTypes()) do
            if fruitType.terrainDataPlaneId ~= nil and string.lower(fruitType.name) ~= "grass" then
                if fruitType.minHarvestingGrowthState ~= nil and fruitType.maxHarvestingGrowthState ~= nil then
                    self:addAIFruitProhibitions(fruitType.index, fruitType.minHarvestingGrowthState, fruitType.maxHarvestingGrowthState)
                end
            end
        end
    end
end

---Paints the sprayed strip's ground look (SPRAY_TYPE only, no fertilisation/nitrogen/yield effect).
-- @param table self Sprayer vehicle
-- @param table workArea Work area giving the sprayed strip corners
-- @param integer farmlandId Sole farmland allowed to receive the treatment
local function paintTreatmentGround(self, workArea, farmlandId)
    if treatmentSprayType == nil then return end
    farmlandId = tonumber(farmlandId)
    if farmlandId == nil or farmlandId <= 0 then return end
    if DensityMapModifier == nil or DensityMapFilter == nil or g_terrainNode == nil then return end
    if getWorldTranslation == nil or getBitVectorMapNumChannels == nil
        or DensityCoordType == nil or DensityValueCompareType == nil then return end
    if workArea == nil or workArea.start == nil or workArea.width == nil or workArea.height == nil then return end

    local mission = g_currentMission
    if mission == nil or mission.fieldGroundSystem == nil or FieldDensityMap == nil
        or g_farmlandManager == nil or type(g_farmlandManager.getLocalMap) ~= "function" then return end

    local sprayTypeMapId, sprayFirstChannel, sprayNumChannels =
        mission.fieldGroundSystem:getDensityMapData(FieldDensityMap.SPRAY_TYPE)
    if sprayTypeMapId == nil then return end
    local groundTypeMapId, groundFirstChannel, groundNumChannels =
        mission.fieldGroundSystem:getDensityMapData(FieldDensityMap.GROUND_TYPE)
    if groundTypeMapId == nil then return end
    local farmlandLocalMap = g_farmlandManager:getLocalMap()
    if farmlandLocalMap == nil then return end

    local sx, _, sz = getWorldTranslation(workArea.start)
    local wx, _, wz = getWorldTranslation(workArea.width)
    local hx, _, hz = getWorldTranslation(workArea.height)
    if sx == nil or wx == nil or hx == nil then return end

    local modifier = DensityMapModifier.new(sprayTypeMapId, sprayFirstChannel, sprayNumChannels, g_terrainNode)
    modifier:setParallelogramWorldCoords(sx, sz, wx, wz, hx, hz, DensityCoordType.POINT_POINT_POINT)

    -- Only paint where there is field ground (groundType > 0): never roads, yards or standing water.
    local fieldFilter = DensityMapFilter.new(groundTypeMapId, groundFirstChannel, groundNumChannels)
    fieldFilter:setValueCompareParams(DensityValueCompareType.GREATER, 0)
    local farmlandFilter = DensityMapFilter.new(
        farmlandLocalMap, 0, getBitVectorMapNumChannels(farmlandLocalMap))
    farmlandFilter:setValueCompareParams(DensityValueCompareType.EQUAL, farmlandId)

    -- SPRAY_TYPE only (the fertiliser ground look); SPRAY_LEVEL is never touched, so no fertilisation.
    modifier:executeSet(treatmentSprayType, farmlandFilter, fieldFilter)
end

local function installSprayerHook()
    if hookInstalled then
        return
    end

    if Sprayer == nil or Sprayer.processSprayerArea == nil then
        Logging.warning("[RealisticCropRotation] Sprayer specialization unavailable; RCR sprayer hook was not installed")
        return
    end

    Sprayer.processSprayerArea = Utils.overwrittenFunction(Sprayer.processSprayerArea, function(self, superFunc, workArea, dt)
        local spec = self.spec_sprayer
        local params = spec ~= nil and spec.workAreaParameters or nil
        local sprayFillType = params ~= nil and params.sprayFillType or FillType.UNKNOWN

        if not RealisticCropRotationSprayerProducts.isProductFillType(sprayFillType) then
            return superFunc(self, workArea, dt)
        end

        -- Vanilla activation guards.
        if params.sprayFillLevel <= 0 then
            return 0, 0
        end

        if not self.isServer and self.currentUpdateDistance > Sprayer.CLIENT_DM_UPDATE_RADIUS then
            return 0, 0
        end

        -- No spraying while stopped.
        if self:getLastSpeed() <= MIN_WORK_SPEED then
            return 0, 0
        end

        -- Select one farmland for the complete work-area cycle; all following boom sections reuse it.
        if not params.isActive then
            params.rcrTargetFarmlandId = getWorkAreaFarmlandId(workArea)
        end
        local targetFarmlandId = params.rcrTargetFarmlandId

        -- Mark active and refresh the effect timer.
        params.isActive = true
        params.lastSprayTime = g_time
        spec.isWorking = true

        -- Tank usage from the work area width.
        if (params.usage or 0) <= 0 then
            local lps = productLitersPerSecondByFillType[sprayFillType]
                or RealisticCropRotationSprayerProducts.litersPerSecond
            params.usage = lps * self:getLastSpeed() * (workArea.workWidth or 0) * dt * 0.001
        end

        -- The server owns the ground state; nearby clients mirror protection for immediate map feedback.
        if self.isServer then
            paintTreatmentGround(self, workArea, targetFarmlandId)
        end
        local changedArea, totalArea =
            paintDiseaseProtection(workArea, sprayFillType, targetFarmlandId)
        params.lastChangedArea = params.lastChangedArea + changedArea
        params.lastStatsArea = params.lastStatsArea + changedArea
        params.lastTotalArea = params.lastTotalArea + totalArea

        return changedArea, totalArea
    end)

    -- AI job completion: without this, the HERBICIDE sprayType has no weed-replacement data for a custom fillType, so the AI never sees the field as done.
    if Sprayer.setSprayerAITerrainDetailProhibitedRange ~= nil then
        Sprayer.setSprayerAITerrainDetailProhibitedRange = Utils.overwrittenFunction(
            Sprayer.setSprayerAITerrainDetailProhibitedRange, setSprayerAITerrainDetailProhibitedRange)
    end

    hookInstalled = true
end

function RealisticCropRotationSprayerProducts.onMissionLoaded()
    productFillTypeSet = {}
    productTreatmentByFillType = {}
    productLitersPerSecondByFillType = {}
    ensureSprayTypes()
    -- Safety net only: the hook is normally installed at source time, before TypeManager:finalizeTypes snapshots it.
    installSprayerHook()

    local pfEnvironment = getPrecisionFarmingEnvironment()
    pfExtendedSprayer = pfEnvironment ~= nil and pfEnvironment.ExtendedSprayer or nil
    pfExtendedSprayerEffects = pfEnvironment ~= nil and pfEnvironment.ExtendedSprayerEffects or nil
    if g_currentMission ~= nil and g_currentMission:getIsClient()
        and pfExtendedSprayer ~= nil and pfExtendedSprayerEffects ~= nil then
        registerPrecisionFarmingTreatmentMap(pfEnvironment)
        patchPrecisionFarmingEffects()
        g_currentMission:addUpdateable(RealisticCropRotationSprayerProducts)
        pfUpdateRegistered = true
    end
end

function RealisticCropRotationSprayerProducts.onMissionDeleted()
    if pfUpdateRegistered and g_currentMission ~= nil then
        g_currentMission:removeUpdateable(RealisticCropRotationSprayerProducts)
    end
    pfUpdateRegistered = false
    pfTreatmentValueMap = nil
    restorePrecisionFarmingEffects()
    productFillTypeSet = {}
    productTreatmentByFillType = {}
    productLitersPerSecondByFillType = {}
end

-- Source-time installation is the only moment the overwritten processSprayerArea reaches every sprayer vehicleType.
installSprayerHook()
