-- HealthTracer — a read-only observability probe for animal health, location
-- and activity over time. Built to answer ONE question during testing:
-- "when animals move barn->pasture or pasture->pasture, does their health
-- drop, and if so WHERE (which husbandry / which factor) and WHEN (which
-- hour tick / which frame of the move-storm)?"
--
-- Design goals:
--   * NON-INVASIVE. This module never writes to animal.health or any spec.
--     It only reads. So it measures the CURRENT behaviour of the mod + RLM
--     honestly — good for establishing a plain-RLM baseline vs. our move path.
--   * Follows INDIVIDUAL animals across moves via their stable RLM uniqueId
--     (animal.id "husbandryIdx-slot" changes on move; uniqueId does not).
--   * Sees EVERY husbandry in the world, registered with the grazing system
--     or not — so you can trace a plain "normal mode" shed as a control.
--   * Emits CSV rows tagged [PHTrace] so `grep "\[PHTrace\]" log.txt` gives a
--     spreadsheet-ready timeline. No file IO, no sandbox surprises.
--
-- Usage (console):
--   phTraceStart [sec]   begin sampling every [sec] real seconds (default 5)
--                        + one forced sample on every in-game hour change
--   phTraceMark <label>  drop a labelled marker row (e.g. "BEFORE MOVE")
--   phTraceSnap          emit one sample set right now (works while stopped)
--   phTraceStop          stop + print a per-animal min/max/delta summary

HealthTracer = {}

local TAG = "[PHTrace]"

HealthTracer.running = false
HealthTracer.intervalMs = 5000
HealthTracer._accMs = 0
HealthTracer._lastHour = nil
HealthTracer._seq = 0
-- Per-uniqueId running summary for the active session: first/last/min health
-- and where the minimum was observed.
HealthTracer._summary = nil

-- ---------------------------------------------------------------------------
-- Small read helpers (mirror the shapes describeHusbandry already uses).
-- ---------------------------------------------------------------------------

local function raw(fmt, ...)
    -- Route through the engine log directly with our own tag so these lines
    -- are trivially greppable and DON'T carry the [PastureHerding] prefix
    -- (keeps the CSV columns clean). Pass the composed line as a single %s
    -- arg so a stray '%' in a husbandry name can't be read as a format spec.
    Logging.info("%s", TAG .. " " .. string.format(fmt, ...))
end

local function sanitize(s)
    s = tostring(s or "")
    s = s:gsub(",", " ")   -- never break the CSV
    s = s:gsub("[\r\n]", " ")
    return s
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 pct(fill, cap)
    if type(cap) ~= "number" or cap <= 0 then return -1 end
    return (fill / cap) * 100
end

-- Collect every husbandry placeable in the world, de-duplicated across the
-- two lists the rest of the mod already reads (husbandrySystem +
-- placeableSystem). A husbandry is anything carrying spec_husbandryAnimals.
local function enumerateHusbandries()
    local seen, out = {}, {}
    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 _, list in ipairs(sources) do
        for _, p in pairs(list) do
            if p ~= nil and not seen[p] and p.spec_husbandryAnimals ~= nil then
                seen[p] = true
                out[#out + 1] = p
            end
        end
    end
    return out
end

-- Classify a husbandry placeable relative to the grazing registry.
local function classifyLocation(placeable)
    local name = (placeable.getName and placeable:getName()) or "husbandry"
    local reg = rawget(_G, "g_pastureHerding")
    reg = reg and reg.grazingRegistry
    local ref = reg and reg.byPlaceable and reg.byPlaceable[placeable]
    if ref == nil then
        return "UNREG:" .. sanitize(name)
    end
    if ref.kind == "barn" then
        return "barn#" .. tostring(ref.barnIdx)
    end
    -- pasture
    local barn = reg.barns and reg.barns[ref.barnIdx]
    local active = barn ~= nil and barn.activeIndex == ref.pastureIdx
    return string.format("pasture#%d.%d%s", ref.barnIdx, ref.pastureIdx, active and "(active)" or "")
end

-- Husbandry-level factors that RLM ties per-animal health to.
local function husbandryFactors(placeable)
    local hSpec = placeable.spec_husbandry
    local prodF   = hSpec and hSpec.productionFactor
    local gProdF  = hSpec and hSpec.globalProductionFactor

    local food = placeable.spec_husbandryFood
    local foodPct = -1
    if food ~= nil then foodPct = pct(sumFillLevels(food), food.capacity) end

    -- Option-A probe: litersPerHour is the herd's food DEMAND that vanilla
    -- updateFeeding reads. If it reads 0 on the first hour tick after a move,
    -- foodFactor -> 0 -> RLRM docks health. With the fix it should be > 0 on
    -- arrival. totalFood is the trough level (should stay full throughout).
    local litersPerHour = -1
    if food ~= nil and type(food.litersPerHour) == "number" then litersPerHour = food.litersPerHour end
    local totalFood = -1
    if type(placeable.getTotalFood) == "function" then
        local okT, tf = pcall(function() return placeable:getTotalFood() end)
        if okT and type(tf) == "number" then totalFood = tf end
    end

    local meadow = placeable.spec_husbandryMeadow
    local meadowPct = -1
    if meadow ~= nil and type(meadow.fillLevels) == "table" then
        local cap = 0
        if type(meadow.capacities) == "table" then
            for _, c in pairs(meadow.capacities) do
                if type(c) == "number" then cap = cap + c end
            end
        end
        meadowPct = pct(sumFillLevels(meadow), cap)
    end

    -- True foodFactor: the value the BASE GAME passes to cluster:updateHealth,
    -- captured by our updateOutput hook (see installHooks). On a registered
    -- pasture the prodFactor column is the mod's productivity FLOOR (overwritten
    -- every frame), NOT the real foodFactor — this column is the honest one.
    local trueFoodFactor = -1
    if type(placeable._phLastFoodFactor) == "number" then trueFoodFactor = placeable._phLastFoodFactor end

    local water = placeable.spec_husbandryWater
    local waterPct = -1
    if water ~= nil and type(placeable.getHusbandryFillLevel) == "function" then
        local fl = placeable:getHusbandryFillLevel(water.fillType) or 0
        local cap = (type(placeable.getHusbandryCapacity) == "function"
            and placeable:getHusbandryCapacity(water.fillType)) or 0
        if water.automaticWaterSupply == true then
            waterPct = 100     -- auto-supply: effectively always full
        else
            waterPct = pct(fl, cap)
        end
    end

    return prodF, gProdF, foodPct, waterPct, meadowPct, litersPerHour, totalFood, trueFoodFactor
