--[[
    Horizon Suite - Focus - Animation Engine
    HS OnUpdate: height lerp, entry fade/slide/collapse, objective flash, auto-hide.
    Uses C_Map.GetBestMapForUnit in RunMapCheck for zone change detection.
]]

local addon = _G.HorizonSuite

local HEIGHT_SNAP_THRESHOLD   = 0.5
local DRIFT_THRESHOLD        = 0.1
local FLASH_ALPHA_BY_INTENSITY = { subtle = 0.12, medium = 0.22, strong = 0.35 }
local GROUP_COLLAPSE_TIMEOUT = 2

local anim = addon.FOCUS_ANIM or { dur = 0.4, stagger = 0.05, slideInX = 20, slideOutX = 20, driftOutY = 10 }

-- ============================================================================
-- SHARED TRANSITION HELPERS (progress, easing, state init)
-- ============================================================================

--- Returns progress p in [0,1] from elapsed time, optional delay, and duration.
local function GetProgress(elapsed, delay, duration)
    local t = math.max(0, (elapsed or 0) - (delay or 0))
    return math.min(t / (duration or anim.dur), 1)
end

--- Sets entry to fadein state. staggerIndex is 0-based (0 = no delay).
function addon.SetEntryFadeIn(entry, staggerIndex)
    if not entry then return end
    entry.animState   = "fadein"
    entry.animTime    = 0
    entry.staggerDelay = (staggerIndex or 0) * anim.stagger
    entry:SetAlpha(0)
end

--- Sets entry to fadeout state.
function addon.SetEntryFadeOut(entry)
    if not entry then return end
    entry.animState   = "fadeout"
    entry.animTime    = 0
end

--- Sets entry to collapsing state. staggerIndex is 0-based.
function addon.SetEntryCollapsing(entry, staggerIndex)
    if not entry then return end
    entry.animState    = "collapsing"
    entry.animTime     = 0
    entry.collapseDelay = (staggerIndex or 0) * anim.stagger
end

--- Sets entry to slide-up state (Y moves from startY to finalY). Used when entries below
--- a collapsed category shift up to fill the gap.
function addon.SetEntrySlideUp(entry, startY)
    if not entry then return end
    entry.animState     = "slideup"
    entry.animTime      = 0
    entry.slideUpStartY = startY
end

-- ============================================================================
-- ANIMATION ENGINE
-- ============================================================================

local HS          = addon.HS
local pool         = addon.pool
local sectionPool  = addon.sectionPool
local scrollChild  = addon.scrollChild
local scrollFrame  = addon.scrollFrame

local function SetPanelHeight(h)
    if InCombatLockdown() then return end
    if addon.GetDB("growUp", false) then
        HS:SetHeight(h)
        return
    end
    local topBefore = HS:GetTop()
    if not topBefore then return end
    -- Clamp height so the panel bottom never goes below the screen edge.
    -- This keeps the scroll-frame visible without repositioning the frame upward.
    local uiBottom = UIParent:GetBottom() or 0
    local maxH = topBefore - uiBottom
    if maxH > 0 then h = math.min(h, maxH) end
    HS:SetHeight(h)
    local topAfter = HS:GetTop()
    if not topAfter then return end
    local drift = topAfter - topBefore
    if math.abs(drift) < DRIFT_THRESHOLD then return end
    local uiTop   = UIParent:GetTop()   or 0
    local uiRight = UIParent:GetRight() or 0
    local right   = HS:GetRight()      or 0
    HS:ClearAllPoints()
    HS:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", right - uiRight, topBefore - uiTop)
end

--- Detects player map changes and invalidates zone task quest cache.
--- Called on a timer; no params or return.
local function RunMapCheck()
    if not addon.focus.enabled or not C_Map or not C_Map.GetBestMapForUnit then return end

    local ctx = addon.ResolvePlayerMapContext and addon.ResolvePlayerMapContext("player") or nil
    local rawMapID = ctx and ctx.rawMapID or C_Map.GetBestMapForUnit("player")
    local zoneMapID = ctx and ctx.zoneMapID or rawMapID

    if zoneMapID and zoneMapID ~= addon.focus.lastZoneMapID then
        addon.focus.lastZoneMapID = zoneMapID
        addon.focus.lastPlayerMapID = rawMapID
        if addon.zoneTaskQuestCache then wipe(addon.zoneTaskQuestCache) end
        if addon.ScheduleRefresh then addon.ScheduleRefresh() end
    elseif not addon.focus.lastZoneMapID and zoneMapID then
        addon.focus.lastZoneMapID = zoneMapID
        addon.focus.lastPlayerMapID = rawMapID
    else
        -- Keep raw mapID updated for debug / proximity checks.
        addon.focus.lastPlayerMapID = rawMapID
    end
end

--- Expose for FocusModule's map ticker.
addon.RunMapCheck = RunMapCheck

