DiseaseGrid = {}
DiseaseGrid.MOD_NAME = g_currentModName or "FS25_CropDiseases_BMP"

local function clamp(v, a, b)
    return math.max(a, math.min(b, v))
end

local function safeDiv(a, b, fallback)
    if b == nil or b == 0 then
        return fallback or 0
    end
    return a / b
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 .. "/cropThreatMask.grle"
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 worldToMap(width, height, worldX, worldZ)
    local terrainSize = g_currentMission and g_currentMission.terrainSize or 2048
    local x = math.floor(width * (worldX + terrainSize * 0.5) / terrainSize)
    local z = math.floor(height * (worldZ + terrainSize * 0.5) / terrainSize)
    return clamp(x, 0, width - 1), clamp(z, 0, height - 1)
end

local function mapToWorld(width, height, mapX, mapZ)
    local terrainSize = g_currentMission and g_currentMission.terrainSize or 2048
    local worldX = (mapX + 0.5) / width * terrainSize - terrainSize * 0.5
    local worldZ = (mapZ + 0.5) / height * terrainSize - terrainSize * 0.5
    return worldX, worldZ
end

local function getFieldGroundMapData()
    if FSDensityMapUtil ~= nil and FSDensityMapUtil.getFieldDataAtWorldPosition ~= nil then
        return {
            mode = "fieldDataUtil"
        }
    end

    local fgs = g_currentMission ~= nil and g_currentMission.fieldGroundSystem or nil
    if fgs == nil or fgs.getDensityMapData == nil then
        return nil
    end

    local mapId, firstChannel, numChannels = fgs:getDensityMapData(FieldDensityMap.GROUND_TYPE)
    if mapId == nil or firstChannel == nil or numChannels == nil then
        return nil
    end

    if DensityMapModifier == nil or DensityMapFilter == nil then
        return nil
    end

    local terrainNode = g_terrainNode or (g_currentMission ~= nil and g_currentMission.terrainRootNode) or nil
    local modifier = DensityMapModifier.new(mapId, firstChannel, numChannels, terrainNode)
    local filter = DensityMapFilter.new(modifier)
    filter:setValueCompareParams(DensityValueCompareType.GREATER, 0)

    return {
        mode = "modifier",
        modifier = modifier,
        filter = filter
    }
end

local function getFieldGroundValueAtWorld(data, worldX, worldZ)
    if data == nil or worldX == nil or worldZ == nil then
        return nil
    end

    if data.mode == "fieldDataUtil" then
        local y = 0
        local terrainNode = g_terrainNode or (g_currentMission ~= nil and g_currentMission.terrainRootNode) or nil
        if getTerrainHeightAtWorldPos ~= nil and terrainNode ~= nil then
            local okHeight, height = pcall(getTerrainHeightAtWorldPos, terrainNode, worldX, 0, worldZ)
            if okHeight then
                y = height or 0
            end
        end

        local ok, isOnField = pcall(FSDensityMapUtil.getFieldDataAtWorldPosition, worldX, y, worldZ)
        if ok then
            return isOnField == true and 1 or 0
        end

        return nil
    end

    if data.mode == "modifier" and data.modifier ~= nil and data.filter ~= nil then
        data.modifier:setParallelogramWorldCoords(worldX - 0.25, worldZ - 0.25, 0.5, 0, 0, 0.5, DensityCoordType.POINT_VECTOR_VECTOR)
        local _, area = data.modifier:executeGet(data.filter)
        return (area or 0) > 0 and 1 or 0
    end

    return nil
end

local function normalize2D(x, z)
    local length = MathUtil.vector2Length(x, z)
    if length == nil or length < 0.0001 then
        return 0, 0
    end
    return x / length, z / length
end

local function setMapPoint(mapId, x, z, firstChannel, numChannels, value)
    if mapId == nil or x == nil or z == nil then
        return false
    end

    if setBitVectorMapPoint ~= nil then
        setBitVectorMapPoint(mapId, x, z, firstChannel, numChannels, value)
    else
        setBitVectorMapParallelogram(mapId, x, z, 1, 0, 0, 1, firstChannel, numChannels, value)
    end

    local readValue = getBitVectorMapPoint(mapId, x, z, firstChannel, numChannels)
    return readValue == value
end

local function getThreatCell(mapId, x, z)
    if mapId == nil or x == nil or z == nil then
        return 0, 0
    end

    local severity = getBitVectorMapPoint(mapId, x, z, 0, 2) or 0
    local threatId = getBitVectorMapPoint(mapId, x, z, 2, 4) or 0
    return severity, threatId
end

local function writeThreatCell(grid, x, z, severity, threatId)
    if grid == nil or grid.mapId == nil or x == nil or z == nil then
        return false
    end

    local targetSeverity = clamp(tonumber(severity or 0) or 0, 0, 3)
    local targetThreatId = clamp(tonumber(threatId or 0) or 0, 0, 15)
    if targetSeverity > 0 and targetThreatId > 0 and DiseaseProtectionGrid ~= nil then
        local defs = _G.DiseaseDefs
        local threatKey = defs ~= nil and defs.getThreatKeyById ~= nil and defs:getThreatKeyById(targetThreatId) or nil
        local category = defs ~= nil and defs.getThreatCategory ~= nil and defs:getThreatCategory(threatKey) or nil
        if category ~= nil and DiseaseProtectionGrid:isProtectedCell(x, z, category) then
            return false
        end
    end
    local oldSeverity, oldThreatId = getThreatCell(grid.mapId, x, z)

    if oldSeverity == targetSeverity and oldThreatId == targetThreatId then
        return false
    end

    local severityOk = setMapPoint(grid.mapId, x, z, 0, 2, targetSeverity)
    local threatOk = setMapPoint(grid.mapId, x, z, 2, 4, targetThreatId)
    if not (severityOk and threatOk) then
        return false
    end

    if DiseaseMapFrame ~= nil and DiseaseMapFrame.onThreatCellChanged ~= nil then
        DiseaseMapFrame:onThreatCellChanged(x, z, targetSeverity, targetThreatId)
    end

    if grid.recordChangedCell ~= nil then
        grid:recordChangedCell(x, z, targetSeverity, targetThreatId)
    end

    return true
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 getDebugWorldPosition()
    local cameraManager = g_cameraManager
    if cameraManager ~= nil and cameraManager.getActiveCamera ~= nil then
        local camera = cameraManager:getActiveCamera()
        local cameraNode = nil
        if type(camera) == "number" then
            cameraNode = camera
        elseif camera ~= nil then
            cameraNode = camera.cameraNode or camera.rootNode or camera
        end
        if cameraNode ~= nil then
            return getWorldTranslation(cameraNode)
        end
    end

    return getReferenceWorldPosition()
end

