-- rOrbs_RunePoints.lua
-- Class-point / rune icons for the health orb.
-- Modes: arc around orb (angle + inside/outside radius) and horizontal row (free-floating).
-- Icon gap uses rOrbsDB.runeHorizSpacing for both arc (angular spacing) and horizontal layout.
-- Fill order: rOrbsDB.runePointsFillRTL — false/nil = left→right (slot 1 first), true = right→left.
-- OnUpdate-based fading. Death Knights get per-rune type colors.

local addonName, ns = ...

-- ── constants ────────────────────────────────────────────────────────────────
local MAX_RUNES        = 7
local FADE_IN          = 0.20
local FADE_OUT         = 0.15
local ARC_RADIUS       = 0.88   -- inside orb (fraction of orb half-size)
local ARC_RADIUS_OUT   = 1.18   -- outside orb
local RUNE_FRAC        = 0.13   -- icon size as fraction of orb size (arc mode)
local CC_BG_SCALE      = 1.8    -- cc_bg.tga is this many times larger than the rune icon

local HORIZ_DEFAULT_SIZE    = 28  -- icon pixel size (horizontal mode)
local HORIZ_DEFAULT_SPACING = 6   -- gap between icons (horizontal mode)

-- Class-power 3D model presets.
-- camScale: HIGHER = model appears SMALLER (camera farther). Tune per displayInfoID.
local CP_MODEL_PRESETS = {
    model3d = {
        displayInfoID = 76935,
        camScale = 1.0 / 1.1,
        posX = 0,
        posY = 0,
        posZ = -0.18,
    },
    ["model3d-legion"] = {
        displayInfoID = 74692,
        camScale = 7.0,
        posX = 0,
        posY = 0,
        posZ = -0.18,
    },
}
-- 3D viewport vs cc_bg: ns.ORB_MODEL_VIEW_PAD + ns.ApplyOrbModelAnchors (config.lua)

-- DK rune colors: Blood=red, Frost=lightblue, Unholy=green, Death=purple
local DK_RUNE_COLORS = {
    [1] = {0.8, 0.1, 0.1},
    [2] = {0.5, 0.8, 1.0},
    [3] = {0.2, 0.8, 0.2},
    [4] = {0.6, 0.2, 0.9},
}

local MEDIA_PATH = "Interface\\AddOns\\rOrbs\\media\\"
local RUNE_TEXTURES = {
    MEDIA_PATH.."rune1", MEDIA_PATH.."rune2", MEDIA_PATH.."rune3",
    MEDIA_PATH.."rune4", MEDIA_PATH.."rune5", MEDIA_PATH.."rune6",
    MEDIA_PATH.."rune6",
}

local function NormalizeMax(max)
    if not max or max <= 0 then return 0 end
    return math.min(max, MAX_RUNES)
end

-- ── class power info ──────────────────────────────────────────────────────────
-- Returns: powerType (or "DK"), max, isDK
local function GetClassPowerInfo()
    local _, class = UnitClass("player")
    if class == "ROGUE" then
        return Enum.PowerType.ComboPoints, NormalizeMax(UnitPowerMax("player", Enum.PowerType.ComboPoints)), false
    elseif class == "DRUID" and GetSpecialization() == 2 then
        return Enum.PowerType.ComboPoints, NormalizeMax(UnitPowerMax("player", Enum.PowerType.ComboPoints)), false
    elseif class == "PALADIN" then
        return Enum.PowerType.HolyPower, NormalizeMax(UnitPowerMax("player", Enum.PowerType.HolyPower)), false
    elseif class == "MONK" and GetSpecialization() == 3 then
        return Enum.PowerType.Chi, NormalizeMax(UnitPowerMax("player", Enum.PowerType.Chi)), false
    elseif class == "WARLOCK" then
        local s = GetSpecialization()
        if s == 1 or s == 3 then
            return Enum.PowerType.SoulShards, NormalizeMax(UnitPowerMax("player", Enum.PowerType.SoulShards)), false
        end
    elseif class == "MAGE" and GetSpecialization() == 1 then
        return Enum.PowerType.ArcaneCharges, NormalizeMax(UnitPowerMax("player", Enum.PowerType.ArcaneCharges)), false
    elseif class == "EVOKER" then
        return Enum.PowerType.Essence, NormalizeMax(UnitPowerMax("player", Enum.PowerType.Essence)), false
    elseif class == "DEATHKNIGHT" then
        return "DK", 6, true
    end
    return nil, 0, false
