-- Sprayer integration: when the player drives a fertilizer / liquid-
-- fertilizer / manure / slurry spreader over a registered pasture, fire
-- the same boost as `phFertilize` would.
--
-- Hooks Sprayer.processSprayerArea (FS25 spec_sprayer), which fires each
-- tick a sprayer actually deposits product onto the ground. We grab the
-- vehicle's world position, look up which pasture contains it, and call
-- GrazingRegistry:applyFertilizer with a per-pasture cooldown so a slow
-- pass doesn't stack the boost to infinity.
--
-- Triggers on FillType names: FERTILIZER, LIQUIDFERTILIZER, MANURE,
-- LIQUIDMANURE. All four are NPK inputs in real-world pasture management.

SprayerHook = {}

if PastureHerdingLog and PastureHerdingLog.info then
    PastureHerdingLog.dev("SprayerHook.lua sourced")
end

SprayerHook.NPK_FILLTYPE_NAMES = {
    FERTILIZER        = true,
    LIQUIDFERTILIZER  = true,
    MANURE            = true,
    LIQUIDMANURE      = true,
}

-- Lime fill types. Precision Farming only limes cultivated FIELDS, so lime spread
-- on a husbandry pasture never changes PF's pH map. We detect it here instead and
-- raise the pasture's own pH correction (see accumulateLime + GrazingRegistry pH).
SprayerHook.LIME_FILLTYPE_NAMES = {
    LIME = true,
}

-- Raise a pasture's lime (pH) correction while a lime spreader is active over it.
-- Time-based: ~limeSecondsToFull seconds of active liming over the pasture fills it
-- to 1.0 (full pH correction), with a completion notification.
function SprayerHook.accumulateLime(mod, bIdx, pIdx, dt)
    local reg = mod and mod.grazingRegistry
    if reg == nil or type(reg.getLimeCorrection) ~= "function" then return end
    local cur = reg:getLimeCorrection(bIdx, pIdx) or 0
    if cur >= 1.0 then return end
    -- Both the processSprayerArea hook and the fallback poll can call this in the
    -- same frame; accumulate off ELAPSED game time per pasture (not summed dt) so a
    -- second call in the same tick adds nothing and liming fills at the intended rate.
    SprayerHook._limeLastTime = SprayerHook._limeLastTime or {}
    local key = bIdx .. ":" .. pIdx
    local now = g_time or 0
    local last = SprayerHook._limeLastTime[key]
    SprayerHook._limeLastTime[key] = now
    local elapsed = (last ~= nil) and (now - last) or (dt or 0)
    if elapsed <= 0 then return end                 -- same-frame re-entry: no time passed
    if elapsed > 2000 then elapsed = dt or 0 end    -- resumed after a gap: use one frame
    local fullSec = mod.limeSecondsToFull or 25
    if fullSec < 1 then fullSec = 1 end
    local new = math.min(1.0, cur + (elapsed / 1000) / fullSec)
    reg:setLimeCorrection(bIdx, pIdx, new)
    if new >= 1.0 and cur < 1.0 and type(mod.pushNotification) == "function" then
        pcall(function()
            mod:pushNotification({ 0.6, 0.8, 1.0, 1.0 },
                string.format("Pasture #%d limed — soil pH corrected toward optimal.", pIdx), 6000)
        end)
    end
end

-- Coverage tuning. We accumulate "fraction of pasture area covered" as the
-- player drives a turned-on spreader carrying an NPK fill across a
-- registered pasture. When the gauge crosses the threshold, the boost
-- fires and the gauge resets — exactly like real fertilizing where a half-
-- assed pass doesn't count.
SprayerHook.COVERAGE_THRESHOLD       = 0.75                  -- fraction (0..1) of pasture polygon
SprayerHook.COVERAGE_DECAY_PER_HOUR  = 0.10                  -- per in-game hour while idle, applied as cells-cleared
SprayerHook.MIN_VEHICLE_SPEED_MS     = 0.1                   -- m/s — clamp parked spreaders so they still trickle progress
SprayerHook.CELL_SIZE_M              = 10                    -- grid cell size in metres
SprayerHook.MILESTONE_STEPS          = { 0.25, 0.50, 0.75 }  -- notification trigger points

-- Drain rate by fill type, in litres per square metre of new ground covered.
-- Rates picked from typical real-world agronomy application rates so that:
--   - A 1200 L liquid-fertilizer tank covers ~2.4 ha before refill
--   - A 30 m3 slurry tanker covers ~1.0 ha before refill
--   - A solid manure spreader of similar capacity covers ~1.5 ha
-- Per 100 m2 cell (10 m × 10 m grid), these convert to:
--   FERTILIZER       0.05 L/m2 × 100 m2 =   5 L per cell
--   LIQUIDFERTILIZER 0.05 L/m2 × 100 m2 =   5 L per cell
--   MANURE           2.0  L/m2 × 100 m2 = 200 L per cell
--   LIQUIDMANURE     3.0  L/m2 × 100 m2 = 300 L per cell
SprayerHook.LITERS_PER_SQM_BY_FILL = {
    FERTILIZER        = 0.05,
    LIQUIDFERTILIZER  = 0.05,
    MANURE            = 2.00,
    LIQUIDMANURE      = 3.00,
}
SprayerHook.DEFAULT_LITERS_PER_SQM   = 0.10                  -- fallback for unknown NPK fills

-- Per-pasture coverage grid, keyed "barnIdx:pIdx". Each entry is a grid
-- table built lazily on first touch:
--   { cellSize, minX, minZ, maxX, maxZ, cellsX, cellsZ,
--     inside  = {...},   -- inside[idx]  = true if cell idx is inside the polygon (precomputed once)
--     covered = {...},   -- covered[idx] = true if cell idx has been sprayed
--     insideCount, coveredCount,
--     lastMilestoneIdx = 0 }   -- highest MILESTONE_STEPS index already announced
SprayerHook._grids = {}
-- Game-time hour of last decay tick, for idle decay.
SprayerHook._lastDecayHour = nil

-- Test (x, z) against a flat 3D contour {x, y, z, x, y, z, ...} as returned
-- by ContourReader.readOutdoorContourPolygon. We use the axis-aligned
-- bounding box rather than a strict point-in-polygon: pasture contours in
-- FS25 are sometimes concave 18+ vertex shapes, and the polygon ray-cast
-- mis-classifies points the player would clearly call "over the pasture".
-- For sprayer detection, "vehicle within the pasture's bbox" is the right
-- semantic — overlapping bboxes on adjacent pastures are rare enough not
-- to matter, and if it ever happens, both pastures sharing a boost is the
-- realistic outcome.
local function pointInPolygon2D(x, z, contour)
    if contour == nil then return false end
    local n = #contour
    if n < 9 or (n % 3) ~= 0 then return false end
    local minX, maxX, minZ, maxZ = math.huge, -math.huge, math.huge, -math.huge
    for i = 1, n, 3 do
        local px, pz = contour[i], contour[i + 2]
        if px and pz then
            if px < minX then minX = px end
            if px > maxX then maxX = px end
            if pz < minZ then minZ = pz end
            if pz > maxZ then maxZ = pz end
        end
    end
    return x >= minX and x <= maxX and z >= minZ and z <= maxZ