-- Ensure HS OnUpdate is running.
--function addon.EnsureFocusUpdateRunning()
--    if not addon.HS or addon._focusUpdateRunning then return end
--    addon._focusUpdateRunning = true
--    addon.HS:SetScript("OnUpdate", function(_, dt)
--        if not addon.focus.enabled then
--            addon._focusUpdateRunning = false
--            addon.HS:SetScript("OnUpdate", nil)
--            return
--        end
--        dt = dt or 0
--        local useAnim = addon.GetDB("animations", true)
--        UpdatePanelHeight(dt)
--        UpdateCombatFade(dt, useAnim)
--        local anyEntryAnimating = UpdateEntryAnimations(dt, useAnim)
--        UpdateCollapseAnimations(dt)
--        UpdateGroupCollapseCompletion()
--        UpdateSectionHeaderSlideUp(dt, useAnim)
--
--        -- Stop the OnUpdate when nothing needs it.
--        local stillAnimating = anyEntryAnimating
--            or addon.focus.collapse.animating
--            or (addon.focus.combat and addon.focus.combat.fadeState ~= nil)
--            or (addon.focus.collapse.groups and next(addon.focus.collapse.groups) ~= nil)
--            or addon.focus.collapse.sectionHeadersFadingOut
--            or addon.focus.collapse.sectionHeadersFadingIn
--        if not stillAnimating then
--            addon._focusUpdateRunning = false
--            addon.HS:SetScript("OnUpdate", nil)
--        end
--    end)
--end

local function UpdatePanelHeight(dt)
    local targetHeight  = addon.focus.layout.targetHeight
    local currentHeight = addon.focus.layout.currentHeight
    if math.abs(currentHeight - targetHeight) > HEIGHT_SNAP_THRESHOLD then
        addon.focus.layout.currentHeight = currentHeight + (targetHeight - currentHeight) * math.min(addon.HEIGHT_SPEED * dt, 1)
        SetPanelHeight(addon.focus.layout.currentHeight)
    elseif currentHeight ~= targetHeight then
        addon.focus.layout.currentHeight = targetHeight
        SetPanelHeight(addon.focus.layout.currentHeight)
    end
end

local function GetFadeOnMouseoverAlpha()
    local pct = tonumber(addon.GetDB("fadeOnMouseoverOpacity", 10)) or 10
    return math.max(0, math.min(100, pct)) / 100
end

--- Returns true if the mouse is over the frame or any of its descendants.
--- Uses GetMouseFocus() for reliable detection when cursor is over child frames
--- (IsMouseOver can be unreliable for parent when mouse is on a child).
local function IsMouseOverFrameOrDescendants(frame)
    if not frame then return false end
    local focus = GetMouseFocus and GetMouseFocus()
    if focus then
        local f = focus
        while f do
            if f == frame then return true end
            f = f.GetParent and f:GetParent()
        end
    end
    if frame.IsMouseOver and frame:IsMouseOver() then return true end
    if not frame.GetChildren then return false end
    local children = { frame:GetChildren() }
    for i = 1, #children do
        if children[i] and IsMouseOverFrameOrDescendants(children[i]) then return true end
    end
    return false
end

--- Returns true if the mouse is over the main tracker, M+ block, or floating quest item.
--- Unified hover boundary so fade-out does not trigger when moving between related UI.
function addon.IsFocusHoverActive()
    if IsMouseOverFrameOrDescendants(HS) then return true end
    local mplus = addon.mplusBlock
    if mplus and mplus:IsShown() then
        local over = mplus:IsMouseOver()
        if over then return true end
    end
    local floatingBtn = _G.HSFloatingQuestItem
    if floatingBtn and floatingBtn:IsShown() then
        local over = floatingBtn:IsMouseOver()
        if over then return true end
    end
    return false
end

addon.IsMouseOverTracker = addon.IsFocusHoverActive

