-- TOG Tools — I'd Hit That (HUD)
-- The always-visible readout. Merges UpdateHUD into addon.modules["idhitthat"].
--
-- GOAL: look like part of the unit frame, not like a window parked next to it.
--
-- Skinned mode
-- ------------
-- Unit-frame addons build their little info bubbles (level, combo points,
-- creature type) from their OWN reusable templates, and chain each one off the
-- previous. We do the same thing: create our readouts with the host addon's
-- template and anchor off the last bubble it drew. The result is visually
-- indistinguishable from a native element, because it IS their artwork.
--
-- ZPerl is the worked example. From ZPerl_Target.xml:
--   levelFrame  27x22, inherits XPerlBackdropTemplate,XPerl_BorderStyleTemplate
--               backdropInfo = XPerl_Frame_Backdrop_32_16_3333
--   cpFrame     anchors TOPLEFT -> levelFrame TOPRIGHT at x = -4
-- So chaining another bubble to the right of level/combo-points is exactly the
-- idiom ZPerl already uses. We anchor off cpFrame when it's visible (rogues,
-- feral druids) and off levelFrame otherwise.
--
-- Every part of a skin is feature-checked and pcall-guarded. If the host addon
-- renames a frame or drops a template, we fall back to the plain look rather
-- than erroring — a skin is a nice-to-have, never a dependency.
--
-- Fallback mode
-- -------------
-- A compact strip of debuff-sized icons with their percentages, no chrome while
-- locked. Icons are the player's ACTUAL equipped weapons via
-- GetInventoryItemTexture, so each number is self-labelling: your sword's icon
-- carries your white-swing chance, your bow's icon carries your ranged chance.
-- Yellow (special) attacks use the class icon since they aren't tied to one
-- weapon; the spell entry reuses it tinted blue so the two are never confused.
--
-- Positioning
-- -----------
-- There is NO free-floating mode. The strip is a CHILD of whichever target
-- frame is actually on screen — a child inherits show/hide, scale and strata
-- for free, so it follows the host and vanishes with it on deselect without any
-- visibility mirroring — or it is not drawn at all.
--
-- When nothing resolves we show nothing and retry, rather than falling back to
-- a window. Unit-frame addons show their target frame slightly AFTER
-- PLAYER_TARGET_CHANGED fires, so the first pass on a fresh target legitimately
-- finds nothing; a brief delay before the bubbles appear is correct, a floating
-- window standing in for them is not.
--
-- There is NO OnUpdate here. Content is redrawn only when the engine says
-- something changed.

local _, addon = ...

local IdHitThat = addon.modules["idhitthat"]
if not IdHitThat then return end

local ICON_SIZE   = 20   -- matches Blizzard's debuff icon footprint
local TEXT_W      = 38   -- fits "100.0%" at GameFontNormalSmall
local GAP         = 3
local PAD         = 5
local UNIT_W      = ICON_SIZE + GAP + TEXT_W + PAD
local MAX_UNITS   = 5    -- yellow + white + off-hand + ranged + spell

-- Skinned bubbles copy the host's HEIGHT but must be wider than ZPerl's 27,
-- because they carry an icon AND a percentage rather than a bare number.
-- The width is derived from its parts instead of hand-tuned, so the text can
-- never spill past the border when a value hits three digits ("100%").
local BUBBLE_H     = 22   -- fallback only; the real height is taken from the host
local BUBBLE_ICON  = 14
local BUBBLE_PAD   = 4    -- inside the border, left and right
local BUBBLE_TGAP  = 2    -- icon to text
local BUBBLE_TEXT  = 30   -- fits "100%" at GameFontNormalSmall
local BUBBLE_W     = BUBBLE_PAD + BUBBLE_ICON + BUBBLE_TGAP + BUBBLE_TEXT + BUBBLE_PAD
-- Declared up here, not next to steps(), because the skin resolvers below close
-- over it — a function can't see a `local` declared after it.
local BUBBLE_GAP   = 2

-- Forward declaration for the same reason: the skin resolvers call this, and it
-- is defined further down.
local measureHostStep

