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

---@class RouteHud
local route = {}
Private.RouteHud = route
route.expanded = {} -- criterion key -> true, for expanded multi-waypoint groups

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

local WIDTH        = 310
local ROW_HEIGHT   = 20
local SECTION_H    = 17
local CONTENT_TOP  = 92    -- 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"

-- 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.
local function BuildGroups(mapID, px, py)
    local items = O:GetIncompleteOnMap(mapID)
    local total = #items
    local groups, byKey = {}, {}
    for _, e in ipairs(items) do
        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)
        g.waypoints[#g.waypoints + 1] = { wp = e.wp, sortKey = sortKey, yards = yards }
    end
    for _, g in ipairs(groups) do
        table.sort(g.waypoints, function(a, b) return a.sortKey < b.sortKey end)
        g.closestKey = g.waypoints[1].sortKey
        g.closestYards = g.waypoints[1].yards
    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.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

-- ---------------------------------------------------------------------------
-- 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

local function OnStepClick(row, button)
    if not row.data then return end
    local d = row.data
    -- 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
    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 .. " spots."), 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)
    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)

    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(const.FONT_OBJECTS.SMALL)
    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(const.FONT_OBJECTS.SMALL) 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(const.FONT_OBJECTS.SMALL)
    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)

    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(const.FONT_OBJECTS.SMALL)
    row.count:SetPoint("RIGHT", 0, 0)
    row.count:SetJustifyH("RIGHT")
    row.count:SetWidth(40)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(const.FONT_OBJECTS.SMALL)
    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("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

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)

    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(const.FONT_OBJECTS.SMALL) 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(const.FONT_OBJECTS.SMALL)
    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
    -- 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 OnWQEnter(row)
    local q = row.data
    if not q then return end
    GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
    GameTooltip:AddLine("|cff33bbff[World Quest]|r " .. q.title, 1, 0.82, 0)
    GameTooltip:AddLine("Zone: " .. (q.mapName or "?"), 0.7, 0.85, 0.7)
    if q.relevant and q.relAch then
        GameTooltip:AddLine("|cffffd700Counts toward:|r " .. (q.relAch.name or ""), 1, 0.84, 0)
        if q.relCrit and q.relCrit.description then
            GameTooltip:AddLine("  " .. q.relCrit.description, 0.9, 0.9, 0.6, true)
        end
    end
    local mins = O:GetQuestTimeLeft(q.questID)
    if mins and mins > 0 then
        GameTooltip:AddLine("Time left: " .. O:FormatTimeLeft(mins), 1, 0.82, 0)
    end
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine("Click (or shift-click) to set a waypoint / crazy arrow.", 0, 1, 0)
    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)

    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(const.FONT_OBJECTS.SMALL)
    row.time:SetPoint("RIGHT", 0, 0)
    row.time:SetJustifyH("RIGHT")
    row.time:SetWidth(60)

    row.label = row:CreateFontString(nil, "OVERLAY")
    row.label:SetFontObject(const.FONT_OBJECTS.SMALL)
    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

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

    local mapID = C_Map.GetBestMapForUnit("player")
    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
    self.groups = groups

    -- Header text
    local zoneName = (mapID and C_Map.GetMapInfo(mapID) and C_Map.GetMapInfo(mapID).name) or "Unknown"
    f.subtitle:SetText(string.format("|cffffffff%s|r  |cffaaaaaa(%d left)|r", zoneName, total))
    f.hint:SetText(string.format("|cffffd700Hearth:|r %s  |cff888888· fly/ride to targets|r", GetBindLocation() or "?"))
    f.trackBtn:SetEnabled(#groups > 0)
    f.ignoredBtn:SetText(string.format("Ignored (%d)", O:IgnoredCount()))

    -- Other zones on the same continent
    local zones = {}
    if mapID 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

    local s = Private.Settings:Route()
    local maxSteps = math.min(s.maxRows or 12, 20)

    -- ---- 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

    -- Theater Troupe: show a line when the next play is one still needed
    local theaterShown = false
    local tline = Private.TheaterTroupe and Private.TheaterTroupe:GetPlannerLine()
    if tline then
        f.theater.label:SetText(tline)
        place(f.theater, SECTION_H)
        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 #groups > 0 then
        f.thisHeader:Show()
        f.zoneNote:Hide()
        place(f.thisHeader, SECTION_H)
        local gi = 1
        while gi <= #groups and stepShown < maxSteps do
            local g = groups[gi]
            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 ""
            local countTag = multi and string.format("  |cff888888(%d waypoints)|r", #g.waypoints) or ""
            -- single-spot criteria show their note inline (multi-spot notes live on sub-rows)
            local noteTag = ""
            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
            end
            row.label:SetText(arrow .. gi .. ". " .. (g.crit.description or g.ach.name) .. countTag .. noteTag)
            row.dist:SetText(O:FormatDistance(g.closestYards))
            row.active:SetShown(IsGroupActive(g))
            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()
                    srow.label:SetPoint("RIGHT", srow.dist, "LEFT", -4, 0)
                    local note = O:GetNote(g.ach, g.crit, wobj.wp)
                    if note and note ~= "" then
                        srow.label:SetText(string.format("     |cff888888\226\128\162|r %s  |cff666666(%.0f, %.0f)|r",
                            note, wobj.wp.x, wobj.wp.y))
                    else
                        srow.label:SetText(string.format("     |cff888888\226\128\162|r  %.0f, %.0f", wobj.wp.x, wobj.wp.y))
                    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 <= #groups then
            f.more:SetText(string.format("|cff888888+ %d more objectives here…|r", #groups - (gi - 1)))
            place(f.more, SECTION_H)
            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 #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
    local zoneShown = 0
    if #zones > 0 then
        y = y + 4
        f.otherHeader:Show()
        place(f.otherHeader, SECTION_H)
        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
    else
        f.otherHeader:Hide()
    end
    HideFrom(f.zoneRows, zoneShown + 1)

    -- Dungeons & Raids (instance objectives with no map location) -- collapsible.
    local instances = O:GetInstanceObjectives()
    local instShown = 0
    if #instances > 0 then
        y = y + 4
        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)
                if d.instance and d.instance:lower() ~= d.name:lower() then
                    row.label:SetText(d.name .. "  |cff888888(" .. d.instance .. ")|r")
                else
                    row.label:SetText(d.name)
                end
                place(row, ROW_HEIGHT)
                row:Show()
                instShown = i
            end
        end
    else
        f.instHeader:Hide()
    end
    HideFrom(f.instRows, instShown + 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 = O:GetWorldQuests(mapID)
    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
    local wqSectionShown, wqShown = false, 0
    if #display > 0 then
        wqSectionShown = true
        y = y + 4
        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]
                local row = AcquireWQRow(f, i)
                row.data = q
                row.icon:SetTexture(q.relevant and "Interface\\Common\\FavoritesIcon" or WQ_ICON)
                local zoneTag = q.isCurrent and "" or ("  |cff888888(" .. (q.mapName or "") .. ")|r")
                local title = q.relevant and ("|cffffd700" .. q.title .. "|r") or q.title
                row.label:SetText(title .. 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()
                wqShown = i
            end
        end
    elseif s.wqRelevantOnly and O:IsMetaZone(mapID) then
        -- Meta zone with no needed WQs currently up: show the dropdown + a 0 note.
        wqSectionShown = true
        y = y + 4
        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)

    -- Empty state / final height
    if stepShown == 0 and zoneShown == 0 and #instances == 0 and not wqSectionShown and not theaterShown 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

-- ---------------------------------------------------------------------------
-- 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

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)
    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)

    local subtitle = f:CreateFontString(nil, "OVERLAY")
    subtitle:SetFontObject(const.FONT_OBJECTS.SMALL)
    subtitle:SetPoint("TOPLEFT", 12, -34)
    subtitle:SetPoint("TOPRIGHT", -12, -34)
    subtitle:SetJustifyH("LEFT")
    f.subtitle = subtitle

    local hint = f:CreateFontString(nil, "OVERLAY")
    hint:SetFontObject(const.FONT_OBJECTS.SMALL)
    hint:SetPoint("TOPLEFT", 12, -49)
    hint:SetPoint("TOPRIGHT", -12, -49)
    hint:SetJustifyH("LEFT")
    f.hint = hint

    local trackBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    trackBtn:SetSize(115, 20)
    trackBtn:SetPoint("TOPLEFT", 12, -65)
    trackBtn:SetText("Track Nearest")
    trackBtn:SetScript("OnClick", function()
        local g = route.groups and route.groups[1]
        if g and g.waypoints[1] then
            Private.WaypointsUtil:TrackWaypoint(g.ach, g.crit, g.waypoints[1].wp)
            route:Refresh()
        end
    end)
    f.trackBtn = trackBtn

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

    local function makeHeader(text, r, g, b)
        local fs = f:CreateFontString(nil, "OVERLAY")
        fs:SetFontObject(const.FONT_OBJECTS.SMALL)
        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)
    f.otherHeader = makeHeader("|cff66aaffOther zones on this continent|r", 0.4, 0.67, 1)

    -- 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(const.FONT_OBJECTS.SMALL)
    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 World Quests section.
    local wqHeader = CreateFrame("Button", nil, f)
    wqHeader:SetHeight(SECTION_H)
    wqHeader.text = wqHeader:CreateFontString(nil, "OVERLAY")
    wqHeader.text:SetFontObject(const.FONT_OBJECTS.SMALL)
    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(const.FONT_OBJECTS.SMALL)
    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(const.FONT_OBJECTS.SMALL)
    more:SetJustifyH("LEFT")
    more:Hide()
    f.more = more

    local empty = f:CreateFontString(nil, "OVERLAY")
    empty:SetFontObject(const.FONT_OBJECTS.SMALL)
    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(const.FONT_OBJECTS.SMALL)
    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(const.FONT_OBJECTS.SMALL)
    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.wqRows = {}
    self.frame = f

    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
