-- =============================================================
--  rCastbar -- Standalone Spell Castbar Addon
--  Author : Imithat (WoWInterface.com) - selias2k (curseforge.com)
--  Version: 1.0.6
--  WoW    : Retail 12.0 (Interface 120001)
--  Lua    : 5.1  (WoW embedded)
-- =============================================================

local ADDON_NAME = ...
local L          -- set after ADDON_LOADED from rCastbar_L

local MEDIA = "Interface\\AddOns\\rCastbar\\media\\"
local MEDIA_PROTECT = MEDIA .. "protect.tga"
-- Center overlay on protected target cast: ~30% larger than bar height
local PROTECT_MARK_SCALE = 1.3
-- Blizzard-Schild (wie frueher am Target-Castbar)
local TEXTURE_CASTBAR_SHIELD = [[Interface\CastingBar\UI-CastingBar-Small-Shield]]

local floor = math.floor
local max   = math.max
local wipe  = table.wipe

-- Snap-to-grid: 10 px grid when manual drag is active
local SNAP_GRID      = 10
local dragGridFrame  = nil

local function SnapCoordToGrid(v, grid)
    grid = grid or SNAP_GRID
    return floor(v / grid + 0.5) * grid
end

local function ShowDragGrid()
    if dragGridFrame then return end
    dragGridFrame = CreateFrame("Frame", nil, UIParent)
    dragGridFrame:SetAllPoints(UIParent)
    dragGridFrame:SetFrameStrata("BACKGROUND")
    local scale = 64
    local screenW = GetScreenWidth()
    local screenH = GetScreenHeight()
    local numV = scale
    local numH = floor(screenH / screenW * scale)
    local offV = screenW / numV
    local offH = screenH / numH
    for i = 1, numV - 1 do
        local line = dragGridFrame:CreateLine()
        line:SetThickness(1)
        line:SetColorTexture(1, 1, 1, 0.3)
        line:SetStartPoint("TOPLEFT", i * offV, 0)
        line:SetEndPoint("BOTTOMLEFT", i * offV, 0)
    end
    for i = 1, numH - 1 do
        local line = dragGridFrame:CreateLine()
        line:SetThickness(1)
        line:SetColorTexture(1, 1, 1, 0.3)
        line:SetStartPoint("BOTTOMLEFT", 0, i * offH)
        line:SetEndPoint("BOTTOMRIGHT", 0, i * offH)
    end
    dragGridFrame:Show()
end

local function HideDragGrid()
    if dragGridFrame then
        dragGridFrame:Hide()
        dragGridFrame = nil
    end
end

-- Artwork proportions
local ART_BG_RATIO   = 170 / 265
local ART_GLOW_RATIO =  35 / 265
local ART_BG_TOP     =  24.9
local ART_BG_BOT     = -24.2
local ART_GLOW_TOP   =  26.5
local ART_GLOW_BOT   = -24.2

-- Default configuration
local defaults = {
    player = {
        enabled        = true,
        width          = 265,
        height         = 15,
        point          = "BOTTOM",
        relPoint       = "BOTTOM",
        relFrame       = "UIParent",
        x              = 0,
        y              = 181,
        fontSize       = 11,
        latency        = true,
        timeToHold     = 0.5,
        -- Success Flash
        castSuccess    = true,
        successColor   = { r = 0,   g = 0.8, b = 0,   a = 1.0 },
        -- Fade
        fadeEnable     = true,
        fadeInTime     = 0.15,
        fadeOutTime    = 0.30,
        -- Dynamic Width
        dynamicWidth   = false,
        -- Icon / Text
        showIcon       = true,
        showSpellName  = true,
        showTime       = true,
        -- Colors
        classColor     = false,
        color          = { r = 0,   g = 0.5, b = 1,   a = 0.8 },
        bgColor        = { r = 0.1, g = 0.1, b = 0.1, a = 0.7 },
        empowerTickColor = { r = 1, g = 1, b = 1, a = 1.0 },
        failColor      = { r = 1,   g = 0,   b = 0,   a = 1.0 },
    },
    target = {
        enabled        = true,
        width          = 265,
        height         = 15,
        point          = "TOP",
        relPoint       = "TOP",
        relFrame       = "UIParent",
        x              = -10,
        y              = -125,
        fontSize       = 11,
        timeToHold     = 0.5,
        castSuccess    = true,
        successColor   = { r = 0,   g = 0.8, b = 0,   a = 1.0 },
        fadeEnable     = true,
        fadeInTime     = 0.15,
        fadeOutTime    = 0.30,
        dynamicWidth   = false,
        showIcon       = true,
        showSpellName  = true,
        showTime       = true,
        classColor     = false,
        color          = { r = 0,   g = 0.5, b = 1,   a = 0.7 },
        bgColor        = { r = 0.1, g = 0.1, b = 0.1, a = 1.0 },
        empowerTickColor = { r = 1, g = 1, b = 1, a = 1.0 },
        shieldColor    = { r = 0.5, g = 0.5, b = 0.5, a = 1.0 },
        shieldBgColor  = { r = 0.1, g = 0.1, b = 0.1, a = 0.7 },
        failColor      = { r = 1,   g = 0,   b = 0,   a = 1.0 },
    },
}

-- Helpers

local function deepMerge(target, source)
    for k, v in pairs(source) do
        if target[k] == nil then
            if type(v) == "table" then
                target[k] = {}
                deepMerge(target[k], v)
            else
                target[k] = v
            end
        elseif type(v) == "table" and type(target[k]) == "table" then
            deepMerge(target[k], v)
        end
    end
end

local function mkFont(parent, size)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    if not fs:SetFont(MEDIA .. "Expressway.ttf", size, "THINOUTLINE") then
        fs:SetFont("Fonts\\FRIZQT__.TTF", size, "THINOUTLINE")
    end
    fs:SetShadowColor(0, 0, 0, 1)
    fs:SetShadowOffset(1, -1)
    return fs
end

local function updateArtwork(bar, w)
    local bgOver   = w * ART_BG_RATIO
    local glowOver = w * ART_GLOW_RATIO

    bar.background:ClearAllPoints()
    bar.background:SetPoint("TOP",    bar, "TOP",    0,        ART_BG_TOP)
    bar.background:SetPoint("LEFT",   bar, "LEFT",  -bgOver,   0)
    bar.background:SetPoint("RIGHT",  bar, "RIGHT",  bgOver,   0)
    bar.background:SetPoint("BOTTOM", bar, "BOTTOM", 0,        ART_BG_BOT)

    bar.glow:ClearAllPoints()
    bar.glow:SetPoint("TOP",    bar, "TOP",    0,          ART_GLOW_TOP)
    bar.glow:SetPoint("LEFT",   bar, "LEFT",  -glowOver,   0)
    bar.glow:SetPoint("RIGHT",  bar, "RIGHT",  glowOver,   0)
    bar.glow:SetPoint("BOTTOM", bar, "BOTTOM", 0,          ART_GLOW_BOT)

    bar.highlight:ClearAllPoints()
    bar.highlight:SetPoint("TOP",    bar, "TOP",    0,          ART_GLOW_TOP)
    bar.highlight:SetPoint("LEFT",   bar, "LEFT",  -glowOver,   0)
    bar.highlight:SetPoint("RIGHT",  bar, "RIGHT",  glowOver,   0)
    bar.highlight:SetPoint("BOTTOM", bar, "BOTTOM", 0,          ART_GLOW_BOT)