end

-- ── state ─────────────────────────────────────────────────────────────────────
local initialized  = false
local RunePointsFrame
local runeFrames   = {}
local runeTex      = {}   -- foreground rune texture per slot
local runeBGTex    = {}   -- background cc_bg texture per slot
local runeModel          = {}   -- 3D model per slot (model3d style)
local runeModelDisplayId = {}   -- loaded displayInfoID per slot
local runeModelSetupKey  = {}   -- displayInfo + cam/pos fingerprint (re-apply on change)
local runeTarget   = {}   -- target alpha
local runeSpeed    = {}   -- alpha/sec (+ = fadein, - = fadeout, 0 = static)
local currentPower = 0
local currentMax   = 0
local currentIsDK  = false

local runeHorizFrame = nil   -- transparent container for horizontal layout
local currentLayout  = nil   -- "arc" or "horizontal" – tracks current parent state
local HideRune

local function GetClassPowerModelPreset()
    local style = rOrbsDB and rOrbsDB.comboPointStyle
    return style and CP_MODEL_PRESETS[style]
end

local function IsClassPowerModelStyle()
    return GetClassPowerModelPreset() ~= nil
end

local function GetModelSetupKey(preset)
    if not preset then return nil end
    return string.format("%d:%.4f:%.4f:%.4f:%.4f",
        preset.displayInfoID, preset.camScale, preset.posX, preset.posY, preset.posZ)
end

local function IsRuneModelReady(model)
    if not model then return false end
    local ok, fileId = pcall(function() return model:GetModelFileID() end)
    return ok and type(fileId) == "number" and fileId > 0
end

local function ApplyRuneModelCamera(model, preset)
    if not model or not preset then return end
    model:SetPortraitZoom(0)
    model:SetCamDistanceScale(preset.camScale)
    model:SetPosition(preset.posX, preset.posY, preset.posZ)
    model:SetRotation(0)
    if model.RefreshCamera then
        model:RefreshCamera()
    end
end

local function EnsureRuneModelLoaded(i)
    local preset = GetClassPowerModelPreset()
    local model = runeModel[i]
    if not preset or not model then return end
    local setupKey = GetModelSetupKey(preset)
    if runeModelSetupKey[i] == setupKey then return end

    local wantedId = preset.displayInfoID
    if runeModelDisplayId[i] ~= wantedId then
        model:ClearModel()
        model:SetDisplayInfo(wantedId)
        runeModelDisplayId[i] = wantedId
    end
    runeModelSetupKey[i] = setupKey

    if IsRuneModelReady(model) then
        ApplyRuneModelCamera(model, preset)
    end
end

local function ApplyRuneVisualMode(i)
    local useModel = IsClassPowerModelStyle()
    if runeTex[i] then
        if useModel then runeTex[i]:Hide() else runeTex[i]:Show() end
    end
    local model = runeModel[i]
    if model then
        if useModel and runeFrames[i] and runeFrames[i]:IsShown() then
            EnsureRuneModelLoaded(i)
            model:Show()
        else
            model:Hide()
        end
    end
end

local function UpdateRuneVisualMode()
    for i = 1, MAX_RUNES do
        ApplyRuneVisualMode(i)
    end
end

-- ── OnUpdate fade driver ──────────────────────────────────────────────────────
local fadeDriver
local driverActive = false

