---@type Orbit
local Orbit = Orbit
local OrbitEngine = Orbit.Engine
local Constants = Orbit.Constants
local CooldownUtils = OrbitEngine.CooldownUtils
local DragDrop = Orbit.CooldownDragDrop
local GC = OrbitEngine.GlowController
local ACTIVE_GLOW_KEY = "orbitActive"

-- [ CONSTANTS ] -------------------------------------------------------------------------------------
local ESSENTIAL_INDEX = Constants.Cooldown.SystemIndex.Essential
local UTILITY_INDEX = Constants.Cooldown.SystemIndex.Utility
local COOLDOWN_THROTTLE = 0.1
local PLACEHOLDER_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
local BOUNDS_PADDING = 30
local HIT_RECT_INSET = -40
local GLOW_OUTSET = 8
local DROP_ZONE_TICK_RATE = 0.05
local GLOW_COLOR_R, GLOW_COLOR_G, GLOW_COLOR_B = 0, 1, 0
local PREVIEW_ALPHA = 0.5
local CURSOR_POLL_INTERVAL = 0.1

-- [ MODULE ] ----------------------------------------------------------------------------------------
Orbit.ViewerInjection = {}
local Injection = Orbit.ViewerInjection

-- [ PLUGIN REFERENCE ] ------------------------------------------------------------------------------
local Plugin = Orbit:GetPlugin("Orbit_CooldownViewer")
if not Plugin then
    error("ViewerInjection.lua: Plugin 'Orbit_CooldownViewer' not found - ensure CooldownManager.lua loads first")
end

-- [ STATE ] -----------------------------------------------------------------------------------------
Plugin.injectedFrames = Plugin.injectedFrames or {}
Plugin.injectedRecyclePools = Plugin.injectedRecyclePools or {}

-- [ DESAT CURVE ] -----------------------------------------------------------------------------------
local DESAT_CURVE = C_CurveUtil.CreateCurve()
DESAT_CURVE:AddPoint(0.0, 0)
DESAT_CURVE:AddPoint(0.001, 1)
DESAT_CURVE:AddPoint(1.0, 1)

-- [ FRAME CREATION ] --------------------------------------------------------------------------------
local function CreateInjectedIcon(parent, systemIndex)
    local icon = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    OrbitEngine.Pixel:Enforce(icon)
    icon:SetSize(40, 40)
    icon.systemIndex = systemIndex
    icon.isInjectedIcon = true
    icon.trackedType = nil
    icon.trackedId = nil

    icon.Icon = icon:CreateTexture(nil, "ARTWORK")
    icon.Icon:SetAllPoints()

    icon.Cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
    icon.Cooldown:SetAllPoints()
    icon.Cooldown:SetDrawSwipe(true)
    icon.Cooldown:SetDrawBling(false)
    icon.Cooldown:Clear()

    icon.ActiveCooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
    icon.ActiveCooldown:SetAllPoints()
    icon.ActiveCooldown:SetDrawSwipe(true)
    icon.ActiveCooldown:SetDrawBling(false)
    icon.ActiveCooldown:SetReverse(true)
    icon.ActiveCooldown:Clear()

    local textOverlay = CreateFrame("Frame", nil, icon)
    textOverlay:SetAllPoints()
    textOverlay:SetFrameLevel(icon:GetFrameLevel() + Constants.Levels.IconOverlay)
    icon.TextOverlay = textOverlay

    local chargeCount = CreateFrame("Frame", nil, textOverlay)
    chargeCount:SetAllPoints(icon)
    chargeCount:SetFrameLevel(icon:GetFrameLevel() + Constants.Levels.IconOverlay)
    local chargeText = chargeCount:CreateFontString(nil, "OVERLAY", "GameFontHighlight", 7)
    chargeText:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -2, 2)
    Orbit.Skin:SetFontWithShadow(chargeText, STANDARD_TEXT_FONT, 12)
    chargeCount.Current = chargeText
    icon.ChargeCount = chargeCount
    chargeCount:Hide()

    icon:EnableMouse(false)
    icon:RegisterForDrag("LeftButton")
    icon:SetScript("OnReceiveDrag", function(self) Injection:OnIconReceiveDrag(self) end)
    icon:SetScript("OnMouseDown", function(self, button)
        if button == "RightButton" and IsShiftKeyDown() then
            Injection:OpenGlowMenu(self)
        elseif GetCursorInfo() then
            Injection:OnIconReceiveDrag(self)
        end
    end)
    icon:Hide()
    return icon
end

