-- ============================================================
-- Decor Vendor Gallery
-- Core/GalleryDetailsBridge.lua
--
-- Connects GalleryItemDetails.lua and GalleryVendorDetails.lua
-- to the preview/details UI.
--
-- Rules:
-- 1. GalleryItemDetails.lua wins for quest/achievement/note extras.
-- 2. GalleryVendorDetails.lua wins for vendor display/cost/requirements.
-- 3. DecorVendor vendor data may be used ONLY by exact vendor ID.
-- 4. Never guess vendors by name.
-- 5. Never pull extra vendors from sourceIndex/vendorGoodies here.
-- ============================================================

local addonName, Gallery = ...

local DVD = _G.DecorVendorData or _G.DecorVendor_Data
local dv = _G.DecorVendor or _G.Decorvendor

local function GetDataTable(name)
    if DVD and DVD[name] then
        return DVD[name]
    end

    if Gallery and Gallery[name] then
        return Gallery[name]
    end

    if dv and dv[name] then
        return dv[name]
    end

    return {}
end

Gallery.C = Gallery.C or {}
local C = Gallery.C
-- Keep old functions as fallbacks where they are safe.
local OldGetPriceText = Gallery.GetPriceText
local OldBuildPreviewSourceText = Gallery.BuildPreviewSourceText
local OldCleanPriceText = Gallery.CleanPriceText
local OldGetAchievementForItem = Gallery.GetAchievementForItem

-- ============================================================
-- Basic item/detail helpers
-- ============================================================

local function GetItemData(item)
    if not item then return {} end
    return item.data or item or {}
end

local function GetItemID(item)
    if not item then return nil end

    local data = GetItemData(item)

    return item.itemID
        or data.itemID
        or data.id
end

function Gallery.GetItemDetailsForItem(item)
    local itemID = GetItemID(item)

    if not itemID or not Gallery.ItemDetails then
        return nil
    end

    return Gallery.ItemDetails[itemID]
        or Gallery.ItemDetails[tonumber(itemID)]
        or Gallery.ItemDetails[tostring(itemID)]
end

function Gallery.GetVendorDetailsForItem(item)
    local itemID = GetItemID(item)

    if not itemID or not Gallery.VendorDetails then
        return nil
    end

    return Gallery.VendorDetails[itemID]
        or Gallery.VendorDetails[tonumber(itemID)]
        or Gallery.VendorDetails[tostring(itemID)]
end

function Gallery.GetDetailField(item, key)
    local itemDetails = Gallery.GetItemDetailsForItem(item)
    local data = GetItemData(item)

    if itemDetails and itemDetails[key] ~= nil then
        return itemDetails[key]
    end

    return data[key]
end

-- ============================================================
-- Catalog source text fallback helpers
-- ============================================================

local function CleanCatalogText(text)
    if type(text) ~= "string" or text == "" then
        return nil
    end

    text = text:gsub("|n", "\n")
    text = text:gsub("\r", "\n")
    text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
    text = text:gsub("|r", "")

    return text
end

local function GetCatalogSourceText(item)
    if not item then return nil end

    local data = GetItemData(item)

    local text =
        data.sourceText
        or data.catalogSourceText
        or data.tooltipText

    if text then
        return CleanCatalogText(text)
    end

    local decorID = data.decorID or item.decorID

    if Gallery.GetCatalogInfo and decorID then
        local info = Gallery.GetCatalogInfo(decorID)

        if info then
            return CleanCatalogText(
                info.sourceText
                or info.catalogSourceText
                or info.tooltipText
            )
        end
    end

    return nil
end

local function GetCatalogLineValue(item, label)
    local text = GetCatalogSourceText(item)

    if not text then
        return nil
    end

    for line in text:gmatch("[^\n]+") do
        local value = line:match("^%s*" .. label .. ":%s*(.-)%s*$")

        if value then
            value = value:gsub("^%s+", ""):gsub("%s+$", "")

            if value ~= "" then
                return value
            end
        end
    end

    return nil
end

-- ============================================================
-- Achievement bridge
-- ============================================================

function Gallery.GetAchievementForItem(item)
    if not item then return nil end

    local itemDetails =
        Gallery.GetItemDetailsForItem
        and Gallery.GetItemDetailsForItem(item)
        or {}

    local data = GetItemData(item)

    -- GalleryItemDetails.lua wins first.
    local achievementID =
        itemDetails.achievementID
        or data.achievementID

    local achievementName =
        itemDetails.achievementName
        or data.achievementName
        or GetCatalogLineValue(item, "Achievement")

    if achievementID or achievementName then
        return {
            id = achievementID,
            achievementID = achievementID,
            name = achievementName or ("Achievement " .. tostring(achievementID)),
            achievementName = achievementName or ("Achievement " .. tostring(achievementID)),
            category =
			itemDetails.achievementCategory
			or data.achievementCategory
			or itemDetails.category
			or data.category,
            faction = itemDetails.faction or data.faction or "neutral",
        }
    end

    -- Safe fallback to old helper only when our detail files do not have anything.
    if OldGetAchievementForItem then
        return OldGetAchievementForItem(item)
    end

    return nil
end

-- ============================================================
-- Currency / price helpers
-- ============================================================

local function GetGoldIconMarkup(size)
    size = size or 14

    local goldIcon =
        Gallery.C
        and Gallery.C.GOLD_ICON
        or "Interface\\MoneyFrame\\UI-GoldIcon"

    return "|T" .. goldIcon .. ":" .. tostring(size) .. ":" .. tostring(size) .. ":0:-1|t"
end

local function GetHearthsteelIconMarkup(size)
    size = size or 14

    -- If your constant is an atlas name, use this.
    local atlas =
        Gallery.C
        and Gallery.C.HEARTHSTEEL_ATLAS

    if atlas then
        return "|A:" .. tostring(atlas) .. ":" .. tostring(size) .. ":" .. tostring(size) .. ":0:-1|a"
    end

    -- If your constant is a normal texture path, use this.
    local icon =
        Gallery.C
        and Gallery.C.HEARTHSTEEL_ICON

    if icon then
        return "|T" .. tostring(icon) .. ":" .. tostring(size) .. ":" .. tostring(size) .. ":0:-1|t"
    end

    return nil
end

local function GetCurrencyIconMarkup(currencyID, size)
    size = size or 14
    currencyID = tonumber(currencyID)

    if not currencyID then
        return nil
    end

    local icon

    -- First try Blizzard API.
    if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
        local info = C_CurrencyInfo.GetCurrencyInfo(currencyID)

        if info and info.iconFileID then
            icon = tostring(info.iconFileID)
        end
    end

    -- Fallback to Constants.lua.
    if not icon and Gallery.C and Gallery.C.CURRENCY_ICONS then
        icon = Gallery.C.CURRENCY_ICONS[currencyID]
    end

    if not icon then
        return nil
    end

    return "|T" .. icon .. ":" .. tostring(size) .. ":" .. tostring(size) .. ":0:-1|t"
end

local function ResizeTextureTag(textureTag)
    if type(textureTag) ~= "string" or textureTag == "" then
        return nil
    end

    -- Fix invisible catalog icons like :0|t.
    textureTag = textureTag:gsub("(|T[^|]-):0:0:0:0(|t)", "%1:14:14:0:-1%2")
    textureTag = textureTag:gsub("(|T[^|]-):0:0(|t)", "%1:14:14:0:-1%2")
    textureTag = textureTag:gsub("(|T[^|]-):0(|t)", "%1:14:14:0:-1%2")

    -- If somehow it has no size at all.
    textureTag = textureTag:gsub("^|T([^|:]+)|t$", "|T%1:14:14:0:-1|t")

    return textureTag
end

