-- =========================================================
-- FS25 Realistic Soil & Fertilizer - Core Simulation
-- =========================================================
-- Per-field N/P/K/pH/OM tracking: depletion on harvest,
-- restoration on fertilizer, rain leaching, seasonal effects,
-- fallow recovery, Precision Farming compatibility.
-- =========================================================
-- Author: TisonK
-- =========================================================

---@class SoilFertilitySystem
SoilFertilitySystem = {}
local SoilFertilitySystem_mt = Class(SoilFertilitySystem)

function SoilFertilitySystem.new(settings)
    local self = setmetatable({}, SoilFertilitySystem_mt)
    self.settings = settings
    self.fieldData = {}
    self.lastUpdate = 0
    self.updateInterval = SoilConstants.TIMING.UPDATE_INTERVAL
    self.isInitialized = false
    self.lastUpdateDay = 0
    self.hookManager = HookManager.new()
    self.layerSystem = SoilLayerSystem and SoilLayerSystem.new() or nil

    -- Per-day flag table for fertilizer application notifications (fieldId → game day last shown)
    -- Prevents notification spam since the sprayer hook fires every frame while active.
    -- Stores the game day, not a timestamp, so the notification fires at most once per field per in-game day.
    self.fertNotifyShown = {}

    -- Per-day throttle tables for crop protection pressure reductions (fieldId → game day last applied).
    -- The sprayer hook fires every frame while the sprayer is active. Without throttling, a single
    -- pass across a field applies the full pressure reduction 60+ times per second, instantly
    -- resetting weed/pest/disease pressure to 0 from even 1L of product applied.
    -- Fix: allow at most ONE reduction event per field per in-game day, matching real-world
    -- application logic (you spray a field once per day at most, not 3600 times per minute).
    self.herbicideAppliedDay  = {}   -- fieldId → game day herbicide last reduced pressure
    self.insecticideAppliedDay = {}  -- fieldId → game day insecticide last reduced pressure
    self.fungicideAppliedDay  = {}   -- fieldId → game day fungicide last reduced pressure

    -- Field scan retry mechanism (for delayed initialization)
    self.fieldsScanPending = true
    self.fieldsScanAttempts = 0
    self.fieldsScanMaxAttempts = 10  -- Try up to 10 times
    self.fieldsScanNextRetry = 0
    self.fieldsScanRetryInterval = 2000  -- 2 seconds between attempts

    -- Frame-based fallback (in case g_currentMission.time is frozen)
    self.fieldsScanStage = 1  -- 1=time-based, 2=frame-based, 3=failed
    self.fieldsScanFrameCounter = 0
    self.fieldsScanMaxFrames = 600  -- 600 frames = ~10 seconds at 60fps

    return self
end

-- Initialize system with ALL real hooks
function SoilFertilitySystem:initialize()
    if self.isInitialized then
        self:info("System already initialized, skipping")
        return
    end

    self:info("Initializing Soil Fertility System...")

    -- Scan fields using real FieldManager
    if g_fieldManager then
        self:scanFields()
    else
        self:warning("FieldManager not available - will try delayed initialization")
    end

    -- Install hooks via HookManager
    self.hookManager:installAll(self)

    -- Initialize density map layer integration (per-pixel soil maps)
    if self.layerSystem then
        self.layerSystem:initialize()
    end

    self.isInitialized = true
    self:info("Soil Fertility System initialized successfully")
    self:info("Fertility System: %s, Nutrient Cycles: %s",
        tostring(self.settings.fertilitySystem),
        tostring(self.settings.nutrientCycles))

    -- Log multifruit compatibility status
    self:logCropProfileStatus()

    -- Show notification
    if self.settings.enabled and self.settings.showNotifications then
        self:showNotification("Soil & Fertilizer Mod Active", "Real soil system with full event hooks")
    end
end