end

-- Strict point-in-polygon (crossing-number) against a flat {x,y,z,...}
-- contour. Used by findPastureContaining to disambiguate overlapping
-- pasture bboxes, and by the grid builder to decide which cells are
-- inside the polygon. Must be defined BEFORE findPastureContaining so the
-- closure resolves it as a local rather than a (nil) global.
local function strictPointInPolygon(x, z, contour)
    if contour == nil then return false end
    local n = #contour
    if n < 9 or (n % 3) ~= 0 then return false end
    local numVerts = n / 3
    local inside = false
    local jIdx = numVerts
    for iIdx = 1, numVerts do
        local pix = contour[(iIdx - 1) * 3 + 1]
        local piz = contour[(iIdx - 1) * 3 + 3]
        local pjx = contour[(jIdx - 1) * 3 + 1]
        local pjz = contour[(jIdx - 1) * 3 + 3]
        if pix and piz and pjx and pjz then
            if ((piz > z) ~= (pjz > z))
                and (x < (pjx - pix) * (z - piz) / ((pjz - piz) + 1e-9) + pix) then
                inside = not inside
            end
        end
        jIdx = iIdx
    end
    return inside
end

-- Cache each pasture's outdoor contour polygon (the fence source, static per
-- placeable) so the containment check doesn't re-read + re-allocate it every
-- frame while a sprayer is active — that was a per-frame engine-call storm
-- scaling with pasture count. Weak keys let removed pastures GC; a short TTL
-- picks up the rare case of a player editing a fence.
SprayerHook._contourCache = SprayerHook._contourCache or setmetatable({}, { __mode = "k" })
local CONTOUR_CACHE_TTL_MS = 5000
local function cachedContour(placeable)
    if placeable == nil then return nil end
    local now = (g_currentMission and g_currentMission.time) or 0
    local e = SprayerHook._contourCache[placeable]
    if e == nil or (now - e.t) > CONTOUR_CACHE_TTL_MS then
        e = { c = ContourReader.readOutdoorContourPolygon(placeable) or false, t = now }
        SprayerHook._contourCache[placeable] = e
    end
    return e.c or nil
end

local function findPastureContaining(registry, x, z)
    if registry == nil or registry.barns == nil then return nil, nil end
    -- Pass 1: strict point-in-polygon. Disambiguates overlapping bboxes —
    -- if pasture A and pasture B share a bbox overlap zone but the vehicle
    -- is actually inside B's polygon, pass 1 returns B regardless of
    -- registry iteration order.
    for bIdx, barn in ipairs(registry.barns) do
        if barn and barn.pastures then
            for pIdx, p in ipairs(barn.pastures) do
                if p ~= nil then
                    local contour = cachedContour(p)
                    if contour ~= nil and strictPointInPolygon(x, z, contour) then
                        return bIdx, pIdx
                    end
                end
            end
        end
    end
    -- Pass 2: fallback to bbox. Some pasture contours in FS25 are stored
    -- as line-strip / non-closed polygons where the strict ray-cast test
    -- mis-classifies legitimate interior points. Bbox is the lenient
    -- fallback so the boost still triggers when the player is clearly
    -- standing in the placeable, even if the strict test fails.
    for bIdx, barn in ipairs(registry.barns) do
        if barn and barn.pastures then
            for pIdx, p in ipairs(barn.pastures) do
                if p ~= nil then
                    local contour = cachedContour(p)
                    if contour ~= nil and pointInPolygon2D(x, z, contour) then
                        return bIdx, pIdx
                    end
                end
            end
        end
    end
    return nil, nil
end

