--[[
    Core.lua - TGXG Interrupt
    -----------------------------------------------------------------------
    Module layout (pattern taken from BliZzi Party Tools):
      TGX.Taint    — secret-value laundering helpers (WoW 12.x)
      TGX.Net      — addon message communication
      TGX.Self     — own player state (kick spell, cooldown)
      TGX.Registry — party member state (name → entry)
      TGX.Assign   — automatic raid-marker kick assignment
    -----------------------------------------------------------------------
]]

TGX = TGX or {}
TGX.VERSION = "1.0.7"
TGX.ready    = false
TGX.testMode = false

------------------------------------------------------------
-- Locale (DE + EN, compact)
------------------------------------------------------------
do
    local de = GetLocale() == "deDE"
    TGX.isDE = de
    TGX.L = {
        TITLE          = "TGXG Interrupt",
        READY          = de and "Bereit"                 or "Ready",
        KICK_NOW       = de and "UNTERBRECHEN!"          or "INTERRUPT!",
        KICK_TARGET    = de and "Ziel unterbrechbar!"    or "Target interruptible!",
        KICK_FOCUS     = de and "Fokus unterbrechbar!"   or "Focus interruptible!",
        ASSIGN_TITLE   = de and "Kick-Einteilung"        or "Kick assignment",
        ASSIGN_CHAT    = de and "kickt"                  or "kicks",
        NO_ASSIGN      = de and "Keine Einteilung aktiv" or "No assignment active",
        WELCOME        = de and "|cff33ff99TGXG|r Interrupt v%s geladen — |cFFFFD700/tgxg|r öffnet die Einstellungen."
                             or "|cff33ff99TGXG|r Interrupt v%s loaded — type |cFFFFD700/tgxg|r for settings.",
    }
    function TGX.MarkerName(i)
        local m = TGX.MARKER_NAMES[i]
        return m and (de and m.de or m.en) or ("?" .. tostring(i))
    end
end

-- Inline raid-marker chat icon ({rt1}..{rt8})
function TGX.MarkerChatIcon(i) return "{rt" .. i .. "}" end

------------------------------------------------------------
-- TGX.Taint — secret-value laundering (WoW 12.x Midnight).
-- A hidden Slider's OnValueChanged fires from C++ context which strips
-- the secret mark; cheap tostring/format paths run first. Identical
-- technique to BliZzi's BIT.Taint (proven in production).
------------------------------------------------------------
do
    local _slider = CreateFrame("Slider", nil, UIParent)
    -- Range covers spell IDs AND millisecond cast-clock values (GetTime()*1000
    -- scale) so the laundering also works for secret cast start/end times.
    _slider:SetMinMaxValues(0, 2147483647)
    _slider:SetSize(1, 1)
    _slider:Hide()
    local _result = nil
    _slider:SetScript("OnValueChanged", function(_, v) _result = v end)

    TGX.Taint = {}

    -- Probe: a tainted number throws when used as a table index.
    local function isClean(n)
        if type(n) ~= "number" then return false end
        return pcall(function() local _ = ({ [n] = 1 })[n] end)
    end
    TGX.Taint.IsClean = isClean

    -- Launder any (possibly secret) numeric value → clean number or nil.
    function TGX.Taint:ResolveNumber(raw)
        if raw == nil then return nil end
        if type(raw) == "number" and isClean(raw) then return raw end
        local okT, s = pcall(tostring, raw)
        if okT and s then
            local okN, n = pcall(tonumber, s)
            if okN and n and isClean(n) then return n end
        end
        local okF, f = pcall(string.format, "%.0f", raw)
        if okF and f then
            local okN, n = pcall(tonumber, f)
            if okN and n and isClean(n) then return n end
        end
        -- Slider fallback. The two SetValue calls MUST be separate pcalls
        -- (a shared pcall would leave _result=0 on a tainted second call).
        pcall(_slider.SetValue, _slider, 0)
        _result = nil
        local ok = pcall(_slider.SetValue, _slider, raw)
        if ok and _result and _result ~= 0 then
            local okN, n = pcall(tonumber, _result)
            if okN and n and isClean(n) then return n end
        end
        return nil
    end

    -- Resolve a (possibly secret) spell ID against our interrupt table.
    function TGX.Taint:ResolveInterrupt(rawID)
        local okD, hit = pcall(function() return TGX.ALL_INTERRUPTS[rawID] end)
        if okD and hit then return rawID end
        local n = self:ResolveNumber(rawID)
        if n and TGX.ALL_INTERRUPTS[n] then return n end
        -- Name path: C_Spell.GetSpellName tolerates secret IDs; gate the
        -- result with issecretvalue before using it as a table key.
        local okN, sn = pcall(C_Spell.GetSpellName, rawID)
        if okN and sn ~= nil and not (type(issecretvalue) == "function" and issecretvalue(sn)) then
            local data = TGX.ALL_INTERRUPTS_BY_NAME[sn]
            if data then return data.id end
        end
        return nil
    end