-- Log which registered fruit types have explicit extraction profiles
-- and which will use the fallback (multifruit/custom map crops).
function SoilFertilitySystem:logCropProfileStatus()
    if not g_fruitTypeManager then return end
    local fruitTypes = g_fruitTypeManager:getFruitTypes()
    if not fruitTypes then return end

    local explicit = {}
    local fallback = {}

    for _, fruitDesc in pairs(fruitTypes) do
        local name = fruitDesc and fruitDesc.name
        if name then
            local lowerName = string.lower(name)
            if SoilConstants.CROP_EXTRACTION[lowerName] then
                table.insert(explicit, name)
            else
                table.insert(fallback, name)
            end
        end
    end

    table.sort(explicit)
    table.sort(fallback)

    local def = SoilConstants.CROP_EXTRACTION_DEFAULT
    self:info("Crop profiles: %d explicit, %d using fallback (N=%.2f P=%.2f K=%.2f)",
        #explicit, #fallback, def.N, def.P, def.K)
    if #explicit > 0 then
        self:info("  Explicit: %s", table.concat(explicit, ", "))
    end
    if #fallback > 0 then
        self:info("  Fallback (multifruit/unknown): %s", table.concat(fallback, ", "))
    end
end

-- Cleanup hooks and resources
function SoilFertilitySystem:delete()
    self.hookManager:uninstallAll()
    if self.layerSystem then
        self.layerSystem:delete()
        self.layerSystem = nil
    end
    self.fieldData = {}
    self.isInitialized = false
end

--- Hook delegate: called by HookManager when harvest occurs
--- Depletes soil nutrients based on crop type and difficulty
---@param fieldId number The field being harvested
---@param fruitTypeIndex number FS25 fruit type index
---@param liters number Amount harvested in liters
---@param strawRatio number 0.0-1.0 fraction of straw that was chopped (0 = dropped/collected, 1 = fully chopped)
function SoilFertilitySystem:onHarvest(fieldId, fruitTypeIndex, liters, strawRatio, area)
    -- Apply weed pressure yield penalty before nutrient update
    if self.settings.weedPressure and SoilConstants.WEED_PRESSURE then
        local field = self.fieldData[fieldId]
        if field then
            local wp = SoilConstants.WEED_PRESSURE
            local pressure = field.weedPressure or 0
            local penalty
            if pressure < wp.LOW then
                penalty = wp.YIELD_PENALTY_LOW
            elseif pressure < wp.MEDIUM then
                penalty = wp.YIELD_PENALTY_MID
            elseif pressure < wp.HIGH then
                penalty = wp.YIELD_PENALTY_HIGH
            else
                penalty = wp.YIELD_PENALTY_PEAK
            end
            if penalty > 0 then
                liters = liters * (1.0 - penalty)
                self:log("Weed penalty field %d: pressure=%.0f, penalty=%.0f%%",
                    fieldId, pressure, penalty * 100)
            end
        end
    end

    -- Pest pressure yield penalty
    if self.settings.pestPressure and SoilConstants.PEST_PRESSURE then
        local field = self.fieldData[fieldId]
        if field then
            local pp = SoilConstants.PEST_PRESSURE
            local pressure = field.pestPressure or 0
            local penalty
            if pressure < pp.LOW then
                penalty = pp.YIELD_PENALTY_LOW
            elseif pressure < pp.MEDIUM then
                penalty = pp.YIELD_PENALTY_MID
            elseif pressure < pp.HIGH then
                penalty = pp.YIELD_PENALTY_HIGH
            else
                penalty = pp.YIELD_PENALTY_PEAK
            end
            if penalty > 0 then
                liters = liters * (1.0 - penalty)
                self:log("Pest penalty field %d: pressure=%.0f, penalty=%.0f%%",
                    fieldId, pressure, penalty * 100)
            end
            -- Harvest disperses pest population
            field.pestPressure = pressure * pp.HARVEST_RESET_FRACTION
            field.insecticideDaysLeft = 0
        end
    end

    -- Disease pressure yield penalty
    if self.settings.diseasePressure and SoilConstants.DISEASE_PRESSURE then
        local field = self.fieldData[fieldId]
        if field then
            local dp = SoilConstants.DISEASE_PRESSURE
            local pressure = field.diseasePressure or 0
            local penalty
            if pressure < dp.LOW then
                penalty = dp.YIELD_PENALTY_LOW
            elseif pressure < dp.MEDIUM then
                penalty = dp.YIELD_PENALTY_MID
            elseif pressure < dp.HIGH then
                penalty = dp.YIELD_PENALTY_HIGH
            else
                penalty = dp.YIELD_PENALTY_PEAK
            end
            if penalty > 0 then
                liters = liters * (1.0 - penalty)
                self:log("Disease penalty field %d: pressure=%.0f, penalty=%.0f%%",
                    fieldId, pressure, penalty * 100)
            end
        end
    end

    self:updateFieldNutrients(fieldId, fruitTypeIndex, liters, strawRatio, area)

    SoilLogger.debug("Harvest: Field %d, Crop %d, %.0fL, area=%.1f", fieldId, fruitTypeIndex, liters, area or 0)

    -- Broadcast to clients if server in multiplayer
    if g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        local field = self.fieldData[fieldId]
        if field and SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

--- Hook delegate: called by HookManager when fertilizer applied
--- Restores soil nutrients based on fertilizer type
---@param fieldId number The field being fertilized
---@param fillTypeIndex number FS25 fill type index for fertilizer
---@param liters number Amount applied in liters
function SoilFertilitySystem:onFertilizerApplied(fieldId, fillTypeIndex, liters)
    self:applyFertilizer(fieldId, fillTypeIndex, liters)

    local fillType = g_fillTypeManager and g_fillTypeManager:getFillTypeByIndex(fillTypeIndex)

    SoilLogger.debug("Fertilizer: Field %d, %s, %.0fL", fieldId, fillType and fillType.name or "unknown", liters)

    -- Broadcast to clients if server in multiplayer
    if g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        local field = self.fieldData[fieldId]
        if field and SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

-- Hook delegate: called by HookManager when field ownership changes
function SoilFertilitySystem:onFieldOwnershipChanged(fieldId, farmlandId, farmId)
    -- If field is no longer owned, clean up data
    if farmId == nil or farmId == 0 then
        if self.fieldData[fieldId] then
            self.fieldData[fieldId] = nil
            self:log("Field %d data removed (no longer owned)", fieldId)
        end
        return
    end

    -- Initialize field data for new owner
    local field = self:getOrCreateField(fieldId, true)
    if field and not field.initialized then
        local defaults = SoilConstants.FIELD_DEFAULTS
        field.nitrogen = defaults.nitrogen
        field.phosphorus = defaults.phosphorus
        field.potassium = defaults.potassium
        field.organicMatter = defaults.organicMatter
        field.pH = defaults.pH
        field.initialized = true
        self:info("Field %d initialized for new owner", fieldId)
    end
end

--- Hook delegate: called by HookManager when sowing/planting occurs on a field.
--- Called when a field is sown. Reserved for future sowing-time logic.
--- NOTE: We previously cleared lastCrop here to force live HUD detection (#123),
--- but that caused duplicate crop entries in history when the same crop is replanted
--- (especially visible with FS25_CropRotation installed — issue #204).
--- Live FieldState detection in getFieldInfo() works regardless of lastCrop, so
--- the clearing was unnecessary and harmful to rotation history accuracy.
---@param fieldId number The field being sown
function SoilFertilitySystem:onSowing(fieldId)
end

--- Hook delegate: called by HookManager when plowing occurs
--- Increases organic matter and normalizes pH
---@param fieldId number The field being plowed
function SoilFertilitySystem:onPlowing(fieldId)
    if not fieldId or fieldId <= 0 then return end

    local field = self:getOrCreateField(fieldId, true)
    if not field then return end

    self:info("[Plowing] Field %d triggered (plowingBonus=%s, weedPressure=%s)",
        fieldId, tostring(self.settings.plowingBonus), tostring(self.settings.weedPressure))

    local changed = false

    -- Plowing benefits 1 & 2: OM increase and pH normalization (only if plowingBonus enabled)
    if self.settings.plowingBonus then
        local omBefore = field.organicMatter or SoilConstants.FIELD_DEFAULTS.organicMatter
        local omIncrease = 0.5
        local omAfter = math.min(omBefore + omIncrease, SoilConstants.NUTRIENT_LIMITS.ORGANIC_MATTER_MAX)
        if omAfter > omBefore then
            field.organicMatter = omAfter
            changed = true
        end

        local phBefore = field.pH or SoilConstants.FIELD_DEFAULTS.pH
        local phTarget = 7.0
        local phNormalization = 0.1
        local phAfter = phBefore
        if phBefore < phTarget then
            phAfter = math.min(phBefore + phNormalization, phTarget)
        elseif phBefore > phTarget then
            phAfter = math.max(phBefore - phNormalization, phTarget)
        end
        if phAfter ~= phBefore then
            field.pH = phAfter
            changed = true
        end

        self:info("[Plowing] Field %d: OM %.1f->%.1f, pH %.2f->%.2f",
            fieldId, omBefore, omAfter, phBefore, phAfter)
    end

    -- Plowing benefit 3: Reset weed pressure (independent of plowingBonus)
    if self.settings.weedPressure and (field.weedPressure or 0) > 0 then
        self:info("[Plowing] Field %d: weed %.0f -> 0", fieldId, field.weedPressure)
        field.weedPressure = 0
        field.herbicideDaysLeft = 0
        changed = true
    end

    -- Plowing benefit 4: Reduce pest pressure (independent of plowingBonus)
    if self.settings.pestPressure and SoilConstants.PLOWING.PEST_PRESSURE_REDUCTION and (field.pestPressure or 0) > 0 then
        local before = field.pestPressure
        field.pestPressure = math.max(0, before - SoilConstants.PLOWING.PEST_PRESSURE_REDUCTION)
        self:info("[Plowing] Field %d: pest %.0f -> %.0f", fieldId, before, field.pestPressure)
        changed = true
    end

    -- Plowing benefit 5: Reduce disease pressure (independent of plowingBonus)
    if self.settings.diseasePressure and SoilConstants.PLOWING.DISEASE_PRESSURE_REDUCTION and (field.diseasePressure or 0) > 0 then
        local before = field.diseasePressure
        field.diseasePressure = math.max(0, before - SoilConstants.PLOWING.DISEASE_PRESSURE_REDUCTION)
        self:info("[Plowing] Field %d: disease %.0f -> %.0f", fieldId, before, field.diseasePressure)
        changed = true
    end

    if changed and g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        if SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

--- Called when a shallow cultivator passes over a field.
--- Partially reduces weed, pest, and disease pressure.
---@param fieldId number
function SoilFertilitySystem:onCultivation(fieldId)
    if not fieldId or fieldId <= 0 then return end
    if not SoilConstants.CULTIVATION then return end

    local field = self:getOrCreateField(fieldId, false)
    if not field then return end

    self:info("[Cultivation] Field %d triggered (weedPressure=%.0f)", fieldId, field.weedPressure or 0)

    local changed = false
    local c = SoilConstants.CULTIVATION

    if self.settings.weedPressure and c.WEED_PRESSURE_REDUCTION and (field.weedPressure or 0) > 0 then
        local before = field.weedPressure
        field.weedPressure = math.max(0, before - c.WEED_PRESSURE_REDUCTION)
        self:info("[Cultivation] Field %d: weed %.0f -> %.0f", fieldId, before, field.weedPressure)
        changed = true
    end

    if self.settings.pestPressure and c.PEST_PRESSURE_REDUCTION and (field.pestPressure or 0) > 0 then
        local before = field.pestPressure
        field.pestPressure = math.max(0, before - c.PEST_PRESSURE_REDUCTION)
        self:info("[Cultivation] Field %d: pest %.0f -> %.0f", fieldId, before, field.pestPressure)
        changed = true
    end

    if self.settings.diseasePressure and c.DISEASE_PRESSURE_REDUCTION and (field.diseasePressure or 0) > 0 then
        local before = field.diseasePressure
        field.diseasePressure = math.max(0, before - c.DISEASE_PRESSURE_REDUCTION)
        self:info("[Cultivation] Field %d: disease %.0f -> %.0f", fieldId, before, field.diseasePressure)
        changed = true
    end

    if changed and g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        if SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

--- Called when herbicide is applied to a field.
--- Reduces weed pressure and activates suppression window.
---@param fieldId number
---@param effectiveness number 0.0-1.0 herbicide effectiveness multiplier
function SoilFertilitySystem:onHerbicideApplied(fieldId, effectiveness)
    if not self.settings.weedPressure then return end
    if not SoilConstants.WEED_PRESSURE then return end

    local field = self:getOrCreateField(fieldId, false)
    if not field then return end

    -- Throttle: apply pressure reduction at most once per field per in-game day.
    -- The sprayer hook fires every frame (~60x/sec). Without this guard, a single
    -- pass applies the full reduction hundreds of times, instantly zeroing pressure.
    local today = (g_currentMission and g_currentMission.environment and
                   g_currentMission.environment.currentDay) or 0
    if self.herbicideAppliedDay[fieldId] == today then return end
    self.herbicideAppliedDay[fieldId] = today

    local wp = SoilConstants.WEED_PRESSURE
    local reduction = wp.HERBICIDE_PRESSURE_REDUCTION * (effectiveness or 1.0)
    local before = field.weedPressure or 0
    field.weedPressure = math.max(0, before - reduction)
    field.herbicideDaysLeft = wp.HERBICIDE_DURATION_DAYS

    self:log("[Herbicide] Field %d: weed pressure %.0f -> %.0f, protected for %d days",
        fieldId, before, field.weedPressure, field.herbicideDaysLeft)

    -- Broadcast in multiplayer
    if g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        if SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

--- Called when insecticide is applied to a field.
---@param fieldId number
---@param effectiveness number 0.0-1.0 insecticide effectiveness multiplier
function SoilFertilitySystem:onInsecticideApplied(fieldId, effectiveness)
    if not self.settings.pestPressure then return end
    if not SoilConstants.PEST_PRESSURE then return end

    local field = self:getOrCreateField(fieldId, false)
    if not field then return end

    -- Throttle: once per field per in-game day (see onHerbicideApplied for rationale)
    local today = (g_currentMission and g_currentMission.environment and
                   g_currentMission.environment.currentDay) or 0
    if self.insecticideAppliedDay[fieldId] == today then return end
    self.insecticideAppliedDay[fieldId] = today

    local pp = SoilConstants.PEST_PRESSURE
    local reduction = pp.INSECTICIDE_PRESSURE_REDUCTION * (effectiveness or 1.0)
    local before = field.pestPressure or 0
    field.pestPressure = math.max(0, before - reduction)
    field.insecticideDaysLeft = pp.INSECTICIDE_DURATION_DAYS

    self:log("[Insecticide] Field %d: pest pressure %.0f -> %.0f, protected for %d days",
        fieldId, before, field.pestPressure, field.insecticideDaysLeft)

    if g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        if SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

--- Called when fungicide is applied to a field.
---@param fieldId number
---@param effectiveness number 0.0-1.0 fungicide effectiveness multiplier
function SoilFertilitySystem:onFungicideApplied(fieldId, effectiveness)
    if not self.settings.diseasePressure then return end
    if not SoilConstants.DISEASE_PRESSURE then return end

    local field = self:getOrCreateField(fieldId, false)
    if not field then return end

    -- Throttle: once per field per in-game day (see onHerbicideApplied for rationale)
    local today = (g_currentMission and g_currentMission.environment and
                   g_currentMission.environment.currentDay) or 0
    if self.fungicideAppliedDay[fieldId] == today then return end
    self.fungicideAppliedDay[fieldId] = today

    local dp = SoilConstants.DISEASE_PRESSURE
    local reduction = dp.FUNGICIDE_PRESSURE_REDUCTION * (effectiveness or 1.0)
    local before = field.diseasePressure or 0
    field.diseasePressure = math.max(0, before - reduction)
    field.fungicideDaysLeft = dp.FUNGICIDE_DURATION_DAYS

    self:log("[Fungicide] Field %d: disease pressure %.0f -> %.0f, protected for %d days",
        fieldId, before, field.diseasePressure, field.fungicideDaysLeft)

    if g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        if SoilFieldUpdateEvent then
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
        end
    end
end

-- Hook delegate: called by HookManager on environment update
function SoilFertilitySystem:onEnvironmentUpdate(env, dt)
    -- Daily soil updates
    local currentDay = env.currentDay or 0
    if currentDay ~= self.lastUpdateDay then
        self.lastUpdateDay = currentDay
        self:updateDailySoil()
    end

    -- Rain effects
    if self.settings.rainEffects and
       env.weather and env.weather.rainScale and
       env.weather.rainScale > SoilConstants.RAIN.MIN_RAIN_THRESHOLD then
        self:applyRainEffects(dt, env.weather.rainScale)
    end
end

-- Logging helpers
function SoilFertilitySystem:log(msg, ...)
    SoilLogger.debug(msg, ...)
end

function SoilFertilitySystem:info(msg, ...)
    SoilLogger.info(msg, ...)
end

function SoilFertilitySystem:warning(msg, ...)
    SoilLogger.warning(msg, ...)
end

-- Notification helper
function SoilFertilitySystem:showNotification(title, message)
    if not self.settings or not self.settings.showNotifications then return end

    if g_currentMission and g_currentMission.hud and g_currentMission.hud.showBlinkingWarning then
        g_currentMission.hud:showBlinkingWarning(title .. ": " .. message, 6000)
    else
        self:info("%s - %s", title, message)
    end
end

-- Update function called every frame
function SoilFertilitySystem:update(dt)
    if not self.settings.enabled then return end

    -- Delayed field scanning retry (3-tier approach: time-based → frame-based → fail gracefully)
    if self.fieldsScanPending then
        if self.fieldsScanStage == 1 then
            -- Stage 1: Time-based retry (10 attempts, 2 sec intervals)
            if self.fieldsScanAttempts < self.fieldsScanMaxAttempts then
                local currentTime = g_currentMission and g_currentMission.time or 0
                if currentTime >= self.fieldsScanNextRetry then
                    self.fieldsScanAttempts = self.fieldsScanAttempts + 1
                    self:log("Retrying field scan (attempt %d/%d)...", self.fieldsScanAttempts, self.fieldsScanMaxAttempts)

                    local success = self:scanFields()
                    if success then
                        self:info("Delayed field scan successful!")
                        self.fieldsScanPending = false
                    else
                        -- Schedule next retry
                        self.fieldsScanNextRetry = currentTime + self.fieldsScanRetryInterval
                        if self.fieldsScanAttempts >= self.fieldsScanMaxAttempts then
                            self:warning("Time-based retry failed after %d attempts - switching to frame-based fallback", self.fieldsScanMaxAttempts)
                            self.fieldsScanStage = 2
                            self.fieldsScanFrameCounter = 0
                            if g_currentMission and g_currentMission.hud then
                                g_currentMission.hud:showBlinkingWarning("Soil Mod: Field initialization delayed. Trying alternative method...", 5000)
                            end
                        end
                    end
                end
            end
        elseif self.fieldsScanStage == 2 then
            -- Stage 2: Frame-based fallback (try every frame for 600 frames = ~10 sec)
            self.fieldsScanFrameCounter = self.fieldsScanFrameCounter + 1

            -- Try scan every 30 frames (twice per second at 60fps) to avoid spam
            if self.fieldsScanFrameCounter % 30 == 0 then
                local success = self:scanFields()
                if success then
                    self:info("Frame-based field scan successful after %d frames!", self.fieldsScanFrameCounter)
                    self.fieldsScanPending = false
                    -- Show success notification so player knows recovery worked
                    if g_currentMission and g_currentMission.hud then
                        g_currentMission.hud:showBlinkingWarning("Soil Mod: Field initialization successful!", 4000)
                    end
                end
            end

            -- Timeout after max frames
            if self.fieldsScanFrameCounter >= self.fieldsScanMaxFrames then
                self:warning("Field initialization failed after all retry attempts (time + frame-based)")
                self.fieldsScanStage = 3

                -- Show error dialog and disable mod gracefully
                if g_gui then
                    g_gui:showInfoDialog({
                        text = "Soil & Fertilizer Mod: Could not initialize fields.\n\nThe game's field system is not responding.\n\nThe mod has been disabled for this session only.\n\nPlease restart the game to try again.\n\nIf this issue persists, please report it.",
                        title = "Field Initialization Failed"
                    })
                end

                -- Disable mod to prevent half-broken state
                if self.settings then
                    self.settings.enabled = false
                end
                self.fieldsScanPending = false
            end
        end
    end

    self.lastUpdate = self.lastUpdate + dt

    if self.lastUpdate >= self.updateInterval then
        self.lastUpdate = 0
        -- Periodic checks could go here
    end

    -- Handle network sync retry for multiplayer clients
    if SoilNetworkEvents_UpdateSyncRetry then
        SoilNetworkEvents_UpdateSyncRetry(dt)
    end
end

-- Check for Precision Farming compatibility
-- Scan all fields from FieldManager
---@return boolean True if successfully scanned fields, false if fields not ready yet
function SoilFertilitySystem:scanFields()
    if not g_fieldManager or not g_fieldManager.fields then
        self:warning("FieldManager not available yet")
        return false
    end

    if next(g_fieldManager.fields) == nil then
        self:log("FieldManager fields table empty - not ready yet")
        return false
    end

    self:log("Scanning fields via FieldManager...")

    local fieldCount = 0
    local farmlandCount = 0

    -- Count farmlands (for logging only)
    if g_farmlandManager and g_farmlandManager.farmlands then
        for _ in pairs(g_farmlandManager.farmlands) do
            farmlandCount = farmlandCount + 1
        end
    end

    -- TRUE FS25 SOURCE OF TRUTH
    -- field.fieldId / field.id / field.index do NOT exist in FS25 — all return nil.
    -- The correct field identifier is field.farmland.id (confirmed in-game).
    -- g_currentMission.fieldManager does not exist; use the global g_fieldManager.fields table directly.
    if not g_fieldManager or not g_fieldManager.fields then
        self:warning("g_fieldManager.fields not available — scan deferred")
        return false
    end
    local fields = g_fieldManager.fields
    -- ipairs is safe on FS25's C++ backed fields table; pairs can trigger __pairs metamethods
    -- and freeze on large maps. Use ipairs for the primary scan.
    for _, field in ipairs(fields) do
        if field and type(field) == "table" then
            local actualFieldId = field.farmland and field.farmland.id

            if actualFieldId and actualFieldId > 0 then
                -- FS25: field.fieldArea is the cultivated area in hectares.
                -- Fallback to farmland area if fieldArea is missing (though it shouldn't be).
                local area = field.fieldArea or (field.farmland and field.farmland.area) or 1.0

                SoilLogger.debug("Found field %d (%.2f ha)", actualFieldId, area)

                local isNew = self.fieldData[actualFieldId] == nil
                self:getOrCreateField(actualFieldId, true, area)

                -- If this is a newly created field and density layers are available,
                -- read existing layer values (pre-seeded GRLE) instead of using defaults.
                if isNew and self.layerSystem and self.layerSystem.available and field.farmland then
                    self.layerSystem:readFieldFromLayers(actualFieldId, self.fieldData[actualFieldId], field.farmland)
                end

                fieldCount = fieldCount + 1
            end
        end
    end

    -- SECONDARY SCAN: catch farmlands whose field entry was unreachable via ipairs on
    -- large/custom maps where g_fieldManager.fields has non-sequential indices (64x maps).
    if g_farmlandManager and g_farmlandManager.farmlands then
        for farmlandId, _ in pairs(g_farmlandManager.farmlands) do
            if type(farmlandId) == "number" and farmlandId > 0 and not self.fieldData[farmlandId] then
                self:getOrCreateField(farmlandId, true, 1.0)
                fieldCount = fieldCount + 1
                SoilLogger.debug("Secondary scan caught missed farmland %d", farmlandId)
            end
        end
    end

    self:info("Scanned %d farmlands and initialized %d fields", farmlandCount, fieldCount)

    if fieldCount > 0 then
        self.fieldsScanPending = false

        -- Broadcast all field data to connected clients immediately after scan.
        -- Without this, clients on a dedicated server never receive the initial state
        -- because per-field syncs only fire on harvest / fertilizer events.
        self:broadcastAllFieldData()

        return true
    end

    return false
end

--- Broadcast every tracked field to all connected clients.
--- Called once after a successful field scan and can be called again
--- at any time to force a full re-sync (e.g. after a save/load cycle).
function SoilFertilitySystem:broadcastAllFieldData()
    if not g_server then return end
    if not g_currentMission then return end
    if not (g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer) then return end
    if not SoilFieldBatchSyncEvent then return end

    local fieldIds = {}
    for fieldId in pairs(self.fieldData) do
        table.insert(fieldIds, fieldId)
    end
    local fieldCount = #fieldIds
    if fieldCount == 0 then return end

    local batchSize  = SoilConstants.NETWORK.FULL_SYNC_BATCH_SIZE
    local batchDelay = SoilConstants.NETWORK.FULL_SYNC_BATCH_DELAY
    local totalBatches = math.ceil(fieldCount / batchSize)
    local fieldData = self.fieldData

    if g_dedicatedServer then
        -- Dedicated server: send all batches synchronously (no live rendering pressure)
        self:info("Broadcasting %d fields to all clients in %d synchronous batches", fieldCount, totalBatches)
        for batchIndex = 1, totalBatches do
            local startIdx = (batchIndex - 1) * batchSize + 1
            local endIdx   = math.min(batchIndex * batchSize, fieldCount)
            local batch = {}
            for i = startIdx, endIdx do
                local id = fieldIds[i]
                batch[id] = fieldData[id]
            end
            local isLast = (batchIndex == totalBatches)
            g_server:broadcastEvent(SoilFieldBatchSyncEvent.new(batch, isLast))
        end
    else
        -- Listen server: drip-feed batches across frames to avoid a single-frame spike
        self:info("Broadcasting %d fields to all clients in %d batched frames", fieldCount, totalBatches)
        local batchDispatcher = {
            batchIndex   = 1,
            totalBatches = totalBatches,
            batchSize    = batchSize,
            batchDelay   = batchDelay,
            timer        = 0,
            fieldIds     = fieldIds,
            fieldData    = fieldData,
            update = function(self, dt)
                if self.batchIndex > self.totalBatches then
                    g_currentMission:removeUpdateable(self)
                    return
                end
                self.timer = self.timer + dt
                if self.timer < self.batchDelay then return end
                self.timer = 0
                local startIdx = (self.batchIndex - 1) * self.batchSize + 1
                local endIdx   = math.min(self.batchIndex * self.batchSize, #self.fieldIds)
                local batch = {}
                for i = startIdx, endIdx do
                    local id = self.fieldIds[i]
                    batch[id] = self.fieldData[id]
                end
                local isLast = (self.batchIndex == self.totalBatches)
                g_server:broadcastEvent(SoilFieldBatchSyncEvent.new(batch, isLast))
                self.batchIndex = self.batchIndex + 1
                if self.batchIndex > self.totalBatches then
                    g_currentMission:removeUpdateable(self)
                end
            end,
            delete = function(self)
                g_currentMission:removeUpdateable(self)
            end
        }
        g_currentMission:addUpdateable(batchDispatcher)
    end
end

--- Send all tracked field data to a single newly-joined client.
--- Wire this up from your multiplayer join / connection-accepted handler.
---@param connection table The network connection object for the joining client
function SoilFertilitySystem:onClientJoined(connection)
    if g_server == nil then return end
    if not connection then return end
    if not SoilFieldUpdateEvent then return end

    local count = 0
    for fieldId, field in pairs(self.fieldData) do
        connection:sendEvent(SoilFieldUpdateEvent.new(fieldId, field))
        count = count + 1
    end

    self:info("Sent %d fields to newly joined client", count)
end

-- Get or create field data
function SoilFertilitySystem:getOrCreateField(fieldId, createIfMissing, area)
    if not fieldId or fieldId <= 0 then return nil end

    -- Return existing field
    if self.fieldData[fieldId] then
        -- Update area if provided (handles initial scan or later updates)
        if area and area > 0 then
            self.fieldData[fieldId].fieldArea = area
        end
        return self.fieldData[fieldId]
    end

    -- Don't create if not requested
    if not createIfMissing then
        return nil
    end

    -- MULTIPLAYER SAFETY: Only server should create new fields
    -- Clients must wait for sync to avoid desync issues with randomized initial values
    if g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
        if g_server == nil then
            -- Client in multiplayer - return nil and wait for server sync
            return nil
        end
    end

    -- Allow lazy creation (HUD-safe, server-only in multiplayer)
    -- Add natural soil variation: ±10% for nutrients, ±0.5 for pH, ±0.5% for OM
    -- This reflects real-world soil diversity across a map.
    --
    -- Use a deterministic hash instead of math.randomseed() to avoid polluting
    -- the global Lua random state (math.randomseed resets the shared PRNG, which
    -- disrupts any other code using math.random() in the same frame — e.g. during
    -- the initial bulk field scan where many fields are created simultaneously).
    local function hash(n)
        -- Lua 5.1-compatible deterministic hash (LCG-style, no bitwise ops).
        -- Produces a float in [0.0, 1.0) that is stable for the same (fieldId, slot) pair
        -- across save/load cycles. Avoids touching math.randomseed (global state).
        n = (n * 1664525 + 1013904223) % 4294967296
        n = (n * 1664525 + 1013904223) % 4294967296
        return n / 4294967296
    end
    local function randField(slot)
        -- Each nutrient gets its own deterministic slot so values are independent
        local r = hash(fieldId * 67890 + slot)
        return r * 2.0 - 1.0  -- range [-1.0, 1.0]
    end

    local function randomize(baseValue, variation, slot)
        return baseValue + randField(slot) * variation
    end

    local defaults = SoilConstants.FIELD_DEFAULTS

    self.fieldData[fieldId] = {
        fieldArea = area or 1.0, -- default 1ha if unknown
        nitrogen   = math.floor(randomize(defaults.nitrogen,   defaults.nitrogen   * 0.10, 1)),
        phosphorus = math.floor(randomize(defaults.phosphorus, defaults.phosphorus * 0.10, 2)),
        potassium  = math.floor(randomize(defaults.potassium,  defaults.potassium  * 0.10, 3)),
        organicMatter = math.max(1.0, math.min(10.0, randomize(defaults.organicMatter, 0.5, 4))),
        pH            = math.max(5.0, math.min(8.5,  randomize(defaults.pH,            0.5, 5))),
        lastCrop = nil,
        lastCrop2 = nil,
        lastCrop3 = nil,
        rotationBonusDaysLeft = 0,
        lastHarvest = 0,
        fertilizerApplied = 0,
        initialized = true,
        weedPressure = 0,
        herbicideDaysLeft = 0,
        pestPressure = 0,
        insecticideDaysLeft = 0,
        diseasePressure = 0,
        fungicideDaysLeft = 0,
        dryDayCount = 0,
        nutrientBuffer = {},  -- Tracks [fillTypeIndex] = litersApplied (reset daily)
        zoneData = {},        -- Sparse {cellKey → {N,P,K,pH,OM}} for per-area overlay
        coveredCells = {},    -- Set of cellKey strings touched by fertilizer today (reset daily)
        coveredCellCount = 0, -- Running count of coveredCells for O(1) fraction computation
        totalFieldCells = 0,  -- Estimated cell count from field area (set on first spray)
        coverageFraction = 0, -- Fraction of field covered today (0.0–1.0)
        compaction = 0,            -- field-average compaction 0–100 (derived from cells)
        compactionCells = {},      -- {cellKey → 0-100} per-cell compaction (10×10 m grid)
        compactionCellDays = {},   -- {cellKey → day} per-cell once-per-day throttle (transient)
        compactionSum = 0,         -- running sum of cell values for O(1) average
        compactionTotalCells = 0,  -- total estimated field cells (set lazily from fieldArea)
        lastAlertYear = 0,    -- In-game year when the last critical alert fired (persisted)
    }

    self:log("Lazy-created field %d with area %.2f ha and natural soil variation", fieldId, self.fieldData[fieldId].fieldArea)
    return self.fieldData[fieldId]
end

-- Daily soil update
function SoilFertilitySystem:updateDailySoil()
    if not self.settings.enabled or not self.settings.nutrientCycles then return end

    local currentDay = (g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay) or 0
    local limits = SoilConstants.NUTRIENT_LIMITS
    local recovery = SoilConstants.FALLOW_RECOVERY
    local seasonal = SoilConstants.SEASONAL_EFFECTS
    local phNorm = SoilConstants.PH_NORMALIZATION

    local isMP = g_server and g_currentMission and g_currentMission.missionDynamicInfo and
                 g_currentMission.missionDynamicInfo.isMultiplayer and SoilFieldUpdateEvent
    local changedFields = isMP and {} or nil

    for fieldId, field in pairs(self.fieldData) do
        local prevWeed    = field.weedPressure    or 0
        local prevPest    = field.pestPressure    or 0
        local prevDisease = field.diseasePressure or 0

        -- Clear fertilizer and coverage buffers daily
        field.nutrientBuffer    = {}
        field.coveredCells      = {}
        field.coveredCellCount  = 0
        field.coverageFraction  = 0

        -- Compaction natural decay (per-cell) + daily throttle reset
        if self.settings.compactionEnabled and SoilConstants.COMPACTION then
            local cp = SoilConstants.COMPACTION
            field.compactionCellDays = {}  -- reset per-cell once-per-day throttle
            if field.compactionCells and next(field.compactionCells) then
                local newSum = 0
                for cellKey, val in pairs(field.compactionCells) do
                    local newVal = val - cp.NATURAL_DECAY_PER_DAY
                    if newVal > 0 then
                        field.compactionCells[cellKey] = newVal
                        newSum = newSum + newVal
                    else
                        field.compactionCells[cellKey] = nil
                    end
                end
                field.compactionSum = newSum
                local tc = field.compactionTotalCells or 0
                field.compaction = tc > 0 and (newSum / tc) or 0
            end
        end

        -- Natural nutrient recovery for fallow fields
        local daysSinceFallow = currentDay - (field.lastHarvest or 0)
        if daysSinceFallow > SoilConstants.TIMING.FALLOW_THRESHOLD then
            field.nitrogen = math.min(limits.MAX, field.nitrogen + recovery.nitrogen)
            field.phosphorus = math.min(limits.MAX, field.phosphorus + recovery.phosphorus)
            field.potassium = math.min(limits.MAX, field.potassium + recovery.potassium)
            field.organicMatter = math.min(limits.ORGANIC_MATTER_MAX, field.organicMatter + recovery.organicMatter)
        end

        -- Seasonal effects (if enabled)
        if self.settings.seasonalEffects and g_currentMission and g_currentMission.environment then
            local season = g_currentMission.environment.currentSeason
            if season == seasonal.SPRING_SEASON then
                field.nitrogen = math.min(limits.MAX, field.nitrogen + seasonal.SPRING_NITROGEN_BOOST)
            elseif season == seasonal.FALL_SEASON then
                field.nitrogen = math.max(limits.MIN, field.nitrogen - seasonal.FALL_NITROGEN_LOSS)
            end
        end

        -- Crop rotation spring bonus
        if self.settings.cropRotation and g_currentMission and g_currentMission.environment then
            local season = g_currentMission.environment.currentSeason
            -- On first day of spring: initialise bonus counter for qualifying fields
            if season == seasonal.SPRING_SEASON and self.lastSeason ~= seasonal.SPRING_SEASON then
                if field.lastCrop and field.lastCrop2 then
                    local cr = SoilConstants.CROP_ROTATION
                    local c1 = string.lower(field.lastCrop)
                    local c2 = string.lower(field.lastCrop2)
                    if cr.LEGUMES[c1] and not cr.LEGUMES[c2]
                       and (field.rotationBonusDaysLeft or 0) == 0 then
                        field.rotationBonusDaysLeft = cr.LEGUME_BONUS_DAYS
                    end
                end
            end
            -- Apply bonus while counter > 0 in spring
            if season == seasonal.SPRING_SEASON and (field.rotationBonusDaysLeft or 0) > 0 then
                local cr = SoilConstants.CROP_ROTATION
                field.nitrogen = math.min(limits.MAX, field.nitrogen + cr.LEGUME_BONUS_N_PER_DAY)
                field.rotationBonusDaysLeft = field.rotationBonusDaysLeft - 1
            end
        end

        -- pH normalization toward neutral (very slow)
        if field.pH < limits.PH_NEUTRAL_LOW then
            field.pH = math.min(limits.PH_NEUTRAL_LOW, field.pH + phNorm.RATE)
        elseif field.pH > limits.PH_NEUTRAL_HIGH then
            field.pH = math.max(limits.PH_NEUTRAL_HIGH, field.pH - phNorm.RATE)
        end

        -- Weed pressure daily growth
        if self.settings.weedPressure and SoilConstants.WEED_PRESSURE then
            local wp = SoilConstants.WEED_PRESSURE
            local pressure = field.weedPressure or 0
            local herbDays = field.herbicideDaysLeft or 0

            -- Decrement herbicide protection
            if herbDays > 0 then
                field.herbicideDaysLeft = herbDays - 1
            end

            -- Only grow when not under herbicide protection
            if (field.herbicideDaysLeft or 0) <= 0 then
                -- Base rate by current pressure tier
                local baseRate
                if pressure < wp.LOW then
                    baseRate = wp.GROWTH_RATE_LOW
                elseif pressure < wp.MEDIUM then
                    baseRate = wp.GROWTH_RATE_MID
                elseif pressure < wp.HIGH then
                    baseRate = wp.GROWTH_RATE_HIGH
                else
                    baseRate = wp.GROWTH_RATE_PEAK
                end

                -- Seasonal multiplier
                local seasonMult = 1.0
                if g_currentMission and g_currentMission.environment then
                    local season = g_currentMission.environment.currentSeason
                    if season == 1 then seasonMult = wp.SEASONAL_SPRING
                    elseif season == 2 then seasonMult = wp.SEASONAL_SUMMER
                    elseif season == 3 then seasonMult = wp.SEASONAL_FALL
                    elseif season == 4 then seasonMult = wp.SEASONAL_WINTER
                    end
                end

                -- Rain bonus (wp.RAIN_BONUS was defined but never applied — Bug 6 fix)
                local rainBonus = 0
                if g_currentMission and g_currentMission.environment and
                   g_currentMission.environment.weather and
                   (g_currentMission.environment.weather.rainScale or 0) > SoilConstants.RAIN.MIN_RAIN_THRESHOLD then
                    rainBonus = wp.RAIN_BONUS
                end

                field.weedPressure = math.min(100, pressure + baseRate * seasonMult + rainBonus)
            end
        end

        -- Pest pressure daily growth
        if self.settings.pestPressure and SoilConstants.PEST_PRESSURE then
            local pp = SoilConstants.PEST_PRESSURE

            -- Decrement insecticide protection
            if (field.insecticideDaysLeft or 0) > 0 then
                field.insecticideDaysLeft = field.insecticideDaysLeft - 1
            end

            -- Only grow when not under insecticide protection
            if (field.insecticideDaysLeft or 0) <= 0 then
                local pressure = field.pestPressure or 0

                -- Base rate by tier
                local baseRate
                if pressure < pp.LOW then
                    baseRate = pp.GROWTH_RATE_LOW
                elseif pressure < pp.MEDIUM then
                    baseRate = pp.GROWTH_RATE_MID
                elseif pressure < pp.HIGH then
                    baseRate = pp.GROWTH_RATE_HIGH
                else
                    baseRate = pp.GROWTH_RATE_PEAK
                end

                -- Seasonal multiplier
                local seasonMult = 1.0
                if g_currentMission and g_currentMission.environment then
                    local season = g_currentMission.environment.currentSeason
                    if season == 1 then seasonMult = pp.SEASONAL_SPRING
                    elseif season == 2 then seasonMult = pp.SEASONAL_SUMMER
                    elseif season == 3 then seasonMult = pp.SEASONAL_FALL
                    elseif season == 4 then seasonMult = pp.SEASONAL_WINTER
                    end
                end

                -- Crop susceptibility multiplier
                local cropMult = 1.0
                if field.lastCrop then
                    cropMult = pp.CROP_SUSCEPTIBILITY[string.lower(field.lastCrop)] or 1.0
                end

                -- Rain bonus (check current rain state)
                local rainBonus = 0
                if g_currentMission and g_currentMission.environment and
                   g_currentMission.environment.weather and
                   (g_currentMission.environment.weather.rainScale or 0) > SoilConstants.RAIN.MIN_RAIN_THRESHOLD then
                    rainBonus = pp.RAIN_BONUS
                end

                field.pestPressure = math.min(100, pressure + (baseRate * seasonMult * cropMult) + rainBonus)
            end
        end

        -- Disease pressure daily growth
        if self.settings.diseasePressure and SoilConstants.DISEASE_PRESSURE then
            local dp = SoilConstants.DISEASE_PRESSURE
            local isRaining = g_currentMission and g_currentMission.environment and
                              g_currentMission.environment.weather and
                              (g_currentMission.environment.weather.rainScale or 0) > SoilConstants.RAIN.MIN_RAIN_THRESHOLD

            -- Track consecutive dry days for natural decay
            if isRaining then
                field.dryDayCount = 0
            else
                field.dryDayCount = (field.dryDayCount or 0) + 1
            end

            -- Decrement fungicide protection
            if (field.fungicideDaysLeft or 0) > 0 then
                field.fungicideDaysLeft = field.fungicideDaysLeft - 1
            end

            local pressure = field.diseasePressure or 0

            -- Natural dry-weather decay (overrides growth)
            if (field.dryDayCount or 0) >= dp.DRY_DAYS_THRESHOLD then
                field.diseasePressure = math.max(0, pressure - dp.DRY_DECAY_RATE)
            elseif (field.fungicideDaysLeft or 0) <= 0 then
                -- Only grow when not protected

                local baseRate
                if pressure < dp.LOW then
                    baseRate = dp.GROWTH_RATE_LOW
                elseif pressure < dp.MEDIUM then
                    baseRate = dp.GROWTH_RATE_MID
                elseif pressure < dp.HIGH then
                    baseRate = dp.GROWTH_RATE_HIGH
                else
                    baseRate = dp.GROWTH_RATE_PEAK
                end

                local seasonMult = 1.0
                if g_currentMission and g_currentMission.environment then
                    local season = g_currentMission.environment.currentSeason
                    if season == 1 then seasonMult = dp.SEASONAL_SPRING
                    elseif season == 2 then seasonMult = dp.SEASONAL_SUMMER
                    elseif season == 3 then seasonMult = dp.SEASONAL_FALL
                    elseif season == 4 then seasonMult = dp.SEASONAL_WINTER
                    end
                end

                local cropMult = 1.0
                if field.lastCrop then
                    cropMult = dp.CROP_SUSCEPTIBILITY[string.lower(field.lastCrop)] or 1.0
                end

                local rainBonus = isRaining and dp.RAIN_BONUS or 0

                field.diseasePressure = math.min(100, pressure + (baseRate * seasonMult * cropMult) + rainBonus)
            end
        end

        -- Burn warning countdown — decrements each day until cleared
        if (field.burnDaysLeft or 0) > 0 then
            field.burnDaysLeft = field.burnDaysLeft - 1
        end

        -- Critical Field Alerts (once per season per owned field)
        if self.settings.showNotifications and g_currentMission and g_currentMission.environment then
            local season = g_currentMission.environment.currentSeason
            local threshold = SoilConstants.CRITICAL_ALERT_THRESHOLD or 50
            if field.lastAlertSeason ~= season then
                local urgency = self:getFieldUrgency(fieldId)
                if urgency > threshold then
                    local isOwned = false
                    local farmId = g_localPlayer and g_localPlayer.farmId
                    if farmId and farmId > 0 and g_farmlandManager then
                        local owner = g_farmlandManager:getFarmlandOwner(fieldId)
                        if owner == farmId then
                            isOwned = true
                        end
                    end
                    if isOwned then
                        self:showNotification("Critical Care Alert", string.format("Field %d requires immediate attention! Urgency: %d%%", fieldId, math.floor(urgency)))
                    end
                    field.lastAlertSeason = season
                end
            end
        end

        -- Track changed fields for MP broadcast (only if pressure values changed)
        if changedFields then
            if math.abs((field.weedPressure    or 0) - prevWeed)    > 0.01 or
               math.abs((field.pestPressure    or 0) - prevPest)    > 0.01 or
               math.abs((field.diseasePressure or 0) - prevDisease) > 0.01 then
                changedFields[fieldId] = field
            end
        end
    end

    -- Broadcast pressure changes to clients (dedicated server / MP only)
    if changedFields and next(changedFields) then
        local count = 0
        for fieldId, field in pairs(changedFields) do
            g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
            count = count + 1
        end
        self:log("Daily broadcast: %d field(s) with pressure changes synced to clients", count)
    end

    -- Track season for spring-transition detection (crop rotation bonus)
    if g_currentMission and g_currentMission.environment then
        self.lastSeason = g_currentMission.environment.currentSeason
    end

    self:log("Daily soil update completed for %d fields", self:getFieldCount())
end

-- Apply rain effects
function SoilFertilitySystem:applyRainEffects(dt, rainScale)
    if not self.settings.enabled or not self.settings.rainEffects then return end

    local rain = SoilConstants.RAIN
    local limits = SoilConstants.NUTRIENT_LIMITS
    local leachFactor = rainScale * dt * rain.LEACH_BASE_FACTOR

    for fieldId, field in pairs(self.fieldData) do
        field.nitrogen = math.max(limits.MIN, field.nitrogen - (leachFactor * rain.NITROGEN_MULTIPLIER))
        field.potassium = math.max(limits.MIN, field.potassium - (leachFactor * rain.POTASSIUM_MULTIPLIER))
        field.phosphorus = math.max(limits.MIN, field.phosphorus - (leachFactor * rain.PHOSPHORUS_MULTIPLIER))
        field.pH = math.max(limits.PH_MIN, field.pH - (leachFactor * rain.PH_ACIDIFICATION))
    end
end

-- Update field nutrients after harvest
---@param fieldId number The field being harvested
---@param fruitTypeIndex number FS25 fruit type index
---@param harvestedLiters number Amount harvested in liters
---@param strawRatio number 0.0-1.0 fraction of straw chopped back into the field (adds organic matter)
---@param area number Area harvested in m² (unused; reserved for future area-normalised depletion)
function SoilFertilitySystem:updateFieldNutrients(fieldId, fruitTypeIndex, harvestedLiters, strawRatio, area)
    if not self.settings.enabled or not self.settings.nutrientCycles then return end

    local field = self:getOrCreateField(fieldId, true)
    if not field then
        self:warning("Cannot update nutrients - field %d not found", fieldId)
        return
    end

    local fruitDesc = g_fruitTypeManager and g_fruitTypeManager:getFruitTypeByIndex(fruitTypeIndex)
    if not fruitDesc then
        self:warning("Cannot update nutrients - fruit type %d not found", fruitTypeIndex)
        return
    end

    -- Shift crop history before recording new crop (lastCrop → lastCrop2 → lastCrop3)
    field.lastCrop3 = field.lastCrop2
    field.lastCrop2 = field.lastCrop

    -- Look up crop-specific extraction rates (how much N/P/K this crop removes from soil)
    -- Different crops have different nutrient demands:
    -- - Wheat/Barley: High nitrogen demand (leafy growth)
    -- - Corn/Maize: Very high N/P demand (large biomass)
    -- - Soybeans: Low nitrogen (fixes own N), moderate P/K
    -- - Potatoes/Sugar beets: High potassium demand (root/tuber crops)
    local name = string.lower(fruitDesc.name or "unknown")
    local rates = SoilConstants.CROP_EXTRACTION[name] or SoilConstants.CROP_EXTRACTION_DEFAULT

    -- NUTRIENT DEPLETION CALCULATION EXPLAINED:
    --
    -- Step 1: Calculate depletion factor
    -- Formula: factor = harvested liters / 1000
    -- Why: Extraction rates in Constants.lua are calibrated per 1000L of harvested crop
    -- Example: 80,000L wheat harvest → factor = 80
    local factor = harvestedLiters / 1000

    -- Step 2: Apply difficulty multiplier
    -- Simple (0.7x): 30% less depletion, easier for new players
    -- Realistic (1.0x): Balanced depletion based on real agricultural rates
    -- Hardcore (1.5x): 50% more depletion, challenging management
    -- Example: factor 80 × 0.7 (Simple) = 56, or × 1.5 (Hardcore) = 120
    local diffMultiplier = SoilConstants.DIFFICULTY.MULTIPLIERS[self.settings.difficulty]
    if diffMultiplier then
        factor = factor * diffMultiplier
    end

    -- Step 2b: Compaction penalty — compacted soil reduces nutrient uptake efficiency,
    -- causing crops to deplete more of what's available to achieve the same yield.
    if self.settings.compactionEnabled and SoilConstants.COMPACTION then
        local cp = SoilConstants.COMPACTION
        local compaction = field.compaction or 0
        if compaction > 0 then
            local penalty = (compaction / 100) * cp.NUTRIENT_PENALTY_MAX
            factor = factor * (1 + penalty)
        end
    end

    -- Step 3a: Crop rotation fatigue — same crop two seasons running depletes more
    if self.settings.cropRotation and field.lastCrop2 and field.lastCrop2 == fruitDesc.name then
        factor = factor * SoilConstants.CROP_ROTATION.FATIGUE_MULTIPLIER
        self:log("Rotation fatigue on field %d (%s harvested twice) — factor ×%.2f",
            fieldId, fruitDesc.name, SoilConstants.CROP_ROTATION.FATIGUE_MULTIPLIER)
    end

    -- Step 3b: Deplete nutrients from field
    -- Formula: new_value = max(0, current_value - (extraction_rate × factor))
    -- Scale: 0-100 nutrient points
    -- Example: N=50, wheat extraction=0.20, factor=80
    --          → 50 - (0.20 × 80) = 50 - 16 = 34 nitrogen remaining (~32% depletion)
    -- Only N/P/K deplete from harvest; pH and organic matter change through other means
    local limits = SoilConstants.NUTRIENT_LIMITS
    field.nitrogen   = math.max(limits.MIN, field.nitrogen   - rates.N * factor)
    field.phosphorus = math.max(limits.MIN, field.phosphorus - rates.P * factor)
    field.potassium  = math.max(limits.MIN, field.potassium  - rates.K * factor)

    -- Deplete zone cells by the same absolute amount (harvest extracts uniformly across field)
    if field.zoneData then
        local dN, dP, dK = rates.N * factor, rates.P * factor, rates.K * factor
        for _, cell in pairs(field.zoneData) do
            cell.N = math.max(limits.MIN, cell.N - dN)
            cell.P = math.max(limits.MIN, cell.P - dP)
            cell.K = math.max(limits.MIN, cell.K - dK)
        end
    end

    -- Step 4: Chopped straw/chaff adds organic matter
    -- When strawRatio > 0 the combine is chopping material back into the field.
    -- The chopped biomass decomposes and increases soil organic matter.
    -- Formula: OM gain = (harvestedLiters / 1000) × strawRatio × OM_RATE
    local sr = strawRatio or 0
    if sr > 0 then
        local omGain = (harvestedLiters / 1000) * sr * SoilConstants.CHOPPED_STRAW.OM_RATE
        field.organicMatter = math.min(limits.ORGANIC_MATTER_MAX, (field.organicMatter or 0) + omGain)
    end

    field.lastCrop = fruitDesc.name
    field.lastHarvest = (g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay) or 0

    self:log(
        "Harvest depletion field %d (%s): -N %.1f -P %.1f -K %.1f",
        fieldId, fruitDesc.name,
        rates.N * factor,
        rates.P * factor,
        rates.K * factor
    )
end

-- Apply fertilizer
function SoilFertilitySystem:applyFertilizer(fieldId, fillTypeIndex, liters)
    if not self.settings.enabled then return end

    local field = self:getOrCreateField(fieldId, true)
    if not field then
        self:warning("Cannot apply fertilizer - field %d not found", fieldId)
        return
    end

    local fillType = g_fillTypeManager and g_fillTypeManager:getFillTypeByIndex(fillTypeIndex)
    if not fillType then
        self:warning("Cannot apply fertilizer - fill type %d not found", fillTypeIndex)
        return
    end

    -- Look up fertilizer profile from constants (defines N/P/K/pH/OM values per type)
    local entry = SoilConstants.FERTILIZER_PROFILES[fillType.name]
    if not entry then
        self:log("Fertilizer type %s not recognized", fillType.name)
        return
    end

    local limits = SoilConstants.NUTRIENT_LIMITS

    -- AREA NORMALIZATION: Calculate hectares for this field
    local areaInHa = field.fieldArea or 1.0
    if areaInHa <= 0 then areaInHa = 1.0 end

    -- FERTILIZER RESTORATION CALCULATION:
    -- V1.7 Realism Update: Incremental application.
    -- Nutrients and crop protection effects are applied every frame as you spray.
    -- This provides immediate feedback on the HUD and removes the "90% cliff" delay.
    
    if not field.nutrientBuffer then field.nutrientBuffer = {} end
    field.nutrientBuffer[fillTypeIndex] = (field.nutrientBuffer[fillTypeIndex] or 0) + liters

    -- 1. Route crop protection products (incremental reduction with daily cap)
    -- Daily cap prevents over-application from driving pressure to zero in a few
    -- frames when BASE_RATES targetRate is mismatched to real sprayer LPS.
    if entry.pestReduction then
        local targetRate = SoilConstants.SPRAYER_RATE.BASE_RATES[fillType.name] or SoilConstants.SPRAYER_RATE.BASE_RATES.INSECTICIDE
        local targetVol  = areaInHa * targetRate.value
        if targetVol > 0 then
            local baseRed = SoilConstants.PEST_PRESSURE.INSECTICIDE_PRESSURE_REDUCTION or 25
            local proposed = (liters / targetVol) * baseRed
            if not self.insecticideDailyApplied then self.insecticideDailyApplied = {} end
            local today = (g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay) or 0
            local e = self.insecticideDailyApplied[fieldId]
            if not e or e.day ~= today then e = { day = today, applied = 0 }; self.insecticideDailyApplied[fieldId] = e end
            local remaining = math.max(0, baseRed - e.applied)
            local clamped = math.min(proposed, remaining)
            e.applied = e.applied + clamped
            if clamped > 0 then self:onInsecticideAppliedIncremental(fieldId, clamped) end
        end

    elseif entry.diseaseReduction then
        local targetRate = SoilConstants.SPRAYER_RATE.BASE_RATES[fillType.name] or SoilConstants.SPRAYER_RATE.BASE_RATES.FUNGICIDE
        local targetVol  = areaInHa * targetRate.value
        if targetVol > 0 then
            local baseRed = SoilConstants.DISEASE_PRESSURE.FUNGICIDE_PRESSURE_REDUCTION or 20
            local proposed = (liters / targetVol) * baseRed
            if not self.fungicideDailyApplied then self.fungicideDailyApplied = {} end
            local today = (g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay) or 0
            local e = self.fungicideDailyApplied[fieldId]
            if not e or e.day ~= today then e = { day = today, applied = 0 }; self.fungicideDailyApplied[fieldId] = e end
            local remaining = math.max(0, baseRed - e.applied)
            local clamped = math.min(proposed, remaining)
            e.applied = e.applied + clamped
            if clamped > 0 then self:onFungicideAppliedIncremental(fieldId, clamped) end
        end

    else
        -- 2. Apply standard nutrients (scaled by the liters applied this frame)
        local rrIdx  = self.settings.replenishmentRate or 3
        local rrMult = SoilConstants.DIFFICULTY.REPLENISHMENT_MULTIPLIERS[rrIdx] or 1.0
        local factor = (liters / 1000) / areaInHa * rrMult

        -- Capture before-values for diagnostic logging (debug mode only).
        local dbgN0, dbgP0, dbgK0, dbgPH0 = field.nitrogen, field.phosphorus, field.potassium, field.pH

        if entry.N then field.nitrogen   = math.min(limits.MAX, field.nitrogen   + entry.N * factor) end
        if entry.P then field.phosphorus = math.min(limits.MAX, field.phosphorus + entry.P * factor) end
        if entry.K then field.potassium  = math.min(limits.MAX, field.potassium  + entry.K * factor) end
        if entry.pH then field.pH        = math.max(limits.PH_MIN, math.min(limits.PH_MAX, field.pH + entry.pH * factor)) end
        if entry.OM then field.organicMatter = math.min(limits.ORGANIC_MATTER_MAX, field.organicMatter + entry.OM * factor) end

        -- Throttled per-field diagnostic (debug mode, lime types always logged; nutrients every 4 s).
        -- Validates that pH shift and nutrient deltas are agronomically sensible.
        -- For LIME/LIQUIDLIME: target ~0.40 pH over a full 1-ha pass at BASE_RATES volume.
        -- For nutrients: visible delta per frame should be tiny; cumulative over full pass = profile value.
        if entry.pH then
            -- pH types (LIME, LIQUIDLIME, GYPSUM): log every application event so you can
            -- see the per-frame delta and verify it adds up to ~0.40 over a full field pass.
            SoilLogger.debug(
                "FertApply pH field=%d type=%-12s liters=%.4f factor=%.6f  pH %.3f -> %.3f (delta=%.4f)",
                fieldId, fillType.name, liters, factor, dbgPH0, field.pH, field.pH - dbgPH0)
        else
            -- Nutrient types: only log once every ~4 s to avoid log spam.
            -- Uses the field buffer length as a crude frame counter (avoids a time lookup).
            local buf = field.nutrientBuffer and field.nutrientBuffer[fillTypeIndex] or 0
            -- Log when buffer crosses a 1000-L boundary (roughly once per ~large-step).
            local prevBuf = buf - liters
            if math.floor(buf / 1000) ~= math.floor(prevBuf / 1000) then
                SoilLogger.debug(
                    "FertApply NPK field=%d type=%-12s buf=%.0fL  N %.1f->%.1f  P %.1f->%.1f  K %.1f->%.1f",
                    fieldId, fillType.name, buf,
                    dbgN0, field.nitrogen, dbgP0, field.phosphorus, dbgK0, field.potassium)
            end
        end

        -- Write updated values to density map layers (per-pixel, at sprayer position)
        if self.layerSystem and self.layerSystem.available then
            local x, _, z = getWorldTranslation(0)  -- fallback; sprayer hook sets vehicle pos via soilSystem
            if self._lastSprayX and self._lastSprayZ then
                x, z = self._lastSprayX, self._lastSprayZ
            end
            if x and z then
                if entry.N then self.layerSystem:updatePixelForField("nitrogen",      x, z, field.nitrogen,      2.0) end
                if entry.P then self.layerSystem:updatePixelForField("phosphorus",    x, z, field.phosphorus,    2.0) end
                if entry.K then self.layerSystem:updatePixelForField("potassium",     x, z, field.potassium,     2.0) end
                if entry.pH then self.layerSystem:updatePixelForField("pH",           x, z, field.pH,            2.0) end
                if entry.OM then self.layerSystem:updatePixelForField("organicMatter",x, z, field.organicMatter, 2.0) end
            end
        end

        -- zoneData per-cell update for per-area PDA overlay coloring (standard maps)
        local sprayX = self._lastSprayX
        local sprayZ = self._lastSprayZ
        if sprayX and sprayZ then
            local zone = SoilConstants.ZONE
            local cx = math.floor(sprayX / zone.CELL_SIZE)
            local cz = math.floor(sprayZ / zone.CELL_SIZE)
            local cellKey = cx .. "_" .. cz

            -- Coverage tracking: count unique cells visited today
            if not field.coveredCells then field.coveredCells = {} end
            if not field.coveredCells[cellKey] then
                field.coveredCells[cellKey] = true
                field.coveredCellCount = (field.coveredCellCount or 0) + 1
                -- Compute totalFieldCells once (lazily) from field area
                if (field.totalFieldCells or 0) == 0 then
                    field.totalFieldCells = math.max(1, math.ceil(areaInHa / zone.CELL_AREA_HA))
                end
                local prevCoverage = field.coverageFraction or 0
                field.coverageFraction = math.min(1.0, field.coveredCellCount / field.totalFieldCells)
                -- Log milestone crossings (10/25/50/75/100%) so testers can verify
                -- coverage is ticking up correctly during a spray pass.
                local milestones = { 0.10, 0.25, 0.50, 0.75, 1.0 }
                for _, m in ipairs(milestones) do
                    if prevCoverage < m and field.coverageFraction >= m then
                        SoilLogger.debug(
                            "Coverage field=%d  %.0f%% covered (%d/%d cells)  type=%s",
                            fieldId, m * 100,
                            field.coveredCellCount, field.totalFieldCells,
                            fillType.name)
                        break
                    end
                end
            end

            if not field.zoneData then field.zoneData = {} end
            if not field.zoneData[cellKey] then
                field.zoneData[cellKey] = {
                    N  = field.nitrogen,
                    P  = field.phosphorus,
                    K  = field.potassium,
                    pH = field.pH,
                    OM = field.organicMatter,
                }
            end
            local cell = field.zoneData[cellKey]
            -- cellFactor must use areaInHa (not zone.CELL_AREA_HA) so that each cell
            -- absorbs nutrients at exactly the same per-frame rate as the whole-field
            -- average.  Using CELL_AREA_HA (0.01 ha) as the denominator made cells
            -- gain nutrients up to 1000× faster than the field total, causing the HUD
            -- to show near-complete N saturation after less than 1% field coverage
            -- (issue #205 Bug 2).
            local cellFactor = (liters / 1000.0) / areaInHa
            if entry.N  then cell.N  = math.min(limits.MAX,                     cell.N  + entry.N  * cellFactor) end
            if entry.P  then cell.P  = math.min(limits.MAX,                     cell.P  + entry.P  * cellFactor) end
            if entry.K  then cell.K  = math.min(limits.MAX,                     cell.K  + entry.K  * cellFactor) end
            if entry.pH then cell.pH = math.max(limits.PH_MIN, math.min(limits.PH_MAX, cell.pH + entry.pH * cellFactor)) end
            if entry.OM then cell.OM = math.min(limits.ORGANIC_MATTER_MAX,      cell.OM + entry.OM * cellFactor) end
        end
    end

    field.fertilizerApplied = (field.fertilizerApplied or 0) + liters

    -- Check for "Field fully treated" notification (once per field per day at 90% threshold)
    -- Skip notification for crop-protection products (INSECTICIDE, FUNGICIDE, HERBICIDE) —
    -- they share FERTILIZER_PROFILES entries for pest/disease reduction but are not fertilizers,
    -- so "fully treated with INSECTICIDE" would be misleading and incorrect.
    local isCropProtection = (
        (SoilConstants.PEST_PRESSURE    and SoilConstants.PEST_PRESSURE.INSECTICIDE_TYPES    and SoilConstants.PEST_PRESSURE.INSECTICIDE_TYPES[fillType.name])    or
        (SoilConstants.DISEASE_PRESSURE and SoilConstants.DISEASE_PRESSURE.FUNGICIDE_TYPES   and SoilConstants.DISEASE_PRESSURE.FUNGICIDE_TYPES[fillType.name])    or
        (SoilConstants.WEED_PRESSURE    and SoilConstants.WEED_PRESSURE.HERBICIDE_TYPES      and SoilConstants.WEED_PRESSURE.HERBICIDE_TYPES[fillType.name])
    )
    if not isCropProtection then
        local baseRateEntry = SoilConstants.SPRAYER_RATE.BASE_RATES[fillType.name] or
                             SoilConstants.SPRAYER_RATE.BASE_RATES.DEFAULT
        local targetVolume = areaInHa * baseRateEntry.value
        local coverageThreshold = targetVolume * SoilConstants.SPRAYER_RATE.FERTILIZER_COVERAGE_THRESHOLD

        local minCoverage = SoilConstants.COVERAGE and SoilConstants.COVERAGE.MIN_FULL_CREDIT or 0.70
        if field.nutrientBuffer[fillTypeIndex] >= coverageThreshold and
           (field.coverageFraction or 0) >= minCoverage then
            local today = (g_currentMission and g_currentMission.environment and
                           g_currentMission.environment.currentDay) or 0
            if not self.fertNotifyShown then self.fertNotifyShown = {} end
            if self.fertNotifyShown[fieldId] ~= today then
                self:showNotification("Soil Update", string.format("Field %d fully treated with %s", fieldId, fillType.name))
                self.fertNotifyShown[fieldId] = today
            end
        end
    end
end

--- Incremental insecticide application (called every frame while spraying)
function SoilFertilitySystem:onInsecticideAppliedIncremental(fieldId, reduction)
    if not self.settings.pestPressure then return end
    local field = self:getOrCreateField(fieldId, false)
    if not field then return end

    local pp = SoilConstants.PEST_PRESSURE
    local before = field.pestPressure or 0
    field.pestPressure = math.max(0, before - reduction)
    field.insecticideDaysLeft = pp.INSECTICIDE_DURATION_DAYS

    -- Sync only occasionally or on significant changes to save bandwidth in MP
end

--- Incremental fungicide application
function SoilFertilitySystem:onFungicideAppliedIncremental(fieldId, reduction)
    if not self.settings.diseasePressure then return end
    local field = self:getOrCreateField(fieldId, false)
    if not field then return end

    local dp = SoilConstants.DISEASE_PRESSURE
    local before = field.diseasePressure or 0
    field.diseasePressure = math.max(0, before - reduction)
    field.fungicideDaysLeft = dp.FUNGICIDE_DURATION_DAYS
end

-- =====================================================================
-- DAILY REDUCTION CAP HELPERS
-- =====================================================================
-- The Direct-path functions below are invoked every frame by the sprayer hook
-- (~60x/sec). Each call computes a per-frame reduction from `liters`, but the
-- base sprayer LPS (~93.5 L/ha for liquid) is ~60× the "target rate" entries
-- in Constants (1.5 L/ha for HERBICIDE, similar for INSECTICIDE/FUNGICIDE).
-- Without a cap, a 40% weed pressure field drops to 0% in < 1 second (issue
-- #205 over-effectiveness bug).
--
-- Fix: cap total daily reduction at REDUCTION × effectiveness.  Progress is
-- still smooth per-frame (good HUD feel) but over-application is useless —
-- matching realism and the once-per-day model used by onHerbicideApplied.
---@return number currentDay
local function _soilGetCurrentDay()
    return (g_currentMission and g_currentMission.environment and
            g_currentMission.environment.currentDay) or 0
end

--- Apply capped daily reduction to a pressure field.
-- @param dailyTable    self.herbicideDailyApplied[fieldId] = { day = N, applied = X }
-- @param fieldId       field id
-- @param proposedRed   unclamped per-frame reduction
-- @param maxDailyRed   cap for today (REDUCTION × effectiveness)
-- @return clamped reduction to actually apply this frame
local function _soilApplyCappedReduction(dailyTable, fieldId, proposedRed, maxDailyRed)
    local today = _soilGetCurrentDay()
    local entry = dailyTable[fieldId]
    if not entry or entry.day ~= today then
        entry = { day = today, applied = 0 }
        dailyTable[fieldId] = entry
    end
    local remaining = math.max(0, maxDailyRed - entry.applied)
    local clamped = math.min(proposedRed, remaining)
    entry.applied = entry.applied + clamped
    return clamped
end

--- Direct-path buffering for non-profile products (Herbicide/Insecticide/Fungicide)
-- NOTE: the formula (liters/targetVol)×REDUCTION depends on targetRate (from
-- Constants, a real-world L/ha figure ~1.5) matching the actual vanilla sprayer
-- LPS (~93.5 L/ha for liquid).  It does NOT — hence the daily cap below.
function SoilFertilitySystem:onHerbicideAppliedDirect(fieldId, effectiveness, liters)
    if not self.settings.weedPressure then return end
    local field = self:getOrCreateField(fieldId, true)
    if not field then return end

    local areaInHa = field.fieldArea or 1.0
    if areaInHa <= 0 then areaInHa = 1.0 end
    local targetRate = SoilConstants.SPRAYER_RATE.BASE_RATES.HERBICIDE.value
    local targetVol = areaInHa * targetRate
    if targetVol <= 0 then return end

    local effective = effectiveness or 1.0
    local maxReduction = (SoilConstants.WEED_PRESSURE.HERBICIDE_PRESSURE_REDUCTION or 30) * effective
    local proposed = (liters / targetVol) * (SoilConstants.WEED_PRESSURE.HERBICIDE_PRESSURE_REDUCTION or 30) * effective

    if not self.herbicideDailyApplied then self.herbicideDailyApplied = {} end
    local reduction = _soilApplyCappedReduction(self.herbicideDailyApplied, fieldId, proposed, maxReduction)

    if reduction > 0 then
        local before = field.weedPressure or 0
        field.weedPressure = math.max(0, before - reduction)
        field.herbicideDaysLeft = SoilConstants.WEED_PRESSURE.HERBICIDE_DURATION_DAYS
    end

    if not field.nutrientBuffer then field.nutrientBuffer = {} end
    field.nutrientBuffer[99991] = (field.nutrientBuffer[99991] or 0) + liters
end

function SoilFertilitySystem:onInsecticideAppliedDirect(fieldId, effectiveness, liters)
    if not self.settings.pestPressure then return end
    local field = self.fieldData[fieldId]
    if not field then return end

    local areaInHa = field.fieldArea or 1.0
    if areaInHa <= 0 then areaInHa = 1.0 end
    local targetRate = SoilConstants.SPRAYER_RATE.BASE_RATES.INSECTICIDE.value
    local targetVol = areaInHa * targetRate
    if targetVol <= 0 then return end

    local effective = effectiveness or 1.0
    local baseRed = SoilConstants.PEST_PRESSURE.INSECTICIDE_PRESSURE_REDUCTION or 25
    local maxReduction = baseRed * effective
    local proposed = (liters / targetVol) * baseRed * effective

    if not self.insecticideDailyApplied then self.insecticideDailyApplied = {} end
    local reduction = _soilApplyCappedReduction(self.insecticideDailyApplied, fieldId, proposed, maxReduction)

    if reduction > 0 then
        self:onInsecticideAppliedIncremental(fieldId, reduction)
    end

    if not field.nutrientBuffer then field.nutrientBuffer = {} end
    field.nutrientBuffer[99992] = (field.nutrientBuffer[99992] or 0) + liters
end

function SoilFertilitySystem:onFungicideAppliedDirect(fieldId, effectiveness, liters)
    if not self.settings.diseasePressure then return end
    local field = self.fieldData[fieldId]
    if not field then return end

    local areaInHa = field.fieldArea or 1.0
    if areaInHa <= 0 then areaInHa = 1.0 end
    local targetRate = SoilConstants.SPRAYER_RATE.BASE_RATES.FUNGICIDE.value
    local targetVol = areaInHa * targetRate
    if targetVol <= 0 then return end

    local effective = effectiveness or 1.0
    local baseRed = SoilConstants.DISEASE_PRESSURE.FUNGICIDE_PRESSURE_REDUCTION or 20
    local maxReduction = baseRed * effective
    local proposed = (liters / targetVol) * baseRed * effective

    if not self.fungicideDailyApplied then self.fungicideDailyApplied = {} end
    local reduction = _soilApplyCappedReduction(self.fungicideDailyApplied, fieldId, proposed, maxReduction)

    if reduction > 0 then
        self:onFungicideAppliedIncremental(fieldId, reduction)
    end

    if not field.nutrientBuffer then field.nutrientBuffer = {} end
    field.nutrientBuffer[99993] = (field.nutrientBuffer[99993] or 0) + liters
end

--- Apply over-application burn penalty to a field.
--- Called by HookManager after fertilizer is applied at rate > BURN_RISK_THRESHOLD.
--- At risk threshold: probabilistic burn (probability scales linearly with excess).
--- At guaranteed threshold: burn every application.
--- Burn reduces pH and nitrogen to simulate salt/chemical soil damage.
---@param fieldId number
---@param rateMultiplier number The actual rate multiplier used (e.g. 1.5)
function SoilFertilitySystem:applyBurnEffect(fieldId, rateMultiplier)
    local field = self.fieldData[fieldId]
    if not field then return end

    local burnCfg = SoilConstants.SPRAYER_RATE
    local limits  = SoilConstants.NUTRIENT_LIMITS
    local phDrop  = 0
    local nDrain  = 0

    if rateMultiplier >= burnCfg.BURN_GUARANTEED_THRESHOLD then
        phDrop = burnCfg.BURN_PH_DROP_CERTAIN
        nDrain = burnCfg.BURN_N_DRAIN_CERTAIN
        field.burnDaysLeft = 3   -- show burn warning in HUD for 3 in-game days
    else
        -- Probability scales linearly between risk threshold and guaranteed threshold
        local excess = (rateMultiplier - burnCfg.BURN_RISK_THRESHOLD) /
                       (burnCfg.BURN_GUARANTEED_THRESHOLD - burnCfg.BURN_RISK_THRESHOLD)
        if math.random() < excess then
            phDrop = burnCfg.BURN_PH_DROP_RISK
            nDrain = burnCfg.BURN_N_DRAIN_RISK
            field.burnDaysLeft = 3
        end
    end

    if phDrop > 0 then
        field.pH       = math.max(limits.PH_MIN, field.pH - phDrop)
        field.nitrogen = math.max(limits.MIN, field.nitrogen - nDrain)

        self:log("Burn effect field %d: pH -%.2f, N -%.1f (rate=%.0f%%)",
            fieldId, phDrop, nDrain, rateMultiplier * 100)

        if self.settings.showNotifications then
            self:showNotification(
                "Fertilizer Burn",
                string.format("Field %d: over-application damage (pH %.1f)", fieldId, field.pH)
            )
        end

        -- Broadcast updated field data in multiplayer
        if g_server and g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
            if SoilFieldUpdateEvent then
                g_server:broadcastEvent(SoilFieldUpdateEvent.new(fieldId, field))
            end
        end
    end
end

--- Get field info for display (HUD, console, etc)
---@param fieldId number The field ID to query
---@return table|nil Field info with nutrient values and status, or nil if not found
function SoilFertilitySystem:getFieldInfo(fieldId)
    if not fieldId or fieldId <= 0 then return nil end

    local field = self:getOrCreateField(fieldId, true)
    if not field then
        self:warning("Field %d not found in getFieldInfo", fieldId)
        return nil
    end

    local thresholds = SoilConstants.STATUS_THRESHOLDS
    local fertThresholds = SoilConstants.FERTILIZATION_THRESHOLDS

    local function nutrientStatus(value, nutrient)
        local t = thresholds[nutrient]
        if not t then return "Unknown" end
        if value < t.poor then return "Poor"
        elseif value < t.fair then return "Fair"
        else return "Good" end
    end

    local currentDay = (g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay) or 0

    -- Resolve current crop name: prefer the live growing fruit (what's actually in
    -- the ground right now) over lastCrop, which is only set on harvest and will be
    -- stale as soon as the next crop is sown.
    -- #99 fix: field.id / field.fieldId are nil in FS25; our fieldId is farmland.id.
    -- g_fieldManager:getFieldById() searches by field.id which is always nil, so it
    -- returns the wrong field or nil depending on list position.
    -- Correct approach: iterate g_fieldManager.fields and match farmland.id.
    local cropName = nil
    if g_fieldManager and g_fieldManager.fields then
        local fsField = nil
        for _, f in ipairs(g_fieldManager.fields) do
            if f and f.farmland and f.farmland.id == fieldId then
                fsField = f
                break
            end
        end
        if fsField then
            -- Fix #123: Field:getFieldState() does NOT exist in FS25.
            -- FieldState is a standalone class; it must be instantiated and then
            -- populated by calling :update(worldX, worldZ) with a point inside the field.
            -- fsField.posX / posZ are the polygon centroid, set by Field:load() via
            -- MathUtil.getPolygonLabel(). They are always valid after field initialization.
            local centerX = fsField.posX
            local centerZ = fsField.posZ
            if centerX and centerZ then
                local ok, fieldState = pcall(function()
                    local fs = FieldState.new()
                    fs:update(centerX, centerZ)
                    return fs
                end)
                if ok and fieldState and fieldState.fruitTypeIndex ~= FruitType.UNKNOWN then
                    local fruitDesc = g_fruitTypeManager and
                        g_fruitTypeManager:getFruitTypeByIndex(fieldState.fruitTypeIndex)
                    if fruitDesc and fruitDesc.name then
                        cropName = fruitDesc.name
                    end
                end
            end
        end
    end
    -- Fall back to lastCrop when the field is fallow (no live fruit detected)
    if not cropName or cropName == "" then
        cropName = field.lastCrop
    end

    -- Compute crop rotation status for external consumers (e.g. FarmTablet)
    local rotationStatus = nil
    if SoilConstants.CROP_ROTATION and field.lastCrop and field.lastCrop2 then
        local cr      = SoilConstants.CROP_ROTATION
        local crop1   = string.lower(field.lastCrop)
        local crop2   = string.lower(field.lastCrop2)
        if cr.LEGUMES[crop1] and not cr.LEGUMES[crop2] then
            rotationStatus = "Bonus"
        elseif field.lastCrop == field.lastCrop2 then
            rotationStatus = "Fatigue"
        else
            rotationStatus = "OK"
        end
    end

    return {
        fieldId = fieldId,
        fieldArea = field.fieldArea or 1.0,
        nitrogen = { value = math.floor(field.nitrogen), status = nutrientStatus(field.nitrogen, "nitrogen") },
        phosphorus = { value = math.floor(field.phosphorus), status = nutrientStatus(field.phosphorus, "phosphorus") },
        potassium = { value = math.floor(field.potassium), status = nutrientStatus(field.potassium, "potassium") },
        organicMatter = field.organicMatter,
        pH = field.pH,
        lastCrop = cropName,
        lastCrop2 = field.lastCrop2,
        rotationStatus = rotationStatus,
        daysSinceHarvest = field.lastHarvest > 0 and (currentDay - field.lastHarvest) or 0,
        fertilizerApplied = field.fertilizerApplied or 0,
        weedPressure = field.weedPressure or 0,
        herbicideActive = (field.herbicideDaysLeft or 0) > 0,
        pestPressure = field.pestPressure or 0,
        insecticideActive = (field.insecticideDaysLeft or 0) > 0,
        diseasePressure = field.diseasePressure or 0,
        fungicideActive = (field.fungicideDaysLeft or 0) > 0,
        burnDaysLeft = field.burnDaysLeft or 0,
        nutrientBuffer   = field.nutrientBuffer or {},
        coverageFraction = field.coverageFraction or 0,
        compaction = field.compaction or 0,
        needsFertilization = (
            field.nitrogen < fertThresholds.nitrogen or
            field.phosphorus < fertThresholds.phosphorus or
            field.potassium < fertThresholds.potassium or
            field.pH < fertThresholds.pH
        )
    }
end

--- Calculate the urgency score (0-100) for a field
---@param fieldId number
---@return number
function SoilFertilitySystem:getFieldUrgency(fieldId)
    local info = self:getFieldInfo(fieldId)
    if not info then return 0 end

    local urgency = 0
    local thresh = SoilConstants.YIELD_SENSITIVITY and SoilConstants.YIELD_SENSITIVITY.OPTIMAL_THRESHOLD or 70
    
    local nDef = math.max(0, thresh - info.nitrogen.value) / thresh
    local pDef = math.max(0, thresh - info.phosphorus.value) / thresh
    local kDef = math.max(0, thresh - info.potassium.value) / thresh

    local phOpt = 6.5  -- optimal pH target (mid-point of neutral band 6.5-7.0)
    local phMin = SoilConstants.NUTRIENT_LIMITS and SoilConstants.NUTRIENT_LIMITS.PH_MIN or 5.0
    local phDef = math.max(0, phOpt - info.pH) / (phOpt - phMin)

    local weedDef = (info.weedPressure or 0) / 100
    local pestDef = (info.pestPressure or 0) / 100
    local diseaseDef = (info.diseasePressure or 0) / 100

    urgency = math.min(100, ((nDef + pDef + kDef + phDef + weedDef + pestDef + diseaseDef) / 7) * 100)
    return urgency
end

-- Get field count
function SoilFertilitySystem:getFieldCount()
    local count = 0
    for _ in pairs(self.fieldData) do
        count = count + 1
    end
    return count
end

-- Save to XML file
function SoilFertilitySystem:saveToXMLFile(xmlFile, key)
    if not xmlFile then return end

    -- SAFETY: ensure fieldData is valid
    if not self or type(self) ~= "table" then
        SoilLogger.error("saveToXMLFile called with invalid self")
        return
    end

    if not self.fieldData or type(self.fieldData) ~= "table" then
        SoilLogger.warning("Cannot save - fieldData invalid (type: %s)", type(self.fieldData))
        return
    end

    local defaults = SoilConstants.FIELD_DEFAULTS
    local index = 0

    for fieldId, field in pairs(self.fieldData) do
        if type(field) == "table" then
            local fieldKey = string.format("%s.field(%d)", key, index)

            setXMLInt(xmlFile, fieldKey .. "#id", fieldId)
            setXMLFloat(xmlFile, fieldKey .. "#fieldArea", field.fieldArea or 1.0)
            setXMLFloat(xmlFile, fieldKey .. "#nitrogen", field.nitrogen or defaults.nitrogen)
            setXMLFloat(xmlFile, fieldKey .. "#phosphorus", field.phosphorus or defaults.phosphorus)
            setXMLFloat(xmlFile, fieldKey .. "#potassium", field.potassium or defaults.potassium)
            setXMLFloat(xmlFile, fieldKey .. "#organicMatter", field.organicMatter or defaults.organicMatter)
            setXMLFloat(xmlFile, fieldKey .. "#pH", field.pH or defaults.pH)
            setXMLString(xmlFile, fieldKey .. "#lastCrop", field.lastCrop or "")
            setXMLString(xmlFile, fieldKey .. "#lastCrop2", field.lastCrop2 or "")
            setXMLString(xmlFile, fieldKey .. "#lastCrop3", field.lastCrop3 or "")
            setXMLInt(xmlFile, fieldKey .. "#rotationBonusDaysLeft", field.rotationBonusDaysLeft or 0)
            setXMLInt(xmlFile, fieldKey .. "#lastHarvest", field.lastHarvest or 0)
            setXMLFloat(xmlFile, fieldKey .. "#fertilizerApplied", field.fertilizerApplied or 0)
            setXMLFloat(xmlFile, fieldKey .. "#weedPressure", field.weedPressure or 0)
            setXMLInt(xmlFile, fieldKey .. "#herbicideDaysLeft", field.herbicideDaysLeft or 0)
            setXMLFloat(xmlFile, fieldKey .. "#pestPressure", field.pestPressure or 0)
            setXMLInt(xmlFile, fieldKey .. "#insecticideDaysLeft", field.insecticideDaysLeft or 0)
            setXMLFloat(xmlFile, fieldKey .. "#diseasePressure", field.diseasePressure or 0)
            setXMLInt(xmlFile, fieldKey .. "#fungicideDaysLeft", field.fungicideDaysLeft or 0)
            setXMLInt(xmlFile, fieldKey .. "#dryDayCount", field.dryDayCount or 0)
            setXMLInt(xmlFile, fieldKey .. "#burnDaysLeft", field.burnDaysLeft or 0)
            setXMLInt(xmlFile, fieldKey .. "#lastAlertYear", field.lastAlertYear or 0)
            setXMLFloat(xmlFile, fieldKey .. "#compaction", field.compaction or 0)

            -- Save per-cell compaction data
            local compIdx = 0
            if field.compactionCells then
                for cellKey, val in pairs(field.compactionCells) do
                    local ck = string.format("%s.compactionCell(%d)", fieldKey, compIdx)
                    setXMLString(xmlFile, ck .. "#key", cellKey)
                    setXMLFloat(xmlFile, ck .. "#v", val)
                    compIdx = compIdx + 1
                end
            end

            -- Save per-area zone cells for overlay coloring
            local zoneIdx = 0
            if field.zoneData then
                for cellKey, cell in pairs(field.zoneData) do
                    local zk = string.format("%s.zone(%d)", fieldKey, zoneIdx)
                    setXMLString(xmlFile, zk .. "#key", cellKey)
                    setXMLFloat(xmlFile, zk .. "#N",  cell.N  or 0)
                    setXMLFloat(xmlFile, zk .. "#P",  cell.P  or 0)
                    setXMLFloat(xmlFile, zk .. "#K",  cell.K  or 0)
                    setXMLFloat(xmlFile, zk .. "#pH", cell.pH or 6.0)
                    setXMLFloat(xmlFile, zk .. "#OM", cell.OM or 0)
                    zoneIdx = zoneIdx + 1
                end
            end

            index = index + 1
        else
            print(string.format(
                "[SoilFertilizer WARNING] Skipping corrupted field entry %s (type: %s)",
                tostring(fieldId),
                type(field)
            ))
        end
    end

    self:info("Saved data for %d fields", index)
end

-- Load from XML file
function SoilFertilitySystem:loadFromXMLFile(xmlFile, key)
    if not xmlFile then return end

    local defaults = SoilConstants.FIELD_DEFAULTS
    self.fieldData = {}
    local index = 0

    while true do
        local fieldKey = string.format("%s.field(%d)", key, index)
        local fieldId = getXMLInt(xmlFile, fieldKey .. "#id")

        if not fieldId then break end

        self.fieldData[fieldId] = {
            fieldArea = getXMLFloat(xmlFile, fieldKey .. "#fieldArea") or 1.0,
            nitrogen = getXMLFloat(xmlFile, fieldKey .. "#nitrogen") or defaults.nitrogen,
            phosphorus = getXMLFloat(xmlFile, fieldKey .. "#phosphorus") or defaults.phosphorus,
            potassium = getXMLFloat(xmlFile, fieldKey .. "#potassium") or defaults.potassium,
            organicMatter = getXMLFloat(xmlFile, fieldKey .. "#organicMatter") or defaults.organicMatter,
            pH = getXMLFloat(xmlFile, fieldKey .. "#pH") or defaults.pH,
            lastCrop = getXMLString(xmlFile, fieldKey .. "#lastCrop"),
            lastCrop2 = getXMLString(xmlFile, fieldKey .. "#lastCrop2"),
            lastCrop3 = getXMLString(xmlFile, fieldKey .. "#lastCrop3"),
            rotationBonusDaysLeft = getXMLInt(xmlFile, fieldKey .. "#rotationBonusDaysLeft") or 0,
            lastHarvest = getXMLInt(xmlFile, fieldKey .. "#lastHarvest") or 0,
            fertilizerApplied = getXMLFloat(xmlFile, fieldKey .. "#fertilizerApplied") or 0,
            weedPressure = getXMLFloat(xmlFile, fieldKey .. "#weedPressure") or 0,
            herbicideDaysLeft = getXMLInt(xmlFile, fieldKey .. "#herbicideDaysLeft") or 0,
            pestPressure = getXMLFloat(xmlFile, fieldKey .. "#pestPressure") or 0,
            insecticideDaysLeft = getXMLInt(xmlFile, fieldKey .. "#insecticideDaysLeft") or 0,
            diseasePressure = getXMLFloat(xmlFile, fieldKey .. "#diseasePressure") or 0,
            fungicideDaysLeft = getXMLInt(xmlFile, fieldKey .. "#fungicideDaysLeft") or 0,
            dryDayCount = getXMLInt(xmlFile, fieldKey .. "#dryDayCount") or 0,
            burnDaysLeft = getXMLInt(xmlFile, fieldKey .. "#burnDaysLeft") or 0,
            lastAlertYear = getXMLInt(xmlFile, fieldKey .. "#lastAlertYear") or 0,
            compaction = 0,
            compactionCells = {},
            compactionCellDays = {},
            compactionSum = 0,
            compactionTotalCells = 0,
            initialized = true,
            nutrientBuffer = {},
            zoneData = {},
        }

        -- Clear empty strings
        if self.fieldData[fieldId].lastCrop == "" then
            self.fieldData[fieldId].lastCrop = nil
        end
        if self.fieldData[fieldId].lastCrop2 == "" then
            self.fieldData[fieldId].lastCrop2 = nil
        end
        if self.fieldData[fieldId].lastCrop3 == "" then
            self.fieldData[fieldId].lastCrop3 = nil
        end

        -- Load per-area zone cells
        local zi = 0
        while true do
            local zk = string.format("%s.zone(%d)", fieldKey, zi)
            local cellKey = getXMLString(xmlFile, zk .. "#key")
            if not cellKey then break end
            self.fieldData[fieldId].zoneData[cellKey] = {
                N  = getXMLFloat(xmlFile, zk .. "#N")  or 0,
                P  = getXMLFloat(xmlFile, zk .. "#P")  or 0,
                K  = getXMLFloat(xmlFile, zk .. "#K")  or 0,
                pH = getXMLFloat(xmlFile, zk .. "#pH") or 6.0,
                OM = getXMLFloat(xmlFile, zk .. "#OM") or 0,
            }
            zi = zi + 1
        end

        -- Load per-cell compaction data and reconstruct running sum + average
        local zone = SoilConstants.ZONE
        local ci = 0
        local sumLoaded = 0
        while true do
            local ck = string.format("%s.compactionCell(%d)", fieldKey, ci)
            local cellKey = getXMLString(xmlFile, ck .. "#key")
            if not cellKey then break end
            local val = getXMLFloat(xmlFile, ck .. "#v") or 0
            if val > 0 then
                self.fieldData[fieldId].compactionCells[cellKey] = val
                sumLoaded = sumLoaded + val
            end
            ci = ci + 1
        end
        if ci > 0 then
            local areaInHa = self.fieldData[fieldId].fieldArea or 1.0
            local totalCells = math.max(1, math.ceil(areaInHa / zone.CELL_AREA_HA))
            self.fieldData[fieldId].compactionSum = sumLoaded
            self.fieldData[fieldId].compactionTotalCells = totalCells
            self.fieldData[fieldId].compaction = sumLoaded / totalCells
        end

        index = index + 1
    end

    self:info("Loaded data for %d fields", index)

    -- Push loaded values to density map layers so visual heatmap reflects saved state.
    -- This runs after loadFromXMLFile, so layerSystem may not be initialized yet
    -- (it initializes in SoilFertilitySystem:initialize which fires before loadSoilData).
    -- Guard: only run if the layer system is available.
    if self.layerSystem and self.layerSystem.available and g_farmlandManager then
        local pushed = 0
        for fieldId, data in pairs(self.fieldData) do
            local farmland = g_farmlandManager:getFarmlandById(fieldId)
            if farmland then
                self.layerSystem:writeFieldToLayers(fieldId, data, farmland)
                pushed = pushed + 1
            end
        end
        if pushed > 0 then
            self:info("Pushed %d fields to density map layers after load", pushed)
        end
    end

    -- Re-broadcast after load so clients that were connected during a
    -- save/load cycle get up-to-date values immediately.
    self:broadcastAllFieldData()
end

-- Debug: List all fields
function SoilFertilitySystem:listAllFields()
    SoilLogger.info("=== Listing all fields ===")

    SoilLogger.info("Our tracked fields:")
    for fieldId, field in pairs(self.fieldData) do
        SoilLogger.info("  Field %d: N=%.1f, P=%.1f, K=%.1f, pH=%.1f, OM=%.2f%%",
            fieldId, field.nitrogen, field.phosphorus, field.potassium, field.pH, field.organicMatter)
    end

    if g_fieldManager and g_fieldManager.fields then
        SoilLogger.info("Fields in FieldManager:")
        for _, field in ipairs(g_fieldManager.fields) do
            -- NOTE: field.fieldId / field.id / field.index are all nil in FS25.
            -- The correct identifier is field.farmland.id (farmland-based ID system).
            local fieldIdStr = tostring(field.farmland and field.farmland.id or "?")
            local nameStr    = tostring(field.name or "Unknown")
            SoilLogger.info("  Field %s: Name=%s", fieldIdStr, nameStr)
        end
    end

    SoilLogger.info("=== End field list ===")
end

-- =========================================================
-- COMPACTION API (P2-D)
-- =========================================================

--- Apply compaction from a heavy vehicle work pass at a specific world position.
--- Throttled to once per cell per in-game day. Field-average is maintained as a
--- running sum over estimated total field cells so the nutrient penalty stays correct.
---@param farmlandId number
---@param worldX number  world X of the implement's work area centre
---@param worldZ number  world Z of the implement's work area centre
function SoilFertilitySystem:onCompaction(farmlandId, worldX, worldZ)
    if not self.settings.compactionEnabled then return end
    local cp = SoilConstants.COMPACTION
    if not cp then return end
    local field = self:getOrCreateField(farmlandId, false)
    if not field then return end

    local zone = SoilConstants.ZONE
    local cx = math.floor(worldX / zone.CELL_SIZE)
    local cz = math.floor(worldZ / zone.CELL_SIZE)
    local cellKey = cx .. "_" .. cz

    local currentDay = (g_currentMission and g_currentMission.environment and
                        g_currentMission.environment.currentDay) or 0

    if not field.compactionCells    then field.compactionCells    = {} end
    if not field.compactionCellDays then field.compactionCellDays = {} end

    if field.compactionCellDays[cellKey] == currentDay then return end
    field.compactionCellDays[cellKey] = currentDay

    local prev   = field.compactionCells[cellKey] or 0
    local newVal = math.min(cp.MAX_COMPACTION, prev + cp.COMPACTION_PER_PASS)
    field.compactionCells[cellKey] = newVal

    field.compactionSum = (field.compactionSum or 0) + (newVal - prev)
    if (field.compactionTotalCells or 0) == 0 then
        local areaInHa = field.fieldArea or 1.0
        field.compactionTotalCells = math.max(1, math.ceil(areaInHa / zone.CELL_AREA_HA))
    end
    field.compaction = field.compactionSum / field.compactionTotalCells

    SoilLogger.debug("Compaction: field=%d cell=%s  %.0f→%.0f%%  avg=%.1f%%",
        farmlandId, cellKey, prev, newVal, field.compaction)
end

--- Apply subsoiler compaction reduction at a specific world position.
---@param farmlandId number
---@param worldX number
---@param worldZ number
function SoilFertilitySystem:onSubsoilerPass(farmlandId, worldX, worldZ)
    if not self.settings.compactionEnabled then return end
    local cp = SoilConstants.COMPACTION
    if not cp then return end
    local field = self:getOrCreateField(farmlandId, false)
    if not field then return end

    if not field.compactionCells then field.compactionCells = {} end

    local zone = SoilConstants.ZONE
    local cx   = math.floor(worldX / zone.CELL_SIZE)
    local cz   = math.floor(worldZ / zone.CELL_SIZE)
    local cellKey = cx .. "_" .. cz

    local prev = field.compactionCells[cellKey] or 0
    if prev <= 0 then return end

    local newVal = math.max(0, prev - cp.SUBSOILER_REDUCTION)
    if newVal > 0 then
        field.compactionCells[cellKey] = newVal
    else
        field.compactionCells[cellKey] = nil
    end

    field.compactionSum = math.max(0, (field.compactionSum or 0) - (prev - newVal))
    local tc = field.compactionTotalCells or 0
    field.compaction = tc > 0 and (field.compactionSum / tc) or 0

    SoilLogger.debug("Subsoiler: field=%d cell=%s  %.0f→%.0f%%  avg=%.1f%%",
        farmlandId, cellKey, prev, newVal, field.compaction)
end