local function NormalizePriceTextForDisplay(priceText)
    if type(priceText) ~= "string" or priceText == "" then
        return nil
    end

    priceText = priceText:gsub("|c%x%x%x%x%x%x%x%x", "")
    priceText = priceText:gsub("|r", "")

    -- Full currency hyperlink with texture:
    -- 250|Hcurrency:3316|h|T...:0|t|h
    priceText = priceText:gsub("(%d+)%s*|Hcurrency:(%d+)|h(|T[^|]-|t)|h", function(amount, currencyID, textureTag)
        local icon = GetCurrencyIconMarkup(currencyID, 14)

        if icon then
            return tostring(amount) .. " " .. icon
        end

        textureTag = ResizeTextureTag(textureTag)

        if textureTag then
            return tostring(amount) .. " " .. textureTag
        end

        return tostring(amount) .. " currency " .. tostring(currencyID)
    end)

    -- Currency hyperlink without texture.
    priceText = priceText:gsub("(%d+)%s*|Hcurrency:(%d+)|h", function(amount, currencyID)
        local icon = GetCurrencyIconMarkup(currencyID, 14)

        if icon then
            return tostring(amount) .. " " .. icon
        end

        return tostring(amount) .. " currency " .. tostring(currencyID)
    end)

    -- Remove leftover hyperlink closers.
    priceText = priceText:gsub("|h", "")

    -- Fix normal texture tags.
    priceText = ResizeTextureTag(priceText) or priceText

    -- Add spacing between amount and icon.
    priceText = priceText:gsub("(%d+)(|T)", "%1 %2")

    priceText = priceText:gsub("^%s+", ""):gsub("%s+$", "")

	    -- Hearthsteel name fallback.
    -- Converts strings like "50 Hearthsteel" into "50 [icon]".
    local hearthsteelIcon = GetHearthsteelIconMarkup(14)

    if hearthsteelIcon then
        priceText = priceText:gsub("(%d+)%s*[Hh]earthsteel", function(amount)
            return tostring(amount) .. " " .. hearthsteelIcon
        end)

        priceText = priceText:gsub("(%d+)%s*HEARTHSTEEL", function(amount)
            return tostring(amount) .. " " .. hearthsteelIcon
        end)
    end
	
    -- Plain number fallback = gold.
    local plainAmount = priceText:match("^%s*(%d+)%s*$")
    if plainAmount then
        priceText = plainAmount .. " " .. GetGoldIconMarkup(14)
    end

    if priceText == "" then
        return nil
    end

    return priceText
end

function Gallery.CleanPriceText(text)
    if OldCleanPriceText then
        text = OldCleanPriceText(text)
    end

    return NormalizePriceTextForDisplay(text)
end

local function FormatPriceTable(price)
    if type(price) ~= "table" then
        return nil
    end

    if price.text then
        return NormalizePriceTextForDisplay(price.text)
    end

    if price.amount and price.currencyIcon then
        return NormalizePriceTextForDisplay(
            tostring(price.amount) .. " |T" .. tostring(price.currencyIcon) .. ":14:14:0:-1|t"
        )
    end

    if price.amount and price.currencyID then
        local icon = GetCurrencyIconMarkup(price.currencyID, 12)

        if icon then
            return tostring(price.amount) .. " " .. icon
        end

        return tostring(price.amount) .. " currency " .. tostring(price.currencyID)
    end

if price.amount and price.currencyName then
    return NormalizePriceTextForDisplay(
        tostring(price.amount) .. " " .. tostring(price.currencyName)
    )
end

    return nil
end

local function GetFirstVendorPrice(item)
    local vendorDetails = Gallery.GetVendorDetailsForItem(item)

    if type(vendorDetails) ~= "table" then
        return nil
    end

    local vendorIDs = {}

    for vendorID in pairs(vendorDetails) do
        table.insert(vendorIDs, vendorID)
    end

    table.sort(vendorIDs, function(a, b)
        return tostring(a) < tostring(b)
    end)

    for _, vendorID in ipairs(vendorIDs) do
        local detail = vendorDetails[vendorID]

        if type(detail) == "table" then
            local priceText =
                NormalizePriceTextForDisplay(detail.priceText)
                or FormatPriceTable(detail.price)

            if priceText then
                return priceText
            end
        end
    end

    return nil
end

function Gallery.GetPriceText(item)
    local itemDetails = Gallery.GetItemDetailsForItem(item)
    local data = GetItemData(item)

    -- Item-level detail override.
    if itemDetails then
        local itemPrice =
            NormalizePriceTextForDisplay(itemDetails.priceText)
            or FormatPriceTable(itemDetails.price)

        if itemPrice then
            return itemPrice
        end
    end

    -- Vendor-level detail price.
    local vendorPrice = GetFirstVendorPrice(item)
    if vendorPrice then
        return vendorPrice
    end

    -- ActiveItems/data fallback.
    local dataPrice =
        NormalizePriceTextForDisplay(data.priceText)
        or FormatPriceTable(data.price)

    if dataPrice then
        return dataPrice
    end

    -- Catalog source text fallback.
    local catalogCost = GetCatalogLineValue(item, "Cost")
    if catalogCost then
        return NormalizePriceTextForDisplay(catalogCost)
    end

    -- Old helper fallback.
    if OldGetPriceText then
        return NormalizePriceTextForDisplay(OldGetPriceText(item))
    end

    return nil
end

-- ============================================================
-- Vendor helpers
-- ============================================================
local function IsVendorHiddenInGallery(itemID, vendorID)
    if not itemID or not vendorID then
        return false
    end

    local vendorDetails =
        Gallery.VendorDetails
        or (_G.DecorVendorData and _G.DecorVendorData.VendorDetails)
        or {}

    local itemVendorDetails = vendorDetails[itemID]
    if type(itemVendorDetails) ~= "table" then
        return false
    end

    local detail = itemVendorDetails[vendorID]
    if type(detail) ~= "table" then
        return false
    end

    return detail.hideInGallery
        or detail.hiddenInGallery
        or detail.galleryHidden
        or detail.displayInGallery == false
end

local function GetVendorIDFromSoldByEntry(entry)
    if type(entry) == "number" then
        return entry
    end

    if type(entry) == "table" then
        return entry.vendorID
            or entry.vendorId
            or entry.npcID
            or entry.npcId
            or entry.id
            or entry[1]
    end

    return nil
end

local function FindVendorByExactID(vendorID)
    vendorID = tonumber(vendorID)

    if not vendorID or not dv.npcs then
        return nil
    end

    for _, group in ipairs(dv.npcs or {}) do
        for _, vendor in ipairs(group.vendors or {}) do
            if tonumber(vendor.id) == vendorID then
                return vendor
            end
        end
    end

    return nil
end

local function GetVendorByIDSafe(vendorID, detail, soldByEntry)
    detail = detail or {}

    local meta = type(soldByEntry) == "table" and soldByEntry or {}
    local exactVendor = FindVendorByExactID(vendorID) or {}

    local mapID =
        detail.mapID
        or detail.mapId
        or meta.mapID
        or meta.mapId
        or exactVendor.mapID
        or exactVendor.mapId

    local x =
        detail.x
        or detail.coordX
        or meta.x
        or meta.coordX
        or exactVendor.x
        or exactVendor.coordX

    local y =
        detail.y
        or detail.coordY
        or meta.y
        or meta.coordY
        or exactVendor.y
        or exactVendor.coordY

    local hasExactLocation = mapID and x and y

    return {
        id = vendorID,

        -- Detail file can override display text, exact DecorVendor data can provide coords.
        title =
            detail.vendorName
            or detail.name
            or meta.vendorName
            or meta.name
            or exactVendor.title
            or exactVendor.name
            or ("Vendor " .. tostring(vendorID)),

        name =
            detail.vendorName
            or detail.name
            or meta.vendorName
            or meta.name
            or exactVendor.name
            or exactVendor.title
            or ("Vendor " .. tostring(vendorID)),

        zone =
            detail.displayZone
            or detail.zone
            or meta.displayZone
            or meta.zone
            or exactVendor.zone,

        mapID = mapID,
        x = x,
        y = y,

        faction =
            detail.faction
            or meta.faction
            or exactVendor.faction,

        expansion =
            detail.expansion
            or meta.expansion
            or exactVendor.expansion,

        -- If we found real coords, do NOT force Location Varies.
        variableLocation =
            not hasExactLocation
            and (detail.variableLocation or meta.variableLocation or exactVendor.variableLocation)
            or false,

        noWaypoint =
            not hasExactLocation
            and (detail.noWaypoint or meta.noWaypoint or exactVendor.noWaypoint or true)
            or false,
    }
end

