local addonName, SinsToolkit = ...

local Hub = {}
SinsToolkit.TeleportHub = Hub

local CATEGORY_ORDER = {
    "current",
    "legacy",
    "raids",
    "class",
    "hearth",
    "toys",
}

local CATEGORY_NAMES = {
    current = "M+ Current Season",
    legacy = "M+ Legacy",
    raids = "Raids",
    class = "Class Teleports",
    hearth = "Hearthstone",
    toys = "Toys",
}

local TELEPORTS = {
    current = {
        { spellID = 1254572, label = "Magisters' Terrace" },
        { spellID = 1254559, label = "Maisara Caverns" },
        { spellID = 1254563, label = "Nexus-Point Xenas" },
        { spellID = 1254400, label = "Windrunner Spire" },
        { spellID = 393273, label = "Algeth'ar Academy" },
        { spellID = 1254555, label = "Pit of Saron" },
        { spellID = 159898, label = "Skyreach" },
        { spellID = 1254551, label = "Seat of the Triumvirate" },
    },
    legacy = {
        { spellID = 410080, label = "The Vortex Pinnacle" },
        { spellID = 424142, label = "Throne of the Tides" },
        { spellID = 445424, label = "Grim Batol" },
        { spellID = 131204, label = "Temple of the Jade Serpent" },
        { spellID = 131228, label = "Siege of Niuzao Temple" },
        { spellID = 131232, label = "Scholomance" },
        { spellID = 131229, label = "Scarlet Monastery" },
        { spellID = 131231, label = "Scarlet Halls" },
        { spellID = 131225, label = "Gate of the Setting Sun" },
        { spellID = 131222, label = "Mogu'shan Palace" },
        { spellID = 131206, label = "Shado-Pan Monastery" },
        { spellID = 131205, label = "Stormstout Brewery" },
        { spellID = 159899, label = "Shadowmoon Burial Grounds" },
        { spellID = 159901, label = "Everbloom" },
        { spellID = 159895, label = "Bloodmaul Slag Mines" },
        { spellID = 159897, label = "Auchindoun" },
        { spellID = 159902, label = "Upper Blackrock Spire" },
        { spellID = 159900, label = "Grimrail Depot" },
        { spellID = 159896, label = "Iron Docks" },
        { spellID = 424163, label = "Darkheart Thicket" },
        { spellID = 424153, label = "Black Rook Hold" },
        { spellID = 393764, label = "Halls of Valor" },
        { spellID = 410078, label = "Neltharion's Lair" },
        { spellID = 393766, label = "Court of Stars" },
        { spellID = 373262, label = "Karazhan" },
        { spellID = 424187, label = "Atal'Dazar" },
        { spellID = 410071, label = "Freehold" },
        { spellID = 424167, label = "Waycrest Manor" },
        { spellID = 410074, label = "The Underrot" },
        { spellID = 373274, label = "Operation: Mechagon" },
        { spellID = 354462, label = "The Necrotic Wake" },
        { spellID = 354463, label = "Plaguefall" },
        { spellID = 354464, label = "Mists of Tirna Scithe" },
        { spellID = 354465, label = "Halls of Atonement" },
        { spellID = 354466, label = "Spires of Ascension" },
        { spellID = 354467, label = "Theater of Pain" },
        { spellID = 354468, label = "De Other Side" },
        { spellID = 354469, label = "Sanguine Depths" },
        { spellID = 367416, label = "Tazavesh, the Veiled Market" },
        { spellID = 393256, label = "Ruby Life Pools" },
        { spellID = 393262, label = "The Nokhud Offensive" },
        { spellID = 393279, label = "The Azure Vault" },
        { spellID = 393276, label = "Neltharus" },
        { spellID = 393267, label = "Brackenhide Hollow" },
        { spellID = 393283, label = "Halls of Infusion" },
        { spellID = 393222, label = "Uldaman: Legacy of Tyr" },
        { spellID = 424197, label = "Dawn of the Infinite" },
        { spellID = 445416, label = "City of Threads" },
        { spellID = 445417, label = "Ara-Kara, City of Echoes" },
        { spellID = 445269, label = "The Stonevault" },
        { spellID = 445414, label = "The Dawnbreaker" },
        { spellID = 445443, label = "The Rookery" },
        { spellID = 445441, label = "Darkflame Cleft" },
        { spellID = 445440, label = "Cinderbrew Brewery" },
        { spellID = 445444, label = "Priory of the Sacred Flame" },
        { spellID = 1216786, label = "Operation: Floodgate" },
        { spellID = 1237215, label = "Eco-Dome Al'dani" },
    },
    raids = {
        { spellID = 373190, label = "Castle Nathria" },
        { spellID = 373191, label = "Sanctum of Domination" },
        { spellID = 373192, label = "Sepulcher of the First Ones" },
        { spellID = 432254, label = "Vault of the Incarnates" },
        { spellID = 432257, label = "Aberrus, the Shadowed Crucible" },
        { spellID = 432258, label = "Amirdrassil, the Dream's Hope" },
        { spellID = 1226482, label = "Liberation of Undermine" },
        { spellID = 1239155, label = "Manaforge Omega" },
    },
    class = {
        { spellID = 3561, class = "MAGE" }, { spellID = 3562, class = "MAGE" },
        { spellID = 3563, class = "MAGE" }, { spellID = 3566, class = "MAGE" },
        { spellID = 3567, class = "MAGE" }, { spellID = 32271, class = "MAGE" },
        { spellID = 32272, class = "MAGE" }, { spellID = 49359, class = "MAGE" },
        { spellID = 49358, class = "MAGE" }, { spellID = 33690, class = "MAGE" },
        { spellID = 53140, class = "MAGE" }, { spellID = 88342, class = "MAGE" },
        { spellID = 88344, class = "MAGE" }, { spellID = 132621, class = "MAGE" },
        { spellID = 132627, class = "MAGE" }, { spellID = 176248, class = "MAGE" },
        { spellID = 176242, class = "MAGE" }, { spellID = 281403, class = "MAGE" },
        { spellID = 281404, class = "MAGE" }, { spellID = 344587, class = "MAGE" },
        { spellID = 1259190, class = "MAGE" },
        { spellID = 10059, class = "MAGE" }, { spellID = 11416, class = "MAGE" },
        { spellID = 11418, class = "MAGE" }, { spellID = 11420, class = "MAGE" },
        { spellID = 11417, class = "MAGE" }, { spellID = 32266, class = "MAGE" },
        { spellID = 32267, class = "MAGE" }, { spellID = 49360, class = "MAGE" },
        { spellID = 49361, class = "MAGE" }, { spellID = 33691, class = "MAGE" },
        { spellID = 53142, class = "MAGE" }, { spellID = 88345, class = "MAGE" },
        { spellID = 88346, class = "MAGE" }, { spellID = 132620, class = "MAGE" },
        { spellID = 132626, class = "MAGE" }, { spellID = 176246, class = "MAGE" },
        { spellID = 176244, class = "MAGE" }, { spellID = 224871, class = "MAGE" },
        { spellID = 281400, class = "MAGE" }, { spellID = 281402, class = "MAGE" },
        { spellID = 344597, class = "MAGE" }, { spellID = 1259194, class = "MAGE" },
        { spellID = 18960, class = "DRUID" }, { spellID = 193753, class = "DRUID" },
        { spellID = 126892, class = "MONK" }, { spellID = 50977, class = "DEATHKNIGHT" },
    },
    hearth = {
        { itemID = 6948, label = "Hearthstone", alwaysKnown = true },
    },
    toys = {
        { itemID = 110560, label = "Garrison Hearthstone" },
        { itemID = 64488, label = "The Innkeeper's Daughter" },
        { itemID = 93672, label = "Dark Portal" },
        { itemID = 54452, label = "Ethereal Portal" },
        { itemID = 162973, label = "Greatfather Winter's Hearthstone" },
        { itemID = 163045, label = "Headless Horseman's Hearthstone" },
        { itemID = 165669, label = "Lunar Elder's Hearthstone" },
        { itemID = 165670, label = "Peddlefeet's Lovely Hearthstone" },
        { itemID = 165802, label = "Noble Gardener's Hearthstone" },
        { itemID = 166746, label = "Fire Eater's Hearthstone" },
        { itemID = 166747, label = "Brewfest Reveler's Hearthstone" },
        { itemID = 168907, label = "Holographic Digitalization Hearthstone" },
        { itemID = 172179, label = "Eternal Traveler's Hearthstone" },
        { itemID = 180290, label = "Night Fae Hearthstone" },
        { itemID = 182773, label = "Necrolord Hearthstone" },
        { itemID = 183716, label = "Venthyr Sinstone" },
        { itemID = 184353, label = "Kyrian Hearthstone" },
        { itemID = 188952, label = "Dominated Hearthstone" },
        { itemID = 190196, label = "Enlightened Hearthstone" },
        { itemID = 190237, label = "Broker's Translocation Matrix" },
        { itemID = 193588, label = "Timewalker's Hearthstone" },
        { itemID = 200630, label = "Ohn'ir Windsage's Hearthstone" },
        { itemID = 206195, label = "Path of the Naaru" },
        { itemID = 208704, label = "Deepdweller's Earthen Hearthstone" },
        { itemID = 209035, label = "Hearthstone of the Flame" },
        { itemID = 210455, label = "Draenic Hologem" },
        { itemID = 212337, label = "Stone of the Hearth" },
        { itemID = 212766, label = "Notorious Thread's Hearthstone" },
        { itemID = 228940, label = "Postmaster's Hearthstone" },
        { itemID = 232517, label = "Explosive Hearthstone" },
        { itemID = 245970, label = "P.O.S.T. Master's Express Hearthstone" },
        { itemID = 246565, label = "Cosmic Hearthstone" },
        { itemID = 257736, label = "Lightcalled Hearthstone" },
        { itemID = 265100, label = "Corewarden's Hearthstone" },
        { itemID = 140192, label = "Dalaran Hearthstone" },
        { itemID = 142542, label = "Tome of Town Portal" },
        { itemID = 168807, label = "Wormhole Generator: Kul Tiras" },
        { itemID = 168808, label = "Wormhole Generator: Zandalar" },
        { itemID = 172924, label = "Wormhole Generator: Shadowlands" },
        { itemID = 198156, label = "Wyrmhole Generator: Dragon Isles" },
        { itemID = 151652, label = "Wormhole Generator: Argus" },
        { itemID = 48933, label = "Wormhole Generator: Northrend" },
        { itemID = 87215, label = "Wormhole Generator: Pandaria" },
        { itemID = 112059, label = "Wormhole Centrifuge" },
    },
}

