-- Watches in-game time and fires barn->pasture / pasture->barn transitions.
-- Stage 1: hardcoded sunrise/sunset hours, configurable in code. No UI.
-- Each barn has independent state ("at_barn" vs "at_pasture"), updated by
-- the scheduler when the time threshold is crossed.

GrazingScheduler = {}
local GrazingScheduler_mt = Class(GrazingScheduler)

GrazingScheduler.SUNRISE_HOUR = 8   -- cows go out at 08:00
GrazingScheduler.SUNSET_HOUR  = 17  -- cows come back at 17:00

-- Sum cluster-level food input (L/hour) across every cluster on a placeable.
-- Mirrors what RLRM does in PlaceableHusbandryFood:updateInputAndOutput —
-- uses cluster:getInput("food") which already accounts for RLRM's
-- consumption multiplier, lactation modifiers, age scaling, etc. Falls back
-- to subType.input.food × numAnimals if the live method isn't available
-- (e.g. mid-frame before the cluster has initialized).
-- Shared between the menu (pre-move duration estimate) and the pump (rate
-- fallback when spec.litersPerHour is 0). Exposed as a static so callers in
-- other files can use it via GrazingScheduler.sumFoodInputAt(...).
function GrazingScheduler.sumFoodInputAt(placeable)
    if placeable == nil then return 0 end
    local spec = placeable.spec_husbandryAnimals
    if spec == nil or spec.clusterSystem == nil then return 0 end
    local list = (spec.clusterSystem.getAnimals and spec.clusterSystem:getAnimals())
        or spec.clusterSystem.animals or {}
    local total = 0
    for _, c in pairs(list) do
        if type(c) == "table" then
            local consumed = false
            if type(c.getInput) == "function" then
                local ok, v = pcall(function() return c:getInput("food") end)
                if ok and type(v) == "number" then
                    total = total + v
                    consumed = true
                end
            end
            if not consumed then
                local subType = (type(c.getSubType) == "function") and (c:getSubType()) or c.subType
                local food = subType and subType.input and subType.input.food
                local n = c.numAnimals or 1
                if type(food) == "number" then total = total + food * n end
            end
        end
    end
    return total
end

function GrazingScheduler.new(registry, mover)
    local self = setmetatable({}, GrazingScheduler_mt)
    self.registry = registry
    self.mover = mover
    -- Per-barnIdx state: "at_barn" | "at_pasture"
    self.state = {}
    -- Last hour we observed; used to detect threshold crossings only once.
    self.lastHour = nil
    -- Toggle for the periodic auto-check; tests can disable via console.
    self.autoEnabled = true
    -- Wired by PastureHerding at construction time so per-walker transit
    -- flow has access to TransitTracker + AnimalManager (Herding Lite).
    self.transitTracker = nil
    self.animalManager = nil
    -- Per-farmId map of the last in-game Period (month) we charged the
    -- herdsman wage on. Persistence seeds this from save XML so a reload
    -- doesn't trigger an instant re-bill.
    self._lastChargedPeriod = {}
    -- Bag of active WalkSessions started by this scheduler. Ticked from the
    -- framework update loop.
    self.activeSessions = {}
    -- Walk queue: only ONE walk runs at a time across all barns. A send made
    -- while another herd is walking is parked here and dispatched when the
    -- active walk finishes. Prevents two sessions (especially barns that share
    -- pastures) from claiming the same animals at once. Runtime-only.
    self.walkQueue = {}
    -- Per-barn flag: player put a beef herd out to pasture by hand, so the
    -- season/weather gate must NOT shed it until they bring it in / change
    -- phase. Persisted; honoured by _tickBeef and reconcileOnLoad.
    self._beefManualOut = {}
    -- Per-barn state we just commanded via transitionOne; reconcile trusts it
    -- until reality settles, so an async instant move can't race a flip.
    self._pendingMove = {}
    return self
end

function GrazingScheduler:tickSessions(dt)
    for i = #self.activeSessions, 1, -1 do
        local s = self.activeSessions[i]
        s:update(dt)
        if s:isDone() then
            table.remove(self.activeSessions, i)
        end
    end
    self:_pumpWalkQueue()
end

-- Is a walk for this barn already queued (waiting to start)?
function GrazingScheduler:_barnQueued(barnIdx)
    for _, q in ipairs(self.walkQueue or {}) do
        if q.barnIdx == barnIdx then return true end
    end
    return false
end