local function applyFieldPixels(grid, field, callback)
    if grid == nil or field == nil or callback == nil then
        return 0
    end

    local polygon = getFieldPolygon2D(field)
    if polygon == nil then
        return 0
    end

    local minX, maxX, minZ, maxZ = polygon:getBoundingBox()
    if minX == nil then
        return 0
    end

    local startX, startZ = worldToMap(grid.width, grid.height, minX, minZ)
    local endX, endZ = worldToMap(grid.width, grid.height, maxX, maxZ)
    local touched = 0

    for z = startZ, endZ do
        for x = startX, endX do
            local worldX, worldZ = mapToWorld(grid.width, grid.height, x, z)
            if polygon:getIsPosInside(worldX, worldZ) then
                callback(x, z)
                touched = touched + 1
            end
        end
    end

    return touched
end

local function createFieldBoundsJob(grid, field)
    if grid == nil or field == nil then
        return nil
    end

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

    local minX, maxX, minZ, maxZ = polygon:getBoundingBox()
    if minX == nil then
        return nil
    end

    local startX, startZ = worldToMap(grid.width, grid.height, minX, minZ)
    local endX, endZ = worldToMap(grid.width, grid.height, maxX, maxZ)
    return {
        field = field,
        polygon = polygon,
        startX = startX,
        endX = endX,
        startZ = startZ,
        endZ = endZ,
        currentX = startX,
        currentZ = startZ,
        changed = 0
    }
end

local function getRandomSpotCenter(field, polygon, radius)
    if field == nil or polygon == nil then
        return nil, nil
    end

    local minX, maxX, minZ, maxZ = polygon:getBoundingBox()
    if minX == nil then
        return nil, nil
    end

    if minX + radius >= maxX - radius or minZ + radius >= maxZ - radius then
        local cx, cz = field:getCenterOfFieldWorldPosition()
        if polygon:getIsCircleInside(cx, cz, radius * 0.6) then
            return cx, cz
        end
        return nil, nil
    end

    for _ = 1, 24 do
        local x = MathUtil.randomFloat(minX + radius, maxX - radius)
        local z = MathUtil.randomFloat(minZ + radius, maxZ - radius)
        if polygon:getIsCircleInside(x, z, radius) then
            return x, z
        end
    end

    local cx, cz = field:getCenterOfFieldWorldPosition()
    if polygon:getIsCircleInside(cx, cz, radius) then
        return cx, cz
    end

    return nil, nil
end

function DiseaseGrid:loadMap()
    local mission = g_currentMission
    if mission == nil then
        return
    end

    self.savePath = g_server ~= nil and buildSavePath() or nil
    self.mapId = createBitVectorMap("CropThreatGrid")
    local defaultSize = 1024

    local loaded = false
    if g_server ~= nil and self.savePath ~= nil and fileExists(self.savePath) then
        loaded = loadBitVectorMapFromFile(self.mapId, self.savePath, 6)
    end

    if not loaded then
        loadBitVectorMapNew(self.mapId, defaultSize, defaultSize, 6, false)
    end

    local mapWidth, mapHeight = getBitVectorMapSize(self.mapId)
    self.width = tonumber(mapWidth or 0) or 0
    self.height = tonumber(mapHeight or 0) or 0
    if self.width <= 0 or self.height <= 0 then
        delete(self.mapId)
        self.mapId = createBitVectorMap("CropThreatGridFallback")
        loadBitVectorMapNew(self.mapId, defaultSize, defaultSize, 6, false)
        mapWidth, mapHeight = getBitVectorMapSize(self.mapId)
        self.width = tonumber(mapWidth or defaultSize) or defaultSize
        self.height = tonumber(mapHeight or defaultSize) or defaultSize
    end
    if self.width == 1 and self.height == 1 then
        delete(self.mapId)
        self.mapId = createBitVectorMap("CropThreatGridRebuilt")
        loadBitVectorMapNew(self.mapId, defaultSize, defaultSize, 6, false)
        mapWidth, mapHeight = getBitVectorMapSize(self.mapId)
        self.width = tonumber(mapWidth or defaultSize) or defaultSize
        self.height = tonumber(mapHeight or defaultSize) or defaultSize
    end
    self.size = self.width

    self.severityModifier = DensityMapModifier.new(self.mapId, 0, 2, g_terrainNode)
    self.typeModifier = DensityMapModifier.new(self.mapId, 2, 4, g_terrainNode)
    self.debugEnabled = false
    self.debugRadius = 55
    self.isDirty = false
    self.changeRevision = 0
    self.saveTimer = 0
    self.saveIntervalMs = 15000
    self.changedCells = {}
    self.changedCellCount = 0
    self.networkTrackingSuppressed = false
    self.snapshotBatchSize = 4096

    addConsoleCommand("gsDiseaseDebug", "Toggle crop threat debug overlay: gsDiseaseDebug [0|1] [radiusM]", "consoleToggleDebug", self)
    addConsoleCommand("gsDiseaseDebugHere", "Print threat under player cell", "consoleDebugHere", self)
    addConsoleCommand("gsDiseaseGridTestHere", "Write/read a single threat cell under player: gsDiseaseGridTestHere [severity0..3] [threatId]", "consoleGridTestHere", self)
end

function DiseaseGrid:markDirty()
    self.isDirty = true
    self.changeRevision = (self.changeRevision or 0) + 1
end

function DiseaseGrid:recordChangedCell(x, z, severity, threatId, protection)
    self:markDirty()

    if self.networkTrackingSuppressed == true or g_server == nil then
        return
    end

    local key = string.format("%d:%d", x or -1, z or -1)
    if self.changedCells[key] == nil then
        self.changedCellCount = (self.changedCellCount or 0) + 1
    end

    self.changedCells[key] = {
        x = x,
        z = z,
        severity = severity,
        threatId = threatId,
        protection = protection ~= nil and protection
            or (DiseaseProtectionGrid ~= nil and DiseaseProtectionGrid:getValue(x, z) or 0)
    }
end

function DiseaseGrid:consumeChangedCellBatch(maxCount)
    local batch = {}
    local limit = math.max(1, tonumber(maxCount or 0) or 0)

    if self.changedCells == nil then
        return batch
    end

    for key, cell in pairs(self.changedCells) do
        table.insert(batch, cell)
        self.changedCells[key] = nil
        self.changedCellCount = math.max(0, (self.changedCellCount or 0) - 1)
        if #batch >= limit then
            break
        end
    end

    return batch
end

function DiseaseGrid:getSnapshotCellBatch(startIndex, maxCount)
    local batch = {}
    local width = self.width or 0
    local height = self.height or 0
    local total = width * height
    local index = math.max(0, tonumber(startIndex or 0) or 0)
    local limit = math.max(1, tonumber(maxCount or self.snapshotBatchSize or 4096) or 4096)

    while index < total and #batch < limit do
        local x = index % width
        local z = math.floor(index / width)
        local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
        local protection = DiseaseProtectionGrid ~= nil and DiseaseProtectionGrid:getValue(x, z) or 0
        if severity > 0 or protection > 0 then
            table.insert(batch, {
                x = x,
                z = z,
                severity = severity,
                threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0,
                protection = protection
            })
        end
        index = index + 1
    end

    return batch, index, index >= total
