-- Grazing tab — three-pane layout modeled on vanilla's
-- InGameMenuProductionFrame:
--   Left list  (barnsList)     : every registered barn
--   Center list (pasturesList) : pastures owned by the left-selected barn
--   Right panel (detailsContainer): info + actions for the center-selected pasture

PHMenuGrazingFrame = {}
local PHMenuGrazingFrame_mt = Class(PHMenuGrazingFrame, TabbedMenuFrameElement)

local modDirectory = g_currentModDirectory

-- Detail tooltip lookup + word-wrap helper. Declared up-here (before any
-- method bodies) so closures captured below resolve them as locals — Lua
-- locals are invisible to code that lexically precedes their declaration.
local DETAIL_TOOLTIP_KEYS = {
    detail_sel_auto_sched = "ph_detail_auto_sched_desc",
    detail_sel_pump       = "ph_detail_pump_desc",
    detail_sel_sunrise    = "ph_detail_sunrise_desc",
    detail_sel_sunset     = "ph_detail_sunset_desc",
    detail_sel_mode       = "ph_detail_mode_desc",
    detail_sel_capacity   = "ph_detail_capacity_desc",
    detail_sel_autowater  = "ph_detail_autowater_desc",
    detail_sel_autorotate = "ph_detail_autorotate_desc",
}

