---@class AddonPrivate
local Private = select(2, ...)

---@class RouteHud
local route = {}
Private.RouteHud = route
route.expanded = {} -- criterion key -> true, for expanded multi-waypoint groups
route.delveExpanded = {} -- delve name -> true, for expanded delve rows
route.wpExpanded = {} -- "wq"/"q"..questID -> true, for WQ/quest rows whose criterion has waypoints
route.wqDebug = false -- /drustwqdebug: force one active zone WQ into the list to prove the pipeline
route.autoSkipped = {} -- auto-mode: group keys the player has skipped this session
route.stepProgress = {} -- "<groupKey>#<objectID>" -> highest route step the player has reached this session

local const = Private.constants
local O = Private.Objectives

-- Adjustable font for all planner text (accessibility). It starts from the addon's
-- SMALL font; ApplyLook() restyles it live from settings (bold outline for eyesight).
-- Overall SIZE is handled by the window scale (proportional, no row clipping).
local PLANNER_FONT = CreateFont("MetaTrackerPlannerFont")
PLANNER_FONT:SetFontObject(const.FONT_OBJECTS.SMALL)

local WIDTH        = 310
local ROW_HEIGHT   = 20
local SECTION_H    = 17
local CONTENT_TOP  = 122   -- where the dynamic sections begin (below the header)
local MAX_ZONE_ROWS = 6
local ROUTE_LIMIT  = 30    -- how many nearest stops to chain into a route
local REFRESH_INTERVAL = 1.0

local FrameBackdrop = {
    bgFile   = "interface\\achievementframe\\ui-guildachievement-parchment",
    edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
    edgeSize = 16,
    insets   = { left = 4, right = 4, top = 4, bottom = 4 },
}
local ZONE_ICON = "Interface\\Icons\\INV_Misc_Map_01"

-- Dark per-entry panel (like the Blizzard achievement window) so text reads on a
-- dark surface instead of the light parchment. Sits below the active/highlight
-- textures; the 1px bottom inset leaves a thin gap that separates stacked rows.
local function StyleRowBg(row)
    -- dark "card" with a small side margin so entries read as raised panels
    local bg = row:CreateTexture(nil, "BACKGROUND", nil, -2)
    bg:SetPoint("TOPLEFT", 2, 0)
    bg:SetPoint("BOTTOMRIGHT", -2, 1)
    bg:SetColorTexture(0.05, 0.05, 0.06, 0.62)
    row.rowbg = bg
    -- faint light top bevel (raised-card feel, stands in for rounded corners)
    local bevel = row:CreateTexture(nil, "BORDER")
    bevel:SetHeight(1)
    bevel:SetPoint("TOPLEFT", 3, 0)
    bevel:SetPoint("TOPRIGHT", -3, 0)
    bevel:SetColorTexture(1, 0.96, 0.85, 0.055)
    row.rowbevel = bevel
    -- thin gold hairline between entries
    local sep = row:CreateTexture(nil, "BORDER")
    sep:SetHeight(1)
    sep:SetPoint("BOTTOMLEFT", 3, 0)
    sep:SetPoint("BOTTOMRIGHT", -3, 0)
    sep:SetColorTexture(0.83, 0.66, 0.28, 0.11)
    row.rowsep = sep
end

-- Give a header button a gold hover glow + a brief press flash (modern feel on top
-- of the WoW look). HIGHLIGHT auto-shows on mouseover; the flash is toggled on click.
local function GoldButtonPolish(btn)
    local hl = btn:CreateTexture(nil, "HIGHLIGHT")
    hl:SetPoint("TOPLEFT", 1, -1)
    hl:SetPoint("BOTTOMRIGHT", -1, 1)
    hl:SetColorTexture(1, 0.82, 0.25, 0.20)
    hl:SetBlendMode("ADD")
    local flash = btn:CreateTexture(nil, "OVERLAY")
    flash:SetPoint("TOPLEFT", 1, -1)
    flash:SetPoint("BOTTOMRIGHT", -1, 1)
    flash:SetColorTexture(1, 0.86, 0.35, 0.18)
    flash:SetBlendMode("ADD")
    flash:Hide()
    btn:HookScript("OnMouseDown", function() flash:Show() end)
    btn:HookScript("OnMouseUp", function() flash:Hide() end)
    btn:HookScript("OnHide", function() flash:Hide() end)
end

-- Ornamental gold rule: two halves that fade to transparent at the outer edges
-- (center-bright) with a small gold diamond in the middle. Shared by the header
-- rules and the between-section separators.
local goldFade = CreateColor(0.90, 0.72, 0.32, 0.0)
local goldFull = CreateColor(0.96, 0.80, 0.38, 0.55)
local function CreateFadeDivider(parent)
    local d = {}
    d.left = parent:CreateTexture(nil, "ARTWORK")
    d.left:SetHeight(1); d.left:SetColorTexture(1, 1, 1, 1)
    d.right = parent:CreateTexture(nil, "ARTWORK")
    d.right:SetHeight(1); d.right:SetColorTexture(1, 1, 1, 1)
    d.gem = parent:CreateTexture(nil, "ARTWORK")
    d.gem:SetSize(6, 6); d.gem:SetColorTexture(0.98, 0.84, 0.42, 0.95)
    d.gem:SetRotation(math.rad(45))
    return d
end
local function PlaceFadeDivider(d, parent, yTop)
    d.left:ClearAllPoints()
    d.left:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, -yTop)
    d.left:SetPoint("TOPRIGHT", parent, "TOP", -5, -yTop)
    d.left:SetGradient("HORIZONTAL", goldFade, goldFull)
    d.right:ClearAllPoints()
    d.right:SetPoint("TOPLEFT", parent, "TOP", 5, -yTop)
    d.right:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -14, -yTop)
    d.right:SetGradient("HORIZONTAL", goldFull, goldFade)
    d.gem:ClearAllPoints()
    d.gem:SetPoint("CENTER", parent, "TOP", 0, -yTop)
    d.left:Show(); d.right:Show(); d.gem:Show()
end

-- Ctrl-click note editor. The target waypoint is held in a module-level var
-- rather than passed through StaticPopup's data arg (that arg isn't reliably
-- delivered to OnAccept across client builds, which left the popup stuck open).
local noteTarget  -- { ach, crit, wp, current }
local function saveNoteFromEditBox(editBox)
    if not noteTarget then return end
    O:SetNote(noteTarget.ach, noteTarget.crit, noteTarget.wp, editBox:GetText())
    if Private.RouteHud then Private.RouteHud:Refresh() end
    if Private.NearbyHud then Private.NearbyHud:Refresh() end
    noteTarget = nil
end
StaticPopupDialogs["METATRACKER_WP_NOTE"] = {
    text = "Waypoint note @ %s\n(why this spot / what to do here):",
    button1 = SAVE or "Save",
    button2 = CANCEL or "Cancel",
    hasEditBox = true,
    editBoxWidth = 260,
    maxLetters = 200,
    OnShow = function(self)
        local eb = self.editBox or (self.GetEditBox and self:GetEditBox())
        if not eb then return end
        eb:SetText((noteTarget and noteTarget.current) or "")
        eb:HighlightText()
        eb:SetFocus()
    end,
    OnAccept = function(self)
        local eb = self.editBox or (self.GetEditBox and self:GetEditBox())
        if eb then saveNoteFromEditBox(eb) end
    end,
    OnCancel = function() noteTarget = nil end,
    EditBoxOnEnterPressed = function(self)
        saveNoteFromEditBox(self)
        self:GetParent():Hide()
    end,
    EditBoxOnEscapePressed = function(self) self:GetParent():Hide() end,
    timeout = 0, whileDead = true, hideOnEscape = true, exclusive = true,
}

-- ---------------------------------------------------------------------------
-- Route building: greedy nearest-neighbour path through the incomplete
-- objectives on the current map, starting from the player's position.
-- ---------------------------------------------------------------------------
-- Group the incomplete objectives by criterion (a criterion with several
-- waypoints becomes ONE group with a list of spots), sorted by each group's
-- closest waypoint distance from the player.
-- Within this many yards of the current route step, advance to the next step
-- (e.g. arriving at a cave entrance switches the arrow to the treasure inside).
local STEP_REACH = 12

