-- ============================================================
-- Asylum Compagnon — GearCompassScanner.lua
-- Collecte Retail : équipement porté et objets des sacs.
-- Résolution asynchrone des données d'objets et export JSON.
-- Aucun tooltip localisé n'est parsé et aucune donnée externe n'est appelée.
-- ============================================================

local ADDON_NAME, NS = ...

local Scanner = {}
NS.GearCompass = Scanner

local JSON = NS.GearCompassJSON
local Checksum = NS.GearCompassChecksum
local Parser = NS.GearCompassItemParser
local DB = NS.GearCompassDB
local V2 = NS.GearCompassV2
local BonusRollHistory = NS.GearCompassBonusRollHistory
local NULL = JSON.NULL

local ITEM_LOAD_TIMEOUT = 8
local GEM_LOAD_MAX_ATTEMPTS = 3
local GEM_LOAD_TIMEOUT = 4
local GEM_LOAD_RETRY_DELAY = 0.75
local GEM_LOAD_EVENT_RETRY_DELAY = 0.05
local ITEM_CLASS_CONSUMABLE = Enum and Enum.ItemClass and Enum.ItemClass.Consumable or 0
local ITEM_SUBCLASS_FOOD_DRINK = Enum and Enum.ItemConsumableSubclass
    and Enum.ItemConsumableSubclass.Fooddrink or 5
local MAX_CHARACTER_RATING = 1000000

-- Liste saisonnière volontairement conservatrice (Midnight 12.0.7).
-- Une valeur absente de ces deux tables reste « inconnue » au lieu d'être
-- déclarée enchantable par supposition. Les armes à distance utilisent elles
-- aussi le champ enchant du itemString pour leurs lunettes/améliorations.
local ENCHANTABLE_EQUIP_LOCATIONS = {
    INVTYPE_HEAD = true,
    INVTYPE_SHOULDER = true,
    INVTYPE_CHEST = true,
    INVTYPE_ROBE = true,
    INVTYPE_LEGS = true,
    INVTYPE_FEET = true,
    INVTYPE_FINGER = true,
    INVTYPE_WEAPON = true,
    INVTYPE_2HWEAPON = true,
    INVTYPE_WEAPONMAINHAND = true,
    INVTYPE_WEAPONOFFHAND = true,
    INVTYPE_RANGED = true,
    INVTYPE_RANGEDRIGHT = true,
}
local NON_ENCHANTABLE_EQUIP_LOCATIONS = {
    INVTYPE_NECK = true,
    INVTYPE_BODY = true,
    INVTYPE_WAIST = true,
    INVTYPE_WRIST = true,
    INVTYPE_HAND = true,
    INVTYPE_TRINKET = true,
    INVTYPE_CLOAK = true,
    INVTYPE_SHIELD = true,
    INVTYPE_HOLDABLE = true,
    INVTYPE_TABARD = true,
    INVTYPE_AMMO = true,
    INVTYPE_THROWN = true,
    INVTYPE_RELIC = true,
    INVTYPE_QUIVER = true,
}
local ENCHANTABLE_EQUIPMENT_SLOTS = {
    [1] = true, [3] = true, [5] = true, [7] = true, [8] = true,
    [11] = true, [12] = true, [16] = true,
}
local NON_ENCHANTABLE_EQUIPMENT_SLOTS = {
    [2] = true, [4] = true, [6] = true, [9] = true, [10] = true,
    [13] = true, [14] = true, [15] = true, [19] = true,
}
local TIER_EQUIP_LOCATIONS = {
    INVTYPE_HEAD = true,
    INVTYPE_SHOULDER = true,
    INVTYPE_CHEST = true,
    INVTYPE_ROBE = true,
    INVTYPE_HAND = true,
    INVTYPE_LEGS = true,
}

-- Objets utilitaires qui n'auront jamais de rôle dans une combinaison GearCompass.
-- Les liens `keystone:` sont également détectés plus bas afin de couvrir les
-- futures variantes sans dépendre uniquement d'une liste d'IDs.
local MYTHIC_KEYSTONE_ITEM_IDS = {
    [138019] = true, -- Legion
    [158923] = true, -- Battle for Azeroth
    [180653] = true, -- Shadowlands et versions Retail suivantes
}
local HEARTHSTONE_ITEM_IDS = {
    [6948] = true,   -- Pierre de foyer
    [110560] = true, -- Pierre de foyer de fief
    [140192] = true, -- Pierre de foyer de Dalaran
}

local function Now()
    return (GetServerTime and GetServerTime()) or time()
end

local function IsSecret(value)
    if type(issecretvalue) ~= "function" then return false end
    local ok, secret = pcall(issecretvalue, value)
    return ok and secret and true or false
end

local function Plain(value, expectedType)
    -- Tester issecretvalue avant toute comparaison/conversion : une valeur
    -- secrète Midnight ne doit jamais atteindre ==, tostring ou tonumber.
    if IsSecret(value) then return nil end
    if value == nil then return nil end
    if expectedType and type(value) ~= expectedType then return nil end
    return value
end

local function OrNull(value)
    if IsSecret(value) then return NULL end
    if value == nil then return NULL end
    return value
end

local function Call(fn, ...)
    if type(fn) ~= "function" then return nil end
    local ok, a, b, c, d, e, f, g, h = pcall(fn, ...)
    if not ok then return nil end
    return a, b, c, d, e, f, g, h
end

local function EquipmentLocation(slot)
    if not (ItemLocation and ItemLocation.CreateFromEquipmentSlot) then return nil end
    return Call(ItemLocation.CreateFromEquipmentSlot, ItemLocation, slot)
end

local function BagLocation(bag, slot)
    if not (ItemLocation and ItemLocation.CreateFromBagAndSlot) then return nil end
    return Call(ItemLocation.CreateFromBagAndSlot, ItemLocation, bag, slot)
end

local function ItemGUID(location)
    if not (location and C_Item and C_Item.GetItemGUID) then return nil end
    return Plain(Call(C_Item.GetItemGUID, location), "string")
end

local function ItemLinkAt(location)
    if not (location and C_Item and C_Item.GetItemLink) then return nil end
    return Plain(Call(C_Item.GetItemLink, location), "string")
end

local function ItemIDAt(location)
    if not (location and C_Item and C_Item.GetItemID) then return nil end
    return Plain(Call(C_Item.GetItemID, location), "number")
end

local function NewSnapshot(sourceType, equipmentSlot, bagID, slotIndex, itemID, link, count, quality, location)
    local localKey
    if sourceType == "equipped" then
        localKey = "equipped:" .. tostring(equipmentSlot)
    else
        localKey = ("bag:%s:%s"):format(tostring(bagID), tostring(slotIndex))
    end
    return {
        local_key = localKey,
        source_type = sourceType,
        equipment_slot = equipmentSlot,
        bag_id = bagID,
        slot_index = slotIndex,
        item_id = Plain(itemID, "number"),
        link = Plain(link, "string"),
        stack_count = Plain(count, "number") or 1,
        quality = Plain(quality, "number"),
        location = location,
        guid = ItemGUID(location),
    }
end

local function CollectEquipment()
    local snapshots = {}
    local firstSlot = INVSLOT_FIRST_EQUIPPED or 1
    local lastSlot = INVSLOT_LAST_EQUIPPED or 19
    for slot = firstSlot, lastSlot do
        local location = EquipmentLocation(slot)
        local itemID = ItemIDAt(location)
            or (GetInventoryItemID and Call(GetInventoryItemID, "player", slot))
        if type(itemID) == "number" and itemID > 0 then
            local link = ItemLinkAt(location)
                or (GetInventoryItemLink and Call(GetInventoryItemLink, "player", slot))
            local quality = GetInventoryItemQuality and Call(GetInventoryItemQuality, "player", slot)
            snapshots[#snapshots + 1] = NewSnapshot(
                "equipped", slot, nil, nil, itemID, link, 1, quality, location)
        end
    end
    return snapshots
end