-- Park a walk request until the active walk finishes. One pending walk per barn
-- (repeated clicks are ignored). Returns true if it was newly queued.
function GrazingScheduler:_enqueueWalk(desc)
    self.walkQueue = self.walkQueue or {}
    if self:_hasActiveSessionFor(desc.barnIdx) or self:_barnQueued(desc.barnIdx) then
        return false
    end
    self.walkQueue[#self.walkQueue + 1] = desc
    local framework = rawget(_G, "g_pastureHerding")
    if framework ~= nil and type(framework.pushNotification) == "function" then
        pcall(function()
            framework:pushNotification({0.3, 0.7, 1.0, 1.0},
                string.format("Barn #%d walk queued — it starts when the current herd finishes moving.", desc.barnIdx),
                5000)
        end)
    end
    PastureHerdingLog.dev("GrazingScheduler: barn#%d walk queued (%s); %d in queue",
        desc.barnIdx, tostring(desc.reason), #self.walkQueue)
    return true
end

-- Dispatch the next queued walk once no session is active. Runs at most one per
-- tick: if the dequeued walk starts a session, the next tick sees it busy and
-- waits; if it can't start (conditions changed), it's dropped and the queue
-- keeps draining.
function GrazingScheduler:_pumpWalkQueue()
    if self.walkQueue == nil or #self.walkQueue == 0 then return end
    if #self.activeSessions > 0 then return end
    local desc = table.remove(self.walkQueue, 1)
    if desc == nil then return end
    PastureHerdingLog.dev("GrazingScheduler: dispatching queued walk for barn#%d (%s); %d left in queue",
        desc.barnIdx, tostring(desc.reason), #self.walkQueue)
    if desc.kind == "pasture" then
        self:walkPastureToPasture(desc.barnIdx, desc.fromIdx, desc.toIdx, desc.reason)
    else
        self:transitionOne(desc.barnIdx, desc.direction, desc.reason)
    end
end

local function getCurrentHour()
    if g_currentMission == nil or g_currentMission.environment == nil then return nil end
    local env = g_currentMission.environment
    -- env.dayMinutes is minutes since midnight (0..1440) in some versions;
    -- env.currentMinute / env.currentHour are the more reliable accessors.
    if type(env.currentHour) == "number" then return env.currentHour end
    if type(env.dayMinutes) == "number" then return math.floor(env.dayMinutes / 60) end
    return nil
end

function GrazingScheduler:tick()
    local hour = getCurrentHour()
    if hour == nil then return end
    if self.lastHour == hour then return end

    local crossed = self.lastHour ~= nil
    self.lastHour = hour
    if not crossed then return end

    -- Auto-feed top-up runs on every hour crossing INDEPENDENT of the master
    -- grazing-schedule toggle — it's a separate opt-in (phAutoFeed), and
    -- autoFeedTick itself no-ops unless the player enabled it. (Previously it
    -- sat below the autoEnabled guard, so turning the schedule off silently
    -- stopped the hourly top-up.)
    local framework = rawget(_G, "g_pastureHerding")
    if framework ~= nil and type(framework.autoFeedTick) == "function" then
        framework:autoFeedTick()
    end

    -- Truth-sync state BEFORE the schedule gate, so the mod tracks animals the
    -- player moved with RLRM (or by hand) even in manual mode. reconcile only
    -- recounts + follows the herd — it never moves anything — so it's safe to
    -- run regardless of the master toggle. The cached self.state[barnIdx] can
    -- otherwise drift whenever cows move outside our own walk system.
    for barnIdx, _ in self.registry:iterBarns() do
        -- Hours have passed since any command, so the tick pass is authoritative:
        -- drop stale pending-move guards before the recount.
        if self._pendingMove then self._pendingMove[barnIdx] = nil end
        self:reconcileStateFromReality(barnIdx)
    end

    -- Everything below is scheduled grazing behaviour — gated on the master
    -- schedule toggle (manual mode leaves cow movement to the player).
    if not self.autoEnabled then return end

    -- Grazing productivity floor. See _applyGrassProductivityFloor.
    self:_applyGrassProductivityFloor()

    -- Sunrise/sunset transitions are also per-barn — we walk the list inside
    -- transitionOne which already filters; we just call it on hour boundaries
    -- for every barn whose autoSchedule flag is on.
    --
    -- Sunrise transitions are additionally gated on the grazing calendar:
    -- outside the season we hold cows at the barn (skip "out"). Sunset
    -- always fires so any cows currently out still come home for the night.
    -- Per-barn sunrise / sunset matching. Each barn can override the global
    -- hours; nil falls back to self.SUNRISE_HOUR / SUNSET_HOUR. Global gates
    -- (calendar, weather) apply identically to every barn since they reflect
    -- the world, not the herd.
    local framework = rawget(_G, "g_pastureHerding")
    local inSeason = (framework == nil) or (type(framework.isGrazingMonth) ~= "function")
                     or framework:isGrazingMonth()
    local badWeather = (framework ~= nil and framework.gateOnWeather == true
                        and type(framework.isBadWeather) == "function"
                        and framework:isBadWeather())

    for barnIdx, _ in self.registry:iterBarns() do
        if self.registry:getAutoSchedule(barnIdx) then
            local herdType = self.registry:getHerdType(barnIdx)
            if herdType == "beef" then
                self:_tickBeef(barnIdx, inSeason, badWeather)
            else
                local sunriseH = self.registry:getBarnSunrise(barnIdx) or self.SUNRISE_HOUR
                local sunsetH  = self.registry:getBarnSunset(barnIdx)  or self.SUNSET_HOUR
                if hour == sunriseH then
                    if not inSeason then
                        PastureHerdingLog.dev("GrazingScheduler: barn#%d sunrise skipped — outside grazing season",
                            barnIdx)
                    elseif badWeather then
                        PastureHerdingLog.dev("GrazingScheduler: barn#%d sunrise skipped — bad weather",
                            barnIdx)
                    else
                        -- Pre-flight grass check: before sending cows out
                        -- for the full sunrise->sunset window, make sure
                        -- there's enough grass to last the day. If the
                        -- currently-active pasture is too thin AND the
                        -- herdsman (autoRotate) is on, swap to a viable
                        -- alternate; if no pasture has enough, keep cows
                        -- in the barn and notify the player.
                        local hoursWindow = sunsetH - sunriseH
                        if hoursWindow <= 0 then hoursWindow = 10 end
                        local viableIdx, totalNeeded, currentFill = self:_findPastureWithEnoughGrass(barnIdx, hoursWindow)
                        if viableIdx == nil then
                            local framework = rawget(_G, "g_pastureHerding")
                            if framework ~= nil and type(framework.pushNotification) == "function" then
                                framework:pushNotification({1.0, 0.4, 0.2, 1.0},
                                    string.format("Herdsman: no pasture under barn#%d has enough grass for today's %dh window (need ~%.0f L). Cows kept in the barn.",
                                        barnIdx, hoursWindow, totalNeeded or 0), 8000)
                            end
                            PastureHerdingLog.dev(
                                "GrazingScheduler: barn#%d sunrise SKIPPED — no pasture has %dh of grass (active has %.0f L, need %.0f L)",
                                barnIdx, hoursWindow, currentFill or 0, totalNeeded or 0)
                        else
                            local curIdx = self.registry:getBarn(barnIdx).activeIndex
                            if viableIdx ~= curIdx then
                                -- Herdsman picked a better pasture than the
                                -- currently-active one. Switch and notify.
                                if self.registry:getAutoRotate(barnIdx) then
                                    self.registry:setActivePasture(barnIdx, viableIdx)
                                    local framework = rawget(_G, "g_pastureHerding")
                                    if framework ~= nil and type(framework.pushNotification) == "function" then
                                        framework:pushNotification({0.3, 0.9, 1.0, 1.0},
                                            string.format("Herdsman: switched barn#%d to pasture #%d (more grass for today).",
                                                barnIdx, viableIdx), 6000)
                                    end
                                    PastureHerdingLog.dev(
                                        "GrazingScheduler: barn#%d sunrise — herdsman rotated %d -> %d (more grass)",
                                        barnIdx, curIdx, viableIdx)
                                else
                                    -- Herdsman select OFF: respect the
                                    -- player's chosen pasture even if
                                    -- thin. Still send them out, but post
                                    -- a warning so they know.
                                    local framework = rawget(_G, "g_pastureHerding")
                                    if framework ~= nil and type(framework.pushNotification) == "function" then
                                        framework:pushNotification({1.0, 0.85, 0.2, 1.0},
                                            string.format("Barn#%d cows sent to active pasture but grass is thin (~%.0f L vs %.0f L needed for the day). Consider enabling Herdsman select or rotating manually.",
                                                barnIdx, currentFill or 0, totalNeeded or 0), 7000)
                                    end
                                end
                            end
                            self:transitionOne(barnIdx, "out", "sunrise")
                        end
                    end
                elseif hour == sunsetH then
                    self:transitionOne(barnIdx, "in", "sunset")
                end
            end
        end
    end
end

-- Beef herds don't follow a daily clock — the player flips a phase. Each
-- hour-crossing we re-check whether the herd is where the current phase +
-- gates say it should be, and fire at most one transition to correct it.
-- Idempotent: transitionOne early-returns when already in the right state.
function GrazingScheduler:_tickBeef(barnIdx, inSeason, badWeather)
    local phase = self.registry:getBeefPhase(barnIdx)
    local state = self.state[barnIdx] or "at_barn"

    if phase == "finishing" then
        -- Always at the shed during finishing — TMR feeding only.
        if state == "at_pasture" then
            self:transitionOne(barnIdx, "in", "beef-finishing")
        end
        return
    end

    -- phase == "grazing"
    -- A pasture mid-reseed can't be grazed — keep the herd in (and don't spam the
    -- "held in" notification from transitionOne every hour of the lockout).
    local barn = self.registry:getBarn(barnIdx)
    local activeRenewing = barn ~= nil and (barn.activeIndex or 0) > 0
                           and self.registry:isRenewing(barnIdx, barn.activeIndex)
    local shouldBeOut = inSeason and not badWeather and not activeRenewing
    if shouldBeOut and state == "at_barn" then
        self:transitionOne(barnIdx, "out", "beef-grazing-resume")
    elseif not shouldBeOut and state == "at_pasture" then
        -- Off-season / bad weather would normally shed the herd. But if the
        -- player put them out by hand (manual-out override), respect it and
        -- leave them — automation only sheds a herd IT sent out.
        if self._beefManualOut and self._beefManualOut[barnIdx] then
            PastureHerdingLog.dev(
                "GrazingScheduler: barn#%d beef would winter-shed but manual-out override is set — leaving at pasture",
                barnIdx)
            return
        end
        local reason = (not inSeason) and "beef-winter-shed" or "beef-bad-weather"
        self:transitionOne(barnIdx, "in", reason)
    end
end

-- Cached set of fillType indices the husbandry treats as roughage. Built
-- lazily on first use because g_fillTypeManager isn't available at file
-- load time. The husbandry's "roughage" food-group is what the meadow
-- grass fills; we drain these from the pasture food trough so the cows
-- have to actually chew grass. NOTE: must be declared ABOVE the helpers
-- that close over it (e.g. _applyGrassProductivityFloor's inner closure)
-- — Lua locals are only visible to functions defined after the local's
-- declaration line.
local _roughageFillTypes
local function getRoughageFillTypes()
    if _roughageFillTypes ~= nil then return _roughageFillTypes end
    -- Don't cache the failure case: if g_fillTypeManager isn't up yet,
    -- return an empty set for THIS call but leave the module-level cache
    -- nil so the next call retries. Caching the failure would otherwise
    -- permanently break the beef-grazing concentrate ratio lerp.
    if g_fillTypeManager == nil or type(g_fillTypeManager.getFillTypeIndexByName) ~= "function" then
        return {}
    end
    local out = {}
    for _, name in ipairs({
        "GRASS_WINDROW", "DRYGRASS_WINDROW", "FORAGE", "SILAGE",
        "HAY", "ALFALFA_FERMENTED", "STRAW",
    }) do
        local ok, idx = pcall(function() return g_fillTypeManager:getFillTypeIndexByName(name) end)
        if ok and type(idx) == "number" then out[idx] = true end
    end
    _roughageFillTypes = out
    return _roughageFillTypes
end

-- Diagnostic: track actual meadow drain vs predicted rate per pasture.
-- Records: net drain (only counts decreases — regrowth ignored), elapsed
-- real and in-game time, cow count, predicted-vs-actual ratio. Dumped via
-- the `phMeadowDrain` console command; resets each dump.
function GrazingScheduler:_measureMeadowDrain()
    local env = g_currentMission and g_currentMission.environment
    if env == nil then return end
    local nowReal = (g_currentMission and g_currentMission.time) or 0
    local nowGameH = (env.currentDay or 0) * 24
                     + (env.currentHour or 0)
                     + (env.currentMinute or 0) / 60
    self._meadowDrainTracker = self._meadowDrainTracker or {}
    for barnIdx, barn in self.registry:iterBarns() do
        for pIdx, pasture in ipairs(barn.pastures) do
            local m = pasture and pasture.spec_husbandryMeadow
            if m ~= nil and type(m.fillLevels) == "table" then
                local key = string.format("%d-%d", barnIdx, pIdx)
                local entry = self._meadowDrainTracker[key]
                local totalFill = 0
                for _, v in pairs(m.fillLevels) do
                    if type(v) == "number" then totalFill = totalFill + v end
                end
                if entry == nil then
                    self._meadowDrainTracker[key] = {
                        startFill   = totalFill,
                        lastFill    = totalFill,
                        startGameH  = nowGameH,
                        startRealMs = nowReal,
                        netDrain    = 0,
                        maxCows     = 0,
                        startedAtGameH = nowGameH,
                    }
                else
                    if totalFill < entry.lastFill then
                        entry.netDrain = entry.netDrain + (entry.lastFill - totalFill)
                    end
                    entry.lastFill = totalFill
                    -- Track peak cow count seen during this window.
                    local spec = pasture.spec_husbandryAnimals
                    local cs = spec and spec.clusterSystem
                    local list = cs and ((cs.getAnimals and cs:getAnimals()) or cs.animals) or {}
                    local cows = 0
                    for _, c in pairs(list) do
                        if type(c) == "table" then cows = cows + (c.numAnimals or 1) end
                    end
                    if cows > entry.maxCows then entry.maxCows = cows end
                end
            end
        end
    end
end

-- Freeze the meadow fill ONLY on an idle pasture (no cows) so grass doesn't
-- drain when nothing is grazing it -- guards against post-load fill resets and
-- background decay. When cows ARE present we leave the meadow entirely alone
-- and let vanilla drain it at its natural rate; grass is the herd's food while
-- it lasts, then depletes.
--
-- History: an earlier version also throttled the drain WHILE grazing (capping
-- it to a "grass fraction" budget by writing fillLevels back). That desynced
-- vanilla's food-place accounting -- after one reverted graze vanilla stopped
-- re-consuming the meadow, so grass froze at 100% and the shown amount no
-- longer matched reality. Removed: never write fillLevels while cows graze.
function GrazingScheduler:_holdMeadowFillWhenEmpty()
    for barnIdx, barn in self.registry:iterBarns() do
        for pIdx, pasture in self:_iterProcessable(barnIdx, barn) do
            local m = pasture and pasture.spec_husbandryMeadow
            if m ~= nil and type(m.fillLevels) == "table" then
                -- Count cluster animals at this pasture.
                local spec = pasture.spec_husbandryAnimals
                local cs = spec and spec.clusterSystem
                local list = cs and ((cs.getAnimals and cs:getAnimals()) or cs.animals) or {}
                local cowCount = 0
                for _, c in pairs(list) do
                    if type(c) == "table" then
                        cowCount = cowCount + (c.numAnimals or 1)
                    end
                end

                pasture._phLastMeadowFill = pasture._phLastMeadowFill or {}
                local lastMap = pasture._phLastMeadowFill

                local rawDropThis, revertedThis = 0, 0
                for ft, level in pairs(m.fillLevels) do
                    if type(level) == "number" then
                        local lastFill = lastMap[ft]
                        if lastFill ~= nil and level < lastFill then
                            local dropped = lastFill - level
                            rawDropThis = rawDropThis + dropped
                            -- Freeze ONLY when no cows are present (guards an
                            -- idle pasture against post-load fill resets +
                            -- background decay). While cows graze we DO NOT
                            -- touch fillLevels: writing them back desyncs
                            -- vanilla's food system (it consumes the meadow
                            -- once, we reset the display fill, and vanilla then
                            -- believes the grass is gone and stops re-consuming
                            -- -> the herd grazes one tick then the meadow
                            -- freezes at 100% and the shown grass no longer
                            -- matches reality). Let vanilla drain it naturally.
                            if cowCount == 0 then
                                m.fillLevels[ft] = lastFill
                                level = lastFill
                                revertedThis = revertedThis + dropped
                            end
                        end
                        lastMap[ft] = level
                    end
                end

                -- MEADOWLOG: throttled per-pasture diagnostic (grep "MEADOWLOG").
                -- Dev-gated (Settings -> Verbose log output). rawDrop>0 +
                -- reverted=0 while grazing = grass depleting correctly.
                if PastureHerdingLog.devMode then
                    pasture._phDrainAccRaw = (pasture._phDrainAccRaw or 0) + rawDropThis
                    pasture._phDrainAccReverted = (pasture._phDrainAccReverted or 0) + revertedThis
                    local nowMs = (g_currentMission and g_currentMission.time) or 0
                    pasture._phDrainLogNextMs = pasture._phDrainLogNextMs or (nowMs + 2000)
                    if nowMs >= pasture._phDrainLogNextMs then
                        if cowCount > 0 or (pasture._phDrainAccRaw or 0) > 0 then
                            PastureHerdingLog.dev(
                                "MEADOWLOG barn#%d pasture#%d cows=%d rawDrop=%.3f reverted=%.3f",
                                barnIdx, pIdx, cowCount, pasture._phDrainAccRaw, pasture._phDrainAccReverted)
                        end
                        pasture._phDrainAccRaw = 0
                        pasture._phDrainAccReverted = 0
                        pasture._phDrainLogNextMs = nowMs + 2000
                    end
                end
            end
        end
    end
end

-- Continuously decay soil quality on whichever pasture each barn is
-- currently grazing. The configured grazingQualityDecayPerGrazingDay
-- (default 15%) is the FULL daily decay; we apply that rate divided by
-- 24 in-game hours per hour-rollover tick. So cows that spend 8h on the
-- pasture trigger 8/24 = 33% of the daily decay; 24h triggers full decay.
-- Uses hour-rollover detection (same lastHour state the scheduler keeps
-- for sunrise/sunset) so the decrement is discrete and time-scale-safe.
-- Ease this barn's grazing condition (0..1) toward a target once per rolled-over
-- in-game hour. Target = the active pasture's grass quality (= soil quality)
-- while the herd is OUT grazing; 0 while housed. The beef weight boost and dairy
-- milk boost both scale by the result, so nothing snaps: house the herd and the
-- condition (and both boosts) fade over a few in-game days. Runs off the same
-- hour-rollover as the soil-quality decay.
function GrazingScheduler:_updateGrazingCondition(barnIdx, atPasture, activeIdx, hours)
    if type(hours) ~= "number" or hours <= 0 then return end
    local framework = rawget(_G, "g_pastureHerding")
    local buildRate = (framework and framework.grazeConditionBuildRate) or 0.03
    local decayRate = (framework and framework.grazeConditionDecayRate) or 0.03

    local target = 0
    if atPasture and type(activeIdx) == "number" then
        target = self.registry:getEffectiveQuality(barnIdx, activeIdx) or 0
    end

    local cond = self.registry:getGrazingCondition(barnIdx) or 0
    local before = cond
    local steps = hours
    if steps > 48 then steps = 48 end   -- cap catch-up work after a long time-skip
    for _ = 1, steps do
        local rate = (target >= cond) and buildRate or decayRate
        cond = cond + (target - cond) * rate
    end
    if cond < 0 then cond = 0 elseif cond > 1 then cond = 1 end
    self.registry:setGrazingCondition(barnIdx, cond)
    if math.abs(cond - before) > 0.0005 then
        PastureHerdingLog.dev(
            "GrazingScheduler: barn#%d grazing condition %.3f -> %.3f (target %.2f, +%dh)",
            barnIdx, before, cond, target, hours)
    end
end

-- Total grass capacity (liters) of a pasture's meadow. Size proxy for the
-- renewal cost and a general helper.
function GrazingScheduler:_meadowCapacity(pasture)
    local m = pasture and pasture.spec_husbandryMeadow
    if m == nil then return 0 end
    local cap = 0
    if type(m.capacities) == "table" then
        for _, v in pairs(m.capacities) do if type(v) == "number" then cap = cap + v end end
    end
    if cap <= 0 and type(m.capacity) == "number" then cap = m.capacity end
    return cap
end

-- Strip a pasture's grass to bare (freshly sown). Mirrors phEmptyMeadow: prefer
-- the vanilla removeFood path so food-place accounting stays consistent.
function GrazingScheduler:_stripMeadowGrass(pasture)
    local m = pasture and pasture.spec_husbandryMeadow
    if m == nil or type(m.fillLevels) ~= "table" then return end
    for fillType, level in pairs(m.fillLevels) do
        if type(level) == "number" and level > 0 then
            if type(pasture.removeFood) == "function" then
                local ok = pcall(function() pasture:removeFood(level, fillType) end)
                if not ok then m.fillLevels[fillType] = 0 end
            else
                m.fillLevels[fillType] = 0
            end
            if type(pasture._phLastMeadowFill) == "table" then
                pasture._phLastMeadowFill[fillType] = m.fillLevels[fillType] or 0
            end
        end
    end
end

-- Renew (reseed) a pasture: renovate soil quality back to full, strip the grass
-- bare, and lock it OUT of grazing for ~2 in-game months while the new sward
-- establishes. Costs money scaled by the pasture's grass capacity. Refuses if a
-- herd is currently out on this pasture. Returns ok(boolean), message(string).
function GrazingScheduler:renewPasture(barnIdx, pIdx)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil then return false, "No such barn." end
    local pasture = barn.pastures[pIdx]
    if pasture == nil then return false, "No such pasture." end

    if self.registry:isRenewing(barnIdx, pIdx) then
        return false, "That pasture is already regrowing."
    end

    -- Refuse while a herd is out on it (this barn's, or another sharing it).
    local atPasture = (self.state[barnIdx] or "at_barn") == "at_pasture"
    if atPasture and barn.activeIndex == pIdx then
        return false, "Bring the herd in first — they are grazing this pasture."
    end
    if self:_occupantBarnOf(pasture) ~= nil then
        return false, "Another herd is on this pasture — bring them in first."
    end

    local env = g_currentMission and g_currentMission.environment
    local today = env and env.currentDay
    if type(today) ~= "number" then return false, "No in-game clock yet." end

    local framework = rawget(_G, "g_pastureHerding")
    -- Size-scaled cost: grass capacity (L) * per-litre rate, with a floor.
    local capacity = self:_meadowCapacity(pasture)
    local perL = (framework and framework.renewCostPerLiter) or 0.20
    local cost = math.floor(capacity * perL + 0.5)
    local minCost = (framework and framework.renewMinCost) or 500
    if cost < minCost then cost = minCost end

    -- Charge the barn's farm.
    local farmId = pasture.ownerFarmId
                   or (barn.placeable and barn.placeable.ownerFarmId) or 1
    if g_currentMission ~= nil and type(g_currentMission.addMoney) == "function" then
        local mtRoot = MoneyType
        local moneyType = mtRoot and (mtRoot.OTHER or mtRoot.FIELD_BUY) or nil
        pcall(function() g_currentMission:addMoney(-cost, farmId, moneyType, true, true) end)
    end

    -- Renovate: reset soil quality to the land's natural potential. With Precision
    -- Farming active that's the PF-derived value for this ground (so poor soil
    -- renews to its natural best, not a flat 100%); otherwise full quality.
    local renewQuality = 1.0
    if framework ~= nil and type(framework.getPFSoilQuality) == "function"
       and pasture.rootNode ~= nil and pasture.rootNode ~= 0
       and type(getWorldTranslation) == "function" then
        local rx, _, rz = getWorldTranslation(pasture.rootNode)
        local pfq = framework:getPFSoilQuality(rx, rz)
        if type(pfq) == "number" then renewQuality = pfq end
    end
    self.registry:setPastureQuality(barnIdx, pIdx, renewQuality)
    self:_stripMeadowGrass(pasture)
    if type(self.registry.setFertPainted) == "function" then
        self.registry:setFertPainted(barnIdx, pIdx, false)
    end

    -- Lockout = 2 months' worth of days from today.
    local dpp = 1
    if framework ~= nil and type(framework.getDaysPerPeriod) == "function" then
        local d = framework:getDaysPerPeriod()
        if type(d) == "number" and d >= 1 then dpp = d end
    end
    local unlockDay = today + 2 * dpp
    self.registry:setRenewalUnlockDay(barnIdx, pIdx, unlockDay)

    local costStr = (g_i18n ~= nil and type(g_i18n.formatMoney) == "function")
        and g_i18n:formatMoney(cost, 0, true, true) or ("$" .. tostring(cost))
    if framework ~= nil and type(framework.pushNotification) == "function" then
        framework:pushNotification({0.4, 0.8, 0.3, 1.0},
            string.format("Pasture #%d reseeded (%s). Regrowing — grazing locked for ~2 months.", pIdx, costStr),
            7000)
    end
    PastureHerdingLog.info(
        "GrazingScheduler: renewed barn#%d pasture#%d — cost %s, cap %dL, locked until day %d",
        barnIdx, pIdx, costStr, capacity, unlockDay)
    return true, string.format("Pasture reseeded (%s). Regrowing for ~2 months.", costStr)
end

function GrazingScheduler:_applyPastureQualityDecay()
    local framework = rawget(_G, "g_pastureHerding")
    local dailyDecay = (framework and framework.grazingQualityDecayPerGrazingDay) or 0.15
    local env = g_currentMission and g_currentMission.environment
    local nowH = env and env.currentHour
    local nowDay = env and env.currentDay
    if type(nowH) ~= "number" then return end

    self._lastQualityTickHour = self._lastQualityTickHour or {}
    local hourlyDecay = dailyDecay / 24
    local dailyRecovery = (framework and framework.grazingQualityRecoveryPerRestDay) or 0.10

    -- Overgrazing + wet-poaching tuning. Both multiply the grazing decay.
    local overgrazeThreshold = (framework and framework.overgrazeGrassThreshold) or 0.15
    local overgrazeMaxMult = (framework and framework.overgrazeMaxMultiplier) or 3.0
    local wetPoachMult = (framework and framework.wetPoachMultiplier) or 1.5
    -- Is it physically wet underfoot right now? (Independent of the scheduling
    -- weather-gate toggle — poaching is about the ground, not the schedule.)
    local badWeather = framework ~= nil and type(framework.isBadWeather) == "function"
                       and framework:isBadWeather() == true

    -- Decay the ACTIVE pasture per hour-rollover, recover ALL other
    -- pastures (under any barn) per hour-rollover. Both use the same
    -- hour-rollover detection so they stay perfectly in sync.
    for barnIdx, barn in self.registry:iterBarns() do
        local lastH = self._lastQualityTickHour[barnIdx]
        local hours = 0
        if lastH ~= nil then
            hours = nowH - lastH
            if hours < 0 then hours = hours + 24 end
        end
        if lastH == nil or hours > 0 then
            self._lastQualityTickHour[barnIdx] = nowH
            if hours > 0 then
                local atPasture = (self.state[barnIdx] or "at_barn") == "at_pasture"
                local activeIdx = barn.activeIndex
                -- Build/decay the herd's grazing condition alongside the soil tick.
                self:_updateGrazingCondition(barnIdx, atPasture, activeIdx, hours)
                for pIdx, pasture in self:_iterProcessable(barnIdx, barn) do
                    -- Renewal lockout expiry: reopen a reseeded pasture once its
                    -- ~2-month establishment window has passed.
                    local unlockDay = self.registry:getRenewalUnlockDay(barnIdx, pIdx)
                    if unlockDay > 0 and type(nowDay) == "number" and nowDay >= unlockDay then
                        self.registry:setRenewalUnlockDay(barnIdx, pIdx, 0)
                        local fw = rawget(_G, "g_pastureHerding")
                        if fw ~= nil and type(fw.pushNotification) == "function" then
                            fw:pushNotification({0.4, 0.8, 0.3, 1.0},
                                string.format("Pasture #%d has regrown and is ready to graze again.", pIdx), 7000)
                        end
                        PastureHerdingLog.info(
                            "GrazingScheduler: barn#%d pasture#%d renewal complete — grazing reopened", barnIdx, pIdx)
                    end
                    -- Timed fade of the sprayed/limed GROUND look: clear it after
                    -- fertPaintClearDays even if the pasture isn't grazed. (Grazing
                    -- also clears it — this is the "whichever comes first" cap.) The
                    -- pH correction / fertilizer boost persist independently.
                    local clearDays = (framework and framework.fertPaintClearDays) or 0
                    if clearDays > 0 and type(nowDay) == "number"
                       and type(self.registry.getFertPainted) == "function"
                       and self.registry:getFertPainted(barnIdx, pIdx) then
                        local paintDay = self.registry:getFertPaintDay(barnIdx, pIdx)
                        if type(paintDay) == "number" and (nowDay - paintDay) >= clearDays then
                            if _G.SprayerHook ~= nil and type(SprayerHook.clearFertVisual) == "function" then
                                pcall(function() SprayerHook.clearFertVisual(pasture) end)
                            end
                            self.registry:setFertPainted(barnIdx, pIdx, false)
                            PastureHerdingLog.dev(
                                "GrazingScheduler: barn#%d pasture#%d sprayed/limed look faded after %d day(s)",
                                barnIdx, pIdx, clearDays)
                        end
                    end
                    -- Safety-net PF soil backfill: if a pasture is still unsampled
                    -- (pre-PF save that wasn't caught at load, or PF added mid-save),
                    -- sample it now. No-op once sampled / without PF.
                    if framework ~= nil and type(framework.applyInitialSoilQuality) == "function"
                       and type(self.registry.getPFSampled) == "function"
                       and not self.registry:getPFSampled(barnIdx, pIdx) then
                        framework:applyInitialSoilQuality(barnIdx, pIdx)
                    end
                    -- Refresh the live PF pH factor (terrain-based; changes only when
                    -- the player limes). Cheap — once per pasture per in-game hour.
                    if framework ~= nil and type(framework.getPFPHFactor) == "function"
                       and pasture.rootNode ~= nil and pasture.rootNode ~= 0
                       and type(getWorldTranslation) == "function" then
                        local qx, _, qz = getWorldTranslation(pasture.rootNode)
                        local phf = framework:getPFPHFactor(qx, qz)
                        if type(phf) == "number" then self.registry:setPHFactor(barnIdx, pIdx, phf) end
                    end
                    if atPasture and pIdx == activeIdx and dailyDecay > 0 then
                        -- Grazing -> decay (writes through to the shared land record).
                        -- OVERGRAZING: below the grass threshold the decay ramps up
                        -- toward overgrazeMaxMult as the pasture approaches bare dirt.
                        local overMult = 1.0
                        if overgrazeThreshold > 0 then
                            local grassPct = self:_meadowGrassPct(pasture) or 1.0
                            if grassPct < overgrazeThreshold then
                                local t = 1.0 - (grassPct / overgrazeThreshold)  -- 0 at threshold, 1 at empty
                                if t < 0 then t = 0 elseif t > 1 then t = 1 end
                                overMult = 1.0 + t * (overgrazeMaxMult - 1.0)
                            end
                        end
                        -- WET POACHING: extra decay while grazing in the rain/snow.
                        local wetMult = badWeather and wetPoachMult or 1.0
                        local current = self.registry:getPastureQuality(barnIdx, pIdx)
                        local newQ = math.max(0.1, current - hourlyDecay * hours * overMult * wetMult)
                        self.registry:setPastureQuality(barnIdx, pIdx, newQ)
                        PastureHerdingLog.dev(
                            "GrazingScheduler: barn#%d pasture#%d quality decay %.3f -> %.3f (+%dh, overgraze x%.2f, wet x%.2f)",
                            barnIdx, pIdx, current, newQ, hours, overMult, wetMult)
                    elseif dailyRecovery > 0 then
                        -- Idle -> recover (apply once per hour rolled over)
                        for _ = 1, hours do
                            local before = self.registry:getPastureQuality(barnIdx, pIdx)
                            if before < 1.0 then
                                self.registry:applyHourlyRecovery(barnIdx, pIdx)
                                PastureHerdingLog.dev(
                                    "GrazingScheduler: barn#%d pasture#%d quality recovery %.3f -> %.3f",
                                    barnIdx, pIdx, before,
                                    self.registry:getPastureQuality(barnIdx, pIdx))
                            end
                        end
                    end
                end
            end
        end
    end
end

-- Scale every registered pasture's meadow capacity by RLRM's foodScale
-- (the player-chosen consumption multiplier). If RLRM is at 1.0 or absent,
-- this is a complete no-op. At 2× consumption we double the meadow's
-- capacity so pasture-vs-herd timing matches vanilla feel. Asymmetric:
-- foodScale < 1.0 is a no-op too (player picked easier — leave alone).
-- Snapshots the original cap on first run so we never compound or lose
-- the model's authored value.
-- Read RLRM's foodScale setting. We read the RLRM global via plain access
-- (see getFoodScale) and, as a robust fallback, RLRM persists its settings to
-- <savegame>/
-- rm_RlSettings.xml (legacy: rlSettings.xml) on game save, storing a 1..10
-- state index that maps to the values { 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4,
-- 4.5, 5 }. We read that file directly.
--
-- Refresh paths: once on first call (lazy init), after each game save,
-- and on demand via the phReloadRLRMSettings console command. Mid-game
-- changes from the player won't be visible until they save — fine for a
-- setting most players set once at the start.
local RLRM_FOOD_SCALE_VALUES = { 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5 }
local _cachedFoodScale  -- nil until first lookup; 1.0 = no compensation

local function readRLRMSettingsFromDisk()
    if g_currentMission == nil or g_currentMission.missionInfo == nil then return nil end
    local savegameDir = g_currentMission.missionInfo.savegameDirectory
    if savegameDir == nil then return nil end
    local function tryRead(filename, rootKey, attrPath)
        local fullPath = savegameDir .. "/" .. filename
        if fileExists == nil or not fileExists(fullPath) then return nil end
        local xmlHandle = loadXMLFile("rlrm_settings_probe", fullPath)
        if xmlHandle == nil or xmlHandle == 0 then return nil end
        local state = getXMLInt(xmlHandle, attrPath)
        delete(xmlHandle)
        return state
    end
    -- New filename / new rootKey first.
    local state = tryRead("rm_RlSettings.xml", "rm_RlSettings", "rm_RlSettings.foodScale#value")
    if state == nil then
        state = tryRead("rlSettings.xml", "settings", "settings.foodScale#value")
    end
    if type(state) == "number" and state >= 1 and state <= #RLRM_FOOD_SCALE_VALUES then
        return RLRM_FOOD_SCALE_VALUES[state]
    end
    return nil
end

function GrazingScheduler.refreshRLRMFoodScale()
    local s = readRLRMSettingsFromDisk()
    if type(s) == "number" and s > 0 then
        _cachedFoodScale = s
        PastureHerdingLog.dev("GrazingScheduler: RLRM foodScale read from rm_RlSettings.xml = %.2f", s)
    end
end

local function getFoodScale()
    -- Lazy init: read the RLRM global via PLAIN access (RLRM classes resolve
    -- through the sandbox __index metatable, same as the
    -- RealisticLivestock_AnimalClusterHusbandry access in HusbandryHook; rawget
    -- bypasses __index and always misses). Falls back to the on-disk setting.
    if _cachedFoodScale == nil then
        local rl = RealisticLivestock_PlaceableHusbandryFood
        if rl ~= nil and type(rl.foodScale) == "number" and rl.foodScale > 0 then
            _cachedFoodScale = rl.foodScale
        else
            local s = readRLRMSettingsFromDisk()
            _cachedFoodScale = (type(s) == "number" and s > 0) and s or 1.0
        end
    end
    return _cachedFoodScale or 1.0
end

function GrazingScheduler:_applyMeadowFoodScaleCompensation()
    local framework = rawget(_G, "g_pastureHerding")
    if framework ~= nil and framework.meadowFoodScaleCompensation == false then return end
    local scale = getFoodScale()
    -- One-shot diagnostic when the value we read flips: helps confirm whether
    -- our scaler sees RLRM's setting change live.
    if self._lastSeenFoodScale ~= scale then
        local rl = RealisticLivestock_PlaceableHusbandryFood
        PastureHerdingLog.dev(
            "GrazingScheduler: foodScale read changed %s -> %s (RealisticLivestock_PlaceableHusbandryFood=%s, .foodScale=%s)",
            tostring(self._lastSeenFoodScale), tostring(scale),
            tostring(rl), tostring(rl and rl.foodScale))
        self._lastSeenFoodScale = scale
    end
    if scale <= 1.0 then return end

    for barnIdx, barn in self.registry:iterBarns() do
        for _, pasture in self:_iterProcessable(barnIdx, barn) do
            local m = pasture and pasture.spec_husbandryMeadow
            if m ~= nil and type(m.capacities) == "table" then
                m._phOriginalMeadowCapacity = m._phOriginalMeadowCapacity or {}
                local origMap = m._phOriginalMeadowCapacity
                local maxCap = 0
                local totalFill = 0
                for ft, cap in pairs(m.capacities) do
                    if type(cap) == "number" then
                        -- Snapshot the authored cap on first sight if we
                        -- don't already have one from persistence load.
                        if origMap[ft] == nil then
                            origMap[ft] = cap
                        end
                        local newCap = math.floor(origMap[ft] * scale)
                        -- Universal rebalance: whenever the cap we want
                        -- differs from what's currently set (first scale-
                        -- up, scale changed mid-game, scale changed across
                        -- save/reload), bump fillLevels proportionally so
                        -- the bar percentage stays the same. Drives both
                        -- the up-scale (cap grows -> fill grows) and
                        -- down-scale (cap shrinks -> fill shrinks, so it
                        -- never exceeds the new cap).
                        if newCap ~= cap and cap > 0 then
                            local levels = m.fillLevels
                            if type(levels) == "table" and type(levels[ft]) == "number" then
                                local prevFill = levels[ft]
                                levels[ft] = math.floor(prevFill * (newCap / cap))
                                PastureHerdingLog.dev(
                                    "GrazingScheduler: meadow rebalance ft=%s cap %.0f -> %.0f (scale=%.2f), fill %.0f -> %.0f",
                                    tostring(ft), cap, newCap, scale, prevFill, levels[ft])
                            end
                        end
                        m.capacities[ft] = newCap
                        if newCap > maxCap then maxCap = newCap end
                        -- Track current total fill for the cached info
                        -- tables below.
                        local lvl = (m.fillLevels and m.fillLevels[ft]) or 0
                        totalFill = totalFill + lvl
                    end
                end
                if maxCap > 0 then m.capacity = maxCap end
                -- Vanilla husbandry's UI (the pop-up when you walk up to
                -- the pasture, and the in-game animal menu) reads its cap
                -- and ratio from these cached subtables — NOT from m.cap /
                -- m.capacities. Without this update they keep showing the
                -- authored value and we look broken.
                -- Only rewrite the cached info tables + raise the dirty flag
                -- when the numbers actually change (scale change / grazing /
                -- regrowth). Previously this re-stringified and force-dirtied
                -- the meadow UI every frame in steady state, making vanilla
                -- re-render it needlessly. fill/cap are stable between the
                -- discrete consumption/regrowth ticks.
                if m._phLastScaleInfoFill ~= totalFill or m._phLastScaleInfoCap ~= maxCap then
                    m._phLastScaleInfoFill = totalFill
                    m._phLastScaleInfoCap = maxCap
                    local ratio = (maxCap > 0) and math.min(1.0, totalFill / maxCap) or 0
                    -- The pasture-near info dialog renders info.text VERBATIM
                    -- as a pre-formatted string ("19164 l"), not from value/
                    -- capacity, so we re-stringify it. Vanilla uses lowercase
                    -- "l" with a space separator.
                    local textStr = string.format("%d l", math.floor(totalFill))
                    if type(m.info) == "table" then
                        m.info.capacity = maxCap
                        m.info.value = totalFill
                        m.info.ratio = ratio
                        m.info.text = textStr
                    end
                    if type(m.foodInfo) == "table" then
                        m.foodInfo.capacity = maxCap
                        m.foodInfo.value = totalFill
                        m.foodInfo.ratio = ratio
                        m.foodInfo.text = textStr
                    end
                    m.isMeadowInfoDirty = true
                end
            end
        end
    end