-- Blizzard's own aura grid, from Classic Blizzard_UnitFrame/TargetFrame.lua:
--   LARGE_AURA_SIZE = 21, a debuff's bordered frame is size + 2, and rows are
--   separated by AURA_OFFSET_Y = 1.
-- Matching those makes our column read as part of the same grid the target's
-- debuffs already use, rather than as an arbitrary overlay.
local BLIZZ_AURA_SIZE     = 21
local BLIZZ_AURA_BORDER   = 2
local BLIZZ_AURA_OFFSET_Y = 1

local CLASS_ICON = "Interface\\TargetingFrame\\UI-Classes-Circles"
-- Spell reuses the class icon, so it gets a blue tint to tell it apart from
-- the untinted yellow-attack entry.
local SPELL_TINT = { 0.5, 0.7, 1.0 }

local hud, units, footer
local _lastText = {}

local function cfg()
    return addon.DB and addon.DB.global and addon.DB.global.idHitThat
end

-- Translate the "which side" setting into anchor points. Shared by every skin
-- that has no bubble of its own to chain off.
local function sidePoints(gap)
    local c = cfg()
    local side = (c and c.side) or "right"
    if side == "left" then
        return "TOPRIGHT", "TOPLEFT", -gap, 0
    elseif side == "below" then
        return "TOPLEFT", "BOTTOMLEFT", 0, -gap
    end
    return "TOPLEFT", "TOPRIGHT", gap, 0
end

-- ---------------------------------------------------------------------------
-- Skins
-- ---------------------------------------------------------------------------
-- Each skin resolves to: host frame to parent under, the bubble to chain off,
-- and the template/backdrop that makes our bubbles match theirs.
local SKINS = {
    {
        key   = "zperl",
        -- ZPerl splits across several addons; the target frame is the one we need.
        check = function() return _G.XPerl_Target end,
        resolve = function()
            local f = _G.XPerl_Target
            if not (f and f.IsShown and f:IsShown()) then return nil end
            if not f.levelFrame then return nil end
            -- Chain off combo points when they're up, else off the level bubble
            -- — the same precedence ZPerl's own layout uses.
            local anchor = f.levelFrame
            if f.cpFrame and f.cpFrame.IsShown and f.cpFrame:IsShown() then
                anchor = f.cpFrame
            end
            return {
                parent   = f,
                anchor   = anchor,
                -- TOPLEFT -> TOPRIGHT so our first row's top edge is flush with
                -- theirs; anything centre-anchored puts our rows half a bubble
                -- out of step with their column.
                point    = "TOPLEFT",
                relPoint = "TOPRIGHT",
                x        = BUBBLE_GAP,
                y        = 0,
                -- Take the bubble height AND the row spacing from THEIR frame
                -- rather than assuming 22 / +2. ZPerl's size is user-
                -- configurable, so hardcoded values drift out of alignment the
                -- moment anyone rescales it.
                unitH    = anchor:GetHeight(),
                unitStep = measureHostStep(anchor, f),
                template = "XPerlBackdropTemplate,XPerl_BorderStyleTemplate",
                backdrop = "XPerl_Frame_Backdrop_32_16_3333",
            }
        end,
    },
    {
        key   = "elvui",
        check = function() return _G.ElvUF_Target end,
        resolve = function()
            local f = _G.ElvUF_Target
            if not (f and f.IsShown and f:IsShown()) then return nil end

            -- Verified against tukui-org/ElvUI source (Modules/UnitFrames):
            -- frames spawn as 'ElvUF_'..StringTitle(unit), and the target frame
            -- carries Health / Power / Name / Portrait2D / Portrait3D / Buffs /
            -- Debuffs / Castbar. There is NO level element — ElvUI renders the
            -- level inside the Name tag — so unlike ZPerl there is no info
            -- bubble to chain off. Its AURA grid is the thing to align to.
            --
            -- Configure_Auras sets `auras.size` (icon size), `auras.spacing`,
            -- and `auras.height` when keepSizeRatio is off. Those are driven by
            -- the user's ElvUI profile, so reading them is the only way to
            -- match whatever size they've configured.
            local auras = f.Debuffs or f.Buffs
            local h = auras and (auras.height or auras.size)
            if not h or h <= 0 then h = BLIZZ_AURA_SIZE + BLIZZ_AURA_BORDER end
            local spacing = (auras and auras.spacing) or BUBBLE_GAP

            local point, relPoint, x, y = sidePoints(spacing)
            return {
                parent   = f,
                anchor   = f,
                point    = point,
                relPoint = relPoint,
                x        = x,
                y        = y,
                unitH    = h,
                unitStep = h + spacing,
                -- ElvUI skins its own widgets through its private API, which we
                -- have no business calling. Plain backing rather than a forged
                -- imitation of their border.
                plain    = true,
            }
        end,
    },
    {
        key   = "blizzard",
        check = function() return _G.TargetFrame end,
        resolve = function()
            local f = _G.TargetFrame
            if not (f and f.IsShown and f:IsShown()) then return nil end

            -- Blizzard has no reusable info-bubble artwork in Classic (the
            -- level is bare text on the frame art, not a box), so instead of
            -- borrowing a border we match the AURA grid: same height as a
            -- bordered debuff icon, same 1px row gap. That is the only real
            -- rhythm this frame has to align to.
            local h = BLIZZ_AURA_SIZE + BLIZZ_AURA_BORDER

            -- If the target actually has debuffs up, measure a real one rather
            -- than trusting the constants — a UI scale or a Blizzard change
            -- would move them and the live frame is authoritative.
            local sample = _G.TargetFrameDebuff1
            if sample and sample.IsShown and sample:IsShown() and sample:GetHeight() > 0 then
                h = sample:GetHeight()
            end

            local point, relPoint, x, y = sidePoints(BUBBLE_GAP)
            return {
                parent   = f,
                anchor   = f,
                point    = point,
                relPoint = relPoint,
                x        = x,
                y        = y,
                unitH    = h,
                unitStep = h + BLIZZ_AURA_OFFSET_Y,
                plain    = true,
            }
        end,
    },
}