local panel, scrollFrame, scrollChild, searchBox, viewButton, minimapButton
local buttons = {}
local headers = {}
local searchText = ""
local initialized = false
local BUTTON_SIZE = 40
local CELL_WIDTH = 104
local CELL_HEIGHT = 78
local PANEL_MIN_WIDTH = 540
local PANEL_MIN_HEIGHT = 380
local LIST_ROW_HEIGHT = 36
local POOL_SIZE = 180

local function Print(message)
    print("|cff9b59b6[Sin's Toolkit QOL]|r " .. message)
end

local function IsEnabled()
    return SinsToolkit.IsFeatureActive and SinsToolkit.IsFeatureActive("teleportHubEnabled")
end

local function IsListView()
    return SinsToolkitDB and SinsToolkitDB.teleportHubView == "list"
end

local function GetSpellName(spellID)
    return C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(spellID) or GetSpellInfo and GetSpellInfo(spellID)
end

local function GetSpellIcon(spellID)
    return C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(spellID) or select(3, GetSpellInfo(spellID))
end

local function GetSecureSpell(entry)
    if entry.secureSpell then return entry.secureSpell end
    entry.secureSpell = entry.spellID and GetSpellName(entry.spellID) or nil
    return entry.secureSpell
end

local function IsSpellKnown(spellID)
    if C_SpellBook and C_SpellBook.IsSpellKnown then
        return C_SpellBook.IsSpellKnown(spellID)
    end
    return IsSpellKnown and IsSpellKnown(spellID)