-- ---------------------------------------------------------------------------
-- Fertilizer VISUAL: darken the pasture ground where the player sprays, the
-- way a real field darkens when fertilized. Established empirically (by diffing
-- a grass field before/after fertilizing): the render needs BOTH the
-- field-ground-type flag (ch 0-3) AND the sprayType bit (ch 9-11) set on the
-- terrain-detail map -- setting spray alone renders nothing on non-field meadow
-- ground. We paint the actual implement footprint
-- as the player drives (deduped per cell so we don't re-write every tick) and
-- clear the whole pasture when it's next grazed.
-- ---------------------------------------------------------------------------
SprayerHook.FERT_FIELD_VALUE   = 14  -- field-ground-type that makes spray render (matches a grass field)
SprayerHook.FERT_SPRAY_VALUE   = 1   -- sprayType == fertilizer (proven by the probe diff)
SprayerHook.FERT_PAINT_STEP_M  = 1   -- footprint paint resolution (m) — small so it hugs the spread
SprayerHook.FERT_CLEAR_STEP_M  = 4   -- coarser step for the whole-pasture clear fallback

-- Resolve + cache the terrain-detail map id and channel layout (once).
function SprayerHook.getFertMapInfo()
    local info = SprayerHook._fertMapInfo
    if info ~= nil then return info end
    local mapId = g_currentMission and g_currentMission.terrainDetailId
    if type(mapId) ~= "number" then return nil end
    local fgs = g_currentMission and g_currentMission.fieldGroundSystem
    info = {
        mapId = mapId,
        gcF = (fgs and fgs.groundTypesFirstChannel) or 0,
        gcN = (fgs and fgs.groundTypesNumChannels) or 4,
        scF = (fgs and fgs.sprayTypesFirstChannel) or 9,
        scN = (fgs and fgs.sprayTypesNumChannels) or 3,
    }
    SprayerHook._fertMapInfo = info
    return info
end

-- Write `value` into [firstCh, firstCh+numCh) of the terrain-detail map over
-- an axis-aligned cell (GIANTS 3-corner convention). Best-effort, pcall'd.
local function fertWrite(info, firstCh, numCh, minX, minZ, maxX, maxZ, value)
    if type(DensityMapModifier) ~= "table" or type(DensityMapModifier.new) ~= "function" then return end
    local coordType = (type(DensityCoordType) == "table" and DensityCoordType.POINT_POINT_POINT) or 0
    pcall(function()
        local m = DensityMapModifier.new(info.mapId, firstCh, numCh)
        m:setParallelogramWorldCoords(minX, minZ, maxX, minZ, minX, maxZ, coordType)
        m:executeSet(value)
    end)
end

-- Return the three world corners (start, +width, +height) of the vehicle's
-- SPRAYER work area -- i.e. the exact parallelogram the game applies product
-- to, located at the implement's spray output and only as wide as the spread.
-- Falls back to the first work area that has the three nodes. nil if none.
local function sprayerWorkAreaCorners(vehicle)
    local swa = vehicle and vehicle.spec_workArea
    if swa == nil or type(swa.workAreas) ~= "table" then return nil end
    local sprayType = (type(WorkAreaType) == "table" and WorkAreaType.SPRAYER) or nil
    local chosen = nil
    for _, wa in ipairs(swa.workAreas) do
        if wa.start ~= nil and wa.width ~= nil and wa.height ~= nil then
            if sprayType ~= nil and wa.type == sprayType then chosen = wa; break end
            if chosen == nil then chosen = wa end
        end
    end
    if chosen == nil then return nil end
    local sx, _, sz = getWorldTranslation(chosen.start)
    local wx, _, wz = getWorldTranslation(chosen.width)
    local hx, _, hz = getWorldTranslation(chosen.height)
    if sx == nil or wx == nil or hx == nil then return nil end
    return sx, sz, wx, wz, hx, hz
end

-- Paint the fertilized look over the sprayer's actual application parallelogram
-- (start + u*widthEdge + v*heightEdge), sampled at FERT_PAINT_STEP_M and clipped
-- to the pasture polygon. Dedups per cell via placeable._phFertPaintedCells so
-- re-driving the same ground is a no-op and we never storm the density map.
-- Resolve the terrain sprayType value for LIME the same way the base game does
-- (FieldManager: FieldSprayType.getValueByType(FieldSprayType.LIME)). Cached.
-- Falls back to the fertilizer value if the enum isn't available.
function SprayerHook.getLimeSprayValue()
    if SprayerHook._limeSprayValue ~= nil then return SprayerHook._limeSprayValue end
    local v = SprayerHook.FERT_SPRAY_VALUE
    if type(FieldSprayType) == "table" and type(FieldSprayType.getValueByType) == "function"
       and FieldSprayType.LIME ~= nil then
        local ok, lv = pcall(function() return FieldSprayType.getValueByType(FieldSprayType.LIME) end)
        if ok and type(lv) == "number" then v = lv end
    end
    SprayerHook._limeSprayValue = v
    return v
end

-- sprayValue selects which sprayType look to paint (fertilizer by default; pass the
-- lime value for the lime dusting). Ground-type 14 is still written so the spray
-- renders on non-field meadow ground.
function SprayerHook.paintFertParallelogram(placeable, sx, sz, wx, wz, hx, hz, sprayValue)
    local info = SprayerHook.getFertMapInfo()
    if info == nil or placeable == nil then return end
    sprayValue = sprayValue or SprayerHook.FERT_SPRAY_VALUE
    local contour = cachedContour(placeable)
    if contour == nil then return end
    local step = SprayerHook.FERT_PAINT_STEP_M
    local wxv, wzv = wx - sx, wz - sz   -- width edge (implement span)
    local hxv, hzv = hx - sx, hz - sz   -- height edge (travel sweep this tick)
    local nu = math.max(1, math.ceil(math.sqrt(wxv * wxv + wzv * wzv) / step))
    local nv = math.max(1, math.ceil(math.sqrt(hxv * hxv + hzv * hzv) / step))
    placeable._phFertPaintedCells = placeable._phFertPaintedCells or {}
    local cells = placeable._phFertPaintedCells
    local h = step * 0.5
    for iu = 0, nu do
        for iv = 0, nv do
            local u, v = iu / nu, iv / nv
            local px = sx + u * wxv + v * hxv
            local pz = sz + u * wzv + v * hzv
            local key = math.floor(px / step) .. ":" .. math.floor(pz / step)
            if cells[key] == nil and strictPointInPolygon(px, pz, contour) then
                fertWrite(info, info.gcF, info.gcN, px - h, pz - h, px + h, pz + h, SprayerHook.FERT_FIELD_VALUE)
                fertWrite(info, info.scF, info.scN, px - h, pz - h, px + h, pz + h, sprayValue)
                cells[key] = true  -- dedup only; clear sweeps the whole polygon
            end
        end
    end
end

-- Clear the fertilized look by sweeping the whole pasture polygon back to 0 at
-- a coarse step (zeroing already-zero ground is harmless, so precision isn't
-- needed and this stays cheap + bounded even if the player fertilized a lot).
-- Polygon-clipped so it never touches ground outside the pasture, and works
-- after a reload where the runtime cell set is gone.
function SprayerHook.clearFertVisual(placeable)
    local info = SprayerHook.getFertMapInfo()
    if info == nil or placeable == nil then return end
    local contour = cachedContour(placeable)
    if contour == nil then return end
    local minX, maxX, minZ, maxZ = math.huge, -math.huge, math.huge, -math.huge
    for i = 1, #contour, 3 do
        local px, pz = contour[i], contour[i + 2]
        if px and pz then
            if px < minX then minX = px end
            if px > maxX then maxX = px end
            if pz < minZ then minZ = pz end
            if pz > maxZ then maxZ = pz end
        end
    end
    if maxX <= minX or maxZ <= minZ then return end
    local step = SprayerHook.FERT_CLEAR_STEP_M
    local cz = minZ
    while cz < maxZ do
        local cx = minX
        while cx < maxX do
            if strictPointInPolygon(cx + step * 0.5, cz + step * 0.5, contour) then
                fertWrite(info, info.gcF, info.gcN, cx, cz, cx + step, cz + step, 0)
                fertWrite(info, info.scF, info.scN, cx, cz, cx + step, cz + step, 0)
            end
            cx = cx + step
        end
        cz = cz + step
    end
    placeable._phFertPaintedCells = nil
end

local function getCurrentSprayFillTypeName(vehicle)
    local spec = vehicle and vehicle.spec_sprayer
    if spec == nil then return nil end
    local wp = spec.workAreaParameters
    local ftIdx = wp and wp.sprayFillType
    if ftIdx == nil or ftIdx == FillType.UNKNOWN then return nil end
    if g_fillTypeManager == nil then return nil end
    local ft = g_fillTypeManager:getFillTypeByIndex(ftIdx)
    return ft and ft.name
end

-- Compute the area (m2) of a pasture placeable. Prefer the cached
-- outdoorContourPolygon.area; fall back to bbox area from our contour.
local function getPastureArea(placeable)
    local spec = placeable and placeable.spec_husbandryAnimals
    local poly = spec and spec.outdoorContourPolygon
    if type(poly) == "table" and type(poly.area) == "number" and poly.area > 0 then
        return poly.area
    end
    local contour = cachedContour(placeable)
    if contour == nil then return nil end
    local n = #contour
    local minX, maxX, minZ, maxZ = math.huge, -math.huge, math.huge, -math.huge
    for i = 1, n, 3 do
        local px, pz = contour[i], contour[i + 2]
        if px and pz then
            if px < minX then minX = px end
            if px > maxX then maxX = px end
            if pz < minZ then minZ = pz end
            if pz > maxZ then maxZ = pz end
        end
    end
    if maxX <= minX or maxZ <= minZ then return nil end
    return (maxX - minX) * (maxZ - minZ)
end

-- Build a fresh coverage grid for the given pasture placeable. Walks the
-- contour bbox in CELL_SIZE_M increments, runs strict PIP on each cell
-- centre, and records which cells are inside. Runs once per pasture per
-- session — cached on SprayerHook._grids.
local function buildGrid(placeable)
    local contour = cachedContour(placeable)
    if contour == nil then return nil end
    local n = #contour
    local minX, maxX, minZ, maxZ = math.huge, -math.huge, math.huge, -math.huge
    for i = 1, n, 3 do
        local px, pz = contour[i], contour[i + 2]
        if px and pz then
            if px < minX then minX = px end
            if px > maxX then maxX = px end
            if pz < minZ then minZ = pz end
            if pz > maxZ then maxZ = pz end
        end
    end
    if maxX <= minX or maxZ <= minZ then return nil end

    local cellSize = SprayerHook.CELL_SIZE_M
    local cellsX = math.ceil((maxX - minX) / cellSize)
    local cellsZ = math.ceil((maxZ - minZ) / cellSize)
    local grid = {
        cellSize = cellSize,
        minX = minX, minZ = minZ, maxX = maxX, maxZ = maxZ,
        cellsX = cellsX, cellsZ = cellsZ,
        inside = {}, covered = {},
        insideCount = 0, coveredCount = 0,
        lastMilestoneIdx = 0,
    }
    for cz = 0, cellsZ - 1 do
        for cx = 0, cellsX - 1 do
            local wx = minX + (cx + 0.5) * cellSize
            local wz = minZ + (cz + 0.5) * cellSize
            if strictPointInPolygon(wx, wz, contour) then
                local idx = cz * cellsX + cx
                grid.inside[idx] = true
                grid.insideCount = grid.insideCount + 1
            end
        end
    end
    return grid
end

-- Persisted mid-pass coverage waiting to be applied to a grid once it's built.
-- Keyed by placeable (the grid's cell indices re-derive deterministically from
-- the same pasture geometry, so we only need to restore WHICH cells were covered).
SprayerHook._pendingCoverage = SprayerHook._pendingCoverage or {}

local function getOrBuildGrid(barnIdx, pIdx, placeable)
    local key = barnIdx .. ":" .. pIdx
    local grid = SprayerHook._grids[key]
    if grid == nil and placeable ~= nil then
        grid = buildGrid(placeable)
        if grid ~= nil then
            SprayerHook._grids[key] = grid
            PastureHerdingLog.dev(
                "SprayerHook: built coverage grid for barn#%d pasture#%d (%dx%d cells, %d inside polygon)",
                barnIdx, pIdx, grid.cellsX, grid.cellsZ, grid.insideCount)
            -- Seed from a persisted mid-pass if the pasture geometry still matches
            -- (same cell grid + inside-count → same cell indices).
            local pend = SprayerHook._pendingCoverage[placeable]
            if pend ~= nil then
                SprayerHook._pendingCoverage[placeable] = nil
                if pend.cellsX == grid.cellsX and pend.cellsZ == grid.cellsZ
                   and pend.insideCount == grid.insideCount then
                    local cc = 0
                    for idx in pairs(pend.covered) do
                        if grid.inside[idx] then grid.covered[idx] = true; cc = cc + 1 end
                    end
                    grid.coveredCount = cc
                    PastureHerdingLog.dev(
                        "SprayerHook: restored %d covered cell(s) for barn#%d pasture#%d from save",
                        cc, barnIdx, pIdx)
                else
                    PastureHerdingLog.dev(
                        "SprayerHook: skipped coverage restore for barn#%d pasture#%d — geometry changed",
                        barnIdx, pIdx)
                end
            end
        end
    end
    return grid
end

-- Stamp every grid cell whose centre is within `radius` metres of (vx, vz)
-- as covered, but only if the cell is also inside the polygon. Returns the
-- number of NEW cells covered this stamp (for milestone tracking).
local function stampCells(grid, vx, vz, radius)
    if grid == nil then return 0 end
    local cellSize = grid.cellSize
    local r2 = radius * radius
    -- Bounding range of cells we might touch
    local cxMin = math.max(0, math.floor((vx - radius - grid.minX) / cellSize))
    local cxMax = math.min(grid.cellsX - 1, math.floor((vx + radius - grid.minX) / cellSize))
    local czMin = math.max(0, math.floor((vz - radius - grid.minZ) / cellSize))
    local czMax = math.min(grid.cellsZ - 1, math.floor((vz + radius - grid.minZ) / cellSize))
    local newCells = 0
    for cz = czMin, czMax do
        for cx = cxMin, cxMax do
            local idx = cz * grid.cellsX + cx
            if grid.inside[idx] and not grid.covered[idx] then
                local cx_world = grid.minX + (cx + 0.5) * cellSize
                local cz_world = grid.minZ + (cz + 0.5) * cellSize
                local dx = cx_world - vx
                local dz = cz_world - vz
                if dx * dx + dz * dz <= r2 then
                    grid.covered[idx] = true
                    grid.coveredCount = grid.coveredCount + 1
                    newCells = newCells + 1
                end
            end
        end
    end
    return newCells
end

-- Per-tick: drain tank by usagePerMin*dt, accumulate coverage, fire boost
-- when coverage crosses the threshold. dt is in ms.
function SprayerHook.onActiveOverPasture(vehicle, dt)
    if vehicle == nil or vehicle.rootNode == nil then return end
    local mod = _G.g_pastureHerding
    if mod == nil or mod.grazingRegistry == nil then return end
    local fillName = getCurrentSprayFillTypeName(vehicle)
    local isLime = fillName ~= nil and SprayerHook.LIME_FILLTYPE_NAMES[fillName]
    local isNPK  = fillName ~= nil and SprayerHook.NPK_FILLTYPE_NAMES[fillName]
    if not isLime and not isNPK then return end

    local x, _, z = getWorldTranslation(vehicle.rootNode)
    if x == nil then return end

    local bIdx, pIdx = findPastureContaining(mod.grazingRegistry, x, z)
    if bIdx == nil then return end

    -- LIME: PF won't lime husbandry (non-field) pasture ground, so instead of the
    -- fertilizer path we raise this pasture's own pH correction, paint the lime
    -- look, and stop here.
    if isLime then
        local barn = mod.grazingRegistry.barns[bIdx]
        local placeable = barn and barn.pastures and barn.pastures[pIdx]
        if placeable ~= nil then
            local sx, sz, wx, wz, hx, hz = sprayerWorkAreaCorners(vehicle)
            if sx ~= nil then
                SprayerHook.paintFertParallelogram(placeable, sx, sz, wx, wz, hx, hz,
                    SprayerHook.getLimeSprayValue())
                if type(mod.grazingRegistry.setFertPainted) == "function" then
                    mod.grazingRegistry:setFertPainted(bIdx, pIdx, true)  -- cleared on next graze
                end
            end
        end
        SprayerHook.accumulateLime(mod, bIdx, pIdx, dt)
        return
    end

    local barn = mod.grazingRegistry.barns[bIdx]
    local placeable = barn and barn.pastures and barn.pastures[pIdx]
    if placeable == nil then return end
    local pastureArea = getPastureArea(placeable)
    if pastureArea == nil or pastureArea <= 0 then return end

    local spec = vehicle.spec_sprayer
    local wp = spec and spec.workAreaParameters
    local dtSec = (dt or 0) / 1000

    -- Fertilized VISUAL: paint the sprayer's actual application area (at the
    -- implement's output, only as wide as the spread) as the player drives, so
    -- the ground darkens exactly where product landed. Independent of the boost
    -- timer below — the look lasts until the pasture is next grazed, cleared by
    -- the scheduler. Marks the pasture painted in the registry so the clear
    -- survives a save/reload.
    local sx, sz, wx, wz, hx, hz = sprayerWorkAreaCorners(vehicle)
    if sx ~= nil then
        SprayerHook.paintFertParallelogram(placeable, sx, sz, wx, wz, hx, hz)
        if type(mod.grazingRegistry.setFertPainted) == "function" then
            mod.grazingRegistry:setFertPainted(bIdx, pIdx, true)
        end
    end

    -- Track which pasture is being actively sprayed this frame so the debug
    -- overlay can show only THIS pasture's uncovered cells, not every
    -- registered pasture on the map.
    SprayerHook._activePasture = {
        bIdx = bIdx, pIdx = pIdx, ts = g_time or 0,
    }

    -- Already-fertilized? Skip the whole coverage + drain path. Re-spraying
    -- doesn't extend the boost (player drives back to do useful work
    -- elsewhere) and the tank shouldn't drain.
    local daysRem = 0
    if type(mod.grazingRegistry.getFertilizerDaysRemaining) == "function" then
        daysRem = mod.grazingRegistry:getFertilizerDaysRemaining(bIdx, pIdx) or 0
    end
    if daysRem > 0 then
        return
    end

    -- Spatial coverage. Mark cells in a workWidth/2 radius around the actual
    -- SPRAY SOURCE (the implement work-area centre) rather than the tractor —
    -- product lands at the implement, which can sit metres behind the tractor,
    -- so keying coverage off rootNode measured the wrong spot. Falls back to
    -- the tractor position if the work area is unavailable. Re-covering a cell
    -- does nothing, so coverage % only rises by reaching new ground.
    local coverX, coverZ = x, z
    -- Prefer the implement's ACTUAL work-area width (the start->width node span
    -- the paint uses) so coverage and the visual agree on the spread; fall back
    -- to the declared workingWidth, then a constant.
    local workWidth = (spec.usageScale and spec.usageScale.workingWidth) or 12
    if sx ~= nil then
        coverX = sx + 0.5 * (wx - sx) + 0.5 * (hx - sx)
        coverZ = sz + 0.5 * (wz - sz) + 0.5 * (hz - sz)
        local waWidth = math.sqrt((wx - sx) * (wx - sx) + (wz - sz) * (wz - sz))
        if waWidth > 0.5 then workWidth = waWidth end
    end
    local grid = getOrBuildGrid(bIdx, pIdx, placeable)
    local newCells = 0
    if grid ~= nil and grid.insideCount > 0 then
        newCells = stampCells(grid, coverX, coverZ, workWidth * 0.5)
    end

    -- Drain the tank ONLY when we actually covered new ground this tick,
    -- and scale the drain by the AREA of the new ground (cells × cellArea)
    -- × the fill type's real-world application rate. Time-based drain was
    -- too low because the spreader spends almost no real-time over each
    -- 100 m2 cell, and at the fallback 60 L/min it added up to ~1 L/cell —
    -- nowhere near a real slurry tanker's 30 m3/ha rate.
    if newCells > 0 then
        local cellArea = (grid and grid.cellSize and grid.cellSize * grid.cellSize) or 100
        local literPerSqM = SprayerHook.LITERS_PER_SQM_BY_FILL[fillName]
            or SprayerHook.DEFAULT_LITERS_PER_SQM
        local liters = newCells * cellArea * literPerSqM
        if liters > 0 and vehicle.addFillUnitFillLevel ~= nil and spec.fillUnitIndex ~= nil then
            local farmId = vehicle.getOwnerFarmId and vehicle:getOwnerFarmId() or AccessHandler.EVERYONE
            local ftIdx = wp and wp.sprayFillType or FillType.UNKNOWN
            vehicle:addFillUnitFillLevel(farmId, spec.fillUnitIndex, -liters, ftIdx,
                ToolType and ToolType.UNDEFINED or 0)
        end
    end

    -- Force the visible sprayer effects (particle cone, spray sound, anim
    -- nodes) to play even though we're not over farmland. Vanilla gates
    -- these on `lastSprayTime + 100 > g_time`, which it normally bumps from
    -- inside processSprayerArea — but PF's ExtendedSprayer bypasses that
    -- branch when there's no field, so the field never gets set and the
    -- effects stay off. We bump lastSprayTime ourselves but DO NOT set
    -- workAreaParameters.isActive — vanilla's onEndWorkAreaProcessing reads
    -- that flag and drains the tank by spec.usage per tick. With our own
    -- newCells-gated drain above, letting vanilla drain too would
    -- double-bleed the tank over already-covered cells.
    if wp ~= nil then
        wp.lastSprayTime = g_time or 0
        -- Override vanilla's drain. When the pasture sits on owned
        -- farmland, vanilla's WorkArea spec calls processSprayerArea ->
        -- FSDensityMapUtil.updateSprayArea (which writes the spray-type
        -- density map regardless of crop presence), gets back a non-zero
        -- changedArea, sets isActive=true, and then onEndWorkAreaProcessing
        -- drains the tank by spec.usage per frame. With our newCells-gated
        -- drain above already running, that's a double-bleed over already-
        -- covered cells. Force the flag back to false every frame we are
        -- over a registered pasture so only OUR drain logic counts.
        wp.isActive = false
    end
    spec.isWorking = true
    if vehicle.isClient then
        -- When Precision Farming is loaded, vanilla's Sprayer:updateSprayerEffects
        -- is replaced with a no-op (ExtendedSprayer.lua:1715-1716) and PF
        -- drives effects through its own ExtendedSprayer.updateSprayerEffectState.
        -- Call the PF entry point when it exists; fall back to vanilla otherwise.
        if _G.ExtendedSprayer ~= nil
            and type(ExtendedSprayer.updateSprayerEffectState) == "function" then
            ExtendedSprayer.updateSprayerEffectState(vehicle, true)
        elseif type(vehicle.updateSprayerEffects) == "function" then
            vehicle:updateSprayerEffects(true)
        end

        -- Per-nozzle gate. PF's ExtendedSprayer:updateExtendedSprayerNozzleEffectState
        -- forces every individual nozzle off when groundType == NONE — which is
        -- always true over a pasture placeable. Install a one-time instance
        -- wrapper that, while we are actively over a pasture (boolean reset
        -- to false on every poll), restores the nozzle to the turned-on
        -- state. Leaving the pasture stops setting the flag, so on the very
        -- next poll the wrapper returns PF's verdict unchanged and the
        -- effects shut off immediately.
        if type(vehicle.updateExtendedSprayerNozzleEffectState) == "function"
            and not vehicle._phNozzleOverridden then
            local orig = vehicle.updateExtendedSprayerNozzleEffectState
            vehicle.updateExtendedSprayerNozzleEffectState = function(selfVeh, effectData, dtArg, isTurnedOn, lastSpeed)
                local isActive, amountScale = orig(selfVeh, effectData, dtArg, isTurnedOn, lastSpeed)
                if selfVeh._phForceNozzle and isTurnedOn then
                    isActive = true
                end
                return isActive, amountScale
            end
            vehicle._phNozzleOverridden = true
        end
        vehicle._phForceNozzle = true
    end

    -- Milestone notifications: fire a HUD message as the player's coverage
    -- crosses each step in MILESTONE_STEPS. Tracked per-grid so we don't
    -- spam — once announced, the same milestone won't re-announce until the
    -- grid is reset (boost fired) or decays back below the step.
    if grid ~= nil and newCells > 0 and grid.insideCount > 0 then
        local pct = grid.coveredCount / grid.insideCount
        for stepIdx, stepValue in ipairs(SprayerHook.MILESTONE_STEPS) do
            if stepIdx > grid.lastMilestoneIdx and pct >= stepValue
                and pct < SprayerHook.COVERAGE_THRESHOLD then
                grid.lastMilestoneIdx = stepIdx
                if type(mod.pushNotification) == "function" then
                    mod:pushNotification({0.6, 0.9, 1.0, 1.0},
                        string.format("Pasture #%d fertilizer coverage: %d%%",
                            pIdx, math.floor(pct * 100 + 0.5)),
                        4000)
                end
            end
        end

        if pct >= SprayerHook.COVERAGE_THRESHOLD then
            local days = (type(mod.getFertilizerBoostDays) == "function"
                and mod:getFertilizerBoostDays())
                or mod.fertilizerBoostDays or 7
            local ok, expireDay = mod.grazingRegistry:applyFertilizer(bIdx, pIdx, days)
            if ok then
                -- Reset the grid so a second pass starts from zero.
                grid.covered = {}
                grid.coveredCount = 0
                grid.lastMilestoneIdx = 0

                if type(mod.pushNotification) == "function" then
                    local regrowthMul = mod.fertilizerRegrowthMultiplier or 2.0
                    local qualityMul = mod.fertilizerQualityMultiplier or 2.0
                    local msg = string.format(
                        "Pasture #%d fertilized (%s) — regrowth x%.1f, soil recovery x%.1f, %d days",
                        pIdx, fillName:lower(), regrowthMul, qualityMul, days)
                    mod:pushNotification({0.4, 1.0, 0.4, 1.0}, msg, 8000)
                end

                PastureHerdingLog.dev("SprayerHook: barn#%d pasture#%d boosted via %s (expires day %d)",
                    bIdx, pIdx, fillName, expireDay or -1)
            end
        end
    end