local function UpdateFades(_, elapsed)
    local stillFading = false
    for i = 1, MAX_RUNES do
        local spd = runeSpeed[i]
        if spd ~= 0 then
            local f     = runeFrames[i]
            local alpha = f:GetAlpha() + spd * elapsed
            if spd > 0 then
                if alpha >= 1 then
                    alpha = 1; runeSpeed[i] = 0; f:SetAlpha(1)
                else
                    stillFading = true; f:SetAlpha(alpha)
                end
            else
                if alpha <= 0 then
                    alpha = 0; runeSpeed[i] = 0; f:SetAlpha(0); f:Hide()
                else
                    stillFading = true; f:SetAlpha(alpha)
                end
            end
        end
    end
    if not stillFading then
        driverActive = false
        fadeDriver:SetScript("OnUpdate", nil)
    end
end

local function EnsureDriver()
    if not driverActive then
        driverActive = true
        fadeDriver:SetScript("OnUpdate", UpdateFades)
    end
end

-- ── rune helpers ──────────────────────────────────────────────────────────────
local function FadeInRune(i)
    if runeTarget[i] == 1 and runeSpeed[i] >= 0 then return end
    runeTarget[i] = 1
    runeSpeed[i]  = 1 / FADE_IN
    runeFrames[i]:Show()
    ApplyRuneVisualMode(i)
    EnsureDriver()
end

local function FadeOutRune(i)
    if runeTarget[i] == 0 and runeSpeed[i] <= 0 then return end
    runeTarget[i] = 0
    runeSpeed[i]  = -(1 / FADE_OUT)
    EnsureDriver()
end

local function ShowRune(i)
    if runeTarget[i] == 1 then return end
    runeTarget[i] = 1
    runeSpeed[i]  = 0
    runeFrames[i]:SetAlpha(1)
    runeFrames[i]:Show()
    ApplyRuneVisualMode(i)
end

HideRune = function(i)
    if runeTarget[i] == 0 and runeSpeed[i] == 0 then return end
    runeTarget[i] = 0
    runeSpeed[i]  = 0
    runeFrames[i]:SetAlpha(0)
    runeFrames[i]:Hide()
    if runeModel[i] then runeModel[i]:Hide() end
end

-- ── DK rune colors ────────────────────────────────────────────────────────────
local function UpdateDKColors()
    for i = 1, 6 do
        local runeType = GetRuneType and GetRuneType(i) or 1
        local c = DK_RUNE_COLORS[runeType] or DK_RUNE_COLORS[1]
        runeTex[i]:SetVertexColor(c[1], c[2], c[3], 1)
    end
end

local function ResetColors()
    for i = 1, MAX_RUNES do
        runeTex[i]:SetVertexColor(1, 1, 1, 1)
    end
end

-- ── bg-texture visibility ─────────────────────────────────────────────────────
local function UpdateBGVisibility()
    local show = rOrbsDB and rOrbsDB.runePointsBGShow ~= false
    for i = 1, MAX_RUNES do
        if runeBGTex[i] then
            if show then runeBGTex[i]:Show() else runeBGTex[i]:Hide() end
        end
    end
end

-- ── horizontal layout ─────────────────────────────────────────────────────────
-- offX/offY are UIParent-pixel offsets from screen centre (same convention as ComboBar).
local function ApplyRuneHorizLayout()
    if not runeHorizFrame then return end
    local offX = (rOrbsDB and rOrbsDB.runeHorizOffX) or 0
    local offY = (rOrbsDB and rOrbsDB.runeHorizOffY) or -200
    runeHorizFrame:ClearAllPoints()
    runeHorizFrame:SetPoint("CENTER", UIParent, "CENTER", offX, offY)
    local uisc = (rOrbsDB and rOrbsDB.runeHorizUIScale) or 1
    runeHorizFrame:SetScale(uisc)
end

