-- ============================================================
-- 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 Parser = NS.GearCompassItemParser
local DB = NS.GearCompassDB
local NULL = JSON.NULL

local ITEM_LOAD_TIMEOUT = 8
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

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

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 _, gemLink
        if C_Item.GetItemGem then _, gemLink = Call(C_Item.GetItemGem, link, socketIndex) end
        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 gemRaw = Call(C_Item.GetItemStats, gemLink or gemID)
            local normalized, gemUnmapped = Parser.EmptyStats(), {}
            local resolved = type(gemRaw) == "table"
            if resolved then
                normalized, gemUnmapped = Parser.NormalizeStats(gemRaw)
                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),
                item_link = OrNull(gemLink),
                stats = normalized,
                unmapped_stats = gemUnmapped,
                resolved = resolved,
            }
        end
    end
    return stats, unmapped, gemStats, meta
end

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 ReadSetInfo(setID, itemID)
    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 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
    return {
        set_id = OrNull(setID),
        set_name = OrNull(setName),
        is_set_item = setID ~= nil,
        -- Aucune API ne classe de façon certaine un set comme « tier ».
        is_tier_item = NULL,
        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 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 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 = ReadOriginalCraft(guid),
        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

    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_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(InventoryType(itemInfo)),
            equip_location = OrNull(equipLoc),
            item_class_id = OrNull(info.classID),
            item_subclass_id = OrNull(info.subclassID),
            bind_type = OrNull(info.bindType),
            is_equippable = OrNull(equippable),
            stack_count = snapshot.stack_count or 1,
            uniqueness = ReadUniqueness(itemInfo),
        },
        stats = stats,
        stats_meta = statsMeta,
        unmapped_stats = unmapped,
        modifiers = modifiers,
        set = ReadSetInfo(info.setID, itemID),
        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 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 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
            -- 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 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
    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),
    }
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

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
    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
    if settings.includeEquipped ~= false then AddSnapshots(CollectEquipment()) end
    if settings.includeBags ~= false then AddSnapshots(CollectBags()) end

    local snapshots, excluded = ExcludeSnapshots(
        collectedSnapshots, settings.includeNonEquippable == true)

    if onProgress then pcall(onProgress, 0, #snapshots) end
    ResolveSnapshots(snapshots, onProgress, function(liveEntries, errors)
        if generation ~= exportGeneration then return end
        local items = DeduplicateAndFilter(liveEntries, settings.includeNonEquippable == true)

        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 = {
            schema_version = 1,
            export_type = "gearcompass",
            generated_at = Now(),
            adjustments = {
                ring_enchants = {
                    slots = 2,
                    enabled = true,
                },
            },
            addon = { name = ADDON_NAME, version = OrNull(NS.VERSION) },
            game = GameData(),
            character = CharacterData(),
            scan = {
                equipped = { included = settings.includeEquipped ~= false, item_count = counts.equipped },
                bags = { included = settings.includeBags ~= false, item_count = counts.bags },
                excluded_before_resolution = excluded,
                stats = {
                    intrinsic_source = "C_Item.GetItemStats(normalized_item_string)",
                    normalized_fields = "enchant_and_four_gem_fields_cleared",
                    socketed_gems_exported_separately = true,
                },
            },
            items = JSON.Array(items),
            errors = errors,
        }
        local encoded, encodeError = JSON.Encode(root)
        local summary = {
            total = #items, equipped = counts.equipped, bags = counts.bags,
            excluded = excluded.total, errors = #errors, generatedAt = root.generated_at,
        }
        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