end

-- Fraction (0..1) of a pasture's meadow grass fill. 1.0 if it has no meadow
-- spec (treated as "full" so we never rotate off it by mistake).
function GrazingScheduler:_meadowGrassPct(pasture)
    local m = pasture and pasture.spec_husbandryMeadow
    if m == nil or type(m.fillLevels) ~= "table" then return 1.0 end
    local fill, cap = 0, 0
    for ft, v in pairs(m.fillLevels) do
        if type(v) == "number" then fill = fill + v end
        local c = (m.capacities and m.capacities[ft]) or m.capacity or 0
        cap = cap + (c or 0)
    end
    return (cap > 0) and (fill / cap) or 1.0
end

-- Move an OUT herd from curIdx to newIdx within a barn: a recorded pasture-to-
-- pasture path makes it a visible walk (which sets active/lastGrazed/decay
-- itself); otherwise an instant relocation, guarded against mixing herds.
-- Notifies + logs. Returns true on success. Shared by starvation + proactive
-- (rotate-threshold) rotation.
function GrazingScheduler:_autoRotateMove(barnIdx, curIdx, newIdx, reason)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil or newIdx == nil or curIdx == nil or newIdx == curIdx then return false end
    local source = barn.pastures[curIdx]
    local newPasture = barn.pastures[newIdx]
    if source == nil or newPasture == nil then return false end
    -- Already walking OR already parked in the walk queue for this barn -> do
    -- nothing. Without the _barnQueued half, a rotation that gets queued (behind
    -- another barn's walk) leaves the herd on the still-depleted pasture, so the
    -- grass trigger re-fires every frame and this re-notifies in a loop.
    if self:_hasActiveSessionFor(barnIdx) or self:_barnQueued(barnIdx) then return false end
    local walked = false
    if self.registry:hasPairPath(barnIdx, curIdx, newIdx) then
        local w, wmsg = self:walkPastureToPasture(barnIdx, curIdx, newIdx, reason)
        if wmsg == "queued" then
            -- Parked behind another walk; _enqueueWalk already posted a "queued"
            -- notice. Skip the "is walking" toast and don't re-trigger (the
            -- _barnQueued guard above blocks the next frame's re-entry).
            return true
        end
        walked = w == true
    end
    local ok, msg = true, nil
    if not walked then
        if self:wouldMixHerds(barnIdx, newPasture) then
            ok, msg = false, "target pasture occupied by another herd"
        else
            ok, msg = self.mover:moveAll(source, newPasture, nil)
            if ok then
                self.registry:setActivePasture(barnIdx, newIdx)
                self.registry:setLastGrazed(barnIdx, newIdx)
                -- No instant decay here: soil decays continuously per hour on the
                -- ACTIVE pasture (_applyPastureQualityDecay). Applying a full-day
                -- hit on rotation too would degrade rotated pastures ~2x.
            end
        end
    end
    if ok then
        local framework = rawget(_G, "g_pastureHerding")
        if framework ~= nil and type(framework.pushNotification) == "function" then
            framework:pushNotification({ 0.3, 0.9, 1.0, 1.0 },
                string.format("Herdsman %s barn#%d cows from pasture #%d to pasture #%d.",
                    walked and "is walking" or "moved", barnIdx, curIdx, newIdx), 6000)
        end
        PastureHerdingLog.dev("GrazingScheduler: barn#%d %s pasture #%d -> #%d (%s)",
            barnIdx, reason, curIdx, newIdx, walked and "walk" or "instant")
        self._starvationNotified = self._starvationNotified or {}
        self._starvationNotified[barnIdx] = nil
    else
        PastureHerdingLog.dev("GrazingScheduler: barn#%d %s move failed: %s", barnIdx, reason, tostring(msg))
    end
    return ok
end

-- Proactive herdsman rotation: while OUT with autoRotate ON and the player has
-- set a rotate threshold, walk the herd to a fresher paddock once the current
-- pasture's grass drops below that threshold — BEFORE it fully starves. Only
-- rotates to a pasture that is itself above the threshold, so it never ping-
-- pongs between depleted paddocks (starvation handles the all-empty case).
function GrazingScheduler:_checkProactiveRotation()
    local framework = rawget(_G, "g_pastureHerding")
    local rotateAt = (framework and framework.rotateAtGrassPercent) or 0
    if rotateAt <= 0 then return end
    for barnIdx, barn in self.registry:iterBarns() do
        if (self.state[barnIdx] or "at_barn") == "at_pasture"
           and self.registry:getAutoRotate(barnIdx)
           and not self:_hasActiveSessionFor(barnIdx)
           and not self:_barnQueued(barnIdx) then
            local curIdx = barn.activeIndex
            local curPct = self:_meadowGrassPct(self.registry:getActivePasture(barnIdx))
            if curIdx and curIdx > 0 and curPct < rotateAt then
                local newIdx = self:_pickPastureWithMostGrass(barnIdx)
                if newIdx ~= nil and newIdx ~= curIdx and barn.pastures[newIdx] ~= nil
                   and self:_meadowGrassPct(barn.pastures[newIdx]) > rotateAt then
                    self:_autoRotateMove(barnIdx, curIdx, newIdx, "rotate-threshold")
                end
            end
        end
    end
end

-- Detect "the active pasture is depleted and cows have nothing to eat".
-- Triggered when meadow grass < starvationGrassPct AND the trough's non-
-- roughage fill ratio is < starvationSupplementPct AND the herd is out.
--
-- If autoRotate (herdsman select) is ON -> find the pasture with the most
-- grass, switch activeIndex, and physically move the cows there. If OFF ->
-- fire a one-shot side notification telling the player to act, debounced
-- per-barn so we don't spam every frame.
function GrazingScheduler:_checkPastureStarvation()
    self._starvationNotified = self._starvationNotified or {}
    for barnIdx, barn in self.registry:iterBarns() do
        if (self.state[barnIdx] or "at_barn") == "at_pasture" then
            local pasture = self.registry:getActivePasture(barnIdx)
            if pasture ~= nil then
                local m = pasture.spec_husbandryMeadow
                local grassFill, grassCap = 0, 0
                if m ~= nil and type(m.fillLevels) == "table" then
                    for ft, v in pairs(m.fillLevels) do
                        if type(v) == "number" then grassFill = grassFill + v end
                        local c = (m.capacities and m.capacities[ft]) or m.capacity or 0
                        grassCap = grassCap + (c or 0)
                    end
                end
                local grassPct = (grassCap > 0) and (grassFill / grassCap) or 1.0

                -- Trough non-roughage ratio (same logic as the beef floor lerp).
                local food = pasture.spec_husbandryFood
                local roughage = getRoughageFillTypes()
                local sFill, sCap = 0, 0
                if food ~= nil and type(food.fillLevels) == "table" then
                    for ft, level in pairs(food.fillLevels) do
                        if not roughage[ft] and type(level) == "number" then
                            sFill = sFill + level
                            local c = (food.capacities and food.capacities[ft]) or food.capacity or 0
                            sCap = sCap + (c or 0)
                        end
                    end
                end
                local supplementPct = (sCap > 0) and (sFill / sCap) or 0

                local framework = rawget(_G, "g_pastureHerding")
                local gThr = (framework and framework.starvationGrassPct) or 0.10
                local sThr = (framework and framework.starvationSupplementPct) or 0.05
                if grassPct < gThr and supplementPct < sThr then
                    -- Starving. Decide how to react.
                    if self.registry:getAutoRotate(barnIdx) then
                        -- Herdsman ON: pick the best alternate pasture and
                        -- move the cows there. _pickPastureWithMostGrass
                        -- honours rest-days + quality, and its "hold"
                        -- gate is bypassed because the current pasture
                        -- grass is below threshold.
                        local newIdx = self:_pickPastureWithMostGrass(barnIdx)
                        local curIdx = barn.activeIndex
                        if self:_hasActiveSessionFor(barnIdx) or self:_barnQueued(barnIdx) then
                            -- A move for this barn is already walking OR queued;
                            -- don't start/re-queue another this tick (would yank
                            -- cows mid-route and re-notify in a loop).
                        elseif newIdx ~= nil and newIdx ~= curIdx and barn.pastures[newIdx] ~= nil then
                            self:_autoRotateMove(barnIdx, curIdx, newIdx, "starvation-rotate")
                        elseif self._starvationNotified[barnIdx] ~= "no-alternate" then
                            -- No better pasture exists — fall through to notify.
                            self._starvationNotified[barnIdx] = "no-alternate"
                            local framework = rawget(_G, "g_pastureHerding")
                            if framework ~= nil and type(framework.pushNotification) == "function" then
                                framework:pushNotification({1.0, 0.4, 0.2, 1.0},
                                    string.format("Barn#%d cows out of grass — no other pasture has feed. Add a paddock or feed manually.",
                                        barnIdx), 8000)
                            end
                        end
                    else
                        -- Herdsman OFF: just alert the player. Debounce so
                        -- we don't re-spam every frame; clear the flag once
                        -- the situation resolves (grass refills or trough
                        -- is topped up).
                        if self._starvationNotified[barnIdx] ~= "alert" then
                            self._starvationNotified[barnIdx] = "alert"
                            local framework = rawget(_G, "g_pastureHerding")
                            if framework ~= nil and type(framework.pushNotification) == "function" then
                                framework:pushNotification({1.0, 0.4, 0.2, 1.0},
                                    string.format("Barn#%d cows are out of grass on pasture #%d. Bring them in, load TMR, or rotate manually.",
                                        barnIdx, barn.activeIndex), 8000)
                            end
                            PastureHerdingLog.dev(
                                "GrazingScheduler: barn#%d STARVING on pasture #%d (manual mode — player alerted)",
                                barnIdx, barn.activeIndex)
                        end
                    end
                else
                    -- Situation resolved: refill happened or player took
                    -- action. Clear the debounce so a future starvation
                    -- event fires a fresh notification.
                    self._starvationNotified[barnIdx] = nil
                end
            end
        end
    end