-- [ ICON UPDATE ] -----------------------------------------------------------------------------------
-- GetItemCooldown returns are conditionally secret under M+/raid restrictions — issecretvalue BEFORE branching; secret path goes straight into the Cooldown C-sink.
local function ApplyItemCooldownAndCount(icon)
    local start, duration = C_Container.GetItemCooldown(icon.trackedId)
    if issecretvalue(start) or issecretvalue(duration) then
        icon.Cooldown:SetCooldown(start, duration)
        icon.ActiveCooldown:Clear()
        icon.Icon:SetDesaturation(1)
    else
        local threshold = icon.useSpellId and 1.5 or 0          -- useSpellId filters GCD-only cooldown
        if start and duration and duration > threshold then
            if icon.activeDuration and duration > icon.activeDuration
               and (GetTime() - start) < icon.activeDuration then
                icon.Cooldown:Clear()
                icon.ActiveCooldown:SetCooldown(start, icon.activeDuration)
                icon.Icon:SetDesaturation(0)
            else
                icon.Cooldown:SetCooldown(start, duration)
                icon.ActiveCooldown:Clear()
                icon.Icon:SetDesaturation(1)
            end
        elseif icon.useSpellId then
            local durObj = C_Spell.GetSpellCooldownDuration(icon.useSpellId)
            if durObj then
                icon.Cooldown:SetCooldownFromDurationObject(durObj, true)
                icon.Icon:SetDesaturation(durObj:EvaluateRemainingPercent(DESAT_CURVE))
            else
                icon.Cooldown:Clear()
                icon.Icon:SetDesaturation(0)
            end
            icon.ActiveCooldown:Clear()
        else
            icon.Cooldown:Clear()
            icon.ActiveCooldown:Clear()
            icon.Icon:SetDesaturation(0)
        end
    end
    local count = C_Item.GetItemCount(icon.trackedId, false, true)
    if count and count > 1 then icon.ChargeCount.Current:SetText(count); icon.ChargeCount:Show()
    else icon.ChargeCount:Hide() end
end

function Injection:UpdateIcon(icon)
    if not icon.trackedId then icon:Hide(); return end

    local texture
    if icon.trackedType == "spell" then
        local activeId = Orbit.CooldownData:GetActiveSpellID(icon.trackedId)
        if not IsSpellKnown(icon.trackedId) and not IsPlayerSpell(icon.trackedId) then
            if activeId == icon.trackedId or (not IsSpellKnown(activeId) and not IsPlayerSpell(activeId)) then
                icon:Hide(); return
            end
        end
        texture = C_Spell.GetSpellTexture(activeId)
        if texture then
            icon.Icon:SetTexture(texture)
            local cdInfo = C_Spell.GetSpellCooldown(activeId)
            local onGCD = cdInfo and cdInfo.isOnGCD
            local showGCDSwipe = Plugin:GetSetting(icon.systemIndex, "ShowGCDSwipe") ~= false
            -- ignoreGCD excludes the GCD contribution from durObj; ignored on older clients.
            local durObj = C_Spell.GetSpellCooldownDuration(activeId, not showGCDSwipe)
            if onGCD and not showGCDSwipe then
                icon.Cooldown:Clear()
                icon.ActiveCooldown:Clear()
                icon.Icon:SetDesaturation(0)
            elseif durObj then
                if icon.activeDuration and icon._activeGlowExpiry and not onGCD and GetTime() < icon._activeGlowExpiry then
                    local castTime = icon._activeGlowExpiry - icon.activeDuration
                    icon.Cooldown:Clear()
                    icon.ActiveCooldown:SetCooldown(castTime, icon.activeDuration)
                    icon.Icon:SetDesaturation(0)
                else
                    icon.Cooldown:SetCooldownFromDurationObject(durObj, true)
                    icon.ActiveCooldown:Clear()
                    icon.Icon:SetDesaturation(onGCD and 0 or durObj:EvaluateRemainingPercent(DESAT_CURVE))
                    if icon._activeGlowExpiry then icon._activeGlowExpiry = nil end
                end
            else
                icon.Cooldown:Clear()
                icon.ActiveCooldown:Clear()
                icon.Icon:SetDesaturation(0)
            end
            -- Pass currentCharges directly to SetText — boolean-testing it would throw on the secret-in-combat path.
            local chargeInfo = C_Spell.GetSpellCharges and C_Spell.GetSpellCharges(activeId)
            if chargeInfo then
                icon.ChargeCount.Current:SetText(chargeInfo.currentCharges)
                icon.ChargeCount:Show()
            else
                local displayCount = C_Spell.GetSpellDisplayCount(activeId)
                if displayCount then icon.ChargeCount.Current:SetText(displayCount); icon.ChargeCount:Show()
                else icon.ChargeCount:Hide() end
            end
        end
    elseif icon.trackedType == "item" then
        local usable, noMana = C_Item.IsUsableItem(icon.trackedId)
        local isUsable = usable or noMana or C_Item.GetItemCount(icon.trackedId, false, true) > 0
        texture = C_Item.GetItemIconByID(icon.trackedId)
        if texture then
            icon.Icon:SetTexture(texture)
            if not isUsable then
                icon.Cooldown:Clear()
                icon.Icon:SetDesaturation(1)
                icon.ChargeCount.Current:SetText("0"); icon.ChargeCount:Show()
            else
                ApplyItemCooldownAndCount(icon)
            end
        end
    end

    if not texture then
        icon.Icon:SetTexture(PLACEHOLDER_ICON)
        icon.Icon:SetDesaturation(1)
        icon.Cooldown:Clear()
        icon.ChargeCount:Hide()
    end
    icon:Show()