-- Generic attach targets, used when no skin matches. Conventional globals for
-- each addon; a wrong or renamed one simply fails to resolve.
local TARGET_FRAME_CANDIDATES = {
    "ElvUF_Target", "XPerl_Target", "SUFUnittarget", "PitBull4_Frames_Target", "TargetFrame",
}

-- Reports WHY it landed where it did. Falling back is silent by design (a
-- missing skin must never be fatal), which also makes it undiagnosable from a
-- screenshot — so the reason goes to the debug channel.
local _lastReason
local function reason(why)
    if _lastReason ~= why then
        _lastReason = why
        addon:Debug("IdHitThat HUD: " .. why)
    end
end

-- Measure the host's own vertical rhythm instead of assuming one.
--
-- Guessing "their height + 2" is what kept our column drifting out of step with
-- theirs. Rather than pick a number, find the nearest sibling stacked directly
-- BELOW the anchor (shown, horizontally overlapping it, top edge lower) and use
-- the real top-to-top distance. That reproduces their spacing exactly, whatever
-- it happens to be, and tracks any rescale of the host frame.
--
-- Returns nil when nothing is stacked below the anchor, in which case the
-- caller falls back to height + BUBBLE_GAP.
function measureHostStep(anchor, parent)
    if not (anchor and parent and parent.GetChildren) then return nil end

    local aTop, aLeft, aRight = anchor:GetTop(), anchor:GetLeft(), anchor:GetRight()
    if not (aTop and aLeft and aRight) then return nil end

    local nearestTop
    for _, child in ipairs({ parent:GetChildren() }) do
        if child ~= anchor and child.IsShown and child:IsShown() then
            local t, l, r = child:GetTop(), child:GetLeft(), child:GetRight()
            -- Below the anchor, and sharing horizontal space with it — that's
            -- what makes it part of the same visual column.
            if t and l and r and t < aTop - 1 and r > aLeft and l < aRight then
                if not nearestTop or t > nearestTop then nearestTop = t end
            end
        end
    end

    if not nearestTop then return nil end
    local step = aTop - nearestTop
    -- Sanity clamp: a nonsense measurement is worse than the fallback.
    if step > 6 and step < 200 then return step end
    return nil
end

