DiseaseCore = {}
DiseaseCore.modName = g_currentModName or "FS25_CropDiseases_BMP"
DiseaseCore.modDirectory = g_currentModDirectory or ""
_G.BMP_DISEASE_DEBUG_LOG = _G.BMP_DISEASE_DEBUG_LOG == false

local function clamp(v, a, b) return math.max(a, math.min(b, v)) end
local function nz(v, d) if v == nil then return d end return v end
local function safeDiv(a, b, d) if b == nil or b == 0 then return d or 0 end return a / b end
local function round3(v) return math.floor((v or 0) * 1000 + 0.5) / 1000 end
local function contains(list, value) for _, item in ipairs(list or {}) do if item == value then return true end end return false end
local function getDefs() return _G.DiseaseDefs end
local function getTuning() local defs = getDefs() return defs and defs.TUNING or {} end
local function getThreatCategorySafe(threatKey, fallback)
    local defs = getDefs()
    if threatKey ~= nil and defs ~= nil and defs.getThreatCategory ~= nil then
        local category = defs:getThreatCategory(threatKey)
        if category ~= nil then
            return category
        end
    end

    return fallback
end
local function splitCsv(value)
    local result = {}
    for item in string.gmatch(tostring(value or ""), "([^,]+)") do
        local trimmed = string.match(item, "^%s*(.-)%s*$")
        if trimmed ~= nil and trimmed ~= "" then
            table.insert(result, trimmed)
        end
    end
    return result
end

local function joinCsv(list)
    return table.concat(list or {}, ",")
end

local function debugLog(fmt, ...)
    if _G.BMP_DISEASE_DEBUG_LOG ~= true then
        return
    end

    if select("#", ...) > 0 then
        print(string.format("[DiseaseDebug] " .. tostring(fmt), ...))
    else
        print("[DiseaseDebug] " .. tostring(fmt))
    end
end
local g_customSprayerHooked = false
local g_customDensitySprayHooked = false
local g_customAIAreaHooked = false
local g_diseaseSaveHooksInstalled = false

local function getDiseaseSaveXmlPath()
    local mission = g_currentMission
    local info = mission and mission.missionInfo or nil
    local dir = info and info.savegameDirectory or nil
    if dir == nil or dir == "" then
        return nil
    end
    return dir .. "/cropDiseases.xml"
end

local function getDiseaseMaskSavePath()
    local mission = g_currentMission
    local info = mission and mission.missionInfo or nil
    local dir = info and info.savegameDirectory or nil
    if dir == nil or dir == "" then
        return nil
    end
    return dir .. "/cropThreatMask.grle"
end

local function getI18nTextSafe(key, fallback)
    if g_i18n ~= nil and key ~= nil then
        local ok, value = pcall(function()
            return g_i18n:getText(key)
        end)
        if ok and value ~= nil and value ~= "" and value ~= key then
            return value
        end
    end

    return fallback or tostring(key or "")
end

local function getThreatDisplayName(threatKey)
    if threatKey == nil or threatKey == "" then
        return getI18nTextSafe("bmp_disease_title", "Crop threat")
    end

    return getI18nTextSafe(threatKey, tostring(threatKey))
end

local function getStateDisplayName(state)
    state = tonumber(state or 0) or 0
    if state <= 0 then return getI18nTextSafe("bmp_dis_none", "none") end
    if state == 1 then return getI18nTextSafe("bmp_dis_low", "low") end
    if state == 2 then return getI18nTextSafe("bmp_dis_med", "medium") end
    return getI18nTextSafe("bmp_dis_high", "high")
end

local function ensureCustomSprayTypes()
    if g_fillTypeManager == nil or g_sprayTypeManager == nil then
        return
    end

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

    local function registerNeutralSprayType(name, fillType)
        if fillType == nil then return end
        local sprayType = g_sprayTypeManager:getSprayTypeByFillTypeIndex(fillType.index)
        if sprayType == nil then
            -- The manager only accepts one of the three vanilla type names.
            -- Register first so vehicles recognize the liquid, then neutralize
            -- every vanilla field-action flag below.
            sprayType = g_sprayTypeManager:addSprayType(name, litersPerSecond, "HERBICIDE", sprayGroundType, false)
        end
        if sprayType ~= nil then
            sprayType.isHerbicide = false
            sprayType.isFertilizer = false
            sprayType.isLime = false
            sprayType.sprayGroundType = 0
            debugLog("Registered neutral sprayType %s (lps=%.4f)", tostring(name), litersPerSecond)
        end
    end

    local insecticideFillType = g_fillTypeManager:getFillTypeByName("INSECTICIDE")
    registerNeutralSprayType("INSECTICIDE", insecticideFillType)

    local fungicideFillType = g_fillTypeManager:getFillTypeByName("FUNGICIDE")
    registerNeutralSprayType("FUNGICIDE", fungicideFillType)
end

local function ensureInsecticideSprayerMaterialHolder()
    if g_materialManager == nil then
        return
    end

    if DiseaseCore.modDirectory == nil or DiseaseCore.modDirectory == "" then
        print("[DiseaseCore] modDirectory is empty, cannot load insecticide sprayer material holder")
        return
    end

    local holderPath = Utils.getFilename("effects/sprayer/sprayerMeshes_materialHolder.i3d", DiseaseCore.modDirectory)
    g_materialManager:addModMaterialHolder(holderPath)
    g_materialManager:loadModMaterialHolders()
end

local function isInsecticideFillType(fillType)
    local insecticideFillType = g_fillTypeManager and g_fillTypeManager:getFillTypeIndexByName("INSECTICIDE") or nil
    return insecticideFillType ~= nil and fillType == insecticideFillType
end

local function isFungicideFillType(fillType)
    local fungicideFillType = g_fillTypeManager and g_fillTypeManager:getFillTypeIndexByName("FUNGICIDE") or nil
    return fungicideFillType ~= nil and fillType == fungicideFillType
end
local function getInsecticideVisualFillType(fillType)
    if not isInsecticideFillType(fillType) and not isFungicideFillType(fillType) then
        return fillType
    end

    return g_fillTypeManager and g_fillTypeManager:getFillTypeIndexByName("HERBICIDE") or fillType
end

local function hookInsecticideSprayerProcessing()
    if g_customSprayerHooked or Sprayer == nil or Sprayer.processSprayerArea == nil then
        return
    end

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

        if not isInsecticideFillType(sprayFillType) and not isFungicideFillType(sprayFillType) then
            return superFunc(self, workArea, dt)
        end

        if self:getIsAIActive() and (self.isServer and (sprayFillType == nil or sprayFillType == FillType.UNKNOWN)) then
            self.rootVehicle:stopCurrentAIJob(AIMessageErrorOutOfFill.new())
            return 0, 0
        end

        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

        params.isActive = true
        params.lastSprayTime = g_time

        if self:getLastSpeed() > 1 then
            spec.isWorking = true
        end

        return 0, 0
    end)

    g_customSprayerHooked = true
    debugLog("Hooked sprayer processing for INSECTICIDE/FUNGICIDE")
end

local function hookInsecticideSprayerEffects()
    if Sprayer == nil or Sprayer.updateSprayerEffects == nil or Sprayer.getInsecticideEffectFillTypeHooked then
        return
    end

    Sprayer.updateSprayerEffects = Utils.overwrittenFunction(Sprayer.updateSprayerEffects, function(self, superFunc, force)
        local spec = self.spec_sprayer
        local effectsState = self:getAreEffectsVisible()

        if effectsState ~= spec.lastEffectsState or force then
            if effectsState then
                local fillUnitIndex = self:getSprayerFillUnitIndex()
                local fillType = self:getFillUnitLastValidFillType(fillUnitIndex)
                if fillType == FillType.UNKNOWN then
                    fillType = self:getFillUnitFirstSupportedFillType(fillUnitIndex)
                end

                local effectFillType = getInsecticideVisualFillType(fillType)
                g_effectManager:setEffectTypeInfo(spec.effects, effectFillType)
                g_effectManager:startEffects(spec.effects)
                g_soundManager:playSamples(spec.samples.spray)

                local sprayType = self:getActiveSprayType()
                if sprayType ~= nil then
                    g_effectManager:setEffectTypeInfo(sprayType.effects, effectFillType)
                    g_effectManager:startEffects(sprayType.effects)
                    g_animationManager:startAnimations(sprayType.animationNodes)
                    g_soundManager:playSamples(sprayType.samples.spray)
                end

                g_animationManager:startAnimations(spec.animationNodes)
            else
                g_effectManager:stopEffects(spec.effects)
                g_animationManager:stopAnimations(spec.animationNodes)
                g_soundManager:stopSamples(spec.samples.spray)
                for _, sprayType in ipairs(spec.sprayTypes) do
                    g_effectManager:stopEffects(sprayType.effects)
                    g_animationManager:stopAnimations(sprayType.animationNodes)
                    g_soundManager:stopSamples(sprayType.samples.spray)
                end
            end

            spec.lastEffectsState = effectsState
            return
        end

        return superFunc(self, force)
    end)

    Sprayer.getInsecticideEffectFillTypeHooked = true
    debugLog("Hooked sprayer effects for INSECTICIDE/FUNGICIDE")
end

local function getFarmlandIdAtXZ(x, z)
    if g_farmlandManager == nil then return nil end
    local farmland = g_farmlandManager:getFarmlandAtWorldPosition(x, z)
    return farmland and farmland.id or nil
end

local function hookCustomSprayDensityProcessing()
    if g_customDensitySprayHooked or FSDensityMapUtil == nil or FSDensityMapUtil.updateSprayArea == nil then
        return
    end

    local originalUpdateSprayArea = FSDensityMapUtil.updateSprayArea
    FSDensityMapUtil.updateSprayArea = function(startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ, sprayTypeIndex, sprayAmount)
        local desc = g_sprayTypeManager ~= nil and g_sprayTypeManager:getSprayTypeByIndex(sprayTypeIndex) or nil
        local fillTypeIndex = desc ~= nil and desc.fillType ~= nil and desc.fillType.index or nil
        if isInsecticideFillType(fillTypeIndex) or isFungicideFillType(fillTypeIndex) then
            -- Custom crop-care liquids must not touch vanilla weed, fertilizer,
            -- lime or spray-ground density maps.
            return 0, 0
        end
        return originalUpdateSprayArea(startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ, sprayTypeIndex, sprayAmount)
    end

    g_customDensitySprayHooked = true
    debugLog("Hooked density spray processing for neutral INSECTICIDE/FUNGICIDE")
end

local function hookCustomSprayAIArea()
    if g_customAIAreaHooked or AIVehicleUtil == nil or AIVehicleUtil.getAIAreaOfVehicle == nil then return end
    local originalGetAIArea = AIVehicleUtil.getAIAreaOfVehicle
    AIVehicleUtil.getAIAreaOfVehicle = function(vehicle, startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ)
        if vehicle ~= nil and vehicle.getSprayerFillUnitIndex ~= nil and vehicle.getFillUnitFillType ~= nil and DiseaseGrid ~= nil then
            local fillUnitIndex = vehicle:getSprayerFillUnitIndex()
            local fillType = fillUnitIndex ~= nil and vehicle:getFillUnitFillType(fillUnitIndex) or FillType.UNKNOWN
            local category = isInsecticideFillType(fillType) and "pest" or (isFungicideFillType(fillType) and "disease" or nil)
            if category ~= nil and DiseaseGrid.getThreatAreaByCategory ~= nil then
                vehicle.spec_diseaseSprayerHealer = vehicle.spec_diseaseSprayerHealer or {}
                local state = vehicle.spec_diseaseSprayerHealer
                local centerX = (startWorldX + widthWorldX + heightWorldX) / 3
                local centerZ = (startWorldZ + widthWorldZ + heightWorldZ) / 3
                local farmlandId = DiseaseCore ~= nil and DiseaseCore.getActiveThreatCategoryAtWorldPos ~= nil
                    and select(1, DiseaseCore:getActiveThreatCategoryAtWorldPos(centerX, centerZ)) or nil
                if farmlandId == nil then
                    local node = vehicle.rootNode or (vehicle.components and vehicle.components[1] and vehicle.components[1].node)
                    if node ~= nil then
                        local vehicleX, _, vehicleZ = getWorldTranslation(node)
                        farmlandId = select(1, DiseaseCore:getActiveThreatCategoryAtWorldPos(vehicleX, vehicleZ))
                    end
                end
                if farmlandId ~= nil then
                    local entry = DiseaseCore.fields and DiseaseCore.fields[farmlandId] or nil
                    local activeCategory = entry and entry.category or nil
                    if activeCategory == category and (entry.state or 0) > 0 then
                        state.aiTreatmentFarmlandId = farmlandId
                        state.aiTreatmentCategory = category
                    end
                end

                -- Once an infected field is accepted, keep the whole vanilla
                -- field area valid for this AI course. Otherwise clearing the
                -- current cells makes the worker toggle the sprayer every few
                -- metres. Manual spraying still uses exact threat cells.
                if state.aiTreatmentFarmlandId ~= nil and state.aiTreatmentCategory == category then
                    return originalGetAIArea(vehicle, startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ)
                end
                return DiseaseGrid:getThreatAreaByCategory(
                    startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ, category)
            end
        end
        return originalGetAIArea(vehicle, startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ)
    end
    g_customAIAreaHooked = true
    debugLog("Hooked AI area queries for crop threats")
end

local function getFieldPolygon2D(field)
    if field == nil then
        return nil
    end

    local polygon = Polygon2D.new()
    local densityMapPolygon = field.getDensityMapPolygon ~= nil and field:getDensityMapPolygon() or nil
    local vertices = densityMapPolygon ~= nil and densityMapPolygon.getVerticesList ~= nil and densityMapPolygon:getVerticesList() or nil

    if vertices ~= nil and #vertices >= 6 then
        for i = 1, #vertices, 2 do
            polygon:addPos(vertices[i], vertices[i + 1])
        end
    else
        for _, point in ipairs(field:getPolygonPoints() or {}) do
            local x, _, z = getWorldTranslation(point)
            polygon:addPos(x, z)
        end
    end

    if polygon:getNumVertices() < 3 then
        return nil
    end

    return polygon
end

local function getFieldSweepAnchor(field)
    if field == nil then
        return 0, 0
    end

    local points = field.getPolygonPoints ~= nil and field:getPolygonPoints() or nil
    if points == nil or #points == 0 then
        return 0, 0
    end

    local sumX, sumZ, count = 0, 0, 0
    for _, point in ipairs(points) do
        if point ~= nil then
            local x, _, z = getWorldTranslation(point)
            sumX = sumX + (x or 0)
            sumZ = sumZ + (z or 0)
            count = count + 1
        end
    end

    if count <= 0 then
        return 0, 0
    end

    return sumX / count, sumZ / count
end

local function getFieldKey(field)
    if field == nil then
        return nil
    end

    if field.getId ~= nil then
        local fieldId = field:getId()
        if fieldId ~= nil then
            return fieldId
        end
    end

    if field.fieldId ~= nil then
        return field.fieldId
    end

    return field.farmland and field.farmland.id or nil
end

local function getFieldKeyAtXZ(x, z)
    if x == nil or z == nil or g_fieldManager == nil or g_fieldManager.getFields == nil then
        return nil
    end

    for _, field in ipairs(g_fieldManager:getFields()) do
        local polygon = getFieldPolygon2D(field)
        if polygon ~= nil and polygon:getIsPosInside(x, z) then
            return getFieldKey(field)
        end
    end

    return nil
end

local function installDiseaseSaveHooks()
    if g_diseaseSaveHooksInstalled then
        return
    end

    if ItemSystem ~= nil and ItemSystem.save ~= nil then
        ItemSystem.save = Utils.prependedFunction(ItemSystem.save, function(_, _, _)
            if DiseaseCore ~= nil and DiseaseCore.saveToSavegame ~= nil then
                DiseaseCore._savePath = getDiseaseSaveXmlPath()
                DiseaseCore:saveToSavegame()
            end
            if DiseaseGrid ~= nil and DiseaseGrid.saveNow ~= nil then
                DiseaseGrid.savePath = getDiseaseMaskSavePath() or DiseaseGrid.savePath
                DiseaseGrid:saveNow(true)
            end
            if DiseaseProtectionGrid ~= nil and DiseaseProtectionGrid.saveNow ~= nil then
                DiseaseProtectionGrid.savePath = getDiseaseMaskSavePath() ~= nil
                    and string.gsub(getDiseaseMaskSavePath(), "cropThreatMask%.grle$", "cropProtectionMask.grle")
                    or DiseaseProtectionGrid.savePath
                DiseaseProtectionGrid:saveNow(true)
            end
        end)
    end

    if ItemSystem ~= nil and ItemSystem.loadItems ~= nil then
        ItemSystem.loadItems = Utils.prependedFunction(ItemSystem.loadItems, function(_, _, ...)
            if DiseaseGrid ~= nil then
                DiseaseGrid.savePath = getDiseaseMaskSavePath() or DiseaseGrid.savePath
            end
            if DiseaseCore ~= nil then
                DiseaseCore._savePath = getDiseaseSaveXmlPath()
                if DiseaseCore.loadFromSavegame ~= nil then
                    DiseaseCore:loadFromSavegame()
                end
            end
        end)
    end

    g_diseaseSaveHooksInstalled = true
end

local function getFarmlandIdFromWorkAreas(tool)
    if tool == nil or g_farmlandManager == nil then
        return nil
    end

    local specWorkArea = tool.spec_workArea
    local workAreas = specWorkArea and specWorkArea.workAreas or tool.workAreas
    if workAreas == nil then
        return nil
    end

    for _, workArea in pairs(workAreas) do
        if workArea ~= nil and workArea.start ~= nil and workArea.width ~= nil and workArea.height ~= nil then
            local sx, _, sz = getWorldTranslation(workArea.start)
            local wx, _, wz = getWorldTranslation(workArea.width)
            local hx, _, hz = getWorldTranslation(workArea.height)
            local cx = (sx + wx + hx) / 3
            local cz = (sz + wz + hz) / 3
            local fieldKey = getFieldKeyAtXZ(cx, cz)
            if fieldKey ~= nil then
                return fieldKey
            end
        end
    end

    return nil
end

local function getReferenceWorldPosition()
    local mission = g_currentMission
    if mission == nil then
        return nil, nil, nil
    end

    local localPlayer = g_localPlayer
    if localPlayer ~= nil then
        if localPlayer.rootNode ~= nil then
            return getWorldTranslation(localPlayer.rootNode)
        end

        if localPlayer.getCurrentVehicle ~= nil then
            local vehicle = localPlayer:getCurrentVehicle()
            if vehicle ~= nil then
                local node = vehicle.rootNode or (vehicle.components and vehicle.components[1] and vehicle.components[1].node)
                if node ~= nil then
                    return getWorldTranslation(node)
                end
            end
        end
    end

    local player = mission.player
    if player ~= nil and player.rootNode ~= nil then
        return getWorldTranslation(player.rootNode)
    end

    local controlledVehicle = mission.controlledVehicle
    if controlledVehicle ~= nil then
        local node = controlledVehicle.rootNode or (controlledVehicle.components and controlledVehicle.components[1] and controlledVehicle.components[1].node)
        if node ~= nil then
            return getWorldTranslation(node)
        end
    end

    local controlledObject = mission.controlledObject
    if controlledObject ~= nil and controlledObject.rootNode ~= nil then
        return getWorldTranslation(controlledObject.rootNode)
    end

    local cameraManager = g_cameraManager
    if cameraManager ~= nil and cameraManager.getActiveCamera ~= nil then
        local camera = cameraManager:getActiveCamera()
        local cameraNode = camera and (camera.cameraNode or camera.rootNode or camera)
        if cameraNode ~= nil then
            return getWorldTranslation(cameraNode)
        end
    end

    return nil, nil, nil
end

local function getFarmlandIdUnderPlayer()
    local x, _, z = getReferenceWorldPosition()
    if x == nil then return nil end
    return getFieldKeyAtXZ(x, z)
end

local function getFieldByFarmlandId(farmlandId)
    if farmlandId == nil or g_fieldManager == nil then
        return nil
    end

    if g_fieldManager.getFieldById ~= nil then
        local direct = g_fieldManager:getFieldById(farmlandId)
        if direct ~= nil then
            return direct
        end
    end

    if g_fieldManager.getFields == nil then
        return nil
    end

    for _, field in ipairs(g_fieldManager:getFields()) do
        if getFieldKey(field) == farmlandId then
            return field
        end
    end

    return nil