end

-- [ DATA MANAGEMENT ] -------------------------------------------------------------------------------
function Injection:GetInjectedItems(systemIndex)
    return Plugin:GetSpecData(systemIndex, "InjectedItems") or {}
end

function Injection:SetInjectedItems(systemIndex, items)
    Plugin:SetSpecData(systemIndex, "InjectedItems", items)
end

function Injection:AddItem(systemIndex, itemType, itemId, afterNativeIndex, arrayIndex)
    local items = self:GetInjectedItems(systemIndex)
    local slotId = (itemType == "item") and DragDrop:ResolveEquipmentSlot(itemId) or nil
    for _, entry in ipairs(items) do
        if slotId and entry.slotId == slotId then return end
        if not slotId and entry.type == itemType and entry.id == itemId then return end
    end
    local entry = DragDrop:BuildInjectedItemEntry(itemType, itemId, afterNativeIndex)
    if not entry then return end
    if arrayIndex and arrayIndex <= #items then
        table.insert(items, arrayIndex, entry)
    else
        items[#items + 1] = entry
    end
    self:SetInjectedItems(systemIndex, items)
    self:RefreshFrames(systemIndex)
end

function Injection:RemoveItemAtIndex(systemIndex, removeIdx)
    local items = self:GetInjectedItems(systemIndex)
    table.remove(items, removeIdx)
    self:SetInjectedItems(systemIndex, items)
    self:RefreshFrames(systemIndex)
end

-- [ FRAME MANAGEMENT ] ------------------------------------------------------------------------------
function Injection:GetRecyclePool(systemIndex)
    if not Plugin.injectedRecyclePools[systemIndex] then Plugin.injectedRecyclePools[systemIndex] = {} end
    return Plugin.injectedRecyclePools[systemIndex]
end

function Injection:AcquireFrame(systemIndex, parent)
    if InCombatLockdown() then return nil end
    local pool = self:GetRecyclePool(systemIndex)
    local frame = table.remove(pool)
    if frame then
        frame:SetParent(parent)
        frame:ClearAllPoints()
        frame.Cooldown:Clear()
        frame.ActiveCooldown:Clear()
        frame.ChargeCount:Hide()
        return frame
    end
    return CreateInjectedIcon(parent, systemIndex)
end

function Injection:ReleaseFrame(systemIndex, frame)
    frame:Hide()
    frame:ClearAllPoints()
    frame.trackedType = nil
    frame.trackedId = nil
    frame.useSpellId = nil
    frame._glowEntry = nil
    frame._activeGlowExpiry = nil
    GC:Hide(frame, ACTIVE_GLOW_KEY)
    Orbit.SpellGlows:UnregisterProc(frame)
    Orbit.IconCastState:Untrack(frame)
    frame.Cooldown:Clear()
    frame.ActiveCooldown:Clear()
    frame.ChargeCount:Hide()
    local pool = self:GetRecyclePool(systemIndex)
    pool[#pool + 1] = frame
end

function Injection:RefreshFrames(systemIndex)
    if InCombatLockdown() then return end
    local items = self:GetInjectedItems(systemIndex)
    local active = Plugin.injectedFrames[systemIndex] or {}
    -- Release excess frames
    for i = #items + 1, #active do
        self:ReleaseFrame(systemIndex, active[i])
        active[i] = nil
    end
    local viewerMap = Plugin.viewerMap
    local entry = viewerMap[systemIndex]
    local parent = entry and entry.anchor or UIParent
    for i, data in ipairs(items) do
        local frame = active[i]
        if not frame then
            frame = self:AcquireFrame(systemIndex, parent)
            if not frame then return end
            active[i] = frame
        else
            frame:SetParent(parent)
        end
        frame.systemIndex = systemIndex
        if frame.trackedId ~= data.id then
            frame._activeGlowExpiry = nil
            GC:Hide(frame, ACTIVE_GLOW_KEY)
        end
        frame.trackedType = data.type
        frame.trackedId = data.id
        frame.useSpellId = data.useSpellId
        self:RequestActiveDurationLearn(systemIndex, data)
        frame.activeDuration = data.activeDuration
        frame._glowEntry = data
        frame._activeGlowTypeName, frame._activeGlowOptions = Orbit.SpellGlows:BuildGlowOptions(data.glows and data.glows.active, ACTIVE_GLOW_KEY, Constants.Glow.DefaultColor)
        if data.type == "spell" then
            Orbit.SpellGlows:RegisterProc(frame, function() return frame.trackedId end, function(cond)
                local entry = frame._glowEntry
                local t = entry and entry.glows and entry.glows[cond]
                if t ~= nil then return t end
                if cond == "proc" then return Plugin:GetSetting(frame.systemIndex, "ProcGlowType") end
            end, function(cond)
                local entry = frame._glowEntry
                return entry and entry.alerts and entry.alerts[cond]
            end)
            Orbit.IconCastState:Track(frame, function() return frame.trackedId end)
        else
            Orbit.SpellGlows:UnregisterProc(frame)
            Orbit.IconCastState:Untrack(frame)
        end
        frame.injectedIndex = i
        frame.afterNativeIndex = data.afterNativeIndex or 0
        self:UpdateIcon(frame)
    end
    Plugin.injectedFrames[systemIndex] = active
    -- Trigger layout refresh (sync + deferred next-frame to guarantee layout/texture boundaries are flushed)
    if entry and entry.anchor and Plugin.ProcessChildren then
        Plugin:ProcessChildren(entry.anchor)
        C_Timer.After(0, function()
            if not InCombatLockdown() then
                Plugin:ProcessChildren(entry.anchor)
            end
        end)
    end
end

-- [ ACTIVE DURATION LEARN ] -------------------------------------------------------------------------
-- Spell-only: override/tooltip resolve synchronously, else a one-shot aura watch fills activeDuration on first application (same pipeline as Tracked).
function Injection:RequestActiveDurationLearn(systemIndex, data, force)
    if not data or data.type ~= "spell" or not data.id then return end
    if not force and (data.activeDuration ~= nil or data.activeDurationLearned) then return end
    local value, watch = Orbit.CooldownData:ResolveActiveDuration(data.id)
    if value ~= nil then
        data.activeDuration = value
        return
    end
    if not watch or data.activeDurationLearned then return end
    Orbit.CooldownLearn:RequestOnce("inject:" .. systemIndex .. ":" .. tostring(data.id), watch, function(duration)
        local items = self:GetInjectedItems(systemIndex)
        local changed = false
        for _, e in ipairs(items) do
            if e.type == "spell" and e.id == data.id and not e.activeDurationLearned then
                e.activeDuration = duration
                e.activeDurationLearned = true
                changed = true
            end
        end
        if changed then
            self:SetInjectedItems(systemIndex, items)
            self:RefreshFrames(systemIndex)
        end
    end)
end

-- [ GLOW MENU ] -------------------------------------------------------------------------------------
function Injection:OpenGlowMenu(icon)
    local entry = icon._glowEntry
    if not entry then return end
    Orbit.SpellGlows:OpenMenu(icon, {
        id = entry.id,
        itemType = entry.type,
        supported = { proc = true, active = true },
        get = function(cond) return entry.glows and entry.glows[cond] end,
        set = function(cond, t)
            entry.glows = entry.glows or {}
            entry.glows[cond] = t
            Injection:SetInjectedItems(icon.systemIndex, Injection:GetInjectedItems(icon.systemIndex))
            if not InCombatLockdown() then Injection:RefreshFrames(icon.systemIndex) end
        end,
        getAlert = function(cond) return entry.alerts and entry.alerts[cond] end,
        setAlert = function(cond, v)
            entry.alerts = entry.alerts or {}
            entry.alerts[cond] = v
            Injection:SetInjectedItems(icon.systemIndex, Injection:GetInjectedItems(icon.systemIndex))
        end,
        removeCallback = function() if not InCombatLockdown() then Injection:RemoveInjectedIcon(icon) end end,
    })
end

-- [ PUBLIC API ] ------------------------------------------------------------------------------------
function Injection:GetActiveFrames(systemIndex)
    local frames = Plugin.injectedFrames[systemIndex]
    if not frames or #frames == 0 then return nil end
    return frames
end

-- [ DRAG AND DROP ] ---------------------------------------------------------------------------------
function Injection:OnViewerReceiveDrag(anchor)
    local itemType, actualId = DragDrop:ResolveCursorInfo()
    if not itemType then return end
    if not DragDrop:HasCooldown(itemType, actualId) then return end
    local targetAfterNativeIndex, arrayIndex = self:GetDropInsertionInfo(anchor)
    self:RemovePhantom()
    ClearCursor()
    self:AddItem(anchor.systemIndex, itemType, actualId, targetAfterNativeIndex, arrayIndex)
end

function Injection:OnIconReceiveDrag(icon)
    -- Forward to the parent anchor so the cursor-position logic handles placement
    local entry = Plugin.viewerMap[icon.systemIndex]
    if entry and entry.anchor then self:OnViewerReceiveDrag(entry.anchor) end
end

function Injection:RemoveInjectedIcon(icon)
    local systemIndex = icon.systemIndex
    local idx = icon.injectedIndex
    if not idx then return end
    self:RemoveItemAtIndex(systemIndex, idx)
end

function Injection:FlushAll()
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        self:SetInjectedItems(sysIdx, {})
        local active = Plugin.injectedFrames[sysIdx] or {}
        for i = #active, 1, -1 do
            self:ReleaseFrame(sysIdx, active[i])
            active[i] = nil
        end
        Plugin.injectedFrames[sysIdx] = active
        local entry = Plugin.viewerMap[sysIdx]
        if entry and entry.anchor and Plugin.ProcessChildren then
            Plugin:ProcessChildren(entry.anchor)
        end
    end
end

-- [ UPDATE TICKER ] ---------------------------------------------------------------------------------
function Injection:StartUpdateTicker()
    if Plugin._injectedTickerSetup then return end
    Plugin._injectedTickerSetup = true
    local nextUpdate = 0
    local frame = CreateFrame("Frame")
    frame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
    frame:RegisterEvent("SPELL_UPDATE_CHARGES")
    frame:RegisterEvent("BAG_UPDATE_COOLDOWN")
    frame:RegisterEvent("BAG_UPDATE")
    frame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
    frame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
    -- Charge/stack counts also change from aura applications with no cooldown event; native icons refresh on UNIT_AURA, injected ones must too.
    frame:RegisterUnitEvent("UNIT_AURA", "player")
    frame:SetScript("OnEvent", function(_, event, unit, _, spellId)
        if event == "PLAYER_EQUIPMENT_CHANGED" then
            Injection:OnEquipmentChanged()
            return
        end
        if event == "UNIT_SPELLCAST_SUCCEEDED" then
            Injection:OnSpellCast(spellId)
            return
        end
        local now = GetTime()
        if now < nextUpdate then return end
        nextUpdate = now + COOLDOWN_THROTTLE
        for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
            local frames = Plugin.injectedFrames[sysIdx]
            if frames then
                for _, icon in ipairs(frames) do
                    if icon:IsShown() and not icon.isPhantom then Injection:UpdateIcon(icon) end
                end
            end
        end
    end)
end

-- [ SPELL CAST HANDLER ] ----------------------------------------------------------------------------
function Injection:OnSpellCast(spellId)
    if not spellId then return end
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        local frames = Plugin.injectedFrames[sysIdx]
        if frames then
            for _, icon in ipairs(frames) do
                local isMatch = (icon.trackedType == "spell" and icon.trackedId == spellId)
                    or (icon.trackedType == "item" and icon.useSpellId == spellId)
                if isMatch then
                    local alerts = icon._glowEntry and icon._glowEntry.alerts
                    if alerts and alerts.active then Orbit.SpellGlows:FireAlert(alerts.active, icon.trackedId) end
                    if icon.activeDuration then
                        icon._activeGlowExpiry = GetTime() + icon.activeDuration
                        if icon._activeGlowTypeName then GC:Show(icon, ACTIVE_GLOW_KEY, icon._activeGlowTypeName, icon._activeGlowOptions) end
                        local expectedId = icon.trackedId
                        C_Timer.After(icon.activeDuration, function()
                            if icon.trackedId ~= expectedId then return end
                            -- A recast before this fires pushed _activeGlowExpiry later; let that newer timer clear it.
                            if icon._activeGlowExpiry and GetTime() < icon._activeGlowExpiry then return end
                            icon._activeGlowExpiry = nil
                            GC:Hide(icon, ACTIVE_GLOW_KEY)
                        end)
                    end
                end
            end
        end
    end
end

-- [ EQUIPMENT CHANGE HANDLER ] ----------------------------------------------------------------------
function Injection:OnEquipmentChanged()
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        local items = self:GetInjectedItems(sysIdx)
        local changed = false
        for i = #items, 1, -1 do
            local entry = items[i]
            if entry.slotId then
                local newItemId = GetInventoryItemID("player", entry.slotId)
                if newItemId and newItemId ~= entry.id then
                    entry.id = newItemId
                    entry.useSpellId = select(2, GetItemSpell(newItemId)) or nil
                    changed = true
                elseif not newItemId then
                    table.remove(items, i)
                    changed = true
                end
            end
        end
        if changed then
            self:SetInjectedItems(sysIdx, items)
            self:RefreshFrames(sysIdx)
        end
    end
end

-- [ DROP ZONE OVERLAYS ] ----------------------------------------------------------------------------
function Injection:CreateDropZone(anchor)
    if anchor._dropZone then return end
    local zone = CreateFrame("Frame", nil, UIParent)
    zone:SetPoint("TOPLEFT", anchor, "TOPLEFT")
    zone:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT")
    zone:SetFrameStrata(Orbit.Constants.Strata.Topmost)
    zone:SetFrameLevel(999)
    zone:Hide()
    local outset = function()
        local borderSize = Orbit.db and Orbit.db.GlobalSettings and Orbit.db.GlobalSettings.BorderSize or 1
        return borderSize + GLOW_OUTSET
    end
    Orbit.DropZoneGlow:Attach(zone, GLOW_COLOR_R, GLOW_COLOR_G, GLOW_COLOR_B, outset)
    zone:SetScript("OnHide", function() Injection:RemovePhantom() end)
    anchor._dropZone = zone
end

-- [ PHANTOM PREVIEW ] -------------------------------------------------------------------------------
function Injection:GetOrCreatePhantom(sysIdx)
    if self._phantom then return self._phantom end
    local entry = Plugin.viewerMap[sysIdx]
    -- Parent to the Orbit anchor, never entry.viewer: the viewer is Blizzard's secure InCombatProtect frame and a child taints its secret item state (README gotcha). SetPoint against native icons works cross-parent.
    local parent = entry and entry.anchor or UIParent
    local icon = CreateInjectedIcon(parent, sysIdx)
    icon.isPhantom = true
    icon:SetAlpha(PREVIEW_ALPHA)
    icon:Show()
    self._phantom = icon
    self._phantomSysIdx = nil
    self._phantomArrayIdx = nil
    return icon
end

function Injection:RemovePhantom()
    if not self._phantom then return end
    local sysIdx = self._phantomSysIdx
    if sysIdx then
        local frames = Plugin.injectedFrames[sysIdx]
        if frames then
            for i = #frames, 1, -1 do
                if frames[i] == self._phantom then table.remove(frames, i); break end
            end
        end
        local entry = Plugin.viewerMap[sysIdx]
        if entry and entry.anchor and Plugin.ProcessChildren then
            Plugin:ProcessChildren(entry.anchor)
        end
    end
    self._phantom:Hide()
    self._phantom:ClearAllPoints()
    self._phantom = nil
    self._phantomSysIdx = nil
    self._phantomArrayIdx = nil
    self._phantomAfterNative = nil
end

function Injection:GetVisiblePositions(anchor, nativeOnly)
    local sysIdx = anchor.systemIndex
    local entry = Plugin.viewerMap[sysIdx]
    if not entry or not entry.viewer then return {} end
    local blizzFrame = entry.viewer
    local scale = blizzFrame:GetEffectiveScale()
    local positions = {}
    local children = { blizzFrame:GetChildren() }
    for i = 1, #children do
        local child = children[i]
        if child and child:IsShown() and child:GetLeft() and not child.isPhantom and (not nativeOnly or not child.isInjectedIcon) then
            positions[#positions + 1] = {
                frame = child,
                left = child:GetLeft() * scale,
                right = child:GetRight() * scale,
                mid = (child:GetLeft() + child:GetRight()) / 2 * scale,
            }
        end
    end
    table.sort(positions, function(a, b) return a.left < b.left end)
    return positions
end

function Injection:GetDropInsertionInfo(anchor)
    local cx = GetCursorPosition()
    local positions = self:GetVisiblePositions(anchor, false)
    local dropVisualIdx = #positions + 1
    for i, pos in ipairs(positions) do
        if cx < pos.mid then dropVisualIdx = i; break end
    end
    
    local sysIdx = anchor.systemIndex
    local existingItems = self:GetInjectedItems(sysIdx)
    local frames = Plugin.injectedFrames[sysIdx] or {}
    -- Filter out phantom from frame lookups
    local realFrames = {}
    for _, f in ipairs(frames) do
        if not f.isPhantom then realFrames[#realFrames + 1] = f end
    end
    
    local frameBefore = (dropVisualIdx > 1) and positions[dropVisualIdx - 1].frame or nil
    local frameAfter = (dropVisualIdx <= #positions) and positions[dropVisualIdx].frame or nil
    
    local nativeCount = 0
    for i = 1, dropVisualIdx - 1 do
        if not positions[i].frame.isInjectedIcon then nativeCount = nativeCount + 1 end
    end
    
    local targetAfterNativeIndex = nativeCount
    local insertArrayIndex = #existingItems + 1
    
    if frameBefore and frameBefore.isInjectedIcon then
        for i, f in ipairs(realFrames) do
            if f == frameBefore then insertArrayIndex = i + 1; break end
        end
    elseif frameAfter and frameAfter.isInjectedIcon then
        for i, f in ipairs(realFrames) do
            if f == frameAfter then insertArrayIndex = i; break end
        end
    else
        for i, item in ipairs(existingItems) do
            if item.afterNativeIndex >= targetAfterNativeIndex then insertArrayIndex = i; break end
        end
    end
    
    return targetAfterNativeIndex, insertArrayIndex
end

function Injection:UpdateDropZoneHighlight(anchor)
    local zone = anchor._dropZone
    if not zone or not zone:IsShown() then return end
    local sysIdx = anchor.systemIndex
    
    local cx, cy = GetCursorPosition()
    local scale = anchor:GetEffectiveScale()
    local left, right, top, bottom = anchor:GetLeft(), anchor:GetRight(), anchor:GetTop(), anchor:GetBottom()
    if not (left and top and bottom) then
        if self._phantomSysIdx == sysIdx then self:RemovePhantom() end
        return
    end
    
    local boundsPadding = BOUNDS_PADDING * scale
    if cy > (top * scale) + boundsPadding or cy < (bottom * scale) - boundsPadding then
        if self._phantomSysIdx == sysIdx then self:RemovePhantom() end
        return
    end
    
    local tex = DragDrop:GetCursorTexture()
    if not tex then
        if self._phantomSysIdx == sysIdx then self:RemovePhantom() end
        return
    end
    
    local afterNative, arrayIdx = self:GetDropInsertionInfo(anchor)
    
    -- Only reflow if position actually changed
    if self._phantomSysIdx == sysIdx and self._phantomArrayIdx == arrayIdx and self._phantomAfterNative == afterNative then
        return
    end
    
    -- Remove from old position
    if self._phantomSysIdx then
        local oldFrames = Plugin.injectedFrames[self._phantomSysIdx]
        if oldFrames then
            for i = #oldFrames, 1, -1 do
                if oldFrames[i] == self._phantom then table.remove(oldFrames, i); break end
            end
        end
    end
    
    local phantom = self:GetOrCreatePhantom(sysIdx)
    phantom.Icon:SetTexture(tex)
    phantom.afterNativeIndex = afterNative
    phantom.systemIndex = sysIdx
    
    local entry = Plugin.viewerMap[sysIdx]
    local parent = entry and entry.anchor or UIParent
    phantom:SetParent(parent)
    
    local frames = Plugin.injectedFrames[sysIdx] or {}
    Plugin.injectedFrames[sysIdx] = frames
    local clampedIdx = math.min(arrayIdx, #frames + 1)
    table.insert(frames, clampedIdx, phantom)
    
    self._phantomSysIdx = sysIdx
    self._phantomArrayIdx = arrayIdx
    self._phantomAfterNative = afterNative
    
    if entry and entry.anchor and Plugin.ProcessChildren then
        Plugin:ProcessChildren(entry.anchor)
    end
end

function Injection:ShowDropZones()
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        local entry = Plugin.viewerMap[sysIdx]
        if entry and entry.anchor and entry.anchor._dropZone then
            entry.anchor._dropZone:Show()
        end
    end
    if not self._dropZoneTicker then
        self._dropZoneTicker = C_Timer.NewTicker(DROP_ZONE_TICK_RATE, function()
            if not self._dropZonesVisible then
                self._dropZoneTicker:Cancel()
                self._dropZoneTicker = nil
                return
            end
            -- Find which viewer the cursor is closest to vertically
            local _, cy = GetCursorPosition()
            local bestAnchor, bestDist = nil, math.huge
            for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
                local entry = Plugin.viewerMap[sysIdx]
                if entry and entry.anchor then
                    local anchor = entry.anchor
                    local top, bottom = anchor:GetTop(), anchor:GetBottom()
                    local scale = anchor:GetEffectiveScale()
                    if top and bottom then
                        local midY = (top + bottom) / 2 * scale
                        local dist = math.abs(cy - midY)
                        if dist < bestDist then
                            bestDist = dist
                            bestAnchor = anchor
                        end
                    end
                end
            end
            if bestAnchor then
                -- Remove phantom from any other viewer
                if self._phantomSysIdx and self._phantomSysIdx ~= bestAnchor.systemIndex then
                    self:RemovePhantom()
                end
                self:UpdateDropZoneHighlight(bestAnchor)
            end
        end)
    end
    self._dropZonesVisible = true
end

function Injection:HideDropZones()
    self._dropZonesVisible = false
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        local entry = Plugin.viewerMap[sysIdx]
        if entry and entry.anchor and entry.anchor._dropZone then
            entry.anchor._dropZone:Hide()
        end
    end
end

-- [ CURSOR WATCHER INTEGRATION ] --------------------------------------------------------------------
function Injection:SetClickEnabled(enabled)
    local isDroppable = enabled and self:IsDraggingCooldownAbility()
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        local frames = Plugin.injectedFrames[sysIdx]
        if frames then
            for _, icon in ipairs(frames) do
                icon:EnableMouse(enabled)
            end
        end
        local entry = Plugin.viewerMap[sysIdx]
        if entry and entry.anchor then
            entry.anchor:EnableMouse(enabled)
        end
    end
    if isDroppable then self:ShowDropZones() else self:HideDropZones() end
end

function Injection:IsDraggingCooldownAbility()
    local itemType, actualId = DragDrop:ResolveCursorInfo()
    if not itemType then return false end
    if not DragDrop:IsDraggingCooldownAbility() then return false end

    local slotId = (itemType == "item") and DragDrop:ResolveEquipmentSlot(actualId) or nil
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        local items = self:GetInjectedItems(sysIdx)
        for _, entry in ipairs(items) do
            if slotId and entry.slotId == slotId then return false end
            if not slotId and entry.type == itemType and entry.id == actualId then return false end
        end
    end

    return true
end

-- [ CURSOR WATCHER ] --------------------------------------------------------------------------------
-- Drives SetClickEnabled + OOCFade reveal for `orbitCursorReveal` frames while a cooldown ability is on the cursor.
function Injection:StartCursorWatcher()
    if Plugin._injectedCursorWatcher then return end
    local accum = 0
    local lastCursor, lastEditMode, lastShift = nil, nil, nil
    local watcher = CreateFrame("Frame")
    watcher:SetScript("OnUpdate", function(_, elapsed)
        accum = accum + elapsed
        if accum < CURSOR_POLL_INTERVAL then return end
        accum = 0
        if InCombatLockdown() then return end
        local cursorType = GetCursorInfo()
        local isEditMode = EditModeManagerFrame and EditModeManagerFrame:IsShown() or false
        local isShift = IsShiftKeyDown() and true or false
        if cursorType == lastCursor and isEditMode == lastEditMode and isShift == lastShift then return end
        lastCursor, lastEditMode, lastShift = cursorType, isEditMode, isShift
        local p = Orbit.Profiler
        local s = p and p:Begin()
        if Orbit.OOCFadeMixin then Orbit.OOCFadeMixin:RefreshAll() end
        local isDroppable = Injection:IsDraggingCooldownAbility()
        Injection:SetClickEnabled(isDroppable or isShift or isEditMode)
        if p then p:End(Plugin, "CursorWatcher", s) end
    end)
    Plugin._injectedCursorWatcher = watcher
end

-- [ LIFECYCLE ] -------------------------------------------------------------------------------------
function Injection:Initialize()
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        self:RefreshFrames(sysIdx)
        local entry = Plugin.viewerMap[sysIdx]
        if entry and entry.anchor then
            local anchor = entry.anchor
            self:CreateDropZone(anchor)
            anchor:SetHitRectInsets(HIT_RECT_INSET, HIT_RECT_INSET, HIT_RECT_INSET, HIT_RECT_INSET)
            anchor:SetScript("OnReceiveDrag", function(self) Injection:OnViewerReceiveDrag(self) end)
            local origMouseDown = anchor:GetScript("OnMouseDown")
            anchor:SetScript("OnMouseDown", function(self, button, ...)
                if GetCursorInfo() then
                    Injection:OnViewerReceiveDrag(self)
                    return
                end
                if origMouseDown then origMouseDown(self, button, ...) end
            end)
        end
    end
    self:StartUpdateTicker()
    self:StartCursorWatcher()
end

-- [ SPEC CHANGE ] -----------------------------------------------------------------------------------
function Injection:OnSpecChanged()
    for _, sysIdx in ipairs({ ESSENTIAL_INDEX, UTILITY_INDEX }) do
        self:RefreshFrames(sysIdx)
    end
end