local function UpdateHoverFade(dt, useAnim)
    -- Combat hide/fade owns alpha during combat transitions and deferred hide.
    if addon.focus.combat.fadeState
        or addon.focus.pendingHideAfterCombat
        or (addon.ShouldHideInCombat and addon.ShouldHideInCombat())
        or (addon.ShouldFadeInCombat and addon.ShouldFadeInCombat()) then
        return
    end
    if not addon.GetDB("showOnMouseoverOnly", false) then
        if addon.focus.hoverFade.fadeState then
            addon.focus.hoverFade.fadeState = nil
            addon.focus.hoverFade.fadeTime = 0
        end
        if HS:IsShown() then
            HS:SetAlpha(1)
            local floatingBtn = _G.HSFloatingQuestItem
            if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(1) end
        end
        return
    end
    if not HS:IsShown() then
        if addon.focus.hoverFade.fadeState then
            addon.focus.hoverFade.fadeState = nil
            addon.focus.hoverFade.fadeTime = 0
            addon.focus.hoverFade.startAlpha = nil
        end
        return
    end

    local mouseOver = addon.IsFocusHoverActive()
    local fadeAlpha = GetFadeOnMouseoverAlpha()
    local targetAlpha = mouseOver and 1 or fadeAlpha
    local currentAlpha = HS:GetAlpha()
    local dur = anim.dur
    local hf = addon.focus.hoverFade

    if hf.fadeState == nil then
        if math.abs(currentAlpha - targetAlpha) < 0.01 then
            HS:SetAlpha(targetAlpha)
            local floatingBtn = _G.HSFloatingQuestItem
            if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
            return
        end
        hf.fadeState = mouseOver and "in" or "out"
        hf.fadeTime = 0
        hf.startAlpha = currentAlpha
    else
        -- Re-check hover each tick; retarget immediately if it changed mid-transition
        local prevTarget = (hf.fadeState == "in") and 1 or fadeAlpha
        if math.abs(targetAlpha - prevTarget) >= 0.01 then
            hf.fadeState = mouseOver and "in" or "out"
            hf.fadeTime = 0
            hf.startAlpha = currentAlpha
        end
    end

    hf.fadeTime = hf.fadeTime + dt
    local p = GetProgress(hf.fadeTime, 0, dur)
    local ep = p  -- linear: same perceived speed for fade in and out
    local startAlpha = hf.startAlpha or currentAlpha
    local newAlpha = startAlpha + (targetAlpha - startAlpha) * ep

    if not useAnim then
        HS:SetAlpha(targetAlpha)
        local floatingBtn = _G.HSFloatingQuestItem
        if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
        hf.fadeState = nil
        hf.fadeTime = 0
        hf.startAlpha = nil
        return
    end

    HS:SetAlpha(newAlpha)
    local floatingBtn = _G.HSFloatingQuestItem
    if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(newAlpha) end

    if p >= 1 then
        HS:SetAlpha(targetAlpha)
        if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
        hf.fadeState = nil
        hf.fadeTime = 0
        hf.startAlpha = nil
    end
end

local function UpdateCombatFade(dt, useAnim)
    local combatState = addon.focus.combat.fadeState
    if not combatState then return end
    local mode = addon.GetCombatVisibility and addon.GetCombatVisibility() or "show"
    local isFadeMode = (mode == "fade")
    local dur = anim.dur
    addon.focus.combat.fadeTime = addon.focus.combat.fadeTime + dt
    local floatingBtn = _G.HSFloatingQuestItem

    if combatState == "out" then
        if addon.focus.combat.fadeFromAlpha == nil then
            addon.focus.combat.fadeFromAlpha = HS:GetAlpha() or 1
        end
        local startAlpha = addon.focus.combat.fadeFromAlpha or 1
        if isFadeMode then
            local targetAlpha = addon.GetCombatFadeAlpha and addon.GetCombatFadeAlpha() or 0.3
            if not useAnim then
                HS:SetAlpha(targetAlpha)
                if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
                addon.focus.combat.fadeState = nil
                addon.focus.combat.fadeTime = 0
                addon.focus.combat.fadeFromAlpha = nil
                addon.focus.combat.faded = true
            else
                local p = GetProgress(addon.focus.combat.fadeTime, 0, dur)
                local ep = addon.easeIn(p)
                local alpha = startAlpha + (targetAlpha - startAlpha) * ep
                HS:SetAlpha(alpha)
                if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(alpha) end
                if p >= 1 then
                    HS:SetAlpha(targetAlpha)
                    if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
                    addon.focus.combat.fadeState = nil
                    addon.focus.combat.fadeTime = 0
                    addon.focus.combat.fadeFromAlpha = nil
                    addon.focus.combat.faded = true
                end
            end
        elseif not useAnim then
            if not InCombatLockdown() then
                HS:Hide()
                if floatingBtn then floatingBtn:Hide() end
                if addon.UpdateFloatingQuestItem then addon.UpdateFloatingQuestItem(nil) end
                if addon.UpdateMplusBlock then addon.UpdateMplusBlock() end
            else
                addon.focus.pendingHideAfterCombat = true
            end
            addon.focus.combat.fadeState = nil
            addon.focus.combat.fadeTime = 0
            addon.focus.combat.fadeFromAlpha = nil
        else
            local p = GetProgress(addon.focus.combat.fadeTime, 0, dur)
            local ep = addon.easeIn(p)
            local alpha = startAlpha * (1 - ep)
            HS:SetAlpha(alpha)
            if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(alpha) end
            if p >= 1 then
                if not InCombatLockdown() then
                    HS:Hide()
                    if floatingBtn then floatingBtn:Hide() end
                    if addon.UpdateFloatingQuestItem then addon.UpdateFloatingQuestItem(nil) end
                    if addon.UpdateMplusBlock then addon.UpdateMplusBlock() end
                else
                    addon.focus.pendingHideAfterCombat = true
                end
                addon.focus.combat.fadeState = nil
                addon.focus.combat.fadeTime = 0
                addon.focus.combat.fadeFromAlpha = nil
            end
        end
    elseif combatState == "in" then
        addon.focus.combat.fadeFromAlpha = nil
        if addon.focus.combat.fadeInFromAlpha == nil then
            addon.focus.combat.fadeInFromAlpha = HS:GetAlpha() or 0
        end
        local startAlpha = addon.focus.combat.fadeInFromAlpha or 0
        local targetAlpha = 1
        if addon.GetDB("showOnMouseoverOnly", false) then
            local fadeAlpha = GetFadeOnMouseoverAlpha()
            targetAlpha = addon.IsFocusHoverActive() and 1 or fadeAlpha
        end
        if not useAnim then
            HS:SetAlpha(targetAlpha)
            if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
            addon.focus.combat.fadeState = nil
            addon.focus.combat.fadeTime = 0
            addon.focus.combat.fadeInFromAlpha = nil
            addon.focus.combat.faded = nil
        else
            local p = GetProgress(addon.focus.combat.fadeTime, 0, dur)
            local ep = addon.easeOut(p)
            local alpha = startAlpha + (targetAlpha - startAlpha) * ep
            if HS:IsShown() then HS:SetAlpha(alpha) end
            if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(alpha) end
            if p >= 1 then
                HS:SetAlpha(targetAlpha)
                if floatingBtn and floatingBtn:IsShown() then floatingBtn:SetAlpha(targetAlpha) end
                addon.focus.combat.fadeState = nil
                addon.focus.combat.fadeTime = 0
                addon.focus.combat.fadeInFromAlpha = nil
                addon.focus.combat.faded = nil
            end
        end
    end