end

function DiseaseGrid:applyNetworkCellBatch(cells, resetBefore)
    if self.mapId == nil then
        return
    end

    self.networkTrackingSuppressed = true

    if resetBefore == true then
        setBitVectorMapParallelogram(self.mapId, 0, 0, self.width, 0, 0, self.height, 0, 6, 0)
        if DiseaseProtectionGrid ~= nil and DiseaseProtectionGrid.mapId ~= nil then
            setBitVectorMapParallelogram(DiseaseProtectionGrid.mapId, 0, 0, self.width, 0, 0, self.height, 0, 2, 0)
        end
        if DiseaseMapFrame ~= nil and DiseaseMapFrame.mapCache ~= nil then
            for _, entry in pairs(DiseaseMapFrame.mapCache) do
                if type(entry) == "table" then
                    entry.initialized = false
                end
            end
        end
    end

    for _, cell in ipairs(cells or {}) do
        if DiseaseProtectionGrid ~= nil then
            DiseaseProtectionGrid:applyNetworkCell(cell.x, cell.z, cell.protection or 0)
        end
        writeThreatCell(self, cell.x, cell.z, cell.severity, cell.threatId)
    end

    self.networkTrackingSuppressed = false
    self.isDirty = false
    self.changeRevision = (self.changeRevision or 0) + 1
end

function DiseaseGrid:saveNow(force)
    if g_server == nil or self.mapId == nil or self.savePath == nil then
        return false
    end
    if not force and not self.isDirty then
        return false
    end
    saveBitVectorMapToFile(self.mapId, self.savePath)
    self.isDirty = false
    self.saveTimer = 0
    return true
end

function DiseaseGrid:deleteMap()
    removeConsoleCommand("gsDiseaseDebug")
    removeConsoleCommand("gsDiseaseDebugHere")
    removeConsoleCommand("gsDiseaseGridTestHere")

    if self.mapId ~= nil then
        self:saveNow(true)
        delete(self.mapId)
    end

    self.mapId = nil
    self.severityModifier = nil
    self.typeModifier = nil
    self._fieldGroundMapData = nil
    self.changedCells = {}
    self.changedCellCount = 0
end

function DiseaseGrid:worldToLocal(worldX, worldZ)
    return worldToMap(self.width, self.height, worldX, worldZ)
end

function DiseaseGrid:getIsFieldGroundAtMapCell(x, z)
    if self.width == nil or self.height == nil then
        return true
    end

    local data = self._fieldGroundMapData
    if data == nil then
        data = getFieldGroundMapData()
        self._fieldGroundMapData = data
    end
    if data == nil then
        return true
    end

    local worldX, worldZ = mapToWorld(self.width, self.height, x, z)
    local value = getFieldGroundValueAtWorld(data, worldX, worldZ)
    if value == nil then
        return true
    end

    return value > 0
end

function DiseaseGrid:setValueAtWorldParallelogram(worldX, worldZ, worldWidthX, worldWidthZ, worldHeightX, worldHeightZ, firstChannel, numChannels, value)
    local x, z = self:worldToLocal(worldX, worldZ)
    local widthX, widthZ = self:worldToLocal(worldWidthX, worldWidthZ)
    local heightX, heightZ = self:worldToLocal(worldHeightX, worldHeightZ)
    setBitVectorMapParallelogram(self.mapId, x, z, widthX - x, widthZ - z, heightX - x, heightZ - z, firstChannel, numChannels, value)
    self:markDirty()
end

function DiseaseGrid:setThreatInArea(area, severity, threatId)
    -- deprecated for direct world-space helpers
end

function DiseaseGrid:clearArea(area)
    -- deprecated for direct world-space helpers
end

function DiseaseGrid:setCircleThreatWorld(worldX, worldZ, radius, severity, threatId)
    if self.mapId == nil then
        return 0
    end

    local minX = worldX - radius
    local minZ = worldZ - radius
    local maxX = worldX + radius
    local maxZ = worldZ + radius
    local startX, startZ = self:worldToLocal(minX, minZ)
    local endX, endZ = self:worldToLocal(maxX, maxZ)
    local radiusSq = radius * radius
    local touched = 0

    for z = startZ, endZ do
        for x = startX, endX do
            local px, pz = mapToWorld(self.width, self.height, x, z)
            local dx = px - worldX
            local dz = pz - worldZ
            if dx * dx + dz * dz <= radiusSq then
                if writeThreatCell(self, x, z, severity, threatId) then
                    touched = touched + 1
                end
            end
        end
    end

    if touched > 0 then
        self:markDirty()
    end

    return touched
end

function DiseaseGrid:setCircleThreatOnField(field, worldX, worldZ, radius, severity, threatId)
    if self.mapId == nil or field == nil then
        return 0
    end

    local polygon = getFieldPolygon2D(field)
    if polygon == nil then
        return 0
    end

    local minX = worldX - radius
    local minZ = worldZ - radius
    local maxX = worldX + radius
    local maxZ = worldZ + radius
    local startX, startZ = self:worldToLocal(minX, minZ)
    local endX, endZ = self:worldToLocal(maxX, maxZ)
    local radiusSq = radius * radius
    local touched = 0

    for z = startZ, endZ do
        for x = startX, endX do
            local px, pz = mapToWorld(self.width, self.height, x, z)
            local dx = px - worldX
            local dz = pz - worldZ
            if dx * dx + dz * dz <= radiusSq and polygon:getIsPosInside(px, pz) then
                if writeThreatCell(self, x, z, severity, threatId) then
                    touched = touched + 1
                end
            end
        end
    end

    if touched > 0 then
        self:markDirty()
    end

    return touched
end

function DiseaseGrid:createPromoteThreatJob(field, newSeverity, requiredThreatId)
    local job = createFieldBoundsJob(self, field)
    if job == nil then
        return nil
    end

    job.kind = "promote"
    job.targetSeverity = clamp(tonumber(newSeverity or 0) or 0, 0, 3)
    job.requiredThreatId = requiredThreatId
    if job.targetSeverity <= 0 then
        return nil
    end

    return job
end

function DiseaseGrid:stepPromoteThreatJob(job, maxRows)
    if self.mapId == nil or job == nil then
        return true, 0
    end

    local rowsBudget = math.max(1, tonumber(maxRows or 8) or 8)
    local changedThisStep = 0
    local rowsDone = 0

    while job.currentZ <= job.endZ and rowsDone < rowsBudget do
        for x = job.currentX, job.endX do
            local worldX, worldZ = mapToWorld(self.width, self.height, x, job.currentZ)
            if job.polygon:getIsPosInside(worldX, worldZ) then
                local severity = getBitVectorMapPoint(self.mapId, x, job.currentZ, 0, 2) or 0
                if severity > 0 then
                    local threatId = getBitVectorMapPoint(self.mapId, x, job.currentZ, 2, 4) or 0
                    if job.requiredThreatId == nil or job.requiredThreatId <= 0 or threatId == job.requiredThreatId then
                        if severity < job.targetSeverity and writeThreatCell(self, x, job.currentZ, job.targetSeverity, threatId) then
                            changedThisStep = changedThisStep + 1
                        end
                    end
                end
            end
        end

        job.currentX = job.startX
        job.currentZ = job.currentZ + 1
        rowsDone = rowsDone + 1
    end

    if changedThisStep > 0 then
        job.changed = (job.changed or 0) + changedThisStep
        self:markDirty()
    end

    return job.currentZ > job.endZ, changedThisStep
