-- Transmoggin - Copyright (c) 2026 Rooster_Roost. All Rights Reserved.
-- Proprietary software. Personal non-commercial use only; redistribution/repackaging/forks/reuse require permission.
-- Full license: Core.lua and LICENSE.txt

local T=Transmoggin

local function pickField(t,...)
    if type(t)~="table" then return nil end
    for i=1,select("#",...) do local k=select(i,...); if t[k]~=nil then return t[k] end end
end

function T:GetAppearanceSourcesSafe(visualID,categoryID,slotDef)
    if not (C_TransmogCollection and C_TransmogCollection.GetAppearanceSources and visualID) then return {} end
    self.state.sourceListCache=self.state.sourceListCache or {}
    local slotKey=(slotDef and slotDef.key) or (self.state and self.state.selectedSlot) or ""
    local cacheKey=tostring(visualID)..":"..tostring(categoryID or 0)..":"..tostring(slotKey)
    local cached=self.state.sourceListCache[cacheKey]
    if cached then return cached end
    local slot=slotDef or self:GetSlotDefForCategory(categoryID) or self:GetSlotDef(self.state.selectedSlot)
    local loc=slot and self:MakeLocation(slot) or nil
    local ok,res=pcall(C_TransmogCollection.GetAppearanceSources,visualID,categoryID,loc)
    if not ok or type(res)~="table" then ok,res=pcall(C_TransmogCollection.GetAppearanceSources,visualID,categoryID) end
    if not ok or type(res)~="table" then ok,res=pcall(C_TransmogCollection.GetAppearanceSources,visualID) end
    if ok and type(res)=="table" then self.state.sourceListCache[cacheKey]=res; return res end
    self.state.sourceListCache[cacheKey]={}
    return self.state.sourceListCache[cacheKey]
end

function T:SourceIsCollected(src)
    if type(src)=="table" and src.isCollected~=nil then return src.isCollected and true or false end
    local sid=type(src)=="table" and (src.itemModifiedAppearanceID or src.sourceID) or src
    if sid and C_TransmogCollection and C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance then
        local ok,v=pcall(C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance,sid)
        if ok and v~=nil then return v and true or false end
    end
    if sid and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
        self.state.sourceInfoCache=self.state.sourceInfoCache or {}
        local info=self.state.sourceInfoCache[sid]
        if not info then
            local ok,res=pcall(C_TransmogCollection.GetSourceInfo,sid)
            if ok and type(res)=="table" then info=res; self.state.sourceInfoCache[sid]=res end
        end
        if type(info)=="table" and info.isCollected~=nil then return info.isCollected and true or false end
    end
    return false
end

-- Collection ownership is appearance-wide. A visual is owned when ANY valid
-- source that grants that visual is collected. This prevents false "Missing"
-- labels when the player owns a different item source for the same appearance.
function T:IsVisualCollected(visualID,categoryID,preferredSourceID)
    if not visualID then return false end
    self.state.visualCollectedCache=self.state.visualCollectedCache or {}
    local ck=tostring(visualID)..":"..tostring(categoryID or 0)
    local cv=self.state.visualCollectedCache[ck]
    if cv~=nil then return cv end
    local sources=self:GetAppearanceSourcesSafe(visualID,categoryID)
    for _,src in ipairs(sources) do if self:SourceIsCollected(src) then self.state.visualCollectedCache[ck]=true; return true end end
    if preferredSourceID and self:SourceIsCollected(preferredSourceID) then self.state.visualCollectedCache[ck]=true; return true end
    if C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances and categoryID then
        local slot=self:GetSlotDefForCategory(categoryID); local loc=slot and self:MakeLocation(slot) or nil
        local ok,apps=pcall(C_TransmogCollection.GetCategoryAppearances,categoryID,loc)
        if not ok or type(apps)~="table" then ok,apps=pcall(C_TransmogCollection.GetCategoryAppearances,categoryID) end
        if ok and type(apps)=="table" then
            for _,a in ipairs(apps) do if a.visualID==visualID then local v=a.isCollected and true or false; self.state.visualCollectedCache[ck]=v; return v end end
        end
    end
    self.state.visualCollectedCache[ck]=false
    return false
end

local function sourceIDOf(src)
    return type(src)=="table" and (src.sourceID or src.itemModifiedAppearanceID) or src
end

function T:GetGeneratedItemIDForModifiedAppearance(modID)
    if self.clientMode and self.clientMode~="mop-classic" then return nil end
    modID=tonumber(modID)
    if not modID then return nil end
    -- v0.10.9 ships a direct reverse index generated from the complete local
    -- ItemModifiedAppearance table. This avoids a linear scan/stutter the first
    -- time an old-client source ID needs to be resolved.
    if self.GeneratedModToItem then
        local itemID=self.GeneratedModToItem[modID]
        if itemID and itemID~=0 then return itemID end
    end
    -- Alpha34 ships GeneratedModToItem as a direct reverse index. The linear
    -- GeneratedItemSourceDB fallback is retained only for incomplete/legacy packages.
    if not self.GeneratedItemSourceDB then return nil end
    self.state.generatedModToItem=self.state.generatedModToItem or {}
    local cached=self.state.generatedModToItem[modID]
    if cached~=nil then return cached~=false and cached or nil end
    -- The shipped GeneratedModToItem index is authoritative in UI/runtime paths.
    -- Never fall back to a linear GeneratedItemSourceDB scan here.
    self.state.generatedModToItem[modID]=false
    return nil
end


function T:GetConcreteItemForAppearance(visualID,preferredSourceID)
    visualID=tonumber(visualID)
    if not visualID or visualID==0 then return nil end

    self.state.specialAppearanceItemCache=self.state.specialAppearanceItemCache or {}
    local cacheKey=tostring(visualID)..":"..tostring(preferredSourceID or 0)
    local cached=self.state.specialAppearanceItemCache[cacheKey]
    if cached~=nil then return cached~=false and cached or nil end

    local function make(itemID,sourceID,name,provider)
        itemID=tonumber(itemID)
        if not itemID or itemID==0 then return nil end
        local itemName,itemLink
        if GetItemInfo then itemName,itemLink=GetItemInfo(itemID) end
        if (not itemName or itemName=="") and self.GeneratedItemNames then itemName=self.GeneratedItemNames[itemID] end
        return {itemID=itemID,sourceID=tonumber(sourceID),itemName=itemName or name,itemLink=itemLink,provider=provider}
    end

    -- Strongest bridge: a merchant item physically observed in the live client.
    -- Resolve it through direct indexes; never walk all learned merchant records here.
    if self.EnsureMerchantIndexes then self:EnsureMerchantIndexes() end
    if self.db then
        local itemID=preferredSourceID and self.db.merchantSourceIndex and self.db.merchantSourceIndex[tonumber(preferredSourceID)] or nil
        local learned=itemID and self.db.sourceIntel and self.db.sourceIntel[itemID] or nil
        if learned then
            local row=make(itemID,learned.modifiedAppearanceID,learned.itemName,"verified merchant")
            if row then self.state.specialAppearanceItemCache[cacheKey]=row; return row end
        end
        local bucket=self.db.merchantAppearanceIndex and self.db.merchantAppearanceIndex[visualID]
        if type(bucket)=="table" then
            local firstItemID=next(bucket)
            local first=firstItemID and self.db.sourceIntel and self.db.sourceIntel[firstItemID]
            local row=first and make(firstItemID,first.modifiedAppearanceID,first.itemName,"verified merchant") or nil
            if row then self.state.specialAppearanceItemCache[cacheKey]=row; return row end
        end
    end

    -- Exact source relationship from Blizzard/generated client data.
    if preferredSourceID then
        local itemID
        if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
            local ok,id=pcall(C_TransmogCollection.GetSourceItemID,preferredSourceID)
            if ok then itemID=id end
        end
        if (not itemID or itemID==0) and self.GetGeneratedItemIDForModifiedAppearance then
            itemID=self:GetGeneratedItemIDForModifiedAppearance(preferredSourceID)
        end
        local row=make(itemID,preferredSourceID,nil,"source ID")
        if row then self.state.specialAppearanceItemCache[cacheKey]=row; return row end
    end

    -- Never brute-force GeneratedItemSourceDB here. This function runs from
    -- Salon/search code. Only direct/indexed source relationships are allowed.

    self.state.specialAppearanceItemCache[cacheKey]=false
    return nil