end

local function UpdateEntryAnimations(dt, useAnim)
    local anyAnimating = false
    for i = 1, addon.POOL_SIZE do
        local e = pool[i]

        if e.animState == "fadein" then
            e.animTime = e.animTime + dt
            if not useAnim then
                e:SetAlpha(1)
                if not InCombatLockdown() then
                    e:ClearAllPoints()
                    e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX, e.finalY)
                end
                e.animState = "active"
            elseif e.animTime < (e.staggerDelay or 0) then
                e:SetAlpha(0)
            else
                local p = GetProgress(e.animTime, e.staggerDelay, anim.dur)
                local ep = addon.easeOut(p)
                e:SetAlpha(ep)
                if not InCombatLockdown() then
                    local slideX = (1 - ep) * anim.slideInX
                    e:ClearAllPoints()
                    e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX + slideX, e.finalY)
                end
                if p >= 1 then
                    e:SetAlpha(1)
                    if not InCombatLockdown() then
                        e:ClearAllPoints()
                        e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX, e.finalY)
                    end
                    e.animState = "active"
                end
            end
            anyAnimating = true

        elseif e.animState == "completing" then
            e.animTime = e.animTime + dt
            if e.animTime >= addon.COMPLETE_HOLD then
                e.animState = "fadeout"
                e.animTime  = 0
            end
            anyAnimating = true

        elseif e.animState == "fadeout" then
            e.animTime = e.animTime + dt
            if not useAnim then
                local wasPromotion = e.promotionFadeOut
                if wasPromotion then
                    addon.promotionFadeOutCount = (addon.promotionFadeOutCount or 0) - 1
                    e.promotionFadeOut = nil
                end
                addon.ClearEntry(e)
                if wasPromotion and addon.promotionFadeOutCount == 0 and addon.onPromotionFadeOutCompleteCallback then
                    addon.onPromotionFadeOutCompleteCallback()
                end
            else
                local p  = GetProgress(e.animTime, 0, anim.dur)
                local ep = addon.easeIn(p)
                e:SetAlpha(1 - ep)
                if not InCombatLockdown() then
                    local driftY = ep * anim.driftOutY
                    e:ClearAllPoints()
                    e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX, e.finalY + driftY)
                end
                if p >= 1 then
                    local wasPromotion = e.promotionFadeOut
                    if wasPromotion then
                        addon.promotionFadeOutCount = (addon.promotionFadeOutCount or 0) - 1
                        e.promotionFadeOut = nil
                    end
                    addon.ClearEntry(e)
                    if wasPromotion and addon.promotionFadeOutCount == 0 and addon.onPromotionFadeOutCompleteCallback then
                        addon.onPromotionFadeOutCompleteCallback()
                    end
                end
            end
            anyAnimating = true

        elseif e.animState == "slideout" then
            -- Horizontal slide out (same as collapse, no delay). Used for e.g. nearby turn-on transition.
            e.animTime = e.animTime + dt
            if not useAnim then
                addon.ClearEntry(e)
            else
                local p = GetProgress(e.animTime, 0, anim.dur)
                local ep = addon.easeIn(p)
                e:SetAlpha(1 - ep)
                if not InCombatLockdown() then
                    local slideX = ep * anim.slideOutX
                    e:ClearAllPoints()
                    e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX + slideX, e.finalY)
                end
                if p >= 1 then
                    addon.ClearEntry(e)
                end
            end
            anyAnimating = true

        elseif e.animState == "collapsing" then
            e.animTime = e.animTime + dt
            local delay = e.collapseDelay or 0
            if not useAnim and e.animTime >= delay then
                addon.ClearEntry(e)
            elseif e.animTime >= delay then
                local p = GetProgress(e.animTime, delay, anim.dur)
                local ep = addon.easeIn(p)
                e:SetAlpha(1 - ep)
                if not InCombatLockdown() then
                    local slideX = ep * anim.slideOutX
                    e:ClearAllPoints()
                    e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX + slideX, e.finalY)
                end
                if p >= 1 then
                    addon.ClearEntry(e)
                end
            end
            anyAnimating = true

        elseif e.animState == "slideup" then
            e.animTime = e.animTime + dt
            if not useAnim then
                if not InCombatLockdown() and e.finalX and e.finalY then
                    e:ClearAllPoints()
                    e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX, e.finalY)
                end
                e.animState = "active"
                e.slideUpStartY = nil
            else
                local startY = e.slideUpStartY
                if startY and e.finalY ~= nil and e.finalX ~= nil then
                    local p = GetProgress(e.animTime, 0, anim.dur)
                    local ep = addon.easeOut(p)
                    local y = startY + (e.finalY - startY) * ep
                    if not InCombatLockdown() then
                        e:ClearAllPoints()
                        e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX, y)
                    end
                    if p >= 1 then
                        if not InCombatLockdown() then
                            e:ClearAllPoints()
                            e:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", e.finalX, e.finalY)
                        end
                        e.animState = "active"
                        e.slideUpStartY = nil
                    end
                else
                    e.animState = "active"
                    e.slideUpStartY = nil
                end
            end
            anyAnimating = true
        end

        if e.flashTime > 0 then
            e.flashTime = e.flashTime - dt
            local fp = math.max(e.flashTime / addon.FLASH_DUR, 0)
            local intensity = addon.GetDB("objectiveProgressFlashIntensity", "subtle")
            local alphaMax = FLASH_ALPHA_BY_INTENSITY[intensity] or FLASH_ALPHA_BY_INTENSITY.subtle
            -- Punch-then-settle: phase 1 (0-15%) rapid ease-in to full, phase 2 (15-100%) smooth ease-out to 0
            local alpha
            if fp > 0.85 then
                local phase1Progress = (1 - fp) / 0.15
                alpha = addon.easeIn(phase1Progress) * alphaMax
            else
                local phase2Progress = (0.85 - fp) / 0.85
                alpha = (1 - addon.easeOut(phase2Progress)) * alphaMax
            end
            local fc = addon.GetDB("objectiveProgressFlashColor", nil)
            if not fc or #fc < 3 then fc = { 1, 1, 1 } end
            e.flash:SetColorTexture(fc[1], fc[2], fc[3], alpha)
            anyAnimating = true
        end
    end
    -- When all "slideout" entries have finished, run the completion callback (e.g. nearby turn-on phase 2).
    local stillSlideOut = 0
    for i = 1, addon.POOL_SIZE do
        if pool[i].animState == "slideout" then stillSlideOut = stillSlideOut + 1 end
    end
    if stillSlideOut == 0 and addon.focus.callbacks.onSlideOutComplete then
        local fn = addon.focus.callbacks.onSlideOutComplete
        addon.focus.callbacks.onSlideOutComplete = nil
        fn()
    end
    return anyAnimating
