-- ============================================================
-- Asylum Compagnon — Kicks.lua
-- Tracker d'interruptions compatible Midnight (12.0+), sans CLEU.
--
-- Détection LOCALE (fiable pour soi) :
--   · UNIT_SPELLCAST_SUCCEEDED (joueur OU pet → rattaché au
--     propriétaire) avec un sort de NS.INTERRUPTS (Core.lua)
--     → cooldown + fenêtre de 0,6 s ;
--   · UNIT_SPELLCAST_INTERRUPTED d'un ennemi dans la fenêtre
--     → kick réussi ; sinon → « kick dans le vent ».
--
-- SYNCHRO (fiable pour les amis) : depuis Midnight, les casts des
-- autres joueurs sont masqués/« secrets » côté client. Chaque addon
-- annonce donc SES kicks au groupe via le canal AsylumSync ("K" puis
-- "KR" réussi/raté) — même principe que KickSync. Aucune dépendance.
-- ============================================================

local ADDON_NAME, NS = ...

local Kicks = {}
NS.Kicks = Kicks
Kicks.available = false

local OVERLAY_W    = 190
local ROW_H        = 22
local HEADER_H     = 24
local MAX_ROWS     = 8
local MISS_WINDOW  = 0.6
local PEER_TIMEOUT = 2.5 -- sans résultat reçu, on efface le « pending »
local HIT_DECIDE   = 0.8 -- fenêtre de décision hit/raté de MON kick (voir StartKick)

local WHITE = "Interface\\Buttons\\WHITE8x8"
local FONT  = "Fonts\\FRIZQT__.TTF"
local ICON_HIT  = "Interface\\RaidFrame\\ReadyCheck-Ready"
local ICON_MISS = "Interface\\RaidFrame\\ReadyCheck-NotReady"

-- indexer une table avec une clé « secrète » (Midnight) lève une
-- erreur → toujours passer par ce pcall
local function LookupInterrupt(spellID)
    local ok, cd = pcall(function() return NS.INTERRUPTS and NS.INTERRUPTS[spellID] end)
    if ok then return cd end
end

-- état : [guid] = { key, name, class, readyAt, lastResult, castAt, gen }
local tracked = {}
local byKey   = {} -- "Nom-Royaume" → guid
local order   = {}
local overlay, rows, ticker
local myGUID
local lastMyInterruptAt = 0 -- GetTime() de MA dernière interruption réussie (CLEU)

-- Rotation : un joueur y est inclus si override MANUEL (menu Kicks) ; sinon
-- AUTO = il possède une interruption (détecté localement et partagé via « IC »).
-- Un prêtre sans le talent / un druide heal diffuse « pas de kick » → exclu
-- automatiquement, réinclus s'il prend le talent. Sans info encore → inclus.
local function InRotation(st)
    if not st then return false end
    local m = NS.db and NS.db.kickManual and NS.db.kickManual[st.key]
    if m ~= nil then return m and true or false end
    return st.hasKick ~= false
end

-- ---------- Roster ----------
local function ClassColor(token)
    local c = token and RAID_CLASS_COLORS and RAID_CLASS_COLORS[token]
    if c then return c.r, c.g, c.b end
    return 1, 1, 1
end

