PastureHerding = {}
local PastureHerding_mt = Class(PastureHerding)

function PastureHerding.new()
    local self = setmetatable({}, PastureHerding_mt)

    self.version = "0.7.1.0"
    self.modName = g_currentModName
    self.modDirectory = g_currentModDirectory

    self.hasRLM = false
    self.navMeshLayer = nil
    self.husbandryHook = nil

    return self
end

function PastureHerding:earlyInstall()
    self.navMeshLayer = NavMeshLayer.new(self)
    self.husbandryHook = HusbandryHook.new(self)
    self.grazingRegistry = GrazingRegistry.new()
    self.animalMover = AnimalMover.new()
    self.transitTracker = TransitTracker.new()
    self.grazingScheduler = GrazingScheduler.new(self.grazingRegistry, self.animalMover)
    self.shrinkNextPen = false
    self.shrinkRadius = 5.0
    self.debugContours = false
    self.debugPaths = false
    self.debugSprayCoverage = false
    self._activeWalkers = {}
    self._pathRecording = false
    self._pathSampleSpacing = 1.0
    self._pathRecordLastX = nil
    self._pathRecordLastZ = nil
    self._activeWalkSessions = {}
    -- ON: the auto-migrate path builds the nav mesh directly from the pasture
    -- contour (ContourBuilder.fabricate) — proven safe and warning-free.
    self.autoSubstituteOnPlacement = true
    self.autoFeedEnabled = false
    -- Grazing calendar: months 1..12 (FS25 period index). Cows go out on
    -- sunrise transitions ONLY when the current month falls within
    -- [grazingSeasonStart, grazingSeasonEnd]. Wraps around the year-end
    -- when start > end (e.g. start=11, end=3 means Nov-Mar). Defaults
    -- skip Nov-Feb winter.
    self.grazingSeasonStart = 3   -- March
    self.grazingSeasonEnd   = 10  -- October
    -- Auto-rotation rest period. When >0, _pickPastureWithMostGrass skips
    -- any pasture grazed within the last N days (uses the lastGrazed
    -- registry). Default 1 day so a freshly-grazed pasture sits out
    -- tomorrow's pick, forcing rotation even if foliage regrew overnight.
    self.grazingMinRestDays = 2
    -- Weather gate: when true, sunrise transitions are skipped while the
    -- current weather is rain or snow. Cows stay at the barn that day.
    self.gateOnWeather = true
    -- Pasture quality (soil health). Each grazing day reduces a pasture's
    -- quality; each rest day recovers it. Quality multiplies the effective
    -- grass during auto-rotation picks and shows in the Grazing status pane.
    self.grazingQualityDecayPerGrazingDay = 0.15
    self.grazingQualityRecoveryPerRestDay = 0.10
    -- OVERGRAZING: grazing a pasture that's already low on grass hammers the soil
    -- far harder than grazing lush growth. Below overgrazeGrassThreshold the soil
    -- decay multiplier ramps from 1x (at the threshold) up to overgrazeMaxMultiplier
    -- (at bare dirt), so repeatedly grazing paddocks into the ground drives their
    -- quality toward the floor and they recover painfully slowly.
    self.overgrazeGrassThreshold = 0.15
    self.overgrazeMaxMultiplier = 3.0
    -- WET-WEATHER POACHING: grazing on wet/soft ground (rain or snow) damages the
    -- soil more (hoof poaching). Extra decay multiplier applied while the herd
    -- grazes during bad weather — a reason to house them in the wet.
    self.wetPoachMultiplier = 1.5
    -- GRAZING OUTPUT BOOST: animals that graze OFTEN on GOOD-quality grass do
    -- better — beef hold a higher weight (→ better sale price) and dairy give
    -- more milk. Driven by a single per-herd "grazing condition" (0..1) that
    -- eases up toward the active pasture's grass quality (= soil quality) while
    -- the herd is out, and decays toward 0 while they're housed. Both boosts
    -- scale by that condition, so nothing snaps — house the herd and the boost
    -- fades over a few in-game days. Per-animal variation ("not every animal
    -- thrives") comes free from RLM genetics (metabolism for weight,
    -- productivity for milk). All applied via server-only Animal hooks; see
    -- HusbandryHook and GrazingScheduler:_updateGrazingCondition.
    self.grazeWeightBonus = 0.10   -- beef weight ceiling lift at full condition (~+10% sale value)
    self.grazeMilkBonus = 0.15     -- dairy milk multiplier at full condition (up to +15%)
    -- Per-in-game-hour easing rates. ~0.03/hr reaches ~90% of target in ~3 days
    -- and fades to ~10% in ~3 days — smooth and month-length-independent (runs
    -- on the same hour-rollover as the soil-quality decay).
    self.grazeConditionBuildRate = 0.03
    self.grazeConditionDecayRate = 0.03
    -- PASTURE RENEWAL (reseeding): renovate a worn paddock back to full soil
    -- quality, at the cost of money + a ~2-month grazing lockout while the new
    -- sward establishes. Cost is SIZE-SCALED off the pasture's grass capacity
    -- (bigger paddock = bigger bill), with a floor. See
    -- GrazingScheduler:renewPasture.
    self.renewCostPerLiter = 0.20   -- $ per litre of grass capacity
    self.renewMinCost = 500         -- floor so tiny paddocks still cost something
    -- PRECISION FARMING soil integration (#4): when the PF DLC is active, a
    -- pasture's STARTING soil quality (and its post-renewal reset) comes from PF's
    -- real soil map — so some pastures start good, some middling, some need work.
    -- PF grades soil by yieldPotential (~0.8 poor .. 1.25 premium); we map that
    -- band onto [pfSoilQualityLow .. pfSoilQualityHigh]. No-op without PF (falls
    -- back to 100%). See getPFSoilQuality.
    self.pfSoilEnabled = true
    self.pfSoilQualityLow = 0.5     -- quality for the map's poorest soil type
    self.pfSoilQualityHigh = 1.0    -- quality for the map's best soil type
    -- PF has only a handful of discrete soil types, so raw quality lands on a few
    -- fixed "set points". Add a small deterministic per-location jitter (+/- this)
    -- so pastures on the same soil type vary naturally. Keyed on world pos -> stable
    -- across reloads/renewals. 0 disables.
    self.pfSoilVariation = 0.08
    -- PF pH (#4b, ONGOING modifier): a pasture's soil pH vs the optimal pH for its
    -- soil type scales its EFFECTIVE quality (grass growth) — liming a pasture toward
    -- optimal improves it, live. Neutral (1.0) without PF. pfPHTolerance = pH units
    -- away from optimal for the full penalty; pfPHMaxPenalty = max quality reduction.
    self.pfPHEnabled = true
    self.pfPHTolerance = 1.5
    self.pfPHMaxPenalty = 0.4
    -- Seconds of ACTIVE liming over a pasture to fully correct its pH (lime spread
    -- is detected by SprayerHook because PF won't lime non-field pasture ground).
    self.limeSecondsToFull = 25
    -- The sprayed/limed GROUND look clears on the next graze OR after this many
    -- in-game days, whichever comes first (0 disables the timed fade — clear only on
    -- graze). The soil pH correction / fertilizer boost are separate and unaffected.
    self.fertPaintClearDays = 2
    -- Productivity floor for any herd out on pasture (dairy or beef).
    -- Vanilla maths caps grass-only at ~40% because meadow grass only covers
    -- roughage. Real cattle on good pasture maintain decent body condition
    -- on grass alone, so the scheduler clamps the pasture's productivity up
    -- to this value every frame while a herd is grazing there. The floor
    -- only lifts up — extra concentrate (pump for dairy, hand-fed for beef)
    -- still takes vanilla above 65% normally.
    self.grassProductivityFloor = 1.00
    -- Beef-grazing-mode productivity floor on grass ONLY. Real beef cattle
    -- on pasture without supplementation maintain ~80% condition. When the
    -- player loads TMR / beef nuts / mineral feed into the pasture trough
    -- the floor lerps from this value up to 1.00 in proportion to the
    -- trough's non-roughage fill ratio.
    self.beefGrazingFloor = 0.80
    -- Cap how much meadow grass can regrow PER IN-GAME DAY. Vanilla
    -- husbandry regrows the meadow to full overnight on the day-change
    -- tick, which makes grazing rotation pointless. Throttling to 8% of
    -- the meadow's capacity per day means a fully-depleted pasture takes
    -- ~12 in-game days to recover — realistic for actual grass regrowth.
    self.maxMeadowRegrowthPerDay = 0.40
    -- Floor for individual animal health while a herd is on a pasture under
    -- a registered barn. Vanilla's per-tick health recalc on the first frame
    -- Fertilizer / slurry boost: when the player applies fertilizer to a
    -- pasture, the next N in-game days get faster regrowth and faster
    -- quality recovery. Defaults below match real-world agronomy: NPK
    -- application roughly doubles short-term grass growth and helps the
    -- soil rebuild after grazing.
    -- Boost duration follows the player's "days per month" / period length
    -- setting, so a real-world ~30 day fertilizer cycle scales to whatever
    -- the player picked (3, 7, 14, ...). Falls back to 7 if the environment
    -- isn't loaded yet (e.g. during console use very early in boot).
    self.fertilizerBoostDays = 7  -- floor / fallback only — see getFertilizerBoostDays()
    self.fertilizerRegrowthMultiplier = 2.0
    self.fertilizerQualityMultiplier = 2.0
    -- Herdsman wage. When ANY registered barn on a farm has autoSchedule OR
    -- autoRotate ON, the player is charged once per in-game month (one
    -- Period) for the labour. Per-farm, flat fee — multiple barns on the
    -- same farm don't multiply the cost. Derived from 14 $/hr × 39 hr/wk ×
    -- 52 wk / 12 months ~ 2366 $/month.
    self.herdsmanHourlyWage = 14
    self.herdsmanHoursPerWeek = 39
    self.herdsmanMonthlyCost = math.floor(14 * 39 * 52 / 12)
    -- Starvation trigger thresholds. The herdsman rotates (or the player
    -- gets alerted) when meadow grass AND trough supplement both fall
    -- below these fractions. Earlier defaults of 1% / 1% meant the rotate
    -- fired only when vanilla had already dragged grass to literal zero,
    -- which feels abrupt and can leave cows that were still mid-walk
    -- stranded. Triggering at 10% gives the rotate breathing room — by
    -- the time RLM finishes transferring everything, the source pasture
    -- still has a tiny buffer of grass for in-flight cows to nibble at.
    self.starvationGrassPct = 0.10
    self.starvationSupplementPct = 0.05
    -- Registration proximity. Barn/pasture pickup is "nearest match", which
    -- with no cap happily grabbed a pen hundreds of metres away — players
    -- ended up attaching a pasture to a barn 400m off by accident. You must
    -- now be standing at the thing you're registering. Tunable via
    -- phRegisterMaxDistance (raise it if a large pasture placeable measures
    -- from its centre and legitimately reads further than this).
    self.registerMaxDistanceM = 10
    -- Auto-open gates (fence/hedgerow AnimatedObjects) that sit on a herd's walk
    -- path: opened at walk start, closed once the herd is through. 3-way mode:
    --   "all"      → every walk (manual + herdsman)
    --   "herdsman" → only automatic (herdsman) walks, not manual send/bring/move
    --   "off"      → never
    -- Set via the Settings tab or phGateAutoOpen. See GateController + WalkSession.
    self.gateAutoMode = "all"
    -- Pasture->barn separation that triggers a WARNING (not a block). A distant
    -- paddock is legitimate — the cows just walk further — so we flag it
    -- rather than refuse it.
    self.pastureBarnWarnDistanceM = 200
    -- Master toggle for the RLRM food-scale meadow-capacity compensation.
    -- When true (default), and RLRM's foodScale > 1.0, pasture meadow
    -- capacities scale by the same factor — pasture-vs-herd timing stays
    -- at the vanilla feel even when the player has consumption inflated.
    -- Flip OFF if you've calibrated your farm assuming pasture stays at
    -- vanilla cap regardless of RLRM consumption.
    self.meadowFoodScaleCompensation = true
    -- Fraction of total food intake that comes from the meadow (grass)
    -- when cows are on pasture. Vanilla cattle split is roughly
    -- 65% roughage / 25% energy/concentrate / 10% minerals — only the
    -- roughage portion drains the meadow, the rest comes from the
    -- pasture trough (supplement) or barn pump. Used by the Grazing
    -- tab's "grass lasts Xh" estimator so it matches what actually
    -- depletes the meadow.
    self.pastureGrassFoodFraction = 0.65
    -- In-memory notification ring buffer. Anything routed through
    -- pushNotification(...) is appended here so the Notifications tab in
    -- the menu can review past alerts the player may have missed. Newest
    -- entry first; capped at notificationLogMax to avoid unbounded growth.
    self._notifications = self._notifications or {}
    self.notificationLogMax = 50
    -- Auto-rotate threshold. Auto-rotation will KEEP grazing the current
    -- pasture until its grass drops below this fraction, then rotate to
    -- the best remaining pasture. Default 0 = rotate every day (old
    -- behavior). 0.30 = keep grazing until grass < 30%.
    self.rotateAtGrassPercent = 0.30
    -- Pasture food-trough capacity is capped at this many hours of vanilla
    -- consumption (per-herd, scales automatically with animal count via
    -- spec_husbandryFood.litersPerHour). Default 24 = one day; the player
    -- must refill at least daily, and can't stockpile a week's feed.
    self.pastureFoodCapacityHours = 24
    self.husbandryHook:install()
end

function PastureHerding:update(dt)
    HusbandryHook.processPendingAutoSubstitute()
    -- Startup alert moved to PHMenu:onOpen — it's shown the first time the
    -- player opens our menu rather than on game start. The player has
    -- already loaded into the world and read any other startup popups by
    -- then, so the alert lands when it's relevant.
    if self.debugContours and self.navMeshLayer ~= nil then
        self.navMeshLayer:drawDebugContours()
    end
    if self.debugPaths then
        self:drawDebugPaths()
    end
    -- Live recording overlay: while phPathRecord is ON, always draw the
    -- waypoints the player has captured so far (regardless of the global
    -- debugPaths toggle). Stops the instant recording is toggled off.
    if self._pathRecording and self._buildingPath ~= nil then
        self:drawRecordingPath()  -- 3D world line (drawDebugLine is legal in update)
    end
    -- The flashing 2D banner is rendered from PastureHerding:draw() (addDrawable),
    -- the only place renderText/renderOverlay are legal.
    if self.navMeshLayer ~= nil then
        self.navMeshLayer:pollReadiness()
    end
    -- Poll active sprayers for NPK-over-pasture detection. Precision Farming
    -- gates processSprayerArea on farmland, so the hook alone misses pasture
    -- placeables — this polling path catches "spreader turned on with NPK
    -- fill inside a registered pasture contour" once per second.
    if SprayerHook ~= nil and type(SprayerHook.poll) == "function" then
        self._sprayerPollAcc = (self._sprayerPollAcc or 0) + (dt or 0)
        if self._sprayerPollAcc >= 1000 then
            SprayerHook.poll(self._sprayerPollAcc)
            self._sprayerPollAcc = 0
        end
    end
    if self.debugSprayCoverage
        and SprayerHook ~= nil and type(SprayerHook.drawDebugCoverage) == "function" then
        SprayerHook.drawDebugCoverage()
    end
    self._sweepAcc = (self._sweepAcc or 0) + (dt or 0)
    if self._sweepAcc >= 2000 and self.navMeshLayer ~= nil then
        self._sweepAcc = 0
        self.navMeshLayer:sweepDeadEntries()
        self.navMeshLayer:sweepEmptyPensForAutoMigrate()
    end
    if self.grazingScheduler ~= nil then
        self.grazingScheduler:tick()
        self.grazingScheduler:tickSessions(dt)
        -- Grass-only productivity floor (dairy and beef). Runs every frame so
        -- vanilla husbandry's per-frame recalculation can't drag the displayed
        -- productivity bar back below the floor in between hourly ticks.
        self.grazingScheduler:_applyGrassProductivityFloor()
        -- Instant productivity recovery (force Red Tape's setting to Instant if
        -- present, else pin our barns) so a moved herd doesn't ramp.
        self.grazingScheduler:_applyInstantProductivity()
        -- Re-prime each freshly-moved husbandry's food demand
        -- (spec.litersPerHour) for a few seconds after a move so the next hourly
        -- updateFeeding reads real demand instead of a stale 0 (which is what
        -- makes RLRM dock ~35 health on arrival). See AnimalMover.refreshFoodDemand.
        if AnimalMover ~= nil and type(AnimalMover.tickPendingRefresh) == "function" then
            AnimalMover.tickPendingRefresh()
        end
        -- Show the pasture's food-pile mesh only when its barn's Feed Trough
        -- is enabled (beef default on, dairy default off), so the visual cue
        -- matches whether the player is meant to hand-feed at pasture.
        self.grazingScheduler:_applyPastureFoodNodeVisibility()
        -- Drain solid manure + slurry off every pasture back to the home
        -- barn's storage so the player doesn't have to drive out to each
        -- pasture with an empty tanker — the slurry pit / manure pile
        -- stays centralised at the barn.
        self.grazingScheduler:_pumpManureToBarn()
        -- Throttle vanilla's overnight meadow regrowth so depleted pastures
        -- take ~12 in-game days to recover instead of one tick.
        self.grazingScheduler:_throttleMeadowRegrowth()
        -- Realistic grass rule: meadow fillLevels can only DECREASE when
        -- cows are physically present. Without cows, any decrement vanilla
        -- tries to apply gets reverted to the last frame's value. Grass
        -- still grows freely via the regrowth drip. Catches the post-load
        -- "vanilla resets fill to a lower persisted value" case + any
        -- background decay vanilla applies to empty pastures.
        self.grazingScheduler:_holdMeadowFillWhenEmpty()
        -- Diagnostic: accumulate actual meadow drain vs cow count over
        -- time, dump via phMeadowDrain. Dev-only — this does a full
        -- barn×pasture×cluster rescan per frame, so gate it behind Verbose
        -- log output (devMode) like the other diagnostics.
        if PastureHerdingLog.devMode then
            self.grazingScheduler:_measureMeadowDrain()
        end
        -- Continuously decay soil quality on the active pasture while cows
        -- are grazing. Hour-rollover ticks apply 1/24 of the daily decay
        -- per in-game hour so quality drops gradually instead of in one
        -- shot at sunset.
        self.grazingScheduler:_applyPastureQualityDecay()
        -- RLRM food-scale compensation: when player has RLRM consumption
        -- > 1.0, scale meadow capacity by the same factor so pasture-vs-
        -- herd timing stays at the vanilla feel.
        self.grazingScheduler:_applyMeadowFoodScaleCompensation()
        -- Cap each active pasture's food trough at ~1 day of consumption so
        -- the player can't pre-load a week's feed and forget about it.
        self.grazingScheduler:_applyPastureFoodCapacityCap()
        -- Per-pasture animal-population cap from the Grazing tab picker.
        self.grazingScheduler:_applyPastureAnimalCapacityCap()
        -- Charge the player a monthly herdsman wage if any registered barn
        -- has autoSchedule or autoRotate ON. Idempotent — only fires once
        -- per farm per in-game Period rollover.
        self.grazingScheduler:_chargeHerdsmanWage()
        -- Watch for a fully-depleted pasture (no grass + empty trough).
        -- If herdsman select is ON, auto-rotate to the best alternate;
        -- otherwise notify the player so they can act. Debounced per barn.
        self.grazingScheduler:_checkPastureStarvation()
        -- Herdsman ON + a rotate threshold set: proactively walk the herd to a
        -- fresher pasture once the current one drops below the threshold (before
        -- it fully starves), using a recorded pasture-to-pasture path if there is
        -- one. No-op when no threshold is set (rotateAtGrassPercent == 0).
        self.grazingScheduler:_checkProactiveRotation()
    end
    self:tickWalkers(dt)
    self:tickPathRecorder()
    self:maybeRunDeferredPersistenceLoad()
    -- Read-only health/location/activity probe (phTraceStart). No-op unless a
    -- trace session is running; never mutates animal or husbandry state.
    if HealthTracer ~= nil and type(HealthTracer.tick) == "function" then
        HealthTracer.tick(dt)
    end
end

-- Central notification helper. Single entry point for every side-
-- notification the mod surfaces. Shows the popup AND appends to the in-
-- memory log so the Notifications tab can replay it later if the player
-- looked away. Color is a 4-table {r,g,b,a}; durationMs defaults to 5000.
function PastureHerding:pushNotification(color, text, durationMs)
    if type(text) ~= "string" or text == "" then return end
    color = color or {1.0, 1.0, 1.0, 1.0}
    durationMs = durationMs or 5000
    -- HUD pop-up. Wrapped in pcall so a HUD-missing scenario (early frame)
    -- doesn't break the log append below.
    local hud = g_currentMission and g_currentMission.hud
    if hud ~= nil and type(hud.addSideNotification) == "function" then
        pcall(function() hud:addSideNotification(color, text, durationMs) end)
    end
    -- Stamp with in-game day + hour so the log entry is meaningful to the
    -- player. Fall back to "?" if env isn't up yet.
    local env = g_currentMission and g_currentMission.environment
    local day = (env and env.currentDay) or 0
    local hour = (env and env.currentHour) or 0
    local minute = (env and env.currentMinute) or 0
    local stamp = string.format("Day %d  %02d:%02d", day, hour, minute)
    self._notifications = self._notifications or {}
    -- Prepend so newest is index 1 — saves the list frame from reversing
    -- on every refresh.
    table.insert(self._notifications, 1, {
        stamp = stamp, text = text, color = color, durationMs = durationMs,
    })
    -- Cap.
    local cap = self.notificationLogMax or 50
    while #self._notifications > cap do
        table.remove(self._notifications)
    end
end

function PastureHerding:tickWalkers(dt)
    if self._activeWalkers ~= nil then
        for i = #self._activeWalkers, 1, -1 do
            local w = self._activeWalkers[i]
            w:update(dt)
            if w:isDone() then
                PastureHerdingLog.dev("Walker: completed at end of path")
                w:delete()
                table.remove(self._activeWalkers, i)
            end
        end
    end
    if self._activeWalkSessions ~= nil then
        for i = #self._activeWalkSessions, 1, -1 do
            local s = self._activeWalkSessions[i]
            s:update(dt)
            if s:isDone() then
                table.remove(self._activeWalkSessions, i)
            end
        end
    end
end

function PastureHerding:maybeRunDeferredPersistenceLoad()
    if not self._pendingPersistenceLoad then return end
    if self.grazingRegistry == nil then
        self._pendingPersistenceLoad = false
        return
    end
    local hs = g_currentMission and g_currentMission.husbandrySystem
    if hs == nil or hs.placeables == nil then return end
    local hasAny = false
    for _ in pairs(hs.placeables) do hasAny = true; break end
    if not hasAny then return end

    self._pendingPersistenceLoad = false
    local okLoad, errLoad = pcall(function() GrazingPersistence.load(self.grazingRegistry, self.grazingScheduler) end)
    if not okLoad then
        PastureHerdingLog.warning("GrazingPersistence.load raised: %s", tostring(errLoad))
    end
    -- After persisted state is restored, reconcile each barn against the
    -- current clock + gates so cows that were saved at pasture either resume
    -- grazing (window still open) or come home (window closed). Avoids the
    -- "save+exit while grazing strands cows at pasture" failure mode.
    if self.grazingScheduler ~= nil and type(self.grazingScheduler.reconcileOnLoad) == "function" then
        local okRec, errRec = pcall(function() self.grazingScheduler:reconcileOnLoad() end)
        if not okRec then
            PastureHerdingLog.warning("GrazingScheduler:reconcileOnLoad raised: %s", tostring(errRec))
        end
    end
    -- One-time PF soil backfill for pastures registered before the PF feature (or
    -- before PF was added to this save) — no remove/re-add needed.
    local okBf, errBf = pcall(function() self:backfillPFSoil() end)
    if not okBf then
        PastureHerdingLog.warning("PastureHerding:backfillPFSoil raised: %s", tostring(errBf))
    end
    -- Re-push our persisted custom names onto the placeables so RLRM and the
    -- vanilla menus show them again after a reload (they may not persist the
    -- name field we wrote at rename time).
    if self.grazingRegistry ~= nil and type(self.grazingRegistry.reapplyGlobalNames) == "function" then
        pcall(function() self.grazingRegistry:reapplyGlobalNames() end)
    end
end

-- One-shot startup alert. Shown exactly once per SAVE — when the player
-- dismisses it, an "ack" flag is set on the framework and persisted to the
-- grazing XML, so the dialog never re-appears for that save. The flag also
-- gates the function so the dialog doesn't try to re-show during the same
-- session.
function PastureHerding:_maybeShowStartupAlert()
    if self._startupAlertShown then return end
    if self.startupAlertAcked == true then
        self._startupAlertShown = true
        return
    end
    if g_currentMission == nil or g_currentMission.time == nil then return end
    local elapsed = g_currentMission.time - (self.loadCompletedAt or 0)
    if elapsed < 5000 then return end
    if g_gui == nil or type(g_gui.showDialog) ~= "function" then
        self._startupAlertShown = true
        return
    end

    -- Lead with a missing-mod warning if the player launched without RLRM
    -- (the only HARD requirement). HerdingLite is recommended but the mod
    -- has a fallback walker so it doesn't surface here.
    -- Keep this SHORT: FS25's InfoDialog text box is a fixed height and does
    -- not scroll, so a long message clips at the bottom. The full setup guide,
    -- every feature, and troubleshooting live on the Information tab (which this
    -- points the player to), so here we only cover the must-do essentials.
    local missingWarning = ""
    if self.requiredModsMissing ~= nil and #self.requiredModsMissing > 0 then
        missingWarning = "MISSING REQUIRED MOD(S): "
            .. table.concat(self.requiredModsMissing, ", ")
            .. ". Most features are inactive without it — install and reload.\n\n"
    end

    local msg = missingWarning
        .. "Pasture Grazing System - quick setup\n\n"
        .. "Before putting animals on pasture:\n\n"
        .. "1. Set Period length to AT LEAST 3 days per month (Settings > General).\n\n"
        .. "2. Pick your RLRM food scale, then SAVE the game - the mod reads RLRM's setting from the save file. Re-save whenever you change it.\n\n"
        .. "Full setup, all features, and troubleshooting are on the INFORMATION tab. Open the menu with Ctrl+P.\n\n"
        .. "Click OK - this won't appear again for this save."

    -- AutoDrive's pattern (register.lua:375). g_gui:showDialog returns a
    -- wrapper whose .target is the actual InfoDialog instance — configure
    -- via setters. setCallback fires on OK click; we use it to persist the
    -- ack so the dialog never re-shows on this save.
    local dialogResult, hasTarget, hasSetText
    local ok, err = pcall(function()
        local dialog = g_gui:showDialog("InfoDialog")
        dialogResult = dialog
        if dialog and dialog.target then
            hasTarget = true
            local t = dialog.target
            hasSetText = type(t.setText) == "function"
            if type(t.setDialogType) == "function" and DialogElement and DialogElement.TYPE_WARNING then
                t:setDialogType(DialogElement.TYPE_WARNING)
            end
            if type(t.setText) == "function" then t:setText(msg) end
            if type(t.setCallback) == "function" then
                t:setCallback(function() self:_onStartupAlertAcked() end, self)
            end
        end
    end)
    PastureHerdingLog.dev("startup alert: showDialog ok=%s err=%s dialog=%s hasTarget=%s hasSetText=%s",
        tostring(ok), tostring(err), tostring(dialogResult),
        tostring(hasTarget), tostring(hasSetText))
    self._startupAlertShown = true
end

function PastureHerding:_onStartupAlertAcked()
    if self.startupAlertAcked == true then return end
    self.startupAlertAcked = true
    PastureHerdingLog.dev("startup alert acknowledged — won't reappear")
end

function PastureHerding:load()
    self.hasRLM = RealisticLivestock_AnimalClusterHusbandry ~= nil
        or (_G and _G.RealisticLivestock_AnimalClusterHusbandry ~= nil)
        or (g_modIsLoaded ~= nil and g_modIsLoaded["FS25_RealisticLivestockRM"] == true)

    -- Hard-required: Realistic Livestock RM. The mod's per-animal health,
    -- food-scale compensation, and cluster handling are all built around
    -- RLRM's API. Without it most features either no-op or produce stale
    -- data, so surface a loud red alert at first menu-open instead of
    -- letting the player wonder why nothing seems to work.
    self.requiredModsMissing = {}
    if not self.hasRLM then
        self.requiredModsMissing[#self.requiredModsMissing + 1] = "Realistic Livestock RM (FS25_RealisticLivestockRM)"
        PastureHerdingLog.error("Required mod missing: FS25_RealisticLivestockRM. The mod will load but most features are inactive without it.")
    end
    -- Soft-recommended: Animal Herding Lite. Walker.lua has a bare-clone
    -- fallback for when this isn't available, so log + continue rather
    -- than nag — animations just look less polished without it.
    self.hasHerdingLite = g_modIsLoaded ~= nil and g_modIsLoaded["FS25_AnimalHerdingLite"] == true
    if not self.hasHerdingLite then
        PastureHerdingLog.info("Recommended mod not loaded: FS25_AnimalHerdingLite. Walker falls back to bare clone (animations less polished).")
    end

    self.animalManager = rawget(_G, "g_animalManager") or g_animalManager
    self.HerdableAnimal = rawget(_G, "HerdableAnimal") or HerdableAnimal
    if self.animalManager ~= nil then
        PastureHerdingLog.dev("captured g_animalManager from PastureHerding scope (Herding Lite integration ready)")
    end
    if self.grazingScheduler ~= nil then
        self.grazingScheduler.transitTracker = self.transitTracker
        self.grazingScheduler.animalManager = self.animalManager
    end
    self.loadCompletedAt = (g_currentMission and g_currentMission.time) or 0
    self.loadGracePeriodMs = 10000  -- defer save-load substitutes until this long after load
    self._startupAlertShown = false
    PastureHerdingLog.info("loaded v%s (RLM detected: %s, loadCompletedAt=%d)",
        self.version, tostring(self.hasRLM), self.loadCompletedAt)

    HusbandryHook.installLateClusterHook()
    HusbandryHook.installContourHook()
    if self.hasRLM then HusbandryHook.installSetClustersHook() end
    self:registerExistingPlaceables()
    self:registerConsoleCommands()
    self:installSaveHook()
    -- Defer the persistence restore: at loadMapFinished, placeables aren't
    -- yet in g_currentMission.husbandrySystem.placeables; they show up as
    -- the per-placeable create events fire over the next 1-2 seconds. The
    -- update tick will fire the restore once placeables are present.
    self._pendingPersistenceLoad = self.grazingRegistry ~= nil
    if g_currentMission ~= nil and g_currentMission.addUpdateable ~= nil then
        g_currentMission:addUpdateable(self)
        PastureHerdingLog.dev("registered as updateable for debug overlay")
    end
    -- Separate DRAW registration: addDrawable objects get :draw() in the draw
    -- phase, the only place renderText/renderOverlay (the recording banner) are
    -- legal. (addUpdateable only gives update.)
    if g_currentMission ~= nil and type(g_currentMission.addDrawable) == "function" then
        g_currentMission:addDrawable(self)
        PastureHerdingLog.dev("registered as drawable (recording banner)")
    end
end

-- Draw-phase callback (via addDrawable). Only 2D HUD rendering belongs here.
function PastureHerding:draw()
    if self._pathRecording then
        self:drawRecordingHud()
    end
end

function PastureHerding:installSaveHook()
    if self._saveHookInstalled then return end
    local framework = self
    -- FSCareerMissionInfo.saveToXMLFile fires when the player saves the game.
    -- We append our own write afterwards so the side-XML lands in the same
    -- savegame folder.
    if FSCareerMissionInfo ~= nil and FSCareerMissionInfo.saveToXMLFile ~= nil then
        FSCareerMissionInfo.saveToXMLFile = Utils.appendedFunction(
            FSCareerMissionInfo.saveToXMLFile,
            function(missionInfo, ...)
                if framework.grazingRegistry == nil then return end
                local ok, err = pcall(function() GrazingPersistence.save(framework.grazingRegistry, framework.grazingScheduler) end)
                if not ok then
                    PastureHerdingLog.warning("GrazingPersistence.save raised: %s", tostring(err))
                end
                -- Re-read RLRM's foodScale from disk on save. RLRM writes
                -- its settings XML during the same save event, so a moment
                -- later we can pick up the new value. Player can therefore
                -- change RLRM's setting in-game -> save -> the new scale
                -- takes effect on the next frame, without a full reload.
                if GrazingScheduler ~= nil and type(GrazingScheduler.refreshRLRMFoodScale) == "function" then
                    pcall(function() GrazingScheduler.refreshRLRMFoodScale() end)
                end
            end
        )
        self._saveHookInstalled = true
        PastureHerdingLog.dev("installSaveHook: appended FSCareerMissionInfo.saveToXMLFile")
    else
        PastureHerdingLog.warning("installSaveHook: FSCareerMissionInfo.saveToXMLFile not available; grazing persistence won't save")
    end
end

-- ===== British Fill Units (BFU) compatibility =====
-- BFU (FS25_BritishFillUnits) is a display-only mod that presents fill amounts
-- as mass (kg / t) instead of litres, using each fill type's massPerLiter
-- density. It converts vanilla/RL displays via its own hooks, but it can't
-- convert OUR custom menu labels ("Food:", "Grass:") — they carry no fill-type
-- name it can parse, and we don't set its fill-type context. So we do the same
-- conversion ourselves, gated on BFU being present, using the standard
-- fillType.massPerLiter. Falls back to litres when BFU is absent. (No double-
-- conversion: BFU only touches litre-patterns with a known fill type; our
-- output is either litres it ignores or already mass.)
function PastureHerding:isBFUActive()
    if self._bfuActive == nil then
        local active = false
        local MOD = "FS25_BritishFillUnits"
        -- FS25 sandboxes each mod's globals, so BFU's `BFU` global is NOT
        -- visible in our scope (same reason RLRM is reached via its namespace).
        -- Detect via the engine mod list. g_modIsLoaded is a TABLE keyed by mod
        -- name (g_modIsLoaded["FS25_..."]); guard for a function form too.
        -- Plain global access (NOT rawget): engine globals resolve through the
        -- sandbox's __index metatable; rawget bypasses it and returns nil.
        local ml = g_modIsLoaded
        if type(ml) == "table" then
            if ml[MOD] then active = true end
            -- Fuzzy fallback: the zip may be renamed/version-suffixed
            -- (e.g. FS25_BritishFillUnits_v1.3), so scan for any loaded mod
            -- whose name contains "BritishFillUnit".
            if not active then
                for name, loaded in pairs(ml) do
                    if loaded and type(name) == "string" and name:find("BritishFillUnit") then
                        active = true
                        break
                    end
                end
            end
        elseif type(ml) == "function" then
            local ok, res = pcall(ml, MOD)
            if ok and res == true then active = true end
        end
        -- Authoritative: scan the mod manager's registry for any active mod
        -- whose name / title / dir contains "british" (BFU's zip + title).
        if not active then
            local mm = g_modManager
            if type(mm) == "table" then
                local lists = {}
                for _, k in ipairs({ "mods", "modsByName", "nameToMod", "activeMods" }) do
                    if type(mm[k]) == "table" then lists[#lists + 1] = mm[k] end
                end
                for _, list in ipairs(lists) do
                    for _, mod in pairs(list) do
                        if type(mod) == "table" then
                            for _, field in ipairs({ mod.modName, mod.title, mod.modDir, mod.name }) do
                                if type(field) == "string" and field:lower():find("british") then
                                    active = true
                                    break
                                end
                            end
                        end
                        if active then break end
                    end
                    if active then break end
                end
            end
        end
        -- Last-ditch fallbacks: the mod-env namespace table (FS25 exposes each
        -- mod's env as a global named after the mod), or the BFU global itself.
        if not active and rawget(_G, MOD) ~= nil then active = true end
        if not active and rawget(_G, "BFU") ~= nil then active = true end
        self._bfuActive = active
        PastureHerdingLog.dev("BFU compat: British Fill Units %s", active and "DETECTED — fill displays will use kg/t" or "not loaded — fill displays stay in litres")
    end
    return self._bfuActive
end

-- BFU converts SOLIDS to mass but keeps genuine LIQUIDS in litres ("genuine
-- liquids remain in litres"). Skip conversion for these so water/milk/slurry
-- stay in litres like the rest of the game under BFU.
local BFU_LIQUID_FILLTYPES = {
    WATER = true, MILK = true, LIQUIDMANURE = true, DIGESTATE = true,
    LIQUIDFERTILIZER = true, DIESEL = true, DEF = true, ADBLUE = true,
    HERBICIDE = true, OIL = true, BEER = true, WINE = true, JUICE = true,
    CHOCOLATE = true, BUTTER = true,
}

-- fillType.massPerLiter -> tonnes per litre. FS25 stores most as t/l (<= 0.01);
-- a few as kg/l (> 0.01), which BFU divides by 1000 — mirror that. Returns nil
-- for liquids (keep litres) and for unresolvable/zero densities.
local function bfuTonnesPerLiter(fillTypeIndex)
    if fillTypeIndex == nil or g_fillTypeManager == nil then return nil end
    local ft = g_fillTypeManager:getFillTypeByIndex(fillTypeIndex)
    if ft == nil then return nil end
    if ft.name ~= nil and BFU_LIQUID_FILLTYPES[ft.name] then return nil end
    local mpl = ft.massPerLiter
    if type(mpl) ~= "number" or mpl <= 0 then return nil end
    if mpl > 0.01 then mpl = mpl / 1000 end
    return mpl
end

-- Format a mass (tonnes) the way BFU does: < 1000 kg -> "N kg", else "N t".
local function bfuFormatMass(tonnes)
    local kg = tonnes * 1000
    if kg < 1000 then
        local prec = (kg > 0 and kg < 10) and 1 or 0
        return string.format("%." .. prec .. "f kg", kg)
    end
    return string.format("%.1f t", tonnes)
end

-- Pick a representative fillType from a spec's fillLevels table (first with a
-- level, else first key). nil if none — callers then fall back to litres.
function PastureHerding.firstFillType(spec)
    if spec == nil or type(spec.fillLevels) ~= "table" then return nil end
    local firstKey
    for ft, lvl in pairs(spec.fillLevels) do
        if firstKey == nil then firstKey = ft end
        if type(lvl) == "number" and lvl > 0 then return ft end
    end
    return firstKey
end

-- Format a single fill amount (litres). Under BFU with a resolvable density,
-- returns kg/t; otherwise "N L".
function PastureHerding:fmtVol(liters, fillTypeIndex)
    liters = tonumber(liters) or 0
    if self:isBFUActive() then
        local d = bfuTonnesPerLiter(fillTypeIndex)
        if d ~= nil then return bfuFormatMass(liters * d) end
    end
    return string.format("%.0f L", liters)
end

-- Format a fill / capacity pair -> "0.4 / 0.5 t" or "400 / 500 L".
function PastureHerding:fmtVolPair(fill, cap, fillTypeIndex)
    fill = tonumber(fill) or 0
    cap = tonumber(cap) or 0
    if self:isBFUActive() then
        local d = bfuTonnesPerLiter(fillTypeIndex)
        if d ~= nil then
            return bfuFormatMass(fill * d) .. " / " .. bfuFormatMass(cap * d)
        end
    end
    return string.format("%.0f / %.0f L", fill, cap)
end

-- Format a per-day rate -> "2.2 t/day" or "2223 L/day".
function PastureHerding:fmtVolPerDay(litersPerDay, fillTypeIndex)
    litersPerDay = tonumber(litersPerDay) or 0
    if self:isBFUActive() then
        local d = bfuTonnesPerLiter(fillTypeIndex)
        if d ~= nil then return bfuFormatMass(litersPerDay * d) .. "/day" end
    end
    return string.format("%.0f L/day", litersPerDay)
end

-- Diagnostic: dump the mod registry so we can see how BFU is named/keyed and
-- why detection resolves the way it does. Run `phBFU` in the console.
function PastureHerding:consoleBFU()
    local out = { "=== phBFU: British Fill Units detection ===" }
    local ml = g_modIsLoaded  -- plain access (rawget bypasses the sandbox __index)
    out[#out + 1] = "g_modIsLoaded type = " .. type(ml)
    if type(ml) == "table" then
        local keys = {}
        for k, v in pairs(ml) do keys[#keys + 1] = tostring(k) .. "=" .. tostring(v) end
        table.sort(keys)
        out[#out + 1] = string.format("  keys (%d): %s", #keys, table.concat(keys, ", "))
    end
    local mm = g_modManager
    out[#out + 1] = "g_modManager type = " .. type(mm)
    if type(mm) == "table" then
        for _, k in ipairs({ "mods", "modsByName", "nameToMod", "activeMods" }) do
            if type(mm[k]) == "table" then
                local names = {}
                for key, mod in pairs(mm[k]) do
                    if type(mod) == "table" then
                        names[#names + 1] = tostring(mod.modName or mod.name or key)
                    else
                        names[#names + 1] = tostring(key)
                    end
                end
                out[#out + 1] = string.format("  g_modManager.%s (%d): %s", k, #names, table.concat(names, ", "))
            end
        end
    end
    self._bfuActive = nil  -- force fresh detection
    out[#out + 1] = "isBFUActive() = " .. tostring(self:isBFUActive())
    out[#out + 1] = string.format("rawget BFU=%s  FS25_BritishFillUnits=%s",
        tostring(rawget(_G, "BFU") ~= nil), tostring(rawget(_G, "FS25_BritishFillUnits") ~= nil))
    PastureHerdingLog.info("%s", table.concat(out, "\n"))
    return "phBFU: dumped mod registry + BFU detection to log.txt (grep 'phBFU')"
end

function PastureHerding:registerConsoleCommands()
    if addConsoleCommand == nil then return end
    addConsoleCommand("phShrinkNextPen", "Substitute the next-placed empty pen's contour with a small test square",
        "consoleShrinkNextPen", self)
    addConsoleCommand("phShrinkLast", "Substitute the LAST-created empty husbandry's contour with a small test square",
        "consoleShrinkLast", self)
    addConsoleCommand("phReshapeLast", "Re-shape the LAST-created empty husbandry's contour (repeatable; uses phShrinkRadius)",
        "consoleReshapeLast", self)
    addConsoleCommand("phRestoreLast", "Restore the LAST-substituted empty husbandry to its original engine nav mesh",
        "consoleRestoreLast", self)
    addConsoleCommand("phShrinkToContour", "Substitute the LAST husbandry's nav mesh using its REAL outdoorContourPolygon (e.g. FencelessPastures fence)",
        "consoleShrinkToContour", self)
    addConsoleCommand("phMigrateNearest", "Migrate the NEAREST husbandry to its outdoorContourPolygon. WARNING: pens with animals will show data preserved but visuals invisible (RLM redistribution fails for procedural nav meshes). Empty pens only is recommended.",
        "consoleMigrateNearest", self)
    addConsoleCommand("phAutoSubstitute", "Toggle auto-substitute on new placement (default ON).",
        "consoleAutoSubstitute", self)
    addConsoleCommand("phShrinkRadius", "Set the test-square half-size (meters). Default 5.",
        "consoleShrinkRadius", self)
    addConsoleCommand("phStatus", "Print PastureHerding framework status.", "consoleStatus", self)
    addConsoleCommand("phBFU", "Dump the mod registry (g_modIsLoaded / g_modManager) + our British Fill Units detection, to debug BFU unit compatibility.", "consoleBFU", self)
    if HealthTracer ~= nil then
        addConsoleCommand("phTraceStart", "Start the animal health/location/activity tracer. Emits [PHTrace] CSV rows every N seconds + on every game-hour. Usage: phTraceStart [sec=5].",
            "consoleTraceStart", HealthTracer)
        addConsoleCommand("phTraceMark", "Write a labelled marker sample into the trace timeline. Usage: phTraceMark <label> (e.g. phTraceMark BEFORE MOVE).",
            "consoleTraceMark", HealthTracer)
        addConsoleCommand("phTraceSnap", "Emit one trace sample right now (works even when the tracer is stopped).",
            "consoleTraceSnap", HealthTracer)
        addConsoleCommand("phTraceStop", "Stop the tracer and print a per-animal first/last/min health summary.",
            "consoleTraceStop", HealthTracer)
    end
    addConsoleCommand("phFoodScale", "Print the current RLRM food-scale value our scheduler reads, plus per-pasture cap snapshots.", "consoleFoodScale", self)
    addConsoleCommand("phReloadRLRMSettings", "Re-read RLRM's foodScale from rm_RlSettings.xml on disk. Use after changing the setting in RLRM's menu if grass cap isn't reflecting the new value.", "consoleReloadRLRMSettings", self)
    addConsoleCommand("phMeadowDrain", "Dump per-pasture meadow drain rate measured so far. Compares actual L/hr drain to the per-cow rate RLM publishes. Resets the counter on dump.", "consoleMeadowDrain", self)
    addConsoleCommand("phProbeMeadow", "Dump every field on every registered pasture's spec_husbandryMeadow plus capacity-getter results. Used to find which field vanilla husbandry UI is reading for the meadow cap.", "consoleProbeMeadow", self)
    addConsoleCommand("phUnscaleMeadow", "Undo ONE round of food-scale compounding on every registered pasture's meadow cap (divides cap + fillLevels by current RLRM foodScale, clears the snapshot, lets the scaler re-snapshot and re-multiply cleanly). Run multiple times for a multi-cycle compounded save.", "consoleUnscaleMeadow", self)
    addConsoleCommand("phDebugContours", "Toggle green debug-line overlay on substituted nav-mesh contours.",
        "consoleDebugContours", self)
    addConsoleCommand("phDumpPlaceable", "Dump all spec_* fields on the LAST-created husbandry's placeable to log.",
        "consoleDumpPlaceable", self)
    addConsoleCommand("phEmptyTrough", "Empty the food trough of the NEAREST registered pasture (all fillLevels set to 0).",
        "consoleEmptyTrough", self)
    addConsoleCommand("phEmptyMeadow", "Empty the GRASS meadow of the NEAREST registered pasture (spec_husbandryMeadow fillLevels -> 0). Starvation test: with the trough ALSO empty, foodFactor drops and health should decline over the next few hours.",
        "consoleEmptyMeadow", self)
    addConsoleCommand("phFillMeadow", "Fill the GRASS meadow of the NEAREST registered pasture to capacity (spec_husbandryMeadow fillLevels -> max). Companion to phEmptyMeadow for testing grazing / rotation.",
        "consoleFillMeadow", self)
    addConsoleCommand("phRenewPasture", "Reseed the NEAREST registered pasture: full soil quality, bare grass, ~2-month grazing lockout, size-scaled cost. Testing companion for the Grazing-tab Renew button.",
        "consoleRenewPasture", self)
    addConsoleCommand("phProbePF", "Probe the Precision Farming DLC soil API (dumps g_precisionFarming + soil/yield maps + a sample query at the nearest pasture to log.txt). Used to wire pasture soil quality to PF's real soil map.",
        "consoleProbePF", self)
    addConsoleCommand("phPFSoil", "Print the Precision-Farming-derived soil quality for the NEAREST registered pasture (read-only test of feature #4's soil sampling).",
        "consolePFSoil", self)
    addConsoleCommand("phPFpH", "Print the Precision Farming pH factor for the NEAREST registered pasture (soil pH vs the optimal pH for its soil type -> grass-growth modifier). Read-only test.",
        "consolePFpH", self)
    addConsoleCommand("phLimePasture", "Fully lime the NEAREST registered pasture (pH correction -> 100%%), removing its pH grass penalty. Tests the lime->pH loop without driving a spreader.",
        "consoleLimePasture", self)
    addConsoleCommand("phProbeSpray", "Dump the terrain groundType + sprayType at the player/vehicle position. Spread lime on a REAL field, drive onto it, run this to read the values our lime ground-texture paint should match.",
        "consoleProbeSpray", self)
    addConsoleCommand("phSoilReport", "Per-pasture soil/state table (grass %, soil quality, pH factor, effective quality, lime %, fertilizer, renewal) for every registered pasture. Prints to log.txt — run repeatedly to watch values move.",
        "consoleSoilReport", self)
    addConsoleCommand("phHerdReport", "Per-barn animal table (count, avg health, avg weight vs target, milk l/h, milk stored, grazing condition, production type + schedule). Prints to log.txt.",
        "consoleHerdReport", self)
    addConsoleCommand("phProbeCluster", "Dump the nearest registered barn's animal cluster system internals — supported subtypes (breeds), addAnimal API, lactation fields. Diagnostic for the upcoming phFillDairy / phFillBeef commands.",
        "consoleProbeCluster", self)
    addConsoleCommand("phFillDairy", "Test-fill the nearest registered barn with lactating dairy cows. Usage: phFillDairy [breed]. Breeds: holstein, redholstein, brownswiss, ayrshire, kerry, shorthornmilkers, jersey, guernsey. No arg = mix of every installed dairy breed.",
        "consoleFillDairy", self)
    addConsoleCommand("phFillBeef", "Test-fill the nearest registered barn with beef cattle. Usage: phFillBeef [breed]. Breeds: angus, redangus, limousin, simmental, charolais, hereford, shorthorn, irishmoiled, britishblue, beltedgalloway. No arg = mix of every installed beef breed.",
        "consoleFillBeef", self)
    addConsoleCommand("phRegisterBarn", "Mark the NEAREST cow husbandry as a grazing barn (home for the cows at night).",
        "consoleRegisterBarn", self)
    addConsoleCommand("phAddPasture", "Add the NEAREST fenceless pasture as a pasture for the NEAREST registered barn (walk to the barn you want, hit phAddPasture).",
        "consoleAddPasture", self)
    addConsoleCommand("phSetActivePasture", "Set today's active pasture index for a barn. Usage: phSetActivePasture <pastureIdx> [barnIdx=1].",
        "consoleSetActivePasture", self)
    addConsoleCommand("phForceTransition", "Manually trigger transition. Usage: phForceTransition out|in.",
        "consoleForceTransition", self)
    addConsoleCommand("phGrazingStatus", "Print grazing registry + scheduler state.",
        "consoleGrazingStatus", self)
    addConsoleCommand("phFeedStatus", "Dump per-husbandry food / water / milk / manure levels + average herd health for every registered barn and pasture.",
        "consoleFeedStatus", self)
    addConsoleCommand("phAutoFeed", "Toggle auto-feeding registered barns: while ON, every in-game hour any barn whose food OR straw is below 50% gets topped up to 100%. Optional arg: on / off.",
        "consoleAutoFeed", self)
    addConsoleCommand("phUnregisterGrazing", "Remove the NEAREST registered barn or pasture from the grazing registry.",
        "consoleUnregisterGrazing", self)
    addConsoleCommand("phCycleActivePasture", "Advance the active pasture to the next one in the rotation. Usage: phCycleActivePasture [barnIdx=1].",
        "consoleCycleActivePasture", self)
    addConsoleCommand("phSetActiveByPosition", "Set the active pasture by picking the NEAREST registered pasture to your position. Usage: phSetActiveByPosition [barnIdx=1].",
        "consoleSetActiveByPosition", self)
    addConsoleCommand("phAutoRotate", "Toggle automatic pasture rotation at sunset. Usage: phAutoRotate on|off [barnIdx=1].",
        "consoleAutoRotate", self)
    addConsoleCommand("phFertilize", "Apply fertilizer / slurry boost to the NEAREST registered pasture. Faster regrowth + faster soil-quality recovery. Default duration matches the savegame's days-per-month setting (one in-game month). Usage: phFertilize [days].",
        "consoleFertilize", self)
    addConsoleCommand("phSprayDebug", "Dump every loaded sprayer's state (turnedOn, fillType, position, pasture-containment) so we can see why the boost isn't firing.",
        "consoleSprayDebug", self)
    addConsoleCommand("phSprayCoverDebug", "Toggle the red-square overlay drawn on every uncovered fertilizer cell of every registered pasture. Use to plan stripe-pattern passes.",
        "consoleSprayCoverDebug", self)
    addConsoleCommand("phPathAdd", "Add your current world position as the next waypoint on the NEAREST registered pasture's path.",
        "consolePathAdd", self)
    addConsoleCommand("phPathClear", "Clear the waypoint path of the NEAREST registered pasture.",
        "consolePathClear", self)
    addConsoleCommand("phPathReverse", "Reverse the waypoint order on the NEAREST registered pasture's path.",
        "consolePathReverse", self)
    addConsoleCommand("phPathStart", "Start building a path for a specific pasture. Usage: phPathStart <pastureIdx> [barnIdx=1].",
        "consolePathStart", self)
    addConsoleCommand("phRegisterMaxDistance", "Max distance (m) you may stand from a barn/pasture when registering it (default 10). Usage: phRegisterMaxDistance <meters>.",
        "consoleRegisterMaxDistance", self)
    addConsoleCommand("phPathStartPair", "Start building a pasture->pasture path so an out herd can walk straight between pastures. Usage: phPathStartPair <fromPastureIdx> <toPastureIdx> [barnIdx=1].",
        "consolePathStartPair", self)
    addConsoleCommand("phMovePasture", "Walk the herd that is currently OUT straight to another pasture along a recorded pasture->pasture path. Usage: phMovePasture <toPastureIdx> [barnIdx=1].",
        "consoleMovePasture", self)
    addConsoleCommand("phPathFinish", "Stop building the current path. Future phPathAdd reverts to nearest-pasture pick.",
        "consolePathFinish", self)
    addConsoleCommand("phPathRecord", "Toggle path recording. While ON, your position is sampled every ~3m and added as a waypoint to the building pasture. Walk or drive the route, then run again to stop.",
        "consolePathRecord", self)
    addConsoleCommand("phPathSampleEvery", "Set the recording sample spacing in meters (default 3.0). Usage: phPathSampleEvery <meters>.",
        "consolePathSampleEvery", self)
    addConsoleCommand("phPathShow", "Toggle yellow debug overlay of all registered paths.",
        "consolePathShow", self)
    addConsoleCommand("phTestGate", "Find the nearest gate (AnimatedObject within 15m) and open/close it, to confirm the auto-open adapter works. Usage: phTestGate [open|close].",
        "consoleTestGate", self)
    addConsoleCommand("phGateAutoOpen", "Set auto-gate mode: all (every walk) | herdsman (auto walks only, not manual) | off. Usage: phGateAutoOpen all|herdsman|off.",
        "consoleGateAutoOpen", self)
    addConsoleCommand("phHLProbe", "Probe whether Herding Lite's g_animalManager is accessible from our mod scope.",
        "consoleHLProbe", self)
    addConsoleCommand("phTestWalk", "Spawn one cow walker from barn through the ACTIVE pasture's waypoints. Diagnostic only.",
        "consoleTestWalk", self)
    addConsoleCommand("phTestWalkMany", "Spawn N walkers staggered every 3s along the ACTIVE pasture's path. Usage: phTestWalkMany <count>.",
        "consoleTestWalkMany", self)
    PastureHerdingLog.dev("console commands registered: pasture (phShrinkNextPen/phShrinkLast/phReshapeLast/phRestoreLast/phShrinkToContour/phMigrateNearest/phAutoSubstitute/phShrinkRadius/phStatus/phDebugContours/phDumpPlaceable) + grazing (phRegisterBarn/phAddPasture/phSetActivePasture/phForceTransition/phGrazingStatus/phUnregisterGrazing/phCycleActivePasture/phSetActiveByPosition/phAutoRotate) + paths (phPathAdd/phPathClear/phPathReverse/phPathShow)")
end

function PastureHerding:consoleShrinkNextPen()
    self.shrinkNextPen = true
    return string.format("PastureHerding: next-placed empty pen will be shrunk to a %.1fm half-square. Place a pen now.", self.shrinkRadius)
end

local function runSquareReshape(framework, label)
    local clusterHusbandry = HusbandryHook.lastClusterHusbandry
    local placeable = HusbandryHook.lastPlaceable
    if placeable == nil or clusterHusbandry == nil then
        return "PastureHerding: no recent husbandry captured yet. Place a pen first."
    end

    local cx, cy, cz = framework.navMeshLayer:getCenter(placeable)
    if cx == nil or (cx == 0 and cy == 0 and cz == 0) then
        local navNode = clusterHusbandry.navigationNode
        if navNode ~= nil and navNode ~= 0 and getWorldTranslation ~= nil then
            cx, cy, cz = getWorldTranslation(navNode)
        end
    end
    cx = cx or 0; cy = cy or 0; cz = cz or 0

    if cx == 0 and cy == 0 and cz == 0 then
        return "PastureHerding: cannot determine pen center (cached center is origin and nav node is at origin too)"
    end

    local r = framework.shrinkRadius or 5.0
    local contour = ContourBuilder.buildSquareAround3D(cx, cy, cz, r)
    PastureHerdingLog.info("%s: contour at center (%.2f,%.2f,%.2f) radius=%.2f for last husbandryId=%s",
        label, cx, cy, cz, r, tostring(clusterHusbandry.husbandryId))
    local ok = framework.navMeshLayer:substituteContour(placeable, contour)
    return ok and ("PastureHerding: " .. label .. " succeeded") or ("PastureHerding: " .. label .. " FAILED (see log)")
end

function PastureHerding:consoleShrinkLast()
    return runSquareReshape(self, "phShrinkLast")
end

function PastureHerding:consoleReshapeLast()
    return runSquareReshape(self, "phReshapeLast")
end

function PastureHerding:consoleShrinkToContour()
    local placeable = HusbandryHook.lastPlaceable
    if placeable == nil then
        return "PastureHerding: no recent placeable. Place a pen first."
    end
    local contour, err = ContourReader.readOutdoorContourPolygon(placeable)
    if contour == nil then
        PastureHerdingLog.error("phShrinkToContour: %s", tostring(err))
        return "PastureHerding: " .. tostring(err)
    end
    PastureHerdingLog.info("phShrinkToContour: read %d-point outdoorContourPolygon: %s",
        #contour / 3, ContourReader.describe((function()
            local out = {}
            for i = 1, #contour, 3 do out[#out + 1] = { x = contour[i], y = contour[i + 1], z = contour[i + 2] } end
            return out
        end)()))
    local ok = self.navMeshLayer:substituteContour(placeable, contour)
    return ok and "PastureHerding: phShrinkToContour succeeded" or "PastureHerding: phShrinkToContour FAILED (see log)"
end

local function getPlayerWorldPos()
    if g_currentMission ~= nil and g_currentMission.player ~= nil then
        local p = g_currentMission.player
        if p.rootNode ~= nil and p.rootNode ~= 0 and getWorldTranslation ~= nil then
            return getWorldTranslation(p.rootNode)
        end
        if p.position ~= nil then return p.position.x, p.position.y, p.position.z end
    end
    if g_localPlayer ~= nil and g_localPlayer.getPosition ~= nil then
        return g_localPlayer:getPosition()
    end
    return nil
end

local function findNearestHusbandryPlaceable(px, pz)
    local best, bestD2 = nil, math.huge
    local sources = {}
    if g_currentMission and g_currentMission.husbandrySystem and g_currentMission.husbandrySystem.placeables then
        sources[#sources + 1] = g_currentMission.husbandrySystem.placeables
    end
    if g_currentMission and g_currentMission.placeableSystem and g_currentMission.placeableSystem.placeables then
        sources[#sources + 1] = g_currentMission.placeableSystem.placeables
    end
    for _, src in ipairs(sources) do
        for _, p in pairs(src) do
            if p.spec_husbandryAnimals ~= nil and p.rootNode ~= nil and p.rootNode ~= 0 then
                local x, _, z = getWorldTranslation(p.rootNode)
                local dx, dz = x - px, z - pz
                local d2 = dx*dx + dz*dz
                if d2 < bestD2 then bestD2 = d2; best = p end
            end
        end
    end
    return best, math.sqrt(bestD2)
end

function PastureHerding:consoleMigrateNearest()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "PastureHerding: cannot find player position" end

    local placeable, dist = findNearestHusbandryPlaceable(px, pz)
    if placeable == nil then return "PastureHerding: no husbandry placeables found" end

    local contour, err = ContourReader.readOutdoorContourPolygon(placeable)
    if contour == nil then
        return string.format("PastureHerding: nearest pen at %.1fm has no usable outdoorContourPolygon (%s)", dist, tostring(err))
    end

    PastureHerdingLog.info("phMigrateNearest: targeting placeable at %.1fm with %d-point polygon (allowing animals)",
        dist, #contour / 3)
    local ok = self.navMeshLayer:substituteContour(placeable, contour, { allowAnimals = true })
    return ok and string.format("PastureHerding: phMigrateNearest succeeded (pen at %.1fm)", dist)
              or "PastureHerding: phMigrateNearest FAILED (see log)"
end

function PastureHerding:consoleAutoSubstitute()
    self.autoSubstituteOnPlacement = not self.autoSubstituteOnPlacement
    return string.format("PastureHerding: autoSubstituteOnPlacement = %s",
        self.autoSubstituteOnPlacement and "ON (new fenceless pens auto-migrate on placement)" or "OFF")
end

function PastureHerding:consoleRestoreLast()
    local placeable = HusbandryHook.lastPlaceable
    if placeable == nil then
        return "PastureHerding: no recent husbandry captured yet. Place a pen first."
    end
    local ok = self.navMeshLayer:restoreOriginal(placeable)
    return ok and "PastureHerding: phRestoreLast succeeded" or "PastureHerding: phRestoreLast FAILED (see log)"
end

function PastureHerding:consoleShrinkRadius(arg)
    local n = tonumber(arg)
    if n == nil or n <= 0 then
        return "Usage: phShrinkRadius <meters>"
    end
    self.shrinkRadius = n
    return string.format("PastureHerding: shrink half-size set to %.2f m", n)
end

function PastureHerding:consoleDebugContours()
    self.debugContours = not self.debugContours
    return string.format("PastureHerding: debug contour overlay %s", self.debugContours and "ON (green lines on substituted pens)" or "OFF")
end

-- Empty the food trough of the nearest registered pasture. Useful when
-- testing the daily-refill loop or when the player wants to dump spoiled
-- feed. Walks every fillType on the pasture's spec_husbandryFood and uses
-- the placeable's removeFood path so any vanilla side-effects (production
-- recalc, money tracking) fire correctly.
-- Diagnostic for the upcoming phFillDairy / phFillBeef commands. Walks the
-- nearest registered barn's spec_husbandryAnimals + clusterSystem and logs
-- every field and method we'd need to spawn animals at known breeds with
-- known ages and lactation states. Skim the log output and tell me which
-- methods exist + how subtype names are exposed; I'll wire the fill
-- commands against that.
function PastureHerding:consoleProbeCluster()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "phProbeCluster: cannot find player position" end
    local nearestBarnIdx
    do
        local bestD2 = math.huge
        for idx, barn in self.grazingRegistry:iterBarns() do
            local bp = barn.placeable
            if bp and bp.rootNode and bp.rootNode ~= 0 then
                local bx, _, bz = getWorldTranslation(bp.rootNode)
                local dx, dz = bx - px, bz - pz
                local d2 = dx * dx + dz * dz
                if d2 < bestD2 then bestD2 = d2; nearestBarnIdx = idx end
            end
        end
    end
    if nearestBarnIdx == nil then return "phProbeCluster: no registered barns" end
    local barn = self.grazingRegistry:getBarn(nearestBarnIdx)
    local pl = barn and barn.placeable
    if pl == nil then return "phProbeCluster: barn placeable missing" end
    local spec = pl.spec_husbandryAnimals
    if spec == nil then
        PastureHerdingLog.info("=== phProbeCluster: barn#%d has NO spec_husbandryAnimals ===", nearestBarnIdx)
        return "phProbeCluster: no spec_husbandryAnimals"
    end
    PastureHerdingLog.info("=== phProbeCluster: barn#%d ===", nearestBarnIdx)

    -- spec_husbandryAnimals top-level fields/methods
    local function dumpTable(label, t, depth)
        depth = depth or 0
        local pad = string.rep("  ", depth)
        if type(t) ~= "table" then
            PastureHerdingLog.info("%s%s = %s", pad, label, type(t))
            return
        end
        local keys = {}
        for k, _ in pairs(t) do keys[#keys + 1] = k end
        table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
        for _, k in ipairs(keys) do
            local v = t[k]
            local typ = type(v)
            local tag = tostring(k) .. ":" .. typ
            if typ == "table" then
                local n = (#v > 0) and ("[" .. tostring(#v) .. "]") or "{}"
                tag = tag .. n
            elseif typ == "function" then
                tag = tostring(k) .. "()"
            elseif typ == "number" or typ == "string" or typ == "boolean" then
                local s = tostring(v)
                if #s > 40 then s = s:sub(1, 37) .. "..." end
                tag = tostring(k) .. "=" .. s
            end
            PastureHerdingLog.info("%s%s%s", pad, label and (label .. ".") or "", tag)
        end
    end

    PastureHerdingLog.info("--- spec_husbandryAnimals fields ---")
    dumpTable("spec", spec, 1)

    local cs = spec.clusterSystem
    if cs == nil then
        PastureHerdingLog.info("clusterSystem = nil")
        return "phProbeCluster: clusterSystem missing — see log"
    end
    PastureHerdingLog.info("--- clusterSystem fields ---")
    dumpTable("cs", cs, 1)

    -- Animal subtypes — RLM normally exposes them via a manager or
    -- registers them per husbandry animal type. Try a few common access
    -- paths and log whatever we find.
    PastureHerdingLog.info("--- animal subtypes / breeds ---")
    local candidates = {
        cs.supportedSubTypes, cs.subTypes, cs.subtypes,
        spec.supportedSubTypes, spec.subTypes,
        g_currentMission and g_currentMission.animalSystem
            and g_currentMission.animalSystem.subTypes,
    }
    for i, candidate in ipairs(candidates) do
        if type(candidate) == "table" then
            local n = 0
            for _, _ in pairs(candidate) do n = n + 1 end
            PastureHerdingLog.info("  candidate#%d is a table with %d entries", i, n)
            local samples = 0
            for k, v in pairs(candidate) do
                if samples >= 5 then break end
                samples = samples + 1
                if type(v) == "table" then
                    local subKeys = {}
                    for sk, _ in pairs(v) do subKeys[#subKeys + 1] = tostring(sk) end
                    table.sort(subKeys)
                    PastureHerdingLog.info("    [%s] keys: %s",
                        tostring(k), table.concat(subKeys, ", "))
                    if v.name then PastureHerdingLog.info("       name=%s", tostring(v.name)) end
                    if v.fillType then PastureHerdingLog.info("       fillType=%s", tostring(v.fillType)) end
                    if v.outputs then
                        local outs = {}
                        for ok, _ in pairs(v.outputs) do outs[#outs + 1] = tostring(ok) end
                        PastureHerdingLog.info("       outputs: %s", table.concat(outs, ", "))
                    end
                else
                    PastureHerdingLog.info("    [%s] = %s", tostring(k), type(v))
                end
            end
        end
    end

    -- Existing animals — if the barn already has cows, log their breed/
    -- subtype so we know what names to filter by.
    local animals = (cs.getAnimals and cs:getAnimals()) or cs.animals or {}
    local count = 0
    for _, a in pairs(animals) do count = count + 1 end
    PastureHerdingLog.info("--- existing animals: %d cluster(s) ---", count)
    local shown = 0
    for _, a in pairs(animals) do
        if shown >= 3 then break end
        shown = shown + 1
        local fields = {}
        for k, v in pairs(a) do
            if type(v) ~= "function" and type(v) ~= "table" then
                fields[#fields + 1] = tostring(k) .. "=" .. tostring(v)
            elseif type(v) == "table" then
                fields[#fields + 1] = tostring(k) .. "{}"
            end
        end
        table.sort(fields)
        PastureHerdingLog.info("  animal#%d: %s", shown, table.concat(fields, ", "))
    end

    -- Cluster objects (the real ones, not the "animals" id->numAnimals map).
    -- This is where FS25 stores AnimalCluster instances with their metatables.
    PastureHerdingLog.info("--- cs.clusters: %d ---", type(cs.clusters) == "table" and #cs.clusters or -1)
    if type(cs.clusters) == "table" then
        for i, c in ipairs(cs.clusters) do
            if i > 2 then break end
            PastureHerdingLog.info("  cluster#%d type=%s", i, type(c))
            local mt = getmetatable(c)
            PastureHerdingLog.info("    metatable=%s", tostring(mt))
            if type(mt) == "table" and type(mt.__index) == "table" then
                local methods = {}
                for k, v in pairs(mt.__index) do
                    if type(v) == "function" then methods[#methods + 1] = k end
                end
                table.sort(methods)
                PastureHerdingLog.info("    methods=%s", table.concat(methods, ", "))
            end
            if type(c) == "table" then
                for k, v in pairs(c) do
                    if type(v) ~= "function" and type(v) ~= "table" then
                        PastureHerdingLog.info("    %s=%s", tostring(k), tostring(v))
                    end
                end
            end
        end
    end
    -- Dump method names from a metatable's __index chain.
    local function dumpMtMethods(label, obj, filterSubstr)
        local mt = getmetatable(obj)
        if type(mt) ~= "table" then
            PastureHerdingLog.info("--- %s no metatable ---", label)
            return
        end
        local idx = mt.__index
        if type(idx) == "function" then
            PastureHerdingLog.info("--- %s metatable __index is a function (can't enumerate) ---", label)
            return
        end
        if type(idx) ~= "table" then
            PastureHerdingLog.info("--- %s metatable __index = %s ---", label, type(idx))
            return
        end
        local methods = {}
        for k, v in pairs(idx) do
            if type(v) == "function" then
                if filterSubstr == nil or tostring(k):lower():find(filterSubstr) then
                    methods[#methods + 1] = k
                end
            end
        end
        table.sort(methods)
        PastureHerdingLog.info("--- %s methods%s: %s ---",
            label, filterSubstr and (" matching '" .. filterSubstr .. "'") or "",
            table.concat(methods, ", "))
    end

    dumpMtMethods("clusterSystem", cs)
    dumpMtMethods("placeable", pl, "add")
    -- First animal record — find its class for new()
    if type(cs.animals) == "table" then
        for _, a in pairs(cs.animals) do
            dumpMtMethods("animal[1]", a)
            break
        end
    end

    -- spec.clusterHusbandry is the RLM-side handler that usually owns the
    -- add-animal API. Dump every field/method it carries.
    if type(spec.clusterHusbandry) == "table" then
        PastureHerdingLog.info("--- spec.clusterHusbandry fields ---")
        local fields = {}
        for k, v in pairs(spec.clusterHusbandry) do
            local label = type(v) == "function" and (tostring(k) .. "()") or tostring(k) .. ":" .. type(v)
            fields[#fields + 1] = label
        end
        table.sort(fields)
        PastureHerdingLog.info("  %s", table.concat(fields, ", "))
        dumpMtMethods("clusterHusbandry", spec.clusterHusbandry)
        dumpMtMethods("clusterHusbandry", spec.clusterHusbandry, "add")
    end

    return "phProbeCluster: see log."
end

-- Shared fill implementation. Test-fill the nearest registered barn with a
-- mix of breeds matching `kind` ("dairy" or "beef"). Each animal is given
-- the configured age (months). Lactation comes from the breed's natural
-- gender/age math — adult females over 12mo are auto-lactating in RLM.
-- Uses placeable:addAnimals(subTypeIndex, numAnimals, age) which is RLM's
-- documented external API.
function PastureHerding:_fillBarnWithBreeds(kind, breedFilter)
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "phFillBarn: cannot find player position" end
    -- Nearest registered barn.
    local nearestBarnIdx, nearestBarn
    do
        local bestD2 = math.huge
        for idx, barn in self.grazingRegistry:iterBarns() do
            local bp = barn.placeable
            if bp and bp.rootNode and bp.rootNode ~= 0 then
                local bx, _, bz = getWorldTranslation(bp.rootNode)
                local dx, dz = bx - px, bz - pz
                local d2 = dx * dx + dz * dz
                if d2 < bestD2 then bestD2 = d2; nearestBarnIdx = idx; nearestBarn = barn end
            end
        end
    end
    if nearestBarnIdx == nil then return "phFillBarn: no registered barns" end
    local pl = nearestBarn and nearestBarn.placeable
    local spec = pl and pl.spec_husbandryAnimals
    local cs = spec and spec.clusterSystem
    if cs == nil then return "phFillBarn: nearest barn has no clusterSystem" end

    -- Breed sets — picked from the subtypes RLM logs at startup.
    -- Age field on existing animals is in MONTHS. Dairy = 12mo (lactation
    -- ready in RLM); beef = 10mo (juvenile finishing ready).
    --
    -- Breed lists cover both RLM-vanilla (suffix _VANILLA) and the
    -- FS25_CowBreedsRLRM pack (suffix _PACK). getSubTypeIndexByName silently
    -- skips any name that isn't loaded, so missing breeds just drop out of
    -- the mix without erroring.
    local breedNames, ageMonths
    if kind == "dairy" then
        breedNames = {
            "COW_HOLSTEIN_VANILLA", "COW_REDHOLSTEIN_VANILLA", "COW_BROWNSWISS_VANILLA",
            "COW_REDHOLSTEIN_PACK", "COW_AYRSHIRE_PACK", "COW_KERRY_PACK",
            "COW_SHORTHORNMILKERS_PACK", "COW_JERSEY_PACK", "COW_GUERNSEY_PACK",
        }
        ageMonths = 12
    else
        breedNames = {
            "COW_ANGUS_VANILLA", "COW_LIMOUSIN_VANILLA", "COW_SIMMENTAL_PACK",
            "COW_REDANGUS_PACK", "COW_CHAROLAIS_PACK", "COW_HEREFORD_PACK",
            "COW_SHORTHORN_PACK", "COW_IRISHMOILED_PACK", "COW_BRITISHBLUE_PACK",
            "COW_BELTEDGALLOWAY_PACK",
        }
        ageMonths = 10
    end
    -- Optional breed filter — keep only breeds whose name contains the
    -- given substring (case-insensitive). Lets the player target a
    -- specific breed: e.g. `phFillDairy holstein` keeps Holstein + Red
    -- Holstein and skips Brown Swiss.
    if breedFilter ~= nil and breedFilter ~= "" then
        local needle = string.lower(tostring(breedFilter))
        local kept = {}
        for _, name in ipairs(breedNames) do
            if string.find(string.lower(name), needle, 1, true) ~= nil then
                kept[#kept + 1] = name
            end
        end
        if #kept == 0 then
            return string.format(
                "phFillBarn: filter '%s' matched no %s breeds. Available: %s",
                breedFilter, kind, table.concat(breedNames, ", "))
        end
        breedNames = kept
    end

    -- Resolve subtype indices via the global animalSystem.
    local animSys = g_currentMission and g_currentMission.animalSystem
    if animSys == nil or type(animSys.getSubTypeIndexByName) ~= "function" then
        return "phFillBarn: g_currentMission.animalSystem:getSubTypeIndexByName not available"
    end
    local resolvedSubtypes = {}
    for _, name in ipairs(breedNames) do
        local idx
        local ok = pcall(function() idx = animSys:getSubTypeIndexByName(name) end)
        if ok and type(idx) == "number" and idx > 0 then
            resolvedSubtypes[#resolvedSubtypes + 1] = { name = name, subTypeIndex = idx }
            PastureHerdingLog.info("phFillBarn: resolved %s -> subTypeIndex %d", name, idx)
        else
            PastureHerdingLog.warning("phFillBarn: couldn't resolve subtype %s", name)
        end
    end
    if #resolvedSubtypes == 0 then return "phFillBarn: no breeds resolved — see log" end

    local capacity = (spec.maxNumAnimals or spec.baseMaxNumAnimals or 30)
    local existing = 0
    if type(cs.animals) == "table" then
        for _, c in pairs(cs.animals) do existing = existing + (c.numAnimals or 1) end
    end
    local toAdd = capacity - existing
    if toAdd <= 0 then return string.format("phFillBarn: barn already at capacity (%d/%d)", existing, capacity) end

    -- Spread evenly across breeds and use RLM's documented external API:
    -- placeable:addAnimals(subTypeIndex, numAnimals, age) where age is in
    -- months. RLM does the cluster bookkeeping internally and triggers the
    -- HUSBANDRY_ANIMALS_CHANGED publish at the end.
    if type(pl.addAnimals) ~= "function" then
        return "phFillBarn: placeable has no :addAnimals method (RLM not loaded?)"
    end
    local perBreed = math.ceil(toAdd / #resolvedSubtypes)
    local added, errors = 0, {}
    for _, breed in ipairs(resolvedSubtypes) do
        if added >= toAdd then break end
        local thisBreedCount = math.min(perBreed, toAdd - added)
        local ok, err = pcall(function()
            pl:addAnimals(breed.subTypeIndex, thisBreedCount, ageMonths)
        end)
        if ok then
            added = added + thisBreedCount
            PastureHerdingLog.info("phFillBarn: added %d × %s (age=%d months)",
                thisBreedCount, breed.name, ageMonths)
        else
            errors[#errors + 1] = breed.name .. ": " .. tostring(err)
            PastureHerdingLog.warning("phFillBarn: %s add failed: %s", breed.name, tostring(err))
        end
    end

    if added == 0 then
        return "phFillBarn: 0 added. Errors:\n  " .. table.concat(errors, "\n  ")
    end
    return string.format("phFillBarn: added %d %s cattle to barn#%d. See log for breakdown.",
        added, kind, nearestBarnIdx)
end

function PastureHerding:consoleFillDairy(breedArg)
    return self:_fillBarnWithBreeds("dairy", breedArg)
end

function PastureHerding:consoleFillBeef(breedArg)
    return self:_fillBarnWithBreeds("beef", breedArg)
end

function PastureHerding:consoleEmptyTrough()
    -- Use the method wrapper instead of the module-local function — the
    -- local is declared after this function in the file, so a direct
    -- reference is nil at call time.
    local bIdx, pIdx, distOrErr = self:getNearestPasture()
    if bIdx == nil then return "phEmptyTrough: " .. tostring(distOrErr) end
    local pasture = self.grazingRegistry:getBarn(bIdx).pastures[pIdx]
    if pasture == nil then return "phEmptyTrough: pasture not resolvable" end
    local foodSpec = pasture.spec_husbandryFood
    if foodSpec == nil or type(foodSpec.fillLevels) ~= "table" then
        return "phEmptyTrough: pasture has no food trough"
    end
    local totalRemoved = 0
    for fillType, level in pairs(foodSpec.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 ok then totalRemoved = totalRemoved + level end
            else
                -- Fallback: write fillLevels directly when removeFood isn't
                -- available (defensive — vanilla husbandry has it).
                foodSpec.fillLevels[fillType] = 0
                totalRemoved = totalRemoved + level
            end
        end
    end
    PastureHerdingLog.info("phEmptyTrough: cleared %d L from barn#%d pasture#%d (%.1fm away)",
        totalRemoved, bIdx, pIdx, distOrErr)
    return string.format("phEmptyTrough: cleared %d L from pasture #%d", totalRemoved, pIdx)
end

-- Empty the GRASS meadow (spec_husbandryMeadow) of the nearest registered
-- pasture. Mirrors consoleEmptyTrough but targets the meadow. Used to force a
-- starvation scenario: trough empty + meadow empty => foodFactor 0 => health
-- declines. Our regrowth gate leaves an occupied pasture empty (no drip while
-- cows graze), so it stays starved until the player refills / moves the herd.
function PastureHerding:consoleEmptyMeadow()
    local bIdx, pIdx, distOrErr = self:getNearestPasture()
    if bIdx == nil then return "phEmptyMeadow: " .. tostring(distOrErr) end
    local pasture = self.grazingRegistry:getBarn(bIdx).pastures[pIdx]
    if pasture == nil then return "phEmptyMeadow: pasture not resolvable" end
    local m = pasture.spec_husbandryMeadow
    if m == nil or type(m.fillLevels) ~= "table" then
        return "phEmptyMeadow: pasture has no meadow"
    end
    local totalRemoved = 0
    for fillType, level in pairs(m.fillLevels) do
        if type(level) == "number" and level > 0 then
            -- Prefer the vanilla removal path (the meadow overrides removeFood)
            -- so food-place accounting stays consistent; fall back to a direct
            -- write only if removeFood is unavailable.
            if type(pasture.removeFood) == "function" then
                local ok = pcall(function() pasture:removeFood(level, fillType) end)
                if ok then totalRemoved = totalRemoved + level
                else m.fillLevels[fillType] = 0; totalRemoved = totalRemoved + level end
            else
                m.fillLevels[fillType] = 0
                totalRemoved = totalRemoved + level
            end
            -- Keep our meadow-hold tracker in sync so it doesn't see a phantom
            -- drop to react to on the next frame.
            if type(pasture._phLastMeadowFill) == "table" then
                pasture._phLastMeadowFill[fillType] = m.fillLevels[fillType] or 0
            end
        end
    end
    PastureHerdingLog.info("phEmptyMeadow: cleared %d L grass from barn#%d pasture#%d (%.1fm away)",
        totalRemoved, bIdx, pIdx, distOrErr)
    return string.format("phEmptyMeadow: cleared %d L grass from pasture #%d — watch foodFactor/health drop over the next hours",
        totalRemoved, pIdx)
end

-- Companion to phEmptyMeadow: refill the nearest pasture's grass to capacity.
-- Handy for testing grazing drain, rotation thresholds, and starvation recovery
-- without waiting for regrowth.
function PastureHerding:consoleFillMeadow()
    local bIdx, pIdx, distOrErr = self:getNearestPasture()
    if bIdx == nil then return "phFillMeadow: " .. tostring(distOrErr) end
    local pasture = self.grazingRegistry:getBarn(bIdx).pastures[pIdx]
    if pasture == nil then return "phFillMeadow: pasture not resolvable" end
    local m = pasture.spec_husbandryMeadow
    if m == nil or type(m.fillLevels) ~= "table" then
        return "phFillMeadow: pasture has no meadow"
    end
    local function capFor(ft)
        if type(m.capacities) == "table" and type(m.capacities[ft]) == "number" then
            return m.capacities[ft]
        end
        return (type(m.capacity) == "number" and m.capacity) or 0
    end
    -- Fill every grass fill type this meadow knows about (tracked levels + any
    -- with a declared capacity) up to its cap.
    local fts = {}
    for ft in pairs(m.fillLevels) do fts[ft] = true end
    if type(m.capacities) == "table" then for ft in pairs(m.capacities) do fts[ft] = true end end
    local totalAdded = 0
    for ft in pairs(fts) do
        local cap = capFor(ft)
        local cur = m.fillLevels[ft] or 0
        if cap > 0 and cap > cur then
            m.fillLevels[ft] = cap
            totalAdded = totalAdded + (cap - cur)
            -- Keep our meadow-hold tracker in step so it doesn't react to the jump.
            if type(pasture._phLastMeadowFill) == "table" then
                pasture._phLastMeadowFill[ft] = cap
            end
        end
    end
    if totalAdded == 0 then
        return string.format("phFillMeadow: pasture #%d meadow already full (or no capacity info)", pIdx)
    end
    PastureHerdingLog.info("phFillMeadow: filled grass on barn#%d pasture#%d (+%d L, %.1fm away)",
        totalAdded, bIdx, pIdx, distOrErr)
    return string.format("phFillMeadow: filled grass to full on pasture #%d (+%d L)", pIdx, totalAdded)
end

-- Reseed the NEAREST registered pasture: full soil quality, bare grass, ~2-month
-- grazing lockout, size-scaled cost. Testing companion for the Grazing-tab button.
function PastureHerding:consoleRenewPasture()
    local bIdx, pIdx, distOrErr = self:getNearestPasture()
    if bIdx == nil then return "phRenewPasture: " .. tostring(distOrErr) end
    if self.grazingScheduler == nil or type(self.grazingScheduler.renewPasture) ~= "function" then
        return "phRenewPasture: scheduler unavailable"
    end
    local ok, msg = self.grazingScheduler:renewPasture(bIdx, pIdx)
    return string.format("phRenewPasture: %s", tostring(msg or (ok and "done" or "failed")))
end

-- Resolve the Precision Farming instance (namespaced under FS25_precisionFarming),
-- or nil. PF's soil/pH/nitrogen maps hang off this.
function PastureHerding:_pf()
    local ok, pf = pcall(function()
        local ns = _G["FS25_precisionFarming"]
        local inst = type(ns) == "table" and ns.g_precisionFarming or nil
        if type(inst) ~= "table" and g_currentMission ~= nil then
            inst = g_currentMission.g_precisionFarming  -- fieldCalculator's bridge, if present
        end
        return inst
    end)
    if ok and type(pf) == "table" then return pf end
    return nil
end

-- Raw-read a channel range from a PF value-map's bitVectorMap at world (x,z),
-- bypassing PF's sample/cover gate. Returns the integer value or nil. Uses
-- getBitVectorMapPoint (bitVectorMaps — NOT getDensityAtWorldPos, which is for
-- density maps and errors here) with the base-game world->local pixel conversion.
function PastureHerding:_pfReadMapRaw(vmap, x, z, firstCh, numCh)
    if type(vmap) ~= "table" then return nil end
    local mapId = vmap.bitVectorMap
    if type(mapId) ~= "number" or mapId == 0 or type(getBitVectorMapPoint) ~= "function" then return nil end
    if g_currentMission == nil or type(g_currentMission.terrainSize) ~= "number" then return nil end
    local w, h = vmap.sizeX, vmap.sizeY
    if type(getBitVectorMapSize) == "function" then
        local okS, sw, sh = pcall(getBitVectorMapSize, mapId)
        if okS and type(sw) == "number" and sw > 0 then w, h = sw, sh end
    end
    if type(w) ~= "number" or w <= 0 or type(h) ~= "number" or h <= 0 then return nil end
    local ts = g_currentMission.terrainSize
    local lx = math.floor(w * (x + ts * 0.5) / ts)
    local lz = math.floor(h * (z + ts * 0.5) / ts)
    if lx < 0 then lx = 0 elseif lx >= w then lx = w - 1 end   -- valid indices are 0..w-1
    if lz < 0 then lz = 0 elseif lz >= h then lz = h - 1 end
    local okP, raw = pcall(getBitVectorMapPoint, mapId, lx, lz, firstCh or 0, numCh or 1)
    if okP and type(raw) == "number" then return raw end
    return nil
end

-- Soil-type index (1..N) at world (x,z), or nil. (B) raw channel read primary
-- (works on unsampled ground), (A) getTypeIndexAtWorldPos fallback (sampled only).
function PastureHerding:_pfSoilTypeIndexAt(x, z, soilMap)
    soilMap = soilMap or (self:_pf() and self:_pf().soilMap)
    if type(soilMap) ~= "table" then return nil end
    local soilTypes = soilMap.soilTypes
    if type(soilTypes) ~= "table" or #soilTypes == 0 then return nil end

    local typeIdx
    local raw = self:_pfReadMapRaw(soilMap, x, z, soilMap.typeFirstChannel or 0, soilMap.typeNumChannels or 2)
    if raw ~= nil then typeIdx = raw + 1 end  -- soilTypes is 1-indexed

    if typeIdx == nil or soilTypes[typeIdx] == nil then
        if type(soilMap.getTypeIndexAtWorldPos) == "function" then
            local okT, idx = pcall(function() return soilMap:getTypeIndexAtWorldPos(x, z) end)
            if okT and type(idx) == "number" and idx >= 1 and idx <= #soilTypes then typeIdx = idx end
        end
    end
    if typeIdx == nil then return nil end
    if typeIdx < 1 then typeIdx = 1 elseif typeIdx > #soilTypes then typeIdx = #soilTypes end
    return typeIdx
end

-- Feature #4: derive a pasture's soil quality (0.1..1.0) from Precision Farming's
-- soil map at world (x,z), or nil if PF absent / unreadable (caller keeps default).
-- Soil types carry a yieldPotential (~0.8 poor .. 1.25 premium) mapped onto the
-- [pfSoilQualityLow..pfSoilQualityHigh] band across the map's own range, plus a
-- small deterministic per-location jitter.
function PastureHerding:getPFSoilQuality(x, z)
    if self.pfSoilEnabled == false then return nil end
    if type(x) ~= "number" or type(z) ~= "number" then return nil end

    local pf = self:_pf()
    local soilMap = pf and pf.soilMap
    if type(soilMap) ~= "table" then return nil end
    local soilTypes = soilMap.soilTypes
    if type(soilTypes) ~= "table" or #soilTypes == 0 then return nil end

    local typeIdx = self:_pfSoilTypeIndexAt(x, z, soilMap)
    if typeIdx == nil then return nil end
    local st = soilTypes[typeIdx]
    local yp = st and st.yieldPotential
    if type(yp) ~= "number" then return nil end

    -- Map yieldPotential across the map's own min..max soil range into the band.
    local ypMin, ypMax = math.huge, -math.huge
    for _, s in ipairs(soilTypes) do
        local v = s.yieldPotential
        if type(v) == "number" then
            if v < ypMin then ypMin = v end
            if v > ypMax then ypMax = v end
        end
    end
    local qLo = self.pfSoilQualityLow or 0.5
    local qHi = self.pfSoilQualityHigh or 1.0
    local q
    if ypMax <= ypMin then q = qHi
    else q = qLo + ((yp - ypMin) / (ypMax - ypMin)) * (qHi - qLo) end
    -- Deterministic per-location jitter so pastures on the same soil type don't all
    -- read the same set point. Hash of (x,z) -> 0..1, stable across reloads/renewals.
    local jitterRange = self.pfSoilVariation or 0.08
    if jitterRange > 0 then
        local hsh = math.sin(x * 12.9898 + z * 78.233) * 43758.5453
        hsh = hsh - math.floor(hsh)               -- 0..1
        q = q + (hsh - 0.5) * 2 * jitterRange      -- +/- jitterRange
    end
    if q < 0.1 then q = 0.1 elseif q > 1.0 then q = 1.0 end
    PastureHerdingLog.dev("PF soil at (%.0f,%.0f): type#%d '%s' yp=%.2f -> quality=%.0f%%",
        x, z, typeIdx, tostring(st.name), yp, q * 100)
    return q
end

-- Feature #4b: live pH factor (0..1) for the pasture ground at world (x,z) — how
-- close the soil pH is to the OPTIMAL pH for its soil type. 1.0 = at/near optimal;
-- down to (1 - pfPHMaxPenalty) at >= pfPHTolerance pH units off. Neutral 1.0 if PF
-- / pH unreadable, so it's always safe to multiply into effective quality.
function PastureHerding:getPFPHFactor(x, z)
    if self.pfPHEnabled == false then return 1.0 end
    if type(x) ~= "number" or type(z) ~= "number" then return 1.0 end
    local pf = self:_pf()
    local pHMap = pf and pf.pHMap
    local soilMap = pf and pf.soilMap
    if type(pHMap) ~= "table" or type(soilMap) ~= "table" then return 1.0 end

    -- Current pH internal state (raw read bypasses cover), then -> actual pH value.
    local state = self:_pfReadMapRaw(pHMap, x, z, pHMap.firstChannel or 0, pHMap.numChannels or 5)
    if state == nil then return 1.0 end
    local curPH
    if type(pHMap.getPhValueFromInternalValue) == "function" then
        local ok, v = pcall(function() return pHMap:getPhValueFromInternalValue(state) end)
        if ok and type(v) == "number" then curPH = v end
    end
    if curPH == nil then return 1.0 end

    -- Optimal pH for this ground's soil type.
    local optPH
    local typeIdx = self:_pfSoilTypeIndexAt(x, z, soilMap)
    if typeIdx ~= nil and type(pHMap.getOptimalPHValueForSoilTypeIndex) == "function" then
        local okO, optState = pcall(function() return pHMap:getOptimalPHValueForSoilTypeIndex(typeIdx) end)
        if okO and type(optState) == "number" then
            if type(pHMap.getPhValueFromInternalValue) == "function" then
                local okC, v2 = pcall(function() return pHMap:getPhValueFromInternalValue(optState) end)
                if okC and type(v2) == "number" then optPH = v2 end
            end
            optPH = optPH or optState
        end
    end
    if optPH == nil then return 1.0 end

    local tol = self.pfPHTolerance or 1.5
    local maxPen = self.pfPHMaxPenalty or 0.4
    local penFrac = math.min(math.abs(curPH - optPH) / math.max(0.01, tol), 1.0)
    local factor = 1.0 - penFrac * maxPen
    PastureHerdingLog.dev("PF pH at (%.0f,%.0f): pH=%.2f optimal=%.2f (type#%s) -> factor=%.2f",
        x, z, curPH, optPH, tostring(typeIdx), factor)
    return factor
end

-- World (x,z) of a pasture placeable, or nil.
function PastureHerding:_pastureWorldXZ(pasture)
    if pasture == nil or pasture.rootNode == nil or pasture.rootNode == 0 then return nil end
    if type(getWorldTranslation) ~= "function" then return nil end
    local x, _, z = getWorldTranslation(pasture.rootNode)
    return x, z
end

-- Set a newly-registered pasture's starting soil quality from PF (if available).
-- Called only for NEW registrations (menu/console) — the load path keeps the
-- pasture's saved quality, so existing pastures are untouched (decision 4).
function PastureHerding:applyInitialSoilQuality(barnIdx, pIdx)
    if self.pfSoilEnabled == false then return end  -- PF soil layer turned off in Settings
    local reg = self.grazingRegistry
    local barn = reg and reg:getBarn(barnIdx)
    local pasture = barn and barn.pastures[pIdx]
    if pasture == nil then return end
    -- Sample each pasture's PF soil exactly ONCE (marked pfSampled). Skips shared
    -- pastures already done under their first barn; a pre-PF save has no flag, so
    -- backfillPFSoil() on load samples it here without needing remove/re-add.
    if type(reg.getPFSampled) == "function" and reg:getPFSampled(barnIdx, pIdx) then
        return
    end
    local x, z = self:_pastureWorldXZ(pasture)
    if x == nil then return end
    -- PF must be active to sample. If it's absent, leave quality untouched and do
    -- NOT mark — so it can still backfill later if PF is added to the save.
    if self:_pf() == nil then return end
    local q = self:getPFSoilQuality(x, z)
    if q ~= nil then
        reg:setPastureQuality(barnIdx, pIdx, q)
        PastureHerdingLog.info(
            "PastureHerding: barn#%d pasture#%d soil quality from Precision Farming = %.0f%%",
            barnIdx, pIdx, q * 100)
    end
    if type(reg.setPHFactor) == "function" then
        reg:setPHFactor(barnIdx, pIdx, self:getPFPHFactor(x, z))
    end
    if type(reg.setPFSampled) == "function" then
        reg:setPFSampled(barnIdx, pIdx, true)
    end
end

-- One-time backfill: sample PF soil for any pasture that hasn't been sampled yet
-- (i.e. saves created before the PF feature). Runs after load; a no-op once every
-- pasture is flagged, and a no-op entirely without PF.
function PastureHerding:backfillPFSoil()
    if self.pfSoilEnabled == false then return end
    local reg = self.grazingRegistry
    if reg == nil or type(reg.iterBarns) ~= "function" then return end
    if self:_pf() == nil then return end  -- no PF -> nothing to backfill
    local n = 0
    for barnIdx, barn in reg:iterBarns() do
        for pIdx in ipairs(barn.pastures) do
            if type(reg.getPFSampled) ~= "function" or not reg:getPFSampled(barnIdx, pIdx) then
                self:applyInitialSoilQuality(barnIdx, pIdx)
                n = n + 1
            end
        end
    end
    if n > 0 then
        PastureHerdingLog.info("PastureHerding: backfilled Precision Farming soil for %d pasture(s)", n)
    end
end

-- Print the PF-derived soil quality for the NEAREST pasture (testing companion for
-- feature #4 — does not change anything).
function PastureHerding:consolePFSoil()
    local bIdx, pIdx = self:getNearestPasture()
    if bIdx == nil then return "phPFSoil: no nearby registered pasture" end
    local pasture = self.grazingRegistry:getBarn(bIdx).pastures[pIdx]
    local x, z = self:_pastureWorldXZ(pasture)
    if x == nil then return "phPFSoil: pasture has no position" end
    local q = self:getPFSoilQuality(x, z)
    if q == nil then
        return "phPFSoil: no Precision Farming soil data here (is the PF DLC active?)"
    end
    return string.format("phPFSoil: barn#%d pasture#%d PF soil quality = %.0f%%", bIdx, pIdx, q * 100)
end

-- Print the PF pH factor (and derived effective-quality multiplier) for the
-- NEAREST pasture — verifies the pH read before it's wired into the mechanics.
function PastureHerding:consolePFpH()
    local bIdx, pIdx = self:getNearestPasture()
    if bIdx == nil then return "phPFpH: no nearby registered pasture" end
    local pasture = self.grazingRegistry:getBarn(bIdx).pastures[pIdx]
    local x, z = self:_pastureWorldXZ(pasture)
    if x == nil then return "phPFpH: pasture has no position" end
    local factor = self:getPFPHFactor(x, z)
    return string.format("phPFpH: barn#%d pasture#%d pH factor = %.2f (grass %+.0f%%) — see log for pH/optimal",
        bIdx, pIdx, factor, (factor - 1.0) * 100)
end

-- Fully lime the NEAREST pasture (sets pH correction to 100%) — testing companion
-- for the SprayerHook lime detection, without driving a spreader over it.
function PastureHerding:consoleLimePasture()
    local bIdx, pIdx = self:getNearestPasture()
    if bIdx == nil then return "phLimePasture: no nearby registered pasture" end
    if type(self.grazingRegistry.setLimeCorrection) ~= "function" then return "phLimePasture: unavailable" end
    self.grazingRegistry:setLimeCorrection(bIdx, pIdx, 1.0)
    local phf = self.grazingRegistry:getPHFactor(bIdx, pIdx)
    return string.format("phLimePasture: barn#%d pasture#%d fully limed — effective pH factor now %.2f (grass %+.0f%%)",
        bIdx, pIdx, phf, (phf - 1.0) * 100)
end

-- Read the terrain-detail groundType + sprayType at the player/vehicle position and
-- dump them. Use it to nail the LIME ground texture: spread lime on a REAL field,
-- drive onto it, run phProbeSpray -> the groundType/sprayType values the game wrote
-- are what our lime paint should match.
function PastureHerding:consoleProbeSpray()
    local x, y, z
    if g_currentMission ~= nil and g_currentMission.controlledVehicle ~= nil
       and g_currentMission.controlledVehicle.rootNode ~= nil and getWorldTranslation ~= nil then
        x, y, z = getWorldTranslation(g_currentMission.controlledVehicle.rootNode)
    else
        x, y, z = getPlayerWorldPos()
    end
    if x == nil then return "phProbeSpray: could not get a position" end
    local mapId = g_currentMission and g_currentMission.terrainDetailId
    if type(getDensityAtWorldPos) ~= "function" or type(mapId) ~= "number" then
        return "phProbeSpray: terrain-detail map unavailable"
    end
    local ok, bits = pcall(getDensityAtWorldPos, mapId, x, y or 0, z)
    if not ok or type(bits) ~= "number" then return "phProbeSpray: density read failed" end
    local info = (rawget(_G, "SprayerHook") and SprayerHook.getFertMapInfo and SprayerHook.getFertMapInfo()) or {}
    local gcF, gcN = info.gcF or 0, info.gcN or 4
    local scF, scN = info.scF or 9, info.scN or 3
    local function decode(first, num)
        if bit32 == nil then return -1 end
        return bit32.band(bit32.rshift(bits, first), bit32.lshift(1, num) - 1)
    end
    local gt, st = decode(gcF, gcN), decode(scF, scN)
    local limeV = rawget(_G, "SprayerHook") and SprayerHook.getLimeSprayValue and SprayerHook.getLimeSprayValue()
    PastureHerdingLog.info(
        "phProbeSpray at (%.0f,%.0f): groundType[%d..%d]=%d sprayType[%d..%d]=%d (raw=%d) | FieldSprayType.LIME=%s FERT=%d",
        x, z, gcF, gcN, gt, scF, scN, st, bits, tostring(limeV), SprayerHook.FERT_SPRAY_VALUE)
    return string.format("phProbeSpray: groundType=%d sprayType=%d (lime enum value=%s) — see log",
        gt, st, tostring(limeV))
end

-- Probe the Precision Farming DLC soil API (feature #4). PF ships ENCRYPTED, so
-- its Lua can't be read from source — this dumps g_precisionFarming's structure,
-- its soil/yield-map objects + methods, a sample query at the NEAREST pasture, and
-- any soil-types table (looking for a yield-potential/quality factor). Run in-game
-- standing near a pasture, then share the 'phProbePF' lines from log.txt.
function PastureHerding:consoleProbePF()
    local log = PastureHerdingLog.info
    local function has(k, sub) return tostring(k):lower():find(sub, 1, true) ~= nil end
    local function fields(label, t)
        if type(t) ~= "table" then log("%s = %s", label, type(t)); return end
        local keys = {}
        for k in pairs(t) do keys[#keys + 1] = k end
        table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
        for _, k in ipairs(keys) do
            local v = t[k]; local typ = type(v); local tag
            if typ == "table" then
                tag = tostring(k) .. ":table" .. ((#v > 0) and ("[" .. #v .. "]") or "{}")
            elseif typ == "function" then tag = tostring(k) .. "()"
            else tag = tostring(k) .. "=" .. tostring(v) end
            log("  %s.%s", label, tag)
        end
    end
    local function methods(label, obj, filterSubstr)
        local mt = getmetatable(obj)
        local idx = type(mt) == "table" and mt.__index or nil
        if type(idx) ~= "table" then log("--- %s methods: __index=%s ---", label, type(idx)); return end
        local ms = {}
        for k, v in pairs(idx) do
            if type(v) == "function" and (filterSubstr == nil or has(k, filterSubstr)) then
                ms[#ms + 1] = tostring(k)
            end
        end
        table.sort(ms)
        log("--- %s methods%s: %s ---", label,
            filterSubstr and (" ~'" .. filterSubstr .. "'") or "", table.concat(ms, ", "))
    end

    log("=== phProbePF: Precision Farming soil API probe ===")
    -- PF namespaces its globals under a top-level table named after the mod:
    -- _G["FS25_precisionFarming"].g_precisionFarming.soilMap. (Confirmed from
    -- FS25_fieldCalculator's PF bridge.) Reach _G by string key (chains through
    -- our sandbox __index) — bare `g_precisionFarming` fails because it lives in
    -- the namespace, not top-level.
    local function G(name)
        local ok, v = pcall(function() return _G[name] end)
        if ok then return v end
        return nil
    end
    local ns = G("FS25_precisionFarming")
    log("_G['FS25_precisionFarming'] namespace = %s", type(ns))
    local pf = nil
    if type(ns) == "table" then
        pf = ns.g_precisionFarming
        log("  namespace.g_precisionFarming = %s | namespace.PrecisionFarming = %s",
            type(pf), type(ns.PrecisionFarming))
    end
    if type(pf) ~= "table" then
        pf = G("g_precisionFarming")
        if type(pf) ~= "table" and g_currentMission ~= nil then
            pf = g_currentMission.g_precisionFarming  -- fieldCalculator bridges it here
        end
        log("  fallback (plain g_precisionFarming / mission bridge) = %s", type(pf))
    end
    local dirTbl = G("g_modNameToDirectory")
    log("  g_modNameToDirectory['FS25_precisionFarming'] = %s",
        tostring(type(dirTbl) == "table" and dirTbl["FS25_precisionFarming"] or nil))
    if type(pf) ~= "table" then
        return "phProbePF: PF instance not resolved — see log"
    end
    log("PF instance resolved: type=%s", type(pf))
    fields("g_precisionFarming", pf)

    -- Candidate soil/yield/nutrient map sub-objects.
    local mapKeys = {}
    for k, v in pairs(pf) do
        if type(v) == "table" and (has(k, "soil") or has(k, "yield") or has(k, "nutrient")
           or has(k, "nitrogen") or has(k, "map") or has(k, "ph")) then
            mapKeys[#mapKeys + 1] = k
        end
    end
    local mapKeyStr = {}
    for _, k in ipairs(mapKeys) do mapKeyStr[#mapKeyStr + 1] = tostring(k) end
    log("candidate map objects: %s", table.concat(mapKeyStr, ", "))

    -- Nearest pasture world position for a sample query.
    local bIdx, pIdx = self:getNearestPasture()
    local px, pz
    if bIdx ~= nil then
        local pasture = self.grazingRegistry:getBarn(bIdx).pastures[pIdx]
        if pasture and pasture.rootNode and pasture.rootNode ~= 0 and getWorldTranslation then
            px, _, pz = getWorldTranslation(pasture.rootNode)
        end
    end
    log("nearest pasture: barn#%s pasture#%s pos=(%s,%s)",
        tostring(bIdx), tostring(pIdx), tostring(px), tostring(pz))

    local QUERY_FNS = { "getTypeIndexAtWorldPos", "getSoilTypeIndexAtWorldPos",
        "getInfoAtWorldPosition", "getValueAtWorldPos", "getTypeAtWorldPos",
        "getSoilTypeAtWorldPos", "getYieldPotentialAtWorldPos", "getDensityAtWorldPos" }
    local TYPE_TABLES = { "soilTypes", "types", "soilTypeData", "typeData" }
    for _, k in ipairs(mapKeys) do
        local m = pf[k]
        log("--- map '%s' ---", tostring(k))
        fields("  " .. tostring(k), m)
        methods(tostring(k), m, "get")
        methods(tostring(k), m, "world")
        methods(tostring(k), m, "type")
        if px ~= nil then
            for _, fn in ipairs(QUERY_FNS) do
                if type(m[fn]) == "function" then
                    local ok, a, b, c = pcall(function() return m[fn](m, px, pz) end)
                    log("    %s.%s(px,pz) -> ok=%s: %s / %s / %s",
                        tostring(k), fn, tostring(ok), tostring(a), tostring(b), tostring(c))
                end
            end
        end
        for _, tk in ipairs(TYPE_TABLES) do
            if type(m[tk]) == "table" then
                log("    %s.%s (%d entries):", tostring(k), tk, #m[tk])
                for i, st in ipairs(m[tk]) do
                    if type(st) == "table" then
                        local nm = st.name or st.title or st.typeName or ("#" .. i)
                        local yp = st.yieldPotential or st.factor or st.yield or st.value or "?"
                        log("      [%d] name=%s yieldPotential/factor=%s", i, tostring(nm), tostring(yp))
                        if i == 1 then fields("        type[1]", st) end
                    end
                end
            end
        end
    end
    return "phProbePF: dumped PF soil API to log.txt — share the 'phProbePF' lines"
end

function PastureHerding:consoleDumpPlaceable()
    local placeable = HusbandryHook.lastPlaceable
    if placeable == nil then
        return "PastureHerding: no recent placeable. Place a pen first."
    end
    local specs = {}
    for k, v in pairs(placeable) do
        if type(k) == "string" and k:sub(1, 5) == "spec_" then
            specs[#specs + 1] = k
        end
    end
    table.sort(specs)
    PastureHerdingLog.info("phDumpPlaceable: placeable has %d spec_* fields:", #specs)
    for _, name in ipairs(specs) do
        local spec = placeable[name]
        if type(spec) == "table" then
            local keys = {}
            for kk in pairs(spec) do keys[#keys + 1] = tostring(kk) end
            table.sort(keys)
            PastureHerdingLog.info("  %s: {%s}", name, table.concat(keys, ", "))
        else
            PastureHerdingLog.info("  %s = %s", name, tostring(spec))
        end
    end
    return string.format("PastureHerding: dumped %d specs to log", #specs)
end

function PastureHerding:consoleUnscaleMeadow()
    if self.grazingRegistry == nil then return "PastureHerding: registry missing" end
    local scale = 1.0
    if GrazingScheduler ~= nil and type(GrazingScheduler.refreshRLRMFoodScale) == "function" then
        GrazingScheduler.refreshRLRMFoodScale()
    end
    local rl = RealisticLivestock_PlaceableHusbandryFood  -- plain access (rawget bypasses sandbox __index)
    if rl ~= nil and type(rl.foodScale) == "number" and rl.foodScale > 0 then
        scale = rl.foodScale
    end
    -- Try the cached read too in case the rl global is sandboxed away.
    -- We can't expose _cachedFoodScale from outside the scheduler closure;
    -- fall back to a sensible default.
    if scale <= 1.0 then
        return string.format("PastureHerding: foodScale is %.2f - nothing to unscale (cap is already at vanilla value).", scale)
    end
    local out = {}
    out[#out + 1] = string.format("Unscaling by /%.2f (current RLRM foodScale):", scale)
    for barnIdx, barn in self.grazingRegistry:iterBarns() do
        for pIdx, p in ipairs(barn.pastures) do
            local m = p and p.spec_husbandryMeadow
            if m ~= nil then
                local newMaxCap = 0
                if type(m.capacities) == "table" then
                    for ft, cap in pairs(m.capacities) do
                        if type(cap) == "number" then
                            local nc = math.floor(cap / scale)
                            m.capacities[ft] = nc
                            if nc > newMaxCap then newMaxCap = nc end
                            local levels = m.fillLevels
                            if type(levels) == "table" and type(levels[ft]) == "number" then
                                levels[ft] = math.floor(levels[ft] / scale)
                            end
                            out[#out + 1] = string.format("  barn#%d pasture#%d ft=%s cap %.0f -> %.0f", barnIdx, pIdx, tostring(ft), cap, nc)
                        end
                    end
                end
                if newMaxCap > 0 then m.capacity = newMaxCap end
                -- Clear snapshot so the scaler re-snapshots the now-correct
                -- value on its next frame and rescales cleanly.
                m._phOriginalMeadowCapacity = nil
                -- Also reset the cached UI fields so the dialog isn't stuck
                -- on stale strings.
                if type(m.info) == "table" then
                    m.info.capacity = newMaxCap
                    m.info.value = newMaxCap
                    m.info.ratio = 1
                    m.info.text = string.format("%d l", newMaxCap)
                end
                if type(m.foodInfo) == "table" then
                    m.foodInfo.capacity = newMaxCap
                    m.foodInfo.value = newMaxCap
                    m.foodInfo.ratio = 1
                    m.foodInfo.text = string.format("%d l", newMaxCap)
                end
                m.isMeadowInfoDirty = true
            end
        end
    end
    out[#out + 1] = "Done. SAVE the game now and the snapshot will persist so cap won't compound on next reload."
    return table.concat(out, "\n")
end

function PastureHerding:consoleProbeMeadow()
    if self.grazingRegistry == nil then return "PastureHerding: registry missing" end
    local out = {}
    local function dumpTable(label, t)
        if type(t) ~= "table" then
            out[#out + 1] = string.format("  %s : nil", label)
            return
        end
        out[#out + 1] = string.format("  -- %s --", label)
        for k, v in pairs(t) do
            local desc = type(v)
            if type(v) == "number" then
                desc = string.format("number=%s", tostring(v))
            elseif type(v) == "table" then
                local entries = {}
                for kk, vv in pairs(v) do
                    if type(vv) == "number" then
                        entries[#entries + 1] = string.format("[%s]=%.0f", tostring(kk), vv)
                    elseif type(vv) == "string" then
                        entries[#entries + 1] = string.format("[%s]='%s'", tostring(kk), vv)
                    end
                    if #entries >= 6 then break end
                end
                desc = string.format("table{%s}", table.concat(entries, ","))
            elseif type(v) == "boolean" then
                desc = string.format("bool=%s", tostring(v))
            end
            out[#out + 1] = string.format("    %s : %s", tostring(k), desc)
        end
    end
    for barnIdx, barn in self.grazingRegistry:iterBarns() do
        for pIdx, p in ipairs(barn.pastures) do
            if p ~= nil then
                out[#out + 1] = string.format("==== barn#%d pasture#%d ====", barnIdx, pIdx)
                dumpTable("spec_husbandryMeadow",  p.spec_husbandryMeadow)
                dumpTable("spec_husbandryFood",    p.spec_husbandryFood)
                dumpTable("spec_husbandry",        p.spec_husbandry)
                -- Anything else that might back the "stand-near-pasture"
                -- info dialog. We also check infoTrigger which vanilla
                -- placeables sometimes expose for in-world info text.
                if p.infoTrigger ~= nil then
                    dumpTable("infoTrigger (placeable.infoTrigger)", p.infoTrigger)
                end
                if p.spec_infoTrigger ~= nil then
                    dumpTable("spec_infoTrigger", p.spec_infoTrigger)
                end
                if p.spec_husbandryAnimals ~= nil then
                    out[#out + 1] = string.format("  spec_husbandryAnimals: clusterSystem=%s", tostring(p.spec_husbandryAnimals.clusterSystem))
                end
            end
        end
    end
    return table.concat(out, "\n")
end

function PastureHerding:consoleMeadowDrain()
    local sched = self.grazingScheduler
    if sched == nil or sched._meadowDrainTracker == nil or next(sched._meadowDrainTracker) == nil then
        return "PastureHerding: no meadow drain data yet (open a cycle first)"
    end
    local env = g_currentMission and g_currentMission.environment
    local nowGameH = env and ((env.currentDay or 0) * 24 + (env.currentHour or 0) + (env.currentMinute or 0) / 60) or 0
    local nowReal = (g_currentMission and g_currentMission.time) or 0
    local out = {}
    out[#out + 1] = "==== Meadow drain report ===="
    for key, e in pairs(sched._meadowDrainTracker) do
        local gameH = nowGameH - e.startGameH
        local realSec = (nowReal - e.startRealMs) / 1000
        local actualLPerH = gameH > 0 and (e.netDrain / gameH) or 0
        -- Predicted: re-query the herd's grass demand right now.
        local barnIdx, pIdx = key:match("(%d+)-(%d+)")
        barnIdx, pIdx = tonumber(barnIdx), tonumber(pIdx)
        local barn = self.grazingRegistry and self.grazingRegistry:getBarn(barnIdx)
        local pasture = barn and barn.pastures[pIdx]
        local herdLPH = 0
        if barn ~= nil then
            herdLPH = GrazingScheduler.sumFoodInputAt(barn.placeable)
            for _, p in ipairs(barn.pastures) do
                herdLPH = herdLPH + GrazingScheduler.sumFoodInputAt(p)
            end
        end
        local grassFraction = (self.pastureGrassFoodFraction or 0.65)
        local predictedLPH = herdLPH * grassFraction
        local ratio = predictedLPH > 0 and (actualLPerH / predictedLPH) or 0
        local perCowActual = e.maxCows > 0 and (actualLPerH / e.maxCows) or 0
        out[#out + 1] = string.format(
            "  barn#%d pasture#%d:  netDrain=%.0f L over %.1f game-hr (%.0f real-sec)",
            barnIdx, pIdx, e.netDrain, gameH, realSec)
        out[#out + 1] = string.format(
            "    actual=%.0f L/game-hr,  predicted=%.0f L/game-hr (herd-total %.0f × %.0f%% roughage)",
            actualLPerH, predictedLPH, herdLPH, grassFraction * 100)
        out[#out + 1] = string.format(
            "    ratio actual/predicted=%.2fx  -  max cows=%d  -  per-cow actual=%.1f L/game-hr",
            ratio, e.maxCows, perCowActual)
    end
    out[#out + 1] = "(counter reset)"
    sched._meadowDrainTracker = {}
    return table.concat(out, "\n")
end

function PastureHerding:consoleReloadRLRMSettings()
    if GrazingScheduler ~= nil and type(GrazingScheduler.refreshRLRMFoodScale) == "function" then
        GrazingScheduler.refreshRLRMFoodScale()
        -- Drop the meadow snapshots so the new scale re-applies cleanly
        -- from the model's authored capacity, not from a previously-scaled
        -- value. Without this the scaler treats whatever-it-saw-before as
        -- the original.
        if self.grazingRegistry ~= nil then
            for _, barn in self.grazingRegistry:iterBarns() do
                for _, p in ipairs(barn.pastures) do
                    local m = p and p.spec_husbandryMeadow
                    if m ~= nil then m._phOriginalMeadowCapacity = nil end
                end
            end
        end
        return "PastureHerding: RLRM settings re-read; meadow snapshots cleared. Next frame will re-snapshot and re-scale."
    end
    return "PastureHerding: GrazingScheduler.refreshRLRMFoodScale unavailable"
end

function PastureHerding:consoleFoodScale()
    local rl = RealisticLivestock_PlaceableHusbandryFood  -- plain access (rawget bypasses sandbox __index)
    local scale = (rl and rl.foodScale)
    local out = {}
    out[#out + 1] = string.format("RealisticLivestock_PlaceableHusbandryFood global = %s", tostring(rl))
    out[#out + 1] = string.format("  .foodScale = %s (type %s)", tostring(scale), type(scale))
    out[#out + 1] = string.format("framework.meadowFoodScaleCompensation = %s",
        tostring(self.meadowFoodScaleCompensation))
    if self.grazingRegistry ~= nil then
        for barnIdx, barn in self.grazingRegistry:iterBarns() do
            for pIdx, p in ipairs(barn.pastures) do
                local m = p and p.spec_husbandryMeadow
                if m ~= nil then
                    local snap = m._phOriginalMeadowCapacity
                    local snapStr = "(no snapshot — never scaled up)"
                    if type(snap) == "table" then
                        snapStr = ""
                        for ft, c in pairs(snap) do
                            snapStr = snapStr .. string.format("ft=%s origCap=%s, ", tostring(ft), tostring(c))
                        end
                    end
                    out[#out + 1] = string.format(
                        "barn#%d pasture#%d  meadow.capacity=%s  snapshot: %s",
                        barnIdx, pIdx, tostring(m.capacity), snapStr)
                end
            end
        end
    end
    return table.concat(out, "\n")
end

function PastureHerding:consoleStatus()
    return string.format("PastureHerding v%s | RLM=%s | registered husbandries=%d | shrinkNextPen=%s | shrinkRadius=%.2f",
        self.version, tostring(self.hasRLM), self.navMeshLayer and self.navMeshLayer:count() or 0,
        tostring(self.shrinkNextPen), self.shrinkRadius)
end

function PastureHerding:registerExistingPlaceables()
    if g_currentMission == nil then
        PastureHerdingLog.dev("registerExistingPlaceables: no g_currentMission")
        return
    end

    local sources = {}
    if g_currentMission.husbandrySystem ~= nil and g_currentMission.husbandrySystem.placeables ~= nil then
        sources[#sources + 1] = { name = "husbandrySystem.placeables", t = g_currentMission.husbandrySystem.placeables }
    end
    if g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables ~= nil then
        sources[#sources + 1] = { name = "placeableSystem.placeables", t = g_currentMission.placeableSystem.placeables }
    end

    if #sources == 0 then
        PastureHerdingLog.warning("registerExistingPlaceables: no placeable tables found on g_currentMission")
        return
    end

    local seen = {}
    local total = 0
    local hits = 0
    for _, src in ipairs(sources) do
        local n = 0
        for _, p in pairs(src.t) do
            n = n + 1
            if not seen[p] then
                seen[p] = true
                total = total + 1
                if p.spec_husbandryAnimals ~= nil then
                    hits = hits + 1
                    local spec = p.spec_husbandryAnimals
                    local navNode = (spec.clusterHusbandry and spec.clusterHusbandry.navigationNode)
                        or spec.navigationNode
                    PastureHerdingLog.dev("existing pen id=%s navigationNode=%s clusterHusbandry=%s husbandry=%s",
                        tostring(NetworkUtil.getObjectId(p) or p),
                        tostring(navNode),
                        tostring(spec.clusterHusbandry),
                        tostring(spec.husbandry))
                    if navNode ~= nil then
                        self.navMeshLayer:buildForHusbandry(p, navNode)
                    end
                end
            end
        end
        PastureHerdingLog.dev("registerExistingPlaceables: %s had %d entries", src.name, n)
    end
    PastureHerdingLog.dev("registerExistingPlaceables: %d unique placeables, %d animal husbandries", total, hits)
end

-- ---------------- Grazing scheduler console commands ----------------

local function findNearestHusbandryWithFilter(px, pz, filterFn)
    local best, bestD2 = nil, math.huge
    local sources = {}
    if g_currentMission and g_currentMission.husbandrySystem and g_currentMission.husbandrySystem.placeables then
        sources[#sources + 1] = g_currentMission.husbandrySystem.placeables
    end
    if g_currentMission and g_currentMission.placeableSystem and g_currentMission.placeableSystem.placeables then
        sources[#sources + 1] = g_currentMission.placeableSystem.placeables
    end
    for _, src in ipairs(sources) do
        for _, p in pairs(src) do
            if p.spec_husbandryAnimals ~= nil and p.rootNode ~= nil and p.rootNode ~= 0 then
                if filterFn == nil or filterFn(p) then
                    local x, _, z = getWorldTranslation(p.rootNode)
                    local dx, dz = x - px, z - pz
                    local d2 = dx*dx + dz*dz
                    if d2 < bestD2 then bestD2 = d2; best = p end
                end
            end
        end
    end
    return best, math.sqrt(bestD2)
end

local function hasPolygon(placeable)
    local contour = ContourReader.readOutdoorContourPolygon(placeable)
    return contour ~= nil
end

function PastureHerding:consoleRegisterBarn()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "PastureHerding: cannot find player position" end
    local placeable, dist = findNearestHusbandryWithFilter(px, pz)
    if placeable == nil then return "PastureHerding: no husbandry placeables found" end
    local maxD = self.registerMaxDistanceM or 10
    if dist ~= nil and dist > maxD then
        -- NOTE: no placeableDisplayName() here — that local is declared further
        -- down the file, so it isn't in scope for this function (Lua locals are
        -- only visible to functions defined after the declaration line).
        return string.format(
            "PastureHerding: nearest husbandry is %.0fm away (max %.0fm). Stand at the barn you want to register.",
            dist, maxD)
    end
    local idx, err = self.grazingRegistry:registerBarn(placeable)
    if idx == nil then return "PastureHerding: " .. tostring(err) end
    self._lastBarnIdx = idx
    -- One-time onboarding hint. After the OFF-by-default toggle change a
    -- freshly-registered barn does nothing on its own until the player
    -- enables Grazing toggle and Auto Pasture Select from the menu.
    -- Without this nudge the cows just sit in the barn looking normal and
    -- the player can't tell whether registration actually worked.
    self:pushNotification({0.2, 1.0, 0.4, 1.0},
        string.format("Barn registered (#%d). Open the Pasture Grazing System menu " ..
                      "and enable Grazing toggle + Auto Pasture Select to start the daily cycle.", idx),
        8000)
    return string.format("PastureHerding: registered barn#%d (placeable at %.1fm)", idx, dist)
end

-- Shared helper: walk the registry and return the index of the registered
-- barn closest to the player, plus distance. Returns nil if there are no
-- barns yet. Used by both consoleAddPasture (commits) and previewAddPasture
-- (UI indicator — no commit).
function PastureHerding:_findNearestRegisteredBarn()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return nil end
    local bestIdx, bestD2 = nil, math.huge
    for idx, barn in self.grazingRegistry:iterBarns() do
        local bp = barn.placeable
        if bp and bp.rootNode and bp.rootNode ~= 0 then
            local bx, _, bz = getWorldTranslation(bp.rootNode)
            local dx, dz = bx - px, bz - pz
            local d2 = dx*dx + dz*dz
            if d2 < bestD2 then bestD2 = d2; bestIdx = idx end
        end
    end
    if bestIdx == nil then return nil end
    return bestIdx, math.sqrt(bestD2)
end

-- Returns the display name of a placeable, robust against vanilla / RLM /
-- modded husbandries that store the name differently.
local function placeableDisplayName(placeable)
    if placeable == nil then return "?" end
    if type(placeable.getName) == "function" then
        local ok, name = pcall(function() return placeable:getName() end)
        if ok and type(name) == "string" and name ~= "" then return name end
    end
    return placeable.savedName or "(unnamed)"
end

-- List a husbandry's animals (RLM clusters or base game), tolerant of both APIs.
local function herdAnimalList(placeable)
    local spec = placeable and placeable.spec_husbandryAnimals
    local cs = spec and spec.clusterSystem
    if cs == nil then return {} end
    return (cs.getAnimals and cs:getAnimals()) or cs.animals or {}
end

-- phSoilReport: per-pasture soil/state table for testing the PF + grazing model.
-- Prints to log.txt (timestamped); run it repeatedly to watch values move.
function PastureHerding:consoleSoilReport()
    local reg = self.grazingRegistry
    local sched = self.grazingScheduler
    if reg == nil or type(reg.iterBarns) ~= "function" then return "phSoilReport: no registry" end
    local log = PastureHerdingLog.info
    local env = g_currentMission and g_currentMission.environment
    local today = (env and env.currentDay) or 0
    log("=== phSoilReport === (PF soil layer: %s)", (self.pfSoilEnabled == false) and "OFF" or "ON")
    local nB = 0
    for barnIdx, barn in reg:iterBarns() do
        nB = nB + 1
        local cond = (type(reg.getGrazingCondition) == "function") and reg:getGrazingCondition(barnIdx) or 0
        local state = (sched and sched.state and sched.state[barnIdx]) or "at_barn"
        log("Barn#%d '%s' [%s]  grazing condition %.0f%%",
            barnIdx, placeableDisplayName(barn.placeable), state, cond * 100)
        for pIdx, pasture in ipairs(barn.pastures) do
            local active = (barn.activeIndex == pIdx) and "*ACT*" or "     "
            local grass = (sched and type(sched._meadowGrassPct) == "function")
                          and (sched:_meadowGrassPct(pasture) or -1) or -1
            local q    = reg:getPastureQuality(barnIdx, pIdx)
            local ph   = (type(reg.getPHFactor) == "function") and reg:getPHFactor(barnIdx, pIdx) or 1.0
            local eff  = (type(reg.getEffectiveQuality) == "function") and reg:getEffectiveQuality(barnIdx, pIdx) or q
            local lime = (type(reg.getLimeCorrection) == "function") and reg:getLimeCorrection(barnIdx, pIdx) or 0
            local fert = (type(reg.getFertilizerDaysRemaining) == "function") and reg:getFertilizerDaysRemaining(barnIdx, pIdx) or 0
            local extra = (fert > 0) and string.format(" | fert %dd", fert) or ""
            if type(reg.isRenewing) == "function" and reg:isRenewing(barnIdx, pIdx) then
                local ud = reg:getRenewalUnlockDay(barnIdx, pIdx) or 0
                extra = extra .. string.format(" | REGROWTH %dd", math.max(0, math.floor(ud - today)))
            end
            log("   P%-2d '%s' %s grass %3.0f%% | soil %3.0f%% | pH x%.2f | eff %3.0f%% | lime %3.0f%%%s",
                pIdx, placeableDisplayName(pasture), active,
                (grass < 0 and 0 or grass * 100), q * 100, ph, eff * 100, lime * 100, extra)
        end
    end
    return string.format("phSoilReport: %d barn(s) -> see log.txt", nB)
end

-- phHerdReport: per-barn animal table (count, health, weight vs target, milk,
-- grazing condition) for testing the boost + productivity. Prints to log.txt.
function PastureHerding:consoleHerdReport()
    local reg = self.grazingRegistry
    local sched = self.grazingScheduler
    if reg == nil or type(reg.iterBarns) ~= "function" then return "phHerdReport: no registry" end
    local log = PastureHerdingLog.info
    log("=== phHerdReport ===")
    local nB = 0
    for barnIdx, barn in reg:iterBarns() do
        nB = nB + 1
        local p = barn.placeable
        local prod  = (type(reg.getProductionType) == "function") and reg:getProductionType(barnIdx) or "?"
        local sm    = (type(reg.getScheduleMode) == "function") and reg:getScheduleMode(barnIdx) or "?"
        local state = (sched and sched.state and sched.state[barnIdx]) or "at_barn"
        local cond  = (type(reg.getGrazingCondition) == "function") and reg:getGrazingCondition(barnIdx) or 0
        -- Count the herd WHERE IT IS: the active pasture when out, else the barn.
        local src = p
        if state == "at_pasture" and barn.activeIndex and barn.pastures[barn.activeIndex] then
            src = barn.pastures[barn.activeIndex]
        end
        local n, hSum, wSum, tSum, milkHr, hMin = 0, 0, 0, 0, 0, 999
        for _, a in pairs(herdAnimalList(src)) do
            if type(a) == "table" then
                local cnt = a.numAnimals or 1
                n = n + cnt
                -- Health: RLM exposes .health (0..100) or getHealthFactor() (0..1).
                local h
                if type(a.health) == "number" then h = a.health
                elseif type(a.getHealthFactor) == "function" then
                    local okH, hf = pcall(function() return a:getHealthFactor() end)
                    if okH and type(hf) == "number" then h = hf * 100 end
                end
                if h ~= nil then hSum = hSum + h * cnt; if h < hMin then hMin = h end end
                if type(a.weight) == "number" then wSum = wSum + a.weight * cnt end
                if type(a.targetWeight) == "number" then tSum = tSum + a.targetWeight * cnt end
                if type(a.getOutput) == "function" then
                    local ok, m = pcall(function() return a:getOutput("milk") end)
                    if ok and type(m) == "number" then milkHr = milkHr + m * cnt end
                end
            end
        end
        local milkStored, ms = 0, p and p.spec_husbandryMilk
        if ms and type(ms.fillTypes) == "table" and type(p.getHusbandryFillLevel) == "function" then
            for _, ft in ipairs(ms.fillTypes) do milkStored = milkStored + (p:getHusbandryFillLevel(ft) or 0) end
        end
        log("Barn#%d '%s' [%s, %s, %s]  animals=%d  grazing condition=%.0f%%",
            barnIdx, placeableDisplayName(p), prod, sm, state, n, cond * 100)
        if n > 0 then
            local avgW, avgT = wSum / n, tSum / n
            log("   health avg %.0f%% (min %.0f) | weight avg %.0f / %.0f kg (%.0f%%) | milk %.1f l/h | stored %.0f L",
                hSum / n, (hMin == 999 and 0 or hMin), avgW, avgT,
                (avgT > 0 and avgW / avgT * 100 or 0), milkHr, milkStored)
        end
    end
    return string.format("phHerdReport: %d barn(s) -> see log.txt", nB)
end

-- Read-only preview: which barn WOULD an Add-Pasture click target right
-- now? Returns (barnIdx, barnName, barnDist, pastureName, pastureDist) or
-- nil + reason. Used by the Setup tab's "Target barn" indicator.
function PastureHerding:previewAddPasture()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return nil, "no player position" end
    local barnIdx, barnDist = self:_findNearestRegisteredBarn()
    if barnIdx == nil then return nil, "no barns registered" end
    local barn = self.grazingRegistry:getBarn(barnIdx)
    local barnName = placeableDisplayName(barn and barn.placeable)
    local placeable, pastureDist = findNearestHusbandryWithFilter(px, pz, hasPolygon)
    local pastureName = placeableDisplayName(placeable)
    return barnIdx, barnName, barnDist, pastureName, pastureDist
end

-- Add the nearest eligible pasture to a SPECIFIC barn index — used by the
-- Setup tab when the player has explicitly picked a target via the barn
-- picker. Returns the same kind of string consoleAddPasture does so the
-- caller can log/display it.
function PastureHerding:addPastureToSpecificBarn(targetBarnIdx)
    if targetBarnIdx == nil or self.grazingRegistry:getBarn(targetBarnIdx) == nil then
        return "PastureHerding: invalid target barn index"
    end
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "PastureHerding: cannot find player position" end
    local placeable, pastureDist = findNearestHusbandryWithFilter(px, pz, hasPolygon)
    if placeable == nil then return "PastureHerding: no nearby pen has a usable outdoorContourPolygon" end
    local maxD = self.registerMaxDistanceM or 10
    if pastureDist ~= nil and pastureDist > maxD then
        return string.format(
            "PastureHerding: nearest pen '%s' is %.0fm away (max %.0fm). Stand at the pasture you want to add.",
            placeableDisplayName(placeable), pastureDist, maxD)
    end

    self._lastBarnIdx = targetBarnIdx
    local barn = self.grazingRegistry:getBarn(targetBarnIdx)
    local barnName = placeableDisplayName(barn and barn.placeable)
    local pIdx, err = self.grazingRegistry:addPastureToBarn(targetBarnIdx, placeable)
    if pIdx == nil then return "PastureHerding: " .. tostring(err) end
    self:applyInitialSoilQuality(targetBarnIdx, pIdx)
    local msg = string.format("PastureHerding: added pasture#%d '%s' to barn#%d '%s' (pasture %.1fm)",
        pIdx, placeableDisplayName(placeable), targetBarnIdx, barnName, pastureDist or 0)
    PastureHerdingLog.info(msg)
    -- Warn (don't block) if the pen sits a long way from the barn it's now
    -- linked to — that's a long cow walk and usually means the wrong barn was
    -- selected in the picker.
    local sepMsg = self:_warnIfPastureFarFromBarn(placeable, barn and barn.placeable, targetBarnIdx, pIdx)
    if sepMsg ~= nil then msg = msg .. " " .. sepMsg end
    return msg
end

-- Distance between a pasture and its barn. Returns a warning suffix (and pushes
-- a notification) when it exceeds pastureBarnWarnDistanceM, else nil.
function PastureHerding:_warnIfPastureFarFromBarn(pasturePlaceable, barnPlaceable, barnIdx, pIdx)
    if pasturePlaceable == nil or barnPlaceable == nil then return nil end
    if pasturePlaceable.rootNode == nil or barnPlaceable.rootNode == nil then return nil end
    local okP, pxw, _, pzw = pcall(function() return getWorldTranslation(pasturePlaceable.rootNode) end)
    local okB, bxw, _, bzw = pcall(function() return getWorldTranslation(barnPlaceable.rootNode) end)
    if not okP or not okB or pxw == nil or bxw == nil then return nil end
    local dx, dz = pxw - bxw, pzw - bzw
    local sep = math.sqrt(dx * dx + dz * dz)
    local warnAt = self.pastureBarnWarnDistanceM or 200
    if sep <= warnAt then return nil end
    local text = string.format(
        "Heads up: pasture #%d is %.0fm from barn #%d — the herd will walk that far each way. Check you picked the right barn.",
        pIdx or 0, sep, barnIdx or 0)
    self:pushNotification({1.0, 0.85, 0.2, 1.0}, text, 9000)
    PastureHerdingLog.warning("PastureHerding: %s", text)
    return string.format("(WARNING: %.0fm from its barn)", sep)
end

function PastureHerding:consoleAddPasture()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "PastureHerding: cannot find player position" end
    local placeable, pastureDist = findNearestHusbandryWithFilter(px, pz, hasPolygon)
    if placeable == nil then return "PastureHerding: no nearby pen has a usable outdoorContourPolygon" end
    local maxD = self.registerMaxDistanceM or 10
    if pastureDist ~= nil and pastureDist > maxD then
        return string.format(
            "PastureHerding: nearest pen '%s' is %.0fm away (max %.0fm). Stand at the pasture you want to add.",
            placeableDisplayName(placeable), pastureDist, maxD)
    end

    -- Always target the barn NEAREST the player. Previously this was locked
    -- to the most-recently-registered barn (_lastBarnIdx), which meant the
    -- player couldn't go back and add pastures to an earlier barn without
    -- re-registering it. Nearest-barn matches the natural "walk to the barn
    -- you want, hit ADD" workflow and works in any order.
    local barnIdx, barnDist = self:_findNearestRegisteredBarn()
    if barnIdx == nil then
        return "PastureHerding: no barns registered. Run phRegisterBarn first."
    end
    self._lastBarnIdx = barnIdx
    local barn = self.grazingRegistry:getBarn(barnIdx)
    local barnName = placeableDisplayName(barn and barn.placeable)
    local pIdx, err = self.grazingRegistry:addPastureToBarn(barnIdx, placeable)
    if pIdx == nil then return "PastureHerding: " .. tostring(err) end
    self:applyInitialSoilQuality(barnIdx, pIdx)
    local msg = string.format("PastureHerding: added pasture#%d '%s' to barn#%d '%s' (barn %.1fm, pasture %.1fm)",
        pIdx, placeableDisplayName(placeable), barnIdx, barnName, barnDist, pastureDist)
    local sepMsg = self:_warnIfPastureFarFromBarn(placeable, barn and barn.placeable, barnIdx, pIdx)
    if sepMsg ~= nil then msg = msg .. " " .. sepMsg end
    return msg
end

function PastureHerding:consoleSetActivePasture(pastureIdxArg, barnIdxArg)
    local pIdx = tonumber(pastureIdxArg)
    if pIdx == nil then return "Usage: phSetActivePasture <pastureIdx> [barnIdx]" end
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local ok, err = self.grazingRegistry:setActivePasture(bIdx, pIdx)
    if not ok then return "PastureHerding: " .. tostring(err) end
    return string.format("PastureHerding: barn#%d active pasture set to %d", bIdx, pIdx)
end

function PastureHerding:consoleForceTransition(directionArg)
    local d = tostring(directionArg or "")
    if d ~= "out" and d ~= "in" then return "Usage: phForceTransition out|in" end
    self.grazingScheduler:transitionAll(d, "manual")
    return string.format("PastureHerding: %s transition dispatched", d)
end

local function sumFillLevels(spec)
    if spec == nil or spec.fillLevels == nil then return 0 end
    local total = 0
    for _, lvl in pairs(spec.fillLevels) do
        if type(lvl) == "number" then total = total + lvl end
    end
    return total
end

local function fillTypeNames(spec)
    if spec == nil or spec.fillLevels == nil then return "" end
    local names = {}
    for ft in pairs(spec.fillLevels) do
        if g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByIndex ~= nil then
            local ftDef = g_fillTypeManager:getFillTypeByIndex(ft)
            names[#names + 1] = (ftDef and ftDef.name) or tostring(ft)
        else
            names[#names + 1] = tostring(ft)
        end
    end
    table.sort(names)
    return table.concat(names, ", ")
end

local function describeHusbandry(placeable, label)
    local lines = { string.format("  %s [%s]", label,
        tostring((placeable.getName and placeable:getName()) or placeable)) }

    local spec = placeable.spec_husbandryAnimals
    if spec ~= nil and spec.clusterSystem ~= nil then
        local cs = spec.clusterSystem
        local list = (cs.getAnimals and cs:getAnimals()) or cs.animals or {}
        local count, healthSum, healthN = 0, 0, 0
        for _, a in pairs(list) do
            count = count + (a.numAnimals or 1)
            if a.health ~= nil then
                healthSum = healthSum + a.health * (a.numAnimals or 1)
                healthN = healthN + (a.numAnimals or 1)
            end
        end
        local avg = healthN > 0 and (healthSum / healthN) or 0
        lines[#lines + 1] = string.format("    animals: %d  avg health: %.1f / 100", count, avg)
    end

    local food = placeable.spec_husbandryFood
    local troughFill, troughCap = 0, 0
    if food ~= nil then
        troughFill = sumFillLevels(food)
        troughCap = food.capacity or 0
        local pct = troughCap > 0 and (troughFill / troughCap * 100) or 0
        lines[#lines + 1] = string.format("    trough:  %.0f / %.0f L (%.0f%%) types: [%s]",
            troughFill, troughCap, pct, fillTypeNames(food))
    end

    local meadow = placeable.spec_husbandryMeadow
    local meadowFill, meadowCap = 0, 0
    if meadow ~= nil and type(meadow.fillLevels) == "table" then
        meadowFill = sumFillLevels(meadow)
        if type(meadow.capacities) == "table" then
            for _, c in pairs(meadow.capacities) do
                if type(c) == "number" then meadowCap = meadowCap + c end
            end
        end
        local pct = meadowCap > 0 and (meadowFill / meadowCap * 100) or 0
        lines[#lines + 1] = string.format("    meadow:  %.0f / %.0f L (%.0f%%) (grass available for grazing)",
            meadowFill, meadowCap, pct)
    end

    -- Animals are fed from EITHER trough OR meadow. Only flag low food when
    -- both are empty AND there's a trough capacity to fill (pure-grazing pens
    -- with capacity=0 trough are intentionally meadow-only).
    if (troughCap > 0 or meadowCap > 0) and (troughFill + meadowFill) < math.max(troughCap, meadowCap) * 0.1 then
        lines[#lines + 1] = "    !! LOW FOOD (trough + meadow both nearly empty)"
    end

    -- Water / milk / manure use a single fillType + the placeable's storage,
    -- NOT a per-spec fillLevels table. Read via getHusbandryFillLevel(fillType)
    -- on the placeable.
    local function readStorage(ft)
        if ft == nil then return 0, 0 end
        local fl, cap = 0, 0
        if type(placeable.getHusbandryFillLevel) == "function" then
            fl = placeable:getHusbandryFillLevel(ft) or 0
        end
        if type(placeable.getHusbandryCapacity) == "function" then
            cap = placeable:getHusbandryCapacity(ft) or 0
        end
        return fl, cap
    end

    local water = placeable.spec_husbandryWater
    if water ~= nil then
        local autoSup = water.automaticWaterSupply == true
        local fl, cap = readStorage(water.fillType)
        local pct = cap > 0 and (fl / cap * 100) or 0
        lines[#lines + 1] = string.format("    water:   %.0f / %.0f L (%.0f%%)%s",
            fl, cap, pct, autoSup and "  [auto-supply]" or "")
        if not autoSup and cap > 0 and pct < 10 then lines[#lines + 1] = "    !! LOW WATER" end
    end

    local milk = placeable.spec_husbandryMilk
    if milk ~= nil and milk.hasMilkProduction then
        local fl, cap = readStorage(milk.fillType)
        lines[#lines + 1] = string.format("    milk:    %.0f / %.0f L", fl, cap)
    end

    local straw = placeable.spec_husbandryStraw
    if straw ~= nil then
        local fl, cap = readStorage(straw.fillType)
        if cap > 0 then
            lines[#lines + 1] = string.format("    straw:   %.0f / %.0f L", fl, cap)
        else
            lines[#lines + 1] = "    straw:   (present)"
        end
    end

    local manure = placeable.spec_husbandryLiquidManure
    if manure ~= nil then
        local fl, cap = readStorage(manure.fillType)
        if cap > 0 then
            lines[#lines + 1] = string.format("    manure:  %.0f / %.0f L", fl, cap)
        else
            lines[#lines + 1] = "    manure:  (present)"
        end
    end

    return table.concat(lines, "\n")
end

-- Top up a husbandry's spec_husbandryFood and spec_husbandryStraw to 100%
-- if either is currently below 50%. Uses the first supported fill type for
-- each spec — works for the testing case where you just want cows happy.
function PastureHerding:autoFeedTopUp(placeable, label)
    if placeable == nil then return end
    local farmId = placeable.ownerFarmId or 1

    local function topUpSpec(spec, kind)
        if spec == nil then return end
        local cap = spec.capacity
        if type(cap) ~= "number" or cap <= 0 then return end
        local current = 0
        if type(spec.fillLevels) == "table" then
            for _, lvl in pairs(spec.fillLevels) do
                if type(lvl) == "number" then current = current + lvl end
            end
        end
        local pct = current / cap
        if pct >= 0.5 then return end
        local need = cap - current
        -- Prefer the highest-productivity feed (TMR / FORAGE) for food. Falls
        -- back to whatever the spec accepts if none of the preferred types
        -- are supported. For straw the preferred order doesn't really apply,
        -- but the same lookup runs harmlessly.
        local ft = nil
        if type(spec.supportedFillTypes) == "table" and g_fillTypeManager ~= nil then
            local preferred
            if kind == "food" then
                preferred = { "FORAGE_MIXING", "FORAGE", "SILAGE",
                              "ALFALFA_FERMENTED", "DRYGRASS_WINDROW", "GRASS_WINDROW" }
            else
                preferred = { "STRAW", "STRAW_LONG" }
            end
            for _, name in ipairs(preferred) do
                local idx = g_fillTypeManager:getFillTypeIndexByName(name)
                if idx ~= nil and spec.supportedFillTypes[idx] ~= nil then
                    ft = idx
                    break
                end
            end
            if ft == nil then
                for k in pairs(spec.supportedFillTypes) do ft = k; break end
            end
        end
        if ft == nil then return end
        local methodName = (kind == "food") and "addFood" or "addStraw"
        local actuallyAdded = 0
        if type(placeable[methodName]) == "function" then
            pcall(function()
                actuallyAdded = placeable[methodName](placeable, farmId, need, ft) or 0
            end)
        end
        local ftName = (g_fillTypeManager and g_fillTypeManager.getFillTypeNameByIndex
                        and g_fillTypeManager:getFillTypeNameByIndex(ft)) or tostring(ft)
        PastureHerdingLog.dev("autoFeed[%s]: %s %.0f / %.0f L (%.0f%%) -> topped up %.0f L of %s",
            tostring(label), kind, current, cap, pct * 100, actuallyAdded, tostring(ftName))
    end

    topUpSpec(placeable.spec_husbandryFood,  "food")
    topUpSpec(placeable.spec_husbandryStraw, "straw")
end

-- Hourly check called from GrazingScheduler:tick on hour-change crossings.
function PastureHerding:autoFeedTick()
    if not self.autoFeedEnabled then return end
    if self.grazingRegistry == nil then return end
    for bIdx, barn in self.grazingRegistry:iterBarns() do
        if barn.placeable ~= nil then
            self:autoFeedTopUp(barn.placeable, string.format("barn#%d", bIdx))
        end
    end
end

function PastureHerding:consoleAutoFeed(arg)
    if self.grazingRegistry == nil then return "PastureHerding: registry missing" end
    local s = (arg or ""):lower()
    if s == "on" then
        self.autoFeedEnabled = true
    elseif s == "off" then
        self.autoFeedEnabled = false
    else
        self.autoFeedEnabled = not self.autoFeedEnabled
    end
    -- Run a check immediately so the operator sees something happen on the
    -- "on" transition without waiting an hour.
    if self.autoFeedEnabled then self:autoFeedTick() end
    return string.format("PastureHerding: autoFeed %s", self.autoFeedEnabled and "ON" or "OFF")
end

function PastureHerding:consoleFeedStatus()
    if self.grazingRegistry == nil then return "PastureHerding: registry missing" end
    local out = { "phFeedStatus:" }
    for bIdx, barn in self.grazingRegistry:iterBarns() do
        if barn.placeable ~= nil then
            out[#out + 1] = describeHusbandry(barn.placeable, string.format("barn#%d", bIdx))
        end
        for pIdx, pasture in ipairs(barn.pastures) do
            local marker = (pIdx == barn.activeIndex) and "ACTIVE" or "      "
            out[#out + 1] = describeHusbandry(pasture, string.format("barn#%d pasture#%d %s", bIdx, pIdx, marker))
        end
    end
    if #out == 1 then out[#out + 1] = "  (no barns registered)" end
    local report = table.concat(out, "\n")
    PastureHerdingLog.info("%s", report)
    return "PastureHerding: feed status written to log"
end

function PastureHerding:consoleGrazingStatus()
    local registry = self.grazingRegistry:describe()
    local sched = self.grazingScheduler:describe()
    PastureHerdingLog.info("phGrazingStatus:\n%s\n%s", sched, registry)
    return "PastureHerding: status written to log"
end

function PastureHerding:consoleCycleActivePasture(barnIdxArg)
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local newIdx, err = self.grazingRegistry:cycleActivePasture(bIdx)
    if newIdx == nil then return "PastureHerding: " .. tostring(err) end
    return string.format("PastureHerding: barn#%d active pasture cycled to %d", bIdx, newIdx)
end

function PastureHerding:consoleSetActiveByPosition(barnIdxArg)
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local barn = self.grazingRegistry:getBarn(bIdx)
    if barn == nil then return string.format("PastureHerding: no such barn#%d", bIdx) end
    if #barn.pastures == 0 then return string.format("PastureHerding: barn#%d has no pastures", bIdx) end

    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "PastureHerding: cannot find player position" end

    local bestIdx, bestD2 = nil, math.huge
    for pIdx, placeable in ipairs(barn.pastures) do
        if placeable.rootNode ~= nil and placeable.rootNode ~= 0 then
            local x, _, z = getWorldTranslation(placeable.rootNode)
            local dx, dz = x - px, z - pz
            local d2 = dx*dx + dz*dz
            if d2 < bestD2 then bestD2 = d2; bestIdx = pIdx end
        end
    end
    if bestIdx == nil then return "PastureHerding: no usable pastures on barn" end

    local ok, err = self.grazingRegistry:setActivePasture(bIdx, bestIdx)
    if not ok then return "PastureHerding: " .. tostring(err) end
    return string.format("PastureHerding: barn#%d active pasture set to %d (at %.1fm)",
        bIdx, bestIdx, math.sqrt(bestD2))
end

local function nearestPasture(framework)
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return nil, nil, "cannot find player position" end
    local bIdx, pIdx, d2 = framework.grazingRegistry:findNearestPasture(px, pz)
    if bIdx == nil then return nil, nil, "no registered pastures" end
    return bIdx, pIdx, math.sqrt(d2)
end

-- Resolve the (barnIdx, pastureIdx) to work on for path commands.
-- Prefers the explicit 'currently building' selection if set; otherwise
-- falls back to the nearest pasture to the player.
-- Public-facing wrapper so frames can call this without dipping into the
-- module-local nearestPasture function.
function PastureHerding:getNearestPasture()
    return nearestPasture(self)
end

-- Returns true when the current weather is rain or snow. FS25's weather
-- API varies slightly by version; we try a few accessors defensively. Falls
-- back to "clear" (false) when nothing recognisable is available.
function PastureHerding:isBadWeather()
    if g_currentMission == nil or g_currentMission.environment == nil then return false end
    local env = g_currentMission.environment
    -- Newer FS25 builds expose env.weather:getCurrentRainTypeId() (0 = clear).
    if type(env.weather) == "table" and type(env.weather.getCurrentRainTypeId) == "function" then
        local id = env.weather:getCurrentRainTypeId()
        if id ~= nil and id ~= 0 then return true end
    end
    -- Older path: env.currentRain is a numeric weather type while raining.
    if type(env.currentRain) == "number" and env.currentRain > 0 then return true end
    -- WeatherSystem path: env.weatherSystem:getCurrentWeather()
    if type(env.weatherSystem) == "table" and type(env.weatherSystem.getCurrentWeather) == "function" then
        local w = env.weatherSystem:getCurrentWeather()
        if type(w) == "table" and (w.weatherType == "rain" or w.weatherType == "snow"
                                   or w.isRaining == true or w.isSnowing == true) then
            return true
        end
    end
    return false
end

-- Resolve the in-game CALENDAR month (1=Jan..12=Dec) regardless of the
-- game's 'periods per year' setting. FS25's year starts in MARCH —
-- currentPeriod 1 = March, 2 = April, ..., 12 = February. So we shift by
-- +2 (mod 12) to land on the calendar month the player sees in the date
-- display, then handle non-12-period games by taking the period's
-- midpoint in months before applying the same shift.
function PastureHerding:getCurrentMonth()
    local env = g_currentMission and g_currentMission.environment
    if env == nil then return nil end
    if type(env.getCurrentMonth) == "function" then
        local ok, m = pcall(function() return env:getCurrentMonth() end)
        if ok and type(m) == "number" then return m end
    end
    if type(env.currentMonth) == "number" then return env.currentMonth end
    local period = env.currentPeriod
    if type(period) ~= "number" then return nil end
    local n = env.numberOfPeriods or env.numPeriods or 12
    if type(n) ~= "number" or n <= 0 then n = 12 end
    -- Period midpoint in 0..12 game-year space (0 = start of period 1 = March).
    local gameMonthIdx = ((period - 1) + 0.5) * 12 / n
    -- Shift +2 because game year starts in March (calendar month 3).
    -- mod 12, then 1..12-clamp via floor + 1.
    local m = math.floor(gameMonthIdx + 2) % 12 + 1
    return m
end

-- Returns true when the current in-game month is inside the grazing
-- season. Wraps around the calendar — start=11 end=3 means Nov+Dec+Jan+Feb+Mar.
-- When start == end, only that single month counts as in-season.
function PastureHerding:isGrazingMonth(monthArg)
    local month = monthArg or self:getCurrentMonth()
    if type(month) ~= "number" then return true end
    local s, e = self.grazingSeasonStart or 1, self.grazingSeasonEnd or 12
    local inside
    if s <= e then
        inside = (month >= s and month <= e)
    else
        inside = (month >= s or month <= e)
    end
    local env = g_currentMission and g_currentMission.environment or {}
    PastureHerdingLog.dev(
        "PastureHerding:isGrazingMonth month=%d season=[%d..%d] inside=%s | env.currentPeriod=%s numberOfPeriods=%s currentMonth=%s currentDay=%s daysPerPeriod=%s",
        month, s, e, tostring(inside),
        tostring(env.currentPeriod), tostring(env.numberOfPeriods or env.numPeriods),
        tostring(env.currentMonth), tostring(env.currentDay),
        tostring(env.daysPerPeriod))
    return inside
end

local function resolvePathTarget(framework)
    if framework._buildingPath ~= nil then
        local b, p = framework._buildingPath.barnIdx, framework._buildingPath.pastureIdx
        if framework.grazingRegistry:getBarn(b) ~= nil
           and framework.grazingRegistry:getBarn(b).pastures[p] ~= nil then
            return b, p, "(currently building)"
        end
        framework._buildingPath = nil -- target gone (deleted?), drop it
    end
    local b, p, dist = nearestPasture(framework)
    if b == nil then return nil, nil, dist end
    return b, p, string.format("(nearest: %.1fm)", dist)
end

-- Tries the player's character first; if they're in a vehicle, samples
-- the vehicle's root instead. Lets the user record paths either on foot
-- or by driving a tractor along the route.
local function getRecordingWorldPos()
    if g_currentMission ~= nil and g_currentMission.controlledVehicle ~= nil then
        local v = g_currentMission.controlledVehicle
        if v.rootNode ~= nil and v.rootNode ~= 0 then
            return getWorldTranslation(v.rootNode)
        end
    end
    return getPlayerWorldPos()
end

-- Route a recorded waypoint / query to the right store: a build target with a
-- `fromIdx` is a pasture-pasture pair path; otherwise it's a barn->pasture path.
function PastureHerding:_addBuildWaypoint(bp, x, z)
    if bp.fromIdx ~= nil then
        return self.grazingRegistry:addPairWaypoint(bp.barnIdx, bp.fromIdx, bp.pastureIdx, x, z)
    end
    return self.grazingRegistry:addWaypoint(bp.barnIdx, bp.pastureIdx, x, z)
end

function PastureHerding:_buildPathLen(bp)
    local p
    if bp.fromIdx ~= nil then
        p = self.grazingRegistry:getPairPath(bp.barnIdx, bp.fromIdx, bp.pastureIdx)
    else
        p = self.grazingRegistry:getPath(bp.barnIdx, bp.pastureIdx)
    end
    return (p and #p or 0) / 2
end

function PastureHerding:tickPathRecorder()
    if not self._pathRecording then return end
    if self._buildingPath == nil then
        -- Build target was cleared mid-record; stop.
        self._pathRecording = false
        PastureHerdingLog.info("phPathRecord: stopped (build target cleared)")
        return
    end
    local x, _, z = getRecordingWorldPos()
    if x == nil then return end

    if self._pathRecordLastX == nil then
        -- First sample: record immediately.
        self:_addBuildWaypoint(self._buildingPath, x, z)
        self._pathRecordLastX, self._pathRecordLastZ = x, z
        return
    end

    local dx, dz = x - self._pathRecordLastX, z - self._pathRecordLastZ
    local d2 = dx * dx + dz * dz
    local spacing = self._pathSampleSpacing or 3.0
    if d2 >= spacing * spacing then
        self:_addBuildWaypoint(self._buildingPath, x, z)
        self._pathRecordLastX, self._pathRecordLastZ = x, z
    end
end

function PastureHerding:consolePathRecord()
    if self._buildingPath == nil then
        return "PastureHerding: run phPathStart <pastureIdx> (or phPathStartPair <from> <to>) first so I know which path the recording belongs to"
    end
    local bp = self._buildingPath
    self._pathRecording = not self._pathRecording
    local what = bp.fromIdx ~= nil
        and string.format("pasture#%d->pasture#%d", bp.fromIdx, bp.pastureIdx)
        or string.format("pasture#%d", bp.pastureIdx)
    if self._pathRecording then
        self._pathRecordLastX, self._pathRecordLastZ = nil, nil
        return string.format("PastureHerding: RECORDING %s path for barn#%d. Walk or drive the route. Run phPathRecord again to stop.",
            what, bp.barnIdx)
    else
        return string.format("PastureHerding: stopped recording. Path has %d waypoints.",
            self:_buildPathLen(bp))
    end
end

function PastureHerding:consoleRegisterMaxDistance(arg)
    local n = tonumber(arg)
    if n == nil or n <= 0 then
        return string.format("Usage: phRegisterMaxDistance <meters>  (current: %.0fm)",
            self.registerMaxDistanceM or 10)
    end
    self.registerMaxDistanceM = n
    return string.format("PastureHerding: must now be within %.0fm of a barn/pasture to register it", n)
end

function PastureHerding:consolePathSampleEvery(arg)
    local n = tonumber(arg)
    if n == nil or n <= 0 then return "Usage: phPathSampleEvery <meters>" end
    self._pathSampleSpacing = n
    return string.format("PastureHerding: recorder samples every %.1f m", n)
end

function PastureHerding:consolePathStart(pastureIdxArg, barnIdxArg)
    local pIdx = tonumber(pastureIdxArg)
    if pIdx == nil then return "Usage: phPathStart <pastureIdx> [barnIdx]" end
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local barn = self.grazingRegistry:getBarn(bIdx)
    if barn == nil then return string.format("PastureHerding: no such barn#%d", bIdx) end
    if barn.pastures[pIdx] == nil then
        return string.format("PastureHerding: barn#%d has no pasture#%d", bIdx, pIdx)
    end
    self._buildingPath = { barnIdx = bIdx, pastureIdx = pIdx }
    return string.format("PastureHerding: building path for barn#%d pasture#%d. All path commands (Add/Clear/Reverse) now target this pasture until you run phPathFinish.",
        bIdx, pIdx)
end

-- Build a pasture->pasture path so a herd already out can walk straight from one
-- pasture to another. Records fromIdx->toIdx; the reverse walk auto-reverses it.
function PastureHerding:consolePathStartPair(fromArg, toArg, barnIdxArg)
    local fromIdx = tonumber(fromArg)
    local toIdx = tonumber(toArg)
    if fromIdx == nil or toIdx == nil then
        return "Usage: phPathStartPair <fromPastureIdx> <toPastureIdx> [barnIdx]"
    end
    if fromIdx == toIdx then return "PastureHerding: from and to must be different pastures" end
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local barn = self.grazingRegistry:getBarn(bIdx)
    if barn == nil then return string.format("PastureHerding: no such barn#%d", bIdx) end
    if barn.pastures[fromIdx] == nil then
        return string.format("PastureHerding: barn#%d has no pasture#%d (from)", bIdx, fromIdx)
    end
    if barn.pastures[toIdx] == nil then
        return string.format("PastureHerding: barn#%d has no pasture#%d (to)", bIdx, toIdx)
    end
    self._buildingPath = { barnIdx = bIdx, pastureIdx = toIdx, fromIdx = fromIdx }
    return string.format("PastureHerding: building pasture#%d->pasture#%d path for barn#%d. Run phPathRecord and walk/drive from pasture #%d to pasture #%d, then phPathRecord again to stop. phPathFinish when done.",
        fromIdx, toIdx, bIdx, fromIdx, toIdx)
end

-- Send the herd that is CURRENTLY OUT straight to another pasture along a
-- recorded pasture->pasture path (auto-reversed if recorded the other way).
function PastureHerding:consoleMovePasture(toArg, barnIdxArg)
    local toIdx = tonumber(toArg)
    if toIdx == nil then return "Usage: phMovePasture <toPastureIdx> [barnIdx]" end
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local barn = self.grazingRegistry:getBarn(bIdx)
    if barn == nil then return string.format("PastureHerding: no such barn#%d", bIdx) end
    if self.grazingScheduler == nil then return "PastureHerding: scheduler not ready" end
    -- Sync to reality first so activeIndex reflects any RLRM/manual move before
    -- we read where the herd currently is.
    self.grazingScheduler:reconcileStateFromReality(bIdx)
    local fromIdx = barn.activeIndex
    local ok, msg = self.grazingScheduler:walkPastureToPasture(bIdx, fromIdx, toIdx, "manual")
    if ok then
        return string.format("PastureHerding: barn#%d herd walking pasture #%d -> #%d", bIdx, fromIdx, toIdx)
    end
    return "PastureHerding: cannot move — " .. tostring(msg)
end

function PastureHerding:consolePathFinish()
    if self._buildingPath == nil then
        return "PastureHerding: not currently building a path"
    end
    local b, p = self._buildingPath.barnIdx, self._buildingPath.pastureIdx
    self._buildingPath = nil
    return string.format("PastureHerding: finished building path for barn#%d pasture#%d", b, p)
end

-- ── Menu-driven walk-path recording (barn -> pasture) ────────────────────────
-- Thin wrappers over the same _buildingPath / _pathRecording machinery the
-- phPath* console commands drive, so the map menu's "Record Walk Path" flow and
-- the console stay in sync. tickPathRecorder() samples the player every ~3 m
-- (on foot or in a vehicle) while _pathRecording is on.
function PastureHerding:beginPathRecording(barnIdx, pastureIdx)
    local barn = self.grazingRegistry and self.grazingRegistry:getBarn(barnIdx)
    if barn == nil or barn.pastures[pastureIdx] == nil then return false end
    -- A fresh recording REPLACES any previous path for this barn's pasture.
    self.grazingRegistry:clearPath(barnIdx, pastureIdx)
    self._buildingPath = { barnIdx = barnIdx, pastureIdx = pastureIdx }
    self._pathRecording = true
    self._pathRecordLastX, self._pathRecordLastZ = nil, nil
    PastureHerdingLog.info("PastureHerding: begin recording path barn#%d pasture#%d", barnIdx, pastureIdx)
    return true
end

-- Pasture -> pasture (fromIdx -> toIdx) path within a barn. The reverse walk
-- auto-reverses it, same as the console phPathStartPair flow.
function PastureHerding:beginPathRecordingPair(barnIdx, fromIdx, toIdx)
    local barn = self.grazingRegistry and self.grazingRegistry:getBarn(barnIdx)
    if barn == nil or fromIdx == toIdx then return false end
    if barn.pastures[fromIdx] == nil or barn.pastures[toIdx] == nil then return false end
    self.grazingRegistry:clearPairPath(barnIdx, fromIdx, toIdx)
    self._buildingPath = { barnIdx = barnIdx, pastureIdx = toIdx, fromIdx = fromIdx }
    self._pathRecording = true
    self._pathRecordLastX, self._pathRecordLastZ = nil, nil
    PastureHerdingLog.info("PastureHerding: begin recording pasture path barn#%d %d->%d", barnIdx, fromIdx, toIdx)
    return true
end

function PastureHerding:finishPathRecording()
    local bp = self._buildingPath
    self._pathRecording = false
    self._buildingPath = nil
    if bp == nil then return 0 end
    local n = self:_buildPathLen(bp)
    PastureHerdingLog.info("PastureHerding: finished recording path barn#%d (%d waypoints)", bp.barnIdx, n)
    return n
end

function PastureHerding:cancelPathRecording()
    local bp = self._buildingPath
    self._pathRecording = false
    if bp ~= nil and self.grazingRegistry ~= nil then
        if bp.fromIdx ~= nil then
            self.grazingRegistry:clearPairPath(bp.barnIdx, bp.fromIdx, bp.pastureIdx)
        else
            self.grazingRegistry:clearPath(bp.barnIdx, bp.pastureIdx)
        end
    end
    self._buildingPath = nil
end

function PastureHerding:isRecordingPath()
    return self._pathRecording == true
end

-- {barnIdx, pastureIdx} currently being recorded, or nil.
function PastureHerding:getRecordingTarget()
    if not self._pathRecording then return nil end
    return self._buildingPath
end

function PastureHerding:getRecordingWaypointCount()
    local bp = self._buildingPath
    if bp == nil or self.grazingRegistry == nil then return 0 end
    return self:_buildPathLen(bp)
end

function PastureHerding:consoleSprayDebug()
    if SprayerHook == nil or type(SprayerHook.debugDump) ~= "function" then
        return "PastureHerding: SprayerHook not loaded"
    end
    return SprayerHook.debugDump()
end

function PastureHerding:consoleSprayCoverDebug()
    self.debugSprayCoverage = not self.debugSprayCoverage
    return string.format("PastureHerding: spray coverage overlay = %s",
        self.debugSprayCoverage and "ON" or "OFF")
end

-- Returns the in-game days a fresh fertilizer boost should last. Reads the
-- mission's days-per-period so the boost scales to the player's calendar
-- setting (3, 7, 14, ... days = 1 month). Falls back to fertilizerBoostDays
-- if the environment isn't available yet.
function PastureHerding:getFertilizerBoostDays()
    local env = g_currentMission and g_currentMission.environment
    if env ~= nil and type(env.daysPerPeriod) == "number" and env.daysPerPeriod > 0 then
        return env.daysPerPeriod
    end
    return self.fertilizerBoostDays or 7
end

function PastureHerding:consoleFertilize(daysArg)
    local bIdx, pIdx, ctxOrErr = resolvePathTarget(self)
    if bIdx == nil then return "PastureHerding: " .. tostring(ctxOrErr) end
    local days = tonumber(daysArg) or self:getFertilizerBoostDays()
    local ok, expireDay = self.grazingRegistry:applyFertilizer(bIdx, pIdx, days)
    if not ok then return "PastureHerding: applyFertilizer failed (no in-game day yet?)" end
    return string.format("PastureHerding: fertilized barn#%d pasture#%d for %d days (expires day %d) %s",
        bIdx, pIdx, days, expireDay or -1, ctxOrErr)
end

function PastureHerding:consolePathAdd()
    local bp = self._buildingPath
    if bp ~= nil and bp.fromIdx ~= nil then
        local px, _, pz = getPlayerWorldPos()
        local n, err = self.grazingRegistry:addPairWaypoint(bp.barnIdx, bp.fromIdx, bp.pastureIdx, px, pz)
        if n == nil then return "PastureHerding: " .. tostring(err) end
        return string.format("PastureHerding: added waypoint #%d to barn#%d pasture#%d->pasture#%d path (currently building)",
            n, bp.barnIdx, bp.fromIdx, bp.pastureIdx)
    end
    local bIdx, pIdx, ctxOrErr = resolvePathTarget(self)
    if bIdx == nil then return "PastureHerding: " .. tostring(ctxOrErr) end
    local px, _, pz = getPlayerWorldPos()
    local n, err = self.grazingRegistry:addWaypoint(bIdx, pIdx, px, pz)
    if n == nil then return "PastureHerding: " .. tostring(err) end
    return string.format("PastureHerding: added waypoint #%d to barn#%d pasture#%d path %s",
        n, bIdx, pIdx, ctxOrErr)
end

function PastureHerding:consolePathClear()
    local bp = self._buildingPath
    if bp ~= nil and bp.fromIdx ~= nil then
        if self.grazingRegistry:clearPairPath(bp.barnIdx, bp.fromIdx, bp.pastureIdx) then
            return string.format("PastureHerding: cleared pasture#%d->pasture#%d path for barn#%d",
                bp.fromIdx, bp.pastureIdx, bp.barnIdx)
        end
        return "PastureHerding: no pasture-to-pasture path to clear"
    end
    local bIdx, pIdx, ctxOrErr = resolvePathTarget(self)
    if bIdx == nil then return "PastureHerding: " .. tostring(ctxOrErr) end
    if self.grazingRegistry:clearPath(bIdx, pIdx) then
        return string.format("PastureHerding: cleared path for barn#%d pasture#%d %s", bIdx, pIdx, ctxOrErr)
    end
    return "PastureHerding: no path to clear"
end

function PastureHerding:consolePathReverse()
    local bp = self._buildingPath
    if bp ~= nil and bp.fromIdx ~= nil then
        return "PastureHerding: pasture-to-pasture paths auto-reverse for the return trip; nothing to reverse"
    end
    local bIdx, pIdx, ctxOrErr = resolvePathTarget(self)
    if bIdx == nil then return "PastureHerding: " .. tostring(ctxOrErr) end
    if self.grazingRegistry:reversePath(bIdx, pIdx) then
        return string.format("PastureHerding: reversed path for barn#%d pasture#%d %s", bIdx, pIdx, ctxOrErr)
    end
    return "PastureHerding: path too short to reverse"
end

-- Gate test command: find the nearest gate AnimatedObject and toggle it (shares
-- the GateController adapter that herd walks use). Confirms open/close alone.
-- Usage: phTestGate [open|close]  (no arg = toggle by current anim time).
function PastureHerding:consoleTestGate(action)
    if rawget(_G, "GateController") == nil then return "PastureHerding: GateController not loaded" end
    local px, _, pz = getRecordingWorldPos()
    if px == nil then return "PastureHerding: no player position" end
    local gates = GateController.enumerateNear(px, pz, 15)
    if #gates == 0 then
        return "PastureHerding: no gate/animated-object within 15m -- stand closer to the gate"
    end
    local g = gates[1]
    local ao = g.ao
    local open
    if action == "open" then open = true
    elseif action == "close" then open = false
    else
        local t = nil
        if type(ao.getAnimTime) == "function" then
            local ok, v = pcall(function() return ao:getAnimTime() end)
            if ok then t = v end
        end
        -- Toggle by current anim time: open if currently closed (t < 0.5),
        -- close if currently open. (The old `a and b or c` form always yielded
        -- true, so a bare phTestGate could never close a gate.)
        open = not (type(t) == "number" and t >= 0.5)
    end
    local method = GateController.drive(ao, open)
    local nm = tostring(ao.saveId)
    if method == nil then
        return string.format("PastureHerding: found gate '%s' (%.1fm) but none of setDirection/setState/setAnimTime worked", nm, g.d)
    end
    return string.format("PastureHerding: %s gate '%s' (%.1fm) via %s()", open and "OPENED" or "CLOSED", nm, g.d, method)
end

function PastureHerding:consoleGateAutoOpen(modeArg)
    local m = tostring(modeArg or ""):lower()
    if m == "all" or m == "on" or m == "true" or m == "1" then
        self.gateAutoMode = "all"
    elseif m == "herdsman" or m == "auto" then
        self.gateAutoMode = "herdsman"
    elseif m == "off" or m == "none" or m == "false" or m == "0" then
        self.gateAutoMode = "off"
    else
        return string.format("Usage: phGateAutoOpen all|herdsman|off  (currently %s)",
            tostring(self.gateAutoMode))
    end
    return string.format("PastureHerding: herd gate auto-open = %s", tostring(self.gateAutoMode))
end
function PastureHerding:consoleHLProbe()
    local results = {}
    local am = rawget(_G, "g_animalManager") or g_animalManager
    if am == nil then
        results[#results + 1] = "g_animalManager = nil (Herding Lite not loaded OR sandboxed away from us)"
    else
        results[#results + 1] = string.format("g_animalManager = %s (type=%s)", tostring(am), type(am))
        local fns = {
            "createHerdableAnimalFromData",
            "getVisualAnimalFromCache",
            "addAnimalTypeToCache",
        }
        for _, fn in ipairs(fns) do
            results[#results + 1] = string.format("  %s: %s", fn, type(am[fn]))
        end
    end
    local ha = rawget(_G, "HerdableAnimal") or HerdableAnimal
    results[#results + 1] = string.format("HerdableAnimal class: %s", type(ha))
    local mod = g_modIsLoaded and g_modIsLoaded["FS25_AnimalHerdingLite"]
    results[#results + 1] = string.format("g_modIsLoaded[FS25_AnimalHerdingLite] = %s", tostring(mod))
    PastureHerdingLog.info("phHLProbe:\n  %s", table.concat(results, "\n  "))
    return "PastureHerding: HL probe written to log"
end

function PastureHerding:consoleTestWalk()
    if self.grazingRegistry == nil then return "PastureHerding: registry missing" end
    local bIdx = self._lastBarnIdx or 1
    local barn = self.grazingRegistry:getBarn(bIdx)
    if barn == nil then return string.format("PastureHerding: no such barn#%d", bIdx) end
    if barn.activeIndex == 0 then return "PastureHerding: barn has no active pasture" end
    local pIdx = barn.activeIndex
    local pasture = barn.pastures[pIdx]
    local waypoints = self.grazingRegistry:getPath(bIdx, pIdx)
    if waypoints == nil or #waypoints < 2 then
        return "PastureHerding: active pasture has no waypoints; add some with phPathAdd first"
    end

    -- Build [barn root, ...waypoints, pasture root] as the walker's path
    local pathPoints = {}
    if barn.placeable.rootNode and barn.placeable.rootNode ~= 0 then
        local bx, _, bz = getWorldTranslation(barn.placeable.rootNode)
        pathPoints[#pathPoints + 1] = { x = bx, z = bz }
    end
    for i = 1, #waypoints, 2 do
        pathPoints[#pathPoints + 1] = { x = waypoints[i], z = waypoints[i + 1] }
    end
    if pasture.rootNode and pasture.rootNode ~= 0 then
        local pxw, _, pzw = getWorldTranslation(pasture.rootNode)
        pathPoints[#pathPoints + 1] = { x = pxw, z = pzw }
    end

    local animals = Walker.collectAnimalsWithVisuals(barn.placeable)
    if #animals == 0 then return "PastureHerding: no animals with visuals in barn" end
    local am = rawget(_G, "g_animalManager") or g_animalManager
    PastureHerdingLog.info("consoleTestWalk: g_animalManager resolved to %s (type=%s)",
        tostring(am), type(am))
    local walker = Walker.new(barn.placeable, pathPoints, Walker.DEFAULT_SPEED, animals[1], am)
    if walker == nil then
        return "PastureHerding: walker could not spawn (see log)"
    end
    self._activeWalkers[#self._activeWalkers + 1] = walker
    return string.format("PastureHerding: spawned test walker on barn#%d -> pasture#%d (%d path points)",
        bIdx, pIdx, #pathPoints)
end

function PastureHerding:consoleTestWalkMany(countArg)
    local n = tonumber(countArg)
    if n == nil or n < 1 then return "Usage: phTestWalkMany <count>" end
    if self.grazingRegistry == nil then return "PastureHerding: registry missing" end
    local bIdx = self._lastBarnIdx or 1
    local barn = self.grazingRegistry:getBarn(bIdx)
    if barn == nil then return string.format("PastureHerding: no such barn#%d", bIdx) end
    if barn.activeIndex == 0 then return "PastureHerding: barn has no active pasture" end
    local pIdx = barn.activeIndex
    local pasture = barn.pastures[pIdx]
    local waypoints = self.grazingRegistry:getPath(bIdx, pIdx)
    if waypoints == nil or #waypoints < 2 then
        return "PastureHerding: active pasture has no waypoints; add some with phPathAdd first"
    end

    local pathPoints = {}
    if barn.placeable.rootNode and barn.placeable.rootNode ~= 0 then
        local bx, _, bz = getWorldTranslation(barn.placeable.rootNode)
        pathPoints[#pathPoints + 1] = { x = bx, z = bz }
    end
    for i = 1, #waypoints, 2 do
        pathPoints[#pathPoints + 1] = { x = waypoints[i], z = waypoints[i + 1] }
    end
    if pasture.rootNode and pasture.rootNode ~= 0 then
        local pxw, _, pzw = getWorldTranslation(pasture.rootNode)
        pathPoints[#pathPoints + 1] = { x = pxw, z = pzw }
    end

    local allAnimals = Walker.collectAnimalsWithVisuals(barn.placeable)
    if #allAnimals == 0 then
        return "PastureHerding: no animals with visuals in barn"
    end
    local picked = {}
    for i = 1, math.min(n, #allAnimals) do picked[#picked + 1] = allAnimals[i] end
    if #picked < n then
        PastureHerdingLog.info("phTestWalkMany: requested %d but barn only has %d visible animals; using %d",
            n, #allAnimals, #picked)
    end

    local am = rawget(_G, "g_animalManager") or g_animalManager
    local session = WalkSession.new(barn.placeable, pasture, pathPoints, picked, {
        label = string.format("test-%d", #picked),
        animalManager = am,
    })
    if session == nil then
        return "PastureHerding: WalkSession could not start (see log)"
    end
    self._activeWalkSessions[#self._activeWalkSessions + 1] = session
    return string.format("PastureHerding: started WalkSession of %d walkers on barn#%d -> pasture#%d", n, bIdx, pIdx)
end

function PastureHerding:consolePathShow()
    self.debugPaths = not self.debugPaths
    return string.format("PastureHerding: path debug overlay %s",
        self.debugPaths and "ON (yellow lines)" or "OFF")
end

-- Draw the path currently being recorded in bright green so the player can
-- see what they've captured so far as they walk / drive the route. Only
-- active while self._pathRecording is true and there's a build target.
function PastureHerding:drawRecordingPath()
    if drawDebugLine == nil or self.grazingRegistry == nil then return end
    local bp = self._buildingPath
    if bp == nil then return end
    local path
    if bp.fromIdx ~= nil then
        path = self.grazingRegistry:getPairPath(bp.barnIdx, bp.fromIdx, bp.pastureIdx)
    else
        path = self.grazingRegistry:getPath(bp.barnIdx, bp.pastureIdx)
    end
    if path == nil or #path < 2 then return end
    local r, g, b = 0.1, 1.0, 0.2  -- bright green, visually distinct from
                                   -- the amber debug overlay
    local yLift = 0.5
    local terrainId = g_currentMission and g_currentMission.terrainRootNode or 0
    local segments = {}
    for i = 1, #path, 2 do
        local x, z = path[i], path[i + 1]
        local y = 0
        if getTerrainHeightAtWorldPos ~= nil then
            y = getTerrainHeightAtWorldPos(terrainId, x, 0, z) or 0
        end
        segments[#segments + 1] = { x = x, y = y + yLift, z = z }
    end
    for i = 1, #segments - 1 do
        local a, b1 = segments[i], segments[i + 1]
        drawDebugLine(a.x, a.y, a.z, r, g, b, b1.x, b1.y, b1.z, r, g, b)
    end
end

-- Lazily create a plain fillable rectangle overlay (base UI atlas pointed at the
-- solid colorPreset slice). Sentinel 0 = "tried, unavailable" so we don't retry.
function PastureHerding:_ensureRecOverlay()
    if self._recOverlay ~= nil then return end
    self._recOverlay = 0
    if createImageOverlay == nil or g_baseUIFilename == nil then return end
    local ov = createImageOverlay(g_baseUIFilename)
    if ov == nil or ov == 0 then return end
    if g_overlayManager ~= nil and type(g_overlayManager.getSliceInfoById) == "function"
       and setOverlayUVs ~= nil then
        local slice = g_overlayManager:getSliceInfoById("gui.colorPreset")
        if slice ~= nil and slice.uvs ~= nil then
            pcall(function() setOverlayUVs(ov, unpack(slice.uvs)) end)
        end
    end
    self._recOverlay = ov
end

-- Flashing on-screen warning shown the whole time a walk path is being recorded,
-- so the player knows they're in "recording" mode and how to finish. Runs from
-- the HUD draw hook (never call directly from update — renderText is draw-only).
function PastureHerding:drawRecordingHud()
    if renderText == nil then return end
    local ALIGN_CENTER = (RenderText and RenderText.ALIGN_CENTER) or 1
    local ALIGN_LEFT = (RenderText and RenderText.ALIGN_LEFT) or 0
    local time = (g_currentMission and g_currentMission.time) or 0
    local pulse = 0.5 + 0.5 * math.abs(math.sin(time / 1000 * 4.0))

    local cx = 0.5
    local top = 0.955
    local boxW, boxH = 0.46, 0.088
    local left = cx - boxW * 0.5
    local bottom = top - boxH

    -- Flashing red warning box (best-effort; degrades to text-only if the overlay
    -- primitives aren't in scope).
    self:_ensureRecOverlay()
    local ov = self._recOverlay
    if ov ~= nil and ov ~= 0 and renderOverlay ~= nil and setOverlayColor ~= nil then
        setOverlayColor(ov, 0.10, 0.0, 0.0, 0.60)
        renderOverlay(ov, left, bottom, boxW, boxH)
        local a = 0.35 + 0.65 * pulse
        setOverlayColor(ov, 0.95, 0.10, 0.10, a)
        renderOverlay(ov, left, top - 0.006, boxW, 0.006)       -- top bar
        renderOverlay(ov, left, bottom, boxW, 0.006)            -- bottom bar
        setOverlayColor(ov, 1, 1, 1, 1)
    end

    -- Resolve the from/to display names for the instruction line. For a
    -- pasture->pasture path (fromIdx set) the start is a pasture; otherwise it's
    -- the barn.
    local bp = self._buildingPath
    local reg = self.grazingRegistry
    local barn = bp ~= nil and reg ~= nil and reg:getBarn(bp.barnIdx) or nil
    local function nameOfPasture(idx)
        local p = barn and barn.pastures[idx]
        if p == nil then return "the pasture" end
        local cn = type(reg.getCustomName) == "function" and reg:getCustomName(p) or nil
        if type(cn) == "string" and cn ~= "" then return cn end
        if type(p.getName) == "function" then
            local ok, nm = pcall(function() return p:getName() end)
            if ok and type(nm) == "string" and nm ~= "" then return nm end
        end
        return "the pasture"
    end
    local toName = bp and nameOfPasture(bp.pastureIdx) or "the pasture"
    local fromName = (bp and bp.fromIdx ~= nil) and nameOfPasture(bp.fromIdx) or "the barn"
    local n = self:getRecordingWaypointCount()

    setTextBold(true)
    setTextAlignment(ALIGN_CENTER)
    setTextColor(1.0, 0.25 + 0.35 * pulse, 0.25 + 0.35 * pulse, 1.0)
    renderText(cx, top - 0.030, 0.020, string.format(">> RECORDING WALK PATH   (%d pts)", n))
    setTextBold(false)
    setTextColor(0.95, 0.95, 0.95, 1.0)
    renderText(cx, top - 0.058, 0.0135,
        string.format("Walk from %s to %s, then open the menu and choose Finish Path.", fromName, toName))
    -- Restore default render state so nothing leaks into other HUD draws.
    setTextAlignment(ALIGN_LEFT)
    setTextColor(1, 1, 1, 1)
    setTextBold(false)
end

-- Distinct debug-overlay colours. Barn->pasture (home) routes use a WARM palette
-- keyed by barn; pasture->pasture routes use a COOL palette keyed by barn. So the
-- overlay tells you BOTH which barn a path belongs to AND whether it's a home
-- route (warm) or a pasture-to-pasture route (cool).
PastureHerding.BARN_PATH_COLORS = {
    {1.00, 0.85, 0.00},  -- amber
    {1.00, 0.45, 0.10},  -- orange
    {1.00, 0.20, 0.20},  -- red
    {0.95, 0.70, 0.25},  -- gold
    {1.00, 0.55, 0.80},  -- pink
}
PastureHerding.PAIR_PATH_COLORS = {
    {0.15, 0.90, 1.00},  -- cyan
    {0.30, 0.55, 1.00},  -- blue
    {0.70, 0.35, 1.00},  -- purple
    {0.20, 1.00, 0.65},  -- teal-green
    {0.55, 0.80, 1.00},  -- light blue
}

local function pickPathColor(palette, idx)
    if type(idx) ~= "number" or idx < 1 then idx = 1 end
    return palette[((idx - 1) % #palette) + 1]
end

function PastureHerding:drawDebugPaths()
    if drawDebugLine == nil or self.grazingRegistry == nil then return end
    local yLift = 0.4
    local terrainId = g_currentMission and g_currentMission.terrainRootNode or 0
    -- Draw a flat {x,z,...} waypoint list as connected 3D line segments, lifted
    -- above the terrain so it reads over grass. Draws ONLY the player-defined
    -- waypoints — no implicit barn-center / pasture-center anchors.
    local function drawFlatPath(path, col)
        if type(path) ~= "table" or #path < 4 then return end
        local r, g, b = col[1], col[2], col[3]
        local prevX, prevY, prevZ
        for i = 1, #path, 2 do
            local x, z = path[i], path[i + 1]
            local y = 0
            if getTerrainHeightAtWorldPos ~= nil then
                y = (getTerrainHeightAtWorldPos(terrainId, x, 0, z) or 0) + yLift
            end
            if prevX ~= nil then
                drawDebugLine(prevX, prevY, prevZ, r, g, b, x, y, z, r, g, b)
            end
            prevX, prevY, prevZ = x, y, z
        end
    end
    for bIdx, barn in self.grazingRegistry:iterBarns() do
        -- Barn -> pasture (home) routes, warm colour for this barn.
        local barnCol = pickPathColor(self.BARN_PATH_COLORS, bIdx)
        for pIdx, _pasture in ipairs(barn.pastures) do
            drawFlatPath(self.grazingRegistry:getPath(bIdx, pIdx), barnCol)
        end
        -- Pasture -> pasture routes recorded under this barn, cool colour.
        local pairCol = pickPathColor(self.PAIR_PATH_COLORS, bIdx)
        if type(barn.pasturePaths) == "table" then
            for _, e in ipairs(barn.pasturePaths) do
                drawFlatPath(e.path, pairCol)
            end
        end
    end
end

function PastureHerding:consoleAutoRotate(stateArg, barnIdxArg)
    local s = tostring(stateArg or ""):lower()
    if s ~= "on" and s ~= "off" then return "Usage: phAutoRotate on|off [barnIdx]" end
    local bIdx = tonumber(barnIdxArg) or self._lastBarnIdx or 1
    local ok, err = self.grazingRegistry:setAutoRotate(bIdx, s == "on")
    if not ok then return "PastureHerding: " .. tostring(err) end
    return string.format("PastureHerding: barn#%d autoRotate %s", bIdx, s)
end

function PastureHerding:consoleUnregisterGrazing()
    local px, _, pz = getPlayerWorldPos()
    if px == nil then return "PastureHerding: cannot find player position" end
    local placeable, dist = findNearestHusbandryWithFilter(px, pz)
    if placeable == nil then return "PastureHerding: no husbandry placeables found" end
    if self.grazingRegistry:unregister(placeable) then
        return string.format("PastureHerding: unregistered placeable at %.1fm from grazing registry", dist)
    end
    return "PastureHerding: nearest placeable wasn't registered"
end