end

local function UpdateSectionHeaderFadeOut(dt, useAnim)
    if not addon.focus.collapse.sectionHeadersFadingOut then return end
    local fadeOutKeys = addon.focus.collapse.sectionHeadersFadingOutKeys
    local function shouldFade(s)
        if not s.active then return false end
        if fadeOutKeys then
            return s.groupKey and fadeOutKeys[s.groupKey]
        end
        return true
    end
    if not useAnim then
        for i = 1, addon.SECTION_POOL_SIZE do
            local s = sectionPool[i]
            if shouldFade(s) then
                s:SetAlpha(0)
                s:Hide()
                s.active = false
            end
        end
        addon.focus.collapse.sectionHeadersFadingOut = false
        addon.focus.collapse.sectionHeadersFadingOutKeys = nil
        addon.focus.collapse.sectionHeaderFadeTime = 0
        return
    end
    addon.focus.collapse.sectionHeaderFadeTime = addon.focus.collapse.sectionHeaderFadeTime + dt
    local p = math.min(addon.focus.collapse.sectionHeaderFadeTime / anim.dur, 1)
    local ep = addon.easeIn(p)
    for i = 1, addon.SECTION_POOL_SIZE do
        local s = sectionPool[i]
        if shouldFade(s) and not InCombatLockdown() and s.finalX ~= nil then
            s:SetAlpha(1 - ep)
            local slideX = ep * anim.slideOutX
            s:ClearAllPoints()
            s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX + slideX, s.finalY)
        elseif shouldFade(s) then
            s:SetAlpha(1 - ep)
        end
    end
    if p >= 1 then
        for i = 1, addon.SECTION_POOL_SIZE do
            local s = sectionPool[i]
            if shouldFade(s) then
                s:Hide()
                s.active = false
            end
        end
        addon.focus.collapse.sectionHeadersFadingOut = false
        addon.focus.collapse.sectionHeadersFadingOutKeys = nil
        addon.focus.collapse.sectionHeaderFadeTime = 0
    end
