--[[
    Horizon Suite - Focus - Layout Engine
    PopulateEntry, FullLayout, ToggleCollapse, AcquireEntry, section headers, header button, keybind, floating item, M+ block.
]]

local addon = _G.HorizonSuite

-- ============================================================================
-- LAYOUT ENGINE
-- ============================================================================

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

--- Player's current zone name from map API. Used to suppress redundant zone labels for in-zone quests.
--- Schedule deferred refreshes when Endeavors or Decor have placeholder names (API data not yet loaded).
local function SchedulePlaceholderRefreshes(quests)
    if addon.focus.placeholderRefreshScheduled then return end
    for _, q in ipairs(quests) do
        local isEndeavorPlaceholder = q.isEndeavor and q.endeavorID and q.title == ("Endeavor " .. tostring(q.endeavorID))
        local isDecorPlaceholder = q.isDecor and q.decorID and q.title == ("Decor " .. tostring(q.decorID))
        if isEndeavorPlaceholder or isDecorPlaceholder then
            addon.focus.placeholderRefreshScheduled = true
            C_Timer.After(2, function()
                if addon.focus.enabled and addon.ScheduleRefresh then addon.ScheduleRefresh() end
            end)
            C_Timer.After(4, function()
                if addon.focus.enabled and addon.ScheduleRefresh then addon.ScheduleRefresh() end
            end)
            break
        end
    end
end