-- Organise a group's waypoints into ORDERED objects using authored route steps.
-- Each object is a chain of steps (step 1 = entrance/approach ... last = the item).
-- Waypoints with no assignment each become their own single-step object, so groups
-- with no authored steps route exactly as before (nearest waypoint first).
local function AssembleGroupRoute(g)
    local buckets, order, looseSeq = {}, {}, 0
    for _, wobj in ipairs(g.waypoints) do
        local oid = wobj.object
        local b
        if oid and oid > 0 then
            b = buckets[oid]
            if not b then b = { id = oid, steps = {} }; buckets[oid] = b; order[#order + 1] = b end
        else
            looseSeq = looseSeq + 1
            b = { id = -looseSeq, loose = true, steps = {} }
            order[#order + 1] = b
        end
        b.steps[#b.steps + 1] = wobj
    end
    for _, b in ipairs(order) do
        table.sort(b.steps, function(a, c) return (a.step or 1) < (c.step or 1) end)
        b.entrance = b.steps[1]
        b.entranceKey = b.entrance.sortKey
        b.entranceYards = b.entrance.yards
    end
    -- Objects visited nearest-entrance first (the reachable approach point, NOT a
    -- point that happens to sit directly under the player, e.g. an underground item).
    table.sort(order, function(a, c) return a.entranceKey < c.entranceKey end)
    g.objects = order

    -- The nearest object drives the group's target. For a multi-step object we walk
    -- step-by-step: once within reach of the current step, the next becomes the target.
    local nearest = order[1]
    local cur = 1
    if #nearest.steps > 1 and nearest.id > 0 then
        local prog = Private.RouteHud.stepProgress
        local okey = g.key .. "#" .. nearest.id
        local reached = prog[okey] or 0
        cur = math.min(reached + 1, #nearest.steps)
        local wobj = nearest.steps[cur]
        if reached < #nearest.steps and wobj.yards and wobj.yards <= STEP_REACH then
            prog[okey] = cur
            cur = math.min(cur + 1, #nearest.steps)
        end
    end
    g.primary = nearest.steps[cur]
    g.primaryObject = nearest
    g.primaryStepNum = cur

    -- Rebuild display order object-by-object (entrance, deeper, item, next object…).
    local ordered = {}
    for _, b in ipairs(order) do
        for _, wobj in ipairs(b.steps) do
            wobj.objRef = b
            ordered[#ordered + 1] = wobj
        end
    end
    g.waypoints = ordered

    -- Choosing between DIFFERENT criteria uses the distance to the step we actually
    -- need to reach next (the entrance, until we've arrived there).
    g.closestKey = g.primary.sortKey
    g.closestYards = g.primary.yards
end

local function BuildGroups(mapID, px, py)
    local items = O:GetIncompleteOnMap(mapID)
    local total = #items
    local groups, byKey = {}, {}
    for _, e in ipairs(items) do
        -- Skip WQ-only targets (e.g. Nazjatar assault elites): they only exist while
        -- their World Quest is up, at which point they appear in the World Quests
        -- section. Listing their static spawn points in "This zone" is just clutter.
        if not O:IsWorldQuestOnly(e.crit) then
            local key = e.ach.achievementID .. ":" .. (e.crit.criteriaIndex or e.crit.description or "")
            local g = byKey[key]
            if not g then
                g = { ach = e.ach, crit = e.crit, key = key, waypoints = {} }
                byKey[key] = g
                groups[#groups + 1] = g
            end
            local sortKey, yards = O:Distance(mapID, px, py, e.wp.x / 100, e.wp.y / 100)
            local si = O:GetWaypointStep(e.ach, e.crit, e.wp)
            g.waypoints[#g.waypoints + 1] = {
                wp = e.wp, sortKey = sortKey, yards = yards,
                object = si and si.object, step = si and si.step,
            }
        end
    end
    for _, g in ipairs(groups) do
        AssembleGroupRoute(g)
    end
    table.sort(groups, function(a, b) return a.closestKey < b.closestKey end)
    return groups, total
end

local function IsWaypointActive(ach, crit, wp)
    local active = Private.WaypointsUtil:GetActiveTarget()
    return active ~= nil
        and active.achievement == ach and active.criterion == crit
        and active.mapID == wp.mapID and active.x == wp.x and active.y == wp.y
end

local function IsGroupActive(g)
    for _, wobj in ipairs(g.waypoints) do
        if IsWaypointActive(g.ach, g.crit, wobj.wp) then return true end
    end
    return false
end

local function IsRowActive(d)
    if d.group then return IsGroupActive(d.group) end
    if d.wp then return IsWaypointActive(d.ach, d.crit, d.wp) end
    return false
end

-- Best searchable instance name: prefer the pre-cleaned name, else strip
-- "(...)" and any "Mythic:/Heroic:/Normal:" prefix from the criterion.
local function DungeonName(d)
    if d.instance then return d.instance end -- search the actual instance, not a boss name
    if d.name then return d.name end
    local name = d.crit.description or d.ach.name or ""
    name = name:gsub("%s*%b()", ""):gsub("^%s*[MmHhNn]%a+:%s*", "")
    return (name:gsub("^%s*(.-)%s*$", "%1"))
end

-- Open the Group Finder and CLICK the correct sub-tab button. Group Finder group
-- buttons: 1 = Dungeon Finder, 2 = Raid Finder, 3 = Premade Groups.
local function openGroupFinder(tabIndex)
    if C_AddOns and C_AddOns.LoadAddOn then
        pcall(C_AddOns.LoadAddOn, "Blizzard_GroupFinder")
    end
    if PVEFrame_ShowFrame then
        PVEFrame_ShowFrame("GroupFinderFrame")
    elseif TogglePVEFrame then
        TogglePVEFrame()
    end
    if tabIndex and GroupFinderFrame then
        local btn = GroupFinderFrame["groupButton" .. tabIndex]
        if btn and btn.Click then
            pcall(function() btn:Click() end)
        elseif GroupFinderFrame_SelectGroupButton then
            pcall(GroupFinderFrame_SelectGroupButton, tabIndex)
        end
    end
end

-- Premade Groups: switch to the Premade sub-tab, then fill + run the search
-- (category 2 = Dungeons, 3 = Raids). Each Blizzard call is guarded individually
-- so a renamed/missing one never stops the search box from being filled.
local function PremadeSearch(name, category)
    openGroupFinder(3)
    -- Step 1: navigate to the category's search list.
    C_Timer.After(0.2, function()
        local lf = _G.LFGListFrame
        local panel = lf and lf.SearchPanel
        if not panel then return end
        if type(_G.LFGListSearchPanel_SetCategory) == "function" then
            pcall(_G.LFGListSearchPanel_SetCategory, panel, category, 0, 0)
        end
        if type(_G.LFGListFrame_SetActivePanel) == "function" then
            pcall(_G.LFGListFrame_SetActivePanel, lf, panel)
        end
    end)
    -- Step 2: once the list has settled, fill the box and trigger the search the
    -- same way pressing Enter does (so the filter actually applies + sticks).
    C_Timer.After(0.5, function()
        local lf = _G.LFGListFrame
        local panel = lf and lf.SearchPanel
        local box = panel and panel.SearchBox
        if not box then return end
        box:SetText(name)
        if box.SetCursorPosition then box:SetCursorPosition(0) end
        local onEnter = box:GetScript("OnEnterPressed")
        if onEnter then
            pcall(onEnter, box)
        elseif type(_G.LFGListSearchPanel_DoSearch) == "function" then
            pcall(_G.LFGListSearchPanel_DoSearch, panel)
        end
    end)
    if Private.Addon then Private.Addon:Print("Premade Groups: searching for |cff00ff00" .. name .. "|r.") end
end

-- Open the automatic finder (Dungeon Finder tab 1 / Raid Finder tab 2). Ticking &
-- queueing are protected, so we switch to the right tab and tell the player.
local function OpenAutoFinder(tabIndex, name, label)
    openGroupFinder(tabIndex)
    if Private.Addon then
        Private.Addon:Print(label .. ": pick |cff00ff00" .. name ..
            "|r and press Find Group. (Blizzard blocks addons from pressing Queue for you.)")
    end
end

-- Per-row Find Group menu with the routing options.
local function ShowFindGroupMenu(anchor, d)
    local name = DungeonName(d)
    MenuUtil.CreateContextMenu(anchor, function(_, root)
        root:CreateTitle(name)
        if d.difficulty then
            root:CreateTitle("|cff" .. d.difficulty.color .. "Requires " .. d.difficulty.label .. "|r")
        end
        if d.isRaid then
            root:CreateButton("Raid Finder (current LFR)", function() OpenAutoFinder(2, name, "Raid Finder") end)
            root:CreateButton("Premade Groups — Raids (incl. Legacy)", function() PremadeSearch(name, 3) end)
        else
            root:CreateButton("Dungeon Finder (specific)", function() OpenAutoFinder(1, name, "Dungeon Finder") end)
            root:CreateButton("Premade Groups — Dungeons", function() PremadeSearch(name, 2) end)
        end
    end)
end

-- ---------------------------------------------------------------------------
-- Section filter (tick boxes for what the planner/scanner shows)
-- ---------------------------------------------------------------------------
local FILTER_DEFS = {
    { key = "thisZone",    label = "This zone objectives" },
    { key = "otherZones",  label = "Other zones (continent)" },
    { key = "dungeons",    label = "Dungeons & Raids" },
    { key = "delves",      label = "Delves" },
    { key = "worldQuests", label = "World Quests" },
    { key = "quests",      label = "Storylines & Quests" },
    { key = "renown",      label = "Renown & Reputation" },
    { key = "theater",     label = "Theater Troupe" },
}

-- Object types that appear in the "This zone" list, each independently toggleable.
local ZONE_TYPE_DEFS = {
    { key = "treasure",   label = "Treasures" },
    { key = "rare",       label = "Rares" },
    { key = "quest",      label = "Quests & objectives" },
    { key = "explore",    label = "Exploration" },
    { key = "glyph",      label = "Skyriding glyphs" },
    { key = "race",       label = "Races / time trials" },
    { key = "lore",       label = "Lore & books" },
    { key = "flightpath", label = "Flight points" },
    { key = "other",      label = "Other / uncategorised" },
}
local ZONE_TYPE_KEYS = {}
for _, d in ipairs(ZONE_TYPE_DEFS) do ZONE_TYPE_KEYS[d.key] = true end

local function ShowFilterMenu(anchor)
    local flt = Private.Settings:Route().filters
    flt.zoneTypes = flt.zoneTypes or {}
    local zt = flt.zoneTypes
    local function toggle(tbl, key)
        tbl[key] = (tbl[key] == false)
        route:Refresh()
        if MenuResponse then return MenuResponse.Refresh end -- keep menu open
    end
    MenuUtil.CreateContextMenu(anchor, function(_, root)
        root:CreateTitle("Show in planner")
        for _, def in ipairs(FILTER_DEFS) do
            local key = def.key
            root:CreateCheckbox(def.label,
                function() return flt[key] ~= false end,
                function() return toggle(flt, key) end)
            -- right after "This zone objectives": a submenu of its object types
            if key == "thisZone" then
                local sub = root:CreateButton("     |cffaaaaaaThis zone: object types|r")
                for _, td in ipairs(ZONE_TYPE_DEFS) do
                    local tk = td.key
                    sub:CreateCheckbox(td.label,
                        function() return zt[tk] ~= false end,
                        function() return toggle(zt, tk) end)
                end
            end
        end
        root:CreateDivider()
        local rs = Private.Settings:Route()
        -- Auto mode: hands-free track the nearest objective (with a Skip button).
        root:CreateCheckbox("Auto-track nearest (hands-free)",
            function() return rs and rs.autoMode end,
            function()
                if rs then rs.autoMode = not rs.autoMode; route.autoSkipped = {} end
                route:Refresh()
                if MenuResponse then return MenuResponse.Refresh end
            end)
        -- Same, but only while inside an instance (dungeon / raid / delve / scenario).
        root:CreateCheckbox("Auto-track nearest in instances only",
            function() return rs and rs.autoModeInstance end,
            function()
                if rs then rs.autoModeInstance = not rs.autoModeInstance; route.autoSkipped = {} end
                route:Refresh()
                if MenuResponse then return MenuResponse.Refresh end
            end)
        -- Accessibility: bold/outlined text + window scale.
        root:CreateCheckbox("Bold / outlined text (easier to read)",
            function() return rs and rs.boldText end,
            function()
                if rs then rs.boldText = not rs.boldText end
                route:ApplyLook()
                if MenuResponse then return MenuResponse.Refresh end
            end)
        local scaleSub = root:CreateButton("Window scale (size)")
        for _, sc in ipairs({ { "90%", 0.9 }, { "100%", 1.0 }, { "110%", 1.1 }, { "125%", 1.25 }, { "150%", 1.5 } }) do
            local val = sc[2]
            scaleSub:CreateRadio(sc[1],
                function() return rs and math.abs((rs.scale or 1) - val) < 0.001 end,
                function()
                    if rs then rs.scale = val end
                    route:ApplyLook()
                    if MenuResponse then return MenuResponse.Refresh end
                end)
        end
        root:CreateDivider()
        root:CreateButton("Show all", function()
            for _, def in ipairs(FILTER_DEFS) do flt[def.key] = true end
            for _, td in ipairs(ZONE_TYPE_DEFS) do zt[td.key] = true end
            route:Refresh()
            if MenuResponse then return MenuResponse.Refresh end
        end)
    end)
end

-- ---------------------------------------------------------------------------
-- Step rows (this zone's route)
-- ---------------------------------------------------------------------------
local function trackToggle(ach, crit, wp)
    if IsWaypointActive(ach, crit, wp) then
        Private.WaypointsUtil:ClearActiveTarget()
    else
        Private.WaypointsUtil:TrackWaypoint(ach, crit, wp)
    end
    Private.RouteHud:Refresh()
end

-- Resolve a row to a single concrete (ach, crit, wp) -- a sub-row or single-spot
-- group. Multi-spot group headers have no single waypoint (expand them first).
local function ConcreteWP(d)
    if d.wp then return d.ach, d.crit, d.wp end
    if d.group and #d.group.waypoints == 1 then
        return d.group.ach, d.group.crit, d.group.waypoints[1].wp
    end
    return nil
end

-- Candidate offsets for the live waypoint-fix picker. "On file" is the shipped
-- coord; ±4 X covers the common map-rescale shift (e.g. Ringing Deeps 11.1).
local function OffsetVariants(wp)
    local x, y = wp.x or 0, wp.y or 0
    return {
        { label = "On file", x = x,       y = y },
        { label = "X -4",    x = x - 4.0, y = y },
        { label = "X +4",    x = x + 4.0, y = y },
    }
end

-- Alt-left-click => mode "go" (route to each candidate to test it in-game).
-- Alt-right-click => mode "set" (record the chosen candidate as the fix).
local function ShowOffsetMenu(anchor, ach, crit, wp, mode)
    local name = crit.description or ach.name or "Waypoint"
    local variants = OffsetVariants(wp)
    local saved = O:GetWaypointFix(ach, crit, wp)
    MenuUtil.CreateContextMenu(anchor, function(_, root)
        root:CreateTitle(mode == "set" and ("Set correct: " .. name) or ("Test offset: " .. name))
        for _, v in ipairs(variants) do
            local isSaved = saved and math.abs(saved.x - v.x) < 0.05 and math.abs(saved.y - v.y) < 0.05
            -- unsaved on-file with no fix is also the "current" default
            local isDefault = (not saved) and v.label == "On file"
            local mark = (isSaved or isDefault) and "|cff00ff00\226\156\147|r " or ""
            local text = string.format("%s%s  (%.1f, %.1f)", mark, v.label, v.x, v.y)
            root:CreateButton(text, function()
                if mode == "set" then
                    O:SetWaypointFix(ach, crit, wp, v.x, v.y, v.label)
                    if Private.Addon then
                        Private.Addon:Print(string.format("Fix saved for |cffffd700%s|r: |cff00ff00%.1f, %.1f|r (%s)",
                            name, v.x, v.y, v.label))
                    end
                    Private.RouteHud:Refresh()
                    if Private.NearbyHud then Private.NearbyHud:Refresh() end
                else
                    Private.WaypointsUtil:TrackPoint(wp.mapID, v.x, v.y, name .. " [" .. v.label .. "]")
                    if Private.Addon then
                        Private.Addon:Print(string.format("Routing to |cff00ff00%s|r (%.1f, %.1f) \226\128\148 run there, then Alt-right-click to confirm if correct.",
                            v.label, v.x, v.y))
                    end
                end
            end)
        end
        if mode == "set" and saved then
            root:CreateButton("|cffff6666Clear saved fix|r", function()
                O:ClearWaypointFix(ach, crit, wp)
                Private.RouteHud:Refresh()
                if Private.NearbyHud then Private.NearbyHud:Refresh() end
            end)
        end
        -- Route-step authoring: group a criterion's waypoints into ordered objects.
        -- object = which collectible/target; step = order within it (1 = entrance).
        if mode == "set" then
            root:CreateDivider()
            root:CreateTitle("Route step (visit order)")
            local objSub = root:CreateButton("Objective (which target)")
            for i = 1, 12 do
                objSub:CreateRadio(tostring(i),
                    function() local c = O:GetWaypointStep(ach, crit, wp); return c and c.object == i end,
                    function()
                        O:SetWaypointStep(ach, crit, wp, i, nil)
                        if Private.Addon then Private.Addon:Print(string.format(
                            "Route step for |cffffd700%s|r: objective |cff00ff00%d|r.", name, i)) end
                        Private.RouteHud:Refresh()
                        if MenuResponse then return MenuResponse.Refresh end
                    end)
            end
            local stepSub = root:CreateButton("Step (order within objective)")
            for i = 1, 8 do
                stepSub:CreateRadio(i == 1 and "1 (entrance / approach)" or tostring(i),
                    function() local c = O:GetWaypointStep(ach, crit, wp); return c and c.step == i end,
                    function()
                        O:SetWaypointStep(ach, crit, wp, nil, i)
                        if Private.Addon then Private.Addon:Print(string.format(
                            "Route step for |cffffd700%s|r: step |cff00ff00%d|r.", name, i)) end
                        Private.RouteHud:Refresh()
                        if MenuResponse then return MenuResponse.Refresh end
                    end)
            end
            if O:GetWaypointStep(ach, crit, wp) then
                root:CreateButton("|cffff6666Clear route step|r", function()
                    O:ClearWaypointStep(ach, crit, wp)
                    Private.RouteHud:Refresh()
                end)
            end
        end
    end)
end

local function OnStepClick(row, button)
    if not row.data then return end
    local d = row.data
    -- Alt-click => live waypoint-offset picker (Alt-left tests, Alt-right sets).
    if IsAltKeyDown() then
        local ach, crit, wp = ConcreteWP(d)
        if wp then
            ShowOffsetMenu(row, ach, crit, wp, button == "RightButton" and "set" or "go")
        elseif d.group and #d.group.waypoints > 1 then
            Private.RouteHud.expanded[d.key] = true
            Private.RouteHud:Refresh()
            if Private.Addon then
                Private.Addon:Print("Expanded \226\128\148 Alt-click a specific spot to test/fix its offset.")
            end
        end
        return
    end
    -- Shift-click opens the main window at this objective's achievement + guide.
    if IsShiftKeyDown() and button ~= "RightButton" then
        local ach = (d.group and d.group.ach) or d.ach
        if ach then
            local idx = Private.ExpansionUtil:GetExpansionIndexForAchievement(ach.achievementID)
            Private.UI:ShowAchievement(idx, ach.achievementID)
            return
        end
    end
    -- Ctrl-click authors a note on a specific waypoint (a sub-row, or a single-spot
    -- criterion). Multi-spot group headers have no single waypoint, so expand first.
    if IsControlKeyDown() and button ~= "RightButton" then
        local ach, crit, wp
        if d.wp then
            ach, crit, wp = d.ach, d.crit, d.wp
        elseif d.group and #d.group.waypoints == 1 then
            ach, crit, wp = d.group.ach, d.group.crit, d.group.waypoints[1].wp
        end
        if wp then
            noteTarget = { ach = ach, crit = crit, wp = wp, current = O:GetNote(ach, crit, wp) }
            StaticPopup_Show("METATRACKER_WP_NOTE", string.format("%.1f, %.1f", wp.x, wp.y))
            return
        end
    end
    if button == "RightButton" then
        -- ignore: a group ignores all its spots; a sub-row ignores just that spot
        if d.group then
            for _, wobj in ipairs(d.group.waypoints) do
                O:SetIgnored(d.group.ach, d.group.crit, wobj.wp, true)
            end
        else
            O:SetIgnored(d.ach, d.crit, d.wp, true)
        end
        if Private.NearbyHud then Private.NearbyHud:Refresh() end
        Private.RouteHud:Refresh()
        return
    end
    if d.group then
        if #d.group.waypoints > 1 then
            Private.RouteHud.expanded[d.key] = not Private.RouteHud.expanded[d.key]
            Private.RouteHud:Refresh()
        else
            trackToggle(d.group.ach, d.group.crit, d.group.waypoints[1].wp)
        end
    else
        trackToggle(d.ach, d.crit, d.wp) -- a waypoint sub-row
    end
end

local function OnStepEnter(row)
    if not row.data then return end
    local d = row.data
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    local ctype = O:Classify(d.ach, d.crit)
    local header = d.ach.name
    if ctype and O.TYPE_LABELS[ctype] then
        header = "|cffffd700[" .. O.TYPE_LABELS[ctype] .. "]|r " .. header
    end
    GameTooltip:AddLine(header, 1, 0.82, 0)
    GameTooltip:AddLine(d.crit.description or "", 1, 1, 1, true)
    if d.crit.guide and d.crit.guide ~= "" then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Guide:", 1, 0.84, 0)
        GameTooltip:AddLine(d.crit.guide, 0.8, 1, 0.8, true)
    else
        local hint = O:ContextHint(ctype)
        if hint then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(hint, 0.8, 1, 0.8, true)
        end
    end
    -- Status: authored route step + any saved coordinate fix (concrete waypoints only)
    local ach, crit, wp = ConcreteWP(d)
    if wp then
        local fix = O:GetWaypointFix(ach, crit, wp)
        local si = O:GetWaypointStep(ach, crit, wp)
        if fix or (si and si.object) then
            GameTooltip:AddLine(" ")
            if si and si.object then
                GameTooltip:AddLine(string.format("Route step: |cffffd700objective %d, step %d|r%s",
                    si.object, si.step or 1, (si.step or 1) == 1 and "  (entrance)" or ""), 0.6, 0.85, 1)
            end
            if fix then
                GameTooltip:AddLine(string.format("Fix saved: |cff00ff00%.1f, %.1f|r (was %.1f, %.1f)",
                    fix.x, fix.y, fix.origX or wp.x, fix.origY or wp.y), 0, 1, 0)
            end
        end
    end

    -- Actions
    GameTooltip:AddLine(" ")
    if d.group and #d.group.waypoints > 1 then
        local exp = Private.RouteHud.expanded[d.key]
        GameTooltip:AddLine(exp and "Click to collapse."
            or ("Click to expand its " .. #d.group.waypoints .. " steps."), 0, 1, 0)
    elseif IsRowActive(d) then
        GameTooltip:AddLine("Click to clear this waypoint.", 1, 0.5, 0)
    else
        GameTooltip:AddLine("Click to route here.", 0, 1, 0)
    end
    GameTooltip:AddLine("Right-click to ignore (skip).", 0.7, 0.7, 0.7)
    -- note authoring is only possible on a concrete waypoint (sub-row / single spot)
    if d.wp or (d.group and #d.group.waypoints == 1) then
        local existing = d.wp and O:GetNote(d.ach, d.crit, d.wp)
            or (d.group and O:GetNote(d.group.ach, d.group.crit, d.group.waypoints[1].wp))
        GameTooltip:AddLine(existing and "Ctrl-click to edit this waypoint's note."
            or "Ctrl-click to add a note for this waypoint.", 0.5, 0.8, 1)
        if wp then
            GameTooltip:AddLine("Alt-click to TEST offsets; Alt-right-click to SET offset / route step.", 1, 0.7, 0.3)
        end
    end
    GameTooltip:AddLine("Shift-click to open the achievement + guide.", 0, 0.8, 1)
    GameTooltip:Show()
end

local function AcquireStepRow(f, index)
    if f.stepRows[index] then return f.stepRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.active = row:CreateTexture(nil, "BACKGROUND")
    row.active:SetAllPoints()
    row.active:SetColorTexture(1, 0.82, 0, 0.18)
    row.active:Hide()

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)

    row.dist = row:CreateFontString(nil, "OVERLAY")
    row.dist:SetFontObject(PLANNER_FONT)
    row.dist:SetPoint("RIGHT", 0, 0)
    row.dist:SetJustifyH("RIGHT")
    row.dist:SetWidth(46)

    -- Per-row "Find Group" button, shown only on dungeon-type rows.
    row.lfg = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.lfg:SetSize(36, 16)
    row.lfg:SetPoint("RIGHT", row.dist, "LEFT", -2, 0)
    row.lfg:SetText("LFG")
    local lfgFS = row.lfg:GetFontString()
    if lfgFS then lfgFS:SetFontObject(PLANNER_FONT) end
    row.lfg:SetScript("OnClick", function() if row.data then ShowFindGroupMenu(row.lfg, row.data) end end)
    row.lfg:Hide()

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetPoint("RIGHT", row.dist, "LEFT", -4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(1, 0.85, 0, 0.15)

    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    row:SetScript("OnClick", OnStepClick)
    row:SetScript("OnEnter", OnStepEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.stepRows[index] = row
    return row
end

-- ---------------------------------------------------------------------------
-- Zone rows (other zones with objectives)
-- ---------------------------------------------------------------------------
local function OnZoneClick(row)
    local z = row.data
    if not z or not z.mapID then return end
    -- Point at the zone centre (reliable "head here" target, independent of any
    -- individual objective's coordinates) and open the world map to the zone.
    Private.WaypointsUtil:TrackZone(z.mapID, z.name)
    if WorldMapFrame then
        if not WorldMapFrame:IsShown() then WorldMapFrame:Show() end
        WorldMapFrame:SetMapID(z.mapID)
    end
    Private.RouteHud:Refresh()
end

local function OnZoneEnter(row)
    local z = row.data
    if not z then return end
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    GameTooltip:AddLine(z.name, 1, 0.82, 0)
    GameTooltip:AddLine(string.format("%d objective%s remaining here.", z.count, z.count == 1 and "" or "s"), 1, 1, 1)
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine("Click to waypoint this zone + open its map.", 0, 1, 0)
    GameTooltip:AddLine("(fly/ride, or hearth if it's closer).", 0.6, 0.8, 0.6)
    GameTooltip:Show()
end

local function AcquireZoneRow(f, index)
    if f.zoneRows[index] then return f.zoneRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)
    row.icon:SetTexture(ZONE_ICON)

    row.count = row:CreateFontString(nil, "OVERLAY")
    row.count:SetFontObject(PLANNER_FONT)
    row.count:SetPoint("RIGHT", 0, 0)
    row.count:SetJustifyH("RIGHT")
    row.count:SetWidth(40)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetPoint("RIGHT", row.count, "LEFT", -4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(0.3, 0.6, 1, 0.15)

    row:SetScript("OnClick", OnZoneClick)
    row:SetScript("OnEnter", OnZoneEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.zoneRows[index] = row
    return row
end

local function HideFrom(pool, startIndex)
    for i = startIndex, #pool do
        if pool[i] then pool[i].data = nil; pool[i]:Hide() end
    end
end

-- ---------------------------------------------------------------------------
-- Instance rows (dungeons & raids -- no map location)
-- ---------------------------------------------------------------------------
local function OnInstClick(row, button)
    if not row.data then return end
    local d = row.data
    if button == "RightButton" then
        O:SetIgnored(d.ach, d.crit, O:ZeroWaypoint(), true)
        if Private.NearbyHud then Private.NearbyHud:Refresh() end
        Private.RouteHud:Refresh()
        return
    end
    if IsShiftKeyDown() then
        -- Open the main window at this dungeon/raid's achievement + guide.
        Private.UI:ShowAchievement(d.expansionIndex, d.ach.achievementID)
        return
    end
    ShowFindGroupMenu(row, d)
end

local function OnInstEnter(row)
    if not row.data then return end
    local d = row.data
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    GameTooltip:AddLine((d.isRaid and "|cffff8000[Raid]|r " or "|cff40c0ff[Dungeon]|r ") .. (d.instance or d.name), 1, 0.82, 0)
    if d.name and d.instance and d.name:lower() ~= d.instance:lower() then
        GameTooltip:AddLine(d.name .. "  |cffaaaaaa(boss/objective)|r", 1, 1, 1, true)
    end
    GameTooltip:AddLine(" ")
    if d.difficulty then
        local c = d.difficulty.color
        GameTooltip:AddLine("Requires: |cff" .. c .. d.difficulty.label .. "|r", 0.9, 0.9, 0.9)
    end
    GameTooltip:AddLine("Enter / search: |cff00ff00" .. (d.instance or d.name) .. "|r", 0.6, 0.9, 0.6)
    GameTooltip:AddLine("From achievement: " .. (d.ach.name or ""), 0.55, 0.7, 0.55)
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine("Click for group-finder options.", 0, 1, 0)
    GameTooltip:AddLine("Shift-click to open the achievement + guide.", 0, 0.8, 1)
    GameTooltip:AddLine("Right-click to ignore (skip).", 0.7, 0.7, 0.7)
    GameTooltip:Show()
end

-- ---------------------------------------------------------------------------
-- Renown & Reputation rows (current continent's major factions)
-- ---------------------------------------------------------------------------
local function OnRenownEnter(row)
    if not row.data then return end
    local r = row.data
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    GameTooltip:AddLine("|cffffcc33" .. (r.isRenown and "[Renown]" or "[Reputation]") .. "|r " .. (r.name or ""), 1, 0.82, 0)
    if r.isRenown then
        if r.maxed then
            GameTooltip:AddLine("Renown " .. r.renownLevel .. " \226\128\148 |cff00ff00maxed|r", 0, 1, 0)
        else
            GameTooltip:AddLine(string.format("Renown %d  \226\128\148  %d / %d to next", r.renownLevel, r.current, r.threshold), 1, 1, 1)
        end
    else
        if r.maxed then
            GameTooltip:AddLine((r.standingLabel or "Exalted") .. " \226\128\148 |cff00ff00maxed|r", 0, 1, 0)
        elseif r.threshold and r.threshold > 0 then
            GameTooltip:AddLine(string.format("%s  \226\128\148  %d / %d to next", r.standingLabel or "?", r.current, r.threshold), 1, 1, 1)
        else
            GameTooltip:AddLine(r.standingLabel or "?", 1, 1, 1)
        end
    end
    -- what the meta actually needs (often less than max), and whether it's done
    GameTooltip:AddLine(" ")
    if r.met then
        GameTooltip:AddLine("|cff00ff00Requirement met" ..
            (r.required and (" \226\128\148 " .. r.required) or "") .. "|r", 0, 1, 0)
    elseif r.required then
        GameTooltip:AddLine("Needs: |cffffd700" .. r.required .. "|r", 1, 0.82, 0)
    end
    -- surface the linked achievement's criterion guide, if the data has one
    if r.linkedAch and r.linkedAch.guide then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Guide:", 1, 0.84, 0)
        GameTooltip:AddLine(r.linkedAch.guide, 0.8, 1, 0.8, true)
    end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine("Click to open the Reputation panel.", 0, 1, 0)
    if r.linkedAch then
        GameTooltip:AddLine("Shift-click to open its achievement" ..
            (r.linkedAch.achName and (" (" .. r.linkedAch.achName .. ")") or "") .. ".", 0, 0.8, 1)
    end
    GameTooltip:Show()
end

local function OnRenownClick(row)
    if not row.data then return end
    local d = row.data
    if IsShiftKeyDown() and d.linkedAch then
        Private.UI:ShowAchievement(d.linkedAch.expIndex, d.linkedAch.achID)
        return
    end
    if ToggleCharacter then ToggleCharacter("ReputationFrame") end
end

local function AcquireRenownRow(f, index)
    if f.renownRows[index] then return f.renownRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)
    row.icon:SetTexture("Interface\\Icons\\Achievement_Reputation_01")

    row.prog = row:CreateFontString(nil, "OVERLAY")
    row.prog:SetFontObject(PLANNER_FONT)
    row.prog:SetPoint("RIGHT", 0, 0)
    row.prog:SetJustifyH("RIGHT")
    row.prog:SetWidth(130)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetPoint("RIGHT", row.prog, "LEFT", -4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(0.9, 0.8, 0.2, 0.10)

    row:RegisterForClicks("LeftButtonUp")
    row:SetScript("OnClick", OnRenownClick)
    row:SetScript("OnEnter", OnRenownEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.renownRows[index] = row
    return row
end

-- ---------------------------------------------------------------------------
-- Delve rows. Each delve is one expandable header row (route to its entrance +
-- a running "done / total" of its Stories/Discoveries objectives). Expanding it
-- lists those objectives as sub-rows. Entrance coords come from Objectives'
-- DELVE_LOCATIONS. One shared pool renders both header and objective rows.
-- ---------------------------------------------------------------------------
local function RouteToDelve(d)
    if not d then return end
    Private.WaypointsUtil:TrackPoint(d.mapID, d.x, d.y, d.name .. " (Delve entrance)")
end

local function OnDelveClick(row, button)
    local e = row.data
    if not e then return end
    if e.isHeader then
        local d = e.delve
        if IsShiftKeyDown() then
            -- open the delve's achievement + guide (prefer its Stories achievement)
            local ach = (e.storyAch or (d.objectives[1] and d.objectives[1].ach))
            if ach then
                local idx = Private.ExpansionUtil:GetExpansionIndexForAchievement(ach.achievementID)
                Private.UI:ShowAchievement(idx, ach.achievementID)
            end
            return
        end
        if button == "RightButton" then
            RouteToDelve(d) -- right-click routes without expanding
            return
        end
        route.delveExpanded[d.name] = not route.delveExpanded[d.name]
        route:Refresh()
    else
        -- objective sub-row: shift-click opens its achievement, else route to the delve
        local obj = e.obj
        if IsShiftKeyDown() and obj and obj.ach then
            local idx = Private.ExpansionUtil:GetExpansionIndexForAchievement(obj.ach.achievementID)
            Private.UI:ShowAchievement(idx, obj.ach.achievementID)
            return
        end
        RouteToDelve(e.delve)
    end
end

local function OnDelveEnter(row)
    local e = row.data
    if not e then return end
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    if e.isHeader then
        local d = e.delve
        GameTooltip:AddLine("|cffcc88ff[Delve]|r " .. d.name, 1, 0.82, 0)
        GameTooltip:AddLine("|cffaaaaaa" .. (d.zone or "") .. "|r", 0.8, 0.8, 0.8)
        GameTooltip:AddLine(string.format("Objectives: |cff00ff00%d|r / %d done", d.doneCount, d.totalCount), 0.9, 0.9, 0.9)
        if d.activeStory and d.activeStory ~= "" then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Active on the map now:", 1, 0.84, 0)
            GameTooltip:AddLine(d.activeStory, 0.85, 0.85, 0.85, true)
            if d.activeNeeded then
                GameTooltip:AddLine("|cffffd700The active story is one you still need \226\128\148 do it now!|r", 1, 0.84, 0, true)
            end
        end
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Click to " .. (route.delveExpanded[d.name] and "collapse" or "expand") .. " its objectives.", 0, 1, 0)
        GameTooltip:AddLine("Right-click to route to the entrance.", 0.4, 0.9, 0.6)
        GameTooltip:AddLine("Shift-click to open the achievement + guide.", 0, 0.8, 1)
    else
        local obj = e.obj
        GameTooltip:AddLine((obj.isChest and "|cffffd700[Chest]|r " or "|cff88ccff[Story]|r ") .. (obj.label or ""),
            1, 1, 1, true)
        GameTooltip:AddLine(obj.met and "|cff00ff00Completed|r" or "|cffff8800Not yet done|r", 0.9, 0.9, 0.9)
        GameTooltip:AddLine("From: " .. (obj.ach and obj.ach.name or ""), 0.55, 0.7, 0.55)
        if obj.crit and obj.crit.guide and obj.crit.guide ~= "" then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("|cffffd700Guide:|r", 1, 0.84, 0)
            GameTooltip:AddLine(obj.crit.guide, 0.8, 1, 0.8, true)
        end
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Click to route to the delve entrance.", 0, 1, 0)
        GameTooltip:AddLine("Shift-click to open its achievement.", 0, 0.8, 1)
    end
    GameTooltip:Show()
end

local function AcquireDelveRow(f, index)
    if f.delveRows[index] then return f.delveRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)

    row.travel = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.travel:SetSize(48, 16)
    row.travel:SetPoint("RIGHT", 0, 0)
    row.travel:SetText("Travel")
    local tfs = row.travel:GetFontString()
    if tfs then tfs:SetFontObject(PLANNER_FONT) end
    row.travel:SetScript("OnClick", function() if row.data then RouteToDelve(row.data.delve) end end)

    row.prog = row:CreateFontString(nil, "OVERLAY")
    row.prog:SetFontObject(PLANNER_FONT)
    row.prog:SetPoint("RIGHT", 0, 0)
    row.prog:SetJustifyH("RIGHT")
    row.prog:SetWidth(70)

    -- Callout box: the still-needed story that's live on the map right now. Sits
    -- just left of the Travel button, parallel to the delve name (headers only).
    row.storyBox = CreateFrame("Frame", nil, row)
    row.storyBox:SetHeight(15)
    row.storyBox.border = row.storyBox:CreateTexture(nil, "BACKGROUND")
    row.storyBox.border:SetAllPoints()
    row.storyBox.border:SetColorTexture(0.25, 0.85, 0.35, 0.65)
    row.storyBox.bg = row.storyBox:CreateTexture(nil, "BORDER")
    row.storyBox.bg:SetPoint("TOPLEFT", 1, -1)
    row.storyBox.bg:SetPoint("BOTTOMRIGHT", -1, 1)
    row.storyBox.bg:SetColorTexture(0.05, 0.16, 0.06, 0.95)
    row.storyBox.text = row.storyBox:CreateFontString(nil, "OVERLAY")
    row.storyBox.text:SetFontObject(PLANNER_FONT)
    row.storyBox.text:SetPoint("CENTER")
    row.storyBox.text:SetTextColor(0.35, 1, 0.4)
    row.storyBox:SetPoint("RIGHT", row.travel, "LEFT", -6, 0)
    row.storyBox:Hide()

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(0.8, 0.5, 1, 0.10)

    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    row:SetScript("OnClick", OnDelveClick)
    row:SetScript("OnEnter", OnDelveEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.delveRows[index] = row
    return row
end

local function AcquireInstRow(f, index)
    if f.instRows[index] then return f.instRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)

    row.find = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.find:SetSize(48, 16)
    row.find:SetPoint("RIGHT", 0, 0)
    row.find:SetText("Find")
    local ffs = row.find:GetFontString()
    if ffs then ffs:SetFontObject(PLANNER_FONT) end
    row.find:SetScript("OnClick", function() if row.data then ShowFindGroupMenu(row.find, row.data) end end)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetPoint("RIGHT", row.find, "LEFT", -4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(0.4, 1, 0.7, 0.12)

    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    row:SetScript("OnClick", OnInstClick)
    row:SetScript("OnEnter", OnInstEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.instRows[index] = row
    return row
end

-- ---------------------------------------------------------------------------
-- World Quest rows (live, current + nearby zones)
-- ---------------------------------------------------------------------------
local WQ_ICON = "Interface\\GossipFrame\\AvailableQuestIcon"

local function OnWQClick(row)
    local q = row.data
    if not q then return end
    -- a checklist criterion row: shift-click opens the achievement; a plain click
    -- routes to that criterion's spot (or super-tracks the WQ if it has no waypoint).
    if q.isCrit then
        if IsShiftKeyDown() and q.ach then
            local idx = Private.ExpansionUtil:GetExpansionIndexForAchievement(q.ach.achievementID)
            Private.UI:ShowAchievement(idx, q.ach.achievementID)
            return
        end
        if q.wp and q.crit then
            Private.WaypointsUtil:TrackWaypoint(q.ach, q.crit, q.wp)
        elseif C_SuperTrack and C_SuperTrack.SetSuperTrackedQuestID and q.questID then
            pcall(C_SuperTrack.SetSuperTrackedQuestID, q.questID)
        end
        Private.RouteHud:Refresh()
        return
    end
    -- a waypoint sub-row: route straight to that spot
    if q.isWp then
        if q.isAuto then
            Private.WaypointsUtil:TrackPoint(q.mapID, q.wp.x, q.wp.y, q.title)
            if C_SuperTrack and C_SuperTrack.SetSuperTrackedQuestID and q.questID then
                pcall(C_SuperTrack.SetSuperTrackedQuestID, q.questID)
            end
        elseif q.crit then Private.WaypointsUtil:TrackWaypoint(q.ach, q.crit, q.wp)
        else Private.WaypointsUtil:TrackPoint(q.wp.mapID, q.wp.x, q.wp.y, q.title) end
        Private.RouteHud:Refresh()
        return
    end
    -- Shift-click opens the meta achievement this WQ counts toward, in the main window.
    if IsShiftKeyDown() and q.relAch then
        local idx = Private.ExpansionUtil:GetExpansionIndexForAchievement(q.relAch.achievementID)
        Private.UI:ShowAchievement(idx, q.relAch.achievementID)
        return
    end
    -- Click toggles the dropdown ONLY for a multi-criterion achievement checklist.
    -- Every other WQ falls through to route at the live quest location below.
    local achCrits = q.relAch and q.relAch.criteria
    local hasChecklist = q.relAch and q.relAch.wqChecklist and achCrits and #achCrits > 1
    if hasChecklist then
        local key = "wq" .. q.questID
        route.wpExpanded[key] = not route.wpExpanded[key]
        Private.RouteHud:Refresh()
        return
    end
    -- Otherwise set a TomTom pin / crazy arrow to the WQ and super-track it natively.
    Private.WaypointsUtil:TrackPoint(q.mapID, (q.x or 0.5) * 100, (q.y or 0.5) * 100, q.title)
    if C_SuperTrack and C_SuperTrack.SetSuperTrackedQuestID then
        pcall(C_SuperTrack.SetSuperTrackedQuestID, q.questID)
    end
    Private.RouteHud:Refresh()
end

local function OnWpSubEnter(row)
    local q = row.data
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    GameTooltip:AddLine(string.format("|cffffd700Waypoint|r  %.0f, %.0f", q.wp.x, q.wp.y), 1, 0.82, 0)
    local note = q.crit and O:GetNote(q.ach, q.crit, q.wp)
    if note and note ~= "" then GameTooltip:AddLine(note, 0.8, 1, 0.8, true) end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine("Click to route here.", 0, 1, 0)
    GameTooltip:Show()
end

local function OnWQEnter(row)
    local q = row.data
    if not q then return end
    if q.isCrit then
        GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
        local done = O:IsCriterionComplete(q.ach, q.crit)
        GameTooltip:AddLine((done and "|cff55aa55[Done]|r " or "|cff00ff00[Still needed]|r ")
            .. (q.crit.description or ""), 1, 0.82, 0)
        if q.crit.guide and q.crit.guide ~= "" then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("|cffffd700How:|r", 1, 0.84, 0)
            GameTooltip:AddLine(q.crit.guide, 0.8, 1, 0.8, true)
        end
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(done and "Already complete \226\128\148 pick a |cff00ff00[NEEDED]|r one instead."
            or "Click to route here.", 0, 1, 0)
        GameTooltip:AddLine("Shift-click to open the achievement.", 0, 0.8, 1)
        GameTooltip:Show()
        return
    end
    if q.isWp then return OnWpSubEnter(row) end
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    -- Section 1: identity
    GameTooltip:AddLine("|cff33bbff[World Quest]|r " .. q.title, 1, 0.82, 0)
    GameTooltip:AddLine("Zone: " .. (q.mapName or "?"), 0.7, 0.85, 0.7)
    local achCrits = q.relevant and q.relAch and q.relAch.criteria
    local hasChecklist = q.relAch and q.relAch.wqChecklist and achCrits and #achCrits > 1
    -- Section 2: what it counts toward (+ checklist summary / criterion / guide)
    if q.relevant and q.relAch then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("|cffffd700Counts toward:|r " .. (q.relAch.name or ""), 1, 0.84, 0)
        if hasChecklist then
            -- summarise remaining criteria so it's obvious there's a checklist
            local remain = 0
            for _, c in ipairs(achCrits) do if not O:IsCriterionComplete(q.relAch, c) then remain = remain + 1 end end
            if remain > 0 then
                GameTooltip:AddLine(string.format("  |cff00ff00%d of %d still needed|r \226\128\148 expand to pick one.",
                    remain, #achCrits), 0.9, 1, 0.9, true)
            else
                GameTooltip:AddLine("  |cff55aa55All parts done.|r", 0.7, 0.9, 0.7)
            end
        else
            if q.relCrit and q.relCrit.description then
                GameTooltip:AddLine("  " .. q.relCrit.description, 0.9, 0.9, 0.6, true)
            end
            if q.relCrit and q.relCrit.guide and q.relCrit.guide ~= "" then
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine("|cffffd700Guide:|r", 1, 0.84, 0)
                GameTooltip:AddLine(q.relCrit.guide, 0.8, 1, 0.8, true)
            end
        end
    end
    -- Section 3: time left
    local mins = O:GetQuestTimeLeft(q.questID)
    if mins and mins > 0 then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Time left: " .. O:FormatTimeLeft(mins), 1, 0.82, 0)
    end
    -- Section 4: actions
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(hasChecklist and "Click to expand the checklist (which parts you still need)."
        or "Click to set a waypoint / crazy arrow.", 0, 1, 0)
    if q.relevant and q.relAch then
        GameTooltip:AddLine("Shift-click to open its achievement" ..
            (q.relAch.name and (" (" .. q.relAch.name .. ")") or "") .. ".", 0, 0.8, 1)
    end
    GameTooltip:Show()
end

local function AcquireWQRow(f, index)
    if f.wqRows[index] then return f.wqRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)
    row.icon:SetTexture(WQ_ICON)

    row.time = row:CreateFontString(nil, "OVERLAY")
    row.time:SetFontObject(PLANNER_FONT)
    row.time:SetPoint("RIGHT", 0, 0)
    row.time:SetJustifyH("RIGHT")
    row.time:SetWidth(60)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetPoint("RIGHT", row.time, "LEFT", -4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(0.2, 0.7, 1, 0.15)

    row:SetScript("OnClick", OnWQClick)
    row:SetScript("OnEnter", OnWQEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.wqRows[index] = row
    return row
end

-- ---------------------------------------------------------------------------
-- Storyline / Quest rows (meta-required questlines + in-log quests)
-- ---------------------------------------------------------------------------
local Q_AVAIL_ICON = "Interface\\GossipFrame\\AvailableQuestIcon"
local Q_ACTIVE_ICON = "Interface\\GossipFrame\\ActiveQuestIcon"

local function OnQuestClick(row)
    local q = row.data
    if not q then return end
    -- a waypoint sub-row: route straight to that spot
    if q.isWp then
        if q.isAuto then
            Private.WaypointsUtil:TrackPoint(q.mapID, q.wp.x, q.wp.y, q.title)
            if C_SuperTrack and C_SuperTrack.SetSuperTrackedQuestID and q.questID then
                pcall(C_SuperTrack.SetSuperTrackedQuestID, q.questID)
            end
        elseif q.crit then Private.WaypointsUtil:TrackWaypoint(q.ach, q.crit, q.wp)
        else Private.WaypointsUtil:TrackPoint(q.wp.mapID, q.wp.x, q.wp.y, q.title) end
        Private.RouteHud:Refresh()
        return
    end
    if IsShiftKeyDown() and q.ach then
        local idx = Private.ExpansionUtil:GetExpansionIndexForAchievement(q.ach.achievementID)
        Private.UI:ShowAchievement(idx, q.ach.achievementID)
        return
    end
    -- If the meta criterion has MULTIPLE hand-authored waypoints, click toggles the
    -- dropdown (must match the renderer's #wps > 1, or a single-waypoint criterion
    -- would swallow the click here without the renderer ever showing sub-rows).
    local wps = q.crit and q.crit.waypoints
    if wps and #wps > 1 then
        local key = "q" .. (q.questID or tostring(q))
        route.wpExpanded[key] = not route.wpExpanded[key]
        Private.RouteHud:Refresh()
        return
    end
    -- storylines carry a map position (route to the start); everything gets the
    -- native super-track so the on-screen arrow follows the quest's objectives.
    if q.x and q.y and q.mapID then
        Private.WaypointsUtil:TrackPoint(q.mapID, (q.x or 0.5) * 100, (q.y or 0.5) * 100, q.title)
    end
    if C_SuperTrack and C_SuperTrack.SetSuperTrackedQuestID and q.questID then
        pcall(C_SuperTrack.SetSuperTrackedQuestID, q.questID)
    end
    Private.RouteHud:Refresh()
end

local function OnQuestEnter(row)
    local q = row.data
    if not q then return end
    if q.isWp then return OnWpSubEnter(row) end
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    local tag = q.unavailable and "|cffcc6666[Locked]|r"
        or ("|cffffcc33" .. (q.kind == "storyline" and "[Storyline]" or "[Quest]") .. "|r")
    -- Section 1: identity (name / storyline / zone)
    GameTooltip:AddLine(tag .. " " .. (q.questName or q.title or ""), 1, 0.82, 0)
    if q.storyName and q.storyName ~= "" and q.storyName:lower() ~= (q.questName or ""):lower() then
        GameTooltip:AddLine("Storyline: |cff9a9a9a" .. q.storyName .. "|r", 0.7, 0.85, 0.7)
    end
    if q.mapName and q.mapName ~= "" then GameTooltip:AddLine("Zone: " .. q.mapName, 0.7, 0.85, 0.7) end

    -- Section 2: status + storyline progress
    GameTooltip:AddLine(" ")
    if q.unavailable then
        GameTooltip:AddLine("|cffcc6666Prerequisite not met|r \226\128\148 this storyline isn't available to "
            .. "you yet; finish the earlier one(s) in this achievement first.", 0.9, 0.72, 0.72, true)
    elseif q.onQuest then
        GameTooltip:AddLine(q.isComplete and "|cff00ff00Ready to turn in|r"
            or "|cffffd700In progress \226\128\148 continue it|r", 0.9, 0.9, 0.6)
    else
        GameTooltip:AddLine("|cff88ff88Not started \226\128\148 pick it up here|r", 0.6, 0.9, 0.6)
    end
    -- which quest you're on + how many remain (dynamic; questline API)
    if q.questLineID and C_QuestLine and C_QuestLine.GetQuestLineQuests then
        local ok, qids = pcall(C_QuestLine.GetQuestLineQuests, q.questLineID)
        if ok and type(qids) == "table" and #qids > 0 then
            local total, done, curIdx = #qids, 0, nil
            for i, qid in ipairs(qids) do
                if C_QuestLog.IsQuestFlaggedCompleted and C_QuestLog.IsQuestFlaggedCompleted(qid) then done = done + 1 end
                if C_QuestLog.IsOnQuest and C_QuestLog.IsOnQuest(qid) then curIdx = i end
            end
            GameTooltip:AddLine(string.format("Storyline: |cffffd700%d/%d|r done \226\128\148 |cff88ccff%d to go|r",
                done, total, total - done), 0.8, 0.9, 1)
            if curIdx then GameTooltip:AddLine(string.format("You're on quest %d of %d.", curIdx, total), 0.8, 0.9, 1) end
        end
    end

    -- Section 3: what it counts toward (+ guide, spaced off on its own)
    if q.ach then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("|cffffd700Counts toward:|r " .. (q.ach.name or ""), 1, 0.84, 0)
        if q.crit and q.crit.description then GameTooltip:AddLine("  " .. q.crit.description, 0.9, 0.9, 0.6, true) end
        if q.crit and q.crit.guide and q.crit.guide ~= "" then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("|cffffd700Guide:|r", 1, 0.84, 0)
            GameTooltip:AddLine(q.crit.guide, 0.8, 1, 0.8, true)
        end
    end

    -- Section 4: actions
    GameTooltip:AddLine(" ")
    if q.unavailable then
        GameTooltip:AddLine("Not available yet \226\128\148 can't route to it.", 0.7, 0.7, 0.7)
    else
        GameTooltip:AddLine("Click to route / super-track it.", 0, 1, 0)
    end
    if q.ach then GameTooltip:AddLine("Shift-click to open its achievement.", 0, 0.8, 1) end
    GameTooltip:Show()
end

local function AcquireQuestRow(f, index)
    if f.questRows[index] then return f.questRows[index] end
    local row = CreateFrame("Button", nil, f)
    row:SetHeight(ROW_HEIGHT)
    StyleRowBg(row)

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(15, 15)
    row.icon:SetPoint("LEFT", 0, 0)

    row.status = row:CreateFontString(nil, "OVERLAY")
    row.status:SetFontObject(PLANNER_FONT)
    row.status:SetPoint("RIGHT", 0, 0)
    row.status:SetJustifyH("RIGHT")
    row.status:SetWidth(60)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(PLANNER_FONT)
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
    row.label:SetPoint("RIGHT", row.status, "LEFT", -4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    local hl = row:CreateTexture(nil, "HIGHLIGHT")
    hl:SetAllPoints()
    hl:SetColorTexture(1, 0.72, 0.3, 0.13)

    row:RegisterForClicks("LeftButtonUp")
    row:SetScript("OnClick", OnQuestClick)
    row:SetScript("OnEnter", OnQuestEnter)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)
    f.questRows[index] = row
    return row
end

-- ---------------------------------------------------------------------------
-- Refresh
-- ---------------------------------------------------------------------------
function route:Refresh()
    local f = self.frame
    if not f or not f:IsShown() then return end

    -- GetBestMapForUnit can briefly return nil (and 0 is TRUTHY in Lua, so it must be
    -- excluded explicitly) during phase/scenario transitions -- e.g. early Nazjatar.
    -- Fall back to the last resolved zone so the planner doesn't blank to "Unknown".
    local mapID = C_Map.GetBestMapForUnit("player")
    if not mapID or mapID == 0 then
        mapID = self.lastMapID
    else
        self.lastMapID = mapID
    end
    local groups, total = {}, 0
    if mapID then
        local pos = C_Map.GetPlayerMapPosition(mapID, "player")
        local px, py = 0.5, 0.5
        if pos then px, py = pos:GetXY() end
        groups, total = BuildGroups(mapID, px, py)
    end

    local s = Private.Settings:Route()
    local maxSteps = math.min(s.maxRows or 12, 20)
    -- Filter button: a whole section is shown unless explicitly false; and within
    -- "This zone", each objective TYPE can be ticked off independently.
    local filt = s.filters or {}
    local function on(k) return filt[k] ~= false end

    -- "This zone" object-type filter: drop any objective whose type is unticked.
    if on("thisZone") then
        local zt = filt.zoneTypes or {}
        local kept = {}
        for _, g in ipairs(groups) do
            local t = O:Classify(g.ach, g.crit)
            t = (t and ZONE_TYPE_KEYS[t]) and t or "other"
            if zt[t] ~= false then kept[#kept + 1] = g end
        end
        groups = kept
    else
        groups = {}
    end
    self.groups = groups

    -- Auto mode: hands-free track the nearest non-skipped, non-ignored objective.
    -- Re-picks as the player moves; the Track button becomes "Skip" to move past one.
    -- Active either everywhere (autoMode) or only inside instances (autoModeInstance).
    local autoActive = self:AutoActive()
    self.autoCurrent = nil
    if autoActive then
        for _, g in ipairs(groups) do
            local wp = g.primary and g.primary.wp
            if wp and not self.autoSkipped[g.key] and not O:IsIgnored(g.ach, g.crit, wp) then
                self.autoCurrent = g
                local active = Private.WaypointsUtil:GetActiveTarget()
                -- Compare the exact waypoint (not just the criterion) so auto mode
                -- follows the authored route steps (entrance -> item) within one objective.
                if not (active and active.waypoint == wp) then
                    Private.WaypointsUtil:TrackWaypoint(g.ach, g.crit, wp)
                end
                break
            end
        end
    end

    -- Header text (the "N left" count reflects the whole zone, not the view filter)
    local zoneName = (mapID and C_Map.GetMapInfo(mapID) and C_Map.GetMapInfo(mapID).name) or "Unknown"
    f.subtitle:SetText(string.format("|cfff5dd9a%s|r   |cff8f8f8f%d left|r", zoneName, total))
    f.hint:SetText(string.format("|cffffd700Hearth:|r %s  |cff888888· fly/ride to targets|r", GetBindLocation() or "?"))
    if autoActive then
        f.trackBtn:SetText("Skip Next")
        f.trackBtn:SetEnabled(self.autoCurrent ~= nil)
    else
        f.trackBtn:SetText("Track Nearest")
        f.trackBtn:SetEnabled(#groups > 0)
    end
    f.ignoredBtn:SetText(string.format("Ignored (%d)", O:IgnoredCount()))

    -- Other zones on the same continent
    local zones = {}
    if mapID and on("otherZones") then
        local continent = O:GetContinentID(mapID)
        for _, z in ipairs(O:GetZoneSummaries(continent)) do
            if z.mapID ~= mapID then zones[#zones + 1] = z end
        end
    end

    -- ---- Lay out the dynamic sections top-down ----
    local y = CONTENT_TOP
    local function place(region, height)
        region:ClearAllPoints()
        region:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -y)
        region:SetPoint("TOPRIGHT", f, "TOPRIGHT", -12, -y)
        y = y + height
    end

    -- Ornamental gold rule between MAJOR sections (pooled, reused per refresh).
    local dividerShown = 0
    local function placeSeparator()
        y = y + 4
        dividerShown = dividerShown + 1
        local d = f.dividers[dividerShown]
        if not d then d = CreateFadeDivider(f); f.dividers[dividerShown] = d end
        PlaceFadeDivider(d, f, y)
        y = y + 6
    end

    -- Theater Troupe: show a line when the next play is one still needed
    local theaterShown = false
    local tline = on("theater") and Private.TheaterTroupe and Private.TheaterTroupe:GetPlannerLine()
    if tline then
        f.theater.label:SetText(tline)
        -- This line can wrap to 2 rows (long text, or bold/scaled font), so size the
        -- section to the wrapped text height instead of a fixed row, or it clips the
        -- "This zone" header below it.
        f.theater:SetWidth(WIDTH - 24)
        local th = math.max(SECTION_H, math.ceil(f.theater.label:GetStringHeight() or 0) + 5)
        place(f.theater, th)
        f.theater:Show()
        theaterShown = true
    else
        f.theater:Hide()
    end

    -- This zone (criteria grouped; a multi-waypoint criterion is ONE expandable row)
    local stepShown = 0
    if on("thisZone") and #groups > 0 then
        f.thisHeader:Show()
        f.zoneNote:Hide()
        place(f.thisHeader, SECTION_H)
        -- Pin an actively-tracked or expanded objective to the FRONT and exempt it from
        -- the row cap, so passing by closer objectives can't reorder it away or push it
        -- off the list while you're working on it.
        local function pinned(g) return IsGroupActive(g) or self.expanded[g.key] end
        local disp = {}
        for _, g in ipairs(groups) do if pinned(g) then disp[#disp + 1] = g end end
        for _, g in ipairs(groups) do if not pinned(g) then disp[#disp + 1] = g end end
        local gi = 1
        while gi <= #disp do
            local g = disp[gi]
            if not pinned(g) and stepShown >= maxSteps then break end
            local multi = #g.waypoints > 1
            local expanded = multi and self.expanded[g.key]
            local ctype = O:Classify(g.ach, g.crit)

            stepShown = stepShown + 1
            local row = AcquireStepRow(f, stepShown)
            row.data = { group = g, key = g.key, ach = g.ach, crit = g.crit }
            if not O:ApplyTypeIcon(row.icon, ctype) then
                row.icon:SetTexCoord(0, 1, 0, 1)
                row.icon:SetTexture(select(10, GetAchievementInfo(g.ach.achievementID))
                    or "Interface\\Icons\\INV_Misc_QuestionMark")
            end
            local arrow = multi and (expanded and "|cffffd700-|r " or "|cffffd700+|r ") or ""
            -- authored objectives: show "(N steps · M objectives)" so multi-collect criteria read clearly
            local objCount = 0
            if g.objects then for _, b in ipairs(g.objects) do if not b.loose then objCount = objCount + 1 end end end
            local countTag = ""
            if multi then
                countTag = objCount > 0
                    and string.format("  |cff888888(%d step%s · %d objective%s)|r",
                        #g.waypoints, #g.waypoints == 1 and "" or "s", objCount, objCount == 1 and "" or "s")
                    or string.format("  |cff888888(%d waypoints)|r", #g.waypoints)
            end
            -- current route step of the nearest authored (multi-step) object
            local routeTag = ""
            if g.primaryObject and g.primaryObject.id > 0 and #g.primaryObject.steps > 1 then
                routeTag = string.format("  |cff88ccff[step %d/%d]|r", g.primaryStepNum, #g.primaryObject.steps)
            end
            -- single-spot criteria show their note inline (multi-spot notes live on sub-rows)
            local noteTag = ""
            local fixTag = ""
            if not multi then
                local n = O:GetNote(g.ach, g.crit, g.waypoints[1].wp)
                if n and n ~= "" then noteTag = "  |cff88ccff\226\128\148 " .. n .. "|r" end
                local fx = O:GetWaypointFix(g.ach, g.crit, g.waypoints[1].wp)
                if fx then fixTag = string.format("  |cff00ff00[fix %.1f,%.1f]|r", fx.x, fx.y) end
            end
            -- objectives gated behind a world quest: flag whether that WQ is up now
            local wqTag = ""
            local reqWQ = O:GetRequiredWQ(g.crit)
            if reqWQ then
                if O:IsWQActive(reqWQ, mapID) then
                    wqTag = "  |cff00ff00[" .. reqWQ .. " \226\128\148 UP now!]|r"
                else
                    wqTag = "  |cffff8800[needs WQ: " .. reqWQ .. "]|r"
                end
            end
            row.label:SetText(arrow .. gi .. ". " .. (g.crit.description or g.ach.name) .. countTag .. routeTag .. noteTag .. fixTag .. wqTag)
            row.dist:SetText(O:FormatDistance(g.closestYards))
            row.active:SetShown(IsGroupActive(g))
            -- rows are pooled with the sub-waypoint rows (which indent deeper), so
            -- re-assert the parent indent every time this row is used as a header.
            row.label:SetPoint("LEFT", row.icon, "RIGHT", 5, 0)
            if ctype == "dungeon" then
                row.lfg:Show()
                row.label:SetPoint("RIGHT", row.lfg, "LEFT", -4, 0)
            else
                row.lfg:Hide()
                row.label:SetPoint("RIGHT", row.dist, "LEFT", -4, 0)
            end
            place(row, ROW_HEIGHT)
            row:Show()

            if expanded then
                -- Show ALL of an expanded group's waypoints, even past maxSteps -
                -- the player expanded it on purpose and needs every spot to finish it.
                for wi, wobj in ipairs(g.waypoints) do
                    stepShown = stepShown + 1
                    local srow = AcquireStepRow(f, stepShown)
                    srow.data = { ach = g.ach, crit = g.crit, wp = wobj.wp }
                    srow.icon:SetTexture(nil)
                    srow.lfg:Hide()
                    -- Hierarchy WITHIN an objective: step 1 (the entrance, "N.1") is the
                    -- primary sub-row; steps 2+ ("N.2", "N.3" …) nest UNDER it -- extra
                    -- indent + darker text -- so a glance shows they follow the entrance.
                    local step = wobj.step or 1
                    local deep = (wobj.object and wobj.object > 0) and step > 1
                    srow.label:SetPoint("LEFT", srow, "LEFT", deep and 48 or 30, 0)
                    srow.label:SetPoint("RIGHT", srow.dist, "LEFT", -4, 0)
                    local note = O:GetNote(g.ach, g.crit, wobj.wp)
                    local fx = O:GetWaypointFix(g.ach, g.crit, wobj.wp)
                    local fixMark = fx and string.format("  |cff00ff00[fix %.1f,%.1f]|r", fx.x, fx.y) or ""
                    -- authored route tag: [object.step], gold when this is the entrance (step 1)
                    local stepTag = ""
                    if wobj.object and wobj.object > 0 then
                        stepTag = string.format("|cff%s[%d.%d]|r ", (step == 1) and "ffd700" or "88ccff",
                            wobj.object, step)
                    end
                    local bulletC = deep and "5a5a5a" or "888888"
                    local noteC   = deep and "8a8a8a" or "c8c8c8"
                    local coordC  = deep and "5a5a5a" or "777777"
                    if note and note ~= "" then
                        srow.label:SetText(string.format("|cff%s\226\128\162|r %s|cff%s%s|r  |cff%s(%.0f, %.0f)|r%s",
                            bulletC, stepTag, noteC, note, coordC, wobj.wp.x, wobj.wp.y, fixMark))
                    else
                        srow.label:SetText(string.format("|cff%s\226\128\162|r %s|cff%s%.0f, %.0f|r%s",
                            bulletC, stepTag, coordC, wobj.wp.x, wobj.wp.y, fixMark))
                    end
                    srow.dist:SetText(O:FormatDistance(wobj.yards))
                    srow.active:SetShown(IsWaypointActive(g.ach, g.crit, wobj.wp))
                    place(srow, ROW_HEIGHT)
                    srow:Show()
                end
            end
            gi = gi + 1
        end
        if gi <= #disp then
            f.more:SetText(string.format("|cff888888+ %d more objectives here…|r", #disp - (gi - 1)))
            y = y + 5 -- pad above so the line sits centred between the last row and the divider
            place(f.more, SECTION_H - 5)
            f.more:Show()
        else
            f.more:Hide()
        end
    else
        f.thisHeader:Hide()
        f.more:Hide()
        -- zone empty, but the continent has other tracked zones -> reassure + point below
        if on("thisZone") and #zones > 0 then
            f.zoneNote:SetText("|cff888888Nothing to complete in this zone \226\128\148 other tracked zones on this continent are listed below.|r")
            place(f.zoneNote, 34)
            f.zoneNote:Show()
        else
            f.zoneNote:Hide()
        end
    end
    HideFrom(f.stepRows, stepShown + 1)

    -- Other zones (collapsible)
    local zoneShown = 0
    if #zones > 0 then
        placeSeparator()
        f.otherHeader:Show()
        f.otherHeader.text:SetText(string.format("|cff66aaff%s Other zones on this continent (%d)|r",
            s.otherZonesCollapsed and "[+]" or "[-]", #zones))
        place(f.otherHeader, SECTION_H)
        if not s.otherZonesCollapsed then
            for i = 1, math.min(#zones, MAX_ZONE_ROWS) do
                local z = zones[i]
                local row = AcquireZoneRow(f, i)
                row.data = z
                row.label:SetText(z.name)
                row.count:SetText("|cffaaaaaa" .. z.count .. "|r")
                place(row, ROW_HEIGHT)
                row:Show()
                zoneShown = i
            end
        end
    else
        f.otherHeader:Hide()
    end
    HideFrom(f.zoneRows, zoneShown + 1)

    -- Dungeons & Raids (instance objectives with no map location) -- collapsible.
    local instances = on("dungeons") and O:GetInstanceObjectives() or {}
    local instShown = 0
    if #instances > 0 then
        placeSeparator()
        f.instHeader:Show()
        f.instHeader.text:SetText(string.format("|cff66ffaa%s Dungeons & Raids (%d)|r",
            s.dungeonsCollapsed and "[+]" or "[-]", #instances))
        place(f.instHeader, SECTION_H)
        if not s.dungeonsCollapsed then
            for i = 1, math.min(#instances, 12) do
                local d = instances[i]
                local row = AcquireInstRow(f, i)
                row.data = d
                row.icon:SetTexture(O.TYPE_ICONS.dungeon)
                local diffTag = d.difficulty and ("|cff" .. d.difficulty.color .. "[" .. d.difficulty.tag .. "]|r ") or ""
                if d.instance and d.instance:lower() ~= d.name:lower() then
                    row.label:SetText(diffTag .. d.name .. "  |cff888888(" .. d.instance .. ")|r")
                else
                    row.label:SetText(diffTag .. d.name)
                end
                place(row, ROW_HEIGHT)
                row:Show()
                instShown = i
            end
        end
    else
        f.instHeader:Hide()
    end
    HideFrom(f.instRows, instShown + 1)

    -- Delves (War Within) -- one expandable row per delve that still has meta
    -- objectives (Stories bonus objectives + Discoveries chests). The header row
    -- routes to the entrance (Travel / right-click); expand to list its objectives.
    local delves = on("delves") and O:GetDelveObjectives(mapID) or {}
    local delveShown = 0
    if #delves > 0 then
        placeSeparator()
        f.delveHeader:Show()
        f.delveHeader.text:SetText(string.format("|cffcc88ff%s Delves (%d)|r",
            s.delvesCollapsed and "[+]" or "[-]", #delves))
        place(f.delveHeader, SECTION_H)
        if not s.delvesCollapsed then
            for di = 1, #delves do
                local d = delves[di]
                local expanded = route.delveExpanded[d.name]
                local storyAch
                for _, o in ipairs(d.objectives) do
                    if o.ach and o.ach.name and o.ach.name:find(" Stories", 1, true) then storyAch = o.ach; break end
                end
                delveShown = delveShown + 1
                local row = AcquireDelveRow(f, delveShown)
                row.data = { isHeader = true, delve = d, storyAch = storyAch }
                row.icon:SetTexture(O.TYPE_ICONS.dungeon)
                row.icon:Show()
                row.prog:Hide()
                -- Travel/waypoint only helps when you're already in the delve's zone
                -- (cross-zone arrows/pins aren't visible/useful), so only show the
                -- button there; otherwise the callout box + label reach the row edge.
                local inZone = (d.mapID == mapID)
                row.travel:SetShown(inZone)
                local rightEdge = inZone and row.travel or row
                local rightInset = inZone and -6 or -2
                -- live-story callout box, parallel to the name, only when still needed
                if d.activeNeeded and d.activeStoryName then
                    row.storyBox.text:SetText(d.activeStoryName)
                    row.storyBox:SetWidth(row.storyBox.text:GetStringWidth() + 12)
                    row.storyBox:ClearAllPoints()
                    row.storyBox:SetPoint("RIGHT", rightEdge, inZone and "LEFT" or "RIGHT", rightInset, 0)
                    row.storyBox:Show()
                    row.label:SetPoint("RIGHT", row.storyBox, "LEFT", -4, 0)
                else
                    row.storyBox:Hide()
                    row.label:SetPoint("RIGHT", rightEdge, inZone and "LEFT" or "RIGHT", inZone and -4 or -2, 0)
                end
                local arrow = expanded and "|cffcc88ff-|r " or "|cffcc88ff+|r "
                row.label:SetText(string.format("%s%s  |cff888888(%d/%d)|r  |cff666666%s|r",
                    arrow, d.name, d.doneCount, d.totalCount, d.zone or ""))
                place(row, ROW_HEIGHT)
                row:Show()

                if expanded then
                    for _, obj in ipairs(d.objectives) do
                        delveShown = delveShown + 1
                        local srow = AcquireDelveRow(f, delveShown)
                        srow.data = { isHeader = false, delve = d, obj = obj }
                        srow.icon:SetTexture(nil)
                        srow.travel:Hide()
                        srow.storyBox:Hide()
                        srow.prog:Show()
                        srow.label:SetPoint("RIGHT", srow.prog, "LEFT", -4, 0)
                        if obj.met then
                            srow.label:SetText("     |cff888888\226\128\162|r |cff9fe0a0" .. (obj.label or "") .. "|r")
                            srow.prog:SetText("|cff00ff00done|r")
                        elseif obj.activeNow then
                            srow.label:SetText("     |cff33ff66\226\128\162|r |cff33ff66" .. (obj.label or "") .. "|r")
                            srow.prog:SetText("|cff33ff66Live now|r")
                        else
                            srow.label:SetText("     |cff888888\226\128\162|r " .. (obj.label or ""))
                            srow.prog:SetText("")
                        end
                        place(srow, ROW_HEIGHT)
                        srow:Show()
                    end
                end
            end
        end
    else
        f.delveHeader:Hide()
    end
    HideFrom(f.delveRows, delveShown + 1)

    -- World Quests (live, current + nearby zones) -- collapsible. Meta-relevant
    -- ones (data-defined) sort to the top and are highlighted; right-click the
    -- header to show only those.
    local wqs = on("worldQuests") and O:GetWorldQuests(mapID) or {}
    local relCount = 0
    for _, q in ipairs(wqs) do if q.relevant then relCount = relCount + 1 end end
    -- Default: only show meta-relevant WQs (section hides entirely when none are
    -- up). Right-click the header to show every nearby WQ instead.
    local display
    if s.wqRelevantOnly then
        display = {}
        for _, q in ipairs(wqs) do if q.relevant then display[#display + 1] = q end end
    else
        display = wqs
    end
    -- Debug (toggle /drustwqdebug): force one active current-zone WQ into the list
    -- regardless of relevance, so you can confirm the live WQ pipeline works even
    -- when nothing is meta-relevant. It renders with a [debug] tag.
    local debugQID
    if route.wqDebug then
        for _, q in ipairs(wqs) do if q.isCurrent then debugQID = q.questID; break end end
        if debugQID then
            local present = false
            for _, q in ipairs(display) do if q.questID == debugQID then present = true; break end end
            if not present then
                for _, q in ipairs(wqs) do
                    if q.questID == debugQID then table.insert(display, 1, q); break end
                end
            end
        end
    end
    local wqSectionShown, wqShown = false, 0
    if #display > 0 then
        wqSectionShown = true
        placeSeparator()
        f.wqHeader:Show()
        f.wqEmpty:Hide()
        local metaTag
        if s.wqRelevantOnly then
            metaTag = "  |cffffd700meta only|r"
        elseif relCount > 0 then
            metaTag = string.format("  |cffffd700%d meta|r", relCount)
        else
            metaTag = ""
        end
        f.wqHeader.text:SetText(string.format("|cff33bbff%s World Quests (%d)|r%s",
            s.wqCollapsed and "[+]" or "[-]", #display, metaTag))
        place(f.wqHeader, SECTION_H)
        if not s.wqCollapsed then
            for i = 1, math.min(#display, 12) do
                local q = display[i]
                wqShown = wqShown + 1
                local row = AcquireWQRow(f, wqShown)
                row.data = q
                row.icon:SetTexture(q.relevant and "Interface\\Common\\FavoritesIcon" or WQ_ICON)
                row.icon:Show()
                row.time:Show()
                local zoneTag = q.isCurrent and "" or ("  |cff888888(" .. (q.mapName or "") .. ")|r")
                local title = q.relevant and ("|cffffd700" .. q.title .. "|r") or q.title
                if debugQID and q.questID == debugQID and not q.relevant then
                    title = "|cffff66ff[debug]|r " .. title
                end
                -- A meta WQ that feeds a MULTI-criterion achievement (e.g. the orphan
                -- games behind "Work Hard, Play Hard") expands to a CHECKLIST of that
                -- achievement's criteria (OPT-IN per achievement via wqChecklist). Every
                -- other WQ is NOT expandable -- a plain click routes to the live quest
                -- location, which is always accurate; static spawn-point waypoints are
                -- just noise (the player can find cave entrances etc. themselves).
                local relAch = q.relevant and q.relAch
                local achCrits = relAch and relAch.criteria
                local hasChecklist = relAch and relAch.wqChecklist and achCrits and #achCrits > 1
                local checkDone, checkRemain = 0, 0
                if hasChecklist then
                    for _, c in ipairs(achCrits) do
                        if O:IsCriterionComplete(relAch, c) then checkDone = checkDone + 1 else checkRemain = checkRemain + 1 end
                    end
                end
                local expandable = hasChecklist
                local expanded = expandable and route.wpExpanded["wq" .. q.questID]
                local arrow = expandable and (expanded and "|cffffd700-|r " or "|cffffd700+|r ") or ""
                -- checklist progress on the header, e.g. "(3/7 done)"; green when finished
                local doneTag = ""
                if hasChecklist then
                    doneTag = string.format("  |cff%s(%d/%d done)|r",
                        checkRemain == 0 and "00ff00" or "ffcc00", checkDone, #achCrits)
                end
                row.label:SetText(arrow .. title .. doneTag .. zoneTag)
                local mins = O:GetQuestTimeLeft(q.questID)
                local tcol = (mins and mins < 120) and "|cffff8800" or "|cff88ff88"
                row.time:SetText(tcol .. O:FormatTimeLeft(mins) .. "|r")
                place(row, ROW_HEIGHT)
                row:Show()
                if expanded and hasChecklist then
                    -- incomplete criteria FIRST (prioritised + highlighted), completed
                    -- ones after (dimmed with a check) so what's left is obvious.
                    local ordered = {}
                    for _, c in ipairs(achCrits) do ordered[#ordered + 1] = c end
                    table.sort(ordered, function(a, b)
                        local da = O:IsCriterionComplete(relAch, a)
                        local db = O:IsCriterionComplete(relAch, b)
                        if da ~= db then return not da end
                        return (a.criteriaIndex or 0) < (b.criteriaIndex or 0)
                    end)
                    for _, c in ipairs(ordered) do
                        wqShown = wqShown + 1
                        local crow = AcquireWQRow(f, wqShown)
                        local cwp = c.waypoints and c.waypoints[1]
                        crow.data = { isCrit = true, ach = relAch, crit = c, wp = cwp,
                            title = q.title, questID = q.questID }
                        crow.icon:SetTexture(nil); crow.time:SetText("")
                        if O:IsCriterionComplete(relAch, c) then
                            crow.label:SetText("     |cff55aa55\226\156\147|r |cff777777" .. (c.description or "") .. "|r")
                        else
                            crow.label:SetText("     |cff00ff00[NEEDED]|r |cffffd700" .. (c.description or "") .. "|r")
                        end
                        place(crow, ROW_HEIGHT); crow:Show()
                    end
                end
            end
        end
    elseif on("worldQuests") and s.wqRelevantOnly and O:IsMetaZone(mapID) then
        -- Meta zone with no needed WQs currently up: show the dropdown + a 0 note.
        wqSectionShown = true
        placeSeparator()
        f.wqHeader:Show()
        f.wqHeader.text:SetText("|cff33bbff" .. (s.wqCollapsed and "[+]" or "[-]") ..
            " World Quests (0)|r  |cffffd700meta only|r")
        place(f.wqHeader, SECTION_H)
        if not s.wqCollapsed then
            f.wqEmpty:Show()
            place(f.wqEmpty, ROW_HEIGHT)
        else
            f.wqEmpty:Hide()
        end
    else
        f.wqHeader:Hide()
        f.wqEmpty:Hide()
    end
    HideFrom(f.wqRows, wqShown + 1)

    -- Storylines & Quests (meta-required questlines available in the zone/continent,
    -- plus matching quests already in your log) -- collapsible.
    local quests = on("quests") and O:GetNeededQuests(mapID) or {}
    local questShown = 0
    if #quests > 0 then
        placeSeparator()
        f.questHeader:Show()
        f.questHeader.text:SetText(string.format("|cffffb84d%s Storylines & Quests (%d)|r",
            s.questsCollapsed and "[+]" or "[-]", #quests))
        place(f.questHeader, SECTION_H)
        if not s.questsCollapsed then
            for i = 1, math.min(#quests, 12) do
                local q = quests[i]
                questShown = questShown + 1
                local row = AcquireQuestRow(f, questShown)
                row.data = q
                row.icon:SetTexture(q.onQuest and Q_ACTIVE_ICON or Q_AVAIL_ICON)
                row.icon:SetDesaturated(q.unavailable and true or false) -- locked rows greyed
                row.icon:Show()
                row.status:Show()
                local zoneTag = q.isCurrent and "" or ("  |cff888888(" .. (q.mapName or "") .. ")|r")
                local name = q.questName or q.title or "?"
                -- show the specific quest to look for, then the storyline in parens
                local story = ""
                if q.storyName and q.storyName ~= "" and q.storyName:lower() ~= name:lower() then
                    story = " |cff9a9a9a(" .. q.storyName .. ")|r"
                end
                -- dropdown ONLY when there are MULTIPLE authored waypoints (complex);
                -- with 0-1 the automatic quest-giver route is more accurate.
                local wps = q.crit and q.crit.waypoints
                local hasWp = (not q.unavailable) and wps and #wps > 1
                local key = "q" .. (q.questID or i)
                local expanded = hasWp and route.wpExpanded[key]
                local arrow = hasWp and (expanded and "|cffffd700-|r " or "|cffffd700+|r ") or ""
                if q.unavailable then
                    -- locked sibling storyline: greyed, marked as blocked by prerequisites
                    row.label:SetText("|cff8f7676" .. name .. "|r  |cffcc6666(pre-req not met)|r")
                    row.status:SetText("|cffcc6666locked|r")
                else
                    row.label:SetText(arrow .. "|cffffd700" .. name .. "|r" .. story .. zoneTag)
                    if q.isComplete then
                        row.status:SetText("|cff00ff00turn in|r")
                    elseif q.onQuest then
                        row.status:SetText("|cffffd700active|r")
                    elseif q.kind == "storyline" then
                        row.status:SetText("|cff88ccffstory|r")
                    else
                        row.status:SetText("")
                    end
                end
                place(row, ROW_HEIGHT)
                row:Show()
                if hasWp and expanded then
                    -- automatic quest-giver / questline route first (kept available)
                    if q.mapID then
                        questShown = questShown + 1
                        local arow = AcquireQuestRow(f, questShown)
                        arow.data = { isWp = true, isAuto = true, mapID = q.mapID,
                            wp = { x = (q.x or 0.5) * 100, y = (q.y or 0.5) * 100, mapID = q.mapID },
                            title = name, questID = q.questID }
                        arow.icon:SetTexture(nil); arow.status:SetText("")
                        arow.label:SetText("     |cff66ccff\226\128\162 Auto (quest giver)|r")
                        place(arow, ROW_HEIGHT); arow:Show()
                    end
                    for _, wp in ipairs(wps) do
                        questShown = questShown + 1
                        local srow = AcquireQuestRow(f, questShown)
                        srow.data = { isWp = true, ach = q.ach, crit = q.crit, wp = wp, title = name }
                        srow.icon:SetTexture(nil)
                        srow.status:SetText("")
                        srow.label:SetText(string.format("     |cff888888\226\128\162|r  %.0f, %.0f", wp.x, wp.y))
                        place(srow, ROW_HEIGHT)
                        srow:Show()
                    end
                end
            end
        end
    else
        f.questHeader:Hide()
    end
    HideFrom(f.questRows, questShown + 1)

    -- Renown & Reputation (current continent's meta-relevant factions) -- collapsible.
    -- Handles both modern renown (DF/WW) and classic reputations (BfA/SL).
    local renowns = on("renown") and O:GetContinentReputations(mapID) or {}
    local renownShown = 0
    if #renowns > 0 then
        placeSeparator()
        f.renownHeader:Show()
        f.renownHeader.text:SetText(string.format("|cffffcc33%s Renown & Reputation (%d)|r",
            s.renownCollapsed and "[+]" or "[-]", #renowns))
        place(f.renownHeader, SECTION_H)
        if not s.renownCollapsed then
            for i = 1, #renowns do
                local r = renowns[i]
                local row = AcquireRenownRow(f, i)
                row.data = r
                if r.met then
                    -- the tracked criterion is satisfied (e.g. reached the needed
                    -- Revered, not necessarily max) -> green with a check.
                    local reached = (r.isRenown and ("Renown " .. r.renownLevel)) or r.standingLabel or "Done"
                    row.label:SetText("|cff9fe0a0" .. (r.name or "") .. "|r")
                    row.prog:SetText("|cff00ff00" .. reached .. "|r")
                else
                    row.label:SetText(r.name or "")
                    if r.isCovenant then
                        row.prog:SetText(string.format("|cffffd700Renown %d|r |cff888888/ 80|r", r.renownLevel))
                    elseif r.isRenown then
                        if r.maxed then
                            row.prog:SetText("|cff00ff00Renown " .. r.renownLevel .. " (Max)|r")
                        else
                            row.prog:SetText(string.format("|cffffd700Renown %d|r  |cff888888%d/%d|r",
                                r.renownLevel, r.current, r.threshold))
                        end
                    else -- classic reputation
                        if r.maxed then
                            row.prog:SetText("|cff00ff00" .. (r.standingLabel or "Exalted") .. "|r")
                        elseif r.threshold and r.threshold > 0 then
                            row.prog:SetText(string.format("|cffffd700%s|r  |cff888888%d/%d|r",
                                r.standingLabel or "?", r.current, r.threshold))
                        else
                            row.prog:SetText("|cffffd700" .. (r.standingLabel or "?") .. "|r")
                        end
                    end
                end
                place(row, ROW_HEIGHT)
                row:Show()
                renownShown = i
            end
        end
    else
        f.renownHeader:Hide()
    end
    HideFrom(f.renownRows, renownShown + 1)

    -- hide any section dividers not used this pass
    for i = dividerShown + 1, #f.dividers do
        local d = f.dividers[i]
        d.left:Hide(); d.right:Hide(); d.gem:Hide()
    end

    -- Empty state / final height
    if stepShown == 0 and zoneShown == 0 and #instances == 0 and not wqSectionShown
        and not theaterShown and #renowns == 0 and #delves == 0 and #quests == 0 then
        f.empty:Show()
        f:SetHeight(CONTENT_TOP + 84)
    else
        f.empty:Hide()
        f:SetHeight(y + 8)
    end
end

-- ---------------------------------------------------------------------------
-- Ticker / enable / toggle
-- ---------------------------------------------------------------------------
function route:StartTicker()
    if self.ticker then return end
    self.ticker = C_Timer.NewTicker(REFRESH_INTERVAL, function() route:Refresh() end)
end

function route:StopTicker()
    if self.ticker then self.ticker:Cancel(); self.ticker = nil end
end

function route:IsEnabled()
    local s = Private.Settings:Route()
    return s and s.enabled
end

-- Apply accessibility look (window scale + bold/outline text) from settings. Called
-- on init and whenever the scale/font settings change.
-- Is hands-free auto-tracking active right now? True if "everywhere" auto mode is on,
-- or the instance-only auto mode is on and the player is currently inside an instance
-- (dungeon / raid / delve / scenario).
function route:AutoActive()
    local s = Private.Settings:Route()
    if not s then return false end
    if s.autoMode then return true end
    if s.autoModeInstance and IsInInstance() then return true end
    return false
end

function route:ApplyLook()
    local s = Private.Settings:Route()
    if not s then return end
    if self.frame then self.frame:SetScale(s.scale or 1) end
    -- FONT_OBJECTS.SMALL is the font's GLOBAL NAME (a string), not the Font object;
    -- resolve the registered object to read its path/size, then restyle PLANNER_FONT.
    local base = _G[const.FONT_OBJECTS.SMALL]
    local path, size, flags
    if base and base.GetFont then path, size, flags = base:GetFont() end
    path = path or const.MEDIA.FONTS.DEFAULT
    size = size or 10
    if s.boldText then
        -- THICKOUTLINE reads as genuinely bold; plain OUTLINE is too thin to notice
        -- next to the shadow the base font already has. Nudge the size up a touch too.
        PLANNER_FONT:SetFont(path, size + 1, "THICKOUTLINE")
    else
        PLANNER_FONT:SetFont(path, size, flags or "")
    end
end

-- ---------------------------------------------------------------------------
-- Notes export. In-game notes live in SavedVariables; an addon can't rewrite
-- ExpansionData.lua from Lua (WoW sandbox). This produces a copy-pasteable Lua
-- block you can drop into a shipped notes file (or hand to a build script) so
-- the notes travel with the addon when you publish to CurseForge.
-- ---------------------------------------------------------------------------
local function luaEscape(s)
    return (tostring(s or ""):gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", " "))
end

function route:BuildNotesExport()
    local metas = O:GetAllNoteMeta()
    -- group notes by their expansion module so each block is a drop-in Notes.lua
    local byExp, order = {}, {}
    for _, m in ipairs(metas) do
        local exp = m.expansion or "UNKNOWN — could not resolve expansion"
        if not byExp[exp] then byExp[exp] = {}; order[#order + 1] = exp end
        table.insert(byExp[exp], m)
    end
    table.sort(order)

    local lines = {
        "-- MetaTracker waypoint notes — exported " .. date("%Y-%m-%d %H:%M"),
        "-- For each block below: save it as Notes.lua in the named module folder,",
        "-- then add a  Notes.lua  line to that module's .toc file (after ExpansionData.lua).",
        "-- Key = Objectives:MakeKey (achID:critIdx:mapID:x:y).",
    }
    if #metas == 0 then
        lines[#lines + 1] = ""
        lines[#lines + 1] = "-- (no notes authored yet — Ctrl-click a route waypoint to add one)"
        return table.concat(lines, "\n"), 0
    end

    for _, exp in ipairs(order) do
        local folder = (Private.ExpansionUtil:GetModuleFolder(exp)) or ("MetaTracker_<" .. exp .. ">")
        lines[#lines + 1] = ""
        lines[#lines + 1] = "-- ======================================================================"
        lines[#lines + 1] = "-- >>> save as:  " .. folder .. "\\Notes.lua"
        lines[#lines + 1] = "-- >>> then add 'Notes.lua' to " .. folder .. "\\" .. folder .. ".toc"
        lines[#lines + 1] = "-- ======================================================================"
        lines[#lines + 1] = "LibStub(\"MetaTracker\"):RegisterWaypointNotes({"
        for _, m in ipairs(byExp[exp]) do
            local key = string.format("%d:%d:%d:%.1f:%.1f",
                m.achievementID, m.criteriaIndex or 0, m.mapID or 0, m.x or 0, m.y or 0)
            lines[#lines + 1] = string.format("    -- [%d %s] crit %s \"%s\" @ (%.1f, %.1f) map %d",
                m.achievementID, luaEscape(m.achName), tostring(m.criteriaIndex or "?"),
                luaEscape(m.description), m.x or 0, m.y or 0, m.mapID or 0)
            lines[#lines + 1] = string.format("    [\"%s\"] = \"%s\",", key, luaEscape(m.note))
        end
        lines[#lines + 1] = "})"
    end
    return table.concat(lines, "\n"), #metas
end

function route:ShowCopyWindow(titleText, bodyText)
    local f = self.copyFrame
    if not f then
        f = CreateFrame("Frame", "MetaTrackerNotesExport", UIParent, "BackdropTemplate")
        f:SetSize(560, 420)
        f:SetPoint("CENTER")
        f:SetBackdrop(FrameBackdrop)
        f:SetBackdropBorderColor(1, 1, 1, 1)
        f:SetFrameStrata("DIALOG")
        f:SetMovable(true); f:EnableMouse(true); f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", f.StartMoving)
        f:SetScript("OnDragStop", f.StopMovingOrSizing)

        f.title = f:CreateFontString(nil, "OVERLAY")
        f.title:SetFontObject(const.FONT_OBJECTS.HEADING)
        f.title:SetPoint("TOP", 0, -14)

        f.hint = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        f.hint:SetPoint("TOPLEFT", 20, -36)
        f.hint:SetPoint("TOPRIGHT", -20, -36)
        f.hint:SetJustifyH("LEFT")
        f.hint:SetText("Press Ctrl+A then Ctrl+C to copy.")

        local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
        close:SetPoint("TOPRIGHT", 2, 2)

        local scroll = CreateFrame("ScrollFrame", "MetaTrackerNotesExportScroll", f, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT", 16, -56)
        scroll:SetPoint("BOTTOMRIGHT", -34, 16)

        local eb = CreateFrame("EditBox", nil, scroll)
        eb:SetMultiLine(true)
        eb:SetFontObject(ChatFontNormal)
        eb:SetWidth(500)
        eb:SetAutoFocus(false)
        eb:SetScript("OnEscapePressed", function() f:Hide() end)
        scroll:SetScrollChild(eb)
        f.editBox = eb
    end
    f.title:SetText(titleText or "Export")
    f.editBox:SetText(bodyText or "")
    f.editBox:HighlightText()
    f:Show()
    f.editBox:SetFocus()
end

function route:ExportNotes()
    local text, count = self:BuildNotesExport()
    self:ShowCopyWindow("Waypoint notes export (" .. count .. ")", text)
    return count
end

-- Recorded coordinate fixes, in a parseable form to bake back into ExpansionData.
-- Each line: FIX <achID>:<criteriaIndex>:<mapID>  old=<x>,<y>  new=<x>,<y>  -- "desc"
function route:BuildFixesExport()
    local metas = O:GetAllWaypointFixes()
    if #metas == 0 then
        return "-- No waypoint fixes recorded yet.\n"
            .. "-- In the Do-it-now planner: Alt-left-click a waypoint to TEST offsets,\n"
            .. "-- run to the right spot, then Alt-right-click to SET the correct one.", 0
    end
    local byExp = {}
    for _, m in ipairs(metas) do
        local e = m.expansion or "Unknown"
        byExp[e] = byExp[e] or {}
        table.insert(byExp[e], m)
    end
    local lines = {
        "-- ======================================================================",
        "-- MetaTracker waypoint coordinate fixes -- paste this whole block to Claude.",
        "-- Line format:  FIX <achievementID>:<criteriaIndex>:<mapID>  old=x,y  new=x,y",
        "-- ======================================================================",
    }
    for exp, list in pairs(byExp) do
        lines[#lines + 1] = ""
        lines[#lines + 1] = "-- [" .. exp .. "]"
        for _, m in ipairs(list) do
            lines[#lines + 1] = string.format("FIX %d:%s:%d  old=%.1f,%.1f  new=%.1f,%.1f  -- \"%s\"%s",
                m.achievementID, tostring(m.criteriaIndex or 0), m.mapID or 0,
                m.origX or 0, m.origY or 0, m.x or 0, m.y or 0,
                (m.description or m.achName or ""), m.label and ("  ("..m.label..")") or "")
        end
    end
    return table.concat(lines, "\n"), #metas
end

function route:ExportFixes()
    local text, count = self:BuildFixesExport()
    self:ShowCopyWindow("Waypoint coordinate fixes (" .. count .. ")", text)
    return count
end

-- Authored route steps, as a drop-in Steps overlay per module (its OWN section,
-- separate from Notes, so it can be merged/shipped independently). Same key scheme
-- as notes; value is a {object, step} table. Grouped by expansion, ordered by
-- achievement -> criterion -> object -> step so each block reads in route order.
function route:BuildStepsExport()
    local metas = O:GetAllWaypointStepMeta()
    local byExp, order = {}, {}
    for _, m in ipairs(metas) do
        local exp = m.expansion or "UNKNOWN — could not resolve expansion"
        if not byExp[exp] then byExp[exp] = {}; order[#order + 1] = exp end
        table.insert(byExp[exp], m)
    end
    table.sort(order)

    local lines = {
        "-- MetaTracker route steps — exported " .. date("%Y-%m-%d %H:%M"),
        "-- These declare the ORDER a criterion's waypoints are visited:",
        "--   object = which target/collectible (1, 2, 3 …), step = order within it (1 = entrance).",
        "-- Keep this in its OWN file/section (separate from Notes). For each block: save it as",
        "-- Steps.lua in the named module folder, then add a  Steps.lua  line to that module's .toc.",
    }
    if #metas == 0 then
        lines[#lines + 1] = ""
        lines[#lines + 1] = "-- (no route steps authored yet — Alt-right-click a route waypoint, then Route step)"
        return table.concat(lines, "\n"), 0
    end

    for _, exp in ipairs(order) do
        local folder = (Private.ExpansionUtil:GetModuleFolder(exp)) or ("MetaTracker_<" .. exp .. ">")
        lines[#lines + 1] = ""
        lines[#lines + 1] = "-- ======================================================================"
        lines[#lines + 1] = "-- >>> save as:  " .. folder .. "\\Steps.lua"
        lines[#lines + 1] = "-- >>> then add 'Steps.lua' to " .. folder .. "\\" .. folder .. ".toc"
        lines[#lines + 1] = "-- ======================================================================"
        lines[#lines + 1] = "LibStub(\"MetaTracker\"):RegisterWaypointSteps({"
        local lastAch
        for _, m in ipairs(byExp[exp]) do
            if m.achievementID ~= lastAch then
                lines[#lines + 1] = string.format("    -- [%d %s]", m.achievementID, luaEscape(m.achName))
                lastAch = m.achievementID
            end
            local key = string.format("%d:%d:%d:%.1f:%.1f",
                m.achievementID, m.criteriaIndex or 0, m.mapID or 0, m.x or 0, m.y or 0)
            lines[#lines + 1] = string.format("    [\"%s\"] = { object = %d, step = %d },  -- \"%s\" @ (%.1f, %.1f)",
                key, m.object or 1, m.step or 1, luaEscape(m.description), m.x or 0, m.y or 0)
        end
        lines[#lines + 1] = "})"
    end
    return table.concat(lines, "\n"), #metas
end

function route:ExportSteps()
    local text, count = self:BuildStepsExport()
    self:ShowCopyWindow("Route steps export (" .. count .. ")", text)
    return count
end

function route:SetEnabled(enabled)
    local s = Private.Settings:Route()
    if not s then return end
    s.enabled = enabled and true or false
    if not self.frame then self:Initialize() end
    if s.enabled then
        self.frame:Show()
        self:Refresh()
    else
        self.frame:Hide()
    end
end

function route:Toggle()
    self:SetEnabled(not self:IsEnabled())
end

-- Open the planner for THIS session only (used by the one-time what's-new preview),
-- without flipping the persisted `enabled` setting - so it won't auto-open next login.
function route:ShowPreview()
    if not self.frame then self:Initialize() end
    self.frame:Show()
    self:Refresh()
    self:StartTicker()
end

-- ---------------------------------------------------------------------------
-- Construction
-- ---------------------------------------------------------------------------
function route:Initialize()
    if self.frame then return end
    local s = Private.Settings:Route()

    local f = CreateFrame("Frame", "MetaTrackerRouteHud", UIParent, "BackdropTemplate")
    f:SetSize(WIDTH, 160)
    f:SetBackdrop(FrameBackdrop)
    -- Darken the parchment so the coloured text reads with high contrast (the light
    -- parchment made gold-on-gold hard to read); keeps the gold border.
    f:SetBackdropColor(0.09, 0.09, 0.11, 0.96)
    f:SetBackdropBorderColor(1, 1, 1, 1)
    f:SetFrameStrata("MEDIUM")
    f:SetClampedToScreen(true)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", function(self)
        if not Private.Settings:Route().locked then self:StartMoving() end
    end)
    f:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local rs = Private.Settings:Route()
        local point, _, relPoint, x, y = self:GetPoint()
        rs.point, rs.relPoint, rs.x, rs.y = point, relPoint, x, y
    end)

    local titleBar = f:CreateTexture(nil, "ARTWORK")
    titleBar:SetTexture("Interface\\AchievementFrame\\UI-Achievement-Borders")
    titleBar:SetPoint("TOPLEFT", 10, -9)
    titleBar:SetPoint("TOPRIGHT", -10, -9)
    titleBar:SetHeight(22)
    titleBar:SetVertexColor(1, 1, 1, 0.8)
    titleBar:SetTexCoord(0, 0.9765625, 0.66015625, 0.73828125)

    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetFontObject(const.FONT_OBJECTS.HEADING)
    title:SetPoint("TOP", titleBar, "TOP", 0, -3)
    title:SetText("Do-it-now Planner")

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", 3, 3)
    close:SetScale(0.8)
    close:SetScript("OnClick", function() route:SetEnabled(false) end)

    -- Zone header: centered + larger, reads as the window's subtitle heading.
    local subtitle = f:CreateFontString(nil, "OVERLAY")
    subtitle:SetFontObject(const.FONT_OBJECTS.NORMAL)
    subtitle:SetPoint("TOPLEFT", 12, -39)
    subtitle:SetPoint("TOPRIGHT", -12, -39)
    subtitle:SetJustifyH("CENTER")
    f.subtitle = subtitle

    -- Hearth line, centered under the zone header with breathing room.
    local hint = f:CreateFontString(nil, "OVERLAY")
    hint:SetFontObject(PLANNER_FONT)
    hint:SetPoint("TOPLEFT", 12, -61)
    hint:SetPoint("TOPRIGHT", -12, -61)
    hint:SetJustifyH("CENTER")
    f.hint = hint

    f.dividers = {} -- pooled gold rules between major sections (see Refresh)
    -- ornamental gold rule (fade + diamond) framing the top of the toolbar
    PlaceFadeDivider(CreateFadeDivider(f), f, 80)

    local trackBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    trackBtn:SetSize(104, 20)
    trackBtn:SetPoint("TOPLEFT", 12, -88)
    trackBtn:SetText("Track Nearest")
    trackBtn:SetScript("OnClick", function()
        if route:AutoActive() then
            -- Skip the current auto-target; next Refresh picks the next nearest.
            if route.autoCurrent then route.autoSkipped[route.autoCurrent.key] = true end
            route:Refresh()
            return
        end
        local g = route.groups and route.groups[1]
        local prim = g and (g.primary or g.waypoints[1])
        if prim then
            Private.WaypointsUtil:TrackWaypoint(g.ach, g.crit, prim.wp)
            route:Refresh()
        end
    end)
    f.trackBtn = trackBtn

    -- Filter: tick boxes for which sections the scanner shows.
    local filterBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    filterBtn:SetSize(62, 20)
    filterBtn:SetPoint("LEFT", trackBtn, "RIGHT", 5, 0)
    filterBtn:SetText("Filter")
    filterBtn:SetScript("OnClick", function() ShowFilterMenu(filterBtn) end)
    f.filterBtn = filterBtn

    local ignoredBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    ignoredBtn:SetSize(80, 20)
    ignoredBtn:SetPoint("TOPRIGHT", -12, -88)
    ignoredBtn:SetText("Ignored")
    ignoredBtn:SetScript("OnClick", function() O:ShowIgnoredMenu(ignoredBtn) end)
    f.ignoredBtn = ignoredBtn

    -- ornamental gold rule separating the toolbar from the scrolling list
    PlaceFadeDivider(CreateFadeDivider(f), f, CONTENT_TOP - 6)
    GoldButtonPolish(trackBtn)
    GoldButtonPolish(filterBtn)
    GoldButtonPolish(ignoredBtn)

    local function makeHeader(text, r, g, b)
        local fs = f:CreateFontString(nil, "OVERLAY")
        fs:SetFontObject(PLANNER_FONT)
        fs:SetJustifyH("LEFT")
        fs:SetText(text)
        fs:SetTextColor(r, g, b)
        fs:Hide()
        return fs
    end
    f.thisHeader  = makeHeader("|cffffd700This zone|r", 1, 0.84, 0)

    -- Clickable header that collapses/expands the Other zones section.
    local otherHeader = CreateFrame("Button", nil, f)
    otherHeader:SetHeight(SECTION_H)
    otherHeader.text = otherHeader:CreateFontString(nil, "OVERLAY")
    otherHeader.text:SetFontObject(PLANNER_FONT)
    otherHeader.text:SetPoint("LEFT")
    otherHeader.text:SetJustifyH("LEFT")
    local ohl = otherHeader:CreateTexture(nil, "HIGHLIGHT")
    ohl:SetAllPoints()
    ohl:SetColorTexture(0.4, 0.67, 1, 0.10)
    otherHeader:SetScript("OnClick", function()
        local rs = Private.Settings:Route()
        if rs then rs.otherZonesCollapsed = not rs.otherZonesCollapsed end
        route:Refresh()
    end)
    otherHeader:Hide()
    f.otherHeader = otherHeader

    -- Clickable header that collapses/expands the Dungeons & Raids section.
    local instHeader = CreateFrame("Button", nil, f)
    instHeader:SetHeight(SECTION_H)
    instHeader.text = instHeader:CreateFontString(nil, "OVERLAY")
    instHeader.text:SetFontObject(PLANNER_FONT)
    instHeader.text:SetPoint("LEFT")
    instHeader.text:SetJustifyH("LEFT")
    local ihl = instHeader:CreateTexture(nil, "HIGHLIGHT")
    ihl:SetAllPoints()
    ihl:SetColorTexture(0.4, 1, 0.7, 0.10)
    instHeader:SetScript("OnClick", function()
        local rs = Private.Settings:Route()
        if rs then rs.dungeonsCollapsed = not rs.dungeonsCollapsed end
        route:Refresh()
    end)
    instHeader:Hide()
    f.instHeader = instHeader

    -- Clickable header that collapses/expands the Storylines & Quests section.
    local questHeader = CreateFrame("Button", nil, f)
    questHeader:SetHeight(SECTION_H)
    questHeader.text = questHeader:CreateFontString(nil, "OVERLAY")
    questHeader.text:SetFontObject(PLANNER_FONT)
    questHeader.text:SetPoint("LEFT")
    questHeader.text:SetJustifyH("LEFT")
    local qhl = questHeader:CreateTexture(nil, "HIGHLIGHT")
    qhl:SetAllPoints()
    qhl:SetColorTexture(1, 0.72, 0.3, 0.10)
    questHeader:SetScript("OnClick", function()
        local rs = Private.Settings:Route()
        if rs then rs.questsCollapsed = not rs.questsCollapsed end
        route:Refresh()
    end)
    questHeader:Hide()
    f.questHeader = questHeader

    -- Clickable header that collapses/expands the Delves section.
    local delveHeader = CreateFrame("Button", nil, f)
    delveHeader:SetHeight(SECTION_H)
    delveHeader.text = delveHeader:CreateFontString(nil, "OVERLAY")
    delveHeader.text:SetFontObject(PLANNER_FONT)
    delveHeader.text:SetPoint("LEFT")
    delveHeader.text:SetJustifyH("LEFT")
    local dhl = delveHeader:CreateTexture(nil, "HIGHLIGHT")
    dhl:SetAllPoints()
    dhl:SetColorTexture(0.8, 0.5, 1, 0.10)
    delveHeader:SetScript("OnClick", function()
        local rs = Private.Settings:Route()
        if rs then rs.delvesCollapsed = not rs.delvesCollapsed end
        route:Refresh()
    end)
    delveHeader:Hide()
    f.delveHeader = delveHeader

    -- Clickable header that collapses/expands the Renown & Reputation section.
    local renownHeader = CreateFrame("Button", nil, f)
    renownHeader:SetHeight(SECTION_H)
    renownHeader.text = renownHeader:CreateFontString(nil, "OVERLAY")
    renownHeader.text:SetFontObject(PLANNER_FONT)
    renownHeader.text:SetPoint("LEFT")
    renownHeader.text:SetJustifyH("LEFT")
    local rhl = renownHeader:CreateTexture(nil, "HIGHLIGHT")
    rhl:SetAllPoints()
    rhl:SetColorTexture(0.9, 0.8, 0.2, 0.10)
    renownHeader:SetScript("OnClick", function()
        local rs = Private.Settings:Route()
        if rs then rs.renownCollapsed = not rs.renownCollapsed end
        route:Refresh()
    end)
    renownHeader:Hide()
    f.renownHeader = renownHeader

    -- Clickable header that collapses/expands the World Quests section.
    local wqHeader = CreateFrame("Button", nil, f)
    wqHeader:SetHeight(SECTION_H)
    wqHeader.text = wqHeader:CreateFontString(nil, "OVERLAY")
    wqHeader.text:SetFontObject(PLANNER_FONT)
    wqHeader.text:SetPoint("LEFT")
    wqHeader.text:SetJustifyH("LEFT")
    local whl = wqHeader:CreateTexture(nil, "HIGHLIGHT")
    whl:SetAllPoints()
    whl:SetColorTexture(0.2, 0.7, 1, 0.10)
    wqHeader:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    wqHeader:SetScript("OnClick", function(_, button)
        local rs = Private.Settings:Route()
        if not rs then return end
        if button == "RightButton" then
            rs.wqRelevantOnly = not rs.wqRelevantOnly -- toggle meta-relevant only
        else
            rs.wqCollapsed = not rs.wqCollapsed
        end
        route:Refresh()
    end)
    wqHeader:Hide()
    f.wqHeader = wqHeader

    local wqEmpty = f:CreateFontString(nil, "OVERLAY")
    wqEmpty:SetFontObject(PLANNER_FONT)
    wqEmpty:SetJustifyH("LEFT")
    wqEmpty:SetText("|cff888888No world quests needed for this meta right now.|r")
    wqEmpty:Hide()
    f.wqEmpty = wqEmpty

    local more = f:CreateFontString(nil, "OVERLAY")
    more:SetFontObject(PLANNER_FONT)
    more:SetJustifyH("LEFT")
    more:Hide()
    f.more = more

    local empty = f:CreateFontString(nil, "OVERLAY")
    empty:SetFontObject(PLANNER_FONT)
    empty:SetPoint("TOPLEFT", 12, -(CONTENT_TOP + 2))
    empty:SetPoint("TOPRIGHT", -12, -(CONTENT_TOP + 2))
    empty:SetJustifyH("LEFT")
    empty:SetSpacing(3)
    empty:SetText("|cff00ff00Meta-Tracker is working!|r  This continent has no tracked meta achievements. "
        .. "Travel to a |cffffd700Khaz Algar (The War Within)|r zone to see your Worldsoul-Searching route here.")
    empty:Hide()
    f.empty = empty

    -- shown when the current zone has nothing but the continent has other tracked zones
    local zoneNote = f:CreateFontString(nil, "OVERLAY")
    zoneNote:SetFontObject(PLANNER_FONT)
    zoneNote:SetJustifyH("LEFT")
    zoneNote:SetSpacing(2)
    zoneNote:Hide()
    f.zoneNote = zoneNote

    -- Theater Troupe (timed Isle of Dorn event) line; click to route to the stage
    local theater = CreateFrame("Button", nil, f)
    theater:SetHeight(SECTION_H)
    theater.label = theater:CreateFontString(nil, "OVERLAY")
    theater.label:SetFontObject(PLANNER_FONT)
    theater.label:SetPoint("LEFT", 2, 0)
    theater.label:SetPoint("RIGHT", -2, 0)
    theater.label:SetJustifyH("LEFT")
    theater:SetScript("OnClick", function()
        local st = Private.TheaterTroupe and Private.TheaterTroupe:GetStage()
        if st and Private.WaypointsUtil then
            Private.WaypointsUtil:TrackPoint(st.mapID, st.x, st.y, "Theater Troupe (Dornogal)")
        end
    end)
    theater:SetScript("OnEnter", function(b)
        GameTooltip:SetOwner(b, "ANCHOR_RIGHT")
        GameTooltip:AddLine("Theater Troupe (Dornogal)", 1, 0.82, 0)
        local tt = Private.TheaterTroupe
        local rem = tt and tt:GetRemainingPlays()
        if rem and #rem > 0 then
            GameTooltip:AddLine("Plays still needed for We're Here All Night:", 1, 1, 1)
            for _, p in ipairs(rem) do GameTooltip:AddLine("  \226\128\162 " .. p, 0.8, 1, 0.8) end
        end
        if tt then
            local active, activeLeft = tt:IsActive()
            if active then
                GameTooltip:AddLine("|cff00ff00Active now|r \226\128\148 ~" .. tt:FormatTime(activeLeft) .. " left this showing", 0, 1, 0)
            else
                local secs = tt:GetSecondsToNextShowing()
                if secs then GameTooltip:AddLine("Next showing (top of the hour): ~" .. tt:FormatTime(secs), 0.7, 0.7, 0.7) end
            end
        end
        GameTooltip:AddLine("Click to set a waypoint to the stage.", 0, 1, 0)
        GameTooltip:Show()
    end)
    theater:SetScript("OnLeave", function() GameTooltip:Hide() end)
    theater:Hide()
    f.theater = theater

    f.stepRows = {}
    f.zoneRows = {}
    f.instRows = {}
    f.delveRows = {}
    f.wqRows = {}
    f.questRows = {}
    f.renownRows = {}
    self.frame = f

    self:ApplyLook() -- scale + font accessibility

    f:ClearAllPoints()
    f:SetPoint(s.point or "CENTER", UIParent, s.relPoint or "CENTER", s.x or 0, s.y or 0)

    f:SetScript("OnShow", function() route:StartTicker() end)
    f:SetScript("OnHide", function() route:StopTicker() end)

    if s.enabled then
        f:Show()
        self:Refresh()
    else
        f:Hide()
    end
end