end

-- Backwards-compat alias for the install hook below — still wired off the
-- Sprayer.processSprayerArea return, which gives us a free per-frame tick
-- whenever the original sprayer actually does something.
function SprayerHook.onProcessSprayerArea(vehicle, _workArea, dt)
    SprayerHook.onActiveOverPasture(vehicle, dt or 0)
end

-- Install our hook on Sprayer.processSprayerArea. We cannot use
-- Utils.appendedFunction here because that wrapper discards the original
-- function's return values — and Giants' Precision Farming overrides this
-- same function and depends on receiving (changedArea, totalArea) from
-- super (EnvironmentalScoreHerbicide.lua:145). Dropping those crashes PF.
-- So we wrap manually: call original, capture returns, run our side-effect,
-- return the originals.
-- Fallback polling path. Required because Precision Farming gates
-- processSprayerArea on the ground being farmland — and pasture placeables
-- aren't farmland, so the hook never fires when the player tries to spray
-- over a pasture. We poll every second: any sprayer that is turned on,
-- carrying an NPK fillType, and physically inside a registered pasture
-- triggers the same boost path. The hook stays installed too for the case
-- where Precision Farming isn't loaded.
local function _isSprayerActive(vehicle)
    local spec = vehicle and vehicle.spec_sprayer
    if spec == nil then return false end
    -- Sprayer (player OR AI helper) must have toggled the spreader on.
    -- AI helpers set isTurnedOn via the same path as the player, so this
    -- one check covers both — no extra getIsAIActive branch needed.
    if vehicle.getIsTurnedOn ~= nil and not vehicle:getIsTurnedOn() then return false end
    -- Tank must actually have fill. An empty spreader driven over a
    -- pasture is just transport, not application — the boost shouldn't
    -- fire and the tank obviously can't drain.
    local wp = spec.workAreaParameters
    if wp ~= nil and type(wp.sprayFillLevel) == "number" and wp.sprayFillLevel <= 0 then
        return false
    end
    if vehicle.getFillUnitFillLevel ~= nil and spec.fillUnitIndex ~= nil then
        local lvl = vehicle:getFillUnitFillLevel(spec.fillUnitIndex)
        if type(lvl) == "number" and lvl <= 0 then return false end
    end
    return true
