-- ─────────────────────────────────────────────────────────────────────────────
-- Keystone Trust Addon
-- Démarre/arrête le combat log lors des runs M+ et sauvegarde les métadonnées.
-- Le parsing des stats est fait par le client KT desktop via WoWCombatLog.txt.
-- ─────────────────────────────────────────────────────────────────────────────

local ADDON_NAME    = "KeystoneTrust"
local ADDON_VERSION = "0.5.102"

-- ── Structures ────────────────────────────────────────────────────────────────
KeystoneTrustDB = KeystoneTrustDB or {}
KT_BENCHMARKS   = KT_BENCHMARKS or {}   -- legacy v1-v4 flat profile table (still read if present)
KT_RANK         = KT_RANK or {}   -- rank code (1-based) → display string, written in the data file
KT_REALM        = KT_REALM or {}  -- v5: realm slug → blob index in KT_DB
KT_REALM_ALIAS  = KT_REALM_ALIAS or {} -- v5: looseKey(localized realm name) → canonical slug (Cyrillic/CJK)
KT_DB           = KT_DB or {}     -- v5: array of per-realm concatenated profile blobs
KT_COUNT        = KT_COUNT or 0   -- v5: total profile count (for the load message)
KT_FMT          = KT_FMT or 5     -- payload format version (5 = 8-char blocks, 6 = +dispels, 9-char)

local KT = {
    run          = nil,
    inMythicPlus = false,
    runStartTime = nil,
    myKey        = nil,
    frame        = CreateFrame("Frame"),
}

-- ── Helpers ───────────────────────────────────────────────────────────────────

local function GetRunElapsed()
    if not KT.runStartTime then return 0 end
    return math.floor(GetServerTime() - KT.runStartTime)
end

local function GetPlayerRealm()
    local name, realm = UnitFullName("player")
    return name or UnitName("player"), realm or GetRealmName()
end

local function DetectRegion()
    local REGION_MAP = {[1]="us",[2]="kr",[3]="eu",[4]="tw",[5]="cn"}
    if GetCurrentRegion then
        local id = GetCurrentRegion()
        if id and REGION_MAP[id] then return REGION_MAP[id] end
    end
    if GetCurrentRegionName then
        local n = GetCurrentRegionName():lower()
        if n:find("americ") or n:find("^us") then return "us" end
        if n:find("^eu")                      then return "eu" end
        if n:find("korea")  or n:find("^kr")  then return "kr" end
        if n:find("taiwan") or n:find("^tw")  then return "tw" end
        if n:find("china")  or n:find("^cn")  then return "cn" end
    end
    return "eu"
end

local function GetSpecName()
    local idx = GetSpecialization()
    if not idx then return "Unknown" end
    return select(2, GetSpecializationInfo(idx)) or "Unknown"
end

local function GetSpecId()
    local idx = GetSpecialization()
    if not idx then return 0 end
    return (GetSpecializationInfo(idx)) or 0
end

local function GetRoleFromSpec()
    local idx = GetSpecialization()
    if not idx then return "DPS" end
    local role = select(5, GetSpecializationInfo(idx))
    if role == "TANK" then return "TANK"
    elseif role == "HEALER" then return "HEALER"
    else return "DPS" end
end

-- Anyone can be tagged any role ICON in a group (UnitGroupRolesAssigned), regardless of class.
-- Clamp it to what the class can actually play so e.g. a Death Knight tagged HEALER never shows
-- "KT HEAL". classFile is the WoW class token ("DEATHKNIGHT", "PALADIN", …).
local CAN_HEAL = { PRIEST = true, PALADIN = true, SHAMAN = true, MONK = true, DRUID = true, EVOKER = true }
local CAN_TANK = { WARRIOR = true, PALADIN = true, DEATHKNIGHT = true, DEMONHUNTER = true, MONK = true, DRUID = true }
local function ClampRole(role, classFile)
    if not classFile then return role end
    if role == "HEALER" and not CAN_HEAL[classFile] then return "DAMAGER" end
    if role == "TANK"   and not CAN_TANK[classFile] then return "DAMAGER" end
    return role
end

local function GetIlvl()
    local total, count = 0, 0
    for i = 1, 17 do
        if i ~= 4 then
            local link = GetInventoryItemLink("player", i)
            if link then
                local ilvl = select(4, GetItemInfo(link))
                if ilvl and ilvl > 0 then total = total + ilvl; count = count + 1 end
            end
        end
    end
    return count > 0 and math.floor(total / count) or 0
end

-- ── Joueur (métadonnées uniquement — stats parsées par le client KT) ──────────

local function NewPlayer(name, realm, guid)
    return {
        name   = name,
        realm  = realm or GetRealmName(),
        guid   = guid or "",
        class  = "UNKNOWN",
        spec   = "",
        specId = 0,
        role   = "",
        ilvl   = 0,
    }
end

local function GetOrCreatePartyMember(guid, name, realm)
    if not KT.run then return nil end
    if not KT.run.party[guid] then
        local p = NewPlayer(name, realm, guid)
        for _, unit in ipairs({"player","party1","party2","party3","party4"}) do
            if UnitExists(unit) and UnitGUID(unit) == guid then
                local _, cf = UnitClass(unit)
                if cf then p.class = cf end
                local wowRole = ClampRole(UnitGroupRolesAssigned(unit) or "", cf)
                if wowRole == "DAMAGER" then wowRole = "DPS" end
                p.role = wowRole
                break
            end
        end
        KT.run.party[guid] = p
    end
    return KT.run.party[guid]
end

-- ── Run lifecycle ─────────────────────────────────────────────────────────────

local function StartRun(dungeonName, keystoneLevel, affixes, dungeonId)
    KT.runStartTime = GetServerTime()
    KT.inMythicPlus = true

    local playerName, realm = GetPlayerRealm()
    local _, classFile = UnitClass("player")

    KT.run = {
        addonVersion   = ADDON_VERSION,
        playerName     = playerName,
        realm          = realm,
        region         = DetectRegion(),
        wowClass       = classFile or "UNKNOWN",
        spec           = GetSpecName(),
        specId         = GetSpecId(),
        role           = GetRoleFromSpec(),
        dungeonId      = dungeonId or 0,
        dungeonName    = dungeonName,
        keystoneLevel  = keystoneLevel,
        affixes        = affixes or {},
        completed      = false,
        inTime         = false,
        durationSecs   = 0,
        timeLimitSecs  = 0,
        runDate        = date("!%Y-%m-%dT%H:%M:%SZ"),
        party          = {},
        startedLogging = false,
    }

    if C_ChallengeMode.GetTimeLimit then
        KT.run.timeLimitSecs = C_ChallengeMode.GetTimeLimit() or 0
    end

    -- Joueur principal
    local mainGuid = UnitGUID("player")
    local main = NewPlayer(playerName, realm, mainGuid)
    main.class  = classFile or "UNKNOWN"
    main.spec   = GetSpecName()
    main.specId = GetSpecId()
    main.role   = GetRoleFromSpec()
    main.ilvl   = GetIlvl()
    KT.run.party[mainGuid] = main

    -- Membres déjà présents dans le groupe
    for i = 1, 4 do
        local unit = "party" .. i
        if UnitExists(unit) then
            local n, r = UnitFullName(unit)
            local g    = UnitGUID(unit)
            if g and n then GetOrCreatePartyMember(g, n, r) end
        end
    end

    -- Activer le combat log pour que le client KT puisse parser les stats
    if not LoggingCombat() then
        LoggingCombat(true)
        KT.run.startedLogging = true
    end

    print("|cff00ccff[KT]|r Run M+ démarré : |cffffcc00" .. dungeonName .. " +" .. keystoneLevel ..
          "|r — combat log " .. (KT.run.startedLogging and "activé" or "déjà actif"))