function Gallery.GetSoldByEntries(item)
    local entries = {}
    local seen = {}

    if not item then
        return entries
    end

    local data = GetItemData(item)
    local itemID = GetItemID(item)
    local vendorDetails = Gallery.GetVendorDetailsForItem(item)

    -------------------------------------------------
    -- Hide helper
    -------------------------------------------------

    local function IsHiddenVendorDetail(vendorID, detail)
        detail = type(detail) == "table" and detail or {}

        return
            IsVendorHiddenInGallery(itemID, vendorID)
            or detail.hideInGallery == true
            or detail.hiddenInGallery == true
            or detail.galleryHidden == true
            or detail.displayInGallery == false
    end

    -------------------------------------------------
    -- Normal vendor entry
    -------------------------------------------------

    local function AddVendor(vendorID, detail, soldByEntry)
        vendorID = tonumber(vendorID)

        if not vendorID or seen[vendorID] then
            return
        end

        detail = type(detail) == "table" and detail or {}

        -- Hide duplicate vendors from Gallery display only.
        -- Vendor still exists in DecorVendorData and ActiveItems.
        if IsHiddenVendorDetail(vendorID, detail) then
            return
        end

        seen[vendorID] = true

        local vendor = GetVendorByIDSafe(vendorID, detail, soldByEntry)

        table.insert(entries, {
            vendorID = vendorID,
            vendor = vendor,
            soldBy = soldByEntry,
            detail = detail,
            details = detail,
            item = item,
            itemID = itemID,
        })
    end

    -------------------------------------------------
    -- Combined rotating vendor group entry
    -------------------------------------------------

    local function AddCombinedVendorGroup(group)
        if not group or not group.firstVendorID then
            return
        end

        local firstVendorID = tonumber(group.firstVendorID)

        if not firstVendorID or seen[firstVendorID] then
            return
        end

        local sourceDetail = group.detail or {}
        local combinedDetail = {}

        for k, v in pairs(sourceDetail) do
            combinedDetail[k] = v
        end

        combinedDetail.isCombinedVendorGroup = true
        combinedDetail.vendorIDs = group.vendorIDs or {}
        combinedDetail.combineVendorIDs = group.vendorIDs or {}

        combinedDetail.title =
            combinedDetail.combineLabel
            or combinedDetail.title
            or combinedDetail.name
            or "Rotating vendor"

        combinedDetail.name =
            combinedDetail.combineLabel
            or combinedDetail.name
            or combinedDetail.title
            or "Rotating vendor"

        combinedDetail.zone =
            combinedDetail.combineZone
            or combinedDetail.zone

        seen[firstVendorID] = true

        local vendor = GetVendorByIDSafe(firstVendorID, combinedDetail, nil) or {}
        local displayVendor = {}

        if type(vendor) == "table" then
            for k, v in pairs(vendor) do
                displayVendor[k] = v
            end
        end

        -- Force the visible source text to use the combined label instead of the first vendor's name.
        displayVendor.title = combinedDetail.title
        displayVendor.name = combinedDetail.name

        if combinedDetail.zone then
            displayVendor.zone = combinedDetail.zone
        end

        table.insert(entries, {
            vendorID = firstVendorID,
            vendor = displayVendor,
            soldBy = nil,
            detail = combinedDetail,
            details = combinedDetail,
            item = item,
            itemID = itemID,
        })
    end

    -------------------------------------------------
    -- GalleryVendorDetails is the source of truth when present.
    -------------------------------------------------

    if type(vendorDetails) == "table" and next(vendorDetails) ~= nil then
        local combinedGroups = {}

        for vendorID, detail in pairs(vendorDetails) do
            vendorID = tonumber(vendorID)
            detail = type(detail) == "table" and detail or {}

            if vendorID and not IsHiddenVendorDetail(vendorID, detail) then
                local combineKey =
                    detail.combineKey
                    or detail.vendorGroupKey
                    or detail.groupKey

                if combineKey and combineKey ~= "" then
                    combinedGroups[combineKey] = combinedGroups[combineKey] or {
                        firstVendorID = vendorID,
                        vendorIDs = {},
                        detail = detail,
                    }

                    table.insert(combinedGroups[combineKey].vendorIDs, vendorID)
                else
                    AddVendor(vendorID, detail, nil)
                end
            end
        end

        for _, group in pairs(combinedGroups) do
            AddCombinedVendorGroup(group)
        end

    else
        -- Fallback only to ActiveItems soldBy/vendorID.
        -- Do not search sourceIndex/vendorGoodies/name matches here.
        local soldBy = data.soldBy

        if not soldBy and data.vendorID then
            soldBy = { data.vendorID }
        end

        if type(soldBy) == "number" then
            soldBy = { soldBy }
        end

        if type(soldBy) == "table" then
            for _, soldByEntry in ipairs(soldBy) do
                AddVendor(GetVendorIDFromSoldByEntry(soldByEntry), nil, soldByEntry)
            end
        end
    end

    table.sort(entries, function(a, b)
        local av = a.vendor or {}
        local bv = b.vendor or {}

        local az = tostring(av.zone or "")
        local bz = tostring(bv.zone or "")

        if az ~= bz then
            return az < bz
        end

        return tostring(av.title or av.name or "") < tostring(bv.title or bv.name or "")
    end)

    return entries
end

function Gallery.GetVendorsForItem(item)
    local vendors = {}
    local seen = {}

    local entries =
        Gallery.GetSoldByEntries
        and Gallery.GetSoldByEntries(item)
        or {}

    for _, entry in ipairs(entries) do
        local vendor = entry.vendor
        local vendorID = entry.vendorID or (vendor and vendor.id)

        if vendor and vendorID and not seen[vendorID] then
            seen[vendorID] = true
            table.insert(vendors, vendor)
        end
    end

    return vendors
end
-- ============================================================
-- Vendor waypoint helpers
-- Replaces the needed parts of old Core/VendorDetailsHelpers.lua
-- ============================================================

function Gallery.GetPlayerFaction()
    local faction = UnitFactionGroup and UnitFactionGroup("player")

    if faction then
        return string.lower(faction)
    end

    return nil
end

function Gallery.NormalizeFaction(faction)
    if not faction then return "neutral" end

    faction = string.lower(tostring(faction))

    if faction == "alliance" then return "alliance" end
    if faction == "horde" then return "horde" end

    return "neutral"
end

function Gallery.GetVendorEffectiveFaction(vendor)
    if not vendor then return "neutral" end

    if vendor.faction then
        return Gallery.NormalizeFaction(vendor.faction)
    end

    -- Housing neighborhoods
    if vendor.zone == "Founder's Point" then
        return "alliance"
    end

    if vendor.zone == "Razorwind Shores" then
        return "horde"
    end

    return "neutral"
end

function Gallery.VendorMatchesPlayerFaction(vendor)
    local playerFaction = Gallery.GetPlayerFaction()
    local vendorFaction = Gallery.GetVendorEffectiveFaction(vendor)

    if vendorFaction == "neutral" then
        return true
    end

    return playerFaction and vendorFaction == playerFaction
end

function Gallery.VendorHasExactLocation(vendor)
    return vendor
        and vendor.mapID
        and vendor.x
        and vendor.y
        and not vendor.variableLocation
        and not vendor.noWaypoint
end

function Gallery.SetWaypointToVendor(vendor, itemName)
    if not Gallery.VendorHasExactLocation(vendor) then
        return false
    end

    local title = vendor.title or vendor.name or itemName or "Decor Vendor"

    if dv and dv.SetWaypoint then
        dv.SetWaypoint(vendor.mapID, vendor.x, vendor.y, title)
        return true
    end

    if TomTom and TomTom.AddWaypoint then
        local x = tonumber(vendor.x)
        local y = tonumber(vendor.y)

        if x and y then
            TomTom:AddWaypoint(tonumber(vendor.mapID), x / 100, y / 100, {
                title = title,
                persistent = false,
                minimap = true,
                world = true,
            })

            return true
        end
    end

    if C_Map and C_Map.SetUserWaypoint and UiMapPoint then
        local x = tonumber(vendor.x)
        local y = tonumber(vendor.y)
        local mapID = tonumber(vendor.mapID)

        if mapID and x and y then
            local nx = x > 1 and x / 100 or x
            local ny = y > 1 and y / 100 or y

            local point

            if UiMapPoint.CreateFromCoordinates then
                point = UiMapPoint.CreateFromCoordinates(mapID, nx, ny)
            elseif UiMapPoint.CreateFromVector2D and CreateVector2D then
                point = UiMapPoint.CreateFromVector2D(mapID, CreateVector2D(nx, ny))
            end

            if point then
                C_Map.SetUserWaypoint(point)

                if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
                    C_SuperTrack.SetSuperTrackedUserWaypoint(true)
                end

                if C_Map.OpenWorldMap then
                    C_Map.OpenWorldMap(mapID)
                end

                return true
            end
        end
    end

    return false
end

function Gallery.GetBestFactionVendorForItem(item)
    if not item then return nil end

    local itemData = item.data or {}
    local vendors = Gallery.GetVendorsForItem(item)

    if #vendors == 0 then
        return nil
    end

    local playerFaction = Gallery.GetPlayerFaction()
    local currentMapID = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")

    -- 1. Explicit preferred vendor wins if you add this later.
    if itemData.preferredVendorID then
        for _, vendor in ipairs(vendors) do
            if tonumber(vendor.id) == tonumber(itemData.preferredVendorID)
                and Gallery.VendorHasExactLocation(vendor)
                and Gallery.VendorMatchesPlayerFaction(vendor)
            then
                return vendor
            end
        end
    end

    -- 2. Player faction vendor on current map.
    if playerFaction and currentMapID then
        for _, vendor in ipairs(vendors) do
            if Gallery.VendorHasExactLocation(vendor)
                and Gallery.GetVendorEffectiveFaction(vendor) == playerFaction
                and tonumber(vendor.mapID) == tonumber(currentMapID)
            then
                return vendor
            end
        end
    end

    -- 3. Player faction vendor anywhere.
    if playerFaction then
        for _, vendor in ipairs(vendors) do
            if Gallery.VendorHasExactLocation(vendor)
                and Gallery.GetVendorEffectiveFaction(vendor) == playerFaction
            then
                return vendor
            end
        end
    end

    -- 4. Neutral vendor with exact location.
    for _, vendor in ipairs(vendors) do
        if Gallery.VendorHasExactLocation(vendor)
            and Gallery.GetVendorEffectiveFaction(vendor) == "neutral"
        then
            return vendor
        end
    end

    -- 5. Any exact vendor matching faction rules.
    for _, vendor in ipairs(vendors) do
        if Gallery.VendorHasExactLocation(vendor)
            and Gallery.VendorMatchesPlayerFaction(vendor)
        then
            return vendor
        end
    end

    -- 6. Final fallback: any exact vendor.
    for _, vendor in ipairs(vendors) do
        if Gallery.VendorHasExactLocation(vendor) then
            return vendor
        end
    end

    return nil