end

-- Clamp the pasture productivity for ANY herd out grazing — dairy or beef.
-- Vanilla husbandry caps grass-only productivity at ~40% because meadow
-- grass only covers the roughage food group; silage/minerals/concentrate
-- stay at 0 unless the player (or the dairy pump) supplies them. Real
-- cattle on good pasture maintain decent body condition on grass alone,
-- so we lift both productionFactor (instant) and globalProductionFactor
-- (smoothed) up to the configured floor (default 0.65). The floor only
-- lifts — if the pump (dairy) or hand-fed concentrate (beef) takes the
-- pasture above 65%, vanilla's calc wins and we don't cap.
-- Called every frame from PastureHerding:update so vanilla's per-frame
-- recalc can't drag the bar back below the floor between hourly ticks.
function GrazingScheduler:_applyGrassProductivityFloor()
    local framework = rawget(_G, "g_pastureHerding")
    local dairyFloor = (framework and framework.grassProductivityFloor) or 1.00
    local beefGrassFloor = (framework and framework.beefGrazingFloor) or 0.80
    if dairyFloor <= 0 and beefGrassFloor <= 0 then return end

    -- Ratio of non-roughage food in the pasture trough vs its capacity.
    -- Used to lerp the beef-grazing floor between grass-only (low) and
    -- fully supplemented (1.00). Roughage fillTypes are excluded because
    -- the meadow already feeds those — supplementation = concentrate /
    -- TMR / minerals.
    local function pastureConcentrateRatio(pasture)
        local food = pasture and pasture.spec_husbandryFood
        if food == nil or type(food.fillLevels) ~= "table" then return 0 end
        local roughage = getRoughageFillTypes()
        local fill, cap = 0, 0
        for ft, level in pairs(food.fillLevels) do
            if not roughage[ft] and type(level) == "number" then
                fill = fill + level
                local c = (food.capacities and food.capacities[ft]) or food.capacity or 0
                cap = cap + (c or 0)
            end
        end
        if cap <= 0 then return 0 end
        return math.max(0, math.min(1, fill / cap))
    end

    for barnIdx, _ in self.registry:iterBarns() do
        if (self.state[barnIdx] or "at_barn") == "at_pasture" then
            local pasture = self.registry:getActivePasture(barnIdx)
            local pSpec = pasture and pasture.spec_husbandry
            if pSpec ~= nil then
                -- Floor by PRODUCTION type (auto), not schedule: a milk herd gets
                -- the full-milk floor even when grazed full-time; a meat herd gets
                -- the grass-only base, lerping up as the trough is topped with
                -- concentrate. Decoupled from day/continuous/housed.
                local floor
                if self.registry:getProductionType(barnIdx) == "meat" then
                    local ratio = pastureConcentrateRatio(pasture)
                    floor = beefGrassFloor + (1.0 - beefGrassFloor) * ratio
                else
                    floor = dairyFloor
                end
                if floor > 0 then
                    if type(pSpec.productionFactor) == "number"
                       and pSpec.productionFactor < floor then
                        pSpec.productionFactor = floor
                    end
                    if type(pSpec.globalProductionFactor) == "number"
                       and pSpec.globalProductionFactor < floor then
                        pSpec.globalProductionFactor = floor
                    end
                end
            end
        end
    end
end

-- Instant productivity recovery — so a herd that just moved doesn't suffer a
-- productivity DIP that then ramps back up. Two paths:
--   * Red Tape installed: force its "animal productivity recovery" setting to
--     Instant (value 0) once. Red Tape then keeps every husbandry pinned so
--     productivity tracks food immediately, with no gradual ramp.
--   * Red Tape absent: replicate it for OUR managed husbandries. The pasture
--     side is already pinned to its floor by _applyGrassProductivityFloor, so
--     here we cover the BARN side: while the herd is home, pin the barn
--     husbandry's globalProductionFactor to 1 so productivity is full/instant
--     right after a move instead of ramping up over hours.
function GrazingScheduler:_applyInstantProductivity()
    local rt = g_currentMission and g_currentMission.RedTape
    if rt ~= nil and type(rt.settings) == "table" and rt.settings.productivityRecovery ~= nil then
        if not self._redTapeInstantSet then
            self._redTapeInstantSet = true
            if rt.settings.productivityRecovery ~= 0 then
                rt.settings.productivityRecovery = 0
                PastureHerdingLog.info(
                    "GrazingScheduler: Red Tape detected - set its animal productivity recovery to Instant")
            end
        end
        return
    end
    -- Red Tape absent: barn-side instant recovery.
    for barnIdx, barn in self.registry:iterBarns() do
        if (self.state[barnIdx] or "at_barn") == "at_barn" and barn.placeable ~= nil then
            local spec = barn.placeable.spec_husbandry
            if spec ~= nil and type(spec.globalProductionFactor) == "number"
               and spec.globalProductionFactor < 1 then
                spec.globalProductionFactor = 1
            end
        end
    end
end

-- Find a child node by name under rootNode, recursive. Caches nothing here
-- (callers cache the result on the placeable). Defensive: returns nil if
-- the engine accessors aren't present.
local function findNodeByName(rootNode, name)
    if rootNode == nil or rootNode == 0 then return nil end
    if type(getName) ~= "function" or type(getNumOfChildren) ~= "function"
       or type(getChildAt) ~= "function" then return nil end
    if getName(rootNode) == name then return rootNode end
    local n = getNumOfChildren(rootNode) or 0
    for i = 0, n - 1 do
        local child = getChildAt(rootNode, i)
        local found = findNodeByName(child, name)
        if found ~= nil then return found end
    end
    return nil
end

-- Toggle the visibility of the "food" i3d node on each pasture placeable so
-- the visual matches the Feed Trough toggle. When the trough is ENABLED
-- (beef default, or a player-enabled dairy pasture) the food-pile mesh is
-- shown so the player knows to hand-load it. When DISABLED (dairy default =
-- pure graze) the mesh is hidden. Cached per placeable on first lookup; the
-- i3d-node search only walks the tree once per session per pasture.
-- Recursive setVisibility helper. FS25's setVisibility on a TransformGroup
-- doesn't always propagate to child Shapes in the renderer — each Shape
-- carries its own visibility flag. Walking the tree flips them all.
local function setVisibilityRecursive(node, visible, logLines, depth)
    if node == nil or node == 0 or type(setVisibility) ~= "function" then return end
    pcall(function() setVisibility(node, visible) end)
    if logLines ~= nil then
        local nm = (type(getName) == "function") and getName(node) or "?"
        logLines[#logLines + 1] = string.format("%s%s -> visible=%s",
            string.rep("  ", depth or 0), nm, tostring(visible))
    end
    if type(getNumOfChildren) ~= "function" or type(getChildAt) ~= "function" then return end
    local n = getNumOfChildren(node) or 0
    for i = 0, n - 1 do
        setVisibilityRecursive(getChildAt(node, i), visible, logLines, (depth or 0) + 1)
    end
end


