-- Save/load barn+pasture pairings across game sessions.
--
-- Placeable Lua references can't be serialised directly. We snapshot each
-- placeable's world position; on load we walk the husbandry system and
-- resolve by nearest-position match (within a small tolerance). Position is
-- a stable fingerprint because placeables don't move.

GrazingPersistence = {}

local FILENAME = "pastureHerding_grazing.xml"
local RESOLVE_TOLERANCE_M = 5.0  -- saved-position to current-position match radius

local function getSavegameDir()
    local mi = g_currentMission and g_currentMission.missionInfo
    if mi == nil then return nil end
    if type(mi.savegameDirectory) == "string" then return mi.savegameDirectory end
    if type(mi.savegameDirectoryPath) == "string" then return mi.savegameDirectoryPath end
    -- Some FS versions build it from index
    if mi.savegameIndex ~= nil and getUserProfileAppPath ~= nil then
        return string.format("%ssavegame%d", getUserProfileAppPath(), mi.savegameIndex)
    end
    return nil
end

local function placeablePos(placeable)
    if placeable == nil or placeable.rootNode == nil or placeable.rootNode == 0 then return nil end
    if getWorldTranslation == nil then return nil end
    local x, _, z = getWorldTranslation(placeable.rootNode)
    return x, z
end

local function countHusbandryAnimals(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

function GrazingPersistence.save(registry, scheduler)
    if registry == nil then return false, "no registry" end
    local dir = getSavegameDir()
    if dir == nil then return false, "no savegame dir" end
    local path = dir .. "/" .. FILENAME

    local xmlFile = createXMLFile("grazing", path, "grazing")
    if xmlFile == nil or xmlFile == 0 then
        return false, "createXMLFile failed: " .. path
    end

    -- Global grazing-calendar settings live on the framework. Stash them on
    -- the root so they apply across barns without per-barn duplication.
    local framework = rawget(_G, "g_pastureHerding")
    if framework ~= nil then
        if type(framework.grazingSeasonStart) == "number" then
            setXMLInt(xmlFile, "grazing#seasonStart", framework.grazingSeasonStart)
        end
        if type(framework.grazingSeasonEnd) == "number" then
            setXMLInt(xmlFile, "grazing#seasonEnd", framework.grazingSeasonEnd)
        end
        if type(framework.grazingMinRestDays) == "number" then
            setXMLInt(xmlFile, "grazing#minRestDays", framework.grazingMinRestDays)
        end
        setXMLBool(xmlFile, "grazing#gateOnWeather", framework.gateOnWeather == true)
        -- Precision Farming soil layer on/off (one switch: soil quality + pH).
        setXMLBool(xmlFile, "grazing#pfSoilEnabled", framework.pfSoilEnabled ~= false)
        if type(framework.gateAutoMode) == "string" then
            setXMLString(xmlFile, "grazing#gateAutoMode", framework.gateAutoMode)
        end
        if type(framework.maxMeadowRegrowthPerDay) == "number" then
            setXMLFloat(xmlFile, "grazing#maxMeadowRegrowthPerDay", framework.maxMeadowRegrowthPerDay)
        end
        if type(framework.rotateAtGrassPercent) == "number" then
            setXMLFloat(xmlFile, "grazing#rotateAtGrassPercent", framework.rotateAtGrassPercent)
        end
        if type(framework.starvationGrassPct) == "number" then
            setXMLFloat(xmlFile, "grazing#starvationGrassPct", framework.starvationGrassPct)
        end
        if type(framework.starvationSupplementPct) == "number" then
            setXMLFloat(xmlFile, "grazing#starvationSupplementPct", framework.starvationSupplementPct)
        end
        if type(framework.grazingQualityDecayPerGrazingDay) == "number" then
            setXMLFloat(xmlFile, "grazing#qualityDecay", framework.grazingQualityDecayPerGrazingDay)
        end
        if type(framework.grazingQualityRecoveryPerRestDay) == "number" then
            setXMLFloat(xmlFile, "grazing#qualityRecovery", framework.grazingQualityRecoveryPerRestDay)
        end
        if type(framework.pastureGrassFoodFraction) == "number" then
            setXMLFloat(xmlFile, "grazing#grassFraction", framework.pastureGrassFoodFraction)
        end
        -- Grazing output-boost tuning (Settings sliders).
        if type(framework.grazeWeightBonus) == "number" then
            setXMLFloat(xmlFile, "grazing#grazeWeightBonus", framework.grazeWeightBonus)
        end
        if type(framework.grazeMilkBonus) == "number" then
            setXMLFloat(xmlFile, "grazing#grazeMilkBonus", framework.grazeMilkBonus)
        end
        if type(framework.grazeConditionBuildRate) == "number" then
            setXMLFloat(xmlFile, "grazing#grazeConditionBuildRate", framework.grazeConditionBuildRate)
        end
        if type(framework.grazeConditionDecayRate) == "number" then
            setXMLFloat(xmlFile, "grazing#grazeConditionDecayRate", framework.grazeConditionDecayRate)
        end
        if type(framework._pathSampleSpacing) == "number" then
            setXMLFloat(xmlFile, "grazing#pathSampleSpacing", framework._pathSampleSpacing)
        end
        setXMLBool(xmlFile, "grazing#startupAlertAcked", framework.startupAlertAcked == true)
        if type(framework.herdsmanHourlyWage) == "number" then
            setXMLInt(xmlFile, "grazing#herdsmanHourlyWage", framework.herdsmanHourlyWage)
        end
    end

    -- Per-farm last-charged-Period map for the herdsman wage. Without this
    -- a save -> reload would re-bill instantly the next tick (the scheduler
    -- map is in-memory only).
    local scheduler = framework and framework.grazingScheduler
    if scheduler ~= nil and type(scheduler._lastChargedPeriod) == "table" then
        local hi = 0
        for farmId, period in pairs(scheduler._lastChargedPeriod) do
            if type(farmId) == "number" and type(period) == "number" then
                local hKey = string.format("grazing.herdsmanCharge(%d)", hi)
                setXMLInt(xmlFile, hKey .. "#farmId", farmId)
                setXMLInt(xmlFile, hKey .. "#lastPeriod", period)
                hi = hi + 1
            end
        end
    end

    -- Notification archive (the Notifications tab). Persist so the player can
    -- still review past alerts after a save/reload. Stored newest-first, so we
    -- write them in order and load them back in order.
    if framework ~= nil and type(framework._notifications) == "table" then
        local ni = 0
        for _, n in ipairs(framework._notifications) do
            if type(n) == "table" and type(n.text) == "string" and n.text ~= "" then
                local nKey = string.format("grazing.notification(%d)", ni)
                setXMLString(xmlFile, nKey .. "#stamp", n.stamp or "")
                setXMLString(xmlFile, nKey .. "#text", n.text)
                local c = n.color or { 1, 1, 1, 1 }
                setXMLString(xmlFile, nKey .. "#color",
                    string.format("%.3f %.3f %.3f %.3f", c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1))
                ni = ni + 1
            end
        end
    end

    local barnI = 0
    for barnIdx, barn in registry:iterBarns() do
        local bx, bz = placeablePos(barn.placeable)
        if bx ~= nil then
            local barnKey = string.format("grazing.barn(%d)", barnI)
            setXMLFloat(xmlFile, barnKey .. "#x", bx)
            setXMLFloat(xmlFile, barnKey .. "#z", bz)
            setXMLInt(xmlFile, barnKey .. "#activeIndex", barn.activeIndex)
            setXMLBool(xmlFile, barnKey .. "#autoRotate", barn.autoRotate == true)
            -- Mirror getter's exact-truth semantics: a nil autoSchedule
            -- (never toggled by the player) is OFF, not ON. Writing
            -- `~= false` would save nil as true and silently flip the
            -- default back to ON across a save/reload.
            setXMLBool(xmlFile, barnKey .. "#autoSchedule", barn.autoSchedule == true)
            -- Feed Trough: only persist an EXPLICIT override. nil = "follow herd
            -- type" (beef on, dairy off), which getTroughEnabled derives on load.
            if barn.troughEnabled ~= nil then
                setXMLBool(xmlFile, barnKey .. "#troughEnabled", barn.troughEnabled == true)
            end
            if type(barn.customName) == "string" and barn.customName ~= "" then
                setXMLString(xmlFile, barnKey .. "#customName", barn.customName)
            end
            if type(barn.herdType) == "string" then
                setXMLString(xmlFile, barnKey .. "#herdType", barn.herdType)
            end
            if type(barn.beefPhase) == "string" then
                setXMLString(xmlFile, barnKey .. "#beefPhase", barn.beefPhase)
            end
            -- Grazing condition (0..1): the herd's built-up grass-fed boost. Only
            -- persist a meaningful value so a fresh barn stays at 0 on load.
            if type(barn.grazingCondition) == "number" and barn.grazingCondition > 0 then
                setXMLFloat(xmlFile, barnKey .. "#grazingCondition", barn.grazingCondition)
            end
            if type(barn.sunriseHour) == "number" then
                setXMLInt(xmlFile, barnKey .. "#sunriseHour", barn.sunriseHour)
            end
            if type(barn.sunsetHour) == "number" then
                setXMLInt(xmlFile, barnKey .. "#sunsetHour", barn.sunsetHour)
            end
            -- Hour the herd went out this session — drives proportional sunset
            -- grass decay; without it a save/reload while out loses the start
            -- reference and the decay math runs against nil.
            if type(barn._grazingStartHour) == "number" then
                setXMLInt(xmlFile, barnKey .. "#grazingStartHour", barn._grazingStartHour)
            end
            if scheduler ~= nil and scheduler.state ~= nil and scheduler.state[barnIdx] ~= nil then
                setXMLString(xmlFile, barnKey .. "#state", scheduler.state[barnIdx])
            end
            -- Beef manual-out override: player put this herd out by hand, so it
            -- stays out even off-season until they bring it in / change phase.
            if scheduler ~= nil and type(scheduler._beefManualOut) == "table"
               and scheduler._beefManualOut[barnIdx] == true then
                setXMLBool(xmlFile, barnKey .. "#beefManualOut", true)
            end
            for pIdx, p in ipairs(barn.pastures) do
                local pxw, pzw = placeablePos(p)
                if pxw ~= nil then
                    local pKey = string.format("%s.pasture(%d)", barnKey, pIdx - 1)
                    setXMLFloat(xmlFile, pKey .. "#x", pxw)
                    setXMLFloat(xmlFile, pKey .. "#z", pzw)
                    -- Land state now lives in a per-PLACEABLE shared record
                    -- (registry.land), so read it via getLandRecord rather than
                    -- the old per-barn maps. A shared pasture is written under
                    -- both owners' entries with the same values; on load either
                    -- one populates the single shared record (idempotent).
                    local L = (type(registry.getLandRecord) == "function")
                              and registry:getLandRecord(barnIdx, pIdx) or nil
                    local lastDay = L and L.lastGrazedDay
                    if type(lastDay) == "number" then
                        setXMLInt(xmlFile, pKey .. "#lastGrazedDay", lastDay)
                    end
                    local pName = L and L.customName
                    if type(pName) == "string" and pName ~= "" then
                        setXMLString(xmlFile, pKey .. "#customName", pName)
                    end
                    local q = L and L.quality
                    if type(q) == "number" then
                        setXMLFloat(xmlFile, pKey .. "#quality", q)
                    end
                    local cap = L and L.capacity
                    if type(cap) == "number" then
                        setXMLInt(xmlFile, pKey .. "#capacity", cap)
                    end
                    local aw = L and L.autoWater
                    if type(aw) == "boolean" then
                        setXMLBool(xmlFile, pKey .. "#autoWater", aw)
                    end
                    local fertExp = L and L.fertilizerExpireDay
                    if type(fertExp) == "number" and fertExp > 0 then
                        setXMLInt(xmlFile, pKey .. "#fertilizerExpireDay", fertExp)
                    end
                    -- Reseeding lockout: absolute in-game day grazing reopens.
                    local renewDay = L and L.renewalUnlockDay
                    if type(renewDay) == "number" and renewDay > 0 then
                        setXMLInt(xmlFile, pKey .. "#renewalUnlockDay", renewDay)
                    end
                    -- Player lime (pH) correction 0..1.
                    local limeCorr = L and L.pHLimeCorrection
                    if type(limeCorr) == "number" and limeCorr > 0 then
                        setXMLFloat(xmlFile, pKey .. "#limeCorrection", limeCorr)
                    end
                    -- PF soil sampled flag (so we don't re-sample or re-backfill).
                    if L and L.pfSampled == true then
                        setXMLBool(xmlFile, pKey .. "#pfSampled", true)
                    end
                    -- Whether the "fertilized" ground tint is painted on the
                    -- terrain right now (so we can wipe it on the next graze
                    -- even after a reload — the painted map itself is saved by
                    -- the engine, our runtime cell-set is not).
                    local fertPainted = L and L.fertPainted
                    if fertPainted == true then
                        setXMLBool(xmlFile, pKey .. "#fertPainted", true)
                        if type(L.fertPaintDay) == "number" then
                            setXMLInt(xmlFile, pKey .. "#fertPaintDay", L.fertPaintDay)
                        end
                    end
                    -- In-progress fertilizer spread (the half-finished pass). The
                    -- boost itself persists via fertilizerExpireDay above; this
                    -- saves WHICH cells were covered so a mid-pass isn't lost on
                    -- reload. Only the covered set is stateful — the grid geometry
                    -- rebuilds from the pasture contour.
                    local SH = rawget(_G, "SprayerHook")
                    local grid = SH and type(SH._grids) == "table"
                                 and SH._grids[barnIdx .. ":" .. pIdx] or nil
                    if grid ~= nil and (grid.coveredCount or 0) > 0 and type(grid.covered) == "table" then
                        local ids = {}
                        for idx in pairs(grid.covered) do ids[#ids + 1] = idx end
                        if #ids > 0 then
                            table.sort(ids)
                            setXMLString(xmlFile, pKey .. "#fertCovered", table.concat(ids, " "))
                            setXMLInt(xmlFile, pKey .. "#fertCellsX", grid.cellsX or 0)
                            setXMLInt(xmlFile, pKey .. "#fertCellsZ", grid.cellsZ or 0)
                            setXMLInt(xmlFile, pKey .. "#fertInsideCount", grid.insideCount or 0)
                        end
                    end
                    -- Persist the pre-scale meadow capacity snapshot so the
                    -- food-scale scaler doesn't compound on each reload.
                    -- Vanilla husbandry persists spec_husbandryMeadow.capacities
                    -- with our SCALED values; without this restore, the next
                    -- load reads the scaled cap as the new "original" and
                    -- multiplies again -> grass cap explodes after a few saves.
                    local m = p.spec_husbandryMeadow
                    if m ~= nil and type(m._phOriginalMeadowCapacity) == "table" then
                        local snapI = 0
                        for ft, origCap in pairs(m._phOriginalMeadowCapacity) do
                            if type(ft) == "number" and type(origCap) == "number" then
                                local sKey = string.format("%s.origMeadow(%d)", pKey, snapI)
                                setXMLInt(xmlFile, sKey .. "#ft", ft)
                                setXMLInt(xmlFile, sKey .. "#cap", origCap)
                                snapI = snapI + 1
                            end
                        end
                    end
                    local path = registry:getPath(barnIdx, pIdx)
                    if path ~= nil and #path >= 2 then
                        local wpI = 0
                        for i = 1, #path, 2 do
                            local wKey = string.format("%s.waypoint(%d)", pKey, wpI)
                            setXMLFloat(xmlFile, wKey .. "#x", path[i])
                            setXMLFloat(xmlFile, wKey .. "#z", path[i + 1])
                            wpI = wpI + 1
                        end
                    end
                end
            end
            -- Pasture-pasture paths (herd walks straight between pastures).
            if type(barn.pasturePaths) == "table" then
                local ppI = 0
                for _, e in ipairs(barn.pasturePaths) do
                    if type(e.path) == "table" and #e.path >= 4 then
                        local ppKey = string.format("%s.pasturePair(%d)", barnKey, ppI)
                        setXMLInt(xmlFile, ppKey .. "#from", e.from)
                        setXMLInt(xmlFile, ppKey .. "#to", e.to)
                        local wpI = 0
                        for i = 1, #e.path, 2 do
                            local wKey = string.format("%s.waypoint(%d)", ppKey, wpI)
                            setXMLFloat(xmlFile, wKey .. "#x", e.path[i])
                            setXMLFloat(xmlFile, wKey .. "#z", e.path[i + 1])
                            wpI = wpI + 1
                        end
                        ppI = ppI + 1
                    end
                end
            end
            barnI = barnI + 1
        end
    end

    saveXMLFile(xmlFile)
    delete(xmlFile)
    PastureHerdingLog.dev("GrazingPersistence: saved %d barn(s) to %s", barnI, path)
    return true, path
end

local function findPlaceableAt(x, z, tolerance)
    local best, bestD2 = nil, tolerance * tolerance
    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 px, _, pz = getWorldTranslation(p.rootNode)
                local dx, dz = px - x, pz - z
                local d2 = dx*dx + dz*dz
                if d2 < bestD2 then bestD2 = d2; best = p end
            end
        end
    end
    return best
end

function GrazingPersistence.load(registry, scheduler)
    if registry == nil then return false, "no registry" end
    local dir = getSavegameDir()
    if dir == nil then return false, "no savegame dir" end
    local path = dir .. "/" .. FILENAME
    if fileExists ~= nil and not fileExists(path) then
        PastureHerdingLog.dev("GrazingPersistence: no saved grazing file (%s)", path)
        return true, "no file"
    end

    local xmlFile = loadXMLFile("grazing", path)
    if xmlFile == nil or xmlFile == 0 then
        return false, "loadXMLFile failed: " .. path
    end

    local framework = rawget(_G, "g_pastureHerding")
    if framework ~= nil then
        if hasXMLProperty(xmlFile, "grazing#seasonStart") then
            framework.grazingSeasonStart = getXMLInt(xmlFile, "grazing#seasonStart") or framework.grazingSeasonStart
        end
        if hasXMLProperty(xmlFile, "grazing#seasonEnd") then
            framework.grazingSeasonEnd = getXMLInt(xmlFile, "grazing#seasonEnd") or framework.grazingSeasonEnd
        end
        if hasXMLProperty(xmlFile, "grazing#minRestDays") then
            framework.grazingMinRestDays = getXMLInt(xmlFile, "grazing#minRestDays") or framework.grazingMinRestDays
        end
        if hasXMLProperty(xmlFile, "grazing#gateAutoMode") then
            local m = getXMLString(xmlFile, "grazing#gateAutoMode")
            if m == "all" or m == "herdsman" or m == "off" then framework.gateAutoMode = m end
        end
        if hasXMLProperty(xmlFile, "grazing#gateOnWeather") then
            framework.gateOnWeather = getXMLBool(xmlFile, "grazing#gateOnWeather") == true
        end
        if hasXMLProperty(xmlFile, "grazing#pfSoilEnabled") then
            local on = getXMLBool(xmlFile, "grazing#pfSoilEnabled") ~= false
            framework.pfSoilEnabled = on
            framework.pfPHEnabled = on
        end
        if hasXMLProperty(xmlFile, "grazing#maxMeadowRegrowthPerDay") then
            framework.maxMeadowRegrowthPerDay =
                getXMLFloat(xmlFile, "grazing#maxMeadowRegrowthPerDay") or framework.maxMeadowRegrowthPerDay
        end
        if hasXMLProperty(xmlFile, "grazing#rotateAtGrassPercent") then
            framework.rotateAtGrassPercent =
                getXMLFloat(xmlFile, "grazing#rotateAtGrassPercent") or framework.rotateAtGrassPercent
        end
        if hasXMLProperty(xmlFile, "grazing#starvationGrassPct") then
            framework.starvationGrassPct =
                getXMLFloat(xmlFile, "grazing#starvationGrassPct") or framework.starvationGrassPct
        end
        if hasXMLProperty(xmlFile, "grazing#starvationSupplementPct") then
            framework.starvationSupplementPct =
                getXMLFloat(xmlFile, "grazing#starvationSupplementPct") or framework.starvationSupplementPct
        end
        if hasXMLProperty(xmlFile, "grazing#qualityDecay") then
            framework.grazingQualityDecayPerGrazingDay =
                getXMLFloat(xmlFile, "grazing#qualityDecay") or framework.grazingQualityDecayPerGrazingDay
        end
        if hasXMLProperty(xmlFile, "grazing#qualityRecovery") then
            framework.grazingQualityRecoveryPerRestDay =
                getXMLFloat(xmlFile, "grazing#qualityRecovery") or framework.grazingQualityRecoveryPerRestDay
        end
        if hasXMLProperty(xmlFile, "grazing#grassFraction") then
            framework.pastureGrassFoodFraction =
                getXMLFloat(xmlFile, "grazing#grassFraction") or framework.pastureGrassFoodFraction
        end
        if hasXMLProperty(xmlFile, "grazing#grazeWeightBonus") then
            framework.grazeWeightBonus =
                getXMLFloat(xmlFile, "grazing#grazeWeightBonus") or framework.grazeWeightBonus
        end
        if hasXMLProperty(xmlFile, "grazing#grazeMilkBonus") then
            framework.grazeMilkBonus =
                getXMLFloat(xmlFile, "grazing#grazeMilkBonus") or framework.grazeMilkBonus
        end
        if hasXMLProperty(xmlFile, "grazing#grazeConditionBuildRate") then
            framework.grazeConditionBuildRate =
                getXMLFloat(xmlFile, "grazing#grazeConditionBuildRate") or framework.grazeConditionBuildRate
        end
        if hasXMLProperty(xmlFile, "grazing#grazeConditionDecayRate") then
            framework.grazeConditionDecayRate =
                getXMLFloat(xmlFile, "grazing#grazeConditionDecayRate") or framework.grazeConditionDecayRate
        end
        if hasXMLProperty(xmlFile, "grazing#pathSampleSpacing") then
            framework._pathSampleSpacing =
                getXMLFloat(xmlFile, "grazing#pathSampleSpacing") or framework._pathSampleSpacing
        end
        if hasXMLProperty(xmlFile, "grazing#startupAlertAcked") then
            framework.startupAlertAcked = getXMLBool(xmlFile, "grazing#startupAlertAcked") == true
        end
        if hasXMLProperty(xmlFile, "grazing#herdsmanHourlyWage") then
            local w = getXMLInt(xmlFile, "grazing#herdsmanHourlyWage")
            if type(w) == "number" then
                framework.herdsmanHourlyWage = w
                framework.herdsmanMonthlyCost = math.floor(w * (framework.herdsmanHoursPerWeek or 39) * 52 / 12)
            end
        end
    end

    -- Restore per-farm last-charged-Period for the herdsman wage so the
    -- first post-load tick sees last == currentPeriod and skips re-billing
    -- until the next real rollover.
    local scheduler = framework and framework.grazingScheduler
    if scheduler ~= nil then
        scheduler._lastChargedPeriod = scheduler._lastChargedPeriod or {}
        local hi = 0
        while true do
            local hKey = string.format("grazing.herdsmanCharge(%d)", hi)
            if not hasXMLProperty(xmlFile, hKey) then break end
            local farmId = getXMLInt(xmlFile, hKey .. "#farmId")
            local period = getXMLInt(xmlFile, hKey .. "#lastPeriod")
            if type(farmId) == "number" and type(period) == "number" then
                scheduler._lastChargedPeriod[farmId] = period
            end
            hi = hi + 1
        end
    end

    -- Restore the notification archive (Notifications tab). Saved newest-first;
    -- appended after anything raised this session (which is newer), then capped.
    if framework ~= nil then
        framework._notifications = framework._notifications or {}
        local ni = 0
        while true do
            local nKey = string.format("grazing.notification(%d)", ni)
            if not hasXMLProperty(xmlFile, nKey .. "#text") then break end
            local text = getXMLString(xmlFile, nKey .. "#text")
            if type(text) == "string" and text ~= "" then
                local stamp = hasXMLProperty(xmlFile, nKey .. "#stamp")
                    and getXMLString(xmlFile, nKey .. "#stamp") or "?"
                local color = { 1, 1, 1, 1 }
                if hasXMLProperty(xmlFile, nKey .. "#color") then
                    local cs = getXMLString(xmlFile, nKey .. "#color") or ""
                    local r, g, b, a = cs:match("([%d%.%-]+)%s+([%d%.%-]+)%s+([%d%.%-]+)%s+([%d%.%-]+)")
                    if r ~= nil then
                        color = { tonumber(r) or 1, tonumber(g) or 1, tonumber(b) or 1, tonumber(a) or 1 }
                    end
                end
                framework._notifications[#framework._notifications + 1] =
                    { stamp = stamp, text = text, color = color }
            end
            ni = ni + 1
        end
        local cap = framework.notificationLogMax or 50
        while #framework._notifications > cap do
            table.remove(framework._notifications)
        end
    end

    local barnsRestored, pasturesRestored, missing = 0, 0, 0
    local barnI = 0
    while true do
        local barnKey = string.format("grazing.barn(%d)", barnI)
        if not hasXMLProperty(xmlFile, barnKey) then break end
        local bx = getXMLFloat(xmlFile, barnKey .. "#x")
        local bz = getXMLFloat(xmlFile, barnKey .. "#z")
        local activeIdx = getXMLInt(xmlFile, barnKey .. "#activeIndex") or 0
        local autoRotate = getXMLBool(xmlFile, barnKey .. "#autoRotate") == true
        local barnPlaceable = (bx ~= nil and bz ~= nil) and findPlaceableAt(bx, bz, RESOLVE_TOLERANCE_M) or nil
        if barnPlaceable == nil then
            PastureHerdingLog.warning("GrazingPersistence: could not resolve barn at (%.1f, %.1f); skipping",
                bx or 0, bz or 0)
            missing = missing + 1
        else
            local barnIdx = registry:registerBarn(barnPlaceable)
            if barnIdx ~= nil then
                barnsRestored = barnsRestored + 1
                -- activeIdx is the saved 1-based slot in the pasture list. If an
                -- earlier pasture fails to resolve below, every later pasture's
                -- restored pIdx shifts down — so we can't reuse the raw saved
                -- index. Instead we watch for the saved slot (activeIdx-1) as we
                -- iterate and remember the pIdx it actually restored to.
                local restoredActivePIdx = nil
                -- Maps a saved 0-based pasture slot -> the pIdx it actually
                -- restored to (pastures that fail to resolve shift everything
                -- after them). Used to re-key pasture-pair paths on load.
                local slotToPIdx = {}
                local pI = 0
                local pMisses = 0
                -- Tolerate gaps in the saved pasture slots. The save side keys
                -- pastures by their raw list index and skips any whose position
                -- can't be resolved at save time, which can leave a hole (slot N
                -- missing while N+1 exists). Breaking at the first hole would
                -- silently drop every later pasture in this barn — so skip an
                -- empty slot and keep scanning, giving up only after many
                -- consecutive empties.
                while pMisses < 128 do
                    local pKey = string.format("%s.pasture(%d)", barnKey, pI)
                    if not hasXMLProperty(xmlFile, pKey) then
                        pMisses = pMisses + 1
                        pI = pI + 1
                    else
                        pMisses = 0
                    local px = getXMLFloat(xmlFile, pKey .. "#x")
                    local pz = getXMLFloat(xmlFile, pKey .. "#z")
                    local pPlaceable = (px ~= nil and pz ~= nil) and findPlaceableAt(px, pz, RESOLVE_TOLERANCE_M) or nil
                    if pPlaceable == nil then
                        PastureHerdingLog.warning("GrazingPersistence: could not resolve pasture at (%.1f, %.1f); skipping",
                            px or 0, pz or 0)
                        missing = missing + 1
                    else
                        local pIdx = registry:addPastureToBarn(barnIdx, pPlaceable)
                        if pIdx ~= nil then
                            pasturesRestored = pasturesRestored + 1
                            slotToPIdx[pI] = pIdx
                            if pI == activeIdx - 1 then
                                restoredActivePIdx = pIdx
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#lastGrazedDay") then
                                local d = getXMLInt(xmlFile, pKey .. "#lastGrazedDay")
                                if d ~= nil and type(registry.setLastGrazedDay) == "function" then
                                    registry:setLastGrazedDay(barnIdx, pIdx, d)
                                end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#customName") then
                                local nm = getXMLString(xmlFile, pKey .. "#customName")
                                local barn = registry:getBarn(barnIdx)
                                local p = barn and barn.pastures[pIdx]
                                if p ~= nil and type(registry.setCustomName) == "function" then
                                    registry:setCustomName(p, nm)
                                end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#quality") then
                                local q = getXMLFloat(xmlFile, pKey .. "#quality")
                                if q ~= nil then registry:setPastureQuality(barnIdx, pIdx, q) end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#capacity") then
                                local cap = getXMLInt(xmlFile, pKey .. "#capacity")
                                if cap ~= nil then registry:setPastureCapacity(barnIdx, pIdx, cap) end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#autoWater") then
                                registry:setPastureAutoWater(barnIdx, pIdx,
                                    getXMLBool(xmlFile, pKey .. "#autoWater") == true)
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#fertilizerExpireDay") then
                                local exp = getXMLInt(xmlFile, pKey .. "#fertilizerExpireDay")
                                if type(exp) == "number"
                                   and type(registry.setFertilizerExpireDay) == "function" then
                                    registry:setFertilizerExpireDay(barnIdx, pIdx, exp)
                                end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#renewalUnlockDay")
                               and type(registry.setRenewalUnlockDay) == "function" then
                                local rd = getXMLInt(xmlFile, pKey .. "#renewalUnlockDay")
                                if type(rd) == "number" then
                                    registry:setRenewalUnlockDay(barnIdx, pIdx, rd)
                                end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#limeCorrection")
                               and type(registry.setLimeCorrection) == "function" then
                                local lc = getXMLFloat(xmlFile, pKey .. "#limeCorrection")
                                if type(lc) == "number" then
                                    registry:setLimeCorrection(barnIdx, pIdx, lc)
                                end
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#pfSampled")
                               and getXMLBool(xmlFile, pKey .. "#pfSampled") == true
                               and type(registry.setPFSampled) == "function" then
                                registry:setPFSampled(barnIdx, pIdx, true)
                            end
                            if hasXMLProperty(xmlFile, pKey .. "#fertPainted")
                               and getXMLBool(xmlFile, pKey .. "#fertPainted") == true
                               and type(registry.setFertPainted) == "function" then
                                registry:setFertPainted(barnIdx, pIdx, true)
                                -- Restore the ORIGINAL paint day (setFertPainted just
                                -- stamped today) so the timed fade doesn't reset on load.
                                if hasXMLProperty(xmlFile, pKey .. "#fertPaintDay")
                                   and type(registry.setFertPaintDay) == "function" then
                                    registry:setFertPaintDay(barnIdx, pIdx,
                                        getXMLInt(xmlFile, pKey .. "#fertPaintDay"))
                                end
                            end
                            -- Restore an in-progress fertilizer spread into
                            -- SprayerHook's pending map (keyed by placeable). It's
                            -- applied when the coverage grid is next built, if the
                            -- pasture geometry still matches.
                            local SH = rawget(_G, "SprayerHook")
                            if SH ~= nil and hasXMLProperty(xmlFile, pKey .. "#fertCovered") then
                                local covStr = getXMLString(xmlFile, pKey .. "#fertCovered") or ""
                                local covered = {}
                                for tok in covStr:gmatch("%S+") do
                                    local idx = tonumber(tok)
                                    if idx ~= nil then covered[idx] = true end
                                end
                                if next(covered) ~= nil then
                                    SH._pendingCoverage = SH._pendingCoverage or {}
                                    SH._pendingCoverage[pPlaceable] = {
                                        covered = covered,
                                        cellsX = getXMLInt(xmlFile, pKey .. "#fertCellsX") or 0,
                                        cellsZ = getXMLInt(xmlFile, pKey .. "#fertCellsZ") or 0,
                                        insideCount = getXMLInt(xmlFile, pKey .. "#fertInsideCount") or 0,
                                    }
                                end
                            end
                            -- Restore the pre-scale meadow capacity snapshot
                            -- BEFORE the scaler's first frame. Without this
                            -- the scaler would snapshot the already-scaled
                            -- cap on reload and multiply again, exploding the
                            -- cap after each save/reload cycle.
                            local pMeadow = pPlaceable.spec_husbandryMeadow
                            if pMeadow ~= nil then
                                local sI = 0
                                while true do
                                    local sKey = string.format("%s.origMeadow(%d)", pKey, sI)
                                    if not hasXMLProperty(xmlFile, sKey) then break end
                                    local ft = getXMLInt(xmlFile, sKey .. "#ft")
                                    local origCap = getXMLInt(xmlFile, sKey .. "#cap")
                                    if type(ft) == "number" and type(origCap) == "number" then
                                        pMeadow._phOriginalMeadowCapacity =
                                            pMeadow._phOriginalMeadowCapacity or {}
                                        pMeadow._phOriginalMeadowCapacity[ft] = origCap
                                    end
                                    sI = sI + 1
                                end
                            end
                            -- Restore waypoint path for this pasture
                            local wI = 0
                            while true do
                                local wKey = string.format("%s.waypoint(%d)", pKey, wI)
                                if not hasXMLProperty(xmlFile, wKey) then break end
                                local wx = getXMLFloat(xmlFile, wKey .. "#x")
                                local wz = getXMLFloat(xmlFile, wKey .. "#z")
                                if wx ~= nil and wz ~= nil then
                                    registry:addWaypoint(barnIdx, pIdx, wx, wz)
                                end
                                wI = wI + 1
                            end
                        end
                    end
                    pI = pI + 1
                    end
                end
                -- Restore pasture-pasture paths. Saved from/to are 1-based
                -- pasture slots at save time; re-key them through slotToPIdx so
                -- a pasture that failed to resolve (shifting indices) can't
                -- mis-point a pair. Skip a pair if either end didn't restore.
                local ppI = 0
                while true do
                    local ppKey = string.format("%s.pasturePair(%d)", barnKey, ppI)
                    if not hasXMLProperty(xmlFile, ppKey) then break end
                    local savedFrom = getXMLInt(xmlFile, ppKey .. "#from")
                    local savedTo = getXMLInt(xmlFile, ppKey .. "#to")
                    local fromP = savedFrom and slotToPIdx[savedFrom - 1] or nil
                    local toP = savedTo and slotToPIdx[savedTo - 1] or nil
                    if fromP ~= nil and toP ~= nil and fromP ~= toP then
                        local wI = 0
                        while true do
                            local wKey = string.format("%s.waypoint(%d)", ppKey, wI)
                            if not hasXMLProperty(xmlFile, wKey) then break end
                            local wx = getXMLFloat(xmlFile, wKey .. "#x")
                            local wz = getXMLFloat(xmlFile, wKey .. "#z")
                            if wx ~= nil and wz ~= nil then
                                registry:addPairWaypoint(barnIdx, fromP, toP, wx, wz)
                            end
                            wI = wI + 1
                        end
                    else
                        PastureHerdingLog.warning("GrazingPersistence: pasture-pair (slot %s->%s) for barn %d did not fully resolve; skipping",
                            tostring(savedFrom), tostring(savedTo), barnIdx)
                    end
                    ppI = ppI + 1
                end
                if restoredActivePIdx ~= nil then
                    registry:setActivePasture(barnIdx, restoredActivePIdx)
                elseif activeIdx > 0 then
                    -- Saved active pasture didn't resolve on load (removed/moved).
                    -- Leave the registry's default active index rather than
                    -- pointing at a shifted, wrong pasture.
                    PastureHerdingLog.warning("GrazingPersistence: saved active pasture (slot %d) did not restore for barn %d; keeping default active index",
                        activeIdx, barnIdx)
                end
                if autoRotate then
                    registry:setAutoRotate(barnIdx, true)
                end
                if hasXMLProperty(xmlFile, barnKey .. "#autoSchedule") then
                    -- `== true` mirrors the save side (`autoSchedule == true`); `~= false`
                    -- would turn a missing/unparseable key into ON, flipping the OFF default.
                    registry:setAutoSchedule(barnIdx, getXMLBool(xmlFile, barnKey .. "#autoSchedule") == true)
                end
                if hasXMLProperty(xmlFile, barnKey .. "#troughEnabled") then
                    registry:setTroughEnabled(barnIdx, getXMLBool(xmlFile, barnKey .. "#troughEnabled") == true)
                end
                if hasXMLProperty(xmlFile, barnKey .. "#customName") then
                    local bp = registry:getBarn(barnIdx)
                    if bp ~= nil then bp.customName = getXMLString(xmlFile, barnKey .. "#customName") end
                end
                if hasXMLProperty(xmlFile, barnKey .. "#herdType") then
                    registry:setHerdType(barnIdx, getXMLString(xmlFile, barnKey .. "#herdType"))
                end
                if hasXMLProperty(xmlFile, barnKey .. "#beefPhase") then
                    registry:setBeefPhase(barnIdx, getXMLString(xmlFile, barnKey .. "#beefPhase"))
                end
                if hasXMLProperty(xmlFile, barnKey .. "#grazingCondition")
                   and type(registry.setGrazingCondition) == "function" then
                    registry:setGrazingCondition(barnIdx, getXMLFloat(xmlFile, barnKey .. "#grazingCondition"))
                end
                -- Beef manual-out override (player put them out by hand; keep
                -- them out on load even off-season). Restored before
                -- reconcileOnLoad so it honours the flag.
                if scheduler ~= nil and hasXMLProperty(xmlFile, barnKey .. "#beefManualOut")
                   and getXMLBool(xmlFile, barnKey .. "#beefManualOut") == true then
                    scheduler._beefManualOut = scheduler._beefManualOut or {}
                    scheduler._beefManualOut[barnIdx] = true
                end
                if hasXMLProperty(xmlFile, barnKey .. "#sunriseHour") then
                    registry:setBarnSunrise(barnIdx, getXMLInt(xmlFile, barnKey .. "#sunriseHour"))
                end
                if hasXMLProperty(xmlFile, barnKey .. "#sunsetHour") then
                    registry:setBarnSunset(barnIdx, getXMLInt(xmlFile, barnKey .. "#sunsetHour"))
                end
                if hasXMLProperty(xmlFile, barnKey .. "#grazingStartHour")
                   and type(registry.setGrazingStartHour) == "function" then
                    registry:setGrazingStartHour(barnIdx, getXMLInt(xmlFile, barnKey .. "#grazingStartHour"))
                end

                -- Restore scheduler state. Order of trust:
                --  1. Real animal counts (authoritative — RLM already restored animals)
                --  2. Saved XML state (correct unless animals moved while we were unloaded)
                --  3. Default "at_barn"
                -- If saved state disagrees with reality, reality wins and we log it.
                if scheduler ~= nil and scheduler.state ~= nil then
                    local savedState = nil
                    if hasXMLProperty(xmlFile, barnKey .. "#state") then
                        savedState = getXMLString(xmlFile, barnKey .. "#state")
                    end

                    local barnN = countHusbandryAnimals(barnPlaceable)
                    local pastureN = 0
                    local restoredBarn = registry:getBarn(barnIdx)
                    if restoredBarn ~= nil then
                        for _, p in ipairs(restoredBarn.pastures) do
                            -- Skip SHARED pastures (owned by more than one barn):
                            -- their physical animals may belong to the other barn,
                            -- so counting them here would falsely infer this barn
                            -- is out. Shared-pasture placement is resolved
                            -- authoritatively by reconcileOnLoad (which runs next
                            -- with occupant/in-transit guards).
                            local shared = false
                            if type(registry._ownersOf) == "function" then
                                local owners = registry:_ownersOf(p)
                                shared = type(owners) == "table" and #owners > 1
                            end
                            if not shared then
                                pastureN = pastureN + countHusbandryAnimals(p)
                            end
                        end
                    end

                    local inferred = nil
                    if pastureN > 0 and barnN == 0 then
                        inferred = "at_pasture"
                    elseif barnN > 0 and pastureN == 0 then
                        inferred = "at_barn"
                    end

                    local finalState
                    if inferred ~= nil then
                        finalState = inferred
                        if savedState ~= nil and savedState ~= inferred then
                            PastureHerdingLog.dev(
                                "GrazingPersistence: barn#%d saved state=%s but animals are at %s (barn=%d, pasture=%d); using %s",
                                barnIdx, savedState, inferred, barnN, pastureN, inferred)
                        end
                    elseif savedState ~= nil then
                        finalState = savedState
                    else
                        finalState = "at_barn"
                    end
                    scheduler.state[barnIdx] = finalState
                    PastureHerdingLog.dev("GrazingPersistence: barn#%d restored state=%s (barn=%d, pasture=%d, saved=%s)",
                        barnIdx, finalState, barnN, pastureN, tostring(savedState))
                end
            end
        end
        barnI = barnI + 1
    end

    delete(xmlFile)
    PastureHerdingLog.dev("GrazingPersistence: restored %d barn(s), %d pasture(s); %d entry(ies) couldn't resolve",
        barnsRestored, pasturesRestored, missing)
    return true
end