end

-- Idle decay: every in-game hour boundary, randomly clear COVERAGE_DECAY_PER_HOUR
-- fraction of each grid's covered cells. This models nutrient runoff /
-- weathering so half-finished passes don't sit forever; the cells cleared
-- are random across the polygon, so the player has to fix newly-uncovered
-- spots — not the same spot as last time.
local function _tickIdleDecay()
    local env = g_currentMission and g_currentMission.environment
    if env == nil or env.currentHour == nil then return end
    if SprayerHook._lastDecayHour == nil then
        SprayerHook._lastDecayHour = env.currentHour
        return
    end
    if env.currentHour == SprayerHook._lastDecayHour then return end
    SprayerHook._lastDecayHour = env.currentHour

    for _, grid in pairs(SprayerHook._grids) do
        if grid.coveredCount > 0 then
            local toClear = math.floor(grid.coveredCount * SprayerHook.COVERAGE_DECAY_PER_HOUR + 0.5)
            local cleared = 0
            for idx, _ in pairs(grid.covered) do
                if cleared >= toClear then break end
                grid.covered[idx] = nil
                grid.coveredCount = grid.coveredCount - 1
                cleared = cleared + 1
            end
            -- Re-arm earlier milestones if we dropped below them.
            local pct = grid.coveredCount / math.max(1, grid.insideCount)
            for stepIdx, stepValue in ipairs(SprayerHook.MILESTONE_STEPS) do
                if pct < stepValue and grid.lastMilestoneIdx >= stepIdx then
                    grid.lastMilestoneIdx = stepIdx - 1
                end
            end
        end
    end
