local pluginName, pluginShared = ...
local Favorites = {}
pluginShared.Favorites = Favorites
_G.AppearanceMatic = pluginShared

local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

-- Routing and alert lines all go out the same way: Social.lua adds the star and the addon's name, and
-- sends it as an emote or prints it depending on the Emote box.
local function say(text)
    local social = pluginShared.Social
    if social and social.Line then
        return social:Line(text)
    end
    print(text)
end
pluginShared.say = say

-- The same line for the player alone, never an emote: route removals and stars coming off.
local function tell(text)
    local social = pluginShared.Social
    if social and social.Tell then
        return social:Tell(text)
    end
    print(text)
end
pluginShared.tell = tell

local function guarded(fn, ...)
    if type(fn) ~= "function" then
        return false
    end
    return pcall(fn, ...)
end
pluginShared.guarded = guarded

-- The client hands back "secret" values from several APIs while the player is in combat or inside an
-- instance. Touching one raises: comparing it, joining it to a string, matching on it, sorting it and
-- using it as a table key all count, and type() still calls a secret string a string. This hands the
-- value back only when it is safe to use, and nil while the game is holding it back.
local function plain(value)
    if type(issecretvalue) == "function" and issecretvalue(value) then
        return nil
    end
    return value
end
pluginShared.plain = plain

-- Hands an error to the game's own handler so BugGrabber and the like record it with a traceback taken
-- where the error happened, then gives the message back to the caller.
local function handleError(err)
    if type(CallErrorHandler) == "function" then
        CallErrorHandler(err)
    elseif type(geterrorhandler) == "function" then
        local handler = geterrorhandler()
        if type(handler) == "function" then
            handler(err)
        end
    end
    return err
end
pluginShared.handleError = handleError

-- Like guarded, but for calls whose failure means this addon is broken rather than the game answering
-- "no". The addon still carries on, and the error still reaches whatever is collecting errors, so a
-- real bug is never swallowed the way the panel used to swallow a broken draw.
local function reported(fn, ...)
    if type(fn) ~= "function" then
        return false
    end
    return xpcall(fn, handleError, ...)
end
pluginShared.reported = reported

local listeners = {}

function Favorites:EnsureDB()
    AppearanceMaticDB = AppearanceMaticDB or {}
    AppearanceMaticDB.favorites = AppearanceMaticDB.favorites or {}
    return AppearanceMaticDB.favorites
end

function Favorites:IsFavorite(visualID)
    if not visualID then
        return false
    end
    return self:EnsureDB()[visualID] == true
end

-- Any source of the look already in the collection means the appearance is collected
function Favorites:AppearanceCollected(visualID)
    if not (visualID and C_TransmogCollection and type(C_TransmogCollection.GetAppearanceSources) == "function") then
        return false
    end
    local ok, sources = guarded(C_TransmogCollection.GetAppearanceSources, visualID)
    if ok and type(sources) == "table" then
        for _, src in ipairs(sources) do
            if type(src) == "table" and src.isCollected then
                return true
            end
        end
    end
    return false
end

function Favorites:AlreadyCollected(isMount, id)
    if isMount then
        return self.MountCollected and self:MountCollected(id) or false
    end
    return self:AppearanceCollected(id)
end

function Favorites:SayCollected()
    print(L["fav_print_already_collected"])
end

-- A star only ever marks work left to do: starring something already collected leaves it unstarred
-- (and clears a stale star) and says so.
function Favorites:Set(visualID, on)
    if not visualID then
        return false
    end
    local set = self:EnsureDB()
    if on and self:AppearanceCollected(visualID) then
        local had = set[visualID] ~= nil
        set[visualID] = nil
        self:SayCollected()
        if had then
            self:Notify()
        end
        return false
    end
    if on then
        set[visualID] = true
    else
        set[visualID] = nil
    end
    self:Notify()
    return on and true or false
end