end

function DiseaseGrid:createCircleThreatJob(field, worldX, worldZ, radius, severity, threatId)
    local job = createFieldBoundsJob(self, field)
    if job == nil then
        return nil
    end

    local minWX = worldX - radius
    local minWZ = worldZ - radius
    local maxWX = worldX + radius
    local maxWZ = worldZ + radius
    local startX, startZ = self:worldToLocal(minWX, minWZ)
    local endX, endZ = self:worldToLocal(maxWX, maxWZ)

    job.kind = "circle"
    job.startX = math.max(job.startX, startX)
    job.endX = math.min(job.endX, endX)
    job.startZ = math.max(job.startZ, startZ)
    job.endZ = math.min(job.endZ, endZ)
    job.currentX = job.startX
    job.currentZ = job.startZ
    job.worldX = worldX
    job.worldZ = worldZ
    job.radiusSq = radius * radius
    job.severity = severity
    job.threatId = threatId

    if job.startX > job.endX or job.startZ > job.endZ then
        return nil
    end

    return job
end

function DiseaseGrid:createRandomSpotThreatJob(field, threatId, severity, radiusMin, radiusMax)
    if self.mapId == nil or field == nil then
        return nil
    end

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

    local radius = MathUtil.randomFloat(radiusMin, radiusMax)
    local x, z = getRandomSpotCenter(field, polygon, radius)
    if x == nil or z == nil then
        return nil
    end

    return self:createCircleThreatJob(field, x, z, radius, severity, threatId)
end

function DiseaseGrid:stepCircleThreatJob(job, maxRows)
    if self.mapId == nil or job == nil then
        return true, 0
    end

    local rowsBudget = math.max(1, tonumber(maxRows or 8) or 8)
    local changedThisStep = 0
    local rowsDone = 0

    while job.currentZ <= job.endZ and rowsDone < rowsBudget do
        for x = job.currentX, job.endX do
            local px, pz = mapToWorld(self.width, self.height, x, job.currentZ)
            local dx = px - job.worldX
            local dz = pz - job.worldZ
            if dx * dx + dz * dz <= job.radiusSq and job.polygon:getIsPosInside(px, pz) then
                if writeThreatCell(self, x, job.currentZ, job.severity, job.threatId) then
                    changedThisStep = changedThisStep + 1
                end
            end
        end

        job.currentX = job.startX
        job.currentZ = job.currentZ + 1
        rowsDone = rowsDone + 1
    end

    if changedThisStep > 0 then
        job.changed = (job.changed or 0) + changedThisStep
        self:markDirty()
    end

    return job.currentZ > job.endZ, changedThisStep
end

function DiseaseGrid:clearCircleWorld(worldX, worldZ, radius)
    return self:setCircleThreatWorld(worldX, worldZ, radius, 0, 0)
end

function DiseaseGrid:createClearInvalidFieldGroundJob()
    if self.mapId == nil or self.width == nil or self.height == nil or getFieldGroundMapData() == nil then
        return nil
    end

    return {
        currentZ = 0,
        width = self.width,
        height = self.height,
        checked = 0,
        cleared = 0
    }
end

function DiseaseGrid:stepClearInvalidFieldGroundJob(job, maxRows)
    if self.mapId == nil or job == nil then
        return true, 0
    end

    local data = getFieldGroundMapData()
    if data == nil then
        return true, 0
    end

    local rowsBudget = math.max(1, tonumber(maxRows or 4) or 4)
    local changedThisStep = 0
    local rowsDone = 0

    while job.currentZ < (job.height or 0) and rowsDone < rowsBudget do
        for x = 0, (job.width or 1) - 1 do
            local severity = getBitVectorMapPoint(self.mapId, x, job.currentZ, 0, 2) or 0
            if severity > 0 then
                local worldX, worldZ = mapToWorld(self.width, self.height, x, job.currentZ)
                local fieldGroundValue = getFieldGroundValueAtWorld(data, worldX, worldZ)
                if fieldGroundValue ~= nil and fieldGroundValue <= 0 then
                    if writeThreatCell(self, x, job.currentZ, 0, 0) then
                        changedThisStep = changedThisStep + 1
                    end
                end
            end
            job.checked = (job.checked or 0) + 1
        end

        job.currentZ = job.currentZ + 1
        rowsDone = rowsDone + 1
    end

    if changedThisStep > 0 then
        job.cleared = (job.cleared or 0) + changedThisStep
        self:markDirty()
    end

    return job.currentZ >= (job.height or 0), changedThisStep
end

function DiseaseGrid:clearField(field)
    if self.mapId == nil or field == nil then
        return
    end
    local cleared = 0
    applyFieldPixels(self, field, function(x, z)
        if writeThreatCell(self, x, z, 0, 0) then
            cleared = cleared + 1
        end
    end)
    if cleared > 0 then
        self:markDirty()
    end
end

function DiseaseGrid:clearFieldByCategory(field, category)
    if self.mapId == nil or field == nil or category == nil then
        return 0
    end

    local defs = _G.DiseaseDefs
    if defs == nil or defs.getThreatKeyById == nil or defs.getThreat == nil then
        return 0
    end

    local cleared = 0
    applyFieldPixels(self, field, function(x, z)
        local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
        if severity <= 0 then
            return
        end

        local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
        local threatKey = defs:getThreatKeyById(threatId)
        local threat = threatKey ~= nil and defs:getThreat(threatKey) or nil
        if threat ~= nil and threat.category == category and writeThreatCell(self, x, z, 0, 0) then
            cleared = cleared + 1
        end
    end)

    if cleared > 0 then
        self:markDirty()
    end

    return cleared
end

function DiseaseGrid:clearAroundWorldPos(worldX, worldZ, radius)
    if self.mapId == nil then
        return
    end
    self:clearCircleWorld(worldX, worldZ, radius)
end