end

-- Draw a translucent amber quad on every uncovered cell of the pasture
-- the player is currently spraying. Each cell is two ground-following
-- triangles (alpha-blended so the terrain underneath stays visible) plus
-- a thin darker outline for definition. Alpha pulses gently so the
-- "needs spreading" zones feel alive rather than static.
-- Restrictions:
--   1. Only the pasture flagged by _activePasture in the last second.
--   2. Pastures with an active fertilizer boost are skipped.
function SprayerHook.drawDebugCoverage()
    if drawDebugLine == nil or drawDebugTriangle == nil then return end
    local mod = _G.g_pastureHerding
    if mod == nil or mod.grazingRegistry == nil or mod.grazingRegistry.barns == nil then return end
    local active = SprayerHook._activePasture
    if active == nil then return end
    if (g_time or 0) - (active.ts or 0) > 1000 then return end
    if type(mod.grazingRegistry.getFertilizerDaysRemaining) == "function"
        and (mod.grazingRegistry:getFertilizerDaysRemaining(active.bIdx, active.pIdx) or 0) > 0 then
        return
    end

    local barn = mod.grazingRegistry.barns[active.bIdx]
    if barn == nil or barn.pastures == nil then return end
    local key = active.bIdx .. ":" .. active.pIdx
    local grid = SprayerHook._grids[key]
    if grid == nil then return end

    local terrainId = g_currentMission and g_currentMission.terrainRootNode or 0
    local yLift = 0.15
    local cellSize = grid.cellSize
    local half = cellSize * 0.5
    -- Pulse alpha so the markers gently breathe. Period ~1.5s.
    local nowMs = g_time or 0
    local pulse = 0.30 + 0.12 * math.sin(nowMs / 400)
    local fillR, fillG, fillB = 1.0, 0.55, 0.10  -- amber
    local edgeR, edgeG, edgeB = 0.60, 0.25, 0.05 -- darker amber for outline

    for idx, _ in pairs(grid.inside) do
        if not grid.covered[idx] then
            local cx = idx % grid.cellsX
            local cz = math.floor(idx / grid.cellsX)
            local wx = grid.minX + (cx + 0.5) * cellSize
            local wz = grid.minZ + (cz + 0.5) * cellSize
            -- Sample terrain height at all four corners so the quad
            -- conforms to slopes instead of clipping into a hillside.
            local x0, x1 = wx - half, wx + half
            local z0, z1 = wz - half, wz + half
            local function ty(sx, sz)
                if getTerrainHeightAtWorldPos == nil then return yLift end
                return (getTerrainHeightAtWorldPos(terrainId, sx, 0, sz) or 0) + yLift
            end
            local y00 = ty(x0, z0)
            local y10 = ty(x1, z0)
            local y01 = ty(x0, z1)
            local y11 = ty(x1, z1)

            -- Two triangles forming the filled quad.
            drawDebugTriangle(x0, y00, z0,  x1, y10, z0,  x1, y11, z1,
                              fillR, fillG, fillB, pulse, false)
            drawDebugTriangle(x0, y00, z0,  x1, y11, z1,  x0, y01, z1,
                              fillR, fillG, fillB, pulse, false)

            -- Outline on top of the fill.
            drawDebugLine(x0, y00, z0, edgeR, edgeG, edgeB, x1, y10, z0, edgeR, edgeG, edgeB)
            drawDebugLine(x1, y10, z0, edgeR, edgeG, edgeB, x1, y11, z1, edgeR, edgeG, edgeB)
            drawDebugLine(x1, y11, z1, edgeR, edgeG, edgeB, x0, y01, z1, edgeR, edgeG, edgeB)
            drawDebugLine(x0, y01, z1, edgeR, edgeG, edgeB, x0, y00, z0, edgeR, edgeG, edgeB)
        end
    end