end

local function FinalizeRun(completed, inTime, durationSecs)
    if not KT.run then return end

    KT.run.completed    = completed
    KT.run.inTime       = inTime
    KT.run.durationSecs = durationSecs
    local startedLogging = KT.run.startedLogging

    -- Mettre à jour les infos du joueur principal
    local mainGuid = UnitGUID("player")
    if KT.run.party[mainGuid] then
        KT.run.party[mainGuid].spec   = GetSpecName()
        KT.run.party[mainGuid].specId = GetSpecId()
        KT.run.party[mainGuid].role   = GetRoleFromSpec()
        KT.run.party[mainGuid].ilvl   = GetIlvl()
    end

    -- Convertir le dict party en tableau
    local partyArray = {}
    for _, p in pairs(KT.run.party) do
        table.insert(partyArray, p)
    end
    KT.run.party = partyArray

    -- Sauvegarder dans SavedVariables (le client desktop lit WoWCombatLog.txt directement)
    local runId = KT.run.playerName .. "_" ..
                  KT.run.dungeonName:gsub("[^%w]","") .. "_" ..
                  GetServerTime()
    KeystoneTrustDB.runs        = KeystoneTrustDB.runs or {}
    KeystoneTrustDB.runs[runId] = KT.run

    local result = inTime and "|cff00ff00Dans les temps ✓|r" or "|cffff4444Hors temps|r"
    print("|cff00ccff[KT]|r Run terminé " .. result .. " | données sauvegardées pour upload.")

    KT.run          = nil
    KT.inMythicPlus = false
    KT.runStartTime = nil

    -- Désactiver le combat log avec délai (joueurs peuvent sortir pour réparer)
    if startedLogging then
        local delay = completed and 10 or 30
        C_Timer.After(delay, function() LoggingCombat(false) end)
    end
end

-- ── Benchmark & Tooltip ───────────────────────────────────────────────────────

local TOOLTIP_RANK_COLOR = {
    GLADIATEUR="FF8000", ELITE="FF4500", MASTER="C084FC",
    DIAMANT="818CF8",    EMERAUDE="00C0A0", PLATINE="22D3EE",
    OR="FACC15",         ARGENT="D1D5DB",   BRONZE="B45309", PEON="9CA3AF",
}
local TOOLTIP_RANK_LABEL = {
    GLADIATEUR="Gladiateur", ELITE="Élite",     MASTER="Master",
    DIAMANT="Diamant",       EMERAUDE="Émeraude", PLATINE="Platine",
    OR="Or",                 ARGENT="Argent",     BRONZE="Bronze", PEON="Péon",
}

-- Divisions I/II/III (ex. "MASTER III") : couleur du rang de base + suffixe sur le label.
-- Les rangs sans division (données anciennes, ELITE, GLADIATEUR) passent tels quels.
local function RankBaseKey(rank)
    return rank and rank:gsub("%s+I+$", "") or rank
end
local function RankColor(rank)
    if not rank then return "6B7280" end
    return TOOLTIP_RANK_COLOR[rank] or TOOLTIP_RANK_COLOR[RankBaseKey(rank)] or "9CA3AF"
end
local function RankLabel(rank)
    if not rank then return "?" end
    if TOOLTIP_RANK_LABEL[rank] then return TOOLTIP_RANK_LABEL[rank] end
    local base, div = rank:match("^(.-)%s+(I+)$")
    local baseLabel = base and TOOLTIP_RANK_LABEL[base]
    if baseLabel then return baseLabel .. " " .. div end
    return rank
end

local function PctToRank(pct)
    if not pct         then return "PEON" end
    if     pct >= 99.9 then return "GLADIATEUR"
    elseif pct >= 99.7 then return "ELITE"
    elseif pct >= 99   then return "MASTER"
    elseif pct >= 96.7 then return "DIAMANT"
    elseif pct >= 83   then return "EMERAUDE"
    elseif pct >= 64   then return "PLATINE"
    elseif pct >= 45   then return "OR"
    elseif pct >= 27   then return "ARGENT"
    elseif pct >= 8    then return "BRONZE"
    else                    return "PEON" end
end

local function FmtK(v)
    if not v then return "—" end
    if v >= 1000000 then return string.format("%.1fM", v/1000000)
    elseif v >= 1000 then return string.format("%dk", math.floor(v/1000))
    else return tostring(math.floor(v)) end
end

local function NormalizeRealm(realm)
    if not realm then return "" end
    local s = realm:lower()
    -- Strip NFC diacritics (UTF-8 precomposed: é=\xc3\xa9, à=\xc3\xa0 …)
    s = s:gsub("\xc3\xa9","e"):gsub("\xc3\xa8","e"):gsub("\xc3\xaa","e"):gsub("\xc3\xab","e")
    s = s:gsub("\xc3\xa0","a"):gsub("\xc3\xa2","a"):gsub("\xc3\xa4","a"):gsub("\xc3\xa1","a")
    s = s:gsub("\xc3\xb9","u"):gsub("\xc3\xbb","u"):gsub("\xc3\xbc","u"):gsub("\xc3\xba","u")
    s = s:gsub("\xc3\xae","i"):gsub("\xc3\xaf","i"):gsub("\xc3\xad","i")
    s = s:gsub("\xc3\xb4","o"):gsub("\xc3\xb6","o"):gsub("\xc3\xb3","o")
    s = s:gsub("\xc3\xa7","c"):gsub("\xc3\xb1","n")
    -- Strip NFD combining marks (\xcc\x80-\xcc\xbf and \xcd\x80-\xcd\xaf)
    s = s:gsub("\xcc[\x80-\xbf]",""):gsub("\xcd[\x80-\xaf]","")
    s = s:gsub("'",""):gsub("%s+","-")
    return s
end

-- v5: is a realm-bucketed blob DB loaded? (vs a legacy KT_BENCHMARKS file)
local function HasV5() return KT_DB and KT_DB[1] ~= nil end

-- Loose realm key: ASCII-only lowercase (Lua string.lower leaves Cyrillic/CJK untouched) +
-- strip spaces/hyphens/dots/apostrophes/parens. Non-ASCII bytes kept verbatim. MUST match
-- looseRealmKey() in src/lib/realm-aliases.ts so a localized in-game realm name resolves to
-- the same canonical slug the server bucketed the data under.
local function LooseRealm(s)
    if not s then return "" end
    -- ASCII-only lowercase: WoW's string.lower folds Cyrillic per client locale (Р→р), which would
    -- mismatch the server's ASCII-only looseRealmKey (keeps the capital Cyrillic). [A-Z] is a literal
    -- byte range so this is locale-independent; non-ASCII bytes (Cyrillic/CJK) kept verbatim.
    return (s:gsub("[A-Z]", string.lower):gsub("[ %-%.'()]", ""))
end

-- Look up a profile in the v5 realm blobs. Returns the raw base62 payload string or nil.
-- Takes the RAW realm (from UnitFullName): tries NormalizeRealm first, then the localized-name
-- alias map (KT_REALM_ALIAS) so RU/CJK realms whose in-game name can't be folded to the en_US
-- slug still resolve to the canonical bucket.
local function GetKTRecord(name, rawRealm)
    if not name or not KT_DB or not KT_REALM then return nil end
    local idx = KT_REALM[NormalizeRealm(rawRealm)]
    if not idx then
        local canon = KT_REALM_ALIAS[LooseRealm(rawRealm)]
        if canon then idx = KT_REALM[canon] end
    end
    if not idx then return nil end
    local blob = KT_DB[idx]
    if not blob then return nil end
    local needle = "\n" .. name:lower() .. "\t"
    local s = blob:find(needle, 1, true)   -- plain find (names have no magic chars)
    if not s then return nil end
    local ps = s + #needle
    local e  = blob:find("\n", ps, true)
    return e and blob:sub(ps, e - 1) or blob:sub(ps)