local function PersonalBagIDs()
    local ids, seen = JSON.Array({}), {}
    local function Add(id)
        if type(id) == "number" and not seen[id] then seen[id] = true; ids[#ids + 1] = id end
    end
    Add(Enum and Enum.BagIndex and Enum.BagIndex.Backpack or 0)
    local count = Constants and Constants.InventoryConstants
        and Constants.InventoryConstants.NumBagSlots or NUM_BAG_SLOTS or 4
    for bag = 1, count do Add(bag) end
    Add(Enum and Enum.BagIndex and Enum.BagIndex.ReagentBag)
    table.sort(ids)
    return ids
end

local function CollectContainer(bagID, sourceType)
    local snapshots = {}
    if not (C_Container and C_Container.GetContainerNumSlots and C_Container.GetContainerItemInfo) then
        return snapshots
    end
    local numSlots = Plain(Call(C_Container.GetContainerNumSlots, bagID), "number") or 0
    for slot = 1, numSlots do
        local info = Call(C_Container.GetContainerItemInfo, bagID, slot)
        if type(info) == "table" and type(info.itemID) == "number" then
            local location = BagLocation(bagID, slot)
            snapshots[#snapshots + 1] = NewSnapshot(sourceType, nil, bagID, slot,
                info.itemID, info.hyperlink, info.stackCount, info.quality, location)
        end
    end
    return snapshots
end

local function CollectBags()
    local snapshots = {}
    for _, bagID in ipairs(PersonalBagIDs()) do
        local bagItems = CollectContainer(bagID, "bag")
        for _, snapshot in ipairs(bagItems) do snapshots[#snapshots + 1] = snapshot end
    end
    return snapshots
end

local function RefreshSnapshot(snapshot)
    if snapshot.link then return true end

    local currentID = ItemIDAt(snapshot.location)
    if currentID == snapshot.item_id then snapshot.link = ItemLinkAt(snapshot.location) end

    if not snapshot.link and snapshot.source_type == "equipped" and GetInventoryItemID then
        local itemID = Call(GetInventoryItemID, "player", snapshot.equipment_slot)
        if itemID == snapshot.item_id then
            snapshot.link = Plain(Call(GetInventoryItemLink, "player", snapshot.equipment_slot), "string")
        end
    elseif not snapshot.link and snapshot.bag_id and C_Container and C_Container.GetContainerItemInfo then
        local info = Call(C_Container.GetContainerItemInfo, snapshot.bag_id, snapshot.slot_index)
        if type(info) == "table" and info.itemID == snapshot.item_id then
            snapshot.link = Plain(info.hyperlink, "string")
            snapshot.quality = snapshot.quality or Plain(info.quality, "number")
            snapshot.stack_count = Plain(info.stackCount, "number") or snapshot.stack_count
        end
    end
    return snapshot.link ~= nil
end

local function GetItemInfo(itemInfo)
    if not (C_Item and C_Item.GetItemInfo and itemInfo) then return nil end
    local ok, name, apiLink, quality, baseLevel, requiredLevel, itemType, itemSubType,
        stackMax, equipLoc, texture, sellPrice, classID, subclassID, bindType,
        expansionID, setID, isCraftingReagent, description = pcall(C_Item.GetItemInfo, itemInfo)
    if not ok then return nil end
    return {
        name = Plain(name, "string"), apiLink = Plain(apiLink, "string"),
        quality = Plain(quality, "number"), baseLevel = Plain(baseLevel, "number"),
        requiredLevel = Plain(requiredLevel, "number"), itemType = Plain(itemType, "string"),
        itemSubType = Plain(itemSubType, "string"), stackMax = Plain(stackMax, "number"),
        equipLoc = Plain(equipLoc, "string"), texture = Plain(texture, "number"),
        sellPrice = Plain(sellPrice, "number"), classID = Plain(classID, "number"),
        subclassID = Plain(subclassID, "number"), bindType = Plain(bindType, "number"),
        expansionID = Plain(expansionID, "number"), setID = Plain(setID, "number"),
        isCraftingReagent = Plain(isCraftingReagent, "boolean"),
        description = Plain(description, "string"),
    }
end

local function ActualItemLevel(link)
    if not (link and C_Item and C_Item.GetDetailedItemLevelInfo) then return nil end
    return Plain(Call(C_Item.GetDetailedItemLevelInfo, link), "number")
end

-- FileDataID numérique directement consommable par l'application web.
-- Priorité à l'ItemLocation tant qu'elle contient toujours la même instance,
-- puis au lien complet, à GetItemInfo et enfin aux données instantanées.
local function ItemIconFileID(snapshot, itemInfo, cachedTexture)
    local icon
    if snapshot and snapshot.location and C_Item and C_Item.GetItemIcon
        and ItemIDAt(snapshot.location) == snapshot.item_id then
        icon = Plain(Call(C_Item.GetItemIcon, snapshot.location), "number")
    end
    if not icon and itemInfo and C_Item and C_Item.GetItemIconByID then
        icon = Plain(Call(C_Item.GetItemIconByID, itemInfo), "number")
    end
    icon = icon or Plain(cachedTexture, "number")
    if not icon and itemInfo and C_Item and C_Item.GetItemInfoInstant then
        local _, _, _, _, instantIcon = Call(C_Item.GetItemInfoInstant, itemInfo)
        icon = Plain(instantIcon, "number")
    end
    return icon
end
Scanner.ResolveItemIconFileID = ItemIconFileID

function Scanner.SelfTestItemIcon()
    local icon = ItemIconFileID({}, nil, 134400)
    local encoded = JSON.Encode({ item = { icon_file_id = OrNull(icon) } })
    return icon == 134400 and type(encoded) == "string"
        and encoded:find('"icon_file_id":134400', 1, true) ~= nil,
        encoded
end

local function InventoryType(itemInfo)
    if not (itemInfo and C_Item and C_Item.GetItemInventoryTypeByID) then return nil end
    return Plain(Call(C_Item.GetItemInventoryTypeByID, itemInfo), "number")
end

local function IsEquippable(itemInfo, equipLoc)
    if C_Item and C_Item.IsEquippableItem and itemInfo then
        local value = Call(C_Item.IsEquippableItem, itemInfo)
        if not IsSecret(value) and type(value) == "boolean" then return value end
    end
    if type(equipLoc) == "string" then return equipLoc ~= "" end
    return nil
end

local function InstantItemClassification(itemID)
    if not (itemID and C_Item and C_Item.GetItemInfoInstant) then return nil end
    local resolvedID, _, _, equipLoc, _, classID, subclassID =
        Call(C_Item.GetItemInfoInstant, itemID)
    return {
        item_id = Plain(resolvedID, "number") or itemID,
        equip_location = Plain(equipLoc, "string"),
        class_id = Plain(classID, "number"),
        subclass_id = Plain(subclassID, "number"),
    }
end

local function ExclusionReason(snapshot, includeNonEquippable)
    if type(snapshot) ~= "table" then return "invalid" end
    local itemID = snapshot.item_id
    local link = snapshot.link

    -- Une pierre M+ utilise un hyperlink `keystone:` et non un `item:` normal.
    -- L'envoyer à Item:ContinueOnItemLoad provoque précisément le faux timeout
    -- observé sur bag:0:17.
    if MYTHIC_KEYSTONE_ITEM_IDS[itemID]
        or (type(link) == "string" and (link:find("|Hkeystone:", 1, true)
            or link:find("keystone:", 1, true))) then
        return "mythic_keystone"
    end
    if HEARTHSTONE_ITEM_IDS[itemID] then return "hearthstone" end

    local instant = InstantItemClassification(itemID)
    if instant and instant.class_id == ITEM_CLASS_CONSUMABLE
        and instant.subclass_id == ITEM_SUBCLASS_FOOD_DRINK then
        return "food_drink"
    end

    -- Le réglage reste utile pour les autres objets non équipables. Lorsqu'il
    -- est désactivé (valeur par défaut), les éliminer avant tout chargement évite
    -- les attentes et erreurs qui ne concernent pas l'optimisation d'équipement.
    if not includeNonEquippable then
        local itemInfo = link or itemID
        local equippable = IsEquippable(itemInfo, instant and instant.equip_location)
        if equippable == false then return "non_equippable" end
    end
end

local function ExcludeSnapshots(snapshots, includeNonEquippable)
    local kept = {}
    local counts = {
        total = 0, mythic_keystone = 0, hearthstone = 0,
        food_drink = 0, non_equippable = 0, invalid = 0,
    }
    for _, snapshot in ipairs(snapshots or {}) do
        local reason = ExclusionReason(snapshot, includeNonEquippable)
        if reason then
            counts.total = counts.total + 1
            counts[reason] = (counts[reason] or 0) + 1
        else
            kept[#kept + 1] = snapshot
        end
    end
    return kept, counts
end
Scanner.ExcludeSnapshots = ExcludeSnapshots

function Scanner.SelfTestExclusions()
    local kept, counts = ExcludeSnapshots({
        { item_id = 180653, link = "|Hkeystone:123:2:0:0:0|h[Test]|h" },
        { item_id = 6948, link = "item:6948::::::::" },
    }, true)
    return #kept == 0 and counts.total == 2
        and counts.mythic_keystone == 1 and counts.hearthstone == 1,
        counts
end

local function GemIDAt(parsed, socketIndex)
    local raw = parsed and parsed.raw_fields and parsed.raw_fields[socketIndex + 2]
    local value = Plain(tonumber(raw), "number")
    if value and value > 0 then return value end
end

-- C_Item.GetItemStats n'accepte pas un itemID numérique brut. Le lien exact
-- renvoyé par C_Item.GetItemGem reste prioritaire, mais un gemID déjà extrait de
-- l'instance suffit toujours à construire la forme minimale documentée.
local function GemItemInfo(gemID, gemLink)
    gemLink = Plain(gemLink, "string")
    if gemLink and gemLink ~= "" then return gemLink end
    gemID = Plain(gemID, "number")
    if not gemID or gemID <= 0 or gemID ~= math.floor(gemID) then return nil end
    return "item:" .. tostring(gemID)
end
Scanner.BuildGemItemInfo = GemItemInfo

local function QueryGemStats(gemID, gemLink, getItemStats)
    getItemStats = getItemStats or (C_Item and C_Item.GetItemStats)
    local preferred = GemItemInfo(gemID, gemLink)
    local canonical = GemItemInfo(gemID, nil)
    if not preferred or type(getItemStats) ~= "function" then
        return Parser.EmptyStats(), {}, false, preferred, nil,
            "item_data_unavailable"
    end

    -- Le lien complet de C_Item.GetItemGem reste prioritaire. Sur Retail, il
    -- peut toutefois produire une table vide alors que la forme minimale
    -- `item:<gemID>` expose correctement les ratings. Essayer les deux formes
    -- sans jamais certifier une table vide ou entièrement non reconnue.
    local candidates = { preferred }
    if canonical and canonical ~= preferred then candidates[#candidates + 1] = canonical end
    local lastRaw, lastUnmapped, lastItemInfo
    for _, itemInfo in ipairs(candidates) do
        local raw = Call(getItemStats, itemInfo)
        if type(raw) == "table" then
            local normalized, unmapped = Parser.NormalizeStats(raw)
            if Parser.HasUsableStructuredStats(raw) then
                return normalized, unmapped, true, itemInfo, raw, nil
            end
            lastRaw, lastUnmapped, lastItemInfo = raw, unmapped, itemInfo
        end
    end
    local reason = type(lastRaw) == "table"
        and "structured_gem_stats_empty" or "item_data_unavailable"
    return Parser.EmptyStats(), lastUnmapped or {}, false,
        lastItemInfo or preferred, lastRaw, reason
end
Scanner.QueryGemStats = QueryGemStats

-- Identite publique de la gemme placee dans CETTE instance. `resolved` dans
-- le payload compact certifie uniquement cette identite (ID + nom + lien) : la
-- resolution de ses ratings reste suivie separement par `gem_stats[].resolved`.
-- Cette distinction permet a GearCompass de valoriser une gemme connue lorsque
-- Blizzard ne renvoie aucune statistique structuree, sans certifier pour autant
-- un `character_stats` partiel.
local function ResolveGemIdentity(gemID, gemName, gemLink, getItemInfo)
    gemID = Plain(gemID, "number")
    gemName = Plain(gemName, "string")
    gemLink = Plain(gemLink, "string")
    if gemName == "" then gemName = nil end
    if gemLink == "" then gemLink = nil end

    getItemInfo = getItemInfo or (C_Item and C_Item.GetItemInfo)
    if gemID and type(getItemInfo) == "function" and (not gemName or not gemLink) then
        local query = gemLink or GemItemInfo(gemID)
        local ok, loadedName, loadedLink = pcall(getItemInfo, query)
        if ok then
            gemName = gemName or Plain(loadedName, "string")
            gemLink = gemLink or Plain(loadedLink, "string")
        end
    end

    local resolved = gemID ~= nil and gemName ~= nil and gemLink ~= nil
    return gemName, gemLink, resolved,
        resolved and nil or "gem_item_data_unavailable"
end
Scanner.ResolveGemIdentity = ResolveGemIdentity

local function ReadStats(link, parsed)
    local stats = Parser.EmptyStats()
    local unmapped = {}
    local gemStats = JSON.Array({})
    local meta = {
        api = "C_Item.GetItemStats",
        intrinsic_item_stats_available = false,
        base_query_normalized = false,
        enchant_bonus_excluded = false,
        socketed_gem_stats_excluded_from_base = false,
        socketed_gem_stats_exported_separately = false,
        socketed_gem_stats_complete = true,
    }
    if not (link and C_Item and C_Item.GetItemStats) then
        return stats, unmapped, gemStats, meta
    end

    -- Ne jamais modifier l'objet réel. On interroge une copie textuelle du
    -- itemString où l'enchantement ET les quatre gemmes sont mis à zéro. Tous
    -- les bonus qui définissent la variante (bonus IDs, craft, contexte, piste)
    -- restent intacts. Si Blizzard refuse ce format, les métadonnées ci-dessous
    -- indiquent honnêtement que l'exclusion n'a pas pu être garantie.
    local baseStatsItemString = Parser.BuildBaseStatsItemString(link)
    local statsQuery = baseStatsItemString or link
    if baseStatsItemString then
        meta.base_query_normalized = true
        meta.enchant_bonus_excluded = true
        meta.socketed_gem_stats_excluded_from_base = true
    end
    local raw = Call(C_Item.GetItemStats, statsQuery)
    if type(raw) == "table" then
        stats, unmapped = Parser.NormalizeStats(raw)
        meta.intrinsic_item_stats_available = true
    end

    -- Les gemmes restent attachées à l'instance exportée, mais leurs statistiques
    -- sont séparées de `stats` pour que GearCompass puisse les remplacer librement.
    for socketIndex = 1, 4 do
        local gemID = GemIDAt(parsed, socketIndex)
        local gemName, gemLink
        if C_Item.GetItemGem then
            gemName, gemLink = Call(C_Item.GetItemGem, link, socketIndex)
        end
        gemName = Plain(gemName, "string")
        gemLink = Plain(gemLink, "string")
        if not gemID and gemLink then
            local gemParsed = Parser.Parse(gemLink)
            gemID = gemParsed and gemParsed.item_id or nil
        end
        if gemID or gemLink then
            local identityName, identityLink, identityResolved, identityReason =
                ResolveGemIdentity(gemID, gemName, gemLink)
            gemLink = identityLink or gemLink
            local normalized, gemUnmapped, resolved, gemItemInfo, _, gemReason =
                QueryGemStats(gemID, gemLink, C_Item.GetItemStats)
            if resolved then
                meta.socketed_gem_stats_exported_separately = true
            else
                meta.socketed_gem_stats_complete = false
            end
            gemStats[#gemStats + 1] = {
                socket_index = socketIndex,
                gem_id = OrNull(gemID),
                gem_name = OrNull(identityName),
                gem_link = OrNull(gemLink),
                identity_resolved = identityResolved,
                identity_reason = identityResolved and NULL
                    or identityReason or "gem_item_data_unavailable",
                item_link = OrNull(gemLink),
                item_info = OrNull(gemItemInfo),
                stats = normalized,
                unmapped_stats = gemUnmapped,
                resolved = resolved,
                attempts = 1,
                reason = resolved and NULL or gemReason or "item_data_unavailable",
            }
        end
    end
    return stats, unmapped, gemStats, meta
end

local function ResolveEnchantApplicability(equipLoc, equipmentSlot, isEquippable)
    if isEquippable == false then return false end
    equipLoc = Plain(equipLoc, "string")
    if equipLoc and equipLoc ~= "" then
        if ENCHANTABLE_EQUIP_LOCATIONS[equipLoc] then return true end
        if NON_ENCHANTABLE_EQUIP_LOCATIONS[equipLoc] then return false end
        return nil
    end
    equipmentSlot = Plain(equipmentSlot, "number")
    if equipmentSlot and ENCHANTABLE_EQUIPMENT_SLOTS[equipmentSlot] then return true end
    if equipmentSlot and NON_ENCHANTABLE_EQUIPMENT_SLOTS[equipmentSlot] then return false end
    return nil
end
Scanner.ResolveEnchantApplicability = ResolveEnchantApplicability

local function BuildEnchantment(parsed, equipLoc, equipmentSlot, isEquippable)
    local applicable = ResolveEnchantApplicability(equipLoc, equipmentSlot, isEquippable)
    local enchantID = parsed and Plain(parsed.enchant_id, "number") or nil
    if applicable ~= true or not enchantID or enchantID <= 0
        or enchantID ~= math.floor(enchantID) then
        enchantID = nil
    end
    return {
        applicable = OrNull(applicable),
        enchant_id = OrNull(enchantID),
        -- Blizzard ne fournit pas de correspondance publique fiable
        -- enchantID -> spellID/nom. GetSpellInfo(enchantID) serait faux : les
        -- deux espaces d'identifiants sont distincts, donc ces champs restent
        -- honnêtement nuls jusqu'à l'existence d'une API dédiée.
        spell_id = NULL,
        name = NULL,
    }
end
Scanner.BuildEnchantment = BuildEnchantment

local function CurrentSpecID()
    local getSpec = C_SpecializationInfo and C_SpecializationInfo.GetSpecialization
    local getInfo = C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo
    if not (getSpec and getInfo) then return nil end
    local index = Call(getSpec)
    if not index then return nil end
    return Plain(Call(getInfo, index), "number")
end

local function ResolveTierStatus(setID, bonusSpells, itemInfoResolved, equipLoc,
        requiredLevel, characterLevel)
    -- Sans données d'objet complètes, l'absence de setID ne prouve rien.
    if itemInfoResolved ~= true then return nil end
    -- Un objet résolu qui n'appartient à aucun ensemble ne peut pas être tier.
    if type(setID) ~= "number" then return false end
    equipLoc = Plain(equipLoc, "string")
    -- Les pièces de tier de classe n'existent que sur ces cinq emplacements.
    -- Ce garde-fou empêche par exemple un anneau de set à bonus de devenir tier.
    if equipLoc and equipLoc ~= "" and not TIER_EQUIP_LOCATIONS[equipLoc] then return false end
    if not equipLoc or equipLoc == "" then return nil end
    requiredLevel = Plain(requiredLevel, "number")
    characterLevel = Plain(characterLevel, "number")
    if not requiredLevel or not characterLevel then return nil end
    -- À niveau maximum, un ancien tier conserve parfois ses sorts de set dans
    -- l'API. Un niveau requis inférieur à celui du personnage prouve qu'il ne
    -- s'agit pas du tier de la saison actuelle. Les cas non lisibles restent nil.
    if requiredLevel < characterLevel then return false end
    -- Ce signal Blizzard est spécifique au couple spécialisation + item. Il
    -- distingue une pièce de set de classe d'un simple ensemble cosmétique ou
    -- générique, sans parser le nom localisé du set.
    if type(bonusSpells) == "table" then
        for _, spellID in ipairs(bonusSpells) do
            if not IsSecret(spellID) and type(spellID) == "number" and spellID > 0 then
                return true
            end
        end
    end
    -- L'API peut ne rien retourner même quand les données ne sont pas encore
    -- disponibles : ne jamais transformer cette absence en faux certain.
    return nil
end
Scanner.ResolveTierStatus = ResolveTierStatus

local function ReadSetInfo(setID, itemID, itemInfoResolved, equipLoc, requiredLevel)
    local setName
    if setID and C_Item and C_Item.GetItemSetInfo then
        setName = Plain(Call(C_Item.GetItemSetInfo, setID), "string")
    end
    local bonusSpells = JSON.Array({})
    local specID = CurrentSpecID()
    if specID and itemID and C_Item and C_Item.GetSetBonusesForSpecializationByItemID then
        local spells = Call(C_Item.GetSetBonusesForSpecializationByItemID, specID, itemID)
        if not IsSecret(spells) and type(spells) == "table" then
            for _, spellID in ipairs(spells) do
                if not IsSecret(spellID) and type(spellID) == "number" then bonusSpells[#bonusSpells + 1] = spellID end
            end
        end
    end
    local isSetItem
    if itemInfoResolved == true then isSetItem = setID ~= nil end
    return {
        set_id = OrNull(setID),
        set_name = OrNull(setName),
        is_set_item = OrNull(isSetItem),
        is_tier_item = OrNull(ResolveTierStatus(
            setID, bonusSpells, itemInfoResolved, equipLoc, requiredLevel,
            UnitLevel and Call(UnitLevel, "player"))),
        specialization_bonus_spell_ids = bonusSpells,
    }
end

local function ReadUpgradeInfo(itemInfo)
    local result = {
        current_level = NULL, max_level = NULL, max_item_level = NULL,
        track_string = NULL, track_string_id = NULL,
    }
    if itemInfo and C_Item and C_Item.GetItemUpgradeInfo then
        local info = Call(C_Item.GetItemUpgradeInfo, itemInfo)
        if type(info) == "table" then
            result.current_level = OrNull(Plain(info.currentLevel, "number"))
            result.max_level = OrNull(Plain(info.maxLevel, "number"))
            result.max_item_level = OrNull(Plain(info.maxItemLevel, "number"))
            result.track_string = OrNull(Plain(info.trackString, "string"))
            result.track_string_id = OrNull(Plain(info.trackStringID, "number"))
        end
    end
    return result
end

local function ReadUniqueness(itemInfo)
    local result = {
        is_unique = NULL, limit_category_name = NULL,
        limit_category_count = NULL, limit_category_id = NULL,
    }
    if itemInfo and C_Item and C_Item.GetItemUniquenessByID then
        local isUnique, categoryName, categoryCount, categoryID =
            Call(C_Item.GetItemUniquenessByID, itemInfo)
        result.is_unique = OrNull(Plain(isUnique, "boolean"))
        result.limit_category_name = OrNull(Plain(categoryName, "string"))
        result.limit_category_count = OrNull(Plain(categoryCount, "number"))
        result.limit_category_id = OrNull(Plain(categoryID, "number"))
    end
    return result
end

local function ReadSocketInfo(itemInfo)
    local socketCount, addedSocketCount
    if itemInfo and C_Item then
        socketCount = Plain(Call(C_Item.GetItemNumSockets, itemInfo), "number")
        addedSocketCount = Plain(Call(C_Item.GetItemNumAddedSockets, itemInfo), "number")
    end
    return OrNull(socketCount), OrNull(addedSocketCount)
end

local function ReadCraftingSlotModifications(itemGUID)
    local result = JSON.Array({})
    if not (itemGUID and C_TradeSkillUI and C_TradeSkillUI.GetItemSlotModifications) then
        return result
    end
    local slotMods = Call(C_TradeSkillUI.GetItemSlotModifications, itemGUID)
    if type(slotMods) ~= "table" then return result end
    for _, slotMod in ipairs(slotMods) do
        if type(slotMod) == "table" then
            local reagent = slotMod.reagent
            local itemID, currencyID
            if not IsSecret(reagent) and type(reagent) == "table" then
                itemID = Plain(reagent.itemID, "number")
                currencyID = Plain(reagent.currencyID, "number")
            elseif not IsSecret(reagent) and type(reagent) == "number" then
                -- Compatibilité défensive avec le format antérieur à 12.0.0.
                itemID = reagent
            end
            result[#result + 1] = {
                data_slot_index = OrNull(Plain(slotMod.dataSlotIndex, "number")),
                reagent = {
                    item_id = OrNull(itemID),
                    currency_id = OrNull(currencyID),
                },
            }
        end
    end
    return result
end

local function ReadOriginalCraft(itemGUID)
    local result = { recipe_id = NULL, skill_line_ability_id = NULL }
    if itemGUID and C_TradeSkillUI and C_TradeSkillUI.GetOriginalCraftRecipeID then
        local recipeID, skillLineAbilityID = Call(C_TradeSkillUI.GetOriginalCraftRecipeID, itemGUID)
        result.recipe_id = OrNull(Plain(recipeID, "number"))
        result.skill_line_ability_id = OrNull(Plain(skillLineAbilityID, "number"))
    end
    return result
end

function Scanner.BuildItemEntry(snapshot, allowPartial)
    if type(snapshot) ~= "table" or type(snapshot.item_id) ~= "number" then return nil end
    RefreshSnapshot(snapshot)
    local link = snapshot.link
    local parsed = link and Parser.Parse(link) or nil
    local info = GetItemInfo(link or snapshot.item_id)
    if not allowPartial and (not link or not info or not info.name) then return nil end
    info = info or {}
    local itemInfoResolved = info.name ~= nil

    local itemID = parsed and parsed.item_id or snapshot.item_id
    local itemInfo = link or itemID
    local equipLoc = info.equipLoc
    local equippable = IsEquippable(itemInfo, equipLoc)
    local stats, unmapped, gemStats, statsMeta = ReadStats(link, parsed)

    local craftQuality
    if link and C_TradeSkillUI and C_TradeSkillUI.GetItemCraftedQualityByItemInfo then
        craftQuality = Plain(Call(C_TradeSkillUI.GetItemCraftedQualityByItemInfo, link), "number")
    end

    local guid = snapshot.guid
    local instanceKey = guid and ("guid:" .. guid) or snapshot.local_key
    local socketCount, addedSocketCount = ReadSocketInfo(itemInfo)
    local originalCraft = ReadOriginalCraft(guid)
    local inventoryTypeID = InventoryType(itemInfo)
    local modifiers = {
        gem_ids = parsed and parsed.gem_ids or JSON.Array({}),
        gem_stats = gemStats,
        socket_count = socketCount,
        added_socket_count = addedSocketCount,
        bonus_ids = parsed and parsed.bonus_ids or JSON.Array({}),
        context = OrNull(parsed and parsed.context),
        crafted_stats = parsed and parsed.crafted_stats or JSON.Array({}),
        crafting_quality = OrNull(craftQuality),
        crafting_quality_modifier_id = OrNull(parsed and parsed.crafting_quality_modifier_id),
        crafting_slot_modifications = ReadCraftingSlotModifications(guid),
        original_craft = originalCraft,
        upgrade_id = OrNull(parsed and parsed.upgrade_id),
        suffix_id = OrNull(parsed and parsed.suffix_id),
        unique_id = OrNull(parsed and parsed.unique_id),
        link_level = OrNull(parsed and parsed.link_level),
        specialization_id = OrNull(parsed and parsed.specialization_id),
        modifiers_mask = OrNull(parsed and parsed.modifiers_mask),
        raw_fields = parsed and parsed.raw_fields or JSON.Array({}),
        raw_modifiers = parsed and parsed.raw_modifiers or JSON.Array({}),
        relic_bonus_ids = parsed and parsed.relic_bonus_ids or JSON.Array({}),
        crafter_guid = OrNull(parsed and parsed.crafter_guid),
        parser_truncated = parsed and parsed.truncated or false,
    }
    statsMeta.complete_for_instance = statsMeta.intrinsic_item_stats_available
        and statsMeta.base_query_normalized
        and statsMeta.socketed_gem_stats_complete

    local item = {
        item_id = itemID,
        item_name = OrNull(info.name),
        icon_file_id = OrNull(ItemIconFileID(snapshot, itemInfo, info.texture)),
        item_link = OrNull(link),
        item_string = OrNull(parsed and parsed.item_string),
        item_guid = OrNull(guid),
        quality = OrNull(info.quality or snapshot.quality),
        item_level = OrNull(ActualItemLevel(link)),
        required_level = OrNull(info.requiredLevel),
        inventory_type = OrNull(inventoryTypeID),
        inventory_type_id = OrNull(inventoryTypeID),
        equip_location = OrNull(equipLoc),
        item_class_id = OrNull(info.classID),
        item_subclass_id = OrNull(info.subclassID),
        is_two_handed = OrNull(V2 and V2.IsTwoHanded
            and V2.IsTwoHanded(info.classID, info.subclassID, inventoryTypeID)),
        bind_type = OrNull(info.bindType),
        is_equippable = OrNull(equippable),
        stack_count = snapshot.stack_count or 1,
        uniqueness = ReadUniqueness(itemInfo),
    }
    local crafting = V2 and V2.BuildCrafting and V2.BuildCrafting(
        itemInfo, itemID, craftQuality, originalCraft) or {
            is_crafted = NULL, quality = OrNull(craftQuality),
            creation_context = NULL, complete = false,
        }
    local catalyst = V2 and V2.BuildItemCatalyst
        and V2.BuildItemCatalyst(snapshot.location, itemID) or {
            checked = false, conversion_available = NULL,
            conversion_output_item_id = NULL,
            conversion_output_item_name = NULL,
            conversion_output_item_link = NULL,
            conversion_output_icon_file_id = NULL,
            conversion_output_set_id = NULL,
            conversion_output_is_tier = NULL,
            resolution_method = NULL,
            resolution_complete = false,
            checked_at = NULL,
            complete = false,
            reason = "conversion_check_unavailable",
        }
    local voidforge = V2 and V2.BuildItemVoidforge and V2.BuildItemVoidforge(item) or nil

    return {
        instance_key = instanceKey,
        source = {
            type = snapshot.source_type,
            equipment_slot = OrNull(snapshot.equipment_slot),
            bag_id = OrNull(snapshot.bag_id),
            slot_index = OrNull(snapshot.slot_index),
        },
        item = item,
        stats = stats,
        stats_meta = statsMeta,
        unmapped_stats = unmapped,
        modifiers = modifiers,
        enchantment = BuildEnchantment(
            parsed, equipLoc, snapshot.equipment_slot, equippable),
        crafting = crafting,
        catalyst = catalyst,
        voidforge = voidforge,
        set = ReadSetInfo(
            info.setID, itemID, itemInfoResolved, equipLoc, info.requiredLevel),
        upgrade = ReadUpgradeInfo(itemInfo),
    }
end

local function ItemObjectFor(snapshot)
    if type(Item) ~= "table" then return nil end
    -- Le lien capturé décrit l'instance au moment du scan et reste valide même
    -- si le joueur déplace l'objet pendant le chargement. La position vient après.
    if snapshot.link and Item.CreateFromItemLink then
        local object = Call(Item.CreateFromItemLink, Item, snapshot.link)
        if object then return object end
    end
    if snapshot.location and Item.CreateFromItemLocation then
        local object = Call(Item.CreateFromItemLocation, Item, snapshot.location)
        if object then return object end
    end
    if snapshot.item_id and Item.CreateFromItemID then
        return Call(Item.CreateFromItemID, Item, snapshot.item_id)
    end
end

local function NeedsCatalystRetry(entry)
    local catalyst = type(entry) == "table" and entry.catalyst or nil
    return type(catalyst) == "table"
        and catalyst.checked == true
        and catalyst.conversion_available == true
        and catalyst.resolution_complete ~= true
        and catalyst.reason == "conversion_output_icon_unavailable"
end
Scanner.NeedsCatalystRetry = NeedsCatalystRetry

local function ResolveSnapshots(snapshots, onProgress, onComplete)
    local total = #snapshots
    if total == 0 then onComplete({}, JSON.Array({})); return end

    local results, errors, completed = {}, JSON.Array({}), {}
    local done, closed = 0, false
    local timer

    local function FinishIfReady()
        if closed or done < total then return end
        closed = true
        if timer then timer:Cancel(); timer = nil end
        local ordered = {}
        for index = 1, total do if results[index] then ordered[#ordered + 1] = results[index] end end
        onComplete(ordered, errors)
    end

    local function Complete(index, entry, errorCode, errorMessage)
        if closed or completed[index] then return end
        completed[index] = true
        done = done + 1
        results[index] = entry
        if errorCode then
            local snapshot = snapshots[index]
            errors[#errors + 1] = {
                instance_key = entry and entry.instance_key or snapshot.local_key,
                item_id = OrNull(snapshot.item_id),
                source = {
                    type = snapshot.source_type,
                    equipment_slot = OrNull(snapshot.equipment_slot),
                    bag_id = OrNull(snapshot.bag_id),
                    slot_index = OrNull(snapshot.slot_index),
                },
                code = errorCode,
                message = errorMessage,
            }
        end
        if onProgress then pcall(onProgress, done, total) end
        FinishIfReady()
    end

    for index, snapshot in ipairs(snapshots) do
        local entry = Scanner.BuildItemEntry(snapshot, false)
        if entry and not NeedsCatalystRetry(entry) then
            Complete(index, entry)
        else
            local object = ItemObjectFor(snapshot)
            if object and object.ContinueOnItemLoad then
                local ok = pcall(object.ContinueOnItemLoad, object, function()
                    local loaded = Scanner.BuildItemEntry(snapshot, false)
                    if loaded then
                        Complete(index, loaded)
                    else
                        Complete(index, Scanner.BuildItemEntry(snapshot, true),
                            "ITEM_DATA_NOT_AVAILABLE",
                            "Les informations exactes de cet objet n'ont pas pu être chargées.")
                    end
                end)
                if not ok then object = nil end
            end
            if entry and not object then Complete(index, entry) end
            -- Sans mécanisme de chargement, le délai produira une entrée partielle.
        end
    end

    if not closed then
        timer = C_Timer.NewTimer(ITEM_LOAD_TIMEOUT, function()
            timer = nil
            for index, snapshot in ipairs(snapshots) do
                if not completed[index] then
                    Complete(index, Scanner.BuildItemEntry(snapshot, true),
                        "ITEM_DATA_TIMEOUT",
                        "Délai dépassé pendant le chargement des informations de cet objet.")
                end
            end
        end)
    end
    FinishIfReady()
end

local function CancelTimer(timer)
    if timer and type(timer.Cancel) == "function" then pcall(timer.Cancel, timer) end
end

local function CopyTable(source)
    local copy = {}
    if type(source) == "table" then
        for key, value in pairs(source) do copy[key] = value end
    end
    return copy
end

local function ApplyGemStatsRecord(record, normalized, unmapped, attempts, itemInfo)
    record.stats = CopyTable(normalized)
    record.unmapped_stats = CopyTable(unmapped)
    record.resolved = true
    record.attempts = attempts
    record.item_info = OrNull(itemInfo)
    record.reason = NULL
end

local function ApplyGemIdentityRecord(record, gemID, gemName, gemLink, getItemInfo)
    if type(record) ~= "table" then return false end
    local name, link, resolved, reason = ResolveGemIdentity(
        gemID, gemName or Plain(record.gem_name, "string"),
        gemLink or Plain(record.gem_link, "string")
            or Plain(record.item_link, "string"), getItemInfo)
    record.gem_name = OrNull(name)
    record.gem_link = OrNull(link)
    -- Conserver l'ancien nom interne pour les lectures de stats existantes.
    record.item_link = OrNull(link)
    record.identity_resolved = resolved
    record.identity_reason = resolved and NULL
        or reason or "gem_item_data_unavailable"
    return resolved
end

local function RefreshGemCompleteness(entries)
    for _, entry in ipairs(entries or {}) do
        if entry.source and entry.source.type == "equipped" then
            local modifiers = type(entry.modifiers) == "table" and entry.modifiers or {}
            local gemIDs = type(modifiers.gem_ids) == "table" and modifiers.gem_ids or {}
            local gemStats = type(modifiers.gem_stats) == "table" and modifiers.gem_stats or {}
            local complete = #gemStats == #gemIDs
            local anyResolved = false
            for _, record in ipairs(gemStats) do
                if type(record) ~= "table" or record.resolved ~= true then
                    complete = false
                else
                    anyResolved = true
                end
            end
            local meta = type(entry.stats_meta) == "table" and entry.stats_meta or {}
            entry.stats_meta = meta
            meta.socketed_gem_stats_complete = complete
            meta.socketed_gem_stats_exported_separately = anyResolved
            meta.complete_for_instance = meta.intrinsic_item_stats_available == true
                and meta.base_query_normalized == true and complete
        end
    end
end

-- Deuxième étage du scan : seules les gemmes des objets réellement équipés
-- peuvent certifier character_stats. Les entrées des sacs restent disponibles
-- pour les projections, sans retarder ni invalider ce total permanent.
local function ResolveEquippedGemStats(entries, onProgress, onComplete, api)
    api = type(api) == "table" and api or {}
    local getItemStats = api.getItemStats or (C_Item and C_Item.GetItemStats)
    local getItemInfo = api.getItemInfo or (C_Item and C_Item.GetItemInfo)
    local requestLoad = api.requestLoad or (C_Item and C_Item.RequestLoadItemDataByID)
    local newTimer = api.newTimer or (C_Timer and C_Timer.NewTimer)
    local setEventHandler = api.setEventHandler

    local resolvedByID, jobsByID, jobs = {}, {}, {}

    -- Une gemme déjà résolue peut alimenter les autres occurrences du même ID.
    -- Sa contribution sera malgré tout additionnée pour chaque socket plus bas.
    for _, entry in ipairs(entries or {}) do
        if entry.source and entry.source.type == "equipped" then
            local modifiers = type(entry.modifiers) == "table" and entry.modifiers or {}
            local gemStats = type(modifiers.gem_stats) == "table" and modifiers.gem_stats or {}
            for _, record in ipairs(gemStats) do
                local gemID = type(record) == "table" and Plain(record.gem_id, "number") or nil
                if gemID then ApplyGemIdentityRecord(record, gemID, nil, nil, getItemInfo) end
                if gemID and record.resolved == true and type(record.stats) == "table" then
                    resolvedByID[gemID] = {
                        stats = CopyTable(record.stats),
                        unmapped = CopyTable(record.unmapped_stats),
                        itemInfo = GemItemInfo(gemID, Plain(record.item_link, "string")),
                        gemName = Plain(record.gem_name, "string"),
                        gemLink = Plain(record.gem_link, "string")
                            or Plain(record.item_link, "string"),
                    }
                end
            end
        end
    end

    for _, entry in ipairs(entries or {}) do
        if entry.source and entry.source.type == "equipped" then
            local modifiers = type(entry.modifiers) == "table" and entry.modifiers or {}
            local gemIDs = type(modifiers.gem_ids) == "table" and modifiers.gem_ids or {}
            local gemStats = type(modifiers.gem_stats) == "table" and modifiers.gem_stats or nil
            if not gemStats then gemStats = JSON.Array({}); modifiers.gem_stats = gemStats end

            -- gem_ids vient du itemString exact et fait foi sur la présence. Les
            -- records sont dans le même ordre, même lorsque des sockets sont vides.
            for index, rawGemID in ipairs(gemIDs) do
                local gemID = Plain(rawGemID, "number")
                local record = gemStats[index]
                if type(record) ~= "table" then
                    record = {
                        socket_index = index, gem_id = OrNull(gemID), item_link = NULL,
                        gem_name = NULL, gem_link = NULL,
                        identity_resolved = false,
                        identity_reason = "gem_item_data_unavailable",
                        item_info = OrNull(GemItemInfo(gemID)), stats = Parser.EmptyStats(),
                        unmapped_stats = {}, resolved = false, attempts = 0,
                        reason = "item_data_unavailable",
                    }
                    gemStats[index] = record
                elseif gemID then
                    record.gem_id = gemID
                end
                if gemID then ApplyGemIdentityRecord(record, gemID, nil, nil, getItemInfo) end

                if gemID and record.resolved ~= true then
                    local cached = resolvedByID[gemID]
                    if cached then
                        ApplyGemStatsRecord(record, cached.stats, cached.unmapped,
                            Plain(record.attempts, "number") or 1, cached.itemInfo)
                        ApplyGemIdentityRecord(record, gemID,
                            cached.gemName, cached.gemLink, getItemInfo)
                    else
                        local job = jobsByID[gemID]
                        if not job then
                            job = {
                                gemID = gemID,
                                itemInfo = GemItemInfo(gemID, Plain(record.item_link, "string")),
                                attempts = Plain(record.attempts, "number") or 0,
                                lastFailureReason = Plain(record.reason, "string"),
                                records = {},
                            }
                            jobsByID[gemID] = job
                            jobs[#jobs + 1] = job
                        end
                        job.attempts = math.max(job.attempts,
                            Plain(record.attempts, "number") or 0)
                        job.records[#job.records + 1] = record
                    end
                end
            end
        end
    end

    RefreshGemCompleteness(entries)
    if #jobs == 0 then
        if onComplete then onComplete(entries) end
        return
    end

    local closed, completedJobs, timeoutTimer, removeEventHandler = false, 0

    local function FinishAll()
        if closed or completedJobs < #jobs then return end
        closed = true
        CancelTimer(timeoutTimer)
        timeoutTimer = nil
        if removeEventHandler then pcall(removeEventHandler); removeEventHandler = nil end
        for _, job in ipairs(jobs) do CancelTimer(job.retryTimer); job.retryTimer = nil end
        RefreshGemCompleteness(entries)
        if onComplete then onComplete(entries) end
    end

    local function FinishJob(job, normalized, unmapped, itemInfo, reason)
        if closed or job.done then return end
        job.done = true
        CancelTimer(job.retryTimer)
        job.retryTimer = nil
        for _, record in ipairs(job.records) do
            ApplyGemIdentityRecord(record, job.gemID, nil, nil, getItemInfo)
            if reason then
                record.stats = Parser.EmptyStats()
                record.unmapped_stats = {}
                record.resolved = false
                record.attempts = job.attempts
                record.item_info = OrNull(itemInfo or job.itemInfo)
                record.reason = reason
            else
                ApplyGemStatsRecord(record, normalized, unmapped,
                    job.attempts, itemInfo or job.itemInfo)
            end
        end
        completedJobs = completedJobs + 1
        if onProgress then pcall(onProgress, completedJobs, #jobs, "gems") end
        FinishAll()
    end

    local TryJob
    local function ScheduleRetry(job, delay)
        if closed or job.done then return end
        CancelTimer(job.retryTimer)
        job.retryTimer = nil
        if type(newTimer) ~= "function" then
            FinishJob(job, nil, nil, job.itemInfo, "item_data_unavailable")
            return
        end
        local ok, timer = pcall(newTimer, delay, function()
            job.retryTimer = nil
            TryJob(job)
        end)
        if ok then job.retryTimer = timer end
        if not ok then FinishJob(job, nil, nil, job.itemInfo, "item_data_unavailable") end
    end

    local function RequestAndWait(job)
        -- Installer d'abord le repli : si Blizzard répond immédiatement,
        -- ITEM_DATA_LOAD_RESULT pourra le remplacer par le retry court.
        ScheduleRetry(job, GEM_LOAD_RETRY_DELAY)
        if type(requestLoad) == "function" and (job.gemID or job.itemInfo) then
            -- L'API ByID accepte un ItemInfo, mais l'ID numérique évite de
            -- redemander les données d'un hyperlink d'instance incomplet.
            Call(requestLoad, job.gemID or job.itemInfo)
        end
    end

    TryJob = function(job)
        if closed or job.done then return end
        job.attempts = job.attempts + 1
        for _, record in ipairs(job.records) do
            ApplyGemIdentityRecord(record, job.gemID, nil, nil, getItemInfo)
            local exactLink = Plain(record.gem_link, "string")
            if exactLink then job.itemInfo = exactLink end
        end
        local normalized, unmapped, resolved, itemInfo, _, failureReason =
            QueryGemStats(job.gemID, job.itemInfo, getItemStats)
        if resolved then
            FinishJob(job, normalized, unmapped, itemInfo)
        elseif job.attempts >= GEM_LOAD_MAX_ATTEMPTS then
            FinishJob(job, nil, nil, itemInfo,
                failureReason or "item_data_unavailable")
        else
            job.lastFailureReason = failureReason
            RequestAndWait(job)
        end
    end

    local function HandleItemDataLoadResult(itemID)
        itemID = Plain(itemID, "number")
        local job = itemID and jobsByID[itemID] or nil
        if closed or not job or job.done then return end
        ScheduleRetry(job, GEM_LOAD_EVENT_RETRY_DELAY)
    end

    if type(setEventHandler) == "function" then
        removeEventHandler = setEventHandler(HandleItemDataLoadResult)
    elseif type(CreateFrame) == "function" then
        local frame = CreateFrame("Frame")
        frame:RegisterEvent("ITEM_DATA_LOAD_RESULT")
        frame:SetScript("OnEvent", function(_, _, itemID)
            HandleItemDataLoadResult(itemID)
        end)
        removeEventHandler = function()
            frame:UnregisterEvent("ITEM_DATA_LOAD_RESULT")
            frame:SetScript("OnEvent", nil)
        end
    end

    if type(newTimer) == "function" then
        local ok, timer = pcall(newTimer, GEM_LOAD_TIMEOUT, function()
            timeoutTimer = nil
            if closed then return end
            for _, job in ipairs(jobs) do
                if not job.done then
                    FinishJob(job, nil, nil, job.itemInfo, "timeout")
                end
            end
        end)
        if ok then timeoutTimer = timer end
    end

    if onProgress then pcall(onProgress, 0, #jobs, "gems") end
    for _, job in ipairs(jobs) do
        if job.attempts >= GEM_LOAD_MAX_ATTEMPTS then
            FinishJob(job, nil, nil, job.itemInfo,
                job.lastFailureReason or "item_data_unavailable")
        else
            RequestAndWait(job)
        end
    end
    FinishAll()
end
Scanner.ResolveEquippedGemStats = ResolveEquippedGemStats

local function IsNull(value)
    return JSON.IsNull(value)
end

local function DeduplicateAndFilter(entries, includeNonEquippable)
    local seen, output = {}, {}
    for _, entry in ipairs(entries) do
        local equipped = entry.source and entry.source.type == "equipped"
        local isEquippable = entry.item and entry.item.is_equippable
        local keep = equipped or includeNonEquippable or isEquippable == true
        if keep then
            local guid = entry.item and entry.item.item_guid
            local key = (guid and not IsNull(guid)) and ("guid:" .. tostring(guid)) or entry.instance_key
            if key and not seen[key] then
                seen[key] = true
                output[#output + 1] = entry
            end
        end
    end
    return output
end
Scanner.DeduplicateEntries = DeduplicateAndFilter

function Scanner.SelfTestDeduplication()
    local function Entry(key, guid)
        return {
            instance_key = key,
            source = { type = "bag" },
            item = { item_guid = guid or NULL, is_equippable = true },
        }
    end
    local result = DeduplicateAndFilter({
        Entry("bag:0:1", "Item-1"), Entry("bag:0:2", "Item-1"),
        Entry("bag:0:3", nil), Entry("bag:0:4", nil),
    }, false)
    return #result == 3, result
end

local function CharacterData()
    local name = Plain(UnitName and UnitName("player"), "string")
    local realm = Plain(GetRealmName and GetRealmName(), "string")
    local classFile, classID
    if UnitClass then local _, file, id = UnitClass("player"); classFile, classID = file, id end
    classFile = Plain(classFile, "string")
    classID = Plain(classID, "number")
    local specID, specName
    local getSpec = C_SpecializationInfo and C_SpecializationInfo.GetSpecialization
    local getInfo = C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo
    if getSpec and getInfo then
        local index = Call(getSpec)
        if index then
            local id, localizedName = Call(getInfo, index)
            specID, specName = Plain(id, "number"), Plain(localizedName, "string")
        end
    end
    local lootSpecID, lootSpecUsesActive = NULL, NULL
    if V2 and V2.ReadLootSpecialization then
        lootSpecID, lootSpecUsesActive = V2.ReadLootSpecialization(specID)
    end
    return {
        name = OrNull(name), realm = OrNull(realm),
        level = OrNull(Plain(UnitLevel and UnitLevel("player"), "number")),
        class_id = OrNull(classID), class_file = OrNull(classFile),
        spec_id = OrNull(specID), spec_name = OrNull(specName),
        loot_spec_id = lootSpecID,
        loot_spec_uses_active = lootSpecUsesActive,
    }
end

local CHARACTER_STATS_SOURCE = "equipped_items_and_gems"
local CHARACTER_STATS_METHOD = "equipped_items_and_gems"
local CHARACTER_STAT_KEYS = {
    "critical_strike", "haste", "mastery", "versatility",
}
local STATLESS_EQUIP_LOCATIONS = {
    INVTYPE_BODY = true,
    INVTYPE_TABARD = true,
}
Scanner.CHARACTER_STATS_SOURCE = CHARACTER_STATS_SOURCE

local function CharacterStatsMeta(complete, includesGems, unresolvedGems)
    unresolvedGems = type(unresolvedGems) == "table" and unresolvedGems or {}
    JSON.Array(unresolvedGems)
    return {
        method = CHARACTER_STATS_METHOD,
        complete = complete == true,
        includes_gems = includesGems == true,
        includes_enchantments = false,
        includes_temporary_auras = false,
        unresolved_gems = unresolvedGems,
    }
end
Scanner.BuildCharacterStatsMeta = CharacterStatsMeta

local function EmptyCharacterStats()
    return {
        critical_strike = NULL,
        haste = NULL,
        mastery = NULL,
        versatility = NULL,
    }
end

local function NormalizeCharacterRating(value)
    value = Plain(value, "number")
    if value == nil or value ~= value or value == math.huge or value == -math.huge
        or value < 0 or value > MAX_CHARACTER_RATING then
        return nil
    end
    local integer = math.floor(value + 0.5)
    if integer < 0 or integer > MAX_CHARACTER_RATING then return nil end
    return integer
end

local function AddCharacterRatings(totals, stats)
    if type(stats) ~= "table" then return false end
    for _, key in ipairs(CHARACTER_STAT_KEYS) do
        local value = Plain(stats[key], "number")
        if value == nil or value ~= value or value == math.huge
            or value == -math.huge or value < 0
            or value > MAX_CHARACTER_RATING then
            return false
        end
        totals[key] = totals[key] + value
    end
    return true
end

local function BuildPermanentCharacterStats(entries, expectedEquippedCount)
    local totals = {
        critical_strike = 0, haste = 0, mastery = 0, versatility = 0,
    }
    local itemsComplete, gemsComplete, equippedCount = true, true, 0
    local unresolvedGems = JSON.Array({})
    -- `items[].stats` vient de C_Item.GetItemStats sur la variante exacte avec
    -- enchantement/gemmes neutralisés. Blizzard documente cette API comme une
    -- lecture des stats propres à l'objet : elle ne contient donc aucune aura,
    -- nourriture, flacon, potion ou proc. Les gemmes résolues sont rajoutées
    -- séparément. Les enchantements ne le sont PAS : l'addon exporte leur ID et
    -- GearCompass applique leur valeur via sa base saisonnière.
    for _, entry in ipairs(entries or {}) do
        if entry.source and entry.source.type == "equipped" then
            equippedCount = equippedCount + 1
            local item = type(entry.item) == "table" and entry.item or {}
            local equipLoc = Plain(item.equip_location, "string")
            if not (equipLoc and STATLESS_EQUIP_LOCATIONS[equipLoc]) then
                local meta = type(entry.stats_meta) == "table"
                    and entry.stats_meta or {}
                if meta.intrinsic_item_stats_available ~= true
                    or meta.socketed_gem_stats_complete ~= true
                    or not AddCharacterRatings(totals, entry.stats) then
                    itemsComplete = false
                end
                if meta.socketed_gem_stats_complete ~= true then gemsComplete = false end
                local modifiers = type(entry.modifiers) == "table"
                    and entry.modifiers or {}
                local gemStats = type(modifiers.gem_stats) == "table"
                    and modifiers.gem_stats or {}
                for gemIndex, gem in ipairs(gemStats) do
                    if type(gem) ~= "table" or gem.resolved ~= true
                        or not AddCharacterRatings(totals, gem.stats) then
                        gemsComplete = false
                        local gemID = type(gem) == "table"
                            and Plain(gem.gem_id, "number") or nil
                        unresolvedGems[#unresolvedGems + 1] = {
                            equipment_slot = OrNull(Plain(entry.source.equipment_slot, "number")),
                            item_id = OrNull(Plain(item.item_id, "number")),
                            instance_key = OrNull(Plain(entry.instance_key, "string")),
                            socket_index = OrNull(type(gem) == "table"
                                and Plain(gem.socket_index, "number") or gemIndex),
                            gem_id = OrNull(gemID),
                            reason = (type(gem) == "table"
                                and Plain(gem.reason, "string")) or "item_data_unavailable",
                            attempts = (type(gem) == "table"
                                and Plain(gem.attempts, "number")) or 0,
                        }
                    end
                end
                -- Une gemme présente dans le lien mais absente de gem_stats ne
                -- doit pas conduire à un total partiel présenté comme exact.
                local gemIDs = type(modifiers.gem_ids) == "table"
                    and modifiers.gem_ids or {}
                if #gemStats ~= #gemIDs then
                    gemsComplete = false
                    for index = #gemStats + 1, #gemIDs do
                        unresolvedGems[#unresolvedGems + 1] = {
                            equipment_slot = OrNull(Plain(entry.source.equipment_slot, "number")),
                            item_id = OrNull(Plain(item.item_id, "number")),
                            instance_key = OrNull(Plain(entry.instance_key, "string")),
                            socket_index = index,
                            gem_id = OrNull(Plain(gemIDs[index], "number")),
                            reason = "item_data_unavailable",
                            attempts = 0,
                        }
                    end
                end
            end
        end
    end
    if type(expectedEquippedCount) == "number"
        and equippedCount ~= expectedEquippedCount then
        itemsComplete = false
    end
    local complete = itemsComplete and gemsComplete
    if not complete then
        -- includes_gems certifie ici le total complet, pas seulement la présence
        -- des IDs : une instance équipée incomplète interdit aussi la certification.
        return EmptyCharacterStats(), CharacterStatsMeta(false, false, unresolvedGems)
    end

    local result = {}
    for _, key in ipairs(CHARACTER_STAT_KEYS) do
        local normalized = NormalizeCharacterRating(totals[key])
        if normalized == nil then
            return EmptyCharacterStats(), CharacterStatsMeta(false, false, unresolvedGems)
        end
        result[key] = normalized
    end
    return result, CharacterStatsMeta(true, true, unresolvedGems)
end
Scanner.BuildPermanentCharacterStats = BuildPermanentCharacterStats

function Scanner.SelfTestCharacterStats()
    local entries = {
        {
            instance_key = "guid:stats-ring",
            source = { type = "equipped", equipment_slot = 11 },
            item = { item_id = 1001, equip_location = "INVTYPE_FINGER" },
            stats = {
                critical_strike = 1200, haste = 900, mastery = 0, versatility = 0,
            },
            stats_meta = {
                intrinsic_item_stats_available = true,
                socketed_gem_stats_complete = true,
            },
            modifiers = {
                gem_ids = JSON.Array({ 2001 }),
                gem_stats = JSON.Array({ {
                    socket_index = 1, gem_id = 2001, attempts = 1,
                    resolved = true,
                    stats = {
                        critical_strike = 34, haste = 87,
                        mastery = 0, versatility = 0,
                    },
                } }),
            },
        },
        {
            source = { type = "equipped" },
            item = { equip_location = "INVTYPE_HEAD" },
            stats = {
                critical_strike = 0, haste = 0, mastery = 1456, versatility = 321,
            },
            stats_meta = {
                intrinsic_item_stats_available = true,
                socketed_gem_stats_complete = true,
            },
            modifiers = { gem_ids = JSON.Array({}), gem_stats = JSON.Array({}) },
        },
        {
            source = { type = "equipped" },
            item = { equip_location = "INVTYPE_TABARD" },
        },
        {
            source = { type = "bag" },
            item = { equip_location = "INVTYPE_HEAD" },
            stats = {
                critical_strike = 9999, haste = 9999,
                mastery = 9999, versatility = 9999,
            },
        },
    }
    local result, resultMeta = BuildPermanentCharacterStats(entries, 3)
    local ok = result.critical_strike == 1234 and result.haste == 987
        and result.mastery == 1456 and result.versatility == 321
        and resultMeta.method == CHARACTER_STATS_METHOD
        and resultMeta.complete == true and resultMeta.includes_gems == true
        and resultMeta.includes_enchantments == false
        and resultMeta.includes_temporary_auras == false
        and type(resultMeta.unresolved_gems) == "table"
        and #resultMeta.unresolved_gems == 0

    -- La contribution de la gemme doit être ajoutée une seule fois et avec le
    -- delta exact exposé par C_Item.GetItemStats sur la gemme.
    local savedGemIDs = entries[1].modifiers.gem_ids
    local savedGemStats = entries[1].modifiers.gem_stats
    entries[1].modifiers.gem_ids = JSON.Array({})
    entries[1].modifiers.gem_stats = JSON.Array({})
    local withoutGem, withoutGemMeta = BuildPermanentCharacterStats(entries, 3)
    entries[1].modifiers.gem_ids = savedGemIDs
    entries[1].modifiers.gem_stats = savedGemStats
    ok = ok and withoutGemMeta.complete == true
        and result.critical_strike - withoutGem.critical_strike == 34
        and result.haste - withoutGem.haste == 87
        and result.mastery - withoutGem.mastery == 0
        and result.versatility - withoutGem.versatility == 0

    -- Une nourriture, un flacon ou toute autre aura ne fait pas partie des
    -- entrées d'équipement : des données temporaires parasites doivent être
    -- parfaitement ignorées par ce calcul pur.
    entries.temporary_food_rating = 9999
    entries.temporary_flask_rating = 9999
    entries.temporary_weapon_oil_rating = 9999
    local withTemporaryEffects, temporaryMeta = BuildPermanentCharacterStats(entries, 3)
    ok = ok and withTemporaryEffects.critical_strike == result.critical_strike
        and withTemporaryEffects.haste == result.haste
        and withTemporaryEffects.mastery == result.mastery
        and withTemporaryEffects.versatility == result.versatility
        and temporaryMeta.complete == true

    -- Ajouter un enchantement à l'instance ne change jamais character_stats :
    -- GearCompass valorise cet ID depuis sa base saisonnière.
    entries[1].enchantment = BuildEnchantment(
        { enchant_id = 7654 }, "INVTYPE_FINGER", 11, true)
    local withEnchant, enchantMeta = BuildPermanentCharacterStats(entries, 3)
    ok = ok and withEnchant.critical_strike == result.critical_strike
        and withEnchant.haste == result.haste
        and withEnchant.mastery == result.mastery
        and withEnchant.versatility == result.versatility
        and enchantMeta.complete == true

    entries[1].modifiers.gem_stats[1].resolved = false
    local incomplete, incompleteMeta = BuildPermanentCharacterStats(entries, 3)
    ok = ok and IsNull(incomplete.critical_strike) and IsNull(incomplete.haste)
        and IsNull(incomplete.mastery) and IsNull(incomplete.versatility)
        and incompleteMeta.complete == false
        and incompleteMeta.includes_gems == false
        and incompleteMeta.includes_enchantments == false
        and incompleteMeta.includes_temporary_auras == false
        and #incompleteMeta.unresolved_gems == 1
        and incompleteMeta.unresolved_gems[1].gem_id == 2001
    return not not ok, {
        permanent = result, permanent_meta = resultMeta,
        incomplete = incomplete, incomplete_meta = incompleteMeta,
    }
end

function Scanner.SelfTestGemResolution()
    local primaryName, primaryLink, primaryIdentityResolved = ResolveGemIdentity(
        240983, nil, nil, function(itemInfo)
            if itemInfo == "item:240983" then
                return "Primary Stat Gem", "|Hitem:240983|h[Primary Stat Gem]|h"
            end
        end)
    local secondaryName, secondaryLink, secondaryIdentityResolved = ResolveGemIdentity(
        240892, "Haste Mastery Gem", "|Hitem:240892|h[Haste Mastery Gem]|h")
    local identityFixturesOK = primaryIdentityResolved == true
        and primaryName == "Primary Stat Gem"
        and primaryLink == "|Hitem:240983|h[Primary Stat Gem]|h"
        and secondaryIdentityResolved == true
        and secondaryName == "Haste Mastery Gem"
        and secondaryLink == "|Hitem:240892|h[Haste Mastery Gem]|h"

    local timers, eventHandler = {}, nil
    local function NewTimer(delay, callback)
        local timer = { delay = delay, callback = callback, cancelled = false }
        function timer:Cancel() self.cancelled = true end
        timers[#timers + 1] = timer
        return timer
    end
    local function RunNextTimer()
        local selectedIndex, selected
        for index, timer in ipairs(timers) do
            if not timer.cancelled and not timer.fired
                and (not selected or timer.delay < selected.delay) then
                selectedIndex, selected = index, timer
            end
        end
        if not selected then return false end
        selected.fired = true
        selected.callback()
        return selectedIndex ~= nil
    end
    local function RunAllTimers()
        local guard = 0
        while RunNextTimer() do
            guard = guard + 1
            if guard > 20 then return false end
        end
        return true
    end
    local function SetEventHandler(handler)
        eventHandler = handler
        return function() eventHandler = nil end
    end
    local function TestEntry(gemIDs, gemStats, key)
        return {
            instance_key = key or "guid:test",
            source = { type = "equipped", equipment_slot = 2 },
            item = { item_id = 50228, equip_location = "INVTYPE_NECK" },
            stats = {
                critical_strike = 100, haste = 200, mastery = 300, versatility = 0,
            },
            stats_meta = {
                intrinsic_item_stats_available = true,
                base_query_normalized = true,
                socketed_gem_stats_complete = #gemIDs == 0,
            },
            modifiers = {
                gem_ids = JSON.Array(gemIDs),
                gem_stats = JSON.Array(gemStats),
            },
        }
    end

    local calls, lastItemInfo = 0, nil
    local function LoadOnSecondCall(itemInfo)
        calls = calls + 1
        lastItemInfo = itemInfo
        if calls == 1 then return nil end
        -- Cas réel validé : 240892 = +14 Hâte, +6 Maîtrise.
        return { ITEM_MOD_HASTE_RATING_SHORT = 14, ITEM_MOD_MASTERY_RATING_SHORT = 6 }
    end

    -- Premier appel synchrone : le lien de gemme manque, mais le gemID connu
    -- produit bien "item:<id>" et non un nombre brut.
    local _, _, firstResolved = QueryGemStats(240892, nil, LoadOnSecondCall)
    local firstItemInfo = lastItemInfo
    local duplicateEntry = TestEntry(
        { 240892, 240892 },
        {
            { socket_index = 1, gem_id = 240892, item_link = NULL,
                stats = Parser.EmptyStats(), unmapped_stats = {}, resolved = false,
                attempts = 1, reason = "item_data_unavailable" },
            { socket_index = 2, gem_id = 240892, item_link = NULL,
                stats = Parser.EmptyStats(), unmapped_stats = {}, resolved = false,
                attempts = 1, reason = "item_data_unavailable" },
        })
    -- Reproduit les valeurs intrinsèques du rapport 0.46.5 afin que le test
    -- contrôle aussi le total final réellement attendu par GearCompass.
    duplicateEntry.stats = {
        critical_strike = 356, haste = 598, mastery = 871, versatility = 232,
    }
    local successFinalizations, requests = 0, 0
    ResolveEquippedGemStats({ duplicateEntry }, nil, function()
        successFinalizations = successFinalizations + 1
    end, {
        getItemStats = LoadOnSecondCall,
        getItemInfo = function(itemInfo)
            if itemInfo == 240892 or itemInfo == "item:240892" then
                return "Haste Mastery Gem", "|Hitem:240892|h[Haste Mastery Gem]|h"
            end
        end,
        requestLoad = function(itemInfo)
            requests = requests + 1
            lastItemInfo = itemInfo
        end,
        newTimer = NewTimer,
        setEventHandler = SetEventHandler,
    })
    if eventHandler then eventHandler(240892, true) end
    local eventRetryRan = RunNextTimer()
    local successTimersDrained = RunAllTimers()
    if eventHandler then eventHandler(240892, true) end

    local duplicateStats, duplicateMeta = BuildPermanentCharacterStats(
        { duplicateEntry }, 1)
    local duplicateOK = duplicateStats.critical_strike == 356
        and duplicateStats.haste == 626 and duplicateStats.mastery == 883
        and duplicateStats.versatility == 232
        and duplicateStats.haste - duplicateEntry.stats.haste == 28
        and duplicateStats.mastery - duplicateEntry.stats.mastery == 12
        and duplicateMeta.complete == true
        and duplicateMeta.includes_gems == true
        and duplicateEntry.modifiers.gem_stats[1].attempts == 2
        and duplicateEntry.modifiers.gem_stats[2].attempts == 2

    -- Une table vide ne doit jamais produire includes_gems=true. À l'inverse,
    -- si le lien complet échoue mais que `item:<id>` fonctionne, le repli doit
    -- conserver exactement les +14 Hâte / +6 Maîtrise structurés.
    local _, _, emptyResolved, _, _, emptyReason = QueryGemStats(
        240892, nil, function() return {} end)
    local fullGemLink = "|cff0070dd|Hitem:240892::::::::90:105:::::::|h[Test Gem]|h|r"
    local fallbackStats, _, fallbackResolved, fallbackItemInfo = QueryGemStats(
        240892, fullGemLink, function(itemInfo)
            if itemInfo == "item:240892" then
                return {
                    ITEM_MOD_HASTE_RATING_SHORT = 14,
                    ITEM_MOD_MASTERY_RATING_SHORT = 6,
                }
            end
            return {}
        end)
    local strictResolutionOK = emptyResolved == false
        and emptyReason == "structured_gem_stats_empty"
        and fallbackResolved == true and fallbackItemInfo == "item:240892"
        and fallbackStats.haste == 14 and fallbackStats.mastery == 6

    -- Aucun socket rempli : résolution immédiate et certification vraie.
    local emptyEntry = TestEntry({}, {}, "guid:empty")
    local emptyFinalizations = 0
    ResolveEquippedGemStats({ emptyEntry }, nil, function()
        emptyFinalizations = emptyFinalizations + 1
    end, {})
    local emptyStats, emptyMeta = BuildPermanentCharacterStats({ emptyEntry }, 1)
    local emptyOK = emptyFinalizations == 1 and emptyMeta.complete == true
        and emptyMeta.includes_gems == true and #emptyMeta.unresolved_gems == 0
        and emptyStats.critical_strike == 100

    -- Échec définitif : trois tentatives au total, nulls et diagnostic précis.
    timers, eventHandler = {}, nil
    local failedEntry = TestEntry(
        { 240898 },
        { { socket_index = 1, gem_id = 240898, item_link = NULL,
            stats = Parser.EmptyStats(), unmapped_stats = {}, resolved = false,
            attempts = 1, reason = "item_data_unavailable" } },
        "guid:failed")
    local failedFinalizations, failedCalls = 0, 0
    ResolveEquippedGemStats({ failedEntry }, nil, function()
        failedFinalizations = failedFinalizations + 1
    end, {
        getItemStats = function(itemInfo)
            failedCalls = failedCalls + 1
            lastItemInfo = itemInfo
            return nil
        end,
        getItemInfo = function(itemInfo)
            if itemInfo == 240898 or itemInfo == "item:240898" then
                return "Site Resolved Gem", "|Hitem:240898|h[Site Resolved Gem]|h"
            end
        end,
        requestLoad = function() end,
        newTimer = NewTimer,
        setEventHandler = SetEventHandler,
    })
    local failureTimersDrained = RunAllTimers()
    local failedStats, failedMeta = BuildPermanentCharacterStats({ failedEntry }, 1)
    local diagnostic = failedMeta.unresolved_gems[1]
    -- Deux formes sont essayees a chaque tentative (lien exact puis item:<id>),
    -- soit quatre appels ici apres l'essai initial deja compte dans le record.
    local failureOK = failedFinalizations == 1 and failedCalls == 4
        and IsNull(failedStats.critical_strike) and IsNull(failedStats.haste)
        and IsNull(failedStats.mastery) and IsNull(failedStats.versatility)
        and failedMeta.complete == false and failedMeta.includes_gems == false
        and diagnostic and diagnostic.equipment_slot == 2
        and diagnostic.item_id == 50228 and diagnostic.instance_key == "guid:failed"
        and diagnostic.socket_index == 1 and diagnostic.gem_id == 240898
        and diagnostic.reason == "item_data_unavailable" and diagnostic.attempts == 3
        and failedEntry.modifiers.gem_stats[1].identity_resolved == true
        and failedEntry.modifiers.gem_stats[1].gem_name == "Site Resolved Gem"
        and failedEntry.modifiers.gem_stats[1].gem_link
            == "|Hitem:240898|h[Site Resolved Gem]|h"
        and IsNull(failedEntry.modifiers.gem_stats[1].identity_reason)

    local ok = identityFixturesOK
        and firstResolved == false and firstItemInfo == "item:240892"
        and lastItemInfo == "item:240898"
        and calls == 2 and requests >= 1 and eventRetryRan and successTimersDrained
        and successFinalizations == 1 and duplicateOK and emptyOK
        and strictResolutionOK and failureTimersDrained and failureOK
    return not not ok, {
        canonical_fallback = "item:240892",
        identity_240983 = {
            name = primaryName, link = primaryLink, resolved = primaryIdentityResolved,
        },
        identity_240892 = {
            name = secondaryName, link = secondaryLink, resolved = secondaryIdentityResolved,
        },
        success_calls = calls,
        success_finalizations = successFinalizations,
        duplicate_stats = duplicateStats,
        empty_table_reason = emptyReason,
        link_fallback_stats = fallbackStats,
        empty_meta = emptyMeta,
        failure_calls = failedCalls,
        failure_finalizations = failedFinalizations,
        failure_meta = failedMeta,
    }
end

local function GameData()
    local version, build, buildDate, tocVersion
    if GetBuildInfo then
        version, build, buildDate, tocVersion = GetBuildInfo()
    end
    local project
    if WOW_PROJECT_ID and WOW_PROJECT_MAINLINE and WOW_PROJECT_ID == WOW_PROJECT_MAINLINE then
        project = "retail"
    end
    return {
        project = OrNull(project), version = OrNull(Plain(version, "string")),
        build = OrNull(Plain(build, "string")), build_date = OrNull(Plain(buildDate, "string")),
        interface = OrNull(Plain(tocVersion, "number")),
        locale = OrNull(Plain(GetLocale and GetLocale(), "string")),
    }
end

-- Projection dédiée à GearCompass : les entrées riches restent intactes pour
-- Asylum Compagnon, puis seules les clés réellement consommées sont copiées
-- dans le payload sérialisé. Ne jamais modifier `entry` dans cette fonction.
local function CompactItemEntry(entry)
    entry = type(entry) == "table" and entry or {}
    local source = type(entry.source) == "table" and entry.source or {}
    local item = type(entry.item) == "table" and entry.item or {}
    local uniqueness = type(item.uniqueness) == "table" and item.uniqueness or {}
    local stats = type(entry.stats) == "table" and entry.stats or {}
    local modifiers = type(entry.modifiers) == "table" and entry.modifiers or {}
    local enchantment = type(entry.enchantment) == "table" and entry.enchantment or {}
    local crafting = type(entry.crafting) == "table" and entry.crafting or {}
    local catalyst = type(entry.catalyst) == "table" and entry.catalyst or {}
    local voidforge = type(entry.voidforge) == "table" and entry.voidforge or nil
    local setInfo = type(entry.set) == "table" and entry.set or {}
    local upgrade = type(entry.upgrade) == "table" and entry.upgrade or {}
    local gemIDs = type(modifiers.gem_ids) == "table"
        and modifiers.gem_ids or JSON.Array({})
    local gemStats = type(modifiers.gem_stats) == "table"
        and modifiers.gem_stats or {}
    local compactGems = JSON.Array({})
    for index, rawGemID in ipairs(gemIDs) do
        local record = type(gemStats[index]) == "table" and gemStats[index] or {}
        local gemID = Plain(record.gem_id, "number")
            or Plain(rawGemID, "number")
        local gemName = Plain(record.gem_name, "string")
        local gemLink = Plain(record.gem_link, "string")
            or Plain(record.item_link, "string")
        local identityResolved = record.identity_resolved == true
            or (gemID ~= nil and gemName ~= nil and gemLink ~= nil)
        local identityReason = Plain(record.identity_reason, "string")
        if gemName and #gemName > 200 then gemName = gemName:sub(1, 200) end
        compactGems[#compactGems + 1] = {
            socket_index = OrNull(Plain(record.socket_index, "number") or index),
            gem_id = OrNull(gemID),
            gem_name = OrNull(gemName),
            gem_link = OrNull(gemLink),
            resolved = identityResolved,
            reason = identityResolved and NULL
                or identityReason or "gem_item_data_unavailable",
        }
    end

    local function Stat(key)
        return Plain(stats[key], "number") or 0
    end

    local applicable = Plain(enchantment.applicable, "boolean")
    local enchantID = Plain(enchantment.enchant_id, "number")
    local spellID = Plain(enchantment.spell_id, "number")
    local enchantName = Plain(enchantment.name, "string")
    if applicable ~= true then enchantID, spellID, enchantName = nil, nil, nil end
    if not enchantID or enchantID <= 0 or enchantID ~= math.floor(enchantID) then enchantID = nil end
    if not spellID or spellID <= 0 or spellID ~= math.floor(spellID) then spellID = nil end
    if enchantName and #enchantName > 160 then enchantName = enchantName:sub(1, 160) end

    return {
        instance_key = OrNull(entry.instance_key),
        source = {
            type = OrNull(source.type),
            equipment_slot = OrNull(source.equipment_slot),
            bag_id = OrNull(source.bag_id),
            slot_index = OrNull(source.slot_index),
        },
        item = {
            item_id = OrNull(item.item_id),
            item_name = OrNull(item.item_name),
            item_link = OrNull(item.item_link),
            item_guid = OrNull(item.item_guid),
            item_level = OrNull(item.item_level),
            equip_location = OrNull(item.equip_location),
            item_class_id = OrNull(item.item_class_id),
            item_subclass_id = OrNull(item.item_subclass_id),
            inventory_type_id = OrNull(item.inventory_type_id or item.inventory_type),
            is_two_handed = OrNull(item.is_two_handed),
            is_equippable = OrNull(item.is_equippable),
            quality = OrNull(item.quality),
            icon_file_id = OrNull(item.icon_file_id),
            uniqueness = {
                is_unique = OrNull(uniqueness.is_unique),
            },
        },
        enchantment = {
            applicable = OrNull(applicable),
            enchant_id = OrNull(enchantID),
            spell_id = OrNull(spellID),
            name = OrNull(enchantName),
        },
        crafting = {
            is_crafted = OrNull(crafting.is_crafted),
            quality = OrNull(crafting.quality),
            creation_context = OrNull(crafting.creation_context),
            complete = crafting.complete == true,
        },
        catalyst = {
            checked = catalyst.checked == true,
            conversion_available = OrNull(
                Plain(catalyst.conversion_available, "boolean")),
            conversion_output_item_id = OrNull(
                Plain(catalyst.conversion_output_item_id, "number")),
            conversion_output_item_name = OrNull(
                Plain(catalyst.conversion_output_item_name, "string")),
            conversion_output_item_link = OrNull(
                Plain(catalyst.conversion_output_item_link, "string")),
            conversion_output_icon_file_id = OrNull(
                Plain(catalyst.conversion_output_icon_file_id, "number")),
            conversion_output_set_id = OrNull(
                Plain(catalyst.conversion_output_set_id, "number")),
            conversion_output_is_tier = OrNull(
                Plain(catalyst.conversion_output_is_tier, "boolean")),
            resolution_method = OrNull(
                Plain(catalyst.resolution_method, "string")),
            resolution_complete = catalyst.resolution_complete == true,
            checked_at = OrNull(Plain(catalyst.checked_at, "number")),
            complete = catalyst.complete == true,
            reason = OrNull(Plain(catalyst.reason, "string")),
        },
        voidforge = voidforge and {
            checked = voidforge.checked == true,
            eligible_for_ascendant_upgrade = OrNull(
                Plain(voidforge.eligible_for_ascendant_upgrade, "boolean")),
            already_ascendant_upgraded = OrNull(
                Plain(voidforge.already_ascendant_upgraded, "boolean")),
            complete = voidforge.complete == true,
        } or nil,
        stats = {
            strength = Stat("strength"),
            agility = Stat("agility"),
            intellect = Stat("intellect"),
            stamina = Stat("stamina"),
            critical_strike = Stat("critical_strike"),
            haste = Stat("haste"),
            mastery = Stat("mastery"),
            versatility = Stat("versatility"),
        },
        modifiers = {
            gem_ids = gemIDs,
            gems = compactGems,
            socket_count = OrNull(modifiers.socket_count),
        },
        set = {
            is_set_item = OrNull(setInfo.is_set_item),
            set_id = OrNull(setInfo.set_id),
            set_name = OrNull(setInfo.set_name),
            is_tier_item = OrNull(setInfo.is_tier_item),
        },
        upgrade = {
            track_string = OrNull(upgrade.track_string),
            current_level = OrNull(upgrade.current_level),
            max_level = OrNull(upgrade.max_level),
        },
    }
end
Scanner.ProjectCompactItem = CompactItemEntry

local function BuildCompactPayload(items, errors, character, characterStats,
        characterStatsMeta, domains)
    local compactItems = JSON.Array({})
    for _, entry in ipairs(items or {}) do
        compactItems[#compactItems + 1] = CompactItemEntry(entry)
    end
    errors = type(errors) == "table" and errors or {}
    local gearErrors = V2 and V2.NormalizeGearErrors
        and V2.NormalizeGearErrors(errors) or errors
    JSON.Array(gearErrors)
    characterStats = characterStats or EmptyCharacterStats()
    if type(characterStatsMeta) ~= "table" then
        local inferredComplete = true
        for _, key in ipairs(CHARACTER_STAT_KEYS) do
            if Plain(characterStats[key], "number") == nil then inferredComplete = false end
        end
        characterStatsMeta = CharacterStatsMeta(inferredComplete, inferredComplete)
    end
    domains = type(domains) == "table" and domains
        or (V2 and V2.BuildDomains and V2.BuildDomains()) or {}
    local resources = type(domains.resources) == "table" and domains.resources
        or { complete = false, season_manifest = NULL, currencies = JSON.Array({}),
            items = JSON.Array({}), catalyst = {}, voidforge = {}, errors = JSON.Array({}) }
    local upgradeState = type(domains.upgrade_state) == "table" and domains.upgrade_state
        or { complete = false, slots = JSON.Array({}), errors = JSON.Array({}) }
    local weeklyRewards = type(domains.weekly_rewards) == "table" and domains.weekly_rewards
        or {
            complete = false,
            activities = JSON.Array({}),
            mythic_plus_runs = JSON.Array({}),
            mythic_plus_runs_meta = {
                complete = false,
                source = "C_MythicPlus.GetRunHistory",
                scope = "current_week_completed",
                include_previous_weeks = false,
                include_incomplete_runs = false,
                current_season_only = true,
                returned_count = NULL,
                count = 0,
            },
            errors = JSON.Array({}),
        }
    local collectionStatus = V2 and V2.BuildCollectionStatus
        and V2.BuildCollectionStatus(#gearErrors == 0, characterStatsMeta.complete == true,
            resources, upgradeState, weeklyRewards) or {}
    local bonusRollHistory = type(domains.bonus_roll_history_contract) == "table"
        and domains.bonus_roll_history_contract
        or (BonusRollHistory and BonusRollHistory.GetExport
            and BonusRollHistory.GetExport()) or {
                bonus_roll_history_complete = false,
                bonus_roll_history_scope = "prospective",
                bonus_roll_history_started_at = NULL,
                bonus_roll_history = JSON.Array({}),
            }
    return {
        schema_version = 2,
        export_type = "gearcompass",
        addon = { name = ADDON_NAME, version = OrNull(NS.VERSION) },
        client = domains.client or {},
        character = character or CharacterData(),
        character_stats = characterStats,
        character_stats_meta = characterStatsMeta,
        character_stats_source = CHARACTER_STATS_SOURCE,
        resources = resources,
        upgrade_state = upgradeState,
        weekly_rewards = weeklyRewards,
        collection_status = collectionStatus,
        bonus_roll_history_complete = bonusRollHistory.bonus_roll_history_complete == true,
        bonus_roll_history_scope = Plain(
            bonusRollHistory.bonus_roll_history_scope, "string") or "prospective",
        bonus_roll_history_started_at = OrNull(Plain(
            bonusRollHistory.bonus_roll_history_started_at, "number")),
        bonus_roll_history = type(bonusRollHistory.bonus_roll_history) == "table"
            and bonusRollHistory.bonus_roll_history or JSON.Array({}),
        errors = gearErrors,
        items = compactItems,
    }
end
Scanner.BuildCompactPayload = BuildCompactPayload

local function EncodeCompactPayload(root)
    if not (Checksum and type(Checksum.Encode) == "function") then
        return nil, "GEARCOMPASS_CHECKSUM_UNAVAILABLE"
    end
    return Checksum.Encode(root)
end
Scanner.EncodeCompactPayload = EncodeCompactPayload

function Scanner.SelfTestCompactPayload()
    local full = {
        instance_key = "bag:0:1",
        source = { type = "bag", equipment_slot = NULL, bag_id = 0, slot_index = 1 },
        item = {
            item_id = 123, item_name = "Test", item_level = 300,
            equip_location = "INVTYPE_HEAD", is_equippable = true,
            quality = 4, icon_file_id = 134400, item_link = "item:123",
            item_guid = "Item-0-000000000001",
            item_class_id = 4, item_subclass_id = 2,
            inventory_type_id = 1, is_two_handed = NULL,
            uniqueness = { is_unique = false, limit_category_id = 99 },
        },
        stats = {
            strength = 0, agility = 10, intellect = 0, stamina = 20,
            critical_strike = 3, haste = 4, mastery = 5, versatility = 6,
            armor = 999, leech = 7,
        },
        stats_meta = { complete_for_instance = true },
        unmapped_stats = { UNUSED = 1 },
        modifiers = {
            gem_ids = JSON.Array({ 240983, 240892 }), socket_count = 2,
            gem_stats = JSON.Array({
                {
                    socket_index = 1, gem_id = 240983,
                    gem_name = "Primary Gem",
                    gem_link = "|Hitem:240983|h[Primary Gem]|h",
                    item_link = "|Hitem:240983|h[Primary Gem]|h",
                    identity_resolved = true, identity_reason = NULL,
                },
                {
                    socket_index = 2, gem_id = 240892,
                    gem_name = "Haste Mastery Gem",
                    gem_link = "|Hitem:240892|h[Haste Mastery Gem]|h",
                    item_link = "|Hitem:240892|h[Haste Mastery Gem]|h",
                    identity_resolved = true, identity_reason = NULL,
                },
            }),
            bonus_ids = JSON.Array({ 222 }),
        },
        enchantment = {
            applicable = true, enchant_id = 777, spell_id = NULL, name = NULL,
        },
        crafting = {
            is_crafted = false, quality = NULL,
            creation_context = "RaidNormal", complete = true,
        },
        catalyst = {
            checked = true, conversion_available = true,
            conversion_output_item_id = NULL,
            conversion_output_item_name = NULL,
            conversion_output_item_link = NULL,
            conversion_output_icon_file_id = 134401,
            conversion_output_set_id = NULL,
            conversion_output_is_tier = NULL,
            resolution_method = "C_Item.GetItemConversionOutputIcon",
            resolution_complete = true,
            checked_at = 1780000000,
            complete = true,
            reason = NULL,
        },
        set = { is_set_item = true, set_id = 12, set_name = "Set", is_tier_item = true },
        upgrade = {
            track_string = "Hero", current_level = 2, max_level = 6,
            max_item_level = 320, track_string_id = 88,
        },
    }
    local character = {
        name = "Test", realm = "Realm", class_file = "DRUID", class_id = 11,
        spec_id = 104, spec_name = "Guardian", level = 80,
        loot_spec_id = 104, loot_spec_uses_active = true,
    }
    local characterStats = {
        critical_strike = 1234, haste = 987, mastery = 1456, versatility = 321,
    }
    local characterStatsMeta = CharacterStatsMeta(true, true)
    local domains = {
        client = {
            wow_version = "12.0.7", build = "68453", interface = 120007,
            locale = "frFR", region_id = 3, region = "EU", generated_at = 1780000000,
        },
        resources = {
            complete = false, season_manifest = "midnight-s1",
            currencies = JSON.Array({}), items = JSON.Array({}),
            catalyst = {}, voidforge = {}, errors = JSON.Array({}),
        },
        upgrade_state = { complete = true, slots = JSON.Array({}), errors = JSON.Array({}) },
        weekly_rewards = {
            complete = true,
            activities = JSON.Array({}),
            mythic_plus_runs = JSON.Array({
                {
                    map_challenge_mode_id = 200, map_name = "Test Dungeon",
                    ui_map_id = 1200, level = 10, this_week = true,
                    completed = true, run_score = 123.4, duration_seconds = 1800,
                    completion_date = {
                        year = 2026, month = 7, month_day = 16, weekday = 5,
                        hour = 20, minute = 30, complete = true,
                    },
                    completion_date_complete = true, season_id = 1, complete = true,
                },
            }),
            mythic_plus_runs_meta = {
                complete = true, source = "C_MythicPlus.GetRunHistory",
                scope = "current_week_completed", include_previous_weeks = false,
                include_incomplete_runs = false, current_season_only = true,
                returned_count = 1, count = 1,
            },
            errors = JSON.Array({}),
        },
        bonus_roll_history_contract = {
            bonus_roll_history_complete = false,
            bonus_roll_history_scope = "prospective",
            bonus_roll_history_started_at = 1780000000,
            bonus_roll_history = JSON.Array({
                {
                    event_id = "bonus-roll:1780000100:deadbeef",
                    recorded_at = 1780000100, result_type = "item",
                    type_identifier = "item", item_id = 251115,
                    item_link = "|Hitem:251115|h[Test roll]|h", quantity = 1,
                    destination = "Test Raid", instance_name = "Test Raid",
                    encounter_name = "Test Boss", difficulty = "Heroic",
                    context = "raid", confidence = "event_observed",
                    loot_spec_id = 104, map_id = 2405, instance_id = 1307,
                    encounter_id = 2734, difficulty_id = 15,
                    mythic_level = NULL, source_type = "raid",
                    context_confidence = "observed", currency_id = NULL,
                    money = NULL, is_secondary_result = false, corrupted = false,
                },
            }),
        },
    }
    local encoded = EncodeCompactPayload(BuildCompactPayload(
        { full }, JSON.Array({}), character, characterStats, characterStatsMeta, domains))
    local incompleteEncoded = EncodeCompactPayload(BuildCompactPayload(
        { full }, JSON.Array({}), character, EmptyCharacterStats(),
        CharacterStatsMeta(false, false), domains))
    local checksumValid = Checksum and Checksum.Verify and Checksum.Verify(encoded)
    local catalystRetryRequired = NeedsCatalystRetry({ catalyst = {
        checked = true, conversion_available = true,
        resolution_complete = false,
        reason = "conversion_output_icon_unavailable",
    } })
    local catalystRetryNotRequired = NeedsCatalystRetry({ catalyst = {
        checked = true, conversion_available = true,
        resolution_complete = true, reason = NULL,
    } })
    local required = type(encoded) == "string"
        and encoded:find('"icon_file_id":134400', 1, true)
        and encoded:find('"is_tier_item":true', 1, true)
        and encoded:find('"enchantment":{"applicable":true,"enchant_id":777,"name":null,"spell_id":null}', 1, true)
        and encoded:find('"character_stats":{"critical_strike":1234,"haste":987,"mastery":1456,"versatility":321}', 1, true)
        and encoded:find('"character_stats_meta":{"complete":true,"includes_enchantments":false,"includes_gems":true,"includes_temporary_auras":false,"method":"equipped_items_and_gems","unresolved_gems":[]}', 1, true)
        and encoded:find('"character_stats_source":"equipped_items_and_gems"', 1, true)
        and encoded:find('"schema_version":2', 1, true)
        and encoded:find('"region":"EU"', 1, true)
        and encoded:find('"loot_spec_id":104', 1, true)
        and encoded:find('"item_link":"item:123"', 1, true)
        and encoded:find('"item_guid":"Item-0-000000000001"', 1, true)
        and encoded:find('"bag_id":0', 1, true)
        and encoded:find('"slot_index":1', 1, true)
        and encoded:find('"inventory_type_id":1', 1, true)
        and encoded:find('"creation_context":"RaidNormal"', 1, true)
        and encoded:find('"conversion_output_icon_file_id":134401', 1, true)
        and encoded:find('"conversion_output_item_id":null', 1, true)
        and encoded:find('"conversion_output_set_id":null', 1, true)
        and encoded:find('"conversion_output_is_tier":null', 1, true)
        and encoded:find('"resolution_method":"C_Item.GetItemConversionOutputIcon"', 1, true)
        and encoded:find('"resolution_complete":true', 1, true)
        and encoded:find('"checked_at":1780000000', 1, true)
        and encoded:find('"complete":true', 1, true)
        and encoded:find('"season_manifest":"midnight-s1"', 1, true)
        and encoded:find('"collection_status":', 1, true)
        and encoded:find('"mythic_plus_runs":[{', 1, true)
        and encoded:find('"map_challenge_mode_id":200', 1, true)
        and encoded:find('"scope":"current_week_completed"', 1, true)
        and encoded:find('"returned_count":1', 1, true)
        and encoded:find('"bonus_roll_history":[{', 1, true)
        and encoded:find('"bonus_roll_history_complete":false', 1, true)
        and encoded:find('"bonus_roll_history_scope":"prospective"', 1, true)
        and encoded:find('"bonus_roll_history_started_at":1780000000', 1, true)
        and encoded:find('"event_id":"bonus-roll:1780000100:deadbeef"', 1, true)
        and encoded:find('"integrity":{"algorithm":"adler32","encoding":"utf-8","scope":"raw_json_with_integrity_value_placeholder","value":"', 1, true)
        and checksumValid == true
        and encoded:find('"gem_ids":[240983,240892]', 1, true)
        and encoded:find('"gem_id":240983', 1, true)
        and encoded:find('"gem_id":240892', 1, true)
        and encoded:find('"gem_name":"Primary Gem"', 1, true)
        and encoded:find('"gem_link":"\\u007cHitem:240892\\u007ch[Haste Mastery Gem]\\u007ch"', 1, true)
        and encoded:find('"socket_count":2', 1, true)
        and catalystRetryRequired == true and catalystRetryNotRequired == false
        and incompleteEncoded:find('"character_stats":{"critical_strike":null,"haste":null,"mastery":null,"versatility":null}', 1, true)
        and incompleteEncoded:find('"character_stats_meta":{"complete":false,"includes_enchantments":false,"includes_gems":false,"includes_temporary_auras":false,"method":"equipped_items_and_gems","unresolved_gems":[]}', 1, true)
    local removed = type(encoded) == "string"
        and not encoded:find('"game":', 1, true)
        and not encoded:find('"scan":', 1, true)
        and not encoded:find('"adjustments":', 1, true)
        and not encoded:find('"gem_stats":', 1, true)
        and not encoded:find('"armor":', 1, true)
    return not not (required and removed), encoded
end

function Scanner.SelfTestEnchantments()
    local enchanted = BuildEnchantment({ enchant_id = 7654 }, "INVTYPE_FINGER", nil, true)
    local unenchanted = BuildEnchantment({}, "INVTYPE_FINGER", nil, true)
    local notApplicable = BuildEnchantment({ enchant_id = 999 }, "INVTYPE_TRINKET", nil, true)
    local uncertain = BuildEnchantment({ enchant_id = 999 }, nil, nil, nil)

    local first = CompactItemEntry({
        instance_key = "bag:0:1", item = { item_id = 123, is_equippable = true },
        source = { type = "bag" }, enchantment = enchanted,
    })
    local second = CompactItemEntry({
        instance_key = "bag:0:2", item = { item_id = 123, is_equippable = true },
        source = { type = "bag" }, enchantment = unenchanted,
    })
    local ok = enchanted.applicable == true and enchanted.enchant_id == 7654
        and IsNull(enchanted.spell_id) and IsNull(enchanted.name)
        and unenchanted.applicable == true and IsNull(unenchanted.enchant_id)
        and IsNull(unenchanted.spell_id) and IsNull(unenchanted.name)
        and notApplicable.applicable == false and IsNull(notApplicable.enchant_id)
        and IsNull(notApplicable.spell_id) and IsNull(notApplicable.name)
        and IsNull(uncertain.applicable) and IsNull(uncertain.enchant_id)
        and IsNull(uncertain.spell_id) and IsNull(uncertain.name)
        and first.instance_key ~= second.instance_key
        and first.item.item_id == second.item.item_id
        and first.enchantment.enchant_id == 7654
        and IsNull(second.enchantment.enchant_id)
    return not not ok, { enchanted, unenchanted, notApplicable, uncertain, first, second }
end

function Scanner.SelfTestTierDetection()
    local tier = ResolveTierStatus(
        1980, { 1264812, 1264813 }, true, "INVTYPE_SHOULDER", 90, 90)
    local ordinarySet = ResolveTierStatus(42, {}, true, "INVTYPE_HEAD", 90, 90)
    local nonTierSlot = ResolveTierStatus(
        43, { 999 }, true, "INVTYPE_FINGER", 90, 90)
    local legacyTier = ResolveTierStatus(
        44, { 998 }, true, "INVTYPE_HEAD", 80, 90)
    local unresolved = ResolveTierStatus(42, {}, false, "INVTYPE_HEAD", 90, 90)
    local certainNonSet = ResolveTierStatus(nil, {}, true, "INVTYPE_HEAD", 90, 90)
    return tier == true and ordinarySet == nil and nonTierSlot == false
        and legacyTier == false and unresolved == nil and certainNonSet == false,
        { tier = tier, ordinary_set = OrNull(ordinarySet), non_tier_slot = nonTierSlot,
            legacy_tier = legacyTier, unresolved = OrNull(unresolved),
            certain_non_set = certainNonSet }
end

local exportGeneration = 0
local lastSummary

function Scanner.GetLastSummary()
    return lastSummary
end

function Scanner.ClearLastSummary()
    lastSummary = nil
end

function Scanner.StartExport(onProgress, onComplete, overrideSettings)
    exportGeneration = exportGeneration + 1
    local generation = exportGeneration
    -- Amorcer l'historique M+ dès le clic. La résolution asynchrone des objets
    -- laisse ainsi au client le temps de rafraîchir ses données avant que les
    -- domaines GearCompass soient construits à la fin du scan.
    if C_MythicPlus and type(C_MythicPlus.RequestMapInfo) == "function" then
        pcall(C_MythicPlus.RequestMapInfo)
    end
    local savedSettings = overrideSettings or DB.GetSettings()
    -- Instantané immuable : un clic sur une case pendant les callbacks de
    -- chargement ne doit pas modifier silencieusement l'export en cours.
    local settings = {
        includeEquipped = savedSettings.includeEquipped ~= false,
        includeBags = savedSettings.includeBags ~= false,
        includeNonEquippable = savedSettings.includeNonEquippable == true,
    }
    local collectedSnapshots = {}

    local function AddSnapshots(list)
        for _, snapshot in ipairs(list or {}) do
            collectedSnapshots[#collectedSnapshots + 1] = snapshot
        end
    end
    -- Les objets portés sont toujours résolus pour produire le total permanent
    -- `character_stats`, même si l'utilisateur choisit de ne pas les inclure
    -- dans `items[]`. Cette option ne doit jamais modifier le référentiel stats.
    AddSnapshots(CollectEquipment())
    if settings.includeBags ~= false then AddSnapshots(CollectBags()) end

    -- Compter l'équipement AVANT les exclusions. Une pièce portée écartée par
    -- erreur ou devenue illisible doit rendre le total incomplet, pas disparaître
    -- silencieusement du nombre attendu.
    local expectedEquippedCount = 0
    for _, snapshot in ipairs(collectedSnapshots) do
        if snapshot.source_type == "equipped" then
            expectedEquippedCount = expectedEquippedCount + 1
        end
    end
    local snapshots, excluded = ExcludeSnapshots(
        collectedSnapshots, settings.includeNonEquippable == true)

    if onProgress then pcall(onProgress, 0, #snapshots) end
    local function ItemProgress(done, total)
        if onProgress then pcall(onProgress, done, total, "items") end
    end
    ResolveSnapshots(snapshots, ItemProgress, function(liveEntries, errors)
        if generation ~= exportGeneration then return end
        ResolveEquippedGemStats(liveEntries, onProgress, function(resolvedEntries)
        if generation ~= exportGeneration then return end
        local characterStats, characterStatsMeta = BuildPermanentCharacterStats(
            resolvedEntries, expectedEquippedCount)
        local resolvedItems = DeduplicateAndFilter(
            resolvedEntries, settings.includeNonEquippable == true)
        local items = {}
        for _, entry in ipairs(resolvedItems) do
            if settings.includeEquipped ~= false
                or not (entry.source and entry.source.type == "equipped") then
                items[#items + 1] = entry
            end
        end

        -- Les échecs des instances portées servent seulement au champ optionnel
        -- character_stats lorsque l'équipement est décoché. Ne pas les présenter
        -- comme des erreurs bloquantes d'items qui ne sont pas exportés.
        if settings.includeEquipped == false then
            local visibleErrors = JSON.Array({})
            for _, itemError in ipairs(errors) do
                if not (itemError.source and itemError.source.type == "equipped") then
                    visibleErrors[#visibleErrors + 1] = itemError
                end
            end
            errors = visibleErrors
        end

        local counts = { equipped = 0, bags = 0 }
        for _, entry in ipairs(items) do
            local sourceType = entry.source and entry.source.type
            if sourceType == "equipped" then counts.equipped = counts.equipped + 1
            elseif sourceType == "bag" then counts.bags = counts.bags + 1 end
            if entry.item and entry.item.is_equippable == true then
                if entry.stats_meta and not entry.stats_meta.intrinsic_item_stats_available then
                    errors[#errors + 1] = {
                        instance_key = entry.instance_key,
                        code = "ITEM_STATS_NOT_AVAILABLE",
                        message = "Les statistiques structurées de cet objet ne sont pas disponibles.",
                    }
                end
                if IsNull(entry.item.item_level) then
                    errors[#errors + 1] = {
                        instance_key = entry.instance_key,
                        code = "ITEM_LEVEL_NOT_AVAILABLE",
                        message = "Le niveau d'objet réel n'est pas disponible.",
                    }
                end
            end
        end

        local root = BuildCompactPayload(
            items, errors, nil, characterStats, characterStatsMeta)
        local encoded, encodeError = EncodeCompactPayload(root)
        local generatedAt = Now()
        local summary = {
            total = #items, equipped = counts.equipped, bags = counts.bags,
            excluded = excluded.total, errors = #errors, generatedAt = generatedAt,
        }
        if encoded then
            local data = DB.Get()
            if data then data.lastExport = encoded end
            lastSummary = summary
        end
        if onComplete then
            pcall(onComplete, encoded, encodeError, summary)
        end
        end)
    end)
end