function DiseaseGrid:clearParallelogram(startNode, widthNode, heightNode, extraMargin)
    if self.mapId == nil or startNode == nil or widthNode == nil or heightNode == nil then
        return
    end

    local sx, _, sz = getWorldTranslation(startNode)
    local wx, _, wz = getWorldTranslation(widthNode)
    local hx, _, hz = getWorldTranslation(heightNode)
    local margin = math.max(0, tonumber(extraMargin) or 0)

    if margin > 0 then
        local widthDirX, widthDirZ = normalize2D(wx - sx, wz - sz)
        local heightDirX, heightDirZ = normalize2D(hx - sx, hz - sz)
        local offsetX = (widthDirX + heightDirX) * margin
        local offsetZ = (widthDirZ + heightDirZ) * margin

        sx = sx - offsetX
        sz = sz - offsetZ
        wx = wx + widthDirX * margin - heightDirX * margin
        wz = wz + widthDirZ * margin - heightDirZ * margin
        hx = hx - widthDirX * margin + heightDirX * margin
        hz = hz - widthDirZ * margin + heightDirZ * margin
    end

    self:setValueAtWorldParallelogram(sx, sz, wx, wz, hx, hz, 0, 2, 0)
    self:setValueAtWorldParallelogram(sx, sz, wx, wz, hx, hz, 2, 4, 0)
end

function DiseaseGrid:clearParallelogramByCategory(startNode, widthNode, heightNode, extraMargin, category)
    if self.mapId == nil or startNode == nil or widthNode == nil or heightNode == nil or category == nil then
        return 0
    end

    local defs = _G.DiseaseDefs
    if defs == nil or defs.getThreatKeyById == nil or defs.getThreat == nil then
        return 0
    end

    local sx, _, sz = getWorldTranslation(startNode)
    local wx, _, wz = getWorldTranslation(widthNode)
    local hx, _, hz = getWorldTranslation(heightNode)
    local margin = math.max(0, tonumber(extraMargin) or 0)

    if margin > 0 then
        local widthDirX, widthDirZ = normalize2D(wx - sx, wz - sz)
        local heightDirX, heightDirZ = normalize2D(hx - sx, hz - sz)
        local offsetX = (widthDirX + heightDirX) * margin
        local offsetZ = (widthDirZ + heightDirZ) * margin

        sx = sx - offsetX
        sz = sz - offsetZ
        wx = wx + widthDirX * margin - heightDirX * margin
        wz = wz + widthDirZ * margin - heightDirZ * margin
        hx = hx - widthDirX * margin + heightDirX * margin
        hz = hz - widthDirZ * margin + heightDirZ * margin
    end

    local startX, startZ = self:worldToLocal(sx, sz)
    local widthX, widthZ = self:worldToLocal(wx, wz)
    local heightX, heightZ = self:worldToLocal(hx, hz)
    local edgeAX = wx - sx
    local edgeAZ = wz - sz
    local edgeBX = hx - sx
    local edgeBZ = hz - sz
    local determinant = edgeAX * edgeBZ - edgeAZ * edgeBX

    local minX = math.max(0, math.min(startX, widthX, heightX))
    local maxX = math.min(self.width - 1, math.max(startX, widthX, heightX))
    local minZ = math.max(0, math.min(startZ, widthZ, heightZ))
    local maxZ = math.min(self.height - 1, math.max(startZ, widthZ, heightZ))
    local cleared = 0

    if math.abs(determinant) < 0.0001 then
        return 0
    end

    for z = minZ, maxZ do
        for x = minX, maxX do
            local worldX, worldZ = mapToWorld(self.width, self.height, x, z)
            local relX = worldX - sx
            local relZ = worldZ - sz
            local u = (relX * edgeBZ - relZ * edgeBX) / determinant
            local v = (edgeAX * relZ - edgeAZ * relX) / determinant

            if u >= 0 and u <= 1 and v >= 0 and v <= 1 then
                local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
                if severity > 0 then
                    local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                    local threatKey = defs:getThreatKeyById(threatId)
                    local threat = threatKey ~= nil and defs:getThreat(threatKey) or nil
                    if threat ~= nil and threat.category == category then
                        if writeThreatCell(self, x, z, 0, 0) then
                            cleared = cleared + 1
                        end
                    end
                end
            end
        end
    end

    if cleared > 0 then
        self:markDirty()
    end

    return cleared
end

function DiseaseGrid:clearParallelogramByCure(startNode, widthNode, heightNode, extraMargin, action)
    if self.mapId == nil or startNode == nil or widthNode == nil or heightNode == nil or action == nil then
        return 0
    end

    local defs = _G.DiseaseDefs
    if defs == nil or defs.getThreatKeyById == nil or defs.getThreat == nil then
        return 0
    end

    local sx, _, sz = getWorldTranslation(startNode)
    local wx, _, wz = getWorldTranslation(widthNode)
    local hx, _, hz = getWorldTranslation(heightNode)
    local margin = math.max(0, tonumber(extraMargin) or 0)

    if margin > 0 then
        local widthDirX, widthDirZ = normalize2D(wx - sx, wz - sz)
        local heightDirX, heightDirZ = normalize2D(hx - sx, hz - sz)
        local offsetX = (widthDirX + heightDirX) * margin
        local offsetZ = (widthDirZ + heightDirZ) * margin

        sx = sx - offsetX
        sz = sz - offsetZ
        wx = wx + widthDirX * margin - heightDirX * margin
        wz = wz + widthDirZ * margin - heightDirZ * margin
        hx = hx - widthDirX * margin + heightDirX * margin
        hz = hz - widthDirZ * margin + heightDirZ * margin
    end

    local startX, startZ = self:worldToLocal(sx, sz)
    local widthX, widthZ = self:worldToLocal(wx, wz)
    local heightX, heightZ = self:worldToLocal(hx, hz)
    local edgeAX = wx - sx
    local edgeAZ = wz - sz
    local edgeBX = hx - sx
    local edgeBZ = hz - sz
    local determinant = edgeAX * edgeBZ - edgeAZ * edgeBX

    local minX = math.max(0, math.min(startX, widthX, heightX))
    local maxX = math.min(self.width - 1, math.max(startX, widthX, heightX))
    local minZ = math.max(0, math.min(startZ, widthZ, heightZ))
    local maxZ = math.min(self.height - 1, math.max(startZ, widthZ, heightZ))
    local cleared = 0

    if math.abs(determinant) < 0.0001 then
        return 0
    end

    for z = minZ, maxZ do
        for x = minX, maxX do
            local worldX, worldZ = mapToWorld(self.width, self.height, x, z)
            local relX = worldX - sx
            local relZ = worldZ - sz
            local u = (relX * edgeBZ - relZ * edgeBX) / determinant
            local v = (edgeAX * relZ - edgeAZ * relX) / determinant

            if u >= 0 and u <= 1 and v >= 0 and v <= 1 then
                local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
                if severity > 0 then
                    local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                    local threatKey = defs:getThreatKeyById(threatId)
                    local threat = threatKey ~= nil and defs:getThreat(threatKey) or nil

                    if threat ~= nil and threat.cures ~= nil then
                        for _, cureAction in ipairs(threat.cures) do
                            if cureAction == action then
                                if writeThreatCell(self, x, z, 0, 0) then
                                    cleared = cleared + 1
                                end
                                break
                            end
                        end
                    end
                end
            end
        end
    end

    if cleared > 0 then
        self:markDirty()
    end

    return cleared
end