end

local function subTypeName(cluster)
    local st = (type(cluster.getSubType) == "function") and cluster:getSubType() or cluster.subType
    if type(st) == "table" then return sanitize(st.name or "?") end
    return sanitize(st)
end

local function isInTransit(cluster)
    local fw = rawget(_G, "g_pastureHerding")
    local tt = fw and fw.transitTracker
    if tt == nil or type(tt.isInTransit) ~= "function" then return false end
    local ok, res = pcall(function() return tt:isInTransit(cluster) end)
    return ok and res == true
end

-- ---------------------------------------------------------------------------
-- Sampling
-- ---------------------------------------------------------------------------

local function gameClock()
    local env = g_currentMission and g_currentMission.environment
    local day  = env and env.currentDay or -1
    local hour = env and env.currentHour or -1
    if type(hour) == "number" then hour = math.floor(hour) end
    local timeMs = (g_currentMission and g_currentMission.time) or 0
    return day, hour, timeMs
end

function HealthTracer.emitHeader()
    raw("HEADER,seq,day,hour,timeMs,reason,uniqueId,idSlot,subType,age,count,health,location,doing,prodFactor,globalProdFactor,food%%,water%%,meadow%%,litersPerHour,totalFood,trueFoodFactor")
end

-- reason: SAMPLE | HOUR | SNAP | <marker label>
function HealthTracer.emitSample(reason)
    HealthTracer._seq = HealthTracer._seq + 1
    local seq = HealthTracer._seq
    local day, hour, timeMs = gameClock()
    reason = sanitize(reason or "SAMPLE")

    local rows = 0
    for _, placeable in ipairs(enumerateHusbandries()) do
        local spec = placeable.spec_husbandryAnimals
        local cs = spec and spec.clusterSystem
        if cs ~= nil then
            local location = classifyLocation(placeable)
            local prodF, gProdF, foodPct, waterPct, meadowPct, litersPerHour, totalFood, trueFoodFactor = husbandryFactors(placeable)
            local list = (cs.getAnimals and cs:getAnimals()) or cs.animals or {}
            for _, c in pairs(list) do
                if type(c) == "table" then
                    local uid    = c.uniqueId or c.id or "?"
                    local slot   = c.id or "?"
                    local health = (type(c.health) == "number") and c.health or -1
                    local count  = c.numAnimals or 1
                    local age    = (type(c.age) == "number") and c.age or -1
                    local doing  = isInTransit(c) and "walking" or "resident"
                    raw("SAMPLE,%d,%d,%d,%.0f,%s,%s,%s,%s,%s,%d,%.1f,%s,%s,%.3f,%.3f,%.1f,%.1f,%.1f,%.2f,%.0f,%.3f",
                        seq, day, hour, timeMs, reason,
                        sanitize(uid), sanitize(slot), subTypeName(c), tostring(age), count, health,
                        location, doing,
                        prodF or -1, gProdF or -1, foodPct, waterPct, meadowPct, litersPerHour, totalFood, trueFoodFactor)
                    rows = rows + 1

                    -- Fold into the running session summary keyed by uniqueId.
                    local sum = HealthTracer._summary
                    if sum ~= nil and health >= 0 then
                        local key = tostring(uid)
                        local e = sum[key]
                        if e == nil then
                            e = { first = health, firstLoc = location, min = health, minLoc = location,
                                  minReason = reason, last = health, lastLoc = location, subType = subTypeName(c) }
                            sum[key] = e
                        end
                        e.last = health
                        e.lastLoc = location
                        if health < e.min then
                            e.min = health; e.minLoc = location; e.minReason = reason
                        end
                    end
                end
            end
        end
    end
    return rows