end

local function GetToyNameIcon(itemID)
    if C_ToyBox and C_ToyBox.GetToyInfo then
        local _, name, icon = C_ToyBox.GetToyInfo(itemID)
        if name then return name, icon end
    end
    local name = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(itemID)
    local icon = C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(itemID)
    return name, icon
end

local function IsToyKnown(itemID)
    return PlayerHasToy and PlayerHasToy(itemID)
end

local function EntryName(entry)
    if entry.category == "hearth" then
        return GetBindLocation and GetBindLocation() or entry.label or "Hearthstone"
    end
    if entry.itemID and not entry.alwaysKnown then
        local name = GetToyNameIcon(entry.itemID)
        return name or entry.label or ("Item " .. entry.itemID)
    end
    return (SinsToolkitDB and SinsToolkitDB.teleportHubShowDungeonNames and entry.label)
        or (entry.spellID and GetSpellName(entry.spellID))
        or entry.label
        or "Unknown"
end

local function EntryIcon(entry)
    if entry.itemID then
        if entry.alwaysKnown and C_Item and C_Item.GetItemIconByID then
            return C_Item.GetItemIconByID(entry.itemID)
        end
        local _, icon = GetToyNameIcon(entry.itemID)
        return icon or (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(entry.itemID))
    end
    return entry.spellID and GetSpellIcon(entry.spellID)