end

local function getFruitNameByIndex(fruitIndex)
    if fruitIndex == nil or fruitIndex == FruitType.UNKNOWN or g_fruitTypeManager == nil then return nil end
    local desc = g_fruitTypeManager:getFruitTypeByIndex(fruitIndex)
    return desc and desc.name or nil
end

local function createModifier(levelType)
    local fgs = g_currentMission and g_currentMission.fieldGroundSystem
    if fgs == nil then return nil, nil end
    local mapId, first, channels = fgs:getDensityMapData(levelType)
    if mapId == nil then return nil, nil end
    return DensityMapModifier.new(mapId, first, channels, g_terrainNode), fgs:getMaxValue(levelType) or 0
end

local function executeAverageOnField(field, modifier, maxValue)
    if field == nil or modifier == nil or maxValue == nil or maxValue <= 0 then return nil, 0 end
    field:getDensityMapPolygon():applyToModifier(modifier)
    local sum, touchedPixels, totalPixels = modifier:executeGet()
    local pixels = totalPixels or touchedPixels or 0
    if pixels <= 0 then return nil, 0 end
    return clamp(safeDiv(sum or 0, pixels * maxValue, 0), 0, 1), pixels
end

local function classifyGrowthWindow(desc, growthState)
    -- A valid fruit at growth state 0 is the freshly sown first stage for
    -- several crops/maps. FruitType.UNKNOWN, not the numeric state alone,
    -- determines that there is no crop.
    if desc == nil or growthState == nil or growthState < 0 then return nil, false end
    if desc.getIsWithered ~= nil and desc:getIsWithered(growthState) then return nil, false end
    if desc.getIsCut ~= nil and desc:getIsCut(growthState) then return nil, false end
    if desc.getIsPreparable ~= nil and desc:getIsPreparable(growthState) then return "harvest", true end
    if desc.preparedGrowthState ~= nil and desc.preparedGrowthState >= 0 and desc.preparedGrowthState == growthState then return nil, false end
    if desc.getIsGrowing ~= nil and desc:getIsGrowing(growthState) then
        local maxGrowing = math.max(1, nz(desc.minHarvestingGrowthState, 2) - 1)
        local ratio = clamp(safeDiv(math.max(growthState - 1, 0), math.max(maxGrowing - 1, 1), 0), 0, 1)
        if ratio < 0.34 then return "early", true end
        if ratio < 0.68 then return "mid", true end
        return "late", true
    end
    if nz(desc.minHarvestingGrowthState, 0) > 0 and growthState >= desc.minHarvestingGrowthState and growthState <= nz(desc.maxHarvestingGrowthState, desc.minHarvestingGrowthState) then
        return "harvest", true
    end
    return nil, false
end

local function getFieldSnapshot(field)
    local mission = g_currentMission
    local info = mission and mission.missionInfo
    if field == nil or mission == nil or info == nil then return nil end

    local fieldState = field:getFieldState()
    if fieldState == nil then
        return nil
    end

    local fruitIndex = fieldState.fruitTypeIndex or FruitType.UNKNOWN
    local growthState = fieldState.growthState or 0
    local fruitDesc = g_fruitTypeManager and g_fruitTypeManager:getFruitTypeByIndex(fruitIndex) or nil
    local growthWindow, isActiveCrop = classifyGrowthWindow(fruitDesc, growthState)

    local spray, plow, lime, weed, stubble, roller = 1, 1, 1, 0, 0, 0
    if fieldState.getHarvestScaleFactors ~= nil then
        spray, plow, lime, weed, stubble, roller = fieldState:getHarvestScaleFactors()
    end

    local water = 0
    local fgs = mission.fieldGroundSystem
    local waterMax = fgs and fgs.getMaxValue and fgs:getMaxValue(FieldDensityMap.WATER_LEVEL) or 0
    if waterMax ~= nil and waterMax > 0 then
        water = clamp((fieldState.waterLevel or 0) / waterMax, 0, 1)
    end

    return {
        fruitIndex = fruitIndex, fruitName = fruitDesc and fruitDesc.name or nil, growthState = growthState,
        growthWindow = growthWindow, isActiveCrop = isActiveCrop, pixels = 1,
        spray = clamp(spray or 1, 0, 1), lime = clamp(lime or 1, 0, 1), plow = clamp(plow or 1, 0, 1),
        rollerNeed = clamp(1 - (roller or 1), 0, 1), stubble = clamp(1 - (stubble or 1), 0, 1),
        water = water, weed = clamp(1 - (weed or 1), 0, 1),
        missionWeedsEnabled = info.weedsEnabled == true, missionLimeRequired = info.limeRequired == true,
        missionPlowingRequired = info.plowingRequiredEnabled == true,
        platformRolling = Platform.gameplay.useRolling == true, platformStubble = Platform.gameplay.useStubbleShred == true
    }
end

local function buildRiskComponents(snapshot)
    return {
        spray = clamp(1 - nz(snapshot.spray, 0), 0, 1),
        lime = clamp(snapshot.missionLimeRequired and (1 - nz(snapshot.lime, 1)) or 0, 0, 1),
        plow = clamp(snapshot.missionPlowingRequired and (1 - nz(snapshot.plow, 1)) or 0, 0, 1),
        roller = clamp(snapshot.platformRolling and nz(snapshot.rollerNeed, 0) or 0, 0, 1),
        weed = clamp(snapshot.missionWeedsEnabled and nz(snapshot.weed, 0) or 0, 0, 1),
        stubble = clamp(snapshot.platformStubble and nz(snapshot.stubble, 0) or 0, 0, 1),
        water = clamp(nz(snapshot.water, 0), 0, 1)
    }
end

local function calculateThreatRisk(snapshot)
    local defs = getDefs()
    local result = {risk = 0, bestKey = nil, bestCategory = nil, scores = {}, components = buildRiskComponents(snapshot or {})}
    if defs == nil or defs.THREATS == nil or snapshot == nil or not snapshot.isActiveCrop then return result end
    for key, threat in pairs(defs.THREATS) do
        if defs:isThreatCompatible(key, snapshot.fruitName, snapshot.growthWindow) then
            local totalWeight, weighted = 0, 0
            for comp, weight in pairs(threat.weights or {}) do
                totalWeight = totalWeight + math.abs(weight)
                weighted = weighted + nz(result.components[comp], 0) * weight
            end
            local risk = clamp(safeDiv(weighted, totalWeight, 0), 0, 1)
            if threat.category == "disease" and snapshot.growthWindow == "harvest" then risk = clamp(risk + 0.05, 0, 1) end
            if threat.category == "pest" and snapshot.growthWindow == "early" then risk = clamp(risk + 0.03, 0, 1) end
            result.scores[key] = round3(risk)
            if risk > result.risk then
                result.risk = risk
                result.bestKey = key
                result.bestCategory = threat.category
            end
        end
    end
    result.risk = round3(result.risk)
    return result
end

local function getCompatibleThreatKeys(snapshot)
    local defs = getDefs()
    if defs == nil or defs.THREATS == nil or snapshot == nil or not snapshot.isActiveCrop then
        return {}
    end

    if defs.getCompatibleThreatKeys ~= nil then
        return defs:getCompatibleThreatKeys(snapshot.fruitName, snapshot.growthWindow)
    end

    local keys = {}
    for key, _ in pairs(defs.THREATS) do
        if defs:isThreatCompatible(key, snapshot.fruitName, snapshot.growthWindow) then
            table.insert(keys, key)
        end
    end

    table.sort(keys)
    return keys
end

local function getPlayerFarmId()
    local mission = g_currentMission
    if mission ~= nil and mission.getFarmId ~= nil then
        local farmId = mission:getFarmId()
        if farmId ~= nil then
            return farmId
        end
    end

    if g_localPlayer ~= nil then
        if g_localPlayer.getFarmId ~= nil then
            return g_localPlayer:getFarmId()
        end

        if g_localPlayer.farmId ~= nil then
            return g_localPlayer.farmId
        end
    end

    return nil
end

local function getOwnedFarmIdForField(field)
    if field == nil then
        return nil
    end

    if field.getOwnerFarmId ~= nil then
        return field:getOwnerFarmId()
    end

    local farmlandId = field.farmland and field.farmland.id or nil
    if farmlandId == nil or g_farmlandManager == nil or g_farmlandManager.getFarmlandOwner == nil then
        return nil
    end

    return g_farmlandManager:getFarmlandOwner(farmlandId)
end

local function getIsUserOwnedFarm(farmId)
    if farmId == nil or farmId == FarmlandManager.NO_OWNER_FARM_ID or g_farmManager == nil or g_farmManager.getFarmById == nil then
        return false
    end

    local farm = g_farmManager:getFarmById(farmId)
    if farm == nil then
        return false
    end

    return farm.isSpectator ~= true
end

local function getIsPlayerOwnedField(field)
    local fieldFarmId = getOwnedFarmIdForField(field)
    return getIsUserOwnedFarm(fieldFarmId)
end

local function getFieldBehaviorProfile(field, tuning)
    tuning = tuning or getTuning()
    local isPlayerField = field ~= nil and getIsPlayerOwnedField(field)
    if isPlayerField then
        return {
            isPlayerField = true,
            infectionChanceMultiplier = nz(tuning.playerInfectionChanceMultiplier, 1.35),
            infectionSpotsMultiplier = nz(tuning.playerInfectionSpotsMultiplier, 1.50),
            infectionRadiusMultiplier = nz(tuning.playerInfectionRadiusMultiplier, 1.15),
            spreadSeedsMultiplier = nz(tuning.playerSpreadSeedsMultiplier, 1.35),
            spreadSpawnMultiplier = nz(tuning.playerSpreadSpawnMultiplier, 1.40),
            stageExpandDelayHoursMin = nz(tuning.stageExpandDelayHoursMin, 5.0),
            stageExpandDelayHoursMax = nz(tuning.stageExpandDelayHoursMax, 12.0),
            stage3RepeatExpandHoursMin = nz(tuning.stage3RepeatExpandHoursMin, 3.0),
            stage3RepeatExpandHoursMax = nz(tuning.stage3RepeatExpandHoursMax, 5.0),
            stage3DamageTickHours = nz(tuning.stage3DamageTickHours, 6.0)
        }
    end

    return {
        isPlayerField = false,
        infectionChanceMultiplier = nz(tuning.npcInfectionChanceMultiplier, 0.60),
        infectionSpotsMultiplier = nz(tuning.npcInfectionSpotsMultiplier, 0.75),
        infectionRadiusMultiplier = nz(tuning.npcInfectionRadiusMultiplier, 0.90),
        spreadSeedsMultiplier = nz(tuning.npcSpreadSeedsMultiplier, 0.75),
        spreadSpawnMultiplier = nz(tuning.npcSpreadSpawnMultiplier, 0.70),
        stageExpandDelayHoursMin = nz(tuning.npcStageExpandDelayHoursMin, 7.0),
        stageExpandDelayHoursMax = nz(tuning.npcStageExpandDelayHoursMax, 14.0),
        stage3RepeatExpandHoursMin = nz(tuning.stage3RepeatExpandHoursMin, 3.0),
        stage3RepeatExpandHoursMax = nz(tuning.stage3RepeatExpandHoursMax, 5.0),
        stage3DamageTickHours = nz(tuning.stage3DamageTickHours, 6.0)
    }
end

local function getCurrentDayToken()
    local environment = g_currentMission and g_currentMission.environment
    if environment == nil then
        return nil
    end

    return environment.currentMonotonicDay or environment.currentDay
end

local function normalizeProtectionCategory(category)
    if category == "disease" or category == "pest" then
        return category
    end

    return nil
end

local function isEntryProtectedFromCategory(entry, category, dayToken)
    if entry == nil then
        return false
    end

    local protectedUntilDay = tonumber(entry.protectedUntilDay or 0) or 0
    if protectedUntilDay <= 0 or (tonumber(dayToken or 0) or 0) > protectedUntilDay then
        return false
    end

    local protectedCategory = normalizeProtectionCategory(entry.protectedCategory)
    local threatCategory = normalizeProtectionCategory(category)
    return protectedCategory == nil or threatCategory == nil or protectedCategory == threatCategory
end

local function getCurrentGameHours()
    local environment = g_currentMission and g_currentMission.environment
    if environment == nil then
        return nil
    end

    local dayToken = environment.currentMonotonicDay or environment.currentDay
    if dayToken == nil then
        return nil
    end

    local dayTime = tonumber(environment.dayTime or 0) or 0
    local dayHours
    if dayTime > 1440 then
        dayHours = dayTime / 3600000
    elseif dayTime > 48 then
        dayHours = dayTime / 60
    else
        dayHours = dayTime
    end

    return dayToken * 24 + clamp(dayHours, 0, 24)
end

local function getCurrentHoursInDay()
    local environment = g_currentMission and g_currentMission.environment
    if environment == nil then
        return nil
    end

    local dayTime = tonumber(environment.dayTime or 0) or 0
    local dayHours
    if dayTime > 1440 then
        dayHours = dayTime / 3600000
    elseif dayTime > 48 then
        dayHours = dayTime / 60
    else
        dayHours = dayTime
    end

    return clamp(dayHours, 0, 24)
end

local function getCompatibleDiseaseThreatKeys(snapshot)
    local defs = getDefs()
    local keys = getCompatibleThreatKeys(snapshot)
    local diseases = {}

    for _, key in ipairs(keys) do
        local threat = defs and defs.getThreat and defs:getThreat(key) or nil
        if threat ~= nil and threat.category == "disease" then
            table.insert(diseases, key)
        end
    end

    return diseases
end

local function buildSavePath()
    local info = g_currentMission and g_currentMission.missionInfo
    local dir = info and info.savegameDirectory or nil
    if dir == nil or dir == "" then return nil end
    return dir .. "/cropDiseases.xml"
end

function DiseaseCore:getOrCreateEntry(farmlandId)
    local entry = self.fields[farmlandId]
    if entry == nil then
        entry = {
            threatKey = nil, category = nil, state = 0, cropActive = false, sessionId = 0,
            activeDays = 0, exposureDays = 0, neglectDays = 0, healStreak = 0,
            protectedUntilDay = 0, protectedCategory = nil, lastTreatedDay = nil,
            seasonThreatCount = 0, seasonThreatLimit = nil, seasonThreatHistory = "", lastSeasonThreatKey = nil,
            lastRisk = 0, lastThreatRisk = 0, lastFruitIndex = nil, lastGrowthState = 0, lastGrowthWindow = nil, lastScan = nil,
            stageHours = 0, stageExpanded = false, stageExpandDelayHours = 0, damageReady = false, stage3DamageDelayHours = 0,
            stage3NextExpandHours = 0, stage3DamageHoursToday = 0, stage3DamageDay = nil,
            spreadScheduleDay = nil, spreadsPerDay = 0, spreadsDoneToday = 0, nextSpreadHourOfDay = nil, nextDamageHourOfDay = nil
        }
        self.fields[farmlandId] = entry
    end
    return entry
end

function DiseaseCore:getSeasonThreatHistory(entry)
    if entry == nil then
        return {}
    end

    if type(entry.seasonThreatHistory) == "table" then
        return entry.seasonThreatHistory
    end

    return splitCsv(entry.seasonThreatHistory)
end

function DiseaseCore:setSeasonThreatHistory(entry, history)
    if entry ~= nil then
        entry.seasonThreatHistory = joinCsv(history)
    end
end

function DiseaseCore:getSeasonThreatFrequencyRange(tuning)
    local value = tostring((tuning or getTuning()).seasonalThreatFrequency or "1-2")
    local minValue, maxValue = string.match(value, "^%s*(%d+)%s*%-%s*(%d+)%s*$")
    if minValue == nil then
        minValue = string.match(value, "^%s*(%d+)%s*$")
        maxValue = minValue
    end

    local minCount = math.max(0, tonumber(minValue or 1) or 1)
    local maxCount = math.max(minCount, tonumber(maxValue or minCount) or minCount)
    return minCount, maxCount
end

function DiseaseCore:rollSeasonThreatLimit(tuning)
    local minCount, maxCount = self:getSeasonThreatFrequencyRange(tuning)
    if maxCount <= minCount then
        return minCount
    end

    return math.random(minCount, maxCount)
end

function DiseaseCore:getSeasonThreatLimit(entry, tuning)
    if entry == nil then
        return 0
    end

    if entry.seasonThreatLimit == nil then
        entry.seasonThreatLimit = self:rollSeasonThreatLimit(tuning)
    end

    return math.max(0, tonumber(entry.seasonThreatLimit or 0) or 0)
end

function DiseaseCore:getRemainingSeasonThreats(entry, tuning)
    return math.max(0, self:getSeasonThreatLimit(entry, tuning) - math.max(0, tonumber(entry.seasonThreatCount or 0) or 0))
end

function DiseaseCore:hasSeasonSeenThreat(entry, threatKey)
    if entry == nil or threatKey == nil then
        return false
    end

    return contains(self:getSeasonThreatHistory(entry), threatKey)
end

function DiseaseCore:canStartSeasonThreat(entry, threatKey, tuning)
    if entry == nil or threatKey == nil then
        return false
    end

    if self:getRemainingSeasonThreats(entry, tuning) <= 0 then
        return false
    end

    if entry.lastSeasonThreatKey == threatKey or self:hasSeasonSeenThreat(entry, threatKey) then
        return false
    end

    return true
end

function DiseaseCore:registerSeasonThreat(entry, threatKey)
    if entry == nil or threatKey == nil or threatKey == "" then
        return
    end

    local history = self:getSeasonThreatHistory(entry)
    if not contains(history, threatKey) then
        table.insert(history, threatKey)
        self:setSeasonThreatHistory(entry, history)
    end

    entry.lastSeasonThreatKey = threatKey
    entry.seasonThreatCount = math.max(0, tonumber(entry.seasonThreatCount or 0) or 0) + 1
end

function DiseaseCore:getBestSeasonThreatCandidate(entry, snapshot, riskData, tuning)
    local defs = getDefs()
    if defs == nil or riskData == nil or riskData.scores == nil then
        return nil, nil, 0
    end

    local bestKey = nil
    local bestRisk = 0
    for _, threatKey in ipairs(getCompatibleThreatKeys(snapshot) or {}) do
        local risk = tonumber(riskData.scores[threatKey] or 0) or 0
        if risk > bestRisk and self:canStartSeasonThreat(entry, threatKey, tuning) then
            bestKey = threatKey
            bestRisk = risk
        end
    end

    if bestKey == nil then
        return nil, nil, 0
    end

    return bestKey, defs.getThreatCategory ~= nil and defs:getThreatCategory(bestKey) or nil, bestRisk
end

function DiseaseCore:resetThreatTimers(entry)
    entry.stageHours = 0
    entry.stageExpanded = false
    entry.stageExpandDelayHours = 0
    entry.damageReady = false
    entry.stage3DamageDelayHours = 0
    entry.stage3NextExpandHours = 0
    entry.stage3DamageHoursToday = 0
    entry.stage3DamageDay = nil
    entry.spreadScheduleDay = nil
    entry.spreadsPerDay = 0
    entry.spreadsDoneToday = 0
    entry.nextSpreadHourOfDay = nil
    entry.nextDamageHourOfDay = nil
end

function DiseaseCore:getDailySpreadConfig(entry, field)
    local state = nz(entry and entry.state, 0)
    local isPlayerField = field ~= nil and getIsPlayerOwnedField(field) == true

    if isPlayerField then
        if state >= 3 then
            return {
                spreadsMin = 4,
                spreadsMax = 6,
                firstDelayMin = 0.75,
                firstDelayMax = 2.25,
                nextDelayMin = 2.75,
                nextDelayMax = 4.25,
                damageDelayMin = 1.00,
                damageDelayMax = 2.50
            }
        end

        return {
            spreadsMin = 3,
            spreadsMax = 5,
            firstDelayMin = 0.75,
            firstDelayMax = 2.50,
            nextDelayMin = 3.00,
            nextDelayMax = 6.00,
            damageDelayMin = 0.00,
            damageDelayMax = 0.00
        }
    end

    return {
        spreadsMin = 2,
        spreadsMax = 4,
        firstDelayMin = 1.50,
        firstDelayMax = 4.00,
        nextDelayMin = 4.00,
        nextDelayMax = 7.00,
        damageDelayMin = 0.00,
        damageDelayMax = 0.00
    }
