-- ============================================================
-- Asylum Compagnon — HoverItemLevel.lua
-- Affiche le niveau d'objet moyen équipé dans l'infobulle d'un joueur et
-- enrichit les fiches natives du joueur et d'inspection (ilvl par pièce,
-- sockets et nom/présence de l'enchantement). La moyenne ajoutée par l'addon
-- est réservée à l'inspection : Blizzard l'affiche déjà sur la fiche personnelle.
--
-- Retail fournit cette information de façon asynchrone : NotifyInspect
-- demande les données, INSPECT_READY confirme leur disponibilité, puis
-- C_PaperDollInfo.GetInspectItemLevel lit la valeur. Une seule demande est
-- envoyée à la fois et le résultat reste en cache 120 secondes afin de ne
-- pas solliciter inutilement le serveur d'inspection.
-- ============================================================

local ADDON_NAME, NS = ...

local HIL = {}
NS.HoverItemLevel = HIL

local CACHE_TTL = 120
local REQUEST_TIMEOUT = 5
local MIN_REQUEST_INTERVAL = 1.5
local MAX_QUEUE = 8
local FAILURE_RETRY_DELAY = 15

local cache = {}
local failedUntil = {}
local queue = {}
local queued = {}
local pending
local processTimer
local requestSerial = 0
local lastRequestAt = -1000

local ProcessQueue
local RefreshCurrentTooltip

local function Now()
    return (type(GetTime) == "function" and GetTime()) or 0
end

local function TooltipEnabled()
    return NS.db and NS.db.tbHoverItemLevelTooltip == true
end

local function InspectEnabled()
    return NS.db and NS.db.tbHoverItemLevelInspect == true
end

local function NormalizeItemLevel(value)
    local ok, rounded = pcall(function()
        if type(value) ~= "number" or value <= 0 then return nil end
        return math.floor(value + 0.5)
    end)
    return ok and rounded or nil
end

-- Les GUID et certains tokens d'un ennemi peuvent devenir des valeurs
-- secrètes en Midnight. Toute opération sur leur contenu reste donc isolée.
local function IsPlayerGUID(guid)
    local ok, isPlayer = pcall(function()
        if type(guid) ~= "string" then return false end
        if string.match(guid, "^Player%-") ~= nil then return true end
        return false
    end)
    return ok and isPlayer == true
end

local function SameGUID(unit, guid)
    if unit == nil or guid == nil or type(UnitGUID) ~= "function" then return false end
    local ok, same = pcall(function()
        if UnitGUID(unit) == guid then return true end
        return false
    end)
    return ok and same == true
end

local function GetTooltipUnit(tooltip)
    if not tooltip or type(tooltip.GetUnit) ~= "function" then return nil end
    local ok, _, unit = pcall(tooltip.GetUnit, tooltip)
    return ok and unit or nil
end

local function ResolveUnit(guid, preferred)
    if SameGUID(preferred, guid) then return preferred end
    if SameGUID("mouseover", guid) then return "mouseover" end

    if type(UnitTokenFromGUID) == "function" then
        local ok, unit = pcall(function()
            local candidate = UnitTokenFromGUID(guid)
            if candidate == nil or UnitGUID(candidate) ~= guid then return nil end
            return candidate
        end)
        if ok and unit ~= nil then return unit end
    end
    return nil
end

local function CanRequest(unit, guid)
    local resolved = ResolveUnit(guid, unit)
    if resolved == nil or type(CanInspect) ~= "function"
        or type(NotifyInspect) ~= "function" then
        return false
    end
    local ok, can = pcall(function()
        if type(UnitExists) == "function" and not UnitExists(resolved) then return false end
        if type(UnitIsVisible) == "function" and not UnitIsVisible(resolved) then return false end
        if CanInspect(resolved, false) then return true end
        return false
    end)
    return ok and can == true, resolved
end

local function CacheRead(store, guid, now)
    local entry = store[guid]
    if not entry then return nil end
    if now >= entry.expiresAt then
        store[guid] = nil
        return nil
    end
    return entry.itemLevel
end

local function GetCached(guid)
    return CacheRead(cache, guid, Now())
end

local function Store(guid, itemLevel)
    local normalized = NormalizeItemLevel(itemLevel)
    if not normalized then return nil end
    cache[guid] = {
        itemLevel = normalized,
        expiresAt = Now() + CACHE_TTL,
    }
    failedUntil[guid] = nil
    return normalized
end

local function OwnItemLevel(guid)
    if not SameGUID("player", guid) or type(GetAverageItemLevel) ~= "function" then return nil end
    local ok, overall, equipped = pcall(GetAverageItemLevel)
    if not ok then return nil end
    return Store(guid, equipped or overall)
end

local function InspectFrameIsOpen()
    local frame = _G.InspectFrame
    if not frame or type(frame.IsShown) ~= "function" then return false end
    local ok, shown = pcall(frame.IsShown, frame)
    return ok and shown == true
end

local function ClearQueue()
    for i = #queue, 1, -1 do queue[i] = nil end
    for guid in pairs(queued) do queued[guid] = nil end
end

local function ScheduleProcess(delay)
    if processTimer or not (C_Timer and C_Timer.NewTimer) then return end
    processTimer = C_Timer.NewTimer(math.max(0, delay or 0), function()
        processTimer = nil
        ProcessQueue()
    end)
end