end

local function IsKnown(entry)
    if entry.alwaysKnown then return true end
    if entry.itemID then return IsToyKnown(entry.itemID) end
    return entry.spellID and IsSpellKnown(entry.spellID)
end

local function EntryMatches(entry)
    if searchText == "" then return true end
    local haystack = ((EntryName(entry) or "") .. " " .. tostring(entry.spellID or entry.itemID or "")):lower()
    return haystack:find(searchText, 1, true) ~= nil
end

local function CanShowEntry(entry, classToken)
    if entry.class and entry.class ~= classToken then return false end
    if not EntryMatches(entry) then return false end
    return (SinsToolkitDB and SinsToolkitDB.teleportHubShowLocked) or IsKnown(entry)
end

local function ClearButton(button)
    if InCombatLockdown and InCombatLockdown() then return end
    button:SetAttribute("type", nil)
    button:SetAttribute("spell", nil)
    button:SetAttribute("item", nil)
    button:SetAttribute("toy", nil)
    button:SetAttribute("macrotext", nil)
end

local function AssignButton(button, entry)
    if InCombatLockdown and InCombatLockdown() then return end
    ClearButton(button)
    if not IsKnown(entry) then return end
    if entry.itemID and entry.alwaysKnown then
        local name = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(entry.itemID)
        button:SetAttribute("type", "item")
        button:SetAttribute("item", name or entry.itemID)
    elseif entry.itemID then
        button:SetAttribute("type", "toy")
        button:SetAttribute("toy", entry.itemID)
    elseif entry.spellID then
        button:SetAttribute("type", "spell")
        button:SetAttribute("spell", entry.spellID)
    end
end