end

local function UpdateSectionHeaderSlideUp(dt, useAnim)
    if not useAnim then
        for i = 1, addon.SECTION_POOL_SIZE do
            local s = sectionPool[i]
            if s and s.active and s.slideUpStartY ~= nil then
                s.slideUpStartY = nil
                s.slideUpAnimTime = nil
                if not InCombatLockdown() and s.finalX ~= nil and s.finalY ~= nil then
                    s:ClearAllPoints()
                    s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX, s.finalY)
                end
            end
        end
        return
    end
    for i = 1, addon.SECTION_POOL_SIZE do
        local s = sectionPool[i]
        if s and s.active and s.slideUpStartY ~= nil and s.finalX ~= nil and s.finalY ~= nil then
            s.slideUpAnimTime = (s.slideUpAnimTime or 0) + dt
            local p = GetProgress(s.slideUpAnimTime, 0, anim.dur)
            local ep = addon.easeOut(p)
            local y = s.slideUpStartY + (s.finalY - s.slideUpStartY) * ep
            if not InCombatLockdown() then
                s:ClearAllPoints()
                s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX, y)
            end
            if p >= 1 then
                if not InCombatLockdown() then
                    s:ClearAllPoints()
                    s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX, s.finalY)
                end
                s.slideUpStartY = nil
                s.slideUpAnimTime = nil
            end
        end
    end
end

local function UpdateSectionHeaderFadeIn(dt, useAnim)
    if not addon.focus.collapse.sectionHeadersFadingIn then return end
    if not useAnim then
        for i = 1, addon.SECTION_POOL_SIZE do
            if sectionPool[i].active then
                sectionPool[i]:SetAlpha(1)
            end
        end
        addon.focus.collapse.sectionHeadersFadingIn = false
        addon.focus.collapse.sectionHeaderFadeTime = 0
        return
    end
    addon.focus.collapse.sectionHeaderFadeTime = addon.focus.collapse.sectionHeaderFadeTime + dt
    local allDone = true
    for i = 1, addon.SECTION_POOL_SIZE do
        local s = sectionPool[i]
        if s.active and s.staggerDelay ~= nil then
            if addon.focus.collapse.sectionHeaderFadeTime < s.staggerDelay then
                s:SetAlpha(0)
                if not InCombatLockdown() and s.finalX ~= nil then
                    local slideX = anim.slideInX
                    s:ClearAllPoints()
                    s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX + slideX, s.finalY)
                end
                allDone = false
            else
                local p = GetProgress(addon.focus.collapse.sectionHeaderFadeTime, s.staggerDelay, anim.dur)
                local ep = addon.easeOut(p)
                s:SetAlpha(ep)
                if not InCombatLockdown() and s.finalX ~= nil then
                    local slideX = (1 - ep) * anim.slideInX
                    s:ClearAllPoints()
                    s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX + slideX, s.finalY)
                end
                if p < 1 then allDone = false end
            end
        end
    end
    if allDone then
        for i = 1, addon.SECTION_POOL_SIZE do
            local s = sectionPool[i]
            if s.active then
                s:SetAlpha(1)
                if not InCombatLockdown() and s.finalX ~= nil then
                    s:ClearAllPoints()
                    s:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", s.finalX, s.finalY)
                end
            end
        end
        addon.focus.collapse.sectionHeadersFadingIn = false
        addon.focus.collapse.sectionHeaderFadeTime = 0
    end
end

local function UpdateCollapseAnimations(dt)
    -- Section header fade-out runs independently (for WQ toggle, etc.) via main loop.
    if not addon.focus.collapse.animating then return end
    local useAnim = addon.GetDB("animations", true)
    local stillCollapsing = false
    for i = 1, addon.POOL_SIZE do
        if pool[i].animState == "collapsing" then
            stillCollapsing = true
            break
        end
    end
    local stillSectionHeadersFadingOut = addon.focus.collapse.sectionHeadersFadingOut
    if not stillCollapsing and not stillSectionHeadersFadingOut then
        addon.focus.collapse.animating = false
        for i = 1, addon.POOL_SIZE do
            if pool[i].animState == "idle" and (pool[i].questID or pool[i].entryKey) then
                addon.ClearEntry(pool[i], false)
            end
        end
        wipe(addon.activeMap)
        if addon.GetDB("animations", true) then
            addon.focus.collapse.sectionHeadersFadingIn  = true
            addon.focus.collapse.sectionHeaderFadeTime    = 0
        end
        if addon.FullLayout then addon.FullLayout() end
    end