function DiseaseGrid:applyRandomSpots(field, threatId, severity, numSpots, radiusMin, radiusMax)
    if self.mapId == nil or field == nil or numSpots == nil or numSpots <= 0 then
        return 0
    end

    local polygon = getFieldPolygon2D(field)
    if polygon == nil then
        return 0
    end

    local applied = 0
    for _ = 1, numSpots do
        local placed = false
        for _ = 1, 10 do
            local radius = MathUtil.randomFloat(radiusMin, radiusMax)
            local x, z = getRandomSpotCenter(field, polygon, radius)
            if x ~= nil and z ~= nil then
                local touched = self:setCircleThreatOnField(field, x, z, radius, severity, threatId)
                if touched > 0 then
                    applied = applied + touched
                    placed = true
                    break
                end
            end
        end
        if not placed then
            -- leave this spot empty; caller will see lower applied count and can retry next day
        end
    end

    return applied
end

function DiseaseGrid:healRandomSpots(field, numSpots, radiusMin, radiusMax)
    if self.mapId == nil or field == nil or numSpots == nil or numSpots <= 0 then
        return 0
    end

    local polygon = getFieldPolygon2D(field)
    if polygon == nil then
        return 0
    end

    local applied = 0
    for _ = 1, numSpots do
        local radius = MathUtil.randomFloat(radiusMin, radiusMax)
        local x, z = getRandomSpotCenter(field, polygon, radius)
        if x ~= nil and z ~= nil then
            applied = applied + self:clearCircleWorld(x, z, radius)
        end
    end

    return applied
end

function DiseaseGrid:getAverageSeverityOnField(field)
    if self.mapId == nil or field == nil then
        return 0, 0
    end
    local sum = 0
    local pixels = applyFieldPixels(self, field, function(x, z)
        sum = sum + (getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0)
    end)
    if pixels <= 0 then
        return 0, 0
    end
    return clamp(safeDiv(sum, pixels * 3, 0), 0, 1), pixels
end

function DiseaseGrid:getFieldSummary(field)
    local summary = {
        averageSeverity = 0,
        maxSeverity = 0,
        infectedPixels = 0,
        pixels = 0,
        dominantThreatId = 0
    }

    if self.mapId == nil or field == nil then
        return summary
    end

    local sum = 0
    local threatCounts = {}
    summary.pixels = applyFieldPixels(self, field, function(x, z)
        local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
        if severity > 0 then
            local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
            sum = sum + severity
            summary.infectedPixels = summary.infectedPixels + 1
            summary.maxSeverity = math.max(summary.maxSeverity, severity)
            if threatId > 0 then
                threatCounts[threatId] = (threatCounts[threatId] or 0) + 1
            end
        end
    end)

    if summary.pixels > 0 then
        summary.averageSeverity = clamp(safeDiv(sum, summary.pixels * 3, 0), 0, 1)
    end

    local bestCount = 0
    for threatId, count in pairs(threatCounts) do
        if count > bestCount then
            bestCount = count
            summary.dominantThreatId = threatId
        end
    end

    return summary
end

function DiseaseGrid:getFieldProtectionCoverage(field, category)
    if field == nil or DiseaseProtectionGrid == nil or DiseaseProtectionGrid.mapId == nil then return 0 end
    local total, protected = 0, 0
    applyFieldPixels(self, field, function(x, z)
        total = total + 1
        if DiseaseProtectionGrid:isProtectedCell(x, z, category) then protected = protected + 1 end
    end)
    return total > 0 and protected / total or 0
end

function DiseaseGrid:promoteThreatOnField(field, newSeverity, requiredThreatId)
    if self.mapId == nil or field == nil then
        return 0
    end

    local targetSeverity = clamp(tonumber(newSeverity or 0) or 0, 0, 3)
    if targetSeverity <= 0 then
        return 0
    end

    local promoted = 0
    applyFieldPixels(self, field, function(x, z)
        local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
        if severity <= 0 then
            return
        end

        local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
        if requiredThreatId ~= nil and requiredThreatId > 0 and threatId ~= requiredThreatId then
            return
        end

        if severity < targetSeverity then
            if writeThreatCell(self, x, z, targetSeverity, threatId) then
                promoted = promoted + 1
            end
        end
    end)

    if promoted > 0 then
        self:markDirty()
    end

    return promoted
end

function DiseaseGrid:expandThreatOnField(field, newSeverity, requiredThreatId, numSeeds, radiusMin, radiusMax)
    if self.mapId == nil or field == nil then
        return 0
    end

    local targetSeverity = clamp(tonumber(newSeverity or 0) or 0, 0, 3)
    if targetSeverity <= 0 then
        return 0
    end

    local seeds = self:getInfectedSpotsOnField(field, math.max(1, tonumber(numSeeds or 4) or 4), requiredThreatId)
    if #seeds == 0 then
        return 0
    end

    local applied = 0
    local minRadius = math.max(1.5, tonumber(radiusMin or 4.5) or 4.5)
    local maxRadius = math.max(minRadius, tonumber(radiusMax or minRadius) or minRadius)

    for _, spot in ipairs(seeds) do
        local threatId = (requiredThreatId ~= nil and requiredThreatId > 0) and requiredThreatId or (spot.threatId or 0)
        local radius = MathUtil.randomFloat(minRadius, maxRadius)
        applied = applied + self:setCircleThreatOnField(field, spot.x, spot.z, radius, math.max(targetSeverity, tonumber(spot.severity or 0) or 0), threatId)
    end

    return applied
end

function DiseaseGrid:capThreatOnField(field, maxSeverity, requiredThreatId)
    if self.mapId == nil or field == nil then
        return 0
    end

    local capped = 0
    local targetSeverity = clamp(tonumber(maxSeverity or 0) or 0, 0, 3)
    applyFieldPixels(self, field, function(x, z)
        local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
        if severity <= targetSeverity then
            return
        end

        local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
        if requiredThreatId ~= nil and requiredThreatId > 0 and threatId ~= requiredThreatId then
            return
        end

        if writeThreatCell(self, x, z, targetSeverity, threatId) then
            capped = capped + 1
        end
    end)

    if capped > 0 then
        self:markDirty()
    end

    return capped
end