function Favorites:Toggle(visualID)
    if not visualID then
        return false
    end
    self:Set(visualID, not self:IsFavorite(visualID))
    return self:IsFavorite(visualID)
end

function Favorites:All()
    local out = {}
    for visualID in pairs(self:EnsureDB()) do
        out[#out + 1] = visualID
    end
    table.sort(out)
    return out
end

function Favorites:AddListener(fn)
    if type(fn) == "function" then
        listeners[#listeners + 1] = fn
    end
end

function Favorites:Notify()
    -- a star moved, so the walked-out list of starred mounts is built again next time it is asked for
    if self.InvalidateMountScan then
        self:InvalidateMountScan()
    end
    for i = 1, #listeners do
        reported(listeners[i])
    end
end

function Favorites:CellPassesFilter(visualID)
    if not (AppearanceMaticDB and AppearanceMaticDB.preview and AppearanceMaticDB.preview.favoritesOnly) then
        return true
    end
    return self:IsFavorite(visualID)
end

function Favorites:MigrateNative(candidateVisualIDs, isNativeFavorite)
    if AppearanceMaticDB and AppearanceMaticDB.favoritesMigrated then
        return 0
    end
    local set = self:EnsureDB()
    local added = 0
    if type(candidateVisualIDs) == "table" and type(isNativeFavorite) == "function" then
        for i = 1, #candidateVisualIDs do
            local visualID = candidateVisualIDs[i]
            if not set[visualID] and isNativeFavorite(visualID) then
                set[visualID] = true
                added = added + 1
            end
        end
    end
    AppearanceMaticDB.favoritesMigrated = true
    if added > 0 then
        self:Notify()
    end
    return added
end

function Favorites:RunNativeMigration()
    if AppearanceMaticDB and AppearanceMaticDB.favoritesMigrated then
        return 0
    end
    if not (C_TransmogCollection and type(C_TransmogCollection.GetCategoryAppearances) == "function"
        and type(C_TransmogCollection.GetIsAppearanceFavorite) == "function"
        and Enum and type(Enum.TransmogCollectionType) == "table") then
        return 0
    end
    local candidates = {}
    for _, categoryID in pairs(Enum.TransmogCollectionType) do
        if type(categoryID) == "number" and categoryID > 0 then
            local ok, appearances = guarded(C_TransmogCollection.GetCategoryAppearances, categoryID)
            if ok and type(appearances) == "table" then
                for _, appearance in ipairs(appearances) do
                    if type(appearance) == "table" and appearance.visualID then
                        candidates[#candidates + 1] = appearance.visualID
                    end
                end
            end
        end
    end
    if #candidates == 0 then
        return 0
    end
    return self:MigrateNative(candidates, function(visualID)
        local ok, fav = guarded(C_TransmogCollection.GetIsAppearanceFavorite, visualID)
        return (ok and fav) and true or false
    end)
end

function Favorites:ImportLegacy()
    if AppearanceMaticDB and AppearanceMaticDB.legacyImported then
        return 0
    end
    local legacy = _G.W2TSPlannerDB and _G.W2TSPlannerDB.favorites
    if type(legacy) ~= "table" then
        return 0
    end
    local set = self:EnsureDB()
    local added = 0
    for visualID, on in pairs(legacy) do
        if on and not set[visualID] then
            set[visualID] = true
            added = added + 1
        end
    end
    AppearanceMaticDB.legacyImported = true
    if added > 0 then
        self:Notify()
    end
    return added
end

function Favorites:EnsureStarButton(model)
    if model.w2tsStar then
        return model.w2tsStar
    end
    if type(CreateFrame) ~= "function" or type(model.CreateTexture) ~= "function" then
        return nil
    end
    local button
    local ok = guarded(function()
        button = CreateFrame("Button", nil, model)
        button:SetSize(18, 18)
        button:SetFrameLevel((model:GetFrameLevel() or 1) + 5)
        local starIcon = model.Favorite and model.Favorite.Icon
        if starIcon then
            button:SetPoint("CENTER", starIcon, "CENTER", 0, 0)
        else
            button:SetPoint("TOPLEFT", model, "TOPLEFT", -4, 4)
        end
        local tex = button:CreateTexture(nil, "OVERLAY")
        tex:SetAllPoints()
        tex:SetAtlas("PetJournal-FavoritesIcon")
        tex:SetDesaturated(true)
        tex:SetAlpha(0.35)
        button.tex = tex
        button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        button:SetScript("OnClick", function(_, mouseButton)
            local info = model.visualInfo
            if not (info and info.visualID) then return end
            if mouseButton == "RightButton" then
                Favorites:GuideToVisual(info.visualID)
                return
            end
            Favorites:Toggle(info.visualID)
            Favorites:RefreshStars(model:GetParent())
        end)
        button:SetScript("OnEnter", function()
            if GameTooltip then
                GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
                local info = model.visualInfo
                local fav = info and info.visualID and Favorites:IsFavorite(info.visualID)
                if not fav and info and info.visualID and Favorites:AppearanceCollected(info.visualID) then
                    GameTooltip:SetText(L["fav_tooltip_collected"], 0.6, 0.6, 0.6)
                else
                    GameTooltip:SetText(fav and L["fav_tooltip_remove_favorite"] or L["fav_tooltip_add_favorite"])
                end
                GameTooltip:AddLine(L["fav_tooltip_guide_hint"], 0.4, 1, 0.4)
                GameTooltip:Show()
            end
        end)
        button:SetScript("OnLeave", function()
            if GameTooltip then
                GameTooltip:Hide()
            end
        end)
    end)
    if ok then
        model.w2tsStar = button
    end
    return model.w2tsStar
end

-- The route icon beside the star: lit when the appearance is on the route, dim when not
function Favorites:EnsureRouteButton(model)
    if model.w2tsRoute then
        return model.w2tsRoute
    end
    local star = model.w2tsStar
    local routeList = pluginShared.RouteList
    if not (star and routeList) or type(CreateFrame) ~= "function" then
        return nil
    end
    local button
    local ok = guarded(function()
        button = CreateFrame("Button", nil, model)
        button:SetSize(18, 18)
        button:SetFrameLevel((model:GetFrameLevel() or 1) + 5)
        button:SetPoint("LEFT", star, "RIGHT", 2, 0)
        local tex = button:CreateTexture(nil, "OVERLAY")
        tex:SetAllPoints()
        tex:SetTexture(routeList.ICON_DIM)
        button.tex = tex
        button:SetScript("OnClick", function()
            local info = model.visualInfo
            if not (info and info.visualID) then return end
            local lockouts = pluginShared.Lockouts
            if lockouts and lockouts.ToggleRouteItem then
                lockouts:ToggleRouteItem(false, info.visualID)
            else
                routeList:Toggle(false, info.visualID)
            end
            Favorites:RefreshStars(model:GetParent())
        end)
        button:SetScript("OnEnter", function()
            if GameTooltip then
                GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
                local info = model.visualInfo
                local onRoute = info and info.visualID and routeList:Has(false, info.visualID)
                GameTooltip:SetText(onRoute and L["route_tooltip_remove"] or L["route_tooltip_add"])
                GameTooltip:AddLine(L["route_tooltip_desc"], 0.8, 0.8, 0.8, true)
                GameTooltip:Show()
            end
        end)
        button:SetScript("OnLeave", function()
            if GameTooltip then
                GameTooltip:Hide()
            end
        end)
    end)
    if ok then
        model.w2tsRoute = button
    end
    return model.w2tsRoute
end

function Favorites:RefreshStars(collection)
    if type(collection) ~= "table" or type(collection.Models) ~= "table" then
        return
    end
    for i = 1, #collection.Models do
        local model = collection.Models[i]
        local info = model and model.visualInfo
        if info and info.visualID then
            local fav = self:IsFavorite(info.visualID)
            if model.Favorite and model.Favorite.Icon then
                reported(model.Favorite.Icon.SetShown, model.Favorite.Icon, fav)
            end
            local button = self:EnsureStarButton(model)
            if button and button.tex then
                -- Blizzard's own icon covers the favorited state; show the dim star only when unfavorited
                reported(button.tex.SetShown, button.tex, not fav)
            end
            local routeButton = self:EnsureRouteButton(model)
            local routeList = pluginShared.RouteList
            if routeButton and routeButton.tex and routeList then
                reported(routeButton.tex.SetTexture, routeButton.tex, routeList:Has(false, info.visualID) and routeList.ICON or routeList.ICON_DIM)
            end
        end
    end
end

function Favorites:ApplyListFilter(items)
    if not (items and AppearanceMaticDB and AppearanceMaticDB.preview and AppearanceMaticDB.preview.favoritesOnly) then
        return
    end
    local list = items.filteredVisualsList
    if type(list) ~= "table" then
        return
    end
    local kept = {}
    for i = 1, #list do
        local vi = list[i]
        if vi and self:CellPassesFilter(vi.visualID) then
            kept[#kept + 1] = vi
        end
    end
    items.filteredVisualsList = kept
    if items.PagingFrame and items.PAGE_SIZE and type(items.PagingFrame.SetMaxPages) == "function" then
        local pages = math.ceil(#kept / items.PAGE_SIZE)
        if pages < 1 then
            pages = 1
        end
        items.PagingFrame:SetMaxPages(pages)
    end
end

function Favorites:RefreshWardrobe()
    local items = WardrobeCollectionFrame and WardrobeCollectionFrame.ItemsCollectionFrame
    if type(items) ~= "table" then
        return
    end
    if items.PagingFrame and type(items.PagingFrame.SetCurrentPage) == "function" then
        guarded(items.PagingFrame.SetCurrentPage, items.PagingFrame, 1)
    end
    if type(items.RefreshVisualsList) == "function" then
        guarded(items.RefreshVisualsList, items)
    end
    if type(items.UpdateItems) == "function" then
        guarded(items.UpdateItems, items)
    end
end

function Favorites:InstallFilterMenu()
    if self.filterMenuHooked then
        return
    end
    if type(Menu) ~= "table" or type(Menu.ModifyMenu) ~= "function" then
        return
    end
    self.filterMenuHooked = true
    Menu.ModifyMenu("MENU_WARDROBE_FILTER", function(owner, rootDescription)
        rootDescription:CreateCheckbox(L["fav_menu_favorites"], function()
            return AppearanceMaticDB and AppearanceMaticDB.preview and AppearanceMaticDB.preview.favoritesOnly or false
        end, function()
            AppearanceMaticDB = AppearanceMaticDB or {}
            AppearanceMaticDB.preview = AppearanceMaticDB.preview or {}
            AppearanceMaticDB.preview.favoritesOnly = not AppearanceMaticDB.preview.favoritesOnly
            Favorites:RefreshWardrobe()
            return MenuResponse and MenuResponse.Refresh
        end)
    end)
end

function Favorites:ExpansionNameForSource(sourceID, sources)
    local itemID
    if sourceID and C_TransmogCollection and type(C_TransmogCollection.GetSourceInfo) == "function" then
        local ok, info = guarded(C_TransmogCollection.GetSourceInfo, sourceID)
        if ok and type(info) == "table" then
            itemID = info.itemID
        end
    end
    if not itemID and type(sources) == "table" then
        for _, src in ipairs(sources) do
            if type(src) == "table" and src.itemID then
                itemID = src.itemID
                break
            end
        end
    end
    if not (itemID and C_Item and type(C_Item.GetItemInfo) == "function") then
        return nil
    end
    local ok, expansionID = guarded(function(id)
        return select(15, C_Item.GetItemInfo(id))
    end, itemID)
    if not (ok and type(expansionID) == "number") then
        return nil
    end
    local name
    if type(GetExpansionName) == "function" then
        local okName, result = pcall(GetExpansionName, expansionID)
        if okName and type(result) == "string" and result ~= "" then
            name = result
        end
    end
    name = name or _G["EXPANSION_NAME" .. expansionID]
    if type(name) ~= "string" or name == "" then
        return nil
    end
    return name
end

function Favorites:InstallTooltipHook()
    if self.tooltipHooked or type(hooksecurefunc) ~= "function" then
        return
    end
    local frame = WardrobeCollectionFrame
    if type(frame) ~= "table" or type(frame.SetAppearanceTooltip) ~= "function" then
        return
    end
    self.tooltipHooked = true
    hooksecurefunc(frame, "SetAppearanceTooltip", function(_, _, sources, primarySourceID)
        if not (GameTooltip and GameTooltip:IsShown()) then
            return
        end
        local profile = pluginShared.db and pluginShared.db.profile
        local showExpansion = not (profile and profile.generalSettings.expansionInTooltip == false)
        -- Prefer appending the expansion right after the instance name on Blizzard's source line; the
        -- raid named there also gets its access line (faction control, campaign travel)
        local lockouts = pluginShared.Lockouts
        local tagged, gatedRaid = false, nil
        if lockouts and type(lockouts.RaidExpansionName) == "function" and type(GameTooltip.NumLines) == "function" then
            local tipName = GameTooltip:GetName()
            for i = 1, GameTooltip:NumLines() do
                local fs = tipName and _G[tipName .. "TextLeft" .. i]
                local text = fs and fs:GetText()
                if type(text) == "string" and not (type(issecretvalue) == "function" and issecretvalue(text)) then
                    for instanceName in text:gmatch("[^,]+") do
                        local trimmed = instanceName:gsub("^%s+", ""):gsub("%s+$", "")
                        local expansion = lockouts:RaidExpansionName(trimmed)
                        if expansion then
                            gatedRaid = gatedRaid or trimmed
                            if showExpansion and not text:find(expansion, 1, true) then
                                fs:SetText(text .. ", " .. expansion)
                                tagged = true
                            end
                            break
                        end
                    end
                end
            end
        end
        if showExpansion and not tagged then
            local name = Favorites:ExpansionNameForSource(primarySourceID, sources)
            if name then
                GameTooltip:AddLine(string.format(L["fav_tooltip_expansion_fmt"], name), 0.6, 0.8, 1)
            end
        end
        if gatedRaid and lockouts and type(lockouts.GateLine) == "function" then
            local gateText, gr, gg, gb = lockouts:GateLine(gatedRaid)
            if gateText then
                GameTooltip:AddLine(gateText, gr, gg, gb, true)
            end
        end
        Favorites:AddAcquisitionLines(GameTooltip, primarySourceID, sources)
        GameTooltip:Show()
    end)
end

-- Where to get it, from the shipped data: cost, vendor or zone, how it drops, and any vendor note.
function Favorites:AddAcquisitionLines(tooltip, primarySourceID, sources)
    local acq = pluginShared.Acquisition
    if not (acq and C_TransmogCollection and type(C_TransmogCollection.GetSourceInfo) == "function") then
        return
    end
    local candidates = {}
    if primarySourceID then candidates[#candidates + 1] = primarySourceID end
    for _, src in ipairs(type(sources) == "table" and sources or {}) do
        local id = type(src) == "table" and src.sourceID or src
        if type(id) == "number" and id ~= primarySourceID then candidates[#candidates + 1] = id end
    end
    -- one lookup per appearance: the record and its access lines are kept until a quest, level, spell
    -- or reputation event (or a quest title arriving) invalidates them
    local key = primarySourceID or candidates[1]
    self.tooltipCache = self.tooltipCache or {}
    local cached = key and self.tooltipCache[key]
    if not cached then
        local best = self:BestRecordForSources(candidates)
        cached = { best = best, access = best and acq.AccessLines and acq:AccessLines(best) or {} }
        if key then
            self.tooltipCache[key] = cached
        end
    end
    do
        local known = cached.best
        if known then
            local who = known.vendorName or (known.npcID and acq:NpcName(known.npcID)) or nil
            if known.costText then
                tooltip:AddLine(string.format(L["fav_tooltip_cost_fmt"], known.costText), 0.8, 0.8, 0.8)
            end
            local sourceText = acq:SourceText(known)
            if sourceText then
                tooltip:AddLine(sourceText, 0.8, 0.8, 0.8)
            end
            if known.mapID and known.x and known.y then
                tooltip:AddLine(acq:PlaceTitle(who or (known.costRows and L["fav_label_vendor"]) or L["fav_label_location"], known.mapID)
                    .. string.format(" (%.1f, %.1f)", known.x * 100, known.y * 100), 0.6, 0.8, 1)
            elseif known.zoneMapID then
                tooltip:AddLine(string.format(L["fav_tooltip_zone_fmt"], acq:ZoneName(known.zoneMapID) or L["fav_label_location"]), 0.6, 0.8, 1)
            elseif who then
                tooltip:AddLine(who, 0.6, 0.8, 1)
            end
            local note = acq:NoteText(known)
            if note then
                tooltip:AddLine(note, 1, 0.82, 0, true)
            end
            for _, access in ipairs(cached.access) do
                if access.blocked then
                    tooltip:AddLine(string.format(L["lock_access_label"], access.text), 1, 0.35, 0.35, true)
                else
                    tooltip:AddLine(string.format(L["lock_access_label"], access.text), 0.7, 0.7, 0.7, true)
                end
            end
            -- Blizzard says it cannot track this; we can guide to it, say so in its place
            if (known.mapID or known.zoneMapID) and type(CONTENT_TRACKING_UNTRACKABLE_TOOLTIP_PROMPT) == "string"
                and type(tooltip.NumLines) == "function" then
                local tipName = tooltip:GetName()
                for i = 1, tooltip:NumLines() do
                    local fs = tipName and _G[tipName .. "TextLeft" .. i]
                    local text = fs and fs:GetText()
                    if type(text) == "string" and not (type(issecretvalue) == "function" and issecretvalue(text))
                        and text:find(CONTENT_TRACKING_UNTRACKABLE_TOOLTIP_PROMPT, 1, true) then
                        fs:SetText(L["fav_tooltip_guide_star_hint"])
                        fs:SetTextColor(0.4, 1, 0.4)
                        break
                    end
                end
            end
        end
    end
end

function Favorites:InvalidateTooltipCache()
    self.tooltipCache = nil
end

-- The same look can come from several items; the record that knows the most wins.
-- Returns the record and the sourceID it came from.
function Favorites:BestRecordForSources(candidates)
    local acq = pluginShared.Acquisition
    if not (acq and C_TransmogCollection and type(C_TransmogCollection.GetSourceInfo) == "function") then
        return nil
    end
    local best, bestRank, bestSource = nil, 0, nil
    for _, sourceID in ipairs(candidates or {}) do
        local okInfo, info = pcall(C_TransmogCollection.GetSourceInfo, sourceID)
        local itemID = okInfo and type(info) == "table" and info.itemID or nil
        local okVendor, known = false, nil
        if itemID then okVendor, known = pcall(acq.GetVendor, acq, itemID) end
        if okVendor and type(known) == "table" then
            local rank = (known.costRows and 4 or 0) + (known.mapID and 2 or 0) + (known.zoneMapID and 1 or 0)
            if rank > bestRank then best, bestRank, bestSource = known, rank, sourceID end
        end
    end
    return best, bestSource
end

-- Start guiding to an appearance from the wardrobe (star right-click).
function Favorites:GuideToVisual(visualID)
    local lockouts = pluginShared.Lockouts
    local acq = pluginShared.Acquisition
    if not (visualID and lockouts and acq and C_TransmogCollection and type(C_TransmogCollection.GetAppearanceSources) == "function") then
        return false
    end
    local okSources, sources = pcall(C_TransmogCollection.GetAppearanceSources, visualID)
    local candidates = {}
    for _, src in ipairs(okSources and type(sources) == "table" and sources or {}) do
        if type(src) == "table" and src.sourceID then candidates[#candidates + 1] = src.sourceID end
    end
    local known, sourceID = self:BestRecordForSources(candidates)
    if not known then
        tell(L["fav_print_no_location"])
        return false
    end
    local mapID, x, y = known.mapID, known.x, known.y
    local who = known.vendorName or (known.npcID and acq:NpcName(known.npcID)) or nil
    local title, zoneOnly
    if mapID and x and y then
        title = acq:PlaceTitle(who or (known.costRows and L["fav_label_vendor_location"]) or L["fav_label_location"], mapID)
    elseif known.zoneMapID then
        mapID, x, y, zoneOnly = known.zoneMapID, 0.5, 0.5, true
        title = string.format(L["fav_tooltip_zone_fmt"], acq:PlaceTitle(who, known.zoneMapID))
    else
        tell(L["fav_print_no_location"])
        return false
    end
    local trackType = pluginShared.Tracking and pluginShared.Tracking.APPEARANCE or 0
    -- the same link the panel row builds: Blizzard tracking for the pin and the collected alert, the
    -- shipped location for the route, a plain waypoint when the source cannot be tracked
    if type(sourceID) == "number" and lockouts.TrackTarget and lockouts.HandleTrackLink then
        local raw = lockouts:TrackTarget(trackType, sourceID, nil, nil, { mapID = mapID, x = x, y = y, title = title, zoneOnly = zoneOnly })
        if type(raw) == "string" and raw ~= "" and (lockouts:HandleTrackLink(raw) or lockouts:HandleWaypointLink(raw)) then
            return true
        end
    end
    local link = string.format("w2tswp:%d:%d:%d:%d:%d:%s", mapID, math.floor(x * 1000 + 0.5), math.floor(y * 1000 + 0.5), trackType, sourceID or 0, title)
    return lockouts:HandleWaypointLink(link)
end

function Favorites:InstallWardrobeHooks()
    if self.wardrobeHooked or type(hooksecurefunc) ~= "function" then
        return
    end
    local items = WardrobeCollectionFrame and WardrobeCollectionFrame.ItemsCollectionFrame
    if type(items) ~= "table" or type(items.UpdateItems) ~= "function" then
        return
    end
    self.wardrobeHooked = true
    hooksecurefunc(items, "UpdateItems", function(collection)
        Favorites:RefreshStars(collection)
    end)
    if type(items.RefreshVisualsList) == "function" then
        hooksecurefunc(items, "RefreshVisualsList", function(collection)
            Favorites:ApplyListFilter(collection)
        end)
    end
    self:InstallFilterMenu()
    self:InstallTooltipHook()
    self:RefreshStars(items)
end

local favLoader = CreateFrame and CreateFrame("Frame")
if favLoader then
    favLoader:RegisterEvent("ADDON_LOADED")
    favLoader:RegisterEvent("PLAYER_LOGIN")
    favLoader:SetScript("OnEvent", function(_, event, name)
        if event == "ADDON_LOADED" and (name == "Blizzard_Collections" or name == pluginName) then
            Favorites:EnsureDB()
            Favorites:InstallWardrobeHooks()
            Favorites:InstallMountHooks()
        elseif event == "PLAYER_LOGIN" then
            Favorites:EnsureDB()
            Favorites:ImportLegacy()
            Favorites:RunNativeMigration()
        end
    end)
end