local function GetButton(index)
    if buttons[index] then return buttons[index] end
    local button = CreateFrame("Button", addonName .. "TeleportHubButton" .. index, nil, "SecureActionButtonTemplate")
    button:RegisterForClicks("AnyUp", "AnyDown")
    button:SetSize(BUTTON_SIZE, BUTTON_SIZE)
    button.icon = button:CreateTexture(nil, "BACKGROUND")
    button.icon:SetAllPoints(button)
    button.label = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    button.label:SetPoint("TOP", button, "BOTTOM", 0, -2)
    button.label:SetWidth(CELL_WIDTH)
    button.label:SetJustifyH("CENTER")
    button.label:SetMaxLines(2)
    button:SetScript("OnEnter", function(self)
        if not self.entry or not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        if self.entry.itemID and not self.entry.alwaysKnown and GameTooltip.SetToyByItemID then
            GameTooltip:SetToyByItemID(self.entry.itemID)
        elseif self.entry.itemID then
            GameTooltip:SetItemByID(self.entry.itemID)
        elseif self.entry.spellID then
            GameTooltip:SetSpellByID(self.entry.spellID)
        end
        if not IsKnown(self.entry) then
            GameTooltip:AddLine("Not learned or not owned.", 1, 0.25, 0.25)
        end
        GameTooltip:Show()
    end)
    button:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
    buttons[index] = button
    return button
end

local function InitButtonPool()
    for i = 1, POOL_SIZE do
        GetButton(i)
    end
end

local function LayoutButton(button, entry, x, y, width)
    button:SetParent(scrollChild)
    button:ClearAllPoints()
    button.icon:ClearAllPoints()
    button.label:ClearAllPoints()

    if IsListView() then
        button:SetSize(BUTTON_SIZE, BUTTON_SIZE)
        button:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", x, y)
        button.icon:SetAllPoints(button)
        button.label:SetPoint("LEFT", button.icon, "RIGHT", 8, 0)
        button.label:SetWidth(width - BUTTON_SIZE - 12)
        button.label:SetJustifyH("LEFT")
        button.label:SetMaxLines(1)
    else
        button:SetSize(BUTTON_SIZE, BUTTON_SIZE)
        button:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", x, y)
        button.icon:SetAllPoints(button)
        button.label:SetPoint("TOP", button, "BOTTOM", 0, -2)
        button.label:SetJustifyH("CENTER")
        button.label:SetMaxLines(2)
        button.label:SetWidth(CELL_WIDTH)
    end

    button.icon:SetTexture(EntryIcon(entry) or "Interface\\Icons\\INV_Misc_QuestionMark")
    button:SetNormalTexture(EntryIcon(entry) or "Interface\\Icons\\INV_Misc_QuestionMark")
    button.icon:SetDesaturated(not IsKnown(entry))
    button.label:SetText(EntryName(entry))
    button.label:SetTextColor(IsKnown(entry) and 1 or 0.55, IsKnown(entry) and 0.82 or 0.55, IsKnown(entry) and 0.35 or 0.55)
end

local function GetHeader(index)
    if headers[index] then return headers[index] end
    local header = scrollChild:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    header:SetTextColor(0.85, 0.66, 0.30)
    headers[index] = header
    return header
end

