--[[
============================================================================
ETB - MapperBridge.lua v2.0 (Compact Format)
============================================================================
Single data layer replacing ALL old static source data files.

READS FROM (in priority order):
  1. NS.Shipped (verbose format from ETBDataMapper SavedVariables or old shipped)
  2. NS.Compact (compact positional arrays — 65% smaller, lazy-decoded)
  3. ETB.SourceDB (source enrichment — boss names, instance keys)

COMPACT FORMAT (positions 1-20 + optional trailing):
  1:sID 2:npc 3:st(idx) 4:eID 5:iID 6:mID 7:diff 8:q 9:b 10:lvl
  11:f 12:u 13:awp 14:mod 15:flags 16:zone(idx) 17:instN(idx)
  18:encN(idx) 19:qID 20:rwp
  21:co 22:vars 23:cl 24:npcNi 25:zones(idx) 26:srcs 27:cost 28:clf

SOURCEDB ENRICHMENT (Data/Source/):
  When compact data is missing encounter names or instance names,
  SourceDB provides them from 18,845 enrichment-merged items.
  Also provides fallback source data for items not in compact data.

PROVIDES the exact API surface consumed by:
  - Core/SourceMapper.lua   → ETB.SourceData_ATT:GetPrimarySource/GetAllSources
  - Core/Commands.lua       → ETB.SourceData_ATT:GetCount/HasSource/GetRawSource
  - Core/BlizzardData.lua   → ETB.SourceData:GetSourceFormatted
  - Modules/TooltipEnhancer → ETB.SourceDataV2:GetPrimarySource (fallback)
  - UI/SourcesModal.lua     → ETB.SourceData_ATT:GetAllSources
============================================================================
]]--

local ADDON_NAME, NS = ...
local ETB = NS.ETB

-- ============================================================================
-- DIFFICULTY / WORLD BOSS / RAID TABLES
-- ============================================================================

local DIFF_NAMES = {
    [1] = "Normal", [2] = "Heroic", [3] = "10N", [4] = "25N",
    [5] = "10H", [6] = "25H", [7] = "LFR", [8] = "Challenge Mode",
    [9] = "40 Player", [14] = "Flex Normal", [15] = "Flex Heroic",
    [16] = "Mythic", [17] = "LFR",
    [174] = "Alpha (Heroic+)", [175] = "Beta (Heroic++)", [176] = "Gamma (Heroic+++)",
    [215] = "Inferno", [216] = "Inferno+", [217] = "Inferno++",
    [220] = "Celestial",
}

local WORLD_BOSS_ZONES = {
    [691] = "Kun-Lai Summit", [725] = "Valley of the Four Winds",
    [814] = "Isle of Thunder", [826] = "Isle of Giants",
    [857] = "Timeless Isle", [858] = "Timeless Isle",
    [859] = "Timeless Isle", [860] = "Timeless Isle", [861] = "Timeless Isle",
}

local RAID_INSTANCES = {
    [249]=true,[309]=true,[409]=true,[469]=true,[509]=true,[531]=true,[533]=true,
    [532]=true,[534]=true,[544]=true,[548]=true,[550]=true,[564]=true,[565]=true,[580]=true,
    [603]=true,[615]=true,[616]=true,[624]=true,[631]=true,[649]=true,[724]=true,
    [669]=true,[671]=true,[720]=true,[754]=true,[967]=true,
    [996]=true,[1008]=true,[1009]=true,[1098]=true,[1136]=true,
}

local SOURCE_TYPE_MAP = {
    BOSS = "BOSS", VENDOR = "VENDOR", QUEST = "QUEST",
    WORLD_DROP = "WORLD_DROP", CRAFTED = "CRAFTED",
    PVP = "PVP", ACHIEVEMENT = "ACHIEVEMENT",
    EVENT = "WORLD_DROP", REPUTATION = "VENDOR",
    RARE_MOB = "BOSS", SCENARIO = "BOSS",
    REMOVED = "WORLD_DROP", TREASURE = "WORLD_DROP",
    OTHER = "WORLD_DROP", UNKNOWN = "UNKNOWN",
    ARCHAEOLOGY = "WORLD_DROP",
}

local PROF_NAMES = {
    [164] = "Blacksmithing", [165] = "Leatherworking",
    [171] = "Alchemy", [182] = "Herbalism", [185] = "Cooking",
    [186] = "Mining", [197] = "Tailoring", [202] = "Engineering",
    [333] = "Enchanting", [356] = "Fishing", [393] = "Skinning",
    [755] = "Jewelcrafting", [773] = "Inscription",
}

local CLASS_NAMES = {
    [1] = "Warrior", [2] = "Paladin", [3] = "Hunter", [4] = "Rogue",
    [5] = "Priest", [6] = "Death Knight", [7] = "Shaman", [8] = "Mage",
    [9] = "Warlock", [10] = "Monk", [11] = "Druid",
}

local REP_NAMES = {
    [0] = "Hated", [1] = "Hostile", [2] = "Unfriendly", [3] = "Neutral",
    [4] = "Friendly", [5] = "Honored", [6] = "Revered", [7] = "Exalted",
}

-- ============================================================================
-- COMPACT FORMAT DECODER
-- ============================================================================