-- Move any solid manure and liquid slurry produced on each pasture back
-- to the home barn's storage. The pasture placeable's husbandry spec
-- accumulates manure as cows graze; without this helper the player has to
-- drive an empty tanker out to every pasture to collect tiny amounts. With
-- this helper the slurry pit / manure pile stays centralised at the barn.
-- Produce (milk + slurry) that accumulates in a pasture's husbandry storage
-- while the herd grazes is drained back to the HOME barn's storage each frame,
-- so the player doesn't have to drive a tanker to every paddock. Uses the
-- husbandry fill API (get/free/remove/add) — the produce lives in the husbandry
-- STORAGE, not the spec's own fillLevels. Which fill types to move is read from
-- the pasture's output specs, so a paddock without them is a no-op.
function GrazingScheduler:_pumpManureToBarn()
    for barnIdx, barn in self.registry:iterBarns() do
        local bp = barn.placeable
        if bp ~= nil and type(bp.getHusbandryFreeCapacity) == "function"
           and type(bp.addHusbandryFillLevelFromTool) == "function" then
            local farmId = (type(bp.getOwnerFarmId) == "function" and bp:getOwnerFarmId())
                           or bp.ownerFarmId or 1
            for _, p in self:_iterProcessable(barnIdx, barn) do
                if p ~= nil and type(p.getHusbandryFillLevel) == "function"
                   and type(p.removeHusbandryFillLevel) == "function" then
                    -- Which produce fill types does this pasture actually have?
                    local fts = {}
                    local milkSpec = p.spec_husbandryMilk
                    if milkSpec ~= nil and type(milkSpec.fillTypes) == "table" then
                        for _, ft in ipairs(milkSpec.fillTypes) do fts[ft] = true end
                    end
                    local lmSpec = p.spec_husbandryLiquidManure
                    if lmSpec ~= nil and lmSpec.fillType ~= nil then fts[lmSpec.fillType] = true end
                    local manSpec = p.spec_husbandryManure
                    if manSpec ~= nil and manSpec.fillType ~= nil then fts[manSpec.fillType] = true end
                    for ft, _ in pairs(fts) do
                        local level = p:getHusbandryFillLevel(ft) or 0
                        if level > 0 then
                            local room = bp:getHusbandryFreeCapacity(ft) or 0
                            local amount = math.min(level, room)  -- don't overflow the barn
                            if amount > 0 then
                                -- Remove from the pasture; measure what actually left
                                -- (removeHusbandryFillLevel's return varies).
                                pcall(function() p:removeHusbandryFillLevel(farmId, amount, ft) end)
                                local after = p:getHusbandryFillLevel(ft) or 0
                                local moved = math.max(0, level - after)
                                if moved > 0 then
                                    pcall(function()
                                        bp:addHusbandryFillLevelFromTool(farmId, moved, ft, nil, nil, nil)
                                    end)
                                end
                            end
                        end
                    end
                end
            end
        end
    end
end

-- Cap meadow regrowth at framework.maxMeadowRegrowthPerDay × capacity per
-- in-game day. Vanilla husbandry regrows the meadow to 100% overnight on
-- the day-change tick, which defeats the point of pasture rotation: a
-- pasture you grazed empty yesterday is full again today, so quality math
-- and rest-days lose their meaning. We track each pasture's previous fill
-- level frame-by-frame; when vanilla adds grass we let through at most
-- one day's budget, then clamp the rest. Cow consumption (delta < 0) is
-- passed through unchanged.
function GrazingScheduler:_throttleMeadowRegrowth()
    local env = g_currentMission and g_currentMission.environment
    local today = env and env.currentDay
    local nowHour = env and env.currentHour
    if type(today) ~= "number" or type(nowHour) ~= "number" then return end
    local framework = rawget(_G, "g_pastureHerding")
    local maxPerDay = (framework and framework.maxMeadowRegrowthPerDay) or 0.08
    if maxPerDay <= 0 or maxPerDay >= 1 then return end

    local fertilizerMul = (framework and framework.fertilizerRegrowthMultiplier) or 2.0
    for barnIdx, barn in self.registry:iterBarns() do
        for pIdx, pasture in self:_iterProcessable(barnIdx, barn) do
            local m = pasture and pasture.spec_husbandryMeadow
            if m ~= nil and type(m.fillLevels) == "table" then
                pasture._phMeadowState = pasture._phMeadowState or {}
                local st = pasture._phMeadowState

                -- Grass does NOT regrow on a pasture while cows are grazing it
                -- (rotational grazing: the occupied pasture depletes, rested
                -- pastures recover). Without this gate, regrowth refills the
                -- meadow as fast as the herd eats it and the pasture never
                -- depletes -- it just pins at capacity.
                local cowCount = 0
                do
                    local aspec = pasture.spec_husbandryAnimals
                    local cs = aspec and aspec.clusterSystem
                    local alist = cs and ((cs.getAnimals and cs:getAnimals()) or cs.animals) or {}
                    for _, c in pairs(alist) do
                        if type(c) == "table" then cowCount = cowCount + (c.numAnimals or 1) end
                    end
                end

                local fertilized = self.registry:isPastureFertilized(barnIdx, pIdx)
                local pastureMaxPerDay = fertilized and (maxPerDay * fertilizerMul) or maxPerDay
                -- Effective quality scales regrowth (stored soil quality x live PF
                -- pH factor). 0.1..1.0, so the worst still grows at 10% of normal
                -- (never zero — always recoverable with rest + fertilizer + liming).
                local quality = self.registry:getEffectiveQuality(barnIdx, pIdx) or 1.0
                pastureMaxPerDay = pastureMaxPerDay * quality
                -- Hourly drip: split the daily budget into 24 hourly slices.
                -- Each in-game hour rollover we add cap × maxPerDay / 24,
                -- so the bar climbs gradually across the day instead of
                -- one big jump at midnight. Capped at meadow capacity.
                local hourlySlice = pastureMaxPerDay / 24
                for ft, level in pairs(m.fillLevels) do
                    local entry = st[ft]
                    local cap = (m.capacities and m.capacities[ft]) or m.capacity or level
                    if entry == nil then
                        entry = { day = today, hour = nowHour, lastLevel = level }
                        st[ft] = entry
                    end
                    -- Hour rollover detection. Handles 23 -> 0 (midnight)
                    -- naturally because the inequality test doesn't care
                    -- about ordering.
                    if entry.hour ~= nowHour then
                        -- Only regrow a RESTED pasture (no cows). A grazed
                        -- pasture depletes; regrowth resumes once it's vacated.
                        if cowCount == 0 then
                            local budget = (cap or 0) * hourlySlice
                            local target = math.min(cap or level, level + budget)
                            if target > level then
                                m.fillLevels[ft] = target
                                PastureHerdingLog.dev(
                                    "GrazingScheduler:regrowth drip ft=%s %.0f -> %.0f (cap=%.0f, +%.0f/hr, %.0f%% per day total)",
                                    tostring(ft), level, target, cap or 0,
                                    target - level, pastureMaxPerDay * 100)
                                level = target
                            end
                        end
                        -- Advance the hour marker even when grazed so vacating
                        -- the pasture doesn't trigger a catch-up regrowth burst.
                        entry.hour = nowHour
                        entry.day = today
                    end
                    entry.lastLevel = level
                end
            end
        end
    end
end

-- Clamp every cluster animal's health up to the configured floor for both
-- registered barns AND their pastures. Catches the single-tick health crash
-- vanilla applies on arrival when destination food / water / productivity
-- hasn't stabilised yet. Without the barn-side clamp, cows returning from
-- pasture at sunset could hit 0 health (and die) when the barn's water /
-- food had drained during the day.
-- Charge the player a fixed monthly wage for the "herdsman" (auto-schedule
-- + auto-rotate). Triggered when env.currentPeriod ticks over, once per
-- farm. Multiple toggles or barns on the same farm do not multiply the
-- fee — it's a single flat fee per farm per month. Charges go negative
-- if the farm can't afford it (same model FS25 uses for fuel / seed).
function GrazingScheduler:_chargeHerdsmanWage()
    local env = g_currentMission and g_currentMission.environment
    local period = env and env.currentPeriod
    if type(period) ~= "number" then return end

    self._lastChargedPeriod = self._lastChargedPeriod or {}

    -- Build set of farmIds that have at least one barn with either toggle
    -- ON. A farm with everything OFF gets no charge this Period.
    -- Both "Herdsman grazing" (autoSchedule — daily gate open/close) and
    -- "Herdsman select" (autoRotate — pasture decision) count as herdsman
    -- labour. Either toggle ON anywhere on the farm = full monthly wage,
    -- not multiplied by toggle count or barn count.
    local farmsNeedingHerdsman = {}
    for barnIdx, barn in self.registry:iterBarns() do
        if self.registry:getAutoSchedule(barnIdx)
           or self.registry:getAutoRotate(barnIdx) then
            local farmId = (barn.placeable and barn.placeable.ownerFarmId) or 1
            farmsNeedingHerdsman[farmId] = true
        end
    end

    local framework = rawget(_G, "g_pastureHerding")
    local cost = (framework and framework.herdsmanMonthlyCost) or 2366
    if cost <= 0 then return end

    -- MoneyType is a base-game engine global: reach it via PLAIN access
    -- (rawget bypasses the sandbox __index and returns nil, leaving addMoney
    -- with a nil category — the wage then miscategorizes or fails).
    local mtRoot = MoneyType
    local moneyType = mtRoot and (mtRoot.HIRED_WORKER or mtRoot.OTHER) or nil

    for farmId in pairs(farmsNeedingHerdsman) do
        local last = self._lastChargedPeriod[farmId]
        if last == nil then
            -- First time we've seen this farm with a toggle ON. Seed the
            -- map to the current Period so the partial month they're in
            -- right now is FREE. The first real charge fires at the next
            -- Period rollover — i.e. billed in arrears at month-end for
            -- the full month they used the herdsman.
            self._lastChargedPeriod[farmId] = period
        elseif last ~= period then
            self._lastChargedPeriod[farmId] = period
            -- Pause the wage during the off-season (winter for default
            -- settings: November through February with seasonStart=3,
            -- seasonEnd=10). The "billed" month is `last` — the period
            -- that just ended — not the current Period. Skip charging if
            -- the herd was sitting in the barn for the whole prior month.
            local sStart = (framework and framework.grazingSeasonStart) or 3
            local sEnd = (framework and framework.grazingSeasonEnd) or 10
            local billedFor = last
            local inSeason = billedFor >= sStart and billedFor <= sEnd
            if not inSeason then
                PastureHerdingLog.dev(
                    "GrazingScheduler: herdsman wage SKIPPED period=%d farm=%d (billed month %d is off-season)",
                    period, farmId, billedFor)
                -- Skip charge + notification but still advance the marker
                -- (already done above) so the in-season months resume
                -- billing one Period at a time, not all at once.
                -- (fall through to next farm)
            elseif g_currentMission ~= nil and type(g_currentMission.addMoney) == "function" then
                pcall(function()
                    g_currentMission:addMoney(-cost, farmId, moneyType, true, true)
                end)
            end
            if inSeason then
            -- Side notification so the player sees WHY the balance dropped
            -- — addMoney's own money-change popup just shows the number.
            -- Use the player-chosen currency symbol from g_i18n so the
            -- display matches the rest of the in-game economy (EUR / USD
            -- / GBP per the save's locale settings).
            local function fmtMoney(amount)
                if g_i18n ~= nil and type(g_i18n.formatMoney) == "function" then
                    return g_i18n:formatMoney(amount, 0, true, true)
                end
                return string.format("%d", amount)
            end
            if framework ~= nil and type(framework.pushNotification) == "function" then
                local msg = string.format("Herdsman wage paid: %s", fmtMoney(cost))
                framework:pushNotification({1.0, 0.85, 0.2, 1.0}, msg, 5000)
            end
            PastureHerdingLog.dev(
                "GrazingScheduler: herdsman wage period=%d farm=%d charged %s",
                period, farmId, fmtMoney(cost))
            end -- end of inSeason block (notification + log)
        end
    end
end

-- Decide whether a BARN is starving. Animals at the barn rely entirely on
-- the food trough (spec_husbandryFood) — there's no meadow fallback the
-- way pastures have. So the predicate is simpler: total trough fill below
-- the starvation supplement threshold. We mirror the pasture supplement
-- threshold (default 5%) so the model is symmetric.
-- Pre-flight grass check for the sunrise transition. Computes the herd's
-- total roughage demand for the given `hoursNeeded` window and returns the
-- index of a pasture that can sustain them (preferring the currently-active
-- one if it qualifies). Returns nil if NO pasture under the barn has enough
-- grass — the caller should then keep cows in the barn and notify the player.
--
-- Returns: viableIdx (or nil), totalNeeded, currentPastureFill (for log/notify)
function GrazingScheduler:_findPastureWithEnoughGrass(barnIdx, hoursNeeded)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil then return nil, 0, 0 end

    -- Herd's hourly food demand, RLRM-aware. Same source the menu uses.
    local herdHr = GrazingScheduler.sumFoodInputAt(barn.placeable)
    for _, p in ipairs(barn.pastures) do
        herdHr = herdHr + GrazingScheduler.sumFoodInputAt(p)
    end
    if herdHr <= 0 then
        -- No animals / no rate data: defer to current behavior (send to
        -- active pasture; no override).
        return barn.activeIndex, 0, 0
    end
    local needed = herdHr * hoursNeeded

    local function meadowFill(p)
        local m = p and p.spec_husbandryMeadow
        if m == nil or type(m.fillLevels) ~= "table" then return 0 end
        local total = 0
        for _, v in pairs(m.fillLevels) do
            if type(v) == "number" then total = total + v end
        end
        return total
    end

    local curIdx = barn.activeIndex
    local curFill = (curIdx and barn.pastures[curIdx]) and meadowFill(barn.pastures[curIdx]) or 0

    -- Prefer the current active pasture if it has enough. Avoids unnecessary
    -- switching. (Skip if it's mid-reseed — it's locked out of grazing.)
    if curIdx and curFill >= needed and not self.registry:isRenewing(barnIdx, curIdx) then
        return curIdx, needed, curFill
    end

    -- Search siblings for the one with the most grass that meets the need.
    -- Skip any paddock another barn's herd is on so we never route this herd
    -- onto an occupied shared pasture (wouldMixHerds is empty-safe).
    local bestIdx, bestFill = nil, 0
    for i, p in ipairs(barn.pastures) do
        if i ~= curIdx and not self:wouldMixHerds(barnIdx, p)
           and not self.registry:isRenewing(barnIdx, i) then
            local fill = meadowFill(p)
            if fill >= needed and fill > bestFill then
                bestFill = fill
                bestIdx = i
            end
        end
    end

    if bestIdx ~= nil then
        return bestIdx, needed, curFill
    end
    return nil, needed, curFill
end

-- Decide whether a pasture is starving. Used by _checkPastureStarvation
-- to fire side-notifications and trigger auto-rotate when grass and
-- supplement are both depleted. Grass < starvationGrassPct AND
-- supplement < starvationSupplementPct.
function GrazingScheduler:_isPastureStarving(pasture)
    if pasture == nil then return false end
    local framework = rawget(_G, "g_pastureHerding")
    local gThr = (framework and framework.starvationGrassPct) or 0.10
    local sThr = (framework and framework.starvationSupplementPct) or 0.05

    local m = pasture.spec_husbandryMeadow
    local grassFill, grassCap = 0, 0
    if m ~= nil and type(m.fillLevels) == "table" then
        for ft, v in pairs(m.fillLevels) do
            if type(v) == "number" then grassFill = grassFill + v end
            local c = (m.capacities and m.capacities[ft]) or m.capacity or 0
            grassCap = grassCap + (c or 0)
        end
    end
    local grassPct = (grassCap > 0) and (grassFill / grassCap) or 1.0

    -- Non-roughage supplement fraction in the trough (same logic as the
    -- beef floor lerp and the starvation rotate predicate).
    local food = pasture.spec_husbandryFood
    local roughage = getRoughageFillTypes()
    local sFill, sCap = 0, 0
    if food ~= nil and type(food.fillLevels) == "table" then
        for ft, level in pairs(food.fillLevels) do
            if not roughage[ft] and type(level) == "number" then
                sFill = sFill + level
                local c = (food.capacities and food.capacities[ft]) or food.capacity or 0
                sCap = sCap + (c or 0)
            end
        end
    end
    local supplementPct = (sCap > 0) and (sFill / sCap) or 0

    return grassPct < gThr and supplementPct < sThr
end

function GrazingScheduler:_applyPastureFoodNodeVisibility()
    for barnIdx, barn in self.registry:iterBarns() do
        -- Feed Trough is a per-BARN toggle, so every pasture on that barn
        -- flips together. Show the food-pile mesh when the trough is ENABLED
        -- (there's a trough to hand-fill / supplement at pasture), hide it
        -- when it's pure-graze.
        local troughOn = self.registry:getTroughEnabled(barnIdx)
        for pIdx, pasture in self:_iterProcessable(barnIdx, barn) do
            if pasture ~= nil and pasture.rootNode ~= nil and pasture.rootNode ~= 0 then
                -- Cache the food-pile mesh AND the food marker icon (markerFood)
                -- once each. Both are hidden/shown together with the trough.
                if pasture._phFoodNode == nil then
                    pasture._phFoodNode = findNodeByName(pasture.rootNode, "food") or false
                    PastureHerdingLog.dev(
                        "GrazingScheduler: food-node lookup on barn#%d pasture#%d — rootNode=%s found=%s",
                        barnIdx, pIdx, tostring(pasture.rootNode),
                        tostring(pasture._phFoodNode))
                end
                if pasture._phMarkerFoodNode == nil then
                    pasture._phMarkerFoodNode = findNodeByName(pasture.rootNode, "markerFood") or false
                    PastureHerdingLog.dev(
                        "GrazingScheduler: markerFood lookup on barn#%d pasture#%d — found=%s",
                        barnIdx, pIdx, tostring(pasture._phMarkerFoodNode))
                end
                -- Flip the food mesh + the food marker icon together, but only
                -- when the trough state actually changes (or on first apply).
                -- troughEnabled changes only on a player toggle, so this skips
                -- the recursive setVisibility walk on ~every frame.
                if pasture._phFoodTroughLast ~= troughOn and type(setVisibility) == "function" then
                    pasture._phFoodTroughLast = troughOn
                    PastureHerdingLog.dev(
                        "GrazingScheduler: barn#%d pasture#%d food+marker visibility=%s (troughEnabled=%s herdType=%s)",
                        barnIdx, pIdx, tostring(troughOn), tostring(troughOn),
                        self.registry:getHerdType(barnIdx))
                    local foodNode = pasture._phFoodNode
                    if foodNode and foodNode ~= false then
                        local logLines = PastureHerdingLog.devMode and {} or nil
                        setVisibilityRecursive(foodNode, troughOn, logLines, 0)
                        if logLines ~= nil then
                            PastureHerdingLog.dev("GrazingScheduler: food-node tree dump for barn#%d pasture#%d:",
                                barnIdx, pIdx)
                            for _, line in ipairs(logLines) do
                                PastureHerdingLog.dev("  %s", line)
                            end
                        end
                    end
                    -- Hide/show the food MARKER icon (markerFood) with the trough.
                    local markerNode = pasture._phMarkerFoodNode
                    if markerNode and markerNode ~= false then
                        setVisibilityRecursive(markerNode, troughOn, nil, 0)
                    end
                end
            end     -- closes: if pasture ~= nil
        end         -- closes: for pIdx
    end             -- closes: for barnIdx
end                 -- closes: function

-- Cap each active pasture's food-trough capacity at roughly one in-game
-- day of consumption so the player has to refill at least daily. Without
-- this, a player can drive out once and stockpile a week's feed in one
-- trip — defeats the point of the "drive feed to pasture" mechanic.
--
-- One day = `litersPerHour * pastureFoodCapacityHours` (default 24, plus a
-- small cushion so cows don't crash to empty at midnight). litersPerHour
-- is FS25's per-herd consumption rate, so the cap scales with animal
-- count automatically.
-- Clamp the active pasture's animal-population cap to the player's per-
-- pasture choice (set via the Grazing tab picker). Like the food trough
-- cap, this snapshots the original maxNumAnimals once so we can scale
-- back up if the player unsets the cap.
function GrazingScheduler:_applyPastureAnimalCapacityCap()
    for barnIdx, barn in self.registry:iterBarns() do
        for pIdx, pasture in self:_iterProcessable(barnIdx, barn) do
            -- Auto-water override: nil = leave alone, true/false force the
            -- spec field. Snapshot the original on first touch so we can
            -- restore it cleanly if the player picks "Default" later.
            local aw = self.registry:getPastureAutoWater(barnIdx, pIdx)
            local waterSpec = pasture and pasture.spec_husbandryWater
            if waterSpec ~= nil then
                if waterSpec._phOriginalAutoSupply == nil then
                    waterSpec._phOriginalAutoSupply = waterSpec.automaticWaterSupply == true
                end
                local target
                if aw == nil then
                    target = waterSpec._phOriginalAutoSupply
                else
                    target = aw
                end
                if waterSpec.automaticWaterSupply ~= target then
                    waterSpec.automaticWaterSupply = target
                end
                -- waterPlane mesh: when auto-supply is on, push it up to
                -- maxY so the trough looks full. When off, vanilla animates
                -- it from the fillLevel and we don't touch it. Try the spec
                -- field first; fall back to a by-name lookup if the spec
                -- doesn't expose waterPlanes (custom i3d builds).
                if pasture._phWaterPlane == nil then
                    local node, maxY, originalY
                    local planes = waterSpec.waterPlanes or waterSpec.waterPlane
                    if type(planes) == "table" then
                        if planes.node then planes = { planes } end
                        for _, wp in ipairs(planes) do
                            if wp and wp.node and wp.maxY then
                                node = wp.node; maxY = wp.maxY; break
                            end
                        end
                    end
                    if node == nil and pasture.rootNode then
                        node = findNodeByName(pasture.rootNode, "waterPlane")
                        maxY = 0.177  -- user-provided default for cowBarnSmall.i3d
                    end
                    if node and type(getTranslation) == "function" then
                        local _, oy = getTranslation(node)
                        originalY = oy
                    end
                    pasture._phWaterPlane = node and
                        { node = node, maxY = maxY, originalY = originalY } or false
                end
                if pasture._phWaterPlane and pasture._phWaterPlane ~= false
                   and type(setTranslation) == "function" and type(getTranslation) == "function" then
                    local wp = pasture._phWaterPlane
                    -- The node can be destroyed out from under us if the placeable
                    -- was sold this frame (before onDelete unregisters it). Guard
                    -- against a dead entity id and a nil translation, or
                    -- getTranslation raises "Unknown entity id" and curY-desiredY
                    -- crashes on nil every frame.
                    local alive = wp.node ~= nil
                        and (entityExists == nil or entityExists(wp.node))
                    if alive then
                        local x, curY, z = getTranslation(wp.node)
                        local desiredY = (target == true) and wp.maxY or wp.originalY
                        if curY ~= nil and desiredY ~= nil and math.abs(curY - desiredY) > 0.001 then
                            pcall(function() setTranslation(wp.node, x, desiredY, z) end)
                        end
                    end
                end
            end
            local cap = self.registry:getPastureCapacity(barnIdx, pIdx)
            local spec = pasture and pasture.spec_husbandryAnimals
            if spec ~= nil and type(spec.maxNumAnimals) == "number" then
                if spec._phOriginalMaxNumAnimals == nil then
                    spec._phOriginalMaxNumAnimals = spec.maxNumAnimals
                end
                -- Cap is a true OVERRIDE: any picker value (including ones
                -- above the vanilla cap) replaces maxNumAnimals outright.
                -- nil cap restores the original. Also keep configMaxNumAnimals
                -- in sync since some RLM paths read that field instead.
                local desired
                if type(cap) == "number" and cap > 0 then
                    desired = cap
                else
                    desired = spec._phOriginalMaxNumAnimals
                end
                if spec.maxNumAnimals ~= desired then
                    spec.maxNumAnimals = desired
                end
                if type(spec.configMaxNumAnimals) == "number"
                   and spec.configMaxNumAnimals ~= desired then
                    spec.configMaxNumAnimals = desired
                end
            end
        end
    end
end

function GrazingScheduler:_applyPastureFoodCapacityCap()
    local framework = rawget(_G, "g_pastureHerding")
    local hours = (framework and framework.pastureFoodCapacityHours) or 24
    if hours <= 0 then return end
    for barnIdx, _ in self.registry:iterBarns() do
        local pasture = self.registry:getActivePasture(barnIdx)
        local foodSpec = pasture and pasture.spec_husbandryFood
        if foodSpec ~= nil and type(foodSpec.litersPerHour) == "number"
           and foodSpec.litersPerHour > 0 then
            -- Snapshot the original capacity once so we can restore it if
            -- the pasture is ever unregistered (and so we don't compound
            -- our own previous edit when litersPerHour fluctuates).
            if foodSpec._phOriginalCapacity == nil then
                foodSpec._phOriginalCapacity = foodSpec.capacity or 0
            end
            local desired = math.ceil(foodSpec.litersPerHour * hours)
            -- Cap is min(desired, originalCapacity) so we never raise a
            -- trough that's already smaller than 1 day's consumption.
            local cap = desired
            if foodSpec._phOriginalCapacity and foodSpec._phOriginalCapacity > 0 then
                cap = math.min(cap, foodSpec._phOriginalCapacity)
            end
            if foodSpec.capacity ~= cap then
                foodSpec.capacity = cap
            end
        end
    end
end

-- After persistence load, decide where every barn's cows *should* be right
-- now based on the in-game clock and gates (calendar, weather, autoSchedule,
-- per-barn sunrise/sunset). If reality disagrees, physically correct it:
--   - Cows at pasture but window has closed (past sunset, off-season, bad
--     weather, autoSchedule off) -> bring them in.
--   - Cows at barn but window is open AND there's at least one in-game hour
--     left before sunset -> send them out.
-- Otherwise leave them where they are. Called once after GrazingPersistence
-- :load by PastureHerding:_tryLoadPersistedGrazing.
-- Truth-sync helper: count animals at the barn vs each pasture and update
-- self.state[barnIdx] to match reality. Doesn't MOVE anything — the
-- reconcile-on-load path is where physical correction happens; this is for
-- catching state drift caused by the player (e.g. driving cattle into the
-- shed by hand, vehicle-transport mods, RLM sell/buy). Mixed (animals on
-- both sides) is treated as "wherever the majority is". Returns the
-- resolved state for the caller's convenience.
function GrazingScheduler:reconcileStateFromReality(barnIdx)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil then return self.state[barnIdx] end
    -- Don't fight our own in-flight walk: while a WalkSession for this barn is
    -- running the animals are deliberately HELD at the source, so a recount
    -- would misread the herd's location (and wrongly flip state/beef-phase).
    -- The session owns the state until it finishes.
    if self:_hasActiveSessionFor(barnIdx) then return self.state[barnIdx] end
    local function countAt(p)
        if p == nil then return 0 end
        local s = p.spec_husbandryAnimals
        if s == nil or s.clusterSystem == nil then return 0 end
        local list = (s.clusterSystem.getAnimals and s.clusterSystem:getAnimals())
            or s.clusterSystem.animals or {}
        local n = 0
        for _, a in pairs(list) do if type(a) == "table" then n = n + (a.numAnimals or 1) end end
        return n
    end
    local barnN = countAt(barn.placeable)
    -- Sum pasture animals AND track which pasture holds the most — that's where
    -- the herd actually is, even if RLRM (or the player) moved them somewhere
    -- other than our recorded activeIndex.
    local pastureN = 0
    local rawPastureN = 0   -- UNGUARDED total across this barn's pastures
    local bestPIdx, bestPN = nil, 0
    for pIdx, p in ipairs(barn.pastures) do
        local c = countAt(p)
        rawPastureN = rawPastureN + c
        -- Don't attribute ANOTHER barn's herd to this one. A pasture shared with
        -- (or physically holding) a different barn's grazing herd must not count
        -- as THIS barn being out there — otherwise, opening a second barn whose
        -- list contains that same pasture would see the first barn's animals,
        -- flip this barn to "at_pasture", and force its active pasture to match
        -- the first barn's (two barns wrongly locked onto one pasture).
        if c > 0 then
            local occ = self:_occupantBarnOf(p)
            if occ ~= nil and occ ~= barnIdx then c = 0 end
            -- Ignore a pasture that's the source/dest of an in-flight walk: those
            -- animals are mid-transfer (typically ANOTHER barn's herd being held
            -- there during its walk), not a settled location for THIS barn. This
            -- stops the follow-herd block below from re-pointing our active
            -- pasture at a transient herd.
            if self:_pastureInActiveTransit(p) then c = 0 end
        end
        pastureN = pastureN + c
        if c > bestPN then bestPN = c; bestPIdx = pIdx end
    end
    local resolved
    if barnN == 0 and pastureN == 0 then
        resolved = self.state[barnIdx] or "at_barn"  -- no animals — keep cache
    elseif pastureN > barnN then
        resolved = "at_pasture"
    else
        resolved = "at_barn"
    end
    -- Trust a move we just commanded (transitionOne) until reality settles to
    -- match it. An instant pathless move dispatches async, so a reconcile a
    -- frame later can still see animals at the source and wrongly flip state /
    -- beef phase. Clear the pending flag the moment reality agrees.
    if self._pendingMove ~= nil and self._pendingMove[barnIdx] ~= nil then
        if resolved == self._pendingMove[barnIdx] then
            self._pendingMove[barnIdx] = nil            -- settled -> proceed
        else
            return self._pendingMove[barnIdx]           -- not settled -> don't override
        end
    end
    if self.state[barnIdx] ~= resolved then
        local prev = self.state[barnIdx]
        PastureHerdingLog.dev(
            "GrazingScheduler:reconcileStateFromReality barn#%d state %s -> %s (barnN=%d pastureN=%d)",
            barnIdx, tostring(prev), resolved, barnN, pastureN)
        self.state[barnIdx] = resolved
        -- Beef herds under the HERDSMAN (auto mode): a player-initiated move is
        -- treated as an implicit instruction, so keep the advisory phase /
        -- manual-out override consistent with where the herd actually is. In
        -- MANUAL mode the phase is a pure preference the player sets by hand and
        -- must never auto-change on a move — so this whole block is gated on the
        -- herdsman being enabled.
        if self.registry:getHerdType(barnIdx) == "beef"
           and self.registry:getAutoSchedule(barnIdx) then
            if prev == "at_pasture" and resolved == "at_barn" then
                -- Only treat this as a deliberate move-home if the herd is
                -- GENUINELY fully home — no animals on ANY of this barn's pastures
                -- by a RAW (unguarded) count. The guarded pastureN can read 0 for
                -- a shared / in-transit pasture that still physically holds our own
                -- herd, and flipping the phase on that transient miscount is what
                -- made beef mode flap grazing <-> finishing on its own.
                if rawPastureN == 0 then
                    -- Moved home by hand: drop any manual-out override and, if
                    -- still in "grazing", flip to "finishing" so _tickBeef keeps
                    -- them in.
                    if self._beefManualOut then self._beefManualOut[barnIdx] = nil end
                    if self.registry:getBeefPhase(barnIdx) == "grazing" then
                        PastureHerdingLog.dev(
                            "GrazingScheduler:reconcileStateFromReality barn#%d beef phase auto-flipped grazing -> finishing (herd fully home)",
                            barnIdx)
                        self.registry:setBeefPhase(barnIdx, "finishing")
                    end
                else
                    PastureHerdingLog.dev(
                        "GrazingScheduler:reconcileStateFromReality barn#%d beef NOT flipping phase — %d animal(s) still on pasture (guarded count hid them)",
                        barnIdx, rawPastureN)
                end
            elseif prev == "at_barn" and resolved == "at_pasture" then
                -- Put out to pasture by hand (e.g. RLRM) — set a manual-out
                -- override so the season/weather gate won't immediately shed
                -- them again (they stay until the player brings them in).
                self._beefManualOut = self._beefManualOut or {}
                self._beefManualOut[barnIdx] = true
                PastureHerdingLog.dev(
                    "GrazingScheduler:reconcileStateFromReality barn#%d beef manual-out override SET (player moved herd to pasture)",
                    barnIdx)
            end
        end
    end
    -- Follow the herd if it's on a DIFFERENT pasture than we recorded (e.g. the
    -- player used RLRM to transport them straight to another paddock). Keeps
    -- activeIndex — and therefore paths / occupancy / land bookkeeping — pointed
    -- at where the animals really are. Runs even when the at-barn/at-pasture
    -- flag didn't change, since a pasture->pasture RLRM move doesn't flip it.
    if resolved == "at_pasture" and bestPIdx ~= nil and (barn.activeIndex or 0) ~= bestPIdx then
        PastureHerdingLog.dev(
            "GrazingScheduler:reconcileStateFromReality barn#%d activeIndex %s -> %d (herd found there — external/RLRM move)",
            barnIdx, tostring(barn.activeIndex), bestPIdx)
        self.registry:setActivePasture(barnIdx, bestPIdx)
    end
    return resolved
end

function GrazingScheduler:reconcileOnLoad()
    local framework = rawget(_G, "g_pastureHerding")
    local hour = getCurrentHour()
    if hour == nil then return end
    -- Seed lastHour so the regular tick() loop doesn't re-fire the same
    -- transition we just decided this hour.
    self.lastHour = hour

    local inSeason = (framework == nil) or (type(framework.isGrazingMonth) ~= "function")
                     or framework:isGrazingMonth()
    local badWeather = (framework ~= nil and framework.gateOnWeather == true
                        and type(framework.isBadWeather) == "function"
                        and framework:isBadWeather())

    local function countAt(p)
        if p == nil then return 0 end
        local s = p.spec_husbandryAnimals
        if s == nil or s.clusterSystem == nil then return 0 end
        local list = (s.clusterSystem.getAnimals and s.clusterSystem:getAnimals())
            or s.clusterSystem.animals or {}
        local n = 0
        for _, a in pairs(list) do if type(a) == "table" then n = n + (a.numAnimals or 1) end end
        return n
    end

    for barnIdx, barn in self.registry:iterBarns() do
        local sunriseH = self.registry:getBarnSunrise(barnIdx) or self.SUNRISE_HOUR
        local sunsetH  = self.registry:getBarnSunset(barnIdx)  or self.SUNSET_HOUR
        local autoOn = self.registry:getAutoSchedule(barnIdx)
        local hasActive = (barn.activeIndex or 0) > 0 and barn.pastures[barn.activeIndex] ~= nil
        local herdType = self.registry:getHerdType(barnIdx)

        -- Dairy gates on the in-day window; beef gates on the player-set
        -- phase + season only. Beef in "finishing" phase forces shed even
        -- mid-season.
        local shouldBeOut
        if herdType == "beef" then
            local phase = self.registry:getBeefPhase(barnIdx)
            -- Normal: graze when auto + in-season + fine weather. Override: a
            -- persisted manual-out flag (player put them out by hand) keeps them
            -- out even off-season / bad-weather. "finishing" still means shed.
            local manualOut = (self._beefManualOut ~= nil and self._beefManualOut[barnIdx] == true)
            shouldBeOut = hasActive and (phase == "grazing")
                          and ((autoOn and inSeason and not badWeather) or manualOut)
        else
            -- Window: [sunriseH, sunsetH - 1] — we require >= 1h of pasture
            -- time left, so the herd isn't spawned only to immediately walk
            -- home.
            local windowOpen = (hour >= sunriseH) and (hour <= sunsetH - 1)
            shouldBeOut = autoOn and inSeason and not badWeather
                          and hasActive and windowOpen
        end

        -- Renewal: a pasture inside its reseeding lockout can't be grazed —
        -- keep the herd in regardless of window/phase (folded into the
        -- should-be-out gate per the load-reconcile contract).
        if hasActive and self.registry:isRenewing(barnIdx, barn.activeIndex) then
            shouldBeOut = false
        end

        local barnN = countAt(barn.placeable)
        -- Count only THIS barn's own herd on pasture. A pasture shared with
        -- another barn (dairy + dry-cow barns sharing paddocks) may physically
        -- hold the OTHER barn's herd; attributing those animals here would make
        -- this barn think it's out and then recall the wrong herd on load.
        local pastureN = 0
        for _, p in ipairs(barn.pastures) do
            local c = countAt(p)
            if c > 0 then
                local occ = self:_occupantBarnOf(p)
                if occ ~= nil and occ ~= barnIdx then c = 0 end
            end
            pastureN = pastureN + c
        end

        PastureHerdingLog.dev(
            "GrazingScheduler:reconcileOnLoad barn#%d hour=%d window=[%d..%d) shouldBeOut=%s (auto=%s hasActive=%s) barnN=%d pastureN=%d",
            barnIdx, hour, sunriseH, sunsetH, tostring(shouldBeOut),
            tostring(autoOn), tostring(hasActive), barnN, pastureN)

        -- MANUAL mode (herdsman / autoSchedule OFF) — dairy OR beef: the player
        -- alone controls send-out/in, so on load leave the herd exactly where
        -- they physically are, never recall/resume by schedule or phase. Just
        -- record reality. The beef phase is only an instruction to the herdsman,
        -- so with the herdsman off it must not move animals on load either.
        if not autoOn then
            self.state[barnIdx] = (pastureN > barnN) and "at_pasture" or "at_barn"
            PastureHerdingLog.dev("GrazingScheduler: barn#%d manual mode — left herd in place on load (state=%s)",
                barnIdx, self.state[barnIdx])
        elseif pastureN > 0 and not shouldBeOut then
            -- Teleport home (no walk session): right after load, RLM hasn't
            -- spawned VisualAnimals on the pasture yet, so Walker would skip
            -- with "no visible animals". A direct bulk move via AnimalMover
            -- handles split-state cleanly — sweep every pasture that has any
            -- animals back into the barn.
            local total = 0
            for pIdx, p in ipairs(barn.pastures) do
                if countAt(p) > 0 then
                    -- Never sweep a shared pasture that's holding ANOTHER barn's
                    -- herd back into this barn — that's the bug that swapped the
                    -- dairy and dry-cow herds' home barns on load.
                    local occ = self:_occupantBarnOf(p)
                    if occ ~= nil and occ ~= barnIdx then
                        PastureHerdingLog.dev(
                            "GrazingScheduler:reconcileOnLoad barn#%d skipping load-recall of pasture#%d (held by barn#%s)",
                            barnIdx, pIdx, tostring(occ))
                    else
                        local ok, msg = self.mover:moveAll(p, barn.placeable, nil)
                        if ok then
                            total = total + 1
                        else
                            PastureHerdingLog.warning(
                                "GrazingScheduler:reconcileOnLoad barn#%d pasture#%d load-recall move failed: %s",
                                barnIdx, pIdx, tostring(msg))
                        end
                    end
                end
            end
            self.state[barnIdx] = "at_barn"
            PastureHerdingLog.dev("GrazingScheduler: barn#%d load-recall — teleported animals home from %d pasture(s)",
                barnIdx, total)
        elseif pastureN == 0 and barnN > 0 and shouldBeOut then
            self.state[barnIdx] = "at_barn"
            self:transitionOne(barnIdx, "out", "load-resume")
        else
            self.state[barnIdx] = (pastureN > barnN) and "at_pasture" or "at_barn"
        end
    end
end

-- Mixer-wagon abstraction: consume up to one hour of food from whatever
-- the barn has in storage (any fillType — silage, forage, TMR-mix, etc.)
-- and deposit an equivalent amount at the pasture trough as a non-roughage
-- supplement (the first concentrate-family fillType the pasture accepts).
-- This represents the player's mixer wagon turning bulk feed into a
-- finished ration for the cows on pasture. The roughage need is still met
-- by grass; the supplement covers the other food groups so productivity
-- climbs to 100% without us having to teleport magic concentrate.
-- Reverse pump: when cows leave a pasture (sunset / send-back), drain any
-- leftover non-roughage concentrate from the pasture trough back to the
-- barn so the player doesn't lose the TMR they pumped earlier in the day.
-- Roughage in the trough (silage/hay from manual loading) stays — that's
-- player-placed feed for pasture grazing, not pump output.
function GrazingScheduler:_drainPastureTroughToBarn(barn, pasture)
    if barn == nil or pasture == nil then return end
    local bFood = barn.spec_husbandryFood
    local pFood = pasture.spec_husbandryFood
    if bFood == nil or pFood == nil then return end
    local bLevels = bFood.fillLevels
    local pLevels = pFood.fillLevels
    if type(bLevels) ~= "table" or type(pLevels) ~= "table" then return end
    local roughage = getRoughageFillTypes()

    local farmId = barn.ownerFarmId or pasture.ownerFarmId or 1
    local totalDrained = 0
    for ft, level in pairs(pLevels) do
        if type(level) == "number" and level > 0 and not roughage[ft] then
            -- Try barn.addFood first; fall back to direct fillLevels write
            -- (same pattern as the forward pump — vanilla's addFood may
            -- reject the deposit, in which case we write straight to
            -- bLevels respecting per-fillType capacity).
            local added = 0
            pcall(function() added = barn:addFood(farmId, level, ft) or 0 end)
            if added <= 0 then
                local cap = (bFood.capacities and bFood.capacities[ft]) or bFood.capacity or 0
                local current = bLevels[ft] or 0
                local room = math.max(0, cap - current)
                added = math.min(level, room)
                if added > 0 then bLevels[ft] = current + added end
            end
            if added > 0 then
                pLevels[ft] = math.max(0, level - added)
                totalDrained = totalDrained + added
            end
        end
    end

    -- Refresh cached UI fields on both placeables so the bars update
    -- without waiting for vanilla's next pass.
    local function refreshInfo(spec)
        if spec == nil or type(spec.fillLevels) ~= "table" then return end
        local total = 0
        for _, v in pairs(spec.fillLevels) do if type(v) == "number" then total = total + v end end
        if type(spec.info) == "table" then
            spec.info.value = total
            if type(spec.info.capacity) == "number" and spec.info.capacity > 0 then
                spec.info.ratio = math.min(1.0, total / spec.info.capacity)
            end
            spec.info.text = string.format("%d l", math.floor(total))
        end
    end
    refreshInfo(pFood)
    refreshInfo(bFood)

    if totalDrained > 0 then
        PastureHerdingLog.dev(
            "GrazingScheduler: returned %.0f L of leftover hand-fed concentrate from pasture to barn",
            totalDrained)
    end
end

-- Per-frame driver. Walks every dairy barn that has cows at pasture and
-- runs the pump. Replaces the old hour-rollover trigger, which only fired
-- on in-game hour change — too sparse when the player is at slow game
-- speed or has been in menus, leaving the trough empty for hours of real
-- time. Per-frame plus the per-deposit dedup keeps the log clean while
-- keeping the trough effectively full.
-- Concentrate pump REMOVED: the grass-productivity floor already gives
-- grazing dairy full milk, so auto-pumping concentrate to the pasture was
-- redundant AND it stopped the cows grazing (they ate the pumped feed first
-- and the pasture never depleted). Feeding at pasture is now an explicit,
-- hand-fed choice via the per-barn Feed Trough toggle (beef on, dairy off).

-- Wipe the player-painted "fertilized" ground tint on a pasture (if any),
-- resetting the terrain field/spray channels SprayerHook set. Gated on the
-- registry flag so it only runs when there's actually something painted, and
-- so it still fires correctly after a save/reload (the flag is persisted).
function GrazingScheduler:_clearPastureFertVisual(barnIdx, pIdx)
    if self.registry == nil or type(self.registry.getFertPainted) ~= "function" then return end
    if not self.registry:getFertPainted(barnIdx, pIdx) then return end
    local barn = self.registry.barns and self.registry.barns[barnIdx]
    local placeable = barn and barn.pastures and barn.pastures[pIdx]
    if placeable ~= nil and _G.SprayerHook ~= nil and type(SprayerHook.clearFertVisual) == "function" then
        pcall(function() SprayerHook.clearFertVisual(placeable) end)
    end
    self.registry:setFertPainted(barnIdx, pIdx, false)
end

-- direction: "out" (barn -> active pasture) or "in" (pasture -> barn)
-- reason: free-text label for the log
function GrazingScheduler:transitionAll(direction, reason)
    if direction ~= "out" and direction ~= "in" then
        PastureHerdingLog.warning("GrazingScheduler:transitionAll bad direction=%s", tostring(direction))
        return
    end
    local n = 0
    for barnIdx, barn in self.registry:iterBarns() do
        if self:transitionOne(barnIdx, direction, reason) then
            n = n + 1
        end
    end
    PastureHerdingLog.dev("GrazingScheduler: %s transition (%s) dispatched for %d barn(s)",
        direction, tostring(reason), n)
end

-- Returns true if a WalkSession is currently in flight for this barn
-- (cows physically moving between barn and pasture). Used to debounce
-- the Send Out / Bring In buttons so the player can't spawn parallel
-- transitions by spam-clicking — each click would otherwise queue another
-- WalkSession, breaking the visual flow.
function GrazingScheduler:_hasActiveSessionFor(barnIdx)
    if self.activeSessions == nil then return false end
    for _, s in ipairs(self.activeSessions) do
        if s ~= nil and s.barnIdx == barnIdx and not s:isDone() then
            return true
        end
    end
    return false
end

-- Is this placeable the source or destination of an in-flight walk? Its animals
-- are mid-transfer (held at the source during the walk), so a reconcile must NOT
-- read them as a settled herd location — otherwise, while one barn walks a herd
-- across a shared pasture, another barn's reconcile counts those in-transit
-- animals and wrongly re-points its active pasture at them (then a queued send
-- sources from the wrong, now-empty pasture and silently no-ops).
function GrazingScheduler:_pastureInActiveTransit(placeable)
    if placeable == nil or self.activeSessions == nil then return false end
    for _, s in ipairs(self.activeSessions) do
        if s ~= nil and not s:isDone()
           and (s.sourcePlaceable == placeable or s.destPlaceable == placeable) then
            return true
        end
    end
    return false
end

function GrazingScheduler:transitionOne(barnIdx, direction, reason)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil then return false end
    -- Serialize walks: only ONE walk runs at a time across ALL barns. Two herds
    -- walking at once — especially barns that share pastures — fight over the
    -- same animal pool (the second session claims animals the first is still
    -- moving), so the second herd's walkers spawn as nil and never transfer.
    -- If a walk is already running, park this one; the pump starts it when the
    -- active walk finishes.
    if #self.activeSessions > 0 then
        self:_enqueueWalk({ kind = "transition", barnIdx = barnIdx, direction = direction, reason = reason })
        return "queued"
    end
    -- Same barn already mid-transition (defensive double-click guard).
    if self:_hasActiveSessionFor(barnIdx) then
        PastureHerdingLog.dev(
            "GrazingScheduler: barn#%d %s ignored — a transition is already in flight",
            barnIdx, direction)
        return false
    end
    if #barn.pastures == 0 then
        PastureHerdingLog.dev("GrazingScheduler: barn#%d has no pastures; skipping %s", barnIdx, direction)
        return false
    end
    local pasture = self.registry:getActivePasture(barnIdx)
    if pasture == nil then
        PastureHerdingLog.dev("GrazingScheduler: barn#%d has no active pasture; skipping", barnIdx)
        return false
    end

    local source, dest
    local nowState = self.state[barnIdx] or "at_barn"
    if direction == "out" then
        if nowState == "at_pasture" then
            PastureHerdingLog.dev("GrazingScheduler: barn#%d already at_pasture; skipping out", barnIdx)
            return false
        end
        source = barn.placeable
        dest = pasture
        -- Renewal: the active pasture is mid-reseed (2-month lockout) — hold in.
        if self.registry:isRenewing(barnIdx, barn.activeIndex) then
            local framework = rawget(_G, "g_pastureHerding")
            if framework ~= nil and type(framework.pushNotification) == "function" then
                framework:pushNotification({1.0, 0.6, 0.2, 1.0},
                    string.format("Barn#%d cows held in — the active pasture is regrowing after reseeding.", barnIdx), 6000)
            end
            PastureHerdingLog.dev("GrazingScheduler: barn#%d 'out' blocked — active pasture renewing", barnIdx)
            return false
        end
        -- Anti-mix: don't send this herd out onto a pasture another herd is
        -- already grazing (foundation for shared pastures).
        local mix, occ = self:wouldMixHerds(barnIdx, dest)
        if mix then
            local framework = rawget(_G, "g_pastureHerding")
            if framework ~= nil and type(framework.pushNotification) == "function" then
                framework:pushNotification({1.0, 0.4, 0.2, 1.0},
                    string.format("Barn#%d cows held in — their pasture is occupied by another herd (barn #%s).",
                        barnIdx, tostring(occ)), 7000)
            end
            PastureHerdingLog.dev(
                "GrazingScheduler: barn#%d 'out' blocked — target pasture occupied by barn #%s's herd",
                barnIdx, tostring(occ))
            return false
        end
    else
        if nowState == "at_barn" then
            PastureHerdingLog.dev("GrazingScheduler: barn#%d already at_barn; skipping in", barnIdx)
            return false
        end
        source = pasture
        dest = barn.placeable
        -- Herd is going home — a manual-out override no longer applies.
        if self._beefManualOut then self._beefManualOut[barnIdx] = nil end
        -- Reverse pump: return any unused concentrate in the pasture
        -- trough back to the barn so the player doesn't lose feed each
        -- cycle. Roughage left in the trough stays (player-loaded silage).
        self:_drainPastureTroughToBarn(barn.placeable, pasture)
    end

    -- Empty-source guard. An empty barn (no cows yet) shouldn't error every
    -- frame when the scheduler reaches its sunrise / beef-grazing-resume
    -- tick — it should just no-op until the player adds animals. Without
    -- this, AnimalMover returns "source has 0 animals" and we log it as
    -- Error, spamming the log on every tick where the toggle is ON for an
    -- empty barn.
    local function countAnimals(p)
        local spec = p and p.spec_husbandryAnimals
        local cs = spec and spec.clusterSystem
        local list = cs and ((cs.getAnimals and cs:getAnimals()) or cs.animals) or nil
        if type(list) ~= "table" then return 0 end
        local n = 0
        for _, a in pairs(list) do
            if type(a) == "table" and type(a.numAnimals) == "number" then
                n = n + a.numAnimals
            elseif type(a) == "table" then
                n = n + 1
            end
        end
        return n
    end
    local movingCount = countAnimals(source)
    if movingCount <= 0 then
        PastureHerdingLog.dev(
            "GrazingScheduler: barn#%d %s (%s) — source empty; skipping",
            barnIdx, direction, tostring(reason))
        return false
    end

    -- Capacity pre-flight. Without this the move dispatches, RLRM silently drops
    -- it (destination full), and the herd just never arrives — which then looks
    -- like "the animals won't go out".
    local fits, destCap, destFree = self:_destinationHasRoom(dest, movingCount)
    if not fits then
        local where = (direction == "out")
            and string.format("pasture #%d", barn.activeIndex or 0) or "the barn"
        local framework = rawget(_G, "g_pastureHerding")
        if framework ~= nil and type(framework.pushNotification) == "function" then
            framework:pushNotification({1.0, 0.4, 0.2, 1.0},
                string.format("Barn#%d: %s holds %d animals (%d free) but the herd is %d. Raise the pasture's animal capacity in the Grazing tab.",
                    barnIdx, where, destCap or 0, destFree or 0, movingCount), 9000)
        end
        PastureHerdingLog.warning(
            "GrazingScheduler: barn#%d %s BLOCKED — destination capacity %d (%d free) < herd %d",
            barnIdx, direction, destCap or 0, destFree or 0, movingCount)
        return false
    end

    PastureHerdingLog.dev("GrazingScheduler: barn#%d %s (%s)", barnIdx, direction, tostring(reason))

    -- If the active pasture has a path defined, run a WalkSession with per-
    -- walker move + transit suppression. Otherwise fall back to bulk move.
    local pastureIdx = barn.activeIndex
    local pathFlat = self.registry:getPath(barnIdx, pastureIdx)
    -- Quality decay is now applied continuously while cows are at
    -- pasture by _applyPastureQualityDecay (called per-frame from the
    -- update batch). The OUT/IN transitions just track lastGrazed for
    -- recovery math and reset bookkeeping.
    local moved = false
    if pathFlat ~= nil and #pathFlat >= 4 and self.transitTracker ~= nil and self.animalManager ~= nil then
        -- A walk session only starts if the source has VISIBLE animals. Right
        -- after a load (before RLRM re-spawns visuals) the cluster count can be
        -- >0 while the visible count is 0; _startWalkSession then returns false.
        -- Fall back to an instant bulk move so we never mark the herd
        -- "at_pasture" while it's physically still in the barn.
        moved = self:_startWalkSession(barnIdx, direction, source, dest, pathFlat, barn, reason) == true
        if not moved then
            local ok = self.mover:moveAll(source, dest, nil)
            moved = ok == true
        end
    else
        local ok, msg = self.mover:moveAll(source, dest, nil)
        if not ok then
            PastureHerdingLog.error("GrazingScheduler: move failed for barn#%d: %s", barnIdx, tostring(msg))
            return false
        end
        moved = true
    end
    if not moved then
        -- Neither a walk nor an instant move happened — leave state untouched so
        -- reconcile doesn't have to unwind a false "at_pasture".
        PastureHerdingLog.warning("GrazingScheduler: barn#%d %s — nothing moved; leaving state unchanged", barnIdx, direction)
        return false
    end
    self.state[barnIdx] = (direction == "out") and "at_pasture" or "at_barn"
    if direction == "out" then
        self.registry:setLastGrazed(barnIdx, pastureIdx)
    end

    -- Remember the state we just commanded. An instant (pathless) move dispatches
    -- async, so a reconcile firing a frame later can still count animals at the
    -- source and wrongly "correct" us — flipping state and (for beef) the phase.
    -- reconcileStateFromReality trusts this commanded state until reality settles
    -- to match it; the next tick() pass clears it as a backstop.
    self._pendingMove = self._pendingMove or {}
    self._pendingMove[barnIdx] = self.state[barnIdx]

    -- The following beef bookkeeping only matters when the HERDSMAN (auto mode)
    -- is running — it keeps the advisory phase / manual-out override in step with
    -- a player move so the automation doesn't immediately undo it. In MANUAL mode
    -- the phase is a pure preference the player sets by hand and a manual send /
    -- bring must never change it, so both blocks are gated on the herdsman.
    local herdsmanOn = self.registry:getHerdType(barnIdx) == "beef"
                       and self.registry:getAutoSchedule(barnIdx)
    -- A player send-out under the herdsman: set the manual-out override so the
    -- season/weather gate won't shed them on the next tick. Automated out-moves
    -- (beef-grazing-resume, sunrise) deliberately DON'T set it, so automation can
    -- still shed a herd it sent out once the season ends.
    if herdsmanOn and direction == "out" and (reason == "menu" or reason == "manual") then
        self._beefManualOut = self._beefManualOut or {}
        self._beefManualOut[barnIdx] = true
        PastureHerdingLog.dev(
            "GrazingScheduler: barn#%d beef manual-out override SET (player send-out, reason=%s)",
            barnIdx, tostring(reason))
    end
    -- The mirror: a player bring-IN under the herdsman flips a "grazing" phase to
    -- "finishing" so the next tick doesn't just send them back out. Same signal
    -- the reconcile path applies to an external move home.
    if herdsmanOn and direction == "in" and (reason == "menu" or reason == "manual")
       and self.registry:getBeefPhase(barnIdx) == "grazing" then
        self.registry:setBeefPhase(barnIdx, "finishing")
        PastureHerdingLog.dev(
            "GrazingScheduler: barn#%d beef phase grazing -> finishing (manual bring-in)", barnIdx)
    end

    -- Cows have grazed this pasture for the day and are heading home — wipe any
    -- "fertilized" ground tint the player painted (it's been eaten down). The
    -- look therefore lasts from fertilizing until the next graze, as designed.
    if direction == "in" then
        self:_clearPastureFertVisual(barnIdx, pastureIdx)
    end

    -- After cows return to the barn at sunset, advance the active pasture
    -- by one if autoRotate is on for this barn. The next morning's
    -- transition will use the rotated index.
    if direction == "in" and self.registry:getAutoRotate(barnIdx) then
        local newIdx, why = self:_pickPastureWithMostGrass(barnIdx)
        local curIdx = barn and barn.activeIndex or 0
        PastureHerdingLog.dev("GrazingScheduler: barn#%d autoRotate decision — current=#%d picked=#%s reason=%s",
            barnIdx, curIdx, tostring(newIdx), tostring(why))
        if newIdx ~= nil and newIdx ~= curIdx then
            self.registry:setActivePasture(barnIdx, newIdx)
            PastureHerdingLog.dev("GrazingScheduler: barn#%d autoRotate -> pasture #%d", barnIdx, newIdx)
        end
    elseif direction == "in" then
        PastureHerdingLog.dev("GrazingScheduler: barn#%d autoRotate is OFF — staying on current pasture", barnIdx)
    end

    return true
end

-- Pick whichever pasture on this barn has the most meadow grass available.
-- Returns (pastureIdx, reasonString) or (nil, why) when no usable pasture.
-- Threshold: at least 1% fill counts; bare-dirt pastures are skipped so the
-- scheduler holds the current pasture rather than send cows somewhere they
-- can't eat.
function GrazingScheduler:_pickPastureWithMostGrass(barnIdx)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil or #barn.pastures == 0 then return nil, "no pastures" end

    local function meadowPct(p)
        local m = p and p.spec_husbandryMeadow
        if m == nil or type(m.fillLevels) ~= "table" then return -1 end
        local fill, cap = 0, 0
        for _, v in pairs(m.fillLevels) do if type(v) == "number" then fill = fill + v end end
        if type(m.capacities) == "table" then
            for _, v in pairs(m.capacities) do if type(v) == "number" then cap = cap + v end end
        end
        if cap <= 0 then return -1 end
        return fill / cap
    end

    -- Stay-on-current-pasture gate. If the player has set a rotate-at
    -- threshold (e.g. 30%), keep grazing the current pasture until its
    -- grass drops below that. Avoids the daily rotation thrash that happens
    -- when the regrowth throttle keeps every pasture slightly above the
    -- "most grass" winner. Only kicks in when the current pasture still
    -- has more grass than the threshold.
    local framework = rawget(_G, "g_pastureHerding")
    local rotateAt = (framework and framework.rotateAtGrassPercent) or 0
    if rotateAt > 0 and barn.activeIndex and barn.activeIndex > 0
       and not self.registry:isRenewing(barnIdx, barn.activeIndex) then
        local cur = barn.pastures[barn.activeIndex]
        local curPct = meadowPct(cur)
        if curPct > rotateAt then
            return barn.activeIndex,
                string.format("hold (current %.0f%% > threshold %.0f%%)",
                    curPct * 100, rotateAt * 100)
        end
    end

    -- Effective grass = raw grass × effective quality (soil × pH), so a
    -- high-grass-but-degraded/wrong-pH pasture loses to a fresher, better-limed one.
    local function effectiveGrass(i, p)
        local pct = meadowPct(p)
        if pct < 0 then return pct end
        return pct * self.registry:getEffectiveQuality(barnIdx, i)
    end

    local restDays = (framework and framework.grazingMinRestDays) or 0
    local today = g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay
    local function daysSinceGrazed(i)
        if restDays <= 0 or today == nil then return math.huge end
        local lastDay = framework and framework.grazingRegistry:getLastGrazed(barnIdx, i)
        if lastDay == nil then return math.huge end
        return today - lastDay
    end

    -- Skip a paddock that already holds ANOTHER barn's herd — the herdsman must
    -- rotate onto an EMPTY (or its own) pasture, never dump this herd on top of a
    -- different one and merge the groups. (Shared-pasture safety; wouldMixHerds
    -- is empty-safe and only flags foreign-occupied targets.)
    local function occupiedByOther(p)
        return self:wouldMixHerds(barnIdx, p) == true
    end
    -- A pasture mid-reseed (2-month lockout) is off-limits to the rotation picker.
    local function unavailable(i, p)
        return occupiedByOther(p) or self.registry:isRenewing(barnIdx, i)
    end

    -- Pass 1: prefer pastures both well-rested AND with effective grass.
    local bestIdx, bestPct = nil, -1
    for i, p in ipairs(barn.pastures) do
        local pct = effectiveGrass(i, p)
        if pct > bestPct and daysSinceGrazed(i) >= restDays and not unavailable(i, p) then
            bestPct = pct
            bestIdx = i
        end
    end

    -- Pass 2: every pasture is within the rest window. Pick the most-rested
    -- one with eatable grass; we'd rather rotate to a less-rested field than
    -- send cows to bare dirt.
    if bestIdx == nil then
        local mostDays, mostPct = -1, -1
        for i, p in ipairs(barn.pastures) do
            local pct = effectiveGrass(i, p)
            local d = daysSinceGrazed(i)
            if pct >= 0.01 and (d > mostDays or (d == mostDays and pct > mostPct))
               and not unavailable(i, p) then
                mostDays = d
                mostPct = pct
                bestIdx = i
                bestPct = pct
            end
        end
    end

    if bestIdx == nil or bestPct < 0.01 then
        return nil, "no pasture has eatable grass"
    end
    return bestIdx, string.format("%.0f%% grass, rested %d day(s)",
        bestPct * 100, math.min(999, daysSinceGrazed(bestIdx)))
end

function GrazingScheduler:_startWalkSession(barnIdx, direction, source, dest, pathFlat, barn, reason)
    -- Use ONLY the player-defined waypoints (reversed for 'in' direction).
    -- No automatic prepend of source.root or append of dest.root \xE2\x80\x94 the path
    -- is exactly what the player walked.
    local pathPoints = {}
    if direction == "out" then
        for i = 1, #pathFlat, 2 do
            pathPoints[#pathPoints + 1] = { x = pathFlat[i], z = pathFlat[i + 1] }
        end
    else
        for i = #pathFlat - 1, 1, -2 do
            pathPoints[#pathPoints + 1] = { x = pathFlat[i], z = pathFlat[i + 1] }
        end
    end

    local animals = Walker.collectAnimalsWithVisuals(source)
    if #animals == 0 then
        PastureHerdingLog.warning("GrazingScheduler: barn#%d %s — source has no visible animals; no walk session started",
            barnIdx, direction)
        return false
    end

    local session = WalkSession.new(source, dest, pathPoints, animals, {
        label = string.format("barn%d-%s", barnIdx, direction),
        reason = reason,   -- drives the 3-way auto-gate mode (manual vs herdsman)
        animalManager = self.animalManager,
        transitTracker = self.transitTracker,
        animalMover = self.mover,
    })
    if session == nil then
        PastureHerdingLog.error("GrazingScheduler: barn#%d %s — WalkSession.new failed", barnIdx, direction)
        return false
    end
    -- Tag the session with its barn so _hasActiveSessionFor can debounce
    -- subsequent Send Out / Bring In clicks until this one finishes.
    session.barnIdx = barnIdx
    self.activeSessions[#self.activeSessions + 1] = session
    PastureHerdingLog.dev("GrazingScheduler: barn#%d %s — started WalkSession of %d animals",
        barnIdx, direction, #animals)
    return true
end

-- Count animals physically present in a husbandry placeable.
function GrazingScheduler:_countAnimalsIn(placeable)
    local s = placeable and placeable.spec_husbandryAnimals
    local cs = s and s.clusterSystem
    local list = cs and ((cs.getAnimals and cs:getAnimals()) or cs.animals) or nil
    if type(list) ~= "table" then return 0 end
    local n = 0
    for _, a in pairs(list) do if type(a) == "table" then n = n + (a.numAnimals or 1) end end
    return n
end

-- Will `herdCount` more animals fit in this husbandry? A husbandry refuses a
-- move that would exceed spec.maxNumAnimals, and RLRM does so SILENTLY — the
-- dispatch "succeeds" and the animals simply never arrive. So we pre-flight it
-- and tell the player instead of firing a doomed move (and then thrashing when
-- reconcile sees them still at the source).
-- Returns: fits (bool), capacity (number|nil), freeSpace (number|nil)
function GrazingScheduler:_destinationHasRoom(destPlaceable, herdCount)
    local spec = destPlaceable and destPlaceable.spec_husbandryAnimals
    if spec == nil or type(spec.maxNumAnimals) ~= "number" or spec.maxNumAnimals <= 0 then
        return true, nil, nil -- unknown/unlimited capacity — don't block
    end
    local cap = spec.maxNumAnimals
    local existing = self:_countAnimalsIn(destPlaceable)
    local free = cap - existing
    return (herdCount <= free), cap, free
end

-- Which barn (herd) currently considers itself grazing on this placeable, if
-- any. Resolved from live state rather than the single-owner registry map, so
-- it stays correct once a pasture can be shared by two barns.
function GrazingScheduler:_occupantBarnOf(targetPlaceable)
    if targetPlaceable == nil then return nil end
    for bIdx, _barn in self.registry:iterBarns() do
        if (self.state[bIdx] or "at_barn") == "at_pasture" then
            if self.registry:getActivePasture(bIdx) == targetPlaceable then
                return bIdx
            end
        end
    end
    return nil
end

-- Should THIS barn run the shared physical/land processing for this pasture
-- this frame? A pasture owned by a single barn is always processed by it. A
-- SHARED pasture (Part B) is processed by exactly ONE barn per frame — the barn
-- currently grazing it, else its primary owner — so the idle co-owner doesn't
-- fight the grazing owner over the one physical meadow / trough / capacity.
function GrazingScheduler:_shouldProcessPasture(barnIdx, placeable)
    local reg = self.registry
    if reg == nil or placeable == nil then return true end
    local owners = (type(reg._ownersOf) == "function") and reg:_ownersOf(placeable) or nil
    if owners == nil or #owners <= 1 then return true end -- not shared -> cheap path
    local occ = self:_occupantBarnOf(placeable)
    if occ ~= nil then return occ == barnIdx end
    local ref = reg.byPlaceable and reg.byPlaceable[placeable]
    if ref ~= nil and ref.barnIdx ~= nil then return ref.barnIdx == barnIdx end
    return true
end

-- Iterator over a barn's pastures that yields ONLY the ones this barn should
-- physically process this frame (see _shouldProcessPasture). Drop-in for
-- `ipairs(barn.pastures)` in the meadow/land-MUTATING per-frame loops so a
-- shared pasture is touched once, not once per owning barn. Yields (pIdx,
-- pasture) with the real index.
function GrazingScheduler:_iterProcessable(barnIdx, barn)
    local i, n = 0, #barn.pastures
    return function()
        while i < n do
            i = i + 1
            local p = barn.pastures[i]
            if p ~= nil and self:_shouldProcessPasture(barnIdx, p) then
                return i, p
            end
        end
    end
end

-- Anti-herd-mixing guard: would moving movingBarnIdx's herd INTO targetPlaceable
-- put it on top of a DIFFERENT herd already there? Returns (true, occupantBarn)
-- if so. Foundation for shared pastures — with today's single-owner pastures no
-- other barn can be grazing the same placeable, so this never fires and never
-- blocks a legitimate same-herd move.
function GrazingScheduler:wouldMixHerds(movingBarnIdx, targetPlaceable)
    if targetPlaceable == nil then return false, nil end
    -- Empty target -> always safe.
    if self:_countAnimalsIn(targetPlaceable) <= 0 then return false, nil end
    -- Target has animals. It is ONLY safe to move in if those animals are
    -- confirmed to be the moving barn's OWN herd (occupant == movingBarnIdx).
    -- Anything else — another barn's herd, OR animals we can't attribute because
    -- the cache is out of sync on a shared pasture — must BLOCK. Previously we
    -- fell back to the pasture's PRIMARY owner and allowed the move when that
    -- happened to be the moving barn; on a shared pasture that let the herdsman
    -- dump its herd on top of the OTHER barn's herd (grass-rotation into an
    -- occupied paddock), merging the two groups.
    local occ = self:_occupantBarnOf(targetPlaceable)
    if occ == movingBarnIdx then
        return false, occ
    end
    if occ == nil then
        -- Unknown occupant but animals present — report the primary owner for the
        -- message, but still block.
        local reg = self.registry
        local ref = reg and reg.byPlaceable and reg.byPlaceable[targetPlaceable]
        occ = ref and ref.barnIdx or nil
    end
    return true, occ
end

-- Walk a herd that is CURRENTLY OUT at pasture `fromIdx` straight to pasture
-- `toIdx` along the recorded pasture-pasture path (auto-reversed as needed),
-- instead of routing home through the barn. The herd stays "out" the whole
-- time; the WalkSession's per-walker moveOne relocates each animal
-- fromPasture->toPasture as it arrives. Returns (true) on success or
-- (false, reason) if it couldn't start. Used by the manual console trigger
-- and by the herdsman starvation-rotate (in place of an instant teleport).
function GrazingScheduler:walkPastureToPasture(barnIdx, fromIdx, toIdx, reason)
    local barn = self.registry:getBarn(barnIdx)
    if barn == nil then return false, "no such barn" end
    if fromIdx == toIdx then return false, "herd is already on that pasture" end
    local source = barn.pastures[fromIdx]
    local dest = barn.pastures[toIdx]
    if source == nil then return false, "no such source pasture" end
    if dest == nil then return false, "no such target pasture" end
    -- Serialize walks (see transitionOne): if another herd is walking, park this
    -- pasture-to-pasture move and start it when the active walk finishes.
    if #self.activeSessions > 0 then
        self:_enqueueWalk({ kind = "pasture", barnIdx = barnIdx, fromIdx = fromIdx, toIdx = toIdx, reason = reason })
        return true, "queued"
    end
    -- Truth-sync the cached state first. During a walk-out the animals are held
    -- at the source, so self.state can still read "at_barn" for a while after
    -- they've physically arrived at pasture (the cache only refreshes on the
    -- hourly tick / next transition). Recount from reality before we judge.
    self:reconcileStateFromReality(barnIdx)
    if (self.state[barnIdx] or "at_barn") ~= "at_pasture" then
        return false, "herd is not out at pasture"
    end
    if self:_hasActiveSessionFor(barnIdx) then
        return false, "a walk is already in progress for this barn"
    end
    if self.transitTracker == nil or self.animalManager == nil then
        return false, "walk subsystems unavailable"
    end
    local pathFlat = self.registry:getPairPath(barnIdx, fromIdx, toIdx)
    if pathFlat == nil or #pathFlat < 4 then
        return false, "no pasture-to-pasture path recorded between those pastures"
    end
    -- Guard against a stale activeIndex: the source pasture must actually hold
    -- the herd, or the walk would silently spawn nobody.
    local function countAt(p)
        local s = p and p.spec_husbandryAnimals
        local cs = s and s.clusterSystem
        local list = cs and ((cs.getAnimals and cs:getAnimals()) or cs.animals) or nil
        if type(list) ~= "table" then return 0 end
        local n = 0
        for _, a in pairs(list) do if type(a) == "table" then n = n + (a.numAnimals or 1) end end
        return n
    end
    local movingCount = countAt(source)
    if movingCount <= 0 then
        return false, string.format("no animals on source pasture #%d (herd may be on a different pasture)", fromIdx)
    end
    -- Anti-mix: refuse to walk onto a pasture another herd is already on.
    local mix, occ = self:wouldMixHerds(barnIdx, dest)
    if mix then
        return false, string.format("target pasture #%d is occupied by another herd (barn #%s)", toIdx, tostring(occ))
    end
    -- Capacity pre-flight (RLRM drops an over-capacity move silently).
    local fits, destCap, destFree = self:_destinationHasRoom(dest, movingCount)
    if not fits then
        return false, string.format(
            "pasture #%d holds %d animals (%d free) but the herd is %d — raise its animal capacity in the Grazing tab",
            toIdx, destCap or 0, destFree or 0, movingCount)
    end

    -- pathFlat is already oriented fromIdx->toIdx, so start it "forward" (same
    -- handling as an "out" walk — no reversal).
    self:_startWalkSession(barnIdx, "out", source, dest, pathFlat, barn, reason)
    -- Herd is still out; just switch which pasture is active and roll the
    -- land bookkeeping onto the new one.
    self.registry:setActivePasture(barnIdx, toIdx)
    self.registry:setLastGrazed(barnIdx, toIdx)
    -- (continuous per-hour decay handles the new active pasture; no instant hit)
    PastureHerdingLog.dev("GrazingScheduler: barn#%d pasture-walk #%d -> #%d (%s)",
        barnIdx, fromIdx, toIdx, tostring(reason))
    return true
end

function GrazingScheduler:setSchedule(sunriseHour, sunsetHour)
    if type(sunriseHour) == "number" then self.SUNRISE_HOUR = sunriseHour end
    if type(sunsetHour)  == "number" then self.SUNSET_HOUR  = sunsetHour end
end

-- UI entry point: flip a beef herd's phase and immediately reconcile, so
-- the player sees the herd move within seconds rather than waiting for the
-- next hour rollover. No-op for dairy.
function GrazingScheduler:setBeefPhaseAndReconcile(barnIdx, phase)
    if self.registry:getHerdType(barnIdx) ~= "beef" then return false end
    local prevPhase = self.registry:getBeefPhase(barnIdx)
    self.registry:setBeefPhase(barnIdx, phase)
    -- The beef phase is ADVISORY: it tells the HERDSMAN (auto mode) what the
    -- player wants — graze vs. finish — and must NOT move animals by itself. In
    -- manual mode (herdsman off for this barn) the player alone sends the herd
    -- out / brings it in, so setting the phase just records the preference and
    -- moves nothing. We only act on the phase when the herdsman is enabled.
    if not self.registry:getAutoSchedule(barnIdx) then
        return true
    end
    -- Herdsman ON: a GENUINE phase change = the player retaking control; drop any
    -- manual-out override so the new phase's automation is authoritative. But do
    -- NOT drop it on a no-op re-apply of the same phase — the grazing menu
    -- re-syncs its mode picker on refresh (setState fires the FS25 callback bug ->
    -- this is called with the unchanged phase), and clearing the override there
    -- would shed a beef herd the player just sent out by hand.
    if phase ~= prevPhase and self._beefManualOut then
        self._beefManualOut[barnIdx] = nil
    end
    local framework = rawget(_G, "g_pastureHerding")
    local inSeason = (framework == nil) or (type(framework.isGrazingMonth) ~= "function")
                     or framework:isGrazingMonth()
    local badWeather = (framework ~= nil and framework.gateOnWeather == true
                        and type(framework.isBadWeather) == "function"
                        and framework:isBadWeather())
    self:_tickBeef(barnIdx, inSeason, badWeather)
    return true
end

-- UI entry point: set a barn's generic SCHEDULE mode and reconcile. Maps onto the
-- internal engine: day -> daily cycle (dairy); continuous/housed -> the phase model
-- (beef grazing/finishing) with its advisory-in-manual-mode reconcile. Production
-- type is independent + automatic, so this never affects milk/meat behaviour.
function GrazingScheduler:setScheduleModeAndReconcile(barnIdx, mode)
    if mode == "day" then
        self.registry:setHerdType(barnIdx, "dairy")
        -- Drop any beef manual-out override so it can't keep a later beef switch
        -- out against the season/weather gate.
        if self._beefManualOut then self._beefManualOut[barnIdx] = nil end
        return true   -- daily cycle applies at the next sunrise/sunset transition
    elseif mode == "continuous" then
        self.registry:setHerdType(barnIdx, "beef")
        return self:setBeefPhaseAndReconcile(barnIdx, "grazing")
    elseif mode == "housed" then
        self.registry:setHerdType(barnIdx, "beef")
        return self:setBeefPhaseAndReconcile(barnIdx, "finishing")
    end
    return false
end

function GrazingScheduler:setAutoEnabled(enabled)
    self.autoEnabled = enabled == true
end

function GrazingScheduler:describe()
    local hour = getCurrentHour() or -1
    local lines = {
        string.format("Schedule: out at %02d:00, in at %02d:00 (autoEnabled=%s, currentHour=%d)",
            self.SUNRISE_HOUR, self.SUNSET_HOUR, tostring(self.autoEnabled), hour),
    }
    for idx, barn in self.registry:iterBarns() do
        local s = self.state[idx] or "at_barn"
        lines[#lines + 1] = string.format("  barn#%d  state=%s", idx, s)
    end
    return table.concat(lines, "\n")
end