local function resolveSkin()
    if not cfg() then return nil end

    for _, skin in ipairs(SKINS) do
        local ok, res = pcall(function()
            if not skin.check() then return nil end
            return skin.resolve()
        end)
        if not ok then
            reason("skin '" .. skin.key .. "' errored: " .. tostring(res))
        elseif res then
            res.key = skin.key
            reason("using skin '" .. skin.key .. "'")
            return res
        end
    end

    reason("no skin matched (host frame missing or hidden) - plain attach or retry")
    return nil
end

local function resolveTargetFrame()
    for _, name in ipairs(TARGET_FRAME_CANDIDATES) do
        local f = _G[name]
        if f and f.IsShown and f:IsShown() and f.GetCenter and f:GetCenter() then
            return f
        end
    end
    return nil
end

-- Fallback "skin" for a host we have no bespoke support for — ElvUI, Shadowed,
-- Pitbull, or anything unknown. It still produces the same bubbles, parented
-- and top-aligned to the host frame, sized to Blizzard's aura grid (which is a
-- neutral, sane default) rather than to that addon's own artwork.
--
-- This is deliberately NOT a guess at those addons' internals. Writing a real
-- skin means reading the addon's source for its actual element names and
-- dimensions, the way the ZPerl skin was built. Until that's done for a given
-- host, this keeps it looking like a component instead of nothing.
local function genericSkin(frame)
    if not frame then return nil end

    local h = BLIZZ_AURA_SIZE + BLIZZ_AURA_BORDER
    -- Prefer the host's own rhythm when it happens to be measurable.
    local measured = measureHostStep(frame, frame:GetParent())
    local point, relPoint, x, y = sidePoints(BUBBLE_GAP)

    return {
        key      = "generic",
        parent   = frame,
        anchor   = frame,
        point    = point,
        relPoint = relPoint,
        x        = x,
        y        = y,
        unitH    = h,
        unitStep = measured or (h + BLIZZ_AURA_OFFSET_Y),
        plain    = true,
    }
end

-- ---------------------------------------------------------------------------
-- Construction
-- ---------------------------------------------------------------------------
-- Apply a host template's backdrop. Guarded because it reaches into another
-- addon's XML contract, which we do not control.
local function applyBackdrop(frame, backdropGlobal)
    if not backdropGlobal then return end
    pcall(function()
        local info = _G[backdropGlobal]
        if info and frame.OnBackdropLoaded then
            frame.backdropInfo = info
            frame:OnBackdropLoaded()
        end
    end)
end

-- Per-unit step, returning (stepX, stepY, unitW, unitH).
--
-- ZPerl chains cpFrame to levelFrame at x = -4, but that negative offset is
-- tuned to ITS bubble's border padding, not ours — copying it verbatim made
-- our bubbles visibly overlap. A small positive gap reproduces the separated
-- look of ZPerl's own level / combo-point boxes instead.
--
-- Height comes from the host frame (skin.unitH) so our rows are the same height
-- as theirs and the two columns line up row for row.
local function steps(skin)
    if skin then
        local h = skin.unitH or BUBBLE_H
        -- Measured top-to-top distance from the host's own column when we could
        -- find one; only fall back to a guess when nothing is stacked below.
        local stepY = skin.unitStep or (h + BUBBLE_GAP)
        return BUBBLE_W + BUBBLE_GAP, stepY, BUBBLE_W, h
    end
    return UNIT_W, ICON_SIZE + 2, ICON_SIZE + GAP + TEXT_W, ICON_SIZE
end

-- Tooltip placement is deliberately NOT addon.UI.AttachTooltip: that helper
-- routes through addon.Tooltip.Owner, whose flip threshold is tuned for the
-- main window and would happily cover the strip. We anchor off the whole strip
-- (not the hovered bubble) so no bubble is ever obscured no matter which one
-- the mouse is on, and flip to the other side near the screen edge.
local function showTip(u)
    if not u.ttTitle or not hud then return end

    GameTooltip:SetOwner(u, "ANCHOR_NONE")
    GameTooltip:ClearAllPoints()

    local right = hud:GetRight()
    if right and right > GetScreenWidth() * 0.7 then
        GameTooltip:SetPoint("TOPRIGHT", hud, "TOPLEFT", -8, 0)
    else
        GameTooltip:SetPoint("TOPLEFT", hud, "TOPRIGHT", 8, 0)
    end

    GameTooltip:SetText(u.ttTitle, 1, 1, 1)
    if u.ttBody then GameTooltip:AddLine(u.ttBody, nil, nil, nil, true) end
    if u.ttFoot then GameTooltip:AddLine(u.ttFoot, 1, 0.82, 0, true) end
    GameTooltip:Show()
