-- A timed group walk for one transition (barn -> pasture or pasture -> barn).
-- Spawns N walkers one at a time on a configurable interval, watches their
-- progress, fires a completion callback when they've all arrived.
--
-- Lifecycle:
--   pending  -> first tick starts the spawn timer
--   spawning -> spawnCounter < N; timer fires a new walker every spacingMs
--   walking  -> all N spawned; waiting for them to finish their walks
--   done     -> all walkers reached their destination; onComplete() invoked once

WalkSession = {}
local WalkSession_mt = Class(WalkSession)

WalkSession.DEFAULT_SPACING_MS = 3000

-- Realism / anti-conga-line: instead of walking single-file at identical
-- spacing on the exact centre line, each walker gets a small random lateral
-- offset and a slightly different speed, and the spawn interval is jittered.
-- The herd spreads across the track width and naturally clumps/strings out.
-- Tune to taste (all three are independent).
WalkSession.LANE_HALF_WIDTH_M = 1.75   -- each walker offset within ±this many metres of the centre line
WalkSession.SPEED_JITTER      = 0.12   -- per-walker speed multiplier is 1 ± this (±12%)
WalkSession.SPACING_JITTER    = 0.45   -- each spawn interval is spacingMs × (1 ± this) (±45%)

-- Random value in [-1, 1]. math.random is fine here — purely visual, SP-only.
local function jitterUnit()
    return math.random() * 2 - 1
end

