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

-- Transmoggin Source DB
-- Fast, local, itemID-keyed acquisition data. Static records can be generated
-- offline and shipped with the addon. Learned merchant records are stored in
-- SavedVariables so the addon never needs web access while WoW is running.
T.SourceDB=T.SourceDB or {}

local function trim(s)
    if type(s)~="string" then return s end
    return (s:gsub("^%s+",""):gsub("%s+$",""))
end

function T:GetSourceDBRecord(itemID,itemName)
    local static=itemID and self.SourceDB[itemID] or nil
    if not static and itemName and self.GetSourceNameRecord then static=self:GetSourceNameRecord(itemName) end
    local learned=itemID and self.db and self.db.sourceIntel and self.db.sourceIntel[itemID] or nil
    local allowGenerated=(not self.clientMode) or self.clientMode=="mop-classic"
    local generated=allowGenerated and itemID and self.GeneratedItemSourceDB and self.GeneratedItemSourceDB[itemID] or nil
    local aux=allowGenerated and itemID and self.GeneratedAuxSourceDB and self.GeneratedAuxSourceDB[itemID] or nil
    local meta=allowGenerated and itemID and self.GeneratedClientItemMeta and self.GeneratedClientItemMeta[itemID] or nil
    -- Set membership by itself is useful metadata but is not an acquisition source.
    -- Only promote generated client data when the Encounter Journal ties this exact
    -- itemID to one or more encounters.
    local hasGeneratedEncounter=generated and type(generated.e)=="table" and #generated.e>0
    local hasGeneratedMeta=generated~=nil
    if not static and not learned and not hasGeneratedMeta and not aux and not meta then return nil end
    local out={}
    if type(static)=="table" then for k,v in pairs(static) do out[k]=v end end
    if generated then
        if not out.itemName and self.GeneratedItemNames then out.itemName=self.GeneratedItemNames[itemID] end
        out.appearanceID=out.appearanceID or generated.a
        out.modifiedAppearanceID=out.modifiedAppearanceID or generated.m
        if hasGeneratedEncounter and self.GeneratedEncounterSources then
            out.kind=out.kind or "Boss Drop"
            out.encounters=out.encounters or {}
            for _,sourceIndex in ipairs(generated.e) do
                local row=self.GeneratedEncounterSources[sourceIndex]
                if row then
                    local d={encounter=row[1],instance=row[2],sourceIndex=sourceIndex}
                    if row[3] and row[3]~="" then d.difficulty=row[3] end
                    -- Phase 1 encounter intelligence is a compact source-indexed
                    -- enrichment generated offline. It never scans the item DB.
                    local intel=self.GeneratedEncounterIntel and self.GeneratedEncounterIntel[sourceIndex] or nil
                    if intel then
                        d.aliases=intel.a
                        d.zone=intel.z
                        d.zoneProvider=intel.zp
                    end
                    out.encounters[#out.encounters+1]=d
                end
            end
            local first=out.encounters[1]
            if first then
                out.encounter=out.encounter or first.encounter
                out.instance=out.instance or first.instance
                out.difficulty=out.difficulty or first.difficulty
            end
        end
        if generated.s and self.GeneratedSetSources then
            out.sets=out.sets or {}
            for _,setID in ipairs(generated.s) do
                local row=self.GeneratedSetSources[setID]
                if row then out.sets[#out.sets+1]={name=row[1],expansionID=row[2]} end
            end
        end
        out.clientDB=true
    end
    if meta then
        -- Positional compact metadata generated from the complete local DB export:
        -- 1 source mask, 2 faction ID, 3 rep rank, 4 req level, 5 req skill,
        -- 6 req skill rank, 7 req ability, 8 base buy price, 9 currency-cost flag,
        -- 10 starts quest ID, 11 opposite-faction item ID.
        local mask=tonumber(meta[1]) or 0
        out.sourceTypes=out.sourceTypes or {}
        for sourceType=0,10 do
            local bit=2^sourceType
            if math.floor(mask/bit)%2==1 then out.sourceTypes[#out.sourceTypes+1]=sourceType end
        end
        local priority={1,8,2,3,4,7,10,6,5,9,0}
        if not out.kind and self.GeneratedSourceTypeNames then
            for _,sourceType in ipairs(priority) do
                for _,present in ipairs(out.sourceTypes) do
                    if present==sourceType then out.kind=self.GeneratedSourceTypeNames[sourceType]; break end
                end
                if out.kind then break end
            end
        end
        out.requiredFactionID=meta[2] or out.requiredFactionID
        if out.requiredFactionID and out.requiredFactionID~=0 and self.GeneratedFactionNames then out.requiredFactionName=self.GeneratedFactionNames[out.requiredFactionID] end
        out.requiredReputationRank=meta[3] or out.requiredReputationRank
        if out.requiredReputationRank and self.GeneratedRepNames then out.requiredReputationName=self.GeneratedRepNames[out.requiredReputationRank] end
        out.requiredLevel=meta[4] or out.requiredLevel
        out.requiredSkillID=meta[5] or out.requiredSkillID
        out.requiredSkillRank=meta[6] or out.requiredSkillRank
        out.requiredAbility=meta[7] or out.requiredAbility
        out.baseBuyPrice=meta[8] or out.baseBuyPrice
        out.hasCurrencyCost=(meta[9]==1) or out.hasCurrencyCost
        out.startsQuestID=meta[10] or out.startsQuestID
        out.oppositeFactionItemID=meta[11] or out.oppositeFactionItemID
        out.fullClientDB=true
    end
    if aux then
        if not out.itemName then out.itemName=aux.n end
        if aux.k==1 then
            out.kind=out.kind or "Crafted"
            out.profession=out.profession or aux.p
            out.recipe=out.recipe or aux.r
            out.recipeSpellID=out.recipeSpellID or aux.sp
            out.skillRank=out.skillRank or aux.rk
            if aux.g then
                out.reagents=out.reagents or {}
                for _,g in ipairs(aux.g) do
                    local reagentID,count=g[1],g[2]
                    local reagentName=self.GeneratedAuxReagentNames and self.GeneratedAuxReagentNames[reagentID] or nil
                    out.reagents[#out.reagents+1]={itemID=reagentID,count=count,name=reagentName}
                end
            end
            out.clientCraftDB=true
        end
        if aux.q then
            out.questPackages=out.questPackages or {}
            for _,q in ipairs(aux.q) do out.questPackages[#out.questPackages+1]={packageID=q[1],quantity=q[2]} end
            if not out.kind then out.kind="Quest Reward" end
            out.clientQuestPackageDB=true
        end
    end
    -- Alpha 36 P2: shipped exact quest reward relationships are authoritative
    -- and available immediately on first install. Live quest learning remains an
    -- enrichment/verification layer, not a prerequisite for HOW TO OBTAIN.
    local shipped=itemID and self.GeneratedQuestRewardByItem and self.GeneratedQuestRewardByItem[itemID] or nil
    if type(shipped)=="table" and #shipped>0 then
        out.quests=out.quests or {}
        for _,q in ipairs(shipped) do
            local clientOK=(not q.cl) or (q.cl=="mop" and self.IsMoPClassic and self:IsMoPClassic()) or (q.cl=="retail" and self.IsRetail and self:IsRetail())
            if clientOK then
                local firstStarter=type(q.s)=="table" and q.s[1] or nil
                local firstEnder=type(q.e)=="table" and q.e[1] or nil
                out.quests[#out.quests+1]={
                    questID=q.q, quest=q.n, rewardType=q.t,
                    giver=q.g or (firstStarter and firstStarter[2]),
                    ender=(type(q.e)=="string" and q.e) or (firstEnder and firstEnder[2]),
                    zone=q.z or (firstStarter and firstStarter[3]),
                    starters=q.s, enders=type(q.e)=="table" and q.e or nil,
                    confidence=q.c or "VERIFIED", provider=q.p or "Transmoggin shipped quest corpus",
                    shipped=true,
                }
            end
        end
        local q=out.quests[1]
        if q then
            out.quest=out.quest or q.quest
            out.questID=out.questID or q.questID
            out.kind=out.kind or "Quest Reward"
            out.shippedQuestVerified=true
        end
    end

    local exactQuests=itemID and self.GetQuestIntelForItem and self:GetQuestIntelForItem(itemID) or nil
    if type(exactQuests)=="table" and #exactQuests>0 then
        out.quests=out.quests or {}
        local seen={}
        for _,q in ipairs(out.quests) do seen[tostring(q.questID or "")..":"..tostring(q.rewardType or "")]=true end
        for _,q in ipairs(exactQuests) do
            local key=tostring(q.questID or "")..":"..tostring(q.rewardType or "")
            if not seen[key] then out.quests[#out.quests+1]=q; seen[key]=true end
        end
        local q=exactQuests[1]
        out.quest=out.quest or q.quest
        out.questID=out.questID or q.questID
        out.kind=out.kind or "Quest Reward"
        out.clientQuestAPIVerified=true
    end
    if type(learned)=="table" then
        for k,v in pairs(learned) do
            if learned.merchantVerified and (k=="vendor" or k=="zone" or k=="faction" or k=="costs" or k=="kind" or k=="itemName" or k=="merchantVerified" or k=="updated" or k=="merchants") then
                out[k]=v
            elseif out[k]==nil then
                out[k]=v
            end
        end
    end
    return out
end

local function moneyText(copper)
    copper=tonumber(copper) or 0
    if copper<=0 then return nil end
    local g=math.floor(copper/10000)
    local s=math.floor((copper%10000)/100)
    local c=copper%100
    local parts={}
    if g>0 then parts[#parts+1]=g.." Gold" end
    if s>0 then parts[#parts+1]=s.." Silver" end
    if c>0 then parts[#parts+1]=c.." Copper" end
    return table.concat(parts," ")
end
T.FormatMoneyPlain=moneyText

local function merchantName()
    local n=UnitName and UnitName("npc")
    return (n and n~="") and n or "Merchant"
end

local function currentZone()
    if GetRealZoneText then
        local z=GetRealZoneText()
        if z and z~="" then return z end
    end
    if GetZoneText then return GetZoneText() end
end

local function factionTag()
    local f=UnitFactionGroup and UnitFactionGroup("player")
    return (f=="Alliance" or f=="Horde") and f or nil
end

local function resolveCurrencyName(link,fallback)
    if fallback and fallback~="" and fallback~="Currency" then return fallback end

    local currencyID=type(link)=="string" and tonumber(link:match("currency:(%d+)")) or nil
    if currencyID and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
        local ok,info=pcall(C_CurrencyInfo.GetCurrencyInfo,currencyID)
        if ok and type(info)=="table" and info.name and info.name~="" then
            return info.name,currencyID
        end
    end

    local itemID=type(link)=="string" and tonumber(link:match("item:(%d+)")) or nil
    if itemID then
        local itemName=GetItemInfo and GetItemInfo(itemID)
        if itemName and itemName~="" then return itemName,nil,itemID end
    end

    return fallback or "Currency",currencyID,itemID
end

local function costParts(index,price,extended)
    local out={}

    if price and price>0 then
        out[#out+1]={
            type="money",
            amount=price,
            text=moneyText(price)
        }
    end

    if extended and GetMerchantItemCostInfo and GetMerchantItemCostItem then
        local ok,n=pcall(GetMerchantItemCostInfo,index)
        if ok and type(n)=="number" then
            for j=1,n do
                local ok2,texture,value,link,name=pcall(GetMerchantItemCostItem,index,j)
                if ok2 and value and value>0 then
                    local resolvedName,currencyID,itemID=resolveCurrencyName(link,name)
                    out[#out+1]={
                        type=currencyID and "currency" or "token",
                        currencyID=currencyID,
                        itemID=itemID,
                        amount=value,
                        name=resolvedName,
                        icon=texture,
                        link=link
                    }
                end
            end
        end
    end

    return out
end

function T:FormatMerchantCostForChat(costs)
    if type(costs)~="table" or #costs==0 then return "No cost exposed by client" end
    local parts={}
    for _,c in ipairs(costs) do
        if c.type=="money" and c.text then
            parts[#parts+1]=c.text
        elseif (c.type=="currency" or c.type=="token") and c.amount then
            parts[#parts+1]=tostring(c.amount).." "..tostring(c.name or "Currency")
        end
    end
    return #parts>0 and table.concat(parts," + ") or "No cost exposed by client"
end


-- Merchant API compatibility layer.
-- MoP Classic still exposes the legacy GetMerchantItemInfo tuple while modern
-- Retail provides C_MerchantFrame.GetItemInfo(). Keep both clients on the same
-- indexed learning path without forcing Retail through deprecated APIs.
local function merchantNumItems()
    if GetMerchantNumItems then
        local ok,n=pcall(GetMerchantNumItems)
        if ok and type(n)=="number" then return n end
    end
    return 0
end

local function merchantItemInfo(index)
    if GetMerchantItemInfo then
        local ok,name,texture,price,quantity,numAvailable,isPurchasable,isUsable,extendedCost,currencyID,spellID=pcall(GetMerchantItemInfo,index)
        if ok then
            return name,texture,price,quantity,numAvailable,isPurchasable,isUsable,extendedCost,currencyID,spellID
        end
    end

    if C_MerchantFrame and C_MerchantFrame.GetItemInfo then
        local ok,info=pcall(C_MerchantFrame.GetItemInfo,index)
        if ok and type(info)=="table" then
            local extendedCost=false
            if GetMerchantItemCostInfo then
                local okCost,n=pcall(GetMerchantItemCostInfo,index)
                extendedCost=okCost and type(n)=="number" and n>0 or false
            end
            return info.name,info.texture,info.price,info.stackCount,info.numAvailable,
                info.isPurchasable,nil,extendedCost,nil,nil
        end
    end
end

local function merchantItemIdentity(index)
    local link
    if GetMerchantItemLink then
        local ok,v=pcall(GetMerchantItemLink,index)
        if ok then link=v end
    end

    local itemID=link and tonumber(link:match("item:(%d+)")) or nil
    if not itemID and GetMerchantItemID then
        local ok,v=pcall(GetMerchantItemID,index)
        if ok then itemID=tonumber(v) end
    end
    return itemID,link
end

function T:ShouldLearnCurrentMerchant()
    -- Auction House is not an acquisition vendor and must never contaminate
    -- verified merchant intelligence.
    if AuctionHouseFrame and AuctionHouseFrame:IsShown() then return false,"auction-house" end
    if AuctionFrame and AuctionFrame:IsShown() then return false,"auction-house" end

    -- MERCHANT_SHOW is authoritative. Classic can fire it before MerchantFrame
    -- reports visible, so do not gate the scan on MerchantFrame:IsShown().
    local hasInfoAPI=GetMerchantItemInfo or (C_MerchantFrame and C_MerchantFrame.GetItemInfo)
    if not (GetMerchantNumItems and hasInfoAPI) then
        return false,"merchant-api-unavailable"
    end

    return true
end

local function merchantCategoryFromSourceInfo(self,info)
    if type(info)~="table" then return nil end
    local categoryID=tonumber(info.categoryID or info.collectionType)
    if categoryID and categoryID~=0 then return categoryID end
    local slotID=tonumber(info.inventorySlot)
    if (not slotID or slotID==0) and info.invType and C_Transmog and C_Transmog.GetSlotForInventoryType then
        local ok,v=pcall(C_Transmog.GetSlotForInventoryType,info.invType)
        if ok then slotID=tonumber(v) end
    end
    if slotID and self.SLOTS then
        for _,slot in ipairs(self.SLOTS) do
            if tonumber(slot.slotID)==slotID and slot.category then return tonumber(slot.category) end
        end
    end
    return nil
end

local MERCHANT_EQUIPLOC_CATEGORY={
    INVTYPE_HEAD=1,
    INVTYPE_SHOULDER=2,
    INVTYPE_CLOAK=3,
    INVTYPE_CHEST=4,
    INVTYPE_ROBE=4,
    INVTYPE_BODY=5,
    INVTYPE_TABARD=6,
    INVTYPE_WRIST=7,
    INVTYPE_HAND=8,
    INVTYPE_WAIST=9,
    INVTYPE_LEGS=10,
    INVTYPE_FEET=11,
}

local MERCHANT_WEAPON_SUBCLASS_CATEGORY={
    [0]=13,  -- 1H Axe
    [1]=20,  -- 2H Axe
    [2]=25,  -- Bow
    [3]=27,  -- Gun
    [4]=15,  -- 1H Mace
    [5]=21,  -- 2H Mace
    [6]=24,  -- Polearm
    [7]=14,  -- 1H Sword
    [8]=22,  -- 2H Sword
    [10]=23, -- Staff
    [13]=17, -- Fist
    [15]=16, -- Dagger
    [18]=28, -- Crossbow
    [19]=12, -- Wand
}

local function merchantCategoryFromItem(itemID,link)
    if not GetItemInfoInstant then return nil end
    local ok,_,_,_,equipLoc,_,classID,subclassID=pcall(GetItemInfoInstant,link or itemID)
    if not ok then return nil end

    local armorCategory=MERCHANT_EQUIPLOC_CATEGORY[equipLoc]
    if armorCategory then return armorCategory end

    classID=tonumber(classID)
    subclassID=tonumber(subclassID)
    if classID==2 then
        return MERCHANT_WEAPON_SUBCLASS_CATEGORY[subclassID]
    elseif classID==4 and subclassID==6 then
        return 18 -- Shield
    end
    return nil
end

function T:IndexVerifiedMerchantAppearance(itemID,rec)
    if not self.db or type(rec)~="table" or not rec.merchantVerified then return end
    itemID=tonumber(itemID)
    local visualID=tonumber(rec.appearanceID)
    local sourceID=tonumber(rec.modifiedAppearanceID)
    local categoryID=tonumber(rec.categoryID)
    if (not categoryID or categoryID==0) then
        categoryID=merchantCategoryFromItem(itemID,nil)
        if categoryID then rec.categoryID=categoryID end
    end
    if not itemID then return end

    self.db.merchantAppearanceIndex=self.db.merchantAppearanceIndex or {}
    self.db.merchantSourceIndex=self.db.merchantSourceIndex or {}
    self.db.merchantCatalogByCategory=self.db.merchantCatalogByCategory or {}

    if visualID and visualID~=0 then
        local byAppearance=self.db.merchantAppearanceIndex[visualID]
        if type(byAppearance)~="table" then byAppearance={}; self.db.merchantAppearanceIndex[visualID]=byAppearance end
        byAppearance[itemID]=true
    end
    if sourceID and sourceID~=0 then self.db.merchantSourceIndex[sourceID]=itemID end

    -- Persist only exact category relationships in the browse catalog. Unknown
    -- categories stay available to HOW TO OBTAIN but never leak into every slot.
    if visualID and visualID~=0 and categoryID and categoryID~=0 then
        local bucket=self.db.merchantCatalogByCategory[categoryID]
        if type(bucket)~="table" then bucket={}; self.db.merchantCatalogByCategory[categoryID]=bucket end
        bucket[visualID]={
            visualID=visualID,
            sourceID=sourceID,
            itemModifiedAppearanceID=sourceID,
            itemID=itemID,
            itemName=rec.itemName,
            icon=rec.icon,
            categoryID=categoryID,
            specialCatalogSource=self.clientMode=="retail" and "merchant-retail" or "merchant-mop",
        }
    end
end

function T:EnsureMerchantIndexes()
    if not self.db then return end
    if self.db.merchantIndexVersion==5 and type(self.db.merchantAppearanceIndex)=="table"
        and type(self.db.merchantSourceIndex)=="table" and type(self.db.merchantCatalogByCategory)=="table" then return end
    self.db.merchantAppearanceIndex={}
    self.db.merchantSourceIndex={}
    self.db.merchantCatalogByCategory={}
    for itemID,rec in pairs(self.db.sourceIntel or {}) do
        if type(rec)=="table" and rec.merchantVerified then self:IndexVerifiedMerchantAppearance(itemID,rec) end
    end
    self.db.merchantIndexVersion=5
end

function T:LearnCurrentMerchantSources(showProgress)
    if not self.db then return 0 end
    if showProgress and self.db.profile and self.db.profile.help and self.db.profile.help.chatScans==false then
        showProgress=false
    end
    local allowed=self:ShouldLearnCurrentMerchant()
    if not allowed then return 0 end

    local count=merchantNumItems()
    if count<=0 then
        if showProgress and self.Print then
            self:Print("Vendor scan: no merchant items were exposed by the client.")
        end
        return 0
    end

    self.db.sourceIntel=self.db.sourceIntel or {}
    self.db.merchantNameIndex=self.db.merchantNameIndex or {}
    self:EnsureMerchantIndexes()

    local vendor=merchantName()
    local zone=currentZone() or "Unknown location"
    local faction=factionTag()
    if showProgress and self.Print then
        self:Print(("Scanning vendor: %s - %s ..."):format(vendor or "Merchant",zone))
    end
    local learnedCount=0
    local pricedCount=0
    local unresolvedCount=0
    local appearanceLinkedCount=0
    local blizzardAppearanceLinks=0
    local generatedAppearanceLinks=0
    local retailCatalogPromotions=0

    for i=1,count do
        local name,texture,price,quantity,numAvailable,isPurchasable,isUsable,extendedCost=merchantItemInfo(i)
        local itemID,link=merchantItemIdentity(i)

        if itemID then
            local costs=costParts(i,price,extendedCost)
            local rec=self.db.sourceIntel[itemID] or {}

            rec.merchants=rec.merchants or {}
            local merchantKey=(vendor or "Merchant").."|"..(zone or "").."|"..(faction or "")
            local merchantRow={
                vendor=vendor,
                zone=zone,
                faction=faction,
                costs=costs,
                updated=time and time() or 0,
                verified=true,
            }
            rec.merchants[merchantKey]=merchantRow

            -- Latest directly observed merchant remains the preferred route.
            rec.itemName=name or rec.itemName
            rec.icon=texture or rec.icon
            if rec.itemName and rec.itemName~="" then
                self.db.merchantNameIndex[rec.itemName:lower()]=itemID
            end
            rec.kind="Vendor"
            rec.vendor=vendor
            rec.zone=zone
            rec.faction=faction
            rec.costs=costs

            -- Tie the observed merchant item directly to Blizzard's wardrobe
            -- identity. MoP Classic exposes appearanceID + modifiedAppearanceID
            -- through C_TransmogCollection.GetItemInfo().
            local appearanceID,modifiedAppearanceID
            local resolvedByBlizzard=false

            if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                local ok,a,m=pcall(C_TransmogCollection.GetItemInfo,link or itemID)
                if ok then
                    appearanceID=tonumber(a)
                    modifiedAppearanceID=tonumber(m)
                    resolvedByBlizzard=appearanceID and appearanceID~=0 or false
                end

                -- Some Classic item links are not fully hydrated on the first frame.
                -- Retry using the raw itemID before falling back to generated data.
                if not appearanceID or appearanceID==0 then
                    local ok2,a2,m2=pcall(C_TransmogCollection.GetItemInfo,itemID)
                    if ok2 then
                        appearanceID=tonumber(a2)
                        modifiedAppearanceID=tonumber(m2)
                        resolvedByBlizzard=appearanceID and appearanceID~=0 or false
                    end
                end
            end

            local generated=self.GeneratedItemSourceDB and self.GeneratedItemSourceDB[itemID] or nil
            if (not appearanceID or appearanceID==0) and generated then
                appearanceID=generated.a
            end
            if (not modifiedAppearanceID or modifiedAppearanceID==0) and generated then
                modifiedAppearanceID=generated.m
            end

            if appearanceID and appearanceID~=0 then
                rec.appearanceID=appearanceID
                appearanceLinkedCount=appearanceLinkedCount+1
                if resolvedByBlizzard then
                    blizzardAppearanceLinks=blizzardAppearanceLinks+1
                else
                    generatedAppearanceLinks=generatedAppearanceLinks+1
                end
            end
            if modifiedAppearanceID and modifiedAppearanceID~=0 then
                rec.modifiedAppearanceID=modifiedAppearanceID
            end

            -- Cross-client vendor catalog discovery is indexed only at scan time.
            -- MoP Classic and Retail both promote exact merchant appearances that
            -- Blizzard's normal catalog omitted. Never enumerate sourceIntel from
            -- the appearance-grid hot path.
            if appearanceID and appearanceID~=0 then
                local sourceID=modifiedAppearanceID
                local sourceInfo=nil
                if sourceID and sourceID~=0 and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
                    local okInfo,info=pcall(C_TransmogCollection.GetSourceInfo,sourceID)
                    if okInfo and type(info)=="table" then sourceInfo=info end
                end
                local categoryID=merchantCategoryFromSourceInfo(self,sourceInfo)
                if not categoryID then categoryID=merchantCategoryFromItem(itemID,link) end
                rec.categoryID=categoryID or rec.categoryID
                rec.retailVendorCatalogDiscovered=categoryID and true or false
                if categoryID then retailCatalogPromotions=retailCatalogPromotions+1 end
            end

            rec.learned=true
            rec.merchantVerified=true
            rec.merchantIndex=i
            rec.merchantItemCount=count
            rec.updated=time and time() or 0

            self.db.sourceIntel[itemID]=rec
            self:IndexVerifiedMerchantAppearance(itemID,rec)
            learnedCount=learnedCount+1
            if #costs>0 then pricedCount=pricedCount+1 end
        else
            unresolvedCount=unresolvedCount+1
        end
    end

    if learnedCount>0 then
        self.db.merchantIntelGeneration=(tonumber(self.db.merchantIntelGeneration) or 0)+1
    end

    if self.state then
        self.state.acquisitionTextCache={}
        -- Keep the existing authoritative search cache. Clearing it here forced
        -- the next search to rebuild acquisition text for the entire catalog.
        self.state.authoritativeSearchCache=self.state.authoritativeSearchCache or {}
        self.state.sourceCache={}
        self.state.sourceListCache={}
        self.state.sourceInfoCache={}
        self.state.specialAppearanceItemCache={}
        self.state.filteredAppearanceCache={}
        -- Selected records can carry an old pre-vendor sourceText.
        if self.state.selectedRecord then
            self.state.selectedRecord.sourceText=nil
            self.state.selectedRecord.sourceTextPending=nil
        end
    end

    if showProgress and self.Print then
        self:Print(("Vendor scan complete: %d/%d items learned, %d with price/currency data%s."):format(
            learnedCount,
            count,
            pricedCount,
            unresolvedCount>0 and (", "..tostring(unresolvedCount).." awaiting item data") or ""
        ))
        self:Print(("Verified merchant: %s - %s"):format(vendor,zone))
        self:Print(("Appearance links learned: %d/%d (Blizzard %d, fallback %d, unresolved %d)"):format(
            appearanceLinkedCount,
            learnedCount,
            blizzardAppearanceLinks,
            generatedAppearanceLinks,
            math.max(0,learnedCount-appearanceLinkedCount)
        ))
        if retailCatalogPromotions>0 then
            self:Print(("Vendor catalog discoveries: %d appearances promoted."):format(retailCatalogPromotions))
        end
    end

    return learnedCount
end


function T:GetVerifiedMerchantRecord(itemID)
    itemID=tonumber(itemID)
    if not itemID or not self.db or not self.db.sourceIntel then return nil end
    local rec=self.db.sourceIntel[itemID]
    if type(rec)=="table" and rec.merchantVerified and rec.vendor then
        return rec
    end
    return nil
end


function T:GetVerifiedMerchantRecordsForAppearance(visualID,itemIDs,sourceIDs)
    local out={}
    local seen={}

    local function add(rec,itemID)
        if type(rec)~="table" or not rec.merchantVerified or not rec.vendor then return end

        local function push(row)
            if type(row)~="table" or not row.vendor then return end
            local key=tostring(itemID or 0)..":"..tostring(row.vendor or "")..":"..tostring(row.zone or "")
            if seen[key] then return end
            seen[key]=true

            local copy={}
            for k,v in pairs(rec) do copy[k]=v end
            copy.vendor=row.vendor or rec.vendor
            copy.zone=row.zone or rec.zone
            copy.faction=row.faction or rec.faction
            copy.costs=row.costs or rec.costs
            copy.updated=row.updated or rec.updated
            copy._merchantItemID=itemID
            out[#out+1]=copy
        end

        -- Latest observed merchant first.
        push(rec)

        -- Preserve other exact vendors learned for the same item.
        if type(rec.merchants)=="table" then
            for _,row in pairs(rec.merchants) do push(row) end
        end
    end

    if type(itemIDs)=="table" then
        for _,itemID in ipairs(itemIDs) do
            add(self:GetVerifiedMerchantRecord(itemID),itemID)
        end
    end

    -- Exact indexed appearance bridge. No full merchant-memory scan.
    self:EnsureMerchantIndexes()
    local visualKey=tonumber(visualID)
    local byAppearance=visualKey and self.db and self.db.merchantAppearanceIndex and self.db.merchantAppearanceIndex[visualKey]
    if type(byAppearance)=="table" then
        for itemID in pairs(byAppearance) do add(self:GetVerifiedMerchantRecord(itemID),itemID) end
    end

    -- Exact indexed modified-appearance/source bridge.
    if type(sourceIDs)=="table" and self.db and self.db.merchantSourceIndex then
        for _,sid in ipairs(sourceIDs) do
            local itemID=self.db.merchantSourceIndex[tonumber(sid)]
            if itemID then add(self:GetVerifiedMerchantRecord(itemID),itemID) end
        end
    end

    -- Exact item-name fallback. This is intentionally strict (exact lowercase
    -- match only) so we do not create fuzzy/incorrect acquisition routes.
    if self.db and self.db.merchantNameIndex and type(itemIDs)=="table" then
        for _,itemID in ipairs(itemIDs) do
            local name=self.ResolveDisplayItemName and self:ResolveDisplayItemName(itemID) or nil
            if name and name~="" then
                local learnedID=self.db.merchantNameIndex[name:lower()]
                if learnedID then add(self:GetVerifiedMerchantRecord(learnedID),learnedID) end
            end
        end
    end

    -- Also compare the currently selected appearance's display item name.
    if self.db and self.db.merchantNameIndex and self.state and self.state.selectedRecord then
        local currentName=self.state.selectedRecord.itemName or self.state.selectedRecord.name
        if currentName and currentName~="" then
            local learnedID=self.db.merchantNameIndex[currentName:lower()]
            if learnedID then add(self:GetVerifiedMerchantRecord(learnedID),learnedID) end
        end
    end


    return out
end



function T:FormatDBSource(rec)
    if not rec or not rec.itemID then return nil end
    local d=self:GetSourceDBRecord(rec.itemID,rec.itemName or rec.name)
    if not d then return nil end
    -- Merge a bundled in-game vendor guide when the client identifies a vendor
    -- source but does not ship the merchant inventory relationship.
    if (d.kind=="Vendor" or d.sourceKind=="Vendor") and self.GetVendorGuide then
        local guide=self:GetVendorGuide({itemID=rec.itemID,itemName=d.itemName or rec.itemName or rec.name,name=rec.name})
        if guide then
            if (not d.vendor) and (not d.vendors) and guide.vendors then d.vendors=guide.vendors end
            d.whereToGo=d.whereToGo or guide.whereToGo
            d.vendorGuideTitle=d.vendorGuideTitle or guide.title
            d.vendorGuideNote=d.vendorGuideNote or guide.note
        end
    end
    -- Item/appearance/set metadata alone does not explain how to obtain an item.
    -- Let the resolver continue to Blizzard's source type instead of presenting a
    -- misleading DB block when no acquisition relationship exists.
    local hasAcquisition=d.kind or d.sourceKind or d.vendor or d.vendors or d.quest or d.npcs or d.encounter or d.instance or d.profession or d.recipe or d.questPackages or d.fullClientDB or (type(d.encounters)=="table" and #d.encounters>0) or (type(d.costs)=="table" and #d.costs>0) or d.currency or d.amount or d.money
    if not hasAcquisition then return nil end
    local lines={}
    local function add(s) if s and s~="" then lines[#lines+1]=s end end
    add("HOW TO OBTAIN")

    if d.merchantVerified and d.vendor then
        add("VERIFIED VENDOR")
        add("Vendor: "..d.vendor..(d.zone and (" - "..d.zone) or ""))
        if type(d.costs)=="table" and #d.costs>0 then
            local costParts={}
            for _,c in ipairs(d.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 add("Cost: "..table.concat(costParts," + ")) end
        end
        add("Item: "..(d.itemName or rec.itemName or rec.name or "Unknown item"))
        return table.concat(lines,"\n")
    end
    add("Item: "..(d.itemName or rec.itemName or rec.name or "Unknown item"))
    local kind=d.kind or d.sourceKind
    if kind then add("Source: "..kind) end
    if kind=="Vendor" or d.vendor or d.vendors then
        if d.vendor then
            local where=d.zone and (" — "..d.zone) or ""
            local fac=d.faction and (" ["..d.faction.."]") or ""
            add("Vendor: "..d.vendor..where..fac)
        end
        if type(d.vendors)=="table" then
            add("Vendors:")
            for _,v in ipairs(d.vendors) do
                local where=v.zone and (" — "..v.zone) or ""
                local fac=v.faction and (" ["..v.faction.."]") or ""
                add("  "..(v.name or "Vendor")..where..fac)
            end
        end
    end
    if d.whereToGo then
        add("")
        add("WHERE TO GO")
        add(d.whereToGo)
        if d.vendorGuideNote then add(d.vendorGuideNote) end
    end
    if d.quest then add("Quest: "..d.quest) end
    if d.zone and kind~="Vendor" and not d.vendor then add("Zone: "..d.zone) end
    if d.npcs and #d.npcs>0 then
        add((kind=="World Boss" and "World boss:" or kind=="World Drop" and "Drops from:" or "NPC:").." "..table.concat(d.npcs,", "))
    end
    if type(d.encounters)=="table" and #d.encounters>0 then
        add("Drops from:")
        for i=1,math.min(#d.encounters,6) do
            local e=d.encounters[i]
            local line="  "..(e.encounter or "Encounter")
            if e.instance and e.instance~="" then line=line.." — "..e.instance end
            if e.difficulty and e.difficulty~="" then line=line.." ["..e.difficulty.."]" end
            add(line)
        end
        if #d.encounters>6 then add("  +"..tostring(#d.encounters-6).." additional encounter source(s)") end
    else
        if d.instance then add("Instance: "..d.instance) end
        if d.encounter then add("Boss: "..d.encounter) end
        if d.difficulty then add("Difficulty: "..d.difficulty) end
    end
    if type(d.sets)=="table" and #d.sets>0 then
        local names={}
        for i=1,math.min(#d.sets,3) do if d.sets[i].name then names[#names+1]=d.sets[i].name end end
        if #names>0 then add("Set: "..table.concat(names,", ")) end
    end
    if d.profession then add("Crafted by: "..d.profession) end
    if d.recipe then add("Recipe: "..d.recipe) end
    if d.skillRank and d.skillRank>0 then add("Required skill: "..tostring(d.skillRank)) end
    if type(d.reagents)=="table" and #d.reagents>0 then
        add("Materials:")
        for i=1,math.min(#d.reagents,8) do
            local r=d.reagents[i]
            local rn=r.name
            if (not rn or rn=="") and r.itemID and self.ResolveDisplayItemName then rn=self:ResolveDisplayItemName(r.itemID) end
            add("  "..tostring(r.count or 1).." x "..(rn or "Loading material name..."))
        end
        if #d.reagents>8 then add("  +"..tostring(#d.reagents-8).." additional material(s)") end
    end
    if type(d.questPackages)=="table" and #d.questPackages>0 then
        add("Quest reward data: this item is present in Blizzard quest reward package data.")
        if not d.quest then add("Exact quest title is not linked by the local client tables exported so far.") end
    end
    if type(d.costs)=="table" and #d.costs>0 then
        local parts={}
        for _,c in ipairs(d.costs) do
            if c.type=="money" and c.text then parts[#parts+1]=c.text
            elseif c.type=="currency" then
                local cn=c.name
                if (not cn or cn=="" or cn=="Currency") and c.itemID and self.ResolveDisplayItemName then cn=self:ResolveDisplayItemName(c.itemID,"Loading currency name...") end
                parts[#parts+1]=tostring(c.amount or 0).." "..(cn or "Currency")
            end
        end
        if #parts>0 then add("Cost: "..table.concat(parts," + ")) end
    elseif d.amount and d.currency then
        add("Cost: "..tostring(d.amount).." "..d.currency)
    end
    if d.reputation then add("Reputation: "..d.reputation) end
    if d.requiredFactionName and d.requiredFactionName~="" then
        local rep=d.requiredReputationName and (" - "..d.requiredReputationName) or ""
        add("Reputation requirement: "..d.requiredFactionName..rep)
    end
    if d.requiredLevel and d.requiredLevel>0 then add("Required level: "..tostring(d.requiredLevel)) end
    if d.requiredSkillRank and d.requiredSkillRank>0 and not d.profession then add("Required profession skill rank: "..tostring(d.requiredSkillRank)) end
    if d.startsQuestID and d.startsQuestID>0 then add("This item starts quest ID: "..tostring(d.startsQuestID)) end
    if kind=="Vendor" and type(d.costs)~="table" and not (d.amount and d.currency) then
        if d.hasCurrencyCost then
            add("Cost: special currency or token cost.")
            add("The exact price is shown by the vendor in-game. Transmoggin will remember the vendor and price after you open that merchant once.")
        elseif d.baseBuyPrice and d.baseBuyPrice>0 then
            local txt=self.FormatMoneyPlain and self.FormatMoneyPlain(d.baseBuyPrice) or nil
            if txt then add("Client base vendor price: "..txt) end
        end
    end
    if kind=="Quest Reward" and not d.quest and not d.questPackages then
        add("Quest source is verified by the client. Exact quest-to-reward linkage is not shipped in this client DB export.")
    elseif kind=="World Drop" and not d.npcs then
        add("World-drop source is verified by the client. Exact creature loot tables are not shipped in this client DB export.")
    end
    if type(d.sourceTypes)=="table" and #d.sourceTypes>1 and self.GeneratedSourceTypeNames then
        local labels={}
        for _,sourceType in ipairs(d.sourceTypes) do local label=self.GeneratedSourceTypeNames[sourceType]; if label and label~="Unknown / alternate source" then labels[#labels+1]=label end end
        if #labels>1 then add("Client source variants: "..table.concat(labels,", ")) end
    end
    if d.note then add(d.note) end
    if d.clientDB then add("Data: local MoP Classic client Encounter Journal DB") end
    if d.clientCraftDB then add("Data: local MoP Classic profession/recipe DB") end
    if d.clientQuestPackageDB and not d.clientDB and not d.clientCraftDB then add("Data: local MoP Classic quest package DB") end
    if d.fullClientDB and not d.clientDB and not d.clientCraftDB and not d.clientQuestPackageDB then add("Data: complete local MoP Classic client DB export") end
    return table.concat(lines,"\n")
end