--- Player's current zone name. Uses Zone tier (whole zone, e.g. K'aresh), not continent or micro.
--- Dungeon/Delve: current map only. Overworld: GetZoneText() or walk up to Zone (mapType 3).
local function GetPlayerCurrentZoneName()
    if not C_Map or not C_Map.GetBestMapForUnit then return nil end
    local mapID = C_Map.GetBestMapForUnit("player")
    if not mapID or not C_Map.GetMapInfo then return nil end

    -- Dungeon or Delve: use current map only (no walk-up).
    if (addon.IsDelveActive and addon.IsDelveActive()) or (addon.IsInPartyDungeon and addon.IsInPartyDungeon()) then
        local info = C_Map.GetMapInfo(mapID)
        return info and info.name or nil
    end

    -- Overworld: prefer GetZoneText() (fixes wrong-map e.g. Dornegol when in K'aresh).
    if GetZoneText and type(GetZoneText) == "function" then
        local zoneText = GetZoneText()
        if zoneText and zoneText:match("%S") then
            return zoneText:match("^%s*(.-)%s*$") or zoneText
        end
    end

    -- Fallback: walk up via parentMapID until Zone (mapType 3) or no parent.
    local UIMAPTYPE_ZONE = 3
    local current = mapID
    local info = C_Map.GetMapInfo(current)
    while info do
        if info.mapType == UIMAPTYPE_ZONE then
            return info.name
        end
        if not info.parentMapID or info.parentMapID == 0 then
            break
        end
        current = info.parentMapID
        info = C_Map.GetMapInfo(current)
    end
    info = C_Map.GetMapInfo(mapID)
    return info and info.name or nil
end

local function AcquireEntry()
    for i = 1, addon.POOL_SIZE do
        if pool[i].animState == "idle" and not pool[i].questID and not pool[i].entryKey then
            return pool[i]
        end
    end
    for i = 1, addon.POOL_SIZE do
        if pool[i].animState == "fadeout" then
            addon.ClearEntry(pool[i])
            return pool[i]
        end
    end
    return nil
end

-- Safety: if load order is disrupted (or a hot-reload partially loads files),
-- layout can run before FocusAnimation defines addon.SetEntryFadeIn.
-- Fall back to a no-animation init so we never hard-crash.
local function SafeEntryFadeIn(entry, staggerIndex)
    if addon.SetEntryFadeIn then
        addon.SetEntryFadeIn(entry, staggerIndex)
        return
    end
    if not entry then return end
    entry.animState = "active"
    entry.animTime = 0
    entry.staggerDelay = 0
    if entry.SetAlpha then entry:SetAlpha(1) end
end

local headerBtn = CreateFrame("Button", nil, addon.HS)
headerBtn:SetPoint("TOPLEFT", addon.HS, "TOPLEFT", 0, 0)
headerBtn:SetPoint("TOPRIGHT", addon.HS, "TOPRIGHT", 0, 0)
headerBtn:SetHeight(addon.PADDING + addon.GetHeaderHeight())
headerBtn:RegisterForClicks("LeftButtonUp")
headerBtn:SetScript("OnClick", function()
    addon.ToggleCollapse()
end)
headerBtn:SetScript("OnEnter", function()
    if addon.GetDB("hideObjectivesHeader", false) then
        addon.chevron:SetAlpha(1)
        if not addon.GetDB("hideOptionsButton", false) then
            addon.optionsBtn:SetAlpha(1)
        end
    end
end)
headerBtn:SetScript("OnLeave", function()
    if addon.GetDB("hideObjectivesHeader", false) then
        if addon.optionsBtn:IsMouseOver() then return end
        addon.chevron:SetAlpha(0)
        if not addon.GetDB("hideOptionsButton", false) then
            addon.optionsBtn:SetAlpha(0)
        end
    end
end)
headerBtn:RegisterForDrag("LeftButton")
headerBtn:SetScript("OnDragStart", function()
    if HorizonDB and HorizonDB.lockPosition then return end
    if InCombatLockdown() then return end
    addon.HS:StartMoving()
end)
headerBtn:SetScript("OnDragStop", function()
    if HorizonDB and HorizonDB.lockPosition then return end
    addon.HS:StopMovingOrSizing()
    addon.HS:SetUserPlaced(false)
    if InCombatLockdown() then return end
    addon.SavePanelPosition()
end)

local collapseKeybindBtn = CreateFrame("Button", "HSCollapseButton", nil)
collapseKeybindBtn:SetScript("OnClick", function()
    addon.ToggleCollapse()
end)
collapseKeybindBtn:RegisterForClicks("AnyUp")

local nearbyToggleKeybindBtn = CreateFrame("Button", "HSNearbyToggleButton", nil)
nearbyToggleKeybindBtn:SetScript("OnClick", function()
    local newShow = not addon.GetDB("showNearbyGroup", true)
    addon.SetDB("showNearbyGroup", newShow)
    if InCombatLockdown() then
        if _G.HorizonSuite_RequestRefresh then _G.HorizonSuite_RequestRefresh() end
        if _G.HorizonSuite_FullLayout then _G.HorizonSuite_FullLayout() end
        return
    end
    if newShow then
        if addon.GetDB("animations", true) and addon.StartNearbyTurnOnTransition then
            addon.StartNearbyTurnOnTransition()
        else
            if _G.HorizonSuite_RequestRefresh then _G.HorizonSuite_RequestRefresh() end
            if _G.HorizonSuite_FullLayout then _G.HorizonSuite_FullLayout() end
        end
    else
        if addon.GetDB("animations", true) and addon.StartGroupCollapseVisual then
            addon.StartGroupCollapseVisual("NEARBY")
        else
            if _G.HorizonSuite_RequestRefresh then _G.HorizonSuite_RequestRefresh() end
            if _G.HorizonSuite_FullLayout then _G.HorizonSuite_FullLayout() end
        end
    end
end)
nearbyToggleKeybindBtn:RegisterForClicks("AnyUp")

--- Full layout of the objectives panel.
-- @brief Computes and applies the complete tracker layout: visibility, quest list, section headers, entry positions, scroll height.
-- Algorithm: (1) Bail if disabled or in combat. (2) Hide panel if instance visibility
-- forbids. (3) Apply grow-up anchor and header visibility from DB. (4) When collapsed,
-- update floating item and header count then return. (5) Read and sort/group quests,
-- mark entries no longer in list for fadeout. (6) For each grouped quest, acquire or
-- reuse pool entry and PopulateEntry. (7) Place section headers and entries by group,
-- respecting collapsed categories. (8) Set scroll child height, clamp scroll offset,
-- compute target panel height and show frame.
local lastMinimal = false
local function FullLayout()
    if not addon.focus.enabled then return end
    if InCombatLockdown() then
        addon.focus.layoutPendingAfterCombat = true
        return
    end
    addon.focus.layoutPendingAfterCombat = false

    if not addon.ShouldShowInInstance() then
        addon.HS:Hide()
        addon.UpdateFloatingQuestItem(nil)
        if addon.UpdateMplusBlock then addon.UpdateMplusBlock() end
        return
    end

    if addon.ShouldHideInCombat() then
        addon.HS:Hide()
        addon.UpdateFloatingQuestItem(nil)
        if addon.UpdateMplusBlock then addon.UpdateMplusBlock() end
        return
    end

    if addon.GetDB("growUp", false) then
        addon.ApplyGrowUpAnchor()
    end

    -- Layout indentation model:
    --  - Category chevron '−/+' is the left pivot.
    --  - Quest titles start two spaces to the right of the chevron.
    --  - Zone/objectives start two spaces to the right of the title.
    -- Measure "two spaces" using the current TitleFont so it scales with typography.
    do
        addon.focus.layout = addon.focus.layout or {}
        local twoSpaces = 8
        local ok = pcall(function()
            addon.focus.layout.__indentMeasure = addon.focus.layout.__indentMeasure or addon.scrollChild:CreateFontString(nil, "ARTWORK")
            local fs = addon.focus.layout.__indentMeasure
            fs:Hide()
            fs:SetFontObject(addon.TitleFont)
            fs:SetText("  ")
            local w = fs:GetStringWidth()
            if w and w > 0 then twoSpaces = w end
        end)
        if not ok then twoSpaces = 8 end
        addon.focus.layout.twoSpacesPx = twoSpaces
        addon.focus.layout.titleIndentPx = twoSpaces
    end

    local minimal = addon.GetDB("hideObjectivesHeader", false)
    local hideOptBtn = addon.GetDB("hideOptionsButton", false)
    if minimal then
        addon.headerText:Hide()
        addon.headerShadow:Hide()
        addon.countText:Hide()
        addon.countShadow:Hide()
        addon.divider:Hide()
        addon.optionsBtn:SetFrameLevel(headerBtn:GetFrameLevel() + 1)
        addon.optionsBtn:SetParent(addon.HS)
        headerBtn:SetHeight(addon.MINIMAL_HEADER_HEIGHT)
        addon.chevron:Show()
        if hideOptBtn then
            addon.optionsBtn:Hide()
        else
            addon.optionsLabel:SetText(addon.L["Options"])
            addon.optionsBtn:SetWidth(math.max(addon.optionsLabel:GetStringWidth() + 4, 44))
            addon.optionsBtn:Show()
            -- Visible on hover only: use alpha so frames stay in layout and remain clickable
            if not lastMinimal then
                addon.chevron:SetAlpha(headerBtn:IsMouseOver() and 1 or 0)
                addon.optionsBtn:SetAlpha(headerBtn:IsMouseOver() and 1 or 0)
            end
        end
    else
        addon.optionsBtn:SetFrameLevel(headerBtn:GetFrameLevel() + 1)
        addon.optionsBtn:SetParent(addon.HS)
        addon.chevron:SetAlpha(1)
        addon.headerText:Show()
        addon.headerShadow:Show()
        local headerStr = addon.ApplyTextCase(addon.L["OBJECTIVES"], "headerTextCase", "upper")
        addon.headerText:SetText(headerStr)
        addon.headerShadow:SetText(headerStr)
        if addon.GetDB("showQuestCount", true) then addon.countText:Show(); addon.countShadow:Show() else addon.countText:Hide(); addon.countShadow:Hide() end
        addon.chevron:Show()
        if hideOptBtn then
            addon.optionsBtn:Hide()
        else
            addon.optionsBtn:SetAlpha(1)
            addon.optionsBtn:Show()
            addon.optionsLabel:SetText(addon.L["Options"])
            addon.optionsBtn:SetWidth(math.max(addon.optionsLabel:GetStringWidth() + 4, 44))
        end
        addon.divider:SetShown(addon.GetDB("showHeaderDivider", true))
        headerBtn:SetHeight(addon.PADDING + addon.GetHeaderHeight())
    end
    lastMinimal = minimal

    local contentTop = addon.GetContentTop()

    -- Update the Mythic+ block so we can anchor the scrollFrame around it.
    if addon.UpdateMplusBlock then
        addon.UpdateMplusBlock()
    end

    scrollFrame:ClearAllPoints()
    local mplus = addon.mplusBlock
    local hasMplus = mplus and mplus:IsShown()
    local mplusPos = addon.GetDB("mplusBlockPosition", "top") or "top"
    local gap = 4

    local blockFrame = hasMplus and mplus or nil
    local blockPos = hasMplus and mplusPos or "top"

    if blockFrame and blockPos == "top" then
        scrollFrame:SetPoint("TOPLEFT", blockFrame, "BOTTOMLEFT", 0, -gap)
        scrollFrame:SetPoint("BOTTOMRIGHT", addon.HS, "BOTTOMRIGHT", 0, addon.PADDING)
    elseif blockFrame and blockPos == "bottom" then
        scrollFrame:SetPoint("TOPLEFT", addon.HS, "TOPLEFT", 0, contentTop)
        scrollFrame:SetPoint("BOTTOMRIGHT", blockFrame, "TOPRIGHT", 0, gap)
    else
        scrollFrame:SetPoint("TOPLEFT", addon.HS, "TOPLEFT", 0, contentTop)
        scrollFrame:SetPoint("BOTTOMRIGHT", addon.HS, "BOTTOMRIGHT", 0, addon.PADDING)
    end

    local rares = addon.GetDB("showRareBosses", true) and addon.GetRaresOnMap() or {}
    if #rares > 0 then
        local currentRareKeys = {}
        for _, r in ipairs(rares) do currentRareKeys[r.entryKey or r.questID] = true end
        if addon.focus.rares.trackingInit and not addon.focus.zoneJustChanged then
            for key in pairs(currentRareKeys) do
                if not addon.focus.rares.prevKeys[key] and PlaySound and addon.GetDB("rareAddedSound", true) then
                    pcall(PlaySound, addon.RARE_ADDED_SOUND)
                    break
                end
            end
        end
        if addon.focus.zoneJustChanged then addon.focus.zoneJustChanged = false end
        addon.focus.rares.trackingInit = true
        addon.focus.rares.prevKeys = currentRareKeys
    else
        addon.focus.rares.prevKeys = {}
    end

    if addon.focus.collapsed then
        if addon.focus.collapse.pendingWQCollapse then
            addon.focus.collapse.pendingWQCollapse = false
        end
        local quests = addon.ReadTrackedQuests()
        for _, r in ipairs(rares) do quests[#quests + 1] = r end
        if addon.GetDB("showAchievements", true) and addon.ReadTrackedAchievements then
            for _, a in ipairs(addon.ReadTrackedAchievements()) do quests[#quests + 1] = a end
        end
        if addon.ReadTrackedEndeavors then
            for _, e in ipairs(addon.ReadTrackedEndeavors()) do quests[#quests + 1] = e end
        end
        if addon.ReadTrackedDecor then
            for _, d in ipairs(addon.ReadTrackedDecor()) do quests[#quests + 1] = d end
        end
        SchedulePlaceholderRefreshes(quests)
        addon.UpdateFloatingQuestItem(quests)
        addon.UpdateHeaderQuestCount(#quests, addon.CountTrackedInLog(quests))

        -- During panel collapse animation, skip full collapsed layout so section headers
        -- stay visible; UpdateCollapseAnimations will call FullLayout when done.
        if addon.focus.collapse.animating then
            if #quests > 0 then
                if addon.focus.combat.fadeState == "in" then addon.HS:SetAlpha(0) end
                addon.HS:Show()
            end
            return
        end

        if addon.GetDB("showSectionHeadersWhenCollapsed", false) then
            -- Collapsed-with-headers: show section headers only, no entries.
            local grouped = addon.SortAndGroupQuests(quests)
            local showSections = #grouped >= 1 and addon.GetDB("showSectionHeaders", true)

            if showSections and #grouped > 0 then
                scrollFrame:Show()
                addon.HideAllSectionHeaders()
                addon.focus.layout.sectionIdx = 0
                local focusedGroupKey = addon.GetFocusedGroupKey(grouped)
                local yOff = 0
                for gi, grp in ipairs(grouped) do
                    if gi > 1 then
                        yOff = yOff - addon.GetSectionSpacing()
                    end
                    local sec = addon.AcquireSectionHeader(grp.key, focusedGroupKey)
                    if sec then
                        sec:ClearAllPoints()
                        local x = addon.PADDING
                        sec:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", x, yOff)
                        sec.finalX, sec.finalY = x, yOff
                        yOff = yOff - addon.GetSectionHeaderHeight() - addon.GetSectionToEntryGap()
                    end
                end
                local totalContentH = math.max(-yOff, 1)
                scrollChild:SetHeight(totalContentH)
                scrollFrame:SetVerticalScroll(0)
                addon.focus.layout.scrollOffset = 0
                local headerArea = addon.PADDING + addon.GetHeaderHeight() + addon.DIVIDER_HEIGHT + addon.GetHeaderToContentGap()
                local visibleH = math.min(totalContentH, addon.GetMaxContentHeight())
                local blockHeight = (hasMplus and addon.GetMplusBlockHeight and (addon.GetMplusBlockHeight() + gap * 2)) or 0
                addon.focus.layout.targetHeight = math.max(addon.MIN_HEIGHT, headerArea + visibleH + addon.PADDING + blockHeight)
            else
                scrollFrame:Hide()
                addon.focus.layout.targetHeight = addon.GetCollapsedHeight()
            end
        else
            scrollFrame:Hide()
            addon.focus.layout.targetHeight = addon.GetCollapsedHeight()
        end

        if #quests > 0 then
            if addon.focus.combat.fadeState == "in" then addon.HS:SetAlpha(0) end
            addon.HS:Show()
        end
        return
    end

    scrollFrame:Show()

    local quests  = addon.ReadTrackedQuests()
    for _, r in ipairs(rares) do quests[#quests + 1] = r end
    if addon.GetDB("showAchievements", true) and addon.ReadTrackedAchievements then
        for _, a in ipairs(addon.ReadTrackedAchievements()) do quests[#quests + 1] = a end
    end
    if addon.ReadTrackedEndeavors then
        for _, e in ipairs(addon.ReadTrackedEndeavors()) do quests[#quests + 1] = e end
    end
    if addon.ReadTrackedDecor then
        for _, d in ipairs(addon.ReadTrackedDecor()) do quests[#quests + 1] = d end
    end
    SchedulePlaceholderRefreshes(quests)
    addon.UpdateFloatingQuestItem(quests)
    local grouped = addon.SortAndGroupQuests(quests)

    -- When a category is collapsing, skip full layout to avoid section header flicker.
    if addon.focus.collapse.groups and next(addon.focus.collapse.groups) then
        if addon.focus.collapse.pendingWQCollapse then
            addon.focus.collapse.pendingWQCollapse = false
        end
        addon.UpdateHeaderQuestCount(#quests, addon.CountTrackedInLog(quests))
        if addon.EnsureFocusUpdateRunning then addon.EnsureFocusUpdateRunning() end
        return
    end

    -- Source of truth: use filtered grouped output so hideOtherCategoriesInDelve
    -- correctly clears stale entries when transitioning into Delve/dungeon.
    local currentIDs = {}
    for _, grp in ipairs(grouped) do
        for _, qData in ipairs(grp.quests) do
            local key = qData.entryKey or qData.questID
            if key then currentIDs[key] = true end
        end
    end

    -- Align with SortAndGroupQuests hideOtherCategoriesInDelve: when filter returns
    -- only DELVES or DUNGEON, clear stale entries immediately (no fade animation).
    local onlyDelveShown = (#grouped == 1 and grouped[1] and grouped[1].key == "DELVES")
        and (addon.IsDelveActive and addon.IsDelveActive())
    local onlyDungeonShown = (#grouped == 1 and grouped[1] and grouped[1].key == "DUNGEON")
        and (addon.IsInPartyDungeon and addon.IsInPartyDungeon())
    local onlyInstanceGroupShown = onlyDelveShown or onlyDungeonShown
    local useWQCollapse = addon.focus.collapse.pendingWQCollapse
        and addon.GetDB("animations", true)
        and not onlyInstanceGroupShown
    local useWQExpand = addon.focus.collapse.pendingWQExpand
        and addon.GetDB("animations", true)
    if useWQExpand then
        if addon.PrepareGroupExpandSlideDown then addon.PrepareGroupExpandSlideDown("WORLD") end
        addon.focus.collapse.pendingWQExpand = false
    end
    local slideUpStarts = nil
    local slideUpStartsSec = nil
    if useWQCollapse then
        slideUpStarts = {}
        for k in pairs(currentIDs) do
            local e = activeMap[k]
            if e and (e.animState == "active" or e.animState == "fadein") and e.finalY ~= nil then
                slideUpStarts[k] = e.finalY
            end
        end
        slideUpStartsSec = {}
        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
                slideUpStartsSec[s.groupKey] = s.finalY
            end
        end
    end
    local toRemove = {}
    for key, entry in pairs(activeMap) do
        if not currentIDs[key] then
            if onlyInstanceGroupShown and addon.ClearEntry then
                addon.ClearEntry(entry)
            elseif useWQCollapse and entry.animState ~= "completing" and entry.animState ~= "fadeout" then
                toRemove[#toRemove + 1] = { key = key, entry = entry }
            elseif entry.animState ~= "completing" and entry.animState ~= "fadeout" then
                addon.SetEntryFadeOut(entry)
            end
            activeMap[key] = nil
        end
    end
    if useWQCollapse and #toRemove > 0 then
        table.sort(toRemove, function(a, b)
            return (a.entry.finalY or 0) > (b.entry.finalY or 0)
        end)
        addon.focus.collapse.optionCollapseKeys = {}
        for i, t in ipairs(toRemove) do
            addon.SetEntryCollapsing(t.entry, i - 1)
            addon.focus.collapse.optionCollapseKeys[t.key] = true
        end
        addon.focus.collapse.pendingWQCollapse = false
    end
    if addon.focus.collapse.pendingWQCollapse then
        addon.focus.collapse.pendingWQCollapse = false
    end
    if onlyInstanceGroupShown and addon.ClearEntry then
        for i = 1, addon.POOL_SIZE do
            local e = pool[i]
            if e and (e.questID or e.entryKey) then
                local key = e.questID or e.entryKey
                if not currentIDs[key] then
                    addon.ClearEntry(e)
                end
            end
        end
    end

    for _, grp in ipairs(grouped) do
        for _, qData in ipairs(grp.quests) do
            local key = qData.entryKey or qData.questID
            local entry = activeMap[key]
            if not entry then
                entry = AcquireEntry()
                if entry then
                    SafeEntryFadeIn(entry, 0)
                    activeMap[key] = entry
                end
            elseif entry.animState == "idle" and not entry.questID and not entry.entryKey then
                -- Zombie entry left over from a group collapse: reset it for fadein.
                SafeEntryFadeIn(entry, 0)
            end
            if entry then
                entry.groupKey = grp.key
                addon.PopulateEntry(entry, qData, grp.key)
            end
        end
    end

    -- Build current "priority" sets (tracked/in-log) for WORLD, WEEKLY, DAILY to detect promotion.
    local function isPriorityWorld(q) return (q.isTracked or q.isAccepted) and true or false end
    local function isPriorityWeeklyDaily(q) return q.isAccepted and true or false end
    local curPriority = {}
    for _, grp in ipairs(grouped) do
        if grp.key == "WORLD" or grp.key == "WEEKLY" or grp.key == "DAILY" then
            curPriority[grp.key] = {}
            for _, qData in ipairs(grp.quests) do
                local key = qData.entryKey or qData.questID
                if key and ((grp.key == "WORLD" and isPriorityWorld(qData)) or ((grp.key == "WEEKLY" or grp.key == "DAILY") and isPriorityWeeklyDaily(qData))) then
                    curPriority[grp.key][key] = true
                end
            end
        end
    end

    -- Promotion animation: if priority set grew (tracked/in-log added), fade out only the promoted quest(s) then reflow and fade them in at top.
    if addon.GetDB("animations", true) then
        addon.focus.promotion.prevWorld  = addon.focus.promotion.prevWorld  or {}
        addon.focus.promotion.prevWeekly = addon.focus.promotion.prevWeekly or {}
        addon.focus.promotion.prevDaily  = addon.focus.promotion.prevDaily  or {}
        local promotedKeys = {}
        for _, grp in ipairs(grouped) do
            if grp.key == "WORLD" or grp.key == "WEEKLY" or grp.key == "DAILY" then
                local cur = (grp.key == "WORLD" and curPriority.WORLD) or (grp.key == "WEEKLY" and curPriority.WEEKLY) or (grp.key == "DAILY" and curPriority.DAILY) or {}
                local prev = (grp.key == "WORLD" and addon.focus.promotion.prevWorld) or (grp.key == "WEEKLY" and addon.focus.promotion.prevWeekly) or (grp.key == "DAILY" and addon.focus.promotion.prevDaily) or {}
                if next(prev) then
                    for k in pairs(cur) do
                        if not prev[k] then promotedKeys[k] = true end
                    end
                end
            end
        end
        local promotionFadeOutCount = 0
        if next(promotedKeys) then
            addon.focus.promotion.prevWorld  = curPriority.WORLD  or {}
            addon.focus.promotion.prevWeekly = curPriority.WEEKLY or {}
            addon.focus.promotion.prevDaily  = curPriority.DAILY  or {}
            for key in pairs(promotedKeys) do
                local entry = activeMap[key]
                if entry and (entry.animState == "active" or entry.animState == "fadein") and entry.finalX and entry.finalY then
                    addon.SetEntryFadeOut(entry)
                    entry.promotionFadeOut = true
                    promotionFadeOutCount = promotionFadeOutCount + 1
                end
            end
            if promotionFadeOutCount > 0 then
                addon.promotionFadeOutCount = promotionFadeOutCount
                addon.onPromotionFadeOutCompleteCallback = function()
                    addon.onPromotionFadeOutCompleteCallback = nil
                    addon.promotionFadeOutCount = nil
                    if addon.FullLayout then addon.FullLayout() end
                end
                addon.UpdateHeaderQuestCount(#quests, addon.CountTrackedInLog(quests))
                if addon.EnsureFocusUpdateRunning then addon.EnsureFocusUpdateRunning() end
                return
            end
        end
    end

    -- Update prev priority for next comparison (when not doing promotion transition).
    addon.focus.promotion.prevWorld  = curPriority.WORLD  or {}
    addon.focus.promotion.prevWeekly = curPriority.WEEKLY or {}
    addon.focus.promotion.prevDaily  = curPriority.DAILY  or {}

    local excludeSectionHeadersForFade = nil
    if addon.focus.collapse.optionCollapseKeys and next(addon.focus.collapse.optionCollapseKeys) and addon.GetDB("animations", true) then
        local newGroupKeys = {}
        for _, grp in ipairs(grouped) do
            newGroupKeys[grp.key] = true
        end
        local disappearing = {}
        for i = 1, addon.SECTION_POOL_SIZE do
            local s = sectionPool[i]
            if s and s.active and s.groupKey and not newGroupKeys[s.groupKey] then
                disappearing[s.groupKey] = true
            end
        end
        if next(disappearing) then
            addon.focus.collapse.sectionHeadersFadingOut = true
            addon.focus.collapse.sectionHeadersFadingOutKeys = disappearing
            addon.focus.collapse.sectionHeaderFadeTime = 0
            excludeSectionHeadersForFade = disappearing
            if addon.EnsureFocusUpdateRunning then addon.EnsureFocusUpdateRunning() end
        end
    end
    addon.HideAllSectionHeaders(excludeSectionHeadersForFade)
    addon.focus.layout.sectionIdx = 0

    local yOff = 0
    local entryIndex = 0

    local showSections = #grouped > 1 and addon.GetDB("showSectionHeaders", true)
    local focusedGroupKey = addon.GetFocusedGroupKey(grouped)

    -- Offset quest entries so their text starts under the section header label
    -- (i.e. same start as category text, excluding the chevron).
    local sectionLabelX = 0
    if showSections then
        local w = addon.focus.layout and addon.focus.layout.twoSpacesPx
        if type(w) == "number" and w > 0 then
            sectionLabelX = math.floor(w + 0.5)
        else
            -- Fallback if the layout cache isn't populated yet.
            local meas = addon.focus.layout.__sectionIndentMeasure
            if not meas then
                meas = scrollChild:CreateFontString(nil, "ARTWORK")
                meas:Hide()
                addon.focus.layout.__sectionIndentMeasure = meas
            end
            meas:SetFontObject(addon.TitleFont)
            meas:SetText("  ")
            local mw = meas:GetStringWidth()
            if mw and mw > 0 then sectionLabelX = math.floor(mw + 0.5) end
        end
    end

    -- Persist for other modules/tests.
    addon.focus.layout.sectionLabelX = sectionLabelX

    for gi, grp in ipairs(grouped) do
        local isCollapsed = showSections and addon.IsCategoryCollapsed(grp.key)

        if showSections then
            if gi > 1 then
                yOff = yOff - addon.GetSectionSpacing()
            end
            local sec = addon.AcquireSectionHeader(grp.key, focusedGroupKey)
            if sec then
                sec:ClearAllPoints()
                local x = addon.PADDING
                sec:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", x, yOff)
                sec.finalX, sec.finalY = x, yOff
                yOff = yOff - addon.GetSectionHeaderHeight() - addon.GetSectionToEntryGap()
            end
        end

        if isCollapsed then
            -- Do not position entries for collapsed groups; hide any that are not
            -- currently animating a collapse.
            for _, qData in ipairs(grp.quests) do
                local key = qData.entryKey or qData.questID
                local entry = activeMap[key]
                if entry and entry.animState ~= "collapsing" then
                    entry:Hide()
                end
            end
        else
            local entrySpacing = ((grp.key == "DELVES" or grp.key == "DUNGEON") and addon.DELVE_ENTRY_SPACING) or addon.GetTitleSpacing()
            local categoryCounter = 0
            for _, qData in ipairs(grp.quests) do
                categoryCounter = categoryCounter + 1
                qData.categoryIndex = categoryCounter
                local key = qData.entryKey or qData.questID
                local entry = activeMap[key]
                if entry then
                    entry.groupKey = grp.key

                    -- Use the same base X as section headers so entries stay aligned with categories.
                    -- Keep a small indent for entries (for numbering/chevron separation) via layout.entryIndentPx.
                    local entryBaseX = addon.PADDING
                    local entryIndentPx = (addon.focus and addon.focus.layout and addon.focus.layout.entryIndentPx) or 0

                    -- Extra cushion when quest icons are enabled:
                    -- Do NOT shift the whole entry; that can make the icon sit left of the supertrack bar.
                    local iconModePad = 0

                    local entryX = entryBaseX + sectionLabelX + entryIndentPx + iconModePad

                    entry.finalX = entryX
                    entry.finalY = yOff
                    entry.staggerDelay = entryIndex * addon.FOCUS_ANIM.stagger
                    entryIndex = entryIndex + 1

                    if not entry:IsShown() and (entry.animState == "active" or entry.animState == "idle") and addon.GetDB("animations", true) then
                        SafeEntryFadeIn(entry, entryIndex - 1)
                    end
                    entry:ClearAllPoints()
                    entry:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", entryX, yOff)

                    -- Keep questTypeIcon anchored to the entry frame so it scrolls/clips correctly.
                    if entry.questTypeIcon then
                        entry.questTypeIcon:ClearAllPoints()
                        local showIcons = addon.GetDB("showQuestTypeIcons", false)
                        if showIcons then
                            -- Place icon to the right of the supertracked highlight bar so the bar is always leftmost.
                            local highlightStyle = addon.NormalizeHighlightStyle(addon.GetDB("activeQuestHighlight", "bar-left")) or "bar-left"
                            local barW = math.max(2, math.min(6, tonumber(addon.GetDB("highlightBarWidth", 2)) or 2))
                            local barLeft = addon.BAR_LEFT_OFFSET or 12
                            local padAfterBar = 6

                            if highlightStyle == "bar-left" or highlightStyle == "pill-left" then
                                -- bar starts at -barLeft; its right edge is (-barLeft + barW)
                                entry.questTypeIcon:SetPoint("TOPLEFT", entry, "TOPLEFT", -barLeft + barW + padAfterBar, 0)
                            else
                                -- Fallback to legacy off-to-the-left placement for non-left-bar styles.
                                local iconRight = (addon.BAR_LEFT_OFFSET or 12) + 2
                                entry.questTypeIcon:SetPoint("TOPRIGHT", entry, "TOPLEFT", -iconRight, 0)
                            end
                        else
                            -- Icons off: keep the legacy off-to-the-left placement so text alignment remains unchanged.
                            local iconRight = (addon.BAR_LEFT_OFFSET or 12) + 2
                            entry.questTypeIcon:SetPoint("TOPRIGHT", entry, "TOPLEFT", -iconRight, 0)
                        end
                    end

                    entry:Show()
                    yOff = yOff - entry.entryHeight - entrySpacing
                end
            end
        end
    end

    if slideUpStarts and next(slideUpStarts) and addon.GetDB("animations", true) 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
    if slideUpStartsSec and next(slideUpStartsSec) and addon.GetDB("animations", true) 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
    if useWQExpand and addon.ApplyGroupExpandSlideDown then
        addon.ApplyGroupExpandSlideDown()
    end

    addon.UpdateHeaderQuestCount(#quests, addon.CountTrackedInLog(quests))

    local totalContentH = math.max(-yOff, 1)
    local prevScroll = addon.focus.layout.scrollOffset
    scrollChild:SetHeight(totalContentH)

    local frameH = scrollFrame:GetHeight() or 0
    local maxScr = math.max(totalContentH - frameH, 0)
    addon.focus.layout.scrollOffset = math.min(prevScroll, maxScr)
    scrollFrame:SetVerticalScroll(addon.focus.layout.scrollOffset)

    local headerArea    = addon.PADDING + addon.GetHeaderHeight() + addon.DIVIDER_HEIGHT + addon.GetHeaderToContentGap()
    local visibleH      = math.min(totalContentH, addon.GetMaxContentHeight())
    local blockHeight   = (hasMplus and addon.GetMplusBlockHeight and (addon.GetMplusBlockHeight() + gap * 2)) or 0
    addon.focus.layout.targetHeight  = math.max(addon.MIN_HEIGHT, headerArea + visibleH + addon.PADDING + blockHeight)

    if #quests > 0 then
        if addon.focus.combat.fadeState == "in" then addon.HS:SetAlpha(0) end
        addon.HS:Show()
    end

    if addon.EnsureFocusUpdateRunning then addon.EnsureFocusUpdateRunning() end
end

addon.GetPlayerCurrentZoneName = GetPlayerCurrentZoneName
addon.AcquireEntry        = AcquireEntry
addon.FullLayout          = FullLayout