local function PositionRunesHorizontal(max)
    if not runeHorizFrame then return end

    -- Reparent from orb (or UIParent) to runeHorizFrame if needed
    if currentLayout ~= "horizontal" then
        for i = 1, MAX_RUNES do
            runeFrames[i]:SetParent(runeHorizFrame)
            runeFrames[i]:SetFrameStrata("MEDIUM")
            runeFrames[i]:SetFrameLevel(60)
        end
        runeHorizFrame:Show()
        currentLayout = "horizontal"
    end

    local sz  = (rOrbsDB and rOrbsDB.runeHorizSize)    or HORIZ_DEFAULT_SIZE
    local gap = (rOrbsDB and rOrbsDB.runeHorizSpacing) or HORIZ_DEFAULT_SPACING
    local total = max * sz + (max - 1) * gap

    runeHorizFrame:SetSize(math.max(total, 1), math.max(sz, 1))
    ApplyRuneHorizLayout()

    for i = 1, max do
        local xOff   = (i - 1) * (sz + gap)
        local bgSize = math.floor(sz * CC_BG_SCALE)
        runeFrames[i]:SetSize(sz, sz)
        if runeBGTex[i] then runeBGTex[i]:SetSize(bgSize, bgSize) end
        runeFrames[i]:ClearAllPoints()
        runeFrames[i]:SetPoint("LEFT", runeHorizFrame, "LEFT", xOff, 0)
    end
    for i = max + 1, MAX_RUNES do
        runeFrames[i]:ClearAllPoints()
        HideRune(i)
    end
    UpdateBGVisibility()
end

-- ── arc layout ────────────────────────────────────────────────────────────────
local function deg2rad(d) return d * math.pi / 180 end

local function PositionRunesArc(max)
    if not ns.HealthOrb then return end
    local orb = ns.HealthOrb

    -- Reparent to orb if needed (first time or switching from horizontal)
    if currentLayout ~= "arc" then
        for i = 1, MAX_RUNES do
            runeFrames[i]:SetParent(orb)
            runeFrames[i]:SetFrameStrata("MEDIUM")
            runeFrames[i]:SetFrameLevel(60)
        end
        if runeHorizFrame then runeHorizFrame:Hide() end
        currentLayout = "arc"
    end

    local orbSize = orb:GetWidth()
    if not orbSize or orbSize <= 0 then
        orbSize = (ns.cfg and ns.cfg.units and ns.cfg.units.player and ns.cfg.units.player.size) or 160
    end
    local dbSize    = rOrbsDB and rOrbsDB.runePointsSize
    local rs        = (dbSize and dbSize > 0)
                      and dbSize
                      or math.max(20, math.min(36, math.floor(orbSize * RUNE_FRAC)))
    local offX      = (rOrbsDB and rOrbsDB.runePointsOffX) or 0
    local offY      = (rOrbsDB and rOrbsDB.runePointsOffY) or 0
    -- Outside ring unless explicitly false (matches default runeArcOutside in config)
    local isOutside = (rOrbsDB and rOrbsDB.runeArcOutside ~= false)
    local arcR      = isOutside and ARC_RADIUS_OUT or ARC_RADIUS
    local radius    = (orbSize * 0.5) * arcR
    -- 90° = top, 270° = bottom of orb (WoW: x=cos, y=sin)
    local centerDeg = (rOrbsDB and rOrbsDB.runeArcAngle) or 90
    if ns.SnapRuneArcAngle then centerDeg = ns.SnapRuneArcAngle(centerDeg) end
    -- Same pixel gap as horizontal row: angular step from (icon size + gap) / orbit radius
    local gap       = (rOrbsDB and rOrbsDB.runeHorizSpacing) or HORIZ_DEFAULT_SPACING
    local stepDeg   = math.deg((rs + gap) / radius)
    for i = 1, max do
        local angDeg
        if max == 1 then
            angDeg = centerDeg
        else
            local mid = (max + 1) / 2
            angDeg = centerDeg + (i - mid) * stepDeg
        end
        local ang    = deg2rad(angDeg)
        local bgSize = math.floor(rs * CC_BG_SCALE)
        runeFrames[i]:SetSize(rs, rs)
        if runeBGTex[i] then runeBGTex[i]:SetSize(bgSize, bgSize) end
        runeFrames[i]:ClearAllPoints()
        runeFrames[i]:SetPoint("CENTER", orb, "CENTER", math.cos(ang)*radius + offX, math.sin(ang)*radius + offY)
    end
    for i = max + 1, MAX_RUNES do
        runeFrames[i]:ClearAllPoints()
        HideRune(i)
    end
    UpdateBGVisibility()
end