end

function T:HydrateSpecialAppearanceRecord(rec)
    if not rec or not rec.visualID then return rec end
    local generic=(not rec.itemName) or rec.itemName=="" or tostring(rec.name or ""):match("^Appearance %d+$") or tostring(rec.name or ""):match("^Set Piece %d+$")
    if rec.itemID and not generic then return rec end

    local concrete=self:GetConcreteItemForAppearance(rec.visualID,rec.itemModID or rec.sourceID)
    if not concrete then return rec end

    rec.itemID=rec.itemID or concrete.itemID
    rec.sourceID=rec.sourceID or concrete.sourceID
    rec.itemModID=rec.itemModID or concrete.sourceID
    rec.previewSourceID=rec.previewSourceID or concrete.sourceID
    rec.itemLink=rec.itemLink or concrete.itemLink

    local itemName=concrete.itemName
    local icon
    if rec.itemID and GetItemInfo then
        local n,link,_,_,_,_,_,_,_,tex=GetItemInfo(rec.itemID)
        itemName=itemName or n
        rec.itemLink=rec.itemLink or link
        icon=tex
    end
    if not icon and rec.itemID and GetItemInfoInstant then icon=select(5,GetItemInfoInstant(rec.itemID)) end

    if itemName and itemName~="" then rec.itemName=itemName; rec.name=itemName end
    if icon then rec.icon=icon end
    rec.specialAppearanceResolved=true
    rec.specialAppearanceProvider=concrete.provider
    return rec
end


function T:GetSetBrowserSourceRecord(visualID,categoryID,sourceID,itemID,slotDef)
    visualID=tonumber(visualID)
    sourceID=tonumber(sourceID)
    itemID=tonumber(itemID)
    if not visualID or visualID==0 then return nil end

    -- Sets browser is intentionally class/spec/faction neutral. Build from the
    -- exact set source instead of asking GetSourceRecord to choose a usable source
    -- for the active character.
    local info={}
    if sourceID and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
        local ok,res=pcall(C_TransmogCollection.GetSourceInfo,sourceID)
        if ok and type(res)=="table" then
            for k,v in pairs(res) do info[k]=v end
        end
    end

    if (not itemID or itemID==0) and sourceID and C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
        local ok,res=pcall(C_TransmogCollection.GetSourceItemID,sourceID)
        if ok and res and res~=0 then itemID=tonumber(res) end
    end
    if (not itemID or itemID==0) and sourceID and self.GetGeneratedItemIDForModifiedAppearance then
        itemID=self:GetGeneratedItemIDForModifiedAppearance(sourceID)
    end

    local itemName,itemLink,icon
    if itemID and GetItemInfo then itemName,itemLink=GetItemInfo(itemID) end
    if itemID and GetItemInfoInstant then icon=select(5,GetItemInfoInstant(itemID)) end
    if (not itemName or itemName=="") and itemID and self.GeneratedItemNames then
        itemName=self.GeneratedItemNames[itemID]
    end

    local rec={
        visualID=visualID,
        sourceID=sourceID,
        itemModID=sourceID,
        previewSourceID=sourceID,
        itemID=itemID,
        itemName=itemName,
        name=itemName or info.name or ("Appearance "..tostring(visualID)),
        appearanceName=info.name,
        itemLink=itemLink,
        icon=icon or info.icon or 134400,
        categoryID=categoryID,
        allSources={{sourceID=sourceID,itemID=itemID}},
        sourceType=info.sourceType,
        setBrowserNeutral=true,
    }

    -- Collection state is informational only; never reject the source for being
    -- unusable by the active character.
    if sourceID then
        rec.collected=self:SourceIsCollected(sourceID) and true or false
    else
        rec.collected=self:IsVisualCollected(visualID,categoryID)
    end

    return rec
end

function T:GetSourceRecord(visualID,categoryID,preferredSourceID,slotDef)
    if not visualID then return nil end
    local slotKey=(slotDef and slotDef.key) or (self.state and self.state.selectedSlot) or ""
    local key=tostring(visualID)..":"..tostring(categoryID or 0)..":"..tostring(preferredSourceID or 0)..":"..tostring(slotKey)
    local cached=self.state.sourceCache[key]
    if cached then
        cached.collected=self:IsVisualCollected(visualID,categoryID,cached.itemModID or cached.sourceID)
        return self:HydrateSpecialAppearanceRecord(cached)
    end

    local sources=self:GetAppearanceSourcesSafe(visualID,categoryID,slotDef)
    local chosen=nil
    -- Prefer the exact requested source only if it is collected/usable, otherwise
    -- prefer any collected usable source so names/tooltips match what the player owns.
    if preferredSourceID then
        for _,src in ipairs(sources) do if sourceIDOf(src)==preferredSourceID and self:SourceIsCollected(src) then chosen=src break end end
    end
    if not chosen then for _,src in ipairs(sources) do if self:SourceIsCollected(src) and (type(src)~="table" or src.isUsable~=false) then chosen=src break end end end
    if not chosen and preferredSourceID then for _,src in ipairs(sources) do if sourceIDOf(src)==preferredSourceID then chosen=src break end end end
    if not chosen then for _,src in ipairs(sources) do if type(src)~="table" or src.isUsable~=false then chosen=src break end end end
    chosen=chosen or sources[1]

    local info={}; local sourceID=sourceIDOf(chosen)
    if type(chosen)=="table" then for k,v in pairs(chosen) do info[k]=v end end
    if sourceID and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
        self.state.sourceInfoCache=self.state.sourceInfoCache or {}
        local res=self.state.sourceInfoCache[sourceID]
        if not res then
            local ok,r=pcall(C_TransmogCollection.GetSourceInfo,sourceID)
            if ok and type(r)=="table" then res=r; self.state.sourceInfoCache[sourceID]=r end
        end
        if type(res)=="table" then for k,v in pairs(res) do if info[k]==nil then info[k]=v end end end
    end

    sourceID=sourceID or info.sourceID
    local itemModID=pickField(info,"itemModifiedAppearanceID","itemModID","sourceID") or sourceID
    local itemID=pickField(info,"itemID")
    if not itemID and itemModID and C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
        local ok,res=pcall(C_TransmogCollection.GetSourceItemID,itemModID); if ok and res and res~=0 then itemID=res end
    end
    if not itemID and itemModID and self.GetGeneratedItemIDForModifiedAppearance then
        itemID=self:GetGeneratedItemIDForModifiedAppearance(itemModID)
    end

    local itemName,itemLink,icon
    if itemID then
        itemName,itemLink=GetItemInfo(itemID)
        if not itemName and C_Item and C_Item.GetItemNameByID then local ok,res=pcall(C_Item.GetItemNameByID,itemID); if ok then itemName=res end end
        if GetItemInfoInstant then icon=select(5,GetItemInfoInstant(itemID)) end
    end
    local appearanceName=info.name or ("Appearance "..tostring(visualID))
    local rec={visualID=visualID,sourceID=sourceID,itemID=itemID,itemModID=itemModID,categoryID=categoryID,
        name=itemName or appearanceName,itemName=itemName,appearanceName=appearanceName,itemLink=itemLink,
        icon=icon or info.icon or 134400,isHideVisual=info.isHideVisual,allSources=sources,
        sourceType=info.sourceType,useError=info.useError,useErrorType=info.useErrorType}
    rec.collected=self:IsVisualCollected(visualID,categoryID,itemModID)
    -- Acquisition intelligence can involve several boss/source queries. Compute it
    -- only when the player selects/hovers/tracks the piece, never for every grid tile.
    rec.sourceText=nil
    rec=self:HydrateSpecialAppearanceRecord(rec)
    self.state.sourceCache[key]=rec
    return rec