end

function DiseaseCore:resetDailySpreadSchedule(entry, currentHoursInDay, field)
    if entry == nil then
        return
    end

    local nowHour = clamp(tonumber(currentHoursInDay or getCurrentHoursInDay() or 0) or 0, 0, 24)
    local remainingHours = math.max(0.25, 24 - nowHour)
    local cfg = self:getDailySpreadConfig(entry, field)
    local spreadsPerDay = math.random(cfg.spreadsMin, cfg.spreadsMax)
    local maxPossible = math.max(1, math.floor(math.max(0, remainingHours - cfg.firstDelayMin) / math.max(cfg.nextDelayMin, 0.25)) + 1)

    entry.spreadScheduleDay = getCurrentDayToken()
    entry.spreadsPerDay = math.max(1, math.min(spreadsPerDay, maxPossible))
    entry.spreadsDoneToday = 0

    local firstGapMin = math.min(cfg.firstDelayMin, math.max(0.20, remainingHours - 0.10))
    local firstGapMax = math.min(cfg.firstDelayMax, math.max(firstGapMin, remainingHours - 0.05))
    if firstGapMax <= 0 then
        entry.nextSpreadHourOfDay = nil
    else
        entry.nextSpreadHourOfDay = math.min(23.85, nowHour + MathUtil.randomFloat(firstGapMin, firstGapMax))
    end
    entry.nextDamageHourOfDay = nil
end

function DiseaseCore:scheduleNextSpreadForToday(entry, currentHoursInDay, field)
    if entry == nil then
        return
    end

    local nowHour = clamp(tonumber(currentHoursInDay or getCurrentHoursInDay() or 0) or 0, 0, 24)
    local remainingEvents = math.max(0, (entry.spreadsPerDay or 0) - (entry.spreadsDoneToday or 0))
    local cfg = self:getDailySpreadConfig(entry, field)
    if remainingEvents <= 0 or nowHour >= 23.75 then
        entry.nextSpreadHourOfDay = nil
        return
    end

    local remainingHours = math.max(0.2, 24 - nowHour)
    local maxGapToFitDay = remainingHours - math.max(0, remainingEvents - 1) * math.max(cfg.nextDelayMin, 0.25)
    if maxGapToFitDay <= 0.10 then
        entry.nextSpreadHourOfDay = nil
        return
    end

    local gapMin = math.min(cfg.nextDelayMin, maxGapToFitDay)
    local gapMax = math.min(cfg.nextDelayMax, maxGapToFitDay)
    if gapMax < gapMin then
        gapMin = math.max(0.20, math.min(gapMin, gapMax))
    end

    if gapMax <= 0 then
        entry.nextSpreadHourOfDay = nil
        return
    end

    entry.nextSpreadHourOfDay = math.min(23.90, nowHour + MathUtil.randomFloat(gapMin, gapMax))
end

function DiseaseCore:scheduleStage3DamageAfterSpread(entry, currentHoursInDay, field)
    if entry == nil or nz(entry.state, 0) < 3 then
        return
    end

    local nowHour = clamp(tonumber(currentHoursInDay or getCurrentHoursInDay() or 0) or 0, 0, 24)
    local cfg = self:getDailySpreadConfig(entry, field)
    local minDelay = math.max(0.35, cfg.damageDelayMin or 0.75)
    local maxDelay = math.max(minDelay, cfg.damageDelayMax or 2.25)
    entry.nextDamageHourOfDay = math.min(23.95, nowHour + MathUtil.randomFloat(minDelay, maxDelay))
    if entry.nextDamageHourOfDay <= nowHour then
        entry.nextDamageHourOfDay = nil
    end
end

function DiseaseCore:beginThreatStage(entry, state, field)
    local isPlayerField = field ~= nil and getIsPlayerOwnedField(field) == true
    entry.stageHours = 0
    entry.damageReady = false
    if nz(state, 0) >= 3 then
        entry.stageExpanded = false
        entry.stageExpandDelayHours = 0
        if isPlayerField then
            entry.stage3NextExpandHours = MathUtil.randomFloat(2.8, 3.2)
            entry.stage3DamageDelayHours = nz(getTuning().stage3DamageTickHours, 6.0)
        else
            entry.stage3NextExpandHours = 0
            entry.stage3DamageDelayHours = 0
        end
        entry.stage3DamageHoursToday = 0
        entry.stage3DamageDay = getCurrentDayToken()
    else
        entry.stageExpanded = false
        if isPlayerField then
            entry.stageExpandDelayHours = MathUtil.randomFloat(3.0, 6.0)
        else
            entry.stageExpandDelayHours = 0
        end
        if isPlayerField and nz(state, 0) >= 2 then
            entry.stage3DamageDelayHours = nz(getTuning().stage2DamageTickHours, 12.0)
        else
            entry.stage3DamageDelayHours = 0
        end
        entry.stage3NextExpandHours = 0
        entry.stage3DamageHoursToday = 0
        entry.stage3DamageDay = nz(state, 0) >= 2 and getCurrentDayToken() or nil
    end
    debugLog("begin stage: state=%d expandIn=%.2fh repeatExpandIn=%.2fh damageTick=%.2fh",
        nz(state, 0), nz(entry.stageExpandDelayHours, 0), nz(entry.stage3NextExpandHours, 0), nz(entry.stage3DamageDelayHours, 0))
end

function DiseaseCore:debugLog(...)
    debugLog(...)
end

function DiseaseCore:getFieldBehaviorProfile(field, tuning)
    return getFieldBehaviorProfile(field, tuning)
end

function DiseaseCore:getCurrentDayToken()
    return getCurrentDayToken()
end

function DiseaseCore:getCropDisplayName(field, snapshot)
    snapshot = snapshot or (field ~= nil and getFieldSnapshot(field) or nil)
    if snapshot == nil then
        return getI18nTextSafe("bmp_threat_noCrop", "no active crop")
    end

    local fillTypeIndex = g_fruitTypeManager ~= nil and g_fruitTypeManager.getFillTypeByFruitTypeIndex ~= nil
        and g_fruitTypeManager:getFillTypeByFruitTypeIndex(snapshot.fruitIndex)
        or nil
    if fillTypeIndex ~= nil and g_fillTypeManager ~= nil then
        if g_fillTypeManager.getFillTypeTitleByIndex ~= nil then
            local title = g_fillTypeManager:getFillTypeTitleByIndex(fillTypeIndex)
            if title ~= nil and title ~= "" then
                return title
            end
        elseif g_fillTypeManager.getFillTypeByIndex ~= nil then
            local fillType = g_fillTypeManager:getFillTypeByIndex(fillTypeIndex)
            if fillType ~= nil and fillType.title ~= nil and fillType.title ~= "" then
                return fillType.title
            end
        end
    end

    return tostring(snapshot.fruitName or "?")
end

function DiseaseCore:showThreatNotificationText(text)
    if g_dedicatedServer == true or text == nil or text == "" or g_currentMission == nil or g_currentMission.addIngameNotification == nil or FSBaseMission == nil then
        return
    end

    local notificationType = FSBaseMission.INGAME_NOTIFICATION_CRITICAL
        or FSBaseMission.INGAME_NOTIFICATION_WARNING
        or FSBaseMission.INGAME_NOTIFICATION_INFO

    if notificationType ~= nil then
        g_currentMission:addIngameNotification(notificationType, text)
    end
end

function DiseaseCore:showThreatNotification(farmlandId, entry, field, kind, oldState, newState)
    if entry == nil or entry.threatKey == nil or nz(entry.state, 0) <= 0 then
        return
    end

    field = field or getFieldByFarmlandId(farmlandId)
    local ownerFarmId = field ~= nil and getOwnedFarmIdForField(field) or nil
    if field ~= nil and not getIsUserOwnedFarm(ownerFarmId) then
        return
    end

    local snapshot = field ~= nil and getFieldSnapshot(field) or nil
    local threatText = getThreatDisplayName(entry.threatKey)
    local cropText = self:getCropDisplayName(field, snapshot)
    local severityText = getStateDisplayName(newState or entry.state)
    local templateKey = kind == "stage" and "bmp_alert_stage" or "bmp_alert_new"
    local fallback = kind == "stage"
        and "Crop threat got worse on field %s: %s, %s (%s)"
        or "New crop threat on field %s: %s, %s (%s)"
    local text = string.format(
        getI18nTextSafe(templateKey, fallback),
        tostring(farmlandId or "?"),
        cropText,
        threatText,
        severityText
    )

    if g_server ~= nil and DiseaseThreatNotificationEvent ~= nil and DiseaseThreatNotificationEvent.broadcast ~= nil then
        DiseaseThreatNotificationEvent.broadcast(text, ownerFarmId)
    end

    if g_dedicatedServer ~= true and (ownerFarmId == nil or getPlayerFarmId() == ownerFarmId) then
        self:showThreatNotificationText(text)
    end

    debugLog("threat notification: farmland=%s key=%s oldState=%s newState=%s kind=%s",
        tostring(farmlandId), tostring(entry.threatKey), tostring(oldState), tostring(newState), tostring(kind))
end

function DiseaseCore:clearThreat(entry)
    entry.threatKey = nil
    entry.category = nil
    entry.state = 0
    entry.exposureDays = 0
    entry.neglectDays = 0
    entry.healStreak = 0
    entry.lastThreatRisk = 0
    self:resetThreatTimers(entry)
end

function DiseaseCore:cancelPendingWorkForFarmland(farmlandId)
    if farmlandId == nil then return end
    local function removeJobs(list, indexTable)
        if list ~= nil then
            for i = #list, 1, -1 do
                local job = list[i]
                if job ~= nil and job.farmlandId == farmlandId then table.remove(list, i) end
            end
        end
        if indexTable ~= nil then indexTable[farmlandId] = nil end
    end
    removeJobs(self._pendingLayerJobs, self._pendingLayerByFarmland)
    removeJobs(self._pendingOutbreakJobs, self._pendingOutbreakByFarmland)
    removeJobs(self._pendingDamageJobs, self._pendingDamageByFarmland)
    if self._pendingDailyPromoteByFarmland ~= nil then self._pendingDailyPromoteByFarmland[farmlandId] = nil end
    if self._pausedSpreadByFarmland ~= nil then self._pausedSpreadByFarmland[farmlandId] = nil end
end

function DiseaseCore:updateEntrySnapshot(entry, snapshot, riskData)
    entry.lastFruitIndex = snapshot.fruitIndex
    entry.lastGrowthState = snapshot.growthState
    entry.lastGrowthWindow = snapshot.growthWindow
    entry.lastRisk = round3(riskData and riskData.risk or 0)
    entry.lastThreatRisk = round3((riskData and entry.threatKey and riskData.scores and riskData.scores[entry.threatKey]) or 0)
    entry.lastScan = {
        fruitName = snapshot.fruitName, growthState = snapshot.growthState, growthWindow = snapshot.growthWindow,
        spray = round3(snapshot.spray), lime = round3(snapshot.lime), plow = round3(snapshot.plow),
        rollerNeed = round3(snapshot.rollerNeed), weed = round3(snapshot.weed), stubble = round3(snapshot.stubble), water = round3(snapshot.water)
    }
end

function DiseaseCore:resetForInactiveCrop(entry, snapshot)
    self:clearThreat(entry)
    entry.cropActive = false
    entry.activeDays = 0
    entry.protectedUntilDay = 0
    entry.protectedCategory = nil
    entry.lastTreatedDay = nil
    entry.seasonThreatCount = 0
    entry.seasonThreatLimit = nil
    entry.seasonThreatHistory = ""
    entry.lastSeasonThreatKey = nil
    entry.lastRisk = 0
    entry.lastFruitIndex = snapshot and snapshot.fruitIndex or entry.lastFruitIndex
    entry.lastGrowthState = snapshot and snapshot.growthState or 0
    entry.lastGrowthWindow = snapshot and snapshot.growthWindow or nil
end

function DiseaseCore:isNewCropSession(entry, snapshot)
    if not snapshot.isActiveCrop then return false end
    if not entry.cropActive then return true end
    if entry.lastFruitIndex ~= snapshot.fruitIndex then return true end
    if nz(entry.lastGrowthState, 0) > 0 and snapshot.growthState + 1 < entry.lastGrowthState then return true end
    return false
end

function DiseaseCore:beginCropSession(entry, snapshot)
    self:clearThreat(entry)
    entry.cropActive = true
    entry.sessionId = nz(entry.sessionId, 0) + 1
    entry.activeDays = 0
    entry.protectedUntilDay = 0
    entry.protectedCategory = nil
    entry.lastTreatedDay = nil
    entry.seasonThreatCount = 0
    entry.seasonThreatLimit = self:rollSeasonThreatLimit(getTuning())
    entry.seasonThreatHistory = ""
    entry.lastSeasonThreatKey = nil
    entry.lastFruitIndex = snapshot.fruitIndex
    entry.lastGrowthState = snapshot.growthState
    entry.lastGrowthWindow = snapshot.growthWindow
end