end

local function suppressBlizzardCastbars()
    -- Only UnregisterAllEvents + Hide. Never overwrite .Show on secure Blizzard frames:
    -- assigning a plain Lua function to .Show taints the frame permanently, which
    -- propagates into Blizzard secure code (EditMode -> RefreshEncounterEvents) and
    -- causes "attempt to compare a secret number value (tainted by rCastbar)".
    if PlayerCastingBarFrame then
        PlayerCastingBarFrame:UnregisterAllEvents()
        PlayerCastingBarFrame:Hide()
    end
    if PetCastingBarFrame then
        PetCastingBarFrame:UnregisterAllEvents()
        PetCastingBarFrame:Hide()
    end
end

-- Bar state

local function resetBarState(bar)
    bar.casting          = false
    bar.channeling       = false
    bar.empowering       = false
    bar.castID           = nil
    bar.spellID          = nil
    bar.notInterruptible = false
    bar.holdTime         = 0
    bar.delay            = 0
    bar.max              = 0
    bar.numStages        = nil
    bar.curStage         = 0
    bar.successHold      = 0
    bar.failHold         = 0
    bar.duration_obj     = nil
    bar._useTimerDuration = false
    wipe(bar.stagePoints)
    if bar.ProtectMark then bar.ProtectMark:Hide() end
end

-- Fade helpers

local function fadeIn(bar)
    local cfg = bar.cfg
    if not cfg.fadeEnable then
        bar:SetAlpha(1)
        return
    end
    bar:SetAlpha(0)
    bar.fadeTime   = 0
    bar.fadingIn   = true
    bar.fadingOut  = false
    bar.fadeTarget = cfg.fadeInTime
end

local function fadeOut(bar)
    local cfg = bar.cfg
    if not cfg.fadeEnable then
        bar:Hide()
        return
    end
    bar.fadeTime   = 0
    bar.fadingIn   = false
    bar.fadingOut  = true
    bar.fadeTarget = cfg.fadeOutTime
end

-- Class color helper

local function getClassColor(unit)
    local _, classFile = UnitClass(unit)
    if not classFile then return nil end
    local cc = RAID_CLASS_COLORS[classFile]
    if not cc then return nil end
    return cc.r, cc.g, cc.b
end

-- Apply bar color

local function applyBarColor(bar)
    local cfg = bar.cfg
    local r, g, b = cfg.color.r, cfg.color.g, cfg.color.b
    if cfg.classColor then
        local cr, cg, cb = getClassColor(bar.unit)
        if cr then r, g, b = cr, cg, cb end
    end
    bar:SetStatusBarColor(r, g, b, cfg.color.a)
    if bar.bg then bar.bg:SetVertexColor(cfg.bgColor.r, cfg.bgColor.g, cfg.bgColor.b, cfg.bgColor.a) end
end

-- Success flash

local SUCCESS_HOLD = 0.45
local hideEmpowerStageTicks
local setupEmpowerStageTicks
local updateEmpowerStageVisuals

local function showSuccess(bar)
    local cfg = bar.cfg
    if not cfg.castSuccess then
        bar._useTimerDuration = false
        bar.duration_obj = nil
        resetBarState(bar)
        bar:SetMinMaxValues(0, 1)
        bar:SetValue(0)
        fadeOut(bar)
        return
    end
    bar:SetReverseFill(false)
    bar._useTimerDuration = false
    bar.duration_obj = nil
    bar:SetMinMaxValues(0, 1)
    bar:SetValue(1)
    local sc = cfg.successColor
    bar:SetStatusBarColor(sc.r, sc.g, sc.b, sc.a)
    if bar.bg then
        bar.bg:SetVertexColor(sc.r * 0.25, sc.g * 0.25, sc.b * 0.25, cfg.bgColor.a)
    end
    if bar.Spark then bar.Spark:Hide() end
    if bar.Time  then bar.Time:SetText("") end
    resetBarState(bar)
    hideEmpowerStageTicks(bar)
    bar.successHold = SUCCESS_HOLD
end

-- Protected target cast: fixed grey fill, icon = Blizzard shield — independent of shieldColor/shieldBgColor
-- Spell icons use TexCoord crop below; UI shield needs full rect. SetTimerDuration resets fill color —
-- reapply in onUpdate while the protected cast runs.

local SPELL_ICON_TEXCOORD = { 0.08, 0.92, 0.08, 0.92 }

local function getUnitCastIconTexture(unit)
    local name, _, texture = UnitCastingInfo(unit)
    if name and texture then return texture end
    name, _, texture = UnitChannelInfo(unit)
    if name and texture then return texture end
    return nil
end

local function setSpellIconTexCoord(icon)
    if not icon then return end
    local c = SPELL_ICON_TEXCOORD
    icon:SetTexCoord(c[1], c[2], c[3], c[4])
end

-- Like RothBlizzPlates / 12.x: Blizzard target castbar may return plain booleans when UnitCastingInfo is secret.
local function IsPlainBoolean(v)
    if type(v) ~= "boolean" then return false end
    if type(issecretvalue) == "function" then
        local ok, sec = pcall(issecretvalue, v)
        if ok and sec then return false end
    end
    return true
end

-- Castbar frame: .notInterruptible (plain) or shield layer.
-- NamePlateKAI et al.: BorderShield stays :Show() each cast; alpha 0/1 by protection — check alpha.
local function readCastBarProtectedFromUI(container)
    if not container then return nil end
    local ok, r = pcall(function()
        if container.notInterruptible ~= nil and IsPlainBoolean(container.notInterruptible) then
            return container.notInterruptible
        end
        local shield = container.BorderShield or container.Shield or container.NotInterruptible
        if not shield or not shield.IsShown then
            return nil
        end
        local oks, sh = pcall(shield.IsShown, shield)
        if not (oks and sh) then
            return nil
        end
        local oka, a = pcall(shield.GetAlpha, shield)
        local alpha = (oka and type(a) == "number") and a or 1
        if alpha > 0.5 then
            return true
        end
        if alpha <= 0.01 then
            return false
        end
        return nil
    end)
    if ok then return r end
    return nil
end

-- Do not wrap GetChildren() in {}; use select(i, ...) only. Some script objects lack :GetNumChildren() (bad self) — all via pcall.
local function forEachNameplateDescendant(frame, maxDepth, visitor, depth)
    depth = depth or 0
    if not frame or depth > maxDepth then
        return
    end
    local okN, n = pcall(function()
        return frame:GetNumChildren()
    end)
    if not okN or type(n) ~= "number" or n < 1 then
        return
    end
    for i = 1, n do
        local okCh, ch = pcall(function()
            return select(i, frame:GetChildren())
        end)
        if okCh and ch then
            visitor(ch)
            forEachNameplateDescendant(ch, maxDepth, visitor, depth + 1)
        end
    end