end

function SprayerHook.poll(dt)
    _tickIdleDecay()

    if g_currentMission == nil or g_currentMission.vehicleSystem == nil then return end
    local vehicles = g_currentMission.vehicleSystem.vehicles
    if vehicles == nil then return end

    -- Clear the per-frame nozzle-force flag on every sprayer first. The
    -- active-over-pasture branch below sets it true if the vehicle is still
    -- over a registered pasture. Anything not re-set this frame instantly
    -- falls back to PF's normal gating — so leaving the pasture cuts the
    -- effects immediately rather than lingering past a timeout.
    for _, v in pairs(vehicles) do
        if v ~= nil and v.spec_sprayer ~= nil then
            v._phForceNozzle = false
        end
    end

    for _, v in pairs(vehicles) do
        if v ~= nil and v.spec_sprayer ~= nil and _isSprayerActive(v) then
            SprayerHook.onActiveOverPasture(v, dt or 0)
        end
    end
end

-- Drop every cached grid associated with a barn. Called by
-- GrazingRegistry on pasture removal / barn unregister to prevent stale
-- grids from sitting around indefinitely (memory leak) and from pointing
-- at the wrong pasture after surviving pastures' indices shift. Grids are
-- rebuilt lazily on next first-touch, so wiping is cheap.
function SprayerHook.clearGridsForBarn(barnIdx)
    if SprayerHook._grids == nil then return end
    local prefix = tostring(barnIdx) .. ":"
    local removed = 0
    for key, _ in pairs(SprayerHook._grids) do
        if string.sub(key, 1, #prefix) == prefix then
            SprayerHook._grids[key] = nil
            removed = removed + 1
        end
    end
    if removed > 0 and PastureHerdingLog and PastureHerdingLog.dev then
        PastureHerdingLog.dev("SprayerHook: cleared %d grid(s) for barn#%d", removed, barnIdx)
    end
end

-- Read-only helper for the menu: current coverage for a pasture (0..1).
function SprayerHook.getCoverage(barnIdx, pIdx)
    local grid = SprayerHook._grids[barnIdx .. ":" .. pIdx]
    if grid == nil or grid.insideCount <= 0 then return 0 end
    return grid.coveredCount / grid.insideCount
end

-- For the debug overlay: yield uncovered cell-centre world positions.
-- Returns an array of {x, z, cellSize} (Y is sampled from terrain at draw
-- time). Called by the main update draw step when phSprayCoverDebug is on.
function SprayerHook.getUncoveredCellsForDraw(barnIdx, pIdx)
    local grid = SprayerHook._grids[barnIdx .. ":" .. pIdx]
    if grid == nil then return nil end
    local out = {}
    local cellSize = grid.cellSize
    for idx, _ in pairs(grid.inside) do
        if not grid.covered[idx] then
            local cx = idx % grid.cellsX
            local cz = math.floor(idx / grid.cellsX)
            out[#out + 1] = {
                x = grid.minX + (cx + 0.5) * cellSize,
                z = grid.minZ + (cz + 0.5) * cellSize,
                cellSize = cellSize,
            }
        end
    end
    return out
end

-- Diagnostic: dump every vehicle with spec_sprayer + its turned-on state,
-- fill type, position, and whether the position is inside any registered
-- pasture. Triggered by the phSprayDebug console command.
function SprayerHook.debugDump()
    local lines = { "SprayerHook debug:" }
    if g_currentMission == nil or g_currentMission.vehicleSystem == nil then
        return "SprayerHook debug: no vehicleSystem"
    end
    local vehicles = g_currentMission.vehicleSystem.vehicles or {}
    local count = 0
    for _, v in pairs(vehicles) do
        if v ~= nil and v.spec_sprayer ~= nil then
            count = count + 1
            local on = (v.getIsTurnedOn ~= nil) and v:getIsTurnedOn() or "?"
            local spec = v.spec_sprayer
            local wp = spec and spec.workAreaParameters
            local ftIdx = wp and wp.sprayFillType
            local ftName = "<nil>"
            if ftIdx and g_fillTypeManager then
                local ft = g_fillTypeManager:getFillTypeByIndex(ftIdx)
                ftName = (ft and ft.name) or ("idx="..tostring(ftIdx))
            end
            local x, _, z = 0, 0, 0
            if v.rootNode ~= nil and v.rootNode ~= 0 then
                x, _, z = getWorldTranslation(v.rootNode)
            end
            local mod = _G.g_pastureHerding
            local bIdx, pIdx
            if mod and mod.grazingRegistry then
                bIdx, pIdx = findPastureContaining(mod.grazingRegistry, x, z)
            end
            local npk = SprayerHook.NPK_FILLTYPE_NAMES[ftName] and "yes" or "no"
            lines[#lines + 1] = string.format(
                "  veh#%s name=%s turnedOn=%s fill=%s npk=%s pos=(%.1f,%.1f) inPasture=%s",
                tostring(v.id or "?"),
                tostring(v.configFileName or v.typeName or "?"):match("([^/\\]+)$") or "?",
                tostring(on), ftName, npk, x, z,
                (bIdx and ("barn#"..bIdx.." pasture#"..pIdx)) or "no")
        end
    end
    lines[#lines + 1] = string.format("  total sprayers: %d", count)

    local mod = _G.g_pastureHerding
    if mod and mod.grazingRegistry and mod.grazingRegistry.barns then
        for bIdx, barn in ipairs(mod.grazingRegistry.barns) do
            if barn and barn.pastures then
                for pIdx, p in ipairs(barn.pastures) do
                    local placeable = p  -- barn.pastures[i] is the placeable itself
                    if placeable ~= nil then
                        local contour = ContourReader.readOutdoorContourPolygon(placeable)
                        if contour == nil then
                            lines[#lines + 1] = string.format("  barn#%d pasture#%d: NO CONTOUR", bIdx, pIdx)
                        else
                            local n = #contour
                            local minX, maxX, minZ, maxZ = math.huge, -math.huge, math.huge, -math.huge
                            for i = 1, n, 3 do
                                local x = contour[i]
                                local z = contour[i + 2]
                                if x and z then
                                    if x < minX then minX = x end
                                    if x > maxX then maxX = x end
                                    if z < minZ then minZ = z end
                                    if z > maxZ then maxZ = z end
                                end
                            end
                            local cov = SprayerHook.getCoverage(bIdx, pIdx)
                            local area = getPastureArea(placeable) or 0
                            local grid = SprayerHook._grids[bIdx .. ":" .. pIdx]
                            local gridStr = "no-grid"
                            if grid ~= nil then
                                gridStr = string.format("%d/%d cells", grid.coveredCount, grid.insideCount)
                            end
                            lines[#lines + 1] = string.format(
                                "  barn#%d pasture#%d: %d verts X[%.1f..%.1f] Z[%.1f..%.1f] area=%.0fm2 coverage=%.1f%% (%s)",
                                bIdx, pIdx, math.floor(n / 3), minX, maxX, minZ, maxZ, area, cov * 100, gridStr)
                        end
                    end
                end
            end
        end
    end
    local msg = table.concat(lines, "\n")
    PastureHerdingLog.info("%s", msg)
    return msg
end

function SprayerHook.install()
    if SprayerHook._installed then return end
    if _G.Sprayer == nil or type(Sprayer.processSprayerArea) ~= "function" then
        PastureHerdingLog.dev("SprayerHook: Sprayer.processSprayerArea not available yet; will retry at mission start.")
        return
    end
    local original = Sprayer.processSprayerArea
    Sprayer.processSprayerArea = function(vehicle, workArea, dt)
        local a, b, c = original(vehicle, workArea, dt)
        -- Side-effect runs in a pcall so any bug here can never break PF
        -- or the vanilla sprayer pipeline.
        local ok, err = pcall(SprayerHook.onProcessSprayerArea, vehicle, workArea, dt)
        if not ok and PastureHerdingLog and PastureHerdingLog.error then
            PastureHerdingLog.error("SprayerHook side-effect crashed: %s", tostring(err))
        end
        return a, b, c
    end
    SprayerHook._installed = true
    local boostDays = (g_pastureHerding and type(g_pastureHerding.getFertilizerBoostDays) == "function"
        and g_pastureHerding:getFertilizerBoostDays())
        or (g_pastureHerding and g_pastureHerding.fertilizerBoostDays) or 7
    PastureHerdingLog.dev("SprayerHook: installed — fertilizer/slurry/manure spreading over a registered pasture will trigger a %d-day boost (scales with days-per-month).",
        boostDays)
end