-- ── positioning dispatcher ───────────────────────────────────────────────────
local function PositionRunes(max)
    max = NormalizeMax(max)
    if max <= 0 then return end
    local pos = (rOrbsDB and rOrbsDB.runePointsPosition) or "arc"
    if pos == "horizontal" then
        PositionRunesHorizontal(max)
    else
        PositionRunesArc(max)
    end
end

-- ── DK update ────────────────────────────────────────────────────────────────
local function UpdateDK()
    PositionRunes(6)
    UpdateDKColors()
    local prevPower = currentPower
    local newPower  = 0
    for i = 1, 6 do
        local _, _, ready = GetRuneCooldown(i)
        if ready then newPower = newPower + (2^(i-1)) end
    end
    if newPower == prevPower then return end
    for i = 1, 6 do
        local bit      = 2^(i-1)
        local isReady  = (newPower  % (bit*2) >= bit)
        local wasReady = (prevPower % (bit*2) >= bit)
        if isReady and not wasReady then
            FadeInRune(i)
        elseif not isReady and wasReady then
            FadeOutRune(i)
        elseif isReady then
            ShowRune(i)
        else
            HideRune(i)
        end
    end
    currentPower = newPower
end

-- ── normal class update ───────────────────────────────────────────────────────
local function HideAll()
    for i = 1, MAX_RUNES do HideRune(i) end
    currentPower = 0
end

-- Slot i (left→right / arc order) is lit for given resource amount
local function SlotIsCharged(i, power, max)
    if not max or max <= 0 then return false end
    if rOrbsDB and rOrbsDB.runePointsFillRTL then
        return (max - i + 1) <= power
    end
    return i <= power
end

-- Preview mode flag: ForceRefresh respects this so sliders don't kill the preview
local runePreviewActive = false

-- Preview: show up to currentMax (or MAX_RUNES as fallback) at full alpha
local function ShowAllPreview()
    runePreviewActive = true
    local max = NormalizeMax((currentMax > 0) and currentMax or MAX_RUNES)
    PositionRunes(max)
    for i = 1, max do
        ShowRune(i)
    end
end

local function UpdateDisplay(newPower, max)
    if not max or max == 0 then HideAll(); return end
    PositionRunes(max)
    for i = 1, max do
        local wasActive = SlotIsCharged(i, currentPower, max)
        local isActive  = SlotIsCharged(i, newPower, max)
        if isActive and not wasActive then
            FadeInRune(i)
        elseif not isActive and wasActive then
            FadeOutRune(i)
        elseif isActive then
            ShowRune(i)
        else
            HideRune(i)
        end
    end
    for i = max + 1, MAX_RUNES do HideRune(i) end
    currentPower = newPower
end

-- ── main callback ─────────────────────────────────────────────────────────────
local function OnPowerUpdate()
    if runePreviewActive then return end  -- preview active: don't override it
    local modelStyle = IsClassPowerModelStyle()
    UpdateRuneVisualMode()
    if not modelStyle and rOrbsDB and rOrbsDB.runePointsShow == false then HideAll(); return end
    local powerType, max, isDK = GetClassPowerInfo()
    if not powerType or max == 0 then HideAll(); return end
    if isDK then
        currentIsDK = true
        currentMax  = 6
        UpdateDK()
        return
    end
    currentIsDK = false
    local new = UnitPower("player", powerType)
    if new == currentPower and max == currentMax then return end
    if max ~= currentMax then
        currentMax   = max
        currentPower = 0
        PositionRunes(max)
    end
    UpdateDisplay(new, max)
end

local function ForceRefresh()
    local _, class = UnitClass("player")
    if currentIsDK and class ~= "DEATHKNIGHT" then ResetColors() end
    currentPower = -1
    currentMax   = 0
    currentIsDK  = false
    UpdateRuneVisualMode()
    if runePreviewActive then
        ShowAllPreview()  -- sliders / layout calls during preview re-show instead of hiding
    else
        OnPowerUpdate()
    end
end

local function StopPreview()
    runePreviewActive = false
    ForceRefresh()
end