function DiseaseGrid:spawnNearbyThreatSpots(field, newSeverity, requiredThreatId, numSeeds, spotsPerSeed, offsetMin, offsetMax, radiusMin, radiusMax)
    if self.mapId == nil or field == nil then
        return 0
    end

    local targetSeverity = clamp(tonumber(newSeverity or 0) or 0, 0, 3)
    if targetSeverity <= 0 then
        return 0
    end

    local polygon = getFieldPolygon2D(field)
    if polygon == nil then
        return 0
    end

    local seeds = self:getInfectedSpotsOnField(field, math.max(1, tonumber(numSeeds or 4) or 4), requiredThreatId)
    if #seeds == 0 then
        return 0
    end

    local applied = 0
    local perSeed = math.max(1, tonumber(spotsPerSeed or 1) or 1)
    local minOffset = math.max(0.5, tonumber(offsetMin or 6.0) or 6.0)
    local maxOffset = math.max(minOffset, tonumber(offsetMax or minOffset) or minOffset)
    local minRadius = math.max(1.0, tonumber(radiusMin or 2.5) or 2.5)
    local maxRadius = math.max(minRadius, tonumber(radiusMax or minRadius) or minRadius)

    for _, seed in ipairs(seeds) do
        local threatId = (requiredThreatId ~= nil and requiredThreatId > 0) and requiredThreatId or (seed.threatId or 0)
        for _ = 1, perSeed do
            local angle = MathUtil.randomFloat(0, math.pi * 2)
            local distance = MathUtil.randomFloat(minOffset, maxOffset)
            local radius = MathUtil.randomFloat(minRadius, maxRadius)
            local x = seed.x + math.cos(angle) * distance
            local z = seed.z + math.sin(angle) * distance
            if polygon:getIsCircleInside(x, z, radius) then
                applied = applied + self:setCircleThreatOnField(field, x, z, radius, targetSeverity, threatId)
            end
        end
    end

    return applied
end

function DiseaseGrid:promoteAllThreatsByOne()
    if self.mapId == nil then
        return 0
    end

    local promoted = 0
    for z = 0, math.max((self.height or 1) - 1, 0) do
        for x = 0, math.max((self.width or 1) - 1, 0) do
            local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
            if severity > 0 and severity < 3 then
                local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                if writeThreatCell(self, x, z, severity + 1, threatId) then
                    promoted = promoted + 1
                end
            end
        end
    end

    if promoted > 0 then
        self:markDirty()
    end

    return promoted
end

function DiseaseGrid:getThreatAtWorldPos(worldX, worldZ)
    if self.mapId == nil then
        return 0, 0
    end

    local x, z = self:worldToLocal(worldX, worldZ)
    local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2)
    local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4)
    return severity or 0, threatId or 0
end

function DiseaseGrid:getThreatAreaByCategory(startWorldX, startWorldZ, widthWorldX, widthWorldZ, heightWorldX, heightWorldZ, category)
    if self.mapId == nil or category == nil then return 0, 0 end
    local defs = _G.DiseaseDefs
    if defs == nil then return 0, 0 end

    local startX, startZ = self:worldToLocal(startWorldX, startWorldZ)
    local widthX, widthZ = self:worldToLocal(widthWorldX, widthWorldZ)
    local heightX, heightZ = self:worldToLocal(heightWorldX, heightWorldZ)
    local ax, az = widthWorldX - startWorldX, widthWorldZ - startWorldZ
    local bx, bz = heightWorldX - startWorldX, heightWorldZ - startWorldZ
    local determinant = ax * bz - az * bx
    if math.abs(determinant) < 0.0001 then return 0, 0 end

    local matching, total = 0, 0
    for z = math.max(0, math.min(startZ, widthZ, heightZ)), math.min(self.height - 1, math.max(startZ, widthZ, heightZ)) do
        for x = math.max(0, math.min(startX, widthX, heightX)), math.min(self.width - 1, math.max(startX, widthX, heightX)) do
            local worldX, worldZ = mapToWorld(self.width, self.height, x, z)
            local rx, rz = worldX - startWorldX, worldZ - startWorldZ
            local u = (rx * bz - rz * bx) / determinant
            local v = (ax * rz - az * rx) / determinant
            if u >= 0 and u <= 1 and v >= 0 and v <= 1 then
                total = total + 1
                local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
                if severity > 0 then
                    local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                    local threatKey = defs:getThreatKeyById(threatId)
                    if threatKey ~= nil and defs:getThreatCategory(threatKey) == category then
                        matching = matching + 1
                    end
                end
            end
        end
    end
    return matching, total
end

function DiseaseGrid:getInfectedSpotsOnField(field, maxSamples, requiredThreatId, minSeverity)
    local spots = {}
    if self.mapId == nil or field == nil then
        return spots
    end

    local limit = math.max(1, tonumber(maxSamples or 8) or 8)
    local minSev = clamp(tonumber(minSeverity or 1) or 1, 1, 3)
    local seen = 0
    applyFieldPixels(self, field, function(x, z)
        local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
        if severity >= minSev then
            local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
            if requiredThreatId == nil or requiredThreatId == 0 or threatId == requiredThreatId then
                local wx, wz = mapToWorld(self.width, self.height, x, z)
                seen = seen + 1
                if #spots < limit then
                    table.insert(spots, {x = wx, z = wz, severity = severity, threatId = threatId})
                else
                    local replaceIndex = math.random(1, seen)
                    if replaceIndex <= limit then
                        spots[replaceIndex] = {x = wx, z = wz, severity = severity, threatId = threatId}
                    end
                end
            end
        end
    end)

    return spots
end

function DiseaseGrid:getRandomInfectedSpots(maxSamples, minSeverity)
    local spots = {}
    if self.mapId == nil then
        return spots
    end

    local limit = math.max(1, tonumber(maxSamples or 64) or 64)
    local minSev = clamp(tonumber(minSeverity or 1) or 1, 1, 3)
    local seen = 0

    for z = 0, math.max((self.height or 1) - 1, 0) do
        for x = 0, math.max((self.width or 1) - 1, 0) do
            local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
            if severity >= minSev then
                local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                local wx, wz = mapToWorld(self.width, self.height, x, z)
                seen = seen + 1
                if #spots < limit then
                    table.insert(spots, {x = wx, z = wz, severity = severity, threatId = threatId})
                else
                    local replaceIndex = math.random(1, seen)
                    if replaceIndex <= limit then
                        spots[replaceIndex] = {x = wx, z = wz, severity = severity, threatId = threatId}
                    end
                end
            end
        end
    end

    return spots
end

function DiseaseGrid:consoleToggleDebug(enabledStr, radiusStr)
    if enabledStr == nil or enabledStr == "" then
        self.debugEnabled = not self.debugEnabled
    else
        self.debugEnabled = tonumber(enabledStr) == 1
    end

    local radius = tonumber(radiusStr or "")
    if radius ~= nil then
        self.debugRadius = math.max(10, math.min(radius, 200))
    end

    print(string.format("Disease debug overlay: %s (radius=%dm)", tostring(self.debugEnabled), self.debugRadius or 55))
end

function DiseaseGrid:consoleDebugHere()
    local x, _, z = getDebugWorldPosition()
    if x == nil then
        print("No player or controlled vehicle")
        return
    end
    local localX, localZ = self:worldToLocal(x, z)
    local severity, threatId = self:getThreatAtWorldPos(x, z)
    local defs = _G.DiseaseDefs
    local threatKey = defs and defs.getThreatKeyById and defs:getThreatKeyById(threatId) or "none"
    local protection = DiseaseProtectionGrid ~= nil and DiseaseProtectionGrid:getValue(localX, localZ) or 0
    print(string.format("Threat cell under camera: worldX=%.2f worldZ=%.2f localX=%d localZ=%d map=%dx%d severity=%d threatId=%d key=%s protection=%d (pest=%s disease=%s)",
        x, z, localX or -1, localZ or -1, self.width or 0, self.height or 0, severity or 0, threatId or 0, tostring(threatKey),
        protection, tostring(protection == 1 or protection == 3), tostring(protection >= 2)))