end

-- Target frame castbar (may be missing if other addons hide it or showTargetCastbar=0)
local function notInterruptibleFromBlizzardTargetCastUI()
    local candidates = {
        _G.TargetFrameSpellBar,
        _G.TargetFrame and _G.TargetFrame.SpellBar,
        _G.TargetFrameSpellBarFrame,
    }
    local sawFalse = false
    for i = 1, #candidates do
        local st = readCastBarProtectedFromUI(candidates[i])
        if st == true then return true end
        if st == false then sawFalse = true end
    end
    if sawFalse then return false end
    return nil
end

local function notInterruptibleFromTargetNameplateCastUI()
    if not (C_NamePlate and C_NamePlate.GetNamePlateForUnit) then return nil end
    local ok, np = pcall(C_NamePlate.GetNamePlateForUnit, "target")
    if not ok or not np then return nil end
    local uf = np.UnitFrame or np.unitFrame
    if not uf then return nil end
    local sawTrue, sawFalse = false, false
    local function absorb(st)
        if st == true then sawTrue = true end
        if st == false then sawFalse = true end
    end
    local keys = {
        "castBar", "CastBar", "CastingBar", "castingBar", "CastBarFrame",
        "spellBar", "SpellBar",
    }
    for i = 1, #keys do
        absorb(readCastBarProtectedFromUI(uf[keys[i]]))
    end
    forEachNameplateDescendant(uf, 5, function(ch)
        absorb(readCastBarProtectedFromUI(ch))
    end)
    if sawTrue then return true end
    if sawFalse then return false end
    return nil
end

-- Target frame castbar and/or nameplate castbar (OR true; false if any source says no)
local function resolveBlizzardTargetProtectedState()
    local tf = notInterruptibleFromBlizzardTargetCastUI()
    local np = notInterruptibleFromTargetNameplateCastUI()
    if tf == true or np == true then return true end
    if tf == false or np == false then return false end
    return nil
end

-- Never expose secret booleans from UnitCastingInfo or compare them to true.
-- Return only real Lua boolean or nil (after issecretvalue check in pcall).
local function plainCastNotInterruptibleFlag(v)
    local ok, tag = pcall(function()
        if type(issecretvalue) ~= "function" then return "n" end
        local okt, tv = pcall(type, v)
        if not okt or tv ~= "boolean" then return "n" end
        local oks, sec = pcall(issecretvalue, v)
        if not oks or sec then return "n" end
        if v == true then return "t" end
        if v == false then return "f" end
        return "n"
    end)
    if not ok or tag == nil or tag == "n" then return nil end
    if tag == "t" then return true end
    if tag == "f" then return false end
    return nil
end

local function layoutTargetProtectMark(bar)
    if not bar.ProtectMark then return end
    local h = bar:GetHeight()
    if not h or h <= 0 then h = (bar.cfg and bar.cfg.height) or 15 end
    local sz = max(1, floor(h * PROTECT_MARK_SCALE + 0.5))
    bar.ProtectMark:SetSize(sz, sz)
    bar.ProtectMark:ClearAllPoints()
    bar.ProtectMark:SetPoint("CENTER", bar, "CENTER", 0, 0)
end

-- Visual refresh only (no applyBarColor) — for onUpdate with SetTimerDuration
local function paintTargetProtectedCastLook(bar)
    local cfg = bar.cfg
    local gr, gg, gb = 0.55, 0.55, 0.55
    bar:SetStatusBarColor(gr, gg, gb, 1)
    if bar.bg then
        bar.bg:SetVertexColor(0.22, 0.22, 0.22, cfg.bgColor and cfg.bgColor.a or 0.85)
    end
    if bar.Spark then bar.Spark:SetVertexColor(0.8, 0.8, 0.8, 1) end
    if bar.background then bar.background:SetDesaturated(true) end
    if bar.Icon and cfg.showIcon then
        bar.Icon:SetTexture(TEXTURE_CASTBAR_SHIELD)
        bar.Icon:SetTexCoord(0, 1, 0, 1)
        bar.Icon:Show()
    end
    if bar.ProtectMark then
        layoutTargetProtectMark(bar)
        bar.ProtectMark:Show()
    end
end

local function applyShieldColor(bar, shielded)
    if bar.unit ~= "target" then return end
    local cfg = bar.cfg
    if shielded then
        paintTargetProtectedCastLook(bar)
    else
        applyBarColor(bar)
        if bar.Spark then bar.Spark:SetVertexColor(0.8, 0.6, 0, 1) end
        if bar.background then bar.background:SetDesaturated(false) end
        if bar.Icon then
            if cfg.showIcon then
                local tex = getUnitCastIconTexture(bar.unit)
                if tex then bar.Icon:SetTexture(tex) end
                setSpellIconTexCoord(bar.Icon)
                bar.Icon:Show()
            else
                bar.Icon:Hide()
            end
        end
        if bar.ProtectMark then bar.ProtectMark:Hide() end
    end
end

local function pollTargetNotInterruptible(bar)
    if bar.unit ~= "target" or not bar:IsShown() then return end
    if not (bar.casting or bar.channeling or bar.empowering) then return end
    local name, notInterruptible
    if bar.channeling or bar.empowering then
        name, _, _, _, _, _, notInterruptible = UnitChannelInfo(bar.unit)
    else
        name, _, _, _, _, _, _, notInterruptible = UnitCastingInfo(bar.unit)
    end
    if not name then return end
    local plain = plainCastNotInterruptibleFlag(notInterruptible)
    local v
    if plain == true then v = true elseif plain == false then v = false else v = nil end
    local blizz = resolveBlizzardTargetProtectedState()
    if blizz == true then
        v = true
    elseif blizz == false and v == nil then
        v = false
    end
    if v == nil then return end
    if v ~= bar.notInterruptible then
        bar.notInterruptible = v
        applyShieldColor(bar, v)
    end
end

-- Helper: adjust Text RIGHT anchor based on showTime setting
local function updateTextAnchor(bar)
    local rightOfs = (bar.cfg.showTime ~= false) and -55 or -5
    bar.Text:ClearAllPoints()
    bar.Text:SetPoint("LEFT",  bar, "LEFT",  5,        0)
    bar.Text:SetPoint("RIGHT", bar, "RIGHT", rightOfs, 0)
end

hideEmpowerStageTicks = function(bar)
    if bar.empTickPool then
        for _, tick in ipairs(bar.empTickPool) do
            tick:Hide()
        end
    end
end

local function applyEmpowerTickColor(bar, tick)
    local c = bar.cfg.empowerTickColor or { r = 0, g = 0, b = 0, a = 1.0 }
    tick:SetTextColor(c.r or 1, c.g or 1, c.b or 1, c.a or 1)
end