local function DecodeCompactItem(raw, ZN, EN, NN, ST, expName)
    -- v15.37.0: items ship as Lua-literal strings; expand to the positional
    -- array here (tables pass straight through for verbose/AuditPatch data).
    if NS.Compact and NS.Compact.Decode then raw = NS.Compact.Decode(raw) end
    if not raw or type(raw) ~= "table" then return nil end

    local flags = raw[15] or 0
    local stIdx = raw[3] or 0
    local zoneIdx = raw[16] or 0
    local instIdx = raw[17] or 0
    local encIdx = raw[18] or 0

    local entry = {
        sID   = (raw[1] and raw[1] ~= 0) and raw[1] or nil,
        npc   = (raw[2] and raw[2] ~= 0) and raw[2] or nil,
        st    = (stIdx > 0 and ST[stIdx]) or "UNKNOWN",
        eID   = (raw[4] and raw[4] ~= 0) and raw[4] or nil,
        iID   = (raw[5] and raw[5] ~= 0) and raw[5] or nil,
        mID   = (raw[6] and raw[6] ~= 0) and raw[6] or nil,
        diff  = (raw[7] and raw[7] ~= 0) and raw[7] or nil,
        q     = (raw[8] and raw[8] ~= 0) and raw[8] or nil,
        b     = (raw[9] and raw[9] ~= 0) and raw[9] or nil,
        lvl   = (raw[10] and raw[10] ~= 0) and raw[10] or nil,
        f     = (raw[11] and raw[11] ~= 0) and raw[11] or nil,
        u     = (raw[12] and raw[12] ~= 0) and raw[12] or nil,
        awp   = (raw[13] and raw[13] ~= 0) and raw[13] or nil,
        mod   = (raw[14] and raw[14] ~= 0) and raw[14] or nil,
        raid  = (flags >= 1 and flags % 2 == 1) and 1 or nil,
        pvp   = (flags >= 2 and math.floor(flags / 2) % 2 == 1) and 1 or nil,
        zone  = (zoneIdx > 0 and ZN[zoneIdx]) or nil,
        instN = (instIdx > 0 and ZN[instIdx]) or nil,
        encN  = (encIdx > 0 and EN[encIdx]) or nil,
        qID   = (raw[19] and raw[19] ~= 0) and raw[19] or nil,
        rwp   = (raw[20] and raw[20] ~= 0) and raw[20] or nil,
        _exp  = expName,
    }

    -- Optional trailing fields (21+)
    local co = raw[21]
    if co and type(co) == "table" then entry.co = co end

    local vars = raw[22]
    if vars and type(vars) == "table" then entry.vars = vars end

    local cl = raw[23]
    if cl and type(cl) == "table" then entry.cl = cl end

    local npcNi = raw[24]
    if npcNi and type(npcNi) == "table" then
        local names = {}
        for _, idx in ipairs(npcNi) do
            names[#names + 1] = NN[idx] or "Unknown"
        end
        entry.npcNs = names
    end

    local zoneIdxs = raw[25]
    if zoneIdxs and type(zoneIdxs) == "table" then
        local zones = {}
        for _, idx in ipairs(zoneIdxs) do
            zones[#zones + 1] = ZN[idx] or nil
        end
        entry.zones = zones
    end

    local srcs = raw[26]
    if srcs and type(srcs) == "table" and #srcs > 0 then
        local decoded = {}
        for _, src in ipairs(srcs) do
            if type(src) == "table" then
                local s = {}
                if src[1] and src[1] ~= 0 then s.eID = src[1] end
                if src[2] and src[2] ~= 0 then s.iID = src[2] end
                if src[3] and src[3] ~= 0 then s.npc = src[3] end
                if src[4] and src[4] ~= 0 then s.st = ST[src[4]] or "UNKNOWN" end
                if src[5] and src[5] ~= 0 then s.diff = src[5] end
                if src[6] and src[6] ~= 0 then s.qID = src[6] end
                if src[7] and type(src[7]) == "table" then s.co = src[7] end
                decoded[#decoded + 1] = s
            end
        end
        if #decoded > 0 then entry.srcs = decoded end
    end

    local cost = raw[27]
    if cost and type(cost) == "table" and #cost > 0 then entry.cost = cost end

    local clf = raw[28]
    if clf and clf ~= 0 then entry.clf = clf end

    -- Enrich raid instances table
    if entry.raid == 1 and entry.iID then
        RAID_INSTANCES[entry.iID] = true
    end

    return entry
end

-- ============================================================================
-- INTERNAL: Unified item lookup with lazy decoding
-- ============================================================================

local allItems = nil
local nameTable = nil
local compactLookup = nil  -- {itemID -> {expKey, raw}} for lazy decode
local _ensureBuilding = false  -- true while a chunked EnsureLoaded build is in flight

-- The five big Compact expansion files now live in a sibling LoadOnDemand addon
-- (EnhancedTransmogBrowser_Data) so they aren't compiled at every login. Pull it
-- in the first time we actually need appearance source data. LoadAddOn is
-- synchronous, so once this returns the expansion proxies (NS.Compact.Classic …)
-- are present in the shared Compact table and the lookup below sees them. Tries
-- the modern C_AddOns API first, falls back to the legacy global. Idempotent: a
-- second call after the addon is loaded is a cheap no-op in the WoW client.
-- v15.203.1: the monolithic EnhancedTransmogBrowser_Data sibling was split into
-- five per-expansion LoadOnDemand addons. One LoadAddOn parsing 6.8MB (~68ms) is
-- un-chunkable; five smaller ones (~11-22ms each) load in their own coroutine
-- slice with a yield between, so the parse spreads across frames (A1). It also
-- opens the door to loading only the expansion(s) actually browsed (A2).
local EXP_ADDONS = {
    Classic = "EnhancedTransmogBrowser_Data_Classic",
    TBC     = "EnhancedTransmogBrowser_Data_TBC",
    Wrath   = "EnhancedTransmogBrowser_Data_Wrath",
    Cata    = "EnhancedTransmogBrowser_Data_Cata",
    MoP     = "EnhancedTransmogBrowser_Data_MoP",
}
local _expLoaded = {}   -- expKey -> true once its addon's proxy is installed
local _expWarned = {}   -- expKey -> true once a partial-install warning was shown

-- Load one expansion's data addon (installs NS.Compact.<exp> proxy). Idempotent;
-- a no-op for Audit* keys (no addon — they ship inside the main addon, if at all)
-- and for already-loaded expansions. Returns true if the addon exists & was tried.
local function LoadExpansionAddon(expKey)
    local addon = EXP_ADDONS[expKey]
    if not addon then return false end
    if _expLoaded[expKey] then return true end
    local fn = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn
    if fn then
        local t0 = ETB and ETB.Perf and debugprofilestop and debugprofilestop()
        pcall(fn, addon)
        if t0 then ETB.Perf:Record("data.loadAddon." .. expKey, debugprofilestop() - t0) end
    end
    -- Defensive: partial install (main folder copied without a data sibling) → no
    -- appearance source for that expansion. Warn once; failing loudly beats empty.
    local isLoaded = (C_AddOns and C_AddOns.IsAddOnLoaded) or IsAddOnLoaded
    if isLoaded and isLoaded(addon) then _expLoaded[expKey] = true end
    if isLoaded and not _expLoaded[expKey] and not _expWarned[expKey] and ETB and ETB.Print then
        _expWarned[expKey] = true
        ETB:Print("|cffff6666ETB:|r appearance data for |cffffd100" .. expKey .. "|r ("
            .. addon .. ") isn't installed. Reinstall the full package — all data folders "
            .. "must be in Interface/AddOns.")
    end
    if not isLoaded then _expLoaded[expKey] = true end
    return _expLoaded[expKey] == true
end
ETB.LoadExpansionData = LoadExpansionAddon  -- exposed for per-expansion warming (A2)

-- Back-compat: force-load EVERY expansion (full-data callers / diagnostics).
local function LoadDataAddon()
    for _, k in ipairs({ "Classic", "TBC", "Wrath", "Cata", "MoP" }) do
        LoadExpansionAddon(k)
    end
end
ETB.LoadCompactData = LoadDataAddon  -- exposed so diagnostics can force-load too

-- v15.203.1 A2: per-expansion lazy load. _loadedExp records which expansions are
-- currently reflected in compactLookup; _allLoaded is the fast path for full-data
-- callers. EnsureLoaded(want) loads only the requested expansions (plus whatever's
-- already loaded) and rebuilds the lookup from that union — so a MoP-only scan
-- keeps just Compact.MoP resident. EnsureLoaded() with no arg means "all" and is
-- byte-for-byte the old behavior, so every existing caller is unchanged.
local _loadedExp = {}
local _allLoaded = false
local ALL_EXP = { "Classic", "TBC", "Wrath", "Cata", "MoP" }

-- The set of expansions that should be present after a call: always keep what's
-- already loaded, then add the request (nil request = all five).
local function buildTargetSet(want)
    local t = {}
    for k in pairs(_loadedExp) do t[k] = true end
    if want then
        for k in pairs(want) do if EXP_ADDONS[k] then t[k] = true end end
    else
        for _, k in ipairs(ALL_EXP) do t[k] = true end
    end
    return t
end

-- True if the current published lookup already covers everything `want` asked for.
local function wantSatisfied(want)
    if not allItems then return false end
    if not want then return _allLoaded end
    for k in pairs(want) do
        if EXP_ADDONS[k] and not _loadedExp[k] then return false end
    end
    return true
end

local function EnsureLoaded(want)
    if wantSatisfied(want) then return end
    -- A chunked build is already in flight (it yields *inside* the loops below).
    -- A concurrent main-thread caller can't wait, so it degrades to "not ready"
    -- rather than racing a second build or reading half-built tables. allItems
    -- stays nil until the atomic publish at the very end, so this guard plus the
    -- one above are the only readiness signals callers ever see.
    if _ensureBuilding then return end
    _ensureBuilding = true

    -- Which expansions this build will reflect (already-loaded ∪ request). A
    -- full-data caller (want=nil) gets all five; a targeted warm gets just its set.
    local target = buildTargetSet(want)
    -- Time the synchronous (main-thread) build — the chunked path yields so its
    -- wall time is meaningless, but a synchronous call (e.g. triggered during init
    -- by community-set source resolution) is a real hitch worth naming.
    local _esT0 = (not (coroutine and coroutine.running())) and ETB and ETB.Perf
        and debugprofilestop and debugprofilestop() or nil

    -- Build into LOCAL temporaries; publish atomically at the end (see bottom).
    local tmpAll    = {}
    local tmpName   = { npc = {}, inst = {}, enc = {}, fac = {}, quest = {}, map = {} }
    local tmpLookup = nil

    local totalLoaded = 0
    local source = "none"
    -- Records which expansions actually contributed data this build, so the publish
    -- below marks _loadedExp from real success rather than from the (optimistic)
    -- target set — a missing/disabled data sibling must not be reported as loaded.
    local loadedReal = {}

    local datasets = {
        { key = "Classic", exp = "CLASSIC" },
        { key = "TBC",     exp = "TBC" },
        { key = "Wrath",   exp = "WRATH" },
        { key = "Cata",    exp = "CATA" },
        { key = "MoP",     exp = "MOP" },
        { key = "AuditClassic", exp = "CLASSIC" },  -- ATT cross-check additions
        { key = "AuditWrath",   exp = "WRATH" },
    }

    -- PRIORITY 1: Verbose format (ETBDataMapper SavedVariables or old shipped)
    local shipped = NS.Shipped or {}
    local hasVerbose = false
    for _, ds in ipairs(datasets) do
        local data = shipped[ds.key]
        if data and data.items then
            hasVerbose = true
            loadedReal[ds.key] = true
            for itemID, entry in pairs(data.items) do
                entry._exp = ds.exp
                tmpAll[itemID] = entry
                totalLoaded = totalLoaded + 1
            end
            ETB:YieldSlice()
            source = data._meta and "mapper" or "shipped"
        end
    end

    -- PRIORITY 2: Compact format (the default after the data-split)
    if not hasVerbose then
        local compact = NS.Compact or {}
        local ZN = compact.ZN or {}
        local EN = compact.EN or {}
        local NN = compact.NN or {}
        local ST = compact.ST or {}

        -- Build lazy lookup table. THIS loop (54k inserts across 5 expansions) is
        -- the ~114ms cost; YieldSlice() (checked every 2000 items to keep the
        -- check itself cheap) spreads it across frames when driven by RunChunked,
        -- and is a no-op on the main thread (identical result, synchronous).
        tmpLookup = {}
        local n = 0
        for _, ds in ipairs(datasets) do
            local isAudit = (ds.key == "AuditClassic" or ds.key == "AuditWrath")
            -- Only load/iterate expansions in the target set. Audit* carry no addon
            -- and ride along with whatever real data is present, so always allow them.
            if target[ds.key] or isAudit then
                -- Chunk: yield BEFORE each expansion so its LoadAddOn (the ~11-22ms
                -- parse island) starts in a fresh coroutine slice instead of piling
                -- onto the previous expansion's tail. No-op on the main thread.
                ETB:YieldSlice()
                LoadExpansionAddon(ds.key)        -- installs NS.Compact.<exp> proxy (no-op for Audit*)
                local data = compact[ds.key]
                if data and data.I then
                    loadedReal[ds.key] = true
                    for itemID in pairs(data.I) do
                        tmpLookup[itemID] = { ds.key, ds.exp }
                        totalLoaded = totalLoaded + 1
                        n = n + 1
                        if n % 2000 == 0 then ETB:YieldSlice() end
                    end
                    source = "compact"
                end
            end
        end

        if source == "compact" then
            -- Create lazy-decoding metatable for allItems (closes over tmpLookup,
            -- which becomes the published compactLookup — same table reference).
            local decodedCache = {}
            tmpAll = setmetatable(decodedCache, {
                __index = function(self, itemID)
                    local info = tmpLookup[itemID]
                    if not info then return nil end
                    local expKey, expName = info[1], info[2]
                    local data = compact[expKey]
                    if not data or not data.I or not data.I[itemID] then return nil end

                    local entry = DecodeCompactItem(data.I[itemID], ZN, EN, NN, ST, expName)
                    if entry then
                        rawset(self, itemID, entry)
                    end
                    return entry
                end,
            })

            -- Store references for iteration
            tmpAll.__compact = compact
            tmpAll.__compactLookup = tmpLookup
            tmpAll.__ZN = ZN
            tmpAll.__EN = EN
            tmpAll.__NN = NN
            tmpAll.__ST = ST

            ETB:Debug("MapperBridge", string.format("Compact format: %d items (lazy decode)", totalLoaded))
        end
    end

    -- Load names (both formats)
    local namesData = shipped.Names or (NS.Compact and NS.Compact.Names)
    if namesData then
        tmpName.npc   = namesData.npc or {}
        tmpName.inst  = namesData.inst or {}
        tmpName.enc   = namesData.enc or {}
        tmpName.fac   = namesData.fac or {}
        tmpName.quest = namesData.quest or {}
        tmpName.map   = namesData.map or {}
    end

    -- Merge runtime NPC names (discovered via mouseover/target, persisted in SV)
    if tmpName.npc and ETB.RuntimeNPCNames and ETB.RuntimeNPCNames.MergeIntoNameTable then
        local rtMerged = ETB.RuntimeNPCNames:MergeIntoNameTable(tmpName.npc)
        if rtMerged > 0 then
            ETB:Debug("MapperBridge", string.format("Merged %d runtime NPC names from SavedVariables", rtMerged))
        end
    end

    ETB.SourceData_ATT._dataSource = source
    ETB.SourceData_ATT._itemCount = totalLoaded

    -- Build raid cache from verbose data
    if hasVerbose then
        for _, entry in pairs(tmpAll) do
            if type(entry) == "table" and entry.raid == 1 and entry.iID then
                RAID_INSTANCES[entry.iID] = true
            end
        end
        -- Free the verbose source — data is now in tmpAll
        NS.Shipped = nil
    end

    -- Atomic publish: until this point allItems stayed nil, so concurrent
    -- main-thread readers saw "not ready" and degraded. Set allItems LAST.
    compactLookup = tmpLookup
    nameTable = tmpName
    allItems = tmpAll
    -- Record coverage: compactLookup now reflects exactly the expansions that
    -- actually loaded (not merely the ones we targeted — a missing/disabled data
    -- sibling leaves loadedReal[k] unset, so we don't claim coverage we lack).
    _loadedExp = {}
    local nLoaded = 0
    for k in pairs(target) do
        if EXP_ADDONS[k] and loadedReal[k] then _loadedExp[k] = true; nLoaded = nLoaded + 1 end
    end
    _allLoaded = (nLoaded >= #ALL_EXP)
    _ensureBuilding = false
    if _esT0 then ETB.Perf:Record("data.ensureLoadedSync", debugprofilestop() - _esT0) end
end

local function ResetLoader()
    allItems = nil
    nameTable = nil
    compactLookup = nil
    _ensureBuilding = false
    _loadedExp = {}
    _allLoaded = false
end

-- Clear ONLY the in-flight build guard (not loaded data). If a chunked build is
-- abandoned mid-flight — its coroutine cancelled or erroring before the atomic
-- publish above — _ensureBuilding would otherwise stay true and wedge every future
-- warm (each hits `if _ensureBuilding then return`). The warm driver calls this on
-- a failed/aborted build so the next warm can retry cleanly.
local function AbortBuild()
    _ensureBuilding = false
end

-- Safe iterator that decodes all items (for diagnostics only)
local function IterateAllItems()
    EnsureLoaded()
    if not allItems then return function() return nil end end  -- build in flight; nothing to iterate yet
    if compactLookup then
        -- Force-decode everything for iteration
        local compact = allItems.__compact
        local ZN = allItems.__ZN
        local EN = allItems.__EN
        local NN = allItems.__NN
        local ST = allItems.__ST
        for itemID, info in pairs(compactLookup) do
            if not rawget(allItems, itemID) then
                local expKey, expName = info[1], info[2]
                local data = compact[expKey]
                if data and data.I and data.I[itemID] then
                    local entry = DecodeCompactItem(data.I[itemID], ZN, EN, NN, ST, expName)
                    if entry then rawset(allItems, itemID, entry) end
                end
            end
        end
    end
    return next, allItems, nil
end

-- ============================================================================
-- BuildSourceObject (unchanged API surface)
-- ============================================================================

local function BuildSourceObject(entry, itemID)
    if not entry then return nil end

    local sourceType = entry.st or "UNKNOWN"
    local encounterID = entry.eID
    local instanceID = entry.iID
    local isWorldBoss = encounterID and WORLD_BOSS_ZONES[encounterID] ~= nil or false

    -- Source name priority: Names.lua enc[encounterID] is most reliable,
    -- then NPC name, quest name, faction name, compact encN as fallback.
    -- (Verified: 389/390 enc names match JournalEncounter. The compact encN
    -- string index is a separate lookup path that may differ for some items.)
    local sourceName = (encounterID and nameTable.enc[encounterID])
        or entry.npcN
        or (entry.npcNs and entry.npcNs[1])
        or entry.qN
        or entry.facN
        or entry.encN
        or (entry.npc and nameTable.npc[entry.npc])
        or nil

    local sourceLocation = entry.instN
        or entry.zone
        or (isWorldBoss and WORLD_BOSS_ZONES[encounterID])
        or (instanceID and nameTable.inst[instanceID])
        or nil

    -- JLoot fallback. When the compact row carries no encounter (eID 0/nil) the
    -- boss is unknown and the location collapses to whatever instance the row was
    -- tagged with. ST_QUEST-coded world bosses hit this: the Four Celestials, for
    -- instance, carry no encID and were tagged to Siege of Orgrimmar, so tooltips
    -- and the grid showed "Siege of Orgrimmar" instead of the world-boss event.
    -- JLoot is the authoritative boss-loot graph, keyed strictly by itemID, so it
    -- never borrows a shared appearance's source — safe to pin the real boss here.
    -- Only fills the gap (no compact encID); rows that already name a boss are left
    -- untouched, so this can't introduce cross-attribution.
    if not encounterID and itemID and ETB.JLoot then
        local ld = ETB.JLoot[itemID]
        if ld then
            local enc = (type(ld[1]) == "table") and ld[1][1] or ld[1]
            local je = enc and ETB.JEncounters and ETB.JEncounters[enc]
            if je then
                encounterID = enc
                sourceName = je[1] or sourceName
                local ji = je[2] and ETB.JInstances and ETB.JInstances[je[2]]
                if ji and ji[1] then sourceLocation = ji[1] end
                if WORLD_BOSS_ZONES[enc] ~= nil then isWorldBoss = true end
            end
        end
    end

    -- SourceDB enrichment: prefer enriched boss names when available
    local sdbRec = nil
    if itemID and ETB.SourceDB then
        sdbRec = ETB.SourceDB:Get(itemID)
    end
    if sdbRec then
        -- SourceDB boss name may be more specific than compact data
        if sdbRec.bossName then
            sourceName = sdbRec.bossName
        end
        -- Instance/location: use instanceID→Names.inst, instanceName, or instanceKey
        if not sourceLocation or sourceLocation == "" then
            if sdbRec.instanceID and nameTable.inst[sdbRec.instanceID] then
                sourceLocation = nameTable.inst[sdbRec.instanceID]
            elseif sdbRec.instanceName then
                sourceLocation = sdbRec.instanceName
            elseif sdbRec.instanceKey then
                sourceLocation = sdbRec.instanceKey:gsub("(%l)(%u)", "%1 %2")
                    :gsub("MoP$", ""):gsub("Cata$", ""):gsub("Classic$", "")
                    :gsub("^%s+", ""):gsub("%s+$", "")
            end
        end
        if not encounterID and sdbRec.encounterID then
            encounterID = sdbRec.encounterID
        end
        if not instanceID and sdbRec.instanceID then
            instanceID = sdbRec.instanceID
        end
        if not isWorldBoss and sdbRec.isWorldBoss then
            isWorldBoss = true
        end
        if not entry.mID and sdbRec.mapID then
            entry.mID = sdbRec.mapID
        end
        -- Feed instanceID into raid detection
        if sdbRec.isRaid and sdbRec.instanceID then
            RAID_INSTANCES[sdbRec.instanceID] = true
        end
    end

    -- Variant cross-reference: LFR/difficulty variants often share visuals
    -- with Normal/Heroic versions that have boss names. Inherit when possible.
    if not sourceName and entry.vars and allItems then
        for _, varItemID in ipairs(entry.vars) do
            if varItemID ~= itemID then
                local varEntry = allItems[varItemID]
                if varEntry then
                    local varEID = varEntry.eID
                    if varEID and nameTable.enc[varEID] then
                        sourceName = nameTable.enc[varEID]
                        if not encounterID then encounterID = varEID end
                        break
                    end
                    if varEntry.npcNs and varEntry.npcNs[1] then
                        local vname = varEntry.npcNs[1]
                        if vname ~= "Common Boss Drop" and vname ~= "Raid Vendor" and vname ~= "Zone Drop" then
                            sourceName = vname
                            break
                        end
                    end
                end
            end
        end
    end

    -- JLoot boss override: items classified as VENDOR in compact data but
    -- present in JLoot as boss drops (e.g., heroic-unique Cata/MoP items)
    -- Reclassify VENDOR → BOSS with the correct encounter name and instance
    if itemID and sourceType == "VENDOR" and ETB.JLoot then
        local lootData = ETB.JLoot[itemID]
        if lootData then
            local jEncID = type(lootData[1]) == "table" and lootData[1][1] or lootData[1]
            if jEncID and jEncID > 0 then
                -- This "vendor" item is actually a boss drop per the Encounter Journal
                if nameTable.enc[jEncID] then
                    sourceName = nameTable.enc[jEncID]
                    sourceType = "BOSS"
                    if not encounterID then encounterID = jEncID end
                end
                -- Resolve instance from JEncounters
                if not instanceID or instanceID == 0 then
                    if ETB.JEncounters then
                        local jEnc = ETB.JEncounters[jEncID]
                        -- JEncounters format: {name, instanceID, order}
                        if jEnc and type(jEnc) == "table" then
                            local jInstID = jEnc[2]  -- instanceID is at index 2
                            if jInstID and jInstID > 0 then
                                instanceID = jInstID
                                if nameTable.inst[jInstID] then
                                    sourceLocation = nameTable.inst[jInstID]
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    -- Tier piece source override: show the boss that drops the token
    -- Changes VENDOR → BOSS with the token boss name/instance
    if itemID and sourceType == "VENDOR" and ETB.TierPieceToToken then
        local tokenID = ETB.TierPieceToToken[itemID]
        if tokenID and ETB.TierTokenSource then
            local tokenSrc = ETB.TierTokenSource[tokenID]
            if tokenSrc then
                local tEncID = tokenSrc[1] or 0
                local tInstID = tokenSrc[2] or 0
                -- Override source name with the boss that drops the token
                if tEncID > 0 and nameTable.enc[tEncID] then
                    sourceName = nameTable.enc[tEncID]
                    sourceType = "BOSS"  -- display as boss drop, not vendor
                    if not encounterID then encounterID = tEncID end
                end
                if tInstID > 0 and nameTable.inst[tInstID] then
                    sourceLocation = nameTable.inst[tInstID]
                    if not instanceID then instanceID = tInstID end
                end
            end
        end
    end

    if sourceType == "VENDOR" then
        sourceName = sourceName or "Vendor"
        sourceLocation = sourceLocation or ""
    elseif sourceType == "QUEST" then
        sourceName = sourceName or "Quest Reward"
        sourceLocation = sourceLocation or ""
    elseif sourceType == "WORLD_DROP" or sourceType == "EVENT" or sourceType == "TREASURE" then
        sourceName = sourceName or "Zone Drop"
        sourceLocation = sourceLocation or ""
    elseif sourceType == "CRAFTED" then
        local profID = entry.prof or (entry.craft and entry.craft.prof)
        local profName = profID and PROF_NAMES[profID] or nil
        sourceName = profName or sourceName or "Crafted"
        if entry.sk then
            sourceLocation = "Skill " .. entry.sk
        elseif entry.craft and entry.craft.sk then
            sourceLocation = "Skill " .. entry.craft.sk
        end
    elseif sourceType == "REPUTATION" then
        sourceName = sourceName or entry.facN or "Reputation Vendor"
    elseif sourceType == "PVP" then
        sourceName = sourceName or "PvP Vendor"
    elseif sourceType == "ACHIEVEMENT" then
        sourceName = sourceName or "Achievement"
    end

    local npcNames = nil
    if entry.npcNs and #entry.npcNs > 0 then
        npcNames = entry.npcNs
    elseif entry.npcN then
        npcNames = { entry.npcN }
    end

    local allZones = nil
    if entry.zones and #entry.zones > 0 then
        allZones = entry.zones
    elseif entry.zone then
        allZones = { entry.zone }
    end

    return {
        type = SOURCE_TYPE_MAP[sourceType] or "UNKNOWN",
        name = sourceName or "Unknown",
        location = sourceLocation or "",
        encounterID = encounterID,
        instanceID = instanceID,
        difficultyID = entry.diff,
        difficulty = DIFF_NAMES[entry.diff],
        expansion = entry._exp,
        isWorldBoss = isWorldBoss,
        npcID = entry.npc,
        questID = entry.qID,
        factionID = entry.fac,
        achievementID = entry.achID,
        mapID = entry.mID,
        eventID = entry.evID,
        sourceID = entry.sID,
        npcName = entry.npcN,
        npcNames = npcNames,
        questName = entry.qN,
        factionName = entry.facN,
        instanceName = entry.instN,
        encounterName = (encounterID and nameTable.enc[encounterID]) or entry.encN,
        zone = entry.zone,
        zones = allZones,
        coordinates = entry.co,
        quality = entry.q,
        bindType = entry.b,
        reqLevel = entry.lvl,
        isRaid = entry.raid == 1,
        isPvP = entry.pvp == 1,
        uFlag = entry.u,
        addedWithPatch = entry.awp,
        removedWithPatch = entry.rwp,
        classRestrictions = entry.cl,
        raceRestrictions = entry.rc,
        costs = entry.cost,
        repRequirement = entry.rep,
        professionID = entry.prof,
        requireSkill = entry.sk,
        craftingInfo = entry.craft,
        tokenItemID = entry.tok,
        tokenInfo = entry.tokInfo,
        variants = entry.vars,
        questChain = entry.qc,
        modID = entry.mod,
    }
end

-- ============================================================================
-- MODULE: ETB.SourceData_ATT (Primary API)
-- ============================================================================

ETB.SourceData_ATT = ETB.SourceData_ATT or {}
local SDA = ETB.SourceData_ATT

SDA._dataSource = "pending"
SDA._itemCount = 0
SDA.WORLD_BOSSES = WORLD_BOSS_ZONES
SDA.FALLBACK_ENCOUNTERS = {}
SDA.FALLBACK_INSTANCES = {}
SDA.ENCOUNTER_TO_INSTANCE = {}
SDA.SOURCES = {}
SDA.SOURCES_MULTI = {}

function SDA:Initialize()
    EnsureLoaded()
    if self._itemCount > 0 then
        ETB:Debug("MapperBridge", string.format("Loaded %d items (%s)", self._itemCount, self._dataSource))
    else
        ETB:Debug("MapperBridge", "No data loaded. Check Data/Compact/ or install ETBDataMapper")
    end
    -- SourceDB enrichment: deferred to first BossLootTooltip BuildIndex
    -- Loading 19K items at login adds ~30MB; deferring saves login memory
    if ETB.SourceDB then
        ETB:Debug("MapperBridge", "SourceDB available (deferred load)")
    end
end

-- v12.42.0: public EnsureLoaded method so consumers can opt in to forcing
-- the lazy-load early (e.g. BossLootTooltip:BuildIndex used to call this
-- guarded by `if SDA.EnsureLoaded then` — the method was missing but the
-- guard made it silently no-op. Now it actually works.
function SDA:EnsureLoaded()
    EnsureLoaded()
end

-- A2: load (and reflect in the lookup) only the named expansion(s), keeping any
-- already loaded. expKeys is one key ("MoP") or a list {"Classic","Cata"}. This is
-- what targeted warms use so a single-expansion zone keeps just that data resident.
function SDA:EnsureExpansion(expKeys)
    local want = {}
    if type(expKeys) == "table" then
        for _, k in ipairs(expKeys) do want[k] = true end
    elseif expKeys then
        want[expKeys] = true
    end
    EnsureLoaded(want)
end

-- Non-forcing readiness check (does NOT trigger a load). With no argument: true
-- once any appearance data is built. With expKey(s): true only when those specific
-- expansions are reflected in the current lookup — so a targeted warm can tell
-- whether its expansion is ready vs. needs warming.
function SDA:IsLoaded(expKeys)
    if allItems == nil then return false end
    if not expKeys then return true end
    if type(expKeys) == "table" then
        for _, k in ipairs(expKeys) do
            if EXP_ADDONS[k] and not _loadedExp[k] then return false end
        end
        return true
    end
    if EXP_ADDONS[expKeys] and not _loadedExp[expKeys] then return false end
    return true
end

-- True once every expansion is loaded (full-data callers can gate on this).
function SDA:IsAllLoaded()
    return _allLoaded
end

function SDA:Reset()
    ResetLoader()
    self._dataSource = "reset"
    self._itemCount = 0
end

-- Clear the in-flight build guard without dropping loaded data. Called by the warm
-- driver when a chunked build failed/was abandoned, so the guard can't wedge true.
function SDA:AbortBuild()
    AbortBuild()
end

function SDA:GetCount()
    EnsureLoaded()
    return self._itemCount
end

function SDA:HasSource(itemID)
    EnsureLoaded()
    if compactLookup then
        if compactLookup[itemID] then return true end
    elseif allItems[itemID] then
        return true
    end
    -- Fallback to SourceDB
    if ETB.SourceDB and ETB.SourceDB:Has(itemID) then return true end
    -- Final fallbacks (Timeless Isle / rare neck / PvP-only gear)
    if ETB.ExtraSource and ETB.ExtraSource[itemID] then return true end
    if ETB.PvPSourceItems and ETB.PvPSourceItems[itemID] then return true end
    return false
end

function SDA:GetRawSource(itemID)
    EnsureLoaded()
    local entry = allItems[itemID]
    if entry then return entry end

    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
        local ok, _, sourceID = pcall(C_TransmogCollection.GetItemInfo, itemID)
        if ok and sourceID and sourceID > 0 and allItems[sourceID] then
            return allItems[sourceID]
        end
    end
    return nil
end

function SDA:IterateItems()
    return IterateAllItems()
end

-- ItemMetadata's tables are materialized by an explicit loader, not on access,
-- so make sure they exist before we read PvP/cost/rep data here.
local function EnsureMeta()
    if ETB._ItemMetadataLoader then
        ETB._ItemMetadataLoader()
        ETB._ItemMetadataLoader = nil
    end
end

-- Standing value -> readable name (Blizzard rep thresholds).
local STANDING_NAMES = {
    [0] = "Neutral", [3000] = "Friendly", [9000] = "Honored",
    [21000] = "Revered", [42000] = "Exalted",
}

-- Merge cost / reputation / pvp acquisition detail (from the ItemMetadata tables
-- that were previously dormant) onto a source object so every consumer can show
-- "Costs X" / "Requires Honored" / PvP lines from one place.
local function AttachAcquisitionExtras(itemID, src)
    if not src then return src end
    EnsureMeta()
    if ETB.PvPSourceItems and ETB.PvPSourceItems[itemID] then src.isPvP = true end
    if ETB.VendorCosts and ETB.VendorCosts[itemID] then src.costs = ETB.VendorCosts[itemID] end
    local rep = ETB.ReputationReqs and ETB.ReputationReqs[itemID]
    if rep then
        src.repFactionID = rep[1]
        src.repStanding = rep[2]
        src.repStandingName = STANDING_NAMES[rep[2]]
    end
    return src
end

-- Public: acquisition extras for any item, independent of its primary source.
function SDA:GetAcquisitionExtras(itemID)
    EnsureLoaded()
    EnsureMeta()
    local out
    if ETB.PvPSourceItems and ETB.PvPSourceItems[itemID] then out = out or {}; out.isPvP = true end
    if ETB.VendorCosts and ETB.VendorCosts[itemID] then out = out or {}; out.costs = ETB.VendorCosts[itemID] end
    local rep = ETB.ReputationReqs and ETB.ReputationReqs[itemID]
    if rep then out = out or {}; out.repFactionID = rep[1]; out.repStanding = rep[2]; out.repStandingName = STANDING_NAMES[rep[2]] end
    return out
end

function SDA:GetPrimarySource(itemID)
    EnsureLoaded()
    local entry = self:GetRawSource(itemID)
    if entry then
        return BuildSourceObject(entry, itemID)
    end
    
    -- Fallback: item not in compact data, but SourceDB may have it
    if ETB.SourceDB and ETB.SourceDB:Has(itemID) then
        local sdb = ETB.SourceDB:Get(itemID)
        if sdb then
            -- Resolve instance name through Names.lua first, then direct fields
            EnsureLoaded()
            local instanceName = nil
            if sdb.instanceID and nameTable.inst[sdb.instanceID] then
                instanceName = nameTable.inst[sdb.instanceID]
            elseif sdb.instanceName then
                instanceName = sdb.instanceName
            elseif sdb.instanceKey then
                instanceName = sdb.instanceKey:gsub("(%l)(%u)", "%1 %2")
                    :gsub("MoP$", ""):gsub("Cata$", ""):gsub("Classic$", "")
                    :gsub("^%s+", ""):gsub("%s+$", "")
            end
            -- Feed raid detection
            if sdb.isRaid and sdb.instanceID then
                RAID_INSTANCES[sdb.instanceID] = true
            end
            return {
                type = (sdb.sourceType == "LOOT") and "BOSS" or
                       (sdb.isCrafted and "PROFESSION" or
                       SOURCE_TYPE_MAP[sdb.sourceType] or sdb.sourceType),
                name = sdb.bossName or (sdb.isCrafted and sdb.sourceType) or "Unknown",
                location = instanceName or "",
                encounterID = sdb.encounterID,
                instanceID = sdb.instanceID,
                mapID = sdb.mapID,
                difficultyID = sdb.difficulty,
                difficulty = DIFF_NAMES[sdb.difficulty],
                expansion = sdb.expansion and sdb.expansion:upper() or nil,
                isWorldBoss = sdb.isWorldBoss,
                isRaid = sdb.isRaid,
                isDungeon = sdb.isDungeon,
                isTrash = sdb.isTrash,
                recipeItemID = sdb.recipeItemID,
                ejInstanceID = sdb.ejInstanceID,
            }
        end
    end

    -- Final fallbacks for items not in the compact data or SourceDB.
    EnsureMeta()
    -- (1) ExtraSource: Timeless Isle ilvl-535 upgrades + a rare-drop neck whose
    --     appearance is shared with a lower item, so they never reach the DB.
    if ETB.ExtraSourceInfo then
        local x = ETB.ExtraSourceInfo(itemID)
        if x then return AttachAcquisitionExtras(itemID, x) end
    end
    -- (2) PvP gear that has no compact appearance row of its own.
    if ETB.PvPSourceItems and ETB.PvPSourceItems[itemID] then
        return AttachAcquisitionExtras(itemID, { type = "PVP", name = "PvP Vendor", location = "" })
    end

    return nil
end

function SDA:GetAllSources(itemID)
    local primary = self:GetPrimarySource(itemID)
    if not primary then return {} end

    local result = { primary }
    EnsureLoaded()
    local entry = allItems[itemID]
    if not entry then return result end

    if entry.srcs and type(entry.srcs) == "table" then
        local seen = {}
        if primary.encounterID then seen[primary.encounterID] = true end

        for _, extra in ipairs(entry.srcs) do
            local eid = extra.eID
            if not eid or not seen[eid] then
                if eid then seen[eid] = true end
                local extraName = (eid and nameTable.enc[eid])
                    or (extra.npc and nameTable.npc[extra.npc])
                    or nil
                local extraLoc = (extra.iID and nameTable.inst[extra.iID]) or nil

                table.insert(result, {
                    type = SOURCE_TYPE_MAP[extra.st] or primary.type,
                    name = extraName or "Unknown",
                    location = extraLoc or "",
                    encounterID = eid,
                    instanceID = extra.iID,
                    difficultyID = extra.diff,
                    difficulty = DIFF_NAMES[extra.diff],
                    expansion = primary.expansion,
                    isWorldBoss = eid and WORLD_BOSS_ZONES[eid] ~= nil or false,
                    npcID = extra.npc,
                    questID = extra.qID,
                    coordinates = extra.co,
                    costs = extra.cost,
                })
            end
        end
    end
    return result
end

function SDA:GetSourceString(itemID)
    local all = self:GetAllSources(itemID)
    if #all == 0 then return nil end
    local names = {}
    for _, s in ipairs(all) do
        if s.name and s.name ~= "Unknown" then names[#names + 1] = s.name end
    end
    local nameStr = #names > 0 and table.concat(names, " / ") or all[1].name
    local loc = all[1].location
    if nameStr and loc and loc ~= "" then
        return nameStr .. " - " .. loc
    end
    return nameStr
end

function SDA:GetEncounterName(encounterID)
    if not encounterID or encounterID == 0 then return nil end
    EnsureLoaded()
    if nameTable.enc[encounterID] then return nameTable.enc[encounterID] end
    if EJ_GetEncounterInfo then return EJ_GetEncounterInfo(encounterID) end
    return nil
end

function SDA:GetInstanceName(instanceID)
    if not instanceID or instanceID == 0 then return nil end
    EnsureLoaded()
    if nameTable.inst[instanceID] then return nameTable.inst[instanceID] end
    if EJ_GetInstanceInfo then return EJ_GetInstanceInfo(instanceID) end
    return nil
end

function SDA:IsWorldBoss(encounterID)
    return encounterID and WORLD_BOSS_ZONES[encounterID] ~= nil or false
end

function SDA:GetWorldBossZone(encounterID)
    return WORLD_BOSS_ZONES[encounterID]
end

function SDA:GetTierToken(itemID)
    EnsureLoaded()
    local entry = allItems[itemID]
    if entry and entry.tokInfo then return entry.tokInfo end
    return nil
end

function SDA:FormatAvailability(src)
    if not src then return nil end
    local parts = {}
    if src.uFlag then parts[#parts + 1] = "|cffff4444Unobtainable|r" end
    if src.removedWithPatch then
        parts[#parts + 1] = string.format("Removed in %s", tostring(src.removedWithPatch))
    end
    if src.addedWithPatch then
        parts[#parts + 1] = string.format("Added in %s", tostring(src.addedWithPatch))
    end
    if #parts > 0 then return table.concat(parts, " | ") end
    return nil
end

function SDA:FormatClassRestrictions(src)
    if not src or not src.classRestrictions then return nil end
    local names = {}
    for _, clID in ipairs(src.classRestrictions) do
        names[#names + 1] = CLASS_NAMES[clID] or ("Class " .. clID)
    end
    if #names > 0 then return table.concat(names, ", ") end
    return nil
end

function SDA:GetDataInfo()
    EnsureLoaded()
    local info = { itemCount = self._itemCount, source = self._dataSource, expansions = {} }
    local compact = NS.Compact or {}
    for _, exp in ipairs({"Classic", "TBC", "Wrath", "Cata", "MoP"}) do
        local data = compact[exp]
        if data and data.I then
            local count = 0
            for _ in pairs(data.I) do count = count + 1 end
            info.expansions[exp] = { version = "2.0", generated = "compact", itemCount = count }
        end
    end
    local shipped = NS.Shipped or {}
    for _, exp in ipairs({"Classic", "TBC", "Wrath", "Cata", "MoP"}) do
        local data = shipped[exp]
        if data and data.items and not info.expansions[exp] then
            local count = 0
            for _ in pairs(data.items) do count = count + 1 end
            local meta = data._meta or {}
            info.expansions[exp] = { version = meta.version or "1.0", generated = meta.generated or "shipped", itemCount = count }
        end
    end
    -- Include SourceDB enrichment stats
    if ETB.SourceDB then
        local sdbStats = ETB.SourceDB:GetStats()
        info.sourceDB = sdbStats
    end
    return info
end

-- ============================================================================
-- MODULE: ETB.SourceData (legacy API)
-- ============================================================================

ETB.SourceData = ETB.SourceData or {}
local SD = ETB.SourceData

function SD:GetSource(itemID) return SDA:GetPrimarySource(itemID) end
function SD:GetSourceFormatted(itemID) return SDA:GetSourceString(itemID) end
function SD:Initialize() SDA:Initialize() end
function SD:PrintStats()
    EnsureLoaded()
    ETB:Print(string.format("  Items: %d (%s)", SDA._itemCount, SDA._dataSource))
end

-- ============================================================================
-- MODULE: ETB.SourceDataComplete (stub)
-- ============================================================================

ETB.SourceDataComplete = {
    Initialize = function() end,
    GetPrimarySource = function(self, itemID) return SDA:GetPrimarySource(itemID) end,
    GetSource = function(self, itemID) return SD:GetSource(itemID) end,
    ParseSource = function(self, raw) return nil end,
    GetCount = function(self) return SDA:GetCount() end,
    stats = {},
}

-- ============================================================================
-- MODULE: ETB.SourceDataV2 (stub)
-- ============================================================================

ETB.SourceDataV2 = {
    Initialize = function() end,
    GetPrimarySource = function(self, itemID) return SDA:GetPrimarySource(itemID) end,
    GetSources = function(self, itemID) return SDA:GetAllSources(itemID) end,
    TestItem = function(self, itemID)
        local src = SDA:GetPrimarySource(itemID)
        if src then
            ETB:Print(string.format("  Type: %s | Name: %s | Location: %s", src.type, src.name, src.location))
        else
            ETB:Print("  No mapper data for this item")
        end
    end,
    PrintStats = function(self)
        ETB:Print(string.format("  -> MapperBridge (%d items)", SDA:GetCount()))
    end,
}

-- ============================================================================
-- MODULE: ETB.InstanceData
-- ============================================================================

ETB.InstanceData = ETB.InstanceData or {}
local IData = ETB.InstanceData

IData.DIFFICULTY = {
    [1]  = { name = "Normal",           shortName = "N" },
    [2]  = { name = "Heroic",           shortName = "H" },
    [3]  = { name = "10 Player",        shortName = "10N" },
    [4]  = { name = "25 Player",        shortName = "25N" },
    [5]  = { name = "10 Player Heroic", shortName = "10H" },
    [6]  = { name = "25 Player Heroic", shortName = "25H" },
    [7]  = { name = "Looking For Raid", shortName = "LFR" },
    [14] = { name = "Normal",           shortName = "N" },
    [15] = { name = "Heroic",           shortName = "H" },
    [16] = { name = "Mythic",           shortName = "M" },
    [17] = { name = "Looking For Raid", shortName = "LFR" },
    [174] = { name = "Defense Protocol Alpha",  shortName = "H+" },
    [175] = { name = "Defense Protocol Beta",   shortName = "H++" },
    [176] = { name = "Defense Protocol Gamma",  shortName = "H+++" },
    [215] = { name = "Protocol Inferno",   shortName = "Inferno" },
    [216] = { name = "Protocol Inferno+",  shortName = "Inferno+" },
    [217] = { name = "Protocol Inferno++", shortName = "Inferno++" },
    [220] = { name = "Celestial Dungeon",  shortName = "Celestial" },
}

IData.INSTANCE_TYPE = { DUNGEON = 1, RAID = 2, SCENARIO = 3, WORLD_BOSS = 4 }
IData.EXPANSION = { CLASSIC = 0, TBC = 1, WRATH = 2, CATA = 3, MOP = 4 }

function IData:GetInstance(instanceID)
    if not instanceID then return nil end
    EnsureLoaded()
    local name = nameTable.inst[instanceID]
    if not name and EJ_GetInstanceInfo then name = EJ_GetInstanceInfo(instanceID) end
    if not name then return nil end
    return {
        instanceID = instanceID, name = name, shortName = name,
        expansion = 0, type = RAID_INSTANCES[instanceID] and 2 or 1,
        minLevel = 0, maxPlayers = RAID_INSTANCES[instanceID] and 25 or 5,
    }
end

function IData:GetInstanceName(instanceID)
    if not instanceID then return nil end
    EnsureLoaded()
    return nameTable.inst[instanceID] or (EJ_GetInstanceInfo and EJ_GetInstanceInfo(instanceID)) or nil
end

function IData:GetEncounter(encounterID)
    if not encounterID then return nil end
    EnsureLoaded()
    local name = nameTable.enc[encounterID]
    if not name and EJ_GetEncounterInfo then name = EJ_GetEncounterInfo(encounterID) end
    if not name then return nil end
    return { encounterID = encounterID, name = name, instanceID = 0, order = 0 }
end

function IData:GetEncounterName(encounterID)
    if not encounterID then return nil end
    EnsureLoaded()
    return nameTable.enc[encounterID] or (EJ_GetEncounterInfo and EJ_GetEncounterInfo(encounterID)) or nil
end

function IData:GetDifficulty(difficultyID) return self.DIFFICULTY[difficultyID] end

function IData:GetDifficultyName(difficultyID)
    local data = self.DIFFICULTY[difficultyID]
    if data then return data.name end
    if GetDifficultyInfo then
        local name = GetDifficultyInfo(difficultyID)
        if name and name ~= "" then return name end
    end
    return "Difficulty " .. tostring(difficultyID or "?")
end

function IData:GetDifficultyShortName(difficultyID)
    local data = self.DIFFICULTY[difficultyID]
    if data then return data.shortName end
    if GetDifficultyInfo then
        local name = GetDifficultyInfo(difficultyID)
        if name and name ~= "" then return name end
    end
    return "?"
end

function IData:GetInstanceEncounters(instanceID) return {} end
function IData:GetExpansionInstances(expansion) return {} end

function IData:GetZoneName(zoneID)
    EnsureLoaded()
    return nameTable.map[zoneID]
end

function IData:IsWorldBoss(encounterID)
    return encounterID and WORLD_BOSS_ZONES[encounterID] ~= nil or false
end

function IData:GetWorldBoss(encounterID)
    if not WORLD_BOSS_ZONES[encounterID] then return nil end
    return {
        name = SDA:GetEncounterName(encounterID) or "World Boss",
        zoneName = WORLD_BOSS_ZONES[encounterID],
    }
end

function IData:GetStats()
    EnsureLoaded()
    local instCount, encCount, mapCount = 0, 0, 0
    for _ in pairs(nameTable.inst) do instCount = instCount + 1 end
    for _ in pairs(nameTable.enc) do encCount = encCount + 1 end
    for _ in pairs(nameTable.map) do mapCount = mapCount + 1 end
    return { instances = instCount, encounters = encCount, zones = mapCount }
end

function IData:PrintStats()
    local stats = self:GetStats()
    ETB:Print("|cff00ffff[InstanceData]|r via MapperBridge:")
    ETB:Print(string.format("  Instances: %d", stats.instances))
    ETB:Print(string.format("  Encounters: %d", stats.encounters))
    ETB:Print(string.format("  Zones: %d", stats.zones))
end

-- ============================================================================
-- MODULE: ETB.SourceData_Extended (stub)
-- ============================================================================

ETB.SourceData_Extended = ETB.SourceData_Extended or {
    PROFESSIONS = {
        [164] = "Blacksmithing", [165] = "Leatherworking",
        [171] = "Alchemy", [182] = "Herbalism", [185] = "Cooking",
        [186] = "Mining", [197] = "Tailoring", [202] = "Engineering",
        [333] = "Enchanting", [356] = "Fishing", [393] = "Skinning",
        [755] = "Jewelcrafting", [773] = "Inscription",
    },
    NPC_NAMES = {},
    ZONE_NAMES = {},
    VENDOR_DATA = {},
    QUEST_DATA = {},
    CRAFTED_DATA = {},
}

-- ============================================================================
-- Display formatters for FormatSourceTooltip (Core/SourceMapper.lua).
-- These were referenced but previously undefined, so the Cost / Coords /
-- Reputation tooltip lines never rendered. Implemented here so the August
-- Stone currency cost (and coords / reputation) surface on Celestial-Dungeon
-- vendor gear and every other costed source. Purely additive; each caller is
-- guarded with `and ETB.SourceData_ATT.Format...`, so this only adds lines.
-- ============================================================================

local function _etb_currencyName(id)
    -- Retail/MoP-Classic engine: C_CurrencyInfo.GetCurrencyInfo(id).name
    if C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
        local ok, info = pcall(C_CurrencyInfo.GetCurrencyInfo, id)
        if ok and info and info.name and info.name ~= "" then return info.name end
    end
    -- Legacy global fallback
    if type(GetCurrencyInfo) == "function" then
        local ok, n = pcall(GetCurrencyInfo, id)
        if ok and type(n) == "string" and n ~= "" then return n end
    end
    return nil
end

local function _etb_itemName(id)
    if type(GetItemInfo) == "function" then
        local ok, name = pcall(GetItemInfo, id)
        if ok and name then return name end
    end
    return nil
end

local function _etb_gold(copper)
    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 .. "g" end
    if s > 0 then parts[#parts + 1] = s .. "s" end
    if c > 0 or #parts == 0 then parts[#parts + 1] = c .. "c" end
    return table.concat(parts, " ")
end

-- costs = { {tag, id, qty}, ... }  tag: "c"=currency, "i"=item, "g"=gold(copper)
function ETB.SourceData_ATT:FormatCosts(costs)
    if type(costs) ~= "table" or #costs == 0 then return nil end
    local parts = {}
    for _, c in ipairs(costs) do
        if type(c) == "table" then
            local tag, id, qty = c[1], c[2], c[3]
            if tag == "c" and id then
                local nm = _etb_currencyName(id) or ("Currency #" .. tostring(id))
                local piece = (qty and qty > 1 and (qty .. " ") or "") .. nm
                -- Annotate August Stone currencies with the tier they unlock.
                if ETB.AugustStone and ETB.AugustStone.TierHint then
                    local hint = ETB.AugustStone:TierHint(id)
                    if hint then piece = piece .. " (" .. hint .. ")" end
                end
                parts[#parts + 1] = piece
            elseif tag == "i" and id then
                local nm = _etb_itemName(id) or ("Item #" .. tostring(id))
                parts[#parts + 1] = (qty and qty > 1 and (qty .. "x ") or "") .. nm
            elseif tag == "g" and id then
                parts[#parts + 1] = _etb_gold(id)
            end
        end
    end
    if #parts == 0 then return nil end
    return table.concat(parts, ", ")
end

-- co = {x, y, mapID}  or  { {x,y,map}, {x,y,map}, ... }
function ETB.SourceData_ATT:FormatCoords(co)
    if type(co) ~= "table" then return nil end
    local function one(p)
        if type(p) == "table" and type(p[1]) == "number" and type(p[2]) == "number" then
            return string.format("%.1f, %.1f", p[1], p[2])
        end
        return nil
    end
    -- Flat single point {x,y,map}
    if type(co[1]) == "number" then
        return one(co)
    end
    -- Multiple points
    local out = {}
    for _, p in ipairs(co) do
        local s = one(p)
        if s then out[#out + 1] = s end
        if #out >= 3 then break end
    end
    if #out == 0 then return nil end
    return table.concat(out, " / ")
end

-- ext.repRequirement = {factionID, standingValue}
function ETB.SourceData_ATT:FormatReputation(ext)
    local rep = ext and ext.repRequirement
    if type(rep) ~= "table" then return nil end
    local factionID, standing = rep[1], rep[2]
    local STANDINGS = {
        [3000] = "Friendly", [9000] = "Honored",
        [21000] = "Revered", [42000] = "Exalted",
    }
    local standingName = STANDINGS[standing]
    if not standingName and type(standing) == "number" then
        -- Closest lower threshold
        local best
        for thresh, name in pairs(STANDINGS) do
            if standing >= thresh and (not best or thresh > best) then
                best = thresh; standingName = name
            end
        end
    end
    local factionName
    if type(GetFactionInfoByID) == "function" and factionID then
        local ok, n = pcall(GetFactionInfoByID, factionID)
        if ok and n then factionName = n end
    end
    if standingName and factionName then
        return standingName .. " with " .. factionName
    elseif standingName then
        return standingName
    elseif factionName then
        return factionName
    end
    return nil
end