-- ── frame creation ────────────────────────────────────────────────────────────
local function CreateRuneFrames()
    -- Transparent container for horizontal layout
    runeHorizFrame = CreateFrame("Frame", "rOrbsRuneHorizFrame", UIParent)
    runeHorizFrame:SetFrameStrata("MEDIUM")
    runeHorizFrame:SetFrameLevel(55)
    runeHorizFrame:SetSize(200, 32)
    runeHorizFrame:SetClampedToScreen(true)
    runeHorizFrame:EnableMouse(true)
    runeHorizFrame:SetMovable(true)
    runeHorizFrame:Hide()  -- hidden until horizontal mode is active

    -- SHIFT+Drag to move
    local _drag = false
    local _startX, _startY, _startOX, _startOY = 0, 0, 0, 0
    runeHorizFrame:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        if rOrbsDB and rOrbsDB.orbDragLocked then return end
        if not IsShiftKeyDown() then return end
        _drag = true
        local uisc = UIParent:GetEffectiveScale()
        local sx, sy = GetCursorPosition()
        _startX  = sx / uisc
        _startY  = sy / uisc
        _startOX = (rOrbsDB and rOrbsDB.runeHorizOffX) or 0
        _startOY = (rOrbsDB and rOrbsDB.runeHorizOffY) or -200
        self:SetScript("OnUpdate", function()
            if not _drag then self:SetScript("OnUpdate", nil); return end
            local cx, cy = GetCursorPosition()
            uisc = UIParent:GetEffectiveScale()
            rOrbsDB.runeHorizOffX = math.floor(_startOX + cx / uisc - _startX + 0.5)
            rOrbsDB.runeHorizOffY = math.floor(_startOY + cy / uisc - _startY + 0.5)
            ApplyRuneHorizLayout()
        end)
    end)
    runeHorizFrame:SetScript("OnMouseUp", function(self)
        local hadDrag = _drag
        _drag = false
        self:SetScript("OnUpdate", nil)
        if hadDrag and rOrbsDB and rOrbsDB.snapToGrid then
            local g = 10
            rOrbsDB.runeHorizOffX = math.floor((rOrbsDB.runeHorizOffX or 0) / g + 0.5) * g
            rOrbsDB.runeHorizOffY = math.floor((rOrbsDB.runeHorizOffY or 0) / g + 0.5) * g
            ApplyRuneHorizLayout()
        end
        if ns.GUI and ns.GUI.RefreshValues then ns.GUI:RefreshValues() end
    end)
    local _horizHovering = false
    local _horizShiftWas  = false
    runeHorizFrame:SetScript("OnEnter", function(self)
        _horizHovering = true
        self:SetScript("OnUpdate", function()
            if rOrbsDB and rOrbsDB.orbDragLocked == true then
                GameTooltip:Hide()
                return
            end
            local shiftNow = IsShiftKeyDown()
            if shiftNow == _horizShiftWas then return end
            _horizShiftWas = shiftNow
            if shiftNow then
                GameTooltip:SetOwner(self, "ANCHOR_TOP")
                GameTooltip:AddLine("rOrbs Rune Icons", 0.9, 0.18, 0.18, 1, 1, 1)
                GameTooltip:AddLine("|cffffff00SHIFT + Drag|r to move", 1, 1, 1)
                GameTooltip:Show()
            else
                GameTooltip:Hide()
            end
        end)
        -- Initial state
        if rOrbsDB and rOrbsDB.orbDragLocked == true then return end
        if IsShiftKeyDown() then
            _horizShiftWas = true
            GameTooltip:SetOwner(self, "ANCHOR_TOP")
            GameTooltip:AddLine("rOrbs Rune Icons", 0.9, 0.18, 0.18, 1, 1, 1)
            GameTooltip:AddLine("|cffffff00SHIFT + Drag|r to move", 1, 1, 1)
            GameTooltip:Show()
        end
    end)
    runeHorizFrame:SetScript("OnLeave", function(self)
        _horizHovering = false
        _horizShiftWas = false
        self:SetScript("OnUpdate", nil)
        GameTooltip:Hide()
    end)

    -- Individual rune icon frames
    for i = 1, MAX_RUNES do
        local f = CreateFrame("Frame", "rOrbsRunePoint"..i, UIParent)
        f:SetFrameStrata("MEDIUM")
        f:SetFrameLevel(60)
        f:EnableMouse(false)
        f:SetSize(30, 30)
        f:SetClipsChildren(false)
        f:SetAlpha(0)
        f:Hide()
        -- Background texture (cc_bg.tga)
        local bgTex = f:CreateTexture(nil, "BACKGROUND")
        bgTex:SetPoint("CENTER", f, "CENTER", 0, 0)
        bgTex:SetTexture(MEDIA_PATH.."cc_bg")
        bgTex:SetBlendMode("BLEND")
        -- Foreground rune texture
        local tex = f:CreateTexture(nil, "ARTWORK")
        tex:SetAllPoints(f)
        tex:SetTexture(RUNE_TEXTURES[i])
        tex:SetBlendMode("ADD")
        -- Optional 3D model style per slot (class-power presets)
        local model = CreateFrame("PlayerModel", nil, f)
        model:SetFrameLevel((f:GetFrameLevel() or 0) + 1)
        ns.ApplyOrbModelAnchors(model, f)
        model:EnableMouse(false)
        model:SetAlpha(1)
        model:Hide()
        model:SetScript("OnModelLoaded", function(self)
            local preset = GetClassPowerModelPreset()
            if not preset then return end
            if (self:GetDisplayInfo() or 0) == 0 then return end
            ApplyRuneModelCamera(self, preset)
        end)
        runeFrames[i] = f
        runeBGTex[i]  = bgTex
        runeTex[i]    = tex
        runeModel[i]  = model
        runeModelDisplayId[i] = nil
        runeModelSetupKey[i]  = nil
        runeTarget[i] = 0
        runeSpeed[i]  = 0
    end
    fadeDriver = CreateFrame("Frame", "rOrbsFadeDriver", UIParent)