-- Picker values for the pasture-capacity row. State 1 = "Default" (the
-- pasture's own built-in size) — we use 0 as the sentinel rather than nil
-- because Lua's `#` operator and `ipairs` both stop at the first nil, which
-- would silently zero out both setTexts (only 1 label gets sent) and the sync
-- state-lookup loop (state always resets to 1 on menu re-open). State->value
-- translation maps 0 -> nil at the registry boundary.
local DETAIL_CAPACITY_VALUES = { 0, 10, 15, 20, 25, 30, 40, 50, 75, 100, 150, 200, 250, 300 }

-- Module-level cache. Three mods (RLM + RedTape + EasyDevControls) chain
-- their I18N.getText wrappers; the chain grows each time a save is reloaded
-- because their wrappers stack rather than replace. After enough reloads any
-- single getText call exhausts the Lua stack. We resolve every label we use
-- exactly once per game session via this cache and never re-call g_i18n
-- after that — most of the menu's translation pressure goes away.
local TRANSLATED = {}
local function tr(key)
    local cached = TRANSLATED[key]
    if cached ~= nil then return cached end
    local ok, value = pcall(function() return g_i18n:getText(key) end)
    cached = (ok and value) or key
    TRANSLATED[key] = cached
    return cached
end

local function wrapText(s, maxChars)
    if s == nil or s == "" then return "" end
    maxChars = maxChars or 60
    local lines, line = {}, ""
    for word in string.gmatch(s, "%S+") do
        if #line == 0 then
            line = word
        elseif (#line + 1 + #word) <= maxChars then
            line = line .. " " .. word
        else
            lines[#lines + 1] = line
            line = word
        end
    end
    if #line > 0 then lines[#lines + 1] = line end
    return table.concat(lines, "\n")
end

local function placeableName(p)
    if p == nil then return "?" end
    -- Prefer a player-assigned name (Map tab's Rename option) so barn/pasture
    -- names stay in sync between the Map tab and this Grazing tab.
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    if reg ~= nil and type(reg.getCustomName) == "function" then
        local ok, cn = pcall(function() return reg:getCustomName(p) end)
        if ok and type(cn) == "string" and cn ~= "" then return cn end
    end
    if type(p.getName) == "function" then
        local ok, name = pcall(function() return p:getName() end)
        if ok and type(name) == "string" then return name end
    end
    return tostring(p)
end

local function countAnimalsAt(placeable)
    if placeable == nil then return 0 end
    local spec = placeable.spec_husbandryAnimals
    if spec == nil or spec.clusterSystem == nil then return 0 end
    local list = (spec.clusterSystem.getAnimals and spec.clusterSystem:getAnimals())
        or spec.clusterSystem.animals or {}
    local n = 0
    for _, a in pairs(list) do n = n + (a.numAnimals or 1) end
    return n
end

-- Delegate to GrazingScheduler.sumFoodInputAt — the authoritative shared
-- helper. Keeps menu and scheduler agreeing on the rate.
local function sumFoodInputAt(placeable)
    if GrazingScheduler ~= nil and type(GrazingScheduler.sumFoodInputAt) == "function" then
        return GrazingScheduler.sumFoodInputAt(placeable)
    end
    return 0
end

local function listBarns()
    local out = {}
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or f.grazingRegistry == nil then return out end
    for barnIdx, barn in f.grazingRegistry:iterBarns() do
        out[#out + 1] = { idx = barnIdx, barn = barn }
    end
    return out
end

function PHMenuGrazingFrame.new(target, mt)
    local self = PHMenuGrazingFrame:superClass().new(target, mt or PHMenuGrazingFrame_mt)
    self.name = "PHMenuGrazingFrame"
    self.hasCustomMenuButtons = true
    self.backButtonInfo = { inputAction = InputAction.MENU_BACK }
    self.menuButtonInfo = { self.backButtonInfo }
    self._barns = {}    -- array of { idx, barn }
    self._pastures = {} -- array of placeables for the currently-selected barn
    self.selectedBarnIdx = nil
    self.selectedPastureIdx = nil
    return self
end

function PHMenuGrazingFrame.setupGui()
    if g_gui == nil then return end
    local xmlPath = Utils.getFilename("gui/frames/PHMenuGrazingFrame.xml", modDirectory)
    g_gui:loadGui(xmlPath, "PHMenuGrazingFrame", PHMenuGrazingFrame.new(), true)
end

function PHMenuGrazingFrame:initialize() end

-- SmoothList row-count callbacks. The engine asks "how many rows do you have
-- in section N?" and our answer is the list length.
function PHMenuGrazingFrame:getNumberOfItemsInSection(list, section)
    if list == self.barnsList then return #self._barns end
    if list == self.pasturesList then return #self._pastures end
    return 0
end

function PHMenuGrazingFrame:getNumberOfSections(list)
    return 1
end

-- SmoothList cell-population callback. The engine hands us a ListItem clone
-- and the row index; we set its visible content from our data.
local function setCellIcon(cell, sliceId)
    local icon = cell:getAttribute("icon")
    if icon == nil then return end
    if type(icon.setImageSlice) == "function" then
        pcall(function() icon:setImageSlice(nil, sliceId) end)
    elseif type(icon.setImageUVs) == "function" and g_baseUIFilename ~= nil then
        -- Older API path: setImageFilename + setImageUVs from slice name
        if type(icon.setImageFilename) == "function" then
            icon:setImageFilename(g_baseUIFilename)
        end
    end
    -- Fallback: set via field; some FS25 versions read it on next draw.
    icon.imageSliceId = sliceId
end

function PHMenuGrazingFrame:populateCellForItemInSection(list, section, index, cell)
    if list == self.barnsList then
        local entry = self._barns[index]
        if entry == nil then return end
        local nameElem = cell:getAttribute("name")
        if nameElem and type(nameElem.setText) == "function" then
            nameElem:setText(placeableName(entry.barn.placeable))
        end
        setCellIcon(cell, "gui.icon_ingameMenu_animals")
        return
    end
    if list == self.pasturesList then
        local pasture = self._pastures[index]
        if pasture == nil then return end
        local f = rawget(_G, "g_pastureHerding")
        local renewing = f and f.grazingRegistry
            and type(f.grazingRegistry.isRenewing) == "function"
            and f.grazingRegistry:isRenewing(self.selectedBarnIdx, index) or false
        local nameElem = cell:getAttribute("name")
        if nameElem and type(nameElem.setText) == "function" then
            nameElem:setText(placeableName(pasture))
            -- Grey out a pasture that's mid-reseed (locked out of grazing).
            if type(nameElem.setTextColor) == "function" then
                if renewing then nameElem:setTextColor(0.5, 0.5, 0.5, 0.7)
                else nameElem:setTextColor(1, 1, 1, 1) end
            end
        end
        local activityElem = cell:getAttribute("activity")
        if activityElem and type(activityElem.setText) == "function" then
            local barn = self:_currentBarn()
            local activeIdx = barn and barn.activeIndex or 0
            if renewing then
                activityElem:setText("REGROWTH")
            else
                activityElem:setText(activeIdx == index and "ACTIVE" or "")
            end
        end
        setCellIcon(cell, "gui.icon_others_farmland")
        return
    end
end

function PHMenuGrazingFrame:_currentBarn()
    for _, entry in ipairs(self._barns) do
        if entry.idx == self.selectedBarnIdx then return entry.barn end
    end
    return nil
end

function PHMenuGrazingFrame:_reloadBarns()
    self._barns = listBarns()
    if self.selectedBarnIdx == nil and self._barns[1] ~= nil then
        self.selectedBarnIdx = self._barns[1].idx
    end
    if self.barnsList ~= nil and type(self.barnsList.reloadData) == "function" then
        self.barnsList:reloadData()
    end
end

function PHMenuGrazingFrame:_reloadPastures()
    local barn = self:_currentBarn()
    self._pastures = {}
    if barn ~= nil then
        for _, p in ipairs(barn.pastures) do self._pastures[#self._pastures + 1] = p end
    end
    -- Clear pasture selection so picking a barn doesn't implicitly pick a
    -- pasture too. The right pane stays empty until the user clicks a row
    -- in the center list.
    if self.selectedPastureIdx ~= nil and self.selectedPastureIdx > #self._pastures then
        self.selectedPastureIdx = nil
    end
    if self.pasturesList ~= nil then
        if type(self.pasturesList.reloadData) == "function" then
            self.pasturesList:reloadData()
        end
        -- Some FS25 SmoothList versions auto-select row 1 after reloadData;
        -- explicitly setSelectedItem(0) clears that so no row is highlighted.
        for _, m in ipairs({ "setSelectedItem", "setSelectedIndex", "deselectAll" }) do
            if type(self.pasturesList[m]) == "function" then
                pcall(function() self.pasturesList[m](self.pasturesList, 0) end)
            end
        end
    end
end

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

local function sumCapacities(spec)
    if spec == nil or type(spec.capacities) ~= "table" then return 0 end
    local t = 0
    for _, v in pairs(spec.capacities) do if type(v) == "number" then t = t + v end end
    return t
end

local function averageHealthAt(placeable)
    if placeable == nil then return nil end
    local spec = placeable.spec_husbandryAnimals
    if spec == nil or spec.clusterSystem == nil then return nil end
    local list = (spec.clusterSystem.getAnimals and spec.clusterSystem:getAnimals())
        or spec.clusterSystem.animals or {}
    local sum, n = 0, 0
    for _, a in pairs(list) do
        if a.health ~= nil then
            sum = sum + a.health * (a.numAnimals or 1)
            n = n + (a.numAnimals or 1)
        end
    end
    if n == 0 then return nil end
    return sum / n
end

-- Resize the inner ThreePartBitmap "bar" to a fraction of its background's
-- width. pct is 0..1; clamped here defensively.
-- Sync the right-pane status toggles to the selected barn's flags. Called
-- from _refreshDetails in both modes (barn-only and pasture-selected).
function PHMenuGrazingFrame:_syncDetailToggles()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    if self.detail_sel_auto_sched and type(self.detail_sel_auto_sched.setState) == "function" then
        local on = f.grazingRegistry:getAutoSchedule(self.selectedBarnIdx)
        self.detail_sel_auto_sched:setState(on and 2 or 1, true)
    end
    if self.detail_sel_pump and type(self.detail_sel_pump.setState) == "function" then
        local on = f.grazingRegistry:getTroughEnabled(self.selectedBarnIdx)
        self.detail_sel_pump:setState(on and 2 or 1, true)
    end
    -- Position 1 = Default, 2..25 = 00:00..23:00.
    if self.detail_sel_sunrise and type(self.detail_sel_sunrise.setState) == "function" then
        local h = f.grazingRegistry:getBarnSunrise(self.selectedBarnIdx)
        self.detail_sel_sunrise:setState(h == nil and 1 or (h + 2), true)
    end
    if self.detail_sel_sunset and type(self.detail_sel_sunset.setState) == "function" then
        local h = f.grazingRegistry:getBarnSunset(self.selectedBarnIdx)
        self.detail_sel_sunset:setState(h == nil and 1 or (h + 2), true)
    end
    -- Consolidated mode picker: 1=Dairy, 2=Beef-Grazing, 3=Beef-Finishing.
    -- One MultiTextOption replaces the separate herdType + beefPhase rows,
    -- because the two-row variant hit a TextElement recursion overflow when
    -- placed near the top of the BoxLayout in this FS25 build. Single row
    -- at the end of the layout is stable.
    -- Schedule picker: 1 = Day grazing, 2 = Full-time grazing, 3 = Housed.
    if self.detail_sel_mode and type(self.detail_sel_mode.setState) == "function" then
        local mode = f.grazingRegistry:getScheduleMode(self.selectedBarnIdx)
        local state = (mode == "housed") and 3 or (mode == "continuous") and 2 or 1
        self.detail_sel_mode:setState(state, true)
    end
    if self.detail_sel_autorotate and type(self.detail_sel_autorotate.setState) == "function" then
        local on = f.grazingRegistry:getAutoRotate(self.selectedBarnIdx) == true
        self.detail_sel_autorotate:setState(on and 2 or 1, true)
    end
    -- The status pane has two row sets:
    --   Per-BARN settings (herd mode, schedule, pump, sunrise/sunset) —
    --     these change the whole barn, so showing them while a specific
    --     pasture is selected is misleading. Hide them in pasture mode.
    --   Per-PASTURE settings (capacity, auto-water) — only meaningful when
    --     a pasture is selected. Hide them in barn-only mode.
    local pastureSelected = self.selectedPastureIdx ~= nil
    local function setRowVisible(id, visible)
        local el = self[id] or (self.getDescendantById and self:getDescendantById(id))
        if el ~= nil and type(el.setVisible) == "function" then
            pcall(function() el:setVisible(visible) end)
        end
    end
    setRowVisible("row_detail_capacity",  pastureSelected)
    setRowVisible("row_detail_autowater", pastureSelected)
    setRowVisible("row_detail_auto_sched", not pastureSelected)
    setRowVisible("row_detail_pump",       not pastureSelected)
    setRowVisible("row_detail_sunrise",    not pastureSelected)
    setRowVisible("row_detail_sunset",     not pastureSelected)
    setRowVisible("row_detail_mode",       not pastureSelected)
    setRowVisible("row_detail_autorotate", not pastureSelected)
    -- BoxLayout doesn't compact when children are setVisible(false) — the
    -- hidden slots leave a gap. Force a re-layout on the parent so visible
    -- rows stack tight. Walk a known row up to its BoxLayout ancestor and
    -- invalidate.
    local anchor = self.row_detail_capacity
        or (self.getDescendantById and self:getDescendantById("row_detail_capacity"))
    local layout = anchor and anchor.parent
    if layout ~= nil and type(layout.invalidateLayout) == "function" then
        pcall(function() layout:invalidateLayout() end)
    end
    if pastureSelected and self.detail_sel_capacity
       and type(self.detail_sel_capacity.setState) == "function" then
        -- Refresh the "Default" label (state 1) to show THIS pasture's own
        -- built-in size, so the player sees the real cap that applies when no
        -- override is set (nil override => scheduler restores maxNumAnimals).
        if type(self.detail_sel_capacity.setTexts) == "function" then
            local placeable = self._pastures[self.selectedPastureIdx]
            local nativeCap = nil
            local spec = placeable and placeable.spec_husbandryAnimals
            if spec ~= nil then
                nativeCap = spec._phOriginalMaxNumAnimals or spec.maxNumAnimals
            end
            local first = tr("ph_detail_capacity_default")
            if type(nativeCap) == "number" then
                first = string.format("%s (%d)", first, nativeCap)
            end
            local capLabels = { first }
            for i = 2, #DETAIL_CAPACITY_VALUES do
                capLabels[#capLabels + 1] = string.format("%d cows", DETAIL_CAPACITY_VALUES[i])
            end
            self.detail_sel_capacity:setTexts(capLabels)
        end
        local cap = f.grazingRegistry:getPastureCapacity(self.selectedBarnIdx, self.selectedPastureIdx)
        local state = 1  -- default: pasture's own size (no override)
        if type(cap) == "number" then
            for i, v in ipairs(DETAIL_CAPACITY_VALUES) do
                if v == cap then state = i; break end
            end
        end
        self.detail_sel_capacity:setState(state, true)
    end
    if pastureSelected and self.detail_sel_autowater
       and type(self.detail_sel_autowater.setState) == "function" then
        -- State 1 = Default (nil), 2 = ON, 3 = OFF.
        local aw = f.grazingRegistry:getPastureAutoWater(self.selectedBarnIdx, self.selectedPastureIdx)
        local state = 1
        if aw == true then state = 2
        elseif aw == false then state = 3 end
        self.detail_sel_autowater:setState(state, true)
    end
end

function PHMenuGrazingFrame:_setBarsVisible(visible)
    for _, id in ipairs({ "detail_meadow_bg", "detail_meadow_bar",
                          "detail_water_bg",  "detail_water_bar",
                          "detail_food_bg",   "detail_food_bar",
                          "detail_quality_bg", "detail_quality_bar" }) do
        local el = self[id] or (self.getDescendantById and self:getDescendantById(id))
        if el ~= nil and type(el.setVisible) == "function" then
            pcall(function() el:setVisible(visible) end)
        end
    end
end

function PHMenuGrazingFrame:_setBarWidth(bgId, barId, pct)
    local bg = self[bgId] or (self.getDescendantById and self:getDescendantById(bgId))
    local bar = self[barId] or (self.getDescendantById and self:getDescendantById(barId))
    if bar == nil then return end
    pct = math.max(0, math.min(1, pct or 0))
    local bgWidth = (bg and bg.size and bg.size[1]) or (bg and bg.absSize and bg.absSize[1]) or 500
    local h = (bar.size and bar.size[2]) or (bar.absSize and bar.absSize[2]) or 8
    if type(bar.setSize) == "function" then
        pcall(function() bar:setSize(bgWidth * pct, h) end)
    end
end

function PHMenuGrazingFrame:_refreshDetails()
    local f = rawget(_G, "g_pastureHerding")
    -- Sync the scheduler's cached state against reality before we read it
    -- for display. Catches drift from manual cow-driving / RLM operations
    -- between scheduler ticks so the menu always reflects truth.
    if f and f.grazingScheduler and self.selectedBarnIdx
       and type(f.grazingScheduler.reconcileStateFromReality) == "function" then
        pcall(function() f.grazingScheduler:reconcileStateFromReality(self.selectedBarnIdx) end)
    end
    local barn = self:_currentBarn()
    local pasture = self.selectedPastureIdx and self._pastures[self.selectedPastureIdx] or nil
    local function setText(id, txt)
        local el = self[id] or (self.getDescendantById and self:getDescendantById(id))
        if el ~= nil and type(el.setText) == "function" then el:setText(txt) end
    end
    -- Hide/show a GUI element so it collapses out of the BoxLayout instead
    -- of leaving an empty row taking vertical space.
    local function setVis(id, visible)
        local el = self[id] or (self.getDescendantById and self:getDescendantById(id))
        if el == nil then return end
        if type(el.setVisible) == "function" then el:setVisible(visible) else el.visible = visible end
        if self.invalidateLayout ~= nil then self:invalidateLayout() end
    end
    -- Volume formatters — route fill amounts through the framework so they show
    -- kg/t when British Fill Units is active, litres otherwise. See
    -- PastureHerding:fmtVol. fillType is used for the BFU mass conversion.
    local function fmtVol(liters, ft)
        return (f and f:fmtVol(liters, ft)) or string.format("%.0f L", liters or 0)
    end
    local function fmtVolPair(fill, cap, ft)
        return (f and f:fmtVolPair(fill, cap, ft)) or string.format("%.0f / %.0f L", fill or 0, cap or 0)
    end
    local function fmtVolDay(litersPerDay, ft)
        return (f and f:fmtVolPerDay(litersPerDay, ft)) or string.format("%.0f L/day", litersPerDay or 0)
    end
    local function firstFillType(spec)
        return f and f.firstFillType and f.firstFillType(spec) or nil
    end
    -- Rows that exist only for the per-pasture detail view. Toggled per
    -- mode below so the layout doesn't leave gaps in barn-only or empty
    -- states.
    local PASTURE_ONLY_ROWS = {
        "detail_animals_sub", "detail_meadow_sub",
        "detail_fert", "detail_fert_bg", "detail_fert_sub",
    }
    local function showPastureOnlyRows(vis)
        for _, id in ipairs(PASTURE_ONLY_ROWS) do setVis(id, vis) end
    end

    -- Barn-only mode: no pasture selected but a barn is. Right pane shows
    -- barn-side stats.
    if pasture == nil and barn ~= nil then
        local bPlace = barn.placeable
        local foodSpec = bPlace and bPlace.spec_husbandryFood
        local waterSpec = bPlace and bPlace.spec_husbandryWater
        local foodFill = sumFillLevels(foodSpec)
        local foodCap = (foodSpec and foodSpec.capacity) or 0
        local foodPct = foodCap > 0 and (foodFill / foodCap) or 0
        local waterFill, waterCap = 0, 0
        if waterSpec ~= nil then
            -- pcall guards: husbandry fill-level lookups can transit through
            -- the placeable's UnloadingStation in some mod configurations
            -- (Courseplay etc. wrap the station's update path). A broken
            -- wrap there shouldn't crash the menu refresh.
            if type(bPlace.getHusbandryFillLevel) == "function" then
                local ok, v = pcall(function() return bPlace:getHusbandryFillLevel(waterSpec.fillType) end)
                if ok and type(v) == "number" then waterFill = v end
            end
            if type(bPlace.getHusbandryCapacity) == "function" then
                local ok, v = pcall(function() return bPlace:getHusbandryCapacity(waterSpec.fillType) end)
                if ok and type(v) == "number" then waterCap = v end
            end
        end
        local waterAuto = waterSpec and waterSpec.automaticWaterSupply == true
        -- Auto-supply means "infinite" — pin the bar to 100% instead of
        -- showing the empty backing trough's 0%.
        local waterPct
        if waterAuto then
            waterPct = 1
        else
            waterPct = waterCap > 0 and (waterFill / waterCap) or 0
        end
        local prod = (bPlace and bPlace.spec_husbandry and bPlace.spec_husbandry.globalProductionFactor or 0) * 100
        local h = averageHealthAt(bPlace)
        local barnIdx
        for _, e in ipairs(self._barns) do if e.barn == barn then barnIdx = e.idx; break end end
        local state = (f and f.grazingScheduler and f.grazingScheduler.state[barnIdx]) or "at_barn"

        -- For beef herds out on pasture the displayed productivity is the
        -- floor lerp from _applyGrassProductivityFloor. Annotate the line
        -- so the player can see WHY it's pinned where it is — empty trough
        -- = grass-only floor; topped-up trough = lerped higher. Dairy and
        -- at-barn herds show the bare number (vanilla owns the value).
        local prodLine = string.format("Productivity: %.0f%%", prod)
        if f ~= nil and barnIdx ~= nil
           and type(f.grazingRegistry.getProductionType) == "function"
           and f.grazingRegistry:getProductionType(barnIdx) == "meat"
           and state == "at_pasture" then
            local fwBeefFloor = (f.beefGrazingFloor or 0.80) * 100
            if prod <= fwBeefFloor + 0.5 then
                prodLine = prodLine .. string.format("  (capped: grass only — load TMR to lift)", fwBeefFloor)
            elseif prod < 99.5 then
                prodLine = prodLine .. "  (lifted: trough partly supplemented)"
            else
                prodLine = prodLine .. "  (trough fully supplemented)"
            end
        end
        setText("detail_active", prodLine)
        setText("detail_animals", string.format("Animals: %d", countAnimalsAt(bPlace)))
        if foodCap > 0 then
            setText("detail_meadow", string.format("Food:    %s (%.0f%%)",
                fmtVolPair(foodFill, foodCap, firstFillType(foodSpec)), foodPct * 100))
        else
            setText("detail_meadow", "Food:    (none)")
        end
        if waterSpec ~= nil then
            if waterAuto then
                setText("detail_water", string.format("Water:   %s  [auto-filled]",
                    fmtVol(waterCap > 0 and waterCap or waterFill, waterSpec.fillType)))
            else
                setText("detail_water", string.format("Water:   %s (%.0f%%)",
                    fmtVolPair(waterFill, waterCap, waterSpec.fillType), waterPct * 100))
            end
        else
            setText("detail_water", "Water:   (none)")
        end
        if h ~= nil then
            setText("detail_health", string.format("Health:  %.0f / 100", h))
        else
            setText("detail_health", "Health:  (no animals)")
        end
        -- Repurpose the last text row to show the scheduler state since the
        -- old detail_pump text row has been replaced by the toggle widget.
        -- Also surface the barn's STORED MILK here for dairy barns: while the
        -- cows are OUT the vanilla barn milk panel reads empty, but milk keeps
        -- draining into the barn's storage — so show the real total to avoid the
        -- "milk looks broken while grazing" confusion.
        local stateLine = string.format("State:   %s", state)
        local milkSpec = bPlace and bPlace.spec_husbandryMilk
        if milkSpec ~= nil and type(milkSpec.fillTypes) == "table"
           and type(bPlace.getHusbandryFillLevel) == "function" then
            local milkTotal, milkFt = 0, nil
            for _, ft in ipairs(milkSpec.fillTypes) do
                milkTotal = milkTotal + (bPlace:getHusbandryFillLevel(ft) or 0)
                milkFt = milkFt or ft
            end
            if milkFt ~= nil then
                stateLine = stateLine .. string.format("   |   Milk stored: %s", fmtVol(milkTotal, milkFt))
            end
        end
        -- Grazing condition: the herd's built-up grass-fed boost (0..100%).
        -- Drives extra beef weight + dairy milk; builds on good grass, fades when housed.
        if barnIdx and type(f.grazingRegistry.getGrazingCondition) == "function" then
            local cond = f.grazingRegistry:getGrazingCondition(barnIdx) or 0
            stateLine = stateLine .. string.format("   |   Grazing condition: %.0f%%", cond * 100)
        end
        -- Production type (auto): shows whether this herd milks or is raised for meat.
        if barnIdx and type(f.grazingRegistry.getProductionType) == "function" then
            local prod = f.grazingRegistry:getProductionType(barnIdx)
            stateLine = stateLine .. "   |   " .. tr(prod == "milk" and "ph_produces_milk" or "ph_produces_meat")
        end
        setText("detail_lastgrazed", stateLine)
        self:_setBarsVisible(true)
        self:_setBarWidth("detail_meadow_bg", "detail_meadow_bar", foodPct)
        self:_setBarWidth("detail_water_bg",  "detail_water_bar",  waterPct)

        -- Average pasture soil-quality across the barn's pastures.
        local avgQ, qN = 0, 0
        if barnIdx and barn.pastures ~= nil and type(f.grazingRegistry.getPastureQuality) == "function" then
            for i, _ in ipairs(barn.pastures) do
                avgQ = avgQ + (f.grazingRegistry:getPastureQuality(barnIdx, i) or 1.0)
                qN = qN + 1
            end
        end
        local qBar = qN > 0 and (avgQ / qN) or 1.0
        setText("detail_quality", qN > 0
            and string.format("Avg soil quality: %.0f%%", qBar * 100)
            or "Avg soil quality: (no pastures)")
        self:_setBarWidth("detail_quality_bg", "detail_quality_bar", qBar)
        -- Sub-rows + fert row are per-pasture only — hide them so the
        -- layout collapses around the barn view instead of leaving gaps.
        showPastureOnlyRows(false)

        self:_syncDetailToggles()
        return
    end

    if pasture == nil or barn == nil then
        setText("detail_active", "")
        setText("detail_animals", "")
        setText("detail_meadow", "")
        setText("detail_water", "")
        setText("detail_quality", "")
        setText("detail_health", "")
        setText("detail_lastgrazed", "")
        showPastureOnlyRows(false)
        self:_setBarsVisible(false)
        self:_syncDetailToggles()
        return
    end
    self:_setBarsVisible(true)

    -- Per-pasture detail mode — make sure the pasture-only rows (sub-lines
    -- + fertilizer block) are visible. They get hidden in barn-only and
    -- empty modes above.
    showPastureOnlyRows(true)

    -- Active or not
    local isActive = (barn.activeIndex == self.selectedPastureIdx)
    setText("detail_active", string.format("Active:  %s", isActive and "YES" or "no"))

    -- Animals at this pasture (text set below once we've computed the 8h
    -- stocking suggestion that hangs off the same line).

    -- Meadow grass available + fill bar. Also surface the herd's daily
    -- grass need so the player can see at a glance whether the pasture
    -- will sustain them. Consumption rate comes from spec_husbandryFood.
    -- litersPerHour (the total per-hour food rate scaled by herd size in
    -- vanilla / RLM). × 24 = daily total; days-remaining math is mFill /
    -- daily.
    local meadow = pasture.spec_husbandryMeadow
    local mFill = sumFillLevels(meadow)
    local mCap = sumCapacities(meadow)
    local mPct = (mCap > 0) and (mFill / mCap) or 0
    -- Stocking math driven by the TOTAL herd this barn manages (cows at
    -- the barn + cows on every pasture under it), not just the herd on
    -- this specific pasture. Per-cow consumption rate comes from RLRM's
    -- spec_husbandryFood.litersPerHour, which it overwrites every frame
    -- with sum(animal:getInput("food")) — so it already reflects RLRM's
    -- consumption multiplier and per-breed/age/lactation scaling.
    -- See: RealisticLivestockRM/.../RealisticLivestock_PlaceableHusbandryFood.lua
    -- Fallback to a 6 L/hr hardcoded estimate only if neither pasture
    -- nor barn has a usable rate (early frames before RLRM updates).
    local FALLBACK_L_PER_COW_HR = 6
    -- Grazing window length comes from the barn's actual sunrise/sunset
    -- (or the scheduler defaults if unset). RLRM food scale is already
    -- baked into perCowHr via cluster:getInput("food").
    local sunriseH = (f and f.grazingRegistry and f.grazingRegistry:getBarnSunrise(self.selectedBarnIdx))
                     or (f and f.grazingScheduler and f.grazingScheduler.SUNRISE_HOUR) or 8
    local sunsetH = (f and f.grazingRegistry and f.grazingRegistry:getBarnSunset(self.selectedBarnIdx))
                    or (f and f.grazingScheduler and f.grazingScheduler.SUNSET_HOUR) or 17
    local GRAZE_WINDOW_HOURS = math.max(1, sunsetH - sunriseH)
    local pastureAnimals = countAnimalsAt(pasture)
    local barnAnimals = countAnimalsAt(barn.placeable)
    local pastureWideAnimals = 0
    for _, p in ipairs(barn.pastures) do
        pastureWideAnimals = pastureWideAnimals + countAnimalsAt(p)
    end
    local totalHerd = barnAnimals + pastureWideAnimals

    -- Resolve the herd's total food demand (L/hour) by walking every
    -- cluster on the barn AND on every pasture and summing cluster:getInput
    -- ("food"). This is RLRM's own per-cluster rate so it already includes
    -- the consumption multiplier, lactation, age, and breed modifiers.
    -- Doing it animal-by-animal (rather than reading spec.litersPerHour)
    -- means the rate is correct even when cows are mid-transit between
    -- placeables — that's the gap that made the previous estimate read
    -- "10h" while the actual was ~5h after the herd arrived.
    local herdLitersPerHour = sumFoodInputAt(barn.placeable)
    for _, p in ipairs(barn.pastures) do
        herdLitersPerHour = herdLitersPerHour + sumFoodInputAt(p)
    end
    -- Per-cow rate (for the cows-per-window inversion only). If the herd
    -- demand sum failed to resolve (no animals or no subType), fall back
    -- to the hardcoded vanilla estimate.
    local perCowHr = (totalHerd > 0 and herdLitersPerHour > 0)
        and (herdLitersPerHour / totalHerd) or FALLBACK_L_PER_COW_HR
    -- If herdLitersPerHour came back as 0, fall back to the old math so
    -- the menu doesn't read "0h" on a brand-new save with no animals yet.
    if herdLitersPerHour <= 0 then
        herdLitersPerHour = totalHerd * FALLBACK_L_PER_COW_HR
    end

    -- Hours this meadow sustains the WHOLE managed herd if they all rotate
    -- here. Grass now drains at the FULL herd consumption rate: the concentrate
    -- pump is gone and the meadow-drain throttle was removed, so cows on a
    -- pure-graze pasture take 100% of their intake from grass. (A beef pasture
    -- with a hand-fed trough will last a bit LONGER than shown, since cows eat
    -- the trough feed first — grass lasting longer than estimated is the safe
    -- direction; over-estimating and starving the herd is not.) "grass lasts",
    -- "herd needs L/day", and the stocking suggestion all use this full rate,
    -- so they agree with the actual meadow drain.
    local grassDemandHourly = herdLitersPerHour
    local grassDemandPerCowHr = perCowHr

    local hoursForHerd
    if grassDemandHourly > 0 then
        hoursForHerd = mFill / grassDemandHourly
    end
    -- Inverse: how many cows can graze this pasture for the full window.
    local windowMaxCows = (grassDemandPerCowHr > 0)
        and math.floor(mFill / (GRAZE_WINDOW_HOURS * grassDemandPerCowHr)) or 0
    -- Daily GRASS need: herd's grass-only hourly demand × 24.
    local dailyNeed = grassDemandHourly * 24
    local needSuffix = ""
    if dailyNeed > 0 then
        local grassFt = firstFillType(meadow)
        if mFill > 0 then
            local daysLeft = mFill / dailyNeed
            needSuffix = string.format("herd needs %s  -  %.1f d left", fmtVolDay(dailyNeed, grassFt), daysLeft)
        else
            needSuffix = string.format("herd needs %s", fmtVolDay(dailyNeed, grassFt))
        end
    end
    -- Surface the RLRM food-scale compensation when it's active so the
    -- player can see the cap has been inflated above the model's authored
    -- value. We read the per-fillType snapshot the scheduler writes on
    -- first scale-up: _phOriginalMeadowCapacity[ft]. If it's present AND
    -- differs from the current cap, append a "RLRM ×N scale" suffix.
    local scaleSuffix = ""
    local origSnap = meadow and meadow._phOriginalMeadowCapacity
    if type(origSnap) == "table" then
        local origCap = 0
        for ft, v in pairs(origSnap) do
            if type(v) == "number" then origCap = origCap + v end
        end
        if origCap > 0 and mCap > origCap then
            local mult = mCap / origCap
            scaleSuffix = string.format("RLRM x%.1f", mult)
        end
    end
    if mCap > 0 then
        setText("detail_meadow", string.format("Grass:  %.0f%%  -  %s",
            mPct * 100, fmtVolPair(mFill, mCap, firstFillType(meadow))))
    elseif meadow ~= nil then
        setText("detail_meadow", "Grass:  " .. fmtVol(mFill, firstFillType(meadow)))
    else
        setText("detail_meadow", "Grass:  (no meadow)")
    end
    -- Sub-line: herd grass need + RLRM scale. Joined with a bullet only when
    -- both are present so we don't get stranded separators.
    local meadowSubParts = {}
    if needSuffix ~= "" then meadowSubParts[#meadowSubParts + 1] = needSuffix end
    if scaleSuffix ~= "" then meadowSubParts[#meadowSubParts + 1] = scaleSuffix end
    setText("detail_meadow_sub", table.concat(meadowSubParts, "  -  "))
    -- Practical readout: cows currently here + the total herd this barn
    -- manages (barn + all pastures) + the worst-case "if they all rotated
    -- onto this pasture, how long would the grass last" figure.
    -- Suggestion: how many cows this pasture can sustain through the full
    -- sunrise->sunset window WITHOUT taking grass below the rotate
    -- threshold. The rotate threshold (framework.rotateAtGrassPercent,
    -- default 30%) is the player-configured "stop grazing here when fill
    -- drops below this %" — the reserved regrowth buffer. We subtract it
    -- from the usable grass so the suggestion respects the player's
    -- rotation policy. perCowHr already bakes in RLRM's foodScale.
    local rotateAt = (f and f.rotateAtGrassPercent) or 0.30
    if rotateAt < 0 then rotateAt = 0 end
    if rotateAt > 0.95 then rotateAt = 0.95 end
    local usableCap = mCap * (1.0 - rotateAt)
    local usableFill = math.max(0, mFill - mCap * rotateAt)
    local suggestionLine = ""
    if grassDemandPerCowHr > 0 and usableCap > 0 then
        local nowCows = math.floor(usableFill / (GRAZE_WINDOW_HOURS * grassDemandPerCowHr))
        local capCows = math.floor(usableCap / (GRAZE_WINDOW_HOURS * grassDemandPerCowHr))
        -- Lead with the ACTIONABLE number (cows that fit the window at
        -- current grass level without crossing the reserve). Uses the full
        -- per-cow consumption rate (grass is the whole diet on a pure-graze
        -- pasture), matching the actual meadow drain.
        suggestionLine = string.format(
            "fits ~%d cows for %dh (%d%% reserve - full pasture: %d)",
            nowCows, GRAZE_WINDOW_HOURS, math.floor(rotateAt * 100), capCows)
    end
    if totalHerd > 0 and hoursForHerd ~= nil then
        setText("detail_animals", string.format(
            "Animals here: %d  -  total herd: %d  -  grass lasts %.1f h",
            pastureAnimals, totalHerd, hoursForHerd))
    else
        setText("detail_animals", string.format("Animals here: %d", pastureAnimals))
    end
    setText("detail_animals_sub", suggestionLine)
    self:_setBarWidth("detail_meadow_bg", "detail_meadow_bar", mPct)

    -- Water trough + fill bar. With auto-supply on, the husbandry never
    -- actually drains the trough, so fl/cap reads 0 and the bar would look
    -- empty even though water is effectively infinite. Treat auto-supply as
    -- "always full" — the bar pins to 100% and the text says [auto-filled].
    local water = pasture.spec_husbandryWater
    local wPct = 0
    if water ~= nil then
        local fl, cap = 0, 0
        if type(pasture.getHusbandryFillLevel) == "function" then
            local ok, v = pcall(function() return pasture:getHusbandryFillLevel(water.fillType) end)
            if ok and type(v) == "number" then fl = v end
        end
        if type(pasture.getHusbandryCapacity) == "function" then
            local ok, v = pcall(function() return pasture:getHusbandryCapacity(water.fillType) end)
            if ok and type(v) == "number" then cap = v end
        end
        local autoSup = water.automaticWaterSupply == true
        if autoSup then
            wPct = 1
            setText("detail_water", string.format("Water:   %s  [auto-filled]", fmtVol(cap > 0 and cap or fl, water.fillType)))
        else
            wPct = (cap > 0) and (fl / cap) or 0
            setText("detail_water", string.format("Water:   %s (%.0f%%)",
                fmtVolPair(fl, cap, water.fillType), wPct * 100))
        end
    else
        setText("detail_water", "Water:   (none)")
    end
    self:_setBarWidth("detail_water_bg", "detail_water_bar", wPct)

    -- Pasture feed trough. When the Feed Trough toggle is OFF (dairy default)
    -- the pasture is pure-graze and the trough isn't used — say so. When ON
    -- (beef default / hand-fed supplement) show the raw fill the player has
    -- loaded. No more pump auto-fill.
    local fSpec = pasture.spec_husbandryFood
    local fFill = sumFillLevels(fSpec)
    local fCap = (fSpec and fSpec.capacity) or 0
    local fPct = (fCap > 0) and (fFill / fCap) or 0
    do
        local barn = self:_currentBarn()
        local barnIdxForTrough
        for _, e in ipairs(self._barns) do if e.barn == barn then barnIdxForTrough = e.idx; break end end
        local troughOn = f and barnIdxForTrough and f.grazingRegistry:getTroughEnabled(barnIdxForTrough)
        if fSpec == nil then
            setText("detail_food", "Food:    (no trough)")
            fPct = 0
        elseif not troughOn then
            setText("detail_food", "Food:    (trough off — grazing)")
            fPct = 0
        elseif fCap > 0 then
            setText("detail_food", string.format("Food:    %s (%.0f%%)",
                fmtVolPair(fFill, fCap, firstFillType(fSpec)), fPct * 100))
        else
            setText("detail_food", "Food:    " .. fmtVol(fFill, firstFillType(fSpec)))
        end
    end
    self:_setBarWidth("detail_food_bg", "detail_food_bar", fPct)

    -- Resolve barnIdx for quality / lastGrazed lookups below.
    local barnIdx
    for _, entry in ipairs(self._barns) do
        if entry.barn == barn then barnIdx = entry.idx; break end
    end

    -- Soil quality: degrades on grazing days, recovers on rest days.
    -- Multiplies the effective grass during auto-rotation picks.
    local qPct = 1.0
    if f and barnIdx and type(f.grazingRegistry.getPastureQuality) == "function" then
        qPct = f.grazingRegistry:getPastureQuality(barnIdx, self.selectedPastureIdx) or 1.0
    end
    local qualityLine = string.format("Soil quality:  %.0f%%", qPct * 100)
    -- If this pasture is mid-reseed, show the regrowth lockout + days remaining.
    if f and barnIdx and type(f.grazingRegistry.isRenewing) == "function"
       and f.grazingRegistry:isRenewing(barnIdx, self.selectedPastureIdx) then
        local unlockDay = f.grazingRegistry:getRenewalUnlockDay(barnIdx, self.selectedPastureIdx) or 0
        local env = g_currentMission and g_currentMission.environment
        local today = env and env.currentDay or 0
        local daysLeft = math.max(0, math.floor(unlockDay - today))
        qualityLine = qualityLine .. string.format("   |   REGROWTH — %d day(s) left", daysLeft)
    end
    -- PF soil-pH modifier: surface it only when it's dragging grass growth, with a
    -- nudge to lime. Neutral pH (or no PF) shows nothing.
    if f and barnIdx and type(f.grazingRegistry.getPHFactor) == "function" then
        local phf = f.grazingRegistry:getPHFactor(barnIdx, self.selectedPastureIdx) or 1.0
        if phf < 0.995 then
            qualityLine = qualityLine .. string.format("   |   Soil pH: grass %+.0f%% (lime to improve)",
                (phf - 1.0) * 100)
        end
    end
    setText("detail_quality", qualityLine)
    self:_setBarWidth("detail_quality_bg", "detail_quality_bar", qPct)

    -- Fertilizer row: dedicated bar so its state stands out.
    --   1. Active boost -> "Fertilizer: N d left" + bar fills proportional to days remaining
    --   2. Partial coverage from current spray pass -> "Spreading: X%" + bar = cov / threshold
    --   3. None -> empty (bar pins to 0)
    local fertLine, fertSubLine, fertBarPct = "", "", 0
    if f and barnIdx then
        local daysRem = 0
        if type(f.grazingRegistry.getFertilizerDaysRemaining) == "function" then
            daysRem = f.grazingRegistry:getFertilizerDaysRemaining(barnIdx, self.selectedPastureIdx) or 0
        end
        if daysRem > 0 then
            local regrowthMul = (f and f.fertilizerRegrowthMultiplier) or 2.0
            local qualityMul = (f and f.fertilizerQualityMultiplier) or 2.0
            local fullDays = (f.getFertilizerBoostDays and f:getFertilizerBoostDays())
                              or f.fertilizerBoostDays or 7
            fertLine = string.format("Fertilizer:  %d d left", daysRem)
            fertSubLine = string.format("regrowth x%.1f  -  soil recovery x%.1f", regrowthMul, qualityMul)
            fertBarPct = (fullDays > 0) and math.min(1, daysRem / fullDays) or 0
        elseif SprayerHook ~= nil and type(SprayerHook.getCoverage) == "function" then
            local cov = SprayerHook.getCoverage(barnIdx, self.selectedPastureIdx)
            if cov > 0 then
                local thresh = SprayerHook.COVERAGE_THRESHOLD or 0.75
                fertLine = string.format("Spreading:  %.0f%%", cov * 100)
                fertSubLine = string.format("need %.0f%% covered to apply boost", thresh * 100)
                fertBarPct = math.min(1, cov / thresh)
            else
                fertLine = "Fertilizer:  none applied"
            end
        else
            fertLine = "Fertilizer:  none applied"
        end
    end
    setText("detail_fert", fertLine)
    setText("detail_fert_sub", fertSubLine)
    self:_setBarWidth("detail_fert_bg", "detail_fert_bar", fertBarPct)

    -- Average herd health at this pasture
    local h = averageHealthAt(pasture)
    if h ~= nil then
        setText("detail_health", string.format("Health:  %.0f / 100", h))
    else
        setText("detail_health", "Health:  (no animals here)")
    end

    -- Pump status text is now superseded by the BinaryOption toggle in the
    -- right pane. Keep the toggle in sync with the registry flag.
    self:_syncDetailToggles()

    -- Last grazed
    local lastDay
    if f and barnIdx and type(f.grazingRegistry.getLastGrazed) == "function" then
        lastDay = f.grazingRegistry:getLastGrazed(barnIdx, self.selectedPastureIdx)
    end
    if lastDay == nil then
        setText("detail_lastgrazed", "Last grazed: never")
    else
        local today = g_currentMission and g_currentMission.environment and g_currentMission.environment.currentDay or lastDay
        local diff = today - lastDay
        if diff <= 0 then
            setText("detail_lastgrazed", "Last grazed: today")
        elseif diff == 1 then
            setText("detail_lastgrazed", "Last grazed: yesterday")
        else
            setText("detail_lastgrazed", string.format("Last grazed: %d days ago", diff))
        end
    end
end

-- SmoothList selection callbacks.
function PHMenuGrazingFrame:onBarnSelectionChanged(list, section, index)
    PastureHerdingLog.dev("[PHMenuGrazing] onBarnSelectionChanged section=%s index=%s barns=%d",
        tostring(section), tostring(index), #self._barns)
    local idx = index
    if type(section) == "number" and type(index) ~= "number" then
        -- Some FS25 versions pass (section, index) flipped; defensive.
        idx = section
    end
    local entry = self._barns[idx]
    if entry == nil then return end
    self.selectedBarnIdx = entry.idx
    self.selectedPastureIdx = nil
    self:_reloadPastures()
    self:_buildButtonInfos()
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onPastureSelectionChanged(list, section, index)
    -- Defensive, same as onBarnSelectionChanged: some FS25 versions pass
    -- (section, index) flipped, and the empty ListItem can dispatch a nil
    -- index. Never compare a non-number with >= (crashes the handler).
    local idx = index
    if type(section) == "number" and type(index) ~= "number" then
        idx = section
    end
    self.selectedPastureIdx = (type(idx) == "number" and idx >= 1) and idx or nil
    self:_buildButtonInfos()
    self:_refreshDetails()
end

-- Single bottom-bar button whose text + callback depend on the currently-
-- selected barn's state. at_barn -> "Send out now", at_pasture -> "Bring in
-- now". Rebuild on every state-changing action so the label flips.
function PHMenuGrazingFrame:_buildButtonInfos()
    local f = rawget(_G, "g_pastureHerding")
    -- Sync against reality before reading state — otherwise the button
    -- label here is computed from stale cached state, and onFrameOpen
    -- runs this before _refreshDetails which would have synced it.
    if f and f.grazingScheduler and self.selectedBarnIdx
       and type(f.grazingScheduler.reconcileStateFromReality) == "function" then
        pcall(function() f.grazingScheduler:reconcileStateFromReality(self.selectedBarnIdx) end)
    end
    local state = (f and f.grazingScheduler and self.selectedBarnIdx
                  and f.grazingScheduler.state[self.selectedBarnIdx]) or "at_barn"

    -- Gray the toggle button out when this barn's move can't be issued right
    -- now: either its OWN walk is in flight ("Cows walking..."), or it's parked in
    -- the walk queue behind another barn's walk ("Queued..."). Only one walk runs
    -- at a time across all barns, so a queued send just waits its turn — the
    -- button reflects that instead of looking clickable.
    local sched = f and f.grazingScheduler
    local transitionActive = sched and self.selectedBarnIdx
        and type(sched._hasActiveSessionFor) == "function"
        and sched:_hasActiveSessionFor(self.selectedBarnIdx) == true
    local transitionQueued = sched and self.selectedBarnIdx
        and type(sched._barnQueued) == "function"
        and sched:_barnQueued(self.selectedBarnIdx) == true
    local busy = transitionActive or transitionQueued
    local busyText = transitionActive and "Cows walking..." or (transitionQueued and "Queued..." or nil)

    -- Is the barn's ACTIVE pasture mid-reseed? Then it can't be grazed, so the
    -- 'Send to pasture' button is greyed out (the transition would only hold them
    -- in) with an explanatory label.
    local activeRenewing = false
    if f and self.selectedBarnIdx and type(f.grazingRegistry.isRenewing) == "function" then
        local b = f.grazingRegistry:getBarn(self.selectedBarnIdx)
        local ai = b and b.activeIndex or 0
        if ai > 0 then
            activeRenewing = f.grazingRegistry:isRenewing(self.selectedBarnIdx, ai) == true
        end
    end

    if state == "at_pasture" then
        self.toggleButtonInfo = {
            inputAction = InputAction.MENU_ACTIVATE,
            text = busyText or tr("ph_menu_grazing_btn_in"),
            callback = function() self:onClickBringIn() end,
            profile = "buttonSelect",
            disabled = busy == true,
        }
    else
        self.toggleButtonInfo = {
            inputAction = InputAction.MENU_ACTIVATE,
            text = busyText
                or (activeRenewing and tr("ph_menu_grazing_btn_out_regrowing"))
                or tr("ph_menu_grazing_btn_out"),
            callback = function() self:onClickSendOut() end,
            profile = "buttonSelect",
            disabled = (busy == true) or activeRenewing,
        }
    end

    -- When auto-grazing is on the manual cycle is disabled — the scheduler
    -- will pick the best pasture at sunset based on grass availability.
    local autoOn = f and self.selectedBarnIdx
                   and f.grazingRegistry:getAutoRotate(self.selectedBarnIdx) == true

    self.cycleActiveButtonInfo = {
        inputAction = InputAction.MENU_EXTRA_1,
        text = tr("ph_menu_grazing_btn_cycle"),
        callback = function() self:onClickCycleActivePasture() end,
        profile = "buttonSelect",
        disabled = autoOn == true,
    }

    -- Auto-pasture-select moved out of the bottom bar into a Status-pane
    -- toggle row (see detail_sel_autorotate). Keep this field nil so the
    -- bottom bar omits it without breaking the array builder below.
    self.autoGrazingButtonInfo = nil

    -- Rename button — targets the pasture if one is selected, otherwise the
    -- barn. Disabled when no barn is selected at all.
    -- MENU_EXTRA_3 doesn't exist in all FS25 versions and falls back to ESC
    -- with no label; pick the first that resolves.
    local renameAction = InputAction.MENU_PAGE_NEXT
        or InputAction.MENU_OPTION
        or InputAction.MENU_ACCEPT
    self.renameButtonInfo = {
        inputAction = renameAction,
        text = tr("ph_menu_grazing_btn_rename"),
        callback = function() self:onClickRename() end,
        profile = "buttonSelect",
        disabled = self.selectedBarnIdx == nil,
    }

    -- Set-as-active and Remove-pasture target the currently-selected pasture.
    -- Disabled when no pasture is selected. Set-as-active also disables when
    -- the selected pasture IS already the active one.
    local pastureSel = self.selectedPastureIdx
    local barnEntry = self:_currentBarn()
    local activeIdx = (barnEntry and barnEntry.activeIndex) or 0

    -- A pasture mid-reseed is locked out: can't be set active, can't be renewed
    -- again until it reopens.
    local selRenewing = f and self.selectedBarnIdx and pastureSel
        and type(f.grazingRegistry.isRenewing) == "function"
        and f.grazingRegistry:isRenewing(self.selectedBarnIdx, pastureSel) == true

    self.setActiveButtonInfo = {
        inputAction = InputAction.MENU_AXIS_LEFT_RIGHT,
        text = tr("ph_menu_grazing_btn_set_active"),
        callback = function() self:onClickSetActive() end,
        profile = "buttonSelect",
        disabled = pastureSel == nil or pastureSel == activeIdx or selRenewing == true,
    }

    -- Renew (reseed) the selected pasture. Present only when a pasture is
    -- selected; disabled while it's already regrowing.
    self.renewPastureButtonInfo = nil
    if pastureSel ~= nil then
        self.renewPastureButtonInfo = {
            inputAction = InputAction.MENU_CANCEL or InputAction.MENU_ACTIVATE,
            text = tr("ph_menu_grazing_btn_renew"),
            callback = function() self:onClickRenewPasture() end,
            profile = "buttonSelect",
            disabled = selRenewing == true,
        }
    end

    self.removePastureButtonInfo = {
        inputAction = InputAction.MENU_AXIS_UP_DOWN,
        text = tr("ph_menu_grazing_btn_remove_pasture"),
        callback = function() self:onClickRemovePasture() end,
        profile = "buttonSelect",
        disabled = pastureSel == nil,
    }

    -- Move Herd Here: walk the OUT herd from its current pasture to the selected
    -- one (needs a recorded path between them). Enabled only when the herd is out
    -- at a DIFFERENT pasture than the one selected.
    local canMove = state == "at_pasture" and pastureSel ~= nil
                    and activeIdx > 0 and pastureSel ~= activeIdx
    self.moveHerdButtonInfo = {
        inputAction = InputAction.MENU_EXTRA_2 or InputAction.MENU_CANCEL or InputAction.MENU_ACTIVATE,
        text = tr("ph_menu_grazing_btn_move_herd"),
        callback = function() self:onClickMoveHerd() end,
        profile = "buttonSelect",
        disabled = not canMove,
    }

    -- Quick schedule toggle: Full-time grazing <-> Housed. Hidden for Day grazing
    -- (that uses the daily in/out cycle). Label flips to show what clicking does.
    local schedMode = (f and self.selectedBarnIdx)
                      and f.grazingRegistry:getScheduleMode(self.selectedBarnIdx) or "day"
    if schedMode == "continuous" or schedMode == "housed" then
        local label = (schedMode == "continuous")
            and tr("ph_menu_grazing_btn_house")
            or  tr("ph_menu_grazing_btn_graze_out")
        local schedAction = InputAction.MENU_PAGE_PREV
            or InputAction.MENU_OPTION
            or InputAction.MENU_ACTIVATE
        self.beefPhaseButtonInfo = {
            inputAction = schedAction,
            text = label,
            callback = function() self:onClickToggleBeefPhase() end,
            profile = "buttonSelect",
        }
    else
        self.beefPhaseButtonInfo = nil
    end

    local barnSel = self.selectedBarnIdx
    self.menuButtonInfo = {
        self.backButtonInfo,
        self.toggleButtonInfo,
        self.setActiveButtonInfo,
        self.moveHerdButtonInfo,
        self.cycleActiveButtonInfo,
        self.renameButtonInfo,
        self.removePastureButtonInfo,
    }
    if self.renewPastureButtonInfo ~= nil then
        self.menuButtonInfo[#self.menuButtonInfo + 1] = self.renewPastureButtonInfo
    end
    if self.beefPhaseButtonInfo ~= nil then
        self.menuButtonInfo[#self.menuButtonInfo + 1] = self.beefPhaseButtonInfo
    end
    if type(self.setMenuButtonInfoDirty) == "function" then
        self:setMenuButtonInfoDirty()
    end
end

function PHMenuGrazingFrame:_seedDetailToggleTexts()
    local onOffTexts = { tr("ui_off"), tr("ui_on") }
    if self.detail_sel_auto_sched and type(self.detail_sel_auto_sched.setTexts) == "function" then
        self.detail_sel_auto_sched:setTexts(onOffTexts)
    end
    if self.detail_sel_pump and type(self.detail_sel_pump.setTexts) == "function" then
        self.detail_sel_pump:setTexts(onOffTexts)
    end
    local hourLabels = { tr("ph_detail_hour_default") }
    for h = 0, 23 do hourLabels[#hourLabels + 1] = string.format("%02d:00", h) end
    if self.detail_sel_sunrise and type(self.detail_sel_sunrise.setTexts) == "function" then
        self.detail_sel_sunrise:setTexts(hourLabels)
    end
    if self.detail_sel_sunset and type(self.detail_sel_sunset.setTexts) == "function" then
        self.detail_sel_sunset:setTexts(hourLabels)
    end
    if self.detail_sel_mode and type(self.detail_sel_mode.setTexts) == "function" then
        self.detail_sel_mode:setTexts({
            tr("ph_detail_mode_day"),
            tr("ph_detail_mode_continuous"),
            tr("ph_detail_mode_housed"),
        })
    end
    if self.detail_sel_capacity and type(self.detail_sel_capacity.setTexts) == "function" then
        -- State 1 is the "Default" (no override) slot. It reads the pasture's own
        -- built-in size; here (seed pass, no pasture context yet) show the bare
        -- word — _syncDetailSelectors refreshes it with the actual number once a
        -- pasture is selected.
        local capLabels = { tr("ph_detail_capacity_default") }
        for i = 2, #DETAIL_CAPACITY_VALUES do
            capLabels[#capLabels + 1] = string.format("%d cows", DETAIL_CAPACITY_VALUES[i])
        end
        self.detail_sel_capacity:setTexts(capLabels)
    end
    if self.detail_sel_autowater and type(self.detail_sel_autowater.setTexts) == "function" then
        -- Tri-state: Default (registry nil) / ON / OFF.
        self.detail_sel_autowater:setTexts({
            tr("ph_detail_autowater_default"),
            tr("ph_detail_autowater_on"),
            tr("ph_detail_autowater_off"),
        })
    end
    if self.detail_sel_autorotate and type(self.detail_sel_autorotate.setTexts) == "function" then
        self.detail_sel_autorotate:setTexts({ tr("ui_off"), tr("ui_on") })
    end
end

function PHMenuGrazingFrame:onFrameOpen()
    PHMenuGrazingFrame:superClass().onFrameOpen(self)
    self:_seedDetailToggleTexts()
    self:_buildButtonInfos()
    self:_reloadBarns()
    self:_reloadPastures()
    self:_refreshDetails()
    -- Pre-resolve every tooltip string ONCE per menu open and cache. Avoids
    -- repeated g_i18n:getText calls during the per-frame update() polling
    -- loop, which other mods (RedTape, EasyDevControls, etc.) wrap and
    -- which deeply stack their wrappers — repeated lookups push the chain
    -- past the Lua stack limit.
    self._tooltipCache = {}
    for id, key in pairs(DETAIL_TOOLTIP_KEYS) do
        self._tooltipCache[id] = wrapText(tr(key), 60)
    end
    self._tooltipCache._hint = wrapText(tr("ph_detail_tooltip_hint"), 60)

    if self.detail_tooltip ~= nil and type(self.detail_tooltip.setText) == "function" then
        -- No textWrapWidth / maxNumLines — wrapText() pre-inserted newlines,
        -- and FS25's auto-wrap interacts badly with our long strings (recurses
        -- into TextElement.update around line 578). Strict newline rendering
        -- is enough.
        self.detail_tooltip:setText(self._tooltipCache._hint)
        self._lastTooltipKey = "_hint"
    end
    PastureHerdingLog.dev("[PHMenuGrazing] onFrameOpen detail_tooltip bound=%s",
        tostring(self.detail_tooltip ~= nil))
end

function PHMenuGrazingFrame:onFrameClose()
    PHMenuGrazingFrame:superClass().onFrameClose(self)
end

function PHMenuGrazingFrame:onClickSendOut()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    f.grazingScheduler:transitionOne(self.selectedBarnIdx, "out", "menu")
    self:_buildButtonInfos()
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onClickBringIn()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    f.grazingScheduler:transitionOne(self.selectedBarnIdx, "in", "menu")
    -- Auto-rotate may have swapped the active pasture during the "in"
    -- transition; reload the center list so the ACTIVE tag jumps to the
    -- newly-selected pasture immediately instead of waiting for a refresh.
    if self.pasturesList ~= nil and type(self.pasturesList.reloadData) == "function" then
        self.pasturesList:reloadData()
    end
    self:_buildButtonInfos()
    self:_refreshDetails()
end

-- Resolve which placeable the rename action is targeting.
-- Priority: selected pasture > selected barn. Returns (placeable, kind)
-- where kind is "pasture" or "barn", or nil if nothing's selected.
function PHMenuGrazingFrame:_renameTarget()
    if self.selectedPastureIdx ~= nil and self._pastures[self.selectedPastureIdx] ~= nil then
        return self._pastures[self.selectedPastureIdx], "pasture"
    end
    local barn = self:_currentBarn()
    if barn ~= nil and barn.placeable ~= nil then
        return barn.placeable, "barn"
    end
    return nil, nil
end

function PHMenuGrazingFrame:onClickRename()
    local placeable, kind = self:_renameTarget()
    if placeable == nil then return end
    local current = placeableName(placeable)
    local prompt = (kind == "pasture")
        and tr("ph_menu_grazing_rename_pasture_prompt")
        or  tr("ph_menu_grazing_rename_barn_prompt")

    -- Stash target for the callback so we can refresh the right list afterward.
    self._renameTargetRef = placeable

    local opened = false

    -- Try the static TextInputDialog.show form first — most reliable on FS25.
    if TextInputDialog ~= nil and type(TextInputDialog.show) == "function" then
        local okStatic, errStatic = pcall(function()
            TextInputDialog.show(
                function(text, clickOk) self:onRenameConfirmed(text, clickOk) end,
                nil,        -- target (nil; the closure captures self)
                current,    -- defaultText
                prompt      -- dialogPrompt
            )
        end)
        if okStatic then
            opened = true
        else
            PastureHerdingLog.warning("[PHMenuGrazing] TextInputDialog.show failed: %s", tostring(errStatic))
        end
    end

    -- Fallback: g_gui:showTextInputDialog table form.
    if not opened and g_gui ~= nil and type(g_gui.showTextInputDialog) == "function" then
        local okGui, errGui = pcall(function()
            g_gui:showTextInputDialog({
                text = prompt,
                callback = self.onRenameConfirmed,
                target = self,
                defaultText = current,
                maxCharacters = 40,
            })
        end)
        if okGui then
            opened = true
        else
            PastureHerdingLog.warning("[PHMenuGrazing] g_gui:showTextInputDialog failed: %s", tostring(errGui))
        end
    end

    if not opened then
        PastureHerdingLog.warning("[PHMenuGrazing] no TextInputDialog API available; rename canceled (TextInputDialog=%s, g_gui.showTextInputDialog=%s)",
            tostring(TextInputDialog), tostring(g_gui and g_gui.showTextInputDialog))
        self._renameTargetRef = nil
    end
end

function PHMenuGrazingFrame:onRenameConfirmed(text, clickOk)
    local placeable = self._renameTargetRef
    self._renameTargetRef = nil
    if not clickOk or placeable == nil then return end
    local newName = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if newName == "" then return end

    -- Route through the registry (same as the Map tab) so the name persists,
    -- syncs across every menu (placeableName reads getCustomName first), and
    -- propagates to RLRM / the vanilla menus via applyGlobalName. Writing
    -- setName/savedName directly here was invisible to getCustomName and made
    -- an already-named barn/pasture un-renameable from this tab.
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    if reg ~= nil and type(reg.setCustomName) == "function" then
        pcall(function() reg:setCustomName(placeable, newName) end)
    end

    PastureHerdingLog.dev("[PHMenuGrazing] renamed placeable -> '%s'", newName)

    -- Refresh both lists so the new name shows immediately.
    self:_reloadBarns()
    self:_reloadPastures()
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onClickSetActive()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    f.grazingRegistry:setActivePasture(self.selectedBarnIdx, self.selectedPastureIdx)
    if self.pasturesList ~= nil and type(self.pasturesList.reloadData) == "function" then
        self.pasturesList:reloadData()
    end
    self:_buildButtonInfos()
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onClickMoveHerd()
    local f = rawget(_G, "g_pastureHerding")
    local sched = f and f.grazingScheduler
    if sched == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    if type(sched.walkPastureToPasture) ~= "function" then return end
    local barn = self:_currentBarn()
    local curIdx = (barn and barn.activeIndex) or 0
    if curIdx <= 0 or curIdx == self.selectedPastureIdx then return end
    local ok, msg = sched:walkPastureToPasture(self.selectedBarnIdx, curIdx, self.selectedPastureIdx, "manual")
    if type(f.pushNotification) == "function" and msg ~= "queued" then
        -- (a "queued" result already posts its own notification from the scheduler)
        local color = ok and { 0.2, 1.0, 0.4, 1.0 } or { 1.0, 0.4, 0.2, 1.0 }
        pcall(function()
            f:pushNotification(color, ok and "Herd is walking to the selected pasture"
                or ("Cannot move: " .. tostring(msg)), 6000)
        end)
    end
    self:_buildButtonInfos()
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onClickRemovePasture()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    local placeable = self._pastures[self.selectedPastureIdx]
    if placeable == nil then return end
    f.grazingRegistry:removePastureFromBarn(self.selectedBarnIdx, self.selectedPastureIdx)
    self.selectedPastureIdx = nil
    self:_reloadPastures()
    self:_buildButtonInfos()
    self:_refreshDetails()
end

-- Size-scaled renewal cost preview (mirrors GrazingScheduler:renewPasture).
function PHMenuGrazingFrame:_renewCostPreview(pasture)
    local f = rawget(_G, "g_pastureHerding")
    local sched = f and f.grazingScheduler
    if sched == nil or type(sched._meadowCapacity) ~= "function" then return 0 end
    local cap = sched:_meadowCapacity(pasture)
    local perL = (f and f.renewCostPerLiter) or 0.20
    local minCost = (f and f.renewMinCost) or 500
    local cost = math.floor(cap * perL + 0.5)
    if cost < minCost then cost = minCost end
    return cost
end

-- Renew (reseed) the selected pasture — confirm first (it costs money and locks
-- grazing for ~2 months).
function PHMenuGrazingFrame:onClickRenewPasture()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    local pasture = self._pastures[self.selectedPastureIdx]
    if pasture == nil then return end
    if type(f.grazingRegistry.isRenewing) == "function"
       and f.grazingRegistry:isRenewing(self.selectedBarnIdx, self.selectedPastureIdx) then return end

    local cost = self:_renewCostPreview(pasture)
    local costStr = (g_i18n ~= nil and type(g_i18n.formatMoney) == "function")
        and g_i18n:formatMoney(cost, 0, true, true) or ("$" .. tostring(cost))
    local text = string.format(tr("ph_renew_confirm_text"), costStr)

    local shown = false
    if g_gui ~= nil and type(g_gui.showDialog) == "function" then
        local ok = pcall(function()
            local dialog = g_gui:showDialog("YesNoDialog")
            if dialog ~= nil and dialog.target ~= nil then
                local t = dialog.target
                if type(t.setTitle) == "function" then t:setTitle(tr("ph_renew_confirm_title")) end
                if type(t.setText) == "function" then t:setText(text) end
                if type(t.setCallback) == "function" then
                    t:setCallback(self.onConfirmRenewPasture, self)
                    shown = true
                end
            end
        end)
        if not ok then shown = false end
    end
    if not shown then self:onConfirmRenewPasture(true) end
end

-- YesNoDialog result (yes = true when the player confirms).
function PHMenuGrazingFrame:onConfirmRenewPasture(yes)
    if yes ~= true then return end
    local f = rawget(_G, "g_pastureHerding")
    local sched = f and f.grazingScheduler
    if sched == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    if type(sched.renewPasture) ~= "function" then return end
    local ok, msg = sched:renewPasture(self.selectedBarnIdx, self.selectedPastureIdx)
    if not ok and type(f.pushNotification) == "function" then
        pcall(function() f:pushNotification({ 1.0, 0.4, 0.2, 1.0 }, tostring(msg), 6000) end)
    end
    if self.pasturesList ~= nil and type(self.pasturesList.reloadData) == "function" then
        self.pasturesList:reloadData()
    end
    self:_buildButtonInfos()
    self:_refreshDetails()
end

-- Right-pane status toggles. Click handler is shared; we route by element
-- id. The element's state has already advanced by the time we get here;
-- we just persist the new value to the registry.
function PHMenuGrazingFrame:onClickDetailToggle(stateIndex, element)
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    local id = element and element.id or ""
    local on = (stateIndex == 2)
    if id == "detail_sel_auto_sched" then
        f.grazingRegistry:setAutoSchedule(self.selectedBarnIdx, on)
    elseif id == "detail_sel_pump" then
        f.grazingRegistry:setTroughEnabled(self.selectedBarnIdx, on)
    end
end

-- Consolidated mode picker. State 1 = Dairy, 2 = Beef-Grazing, 3 = Beef-
-- Finishing. Writes both registry fields and triggers a scheduler reconcile
-- so a beef herd starts moving toward its new home within seconds. NOTE:
-- intentionally does NOT call _refreshDetails here — _refreshDetails calls
-- _syncDetailToggles which calls setState on this same option, which would
-- recurse through MultiTextOptionElement (the well-known FS25 bug). The
-- bottom-bar button label is the only thing that needs refreshing.
function PHMenuGrazingFrame:onClickDetailMode(stateIndex, element)
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    -- 1 = Day grazing, 2 = Full-time grazing, 3 = Housed.
    local mode = (stateIndex == 3) and "housed" or (stateIndex == 2) and "continuous" or "day"
    f.grazingScheduler:setScheduleModeAndReconcile(self.selectedBarnIdx, mode)
    -- Feed Trough default follows production type (meat on, milk off) unless the
    -- player has set an explicit override. Sync the on-screen toggle to the
    -- effective state without going through _refreshDetails (which would
    -- re-setState the mode picker mid-click and recurse — the FS25 bug).
    if self.detail_sel_pump and type(self.detail_sel_pump.setState) == "function" then
        local troughOn = f.grazingRegistry:getTroughEnabled(self.selectedBarnIdx)
        self.detail_sel_pump:setState(troughOn and 2 or 1, true)
    end
    self:_buildButtonInfos()
end

-- Per-pasture animal-population cap. State 1 = "Default" (registry stores
-- nil => scheduler keeps the pasture's own built-in maxNumAnimals); states
-- 2..N map to DETAIL_CAPACITY_VALUES[state]. Don't call setState back on the
-- option mid-click — same FS25 recursion rule as everywhere else.
function PHMenuGrazingFrame:onClickDetailCapacity(stateIndex, element)
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    local value = DETAIL_CAPACITY_VALUES[stateIndex or 1]
    -- 0 in the picker is the "Default" slot — pass nil to the registry so the
    -- scheduler's cap helper restores the pasture's own maxNumAnimals.
    if value == 0 then value = nil end
    f.grazingRegistry:setPastureCapacity(self.selectedBarnIdx, self.selectedPastureIdx, value)
end

-- Per-barn Auto Pasture Select toggle. State 1 = OFF, 2 = ON. Stored on
-- the barn registry's autoRotate flag. Drives whether sunset transitions
-- choose a new pasture each rotation.
function PHMenuGrazingFrame:onClickDetailAutoRotate(stateIndex, element)
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    f.grazingRegistry:setAutoRotate(self.selectedBarnIdx, stateIndex == 2)
    -- Refresh the bottom-bar "Cycle active pasture" button — it's disabled
    -- while auto-rotate is on.
    self:_buildButtonInfos()
end

-- Per-pasture auto-water override. State 1 = Default (registry stores nil
-- — pasture keeps whatever its i3d/XML configured), 2 = force ON, 3 = OFF.
function PHMenuGrazingFrame:onClickDetailAutoWater(stateIndex, element)
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil or self.selectedPastureIdx == nil then return end
    local value
    if stateIndex == 2 then value = true
    elseif stateIndex == 3 then value = false
    else value = nil end
    f.grazingRegistry:setPastureAutoWater(self.selectedBarnIdx, self.selectedPastureIdx, value)
end

-- Per-barn sunrise / sunset hour pickers. State 1 = "Default" (nil override);
-- state 2..25 = hour 0..23. Don't call setState back — same rule as the
-- other toggles, mid-click setState recurses MultiTextOption.
function PHMenuGrazingFrame:onClickDetailHour(stateIndex, element)
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    local id = element and element.id or ""
    local hour = (stateIndex == nil or stateIndex <= 1) and nil or (stateIndex - 2)
    if id == "detail_sel_sunrise" then
        f.grazingRegistry:setBarnSunrise(self.selectedBarnIdx, hour)
    elseif id == "detail_sel_sunset" then
        f.grazingRegistry:setBarnSunset(self.selectedBarnIdx, hour)
    end
end

-- Shared tooltip area below the toggle rows. Each control's focus handler
-- writes the relevant description into detail_tooltip; blur clears it. Keeps
-- the explanation inside the right pane so it can't render off-screen the way
-- the side tooltip profile did. DETAIL_TOOLTIP_KEYS + wrapText are declared
-- at the top of this file so methods above this point can resolve them.
function PHMenuGrazingFrame:onDetailControlFocus(element)
    if self.detail_tooltip == nil or type(self.detail_tooltip.setText) ~= "function" then return end
    local id = element and element.id or ""
    local cached = self._tooltipCache and self._tooltipCache[id]
    if cached == nil then return end
    self.detail_tooltip:setText(cached)
end

function PHMenuGrazingFrame:onDetailControlBlur(element)
    if self.detail_tooltip == nil or type(self.detail_tooltip.setText) ~= "function" then return end
    if self._tooltipCache then self.detail_tooltip:setText(self._tooltipCache._hint or "") end
end

-- Drive the tooltip from FocusManager every frame: whichever toggle currently
-- holds keyboard/controller focus shows its description. Polling here side-
-- steps the XML onFocusEnter/onFocusLeave callbacks (which don't reliably
-- fire on mouse hover for these option elements in FS25). Cheap — one
-- getFocusedElement() call per frame while the menu is open.
function PHMenuGrazingFrame:update(dt)
    if PHMenuGrazingFrame:superClass().update ~= nil then
        PHMenuGrazingFrame:superClass().update(self, dt)
    end
    -- Keep the Send/Bring toggle button's busy state live while the menu is
    -- open, so it greys to "Cows walking..." / "Queued..." and un-greys as walks
    -- start, queue, and finish — without the player having to navigate. Throttled
    -- and only rebuilds when the state actually changes.
    self._btnRefreshAcc = (self._btnRefreshAcc or 0) + (dt or 0)
    if self._btnRefreshAcc >= 400 then
        self._btnRefreshAcc = 0
        local fw = rawget(_G, "g_pastureHerding")
        local sc = fw and fw.grazingScheduler
        if sc ~= nil and self.selectedBarnIdx ~= nil then
            local active = (type(sc._hasActiveSessionFor) == "function"
                            and sc:_hasActiveSessionFor(self.selectedBarnIdx)) and 1 or 0
            local queued = (type(sc._barnQueued) == "function"
                            and sc:_barnQueued(self.selectedBarnIdx)) and 1 or 0
            local key = active .. queued
            if self._lastBtnBusyKey ~= key then
                self._lastBtnBusyKey = key
                self:_buildButtonInfos()
            end
        end
    end
    if self.detail_tooltip == nil or type(self.detail_tooltip.setText) ~= "function" then return end
    if self._tooltipCache == nil then return end
    local focused
    if FocusManager ~= nil and type(FocusManager.getFocusedElement) == "function" then
        focused = FocusManager:getFocusedElement()
    end
    local id = focused and focused.id or nil
    -- Pure table lookup — no g_i18n:getText per frame; strings were resolved
    -- once at onFrameOpen and stashed in _tooltipCache.
    local cached = id and self._tooltipCache[id]
    if cached ~= nil then
        if self._lastTooltipKey ~= id then
            self.detail_tooltip:setText(cached)
            self._lastTooltipKey = id
        end
    elseif self._lastTooltipKey ~= "_hint" then
        self.detail_tooltip:setText(self._tooltipCache._hint or "")
        self._lastTooltipKey = "_hint"
    end
end

-- Bottom-bar "Start finishing" / "Return to grazing" — flips the beef
-- phase and immediately reconciles so cows start moving without waiting
-- for the next hour tick.
function PHMenuGrazingFrame:onClickToggleBeefPhase()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    local mode = f.grazingRegistry:getScheduleMode(self.selectedBarnIdx)
    local next = (mode == "continuous") and "housed" or "continuous"
    f.grazingScheduler:setScheduleModeAndReconcile(self.selectedBarnIdx, next)
    self:_buildButtonInfos()
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onClickToggleAutoGrazing()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    local cur = f.grazingRegistry:getAutoRotate(self.selectedBarnIdx) == true
    f.grazingRegistry:setAutoRotate(self.selectedBarnIdx, not cur)
    self:_buildButtonInfos()
    if self.pasturesList ~= nil and type(self.pasturesList.reloadData) == "function" then
        self.pasturesList:reloadData()
    end
    self:_refreshDetails()
end

function PHMenuGrazingFrame:onClickCycleActivePasture()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or self.selectedBarnIdx == nil then return end
    f.grazingRegistry:cycleActivePasture(self.selectedBarnIdx)
    -- Refresh the center list so "ACTIVE" tags move to the new pasture, and
    -- the right pane reflects the new active flag.
    if self.pasturesList ~= nil and type(self.pasturesList.reloadData) == "function" then
        self.pasturesList:reloadData()
    end
    self:_refreshDetails()
end