end

------------------------------------------------------------
-- Robust notInterruptible extraction (12.x).
--
-- Midnight added a castBarID return to UnitCastingInfo/UnitChannelInfo,
-- which can SHIFT the positional returns between builds — a fixed
-- `select(8, ...)` style read then lands on the wrong slot and the flag
-- never reads as "interruptible" (the no-sound bug). Instead we scan all
-- returns AFTER position 6 (isTradeSkill, always the first boolean) and
-- take the LAST boolean found: across every known signature that slot is
-- notInterruptible, regardless of where castBarID got inserted.
--
-- Call as: TGX.ExtractNotInterruptible(UnitCastingInfo(unit))
-- Returns true/false (clean flag) or nil (absent / secret value).
------------------------------------------------------------
function TGX.ExtractNotInterruptible(...)
    local n = select("#", ...)
    local found, val, sec = false, false, false
    for i = 7, n do
        local v = select(i, ...)
        if type(v) == "boolean" then   -- type() is secret-safe
            found = true
            val   = v
            sec   = false
            if type(issecretvalue) == "function" then
                local ok, s = pcall(issecretvalue, v)
                sec = ok and s or false
            end
        end
    end
    if not found or sec then return nil end
    return val == true
end

------------------------------------------------------------
-- TGX.Net — addon messages
-- Protocol (semicolon separated):
--   T1;HELLO;class;spellID;cd     announce self (rate limited reply)
--   T1;KICK;spellID;cd            own interrupt cast
--   T1;ASSIGN;m:name,m:name,...   leader broadcasts marker assignment
--   T1;ASSIGNCLR                  leader cleared the assignment
------------------------------------------------------------
do
    local PREFIX = "TGXGInt"
    local HDR    = "T1"

    TGX.Net = {}

    local function Transmit(payload)
        local inInstance = IsInGroup(LE_PARTY_CATEGORY_INSTANCE)
        if not (inInstance or IsInGroup(LE_PARTY_CATEGORY_HOME)) then return end
        -- ret == 0 means success in Midnight; 11 = M+ addon-channel block.
        local channel = inInstance and "INSTANCE_CHAT" or "PARTY"
        local ok, ret = pcall(C_ChatInfo.SendAddonMessage, PREFIX, payload, channel)
        if ok and ret == 0 then return end
        if inInstance then
            pcall(C_ChatInfo.SendAddonMessage, PREFIX, payload, "PARTY")
        end
    end

    local function Msg(...) return table.concat({ HDR, ... }, ";") end

    function TGX.Net:Hello(class, spellID, cd)  Transmit(Msg("HELLO", class, spellID, cd)) end
    function TGX.Net:Kick(spellID, cd)          Transmit(Msg("KICK", spellID, cd))         end
    function TGX.Net:Assign(str)                Transmit(Msg("ASSIGN", str))               end
    function TGX.Net:AssignClear()              Transmit(Msg("ASSIGNCLR"))                 end

    local dispatch = {}

    dispatch["HELLO"] = function(parts, sender)
        local cls, sid, cd = parts[3], tonumber(parts[4]), tonumber(parts[5])
        if not (cls and TGX.CLASS_COLORS[cls] and sid and TGX.ALL_INTERRUPTS[sid]) then return end
        local e   = TGX.Registry:GetOrCreate(sender)
        e.class   = cls
        e.spellID = sid
        if cd and cd > 0 then e.baseCd = cd end
        e.hasAddon = true
        TGX.Self:BroadcastHello()          -- rate-limited reply
        if TGX.Tracker then TGX.Tracker:Rebuild() end
    end

    dispatch["KICK"] = function(parts, sender)
        local sid, cd = tonumber(parts[3]), tonumber(parts[4])
        if not (cd and cd > 0) then return end
        local e = TGX.Registry:Get(sender)
        if e then
            e.cdEnd  = GetTime() + cd
            e.baseCd = cd
        end
    end

    -- Who may push an assignment to us? Leader always; assistants when
    -- "assignAllowAssist" is on; anyone when "assignAllowAll" is on.
    -- Unverifiable senders (cross-realm edge cases) are accepted, as
    -- losing a legitimate assignment is worse than the rare spoof.
    local function SenderMayAssign(sender)
        local d = TGX.db
        if d and d.assignAllowAll then return true end
        for i = 1, 4 do
            local u = "party" .. i
            if UnitExists(u) and UnitName(u) == sender then
                if UnitIsGroupLeader(u) then return true end
                if d and d.assignAllowAssist
                   and UnitIsGroupAssistant and UnitIsGroupAssistant(u) then
                    return true
                end
                return false
            end
        end
        return true
    end

    dispatch["ASSIGN"] = function(parts, sender)
        if not SenderMayAssign(sender) then return end
        TGX.Assign:ApplyFromString(parts[3], sender)
    end

    dispatch["ASSIGNCLR"] = function(parts, sender)
        if not SenderMayAssign(sender) then return end
        TGX.Assign:Clear(true)
    end

    function TGX.Net:OnMessage(prefix, message, _, sender)
        if prefix ~= PREFIX then return end
        local shortName = Ambiguate(sender, "short")
        if shortName == TGX.Self.name then return end
        local parts = { strsplit(";", message) }
        if parts[1] ~= HDR then return end
        local handler = dispatch[parts[2]]
        if handler then handler(parts, shortName) end
    end

    function TGX.Net:Register() C_ChatInfo.RegisterAddonMessagePrefix(PREFIX) end
