local addonName, ZL = ...
local LOGO_TEXTURE = "Interface\\AddOns\\ZoneLocked\\assets\\zonelocked-logo.png"
local CATEGORY_META = {
    achievements = { name = "Achievements", icon = 236621 },
    quests = { name = "Quests", icon = 413581 },
    mounts = { name = "Mounts", icon = 132261 },
    pets = { name = "Battle Pets", icon = 656595 },
    decor = { name = "Decor", icon = 134435 },
    instances = { name = "Instances", icon = 136106 },
    transmogs = { name = "Transmog", icon = 135048 },
    rares = { name = "Rare Drops", icon = 132176 },
}

StaticPopupDialogs.ZONELOCKED_REMOVE_ZONE = {
    text = "Remove %s from your Zone Locked route?",
    button1 = YES,
    button2 = NO,
    OnAccept = function(_, data)
        if data and data.mapID then
            local _, message = ZL:RemoveZoneFromRoute(data.mapID)
            ZL:Print(message)
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs.ZONELOCKED_LOAD_COMPLETED_ZONE = {
    text = "%s is complete. Its detailed ATT data has been unloaded to keep Zone Locked responsive. Load it now?",
    button1 = "Load Details",
    button2 = CANCEL,
    OnAccept = function(_, data)
        if data and data.mapID then ZL:LoadCompletedZoneDetails(data.mapID, data.categoryKey) end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs.ZONELOCKED_RENAME_JOURNEY = {
    text = "Rename this archived journey:",
    button1 = "Save",
    button2 = CANCEL,
    hasEditBox = true,
    OnShow = function(dialog, data)
        local journey = data and ZL_Saved.journeys and ZL_Saved.journeys[data.index]
        dialog.editBox:SetText(journey and (journey.name or journey.continentName) or "Journey")
        dialog.editBox:HighlightText()
        dialog.editBox:SetFocus()
    end,
    OnAccept = function(dialog, data)
        if data then ZL:RenameJourney(data.index, dialog.editBox:GetText()) end
    end,
    EditBoxOnEnterPressed = function(editBox)
        local dialog = editBox:GetParent()
        if dialog.data then ZL:RenameJourney(dialog.data.index, editBox:GetText()) end
        dialog:Hide()
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs.ZONELOCKED_DELETE_JOURNEY = {
    text = "Delete the archived journey '%s'? This does not remove completion history.",
    button1 = "Delete",
    button2 = CANCEL,
    OnAccept = function(_, data)
        if data then ZL:DeleteJourney(data.index) end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs.ZONELOCKED_CLEAR_HISTORY = {
    text = "Delete %s? This cannot be undone.",
    button1 = "Delete",
    button2 = CANCEL,
    OnAccept = function(_, data)
        if data and data.scope then ZL:ClearHistory(data.scope) end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs.ZONELOCKED_DELETE_HISTORY_EVENT = {
    text = "Delete this history entry?\n\n%s",
    button1 = "Delete",
    button2 = CANCEL,
    OnAccept = function(_, data)
        if data and data.event then ZL:DeleteHistoryEvent(data.event) end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

local function SetBackdrop(frame, color)
    local theme = ZL:GetTheme()
    frame:SetBackdrop({ bgFile = "Interface/Buttons/WHITE8X8", edgeFile = "Interface/Tooltips/UI-Tooltip-Border", edgeSize = 12 })
    frame:SetBackdropColor(unpack(color or theme.panel))
    frame:SetBackdropBorderColor(unpack(theme.border))
end

local function SetMainBackdrop(frame)
    local theme = ZL:GetTheme()
    frame:SetBackdrop({
        bgFile = "Interface/FrameGeneral/UI-Background-Rock",
        edgeFile = "Interface/DialogFrame/UI-DialogBox-Border",
        tile = true,
        tileSize = 128,
        edgeSize = 28,
        insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })
    frame:SetBackdropColor(unpack(theme.window))
    frame:SetBackdropBorderColor(unpack(theme.border))
end

local function ColorHex(color)
    return string.format("%02x%02x%02x", math.floor(color[1] * 255), math.floor(color[2] * 255),
        math.floor(color[3] * 255))
end

function ZL:ApplyTheme()
    local theme = self:GetTheme()
    if self.UI then
        SetMainBackdrop(self.UI)
        self.UI.header:SetColorTexture(unpack(theme.header))
        self.UI.headerAccent:SetColorTexture(unpack(theme.accent))
        self.UI.zoneLockedTitle:SetText("|cff" .. ColorHex(theme.accent) .. "Zone|r |cff" ..
            ColorHex(theme.secondary) .. "Locked|r")
        self.UI.summaryPanel:SetBackdropColor(unpack(theme.panel))
        self.UI.summaryPanel:SetBackdropBorderColor(unpack(theme.border))
        self.UI.summary:SetTextColor(unpack(theme.secondary))
        self.UI.scrollWell:SetBackdropColor(theme.window[1] * 0.55, theme.window[2] * 0.55,
            theme.window[3] * 0.55, 0.96)
        self.UI.scrollWell:SetBackdropBorderColor(unpack(theme.border))
        self.UI.sidebar:SetBackdropColor(unpack(theme.panel))
        self.UI.sidebar:SetBackdropBorderColor(unpack(theme.border))
        self.UI.sidebar.title:SetTextColor(unpack(theme.accent))
        self.UI.logoBadge:SetBackdropColor(unpack(theme.header))
        self.UI.logoBadge:SetBackdropBorderColor(unpack(theme.border))
        for _, menu in ipairs({ self.UI.routeMenu, self.UI.sortMenu, self.UI.transmogFilterMenu }) do
            if menu then
                menu:SetBackdropColor(unpack(theme.panel))
                menu:SetBackdropBorderColor(unpack(theme.border))
            end
        end
        self.UI.tagline:SetTextColor(theme.secondary[1] * 0.82, theme.secondary[2] * 0.82,
            theme.secondary[3] * 0.82, 1)
    end
    if self.WowheadDialog then SetMainBackdrop(self.WowheadDialog) end
    if self.HistoryTransferDialog then SetMainBackdrop(self.HistoryTransferDialog) end
    if self.CollectionAlert then
        self.CollectionAlert:SetBackdropColor(unpack(theme.header))
        self.CollectionAlert:SetBackdropBorderColor(unpack(theme.accent))
    end
    if self.ApplyMiniListTheme then self:ApplyMiniListTheme() end
    self._uiSignature = nil
    self:RefreshUI()
end

function ZL:CreateWowheadDialog()
    if self.WowheadDialog then return end
    local dialog = CreateFrame("Frame", "ZoneLockedWowheadDialog", UIParent, "BackdropTemplate")
    dialog:SetSize(540, 132)
    dialog:SetPoint("CENTER")
    dialog:SetFrameStrata("DIALOG")
    dialog:SetClampedToScreen(true)
    dialog:SetMovable(true)
    dialog:EnableMouse(true)
    dialog:RegisterForDrag("LeftButton")
    dialog:SetScript("OnDragStart", dialog.StartMoving)
    dialog:SetScript("OnDragStop", dialog.StopMovingOrSizing)
    SetMainBackdrop(dialog)

    dialog.title = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    dialog.title:SetPoint("TOP", 0, -18)
    dialog.title:SetText("Copy Wowhead Link")
    dialog.instructions = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    dialog.instructions:SetPoint("TOP", dialog.title, "BOTTOM", 0, -5)
    dialog.instructions:SetText("Press Ctrl+C, then paste the URL into your browser.")
    dialog.instructions:SetTextColor(0.68, 0.76, 0.82)

    dialog.edit = CreateFrame("EditBox", nil, dialog, "InputBoxTemplate")
    dialog.edit:SetPoint("TOPLEFT", 22, -64)
    dialog.edit:SetPoint("TOPRIGHT", -22, -64)
    dialog.edit:SetHeight(24)
    dialog.edit:SetAutoFocus(false)
    dialog.edit:SetScript("OnEscapePressed", function() dialog:Hide() end)
    dialog.edit:SetScript("OnEnterPressed", function() dialog:Hide() end)

    dialog.close = CreateFrame("Button", nil, dialog, "UIPanelButtonTemplate")
    dialog.close:SetSize(110, 24)
    dialog.close:SetPoint("BOTTOM", 0, 14)
    dialog.close:SetText("Close")
    dialog.close:SetScript("OnClick", function() dialog:Hide() end)
    dialog:SetScript("OnHide", function() dialog.edit:ClearFocus() end)
    dialog:Hide()
    table.insert(UISpecialFrames, dialog:GetName())
    self.WowheadDialog = dialog
end

function ZL:ShowWowheadLink(url)
    if not url then return end
    if not self.WowheadDialog then self:CreateWowheadDialog() end
    local availableWidth = math.max(440, (UIParent:GetWidth() or 1000) - 80)
    self.WowheadDialog:SetWidth(math.min(availableWidth, math.max(540, (#url * 7) + 70)))
    self.WowheadDialog:Show()
    self.WowheadDialog.edit:SetText(url)
    self.WowheadDialog.edit:SetFocus()
    self.WowheadDialog.edit:HighlightText()
end

function ZL:CreateHistoryTransferDialog()
    if self.HistoryTransferDialog then return end
    local dialog = CreateFrame("Frame", "ZoneLockedHistoryTransferDialog", UIParent, "BackdropTemplate")
    dialog:SetSize(700, 430)
    dialog:SetPoint("CENTER")
    dialog:SetFrameStrata("DIALOG")
    dialog:SetClampedToScreen(true)
    dialog:SetMovable(true)
    dialog:EnableMouse(true)
    dialog:RegisterForDrag("LeftButton")
    dialog:SetScript("OnDragStart", dialog.StartMoving)
    dialog:SetScript("OnDragStop", dialog.StopMovingOrSizing)
    SetMainBackdrop(dialog)
    dialog.title = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    dialog.title:SetPoint("TOP", 0, -18)
    dialog.instructions = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    dialog.instructions:SetPoint("TOP", dialog.title, "BOTTOM", 0, -6)
    dialog.instructions:SetWidth(640)
    dialog.instructions:SetTextColor(0.68, 0.76, 0.82)

    dialog.box = CreateFrame("Frame", nil, dialog, "BackdropTemplate")
    dialog.box:SetPoint("TOPLEFT", 20, -70)
    dialog.box:SetPoint("BOTTOMRIGHT", -38, 52)
    SetBackdrop(dialog.box, { 0.01, 0.015, 0.02, 0.98 })
    dialog.scroll = CreateFrame("ScrollFrame", nil, dialog.box, "UIPanelScrollFrameTemplate")
    dialog.scroll:SetPoint("TOPLEFT", 8, -8)
    dialog.scroll:SetPoint("BOTTOMRIGHT", -28, 8)
    dialog.edit = CreateFrame("EditBox", nil, dialog.scroll)
    dialog.edit:SetMultiLine(true)
    dialog.edit:SetAutoFocus(false)
    dialog.edit:SetMaxLetters(1000000)
    dialog.edit:SetMaxBytes(1000000)
    dialog.edit:SetFontObject("ChatFontNormal")
    dialog.edit:SetWidth(610)
    dialog.edit:SetTextInsets(4, 4, 4, 4)
    dialog.edit:SetScript("OnEscapePressed", function(editBox) editBox:ClearFocus(); dialog:Hide() end)
    dialog.edit:SetScript("OnTextChanged", function(editBox)
        editBox:SetHeight(math.max(310, editBox:GetStringHeight() + 20))
    end)
    dialog.scroll:SetScrollChild(dialog.edit)

    dialog.action = CreateFrame("Button", nil, dialog, "UIPanelButtonTemplate")
    dialog.action:SetSize(110, 24)
    dialog.action:SetPoint("BOTTOMRIGHT", -136, 18)
    dialog.close = CreateFrame("Button", nil, dialog, "UIPanelButtonTemplate")
    dialog.close:SetSize(110, 24)
    dialog.close:SetPoint("BOTTOMRIGHT", -20, 18)
    dialog.close:SetText("Close")
    dialog.close:SetScript("OnClick", function() dialog:Hide() end)
    dialog:SetScript("OnHide", function() dialog.edit:ClearFocus() end)
    dialog:Hide()
    table.insert(UISpecialFrames, dialog:GetName())
    self.HistoryTransferDialog = dialog
end

function ZL:ShowHistoryExport()
    if not self.HistoryTransferDialog then self:CreateHistoryTransferDialog() end
    local dialog = self.HistoryTransferDialog
    dialog.mode = "export"
    dialog.edit:SetMultiLine(true)
    dialog.title:SetText("Export Zone Locked History")
    dialog.instructions:SetText("Press Ctrl+C to copy this character's events and archived journeys.")
    dialog.instructions:SetTextColor(0.68, 0.76, 0.82)
    dialog.action:SetText("Select All")
    dialog.action:SetScript("OnClick", function()
        dialog.edit:SetFocus()
        dialog.edit:HighlightText()
        dialog.scroll:SetVerticalScroll(0)
    end)
    dialog.edit:SetText(self:ExportHistory())
    dialog:Show()
    dialog.edit:SetFocus()
    dialog.edit:HighlightText()
    dialog.scroll:SetVerticalScroll(0)
end

function ZL:ShowHistoryImport()
    if not self.HistoryTransferDialog then self:CreateHistoryTransferDialog() end
    local dialog = self.HistoryTransferDialog
    dialog.mode = "import"
    dialog.edit:SetMultiLine(true)
    dialog.title:SetText("Import Zone Locked History")
    dialog.instructions:SetText("Paste a Zone Locked history export below. ZLH2 and legacy ZLH1 exports are supported.")
    dialog.action:SetText("Import")
    dialog.action:SetScript("OnClick", function()
        local ok, message, imported = self:ImportHistory(dialog.edit:GetText())
        self:Print(message)
        if ok and imported and imported > 0 then
            dialog:Hide()
        else
            dialog.instructions:SetText(message)
            dialog.instructions:SetTextColor(ok and 1 or 1, ok and 0.82 or 0.35, ok and 0.25 or 0.35)
        end
    end)
    dialog.edit:SetText("")
    dialog.instructions:SetTextColor(0.68, 0.76, 0.82)
    dialog:Show()
    dialog.edit:SetFocus()
end

function ZL:PreviewObjective(objective)
    if not objective then return false end

    if objective.decorID or objective.categoryKey == "decor" then
        local catalogInfo
        if objective.itemID and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByItem then
            local ok, result = pcall(C_HousingCatalog.GetCatalogEntryInfoByItem, objective.itemID, false)
            if ok then catalogInfo = result end
        end
        if not catalogInfo and objective.decorID and C_HousingCatalog and
            C_HousingCatalog.GetCatalogEntryInfoByRecordID and Enum and Enum.HousingCatalogEntryType then
            local ok, result = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID,
                Enum.HousingCatalogEntryType.Decor, objective.decorID, true)
            if ok then catalogInfo = result end
        end
        if catalogInfo then
            if not _G.HousingModelPreviewFrame and C_AddOns and C_AddOns.LoadAddOn then
                pcall(C_AddOns.LoadAddOn, "Blizzard_HousingModelPreview")
            end
            local preview = _G.HousingModelPreviewFrame
            if preview and preview.ShowCatalogEntryInfo then
                preview:ShowCatalogEntryInfo(catalogInfo)
                return true
            end
        end
        self:Print("A Blizzard Decor preview is not available for this objective.")
        return false
    end

    if objective.sourceID or objective.categoryKey == "transmogs" then
        local link = objective.link
        if not link and objective.sourceID and C_TransmogCollection.GetSourceInfo then
            local sourceInfo = C_TransmogCollection.GetSourceInfo(objective.sourceID)
            local itemID = sourceInfo and sourceInfo.itemID
            link = itemID and ("item:" .. itemID) or nil
        end
        if not link and objective.itemID then link = "item:" .. objective.itemID end
        if link and DressUpItemLink then
            DressUpItemLink(link)
            return true
        end
        self:Print("A Blizzard appearance preview is not available for this objective.")
        return false
    end

    self:Print("Ctrl-click preview is available for appearances and Decor.")
    return false
end

function ZL:AddObjectiveWaypoint(objective)
    if not objective or not objective.coordMapID or not objective.coordX or not objective.coordY then
        self:Print("ATT does not provide coordinates for this objective.")
        return false
    end
    if not _G.TomTom or not _G.TomTom.AddWaypoint then
        self:Print("TomTom must be installed and enabled to add this waypoint.")
        return false
    end
    local x = objective.coordX > 1 and objective.coordX / 100 or objective.coordX
    local y = objective.coordY > 1 and objective.coordY / 100 or objective.coordY
    _G.TomTom:AddWaypoint(objective.coordMapID, x, y, {
        title = objective.name or "Zone Locked objective",
        persistent = false,
        from = "ZoneLocked",
    })
    return true
end

function ZL:AddCategoryWaypoint(category)
    local fallback
    local function FindInCategory(candidate)
        for _, objective in ipairs(candidate.objectives or {}) do
            if objective.coordX and objective.coordY then
                if not fallback then fallback = objective end
                if not objective.complete then return objective end
            end
        end
        for _, instance in ipairs(candidate.instances or {}) do
            for _, childCategory in ipairs(instance.categories or {}) do
                local found = FindInCategory(childCategory)
                if found and not found.complete then return found end
            end
        end
    end
    local objective = FindInCategory(category) or fallback
    if not objective then
        self:Print("ATT does not provide coordinates for this category.")
        return false
    end
    return self:AddObjectiveWaypoint(objective)
end

function ZL:HandleObjectiveClick(objective)
    if IsShiftKeyDown() then
        if objective and objective.wowheadURL then self:ShowWowheadLink(objective.wowheadURL) end
    elseif IsControlKeyDown() then
        self:PreviewObjective(objective)
    elseif IsAltKeyDown() then
        self:AddObjectiveWaypoint(objective)
    end
end

function ZL:ShowObjectiveTooltip(button, objective, anchor)
    if not objective then return end
    GameTooltip:SetOwner(button, anchor or "ANCHOR_RIGHT")
    if objective.link then
        GameTooltip:SetHyperlink(objective.link)
    else
        GameTooltip:SetText(objective.name or "Tracked objective")
    end
    if objective.source then GameTooltip:AddLine("Source: " .. objective.source, 0.75, 0.85, 0.95, true) end
    if objective.location then GameTooltip:AddLine("Location: " .. objective.location, 1, 0.82, 0.35, true) end
    if objective.instanceName then GameTooltip:AddLine("Instance: " .. objective.instanceName, 0.72, 0.62, 1, true) end
    if objective.wowheadURL then GameTooltip:AddLine("Shift-click: copy Wowhead link", 0.35, 0.8, 1, true) end
    if objective.sourceID or objective.decorID or objective.categoryKey == "transmogs" or objective.categoryKey == "decor" then
        GameTooltip:AddLine("Ctrl-click: open Blizzard preview", 0.72, 0.62, 1, true)
    end
    if objective.coordX and objective.coordY then
        GameTooltip:AddLine("Alt-click: add TomTom waypoint", 1, 0.82, 0.35, true)
    end
    GameTooltip:Show()
end

function ZL:CreateMainWindow()
    self.UI = CreateFrame("Frame", "ZoneLockedMainFrame", UIParent, "BackdropTemplate")
    self.UI:SetSize(920, 660)
    self.UI:SetPoint("CENTER")
    self.UI:SetClampedToScreen(true)
    self.UI:SetMovable(true)
    self.UI:EnableMouse(true)
    self.UI:RegisterForDrag("LeftButton")
    self.UI:SetScript("OnDragStart", self.UI.StartMoving)
    self.UI:SetScript("OnDragStop", self.UI.StopMovingOrSizing)
    SetMainBackdrop(self.UI)

    self.UI.header = self.UI:CreateTexture(nil, "BACKGROUND")
    self.UI.header:SetPoint("TOPLEFT", 4, -4)
    self.UI.header:SetPoint("TOPRIGHT", -4, -4)
    self.UI.header:SetHeight(58)
    self.UI.header:SetColorTexture(0.025, 0.075, 0.105, 0.98)

    self.UI.headerAccent = self.UI:CreateTexture(nil, "ARTWORK")
    self.UI.headerAccent:SetPoint("BOTTOMLEFT", self.UI.header, "BOTTOMLEFT", 0, 0)
    self.UI.headerAccent:SetPoint("BOTTOMRIGHT", self.UI.header, "BOTTOMRIGHT", 0, 0)
    self.UI.headerAccent:SetHeight(2)
    self.UI.headerAccent:SetColorTexture(0.92, 0.70, 0.25, 0.9)

    self.UI.logoBadge = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.logoBadge:SetSize(52, 52)
    self.UI.logoBadge:SetPoint("LEFT", self.UI.header, "LEFT", 6, 0)
    SetBackdrop(self.UI.logoBadge, { 0.015, 0.02, 0.025, 1 })

    self.UI.logo = self.UI.logoBadge:CreateTexture(nil, "ARTWORK")
    self.UI.logo:SetTexture(LOGO_TEXTURE)
    self.UI.logo:SetTexCoord(0.16, 0.84, 0.02, 0.96)
    self.UI.logo:SetSize(44, 44)
    self.UI.logo:SetPoint("CENTER", self.UI.logoBadge)

    self.UI.zoneLockedTitle = self.UI:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    self.UI.zoneLockedTitle:SetPoint("LEFT", self.UI.logo, "RIGHT", 10, 5)
    self.UI.zoneLockedTitle:SetText("|cffffd36bZone|r |cff65d1ffLocked|r")
    self.UI.tagline = self.UI:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    self.UI.tagline:SetPoint("TOPLEFT", self.UI.zoneLockedTitle, "BOTTOMLEFT", 0, -2)
    self.UI.tagline:SetText("Complete the map. Earn the road ahead.")
    self.UI.tagline:SetTextColor(0.62, 0.72, 0.78)

    self.UI.close = CreateFrame("Button", nil, self.UI, "UIPanelCloseButton")
    self.UI.close:SetPoint("TOPRIGHT", -5, -5)
    self.UI.close:SetScript("OnClick", function() self.UI:Hide() end)
    self.UI:SetScript("OnHide", function()
        if self.selectedMapID and ZL_Saved.completedZones[self.selectedMapID] then
            self:UnloadCompletedZoneDetails(self.selectedMapID)
            self.selectedMapID, self.selectedCategoryKey = nil, nil
            self._uiSignature = nil
        end
    end)

    self.UI.settingsButton = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
    self.UI.settingsButton:SetSize(92, 24)
    self.UI.settingsButton:SetPoint("RIGHT", self.UI.close, "LEFT", -4, 0)
    self.UI.settingsButton:SetText("Settings")
    local settingsHighlight = self.UI.settingsButton:GetHighlightTexture()
    if settingsHighlight then settingsHighlight:SetAlpha(0) end
    self.UI.settingsButton:SetScript("OnClick", function() self:OpenSettings() end)

    self.UI:Hide()
    table.insert(UISpecialFrames, self.UI:GetName())

    self.UI.summaryPanel = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.summaryPanel:SetPoint("TOPLEFT", 14, -66)
    self.UI.summaryPanel:SetPoint("TOPRIGHT", -14, -66)
    self.UI.summaryPanel:SetHeight(28)
    SetBackdrop(self.UI.summaryPanel, { 0.025, 0.035, 0.043, 0.90 })

    self.UI.summary = self.UI.summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    self.UI.summary:SetPoint("LEFT", self.UI.summaryPanel, "LEFT", 10, 0)
    self.UI.summary:SetWidth(680)
    self.UI.summary:SetJustifyH("LEFT")

    self.UI.search = CreateFrame("EditBox", nil, self.UI, "InputBoxTemplate")
    self.UI.search:SetSize(205, 24)
    self.UI.search:SetPoint("TOPLEFT", 20, -103)
    self.UI.search:SetAutoFocus(false)
    self.UI.search:SetText(ZL_Saved.settings.filters.search or "")
    self.UI.search.placeholder = self.UI.search:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    self.UI.search.placeholder:SetPoint("LEFT", 7, 0)
    self.UI.search.placeholder:SetText("Search objectives...")
    self.UI.search.placeholder:SetShown(self.UI.search:GetText() == "")
    self.UI.search:SetScript("OnEscapePressed", self.UI.search.ClearFocus)
    self.UI.search:SetScript("OnEnterPressed", self.UI.search.ClearFocus)
    self.UI.search:SetScript("OnTextChanged", function(editBox, userInput)
        editBox.placeholder:SetShown(editBox:GetText() == "")
        if not userInput then return end
        ZL_Saved.settings.filters.search = editBox:GetText()
        self.searchRevision = (self.searchRevision or 0) + 1
        local revision = self.searchRevision
        C_Timer.After(0.2, function()
            if revision == self.searchRevision then
                self._uiSignature = nil
                self:RefreshUI()
            end
        end)
    end)

    self.UI.clearSearch = CreateFrame("Button", nil, self.UI, "UIPanelCloseButton")
    self.UI.clearSearch:SetSize(22, 22)
    self.UI.clearSearch:SetPoint("RIGHT", self.UI.search, "RIGHT", 2, 0)
    self.UI.clearSearch:SetScript("OnClick", function()
        ZL_Saved.settings.filters.search = ""
        self.UI.search:SetText("")
        self._uiSignature = nil
        self:RefreshUI()
    end)

    self.UI.viewFilters = {}
    local filterDefinitions = {
        { key = "all", text = "All", width = 54 },
        { key = "missing", text = "Missing", width = 72 },
        { key = "collected", text = "Collected", width = 82 },
    }
    local previous = self.UI.search
    for _, definition in ipairs(filterDefinitions) do
        local filterKey = definition.key
        local button = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
        button:SetSize(definition.width, 24)
        button:SetPoint("LEFT", previous, "RIGHT", previous == self.UI.search and 10 or 3, 0)
        button:SetText(definition.text)
        button:SetScript("OnClick", function()
            ZL_Saved.settings.filters.view = filterKey
            self._uiSignature = nil
            self:RefreshUI()
        end)
        self.UI.viewFilters[filterKey] = button
        previous = button
    end

    self.UI.sort = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
    self.UI.sort:SetSize(126, 24)
    self.UI.sort:SetPoint("LEFT", previous, "RIGHT", 8, 0)
    self.UI.sort:SetScript("OnClick", function()
        self.UI.sortMenu:SetShown(not self.UI.sortMenu:IsShown())
    end)

    self.UI.transmogFilter = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
    self.UI.transmogFilter:SetSize(150, 24)
    self.UI.transmogFilter:SetPoint("TOPRIGHT", -20, -101)
    self.UI.transmogFilter:SetScript("OnClick", function()
        self.UI.transmogFilterMenu:SetShown(not self.UI.transmogFilterMenu:IsShown())
    end)

    self.UI.roll = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
    self.UI.roll:SetSize(140, 22)
    self.UI.roll:SetPoint("RIGHT", self.UI.summaryPanel, "RIGHT", -4, 0)
    self.UI.roll:SetText("Roll Next Zone")
    self.UI.roll:SetScript("OnClick", function()
        self:StartZoneRoll()
    end)

    self.UI.scrollWell = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.scrollWell:SetPoint("TOPLEFT", 12, -137)
    self.UI.scrollWell:SetPoint("BOTTOMRIGHT", -29, 12)
    SetBackdrop(self.UI.scrollWell, { 0.012, 0.018, 0.023, 0.93 })

    self.UI.scroll = CreateFrame("ScrollFrame", nil, self.UI, "UIPanelScrollFrameTemplate")
    self.UI.scroll:SetPoint("TOPLEFT", 16, -141)
    self.UI.scroll:SetPoint("BOTTOMRIGHT", -36, 16)
    self.UI.content = CreateFrame("Frame", nil, self.UI.scroll)
    self.UI.content:SetSize(850, 1)
    self.UI.scroll:SetScrollChild(self.UI.content)

    self.UI.sidebar = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.sidebar:SetPoint("TOPLEFT", 16, -141)
    self.UI.sidebar:SetPoint("BOTTOMLEFT", 16, 16)
    self.UI.sidebar:SetWidth(180)
    SetBackdrop(self.UI.sidebar, { 0.018, 0.027, 0.034, 0.97 })
    self.UI.sidebar.title = self.UI.sidebar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    self.UI.sidebar.title:SetPoint("TOPLEFT", 12, -12)
    self.UI.sidebar.title:SetText("ZONE COLLECTIONS")
    self.UI.sidebar.title:SetTextColor(0.95, 0.74, 0.32)
    self.UI.sidebar.buttons = {}
    for index = 1, 8 do
        local button = CreateFrame("Button", nil, self.UI.sidebar, "BackdropTemplate")
        button:SetSize(156, 43)
        button:SetPoint("TOPLEFT", 11, -32 - ((index - 1) * 46))
        SetBackdrop(button, { 0.025, 0.042, 0.052, 0.90 })
        button.icon = button:CreateTexture(nil, "ARTWORK")
        button.icon:SetSize(22, 22)
        button.icon:SetPoint("LEFT", 8, 0)
        button.name = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        button.name:SetPoint("TOPLEFT", button.icon, "TOPRIGHT", 7, 1)
        button.name:SetWidth(112)
        button.name:SetJustifyH("LEFT")
        button.progress = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        button.progress:SetPoint("BOTTOMLEFT", button.icon, "BOTTOMRIGHT", 7, -1)
        button.progress:SetWidth(112)
        button.progress:SetJustifyH("LEFT")
        button.progress:SetWordWrap(false)
        self.UI.sidebar.buttons[index] = button
    end
    self.UI.sidebar:Hide()

    self.UI.routeMenu = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.routeMenu:SetSize(154, 42)
    self.UI.routeMenu:SetFrameLevel(self.UI:GetFrameLevel() + 30)
    SetBackdrop(self.UI.routeMenu, { 0.025, 0.035, 0.043, 0.99 })
    self.UI.routeMenu.remove = CreateFrame("Button", nil, self.UI.routeMenu, "UIPanelButtonTemplate")
    self.UI.routeMenu.remove:SetPoint("TOPLEFT", 7, -7)
    self.UI.routeMenu.remove:SetPoint("BOTTOMRIGHT", -7, 7)
    self.UI.routeMenu.remove:SetText("Remove from route")
    self.UI.routeMenu.remove:SetScript("OnClick", function()
        local mapID = self.UI.routeMenu.mapID
        self.UI.routeMenu:Hide()
        if mapID then
            StaticPopup_Show("ZONELOCKED_REMOVE_ZONE", self:GetZoneDefinition(mapID).name, nil, { mapID = mapID })
        end
    end)
    self.UI.routeMenu:Hide()

    self.UI.sortMenu = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.sortMenu:SetSize(160, 104)
    self.UI.sortMenu:SetFrameLevel(self.UI:GetFrameLevel() + 35)
    self.UI.sortMenu:SetPoint("TOPRIGHT", self.UI.sort, "BOTTOMRIGHT", 0, -3)
    SetBackdrop(self.UI.sortMenu, { 0.025, 0.035, 0.043, 0.99 })
    local sortDefinitions = {
        { key = "default", text = "ATT Order" },
        { key = "missing", text = "Missing First" },
        { key = "name", text = "Name" },
    }
    for index, definition in ipairs(sortDefinitions) do
        local sortKey = definition.key
        local button = CreateFrame("Button", nil, self.UI.sortMenu, "UIPanelButtonTemplate")
        button:SetSize(140, 26)
        button:SetPoint("TOPLEFT", 10, -10 - ((index - 1) * 29))
        button:SetText(definition.text)
        button:SetScript("OnClick", function()
            ZL_Saved.settings.filters.sort = sortKey
            self.UI.sortMenu:Hide()
            self._uiSignature = nil
            self:RefreshUI()
        end)
    end
    self.UI.sortMenu:Hide()
    self.UI.transmogFilterMenu = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
    self.UI.transmogFilterMenu:SetSize(170, 133)
    self.UI.transmogFilterMenu:SetFrameLevel(self.UI:GetFrameLevel() + 35)
    self.UI.transmogFilterMenu:SetPoint("TOPRIGHT", self.UI.transmogFilter, "BOTTOMRIGHT", 0, -3)
    SetBackdrop(self.UI.transmogFilterMenu, { 0.025, 0.035, 0.043, 0.99 })
    for index, definition in ipairs({
        { key = "all", text = "All Sources" },
        { key = "zone", text = "Zone Drops" },
        { key = "quest", text = "Quest Rewards" },
        { key = "vendor", text = "Vendors" },
    }) do
        local sourceKey = definition.key
        local button = CreateFrame("Button", nil, self.UI.transmogFilterMenu, "UIPanelButtonTemplate")
        button:SetSize(150, 26)
        button:SetPoint("TOPLEFT", 10, -10 - ((index - 1) * 29))
        button:SetText(definition.text)
        button:SetScript("OnClick", function()
            ZL_Saved.settings.filters.transmogSource = sourceKey
            self.UI.transmogFilterMenu:Hide()
            self._uiSignature = nil
            self:RefreshUI()
        end)
    end
    self.UI.transmogFilterMenu:Hide()
    local function ResetFrame(_, frame)
        frame:Hide()
        frame:ClearAllPoints()
        if frame:GetObjectType() == "Button" then frame:SetScript("OnClick", nil) end
        frame:SetScript("OnEnter", nil)
        frame:SetScript("OnLeave", nil)
    end
    self.UI.pools = {
        starter = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
        route = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
        heading = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
        objective = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
        instance = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
        instanceCategory = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
        history = CreateFramePool("Frame", self.UI.content, "BackdropTemplate", ResetFrame),
        historySection = CreateFramePool("Frame", self.UI.content, "BackdropTemplate", ResetFrame),
        historyMessage = CreateFramePool("Frame", self.UI.content, "BackdropTemplate", ResetFrame),
        historyJourney = CreateFramePool("Button", self.UI.content, "BackdropTemplate", ResetFrame),
    }

    self.UI.back = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
    self.UI.back:SetSize(108, 24)
    self.UI.back:SetPoint("RIGHT", self.UI.settingsButton, "LEFT", -4, 0)
    self.UI.back:SetText("Show Routes")
    self.UI.back:SetScript("OnClick", function()
        self:UnloadCompletedZoneDetails(self.selectedMapID)
        self.selectedMapID = nil
        self.showHistory = false
        self.UI.scroll:SetVerticalScroll(0)
        self._uiSignature = nil
        self:RefreshUI()
    end)
    self.UI.back:Hide()

    self.UI.historyButton = CreateFrame("Button", nil, self.UI, "UIPanelButtonTemplate")
    self.UI.historyButton:SetSize(82, 24)
    self.UI.historyButton:SetPoint("RIGHT", self.UI.back, "LEFT", -4, 0)
    self.UI.historyButton:SetText("History")
    self.UI.historyButton:SetScript("OnClick", function()
        self:UnloadCompletedZoneDetails(self.selectedMapID)
        self.selectedMapID = nil
        self.showHistory = true
        self.UI.scroll:SetVerticalScroll(0)
        self._uiSignature = nil
        self:RefreshUI()
    end)
end

function ZL:UpdateToolbar()
    local sortLabels = { default = "ATT Order", missing = "Missing First", name = "Name" }
    self.UI.sort:SetText("Sort: " .. (sortLabels[ZL_Saved.settings.filters.sort] or "ATT Order"))
    local details = self.selectedMapID ~= nil
    self.UI.historyButton:SetShown(not self.showHistory)
    self.UI.historyButton:ClearAllPoints()
    if details or self.showHistory then
        self.UI.historyButton:SetPoint("RIGHT", self.UI.back, "LEFT", -4, 0)
    else
        self.UI.historyButton:SetPoint("RIGHT", self.UI.settingsButton, "LEFT", -4, 0)
    end
    self.UI.search:SetShown(details)
    self.UI.clearSearch:SetShown(details and (ZL_Saved.settings.filters.search or "") ~= "")
    for key, button in pairs(self.UI.viewFilters) do
        local selected = ZL_Saved.settings.filters.view == key
        button:SetShown(details)
        button:SetButtonState(selected and "PUSHED" or "NORMAL", selected)
    end
    self.UI.sort:SetShown(details)
    local showTransmogFilter = details and self.selectedCategoryKey == "transmogs"
    local sourceLabels = { all = "All Sources", zone = "Zone Drops", quest = "Quest Rewards", vendor = "Vendors" }
    self.UI.transmogFilter:SetText("Source: " .. (sourceLabels[ZL_Saved.settings.filters.transmogSource] or "All Sources"))
    self.UI.transmogFilter:SetShown(showTransmogFilter)
    if not showTransmogFilter then self.UI.transmogFilterMenu:Hide() end
    if not details then self.UI.sortMenu:Hide() end

    local activeMapID = self:GetActiveZone()
    local canRoll = activeMapID and self:IsZoneComplete(activeMapID) and not self:GetNextZone()
    self.UI.roll:SetShown(not details and not self.showHistory and canRoll and true or false)
end

function ZL:ToggleUI()
    if not self.UI then
        self:Print("The tracker window did not initialize. Reload the UI and check BugGrabber.")
        return
    end
    if self.UI:IsShown() then
        self.UI:Hide()
    else
        self.UI:Show()
        self._uiSignature = nil
        self:RefreshUI()
    end
end

local function ClearContent(content)
    if not content then return end
    if content.historyEmptyText then content.historyEmptyText:Hide() end
    if content.historyRenderError then content.historyRenderError:Hide() end
    if content.historyControls then content.historyControls:Hide() end
    if ZL.UI and ZL.UI.historyClearMenu then ZL.UI.historyClearMenu:Hide() end
    if content.historyMessageFrame then content.historyMessageFrame:Hide() end
    for _, frame in ipairs(content.historySectionFrames or {}) do frame:Hide() end
    for _, frame in ipairs(content.historyJourneyFrames or {}) do frame:Hide() end
    GameTooltip_Hide()
    if ZL.UI.routeMenu then ZL.UI.routeMenu:Hide() end
    if ZL.UI.sortMenu then ZL.UI.sortMenu:Hide() end
    if ZL.UI.transmogFilterMenu then ZL.UI.transmogFilterMenu:Hide() end
    for _, pool in pairs(ZL.UI.pools or {}) do pool:ReleaseAll() end
end

function ZL:SetViewLayout(details)
    self.UI.scroll:ClearAllPoints()
    self.UI.scrollWell:ClearAllPoints()
    if details then
        self.UI.sidebar:Show()
        self.UI.scrollWell:SetPoint("TOPLEFT", 12, -137)
        self.UI.scroll:SetPoint("TOPLEFT", 207, -141)
        self.UI.content:SetWidth(650)
    else
        self.UI.sidebar:Hide()
        self.UI.scrollWell:SetPoint("TOPLEFT", 12, -101)
        self.UI.scroll:SetPoint("TOPLEFT", 16, -105)
        self.UI.content:SetWidth(850)
    end
    self.UI.scrollWell:SetPoint("BOTTOMRIGHT", -29, 12)
    self.UI.scroll:SetPoint("BOTTOMRIGHT", -36, 16)
end

function ZL:SetAdaptiveHeight(contentHeight, details)
    local minimum = details and 570 or 245
    local desired = math.max(minimum, math.min(720, (contentHeight or 1) + (details and 176 or 120)))
    self.UI:SetHeight(desired)
end

function ZL:UpdateCategorySidebar(data)
    local theme = self:GetTheme()
    local search = ((ZL_Saved.settings.filters and ZL_Saved.settings.filters.search) or ""):lower()
    local function HasSearchMatch(category)
        if search == "" then return false end
        for _, objective in ipairs(category.objectives or {}) do
            if (objective.name or ""):lower():find(search, 1, true) then return true end
        end
        for _, instance in ipairs(category.instances or {}) do
            if (instance.name or ""):lower():find(search, 1, true) then return true end
            for _, childCategory in ipairs(instance.categories or {}) do
                if HasSearchMatch(childCategory) then return true end
            end
        end
        return false
    end

    local selected
    for _, category in ipairs(data.categories or {}) do
        if category.key == self.selectedCategoryKey then selected = category; break end
    end
    if not selected then
        selected = data.categories and data.categories[1]
        self.selectedCategoryKey = selected and selected.key or nil
    end
    if search ~= "" and selected and not HasSearchMatch(selected) then
        for _, category in ipairs(data.categories or {}) do
            if HasSearchMatch(category) then
                selected = category
                self.selectedCategoryKey = category.key
                break
            end
        end
    end

    for index, button in ipairs(self.UI.sidebar.buttons) do
        local category = data.categories and data.categories[index]
        if category then
            local categoryKey = category.key
            local categoryName = category.name
            local meta = CATEGORY_META[categoryKey] or { icon = 134400 }
            local categoryDone, categoryTotal = self:GetCategoryProgress(category)
            local percent = categoryTotal > 0 and math.floor((categoryDone / categoryTotal) * 100 + 0.5) or 100
            local enabled = self:IsCategoryEnabled(categoryKey)
            local searchMatches = HasSearchMatch(category)
            button.icon:SetTexture(meta.icon)
            button.icon:SetDesaturated(not enabled)
            button.name:SetText(categoryName .. (searchMatches and " |cffffd36b*|r" or ""))
            button.name:SetTextColor(enabled and theme.secondary[1] or 0.5,
                enabled and theme.secondary[2] or 0.5, enabled and theme.secondary[3] or 0.5)
            button.progress:SetText(string.format("%d%%  %d/%d", percent, categoryDone, categoryTotal))
            local progressColor = categoryDone == categoryTotal and theme.secondary or theme.accent
            button.progress:SetTextColor(enabled and progressColor[1] or 0.5,
                enabled and progressColor[2] or 0.5, enabled and progressColor[3] or 0.5)
            SetBackdrop(button, categoryKey == self.selectedCategoryKey and
                theme.header or theme.panel)
            if categoryKey == self.selectedCategoryKey then
                button:SetBackdropBorderColor(unpack(theme.accent))
            end
            button:SetScript("OnClick", function()
                if IsAltKeyDown() then
                    self:AddCategoryWaypoint(category)
                    return
                end
                self.selectedCategoryKey = categoryKey
                if categoryKey ~= "instances" then
                    self.selectedInstanceMapID = nil
                    self.selectedInstanceCategoryKey = nil
                end
                self._uiSignature = nil
                self.UI.scroll:SetVerticalScroll(0)
                self:RefreshUI()
            end)
            button:SetScript("OnEnter", function(row) row:SetBackdropColor(0.06, 0.14, 0.18, 1) end)
            button:SetScript("OnLeave", function(row)
                row:SetBackdropColor(categoryKey == self.selectedCategoryKey and 0.045 or 0.025,
                    categoryKey == self.selectedCategoryKey and 0.12 or 0.042,
                    categoryKey == self.selectedCategoryKey and 0.16 or 0.052,
                    categoryKey == self.selectedCategoryKey and 0.98 or 0.90)
            end)
            button:Show()
        else
            button:Hide()
        end
    end
    return selected
end

local function ProgressColor(done, total)
    if total == 0 then return "|cff55ff88100% Complete (0 / 0)|r" end
    local percent = math.floor((done / total) * 100 + 0.5)
    if done == total then return string.format("|cff55ff88100%% Complete (%d / %d)|r", done, total) end
    return string.format("|cffffcc55%d%% Complete (%d / %d)|r", percent, done, total)
end

local function Percent(done, total)
    return total > 0 and math.floor((done / total) * 100 + 0.5) or 100
end

local function CreateProgressBar(parent, width, height)
    local bar = CreateFrame("StatusBar", nil, parent)
    bar:SetSize(width, height)
    bar:SetStatusBarTexture("Interface/Buttons/WHITE8X8")
    bar:SetStatusBarColor(0.12, 0.62, 0.82, 0.9)
    bar:SetMinMaxValues(0, 1)
    local background = bar:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints()
    background:SetColorTexture(0.08, 0.09, 0.11, 0.95)
    bar.text = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    bar.text:SetPoint("CENTER")
    return bar
end

local function SetProgressBar(bar, done, total)
    bar:SetMinMaxValues(0, math.max(total, 1))
    bar:SetValue(done)
    local percent = Percent(done, total)
    bar.text:SetText(string.format("%d%%  (%d / %d)", percent, done, total))
    local theme = ZL:GetTheme()
    local color = done == total and theme.secondary or theme.accent
    bar:SetStatusBarColor(color[1], color[2], color[3], color[4] or 0.95)
end

local function ZoneProgress(data)
    if data.eligible == false then return "|cff888888Unavailable to this character|r" end
    if not data.catalogComplete then return "|cffff8844Catalog incomplete|r" end
    local done, total = ZL:GetProgress(data)
    return ProgressColor(done, total)
end

function ZL:BuildRouteView()
    local content, route = self.UI.content, ZL_Saved.route
    local theme = self:GetTheme()
    self:SetViewLayout(false)
    self.UI.back:Hide()
    local unlocked, y = self:GetUnlockedIndex(), -10
    if #route == 0 then
        self.UI.summary:SetText("Choose a continent, then roll where your Zone Locked journey begins.")
        local continents = self:GetSelectableContinents()
        for index, continent in ipairs(continents) do
            local continentID, expansionKey = continent.continentID, continent.expansionKey
            local button = self.UI.pools.starter:Acquire()
            button:Show()
            if not button.zlInitialized then
                button.zlInitialized = true
                button.name = button:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
                button.name:SetPoint("TOPLEFT", 14, -18)
                button.subtitle = button:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                button.subtitle:SetPoint("TOPLEFT", button.name, "BOTTOMLEFT", 0, -10)
                button.action = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                button.action:SetPoint("BOTTOMLEFT", 14, 15)
                button.action:SetText("Roll starting zone")
            end
            button:SetSize(250, 104)
            local column, row = (index - 1) % 3, math.floor((index - 1) / 3)
            button:SetPoint("TOPLEFT", 7 + (column * 266), -12 - (row * 116))
            SetBackdrop(button, theme.panel)
            button.name:SetTextColor(unpack(theme.accent))
            button.action:SetTextColor(unpack(theme.secondary))
            button:SetScript("OnEnter", function(frame) frame:SetBackdropColor(unpack(theme.header)) end)
            button:SetScript("OnLeave", function(frame) frame:SetBackdropColor(unpack(theme.panel)) end)
            button:SetScript("OnClick", function()
                self:StartZoneRoll(continentID, expansionKey)
            end)

            button.name:SetText(continent.name)
            button.subtitle:SetText(continent.expansionName .. "  |  " ..
                #self:GetContinentZoneIDs(continentID, expansionKey) .. " possible zones")
        end
        content:SetHeight(math.max(132, math.ceil(#continents / 3) * 116 + 8))
    else
        local activeName = self:GetZoneDefinition(route[unlocked]).name
        local nextID = self:GetNextZone()
        local cleared = 0
        for _, routeMapID in ipairs(route) do
            if self:IsZoneComplete(routeMapID) then cleared = cleared + 1 end
        end
        self.UI.summary:SetText(string.format("Journey Journal   |   %d of %d Completed   |   Current: %s%s", cleared, #route,
            activeName, nextID and ("   |   Next: " .. self:GetZoneDefinition(nextID).name) or ""))
    end
    for index, mapID in ipairs(route) do
        local routeMapID = mapID
        local data = self.ZoneData[routeMapID] or ZL_Saved.zoneSummaries[routeMapID] or {
            mapID = routeMapID, name = self:GetZoneDefinition(routeMapID).name,
            done = 0, total = 0, provider = "archived", instances = {}, summary = true,
        }
        local done, total
        if data.summary then done, total = data.done or 0, data.total or 0 else done, total = self:GetProgress(data) end
        local row = self.UI.pools.route:Acquire()
        row:Show()
        if not row.zlInitialized then
            row.zlInitialized = true
            row.medallion = CreateFrame("Frame", nil, row, "BackdropTemplate")
            row.medallion:SetSize(48, 48)
            row.medallion:SetPoint("LEFT", -41, 0)
            SetBackdrop(row.medallion, { 0.025, 0.045, 0.055, 1 })
            row.number = row.medallion:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
            row.number:SetPoint("CENTER")
            row.connector = row:CreateTexture(nil, "BACKGROUND")
            row.connector:SetSize(3, 54)
            row.connector:SetPoint("TOP", row.medallion, "BOTTOM", 0, -2)
            row.accent = row:CreateTexture(nil, "ARTWORK")
            row.accent:SetPoint("TOPLEFT", 1, -1)
            row.accent:SetPoint("BOTTOMLEFT", 1, 1)
            row.accent:SetWidth(4)
            row.phase = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.phase:SetPoint("TOPLEFT", 19, -11)
            row.phase:SetTextColor(0.55, 0.68, 0.75)
            row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
            row.name:SetPoint("TOPLEFT", 19, -27)
            row.name:SetWidth(245)
            row.name:SetJustifyH("LEFT")
            row.name:SetWordWrap(false)
            row.subtitle = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            row.subtitle:SetPoint("LEFT", row.name, "RIGHT", 10, 0)
            row.subtitle:SetWordWrap(false)
            row.subtitle:SetTextColor(0.50, 0.62, 0.69)
            row.progress = CreateProgressBar(row, 525, 15)
            row.progress:SetPoint("BOTTOMLEFT", 19, 12)
            row.statePill = CreateFrame("Frame", nil, row, "BackdropTemplate")
            row.statePill:SetSize(102, 25)
            row.statePill:SetPoint("RIGHT", -51, 3)
            row.subtitle:SetPoint("RIGHT", row.statePill, "LEFT", -12, 0)
            row.state = row.statePill:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.state:SetPoint("CENTER")
            row.actions = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
            row.actions:SetSize(30, 22)
            row.actions:SetPoint("RIGHT", -12, 3)
            row.actions:SetText("x")
        end
        row:SetSize(790, 82)
        row:SetPoint("TOPLEFT", 47, y)
        SetBackdrop(row, theme.panel)
        row.medallion:SetBackdropColor(unpack(theme.header))
        row.medallion:SetBackdropBorderColor(unpack(theme.border))
        row:SetScript("OnEnter", function(button) button:SetBackdropColor(unpack(theme.header)) end)
        row:SetScript("OnLeave", function(button) button:SetBackdropColor(unpack(theme.panel)) end)
        row:SetScript("OnClick", function()
            self.UI.routeMenu:Hide()
            if ZL_Saved.completedZones[routeMapID] and
                not (self.forceLoadedZones and self.forceLoadedZones[routeMapID]) then
                StaticPopup_Show("ZONELOCKED_LOAD_COMPLETED_ZONE", data.name, nil, { mapID = routeMapID })
                return
            end
            self.selectedMapID = routeMapID
            self.selectedCategoryKey = nil
            self.UI.scroll:SetVerticalScroll(0)
            self._uiSignature = nil
            self:RefreshUI()
        end)
        row.actions:SetScript("OnClick", function()
            StaticPopup_Show("ZONELOCKED_REMOVE_ZONE", self:GetZoneDefinition(routeMapID).name, nil,
                { mapID = routeMapID })
        end)

        row.number:SetText(index)
        row.name:SetText(data.name)
        row.name:SetTextColor(unpack(theme.accent))
        row.number:SetTextColor(unpack(theme.accent))
        row.phase:SetTextColor(theme.secondary[1] * 0.75, theme.secondary[2] * 0.75,
            theme.secondary[3] * 0.75, 1)
        row.subtitle:SetTextColor(theme.secondary[1] * 0.70, theme.secondary[2] * 0.70,
            theme.secondary[3] * 0.70, 1)
        row.phase:SetText(string.format("CHAPTER %02d", index))
        local instanceNames = {}
        for _, instance in ipairs(data.instances or {}) do instanceNames[#instanceNames + 1] = instance.name end
        if #instanceNames > 0 then
            local visible = { instanceNames[1] }
            if instanceNames[2] then visible[#visible + 1] = instanceNames[2] end
            row.subtitle:SetText("Includes " .. table.concat(visible, ", ") ..
                (#instanceNames > 2 and (" +" .. (#instanceNames - 2) .. " more") or ""))
        else
            row.subtitle:SetText(string.format("%d tracked objectives via %s", total, data.provider or "catalog"))
        end
        SetProgressBar(row.progress, done, total)
        if index > unlocked then
            row.state:SetText("Locked")
            row.state:SetTextColor(1, 0.42, 0.38)
            SetBackdrop(row.statePill, { 0.18, 0.055, 0.045, 0.95 })
            row.accent:SetColorTexture(0.58, 0.20, 0.18, 1)
            row:SetBackdropBorderColor(0.45, 0.20, 0.18, 0.82)
            row.connector:SetColorTexture(0.30, 0.20, 0.18, 0.9)
        elseif self:IsZoneComplete(routeMapID) then
            row.state:SetText("Completed")
            row.state:SetTextColor(unpack(theme.secondary))
            SetBackdrop(row.statePill, theme.panel)
            row.statePill:SetBackdropBorderColor(unpack(theme.secondary))
            row.accent:SetColorTexture(unpack(theme.secondary))
            row:SetBackdropBorderColor(unpack(theme.secondary))
            row.connector:SetColorTexture(unpack(theme.secondary))
        else
            row.state:SetText("Active")
            row.state:SetTextColor(unpack(theme.accent))
            SetBackdrop(row.statePill, theme.panel)
            row.statePill:SetBackdropBorderColor(unpack(theme.accent))
            row.accent:SetColorTexture(unpack(theme.accent))
            row:SetBackdropBorderColor(unpack(theme.accent))
            row.connector:SetColorTexture(unpack(theme.accent))
        end
        row.connector:SetShown(index < #route)
        y = y - 94
    end
    if #route > 0 then content:SetHeight(math.max(1, -y + 2)) end
    self:SetAdaptiveHeight(content:GetHeight(), false)
end

function ZL:BuildHistoryView()
    local content, theme = self.UI.content, self:GetTheme()
    self:SetViewLayout(false)
    self.UI.back:Show()
    local historySettings = ZL_Saved.settings.history
    self.UI.summary:SetText(string.format("Completion History   |   %d events   |   %d archived journey leg%s%s",
        #(ZL_Saved.history or {}), #(ZL_Saved.journeys or {}), #(ZL_Saved.journeys or {}) == 1 and "" or "s",
        historySettings.enabled == false and "   |cffff6666Tracking Off|r" or ""))
    local history = ZL_Saved.history or {}
    local y, shown = -8, 0

    if not content.historyControls then
        local controls = CreateFrame("Frame", nil, content, "BackdropTemplate")
        controls:SetSize(812, 38)
        SetBackdrop(controls, theme.panel)
        controls.search = CreateFrame("EditBox", nil, controls, "InputBoxTemplate")
        controls.search:SetSize(150, 24)
        controls.search:SetPoint("LEFT", 10, 0)
        controls.search:SetAutoFocus(false)
        controls.search:SetMaxLetters(80)
        controls.search:SetScript("OnEnterPressed", function(editBox)
            historySettings.search = editBox:GetText() or ""
            editBox:ClearFocus()
            self._uiSignature = nil
            self:RefreshUI()
        end)
        controls.search:SetScript("OnEscapePressed", function(editBox) editBox:ClearFocus() end)
        controls.searchButton = CreateFrame("Button", nil, controls, "UIPanelButtonTemplate")
        controls.searchButton:SetSize(54, 24)
        controls.searchButton:SetPoint("LEFT", controls.search, "RIGHT", 5, 0)
        controls.searchButton:SetText("Search")
        controls.searchButton:SetScript("OnClick", function()
            historySettings.search = controls.search:GetText() or ""
            controls.search:ClearFocus()
            self._uiSignature = nil
            self:RefreshUI()
        end)
        controls.filters = {}
        local previous = controls.searchButton
        for _, definition in ipairs({
            { key = "all", label = "All" }, { key = "zones", label = "Zones" },
            { key = "quests", label = "Quests" }, { key = "items", label = "Items" },
        }) do
            local filterKey = definition.key
            local button = CreateFrame("Button", nil, controls, "UIPanelButtonTemplate")
            button:SetSize(filterKey == "all" and 48 or 58, 24)
            button:SetPoint("LEFT", previous, "RIGHT", 5, 0)
            button:SetText(definition.label)
            button:SetScript("OnClick", function()
                historySettings.filter = filterKey
                self._uiSignature = nil
                self:RefreshUI()
            end)
            controls.filters[filterKey] = button
            previous = button
        end
        controls.clear = CreateFrame("Button", nil, controls, "UIPanelButtonTemplate")
        controls.clear:SetSize(88, 24)
        controls.clear:SetPoint("RIGHT", -10, 0)
        controls.clear:SetText("Delete")
        controls.clear:SetScript("OnClick", function()
            self.UI.historyClearMenu:SetShown(not self.UI.historyClearMenu:IsShown())
        end)
        controls.export = CreateFrame("Button", nil, controls, "UIPanelButtonTemplate")
        controls.export:SetSize(64, 24)
        controls.export:SetText("Export")
        controls.export:SetScript("OnClick", function() self:ShowHistoryExport() end)
        controls.import = CreateFrame("Button", nil, controls, "UIPanelButtonTemplate")
        controls.import:SetSize(64, 24)
        controls.import:SetPoint("RIGHT", controls.clear, "LEFT", -5, 0)
        controls.export:SetPoint("RIGHT", controls.import, "LEFT", -5, 0)
        controls.import:SetText("Import")
        controls.import:SetScript("OnClick", function() self:ShowHistoryImport() end)
        content.historyControls = controls

        local menu = CreateFrame("Frame", nil, self.UI, "BackdropTemplate")
        menu:SetSize(190, 128)
        menu:SetFrameLevel(self.UI:GetFrameLevel() + 40)
        menu:SetPoint("TOPRIGHT", controls.clear, "BOTTOMRIGHT", 0, -3)
        SetBackdrop(menu, { 0.025, 0.035, 0.043, 0.99 })
        for index, definition in ipairs({
            { scope = "all", label = "All History" },
            { scope = "zones", label = "Zone Completion History" },
            { scope = "quests", label = "Quest History" },
            { scope = "items", label = "Item History" },
        }) do
            local scope = definition.scope
            local label = definition.label
            local button = CreateFrame("Button", nil, menu, "UIPanelButtonTemplate")
            button:SetSize(170, 24)
            button:SetPoint("TOPLEFT", 10, -8 - ((index - 1) * 29))
            button:SetText(label)
            button:SetScript("OnClick", function()
                menu:Hide()
                StaticPopup_Show("ZONELOCKED_CLEAR_HISTORY", label, nil, { scope = scope })
            end)
        end
        menu:Hide()
        self.UI.historyClearMenu = menu
    end
    local controls = content.historyControls
    controls:ClearAllPoints()
    controls:SetPoint("TOPLEFT", 19, y)
    controls.search:SetText(historySettings.search or "")
    for key, button in pairs(controls.filters) do
        local selected = (historySettings.filter or "all") == key
        button:SetButtonState(selected and "PUSHED" or "NORMAL", selected)
    end
    controls:Show()
    y = y - 46

    local selectedFilter = historySettings.filter or "all"
    local search = (historySettings.search or ""):lower()
    local filteredHistory = {}
    for _, event in ipairs(history) do
        if type(event) == "table" then
            local group = self:GetHistoryEventGroup(event)
            local filterMatches = selectedFilter == "all" or selectedFilter == group
            local haystack = table.concat({ event.name or "", event.zoneName or "", event.category or "",
                event.instanceName or "" }, " "):lower()
            if filterMatches and (search == "" or haystack:find(search, 1, true)) then
                filteredHistory[#filteredHistory + 1] = event
            end
        end
    end

    content.historySectionFrames = content.historySectionFrames or {}
    local sectionIndex = 0
    local function AddSection(text, sectionKey)
        sectionIndex = sectionIndex + 1
        local row = content.historySectionFrames[sectionIndex]
        if not row then
            row = CreateFrame("Button", nil, content, "BackdropTemplate")
            row.sectionTitle = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            row.sectionTitle:SetPoint("LEFT", 10, 0)
            row.toggle = row:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
            row.toggle:SetPoint("RIGHT", -10, 0)
            row:SetScript("OnEnter", function(button) button:SetBackdropColor(0.04, 0.075, 0.09, 0.98) end)
            row:SetScript("OnLeave", function(button) button:SetBackdropColor(0.025, 0.04, 0.05, 0.96) end)
            row:SetScript("OnClick", function(button)
                local key = button.sectionKey
                ZL_Saved.ui.historyCollapsed[key] = not ZL_Saved.ui.historyCollapsed[key]
                self._uiSignature = nil
                self:RefreshUI()
            end)
            content.historySectionFrames[sectionIndex] = row
        end
        row:Show()
        row:ClearAllPoints()
        row:SetSize(812, 28)
        row:SetPoint("TOPLEFT", 19, y)
        SetBackdrop(row, theme.header)
        row.sectionTitle:SetText(text)
        row.sectionTitle:SetTextColor(unpack(theme.accent))
        row.sectionKey = sectionKey
        local collapsed = ZL_Saved.ui.historyCollapsed[sectionKey] and true or false
        row.toggle:SetText(collapsed and "+" or "-")
        y = y - 34
        return collapsed
    end

    local recentCollapsed = AddSection("Recent Completions", "recent")
    if not recentCollapsed then
    for index = #filteredHistory, math.max(1, #filteredHistory - 199), -1 do
        local storedEvent = filteredHistory[index]
        local event = type(storedEvent) == "table" and storedEvent or {
            name = tostring(storedEvent or "Completed objective"),
            timestamp = time(),
        }
        local row = self.UI.pools.history:Acquire()
        row:Show()
        if not row.zlInitialized then
            row.zlInitialized = true
            row.icon = row:CreateTexture(nil, "ARTWORK")
            row.icon:SetSize(30, 30)
            row.icon:SetPoint("LEFT", 12, 0)
            row.title = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            row.title:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 10, 1)
            row.title:SetPoint("RIGHT", -155, 0)
            row.title:SetJustifyH("LEFT")
            row.title:SetWordWrap(false)
            row.meta = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            row.meta:SetPoint("TOPLEFT", row.title, "BOTTOMLEFT", 0, -4)
            row.meta:SetTextColor(0.55, 0.67, 0.74)
            row.date = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            row.date:SetPoint("RIGHT", -12, 0)
            row.date:SetWidth(135)
            row.date:SetJustifyH("RIGHT")
            row.delete = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
            row.delete:SetSize(22, 22)
            row.delete:SetPoint("RIGHT", -8, 0)
            row.delete:SetText("x")
            row.delete:SetScript("OnClick", function(button)
                local parent = button:GetParent()
                local event = parent.historyEvent
                StaticPopup_Show("ZONELOCKED_DELETE_HISTORY_EVENT",
                    event and event.name or "Completed objective", nil, { event = event })
            end)
        end
        row:SetSize(812, 50)
        row:SetPoint("TOPLEFT", 19, y)
        SetBackdrop(row, (event.eventType == "zone" or event.eventType == "journey") and
            { 0.035, 0.12, 0.075, 0.94 } or { 0.025, 0.045, 0.06, 0.92 })
        row.icon:Show()
        row.meta:Show()
        row.date:Show()
        row.title:ClearAllPoints()
        row.title:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 10, 1)
        row.title:SetPoint("RIGHT", -190, 0)
        row.icon:SetTexture((event.eventType == "zone" or event.eventType == "journey") and 237381 or 236621)
        row.title:SetText((event.name or "Completed objective"):gsub(" completed", " Completed"))
        row.title:SetTextColor(unpack(theme.accent))
        row.meta:SetTextColor(theme.secondary[1] * 0.72, theme.secondary[2] * 0.72,
            theme.secondary[3] * 0.72, 1)
        local meta = { event.zoneName or "Unknown zone" }
        if event.instanceName and event.instanceName ~= "" then meta[#meta + 1] = event.instanceName end
        if event.category and event.category ~= "" then meta[#meta + 1] = event.category end
        row.meta:SetText(table.concat(meta, "  |  "))
        row.date:SetText(date("%d %b %Y\n%H:%M:%S", event.timestamp or time()))
        row.date:ClearAllPoints()
        row.date:SetPoint("RIGHT", -38, 0)
        row.historyEvent = storedEvent
        y = y - 56
        shown = shown + 1
    end
    if shown == 0 then
        if not content.historyEmptyText then
            content.historyEmptyText = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            content.historyEmptyText:SetWidth(720)
            content.historyEmptyText:SetJustifyH("CENTER")
        end
        content.historyEmptyText:ClearAllPoints()
        content.historyEmptyText:SetPoint("TOP", content, "TOP", 0, y - 8)
        content.historyEmptyText:SetText(#history == 0 and
            "No new completions recorded yet.\nNew enabled events will appear here automatically." or
            "No history entries match the current filter and search.")
        content.historyEmptyText:Show()
        y = y - 76
    elseif content.historyEmptyText then
        content.historyEmptyText:Hide()
    end
    end

    local journeys = ZL_Saved.journeys or {}
    local showJourneys = selectedFilter == "all" or selectedFilter == "zones"
    local journeysCollapsed = showJourneys and AddSection("Archived Journeys", "journeys")
    if showJourneys and not journeysCollapsed and #journeys == 0 then
        local row = content.historyMessageFrame
        if not row then
            row = CreateFrame("Frame", nil, content, "BackdropTemplate")
            content.historyMessageFrame = row
        end
        row:Show()
        if not row.zlMessageInitialized then
            row.zlMessageInitialized = true
            row.message = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            row.message:SetPoint("CENTER")
            row.message:SetTextColor(0.55, 0.64, 0.70)
        end
        row:SetSize(812, 46)
        row:SetPoint("TOPLEFT", 19, y)
        SetBackdrop(row, theme.panel)
        row.message:SetText("Completed continent journeys will be saved here.")
        y = y - 52
    elseif showJourneys and not journeysCollapsed then
        content.historyJourneyFrames = content.historyJourneyFrames or {}
        local renderedJourney = 0
        for index = #journeys, 1, -1 do
            local journey = journeys[index]
            if type(journey) == "table" then
            local journeyText = table.concat({ journey.name or "", journey.continentName or "",
                journey.expansionName or "" }, " "):lower()
            if search == "" or journeyText:find(search, 1, true) then
            renderedJourney = renderedJourney + 1
            local expanded = journey.expanded and true or false
            local zoneCount = #(journey.zones or {})
            local height = 64 + (expanded and math.max(24, zoneCount * 18 + 10) or 0)
            local row = content.historyJourneyFrames[renderedJourney]
            if not row then
                row = CreateFrame("Button", nil, content, "BackdropTemplate")
                content.historyJourneyFrames[renderedJourney] = row
            end
            row:Show()
            row:ClearAllPoints()
            if not row.zlInitialized then
                row.zlInitialized = true
                row.toggle = row:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
                row.toggle:SetPoint("TOPLEFT", 12, -12)
                row.title = row:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
                row.title:SetPoint("TOPLEFT", 34, -10)
                row.title:SetPoint("RIGHT", -190, 0)
                row.title:SetJustifyH("LEFT")
                row.meta = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                row.meta:SetPoint("TOPLEFT", row.title, "BOTTOMLEFT", 0, -5)
                row.meta:SetTextColor(0.55, 0.67, 0.74)
                row.zones = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                row.zones:SetPoint("TOPLEFT", 34, -66)
                row.zones:SetPoint("RIGHT", -18, 0)
                row.zones:SetJustifyH("LEFT")
                row.zones:SetJustifyV("TOP")
                row.rename = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
                row.rename:SetSize(72, 22)
                row.rename:SetPoint("TOPRIGHT", -88, -20)
                row.rename:SetText("Rename")
                row.remove = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
                row.remove:SetSize(72, 22)
                row.remove:SetPoint("TOPRIGHT", -10, -20)
                row.remove:SetText("Delete")
            end
            row:SetSize(812, height)
            row:SetPoint("TOPLEFT", 19, y)
            SetBackdrop(row, theme.panel)
            row.toggle:SetText(expanded and "-" or "+")
            row.title:SetText(journey.name or ((journey.continentName or "Unknown") .. " Journey"))
            local completed = journey.completedAt and date("%d %b %Y, %H:%M", journey.completedAt) or "Unknown date"
            row.meta:SetText(string.format("%s  |  %d zone%s  |  Completed %s",
                journey.expansionName or "Unknown expansion", zoneCount, zoneCount == 1 and "" or "s", completed))
            local zoneNames = {}
            for zoneIndex, mapID in ipairs(journey.zones or {}) do
                local definition = mapID and self:GetZoneDefinition(mapID)
                zoneNames[#zoneNames + 1] = string.format("%d. %s", zoneIndex,
                    definition and definition.name or ("Unknown zone " .. tostring(mapID or "")))
            end
            row.zones:SetText(table.concat(zoneNames, "\n"))
            row.zones:SetShown(expanded)
            row.journey = journey
            row.journeyIndex = index
            row:SetScript("OnClick", function()
                row.journey.expanded = not row.journey.expanded
                self._uiSignature = nil
                self:RefreshUI()
            end)
            row.rename:SetScript("OnClick", function()
                StaticPopup_Show("ZONELOCKED_RENAME_JOURNEY", nil, nil, { index = row.journeyIndex })
            end)
            row.remove:SetScript("OnClick", function()
                StaticPopup_Show("ZONELOCKED_DELETE_JOURNEY",
                    row.journey.name or row.journey.continentName or "Journey", nil,
                    { index = row.journeyIndex })
            end)
            y = y - height - 8
            end
            end
        end
    end
    content:SetHeight(math.max(1, -y + 8))
    self:SetAdaptiveHeight(content:GetHeight(), false)
end

function ZL:BuildDetailsView(mapID)
    local content, theme = self.UI.content, self:GetTheme()
    local data = self.ZoneData[mapID] or self:ScanZone(mapID)
    self:SetViewLayout(true)
    self.UI.back:Show()
    self.UI.summary:SetText(string.format("%s - %s%s", data.name, ZoneProgress(data),
        data.provider and ("  |cff888888via " .. data.provider .. "|r") or ""))

    local category = self:UpdateCategorySidebar(data)
    local y = -4
    if not category then
        content:SetHeight(80)
        self:SetAdaptiveHeight(80, true)
        return
    end

    local heading = self.UI.pools.heading:Acquire()
    heading:Show()
    if not heading.zlInitialized then
        heading.zlInitialized = true
        heading.icon = heading:CreateTexture(nil, "ARTWORK")
        heading.icon:SetSize(26, 26)
        heading.icon:SetPoint("LEFT", 10, 0)
        heading.name = heading:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        heading.name:SetPoint("TOPLEFT", heading.icon, "TOPRIGHT", 8, 2)
        heading.results = heading:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        heading.results:SetPoint("TOPLEFT", heading.name, "BOTTOMLEFT", 0, -3)
        heading.results:SetTextColor(0.55, 0.68, 0.75)
        heading.progress = CreateProgressBar(heading, 230, 16)
        heading.progress:SetPoint("RIGHT", -12, 0)
    end
    heading:SetSize(625, 46)
    heading:SetPoint("TOPLEFT", 6, y)
    SetBackdrop(heading, theme.header)
    heading.icon:SetTexture((CATEGORY_META[category.key] or {}).icon or 134400)
    heading.icon:SetDesaturated(not self:IsCategoryEnabled(category.key))
    heading.name:SetText(category.name .. (self:IsCategoryEnabled(category.key) and "" or "  |cff777777(Excluded)|r"))
    heading.name:SetTextColor(self:IsCategoryEnabled(category.key) and theme.accent[1] or 0.55,
        self:IsCategoryEnabled(category.key) and theme.accent[2] or 0.55,
        self:IsCategoryEnabled(category.key) and theme.accent[3] or 0.55)
    heading.results:SetTextColor(theme.secondary[1] * 0.72, theme.secondary[2] * 0.72,
        theme.secondary[3] * 0.72, 1)
    local categoryDone, categoryTotal = self:GetCategoryProgress(category)
    SetProgressBar(heading.progress, categoryDone, categoryTotal)
    y = y - 54

    if category.key == "instances" then
        local filters = ZL_Saved.settings.filters
        local search = (filters.search or ""):lower()
        heading.results:SetText(string.format("%d linked dungeon%s or raid%s", #(category.instances or {}),
            #(category.instances or {}) == 1 and "" or "s", #(category.instances or {}) == 1 and "" or "s"))

        local function ObjectiveMatches(objective)
            local viewMatches = filters.view == "all" or
                (filters.view == "missing" and not objective.complete) or
                (filters.view == "collected" and objective.complete)
            local sourceFilter = filters.transmogSource or "all"
            local sourceMatches = objective.categoryKey ~= "transmogs" or sourceFilter == "all" or
                objective.transmogSource == sourceFilter
            return viewMatches and sourceMatches and
                (search == "" or (objective.name or ""):lower():find(search, 1, true))
        end

        local function CategoryMatches(childCategory)
            if search == "" then return false end
            if (childCategory.name or ""):lower():find(search, 1, true) then return true end
            for _, objective in ipairs(childCategory.objectives or {}) do
                if (objective.name or ""):lower():find(search, 1, true) then return true end
            end
            return false
        end

        for _, instance in ipairs(category.instances or {}) do
            local instanceData = instance
            local collapsed = ZL_Saved.ui.collapsed[mapID] and
                ZL_Saved.ui.collapsed[mapID]["instance:" .. instanceData.mapID]
            local instanceSearchMatch = search ~= "" and (instanceData.name or ""):lower():find(search, 1, true)
            if search ~= "" and not instanceSearchMatch then
                for _, childCategory in ipairs(instanceData.categories or {}) do
                    if CategoryMatches(childCategory) then instanceSearchMatch = true; break end
                end
            end
            local expanded = not collapsed or instanceSearchMatch
            local instanceDone, instanceTotal = 0, 0
            for _, childCategory in ipairs(instanceData.categories or {}) do
                if self:IsCategoryEnabled(childCategory.key) then
                    instanceDone = instanceDone + childCategory.done
                    instanceTotal = instanceTotal + childCategory.total
                end
            end

            local card = self.UI.pools.instance:Acquire()
            card:Show()
            if not card.zlInitialized then
                card.zlInitialized = true
                card.chevron = card:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
                card.chevron:SetPoint("LEFT", 10, 0)
                card.icon = card:CreateTexture(nil, "ARTWORK")
                card.icon:SetSize(28, 28)
                card.icon:SetPoint("LEFT", 32, 0)
                card.name = card:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
                card.name:SetPoint("TOPLEFT", card.icon, "TOPRIGHT", 9, 2)
                card.kind = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                card.kind:SetPoint("TOPLEFT", card.name, "BOTTOMLEFT", 0, -3)
                card.kind:SetTextColor(0.52, 0.66, 0.74)
                card.progress = CreateProgressBar(card, 205, 15)
                card.progress:SetPoint("RIGHT", -12, 0)
            end
            card:SetSize(610, 52)
            card:SetPoint("TOPLEFT", 12, y)
            SetBackdrop(card, theme.panel)
            card.chevron:SetText(expanded and "-" or "+")
            card.icon:SetTexture(instanceData.kind == "Raid" and 135975 or 136106)
            card.name:SetText(instanceData.name)
            card.kind:SetText(string.upper(instanceData.kind or "INSTANCE") .. "  |  " ..
                #instanceData.categories .. " collection categories")
            SetProgressBar(card.progress, instanceDone, instanceTotal)
            card:SetScript("OnClick", function()
                ZL_Saved.ui.collapsed[mapID] = ZL_Saved.ui.collapsed[mapID] or {}
                local key = "instance:" .. instanceData.mapID
                ZL_Saved.ui.collapsed[mapID][key] = not ZL_Saved.ui.collapsed[mapID][key]
                self._uiSignature = nil
                self:RefreshUI()
            end)
            card:SetScript("OnEnter", function(row) row:SetBackdropColor(0.06, 0.13, 0.17, 1) end)
            card:SetScript("OnLeave", function(row) row:SetBackdropColor(0.035, 0.065, 0.085, 0.96) end)
            y = y - 58

            if expanded then
                for _, childCategory in ipairs(instanceData.categories or {}) do
                    local childData = childCategory
                    local selectedChild = self.selectedInstanceMapID == instanceData.mapID and
                        self.selectedInstanceCategoryKey == childData.key
                    local searchChild = CategoryMatches(childData)
                    local subrow = self.UI.pools.instanceCategory:Acquire()
                    subrow:Show()
                    if not subrow.zlInitialized then
                        subrow.zlInitialized = true
                        subrow.branch = subrow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                        subrow.branch:SetPoint("LEFT", 10, 0)
                        subrow.icon = subrow:CreateTexture(nil, "ARTWORK")
                        subrow.icon:SetSize(20, 20)
                        subrow.icon:SetPoint("LEFT", 29, 0)
                        subrow.name = subrow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                        subrow.name:SetPoint("LEFT", subrow.icon, "RIGHT", 8, 0)
                        subrow.progress = subrow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                        subrow.progress:SetPoint("RIGHT", -10, 0)
                    end
                    subrow:SetSize(592, 32)
                    subrow:SetPoint("TOPLEFT", 30, y)
                    SetBackdrop(subrow, selectedChild and theme.header or theme.panel)
                    subrow.branch:SetText(selectedChild and "-" or "+")
                    subrow.icon:SetTexture((CATEGORY_META[childData.key] or {}).icon or 134400)
                    subrow.icon:SetDesaturated(not self:IsCategoryEnabled(childData.key))
                    subrow.name:SetText(childData.name .. (searchChild and " |cffffd36b*|r" or ""))
                    subrow.progress:SetText(string.format("%d%%  %d/%d", Percent(childData.done, childData.total),
                        childData.done, childData.total))
                    subrow:SetScript("OnClick", function()
                        if IsAltKeyDown() then
                            self:AddCategoryWaypoint(childData)
                            return
                        end
                        if selectedChild then
                            self.selectedInstanceMapID = nil
                            self.selectedInstanceCategoryKey = nil
                        else
                            self.selectedInstanceMapID = instanceData.mapID
                            self.selectedInstanceCategoryKey = childData.key
                        end
                        self._uiSignature = nil
                        self:RefreshUI()
                    end)
                    y = y - 36

                    if selectedChild or searchChild then
                        local shown = 0
                        local childObjectives = {}
                        for _, objective in ipairs(childData.objectives or {}) do
                            if ObjectiveMatches(objective) then childObjectives[#childObjectives + 1] = objective end
                        end
                        if filters.sort == "name" then
                            table.sort(childObjectives, function(a, b)
                                return (a.name or ""):lower() < (b.name or ""):lower()
                            end)
                        elseif filters.sort == "missing" then
                            table.sort(childObjectives, function(a, b)
                                if a.complete ~= b.complete then return not a.complete end
                                return (a.name or ""):lower() < (b.name or ""):lower()
                            end)
                        end
                        for _, objectiveData in ipairs(childObjectives) do
                                local row = self.UI.pools.objective:Acquire()
                                row:Show()
                                if not row.zlInitialized then
                                    row.zlInitialized = true
                                    row.icon = row:CreateTexture(nil, "ARTWORK")
                                    row.icon:SetSize(22, 22)
                                    row.icon:SetPoint("LEFT", 6, 0)
                                    row.label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                                end
                                row:SetSize(564, 29)
                                row:SetPoint("TOPLEFT", 58, y)
                                SetBackdrop(row, theme.panel)
                                row.icon:Show()
                                row.icon:SetTexture(objectiveData.icon or 134400)
                                row.icon:SetDesaturated(not objectiveData.complete)
                                row.label:ClearAllPoints()
                                row.label:SetPoint("LEFT", row.icon, "RIGHT", 8, 0)
                                row.label:SetPoint("RIGHT", -7, 0)
                                row.label:SetJustifyH("LEFT")
                                row.label:SetWordWrap(false)
                                row.label:SetText((objectiveData.complete and "|cff55ff88OK|r " or "|cffff6666o|r ") .. objectiveData.name)
                                row:SetScript("OnEnter", function(button)
                                    button:SetBackdropColor(0.045, 0.10, 0.13, 0.92)
                                    self:ShowObjectiveTooltip(button, objectiveData, "ANCHOR_RIGHT")
                                end)
                                row:SetScript("OnLeave", function(button)
                                    button:SetBackdropColor(0.018, 0.026, 0.032, 0.58)
                                    GameTooltip_Hide()
                                end)
                                row:SetScript("OnClick", function()
                                    self:HandleObjectiveClick(objectiveData)
                                end)
                                shown = shown + 1
                                y = y - 32
                        end
                        if shown == 0 and selectedChild then
                            local empty = self.UI.pools.objective:Acquire()
                            empty:Show()
                            if not empty.zlInitialized then
                                empty.zlInitialized = true
                                empty.icon = empty:CreateTexture(nil, "ARTWORK")
                                empty.icon:SetSize(22, 22)
                                empty.icon:SetPoint("LEFT", 6, 0)
                                empty.label = empty:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                            end
                            empty:SetSize(564, 29)
                            empty:SetPoint("TOPLEFT", 58, y)
                            SetBackdrop(empty, theme.panel)
                            empty.icon:Hide()
                            empty.label:ClearAllPoints()
                            empty.label:SetPoint("LEFT", 8, 0)
                            empty.label:SetText("No objectives match the current filters.")
                            y = y - 32
                        end
                    end
                end
                y = y - 6
            end
        end

        if #(category.instances or {}) == 0 then
            local empty = self.UI.pools.objective:Acquire()
            empty:Show()
            if not empty.zlInitialized then
                empty.zlInitialized = true
                empty.icon = empty:CreateTexture(nil, "ARTWORK")
                empty.icon:SetSize(22, 22)
                empty.icon:SetPoint("LEFT", 6, 0)
                empty.label = empty:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            end
            empty:SetSize(610, 34)
            empty:SetPoint("TOPLEFT", 12, y)
            SetBackdrop(empty, theme.panel)
            empty.icon:Hide()
            empty.label:ClearAllPoints()
            empty.label:SetPoint("LEFT", 10, 0)
            empty.label:SetText("No dungeon or raid is linked to this zone yet.")
            y = y - 40
        end
        content:SetHeight(math.max(1, -y + 10))
        self:SetAdaptiveHeight(content:GetHeight(), true)
        return
    end

    local filters = ZL_Saved.settings.filters
    local search = (filters.search or ""):lower()
    local objectives = {}
    for _, objective in ipairs(category.objectives or {}) do
        local viewMatches = filters.view == "all" or
            (filters.view == "missing" and not objective.complete) or
            (filters.view == "collected" and objective.complete)
        local searchMatches = search == "" or (objective.name or ""):lower():find(search, 1, true)
        local sourceFilter = filters.transmogSource or "all"
        local sourceMatches = category.key ~= "transmogs" or sourceFilter == "all" or
            objective.transmogSource == sourceFilter
        if viewMatches and searchMatches and sourceMatches then objectives[#objectives + 1] = objective end
    end

    if filters.sort == "name" then
        table.sort(objectives, function(a, b) return (a.name or ""):lower() < (b.name or ""):lower() end)
    elseif filters.sort == "missing" then
        table.sort(objectives, function(a, b)
            if a.complete ~= b.complete then return not a.complete end
            return (a.name or ""):lower() < (b.name or ""):lower()
        end)
    end

    local viewNames = { all = "all", missing = "missing", collected = "collected" }
    heading.results:SetText(string.format("Showing %d of %d %s%s", #objectives, category.total,
        viewNames[filters.view] or "all", search ~= "" and (" matching '" .. search .. "'") or ""))

    if #objectives == 0 then
        local empty = self.UI.pools.objective:Acquire()
        empty:Show()
        if not empty.zlInitialized then
            empty.zlInitialized = true
            empty.icon = empty:CreateTexture(nil, "ARTWORK")
            empty.icon:SetSize(22, 22)
            empty.icon:SetPoint("LEFT")
            empty.label = empty:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        end
        empty:SetSize(610, 32)
        empty:SetPoint("TOPLEFT", 12, y)
        SetBackdrop(empty, theme.panel)
        empty.icon:Hide()
        empty.label:ClearAllPoints()
        empty.label:SetPoint("LEFT", 10, 0)
        empty.label:SetTextColor(0.5, 0.5, 0.5)
        empty.label:SetText(category.total == 0 and "No objectives in this category." or "No objectives match the current filters.")
        y = y - 36
    end

    for _, objective in ipairs(objectives) do
        local objectiveData = objective
        local row = self.UI.pools.objective:Acquire()
        row:Show()
        if not row.zlInitialized then
            row.zlInitialized = true
            row.icon = row:CreateTexture(nil, "ARTWORK")
            row.icon:SetSize(22, 22)
            row.icon:SetPoint("LEFT", 6, 0)
            row.label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        end
        row:SetSize(610, 29)
        row:SetPoint("TOPLEFT", 12, y)
        SetBackdrop(row, theme.panel)
        row.icon:Show()
        row.icon:SetTexture(objectiveData.icon or 134400)
        row.icon:SetDesaturated(not objectiveData.complete)
        row.label:ClearAllPoints()
        row.label:SetPoint("LEFT", row.icon, "RIGHT", 8, 0)
        row.label:SetPoint("RIGHT", -7, 0)
        row.label:SetJustifyH("LEFT")
        row.label:SetWordWrap(false)
        row.label:SetTextColor(1, 1, 1)
        row.label:SetText((objectiveData.complete and "|cff55ff88✓|r " or "|cffff6666○|r ") .. objectiveData.name)
        row:SetScript("OnEnter", function(button)
            button:SetBackdropColor(0.045, 0.10, 0.13, 0.92)
            self:ShowObjectiveTooltip(button, objectiveData, "ANCHOR_RIGHT")
        end)
        row:SetScript("OnLeave", function(button)
            button:SetBackdropColor(0.018, 0.026, 0.032, 0.58)
            GameTooltip_Hide()
        end)
        row:SetScript("OnClick", function()
            self:HandleObjectiveClick(objectiveData)
        end)
        y = y - 32
    end

    content:SetHeight(math.max(1, -y + 10))
    self:SetAdaptiveHeight(content:GetHeight(), true)
end

local function GetUISignature(self)
    local parts = { tostring(self.showHistory and "history" or self.selectedMapID or "route"),
        tostring(self.selectedCategoryKey or "category"), tostring(#(ZL_Saved.route or {})),
        tostring(#(ZL_Saved.history or {})), tostring(#(ZL_Saved.journeys or {})) }
    local filters = ZL_Saved.settings.filters
    parts[#parts + 1] = table.concat({ filters.view or "all", filters.sort or "default", filters.search or "",
        filters.transmogSource or "all" }, ":")
    local historySettings = ZL_Saved.settings.history or {}
    parts[#parts + 1] = table.concat({ "history", historySettings.filter or "all", historySettings.search or "",
        tostring(historySettings.enabled ~= false), tostring(ZL_Saved.ui.historyCollapsed.recent and true or false),
        tostring(ZL_Saved.ui.historyCollapsed.journeys and true or false) }, ":")
    for _, journey in ipairs(ZL_Saved.journeys or {}) do
        if type(journey) == "table" then
            parts[#parts + 1] = table.concat({ "journey", journey.name or "", tostring(journey.expanded and true or false) }, ":")
        end
    end
    for _, key in ipairs({ "achievements", "pets", "decor", "instances", "mounts", "quests", "rares", "transmogs" }) do
        parts[#parts + 1] = key .. "=" .. tostring(self:IsCategoryEnabled(key))
    end
    if self.selectedMapID and ZL_Saved.ui.collapsed[self.selectedMapID] then
        local collapsed = ZL_Saved.ui.collapsed[self.selectedMapID]
        for _, key in ipairs({ "achievements", "pets", "decor", "instances", "mounts", "quests", "rares", "transmogs" }) do
            parts[#parts + 1] = "c:" .. key .. "=" .. tostring(collapsed[key] and true or false)
        end
    end
    for _, mapID in ipairs(ZL_Saved.route or {}) do
        local data = self.ZoneData[mapID]
        local summary = ZL_Saved.zoneSummaries[mapID]
        parts[#parts + 1] = tostring(mapID)
        if data then
            parts[#parts + 1] = tostring(data.provider)
            parts[#parts + 1] = tostring(data.done)
            parts[#parts + 1] = tostring(data.total)
            for _, category in ipairs(data.categories or {}) do
                parts[#parts + 1] = category.key .. ":" .. category.done .. "/" .. category.total
            end
        elseif summary then
            parts[#parts + 1] = "summary:" .. tostring(summary.done) .. "/" .. tostring(summary.total)
        end
    end
    return table.concat(parts, "|")
end

function ZL:RefreshUI()
    if self.RefreshMiniList then self:RefreshMiniList() end
    if self.RefreshWorldMapOverlay then self:RefreshWorldMapOverlay() end
    if not self.UI or not self.UI.content or not self.UI:IsShown() then return end
    self:UpdateToolbar()
    local signature = GetUISignature(self)
    if signature == self._uiSignature then return end
    ClearContent(self.UI.content)
    if self.showHistory then
        local ok, message = pcall(self.BuildHistoryView, self)
        if not ok then
            local content = self.UI.content
            if not content.historyRenderError then
                content.historyRenderError = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
                content.historyRenderError:SetPoint("TOP", 0, -38)
                content.historyRenderError:SetWidth(760)
                content.historyRenderError:SetJustifyH("CENTER")
            end
            content.historyRenderError:SetText("History could not be rendered.\n|cffff6666" .. tostring(message) .. "|r")
            content.historyRenderError:Show()
            content:SetHeight(120)
            self:SetAdaptiveHeight(120, false)
            self:Print("History renderer error: " .. tostring(message))
        end
    elseif self.selectedMapID then
        self:BuildDetailsView(self.selectedMapID)
    else
        self:BuildRouteView()
    end
    self._uiSignature = signature
end

function ZL:CreateLockOverlay()
    self.LockOverlay = CreateFrame("Frame", "ZoneLockedBlackout", UIParent, "BackdropTemplate")
    self.LockOverlay:SetAllPoints(UIParent)
    self.LockOverlay:SetFrameStrata("FULLSCREEN_DIALOG")
    self.LockOverlay:SetFrameLevel(10000)
    -- The lock is a visual rule, not an input trap. Mouse and keyboard input
    -- continue to the world/UI so the player can turn around and leave safely.
    self.LockOverlay:EnableMouse(false)
    self.LockOverlay:SetBackdrop({ bgFile = "Interface/Buttons/WHITE8X8" })
    self.LockOverlay:SetBackdropColor(0, 0, 0, 0.80)
    local title = self.LockOverlay:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
    title:SetPoint("CENTER", 0, 35); title:SetText("|cffff5555ZONE LOCKED|r")
    self.LockOverlay.message = self.LockOverlay:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    self.LockOverlay.message:SetPoint("TOP", title, "BOTTOM", 0, -16)
    local hint = self.LockOverlay:CreateFontString(nil, "OVERLAY", "GameFontDisable")
    hint:SetPoint("TOP", self.LockOverlay.message, "BOTTOM", 0, -14)
    hint:SetText("Controls remain active. Return to the active zone, or type /zl blackout.")
    self.LockOverlay:Hide()
end

function ZL:CheckZoneState()
    if not self.LockOverlay then return end
    local currentMapID = self:GetCurrentZoneID()
    local contextMapID = currentMapID and self:GetRouteContextZone(currentMapID)
    local activeMapID = self:GetActiveZone()
    local transition = ZL_Saved.transition
    local currentRoutePosition = contextMapID and self:GetRoutePosition(contextMapID)
    local unlockedIndex = self:GetUnlockedIndex()

    if transition and contextMapID == transition.to then
        ZL_Saved.transition = nil
        transition = nil
    end

    -- Every cleared route entry remains permanently revisit-able. Positions
    -- before the current unlocked entry can only exist after being completed.
    local completedRouteZone = currentRoutePosition and currentRoutePosition < unlockedIndex
    local expansionKey = currentMapID and self:GetMapExpansionKey(currentMapID)
    local outsideEnabledPacks = expansionKey and not self:IsExpansionEnabled(expansionKey)
    local allowed = not activeMapID or outsideEnabledPacks or self:IsHubMap(currentMapID) or contextMapID == activeMapID or completedRouteZone or
        (transition and contextMapID == transition.from)
    if ZL_Saved.settings.blackout and currentMapID and not allowed then
        local activeName = self:GetZoneDefinition(activeMapID).name
        self.LockOverlay.message:SetText("Return to " .. activeName .. " to continue your Zone Locked run.")
        self.LockOverlay:Show()
    else
        self.LockOverlay:Hide()
    end
end

function ZL:CreateCollectionAlert()
    self.CollectionAlert = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    local saved = ZL_Saved.ui.alert
    self.CollectionAlert:SetSize(360, 74)
    self.CollectionAlert:SetPoint(saved.point or "TOP", UIParent, saved.relativePoint or "TOP", saved.x or 0, saved.y or -170)
    self.CollectionAlert:SetFrameStrata("DIALOG")
    self.CollectionAlert:SetClampedToScreen(true)
    self.CollectionAlert:SetMovable(true)
    self.CollectionAlert:EnableMouse(true)
    self.CollectionAlert:RegisterForDrag("LeftButton")
    self.CollectionAlert:SetScript("OnDragStart", self.CollectionAlert.StartMoving)
    self.CollectionAlert:SetScript("OnDragStop", function(alert)
        alert:StopMovingOrSizing()
        local point, _, relativePoint, x, y = alert:GetPoint(1)
        ZL_Saved.ui.alert = { point = point, relativePoint = relativePoint, x = x, y = y }
    end)
    self.CollectionAlert:SetScript("OnEnter", function(alert)
        GameTooltip:SetOwner(alert, "ANCHOR_BOTTOM")
        GameTooltip:SetText("Drag to move this alert")
        GameTooltip:AddLine("Its position is saved between sessions.", 1, 1, 1)
        GameTooltip:Show()
    end)
    self.CollectionAlert:SetScript("OnLeave", GameTooltip_Hide)
    SetBackdrop(self.CollectionAlert, { 0.04, 0.035, 0.02, 0.96 })
    self.CollectionAlert.icon = self.CollectionAlert:CreateTexture(nil, "ARTWORK")
    self.CollectionAlert.icon:SetSize(48, 48); self.CollectionAlert.icon:SetPoint("LEFT", 13, 0)
    self.CollectionAlert.title = self.CollectionAlert:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    self.CollectionAlert.title:SetPoint("TOPLEFT", 74, -14); self.CollectionAlert.title:SetText("Zone Locked Collection")
    self.CollectionAlert.text = self.CollectionAlert:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    self.CollectionAlert.text:SetPoint("TOPLEFT", self.CollectionAlert.title, "BOTTOMLEFT", 0, -7)
    self.CollectionAlert:Hide()
end

function ZL:ShowCollectionAlert(name, itemID, fallbackIcon, force)
    if not self.CollectionAlert or (not force and not ZL_Saved.settings.alerts) then return end
    self.collectionAlertQueue = self.collectionAlertQueue or {}
    self.collectionAlertQueue[#self.collectionAlertQueue + 1] = {
        name = name or "New collection entry", itemID = itemID, icon = fallbackIcon,
    }
    if self.collectionAlertActive then return end
    self:ShowNextCollectionAlert()
end

function ZL:ShowNextCollectionAlert()
    local entry = self.collectionAlertQueue and table.remove(self.collectionAlertQueue, 1)
    if not entry then self.collectionAlertActive = false; return end
    self.collectionAlertActive = true
    self.collectionAlertSerial = (self.collectionAlertSerial or 0) + 1
    local serial = self.collectionAlertSerial
    local icon = entry.itemID and select(10, C_Item.GetItemInfo(entry.itemID)) or entry.icon
    self.CollectionAlert.icon:SetTexture(icon or 134400)
    self.CollectionAlert.text:SetText(entry.name)
    self.CollectionAlert:Show()
    self.CollectionAlert:SetAlpha(1)
    if SOUNDKIT and SOUNDKIT.UI_ACHIEVEMENT_TOAST then PlaySound(SOUNDKIT.UI_ACHIEVEMENT_TOAST) end
    C_Timer.After(3.5, function()
        if self.CollectionAlert and serial == self.collectionAlertSerial then
            UIFrameFadeOut(self.CollectionAlert, 0.4, 1, 0)
            C_Timer.After(0.45, function()
                if self.CollectionAlert and serial == self.collectionAlertSerial then
                    GameTooltip_Hide()
                    self.CollectionAlert:Hide()
                    self.collectionAlertActive = false
                    self:ShowNextCollectionAlert()
                end
            end)
        end
    end)
end