local function AddUnit(unit, forceInclude, old)
    local guid = UnitGUID(unit)
    if not guid or tracked[guid] then return end
    local key = NS.UnitKey(unit)
    if not key then return end
    local name = key:match("^([^%-]+)") or key

    -- seuls les amis qui ont l'addon sont suivis
    if not forceInclude then
        if not (NS.db and NS.db.peers and NS.db.peers[key]) then return end
    end

    local _, classToken = UnitClass(unit)
    -- On RÉUTILISE l'état existant du joueur (kick en recharge, résultat,
    -- CD partagé, rotation) au lieu de repartir de zéro. Avant : chaque
    -- GROUP_ROSTER_UPDATE (join/leave, même en plein combat) remettait tous
    -- les kicks à « PRÊT » alors qu'ils étaient encore en cooldown, et la
    -- rotation était fausse jusqu'aux prochains messages IC.
    local st = old and old[guid]
    if st then
        st.key, st.name, st.class = key, name, classToken
    else
        st = { key = key, name = name, class = classToken,
            readyAt = 0, lastResult = nil, castAt = 0, gen = 0 }
    end
    st.unit = unit -- token d'unité (résolu à chaque refresh : mort/déco lisibles en direct)
    tracked[guid] = st
    byKey[key] = guid
    order[#order + 1] = guid
end

local RefreshOverlayRows -- déclarée plus bas

function Kicks.RefreshRoster()
    local old = {}
    for g, st in pairs(tracked) do old[g] = st end
    wipe(tracked)
    wipe(byKey)
    wipe(order)
    myGUID = UnitGUID("player")
    AddUnit("player", true, old)
    -- coéquipiers à suivre : EN RAID, seulement notre sous-groupe (NS.MyGroupUnits)
    for _, u in ipairs(NS.MyGroupUnits and NS.MyGroupUnits() or {}) do
        if #order >= MAX_ROWS then break end
        AddUnit(u, nil, old)
    end
    if RefreshOverlayRows then RefreshOverlayRows() end
    if Kicks.AnnounceInterruptCD then Kicks.AnnounceInterruptCD() end  -- CD pour la rotation
    if Kicks.UpdateVisibility then Kicks.UpdateVisibility() end
end

function Kicks.GetRosterNames()
    local names = {}
    for _, guid in ipairs(order) do
        local st = tracked[guid]
        local c = st.class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[st.class]
        names[#names + 1] = ("|c%s%s|r"):format((c and c.colorStr) or "ffffffff", st.name)
    end
    return (#names > 0) and table.concat(names, ", ") or NS.L("personne")
end

-- ---------- Encadré (overlay) ----------
local overlayBg, overlayBorders

local function ApplyPosition()
    if not overlay then return end
    overlay:ClearAllPoints()
    local pos = NS.db and NS.db.kickPos
    if pos and pos.p then
        overlay:SetPoint(pos.p, UIParent, pos.rp or pos.p, pos.x or 0, pos.y or 0)
    else
        overlay:SetPoint("CENTER", UIParent, "CENTER", 320, 140)
    end
end

-- opacité du fond + échelle, depuis les réglages sauvegardés
function Kicks.ApplyStyle()
    if not overlay then return end
    local a = (NS.db and NS.db.kickBgAlpha) or 0.6
    overlayBg:SetVertexColor(0, 0, 0, a)
    for _, t in ipairs(overlayBorders) do
        t:SetVertexColor(1, 1, 1, math.min(0.25, 0.05 + a * 0.1))
    end
    overlay:SetScale((NS.db and NS.db.kickScale) or 1)
end

function Kicks.SetBgAlpha(a)
    if NS.db then NS.db.kickBgAlpha = a end
    Kicks.ApplyStyle()
end

local function SavePosition()
    if not (overlay and NS.db) then return end
    local p, _, rp, x, y = overlay:GetPoint(1)
    NS.db.kickPos = { p = p, rp = rp, x = x, y = y }
end

local function BuildOverlay()
    if overlay then return end
    overlay = CreateFrame("Frame", "AsylumCompagnonKickFrame", UIParent)
    overlay:SetSize(OVERLAY_W, HEADER_H + ROW_H + 6)
    overlay:SetFrameStrata("MEDIUM")
    overlay:SetClampedToScreen(true)
    overlay:SetMovable(true)
    overlay:EnableMouse(true)
    overlay:Hide()

    -- chrome commun (fond, bordure 1 px, liseré accent, titre) : Bars.lua
    local chrome = NS.MakeOverlayChrome(overlay, "KICKS", {
        get = function() return Kicks.IsLocked() end,
        set = function(v) Kicks.SetLocked(v) end,
    })
    Kicks._syncLock = chrome.syncLock
    overlayBg, overlayBorders = chrome.bg, chrome.borders

    overlay:SetScript("OnMouseDown", function(self)
        if NS.db and NS.db.kickLocked then return end  -- verrou : pas de déplacement
        self:StartMoving()
    end)
    overlay:SetScript("OnMouseUp", function(self)
        self:StopMovingOrSizing()
        SavePosition()
    end)

    -- poignée de redimensionnement (échelle, sauvegardée)
    local grip = CreateFrame("Button", nil, overlay)
    grip:SetSize(12, 12)
    grip:SetPoint("BOTTOMRIGHT", -1, 1)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function(self)
        local pScale = UIParent:GetEffectiveScale()
        local sx = overlay:GetLeft() * overlay:GetEffectiveScale()
        local sy = overlay:GetTop() * overlay:GetEffectiveScale()
        self:SetScript("OnUpdate", function()
            local cx = GetCursorPosition()
            local newScale = (cx - sx) / (OVERLAY_W * pScale)
            if newScale < 0.6 then newScale = 0.6 end
            if newScale > 2.0 then newScale = 2.0 end
            overlay:SetScale(newScale)
            overlay:ClearAllPoints()
            overlay:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT",
                sx / (pScale * newScale), sy / (pScale * newScale))
        end)
    end)
    grip:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
        if NS.db then NS.db.kickScale = overlay:GetScale() end
        SavePosition()
    end)
    overlay.grip = grip
    grip:SetShown(not (NS.db and NS.db.kickLocked))  -- verrou de taille

    rows = {}