local function toRoman(num)
    local map = {
        {1000, "M"}, {900, "CM"}, {500, "D"}, {400, "CD"},
        {100, "C"}, {90, "XC"}, {50, "L"}, {40, "XL"},
        {10, "X"}, {9, "IX"}, {5, "V"}, {4, "IV"}, {1, "I"},
    }
    local n = math.max(1, math.floor(num or 1))
    local out = {}
    for i = 1, #map do
        local v, s = map[i][1], map[i][2]
        while n >= v do
            n = n - v
            out[#out + 1] = s
        end
    end
    return table.concat(out)
end

local function getEmpowerProgress(bar)
    if bar.duration_obj then
        local okE, elapsed = pcall(bar.duration_obj.GetElapsedDuration, bar.duration_obj, 0)
        local okT, total = pcall(bar.duration_obj.GetTotalDuration, bar.duration_obj, 0)
        -- issecretvalue before total>0 and division — avoids errors on secret numbers (e.g. target empower)
        local safeNums = not issecretvalue or (not issecretvalue(elapsed) and not issecretvalue(total))
        if okE and okT and elapsed and total and safeNums and total > 0 then
            return math.min(math.max(elapsed / total, 0), 1)
        end
    end
    local minV, maxV = bar:GetMinMaxValues()
    local val = bar:GetValue()
    if type(minV) == "number" and type(maxV) == "number" and type(val) == "number" and maxV > minV then
        return math.min(math.max((val - minV) / (maxV - minV), 0), 1)
    end
    return 0
end

updateEmpowerStageVisuals = function(bar)
    if not bar.empowering or not bar.empStageThresholds or not bar.empTickPool then return end
    local progress = getEmpowerProgress(bar)
    local c = bar.cfg.empowerTickColor or { r = 0, g = 0, b = 0, a = 1.0 }
    for i, fs in ipairs(bar.empTickPool) do
        local reached = progress >= (bar.empStageThresholds[i] or 1)
        local alphaMul = reached and 1.0 or 0.0
        fs:SetTextColor(c.r or 1, c.g or 1, c.b or 1, (c.a or 1) * alphaMul)
        fs:Show()
    end
end

setupEmpowerStageTicks = function(bar)
    if not UnitEmpoweredStagePercentages then return end

    local rawStages = UnitEmpoweredStagePercentages(bar.unit, true)
    if not rawStages or #rawStages == 0 then
        rawStages = UnitEmpoweredStagePercentages(bar.unit)
    end
    if not rawStages or #rawStages == 0 then
        hideEmpowerStageTicks(bar)
        return
    end

    local thresholds = {}
    local cumulative = 0
    for i = 1, #rawStages do
        local v = tonumber(rawStages[i]) or 0
        cumulative = cumulative + v
        thresholds[i] = math.min(math.max(cumulative, 0), 1)
    end

    local numStages = #thresholds
    if numStages < 1 then
        hideEmpowerStageTicks(bar)
        return
    end

    if not bar.empTickPool then
        bar.empTickPool = {}
    end
    bar.empStageThresholds = bar.empStageThresholds or {}
    hideEmpowerStageTicks(bar)

    local barWidth = bar:GetWidth()
    for i = 1, numStages do
        local tick = bar.empTickPool[i]
        if not tick then
            tick = bar:CreateFontString(nil, "OVERLAY")
            local romanSize = math.max(12, math.floor((bar.cfg.height or bar:GetHeight() or 15) * 0.9))
            if not tick:SetFont("Fonts\\FRIZQT__.TTF", romanSize, "OUTLINE") then
                tick:SetFont(MEDIA .. "Expressway.ttf", romanSize, "OUTLINE")
            end
            tick:SetShadowColor(0, 0, 0, 1)
            tick:SetShadowOffset(1, -1)
            bar.empTickPool[i] = tick
        end
        local prev = (i == 1) and 0 or (thresholds[i - 1] or 0)
        local cur = thresholds[i] or 1
        local center = ((prev + cur) * 0.5) * barWidth
        tick:ClearAllPoints()
        tick:SetPoint("CENTER", bar, "LEFT", center, 0)
        tick:SetText(toRoman(i))
        applyEmpowerTickColor(bar, tick)
        bar.empStageThresholds[i] = cur
    end
    for i = numStages + 1, #bar.empTickPool do
        bar.empTickPool[i]:Hide()
        bar.empStageThresholds[i] = nil
    end
    updateEmpowerStageVisuals(bar)
end

-- OnUpdate

local function onUpdate(self, elapsed)
    -- Fade-In
    if self.fadingIn then
        self.fadeTime = self.fadeTime + elapsed
        local pct = math.min(self.fadeTime / self.fadeTarget, 1)
        self:SetAlpha(pct)
        if pct >= 1 then self.fadingIn = false end
    end

    -- Success-Hold
    if self.successHold and self.successHold > 0 then
        self.successHold = self.successHold - elapsed
        if self.successHold <= 0 then
            self.successHold = 0
            fadeOut(self)
        end
        return
    end

    -- Failed-Hold -- checked BEFORE fadingOut so it can cancel a running fade
    if self.failHold and self.failHold > 0 then
        self.fadingOut = false
        self.fadingIn  = false
        self:SetAlpha(1)
        self.failHold = self.failHold - elapsed
        if self.failHold <= 0 then
            self.failHold = 0
            resetBarState(self)
            hideEmpowerStageTicks(self)
            fadeOut(self)
        end
        return
    end

    -- Fade-Out
    if self.fadingOut then
        self.fadeTime = self.fadeTime + elapsed
        local pct = math.min(self.fadeTime / self.fadeTarget, 1)
        self:SetAlpha(1 - pct)
        if pct >= 1 then
            self.fadingOut = false
            self:SetAlpha(1)
            self:Hide()
        end
        return
    end

    -- Preview mode: static display, skip all cast logic
    if self.previewMode then return end

    -- Bar fill: SetTimerDuration aktualisiert intern; v5.9 braucht SetValue(nowMs)
    if self._useTimerDuration then
        -- Bar updated internally by StatusBar; only Duration for time text
        if self.duration_obj then
            local ok_e, elapsed = pcall(self.duration_obj.GetElapsedDuration, self.duration_obj, 0)
            if ok_e and elapsed and not (issecretvalue and issecretvalue(elapsed)) then
                self.duration = elapsed
            end
        end
    else
        -- v5.9: SetValue(nowMs) with MinMax(startMs,endMs)
        local nowMs = GetTime() * 1000
        if Enum.StatusBarInterpolation then
            self:SetValue(nowMs, Enum.StatusBarInterpolation.ExponentialEaseOut)
        else
            self:SetValue(nowMs)
        end
        if self.duration_obj then
            local ok_e, elapsed = pcall(self.duration_obj.GetElapsedDuration, self.duration_obj, 0)
            if ok_e and elapsed and not (issecretvalue and issecretvalue(elapsed)) then
                self.duration = elapsed
            end
        end
    end

    -- Time text
    if self.Time and self.cfg.showTime ~= false then
        if self.delay and self.delay > 0 then
            local sign = (self.casting or self.empowering) and "+" or "-"
            self.Time:SetFormattedText("%.1f|cffff0000%s%.2f|r", self.duration, sign, self.delay)
        else
            self.Time:SetFormattedText("%.1f", self.duration)
        end
    end

    -- Spark
    if self.Spark then
        self.Spark:ClearAllPoints()
        self.Spark:SetPoint("CENTER", self:GetStatusBarTexture(), "RIGHT", 0, 0)
    end

    if self.empowering then
        updateEmpowerStageVisuals(self)
    end

    -- Target + geschuetzter Cast: Fill/Icon gegen SetTimerDuration-Zuruecksetzen halten; API nachziehen
    if self.unit == "target" then
        self._niPoll = (self._niPoll or 0) + elapsed
        if self._niPoll >= 0.1 then
            self._niPoll = 0
            pollTargetNotInterruptible(self)
        end
        if self.notInterruptible and (self.casting or self.channeling or self.empowering) then
            paintTargetProtectedCastLook(self)
        end
    end

    -- Safety hide: Cast abgebrochen (z.B. durch Bewegung) – UnitCastingInfo nil
    -- Normal casts only, not channel/empowered (nil between ticks possible)
    self._safetyAccum = (self._safetyAccum or 0) + elapsed
    if self._safetyAccum >= 0.1 then
        self._safetyAccum = 0
        if self.casting and not self.empowering and not self.channeling then
            if not UnitCastingInfo(self.unit) then
                resetBarState(self)
                hideEmpowerStageTicks(self)
                fadeOut(self)
            end
        end
    end
end

-- Event dispatch (before buildCastbar so closure can see it)
local eventDispatch

-- Build castbar frame

local function buildCastbar(unit, cfg)
    local baseName = "rCastbar_" .. (unit:gsub("^%l", string.upper))

    local container = CreateFrame("Frame", baseName .. "_Container", UIParent)
    container:SetSize(cfg.width + 20, cfg.height + 50)
    container:SetPoint(cfg.point, _G[cfg.relFrame] or UIParent, cfg.relPoint, cfg.x, cfg.y)
    container:SetMovable(true)
    container:SetUserPlaced(true)
    container:SetClampedToScreen(true)
    container:EnableMouse(false)
    container.dragging = false
    container._snapDragActive = false
    container:RegisterForDrag("LeftButton")
    container:SetScript("OnUpdate", function(f)
        if not f._snapDragActive then return end
        if not (rCastbarDB and rCastbarDB.snapToGrid and f._movePoint and f._moveRel) then return end
        local uisc = UIParent:GetEffectiveScale()
        local cx, cy = GetCursorPosition()
        cx, cy = cx / uisc, cy / uisc
        local nx = SnapCoordToGrid(f._moveStartAX + (cx - f._moveStartCX))
        local ny = SnapCoordToGrid(f._moveStartAY + (cy - f._moveStartCY))
        f:ClearAllPoints()
        f:SetPoint(f._movePoint, f._moveRel, f._moveRelPoint, nx, ny)
    end)
    container:SetScript("OnDragStart", function(f)
        if IsShiftKeyDown() then
            f.dragging = true
            f:EnableMouse(true)  -- ensure mouse stays on during drag
            if rCastbarDB and rCastbarDB.snapToGrid then
                local p, rt, rp, ax, ay = f:GetPoint(1)
                if p and ax ~= nil and ay ~= nil then
                    f._snapDragActive = true
                    f._moveRel = rt or UIParent
                    f._movePoint = p
                    f._moveRelPoint = rp
                    f._moveStartAX, f._moveStartAY = ax, ay
                    local uisc = UIParent:GetEffectiveScale()
                    local cx, cy = GetCursorPosition()
                    f._moveStartCX = cx / uisc
                    f._moveStartCY = cy / uisc
                    ShowDragGrid()
                    return
                end
            end
            f._snapDragActive = false
            if rCastbarDB and rCastbarDB.snapToGrid then ShowDragGrid() end
            f:StartMoving()
        end
    end)
    container:SetScript("OnDragStop", function(f)
        f.dragging = false
        local wasSnap = f._snapDragActive
        f._snapDragActive = false
        f:StopMovingOrSizing()
        if wasSnap then
            HideDragGrid()
            local point, _, relPoint, x, y = f:GetPoint()
            if rCastbarDB and rCastbarDB[unit] then
                rCastbarDB[unit].point    = point    or rCastbarDB[unit].point
                rCastbarDB[unit].relPoint = relPoint or rCastbarDB[unit].relPoint
                rCastbarDB[unit].x        = floor((x or 0) + 0.5)
                rCastbarDB[unit].y        = floor((y or 0) + 0.5)
            end
        elseif rCastbarDB and rCastbarDB.snapToGrid then
            HideDragGrid()
            C_Timer.After(0, function()
                if not f or not f.GetPoint then return end
                local p, rt, rp, x, y = f:GetPoint(1)
                if not (p and x ~= nil and y ~= nil) then return end
                local rel = rt or UIParent
                local nx, ny = SnapCoordToGrid(x), SnapCoordToGrid(y)
                if nx ~= x or ny ~= y then
                    f:ClearAllPoints()
                    f:SetPoint(p, rel, rp, nx, ny)
                end
                local point, _, relPoint, ox, oy = f:GetPoint()
                if rCastbarDB and rCastbarDB[unit] then
                    rCastbarDB[unit].point    = point    or rCastbarDB[unit].point
                    rCastbarDB[unit].relPoint = relPoint or rCastbarDB[unit].relPoint
                    rCastbarDB[unit].x        = floor((ox or 0) + 0.5)
                    rCastbarDB[unit].y        = floor((oy or 0) + 0.5)
                end
            end)
        else
            HideDragGrid()
            local point, _, relPoint, x, y = f:GetPoint()
            if rCastbarDB and rCastbarDB[unit] then
                rCastbarDB[unit].point    = point    or rCastbarDB[unit].point
                rCastbarDB[unit].relPoint = relPoint or rCastbarDB[unit].relPoint
                rCastbarDB[unit].x        = floor((x or 0) + 0.5)
                rCastbarDB[unit].y        = floor((y or 0) + 0.5)
            end
        end
        -- If bar is hidden and not in preview, release mouse
        local b = _G[baseName]
        if b and not b:IsShown() then
            f:EnableMouse(false)
        end
    end)

    local bar = CreateFrame("StatusBar", baseName, container)
    bar:SetSize(cfg.width, cfg.height)
    bar:SetPoint("CENTER", container, 0, 0)
    bar:SetFrameStrata("HIGH")
    bar:SetStatusBarTexture(MEDIA .. "statusbar3")
    bar:SetStatusBarColor(cfg.color.r, cfg.color.g, cfg.color.b, cfg.color.a)
    bar:SetMinMaxValues(0, 1)
    bar:SetValue(0)

    -- Artwork
    bar.background = bar:CreateTexture(nil, "OVERLAY", nil, -8)
    bar.background:SetTexture(MEDIA .. "castbar")

    bar.bg = bar:CreateTexture(nil, "BACKGROUND", nil, -6)
    bar.bg:SetTexture(MEDIA .. "statusbar3")
    bar.bg:SetAllPoints(bar)
    bar.bg:SetVertexColor(cfg.bgColor.r, cfg.bgColor.g, cfg.bgColor.b, cfg.bgColor.a)

    bar.glow = bar:CreateTexture(nil, "OVERLAY", nil, -4)
    bar.glow:SetTexture(MEDIA .. "castbar_glow")
    bar.glow:SetVertexColor(0, 0, 0, 1)

    bar.highlight = bar:CreateTexture(nil, "OVERLAY", nil, -3)
    bar.highlight:SetTexture(MEDIA .. "castbar_highlight")

    updateArtwork(bar, cfg.width)
    bar.UpdateArtwork = function(self, w)
        updateArtwork(self, w)
        if self.ProtectMark and self.ProtectMark:IsShown() then
            layoutTargetProtectMark(self)
        end
    end

    -- Spark
    bar.Spark = bar:CreateTexture(nil, "OVERLAY", nil, -7)
    bar.Spark:SetBlendMode("ADD")
    bar.Spark:SetVertexColor(0.8, 0.6, 0, 1)
    bar.Spark:SetSize(20, 30)
    bar.Spark:Hide()

    -- Icon
    bar.Icon = bar:CreateTexture(nil, "OVERLAY", nil, -6)
    bar.Icon:SetSize(cfg.height + 4, cfg.height + 4)
    bar.Icon:SetPoint("RIGHT", bar, "LEFT", -3, 0)
    bar.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    if cfg.showIcon then bar.Icon:Show() else bar.Icon:Hide() end

    -- Spell Name
    bar.Text = mkFont(bar, cfg.fontSize)
    -- RIGHT anchor: leave room for time text, or extend if time is hidden
    bar.Text:SetPoint("LEFT",  bar, "LEFT",  5, 0)
    bar.Text:SetPoint("RIGHT", bar, "RIGHT", (cfg.showTime ~= false) and -55 or -5, 0)
    bar.Text:SetJustifyH("LEFT")
    bar.Text:SetWordWrap(false)
    if cfg.showSpellName == false then bar.Text:Hide() end

    -- Time
    bar.Time = mkFont(bar, cfg.fontSize)
    bar.Time:SetPoint("RIGHT", bar, "RIGHT", -2, 0)
    bar.Time:SetJustifyH("RIGHT")
    if cfg.showTime == false then bar.Time:Hide() end

    if unit == "target" then
        bar.ProtectMark = bar:CreateTexture(nil, "OVERLAY", nil, 5)
        bar.ProtectMark:SetTexture(MEDIA_PROTECT)
        bar.ProtectMark:SetTexCoord(0, 1, 0, 1)
        local psz = max(1, floor((cfg.height or 15) * PROTECT_MARK_SCALE + 0.5))
        bar.ProtectMark:SetSize(psz, psz)
        bar.ProtectMark:SetPoint("CENTER", bar, "CENTER", 0, 0)
        bar.ProtectMark:Hide()
    end

    -- Safezone (player only)
    if unit == "player" and cfg.latency then
        bar.SafeZone = bar:CreateTexture(nil, "OVERLAY")
        bar.SafeZone:SetTexture(MEDIA .. "statusbar3")
        bar.SafeZone:SetVertexColor(0.6, 0, 0, 0.6)
        bar.SafeZone:SetPoint("TOPRIGHT",    bar, "TOPRIGHT",    0, 0)
        bar.SafeZone:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", 0, 0)
        bar.SafeZone:SetWidth(1)
    end

    -- State
    bar.unit         = unit
    bar.cfg          = cfg
    bar.casting      = false
    bar.channeling   = false
    bar.empowering   = false
    bar.holdTime     = 0
    bar.failHold     = 0
    bar.successHold  = 0
    bar.duration     = 0
    bar.max          = 0
    bar.delay        = 0
    bar.startTime    = 0
    bar.stagePoints  = {}
    bar.empTickPool  = bar.empTickPool or {}
    bar.castID       = nil
    bar.spellID      = nil
    bar.fadingIn     = false
    bar.fadingOut    = false
    bar.fadeTime     = 0
    bar.fadeTarget   = 0
    bar.duration_obj = nil
    bar._useTimerDuration = false
    bar._safetyAccum = 0

    -- Register unit events on the bar — reliable for NPC target
    local events = {
        "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_FAILED",
        "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_INTERRUPTED",
        "UNIT_SPELLCAST_DELAYED", "UNIT_SPELLCAST_CHANNEL_START", "UNIT_SPELLCAST_CHANNEL_STOP",
        "UNIT_SPELLCAST_CHANNEL_UPDATE", "UNIT_SPELLCAST_EMPOWER_START",
        "UNIT_SPELLCAST_EMPOWER_UPDATE", "UNIT_SPELLCAST_EMPOWER_STOP",
        "UNIT_SPELLCAST_INTERRUPTIBLE", "UNIT_SPELLCAST_NOT_INTERRUPTIBLE",
    }
    for _, ev in ipairs(events) do
        bar:RegisterUnitEvent(ev, unit)
    end
    bar:SetScript("OnEvent", function(self, event, ...)
        local handler = eventDispatch[event]
        if handler then handler(self, event, unit, ...) end
    end)

    bar:SetScript("OnUpdate", onUpdate)
    bar:SetScript("OnShow", function(self) self:GetParent():EnableMouse(true) end)
    bar:SetScript("OnHide", function(self)
        local cont = self:GetParent()
        -- Never disable mouse mid-drag: OnDragStop would never fire -> mouse locks up
        if not cont.dragging then
            cont:EnableMouse(false)
        end
    end)
    bar:Hide()
    return bar, container
end

-- Event handlers

local function onCastStart(bar, event, unit)
    if bar.unit ~= unit then return end

    local name, text, texture, startMs, endMs
    local isTradeSkill, castID, notInterruptible, spellID, numStages

    name, text, texture, startMs, endMs,
        isTradeSkill, castID, notInterruptible, spellID = UnitCastingInfo(unit)

    if name then
        bar.casting = true; bar.channeling = false; bar.empowering = false
    else
        name, text, texture, startMs, endMs,
            isTradeSkill, notInterruptible, spellID, _, numStages = UnitChannelInfo(unit)
        if name then
            if numStages and numStages > 0 then
                bar.empowering = true; bar.channeling = false; bar.casting = false
                -- UnitEmpoweredChannelDuration inkl. hold-at-max per Default
            else
                bar.channeling = true; bar.empowering = false; bar.casting = false
            end
        end
    end

    if not name then bar:Hide(); return end

    -- Retail: startMs/endMs sind "secret values" – keine Arithmetik in Lua.
    -- Hybrid: SetTimerDuration when duration_obj exists, else v5.9 (pass C directly).
    local durationObj
    if bar.empowering and UnitEmpoweredChannelDuration then
        durationObj = UnitEmpoweredChannelDuration(unit)
    elseif bar.channeling and UnitChannelDuration then
        durationObj = UnitChannelDuration(unit)
    elseif UnitCastingDuration then
        durationObj = UnitCastingDuration(unit)
    end
    bar.duration_obj = durationObj

    bar.delay        = 0
    bar.holdTime     = 0
    bar.failHold     = 0
    bar.successHold  = 0
    bar.castID       = (issecretvalue and issecretvalue(castID)) and nil or castID
    bar.spellID      = spellID
    local plainNI = plainCastNotInterruptibleFlag(notInterruptible)
    local prevNI  = bar.notInterruptible
    local merged
    if plainNI == true then
        merged = true
    elseif plainNI == false then
        merged = false
    else
        merged = nil
    end
    local blizzNI = (unit == "target") and resolveBlizzardTargetProtectedState() or nil
    if blizzNI == true then
        merged = true
    elseif blizzNI == false and merged == nil then
        merged = false
    end
    -- API often nil (secret); NOT_INTERRUPTIBLE may fire early/late — do not corrupt state
    if merged == nil then
        merged = prevNI
    end
    bar.notInterruptible = merged == true
    wipe(bar.stagePoints)

    bar:SetReverseFill(bar.channeling)

    if durationObj and bar.SetTimerDuration then
        bar._useTimerDuration = true
        local direction = bar.channeling and Enum.StatusBarTimerDirection.RemainingTime or Enum.StatusBarTimerDirection.ElapsedTime
        bar:SetTimerDuration(durationObj, Enum.StatusBarInterpolation.Immediate, direction)
        local ok, total = pcall(durationObj.GetTotalDuration, durationObj, 0)
        bar.max = (ok and total and not (issecretvalue and issecretvalue(total)) and total > 0) and total or 1
        bar.duration = 0
    else
        bar._useTimerDuration = false
        bar:SetMinMaxValues(startMs, endMs)
        bar:SetValue(GetTime() * 1000)
        bar.max = 1
        bar.duration = 0
    end

    -- Spell name
    if bar.Text then
        if bar.cfg.showSpellName ~= false then
            bar.Text:SetText(text or name)
            bar.Text:Show()
        else
            bar.Text:SetText("")
            bar.Text:Hide()
        end
    end

    -- Time
    if bar.Time then
        if bar.cfg.showTime ~= false then
            bar.Time:SetText("")
            bar.Time:Show()
        else
            bar.Time:Hide()
        end
    end

    if bar.Spark then bar.Spark:Show() end

    -- Icon
    if bar.Icon then
        if texture then bar.Icon:SetTexture(texture) end
        if bar.cfg.showIcon then bar.Icon:Show() else bar.Icon:Hide() end
    end

    -- Dynamic Width (only when spell name is visible)
    if bar.cfg.dynamicWidth and bar.Text and bar.cfg.showSpellName ~= false then
        C_Timer.After(0, function()
            if not bar.Text then return end
            local tw = bar.Text:GetStringWidth()
            if tw and tw > 0 and not (issecretvalue and issecretvalue(tw)) then
                local pad  = bar.cfg.showTime ~= false and 70 or 20
                local newW = math.max(bar.cfg.width, math.min(tw + pad, 500))
                bar:SetWidth(newW)
                bar:UpdateArtwork(newW)
                local cont = _G["rCastbar_" .. (bar.unit:sub(1,1):upper()..bar.unit:sub(2)) .. "_Container"]
                if cont then cont:SetWidth(newW + 20) end
            end
        end)
    end

    if bar.empowering then
        setupEmpowerStageTicks(bar)
    else
        hideEmpowerStageTicks(bar)
    end

    -- Colors
    applyBarColor(bar)
    applyShieldColor(bar, bar.notInterruptible)

    -- Target: notInterruptible often readable only after start or slightly delayed (non-secret)
    if bar.unit == "target" then
        local b = bar
        local function deferPoll()
            if b and b:IsShown() and (b.casting or b.channeling or b.empowering) then
                pollTargetNotInterruptible(b)
            end
        end
        C_Timer.After(0, deferPoll)
        C_Timer.After(0.05, deferPoll)
        C_Timer.After(0.15, deferPoll)
    end

    -- Safezone (player only, braucht echte Cast-Dauer von duration_obj)
    if bar.SafeZone and bar.duration_obj then
        local ok, total = pcall(bar.duration_obj.GetTotalDuration, bar.duration_obj, 0)
        if ok and total and not (issecretvalue and issecretvalue(total)) and total > 0 then
            local _, _, _, latencyMS = GetNetStats()
            latencyMS = latencyMS or 0
            local ratio = (latencyMS / 1000) / total
            if ratio > 1 then ratio = 1 end
            bar.SafeZone:SetWidth(max(1, bar:GetWidth() * ratio))
        end
    end

    bar:Show()
    fadeIn(bar)
end

local function onCastSucceeded(bar, event, unit, castID, spellID)
    if bar.unit ~= unit then return end
    if not bar:IsShown() then return end
    if castID and bar.castID and not (issecretvalue and (issecretvalue(castID) or issecretvalue(bar.castID))) and bar.castID ~= castID then return end
    if bar.channeling or bar.empowering then return end
    showSuccess(bar)
end

local function onCastUpdate(bar, event, unit, castID, spellID)
    if bar.unit ~= unit then return end
    if not bar:IsShown() then return end
    if castID and bar.castID and not (issecretvalue and (issecretvalue(castID) or issecretvalue(bar.castID))) and bar.castID ~= castID then return end

    local name, _, _, startMs, endMs
    if event == "UNIT_SPELLCAST_DELAYED" then
        name, _, _, startMs, endMs = UnitCastingInfo(unit)
    else
        name, _, _, startMs, endMs = UnitChannelInfo(unit)
    end
    if not name then return end

    local durationObj
    if bar.empowering and UnitEmpoweredChannelDuration then
        durationObj = UnitEmpoweredChannelDuration(unit)
    elseif bar.channeling and UnitChannelDuration then
        durationObj = UnitChannelDuration(unit)
    else
        durationObj = UnitCastingDuration and UnitCastingDuration(unit)
    end
    bar.duration_obj = durationObj

    if durationObj and bar.SetTimerDuration then
        bar._useTimerDuration = true
        local direction = bar.channeling and Enum.StatusBarTimerDirection.RemainingTime or Enum.StatusBarTimerDirection.ElapsedTime
        bar:SetTimerDuration(durationObj, Enum.StatusBarInterpolation.Immediate, direction)
        local ok, total = pcall(durationObj.GetTotalDuration, durationObj, 0)
        bar.max = (ok and total and not (issecretvalue and issecretvalue(total)) and total > 0) and total or 1
    else
        bar._useTimerDuration = false
        bar:SetMinMaxValues(startMs, endMs)
        bar:SetValue(GetTime() * 1000)
    end

    if bar.empowering then
        setupEmpowerStageTicks(bar)
    end
end

local function onCastStop(bar, event, unit, castID, spellID)
    if bar.unit ~= unit then return end
    if not bar:IsShown() then return end
    if castID and bar.castID and not (issecretvalue and (issecretvalue(castID) or issecretvalue(bar.castID))) and bar.castID ~= castID then return end

    if event == "UNIT_SPELLCAST_CHANNEL_STOP" then showSuccess(bar); return end
    if event == "UNIT_SPELLCAST_EMPOWER_STOP"  then showSuccess(bar); return end

    -- Ignore STOP if success-flash or fail-hold is already running
    if (bar.successHold and bar.successHold > 0) or (bar.failHold and bar.failHold > 0) then return end

    bar._useTimerDuration = false
    bar.duration_obj = nil
    resetBarState(bar)
    hideEmpowerStageTicks(bar)
    bar:SetMinMaxValues(0, 1)
    bar:SetValue(0)
    fadeOut(bar)
end

local function onCastFail(bar, event, unit, castID, spellID)
    if bar.unit ~= unit then return end
    if not bar:IsShown() then return end
    if castID and bar.castID and not (issecretvalue and (issecretvalue(castID) or issecretvalue(bar.castID))) and bar.castID ~= castID then return end

    local fc = bar.cfg.failColor or { r=1, g=0, b=0, a=1 }
    bar:SetStatusBarColor(fc.r, fc.g, fc.b, fc.a)
    if bar.bg then bar.bg:SetVertexColor(fc.r*0.2, fc.g*0.2, fc.b*0.2, bar.cfg.bgColor.a) end

    if bar.Text and bar.cfg.showSpellName ~= false then
        bar.Text:SetText(
            event == "UNIT_SPELLCAST_FAILED"
            and (FAILED or "Failed") or (INTERRUPTED or "Interrupted"))
        bar.Text:Show()
    end
    if bar.Spark then bar.Spark:Hide() end

    local savedMax = bar.max
    bar.casting    = false
    bar.channeling = false
    bar.empowering = false
    bar.castID     = nil
    bar.successHold = 0
    bar._useTimerDuration = false
    bar.duration_obj = nil
    bar:SetReverseFill(false)
    bar:SetMinMaxValues(0, savedMax > 0 and savedMax or 1)
    bar:SetValue(savedMax > 0 and savedMax or 1)

    bar.failHold = bar.cfg.timeToHold or 0.5
    hideEmpowerStageTicks(bar)
end

local function onInterruptible(bar, event, unit)
    if bar.unit ~= unit then return end
    -- Do not gate on bar:IsShown(): events may fire before Show() (Kui still updates cast_state).
    bar.notInterruptible = (event == "UNIT_SPELLCAST_NOT_INTERRUPTIBLE")
    applyShieldColor(bar, bar.notInterruptible)
end

eventDispatch = {
    UNIT_SPELLCAST_START             = onCastStart,
    UNIT_SPELLCAST_CHANNEL_START     = onCastStart,
    UNIT_SPELLCAST_EMPOWER_START     = onCastStart,
    UNIT_SPELLCAST_SUCCEEDED         = onCastSucceeded,
    UNIT_SPELLCAST_STOP              = onCastStop,
    UNIT_SPELLCAST_CHANNEL_STOP      = onCastStop,
    UNIT_SPELLCAST_EMPOWER_STOP      = onCastStop,
    UNIT_SPELLCAST_DELAYED           = onCastUpdate,
    UNIT_SPELLCAST_CHANNEL_UPDATE    = onCastUpdate,
    UNIT_SPELLCAST_EMPOWER_UPDATE    = onCastUpdate,
    UNIT_SPELLCAST_FAILED            = onCastFail,
    UNIT_SPELLCAST_INTERRUPTED       = onCastFail,
    UNIT_SPELLCAST_INTERRUPTIBLE     = onInterruptible,
    UNIT_SPELLCAST_NOT_INTERRUPTIBLE = onInterruptible,
}

local castbars   = {}
local registered = false

local eventFrame = CreateFrame("Frame", "rCastbar_EventFrame", UIParent)
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")

eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2, arg3)
    if event == "ADDON_LOADED" and arg1 == ADDON_NAME then
        L = rCastbar_L or {}

        if not rCastbarDB        then rCastbarDB = {}        end
        if not rCastbarDB.player then rCastbarDB.player = {} end
        if not rCastbarDB.target then rCastbarDB.target = {} end
        if rCastbarDB.snapToGrid == nil then rCastbarDB.snapToGrid = false end
        deepMerge(rCastbarDB.player, defaults.player)
        deepMerge(rCastbarDB.target, defaults.target)
        if rCastbarDB.player.enabled then
            local bar, cont = buildCastbar("player", rCastbarDB.player)
            castbars.player = { bar = bar, container = cont }
        end
        if rCastbarDB.target.enabled then
            local bar, cont = buildCastbar("target", rCastbarDB.target)
            castbars.target = { bar = bar, container = cont }
        end
        return
    end

    if event == "PLAYER_ENTERING_WORLD" then
        suppressBlizzardCastbars()
        registered = true
        for unit, entry in pairs(castbars) do
            onCastStart(entry.bar, "ForceUpdate", unit)
        end
        return
    end

    if event == "PLAYER_TARGET_CHANGED" then
        local entry = castbars.target
        if entry then
            resetBarState(entry.bar)
            hideEmpowerStageTicks(entry.bar)
            entry.bar:Hide()
            if UnitExists("target") then
                -- Deferred: Target-Daten sind evtl. erst im naechsten Frame verfuegbar (NPC mid-cast)
                C_Timer.After(0, function()
                    if entry and entry.bar and UnitExists("target") then
                        onCastStart(entry.bar, "ForceUpdate", "target")
                    end
                end)
            end
        end
        return
    end

    -- Spell events received on each bar (RegisterUnitEvent on bar)