-- animals: array of RLM animal records (each becomes one walker, in order)
function WalkSession.new(sourcePlaceable, destPlaceable, pathPoints, animals, opts)
    opts = opts or {}
    if pathPoints == nil or #pathPoints < 2 then
        PastureHerdingLog.warning("WalkSession.new: need at least 2 path points")
        return nil
    end
    if animals == nil or #animals == 0 then
        PastureHerdingLog.warning("WalkSession.new: animals list is empty")
        return nil
    end

    local self = setmetatable({}, WalkSession_mt)
    self.sourcePlaceable = sourcePlaceable
    self.destPlaceable = destPlaceable
    self.pathPoints = pathPoints
    self.animals = animals
    self.count = #animals
    self.spacingMs = opts.spacingMs or WalkSession.DEFAULT_SPACING_MS
    self.speed = opts.speed or Walker.DEFAULT_SPEED
    self.onComplete = opts.onComplete
    self.animalManager = opts.animalManager
    self.transitTracker = opts.transitTracker -- if set, per-walker move flow is active
    self.animalMover = opts.animalMover

    self.spawned = 0
    self.spawnTimerMs = 0

    -- Snapshot the meadow of whichever placeable in this walk has one.
    -- IN walks: source is the pasture -> freeze source meadow so vanilla
    -- doesn't drain it as the cluster drifts back to the barn.
    -- OUT walks: destination is the pasture -> freeze destination meadow
    -- so it doesn't drop before any walker has actually arrived. Per-walker
    -- moveOne dispatches still happen and grass IS consumed once cows
    -- visibly arrive at the pasture (the snapshot only freezes during the
    -- walk; on session-end the freeze is released so vanilla resumes
    -- normal consumption from the cluster size at that point).
    local function snapshotMeadow(placeable)
        if placeable == nil then return nil end
        local m = placeable.spec_husbandryMeadow
        if m == nil or type(m.fillLevels) ~= "table" then return nil end
        local snap = {}
        for ft, v in pairs(m.fillLevels) do
            if type(v) == "number" then snap[ft] = v end
        end
        return { meadow = m, levels = snap }
    end
    self.sourceMeadowSnapshot = snapshotMeadow(sourcePlaceable)
    self.destMeadowSnapshot = snapshotMeadow(destPlaceable)
    self.walkers = {}
    self.state = "pending"
    self.label = opts.label or "?"
    -- Gates on this path (fence/hedgerow gates the herd walks through). Opened as
    -- the herd approaches, closed once it's all through. Detected once, up front.
    self.gates = nil
    local framework = rawget(_G, "g_pastureHerding")
    -- Auto-gate mode is a 3-way setting:
    --   "all"      → open gates on EVERY walk (manual + herdsman)
    --   "herdsman" → open gates only on AUTOMATIC (herdsman) walks, not on the
    --                player's own Send Out / Bring In / Move here
    --   "off"      → never
    -- A walk is "manual" when its reason is a direct player action.
    local gateMode = (framework and framework.gateAutoMode) or "all"
    local reason = opts.reason
    local isManual = (reason == "manual" or reason == "menu")
    local gateOn = (gateMode == "all") or (gateMode == "herdsman" and not isManual)
    if gateOn and rawget(_G, "GateController") ~= nil then
        local ok, gates = pcall(function() return GateController.detectOnPath(pathPoints) end)
        if ok and type(gates) == "table" and #gates > 0 then
            self.gates = gates
            -- Open EVERY gate on the path immediately at the START of the walk,
            -- and leave them all open for the whole walk. They're closed together
            -- once the last animal has arrived (see _closeAllGates at session
            -- end). This guarantees every gate — near or far — is open before the
            -- herd reaches it, and none shut in the herd's face mid-route.
            for _, gate in ipairs(gates) do
                gate.opened = true
                local m = GateController.drive(gate.ao, true)
                PastureHerdingLog.dev("WalkSession[%s]: opening gate '%s' at walk start (%s)",
                    self.label or "?", tostring(gate.ao and gate.ao.saveId), tostring(m))
            end
            PastureHerdingLog.dev("WalkSession[%s]: %d gate(s) on path", self.label or "?", #gates)
        end
    end
    return self
end

function WalkSession:_spawnOne()
    local nextIdx = self.spawned + 1
    local animal = self.animals[nextIdx]
    if animal == nil then
        -- No animal at this slot — a spurious spawn tick after the herd is
        -- already fully out (see the one-animal off-by-one). Count it and finish
        -- rather than feeding a nil record into Walker.new (would crash on
        -- animal.visualAnimalIndex inside spawnViaHerdingLite).
        self.spawned = self.spawned + 1
        if self.spawned >= self.count then self.state = "walking" end
        return false
    end

    -- IMPORTANT ORDER: capture the animal's render data from the SOURCE
    -- husbandry BEFORE firing the move. After AnimalMoveEvent runs, RLM
    -- re-keys animal.id to the destination, so any later lookup from source
    -- returns nothing useful (we get the wrong texture).
    local capturedTiles = Walker.captureAnimalTiles(animal, self.sourcePlaceable)

    -- Per-walker flow: mark in-transit now, but DON'T evict the source visual
    -- yet. Eviction (Walker.hideSourceVisual) also nulls animal.id, so doing it
    -- before Walker.new would strand the cow on a spawn failure (visual gone +
    -- id nulled + never moved) AND kill the bare-clone fallback (which needs a
    -- live source visual + string id). We evict only AFTER Walker.new succeeds.
    -- The captured tiles (grabbed above, pre-eviction) are what the HL path
    -- uses, so deferring eviction doesn't affect the spawned walker's textures.
    local perWalkerMove = self.transitTracker ~= nil and self.animalMover ~= nil and self.destPlaceable ~= nil
    if perWalkerMove then
        self.transitTracker:markInTransit(animal, nil)
    end

    -- Per-walker stagger: a small lateral offset off the centre line + a slight
    -- speed variation so the herd doesn't march in a perfect single-file line.
    local laneOffset = jitterUnit() * WalkSession.LANE_HALF_WIDTH_M
    local walkerSpeed = self.speed * (1 + jitterUnit() * WalkSession.SPEED_JITTER)
    local walker = Walker.new(self.sourcePlaceable, self.pathPoints, walkerSpeed, animal, self.animalManager, capturedTiles, laneOffset)
    if walker == nil then
        PastureHerdingLog.warning("WalkSession[%s]: spawn failed at #%d/%d (animal=%s); skipping (source visual left intact)",
            self.label, nextIdx, self.count, tostring(animal and animal.id or "<nil>"))
        if perWalkerMove then self.transitTracker:clearInTransit(animal) end
        self.spawned = self.spawned + 1
        if self.spawned >= self.count then self.state = "walking" end
        return false
    end
    if perWalkerMove then
        -- Walker is up: now evict the source husbandry visual (nulls animal.id)
        -- and record the walker against the in-transit animal.
        Walker.hideSourceVisual(animal, self.sourcePlaceable)
        self:_pokeSourceRedraw()
        self.transitTracker:markInTransit(animal, walker)
    end
    walker._wsAnimal = animal -- so we can clear on arrival
    self.spawned = self.spawned + 1
    self.walkers[#self.walkers + 1] = walker
    PastureHerdingLog.dev("WalkSession[%s]: spawned walker %d/%d (animal %s%s)",
        self.label, self.spawned, self.count, tostring(animal.id),
        perWalkerMove and ", in-transit" or "")
    if self.spawned >= self.count then
        self.state = "walking"
    end
    return true
end

function WalkSession:update(dt)
    dt = dt or 0
    -- Freeze any pasture meadow involved in this walk. Restore fillLevels
    -- that vanilla drained back to their session-start value. We only
    -- LIFT (never lower), so any regrowth drip during the walk is preserved.
    -- IN walks freeze source (pasture -> barn); OUT walks freeze destination
    -- (barn -> pasture).
    local function holdMeadow(snap)
        if snap == nil then return end
        local m = snap.meadow
        local levels = m and m.fillLevels
        if type(levels) ~= "table" then return end
        for ft, savedFill in pairs(snap.levels) do
            local cur = levels[ft]
            if type(cur) == "number" and cur < savedFill then
                levels[ft] = savedFill
            end
        end
    end
    holdMeadow(self.sourceMeadowSnapshot)
    holdMeadow(self.destMeadowSnapshot)
    if self.state == "pending" then
        self:_spawnOne()
        -- _spawnOne may already have advanced to "walking" (a ONE-animal herd
        -- spawns its only walker on this first tick). Don't clobber that back to
        -- "spawning", or a phantom extra spawn fires next tick with
        -- self.animals[2] == nil -> crash. Only advance if still pending.
        if self.state == "pending" then
            self.state = "spawning"
        end
        self.spawnTimerMs = 0
        self.nextSpacingMs = self.spacingMs * (1 + jitterUnit() * WalkSession.SPACING_JITTER)
    elseif self.state == "spawning" then
        self.spawnTimerMs = self.spawnTimerMs + dt
        local interval = self.nextSpacingMs or self.spacingMs
        if self.spawnTimerMs >= interval then
            self.spawnTimerMs = self.spawnTimerMs - interval
            self:_spawnOne()
            -- Fresh jittered interval for the next spawn so gaps aren't uniform.
            self.nextSpacingMs = self.spacingMs * (1 + jitterUnit() * WalkSession.SPACING_JITTER)
        end
    end

    local allDone = true
    for i = #self.walkers, 1, -1 do
        local w = self.walkers[i]
        w:update(dt)
        if w:isDone() then
            -- Arrival: NOW fire the data move barn->pasture (animal was
            -- held at source for the whole walk). Then clear in-transit so
            -- RLM's setClusters lets the destination render it, and poke
            -- the destination husbandry so the visual appears promptly.
            local arrivedAnimal = w._wsAnimal
            if self.transitTracker ~= nil and self.animalMover ~= nil and arrivedAnimal ~= nil then
                local okMove, msg = self.animalMover:moveOne(self.sourcePlaceable, self.destPlaceable, arrivedAnimal)
                if not okMove then
                    PastureHerdingLog.warning("WalkSession[%s]: arrival moveOne failed for animal %s: %s",
                        self.label, tostring(arrivedAnimal and arrivedAnimal.id), tostring(msg))
                end
                self.transitTracker:clearInTransit(arrivedAnimal)
            end
            self:_pokeDestinationRedraw()
            w:delete()
            table.remove(self.walkers, i)
        else
            allDone = false
        end
    end

    if self.state ~= "done" and self.state == "walking" and allDone and self.spawned >= self.count then
        self.state = "done"
        -- Release both meadow freezes. Vanilla husbandry can now resume
        -- normal consumption on whichever pasture(s) are involved.
        self.sourceMeadowSnapshot = nil
        self.destMeadowSnapshot = nil
        -- Gates stayed open for the WHOLE walk; now that the last animal has
        -- arrived at the pasture, close every gate we opened.
        self:_closeAllGates()
        PastureHerdingLog.dev("WalkSession[%s]: all %d walkers arrived", self.label, self.count)
        if self.onComplete ~= nil then
            local ok, err = pcall(self.onComplete)
            if not ok then
                PastureHerdingLog.warning("WalkSession[%s]: onComplete raised: %s", self.label, tostring(err))
            end
        end
    end
end

function WalkSession:isDone()
    return self.state == "done"
end

-- Close any gate we opened. Gates stay OPEN for the entire walk (opened up front
-- in the constructor) and are only closed here, once the last animal has reached
-- the pasture. An earlier version closed each gate as the herd cleared its
-- CLOSE_RADIUS, but a gate far from the barn is already outside that radius when
-- the walk starts, so it slammed shut before the herd ever reached it.
function WalkSession:_closeAllGates()
    if self.gates == nil then return end
    for _, gate in ipairs(self.gates) do
        if gate.opened and not gate.done then
            gate.done = true
            GateController.drive(gate.ao, false)
        end
    end
end

-- After a walker arrives we cleared the in-transit flag. Mark the destination
-- husbandry dirty so RLM's next updateVisuals tick re-draws the new arrival
-- without waiting for some other event to trigger it.
function WalkSession:_pokeDestinationRedraw()
    if self.destPlaceable == nil then return end
    local spec = self.destPlaceable.spec_husbandryAnimals
    if spec == nil then return end
    if spec.clusterHusbandry ~= nil then
        spec.clusterHusbandry.visualUpdatePending = true
    end
    if spec.clusterSystem ~= nil then
        spec.clusterSystem.needsUpdate = true
    end
end

-- After we mark an animal in-transit at spawn we want RLM to hide its
-- existing visual at source on the next tick. Force the source husbandry
-- to re-run updateVisuals so our setClusters filter takes effect.
function WalkSession:_pokeSourceRedraw()
    if self.sourcePlaceable == nil then return end
    local spec = self.sourcePlaceable.spec_husbandryAnimals
    if spec == nil then return end
    if spec.clusterHusbandry ~= nil then
        spec.clusterHusbandry.visualUpdatePending = true
    end
    if spec.clusterSystem ~= nil then
        spec.clusterSystem.needsUpdate = true
    end
end