end

------------------------------------------------------------
-- TGX.Registry — party member state (keyed by short name; unit tokens
-- reshuffle on roster changes, names don't)
------------------------------------------------------------
do
    TGX.Registry = {}
    local _data = {}

    function TGX.Registry:Get(name)  return _data[name] end
    function TGX.Registry:All()      return _data       end
    function TGX.Registry:Remove(n)  _data[n] = nil     end
    function TGX.Registry:Clear()    wipe(_data)        end

    function TGX.Registry:GetOrCreate(name)
        if not _data[name] then _data[name] = { cdEnd = 0 } end
        return _data[name]
    end

    function TGX.Registry:Purge(keep)
        for name in pairs(_data) do
            if not keep[name] then _data[name] = nil end
        end
    end
end

------------------------------------------------------------
-- TGX.Self — own player state
------------------------------------------------------------
do
    TGX.Self = { name = nil, class = nil, spellID = nil, baseCd = nil,
                 cdEnd = 0, isPet = false, lastHello = 0 }

    function TGX.Self:Update()
        self.name = UnitName("player")
        local _, cls = UnitClass("player")
        self.class = cls
    end

    -- Determine own interrupt from spec (registry-driven, spellbook-verified).
    function TGX.Self:FindInterrupt()
        self.spellID = nil
        self.isPet   = false
        local specIndex = GetSpecialization()
        local specID    = specIndex and GetSpecializationInfo(specIndex)
        if specID and TGX.SPEC_NO_INTERRUPT[specID] then return end

        local pick = (specID and TGX.SPEC_OVERRIDES[specID]) or (self.class and TGX.CLASS_INTERRUPTS[self.class])
        if not pick then return end

        if pick.isPet then
            local known = C_SpellBook.IsSpellInSpellBook(pick.id, Enum.SpellBookSpellBank.Pet)
                or C_SpellBook.IsSpellInSpellBook(pick.id, Enum.SpellBookSpellBank.Player)
                or C_SpellBook.IsSpellKnown(pick.id)
            if not known then return end
            self.isPet = true
        end
        self.spellID = pick.id
        self.baseCd  = pick.cd
        self:ReadRealCooldown()
    end

    -- Refine baseCd from the live API (covers talent CD reductions without
    -- needing a talent database). Only trusts values that look like a real
    -- interrupt CD.
    function TGX.Self:ReadRealCooldown()
        if not self.spellID or self.isPet then return end
        local ok, ms = pcall(GetSpellBaseCooldown, self.spellID)
        if ok and ms then
            local clean = tonumber(string.format("%.0f", ms))
            if clean and clean > 1500 then self.baseCd = clean / 1000 end
        end
    end

    -- Is our kick ready right now? Reads the real spell cooldown (clean for
    -- the own player) and tolerates the GCD (<1.5s counts as ready).
    function TGX.Self:IsKickReady()
        if not self.spellID then return false end
        local ok, info = pcall(C_Spell.GetSpellCooldown, self.spellID)
        if ok and info then
            local okD, remaining = pcall(function()
                if info.startTime and info.duration and info.duration > 0 then
                    return (info.startTime + info.duration) - GetTime()
                end
                return 0
            end)
            if okD and type(remaining) == "number" then
                return remaining <= 1.5
            end
        end
        -- API unreadable → fall back to our own tracked cdEnd
        return GetTime() >= (self.cdEnd or 0)
    end

    -- Is the mob in range of our kick? C_Spell.IsSpellInRange can return a
    -- secret/nil for enemies in 12.x — treat "unknown" as in range so the
    -- alert never silently disappears when the API is restricted.
    function TGX.Self:IsKickInRange(unit)
        if not self.spellID or not unit then return true end
        local ok, r = pcall(C_Spell.IsSpellInRange, self.spellID, unit)
        if not ok or r == nil then return true end
        if type(issecretvalue) == "function" then
            local okS, sec = pcall(issecretvalue, r)
            if okS and sec then return true end
        end
        local okB, res = pcall(function() return r == true end)
        if not okB then return true end
        return res
    end

    function TGX.Self:BroadcastHello()
        if not (self.class and self.spellID) then return end
        local now = GetTime()
        if now - self.lastHello < 3 then return end
        self.lastHello = now
        TGX.Net:Hello(self.class, self.spellID, self.baseCd or TGX.ALL_INTERRUPTS[self.spellID].cd)
    end

    function TGX.Self:OnOwnKick(spellID)
        local cd = self.baseCd or (TGX.ALL_INTERRUPTS[spellID] and TGX.ALL_INTERRUPTS[spellID].cd) or 15
        self.cdEnd = GetTime() + cd
        -- Timestamp for the nameplate-interrupt attribution below: a mob
        -- interrupt right after our own kick is OURS, not a teammate's.
        self._lastOwnKickAt = GetTime()
        TGX.Net:Kick(spellID, cd)
        if TGX.KickAlert then TGX.KickAlert:HideAll() end  -- kick used → alert done
    end
end

------------------------------------------------------------
-- Roster management — register party members by class/spec so members
-- without the addon are tracked too (their kicks arrive via the
-- UNIT_SPELLCAST_SUCCEEDED party events, taint-tolerantly resolved).
------------------------------------------------------------
function TGX:ScanRoster()
    if TGX.testMode then return end
    local active = {}
    for i = 1, 4 do
        local u = "party" .. i
        if UnitExists(u) then
            local name = UnitName(u)
            if name then
                active[name] = true
                local _, cls = UnitClass(u)
                if cls and TGX.CLASS_INTERRUPTS[cls] then
                    local role       = UnitGroupRolesAssigned(u)
                    local skipHealer = (role == "HEALER" and not TGX.HEALER_KEEPS_KICK[cls])
                    -- Ambiguous classes (a spec might have no kick) are only
                    -- registered optimistically when the role proves a kicking
                    -- spec — avoids phantom bars for Mistweaver/Resto/Holy.
                    local safe = (not TGX.CLASS_HAS_NOKICK_SPEC[cls]) or role == "DAMAGER" or role == "TANK"
                    if not skipHealer and safe and not TGX.Registry:Get(name) then
                        local specID = GetInspectSpecialization(u)
                        local pick   = (specID and specID > 0 and not TGX.SPEC_NO_INTERRUPT[specID])
                                       and (TGX.SPEC_OVERRIDES[specID] or TGX.CLASS_INTERRUPTS[cls])
                                       or  TGX.CLASS_INTERRUPTS[cls]
                        if specID and specID > 0 and TGX.SPEC_NO_INTERRUPT[specID] then
                            -- known no-kick spec → skip
                        else
                            local e   = TGX.Registry:GetOrCreate(name)
                            e.class   = cls
                            e.spellID = pick.id
                            e.baseCd  = pick.cd
                            e.cdEnd   = e.cdEnd or 0
                        end
                    end
                end
            end
        end
    end
    TGX.Registry:Purge(active)
    if TGX.Assign then TGX.Assign:OnRosterChanged(active) end
    if TGX.Tracker then TGX.Tracker:Rebuild() end
end

------------------------------------------------------------
-- Own + party cast detection
------------------------------------------------------------
local playerFrame = CreateFrame("Frame")
playerFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player", "pet")
playerFrame:SetScript("OnEvent", function(_, _, unit, _, spellID)
    -- Own-player spell IDs are clean; pet IDs can be secret in 12.x.
    local usedID
    if unit == "player" and TGX.ALL_INTERRUPTS[spellID] then
        usedID = spellID
    else
        usedID = TGX.Taint:ResolveInterrupt(spellID)
    end
    if usedID then TGX.Self:OnOwnKick(usedID) end
end)

local partyFrame = CreateFrame("Frame")
partyFrame:SetScript("OnEvent", function(_, _, unit, _, spellID)
    if not unit then return end
    local name
    if unit:find("^partypet") then
        local idx = unit:match("partypet(%d)")
        name = idx and UnitName("party" .. idx)
    else
        name = UnitName(unit)
    end
    if not name then return end
    local usedID = TGX.Taint:ResolveInterrupt(spellID)
    if not usedID then return end
    local e  = TGX.Registry:Get(name)
    local cd = (TGX.ALL_INTERRUPTS[usedID] and TGX.ALL_INTERRUPTS[usedID].cd) or 15
    if e then
        -- Addon users update via their KICK comm (with talent-true CD);
        -- for them the event only refreshes when no comm arrived.
        if not e.hasAddon then
            e.cdEnd  = GetTime() + (e.baseCd or cd)
        end
    end
end)

------------------------------------------------------------
-- Party-kick attribution via the MOB's interrupt event.
--
-- Midnight (12.0.5+) no longer fires UNIT_SPELLCAST_SUCCEEDED for party
-- members, so a teammate's kick can't be seen on their own unit events.
-- What DOES still fire is UNIT_SPELLCAST_INTERRUPTED on the interrupted
-- mob's nameplate — Midnight even added the interrupter's GUID to it.
-- Attribution ladder (BliZzi Party Tools' proven approach):
--   1. own recent kick → skip (already tracked via OnOwnKick)
--   2. interrupter GUID resolves to a CLEAN party name → start their CD
--      (in instances the name is usually secret → falls through)
--   3. candidate heuristic, deferred by 50ms (event-order race with our
--      own SUCCEEDED): registry members whose kick is off cooldown;
--      prefer the one targeting the mob; unambiguous winner starts CD.
-- Addon users additionally sync exact CDs via the KICK comm (stronger,
-- overwrites the heuristic result).
------------------------------------------------------------
local _attrGuard = {}   -- nameplate token → last handled time (de-dup)

local function StartPartyKickCD(name)
    local e = TGX.Registry:Get(name)
    if not (e and e.spellID) then return end
    local cd = e.baseCd or (TGX.ALL_INTERRUPTS[e.spellID] and TGX.ALL_INTERRUPTS[e.spellID].cd) or 15
    e.cdEnd = GetTime() + cd
    if TGX.debug then
        print("|cff33ff99TGXG-DBG|r Party-Kick erkannt: " .. name .. " (CD " .. cd .. "s)")
    end
end

local kickAttrFrame = CreateFrame("Frame")
kickAttrFrame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
kickAttrFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
kickAttrFrame:SetScript("OnEvent", function(_, event, unit, castGUID, spellID, interruptedBy)
    if not unit or not unit:find("^nameplate") then return end
    if not IsInGroup() then return end
    -- CHANNEL_STOP also fires when a channel ends NORMALLY; without an
    -- interrupter GUID we can't tell those apart → ignore to avoid
    -- phantom cooldowns. INTERRUPTED always means a real interrupt.
    if event == "UNIT_SPELLCAST_CHANNEL_STOP" and interruptedBy == nil then return end

    local now = GetTime()
    -- WoW fires several interrupt events per kick (per nameplate token).
    if _attrGuard[unit] and (now - _attrGuard[unit]) < 0.5 then return end
    _attrGuard[unit] = now

    -- 1) Our own kick? Already tracked; don't credit a teammate.
    if TGX.Self._lastOwnKickAt and (now - TGX.Self._lastOwnKickAt) < 1.0 then return end

    -- 2) Direct attribution via the interrupter GUID (clean names only —
    --    a secret name may be DISPLAYED but never compared, so it can't
    --    be matched against the registry).
    if interruptedBy ~= nil then
        local okN, n = pcall(UnitNameFromGUID, interruptedBy)
        if okN and n ~= nil
           and not (type(issecretvalue) == "function" and issecretvalue(n)) then
            local short = n:match("^([^%-]+)") or n
            if TGX.Registry:Get(short) then
                StartPartyKickCD(short)
                return
            end
        end
    end

    -- 3) Deferred candidate heuristic. 50ms lets a racing own-kick
    --    SUCCEEDED land first (Blizzard doesn't guarantee event order
    --    across types within a tick).
    C_Timer.After(0.05, function()
        local t = GetTime()
        if TGX.Self._lastOwnKickAt and (t - TGX.Self._lastOwnKickAt) < 0.3 then return end
        local candidates, targeting = {}, {}
        for i = 1, 4 do
            local pu = "party" .. i
            if UnitExists(pu) then
                local pname = UnitName(pu)
                local e = pname and TGX.Registry:Get(pname)
                if e and e.spellID then
                    -- Drift grace 1.5s: every interrupt CD is ≥10s, so a
                    -- tracker showing "almost ready" while the player just
                    -- kicked means OUR timer drifted — accept and re-arm.
                    local remaining = (e.cdEnd or 0) - t
                    if remaining <= 1.5 then
                        local c = { name = pname }
                        local okT = pcall(function()
                            if UnitIsUnit(pu .. "target", unit) == true then
                                c.targets = true
                            end
                        end)
                        candidates[#candidates + 1] = c
                        if okT and c.targets then targeting[#targeting + 1] = c end
                    end
                end
            end
        end
        local winner
        if #targeting >= 1 then winner = targeting[1]
        elseif #candidates == 1 then winner = candidates[1] end
        if winner then
            StartPartyKickCD(winner.name)
        elseif TGX.debug and #candidates > 1 then
            print("|cff33ff99TGXG-DBG|r Kick nicht zuordenbar (" .. #candidates .. " Kandidaten off-CD)")
        end
    end)
end)

-- Activity gate: party watchers only pay off with teammates around.
function TGX:UpdatePartyWatch()
    if IsInGroup() then
        partyFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED",
            "party1", "party2", "party3", "party4")
    else
        partyFrame:UnregisterEvent("UNIT_SPELLCAST_SUCCEEDED")
    end
end

------------------------------------------------------------
-- TGX.Assign — automatic raid-marker kick assignment
--
-- Deterministic: members WITH an interrupt, ordered player-first then
-- party1..4, get markers in TGX.ASSIGN_MARKER_ORDER. The group leader
-- broadcasts the result so every addon user shows the same table; the
-- local computation is the fallback when the leader has no addon.
------------------------------------------------------------
do
    TGX.Assign = {}
    local A = TGX.Assign
    A.map      = {}    -- markerIndex → playerName
    A.byPlayer = {}    -- playerName  → markerIndex
    A.active   = false

    local function eligibleMembers()
        local list = {}
        if TGX.Self.spellID then list[#list + 1] = TGX.Self.name end
        for i = 1, 4 do
            local u = "party" .. i
            if UnitExists(u) then
                local name = UnitName(u)
                local e = name and TGX.Registry:Get(name)
                if e and e.spellID then list[#list + 1] = name end
            end
        end
        return list
    end

    function A:Compute()
        wipe(self.map); wipe(self.byPlayer)
        local members = eligibleMembers()
        for i, name in ipairs(members) do
            local marker = TGX.ASSIGN_MARKER_ORDER[i]
            if not marker then break end
            self.map[marker]    = name
            self.byPlayer[name] = marker
        end
        self.active = next(self.map) ~= nil
        if TGX.Tracker then TGX.Tracker:Rebuild() end
        return self.active
    end

    function A:Serialize()
        local parts = {}
        for marker, name in pairs(self.map) do
            parts[#parts + 1] = marker .. ":" .. name
        end
        return table.concat(parts, ",")
    end

    function A:ApplyFromString(str, sender)
        if not str or str == "" then return end
        wipe(self.map); wipe(self.byPlayer)
        for token in str:gmatch("[^,]+") do
            local m, name = token:match("^(%d):(.+)$")
            m = tonumber(m)
            if m and name then
                self.map[m]        = name
                self.byPlayer[name] = m
            end
        end
        self.active = next(self.map) ~= nil
        if TGX.Tracker then TGX.Tracker:Rebuild() end
    end

    -- Run the assignment: compute, broadcast (if permitted), announce.
    function A:Run(silent)
        if not self:Compute() then return end
        if self:CanBroadcast() then
            TGX.Net:Assign(self:Serialize())
        end
        if not silent and TGX.db.assignAnnounceChat and IsInGroup()
           and (UnitIsGroupLeader("player") or not IsInGroup()) then
            self:Announce()
        end
        self:Print()
    end

    function A:Announce()
        local channel = IsInGroup(LE_PARTY_CATEGORY_INSTANCE) and "INSTANCE_CHAT" or "PARTY"
        for _, marker in ipairs(TGX.ASSIGN_MARKER_ORDER) do
            local name = self.map[marker]
            if name then
                local msg = TGX.MarkerChatIcon(marker) .. " " .. TGX.MarkerName(marker)
                            .. ": " .. name .. " " .. TGX.L.ASSIGN_CHAT
                pcall(SendChatMessage, msg, channel)
            end
        end
    end

    function A:Print()
        print("|cff33ff99TGXG|r " .. TGX.L.ASSIGN_TITLE .. ":")
        for _, marker in ipairs(TGX.ASSIGN_MARKER_ORDER) do
            local name = self.map[marker]
            if name then
                print("  " .. TGX.MarkerChatIcon(marker) .. " " .. TGX.MarkerName(marker) .. " → " .. name)
            end
        end
    end

    function A:Clear(fromComm)
        wipe(self.map); wipe(self.byPlayer)
        self.active = false
        if not fromComm and self:CanBroadcast() then TGX.Net:AssignClear() end
        if TGX.Tracker then TGX.Tracker:Rebuild() end
    end

    function A:MarkerFor(name) return self.byPlayer[name] end
    function A:MyMarker()      return self.byPlayer[TGX.Self.name] end

    -- May THIS client broadcast assignments? Leader always; assistants
    -- with "assignAllowAssist"; anyone with "assignAllowAll".
    function A:CanBroadcast()
        if UnitIsGroupLeader("player") then return true end
        local d = TGX.db
        if d and d.assignAllowAll then return true end
        if d and d.assignAllowAssist
           and UnitIsGroupAssistant and UnitIsGroupAssistant("player") then
            return true
        end
        return false
    end

    -- Manual single-player assignment from the tracker's click UI.
    -- marker = nil clears the player's assignment. Steals the marker from
    -- its previous owner (one marker ↔ one player). As group leader the
    -- change is broadcast so every addon user sees the same table;
    -- otherwise it applies locally only.
    function A:SetManual(name, marker)
        if not name then return end
        local old = self.byPlayer[name]
        if old then self.map[old] = nil end
        self.byPlayer[name] = nil
        if marker then
            local prevOwner = self.map[marker]
            if prevOwner then self.byPlayer[prevOwner] = nil end
            self.map[marker]    = name
            self.byPlayer[name] = marker
        end
        self.active = next(self.map) ~= nil
        if self:CanBroadcast() then
            if self.active then
                TGX.Net:Assign(self:Serialize())
            else
                TGX.Net:AssignClear()
            end
        end
        if TGX.Tracker then TGX.Tracker:Rebuild() end
    end

    -- Drop assignments for members who left; keeps the rest stable.
    function A:OnRosterChanged(active)
        if not self.active then return end
        local dirty = false
        for marker, name in pairs(self.map) do
            if name ~= TGX.Self.name and not active[name] then
                self.map[marker] = nil
                self.byPlayer[name] = nil
                dirty = true
            end
        end
        if dirty and TGX.Tracker then TGX.Tracker:Rebuild() end
    end
end

------------------------------------------------------------
-- Initialize + main event dispatcher
------------------------------------------------------------
function TGX:Initialize()
    TGXGInterruptDB = TGXGInterruptDB or {}
    self.db = TGXGInterruptDB
    for k, v in pairs(TGX.DEFAULTS) do
        if self.db[k] == nil then self.db[k] = v end
    end

    -- Migration: the castbar's interruptible color default changed from
    -- orange to green (orange was indistinguishable from the "unknown"
    -- state at a glance). Only rewrites the EXACT old default — a color
    -- the user picked deliberately is left alone.
    if self.db.castbarColorR == 1.0 and self.db.castbarColorG == 0.55
       and self.db.castbarColorB == 0.1 then
        self.db.castbarColorR = TGX.DEFAULTS.castbarColorR
        self.db.castbarColorG = TGX.DEFAULTS.castbarColorG
        self.db.castbarColorB = TGX.DEFAULTS.castbarColorB
    end

    -- Rebuild the name lookup with localized spell names.
    TGX.ALL_INTERRUPTS_BY_NAME = {}
    for id, v in pairs(TGX.ALL_INTERRUPTS) do
        local info = C_Spell.GetSpellInfo(id)
        local nm = (info and info.name) or v.name
        if nm then TGX.ALL_INTERRUPTS_BY_NAME[nm] = { id = id, cd = v.cd } end
    end

    TGX.Net:Register()
    TGX.Self:Update()

    if TGX.SpellDB   then TGX.SpellDB:Init()     end
    if TGX.Tracker   then TGX.Tracker:Create()   end
    if TGX.Castbar   then TGX.Castbar:Create()   end
    if TGX.KickAlert then TGX.KickAlert:Create() end
    if TGX.KickIcon  then TGX.KickIcon:Create()  end
    if TGX.Options   then TGX.Options:Init()     end

    TGX.Self:FindInterrupt()
    if TGX.KickIcon then TGX.KickIcon:UpdateVisibility() end
    TGX:UpdatePartyWatch()
    self.ready = true
end

local eventHandlers = {}

eventHandlers["ADDON_LOADED"] = function(addon)
    if addon == "TGXG_Interrupt" then TGX:Initialize() end
end

eventHandlers["PLAYER_LOGIN"] = function()
    print(string.format(TGX.L.WELCOME, TGX.VERSION))
end

eventHandlers["CHAT_MSG_ADDON"] = function(prefix, msg, channel, sender)
    TGX.Net:OnMessage(prefix, msg, channel, sender)
end

eventHandlers["PLAYER_ENTERING_WORLD"] = function()
    TGX:ScanRoster()
    TGX:UpdatePartyWatch()
    C_Timer.After(2, function()
        TGX.Self:FindInterrupt()
        TGX.Self:BroadcastHello()
        TGX:ScanRoster()
    end)
end

eventHandlers["GROUP_ROSTER_UPDATE"] = function()
    TGX:ScanRoster()
    TGX:UpdatePartyWatch()
    TGX.Self:BroadcastHello()
    -- Retry once units are actually loaded (roster events fire early).
    C_Timer.After(2, function() TGX:ScanRoster() end)
end

eventHandlers["PLAYER_SPECIALIZATION_CHANGED"] = function(unit)
    if not unit or unit == "player" then
        TGX.Self:FindInterrupt()
        TGX.Self:BroadcastHello()
        if TGX.KickIcon then TGX.KickIcon:UpdateVisibility() end
    end
end

eventHandlers["SPELLS_CHANGED"] = function()
    TGX.Self:FindInterrupt()
    if TGX.KickIcon then TGX.KickIcon:UpdateVisibility() end
end

eventHandlers["CHALLENGE_MODE_START"] = function()
    -- "Automatische Einteilung": at keystone start the leader computes and
    -- broadcasts the marker assignment. Non-leaders compute locally only
    -- when no broadcast arrives (leader without the addon) — the 3s grace
    -- keeps the two paths from racing.
    if not TGX.db.autoAssignEnabled then return end
    C_Timer.After(1, function()
        if UnitIsGroupLeader("player") then
            TGX.Assign:Run()
        else
            C_Timer.After(3, function()
                if not TGX.Assign.active then
                    TGX.Assign:Compute()
                    TGX.Assign:Print()
                end
            end)
        end
    end)
end

local ef = CreateFrame("Frame")
for event in pairs(eventHandlers) do ef:RegisterEvent(event) end
ef:SetScript("OnEvent", function(_, event, a1, a2, a3, a4)
    local h = eventHandlers[event]
    if h then h(a1, a2, a3, a4) end
end)

------------------------------------------------------------
-- Test mode — fake party for previewing (names as in the design mock)
------------------------------------------------------------
do
    local TEST_POOL = {
        { name = "Jonbonholy", class = "PALADIN", spellID = 96231  },
        { name = "Luckystun",  class = "ROGUE",   spellID = 1766   },
        { name = "Coldwater",  class = "MAGE",    spellID = 2139   },
        { name = "Minti",      class = "DRUID",   spellID = 106839 },
        { name = "Toietxmove", class = "SHAMAN",  spellID = 57994  },
    }
    local _saved

    function TGX:StartTestMode()
        if self.testMode then self:StopTestMode() return end
        self.testMode = true
        _saved = {}
        for k, v in pairs(TGX.Registry:All()) do _saved[k] = v end
        TGX.Registry:Clear()

        local now = GetTime()
        for i, p in ipairs(TEST_POOL) do
            local e   = TGX.Registry:GetOrCreate(p.name)
            e.class   = p.class
            e.spellID = p.spellID
            e.baseCd  = TGX.ALL_INTERRUPTS[p.spellID].cd
            e.cdEnd   = (i % 2 == 0) and (now + e.baseCd * i / 4) or 0
        end
        -- Test assignment preview
        TGX.Assign:Compute()
        if TGX.Tracker then TGX.Tracker:Rebuild(); TGX.Tracker:Show(true) end
        if TGX.Castbar then TGX.Castbar:StartTest() end
    end

    function TGX:StopTestMode()
        self.testMode = false
        TGX.Registry:Clear()
        if _saved then
            for k, v in pairs(_saved) do
                local e = TGX.Registry:GetOrCreate(k)
                for f, val in pairs(v) do e[f] = val end
            end
            _saved = nil
        end
        TGX.Assign:Clear()
        if TGX.Tracker then TGX.Tracker:Rebuild(); TGX.Tracker:UpdateVisibility() end
        if TGX.Castbar then TGX.Castbar:StopTest() end
    end
end

------------------------------------------------------------
-- Slash commands
------------------------------------------------------------
SLASH_TGXG1 = "/tgxg"
SlashCmdList["TGXG"] = function(arg)
    arg = (arg or ""):lower():match("^%s*(%S*)")
    if arg == "test" then
        TGX:StartTestMode()
    elseif arg == "lock" then
        TGX.db.locked = not TGX.db.locked
        if TGX.Tracker  then TGX.Tracker:ApplyLock()  end
        if TGX.Castbar  then TGX.Castbar:ApplyLock()  end
        if TGX.KickIcon then TGX.KickIcon:ApplyLock() TGX.KickIcon:UpdateVisibility() end
    elseif arg == "assign" then
        TGX.Assign:Run()
    elseif arg == "clear" then
        TGX.Assign:Clear()
    elseif arg == "debug" then
        TGX.debug = not TGX.debug
        print("|cff33ff99TGXG|r Debug " .. (TGX.debug and "AN — Gates werden im Chat geloggt" or "aus"))
    else
        if TGX.Options then TGX.Options:Toggle() end
    end
end