end

local function makeUnit(parent, index, skin, vertical)
    local u
    if skin then
        -- Built from the HOST's template, so it inherits their border art.
        local ok, made = pcall(CreateFrame, "Frame", nil, parent, skin.template)
        u = ok and made or CreateFrame("Frame", nil, parent)
        applyBackdrop(u, skin.backdrop)
        -- Hosts with no borrowable bubble art (Blizzard) get a plain dark
        -- backing so the numbers stay legible over frame textures.
        if skin.plain then
            local back = u:CreateTexture(nil, "BACKGROUND")
            back:SetAllPoints(u)
            back:SetColorTexture(0, 0, 0, 0.55)
        end
    else
        u = CreateFrame("Frame", nil, parent)
    end

    local stepX, stepY, unitW, unitH = steps(skin)
    u:SetWidth(unitW)
    u:SetHeight(unitH)
    if vertical then
        u:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -(index - 1) * stepY)
    else
        u:SetPoint("TOPLEFT", parent, "TOPLEFT", (index - 1) * stepX, 0)
    end

    -- Icon scales with the bubble so it stays inset by the same margin no
    -- matter what height we inherited from the host frame.
    local iconSize = ICON_SIZE
    if skin then
        iconSize = math.max(unitH - (BUBBLE_PAD * 2), 8)
    end
    u.icon = u:CreateTexture(nil, "ARTWORK")
    u.icon:SetWidth(iconSize)
    u.icon:SetHeight(iconSize)
    u.icon:SetPoint("LEFT", u, "LEFT", skin and BUBBLE_PAD or 0, 0)
    -- Trim the default icon border so it sits flush.
    u.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

    u.text = u:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    u.text:SetPoint("LEFT", u.icon, "RIGHT", skin and BUBBLE_TGAP or GAP, 0)
    u.text:SetJustifyH("LEFT")
    -- Always give the number an explicit width. Without it the font string
    -- sizes to its content and runs past the bubble border at "100%".
    u.text:SetWidth(skin and BUBBLE_TEXT or TEXT_W)
    if u.text.SetWordWrap then u.text:SetWordWrap(false) end

    -- Mouse is enabled for the tooltips. These bubbles sit beside the unit
    -- frame rather than on top of it, so the clicks they absorb are ones that
    -- would have hit empty space anyway.
    u:EnableMouse(true)
    u:SetScript("OnEnter", showTip)
    u:SetScript("OnLeave", function() GameTooltip:Hide() end)
    return u
end

-- The strip is rebuilt when the skin changes (host addon loaded late, or the
-- user toggled attach mode). Frames can't be destroyed in WoW, so the old one
-- is orphaned to UIParent and hidden — this happens at most a couple of times
-- per session, never in a hot path.
local function buildHUD(skin, vertical)
    -- Layout is part of the key because unit anchors are set at construction
    -- time, so switching column/row has to rebuild.
    local skinKey = "skin:" .. skin.key .. (vertical and "|v" or "|h")
    if hud and hud._skinKey == skinKey then return end

    if hud then
        hud:Hide()
        hud:SetParent(UIParent)
        hud:ClearAllPoints()
    end

    hud = CreateFrame("Frame", nil, skin.parent)
    hud._skinKey = skinKey
    local stepX, stepY, unitW, unitH = steps(skin)
    if vertical then
        hud:SetWidth(unitW)
        hud:SetHeight(unitH + (MAX_UNITS - 1) * stepY)
    else
        hud:SetWidth(unitW + (MAX_UNITS - 1) * stepX)
        hud:SetHeight(unitH)
    end
    -- Never movable, never clamped, no background. There is no free-floating
    -- mode: this thing is always a child of a host unit frame or it does not
    -- exist. A draggable window was never what this is for.
    units = {}
    for i = 1, MAX_UNITS do units[i] = makeUnit(hud, i, skin, vertical) end
    _lastText = {}

    -- Below the whole strip regardless of layout, so a vertical stack doesn't
    -- draw its footer through the middle of the bubbles.
    footer = hud:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    footer:SetPoint("TOPLEFT", hud, "BOTTOMLEFT", 0, -2)
    footer:SetJustifyH("LEFT")

    hud:Hide()