end

-- Group collapse completion: when no entries in a group are collapsing anymore,
-- mark that group as collapsed and trigger a layout refresh.
local function UpdateGroupCollapseCompletion()
    if not addon.focus.collapse.groups then return end
    for groupKey, startTime in pairs(addon.focus.collapse.groups) do
        local stillCollapsing = false
        for i = 1, addon.POOL_SIZE do
            local e = pool[i]
            if e.groupKey == groupKey and e.animState == "collapsing" then
                stillCollapsing = true
                break
            end
        end

        -- Safety timeout in case something goes wrong with anim state.
        local timedOut = (GetTime() - startTime) > GROUP_COLLAPSE_TIMEOUT

        if not stillCollapsing or timedOut then
            addon.focus.collapse.groups[groupKey] = nil

            -- Capture Y positions of entries and section headers in other groups before layout reflow.
            local slideUpStarts = {}
            local slideUpStartsSec = {}
            local useAnim = addon.GetDB("animations", true)
            if useAnim then
                for i = 1, addon.POOL_SIZE do
                    local e = pool[i]
                    if e and (e.questID or e.entryKey)
                        and e.groupKey ~= groupKey
                        and e.animState == "active"
                        and e.finalY ~= nil then
                        local key = e.questID or e.entryKey
                        slideUpStarts[key] = e.finalY
                    end
                end
                for i = 1, addon.SECTION_POOL_SIZE do
                    local s = sectionPool[i]
                    if s and s.active and s.groupKey and s.groupKey ~= groupKey and s.finalY ~= nil then
                        slideUpStartsSec[s.groupKey] = s.finalY
                    end
                end
            end

            -- Clean up any lingering collapsing entries for this group.
            for i = 1, addon.POOL_SIZE do
                local e = pool[i]
                if e.groupKey == groupKey and e.animState == "collapsing" then
                    addon.ClearEntry(e)
                end
            end

            -- Re-run layout so remaining groups close up the gap.
            if addon.FullLayout then
                addon.FullLayout()
            end

            -- Apply slide-up animation to entries that moved up.
            if useAnim and next(slideUpStarts) then
                for i = 1, addon.POOL_SIZE do
                    local e = pool[i]
                    if e and (e.questID or e.entryKey) and e.animState == "active" and e.finalY ~= nil then
                        local key = e.questID or e.entryKey
                        local prevY = slideUpStarts[key]
                        if prevY and prevY ~= e.finalY then
                            addon.SetEntrySlideUp(e, prevY)
                        end
                    end
                end
            end

            -- Apply slide-up animation to section headers that moved up.
            if useAnim and next(slideUpStartsSec) then
                for i = 1, addon.SECTION_POOL_SIZE do
                    local s = sectionPool[i]
                    if s and s.active and s.groupKey and s.finalY ~= nil then
                        local prevY = slideUpStartsSec[s.groupKey]
                        if prevY and prevY ~= s.finalY then
                            s.slideUpStartY = prevY
                            s.slideUpAnimTime = 0
                        end
                    end
                end
            end
        end
    end
end

--- Captures current Y positions before a category expand. Call before SetCategoryCollapsed(key,false) and FullLayout.
--- @param groupKey string Category being expanded (e.g. "WORLD", "NEARBY")
function addon.PrepareGroupExpandSlideDown(groupKey)
    if not addon.GetDB("animations", true) or not groupKey then return end
    local collapse = addon.focus and addon.focus.collapse
    if not collapse then return end
    collapse.expandSlideDownStarts = {}
    collapse.expandSlideDownStartsSec = {}
    for i = 1, addon.POOL_SIZE do
        local e = pool[i]
        if e and (e.questID or e.entryKey) and (e.animState == "active" or e.animState == "fadein") and e.finalY ~= nil then
            local key = e.questID or e.entryKey
            collapse.expandSlideDownStarts[key] = e.finalY
        end
    end
    for i = 1, addon.SECTION_POOL_SIZE do
        local s = sectionPool[i]
        if s and s.active and s.groupKey and s.finalY ~= nil then
            collapse.expandSlideDownStartsSec[s.groupKey] = s.finalY
        end
    end
end