end

-- Resolve a player-facing item name without ever exposing a raw itemID as the
-- label. Retail item records are loaded lazily, so request missing data and let
-- GET_ITEM_INFO_RECEIVED rebuild the currently visible HOW TO OBTAIN panel.
function T:ResolveDisplayItemName(itemID, fallback)
    itemID=tonumber(itemID)
    if not itemID or itemID<=0 then return fallback end
    local name
    if GetItemInfo then name=GetItemInfo(itemID) end
    if (not name or name=="") and C_Item and C_Item.GetItemNameByID then
        local ok,res=pcall(C_Item.GetItemNameByID,itemID)
        if ok and res and res~="" then name=res end
    end
    if (not name or name=="") and self.GeneratedItemNames then name=self.GeneratedItemNames[itemID] end
    if (not name or name=="") and self.GeneratedAuxSourceDB and self.GeneratedAuxSourceDB[itemID] then
        name=self.GeneratedAuxSourceDB[itemID].n
    end
    if name and name~="" then return name end
    if C_Item and C_Item.RequestLoadItemDataByID then pcall(C_Item.RequestLoadItemDataByID,itemID) end
    self.state.sourceResolvePending=true
    return fallback or "Loading item name..."
end


-- Retail quest-source helpers. The shipped Retail corpus is sourceID-indexed,
-- so these helpers only resolve metadata for the currently selected appearance.
-- Never enumerate/query NPCs from Salon grid/search hot paths.
function T:GetRetailQuestMapName(uiMapID)
    uiMapID=tonumber(uiMapID)
    if not uiMapID or uiMapID<=0 then return nil end
    self.state.retailQuestMapNameCache=self.state.retailQuestMapNameCache or {}
    local cached=self.state.retailQuestMapNameCache[uiMapID]
    if cached~=nil then return cached~=false and cached or nil end
    local name
    if C_Map and C_Map.GetMapInfo then
        local ok,info=pcall(C_Map.GetMapInfo,uiMapID)
        if ok and type(info)=="table" then name=info.name end
    end
    self.state.retailQuestMapNameCache[uiMapID]=name or false
    return name
end

function T:ResolveRetailQuestTitle(questID,fallback)
    questID=tonumber(questID)
    if not questID or questID<=0 then return fallback end
    self.state.retailQuestTitleCache=self.state.retailQuestTitleCache or {}
    local cached=self.state.retailQuestTitleCache[questID]
    if cached~=nil then return cached~=false and cached or fallback end

    local name=self.GeneratedRetailQuestNames and self.GeneratedRetailQuestNames[questID]
    if (not name or name=="") and C_QuestLog and C_QuestLog.GetTitleForQuestID then
        local ok,res=pcall(C_QuestLog.GetTitleForQuestID,questID)
        if ok and res and res~="" then name=res end
    end
    if name and name~="" then
        self.state.retailQuestTitleCache[questID]=name
        return name
    end

    if C_QuestLog and C_QuestLog.RequestLoadQuestByID then pcall(C_QuestLog.RequestLoadQuestByID,questID) end
    self.state.sourceResolvePending=true
    return fallback
end

local function retailTooltipFirstText(data)
    if type(data)~="table" then return nil end
    if type(data.lines)=="table" then
        for _,line in ipairs(data.lines) do
            if type(line)=="table" then
                if line.leftText and line.leftText~="" then return line.leftText end
                if type(line.args)=="table" then
                    for _,arg in ipairs(line.args) do
                        if type(arg)=="table" and arg.stringVal and arg.stringVal~="" then return arg.stringVal end
                    end
                end
            end
        end
    end
end

function T:ResolveRetailQuestEntityName(entityID)
    entityID=tonumber(entityID)
    if not entityID or entityID<=0 then return nil end
    self.state.retailQuestEntityNameCache=self.state.retailQuestEntityNameCache or {}
    local cached=self.state.retailQuestEntityNameCache[entityID]
    if cached~=nil then return cached~=false and cached or nil end

    local staticName=self.GeneratedRetailQuestEntityNames and self.GeneratedRetailQuestEntityNames[entityID]
    if staticName and staticName~="" then
        self.state.retailQuestEntityNameCache[entityID]=staticName
        return staticName
    end

    -- Grail synthetic entities/game objects are not valid Creature hyperlinks.
    -- Their static names are used above when available; otherwise leave the name
    -- blank while still showing the shipped map location.
    if entityID>=500000 then
        self.state.retailQuestEntityNameCache[entityID]=false
        return nil
    end

    local name
    if C_TooltipInfo and C_TooltipInfo.GetHyperlink then
        local link=("unit:Creature-0-0-0-0-%d-0000000000"):format(entityID)
        local ok,data=pcall(C_TooltipInfo.GetHyperlink,link)
        if ok then name=retailTooltipFirstText(data) end
    end
    if name and name~="" then
        self.state.retailQuestEntityNameCache[entityID]=name
        return name
    end

    -- Unit tooltip data can arrive asynchronously. Retry only a couple of times
    -- for the selected source; never fan this out across search/catalog entries.
    self.state.retailQuestEntityNameAttempts=self.state.retailQuestEntityNameAttempts or {}
    local attempts=self.state.retailQuestEntityNameAttempts[entityID] or 0
    if attempts<2 and C_Timer and C_Timer.After then
        self.state.retailQuestEntityNameAttempts[entityID]=attempts+1
        self.state.sourceResolvePending=true
        C_Timer.After(0.45+attempts*0.35,function()
            if not T or not T.state then return end
            T.state.retailQuestEntityNameCache[entityID]=nil
            local resolved=T:ResolveRetailQuestEntityName(entityID)
            if resolved and T.RefreshSelectedSourceAfterLearning then
                T:RefreshSelectedSourceAfterLearning()
            end
        end)
    else
        self.state.retailQuestEntityNameCache[entityID]=false
    end
    return nil
end


-- Normalize acquisition data into one predictable structure before formatting.
-- This layer intentionally distinguishes verified exact relationships from
-- partial source-type hints so HOW TO OBTAIN never presents guesses as facts.

function T:NormalizeDifficultyLabel(label)
    if not label or label=="" then return nil end
    local text=tostring(label)

    -- Transmoggin's MoP Classic client path should never surface LFR-style
    -- acquisition routes. Do not relabel them as Normal; simply exclude them.
    if self.clientMode=="mop-classic" then
        local lower=text:lower()
        if lower:find("looking for raid",1,true)
            or lower:find("raid finder",1,true)
            or lower=="lfr"
            or lower:find(" lfr",1,true)
            or lower:find("lfr ",1,true) then
            return nil
        end
    end

    return text
end