end

-- Retry while the host frame catches up. Bounded so a client with no target
-- frame at all can't spin forever; the counter resets the moment we succeed or
-- the target changes.
local RETRY_DELAY = 0.05
local RETRY_MAX   = 40      -- ~2 seconds
local _retries, _retryPending = 0, false

local function scheduleRetry()
    if _retryPending or _retries >= RETRY_MAX then return end
    _retryPending = true
    _retries = _retries + 1
    C_Timer.After(RETRY_DELAY, function()
        _retryPending = false
        IdHitThat:UpdateHUD()
    end)
end

-- The HUD is always a CHILD of the host frame (set at build time), so it
-- inherits show/hide, scale and strata for free — no visibility mirroring
-- needed. Our frame is never protected and we never call anything on theirs,
-- so nothing here can taint a secure unit frame.
local function applyAnchor(skin)
    if not (hud and skin) then return end

    hud:ClearAllPoints()
    hud:SetPoint(skin.point, skin.anchor, skin.relPoint, skin.x, skin.y)

    -- The anchor may be a FontString rather than a Frame, which has no
    -- GetFrameLevel — take the level from the parent frame in that case.
    local base = (skin.anchor.GetFrameLevel and skin.anchor:GetFrameLevel())
        or (skin.parent.GetFrameLevel and skin.parent:GetFrameLevel())
    if base then hud:SetFrameLevel(base + 1) end
end

-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------
-- Green at cap, amber close, red when meaningfully short. Colouring on the
-- shortfall rather than the raw number is what makes it readable at a glance.
local function colorFor(shortfall)
    if shortfall <= 0 then return "ff40ff40" end
    if shortfall <= 2 then return "ffffd100" end
    return "ffff5040"
end

local _classIconCoords
local function classIcon(tex)
    if not _classIconCoords then
        local _, class = UnitClass("player")
        _classIconCoords = (CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[class]) or nil
    end
    tex:SetTexture(CLASS_ICON)
    if _classIconCoords then
        tex:SetTexCoord(unpack(_classIconCoords))
    else
        tex:SetTexCoord(0, 1, 0, 1)
    end
end

-- `slot` nil means use the class icon (yellow / spell); `tint` distinguishes
-- the two, since both fall back to the same class artwork.
local function setUnit(i, slot, pct, shortfall, tint, title, body)
    local u = units[i]
    if not u then return end

    u.ttTitle = title
    u.ttBody  = body
    u.ttFoot  = (shortfall > 0)
        and string.format("Need +%.1f%% more hit to cap against this target.", shortfall)
        or "At the hit cap for this target."

    if slot then
        local tex = GetInventoryItemTexture("player", slot)
        if tex then
            u.icon:SetTexture(tex)
            u.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
        else
            classIcon(u.icon)
        end
    else
        classIcon(u.icon)
    end

    u.icon:SetVertexColor(tint and tint[1] or 1, tint and tint[2] or 1, tint and tint[3] or 1)

    local str = string.format("|c%s%.0f%%|r", colorFor(shortfall), pct)
    -- Skipping identical SetText avoids font-string churn on every target
    -- swap, which is the only per-target cost worth caring about here.
    if _lastText[i] ~= str then
        _lastText[i] = str
        u.text:SetText(str)
    end

    u:Show()
end