function DiseaseCore:updateCropSessionWatch(dt)
    if g_server == nil or g_fieldManager == nil or g_fieldManager.getFields == nil or DiseaseGrid == nil then return end
    self._cropSessionWatchTimerMs = math.max(0, (self._cropSessionWatchTimerMs or 0) - (dt or 0))
    if self._cropSessionWatchTimerMs > 0 then return end
    self._cropSessionWatchTimerMs = 500

    local fields = g_fieldManager:getFields() or {}
    if #fields == 0 then return end
    local index = math.max(1, self._cropSessionWatchIndex or 1)
    local batchSize = math.min(12, #fields)
    for _ = 1, batchSize do
        if index > #fields then index = 1 end
        local field = fields[index]
        index = index + 1
        local farmlandId = field ~= nil and getFieldKey(field) or nil
        local snapshot = field ~= nil and getFieldSnapshot(field) or nil
        if farmlandId ~= nil and snapshot ~= nil then
            local entry = self:getOrCreateEntry(farmlandId)
            local becameInactive = entry.cropActive == true and snapshot.isActiveCrop ~= true
            local startedNewSession = snapshot.isActiveCrop == true and self:isNewCropSession(entry, snapshot)
            if becameInactive or startedNewSession then
                self:cancelPendingWorkForFarmland(farmlandId)
                DiseaseGrid:clearField(field)
                if DiseaseProtectionGrid ~= nil then DiseaseProtectionGrid:clearField(field) end
                if startedNewSession then
                    self:beginCropSession(entry, snapshot)
                    self:updateEntrySnapshot(entry, snapshot, calculateThreatRisk(snapshot))
                    debugLog("crop session changed immediately: farmland=%s fruit=%s growth=%s",
                        tostring(farmlandId), tostring(snapshot.fruitName or "none"), tostring(snapshot.growthState or 0))
                else
                    self:resetForInactiveCrop(entry, snapshot)
                    self:updateEntrySnapshot(entry, snapshot, {risk = 0, scores = {}})
                    debugLog("crop became inactive immediately: farmland=%s", tostring(farmlandId))
                end
            end
        end
    end
    self._cropSessionWatchIndex = index
end

function DiseaseCore:buildNetworkEntrySnapshot(farmlandId, entry)
    if farmlandId == nil or entry == nil then
        return nil
    end

    local normalizedCategory = getThreatCategorySafe(entry.threatKey, entry.category)
    if entry.threatKey ~= nil then
        entry.category = normalizedCategory
    end

    local scan = entry.lastScan or {}
    return {
        farmlandId = farmlandId,
        threatKey = entry.threatKey,
        category = normalizedCategory,
        state = entry.state or 0,
        cropActive = entry.cropActive == true,
        sessionId = entry.sessionId or 0,
        activeDays = entry.activeDays or 0,
        exposureDays = entry.exposureDays or 0,
        neglectDays = entry.neglectDays or 0,
        healStreak = entry.healStreak or 0,
        seasonThreatCount = entry.seasonThreatCount or 0,
        seasonThreatLimit = entry.seasonThreatLimit,
        seasonThreatHistory = entry.seasonThreatHistory,
        lastSeasonThreatKey = entry.lastSeasonThreatKey,
        protectedUntilDay = entry.protectedUntilDay or 0,
        protectedCategory = entry.protectedCategory,
        lastTreatedDay = entry.lastTreatedDay,
        lastRisk = entry.lastRisk or 0,
        lastThreatRisk = entry.lastThreatRisk or 0,
        lastFruitIndex = entry.lastFruitIndex,
        lastGrowthState = entry.lastGrowthState or 0,
        lastGrowthWindow = entry.lastGrowthWindow,
        fruitName = scan.fruitName,
        growthState = scan.growthState or 0,
        growthWindow = scan.growthWindow,
        spray = scan.spray or 0,
        lime = scan.lime or 0,
        plow = scan.plow or 0,
        rollerNeed = scan.rollerNeed or 0,
        weed = scan.weed or 0,
        stubble = scan.stubble or 0,
        water = scan.water or 0
    }
end

function DiseaseCore:getNetworkEntrySignature(snapshot)
    if snapshot == nil then
        return "nil"
    end

    return table.concat({
        tostring(snapshot.threatKey or ""),
        tostring(snapshot.category or ""),
        tostring(snapshot.state or 0),
        tostring(snapshot.cropActive == true),
        tostring(snapshot.sessionId or 0),
        tostring(snapshot.activeDays or 0),
        tostring(snapshot.exposureDays or 0),
        tostring(snapshot.neglectDays or 0),
        tostring(snapshot.healStreak or 0),
        tostring(snapshot.seasonThreatCount or 0),
        tostring(snapshot.seasonThreatLimit or 0),
        tostring(snapshot.seasonThreatHistory or ""),
        tostring(snapshot.lastSeasonThreatKey or ""),
        tostring(snapshot.protectedUntilDay or 0),
        tostring(snapshot.protectedCategory or ""),
        tostring(snapshot.lastTreatedDay or 0),
        string.format("%.4f", snapshot.lastRisk or 0),
        string.format("%.4f", snapshot.lastThreatRisk or 0),
        tostring(snapshot.lastFruitIndex or -1),
        tostring(snapshot.lastGrowthState or 0),
        tostring(snapshot.lastGrowthWindow or ""),
        tostring(snapshot.fruitName or ""),
        tostring(snapshot.growthState or 0),
        tostring(snapshot.growthWindow or ""),
        string.format("%.3f", snapshot.spray or 0),
        string.format("%.3f", snapshot.lime or 0),
        string.format("%.3f", snapshot.plow or 0),
        string.format("%.3f", snapshot.rollerNeed or 0),
        string.format("%.3f", snapshot.weed or 0),
        string.format("%.3f", snapshot.stubble or 0),
        string.format("%.3f", snapshot.water or 0)
    }, "|")
end

function DiseaseCore:collectChangedNetworkEntries(forceAll)
    local changed = {}
    local signatures = self._networkEntrySignatures or {}
    local seen = {}

    for farmlandId, entry in pairs(self.fields or {}) do
        local snapshot = self:buildNetworkEntrySnapshot(farmlandId, entry)
        local signature = self:getNetworkEntrySignature(snapshot)
        seen[farmlandId] = true

        if forceAll == true or signatures[farmlandId] ~= signature then
            table.insert(changed, snapshot)
            signatures[farmlandId] = signature
        end
    end

    for farmlandId, _ in pairs(signatures) do
        if not seen[farmlandId] then
            signatures[farmlandId] = nil
        end
    end

    self._networkEntrySignatures = signatures
    table.sort(changed, function(a, b)
        return (a.farmlandId or 0) < (b.farmlandId or 0)
    end)

    return changed
end

function DiseaseCore:applyNetworkEntrySnapshots(entries, replaceAll)
    self.fields = self.fields or {}

    if replaceAll == true then
        self.fields = {}
    end

    for _, snapshot in ipairs(entries or {}) do
        local farmlandId = snapshot.farmlandId
        if farmlandId ~= nil then
            self.fields[farmlandId] = {
                threatKey = snapshot.threatKey,
                category = getThreatCategorySafe(snapshot.threatKey, snapshot.category),
                state = snapshot.state or 0,
                cropActive = snapshot.cropActive == true,
                sessionId = snapshot.sessionId or 0,
                activeDays = snapshot.activeDays or 0,
                exposureDays = snapshot.exposureDays or 0,
                neglectDays = snapshot.neglectDays or 0,
                healStreak = snapshot.healStreak or 0,
                seasonThreatCount = snapshot.seasonThreatCount or 0,
                seasonThreatLimit = snapshot.seasonThreatLimit,
                seasonThreatHistory = snapshot.seasonThreatHistory or "",
                lastSeasonThreatKey = snapshot.lastSeasonThreatKey,
                protectedUntilDay = snapshot.protectedUntilDay or 0,
                protectedCategory = snapshot.protectedCategory,
                lastTreatedDay = snapshot.lastTreatedDay,
                lastRisk = snapshot.lastRisk or 0,
                lastThreatRisk = snapshot.lastThreatRisk or 0,
                lastFruitIndex = snapshot.lastFruitIndex,
                lastGrowthState = snapshot.lastGrowthState or 0,
                lastGrowthWindow = snapshot.lastGrowthWindow,
                lastScan = {
                    fruitName = snapshot.fruitName,
                    growthState = snapshot.growthState,
                    growthWindow = snapshot.growthWindow,
                    spray = snapshot.spray or 0,
                    lime = snapshot.lime or 0,
                    plow = snapshot.plow or 0,
                    rollerNeed = snapshot.rollerNeed or 0,
                    weed = snapshot.weed or 0,
                    stubble = snapshot.stubble or 0,
                    water = snapshot.water or 0
                }
            }
        end
    end

    if g_client ~= nil then
        self._networkFieldStateReceived = true
        self._networkHasFullSync = self._networkFieldStateReceived and self._networkGridSnapshotComplete
    end
end

function DiseaseCore:loadMap()
    self.fields = {}
    self._savePath = g_server ~= nil and buildSavePath() or nil
    self._hudHookedFarmland = false
    self._hudHookedField = false
    self._lastProcessedDay = nil
    self._lastGameHours = nil
    self._lastDayHours = nil
    self._surpriseInfectionPending = false
    self._surpriseInfectionTimerMs = 0
    self._surpriseInfectionDay = nil
    self._pendingFieldRescans = {}
    self._pendingFieldRescanTimerMs = 0
    self._pendingDailyDays = {}
    self._activeDailyCycle = nil
    self._dailyBatchSize = 1
    self._dailyBatchDelayMs = 0
    self._dailyBatchTimerMs = 0
    self._dailyTimerCooldownMs = 0
    self._backgroundWorkDelayMs = 18
    self._backgroundWorkTimerMs = 0
    self._layerJobRowsPerStep = 16
    self._layerJobStepDelayMs = 18
    self._layerJobStepTimerMs = 0
    self._pendingLayerJobs = {}
    self._pendingLayerByFarmland = {}
    self._pendingDailyPromoteByFarmland = {}
    self._pausedSpreadByFarmland = {}
    self._damageJobSpotsPerStep = 1
    self._damageJobStepDelayMs = 28
    self._damageJobStepTimerMs = 0
    self._pendingDamageJobs = {}
    self._pendingDamageByFarmland = {}
    self._pendingOutbreakJobs = {}
    self._pendingOutbreakByFarmland = {}
    self._networkSyncTimerMs = 0
    self._networkSyncIntervalMs = 350
    self._networkEntrySignatures = {}
    self._networkSnapshotBatchSize = 4096
    self._networkClientRequestTimerMs = 1500
    self._networkClientRequested = false
    self._networkFieldStateReceived = g_server ~= nil
    self._networkGridSnapshotComplete = g_server ~= nil
    self._networkHasFullSync = g_server ~= nil
    self._gridSanitizeJob = nil
    self._gridSanitizeTimerMs = 2000
    self._gridSanitizeStepIntervalMs = 100
    self._gridSanitizeFullIntervalMs = 10000
    self._gridSanitizeRowsPerStep = 8
    self._cropSessionWatchTimerMs = 500
    self._cropSessionWatchIndex = 1
    math.randomseed((g_currentMission and g_currentMission.time or 0) + (os and os.time and os.time() or 0))
    ensureCustomSprayTypes()
    ensureInsecticideSprayerMaterialHolder()
    hookInsecticideSprayerProcessing()
    hookCustomSprayDensityProcessing()
    hookCustomSprayAIArea()
    hookInsecticideSprayerEffects()
    installDiseaseSaveHooks()

    if PlayerHUDUpdater ~= nil then
        if PlayerHUDUpdater.fieldAddFarmland ~= nil and not self._hudHookedFarmland then
            PlayerHUDUpdater.fieldAddFarmland = Utils.appendedFunction(PlayerHUDUpdater.fieldAddFarmland, function(updater, data, box) if DiseaseHUD and DiseaseHUD.fieldAddFarmland then DiseaseHUD:fieldAddFarmland(updater, data, box) end end)
            self._hudHookedFarmland = true
        end
        if PlayerHUDUpdater.fieldAddField ~= nil and not self._hudHookedField then
            PlayerHUDUpdater.fieldAddField = Utils.appendedFunction(PlayerHUDUpdater.fieldAddField, function(updater, fieldState, box) if DiseaseHUD and DiseaseHUD.fieldAddField then DiseaseHUD:fieldAddField(updater, fieldState, box) end end)
            self._hudHookedField = true
        end
    end

    addConsoleCommand("gsDiseaseHerePrint", "Print crop threat at player position", "consoleHerePrint", self)
    addConsoleCommand("gsDiseaseHereSet", "Set crop threat at player position: gsDiseaseHereSet <key> <state0..3>", "consoleHereSet", self)
    addConsoleCommand("gsDiseaseList", "List farmlands that contain fields", "consoleList", self)
    addConsoleCommand("gsDiseasePrint", "Print crop threat by farmlandId", "consolePrint", self)
    addConsoleCommand("gsDiseaseSet", "Set crop threat by farmlandId", "consoleSetDisease", self)
    addConsoleCommand("gsDiseaseRandomizeAll", "Rebuild threats from current crop state", "consoleRandomizeAll", self)
    addConsoleCommand("gsDiseaseApplyDamageAll", "Apply damage patches on active infected crops", "consoleApplyDamageAll", self)
    addConsoleCommand("gsDiseaseForceAll", "Force all active crops to state", "consoleForceAll", self)
    addConsoleCommand("gsDiseaseForceHere", "Force active crop under player", "consoleForceHere", self)
    addConsoleCommand("gsDiseaseInfectHere", "Infect circle around player: gsDiseaseInfectHere <key> <state0..3> [radiusM]", "consoleInfectHere", self)
    addConsoleCommand("gsDiseaseRandomHere", "Apply random compatible threat around player: gsDiseaseRandomHere [state0..3] [radiusM]", "consoleRandomHere", self)
    addConsoleCommand("gsDiseaseDestroyHere", "Destroy crop around player in infected-style spots: gsDiseaseDestroyHere [radiusM] [count]", "consoleDestroyHere", self)
    addConsoleCommand("gsDiseaseClearHere", "Clear threat circle around player: gsDiseaseClearHere [radiusM]", "consoleClearHere", self)
    addConsoleCommand("gsDiseaseDebugLog", "Toggle crop disease debug log output: gsDiseaseDebugLog [0|1]", "consoleDebugLog", self)

    g_messageCenter:subscribe(MessageType.DAY_CHANGED, self.onDayChanged, self)
    g_messageCenter:subscribe(MessageType.FINISHED_GROWTH_PERIOD, self.onFinishedGrowthPeriod, self)

    if g_server ~= nil then
        self:loadFromSavegame()
        self:scanAllFields(false, false)
        self:collectChangedNetworkEntries(true)
    end

    self._lastProcessedDay = getCurrentDayToken()
    self._lastGameHours = g_server ~= nil and getCurrentGameHours() or nil
    self._lastDayHours = g_server ~= nil and getCurrentHoursInDay() or nil
end

function DiseaseCore:deleteMap()
    self:saveToSavegame()
    g_messageCenter:unsubscribeAll(self)
    removeConsoleCommand("gsDiseaseHerePrint")
    removeConsoleCommand("gsDiseaseHereSet")
    removeConsoleCommand("gsDiseaseList")
    removeConsoleCommand("gsDiseasePrint")
    removeConsoleCommand("gsDiseaseSet")
    removeConsoleCommand("gsDiseaseRandomizeAll")
    removeConsoleCommand("gsDiseaseApplyDamageAll")
    removeConsoleCommand("gsDiseaseForceAll")
    removeConsoleCommand("gsDiseaseForceHere")
    removeConsoleCommand("gsDiseaseInfectHere")
    removeConsoleCommand("gsDiseaseRandomHere")
    removeConsoleCommand("gsDiseaseDestroyHere")
    removeConsoleCommand("gsDiseaseClearHere")
    removeConsoleCommand("gsDiseaseDebugLog")
end

function DiseaseCore:onConnectionFinishedLoading(connection)
    if g_server ~= nil and connection ~= nil then
        self:sendFullStateToConnection(connection)
    end
end

function DiseaseCore:ensureSeedForFarmland(farmlandId)
    if farmlandId == nil then return end
    self:getOrCreateEntry(farmlandId)
    if g_server ~= nil then
        local field = getFieldByFarmlandId(farmlandId)
        if field ~= nil then self:scanField(field, false, false) end
    end
end

function DiseaseCore:ingestCareSnapshot(_, _, _) end
function DiseaseCore:advanceThreatsForNewDay()
    if DiseaseGrid == nil then
        debugLog("Disease daily advance skipped: missing DiseaseGrid")
        return
    end

    local tuning = getTuning()
    local defs = getDefs()
    local promoted = 0

    for farmlandId, entry in pairs(self.fields or {}) do
        local field = getFieldByFarmlandId(farmlandId)
        local snapshot = field ~= nil and getFieldSnapshot(field) or nil
        entry.cropActive = snapshot ~= nil and snapshot.isActiveCrop == true
        local oldState = nz(entry.state, 0)
        if field ~= nil and entry.cropActive and oldState > 0 then
            if getIsPlayerOwnedField(field) then
                if DiseasePlayerLogic ~= nil then
                    promoted = promoted + (DiseasePlayerLogic:advanceFieldForNewDay(self, farmlandId, entry, field, defs, tuning) or 0)
                end
            else
                if DiseaseNpcLogic ~= nil then
                    promoted = promoted + (DiseaseNpcLogic:advanceFieldForNewDay(self, farmlandId, entry, field, defs, tuning) or 0)
                end
            end
        end

        debugLog("Disease daily advance entry: farmland=%s cells=%d oldState=%d newState=%d key=%s active=%s",
            tostring(farmlandId), 0, oldState, entry.state or 0, tostring(entry.threatKey or "none"), tostring(entry.cropActive == true))
    end

    debugLog("Disease daily advance grid: promotedCells=%d", promoted or 0)
end

function DiseaseCore:applyDailyFieldDamage()
    local tuning = getTuning()
    local totalChanged, totalDestroyed = 0, 0
    if g_server == nil or DiseaseFieldDamage == nil or DiseaseGrid == nil then
        return totalChanged, 0, totalDestroyed
    end

    local sampledSpots = DiseaseGrid:getRandomInfectedSpots(320, 3)
    local grouped = {}
    for _, spot in ipairs(sampledSpots or {}) do
        local farmlandId = getFieldKeyAtXZ(spot.x, spot.z)
        if farmlandId ~= nil then
            grouped[farmlandId] = grouped[farmlandId] or {}
            table.insert(grouped[farmlandId], spot)
        end
    end

    for farmlandId, spots in pairs(grouped) do
        local entry = self.fields and self.fields[farmlandId] or nil
        local field = getFieldByFarmlandId(farmlandId)
        local snapshot = field ~= nil and getFieldSnapshot(field) or nil
        local cropActive = snapshot ~= nil and snapshot.isActiveCrop == true
        if entry ~= nil and field ~= nil and cropActive and getIsPlayerOwnedField(field) and nz(entry.state, 0) >= 3 and entry.damageReady == true then
            local effectiveRisk = math.max(entry.lastThreatRisk or 0, safeDiv(entry.state or 0, 3, 0), 0.7)
            local changed, _, destroyed = DiseaseFieldDamage:applyDamagePatches(field, entry.state or 0, effectiveRisk, entry.category, spots)
            totalChanged = totalChanged + (changed or 0)
            totalDestroyed = totalDestroyed + (destroyed or 0)
            debugLog("damage applied: farmland=%s state=%d spots=%d changed=%d destroyed=%d",
                tostring(farmlandId), nz(entry.state, 0), #spots, changed or 0, destroyed or 0)
            debugLog("Disease daily field damage: farmland=%s spots=%d state=%d changed=%d destroyed=%d",
                tostring(farmlandId), #spots, entry.state or 0, changed or 0, destroyed or 0)
        end
    end

    return totalChanged, 0, totalDestroyed
end

function DiseaseCore:applyTimedDamage(field, entry, threatId, farmlandId, reasonTag)
    if field == nil or entry == nil or DiseaseFieldDamage == nil or DiseaseGrid == nil then
        return 0, 0, 0
    end

    local tuning = getTuning()
    local state = nz(entry.state, 0)
    local minSeverity = state >= 3 and 3 or 2
    local spotsPerTick = state >= 3
        and nz(tuning.stage3DamageSpotsPerTick, 24)
        or nz(tuning.stage2DamageSpotsPerTick, 4)
    local spots = DiseaseGrid:getInfectedSpotsOnField(field, spotsPerTick, threatId, minSeverity)
    if #spots <= 0 then
        return 0, 0, 0
    end

    local effectiveRisk = math.max(entry.lastThreatRisk or 0, safeDiv(state, 3, 0), state >= 3 and 0.8 or 0.55)
    if self:queueDamageJob(field, entry, farmlandId, reasonTag, spots, effectiveRisk) then
        return 0, 0, 0
    end
    return 0, 0, 0
end

function DiseaseCore:advanceThreatTimers(deltaHours, currentHoursInDay, lastHoursInDay, currentDayToken)
    if deltaHours == nil or deltaHours <= 0 or DiseaseGrid == nil then
        return
    end

    local defs = getDefs()
    local tuning = getTuning()

    for farmlandId, entry in pairs(self.fields or {}) do
        if entry ~= nil and entry.cropActive and nz(entry.state, 0) > 0 then
            local field = getFieldByFarmlandId(farmlandId)
            if field ~= nil then
                if getIsPlayerOwnedField(field) then
                    if DiseasePlayerLogic ~= nil then
                        DiseasePlayerLogic:advanceFieldTimers(self, farmlandId, entry, field, defs, tuning, deltaHours, currentHoursInDay, lastHoursInDay, currentDayToken)
                    end
                else
                    if DiseaseNpcLogic ~= nil then
                        DiseaseNpcLogic:advanceFieldTimers(self, farmlandId, entry, field, defs, tuning, deltaHours, currentHoursInDay, lastHoursInDay, currentDayToken)
                    end
                end
            end
        end
    end
end

function DiseaseCore:scheduleSurpriseInfection(day)
    self._surpriseInfectionPending = false
    self._surpriseInfectionTimerMs = 0
    self._surpriseInfectionDay = day

    local chance = 0.18
    if math.random() >= chance then
        return
    end

    self._surpriseInfectionPending = true
    self._surpriseInfectionTimerMs = math.random(2 * 60 * 1000, 8 * 60 * 1000)
    debugLog("Disease surprise infection scheduled: day=%s delayMs=%d", tostring(day), self._surpriseInfectionTimerMs)
end

function DiseaseCore:triggerSurpriseInfection()
    if g_server == nil or g_fieldManager == nil or g_fieldManager.getFields == nil or DiseaseGrid == nil then
        return false
    end

    local candidates = {}
    for _, field in ipairs(g_fieldManager:getFields()) do
        if getIsPlayerOwnedField(field) then
            local snapshot = getFieldSnapshot(field)
            if snapshot ~= nil and snapshot.isActiveCrop and snapshot.fruitName ~= nil then
                local diseaseKeys = getCompatibleDiseaseThreatKeys(snapshot)
                if #diseaseKeys > 0 then
                    table.insert(candidates, {field = field, snapshot = snapshot, diseaseKeys = diseaseKeys})
                end
            end
        end
    end

    if #candidates == 0 then
        return false
    end

    local picked = candidates[math.random(1, #candidates)]
    local field = picked.field
    local farmlandId = getFieldKey(field)
    if farmlandId == nil then
        return false
    end

    local defs = getDefs()
    local entry = self:getOrCreateEntry(farmlandId)
    local eligibleKeys = {}
    for _, threatKey in ipairs(picked.diseaseKeys or {}) do
        if self:canStartSeasonThreat(entry, threatKey, getTuning()) then
            table.insert(eligibleKeys, threatKey)
        end
    end

    if #eligibleKeys <= 0 then
        debugLog("surprise outbreak skipped by seasonal limit/history: farmland=%s limit=%s count=%s history=%s",
            tostring(farmlandId), tostring(entry.seasonThreatLimit), tostring(entry.seasonThreatCount or 0), tostring(entry.seasonThreatHistory or ""))
        return false
    end

    local threatKey = eligibleKeys[math.random(1, #eligibleKeys)]
    if isEntryProtectedFromCategory(entry, "disease", getCurrentDayToken()) then
        debugLog("surprise outbreak blocked by treatment protection: farmland=%s key=%s protectedUntil=%s",
            tostring(farmlandId), tostring(threatKey), tostring(entry.protectedUntilDay or 0))
        return false
    end

    local threatId = defs and defs.getThreatId and defs:getThreatId(threatKey) or 0
    if threatId == 0 then
        return false
    end

    local spots = math.random(2, 4)
    local applied = 0
    for _ = 1, spots do
        local severity = 1
        applied = applied + (DiseaseGrid:applyRandomSpots(field, threatId, severity, 1, 4.0, 9.0 + severity * 1.5) or 0)
    end
    if applied <= 0 then
        debugLog("surprise outbreak blocked by cell protection: farmland=%s key=%s", tostring(farmlandId), tostring(threatKey))
        return false
    end

    entry.threatKey = threatKey
    entry.category = defs and defs.getThreatCategory and defs:getThreatCategory(threatKey) or "disease"
    entry.exposureDays = 0
    entry.neglectDays = 0
    entry.healStreak = 0
    entry.state = 1
    self:registerSeasonThreat(entry, threatKey)
    self:beginThreatStage(entry, 1, field)
    self:showThreatNotification(farmlandId, entry, field, "new", 0, entry.state)

    self:scanField(field, false, false)
    debugLog("surprise outbreak: farmland=%s key=%s spots=%d", tostring(farmlandId), tostring(threatKey), spots)
    debugLog("Disease surprise infection triggered: farmland=%s key=%s spots=%d", tostring(farmlandId), tostring(threatKey), spots)
    return true
end

function DiseaseCore:processDailyCycle(day)
    if g_server == nil then
        return
    end

    if day ~= nil and self._lastProcessedDay ~= nil and day == self._lastProcessedDay then
        return
    end

    debugLog("Disease daily cycle start: oldDay=%s newDay=%s", tostring(self._lastProcessedDay), tostring(day))
    debugLog("daily cycle: oldDay=%s newDay=%s", tostring(self._lastProcessedDay), tostring(day))
    self._lastProcessedDay = day or self._lastProcessedDay
    table.insert(self._pendingDailyDays, day)
    self:scheduleSurpriseInfection(day)
end

function DiseaseCore:processPendingDays(targetDay)
    if targetDay == nil then
        return
    end

    local lastDay = self._lastProcessedDay
    if lastDay == nil then
        self._lastProcessedDay = targetDay
        return
    end

    if targetDay <= lastDay then
        return
    end

    for day = lastDay + 1, targetDay do
        self:processDailyCycle(day)
    end
end

function DiseaseCore:onDayChanged(day)
    if g_server ~= nil then
        self:processPendingDays(getCurrentDayToken() or day)
    end
end

function DiseaseCore:onFinishedGrowthPeriod(_, hasPendingGrowth)
    if g_server ~= nil and not hasPendingGrowth then
        self:scanAllFields(false, false)
    end
end

function DiseaseCore:update(dt)
    if g_server == nil then
        if not self._networkHasFullSync then
            self._networkClientRequestTimerMs = math.max(0, (self._networkClientRequestTimerMs or 0) - (dt or 0))
            if self._networkClientRequestTimerMs <= 0 then
                if DiseaseGridSnapshotRequestEvent ~= nil and DiseaseGridSnapshotRequestEvent.send ~= nil then
                    DiseaseGridSnapshotRequestEvent.send()
                end
                self._networkClientRequested = true
                self._networkClientRequestTimerMs = 3000
            end
        end
        return
    end

    self._dailyTimerCooldownMs = math.max(0, (self._dailyTimerCooldownMs or 0) - (dt or 0))
    self._backgroundWorkTimerMs = math.max(0, (self._backgroundWorkTimerMs or 0) - (dt or 0))
    self._layerJobStepTimerMs = math.max(0, (self._layerJobStepTimerMs or 0) - (dt or 0))
    self._damageJobStepTimerMs = math.max(0, (self._damageJobStepTimerMs or 0) - (dt or 0))
    self._gridSanitizeTimerMs = math.max(0, (self._gridSanitizeTimerMs or 0) - (dt or 0))

    local currentGameHours = getCurrentGameHours()
    local currentHoursInDay = getCurrentHoursInDay()
    local currentDayToken = getCurrentDayToken()
    if currentGameHours ~= nil and self._lastGameHours ~= nil and currentGameHours > self._lastGameHours
        and (self._dailyTimerCooldownMs or 0) <= 0 then
        self:advanceThreatTimers(
            currentGameHours - self._lastGameHours,
            currentHoursInDay,
            self._lastDayHours,
            currentDayToken
        )
    end
    self._lastGameHours = currentGameHours or self._lastGameHours
    self._lastDayHours = currentHoursInDay or self._lastDayHours

    self:processPendingDays(currentDayToken)
    self:updateCropSessionWatch(dt)
    self:updateBackgroundWork(dt)

    if self._surpriseInfectionPending then
        self._surpriseInfectionTimerMs = math.max(0, (self._surpriseInfectionTimerMs or 0) - (dt or 0))
        if self._surpriseInfectionTimerMs <= 0 then
            self._surpriseInfectionPending = false
            self:triggerSurpriseInfection()
        end
    end

    if self._pendingFieldRescans ~= nil and next(self._pendingFieldRescans) ~= nil then
        self._pendingFieldRescanTimerMs = math.max(0, (self._pendingFieldRescanTimerMs or 0) - (dt or 0))
        if self._pendingFieldRescanTimerMs <= 0 then
            local pending = self._pendingFieldRescans
            self._pendingFieldRescans = {}
            self._pendingFieldRescanTimerMs = 0

            for farmlandId, shouldScan in pairs(pending) do
                if shouldScan then
                    local field = getFieldByFarmlandId(farmlandId)
                    if field ~= nil then
                        self:scanField(field, false, false)
                    end
                end
            end
        end
    end

    self._networkSyncTimerMs = math.max(0, (self._networkSyncTimerMs or 0) - (dt or 0))
    if self._networkSyncTimerMs <= 0 then
        self:broadcastNetworkStateIfNeeded()
        self._networkSyncTimerMs = self._networkSyncIntervalMs or 350
    end
end

function DiseaseCore:updateBackgroundWork(dt)
    if (self._backgroundWorkTimerMs or 0) > 0 then
        return
    end

    local didWork = false
    if self._activeDailyCycle ~= nil or (self._pendingDailyDays ~= nil and #self._pendingDailyDays > 0) then
        didWork = self:updateDailyCycleQueue(dt) == true
    elseif self._pendingDamageJobs ~= nil and #self._pendingDamageJobs > 0 then
        didWork = self:updatePendingDamageJobs(dt) == true
    elseif self._pendingLayerJobs ~= nil and #self._pendingLayerJobs > 0 then
        didWork = self:updatePendingLayerJobs(dt) == true
    elseif self._pendingOutbreakJobs ~= nil and #self._pendingOutbreakJobs > 0 then
        didWork = self:updatePendingOutbreakJobs(dt) == true
    elseif (self._gridSanitizeTimerMs or 0) <= 0 then
        didWork = self:updateGridSanitizer(dt) == true
    end

    if didWork then
        self._backgroundWorkTimerMs = math.max(self._backgroundWorkTimerMs or 0, self._backgroundWorkDelayMs or 0)
    end
end

function DiseaseCore:updateGridSanitizer(_)
    if g_server == nil or DiseaseGrid == nil or DiseaseGrid.createClearInvalidFieldGroundJob == nil or DiseaseGrid.stepClearInvalidFieldGroundJob == nil then
        return false
    end

    if self._gridSanitizeJob == nil then
        self._gridSanitizeJob = DiseaseGrid:createClearInvalidFieldGroundJob()
        if self._gridSanitizeJob == nil then
            self._gridSanitizeTimerMs = self._gridSanitizeFullIntervalMs or 10000
            return false
        end
    end

    local finished, cleared = DiseaseGrid:stepClearInvalidFieldGroundJob(self._gridSanitizeJob, self._gridSanitizeRowsPerStep or 8)
    if (cleared or 0) > 0 then
        debugLog("grid sanitizer cleared cells outside field ground: cleared=%d", cleared or 0)
    end

    if finished then
        local totalCleared = self._gridSanitizeJob ~= nil and (self._gridSanitizeJob.cleared or 0) or 0
        if totalCleared > 0 then
            debugLog("grid sanitizer pass complete: cleared=%d", totalCleared)
        end
        self._gridSanitizeJob = nil
        self._gridSanitizeTimerMs = self._gridSanitizeFullIntervalMs or 10000
    else
        self._gridSanitizeTimerMs = self._gridSanitizeStepIntervalMs or 100
    end

    return true
end

function DiseaseCore:hasPendingDailyWork()
    return self._activeDailyCycle ~= nil
        or (self._pendingDailyDays ~= nil and #self._pendingDailyDays > 0)
        or (self._pendingLayerJobs ~= nil and #self._pendingLayerJobs > 0)
        or (self._pendingDamageJobs ~= nil and #self._pendingDamageJobs > 0)
        or (self._pendingOutbreakJobs ~= nil and #self._pendingOutbreakJobs > 0)
end

function DiseaseCore:hasPendingFieldLayerJob(farmlandId)
    return farmlandId ~= nil and self._pendingLayerByFarmland ~= nil and self._pendingLayerByFarmland[farmlandId] == true
end

function DiseaseCore:queueDamageJob(field, entry, farmlandId, reasonTag, spots, effectiveRisk)
    if DiseaseFieldDamage == nil or field == nil or entry == nil or farmlandId == nil or spots == nil or #spots <= 0 then
        return false
    end

    self._pendingDamageByFarmland = self._pendingDamageByFarmland or {}
    if self._pendingDamageByFarmland[farmlandId] then
        return false
    end

    local damageJob = DiseaseFieldDamage:createDamageJob(field, entry.state or 3, effectiveRisk, entry.category, spots)
    if damageJob == nil then
        return false
    end

    self._pendingDamageJobs = self._pendingDamageJobs or {}
    table.insert(self._pendingDamageJobs, {
        farmlandId = farmlandId,
        entry = entry,
        reasonTag = reasonTag,
        spotsCount = #spots,
        damageJob = damageJob
    })
    self._pendingDamageByFarmland[farmlandId] = true
    return true
end

function DiseaseCore:queueDailyPromoteJob(field, farmlandId, entry, oldState, newState, threatId, isNpc)
    if DiseaseGrid == nil or field == nil or farmlandId == nil or entry == nil then
        return false
    end

    self._pendingLayerByFarmland = self._pendingLayerByFarmland or {}
    if self._pendingLayerByFarmland[farmlandId] then
        return false
    end

    local gridJob = DiseaseGrid:createPromoteThreatJob(field, newState, threatId)
    if gridJob == nil then
        return false
    end

    self._pendingLayerJobs = self._pendingLayerJobs or {}
    table.insert(self._pendingLayerJobs, {
        kind = "dailyPromote",
        farmlandId = farmlandId,
        field = field,
        entry = entry,
        oldState = oldState,
        newState = newState,
        isNpc = isNpc == true,
        gridJob = gridJob
    })
    self._pendingLayerByFarmland[farmlandId] = true
    return true
end

function DiseaseCore:queueDeferredDailyPromote(field, farmlandId, entry, oldState, newState, threatId, isNpc)
    if field == nil or farmlandId == nil or entry == nil then
        return false
    end

    self._pendingDailyPromoteByFarmland = self._pendingDailyPromoteByFarmland or {}
    self._pendingDailyPromoteByFarmland[farmlandId] = {
        field = field,
        farmlandId = farmlandId,
        entry = entry,
        oldState = oldState,
        newState = newState,
        threatId = threatId,
        isNpc = isNpc == true
    }
    return true
end

function DiseaseCore:pausePendingSpreadJobForFarmland(farmlandId)
    if farmlandId == nil or self._pendingLayerJobs == nil then
        return nil
    end

    for index, job in ipairs(self._pendingLayerJobs) do
        if job ~= nil and job.kind == "spread" and job.farmlandId == farmlandId then
            table.remove(self._pendingLayerJobs, index)
            self._pendingLayerByFarmland[farmlandId] = nil
            self._pausedSpreadByFarmland = self._pausedSpreadByFarmland or {}
            self._pausedSpreadByFarmland[farmlandId] = job
            return job
        end
    end

    return nil
end

function DiseaseCore:resumePausedSpreadJobForFarmland(farmlandId)
    if farmlandId == nil or self._pausedSpreadByFarmland == nil then
        return false
    end

    local job = self._pausedSpreadByFarmland[farmlandId]
    if job == nil then
        return false
    end

    self._pausedSpreadByFarmland[farmlandId] = nil
    self._pendingLayerJobs = self._pendingLayerJobs or {}
    table.insert(self._pendingLayerJobs, 1, job)
    self._pendingLayerByFarmland = self._pendingLayerByFarmland or {}
    self._pendingLayerByFarmland[farmlandId] = true
    return true
end

function DiseaseCore:queueDailyStageReinforceSpread(field, farmlandId, entry, targetState, threatId)
    if field == nil or farmlandId == nil or entry == nil or nz(targetState, 0) <= 0 then
        return false
    end

    local tuning = getTuning()
    local profile = self:getFieldBehaviorProfile(field, tuning)
    local spreadSeeds = math.max(2, math.floor(
        (nz(tuning.escalationSpreadSpotsBase, 2) + nz(targetState, 1) * nz(tuning.escalationSpreadSpotsPerLevel, 2))
            * nz(profile.spreadSeedsMultiplier, 1.0) * 1.35 + 0.5
    ))
    local spreadSpawn = math.max(2, math.floor(
        (nz(tuning.escalationSpawnNewSpotsBase, 2) + math.max(0, nz(targetState, 1) - 1) * nz(tuning.escalationSpawnNewSpotsPerLevel, 1))
            * nz(profile.spreadSpawnMultiplier, 1.0) * 1.35 + 0.5
    ))

    return self:queueThreatSpreadJob(
        field,
        farmlandId,
        entry,
        "daily stage reinforce",
        entry.stageHours,
        threatId,
        targetState,
        spreadSeeds,
        spreadSpawn,
        nz(tuning.escalationSpreadRadiusMin, 4.5) * nz(tuning.escalationSpreadRadiusMultiplier, 2.0) * 1.15,
        nz(tuning.escalationSpreadRadiusMax, 7.0) * nz(tuning.escalationSpreadRadiusMultiplier, 2.0) * 1.15,
        nz(tuning.escalationSpawnOffsetMin, 6.0),
        nz(tuning.escalationSpawnOffsetMax, 14.0),
        nz(tuning.escalationSpawnRadiusMin, 2.5) * 1.10,
        nz(tuning.escalationSpawnRadiusMax, 5.0) * 1.10
    )
end

function DiseaseCore:queueThreatSpreadJob(field, farmlandId, entry, reasonTag, stageHoursSnapshot, threatId, targetSeverity, spreadSeeds, spreadSpawn, spreadRadiusMin, spreadRadiusMax, spawnOffsetMin, spawnOffsetMax, spawnRadiusMin, spawnRadiusMax)
    if DiseaseGrid == nil or field == nil or farmlandId == nil or entry == nil then
        return false
    end

    self._pendingLayerByFarmland = self._pendingLayerByFarmland or {}
    if self._pendingLayerByFarmland[farmlandId] then
        return false
    end

    local seeds = DiseaseGrid:getInfectedSpotsOnField(field, math.max(1, tonumber(spreadSeeds or 1) or 1), threatId)
    if seeds == nil or #seeds <= 0 then
        return false
    end

    self._pendingLayerJobs = self._pendingLayerJobs or {}
    table.insert(self._pendingLayerJobs, {
        kind = "spread",
        farmlandId = farmlandId,
        field = field,
        entry = entry,
        reasonTag = reasonTag,
        stageHoursSnapshot = stageHoursSnapshot,
        targetSeverity = targetSeverity,
        threatId = threatId,
        seeds = seeds,
        seedIndex = 1,
        spawnIndex = 1,
        spotsPerSeed = math.max(0, tonumber(spreadSpawn or 0) or 0),
        spreadRadiusMin = spreadRadiusMin,
        spreadRadiusMax = spreadRadiusMax,
        spawnOffsetMin = spawnOffsetMin,
        spawnOffsetMax = spawnOffsetMax,
        spawnRadiusMin = spawnRadiusMin,
        spawnRadiusMax = spawnRadiusMax,
        totalExpanded = 0,
        totalSpawned = 0
    })
    self._pendingLayerByFarmland[farmlandId] = true
    return true
end

function DiseaseCore:updatePendingLayerJobs(_)
    if self._pendingLayerJobs == nil or #self._pendingLayerJobs <= 0 or DiseaseGrid == nil then
        return false
    end
    if (self._layerJobStepTimerMs or 0) > 0 then
        return false
    end

    local job = self._pendingLayerJobs[1]
    local missingRequiredJobState = job == nil
        or job.entry == nil
        or (job.kind == "dailyPromote" and job.gridJob == nil)
        or (job.kind == "spread" and job.seeds == nil)
    if missingRequiredJobState then
        table.remove(self._pendingLayerJobs, 1)
        if job ~= nil and job.farmlandId ~= nil then
            self._pendingLayerByFarmland[job.farmlandId] = nil
        end
        return true
    end

    local finished = false
    if job.kind == "dailyPromote" then
        finished = select(1, DiseaseGrid:stepPromoteThreatJob(job.gridJob, self._layerJobRowsPerStep or 16))
    elseif job.kind == "spread" then
        if job.currentCircleJob == nil then
            local seed = job.seeds and job.seeds[job.seedIndex] or nil
            if seed == nil then
                finished = true
            elseif job.spawnIndex <= 1 then
                local radius = MathUtil.randomFloat(job.spreadRadiusMin, job.spreadRadiusMax)
                local severity = math.max(job.targetSeverity or 1, tonumber(seed.severity or 0) or 0)
                job.currentCircleJob = DiseaseGrid:createCircleThreatJob(job.field, seed.x, seed.z, radius, severity, job.threatId)
                job.currentCircleKind = "expand"
                if job.currentCircleJob == nil then
                    job.seedIndex = job.seedIndex + 1
                end
            else
                local angle = MathUtil.randomFloat(0, math.pi * 2)
                local distance = MathUtil.randomFloat(job.spawnOffsetMin, job.spawnOffsetMax)
                local radius = MathUtil.randomFloat(job.spawnRadiusMin, job.spawnRadiusMax)
                local x = seed.x + math.cos(angle) * distance
                local z = seed.z + math.sin(angle) * distance
                job.currentCircleJob = DiseaseGrid:createCircleThreatJob(job.field, x, z, radius, job.targetSeverity, job.threatId)
                job.currentCircleKind = "spawn"
                if job.currentCircleJob == nil then
                    job.spawnIndex = job.spawnIndex + 1
                    if job.spawnIndex > job.spotsPerSeed then
                        job.spawnIndex = 1
                        job.seedIndex = job.seedIndex + 1
                    end
                end
            end
        end

        if not finished and job.currentCircleJob ~= nil then
            local circleFinished, changed = DiseaseGrid:stepCircleThreatJob(job.currentCircleJob, self._layerJobRowsPerStep or 16)
            if job.currentCircleKind == "expand" then
                job.totalExpanded = (job.totalExpanded or 0) + (changed or 0)
            else
                job.totalSpawned = (job.totalSpawned or 0) + (changed or 0)
            end
            if circleFinished then
                if job.currentCircleKind == "expand" then
                    if job.spotsPerSeed > 0 then
                        job.spawnIndex = 2
                    else
                        job.seedIndex = job.seedIndex + 1
                    end
                else
                    job.spawnIndex = job.spawnIndex + 1
                    if job.spawnIndex > job.spotsPerSeed then
                        job.spawnIndex = 1
                        job.seedIndex = job.seedIndex + 1
                    end
                end
                job.currentCircleJob = nil
                job.currentCircleKind = nil
            end

            finished = job.seedIndex > #job.seeds and job.currentCircleJob == nil
        end
    end

    if not finished then
        self._layerJobStepTimerMs = math.max(self._layerJobStepTimerMs or 0, self._layerJobStepDelayMs or 0)
        return true
    end

    local followUpSpread = nil
    if job.kind == "dailyPromote" then
        job.entry.state = job.newState
        job.entry.neglectDays = nz(job.entry.neglectDays, 0) + 1
        self:beginThreatStage(job.entry, job.newState, job.field)
        self:showThreatNotification(job.farmlandId, job.entry, job.field, "stage", job.oldState, job.newState)
        if job.isNpc then
            self:debugLog("daily stage rise npc: farmland=%s oldState=%d newState=%d",
                tostring(job.farmlandId), job.oldState or 0, nz(job.entry.state, 0))
        else
            followUpSpread = {
                field = job.field,
                farmlandId = job.farmlandId,
                entry = job.entry,
                targetState = job.newState,
                threatId = job.field ~= nil and getDefs() ~= nil and getDefs().getThreatId ~= nil and getDefs():getThreatId(job.entry.threatKey) or job.gridJob.requiredThreatId
            }
            self:debugLog("daily stage rise: farmland=%s oldState=%d newState=%d",
                tostring(job.farmlandId), job.oldState or 0, nz(job.entry.state, 0))
        end
    elseif job.kind == "spread" then
        self:debugLog("%s: farmland=%s state=%d afterHours=%.2f expandedCells=%d spawnedCells=%d",
            tostring(job.reasonTag or "stage spread"), tostring(job.farmlandId), nz(job.entry.state, 0),
            nz(job.stageHoursSnapshot, 0), job.totalExpanded or 0, job.totalSpawned or 0)
    end

    self._pendingLayerByFarmland[job.farmlandId] = nil
    table.remove(self._pendingLayerJobs, 1)
    self._layerJobStepTimerMs = math.max(self._layerJobStepTimerMs or 0, self._layerJobStepDelayMs or 0)
    local deferredPromote = self._pendingDailyPromoteByFarmland ~= nil and self._pendingDailyPromoteByFarmland[job.farmlandId] or nil
    if deferredPromote ~= nil then
        self._pendingDailyPromoteByFarmland[job.farmlandId] = nil
        self:queueDailyPromoteJob(
            deferredPromote.field,
            deferredPromote.farmlandId,
            deferredPromote.entry,
            deferredPromote.oldState,
            deferredPromote.newState,
            deferredPromote.threatId,
            deferredPromote.isNpc
        )
    end
    if followUpSpread ~= nil then
        self:queueDailyStageReinforceSpread(
            followUpSpread.field,
            followUpSpread.farmlandId,
            followUpSpread.entry,
            followUpSpread.targetState,
            followUpSpread.threatId
        )
    end
    self:resumePausedSpreadJobForFarmland(job.farmlandId)
    return true
end

function DiseaseCore:updatePendingDamageJobs(_)
    if self._pendingDamageJobs == nil or #self._pendingDamageJobs <= 0 or DiseaseFieldDamage == nil then
        return false
    end
    if (self._damageJobStepTimerMs or 0) > 0 then
        return false
    end

    local job = self._pendingDamageJobs[1]
    if job == nil or job.damageJob == nil or job.entry == nil then
        table.remove(self._pendingDamageJobs, 1)
        if job ~= nil and job.farmlandId ~= nil then
            self._pendingDamageByFarmland[job.farmlandId] = nil
        end
        return true
    end

    local finished = select(1, DiseaseFieldDamage:stepDamageJob(job.damageJob, self._damageJobSpotsPerStep or 1))
    if not finished then
        self._damageJobStepTimerMs = math.max(self._damageJobStepTimerMs or 0, self._damageJobStepDelayMs or 0)
        return true
    end

    debugLog("%s: farmland=%s spots=%d state=%d crop=%s fieldGrowth=%d filter=%d..%d changed=%d destroyed=%d",
        tostring(job.reasonTag or "stage3 damage"), tostring(job.farmlandId), job.spotsCount or 0, nz(job.entry.state, 0),
        tostring(job.damageJob.fruitName or "?"), nz(job.damageJob.growthState, 0),
        nz(job.damageJob.minDestroyState, 0), nz(job.damageJob.maxDestroyState, 0),
        job.damageJob.totalChanged or 0, job.damageJob.totalDestroyed or 0)

    self._pendingDamageByFarmland[job.farmlandId] = nil
    table.remove(self._pendingDamageJobs, 1)
    self._damageJobStepTimerMs = math.max(self._damageJobStepTimerMs or 0, self._damageJobStepDelayMs or 0)
    return true
end

function DiseaseCore:queueOutbreakJob(field, farmlandId, entry, threatKey, category, threatId, severity, spots, radiusMin, radiusMax, risk, chance)
    if field == nil or farmlandId == nil or entry == nil then
        return false
    end

    category = getThreatCategorySafe(threatKey, category)

    local protectedCoverage = DiseaseGrid ~= nil and DiseaseGrid.getFieldProtectionCoverage ~= nil
        and DiseaseGrid:getFieldProtectionCoverage(field, category) or 0
    if protectedCoverage >= 0.98 then
        entry.exposureDays = 0
        debugLog("outbreak blocked by seasonal cell protection: farmland=%s key=%s category=%s coverage=%.3f",
            tostring(farmlandId), tostring(threatKey), tostring(category), protectedCoverage)
        return false
    end

    if isEntryProtectedFromCategory(entry, category, getCurrentDayToken()) then
        debugLog("outbreak blocked by treatment protection: farmland=%s key=%s category=%s protectedUntil=%s",
            tostring(farmlandId), tostring(threatKey), tostring(category), tostring(entry.protectedUntilDay or 0))
        return false
    end

    if not self:canStartSeasonThreat(entry, threatKey, getTuning()) then
        debugLog("outbreak blocked by seasonal limit/history: farmland=%s key=%s limit=%s count=%s history=%s",
            tostring(farmlandId), tostring(threatKey), tostring(entry.seasonThreatLimit), tostring(entry.seasonThreatCount or 0), tostring(entry.seasonThreatHistory or ""))
        return false
    end

    self._pendingOutbreakByFarmland = self._pendingOutbreakByFarmland or {}
    if self._pendingOutbreakByFarmland[farmlandId] then
        return false
    end

    self._pendingOutbreakJobs = self._pendingOutbreakJobs or {}
    table.insert(self._pendingOutbreakJobs, {
        field = field,
        farmlandId = farmlandId,
        entry = entry,
        threatKey = threatKey,
        category = category,
        threatId = threatId,
        severity = severity,
        remainingSpots = math.max(1, spots or 1),
        totalSpots = math.max(1, spots or 1),
        radiusMin = radiusMin,
        radiusMax = radiusMax,
        risk = risk,
        chance = chance,
        applied = 0
    })
    self._pendingOutbreakByFarmland[farmlandId] = true
    return true
end

function DiseaseCore:updatePendingOutbreakJobs(_)
    if self._pendingOutbreakJobs == nil or #self._pendingOutbreakJobs <= 0 or DiseaseGrid == nil then
        return false
    end

    local job = self._pendingOutbreakJobs[1]
    if job == nil or job.field == nil or job.entry == nil then
        table.remove(self._pendingOutbreakJobs, 1)
        if job ~= nil and job.farmlandId ~= nil then
            self._pendingOutbreakByFarmland[job.farmlandId] = nil
        end
        return true
    end

    if isEntryProtectedFromCategory(job.entry, job.category, getCurrentDayToken()) then
        debugLog("queued outbreak cancelled by treatment protection: farmland=%s key=%s category=%s protectedUntil=%s",
            tostring(job.farmlandId), tostring(job.threatKey), tostring(job.category), tostring(job.entry.protectedUntilDay or 0))
        if DiseaseGrid.clearFieldByCategory ~= nil and normalizeProtectionCategory(job.category) ~= nil then
            DiseaseGrid:clearFieldByCategory(job.field, job.category)
        end
        self._pendingOutbreakByFarmland[job.farmlandId] = nil
        table.remove(self._pendingOutbreakJobs, 1)
        return true
    end

    if not self:canStartSeasonThreat(job.entry, job.threatKey, getTuning()) then
        debugLog("queued outbreak cancelled by seasonal limit/history: farmland=%s key=%s limit=%s count=%s history=%s",
            tostring(job.farmlandId), tostring(job.threatKey), tostring(job.entry.seasonThreatLimit), tostring(job.entry.seasonThreatCount or 0), tostring(job.entry.seasonThreatHistory or ""))
        self._pendingOutbreakByFarmland[job.farmlandId] = nil
        table.remove(self._pendingOutbreakJobs, 1)
        return true
    end

    if job.currentSpotJob == nil then
        job.currentSpotJob = DiseaseGrid:createRandomSpotThreatJob(
            job.field,
            job.threatId,
            job.severity,
            job.radiusMin,
            job.radiusMax
        )
        if job.currentSpotJob == nil then
            job.remainingSpots = math.max(0, (job.remainingSpots or 0) - 1)
        end
    end

    if job.currentSpotJob ~= nil then
        local finished = false
        local changed = 0
        finished, changed = DiseaseGrid:stepCircleThreatJob(job.currentSpotJob, self._layerJobRowsPerStep or 16)
        job.applied = (job.applied or 0) + (changed or 0)
        if finished then
            job.currentSpotJob = nil
            job.remainingSpots = math.max(0, (job.remainingSpots or 0) - 1)
        end
    end

    if job.remainingSpots > 0 then
        return true
    end

    local entry = job.entry
    if (job.applied or 0) > 0 then
        entry.threatKey = job.threatKey
        entry.category = getThreatCategorySafe(job.threatKey, job.category)
        entry.state = math.max(nz(entry.state, 0), job.severity or 1)
        entry.exposureDays = 0
        entry.neglectDays = 0
        entry.healStreak = 0
        self:registerSeasonThreat(entry, job.threatKey)
        self:beginThreatStage(entry, entry.state, job.field)
        self:showThreatNotification(job.farmlandId, entry, job.field, "new", 0, entry.state)
        debugLog("new outbreak: farmland=%s risk=%.3f chance=%.3f spots=%d radius=%.1f..%.1f key=%s applied=%d",
            tostring(job.farmlandId), nz(job.risk, 0), clamp(job.chance, 0, 0.95), job.totalSpots or 0,
            job.radiusMin or 0, job.radiusMax or 0, tostring(job.threatKey), job.applied or 0)
    else
        debugLog("outbreak failed to place cells: farmland=%s risk=%.3f chance=%.3f spots=%d radius=%.1f..%.1f key=%s",
            tostring(job.farmlandId), nz(job.risk, 0), clamp(job.chance, 0, 0.95), job.totalSpots or 0,
            job.radiusMin or 0, job.radiusMax or 0, tostring(job.threatKey))
    end

    self._pendingOutbreakByFarmland[job.farmlandId] = nil
    table.remove(self._pendingOutbreakJobs, 1)
    return true
end

function DiseaseCore:buildDailyFieldQueue(day)
    if g_fieldManager == nil or g_fieldManager.getFields == nil then
        return nil
    end

    local items = {}
    for _, field in ipairs(g_fieldManager:getFields()) do
        local x, z = getFieldSweepAnchor(field)
        table.insert(items, {
            field = field,
            x = x,
            z = z
        })
    end

    table.sort(items, function(a, b)
        if a == nil or b == nil then
            return false
        end
        if math.abs((a.z or 0) - (b.z or 0)) > 0.01 then
            return (a.z or 0) < (b.z or 0)
        end
        return (a.x or 0) < (b.x or 0)
    end)

    return {
        day = day,
        index = 1,
        items = items
    }
end

function DiseaseCore:advanceThreatForDailyField(field)
    if field == nil then
        return false
    end

    local farmlandId = getFieldKey(field)
    if farmlandId == nil then
        return false
    end

    local entry = self.fields and self.fields[farmlandId] or nil
    if entry == nil then
        return false
    end

    local snapshot = getFieldSnapshot(field)
    entry.cropActive = snapshot ~= nil and snapshot.isActiveCrop == true
    if snapshot == nil or entry.cropActive ~= true or nz(entry.state, 0) <= 0 then
        return false
    end

    local defs = getDefs()
    local tuning = getTuning()
    local oldState = nz(entry.state, 0)
    local threatCategory = entry.category or (defs and defs.getThreatCategory and defs:getThreatCategory(entry.threatKey)) or nil
    if isEntryProtectedFromCategory(entry, threatCategory, getCurrentDayToken()) then
        debugLog("daily promotion skipped by treatment protection: farmland=%s key=%s category=%s protectedUntil=%s",
            tostring(farmlandId), tostring(entry.threatKey), tostring(threatCategory), tostring(entry.protectedUntilDay or 0))
        return false
    end

    if getIsPlayerOwnedField(field) then
        local newState = math.min(oldState + 1, 3)
        local threatId = defs and defs.getThreatId and defs:getThreatId(entry.threatKey) or 0
        if newState > oldState then
            if self:hasPendingFieldLayerJob(farmlandId) then
                self:pausePendingSpreadJobForFarmland(farmlandId)
            end
            if self:queueDailyPromoteJob(field, farmlandId, entry, oldState, newState, threatId, false)
                or (self:hasPendingFieldLayerJob(farmlandId) and self:queueDeferredDailyPromote(field, farmlandId, entry, oldState, newState, threatId, false)) then
                return true
            end
        end
    else
        local npcStageCap = nz(tuning.npcFieldsMaxDiseaseStage, 2)
        local newState = math.min(oldState + 1, npcStageCap)
        local threatId = defs and defs.getThreatId and defs:getThreatId(entry.threatKey) or 0
        if newState > oldState then
            if self:hasPendingFieldLayerJob(farmlandId) then
                self:pausePendingSpreadJobForFarmland(farmlandId)
            end
            if self:queueDailyPromoteJob(field, farmlandId, entry, oldState, newState, threatId, true)
                or (self:hasPendingFieldLayerJob(farmlandId) and self:queueDeferredDailyPromote(field, farmlandId, entry, oldState, newState, threatId, true)) then
                return true
            end
        end
    end

    return false
end

function DiseaseCore:updateDailyCycleQueue(dt)
    if g_server == nil then
        return false
    end

    self._dailyBatchTimerMs = math.max(0, (self._dailyBatchTimerMs or 0) - (dt or 0))
    if self._dailyBatchTimerMs > 0 then
        return false
    end

    if self._activeDailyCycle == nil then
        local nextDay = table.remove(self._pendingDailyDays, 1)
        if nextDay == nil then
            return false
        end
        self._activeDailyCycle = self:buildDailyFieldQueue(nextDay)
        if self._activeDailyCycle == nil then
            return false
        end
    end

    local cycle = self._activeDailyCycle
    local processed = 0

    while cycle ~= nil and cycle.index <= #cycle.items do
        local item = cycle.items[cycle.index]
        cycle.index = cycle.index + 1
        if item ~= nil and item.field ~= nil then
            local queuedPromote = self:advanceThreatForDailyField(item.field)
            if not queuedPromote then
                self:scanField(item.field, false, true)
            end
            processed = processed + 1
        end

        if processed >= math.max(1, self._dailyBatchSize or 1) then
            break
        end
    end

    if cycle ~= nil and cycle.index > #cycle.items then
        debugLog("daily cycle complete: day=%s processedFields=%d",
            tostring(cycle.day), #(cycle.items or {}))
        self._activeDailyCycle = nil
        self._dailyTimerCooldownMs = math.max(self._dailyTimerCooldownMs or 0, 250)
    end

    self._dailyBatchTimerMs = math.max(0, self._dailyBatchDelayMs or 0)
    return processed > 0
end

function DiseaseCore:scanAllFields(resetStates, isDaily)
    if g_fieldManager == nil or g_fieldManager.getFields == nil then return end
    for _, field in ipairs(g_fieldManager:getFields()) do self:scanField(field, resetStates, isDaily) end
end

function DiseaseCore:scanField(field, resetStates, isDaily)
    if field == nil then return end
    local farmlandId = getFieldKey(field)
    if farmlandId == nil then return end

    local snapshot = getFieldSnapshot(field)
    if snapshot == nil then return end

    local entry = self:getOrCreateEntry(farmlandId)
    local riskData = calculateThreatRisk(snapshot)
    local tuning = getTuning()
    local defs = getDefs()
    local profile = getFieldBehaviorProfile(field, tuning)

    if DiseaseGrid == nil then
        self:updateEntrySnapshot(entry, snapshot, riskData)
        return
    end

    if not snapshot.isActiveCrop then
        DiseaseGrid:clearField(field)
        if DiseaseProtectionGrid ~= nil and (entry.cropActive == true or nz(entry.lastGrowthState, 0) > 0) then
            DiseaseProtectionGrid:clearField(field)
        end
        self:resetForInactiveCrop(entry, snapshot)
        self:updateEntrySnapshot(entry, snapshot, {risk = 0, scores = {}})
        return
    end

    if self:isNewCropSession(entry, snapshot) then
        DiseaseGrid:clearField(field)
        if DiseaseProtectionGrid ~= nil then DiseaseProtectionGrid:clearField(field) end
        self:beginCropSession(entry, snapshot)
    else
        entry.cropActive = true
    end

    if isDaily then
        entry.activeDays = nz(entry.activeDays, 0) + 1
    end

    local summary = nil
    local function ensureSummary()
        if summary == nil then
            summary = DiseaseGrid:getFieldSummary(field)
        end
        return summary
    end

    local shouldReadSummaryEarly = not (isDaily and not resetStates and nz(entry.state, 0) <= 0 and entry.threatKey == nil)
    if shouldReadSummaryEarly then
        local currentSummary = ensureSummary()
        if (currentSummary.infectedPixels or 0) > 0 and nz(entry.state, 0) <= 0 then
            entry.state = clamp(tonumber(currentSummary.maxSeverity or 0) or 0, 0, 3)
            if entry.state > 0 then
                local gridThreatKey = defs and defs.getThreatKeyById and defs:getThreatKeyById(currentSummary.dominantThreatId or 0) or nil
                if gridThreatKey ~= nil then
                    entry.threatKey = gridThreatKey
                    entry.category = defs and defs.getThreatCategory and defs:getThreatCategory(gridThreatKey) or entry.category
                    if nz(entry.seasonThreatCount, 0) <= 0 then
                        self:registerSeasonThreat(entry, gridThreatKey)
                    end
                end
                if nz(entry.stageExpandDelayHours, 0) <= 0 and nz(entry.stage3NextExpandHours, 0) <= 0 then
                    self:beginThreatStage(entry, entry.state, field)
                end
                debugLog("restored state from grid: farmland=%s restoredState=%d infectedPixels=%d key=%s",
                    tostring(farmlandId), nz(entry.state, 0), currentSummary.infectedPixels or 0, tostring(entry.threatKey or "none"))
            end
        end
        if (currentSummary.infectedPixels or 0) > 0 and nz(entry.state, 0) <= 0 and entry.threatKey == nil then
            DiseaseGrid:clearField(field)
            summary = DiseaseGrid:getFieldSummary(field)
        end
    end

    local avgSeverity = summary ~= nil and (summary.averageSeverity or 0) or 0
    local eligibleThreatKey, eligibleRiskCategory, eligibleRisk = self:getBestSeasonThreatCandidate(entry, snapshot, riskData, tuning)
    local threatId = defs and defs.getThreatId and defs:getThreatId(eligibleThreatKey or riskData.bestKey) or 0

    if resetStates then
        DiseaseGrid:clearField(field)
        if riskData.bestKey ~= nil and riskData.risk >= nz(tuning.manualRebuildThreshold, 0.86) then
            local rebuildThreatId = defs and defs.getThreatId and defs:getThreatId(riskData.bestKey) or 0
            DiseaseGrid:applyRandomSpots(field, rebuildThreatId, (riskData.risk >= nz(tuning.manualRebuildHighThreshold, 0.96)) and 2 or 1, 3, 5.0, 10.0)
        end
    elseif isDaily and nz(entry.state, 0) <= 0 and eligibleThreatKey ~= nil and entry.activeDays >= nz(tuning.minDaysAfterEmergence, 2) then
        local lowRiskThreshold = nz(tuning.lowRiskThreshold, 0.42)
        local ambientRiskThreshold = nz(tuning.ambientRiskThreshold, 0.12)
        local candidateRisk = eligibleRisk or 0
        local isAmbientRisk = candidateRisk >= ambientRiskThreshold and candidateRisk < lowRiskThreshold
        local riskCategory = eligibleRiskCategory or (defs and defs.getThreatCategory and defs:getThreatCategory(eligibleThreatKey)) or entry.category

        local protectedCoverage = DiseaseGrid.getFieldProtectionCoverage ~= nil
            and DiseaseGrid:getFieldProtectionCoverage(field, riskCategory) or 0
        if protectedCoverage >= 0.98 then
            entry.exposureDays = 0
            debugLog("daily outbreak blocked by seasonal cell protection: farmland=%s key=%s category=%s coverage=%.3f",
                tostring(farmlandId), tostring(eligibleThreatKey), tostring(riskCategory), protectedCoverage)
        elseif isEntryProtectedFromCategory(entry, riskCategory, getCurrentDayToken()) then
            entry.exposureDays = 0
            debugLog("daily outbreak blocked by treatment protection: farmland=%s key=%s category=%s protectedUntil=%s",
                tostring(farmlandId), tostring(eligibleThreatKey), tostring(riskCategory), tostring(entry.protectedUntilDay or 0))
        elseif candidateRisk >= lowRiskThreshold then
            entry.exposureDays = nz(entry.exposureDays, 0) + ((candidateRisk >= nz(tuning.mediumRiskThreshold, 0.64)) and 2 or 1)
        elseif isAmbientRisk then
            entry.exposureDays = nz(entry.exposureDays, 0) + 1
        else
            entry.exposureDays = math.max(nz(entry.exposureDays, 0) - nz(tuning.exposureDecay, 1), 0)
        end

        local needed = nz(tuning.exposureDaysLow, 3)
        if isAmbientRisk then
            needed = nz(tuning.exposureDaysAmbient, 4)
        elseif candidateRisk >= nz(tuning.severeRiskThreshold, 0.82) then
            needed = nz(tuning.exposureDaysSevere, 1)
        elseif candidateRisk >= nz(tuning.mediumRiskThreshold, 0.64) then
            needed = nz(tuning.exposureDaysMedium, 2)
        end

        if entry.exposureDays >= needed then
            local chance
            if isAmbientRisk then
                chance = nz(tuning.ambientInfectionChanceBaseDaily, 0.08)
                    + math.max(0, candidateRisk - ambientRiskThreshold) * nz(tuning.ambientInfectionChanceRiskScale, 0.18)
                chance = chance * nz(tuning.ambientInfectionChanceMultiplier, 1.0)
            else
                chance = nz(tuning.infectionChanceBaseDaily, 0.05)
                    + math.max(0, candidateRisk - lowRiskThreshold) * nz(tuning.infectionChanceRiskScale, 0.22)
                chance = chance * nz(tuning.infectionChanceMultiplier, 1.0)
            end
            chance = chance * nz(profile.infectionChanceMultiplier, 1.0)
            if math.random() < clamp(chance, 0, 0.95) then
                local severity = 1
                local spots
                if isAmbientRisk then
                    spots = 1
                else
                    local initialSpotsMin = math.max(1, nz(tuning.infectionSpotsInitialMin, 3))
                    local initialSpotsMax = math.max(initialSpotsMin, nz(tuning.infectionSpotsInitialMax, 5))
                    local baseSpots = math.random(initialSpotsMin, initialSpotsMax)
                    spots = math.max(1, math.floor(baseSpots * nz(profile.infectionSpotsMultiplier, 1.0) + 0.5))
                end
                local initialRadiusMultiplier = isAmbientRisk and 1.0 or nz(tuning.infectionInitialRadiusMultiplier, 2.0)
                local radiusMin = nz(tuning.infectionRadiusMin, 5.5) * nz(profile.infectionRadiusMultiplier, 1.0) * initialRadiusMultiplier
                local radiusMax = nz(tuning.infectionRadiusMax, 12.0) * nz(profile.infectionRadiusMultiplier, 1.0) * initialRadiusMultiplier
                local queued = self:queueOutbreakJob(
                    field,
                    farmlandId,
                    entry,
                    eligibleThreatKey,
                    riskCategory,
                    threatId,
                    severity,
                    spots,
                    radiusMin,
                    radiusMax,
                    candidateRisk,
                    chance
                )
                if queued then
                    entry.exposureDays = 0
                elseif isAmbientRisk then
                    debugLog("ambient outbreak already queued: farmland=%s risk=%.3f chance=%.3f spots=%d key=%s",
                        tostring(farmlandId), nz(candidateRisk, 0), clamp(chance, 0, 0.95), spots, tostring(eligibleThreatKey))
                else
                    debugLog("outbreak already queued: farmland=%s risk=%.3f chance=%.3f spots=%d radius=%.1f..%.1f key=%s",
                        tostring(farmlandId), nz(candidateRisk, 0), clamp(chance, 0, 0.95), spots, radiusMin, radiusMax, tostring(eligibleThreatKey))
                end
            end
        end
    elseif isDaily and nz(entry.state, 0) <= 0 and riskData.bestKey ~= nil and eligibleThreatKey == nil then
        entry.exposureDays = 0
        debugLog("daily outbreak skipped by seasonal limit/history: farmland=%s limit=%s count=%s history=%s",
            tostring(farmlandId), tostring(entry.seasonThreatLimit), tostring(entry.seasonThreatCount or 0), tostring(entry.seasonThreatHistory or ""))
    end

    local currentSummary = summary
    if isDaily and riskData.risk <= nz(tuning.recoveryRiskThreshold, 0.22) and currentSummary ~= nil and (currentSummary.infectedPixels or 0) > 0 then
        DiseaseGrid:healRandomSpots(field, 1, 5.0, 9.0)
        summary = nil
    end

    local shouldFinalizeWithSummary = resetStates or summary ~= nil or nz(entry.state, 0) > 0 or entry.threatKey ~= nil
    if not shouldFinalizeWithSummary then
        if isDaily then
            entry.neglectDays = 0
        end
        self:updateEntrySnapshot(entry, snapshot, riskData)
        entry.lastThreatRisk = 0
        return
    end

    summary = ensureSummary()
    if isDaily and (summary.infectedPixels or 0) > 0 then
        entry.neglectDays = nz(entry.neglectDays, 0) + 1
    else
        entry.neglectDays = 0
    end

    avgSeverity = summary.averageSeverity or 0
    entry.state = clamp(tonumber(summary.maxSeverity or 0) or 0, 0, 3)
    if entry.state > 0 then
        local gridThreatKey = defs and defs.getThreatKeyById and defs:getThreatKeyById(summary.dominantThreatId or 0) or nil
        entry.threatKey = gridThreatKey or riskData.bestKey or entry.threatKey
        entry.category = (defs and defs.getThreatCategory and defs:getThreatCategory(entry.threatKey)) or riskData.bestCategory
    else
        -- Keep exposure buildup for healthy-looking fields so risk can mature
        -- into a natural outbreak on later daily scans.
        entry.threatKey = nil
        entry.category = nil
        entry.state = 0
        entry.neglectDays = 0
        entry.healStreak = 0
        entry.lastThreatRisk = 0
        self:resetThreatTimers(entry)
    end

    self:updateEntrySnapshot(entry, snapshot, riskData)
    entry.lastThreatRisk = math.max(avgSeverity, safeDiv(entry.state, 3, 0))
end

function DiseaseCore:getLocalThreatState(farmlandId)
    local entry = farmlandId and self.fields[farmlandId] or nil
    if entry == nil or not entry.cropActive then
        return nil
    end
    local x, _, z = getReferenceWorldPosition()
    if x == nil or DiseaseGrid == nil then
        return nil
    end
    local severity, threatId = DiseaseGrid:getThreatAtWorldPos(x, z)
    if severity == nil or severity <= 0 then
        return {
            severity = 0,
            threatId = 0,
            threatKey = nil,
            category = nil
        }
    end
    local defs = getDefs()
    local threatKey = defs and defs.getThreatKeyById and defs:getThreatKeyById(threatId) or entry.threatKey
    return {
        severity = severity,
        threatId = threatId,
        threatKey = threatKey,
        category = defs and defs.getThreatCategory and defs:getThreatCategory(threatKey) or entry.category
    }
end

function DiseaseCore:getThreatDef(key) local defs = getDefs() return defs and defs:getThreat(key) or nil end
function DiseaseCore:getDiseaseDef(key) return self:getThreatDef(key) end

function DiseaseCore:markFieldProtected(farmlandId, category, days)
    if farmlandId == nil then
        return false
    end

    local entry = self:getOrCreateEntry(farmlandId)
    local tuning = getTuning()
    local protectionDays = math.max(1, tonumber(days or tuning.treatmentProtectionDays or 3) or 3)
    local dayToken = getCurrentDayToken() or 0
    local newCategory = normalizeProtectionCategory(category)
    local existingCategory = normalizeProtectionCategory(entry.protectedCategory)
    local existingStillActive = isEntryProtectedFromCategory(entry, existingCategory, dayToken)

    entry.protectedUntilDay = math.max(nz(entry.protectedUntilDay, 0), dayToken + protectionDays)
    if existingStillActive and existingCategory ~= newCategory then
        entry.protectedCategory = nil
    else
        entry.protectedCategory = newCategory
    end
    entry.lastTreatedDay = dayToken
    entry.exposureDays = 0

    debugLog("field treatment protection: farmland=%s category=%s untilDay=%s",
        tostring(farmlandId), tostring(entry.protectedCategory or "all"), tostring(entry.protectedUntilDay))

    return true
end

function DiseaseCore:getLocalProtectionState(farmlandId)
    if farmlandId == nil or DiseaseProtectionGrid == nil then return 0 end
    local x, _, z = getReferenceWorldPosition()
    if x == nil or getFieldKeyAtXZ(x, z) ~= farmlandId then return 0 end
    return DiseaseProtectionGrid:getValueAtWorldPos(x, z)
end

function DiseaseCore:getActiveThreatCategoryAtWorldPos(x, z)
    local farmlandId = getFieldKeyAtXZ(x, z)
    local entry = farmlandId ~= nil and self.fields and self.fields[farmlandId] or nil
    if entry == nil or not entry.cropActive or nz(entry.state, 0) <= 0 then return nil, nil end
    return farmlandId, entry.category
end

function DiseaseCore:markCareProtectionAt(tool, category, days)
    if tool == nil then
        return false
    end

    local farmlandId = getFarmlandIdFromWorkAreas(tool)
    if farmlandId == nil then
        local root = tool.rootNode or (tool.components and tool.components[1] and tool.components[1].node)
        if root ~= nil then
            local x, _, z = getWorldTranslation(root)
            farmlandId = getFieldKeyAtXZ(x, z)
        end
    end

    if farmlandId == nil then
        return false
    end

    local entry = self:getOrCreateEntry(farmlandId)
    entry.lastTreatedDay = getCurrentDayToken()
    entry.exposureDays = 0
    entry.neglectDays = 0

    -- Cell protection is authoritative. Cancel jobs that captured seed positions
    -- before spraying; any surviving untreated cells may schedule fresh work later.
    self:cancelPendingWorkForFarmland(farmlandId)

    -- Do not queue a full synchronous field scan here. On large fields it
    -- causes a visible hitch shortly after the sprayer is switched off.
    -- DiseaseGrid is authoritative; regular daily/growth scans reconcile the
    -- cached field entry without blocking the treatment frame.
    debugLog("season protection painted: farmland=%s category=%s", tostring(farmlandId), tostring(category))
    return true
end

function DiseaseCore:onCareActionAt(tool, action, _, _)
    if tool == nil or action == nil then return 0 end
    if action == "sprayer" then return 0 end
    local root = tool.rootNode or (tool.components and tool.components[1] and tool.components[1].node)
    local x, _, z = nil, nil, nil
    if root ~= nil then
        x, _, z = getWorldTranslation(root)
    end

    local farmlandId = getFarmlandIdFromWorkAreas(tool)
    if farmlandId == nil and x ~= nil then
        farmlandId = getFieldKeyAtXZ(x, z)
    end
    if farmlandId == nil then return 0 end

    local entry = self:getOrCreateEntry(farmlandId)
    local clearedTotal = 0
    if DiseaseGrid ~= nil then
        local usedWorkArea = false
        local specWorkArea = tool.spec_workArea
        local workAreas = specWorkArea and specWorkArea.workAreas or tool.workAreas

        if workAreas ~= nil then
            local terrainSize = g_currentMission and g_currentMission.terrainSize or 2048
            local gridWidth = DiseaseGrid.width or 1024
            local cellSize = terrainSize / math.max(gridWidth, 1)
            local margin = math.max(0.75, cellSize * 0.75)

            for _, workArea in pairs(workAreas) do
                if workArea ~= nil and workArea.start ~= nil and workArea.width ~= nil and workArea.height ~= nil then
                    clearedTotal = clearedTotal + (DiseaseGrid:clearParallelogramByCure(workArea.start, workArea.width, workArea.height, margin, action) or 0)
                    usedWorkArea = true
                end
            end
        end

        if not usedWorkArea and x ~= nil then
            local threat = self:getThreatDef(entry.threatKey)
            local targeted = threat ~= nil and contains(threat.cures, action)
            local radius = targeted and 9.0 or 5.5
            clearedTotal = clearedTotal + (DiseaseGrid:clearAroundWorldPos(x, z, radius) or 0)
        end

        if clearedTotal > 0 then
            entry.neglectDays = 0
            entry.healStreak = 0
            entry.exposureDays = 0
            self:markFieldProtected(farmlandId, entry.category, nil)
        end
    end

    if clearedTotal > 0 then
        self._pendingFieldRescans = self._pendingFieldRescans or {}
        self._pendingFieldRescans[farmlandId] = true
        self._pendingFieldRescanTimerMs = math.min(self._pendingFieldRescanTimerMs or 0, 0)
        if self._pendingFieldRescanTimerMs <= 0 then
            self._pendingFieldRescanTimerMs = 350
        end
    end

    return clearedTotal
end

function DiseaseCore:getDiseaseLabel(farmlandId)
    local entry = self.fields[farmlandId]
    if entry == nil or not entry.cropActive then return g_i18n:getText("bmp_threat_noCrop") end
    local localThreat = self:getLocalThreatState(farmlandId)
    local threatKey = localThreat and localThreat.threatKey or entry.threatKey
    local state = tonumber((localThreat and localThreat.severity) or entry.state or 0) or 0
    if threatKey == nil or state <= 0 then return g_i18n:getText("bmp_dis_none") end
    local threatName = (g_i18n and g_i18n:getText(threatKey)) or threatKey
    local stateLabel = (state <= 0 and g_i18n:getText("bmp_dis_none")) or (state == 1 and g_i18n:getText("bmp_dis_low")) or (state == 2 and g_i18n:getText("bmp_dis_med")) or g_i18n:getText("bmp_dis_high")
    return string.format("%s: %s", threatName, stateLabel)
end

function DiseaseCore:consoleDebugLog(enabledStr)
    if enabledStr ~= nil and enabledStr ~= "" then
        local normalized = string.lower(tostring(enabledStr))
        _G.BMP_DISEASE_DEBUG_LOG = normalized == "1" or normalized == "true" or normalized == "on" or normalized == "yes"
    end

    print(string.format("Crop disease debug log: %s", _G.BMP_DISEASE_DEBUG_LOG == true and "ON" or "OFF"))
end

function DiseaseCore:consoleHerePrint()
    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId == nil then print("No field under player") return end
    self:consolePrint(tostring(farmlandId))
end

function DiseaseCore:consoleHereSet(key, stateStr)
    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId == nil then print("No field under player") return end
    self:consoleSetDisease(tostring(farmlandId), key, stateStr)
end

function DiseaseCore:consoleList()
    if g_fieldManager == nil or g_fieldManager.getFields == nil then print("No fields") return end
    for _, field in ipairs(g_fieldManager:getFields()) do
        local farmlandId = getFieldKey(field) or -1
        print(string.format("fieldId=%s farmlandId=%s hasField=%s", tostring(farmlandId), tostring(field.farmland and field.farmland.id or -1), tostring(field ~= nil)))
    end
end

function DiseaseCore:consolePrint(fidStr)
    local farmlandId = tonumber(fidStr or "")
    if farmlandId == nil then print("Usage: gsDiseasePrint <farmlandId>") return end
    self:ensureSeedForFarmland(farmlandId)
    local entry = self.fields[farmlandId]
    if entry == nil then print(("No record for farmland %d"):format(farmlandId)) return end
    local scan = entry.lastScan or {}
    print(("Farmland %d: active=%s fruit=%s growth=%s window=%s key=%s state=%d mult=%.2f risk=%.3f threatRisk=%.3f activeDays=%d exposure=%d spray=%.3f lime=%.3f plow=%.3f weed=%.3f water=%.3f"):format(
        farmlandId, tostring(entry.cropActive == true), tostring(scan.fruitName or getFruitNameByIndex(entry.lastFruitIndex) or "NONE"),
        tostring(scan.growthState or entry.lastGrowthState or 0), tostring(scan.growthWindow or entry.lastGrowthWindow or "-"),
        tostring(entry.threatKey or "none"), entry.state or 0, self:getYieldMultiplier(farmlandId), entry.lastRisk or 0, entry.lastThreatRisk or 0,
        entry.activeDays or 0, entry.exposureDays or 0, scan.spray or -1, scan.lime or -1, scan.plow or -1, scan.weed or -1, scan.water or -1
    ))
end

function DiseaseCore:consoleSetDisease(fidStr, key, stateStr)
    if g_server == nil then
        if DiseaseConsoleCommandEvent ~= nil and DiseaseConsoleCommandEvent.send ~= nil then
            DiseaseConsoleCommandEvent.send("gsDiseaseSet", fidStr, key, stateStr)
        end
        print("Disease command forwarded to server: gsDiseaseSet")
        return
    end

    local defs = getDefs()
    local farmlandId = tonumber(fidStr or "")
    if farmlandId == nil then print("Usage: gsDiseaseSet <farmlandId> <key> <state0..3>") return end
    if defs == nil or defs:getThreat(key) == nil then print("Unknown threat key: " .. tostring(key)) return end
    self:ensureSeedForFarmland(farmlandId)
    local entry = self:getOrCreateEntry(farmlandId)
    entry.threatKey = key
    entry.category = defs:getThreatCategory(key)
    entry.state = clamp(tonumber(stateStr or 0) or 0, 0, 3)
    entry.neglectDays = 0
    entry.healStreak = 0
    entry.exposureDays = 0
    local field = getFieldByFarmlandId(farmlandId)
    if DiseaseGrid ~= nil and field ~= nil then
        DiseaseGrid:clearField(field)
        if entry.state > 0 then
            DiseaseGrid:applyRandomSpots(field, defs:getThreatId(key), entry.state, math.max(1, entry.state * 2), 5.0, 10.0 + entry.state * 2.0)
        end
        self:scanField(field, false, false)
    end
    print(("Threat set: farmland %d -> %s, state=%d"):format(farmlandId, key, entry.state))
end

function DiseaseCore:consoleRandomizeAll()
    if g_server == nil then
        if DiseaseConsoleCommandEvent ~= nil and DiseaseConsoleCommandEvent.send ~= nil then
            DiseaseConsoleCommandEvent.send("gsDiseaseRandomizeAll")
        end
        print("Disease command forwarded to server: gsDiseaseRandomizeAll")
        return
    end

    if DiseaseGrid ~= nil and g_fieldManager ~= nil and g_fieldManager.getFields ~= nil then
        for _, field in ipairs(g_fieldManager:getFields()) do
            DiseaseGrid:clearField(field)
        end
    end
    self:scanAllFields(true, false)
    print("Threats rebuilt from current crop state.")
end
function DiseaseCore:consoleApplyDamageAll()
    if g_server == nil then
        if DiseaseConsoleCommandEvent ~= nil and DiseaseConsoleCommandEvent.send ~= nil then
            DiseaseConsoleCommandEvent.send("gsDiseaseApplyDamageAll")
        end
        print("Disease command forwarded to server: gsDiseaseApplyDamageAll")
        return
    end

    local changed, _, destroyed = self:applyDailyFieldDamage()
    print(string.format("Threat damage applied: changed=%d destroyed=%d", changed or 0, destroyed or 0))
end

function DiseaseCore:consoleForceAll(stateStr, key)
    if g_server == nil then
        if DiseaseConsoleCommandEvent ~= nil and DiseaseConsoleCommandEvent.send ~= nil then
            DiseaseConsoleCommandEvent.send("gsDiseaseForceAll", stateStr, key)
        end
        print("Disease command forwarded to server: gsDiseaseForceAll")
        return
    end

    local defs = getDefs()
    local state = clamp(tonumber(stateStr or 0) or 0, 0, 3)
    if g_fieldManager == nil or g_fieldManager.getFields == nil then print("No fields") return end
    local count = 0
    for _, field in ipairs(g_fieldManager:getFields()) do
        self:scanField(field, false, false)
        local farmlandId = getFieldKey(field)
        local entry = farmlandId ~= nil and self.fields[farmlandId] or nil
        if entry ~= nil and entry.cropActive then
            local threatKey = key or entry.threatKey or (defs and defs.DEFAULT_THREAT_KEY) or "bmp_dis_powdery"
            if defs == nil or defs:getThreat(threatKey) ~= nil then
                entry.threatKey = threatKey
                entry.category = defs and defs:getThreatCategory(threatKey) or nil
                entry.state = state
                entry.neglectDays = 0
                entry.healStreak = 0
                entry.exposureDays = 0
                if DiseaseGrid ~= nil then
                    DiseaseGrid:clearField(field)
                    if state > 0 then
                        DiseaseGrid:applyRandomSpots(field, defs:getThreatId(threatKey), state, math.max(1, state * 2), 5.0, 10.0 + state * 2.0)
                    end
                end
                count = count + 1
            end
        end
    end
    print(string.format("Forced threat state=%d%s on %d active crops.", state, (key and key ~= "" and (" key=" .. key) or ""), count))
end

function DiseaseCore:consoleForceHere(stateStr, key)
    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId == nil then print("No field under player") return end
    local defs = getDefs()
    self:consoleSetDisease(tostring(farmlandId), key or ((defs and defs.DEFAULT_THREAT_KEY) or "bmp_dis_powdery"), stateStr or "2")
end

function DiseaseCore:consoleInfectHere(key, stateStr, radiusStr)
    if g_server == nil then
        print("gsDiseaseInfectHere is available on server/host only in multiplayer.")
        return
    end

    local defs = getDefs()
    local x, _, z = getReferenceWorldPosition()
    if x == nil then
        print("No player or controlled vehicle")
        return
    end
    if defs == nil or defs:getThreat(key) == nil then
        print("Usage: gsDiseaseInfectHere <key> <state0..3> [radiusM]")
        return
    end
    if DiseaseGrid == nil then
        print("DiseaseGrid not loaded")
        return
    end

    local state = clamp(tonumber(stateStr or 0) or 0, 0, 3)
    local radius = math.max(2, tonumber(radiusStr or 8) or 8)
    local touched = DiseaseGrid:setCircleThreatWorld(x, z, radius, state, defs:getThreatId(key))

    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId ~= nil then
        local field = getFieldByFarmlandId(farmlandId)
        if field ~= nil then
            local entry = self:getOrCreateEntry(farmlandId)
            entry.threatKey = key
            entry.category = defs:getThreatCategory(key)
            self:scanField(field, false, false)
        end
    end

    local hereSeverity, hereThreatId = DiseaseGrid:getThreatAtWorldPos(x, z)
    print(string.format("Threat circle applied: key=%s state=%d radius=%.1f cells=%d hereSeverity=%d hereThreatId=%d", tostring(key), state, radius, touched or 0, hereSeverity or 0, hereThreatId or 0))
end

function DiseaseCore:consoleRandomHere(stateStr, radiusStr)
    if g_server == nil then
        print("gsDiseaseRandomHere is available on server/host only in multiplayer.")
        return
    end

    local defs = getDefs()
    local x, _, z = getReferenceWorldPosition()
    if x == nil then
        print("No player or controlled vehicle")
        return
    end
    if DiseaseGrid == nil then
        print("DiseaseGrid not loaded")
        return
    end

    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId == nil then
        print("No field under player")
        return
    end

    local field = getFieldByFarmlandId(farmlandId)
    if field == nil then
        print("No field for farmland under player")
        return
    end

    local snapshot = getFieldSnapshot(field)
    if snapshot == nil or not snapshot.isActiveCrop or snapshot.fruitName == nil then
        print("No active crop under player")
        return
    end

    local compatibleKeys = getCompatibleThreatKeys(snapshot)
    if #compatibleKeys == 0 then
        print(string.format("No compatible threats for crop=%s window=%s", tostring(snapshot.fruitName), tostring(snapshot.growthWindow)))
        return
    end

    local pickedKey = compatibleKeys[math.random(1, #compatibleKeys)]
    local state = clamp(tonumber(stateStr or 2) or 2, 0, 3)
    local radius = math.max(2, tonumber(radiusStr or 8) or 8)
    local threatId = defs and defs.getThreatId and defs:getThreatId(pickedKey) or 0

    local touched = DiseaseGrid:setCircleThreatWorld(x, z, radius, state, threatId)

    local entry = self:getOrCreateEntry(farmlandId)
    entry.threatKey = pickedKey
    entry.category = defs and defs.getThreatCategory and defs:getThreatCategory(pickedKey) or nil
    entry.state = state
    entry.neglectDays = 0
    entry.healStreak = 0
    entry.exposureDays = 0

    self:scanField(field, false, false)
    local hereSeverity, hereThreatId = DiseaseGrid:getThreatAtWorldPos(x, z)
    print(string.format("Random compatible threat applied: farmland=%d crop=%s key=%s state=%d radius=%.1f cells=%d hereSeverity=%d hereThreatId=%d", farmlandId, tostring(snapshot.fruitName), tostring(pickedKey), state, radius, touched or 0, hereSeverity or 0, hereThreatId or 0))
end

function DiseaseCore:consoleDestroyHere(radiusStr, countStr)
    if g_server == nil then
        print("gsDiseaseDestroyHere is available on server/host only in multiplayer.")
        return
    end

    local x, _, z = getReferenceWorldPosition()
    if x == nil then
        print("No player or controlled vehicle")
        return
    end

    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId == nil then
        print("No field under player")
        return
    end

    local field = getFieldByFarmlandId(farmlandId)
    if field == nil then
        print("No field for farmland under player")
        return
    end
    if DiseaseFieldDamage == nil then
        print("DiseaseFieldDamage not loaded")
        return
    end

    local radius = math.max(2, tonumber(radiusStr or 12) or 12)
    local count = math.max(1, tonumber(countStr or 8) or 8)
    local infectedSpots = {}
    for _ = 1, count do
        local sx = x + MathUtil.randomFloat(-radius, radius)
        local sz = z + MathUtil.randomFloat(-radius, radius)
        table.insert(infectedSpots, {x = sx, z = sz, severity = 3, threatId = 0})
    end

    local changed, _, destroyed = DiseaseFieldDamage:applyDamagePatches(field, 3, 1.0, "disease", infectedSpots)
    print(string.format("Destroy here applied: radius=%.1f spots=%d changed=%d destroyed=%d", radius, count, changed or 0, destroyed or 0))
end

function DiseaseCore:consoleClearHere(radiusStr)
    if g_server == nil then
        print("gsDiseaseClearHere is available on server/host only in multiplayer.")
        return
    end

    local x, _, z = getReferenceWorldPosition()
    if x == nil then
        print("No player or controlled vehicle")
        return
    end
    if DiseaseGrid == nil then
        print("DiseaseGrid not loaded")
        return
    end

    local radius = math.max(2, tonumber(radiusStr or 8) or 8)
    DiseaseGrid:clearAroundWorldPos(x, z, radius)

    local farmlandId = getFarmlandIdUnderPlayer()
    if farmlandId ~= nil then
        local field = getFieldByFarmlandId(farmlandId)
        if field ~= nil then
            self:scanField(field, false, false)
        end
    end

    print(string.format("Threat circle cleared: radius=%.1f", radius))
end

function DiseaseCore:broadcastNetworkStateIfNeeded()
    if g_server == nil then
        return
    end

    if DiseaseGridDeltaEvent ~= nil and DiseaseGrid ~= nil and DiseaseGrid.consumeChangedCellBatch ~= nil then
        local changedCells = DiseaseGrid:consumeChangedCellBatch(8192)
        if #changedCells > 0 then
            DiseaseGridDeltaEvent.broadcast(changedCells)
        end
    end

    if DiseaseFieldStateEvent ~= nil then
        local changedEntries = self:collectChangedNetworkEntries(false)
        if #changedEntries > 0 then
            DiseaseFieldStateEvent.broadcast(changedEntries, false)
        end
    end
end

function DiseaseCore:sendFullStateToConnection(connection)
    if g_server == nil or connection == nil then
        return
    end

    if DiseaseFieldStateEvent ~= nil then
        local entries = self:collectChangedNetworkEntries(true)
        DiseaseFieldStateEvent.sendToConnection(connection, entries, true)
    end

    if DiseaseGrid == nil or DiseaseGridSnapshotEvent == nil or DiseaseGrid.getSnapshotCellBatch == nil then
        return
    end

    local nextIndex = 0
    local firstChunk = true

    while true do
        local cells, updatedIndex, isLast = DiseaseGrid:getSnapshotCellBatch(nextIndex, self._networkSnapshotBatchSize or 4096)
        DiseaseGridSnapshotEvent.sendToConnection(connection, cells, firstChunk, isLast)
        firstChunk = false
        nextIndex = updatedIndex
        if isLast then
            break
        end
    end
end

function DiseaseCore:onClientGridSnapshotChunk(_, isLast)
    if isLast then
        self._networkGridSnapshotComplete = true
        self._networkHasFullSync = self._networkGridSnapshotComplete and self._networkFieldStateReceived
    end
end

function DiseaseCore:runRemoteConsoleCommand(_, commandName, arg1, arg2, arg3)
    if g_server == nil or commandName == nil then
        return
    end

    local handlers = {
        gsDiseaseSet = self.consoleSetDisease,
        gsDiseaseRandomizeAll = self.consoleRandomizeAll,
        gsDiseaseApplyDamageAll = self.consoleApplyDamageAll,
        gsDiseaseForceAll = self.consoleForceAll
    }

    local handler = handlers[commandName]
    if handler ~= nil then
        handler(self, arg1, arg2, arg3)
    end
end

function DiseaseCore:loadFromSavegame()
    if g_server == nil then return end
    local path = self._savePath
    if path == nil or not fileExists(path) then return end
    local xmlFile = loadXMLFile("cropDiseasesXML", path)
    if xmlFile == nil or xmlFile == 0 then return end
    local index = 0
    while true do
        local key = string.format("cropDiseases.field(%d)", index)
        if not hasXMLProperty(xmlFile, key) then break end
        local farmlandId = getXMLInt(xmlFile, key .. "#fieldId") or getXMLInt(xmlFile, key .. "#farmlandId")
        if farmlandId ~= nil then
            self.fields[farmlandId] = {
                threatKey = getXMLString(xmlFile, key .. "#threatKey") or getXMLString(xmlFile, key .. "#diseaseKey"),
                category = getXMLString(xmlFile, key .. "#category"), state = getXMLInt(xmlFile, key .. "#state") or 0,
                cropActive = getXMLBool(xmlFile, key .. "#cropActive") or false, sessionId = getXMLInt(xmlFile, key .. "#sessionId") or 0,
                activeDays = getXMLInt(xmlFile, key .. "#activeDays") or 0, exposureDays = getXMLInt(xmlFile, key .. "#exposureDays") or 0,
                neglectDays = getXMLInt(xmlFile, key .. "#neglectDays") or 0, healStreak = getXMLInt(xmlFile, key .. "#healStreak") or 0,
                seasonThreatCount = getXMLInt(xmlFile, key .. "#seasonThreatCount") or 0,
                seasonThreatLimit = getXMLInt(xmlFile, key .. "#seasonThreatLimit"),
                seasonThreatHistory = getXMLString(xmlFile, key .. "#seasonThreatHistory") or "",
                lastSeasonThreatKey = getXMLString(xmlFile, key .. "#lastSeasonThreatKey"),
                protectedUntilDay = getXMLInt(xmlFile, key .. "#protectedUntilDay") or 0,
                protectedCategory = getXMLString(xmlFile, key .. "#protectedCategory"),
                lastTreatedDay = getXMLInt(xmlFile, key .. "#lastTreatedDay"),
                lastRisk = getXMLFloat(xmlFile, key .. "#lastRisk") or 0, lastThreatRisk = getXMLFloat(xmlFile, key .. "#lastThreatRisk") or 0,
                lastFruitIndex = getXMLInt(xmlFile, key .. "#lastFruitIndex"), lastGrowthState = getXMLInt(xmlFile, key .. "#lastGrowthState") or 0,
                lastGrowthWindow = getXMLString(xmlFile, key .. "#lastGrowthWindow"),
                stageHours = getXMLFloat(xmlFile, key .. "#stageHours") or 0,
                stageExpanded = getXMLBool(xmlFile, key .. "#stageExpanded") or false,
                stageExpandDelayHours = getXMLFloat(xmlFile, key .. "#stageExpandDelayHours") or 0,
                damageReady = getXMLBool(xmlFile, key .. "#damageReady") or false,
                stage3DamageDelayHours = getXMLFloat(xmlFile, key .. "#stage3DamageDelayHours") or 0,
                stage3NextExpandHours = getXMLFloat(xmlFile, key .. "#stage3NextExpandHours") or 0,
                stage3DamageHoursToday = getXMLFloat(xmlFile, key .. "#stage3DamageHoursToday") or 0,
                stage3DamageDay = getXMLInt(xmlFile, key .. "#stage3DamageDay"),
                spreadScheduleDay = getXMLInt(xmlFile, key .. "#spreadScheduleDay"),
                spreadsPerDay = getXMLInt(xmlFile, key .. "#spreadsPerDay") or 0,
                spreadsDoneToday = getXMLInt(xmlFile, key .. "#spreadsDoneToday") or 0,
                nextSpreadHourOfDay = getXMLFloat(xmlFile, key .. "#nextSpreadHourOfDay"),
                nextDamageHourOfDay = getXMLFloat(xmlFile, key .. "#nextDamageHourOfDay"),
                lastScan = {
                    fruitName = getXMLString(xmlFile, key .. "#fruitName"), growthState = getXMLInt(xmlFile, key .. "#growthState"),
                    growthWindow = getXMLString(xmlFile, key .. "#growthWindow"), spray = getXMLFloat(xmlFile, key .. "#spray") or 0,
                    lime = getXMLFloat(xmlFile, key .. "#lime") or 0, plow = getXMLFloat(xmlFile, key .. "#plow") or 0,
                    rollerNeed = getXMLFloat(xmlFile, key .. "#rollerNeed") or 0, weed = getXMLFloat(xmlFile, key .. "#weed") or 0,
                    stubble = getXMLFloat(xmlFile, key .. "#stubble") or 0, water = getXMLFloat(xmlFile, key .. "#water") or 0
                }
            }
            local entry = self.fields[farmlandId]
            local defs = getDefs()
            if entry.threatKey ~= nil and defs ~= nil and defs.getThreatCategory ~= nil then
                entry.category = defs:getThreatCategory(entry.threatKey) or entry.category
            end
            if entry.cropActive == true and entry.seasonThreatLimit == nil then
                entry.seasonThreatLimit = self:rollSeasonThreatLimit(getTuning())
            end
            if entry.cropActive == true and nz(entry.state, 0) > 0 and entry.threatKey ~= nil and nz(entry.seasonThreatCount, 0) <= 0 then
                self:registerSeasonThreat(entry, entry.threatKey)
            end
            local field = getFieldByFarmlandId(farmlandId)
            if nz(entry.state, 0) >= 3 then
                local profile = getFieldBehaviorProfile(field, getTuning())
                if nz(entry.stage3DamageDelayHours, 0) <= 0 then
                    entry.stage3DamageDelayHours = nz(profile.stage3DamageTickHours, 6.0)
                end
                if nz(entry.stage3NextExpandHours, 0) <= 0 then
                    entry.stage3NextExpandHours = math.max(0.1, nz(entry.stageHours, 0) + MathUtil.randomFloat(
                        nz(profile.stage3RepeatExpandHoursMin, 3.0),
                        nz(profile.stage3RepeatExpandHoursMax, 5.0)
                    ))
                end
                if entry.stage3DamageDay == nil then
                    entry.stage3DamageDay = getCurrentDayToken()
                end
            elseif nz(entry.state, 0) > 0 and nz(entry.stageExpandDelayHours, 0) <= 0 then
                local profile = getFieldBehaviorProfile(field, getTuning())
                entry.stageExpandDelayHours = math.max(0.2, nz(entry.stageHours, 0) + MathUtil.randomFloat(
                    nz(profile.stageExpandDelayHoursMin, 5.0),
                    nz(profile.stageExpandDelayHoursMax, 12.0)
                ))
            end
        end
        index = index + 1
    end
    delete(xmlFile)
end

function DiseaseCore:saveToSavegame()
    if g_server == nil then return end
    local path = self._savePath
    if path == nil then return end
    local xmlFile = createXMLFile("cropDiseasesXML", path, "cropDiseases")
    if xmlFile == nil or xmlFile == 0 then return end
    local index = 0
    for farmlandId, entry in pairs(self.fields) do
        local key = string.format("cropDiseases.field(%d)", index)
        local scan = entry.lastScan or {}
        local field = getFieldByFarmlandId(farmlandId)
        local farmlandOnlyId = field and field.farmland and field.farmland.id or farmlandId
        if entry.threatKey ~= nil then
            entry.category = getThreatCategorySafe(entry.threatKey, entry.category)
        end
        setXMLInt(xmlFile, key .. "#fieldId", farmlandId)
        setXMLInt(xmlFile, key .. "#farmlandId", farmlandId)
        setXMLInt(xmlFile, key .. "#ownerFarmlandId", farmlandOnlyId)
        if entry.threatKey ~= nil then setXMLString(xmlFile, key .. "#threatKey", entry.threatKey) end
        if entry.category ~= nil then setXMLString(xmlFile, key .. "#category", entry.category) end
        setXMLBool(xmlFile, key .. "#cropActive", entry.cropActive == true)
        setXMLInt(xmlFile, key .. "#sessionId", entry.sessionId or 0)
        setXMLInt(xmlFile, key .. "#state", entry.state or 0)
        if entry.lastFruitIndex ~= nil then setXMLInt(xmlFile, key .. "#lastFruitIndex", entry.lastFruitIndex) end
        setXMLInt(xmlFile, key .. "#lastGrowthState", entry.lastGrowthState or 0)
        if entry.lastGrowthWindow ~= nil then setXMLString(xmlFile, key .. "#lastGrowthWindow", entry.lastGrowthWindow) end
        setXMLInt(xmlFile, key .. "#activeDays", entry.activeDays or 0)
        setXMLInt(xmlFile, key .. "#exposureDays", entry.exposureDays or 0)
        setXMLInt(xmlFile, key .. "#neglectDays", entry.neglectDays or 0)
        setXMLInt(xmlFile, key .. "#healStreak", entry.healStreak or 0)
        setXMLInt(xmlFile, key .. "#seasonThreatCount", entry.seasonThreatCount or 0)
        if entry.seasonThreatLimit ~= nil then setXMLInt(xmlFile, key .. "#seasonThreatLimit", entry.seasonThreatLimit) end
        if entry.seasonThreatHistory ~= nil and entry.seasonThreatHistory ~= "" then setXMLString(xmlFile, key .. "#seasonThreatHistory", entry.seasonThreatHistory) end
        if entry.lastSeasonThreatKey ~= nil then setXMLString(xmlFile, key .. "#lastSeasonThreatKey", entry.lastSeasonThreatKey) end
        setXMLInt(xmlFile, key .. "#protectedUntilDay", entry.protectedUntilDay or 0)
        if entry.protectedCategory ~= nil then setXMLString(xmlFile, key .. "#protectedCategory", entry.protectedCategory) end
        if entry.lastTreatedDay ~= nil then setXMLInt(xmlFile, key .. "#lastTreatedDay", entry.lastTreatedDay) end
        setXMLFloat(xmlFile, key .. "#stageHours", entry.stageHours or 0)
        setXMLBool(xmlFile, key .. "#stageExpanded", entry.stageExpanded == true)
        setXMLFloat(xmlFile, key .. "#stageExpandDelayHours", entry.stageExpandDelayHours or 0)
        setXMLBool(xmlFile, key .. "#damageReady", entry.damageReady == true)
        setXMLFloat(xmlFile, key .. "#stage3DamageDelayHours", entry.stage3DamageDelayHours or 0)
        setXMLFloat(xmlFile, key .. "#stage3NextExpandHours", entry.stage3NextExpandHours or 0)
        setXMLFloat(xmlFile, key .. "#stage3DamageHoursToday", entry.stage3DamageHoursToday or 0)
        if entry.stage3DamageDay ~= nil then setXMLInt(xmlFile, key .. "#stage3DamageDay", entry.stage3DamageDay) end
        if entry.spreadScheduleDay ~= nil then setXMLInt(xmlFile, key .. "#spreadScheduleDay", entry.spreadScheduleDay) end
        setXMLInt(xmlFile, key .. "#spreadsPerDay", entry.spreadsPerDay or 0)
        setXMLInt(xmlFile, key .. "#spreadsDoneToday", entry.spreadsDoneToday or 0)
        if entry.nextSpreadHourOfDay ~= nil then setXMLFloat(xmlFile, key .. "#nextSpreadHourOfDay", entry.nextSpreadHourOfDay) end
        if entry.nextDamageHourOfDay ~= nil then setXMLFloat(xmlFile, key .. "#nextDamageHourOfDay", entry.nextDamageHourOfDay) end
        setXMLFloat(xmlFile, key .. "#lastRisk", entry.lastRisk or 0)
        setXMLFloat(xmlFile, key .. "#lastThreatRisk", entry.lastThreatRisk or 0)
        if scan.fruitName ~= nil then setXMLString(xmlFile, key .. "#fruitName", scan.fruitName) end
        if scan.growthState ~= nil then setXMLInt(xmlFile, key .. "#growthState", scan.growthState) end
        if scan.growthWindow ~= nil then setXMLString(xmlFile, key .. "#growthWindow", scan.growthWindow) end
        setXMLFloat(xmlFile, key .. "#spray", scan.spray or 0)
        setXMLFloat(xmlFile, key .. "#lime", scan.lime or 0)
        setXMLFloat(xmlFile, key .. "#plow", scan.plow or 0)
        setXMLFloat(xmlFile, key .. "#rollerNeed", scan.rollerNeed or 0)
        setXMLFloat(xmlFile, key .. "#weed", scan.weed or 0)
        setXMLFloat(xmlFile, key .. "#stubble", scan.stubble or 0)
        setXMLFloat(xmlFile, key .. "#water", scan.water or 0)
        index = index + 1
    end
    saveXMLFile(xmlFile)
    delete(xmlFile)
end

addModEventListener(DiseaseCore)