--- Applies slide-down animation to entries and section headers that moved after category expand.
--- Call after FullLayout when expandSlideDownStarts/expandSlideDownStartsSec were set.
function addon.ApplyGroupExpandSlideDown()
    local collapse = addon.focus and addon.focus.collapse
    if not collapse or not addon.GetDB("animations", true) then return end
    local starts = collapse.expandSlideDownStarts
    local startsSec = collapse.expandSlideDownStartsSec
    collapse.expandSlideDownStarts = nil
    collapse.expandSlideDownStartsSec = nil
    if not starts and not startsSec then return end
    if starts and next(starts) then
        for i = 1, addon.POOL_SIZE do
            local e = pool[i]
            if e and (e.questID or e.entryKey) and e.animState == "active" and e.finalY ~= nil then
                local key = e.questID or e.entryKey
                local prevY = starts[key]
                if prevY and prevY ~= e.finalY then
                    addon.SetEntrySlideUp(e, prevY)
                end
            end
        end
    end
    if startsSec and next(startsSec) then
        for i = 1, addon.SECTION_POOL_SIZE do
            local s = sectionPool[i]
            if s and s.active and s.groupKey and s.finalY ~= nil then
                local prevY = startsSec[s.groupKey]
                if prevY and prevY ~= s.finalY then
                    s.slideUpStartY = prevY
                    s.slideUpAnimTime = 0
                end
            end
        end
    end
    if addon.EnsureFocusUpdateRunning then addon.EnsureFocusUpdateRunning() end
end

--- Clears optionCollapseKeys when all WQ-toggle collapsing entries have finished.
local function UpdateOptionCollapseCompletion()
    local keys = addon.focus and addon.focus.collapse and addon.focus.collapse.optionCollapseKeys
    if not keys or not next(keys) then return end
    local stillCollapsing = false
    for i = 1, addon.POOL_SIZE do
        local e = pool[i]
        local key = e and (e.questID or e.entryKey)
        if key and keys[key] and e.animState == "collapsing" then
            stillCollapsing = true
            break
        end
    end
    if not stillCollapsing then
        addon.focus.collapse.optionCollapseKeys = nil
    end
end

-- ============================================================================
-- EXPORTS (defined last so local Update* functions are in scope)
-- ============================================================================

function addon.EnsureFocusUpdateRunning()
    if not addon.HS or addon._focusUpdateRunning then return end
    addon._focusUpdateRunning = true
    addon.HS:SetScript("OnUpdate", function(_, dt)
        if not addon.focus.enabled then
            addon._focusUpdateRunning = false
            addon.HS:SetScript("OnUpdate", nil)
            return
        end
        dt = dt or 0
        local useAnim = addon.GetDB("animations", true)
        UpdatePanelHeight(dt)
        UpdateCombatFade(dt, useAnim)
        UpdateHoverFade(dt, useAnim)
        local anyEntryAnimating = UpdateEntryAnimations(dt, useAnim)
        UpdateSectionHeaderFadeOut(dt, useAnim)
        UpdateCollapseAnimations(dt)
        UpdateGroupCollapseCompletion()
        UpdateOptionCollapseCompletion()
        UpdateSectionHeaderSlideUp(dt, useAnim)
        UpdateSectionHeaderFadeIn(dt, useAnim)

        local anySectionSliding = false
        for i = 1, addon.SECTION_POOL_SIZE do
            if sectionPool[i] and sectionPool[i].active and sectionPool[i].slideUpStartY ~= nil then
                anySectionSliding = true
                break
            end
        end
        local hoverFadeNeedsUpdate = false
        if addon.focus.hoverFade and addon.GetDB("showOnMouseoverOnly", false) and HS:IsShown() and not addon.focus.combat.fadeState then
            local mouseOver = addon.IsFocusHoverActive()
            local fadeAlpha = GetFadeOnMouseoverAlpha()
            local targetAlpha = mouseOver and 1 or fadeAlpha
            local currentAlpha = HS:GetAlpha()
            hoverFadeNeedsUpdate = (addon.focus.hoverFade.fadeState ~= nil) or (math.abs(currentAlpha - targetAlpha) >= 0.01)
        end

        local stillAnimating = anyEntryAnimating
            or anySectionSliding
            or addon.focus.collapse.animating
            or (addon.focus.combat and addon.focus.combat.fadeState ~= nil)
            or hoverFadeNeedsUpdate
            or (addon.GetDB("showOnMouseoverOnly", false) and HS:IsShown() and not addon.focus.combat.fadeState)  -- Keep polling for hover when show-on-mouseover is on
            or (addon.focus.collapse.groups and next(addon.focus.collapse.groups) ~= nil)
            or (addon.focus.collapse.optionCollapseKeys and next(addon.focus.collapse.optionCollapseKeys) ~= nil)
            or addon.focus.collapse.sectionHeadersFadingOut
            or addon.focus.collapse.sectionHeadersFadingIn
        if not stillAnimating then
            addon._focusUpdateRunning = false
            addon.HS:SetScript("OnUpdate", nil)
        end
    end)
end