function IdHitThat:UpdateHUD()
    local c = cfg()
    if not c or not c.enabled then
        if hud then hud:Hide() end
        return
    end

    local r = self:GetResult()
    if not r.valid then
        if hud then hud:Hide() end
        return
    end

    -- A bespoke skin if we have one for this host, else the generic bubbles on
    -- whatever target frame is on screen.
    local skin = resolveSkin() or genericSkin(resolveTargetFrame())

    -- No host frame yet. Unit-frame addons show their target frame slightly
    -- AFTER PLAYER_TARGET_CHANGED fires, so on a fresh target our first pass
    -- can genuinely find nothing shown. Show nothing and try again shortly —
    -- a brief delay before the bubbles appear is correct; a floating window
    -- standing in for them is not.
    if not skin then
        if hud then hud:Hide() end
        scheduleRetry()
        return
    end
    _retries = 0

    local vertical = (c.layout or "vertical") == "vertical"
    buildHUD(skin, vertical)
    applyAnchor(skin)

    local i = 1
    setUnit(i, nil, r.special, r.capShort, nil,
        "Yellow attacks",
        "Your chance to land a special attack \226\128\148 anything you press a button for, "
        .. "like Sinister Strike or Mortal Strike. Dual wielding does not penalise these.")
    i = i + 1

    if c.showWhite then
        setUnit(i, 16, r.whiteMain, r.capShort, nil,
            "White attacks (main hand)",
            "Your chance to land an ordinary auto-attack swing with your main-hand weapon.")
        i = i + 1
        if r.whiteOff > 0 then
            setUnit(i, 17, r.whiteOff, r.capShort, nil,
                "White attacks (off hand)",
                "Your chance to land an auto-attack swing with your off-hand weapon. "
                .. "Dual wielding adds a flat miss penalty to white swings only.")
            i = i + 1
        end
    end

    -- Only drawn when a real ranged weapon is equipped, so melee classes never
    -- see a dead icon and relic-slot classes never see a bogus one.
    if c.showRanged and r.ranged > 0 then
        setUnit(i, 18, r.ranged, r.rangedCapShort, nil,
            "Ranged shots",
            "Your chance to land a shot with your bow, gun, crossbow, thrown weapon or wand. "
            .. "Ranged attacks never glance, so anything that isn't a miss lands in full.")
        i = i + 1
    end

    if c.showSpell then
        setUnit(i, nil, r.spell, 0, SPELL_TINT,
            "Spells",
            "Your chance to land a damaging spell on this target. Spell hit is separate from "
            .. "melee and ranged hit and is not affected by weapon skill.")
        i = i + 1
    end

    for blank = i, MAX_UNITS do
        units[blank]:Hide()
        _lastText[blank] = nil
    end

    -- Footer: whichever shortfall the player can actually act on. A hunter
    -- capped on melee but short on ranged needs to be told about ranged.
    -- Suppressed in skinned mode, where loose text under the frame would break
    -- the illusion the skin exists to create.
    local footShort, footWhat = r.capShort, "hit"
    if c.showRanged and r.ranged > 0 and r.rangedCapShort > footShort then
        footShort, footWhat = r.rangedCapShort, "ranged hit"
    end

    if skin then
        footer:Hide()
    elseif footShort > 0 then
        footer:SetText(string.format("|cffaaaaaaneed +%.1f%% %s|r", footShort, footWhat))
        footer:Show()
    elseif r.glancing > 0 then
        footer:SetText(string.format("|cffaaaaaa%.0f%% glancing|r", r.glancing))
        footer:Show()
    else
        footer:Hide()
    end

    -- Shrink to the rows actually drawn, so the tooltip anchors off the real
    -- edge of the strip rather than off empty reserved space.
    local n = i - 1
    local stepX, stepY, unitW, unitH = steps(skin)
    if vertical then
        hud:SetWidth(unitW)
        hud:SetHeight(unitH + (n - 1) * stepY)
    else
        hud:SetWidth(unitW + (n - 1) * stepX)
        hud:SetHeight(unitH)
    end
    hud:Show()
end

-- Called from the engine on every event-driven refresh, so a new target gets a
-- fresh retry budget rather than inheriting an exhausted one.
function IdHitThat:ResetHUDRetries()
    _retries = 0
end

-- Re-resolve after everything has loaded. Unit-frame addons build their frames
-- well after our OnEnable runs, so resolving at init would always miss them and
-- silently fall back to the plain look.
local loader = CreateFrame("Frame")
loader:RegisterEvent("PLAYER_ENTERING_WORLD")
loader:SetScript("OnEvent", function()
    if IdHitThat.UpdateHUD then IdHitThat:UpdateHUD() end
end)