end

local ICON_HIT_T  = "|TInterface\\RaidFrame\\ReadyCheck-Ready:0|t "
local ICON_MISS_T = "|TInterface\\RaidFrame\\ReadyCheck-NotReady:0|t "

local function GetBar(i)
    if rows[i] then return rows[i] end
    local bar = NS.CreateBar(overlay, OVERLAY_W - 16, ROW_H - 3)
    rows[i] = bar
    return bar
end

-- ---------- Rotation de kick : badge « 1 » (vert) / « 2 » (orange) ----------
-- Ordre calculé LOCALEMENT (donc identique sur tous les écrans) à partir des CD
-- réels et des « prêt à » déjà connus : prochain = joueur PRÊT au CD le plus
-- court ; suivant = le 2e. Un joueur qui kick part en recharge → sort du
-- classement → le « 2 » passe « 1 » automatiquement. Badge uniquement sur des
-- kicks PRÊTS — sauf si TOUT le groupe est en recharge, alors on prend ceux qui
-- reviennent le plus vite. (v0.26 : petits badges numérotés à gauche des barres
-- au lieu des bordures pleines — plus discret et lisible en combat.)
local ROTA_GREEN  = { 0.10, 0.48, 0.24 }
local ROTA_ORANGE = { 0.58, 0.35, 0.07 }
local rowBadges = {}
local function GetRowBadge(i)
    if rowBadges[i] then return rowBadges[i] end
    local f = CreateFrame("Frame", nil, overlay)
    f:SetSize(14, 14)
    f:SetFrameLevel((overlay:GetFrameLevel() or 1) + 6)
    local disc = f:CreateTexture(nil, "ARTWORK")
    disc:SetTexture(WHITE)
    disc:SetAllPoints()
    local mask = f:CreateMaskTexture()
    mask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask")
    mask:SetAllPoints(disc)
    disc:AddMaskTexture(mask)
    f.disc = disc
    local fs = f:CreateFontString(nil, "OVERLAY")
    fs:SetFont(FONT, 9, "OUTLINE")
    fs:SetPoint("CENTER", 0, 0)
    f.fs = fs
    f:Hide()
    rowBadges[i] = f
    return f
end
local function SetRowBadge(i, bar, rank)
    local f = GetRowBadge(i)
    if not rank or not bar then f:Hide(); return end
    f:ClearAllPoints()
    f:SetPoint("RIGHT", bar, "LEFT", -4, 0)
    local c = (rank == 1) and ROTA_GREEN or ROTA_ORANGE
    f.disc:SetVertexColor(c[1], c[2], c[3], 1)
    f.fs:SetText(tostring(rank))
    f.fs:SetTextColor(1, 1, 1, 0.95)
    f:Show()