local function CreatePanel()
    panel = CreateFrame("Frame", addonName .. "TeleportHubPanel", UIParent, "BackdropTemplate")
    local window = SinsToolkitDB and SinsToolkitDB.teleportHubWindow or {}
    panel:SetSize(window.width or 760, window.height or 620)
    panel:SetPoint(window.point or "CENTER", UIParent, window.relativePoint or "CENTER", window.x or 0, window.y or 0)
    if panel.SetResizeBounds then
        panel:SetResizeBounds(PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT, 980, 780)
    else
        panel:SetMinResize(PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT)
        panel:SetMaxResize(980, 780)
    end
    panel:SetFrameStrata("DIALOG")
    panel:SetClampedToScreen(true)
    panel:EnableMouse(true)
    panel:SetMovable(true)
    panel:SetResizable(true)
    panel:RegisterForDrag("LeftButton")
    panel:SetScript("OnDragStart", panel.StartMoving)
    panel:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        Hub:SaveWindow()
    end)
    panel:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    panel:SetBackdropColor(0.02, 0.015, 0.025, 0.94)
    panel:SetBackdropBorderColor(0.46, 0.28, 0.58, 0.9)
    panel:Hide()
    panel:SetScript("OnSizeChanged", function()
        if scrollChild and scrollFrame then
            scrollChild:SetWidth(math.max(1, scrollFrame:GetWidth()))
        end
        if panel:IsShown() then Hub:Render() end
    end)

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", 16, -14)
    title:SetText("|cffb868dcSin's Toolkit QOL|r |cffe8b4ecTeleport Hub|r")

    local close = CreateFrame("Button", nil, panel, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", -4, -4)

    searchBox = CreateFrame("EditBox", nil, panel, "InputBoxTemplate")
    searchBox:SetSize(300, 24)
    searchBox:SetPoint("TOPRIGHT", -42, -17)
    searchBox:SetAutoFocus(false)
    searchBox:SetText("")
    searchBox:SetScript("OnTextChanged", function(self)
        searchText = (self:GetText() or ""):lower()
        Hub:Render()
    end)

    viewButton = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    viewButton:SetSize(62, 24)
    viewButton:SetPoint("RIGHT", searchBox, "LEFT", -12, 0)
    viewButton:SetScript("OnClick", function()
        if not SinsToolkitDB then return end
        SinsToolkitDB.teleportHubView = IsListView() and "grid" or "list"
        Hub:Render()
    end)

    scrollFrame = CreateFrame("ScrollFrame", nil, panel, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", 16, -52)
    scrollFrame:SetPoint("BOTTOMRIGHT", -30, 16)
    scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetSize(math.max(1, scrollFrame:GetWidth()), 1)
    scrollFrame:SetScrollChild(scrollChild)

    local resize = CreateFrame("Button", nil, panel)
    resize:SetSize(16, 16)
    resize:SetPoint("BOTTOMRIGHT", -4, 4)
    resize:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    resize:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    resize:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    resize:SetScript("OnMouseDown", function()
        panel:StartSizing("BOTTOMRIGHT")
    end)
    resize:SetScript("OnMouseUp", function()
        panel:StopMovingOrSizing()
        Hub:SaveWindow()
        Hub:Render()
    end)
end

function Hub:SaveWindow()
    if not panel or not SinsToolkitDB then return end
    SinsToolkitDB.teleportHubWindow = SinsToolkitDB.teleportHubWindow or {}
    local point, _, relativePoint, x, y = panel:GetPoint(1)
    SinsToolkitDB.teleportHubWindow.width = panel:GetWidth()
    SinsToolkitDB.teleportHubWindow.height = panel:GetHeight()
    SinsToolkitDB.teleportHubWindow.point = point or "CENTER"
    SinsToolkitDB.teleportHubWindow.relativePoint = relativePoint or point or "CENTER"
    SinsToolkitDB.teleportHubWindow.x = x or 0
    SinsToolkitDB.teleportHubWindow.y = y or 0
end

function Hub:Render()
    if not panel or not scrollChild or InCombatLockdown() then return end
    if viewButton then viewButton:SetText(IsListView() and "Grid" or "List") end
    for _, button in ipairs(buttons) do
        button:Hide()
        button:SetParent(nil)
        button.entry = nil
        ClearButton(button)
    end
    for _, header in ipairs(headers) do
        header:Hide()
    end

    local _, classToken = UnitClass("player")
    local x, y = 10, -8
    local column = 0
    local index = 1
    local headerIndex = 1
    local width = math.max(1, scrollFrame:GetWidth() - 18)
    scrollChild:SetWidth(width)
    local columns = math.max(1, math.floor(width / CELL_WIDTH))
    local listView = IsListView()

    for _, category in ipairs(CATEGORY_ORDER) do
        local entries = TELEPORTS[category]
        local shown = {}
        for _, entry in ipairs(entries or {}) do
            entry.category = category
            if CanShowEntry(entry, classToken) then shown[#shown + 1] = entry end
        end

        if #shown > 0 then
            local header = GetHeader(headerIndex)
            headerIndex = headerIndex + 1
            header:ClearAllPoints()
            header:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 4, y)
            header:SetText((CATEGORY_NAMES[category] or category) .. " (" .. #shown .. ")")
            header:Show()
            y = y - 28
            column = 0

            for _, entry in ipairs(shown) do
                local button = GetButton(index)
                index = index + 1
                button.entry = entry
                AssignButton(button, entry)
                if listView then
                    LayoutButton(button, entry, x + 4, y, width - 20)
                    y = y - LIST_ROW_HEIGHT
                else
                    LayoutButton(button, entry, x + (column * CELL_WIDTH) + ((CELL_WIDTH - BUTTON_SIZE) / 2), y, CELL_WIDTH)
                    column = column + 1
                end
                button:Show()
                if not listView and column >= columns then
                    column = 0
                    y = y - CELL_HEIGHT
                end
            end
            if not listView and column ~= 0 then y = y - CELL_HEIGHT end
            y = y - 10
        end
    end

    scrollChild:SetHeight(math.max(1, -y + 24))
end

function Hub:Open()
    if not IsEnabled() then
        Print("Teleport Hub is disabled.")
        return
    end
    if InCombatLockdown and InCombatLockdown() then
        Print("Teleport Hub cannot open in combat.")
        return
    end
    if not initialized then self:Init() end
    panel:Show()
    self:Render()
end

function Hub:Toggle()
    if not panel or not panel:IsShown() then
        self:Open()
    elseif not InCombatLockdown() then
        panel:Hide()
    end
end

function Hub:Scan()
    local known, total = 0, 0
    local _, classToken = UnitClass("player")
    for _, category in ipairs(CATEGORY_ORDER) do
        for _, entry in ipairs(TELEPORTS[category] or {}) do
            if not entry.class or entry.class == classToken then
                total = total + 1
                if IsKnown(entry) then known = known + 1 end
            end
        end
    end
    Print(("Teleport Hub found %d usable entries out of %d tracked entries for this character."):format(known, total))
end

local function UpdateMinimapButton()
    if not minimapButton then return end
    if SinsToolkitDB and SinsToolkitDB.teleportHubEnabled and SinsToolkitDB.teleportHubMinimap then
        minimapButton:Show()
    else
        minimapButton:Hide()
    end
end

local function CreateMinimapButton()
    minimapButton = CreateFrame("Button", addonName .. "TeleportHubMinimapButton", Minimap)
    minimapButton:SetSize(31, 31)
    minimapButton:SetFrameStrata("MEDIUM")
    minimapButton:SetPoint("TOPLEFT", Minimap, "TOPLEFT", 0, 0)
    minimapButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    minimapButton:RegisterForDrag("LeftButton")
    minimapButton.icon = minimapButton:CreateTexture(nil, "BACKGROUND")
    minimapButton.icon:SetTexture("Interface\\AddOns\\SinsToolkit\\Media\\Logo.tga")
    minimapButton.icon:SetSize(22, 22)
    minimapButton.icon:SetPoint("CENTER")
    minimapButton.border = minimapButton:CreateTexture(nil, "OVERLAY")
    minimapButton.border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
    minimapButton.border:SetSize(54, 54)
    minimapButton.border:SetPoint("TOPLEFT")
    minimapButton:SetScript("OnClick", function(_, button)
        if button == "RightButton" then
            SinsToolkitDB.teleportHubMinimap = false
            UpdateMinimapButton()
            Print("Teleport Hub minimap button hidden. Use /stktp minimap to show it again.")
        else
            Hub:Toggle()
        end
    end)
    minimapButton:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("Sin's Toolkit QOL Teleport Hub", 0.74, 0.45, 0.95)
        GameTooltip:AddLine("Left-click: open teleport panel", 1, 1, 1)
        GameTooltip:AddLine("Right-click: hide minimap button", 1, 1, 1)
        GameTooltip:AddLine("/stktp minimap shows it again", 0.85, 0.66, 0.30)
        GameTooltip:Show()
    end)
    minimapButton:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

function Hub:Init()
    if initialized then return end
    initialized = true
    InitButtonPool()
    CreatePanel()
    CreateMinimapButton()
    UpdateMinimapButton()
end

function SinsToolkit.ApplyTeleportHub()
    if not initialized then Hub:Init() end
    UpdateMinimapButton()
    if panel and panel:IsShown() and not IsEnabled() and not InCombatLockdown() then
        panel:Hide()
    elseif panel and panel:IsShown() and not InCombatLockdown() then
        Hub:Render()
    end
end

local function ShowHelp()
    Print("commands:")
    print("  |cffffff00/stktp|r - open or close the Teleport Hub")
    print("  |cffffff00/stktp on|r / |cffffff00off|r - enable or disable Teleport Hub")
    print("  |cffffff00/stktp minimap|r - show or hide the minimap button")
    print("  |cffffff00/stktp scan|r - print known teleport count")
    print("  |cffffff00/stktp locked|r - show or hide locked teleports")
    print("  |cffffff00/stktp view|r - switch between grid and list view")
end

SLASH_SINSTOOLKITTELEPORTHUB1 = "/stktp"
SLASH_SINSTOOLKITTELEPORTHUB2 = "/stkteleport"
SlashCmdList.SINSTOOLKITTELEPORTHUB = function(msg)
    if SinsToolkit.EnsureDB then SinsToolkit.EnsureDB() end
    msg = (msg or ""):lower():match("^%s*(.-)%s*$")

    if msg == "" or msg == "toggle" then
        Hub:Toggle()
    elseif msg == "open" or msg == "show" then
        Hub:Open()
    elseif msg == "close" or msg == "hide" then
        if panel and not InCombatLockdown() then panel:Hide() end
    elseif msg == "on" or msg == "enable" then
        SinsToolkitDB.teleportHubEnabled = true
        SinsToolkit.ApplyTeleportHub()
        Print("Teleport Hub enabled.")
    elseif msg == "off" or msg == "disable" then
        SinsToolkitDB.teleportHubEnabled = false
        SinsToolkit.ApplyTeleportHub()
        Print("Teleport Hub disabled.")
    elseif msg == "minimap" then
        SinsToolkitDB.teleportHubMinimap = not SinsToolkitDB.teleportHubMinimap
        UpdateMinimapButton()
        Print("Teleport Hub minimap button " .. (SinsToolkitDB.teleportHubMinimap and "|cff00ff00shown|r." or "|cffff4040hidden|r."))
    elseif msg == "scan" then
        Hub:Scan()
    elseif msg == "locked" then
        SinsToolkitDB.teleportHubShowLocked = not SinsToolkitDB.teleportHubShowLocked
        if panel and panel:IsShown() then Hub:Render() end
        Print("Locked teleports " .. (SinsToolkitDB.teleportHubShowLocked and "|cff00ff00shown|r." or "|cffff4040hidden|r."))
    elseif msg == "view" then
        SinsToolkitDB.teleportHubView = IsListView() and "grid" or "list"
        if panel and panel:IsShown() then Hub:Render() end
        Print("Teleport Hub view set to |cffffff00" .. SinsToolkitDB.teleportHubView .. "|r.")
    elseif msg == "grid" or msg == "list" then
        SinsToolkitDB.teleportHubView = msg
        if panel and panel:IsShown() then Hub:Render() end
        Print("Teleport Hub view set to |cffffff00" .. SinsToolkitDB.teleportHubView .. "|r.")
    else
        ShowHelp()
    end
end

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("SPELLS_CHANGED")
eventFrame:RegisterEvent("NEW_TOY_ADDED")
eventFrame:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_LOGIN" then
        if SinsToolkit.EnsureDB then SinsToolkit.EnsureDB() end
        Hub:Init()
    elseif panel and panel:IsShown() and not InCombatLockdown() then
        Hub:Render()
    end
end)