end

function Gallery.TrySetVendorWaypoint(item)
    if not item then return false end

    local bestVendor = Gallery.GetBestFactionVendorForItem(item)

    if bestVendor then
        return Gallery.SetWaypointToVendor(
            bestVendor,
            Gallery.GetItemName and Gallery.GetItemName(item.itemID, item.data) or nil
        )
    end

    local vendors = Gallery.GetVendorsForItem(item)

    if #vendors > 0 then
        print("|cffffd100Decor Vendor Gallery:|r This vendor has no fixed waypoint location.")
        return false
    end

    print("|cffffd100Decor Vendor Gallery:|r No vendor location found for this item.")
    return false
end

-- ============================================================
-- Vendor name helper
-- Used by UI/Grid.lua tooltip and older GalleryUI fallback code.
-- Accepts either a full Gallery item or raw itemData.
-- ============================================================

function Gallery.GetVendorNamesFromItem(itemOrData)
    if not itemOrData then
        return nil
    end

    local item

    -- If this is already a Gallery item:
    -- item = { itemID = ..., data = {...} }
    if itemOrData.data then
        item = itemOrData
    else
        -- If this is raw itemData from Grid.lua:
        -- itemData = { itemID = ..., soldBy = {...} }
        item = {
            itemID = itemOrData.itemID or itemOrData.id,
            decorID = itemOrData.decorID,
            data = itemOrData,
        }
    end

    local entries =
        Gallery.GetSoldByEntries
        and Gallery.GetSoldByEntries(item)
        or {}

    local names = {}
    local seen = {}

    for _, entry in ipairs(entries) do
        local vendor = entry.vendor or {}
        local detail = entry.detail or entry.details or {}
        local vendorID = entry.vendorID or vendor.id

        local name =
            detail.vendorName
            or detail.name
            or vendor.title
            or vendor.name
            or (vendorID and ("Vendor " .. tostring(vendorID)))

        if name and name ~= "" and not seen[name] then
            seen[name] = true
            table.insert(names, name)
        end
    end

    -- Extra fallback in case GetSoldByEntries was unavailable or returned nothing.
    if #names == 0 then
        local data = item.data or {}

        local soldBy = data.soldBy

        if not soldBy and data.vendorID then
            soldBy = { data.vendorID }
        end

        if type(soldBy) == "number" then
            soldBy = { soldBy }
        end

        if type(soldBy) == "table" then
            for _, entry in ipairs(soldBy) do
                local vendorID

                if type(entry) == "number" then
                    vendorID = entry
                elseif type(entry) == "table" then
                    vendorID =
                        entry.vendorID
                        or entry.vendorId
                        or entry.npcID
                        or entry.npcId
                        or entry.id
                        or entry[1]
                end

                vendorID = tonumber(vendorID)

                if vendorID then
                    local name = "Vendor " .. tostring(vendorID)

                    if not seen[name] then
                        seen[name] = true
                        table.insert(names, name)
                    end
                end
            end
        end
    end

    if #names == 0 then
        return nil
    end

    table.sort(names)
    return table.concat(names, ", ")
end
-- ============================================================
-- Requirement helpers
-- ============================================================

local function GetItemRequirement(item)
    local itemDetails = Gallery.GetItemDetailsForItem(item)
    local data = GetItemData(item)

    if itemDetails then
        return itemDetails.requirement
            or itemDetails.requires
            or data.requirement
            or data.requires
    end

    return data.requirement or data.requires
end

function Gallery.GetRequirementTextForVendorEntry(entry, itemData)
    local item = entry and entry.item
    local detail = entry and (entry.detail or entry.details)

    local requirement =
        detail and (detail.requirement or detail.requires)
        or GetItemRequirement(item)
        or itemData and (itemData.requirement or itemData.requires)

    if not requirement then
        return nil
    end

    if Gallery.FormatRequirement then
        return Gallery.FormatRequirement(requirement)
    end

    if type(requirement) == "string" then
        return requirement
    end

    if type(requirement) == "table" then
        if requirement.type == "quest" then
            return "Requires " .. tostring(requirement.questName or requirement.name or requirement.questID or "quest")
        end

        if requirement.type == "renown" and requirement.rank and requirement.faction then
            return "Requires Renown " .. tostring(requirement.rank) .. " with " .. tostring(requirement.faction)
        end

        if requirement.faction and requirement.rank then
            return "Requires " .. tostring(requirement.faction) .. " - " .. tostring(requirement.rank)
        end

        if requirement.faction then
            return "Requires " .. tostring(requirement.faction)
        end
    end

    return tostring(requirement)
end

-- ============================================================
-- Vendor purchase text
-- ============================================================

local function CleanVendorZone(zone)
    if not zone or zone == "" then
        return nil
    end

    zone = tostring(zone)
    zone = zone:gsub("%s+%-%s+.*$", "")

    local afterComma = zone:match(",%s*(.+)$")
    if afterComma and afterComma ~= "" then
        zone = afterComma
    end

    zone = zone:gsub("^%s+", ""):gsub("%s+$", "")

    if zone == "" then
        return nil
    end

    return zone
end

local function CountUniqueVendorPrices(entries)
    local unique = {}
    local count = 0

    for _, entry in ipairs(entries or {}) do
        local detail = entry.detail or entry.details

        local priceText =
            detail
            and (
                NormalizePriceTextForDisplay(detail.priceText)
                or FormatPriceTable(detail.price)
            )

        if priceText and not unique[priceText] then
            unique[priceText] = true
            count = count + 1
        end
    end

    return count
end

local function StripRequiresPrefix(reqText)
    if not reqText then
        return nil
    end

    reqText = tostring(reqText)
    reqText = reqText:gsub("^%s*[Rr]equire:%s*", "")
    reqText = reqText:gsub("^%s*[Rr]equires:%s*", "")
    reqText = reqText:gsub("^%s*[Rr]equires%s+", "")
    reqText = reqText:gsub("^%s+", ""):gsub("%s+$", "")

    if reqText == "" then
        return nil
    end

    return reqText
end

function Gallery.BuildVendorPurchaseText(item)
    local data = GetItemData(item)
    local entries = Gallery.GetSoldByEntries and Gallery.GetSoldByEntries(item) or {}

    if #entries == 0 then
        return nil
    end

    for _, entry in ipairs(entries) do
        entry.item = item
    end

    local uniquePriceCount = CountUniqueVendorPrices(entries)
    local lines = {}

    -- Shared requirement detection.
    local sharedRequirementText
    local mixedRequirements = false

    for _, entry in ipairs(entries) do
        local reqText =
            Gallery.GetRequirementTextForVendorEntry
            and Gallery.GetRequirementTextForVendorEntry(entry, data)

        reqText = StripRequiresPrefix(reqText)

        if reqText then
            if not sharedRequirementText then
                sharedRequirementText = reqText
            elseif sharedRequirementText ~= reqText then
                mixedRequirements = true
            end
        end
    end

    if sharedRequirementText and not mixedRequirements then
        table.insert(lines, "|cffffd100Requires:|r " .. sharedRequirementText)
        table.insert(lines, "")
    end

    for _, entry in ipairs(entries) do
        local vendor = entry.vendor or {}
        local detail = entry.detail or entry.details or {}

        local vendorName =
            detail.vendorName
            or detail.name
            or vendor.title
            or vendor.name
            or "Unknown Vendor"

        local zone =
            detail.displayZone
            or detail.zone
            or vendor.zone
            or data.zone

        zone = CleanVendorZone(zone)

        local line = "|cff80ccffPurchase from|r |cffffd100" .. tostring(vendorName) .. "|r"

        if zone then
            line = line .. " |cffaaaaaain " .. tostring(zone) .. "|r"
        end

        table.insert(lines, line)

        local priceText =
            NormalizePriceTextForDisplay(detail.priceText)
            or FormatPriceTable(detail.price)

        if uniquePriceCount > 1 and priceText then
            table.insert(lines, "|cffffd100Cost:|r " .. tostring(priceText))
        end

        if mixedRequirements then
            local reqText =
                Gallery.GetRequirementTextForVendorEntry
                and Gallery.GetRequirementTextForVendorEntry(entry, data)

            reqText = StripRequiresPrefix(reqText)

            if reqText then
                table.insert(lines, "|cffffd100Requires:|r " .. reqText)
            end
        end
    end

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