end
-- Le joueur peut-il kicker MAINTENANT ? Un joueur MORT (ou déconnecté) sort
-- de la rotation : s'il était « 2 », le badge glisse sur le suivant, et il
-- revient automatiquement une fois rez (le démoniste, lui, ne revient que
-- quand son pet est ressorti : mort → UNIT_PET → « pas de kick » → hasKick).
-- Tout est lu en direct (état d'unité, synchronisé par le serveur) → tous
-- les écrans voient la même rotation. Tout sous pcall : en cas de valeur
-- « secrète » Midnight ou d'API absente, on le laisse dedans (fail-open).
local function CanKickNow(st)
    local unit = st and st.unit
    if not unit then return true end
    local excluded = false
    pcall(function()
        if UnitIsDeadOrGhost and UnitIsDeadOrGhost(unit) then excluded = true end
        if UnitIsConnected and UnitIsConnected(unit) == false then excluded = true end
    end)
    return not excluded
end

local function ComputeRotation(now)
    local ready, waiting = {}, {}
    for _, guid in ipairs(order) do
        local st = tracked[guid]
        if st and InRotation(st) and CanKickNow(st) then
            local cd = st.cd or 15
            if (st.readyAt or 0) - now <= 0 then
                ready[#ready + 1] = { st = st, cd = cd }
            else
                waiting[#waiting + 1] = { st = st, cd = cd, ra = st.readyAt or 0 }
            end
        end
    end
    table.sort(ready, function(a, b)
        if a.cd ~= b.cd then return a.cd < b.cd end
        return (a.st.name or "") < (b.st.name or "")
    end)
    table.sort(waiting, function(a, b)
        if a.ra ~= b.ra then return a.ra < b.ra end
        if a.cd ~= b.cd then return a.cd < b.cd end
        return (a.st.name or "") < (b.st.name or "")
    end)
    local g = ready[1] and ready[1].st or (waiting[1] and waiting[1].st)
    local o
    if #ready >= 2 then o = ready[2].st
    elseif #ready == 1 then o = waiting[1] and waiting[1].st
    else o = waiting[2] and waiting[2].st end
    return g, o
end

-- Tri priorité (esprit Portal Authority) : PRÊT en haut, puis recharge
-- croissante ; un joueur qui vient de kick retombe en bas et remonte à
-- mesure que son interruption revient. Barres draining + couleur de classe.
local sortedKicks = {}
local function UpdateRows()
    if not overlay or not overlay:IsShown() then return end
    local now = GetTime()
    wipe(sortedKicks)
    -- COHÉRENCE : pas de barre pour un joueur SANS interruption (hasKick=false :
    -- démoniste sans le bon pet sorti, prêtre sans le talent, druide heal…) —
    -- une barre « PRÊT » mentirait. Réapparaît dès que le kick revient (pet
    -- ressorti → IC, ou kick réellement lancé). hasKick=nil (pas encore d'info,
    -- vieille version d'addon) → on affiche (bénéfice du doute). Le forçage
    -- manuel du menu Kicks (kickManual=true) force aussi l'affichage.
    local manual = NS.db and NS.db.kickManual
    for _, guid in ipairs(order) do
        local st = tracked[guid]
        if st and (st.hasKick ~= false or (manual and manual[st.key] == true)) then
            sortedKicks[#sortedKicks + 1] = st
        end
    end
    table.sort(sortedKicks, function(a, b)
        local ra = math.max(0, (a.readyAt or 0) - now)
        local rb = math.max(0, (b.readyAt or 0) - now)
        if ra ~= rb then return ra < rb end
        return (a.name or "") < (b.name or "")
    end)
    local n = #sortedKicks
    -- Aperçu (menu) sans coéquipier : une barre de démo à MON nom pour placer
    -- le cadre. Retirée dès qu'un vrai kick existe (wipe en tête de fonction).
    if Kicks._preview and n == 0 then
        local _, cls = UnitClass("player")
        sortedKicks[1] = { name = UnitName("player") or "Preview", class = cls,
            readyAt = now, cd = 15 }
        n = 1
    end
    local greenSt, orangeSt
    if n >= 2 and not (NS.db and NS.db.kickRotation == false) then
        greenSt, orangeSt = ComputeRotation(now)
    end
    -- gouttière à gauche pour les badges 1/2 quand la rotation est active
    local left = (greenSt or orangeSt) and 24 or 8
    for i = 1, math.max(n, #rows) do
        local bar = rows[i]
        if i <= n then
            bar = bar or GetBar(i)
            bar:ClearAllPoints()
            bar:SetPoint("TOPLEFT", left, -(HEADER_H + (i - 1) * ROW_H))
            bar:SetPoint("TOPRIGHT", -8, -(HEADER_H + (i - 1) * ROW_H))
            local st = sortedKicks[i]
            local remaining = (st.readyAt or 0) - now
            local total = st.cd or 15
            local icon = st.lastSpell and NS.SpellIcon(st.lastSpell) or nil
            local mark = ""
            if st.lastResult == "hit" then mark = ICON_HIT_T
            elseif st.lastResult == "miss" then mark = ICON_MISS_T end
            if remaining > 0 then
                bar:Set(icon, NS.ClassRGB(st.class), (st.name or "?"),
                    ("%d"):format(math.ceil(remaining)), remaining / total, { 0.95, 0.65, 0.2 }, mark)
            else
                bar:Set(icon, NS.ClassRGB(st.class), (st.name or "?"),
                    NS.L("PRÊT"), 1, { 0.15, 0.80, 0.40 }, mark)
            end
            bar:Show()
            if st == greenSt then SetRowBadge(i, bar, 1)
            elseif st == orangeSt then SetRowBadge(i, bar, 2)
            else SetRowBadge(i, bar, nil) end
        elseif bar then
            bar:Hide()
            if rowBadges[i] then rowBadges[i]:Hide() end
        end
    end
    overlay:SetHeight(HEADER_H + math.max(n, 1) * ROW_H + 6)
end
RefreshOverlayRows = UpdateRows

-- ---------- Application d'un kick à un état ----------
local function StartKick(st, cd, isMine, spellID)
    local now = GetTime()
    if (now - (st.castAt or 0)) < 0.1 then return end -- doublon
    -- un kick réellement lancé PROUVE que le joueur a une interruption :
    -- on le (ré)inclut même si l'auto-détection l'avait marqué « pas de kick »
    st.hasKick = true
    st.castAt = now
    st.readyAt = now + cd
    st.cd = cd
    if spellID then st.lastSpell = spellID end
    st.lastResult = "pending"
    st.gen = st.gen + 1
    local g = st.gen
    local castT = now
    if isMine then
        -- Décision hit/raté INDÉPENDANTE DE L'ORDRE des events. Le journal de
        -- combat (SPELL_INTERRUPT, source = moi) peut arriver AVANT ou APRÈS le
        -- UNIT_SPELLCAST_SUCCEEDED qui déclenche ce StartKick. Avant, si le CLEU
        -- arrivait en premier, CreditMyKickHit ne trouvait pas de « pending » →
        -- rien → puis le minuteur marquait « raté » : d'où des kicks réussis
        -- comptés ratés. Désormais on tranche après HIT_DECIDE : si une
        -- interruption réussie de MA part est proche du cast (avant OU après),
        -- c'est un HIT. (Si CreditMyKickHit a déjà tranché « hit », on sort.)
        C_Timer.After(HIT_DECIDE, function()
            if st.gen ~= g or st.lastResult ~= "pending" then return end
            local hit = math.abs((lastMyInterruptAt or 0) - castT) <= HIT_DECIDE
            st.lastResult = hit and "hit" or "miss"
            st.gen = st.gen + 1
            if NS.Stats then NS.Stats.RecordSelfKick(st.lastResult) end   -- stats M+
            if NS.Sync and NS.Sync.BroadcastKickResult then
                NS.Sync.BroadcastKickResult(st.lastResult)
            end
        end)
    else
        -- pair : si aucun résultat n'arrive, on efface le sablier
        C_Timer.After(PEER_TIMEOUT, function()
            if st.gen == g and st.lastResult == "pending" then
                st.lastResult = nil
            end
        end)
    end
end

-- ---------- Détection locale (événements d'unité, sans CLEU) ----------
-- pet → propriétaire : utilitaire partagé (Core.lua)
local OwnerUnit = NS.OwnerUnit

local function OnCastSucceeded(unit, spellID)
    local cd = LookupInterrupt(spellID)
    if not cd then return end

    local guid = UnitGUID(OwnerUnit(unit))
    local st = guid and tracked[guid]
    if not st then return end

    local isMine = (guid == myGUID)
    -- pour MON kick, on lit le vrai CD (talents/charges) et on le diffuse ;
    -- les amis reçoivent donc mon cooldown réel, pas la valeur de base
    if isMine and NS.RealCooldown then cd = NS.RealCooldown(spellID, cd) end
    StartKick(st, cd, isMine, spellID)
    if isMine and NS.Sync and NS.Sync.BroadcastKick then
        NS.Sync.BroadcastKick(cd, spellID)
    end
end

local function OnCastInterrupted(unit)
    -- un ennemi vient de se faire couper : crédite le(s) kick(s) locaux
    -- en attente (approximation si plusieurs kicks simultanés)
    local guid = UnitGUID(unit)
    if guid and tracked[guid] then return end -- c'est un des nôtres qui est coupé
    local now = GetTime()
    for _, g in ipairs(order) do
        local st = tracked[g]
        if st and st.lastResult == "pending" and (now - (st.castAt or 0)) <= MISS_WINDOW then
            st.lastResult = "hit"
            st.gen = st.gen + 1
            if g == myGUID then
                if NS.Stats then NS.Stats.RecordSelfKick("hit") end   -- stats M+
                if NS.Sync and NS.Sync.BroadcastKickResult then
                    NS.Sync.BroadcastKickResult("hit")
                end
            end
        end
    end
end

-- ---------- Synchro reçue des amis (via Sync.lua) ----------
function Kicks.OnPeerKick(senderKey, cd, spellID)
    local guid = byKey[senderKey]
    local st = guid and tracked[guid]
    if not st or not cd or cd <= 0 then return end
    StartKick(st, cd, false, spellID)
end

function Kicks.OnPeerKickResult(senderKey, res)
    local guid = byKey[senderKey]
    local st = guid and tracked[guid]
    if not st then return end
    if res == "hit" or res == "miss" then
        st.lastResult = res
        st.gen = st.gen + 1
    end
end

-- CD du kick d'un pair (rotation) : cd>0 → il a un kick ; cd=0 → pas de kick
function Kicks.OnPeerKickCD(senderKey, cd)
    local guid = byKey[senderKey]
    local st = guid and tracked[guid]
    if not st then return end
    if cd and cd > 0 then st.cd = cd; st.hasKick = true
    else st.hasKick = false end
    if NS.RefreshKickRotationList then NS.RefreshKickRotationList() end
end
-- Un sort d'interruption est-il connu ? On regarde le grimoire du JOUEUR
-- **ET** celui du FAMILIER (IsSpellKnown(sid, true)) : les kicks du démoniste
-- (Verrou de sort du chasseur corrompu, Lancer de hache du gangregarde…) sont
-- des sorts de PET — IsPlayerSpell répondait « non » et un démono se
-- retrouvait « pas de kick » alors que son gangregarde kick très bien.
local function KnowsInterrupt(sid)
    if IsPlayerSpell and IsPlayerSpell(sid) then return true end
    if IsSpellKnown then
        if IsSpellKnown(sid) then return true end
        if IsSpellKnown(sid, true) then return true end -- grimoire du familier
    end
    return false
end

-- mon interruption (le CD le plus court parmi celles que je connais)
local function FindMyInterrupt()
    if not NS.INTERRUPTS then return nil end
    local best, bestcd
    pcall(function()
        for sid in pairs(NS.INTERRUPTS) do
            if KnowsInterrupt(sid) then
                local base = LookupInterrupt(sid) or 15
                local cd = (NS.RealCooldown and NS.RealCooldown(sid, base)) or base
                if not bestcd or cd < bestcd then best, bestcd = sid, cd end
            end
        end
    end)
    return best, bestcd
end
-- diffuse TOUJOURS : cd réel si j'ai une interruption, sinon 0 (= pas de kick)
function Kicks.AnnounceInterruptCD()
    local sid, cd = FindMyInterrupt()
    cd = (sid and cd and cd > 0) and math.floor(cd) or 0
    if myGUID and tracked[myGUID] then
        if cd > 0 then tracked[myGUID].cd = cd end
        tracked[myGUID].hasKick = (cd > 0)
    end
    if NS.Sync and NS.Sync.BroadcastKickCD then NS.Sync.BroadcastKickCD(cd) end
    if NS.RefreshKickRotationList then NS.RefreshKickRotationList() end
end

-- liste pour le menu Kicks : joueurs (avec l'addon) + état de rotation
function Kicks.GetRotationList()
    local out = {}
    for _, guid in ipairs(order) do
        local st = tracked[guid]
        if st then
            out[#out + 1] = { key = st.key, name = st.name, class = st.class,
                hasKick = st.hasKick, inRotation = InRotation(st), me = (guid == myGUID) }
        end
    end
    return out
end
function Kicks.SetInRotation(key, on)
    if not key or not NS.db then return end
    NS.db.kickManual = NS.db.kickManual or {}
    NS.db.kickManual[key] = on and true or false
end

-- ---------- API ----------
function Kicks.SetEnabled(on)
    if NS.db then NS.db.kickOverlay = on and true or false end
    Kicks.UpdateVisibility()
end

-- L'overlay ne s'affiche QUE si : module activé (master) + en groupe avec un
-- VRAI joueur + contexte courant coché (Open World / Donjon / Raid). Le ticker
-- est coupé quand c'est masqué (zéro CPU hors des cas utiles).
function Kicks.UpdateVisibility()
    if not overlay then return end
    -- mode Aperçu (menu) : force l'affichage hors groupe pour placer le cadre
    local show = Kicks._preview or (Kicks.IsEnabled()
        and (not NS.HasRealGroup or NS.HasRealGroup())
        and (not NS.ModuleEnabledHere or NS.ModuleEnabledHere("kick")))
    overlay:SetShown(show and true or false)
    if show then
        if not ticker then ticker = C_Timer.NewTicker(0.2, UpdateRows) end
    elseif ticker then
        ticker:Cancel()
        ticker = nil
    end
end

function Kicks.IsEnabled()
    return not (NS.db and NS.db.kickOverlay == false)
end

-- Aperçu : affiche l'encadré même hors groupe (positionnement). Coupé au
-- changement d'onglet par l'UI (ShowPage).
function Kicks.SetPreview(on)
    Kicks._preview = on and true or false
    if RefreshOverlayRows then RefreshOverlayRows() end
    Kicks.UpdateVisibility()
end
function Kicks.IsPreview() return Kicks._preview and true or false end

-- verrou du cadre : masque la poignée (plus de redimensionnement) ET bloque le
-- déplacement (voir OnMouseDown) — pour ne rien bouger par erreur.
function Kicks.SetLocked(on)
    if NS.db then NS.db.kickLocked = on and true or false end
    if overlay and overlay.grip then
        overlay.grip:SetShown(not (NS.db and NS.db.kickLocked))
    end
    if Kicks._syncLock then Kicks._syncLock() end          -- cadenas de la barre de titre
    if NS._syncKickMenuLock then NS._syncKickMenuLock() end -- case « Verrouiller » du menu
end
function Kicks.IsLocked()
    return NS.db and NS.db.kickLocked and true or false
end

function Kicks.ResetPosition()
    if NS.db then
        NS.db.kickPos = nil
        NS.db.kickScale = nil
    end
    if overlay then overlay:SetScale(1) end
    if overlay and overlay.grip then overlay.grip:SetShown(not (NS.db and NS.db.kickLocked)) end
    ApplyPosition()
end

-- ---------- Interruption RÉUSSIE (kick réussi) : journal de combat ----------
-- BUG 0.27 : en Midnight, UNIT_SPELLCAST_INTERRUPTED ne se déclenche pas de
-- façon fiable sur les ennemis → aucun kick n'était crédité « réussi » et TOUS
-- finissaient « ratés » via le minuteur de StartKick. Le JOURNAL DE COMBAT, lui,
-- expose MES PROPRES interruptions réussies de façon fiable (SPELL_INTERRUPT
-- avec moi comme source — pas une valeur « secrète »). Chaque client détecte
-- donc SON hit et le diffuse (« KR hit ») ; les ratés restent gérés par le
-- minuteur. On garde aussi l'ancien repli UNIT_SPELLCAST_INTERRUPTED (inoffensif).
local function CreditMyKickHit()
    local st = myGUID and tracked[myGUID]
    if not st or st.lastResult ~= "pending" then return end
    st.lastResult = "hit"
    st.gen = st.gen + 1
    if NS.Stats then NS.Stats.RecordSelfKick("hit") end
    if NS.Sync and NS.Sync.BroadcastKickResult then NS.Sync.BroadcastKickResult("hit") end
end

local clog = CreateFrame("Frame")
local function InitCombatLog()
    if not (CombatLogGetCurrentEventInfo and clog.RegisterEvent) then return end
    if not pcall(clog.RegisterEvent, clog, "COMBAT_LOG_EVENT_UNFILTERED") then return end
    clog:SetScript("OnEvent", function()
        local _, sub, _, sourceGUID = CombatLogGetCurrentEventInfo()
        if sub == "SPELL_INTERRUPT" and sourceGUID and sourceGUID == myGUID then
            lastMyInterruptAt = GetTime()   -- sert aussi à la décision de StartKick
            pcall(CreditMyKickHit)
        end
    end)
end

-- ---------- Initialisation / événements ----------
-- Enregistrement différé (pile propre) et sous pcall : requis depuis
-- les restrictions Midnight. Le handler est lui aussi protégé.
local caster = CreateFrame("Frame")
local function InitCastEvents()
    local ok1 = pcall(caster.RegisterEvent, caster, "UNIT_SPELLCAST_SUCCEEDED")
    local ok2 = pcall(caster.RegisterEvent, caster, "UNIT_SPELLCAST_INTERRUPTED")
    Kicks.available = (ok1 and ok2) and true or false
    if Kicks.available then
        caster:SetScript("OnEvent", function(_, event, unit, _, spellID)
            if type(unit) ~= "string" then return end
            if event == "UNIT_SPELLCAST_SUCCEEDED" then
                pcall(OnCastSucceeded, unit, spellID)
            else
                pcall(OnCastInterrupted, unit)
            end
        end)
    end
end

local groupTimer, petTimer
local ev = CreateFrame("Frame")
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
ev:RegisterEvent("GROUP_ROSTER_UPDATE")
ev:RegisterEvent("ZONE_CHANGED_NEW_AREA")   -- changement de contexte (monde/donjon/raid)
ev:RegisterEvent("PLAYER_REGEN_DISABLED")   -- début de combat → CD à jour pour la rotation
ev:RegisterEvent("UNIT_PET")                -- pet invoqué/changé → le kick peut (dis)apparaître
ev:SetScript("OnEvent", function(_, event, arg1)
    if event == "ZONE_CHANGED_NEW_AREA" then
        if Kicks.UpdateVisibility then Kicks.UpdateVisibility() end
    elseif event == "PLAYER_REGEN_DISABLED" then
        if Kicks.AnnounceInterruptCD then Kicks.AnnounceInterruptCD() end
    elseif event == "UNIT_PET" then
        -- démoniste/chasseur : le kick vit dans le grimoire du familier →
        -- on ré-annonce quand MON pet change (invocation, sacrifice, swap)
        if arg1 == "player" and not petTimer then
            petTimer = C_Timer.NewTimer(2, function()
                petTimer = nil
                if Kicks.AnnounceInterruptCD then Kicks.AnnounceInterruptCD() end
            end)
        end
    elseif event == "PLAYER_ENTERING_WORLD" then
        if not overlay then
            C_Timer.After(3, function()
                BuildOverlay()
                ApplyPosition()
                Kicks.ApplyStyle()
                InitCastEvents()
                InitCombatLog()
                Kicks.RefreshRoster()
                Kicks.SetEnabled(Kicks.IsEnabled())
            end)
        else
            -- re-zone : le contexte (monde/donjon/raid) et le sous-groupe
            -- peuvent changer → on reconstruit + réévalue la visibilité
            Kicks.RefreshRoster()
        end
    elseif event == "GROUP_ROSTER_UPDATE" then
        if overlay and not groupTimer then
            groupTimer = C_Timer.NewTimer(2, function()
                groupTimer = nil
                Kicks.RefreshRoster()
            end)
        end
    end
end)
