-- ─────────────────────────────────────────────────────────────────────────────
-- 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.75"

-- ── Structures ────────────────────────────────────────────────────────────────
KeystoneTrustDB = KeystoneTrustDB or {}
KT_BENCHMARKS   = KT_BENCHMARKS or {}
KT_RANK         = KT_RANK or {}   -- v4: rank code (1-based) → display string, written in the data file

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",
}

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

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
    local realmNorm = NormalizeRealm(realm)
    -- Benchmark file uses lowercase names — try exact case first, then lowercase fallback.
    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.
local KT_TOP_PCT = {
    GLADIATEUR=0.0015, ELITE=0.005, MASTER=0.03, DIAMANT=0.3, EMERAUDE=3.3,
    PLATINE=17, OR=36, ARGENT=55, BRONZE=73, 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=..}
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]
        else
            -- v3 flat: {idx, level, rank, perf, kicks, deaths} — topPct derived from rank
            return name, d[2], d[3], d[3] and KT_TOP_PCT[d[3]], d[4], d[4], d[5], d[6]
        end
    end
    return d.name, d.level, d.rank, d.topPct, d.dps, d.hps, d.kicks, d.deaths
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

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

    if type(data) == "string" then return ParseV4String(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 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 #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

local function RenderKTDungeons(dungeons, roleKey, isHealer, showTooltip)
    if not dungeons or #dungeons == 0 then return end
    if showTooltip == nil then showTooltip = true end
    local roleLabel = roleKey == "HEALER" and "Heal" or roleKey == "TANK" and "Tank" or "DPS"
    GameTooltip:AddLine(" ")
    GameTooltip:AddLine(string.format("|cff00ccff[Keystone Trust]|r |cff94a3b8— %s|r", roleLabel))
    local perfHeader = isHealer and "HPS" or "DPS"
    GameTooltip:AddDoubleLine(
        "|cff6b7280Dungeon|r",
        string.format("|cff6b7280Rank         top    %s    kick   death|r", perfHeader),
        1, 1, 1, 1, 1, 1)
    for _, d in ipairs(dungeons) do
        local dname, dlevel, drank, dtopPct, ddps, dhps, dkicks, ddeaths = UnpackDungeon(d, isHealer)
        local perf   = isHealer and (FmtK(dhps) .. " hps") or (FmtK(ddps) .. " dps")
        local kicks  = dkicks  ~= nil and tostring(dkicks)  or "—"
        local deaths = ddeaths ~= nil and tostring(ddeaths) or "—"
        local left   = string.format("|cffffcc00+%s|r %s", tostring(dlevel or "?"), dname or "?")
        local rank   = drank
        local color  = rank and (TOOLTIP_RANK_COLOR[rank] or "9CA3AF") or "6B7280"
        local label  = rank and (TOOLTIP_RANK_LABEL[rank] or rank)     or "?"
        local pct    = dtopPct and ("top " .. tostring(dtopPct) .. "%") or "?%"
        local right  = string.format("|cff%s[%s]|r %s  %s  %sk %sm",
                           color, label, pct, perf, kicks, deaths)
        GameTooltip:AddDoubleLine(left, right, 0.8,0.8,0.8, 1,1,1)
    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
        tooltip:AddLine(" ")
        tooltip:AddLine("|cff00ccff[KT]|r Non analysé sur wowkeystonetrust.com")
        if showTooltip then tooltip:Show() end
        return
    end
    local dungeons, roleKey, isHealer = GetDungeonsForUnit(ktData, unit)
    if not dungeons then return end
    if #dungeons == 0 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
        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 the key matches KT_BENCHMARKS slugs
    local pname, prealm = nameRealm:match("^([^%-]+)%-(.+)$")
    local lookupKey = (pname and prealm) and (pname .. "-" .. NormalizeRealm(prealm)) or nameRealm
    local 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.
    -- The old O(n) pairs(KT_BENCHMARKS) scan is removed — too expensive at 50k entries.
    if not data and not nameRealm:find("%-") then
        local myRealm = NormalizeRealm(GetRealmName())
        data = GetKTCache(nameRealm .. "-" .. myRealm)
              or GetKTCache(nameRealm:lower() .. "-" .. myRealm)
    end
    if KT._debugtt then
        print("|cff00ccff[KT DEBUG]|r TryKT name='" .. tostring(nameRealm) ..
              "' key='" .. tostring(lookupKey) .. "' found=" .. tostring(data ~= nil))
    end
    if not data then
        if showNotFound then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("|cff00ccff[KT]|r Non analysé sur 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
        local ncached = 0
        for _ in pairs(KT_BENCHMARKS) do ncached = ncached + 1 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

        -- 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
            C_Timer.After(0, function()
                if not GameTooltip:IsShown() then return end
                if KT._ftFriend then TryKTTooltipForName(KT._ftFriend, false) 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
                C_Timer.After(0, function()
                    if GameTooltip:IsShown() then TryKTTooltipForName(leader, false) 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
                C_Timer.After(0, function()
                    if not GameTooltip:IsShown() then return end
                    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, false) 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.
        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)

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

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

    if 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 = {}
        print("|cff00ccff[KT]|r Cache KT_BENCHMARKS 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 db = 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 = db.character or {}
        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" .. (TOOLTIP_RANK_COLOR[drank] or "9CA3AF") ..
                             "[" .. (TOOLTIP_RANK_LABEL[drank] or 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