-- ============================================================
-- Main DecorVendor source lookup/actions
-- Gallery should reuse main addon data instead of duplicating it.
-- ============================================================

local function GalleryRowMatchesItemID(row, itemID)
    if type(row) ~= "table" or not itemID then
        return false
    end

    itemID = tonumber(itemID)

    local directItemID =
        row.itemID
        or row.itemId
        or row.item
        or row.item_id

    if tonumber(directItemID) == itemID then
        return true
    end

    -- Some of your main addon source rows use id = itemID.
    -- Avoid matching random group IDs by requiring item-ish fields too.
    if tonumber(row.id) == itemID then
        if row.model3D
            or row.decorID
            or row.bossencounter
            or row.encounterID
            or row.mapID
            or row.note
            or row.name
            or row.title
        then
            return true
        end
    end

    return false
end

local function GalleryScanSourceTable(root, itemID, sourceKey, results, seen)
    if type(root) ~= "table" then
        return
    end

    if seen[root] then
        return
    end

    seen[root] = true

    if GalleryRowMatchesItemID(root, itemID) then
        table.insert(results, {
            sourceKey = sourceKey,
            row = root,
        })
    end

    for _, child in pairs(root) do
        if type(child) == "table" then
            GalleryScanSourceTable(child, itemID, sourceKey, results, seen)
        end
    end
end

function Gallery.GetMainSourceRowsForItem(item)
    local itemID = GetItemID(item)

    if not itemID then
        return {}
    end

    local results = {}
    local seen = {}

    local sourceTables = {
        { key = "boss",       root = dv.bossdrops },
        { key = "drop",       root = dv.drops },
        { key = "event",      root = dv.events },
        { key = "treasure",   root = dv.treasures or dv.treasure },
        { key = "quest",      root = dv.quests },
        { key = "achievement", root = dv.achievements },
        { key = "profession", root = dv.professions },
    }

    for _, sourceInfo in ipairs(sourceTables) do
        GalleryScanSourceTable(sourceInfo.root, itemID, sourceInfo.key, results, seen)
    end

    return results
end

local function GalleryAddLocation(locations, mapID, x, y, title)
    mapID = tonumber(mapID)
    x = tonumber(x)
    y = tonumber(y)

    if not mapID or not x or not y then
        return
    end

    table.insert(locations, {
        mapID = mapID,
        x = x,
        y = y,
        title = title or "Decor Location",
    })
end

local function GalleryCollectLocationsFromRow(row)
    local locations = {}

    if type(row) ~= "table" then
        return locations
    end

    local title =
        row.pinTitle
        or row.locationName
        or row.rareName
        or row.dropName
        or row.name
        or row.title
        or "Decor Location"

    -- Single location on the row.
    GalleryAddLocation(
        locations,
        row.mapID or row.mapId,
        row.x or row.coordX,
        row.y or row.coordY,
        title
    )

    -- Multiple possible location table names.
    local locationTables = {
        row.locations,
        row.waypoints,
        row.pins,
        row.mapPins,
    }

    for _, locationTable in ipairs(locationTables) do
        if type(locationTable) == "table" then
            for _, loc in pairs(locationTable) do
                if type(loc) == "table" then
                    local locMapID
                    local locX
                    local locY

                    if loc.mapID or loc.mapId then
                        locMapID = loc.mapID or loc.mapId
                        locX = loc.x or loc.coordX or loc[1]
                        locY = loc.y or loc.coordY or loc[2]
                    elseif loc[3] then
                        -- Supports { mapID, x, y }
                        locMapID = loc[1]
                        locX = loc[2]
                        locY = loc[3]
                    else
                        -- Supports { x, y } using parent mapID.
                        locMapID = row.mapID or row.mapId
                        locX = loc[1]
                        locY = loc[2]
                    end

                    GalleryAddLocation(
                        locations,
                        locMapID,
                        locX,
                        locY,
                        loc.title or loc.name or loc.rareName or title
                    )
                end
            end
        end
    end

    return locations
end

local function GalleryGetJournalActionFromRow(row)
    if type(row) ~= "table" then
        return nil
    end

    local encounterID =
        row.encounterID
        or row.journalEncounterID
        or row.bossencounter

    local instanceID =
        row.instanceID
        or row.journalInstanceID
        or row.ejInstanceID

    if not encounterID and not instanceID then
        return nil
    end

    return {
        type = "journal",
        encounterID = encounterID,
        instanceID = instanceID,
        difficultyID = row.difficultyID or 1,
        title = row.name or row.title or row.dropName or row.bossName or "Boss Drop",
    }
end

function Gallery.GetSpecialActionForItem(item)
    if not item then
        return nil
    end

    local itemDetails =
        Gallery.GetItemDetailsForItem
        and Gallery.GetItemDetailsForItem(item)
        or {}

    local data = GetItemData(item)

    local rows =
        Gallery.GetMainSourceRowsForItem
        and Gallery.GetMainSourceRowsForItem(item)
        or {}

    -- ========================================================
    -- 1. Main addon rare/delve/event pins
    -- Mirrors main boss line behavior:
    -- local rares = dv.rareEvents and dv.rareEvents[itemData.bossevent]
    -- ========================================================
    local allLocations = {}
    local seenLocations = {}
    local actionTitle

    local function AddLocation(mapID, x, y, title, zone)
        mapID = tonumber(mapID)
        x = tonumber(x)
        y = tonumber(y)

        if not mapID or not x or not y then
            return
        end

        local key = tostring(mapID) .. ":" .. tostring(x) .. ":" .. tostring(y) .. ":" .. tostring(title or "")

        if seenLocations[key] then
            return
        end

        seenLocations[key] = true

        table.insert(allLocations, {
            mapID = mapID,
            x = x,
            y = y,
            title = title or "Decor Location",
            zone = zone,
        })
    end

    for _, result in ipairs(rows) do
        local row = result.row or {}

		local eventKey =
		row.bossevent
		or row.rareEvent
		or row.sourceAction
		or row.eventName
		or row.dropName

		or itemDetails.sourceAction
		or itemDetails.bossevent
		or itemDetails.rareEvent
		or itemDetails.eventName
		or itemDetails.dropName

		or data.sourceAction
		or data.bossevent
		or data.rareEvent
		or data.eventName
		or data.dropName

        local rares =
            eventKey
            and dv.rareEvents
            and dv.rareEvents[eventKey]

        if type(rares) == "table" then
            actionTitle =
                row.bossevent
                or row.dropName
                or row.name
                or row.title
                or itemDetails.dropName
                or eventKey
                or "Decor Locations"

            for _, rare in ipairs(rares) do
                if type(rare) == "table" then
                    AddLocation(
                        rare.mapID or row.mapID or itemDetails.mapID or data.mapID,
                        rare.x,
                        rare.y,
                        rare.title or rare.name or actionTitle,
                        rare.zone or row.zone
                    )
                end
            end
        end

        -- Also support rows that already have direct x/y or locations.
        if GalleryCollectLocationsFromRow then
            local locations = GalleryCollectLocationsFromRow(row)

            for _, location in ipairs(locations or {}) do
                AddLocation(
                    location.mapID or row.mapID,
                    location.x,
                    location.y,
                    location.title or row.name or row.title or actionTitle,
                    location.zone or row.zone
                )
            end
        end
    end

if #allLocations > 0 then
    local parentActionKey =
        itemDetails.sourceAction
        or data.sourceAction
        or itemDetails.bossevent
        or data.bossevent
        or itemDetails.rareEvent
        or data.rareEvent
        or itemDetails.eventName
        or data.eventName
        or itemDetails.dropName
        or data.dropName

    local parentAction =
        parentActionKey
        and Gallery.C
        and Gallery.C.SOURCE_ACTIONS
        and Gallery.C.SOURCE_ACTIONS[parentActionKey]

    local openMapID =
        parentAction
        and (
            parentAction.openMapID
            or parentAction.parentMapID
            or parentAction.mapID
        )

    return {
        type = "map",
        title =
            (parentAction and parentAction.label)
            or actionTitle
            or itemDetails.dropName
            or data.dropName
            or "Decor Locations",

        locations = allLocations,

        -- This is the missing piece.
        -- For Midnight Delves this should become 2537.
        openMapID = openMapID,
    }