end)

-- Preview API (used by GUI edit mode)
function rCastbar_ShowPreview(show)
    for unit, entry in pairs(castbars) do
        local bar  = entry.bar
        local cont = entry.container
        if show then
            -- Show bar in a static preview state
            bar.previewMode = true
            applyBarColor(bar)
            bar:SetMinMaxValues(0, 1)
            bar:SetValue(0.65)
            if bar.Text then
                if bar.cfg.showSpellName ~= false then
                    bar.Text:SetText("Preview")
                    bar.Text:Show()
                end
            end
            if bar.Time then
                if bar.cfg.showTime ~= false then
                    bar.Time:SetText("1.5")
                    bar.Time:Show()
                end
            end
            if bar.Icon and bar.cfg.showIcon then
                bar.Icon:SetTexture(237017)
                bar.Icon:Show()
            end
            if bar.Spark then
                bar.Spark:ClearAllPoints()
                bar.Spark:SetPoint("CENTER", bar:GetStatusBarTexture(), "RIGHT", 0, 0)
                bar.Spark:Show()
            end
            bar:SetAlpha(1)
            bar:Show()  -- triggers OnShow -> container:EnableMouse(true)
        else
            bar.previewMode = false
            -- Only hide if no real cast is running
            if not bar.casting and not bar.channeling and not bar.empowering
               and not (bar.successHold and bar.successHold > 0)
               and not (bar.failHold   and bar.failHold   > 0)
               and not bar.fadingOut then
                bar:Hide()  -- triggers OnHide -> container:EnableMouse(false) if not dragging
            end
        end
    end
end