end

-- ── init ─────────────────────────────────────────────────────────────────────
local function Initialize()
    if initialized then return end
    initialized = true
    CreateRuneFrames()

    if ns.RegisterBlizzardEditModeHost and runeHorizFrame then
        ns.RegisterBlizzardEditModeHost(runeHorizFrame, "runeHoriz", { kind = "runeHoriz" })
    end

    RunePointsFrame = CreateFrame("Frame", "rOrbsRunePointsFrame", UIParent)
    RunePointsFrame:RegisterEvent("UNIT_POWER_FREQUENT")
    RunePointsFrame:RegisterEvent("RUNE_POWER_UPDATE")
    RunePointsFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    RunePointsFrame:RegisterEvent("PLAYER_TALENT_UPDATE")
    RunePointsFrame:RegisterEvent("UNIT_ENTERED_VEHICLE")
    RunePointsFrame:RegisterEvent("UNIT_EXITED_VEHICLE")
    RunePointsFrame:RegisterEvent("UNIT_DISPLAYPOWER")

    RunePointsFrame:SetScript("OnEvent", function(self, event, unit)
        if event == "UNIT_POWER_FREQUENT" then
            if unit ~= "player" then return end
            OnPowerUpdate()
        elseif event == "RUNE_POWER_UPDATE" then
            if currentIsDK then OnPowerUpdate() end
        else
            if event == "UNIT_DISPLAYPOWER" and unit ~= "player" then return end
            ForceRefresh()
        end
    end)

    C_Timer.After(1.0, ForceRefresh)

    ns.RunePoints = {
        ForceRefresh       = ForceRefresh,
        PositionRunes      = function()
            local _, max = GetClassPowerInfo()
            PositionRunes(max or currentMax or 0)
        end,
        UpdateBGVisibility = UpdateBGVisibility,
        ShowAll            = ShowAllPreview,
        StopPreview        = StopPreview,
        HideAll            = HideAll,
        ApplyRuneHorizLayout = ApplyRuneHorizLayout,
    }
end

local boot = CreateFrame("Frame")
boot:RegisterEvent("PLAYER_LOGIN")
boot:SetScript("OnEvent", function(self)
    self:UnregisterAllEvents()
    C_Timer.After(0.5, Initialize)
end)