end

    -- ========================================================
    -- 2. Real Encounter Journal action
    -- Only use this if BOTH instanceID and encounterID exist.
    -- If you only have mapID, opening journal can land on the wrong page.
    -- ========================================================
    for _, result in ipairs(rows) do
        local row = result.row or {}

        local encounterID =
            row.encounterID
            or row.journalEncounterID
            or row.bossencounter
            or itemDetails.encounterID
            or itemDetails.bossencounter
            or data.encounterID
            or data.bossencounter

        local instanceID =
            row.instanceID
            or row.journalInstanceID
            or row.ejInstanceID
            or itemDetails.instanceID
            or itemDetails.journalInstanceID
            or data.instanceID
            or data.journalInstanceID

        if instanceID and encounterID then
            return {
                type = "journal",
                instanceID = instanceID,
                encounterID = encounterID,
                difficultyID = row.difficultyID or itemDetails.difficultyID or data.difficultyID or 1,
                title = row.name or row.title or itemDetails.dropName or data.dropName or "Boss Drop",
            }
        end
    end

    -- Direct item-details journal fallback.
    do
        local encounterID = itemDetails.encounterID or itemDetails.bossencounter or data.encounterID or data.bossencounter
        local instanceID = itemDetails.instanceID or itemDetails.journalInstanceID or data.instanceID or data.journalInstanceID

        if instanceID and encounterID then
            return {
                type = "journal",
                instanceID = instanceID,
                encounterID = encounterID,
                difficultyID = itemDetails.difficultyID or data.difficultyID or 1,
                title = itemDetails.dropName or itemDetails.bossName or data.dropName or data.bossName or "Boss Drop",
            }
        end
    end

    -- ========================================================
    -- 3. Gallery constants fallback
    -- For grouped lists like Gallery.C.SOURCE_ACTIONS["Midnight Delves"].
    -- IMPORTANT: this comes BEFORE the plain mapID/worldmap fallback,
    -- otherwise delve groups can skip their pin list.
    -- ========================================================
	
	local possibleKeys = {
    -- Explicit override first
    itemDetails.sourceAction,
    data.sourceAction,

    -- Your boss/drop event key
    itemDetails.bossevent,
    data.bossevent,

    -- Alternate event naming
    itemDetails.rareEvent,
    data.rareEvent,

    itemDetails.eventName,
    data.eventName,

    -- Normal source names
    itemDetails.dropName,
    data.dropName,

    itemDetails.delveName,
    data.delveName,

    itemDetails.treasureName,
    data.treasureName,
}

    for _, key in ipairs(possibleKeys) do
        local action =
            key
            and Gallery.C
            and Gallery.C.SOURCE_ACTIONS
            and Gallery.C.SOURCE_ACTIONS[key]

        if type(action) == "table" then
            if action.type == "map" then
                local locations = {}

                if type(action.locations) == "table" then
                    for _, loc in ipairs(action.locations) do
                        if type(loc) == "table" then
                            local mapID = loc.mapID or action.mapID
                            local x = loc.x
                            local y = loc.y

                            if mapID and x and y then
                                table.insert(locations, {
                                    mapID = mapID,
                                    x = x,
                                    y = y,
                                    title = loc.title or loc.name or loc.label or action.label or key,
                                    zone = loc.zone or action.zone,
                                })
                            end
                        end
                    end
                elseif action.mapID and action.x and action.y then
                    table.insert(locations, {
                        mapID = action.mapID,
                        x = action.x,
                        y = action.y,
                        title = action.label or key,
                        zone = action.zone,
                    })
                end

                if #locations > 0 then
return {
    type = "map",
    title = action.label or key,
    locations = locations,

    -- Optional parent/overview map to open after pinning.
    openMapID = action.openMapID or action.parentMapID or action.mapID,
}
                end
            end

            if action.type == "worldmap" and action.mapID then
                return {
                    type = "worldmap",
                    mapID = action.mapID,
                    title = action.label or key,
                }
            end

            if action.type == "journal" then
                local encounterID = action.encounterID or action.bossencounter
                local instanceID = action.instanceID or action.journalInstanceID

                if instanceID and encounterID then
                    return {
                        type = "journal",
                        instanceID = instanceID,
                        encounterID = encounterID,
                        difficultyID = action.difficultyID or 1,
                        title = action.label or key,
                    }
                end
            end
        end
    end

    -- ========================================================
    -- 4. Boss/drop rows with only mapID
    -- Mirrors your main addon boss right-click behavior:
    -- C_Map.OpenWorldMap(itemData.mapID)
    -- ========================================================
    for _, result in ipairs(rows) do
        local row = result.row or {}

        if row.mapID then
            return {
                type = "worldmap",
                mapID = row.mapID,
                title = row.name or row.title or itemDetails.dropName or data.dropName or "Boss Map",
            }
        end
    end

    if itemDetails.mapID and not itemDetails.x and not itemDetails.y then
        return {
            type = "worldmap",
            mapID = itemDetails.mapID,
            title = itemDetails.dropName or itemDetails.bossName or "Map",
        }
    end

    if data.mapID and not data.x and not data.y then
        return {
            type = "worldmap",
            mapID = data.mapID,
            title = data.dropName or data.bossName or itemDetails.dropName or "Map",
        }
    end

    return nil
end

function Gallery.SetGalleryMapWaypoints(action)
    if not action or type(action.locations) ~= "table" or #action.locations == 0 then
        print("|cffff4040Decor Vendor Gallery:|r No locations found for this source.")
        return false
    end

    local firstLocation = action.locations[1]

    local openMapID = tonumber(
        action.openMapID
        or action.parentMapID
        or action.mapID
    )

    local hasTomTom = TomTom and TomTom.AddWaypoint

    -- ========================================================
    -- Add all real pins using their own child mapIDs.
    -- ========================================================
    for _, location in ipairs(action.locations) do
        local mapID = tonumber(location.mapID)
        local x = tonumber(location.x)
        local y = tonumber(location.y)

        if mapID and x and y then
            local nx = x > 1 and x / 100 or x
            local ny = y > 1 and y / 100 or y

            local title =
                location.title
                or location.name
                or action.title
                or "Decor Location"

            if location.zone then
                title = tostring(title) .. " - " .. tostring(location.zone)
            end

            if hasTomTom then
                TomTom:AddWaypoint(mapID, nx, ny, {
                    title = title,
                    persistent = false,
                    minimap = true,
                    world = true,
                })
            end
        end
    end

    -- ========================================================
    -- Blizzard fallback:
    -- Only set a native waypoint if we are NOT opening a parent map.
    -- If we set a native waypoint while using openMapID, WoW yanks
    -- the map back into the first child zone.
    -- ========================================================
    if not hasTomTom then
        if openMapID then
            print("|cffffd100Decor Vendor Gallery:|r TomTom is needed to pin all delve entrances. Opening the overview map instead.")
        elseif firstLocation then
            local mapID = tonumber(firstLocation.mapID)
            local x = tonumber(firstLocation.x)
            local y = tonumber(firstLocation.y)

            if mapID and x and y and C_Map and C_Map.SetUserWaypoint and UiMapPoint then
                local nx = x > 1 and x / 100 or x
                local ny = y > 1 and y / 100 or y

                local point

                if UiMapPoint.CreateFromCoordinates then
                    point = UiMapPoint.CreateFromCoordinates(mapID, nx, ny)
                elseif UiMapPoint.CreateFromVector2D and CreateVector2D then
                    point = UiMapPoint.CreateFromVector2D(mapID, CreateVector2D(nx, ny))
                end

                if point then
                    C_Map.SetUserWaypoint(point)

                    if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
                        C_SuperTrack.SetSuperTrackedUserWaypoint(true)
                    end
                end
            end
        end
    end

    -- ========================================================
    -- Open the parent/overview map if provided.
    -- Otherwise open the first location's map.
    -- Use a tiny delay so TomTom/user waypoint logic does not yank it.
    -- ========================================================
    local mapToOpen = tonumber(
        openMapID
        or (firstLocation and firstLocation.mapID)
    )

    if mapToOpen then
        C_Timer.After(0.05, function()
            if C_Map and C_Map.OpenWorldMap then
                C_Map.OpenWorldMap(mapToOpen)
                return
            end

            if WorldMapFrame then
                ShowUIPanel(WorldMapFrame)

                if WorldMapFrame.SetMapID then
                    WorldMapFrame:SetMapID(mapToOpen)
                end
            elseif OpenWorldMap then
                OpenWorldMap(mapToOpen)
            end
        end)
    end

    print("|cff00ccffDecor Vendor Gallery:|r Location pins added for " .. tostring(action.title or "decor source") .. ".")

    return true
end