end

function HealthTracer.tick(dt)
    if not HealthTracer.running then return end
    -- Forced sample on in-game hour change (RLM's health/feeding runs hourly,
    -- so the hour boundary is the most diagnostic single moment).
    local _, hour = gameClock()
    if hour ~= HealthTracer._lastHour then
        HealthTracer._lastHour = hour
        HealthTracer.emitSample("HOUR")
        HealthTracer._accMs = 0
        return
    end
    HealthTracer._accMs = HealthTracer._accMs + (dt or 0)
    if HealthTracer._accMs >= HealthTracer.intervalMs then
        HealthTracer._accMs = 0
        HealthTracer.emitSample("SAMPLE")
    end
end

-- ---------------------------------------------------------------------------
-- Console commands
-- ---------------------------------------------------------------------------

function HealthTracer:consoleTraceStart(intervalStr)
    local sec = tonumber(intervalStr)
    if type(sec) == "number" and sec > 0 then
        HealthTracer.intervalMs = math.floor(sec * 1000)
    else
        HealthTracer.intervalMs = 5000
    end
    HealthTracer.running = true
    HealthTracer._accMs = 0
    HealthTracer._lastHour = nil
    HealthTracer._seq = 0
    HealthTracer._summary = {}
    HealthTracer.emitHeader()
    local rows = HealthTracer.emitSample("START")
    return string.format("%s tracing STARTED: every %.1fs + every game-hour. First sample: %d animal-rows. Grep '%s' in log.txt for CSV.",
        TAG, HealthTracer.intervalMs / 1000, rows, TAG)
end

function HealthTracer:consoleTraceMark(...)
    local parts = { ... }
    local label = table.concat(parts, " ")
    if label == "" then label = "MARK" end
    local rows = HealthTracer.emitSample("MARK:" .. label)
    return string.format("%s marker '%s' written (%d animal-rows).", TAG, label, rows)
end

function HealthTracer:consoleTraceSnap()
    if HealthTracer._seq == 0 then HealthTracer.emitHeader() end
    local rows = HealthTracer.emitSample("SNAP")
    return string.format("%s snapshot written (%d animal-rows).", TAG, rows)
end

function HealthTracer:consoleTraceStop()
    if not HealthTracer.running and HealthTracer._summary == nil then
        return TAG .. " tracing was not running."
    end
    HealthTracer.running = false
    local sum = HealthTracer._summary or {}
    -- Sort keys for stable output.
    local keys = {}
    for k in pairs(sum) do keys[#keys + 1] = k end
    table.sort(keys)
    raw("SUMMARY,uniqueId,subType,firstHealth,lastHealth,minHealth,delta,minAt,minReason")
    local dropped = 0
    for _, k in ipairs(keys) do
        local e = sum[k]
        local delta = e.last - e.first
        if delta < 0 then dropped = dropped + 1 end
        raw("SUMMARY,%s,%s,%.1f,%.1f,%.1f,%+.1f,%s,%s",
            sanitize(k), sanitize(e.subType), e.first, e.last, e.min, delta,
            sanitize(e.minLoc), sanitize(e.minReason))
    end
    HealthTracer._summary = nil
    return string.format("%s tracing STOPPED. %d animals tracked, %d ended below their start health. Grep 'SUMMARY' in the [PHTrace] lines.",
        TAG, #keys, dropped)
end

-- Install the foodFactor-capture hook. Appends to PlaceableHusbandryAnimals:
-- updateOutput, which the base game calls each hour with the real foodFactor
-- right before cluster:updateHealth(foodFactor). We stash it on the placeable
-- so the tracer's trueFoodFactor column reflects the value health is actually
-- computed from (the prodFactor column is contaminated by the mod's floor on
-- registered pastures). Best-effort: guarded, no-op if the class isn't ready.
function HealthTracer.installHooks()
    if HealthTracer._hooksInstalled then return end
    -- Plain access: PlaceableHusbandryAnimals is an engine class reached via the
    -- sandbox __index metatable; rawget bypasses it and always returns nil.
    local cls = PlaceableHusbandryAnimals
    if cls == nil or type(cls.updateOutput) ~= "function" then
        PastureHerdingLog.warning("HealthTracer: PlaceableHusbandryAnimals.updateOutput not ready; foodFactor capture NOT installed")
        return
    end
    if type(Utils) ~= "table" or type(Utils.appendedFunction) ~= "function" then return end
    cls.updateOutput = Utils.appendedFunction(cls.updateOutput, function(self, a, b)
        -- updateOutput is an overwritten spec fn (self, superFunc, foodFactor, ...);
        -- capture the foodFactor whichever arg position it lands in.
        local ff = (type(b) == "number") and b or (type(a) == "number" and a or nil)
        if ff ~= nil then self._phLastFoodFactor = ff end
    end)
    HealthTracer._hooksInstalled = true
    PastureHerdingLog.dev("HealthTracer: foodFactor capture hook installed on PlaceableHusbandryAnimals.updateOutput")
end

PastureHerdingLog.dev("HealthTracer loaded")