local function Enqueue(guid, unit)
    if pending and pending.guid == guid then return true end
    if queued[guid] then return true end

    if #queue >= MAX_QUEUE then
        local removed = table.remove(queue, 1)
        if removed then queued[removed.guid] = nil end
    end
    queue[#queue + 1] = { guid = guid, unit = unit }
    queued[guid] = true
    ProcessQueue()
    return (pending and pending.guid == guid) or queued[guid] == true
end

ProcessQueue = function()
    if not TooltipEnabled() then
        ClearQueue()
        return
    end
    if pending then return end
    if InspectFrameIsOpen() then
        ScheduleProcess(1)
        return
    end

    local remaining = MIN_REQUEST_INTERVAL - (Now() - lastRequestAt)
    if remaining > 0 then
        ScheduleProcess(remaining)
        return
    end

    while #queue > 0 do
        -- Le dernier joueur réellement survolé passe en premier ; les anciennes
        -- entrées restent bornées derrière lui sans ralentir l'infobulle active.
        local request = table.remove(queue)
        queued[request.guid] = nil

        if not GetCached(request.guid) then
            local can, unit = CanRequest(request.unit, request.guid)
            if can and unit then
                requestSerial = requestSerial + 1
                local serial = requestSerial
                local ok = pcall(NotifyInspect, unit)
                if ok then
                    lastRequestAt = Now()
                    pending = {
                        guid = request.guid,
                        unit = unit,
                        serial = serial,
                        requestedAt = lastRequestAt,
                    }
                    if C_Timer and C_Timer.NewTimer then
                        C_Timer.NewTimer(REQUEST_TIMEOUT, function()
                            if pending and pending.serial == serial then
                                local timedOutGUID = pending.guid
                                pending = nil
                                failedUntil[timedOutGUID] = Now() + FAILURE_RETRY_DELAY
                                if TooltipEnabled() and RefreshCurrentTooltip then
                                    RefreshCurrentTooltip(timedOutGUID)
                                end
                                ProcessQueue()
                            end
                        end)
                    end
                    return
                else
                    failedUntil[request.guid] = Now() + FAILURE_RETRY_DELAY
                end
            end
        end
    end
end

local function RequestItemLevel(guid, unit)
    local cached = GetCached(guid)
    if cached then return "cached", cached end

    local own = OwnItemLevel(guid)
    if own then return "cached", own end

    local retryAt = failedUntil[guid]
    if retryAt and Now() < retryAt then return nil end
    if retryAt then failedUntil[guid] = nil end

    if pending and pending.guid == guid then return "pending" end
    if queued[guid] then return "pending" end

    local can, resolved = CanRequest(unit, guid)
    if not can then return nil end
    if Enqueue(guid, resolved) then return "pending" end
    return nil
end

local function AddItemLevelLine(tooltip, itemLevel)
    local label = NS.L and NS.L("Niveau d'objet") or "Niveau d'objet"
    tooltip:AddLine(("|cff%s%s :|r |cffa78bfa%d|r"):format(
        NS.ACCENT or "a78bfa", label, itemLevel))
end

local function AddPendingLine(tooltip)
    local label = NS.L and NS.L("Niveau d'objet") or "Niveau d'objet"
    local status = NS.L and NS.L("inspection en cours…") or "inspection en cours…"
    tooltip:AddLine(("|cff%s%s :|r |cff888888%s|r"):format(
        NS.ACCENT or "a78bfa", label, status))
end

local function RenderUnitTooltip(tooltip, data)
    if tooltip ~= GameTooltip or not TooltipEnabled() then return end

    local unit = GetTooltipUnit(tooltip)
    local guid = data and data.guid
    if guid == nil and unit ~= nil and type(UnitGUID) == "function" then
        local ok, value = pcall(UnitGUID, unit)
        if ok then guid = value end
    end
    if not IsPlayerGUID(guid) then return end

    local state, itemLevel = RequestItemLevel(guid, unit)
    if state == "cached" and itemLevel then
        AddItemLevelLine(tooltip, itemLevel)
    elseif state == "pending" then
        AddPendingLine(tooltip)
    end
end

local function OnUnitTooltip(tooltip, data)
    -- Un tooltip ennemi peut contenir un GUID/token secret. La totalité du
    -- traitement est cloisonnée afin qu'une restriction Blizzard ne puisse
    -- jamais produire une erreur Lua ni contaminer le reste de l'addon.
    pcall(RenderUnitTooltip, tooltip, data)
end

RefreshCurrentTooltip = function(guid)
    pcall(function()
        if not GameTooltip or type(GameTooltip.IsShown) ~= "function"
            or not GameTooltip:IsShown() then return end
        local currentUnit = GetTooltipUnit(GameTooltip)
        -- Ne jamais remplacer une autre infobulle (objet, sort, autre joueur)
        -- si l'utilisateur a déplacé sa souris avant la réponse du serveur.
        if currentUnit == nil then return end
        if guid ~= nil and not SameGUID(currentUnit, guid) then return end
        if type(GameTooltip.SetUnit) == "function" then
            GameTooltip:SetUnit(currentUnit)
        end
    end)
end

-- ---------- Fiches de personnage Blizzard ----------
-- Cette partie ne lance aucune inspection supplémentaire : elle se branche sur
-- les mises à jour des fiches du joueur et de Blizzard_InspectUI. Les sockets
-- viennent des données structurées du tooltip ; l'enchantement est identifié
-- dans le itemString exact et son nom localisé n'est utilisé que pour l'affichage.
local INSPECT_SOCKET_LIMIT = 4
local INSPECT_SLOT_LAYOUT = {
    [INVSLOT_HEAD or 1] = "left",
    [INVSLOT_NECK or 2] = "left",
    [INVSLOT_SHOULDER or 3] = "left",
    [INVSLOT_BACK or 15] = "left",
    [INVSLOT_CHEST or 5] = "left",
    [INVSLOT_WRIST or 9] = "left",
    [INVSLOT_HAND or 10] = "right",
    [INVSLOT_WAIST or 6] = "right",
    [INVSLOT_LEGS or 7] = "right",
    [INVSLOT_FEET or 8] = "right",
    [INVSLOT_FINGER1 or 11] = "right",
    [INVSLOT_FINGER2 or 12] = "right",
    [INVSLOT_TRINKET1 or 13] = "right",
    [INVSLOT_TRINKET2 or 14] = "right",
    [INVSLOT_MAINHAND or 16] = "center",
    [INVSLOT_OFFHAND or 17] = "center",
}
local INSPECT_SLOT_BUTTONS = {
    "InspectHeadSlot", "InspectNeckSlot", "InspectShoulderSlot", "InspectBackSlot",
    "InspectChestSlot", "InspectWristSlot", "InspectHandsSlot", "InspectWaistSlot",
    "InspectLegsSlot", "InspectFeetSlot", "InspectFinger0Slot", "InspectFinger1Slot",
    "InspectTrinket0Slot", "InspectTrinket1Slot", "InspectMainHandSlot",
    "InspectSecondaryHandSlot",
}
local CHARACTER_SLOT_BUTTONS = {
    "CharacterHeadSlot", "CharacterNeckSlot", "CharacterShoulderSlot",
    "CharacterBackSlot", "CharacterChestSlot", "CharacterWristSlot",
    "CharacterHandsSlot", "CharacterWaistSlot", "CharacterLegsSlot",
    "CharacterFeetSlot", "CharacterFinger0Slot", "CharacterFinger1Slot",
    "CharacterTrinket0Slot", "CharacterTrinket1Slot", "CharacterMainHandSlot",
    "CharacterSecondaryHandSlot",
}
-- Les armes sont voisines en bas du modèle. Deux libellés de 94 px sur une
-- seule ligne se chevauchent forcément : la main droite (arme de gauche) garde
-- la ligne haute, la main gauche (arme de droite) la ligne juste en dessous.
local CENTER_ENCHANT_Y = {
    [INVSLOT_MAINHAND or 16] = 30,
    [INVSLOT_OFFHAND or 17] = 15,
}
-- Midnight fournit naturellement un socket aux bijoux. Si les données
-- structurées ne renvoient aucune ligne de socket pour l'un de ces emplacements,
-- garder un indicateur rouge permet de distinguer « pas de gemme » de « rien à
-- afficher ». Les sockets optionnels tête/poignets/ceinture ne sont pas exigés.
local REQUIRED_SOCKET_SLOTS_BY_EXPANSION = {
    [11] = {
        [INVSLOT_NECK or 2] = 1,
        [INVSLOT_FINGER1 or 11] = 1,
        [INVSLOT_FINGER2 or 12] = 1,
    },
}
local inspectDisplays = {}
local characterDisplays = {}
local inspectHooksInstalled = false
local characterHooksInstalled = false
local inspectRefreshQueued = false
local characterRefreshQueued = false
local inspectItemRequestUntil = {}

local function PlainInspectValue(value, expectedType)
    if type(issecretvalue) == "function" then
        local ok, secret = pcall(issecretvalue, value)
        if not ok or secret then return nil end
    end
    if expectedType and type(value) ~= expectedType then return nil end
    return value
end

local function InspectUnit()
    local frame = _G.InspectFrame
    local unit = frame and frame.unit
    if type(unit) ~= "string" then return nil end
    local ok, exists = pcall(UnitExists, unit)
    return ok and exists and unit or nil
end

local function DetailedItemLevel(itemLink)
    local fn = C_Item and C_Item.GetDetailedItemLevelInfo or _G.GetDetailedItemLevelInfo
    if type(fn) ~= "function" then return nil end
    local ok, value = pcall(fn, itemLink)
    if not ok then return nil end
    return NormalizeItemLevel(value)
end

local function ItemQualityHex(unit, slot)
    if type(GetInventoryItemQuality) ~= "function" then return "a78bfa" end
    local ok, quality = pcall(GetInventoryItemQuality, unit, slot)
    quality = ok and PlainInspectValue(quality, "number") or nil
    if not quality or type(GetItemQualityColor) ~= "function" then return "a78bfa" end
    local colorOK, _, _, _, hex = pcall(GetItemQualityColor, quality)
    if not colorOK or type(hex) ~= "string" then return "a78bfa" end
    return hex:gsub("^ff", "")
end

local function ParseInspectedItem(itemLink)
    local parser = NS.GearCompassItemParser
    if not parser or type(parser.Parse) ~= "function" then return nil end
    local ok, parsed = pcall(parser.Parse, itemLink)
    return ok and type(parsed) == "table" and parsed or nil
end

local function EnchantApplicability(equipLoc, slot)
    local scanner = NS.GearCompass
    if not scanner or type(scanner.ResolveEnchantApplicability) ~= "function" then return nil end
    local ok, applicable = pcall(scanner.ResolveEnchantApplicability, equipLoc, slot, true)
    return ok and type(applicable) == "boolean" and applicable or nil
end

-- Extrait la valeur d'un libellé Blizzard localisé de forme « préfixe %s
-- suffixe » sans transformer le texte en motif Lua. Cette lecture sert
-- uniquement à l'affichage ; aucune décision d'export GearCompass n'en dépend.
local function ExtractFormattedValue(text, formatString)
    text = PlainInspectValue(text, "string")
    formatString = PlainInspectValue(formatString, "string")
    if not text or not formatString then return nil end
    local markerStart, markerEnd = formatString:find("%s", 1, true)
    if not markerStart then return nil end
    local prefix = formatString:sub(1, markerStart - 1)
    local suffix = formatString:sub(markerEnd + 1)
    if text:sub(1, #prefix) ~= prefix then return nil end
    if suffix ~= "" and text:sub(-#suffix) ~= suffix then return nil end
    local valueEnd = suffix == "" and #text or (#text - #suffix)
    if valueEnd < #prefix then return nil end
    local value = text:sub(#prefix + 1, valueEnd)
    return value ~= "" and value or nil
end

local function CleanEnchantText(text)
    text = PlainInspectValue(text, "string")
    if not text then return nil end
    -- Retail peut entourer le nom avec l'ancien code |cAARRGGBB ou avec le
    -- nouveau jeton de couleur nommé |cnQUALITÉ:. On retire seulement
    -- l'enveloppe extérieure ; les atlas de qualité restent affichables.
    text = text:gsub("^|cn[^:]+:", "")
    text = text:gsub("^|c%x%x%x%x%x%x%x%x", "")
    text = text:gsub("|r$", "")
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    return text ~= "" and text or nil
end

local function StructuredInspectDetails(unit, slot)
    local textures = {}
    if not (C_TooltipInfo and type(C_TooltipInfo.GetInventoryItem) == "function") then
        return textures, nil, false
    end
    local ok, data = pcall(C_TooltipInfo.GetInventoryItem, unit, slot)
    if not ok or type(data) ~= "table" or type(data.lines) ~= "table" then
        return textures, nil, false
    end
    local socketLineType = Enum and Enum.TooltipDataLineType
        and Enum.TooltipDataLineType.GemSocket or 3
    local enchantText
    local enchantTemplate = PlainInspectValue(_G.ENCHANTED_TOOLTIP_LINE, "string")
    for _, line in ipairs(data.lines) do
        if type(line) == "table" then
            local lineType = PlainInspectValue(line.type, "number")
            local gemIcon = PlainInspectValue(line.gemIcon)
            local socketType = PlainInspectValue(line.socketType, "string")
            if lineType == socketLineType or gemIcon ~= nil or socketType ~= nil then
                textures[#textures + 1] = {
                    texture = gemIcon or ("Interface\\ItemSocketingFrame\\UI-EmptySocket-"
                        .. (socketType or "Prismatic")),
                    filled = gemIcon ~= nil,
                }
            end
            if not enchantText and enchantTemplate then
                enchantText = CleanEnchantText(ExtractFormattedValue(
                    PlainInspectValue(line.leftText, "string"), enchantTemplate))
            end
        end
    end
    return textures, enchantText, true
end

local function GemIcon(gemInfo, getItemIcon, getItemInfoInstant)
    if type(gemInfo) ~= "number" and type(gemInfo) ~= "string" then return nil end
    getItemIcon = getItemIcon or (C_Item and C_Item.GetItemIconByID)
    if type(getItemIcon) == "function" then
        local ok, icon = pcall(getItemIcon, gemInfo)
        icon = ok and PlainInspectValue(icon) or nil
        if icon then return icon end
    end
    getItemInfoInstant = getItemInfoInstant or _G.GetItemInfoInstant
    if type(getItemInfoInstant) == "function" then
        local ok, _, _, _, _, icon = pcall(getItemInfoInstant, gemInfo)
        if ok then return PlainInspectValue(icon) end
    end
    return nil
end

-- Source prioritaire pour l'etat rempli/vide : l'instance exacte. Sur certains
-- objets Midnight (notamment des anneaux craftes), le TooltipData expose bien
-- une ligne de socket mais laisse `gemIcon` vide alors que la gemme est presente.
-- GetItemGemID/GetItemGem lisent le lien complet et evitent ce faux rouge.
local function SocketedGemInfo(itemLink, index, parsedGemID, api)
    api = type(api) == "table" and api or {}
    local getGemID = api.getItemGemID or (C_Item and C_Item.GetItemGemID)
    local getGem = api.getItemGem or (C_Item and C_Item.GetItemGem)
    local gemID
    if type(getGemID) == "function" then
        local ok, value = pcall(getGemID, itemLink, index)
        gemID = ok and PlainInspectValue(value, "number") or nil
        if not gemID or gemID <= 0 or gemID ~= math.floor(gemID) then gemID = nil end
    end

    local gemName, gemLink
    if type(getGem) == "function" then
        local ok, name, link = pcall(getGem, itemLink, index)
        if ok then
            gemName = PlainInspectValue(name, "string")
            gemLink = PlainInspectValue(link, "string")
            if gemName == "" then gemName = nil end
            if gemLink == "" then gemLink = nil end
        end
    end
    gemID = gemID or PlainInspectValue(parsedGemID, "number")
    if not gemID or gemID <= 0 or gemID ~= math.floor(gemID) then gemID = nil end
    local filled = gemID ~= nil or gemName ~= nil or gemLink ~= nil
    if not filled then return nil, false end

    local icon = GemIcon(gemLink or gemID,
        api.getItemIcon, api.getItemInfoInstant)
    return {
        texture = icon or "Interface\\ItemSocketingFrame\\UI-EmptySocket-Prismatic",
        filled = true,
    }, true
end
HIL.ResolveSocketedGemInfo = SocketedGemInfo

local function RequiredSocketCount(unit, slot)
    if type(GetExpansionForLevel) ~= "function" or type(UnitLevel) ~= "function" then
        return 0
    end
    local ok, expansion = pcall(function()
        return GetExpansionForLevel(UnitLevel(unit))
    end)
    expansion = ok and PlainInspectValue(expansion, "number") or nil
    local slots = expansion and REQUIRED_SOCKET_SLOTS_BY_EXPANSION[expansion]
    return slots and slots[slot] or 0
end

local function SocketState(itemLink, parsed, textures, requiredCount, api)
    api = type(api) == "table" and api or {}
    textures = type(textures) == "table" and textures or {}
    local gemIDs = parsed and type(parsed.gem_ids) == "table" and parsed.gem_ids or {}
    local socketCount
    local getSocketCount = api.getItemNumSockets or (C_Item and C_Item.GetItemNumSockets)
    if type(getSocketCount) == "function" then
        local ok, value = pcall(getSocketCount, itemLink)
        socketCount = ok and PlainInspectValue(value, "number") or nil
    end
    socketCount = math.max(socketCount or 0, #textures, #gemIDs, requiredCount or 0)
    socketCount = math.min(socketCount, INSPECT_SOCKET_LIMIT)

    local filledCount, unresolvedRequired = 0, false
    for index = 1, socketCount do
        local exactSocket, exactFilled = SocketedGemInfo(
            itemLink, index, gemIDs[index], api)
        if exactFilled then
            -- L'API d'instance gagne toujours sur une fausse texture vide issue
            -- de TooltipData. C'est le cas visible sur les deux anneaux fournis.
            textures[index] = exactSocket
        elseif not textures[index] then
            textures[index] = {
                texture = "Interface\\ItemSocketingFrame\\UI-EmptySocket-Prismatic",
                filled = false,
            }
        end
        if textures[index].filled == true then
            filledCount = filledCount + 1
        elseif index <= (requiredCount or 0) then
            unresolvedRequired = true
        end
    end
    return textures, socketCount, filledCount, unresolvedRequired
end
HIL.ResolveSocketState = SocketState

local function AtEffectiveMaxLevel(unit)
    if type(UnitLevel) ~= "function" then return true end
    local ok, level = pcall(UnitLevel, unit)
    level = ok and PlainInspectValue(level, "number") or nil
    if not level or type(IsLevelAtEffectiveMaxLevel) ~= "function" then return true end
    local maxOK, isMax = pcall(IsLevelAtEffectiveMaxLevel, level)
    return not maxOK or isMax == true
end

local function RequestInspectItemData(itemLink)
    if not (C_Item and type(C_Item.RequestLoadItemDataByID) == "function") then return end
    local getter = C_Item.GetItemInfoInstant or _G.GetItemInfoInstant
    if type(getter) ~= "function" then return end
    local ok, itemID = pcall(getter, itemLink)
    itemID = ok and PlainInspectValue(itemID, "number") or nil
    if not itemID then return end
    local now = Now()
    if (inspectItemRequestUntil[itemID] or 0) > now then return end
    inspectItemRequestUntil[itemID] = now + 5
    pcall(C_Item.RequestLoadItemDataByID, itemID)
end

local function AnchorItemDisplay(button, display, layout)
    display:ClearAllPoints()
    display.ilvl:ClearAllPoints()
    display.enchant:ClearAllPoints()
    for _, texture in ipairs(display.sockets) do texture:ClearAllPoints() end

    if layout == "left" then
        display:SetPoint("TOPLEFT", button, "TOPRIGHT", 1, 0)
        display:SetPoint("BOTTOMLEFT", button, "BOTTOMRIGHT", 1, 0)
        display:SetWidth(96)
        display.ilvl:SetPoint("BOTTOMLEFT", display, "BOTTOMLEFT", 6, 3)
        display.enchant:SetPoint("TOPLEFT", display, "TOPLEFT", 6, -4)
        display.sockets[1]:SetPoint("LEFT", display.ilvl, "RIGHT", 4, 0)
        for index = 2, INSPECT_SOCKET_LIMIT do
            display.sockets[index]:SetPoint("LEFT", display.sockets[index - 1], "RIGHT", 1, 0)
        end
    elseif layout == "right" then
        display:SetPoint("TOPRIGHT", button, "TOPLEFT", -1, 0)
        display:SetPoint("BOTTOMRIGHT", button, "BOTTOMLEFT", -1, 0)
        display:SetWidth(96)
        display.ilvl:SetPoint("BOTTOMRIGHT", display, "BOTTOMRIGHT", -6, 3)
        display.enchant:SetPoint("TOPRIGHT", display, "TOPRIGHT", -6, -4)
        display.sockets[1]:SetPoint("RIGHT", display.ilvl, "LEFT", -4, 0)
        for index = 2, INSPECT_SOCKET_LIMIT do
            display.sockets[index]:SetPoint("RIGHT", display.sockets[index - 1], "LEFT", -1, 0)
        end
    else
        display:SetPoint("BOTTOM", button, "TOP", 0, 1)
        display:SetSize(126, 48)
        display.ilvl:SetPoint("BOTTOM", button, "TOP", 0, 1)
        local slot = type(button.GetID) == "function" and button:GetID() or nil
        display.enchant:SetWidth(126)
        display.enchant:SetPoint("BOTTOM", button, "TOP", 0,
            CENTER_ENCHANT_Y[slot] or 15)
        if type(display.enchant.SetJustifyH) == "function" then
            display.enchant:SetJustifyH("CENTER")
        end
        display.sockets[1]:SetPoint("LEFT", display.ilvl, "RIGHT", 4, 0)
        for index = 2, INSPECT_SOCKET_LIMIT do
            display.sockets[index]:SetPoint("LEFT", display.sockets[index - 1], "RIGHT", 1, 0)
        end
    end
end

local function GetItemDisplay(button, registry)
    local display = registry[button]
    if display then return display end
    display = CreateFrame("Frame", nil, button:GetParent())
    display:SetFrameLevel((button:GetFrameLevel() or 1) + 5)
    display:EnableMouse(false)
    display.ilvl = display:CreateFontString(nil, "OVERLAY", "GameFontHighlightOutline")
    display.enchant = display:CreateFontString(nil, "OVERLAY", "GameFontHighlightOutline")
    if type(display.enchant.SetWordWrap) == "function" then
        display.enchant:SetWordWrap(false)
    end
    if type(display.enchant.SetMaxLines) == "function" then
        display.enchant:SetMaxLines(1)
    end
    display.enchant:SetWidth(94)
    display.sockets = {}
    for index = 1, INSPECT_SOCKET_LIMIT do
        local texture = display:CreateTexture(nil, "OVERLAY")
        texture:SetSize(14, 14)
        display.sockets[index] = texture
    end
    AnchorItemDisplay(button, display, INSPECT_SLOT_LAYOUT[button:GetID()])
    registry[button] = display
    return display
end

local function GetInspectDisplay(button)
    return GetItemDisplay(button, inspectDisplays)
end

local function GetCharacterDisplay(button)
    return GetItemDisplay(button, characterDisplays)
end

local function HideInspectDisplays()
    local parent = _G.InspectPaperDollItemsFrame
    if parent and parent.AsylumInspectItemLevel then parent.AsylumInspectItemLevel:Hide() end
    for _, display in pairs(inspectDisplays) do display:Hide() end
end

local function HideCharacterDisplays()
    for _, display in pairs(characterDisplays) do display:Hide() end
end

local function UpdateItemSlot(button, unit, registry)
    if not InspectEnabled() or not button or not unit then return end
    local slot = type(button.GetID) == "function" and button:GetID() or nil
    local layout = slot and INSPECT_SLOT_LAYOUT[slot]
    if not layout then return end

    local ok, itemLink = pcall(GetInventoryItemLink, unit, slot)
    itemLink = ok and PlainInspectValue(itemLink, "string") or nil
    local display = registry == characterDisplays
        and GetCharacterDisplay(button) or GetInspectDisplay(button)
    if not itemLink then display:Hide(); return end

    local parsed = ParseInspectedItem(itemLink)
    local ilvl = DetailedItemLevel(itemLink)
    local socketTextures, enchantText, structuredReady = StructuredInspectDetails(unit, slot)
    if ilvl then
        display.ilvl:SetText(("|cff%s%d|r"):format(ItemQualityHex(unit, slot), ilvl))
    else
        display.ilvl:SetText("")
    end
    if not ilvl or not structuredReady then RequestInspectItemData(itemLink) end

    local equipLoc
    if type(GetItemInfoInstant) == "function" then
        local infoOK, _, _, _, value = pcall(GetItemInfoInstant, itemLink)
        if infoOK then equipLoc = PlainInspectValue(value, "string") end
    end
    local applicable = EnchantApplicability(equipLoc, slot)
    if enchantText then
        display.enchant:SetText("|cff39d98a" .. enchantText .. "|r")
    elseif applicable == true and parsed then
        if type(parsed.enchant_id) == "number" and parsed.enchant_id > 0 then
            display.enchant:SetText("|cff39d98a" .. NS.L("Enchanté") .. "|r")
        elseif AtEffectiveMaxLevel(unit) then
            -- Rouge franc et lisible : un enchantement manquant ne doit jamais
            -- ressembler à une information valide ou neutre.
            display.enchant:SetText("|cffff0000" .. NS.L("Sans enchant.") .. "|r")
        else
            display.enchant:SetText("")
        end
    else
        display.enchant:SetText("")
    end

    local socketDataIncomplete
    socketTextures, _, _, socketDataIncomplete = SocketState(
        itemLink, parsed, socketTextures, RequiredSocketCount(unit, slot))
    if socketDataIncomplete then RequestInspectItemData(itemLink) end
    for index, texture in ipairs(display.sockets) do
        local socket = socketTextures[index]
        if socket then
            texture:SetTexture(socket.texture)
            texture:SetVertexColor(socket.filled and 1 or 1, socket.filled and 1 or 0.25,
                socket.filled and 1 or 0.25, 1)
            texture:Show()
        else
            texture:Hide()
        end
    end
    display:Show()
end

local function UpdateInspectSlot(button, unit)
    return UpdateItemSlot(button, unit, inspectDisplays)
end

local function UpdateCharacterSlot(button)
    return UpdateItemSlot(button, "player", characterDisplays)
end

local function EnsureInspectAverageDisplay()
    local parent = _G.InspectPaperDollItemsFrame
    if not parent then return nil end
    if not parent.AsylumInspectItemLevel then
        local fontObject = _G.GameFontHighlightOutline22
            and "GameFontHighlightOutline22" or "GameFontNormalLarge"
        local label = parent:CreateFontString(nil, "OVERLAY", fontObject)
        parent.AsylumInspectItemLevel = label
    end
    local label = parent.AsylumInspectItemLevel
    label:ClearAllPoints()
    -- Disposition éprouvée par BetterCharacterPanel : grande valeur dans un
    -- bloc dédié du modèle, loin du nom, du niveau et de la classe Blizzard.
    label:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -20)
    label:SetPoint("BOTTOMLEFT", parent, "TOPRIGHT", -80, -67)
    if type(label.SetJustifyH) == "function" then label:SetJustifyH("CENTER") end
    if type(label.SetJustifyV) == "function" then label:SetJustifyV("MIDDLE") end
    return parent.AsylumInspectItemLevel
end

local function SetAverageItemLevel(label, itemLevel)
    if not label then return end
    label:SetText(itemLevel and ("|cffa78bfa" .. itemLevel .. "|r") or "")
    label:SetShown(itemLevel ~= nil)
end

local function RefreshInspectFrame(expectedGUID)
    if not InspectEnabled() or not InspectFrameIsOpen() then HideInspectDisplays(); return end
    local unit = InspectUnit()
    if not unit or (expectedGUID and not SameGUID(unit, expectedGUID)) then return end

    local label = EnsureInspectAverageDisplay()
    if label then
        local itemLevel
        if C_PaperDollInfo and type(C_PaperDollInfo.GetInspectItemLevel) == "function" then
            local ok, value = pcall(C_PaperDollInfo.GetInspectItemLevel, unit)
            if ok then itemLevel = NormalizeItemLevel(value) end
        end
        SetAverageItemLevel(label, itemLevel)
        if itemLevel and type(UnitGUID) == "function" then
            local ok, guid = pcall(UnitGUID, unit)
            if ok and IsPlayerGUID(guid) then Store(guid, itemLevel) end
        end
    end

    for _, name in ipairs(INSPECT_SLOT_BUTTONS) do
        local button = _G[name]
        if button then UpdateInspectSlot(button, unit) end
    end
end
HIL.RefreshInspectFrame = RefreshInspectFrame

local function CharacterFrameIsOpen()
    local frame = _G.CharacterFrame
    local head = _G.CharacterHeadSlot
    if not frame or not head or type(frame.IsShown) ~= "function"
        or type(head.IsShown) ~= "function" then
        return false
    end
    local frameOK, frameShown = pcall(frame.IsShown, frame)
    local headOK, headShown = pcall(head.IsShown, head)
    return frameOK and frameShown and headOK and headShown
end

local function RefreshCharacterFrame()
    if not InspectEnabled() or not CharacterFrameIsOpen() then
        HideCharacterDisplays()
        return
    end

    for _, name in ipairs(CHARACTER_SLOT_BUTTONS) do
        local button = _G[name]
        if button then UpdateCharacterSlot(button) end
    end
end
HIL.RefreshCharacterFrame = RefreshCharacterFrame

local function QueueInspectRefresh(expectedGUID)
    if inspectRefreshQueued then return end
    inspectRefreshQueued = true
    local function Run()
        inspectRefreshQueued = false
        pcall(RefreshInspectFrame, expectedGUID)
    end
    if C_Timer and C_Timer.After then C_Timer.After(0, Run) else Run() end
end

local function QueueCharacterRefresh()
    if characterRefreshQueued then return end
    characterRefreshQueued = true
    local function Run()
        characterRefreshQueued = false
        pcall(RefreshCharacterFrame)
    end
    if C_Timer and C_Timer.After then C_Timer.After(0, Run) else Run() end
end

local function SetupInspectUI()
    if inspectHooksInstalled or not _G.InspectFrame then return end
    if type(_G.InspectPaperDollItemSlotButton_Update) == "function" then
        hooksecurefunc("InspectPaperDollItemSlotButton_Update", function(button)
            if InspectEnabled() then pcall(UpdateInspectSlot, button, InspectUnit())
            elseif inspectDisplays[button] then inspectDisplays[button]:Hide() end
        end)
    end
    if type(_G.InspectPaperDollFrame_SetLevel) == "function" then
        hooksecurefunc("InspectPaperDollFrame_SetLevel", function()
            QueueInspectRefresh(nil)
        end)
    end
    _G.InspectFrame:HookScript("OnShow", function() QueueInspectRefresh(nil) end)
    _G.InspectFrame:HookScript("OnHide", HideInspectDisplays)
    inspectHooksInstalled = true
    QueueInspectRefresh(nil)
end

local function SetupCharacterUI()
    if characterHooksInstalled then return end
    if type(_G.PaperDollItemSlotButton_Update) == "function" then
        hooksecurefunc("PaperDollItemSlotButton_Update", function(button)
            if InspectEnabled() then pcall(UpdateCharacterSlot, button)
            elseif characterDisplays[button] then characterDisplays[button]:Hide() end
        end)
    end
    if _G.CharacterFrame and type(_G.CharacterFrame.HookScript) == "function" then
        _G.CharacterFrame:HookScript("OnShow", QueueCharacterRefresh)
        _G.CharacterFrame:HookScript("OnHide", HideCharacterDisplays)
    end
    characterHooksInstalled = true
    QueueCharacterRefresh()
end

function HIL.ApplySetting()
    if not TooltipEnabled() then ClearQueue() end
    if InspectEnabled() then
        SetupInspectUI()
        SetupCharacterUI()
        QueueInspectRefresh(nil)
        QueueCharacterRefresh()
    else
        HideInspectDisplays()
        HideCharacterDisplays()
    end
    RefreshCurrentTooltip(nil)
end

function HIL.ClearCache()
    for guid in pairs(cache) do cache[guid] = nil end
    for guid in pairs(failedUntil) do failedUntil[guid] = nil end
    for itemID in pairs(inspectItemRequestUntil) do inspectItemRequestUntil[itemID] = nil end
end

function HIL.SelfTest()
    local fake = {}
    fake["Player-1-TEST"] = { itemLevel = 681, expiresAt = 220 }
    local filledSockets, _, filledCount, filledIncomplete = SocketState(
        "|Hitem:193708|h[Test Ring]|h", { gem_ids = {} }, {
            { texture = "Interface\\ItemSocketingFrame\\UI-EmptySocket-Red", filled = false },
        }, 1, {
            getItemNumSockets = function() return 1 end,
            getItemGemID = function(_, index) return index == 1 and 240892 or nil end,
            getItemGem = function(_, index)
                if index == 1 then
                    return "Haste Mastery Gem", "|Hitem:240892|h[Haste Mastery Gem]|h"
                end
            end,
            getItemIcon = function(itemInfo)
                return itemInfo and 7549155 or nil
            end,
        })
    local fallbackSockets, _, fallbackCount = SocketState(
        "item:240949", { gem_ids = { 240983 } }, {}, 1, {
            getItemNumSockets = function() return 1 end,
            getItemGemID = function() return nil end,
            getItemGem = function() return nil end,
            getItemIcon = function() return 7549149 end,
        })
    local emptySockets, _, emptyCount, emptyIncomplete = SocketState(
        "item:193708", { gem_ids = {} }, {}, 1, {
            getItemNumSockets = function() return 1 end,
            getItemGemID = function() return nil end,
            getItemGem = function() return nil end,
        })
    return IsPlayerGUID("Player-1-TEST")
        and not IsPlayerGUID("Creature-1-TEST")
        and NormalizeItemLevel(681.49) == 681
        and NormalizeItemLevel(681.50) == 682
        and CacheRead(fake, "Player-1-TEST", 100) == 681
        and CacheRead(fake, "Player-1-TEST", 220) == nil
        and ExtractFormattedValue("Enchanté : Maîtrise", "Enchanté : %s") == "Maîtrise"
        and ExtractFormattedValue("[Hâte] bonus", "[%s] bonus") == "Hâte"
        and CleanEnchantText("|cnQUALITY4:Maîtrise|r") == "Maîtrise"
        and CENTER_ENCHANT_Y[INVSLOT_MAINHAND or 16]
            > CENTER_ENCHANT_Y[INVSLOT_OFFHAND or 17]
        and filledSockets[1].filled == true
        and filledSockets[1].texture == 7549155
        and filledCount == 1 and filledIncomplete == false
        and fallbackSockets[1].filled == true
        and fallbackSockets[1].texture == 7549149
        and fallbackCount == 1
        and emptySockets[1].filled == false
        and emptyCount == 0 and emptyIncomplete == true
end

local events = CreateFrame("Frame")
events:RegisterEvent("ADDON_LOADED")
events:RegisterEvent("INSPECT_READY")
events:RegisterEvent("ITEM_DATA_LOAD_RESULT")
events:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
events:RegisterEvent("SOCKET_INFO_UPDATE")
events:RegisterEvent("UNIT_INVENTORY_CHANGED")
events:SetScript("OnEvent", function(_, event, arg1, arg2)
    if event == "ADDON_LOADED" then
        if arg1 == ADDON_NAME then
            -- InitDB est exécuté par Core sur ce même événement. Reporter d'une
            -- frame garantit que les préférences migrées sont déjà disponibles,
            -- y compris si Blizzard_InspectUI était chargé avant notre addon.
            if C_Timer and C_Timer.After then
                C_Timer.After(0, HIL.ApplySetting)
            else
                HIL.ApplySetting()
            end
        elseif arg1 == "Blizzard_InspectUI" and InspectEnabled() then
            SetupInspectUI()
        end
        return
    end
    if event == "ITEM_DATA_LOAD_RESULT" then
        if type(arg1) == "number" then
            inspectItemRequestUntil[arg1] = Now() + (arg2 == false and 5 or 60)
        end
        if InspectEnabled() and InspectFrameIsOpen() then QueueInspectRefresh(nil) end
        if InspectEnabled() and CharacterFrameIsOpen() then QueueCharacterRefresh() end
        return
    end
    if event == "PLAYER_EQUIPMENT_CHANGED" then
        local ok, guid = pcall(UnitGUID, "player")
        if ok and guid then cache[guid] = nil end
        if InspectEnabled() then QueueCharacterRefresh() end
        return
    end
    if event == "SOCKET_INFO_UPDATE" then
        if InspectEnabled() then QueueCharacterRefresh() end
        return
    end
    if event == "UNIT_INVENTORY_CHANGED" then
        if arg1 == "player" and InspectEnabled() then QueueCharacterRefresh() end
        return
    end

    if InspectEnabled() and InspectFrameIsOpen() then
        pcall(RefreshInspectFrame, arg1)
        QueueInspectRefresh(arg1)
    end

    if not pending then return end
    local ok, matches = pcall(function() return arg1 == pending.guid end)
    if not ok or not matches then return end

    local request = pending
    pending = nil
    local unit = ResolveUnit(request.guid, request.unit)
    local stored
    if unit and C_PaperDollInfo and type(C_PaperDollInfo.GetInspectItemLevel) == "function" then
        local readOK, itemLevel = pcall(C_PaperDollInfo.GetInspectItemLevel, unit)
        if readOK then stored = Store(request.guid, itemLevel) end
    end

    -- Si l'utilisateur a ouvert l'inspection manuelle pendant notre attente,
    -- laisser Blizzard conserver sa cible au lieu de la fermer sous ses pieds.
    if not InspectFrameIsOpen() and type(ClearInspectPlayer) == "function" then
        pcall(ClearInspectPlayer)
    end
    if stored and TooltipEnabled() then RefreshCurrentTooltip(request.guid) end
    ProcessQueue()
end)

-- OnTooltipSetUnit n'est plus l'API Retail recommandée. Le post-callback
-- structuré est global, mais la fonction filtre explicitement GameTooltip.
if TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall
    and Enum and Enum.TooltipDataType and Enum.TooltipDataType.Unit then
    TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, OnUnitTooltip)
end

do
    local loaded
    if C_AddOns and type(C_AddOns.IsAddOnLoaded) == "function" then
        local ok, value = pcall(C_AddOns.IsAddOnLoaded, "Blizzard_InspectUI")
        loaded = ok and value
    elseif type(IsAddOnLoaded) == "function" then
        local ok, value = pcall(IsAddOnLoaded, "Blizzard_InspectUI")
        loaded = ok and value
    end
    if loaded and InspectEnabled() then SetupInspectUI() end
end