end

local function GetKTCache(nameRealm)
    if not KT_BENCHMARKS then return nil end
    return KT_BENCHMARKS[nameRealm]
end

local function GetKTCacheUnit(name, realm)
    if not name then return nil end
    if HasV5() then return GetKTRecord(name, realm) end
    -- Legacy: benchmark file uses lowercase names — try exact case first, then lowercase fallback.
    local realmNorm = NormalizeRealm(realm)
    return KT_BENCHMARKS[name .. "-" .. realmNorm]
        or KT_BENCHMARKS[name:lower() .. "-" .. realmNorm]
end

-- topPct is no longer stored in benchmark data — derived from rank at display time.
-- Must match RANK_BOUNDARIES (dungeon-benchmark-seeder): the real percentile cutoffs.
local KT_TOP_PCT = {
    GLADIATEUR=0.1, ELITE=1,
    -- Divisions I/II/III — mêmes cutoffs que RANK_TOP_PCT (dungeon-benchmark-rank.ts).
    -- Les clés de base restent pour les données antérieures aux divisions.
    ["MASTER I"]=5,     ["MASTER II"]=3.67,    ["MASTER III"]=2.33,    MASTER=5,
    ["DIAMANT I"]=12,   ["DIAMANT II"]=9.67,   ["DIAMANT III"]=7.33,   DIAMANT=12,
    ["EMERAUDE I"]=22,  ["EMERAUDE II"]=18.67, ["EMERAUDE III"]=15.33, EMERAUDE=22,
    ["PLATINE I"]=40,   ["PLATINE II"]=34,     ["PLATINE III"]=28,     PLATINE=40,
    ["OR I"]=60,        ["OR II"]=53.33,       ["OR III"]=46.67,       OR=60,
    ["ARGENT I"]=80,    ["ARGENT II"]=73.33,   ["ARGENT III"]=66.67,   ARGENT=80,
    ["BRONZE I"]=95,    ["BRONZE II"]=90,      ["BRONZE III"]=85,      BRONZE=95,
    ["PEON I"]=100,     ["PEON II"]=98.33,     ["PEON III"]=96.67,     PEON=100,
}

-- Unpack a dungeon entry for display. Three formats supported:
--   v3 flat  {dng_idx, level, rank, perf, kicks, deaths}          d[1]=number, d[5]=number (no inner table)
--   v2 compact {dng_idx, level, rank, topPct, perf, kicks, deaths} d[1]=number, d[7]=number (inner table)
--   v1 legacy  {name=.., level=.., rank=.., topPct=.., dps=.., hps=.., kicks=.., deaths=..}
-- Returns: name, level, rank, topPct, dps, hps, kicks, deaths, dispels, avoidPct, perf2, rank2.
-- Trailing fields are nil for formats that don't carry them (v5/v6).
local function UnpackDungeon(d, isHealer)
    if type(d[1]) == "number" then
        local name = (KT_DNG and KT_DNG[d[1]]) or "?"
        if d[7] ~= nil then
            -- v2: {idx, level, rank, topPct, perf, kicks, deaths}
            return name, d[2], d[3], d[4], d[5], d[5], d[6], d[7], nil, nil, nil, nil
        else
            -- v3/v5/v6/v7 flat: {idx,level,rank,perf,kicks,deaths [,dispels@8,avoid@9,perf2@10,rank2@11]}
            return name, d[2], d[3], d[3] and KT_TOP_PCT[d[3]], d[4], d[4], d[5], d[6], d[8], d[9], d[10], d[11]
        end
    end
    return d.name, d.level, d.rank, d.topPct, d.dps, d.hps, d.kicks, d.deaths, d.dispels, d.avoidPct, d.perf2, d.rank2
end

-- v3 flat stride: {role, idx1,lvl1,rank1,perf1,kicks1,deaths1, idx2,...}
local KT_FLAT_STRIDE = 6