function Gallery.OpenJournalForItemAction(action)
    if not action then
        return false
    end

    local instanceID = tonumber(action.instanceID)
    local encounterID = tonumber(action.encounterID)
    local difficultyID = tonumber(action.difficultyID) or 1

    if not instanceID or not encounterID then
        print("|cffff4040Decor Vendor Gallery:|r Missing instanceID or encounterID for Encounter Journal action.")
        return false
    end

    if EncounterJournal_LoadUI then
        EncounterJournal_LoadUI()
    end

    if EncounterJournal then
        ShowUIPanel(EncounterJournal)
    end

    if EJ_SetDifficulty then
        pcall(EJ_SetDifficulty, difficultyID)
    end

    if EJ_SelectInstance then
        pcall(EJ_SelectInstance, instanceID)
    end

    if EJ_SelectEncounter then
        -- Tiny delay helps the Encounter Journal finish loading the instance first.
        if C_Timer and C_Timer.After then
            C_Timer.After(0.10, function()
                if EncounterJournal then
                    ShowUIPanel(EncounterJournal)
                end

                pcall(EJ_SelectEncounter, encounterID)
            end)
        else
            pcall(EJ_SelectEncounter, encounterID)
        end

        return true
    end

    if EncounterJournal_OpenJournal then
        EncounterJournal_OpenJournal(difficultyID, instanceID, encounterID)
        return true
    end

    print("|cffff4040Decor Vendor Gallery:|r Could not open Encounter Journal.")
    return false
end

function Gallery.RunSpecialItemAction(item)
    local action =
        Gallery.GetSpecialActionForItem
        and Gallery.GetSpecialActionForItem(item)

    if not action then
        return false
    end

    if action.type == "journal" then
        return Gallery.OpenJournalForItemAction(action)
    end

    if action.type == "map" then
        return Gallery.SetGalleryMapWaypoints(action)
    end

    if action.type == "worldmap" then
        return Gallery.OpenWorldMapForAction(action)
    end

    return false
end

function Gallery.OpenWorldMapForAction(action)
    if not action or not action.mapID then
        return false
    end

    local mapID = tonumber(action.mapID)

    if not mapID then
        return false
    end

    if C_Map and C_Map.OpenWorldMap then
        C_Map.OpenWorldMap(mapID)
        return true
    end

    if WorldMapFrame then
        ShowUIPanel(WorldMapFrame)

        if WorldMapFrame.SetMapID then
            WorldMapFrame:SetMapID(mapID)
        end

        return true
    end

    return false
end
-- ============================================================
-- Preview source/details text
-- ============================================================

local function AddLine(lines, text)
    if text and text ~= "" then
        table.insert(lines, text)
    end
end

local function AddBlank(lines)
    if #lines > 0 and lines[#lines] ~= "" then
        table.insert(lines, "")
    end
end

local function GetSourceHeader(item)
    if Gallery.GetSourceSummaryText then
        local text = Gallery.GetSourceSummaryText(item)
        if text and text ~= "" then
            return text
        end
    end

    if Gallery.GetSourceTextForItem then
        local text = Gallery.GetSourceTextForItem(item)
        if text and text ~= "" then
            return text
        end
    end

    local source =
        Gallery.GetDetailField(item, "source")
        or Gallery.GetDetailField(item, "sourceType")
        or "Unknown"

    local labels = Gallery.C and Gallery.C.SOURCE_LABELS

    if labels and labels[source] then
        return labels[source]
    end

    return tostring(source)
end

local function CleanDisplayText(text)
    if type(text) ~= "string" then
        return nil
    end

    text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
    text = text:gsub("|r", "")
    text = text:gsub("^%s+", ""):gsub("%s+$", "")

    if text == "" then
        return nil
    end

    return text
end

local function AddUniqueText(list, seen, text)
    text = CleanDisplayText(text)

    if not text or seen[text] then
        return
    end

    seen[text] = true
    table.insert(list, text)
end

local function FindBestMainSourceRowForEvent(item, eventKey)
    local rows =
        Gallery.GetMainSourceRowsForItem
        and Gallery.GetMainSourceRowsForItem(item)
        or {}

    for _, result in ipairs(rows) do
        local row = result.row or {}

        if row.bossevent == eventKey
            or row.rareEvent == eventKey
            or row.sourceAction == eventKey
            or row.eventName == eventKey
            or row.dropName == eventKey
        then
            return row
        end
    end

    return rows[1] and rows[1].row or nil
end

function Gallery.BuildEventDropSourceText(item)
    if not item then
        return nil
    end

    local itemDetails =
        Gallery.GetItemDetailsForItem
        and Gallery.GetItemDetailsForItem(item)
        or {}

    local data = GetItemData(item)

    local eventKey =
        itemDetails.sourceAction
        or data.sourceAction
        or itemDetails.bossevent
        or data.bossevent
        or itemDetails.rareEvent
        or data.rareEvent
        or itemDetails.eventName
        or data.eventName
        or itemDetails.dropName
        or data.dropName
        or GetCatalogLineValue(item, "Drop")

    eventKey = CleanDisplayText(eventKey)

    if not eventKey then
        return nil
    end

    local action =
        Gallery.C
        and Gallery.C.SOURCE_ACTIONS
        and Gallery.C.SOURCE_ACTIONS[eventKey]

    local row = FindBestMainSourceRowForEvent(item, eventKey)

    local rareEvents =
        dv
        and dv.rareEvents
        and dv.rareEvents[eventKey]

    -- Only take over formatting if this is a grouped map/pin source.
    if type(action) ~= "table" and type(rareEvents) ~= "table" then
        return nil
    end

    local locations = {}
    local zones = {}
    local zoneOrder = {}
    local zoneSeen = {}

    local function AddLocation(title, zone, mapID, x, y)
        title = CleanDisplayText(title)
        zone = CleanDisplayText(zone)

        if not title then
            title = "Unknown Location"
        end

        table.insert(locations, {
            title = title,
            zone = zone,
            mapID = mapID,
            x = x,
            y = y,
        })

        if zone then
            if not zones[zone] then
                zones[zone] = {}
            end

            table.insert(zones[zone], title)

            if not zoneSeen[zone] then
                zoneSeen[zone] = true
                table.insert(zoneOrder, zone)
            end
        end
    end

    if type(action) == "table" and type(action.locations) == "table" then
        for _, loc in ipairs(action.locations) do
            if type(loc) == "table" then
                AddLocation(
                    loc.title or loc.name or action.label or eventKey,
                    loc.zone or action.zone,
                    loc.mapID or action.mapID,
                    loc.x,
                    loc.y
                )
            end
        end
    elseif type(rareEvents) == "table" then
        for _, rare in ipairs(rareEvents) do
            if type(rare) == "table" then
                AddLocation(
                    rare.title or rare.name or eventKey,
                    rare.zone or (row and row.zone) or data.zone,
                    rare.mapID or (row and row.mapID) or data.mapID,
                    rare.x,
                    rare.y
                )
            end
        end
    end

    local eventLabel =
        (action and action.label)
        or (row and row.bossevent)
        or (row and row.eventName)
        or eventKey

    local lines = {}

    AddLine(lines, "|cff00ffffEvent:|r " .. tostring(eventLabel))

    -- Darkshore style: one zone, list rares.
    if #zoneOrder <= 1 then
        local locationText =
            zoneOrder[1]
            or CleanDisplayText(row and row.zone)
            or CleanDisplayText(data.zone)
            or CleanDisplayText(action and action.zone)

        if locationText then
            AddLine(lines, "|cff00ffffLocation:|r " .. tostring(locationText))
        end

        AddBlank(lines)
        AddLine(lines, "|cff00ff66Note:|r Drops from:")

        for _, loc in ipairs(locations) do
            AddLine(lines, "• " .. tostring(loc.title))
        end

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

    -- Multi-zone style: compact grouped list.
    AddLine(lines, "|cff00ffffLocation:|r Multiple delve entrances")
    AddBlank(lines)
    AddLine(lines, "|cff00ff66Entrances:|r")

    for _, zone in ipairs(zoneOrder) do
        local names = zones[zone] or {}

        if #names > 0 then
            AddLine(
                lines,
                "|cffffd100" .. tostring(zone) .. ":|r " .. table.concat(names, ", ")
            )
        end
    end

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