end

function DiseaseGrid:consoleGridTestHere(severityStr, threatIdStr)
    local x, _, z = getReferenceWorldPosition()
    if x == nil then
        print("No player or controlled vehicle")
        return
    end
    if self.mapId == nil then
        print("DiseaseGrid not loaded")
        return
    end

    local localX, localZ = self:worldToLocal(x, z)
    local severity = clamp(tonumber(severityStr or 2) or 2, 0, 3)
    local threatId = clamp(tonumber(threatIdStr or 1) or 1, 0, 15)

    if writeThreatCell(self, localX, localZ, severity, threatId) then
        self:markDirty()
    end

    local readSeverity = getBitVectorMapPoint(self.mapId, localX, localZ, 0, 2) or 0
    local readThreatId = getBitVectorMapPoint(self.mapId, localX, localZ, 2, 4) or 0
    print(string.format("DiseaseGrid test: worldX=%.2f worldZ=%.2f localX=%d localZ=%d map=%dx%d writeSeverity=%d writeThreatId=%d readSeverity=%d readThreatId=%d",
        x, z, localX or -1, localZ or -1, self.width or 0, self.height or 0, severity, threatId, readSeverity, readThreatId))
end

function DiseaseGrid:update(dt)
    if g_server == nil or self.mapId == nil or not self.isDirty then
        return
    end

    self.saveTimer = (self.saveTimer or 0) + (dt or 0)
    if self.saveTimer >= (self.saveIntervalMs or 15000) then
        self:saveNow(false)
    end
end

function DiseaseGrid:draw()
    if not self.debugEnabled or self.mapId == nil then
        return
    end

    local px, _, pz = getDebugWorldPosition()
    if px == nil then
        return
    end
    local terrainSize = g_currentMission and g_currentMission.terrainSize or 2048
    local cellSize = terrainSize / math.max(self.width or 1, 1)
    local radius = self.debugRadius or 55
    local startX, startZ = self:worldToLocal(px - radius, pz - radius)
    local endX, endZ = self:worldToLocal(px + radius, pz + radius)
    local defs = _G.DiseaseDefs

    for z = startZ, endZ do
        for x = startX, endX do
                local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
            if severity > 0 then
                local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                local cx, cz = mapToWorld(self.width, self.height, x, z)
                local y = getTerrainHeightAtWorldPos(g_terrainNode, cx, 0, cz) + 0.08 + severity * 0.03
                local half = cellSize * 0.48
                local r = severity == 1 and 1 or (severity == 2 and 1 or 0.9)
                local g = severity == 1 and 0.85 or (severity == 2 and 0.55 or 0.15)
                local b = severity == 3 and 0.15 or 0.1

                drawDebugLine(cx - half, y, cz - half, r, g, b, cx + half, y, cz - half, r, g, b)
                drawDebugLine(cx + half, y, cz - half, r, g, b, cx + half, y, cz + half, r, g, b)
                drawDebugLine(cx + half, y, cz + half, r, g, b, cx - half, y, cz + half, r, g, b)
                drawDebugLine(cx - half, y, cz + half, r, g, b, cx - half, y, cz - half, r, g, b)

                if math.abs(cx - px) <= cellSize * 1.5 and math.abs(cz - pz) <= cellSize * 1.5 then
                    local threatKey = defs and defs.getThreatKeyById and defs:getThreatKeyById(threatId) or tostring(threatId)
                    Utils.renderTextAtWorldPosition(cx, y + 0.12, cz, string.format("S%d %s", severity, tostring(threatKey)), 0.012, 0, 1, 1, 1, 1)
                end
            end
        end
    end
end

addModEventListener(DiseaseGrid)

function DiseaseGrid:clearParallelogramByThreatKey(startNode, widthNode, heightNode, extraMargin, threatKeyToClear)
    if self.mapId == nil or startNode == nil or widthNode == nil or heightNode == nil or threatKeyToClear == nil then
        return 0
    end

    local defs = _G.DiseaseDefs
    if defs == nil or defs.getThreatKeyById == nil then
        return 0
    end

    local sx, _, sz = getWorldTranslation(startNode)
    local wx, _, wz = getWorldTranslation(widthNode)
    local hx, _, hz = getWorldTranslation(heightNode)
    local margin = math.max(0, tonumber(extraMargin) or 0)

    if margin > 0 then
        local widthDirX, widthDirZ = normalize2D(wx - sx, wz - sz)
        local heightDirX, heightDirZ = normalize2D(hx - sx, hz - sz)
        local offsetX = (widthDirX + heightDirX) * margin
        local offsetZ = (widthDirZ + heightDirZ) * margin

        sx = sx - offsetX
        sz = sz - offsetZ
        wx = wx + widthDirX * margin - heightDirX * margin
        wz = wz + widthDirZ * margin - heightDirZ * margin
        hx = hx - widthDirX * margin + heightDirX * margin
        hz = hz - widthDirZ * margin + heightDirZ * margin
    end

    local startX, startZ = self:worldToLocal(sx, sz)
    local widthX, widthZ = self:worldToLocal(wx, wz)
    local heightX, heightZ = self:worldToLocal(hx, hz)
    local edgeAX = wx - sx
    local edgeAZ = wz - sz
    local edgeBX = hx - sx
    local edgeBZ = hz - sz
    local determinant = edgeAX * edgeBZ - edgeAZ * edgeBX

    local minX = math.max(0, math.min(startX, widthX, heightX))
    local maxX = math.min(self.width - 1, math.max(startX, widthX, heightX))
    local minZ = math.max(0, math.min(startZ, widthZ, heightZ))
    local maxZ = math.min(self.height - 1, math.max(startZ, widthZ, heightZ))
    local cleared = 0

    if math.abs(determinant) < 0.0001 then
        return 0
    end

    for z = minZ, maxZ do
        for x = minX, maxX do
            local worldX, worldZ = mapToWorld(self.width, self.height, x, z)
            local relX = worldX - sx
            local relZ = worldZ - sz
            local u = (relX * edgeBZ - relZ * edgeBX) / determinant
            local v = (edgeAX * relZ - edgeAZ * relX) / determinant

            if u >= 0 and u <= 1 and v >= 0 and v <= 1 then
                local severity = getBitVectorMapPoint(self.mapId, x, z, 0, 2) or 0
                if severity > 0 then
                    local threatId = getBitVectorMapPoint(self.mapId, x, z, 2, 4) or 0
                    local threatKey = defs:getThreatKeyById(threatId)
                    if threatKey == threatKeyToClear then
                        if writeThreatCell(self, x, z, 0, 0) then
                            cleared = cleared + 1
                        end
                    end
                end
            end
        end
    end

    if cleared > 0 then
        self:markDirty()
    end

    return cleared
end