-- v4 string format: "role,idx,lvl,rankcode,perf,kicks,deaths,idx,lvl,..." — decoded
-- lazily here (only for inspected players; the temp table is GC'd after the tooltip).
-- Produces v3-flat-shaped entries {idx, level, rank(string), perf, kicks, deaths} so
-- UnpackDungeon handles them unchanged.
local function ParseV4String(s)
    local nums, n = {}, 0
    for tok in s:gmatch("([^,]+)") do n = n + 1; nums[n] = tonumber(tok) end
    if n < 1 or not nums[1] then return nil, nil, false end
    local role     = nums[1]
    local isHealer = role == 2
    local roleKey  = role == 2 and "HEALER" or role == 3 and "TANK" or "DPS"
    local dungeons, dn = {}, 0
    for i = 2, n, KT_FLAT_STRIDE do
        if nums[i] then
            dn = dn + 1
            local rcode = nums[i + 2]
            local rank  = (rcode and rcode > 0 and KT_RANK[rcode]) or nil
            dungeons[dn] = { nums[i], nums[i + 1], rank, nums[i + 3], nums[i + 4], nums[i + 5] }
        end
    end
    return dn > 0 and dungeons or nil, roleKey, isHealer
end

-- ── v5: realm-bucketed base62 blobs ────────────────────────────────────────────
local KT_B62  = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
local KT_B62V = {}
for i = 1, #KT_B62 do KT_B62V[KT_B62:sub(i, i)] = i - 1 end
local function d1(c)    return KT_B62V[c] or 0 end
local function d2(a, b) return (KT_B62V[a] or 0) * 62 + (KT_B62V[b] or 0) end

-- Decode a v5/v6/v7 payload into flat dungeon entries (UnpackDungeon handles them).
-- KT_FMT (file-level) selects the layout — a newer reader still reads older files:
--   v5: header role(1); block 8  = idx,lvl,rank,perf×2,kicks×2,deaths
--   v6: header role(1); block 9  = …,dispels
--   v7: header role(1)+ilvl(2); block 14 = …,dispels,avoidTaken×2,perf2×2,rank2
-- Entry array uses [8+] for the extra fields ([7] stays nil so UnpackDungeon's flat-vs-v2
-- check holds). ilvl (per profile) is attached as dungeons.ilvl.
local function ParseV5Payload(p)
    if not p or #p < 1 then return nil, nil, false end
    local fmt      = KT_FMT or 5
    local role     = d1(p:sub(1, 1))
    local isHealer = role == 2
    local roleKey  = role == 2 and "HEALER" or role == 3 and "TANK" or "DPS"
    local v6       = fmt >= 6
    local v7       = fmt >= 7
    local stride   = v7 and 14 or (v6 and 9 or 8)
    local base     = v7 and 4 or 2  -- v7 header = role(1) + ilvl(2)
    local dungeons, dn = {}, 0
    if v7 then dungeons.ilvl = d2(p:sub(2, 2), p:sub(3, 3)) end
    local len = #p
    for i = base, len, stride do
        if i + stride - 1 <= len then
            local rcode = d1(p:sub(i + 2, i + 2))
            local rank  = (rcode > 0 and KT_RANK[rcode]) or nil
            dn = dn + 1
            local e = {
                d1(p:sub(i, i)),                       -- dungeon idx
                d1(p:sub(i + 1, i + 1)),               -- level
                rank,                                  -- primary rank
                d2(p:sub(i + 3, i + 3), p:sub(i + 4, i + 4)) * 1000, -- primary perf
                d2(p:sub(i + 5, i + 5), p:sub(i + 6, i + 6)),        -- kicks
                d1(p:sub(i + 7, i + 7)),               -- deaths
            }
            if v6 then e[8] = d1(p:sub(i + 8, i + 8)) end             -- dispels ([7] stays nil)
            if v7 then
                local av = d2(p:sub(i + 9, i + 9), p:sub(i + 10, i + 10)) * 1000 -- avoidable dmg taken (0 = no data)
                e[9]  = (av > 0) and av or nil
                e[10] = d2(p:sub(i + 11, i + 11), p:sub(i + 12, i + 12)) * 1000   -- secondary perf
                local r2 = d1(p:sub(i + 13, i + 13))
                e[11] = (r2 > 0 and KT_RANK[r2]) or nil                            -- secondary rank
            end
            dungeons[dn] = e
        end
    end
    return dn > 0 and dungeons or nil, roleKey, isHealer
end

-- Current assigned role of a group unit → KT role key, or nil if unknown (not grouped /
-- no role assigned). Used to pick the data matching the spec the player is ACTUALLY on.
local function UnitRoleKey(unit)
    if not unit or not UnitGroupRolesAssigned then return nil end
    local r = UnitGroupRolesAssigned(unit)
    if r == "TANK" then return "TANK"
    elseif r == "HEALER" then return "HEALER"
    elseif r == "DAMAGER" then return "DPS" end
    return nil
end

-- Single-role formats (v3/v4/v5) carry data for ONE role. If the unit is currently tagged
-- in a DIFFERENT role, we have no data for the spec they're playing → return {} so the
-- tooltip shows "No data for this spec" instead of their off-spec numbers. Only gates when
-- the current role is known (in a group); solo / LFG-list hovers show the stored role.
local function GateByRole(unit, dungeons, roleKey, isHealer)
    local cur = UnitRoleKey(unit)
    if cur and roleKey and cur ~= roleKey then return {}, cur, (cur == "HEALER") end
    return dungeons, roleKey, isHealer
end

local function GetDungeonsForUnit(data, unit)
    if not data then return nil, nil, false end

    -- Both v4 and v5 records are strings: v4 is comma-separated decimals, v5 is
    -- comma-free base62.
    if type(data) == "string" then
        if data:find(",", 1, true) then return GateByRole(unit, ParseV4String(data)) end
        return GateByRole(unit, ParseV5Payload(data))
    end

    if type(data[1]) == "number" then
        local role     = data[1]
        local isHealer = role == 2
        local roleKey  = role == 2 and "HEALER" or role == 3 and "TANK" or "DPS"

        if type(data[2]) == "number" then
            -- v3 flat format: unpack into temporary dungeon tables for display (GC'd after tooltip)
            local dungeons = {}
            local n = 0
            for i = 2, #data, KT_FLAT_STRIDE do
                n = n + 1
                dungeons[n] = {data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5]}
            end
            return GateByRole(unit, n > 0 and dungeons or nil, roleKey, isHealer)
        end

        -- v2 compact: {role, {entry_table}, ...}
        local dungeons = {}
        for i = 2, #data do dungeons[i - 1] = data[i] end
        return GateByRole(unit, #dungeons > 0 and dungeons or nil, roleKey, isHealer)
    end

    -- v1 legacy format (data.roles)
    if not data.roles then return nil, nil, false end
    local _, classFile = UnitClass(unit)
    local wowRole = ClampRole(UnitGroupRolesAssigned and UnitGroupRolesAssigned(unit) or "", classFile)
    local roleKey
    if wowRole == "TANK"        then roleKey = "TANK"
    elseif wowRole == "HEALER"  then roleKey = "HEALER"
    elseif wowRole == "DAMAGER" then roleKey = "DPS"
    end
    if roleKey then
        local dungeons = data.roles[roleKey]
        if dungeons and #dungeons > 0 then return dungeons, roleKey, (roleKey == "HEALER") end
        return {}, roleKey, (roleKey == "HEALER")
    end
    for _, rk in ipairs({"DPS","HEALER","TANK"}) do
        if data.roles[rk] and #data.roles[rk] > 0 then
            return data.roles[rk], rk, (rk == "HEALER")
        end
    end
    return nil, nil, false
end

-- "3 kicks" / "1 kick" / "0 deaths" — count + pluralised noun, or "—" when unknown.
local function CountLabel(n, word)
    if n == nil then return "—" end
    return tostring(n) .. " " .. word .. (n == 1 and "" or "s")
end

-- Per-field display toggles (the minimap options panel writes KeystoneTrustDB.show).
-- Level + dungeon name are always shown; everything else is opt-out here.
local KT_SHOW_DEFAULTS = {
    rank = true, topPct = true, perf = true, kicks = true, deaths = true,
    dispels = true, avoid = true, ilvl = true, perf2 = false, rank2 = false,
}
function KTShow(key)
    local s = KeystoneTrustDB and KeystoneTrustDB.show
    local v = s and s[key]
    if v == nil then return KT_SHOW_DEFAULTS[key] end
    return v
end

-- Tooltip layout: WoW tooltips aren't monospace, so each value is SELF-LABELLED ("285k dps ·
-- 4 kicks · 1 death") — no header to misalign. LEFT = level · dungeon (· rank · top%); RIGHT =
-- the self-labelled stats. Each field is shown only if enabled in the options panel.
-- Dedup guard. The LFG applicant / friend / search hooks each defer a render with
-- C_Timer.After(0); moving the mouse fast queues SEVERAL deferred renders onto the SAME
-- already-shown GameTooltip, stacking the KT block 2-3×. Claim the tooltip on first decoration;
-- the flag is cleared on OnTooltipCleared/OnHide (registered at load) so every fresh tooltip
-- content still gets exactly one block. Each render path (RenderKTDungeons + the "not analysed"
-- / "no data" text branches) calls this exactly once, so claims never collide.
local function KTClaimTooltip()
    if GameTooltip.__ktDone then return false end
    GameTooltip.__ktDone = true
    return true
end

local function RenderKTDungeons(dungeons, roleKey, isHealer, showTooltip)
    if not dungeons or #dungeons == 0 then return end
    if not KTClaimTooltip() then return end
    if showTooltip == nil then showTooltip = true end
    local roleLabel = roleKey == "HEALER" and "Heal" or roleKey == "TANK" and "Tank" or "DPS"
    local perfUnit  = isHealer and "hps" or "dps"
    local perf2Unit = isHealer and "dps" or "hps"
    local DIM, SEP = "|cff94a3b8", " |cff4b5563·|r "
    GameTooltip:AddLine(" ")
    -- Header: [Keystone Trust] — Role  (+ ilvl when enabled and present).
    local header = string.format("|cff00ccff[Keystone Trust]|r |cff94a3b8— %s|r", roleLabel)
    if KTShow("ilvl") and dungeons.ilvl and dungeons.ilvl > 0 then
        header = header .. string.format(" |cff94a3b8· %s ilvl|r", tostring(dungeons.ilvl))
    end
    GameTooltip:AddLine(header)
    for _, d in ipairs(dungeons) do
        local dname, dlevel, drank, dtopPct, ddps, dhps, dkicks, ddeaths, ddispels, davoid, dperf2, drank2 = UnpackDungeon(d, isHealer)
        -- LEFT: +lvl Dungeon (always) [· rank · top%]
        local left = string.format("|cffffcc00+%s|r |cffffffff%s|r", tostring(dlevel or "?"), dname or "?")
        if KTShow("rank") and drank then
            left = left .. string.format("  |cff%s%s|r", RankColor(drank), RankLabel(drank))
        end
        if KTShow("topPct") and dtopPct then
            left = left .. string.format(" |cff94a3b8%s%%|r", tostring(dtopPct))
        end
        -- RIGHT: enabled stats only, joined with separators (no ragged leading/trailing sep).
        local segs = {}
        if KTShow("perf")    then segs[#segs+1] = string.format("|cffffffff%s %s|r", isHealer and FmtK(dhps) or FmtK(ddps), perfUnit) end
        if KTShow("kicks")   then segs[#segs+1] = DIM .. CountLabel(dkicks, "kick") .. "|r" end
        if KTShow("deaths")  then segs[#segs+1] = DIM .. CountLabel(ddeaths, "death") .. "|r" end
        if KTShow("dispels") and ddispels ~= nil then segs[#segs+1] = DIM .. CountLabel(ddispels, "dispel") .. "|r" end
        if KTShow("avoid")   then segs[#segs+1] = DIM .. (davoid and FmtK(davoid) or "—") .. " avoid.|r" end
        if KTShow("perf2") and dperf2 and dperf2 > 0 then segs[#segs+1] = DIM .. FmtK(dperf2) .. " " .. perf2Unit .. "|r" end
        if KTShow("rank2") and drank2 then segs[#segs+1] = DIM .. "[" .. RankLabel(drank2) .. "]|r" end
        GameTooltip:AddDoubleLine(left, table.concat(segs, SEP))
    end
    if showTooltip then GameTooltip:Show() end
end

-- Unit-based tooltip (party frames, nameplates, raid frames…)
-- showTooltip=false when called from TooltipDataProcessor (before Show), true otherwise
local function OnTooltipSetUnit(tooltip, data, showTooltip)
    if showTooltip == nil then showTooltip = true end
    local unit = (data and data.unit) or select(2, tooltip:GetUnit())
    if not unit then return end
    -- UnitIsPlayer can error with "secret values" on world-cursor units (SetWorldCursor).
    local ok, isPlayer = pcall(UnitIsPlayer, unit)
    if not ok or not isPlayer then return end
    local name, realm = UnitFullName(unit)
    if not name then return end
    realm = realm or GetRealmName()
    local ktData = GetKTCacheUnit(name, realm)
    if not ktData then
        if KTClaimTooltip() then
            tooltip:AddLine(" ")
            tooltip:AddLine("|cff00ccff[KT]|r Not analyzed yet — wowkeystonetrust.com")
            if showTooltip then tooltip:Show() end
        end
        return
    end
    local dungeons, roleKey, isHealer = GetDungeonsForUnit(ktData, unit)
    if not dungeons then return end
    if #dungeons == 0 then
        if KTClaimTooltip() then
            local roleLabel = roleKey == "HEALER" and "Heal" or roleKey == "TANK" and "Tank" or "DPS"
            tooltip:AddLine(" ")
            tooltip:AddLine(string.format("|cff00ccff[Keystone Trust]|r |cff94a3b8— %s|r", roleLabel))
            tooltip:AddLine("|cff6b7280No data for this spec|r")
            if showTooltip then tooltip:Show() end
        end
        return
    end
    RenderKTDungeons(dungeons, roleKey, isHealer, showTooltip)
end

-- Name-based tooltip (friends list, LFG group finder…)
-- showNotFound: affiche un message si le joueur n'est pas dans KT_BENCHMARKS
local function TryKTTooltipForName(nameRealm, showNotFound)
    if not nameRealm or nameRealm == "" then return end
    -- Split "Name-Realm" and normalize the realm so it matches the stored slugs.
    local pname, prealm = nameRealm:match("^([^%-]+)%-(.+)$")
    local data
    if HasV5() then
        -- v5: look up directly in the realm blob (no realm in the string → current realm).
        if pname and prealm then
            data = GetKTRecord(pname, prealm)
        else
            data = GetKTRecord(nameRealm, GetRealmName())
        end
    else
        local lookupKey = (pname and prealm) and (pname .. "-" .. NormalizeRealm(prealm)) or nameRealm
        data = GetKTCache(lookupKey)
        if not data and pname and prealm then
            data = GetKTCache(pname:lower() .. "-" .. NormalizeRealm(prealm))
        end
        if not data and lookupKey ~= nameRealm then
            data = GetKTCache(nameRealm)  -- fallback: try original casing
        end
        -- If still no data and no realm provided, try current player's realm only.
        if not data and not nameRealm:find("%-") then
            local myRealm = NormalizeRealm(GetRealmName())
            data = GetKTCache(nameRealm .. "-" .. myRealm)
                  or GetKTCache(nameRealm:lower() .. "-" .. myRealm)
        end
    end
    if KT._debugtt then
        print("|cff00ccff[KT DEBUG]|r TryKT name='" .. tostring(nameRealm) ..
              "' found=" .. tostring(data ~= nil))
    end
    if not data then
        if showNotFound and KTClaimTooltip() then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("|cff00ccff[KT]|r Not analyzed yet — wowkeystonetrust.com")
            GameTooltip:Show()
        end
        return
    end
    -- Use GetDungeonsForUnit which handles v1/v2/v3 formats (fixes `not data.roles` for v3)
    local dungeons, roleKey, isHealer = GetDungeonsForUnit(data, nil)
    if not dungeons or #dungeons == 0 then return end
    RenderKTDungeons(dungeons, roleKey, isHealer)
end

-- ── Event frame ───────────────────────────────────────────────────────────────

KT.frame:RegisterEvent("ADDON_LOADED")
KT.frame:RegisterEvent("CHALLENGE_MODE_START")
KT.frame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
KT.frame:RegisterEvent("CHALLENGE_MODE_RESET")
KT.frame:RegisterEvent("GROUP_ROSTER_UPDATE")
KT.frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")

KT.frame:SetScript("OnEvent", function(_, event, ...)
    if event == "ADDON_LOADED" then
        local name = ...
        if name ~= ADDON_NAME then return end
        KeystoneTrustDB.runs = KeystoneTrustDB.runs or {}
        local myName, myRealm = GetPlayerRealm()
        KT.myKey = myName .. "-" .. myRealm
        -- Advanced Combat Logging MUST be on, or WoW omits the COMBATANT_INFO lines (the party
        -- roster + specIds). Without them the desktop client parses a run with ZERO players and
        -- the upload is rejected (empty party / 422). LoggingCombat() alone is NOT enough. Force
        -- it at load — before any key — so every run captured this session has a full roster.
        pcall(function() SetCVar("advancedCombatLogging", 1) end)
        local ncached = KT_COUNT or 0
        if ncached == 0 then for _ in pairs(KT_BENCHMARKS) do ncached = ncached + 1 end end
        print("|cff00ccff[Keystone Trust]|r v" .. ADDON_VERSION ..
              " chargé — " .. ncached .. " profil(s) KT en cache (/kt bench)")
        -- Unit-based (party frames, nameplates…)
        -- TooltipDataProcessor fires before GameTooltip:Show(), so no resize flicker.
        if TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall and Enum.TooltipDataType then
            TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, function(tooltip, data)
                if tooltip ~= GameTooltip then return end
                OnTooltipSetUnit(tooltip, data, false)
            end)
        else
            -- Fallback for environments where TooltipDataProcessor is unavailable
            hooksecurefunc(GameTooltip, "SetUnit", function(tt, unit)
                if not unit then return end
                C_Timer.After(0, function()
                    if not GameTooltip:IsShown() then return end
                    local _, cur = GameTooltip:GetUnit()
                    if cur == unit then OnTooltipSetUnit(GameTooltip, { unit = unit }, true) end
                end)
            end)
        end

        -- Scan GameTooltip text lines for a likely player name (applicants, etc.).
        -- Returns "Name" or "Name-Realm" or nil.
        local function scanTooltipName()
            for i = 1, 8 do
                local line = _G["GameTooltipTextLeft" .. i]
                if not line then break end
                local raw = line:GetText()
                if not raw or raw == "" then break end
                local cleaned = raw:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):match("^%s*(.-)%s*$")
                if not cleaned or cleaned == "" then break end
                if KT._debugtt then print("|cff00ccff[KT DEBUG]|r  scan L" .. i .. ": '" .. cleaned .. "'") end
                if not cleaned:find("%s") and #cleaned >= 2 and #cleaned <= 40 then
                    return cleaned
                end
                local nameRealm = cleaned:match("[^%s:,%(%)]+%-[^%s:,%(%)]+$")
                if nameRealm and #nameRealm >= 4 and #nameRealm <= 40 then
                    return nameRealm
                end
            end
        end

        -- Clear the KT dedup flag whenever the tooltip is reset or hidden, so each fresh tooltip
        -- content gets exactly ONE KT block (see KTClaimTooltip). Without this, fast mouse moves
        -- over LFG applicants queued several deferred renders onto the same tooltip → block ×2-3.
        GameTooltip:HookScript("OnTooltipCleared", function(tt) tt.__ktDone = nil end)
        GameTooltip:HookScript("OnHide", function(tt) tt.__ktDone = nil end)

        -- Friends list ONLY. The hovered friend is resolved in FriendsTooltip.button
        -- (KT._ftFriend). Render deferred so we land after Raider.IO overwrites GameTooltip.
        -- LFG is handled by its own hooks below (NOT here) — relying on OnShow for LFG is
        -- unreliable because moving between list rows doesn't always hide/re-show the tooltip.
        GameTooltip:HookScript("OnShow", function(tt)
            local _, unitPre = GameTooltip:GetUnit()
            if unitPre then return end -- unit tooltips handled by TooltipDataProcessor
            local owner = tt:GetOwner()
            local ownerName = owner and owner:GetName()
            if ownerName ~= "FriendsTooltip" then return end
            if KT._debugtt then print("|cff00ccff[KT DEBUG]|r OnShow FriendsTooltip ftFriend=" .. tostring(KT._ftFriend)) end
            -- Hover token (see KT._ttSeq below): a newer hover invalidates this deferred render so
            -- we never paint player A's data onto player B's tooltip after a fast move.
            KT._ttSeq = (KT._ttSeq or 0) + 1; local seq = KT._ttSeq
            C_Timer.After(0, function()
                if not GameTooltip:IsShown() or KT._ttSeq ~= seq then return end
                if KT._ftFriend then TryKTTooltipForName(KT._ftFriend, true) end
            end)
        end)

        -- ── LFG group finder ──────────────────────────────────────────────────
        -- Both hooks fire on EVERY hover (OnEnter → tooltip fn). hooksecurefunc runs
        -- after the tooltip is shown, so we defer one frame to land after Raider.IO and
        -- render directly (no dependency on GameTooltip:OnShow, which may not re-fire
        -- when moving between adjacent list rows). GameTooltip:Show() on an already-shown
        -- tooltip does not re-fire OnShow, so there is no render loop.

        -- Search results: hovering an entry → leader's KT stats.
        if type(LFGListUtil_SetSearchEntryTooltip) == "function" then
            hooksecurefunc("LFGListUtil_SetSearchEntryTooltip", function(tooltip, resultID)
                if tooltip ~= GameTooltip then return end
                local info = C_LFGList and C_LFGList.GetSearchResultInfo and C_LFGList.GetSearchResultInfo(resultID)
                local leader = info and info.leaderName
                if KT._debugtt then
                    print("|cff00ccff[KT DEBUG]|r LFG entry resultID=" .. tostring(resultID) .. " leaderName=" .. tostring(leader))
                    if info and not leader then
                        local keys = {}
                        for k in pairs(info) do keys[#keys + 1] = k end
                        print("|cff00ccff[KT DEBUG]|r   info keys: " .. table.concat(keys, ","))
                    end
                end
                if not leader then return end
                KT._ttSeq = (KT._ttSeq or 0) + 1; local seq = KT._ttSeq
                C_Timer.After(0, function()
                    if KT._ttSeq ~= seq then return end -- superseded by a newer hover
                    if GameTooltip:IsShown() then TryKTTooltipForName(leader, true) end
                end)
            end)
        end

        -- Applicants (recruitment): hovering an applicant member → their KT stats.
        -- Defensive: handle table/string return from the API, alternate button fields,
        -- and fall back to a tooltip text scan if the API path yields nothing.
        if type(LFGListApplicantMember_OnEnter) == "function" then
            hooksecurefunc("LFGListApplicantMember_OnEnter", function(self)
                local memberIdx   = self and self.memberIdx
                local parent      = self and self.GetParent and self:GetParent()
                local applicantID = (parent and parent.applicantID) or (self and self.applicantID)
                local name
                if applicantID and memberIdx and C_LFGList and C_LFGList.GetApplicantMemberInfo then
                    local res = C_LFGList.GetApplicantMemberInfo(applicantID, memberIdx)
                    if type(res) == "table" then name = res.name
                    elseif type(res) == "string" then name = res end
                end
                if KT._debugtt then
                    print("|cff00ccff[KT DEBUG]|r LFG applicant idx=" .. tostring(memberIdx) ..
                          " appID=" .. tostring(applicantID) .. " apiName=" .. tostring(name))
                end
                KT._ttSeq = (KT._ttSeq or 0) + 1; local seq = KT._ttSeq
                C_Timer.After(0, function()
                    if not GameTooltip:IsShown() or KT._ttSeq ~= seq then return end -- superseded by a newer hover
                    local n = name or scanTooltipName()
                    if KT._debugtt then print("|cff00ccff[KT DEBUG]|r LFG applicant finalName=" .. tostring(n)) end
                    if n then TryKTTooltipForName(n, true) end
                end)
            end)
        end

        -- Resolve which friend FriendsTooltip describes (via its trigger button) and store
        -- the Name-Realm. We DON'T render here — Raider.IO overwrites GameTooltip after us,
        -- so the actual render is deferred from GameTooltip:OnShow (owner==FriendsTooltip).
        local function resolveFriendFromTooltip()
            KT._ftFriend = nil
            local btn = FriendsTooltip and FriendsTooltip.button
            if KT._debugtt then
                print("|cff00ccff[KT DEBUG]|r FT.button=" .. tostring(btn) ..
                      " id=" .. tostring(btn and btn.id) ..
                      " type=" .. tostring(btn and btn.buttonType))
            end
            if not btn then return end
            local id, btype = btn.id, btn.buttonType
            if not id then return end

            -- WoW character friend
            if btype == (FRIENDS_BUTTON_TYPE_WOW or 2) then
                local info = C_FriendList and C_FriendList.GetFriendInfoByIndex and C_FriendList.GetFriendInfoByIndex(id)
                if info and info.name then
                    local realm = info.realm and NormalizeRealm(info.realm) or NormalizeRealm(GetRealmName())
                    KT._ftFriend = info.name .. "-" .. realm
                end
                return
            end

            -- BattleNet friend → may have a WoW character logged in
            local acc = C_BattleNet and C_BattleNet.GetFriendAccountInfo and C_BattleNet.GetFriendAccountInfo(id)
            local char = acc and acc.gameAccountInfo
            if char and char.characterName and char.clientProgram == "WoW" then
                local realm = char.realmName and NormalizeRealm(char.realmName) or NormalizeRealm(GetRealmName())
                KT._ftFriend = char.characterName .. "-" .. realm
            end
        end

        local function hookFriendsTooltip()
            if not FriendsTooltip or KT._ftHooked then return end
            KT._ftHooked = true
            FriendsTooltip:HookScript("OnShow", function()
                if KT._debugtt then print("|cff00ccff[KT DEBUG]|r FriendsTooltip:OnShow") end
                pcall(resolveFriendFromTooltip)
            end)
            FriendsTooltip:HookScript("OnHide", function() KT._ftFriend = nil end)
        end
        hookFriendsTooltip()
        C_Timer.After(5, hookFriendsTooltip)  -- retry after UI fully loads

    elseif event == "CHALLENGE_MODE_START" then
        -- Active le combat log automatiquement dès qu'une clé commence.
        -- Le joueur n'a plus besoin d'un addon tiers (WilduTools, etc.) pour ça.
        -- Re-force l'advanced logging au cas où il aurait été coupé en cours de session
        -- (sinon : pas de COMBATANT_INFO → run sans joueurs → upload rejeté).
        pcall(function() SetCVar("advancedCombatLogging", 1) end)
        if not LoggingCombat() then
            LoggingCombat(true)
            print("|cff7EC8E3[KeystoneTrust]|r Combat log activé automatiquement.")
        end
        C_Timer.After(0.5, function()
            -- Noms canoniques anglais par mapID — indépendant de la locale du client.
            -- Ajouter les autres mapID au fur et à mesure des runs confirmés.
            local KT_DUNGEON_CANONICAL = {
                [161] = "Skyreach",
                [239] = "Seat of the Triumvirate",
                [402] = "Algeth'ar Academy",
                [556] = "Pit of Saron",
                [557] = "Windrunner Spire",
                [558] = "Magisters' Terrace",
                [559] = "Nexus-Point Xenas",
                [560] = "Maisara Caverns",
            }
            local mapID = C_ChallengeMode.GetActiveChallengeMapID
                          and C_ChallengeMode.GetActiveChallengeMapID()
            local mapName = mapID and KT_DUNGEON_CANONICAL[mapID]
            if not mapName then
                mapName = GetRealZoneText() or "Unknown Dungeon"
                if mapID and C_ChallengeMode.GetMapUIInfo then
                    local n = C_ChallengeMode.GetMapUIInfo(mapID)
                    if n and n ~= "" then mapName = n end
                end
            end
            local keystoneLevel = 0
            if C_ChallengeMode.GetActiveKeystoneInfo then
                local lvl = C_ChallengeMode.GetActiveKeystoneInfo()
                keystoneLevel = lvl or 0
            end
            if keystoneLevel == 0 and C_ChallengeMode.GetActiveKeystoneLevel then
                keystoneLevel = C_ChallengeMode.GetActiveKeystoneLevel() or 0
            end
            local affixList = {}
            local affixIds = (C_ChallengeMode.GetActiveKeystoneAffixes
                             and C_ChallengeMode.GetActiveKeystoneAffixes()) or {}
            if #affixIds == 0 and C_ChallengeMode.GetActiveKeystoneInfo then
                local _, ids = C_ChallengeMode.GetActiveKeystoneInfo()
                affixIds = ids or {}
            end
            for _, id in ipairs(affixIds) do
                local affixName = C_ChallengeMode.GetAffixInfo
                                  and select(1, C_ChallengeMode.GetAffixInfo(id))
                table.insert(affixList, affixName or tostring(id))
            end
            StartRun(mapName, keystoneLevel, affixList, mapID)
        end)

    elseif event == "CHALLENGE_MODE_COMPLETED" then
        local _, _, timeMs, onTime = ...
        local elapsedSecs = math.floor((timeMs or GetRunElapsed() * 1000) / 1000)
        local inTime
        if KT.run and KT.run.timeLimitSecs and KT.run.timeLimitSecs > 0 then
            inTime = elapsedSecs <= KT.run.timeLimitSecs
        else
            inTime = (onTime == true or onTime == 1)
        end
        FinalizeRun(true, inTime, elapsedSecs)

    elseif event == "CHALLENGE_MODE_RESET" then
        if KT.inMythicPlus and KT.run then
            FinalizeRun(false, false, GetRunElapsed())
        end

    elseif event == "ZONE_CHANGED_NEW_AREA" then
        -- Activer le log dès l'entrée dans une instance M+ (avant CHALLENGE_MODE_START).
        -- C_ChallengeMode.GetActiveChallengeMapID retourne un mapID non-nil dès qu'on
        -- est dans une zone mythique+ valide, même avant que la clé ne soit lancée.
        local inMythicZone = C_ChallengeMode.GetActiveChallengeMapID
                             and C_ChallengeMode.GetActiveChallengeMapID() ~= nil
                             and C_ChallengeMode.GetActiveChallengeMapID() ~= 0
        if inMythicZone and not LoggingCombat() then
            LoggingCombat(true)
            print("|cff7EC8E3[KeystoneTrust]|r Combat log activé (entrée en instance M+).")
        end

    elseif event == "GROUP_ROSTER_UPDATE" then
        if not KT.inMythicPlus or not KT.run then return end
        for i = 1, 4 do
            local unit = "party" .. i
            if UnitExists(unit) then
                local n, r = UnitFullName(unit)
                local g    = UnitGUID(unit)
                if g and n and not KT.run.party[g] then
                    GetOrCreatePartyMember(g, n, r)
                end
            end
        end
    end
end)

-- ── Display options panel + minimap button ────────────────────────────────────
-- A small minimap bubble opens a panel where the player toggles which stats appear in the
-- KT tooltip. Level + dungeon name are always shown; everything else is opt-in/out.

local KT_optionsPanel

local function KT_ToggleOptions()
    if not KT_optionsPanel then return end
    if KT_optionsPanel:IsShown() then KT_optionsPanel:Hide() else KT_optionsPanel:Show() end
end

local function KT_BuildOptionsPanel()
    local f = CreateFrame("Frame", "KeystoneTrustOptions", UIParent, "BasicFrameTemplateWithInset")
    f:SetSize(250, 350)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetMovable(true); f:EnableMouse(true); f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:SetClampedToScreen(true)
    if f.TitleText then f.TitleText:SetText("Keystone Trust") end

    local sub = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    sub:SetPoint("TOP", 0, -28); sub:SetText("Tooltip stats to show")
    local note = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    note:SetPoint("BOTTOMLEFT", 16, 14); note:SetWidth(220); note:SetJustifyH("LEFT")
    note:SetText("Keystone level + dungeon name are always shown.")

    local fields = {
        { "rank",    "Rank" },
        { "topPct",  "Top %" },
        { "perf",    "DPS / HPS" },
        { "kicks",   "Interrupts" },
        { "deaths",  "Deaths" },
        { "dispels", "Dispels" },
        { "avoid",   "Avoidable damage" },
        { "ilvl",    "Item level" },
        { "perf2",   "Secondary perf (a healer's DPS…)" },
        { "rank2",   "Secondary rank" },
    }
    local y = -54
    for _, fld in ipairs(fields) do
        local key, lbl = fld[1], fld[2]
        local cb = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
        cb:SetPoint("TOPLEFT", 16, y)
        cb:SetSize(24, 24)
        cb:SetChecked(KTShow(key))
        local t = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        t:SetPoint("LEFT", cb, "RIGHT", 3, 0); t:SetText(lbl)
        cb:SetScript("OnClick", function(self)
            KeystoneTrustDB.show = KeystoneTrustDB.show or {}
            KeystoneTrustDB.show[key] = self:GetChecked() and true or false
        end)
        y = y - 27
    end
    f:Hide()
    return f
end

local function KT_BuildMinimapButton()
    if not Minimap then return end
    local b = CreateFrame("Button", "KeystoneTrustMinimapButton", Minimap)
    b:SetFrameStrata("MEDIUM"); b:SetFrameLevel(8); b:SetSize(31, 31)
    b:RegisterForClicks("LeftButtonUp")
    b:RegisterForDrag("LeftButton")

    -- The KT logo is already a self-contained round emblem (cropped to its content), so we show
    -- it large and centred WITHOUT a tracking ring / dark backdrop (those just muddied it).
    local icon = b:CreateTexture(nil, "ARTWORK")
    icon:SetSize(28, 28); icon:SetPoint("CENTER", b, "CENTER", 0, 0)
    icon:SetTexture("Interface\\AddOns\\KeystoneTrust\\resources\\icon.tga")

    -- Radius based on the actual minimap size + margin so the button sits on the OUTER edge
    -- (a fixed radius lands inside the larger retail minimap and covers the map).
    local function reposition()
        local a = math.rad(KeystoneTrustDB.minimapAngle or 215)
        local r = (Minimap:GetWidth() / 2) + 8
        b:SetPoint("CENTER", Minimap, "CENTER", r * math.cos(a), r * math.sin(a))
    end
    reposition()

    b:SetScript("OnDragStart", function()
        b:SetScript("OnUpdate", function()
            local mx, my = Minimap:GetCenter()
            local scale  = Minimap:GetEffectiveScale()
            local px, py = GetCursorPosition()
            KeystoneTrustDB.minimapAngle = math.deg(math.atan2(py / scale - my, px / scale - mx))
            reposition()
        end)
    end)
    b:SetScript("OnDragStop", function() b:SetScript("OnUpdate", nil) end)
    b:SetScript("OnClick", KT_ToggleOptions)
    b:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("|cff00ccffKeystone Trust|r")
        GameTooltip:AddLine("Click: display options", 0.8, 0.8, 0.8)
        GameTooltip:AddLine("Drag: move button", 0.6, 0.6, 0.6)
        GameTooltip:Show()
    end)
    b:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

local KT_uiInit = CreateFrame("Frame")
KT_uiInit:RegisterEvent("PLAYER_LOGIN")
KT_uiInit:SetScript("OnEvent", function()
    KeystoneTrustDB = KeystoneTrustDB or {}
    if not KT_optionsPanel then KT_optionsPanel = KT_BuildOptionsPanel() end
    if not KeystoneTrustMinimapButton then KT_BuildMinimapButton() end
end)

-- ── Slash commands ────────────────────────────────────────────────────────────

SLASH_KT1 = "/kt"
SlashCmdList["KT"] = function(msg)
    local cmd = strtrim(msg):lower()

    if cmd == "options" or cmd == "config" or cmd == "opt" then
        KT_ToggleOptions()

    elseif cmd == "status" then
        if KT.inMythicPlus and KT.run then
            print("|cff00ccff[KT]|r " .. KT.run.dungeonName .. " +" ..
                  KT.run.keystoneLevel .. " | " .. GetRunElapsed() .. "s" ..
                  " | logging=" .. tostring(LoggingCombat()))
        else
            print("|cff00ccff[KT]|r Aucun run M+ en cours.")
        end
        local nruns = 0
        for _ in pairs(KeystoneTrustDB.runs or {}) do nruns = nruns + 1 end
        print("|cff00ccff[KT]|r " .. nruns .. " run(s) enregistrés localement.")

    elseif cmd == "clear" then
        KeystoneTrustDB.runs = {}
        print("|cff00ccff[KT]|r Logs de runs effacés.")

    elseif cmd == "clearcache" then
        KT_BENCHMARKS = {}; KT_DB = {}; KT_REALM = {}; KT_REALM_ALIAS = {}; KT_COUNT = 0
        print("|cff00ccff[KT]|r Cache KT effacé — relancez une sync depuis le client KT.")

    elseif cmd == "debugtt" then
        KT._debugtt = not KT._debugtt
        print("|cff00ccff[KT]|r Debug tooltip " .. (KT._debugtt and "|cff00ff00ON|r — survole un tooltip pour voir les lignes lues" or "|cffff4444OFF|r"))

    elseif cmd == "lfgcheck" then
        local function yn(v) return v and "|cff00ff00OK|r" or "|cffff4444absent|r" end
        print("|cff00ccff[KT]|r ── LFG hooks diagnostic ──")
        print("|cff00ccff[KT]|r LFGListUtil_SetSearchEntryTooltip: " .. yn(type(LFGListUtil_SetSearchEntryTooltip) == "function"))
        print("|cff00ccff[KT]|r LFGListApplicantMember_OnEnter: " .. yn(type(LFGListApplicantMember_OnEnter) == "function"))
        print("|cff00ccff[KT]|r C_LFGList.GetSearchResultInfo: " .. yn(C_LFGList and type(C_LFGList.GetSearchResultInfo) == "function"))
        print("|cff00ccff[KT]|r C_LFGList.GetApplicantMemberInfo: " .. yn(C_LFGList and type(C_LFGList.GetApplicantMemberInfo) == "function"))

    elseif cmd == "bench" then
        local myName, myRealm = GetPlayerRealm()
        local db = HasV5() and GetKTRecord(myName, myRealm)
                   or GetKTCache(KT.myKey or "")
        local dungeons, _, isH = db and GetDungeonsForUnit(db, "player")
        if not db or not dungeons or #dungeons == 0 then
            print("|cff00ccff[KT]|r Aucun benchmark disponible.")
            print("|cff00ccff[KT]|r Lance une analyse depuis le client KT, puis /reload.")
            return
        end
        local char = (type(db) == "table" and db.character) or { name = myName, realm = myRealm }
        print("|cff00ccff[KT]|r ── Benchmarks" ..
              (char.name and (" — " .. char.name .. "-" .. (char.realm or "")) or "") .. " ──")
        for _, d in ipairs(dungeons) do
            local dname, dlevel, drank, dtopPct, ddps, dhps, dkicks, ddeaths = UnpackDungeon(d, isH)
            local rankStr  = drank and ("|cff" .. RankColor(drank) ..
                             "[" .. RankLabel(drank) .. "]|r ") or ""
            local pctStr   = dtopPct and ("top " .. tostring(dtopPct) .. "%") or "—"
            local perfStr  = isH and ("HPS " .. FmtK(dhps)) or ("DPS " .. FmtK(ddps))
            local kickStr  = dkicks  ~= nil and (tostring(dkicks)  .. " kick") or "— kick"
            local deathStr = ddeaths ~= nil and (tostring(ddeaths) .. " mort") or "— mort"
            local lvlStr   = dlevel and ("+" .. tostring(dlevel)) or "—"
            print("|cff00ccff[KT]|r " .. (dname or "?") .. "  " .. lvlStr ..
                  "  " .. rankStr .. pctStr .. "  " .. perfStr ..
                  "  " .. kickStr .. "  " .. deathStr)
        end

    elseif cmd == "maps" then
        local maps = C_ChallengeMode.GetMapTable and C_ChallengeMode.GetMapTable() or {}
        if #maps == 0 then
            print("|cff00ccff[KT]|r Aucun donjon de saison trouvé (hors d'un donjon M+ ?)")
        else
            print("|cff00ccff[KT]|r Donjons M+ saison en cours :")
            for _, mapID in ipairs(maps) do
                local name = C_ChallengeMode.GetMapUIInfo and C_ChallengeMode.GetMapUIInfo(mapID) or "?"
                print("|cff00ccff[KT]|r   mapID=|cffffcc00" .. mapID .. "|r → " .. tostring(name))
            end
        end

    else
        print("|cff00ccff[KT]|r /kt status | /kt bench | /kt clear | /kt clearcache | /kt maps | /kt debugtt | /kt lfgcheck")
    end
end