function T:NormalizeAcquisitionSources(rec)
    if not rec then return nil end

    local result={routes={},itemName=rec.itemName or rec.name,visualID=rec.visualID}
    local routeSeen={}
    local itemIDs,seenItems={},{}
    local sourceIDs,seenSources={},{}

    local function addItem(id)
        id=tonumber(id)
        if id and id>0 and not seenItems[id] then
            seenItems[id]=true
            itemIDs[#itemIDs+1]=id
        end
    end

    local function addSource(src)
        local sid=sourceIDOf(src)
        sid=tonumber(sid)
        if sid and sid>0 and not seenSources[sid] then
            seenSources[sid]=true
            sourceIDs[#sourceIDs+1]=sid
        end
        if type(src)=="table" then addItem(src.itemID) end
    end

    addItem(rec.itemID)
    addSource(rec.sourceID)
    addSource(rec.itemModID)
    for _,src in ipairs(rec.allSources or {}) do addSource(src) end

    for _,sid in ipairs(sourceIDs) do
        local itemID
        if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
            local ok,id=pcall(C_TransmogCollection.GetSourceItemID,sid)
            if ok then itemID=id end
        end
        if (not itemID or itemID==0) and self.GetGeneratedItemIDForModifiedAppearance then
            itemID=self:GetGeneratedItemIDForModifiedAppearance(sid)
        end
        addItem(itemID)
    end

    local function routeKey(r)
        return table.concat({
            tostring(r.type or ""),
            tostring(r.boss or ""),
            tostring(r.instance or ""),
            tostring(r.vendor or ""),
            tostring(r.quest or ""),
            tostring(r.questID or ""),
            tostring(r.starterID or ""),
            tostring(r.profession or ""),
            tostring(r.zone or ""),
            tostring(r.itemID or "")
        },"|")
    end

    local function mergeDifficulty(existing,newValue)
        if not newValue or newValue=="" then return end
        if not existing.difficulty or existing.difficulty=="" then
            existing.difficulty=newValue
            return
        end
        local seen={}
        local parts={}
        local function addPart(v)
            for part in tostring(v):gmatch("[^,]+") do
                part=(part:gsub("^%s+",""):gsub("%s+$",""))
                if part~="" and not seen[part] then
                    seen[part]=true
                    parts[#parts+1]=part
                end
            end
        end
        addPart(existing.difficulty)
        addPart(newValue)
        existing.difficulty=table.concat(parts,", ")
    end

    local function addRoute(r)
        if not r or not r.type then return end

        if r.difficulty then
            r.difficulty=self:NormalizeDifficultyLabel(r.difficulty)
        end

        -- On MoP Classic, a route that only exists as an LFR record is invalid
        -- for this client and is discarded rather than relabeled.
        if self.clientMode=="mop-classic" and r.difficulty==nil then
            -- Keep routes that never had a difficulty requirement at all.
            -- Drop only known boss routes whose original difficulty was LFR-like.
            local original=tostring(r._originalDifficulty or ""):lower()
            if original:find("looking for raid",1,true)
                or original:find("raid finder",1,true)
                or original=="lfr" then
                return
            end
        end

        local k=routeKey(r)
        local existing=routeSeen[k]
        if existing then
            mergeDifficulty(existing,r.difficulty)
            return
        end
        routeSeen[k]=r
        result.routes[#result.routes+1]=r
    end

    -- A merchant the player has physically opened is our strongest vendor
    -- evidence. The WoW client itself supplied the exact item/vendor/cost pairing.
    if self.GetVerifiedMerchantRecordsForAppearance then
        local merchantRecords=self:GetVerifiedMerchantRecordsForAppearance(visualID,itemIDs,sourceIDs)
        for _,learned in ipairs(merchantRecords) do
            local costText
            if type(learned.costs)=="table" and #learned.costs>0 then
                local costParts={}
                for _,c in ipairs(learned.costs) do
                    if c.type=="money" and c.text then
                        costParts[#costParts+1]=c.text
                    elseif (c.type=="currency" or c.type=="token") and c.amount then
                        costParts[#costParts+1]=tostring(c.amount).." "..tostring(c.name or "Currency")
                    end
                end
                if #costParts>0 then costText=table.concat(costParts," + ") end
            end

            local base=self:GetVerifiedMerchantRecord(learned._merchantItemID)
            local isPreferred=base
                and base.vendor==learned.vendor
                and tostring(base.zone or "")==tostring(learned.zone or "")

            addRoute({
                type="Vendor",
                vendor=learned.vendor,
                zone=learned.zone,
                faction=learned.faction,
                itemID=learned._merchantItemID,
                itemName=learned.itemName,
                cost=costText,
                confidence="VERIFIED",
                provider="Observed in-game merchant",
                locationVerified=true,
                preferred=isPreferred and true or false
            })
        end
    end

    -- Retail exact quest-reward graph. Blizzard ships CollectableSourceQuestSparse
    -- keyed by transmog sourceID, which is a better join than forcing Retail
    -- quest acquisition through an itemID database. The full table is generated
    -- offline and every lookup here is O(1) by the sourceIDs already on this appearance.
    if self.clientMode=="retail" and self.GeneratedRetailQuestBySource then
        for _,sid in ipairs(sourceIDs) do
            local qrow=self.GeneratedRetailQuestBySource[sid]
            if type(qrow)=="table" then
                local questID=tonumber(qrow[1])
                local giverID=tonumber(qrow[2])
                local giverMap=tonumber(qrow[3])
                local giverName=giverID and self:ResolveRetailQuestEntityName(giverID) or nil
                local giverZone=giverMap and self:GetRetailQuestMapName(giverMap) or nil
                local starters={}
                if giverID or giverZone or qrow[4] or qrow[5] then
                    starters[1]={giverID,giverName,giverZone,qrow[4],qrow[5],giverMap}
                end

                local enders={}
                local endRows=questID and self.GeneratedRetailQuestEnders and self.GeneratedRetailQuestEnders[questID]
                for _,erow in ipairs(type(endRows)=="table" and endRows or {}) do
                    local endID=tonumber(erow[1])
                    local endMap=tonumber(erow[2])
                    enders[#enders+1]={
                        endID,
                        endID and self:ResolveRetailQuestEntityName(endID) or nil,
                        endMap and self:GetRetailQuestMapName(endMap) or nil,
                        erow[3],erow[4],endMap
                    }
                end

                addRoute({
                    type="Quest Reward",
                    quest=self:ResolveRetailQuestTitle(questID),
                    questID=questID,
                    starters=starters,
                    enders=enders,
                    starterID=giverID,
                    sourceID=sid,
                    confidence="VERIFIED",
                    provider="Blizzard Retail client DB",
                    referenceProvider="Grail map/turn-in enrichment",
                    shipped=true
                })
            end
        end
    end

    -- Exact local DB relationships. Generated encounter rows are built from the
    -- client DB export and tied to the exact itemID.
    if self.GetSourceDBRecord then
        for _,itemID in ipairs(itemIDs) do
            local itemName=self.ResolveDisplayItemName and self:ResolveDisplayItemName(itemID) or nil
            local d=self:GetSourceDBRecord(itemID,itemName)
            if d then
                if type(d.encounters)=="table" and #d.encounters>0 then
                    for _,e in ipairs(d.encounters) do
                        if e.encounter or e.instance then
                            addRoute({
                                type="Boss Drop",
                                boss=e.encounter,
                                instance=e.instance,
                                zone=e.zone,
                                aliases=e.aliases,
                                difficulty=self:NormalizeDifficultyLabel(e.difficulty),
                                _originalDifficulty=e.difficulty,
                                itemID=itemID,
                                itemName=d.itemName or itemName,
                                confidence="VERIFIED",
                                provider="Local client DB",
                                zoneProvider=e.zoneProvider
                            })
                        end
                    end
                elseif d.encounter or d.instance then
                    addRoute({
                        type=d.kind or d.sourceKind or "Boss Drop",
                        boss=d.encounter,
                        instance=d.instance,
                        difficulty=self:NormalizeDifficultyLabel(d.difficulty),
                        _originalDifficulty=d.difficulty,
                        itemID=itemID,
                        itemName=d.itemName or itemName,
                        confidence="VERIFIED",
                        provider="Local client DB"
                    })
                end

                if d.vendor then
                    addRoute({
                        type="Vendor",
                        vendor=d.vendor,
                        zone=d.zone,
                        itemID=itemID,
                        itemName=d.itemName or itemName,
                        cost=d.amount and d.currency and (tostring(d.amount).." "..tostring(d.currency)) or nil,
                        confidence="HIGH CONFIDENCE",
                        provider="Transmoggin source DB"
                    })
                end
                if type(d.vendors)=="table" then
                    for _,v in ipairs(d.vendors) do
                        if v.name then
                            addRoute({
                                type="Vendor",
                                vendor=v.name,
                                zone=v.zone,
                                faction=v.faction,
                                itemID=itemID,
                                itemName=d.itemName or itemName,
                                confidence="HIGH CONFIDENCE",
                                provider="Transmoggin source DB"
                            })
                        end
                    end
                end
                if type(d.quests)=="table" and #d.quests>0 then
                    for _,q in ipairs(d.quests) do
                        addRoute({
                            type="Quest Reward", quest=q.quest, questID=q.questID, rewardType=q.rewardType,
                            quantity=q.quantity, giver=q.giver, ender=q.ender, zone=q.zone or d.zone,
                            starters=q.starters, enders=q.enders, itemID=itemID,
                            itemName=d.itemName or itemName, confidence=q.confidence or "VERIFIED",
                            provider=q.provider or "Blizzard Quest API", shipped=q.shipped
                        })
                    end
                elseif d.quest then
                    addRoute({
                        type="Quest Reward", quest=d.quest, questID=d.questID, zone=d.zone,
                        itemID=itemID, itemName=d.itemName or itemName, confidence="HIGH CONFIDENCE",
                        provider="Transmoggin source DB"
                    })
                elseif type(d.questPackages)=="table" and #d.questPackages>0 then
                    addRoute({
                        type="Quest Reward",
                        itemID=itemID,
                        itemName=d.itemName or itemName,
                        confidence="PARTIAL",
                        note="The client confirms this item is in quest reward data, but does not expose the exact quest title.",
                        provider="Local client DB"
                    })
                end
                if d.profession or d.recipe then
                    addRoute({
                        type="Profession / Crafted",
                        profession=d.profession,
                        recipe=d.recipe,
                        itemID=itemID,
                        itemName=d.itemName or itemName,
                        confidence=(d.profession and d.recipe) and "HIGH CONFIDENCE" or "PARTIAL",
                        provider="Local client DB"
                    })
                end
                if (d.kind=="World Drop" or d.sourceKind=="World Drop") then
                    addRoute({
                        type="World Drop",
                        zone=d.zone,
                        itemID=itemID,
                        itemName=d.itemName or itemName,
                        confidence=d.zone and "HIGH CONFIDENCE" or "PARTIAL",
                        provider="Transmoggin source DB"
                    })
                end
            end
        end
    end

    -- Retail authoritative encounter graph directly from Blizzard.
    if self.clientMode=="retail" and C_TransmogCollection and C_TransmogCollection.GetAppearanceSourceDrops then
        for _,sid in ipairs(sourceIDs) do
            local ok,drops=pcall(C_TransmogCollection.GetAppearanceSourceDrops,sid)
            if ok and type(drops)=="table" then
                for _,d in ipairs(drops) do
                    if d.encounter or d.instance then
                        local diffParts={}
                        if type(d.difficulties)=="table" then
                            for _,difficulty in ipairs(d.difficulties) do
                                local clean=self:NormalizeDifficultyLabel(difficulty)
                                if clean then diffParts[#diffParts+1]=clean end
                            end
                        end
                        local diffs=#diffParts>0 and table.concat(diffParts,", ") or nil
                        addRoute({
                            type="Boss Drop",
                            boss=d.encounter,
                            instance=d.instance,
                            difficulty=diffs,
                            confidence="VERIFIED",
                            provider="Blizzard Wardrobe API"
                        })
                    end
                end
            end
        end
    end

    -- Generic Blizzard source types (Dungeon / Raid, Vendor, Quest, etc.)
    -- are evidence for the resolver, NOT actionable acquisition answers.
    -- Never promote them into HOW TO OBTAIN unless we also know a concrete
    -- boss/instance/vendor/quest/profession/location relationship.
    if #result.routes==0 then return nil end

    local confidencePriority={VERIFIED=1,["HIGH CONFIDENCE"]=2,PARTIAL=3}
    local typePriority={
        ["Boss Drop"]=1,
        ["World Boss"]=2,
        ["Vendor"]=3,
        ["Quest Reward"]=4,
        ["Profession / Crafted"]=5,
        ["World Drop"]=6,
        ["Dungeon / Raid"]=7,
        ["Achievement"]=8,
        ["Trading Post"]=9,
    }

    table.sort(result.routes,function(a,b)
        if (a.preferred and true or false)~=(b.preferred and true or false) then
            return a.preferred and true or false
        end

        local ac=confidencePriority[a.confidence] or 9
        local bc=confidencePriority[b.confidence] or 9
        if ac~=bc then return ac<bc end

        local at=typePriority[a.type] or 20
        local bt=typePriority[b.type] or 20
        if at~=bt then return at<bt end

        local ak=(a.instance or "")..(a.boss or "")..(a.vendor or "")..(a.quest or "")
        local bk=(b.instance or "")..(b.boss or "")..(b.vendor or "")..(b.quest or "")
        return ak<bk
    end)

    return result
end


function T:IsActionableAcquisitionRoute(r)
    if not r or not r.type then return false end

    if r.type=="Boss Drop" or r.type=="World Boss" or r.type=="Dungeon / Raid" then
        return (r.boss and r.boss~="") or (r.instance and r.instance~="")
    elseif r.type=="Vendor" then
        return r.vendor and r.vendor~=""
    elseif r.type=="Quest Reward" then
        return (r.quest and r.quest~="") or (r.zone and r.zone~="")
    elseif r.type=="Profession / Crafted" then
        return (r.profession and r.profession~="") or (r.recipe and r.recipe~="")
    elseif r.type=="World Drop" then
        return r.zone and r.zone~=""
    elseif r.type=="Achievement" then
        return (r.achievement and r.achievement~="") or (r.note and r.note~="")
    elseif r.type=="Trading Post" then
        return true
    end

    return (r.boss and r.boss~="")
        or (r.instance and r.instance~="")
        or (r.vendor and r.vendor~="")
        or (r.quest and r.quest~="")
        or (r.profession and r.profession~="")
        or (r.recipe and r.recipe~="")
        or (r.zone and r.zone~="")
end

function T:FormatNormalizedAcquisition(rec)
    local normalized=self:NormalizeAcquisitionSources(rec)
    if not normalized or not normalized.routes then return nil end

    local actionable={}
    for _,r in ipairs(normalized.routes) do
        if self:IsActionableAcquisitionRoute(r) then
            actionable[#actionable+1]=r
        end
    end
    if #actionable==0 then return nil end

    local lines={}
    local function add(v) if v and v~="" then lines[#lines+1]=v end end
    add("HOW TO OBTAIN")

    local shown=math.min(#actionable,5)
    for i=1,shown do
        local r=actionable[i]
        if i>1 then add("") end

        if i==1 then
            add(r.preferred and "BEST METHOD - VERIFIED VENDOR" or "BEST ROUTE")
        else
            add("OPTION "..tostring(i))
        end

        if r.type=="Boss Drop" or r.type=="World Boss" then
            add("Source: "..tostring(r.type))
            if r.boss then add("Boss: "..r.boss) end
            if r.instance then add("Instance: "..r.instance) end
            if r.zone and r.zone~=r.instance then add("Zone: "..r.zone) end
            if r.difficulty then add("Difficulty: "..r.difficulty) end
            if r.confidence and r.provider then add("Verification: "..r.confidence.." - "..r.provider) end
            if r.zoneProvider then add("Location data: REFERENCE - "..r.zoneProvider) end
        elseif r.type=="Vendor" then
            add("Source: Vendor")
            local vendor=r.vendor
            if r.zone then vendor=vendor.." - "..r.zone end
            if r.faction then vendor=vendor.." ["..r.faction.."]" end
            add("Vendor: "..vendor)
            if r.cost then add("Cost: "..r.cost) end
        elseif r.type=="Quest Reward" then
            local debugMode=self.db and self.db.profile and self.db.profile.debugMode
            add("Source: Quest Reward")
            if r.quest then add("Quest: "..r.quest) end

            if type(r.starters)=="table" and #r.starters>0 then
                for _,g in ipairs(r.starters) do
                    local loc=g[3] or (g[6] and self.GetRetailQuestMapName and self:GetRetailQuestMapName(g[6]))
                    if g[4] and g[5] then loc=(loc and (loc.." ") or "")..string.format("(%.2f, %.2f)",g[4],g[5]) end
                    local giverName=g[2] or (g[1] and self.ResolveRetailQuestEntityName and self:ResolveRetailQuestEntityName(g[1]))
                    if giverName then
                        local line="Quest Giver: "..giverName
                        if loc and loc~="" then line=line.." - "..loc end
                        if debugMode and g[1] then line=line.." [NPC "..tostring(g[1]).."]" end
                        add(line)
                    elseif loc and loc~="" then
                        add("Quest Giver Location: "..loc)
                    end
                end
            elseif r.giver then
                local line="Quest Giver: "..r.giver
                if r.zone then line=line.." - "..r.zone end
                add(line)
            elseif r.zone then
                add("Quest Giver Location: "..r.zone)
            end

            if type(r.enders)=="table" and #r.enders>0 then
                for _,e in ipairs(r.enders) do
                    local loc=e[3] or (e[6] and self.GetRetailQuestMapName and self:GetRetailQuestMapName(e[6]))
                    if e[4] and e[5] then loc=(loc and (loc.." ") or "")..string.format("(%.2f, %.2f)",e[4],e[5]) end
                    local enderName=e[2] or (e[1] and self.ResolveRetailQuestEntityName and self:ResolveRetailQuestEntityName(e[1]))
                    if enderName then
                        local line="Turn In: "..enderName
                        if loc and loc~="" then line=line.." - "..loc end
                        if debugMode and e[1] then line=line.." [NPC "..tostring(e[1]).."]" end
                        add(line)
                    elseif loc and loc~="" then
                        add("Turn-In Location: "..loc)
                    end
                end
            elseif r.ender then
                add("Turn In: "..r.ender)
            end

            if debugMode then
                if r.questID then add("Quest ID: "..tostring(r.questID)) end
                if r.rewardType then add("Reward: "..r.rewardType) end
                if r.confidence and r.provider then
                    if r.confidence=="REFERENCE" then add("Reference: "..r.provider) else add("Verification: "..r.confidence.." - "..r.provider) end
                end
                if r.referenceProvider then add("Location reference: "..r.referenceProvider) end
            end
        elseif r.type=="Profession / Crafted" then
            add("Source: Crafted")
            if r.profession then add("Profession: "..r.profession) end
            if r.recipe then add("Recipe: "..r.recipe) end
        elseif r.type=="World Drop" then
            add("Source: World Drop")
            if r.zone then add("Location: "..r.zone) end
        else
            add("Source: "..tostring(r.type))
            if r.boss then add("Boss: "..r.boss) end
            if r.instance then add("Location: "..r.instance) end
            if r.vendor then add("Vendor: "..r.vendor) end
            if r.quest then add("Quest: "..r.quest) end
            if r.zone and not r.instance then add("Location: "..r.zone) end
        end

        if r.itemName then add("Item: "..r.itemName) end
    end

    if #actionable>shown then
        add("")
        add("+ "..tostring(#actionable-shown).." additional acquisition route(s)")
    end

    return table.concat(lines,"\n")
end


function T:RefreshSelectedSourceAfterLearning()
    if not self.state then return end

    -- Invalidate source/acquisition caches so freshly learned merchant data
    -- is used immediately.
    self.state.sourceCache={}
    self.state.acquisitionTextCache={}
    self.state.authoritativeSearchCache={}

    local rec=self.state.selectedRecord
    if rec and self.main and self.main:IsShown() then
        -- Re-resolve the currently selected appearance from the same visual/source.
        local fresh=self:GetSourceRecord(
            rec.visualID,
            rec.categoryID or self.state.selectedCategory,
            rec.sourceID or rec.itemModID,
            self:GetSlotDef(self.state.selectedSlot)
        )
        if fresh then
            self.state.selectedRecord=fresh
            if self.ChooseAppearance then
                self:ChooseAppearance(fresh)
            elseif self.RefreshWishlistButton then
                self:RefreshWishlistButton()
            end
        end
    end

    if self.RefreshAppearanceGrid and self.main and self.main:IsShown() then
        self:RefreshAppearanceGrid()
    end
end

function T:RefreshAcquisitionAfterMerchantScan()
    if not self.state then return end
    self.state.acquisitionTextCache={}
    self.state.authoritativeSearchCache={}
    self.state.sourceCache={}

    local rec=self.state.selectedRecord
    if rec then
        rec.sourceText=nil
        rec.sourceTextPending=nil
        -- Force the exact current record through the normalized resolver now.
        self:EnsureSourceText(rec)
    end

    -- If the salon is visible, repaint the detail pane/grid as well.
    if self.main and self.main:IsShown() then
        if self.RefreshAppearanceGrid then self:RefreshAppearanceGrid() end
        if self.RefreshWishlistButton then self:RefreshWishlistButton() end
    end
end

function T:EnsureSourceText(rec)
    if not rec then return "Source unavailable." end
    self.state.acquisitionTextCache=self.state.acquisitionTextCache or {}
    local generation=(self.db and self.db.merchantIntelGeneration) or 0
    local k=tostring(generation)..":"..tostring(rec.itemID or 0)..":"..tostring(rec.itemModID or rec.sourceID or 0)..":"..tostring(rec.visualID or 0)
    if self.state.acquisitionTextCache[k] then return self.state.acquisitionTextCache[k] end

    -- sourceText may have been built before a merchant was visited. Always run
    -- normalized acquisition again so newly verified merchant evidence can win.
    self.state.sourceResolvePending=false
    local text=self:FormatNormalizedAcquisition(rec)
    if not text or text=="" then
        text=self:GetAcquisitionText(rec)
        local lower=type(text)=="string" and text:lower() or ""
        local genericOnly =
            lower:find("exact source relationship is not available",1,true)
            or lower:find("source type, but",1,true)
        if genericOnly then
            text="HOW TO OBTAIN\nExact acquisition source not yet resolved."
        end
    end
    local pending=self.state.sourceResolvePending and true or false
    self.state.sourceResolvePending=false
    rec.sourceText=text
    rec.sourceTextPending=pending
    if not pending then self.state.acquisitionTextCache[k]=text end
    return text
end

function T:GetAcquisitionText(rec)
    if not rec then return "Source unavailable." end
    -- Resolve every item that can unlock this appearance, not only the source tile
    -- currently selected. This lets an appearance expose boss/craft/quest alternatives.
    if self.FormatDBSource then
        local itemIDs,seenItems={},{}
        local function addItem(id)
            id=tonumber(id)
            if id and id>0 and not seenItems[id] then seenItems[id]=true; itemIDs[#itemIDs+1]=id end
        end
        addItem(rec.itemID)
        if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
            for _,src in ipairs(rec.allSources or {}) do
                local sid=sourceIDOf(src)
                if sid then
                    local ok,id=pcall(C_TransmogCollection.GetSourceItemID,sid)
                    if ok then addItem(id) end
                    if (not ok or not id or id==0) and self.GetGeneratedItemIDForModifiedAppearance then
                        addItem(self:GetGeneratedItemIDForModifiedAppearance(sid))
                    end
                end
            end
        end
        local blocks={}
        for _,itemID in ipairs(itemIDs) do
            local itemName=self:ResolveDisplayItemName(itemID)
            local text=self:FormatDBSource({itemID=itemID,itemName=itemName,name=itemName})
            if text and text~="" then blocks[#blocks+1]=text end
            if #blocks>=4 then break end
        end
        if #blocks==1 then return blocks[1] end
        if #blocks>1 then
            local out={}
            for i,text in ipairs(blocks) do
                out[#out+1]="OPTION "..tostring(i)
                out[#out+1]=text
                if i<#blocks then out[#out+1]="" end
            end
            return table.concat(out,"\n")
        end
    end
    -- Curated source intelligence fills gaps Blizzard does not expose (vendor names,
    -- faction routing, and currencies). It wins when a verified record exists.
    if self.FormatSourceIntel then
        local enriched=self:FormatSourceIntel(rec)
        if enriched and enriched~="" then return enriched end
    end
    local ids={}
    if rec.itemModID then ids[#ids+1]=rec.itemModID end
    for _,src in ipairs(rec.allSources or {}) do local sid=sourceIDOf(src); if sid then ids[#ids+1]=sid end end

    local lines={}
    local seen={}
    local function add(line)
        if line and line~="" and not seen[line] then seen[line]=true; lines[#lines+1]=line end
    end

    -- Instance/boss sources. Blizzard exposes the exact encounter, instance and
    -- difficulties for these sources, so show them as structured farming intel.
    if self.cap.sourceDrops and C_TransmogCollection and C_TransmogCollection.GetAppearanceSourceDrops then
        local dropRows={}
        local dropSeen={}
        for _,sid in ipairs(ids) do
            local ok,drops=pcall(C_TransmogCollection.GetAppearanceSourceDrops,sid)
            if ok and type(drops)=="table" then
                for _,d in ipairs(drops) do
                    local encounter=d.encounter or ""
                    local instance=d.instance or ""
                    local diffs=(type(d.difficulties)=="table" and #d.difficulties>0) and table.concat(d.difficulties,", ") or ""
                    local tiers=(type(d.tiers)=="table" and #d.tiers>0) and table.concat(d.tiers,", ") or ""
                    local key=instance.."|"..encounter.."|"..diffs
                    if key~="||" and not dropSeen[key] then
                        dropSeen[key]=true
                        dropRows[#dropRows+1]={instance=instance,encounter=encounter,diffs=diffs,tiers=tiers,instanceType=d.instanceType}
                    end
                end
            end
        end
        if #dropRows>0 then
            add("HOW TO OBTAIN")
            for i=1,math.min(#dropRows,5) do
                local d=dropRows[i]
                local kind="BOSS DROP"
                local inst=(d.instance or ""):lower()
                if inst:find("world",1,true) or inst:find("outdoor",1,true) then kind="WORLD BOSS" end
                add(kind..": "..((d.encounter~="" and d.encounter) or "Unknown encounter"))
                if d.instance~="" then add("Location: "..d.instance) end
                if d.diffs~="" then add("Difficulty: "..d.diffs) end
                if d.tiers~="" then add("Expansion/Tier: "..d.tiers) end
                if i<#dropRows and i<5 then add(" ") end
            end
            if #dropRows>5 then add("+ "..(#dropRows-5).." more boss source(s)") end
            if rec.itemName then add("Item: "..rec.itemName) end
            return table.concat(lines,"\n")
        end
    end


    -- Retail has a much richer appearance/source graph than older clients.  Build
    -- a detailed source summary from every known source for this appearance before
    -- falling back to a generic source-type label.  This is deliberately lazy: it
    -- only runs for the selected/hovered appearance and is cached by EnsureSourceText.
    if self.clientMode=="retail" and C_TransmogCollection then
        local retailSources={}
        local retailSeen={}
        local function addRetailSource(src)
            local sid=sourceIDOf(src)
            if not sid or retailSeen[sid] then return end
            retailSeen[sid]=true
            local info=type(src)=="table" and src or nil
            if C_TransmogCollection.GetSourceInfo then
                local ok,full=pcall(C_TransmogCollection.GetSourceInfo,sid)
                if ok and type(full)=="table" then
                    if not info then info={} end
                    for k,v in pairs(full) do if info[k]==nil then info[k]=v end end
                end
            end
            info=info or {sourceID=sid}
            info.sourceID=info.sourceID or sid
            retailSources[#retailSources+1]=info
        end
        for _,src in ipairs(rec.allSources or {}) do addRetailSource(src) end
        if C_TransmogCollection.GetAllAppearanceSources and rec.visualID then
            local ok,all=pcall(C_TransmogCollection.GetAllAppearanceSources,rec.visualID)
            if ok and type(all)=="table" then for _,src in ipairs(all) do addRetailSource(src) end end
        end
        if #retailSources>0 then
            local sourceTypeNames={
                [1]="Dungeon / Raid",[2]="Quest",[3]="Vendor",[4]="World Drop",
                [5]="Hidden",[6]="Unavailable",[7]="Achievement",[8]="Profession / Crafted",
                [9]="Invalid",[10]="Trading Post",
            }
            local byType, typeOrder={},{}
            local itemNames,itemSeen={},{}
            local setNames,setSeen={},{}
            local variantSeen,variants={},{}
            local reqSeen,requirements={},{}
            for _,info in ipairs(retailSources) do
                local st=tonumber(info.sourceType) or 0
                if not byType[st] then byType[st]={}; typeOrder[#typeOrder+1]=st end
                byType[st][#byType[st]+1]=info
                local iid=tonumber(info.itemID)
                if iid and iid>0 and not itemSeen[iid] then
                    itemSeen[iid]=true
                    local nm=self:ResolveDisplayItemName(iid)
                    itemNames[#itemNames+1]={id=iid,name=nm or "Loading item name...",sourceType=st,sourceID=info.sourceID}
                end
                local mod=tonumber(info.itemModID)
                if mod and not variantSeen[mod] then
                    variantSeen[mod]=true
                    local labels={[0]="Normal / Base",[1]="Heroic",[3]="Mythic / Elite",[4]="Raid Finder"}
                    variants[#variants+1]=labels[mod] or ("Item modifier "..mod)
                end
                if info.useError and info.useError~="" and not reqSeen[info.useError] then reqSeen[info.useError]=true; requirements[#requirements+1]=info.useError end
                if C_TransmogSets and C_TransmogSets.GetSetsContainingSourceID and info.sourceID then
                    local ok,sets=pcall(C_TransmogSets.GetSetsContainingSourceID,info.sourceID)
                    if ok and type(sets)=="table" then
                        for _,setID in ipairs(sets) do
                            if not setSeen[setID] then
                                setSeen[setID]=true
                                local setInfo
                                if C_TransmogSets.GetSetInfo then local ok2,v=pcall(C_TransmogSets.GetSetInfo,setID); if ok2 then setInfo=v end end
                                if type(setInfo)=="table" and setInfo.name then
                                    local label=setInfo.name
                                    if setInfo.description and setInfo.description~="" then label=label.." ("..setInfo.description..")" end
                                    setNames[#setNames+1]=label
                                end
                            end
                        end
                    end
                end
            end
            table.sort(typeOrder)
            add("HOW TO OBTAIN")
            if rec.itemName then add("Selected item: "..rec.itemName) end
            if #typeOrder>0 then
                local labels={}
                for _,st in ipairs(typeOrder) do labels[#labels+1]=sourceTypeNames[st] or ("Source type "..st) end
                add("Source type(s): "..table.concat(labels,", "))
            end
            if #setNames>0 then
                add("Set: "..table.concat(setNames,", ",1,math.min(#setNames,3)))
                if #setNames>3 then add("+ "..tostring(#setNames-3).." additional set association(s)") end
            end
            if #variants>1 then add("Appearance variants: "..table.concat(variants,", ")) end
            if #itemNames>0 then
                add("")
                add("ITEMS THAT UNLOCK THIS APPEARANCE")
                for i=1,math.min(#itemNames,8) do
                    local it=itemNames[i]
                    local kind=sourceTypeNames[it.sourceType] or "Source"
                    add("  "..it.name.." — "..kind)
                end
                if #itemNames>8 then add("  +"..tostring(#itemNames-8).." additional item source(s)") end
            end
            if #requirements>0 then
                add("")
                add("REQUIREMENTS")
                for i=1,math.min(#requirements,3) do add("  "..requirements[i]) end
            end
            local holidaySeen=false
            if C_TransmogCollection.GetSourceRequiredHoliday then
                for _,info in ipairs(retailSources) do
                    if info.sourceID then
                        local ok,h=pcall(C_TransmogCollection.GetSourceRequiredHoliday,info.sourceID)
                        if ok and h and h~="" then add("Available during: "..h); holidaySeen=true; break end
                    end
                end
            end
            -- If Blizzard exposes structured encounter drops, append them even when
            -- another source type also exists for the same appearance.
            if C_TransmogCollection.GetAppearanceSourceDrops then
                local seenDrop,dropCount={},0
                for _,info in ipairs(retailSources) do
                    if info.sourceID then
                        local ok,drops=pcall(C_TransmogCollection.GetAppearanceSourceDrops,info.sourceID)
                        if ok and type(drops)=="table" then
                            for _,d in ipairs(drops) do
                                local key=tostring(d.instance or "").."|"..tostring(d.encounter or "")
                                if key~="|" and not seenDrop[key] then
                                    if dropCount==0 then add(""); add("ENCOUNTER SOURCES") end
                                    seenDrop[key]=true; dropCount=dropCount+1
                                    local line="  "..(d.encounter or "Encounter")
                                    if d.instance and d.instance~="" then line=line.." — "..d.instance end
                                    if type(d.difficulties)=="table" and #d.difficulties>0 then line=line.." ["..table.concat(d.difficulties,", ").."]" end
                                    add(line)
                                    if dropCount>=6 then break end
                                end
                            end
                        end
                    end
                    if dropCount>=6 then break end
                end
            end
            add("")
            add("Retail source graph: Blizzard Wardrobe APIs + Transmoggin local data")
            return table.concat(lines,"\n")
        end
    end

    local typeNames={
        [1]="Dungeon / raid encounter", [2]="Quest reward", [3]="Vendor",
        [4]="World drop", [5]="Hidden until collected", [6]="Not collectible on this character",
        [7]="Achievement reward", [8]="Profession / crafted", [9]="Not valid for transmog",
        [10]="Trading Post",
    }
    local found={}
    local function addType(v) if v and typeNames[v] then found[v]=true end end
    addType(rec.sourceType)
    for _,src in ipairs(rec.allSources or {}) do
        if type(src)=="table" then addType(src.sourceType) end
        local sid=sourceIDOf(src)
        if sid and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
            local ok,inf=pcall(C_TransmogCollection.GetSourceInfo,sid)
            if ok and type(inf)=="table" then
                addType(inf.sourceType)
                if not rec.useError and inf.useError then rec.useError=inf.useError end
            end
        end
    end

    add("HOW TO OBTAIN")
    if found[2] then
        add("Source: Quest reward")
        add("Exact quest: not present in the local client DB2 export for this item yet.")
    elseif found[4] then
        add("Source: World drop")
        add("Exact mob / elite: not present in the local client DB2 export for this item yet.")
    elseif found[3] then
        add("Source: Vendor")
        add("Vendor inventory / currency: not present in the local client DB2 export for this item yet.")
    elseif found[8] then
        add("Source: Profession / crafted")
        add("Profession / recipe: not present in the local client DB2 export for this item yet.")
    elseif found[7] then
        add("Source: Achievement reward")
    elseif found[10] then
        add("Source: Trading Post")
    elseif found[1] then
        add("Source: Dungeon / raid encounter")
    else
        local kinds={}; for id in pairs(found) do kinds[#kinds+1]=typeNames[id] end; table.sort(kinds)
        if #kinds>0 then add("Source: "..table.concat(kinds,", ")) end
    end

    if rec.itemName then add("Item: "..rec.itemName) end
    if rec.itemID and self.GeneratedItemSourceDB then
        local meta=self.GeneratedItemSourceDB[rec.itemID]
        if meta and meta.s and self.GeneratedSetSources then
            local names={}
            for i=1,math.min(#meta.s,3) do
                local row=self.GeneratedSetSources[meta.s[i]]
                if row and row[1] then names[#names+1]=row[1] end
            end
            if #names>0 then add("Appearance set: "..table.concat(names,", ")) end
        end
    end
    if rec.useError and rec.useError~="" then add("Requirement: "..rec.useError) end
    if C_TransmogCollection and C_TransmogCollection.GetSourceRequiredHoliday and rec.itemModID then
        local ok,h=pcall(C_TransmogCollection.GetSourceRequiredHoliday,rec.itemModID)
        if ok and h and h~="" then add("Available during: "..h) end
    end
    if #lines>1 then return table.concat(lines,"\n") end
    return rec.collected and "Collected - detailed source data is not exposed by this client." or "Uncollected - detailed source data is not exposed by this client."
end

function T:GetWowheadURL(rec)
    -- Kept only for backward compatibility with older saved UI references.
    -- Transmoggin now displays bundled source intelligence directly in the salon.
    return nil
end

function T:ShowWowheadLink(rec)
    self:Print("Source information is now displayed directly in the item panel.")
end

function T:ShowAppearanceTooltip(owner,rec)
    if not rec then return end
    rec.collected=self:IsPieceCollected(rec)
    GameTooltip:SetOwner(owner,"ANCHOR_RIGHT"); GameTooltip:ClearLines()
    GameTooltip:SetText(rec.itemName or rec.name or "Item",1,0.82,0.30)
    local d=(rec.itemID and self.GetSourceDBRecord) and self:GetSourceDBRecord(rec.itemID,rec.itemName or rec.name) or nil
    local kind=d and (d.kind or d.sourceKind) or nil
    if kind then GameTooltip:AddLine("Source: "..kind,0.90,0.90,0.90,true) end
    if d and d.vendor then
        GameTooltip:AddLine("Vendor: "..d.vendor..(d.zone and (" - "..d.zone) or ""),0.75,0.85,1,true)
    elseif d and (d.kind=="Vendor" or d.sourceKind=="Vendor") and self.GetVendorGuide then
        local guide=self:GetVendorGuide(rec)
        if guide and guide.whereToGo then GameTooltip:AddLine(guide.whereToGo,0.75,0.85,1,true) end
    elseif d and d.encounter then
        GameTooltip:AddLine("Drops from: "..d.encounter..(d.instance and (" - "..d.instance) or ""),0.75,0.85,1,true)
    elseif d and d.profession then
        GameTooltip:AddLine("Crafted by: "..d.profession,0.75,0.85,1,true)
    end
    GameTooltip:AddLine("See HOW TO OBTAIN below for full details.",0.55,0.75,0.95,true)
    GameTooltip:Show()
end
