--[[
============================================================================
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)
    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 function EnsureLoaded()
    if allItems then return end

    allItems = {}
    nameTable = { npc = {}, inst = {}, enc = {}, fac = {}, quest = {}, map = {} }

    local totalLoaded = 0
    local source = "none"

    local datasets = {
        { key = "Classic", exp = "CLASSIC" },
        { key = "TBC",     exp = "TBC" },
        { key = "Wrath",   exp = "WRATH" },
        { key = "Cata",    exp = "CATA" },
        { key = "MoP",     exp = "MOP" },
    }

    -- 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
            for itemID, entry in pairs(data.items) do
                entry._exp = ds.exp
                allItems[itemID] = entry
                totalLoaded = totalLoaded + 1
            end
            source = data._meta and "mapper" or "shipped"
        end
    end

    -- PRIORITY 2: Compact format (Data/Compact/*.lua)
    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
        compactLookup = {}

        for _, ds in ipairs(datasets) do
            local data = compact[ds.key]
            if data and data.I then
                for itemID in pairs(data.I) do
                    compactLookup[itemID] = { ds.key, ds.exp }
                    totalLoaded = totalLoaded + 1
                end
                source = "compact"
            end
        end

        if source == "compact" then
            -- Create lazy-decoding metatable for allItems
            local decodedCache = {}
            allItems = setmetatable(decodedCache, {
                __index = function(self, itemID)
                    local info = compactLookup[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
            allItems.__compact = compact
            allItems.__compactLookup = compactLookup
            allItems.__ZN = ZN
            allItems.__EN = EN
            allItems.__NN = NN
            allItems.__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
        nameTable.npc   = namesData.npc or {}
        nameTable.inst  = namesData.inst or {}
        nameTable.enc   = namesData.enc or {}
        nameTable.fac   = namesData.fac or {}
        nameTable.quest = namesData.quest or {}
        nameTable.map   = namesData.map or {}
    end

    -- Merge runtime NPC names (discovered via mouseover/target, persisted in SV)
    if nameTable.npc and ETB.RuntimeNPCNames and ETB.RuntimeNPCNames.MergeIntoNameTable then
        local rtMerged = ETB.RuntimeNPCNames:MergeIntoNameTable(nameTable.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(allItems) 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 allItems
        NS.Shipped = nil
    end
end

local function ResetLoader()
    allItems = nil
    nameTable = nil
    compactLookup = nil
end

-- Safe iterator that decodes all items (for diagnostics only)
local function IterateAllItems()
    EnsureLoaded()
    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

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

function SDA:Reset()
    ResetLoader()
    self._dataSource = "reset"
    self._itemCount = 0
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 then return ETB.SourceDB:Has(itemID) 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

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
    
    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 = {},
}