function Gallery.BuildPreviewSourceText(item)
    if not item then
        return "Unknown"
    end

    local itemDetails = Gallery.GetItemDetailsForItem(item) or {}
    local data = GetItemData(item) or {}

    local lines = {}

    local function GetSourceActionNote()
        local possibleKeys = {
            itemDetails.sourceAction,
            data.sourceAction,

            itemDetails.bossevent,
            data.bossevent,

            itemDetails.rareEvent,
            data.rareEvent,

            itemDetails.eventName,
            data.eventName,

            itemDetails.dropName,
            data.dropName,

            itemDetails.delveName,
            data.delveName,

            itemDetails.treasureName,
            data.treasureName,
        }

        for _, key in ipairs(possibleKeys) do
            local action =
                key
                and Gallery.C
                and Gallery.C.SOURCE_ACTIONS
                and Gallery.C.SOURCE_ACTIONS[key]

            if type(action) == "table" and action.note then
                return action.note
            end
        end

        return nil
    end

    local function NormalizeNoteText(text)
        text = tostring(text or "")

        -- Remove WoW color codes.
        text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
        text = text:gsub("|r", "")

        -- Normalize whitespace.
        text = text:gsub("%s+", " ")
        text = text:lower()

        return text
    end

    local function GetProfessionText()
        return itemDetails.professionText
            or itemDetails.profession
            or data.profession
            or GetCatalogLineValue(item, "Profession")
    end

    local function GetRecipeText()
        local recipeName =
            itemDetails.recipeName
            or data.recipeName

        local recipeItemID =
            itemDetails.recipeItemID
            or data.recipeItemID
            or itemDetails.recipe
            or data.recipe

        if recipeName and recipeName ~= "" then
            return tostring(recipeName)
        end

        if recipeItemID then
            return "Recipe Item " .. tostring(recipeItemID)
        end

        return nil
    end

    local eventDropText =
        Gallery.BuildEventDropSourceText
        and Gallery.BuildEventDropSourceText(item)

    if eventDropText then
        local prefixLines = {}

        local professionText = GetProfessionText()
        if professionText and professionText ~= "" then
            table.insert(prefixLines, "|cffffd100Profession:|r " .. tostring(professionText))
        end

        local recipeText = GetRecipeText()
        if recipeText and recipeText ~= "" then
            table.insert(prefixLines, "|cffffd100Learned From:|r " .. tostring(recipeText))
        end

        if #prefixLines > 0 then
            eventDropText =
                table.concat(prefixLines, "\n")
                .. "\n\n"
                .. eventDropText
        end

        local extraNote =
            itemDetails.note
            or data.note
            or GetSourceActionNote()

        if extraNote and extraNote ~= "" then
            local existingText = NormalizeNoteText(eventDropText)
            local noteText = NormalizeNoteText(extraNote)

            -- Only append the note if the event/drop text did not already include it.
            if noteText ~= "" and not existingText:find(noteText, 1, true) then
                eventDropText = eventDropText .. "\n\n|cffaaaaaaNote:|r " .. tostring(extraNote)
            end
        end

        return eventDropText
    end

    local function HasSource(sourceKey)
        sourceKey = Gallery.NormalizeSource and Gallery.NormalizeSource(sourceKey) or sourceKey

        local rawSource =
            Gallery.GetDetailField(item, "source")
            or Gallery.GetDetailField(item, "sourceType")

        rawSource = Gallery.NormalizeSource and Gallery.NormalizeSource(rawSource) or rawSource

        if rawSource == sourceKey then
            return true
        end

        local dataSources = data.sources

        if type(dataSources) == "table" then
            for _, source in ipairs(dataSources) do
                source = Gallery.NormalizeSource and Gallery.NormalizeSource(source) or source

                if source == sourceKey then
                    return true
                end
            end
        end

        local itemSources = item.sources

        if type(itemSources) == "table" and itemSources[sourceKey] then
            return true
        end

        return false
    end

    local function GetAtlasMarkupForSource(sourceKey, size)
        size = size or 16

        local atlas =
            Gallery.C
            and Gallery.C.SOURCE_ATLAS_ICONS
            and Gallery.C.SOURCE_ATLAS_ICONS[sourceKey]

        if atlas and CreateAtlasMarkup then
            return CreateAtlasMarkup(atlas, size, size)
        end

        return nil
    end

    if HasSource("shop") or itemDetails.shopName then
        local icon = GetAtlasMarkupForSource("shop", 16)
        local shopText = itemDetails.shopName or data.shopName or "In-Game Shop"

        AddLine(lines, "|cffffd100In-Game Shop:|r")

        if icon then
            AddLine(lines, icon .. " |cff80ff80" .. tostring(shopText) .. "|r")
        else
            AddLine(lines, "|cff80ff80" .. tostring(shopText) .. "|r")
        end
    end

    -- Quest.
    local questName =
        itemDetails.questName
        or data.questName
        or GetCatalogLineValue(item, "Quest")

    local questID =
        itemDetails.questID
        or data.questID

    if questName or questID then
        AddBlank(lines)
        AddLine(lines, "|cffffd100Quest:|r")
        AddLine(lines, "|cff80ff80" .. tostring(questName or ("Quest " .. tostring(questID))) .. "|r")
    end

    -- Achievement.
    local achievementInfo =
        Gallery.GetAchievementForItem
        and Gallery.GetAchievementForItem(item)

    local achievementName =
        itemDetails.achievementName
        or data.achievementName
        or (achievementInfo and (achievementInfo.name or achievementInfo.achievementName))
        or GetCatalogLineValue(item, "Achievement")

    local achievementID =
        itemDetails.achievementID
        or data.achievementID
        or (achievementInfo and achievementInfo.id)

    if achievementName or achievementID then
        AddBlank(lines)
        AddLine(lines, "|cffffd100Achievement:|r")
        AddLine(lines, "|cff80ff80" .. tostring(achievementName or ("Achievement " .. tostring(achievementID))) .. "|r")
    end

    local achievementCategory =
        itemDetails.achievementCategory
        or data.achievementCategory

    -- Backwards support:
    -- If this item has an achievement and still uses category = "Quests",
    -- treat that as the achievement frame category.
    if not achievementCategory and (achievementName or achievementID) then
        achievementCategory =
            itemDetails.category
            or data.category
    end

    if achievementCategory and (achievementName or achievementID) then
        AddLine(lines, "|cffaaaaaaAchievement Category:|r " .. tostring(achievementCategory))
    else
        local category =
            itemDetails.category
            or data.category
            or GetCatalogLineValue(item, "Category")

        if category then
            AddLine(lines, "|cffaaaaaaCategory:|r " .. tostring(category))
        end
    end

    -- Profession.
    local professionText = GetProfessionText()

    if professionText then
        AddBlank(lines)
        AddLine(lines, "|cffffd100Profession:|r " .. tostring(professionText))
    end

    -- Recipe / Learned From.
    local recipeText = GetRecipeText()

    if recipeText then
        AddBlank(lines)
        AddLine(lines, "|cffffd100Learned From:|r " .. tostring(recipeText))
    end

    -- Drop / Treasure.
    local dropName =
        itemDetails.dropName
        or itemDetails.bossName
        or data.dropName
        or data.bossName
        or GetCatalogLineValue(item, "Drop")

    local dropZone =
        itemDetails.dropZone
        or itemDetails.zone
        or itemDetails.location
        or data.dropZone
        or data.zone
        or data.location
        or GetCatalogLineValue(item, "Location")
        or GetCatalogLineValue(item, "Zone")

    if dropName then
        AddBlank(lines)
        AddLine(lines, "|cffffd100Drop:|r " .. tostring(dropName))

        if dropZone and dropZone ~= "" then
            AddLine(lines, "|cff00ffffLocation:|r " .. tostring(dropZone))
        end
    end

    local treasureName =
        itemDetails.treasureName
        or data.treasureName
        or GetCatalogLineValue(item, "Treasure")

    if treasureName then
        AddBlank(lines)
        AddLine(lines, "|cffffd100Treasure:|r " .. tostring(treasureName))
    end

    -- Vendor details.
    local vendorText =
        Gallery.BuildVendorPurchaseText
        and Gallery.BuildVendorPurchaseText(item)

    if vendorText then
        AddBlank(lines)
        AddLine(lines, vendorText)
    end

    -- Requirement for non-vendor items only.
    local vendorDetails = Gallery.GetVendorDetailsForItem(item)

    local hasVendor =
        data.soldBy
        or data.vendorID
        or (type(vendorDetails) == "table" and next(vendorDetails) ~= nil)
        or vendorText

    if not hasVendor then
        local requirement = GetItemRequirement(item)

        if requirement then
            local reqText

            if Gallery.FormatRequirement then
                reqText = Gallery.FormatRequirement(requirement)
            elseif type(requirement) == "string" then
                reqText = requirement
            end

            reqText = StripRequiresPrefix(reqText)

            if reqText then
                AddBlank(lines)
                AddLine(lines, "|cffffd100Requires:|r " .. reqText)
            end
        end
    end

    -- Notes.
    if itemDetails.note or data.note then
        AddBlank(lines)
        AddLine(lines, "|cffaaaaaaNote:|r " .. tostring(itemDetails.note or data.note))
    end

    -- Only show the generic source header if we had no richer details.
    -- This prevents double labels like:
    -- Achievement
    -- Achievement:
    if #lines == 0 then
        local sourceHeader = GetSourceHeader(item)

        if sourceHeader and sourceHeader ~= "" then
            return sourceHeader
        end

        if OldBuildPreviewSourceText then
            return OldBuildPreviewSourceText(item)
        end

        return "Unknown"
    end

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